# MCP Workflow Cookbook End-to-end recipes for the most common operator requests that come through the WFDASH MCP. Each recipe lists the goal, the tool calls in order, and the gotchas — so an agent that has never seen the business before can drive the workflow to completion without further prompting. > Tool names below are the MCP tools. Inputs match the `inputSchema` > you get from `tools.discovery`. Output shapes are described in > `docs/api-mcp-layer.md` §3.3. --- ## 1. "I have a supplier invoice — what do I do?" **Goal:** take a supplier's invoice PDF (or delivery note), match it to the right PO, and book the goods into stock. **Sequence:** 1. **Identify the PO** — read "Your Ref" / "Order No" from the document. It's either a stock PO (`PO{yy}{6d-seq}STK`) or a customer PO (`PO-YYYY-MM-NNN`). 2. **Diff first** (read-only) — `purchase_orders.diff({ po_reference, document: { document_no, date, lines: [...] }, mode: "invoice" | "grn" })`. Returns a `MatchReport` with a verdict + per-line `LineComparison[]`. 3. **Decide:** - `verdict === "ready_to_receive"` → commit with `stock_purchases.match_and_receive_from_invoice({ po_reference, document, attachment, confirm: true })`. The same call attaches the scanned invoice to the PO. - Any other verdict (`cost_mismatch`, `qty_mismatch`, `unmatched_po_lines`, `unmatched_invoice_lines`, `supplier_mismatch`, `wrong_status`, `po_not_found`) → **stop and report**. Never `confirm: true` with a non-clean verdict. 4. **For stock POs only** — the invoice receive books stock into `stock_items.current_qty` and writes a `stock_ledger` row. `stock_purchases.receive` is the lower-level tool for partial receives; the diff+match path handles the common case in one call. 5. **For customer POs** — the receive path flips `order_items. item_status` to `collected` and stamps `supplier_invoice_number`. Use `collection_confirm.upload_invoice` if the invoice wasn't attached during step 3, then `collection_confirm.confirm_order_items` to move the lines to `ready_invoice`. **Gotchas:** - `mode: "grn"` is delivery-note mode — skips price validation, accepts missing/zero prices. Use when the supplier sends a packing slip before the invoice. - The matched `document_no` becomes the `supplier_invoice_number` on the order_items. Get it right; don't paraphrase. - `confirm: true` with a non-clean verdict **silently refuses** — you get a report back but no write. Read the verdict before flipping `confirm`. --- ## 2. "Split this order into a continuation" **Goal:** some lines on the order are stuck (waiting on a back-ordered part); the rest can ship. Move the ready lines onto a fresh ORD number. **Sequence:** 1. `orders.get({ id, expand: "items" })` — confirm which lines are `item_status === "pending"` (only pending can be moved). 2. `orders.split({ order_id, item_ids: [] })`. Returns the new continuation order's ORD number. The parent order keeps its number; each moved item gets a breadcrumb in `notes`. **Gotchas:** - Any item not in `pending` refuses silently (it would orphan a PO or driver assignment). Filter the candidates down to `pending` before splitting — `order_items.list({ filter: `order="" && item_status="pending"` })`. - The continuation order inherits customer, company, rep, contact — no need to re-state them. - If you only have one line to move, splitting is overkill. Just update the parent. --- ## 3. "Confirm collection on a partial delivery" **Goal:** the customer received 8 of 10 units today; 2 are on a back-order. Mark the row `collected` with the right `qty_collected` without losing the partial quantity. **Sequence:** 1. `order_items.get({ id, expand: "order" })` — read the current `quantity` and `qty_collected`. The `quantity_collected` parameter is the amount to **add**, not the new total. 2. Compute `to_add = received_qty - qty_collected` (clamp to ≥ 0). 3. `order_items.confirm_collected({ item_ids: [""], quantity_collected: { "": }, note: "Partial delivery 8 of 10, 2 back-ordered", actor: { id: "", name: "" } })`. **Gotchas:** - Eligibility: row must be in `collection_pool` or `assigned`. Anything past the pool refuses with `wrong_status`. - `actor` is **required** — it's stamped on every audit entry. Look it up via `users.lookup({ q: "" })` if you don't have the id. - `note` is optional but recommended for partial receives — the default "agent confirmed" note loses information about why only part was received. --- ## 4. "Bulk-price a quote that just landed" **Goal:** the AI has extracted items but didn't price them. Fill in cost + selling prices from the catalogue in one MCP round-trip. **Sequence:** 1. `quote_items.list({ filter: `quote=""`, expand: "matched_price_list_id" })` — fetch every line. 2. For each line with `matched_price_list_id` populated, read the catalogue's `cost_price` and `markup`. 3. `bulk_update_quote_items({ items: [ { id, patch: { cost_price, selling_price, pricing_source: "ai", pricing_notes: " by : matched catalogue row" }, force_overwrite: false }, ... ]})`. 4. `quotes.re_price({ id: , items: [...] })` — same call shape, scoped to one quote; useful when the agent wants to keep the diff inside the quote context. **Gotchas:** - `force_overwrite: false` is the safe default — rows with `cost_price > 0 AND selling_price > 0` are skipped with `already_priced`. Pass `true` only when the operator explicitly asked to re-price. - `pricing_notes` is required for the audit trail. The agent identity + date is yours to stamp — the tool does NOT auto-inject it. - `selling_price` is auto-derived from `cost_price * (1 + markup/100)` if omitted (rounded to 2 decimal places). Pass it explicitly when the catalogue has a stored selling price you want to preserve. Applies to `quote_items.update`, `bulk_update_quote_items`, and `quote_items.create`. The `previous_*` / `new_*` surfaces in the bulk response reflect the derived values. - Whitelist (both bulk and re-price): `cost_price`, `selling_price`, `description`, `sku`, `unit`, `supplier`, `category`, `markup`, `pricing_source`, `pricing_notes`, `matched_price_list_id`, `user_edited`. Anything else returns `forbidden_field`. --- ## 5. "Customer rejected some lines — partial approve + split" **Goal:** the customer accepts 6 of 8 lines. Approve the 6 on the original quote, clone the rejected 2 into a fresh quote for re-pricing. **Sequence:** 1. `quotes.get({ id, expand: "items" })` — read all line ids. 2. `quotes.partial_approve({ quote_id, decisions: [ { item_id: "", decision: "approved" }, { item_id: "", decision: "rejected", note: "Customer preferred a different supplier" }, ... ]})`. 3. `quotes.split({ source_quote_id, groups: [ { name: "Re-quote rejected lines", item_ids: ["", ""] } ]})`. 4. `quotes.re_price({ id: , items: [...] })` — adjust pricing on the continuation. **Gotchas:** - `quotes.partial_approve` records the customer's choice but doesn't move the items. To action them, follow with `quotes.split`. - Splitting preserves the rejected lines on a new quote with the same customer + company + letterhead, so a `re_price` round feels like a continuation, not a fresh RFQ. - If the customer rejected everything, prefer `quotes.clone` and starting over — splitting into a single empty group is a no-op. --- ## 6. "Find sprint-pricing items I can claim" **Goal:** the buyer-side sprint shows items waiting for pricing proofs. Find one that's unclaimed and lock it for 30 minutes. **Sequence:** 1. `sprint.list({ filter: `claim_status=null || claim_expires_at<"now"`, expand: "quote_item.quote", sort: "-created" })` — list unclaimed sprint items. 2. Pick one — prefer items with `quote_item.quote.customer` already known (faster resolution). 3. `sprint.claim({ id: , claimed_by: "" })`. Returns 409 if another agent already owns it. **Gotchas:** - The claim is 30-min default — finish your work before it expires, or re-claim to extend. - `sprint.attachment_counts({ item_ids: [...] })` is the bulk counterpart to `sprint.item_attachments` — use it to render paperclip badges in any list view without N round-trips. - After pricing, use `bulk_update_quote_items` to push the new prices; the sprint item auto-resolves when the linked `quote_item.user_edited` flips to `true`. --- ## 7. "Create a walk-in quote (no inbound email)" **Goal:** the operator types "make me a quote for John's workshop". There's no inbound RFQ email — build the quote from scratch with customer + company + items, price it, push it into the approval queue, then send. **Sequence:** 1. Resolve customer (and company) names to ids: `customers.list({ search: "John's workshop" })` — pick the matching row, take its `id`. If no match, the `quotes.create` tool will create the customer with the supplied name as a new row (defaults for the rest). 2. Build the items array — each item needs at minimum `description`. Add `cost_price` + `markup` if you know the pricing (selling_price auto-derives); otherwise leave pricing blank and run `quotes.re_price` after the fact. 3. `quotes.create({ customer: "", company: "" (optional), letterhead: "CNL" | "ISHERWOOD", validity_days: 30, description: "", items: [...] })`. 4. If you didn't supply prices in step 3, run `quotes.re_price` to walk the catalogue. Otherwise skip. 5. `quotes.submit({ id: "" })` — flips `status` from `new` to `approval_pending`. Gated on every line having `selling_price > 0`; on a 422 (`code: "unpriced_items"`) the response carries `offendingItemIds` so you can price the gaps and retry. Idempotent — re-submitting an `approval_pending` quote is a no-op (`statusChanged: false`). 6. `quotes.send_email({ id: "" })` — the PDF auto-renders on this call, then n8n takes over the SMTP send. **Gotchas:** - `customer` and `company` accept EITHER a 15-char PB id (verbatim) OR a name (case-insensitive exact match against `customer.customer` / `company.company`). When the name doesn't match, a new row is created with the given name and empty defaults — the operator reassigns it later via the existing quote-editing form. - `quotes.submit` is the canonical gate between "priced" and "ready to send". Bypassing it (e.g. calling `quotes.send_email` directly on a `new` quote) fails the pre-send validation with `code: "invalid_status"` because `new` is not in `RESENDABLE_STATUSES`. Make `submit` part of the walk-in pipeline. - The quote auto-renders a PDF on the next `quotes.send_email` call. Don't pre-render — the send path handles it. - `quotes.create` is for walk-ins. For inbound RFQ emails use `quotes.ingest_from_email` (preferred) or `quotes.create_from_email` (legacy, caller-supplied items). - After creation, follow Recipe 4 to bulk-price from the catalogue, or Recipe 6 if any line lands in the sprint. --- ## Quick reference: tool families by workflow | Workflow | Primary tools | | --- | --- | | Supplier invoice | `purchase_orders.diff`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.receive` (partial) | | Order split | `orders.get`, `order_items.list`, `orders.split` | | Partial collection | `order_items.get`, `order_items.confirm_collected`, `users.lookup` (for actor) | | Bulk pricing | `quote_items.list`, `bulk_update_quote_items`, `quotes.re_price` | | Partial approve | `quotes.get`, `quotes.partial_approve`, `quotes.split`, `quotes.re_price` | | Sprint claim | `sprint.list`, `sprint.claim`, `bulk_update_quote_items` | | Walk-in quote | `customers.list` (resolve), `quotes.create`, `quotes.re_price`, `quotes.submit`, `quotes.send_email` | For the full tool catalogue (auto-generated), see [`docs/api-mcp-layer.md`](../api-mcp-layer.md).