Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/SECRETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ This document lists all secrets required to deploy MCPs via GitHub Actions.
- `pages_read_engagement` - Read associated pages
- `business_management` - Access business accounts

### MCP: `shopify`
OAuth (authorization code grant) against merchant stores. Requires a **public
Shopify Partner app**.
- **`SHOPIFY_CLIENT_ID`**: the Partner app's Client ID
- **`SHOPIFY_CLIENT_SECRET`**: the Partner app's Client secret
- **`SHOPIFY_TOKEN_SECRET`**: a random high-entropy string (e.g. `openssl rand -hex 32`)
used to seal the `{ shop, token }` credential and sign OAuth state. Rotating it
invalidates existing connections (merchants must reconnect).
- Obtain the app at: https://partners.shopify.com → Apps → create app
- App URL: `https://sites-shopify.deco.site`
- Redirect URL: `https://sites-shopify.deco.site/oauth/shopify/callback`
- `SELF_URL` / `MESH_URL` are injected by the deco platform at runtime.

### MCP: `github` (Cloudflare Workers — `deploy-github.yml`)
Unlike the other MCPs, github deploys directly via `wrangler deploy` in
its own workflow. The GitHub Action only needs Cloudflare credentials:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ jobs:
REPLICATE_API_TOKEN: ${{ secrets.REPLICATE_API_TOKEN }}
PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }}
META_ACCESS_TOKEN: ${{ secrets.META_ACCESS_TOKEN }}
SHOPIFY_CLIENT_ID: ${{ secrets.SHOPIFY_CLIENT_ID }}
SHOPIFY_CLIENT_SECRET: ${{ secrets.SHOPIFY_CLIENT_SECRET }}
SHOPIFY_TOKEN_SECRET: ${{ secrets.SHOPIFY_TOKEN_SECRET }}
SHOPIFY_SCOPES: ${{ vars.SHOPIFY_SCOPES }}

