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.
This commit is contained in:
Dave 2026-08-15 23:22:19 +02:00
parent e7672ab7c9
commit e5e31a7bdc

View file

@ -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: {