Skip to main content
Glama
Cubitrek

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