- name: Notify success
if: success()
Expand Down
4 changes: 2 additions & 2 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -3030,7 +3030,7 @@
"short_description": "Read-only access to Shopify stores: catalog, orders, customers, inventory and analytics.",
"owner": "deco",
"has_remote": true,
"has_oauth": true,
"has_oauth": false,
"tags": [
"shopify",
"ecommerce",
Expand All @@ -3044,7 +3044,7 @@
"categories": [
"E-commerce"
],
"readme": "The Shopify MCP gives AI agents read-only access to a Shopify store through the Admin GraphQL API. It can browse and search products, variants and collections, inspect orders (including draft orders, abandoned checkouts, returns, refund previews and fulfillment status), look up customers and segments, check inventory levels per location, review discounts and marketing activities, read online store content (pages, blogs, articles, menus, redirects and theme files), and pull financial data from Shopify Payments (payouts, balance, disputes). It also supports ShopifyQL analytics queries where available. Because every tool is a GraphQL query — never a mutation — the MCP can never modify store data. Connect with a custom-app Admin API access token (Authorization field) and the store's myshopify.com domain."
"readme": "The Shopify MCP gives AI agents read-only access to a Shopify store through the Admin GraphQL API. It can browse and search products, variants and collections, inspect orders (including draft orders, abandoned checkouts, returns, refund previews and fulfillment status), look up customers and segments, check inventory levels per location, review discounts and marketing activities, read online store content (pages, blogs, articles, menus, redirects and theme files), and pull financial data from Shopify Payments (payouts, balance, disputes). It also supports ShopifyQL analytics queries where available. Because every tool is a GraphQL query — never a mutation — the MCP can never modify store data. Connect via OAuth: authorize the app against your store's myshopify.com domain and it obtains a read-only offline Admin API token automatically."
}
},
"server": {
Expand Down
4 changes: 4 additions & 0 deletions scripts/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ try {
"PERPLEXITY_API_KEY",
"META_APP_ID",
"META_APP_SECRET",
"SHOPIFY_CLIENT_ID",
"SHOPIFY_CLIENT_SECRET",
"SHOPIFY_TOKEN_SECRET",
"SHOPIFY_SCOPES",
];

const autoEnvArgs: string[] = [];
Expand Down
67 changes: 46 additions & 21 deletions shopify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,45 @@ and ShopifyQL analytics. See [TOOLS.md](./TOOLS.md) for the full catalog and sco

## Setup

1. In the Shopify admin, create a **custom app** (Settings → Apps and sales channels →
Develop apps) and give it the **read scopes** you need — e.g. `read_products`,
`read_orders`, `read_customers`, `read_inventory`, `read_fulfillments`, `read_locations`,
`read_discounts`, `read_content`, `read_themes`, `read_locales`, `read_translations`,
`read_marketing_events`, `read_users`, `read_companies`, `read_markets`,
`read_shopify_payments_payouts`, `read_shopify_payments_disputes`, `read_reports`.
Tools whose scope is missing fail with a clear ACCESS_DENIED hint; grant only what you use.
2. Install the app on the store and copy the **Admin API access token** (`shpat_…`).
3. Connect the MCP:
- **Token** (connection Authorization field): the Admin API access token
- **Store Domain** (config field): `my-store.myshopify.com`
- **API Version** (optional): defaults to `2026-07`

Orders older than 60 days additionally require the `read_all_orders` scope, which Shopify
only grants to approved apps.
Connecting is a one-click **OAuth** flow — no manual token copying:

1. In the connection UI, start the OAuth flow. You'll land on a page asking for your
store's `my-store.myshopify.com` domain.
2. Shopify shows the standard authorization screen listing the requested **read scopes**.
Approve it and you're connected — the MCP stores a read-only **offline access token**
(it never expires, so there's no refresh).

There's no connection config to fill in. The Admin API version defaults to `2026-07`;
override it per deployment with the `SHOPIFY_API_VERSION` env var.

Default scopes requested: `read_products`, `read_orders`, `read_customers`,
`read_inventory`, `read_fulfillments`, `read_locations`, `read_discounts`, `read_content`,
`read_themes`, `read_locales`, `read_translations`, `read_marketing_events`,
`read_markets`, `read_reports`. Override with the `SHOPIFY_SCOPES` env var
(comma-separated) to add or trim.

Some scopes are **plan/entitlement-gated** and are left out of the default because Shopify
rejects the whole authorize request (`missing_shopify_permission`) if the store can't grant
them: `read_users` and `read_companies` need **Shopify Plus**;
`read_shopify_payments_payouts`/`read_shopify_payments_disputes` need **Shopify Payments**.
Add them via `SHOPIFY_SCOPES` only for stores that have them. Tools whose scope is missing
fail with a clear ACCESS_DENIED hint. Orders older than 60 days additionally require
`read_all_orders`, which Shopify only grants to approved apps.

The OAuth flow needs a **public Shopify Partner app**; its `SHOPIFY_CLIENT_ID`,
`SHOPIFY_CLIENT_SECRET` and `SHOPIFY_TOKEN_SECRET` are provided as deploy secrets — see
[`.github/workflows/SECRETS.md`](../.github/workflows/SECRETS.md).

### Local development

For local dev you can skip OAuth and pass a raw Admin API token via env vars:

```sh
SHOPIFY_STORE_DOMAIN=my-store.myshopify.com SHOPIFY_ACCESS_TOKEN=shpat_xxx bun run dev
```

Env vars are a fallback — connection state (`storeDomain`) and the Authorization header
always win.
Env vars are a fallback — a connected OAuth credential (or a raw token in the Authorization
header) always wins.

## Development

Expand All @@ -51,7 +66,17 @@ bumping the API version.

## Auth notes

Token auth only (same pattern as the Magento MCP). Shopify OAuth would require a registered
partner app (client id/secret) and a per-store authorization URL, which the runtime `oauth`
hook can't currently express — the shop domain isn't known when the authorization URL is
built. If a partner app ever exists, the OAuth flow can be added in `server/main.ts`.
OAuth (authorization code grant, offline token). Shopify's authorize endpoint is
per-store, but the runtime's `authorizationUrl(callbackUrl)` hook doesn't know which store
the user wants. So (like the WhatsApp MCP) the hook points at our own `/oauth/custom` page,
which asks for the shop domain and then drives the real Shopify grant. See the flow diagram
in [`server/lib/oauth.ts`](./server/lib/oauth.ts).

The Shopify access token is sealed (AES-256-GCM, keyed by `SHOPIFY_TOKEN_SECRET`) together
with the shop domain into the connection's access token, so both travel back through the
browser redirect without exposing the raw token, and every tool call carries the store it
belongs to — no separate Store Domain config field. Legacy connections that used a raw
Admin API token plus a `storeDomain` state field still work.

A raw admin token in the `Authorization` header (no OAuth) is still accepted as a fallback,
which is what the local-dev env vars use.
25 changes: 2 additions & 23 deletions shopify/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,16 @@
"friendlyName": "Shopify",
"connection": {
"type": "HTTP",
"url": "https://sites-shopify.deco.site/mcp",
"configSchema": {
"type": "object",
"properties": {
"storeDomain": {
"type": "string",
"title": "Store Domain",
"description": "Shopify store domain, e.g. my-store.myshopify.com (the token goes in the connection's Authorization field)"
},
"apiVersion": {
"type": "string",
"title": "API Version",
"description": "Admin GraphQL API version (default 2026-07)"
}
},
"required": ["storeDomain"]
}
"url": "https://sites-shopify.deco.site/mcp"
},
"description": "Read-only MCP for the Shopify Admin GraphQL API — products, collections, orders, draft orders, customers, inventory, fulfillment, discounts, online store content, B2B, markets, Shopify Payments and ShopifyQL analytics.",
"icon": "https://github.com/shopify.png",
"unlisted": false,
"auth": {
"type": "token",
"header": "Authorization",
"prefix": "Bearer"
},
"metadata": {
"categories": ["E-commerce"],
"official": false,
"tags": ["shopify", "ecommerce", "orders", "products", "customers", "inventory", "analytics", "api"],
"short_description": "Read-only access to Shopify stores: catalog, orders, customers, inventory and analytics.",
"mesh_description": "The Shopify MCP gives AI agents read-only access to a Shopify store through the Admin GraphQL API. It can browse and search products, variants and collections, inspect orders (including draft orders, abandoned checkouts, returns, refund previews and fulfillment status), look up customers and segments, check inventory levels per location, review discounts and marketing activities, read online store content (pages, blogs, articles, menus, redirects and theme files), and pull financial data from Shopify Payments (payouts, balance, disputes). It also supports ShopifyQL analytics queries where available. Because every tool is a GraphQL query — never a mutation — the MCP can never modify store data. Connect with a custom-app Admin API access token (Authorization field) and the store's myshopify.com domain."
"mesh_description": "The Shopify MCP gives AI agents read-only access to a Shopify store through the Admin GraphQL API. It can browse and search products, variants and collections, inspect orders (including draft orders, abandoned checkouts, returns, refund previews and fulfillment status), look up customers and segments, check inventory levels per location, review discounts and marketing activities, read online store content (pages, blogs, articles, menus, redirects and theme files), and pull financial data from Shopify Payments (payouts, balance, disputes). It also supports ShopifyQL analytics queries where available. Because every tool is a GraphQL query — never a mutation — the MCP can never modify store data. Connect via OAuth: authorize the app against your store's myshopify.com domain and it obtains a read-only offline Admin API token automatically."
}
}
31 changes: 31 additions & 0 deletions shopify/server/lib/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
shopifyGraphql,
toGid,
} from "./client.ts";
import { encryptCredential } from "./token.ts";

