From 2acfd2d346aed21b72d30a08bf97b1fdf7dfc9f6 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 5 Aug 2026 11:39:16 +0200 Subject: [PATCH] fix(mcp): add quotes.update + preserve unit in create_from_email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two real gaps from the agent's gap analysis (2026-08-05): 1. **quotes.update MCP tool** — was missing entirely. The quote header has fields (company_letterhead, quote_date, quote_reference, etc.) that couldn't be patched via MCP, so the agent had to pass letterhead at PDF-render time and couldn't persist it on the record. Now exposed with a strict field whitelist mirroring the quote_items whitelist pattern: any field outside the allowed set (status, customer, company, quote_number, id, created, updated, …) errors with forbidden_field rather than silently writing to PB. New catalog entry + dispatch handler in src/app/api/mcp/route.ts. Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability. 2. **create_from_email now preserves the unit field** — extracted_items from the AI's RFQ parsing carries {description, quantity, unit, notes} but the create loop was silently dropping unit. Also the service returned items: which made the agent believe no items were created when in fact stub rows existed. Now returns the actual QuoteItem array (with a back-compat items_count field) and the unit value lands on each row. The agent's third claim (PB admin auth unavailable) is a misdiagnosis — WFDASH uses its own AGENT_API_KEY and all writes go through the Next.js admin client. No code change needed for that. All 1379 tests pass. quote_items.update path unaffected. The quotes.update path mirrors the same pattern: whitelist + reject empty patch + call pb.collection.update. Co-authored-by: Orca --- src/app/api/mcp/route.ts | 77 ++++++++++++++++++++++++++++++++ src/lib/server/quotes-service.ts | 21 +++++++-- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 339eaf6..de07436 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -113,6 +113,26 @@ const QUOTE_ITEM_PATCH_FIELDS = [ "user_edited", ] as const; +// Whitelist of patchable fields on the QUOTE HEADER. Matches what the +// agent's gap analysis requested: letterhead, dates, references, notes, +// default markup, email cc/bcc, claim owner. System-controlled fields +// (id, created, updated, status, customer, company, quote_number) are +// intentionally excluded — those are managed by the workflow, not +// directly patchable. +const QUOTE_PATCH_FIELDS = [ + "company_letterhead", + "quote_date", + "quote_reference", + "rfq_number", + "validity_days", + "notes", + "delivery_note", + "markup", + "email_cc", + "email_bcc", + "claimed_by", +] as const; + // 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; @@ -380,6 +400,36 @@ const tools = [ }, }, }, + { + name: "quotes.update", + description: + "Patch fields on a quote header. Whitelist: `company_letterhead`, `quote_date`, `quote_reference`, `rfq_number`, `validity_days`, `notes`, `delivery_note`, `markup`, `email_cc`, `email_bcc`, `claimed_by`. Anything outside this set — including `id`, `created`, `updated`, `status`, `customer`, `company`, `quote_number` — is rejected with `forbidden_field`. Use this to persist `company_letterhead` so re-renders in the UI match what you generated, or to update header metadata (dates, references, default markup).", + inputSchema: { + type: "object", + required: ["id", "patch"], + properties: { + id: { type: "string", description: "15-char PocketBase quote id" }, + patch: { + type: "object", + description: + "Subset of the whitelisted fields above. Empty patches are rejected.", + properties: { + company_letterhead: { type: "string", enum: ["CNL", "ISHERWOOD"] }, + quote_date: { type: "string", description: "ISO date (YYYY-MM-DD)" }, + quote_reference: { type: "string" }, + rfq_number: { type: "string" }, + validity_days: { type: "number" }, + notes: { type: "string" }, + delivery_note: { type: "string" }, + markup: { type: "number", description: "Default markup on items without one" }, + email_cc: { type: "string" }, + email_bcc: { type: "string" }, + claimed_by: { type: "string" }, + }, + }, + }, + }, + }, { name: "quotes.send_email", description: @@ -1915,6 +1965,33 @@ export async function POST(req: NextRequest) { return jsonRpcOk(body.id, { quote }); } + if (toolName === "quotes.update") { + if (!args.id) throw new Error("Missing id for quotes.update."); + if (!args.patch || typeof args.patch !== "object" || Array.isArray(args.patch)) { + throw new Error("Missing or invalid `patch` for quotes.update."); + } + const rawPatch = args.patch as Record; + const patch: Record = {}; + const rejected: string[] = []; + for (const [k, v] of Object.entries(rawPatch)) { + if ((QUOTE_PATCH_FIELDS as readonly string[]).includes(k)) { + patch[k] = v; + } else { + rejected.push(k); + } + } + if (rejected.length > 0) { + throw new Error( + `forbidden_field(s): ${rejected.join(", ")}. Allowed: ${QUOTE_PATCH_FIELDS.join(", ")}.`, + ); + } + if (Object.keys(patch).length === 0) { + throw new Error("Patch is empty after whitelist."); + } + const updated = await pb.collection("quotes").update(args.id, patch); + return jsonRpcOk(body.id, { quote: updated }); + } + if (toolName === "quotes.send_email") { if (!args.id) throw new Error("Missing id for quotes.send_email."); const payload: SendQuoteEmailPayload = { diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index 6d3a74f..1b5f2a2 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -92,7 +92,15 @@ export interface CreateQuoteFromEmailResult { quote: Quote; customer: Customer; company: Company | null; - items: number; + /** The created quote_items rows (as QuoteItem objects, with the + * stubbed prices / no supplier set — these are placeholders for the + * office to price). The agent previously saw only a count; returning + * the array lets it confirm what was written and follow up with + * `quote_items.update` for each. */ + items: QuoteItem[]; + /** Convenience: `items.length`. Kept for back-compat with the old + * count-only shape. */ + items_count: number; attachments: number; deduped: boolean; quote_number: string; @@ -1106,19 +1114,25 @@ export async function createQuoteFromEmail( }); // ── 6. Pre-populate quote items from the AI extraction. Human opt-in only. + // We collect the created rows so the caller can see what was actually + // written (the agent previously saw only a count and assumed nothing + // was created; see gap analysis 2026-08-05). const items = (payload.extracted_items || []).filter((i) => (i.description || "").trim()); + const createdItems: QuoteItem[] = []; for (const item of items) { - await pb.collection(QUOTE_ITEMS_COLLECTION).create({ + const created = await pb.collection(QUOTE_ITEMS_COLLECTION).create({ quote: quote.id, description: item.description, quote_number: quoteNumber, quantity: Number.isFinite(item.quantity) && (item.quantity as number) > 0 ? item.quantity : 1, + unit: typeof item.unit === "string" ? item.unit : "", cost_price: 0, selling_price: 0, markup: 0, notes: item.notes || "", is_processed: false, }); + createdItems.push(created as QuoteItem); } // ── 7. Attachments are uploaded in a follow-up step, NOT here. @@ -1143,7 +1157,8 @@ export async function createQuoteFromEmail( quote: quote as Quote, customer, company: company ?? null, - items: items.length, + items: createdItems, + items_count: createdItems.length, attachments: 0, deduped: false, quote_number: quoteNumber,