retail-ops-mcp-kit
# retail-ops-mcp-kit
Four small Python MCP servers — one per enterprise-system class a retail/CPG operations
agent integrates (**ERP / supplier network / email / spreadsheet**). Each server *models a
real named SaaS on the wire* (SAP S/4HANA, SPS Commerce, Microsoft Graph, Google Sheets, …),
selectable by env, over **one shared synthetic corpus**. Every mutation is a **staged
write**: it validates, records a `pending_approval` record, returns an `approval_id`, and
**never executes**. A separate `approve(approval_id, token)` needs a human token that lives
only in the server environment and that the agent cannot read — so **auto-execution is
impossible by construction**. Humans stay in control of what runs versus what waits, designed
into the connector layer.
- 4 servers, one runtime dependency (`mcp>=1.27.2`); everything else is stdlib.
- FastMCP + hatchling src-layout + uv, Python 3.12, docstring-per-tool.
- All data is fully synthetic and methodology-only — see [`PROVENANCE.md`](PROVENANCE.md).
## "I know your stack" — tool → real SaaS endpoint
Each server's tool signature is **identical across every dialect it models**; only the wire
envelope changes (the connector seam). A host cannot tell which system answered.
### `retail-erp-mcp` — the three-way match (PO / goods-receipt / invoice)
| MCP tool | Kind | SAP S/4HANA (default) | Oracle NetSuite | MS Dynamics 365 F&O |
|---|---|---|---|---|
| `get_purchase_order` | read | `GET …/API_PURCHASEORDER_PROCESS_SRV/A_PurchaseOrder('…')?$expand=to_PurchaseOrderItem` → `{"d":{…}}` | `GET /services/rest/record/v1/purchaseOrder/{id}` → `{id,tranId,entity,item:{items[]}}` | `GET /data/PurchaseOrderHeadersV2(dataAreaId='usmf',PurchaseOrderNumber='…')` → `{@odata.etag,…}` |
| `get_goods_receipt` | read | `GET …/API_MATERIAL_DOCUMENT_SRV/A_MaterialDocumentHeader` (GM type 101) | `GET …/record/v1/itemReceipt/{id}` | `GET /data/ProductReceiptHeaders` |
| `get_invoice` | read | `GET …/API_SUPPLIERINVOICE_PROCESS_SRV/A_SupplierInvoice('…')` | `GET …/record/v1/vendorBill/{id}` | `GET /data/VendorInvoiceHeaders` |
| `list_open_pos` | read | `GET …/A_PurchaseOrder?$filter=…` → `{"d":{"results":[]}}` | `GET …/record/v1/purchaseOrder?q=status:open` → `{items,hasMore,offset}` | `GET /data/PurchaseOrderHeadersV2?$filter=PurchaseOrderStatus eq 'Backorder'` |
| `stage_invoice_payment` | **staged-write** | `POST …/API_JOURNALENTRYITEMBASIC_SRV/A_JournalEntryItemBasic` (x-csrf-token) | `POST …/record/v1/vendorPayment` | `POST /data/VendorPaymentJournalHeaders` |
| `stage_po_amendment` | **staged-write** | `PATCH …/A_PurchaseOrder('…')` (If-Match / x-csrf) | `PATCH …/record/v1/purchaseOrder/{id}` | `PATCH /data/PurchaseOrderHeadersV2(…)` (If-Match etag) |
| `simulate_po_event` | webhook seam | Advanced Event Mesh CloudEvent `sap/s4/beh/purchaseorder/v1/…/Created/v1` | SuiteScript user-event `https.post` callback | Business Events → Azure Event Grid |
Auth handshakes modeled per dialect (documented, never performed): SAP `x-csrf-token: Fetch`
round-trip + `sap-client`; NetSuite OAuth1.0a account-ID host; D365 `@odata.etag` + `If-Match`.
### `retail-supplier-mcp` — supplier network (ASNs / exceptions / deductions)
| MCP tool | Kind | SPS Commerce (default) | Coupa | SAP Ariba |
|---|---|---|---|---|
| `get_asn` | read | EDI-856 Universal-JSON: `hierarchicalLevels[]` (S→O→T→I), `carrier.scac`, `sscc18` | REST ASN document | cXML `ShipNoticeRequest` |
| `get_shipment_status` | read | 214-style status `{shipmentIdentification, carrier, trackingNumber, status}` | ASN status document | cXML `StatusUpdateRequest` |
| `get_supplier` | read | trading-partner `{tradingPartnerId, senderId, …}` (derived) | supplier record (derived) | supplier + `NetworkID` (derived) |
| `list_exceptions` | read | exception-queue rows `[{id, po_id, evidence_text}]` (`gold_*` withheld) | — same abstraction — | — same abstraction — |
| `stage_credit_note` | **staged-write** | EDI 810-variant | Coupa `document-type:"credit_note"` | `InvoiceDetailRequest purpose="creditMemo"` |
| `stage_debit_note` | **staged-write** | SPS debit doc | Coupa negative invoice | `InvoiceDetailRequest purpose="debitMemo"` |
| `acknowledge_exception` | **staged-write** | EDI **855** PO-ack (`itemStatusCode IA/IB/IR`) + **997** functional ack | `POST /documents/{id}/acknowledge` | cXML confirmation |
| `simulate_inbound_asn` | webhook seam | `{eventType:"document.received", transactionType:"856", …}` + the 856 payload | `{event:"purchase_order.updated", …}` | cXML `StatusUpdateRequest` |
Ack/event vocabulary modeled: 850 (inbound PO), 856 (ASN), 810 (invoice), 855 (PO ack),
997 (functional ack); Coupa `X-COUPA-API-KEY` + scoped OAuth (`core.invoice.write`); Ariba
dual `apikey` + OAuth and cXML `<Credential domain="NetworkID">`.
### `retail-email-mcp` — supplier chase (mailbox threads + staged send)
| MCP tool | Kind | Microsoft Graph (default) | Gmail API |
|---|---|---|---|
| `list_chase_thread` | read | `GET /me/messages?$filter=conversationId eq '…'` → `{"value":[…]}` | `GET /users/me/threads/{id}` |
| `get_supplier_reply` | read | `GET /me/messages/{id}` | `messages.get?format=full` (base64url payload) |
| `stage_chase_email` | **staged-write** | `POST /me/sendMail` (202, no body) | `messages.send {raw, threadId}` |
| `stage_escalation_email` | **staged-write** | `POST /me/sendMail` with `ccRecipients` | `messages.send {raw, threadId}` |
| `simulate_change_notification` | webhook seam | Graph change-notification + `validationToken` echo | Pub/Sub `{message:{data:<base64>}}` + `historyId` cursor |
The chase ladder tone (`soft → firm → escalation`) is **recomputed** from `prior_chases`
against `policy.chase_ladder` — never read from a stored "next step".
### `retail-sheets-mcp` — reconciliation worksheet
| MCP tool | Kind | Google Sheets (default) | MS Graph Excel |
|---|---|---|---|
| `read_reconciliation_sheet` | read | `GET /v4/spreadsheets/{id}/values/Reconciliation!A1:I11` → `{range,majorDimension,values}` | `#microsoft.graph.workbookRange` (values/text/numberFormat/valueTypes, JSON-number cells) |
| `get_range` | read | `GET …/values/{range}` over `Reconciliation` / `Approvals` / `Audit` | `…/worksheets('…')/range(address='…')` |
| `stage_cell_update` | **staged-write** | `PUT …/values/{range}?valueInputOption=USER_ENTERED` | `PATCH …/range(address='…')` |
| `stage_row_append` | **staged-write** | `values:append` | `…/tables/{name}/rows/add {values:[[…]]}` |
| `simulate_change_notification` | webhook seam | Drive `files.watch` push channel (Sheets values have no native webhook) | driveItem change-notification (+ 409/423 stale workbook-session) |
`Reconciliation` is a PO+receipt+invoice+ASN join; `Approvals` (from `approval_actions`,
with a recomputed `Gated` column) and `Audit` (from `audit_entries`, with a recomputed
`Complete` column over `policy.audit_required_fields`) are also readable.
## The safety model — humans control what runs vs. what waits
1. **Every mutation is a staged write.** A mutation tool validates its payload against the
corpus, calls `staging.stage(...)`, and returns `{result:"pending_approval", approval_id,
target_endpoint, payload, validation, status:"pending"}`. Nothing executes at stage time.
2. **`approve(approval_id, token)` is the only path to execution**, and it requires the human
approval token from the server environment (`RETAIL_OPS_MCP_APPROVAL_TOKEN`). The supplied
`token` is compared with `hmac.compare_digest`. That token is **never a tool return, never
in the corpus, never a default** — the agent has no channel to read it, so any token it
fabricates fails. Absent a configured token, approval **fails closed** (refused).
3. **Auto-execution is impossible by construction.** Staging never executes; `approve` needs
a secret the agent structurally cannot hold. This is the deliberate inverse of "no creds in
argv": here the argv token is the *human's proof-of-presence*, intentionally withheld from
the agent.
4. **Execution is fabrication, not persistence.** On a valid human approval, the stored
`execute(payload)` returns a synthetic SaaS response envelope (a SAP journal-entry doc
number, a Graph `202`, a Sheets `updatedCells`) recorded on the in-process ledger — it
never appends to a JSONL or edits the corpus.
5. **A fingerprint invariant makes it testable.** `corpus.fingerprint()` (sha256 over all
fixture bytes) is byte-identical before staging, after staging, and after a full
approve+execute. `smoke_test.py` asserts exactly this.
Shared on every server: `approve`, `list_pending_approvals`, `get_approval_status`, and an
`active_system()` tell.
## System selection
Each server picks the modeled SaaS dialect from an env var (validated allowlist, safe local
default, no credentials):
| Server | Env var | Allowlist | Default |
|---|---|---|---|
| `retail-erp-mcp` | `RETAIL_ERP_SYSTEM` | `sap_s4hana`, `netsuite`, `dynamics365` | `sap_s4hana` |
| `retail-supplier-mcp` | `RETAIL_SUPPLIER_SYSTEM` | `sps_commerce`, `coupa`, `ariba` | `sps_commerce` |
| `retail-email-mcp` | `RETAIL_EMAIL_SYSTEM` | `msgraph`, `gmail` | `msgraph` |
| `retail-sheets-mcp` | `RETAIL_SHEETS_SYSTEM` | `google_sheets`, `msgraph_excel` | `google_sheets` |
`active_system()` reports the wired dialect without leaking any credential.
## Data posture
All 11 fixtures are fully synthetic, methodology-only, and vendored verbatim from the
`agentic-eval-harness` `retail_ops` set — see [`PROVENANCE.md`](PROVENANCE.md). `policy.json`
numbers are `<<TOKEN>>`-tagged invented placeholders; exception rows carry only abstract
`MARKER_*` tokens in benign filler. The `suppliers()` resource is the one kit-authored
derivation (synthesized from the `supplier` FK, flagged `_derived`). No network call is ever
made; there is no runtime path back to the harness.
## Develop
```sh
just setup # uv sync --extra dev
just check # lint + typecheck + test + smoke (the gate)
just smoke # dependency-light staged-write proof → prints SMOKE OK
just run-erp # run a server on stdio (RETAIL_ERP_SYSTEM overridable)
```
## Wire into an MCP host
Claude Desktop (`claude_desktop_config.json`) or a project `.mcp.json`:
```json
{
"mcpServers": {
"retail-erp": { "command": "uv", "args": ["run", "retail-erp-mcp"],
"env": { "RETAIL_ERP_SYSTEM": "sap_s4hana",
"RETAIL_OPS_MCP_APPROVAL_TOKEN": "<operator-injected; NOT known to the agent>" } },
"retail-supplier": { "command": "uv", "args": ["run", "retail-supplier-mcp"],
"env": { "RETAIL_SUPPLIER_SYSTEM": "sps_commerce",
"RETAIL_OPS_MCP_APPROVAL_TOKEN": "<operator-injected>" } },
"retail-email": { "command": "uv", "args": ["run", "retail-email-mcp"],
"env": { "RETAIL_EMAIL_SYSTEM": "msgraph",
"RETAIL_OPS_MCP_APPROVAL_TOKEN": "<operator-injected>" } },
"retail-sheets": { "command": "uv", "args": ["run", "retail-sheets-mcp"],
"env": { "RETAIL_SHEETS_SYSTEM": "google_sheets",
"RETAIL_OPS_MCP_APPROVAL_TOKEN": "<operator-injected>" } }
}
}
```
The approval token belongs in the **host/operator's** env injection (or a secrets manager),
and is deliberately withheld from the agent — that withholding is the entire control. Without
it configured, all approvals fail closed.
## License
Dual-licensed under Apache-2.0 OR MIT (added at T1). All data synthetic — see `PROVENANCE.md`.
TDQS
Scored across 11 tools
Each tool targets a distinct resource or action: fetching PO, invoice, goods receipt, approval status, listing open POs, staging payment/amendment, simulating events, approving, and listing approvals. Only minor potential confusion exists between get_approval_status and list_pending_approvals, but their inputs (approval_id vs listing) clarify the difference.
The set mostly follows a verb_noun pattern (get_*, list_*, stage_*, simulate_*), but 'active_system' and 'approve' deviate from that pattern. These deviations are minor and the intent remains readable.
With 11 tools, the server is well-scoped for its purpose: covering purchase order, invoice, goods receipt, approval workflow, and simulation. No tool is redundant; each adds a distinct capability.
The toolkit covers the core three-way match (PO, GR, invoice), open PO listing, staging of payment and PO amendments, and the approval flow. Minor gaps include no explicit reject/cancel for staged actions and no PO/invoice creation, but these may be outside the server's intended scope.