shopify-operations-mcp
Safe-write Shopify operations with plan-before-execute workflows, out-of-band approval, and tamper-evident audit logging.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@shopify-operations-mcpPlan a price update for all products in the Sale collection."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Safe-write MCP server for Shopify Admin API operations
An agent can read and modify a Shopify store without being able to cause an unrecoverable accident. The safety layer is the differentiator: every write previews before it commits, large or irreversible changes require out-of-band human approval, and every action is recorded to a tamper-evident hash-chained audit file.
Architecture
flowchart TB
subgraph agent["Agent"]
A[Claude]
end
subgraph mcp["MCP stdio transport"]
T[tools/call]
end
subgraph core["safe-write-mcp-core"]
PS[PlanStore]
AS[ApprovalServer]
end
subgraph shopify["Shopify Admin API"]
GQL[GraphQL endpoint<br/>/admin/api/2026-04/graphql.json]
end
subgraph audit["Audit"]
LF[JSONL audit file<br/>hash-chained]
end
A -->|"MCP stdio"| T
T -->|create plan| PS
T -->|preview| PS
T -->|execute plan| PS
PS -->|awaiting_approval| AS
AS -->|approve/reject| PS
PS -->|execute| GQL
PS -->|record| LF
style PS fill:#e1f5fe
style AS fill:#fff3e0
style LF fill:#f3e5f5
style GQL fill:#e8f5e9The two-phase pattern (preview → token → execute) is the core discipline. Every write tool:
Preview — reads current state and computes what would change, performing zero mutation calls
Token — issues a plan token bound to the exact previewed manifest via a SHA-256 fingerprint
Approval — plans exceeding
approvalRequiredAboveItems(default 25) or containing always-gated operations wait for human approval at the token-bearing URL the server prints on startup (e.g.http://127.0.0.1:4319/?token=<token>)Execute — re-reads current values, refuses if they drifted from the preview (
STATE_CHANGED), then applies mutations per-item with a full success/failure ledger
A plan whose manifest exceeds hardMaxItems (default 250) is refused outright — no token, no approval path.
Irreversible operations (cancel_order, refund_order) always require approval regardless of item count and cannot be rolled back. Reversible operations (price changes, inventory adjustments) support rollback within a configurable window (default 24 hours).
Related MCP server: shopify-admin-mcp
Threat model
The risk is not a malicious agent — the agent is trusted to author correct GraphQL. The risk is a trusted-but-fallible agent: syntactically perfect, well-formed operations whose scope is the problem.
The killer scenario — a syntactically perfect bulk reprice with a misplaced decimal:
update_prices([...], newPrice: 1.5) ← meant 15.00, typed 1.5A 500-product bulk update that runs without preview-and-approve, or where the agent's price calculation contains a typo, produces exactly the wrong result at scale. Approval would catch it: a human sees "change 500 prices from $X to $1.50" and flags the不对劲. Without approval, or without the preview that makes the damage visible before it happens, the error lands silently in Shopify.
Three mechanisms carry the safety guarantee:
1. Preview-first, computed-diff. Every write tool reads current state and computes the manifest ({ref, before, after} pairs) without calling any mutation. A STATE_CHANGED re-read at execute time refuses the write if the world moved since preview. The blast radius is visible before anything changes.
2. Approval gating above the threshold. Plans touching >= approvalRequiredAboveItems items (default 25) require human approval. The threshold is sized for "is this large enough to warrant a human eye?" — meaningful for bulk value changes; irrelevant for one-item operations (which get unconditional approval for irreversible ops instead).
3. Plan token bound to exact manifest. The token is a SHA-256 fingerprint of the exact previewed manifest — not an opaque ID. Swapping in a wider set of items or a different price at execute time produces a different fingerprint and is refused as STATEMENT_MISMATCH.
Rollback provides recovery for reversible mistakes (wrong price, wrong inventory level) within the rollback window. It does not recover from the irreversible operations: a cancelled order stays cancelled, a refunded payment stays refunded.
Quick start
npm install
npm test
npm run buildSet the required environment variable and point Claude Desktop at the server (see Configuration below). node dist/index.js starts the localhost approval UI alongside the MCP stdio server.
Demo: the step-by-step walkthrough script (store-wide reprice refused → approval-gated reprice → one-call rollback → hash-chained audit) is in docs/demo-runbook.md.
Configuration
Configuration file (default config.json in the working directory, or path via SHOPIFY_CONFIG):
{
"shopify": {
"storeDomain": "my-store.myshopify.com",
"apiVersion": "2026-04"
},
"plans": {
"planTtlMs": 60000,
"approvalRequiredAboveItems": 25,
"hardMaxItems": 250,
"maxPriceChangePct": 30,
"rollbackTtlMs": 86400000
},
"approvalServer": {
"enabled": true,
"port": 4319,
"requireAuth": true
},
"protectedTags": ["do-not-touch"],
"callerId": "shopify-operations-mcp"
}Config reference
Field | Type | Default | Description |
|
| (required) | MyShopify domain, e.g. |
|
|
| Pinned quarterly Admin API version |
|
| (env only) | Admin API token — never in config file, only |
|
|
| How long a plan token stays valid (ms). Overridable: |
|
|
| Plans touching this many items require human approval. Overridable: |
|
|
| Plans exceeding this item count are refused outright. Overridable: |
|
|
| Price changes exceeding this % require approval. Overridable: |
|
|
| Rollback window (ms, default 24h). Overridable: |
|
|
| Start localhost approval UI alongside MCP server. Overridable: |
|
|
| Port for localhost approval UI (127.0.0.1 only). Overridable: |
|
|
| Require the per-session bearer token on every approval-server route. Set |
|
| (env only) | Explicit bearer token for the approval server — never in config file, only |
|
|
| Tags that plans may never modify. Overridable: |
|
|
| Identity recorded on every audit row. Overridable: |
Invariant: plans.hardMaxItems must be >= plans.approvalRequiredAboveItems. The loader throws if violated.
Environment variables
All config fields are overridable by environment variables (precedence: env > config file > default). SHOPIFY_ADMIN_TOKEN is required and only ever read from the environment.
Tools
Read tools
search_products
Search products by title, SKU, vendor, or tag. Returns products with variants, current prices, and per-location inventory levels.
Arguments:
Field | Type | Description |
|
| Matches products whose title contains the term (Shopify fuzzy search) |
|
| Matches products with a variant whose SKU equals the term |
|
| Matches products from this vendor |
|
| Matches products carrying this tag |
|
| Page size passed to Admin API (default 50) |
Returns: products[] with id, title, vendor, tags, variants (each with id, sku, price, inventoryItemId, inventoryLevels), plus flags.protected / flags.protectedTags indicating whether the product carries a protected tag.
Safety properties: Pure read — zero mutation calls. Protected-tagged products are returned (never filtered out) so a later write plan that touches them is refused.
list_orders
List orders filtered by financial status, fulfillment status, and date range.
Arguments:
Field | Type | Description |
|
|
|
|
|
|
|
| Orders created at or after this datetime |
|
| Orders created at or before this datetime |
|
| Page size (default 250) |
Returns: orders[] with id, name, financialStatus, fulfillmentStatus, totalPrice, lineItems[].
Safety properties: Pure read — zero mutation calls.
Write tools (two-phase)
All write tools go through preview → token → (approval) → execute.
update_inventory
Set absolute inventory quantities at a named location for multiple inventory items. Preview reads current levels; execute calls inventorySetQuantities.
Arguments:
Field | Type | Description |
|
|
|
|
| Each |
Safety properties:
Threshold gating: requires approval when
adjustments.length >= approvalRequiredAboveItems(default 25); refused outright when> hardMaxItems(default 250)Protected-tag enforcement: plans touching a product with a protected tag throw
PROTECTED_RESOURCEbefore a token is issued — no approval pathPer-item ledger: partial failure is recorded, never hidden
Rollback: supported — restores
before.availablequantities via the snapshot
cancel_order
Cancel a Shopify order. Always requires approval regardless of item count. Cannot be rolled back.
Arguments:
Field | Type | Description |
|
|
|
|
|
|
|
| Return items to inventory |
|
| Send cancellation email |
Safety properties:
Always approval:
alwaysRequireApproval: trueis hardcoded in the tool — approval thresholds are never consultedNo snapshot: no
snapshotStore.capture()call — rollback is not opened for this operationRollback: refused with
ROLLBACK_UNSUPPORTED— cancellation is a state transition, not a value change
refund_order
Refund a Shopify order. Always requires approval regardless of item count. Cannot be rolled back.
Arguments:
Field | Type | Description |
|
|
|
|
| Line items and quantities to refund; absent = full refund of all fulfilled items |
|
| Human-readable reason recorded in audit |
Each RefundLineItem: {lineItemId, quantity, restockType?} where restockType is "RETURN" \| "NO_RESTOCK" \| "CANCEL".
Safety properties:
Always approval:
alwaysRequireApproval: truehardcoded in the toolPreview via
refundCalculate: zero-write GraphQL call returns exact suggested refund amounts for the approval surfaceNo snapshot: no rollback support
PII-free audit: only order ID and refund amount are recorded; customer name/email are never in the audit trail
rollback_plan
Undo an executed reversible plan within the rollback window (default 24 hours).
Arguments:
Field | Type | Description |
|
| The token from the executed plan to roll back |
Safety properties:
No approval required: restoring the prior state is the safe direction
Window guard:
ROLLBACK_WINDOW_EXPIREDwhen the snapshot has expired or the plan was never previewedKind guard:
ROLLBACK_UNSUPPORTEDwhen the plan kind iscancel_orderorrefund_orderInverse mutations only on refs that succeeded at execute time — a ref that failed is left untouched
Per-item ledger: partial rollback failure is recorded honestly
Plan lifecycle
Agent calls preview tool
│
▼
Manifest built
(pure reads, zero writes)
│
▼
Item count checked
│
├─── <= hardMaxItems ──► token issued
│ │
│ >= approvalRequiredAboveItems
│ │ or alwaysRequireApproval
│ ▼
│ status: "awaiting_approval"
│ │
│ human approves
│ │
▼ │
HARD_MAX_ITEMS_EXCEEDED │
(no token, refused) ▼
execute_plan
│
STATE_CHANGED check
(re-read, compare digest)
│
┌────┴────┐
success failure
│ │
per-item per-item
ledger ledger
│
snapshot stored
for rollbackLocalhost approval UI
A plain-HTML page for a human to approve or reject plans above the threshold. Runs as its own local-only HTTP server, started alongside the MCP server.
Access: the token-bearing URL the server prints on startup (e.g.
http://127.0.0.1:4319/?token=<token>) on the machine running the server. Unreachable from other machines.Auth: every route — including the read-only GET ones — requires a per-session bearer token (
Authorization: Bearer <token>header, or the?token=query fallback the printed URL uses). The token is generated per start unlessSHOPIFY_APPROVAL_SERVER_AUTH_TOKENsets a stable one; scripted callers pass it as a header (curl -H "Authorization: Bearer $TOKEN" ...). Prefer the env token for scripted or long-lived use rather than scraping the startup log — stderr is routinely captured to log files, where the credential would persist. Loopback binding plus Host/Origin checks stop a hostile browser page, but only the bearer token stops another local process that knows a plan token. SetapprovalServer.requireAuth: falseto fall back to pre-0.4.0 behavior (not recommended).API:
GET /api/plansreturns pending plans as JSON;POST /api/plans/:token/approveandPOST /api/plans/:token/rejecthandle approval.Security boundary: approval/rejection is never exposed as an MCP tool — the agent cannot approve its own plans.
Audit log
Every preview, approval, execution, rejection, and refusal writes one JSON object to the audit file:
{"seq":1,"prev_hash":"0000...","hash":"ab12...","ts":1734567890000,"tool":"update_inventory","reason":"adjusting stock","planToken":"abc123","status":"executed","previewCount":10,"callerId":"shopify-ops","durationMs":234,"detail":"all 10 item(s) executed"}seq— monotonically increasing per-file sequenceprev_hash— SHA-256 of the previous row (genesis = 64 zero chars)hash— SHA-256 of this row (excluding thehashfield itself)Tamper-evident, not tamper-proof: editing, reordering, or deleting a non-terminal row breaks the chain at that row. Suffix truncation or replacing the whole file with a valid chain is not detectable from the file alone.
PII defense-in-depth: top-level keys matching
/customerEmail|customerName/iare stripped before hashing. Free-textreason/detailstrings are not scanned — hosts must sanitize those before callingrecord().Restart safety: on open, the sink verifies the existing chain and resumes
seq/prev_hashfrom the last line.
Verify the chain with scripts/verify-audit.ts.
Dev-store seeder
scripts/seed-store.ts generates a realistic store to point the server at — deterministic, like sw-postgres-mcp's seeder. Everything is derived from a seeded PRNG (mulberry32), so two runs with the same seed produce identical data and identical counts.
Run it:
SHOPIFY_STORE_DOMAIN=my-dev.myshopify.com SHOPIFY_ADMIN_TOKEN=shpat_... npm run seed -- --seed 42The seeder reuses the server's loadConfig, so SHOPIFY_STORE_DOMAIN / SHOPIFY_ADMIN_TOKEN / SHOPIFY_API_VERSION (and any config file) are honored exactly as for the server; --seed defaults to 42. Flags:
--seed <number>— PRNG seed; default42. Any two runs with the same seed are identical.--dry-run— prints the plan counts and verifies the sizing invariants without making any API call (no credentials needed).--order-delay-ms <ms>— sleep between order creates. Development stores caporderCreateat five per minute, so plan ~24 minutes for the 120 orders; default is no delay.
Data shape (seed 42):
Resource | Count | Notes |
Products | 300 | titled |
Variants | 768 | 1–4 per product, SKUs |
Locations | 2 | the first two locations in the store (locations are physical, not created) |
Customers | 20 |
|
Orders | 120 | Bogus-Gateway test orders ( |
Order-state mix: 116 paid / 4 pending; 40 fulfilled / 80 unfulfilled; 12 carry a fixed-amount discount code. Every product, customer, and order is tagged seeded-store.
Sizing invariants (asserted against the loaded config before any API call — the seeder fails fast if they don't hold):
The full variant set (768) exceeds
hardMaxItems(default 250), so a store-wide reprice is refused (HARD_MAX_ITEMS_EXCEEDED).The
saletag covers 156 variants — betweenapprovalRequiredAboveItems(default 25) andhardMaxItems(250) — so a reprice scoped totag:'sale'requests approval but is not refused.
The structural sizing is seed-independent: every 5th product (60 of 300) carries sale, and each product has 1–4 variants, so the sale tag always covers between 60 and 240 variants.
Idempotency: a re-run first wipes every previously-seeded order, customer, and product tagged seeded-store (orders first — customers can only be deleted once their orders are gone), then regenerates. Products delete their variants and inventory items with them; the two locations are reused, never deleted. The script prints counts at the end so you can diff two runs.
Live integration suite
tests/integration/ is a manual-only, env-gated suite that proves the server against the real Admin API and the seeded dev store. It is deliberately kept out of CI: it needs real store credentials (secrets must never reach CI) and it makes rate-limited calls that would flake.
Run it:
SHOPIFY_STORE_DOMAIN=my-dev.myshopify.com SHOPIFY_ADMIN_TOKEN=shpat_... npm run seed -- --seed 42
SHOPIFY_STORE_DOMAIN=my-dev.myshopify.com SHOPIFY_ADMIN_TOKEN=shpat_... npm run test:integrationRe-seed before each run so the destructive tests find fresh candidate orders. With no credentials, the whole suite skips itself with a console note and exits 0 — npm run test:integration and the default npm test both pass as a no-op, and it never runs on CI.
What it covers (each file is describe.skip-gated unless SHOPIFY_STORE_DOMAIN and SHOPIFY_ADMIN_TOKEN are set):
File | Proves |
|
|
|
|
|
|
|
|
| DESTRUCTIVE — exactly one real |
| Cost-aware throttling: a parallel burst exceeding the API cost budget is absorbed by the default client (backoff); a no-retry client surfaces |
Files run serially (fileParallelism: false) because the suite shares one mutable store — destructive writes must never race the read counts. Config: vitest.integration.config.ts.
Limitations
Stated plainly, not hidden:
Order cancels/refunds are irreversible. Approval protects them — it does not enable rollback. A cancelled order cannot be uncancelled; a refunded payment cannot be unwired. RollbackPlan refuses
cancel_orderandrefund_ordertokens withROLLBACK_UNSUPPORTED.Rollback is best-effort snapshot restoration. The snapshot captures the before-state at preview time; it is only usable as an inverse-mutation target while the world hasn't changed. After
rollbackTtlMs(default 24h), rollback is refused withROLLBACK_WINDOW_EXPIRED. Rollback restores values, not external side effects (e.g., a refund notification already sent).Partial failure leaves a per-item ledger, not an exception. When a batch mutation fails for some items and succeeds for others, the executor records each outcome honestly. There is no all-or-nothing rollback across items — only per-item inverse mutations at rollback time for the items that succeeded.
Plan state is in-memory and process-scoped. The PlanStore and SnapshotStore hold all pending and executed plan state in process memory. A server restart loses every pending plan (re-preview required) and every rollback window. The audit log is the durable record of what happened.
Single store, no multi-tenancy. One server process talks to one Shopify store. Running for multiple stores means running multiple server instances with separate credentials and audit files.
No per-user auth.
callerIdidentifies the deployment (default"unknown"), not an individual person. There is no per-MCP-session or per-user authentication in v1. Anyone who can reach the server's stdio transport can use it with the configured store credentials; anyone holding the approval-server bearer token (or reaching the UI with auth disabled) can approve plans.STATE_CHANGEDis a pre-write drift check, not a universal compare-and-swap. The re-read catches drift that exists before the mutation is sent. It does not close the window between re-read and write. Shopify exposes provider-level compare-and-swap for some operations (e.g.changeFromQuantityfor inventory) but not all (plain price updates are last-write-wins).
License
This server cannot be deployed
Maintenance
Related MCP Connectors
MCP server for mandates, delegation, policy-gated execution, credential grants, and audit.
Guarded MCP server for agent-readable business truth, provenance, readiness, and discovery.
MCP server for Boson Protocol — on-chain agentic commerce for physical & digital goods.
MCP server for Modern Treasury — payment orders, transactions, counterparties and ledgers.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceMCP server exposing Shopify commerce backend with ~22 typed tools for orders, inventory, logistics, and fulfillment, including read/write separation and structured errors.-
- AlicenseNot gradedqualityDmaintenanceProduction-grade MCP server for the Shopify Admin GraphQL API, exposing typed tools for AI agents to manage products, orders, customers, and more.13MIT
- AlicenseAqualityAmaintenanceA read-only MCP server that exposes the full Shopify Admin GraphQL API through 6 universal tools, with multi-store support and mutation rejection at the parser level for safety.6MIT
- AlicenseCqualityDmaintenanceMCP server for Shopify Admin API. Enables product, order, customer, and inventory management via natural language.14191MIT