shopify-multistore-mcp
by Cubitrek
README.md
# shopify-multistore-mcp
An MCP server for working across **multiple Shopify stores** (multi-client dev work)
through the **Admin GraphQL API**. Every tool takes a `store` argument that routes
the call to the right client store, so one connector covers all your stores.
Designed to sit **alongside** Shopify's official tooling, not replace it:
| Layer | What it does | You build it? |
|-------|--------------|---------------|
| **Shopify AI Toolkit / Dev MCP** | Theme, apps, Functions, extensions, docs + GraphQL validation | No — install it |
| **Shopify CLI** | `theme dev`, app dev, Functions deploy (per-store auth) | No — install it |
| **This server** | Conversational Admin API read/write **across** stores, with a production write-gate | Yes — this repo |
## Quick start
```bash
git clone https://github.com/Cubitrek/shopify-multistore-mcp.git
cd shopify-multistore-mcp
npm install
npm run build
cp stores.example.json stores.json # then edit — add your stores
```
Then, per store, [get an access token](#getting-an-access-token) and
[connect it to Claude](#connect-it-to-claude). Check your config loaded:
```bash
STORES_CONFIG=./stores.json node -e \
'import("./dist/config.js").then(m=>console.table(m.loadRegistry().list()))'
```
Every store shows `authenticated: true` or `false`, so you can see at a glance
which ones still need a token.
## Tools
**Reads:** `list_stores`, `shop_info`, `get_products`, `get_orders`, `get_metafields`, `get_pages`
**Writes (production-gated):** `set_metafields`, `add_tags`, `remove_tags`,
`create_product`, `update_product`, `create_page`, `update_page`
**Escape hatch:** `graphql` — run any Admin GraphQL query or mutation
### The production write-gate
Each store is tagged `env: "production"` or `env: "dev"`.
- Mutations against a **dev** store run freely.
- Mutations against a **production** store are **blocked** unless the call passes
`confirm: true`. The model must inspect what will change, then explicitly confirm.
Failed writes surface their `userErrors`, so a rejected mutation can never look
like a success.
## Getting an access token
Which path you need depends on the **store's relationship to your Partner
organization** — not on how old the store is. Check this first; picking wrong
costs an hour of dead ends:
| The store is… | Where you see it | Path |
|---|---|---|
| A merchant's own store you have collaborator access to, or your own live store | Dev Dashboard → Stores → **Collaborations** | **Path A** — OAuth capture |
| A **client-transfer** store still held in your Partner org | Dev Dashboard → Stores → **Client transfer** | **Path B** — legacy custom app |
| Already has a legacy custom app from before 2026 | Store admin → Settings → Apps | Reuse it — copy the existing `shpat_…` |
> ⚠️ **Custom apps cannot be installed on client-transfer stores.** Shopify
> restricts those to free and partner-friendly apps, so *every* install attempt
> fails with "The installation link for this app is invalid" — no matter how the
> app is configured. This is not a misconfiguration; Path A simply cannot work
> until the store is transferred to the merchant. Use Path B.
### Path A — OAuth capture (current Shopify platform) ⭐
Shopify has **retired legacy custom apps** on current stores (Spring '26 and
later). Apps are now created in the **Dev Dashboard**, are OAuth-based, and only
hand the access token to a backend during install — there's no "reveal token"
button anymore.
This repo ships that backend as a one-time command. `npm run auth` runs a
throwaway local callback server, completes the install, and writes the resulting
**offline access token** into `stores.json`. Offline tokens don't expire, so it's
a single step per store — afterwards the MCP server behaves exactly as it would
with a legacy static token.
**1. Create the app** in the Dev Dashboard (Partners → your org → Dev dashboard →
Create app → *Start from Dev Dashboard*).
**2. Configure the version:** set your Admin API scopes, uncheck *Embed app in
Shopify admin* (this is a headless connector), and add your redirect URL:
```
http://localhost:3456/callback
```
**3. Set the distribution method.** For a single client store, choose
**Custom distribution** and enter that store's `.myshopify.com` domain.
⚠️ Distribution choice is **permanent per app**, and custom distribution binds the
app to that one store — create a dedicated app per client store.
> Custom distribution is what lets you install on a store that is **not** in your
> organization — e.g. a client store you only have collaborator access to.
> It does **not** work on client-transfer stores held in your own org; see the
> table above.
**4. Configure this repo** (`stores.json`) with the app's Client ID and scopes:
```jsonc
{
"defaultApiVersion": "2026-07",
"app": {
"clientId": "your_client_id",
"clientSecretEnv": "SHOPIFY_CLIENT_SECRET",
"redirectUri": "http://localhost:3456/callback",
"scopes": "read_products,write_products,read_orders,read_content,write_content"
},
"stores": {
"client-a": {
"label": "Client A (production)",
"domain": "client-a.myshopify.com",
"env": "production"
}
}
}
```
**5. Put the client secret where the tool can find it.** `clientSecretEnv` names
an environment variable; the value belongs in `.env` (gitignored), which is
loaded automatically on startup:
```bash
echo 'SHOPIFY_CLIENT_SECRET=shpss_...' >> .env
```
An existing environment variable always wins over `.env`, so
`SHOPIFY_CLIENT_SECRET=… npm run auth …` and CI secrets still override the file.
> Before this was wired up, `.env` was never read and `npm run auth` failed with
> *"App for … has no client secret"* even when the secret was sitting in the file.
> If you see that error, the named variable genuinely isn't reaching the process —
> check the name in `clientSecretEnv` matches the key in `.env`.
**6. Run the capture:**
```bash
npm run build
npm run auth -- --store=client-a
```
It prints an install URL. Open it in a browser signed into that store's admin,
approve the scopes, and the token is written into `stores.json` automatically.
The callback is verified before any token is stored: **HMAC signature** (so a
forged callback is rejected), **state nonce** (CSRF), and a **shop-domain match**
against your config.
#### First install of a custom-distribution app
If Shopify answers **"The installation link for this app is invalid"** or
`Oauth error invalid_link`, check the store type first: on a **client-transfer**
store no install link will ever work, and you want [Path B](#path-b--legacy-custom-app)
instead. Otherwise the app uses custom distribution, and its install link is
**signed by Shopify and cannot be generated locally** — neither
`https://<shop>/admin/oauth/authorize?…` nor
`https://admin.shopify.com/store/<handle>/oauth/install_custom_app?client_id=…`
will work, however correct they look.
Only the link on the app's own Distribution page works, and it must be copied by
hand:
1. Set the app's **App URL** to `http://localhost:3456/` (Shopify opens it with
`?shop=…` and the local server answers that handshake). The redirect URL stays
`http://localhost:3456/callback`.
2. Start the listener and leave it running:
```bash
npm run auth -- --store=client-a --install-link
```
3. In the Dev Dashboard / Partners: **Apps → your app → Distribution**. Press
**Copy** on the Install link, open it in a browser signed into the store admin,
and press **Install**.
Because Shopify originates this flow, the callback carries Shopify's own state
rather than a nonce we generated, so `--install-link` relaxes the state check.
HMAC verification and the shop-domain match still apply. After the first install,
the ordinary authorize URL works for re-auth.
⚠️ **Scopes are fixed at install time.** Adding a scope to `stores.json` later has
no effect until you also add it to the app in the Dev Dashboard, **release a new
version**, and re-run the capture. Verify what was actually granted:
```bash
curl -s -H "X-Shopify-Access-Token: $TOKEN" \
https://<shop>.myshopify.com/admin/oauth/access_scopes.json
```
### Path B — legacy custom app
Legacy custom apps mint a static `shpat_…` token directly in the store admin —
no OAuth, no install link, no callback server. Much simpler than Path A when
it's available.
**As of January 1, 2026, merchants can no longer create them.** Two exceptions:
- **Partners can still create them on stores they hold before transfer.** This is
the sanctioned path for client-transfer stores, and the only one that works
there. Creation is disabled once the store transfers to the merchant.
- **Apps created before the cutoff keep working**, including their tokens.
Store admin → **Settings → Apps → App development** → *Create a legacy custom
app*. Name it, **Configure Admin API scopes**, tick what you need, **Save**, then
**Install app** and reveal the token. Drop it into `stores.json`:
```jsonc
"woot": {
"domain": "your-store.myshopify.com",
"env": "production",
"adminToken": "shpat_..."
}
```
A store using `adminToken` needs no `app` block — that's only read by
`npm run auth`.
> If the page shows only a pointer to the Dev Dashboard with no *Create* button,
> the store is merchant-owned and past the cutoff. Use Path A.
## Configuration reference
```jsonc
{
"defaultApiVersion": "2026-07",
"app": { /* global OAuth app, used by `npm run auth` */ },
"stores": {
"alias": {
"label": "Human-readable name",
"domain": "store.myshopify.com",
"env": "production", // or "dev" — drives the write-gate
"adminToken": "shpat_...", // inline token, OR
"adminTokenEnv": "SOME_VAR", // read from env, OR
"apiVersion": "2026-04", // optional per-store override
"app": { /* optional per-store app override */ }
}
}
}
```
- A store with **no** token is fine — tools return a clear "run `npm run auth`"
error, and `list_stores` reports `authenticated: false`.
- The **client secret is only needed for `npm run auth`**, never to run the server.
- `stores.json` is gitignored. **Never commit real tokens.**
## Connect it to Claude
Add to your MCP config (Claude Desktop `claude_desktop_config.json`, or Claude Code
`.mcp.json`) — this wires the official Dev MCP **and** this multi-store server:
```jsonc
{
"mcpServers": {
"shopify-dev": {
"command": "npx",
"args": ["-y", "@shopify/dev-mcp@latest"]
},
"shopify-multistore": {
"command": "node",
"args": ["/absolute/path/to/shopify-multistore-mcp/dist/index.js"],
"env": {
"STORES_CONFIG": "/absolute/path/to/shopify-multistore-mcp/stores.json"
}
}
}
}
```
Or register it once for every project with the Claude Code CLI:
```bash
claude mcp add shopify-multistore --scope user \
--env STORES_CONFIG=$PWD/stores.json \
-- node $PWD/dist/index.js
```
> If the server fails to start but works fine in your terminal, use an **absolute
> path to `node`** (e.g. `/opt/homebrew/bin/node`). Desktop apps don't inherit
> your shell's `PATH`, so version-manager shims like nvm often aren't on it.
Restart Claude after adding it, then ask *"list my shopify stores"* to confirm.
For theme/app/Functions work, also install the official
[Shopify AI Toolkit](https://github.com/Shopify/Shopify-AI-Toolkit) plugin and the
[Shopify CLI](https://shopify.dev/docs/api/shopify-cli).
## Usage examples
- "List my configured stores."
- "Show active products from vendor Acme in **client-a**."
- "How many unfulfilled orders does **client-a** have this week?"
- "Add a metafield `custom.care_instructions` to product X in **dev-store**." *(runs)*
- "Update the price of variant Y in **client-a**." *(production — asks to confirm first)*
## Development
```bash
npm run build # compile to dist/
npm run dev # tsc --watch
npm run typecheck # type-check without emitting
npm run auth -- --store=<alias> # one-time OAuth token capture
```
### Project layout
```
src/
index.ts # MCP server entry (stdio)
auth.ts # one-time OAuth token capture (local callback server)
config.ts # store registry loader + zod validation
dotenv.ts # minimal .env loader (no dependency)
shopify.ts # Admin GraphQL client + production write-gate
tools.ts # tool definitions
stores.example.json # template — copy to stores.json (gitignored)
```
## Roadmap
- Inventory adjustments, theme asset read/write, bulk operations
- Token refresh / re-auth helper for rotated secrets
- Optional hosted redirect endpoint (for a permanent, non-localhost install URL)
## License
MIT
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues