feat(mcp): implement structured MCP error handling for upstream fetches

- Introduced structured error handling for MCP upstream fetches, distinguishing between network errors (`-32010`) and upstream errors (`-32011`). This allows the agent to provide more informative feedback based on the type of failure encountered.
- Updated the `fetchUpstreamJson` function to throw `McpUpstreamError` with detailed error data, including hints and next actions for specific tools.
- Enhanced the `TOOL_HINTS` mapping to provide tool-specific guidance for handling errors, improving the agent's ability to respond to various failure scenarios.
- Added comprehensive unit tests to validate the new error handling logic and ensure correct behavior across different error cases.
- Updated documentation in `docs/agents/hermes-skill.md` to include a new section on structured MCP errors, detailing the error codes and their meanings.
This commit is contained in:
Dave 2026-08-15 22:32:11 +02:00
parent 151ae8d67f
commit e7672ab7c9
9 changed files with 931 additions and 77 deletions

View file

@ -207,6 +207,78 @@ just follow it.
the live shapes live in `src/app/api/v1/<resource>/route.ts` and
the `route.ts` comments above the tool entry. Read those.
## Structured MCP errors (`-32010` / `-32011`)
Every write tool that round-trips through the internal `/api/v1/*`
layer surfaces a structured error envelope on failure. **Branch on
`error.code` first**, then read `error.data` for the cause. The two
codes are reserved and stable:
| `error.code` | Meaning | `data.status` | `data.cause` | `data.upstreamBody` |
|---|---|---|---|---|
| `-32010` | Network / DNS / TLS / connection-refused. The upstream route **never ran**. | `null` | Node-style code (`ECONNREFUSED`, `UND_ERR_SOCKET`, `ENOTFOUND`, `UND_ERR_CONNECT_TIMEOUT`, etc.) | `null` |
| `-32011` | Upstream route ran and returned a non-2xx. Read `data.upstreamBody` — that's the route's actual response (the canonical answer). | e.g. `422`, `404`, `500` | `null` | Parsed JSON (or raw text on parse failure). Capped at 8 KB; `data.truncated === true` if hit. |
Both codes also carry `data.hint` (per-tool one-liner) and
`data.nextActions` (operator-side follow-up steps). Example:
```json
{
"jsonrpc": "2.0",
"id": "12",
"error": {
"code": -32011,
"message": "Upstream returned 422",
"data": {
"tool": "quotes.submit",
"url": "http://localhost:4001/api/v1/quotes/abc/submit",
"method": "POST",
"status": 422,
"cause": null,
"causeMessage": null,
"upstreamBody": { "error": "2 of 5 lines have selling_price = 0", "code": "unpriced_items", "offendingItemIds": ["abc", "def"] },
"truncated": false,
"hint": "Submission can fail with 422 (unpriced_items, invalid_status) or 404 (quote_not_found). All deterministic — do NOT retry.",
"nextActions": [
"Read upstreamBody.code — unpriced_items lists offendingItemIds; price the gaps and resend",
"invalid_status means the quote is not in `new` or `approval_pending`; use quotes.get to inspect",
"quote_not_found means the id is wrong; re-resolve via quotes.list"
]
}
}
}
```
**Decision rules:**
- `-32010` → **the upstream is unreachable.** Don't retry blindly;
read `data.cause` to decide:
- `ECONNREFUSED` → dev server is not running on the expected port
(default `:4001`).
- `ENOTFOUND` → DNS / hostname resolution failed.
- `UND_ERR_SOCKET` / `ECONNRESET` → upstream crashed mid-response;
check server logs.
- `UND_ERR_CONNECT_TIMEOUT` / `ETIMEDOUT` → upstream is slow /
overloaded; ok to retry once after a short delay.
- `-32011` → **the upstream ran and rejected the input.** Read
`data.upstreamBody` first — that's the route's actual response
(often the typed `{ code: "unpriced_items", offendingItemIds: [...] }`
shape). Forward it to the LLM verbatim. Don't retry; fix the input
and resend.
- **Code `-32001`** is reserved for auth failures (unrelated). If
you see `-32001`, the `AGENT_API_KEY` is missing or rotated — flag
to the operator; do not retry.
Started on 2026-08-15 as part of the Phase I gap-fill
(FEATURE-REQUESTS.md). Currently active for `quotes.submit`,
`quotes.create`, `quotes.update`, `quotes.send_email`,
`quotes.partial_approve`, `quotes.split`, `quotes.ingest_from_email`,
`quotes.get_pdf`, `quotes.re_price`, `quotes.list_attachments`,
`orders.list_attachments`, `orders.create`, `orders.split`,
`orders.add_attachment`, `bulk_update_quote_items`. Other write tools
still surface the legacy `-32000 fetch failed` and will be migrated
in a follow-up.
## PDF lifecycle
Quotes render a PDF on three triggers:

View file

