WFDASH-WEBAPP/src/app/api/mcp/route.ts
Dave 151ae8d67f feat(quotes): introduce quotes.submit tool for approval transition
- Added a new `quotes.submit` tool that transitions a quote from `new` to `approval_pending` once all line items are priced. This tool ensures that only fully priced quotes can be submitted for approval, enhancing the integrity of the approval process.
- Updated the API documentation in `docs/api-mcp-layer.md` to include the new `quotes.submit` tool and its usage details.
- Enhanced the `quotes` status enum in `docs/agents/hermes-skill.md` to reflect the new transition and its implications.
- Regenerated documentation to ensure accuracy, increasing the total tool count from 126 to 127.
- Added comprehensive tests for the `submitQuote` function to cover various scenarios, including idempotency and error handling for unpriced items.
2026-08-15 19:01:52 +02:00

5987 lines
259 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { normalizeOrderItemStatusInput } from "@/lib/server/order-item-status";
import {
createOrder,
createOrderFromQuote,
getOrderItemsCollectionName,
listCollectionPool,
splitOrderForContinuation,
} from "@/lib/server/orders-service";
import {
createQuoteItem,
CreateQuoteItemError,
} from "@/lib/server/quote-items-service";
import {
appendReplyToQuote,
createQuoteFromEmail,
createQuote,
findQuoteByMessageId,
sendQuoteEmail,
retryQuoteEmail,
uploadAttachmentForQuote,
type EmailIntakePayload,
type EmailReplyPayload,
type SendQuoteEmailPayload,
} from "@/lib/server/quotes-service";
import {
sendPurchaseOrderEmail,
retryPurchaseOrderEmail,
type SendPurchaseOrderEmailPayload,
} from "@/lib/server/purchase-orders-service";
import {
generateStockPurchaseNumber,
createStockPurchase,
markStockPurchaseSent,
receiveStockPurchase,
sendStockPurchaseEmail,
retryStockPurchaseEmail,
type CreateStockPurchaseInput,
type StockPurchaseStatus,
} from "@/lib/server/stock-purchases-service";
import { bookStockFromInvoice } from "@/lib/server/stock-booking-service";
import { matchAndReceiveFromPoDocument } from "@/lib/server/stock-purchase-invoice-matching-service";
import { scanPurchaseOrders } from "@/lib/server/purchase-order-agent-service";
import { generatePoForGroup, markGroupReady, listBuyerTasks } from "@/lib/server/buyer-tasks-service";
import { deriveSellingPriceIfOmitted } from "@/lib/server/quote-items-service";
import { isLowStockItem } from "@/lib/stock-status";
import { STOCK_REASON_VALUES } from "@/lib/types";
import {
createCompany,
updateCompany,
deleteCompany,
createCustomer,
updateCustomer,
deleteCustomer,
createSupplier,
updateSupplier,
deleteSupplier,
} from "@/lib/server/management-service";
/** Cross-origin MCP clients (Hermes, agent CLIs in the browser) hit
* CORS preflight on every POST. The default Next.js handler does
* not add CORS headers, so we emit them ourselves for the MCP
* route. Production should narrow the origin allow-list, but for
* v1 we accept any Origin so the same `AGENT_API_KEY` keeps being
* the real auth gate. */
function withCors(response: NextResponse, req: NextRequest): NextResponse {
const origin = req.headers.get("origin");
response.headers.set("Access-Control-Allow-Origin", origin ?? "*");
response.headers.set("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-WFDASH-Identity",
);
response.headers.set("Access-Control-Max-Age", "86400");
return response;
}
/**
* Small helper to wrap the JSON-RPC success envelope. Centralised so
* every tool handler returns the same shape without us forgetting a
* field on one of the 50+ tools.
*
* Optional `status` lets a tool surface a non-200 result without
* dropping the JSON-RPC envelope — useful for "this tool was
* successful but the underlying operation says 409 / 422" so the
* agent can branch on HTTP status without parsing the body. The
* JSON-RPC `result` is still populated so older clients that only
* look at the envelope keep working.
*/
function jsonRpcOk(id: unknown, result: unknown, status?: number) {
const body = {
jsonrpc: "2.0",
id: id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
};
return status
? NextResponse.json(body, { status })
: NextResponse.json(body);
}
// ─── `bulk_update_quote_items` — shared constants & helpers ─────────
// Auto-fill agents patch 20-30 quote_items at a time. Routing each
// PATCH through PocketBase REST means 20-30 separate calls from the
// agent, 20-30 separate admin-token round-trips, and falling out of
// the MCP audit trail. This endpoint takes a batch in one JSON-RPC
// call and applies each patch through the same admin client the
// other MCP tools use.
//
// Whitelist of patchable fields on `quote_items`. Anything outside
// this set — including `id`, `quote`, `created`, `updated`,
// `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, 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",
"description",
"sku",
"unit",
"supplier",
"category",
"markup",
"pricing_source",
"pricing_notes",
"matched_price_list_id",
"user_edited",
] as const;
// Whitelist of patchable fields on the QUOTE HEADER. Matches what the
// agent's gap analysis requested: letterhead, dates, references, notes,
// default markup, email cc/bcc, claim owner. System-controlled fields
// (id, created, updated, status, customer, company, quote_number) are
// intentionally excluded — those are managed by the workflow, not
// directly patchable.
const QUOTE_PATCH_FIELDS = [
"company_letterhead",
"quote_date",
"quote_reference",
"rfq_number",
"validity_days",
"notes",
"delivery_note",
"markup",
"email_cc",
"email_bcc",
"claimed_by",
] as const;
// Whitelist of patchable fields on ORDER_ITEMS for invoice reconciliation.
// Lets the agent adjust actual-invoice values (qty_invoiced,
// 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. `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",
"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",
] as const;
// 100 is plenty for v1. Bumping to 500 once we actually see a
// supplier-switch use case — 100 records × one PB round-trip ~2s.
const BULK_UPDATE_DEFAULT_CAP = 100;
/** Pull a sanitised patch + the rejected-keys list out of raw input.
* Pure — no PB calls — so unit tests can assert on the filter
* without standing up the admin client. */
function sanitiseQuoteItemsPatch(
rawPatch: unknown,
): { patch: Record<string, unknown>; rejected: string[] } {
const patch: Record<string, unknown> = {};
const rejected: string[] = [];
if (rawPatch && typeof rawPatch === "object" && !Array.isArray(rawPatch)) {
for (const [key, value] of Object.entries(rawPatch as Record<string, unknown>)) {
if ((QUOTE_ITEM_PATCH_FIELDS as readonly string[]).includes(key)) {
patch[key] = value;
} else {
rejected.push(key);
}
}
}
return { patch, rejected };
}
/** A price field is "valid" if it's a finite number — reject NaN,
* Infinity, and non-numbers. We don't validate sign (a row CAN
* legitimately have 0 cost for a sample / not-for-sale row, and the
* PB schema is `number` not `non-negative`). */
function isValidPriceValue(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value);
}
/** Trim keys to the whitelist and validate price numerics. Returns
* the sanitised patch and a structured reason string if validation
* failed (so the per-row error in the response is human-readable).
* Reason values match the `reason` enum documented in the tool
* description: `forbidden_field`, `validation_error`, or empty when
* the patch is valid. */
function validateAndSanitisePatch(
rawPatch: unknown,
): { patch: Record<string, unknown>; reason?: string } {
const { patch, rejected } = sanitiseQuoteItemsPatch(rawPatch);
if (rejected.length > 0) {
return { patch, reason: `forbidden_field: ${rejected.join(", ")}` };
}
for (const field of ["cost_price", "selling_price"] as const) {
if (field in patch && !isValidPriceValue(patch[field])) {
return {
patch,
reason: `validation_error: ${field} must be a finite number`,
};
}
}
return { patch };
}
/** A row is "already priced" when both `cost_price` and
* `selling_price` are strictly positive — i.e. the operator (or a
* previous agent run) has committed numbers we shouldn't overwrite
* silently. This is the same hand-edit guard the existing re-price
* route and the order-items status route use. */
function isAlreadyPriced(item: {
cost_price?: number | null;
selling_price?: number | null;
}): boolean {
return (
typeof item.cost_price === "number" &&
item.cost_price > 0 &&
typeof item.selling_price === "number" &&
item.selling_price > 0
);
}
const tools = [
{
name: "orders.list",
description: "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 with `filter` when both are present.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
search: { type: "string" },
filter: { type: "string", description: "PocketBase filter expression (e.g. `created >= \"2026-08-05\"`) on orders fields: created, updated, order_number, customer, company, quote, invoiced, is_repair_order, status, rep, rfq_number, contact_name, email_subject, email_sender, ai_insights, meiliesearch" },
sort: { type: "string", description: "PocketBase sort expression (e.g. `-created`, `order_number`). Default: `-created`." },
},
},
},
{
name: "orders.get",
description: "Fetch a single order by id. Pass `expand` (e.g. `customer,company,quote`) to inline relations.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "orders.create_from_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", description: "15-char PocketBase quote id (from `quotes.list` or `quotes.lookup`)." },
},
},
},
{
name: "orders.create",
description:
"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.\n\n" +
"Required: customer_id, company_id, and at least one item with a non-empty description. " +
"Quote-related fields (quote, potential_quote, quote_match_confidence) stay blank because " +
"there is no originating quote.\n\n" +
"Each item creates an order_item row with item_status=\"pending\" and drv_status=\"unassigned\". " +
"If the per-row secondary fields (sku/unit/supplier_id/notes) fail to patch, the row stays " +
"created (with defaults) and the failure is reported in items_failed — never aborts the " +
"whole order.",
inputSchema: {
type: "object",
required: ["customer_id", "company_id", "items"],
properties: {
customer_id: { type: "string", description: "Customer relation id (PB 15-char). Must resolve in PB." },
company_id: { type: "string", description: "Company relation id (PB 15-char). Must resolve in PB." },
notes: { type: "string", description: "Free-text notes on the order itself." },
contact_name:{ type: "string", description: "Operator-readable contact name (free-text)." },
description: { type: "string", description: "Short description of the order (free-text)." },
rep: { type: "string", description: "Sales rep name (free-text)." },
items: {
type: "array",
minItems: 1,
description: "Line items to create. Each must have a non-empty description.",
items: {
type: "object",
required: ["description"],
properties: {
description: { type: "string", description: "Item description (required, trimmed)." },
quantity: { type: "number", description: "Order quantity. Defaults to 1." },
cost_price: { type: "number", description: "Cost price (supplier cost). Defaults to 0." },
markup: { type: "number", description: "Margin %. Defaults to 0." },
selling_price: { type: "number", description: "Customer-facing unit price. Defaults to 0." },
sku: { type: "string", description: "SKU / part number." },
unit: { type: "string", description: "Unit (EA, BOX, M, etc)." },
supplier_id: { type: "string", description: "Supplier relation id (PB 15-char)." },
notes: { type: "string", description: "Free-text notes on this line." },
},
},
},
},
},
},
{
name: "orders.split",
description:
"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.\n\n" +
"Semantics:\n" +
"- Parent order keeps its number; moved items stay on the parent with their `order` relation " +
"unchanged but get a breadcrumb in `notes` (e.g. \"Moved to ORD-2026-08-018 via order split on 2026-08-11T...\").\n" +
"- A new continuation order is created with a fresh ORD-YYYY-MM-NNN number. It inherits the " +
"parent's customer, company, quote, description, rep, contact_name, rfq_number, and " +
"quote_reference_text — same customer engagement, fresh operational batch.\n" +
"- Each moved item is cloned onto the new order with item_status=\"pending\" and " +
"drv_status=\"unassigned\" (no PO / driver carry-over).\n\n" +
"Guard rails:\n" +
"- Only items with item_status=\"pending\" can be moved. Items in po_generated / assigned / " +
"collected / delivered / invoiced etc. refuse — splitting would orphan POs and driver " +
"assignments.\n" +
"- Items that don't belong to the parent order are reported in items_failed.\n" +
"- Partial success is allowed: if some IDs are valid and some fail, the valid ones still " +
"move. The response carries items_failed[] so the caller can re-issue with the survivors.\n" +
"- Total failure (nothing moved) returns items_failed[] with each per-item reason.",
inputSchema: {
type: "object",
required: ["order_id", "item_ids"],
properties: {
order_id: { type: "string", description: "Parent order id (PB 15-char)." },
item_ids: {
type: "array",
minItems: 1,
items: { type: "string" },
description: "Order_item ids to move to the new continuation order. Must all belong to order_id.",
},
},
},
},
{
name: "collection_pool.list",
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", description: "One of `pending | assigned | collected | in_transit | delivered`. Omit for all statuses." },
},
},
},
{
name: "order_items.update_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", 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." },
},
},
},
{
name: "order_items.confirm_collected",
description:
"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_status` → `collected`, stamps `drv_collected_at` + `collected_date`, bumps `qty_collected`, and appends a `category: \"collection\"` audit note to `notes`. Use this when goods physically arrived at the warehouse but no driver was ever bound to the row — the in-UI `/collection-pool` page forces a driver assignment before \"Mark Collected\" is exposed, so external agents (Giellie, n8n, future integrations) need this path.\n\n" +
"Eligibility: each row's current `item_status` must be `\"collection_pool\"` or `\"assigned\"`. Rows already past the pool (collected / delivered / invoiced / etc.) refuse with `reason: \"wrong_status\"` so an in-flight invoiced or delivered row is never silently overwritten.\n\n" +
"Per-row independence — one row's failure never affects another. The response carries `results[]` (every input id, with status / reason / previous values on success) plus summary `confirmed[]` and `failed[]` arrays so callers can summarise either side without re-iterating `results`.\n\n" +
"Defaults: `qty_collected` jumps to the full `quantity` when no override is supplied (the common case — full qty arrived). Pass `quantity_collected` (per-id map) for partial confirms; per-row values must be ≤ remaining (existing `qty_collected` subtracted). `collected_at` defaults to today (UTC, YYYY-MM-DD — matches the `drv_collected_at` / `collected_date` date fields).\n\n" +
"Caller stamps the audit: `actor.{id,name}` is recorded on every note entry, so the agent identity is preserved in the `notes` JSON feed.",
inputSchema: {
type: "object",
required: ["item_ids", "actor"],
properties: {
item_ids: {
type: "array",
minItems: 1,
description: "Order_item ids to confirm. Each must be in `item_status = collection_pool` or `assigned`.",
items: { type: "string", description: "15-char order_item id" },
},
quantity_collected: {
type: "object",
description:
"Optional per-item override for the qty to ADD to `qty_collected` (not the new total). When omitted, the service defaults to the remaining qty (`quantity - qty_collected`) — i.e. marks the line fully collected.",
additionalProperties: { type: "number" },
},
collected_at: {
type: "string",
description:
"Optional YYYY-MM-DD stamp for `drv_collected_at` / `collected_date`. Defaults to today.",
},
note: {
type: "string",
description:
"Optional free-text note appended to each row's audit trail (alongside the auto-generated body). Truncated/whitespace-only values are ignored.",
},
actor: {
type: "object",
required: ["id", "name"],
description: "Identity recorded on each note entry.",
properties: {
id: { type: "string" },
name: { type: "string" },
},
},
},
},
},
{
name: "order_items.update",
description:
"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"],
properties: {
id: { type: "string", description: "15-char PocketBase order_item id" },
patch: {
type: "object",
description:
"Subset of whitelisted fields. Empty patches are rejected.",
properties: {
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" },
qty_processed: { type: "number" },
qty_assigned: { type: "number" },
qty_collected: { type: "number" },
qty_verified: { type: "number" },
qty_stocked: { type: "number" },
qty_picked: { type: "number" },
qty_loaded: { type: "number" },
qty_delivered: { type: "number" },
qty_invoiced: { type: "number" },
qty_cancelled: { type: "number" },
supplier_invoice_number: { type: "string" },
customer_invoice_number: { type: "string" },
collection_date: { type: "string" },
collected_date: { type: "string" },
verified_date: { type: "string" },
delivered_date: { type: "string" },
invoiced_date: { type: "string" },
},
},
},
},
},
{
name: "orders.add_attachment",
description:
"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 purposes). Max 25 MB. Use `kind: 'proof'` to tag the filename with a [proof] prefix.",
inputSchema: {
type: "object",
required: ["order_id", "filename", "base64"],
properties: {
order_id: { type: "string", description: "15-char PocketBase order id" },
filename: { type: "string" },
content_type: { type: "string" },
size: { type: "number" },
base64: { type: "string" },
kind: { type: "string", enum: ["proof", "outbound"], description: "Defaults to 'outbound'" },
},
},
},
{
name: "orders.list_attachments",
description:
"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, and any operator-uploaded proof/outbound files. Newest first.",
inputSchema: {
type: "object",
required: ["order_id"],
properties: {
order_id: { type: "string", description: "15-char PocketBase order id" },
},
},
},
{
name: "order_items.list",
description:
"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`.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
filter: { type: "string" },
sort: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "order_items.get",
description: "Fetch a single order_item by id. Pass `expand` to inline `order`, `purchase_order`, `category`, `supplier`.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "quotes.create_from_email",
description:
"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' customer (`unknown-sender@no-from.wfdash.local`) that the quoter reassigns in the existing quote-editing form. `message_id` is also optional in the same spirit — a stable `synth-…@wfdash.local` id is derived from the email identity when missing. The legacy `attachments` field is accepted for back-compat but no longer uploaded from this call — use `attachments.upload_for_quote` once per file after this call returns the new quote id.",
inputSchema: {
type: "object",
required: ["subject"],
properties: {
from_email: { type: "string" },
from_name: { type: "string" },
subject: { type: "string" },
body_plain: { type: "string" },
body_html: { type: "string" },
message_id: { type: "string" },
in_reply_to: { type: "string" },
received_at: { type: "string" },
company_hint: { type: "string" },
extracted_items: {
type: "array",
items: {
type: "object",
properties: {
description: { type: "string" },
quantity: { type: "number" },
unit: { type: "string" },
notes: { type: "string" },
},
},
},
attachments: {
type: "array",
description:
"Legacy field, accepted but no longer acted on. Use attachments.upload_for_quote for each file instead.",
items: {
type: "object",
properties: {
filename: { type: "string" },
content_type: { type: "string" },
size: { type: "number" },
base64: { type: "string" },
},
},
},
},
},
},
{
name: "quotes.create",
description:
"Create a quote from scratch (walk-in, no source email). Use this when the operator types 'make me a quote for John' on the dashboard or via the agent — `quotes.create_from_email` is the wrong tool because it requires a `subject`/parsed-email body.\n\n" +
"Resolution:\n" +
"- `customer` and `company` accept either a 15-char PB id (verbatim) or a free-text name. Names are matched case-insensitive exact against `customer.customer` / `company.company`. If no match, a new row is created with the given name and empty default fields.\n\n" +
"Items: each item is written via the shared `quote_items.create` service, so the F.1 auto-derive `selling_price = cost_price * (1 + markup/100)` rule and the F.2 whitelist (category / matched_price_list_id) apply unchanged. `pricing_source` defaults to `\"manual\"` when omitted.\n\n" +
"Lifecycle: starts at `status=\"new\"`, `validity_days=30`, `company_letterhead=\"CNL\"`. The operator then runs `re_price` / `quotes.re_price` to populate prices, then through the existing claim → price → submit → approve → send pipeline.",
inputSchema: {
type: "object",
required: ["customer", "items"],
properties: {
customer: { type: "string", description: "Customer id OR name. If a name is passed and no customer exists, a new one is created." },
company: { type: "string", description: "Company id OR name. If a name is passed and no company exists, a new one is created." },
letterhead: { type: "string", enum: ["CNL", "ISHERWOOD"], description: "Defaults to \"CNL\"." },
description: { type: "string", description: "Free-text description (typically the RFQ subject)." },
validity_days: { type: "number", description: "Days until the quote expires. Defaults to 30." },
items: {
type: "array",
description: "One or more line items. Each `description` is required and trimmed.",
items: {
type: "object",
required: ["description"],
properties: {
description: { type: "string" },
quantity: { type: "number" },
unit: { type: "string" },
sku: { type: "string" },
supplier: { type: "string", description: "Supplier relation id (optional)." },
category: { type: "string", description: "Categories relation id (optional). Resolve from `categories.list` first." },
cost_price: { type: "number" },
selling_price: { type: "number", description: "Auto-derives from cost_price * (1 + markup/100) when both are supplied and selling_price is omitted." },
markup: { type: "number" },
notes: { type: "string" },
pricing_source: { type: "string", description: "Defaults to \"manual\" when omitted." },
pricing_notes: { type: "string" },
matched_price_list_id: { type: "string", description: "Optional 15-char PB id of the catalogue row this line was sourced from." },
},
},
},
},
},
},
{
name: "quotes.ingest_from_email",
description:
"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 out on a slow Claude call. The background processor (driven by `quotes.process_pending`) reads the body + attachments and writes `quote_items` separately. Use this for the new pipeline; `quotes.create_from_email` is the legacy path that creates items from a caller-supplied `extracted_items` array.",
inputSchema: {
type: "object",
required: ["subject"],
properties: {
from_email: { type: "string" },
from_name: { type: "string" },
subject: { type: "string" },
body_plain: { type: "string" },
body_html: { type: "string" },
message_id: { type: "string" },
in_reply_to: { type: "string" },
received_at: { type: "string" },
company_hint: { type: "string" },
},
},
},
{
name: "quotes.process_pending",
description:
"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/items_written) and per-quote outcomes.",
inputSchema: {
type: "object",
properties: {
limit: { type: "number", description: "1-20; default 5" },
},
},
},
{
name: "quotes.re_run_ai",
description:
"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` deletes existing items first so the quote reflects a fresh extraction. Returns the same shape as one entry of `quotes.process_pending`.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", description: "15-char PocketBase quote id" },
wipe_items: { type: "boolean", description: "Delete existing quote_items before processing. Default true." },
},
},
},
{
name: "quotes.append_reply",
description:
"Append a structured reply to an existing quote. Matches by In-Reply-To header first, then by Q-YYYY-MM-NNN in the subject.",
inputSchema: {
type: "object",
required: ["from_email", "subject"],
properties: {
from_email: { type: "string" },
from_name: { type: "string" },
subject: { type: "string" },
body_plain: { type: "string" },
body_html: { type: "string" },
message_id: { type: "string" },
in_reply_to: { type: "string" },
received_at: { type: "string" },
quoted_text: { type: "string" },
},
},
},
{
name: "quotes.find_by_message_id",
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", description: "RFC 5322 Message-ID header from the inbound email (e.g. `<abc123@mail.gmail.com>`)." },
},
},
},
{
name: "quotes.list",
description:
"List quotes. Pass PB filter syntax in `filter` (e.g. `status=\"new\"`). Default sort `-created`.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
filter: { type: "string" },
sort: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "quotes.get",
description: "Fetch a single quote by id. Pass `expand` (e.g. `customer,company,items`).",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "quotes.update",
description:
"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 — including `id`, `created`, `updated`, `status`, `customer`, `company`, `quote_number` — is rejected with `forbidden_field`. Use this to persist `company_letterhead` so re-renders in the UI match what you generated, or to update header metadata (dates, references, default markup). Pass `regenerate_pdf: true` alongside the patch to re-render the PDF in the same call (useful for switching letterhead and immediately getting the new PDF back).",
inputSchema: {
type: "object",
required: ["id", "patch"],
properties: {
id: { type: "string", description: "15-char PocketBase quote id" },
patch: {
type: "object",
description:
"Subset of the whitelisted fields above. Empty patches are rejected.",
properties: {
company_letterhead: { type: "string", enum: ["CNL", "ISHERWOOD"] },
quote_date: { type: "string", description: "ISO date (YYYY-MM-DD)" },
quote_reference: { type: "string" },
rfq_number: { type: "string" },
validity_days: { type: "number" },
notes: { type: "string" },
delivery_note: { type: "string" },
markup: { type: "number", description: "Default markup on items without one" },
email_cc: { type: "string" },
email_bcc: { type: "string" },
claimed_by: { type: "string" },
},
},
regenerate_pdf: { type: "boolean", description: "If true, re-render the PDF after persisting the patch and include it in the response" },
},
},
},
{
name: "quotes.send_email",
description:
"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 attached separately). `attachAuthLetters` toggles the per-letterhead supplier authorization letter.\n\n" +
"Only `id` is required. The service walks four sources for the recipient: `to` → `customer.email` → `customer.alternative_email` → `quote.email_sender`. `cc` / `bcc` default to empty. `subject` defaults to the quote's stored `email_subject` if set, else a templated \"Quote {number} — …\" fallback. `message` defaults to the quote's stored `email_body` if set, else empty.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
to: { type: "string", description: "Optional override for the primary To: address. Falls back to customer.email → customer.alternative_email → quote.email_sender." },
cc: { type: "string", description: "Comma-separated. Defaults to \"\"." },
bcc: { type: "string", description: "Comma-separated. Defaults to \"\"." },
subject: { type: "string", description: "Optional. Defaults to the quote's stored email_subject or a templated fallback." },
message: { type: "string", description: "Optional. Defaults to the quote's stored email_body or empty." },
attachAuthLetters: { type: "boolean" },
selectedAttachmentIds: { type: "array", items: { type: "string" } },
},
},
},
{
name: "quotes.retry_email",
description:
"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", description: "15-char PocketBase quote id." } },
},
},
{
name: "quotes.clone",
description:
"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.",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
},
{
name: "quotes.create_from_template",
description:
"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", description: "15-char PocketBase id of the `is_template=true` quote to instantiate." } },
},
},
{
name: "quotes.save_as_template",
description:
"Snapshot an existing quote as a reusable template. The original quote is not modified; a new `is_template=true` row is created.",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" }, name: { type: "string" } },
},
},
{
name: "quotes.split",
description:
"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.",
inputSchema: {
type: "object",
required: ["source_quote_id", "groups"],
properties: {
source_quote_id: { type: "string" },
groups: {
type: "array",
items: {
type: "object",
required: ["name", "item_ids"],
properties: {
name: { type: "string" },
item_ids: { type: "array", items: { type: "string" } },
},
},
},
},
},
},
{
name: "quotes.partial_approve",
description:
"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.",
inputSchema: {
type: "object",
required: ["quote_id", "decisions"],
properties: {
quote_id: { type: "string" },
decisions: {
type: "array",
items: {
type: "object",
required: ["item_id", "decision"],
properties: {
item_id: { type: "string" },
decision: { type: "string", enum: ["approved", "rejected"] },
note: { type: "string" },
},
},
},
},
},
},
{
name: "quotes.submit",
description:
"Flip a `new` quote to `approval_pending` once every line is priced. Idempotent — re-submitting an already-`approval_pending` quote is a no-op (returns `statusChanged: false`). Gated: every line on the quote must have `selling_price > 0`; unpriced lines are returned as `offendingItemIds` in the 422 so the operator can fix them. Use this after walking through `quotes.create` (walk-in) or `quotes.create_from_email` (email) to push the quote into the approval queue. Body: `{ id, actor?: { id, name } }`. The `actor` is optional and lands on `quotes.submitted_by` when the PB admin has added the column. Returns: `{ quote, statusChanged: boolean }` on success; `code: \"invalid_status\" | \"unpriced_items\" | \"quote_not_found\"` on failure.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", description: "quote id" },
actor: {
type: "object",
description:
"Optional agent / operator identity stamp. Mirrors the `actor` shape used by `order_items.confirm_collected` and `purchase_orders.record_diff_report`.",
properties: {
id: { type: "string" },
name: { type: "string" },
},
},
},
},
},
{
name: "quotes.re_price",
description:
"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.",
inputSchema: {
type: "object",
required: ["id", "items"],
properties: {
id: { type: "string", description: "quote id" },
items: {
type: "array",
items: {
type: "object",
required: ["id", "patch"],
properties: {
id: { type: "string" },
patch: { type: "object" },
force_overwrite: { type: "boolean" },
},
},
},
dry_run: { type: "boolean" },
},
},
},
{
name: "quotes.next_number",
description:
"Reserve the next quote number (`Q{yy}{mm}{NNN}`). Atomic counter; gaps are possible if the caller never persists the resulting draft.",
inputSchema: { type: "object", properties: {} },
},
{
name: "quotes.lookup",
description:
"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.",
inputSchema: {
type: "object",
properties: {
q: { type: "string" },
page: { type: "number" },
limit: { type: "number" },
},
},
},
{
name: "attachments.upload_for_quote",
description:
"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. Errors are returned per-attachment so a single bad file does not roll back the quote. `base64` is the raw file bytes encoded as base64 (no `data:` URL prefix).",
inputSchema: {
type: "object",
required: ["quote_id", "filename", "base64"],
properties: {
quote_id: { type: "string" },
filename: { type: "string" },
content_type: { type: "string" },
size: { type: "number" },
base64: { type: "string" },
message_id: { type: "string" },
},
},
},
{
name: "quotes.list_attachments",
description:
"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.",
inputSchema: {
type: "object",
required: ["quote_id"],
properties: {
quote_id: { type: "string", description: "15-char PocketBase quote id" },
page: { type: "number" },
perPage: { type: "number" },
},
},
},
{
name: "quotes.get_pdf",
description:
"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`. Returns the base64 PDF plus `attachment_id`, `filename`, `size`, and `content_type` so the agent can save/forward the file.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", description: "15-char PocketBase quote id" },
letterhead: { type: "string", enum: ["CNL", "ISHERWOOD"] },
regenerate: { type: "boolean", description: "Force a fresh render even if a PDF already exists" },
},
},
},
{
name: "purchase_orders.add_attachment",
description:
"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.",
inputSchema: {
type: "object",
required: ["po_id", "filename", "base64"],
properties: {
po_id: { type: "string" },
filename: { type: "string" },
content_type: { type: "string" },
size: { type: "number" },
base64: { type: "string" },
},
},
},
{
name: "collection_confirm.list",
description:
"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 separately. Each row carries enough fields to match against an incoming supplier invoice (description, qty, qty_collected, supplier, PO number). Ordered newest-first.",
inputSchema: {
type: "object",
properties: {
source: {
type: "string",
enum: ["customer", "stock", "all"],
description: "`customer` (order_items only), `stock` (stock_purchase_items only), or `all` (default).",
},
perPage: { type: "number", description: "Default 100. Hard cap 500." },
},
},
},
{
name: "collection_confirm.upload_invoice",
description:
"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_invoice` marks items `collected`; (2) `collection_confirm.upload_invoice` uploads the scanned invoice to those items; (3) `collection_confirm.confirm_order_items` moves them `collected → ready_invoice` with an audit stamp. Max 25 MB per file. Caller must base64-encode the bytes (no `data:` URL prefix).",
inputSchema: {
type: "object",
required: ["item_ids", "filename", "base64"],
properties: {
item_ids: {
type: "array",
minItems: 1,
items: { type: "string", description: "15-char order_item id" },
},
filename: { type: "string" },
content_type: { type: "string" },
size: { type: "number" },
base64: { type: "string" },
},
},
},
{
name: "collection_confirm.confirm_order_items",
description:
"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` patches `cost_price` and appends a separate audit line per change. Returns the list of ids that were successfully confirmed.",
inputSchema: {
type: "object",
required: ["item_ids", "invoice", "actor"],
properties: {
item_ids: {
type: "array",
minItems: 1,
items: { type: "string" },
},
invoice: {
type: "object",
required: ["number", "date"],
properties: {
number: { type: "string", description: "Supplier invoice number" },
date: { type: "string", description: "Invoice date (YYYY-MM-DD)" },
},
},
cost_overrides: {
type: "object",
description:
"Optional map of `{item_id: new_cost_price}`. Per-item cost corrections with audit trail.",
additionalProperties: { type: "number" },
},
actor: {
type: "object",
required: ["id", "name"],
properties: {
id: { type: "string", description: "PB user id of the agent / buyer" },
name: { type: "string", description: "Display name for audit trail" },
},
},
},
},
},
{
name: "purchase_orders.list",
description:
"List purchase_orders. Pass PB filter syntax in `filter` (e.g. `status=\"sent\"`). Default sort `-created`.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
filter: { type: "string" },
sort: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "purchase_orders.get",
description: "Fetch a single purchase_order by id. Pass `expand` to inline `supplier`.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "purchase_orders.send_email",
description:
"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.",
inputSchema: {
type: "object",
required: ["id", "cc", "bcc", "subject", "message"],
properties: {
id: { type: "string" },
to: { type: "string" },
cc: { type: "string" },
bcc: { type: "string" },
subject: { type: "string" },
message: { type: "string" },
},
},
},
{
name: "purchase_orders.retry_email",
description:
"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", description: "15-char PocketBase purchase_order id." } },
},
},
{
name: "purchase_orders.stale",
description:
"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.",
inputSchema: {
type: "object",
properties: {
olderThan: { type: "number", description: "Minutes; default 15" },
limit: { type: "number", description: "Max rows; default 50, hard cap 200" },
},
},
},
{
name: "purchase_orders.record_send_outcome",
description:
"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.",
inputSchema: {
type: "object",
required: ["po_id", "outcome"],
properties: {
po_id: { type: "string" },
outcome: { type: "string", enum: ["success", "failure"] },
message_id: { type: "string", description: "RFC 5322 Message-ID of the sent email (success only)" },
error: { type: "string", description: "SMTP error message (failure only)" },
},
},
},
{
name: "bulk_update_quote_items",
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, 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" +
"- Auto-derive selling_price: when a per-row patch contains both `cost_price` and `markup` and omits `selling_price`, `selling_price` is auto-derived as `cost_price * (1 + markup/100)` rounded to 2 decimal places. Pass `selling_price` explicitly to override (e.g. to preserve a catalogue-stored selling price). The `previous_*`/`new_*` surfaces in the response reflect the derived values.\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" +
"- Cap: max 100 items per call (request larger batches by paging).\n" +
"- Dry-run: `dry_run: true` returns the same response shape with status=`dry_run` per row and writes nothing. Use for probe-then-confirm workflows.\n" +
"- Caller stamps `pricing_notes`: the agent knows who it is and the date. The tool does NOT auto-inject an audit string. Include `pricing_notes` in the patch when you change pricing fields.\n\n" +
"Per-row result reasons: `forbidden_field`, `validation_error`, `not_found`, `already_priced`, `pb_error`, or omitted on success.",
inputSchema: {
type: "object",
required: ["items"],
properties: {
items: {
type: "array",
description: "Rows to update. Items are independent — one row's failure never affects another.",
items: {
type: "object",
required: ["id", "patch"],
properties: {
id: {
type: "string",
description: "The 15-char `quote_items` id to patch.",
},
patch: {
type: "object",
description:
"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",
description:
"When true, overwrites a row that already has `cost_price > 0 AND selling_price > 0`. Default false (skip with reason=`already_priced`).",
},
},
},
maxItems: 100,
},
dry_run: {
type: "boolean",
description:
"When true, returns the same response shape with status=`dry_run` per row and performs zero writes. Default false.",
},
},
},
},
// ─── quote_items CRUD (added 2026-08-04) ───────────────────────────────
// The pre-existing `bulk_update_quote_items` tool only updates rows
// that already exist. AI auto-fill agents need to:
// 1. List items under a quote to see what's there (quote_items.list)
// 2. Fetch one by id to inspect it (quote_items.get)
// 3. Add a missing line item after the intake (quote_items.create)
// 4. Patch a single row when bulk is overkill (quote_items.update)
// These four tools + the existing bulk tool cover the full agent loop.
// Patch whitelist on update is identical to bulk_update_quote_items.
{
name: "quote_items.list",
description:
"List `quote_items` rows. Pass PB filter syntax in `filter` (e.g. `quote=\"<quote_id>\"`, `sku~\"M16\"`, `supplier=\"<id>\"`). Default sort `-created`.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
filter: { type: "string" },
sort: { type: "string" },
fields: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "quote_items.get",
description:
"Fetch a single `quote_items` row by id. Pass `expand` to inline relations (e.g. `quote,supplier`).",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
expand: { type: "string" },
fields: { type: "string" },
},
},
},
{
name: "quote_items.create",
description:
"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`.\n\n" +
"Notes:\n" +
"- `quote_id` is required; the parent quote must already exist (the tool does NOT create the quote).\n" +
"- `description` is required and trimmed; empty/whitespace values are rejected.\n" +
"- `quantity` defaults to 1; must be > 0 when supplied.\n" +
"- Auto-derive: when both `cost_price` and `markup` are supplied and `selling_price` is omitted, `selling_price` is set to `cost_price * (1 + markup/100)` rounded to 2 decimal places. Pass `selling_price` explicitly to override.\n" +
"- `pricing_source` defaults to `manual` when omitted — pass `ai` / `catalogue` to opt into the hand-edit guard skip on bulk re-pricing.\n" +
"- `category` is a PB relation id (resolve from `categories.list` first); free-text values are rejected.\n" +
"- `matched_price_list_id` is an optional 15-char PB id of the catalogue row this line was sourced from.\n" +
"- `is_processed` is forced to `false` server-side — newly created rows are drafts.\n" +
"- `quote_number` is denormalised from the parent quote automatically; do not pass it.\n" +
"- **Idempotency**: if a row with the same `(quote_id, description, sku)` was created within the last 60 seconds, the existing row is returned and `idempotent: true` is set on the response. No duplicate is created. The 60s window means a deliberate operator re-add 10 minutes later is still permitted.",
inputSchema: {
type: "object",
required: ["quote_id", "description"],
properties: {
quote_id: { type: "string", description: "Parent quote id (PB 15-char)." },
description: { type: "string" },
quantity: { type: "number" },
unit: { type: "string" },
sku: { type: "string" },
supplier: { type: "string", description: "Supplier relation id (optional)." },
category: { type: "string", description: "Categories relation id (optional). Resolve from `categories.list` first; free-text values are rejected." },
cost_price: { type: "number" },
selling_price: { type: "number", description: "Defaults to 0 — auto-derives from cost_price * (1 + markup/100) when both are supplied and selling_price is omitted. Pass explicitly to override." },
markup: { type: "number", description: "Margin %, defaults to 0." },
notes: { type: "string" },
pricing_source: { type: "string", description: "Defaults to `manual` when omitted. Set to `manual` when programmatically seeding lines so the hand-edit guard on bulk re-pricing doesn't override your values." },
pricing_notes: { type: "string" },
matched_price_list_id: { type: "string", description: "Optional 15-char PB id of the catalogue row this line was sourced from." },
},
},
},
{
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, 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\n" +
"Auto-derive: when both `cost_price` and `markup` are in the patch and `selling_price` is omitted, `selling_price` is auto-derived as `cost_price * (1 + markup/100)` rounded to 2 decimal places. Pass `selling_price` explicitly to override (e.g. to preserve a catalogue-stored selling price).\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",
required: ["id", "patch"],
properties: {
id: { type: "string", description: "The 15-char `quote_items` id." },
patch: {
type: "object",
description:
"Subset of {cost_price, selling_price, description, sku, unit, supplier, category, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited}. `category` is a PB relation id.",
},
},
},
},
{
name: "quote_items.populate_insights",
description:
"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.\n\n" +
"Two modes:\n" +
" - pass `quote_id` → populates every active line item on that quote (Promise.all internally)\n" +
" - pass `item_id` → populates one item\n\n" +
"Match strategy: SKU exact first, then normalised SKU (`DRL-6MM` ≡ `DRL 6MM` ≡ `BOLT-006` ⇒ `BOLT-6`), then Jaccard description fallback (threshold 0.6) against recent quote_items to recover a SKU from the description. `match_kind` in the response tells you which path produced data.\n\n" +
"The persisted blob answers four questions per item:\n" +
" - last_quoted: when did we last quote this SKU? (most recent quote_item with a price)\n" +
" - on_price_list: is this SKU on the price list, with all matching rows?\n" +
" - last_order: did we place an order for this SKU, and when? (any item_status past `po_generated`)\n" +
" - last_invoiced: when did we confirm an invoice? (filter `invoiced_date != ''`)\n\n" +
"Plus deterministic stock (from `stock_items`, falls back to `pos`) and supplier cost history (from `order_items`).\n\n" +
"Idempotent — safe to re-run. Pass `write=false` for a dry run that returns the computed blob without persisting.\n\n" +
"This is the only sanctioned writer for `ai_item_insights`. The `bulk_update_quote_items` whitelist intentionally excludes it; use this tool.",
inputSchema: {
type: "object",
properties: {
quote_id: {
type: "string",
description: "Populate every active line item on this quote.",
},
item_id: {
type: "string",
description: "Populate one quote_item by id.",
},
write: {
type: "boolean",
description: "Persist the result to PB. Defaults true. Pass false for a dry run.",
},
},
oneOf: [
{ required: ["quote_id"] },
{ required: ["item_id"] },
],
},
},
// ─── Stock items + stock purchases + sprint + buyer tasks + stock fulfillment + AI insights ─────
// All added 2026-07-30 to fill the Hermes + n8n coverage gap. Order
// is: stock_items → stock_purchases → sprint → buyer_tasks →
// stock.fulfill → stock.surplus_book → ai_insights.rerun.
{
name: "stock_items.list",
description:
"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`).",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
search: { type: "string" },
low_stock: { type: "boolean" },
expand: { type: "string" },
sort: { type: "string" },
},
},
},
{
name: "stock_items.get",
description:
"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.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
ledger: { type: "string", description: "Set to `false` to omit the ledger slice" },
page: { type: "number" },
perPage: { type: "number" },
},
},
},
{
name: "stock_items.create",
description:
"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`).",
inputSchema: {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
sku: { type: "string" },
description: { type: "string" },
unit: { type: "string" },
location: { type: "string" },
current_qty: { type: "number" },
min_qty: { type: "number" },
target_qty: { type: "number" },
supplier: { type: "string", description: "PB id of the supplier (optional)" },
notes: { type: "string" },
},
},
},
{
name: "stock_items.adjust",
description:
"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.",
inputSchema: {
type: "object",
required: ["id", "change_qty", "reason"],
properties: {
id: { type: "string", description: "stock_item id" },
change_qty: { type: "number" },
reason: { type: "string", enum: ["purchase_surplus", "stock_order", "customer_fulfillment", "adjustment", "return"] },
reference_type: { type: "string" },
reference_id: { type: "string" },
notes: { type: "string" },
},
},
},
{
name: "stock_items.update",
description:
"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 rejection. reason is one of: not_found (404), forbidden_field (400), validation_error (400), pb_error (422).",
inputSchema: {
type: "object",
required: ["id", "patch"],
properties: {
id: { type: "string", description: "15-char stock_item id" },
patch: {
type: "object",
description:
"Subset of {sku, description, notes, unit, selling_price, markup}. Anything else returns forbidden_field.",
properties: {
sku: { type: "string" },
description: { type: "string" },
notes: { type: "string" },
unit: { type: "string" },
selling_price: { type: "number" },
markup: { type: "number" },
},
},
},
},
},
{
name: "stock_purchases.list",
description:
"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-row line count included.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
status: { type: "string" },
sort: { type: "string" },
},
},
},
{
name: "purchase_orders.scan",
description:
"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_missing_supplier_sku), and returns the unified list with `kind: 'stock' | 'customer'` per row. Default filter covers `sent` / `sending` / `mail_failed` — the warehouse-side states where the agent does its work. Use this as the first call in any agent session that processes PO intake.",
inputSchema: {
type: "object",
properties: {
kind: {
type: "string",
enum: ["stock", "customer", "both"],
description: "Restrict to one PO kind, or 'both' (default).",
},
status: {
type: "array",
items: { type: "string" },
description:
"Override the default status filter. Useful when the agent wants to scan only 'sent' (post-receipt), or only 'mail_failed' (retry queue).",
},
maxRowsPerKind: {
type: "number",
description:
"Cap the rows returned per kind (default 100). Bumping this past ~200 makes the diagnostics calls noticeably slow; prefer paginating with `stock_purchases.list` / `purchase_orders.list` instead.",
},
},
},
},
{
name: "purchase_orders.diff",
description:
"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 / cost_delta / per_unit_delta / match_method, `unmatched_po_lines`, `unmatched_invoice_lines`) WITHOUT writing anything. Use this BEFORE deciding to commit; the agent reads the verdict to decide between auto-flip (cost_mismatch = 0 + all lines match) and stop-and-report (any discrepancy). Auto-detects stock vs customer PO via `po_reference`. Pass `mode='grn'` for delivery-note runs (skips cost validation).",
inputSchema: {
type: "object",
required: ["po_reference", "document"],
properties: {
po_reference: {
type: "string",
description:
"The 'Your Ref' / 'Order No' printed on the document. Resolved against `stock_purchases.purchase_number` first, then `purchase_orders.po_number`.",
},
document: {
type: "object",
required: ["document_no", "date", "lines"],
properties: {
document_no: {
type: "string",
description:
"Supplier's invoice or delivery-note number (the 'Document No' on the printed form).",
},
date: {
type: "string",
description:
"Document date in `YYYY-MM-DD`. The agent should convert from the supplier's local format.",
},
supplier_name: {
type: "string",
description:
"Optional supplier name as printed on the document — used as a soft cross-check against the PO's supplier.",
},
lines: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["description", "quantity", "unit_price"],
properties: {
code: { type: "string" },
description: { type: "string" },
quantity: { type: "number" },
unit_price: { type: "number" },
line_total: { type: "number" },
},
},
},
},
},
mode: {
type: "string",
enum: ["invoice", "grn"],
description:
"'invoice' (default) — full price-validating diff. 'grn' — delivery-note mode; cost validation is skipped, accept missing/zero prices. Verdict semantics: 'grn' diffs almost always return `ready_to_receive` because they don't validate cost.",
},
},
},
},
{
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. 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", "actor"],
properties: {
po_reference: {
type: "string",
description:
"The PO reference. Resolved against `stock_purchases.purchase_number` first, then `purchase_orders.po_number`.",
},
report: {
type: "object",
description:
"The structured report payload to persist. Typically the full `MatchReport` returned by `purchase_orders.diff`, but the tool accepts any JSON-shaped object — the agent may pass a synthesised summary if it ran a multi-step workflow.",
},
summary: {
type: "string",
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" },
},
},
},
},
},
{
name: "stock_purchases.get",
description:
"Fetch a single stock_purchase plus its line items. 404 when the id is missing.",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
},
{
name: "stock_purchases.create",
description:
"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 }`.",
inputSchema: {
type: "object",
required: ["supplier", "items"],
properties: {
supplier: { type: "string", description: "suppliers id" },
notes: { type: "string" },
items: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["stock_item", "description", "quantity"],
properties: {
stock_item: { type: "string", description: "stock_items id" },
description: { type: "string" },
quantity: { type: "number" },
cost_price: { type: "number" },
notes: { type: "string" },
},
},
},
},
},
},
{
name: "stock_purchases.next_number",
description: "Reserve the next stock-purchase number (`PO{yy}{6d-seq}STK`). Atomic counter; gaps are possible if the caller never persists the resulting draft.",
inputSchema: { type: "object", properties: {} },
},
{
name: "stock_purchases.send",
description:
"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`.",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
},
{
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). 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", "actor"],
properties: {
id: { type: "string" },
items: {
type: "array",
description: "Optional per-line overrides for partial receives.",
items: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", description: "stock_purchase_item id" },
quantity_received: { type: "number", description: "Override qty to book; omit to use the line's remaining qty" },
},
},
},
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" },
},
},
},
},
},
{
name: "stock_purchases.send_email",
description:
"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 validation.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
to: { type: "string" },
cc: { type: "string" },
bcc: { type: "string" },
subject: { type: "string" },
message: { type: "string" },
},
},
},
{
name: "stock_purchases.retry_email",
description:
"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.",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
},
{
name: "stock_purchases.add_attachment",
description:
"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 versioned-PDF `documents` field is intentionally NOT touched.",
inputSchema: {
"type": "object",
"required": ["purchase_id", "filename", "base64"],
properties: {
purchase_id: {
"type": "string",
"description": "15-char PocketBase stock_purchase id",
},
filename: { "type": "string" },
content_type: { "type": "string" },
size: { "type": "number" },
base64: {
"type": "string",
"description":
"Raw file bytes encoded as base64 (no `data:` URL prefix).",
},
},
},
},
{
name: "stock_purchases.match_and_receive_from_invoice",
description:
"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 receive. The tool auto-detects the PO kind: tries `stock_purchases` first (looking up by `purchase_number`, e.g. `PO26080501STK`), then falls back to `purchase_orders` (looking up by `po_number`, e.g. `PO-2026-08-017`). For stock purchases it books stock_ledger + bumps stock_items.current_qty. For customer POs it bumps `order_items.qty_collected`, sets `collected_date`, stamps `supplier_invoice_number`, and flips `item_status` to `\"collected\"`. The agent parses the document image with its native vision capability and passes the structured fields in (`document.{document_no, date, supplier_name?, lines[]}`). Two modes: `\"invoice\"` (default, full price-validating receive) or `\"grn\"` (delivery-note mode: no price check, accepts missing/zero prices, stamps a TODO note per line, filename prefix becomes `delivery_note_…` instead of `supplier_invoice_…`). Verdict enum: `ready_to_receive | po_not_found | wrong_status | supplier_mismatch | unmatched_po_lines | unmatched_invoice_lines | qty_mismatch | cost_mismatch`. With `confirm=false` (default), the tool only reports — it never writes. With `confirm=true`, the receive is **blocked** unless `verdict === \"ready_to_receive\"`; on success it attaches the document (if `attachment` was supplied) and performs the receive in one round-trip.",
inputSchema: {
type: "object",
required: ["po_reference", "document"],
properties: {
po_reference: {
type: "string",
description:
"The \"Your Ref\" / \"Order No\" on the document. Resolved against `stock_purchases.purchase_number` first, then `purchase_orders.po_number`. Case-insensitive; whitespace-trimmed.",
},
document: {
type: "object",
required: ["document_no", "date", "lines"],
properties: {
document_no: {
type: "string",
description:
"Supplier's invoice or delivery-note number (the \"Document No\" on the printed form).",
},
date: {
type: "string",
description:
"Document date in `YYYY-MM-DD`. The agent should convert from the supplier's local format (e.g. `05/08/26` → `2026-08-05`).",
},
supplier_name: {
type: "string",
description:
"Optional supplier name as printed on the document — used as a soft cross-check against the PO's supplier.",
},
lines: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["description", "quantity", "unit_price"],
properties: {
code: {
type: "string",
description:
"Supplier's SKU / catalogue code (e.g. `56020510`). Optional but strongly preferred — enables the exact-match path.",
},
description: { type: "string" },
quantity: { type: "number" },
unit_price: { type: "number" },
line_total: { type: "number" },
},
},
},
},
},
attachment: {
type: "object",
description:
"Optional raw document bytes to attach. PB will append its own random token at write time. Filename convention depends on `mode`: `supplier_invoice_<slug>_<no>_<YYYY-MM-DD>.<ext>` for invoice mode, `delivery_note_<slug>_<no>_<YYYY-MM-DD>.<ext>` for grn mode.",
required: ["filename", "content_type", "base64"],
properties: {
filename: { type: "string" },
content_type: { type: "string" },
base64: {
type: "string",
description:
"Raw file bytes encoded as base64 (no `data:` URL prefix).",
},
},
},
confirm: {
type: "boolean",
description:
"`false` (default) — dry run, returns the report only. `true` — commit, requires `verdict === \"ready_to_receive\"`, attaches the document (if any) then performs the receive.",
},
mode: {
type: "string",
enum: ["invoice", "grn"],
description:
"`\"invoice\"` (default) — full price-validating receive. `\"grn\"` (goods-received-note) — delivery-note mode: skips price validation, accepts missing/zero prices, stamps a `DELIVERY NOTE …` note on each line, filename prefix becomes `delivery_note_…`.",
},
},
},
},
{
name: "stock_purchase_items.update",
description:
"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-edit guard: cost_price > 0 already on the line is rejected unless force_overwrite is true.",
inputSchema: {
type: "object",
required: ["id", "patch"],
properties: {
id: { type: "string", description: "15-char stock_purchase_item id" },
patch: {
type: "object",
description:
"Subset of {cost_price, sku, description, notes, unit, supplier_sku, quantity}. Anything else returns forbidden_field.",
properties: {
cost_price: { type: "number" },
sku: { type: "string" },
description: { type: "string" },
notes: { type: "string" },
unit: { type: "string" },
supplier_sku: { type: "string" },
quantity: { type: "number" },
},
},
force_overwrite: {
type: "boolean",
description:
"Allow overwriting an existing cost_price > 0. Default false.",
},
},
},
},
{
name: "stock_purchase_items.list",
description:
"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 && purchase = \"sp-1\"`).",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
filter: { type: "string" },
sort: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "stock_purchase_items.bulk_update",
description:
"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: \"dry_run\" per row and writes nothing.",
inputSchema: {
type: "object",
required: ["items"],
properties: {
items: {
type: "array",
description:
"Rows to update. Each row: {id, patch, force_overwrite?}. Patch is the same whitelist as stock_purchase_items.update.",
items: {
type: "object",
required: ["id", "patch"],
properties: {
id: { type: "string" },
patch: {
type: "object",
properties: {
cost_price: { type: "number" },
sku: { type: "string" },
description: { type: "string" },
notes: { type: "string" },
unit: { type: "string" },
supplier_sku: { type: "string" },
quantity: { type: "number" },
},
},
force_overwrite: { type: "boolean" },
},
},
maxItems: 100,
},
dry_run: { type: "boolean" },
},
},
},
{
name: "stock_purchase_items.assign_driver",
description:
"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.",
inputSchema: {
type: "object",
required: ["id", "drv_id"],
properties: {
id: { type: "string", description: "15-char stock_purchase_item id" },
drv_id: { type: "string", description: "Driver relation id" },
assigned_at: {
type: "string",
description: "ISO timestamp; defaults to now.",
},
},
},
},
{
name: "stock_purchase_items.mark_collected",
description:
"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 - quantity_received` (remaining qty); explicit value must be ≥ 0 and ≤ remaining. Header auto-flips to `received` when every line on the parent PO has quantity_received >= quantity.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", description: "15-char stock_purchase_item id" },
quantity_collected: {
type: "number",
description:
"Qty to add to quantity_received. Defaults to remaining qty.",
},
},
},
},
{
name: "sprint.list",
description:
"List the sprint (pending, no-PO) items. Use this as the source of truth for what a buyer still needs to process.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
filter: { type: "string" },
sort: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "sprint.attachment_counts",
description:
"Return a `{ id → count }` map of pricing-proof attachments per sprint item, suitable for showing a paperclip badge in a list view.",
inputSchema: {
type: "object",
properties: {
item_ids: { type: "array", items: { type: "string" } },
},
},
},
{
name: "sprint.item_attachments",
description:
"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: "15-char sprint item id." } },
},
},
{
name: "sprint.claim",
description:
"Race-safe claim on a sprint item. Sets `claim_status=claimed`, `claimed_by=<caller>`, `claimed_at=now`, `claim_expires_at=now+30min`. Returns 409 with the current claim if another agent already owns it.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", description: "sprint item id" },
claimed_by: { type: "string", description: "agent / operator identity (default \"hermes\")" },
},
},
},
{
name: "buyer_tasks.list",
description:
"List the buyer-tasks feed for a tab. `tab` is one of `needs_po`, `po_draft`, `po_sent`. Returns `{ groups: [...] }`.",
inputSchema: {
type: "object",
required: ["tab"],
properties: {
tab: { type: "string", enum: ["needs_po", "po_draft", "po_sent"] },
},
},
},
{
name: "buyer_tasks.generate_po",
description:
"Generate a purchase_order for a (orderId, supplierId) pair and link the supplied items to it. Returns the new `purchaseOrderId`.",
inputSchema: {
type: "object",
required: ["orderId", "supplierId", "items"],
properties: {
orderId: { type: "string" },
supplierId: { type: "string" },
items: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["id", "costPrice", "quantity"],
properties: {
id: { type: "string", description: "order_item id" },
costPrice: { type: "number" },
quantity: { type: "number" },
},
},
},
},
},
},
{
name: "buyer_tasks.mark_ready",
description:
"Mark a (orderId, supplierId) group as ready for collection. `note` is required (non-empty) for the audit trail.",
inputSchema: {
type: "object",
required: ["orderId", "supplierId", "items", "note"],
properties: {
orderId: { type: "string" },
supplierId: { type: "string" },
items: { type: "array", items: { type: "object", properties: { id: { type: "string" } } } },
note: { type: "string" },
},
},
},
// v3 stock-out flow: fulfills an order_item from a stock_item,
// writes the ledger, decrements `current_qty`, stamps
// `item_status="stock_booked"`. Two modes (CREATE / UPDATE).
{
name: "stock.fulfill",
description:
"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_item_id` + `qty` + `intent`) flips an existing order_item to `collected` or `delivered`.",
inputSchema: {
type: "object",
required: ["order_item_id", "stock_item_id", "qty"],
properties: {
order_id: { type: "string" },
order_item_id: { type: "string" },
stock_item_id: { type: "string" },
qty: { type: "number" },
description: { type: "string" },
po_description: { type: "string" },
selling_price: { type: "number" },
cost_price: { type: "number" },
intent: { type: "string", enum: ["collected", "delivered"] },
notes: { type: "string" },
},
},
},
{
name: "stock.surplus_book",
description:
"Book surplus from a collected order_item into a stock_item. Auto-picks the matching stock_item when `stock_item_id` is omitted.",
inputSchema: {
type: "object",
required: ["order_item_id", "surplus_qty"],
properties: {
order_item_id: { type: "string" },
surplus_qty: { type: "number" },
stock_item_id: { type: "string" },
},
},
},
{
name: "ai_insights.rerun",
description:
"Re-run the n8n AI Insights workflow on a single quote or order. Snapshot-once lifecycle — use this when new catalogue data has appeared.",
inputSchema: {
type: "object",
required: ["target", "id"],
properties: {
target: { type: "string", enum: ["quote", "order"] },
id: { type: "string" },
},
},
},
// ─── AI settings (operator-facing config) ───────────────────────────
// The processor (and any agent that wants to introspect the AI
// pipeline) reads from the single `ai_settings` row. The /settings/ai
// UI writes through these MCP tools so the same surface works for
// agents and humans alike. `api_key` is NEVER echoed back — callers
// see `api_key_set: boolean` instead, and write `api_key: ""` to
// clear.
{
name: "ai_settings.get",
description:
"Read the current AI configuration. Returns the redacted record (api_key is empty, api_key_set indicates whether one is configured).",
inputSchema: { type: "object", properties: {} },
},
{
name: "ai_settings.update",
description:
"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 updated (redacted) record. The processor picks up the change on its next call (≤ 60 s cache TTL).",
inputSchema: {
type: "object",
properties: {
provider: { type: "string", enum: ["anthropic", "minimax", ""] },
api_key: { type: "string", description: "Omit to keep current value. Pass \"\" to clear." },
base_url: { type: "string" },
model_id: { type: "string" },
max_tokens: { type: "number" },
enabled: { type: "boolean" },
},
},
},
// ─── Management CRUD — companies / customers / suppliers ──────────
// Five verbs (list / get / create / update / delete) × three
// collections. Hermes agents can run any of these to maintain the
// CRM. `create` / `update` accept either a record id OR a name for
// relation fields (`company`, `rep`) — names are resolved by
// case-insensitive lookup so agents don't need to know ids up
// front.
{
name: "companies.list",
description: "List companies, optionally filtered by name or vendor substring.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
search: { type: "string" },
sort: { type: "string" },
},
},
},
{
name: "companies.get",
description: "Fetch a single company by id.",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
},
{
name: "companies.create",
description:
"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.",
inputSchema: {
type: "object",
required: ["company"],
properties: {
company: { type: "string" },
address: { type: "string" },
vendor: { type: "string" },
coordinates: {
type: "object",
properties: {
lon: { type: "number" },
lat: { type: "number" },
},
},
},
},
},
{
name: "companies.update",
description: "Patch fields on a company. Only the fields present in `patch` are written.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
patch: {
type: "object",
properties: {
company: { type: "string" },
address: { type: "string" },
vendor: { type: "string" },
coordinates: {
type: "object",
properties: {
lon: { type: "number" },
lat: { type: "number" },
},
},
},
},
},
},
},
{
name: "companies.delete",
description:
"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).",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
},
{
name: "customers.list",
description:
"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.\n\n" +
"Filters:\n" +
"- `search`: substring match on customer / email / number AND on rep name / rep email (one query, four fields).\n" +
"- `rep`: exact filter on the rep relation. Accepts a 15-char PB user id (verbatim) OR a user name / email (case-insensitive lookup against `users.name` / `users.email`). Unresolvable input is silently dropped so a bad param doesn't 500 the whole list call.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
search: { type: "string" },
rep: { type: "string", description: "Exact filter on the rep relation. Accepts a 15-char PB user id OR a user name / email (case-insensitive)." },
sort: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "customers.get",
description: "Fetch a single customer by id. Pass `expand` to inline company / rep records.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "customers.create",
description:
"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 can link it later. `coordinates` is `{ lon, lat }` (PB geoPoint).",
inputSchema: {
type: "object",
required: ["customer"],
properties: {
customer: { type: "string" },
email: { type: "string" },
alternative_email: { type: "string" },
buyer_email: { type: "string" },
number: { type: "string" },
company: { type: "string" },
rep: { type: "string" },
coordinates: {
type: "object",
properties: {
lon: { type: "number" },
lat: { type: "number" },
},
},
description: { type: "string" },
},
},
},
{
name: "customers.update",
description:
"Patch fields on a customer. Like `customers.create`, `company` and `rep` accept either id or name. Pass `company: \"\"` or `rep: \"\"` to unset a relation.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
patch: {
type: "object",
properties: {
customer: { type: "string" },
email: { type: "string" },
alternative_email: { type: "string" },
buyer_email: { type: "string" },
number: { type: "string" },
company: { type: "string" },
rep: { type: "string" },
coordinates: {
type: "object",
properties: {
lon: { type: "number" },
lat: { type: "number" },
},
},
description: { type: "string" },
},
},
},
},
},
{
name: "customers.delete",
description:
"Delete a customer. Cascade rules apply — customers linked to quotes or orders will fail to delete unless those rows are removed first.",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
},
{
name: "suppliers.list",
description:
"List suppliers, optionally filtered by name / email / phone / tags substring. `coordinates` is the legacy \"lat, lng\" text field (suppliers pre-date the geopoint migration).",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
search: { type: "string" },
sort: { type: "string" },
},
},
},
{
name: "suppliers.get",
description: "Fetch a single supplier by id.",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
},
{
name: "suppliers.create",
description:
"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).",
inputSchema: {
type: "object",
required: ["suppliername"],
properties: {
suppliername: { type: "string" },
email: { type: "string" },
suppliernumber: { type: "string" },
supplieraddress: { type: "string" },
supply_tags: { type: "string" },
coordinates: { type: "string" },
closingtime: { type: "string" },
},
},
},
{
name: "suppliers.update",
description: "Patch fields on a supplier.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
patch: {
type: "object",
properties: {
suppliername: { type: "string" },
email: { type: "string" },
suppliernumber: { type: "string" },
supplieraddress: { type: "string" },
supply_tags: { type: "string" },
coordinates: { type: "string" },
closingtime: { type: "string" },
},
},
},
},
},
{
name: "suppliers.delete",
description:
"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.",
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
},
{
name: "price_list.add_items",
description:
"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 fields, and passes them in. The tool handles deduplication by `(sku, supplier)`: if a row already exists for that SKU from that supplier, the existing row's `cost_price`, `description`, `selling_price`, `unit`, `category`, `subcategory`, `brand`, `markup`, `stock_qty`, `search_text`, and `sku_normalised` are patched in place; otherwise a new row is inserted. `cost_price` OVERWRITES — the latest price wins. The supplier is stored as free text on `price_list.supplier` (the live schema has no FK), so the supplier name is taken verbatim. Items with missing `sku`, missing `supplier_name`, non-positive `cost_price`, or a PB write failure are returned in `skipped[]` with a per-item reason so the agent can report them back to the operator. Max 500 items per call — chunk larger lists. Optionally pass `source_file_id` (a `raw_price_list` row id, when the operator uploaded the document via `/pricelist` first) and `source_label` (free text, e.g. `\"Q-AFRISAN-2026-08\"`) so the new rows can be traced back to the source document.",
inputSchema: {
type: "object",
required: ["items"],
properties: {
items: {
type: "array",
minItems: 1,
maxItems: 500,
items: {
type: "object",
required: ["sku", "description", "cost_price"],
properties: {
sku: {
type: "string",
description:
"SKU exactly as printed on the source document. Required. The tool normalises it (uppercase, strip non-alphanumeric) for the `sku_normalised` field used by the catalogue search.",
},
description: {
type: "string",
description:
"Human-readable description of the line item. Required. Falls back to the SKU if empty.",
},
cost_price: {
type: "number",
description:
"What the supplier charges per unit, in R. Required; must be > 0. Items with non-positive values land in `skipped[]`.",
},
selling_price: {
type: "number",
description:
"Optional. If omitted, computed from `cost_price * (1 + markup/100)`. The agent should pass it explicitly when the supplier's PDF already shows a selling/RSP column.",
},
markup: {
type: "number",
description:
"Optional markup percentage (e.g. 25 = 25%). Used only when `selling_price` is omitted.",
},
unit: {
type: "string",
description: 'Unit string — \"EA\", \"BOX OF 12\", \"L\", \"KG\", etc. Optional.',
},
category: {
type: "string",
description: "Optional category (\"Bearings\", \"Fasteners\", \"Lubricants\", …).",
},
subcategory: {
type: "string",
description: "Optional subcategory (\"Ball bearings\", \"Hex bolts\", …).",
},
brand: {
type: "string",
description: "Optional brand (\"SKF\", \"Bosch\", \"Afrisan\", …).",
},
stock_qty: {
type: "number",
description:
"Optional. The on-hand qty for this catalogue row. Default 0. The catalogue's `stock_qty` is a denormalised snapshot — the live `current_qty` lives on `stock_items`, not here.",
},
supplier_name: {
type: "string",
description:
"Supplier name as printed on the document. Stored verbatim on `price_list.supplier`. If the operator wants the price list keyed by supplier FK instead of free text, that's a schema migration — not part of this tool.",
},
},
},
},
source_file_id: {
type: "string",
description:
"Optional `raw_price_list.id` linking the new rows back to the source document. Pass when the operator uploaded the document via `/pricelist` first; leave undefined for ad-hoc / paste-into-chat flows.",
},
source_label: {
type: "string",
description:
"Optional free-text label for the source document (e.g. \"Q-AFRISAN-2026-08\" or \"AFRIQ-INA64721\"). Stored on each new row's `source_file` text field so the Catalogue tab can show \"extracted from: …\" without a join.",
},
},
},
},
// Discovery — Hermes (and n8n workflows that prefer a JSON-Schema-only
// catalog) can hit this endpoint instead of building the full JSON-RPC
// envelope just to discover available tools.
{
name: "tools.discovery",
description:
"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) {
try {
assertAgentAuth(req);
const body = await req.json();
// MCP protocol handshake. The spec requires every client to send
// `initialize` before any other method; clients that don't get a
// well-formed response (incl. HTTP 200) abort the session before
// `tools/list` ever fires. This block is what makes WFDASH reachable
// as a real MCP server from any spec-compliant client (Hermes,
// Claude Desktop, Cursor, mcp-cli, …). Before this fix, clients
// got HTTP 404 "Method not found" on `initialize` and terminated
// the session — see docs/bugs/2026-08-04-mcp-initialize-handshake.md.
//
// Result shape: the MCP `initialize` response is a PLAIN object,
// not wrapped in the `content: [{type:"text",...}]` envelope that
// `jsonRpcOk` (used by tools/list and tools/call) wraps its result
// in. Using jsonRpcOk here would put the protocolVersion under
// `result.content[0].text` and clients would still drop us.
if (body?.method === "initialize") {
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: {
protocolVersion: body?.params?.protocolVersion || "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "wfdash-mcp", version: "1.0.0" },
},
});
}
// `notifications/initialized` is a JSON-RPC notification (no `id`,
// no response expected). We acknowledge with 204 so the client
// doesn't hang waiting for a reply that never comes.
if (body?.method === "notifications/initialized") {
return new NextResponse(null, { status: 204 });
}
// Optional liveness probe — some clients send this before opening
// a real session. Result is an empty object per the spec.
if (body?.method === "ping") {
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: {},
});
}
if (body?.method === "tools/list") {
return withCors(
NextResponse.json({ jsonrpc: "2.0", id: body.id ?? null, result: { tools } }),
req,
);
}
if (body?.method === "tools/call") {
const pb = await getAdminPocketBase();
const toolName = body?.params?.name;
const args = body?.params?.arguments || {};
if (toolName === "orders.list") {
const page = Number(args.page || 1);
const perPage = Number(args.perPage || 25);
// orders no longer carry a `status` field — items drive the
// workflow. `filter` is the agent's primary knob (PB syntax,
// e.g. `created >= "2026-08-05" && created < "2026-08-06"`).
// `search` composes on top of `filter`; `sort` defaults to
// `-created` for back-compat with the old handler.
const filter = args.filter || "";
const search = args.search ? `order_number ~ "${args.search}"` : "";
const clauses = [filter, search].filter((c) => c.length > 0);
const combined = clauses.join(" && ");
const sort = args.sort || "-created";
const result = await pb.collection("orders").getList(page, perPage, {
filter: combined,
sort,
expand: "company,customer,quote",
});
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "orders.create_from_quote") {
const result = await createOrderFromQuote(args.quote_id);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "orders.create") {
const customerId = String(args.customer_id || "").trim();
const companyId = String(args.company_id || "").trim();
if (!customerId) throw new Error("Missing customer_id for orders.create.");
if (!companyId) throw new Error("Missing company_id for orders.create.");
const rawItems = Array.isArray(args.items) ? args.items : [];
if (rawItems.length === 0) {
throw new Error("items must be a non-empty array for orders.create.");
}
const items = rawItems.map((it: Record<string, unknown>) => ({
description: typeof it.description === "string" ? it.description.trim() : "",
quantity: typeof it.quantity === "number" ? it.quantity : null,
cost_price: typeof it.cost_price === "number" ? it.cost_price : undefined,
markup: typeof it.markup === "number" ? it.markup : undefined,
selling_price: typeof it.selling_price === "number" ? it.selling_price : undefined,
sku: typeof it.sku === "string" ? it.sku : undefined,
unit: typeof it.unit === "string" ? it.unit : undefined,
supplier_id: typeof it.supplier_id === "string" ? it.supplier_id : undefined,
notes: typeof it.notes === "string" ? it.notes : undefined,
}));
const result = await createOrder({
customer_id: customerId,
company_id: companyId,
notes: typeof args.notes === "string" ? args.notes : undefined,
contact_name: typeof args.contact_name === "string" ? args.contact_name : undefined,
description: typeof args.description === "string" ? args.description : undefined,
rep: typeof args.rep === "string" ? args.rep : undefined,
items,
});
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "orders.split") {
const orderId = String(args.order_id || "").trim();
if (!orderId) throw new Error("Missing order_id for orders.split.");
const rawIds = Array.isArray(args.item_ids) ? args.item_ids : [];
const itemIds = rawIds.filter(
(x: unknown): x is string => typeof x === "string" && x.length > 0,
);
if (itemIds.length === 0) {
throw new Error("item_ids must be a non-empty array of strings for orders.split.");
}
const result = await splitOrderForContinuation(orderId, itemIds);
// Mirror the REST endpoint: surface total failure as a structured
// error envelope rather than a successful empty move.
if (result.itemsMoved === 0) {
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
error: {
code: -32000,
message: "No items moved — every requested item was invalid.",
data: { itemsFailed: result.itemsFailed },
},
});
}
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "collection_pool.list") {
const result = await listCollectionPool({
page: Number(args.page || 1),
perPage: Number(args.perPage || 50),
status: args.status,
});
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "collection_confirm.list") {
// Unified collection_confirm queue: order_items where
// item_status="collected" + stock_purchase_items where
// status="collected". One tool, two sources, filtered by the
// `source` arg (default "all"). Backed by the existing
// `listCollectedItems` service.
const { listCollectedItems } = await import(
"@/lib/server/collection-confirm-service"
);
const source =
args.source === "customer" || args.source === "stock"
? args.source
: "all";
const allRows = await listCollectedItems();
const filtered =
source === "all" ? allRows : allRows.filter((r) => r.source === source);
const perPage = Math.min(
Number(args.perPage || 100),
500,
);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: {
content: [
{
type: "text",
text: JSON.stringify({
source_filter: source,
count: filtered.length,
rows: filtered.slice(0, perPage),
}),
},
],
},
});
}
if (toolName === "collection_confirm.upload_invoice") {
const ids = Array.isArray(args.item_ids) ? args.item_ids.map(String) : [];
const filename = typeof args.filename === "string" ? args.filename : "";
const contentType =
typeof args.content_type === "string"
? args.content_type
: "application/octet-stream";
const base64 = typeof args.base64 === "string" ? args.base64 : "";
if (ids.length === 0) {
return jsonRpcOk(
body.id,
{ ok: false, reason: "invalid_item_ids", message: "item_ids must be a non-empty array" },
400,
);
}
if (!filename || !base64) {
return jsonRpcOk(
body.id,
{ ok: false, reason: "missing_file", message: "filename and base64 are required" },
400,
);
}
const { uploadInvoiceToOrderItems } = await import(
"@/lib/server/collection-confirm-service"
);
try {
const bytes = Buffer.from(base64, "base64");
const file = new File([new Uint8Array(bytes)], filename, {
type: contentType,
});
const result = await uploadInvoiceToOrderItems(ids, file);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: {
content: [
{
type: "text",
text: JSON.stringify({ ok: true, ...result }),
},
],
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonRpcOk(
body.id,
{ ok: false, reason: "upload_failed", message },
422,
);
}
}
if (toolName === "collection_confirm.confirm_order_items") {
const ids = Array.isArray(args.item_ids) ? args.item_ids.map(String) : [];
const invoice = (args.invoice ?? {}) as {
number?: string;
date?: string;
};
const actor = (args.actor ?? {}) as { id?: string; name?: string };
if (ids.length === 0) {
return jsonRpcOk(
body.id,
{ ok: false, reason: "invalid_item_ids", message: "item_ids must be a non-empty array" },
400,
);
}
if (!invoice.number || !invoice.date || !actor.id || !actor.name) {
return jsonRpcOk(
body.id,
{ ok: false, reason: "missing_args", message: "invoice.{number,date} and actor.{id,name} are required" },
400,
);
}
const costOverrides: Record<string, number> = {};
if (args.cost_overrides && typeof args.cost_overrides === "object") {
for (const [k, v] of Object.entries(args.cost_overrides)) {
if (typeof v === "number") costOverrides[k] = v;
}
}
const { confirmOrderItemsForInvoicing } = await import(
"@/lib/server/collection-confirm-service"
);
const confirmed = await confirmOrderItemsForInvoicing(ids, {
invoice: { number: invoice.number, date: invoice.date },
costOverrides,
actor: { id: actor.id, name: actor.name },
});
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: {
content: [
{
type: "text",
text: JSON.stringify({
ok: true,
confirmed,
requested: ids.length,
}),
},
],
},
});
}
if (toolName === "order_items.update_status") {
if (!args.id) {
throw new Error("Missing id for order item update.");
}
const patch = normalizeOrderItemStatusInput({
item_status: args.item_status,
drv_status: args.drv_status,
notes: args.notes,
problem_notes: args.problem_notes,
});
const result = await pb.collection(getOrderItemsCollectionName()).update(args.id, patch);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "order_items.confirm_collected") {
const ids = Array.isArray(args.item_ids)
? (args.item_ids as unknown[]).filter(
(x): x is string => typeof x === "string" && x.length > 0,
)
: [];
const actor = (args.actor ?? {}) as { id?: string; name?: string };
if (ids.length === 0) {
return jsonRpcOk(
body.id,
{ ok: false, reason: "invalid_item_ids", message: "item_ids must be a non-empty array of strings" },
400,
);
}
if (!actor.id || !actor.name) {
return jsonRpcOk(
body.id,
{ ok: false, reason: "missing_actor", message: "actor.{id,name} are required for order_items.confirm_collected" },
400,
);
}
const quantityCollected: Record<string, number> = {};
if (args.quantity_collected && typeof args.quantity_collected === "object") {
for (const [k, v] of Object.entries(args.quantity_collected as Record<string, unknown>)) {
if (typeof v === "number") quantityCollected[k] = v;
}
}
const { confirmOrderItemsCollected } = await import(
"@/lib/server/order-items-service"
);
try {
const result = await confirmOrderItemsCollected(ids, {
actor: { id: String(actor.id), name: String(actor.name) },
note: typeof args.note === "string" ? args.note : undefined,
quantity_collected: quantityCollected,
collected_at: typeof args.collected_at === "string" ? args.collected_at : undefined,
});
// 409 when EVERY row refused — operator wants a clear "nothing
// changed" signal. Partial failures (some ok, some refused)
// return 200 with the per-row breakdown so the agent can
// surface the survivors + reasons.
const status =
result.confirmed.length === 0 && result.failed.length > 0 ? 409 : 200;
return jsonRpcOk(body.id, { ok: true, ...result, requested: ids.length }, status);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const reason = (err as { code?: string }).code ?? "error";
const status =
reason === "missing_actor"
? 400
: reason === "not_found"
? 404
: reason === "wrong_status"
? 409
: 422;
return jsonRpcOk(body.id, { ok: false, reason, message }, status);
}
}
if (toolName === "order_items.update") {
if (!args.id) throw new Error("Missing id for order_items.update.");
if (!args.patch || typeof args.patch !== "object" || Array.isArray(args.patch)) {
throw new Error("Missing or invalid `patch` for order_items.update.");
}
const rawPatch = args.patch as Record<string, unknown>;
const patch: Record<string, unknown> = {};
const rejected: string[] = [];
for (const [k, v] of Object.entries(rawPatch)) {
if ((ORDER_ITEM_PATCH_FIELDS as readonly string[]).includes(k)) {
patch[k] = v;
} else {
rejected.push(k);
}
}
if (rejected.length > 0) {
throw new Error(
`forbidden_field(s): ${rejected.join(", ")}. Allowed: ${ORDER_ITEM_PATCH_FIELDS.join(", ")}.`,
);
}
if (Object.keys(patch).length === 0) {
throw new Error("Patch is empty after whitelist.");
}
const updated = await pb.collection(getOrderItemsCollectionName()).update(args.id, patch);
return jsonRpcOk(body.id, { item: updated });
}
if (toolName === "orders.add_attachment") {
if (!args.order_id || !args.filename || !args.base64) {
throw new Error(
"Missing order_id, filename, or base64 for orders.add_attachment",
);
}
const bytes = Buffer.from(String(args.base64), "base64");
const fd = new FormData();
// The server route expects a `file` Blob. Reconstruct one from
// the base64 bytes (Node's File polyfill is in `node:buffer`).
const blob = new Blob([bytes], {
type: typeof args.content_type === "string"
? args.content_type
: "application/octet-stream",
});
// Attach a filename so the route can read file.name
Object.defineProperty(blob, "name", { value: String(args.filename) });
fd.append("file", blob, String(args.filename));
if (typeof args.kind === "string") fd.append("kind", args.kind);
const res = await fetch(
`${req.nextUrl.origin}/api/v1/orders/${args.order_id}/attachments/upload`,
{
method: "POST",
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: fd,
},
);
if (!res.ok) {
throw new Error(`orders.add_attachment upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return jsonRpcOk(body.id, data);
}
if (toolName === "orders.list_attachments") {
if (!args.order_id) {
throw new Error("Missing order_id for orders.list_attachments.");
}
const res = await fetch(
`${req.nextUrl.origin}/api/v1/orders/${args.order_id}/matched-attachments`,
{
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
},
);
if (!res.ok) {
throw new Error(`orders.list_attachments upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return jsonRpcOk(body.id, data);
}
if (toolName === "quotes.create_from_email") {
const payload: EmailIntakePayload = {
from_email: args.from_email,
from_name: args.from_name,
subject: args.subject,
body_plain: args.body_plain,
body_html: args.body_html,
message_id: args.message_id,
in_reply_to: args.in_reply_to,
received_at: args.received_at,
company_hint: args.company_hint,
extracted_items: args.extracted_items,
attachments: args.attachments,
};
const result = await createQuoteFromEmail(payload);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "quotes.ingest_from_email") {
// The new pipeline's fast front-door. Calls the same /from-email
// route that the HTTP layer exposes, so behaviour stays in lock-
// step (idempotency on message_id, sentinel-customer fallback,
// ai_status="pending" stamp).
const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/from-email`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
from_email: args.from_email,
from_name: args.from_name,
subject: args.subject,
body_plain: args.body_plain,
body_html: args.body_html,
message_id: args.message_id,
in_reply_to: args.in_reply_to,
received_at: args.received_at,
company_hint: args.company_hint,
}),
});
if (!res.ok) {
throw new Error(`quotes.ingest_from_email upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(data) }] },
});
}
if (toolName === "quotes.process_pending") {
// Auth-required by the upstream route. Forward the agent key.
const limit = typeof args.limit === "number" ? args.limit : 5;
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/process-pending?limit=${encodeURIComponent(String(limit))}`,
{
method: "POST",
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
},
);
if (!res.ok) {
throw new Error(`quotes.process_pending upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(data) }] },
});
}
if (toolName === "quotes.create") {
// Walk-in quote creation. Forwards to the REST endpoint so the
// service-layer logic (id-vs-name resolution, item creation via
// createQuoteItem, F.1 auto-derive, F.2 whitelist) is the same
// path as the REST callers.
if (!args.customer || typeof args.customer !== "string") {
throw new Error("customer is required (id or name).");
}
if (!Array.isArray(args.items) || args.items.length === 0) {
throw new Error("items must be a non-empty array.");
}
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/create`,
{
method: "POST",
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
"content-type": "application/json",
},
body: JSON.stringify({
customer: args.customer,
company: args.company,
letterhead: args.letterhead,
description: args.description,
validity_days: args.validity_days,
items: args.items,
}),
},
);
if (!res.ok) {
throw new Error(`quotes.create upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(data) }] },
});
}
if (toolName === "quotes.re_run_ai") {
if (!args.id) throw new Error("Missing id for quotes.re_run_ai.");
const wipe = args.wipe_items === false ? "false" : "true";
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${encodeURIComponent(String(args.id))}/re-run-ai?wipe_items=${wipe}`,
{
method: "POST",
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
},
);
if (!res.ok) {
throw new Error(`quotes.re_run_ai upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(data) }] },
});
}
if (toolName === "quotes.append_reply") {
const payload: EmailReplyPayload = {
from_email: args.from_email,
from_name: args.from_name,
subject: args.subject,
body_plain: args.body_plain,
body_html: args.body_html,
message_id: args.message_id,
in_reply_to: args.in_reply_to,
received_at: args.received_at,
quoted_text: args.quoted_text,
};
const result = await appendReplyToQuote(payload);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
});
}
if (toolName === "quotes.find_by_message_id") {
if (!args.message_id) {
throw new Error("Missing message_id for quotes.find_by_message_id.");
}
const quote = await findQuoteByMessageId(args.message_id);
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify({ quote }) }] },
});
}
if (toolName === "attachments.upload_for_quote") {
if (!args.quote_id) throw new Error("Missing quote_id for attachments.upload_for_quote.");
if (!args.filename) throw new Error("Missing filename for attachments.upload_for_quote.");
if (!args.base64) throw new Error("Missing base64 for attachments.upload_for_quote.");
const attachment = await uploadAttachmentForQuote({
quoteId: args.quote_id,
filename: args.filename,
contentType: args.content_type,
size: args.size,
base64: args.base64,
messageId: args.message_id,
});
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify({ attachment }) }] },
});
}
if (toolName === "quotes.list_attachments") {
if (!args.quote_id) throw new Error("Missing quote_id for quotes.list_attachments.");
const quoteId = String(args.quote_id);
const page = Number(args.page || 1);
const perPage = Math.min(500, Number(args.perPage || 50));
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${quoteId}/attachments?page=${page}&perPage=${perPage}`,
{
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
},
);
if (!res.ok) {
throw new Error(`quotes.list_attachments upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return jsonRpcOk(body.id, data);
}
if (toolName === "quotes.get_pdf") {
if (!args.id) throw new Error("Missing id for quotes.get_pdf.");
const quoteId = String(args.id);
const params = new URLSearchParams();
if (typeof args.letterhead === "string") params.set("letterhead", args.letterhead);
if (args.regenerate === true) params.set("regenerate", "1");
const qs = params.toString();
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${quoteId}/pdf/base64${qs ? `?${qs}` : ""}`,
{
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
},
);
if (!res.ok) {
throw new Error(`quotes.get_pdf upstream ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return jsonRpcOk(body.id, data);
}
if (toolName === "purchase_orders.add_attachment") {
if (!args.po_id || !args.filename || !args.base64) {
throw new Error("Missing po_id, filename, or base64 for purchase_orders.add_attachment");
}
const bytes = Buffer.from(args.base64, "base64");
const fd = new FormData();
fd.append(
"file",
new Blob([new Uint8Array(bytes)], { type: args.content_type || "application/octet-stream" }),
args.filename,
);
const updated = await pb.collection("purchase_orders").update<{
additional_attachments: string[] | string;
}>(args.po_id, fd);
const raw = updated?.additional_attachments ?? [];
const arr = Array.isArray(raw) ? raw : raw ? [raw] : [];
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: { content: [{ type: "text", text: JSON.stringify({ ok: true, attachments: arr }) }] },
});
}
// ─── Sprint + buyer tasks + stock fulfillment + AI insights handlers ──────────────────────────────────
if (toolName === "bulk_update_quote_items") {
const rawItems = args?.items;
const dryRun = Boolean(args?.dry_run);
if (!Array.isArray(rawItems)) {
throw new Error("Missing or non-array `items` for bulk_update_quote_items.");
}
if (rawItems.length === 0) {
return jsonRpcOk(body.id, {
ok: true,
updated: 0,
skipped: 0,
errors: [],
results: [],
dry_run: dryRun,
});
}
if (rawItems.length > BULK_UPDATE_DEFAULT_CAP) {
throw new Error(
`bulk_update_quote_items accepts at most ${BULK_UPDATE_DEFAULT_CAP} items per call (got ${rawItems.length}).`,
);
}
const results: Array<Record<string, unknown>> = [];
const errors: Array<{ row: number; id: string; reason: string }> = [];
let updated = 0;
let skipped = 0;
for (let row = 0; row < rawItems.length; row++) {
const entry = rawItems[row];
const id = entry?.id;
const forceOverwrite = Boolean(entry?.force_overwrite);
// Structural validation — bad shape is reported per-row
// rather than aborting the whole batch.
if (typeof id !== "string" || id.length === 0) {
errors.push({ row, id: String(id ?? ""), reason: "validation_error: missing id" });
continue;
}
if (!entry?.patch || typeof entry.patch !== "object" || Array.isArray(entry.patch)) {
errors.push({ row, id, reason: "validation_error: missing or invalid patch" });
continue;
}
const { patch, reason: validationReason } = validateAndSanitisePatch(entry.patch);
if (validationReason) {
errors.push({ row, id, reason: validationReason });
continue;
}
if (Object.keys(patch).length === 0) {
// Empty patch (e.g. caller provided only forbidden fields)
// — nothing to write. Treat as skip.
errors.push({ row, id, reason: "validation_error: patch is empty after whitelist" });
continue;
}
// Auto-derive selling_price when cost + markup are both in the
// patch and selling_price is omitted. Done BEFORE the existing
// row read so the hand-edit guard sees the post-derive values
// and the previous_*/new_* response surfaces derive correctly.
const derivedPatch = deriveSellingPriceIfOmitted(patch);
// Read the existing row so we can apply the hand-edit guard
// and report previous / new price surfaces in the response.
// We re-fetch rather than relying on agent-supplied state
// because the agent may have a stale view.
let existing: { cost_price?: number | null; selling_price?: number | null };
try {
existing = await pb.collection("quote_items").getOne(id, {
fields: "cost_price,selling_price",
});
} catch (err) {
const message = err instanceof Error ? err.message : "not found";
errors.push({ row, id, reason: `not_found: ${message}` });
continue;
}
if (isAlreadyPriced(existing) && !forceOverwrite) {
skipped++;
results.push({
id,
status: "skipped",
reason: "already_priced",
previous_cost_price: existing.cost_price ?? 0,
previous_selling_price: existing.selling_price ?? 0,
});
continue;
}
if (dryRun) {
updated++;
results.push({
id,
status: "dry_run",
previous_cost_price: existing.cost_price ?? 0,
previous_selling_price: existing.selling_price ?? 0,
would_patch: derivedPatch,
});
continue;
}
// Apply. PB throws on validation errors which we surface
// as `pb_error` rather than aborting the batch.
try {
const updatedRow = await pb.collection("quote_items").update(id, derivedPatch);
updated++;
results.push({
id,
status: "ok",
previous_cost_price: existing.cost_price ?? 0,
previous_selling_price: existing.selling_price ?? 0,
new_cost_price:
derivedPatch.cost_price !== undefined
? (derivedPatch.cost_price as number)
: (existing.cost_price ?? 0),
new_selling_price:
derivedPatch.selling_price !== undefined
? (derivedPatch.selling_price as number)
: (existing.selling_price ?? 0),
updated_fields: Object.keys(derivedPatch),
});
// Touch the response to keep TS happy about the unused var
// on lint warning free configs.
void updatedRow;
} catch (err) {
const message = err instanceof Error ? err.message : "PB error";
errors.push({ row, id, reason: `pb_error: ${message}` });
}
}
return jsonRpcOk(body.id, {
ok: errors.length === 0,
updated,
skipped,
errors,
results,
dry_run: dryRun,
});
}
// ─── quote_items CRUD (added 2026-08-04) ───────────────────────────
// These mirror the four REST endpoints at /api/v1/quote_items so the
// MCP tool surface and the REST surface stay in lock-step. The
// patch whitelist here intentionally matches the one enforced by
// `bulk_update_quote_items` and `/api/v1/quote_items/[id]` — keep
// all three in sync if a new field is added.
const QUOTE_ITEM_MCP_PATCH_FIELDS = [
"cost_price",
"selling_price",
"description",
"sku",
"unit",
"supplier",
"markup",
"pricing_source",
"pricing_notes",
"matched_price_list_id",
"user_edited",
] as const;
if (toolName === "quote_items.list") {
const page = Number(args.page || 1);
const perPage = Math.min(500, Number(args.perPage || 50));
const result = await pb.collection("quote_items").getList(page, perPage, {
filter: typeof args.filter === "string" ? args.filter : "",
sort: typeof args.sort === "string" ? args.sort : "-created",
fields: typeof args.fields === "string" ? args.fields : undefined,
expand: typeof args.expand === "string" ? args.expand : undefined,
});
return jsonRpcOk(body.id, result);
}
if (toolName === "quote_items.get") {
if (!args.id) throw new Error("Missing id for quote_items.get.");
const item = await pb.collection("quote_items").getOne(args.id, {
expand: typeof args.expand === "string" ? args.expand : undefined,
fields: typeof args.fields === "string" ? args.fields : undefined,
});
return jsonRpcOk(body.id, { item });
}
if (toolName === "quote_items.create") {
const quoteId = typeof args.quote_id === "string" ? args.quote_id.trim() : "";
const description = typeof args.description === "string" ? args.description.trim() : "";
if (!quoteId) throw new Error("Missing quote_id for quote_items.create.");
if (!description) throw new Error("description is required (non-empty after trim).");
try {
const result = await createQuoteItem({
quoteId,
description,
quantity:
typeof args.quantity === "number" ? args.quantity : undefined,
unit: typeof args.unit === "string" ? args.unit : undefined,
sku: typeof args.sku === "string" ? args.sku : undefined,
supplier:
typeof args.supplier === "string" && args.supplier
? args.supplier
: null,
cost_price:
typeof args.cost_price === "number" ? args.cost_price : undefined,
selling_price:
typeof args.selling_price === "number"
? args.selling_price
: undefined,
markup: typeof args.markup === "number" ? args.markup : undefined,
notes: typeof args.notes === "string" ? args.notes : undefined,
pricing_source:
typeof args.pricing_source === "string"
? args.pricing_source
: undefined,
pricing_notes:
typeof args.pricing_notes === "string"
? args.pricing_notes
: undefined,
});
// `idempotent: true` means the create was deduped against a
// row created within the last 60 seconds — caller should
// treat the row as already-present and skip downstream
// side-effects (no extra email, no duplicate PO line, etc.).
return jsonRpcOk(body.id, {
item: result.quote_items,
idempotent: result.idempotent ?? false,
});
} catch (err) {
if (err instanceof CreateQuoteItemError) {
// Mirror the JSON-RPC error convention: surface the typed
// status code so the agent can tell 400 from 404 from 500.
throw new Error(err.message);
}
throw err;
}
}
if (toolName === "quote_items.update") {
if (!args.id) throw new Error("Missing id for quote_items.update.");
if (!args.patch || typeof args.patch !== "object" || Array.isArray(args.patch)) {
throw new Error("Missing or invalid `patch` for quote_items.update.");
}
const rawPatch = args.patch as Record<string, unknown>;
const patch: Record<string, unknown> = {};
const rejected: string[] = [];
for (const [k, v] of Object.entries(rawPatch)) {
if ((QUOTE_ITEM_MCP_PATCH_FIELDS as readonly string[]).includes(k)) {
patch[k] = v;
} else {
rejected.push(k);
}
}
if (rejected.length > 0) {
throw new Error(
`forbidden_field(s): ${rejected.join(", ")}. Allowed: ${QUOTE_ITEM_MCP_PATCH_FIELDS.join(", ")}.`,
);
}
if (Object.keys(patch).length === 0) {
throw new Error("Patch is empty after whitelist.");
}
const updated = await pb.collection("quote_items").update(args.id, patch);
return jsonRpcOk(body.id, { item: updated });
}
if (toolName === "quote_items.populate_insights") {
const write = args.write !== false;
const quoteId = typeof args.quote_id === "string" ? args.quote_id.trim() : "";
const itemId = typeof args.item_id === "string" ? args.item_id.trim() : "";
if (!quoteId && !itemId) {
throw new Error(
"quote_items.populate_insights requires either `quote_id` or `item_id`."
);
}
if (quoteId && itemId) {
throw new Error(
"quote_items.populate_insights takes exactly one of `quote_id` or `item_id`, not both."
);
}
try {
if (quoteId) {
// Batch: fan out per-item via the new Item Intel service so
// every line on the quote gets the same shape. Replaces the
// legacy SKU-first batch driver (which wrote the old
// ai_item_insights blob).
const { populateIntelForParent } = await import(
"@/lib/server/item-intel-service"
);
const result = await populateIntelForParent(quoteId, "quote_items", "quotes", { write });
return jsonRpcOk(body.id, { ok: true, mode: "quote", ...result });
}
const { populateItemIntel } = await import("@/lib/server/item-intel-service");
const intel = await populateItemIntel(itemId, "quote_items", { write });
return jsonRpcOk(body.id, {
ok: true,
mode: "item",
item_id: itemId,
intel,
match_kind: intel.stock_list.length || intel.price_list.length || intel.supplier_history.length
? "description_fuzzy"
: "none",
found_data:
intel.stock_list.length > 0 ||
intel.price_list.length > 0 ||
intel.recent_quote.length > 0 ||
intel.recent_order.length > 0 ||
intel.supplier_history.length > 0,
});
} catch (err) {
const message =
err instanceof Error ? err.message : "populate_insights failed";
// Mirror the JSON-RPC error convention used elsewhere in this
// route — surface the message via a 200 envelope so the agent
// can render it; only throw if the input was invalid.
if (
message.includes("Failed to fetch record") ||
message.includes("not found") ||
message.includes("Requested resource")
) {
return jsonRpcOk(body.id, { ok: false, error: message }, -32004);
}
throw err;
}
}
// ─── Orders (extra verbs) ────────────────────────────────────
if (toolName === "orders.get") {
if (!args.id) throw new Error("Missing id for orders.get.");
const order = await pb.collection("orders").getOne(args.id, {
expand: args.expand,
});
return jsonRpcOk(body.id, { order });
}
// ─── Order items (extra verbs) ──────────────────────────────
if (toolName === "order_items.list") {
const page = Number(args.page || 1);
const perPage = Math.min(500, Number(args.perPage || 50));
const result = await pb
.collection(getOrderItemsCollectionName())
.getList(page, perPage, {
filter: args.filter || "",
sort: args.sort || "-created",
expand: args.expand,
});
return jsonRpcOk(body.id, result);
}
if (toolName === "order_items.get") {
if (!args.id) throw new Error("Missing id for order_items.get.");
const item = await pb
.collection(getOrderItemsCollectionName())
.getOne(args.id, { expand: args.expand });
return jsonRpcOk(body.id, { item });
}
// ─── Quotes (extra verbs) ────────────────────────────────────
if (toolName === "quotes.list") {
const page = Number(args.page || 1);
const perPage = Math.min(500, Number(args.perPage || 50));
const result = await pb.collection("quotes").getList(page, perPage, {
filter: args.filter || "",
sort: args.sort || "-created",
expand: args.expand,
});
return jsonRpcOk(body.id, result);
}
if (toolName === "quotes.get") {
if (!args.id) throw new Error("Missing id for quotes.get.");
const quote = await pb.collection("quotes").getOne(args.id, {
expand: args.expand,
});
return jsonRpcOk(body.id, { quote });
}
if (toolName === "quotes.update") {
if (!args.id) throw new Error("Missing id for quotes.update.");
if (!args.patch || typeof args.patch !== "object" || Array.isArray(args.patch)) {
throw new Error("Missing or invalid `patch` for quotes.update.");
}
const rawPatch = args.patch as Record<string, unknown>;
const patch: Record<string, unknown> = {};
const rejected: string[] = [];
for (const [k, v] of Object.entries(rawPatch)) {
if ((QUOTE_PATCH_FIELDS as readonly string[]).includes(k)) {
patch[k] = v;
} else {
rejected.push(k);
}
}
if (rejected.length > 0) {
throw new Error(
`forbidden_field(s): ${rejected.join(", ")}. Allowed: ${QUOTE_PATCH_FIELDS.join(", ")}.`,
);
}
if (Object.keys(patch).length === 0) {
throw new Error("Patch is empty after whitelist.");
}
const updated = await pb.collection("quotes").update(args.id, patch);
// Optional one-shot re-render: if the caller passed
// `regenerate_pdf: true` alongside the patch, fire the same
// PDF-render code path that the WebUI's button uses, so a
// letterhead change (or any other header edit) lands on the
// new PDF in one round-trip. The PDF endpoint reads the
// freshly-persisted letterhead, so the rendered file always
// matches the record.
let pdf: { attachmentId: string; status: string; pdfGeneratedAt: string | null; filename: string; skippedItemIds: string[]; readyItemCount: number; totalItemCount: number; isPartial: boolean } | null = null;
if (args.regenerate_pdf === true) {
const letterhead = (updated.company_letterhead as string) || "CNL";
const pdfRes = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${args.id}/pdf`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify({ letterhead }),
},
);
if (!pdfRes.ok) {
const text = await pdfRes.text();
throw new Error(
`quotes.update patch saved but PDF re-render failed: ${pdfRes.status} ${text}`,
);
}
pdf = (await pdfRes.json()) as typeof pdf & { ok: true };
// The route returns the success shape without an `ok: true`
// discriminator. Normalise it: every successful render has
// `attachmentId` + `filename`. If those are missing we treat
// it as a failure.
if (!pdf || !(pdf as { attachmentId?: unknown }).attachmentId) {
throw new Error("PDF re-render returned an unexpected shape");
}
}
return jsonRpcOk(body.id, { quote: updated, pdf });
}
if (toolName === "quotes.send_email") {
if (!args.id) throw new Error("Missing id for quotes.send_email.");
const payload: SendQuoteEmailPayload = {
to: typeof args.to === "string" ? args.to : undefined,
cc: typeof args.cc === "string" ? args.cc : "",
bcc: typeof args.bcc === "string" ? args.bcc : "",
subject: typeof args.subject === "string" ? args.subject : "",
message: typeof args.message === "string" ? args.message : "",
attachAuthLetters: Boolean(args.attachAuthLetters),
selectedAttachmentIds: Array.isArray(args.selectedAttachmentIds)
? args.selectedAttachmentIds.filter((x: unknown): x is string => typeof x === "string")
: [],
};
const result = await sendQuoteEmail(args.id, payload);
return jsonRpcOk(body.id, result);
}
if (toolName === "quotes.retry_email") {
if (!args.id) throw new Error("Missing id for quotes.retry_email.");
const result = await retryQuoteEmail(args.id);
return jsonRpcOk(body.id, result);
}
if (toolName === "quotes.clone") {
if (!args.id) throw new Error("Missing id for quotes.clone.");
const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/${args.id}/clone`, {
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.clone upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "quotes.create_from_template") {
if (!args.template_id) {
throw new Error("Missing template_id for quotes.create_from_template.");
}
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${args.template_id}/create-from-template`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.create_from_template upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "quotes.save_as_template") {
if (!args.id) throw new Error("Missing id for quotes.save_as_template.");
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${args.id}/save-as-template`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify({ name: args.name }),
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.save_as_template upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "quotes.split") {
if (!args.source_quote_id) {
throw new Error("Missing source_quote_id for quotes.split.");
}
const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/${args.source_quote_id}/split`, {
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify({ groups: Array.isArray(args.groups) ? args.groups : [] }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.split upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "quotes.partial_approve") {
if (!args.quote_id) {
throw new Error("Missing quote_id for quotes.partial_approve.");
}
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${args.quote_id}/partial-approve`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify({ decisions: Array.isArray(args.decisions) ? args.decisions : [] }),
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.partial_approve upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "quotes.submit") {
if (!args.id) {
throw new Error("Missing id for quotes.submit.");
}
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${args.id}/submit`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify(args.actor ? { actor: args.actor } : {}),
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.submit upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "quotes.re_price") {
if (!args.id) throw new Error("Missing id for quotes.re_price.");
if (!Array.isArray(args.items)) {
throw new Error("Missing items array for quotes.re_price.");
}
const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/${args.id}/re-price`, {
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify({
items: args.items,
dry_run: Boolean(args.dry_run),
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.re_price upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "quotes.next_number") {
const { generateQuoteNumberServer } = await import("@/lib/server/quotes-service");
const quote_number = await generateQuoteNumberServer();
return jsonRpcOk(body.id, { quote_number });
}
if (toolName === "quotes.lookup") {
const params = new URLSearchParams();
if (args.q) params.set("q", String(args.q));
if (args.page) params.set("page", String(args.page));
if (args.limit) params.set("limit", String(args.limit));
const res = await fetch(`${req.nextUrl.origin}/api/v1/quotes/lookup?${params}`, {
headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` },
});
if (!res.ok) throw new Error(`quotes.lookup upstream ${res.status}`);
const json = (await res.json()) as unknown;
return jsonRpcOk(body.id, json);
}
if (toolName === "price_list.add_items") {
// Bulk-upsert parsed price-list line items. The agent reads the
// source document (supplier PDF, quote PDF, pasted email) with
// its native vision capability and passes already-parsed fields
// in. We do the dedupe + write here so the MCP server has no
// LLM work — the OCR is the agent's job, the commit is ours.
if (!Array.isArray(args.items) || args.items.length === 0) {
throw new Error(
"Missing or empty items array for price_list.add_items.",
);
}
const { addPriceListItems } = await import(
"@/lib/server/price-list-service"
);
const items = (
args.items as Array<Record<string, unknown>>
).map((raw) => ({
sku: typeof raw.sku === "string" ? raw.sku : "",
description: typeof raw.description === "string" ? raw.description : "",
cost_price:
typeof raw.cost_price === "number" ? raw.cost_price : Number.NaN,
selling_price:
typeof raw.selling_price === "number"
? raw.selling_price
: undefined,
markup:
typeof raw.markup === "number" ? raw.markup : undefined,
unit: typeof raw.unit === "string" ? raw.unit : undefined,
category:
typeof raw.category === "string" ? raw.category : undefined,
subcategory:
typeof raw.subcategory === "string"
? raw.subcategory
: undefined,
brand: typeof raw.brand === "string" ? raw.brand : undefined,
stock_qty:
typeof raw.stock_qty === "number" ? raw.stock_qty : undefined,
supplier_name:
typeof raw.supplier_name === "string" ? raw.supplier_name : "",
}));
const result = await addPriceListItems({
items,
source_file_id:
typeof args.source_file_id === "string"
? args.source_file_id
: undefined,
source_label:
typeof args.source_label === "string"
? args.source_label
: undefined,
});
if (!result.ok) {
return jsonRpcOk(body.id, result, 400);
}
return jsonRpcOk(body.id, {
ok: true,
added: result.added,
updated: result.updated,
ids: result.ids,
skipped: result.skipped,
});
}
// ─── Purchase orders (extra verbs) ──────────────────────────
if (toolName === "purchase_orders.list") {
const page = Number(args.page || 1);
const perPage = Math.min(500, Number(args.perPage || 50));
const result = await pb.collection("purchase_orders").getList(page, perPage, {
filter: args.filter || "",
sort: args.sort || "-created",
expand: args.expand,
});
return jsonRpcOk(body.id, result);
}
if (toolName === "purchase_orders.get") {
if (!args.id) throw new Error("Missing id for purchase_orders.get.");
const purchase_order = await pb.collection("purchase_orders").getOne(args.id, {
expand: args.expand,
});
return jsonRpcOk(body.id, { purchase_order });
}
if (toolName === "purchase_orders.send_email") {
if (!args.id) throw new Error("Missing id for purchase_orders.send_email.");
const payload: SendPurchaseOrderEmailPayload = {
to: typeof args.to === "string" ? args.to : undefined,
cc: typeof args.cc === "string" ? args.cc : "",
bcc: typeof args.bcc === "string" ? args.bcc : "",
subject: typeof args.subject === "string" ? args.subject : "",
message: typeof args.message === "string" ? args.message : "",
};
const result = await sendPurchaseOrderEmail(args.id, payload);
return jsonRpcOk(body.id, result);
}
if (toolName === "purchase_orders.retry_email") {
if (!args.id) throw new Error("Missing id for purchase_orders.retry_email.");
const result = await retryPurchaseOrderEmail(args.id);
return jsonRpcOk(body.id, result);
}
if (toolName === "purchase_orders.stale") {
const params = new URLSearchParams();
if (args.olderThan) params.set("olderThan", String(args.olderThan));
if (args.limit) params.set("limit", String(args.limit));
const res = await fetch(
`${req.nextUrl.origin}/api/v1/purchase-orders/stale?${params}`,
{ headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` } },
);
if (!res.ok) throw new Error(`purchase_orders.stale upstream ${res.status}`);
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "purchase_orders.record_send_outcome") {
const { recordPurchaseOrderSendOutcome } = await import(
"@/lib/server/purchase-orders-service"
);
if (!args.po_id) {
throw new Error("Missing po_id for purchase_orders.record_send_outcome.");
}
const result = await recordPurchaseOrderSendOutcome({
poId: args.po_id,
outcome: args.outcome === "success" ? "success" : "failure",
messageId: typeof args.message_id === "string" ? args.message_id : undefined,
errorMessage: typeof args.error === "string" ? args.error : undefined,
});
return jsonRpcOk(body.id, { purchaseOrder: result });
}
// ─── Stock items ──────────────────────────────────────────────
if (toolName === "stock_items.list") {
const page = Number(args.page || 1);
const perPage = Math.min(500, Number(args.perPage || 50));
const search = typeof args.search === "string" ? args.search.trim() : "";
const lowStock = args.low_stock === true;
const filters: string[] = [];
if (search) {
const safe = search.replace(/"/g, '\\"');
filters.push(
`(name ~ "${safe}" || sku ~ "${safe}" || description ~ "${safe}")`,
);
}
if (lowStock) {
filters.push("(current_qty <= min_qty && is_active = true)");
}
const filter = filters.join(" && ");
const result = await pb.collection("stock_items").getList(page, perPage, {
filter,
sort: args.sort || "-updated",
});
// Best-effort expand of supplier when requested. We can't pass
// expand to getList on the bare collection (the PB client only
// takes expand on getOne / getList<RecordModel>), so we do a
// second pass for the visible page. Cheap for typical page
// sizes (50 rows).
const wantSupplierExpand = typeof args.expand === "string" && args.expand.includes("supplier");
if (wantSupplierExpand) {
const supplierIds = new Set(
(result.items as Array<{ supplier?: string }>)
.map((it) => it.supplier)
.filter((id): id is string => Boolean(id)),
);
const suppliers = supplierIds.size
? await pb.collection("suppliers").getFullList({
filter: Array.from(supplierIds)
.map((id) => `id = "${id}"`)
.join(" || "),
fields: "id,suppliername",
})
: [];
const supplierMap = new Map(suppliers.map((s: { id: string }) => [s.id, s]));
const itemsWithExpand = (result.items as unknown as Array<{ supplier?: string }>).map((it) => ({
...it,
expand: { supplier: it.supplier ? supplierMap.get(it.supplier) : undefined },
}));
return jsonRpcOk(body.id, {
...result,
items: lowStock
? (itemsWithExpand as unknown[]).filter((it) => isLowStockItem(it as Pick<import("@/lib/types").StockItem, "current_qty" | "min_qty" | "is_active">))
: itemsWithExpand,
});
}
const items = lowStock
? (result.items as unknown[]).filter((it) => isLowStockItem(it as Pick<import("@/lib/types").StockItem, "current_qty" | "min_qty" | "is_active">))
: result.items;
return jsonRpcOk(body.id, { ...result, items });
}
if (toolName === "stock_items.get") {
if (!args.id) throw new Error("Missing id for stock_items.get.");
const includeLedger = args.ledger !== "false";
const item = await pb.collection("stock_items").getOne(args.id, {
expand: "supplier",
});
let ledger: { items: unknown[]; page: number; perPage: number; totalItems: number; totalPages: number } = {
items: [],
page: 1,
perPage: 50,
totalItems: 0,
totalPages: 0,
};
if (includeLedger) {
const page = Number(args.page || 1);
const perPage = Number(args.perPage || 50);
const ledgerResult = await pb.collection("stock_ledger").getList(page, perPage, {
filter: `stock_item = "${args.id}"`,
sort: "-created",
expand: "stock_item,created_by",
});
ledger = {
items: ledgerResult.items,
page: ledgerResult.page,
perPage: ledgerResult.perPage,
totalItems: ledgerResult.totalItems,
totalPages: ledgerResult.totalPages,
};
}
return jsonRpcOk(body.id, { item, ledger });
}
if (toolName === "stock_items.create") {
if (!args.name || typeof args.name !== "string" || !args.name.trim()) {
throw new Error("name is required for stock_items.create.");
}
const openingQty = Number(args.current_qty ?? 0);
const minQty = Number(args.min_qty ?? 0);
const item = await pb.collection("stock_items").create({
sku: typeof args.sku === "string" ? args.sku.trim() : "",
name: args.name.trim(),
description: typeof args.description === "string" ? args.description : "",
unit: typeof args.unit === "string" ? args.unit : "",
location: typeof args.location === "string" ? args.location : "",
current_qty: openingQty,
min_qty: minQty,
target_qty: Number(args.target_qty ?? 0),
supplier: typeof args.supplier === "string" ? args.supplier : "",
is_active: true,
notes: typeof args.notes === "string" ? args.notes : "",
});
if (openingQty > 0) {
try {
await pb.collection("stock_ledger").create({
stock_item: item.id,
change_qty: openingQty,
reason: "adjustment",
reference_type: "manual",
reference_id: "",
notes: "Opening balance at stock item creation (via MCP).",
created_by: "",
});
} catch (ledgerErr) {
console.error("[mcp:stock_items.create] opening-balance ledger failed", ledgerErr);
}
}
return jsonRpcOk(body.id, item);
}
if (toolName === "stock_items.adjust") {
if (!args.id) throw new Error("Missing id for stock_items.adjust.");
const changeQty = Number(args.change_qty);
if (!Number.isFinite(changeQty) || changeQty === 0) {
throw new Error("change_qty must be a non-zero number.");
}
const reason = String(args.reason || "");
if (!(STOCK_REASON_VALUES as readonly string[]).includes(reason)) {
throw new Error(
`reason must be one of: ${STOCK_REASON_VALUES.join(", ")}`,
);
}
const signed = Math.trunc(changeQty);
const ledger = await pb.collection("stock_ledger").create({
stock_item: args.id,
change_qty: signed,
reason,
reference_type: typeof args.reference_type === "string" ? args.reference_type : "manual",
reference_id: typeof args.reference_id === "string" ? args.reference_id : "",
notes: typeof args.notes === "string" ? args.notes : "",
created_by: "",
});
const current = await pb.collection("stock_items").getOne(args.id);
const nextQty = Number(current.current_qty ?? 0) + signed;
const item = await pb.collection("stock_items").update(args.id, {
current_qty: nextQty,
});
return jsonRpcOk(body.id, { item, ledger });
}
// ─── Stock purchases ──────────────────────────────────────────
if (toolName === "stock_purchases.list") {
const { listStockPurchases, STOCK_PURCHASE_STATUS_VALUES } = await import(
"@/lib/server/stock-purchases-service"
);
const page = Math.max(1, Number(args.page || 1));
const perPage = Math.min(200, Math.max(1, Number(args.perPage || 50)));
const statusParam = String(args.status || "");
const status = (STOCK_PURCHASE_STATUS_VALUES as readonly string[]).includes(statusParam)
? (statusParam as StockPurchaseStatus | "")
: "";
const result = await listStockPurchases(pb, { status, page, perPage });
return jsonRpcOk(body.id, result);
}
if (toolName === "purchase_orders.scan") {
// The agent's "what needs my attention?" triage call. Lives
// next to `stock_purchases.list` so the operator reading the
// dispatch chain can see the cluster. Both kinds are fetched
// in parallel inside the service; the route just shapes the
// arguments and returns the result envelope.
const kindRaw = String(args.kind ?? "both");
const kind =
kindRaw === "stock" || kindRaw === "customer" || kindRaw === "both"
? kindRaw
: "both";
const status = Array.isArray(args.status)
? (args.status as unknown[]).map((s) => String(s)).filter(Boolean)
: undefined;
const maxRowsPerKind =
typeof args.maxRowsPerKind === "number" && args.maxRowsPerKind > 0
? Math.min(500, Math.floor(args.maxRowsPerKind))
: undefined;
const result = await scanPurchaseOrders(pb, {
kind,
status,
maxRowsPerKind,
});
return jsonRpcOk(body.id, result);
}
if (toolName === "purchase_orders.diff") {
// Read-only price/SKU/qty diff. Internally calls
// `matchAndReceiveFromPoDocument({...args, confirm: false})`
// and unwraps the result — same MatchReport shape the
// existing `stock_purchases.match_and_receive_from_invoice`
// tool returns on its dry-run branch. We re-wrap with
// `ok: true, mode: 'dry_run'` for the agent so the verdict
// + per-line comparisons land in one obvious place.
const poReference = String(args.po_reference ?? "");
const documentRaw = args.document ?? args.invoice;
if (!poReference || !documentRaw || typeof documentRaw !== "object") {
return jsonRpcOk(
body.id,
{
ok: false,
reason: "po_not_found",
message:
"Missing po_reference or document for purchase_orders.diff",
},
400,
);
}
const document = documentRaw as Record<string, unknown>;
const mode = args.mode === "grn" ? "grn" : "invoice";
const result = await matchAndReceiveFromPoDocument({
po_reference: poReference,
document: {
document_no: String(document.document_no ?? ""),
date: String(document.date ?? ""),
supplier_name:
typeof document.supplier_name === "string"
? document.supplier_name
: undefined,
lines: Array.isArray(document.lines)
? (document.lines as Array<Record<string, unknown>>).map((l) => ({
code: typeof l.code === "string" ? l.code : undefined,
description: String(l.description ?? ""),
quantity: Number(l.quantity ?? 0),
unit_price: Number(l.unit_price ?? 0),
line_total:
typeof l.line_total === "number"
? l.line_total
: undefined,
}))
: [],
},
confirm: false,
mode,
});
// Pass-through: the dry-run envelope already has the shape
// the agent needs. We don't synthesise a new envelope —
// that's the whole point of this wrapper, no transformation.
let status = 200;
if (!result.ok) {
status =
result.reason === "po_not_found"
? 404
: result.reason === "wrong_status"
? 409
: 422;
}
return jsonRpcOk(body.id, result, status);
}
if (toolName === "purchase_orders.record_diff_report") {
// Persist the diff payload onto the PO's existing `notes`
// field as a structured JSON entry. No new schema field —
// the notes feed already supports category-tagged entries
// (see `appendNote` in src/app/(app)/collection-confirm/
// view-model.ts). Adding a new category `price_diff_report`
// is a forward-compatible extension.
//
// Resolves po_reference → (kind, id) by the same priority
// the matcher uses: stock_purchases first, then
// purchase_orders. Mirrors the lookup the matcher does
// internally so the agent can pass the same PO reference
// it got from `purchase_orders.diff`.
const poReference = String(args.po_reference ?? "");
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,
{
ok: false,
reason: "invalid_args",
message:
"Missing po_reference or report (must be a JSON object) for purchase_orders.record_diff_report.",
},
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.
const { normalizePoReference } = await import(
"@/lib/server/stock-purchase-invoice-matching-service"
);
const normalised = normalizePoReference(poReference);
// Try stock_purchases first.
let resolvedKind: "stock" | "customer" | null = null;
let resolvedId: string | null = null;
try {
const stockResult = await pb.collection("stock_purchases").getList(1, 1, {
filter: `purchase_number = "${normalised.replace(/"/g, '\\"')}"`,
fields: "id,purchase_number,notes",
});
const stock = stockResult.items?.[0];
if (stock?.id) {
resolvedKind = "stock";
resolvedId = String(stock.id);
}
} catch {
// Not in stock — fall through to customer lookup.
}
if (!resolvedId) {
try {
const customerResult = await pb.collection("purchase_orders").getList(1, 1, {
filter: `po_number = "${normalised.replace(/"/g, '\\"')}"`,
fields: "id,po_number,notes",
});
const customer = customerResult.items?.[0];
if (customer?.id) {
resolvedKind = "customer";
resolvedId = String(customer.id);
}
} catch {
// Not found either — caller will get a 404.
}
}
if (!resolvedId || !resolvedKind) {
return jsonRpcOk(
body.id,
{
ok: false,
reason: "po_not_found",
message: `No stock_purchase or purchase_order matches po_reference ${poReference}`,
},
404,
);
}
// Read the existing notes feed (JSON array of entries).
const collectionName =
resolvedKind === "stock" ? "stock_purchases" : "purchase_orders";
const current = await pb.collection(collectionName).getOne(resolvedId, {
fields: "notes",
});
const existingRaw =
typeof current.notes === "string" && current.notes.trim().length > 0
? current.notes
: "[]";
let existingEntries: unknown[] = [];
try {
const parsed = JSON.parse(existingRaw);
if (Array.isArray(parsed)) existingEntries = parsed;
} catch {
// Corrupt notes feed — start fresh so we don't blow away
// operator notes. The new entry is appended below.
existingEntries = [];
}
const entry = {
ts: new Date().toISOString(),
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
// union is open and existing parsers will skip unknown
// categories (see `NOTE_CATEGORIES` whitelist).
category: "price_diff_report",
body: {
summary,
po_reference: poReference,
kind: resolvedKind,
report: reportRaw,
},
};
const nextNotes = JSON.stringify([...existingEntries, entry]);
await pb.collection(collectionName).update(resolvedId, {
notes: nextNotes,
});
return jsonRpcOk(body.id, {
ok: true,
kind: resolvedKind,
po_id: resolvedId,
entry_index: existingEntries.length,
entry_count: existingEntries.length + 1,
});
}
if (toolName === "stock_purchases.get") {
if (!args.id) throw new Error("Missing id for stock_purchases.get.");
const { getStockPurchase, getStockPurchaseItems } = await import(
"@/lib/server/stock-purchases-service"
);
let purchase: unknown;
try {
purchase = await getStockPurchase(pb, args.id);
} catch {
return jsonRpcOk(body.id, { error: "Stock purchase not found" }, 404);
}
const items = await getStockPurchaseItems(pb, args.id);
return jsonRpcOk(body.id, { purchase, items });
}
if (toolName === "stock_purchases.create") {
if (!args.supplier) throw new Error("supplier is required for stock_purchases.create.");
if (!Array.isArray(args.items) || args.items.length === 0) {
throw new Error("items must be a non-empty array.");
}
const input: CreateStockPurchaseInput = {
supplier: args.supplier,
notes: typeof args.notes === "string" ? args.notes : "",
items: args.items.map((it: { stock_item?: string; description?: string; quantity?: number; cost_price?: number; notes?: string }) => ({
stock_item: it.stock_item ?? "",
description: it.description ?? "",
quantity: Number(it.quantity ?? 0),
cost_price: typeof it.cost_price === "number" ? it.cost_price : undefined,
notes: typeof it.notes === "string" ? it.notes : undefined,
})),
};
const result = await createStockPurchase(pb, input);
if (!result.ok) {
return jsonRpcOk(body.id, { ok: false, reason: result.reason, message: result.message }, 400);
}
return jsonRpcOk(body.id, { purchase: result.purchase, items: result.items });
}
if (toolName === "stock_purchases.send") {
if (!args.id) throw new Error("Missing id for stock_purchases.send.");
const result = await markStockPurchaseSent(pb, args.id);
if (!result.ok) {
return jsonRpcOk(body.id, { ok: false, reason: result.reason, message: result.message }, 409);
}
return jsonRpcOk(body.id, { purchase: result.purchase });
}
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,
});
if (!result.ok) {
return jsonRpcOk(body.id, { ok: false, reason: result.reason, message: result.message, failed_line_id: result.failed_line_id }, 409);
}
// Round-trip the same shape as the API route: after the
// status flip + qty accumulation, commit the inventory
// through the canonical writer (`bookStockFromInvoice`)
// so the ledger / cache only get touched once per PO.
const bookResult = await bookStockFromInvoice({
id: args.id,
actor,
notes: `Booked by ${actor.name} via stock_purchases.receive`,
});
if (!bookResult.ok && bookResult.reason !== "no_lines") {
return jsonRpcOk(
body.id,
{
ok: false,
reason: bookResult.reason,
message: `Receive succeeded but booking failed: ${bookResult.message}`,
purchase: result.purchase,
lines: result.lines,
},
409,
);
}
return jsonRpcOk(body.id, {
purchase: result.purchase,
lines: result.lines,
booking: bookResult.ok
? {
booked_at: bookResult.purchase.bookedAt,
booked_by: bookResult.purchase.bookedBy,
booked_lines: bookResult.lines.length,
}
: { skipped: true, reason: bookResult.reason },
});
}
if (toolName === "stock_purchases.send_email") {
if (!args.id) throw new Error("Missing id for stock_purchases.send_email.");
const result = await sendStockPurchaseEmail(args.id, {
to: typeof args.to === "string" ? args.to : undefined,
cc: typeof args.cc === "string" ? args.cc : "",
bcc: typeof args.bcc === "string" ? args.bcc : "",
subject: typeof args.subject === "string" ? args.subject : "",
message: typeof args.message === "string" ? args.message : "",
});
if (result.failure) {
return jsonRpcOk(body.id, result, 422);
}
return jsonRpcOk(body.id, result);
}
if (toolName === "stock_purchases.retry_email") {
if (!args.id) throw new Error("Missing id for stock_purchases.retry_email.");
const result = await retryStockPurchaseEmail(args.id);
if (result.failure) {
return jsonRpcOk(body.id, result, 422);
}
return jsonRpcOk(body.id, result);
}
if (toolName === "stock_purchases.add_attachment") {
if (!args.purchase_id || !args.filename || !args.base64) {
throw new Error(
"Missing purchase_id, filename, or base64 for stock_purchases.add_attachment",
);
}
const { addStockPurchaseAttachment } = await import(
"@/lib/server/stock-purchases-service"
);
const bytes = Buffer.from(String(args.base64), "base64");
const file = new File([new Uint8Array(bytes)], String(args.filename), {
type:
typeof args.content_type === "string"
? args.content_type
: "application/octet-stream",
});
const result = await addStockPurchaseAttachment({
purchaseId: String(args.purchase_id),
file,
});
return NextResponse.json({
jsonrpc: "2.0",
id: body.id ?? null,
result: {
content: [
{
type: "text",
text: JSON.stringify({
ok: true,
attachments: result.attachments,
added: result.added,
}),
},
],
},
});
}
if (toolName === "stock_purchases.match_and_receive_from_invoice") {
// Buyer-side invoice / delivery-note reconciliation. The agent
// passes already-parsed document fields (it does the OCR with
// its native vision capability); the tool does the matching
// deterministically against PB and either dry-runs or commits
// (attach + receive) in one round-trip. Auto-detects stock
// purchase vs customer PO. `mode: "grn"` skips price checks
// and stamps a TODO note per line.
const poReference = typeof args.po_reference === "string"
? args.po_reference
: "";
const documentRaw = args.document ?? args.invoice; // back-compat with the old `invoice` arg
if (!poReference || !documentRaw || typeof documentRaw !== "object") {
return jsonRpcOk(
body.id,
{
ok: false,
reason: "po_not_found",
message:
"Missing po_reference or document for stock_purchases.match_and_receive_from_invoice",
},
400,
);
}
const document = documentRaw as Record<string, unknown>;
const attachmentRaw = args.attachment;
const attachment =
attachmentRaw && typeof attachmentRaw === "object"
? {
filename: String(
(attachmentRaw as Record<string, unknown>).filename ?? "",
),
content_type: String(
(attachmentRaw as Record<string, unknown>).content_type ?? "",
),
base64: String(
(attachmentRaw as Record<string, unknown>).base64 ?? "",
),
}
: undefined;
const confirm = args.confirm === true;
const mode = args.mode === "grn" ? "grn" : "invoice";
const result = await matchAndReceiveFromPoDocument({
po_reference: poReference,
document: {
document_no: String(document.document_no ?? ""),
date: String(document.date ?? ""),
supplier_name:
typeof document.supplier_name === "string"
? document.supplier_name
: undefined,
lines: Array.isArray(document.lines)
? (document.lines as Array<Record<string, unknown>>).map((l) => ({
code:
typeof l.code === "string" ? l.code : undefined,
description: String(l.description ?? ""),
quantity: Number(l.quantity ?? 0),
unit_price: Number(l.unit_price ?? 0),
line_total:
typeof l.line_total === "number"
? l.line_total
: undefined,
}))
: [],
},
attachment:
attachment && attachment.filename && attachment.base64
? attachment
: undefined,
confirm,
mode,
partial: args.partial === true,
});
let status = 200;
if (!result.ok) {
status =
result.reason === "po_not_found"
? 404
: result.reason === "wrong_status"
? 409
: 422;
} else if (result.mode === "committed") {
status = 200;
}
return jsonRpcOk(body.id, result, status);
}
if (toolName === "stock_purchases.next_number") {
const purchase_number = await generateStockPurchaseNumber(pb);
return jsonRpcOk(body.id, { purchase_number });
}
// ─── Stock purchase items (per-line writes) ─────────────────
if (toolName === "stock_purchase_items.update") {
if (!args.id || !args.patch) {
throw new Error(
"Missing id or patch for stock_purchase_items.update.",
);
}
const { updateStockPurchaseItem } = await import(
"@/lib/server/stock-purchase-items-service"
);
try {
const result = await updateStockPurchaseItem({
id: String(args.id),
patch: args.patch as Record<string, unknown>,
force_overwrite: args.force_overwrite === true,
});
return jsonRpcOk(body.id, {
item: result.item,
previous: result.previous,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const reason = message.split(":")[0]?.trim() || "error";
const status =
reason === "already_priced" ? 409 : reason === "forbidden_field" ? 400 : 422;
return jsonRpcOk(
body.id,
{ ok: false, reason, message },
status,
);
}
}
if (toolName === "stock_purchase_items.list") {
// Mirror of `order_items.list` for the stock-purchase side.
// The agent reaches for this when it needs to scan one PO's
// lines (e.g. after a diff to find rows whose SKU was empty
// and needs filling from the invoice). `expand` accepts any
// PB expand expression, defaulting to `stock_item` because
// every UI surface that joins the catalogue expects the SKU
// inline. Pagination matches `order_items.list`: 25 rows
// per page unless overridden, sorted `-created` so newest
// activity surfaces first.
const page = Number(args.page || 1);
const perPage = Number(args.perPage || 25);
const filter = typeof args.filter === "string" ? args.filter : "";
const sort = typeof args.sort === "string" ? args.sort : "-created";
const expand =
typeof args.expand === "string" ? args.expand : "stock_item";
const result = await pb
.collection("stock_purchase_items")
.getList(page, perPage, { filter, sort, expand });
return jsonRpcOk(body.id, result);
}
if (toolName === "stock_purchase_items.bulk_update") {
if (!Array.isArray(args.items) || args.items.length === 0) {
throw new Error(
"items array (non-empty) is required for stock_purchase_items.bulk_update.",
);
}
const { bulkUpdateStockPurchaseItems } = await import(
"@/lib/server/stock-purchase-items-service"
);
const result = await bulkUpdateStockPurchaseItems({
items: args.items as Parameters<
typeof bulkUpdateStockPurchaseItems
>[0]["items"],
dry_run: args.dry_run === true,
});
return jsonRpcOk(body.id, result);
}
if (toolName === "stock_purchase_items.assign_driver") {
if (!args.id || !args.drv_id) {
throw new Error(
"Missing id or drv_id for stock_purchase_items.assign_driver.",
);
}
const { assignDriver } = await import(
"@/lib/server/stock-purchase-items-service"
);
try {
const result = await assignDriver({
id: String(args.id),
drv_id: String(args.drv_id),
assigned_at:
typeof args.assigned_at === "string" ? args.assigned_at : undefined,
});
return jsonRpcOk(body.id, { item: result.item });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const reason = message.split(":")[0]?.trim() || "error";
const status = reason === "wrong_status" ? 409 : 422;
return jsonRpcOk(
body.id,
{ ok: false, reason, message },
status,
);
}
}
if (toolName === "stock_purchase_items.mark_collected") {
if (!args.id) {
throw new Error("Missing id for stock_purchase_items.mark_collected.");
}
const { markLineCollected } = await import(
"@/lib/server/stock-purchase-items-service"
);
try {
const result = await markLineCollected({
id: String(args.id),
quantity_collected:
typeof args.quantity_collected === "number"
? args.quantity_collected
: undefined,
});
return jsonRpcOk(body.id, result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const reason = message.split(":")[0]?.trim() || "error";
const status = reason === "wrong_status" ? 409 : 422;
return jsonRpcOk(
body.id,
{ ok: false, reason, message },
status,
);
}
}
if (toolName === "stock_items.update") {
if (!args.id || !args.patch) {
throw new Error("Missing id or patch for stock_items.update.");
}
const { updateStockItem } = await import(
"@/lib/server/stock-items-service"
);
const result = await updateStockItem({
id: String(args.id),
patch: args.patch as Parameters<typeof updateStockItem>[0]["patch"],
});
if (!result.ok) {
const status =
result.reason === "not_found"
? 404
: result.reason === "forbidden_field" ||
result.reason === "validation_error"
? 400
: 422;
return jsonRpcOk(body.id, result, status);
}
return jsonRpcOk(body.id, result);
}
// ─── Sprint + buyer tasks ──────────────────────────────────────
if (toolName === "sprint.list") {
const page = Number(args.page || 1);
const perPage = Math.min(500, Number(args.perPage || 50));
const result = await pb.collection("order_sprint_items").getList(page, perPage, {
filter: args.filter || "",
sort: args.sort || "-created",
expand: args.expand,
});
return jsonRpcOk(body.id, result);
}
if (toolName === "sprint.attachment_counts") {
const ids: string[] = Array.isArray(args.item_ids) ? args.item_ids : [];
const counts: Record<string, number> = {};
await Promise.all(
ids.map(async (id) => {
const r = await pb.collection("order_sprint_attachments").getList(1, 1, {
filter: `item = "${id}"`,
fields: "id",
});
counts[id] = r.totalItems;
}),
);
return jsonRpcOk(body.id, { counts });
}
if (toolName === "sprint.item_attachments") {
if (!args.id) throw new Error("Missing id for sprint.item_attachments.");
const result = await pb.collection("order_sprint_attachments").getList(1, 100, {
filter: `item = "${args.id}"`,
sort: "-created",
});
return jsonRpcOk(body.id, result);
}
if (toolName === "sprint.claim") {
if (!args.id) throw new Error("Missing id for sprint.claim.");
const claimedBy = typeof args.claimed_by === "string" ? args.claimed_by : "hermes";
const TTL_MS = 30 * 60 * 1000;
const now = new Date();
const expires = new Date(now.getTime() + TTL_MS).toISOString();
const filter = `id = "${args.id}" && (claim_status = "unclaimed" || claim_status = "" || (claim_status = "claimed" && claim_expires_at < "${now.toISOString()}"))`;
const probe = await pb.collection("order_sprint_items").getList(1, 1, { filter });
if (probe.items.length === 0) {
const current = await pb.collection("order_sprint_items").getOne(args.id);
return jsonRpcOk(body.id, {
ok: false,
error: "Already claimed",
current_claim: {
claimed_by: current.claimed_by,
claimed_at: current.claimed_at,
claim_expires_at: current.claim_expires_at,
},
}, 409);
}
const updated = await pb.collection("order_sprint_items").update(args.id, {
claim_status: "claimed",
claimed_by: claimedBy,
claimed_at: now.toISOString(),
claim_expires_at: expires,
});
return jsonRpcOk(body.id, { ok: true, item: updated });
}
if (toolName === "buyer_tasks.list") {
const tab = String(args.tab || "");
const groups = await listBuyerTasks(tab as Parameters<typeof listBuyerTasks>[0]);
return jsonRpcOk(body.id, { groups });
}
if (toolName === "buyer_tasks.generate_po") {
if (!args.orderId || !args.supplierId || !Array.isArray(args.items) || args.items.length === 0) {
throw new Error("orderId, supplierId, and items are required.");
}
const purchaseOrderId = await generatePoForGroup(
args.orderId,
args.supplierId,
args.items,
);
return jsonRpcOk(body.id, { purchaseOrderId });
}
if (toolName === "buyer_tasks.mark_ready") {
if (!args.orderId || !args.supplierId || !Array.isArray(args.items)) {
throw new Error("orderId, supplierId, and items are required.");
}
if (typeof args.note !== "string" || !args.note.trim()) {
throw new Error("note is required for buyer_tasks.mark_ready.");
}
const result = await markGroupReady(
args.orderId,
args.supplierId,
args.items,
args.note,
);
return jsonRpcOk(body.id, result);
}
// ─── Stock fulfillment ────────────────────────────────────────
// We pass through the /api/v1/stock/fulfill REST route because
// it has the right CREATE-vs-UPDATE mode detection and writes
// the ledger + cache in one round-trip. MCP just shapes the
// body.
if (toolName === "stock.fulfill") {
if (!args.order_item_id || !args.stock_item_id || typeof args.qty !== "number") {
throw new Error("order_item_id, stock_item_id, and qty are required.");
}
const body: Record<string, unknown> = {
order_item_id: args.order_item_id,
stock_item_id: args.stock_item_id,
qty: args.qty,
};
if (typeof args.order_id === "string") body.order_id = args.order_id;
if (typeof args.description === "string") body.description = args.description;
if (typeof args.po_description === "string") body.po_description = args.po_description;
if (typeof args.selling_price === "number") body.selling_price = args.selling_price;
if (typeof args.cost_price === "number") body.cost_price = args.cost_price;
if (args.intent === "collected" || args.intent === "delivered") {
body.intent = args.intent;
}
if (typeof args.notes === "string") body.notes = args.notes;
const res = await fetch(`${req.nextUrl.origin}/api/v1/stock/fulfill`, {
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`stock.fulfill upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "stock.surplus_book") {
if (!args.order_item_id || typeof args.surplus_qty !== "number") {
throw new Error("order_item_id and surplus_qty are required.");
}
const res = await fetch(`${req.nextUrl.origin}/api/v1/stock/surplus-book`, {
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify({
order_item_id: args.order_item_id,
surplus_qty: args.surplus_qty,
stock_item_id: typeof args.stock_item_id === "string" ? args.stock_item_id : undefined,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`stock.surplus_book upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
// ─── AI insights ──────────────────────────────────────────────
if (toolName === "ai_insights.rerun") {
if (args.target !== "quote" && args.target !== "order") {
throw new Error("target must be 'quote' or 'order'");
}
if (!args.id) throw new Error("id is required for ai_insights.rerun");
const envKey = process.env.AI_INSIGHTS_WORKFLOW_WEBHOOK_URL;
if (!envKey) {
return jsonRpcOk(body.id, {
error: "AI_INSIGHTS_WORKFLOW_WEBHOOK_URL is not configured",
}, 503);
}
const webhookRes = await fetch(envKey, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: args.target, id: args.id, source: "mcp" }),
signal: AbortSignal.timeout(5000),
});
if (!webhookRes.ok) {
return jsonRpcOk(body.id, { error: `n8n webhook returned ${webhookRes.status}` }, 502);
}
return jsonRpcOk(body.id, { ok: true, fired: true, target: args.target, id: args.id });
}
// ─── AI settings (operator-facing) ─────────────────────────────
// The HTTP route at /api/v1/ai/settings is the source of truth —
// these handlers are thin proxies so agents can read/write the
// config through the same JSON-RPC surface they already use.
// api_key never crosses the boundary here; the upstream route
// strips it before responding.
if (toolName === "ai_settings.get") {
const res = await fetch(`${req.nextUrl.origin}/api/v1/ai/settings`, {
headers: {
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
});
if (!res.ok) {
throw new Error(`ai_settings.get upstream ${res.status}: ${await res.text()}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "ai_settings.update") {
const patch: Record<string, unknown> = {};
if (typeof args.provider === "string") patch.provider = args.provider;
if (typeof args.api_key === "string") patch.api_key = args.api_key;
if (typeof args.base_url === "string") patch.base_url = args.base_url;
if (typeof args.model_id === "string") patch.model_id = args.model_id;
if (typeof args.max_tokens === "number") patch.max_tokens = args.max_tokens;
if (typeof args.enabled === "boolean") patch.enabled = args.enabled;
const res = await fetch(`${req.nextUrl.origin}/api/v1/ai/settings`, {
method: "PUT",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify(patch),
});
if (!res.ok) {
throw new Error(`ai_settings.update upstream ${res.status}: ${await res.text()}`);
}
return jsonRpcOk(body.id, await res.json());
}
// ─── Companies ────────────────────────────────────────────────
if (toolName === "companies.list") {
const page = Number(args.page || 1);
const perPage = Number(args.perPage || 25);
const filter = args.search
? `company ~ "${args.search}" || vendor ~ "${args.search}"`
: "";
const result = await pb.collection("companies").getList(page, perPage, {
filter,
sort: args.sort || "-created",
});
return jsonRpcOk(body.id, result);
}
if (toolName === "companies.get") {
if (!args.id) throw new Error("Missing id for companies.get.");
const company = await pb.collection("companies").getOne(args.id);
return jsonRpcOk(body.id, { company });
}
if (toolName === "companies.create") {
if (!args.company) throw new Error("Missing company name for companies.create.");
const company = await createCompany(pb, {
company: args.company,
address: args.address,
vendor: args.vendor,
coordinates: args.coordinates,
});
return jsonRpcOk(body.id, { company });
}
if (toolName === "companies.update") {
if (!args.id) throw new Error("Missing id for companies.update.");
if (!args.patch || typeof args.patch !== "object") {
throw new Error("Missing patch object for companies.update.");
}
const company = await updateCompany(pb, args.id, args.patch);
return jsonRpcOk(body.id, { company });
}
if (toolName === "companies.delete") {
if (!args.id) throw new Error("Missing id for companies.delete.");
await deleteCompany(pb, args.id);
return jsonRpcOk(body.id, { deleted: args.id });
}
// ─── Customers ────────────────────────────────────────────────
if (toolName === "customers.list") {
const page = Number(args.page || 1);
const perPage = Number(args.perPage || 25);
const filter = args.search
? `customer ~ "${args.search}" || email ~ "${args.search}" || number ~ "${args.search}"`
: "";
const result = await pb.collection("customers").getList(page, perPage, {
filter,
sort: args.sort || "-created",
expand: args.expand,
});
return jsonRpcOk(body.id, result);
}
if (toolName === "customers.get") {
if (!args.id) throw new Error("Missing id for customers.get.");
const customer = await pb.collection("customers").getOne(args.id, {
expand: args.expand,
});
return jsonRpcOk(body.id, { customer });
}
if (toolName === "customers.create") {
if (!args.customer) throw new Error("Missing customer name for customers.create.");
const customer = await createCustomer(pb, {
customer: args.customer,
email: args.email,
alternative_email: args.alternative_email,
buyer_email: args.buyer_email,
number: args.number,
company: args.company,
rep: args.rep,
coordinates: args.coordinates,
description: args.description,
});
return jsonRpcOk(body.id, { customer });
}
if (toolName === "customers.update") {
if (!args.id) throw new Error("Missing id for customers.update.");
if (!args.patch || typeof args.patch !== "object") {
throw new Error("Missing patch object for customers.update.");
}
const customer = await updateCustomer(pb, args.id, args.patch);
return jsonRpcOk(body.id, { customer });
}
if (toolName === "customers.delete") {
if (!args.id) throw new Error("Missing id for customers.delete.");
await deleteCustomer(pb, args.id);
return jsonRpcOk(body.id, { deleted: args.id });
}
// ─── Suppliers ────────────────────────────────────────────────
if (toolName === "suppliers.list") {
const page = Number(args.page || 1);
const perPage = Number(args.perPage || 25);
const filter = args.search
? `suppliername ~ "${args.search}" || email ~ "${args.search}" || suppliernumber ~ "${args.search}" || supply_tags ~ "${args.search}"`
: "";
const result = await pb.collection("suppliers").getList(page, perPage, {
filter,
sort: args.sort || "-created",
});
return jsonRpcOk(body.id, result);
}
if (toolName === "suppliers.get") {
if (!args.id) throw new Error("Missing id for suppliers.get.");
const supplier = await pb.collection("suppliers").getOne(args.id);
return jsonRpcOk(body.id, { supplier });
}
if (toolName === "suppliers.create") {
if (!args.suppliername) throw new Error("Missing suppliername for suppliers.create.");
const supplier = await createSupplier(pb, {
suppliername: args.suppliername,
email: args.email,
suppliernumber: args.suppliernumber,
supplieraddress: args.supplieraddress,
supply_tags: args.supply_tags,
coordinates: args.coordinates,
closingtime: args.closingtime,
});
return jsonRpcOk(body.id, { supplier });
}
if (toolName === "suppliers.update") {
if (!args.id) throw new Error("Missing id for suppliers.update.");
if (!args.patch || typeof args.patch !== "object") {
throw new Error("Missing patch object for suppliers.update.");
}
const supplier = await updateSupplier(pb, args.id, args.patch);
return jsonRpcOk(body.id, { supplier });
}
if (toolName === "suppliers.delete") {
if (!args.id) throw new Error("Missing id for suppliers.delete.");
await deleteSupplier(pb, args.id);
return jsonRpcOk(body.id, { deleted: args.id });
}
// tools.discovery — flat tool catalog (no JSON-RPC envelope inside
// the text payload). Mirrors `GET /api/mcp` for clients that can
// only build a JSON-RPC request. Returns the same `tools` array as
// `tools/list` — handy when an agent wants a self-describing
// summary without paying the schema-drift tax of two surfaces.
if (toolName === "tools.discovery") {
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}`);
}
return NextResponse.json(
{ jsonrpc: "2.0", id: body?.id ?? null, error: { code: -32601, message: "Method not found" } },
{ status: 404 }
);
} catch (error) {
const message = error instanceof Error ? error.message : "MCP request failed";
// Honour tagged HTTP status (e.g. assertAgentAuth sets status: 401).
// Falls back to 500 for untagged internal errors. JSON-RPC code -32001
// is reserved for auth failures so clients can distinguish them from
// generic server errors (-32000).
const status = (error as Error & { status?: number }).status ?? 500;
const code = status === 401 ? -32001 : -32000;
return NextResponse.json(
{ jsonrpc: "2.0", id: null, error: { code, message } },
{ status }
);
}
}
/**
* CORS preflight — Hermes (and any browser-embedded MCP client) sends
* an OPTIONS request before the POST. Without this handler the route
* rejects the preflight and the JSON-RPC call never fires.
*/
export async function OPTIONS(req: NextRequest) {
return withCors(new NextResponse(null, { status: 204 }), req);
}
/**
* GET /api/mcp
*
* Cheap discovery endpoint for agents that prefer a non-JSON-RPC
* shape. Equivalent to `{"method":"tools/list"}` over POST but
* available as a simple GET so n8n's HTTP Request node and Hermes's
* "fetch the tool catalog" flow can read it without building a
* JSON-RPC envelope.
*
* Response: { tools: Tool[] } (same `Tool` shape as `tools/list`).
*/
export async function GET(req: NextRequest) {
try {
assertAgentAuth(req);
return withCors(
NextResponse.json({ tools }),
req,
);
} catch (error) {
const message = error instanceof Error ? error.message : "MCP request failed";
const status = (error as Error & { status?: number }).status ?? 500;
return withCors(
NextResponse.json({ error: message }, { status }),
req,
);
}
}