fix(mcp): add quotes.update + preserve unit in create_from_email

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: <count> 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 <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-05 11:39:16 +02:00
parent c41908f4b7
commit 2acfd2d346
2 changed files with 95 additions and 3 deletions

View file

@ -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<string, unknown>;
const patch: Record<string, unknown> = {};
const rejected: string[] = [];
for (const [k, v] of Object.entries(rawPatch)) {
if ((QUOTE_PATCH_FIELDS as readonly string[]).includes(k)) {
patch[k] = v;
} else {
rejected.push(k);
}
}
if (rejected.length > 0) {
throw new Error(
`forbidden_field(s): ${rejected.join(", ")}. Allowed: ${QUOTE_PATCH_FIELDS.join(", ")}.`,
);
}
if (Object.keys(patch).length === 0) {
throw new Error("Patch is empty after whitelist.");
}
const updated = await pb.collection("quotes").update(args.id, patch);
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 = {

View file

@ -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,