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",
|
||||
] 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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue