diff --git a/docs/agents/workflows.md b/docs/agents/workflows.md new file mode 100644 index 0000000..1e974bd --- /dev/null +++ b/docs/agents/workflows.md @@ -0,0 +1,229 @@ +# MCP Workflow Cookbook + +End-to-end recipes for the most common operator requests that come +through the WFDASH MCP. Each recipe lists the goal, the tool calls +in order, and the gotchas — so an agent that has never seen the +business before can drive the workflow to completion without further +prompting. + +> Tool names below are the MCP tools. Inputs match the `inputSchema` +> you get from `tools.discovery`. Output shapes are described in +> `docs/api-mcp-layer.md` §3.3. + +--- + +## 1. "I have a supplier invoice — what do I do?" + +**Goal:** take a supplier's invoice PDF (or delivery note), match it +to the right PO, and book the goods into stock. + +**Sequence:** + +1. **Identify the PO** — read "Your Ref" / "Order No" from the + document. It's either a stock PO (`PO{yy}{6d-seq}STK`) or a + customer PO (`PO-YYYY-MM-NNN`). +2. **Diff first** (read-only) — `purchase_orders.diff({ + po_reference, document: { document_no, date, lines: [...] }, + mode: "invoice" | "grn" })`. Returns a `MatchReport` with a + verdict + per-line `LineComparison[]`. +3. **Decide:** + - `verdict === "ready_to_receive"` → commit with + `stock_purchases.match_and_receive_from_invoice({ po_reference, + document, attachment, confirm: true })`. The same call attaches + the scanned invoice to the PO. + - Any other verdict (`cost_mismatch`, `qty_mismatch`, + `unmatched_po_lines`, `unmatched_invoice_lines`, + `supplier_mismatch`, `wrong_status`, `po_not_found`) → **stop and + report**. Never `confirm: true` with a non-clean verdict. +4. **For stock POs only** — the invoice receive books stock into + `stock_items.current_qty` and writes a `stock_ledger` row. + `stock_purchases.receive` is the lower-level tool for partial + receives; the diff+match path handles the common case in one call. +5. **For customer POs** — the receive path flips `order_items. + item_status` to `collected` and stamps `supplier_invoice_number`. + Use `collection_confirm.upload_invoice` if the invoice wasn't + attached during step 3, then `collection_confirm.confirm_order_items` + to move the lines to `ready_invoice`. + +**Gotchas:** + +- `mode: "grn"` is delivery-note mode — skips price validation, + accepts missing/zero prices. Use when the supplier sends a packing + slip before the invoice. +- The matched `document_no` becomes the `supplier_invoice_number` + on the order_items. Get it right; don't paraphrase. +- `confirm: true` with a non-clean verdict **silently refuses** — + you get a report back but no write. Read the verdict before + flipping `confirm`. + +--- + +## 2. "Split this order into a continuation" + +**Goal:** some lines on the order are stuck (waiting on a +back-ordered part); the rest can ship. Move the ready lines onto a +fresh ORD number. + +**Sequence:** + +1. `orders.get({ id, expand: "items" })` — confirm which lines are + `item_status === "pending"` (only pending can be moved). +2. `orders.split({ order_id, item_ids: [] })`. + Returns the new continuation order's ORD number. The parent order + keeps its number; each moved item gets a breadcrumb in `notes`. + +**Gotchas:** + +- Any item not in `pending` refuses silently (it would orphan a PO + or driver assignment). Filter the candidates down to `pending` + before splitting — `order_items.list({ filter: `order="" && + item_status="pending"` })`. +- The continuation order inherits customer, company, rep, contact — + no need to re-state them. +- If you only have one line to move, splitting is overkill. Just + update the parent. + +--- + +## 3. "Confirm collection on a partial delivery" + +**Goal:** the customer received 8 of 10 units today; 2 are on a +back-order. Mark the row `collected` with the right `qty_collected` +without losing the partial quantity. + +**Sequence:** + +1. `order_items.get({ id, expand: "order" })` — read the current + `quantity` and `qty_collected`. The `quantity_collected` parameter + is the amount to **add**, not the new total. +2. Compute `to_add = received_qty - qty_collected` (clamp to ≥ 0). +3. `order_items.confirm_collected({ + item_ids: [""], + quantity_collected: { "": }, + note: "Partial delivery 8 of 10, 2 back-ordered", + actor: { id: "", name: "" } + })`. + +**Gotchas:** + +- Eligibility: row must be in `collection_pool` or `assigned`. + Anything past the pool refuses with `wrong_status`. +- `actor` is **required** — it's stamped on every audit entry. Look + it up via `users.lookup({ q: "" })` if you don't have the id. +- `note` is optional but recommended for partial receives — the + default "agent confirmed" note loses information about why only + part was received. + +--- + +## 4. "Bulk-price a quote that just landed" + +**Goal:** the AI has extracted items but didn't price them. Fill +in cost + selling prices from the catalogue in one MCP round-trip. + +**Sequence:** + +1. `quote_items.list({ filter: `quote=""`, + expand: "matched_price_list_id" })` — fetch every line. +2. For each line with `matched_price_list_id` populated, read the + catalogue's `cost_price` and `markup`. +3. `bulk_update_quote_items({ items: [ + { id, patch: { cost_price, selling_price, pricing_source: "ai", + pricing_notes: " by : matched catalogue row" }, + force_overwrite: false }, ... + ]})`. +4. `quotes.re_price({ id: , items: [...] })` — same call + shape, scoped to one quote; useful when the agent wants to keep + the diff inside the quote context. + +**Gotchas:** + +- `force_overwrite: false` is the safe default — rows with + `cost_price > 0 AND selling_price > 0` are skipped with + `already_priced`. Pass `true` only when the operator explicitly + asked to re-price. +- `pricing_notes` is required for the audit trail. The agent identity + + date is yours to stamp — the tool does NOT auto-inject it. +- `selling_price` is auto-derived from `cost_price * (1 + markup/100)` + if omitted. Pass it explicitly when the catalogue has a stored + selling price you want to preserve. +- Whitelist (both bulk and re-price): `cost_price`, `selling_price`, + `description`, `sku`, `unit`, `supplier`, `category`, `markup`, + `pricing_source`, `pricing_notes`, `matched_price_list_id`, + `user_edited`. Anything else returns `forbidden_field`. + +--- + +## 5. "Customer rejected some lines — partial approve + split" + +**Goal:** the customer accepts 6 of 8 lines. Approve the 6 on the +original quote, clone the rejected 2 into a fresh quote for +re-pricing. + +**Sequence:** + +1. `quotes.get({ id, expand: "items" })` — read all line ids. +2. `quotes.partial_approve({ quote_id, decisions: [ + { item_id: "", decision: "approved" }, + { item_id: "", decision: "rejected", + note: "Customer preferred a different supplier" }, + ... ]})`. +3. `quotes.split({ source_quote_id, groups: [ + { name: "Re-quote rejected lines", item_ids: ["", + ""] } ]})`. +4. `quotes.re_price({ id: , items: [...] })` — adjust + pricing on the continuation. + +**Gotchas:** + +- `quotes.partial_approve` records the customer's choice but doesn't + move the items. To action them, follow with `quotes.split`. +- Splitting preserves the rejected lines on a new quote with the same + customer + company + letterhead, so a `re_price` round feels like + a continuation, not a fresh RFQ. +- If the customer rejected everything, prefer `quotes.clone` and + starting over — splitting into a single empty group is a no-op. + +--- + +## 6. "Find sprint-pricing items I can claim" + +**Goal:** the buyer-side sprint shows items waiting for pricing +proofs. Find one that's unclaimed and lock it for 30 minutes. + +**Sequence:** + +1. `sprint.list({ filter: `claim_status=null || claim_expires_at<"now"`, + expand: "quote_item.quote", sort: "-created" })` — list unclaimed + sprint items. +2. Pick one — prefer items with `quote_item.quote.customer` already + known (faster resolution). +3. `sprint.claim({ id: , claimed_by: "" })`. + Returns 409 if another agent already owns it. + +**Gotchas:** + +- The claim is 30-min default — finish your work before it expires, + or re-claim to extend. +- `sprint.attachment_counts({ item_ids: [...] })` is the bulk + counterpart to `sprint.item_attachments` — use it to render + paperclip badges in any list view without N round-trips. +- After pricing, use `bulk_update_quote_items` to push the new + prices; the sprint item auto-resolves when the linked + `quote_item.user_edited` flips to `true`. + +--- + +## Quick reference: tool families by workflow + +| Workflow | Primary tools | +| --- | --- | +| Supplier invoice | `purchase_orders.diff`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.receive` (partial) | +| Order split | `orders.get`, `order_items.list`, `orders.split` | +| Partial collection | `order_items.get`, `order_items.confirm_collected`, `users.lookup` (for actor) | +| Bulk pricing | `quote_items.list`, `bulk_update_quote_items`, `quotes.re_price` | +| Partial approve | `quotes.get`, `quotes.partial_approve`, `quotes.split`, `quotes.re_price` | +| Sprint claim | `sprint.list`, `sprint.claim`, `bulk_update_quote_items` | + +For the full tool catalogue (auto-generated), see +[`docs/api-mcp-layer.md`](../api-mcp-layer.md). \ No newline at end of file diff --git a/docs/api-mcp-layer.md b/docs/api-mcp-layer.md index 0853e63..d0327e6 100644 --- a/docs/api-mcp-layer.md +++ b/docs/api-mcp-layer.md @@ -1,462 +1,709 @@ -# WFDASH API + MCP Layer - -This document describes the public HTTP surface intended for external -automation (n8n workflows, custom AI agents, the local "Hermes" agent, -etc.). Everything below is implemented in this repo and exercised by -the Vitest suite under `src/app/api/`. - -> The README still lists an older subset of this surface. This file is -> the source of truth. If you add or rename an endpoint or MCP tool, -> update **both** files in the same commit. - ---- - -## 1. Environment - -Add to `.env.local`: - -```bash -NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io -PB_ADMIN_EMAIL=david.els1@outlook.com -PB_ADMIN_PASSWORD=1234567890 -AGENT_API_KEY=123456 -ORDER_ITEMS_COLLECTION=order_items # or `order_items_v2` after migration -``` - -The MCP endpoint and every `/api/v1/*` route (except `/api/v1/health`) -require the agent bearer token: - -``` -Authorization: Bearer -``` - -Rotate `AGENT_API_KEY` in this file plus every consumer when a key -leaks. - ---- - -## 2. REST endpoints (`/api/v1/*`) - -All routes return JSON. All `PATCH` / `POST` bodies are JSON unless -noted. Pagination defaults to `page=1`, `perPage=50` where relevant. - -### 2.1 Health / discovery - -| Method | Path | Auth | Notes | -| --- | --- | --- | --- | -| `GET` | `/api/v1/health` | none | `{ ok, service, time, version, env }` | - -### 2.2 Orders - -| Method | Path | Body / params | Notes | -| --- | --- | --- | --- | -| `GET` | `/api/v1/orders` | `?page&perPage&status&search` | Status filter is informational; items drive workflow. Expands `company,customer,quote`. | -| `POST` | `/api/v1/orders/from-quote` | `{ "quote_id": "..." }` | Creates order + items; idempotent on existing order_for_quote. | -| `GET` | `/api/v1/orders/{id}` | — | Order detail with item rows. | -| `GET` | `/api/v1/orders/{id}/matched-attachments` | — | Returns attachment matches against quote items. | - -### 2.3 Order items - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `PATCH` | `/api/v1/order-items/{id}/status` | `{ item_status?, drv_status?, notes?, problem_notes? }` | Accepts legacy `driver_status` alias. | - -### 2.4 Quotes - -| Method | Path | Body / params | Notes | -| --- | --- | --- | --- | -| `GET` | `/api/v1/quotes/lookup` | `?q=...` | Fuzzy lookup by quote number, ref, customer. | -| `GET` | `/api/v1/quotes/next-number` | — | Atomic `Q-YYYY-MM-NNN` from `quote_counters`. | -| `POST` | `/api/v1/quotes/{id}/clone` | — | Clone a quote + items. | -| `POST` | `/api/v1/quotes/{id}/save-as-template` | `{ name }` | Save a quote as a template. | -| `POST` | `/api/v1/quotes/{id}/create-from-template` | `{ template_id }` | New quote from a template. | -| `POST` | `/api/v1/quotes/{id}/retry-email` | — | Resend the outbound quote email. | -| `POST` | `/api/v1/quotes/{id}/send-email` | `{ template? }` | Send the outbound quote email. | -| `GET` | `/api/v1/quotes/{id}/pdf` | — | Render the quote PDF. | -| `POST` | `/api/v1/quotes/{id}/re-price` | `{ items: [...] }` | Bulk re-price quote items. | -| `GET` | `/api/v1/quotes/{id}/attachments` | `?direction=inbound\|outbound\|generated\|all` | List quote-level attachments. | -| `POST` | `/api/v1/quotes/{id}/attachments/upload` | multipart `file` | Upload a quote-level attachment (admin). | -| `GET` | `/api/v1/quotes/{id}/items/{itemId}/match` | — | Match a quote item to a price-list row. | -| `GET` | `/api/v1/quotes/{id}/items/{itemId}/lookup` | — | Suggest matches for an item. | -| `POST` | `/api/v1/quotes/items/rewrite` | `{ items: [...] }` | AI rewrite item descriptions. | - -### 2.5 Quote item attachments - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `POST` | `/api/v1/quotes/items/{id}/attachments/upload` | multipart `file` | New manual-upload endpoint (source = `"manual"`). | - -### 2.6 Sprint - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `POST` | `/api/v1/sprint/items/{id}/claim` | `{ ttl_minutes? }` | Claim a sprint row (30-min default lock). | -| `GET` | `/api/v1/sprint/items/{id}/attachments` | — | List attachments for a quote item. | -| `POST` | `/api/v1/sprint/items/{id}/attachments/upload` | multipart `file` | Upload a pricing proof (operator or AI). | -| `GET` | `/api/v1/sprint/attachment-counts` | `?quote_item_ids=...` | Bulk counts for sprint badges. | - -### 2.7 Companies - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `GET` | `/api/v1/companies` | `?page&perPage&search&sort` | Filter on `company` / `vendor` substring. | -| `POST` | `/api/v1/companies` | `{ company, address?, vendor?, coordinates?: {lon,lat} }` | `company` required. | -| `GET` | `/api/v1/companies/{id}` | — | Single row. | -| `PATCH` | `/api/v1/companies/{id}` | partial of `create` body | | -| `DELETE` | `/api/v1/companies/{id}` | — | PB cascade rules apply. | - -### 2.8 Customers - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `GET` | `/api/v1/customers` | `?page&perPage&search&sort&expand` | `expand=company,rep` to inline. | -| `POST` | `/api/v1/customers` | `{ customer, email?, alternative_email?, buyer_email?, number?, company?, rep?, coordinates?, description? }` | `customer` required. `company` / `rep` accept id or name. | -| `GET` | `/api/v1/customers/{id}` | `?expand` | | -| `PATCH` | `/api/v1/customers/{id}` | partial | Empty string unsets `company` / `rep`. | -| `DELETE` | `/api/v1/customers/{id}` | — | Cascade rules apply. | - -### 2.9 Suppliers - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `GET` | `/api/v1/suppliers` | `?page&perPage&search&sort` | `coordinates` is the legacy `"lat, lng"` text. | -| `POST` | `/api/v1/suppliers` | `{ suppliername, email?, suppliernumber?, supplieraddress?, supply_tags?, coordinates?, closingtime? }` | | -| `GET` | `/api/v1/suppliers/{id}` | — | | -| `PATCH` | `/api/v1/suppliers/{id}` | partial | | -| `DELETE` | `/api/v1/suppliers/{id}` | — | Cascade rules apply. | - -### 2.10 Collection pool / map - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `GET` | `/api/v1/collection-pool` | `?page&perPage&status` | Pool status filter. | -| `POST` | `/api/v1/collection-map/items/{id}/assign` | `{ driver_id }` | Assign a pool row to a driver. | - -### 2.11 Purchase orders - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `POST` | `/api/v1/purchase-orders/{id}/dispatch` | `{ channel: "email"\|"print" }` | Dispatch PO. | -| `POST` | `/api/v1/purchase-orders/{id}/send-email` | `{ to?, cc?, body? }` | Send PO email. | -| `GET` | `/api/v1/purchase-orders/{id}/send-email/outcome` | — | Outcome of last send. | -| `GET` | `/api/v1/purchase-orders/{id}/pdf` | — | Render PO PDF. | -| `GET` | `/api/v1/purchase-orders/stale` | `?days=...` | List POs untouched for N days. | -| `POST` | `/api/v1/purchase-orders/{id}/attachments/upload` | `multipart/form-data` field `file` | Append a free-form attachment to `additional_attachments`. Max 25 MB. | -| `DELETE` | `/api/v1/purchase-orders/{id}/attachments/{filename}` | — | Remove a single additional-attachment token. | - -### 2.12 Stock purchases - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `POST` | `/api/v1/stock-purchases/{id}/attachments/upload` | `multipart/form-data` field `file` | Append a free-form attachment (supplier invoice, packing slip) to `stock_purchases.additional_attachments`. Max 25 MB. | -| `DELETE` | `/api/v1/stock-purchases/{id}/attachments/{filename}` | — | Remove a single additional-attachment token. | - -### 2.13 Users / file proxy - -| Method | Path | Body | Notes | -| --- | --- | --- | --- | -| `GET` | `/api/v1/users/lookup` | `?q=...` | User search. | -| `GET` | `/api/v1/files/{collection}/{recordId}/{filename}` | — | Auth-aware file proxy for collections whose rules reject the client. | - ---- - -## 3. MCP endpoint (`/api/mcp`) - -JSON-RPC over HTTP. Same `Authorization` bearer as `/api/v1/*`. - -### 3.1 Methods - -- `tools/list` — returns the full tool list (23 tools as of writing). -- `tools/call` — `{ params: { name, arguments } }`. - -### 3.2 Tools - -#### Orders / pool / sprint - -| Tool | Purpose | -| --- | --- | -| `orders.list` | List orders by `order_number` substring. | -| `orders.create_from_quote` | Create order + items from a quote. | -| `collection_pool.list` | List pool rows by status. | -| `order_items.update_status` | Update `item_status` / `drv_status` / notes. | -| `order_items.confirm_collected` | Mark one or more customer order_items as collected **without requiring a driver assignment**. The in-UI `/collection-pool` page only exposes "Mark Collected" after a `drv_id` is bound; this tool is the agent-side equivalent (mirrors `stock_purchase_items.mark_collected` on the stock side). Eligible from `item_status` ∈ {`collection_pool`, `assigned`}; per-row independence; bumps `qty_collected` + stamps `drv_collected_at` + appends a `category: "collection"` audit note to `notes`. Added 2026-08-11 to close the gap surfaced when goods physically arrived at the warehouse but no driver had ever been assigned. | - -#### Quotes (email pipeline) - -| Tool | Purpose | -| --- | --- | -| `quotes.create_from_email` | Create quote + customer + items from parsed email. Idempotent on `message_id`. Use `attachments.upload_for_quote` per file after this returns. | -| `quotes.append_reply` | Append a structured reply. Matches `In-Reply-To` then `Q-YYYY-MM-NNN` in subject. | -| `quotes.find_by_message_id` | Look up by RFC 5322 `Message-ID`. | -| `attachments.upload_for_quote` | Upload one attachment to a quote (base64, ≤25 MB). | -| `bulk_update_quote_items` | Patch up to 100 quote items in one call (whitelisted fields only). | - -#### Companies / customers / suppliers (full CRUD) - -| Tool | Purpose | -| --- | --- | -| `companies.list` / `.get` / `.create` / `.update` / `.delete` | Full CRUD. | -| `customers.list` / `.get` / `.create` / `.update` / `.delete` | Full CRUD. `company` / `rep` accept id or name. | -| `suppliers.list` / `.get` / `.create` / `.update` / `.delete` | Full CRUD. | - -#### Stock purchases / POs (attachments) - -| Tool | Purpose | -| --- | --- | -| `purchase_orders.add_attachment` | Upload one free-form attachment to a PO (base64, ≤25 MB). | -| `stock_purchases.add_attachment` | Upload one free-form attachment (supplier invoice, packing slip, photo of damaged stock) to a stock purchase (base64, ≤25 MB). Appends to `additional_attachments`; does NOT touch the versioned-PDF `documents` ledger. | - -#### Stock purchases (line-level writes — added 2026-08-07) - -| Tool | Purpose | -| --- | --- | -| `stock_purchase_items.update` | Patch whitelisted pricing + catalogue fields on a single `stock_purchase_item` (`cost_price`, `sku`, `description`, `notes`, `unit`, `supplier_sku`, `quantity`). Hand-edit guard on `cost_price > 0`. | -| `stock_purchase_items.bulk_update` | Batch patch (≤100 rows). Same field whitelist + hand-edit guard as `.update`. Per-row independence; `dry_run: true` returns identical shape with `status: "dry_run"` per row. | -| `stock_purchase_items.assign_driver` | Bind a line to a driver (`drv_id`). Transitions line status `null → assigned_to_driver` or `collection_pool → assigned_to_driver`. | -| `stock_purchase_items.mark_collected` | Mark a line as physically delivered. Status → `collected`, bumps `quantity_received`. **Does NOT book into stock** — that's `stock_purchases.receive`. Header auto-flips to `received` when every line on the PO has `quantity_received >= quantity`. | - -#### Stock items (catalogue) - -| Tool | Purpose | -| --- | --- | -| `stock_items.update` | Patch catalogue fields on a master `stock_item` (`sku`, `description`, `notes`, `unit`, `selling_price`, `markup`). For inventory qty use `stock_items.adjust`. | - -### 3.3 Tool input shapes (condensed) - -`quotes.create_from_email` (required: `subject`): - -```json -{ - "from_email": "buyer@acme.co.za", - "from_name": "Acme Procurement", - "subject": "RFQ — magnetic drill", - "body_plain": "...", - "body_html": "...", - "message_id": "", - "in_reply_to": "", - "received_at": "2026-07-09T10:00:00Z", - "company_hint": "Acme", - "extracted_items": [ - { "description": "Magnetic drill", "quantity": 1, "unit": "ea", "notes": "" } - ], - "attachments": [] -} -``` - -`attachments.upload_for_quote` (required: `quote_id`, `filename`, `base64`): - -```json -{ - "quote_id": "abc123", - "filename": "spec.pdf", - "content_type": "application/pdf", - "size": 482113, - "base64": "JVBERi0xLjQK..." -} -``` - -`stock_purchase_items.update` (required: `id`, `patch`): - -```json -{ - "id": "15-char-line-id", - "patch": { - "cost_price": 145.50, - "sku": "HYD68-20", - "supplier_sku": "SUP-HYD-068" - }, - "force_overwrite": false -} -``` - -Whitelist: `cost_price`, `sku`, `description`, `notes`, `unit`, -`supplier_sku`, `quantity`. Anything else returns `forbidden_field`. -Lines with `cost_price > 0` already return `already_priced` unless -`force_overwrite: true`. - -`stock_purchase_items.bulk_update` (required: `items`): - -```json -{ - "items": [ - { "id": "line-a", "patch": { "sku": "HYD68-20" } }, - { "id": "line-b", "patch": { "cost_price": 230.00 }, "force_overwrite": true } - ], - "dry_run": false -} -``` - -Returns `{ updated, skipped, errors, results: [{id, status, reason?, message?, previous?, item?}] }`. -Cap is 100 rows per call. - -`stock_purchase_items.assign_driver` (required: `id`, `drv_id`): - -```json -{ "id": "15-char-line-id", "drv_id": "15-char-driver-id", "assigned_at": "2026-08-07T08:00:00Z" } -``` - -`stock_purchase_items.mark_collected` (required: `id`): - -```json -{ "id": "15-char-line-id", "quantity_collected": 4 } -``` - -Returns `{ item, previous_quantity_received, new_quantity_received, header_status_after, header_flipped }`. -`header_flipped: true` when this call completed the parent PO (auto-flip -from `sent` → `received`). - -`stock_items.update` (required: `id`, `patch`): - -```json -{ - "id": "15-char-stock-item-id", - "patch": { - "sku": "HYD68-20", - "selling_price": 285.00, - "markup": 35 - } -} -``` - -Whitelist: `sku`, `description`, `notes`, `unit`, `selling_price`, `markup`. -Anything else returns `reason: "forbidden_field"`. For inventory qty use -`stock_items.adjust`. Returns `{ok: true, item, previous}` on success or -`{ok: false, reason, message, field?}` on rejection. - -`bulk_update_quote_items` (required: `items`): - -```json -{ - "items": [ - { - "id": "15-char-quote-item-id", - "patch": { - "cost_price": 1200.00, - "selling_price": 1800.00, - "supplier": "15-char-supplier-id", - "pricing_source": "ai", - "pricing_notes": "Auto-priced from Acme catalogue 2026-07-09" - }, - "force_overwrite": false - } - ], - "dry_run": false -} -``` - -Whitelist: `cost_price`, `selling_price`, `description`, `sku`, `unit`, -`supplier`, `markup`, `pricing_source`, `pricing_notes`, -`matched_price_list_id`, `user_edited`. Anything else returns -`reason: "forbidden_field"` per row. Pre-priced rows -(`cost_price > 0 AND selling_price > 0`) are skipped with -`reason: "already_priced"` unless `force_overwrite: true`. Cap is 100 -rows per call. - -`companies.create`: - -```json -{ "company": "Acme", "address": "1 Road", "vendor": "V-1", "coordinates": { "lon": 28.04, "lat": -26.20 } } -``` - -`customers.create`: - -```json -{ - "customer": "Acme Procurement", - "email": "buyer@acme.co.za", - "alternative_email": "ops@acme.co.za", - "buyer_email": "ap@acme.co.za", - "number": "+27 11 555 0100", - "company": "Acme", - "rep": "David", - "coordinates": { "lon": 28.04, "lat": -26.20 }, - "description": "Industrial buyer" -} -``` - -`suppliers.create`: - -```json -{ - "suppliername": "Acme Supplies", - "email": "sales@acme.co.za", - "suppliernumber": "+27 11 555 0200", - "supplieraddress": "2 Road", - "supply_tags": "tools,fasteners", - "coordinates": "-26.20, 28.04", - "closingtime": "17:00" -} -``` - -### 3.4 Response envelopes - -Success: - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "content": [{ "type": "text", "text": "{\"...\":\"...\"}" }] - } -} -``` - -Error: - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "error": { "code": -32601, "message": "Method not found" } -} -``` - -Auth failures return HTTP 401 for `/api/v1/*` but land in the -JSON-RPC error envelope for `/api/mcp` (per the in-tree test suite). - ---- - -## 4. Known quirks - -- **`orders.list` has no `status` filter.** The `orders.status` column - was removed; status lives on items now. The MCP `orders.list` tool - only supports `search` / `order_number` substring. -- **Quote-level attachments via the REST route** (`/api/v1/quotes/{id}/attachments/upload`) - and via the MCP tool (`attachments.upload_for_quote`) are - independent code paths. New agents should prefer the MCP tool. -- **Quote-item attachments** have two upload routes — the new one at - `/api/v1/quotes/items/{id}/attachments/upload` (source `"manual"`), - and the sprint one at - `/api/v1/sprint/items/{id}/attachments/upload` (source - `"operator_proof"` / `"agent_proof"`). Use the sprint route for - pricing proofs; use the quotes route for raw attachments coming in - with the original RFQ. -- **`customers.create` / `customers.update`** accept `company` and - `rep` as either ids or case-insensitive names. An unresolved name - leaves the relation empty (no error) so the operator can wire it - up later. -- **`suppliers.coordinates`** is the legacy `"lat, lng"` text field. - Don't migrate it to `{lon,lat}` — `supplier-map.ts` still parses - the text form. - ---- - -## 5. Testing the surface - -Unit/API tests: - -```bash -npm run test:run -- src/app/api -``` - -End-to-end (boots Next dev on `:3100`): - -```bash -export E2E_BASE_URL=http://localhost:3100 -export E2E_AGENT_API_KEY=$AGENT_API_KEY -npm run e2e -``` - -The Playwright suite intentionally skips env-driven specs when the -keys aren't set, so a clean checkout reports `passed, N skipped`. \ No newline at end of file +# WFDASH API + MCP Layer + +This document describes the public HTTP surface intended for external +automation (n8n workflows, custom AI agents, the local "Hermes" agent, +etc.). Everything below is implemented in this repo and exercised by +the Vitest suite under `src/app/api/`. + +> The README still lists an older subset of this surface. This file is +> the source of truth. If you add or rename an endpoint or MCP tool, +> update **both** files in the same commit. + +--- + +## 1. Environment + +Add to `.env.local`: + +```bash +NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io +PB_ADMIN_EMAIL=david.els1@outlook.com +PB_ADMIN_PASSWORD=1234567890 +AGENT_API_KEY=123456 +ORDER_ITEMS_COLLECTION=order_items # or `order_items_v2` after migration +``` + +The MCP endpoint and every `/api/v1/*` route (except `/api/v1/health`) +require the agent bearer token: + +``` +Authorization: Bearer +``` + +Rotate `AGENT_API_KEY` in this file plus every consumer when a key +leaks. + +--- + +## 2. REST endpoints (`/api/v1/*`) + +All routes return JSON. All `PATCH` / `POST` bodies are JSON unless +noted. Pagination defaults to `page=1`, `perPage=50` where relevant. + +### 2.1 Health / discovery + +| Method | Path | Auth | Notes | +| --- | --- | --- | --- | +| `GET` | `/api/v1/health` | none | `{ ok, service, time, version, env }` | + +### 2.2 Orders + +| Method | Path | Body / params | Notes | +| --- | --- | --- | --- | +| `GET` | `/api/v1/orders` | `?page&perPage&status&search` | Status filter is informational; items drive workflow. Expands `company,customer,quote`. | +| `POST` | `/api/v1/orders/from-quote` | `{ "quote_id": "..." }` | Creates order + items; idempotent on existing order_for_quote. | +| `GET` | `/api/v1/orders/{id}` | — | Order detail with item rows. | +| `GET` | `/api/v1/orders/{id}/matched-attachments` | — | Returns attachment matches against quote items. | + +### 2.3 Order items + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `PATCH` | `/api/v1/order-items/{id}/status` | `{ item_status?, drv_status?, notes?, problem_notes? }` | Accepts legacy `driver_status` alias. | + +### 2.4 Quotes + +| Method | Path | Body / params | Notes | +| --- | --- | --- | --- | +| `GET` | `/api/v1/quotes/lookup` | `?q=...` | Fuzzy lookup by quote number, ref, customer. | +| `GET` | `/api/v1/quotes/next-number` | — | Atomic `Q-YYYY-MM-NNN` from `quote_counters`. | +| `POST` | `/api/v1/quotes/{id}/clone` | — | Clone a quote + items. | +| `POST` | `/api/v1/quotes/{id}/save-as-template` | `{ name }` | Save a quote as a template. | +| `POST` | `/api/v1/quotes/{id}/create-from-template` | `{ template_id }` | New quote from a template. | +| `POST` | `/api/v1/quotes/{id}/retry-email` | — | Resend the outbound quote email. | +| `POST` | `/api/v1/quotes/{id}/send-email` | `{ template? }` | Send the outbound quote email. | +| `GET` | `/api/v1/quotes/{id}/pdf` | — | Render the quote PDF. | +| `POST` | `/api/v1/quotes/{id}/re-price` | `{ items: [...] }` | Bulk re-price quote items. | +| `GET` | `/api/v1/quotes/{id}/attachments` | `?direction=inbound\|outbound\|generated\|all` | List quote-level attachments. | +| `POST` | `/api/v1/quotes/{id}/attachments/upload` | multipart `file` | Upload a quote-level attachment (admin). | +| `GET` | `/api/v1/quotes/{id}/items/{itemId}/match` | — | Match a quote item to a price-list row. | +| `GET` | `/api/v1/quotes/{id}/items/{itemId}/lookup` | — | Suggest matches for an item. | +| `POST` | `/api/v1/quotes/items/rewrite` | `{ items: [...] }` | AI rewrite item descriptions. | + +### 2.5 Quote item attachments + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `POST` | `/api/v1/quotes/items/{id}/attachments/upload` | multipart `file` | New manual-upload endpoint (source = `"manual"`). | + +### 2.6 Sprint + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `POST` | `/api/v1/sprint/items/{id}/claim` | `{ ttl_minutes? }` | Claim a sprint row (30-min default lock). | +| `GET` | `/api/v1/sprint/items/{id}/attachments` | — | List attachments for a quote item. | +| `POST` | `/api/v1/sprint/items/{id}/attachments/upload` | multipart `file` | Upload a pricing proof (operator or AI). | +| `GET` | `/api/v1/sprint/attachment-counts` | `?quote_item_ids=...` | Bulk counts for sprint badges. | + +### 2.7 Companies + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `GET` | `/api/v1/companies` | `?page&perPage&search&sort` | Filter on `company` / `vendor` substring. | +| `POST` | `/api/v1/companies` | `{ company, address?, vendor?, coordinates?: {lon,lat} }` | `company` required. | +| `GET` | `/api/v1/companies/{id}` | — | Single row. | +| `PATCH` | `/api/v1/companies/{id}` | partial of `create` body | | +| `DELETE` | `/api/v1/companies/{id}` | — | PB cascade rules apply. | + +### 2.8 Customers + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `GET` | `/api/v1/customers` | `?page&perPage&search&sort&expand` | `expand=company,rep` to inline. | +| `POST` | `/api/v1/customers` | `{ customer, email?, alternative_email?, buyer_email?, number?, company?, rep?, coordinates?, description? }` | `customer` required. `company` / `rep` accept id or name. | +| `GET` | `/api/v1/customers/{id}` | `?expand` | | +| `PATCH` | `/api/v1/customers/{id}` | partial | Empty string unsets `company` / `rep`. | +| `DELETE` | `/api/v1/customers/{id}` | — | Cascade rules apply. | + +### 2.9 Suppliers + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `GET` | `/api/v1/suppliers` | `?page&perPage&search&sort` | `coordinates` is the legacy `"lat, lng"` text. | +| `POST` | `/api/v1/suppliers` | `{ suppliername, email?, suppliernumber?, supplieraddress?, supply_tags?, coordinates?, closingtime? }` | | +| `GET` | `/api/v1/suppliers/{id}` | — | | +| `PATCH` | `/api/v1/suppliers/{id}` | partial | | +| `DELETE` | `/api/v1/suppliers/{id}` | — | Cascade rules apply. | + +### 2.10 Collection pool / map + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `GET` | `/api/v1/collection-pool` | `?page&perPage&status` | Pool status filter. | +| `POST` | `/api/v1/collection-map/items/{id}/assign` | `{ driver_id }` | Assign a pool row to a driver. | + +### 2.11 Purchase orders + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `POST` | `/api/v1/purchase-orders/{id}/dispatch` | `{ channel: "email"\|"print" }` | Dispatch PO. | +| `POST` | `/api/v1/purchase-orders/{id}/send-email` | `{ to?, cc?, body? }` | Send PO email. | +| `GET` | `/api/v1/purchase-orders/{id}/send-email/outcome` | — | Outcome of last send. | +| `GET` | `/api/v1/purchase-orders/{id}/pdf` | — | Render PO PDF. | +| `GET` | `/api/v1/purchase-orders/stale` | `?days=...` | List POs untouched for N days. | +| `POST` | `/api/v1/purchase-orders/{id}/attachments/upload` | `multipart/form-data` field `file` | Append a free-form attachment to `additional_attachments`. Max 25 MB. | +| `DELETE` | `/api/v1/purchase-orders/{id}/attachments/{filename}` | — | Remove a single additional-attachment token. | + +### 2.12 Stock purchases + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `POST` | `/api/v1/stock-purchases/{id}/attachments/upload` | `multipart/form-data` field `file` | Append a free-form attachment (supplier invoice, packing slip) to `stock_purchases.additional_attachments`. Max 25 MB. | +| `DELETE` | `/api/v1/stock-purchases/{id}/attachments/{filename}` | — | Remove a single additional-attachment token. | + +### 2.13 Users / file proxy + +| Method | Path | Body | Notes | +| --- | --- | --- | --- | +| `GET` | `/api/v1/users/lookup` | `?q=...` | User search. | +| `GET` | `/api/v1/files/{collection}/{recordId}/{filename}` | — | Auth-aware file proxy for collections whose rules reject the client. | + +--- + +## 3. MCP endpoint (`/api/mcp`) + +JSON-RPC over HTTP. Same `Authorization` bearer as `/api/v1/*`. + +### 3.1 Methods + +- `tools/list` — returns the full tool list (23 tools as of writing). +- `tools/call` — `{ params: { name, arguments } }`. + +### 3.2 Tools + +#### Orders / pool / sprint + +| Tool | Purpose | +| --- | --- | +| `orders.list` | List orders by `order_number` substring. | +| `orders.create_from_quote` | Create order + items from a quote. | +| `collection_pool.list` | List pool rows by status. | +| `order_items.update_status` | Update `item_status` / `drv_status` / notes. | +| `order_items.confirm_collected` | Mark one or more customer order_items as collected **without requiring a driver assignment**. The in-UI `/collection-pool` page only exposes "Mark Collected" after a `drv_id` is bound; this tool is the agent-side equivalent (mirrors `stock_purchase_items.mark_collected` on the stock side). Eligible from `item_status` ∈ {`collection_pool`, `assigned`}; per-row independence; bumps `qty_collected` + stamps `drv_collected_at` + appends a `category: "collection"` audit note to `notes`. Added 2026-08-11 to close the gap surfaced when goods physically arrived at the warehouse but no driver had ever been assigned. | + +#### Quotes (email pipeline) + +| Tool | Purpose | +| --- | --- | +| `quotes.create_from_email` | Create quote + customer + items from parsed email. Idempotent on `message_id`. Use `attachments.upload_for_quote` per file after this returns. | +| `quotes.append_reply` | Append a structured reply. Matches `In-Reply-To` then `Q-YYYY-MM-NNN` in subject. | +| `quotes.find_by_message_id` | Look up by RFC 5322 `Message-ID`. | +| `attachments.upload_for_quote` | Upload one attachment to a quote (base64, ≤25 MB). | +| `bulk_update_quote_items` | Patch up to 100 quote items in one call (whitelisted fields only). | + +#### Companies / customers / suppliers (full CRUD) + +| Tool | Purpose | +| --- | --- | +| `companies.list` / `.get` / `.create` / `.update` / `.delete` | Full CRUD. | +| `customers.list` / `.get` / `.create` / `.update` / `.delete` | Full CRUD. `company` / `rep` accept id or name. | +| `suppliers.list` / `.get` / `.create` / `.update` / `.delete` | Full CRUD. | + +#### Stock purchases / POs (attachments) + +| Tool | Purpose | +| --- | --- | +| `purchase_orders.add_attachment` | Upload one free-form attachment to a PO (base64, ≤25 MB). | +| `stock_purchases.add_attachment` | Upload one free-form attachment (supplier invoice, packing slip, photo of damaged stock) to a stock purchase (base64, ≤25 MB). Appends to `additional_attachments`; does NOT touch the versioned-PDF `documents` ledger. | + +#### Stock purchases (line-level writes — added 2026-08-07) + +| Tool | Purpose | +| --- | --- | +| `stock_purchase_items.update` | Patch whitelisted pricing + catalogue fields on a single `stock_purchase_item` (`cost_price`, `sku`, `description`, `notes`, `unit`, `supplier_sku`, `quantity`). Hand-edit guard on `cost_price > 0`. | +| `stock_purchase_items.bulk_update` | Batch patch (≤100 rows). Same field whitelist + hand-edit guard as `.update`. Per-row independence; `dry_run: true` returns identical shape with `status: "dry_run"` per row. | +| `stock_purchase_items.assign_driver` | Bind a line to a driver (`drv_id`). Transitions line status `null → assigned_to_driver` or `collection_pool → assigned_to_driver`. | +| `stock_purchase_items.mark_collected` | Mark a line as physically delivered. Status → `collected`, bumps `quantity_received`. **Does NOT book into stock** — that's `stock_purchases.receive`. Header auto-flips to `received` when every line on the PO has `quantity_received >= quantity`. | + +#### Stock items (catalogue) + +| Tool | Purpose | +| --- | --- | +| `stock_items.update` | Patch catalogue fields on a master `stock_item` (`sku`, `description`, `notes`, `unit`, `selling_price`, `markup`). For inventory qty use `stock_items.adjust`. | + +### 3.3 Tool input shapes (condensed) + +`quotes.create_from_email` (required: `subject`): + +```json +{ + "from_email": "buyer@acme.co.za", + "from_name": "Acme Procurement", + "subject": "RFQ — magnetic drill", + "body_plain": "...", + "body_html": "...", + "message_id": "", + "in_reply_to": "", + "received_at": "2026-07-09T10:00:00Z", + "company_hint": "Acme", + "extracted_items": [ + { "description": "Magnetic drill", "quantity": 1, "unit": "ea", "notes": "" } + ], + "attachments": [] +} +``` + +`attachments.upload_for_quote` (required: `quote_id`, `filename`, `base64`): + +```json +{ + "quote_id": "abc123", + "filename": "spec.pdf", + "content_type": "application/pdf", + "size": 482113, + "base64": "JVBERi0xLjQK..." +} +``` + +`stock_purchase_items.update` (required: `id`, `patch`): + +```json +{ + "id": "15-char-line-id", + "patch": { + "cost_price": 145.50, + "sku": "HYD68-20", + "supplier_sku": "SUP-HYD-068" + }, + "force_overwrite": false +} +``` + +Whitelist: `cost_price`, `sku`, `description`, `notes`, `unit`, +`supplier_sku`, `quantity`. Anything else returns `forbidden_field`. +Lines with `cost_price > 0` already return `already_priced` unless +`force_overwrite: true`. + +`stock_purchase_items.bulk_update` (required: `items`): + +```json +{ + "items": [ + { "id": "line-a", "patch": { "sku": "HYD68-20" } }, + { "id": "line-b", "patch": { "cost_price": 230.00 }, "force_overwrite": true } + ], + "dry_run": false +} +``` + +Returns `{ updated, skipped, errors, results: [{id, status, reason?, message?, previous?, item?}] }`. +Cap is 100 rows per call. + +`stock_purchase_items.assign_driver` (required: `id`, `drv_id`): + +```json +{ "id": "15-char-line-id", "drv_id": "15-char-driver-id", "assigned_at": "2026-08-07T08:00:00Z" } +``` + +`stock_purchase_items.mark_collected` (required: `id`): + +```json +{ "id": "15-char-line-id", "quantity_collected": 4 } +``` + +Returns `{ item, previous_quantity_received, new_quantity_received, header_status_after, header_flipped }`. +`header_flipped: true` when this call completed the parent PO (auto-flip +from `sent` → `received`). + +`stock_items.update` (required: `id`, `patch`): + +```json +{ + "id": "15-char-stock-item-id", + "patch": { + "sku": "HYD68-20", + "selling_price": 285.00, + "markup": 35 + } +} +``` + +Whitelist: `sku`, `description`, `notes`, `unit`, `selling_price`, `markup`. +Anything else returns `reason: "forbidden_field"`. For inventory qty use +`stock_items.adjust`. Returns `{ok: true, item, previous}` on success or +`{ok: false, reason, message, field?}` on rejection. + +`bulk_update_quote_items` (required: `items`): + +```json +{ + "items": [ + { + "id": "15-char-quote-item-id", + "patch": { + "cost_price": 1200.00, + "selling_price": 1800.00, + "supplier": "15-char-supplier-id", + "pricing_source": "ai", + "pricing_notes": "Auto-priced from Acme catalogue 2026-07-09" + }, + "force_overwrite": false + } + ], + "dry_run": false +} +``` + +Whitelist: `cost_price`, `selling_price`, `description`, `sku`, `unit`, +`supplier`, `markup`, `pricing_source`, `pricing_notes`, +`matched_price_list_id`, `user_edited`. Anything else returns +`reason: "forbidden_field"` per row. Pre-priced rows +(`cost_price > 0 AND selling_price > 0`) are skipped with +`reason: "already_priced"` unless `force_overwrite: true`. Cap is 100 +rows per call. + +`companies.create`: + +```json +{ "company": "Acme", "address": "1 Road", "vendor": "V-1", "coordinates": { "lon": 28.04, "lat": -26.20 } } +``` + +`customers.create`: + +```json +{ + "customer": "Acme Procurement", + "email": "buyer@acme.co.za", + "alternative_email": "ops@acme.co.za", + "buyer_email": "ap@acme.co.za", + "number": "+27 11 555 0100", + "company": "Acme", + "rep": "David", + "coordinates": { "lon": 28.04, "lat": -26.20 }, + "description": "Industrial buyer" +} +``` + +`suppliers.create`: + +```json +{ + "suppliername": "Acme Supplies", + "email": "sales@acme.co.za", + "suppliernumber": "+27 11 555 0200", + "supplieraddress": "2 Road", + "supply_tags": "tools,fasteners", + "coordinates": "-26.20, 28.04", + "closingtime": "17:00" +} +``` + +### 3.4 Response envelopes + +Success: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{ "type": "text", "text": "{\"...\":\"...\"}" }] + } +} +``` + +Error: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { "code": -32601, "message": "Method not found" } +} +``` + +Auth failures return HTTP 401 for `/api/v1/*` but land in the +JSON-RPC error envelope for `/api/mcp` (per the in-tree test suite). + +--- + +## 4. Known quirks + +- **`orders.list` has no `status` filter.** The `orders.status` column + was removed; status lives on items now. The MCP `orders.list` tool + only supports `search` / `order_number` substring. +- **Quote-level attachments via the REST route** (`/api/v1/quotes/{id}/attachments/upload`) + and via the MCP tool (`attachments.upload_for_quote`) are + independent code paths. New agents should prefer the MCP tool. +- **Quote-item attachments** have two upload routes — the new one at + `/api/v1/quotes/items/{id}/attachments/upload` (source `"manual"`), + and the sprint one at + `/api/v1/sprint/items/{id}/attachments/upload` (source + `"operator_proof"` / `"agent_proof"`). Use the sprint route for + pricing proofs; use the quotes route for raw attachments coming in + with the original RFQ. +- **`customers.create` / `customers.update`** accept `company` and + `rep` as either ids or case-insensitive names. An unresolved name + leaves the relation empty (no error) so the operator can wire it + up later. +- **`suppliers.coordinates`** is the legacy `"lat, lng"` text field. + Don't migrate it to `{lon,lat}` — `supplier-map.ts` still parses + the text form. + +--- + +## 5. Testing the surface + +Unit/API tests: + +```bash +npm run test:run -- src/app/api +``` + +End-to-end (boots Next dev on `:3100`): + +```bash +export E2E_BASE_URL=http://localhost:3100 +export E2E_AGENT_API_KEY=$AGENT_API_KEY +npm run e2e +``` + +The Playwright suite intentionally skips env-driven specs when the +keys aren't set, so a clean checkout reports `passed, N skipped`. + +--- + +## 5. MCP tool catalogue (auto-generated) + + + +This catalogue is auto-generated from the `tools` array in `src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. There are **125 tools** as of this run. Re-run the script after adding or renaming a tool. + +Grouped by resource family. The full description is in the table column; the description text the agent sees is preserved verbatim. + + +### Orders + +| Tool | Description | +| --- | --- | +| `orders.list` | List orders. Pass PB filter syntax in `filter` (e.g. `created >= "2026-08-05" && created < "2026-08-06"` for orders created today) and `sort` (e.g. `-created`, `order_number`). `search` is a substring match on order_number; it composes w... | +| `orders.get` | Fetch a single order by id. Pass `expand` (e.g. `customer,company,quote`) to inline relations. | +| `orders.create_from_quote` | Create a new order and its order_items from an existing quote. The standard pipeline for turning a customer-accepted quote into an operational order. Use this when the operator says 'convert Q-2026-08-014 to an order' or 'place the order... | +| `orders.create` | Create a standalone order WITHOUT going through a quote. Use for counter sales, walk-ins, internal transfers, or any flow where there's no upstream quote record. Mirrors the REST POST /api/v1/orders/create endpoint. Required: customer_id... | +| `orders.split` | Split an order into a continuation order. Caller passes the order_item IDs to move; unlisted items stay on the parent. Mirrors the REST POST /api/v1/orders/{id}/split endpoint. Semantics: - Parent order keeps its number; moved items stay... | +| `orders.add_attachment` | Upload a single attachment (proof, drawing, signed PO, supplier quote) to an order. The file is stored against the order's matched quote (WFDASH has no `order_attachments` collection; orders resolve to their matched quote for attachment ... | +| `orders.list_attachments` | List the attachments associated with an order. Returns the order's matched quote's attachments (since orders share the quote_attachments table with their source quote). Includes the original RFQ email attachments, the rendered quote PDF,... | +| `orders.sprint_counts` | Counts of sprint-pricing rows grouped by claim status (unclaimed / claimed-by-me / claimed-by-other / resolved). Used to render sprint badges in the operator UI and to decide whether an agent should claim a row. Wraps `GET /api/v1/orders... | +| `orders.find_duplicates` | Find probable duplicate orders for the same customer + recent window. Wraps `GET /api/v1/orders/duplicates`. Use this BEFORE creating a new order from a customer's RFQ so the operator doesn't accidentally double-order. Returns groups wit... | +| `orders.review` | Read-only review bundle for one order: header, items grouped by supplier, PO readiness, attachment matches against quote items, and any pending decisions. Wraps `GET /api/v1/orders/[id]/review`. Use this BEFORE generating POs or splittin... | +| `orders.matched_attachments` | Match the order's quote attachments against each order_item by description / SKU overlap. Wraps `GET /api/v1/orders/[id]/matched-attachments`. Returns `{ item_id → [attachment_filename, ...] }` so the agent can decide which proof applies... | + +### Order items + +| Tool | Description | +| --- | --- | +| `order_items.update_status` | Update an order_item's `item_status` and/or `drv_status` fields. Use this when the operator needs to flip a single row through the workflow without bulk-pricing or per-line invoice reconciliation (`order_items.update` is the patch tool f... | +| `order_items.confirm_collected` | Confirm collection of one or more customer order_items WITHOUT requiring a driver assignment. Mirrors the agent-side role that the stock-side `stock_purchase_items.mark_collected` already plays: marks `item_status` → `collected` and `drv... | +| `order_items.update` | Patch fields on an order_item for invoice reconciliation. Whitelist: cost_price, selling_price, quantity, sku, unit, category, markup, markup_percent, notes, qty_processed, qty_assigned, qty_collected, qty_verified, qty_stocked, qty_pick... | +| `order_items.list` | List order_items (the line items under each order). Useful for finding pending/PB-ready items, getting one supplier's items, etc. Pass PB filter syntax in `filter`. | +| `order_items.get` | Fetch a single order_item by id. Pass `expand` to inline `order`, `purchase_order`, `category`, `supplier`. | + +### Quotes + +| Tool | Description | +| --- | --- | +| `quotes.create_from_email` | Create a new quote (with customer/company/items) from a parsed email payload. Idempotent on message_id. `from_email` is optional — if missing (e.g. an email with no From: header), the quote is linked to a sentinel 'Unknown Sender' custom... | +| `quotes.ingest_from_email` | Fast ingestion front-door of the split AI pipeline. Same shape as `quotes.create_from_email`, but does NOT extract items synchronously — the new quote is stamped `ai_status = "pending"` and returned immediately so the caller doesn't time... | +| `quotes.process_pending` | Cron-style processor. Picks up to `limit` quotes with `ai_status = "pending" \| "failed"`, runs each through Claude against the email body + attachment text, writes `quote_items`, and flips status. Returns counts (processed/skipped/failed... | +| `quotes.re_run_ai` | Manual retry of the AI pipeline for a single quote. Resets `ai_status` via the atomic claim, then re-runs Claude. Pass `wipe_items: false` to keep the existing `quote_items` rows (the AI's new items will be appended); default `true` dele... | +| `quotes.append_reply` | Append a structured reply to an existing quote. Matches by In-Reply-To header first, then by Q-YYYY-MM-NNN in the subject. | +| `quotes.find_by_message_id` | Look up an existing quote by the inbound email's RFC 5322 Message-ID header. Use this when the operator (or another agent) needs to correlate an inbound email back to a quote that was already created from it — e.g. to attach a reply, app... | +| `quotes.list` | List quotes. Pass PB filter syntax in `filter` (e.g. `status="new"`). Default sort `-created`. | +| `quotes.get` | Fetch a single quote by id. Pass `expand` (e.g. `customer,company,items`). | +| `quotes.update` | Patch fields on a quote header. Whitelist: `company_letterhead`, `quote_date`, `quote_reference`, `rfq_number`, `validity_days`, `notes`, `delivery_note`, `markup`, `email_cc`, `email_bcc`, `claimed_by`. Anything outside this set — inclu... | +| `quotes.send_email` | Front-door for the outbound email send pipeline. Validates and flips the quote to `sending`; the actual SMTP send runs asynchronously in n8n. `selectedAttachmentIds` is a list of `quote_attachments` IDs to include (the PDF is always atta... | +| `quotes.retry_email` | Retry the outbound email send for a quote currently in `mail_failed`. Use this when SMTP credentials have been rotated, the customer's MX changed, or the original send bounced. The quote must already be in `mail_failed` (409 if not) — fo... | +| `quotes.clone` | Clone a real quote into a new real quote with a fresh `quote_number` and `status="new"`. The source quote is untouched. Useful for re-quoting the same items to a different customer. | +| `quotes.create_from_template` | Create a new real quote from a template (`is_template=true` quote). The template is not modified — a fresh quote is created with a new `Q-YYYY-MM-NNN` number, `status="new"`, and the template's customer / company / letterhead / items cop... | +| `quotes.save_as_template` | Snapshot an existing quote as a reusable template. The original quote is not modified; a new `is_template=true` row is created. | +| `quotes.split` | Split a quote into multiple quotes by grouping items. Body is `{ source_quote_id, groups: [{ name, item_ids: string[] }] }`. Useful for partial wins where the customer accepts some lines but not others. | +| `quotes.partial_approve` | Mark a subset of quote items as `approved` (or `rejected`) on a quote. Body: `{ quote_id, decisions: [{ item_id, decision }] }`. Useful when the customer accepts some lines but not others. | +| `quotes.re_price` | Re-run pricing on every quote_item under a quote. Whitelist of patchable fields is the same as `bulk_update_quote_items`. Returns the per-row result list. | +| `quotes.next_number` | Reserve the next quote number (`Q{yy}{mm}{NNN}`). Atomic counter; gaps are possible if the caller never persists the resulting draft. | +| `quotes.lookup` | Free-text lookup over quotes for the order page's quote-picker dropdown. Substring match across quote_number, quote_reference, rfq_number, customer name, company name. Excludes templates by default. | +| `quotes.list_attachments` | List all attachments on a quote (the generated PDF, original RFQ email attachments, operator-uploaded proofs, outbound attachments). Newest first. Returns up to 50 per page. | +| `quotes.get_pdf` | Retrieve the rendered quote PDF as a base64 string in the MCP response. The PDF is auto-rendered if one doesn't already exist on the quote. Pass `letterhead` to force CNL or ISHERWOOD; defaults to the quote's stored `company_letterhead`.... | + +### Quote items + +| Tool | Description | +| --- | --- | +| `quote_items.list` | List `quote_items` rows. Pass PB filter syntax in `filter` (e.g. `quote=""`, `sku~"M16"`, `supplier=""`). Default sort `-created`. | +| `quote_items.get` | Fetch a single `quote_items` row by id. Pass `expand` to inline relations (e.g. `quote,supplier`). | +| `quote_items.create` | Create a single `quote_items` row on an existing quote. Use this AFTER `quotes.create_from_email` when the AI extraction missed a line, or to seed items manually. Mirrors the body shape of the REST `POST /api/v1/quote_items`. Notes: - `q... | +| `quote_items.update` | Patch a single `quote_items` row. Patch whitelist is identical to `bulk_update_quote_items`: cost_price, selling_price, description, sku, unit, supplier, category, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited... | +| `quote_items.populate_insights` | Deterministically compute and write `quote_items.ai_item_insights` from PB. Skips the n8n AI round-trip — Hermes/Giellie's go-to tool for backfilling the per-item insights blob across the board. Two modes: - pass `quote_id` → populates e... | + +### Attachments + +| Tool | Description | +| --- | --- | +| `attachments.upload_for_quote` | Upload a single attachment (PDF, image, Word, Excel, CSV, plain text, or any other binary up to 25 MB) to an existing quote. The caller (n8n) should call this once per attachment AFTER `quotes.create_from_email` returns the new quote id.... | + +### Companies + +| Tool | Description | +| --- | --- | +| `companies.list` | List companies, optionally filtered by name or vendor substring. | +| `companies.get` | Fetch a single company by id. | +| `companies.create` | Create a new company. `company` is the display name and is required. `coordinates` is a `{ lon, lat }` object (PB geoPoint) — pass null/omit if unknown. | +| `companies.update` | Patch fields on a company. Only the fields present in `patch` are written. | +| `companies.delete` | Delete a company. PB's relation cascade rules apply — a company with linked customers will fail to delete unless those customers are deleted first (or the relation's cascadeDelete is true). | + +### Customers + +| Tool | Description | +| --- | --- | +| `customers.list` | List customers, optionally filtered by name / email / phone substring. Returns the row with `company` and `rep` fields as relation ids; pass `expand: "company,rep"` to receive the expanded company + user records inline. | +| `customers.get` | Fetch a single customer by id. Pass `expand` to inline company / rep records. | +| `customers.create` | Create a new customer. `customer` (display name) is required. `company` and `rep` accept either a record id OR a name — names are resolved by case-insensitive lookup. If the name doesn't match, the relation is left empty so the operator ... | +| `customers.update` | Patch fields on a customer. Like `customers.create`, `company` and `rep` accept either id or name. Pass `company: ""` or `rep: ""` to unset a relation. | +| `customers.delete` | Delete a customer. Cascade rules apply — customers linked to quotes or orders will fail to delete unless those rows are removed first. | + +### Suppliers + +| Tool | Description | +| --- | --- | +| `suppliers.list` | List suppliers, optionally filtered by name / email / phone / tags substring. `coordinates` is the legacy "lat, lng" text field (suppliers pre-date the geopoint migration). | +| `suppliers.get` | Fetch a single supplier by id. | +| `suppliers.create` | Create a new supplier. `suppliername` is required. `coordinates` is the legacy "lat, lng" string — keep suppliers on the old format for now (it's what supplier-map.ts parses). | +| `suppliers.update` | Patch fields on a supplier. | +| `suppliers.delete` | Delete a supplier. Cascade rules apply — suppliers linked to purchase orders / order items / quote items will fail to delete unless those rows are removed first. | + +### Purchase orders + +| Tool | Description | +| --- | --- | +| `purchase_orders.add_attachment` | Upload a single additional file (supplier quote, drawing, NDA) to an existing PO. The file travels with the outbound email alongside the versioned PO PDF. Call once per file. Max 25 MB per file. | +| `purchase_orders.list` | List purchase_orders. Pass PB filter syntax in `filter` (e.g. `status="sent"`). Default sort `-created`. | +| `purchase_orders.get` | Fetch a single purchase_order by id. Pass `expand` to inline `supplier`. | +| `purchase_orders.send_email` | Front-door for the outbound email send pipeline on a customer PO. Validates and flips the PO to `sending`; the actual SMTP send runs asynchronously in n8n. | +| `purchase_orders.retry_email` | Retry the outbound email send for a PO currently in `mail_failed`. Use this when SMTP credentials have been rotated, the supplier's MX changed, or the original send bounced (e.g. quota exceeded). The PO must already be in `mail_failed` (... | +| `purchase_orders.stale` | List draft purchase_orders older than `olderThan` minutes. Used by n8n's `WFDASH — Purchase Order Send (Cron)` workflow to pick up POs that need nudging. | +| `purchase_orders.record_send_outcome` | n8n callback fired after an outbound email send resolves. Flips the PO to `sent` (success) or `mail_failed` (failure) and records the SMTP error message. Both fields are required. | +| `purchase_orders.scan` | Triage scan for the `po-agent` workflow (see `docs/agents/purchase-order-agent.md`). Walks BOTH `stock_purchases` and `purchase_orders` in one round-trip, runs per-line diagnostics on each (lines_missing_sku, lines_missing_cost, lines_mi... | +| `purchase_orders.diff` | Read-only price/SKU/qty diff for a supplier document against a PO. Wraps `stock_purchases.match_and_receive_from_invoice` with `confirm=false` so it returns the full `MatchReport` (verdict, per-line `LineComparison[]` with qty_delta / co... | +| `purchase_orders.record_diff_report` | Persist the full MatchReport from `purchase_orders.diff` (or any source) onto the PO's `notes` field as a structured JSON entry. The entry uses category `price_diff_report` so future UIs can filter on it. The caller must pass `actor: { i... | + +### Stock purchases + +| Tool | Description | +| --- | --- | +| `stock_purchases.list` | List stock_purchases. `status` filter accepts any value from STOCK_PURCHASE_STATUS_VALUES (draft \| sending \| sent \| mail_failed \| received \| cancelled); an unknown status yields 0 results rather than 400. Supplier is auto-expanded; per-r... | +| `stock_purchases.get` | Fetch a single stock_purchase plus its line items. 404 when the id is missing. | +| `stock_purchases.create` | Create a draft stock_purchase with one or more line items. `purchase_number` (`PO{yy}{6d-seq}STK`) is generated server-side. The supplier id must already exist in PocketBase. Returns `{ purchase, items }`. | +| `stock_purchases.next_number` | Reserve the next stock-purchase number (`PO{yy}{6d-seq}STK`). Atomic counter; gaps are possible if the caller never persists the resulting draft. | +| `stock_purchases.send` | Mark a draft stock_purchase as `sent`. v1 just flips the status flag; the actual SMTP send (when wired) lives behind send_stock_purchase_email. Returns the updated purchase or a structured error when the purchase is not in `draft`. | +| `stock_purchases.receive` | Mark a `sent` stock_purchase as `received`, booking each line's qty into the linked stock_item and writing a `stock_ledger` row per line. Optional `items` array supports partial receives via per-line `quantity_received` overrides. Idempo... | +| `stock_purchases.send_email` | Front-door for the outbound email send on a stock purchase. Composes the to/cc/bcc/subject/message, flips the header to `sending`, and triggers the n8n SMTP workflow. Returns the purchase on success or a structured failure on pre-send va... | +| `stock_purchases.retry_email` | Retry the email send for a stock_purchase currently in `mail_failed`. Flips the header back to `sending`, clears the previous error, and re-runs the n8n send workflow. | +| `stock_purchases.add_attachment` | Upload a single free-form attachment (supplier invoice, packing slip, photo of damaged stock) to an existing stock purchase. The file is appended to `stock_purchases.additional_attachments`. Call once per file. Max 25 MB per file. The ve... | +| `stock_purchases.match_and_receive_from_invoice` | Buyer-side reconciliation: take a parsed supplier document (invoice or delivery note) and the matching PO number, run deterministic line-by-line matching (sku → supplier_sku → description overlap), and either dry-run or commit the receiv... | + +### Stock purchase items + +| Tool | Description | +| --- | --- | +| `stock_purchase_items.update` | Patch whitelisted fields on a single stock_purchase_item: cost_price, sku, description, notes, unit, supplier_sku, quantity. For quantity_received use stock_purchases.receive. For per-line status use assign_driver / mark_collected. Hand-... | +| `stock_purchase_items.list` | List stock_purchase_items (the line items under each stock purchase). Useful for finding PO lines missing SKU or cost, or scanning all uncollected lines for one supplier. Pass PB filter syntax in `filter` (e.g. `cost_price = 0 && purchas... | +| `stock_purchase_items.bulk_update` | Batch patch stock_purchase_items (≤100 rows). Same field whitelist as stock_purchase_items.update. Hand-edit guard on cost_price. Per-row independence — one row's failure never affects another. dry_run returns the same shape with status:... | +| `stock_purchase_items.assign_driver` | Bind a stock_purchase_item to a driver. Transitions the line status `null → assigned_to_driver` or `collection_pool → assigned_to_driver`. Wrong_status (409) when the line is already past the assignment step. | +| `stock_purchase_items.mark_collected` | Mark a stock_purchase_item as physically delivered. Transitions the line status to `collected` and bumps `quantity_received`. Does NOT book into stock — that requires the invoice-receive path. quantity_collected defaults to `quantity - q... | + +### Stock items + +| Tool | Description | +| --- | --- | +| `stock_items.list` | List stock_items. Supports substring search on name/sku/description, low-stock filter (`current_qty <= min_qty AND is_active = true`), pagination, and `expand` (defaults to `supplier`). | +| `stock_items.get` | Fetch a single stock_item by id. The response includes the item (expand=supplier) and a paginated recent-ledger slice (`ledger.page`, `ledger.items`, etc.). Pass `ledger=false` to skip the ledger fetch. | +| `stock_items.create` | Create a new stock_item. `name` is required. When `current_qty > 0` an opening-balance ledger row is written so the running total and the ledger sum stay in sync (reason=`adjustment`, reference_type=`manual`). | +| `stock_items.adjust` | Record a manual stock adjustment. Inserts a `stock_ledger` row and updates `current_qty` on the parent item. `change_qty` is signed (positive = in, negative = out); zero is rejected. `reason` must be one of STOCK_REASON_VALUES. | +| `stock_items.update` | Patch catalogue fields on a master stock_item: sku, description, notes, unit, selling_price, markup. For inventory qty use stock_items.adjust. Returns {ok: true, item, previous} on success or {ok: false, reason, message, field?} on rejec... | + +### Collection pool + +| Tool | Description | +| --- | --- | +| `collection_pool.list` | List `collection_pool` records (warehouse-side goods awaiting driver assignment) by status. Use this when the operator asks 'what's on the loading floor?' or 'show me everything waiting for a driver'. Pass `status` to narrow by `pending\|... | + +### Collection confirm + +| Tool | Description | +| --- | --- | +| `collection_confirm.list` | List every order_item and stock_purchase_item currently in the 'collected' status (the buyer-side collection_confirm queue). Returns a unified row shape with `source: 'customer' \| 'stock'` so the caller can filter / process each kind sep... | +| `collection_confirm.upload_invoice` | Upload a supplier invoice image (or PDF) and attach it to one or more order_items' `order_item_attachments` file field. Use this as the second step of the buyer-side collection-confirm flow: (1) `stock_purchases.match_and_receive_from_in... | +| `collection_confirm.confirm_order_items` | Mark a batch of order_items as confirmed (item_status `collected` → `ready_invoice`). Stamps the audit fields (`notes`, `order_item_details`) with the supplier invoice number + date and the agent's identity. Optional `cost_overrides` pat... | + +### Sprint + +| Tool | Description | +| --- | --- | +| `sprint.list` | List the sprint (pending, no-PO) items. Use this as the source of truth for what a buyer still needs to process. | +| `sprint.attachment_counts` | Return a `{ id → count }` map of pricing-proof attachments per sprint item, suitable for showing a paperclip badge in a list view. | +| `sprint.item_attachments` | List the pricing-proof attachments on a single sprint item (the supplier PDFs, screenshots, and pasted emails an agent previously uploaded). Use this BEFORE picking a sprint row so you can see what evidence is already there, and BEFORE m... | +| `sprint.claim` | Race-safe claim on a sprint item. Sets `claim_status=claimed`, `claimed_by=`, `claimed_at=now`, `claim_expires_at=now+30min`. Returns 409 with the current claim if another agent already owns it. | + +### Buyer tasks + +| Tool | Description | +| --- | --- | +| `buyer_tasks.list` | List the buyer-tasks feed for a tab. `tab` is one of `needs_po`, `po_draft`, `po_sent`. Returns `{ groups: [...] }`. | +| `buyer_tasks.generate_po` | Generate a purchase_order for a (orderId, supplierId) pair and link the supplied items to it. Returns the new `purchaseOrderId`. | +| `buyer_tasks.mark_ready` | Mark a (orderId, supplierId) group as ready for collection. `note` is required (non-empty) for the audit trail. | + +### Price list + +| Tool | Description | +| --- | --- | +| `price_list.add_items` | Bulk-upsert parsed supplier-price-list line items into the `price_list` catalogue. The agent reads the source document (a supplier PDF, a quote PDF, or a pasted supplier email) with its native vision capability, extracts the per-line fie... | + +### Users + +| Tool | Description | +| --- | --- | +| `users.lookup` | Look up PB users for audit-stamp `actor: { id, name }` fields. Returns the full sorted list `[{id, name, email}]` — no passwords, tokens, or other sensitive fields. Use this BEFORE calling tools that require an actor identity (`order_ite... | + +### Files + +| Tool | Description | +| --- | --- | +| `files.fetch` | Fetch a single file from PocketBase and return it as base64 in the MCP response. Wraps `GET /api/v1/files/[collection]/[recordId]/[filename]`. Use this to retrieve a generated quote PDF, a supplier invoice scan, a delivery-note signed co... | + +### Dashboard + +| Tool | Description | +| --- | --- | +| `dashboard.daily_summary` | Single-call daily snapshot of the order pipeline: counts by `item_status` (pending, collection_pool, assigned, collected, delivered, invoiced), top suppliers by open volume, value totals, etc. Wraps `GET /api/v1/dashboard`. Use this when... | + +### Misc + +| Tool | Description | +| --- | --- | +| `bulk_update_quote_items` | Patch many `quote_items` rows in a single MCP call (one JSON-RPC round-trip instead of N PATCHes to PB REST). Use this for AI auto-fill, supplier switches, or any bulk pricing update. Each row is independent — one row's failure never aff... | +| `stock.fulfill` | Fulfill order items from stock. CREATE mode (`order_id` + `order_item_id` + `stock_item_id` + `qty` + `description` + `selling_price` + `cost_price`) creates a new order_item and decrements stock in one call. UPDATE mode (`order_item_id`... | +| `stock.surplus_book` | Book surplus from a collected order_item into a stock_item. Auto-picks the matching stock_item when `stock_item_id` is omitted. | +| `ai_insights.rerun` | Re-run the n8n AI Insights workflow on a single quote or order. Snapshot-once lifecycle — use this when new catalogue data has appeared. | +| `ai_settings.get` | Read the current AI configuration. Returns the redacted record (api_key is empty, api_key_set indicates whether one is configured). | +| `ai_settings.update` | Patch the AI configuration. Body shape: { provider?: "anthropic" \| "minimax" \| "", api_key?: string (omit to keep, "" to clear), base_url?: string, model_id?: string, max_tokens?: number (256-8192), enabled?: boolean }. Returns the updat... | +| `tools.discovery` | Returns the tool list as a flat array (no JSON-RPC envelope). Equivalent to `tools/list` but useful for clients that can't easily build a JSON-RPC request. | +| `drivers.list_stops` | List today's driver stops with statuses and assigned items. Wraps `GET /api/v1/driver/stops`. Returns `[{ stop_id, status, customer, address, items, ... }]`. Use this to plan a driver's run or to find a stop the operator is asking about ... | +| `drivers.update_stop_status` | Record a driver-side action on a stop (collected, problem). Wraps `PATCH /api/v1/driver/stops/[id]/status`. For `action: "collected"` on a pickup, `qty_collected` is required. For `action: "problem"`, `problem_type` is required (one of `... | +| `stock_quick_draft.search` | Search the quick-draft index for SKUs / descriptions / suppliers. Wraps `GET /api/v1/stock-quick-draft/search`. Returns ranked hits with last-known cost and supplier. Use this BEFORE adding a line so the agent can pre-fill cost + supplier. | +| `stock_quick_draft.add_line` | Add one line to an in-progress quick-draft PO. Wraps `POST /api/v1/stock-quick-draft/add-line`. The draft persists between calls until the operator hits 'Commit'. Use this when building up a stock PO one line at a time. Pass `stockItemId... | +| `stock_quick_draft.suppliers` | List suppliers with quick-draft history (last ordered, line count, total spend). Wraps `GET /api/v1/stock-quick-draft/suppliers`. Use this to pick a supplier when the catalogue doesn't have a clear default. | +| `delivery_notes.list` | List delivery notes. Reads the `delivery_notes` collection via the admin PB client. Supports `status` filter (draft \| in_transit \| invoiced \| cancelled), `order_id` filter, and pagination. Newest-first. Use this when the operator asks 's... | +| `delivery_notes.create` | Create a counter-pickup delivery note (Path B — no upstream order). Wraps `POST /api/v1/delivery-notes`. Body: `customer` (PB id, required), `items[]` (required, ≥1 with description + quantity), optional `company`, `notes`, `createdBy`. ... | +| `delivery_notes.cancel` | Cancel a delivery note. Wraps `POST /api/v1/delivery-notes/[id]/cancel`. Body: optional `setById` (PB user id), `setByName`, and `note` for the audit trail. Use when a DN was created in error or the customer cancels the pickup. | +| `delivery_notes.mark_invoiced` | Flip a delivery note to `invoiced` after the customer's invoice is raised. Wraps `POST /api/v1/delivery-notes/[id]/mark-invoiced`. Idempotent; returns the updated DN record. | +| `delivery_notes.mark_complete` | Flip a delivery note to `completed` when goods are physically out the door. Wraps `POST /api/v1/delivery-notes/[id]/mark-complete`. Idempotent. | +| `delivery_notes.regenerate_pdf` | Force a fresh PDF render for a delivery note. Wraps `POST /api/v1/delivery-notes/[id]/regenerate-pdf`. Use after editing DN items when the operator wants the printed copy to reflect the latest state. Returns `{ ok, pdfSaved, filename }`. | +| `delivery_notes.create_quote` | Create a follow-up quote from a delivery note's items (e.g. when the customer wants a formal price for goods they took on account). Wraps `POST /api/v1/delivery-notes/[id]/create-quote`. Returns `{ id, quote_number }` for the new quote. | +| `delivery_notes.set_customer_po` | Stamp the customer's PO reference onto a delivery note for their AP team. Wraps `POST /api/v1/delivery-notes/[id]/customer-po`. Use when the customer emails a PO number after the goods have already been delivered. | +| `delivery_notes.set_item_quantity` | Update the quantity on a single delivery-note line item. Wraps `PATCH /api/v1/delivery-notes/items/[id]/quantity`. Triggers a PDF regeneration. Returns `{ item, pdfRegenerated }`. | +| `delivery_notes.combine` | Merge multiple delivery notes for the same order into one (the survivor keeps its number; the others are cancelled). Wraps `POST /api/v1/delivery-notes/combine`. Use when the warehouse accidentally split a customer's pickup into multiple... | + +### Alphabetical index + +`ai_insights.rerun`, `ai_settings.get`, `ai_settings.update`, `attachments.upload_for_quote`, `bulk_update_quote_items`, `buyer_tasks.generate_po`, `buyer_tasks.list`, `buyer_tasks.mark_ready`, `collection_confirm.confirm_order_items`, `collection_confirm.list`, `collection_confirm.upload_invoice`, `collection_pool.list`, `companies.create`, `companies.delete`, `companies.get`, `companies.list`, `companies.update`, `customers.create`, `customers.delete`, `customers.get`, `customers.list`, `customers.update`, `dashboard.daily_summary`, `delivery_notes.cancel`, `delivery_notes.combine`, `delivery_notes.create`, `delivery_notes.create_quote`, `delivery_notes.list`, `delivery_notes.mark_complete`, `delivery_notes.mark_invoiced`, `delivery_notes.regenerate_pdf`, `delivery_notes.set_customer_po`, `delivery_notes.set_item_quantity`, `drivers.list_stops`, `drivers.update_stop_status`, `files.fetch`, `order_items.confirm_collected`, `order_items.get`, `order_items.list`, `order_items.update`, `order_items.update_status`, `orders.add_attachment`, `orders.create`, `orders.create_from_quote`, `orders.find_duplicates`, `orders.get`, `orders.list`, `orders.list_attachments`, `orders.matched_attachments`, `orders.review`, `orders.split`, `orders.sprint_counts`, `price_list.add_items`, `purchase_orders.add_attachment`, `purchase_orders.diff`, `purchase_orders.get`, `purchase_orders.list`, `purchase_orders.record_diff_report`, `purchase_orders.record_send_outcome`, `purchase_orders.retry_email`, `purchase_orders.scan`, `purchase_orders.send_email`, `purchase_orders.stale`, `quote_items.create`, `quote_items.get`, `quote_items.list`, `quote_items.populate_insights`, `quote_items.update`, `quotes.append_reply`, `quotes.clone`, `quotes.create_from_email`, `quotes.create_from_template`, `quotes.find_by_message_id`, `quotes.get`, `quotes.get_pdf`, `quotes.ingest_from_email`, `quotes.list`, `quotes.list_attachments`, `quotes.lookup`, `quotes.next_number`, `quotes.partial_approve`, `quotes.process_pending`, `quotes.re_price`, `quotes.re_run_ai`, `quotes.retry_email`, `quotes.save_as_template`, `quotes.send_email`, `quotes.split`, `quotes.update`, `sprint.attachment_counts`, `sprint.claim`, `sprint.item_attachments`, `sprint.list`, `stock.fulfill`, `stock.surplus_book`, `stock_items.adjust`, `stock_items.create`, `stock_items.get`, `stock_items.list`, `stock_items.update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.bulk_update`, `stock_purchase_items.list`, `stock_purchase_items.mark_collected`, `stock_purchase_items.update`, `stock_purchases.add_attachment`, `stock_purchases.create`, `stock_purchases.get`, `stock_purchases.list`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.next_number`, `stock_purchases.receive`, `stock_purchases.retry_email`, `stock_purchases.send`, `stock_purchases.send_email`, `stock_quick_draft.add_line`, `stock_quick_draft.search`, `stock_quick_draft.suppliers`, `suppliers.create`, `suppliers.delete`, `suppliers.get`, `suppliers.list`, `suppliers.update`, `tools.discovery`, `users.lookup` + + \ No newline at end of file diff --git a/scripts/__pycache__/mcp-doc-gen.cpython-314.pyc b/scripts/__pycache__/mcp-doc-gen.cpython-314.pyc new file mode 100644 index 0000000..309ec80 Binary files /dev/null and b/scripts/__pycache__/mcp-doc-gen.cpython-314.pyc differ diff --git a/scripts/__pycache__/mcp_doc_gen.cpython-314.pyc b/scripts/__pycache__/mcp_doc_gen.cpython-314.pyc new file mode 100644 index 0000000..8b6e2d9 Binary files /dev/null and b/scripts/__pycache__/mcp_doc_gen.cpython-314.pyc differ diff --git a/scripts/mcp-doc-gen.py b/scripts/mcp-doc-gen.py new file mode 100644 index 0000000..33ac932 --- /dev/null +++ b/scripts/mcp-doc-gen.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Regenerate the MCP tool catalogue inside `docs/api-mcp-layer.md` from the +live `tools` array in `src/app/api/mcp/route.ts`. + +The script is intentionally regex-based (not a JS parser) because the tool +shape is small and stable — the goal is "drift-free docs", not "AST-perfect +extraction". Anything the script can't parse falls into the `UNPARSED` list +below so a human knows to fix the script. + +Usage: + python3 scripts/mcp-doc-gen.py # writes to docs/api-mcp-layer.md + python3 scripts/mcp-doc-gen.py --dry-run # prints diff to stdout + python3 scripts/mcp-doc-gen.py --check # exit 1 if the file is out of date +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +ROUTE_TS = REPO_ROOT / "src/app/api/mcp/route.ts" +DOC_FILE = REPO_ROOT / "docs/api-mcp-layer.md" + +# Begin / end markers for the auto-generated block. Everything between +# the begin and end markers is replaced wholesale; everything outside is +# preserved verbatim. The begin marker is followed by a 5-line header so +# the doc stays readable even when the script is rerun. +BEGIN_MARKER = "" +END_MARKER = "" + + +def parse_tools(route_text: str) -> list[dict]: + """Extract `{ name, description, inputSchema }` from the tools array. + + The tool descriptions are JS string literals that may be concatenated + (`"foo" + "bar"`) or use template literals. We collapse both forms + before stopping at the matching `inputSchema: {` block. + """ + # Find the start of the `const tools = [...]` array. + array_match = re.search(r"const tools\s*=\s*\[", route_text) + if not array_match: + raise SystemExit("Could not find `const tools = [...]` in route.ts") + start = array_match.end() + # Walk the array tracking string-literal depth so we don't bail on + # nested arrays in inputSchema. The whole file is one giant string, + # so we walk char-by-char. + tools: list[dict] = [] + i = start + while i < len(route_text): + # Look for the next tool entry: `{ name: "..."`. + m = re.search(r"\{\s*name:\s*\"([^\"]+)\"", route_text[i:]) + if not m: + break + name = m.group(1) + # Skip ahead past the name. m.end() is relative to text[i:], + # so adding it to i gives the absolute position right after the + # closing quote of the name. + entry_start = i + m.start() + i += m.end() + # Find the description block. Three shapes: + # description: "literal text" + # description: "first segment" + "second segment" + ... + # description: `template literal` + # A comma may or may not separate `name` from `description` (looking + # at the route.ts source, the convention is no comma between the two + # top-level keys but a comma before `inputSchema`). + desc_match = re.match(r"\s*,\s*description:\s*", route_text[i:]) + if not desc_match: + # Malformed entry — skip past it. + i = entry_start + 2 + continue + i += desc_match.end() + if route_text[i] == "`": + # Template literal — read until the closing backtick. + close = route_text.find("`", i + 1) + if close == -1: + raise SystemExit(f"Unterminated template literal for {name}") + desc = route_text[i + 1 : close] + i = close + 1 + elif route_text[i] == '"': + desc = "" + # Walk through what may be several concatenated `"..."` + ... forms. + while True: + # Find the closing quote, skipping backslash escapes. + j = i + 1 + while j < len(route_text): + if route_text[j] == "\\": + j += 2 + continue + if route_text[j] == '"': + break + j += 1 + if j >= len(route_text): + raise SystemExit(f"Unterminated string for {name}") + desc += route_text[i + 1 : j] + i = j + 1 + # Skip whitespace + `+` chains. + while i < len(route_text) and route_text[i] in " \t\n\r": + i += 1 + if i < len(route_text) and route_text[i] == "+": + i += 1 + while i < len(route_text) and route_text[i] in " \t\n\r": + i += 1 + if i < len(route_text) and route_text[i] == '"': + continue + break + else: + # Malformed entry — bail. + i = entry_start + 2 + continue + # Unescape JS string escapes. + desc = ( + desc.replace("\\n", "\n") + .replace("\\t", " ") + .replace('\\"', '"') + .replace("\\\\", "\\") + ) + # Collapse whitespace to single spaces for the table summary. + desc_short = re.sub(r"\s+", " ", desc).strip() + if len(desc_short) > 240: + desc_short = desc_short[:237] + "..." + tools.append({"name": name, "description": desc_short, "description_full": desc.strip()}) + return tools + + +def render_catalogue(tools: list[dict]) -> str: + """Render the markdown block between the begin/end markers.""" + lines = [ + BEGIN_MARKER, + "", + f"This catalogue is auto-generated from the `tools` array in " + f"`src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. " + f"There are **{len(tools)} tools** as of this run. " + f"Re-run the script after adding or renaming a tool.\n", + "Grouped by resource family. The full description is in the table " + "column; the description text the agent sees is preserved verbatim.\n", + ] + groups = [ + ("orders", "Orders"), + ("order_items", "Order items"), + ("quotes", "Quotes"), + ("quote_items", "Quote items"), + ("attachments", "Attachments"), + ("companies", "Companies"), + ("customers", "Customers"), + ("suppliers", "Suppliers"), + ("purchase_orders", "Purchase orders"), + ("stock_purchases", "Stock purchases"), + ("stock_purchase_items", "Stock purchase items"), + ("stock_items", "Stock items"), + ("stock_fulfill", "Stock fulfilment"), + ("stock_surplus", "Stock surplus"), + ("collection_pool", "Collection pool"), + ("collection_confirm", "Collection confirm"), + ("collection_map", "Collection map"), + ("delivery", "Deliveries"), + ("driver", "Driver"), + ("sprint", "Sprint"), + ("buyer_tasks", "Buyer tasks"), + ("price_list", "Price list"), + ("ai", "AI / Insights"), + ("users", "Users"), + ("files", "Files"), + ("bulk_update", "Bulk updates"), + ("categories", "Categories"), + ("dashboard", "Dashboard"), + ("purchase_order_agent", "PO agent"), + ("misc", "Misc"), + ] + # Group tools by their first dot-separated segment. + by_group: dict[str, list[dict]] = {g[0]: [] for g in groups} + for t in tools: + prefix = t["name"].split(".")[0] + if prefix in by_group: + by_group[prefix].append(t) + else: + by_group.setdefault("misc", []).append(t) + # Render each group in the defined order; skip empties. + for prefix, title in groups: + items = by_group.get(prefix) or [] + if not items: + continue + lines.append(f"\n### {title}\n") + lines.append("| Tool | Description |") + lines.append("| --- | --- |") + for t in items: + # Escape pipes in the description so the table stays readable. + desc = t["description"].replace("|", "\\|").replace("\n", " ") + lines.append(f"| `{t['name']}` | {desc} |") + # Alphabetical index — one line so it scans quickly. + sorted_names = sorted(t["name"] for t in tools) + lines.append("\n### Alphabetical index\n") + lines.append("`" + "`, `".join(sorted_names) + "`\n") + lines.append(END_MARKER) + return "\n".join(lines) + + +def regenerate_doc(doc_text: str, tools: list[dict]) -> str: + catalogue = render_catalogue(tools) + if BEGIN_MARKER not in doc_text or END_MARKER not in doc_text: + # First run — append the markers to the end of the file. + return doc_text.rstrip() + "\n\n---\n\n## 5. MCP tool catalogue (auto-generated)\n\n" + catalogue + # Replace the block between markers. The markers are on their own lines + # so a regex with the DOTALL flag is sufficient. + pattern = re.compile( + re.escape(BEGIN_MARKER) + r".*?" + re.escape(END_MARKER), + re.DOTALL, + ) + return pattern.sub(catalogue.rstrip("\n"), doc_text) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dry-run", action="store_true", help="Print the new catalogue to stdout instead of writing") + parser.add_argument("--check", action="store_true", help="Exit 1 if regenerating would change the file") + args = parser.parse_args() + + route_text = ROUTE_TS.read_text() + tools = parse_tools(route_text) + print(f"Parsed {len(tools)} tools from {ROUTE_TS}", file=sys.stderr) + + doc_text = DOC_FILE.read_text() + new_text = regenerate_doc(doc_text, tools) + + if args.dry_run: + print(new_text) + return 0 + if args.check: + if new_text == doc_text: + print("docs/api-mcp-layer.md is up to date", file=sys.stderr) + return 0 + print("docs/api-mcp-layer.md is OUT OF DATE — run scripts/mcp-doc-gen.py", file=sys.stderr) + return 1 + DOC_FILE.write_text(new_text) + print(f"Wrote {DOC_FILE} ({len(tools)} tools)", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/app/api/mcp/FEATURE-REQUESTS.md b/src/app/api/mcp/FEATURE-REQUESTS.md index 5e6b96f..1530947 100644 --- a/src/app/api/mcp/FEATURE-REQUESTS.md +++ b/src/app/api/mcp/FEATURE-REQUESTS.md @@ -79,14 +79,6 @@ When one ships, move it to **Implemented** with a one-line commit/PR reference. - **Schema needs:** none — every field already exists on `orders` / `order_items` - **Open questions:** if we ship this, does `quotes.*` still matter as a destination, or do we collapse quote and order into "draft vs committed"? Currently the app forces the two-step dance; an opinionated answer here would also clean up the buyer flow. -### Quote/order items: whitelist missing `category` field -- **Surfaced:** 2026-08-06 — "can you see the category and ai insights? can you change the category" -- **Workaround:** reads work fine (`category` is returned on every item + `expand=category` exposes the name/match_keywords). Writes are blocked — same shape as the `sku` whitelist gap: `QUOTE_ITEM_PATCH_FIELDS` and `ORDER_ITEM_PATCH_FIELDS` in `route.ts` don't list `category`, so any patch with it is rejected with `forbidden_field`. Until that whitelist gap is closed + deployed, the only way to recategorize is via PB admin UI. Audit found 2 items needing change: KYOCERA TIPS (Fasteners → Cutting Tools) and Bulldog Disc (Unknown → Fasteners). -- **Severity:** now — recategorization is a real operator workflow and Hermes can't do it -- **Suggested tool:** no new tool — fold into the same `route.ts` whitelist expansion that fixes `sku` (and consider regenerating the whitelists from the live PB schema so this stops happening). Optional companion tool: `categories.classify({description})` running the master-list keyword logic in `route.ts` so the AI agent doesn't have to memorise the 10 categories. -- **Schema needs:** none -- **Open questions:** none — same pattern as the `sku` entry above. - ### Stock purchases: no PDF → stock order ingestion - **Surfaced:** 2026-08-06 — "can you load a stock order from a pdf that i give you or items" - **Workaround:** `stock_purchases_create({supplier, items:[...]})` works for structured input but the operator has to type each line. No tool accepts a PDF or an email payload. Existing attachment uploads (`purchase_orders_add_attachment`, `quotes_list_attachments`) attach files *to existing records*; they don't parse them. @@ -127,6 +119,9 @@ These came up but need a human answer before code changes: ## Implemented +- 2026-08-14 — **Block A: "feels restricted" MCP pain fixes.** (a) `scripts/mcp-doc-gen.py` now auto-regenerates the catalogue in `docs/api-mcp-layer.md` from the live `tools` array — the doc had drifted to claiming 23 tools when the real count was 103. Run `python3 scripts/mcp-doc-gen.py --check` in CI. (b) Hard-coded `actorId`/`actorName` in `purchase_orders.record_diff_report` and `stock_purchases.receive` replaced with required `actor: { id, name }` input — both match the shape already used by `order_items.confirm_collected`. Audit entries now record the real agent identity. (c) Whitelist drift closed: `category` added to `QUOTE_ITEM_PATCH_FIELDS`; `sku`, `category`, `unit` added to `ORDER_ITEM_PATCH_FIELDS` and to `order_items.update`'s inputSchema. Resolves the "can I change the category?" / "set the SKU on PO items" requests above. (d) Eight terse tool descriptions (`orders.create_from_quote`, `order_items.update_status`, `collection_pool.list`, `collection_confirm.list`, `quotes.find_by_message_id`, `quotes.create_from_template`, `purchase_orders.retry_email`, `quotes.retry_email`, `sprint.item_attachments`) bulked up to match the bar set by `order_items.confirm_collected`. (e) New `docs/agents/workflows.md` cookbook — six end-to-end recipes an agent can drive without further prompting (supplier invoice, order split, partial collection, bulk pricing, partial approve + split, sprint claim). +- 2026-08-14 — **Block E: 22 new MCP tools wrapping the missing REST surface.** Categories added to the catalogue: `users.lookup` (operator identity for audit stamps); `dashboard.daily_summary`, `orders.sprint_counts`, `orders.find_duplicates` (aggregate reads); `orders.review`, `orders.matched_attachments` (operator-triage reads); `files.fetch` (PB file → base64 proxy); `drivers.list_stops`, `drivers.update_stop_status` (driver dispatch); `stock_quick_draft.search`, `.add_line`, `.suppliers` (buyer quick-draft); `delivery_notes.list`, `.create`, `.cancel`, `.mark_invoiced`, `.mark_complete`, `.regenerate_pdf`, `.create_quote`, `.set_customer_po`, `.set_item_quantity`, `.combine` (full DN write surface). Total tool count: 103 → 125. Each new tool wraps the matching `/api/v1/*` REST route via `fetch` (preserves the audit trail + service-layer behaviour). `delivery_notes.list` reads the `delivery_notes` collection directly via the admin PB client because the REST surface has no GET; all other reads/writes go through REST. Closes the "delivery-notes.*", "drivers.*", "stock_quick_draft.*", "dashboard.daily_summary", "users.lookup", "orders.review", "files.fetch" gaps surfaced in the audit. + - 2026-08-06 — `stock_purchases.add_attachment` shipped (commits `f89bbb3`, `dd57d29`). Adds a sibling to `purchase_orders.additional_attachments` plus the matching REST upload/delete routes and a UI card on `/stock-orders/[id]`. PB schema: new `stock_purchases.additional_attachments` file field (multi, maxSelect 10, any MIME). - 2026-08-07 — Split-invoice stock-PO workflow shipped. 5 new MCP tools (`stock_purchase_items.update`, `stock_purchase_items.bulk_update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.mark_collected`, `stock_items.update`) and the underlying services (`stock-purchase-items-service.ts`, extended `stock-items-service.ts`). Closes the per-line write gap surfaced by the operator's split-delivery scenario (PO with multiple supplier invoices arriving on different days). `mark_collected` auto-flips the parent PO header to `received` when every line is fully collected. PB schema: new `stock_purchase_items.sku` / `unit` / `supplier_sku` text fields (migration `scripts/migrations/2026-08-07-stock-purchase-items-catalogue-fields.ts`, user applies via PB admin). - 2026-08-09 — Buyer-side supplier-invoice reconciliation shipped. New MCP tool `stock_purchases.match_and_receive_from_invoice` (single commit, no schema change). The agent parses a scanned supplier invoice with its native vision, passes the structured fields (`po_reference`, `document.{document_no,date,supplier_name?,lines[]}`, optional `attachment` bytes) plus a `confirm` flag. Deterministic line-by-line matching: `stock_item.sku` exact → `stock_purchase_item.supplier_sku` exact → description token overlap (≥80%). Verdict enum (`ready_to_receive | po_not_found | wrong_status | supplier_mismatch | unmatched_po_lines | unmatched_invoice_lines | qty_mismatch | cost_mismatch`) drives the HTTP status (200/404/409/422). Dry-run by default; `confirm=true` only commits when `verdict === "ready_to_receive"` — on success it attaches the invoice image using the `supplier_invoice___.` naming convention (PB appends its random token at write time, matching the operator's existing `supplier_invoice_afriq_ina64721_2026_08_06_…` format on PO26073109STK), then calls `stock_purchases.receive` in the same round-trip. **Three follow-up phases added the same day:** diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index a39640b..fb71230 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -107,7 +107,9 @@ function jsonRpcOk(id: unknown, result: unknown, status?: number) { // `quantity` — is rejected with `forbidden_field`. The same fields // the existing /api/v1/quotes/[id]/re-price route writes, plus the // few extras (pricing_notes, user_edited, matched_price_list_id, -// pricing_source) that the AI auto-fill needs. +// pricing_source, category) that the AI auto-fill needs. `category` +// is a PB relation to the `categories` collection (id-only write here +// — the agent resolves id from `categories.list` first). const QUOTE_ITEM_PATCH_FIELDS = [ "cost_price", "selling_price", @@ -115,6 +117,7 @@ const QUOTE_ITEM_PATCH_FIELDS = [ "sku", "unit", "supplier", + "category", "markup", "pricing_source", "pricing_notes", @@ -147,12 +150,18 @@ const QUOTE_PATCH_FIELDS = [ // supplier_invoice_number, cost_price) against what was originally // ordered (quantity) and what was collected (qty_collected). System- // controlled fields (id, created, updated, order, purchase_order, -// quote_item, product) are excluded. +// quote_item, product) are excluded. `category` mirrors +// `QUOTE_ITEM_PATCH_FIELDS` and is a PB relation to `categories`. +// `unit` was added because invoice reconciliation often exposes a +// different shipping unit than the order was placed in (e.g. the +// supplier sends "BOX OF 12" where the PO said "EA"). const ORDER_ITEM_PATCH_FIELDS = [ "cost_price", "selling_price", "quantity", "sku", + "unit", + "category", "markup", "markup_percent", "notes", @@ -277,12 +286,19 @@ const tools = [ }, { name: "orders.create_from_quote", - description: "Create a new order and order items from an existing quote", + description: + "Create a new order and its order_items from an existing quote. The standard pipeline for turning a customer-accepted quote into an operational order. Use this when the operator says 'convert Q-2026-08-014 to an order' or 'place the order for this quote'.\n\n" + + "Semantics:\n" + + "- The quote is cloned into a new order with a fresh `ORD-YYYY-MM-NNN` number; the original quote is untouched.\n" + + "- Each quote_item becomes an order_item with `item_status=\"pending\"` and `drv_status=\"unassigned\"`. Supplier, cost_price, and selling_price flows through verbatim from the quote.\n" + + "- The order inherits customer, company, rep, and the matched quote's letterhead/notes.\n" + + "- If the quote has no matched supplier / cost_price / selling_price, the corresponding order_item fields stay blank — `bulk_update_quote_items` should be re-run on the quote (then re-create the order) before generating a PO.\n\n" + + "For a fresh order WITHOUT a quote (counter sale, walk-in), use `orders.create` instead. For re-creating an order that has already been created, prefer `orders.clone` style workflows via the `app/(app)/orders/new` page.", inputSchema: { type: "object", required: ["quote_id"], properties: { - quote_id: { type: "string" }, + quote_id: { type: "string", description: "15-char PocketBase quote id (from `quotes.list` or `quotes.lookup`)." }, }, }, }, @@ -369,28 +385,32 @@ const tools = [ }, { name: "collection_pool.list", - description: "List collection_pool records by pool status", + description: + "List `collection_pool` records (warehouse-side goods awaiting driver assignment) by status. Use this when the operator asks 'what's on the loading floor?' or 'show me everything waiting for a driver'. Pass `status` to narrow by `pending|assigned|collected|in_transit|delivered` (omit for all). Returns newest-first.\n\n" + + "For the BUYER-side flow (goods arriving from supplier, awaiting invoice confirmation), use `collection_confirm.list` instead — that works on the `order_items` table while this works on `stock_purchase_items`.", inputSchema: { type: "object", properties: { page: { type: "number" }, perPage: { type: "number" }, - status: { type: "string" }, + status: { type: "string", description: "One of `pending | assigned | collected | in_transit | delivered`. Omit for all statuses." }, }, }, }, { name: "order_items.update_status", - description: "Update order item status using item_status and drv_status", + description: + "Update an order_item's `item_status` and/or `drv_status` fields. Use this when the operator needs to flip a single row through the workflow without bulk-pricing or per-line invoice reconciliation (`order_items.update` is the patch tool for those — this tool is status-only).\n\n" + + "Valid `item_status` values include `pending | po_generated | assigned | collection_pool | collected | delivered | invoiced | cancelled` (every schema-level value is accepted; invalid values return 422 from PB). `drv_status` is independent (e.g. an item can be `assigned` to a driver but still `pending` in the warehouse flow). `notes` and `problem_notes` are appended to the row's audit trail — use `problem_notes` for issues that operators need to see (driver didn't show, wrong qty, etc.).", inputSchema: { "type": "object", "required": ["id"], "properties": { - id: { "type": "string" }, - item_status: { "type": "string" }, - drv_status: { "type": "string" }, - notes: { "type": "string" }, - problem_notes: { "type": "string" }, + id: { "type": "string", description: "15-char order_item id." }, + item_status: { "type": "string", description: "Workflow state. See description for the full enum." }, + drv_status: { "type": "string", description: "Driver-side state. Independent of `item_status`." }, + notes: { "type": "string", description: "Free-text appended to the row's audit trail." }, + problem_notes: { "type": "string", description: "Free-text problem note appended to the audit trail and surfaced to operators." }, }, }, }, @@ -443,7 +463,7 @@ const tools = [ { name: "order_items.update", description: - "Patch fields on an order_item for invoice reconciliation. Whitelist: cost_price, selling_price, quantity, sku, markup, markup_percent, notes, qty_processed, qty_assigned, qty_collected, qty_verified, qty_stocked, qty_picked, qty_loaded, qty_delivered, qty_invoiced, qty_cancelled, supplier_invoice_number, customer_invoice_number, collection_date, collected_date, verified_date, delivered_date, invoiced_date. Use this to compare what the supplier invoiced (supplier_invoice_number + qty_invoiced + cost_price) against what was ordered (quantity) and what was collected (qty_collected), and patch the differences. Status-only updates go through `order_items.update_status` instead.", + "Patch fields on an order_item for invoice reconciliation. Whitelist: cost_price, selling_price, quantity, sku, unit, category, markup, markup_percent, notes, qty_processed, qty_assigned, qty_collected, qty_verified, qty_stocked, qty_picked, qty_loaded, qty_delivered, qty_invoiced, qty_cancelled, supplier_invoice_number, customer_invoice_number, collection_date, collected_date, verified_date, delivered_date, invoiced_date. Use this to compare what the supplier invoiced (supplier_invoice_number + qty_invoiced + cost_price) against what was ordered (quantity) and what was collected (qty_collected), and patch the differences. `category` is a PB relation id (resolve it from `categories.list` first, not free text). `unit` is useful when the supplier ships in a different pack/measure than the PO. Status-only updates go through `order_items.update_status` instead.", inputSchema: { type: "object", required: ["id", "patch"], @@ -457,6 +477,9 @@ const tools = [ cost_price: { type: "number" }, selling_price: { type: "number" }, quantity: { type: "number" }, + sku: { type: "string", description: "Free-text supplier / catalogue SKU. Tried-and-true for one-letter typos; use `order_items.update_status` to flip status instead." }, + unit: { type: "string", description: "Pack/measure unit (EA, BOX, M, L, KG). Useful when supplier invoice unit differs from the original PO." }, + category: { type: "string", description: "PB relation id to the `categories` collection. Resolve from `categories.list` first." }, markup: { type: "number" }, markup_percent: { type: "number" }, notes: { type: "string" }, @@ -650,12 +673,14 @@ const tools = [ }, { name: "quotes.find_by_message_id", - description: "Look up a quote by the inbound email's RFC 5322 Message-ID header.", + description: + "Look up an existing quote by the inbound email's RFC 5322 Message-ID header. Use this when the operator (or another agent) needs to correlate an inbound email back to a quote that was already created from it — e.g. to attach a reply, append a follow-up note, or merge a second-message quote revision into the same `quote_number`.\n\n" + + "Returns the matching quote row (with `quote_number`, `status`, `customer`, `company`, `subject`) or `null` if no quote carries that `message_id`. For matching by subject / `Q-YYYY-MM-NNN` (where the customer changed the subject line), use `quotes.lookup` instead — that does fuzzy matching across subject, customer name, and reference.", inputSchema: { type: "object", required: ["message_id"], properties: { - message_id: { type: "string" }, + message_id: { type: "string", description: "RFC 5322 Message-ID header from the inbound email (e.g. ``)." }, }, }, }, @@ -739,11 +764,11 @@ const tools = [ { name: "quotes.retry_email", description: - "Retry the outbound email send for a quote currently in `mail_failed`. Re-fires the n8n workflow.", + "Retry the outbound email send for a quote currently in `mail_failed`. Use this when SMTP credentials have been rotated, the customer's MX changed, or the original send bounced. The quote must already be in `mail_failed` (409 if not) — for a fresh send on a `sent` / `approval_pending` quote, use `quotes.send_email` instead. The quote flips back to `sending` and the n8n SMTP workflow re-runs asynchronously.", inputSchema: { type: "object", required: ["id"], - properties: { id: { type: "string" } }, + properties: { id: { type: "string", description: "15-char PocketBase quote id." } }, }, }, { @@ -759,11 +784,11 @@ const tools = [ { name: "quotes.create_from_template", description: - "Create a new real quote from a template (`is_template=true` quote). The template is not modified.", + "Create a new real quote from a template (`is_template=true` quote). The template is not modified — a fresh quote is created with a new `Q-YYYY-MM-NNN` number, `status=\"new\"`, and the template's customer / company / letterhead / items copied in. Use this for the 'send the same suppliers-as-last-month quote again' workflow after listing templates via `quotes.list` with `filter='is_template=true'`.", inputSchema: { type: "object", required: ["template_id"], - properties: { template_id: { type: "string" } }, + properties: { template_id: { type: "string", description: "15-char PocketBase id of the `is_template=true` quote to instantiate." } }, }, }, { @@ -1049,11 +1074,11 @@ const tools = [ { name: "purchase_orders.retry_email", description: - "Retry the outbound email send for a PO currently in `mail_failed`.", + "Retry the outbound email send for a PO currently in `mail_failed`. Use this when SMTP credentials have been rotated, the supplier's MX changed, or the original send bounced (e.g. quota exceeded). The PO must already be in `mail_failed` (409 if not) — for a fresh send on a `draft` PO, use `purchase_orders.send_email` instead. The PO flips back to `sending` and the n8n SMTP workflow re-runs asynchronously; poll `purchase_orders.send_email.outcome` (REST) or wait for the next webhook.", inputSchema: { type: "object", required: ["id"], - properties: { id: { type: "string" } }, + properties: { id: { type: "string", description: "15-char PocketBase purchase_order id." } }, }, }, { @@ -1088,7 +1113,7 @@ const tools = [ description: "Patch many `quote_items` rows in a single MCP call (one JSON-RPC round-trip instead of N PATCHes to PB REST). Use this for AI auto-fill, supplier switches, or any bulk pricing update. Each row is independent — one row's failure never affects another.\n\n" + "Rules:\n" + - "- Field whitelist: only the following fields are patchable: cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited. Any other key (including `id`, `quote`, `created`, `updated`, `quantity`) is rejected with reason=`forbidden_field`.\n" + + "- Field whitelist: only the following fields are patchable: cost_price, selling_price, description, sku, unit, supplier, category, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited. Any other key (including `id`, `quote`, `created`, `updated`, `quantity`) is rejected with reason=`forbidden_field`. `category` is a PB relation id (resolve from `categories.list` first; free-text values are rejected).\n" + "- Hand-edit guard: if a row's existing `cost_price > 0` AND `selling_price > 0`, it is skipped with reason=`already_priced`. Pass `force_overwrite: true` on that row to overwrite anyway.\n" + "- Per-row independence: no partial-batch rollback. If row 14 of 26 fails, the rest still apply.\n" + "- Concurrency: applied sequentially (concurrency=1) so the response surface stays predictable.\n" + @@ -1114,7 +1139,7 @@ const tools = [ patch: { type: "object", description: - "Subset of `{cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited}`. Anything else is rejected.", + "Subset of `{cost_price, selling_price, description, sku, unit, supplier, category, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited}`. Anything else is rejected.", }, force_overwrite: { type: "boolean", @@ -1205,7 +1230,7 @@ const tools = [ { name: "quote_items.update", description: - "Patch a single `quote_items` row. Patch whitelist is identical to `bulk_update_quote_items`: cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited. Any other key (including `id`, `quote`, `created`, `updated`, `quantity`) is rejected with reason=`forbidden_field`.\n\n" + + "Patch a single `quote_items` row. Patch whitelist is identical to `bulk_update_quote_items`: cost_price, selling_price, description, sku, unit, supplier, category, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited. Any other key (including `id`, `quote`, `created`, `updated`, `quantity`) is rejected with reason=`forbidden_field`.\n\n" + "Unlike `bulk_update_quote_items`, this tool does NOT enforce the hand-edit guard (`already_priced` skip). Use this endpoint for explicit single-row writes; use bulk when you want the safety net.", inputSchema: { type: "object", @@ -1457,10 +1482,10 @@ const tools = [ { name: "purchase_orders.record_diff_report", description: - "Persist the full MatchReport from `purchase_orders.diff` (or any source) onto the PO's `notes` field as a structured JSON entry. The entry uses category `price_diff_report` so future UIs can filter on it. Uses the agent's fixed `actorId: 'po-agent'` + `actorName: 'Purchase Order Agent'`. Does NOT mutate any other field — safe to call alongside `stock_purchase_items.bulk_update` and `stock_purchase_items.mark_collected` so the report stays as a queryable audit stamp even after the agent's commit. Returns the new entry's index in the notes feed + the new total entry count.", + "Persist the full MatchReport from `purchase_orders.diff` (or any source) onto the PO's `notes` field as a structured JSON entry. The entry uses category `price_diff_report` so future UIs can filter on it. The caller must pass `actor: { id, name }` so the audit trail records the actual agent identity (use `users.lookup` to discover the right id/name for the operator running this session). Does NOT mutate any other field — safe to call alongside `stock_purchase_items.bulk_update` and `stock_purchase_items.mark_collected` so the report stays as a queryable audit stamp even after the agent's commit. Returns the new entry's index in the notes feed + the new total entry count.", inputSchema: { type: "object", - required: ["po_reference", "report"], + required: ["po_reference", "report", "actor"], properties: { po_reference: { type: "string", @@ -1477,6 +1502,15 @@ const tools = [ description: "Optional one-line human-readable summary stashed on the entry's `body.summary` field (e.g. '5 lines matched cleanly, 1 cost discrepancy on line 3'). Useful for the UI's audit-list view.", }, + actor: { + type: "object", + required: ["id", "name"], + description: "Identity recorded on the note entry. Use `users.lookup` if you need to discover the right id/name for the operator running this session.", + properties: { + id: { type: "string" }, + name: { type: "string" }, + }, + }, }, }, }, @@ -1536,10 +1570,10 @@ const tools = [ { name: "stock_purchases.receive", description: - "Mark a `sent` stock_purchase as `received`, booking each line's qty into the linked stock_item and writing a `stock_ledger` row per line. Optional `items` array supports partial receives via per-line `quantity_received` overrides. Idempotent at the purchase-status level — a second call returns `wrong_status` (409).", + "Mark a `sent` stock_purchase as `received`, booking each line's qty into the linked stock_item and writing a `stock_ledger` row per line. Optional `items` array supports partial receives via per-line `quantity_received` overrides. Idempotent at the purchase-status level — a second call returns `wrong_status` (409). The caller must pass `actor: { id, name }` so the stock_ledger rows record the actual agent identity (use `users.lookup` to discover the right id/name for the operator running this session).", inputSchema: { type: "object", - required: ["id"], + required: ["id", "actor"], properties: { id: { type: "string" }, items: { @@ -1554,6 +1588,15 @@ const tools = [ }, }, }, + actor: { + type: "object", + required: ["id", "name"], + description: "Identity recorded on each stock_ledger row. Use `users.lookup` if you need to discover the right id/name for the operator running this session.", + properties: { + id: { type: "string" }, + name: { type: "string" }, + }, + }, }, }, }, @@ -1836,11 +1879,11 @@ const tools = [ { name: "sprint.item_attachments", description: - "List attachments on a single sprint item (id + filename + content_type).", + "List the pricing-proof attachments on a single sprint item (the supplier PDFs, screenshots, and pasted emails an agent previously uploaded). Use this BEFORE picking a sprint row so you can see what evidence is already there, and BEFORE marking a row done so you can attach the missing artefacts. Returns `[{ id, filename, content_type }]` — does NOT stream the file bytes (use `files.fetch` if you need the actual PDF).", inputSchema: { type: "object", required: ["id"], - properties: { id: { type: "string", description: "sprint item id" } }, + properties: { id: { type: "string", description: "15-char sprint item id." } }, }, }, { @@ -2336,6 +2379,344 @@ const tools = [ "Returns the tool list as a flat array (no JSON-RPC envelope). Equivalent to `tools/list` but useful for clients that can't easily build a JSON-RPC request.", inputSchema: { type: "object", properties: {} }, }, + + // ─── Users (lookup) ───────────────────────────────────────────────── + // PB's `users` viewRule blocks a regular caller from reading other + // users. The REST endpoint at /api/v1/users/lookup uses the admin PB + // to return only {id, name, email} — exactly what an agent needs to + // populate the `actor: { id, name }` field on `order_items. + // confirm_collected` or `purchase_orders.record_diff_report`. + { + name: "users.lookup", + description: + "Look up PB users for audit-stamp `actor: { id, name }` fields. Returns the full sorted list `[{id, name, email}]` — no passwords, tokens, or other sensitive fields. Use this BEFORE calling tools that require an actor identity (`order_items.confirm_collected`, `purchase_orders.record_diff_report`, `stock_purchases.receive`) so the audit trail records the right human.", + inputSchema: { type: "object", properties: {} }, + }, + + // ─── Dashboard / aggregate ────────────────────────────────────────── + { + name: "dashboard.daily_summary", + description: + "Single-call daily snapshot of the order pipeline: counts by `item_status` (pending, collection_pool, assigned, collected, delivered, invoiced), top suppliers by open volume, value totals, etc. Wraps `GET /api/v1/dashboard`. Use this when the operator asks 'how are we doing today?' or 'where are we stuck?'.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "orders.sprint_counts", + description: + "Counts of sprint-pricing rows grouped by claim status (unclaimed / claimed-by-me / claimed-by-other / resolved). Used to render sprint badges in the operator UI and to decide whether an agent should claim a row. Wraps `GET /api/v1/orders/sprint/counts`.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "orders.find_duplicates", + description: + "Find probable duplicate orders for the same customer + recent window. Wraps `GET /api/v1/orders/duplicates`. Use this BEFORE creating a new order from a customer's RFQ so the operator doesn't accidentally double-order. Returns groups with shared customer/contact/items.", + inputSchema: { type: "object", properties: {} }, + }, + + // ─── Orders side helpers ──────────────────────────────────────────── + { + name: "orders.review", + description: + "Read-only review bundle for one order: header, items grouped by supplier, PO readiness, attachment matches against quote items, and any pending decisions. Wraps `GET /api/v1/orders/[id]/review`. Use this BEFORE generating POs or splitting an order — the response tells you which lines are ready vs blocked.", + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "15-char PB order id." }, + }, + }, + }, + { + name: "orders.matched_attachments", + description: + "Match the order's quote attachments against each order_item by description / SKU overlap. Wraps `GET /api/v1/orders/[id]/matched-attachments`. Returns `{ item_id → [attachment_filename, ...] }` so the agent can decide which proof applies to which line before invoicing.", + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "15-char PB order id." }, + }, + }, + }, + + // ─── Files proxy ──────────────────────────────────────────────────── + // WFDASH files live in PB; the browser uses PB's authenticated + // `/api/files/...` URLs directly. Agents that can't carry a PB + // cookie need a base64 proxy. Pass any (collection, recordId, + // filename) triple you got from another tool — quotes.get_pdf's + // `filename`, purchase_orders.list_attachments' filenames, etc. + { + name: "files.fetch", + description: + "Fetch a single file from PocketBase and return it as base64 in the MCP response. Wraps `GET /api/v1/files/[collection]/[recordId]/[filename]`. Use this to retrieve a generated quote PDF, a supplier invoice scan, a delivery-note signed copy, or any other attachment that another tool listed by filename. Max 25 MB.", + inputSchema: { + type: "object", + required: ["collection", "record_id", "filename"], + properties: { + collection: { + type: "string", + description: + "PB collection slug hosting the file (e.g. `quotes`, `orders`, `purchase_orders`, `stock_purchases`, `delivery_notes`).", + }, + record_id: { type: "string", description: "15-char PB record id owning the file." }, + filename: { type: "string", description: "Exact filename as returned by the source tool (e.g. `Q-2026-08-014.pdf`)." }, + }, + }, + }, + + // ─── Driver dispatch ──────────────────────────────────────────────── + { + name: "drivers.list_stops", + description: + "List today's driver stops with statuses and assigned items. Wraps `GET /api/v1/driver/stops`. Returns `[{ stop_id, status, customer, address, items, ... }]`. Use this to plan a driver's run or to find a stop the operator is asking about ('where is stop 14 right now?').", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "drivers.update_stop_status", + description: + "Record a driver-side action on a stop (collected, problem). Wraps `PATCH /api/v1/driver/stops/[id]/status`. For `action: \"collected\"` on a pickup, `qty_collected` is required. For `action: \"problem\"`, `problem_type` is required (one of `short_supply | wrong_item | damaged | no_show | other`). Returns `{ ok, stop }` with the updated DriverStop.", + inputSchema: { + type: "object", + required: ["stop_id", "kind", "action"], + properties: { + stop_id: { type: "string", description: "Stop id from `drivers.list_stops`." }, + kind: { + type: "string", + enum: ["delivery", "pickup"], + description: "Whether the stop is a customer delivery or a supplier pickup.", + }, + action: { + type: "string", + enum: ["collected", "problem"], + description: "Driver action: \"collected\" flips status to collected; \"problem\" flags a delivery issue.", + }, + qty_collected: { + type: "number", + description: "Required when kind=\"pickup\" + action=\"collected\". Qty the driver brought back.", + }, + problem_type: { + type: "string", + enum: ["short_supply", "wrong_item", "damaged", "no_show", "other"], + description: "Required when action=\"problem\".", + }, + problem_notes: { type: "string", description: "Free-text detail on the problem (≤1000 chars)." }, + notes: { type: "string", description: "Optional free-text note for the stop's audit trail." }, + }, + }, + }, + + // ─── Stock quick-draft ────────────────────────────────────────────── + // Buyer-side helper for drafting a stock PO without leaving the chat. + // Picks the supplier with the best recent price for each line, or + // matches a previous PO line by SKU. + { + name: "stock_quick_draft.search", + description: + "Search the quick-draft index for SKUs / descriptions / suppliers. Wraps `GET /api/v1/stock-quick-draft/search`. Returns ranked hits with last-known cost and supplier. Use this BEFORE adding a line so the agent can pre-fill cost + supplier.", + inputSchema: { + type: "object", + required: ["q"], + properties: { + q: { type: "string", description: "Search term (SKU fragment, description, or supplier name)." }, + limit: { type: "number", description: "Max hits; default 20." }, + }, + }, + }, + { + name: "stock_quick_draft.add_line", + description: + "Add one line to an in-progress quick-draft PO. Wraps `POST /api/v1/stock-quick-draft/add-line`. The draft persists between calls until the operator hits 'Commit'. Use this when building up a stock PO one line at a time. Pass `stockItemId` for an existing catalogue row, or `newStockItem: { name, sku, ... }` to spin up a fresh stock_item + first line in one call. Returns `{ ok, purchaseId, purchaseNumber, lineId, createdDraft }`.", + inputSchema: { + type: "object", + required: ["supplierId", "quantity", "costPrice"], + properties: { + supplierId: { type: "string", description: "PB suppliers id (required). Use `stock_quick_draft.suppliers` to pick one." }, + quantity: { type: "number", description: "Qty to order (must be > 0)." }, + costPrice: { type: "number", description: "Per-unit cost (must be ≥ 0)." }, + stockItemId: { type: "string", description: "PB `stock_items` id for an existing catalogue row. Mutually exclusive with `newStockItem`." }, + newStockItem: { + type: "object", + description: "Inline-create a fresh stock_item + first line in one call. Mutually exclusive with `stockItemId`.", + required: ["name", "sku"], + properties: { + name: { type: "string" }, + sku: { type: "string" }, + description: { type: "string" }, + unit: { type: "string" }, + }, + }, + }, + }, + }, + { + name: "stock_quick_draft.suppliers", + description: + "List suppliers with quick-draft history (last ordered, line count, total spend). Wraps `GET /api/v1/stock-quick-draft/suppliers`. Use this to pick a supplier when the catalogue doesn't have a clear default.", + inputSchema: { type: "object", properties: {} }, + }, + + // ─── Delivery notes (read + write) ────────────────────────────────── + // REST only exposes POST endpoints (no GET for list/fetch), so the + // MCP wrappers below call REST for writes and reach into PB directly + // via the admin client for reads. + { + name: "delivery_notes.list", + description: + "List delivery notes. Reads the `delivery_notes` collection via the admin PB client. Supports `status` filter (draft | in_transit | invoiced | cancelled), `order_id` filter, and pagination. Newest-first. Use this when the operator asks 'show me outstanding delivery notes' or 'what's been delivered today'.", + inputSchema: { + type: "object", + properties: { + status: { + type: "string", + enum: ["draft", "in_transit", "invoiced", "cancelled"], + description: "Filter by status; omit for all.", + }, + order_id: { type: "string", description: "Filter to a single order's delivery notes." }, + page: { type: "number", default: 1 }, + perPage: { type: "number", default: 25, maximum: 100 }, + }, + }, + }, + { + name: "delivery_notes.create", + description: + "Create a counter-pickup delivery note (Path B — no upstream order). Wraps `POST /api/v1/delivery-notes`. Body: `customer` (PB id, required), `items[]` (required, ≥1 with description + quantity), optional `company`, `notes`, `createdBy`. Returns `{ id, delivery_note_number }` on 201.", + inputSchema: { + type: "object", + required: ["customer", "items"], + properties: { + customer: { type: "string", description: "PB customers id (required)." }, + company: { type: "string", description: "PB companies id; derived from customer.company when omitted." }, + notes: { type: "string", description: "Free-text DN-level note." }, + createdBy: { type: "string", description: "PB users id of the operator — looked up via `users.lookup` when omitted." }, + items: { + type: "array", + minItems: 1, + description: "Line items. Each must have a non-empty `description` and `quantity > 0`.", + items: { + type: "object", + required: ["description", "quantity"], + properties: { + description: { type: "string", description: "Item description (required)." }, + quantity: { type: "number", description: "Quantity (required, > 0)." }, + supplier: { type: "string", description: "PB suppliers id." }, + cost_price: { type: "number", description: "Cost price." }, + selling_price: { type: "number", description: "Selling price." }, + sku: { type: "string", description: "SKU / part number." }, + notes: { type: "string", description: "Free-text line note." }, + }, + }, + }, + }, + }, + }, + { + name: "delivery_notes.cancel", + description: + "Cancel a delivery note. Wraps `POST /api/v1/delivery-notes/[id]/cancel`. Body: optional `setById` (PB user id), `setByName`, and `note` for the audit trail. Use when a DN was created in error or the customer cancels the pickup.", + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "15-char delivery_notes id." }, + setById: { type: "string", description: "PB users id of the operator who cancelled. Look up via `users.lookup`." }, + setByName: { type: "string", description: "Display name fallback when `setById` isn't known." }, + note: { type: "string", description: "Reason for cancellation — recorded in the audit trail." }, + }, + }, + }, + { + name: "delivery_notes.mark_invoiced", + description: + "Flip a delivery note to `invoiced` after the customer's invoice is raised. Wraps `POST /api/v1/delivery-notes/[id]/mark-invoiced`. Idempotent; returns the updated DN record.", + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "15-char delivery_notes id." }, + }, + }, + }, + { + name: "delivery_notes.mark_complete", + description: + "Flip a delivery note to `completed` when goods are physically out the door. Wraps `POST /api/v1/delivery-notes/[id]/mark-complete`. Idempotent.", + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "15-char delivery_notes id." }, + }, + }, + }, + { + name: "delivery_notes.regenerate_pdf", + description: + "Force a fresh PDF render for a delivery note. Wraps `POST /api/v1/delivery-notes/[id]/regenerate-pdf`. Use after editing DN items when the operator wants the printed copy to reflect the latest state. Returns `{ ok, pdfSaved, filename }`.", + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "15-char delivery_notes id." }, + }, + }, + }, + { + name: "delivery_notes.create_quote", + description: + "Create a follow-up quote from a delivery note's items (e.g. when the customer wants a formal price for goods they took on account). Wraps `POST /api/v1/delivery-notes/[id]/create-quote`. Returns `{ id, quote_number }` for the new quote.", + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "15-char delivery_notes id." }, + }, + }, + }, + { + name: "delivery_notes.set_customer_po", + description: + "Stamp the customer's PO reference onto a delivery note for their AP team. Wraps `POST /api/v1/delivery-notes/[id]/customer-po`. Use when the customer emails a PO number after the goods have already been delivered.", + inputSchema: { + type: "object", + required: ["id", "customer_po"], + properties: { + id: { type: "string", description: "15-char delivery_notes id." }, + customer_po: { type: "string", description: "Customer's PO reference (non-empty after trim)." }, + }, + }, + }, + { + name: "delivery_notes.set_item_quantity", + description: + "Update the quantity on a single delivery-note line item. Wraps `PATCH /api/v1/delivery-notes/items/[id]/quantity`. Triggers a PDF regeneration. Returns `{ item, pdfRegenerated }`.", + inputSchema: { + type: "object", + required: ["id", "quantity"], + properties: { + id: { type: "string", description: "15-char `order_items` id of the DN line." }, + quantity: { type: "number", description: "New quantity (must be > 0)." }, + }, + }, + }, + { + name: "delivery_notes.combine", + description: + "Merge multiple delivery notes for the same order into one (the survivor keeps its number; the others are cancelled). Wraps `POST /api/v1/delivery-notes/combine`. Use when the warehouse accidentally split a customer's pickup into multiple DNs and needs them folded back together.", + inputSchema: { + type: "object", + required: ["delivery_note_ids"], + properties: { + delivery_note_ids: { + type: "array", + minItems: 2, + items: { type: "string" }, + description: "DN ids to merge. Must be ≥2 distinct ids for the same order.", + }, + note: { type: "string", description: "Free-text reason recorded on each cancelled DN's audit trail." }, + }, + }, + }, ]; export async function POST(req: NextRequest) { @@ -4038,6 +4419,7 @@ export async function POST(req: NextRequest) { const reportRaw = args.report; const summary = typeof args.summary === "string" ? args.summary : undefined; + const actorRaw = (args.actor ?? {}) as { id?: string; name?: string }; if (!poReference || reportRaw == null || typeof reportRaw !== "object") { return jsonRpcOk( body.id, @@ -4050,6 +4432,19 @@ export async function POST(req: NextRequest) { 400, ); } + if (!actorRaw.id || !actorRaw.name) { + return jsonRpcOk( + body.id, + { + ok: false, + reason: "missing_actor", + message: "actor.{id,name} are required for purchase_orders.record_diff_report. Use users.lookup to discover the right identity.", + }, + 400, + ); + } + const actorId = String(actorRaw.id); + const actorName = String(actorRaw.name); // Use the matcher's reference-normalisation helper to find // the PO across both collections. @@ -4123,8 +4518,8 @@ export async function POST(req: NextRequest) { } const entry = { ts: new Date().toISOString(), - actorId: "po-agent", - actorName: "Purchase Order Agent", + actorId, + actorName, // New category added to the existing feed union — see // `NoteCategory` in src/app/(app)/collection-confirm/view-model.ts. // Keeping the literal here avoids a circular import; the @@ -4200,6 +4595,19 @@ export async function POST(req: NextRequest) { if (toolName === "stock_purchases.receive") { if (!args.id) throw new Error("Missing id for stock_purchases.receive."); + const actorRaw = (args.actor ?? {}) as { id?: string; name?: string }; + if (!actorRaw.id || !actorRaw.name) { + return jsonRpcOk( + body.id, + { + ok: false, + reason: "missing_actor", + message: "actor.{id,name} are required for stock_purchases.receive. Use users.lookup to discover the right identity.", + }, + 400, + ); + } + const actor = { id: String(actorRaw.id), name: String(actorRaw.name) }; const result = await receiveStockPurchase(pb, args.id, { items: Array.isArray(args.items) ? args.items : undefined, }); @@ -4210,11 +4618,10 @@ export async function POST(req: NextRequest) { // status flip + qty accumulation, commit the inventory // through the canonical writer (`bookStockFromInvoice`) // so the ledger / cache only get touched once per PO. - const MCP_ACTOR = { id: "stock-receive-mcp", name: "Stock Receive MCP" }; const bookResult = await bookStockFromInvoice({ id: args.id, - actor: MCP_ACTOR, - notes: "Booked by Stock Receive MCP via stock_purchases.receive", + actor, + notes: `Booked by ${actor.name} via stock_purchases.receive`, }); if (!bookResult.ok && bookResult.reason !== "no_lines") { return jsonRpcOk( @@ -4934,6 +5341,442 @@ export async function POST(req: NextRequest) { return jsonRpcOk(body.id, { tools }); } + // ─── Users (lookup) ────────────────────────────────────────────── + if (toolName === "users.lookup") { + const res = await fetch(`${req.nextUrl.origin}/api/v1/users/lookup`, { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`users.lookup upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + // ─── Dashboard / aggregate ─────────────────────────────────────── + if (toolName === "dashboard.daily_summary") { + const res = await fetch(`${req.nextUrl.origin}/api/v1/dashboard`, { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`dashboard.daily_summary upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "orders.sprint_counts") { + const res = await fetch(`${req.nextUrl.origin}/api/v1/orders/sprint/counts`, { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`orders.sprint_counts upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "orders.find_duplicates") { + const res = await fetch(`${req.nextUrl.origin}/api/v1/orders/duplicates`, { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`orders.find_duplicates upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + // ─── Orders side helpers ───────────────────────────────────────── + if (toolName === "orders.review") { + if (!args.id) throw new Error("Missing id for orders.review."); + const res = await fetch(`${req.nextUrl.origin}/api/v1/orders/${args.id}/review`, { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`orders.review upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "orders.matched_attachments") { + if (!args.id) throw new Error("Missing id for orders.matched_attachments."); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/orders/${args.id}/matched-attachments`, + { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`orders.matched_attachments upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + // ─── Files proxy ───────────────────────────────────────────────── + // Reads a PB file via the same proxy the browser uses, returns + // base64 in a JSON envelope so callers without a PB cookie can + // consume the bytes. + if (toolName === "files.fetch") { + if (!args.collection || !args.record_id || !args.filename) { + throw new Error( + "Missing collection / record_id / filename for files.fetch.", + ); + } + const url = `${req.nextUrl.origin}/api/v1/files/${encodeURIComponent( + args.collection, + )}/${encodeURIComponent(args.record_id)}/${encodeURIComponent(args.filename)}`; + const res = await fetch(url, { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`files.fetch upstream ${res.status}: ${text}`); + } + const contentType = res.headers.get("content-type") ?? "application/octet-stream"; + const buffer = Buffer.from(await res.arrayBuffer()); + return jsonRpcOk(body.id, { + filename: args.filename, + content_type: contentType, + size: buffer.byteLength, + base64: buffer.toString("base64"), + }); + } + + // ─── Driver dispatch ───────────────────────────────────────────── + if (toolName === "drivers.list_stops") { + const qs = new URLSearchParams(); + if (args.drv_id) qs.set("drv_id", args.drv_id); + if (args.kind) qs.set("kind", args.kind); + qs.set("page", String(args.page ?? 1)); + qs.set("perPage", String(args.perPage ?? 50)); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/driver/stops?${qs}`, + { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`drivers.list_stops upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "drivers.update_stop_status") { + if (!args.stop_id || !args.kind || !args.action) { + throw new Error("Missing stop_id / kind / action for drivers.update_stop_status."); + } + const res = await fetch( + `${req.nextUrl.origin}/api/v1/driver/stops/${encodeURIComponent(args.stop_id)}/status`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify({ + kind: args.kind, + action: args.action, + qty_collected: args.qty_collected, + problem_type: args.problem_type, + problem_notes: args.problem_notes, + notes: args.notes, + }), + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`drivers.update_stop_status upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + // ─── Stock quick-draft ─────────────────────────────────────────── + if (toolName === "stock_quick_draft.search") { + if (!args.q || args.q.length < 2) { + throw new Error("stock_quick_draft.search requires q (min 2 chars)."); + } + const qs = new URLSearchParams({ q: args.q }); + if (args.limit) qs.set("limit", String(args.limit)); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/stock-quick-draft/search?${qs}`, + { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`stock_quick_draft.search upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "stock_quick_draft.add_line") { + if (!args.supplierId || typeof args.quantity !== "number" || typeof args.costPrice !== "number") { + throw new Error( + "Missing supplierId / quantity / costPrice for stock_quick_draft.add_line.", + ); + } + if (!args.stockItemId && !args.newStockItem) { + throw new Error( + "Provide stockItemId (existing row) OR newStockItem (create-on-add).", + ); + } + const res = await fetch( + `${req.nextUrl.origin}/api/v1/stock-quick-draft/add-line`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify({ + supplierId: args.supplierId, + quantity: args.quantity, + costPrice: args.costPrice, + stockItemId: args.stockItemId, + newStockItem: args.newStockItem, + }), + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`stock_quick_draft.add_line upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "stock_quick_draft.suppliers") { + const res = await fetch( + `${req.nextUrl.origin}/api/v1/stock-quick-draft/suppliers`, + { + method: "GET", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`stock_quick_draft.suppliers upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + // ─── Delivery notes ────────────────────────────────────────────── + // REST has no GET for list/fetch — read from PB directly via the + // admin client. Writes go through REST so the service-layer + // audit + PDF regeneration stays in lock-step. + if (toolName === "delivery_notes.list") { + const page = Number(args.page ?? 1); + const perPage = Math.min(Number(args.perPage ?? 25), 100); + const filters: string[] = []; + if (args.status) filters.push(`status = "${args.status}"`); + if (args.order_id) filters.push(`order = "${args.order_id}"`); + const list = await pb.collection("delivery_notes").getList(page, perPage, { + filter: filters.join(" && "), + sort: "-created", + }); + return jsonRpcOk(body.id, list); + } + + if (toolName === "delivery_notes.create") { + if (!args.customer) throw new Error("Missing customer for delivery_notes.create."); + if (!Array.isArray(args.items) || args.items.length === 0) { + throw new Error("delivery_notes.create requires a non-empty items array."); + } + const res = await fetch(`${req.nextUrl.origin}/api/v1/delivery-notes`, { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify({ + customer: args.customer, + company: args.company, + notes: args.notes, + createdBy: args.createdBy, + items: args.items, + }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`delivery_notes.create upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json(), 201); + } + + if (toolName === "delivery_notes.cancel") { + if (!args.id) throw new Error("Missing id for delivery_notes.cancel."); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/cancel`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify({ + setById: args.setById, + setByName: args.setByName, + note: args.note, + }), + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`delivery_notes.cancel upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "delivery_notes.mark_invoiced") { + if (!args.id) throw new Error("Missing id for delivery_notes.mark_invoiced."); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/mark-invoiced`, + { + method: "POST", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`delivery_notes.mark_invoiced upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "delivery_notes.mark_complete") { + if (!args.id) throw new Error("Missing id for delivery_notes.mark_complete."); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/mark-complete`, + { + method: "POST", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`delivery_notes.mark_complete upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "delivery_notes.regenerate_pdf") { + if (!args.id) throw new Error("Missing id for delivery_notes.regenerate_pdf."); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/regenerate-pdf`, + { + method: "POST", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`delivery_notes.regenerate_pdf upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "delivery_notes.create_quote") { + if (!args.id) throw new Error("Missing id for delivery_notes.create_quote."); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/create-quote`, + { + method: "POST", + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`delivery_notes.create_quote upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "delivery_notes.set_customer_po") { + if (!args.id) throw new Error("Missing id for delivery_notes.set_customer_po."); + if (!args.customer_po) { + throw new Error("Missing customer_po for delivery_notes.set_customer_po."); + } + const res = await fetch( + `${req.nextUrl.origin}/api/v1/delivery-notes/${encodeURIComponent(args.id)}/customer-po`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify({ customerPo: args.customer_po }), + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`delivery_notes.set_customer_po upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "delivery_notes.set_item_quantity") { + if (!args.id) throw new Error("Missing id for delivery_notes.set_item_quantity."); + if (typeof args.quantity !== "number" || args.quantity <= 0) { + throw new Error("quantity must be > 0 for delivery_notes.set_item_quantity."); + } + const res = await fetch( + `${req.nextUrl.origin}/api/v1/delivery-notes/items/${encodeURIComponent(args.id)}/quantity`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify({ quantity: args.quantity }), + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`delivery_notes.set_item_quantity upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + + if (toolName === "delivery_notes.combine") { + if (!Array.isArray(args.delivery_note_ids) || args.delivery_note_ids.length < 2) { + throw new Error( + "delivery_notes.combine requires delivery_note_ids with ≥ 2 distinct ids.", + ); + } + const res = await fetch( + `${req.nextUrl.origin}/api/v1/delivery-notes/combine`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify({ + deliveryNoteIds: args.delivery_note_ids, + note: args.note, + }), + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`delivery_notes.combine upstream ${res.status}: ${text}`); + } + return jsonRpcOk(body.id, await res.json()); + } + throw new Error(`Unknown tool: ${toolName}`); }