const originalFetch = globalThis.fetch;

Expand All @@ -16,6 +17,7 @@ afterEach(() => {
delete process.env.SHOPIFY_STORE_DOMAIN;
delete process.env.SHOPIFY_ACCESS_TOKEN;
delete process.env.SHOPIFY_API_VERSION;
delete process.env.SHOPIFY_TOKEN_SECRET;
});

describe("normalizeStoreDomain", () => {
Expand All @@ -41,6 +43,35 @@ describe("normalizeStoreDomain", () => {
});

describe("resolveCredentials", () => {
test("opens a sealed OAuth credential from the authorization header", () => {
process.env.SHOPIFY_TOKEN_SECRET = "unit-test-secret";
const sealed = encryptCredential(
{ shop: "oauth-store.myshopify.com", token: "shpat_oauth" },
"unit-test-secret",
);
const creds = resolveCredentials({ authorization: `Bearer ${sealed}` });
expect(creds.storeDomain).toBe("oauth-store.myshopify.com");
expect(creds.accessToken).toBe("shpat_oauth");
expect(creds.sources).toEqual({
storeDomain: "oauth",
accessToken: "oauth",
});
});

test("falls back to legacy raw token when the header isn't a sealed credential", () => {
process.env.SHOPIFY_TOKEN_SECRET = "unit-test-secret";
const creds = resolveCredentials({
authorization: "Bearer shpat_legacy",
state: { storeDomain: "legacy-store.myshopify.com" },
});
expect(creds.accessToken).toBe("shpat_legacy");
expect(creds.storeDomain).toBe("legacy-store.myshopify.com");
expect(creds.sources).toEqual({
storeDomain: "state",
accessToken: "authorization",
});
});

test("reads token from authorization (strips Bearer) and domain from state", () => {
const creds = resolveCredentials({
authorization: "Bearer shpat_abc123",
Expand Down
59 changes: 43 additions & 16 deletions shopify/server/lib/client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/**
* Shopify Admin GraphQL client — credential resolution, endpoint building and
* a retrying fetch wrapper. Mirrors magento/server/lib/client.ts: the access
* token comes from the connection-level Authorization field
* (MESH_REQUEST_CONTEXT.authorization) and the store domain from state.
* a retrying fetch wrapper.
*
* Credentials come from one of three places, in priority order:
* 1. OAuth: the connection Authorization header carries a sealed credential
* (`{ shop, token }`, AES-GCM) minted by the OAuth flow — both the store
* domain and the access token come from there.
* 2. Legacy token: a raw Admin API token in the Authorization header, paired
* with a `storeDomain` from connection state (pre-OAuth connections).
* 3. Env vars (SHOPIFY_STORE_DOMAIN / SHOPIFY_ACCESS_TOKEN) for local dev.
*/
import {
DEFAULT_API_VERSION,
Expand All @@ -11,10 +17,15 @@ import {
REQUEST_TIMEOUT_MS,
} from "../constants.ts";
import type { ShopifyCredentials } from "../types/env.ts";
import {
decryptCredential,
getTokenSecret,
type SealedCredential,
} from "./token.ts";

export type { ShopifyCredentials };

type CredentialSource = "authorization" | "state" | "env" | "missing";
type CredentialSource = "oauth" | "authorization" | "state" | "env" | "missing";

export interface ResolvedCredentials extends ShopifyCredentials {
/** Where each credential value came from — useful for diagnosing missing config. */
Expand Down Expand Up @@ -46,7 +57,30 @@ export function resolveCredentials(
ctx: MeshRequestContext | undefined,
): ResolvedCredentials {
const state = ctx?.state ?? {};
const apiVersion =
state.apiVersion || process.env.SHOPIFY_API_VERSION || DEFAULT_API_VERSION;

const rawAuth = ctx?.authorization
? ctx.authorization.replace(/^Bearer\s+/i, "").trim()
: "";

// 1. OAuth: the Authorization header is a sealed { shop, token } credential.
if (rawAuth) {
const secret = getTokenSecret();
const sealed = secret
? decryptCredential<SealedCredential>(rawAuth, secret)
: null;
if (sealed?.shop && sealed?.token) {
return {
storeDomain: normalizeStoreDomain(sealed.shop),
accessToken: sealed.token,
apiVersion,
sources: { storeDomain: "oauth", accessToken: "oauth" },
};
}
}

// 2. Legacy: raw token in the header + storeDomain from state (or env).
let storeDomain: { value: string; source: CredentialSource };
if (state.storeDomain) {
storeDomain = { value: state.storeDomain, source: "state" };
Expand All @@ -56,13 +90,9 @@ export function resolveCredentials(
storeDomain = { value: "", source: "missing" };
}

const rawAuth = ctx?.authorization;
const tokenFromAuth = rawAuth
? rawAuth.replace(/^Bearer\s+/i, "").trim()
: undefined;
let accessToken: { value: string; source: CredentialSource };
if (tokenFromAuth) {
accessToken = { value: tokenFromAuth, source: "authorization" };
if (rawAuth) {
accessToken = { value: rawAuth, source: "authorization" };
} else if (process.env.SHOPIFY_ACCESS_TOKEN) {
accessToken = { value: process.env.SHOPIFY_ACCESS_TOKEN, source: "env" };
} else {
Expand All @@ -74,10 +104,7 @@ export function resolveCredentials(
? normalizeStoreDomain(storeDomain.value)
: "",
accessToken: accessToken.value,
apiVersion:
state.apiVersion ||
process.env.SHOPIFY_API_VERSION ||
DEFAULT_API_VERSION,
apiVersion,
sources: {
storeDomain: storeDomain.source,
accessToken: accessToken.source,
Expand All @@ -92,12 +119,12 @@ export function assertValidCredentials(
const where = toolId ? ` (tool=${toolId})` : "";
if (!creds.storeDomain) {
throw new Error(
`Shopify storeDomain is missing${where} — set the Store Domain field in the MCP connection (e.g. my-store.myshopify.com) or the SHOPIFY_STORE_DOMAIN env var.`,
`Shopify storeDomain is missing${where} — reconnect the MCP via OAuth, or for local dev set the SHOPIFY_STORE_DOMAIN env var (e.g. my-store.myshopify.com).`,
);
}
if (!creds.accessToken) {
throw new Error(
`Shopify access token is missing${where} — set the Token field in the MCP connection (Authorization: Bearer, an Admin API access token from a custom app) or the SHOPIFY_ACCESS_TOKEN env var.`,
`Shopify access token is missing${where} — reconnect the MCP via OAuth, or for local dev set the SHOPIFY_ACCESS_TOKEN env var.`,
);
}
}
Expand Down
Loading
Loading