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:
parent
151ae8d67f
commit
e7672ab7c9
9 changed files with 931 additions and 77 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
|
||||
|
|
|
|||
257
src/lib/server/mcp-upstream-fetch.test.ts
Normal file
257
src/lib/server/mcp-upstream-fetch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
310
src/lib/server/mcp-upstream-fetch.ts
Normal file
310
src/lib/server/mcp-upstream-fetch.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue