Sign In

shopify-operations-mcp

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

shopify-operations-mcp

Safe-write Shopify operations MCP server: plan-before-execute writes with out-of-band approval and audit

latest
Source
npmnpm
Version
0.1.2
Version published
Maintainers
1
Created
Source

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:#e8f5e9

The 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 http://127.0.0.1:4319/
  • 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).

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.5

A 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 build

Set 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
  },
  "protectedTags": ["do-not-touch"],
  "callerId": "shopify-operations-mcp"
}

Config reference

FieldTypeDefaultDescription
shopify.storeDomainstring(required)MyShopify domain, e.g. "my-store.myshopify.com"
shopify.apiVersionstring"2026-04"Pinned quarterly Admin API version
shopify.adminTokenstring(env only)Admin API token — never in config file, only SHOPIFY_ADMIN_TOKEN env var
plans.planTtlMspositive int60000How long a plan token stays valid (ms). Overridable: SHOPIFY_PLAN_TTL_MS
plans.approvalRequiredAboveItemspositive int25Plans touching this many items require human approval. Overridable: SHOPIFY_APPROVAL_REQUIRED_ABOVE_ITEMS
plans.hardMaxItemspositive int250Plans exceeding this item count are refused outright. Overridable: SHOPIFY_HARD_MAX_ITEMS
plans.maxPriceChangePctpositive int30Price changes exceeding this % require approval. Overridable: SHOPIFY_MAX_PRICE_CHANGE_PCT
plans.rollbackTtlMspositive int86400000Rollback window (ms, default 24h). Overridable: SHOPIFY_ROLLBACK_TTL_MS
approvalServer.enabledbooleantrueStart localhost approval UI alongside MCP server. Overridable: SHOPIFY_APPROVAL_SERVER_ENABLED
approvalServer.portpositive int4319Port for localhost approval UI (127.0.0.1 only). Overridable: SHOPIFY_APPROVAL_SERVER_PORT
protectedTagsstring[]["do-not-touch"]Tags that plans may never modify. Overridable: SHOPIFY_PROTECTED_TAGS (comma-separated)
callerIdstring"unknown"Identity recorded on every audit row. Overridable: SHOPIFY_CALLER_ID

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:

FieldTypeDescription
titlestring?Matches products whose title contains the term (Shopify fuzzy search)
skustring?Matches products with a variant whose SKU equals the term
vendorstring?Matches products from this vendor
tagstring?Matches products carrying this tag
firstpositive int?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:

FieldTypeDescription
financialStatusFinancialStatus?"pending" | "authorized" | "partially_paid" | "paid" | "partially_refunded" | "refunded" | "voided"
fulfillmentStatusFulfillmentStatus?"fulfilled" | "partial" | "unfulfilled"
createdAfterISO-8601 string?Orders created at or after this datetime
createdBeforeISO-8601 string?Orders created at or before this datetime
firstpositive int?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:

FieldTypeDescription
locationIdstringgid://shopify/Location/… id
adjustmentsInventoryAdjustment[]Each {inventoryItemId, quantity} sets the available quantity at locationId

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_RESOURCE before a token is issued — no approval path
  • Per-item ledger: partial failure is recorded, never hidden
  • Rollback: supported — restores before.available quantities via the snapshot

cancel_order

Cancel a Shopify order. Always requires approval regardless of item count. Cannot be rolled back.

Arguments:

FieldTypeDescription
orderIdstringgid://shopify/Order/… id
reasonstring"customer" | "inventory" | "fraud" | "other"
restockbooleanReturn items to inventory
notifyCustomerbooleanSend cancellation email

Safety properties:

  • Always approval: alwaysRequireApproval: true is hardcoded in the tool — approval thresholds are never consulted
  • No snapshot: no snapshotStore.capture() call — rollback is not opened for this operation
  • Rollback: 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:

FieldTypeDescription
orderIdstringgid://shopify/Order/… id
refundLineItemsRefundLineItem[]?Line items and quantities to refund; absent = full refund of all fulfilled items
reasonstringHuman-readable reason recorded in audit

Each RefundLineItem: {lineItemId, quantity, restockType?} where restockType is "RETURN" \| "NO_RESTOCK" \| "CANCEL".

Safety properties:

  • Always approval: alwaysRequireApproval: true hardcoded in the tool
  • Preview via refundCalculate: zero-write GraphQL call returns exact suggested refund amounts for the approval surface
  • No 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:

FieldTypeDescription
planTokenstringThe 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_EXPIRED when the snapshot has expired or the plan was never previewed
  • Kind guard: ROLLBACK_UNSUPPORTED when the plan kind is cancel_order or refund_order
  • Inverse 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 rollback

Localhost 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: http://127.0.0.1:4319/ (or configured approvalServer.port) on the machine running the server. Unreachable from other machines.
  • API: GET /api/plans returns pending plans as JSON; POST /api/plans/:token/approve and POST /api/plans/:token/reject handle 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 sequence
  • prev_hash — SHA-256 of the previous row (genesis = 64 zero chars)
  • hash — SHA-256 of this row (excluding the hash field 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/i are stripped before hashing. Free-text reason/detail strings are not scanned — hosts must sanitize those before calling record().
  • Restart safety: on open, the sink verifies the existing chain and resumes seq/prev_hash from 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 42

The 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; default 42. 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 cap orderCreate at five per minute, so plan ~24 minutes for the 120 orders; default is no delay.

Data shape (seed 42):

ResourceCountNotes
Products300titled Seeded Product 1…300, spread across 8 vendors and 8 product types
Variants7681–4 per product, SKUs SEED-<product>-<variant>, prices $5–$300
Locations2the first two locations in the store (locations are physical, not created)
Customers20seed-customer-N@example.com
Orders120Bogus-Gateway test orders (test: true, inventory bypassed)

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 sale tag covers 156 variants — between approvalRequiredAboveItems (default 25) and hardMaxItems (250) — so a reprice scoped to tag:'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:integration

Re-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):

FileProves
readTools.test.tssearch_products by vendor/tag/sku/title, first-page pagination, protected-tag flags (seeded products are all unprotected); list_orders financial/fulfillment filters. Product counts are exact seed-42 constants; order counts are tolerant bands (this suite's own destructive tests consume them)
updatePrices.test.tsupdate_prices two-phase against real variants: preview → execute a small change → verify → roll back via rollback_plan → verify restored
updateInventory.test.tsupdate_inventory two-phase against a real inventory item + location: preview → execute a +1 change → verify → roll back → verify restored
createDiscount.test.tscreate_discount two-phase: preview → create → verify active → deactivate via rollback
cancelRefund.test.tsDESTRUCTIVE — exactly one real cancel_order and one real refund_order, preview → approve → execute, against paid/unfulfilled seeded orders discovered at runtime, asserting the audit-consistent success ledger
throttle.test.tsCost-aware throttling: a parallel burst exceeding the API cost budget is absorbed by the default client (backoff); a no-retry client surfaces ShopifyApiError SHOPIFY_THROTTLED instead of an unknown error

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_order and refund_order tokens with ROLLBACK_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 with ROLLBACK_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. callerId identifies 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 (or 127.0.0.1:4319 for approvals) can use it with the configured store credentials.

  • STATE_CHANGED is 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. changeFromQuantity for inventory) but not all (plain price updates are last-write-wins).

License

MIT

Keywords

mcp

FAQs

Package last updated on 17 Aug 2026

Related posts