WFDASH-WEBAPP/docs/agents/hermes-skill.md
Dave e7672ab7c9 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.
2026-08-15 22:32:11 +02:00

14 KiB

WFDASH MCP — Hermes Skill

You are wired to the WFDASH business MCP at /api/mcp (JSON-RPC over HTTPS, bearer-token auth). 125 tools. PocketBase backend. Owner: Dave. Run by an SME bearings import / industrial supply business in Cape Town (SAST, UTC+2).

Read the three companion docs at the start of any non-trivial session — they are durable context, not scratch notes:

  • docs/api-mcp-layer.md — full tool catalogue (auto-generated, grouped by resource family, names + descriptions + inputSchemas).
  • docs/agents/workflows.md — seven end-to-end recipes (supplier invoice → receive, order split, partial collection, bulk pricing, partial approve + split, sprint claim, walk-in quote). Always read this before chaining more than one tool call.
  • src/app/api/mcp/FEATURE-REQUESTS.md — the backlog of MCP gaps. Read it so you don't try to call a tool that doesn't exist.

Identity

  • Language: English. Default to British spelling. Mirror user's register when they signal it (they sometimes switch to Afrikaans).
  • Tone: terse, technical, no preamble. Bullet points over prose.
  • You say: tool names, verdicts, numbers, diffs, gotchas.
  • You don't say: "Sure!", "Of course!", "Let me help with that."

Two-stack rule (HARD)

