Reading data is the easy part of connecting an agent to a store. Listing orders, summarizing sales, finding low stock: if the model gets one of those wrong, you get a wrong answer and you ask again. Writes are different. "Raise the price of these 40 products by 10%" is one sentence for the user and 40 irreversible changes for the store.
I maintain woocommerce-mcp, an open-source Model Context Protocol server for WooCommerce. It exposes products, orders, refunds, coupons, customers, stock, webhooks, and analytics as MCP tools, over stdio or authenticated streamable HTTP. This post is about one part of it: how the write tools are designed so that an agent cannot change a store in a single step, and what changed in v0.5.0 when I added stale preview detection.
I will also be explicit about what the design does not guarantee, because a safety feature that overstates itself is worse than none.
The problem with one-step write tools
The obvious way to write an MCP tool is: take arguments, call the API, return the result. For a write, that means the model's tool call is the change. There is no point where a human sees what is about to happen.
There are three separate failure modes hiding in that:
- The model asks for the wrong thing. It picks the wrong product id, misreads "10" as a fixed amount instead of a percentage, or targets the wrong store.
- The arguments change between "I showed the user" and "I ran it". A client shows a plan, the user approves, and then the model retries with slightly different arguments.
- The store changes underneath the plan. The user approves "+10%" when a product costs 20.00. Before they click confirm, someone else edits the price to 25.00. The same arguments now produce 27.50, a number nobody approved.
The first one is solved by showing the user the exact request. The second and third need something that ties the confirm to the preview. That is what the rest of this post is about.
Layer 1: stores are read-only until you say otherwise
Every store starts read-only. A write tool fails with read_only before it contacts the store unless you set WOOCOMMERCE_READ_ONLY=false (single store) or "readOnly": false in a store list. Over HTTP, write tools also need the woocommerce:write scope, so a read-only token can never change anything.
This sounds trivial, but it decides the default. Someone who installs the server to ask "what sold best last week?" never has to think about writes at all.
Layer 2: two calls instead of one
With WOOCOMMERCE_REQUIRE_CONFIRMATION=true (the default), the first call to a write tool changes nothing. It returns the planned REST request:
{
"status": "confirmation_required",
"store": "default",
"previewId": "pv_4f1c9a2e7b3d0856",
"message": "The change was NOT applied. ...",
"request": { "method": "PUT", "path": "orders/1015", "query": {}, "body": { "status": "completed" } }
}
Calling the same tool again with the same arguments and confirm: true applies it. dryRun: true always previews, even if confirmation is turned off.
The interesting engineering question is how you produce that preview without writing a second, parallel "describe what you would do" implementation for every tool. Two implementations drift. I did not want 19 write tools each with a real path and a preview path.
The planning client
Every tool talks to the store through a WooCommerceClient. The client has a planning() view that behaves normally for reads and throws on the first write instead of sending it:
/**
* Returns a client that performs reads normally but stops at the first write
* and throws a {@link PlannedWrite} describing the request it would send.
*/
planning(options: PlanningOptions = {}): WooCommerceClient {
return new WooCommerceClient(this.options, this.hooks, true, this.shared, this.namespace, options);
}
Inside request(), a non-GET on a planning client becomes a PlannedWrite error carrying method, path, query, and body. The registry runs the tool's real run() function against that client and catches the error:
async function plan<S extends AnyObject>(
spec: ToolSpec<S>,
ctx: ToolContext,
client: WooCommerceClient,
args: z.infer<S>,
): Promise<Plan> {
const reads: RecordedRead[] = [];
const planner = client.planning({ fresh: true, onRead: (read) => reads.push(read) });
try {
const result = await spec.run({ ...ctx, client: planner }, args);
return { write: null, result, state: captureState(reads, null) };
} catch (error) {
if (!(error instanceof PlannedWrite)) throw error;
return { write: error.request, result: null, state: captureState(reads, error.request) };
}
}
So the preview is produced by the same code that does the write. If bulk_adjust_prices computes regular_price from the current price, the preview contains the computed absolute value, because it is literally the body the tool tried to send. If the tool finishes without trying to write (for example every product was skipped), the response is no_change instead of a confirmation request.
The trade-off: a tool that makes several requests, such as woocommerce_fulfill_order (record tracking, complete the order, notify the customer), is previewed only up to its first write. I document that rather than pretend the preview covers the whole sequence.
Layer 3: the previewId
Two calls are not enough on their own. Nothing stops the confirm call from carrying different arguments than the preview. So in v0.4.0 every confirmation_required, dry_run, and no_change response started including a previewId:
export function previewIdFor(tool: string, store: string, args: Record<string, unknown>): string {
const digest = createHash('sha256').update(`${tool}\n${store}\n${canonicalJson(args)}`).digest('hex');
return `pv_${digest.slice(0, 16)}`;
}
canonicalJson sorts object keys and drops undefined members, so { "id": 1, "status": "x" } and { "status": "x", "id": 1 } hash the same. store, dryRun, confirm, and previewId itself are excluded from the hash.
On a confirm call that sends previewId, the server recomputes the id from the call's own tool, store, and arguments. If it differs, the call is refused with preview_mismatch and nothing is sent. This check reads nothing from the store, so it is cheap and runs first.
Why a fingerprint and not a token
I deliberately did not make previewId a random one-time token stored on the server. A fingerprint has properties I wanted:
- No server state. The server keeps no table of issued previews. It survives restarts, works across HTTP sessions, and works the same with one replica or several.
- Deterministic. The same call always gets the same id, which makes audit logs easy to join and tests easy to write.
It also has costs, and the docs list them: the server does not check that a preview was actually requested, how old it is, or whether it was already used. It is a fingerprint, not a secret. If you need single use approvals, you need state, and that is a different design.
previewId is also optional. A confirm without it still works, because breaking every existing client to add a safety check is a good way to make people turn the check off.
Layer 4: stale previews (new in v0.5.0)
The previewId in v0.4.0 answered "did the caller change what it asked for?". It did not answer failure mode 3: "did the store change?". If a price moves from 20.00 to 25.00 between preview and confirm, the arguments { mode: "percent", change: 10 } are identical, so the arguments fingerprint matches, and the server would happily write 27.50.
v0.5.0 adds a second part to the id for tools that read before they write:
pv_e2a742fe51c807fc_a3510d17b8a51b93
|<-- arguments -->| |<-- state -->|
Recording what the plan read
The planning client now runs with fresh: true (it bypasses the read cache) and an onRead callback that records every successful read. After planning, captureState fingerprints those reads. For every record returned it keeps the id, the date_modified, the fields the write sets, and a fixed list of fields writes are commonly computed from: status, type, price, regular_price, sale_price, the sale dates, manage_stock, stock_quantity, stock_status, and total. Payloads that are not records are hashed whole.
entries.push({ resource, dateModified, values });
// ...
const stateId = createHash('sha256').update(canonicalJson(fingerprint)).digest('hex').slice(0, 16);
Why not hash the whole response? Because WooCommerce responses contain fields that change for reasons unrelated to the write, and a state id that changes on every request would make every confirm stale. Hashing only what the write could plausibly depend on, plus date_modified as a catch-all, keeps false positives low without ignoring real changes.
The preview also shows those records, with before and after values, so "+10%" is no longer abstract:
"preconditions": {
"stateId": "a3510d17b8a51b93",
"recordCount": 1,
"records": [
{
"resource": "products/42",
"dateModified": "2026-10-30T09:12:44",
"before": { "regular_price": "20.00" },
"after": { "regular_price": "22.00" }
}
],
"note": "A confirm with this previewId is refused with stale_preview if these records change before it is applied."
}
Up to 50 records are listed, with truncated: true beyond that. The state id always covers all of them.
Checking at confirm time
When a confirm sends a two-part id and the arguments part matches, the registry plans the write again on fresh reads and compares state ids:
if (supplied?.stateId) {
// Re-run the plan on fresh reads and compare the state it was computed from.
const { state } = await plan(spec, ctx, handle.client, args);
const currentStateId = state?.stateId ?? null;
link = { ...link, stateMatch: currentStateId === supplied.stateId };
if (!link.stateMatch) {
throw new StalePreviewError(spec.name, handle.config.id, { /* both ids and current records */ });
}
}
const result = await spec.run(ctx, args);
A mismatch returns stale_preview with both state ids and the current values, so a client can show the user what changed:
{
"error": {
"code": "stale_preview",
"details": {
"previewStateId": "a3510d17b8a51b93",
"currentStateId": "8bed7e78cf74f185",
"current": [
{ "resource": "products/42", "before": { "regular_price": "25.00" }, "after": { "regular_price": "27.50" } }
]
}
}
}
The server instructions tell clients what to do with it: request a fresh preview, show it again, and confirm with the new id.
Two error codes on purpose
preview_mismatch and stale_preview mean different things and I kept them separate. A mismatch means the caller changed what it asked for, which usually points at a client bug or a model retrying with different arguments. A stale preview means the world changed, which is normal in a busy store and just needs a re-preview. Arguments are checked first, so a call with both problems reports preview_mismatch.
Layer 5: the audit log
Set WOOCOMMERCE_AUDIT_LOG to a file path or stderr and every write attempt becomes one JSON line, with an outcome of applied, dry_run, confirmation_required, refused, or failed:
{"time":"2026-10-30T14:02:11.120Z","tool":"woocommerce_update_order_status","store":"default","outcome":"applied","arguments":{"id":1015,"status":"completed"},"client":"static:3f9a1c2b7d10","previewId":"pv_4f1c9a2e7b3d0856","previewMatch":true}
Because the preview entry and the applied entry share a previewId, you can answer "who approved this change, and what did they see?" with a grep. When the id had a state part, the entry also has stateMatch, and a stale_preview refusal is logged as refused with a stale object holding both state ids.
What this is not: it is not a lock
This is the part I most want people to read. The WooCommerce REST API has no conditional writes. There is no If-Match header I can send that makes the store reject a write if the record changed. So the sequence is:
confirm arrives
-> re-plan on fresh reads (GET products?include=42)
-> compare state ids (match)
-> send the write (POST products/batch)
A change that lands between the fresh read and the write is not detected. The check narrows the window from "whenever the user gets around to approving" (seconds to hours) to the length of a round trip. It does not close it.
Other limits, all in the safe writes guide:
-
Only what the plan read is covered. Tools that write without reading, such as
woocommerce_update_order_statusorwoocommerce_create_product, keep the one-part id and behave as in v0.4.0. -
It is opt-in per call. A confirm without
previewId, or with a one-part id, is not checked for staleness. Clients that want the guarantee have to send the id back. - Multi-request tools are checked against the reads before their first write.
- The re-plan costs a read. A confirm with a two-part id makes the same GETs as the preview. For a 100-product batch that is one extra list request, which I think is a fair price.
Testing it
The server has 142 tests (npm test runs them with Node's built-in test runner against a mocked WooCommerce API). The stale preview tests use a one-product store whose price the test can change between calls:
it('shows absolute before and after values and a state part in the previewId', async () => {
const store = mockStore();
const runtime = testRuntime(store.fetchImpl, writable);
const preview = parse(await runTool(runtime, adjustSpec, raise));
assert.equal(preview.status, 'confirmation_required');
assert.match(String(preview.previewId), /^pv_[0-9a-f]{16}_[0-9a-f]{16}$/);
// ...
});
The pattern that matters is preview, mutate the mock, confirm, and assert that no write request reached the mock.
What's next
- More tools on the state check. Anything that reads before writing benefits, so new write tools should be planned through the same client from the start.
- Optional single-use approvals for deployments that are happy to keep state, layered on top of the fingerprint rather than replacing it.
-
Better client guidance. The protocol gives me tool descriptions and server instructions, but how a host renders
preconditionsto a human is up to the host. I want to document a reference rendering.
If you are building MCP tools for any system with writes, I think the general pattern carries over: produce the preview from the same code path as the write, fingerprint both the request and the data it was computed from, give the two failures different names, and write down exactly where the guarantee stops.
On the search side of commerce, I also maintain llm-search-eval, which asks a different question: is a ranking change actually better, or just noise?
I wrote it up in Is your new search actually better?.
Try it
npx -y @sadrmanesh/woocommerce-mcp
Or with Docker:
docker run --rm -i -e MCP_TRANSPORT=stdio \
-e WOOCOMMERCE_URL -e WOOCOMMERCE_CONSUMER_KEY -e WOOCOMMERCE_CONSUMER_SECRET \
ghcr.io/sadrmanesh/woocommerce-mcp:0.5.0
- Repo: https://github.com/sadrmanesh/woocommerce-mcp
- Docs: https://sadrmanesh.github.io/woocommerce-mcp/
- npm: https://www.npmjs.com/package/@sadrmanesh/woocommerce-mcp
It is MIT licensed. Issues and feedback on the stale preview design are very welcome, especially from anyone who has found a way to get conditional writes out of WooCommerce.
Top comments (7)
One part of this design I'd steal: the state id covers the reads the plan was computed from, not just the arguments. That's the half of two-step approval most implementations skip — and the one that actually answers your failure mode 3. The 'what this is not' section is doing real work too; few writeups admit the check-to-use window out loud.
The piece I keep circling: the fingerprint binds the what across the two calls — arguments in v0.4, store state in v0.5 — but nothing binds the who. The server can't distinguish a confirm that came from a human who read the before/after from one issued by the same agent in a chained turn. The two-step flow is enforced by host convention ('server instructions tell clients what to do'), not by the server. Is that where single-use approvals are headed — proof of a human in the loop, like elicitation per confirm or a signed approval the server verifies?
Quick follow-up @techdevfun: this is now shipped in v0.6.0. With
WOOCOMMERCE_APPROVAL_SECRETset, every confirm needs a single-use HMAC token bound to the full previewId (arguments plus store state), the tool, the store and a short expiry. A person signs it out of band withwoocommerce-mcp approve <previewId>, which shows the before/after first, and the agent never sees the key. Missing, expired, replayed or mismatched tokens are refused with their own error codes, and the result lands in the audit log.Release notes: github.com/sadrmanesh/woocommerce-...
Design and limits: sadrmanesh.github.io/woocommerce-m...
Thanks again, your comment turned into the release.
Thanks @techdevfun , this is exactly the gap. You're right: v0.5 binds the what (arguments plus the state the plan was computed from), but not the who. Today the server can't tell a human-read confirm from the same agent chaining preview and confirm in one turn, so the two-step flow is a host convention, and the audit log is the backstop rather than the gate.
The direction I'm leaning toward is an opt-in mode where confirm only succeeds through a channel the agent can't fill in itself: MCP elicitation per confirm, showing the before/after diff, when the client supports it, and otherwise an approval token signed by something outside the model's context (a small approval UI or webhook holding a key the agent never sees). The previewId would be part of what gets signed, so an approval can't be replayed on a different plan or a changed store state.
Elicitation alone still trusts the client to render it honestly, so I'd treat it as "a human was asked" rather than "proof of a human." Signed approval is a great idea and I'd welcome it as the next step for the project. Thanks for pushing on this.
Stale previews are the subtle failure most approval designs miss. A preview should have a versioned snapshot or precondition, so confirmation fails rather than applying an old plan to newly changed state. That one rule turns preview/confirm from UX theater into a meaningful transactional boundary.
Thanks, Alex, "UX theater" is exactly the failure I wanted to avoid. That's how it works since v0.5.0: the previewId carries a fingerprint of the store state the plan was computed from, and confirm re-checks it, refusing with stale_preview instead of applying the old plan. The remaining gap is that WooCommerce has no conditional writes, so it's a precondition check, not a true transaction. In 0.6.0 I also added optional signed, single-use approvals, so the preview binds both what is confirmed and who confirmed it.
A preview is only as fresh as its last check. Does the stale-preview check run again at confirm time?
Yes, it does. Since v0.5.0, tools that compute a write from store data (bulk_adjust_prices, schedule_sale, end_sale, fulfill_order) return a previewId that includes a hash of the records the plan was built from. On confirm, the server re-reads those records and re-runs the plan; if the hash no longer matches, the call fails with stale_preview, nothing is written, and the client re-previews. Honest caveat: WooCommerce's REST API has no conditional writes, so this narrows the race to milliseconds rather than locking it.