diff --git a/docs/agents/hermes-skill.md b/docs/agents/hermes-skill.md index 31ed2b3..b34d6fc 100644 --- a/docs/agents/hermes-skill.md +++ b/docs/agents/hermes-skill.md @@ -207,6 +207,78 @@ just follow it. the live shapes live in `src/app/api/v1//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: diff --git a/src/app/api/mcp/FEATURE-REQUESTS.md b/src/app/api/mcp/FEATURE-REQUESTS.md index dabaf30..8c0dc2a 100644 --- a/src/app/api/mcp/FEATURE-REQUESTS.md +++ b/src/app/api/mcp/FEATURE-REQUESTS.md @@ -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 diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 01104d0..83e309a 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index ce60c96..be83eb7 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -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 = { + "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 = ''`})", + ], + }, + "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 } diff --git a/src/components/item-intel-popover.tsx b/src/components/item-intel-popover.tsx index 3f60231..1fae2c0 100644 --- a/src/components/item-intel-popover.tsx +++ b/src/components/item-intel-popover.tsx @@ -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, diff --git a/src/lib/server/item-intel-service.ts b/src/lib/server/item-intel-service.ts index c7a9186..f303c1c 100644 --- a/src/lib/server/item-intel-service.ts +++ b/src/lib/server/item-intel-service.ts @@ -399,12 +399,13 @@ async function buildPriceList( async function buildQuoteHistory( pb: Awaited>, target: { id: string; description: string }, + supplierNameById: Map, ): Promise { 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), ]); diff --git a/src/lib/server/mcp-upstream-fetch.test.ts b/src/lib/server/mcp-upstream-fetch.test.ts new file mode 100644 index 0000000..f66b38b --- /dev/null +++ b/src/lib/server/mcp-upstream-fetch.test.ts @@ -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("502 Bad Gateway", { + 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("502 Bad Gateway"); + }); + + 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); + }); +}); diff --git a/src/lib/server/mcp-upstream-fetch.ts b/src/lib/server/mcp-upstream-fetch.ts new file mode 100644 index 0000000..1a4f571 --- /dev/null +++ b/src/lib/server/mcp-upstream-fetch.ts @@ -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//)` 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 { + 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(); + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index bb57f0b..7a7bddc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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. */