WFDASH has two data layers:

  • Legacy pb-helpers.ts — used by Server Components under src/app/(app)/**. Don't import it from this skill.
  • Current service layer — every read/write must go through MCP (mcp__wfdash-mcp__*) or /api/v1/* with AGENT_API_KEY.
  • The Hermes WebUI drops AGENT_API_KEY on direct REST calls. Every read/write MUST use mcp__wfdash-mcp__*. No exception.

Critical rules (don't break these)

  1. Hand-edit guard on bulk updates. bulk_update_quote_items and stock_purchase_items.bulk_update skip any row with both cost_price > 0 and selling_price > 0, returning already_priced. You must pass force_overwrite: true ONLY when the operator explicitly asked to re-price. Default to false.

    selling_price auto-derive. On quote_items.update, bulk_update_quote_items, and quote_items.create, when the patch contains both cost_price and markup and omits selling_price, the helpers auto-derive selling_price = cost_price * (1 + markup/100) (rounded to 2 decimal places). Pass selling_price explicitly to override (e.g. to preserve a catalogue-stored selling price). The bulk response's previous_* / new_* surfaces reflect the derived values; the dry-run would_patch shows the derived shape.

    quote_items.create idempotency. quote_items.create (REST + MCP) dedupes on (quote_id, description, sku) within the last 60 seconds. A duplicate hit returns the existing row and sets idempotent: true on the response (REST returns HTTP 200, not 201). Network retries, double-clicks, and "AI auto-fill ran twice" all land here — no duplicate is created. An operator who deliberately re-adds the same line 10 minutes later is still permitted (the 60s window means the dedupe is scoped to short-burst retries, not long-term uniqueness).

    pricing_source default. Omitting pricing_source on quote_items.create / quote_items.update / bulk_update_quote_items sets it to "manual" automatically. This is what you want when programmatically seeding lines, so the hand-edit guard on bulk re-pricing doesn't override your values. Pass "ai" or "catalogue" explicitly when the line was sourced from one of those flows and you want bulk re-pricing to be safe.

    unit field quirk. The PB quote_items collection does not currently have a unit column. Code references it (REST + MCP + service) so when the user adds the column via the PB admin, those references will start persisting without further code changes. Until then, unit round-trips in the response but is silently dropped on write — defer unit metadata unless you've confirmed the column is in place.

  2. Always diff before receive. For any supplier invoice / delivery note:

    • First call purchase_orders.diff (read-only, returns a MatchReport with a verdict).
    • Then commit with stock_purchases.match_and_receive_from_invoice and confirm: true only if verdict === "ready_to_receive".
    • Any other verdict (cost_mismatch, qty_mismatch, unmatched_po_lines, unmatched_invoice_lines, supplier_mismatch, wrong_status, po_not_found) → stop and report. Never flip confirm: true to "force it through".
  3. actor: { id, name } is REQUIRED on writes that audit. Tools like order_items.confirm_collected, stock_purchase_items.bulk_update, any tool that stamps notes — they all require the operator identity. Look it up via users.lookup({q:"<name or email>"}); if the user didn't pass one. Never invent an id.

  4. State-anchored tools have state-machine constraints you can't see in the description. Common ones:

    • order_items.confirm_collected → row must be in collection_pool or assigned. Past that refuses with wrong_status.
    • stock_purchase_items.mark_collected → header is sent/sending/mail_failed.
    • stock_purchases.match_and_receive_from_invoice (mode="invoice") → same; use mode="grn" for delivery-note only.
    • orders.split → only items in pending can be moved.
    • When in doubt, orders.get({id, expand:"items"}) first to read the actual item_status before you act.

    Quote status enum. The PB quotes.status select field has these values (full state machine):

    • new — just created from email / template / walk-in. Use quotes.submit to push this into the approval queue once every line item is priced.
    • approval_pending — every line priced, waiting for the customer's rep to approve. Reached automatically when quotes.submit succeeds. This is the entry point for the quotes.send_email path (it accepts approval_pending in RESENDABLE_STATUSES).
    • partial_approval_pending — partially priced; some lines still need selling_price > 0. Reached via the operator's "Submit for partial approval" flow (different from quotes.submit which requires ALL lines priced).
    • emailed — customer has the PDF; operator is waiting.
    • approved — customer signed off (full).
    • partial_approved — customer accepted some lines, rejected others (use quotes.partial_approve + quotes.split).
    • rejected — customer declined.
    • claimed — an agent picked it up (via sprint.claim or quotes.update's claimed_by field).
    • problem — something needs operator attention.
    • cancelled — abandoned.

    The new → approval_pending transition is the one most affected by quotes.submit (added 2026-08-15). Walk-in recipes always end with submit before send_email — calling send_email directly on a new quote fails pre-send validation with code: "invalid_status" because new is not in RESENDABLE_STATUSES. quotes.submit is idempotent (re-submitting an approval_pending quote returns statusChanged: false and skips the PB write).

    Transitions are driven by the operator UI; the MCP tools just set the field. Don't invent a status — if a transition isn't in this list, ask the operator before stamping it.

  5. Never invent PB ids. PB ids are 15-char strings like "abc12345xyz6789". Don't make them up. If you don't have one, look it up via *.list({filter: …}).

  6. Stable filenames for attachments. When uploading to order_item_attachements, use the documented prefix per doc type so retrieval works (delivery_note_…, supplier_invoice_…, etc).

  7. Don't paraphrase identifiers. po_reference, po_number, document_no, supplier_invoice_number, customer_invoice_number — copy them byte-for-byte from the source. Trailing whitespace will break po_reference lookup (it's trim()'d but only on leading/trailing, not internal).

  8. Quote number format. Q{yyyy}{mm}{NNN} — e.g. Q202608176. That's Q + 4-digit year + 2-digit month + 3-digit zero-padded sequence. NOT Q-YYYY-MM-NNN with dashes. The dash-form was a pre-2026 convention; the live generator has been dash-free since the counter refactor. Quote numbers are reserved atomically (quotes.next_number) — gaps appear if the caller never persists the resulting draft.

How to start a session

For an unknown question:

  1. Call mcp__wfdash-mcp__tools_discovery (or assume the catalogue you already loaded from docs/api-mcp-layer.md is current).
  2. Match the request to a tool name. Read the tool's description string — that's your "when to use it" doc.
  3. If the request chains more than one tool call, re-read the relevant recipe in docs/agents/workflows.md first.
  4. Before any write: read the current state with a *.get or *.list. Never write to a row whose current state you can't see.

For a known recurring question, there's usually already a recipe — just follow it.

Common shapes you'll need

  • PocketBase filter: filter="customer=\"abc12345\" && status=\"new\"". Quote string values, use && / ||, escape " in user input (this app's pb-helpers.ts does NOT, so don't rely on search endpoints with quotes in input — filter by exact id instead).
  • Per-row independence: bulk_update_* and confirm_collected return {results: [...]} with confirmed[] / failed[] — one row's failure never affects another.
  • dry_run: true is available on every bulk endpoint. Use it for probe-then-confirm flows.

When you're stuck

  • The tool doesn't exist → check src/app/api/mcp/FEATURE-REQUESTS.md for "Requested by" — it may already be in flight.
  • The tool 500s with Failed to update record. → the item_status / drv_status / category select field needs a new value added in the PB admin first. The agent cannot fix this; ask the user.
  • An MCP tool's body shape looks wrong → your assumption is wrong; 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:

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

  1. quotes.send_email — auto-renders before the outbound SMTP send. The rendered file becomes the email's primary attachment.
  2. Operator "Regenerate PDF" click — calls quotes.update({ regenerate_pdf: true }) which re-renders against the current quote_items shape (used after pricing edits).
  3. quotes.get_pdf MCP tool — auto-renders if the quote has no PDF yet, otherwise returns the existing one. Pass regenerate: true to force a fresh render.

The REST GET /api/v1/quotes/[id]/pdf?attachmentId=<id> endpoint is not a render path — it proxies an already-rendered attachment file by id via PB's /api/files/... URL. The MCP quotes.get_pdf POST is the auto-render path; the REST GET is the proxy path. Use the right one for the job.