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:
parent
c41908f4b7
commit
2acfd2d346
2 changed files with 95 additions and 3 deletions
|
|
@ -113,6 +113,26 @@ const QUOTE_ITEM_PATCH_FIELDS = [
|
||||||
"user_edited",
|
"user_edited",
|
||||||
] as const;
|
] 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
|
// 100 is plenty for v1. Bumping to 500 once we actually see a
|
||||||
// supplier-switch use case — 100 records × one PB round-trip ~2s.
|
// supplier-switch use case — 100 records × one PB round-trip ~2s.
|
||||||
const BULK_UPDATE_DEFAULT_CAP = 100;
|
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",
|
name: "quotes.send_email",
|
||||||
description:
|
description:
|
||||||
|
|
@ -1915,6 +1965,33 @@ export async function POST(req: NextRequest) {
|
||||||
return jsonRpcOk(body.id, { quote });
|
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 (toolName === "quotes.send_email") {
|
||||||
if (!args.id) throw new Error("Missing id for quotes.send_email.");
|
if (!args.id) throw new Error("Missing id for quotes.send_email.");
|
||||||
const payload: SendQuoteEmailPayload = {
|
const payload: SendQuoteEmailPayload = {
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,15 @@ export interface CreateQuoteFromEmailResult {
|
||||||
quote: Quote;
|
quote: Quote;
|
||||||
customer: Customer;
|
customer: Customer;
|
||||||
company: Company | null;
|
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;
|
attachments: number;
|
||||||
deduped: boolean;
|
deduped: boolean;
|
||||||
quote_number: string;
|
quote_number: string;
|
||||||
|
|
@ -1106,19 +1114,25 @@ export async function createQuoteFromEmail(
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 6. Pre-populate quote items from the AI extraction. Human opt-in only.
|
// ── 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 items = (payload.extracted_items || []).filter((i) => (i.description || "").trim());
|
||||||
|
const createdItems: QuoteItem[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
await pb.collection(QUOTE_ITEMS_COLLECTION).create({
|
const created = await pb.collection(QUOTE_ITEMS_COLLECTION).create({
|
||||||
quote: quote.id,
|
quote: quote.id,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
quote_number: quoteNumber,
|
quote_number: quoteNumber,
|
||||||
quantity: Number.isFinite(item.quantity) && (item.quantity as number) > 0 ? item.quantity : 1,
|
quantity: Number.isFinite(item.quantity) && (item.quantity as number) > 0 ? item.quantity : 1,
|
||||||
|
unit: typeof item.unit === "string" ? item.unit : "",
|
||||||
cost_price: 0,
|
cost_price: 0,
|
||||||
selling_price: 0,
|
selling_price: 0,
|
||||||
markup: 0,
|
markup: 0,
|
||||||
notes: item.notes || "",
|
notes: item.notes || "",
|
||||||
is_processed: false,
|
is_processed: false,
|
||||||
});
|
});
|
||||||
|
createdItems.push(created as QuoteItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 7. Attachments are uploaded in a follow-up step, NOT here.
|
// ── 7. Attachments are uploaded in a follow-up step, NOT here.
|
||||||
|
|
@ -1143,7 +1157,8 @@ export async function createQuoteFromEmail(
|
||||||
quote: quote as Quote,
|
quote: quote as Quote,
|
||||||
customer,
|
customer,
|
||||||
company: company ?? null,
|
company: company ?? null,
|
||||||
items: items.length,
|
items: createdItems,
|
||||||
|
items_count: createdItems.length,
|
||||||
attachments: 0,
|
attachments: 0,
|
||||||
deduped: false,
|
deduped: false,
|
||||||
quote_number: quoteNumber,
|
quote_number: quoteNumber,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue