Closes architectural gap #1 from the Giellie skill (the operator's AI agent could ingest quotes via /api/mcp but had no way to enumerate, add, or patch the resulting line items - every auto-price flow had to stop and ask a human for the item IDs). Adds: - GET /api/v1/quote_items (list w/ filter, sort, expand) - POST /api/v1/quote_items (create on existing quote) - GET /api/v1/quote_items/[id] (fetch one) - PATCH /api/v1/quote_items/[id] (whitelisted patch) - quote_items.list MCP tool - quote_items.get MCP tool - quote_items.create MCP tool - quote_items.update MCP tool Patch whitelist on update is identical to bulk_update_quote_items: cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited Everything else (id, quote, created, updated, quantity, is_processed) returns 400 / MCP error with the allow-list echoed back so the agent can self-correct. quote_items.update does NOT enforce the already_priced skip - that's the safety-net for bulk_update_quote_items. Single-row patches are explicit writes. Newly created rows have is_processed=false forced server-side; Giellie still cannot flip the flag (architectural gap #2 unchanged - that's a UI gate by design). Also: removed "JEI" from quotes.company_letterhead select values on the live PB - valid values are now ["CNL", "ISHERWOOD"]. Use the uppercase form when setting this field. Giellie skill doc refreshed: tool count 69 to 73, new "quote_items CRUD" subsection, three new REST rows, architectural gap #1 marked RESOLVED, hard rule #1 updated to cover the new endpoint, new worked example for "add a missing line item + price it", change-log entry. Tests: 82 passing across the three touched files (was 63 before). Type-check: zero new errors (pre-existing 29 in orders-service-review.test.ts unchanged). Deployment: pending operator rebuild + redeploy. Until then POST /api/v1/quote_items returns 404 and the new tools don't appear in tools/list. Co-authored-by: Orca <help@stably.ai>
2807 lines
108 KiB
TypeScript
2807 lines
108 KiB
TypeScript
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 { createOrderFromQuote, getOrderItemsCollectionName, listCollectionPool } from "@/lib/server/orders-service";
|
||
import {
|
||
appendReplyToQuote,
|
||
createQuoteFromEmail,
|
||
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 { generatePoForGroup, markGroupReady, listBuyerTasks } from "@/lib/server/buyer-tasks-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) that the AI auto-fill needs.
|
||
const QUOTE_ITEM_PATCH_FIELDS = [
|
||
"cost_price",
|
||
"selling_price",
|
||
"description",
|
||
"sku",
|
||
"unit",
|
||
"supplier",
|
||
"markup",
|
||
"pricing_source",
|
||
"pricing_notes",
|
||
"matched_price_list_id",
|
||
"user_edited",
|
||
] 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, optionally filtered by a substring on order_number",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
page: { type: "number" },
|
||
perPage: { type: "number" },
|
||
search: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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 order items from an existing quote",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["quote_id"],
|
||
properties: {
|
||
quote_id: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "collection_pool.list",
|
||
description: "List collection_pool records by pool status",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
page: { type: "number" },
|
||
perPage: { type: "number" },
|
||
status: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "order_items.update_status",
|
||
description: "Update order item status using item_status and drv_status",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: {
|
||
id: { type: "string" },
|
||
item_status: { type: "string" },
|
||
drv_status: { type: "string" },
|
||
notes: { type: "string" },
|
||
problem_notes: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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.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 a quote by the inbound email's RFC 5322 Message-ID header.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["message_id"],
|
||
properties: {
|
||
message_id: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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.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.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id", "cc", "bcc", "subject", "message"],
|
||
properties: {
|
||
id: { type: "string" },
|
||
to: { type: "string", description: "Optional override for the primary To: address" },
|
||
cc: { type: "string" },
|
||
bcc: { type: "string" },
|
||
subject: { type: "string" },
|
||
message: { type: "string" },
|
||
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`. Re-fires the n8n workflow.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: { id: { type: "string" } },
|
||
},
|
||
},
|
||
{
|
||
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.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["template_id"],
|
||
properties: { template_id: { type: "string" } },
|
||
},
|
||
},
|
||
{
|
||
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.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: "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: "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`.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: { id: { type: "string" } },
|
||
},
|
||
},
|
||
{
|
||
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, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited. Any other key (including `id`, `quote`, `created`, `updated`, `quantity`) is rejected with reason=`forbidden_field`.\n" +
|
||
"- 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, 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" +
|
||
"- All pricing fields default to 0; the caller is expected to call `quotes.re_price` afterwards if margins were set on cost.\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.",
|
||
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)." },
|
||
cost_price: { type: "number" },
|
||
selling_price: { type: "number" },
|
||
markup: { type: "number", description: "Margin %, defaults to 0." },
|
||
notes: { type: "string" },
|
||
pricing_source: { type: "string" },
|
||
pricing_notes: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "quote_items.update",
|
||
description:
|
||
"Patch a single `quote_items` row. Patch whitelist is identical to `bulk_update_quote_items`: cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited. Any other key (including `id`, `quote`, `created`, `updated`, `quantity`) is rejected with reason=`forbidden_field`.\n\n" +
|
||
"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, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited}.",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
// ─── 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_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: "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).",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
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" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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: "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 attachments on a single sprint item (id + filename + content_type).",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: { id: { type: "string", description: "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" },
|
||
},
|
||
},
|
||
},
|
||
// ─── 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.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
page: { type: "number" },
|
||
perPage: { type: "number" },
|
||
search: { type: "string" },
|
||
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" } },
|
||
},
|
||
},
|
||
// 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: {} },
|
||
},
|
||
];
|
||
|
||
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 by order_number substring instead, so the
|
||
// tool is still useful for finding a specific order.
|
||
const filter = args.search ? `order_number ~ "${args.search}"` : "";
|
||
const result = await pb.collection("orders").getList(page, perPage, {
|
||
filter,
|
||
sort: "-created",
|
||
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 === "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 === "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 === "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.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 === "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;
|
||
}
|
||
|
||
// 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: patch,
|
||
});
|
||
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, patch);
|
||
updated++;
|
||
results.push({
|
||
id,
|
||
status: "ok",
|
||
previous_cost_price: existing.cost_price ?? 0,
|
||
previous_selling_price: existing.selling_price ?? 0,
|
||
new_cost_price:
|
||
patch.cost_price !== undefined
|
||
? (patch.cost_price as number)
|
||
: (existing.cost_price ?? 0),
|
||
new_selling_price:
|
||
patch.selling_price !== undefined
|
||
? (patch.selling_price as number)
|
||
: (existing.selling_price ?? 0),
|
||
updated_fields: Object.keys(patch),
|
||
});
|
||
// 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).");
|
||
|
||
// quantity: must be > 0 when supplied; default 1
|
||
let quantity = 1;
|
||
if (args.quantity !== undefined && args.quantity !== null) {
|
||
const q = Number(args.quantity);
|
||
if (!Number.isFinite(q) || q <= 0) {
|
||
throw new Error("quantity must be a finite number > 0.");
|
||
}
|
||
quantity = q;
|
||
}
|
||
|
||
// Resolve parent quote for the denormalised quote_number.
|
||
// PB rejects the create with a relation error if the quote id
|
||
// doesn't exist, but a clean "quote not found" surface is much
|
||
// easier for agents to recover from.
|
||
let quoteNumber = "";
|
||
try {
|
||
const parent = await pb.collection("quotes").getOne(quoteId, {
|
||
fields: "id,quote_number",
|
||
});
|
||
quoteNumber = String(parent.quote_number || "");
|
||
} catch (err) {
|
||
throw new Error(
|
||
`quote ${quoteId} not found: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
}
|
||
|
||
const created = await pb.collection("quote_items").create({
|
||
quote: quoteId,
|
||
quote_number: quoteNumber,
|
||
description,
|
||
quantity,
|
||
cost_price: Number(args.cost_price) || 0,
|
||
selling_price: Number(args.selling_price) || 0,
|
||
markup: Number(args.markup) || 0,
|
||
sku: typeof args.sku === "string" ? args.sku : "",
|
||
unit: typeof args.unit === "string" ? args.unit : "",
|
||
supplier:
|
||
typeof args.supplier === "string" && args.supplier ? args.supplier : null,
|
||
notes: typeof args.notes === "string" ? args.notes : "",
|
||
pricing_source:
|
||
typeof args.pricing_source === "string" ? args.pricing_source : "manual",
|
||
pricing_notes: typeof args.pricing_notes === "string" ? args.pricing_notes : "",
|
||
is_processed: false,
|
||
});
|
||
return jsonRpcOk(body.id, { item: created });
|
||
}
|
||
|
||
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 });
|
||
}
|
||
|
||
// ─── 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.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.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);
|
||
}
|
||
|
||
// ─── 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 === "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 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);
|
||
}
|
||
return jsonRpcOk(body.id, { purchase: result.purchase, lines: result.lines });
|
||
}
|
||
|
||
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.next_number") {
|
||
const purchase_number = await generateStockPurchaseNumber(pb);
|
||
return jsonRpcOk(body.id, { purchase_number });
|
||
}
|
||
|
||
// ─── 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 });
|
||
}
|
||
|
||
// ─── 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 });
|
||
}
|
||
|
||
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,
|
||
);
|
||
}
|
||
}
|