When the OMS Speaks MCP: Idempotency and ERP Sync for Agent-Driven Order Changes
In After the Buy Button I looked at what the agentic checkout protocols leave for the back office once an order exists. This post picks up one step later: the order exists, it has synced to the ERP, maybe it has gone to the warehouse, and now an agent wants to change it.
That used to be a hypothetical. It is less hypothetical this month. Salesforce’s Winter ‘27 release went GA on October 12, 2026 (release announcement), and its Commerce Cloud innovations page lists an “MCP Server for OMS” described as letting AI agents “query, update, and resolve order issues through a headless architecture”, plus “Commerce Operations: MCP and Agent Plugins” (marked GA Oct ‘26) for developers and admins to “triage order failures” among other things from coding agents. ERP vendors got there first. This post is about what you have to build around those tools before you let an agent near them.
What the vendors actually expose
I want to be precise here, because marketing pages and tool lists are different things.
- Salesforce Order Management. The innovations page describes the OMS MCP server in one paragraph and does not give a GA date for it or list its tools. I could not find a published tool reference for it. What is documented is the general Salesforce Hosted MCP Servers platform (GA since April 2026 per the developer blog), which offers SObject read/create/update/delete tools and lets admins expose Flows, Apex invocable actions and API Catalog endpoints as custom tools. So in practice, today, “agent changes an order in Salesforce OMS” likely means either generic record writes or a Flow you exposed yourself. I would not assume more until the tool list is published.
- NetSuite. The MCP Standard Tools SuiteApp, used through the AI Connector Service, includes record tools:
ns_createRecord,ns_getRecord,ns_updateRecordandns_getRecordTypeMetadata.ns_updateRecordtakes a record type, an internal id and a JSON string of field values. It goes through REST Web Services and inherits its limits. That is a generic field write on a sales order, with no notion of business intent. - Business Central. The Business Central MCP server is read-only by default. Admins configure API pages with Allow Create, Modify, Delete and Bound Actions, and each allowed operation becomes a tool. Again: API-page CRUD and OData bound actions.
- Shopify. The agent-facing Order MCP exposes a single tool,
get_order, for orders the agent itself placed. The Customer Accounts MCP server covers authenticated customer actions including order status and details, with the tool list discovered viatools/listrather than documented on the page. I found no Shopify MCP tool for merchant-side order edits.
The pattern: the ERP side gives you generic record writes with role permissions, and the OMS side gives you either reads or something still thinly documented. Nobody ships an “order change with business semantics and idempotency” tool. You will build that layer.
Not all order changes are equal
The useful question is not “can the agent edit the order” but “what state is the order in, across every system that holds a copy”. Here is how I classify the common changes. This is my opinion, built from what tends to break, not a standard.
| Change | Before release to WMS/3PL | Released, not picked | Picked/packed | Shipped |
|---|---|---|---|---|
| Address change | Auto-apply after validation | Needs WMS ack | Human | Carrier intercept only, human |
| Full cancel | Auto-apply | Needs WMS cancel ack | Human | Not a cancel; return flow |
| Line cancel / qty reduce | Auto-apply | Needs WMS ack | Human | Return flow |
| Change fulfillment location (reroute) | Auto-apply if inventory reserved | Needs cancel ack from old node first | Human | n/a |
| Hold | Auto-apply | Best effort, needs ack | Usually too late | n/a |
| Release hold | Auto-apply if hold reason is cleared by rule | Same | Same | n/a |
| Split shipment | Auto-apply | Needs WMS ack | Human | n/a |
| Upgrade shipping method | Auto-apply if price delta authorized | Needs WMS ack (label not yet printed) | Human | n/a |
| Refund / return initiation | Rule-based within policy limits | Same | Same | Same |
Then overlay the ERP and payment state, which is independent of the warehouse:
- Payment authorized, not captured. Cancels and reductions are cheap: void or partial-release the authorization. Increases (shipping upgrade, added line) need a new or incremental authorization, which may fail.
- Captured. Every reduction becomes a refund. Refunds are money movement and I would put a per-order and per-day amount ceiling on anything an agent can trigger without a human.
- Invoiced in the ERP. Changing an invoiced sales order means a credit memo or a new invoice, not an edit. If the period is closed, it means a correction in the current period. An agent should never be issuing raw field updates against an invoiced transaction. With generic tools like
ns_updateRecord, nothing stops it except role permissions, so set those deliberately.
Rule of thumb I use: a change is auto-applicable only if every downstream system that already holds a copy of the order can be updated synchronously and confirms it, and no money moves beyond an already-approved envelope. Everything else becomes a change request that waits for confirmation.
MCP gives you no idempotency
The MCP spec is explicit about what it does not do. A tools/call request has a JSON-RPC id, a tool name and arguments (Tools, 2025-06-18). The JSON-RPC id correlates a response to a request on one connection; it is not a deduplication key and a client retry gets a new one.
The closest thing is the tool annotation idempotentHint: “If true, calling the tool repeatedly with the same arguments will have no additional effect on its environment” (schema 2025-11-25). It is a hint, defaults to false, and the spec says clients “MUST consider tool annotations to be untrusted unless they come from trusted servers”. It describes behavior; it does not create it.
On timeouts, the lifecycle spec says senders SHOULD set timeouts and, when one fires, should send a cancellation notification (Lifecycle). The cancellation page then admits the obvious: processing “may have completed before cancellation arrives” and both sides “MUST handle these race conditions gracefully” (Cancellation). So a timed-out cancel-order call has an unknown outcome, and the protocol hands that problem to you.
Three sources of duplicates, in rough order of how often I expect them:
- The model calls the tool twice. It did not see the result, the context got trimmed, or the user rephrased and the agent “helpfully” did it again. Same intent, different arguments sometimes (a reformatted address).
- Client or transport retry after a timeout or dropped stream.
- Downstream retry inside your own integration: the MCP server retries the OMS, the OMS retries the WMS webhook, the iPaaS retries the ERP write.
Note that number 1 defeats any idempotency scheme keyed on argument hashing. The key has to represent intent, and it has to be stable across retries of the same intent.
Carry an intent id end to end
The design I would build: the agent never mutates the order directly. It calls request_order_change, which creates a durable change request with a caller-supplied intent_id, and returns its id and state. Everything downstream carries that id.
{
"name": "request_order_change",
"description": "Request a change to an existing order. Does not apply the change synchronously. Returns a change request whose state must be checked with get_order_change. Reusing an intent_id returns the existing request instead of creating a new one.",
"inputSchema": {
"type": "object",
"required": ["intent_id", "order_id", "expected_order_version", "change"],
"properties": {
"intent_id": { "type": "string", "description": "UUID generated once per user intent. Reuse on retry." },
"order_id": { "type": "string" },
"expected_order_version": { "type": "integer" },
"change": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["address_change", "cancel", "line_cancel", "reroute", "hold", "release_hold", "split", "shipping_upgrade", "return_init"] },
"lines": { "type": "array", "items": { "type": "object", "properties": { "line_id": { "type": "string" }, "qty": { "type": "integer" } } } },
"ship_to": { "type": "object" },
"location_id": { "type": "string" },
"shipping_method": { "type": "string" },
"reason_code": { "type": "string" }
}
},
"requested_by": { "type": "object", "properties": { "principal": { "type": "string" }, "on_behalf_of": { "type": "string" }, "evidence_ref": { "type": "string" } } }
}
},
"outputSchema": {
"type": "object",
"properties": {
"change_request_id": { "type": "string" },
"state": { "enum": ["received", "rejected", "pending_approval", "pending_downstream", "applied", "partially_applied", "failed", "compensated"] },
"policy_decision": { "type": "string" },
"current_order_version": { "type": "integer" },
"downstream": { "type": "array", "items": { "type": "object", "properties": { "system": { "type": "string" }, "status": { "type": "string" }, "external_ref": { "type": "string" } } } }
}
},
"annotations": { "idempotentHint": true, "destructiveHint": true }
}
Some deliberate choices:
intent_idis required and generated by the agent runtime, not by the model. In my view, letting the model invent the key is how you get a fresh UUID on every retry. Have the host mint it when the user confirms the action and pass it in.- Same
intent_idwith different arguments returns a conflict, not a second change. That is the Stripe-style rule and it catches the “reformatted address” duplicate. - The tool returns quickly with a state. The 2025-11-25 spec also adds an experimental tasks utility for long-running requests; I would still keep the change request as your own durable object, because it has to outlive any single MCP session and be visible to humans.
get_order_changebychange_request_idorintent_idis the recovery path for unknown outcomes. After a timeout, the agent asks, it does not retry blind.
Downstream, the change request id becomes the idempotency key or external reference in each system: the WMS cancel request reference, the PSP refund idempotency key, the ERP credit memo external id. Where a system has no idempotency support, the integration layer checks for an existing record carrying that external id before writing. In NetSuite that typically means a custom field or external id on the transaction that you query before creating.
Agent host MCP server / change svc OMS WMS/3PL ERP / PSP
| request_order_change(intent_id=I) | | |
|------------------------------------>| dedupe on I | |
| | policy check | |
| | version check | |
|<---- {CR-1, pending_downstream} ----| | |
| |-- hold(CR-1)->| |
| | |-- cancel(CR-1)->|
| | |<- ack / reject -|
| |<- status -----| |
| |-- credit/refund(key=CR-1) ----------------> |
| |<------------------------------- ok -------- |
| get_order_change(CR-1) | state=applied | |
|------------------------------------>| | |
|<---- {CR-1, applied, refs} ---------| | |
Unknown outcomes and compensation
Each hop can time out. The rule is the same at every layer: a timeout is not a failure, it is “unknown”, and unknown is resolved by querying with the key, never by re-issuing a different request.
Compensation needs to be designed per change type, because many are not cleanly reversible:
- A WMS cancel that was acknowledged but the carton shipped anyway (it happens when the ack and the ship-confirm cross) turns into a return-to-sender or a customer return, and the refund you already issued now needs a different reconciliation entry.
- A reroute is two operations: cancel at the old node, allocate at the new one. Do them in that order and only after the cancel is acked. If you allocate first and the cancel fails, you ship twice. I would model reroute as a saga with explicit
partially_appliedstate rather than pretend it is atomic. - A shipping upgrade that succeeded at the WMS but failed incremental authorization leaves you choosing between eating the cost and reverting. Decide that in policy before it happens.
Concurrency: the warehouse does not wait
The agent reads the order, the user thinks for 40 seconds, the agent writes. In that window the WMS may have picked it. Two controls:
- Optimistic version check.
expected_order_versionmust match the OMS version at apply time, or the request is rejected with the current version and state. Bump the version on every warehouse event, not only on edits, otherwise the check is useless. If your OMS has no version field, use last-modified timestamp plus status as a weaker substitute. - A short hold as a lock. For changes that need downstream confirmation, the change service places an OMS-side hold first, so the order is not released while the change is in flight. Holds need a timeout and an owner (the change request id) or you will find orders stuck on hold for days with nobody knowing why.
Even with both, the WMS can win the race. Treat the WMS ack as authoritative for anything physical. If the WMS rejects because it already picked, the change request goes to rejected or pending_approval, and the agent tells the user the truth.
Audit: what to record per action
For every change request, I would store at minimum:
change_request_id,intent_id, timestamps for every state transition.- Principal chain: the human user, the agent or platform identity, the MCP client id and OAuth subject, and whether the agent acted on the user’s explicit instruction or on its own initiative.
- The raw tool call arguments and the normalized change, plus the order version read and the version at apply time.
- Policy decision and the rule version that made it (auto-apply, needs approval, rejected), and who approved if a human did.
- Every downstream call with its key, external reference, response and latency.
- Money impact: amounts voided, captured, refunded, with PSP references.
- For conversational agents, a pointer to the conversation turn that requested the change. Not necessarily the transcript itself, given privacy rules, but enough to retrieve it in a dispute.
The Business Central release plan mentions telemetry and auditing for MCP calls. Useful, but it records that a tool was called, not why. The business audit has to live in your change service.
The ERP sync boundary
This is where I have the strongest opinion. Before the order syncs to the ERP, the OMS is the source of truth for order content and the ERP just receives. After it syncs, you have two copies, and the agent’s change has to land in both in a defined direction.
My recommendation: pick one writer per lifecycle phase and enforce it.
- Order content (lines, quantities, address, ship method): the OMS owns it until shipment. Agent changes go to the OMS and flow to the ERP through your normal sync, tagged with the change request id. The agent should not have ERP write tools for sales order content at all. If you enable NetSuite or Business Central MCP write tools for other reasons, scope the role so sales order edits are excluded.
- Financial documents (invoices, credit memos, payments, refunds recorded): the ERP owns them. Agent-initiated refunds go through the change service, which calls the PSP and then posts the corresponding ERP document with the same key. The ERP never learns about a refund from a field edit.
- Physical state (picked, shipped, tracking): the WMS/3PL owns it, and both OMS and ERP follow.
The failure I see coming: someone wires a capable agent to both an OMS MCP server and an ERP MCP server, and it “fixes” an address in both directly. Now the sync job sees the ERP changed, maybe overwrites it back, maybe raises a conflict nobody reads. Two writers, no arbiter. Generic CRUD tools make this very easy to do by accident.
Checklist
- Agents change orders through one intent-level tool, not generic record updates.
-
intent_idis minted by the host on user confirmation and reused on every retry. - Same key with different arguments is rejected as a conflict.
- Every change type has a classification by WMS state and payment/ERP state, written down as policy with a version.
- Order version increments on warehouse events; writes require
expected_order_version. - In-flight changes hold the order, with hold owner and expiry.
- Timeouts resolve via
get_order_change, never via blind retry. - Change request id is propagated as the idempotency key or external id to WMS, PSP and ERP.
- Reroutes and other multi-step changes are sagas with explicit partial states and compensations.
- Money-moving changes have per-order and per-day ceilings for unattended agents.
- Agent roles in ERP MCP servers exclude sales order and invoice writes unless you have a specific reason.
- One writer per lifecycle phase for order content, financials and physical state.
- Audit record per change covers principal chain, policy decision, downstream refs and money impact.
What I could not settle
The Salesforce OMS MCP server is the one I most want to see documented. If its tools turn out to be intent-level (cancel, change address, reroute) with server-side idempotency and state checks, a lot of the layer above becomes thinner. If they are Flow or record wrappers, you need all of it. I will revisit when a tool reference is published. I also could not find a Shopify MCP surface for merchant-side order edits; if one exists, I would like to know about it.