From e5e31a7bdc113fec917f1debea03a5eb134df8ab Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 15 Aug 2026 23:22:19 +0200 Subject: [PATCH] feat(mcp): implement upstream origin resolution for internal API calls - Added a new `upstreamOrigin` function to resolve the correct upstream API origin for internal MCP to REST calls, addressing issues with container deployments where `req.nextUrl.origin` resolves incorrectly. - The function prioritizes environment variables for configuration, ensuring proper routing in various deployment scenarios. - Updated multiple API fetch calls to utilize the new `upstreamOrigin` function, improving reliability and consistency in internal API interactions. --- src/app/api/mcp/route.ts | 132 ++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 45 deletions(-) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index be83eb7..bdd1359 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -95,6 +95,42 @@ function withCors(response: NextResponse, req: NextRequest): NextResponse { * JSON-RPC `result` is still populated so older clients that only * look at the envelope keep working. */ +/** + * Resolve the upstream `/api/v1` origin for internal MCP → REST + * loopback calls. + * + * Why this exists: in container deployments (Coolify, Docker, k8s) + * `req.nextUrl.origin` resolves to the bind socket — typically + * `https://0.0.0.0:3000` because the Dockerfile sets + * `HOSTNAME="0.0.0.0"`. That URL doesn't route outbound and the + * plain-HTTP container answers with an SSL handshake mismatch + * (`ERR_SSL_WRONG_VERSION_NUMBER`), surfacing as `-32010 / fetch + * failed` on every internal fetch. + * + * Resolution order: + * 1. `AGENT_API_BASE_URL` env var — explicit override; set this in + * the deploy env to the public origin (e.g. + * `https://wfdash.apphub.co.za`) or, for in-container loopback, + * `http://127.0.0.1:3000`. + * 2. `NEXT_PUBLIC_BASE_URL` env var — secondary override; mostly + * for symmetry with other parts of the app. + * 3. `req.url` parsed — works correctly for non-container deploys + * and dev (`localhost:4001`). + * + * Note: the URL is constructed via `new URL()` not string concat, so + * an env var without a scheme is rejected cleanly (Node throws) rather + * than silently becoming a malformed upstream. + */ +function upstreamOrigin(req: NextRequest): string { + const fromEnv = + process.env.AGENT_API_BASE_URL || process.env.NEXT_PUBLIC_BASE_URL; + if (fromEnv) { + // Validate; throws on malformed. + return new URL(fromEnv).origin; + } + return new URL(req.url).origin; +} + function jsonRpcOk(id: unknown, result: unknown, status?: number) { const body = { jsonrpc: "2.0", @@ -3358,7 +3394,7 @@ export async function POST(req: NextRequest) { if (typeof args.kind === "string") fd.append("kind", args.kind); const res = await fetch( - `${req.nextUrl.origin}/api/v1/orders/${args.order_id}/attachments/upload`, + `${upstreamOrigin(req)}/api/v1/orders/${args.order_id}/attachments/upload`, { method: "POST", headers: { @@ -3379,7 +3415,7 @@ export async function POST(req: NextRequest) { throw new Error("Missing order_id for orders.list_attachments."); } const data = await fetchUpstreamJson( - `${req.nextUrl.origin}/api/v1/orders/${args.order_id}/matched-attachments`, + `${upstreamOrigin(req)}/api/v1/orders/${args.order_id}/matched-attachments`, { method: "GET" }, "orders.list_attachments", ); @@ -3414,7 +3450,7 @@ export async function POST(req: NextRequest) { // step (idempotency on message_id, sentinel-customer fallback, // ai_status="pending" stamp). const data = await fetchUpstreamJson( - `${req.nextUrl.origin}/api/v1/quotes/from-email`, + `${upstreamOrigin(req)}/api/v1/quotes/from-email`, { method: "POST", headers: { "content-type": "application/json" }, @@ -3443,7 +3479,7 @@ export async function POST(req: NextRequest) { // Auth-required by the upstream route. Forward the agent key. const limit = typeof args.limit === "number" ? args.limit : 5; const res = await fetch( - `${req.nextUrl.origin}/api/v1/quotes/process-pending?limit=${encodeURIComponent(String(limit))}`, + `${upstreamOrigin(req)}/api/v1/quotes/process-pending?limit=${encodeURIComponent(String(limit))}`, { method: "POST", headers: { @@ -3475,7 +3511,7 @@ export async function POST(req: NextRequest) { } const hintEntry = TOOL_HINTS["quotes.create"]; const data = await fetchUpstreamJson( - `${req.nextUrl.origin}/api/v1/quotes/create`, + `${upstreamOrigin(req)}/api/v1/quotes/create`, { method: "POST", headers: { @@ -3504,7 +3540,7 @@ export async function POST(req: NextRequest) { if (!args.id) throw new Error("Missing id for quotes.re_run_ai."); const wipe = args.wipe_items === false ? "false" : "true"; const res = await fetch( - `${req.nextUrl.origin}/api/v1/quotes/${encodeURIComponent(String(args.id))}/re-run-ai?wipe_items=${wipe}`, + `${upstreamOrigin(req)}/api/v1/quotes/${encodeURIComponent(String(args.id))}/re-run-ai?wipe_items=${wipe}`, { method: "POST", headers: { @@ -3575,12 +3611,18 @@ export async function POST(req: NextRequest) { } if (toolName === "quotes.list_attachments") { - if (!args.quote_id) throw new Error("Missing quote_id for quotes.list_attachments."); - const quoteId = String(args.quote_id); + // Schema/route alignment: tool catalog advertises the param as + // `id` (matching the REST `[id]` path), but this handler used to + // require `quote_id`. Accept either so callers using either + // name don't 500 before the fetch even runs. Hermes (and the + // auto-gen docs) use `id`. + const quoteId = args.id ?? args.quote_id; + if (!quoteId) throw new Error("Missing id (or quote_id) for quotes.list_attachments."); + const quoteIdStr = String(quoteId); const page = Number(args.page || 1); const perPage = Math.min(500, Number(args.perPage || 50)); const data = await fetchUpstreamJson( - `${req.nextUrl.origin}/api/v1/quotes/${quoteId}/attachments?page=${page}&perPage=${perPage}`, + `${upstreamOrigin(req)}/api/v1/quotes/${quoteIdStr}/attachments?page=${page}&perPage=${perPage}`, { method: "GET" }, "quotes.list_attachments", ); @@ -3596,7 +3638,7 @@ export async function POST(req: NextRequest) { const qs = params.toString(); const hintEntry = TOOL_HINTS["quotes.get_pdf"]; const data = await fetchUpstreamJson( - `${req.nextUrl.origin}/api/v1/quotes/${quoteId}/pdf/base64${qs ? `?${qs}` : ""}`, + `${upstreamOrigin(req)}/api/v1/quotes/${quoteId}/pdf/base64${qs ? `?${qs}` : ""}`, { method: "GET" }, "quotes.get_pdf", { hint: hintEntry?.hint, nextActions: hintEntry?.nextActions }, @@ -4036,7 +4078,7 @@ export async function POST(req: NextRequest) { if (args.regenerate_pdf === true) { const letterhead = (updated.company_letterhead as string) || "CNL"; const pdfRes = await fetch( - `${req.nextUrl.origin}/api/v1/quotes/${args.id}/pdf`, + `${upstreamOrigin(req)}/api/v1/quotes/${args.id}/pdf`, { method: "POST", headers: { @@ -4090,7 +4132,7 @@ export async function POST(req: NextRequest) { if (toolName === "quotes.clone") { if (!args.id) throw new Error("Missing id for quotes.clone."); - const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/${args.id}/clone`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/quotes/${args.id}/clone`, { method: "POST", headers: { "Content-Type": "application/json", @@ -4109,7 +4151,7 @@ export async function POST(req: NextRequest) { throw new Error("Missing template_id for quotes.create_from_template."); } const res = await fetch( - `${req.nextUrl.origin}/api/v1/quotes/${args.template_id}/create-from-template`, + `${upstreamOrigin(req)}/api/v1/quotes/${args.template_id}/create-from-template`, { method: "POST", headers: { @@ -4128,7 +4170,7 @@ export async function POST(req: NextRequest) { if (toolName === "quotes.save_as_template") { if (!args.id) throw new Error("Missing id for quotes.save_as_template."); const res = await fetch( - `${req.nextUrl.origin}/api/v1/quotes/${args.id}/save-as-template`, + `${upstreamOrigin(req)}/api/v1/quotes/${args.id}/save-as-template`, { method: "POST", headers: { @@ -4149,7 +4191,7 @@ export async function POST(req: NextRequest) { if (!args.source_quote_id) { throw new Error("Missing source_quote_id for quotes.split."); } - const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/${args.source_quote_id}/split`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/quotes/${args.source_quote_id}/split`, { method: "POST", headers: { "Content-Type": "application/json", @@ -4170,7 +4212,7 @@ export async function POST(req: NextRequest) { } const hintEntry = TOOL_HINTS["quotes.partial_approve"]; const data = await fetchUpstreamJson( - `${req.nextUrl.origin}/api/v1/quotes/${args.quote_id}/partial-approve`, + `${upstreamOrigin(req)}/api/v1/quotes/${args.quote_id}/partial-approve`, { method: "POST", headers: { @@ -4190,7 +4232,7 @@ export async function POST(req: NextRequest) { } const hintEntry = TOOL_HINTS["quotes.submit"]; const data = await fetchUpstreamJson( - `${req.nextUrl.origin}/api/v1/quotes/${args.id}/submit`, + `${upstreamOrigin(req)}/api/v1/quotes/${args.id}/submit`, { method: "POST", headers: { @@ -4209,7 +4251,7 @@ export async function POST(req: NextRequest) { if (!Array.isArray(args.items)) { throw new Error("Missing items array for quotes.re_price."); } - const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/${args.id}/re-price`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/quotes/${args.id}/re-price`, { method: "POST", headers: { "Content-Type": "application/json", @@ -4238,7 +4280,7 @@ export async function POST(req: NextRequest) { if (args.q) params.set("q", String(args.q)); if (args.page) params.set("page", String(args.page)); if (args.limit) params.set("limit", String(args.limit)); - const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/lookup?${params}`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/quotes/lookup?${params}`, { headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, }); if (!res.ok) throw new Error(`quotes.lookup upstream ${res.status}`); @@ -4353,7 +4395,7 @@ export async function POST(req: NextRequest) { if (args.olderThan) params.set("olderThan", String(args.olderThan)); if (args.limit) params.set("limit", String(args.limit)); const res = await fetch( - `${req.nextUrl.origin}/api/v1/purchase-orders/stale?${params}`, + `${upstreamOrigin(req)}/api/v1/purchase-orders/stale?${params}`, { headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` } }, ); if (!res.ok) throw new Error(`purchase_orders.stale upstream ${res.status}`); @@ -5318,7 +5360,7 @@ export async function POST(req: NextRequest) { body.intent = args.intent; } if (typeof args.notes === "string") body.notes = args.notes; - const res = await fetch(`${req.nextUrl.origin}/api/v1/stock/fulfill`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/stock/fulfill`, { method: "POST", headers: { "Content-Type": "application/json", @@ -5337,7 +5379,7 @@ export async function POST(req: NextRequest) { if (!args.order_item_id || typeof args.surplus_qty !== "number") { throw new Error("order_item_id and surplus_qty are required."); } - const res = await fetch(`${req.nextUrl.origin}/api/v1/stock/surplus-book`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/stock/surplus-book`, { method: "POST", headers: { "Content-Type": "application/json", @@ -5387,7 +5429,7 @@ export async function POST(req: NextRequest) { // api_key never crosses the boundary here; the upstream route // strips it before responding. if (toolName === "ai_settings.get") { - const res = await fetch(`${req.nextUrl.origin}/api/v1/ai/settings`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/ai/settings`, { headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, }, @@ -5406,7 +5448,7 @@ export async function POST(req: NextRequest) { if (typeof args.model_id === "string") patch.model_id = args.model_id; if (typeof args.max_tokens === "number") patch.max_tokens = args.max_tokens; if (typeof args.enabled === "boolean") patch.enabled = args.enabled; - const res = await fetch(`${req.nextUrl.origin}/api/v1/ai/settings`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/ai/settings`, { method: "PUT", headers: { "content-type": "application/json", @@ -5580,7 +5622,7 @@ export async function POST(req: NextRequest) { // ─── Users (lookup) ────────────────────────────────────────────── if (toolName === "users.lookup") { - const res = await fetch(`${req.nextUrl.origin}/api/v1/users/lookup`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/users/lookup`, { method: "GET", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, }); @@ -5593,7 +5635,7 @@ export async function POST(req: NextRequest) { // ─── Dashboard / aggregate ─────────────────────────────────────── if (toolName === "dashboard.daily_summary") { - const res = await fetch(`${req.nextUrl.origin}/api/v1/dashboard`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/dashboard`, { method: "GET", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, }); @@ -5605,7 +5647,7 @@ export async function POST(req: NextRequest) { } if (toolName === "orders.sprint_counts") { - const res = await fetch(`${req.nextUrl.origin}/api/v1/orders/sprint/counts`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/orders/sprint/counts`, { method: "GET", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, }); @@ -5617,7 +5659,7 @@ export async function POST(req: NextRequest) { } if (toolName === "orders.find_duplicates") { - const res = await fetch(`${req.nextUrl.origin}/api/v1/orders/duplicates`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/orders/duplicates`, { method: "GET", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, }); @@ -5631,7 +5673,7 @@ export async function POST(req: NextRequest) { // ─── Orders side helpers ───────────────────────────────────────── if (toolName === "orders.review") { if (!args.id) throw new Error("Missing id for orders.review."); - const res = await fetch(`${req.nextUrl.origin}/api/v1/orders/${args.id}/review`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/orders/${args.id}/review`, { method: "GET", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, }); @@ -5645,7 +5687,7 @@ export async function POST(req: NextRequest) { if (toolName === "orders.matched_attachments") { if (!args.id) throw new Error("Missing id for orders.matched_attachments."); const res = await fetch( - `${req.nextUrl.origin}/api/v1/orders/${args.id}/matched-attachments`, + `${upstreamOrigin(req)}/api/v1/orders/${args.id}/matched-attachments`, { method: "GET", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, @@ -5668,7 +5710,7 @@ export async function POST(req: NextRequest) { "Missing collection / record_id / filename for files.fetch.", ); } - const url = `${req.nextUrl.origin}/api/v1/files/${encodeURIComponent( + const url = `${upstreamOrigin(req)}/api/v1/files/${encodeURIComponent( args.collection, )}/${encodeURIComponent(args.record_id)}/${encodeURIComponent(args.filename)}`; const res = await fetch(url, { @@ -5697,7 +5739,7 @@ export async function POST(req: NextRequest) { qs.set("page", String(args.page ?? 1)); qs.set("perPage", String(args.perPage ?? 50)); const res = await fetch( - `${req.nextUrl.origin}/api/v1/driver/stops?${qs}`, + `${upstreamOrigin(req)}/api/v1/driver/stops?${qs}`, { method: "GET", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, @@ -5715,7 +5757,7 @@ export async function POST(req: NextRequest) { throw new Error("Missing stop_id / kind / action for drivers.update_stop_status."); } const res = await fetch( - `${req.nextUrl.origin}/api/v1/driver/stops/${encodeURIComponent(args.stop_id)}/status`, + `${upstreamOrigin(req)}/api/v1/driver/stops/${encodeURIComponent(args.stop_id)}/status`, { method: "PATCH", headers: { @@ -5747,7 +5789,7 @@ export async function POST(req: NextRequest) { const qs = new URLSearchParams({ q: args.q }); if (args.limit) qs.set("limit", String(args.limit)); const res = await fetch( - `${req.nextUrl.origin}/api/v1/stock-quick-draft/search?${qs}`, + `${upstreamOrigin(req)}/api/v1/stock-quick-draft/search?${qs}`, { method: "GET", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, @@ -5772,7 +5814,7 @@ export async function POST(req: NextRequest) { ); } const res = await fetch( - `${req.nextUrl.origin}/api/v1/stock-quick-draft/add-line`, + `${upstreamOrigin(req)}/api/v1/stock-quick-draft/add-line`, { method: "POST", headers: { @@ -5797,7 +5839,7 @@ export async function POST(req: NextRequest) { if (toolName === "stock_quick_draft.suppliers") { const res = await fetch( - `${req.nextUrl.origin}/api/v1/stock-quick-draft/suppliers`, + `${upstreamOrigin(req)}/api/v1/stock-quick-draft/suppliers`, { method: "GET", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, @@ -5832,7 +5874,7 @@ export async function POST(req: NextRequest) { if (!Array.isArray(args.items) || args.items.length === 0) { throw new Error("delivery_notes.create requires a non-empty items array."); } - const res = await fetch(`${req.nextUrl.origin}/api/v1/delivery-notes`, { + const res = await fetch(`${upstreamOrigin(req)}/api/v1/delivery-notes`, { method: "POST", headers: { "Content-Type": "application/json", @@ -5856,7 +5898,7 @@ export async function POST(req: NextRequest) { if (toolName === "delivery_notes.cancel") { if (!args.id) throw new Error("Missing id for delivery_notes.cancel."); const res = await fetch( - `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/cancel`, + `${upstreamOrigin(req)}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/cancel`, { method: "POST", headers: { @@ -5880,7 +5922,7 @@ export async function POST(req: NextRequest) { if (toolName === "delivery_notes.mark_invoiced") { if (!args.id) throw new Error("Missing id for delivery_notes.mark_invoiced."); const res = await fetch( - `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/mark-invoiced`, + `${upstreamOrigin(req)}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/mark-invoiced`, { method: "POST", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, @@ -5896,7 +5938,7 @@ export async function POST(req: NextRequest) { if (toolName === "delivery_notes.mark_complete") { if (!args.id) throw new Error("Missing id for delivery_notes.mark_complete."); const res = await fetch( - `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/mark-complete`, + `${upstreamOrigin(req)}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/mark-complete`, { method: "POST", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, @@ -5912,7 +5954,7 @@ export async function POST(req: NextRequest) { if (toolName === "delivery_notes.regenerate_pdf") { if (!args.id) throw new Error("Missing id for delivery_notes.regenerate_pdf."); const res = await fetch( - `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/regenerate-pdf`, + `${upstreamOrigin(req)}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/regenerate-pdf`, { method: "POST", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, @@ -5928,7 +5970,7 @@ export async function POST(req: NextRequest) { if (toolName === "delivery_notes.create_quote") { if (!args.id) throw new Error("Missing id for delivery_notes.create_quote."); const res = await fetch( - `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/create-quote`, + `${upstreamOrigin(req)}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/create-quote`, { method: "POST", headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, @@ -5947,7 +5989,7 @@ export async function POST(req: NextRequest) { throw new Error("Missing customer_po for delivery_notes.set_customer_po."); } const res = await fetch( - `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/customer-po`, + `${upstreamOrigin(req)}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/customer-po`, { method: "POST", headers: { @@ -5970,7 +6012,7 @@ export async function POST(req: NextRequest) { throw new Error("quantity must be > 0 for delivery_notes.set_item_quantity."); } const res = await fetch( - `${req.nextUrl.origin}/api/v1/delivery-notes/items/${encodeURIComponent(args.id)}/quantity`, + `${upstreamOrigin(req)}/api/v1/delivery-notes/items/${encodeURIComponent(args.id)}/quantity`, { method: "PATCH", headers: { @@ -5994,7 +6036,7 @@ export async function POST(req: NextRequest) { ); } const res = await fetch( - `${req.nextUrl.origin}/api/v1/delivery-notes/combine`, + `${upstreamOrigin(req)}/api/v1/delivery-notes/combine`, { method: "POST", headers: {