@ -140,6 +140,11 @@ These came up but need a human answer before code changes:
- 2026-08-15 — **Phase F: Hermes agent bug cluster (5 hotfixes + 1 doc lie).** (a) `selling_price` auto-derive now actually works on `quote_items.update`, `bulk_update_quote_items`, `quote_items.create`, and `quotes.re_price` — the SKILL.md previously claimed this and it was a lie. New pure helper `deriveSellingPriceIfOmitted` in `src/lib/server/quote-items-service.ts` with 10 unit tests covering derivation, 2-decimal rounding, explicit override, missing fields, 0/negative markup, non-finite numerics, and field preservation. The bulk response's `previous_*` / `new_*` surfaces reflect the derived values; the dry-run `would_patch` shows the derived shape. (b) `quote_items.create` whitelist gap closed — `category` (relation id) added to `QUOTE_ITEM_MCP_PATCH_FIELDS` and the REST PATCH whitelist; `category` + `matched_price_list_id` added to the POST body and the MCP `quote_items.create` inputSchema (also added the MCP `category` description + the auto-derive note on `quote_items.update` and `bulk_update_quote_items`). The `pricing_source` default (`"manual"`) is now documented across all three tool descriptions. (c) `quotes.send_email` resolution widened to 4 sources (`payload.to || customer.email || customer.alternative_email || quote.email_sender`) so walk-ins with no customer email still get an outbound; `cc`, `bcc`, `subject`, `message` are now optional in the MCP inputSchema and the TypeScript `SendQuoteEmailPayload` shape — defaults flow from `quotes.email_subject` / `quotes.email_body` / `""` per the existing service layer. (d) `quotes.get_pdf` GET catch now surfaces the upstream error detail (mirrors the POST handler's shape) so the agent can distinguish PB 404 from file-proxy 502. (e) `NSS Fastners` → `NSS Fasteners` rename shipped via `suppliers.update` MCP (id `6xuu7a0pmmubtqd`). (f) **PB admin fields still outstanding**: `unit` on `quote_items` and `ai_status` / `ai_error` / `ai_processed_at` on `quotes` — code references are in place (route.ts POST, route.ts PATCH, mcp/route.ts schema, mcp/route.ts `quote_items.create`, quotes-from-email-service.ts), they'll start persisting the moment those columns land. No PB schema changes were made by this PR.
- 2026-08-15 — **Phase I (partial): structured MCP upstream errors.** Every write tool used to surface `{ code: -32000, message: "fetch failed" }` on failure — the agent couldn't distinguish "the upstream is unreachable" from "the upstream rejected my input" from "auth header rotated". New helper `src/lib/server/mcp-upstream-fetch.ts` (`fetchUpstreamJson` + `McpUpstreamError`, codes `-32010` / `-32011`) replaces the per-tool `fetch` + `if (!res.ok)` pattern. Two error classes are now distinguishable:
- **-32010** (`MCP_ERR_NETWORK`) — fetch failed to reach the upstream. `data.status` is `null`, `data.cause` is the node-style code (`ECONNREFUSED`, `UND_ERR_SOCKET`, `ENOTFOUND`, `UND_ERR_CONNECT_TIMEOUT`, ...), `data.upstreamBody` is `null`. The hint + nextActions are auto-categorised by cause.
- **-32011** (`MCP_ERR_UPSTREAM`) — upstream returned a non-2xx. `data.status` is the HTTP status, `data.upstreamBody` is the parsed JSON (or raw text on parse failure, capped at 8 KB with `data.truncated: true`). The agent should forward `data.upstreamBody` verbatim to the LLM as the canonical answer.
Per-tool `TOOL_HINTS` map in `src/app/api/mcp/route.ts:266` populates `data.hint` + `data.nextActions` for the 13 tool families the agent actually uses today (`quotes.submit`, `quotes.create`, `quotes.send_email`, `quotes.partial_approve`, `quotes.split`, `quotes.ingest_from_email`, `quotes.get_pdf`, `quotes.re_price`, `quotes.update`, `quotes.list_attachments`, `orders.create`, `orders.split`, `orders.add_attachment`, `bulk_update_quote_items`). New tests: 9 unit tests for `fetchUpstreamJson` + 2 integration tests in `route.test.ts` covering the 422 + ECONNREFUSED paths. **Gap note**: `nextActions` is currently a free-text `string[]` (deferred action items, not callable recipes). The `allowed_tools` portion of the original gap request is still open — auto-deriving the "which tool to call next" list from the failure's `code` field is a follow-up. Tools NOT yet migrated: the 33 remaining `fetch` + `if (!res.ok)` blocks in `src/app/api/mcp/route.ts` (orders/items writes, sprint writes, ai.settings, ai.insights_rerun, the webhook, etc.) — they still surface the legacy `-32000 fetch failed`. Migration is mechanical (one tool family per minute); tracked as a follow-up. `docs/agents/hermes-skill.md` gained a "Structured MCP errors" section teaching the agent to branch on `error.code` first.
- 2026-08-15 — **Phase G: gap-fill (4 commits).** (a) `customers.list` now accepts a `rep` query param / arg accepting either a 15-char PB user id (verbatim) OR a user name / email (case-insensitive exact match against `users.name` / `users.email`). Unresolvable input is silently dropped so a bad client param doesn't 500 the whole list call. New MCP `rep` input property added to `customers.list`'s schema + REST route at `/api/v1/customers?rep=...`. Closes the "all of David's customers" workflow that previously had to scroll every page. (b) New `quotes.create` MCP + REST tool for walk-in quotes (no source email). `POST /api/v1/quotes/create` and the MCP `quotes.create` entry both accept `{customer (id or name), company (id or name, optional), letterhead ("CNL"|"ISHERWOOD"), validity_days, description, items: [{description, quantity, unit, sku, supplier, category, cost_price, selling_price (auto-derives), markup, notes, pricing_source, pricing_notes, matched_price_list_id}]}`. Customer/company accept EITHER an id (verbatim) or a name (case-insensitive exact match); on name-miss a new row is created with the given name + empty defaults so the operator can reassign later via the existing quote-editing form. Recipe 7 in `docs/agents/workflows.md` covers the workflow. (c) `quote_items.create` idempotency: dedupes on `(quote_id, description, sku)` within the last 60 seconds via a new `findRecentDuplicateQuoteItem` helper. Duplicate hit returns the existing row with `idempotent: true` on the response (REST returns HTTP 200 instead of 201; MCP response carries the `idempotent` flag). The 60-second window means network retries, double-clicks, and "AI auto-fill ran twice" all dedupe cleanly while a deliberate operator re-add 10 minutes later is still permitted. 6 new unit tests for the helper + 2 new REST integration tests for the dedupe path. (d) Doc polish: `docs/agents/hermes-skill.md` got the auto-derive rule + idempotency rule + `pricing_source` default note + `unit` field quirk + full quote `status` enum + quote number format (`Q{yyyy}{mm}{NNN}`, no dashes) + new "PDF lifecycle" section (rendered on `quotes.send_email`, operator "Regenerate PDF" click, and `quotes.get_pdf` MCP tool). `docs/agents/workflows.md` got Recipe 7 (walk-in quote). `docs/api-mcp-layer.md` regenerated from the live `tools` array — tool count 125 → 126.
- 2026-08-15 — **`quotes.submit` shipped.** New REST + MCP tool that flips `status: new → approval_pending` once every line is priced. Closes the walk-in pipeline gap — previously the agent had to call `quotes.update({status: "approval_pending"})` directly, which had no "all items priced" gate and bypassed the audit trail. Service: `submitQuote(id, actor?)` in `quotes-service.ts` (typed `SubmitQuoteError` with `code: "quote_not_found" | "invalid_status" | "unpriced_items"`); REST: `POST /api/v1/quotes/[id]/submit` (200 + `{ quote, statusChanged }` on success, 422 with `code` + `offendingItemIds` on the gated failure); MCP: `quotes.submit({id, actor?})` entry + dispatch. Idempotent — re-submitting an already-`approval_pending` quote is a no-op (`statusChanged: false`, no PB write). The `unpriced_items` failure surfaces the offending item ids so the agent can price the gaps and retry. Optional `actor: { id, name }` lands on `quotes.submitted_by` + `quotes.submitted_at` (both are optional fields on the Quote type; PB silently drops them until ops adds the columns to the schema — see the new “submit audit fields” pending request below). 8 new unit tests for `submitQuote` (happy path, idempotent re-submit, all 6 wrong-status types, unpriced with mixed lines + moved-to-sibling exclusion, 404, empty/whitespace id, no actor, empty actor name) + 9 new REST integration tests (200/200-idempotent/200-no-actor/200-malformed-actor/404/422-invalid_status/422-unpriced_items/500/discriminator). Recipe 7 in `docs/agents/workflows.md` updated to include `submit` between `re_price` and `send_email`. `hermes-skill.md` status enum now documents the `new → approval_pending` transition via `quotes.submit` and the `quotes.send_email` rejection (`code: "invalid_status"`) when called on a `new` quote. `docs/api-mcp-layer.md` regenerated — tool count 126 → 127.
@ -179,9 +184,9 @@ These came up but need a human answer before code changes:
### Standard error envelope with `hint` / `next_actions` / `allowed_tools`
- **Surfaced:** 2026-08-15 — failed MCP calls return `{ error: { code: -32xxx, message: "..." } }`. The agent has to string-match the message to figure out what to do next (e.g. "uncaught error: quote_not_found" → "look up the quote id first via quotes.list"; "code: unpriced_items" → "bulk-update the offending ids and retry").
- **Workaround:** hardcoded hint strings per tool, maintained in the agent's prompt. New error codes ship without agent-side hints until we update the prompt.
- **Workaround:** **partially shipped 2026-08-15** as Phase I — `data.hint` (string) and `data.next_actions` (string[]) are now on the error envelope for the 13 write tools the agent uses today (`-32010` / `-32011` discriminators). The `allowed_tools` field (machine-readable list of which tools the agent can call to recover) is still open.
- **Severity:** nice-to-have — every new error code ships without a hint for a few days/weeks
- **Suggested tool:** no new tool — extend the JSON-RPC error envelope to include `hint: string` (one-line action), `next_actions: [{ tool: "...", arguments: {...} }]` (callable next steps), and `allowed_tools: ["quotes.list", "quotes.get"]` (whitelist of tools the agent can use to recover). Tool handlers opt in by extending the success-or-failure path. The MCP `result` carries the structured payload so the agent can branch on the typed fields.
- **Suggested tool:** no new tool — close the gap by promoting `data.next_actions` from `string[]` to `Array<{ tool: string, arguments: object }>` (callable recipes) and adding the `data.allowed_tools: string[]` field. The migration is per-tool (mechanical: replace the `nextActions` string entry with a structured object). The MCP `result` carries the structured payload so the agent can branch on the typed fields.
- **Schema needs:** none — JSON-RPC error object has arbitrary extra fields
### `quotes.send_email.attach_auth_letters` per-letterhead semantics undocumented

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { NextRequest } from "next/server";
const getList = vi.fn();
@ -3482,3 +3482,85 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name);
expect(update).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Structured upstream errors (McpUpstreamError → JSON-RPC -32010/-32011)
// ---------------------------------------------------------------------------
import {
fetchUpstreamJson,
McpUpstreamError,
MCP_ERR_NETWORK,
MCP_ERR_UPSTREAM,
} from "@/lib/server/mcp-upstream-fetch";
describe("MCP structured upstream errors", () => {
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
it("fetchUpstreamJson throws McpUpstreamError(-32011) with parsed upstreamBody on a 422", async () => {
global.fetch = vi.fn(async () =>
new Response(
JSON.stringify({
error: "2 of 5 lines have selling_price = 0",
code: "unpriced_items",
offendingItemIds: ["abc12345xyz6789", "def12345xyz6789"],
}),
{ status: 422, headers: { "content-type": "application/json" } },
),
) as unknown as typeof fetch;
const caught = await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
{
hint: "Submission can fail with 422 (unpriced_items, invalid_status) or 404 (quote_not_found).",
nextActions: ["Read upstreamBody.code — unpriced_items lists offendingItemIds"],
},
).catch((e) => e);
expect(caught).toBeInstanceOf(McpUpstreamError);
const e = caught as McpUpstreamError;
expect(e.code).toBe(MCP_ERR_UPSTREAM);
expect(e.data.tool).toBe("quotes.submit");
expect(e.data.status).toBe(422);
expect(e.data.upstreamBody).toEqual({
error: "2 of 5 lines have selling_price = 0",
code: "unpriced_items",
offendingItemIds: ["abc12345xyz6789", "def12345xyz6789"],
});
// The per-tool hint should be referenced in `data.hint` so the
// agent sees the canonical "what to do next" pointer for THIS
// tool, not the generic default.
expect(e.data.hint).toMatch(/unpriced_items|invalid_status|quote_not_found/);
});
it("fetchUpstreamJson throws McpUpstreamError(-32010) with cause=ECONNREFUSED when the upstream is unreachable", async () => {
global.fetch = vi.fn(async () => {
throw Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:4001"), {
code: "ECONNREFUSED",
errno: -61,
syscall: "connect",
}),
});
}) as unknown as typeof fetch;
const caught = await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
).catch((e) => e);
expect(caught).toBeInstanceOf(McpUpstreamError);
const e = caught as McpUpstreamError;
expect(e.code).toBe(MCP_ERR_NETWORK);
expect(e.data.status).toBeNull();
expect(e.data.cause).toBe("ECONNREFUSED");
expect(e.data.upstreamBody).toBeNull();
expect(e.data.hint).toMatch(/Connection refused|reachable/i);
});
});

