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:

  1. 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.
  2. 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.
  3. 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_status or woocommerce_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 preconditions to 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 

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.