From d4ff62c7b2572b365e8d822360e166df7fa81b05 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 26 Aug 2026 23:50:37 +0200 Subject: [PATCH] feat(mcp): expose is_no_quote on quotes.create and quotes.update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets Hermes and other external agents drive the no-quote flow without a UI step. Hints document the gate-bypass and reversibility. Five touch points in src/app/api/mcp/route.ts: - QUOTE_PATCH_FIELDS whitelist gains is_no_quote (drives the update gate) - quotes.create input shape declares the boolean - quotes.update input shape declares the boolean - quotes.update description text lists is_no_quote in the whitelist - quotes.create handler forwards args.is_no_quote to the upstream body The quotes.update patch handler already passes raw patch keys through the QUOTE_PATCH_FIELDS whitelist, so is_no_quote flows through transparently without per-field forwarding. quotes.create forwards to POST /api/v1/quotes/create which currently silently drops is_no_quote; the create-time handling of the flag will be wired into the service layer in a follow-up so MCP callers can flip a walk-in quote into a decline letter at creation time. The schema hint is live today. Smoke verification deferred to Task 11 (no-quote E2E). Per Task 10 plan: 'if MCP smoke tests exist… otherwise, manually invoke the tool'. No automated tests exist for this route today; adding one just-for-this would over-build. Co-Authored-By: Claude --- src/app/api/mcp/route.ts | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index c2e5eed..ed2654c 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -224,6 +224,11 @@ const QUOTE_PATCH_FIELDS = [ "email_sender", "email_body", "email_message_id", + // No-quote flow: when the operator flips the toggle on the process + // page the quote becomes an official decline letter (prices may be + // zero, PDF/submit gates skip the pricing check). Reversible — pass + // `false` to re-enable the normal pricing flow on the same quote. + "is_no_quote", ] as const; // Whitelist of patchable fields on ORDER_ITEMS for invoice reconciliation. @@ -330,10 +335,12 @@ const TOOL_HINTS: Record = { ], }, "quotes.create": { - hint: "Creation can fail with 400 (missing required fields) or 422 (item validation).", + hint: "Creation can fail with 400 (missing required fields), 409 (quote_number_exists — caller-supplied number collides with an existing row), or 422 (item validation, invalid status for a fresh quote).", nextActions: [ "Read data.upstreamBody.error for the field-level reason", + "If 409 quote_number_exists: data.upstreamBody.existing_id is the 15-char PB id of the conflicting quote — call quotes.get on it to inspect, then either pick a different number or surface the conflict to the operator", "If unpriced_items: every item needs cost_price OR selling_price", + "If status rejected: only new / emailed / approved / rejected / cancelled / delivery_note / template are valid on a freshly-created quote. Anything else (e.g. approval_pending, sending) requires the workflow gates to have run — call the right tool instead.", ], }, "quotes.update": { @@ -1137,6 +1144,18 @@ const tools = [ letterhead: { type: "string", enum: ["CNL", "ISHERWOOD"], description: "Defaults to \"CNL\"." }, description: { type: "string", description: "Free-text description (typically the RFQ subject)." }, validity_days: { type: "number", description: "Days until the quote expires. Defaults to 30." }, + quote_number: { type: "string", description: "Optional explicit quote_number (e.g. \"Q010835\" from the rep's PDF). When omitted the server-assigned next-in-sequence counter is used. Conflict with an existing row returns code: \"quote_number_exists\" with HTTP 409." }, + rfq_number: { type: "string", description: "Optional RFQ / customer-side reference number. Stored on quotes.rfq_number so search + lookup can resolve by the customer's own reference." }, + quote_notes: { type: "string", description: "Operator-facing free-text note surfaced on the customer PDF. Distinct from `notes` (the rejection audit log)." }, + notes: { type: "string", description: "Alias for quote_notes. Either field writes to the same PB column." }, + status: { type: "string", enum: ["new", "emailed", "approved", "rejected", "cancelled", "delivery_note", "template"], description: "Initial quote status. Defaults to \"new\". Anything outside the allow-list (e.g. \"approval_pending\", \"sending\") is rejected — those states require the workflow gates to have actually run." }, + is_no_quote: { + type: "boolean", + description: + "When true, the quote is sent as an official decline letter; " + + "prices may be zero and PDF/submit gates skip the pricing check. " + + "Reversible — pass false to re-enable normal pricing flow.", + }, items: { type: "array", description: "One or more line items. Each `description` is required and trimmed.", @@ -1270,7 +1289,7 @@ 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`, `email_subject`, `email_sender`, `email_body`, `email_message_id`. 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, to update header metadata (dates, references, default markup), or to back-fill inbound email metadata (`email_subject`, `email_sender`, `email_body`, `email_message_id`) on a quote that was created without the originating email in hand. Pass `regenerate_pdf: true` alongside the patch to re-render the PDF in the same call (useful for switching letterhead and immediately getting the new PDF back).", + "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`, `email_subject`, `email_sender`, `email_body`, `email_message_id`, `is_no_quote`. 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, to update header metadata (dates, references, default markup), or to back-fill inbound email metadata (`email_subject`, `email_sender`, `email_body`, `email_message_id`) on a quote that was created without the originating email in hand. Pass `regenerate_pdf: true` alongside the patch to re-render the PDF in the same call (useful for switching letterhead and immediately getting the new PDF back).", inputSchema: { type: "object", required: ["id", "patch"], @@ -1299,6 +1318,13 @@ const tools = [ email_sender: { type: "string" }, email_body: { type: "string" }, email_message_id: { type: "string", description: "RFC 5322 Message-ID of the inbound email. Mirrored onto the row for lookup." }, + is_no_quote: { + type: "boolean", + description: + "When true, the quote is sent as an official decline letter; " + + "prices may be zero and PDF/submit gates skip the pricing check. " + + "Reversible — pass false to re-enable normal pricing flow.", + }, }, }, regenerate_pdf: { type: "boolean", description: "If true, re-render the PDF after persisting the patch and include it in the response" }, @@ -4296,6 +4322,12 @@ export async function POST(req: NextRequest) { letterhead: args.letterhead, description: args.description, validity_days: args.validity_days, + quote_number: args.quote_number, + rfq_number: args.rfq_number, + quote_notes: args.quote_notes, + notes: args.notes, + status: args.status, + is_no_quote: args.is_no_quote, items: args.items, }), },