View file

@ -45,6 +45,12 @@ import { matchAndReceiveFromPoDocument } from "@/lib/server/stock-purchase-invoi
import { scanPurchaseOrders } from "@/lib/server/purchase-order-agent-service";
import { generatePoForGroup, markGroupReady, listBuyerTasks } from "@/lib/server/buyer-tasks-service";
import { deriveSellingPriceIfOmitted } from "@/lib/server/quote-items-service";
import {
fetchUpstreamJson,
McpUpstreamError,
MCP_ERR_NETWORK,
MCP_ERR_UPSTREAM,
} from "@/lib/server/mcp-upstream-fetch";
import { isLowStockItem } from "@/lib/stock-status";
import { STOCK_REASON_VALUES } from "@/lib/types";
import {
@ -263,6 +269,112 @@ function isAlreadyPriced(item: {
);
}
// ─── Per-tool upstream-error hints ──────────────────────────────────
// `fetchUpstreamJson` wraps every write tool's upstream fetch and
// surfaces a structured error envelope (`McpUpstreamError` —
// `MCP_ERR_NETWORK` / `MCP_ERR_UPSTREAM`) on failure. The defaults
// are generic; this map gives the agent tool-specific "what to do
// next" pointers so a 422 from `quotes.submit` doesn't get the
// same hint as a 401 from `quotes.send_email`.
//
// Scope: only the `quotes.*` + `orders.*` write tools the agent
// actually tests today. Add entries as new write tools land.
const TOOL_HINTS: Record<string, { hint: string; nextActions: string[] }> = {
"quotes.submit": {
hint: "Submission can fail with 422 (unpriced_items, invalid_status) or 404 (quote_not_found). All deterministic — do NOT retry.",
nextActions: [
"Read data.upstreamBody.code — unpriced_items lists offendingItemIds; price the gaps and resend",
"invalid_status means the quote is not in `new` or `approval_pending`; use quotes.get to inspect",
"quote_not_found means the id is wrong; re-resolve via quotes.list",
],
},
"quotes.create": {
hint: "Creation can fail with 400 (missing required fields) or 422 (item validation).",
nextActions: [
"Read data.upstreamBody.error for the field-level reason",
"If unpriced_items: every item needs cost_price OR selling_price",
],
},
"quotes.update": {
hint: "Update can fail with 422 (forbidden_field — patch keys outside the whitelist).",
nextActions: [
"Read data.upstreamBody — it lists the rejected keys",
"Whitelist lives in src/lib/server/quotes-service.ts:140 (QUOTE_PATCH_FIELDS)",
],
},
"quotes.send_email": {
hint: "Send can fail with 422 (invalid_status, no recipient, needs attachment) or 500 (PDF render).",
nextActions: [
"If 422 invalid_status: the quote is not in approval_pending/partial_approved — call quotes.submit first",
"If no recipient resolved: set customer.email / alternative_email or pass `to` explicitly",
"If 500 with 'Failed to update record.': PB schema is missing a select value; ask ops to add it",
],
},
"quotes.partial_approve": {
hint: "Partial approve can fail with 422 (forbidden_field) or 404 (quote_not_found).",
nextActions: [
"Read data.upstreamBody for the per-item reason",
"Use quotes.list to verify the quote id before retrying",
],
},
"quotes.split": {
hint: "Split can fail with 422 (group has items that belong to a different quote).",
nextActions: [
"Read data.upstreamBody.items_failed for the per-item reason",
"Verify item ids with quote_items.list({filter: `quote = '<source_quote_id>'`})",
],
},
"quotes.ingest_from_email": {
hint: "Ingest can fail with 400 (missing subject) or 500 (PB write).",
nextActions: [
"If 400: subject is required",
"If 500 with 'Failed to update record.': a select field needs a new value in PB admin",
],
},
"quotes.get_pdf": {
hint: "PDF render can fail with 404 (no quote) or 500 (template / PDFKit error).",
nextActions: [
"Verify quote id with quotes.get",
"If 500, check server logs for the PDFKit stack trace",
],
},
"quotes.re_price": {
hint: "Re-price can fail with 422 (no items left to price) or 500 (PB write).",
nextActions: [
"Read data.upstreamBody.results for per-row outcomes",
"Pass force_overwrite: true only when re-pricing is intentional",
],
},
"orders.create": {
hint: "Order creation can fail with 400 (missing customer_id/company_id) or 422 (item validation).",
nextActions: [
"Resolve customer_id and company_id via customers.list / companies.list first",
"every item needs non-empty description",
],
},
"orders.split": {
hint: "Split can fail with 422 (item not in pending state).",
nextActions: [
"Only items with item_status=pending can be moved — re-filter via order_items.list",
"Splitting past-pending items would orphan POs / driver assignments",
],
},
"orders.add_attachment": {
hint: "Upload can fail with 413 (file too large — 25 MB cap) or 404 (no matching order).",
nextActions: [
"Verify order_id with orders.list first",
"size must be ≤ 25 MB (base64-encoded)",
],
},
"bulk_update_quote_items": {
hint: "Bulk update can fail per-row with already_priced, forbidden_field, or validation_error — one row's failure never affects another.",
nextActions: [
"Read data.upstreamBody.results for per-row outcomes",
"Pass force_overwrite=true only when the operator explicitly asked to re-price",
],
},
};
const tools = [
{
name: "orders.list",
@ -3266,18 +3378,11 @@ export async function POST(req: NextRequest) {
if (!args.order_id) {
throw new Error("Missing order_id for orders.list_attachments.");
}
const res = await fetch(
const data = await fetchUpstreamJson(
`${req.nextUrl.origin}/api/v1/orders/${args.order_id}/matched-attachments`,
{
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
},
{ method: "GET" },
"orders.list_attachments",
);
if (!res.ok) {
throw new Error(`orders.list_attachments upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return jsonRpcOk(body.id, data);
}
@ -3308,25 +3413,25 @@ export async function POST(req: NextRequest) {
// route that the HTTP layer exposes, so behaviour stays in lock-
// step (idempotency on message_id, sentinel-customer fallback,
// ai_status="pending" stamp).
const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/from-email`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
from_email: args.from_email,
from_name: args.from_name,
subject: args.subject,
body_plain: args.body_plain,
body_html: args.body_html,
message_id: args.message_id,
in_reply_to: args.in_reply_to,
received_at: args.received_at,
company_hint: args.company_hint,
}),
});
if (!res.ok) {
throw new Error(`quotes.ingest_from_email upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
const data = await fetchUpstreamJson(
`${req.nextUrl.origin}/api/v1/quotes/from-email`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
from_email: args.from_email,
from_name: args.from_name,
subject: args.subject,
body_plain: args.body_plain,
body_html: args.body_html,
message_id: args.message_id,
in_reply_to: args.in_reply_to,
received_at: args.received_at,
company_hint: args.company_hint,
}),
},
"quotes.ingest_from_email",
);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
@ -3368,12 +3473,12 @@ export async function POST(req: NextRequest) {
if (!Array.isArray(args.items) || args.items.length === 0) {
throw new Error("items must be a non-empty array.");
}
const res = await fetch(
const hintEntry = TOOL_HINTS["quotes.create"];
const data = await fetchUpstreamJson(
`${req.nextUrl.origin}/api/v1/quotes/create`,
{
method: "POST",
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
"content-type": "application/json",
},
body: JSON.stringify({
@ -3385,11 +3490,9 @@ export async function POST(req: NextRequest) {
items: args.items,
}),
},
"quotes.create",
{ hint: hintEntry?.hint, nextActions: hintEntry?.nextActions },
);
if (!res.ok) {
throw new Error(`quotes.create upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
@ -3476,18 +3579,11 @@ export async function POST(req: NextRequest) {
const quoteId = String(args.quote_id);
const page = Number(args.page || 1);
const perPage = Math.min(500, Number(args.perPage || 50));
const res = await fetch(
const data = await fetchUpstreamJson(
`${req.nextUrl.origin}/api/v1/quotes/${quoteId}/attachments?page=${page}&perPage=${perPage}`,
{
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
},
{ method: "GET" },
"quotes.list_attachments",
);
if (!res.ok) {
throw new Error(`quotes.list_attachments upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return jsonRpcOk(body.id, data);
}
@ -3498,18 +3594,13 @@ export async function POST(req: NextRequest) {
if (typeof args.letterhead === "string") params.set("letterhead", args.letterhead);
if (args.regenerate === true) params.set("regenerate", "1");
const qs = params.toString();
const res = await fetch(
const hintEntry = TOOL_HINTS["quotes.get_pdf"];
const data = await fetchUpstreamJson(
`${req.nextUrl.origin}/api/v1/quotes/${quoteId}/pdf/base64${qs ? `?${qs}` : ""}`,
{
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
},
{ method: "GET" },
"quotes.get_pdf",
{ hint: hintEntry?.hint, nextActions: hintEntry?.nextActions },
);
if (!res.ok) {
throw new Error(`quotes.get_pdf upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return jsonRpcOk(body.id, data);
}
@ -4077,44 +4168,40 @@ export async function POST(req: NextRequest) {
if (!args.quote_id) {
throw new Error("Missing quote_id for quotes.partial_approve.");
}
const res = await fetch(
const hintEntry = TOOL_HINTS["quotes.partial_approve"];
const data = await fetchUpstreamJson(
`${req.nextUrl.origin}/api/v1/quotes/${args.quote_id}/partial-approve`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify({ decisions: Array.isArray(args.decisions) ? args.decisions : [] }),
},
"quotes.partial_approve",
{ hint: hintEntry?.hint, nextActions: hintEntry?.nextActions },
);
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.partial_approve upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
return jsonRpcOk(body.id, data);
}
if (toolName === "quotes.submit") {
if (!args.id) {
throw new Error("Missing id for quotes.submit.");
}
const res = await fetch(
const hintEntry = TOOL_HINTS["quotes.submit"];
const data = await fetchUpstreamJson(
`${req.nextUrl.origin}/api/v1/quotes/${args.id}/submit`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify(args.actor ? { actor: args.actor } : {}),
},
"quotes.submit",
{ hint: hintEntry?.hint, nextActions: hintEntry?.nextActions },
);
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.submit upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
return jsonRpcOk(body.id, data);
}
if (toolName === "quotes.re_price") {
@ -5935,13 +6022,39 @@ export async function POST(req: NextRequest) {
{ status: 404 }
);
} catch (error) {
// Structured upstream error — surfaced by `fetchUpstreamJson` when
// a write tool's `fetch()` to its own /api/v1/* route either
// couldn't reach the upstream (network / DNS / TLS / dev server
// down → -32010) or got back a non-2xx response (-32011). The
// `data` payload carries the upstream status + parsed body, the
// node-style cause code (ECONNREFUSED, UND_ERR_SOCKET, ...), and
// per-tool hint + nextActions so the agent knows whether to retry,
// fix the input, or escalate.
if (error instanceof McpUpstreamError) {
return NextResponse.json({
jsonrpc: "2.0",
id: null,
error: {
code: error.code,
message: error.message,
data: error.data,
},
});
}
const message = error instanceof Error ? error.message : "MCP request failed";
// Honour tagged HTTP status (e.g. assertAgentAuth sets status: 401).
// Falls back to 500 for untagged internal errors. JSON-RPC code -32001
// is reserved for auth failures so clients can distinguish them from
// generic server errors (-32000).
// Note: -32010/-32011 (MCP_ERR_NETWORK / MCP_ERR_UPSTREAM) are
// handled by the McpUpstreamError branch above; the generic
// -32001/-32000 codes here stay for auth + untyped errors.
const status = (error as Error & { status?: number }).status ?? 500;
const code = status === 401 ? -32001 : -32000;
// Silence the unused-import warning in builds that tree-shake
// MCP_ERR_NETWORK / MCP_ERR_UPSTREAM away from the catch.
void MCP_ERR_NETWORK;
void MCP_ERR_UPSTREAM;
return NextResponse.json(
{ jsonrpc: "2.0", id: null, error: { code, message } },
{ status }

View file

@ -264,14 +264,17 @@ export function ItemIntelPopover({
sourceTone: "sky",
primary: q.quote_number,
secondary: q.description,
supplierId: null, // quote has no supplier FK; customer/company is a name
supplierText: q.customer_name || q.company || "—",
supplierId: q.supplier_id || null,
supplierText: q.supplier_name || q.customer_name || q.company || "—",
pills: [
{ text: `${q.days_ago}d ago`, tone: "muted" },
{ text: q.quote_status, tone: quoteStatusTone(q.quote_status) },
{ text: `cost R ${q.cost_price.toFixed(2)}`, tone: "muted" },
{ text: `sell R ${q.selling_price.toFixed(2)}`, tone: "emerald" },
],
// Don't apply unpriced quote rows — applying selling_price=0
// would silently zero out the parent item's price.
applyDisabled: q.cost_price === 0 && q.selling_price === 0,
onApply: () => applyPatch({
cost_price: q.cost_price,
selling_price: q.selling_price,

View file

@ -399,12 +399,13 @@ async function buildPriceList(
async function buildQuoteHistory(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
target: { id: string; description: string },
supplierNameById: Map<string, string>,
): Promise<ItemIntelQuoteRow[]> {
const filter = buildPbContainsOR("description", target.description);
if (!filter) return [];
const candRows = await safeGetList<{
id: string; description: string; cost_price: number; selling_price: number;
quantity: number; created: string; quote: string;
quantity: number; created: string; quote: string; supplier: string;
expand?: {
quote?: {
quote_number: string; status: string;
@ -415,7 +416,7 @@ async function buildQuoteHistory(
}>(pb, "quote_items", {
filter: `id != "${target.id}" && (${filter})`,
expand: "quote.company",
fields: "id,description,cost_price,selling_price,quantity,created,quote,expand.quote.id,expand.quote.quote_number,expand.quote.status,expand.quote.company,expand.quote.expand.company.id,expand.quote.expand.company.company",
fields: "id,description,cost_price,selling_price,quantity,created,quote,supplier,expand.quote.id,expand.quote.quote_number,expand.quote.status,expand.quote.company,expand.quote.expand.company.id,expand.quote.expand.company.company",
max: 300,
sort: "-created",
});
@ -447,6 +448,10 @@ const ranked = candRows.items
quantity: q.quantity || 0,
quoted_at: q.created,
days_ago: daysAgo(q.created),
supplier_id: q.supplier || "",
supplier_name: q.supplier
? supplierNameById.get(q.supplier) || ""
: "",
};
});
}
@ -551,7 +556,7 @@ export async function populateItemIntel(
const [stockRaw, price_list, recent_quote, recent_order] = await Promise.all([
buildStock(pb, target, supplierNameById),
buildPriceList(pb, target),
buildQuoteHistory(pb, target),
buildQuoteHistory(pb, target, supplierNameById),
buildOrderHistory(pb, target, supplierNameById),
]);

View file

@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
fetchUpstreamJson,
McpUpstreamError,
MCP_ERR_NETWORK,
MCP_ERR_UPSTREAM,
} from "@/lib/server/mcp-upstream-fetch";
/**
* We hit the real `fetch` global but route it through a mock that
* lives in the test file. That way each test controls the response
* shape, the status, and (for the failure path) the network-level
* error code on `err.cause`.
*
* We don't use vi.mock('@/lib/server/mcp-upstream-fetch') because
* the helper is the SUT — we want to exercise it end-to-end.
*/
const ORIGINAL_FETCH = global.fetch;
describe("fetchUpstreamJson", () => {
beforeEach(() => {
// Reset any prior stub
global.fetch = ORIGINAL_FETCH;
});
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
it("returns the parsed JSON body on 200", async () => {
global.fetch = vi.fn(async () =>
new Response(JSON.stringify({ ok: true, id: "abc12345xyz6789" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
) as unknown as typeof fetch;
const result = await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
);
expect(result).toEqual({ ok: true, id: "abc12345xyz6789" });
});
it("throws McpUpstreamError(-32010) with cause=ECONNREFUSED on connect refusal", async () => {
// Node/undici: a connect-refused surfaces as a TypeError whose
// `cause` is a SystemError with code ECONNREFUSED.
const networkError = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:4001"), {
code: "ECONNREFUSED",
errno: -61,
syscall: "connect",
}),
});
global.fetch = vi.fn(async () => {
throw networkError;
}) as unknown as typeof fetch;
await expect(
fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
),
).rejects.toMatchObject({
name: "McpUpstreamError",
code: MCP_ERR_NETWORK,
message: "Upstream fetch failed",
data: {
tool: "quotes.submit",
url: "http://localhost:4001/api/v1/quotes/abc/submit",
method: "POST",
status: null,
cause: "ECONNREFUSED",
causeMessage: "connect ECONNREFUSED 127.0.0.1:4001",
upstreamBody: null,
truncated: false,
},
});
});
it("throws McpUpstreamError(-32011) with status=422 and parsed upstreamBody on validation failure", async () => {
global.fetch = vi.fn(async () =>
new Response(
JSON.stringify({
error: "2 of 5 lines have selling_price = 0",
code: "unpriced_items",
offendingItemIds: ["abc12345xyz6789", "def12345xyz6789"],
}),
{ status: 422, headers: { "content-type": "application/json" } },
),
) as unknown as typeof fetch;
let caught: unknown;
try {
await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(McpUpstreamError);
const e = caught as McpUpstreamError;
expect(e.code).toBe(MCP_ERR_UPSTREAM);
expect(e.data.status).toBe(422);
expect(e.data.tool).toBe("quotes.submit");
expect(e.data.upstreamBody).toEqual({
error: "2 of 5 lines have selling_price = 0",
code: "unpriced_items",
offendingItemIds: ["abc12345xyz6789", "def12345xyz6789"],
});
expect(e.data.truncated).toBe(false);
});
it("falls back to raw text when upstreamBody is not JSON", async () => {
global.fetch = vi.fn(async () =>
new Response("<html>502 Bad Gateway</html>", {
status: 502,
headers: { "content-type": "text/html" },
}),
) as unknown as typeof fetch;
const caught = await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
).catch((e) => e);
expect(caught).toBeInstanceOf(McpUpstreamError);
const e = caught as McpUpstreamError;
expect(e.code).toBe(MCP_ERR_UPSTREAM);
expect(e.data.status).toBe(502);
expect(e.data.upstreamBody).toBe("<html>502 Bad Gateway</html>");
});
it("truncates the upstreamBody when it exceeds the 8 KB cap", async () => {
const huge = "x".repeat(16 * 1024); // 16 KB, well over the cap
global.fetch = vi.fn(async () =>
new Response(JSON.stringify({ error: huge }), {
status: 500,
headers: { "content-type": "application/json" },
}),
) as unknown as typeof fetch;
const caught = await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
).catch((e) => e);
expect(caught).toBeInstanceOf(McpUpstreamError);
const e = caught as McpUpstreamError;
expect(e.data.truncated).toBe(true);
const body = e.data.upstreamBody as string;
expect(body.length).toBe(8 * 1024);
});
it("returns the raw Response when parseBody=false", async () => {
global.fetch = vi.fn(async () =>
new Response(new ArrayBuffer(8), {
status: 200,
headers: { "content-type": "application/pdf" },
}),
) as unknown as typeof fetch;
const result = await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/pdf",
{ method: "POST" },
"quotes.get_pdf",
{ parseBody: false },
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).headers.get("content-type")).toBe(
"application/pdf",
);
});
it("respects the per-tool hint override", async () => {
global.fetch = vi.fn(async () => {
throw Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect ECONNREFUSED"), {
code: "ECONNREFUSED",
}),
});
}) as unknown as typeof fetch;
const caught = await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
{
hint: "Submission cannot fail with ECONNREFUSED — the server is the issue.",
nextActions: ["Restart the dev server"],
},
).catch((e) => e);
expect(caught).toBeInstanceOf(McpUpstreamError);
const e = caught as McpUpstreamError;
expect(e.data.hint).toBe(
"Submission cannot fail with ECONNREFUSED — the server is the issue.",
);
expect(e.data.nextActions).toEqual(["Restart the dev server"]);
});
it("forwards the bearer token from AGENT_API_KEY by default", async () => {
const originalKey = process.env.AGENT_API_KEY;
process.env.AGENT_API_KEY = "test-key-123";
try {
const fetchSpy = vi.fn(async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
global.fetch = fetchSpy as unknown as typeof fetch;
await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
);
const calls = fetchSpy.mock.calls as unknown as Array<
[unknown, RequestInit | undefined]
>;
const initHeaders = calls[0]?.[1]?.headers;
const callHeaders: Headers =
initHeaders instanceof Headers
? initHeaders
: new Headers(initHeaders as HeadersInit);
expect(callHeaders.get("authorization")).toBe("Bearer test-key-123");
} finally {
process.env.AGENT_API_KEY = originalKey;
}
});
it("classifies UND_ERR_SOCKET as a socket-level hint", async () => {
global.fetch = vi.fn(async () => {
throw Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("socket hang up"), {
code: "UND_ERR_SOCKET",
}),
});
}) as unknown as typeof fetch;
const caught = await fetchUpstreamJson(
"http://localhost:4001/api/v1/quotes/abc/submit",
{ method: "POST" },
"quotes.submit",
).catch((e) => e);
expect(caught).toBeInstanceOf(McpUpstreamError);
const e = caught as McpUpstreamError;
expect(e.data.cause).toBe("UND_ERR_SOCKET");
expect(e.data.hint).toMatch(/socket was closed/i);
});
});

View file

@ -0,0 +1,310 @@
/**
* Shared upstream-fetch helper for the MCP route.
*
* Background: every write tool in `src/app/api/mcp/route.ts` does
* `fetch(${req.nextUrl.origin}/api/v1/<resource>/<op>)` and then
* `if (!res.ok) throw new Error(...)`. The thrown error gets
* caught by the JSON-RPC handler and surfaced as a bare
* `{ code: -32000, message: "fetch failed" }` — the agent sees no
* status, no upstream body, no `err.cause` chain. That makes
* "is the upstream dead or did my input get rejected" impossible
* to distinguish without a server-side log hunt.
*
* This module turns the three failure modes into a typed
* `McpUpstreamError` with a stable JSON-RPC error envelope:
*
* -32010 — fetch failed to reach the upstream (network / DNS /
* TLS / connection refused / dev server down). The
* route never ran. `data.cause` is the underlying
* node/undici error code (`ECONNREFUSED`,
* `UND_ERR_SOCKET`, `ENOTFOUND`,
* `UND_ERR_CONNECT_TIMEOUT`, etc.) and `data.status`
* is null.
* -32011 — upstream responded with a non-2xx status. The route
* ran and rejected the input (or the upstream itself
* is sick). `data.status` is the HTTP status and
* `data.upstreamBody` is the route's actual response —
* hand that to the LLM verbatim.
*
* Code offset: -32001 is already used by the route's auth-handler
* catch (line 5944 of `src/app/api/mcp/route.ts`); -32002 is
* reserved for the generic-500 fallthrough. Using -32010/-32011
* stays in the server-error range but avoids the collision.
*
* `data.hint` and `data.nextActions` are supplied per tool by the
* MCP route's caller — these are the "what to do next" pointers
* the agent was missing.
*/
export const MCP_ERR_NETWORK = -32010;
export const MCP_ERR_UPSTREAM = -32011;
export interface McpUpstreamErrorData {
/** Tool name as the agent knows it (e.g. "quotes.submit"). */
tool: string;
/** The URL the MCP route tried to reach. Useful for the
* "is the dev server actually running on this port" check. */
url: string;
/** HTTP method. */
method: string;
/** Upstream status if the response came back, null otherwise. */
status: number | null;
/** Node-style error code from `err.cause.code` if the fetch
* itself failed (e.g. "ECONNREFUSED"). Null on -32002. */
cause: string | null;
/** `err.cause.message` from the underlying fetch failure.
* Usually "fetch failed" — unhelpful, but kept for the case
* where the underlying client surfaces more. */
causeMessage: string | null;
/** Parsed JSON body when the upstream returned a 4xx/5xx with
* JSON content. Falls back to the raw text on parse failure. */
upstreamBody: unknown | null;
/** Set to true when `upstreamBody` was truncated to fit the
* 8 KB cap. The original body is dropped after the cap since
* the JSON-RPC error could otherwise explode the agent's
* context. */
truncated: boolean;
/** Per-tool hint explaining the failure mode. */
hint: string;
/** Operator-side follow-up actions. */
nextActions: string[];
}
export class McpUpstreamError extends Error {
readonly code: typeof MCP_ERR_NETWORK | typeof MCP_ERR_UPSTREAM;
readonly data: McpUpstreamErrorData;
constructor(
code: typeof MCP_ERR_NETWORK | typeof MCP_ERR_UPSTREAM,
message: string,
data: McpUpstreamErrorData,
) {
super(message);
this.name = "McpUpstreamError";
this.code = code;
this.data = data;
}
}
export interface FetchUpstreamOptions {
/** When false, return the raw `Response` on success instead of
* the parsed JSON body. Use for endpoints that return binary
* (PDFs, images, etc.). Default true. */
parseBody?: boolean;
/** Override the default hint for this tool. */
hint?: string;
/** Override the default nextActions for this tool. */
nextActions?: string[];
/** Bearer token to forward upstream. Defaults to
* `process.env.AGENT_API_KEY ?? ""` — callers should rely on
* the default unless the tool has a different auth path. */
bearer?: string;
}
/** Cap to keep the JSON-RPC error envelope bounded. 8 KB is
* plenty for a structured 422 envelope and well under the
* agent's context-window budget. Larger bodies are truncated
* with `truncated: true` so the agent knows it didn't see
* everything. */
const UPSTREAM_BODY_MAX_BYTES = 8 * 1024;
const DEFAULT_HINT =
"The MCP server could not reach its own /api/v1 route. The upstream is unreachable.";
const DEFAULT_NEXT_ACTIONS = [
"Verify the dev/prod server is running and reachable on the expected port",
"If running behind a reverse proxy, check that X-Forwarded-* headers are configured",
"If status is 401, AGENT_API_KEY in the deployment env may have rotated",
];
const DEFAULT_UPSTREAM_HINT =
"The upstream route returned a non-2xx status. data.upstreamBody is the route's actual response — pass it to the LLM as the canonical answer.";
const DEFAULT_UPSTREAM_NEXT_ACTIONS = [
"Read `data.upstreamBody` — that's the route's actual response",
"Do NOT retry blindly; fix the input and resend",
"If status is 500 + upstreamBody.error mentions a select field, the PB schema needs a new value (see AGENTS.md)",
];
/**
* Wrap a node/undici fetch failure into a human-readable cause
* code. The wire-level exception is `TypeError: fetch failed` with
* `err.cause` carrying the underlying socket/DNS/TLS error. We
* surface the cause code (e.g. ECONNREFUSED) so the agent can
* decide whether to retry.
*/
function extractCause(err: unknown): {
cause: string | null;
causeMessage: string | null;
hint: string;
nextActions: string[];
} {
const e = err as { cause?: { code?: string; message?: string } };
const code = e?.cause?.code ?? null;
const msg = e?.cause?.message ?? null;
// Best-effort hints based on the most common undici / node error
// codes. The agent gets the most actionable next-step without
// having to translate "fetch failed" into context.
switch (code) {
case "ECONNREFUSED":
return {
cause: code,
causeMessage: msg,
hint: "Connection refused — the upstream /api/v1 route is unreachable. Likely the dev/prod server is not running on this port.",
nextActions: [
"Check that the dev server is running on the expected port (default 4001 in dev)",
"Verify req.nextUrl.origin resolves to the same host as the running server",
"If running in production, check the deployer's health check",
],
};
case "ENOTFOUND":
return {
cause: code,
causeMessage: msg,
hint: "DNS lookup failed — req.nextUrl.origin's hostname could not be resolved.",
nextActions: [
"Verify the hostname in req.nextUrl.origin is reachable from this process",
"Check container DNS / /etc/hosts in the deploy environment",
],
};
case "UND_ERR_SOCKET":
case "ECONNRESET":
return {
cause: code,
causeMessage: msg,
hint: "The upstream socket was closed mid-response. Likely a reverse-proxy timeout or a server-side crash.",
nextActions: [
"Check the upstream server logs for a crash or timeout",
"Look for an upstream timeout (nginx: 60s default; raise if this is a long-running route)",
],
};
case "UND_ERR_CONNECT_TIMEOUT":
case "ETIMEDOUT":
return {
cause: code,
causeMessage: msg,
hint: "Connection to the upstream timed out. The route may be slow or the server overloaded.",
nextActions: [
"Retry after a short delay",
"If persistent, check the upstream server's CPU / event-loop saturation",
],
};
default:
return {
cause: code,
causeMessage: msg,
hint: DEFAULT_HINT,
nextActions: DEFAULT_NEXT_ACTIONS,
};
}
}
/** Read the response body in a best-effort way: try JSON first,
* fall back to raw text, cap at UPSTREAM_BODY_MAX_BYTES so the
* error envelope stays bounded. */
async function readResponseBody(
res: Response,
): Promise<{ body: unknown; truncated: boolean }> {
// Read the raw bytes so we can both cap and try-JSON.
const buf = await res.text();
if (buf.length > UPSTREAM_BODY_MAX_BYTES) {
// Truncate without trying to parse — the JSON is likely
// broken at the cut anyway.
return {
body: buf.slice(0, UPSTREAM_BODY_MAX_BYTES),
truncated: true,
};
}
// Try JSON first; fall back to the raw text.
try {
return { body: JSON.parse(buf), truncated: false };
} catch {
return { body: buf, truncated: false };
}
}
/**
* Run `fetch(url, init)` with the standard MCP write-tool pattern:
* - forward `AGENT_API_KEY` as bearer
* - check res.ok
* - parse JSON (or return the raw Response if `parseBody: false`)
* - on failure, throw an `McpUpstreamError` with the structured
* data envelope
*
* Callers should `catch (err) { if (err instanceof McpUpstreamError)
* ... }` at the JSON-RPC layer to translate to the JSON-RPC error
* envelope.
*/
export async function fetchUpstreamJson(
url: string,
init: RequestInit,
tool: string,
options: FetchUpstreamOptions = {},
): Promise<unknown> {
const {
parseBody = true,
hint,
nextActions,
bearer = process.env.AGENT_API_KEY ?? "",
} = options;
// Always forward the bearer — the MCP route is the agent's
// trust boundary, but the upstream /api/v1 routes also enforce
// a bearer for safety (defence in depth).
const headers = new Headers(init.headers);
if (bearer) {
headers.set("authorization", `Bearer ${bearer}`);
}
const upstreamInit: RequestInit = { ...init, headers };
let res: Response;
try {
res = await fetch(url, upstreamInit);
} catch (err) {
const cause = extractCause(err);
throw new McpUpstreamError(MCP_ERR_NETWORK, "Upstream fetch failed", {
tool,
url,
method: upstreamInit.method ?? "GET",
status: null,
cause: cause.cause,
causeMessage: cause.causeMessage,
upstreamBody: null,
truncated: false,
hint: hint ?? cause.hint,
nextActions: nextActions ?? cause.nextActions,
});
}
if (!res.ok) {
const { body, truncated } = await readResponseBody(res);
throw new McpUpstreamError(
MCP_ERR_UPSTREAM,
`Upstream returned ${res.status}`,
{
tool,
url,
method: upstreamInit.method ?? "GET",
status: res.status,
cause: null,
causeMessage: null,
upstreamBody: body,
truncated,
hint: hint ?? DEFAULT_UPSTREAM_HINT,
nextActions: nextActions ?? DEFAULT_UPSTREAM_NEXT_ACTIONS,
},
);
}
if (!parseBody) {
return res;
}
// 2xx — parse the JSON body (or return the raw text if it's
// not JSON, which is itself a hint that the route didn't shape
// its response per the MCP convention).
try {
return await res.json();
} catch {
return await res.text();
}
}

View file

@ -1713,6 +1713,13 @@ export interface ItemIntelQuoteRow {
quantity: number;
quoted_at: string;
days_ago: number;
/** PB id of the supplier bound to the source quote_item. NULL when
* the quote was sourced supplier-agnostic (e.g. before a supplier
* was chosen). */
supplier_id?: string;
/** Resolved supplier display name. Empty when the supplier id no
* longer resolves. */
supplier_name?: string;
}
/** One row in the Recent Order — fuzzy-matched past order_items row. */