From f866dcbb3bc44f875c27dcee701ff12b538c9517 Mon Sep 17 00:00:00 2001 From: guitavano Date: Tue, 28 Jul 2026 12:49:16 -0300 Subject: [PATCH 1/3] feat(shopify): OAuth (offline token) via a store-domain prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the manual custom-app token flow with a one-click OAuth grant. Shopify's authorize endpoint is per-store but the runtime's authorizationUrl(callbackUrl) hook doesn't know the store, so (like the WhatsApp MCP) the hook points at our own /oauth/custom page which asks for the shop domain, then drives the real Shopify grant and hands the mesh a sealed credential. - server/lib/token.ts: stateless crypto — HMAC-signed state + AES-256-GCM sealed { shop, token } credential (so the Admin token never travels readable through browser redirects) + Shopify callback HMAC verification. - server/lib/oauth.ts: oauth hook (authorizationUrl/exchangeCode) and the /oauth/custom + /oauth/shopify/callback route handlers. Offline token, no refresh. Requested scopes trimmed to what a standard store can grant (Plus/Payments-gated scopes excluded) and overridable via SHOPIFY_SCOPES. - server/lib/client.ts: resolveCredentials opens the sealed credential (both shop + token come from it); legacy raw-token + env-var paths kept. - types/env.ts + app.json: drop the storeDomain config field and the token auth block; refresh descriptions. - deploy.ts / deploy.yml / SECRETS.md: wire SHOPIFY_CLIENT_ID, SHOPIFY_CLIENT_SECRET, SHOPIFY_TOKEN_SECRET, SHOPIFY_SCOPES. - README: rewrite setup/auth notes for OAuth. Tests: 51 pass (token sign/seal, HMAC, route handlers, credential resolution). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/SECRETS.md | 13 ++ .github/workflows/deploy.yml | 4 + scripts/deploy.ts | 4 + shopify/README.md | 65 +++++-- shopify/app.json | 15 +- shopify/server/lib/client.test.ts | 31 +++ shopify/server/lib/client.ts | 59 ++++-- shopify/server/lib/oauth.test.ts | 225 ++++++++++++++++++++++ shopify/server/lib/oauth.ts | 307 ++++++++++++++++++++++++++++++ shopify/server/lib/token.test.ts | 105 ++++++++++ shopify/server/lib/token.ts | 132 +++++++++++++ shopify/server/main.ts | 10 +- shopify/server/types/env.ts | 5 - 13 files changed, 919 insertions(+), 56 deletions(-) create mode 100644 shopify/server/lib/oauth.test.ts create mode 100644 shopify/server/lib/oauth.ts create mode 100644 shopify/server/lib/token.test.ts create mode 100644 shopify/server/lib/token.ts diff --git a/.github/workflows/SECRETS.md b/.github/workflows/SECRETS.md index aa804199..b800b87f 100644 --- a/.github/workflows/SECRETS.md +++ b/.github/workflows/SECRETS.md @@ -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: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 31973557..a75db71d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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() diff --git a/scripts/deploy.ts b/scripts/deploy.ts index c066017b..4bcb5fdb 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -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[] = []; diff --git a/shopify/README.md b/shopify/README.md index aa78d75d..1a3e79bc 100644 --- a/shopify/README.md +++ b/shopify/README.md @@ -11,30 +11,43 @@ 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). +3. **API Version** (optional config field): defaults to `2026-07`. + +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 @@ -51,7 +64,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. diff --git a/shopify/app.json b/shopify/app.json index 859f6f23..50c3c414 100644 --- a/shopify/app.json +++ b/shopify/app.json @@ -8,33 +8,22 @@ "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"] + } } }, "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." } } diff --git a/shopify/server/lib/client.test.ts b/shopify/server/lib/client.test.ts index 048851f0..be877c27 100644 --- a/shopify/server/lib/client.test.ts +++ b/shopify/server/lib/client.test.ts @@ -8,6 +8,7 @@ import { shopifyGraphql, toGid, } from "./client.ts"; +import { encryptCredential } from "./token.ts"; const originalFetch = globalThis.fetch; @@ -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", () => { @@ -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", diff --git a/shopify/server/lib/client.ts b/shopify/server/lib/client.ts index 8abc6e4d..f5324fa2 100644 --- a/shopify/server/lib/client.ts +++ b/shopify/server/lib/client.ts @@ -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, @@ -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. */ @@ -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(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" }; @@ -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 { @@ -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, @@ -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.`, ); } } diff --git a/shopify/server/lib/oauth.test.ts b/shopify/server/lib/oauth.test.ts new file mode 100644 index 00000000..897d1e1c --- /dev/null +++ b/shopify/server/lib/oauth.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + handleOAuthRoute, + OAUTH_CALLBACK_PATH, + OAUTH_CONNECT_PATH, + shopifyOAuth, +} from "./oauth.ts"; +import { + decryptCredential, + encryptCredential, + signState, + verifyState, +} from "./token.ts"; +import { createHmac } from "node:crypto"; + +const SELF = "https://sites-shopify.deco.site"; +const MESH = "https://mesh.example.com"; +const SECRET = "oauth-test-secret"; +const CLIENT_ID = "test-client-id"; +const CLIENT_SECRET = "test-client-secret"; +const CALLBACK = `${MESH}/oauth/callback?state=mesh-state`; + +const originalFetch = globalThis.fetch; + +beforeEach(() => { + process.env.SHOPIFY_CLIENT_ID = CLIENT_ID; + process.env.SHOPIFY_CLIENT_SECRET = CLIENT_SECRET; + process.env.SHOPIFY_TOKEN_SECRET = SECRET; + process.env.SELF_URL = SELF; + process.env.MESH_URL = MESH; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.SHOPIFY_CLIENT_ID; + delete process.env.SHOPIFY_CLIENT_SECRET; + delete process.env.SHOPIFY_TOKEN_SECRET; + delete process.env.SELF_URL; + delete process.env.MESH_URL; + delete process.env.SHOPIFY_SCOPES; +}); + +function shopifyHmac(params: Record): string { + const message = Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .sort() + .join("&"); + return createHmac("sha256", CLIENT_SECRET).update(message).digest("hex"); +} + +describe("authorizationUrl", () => { + test("points at our own connect page carrying the callback", () => { + const url = new URL(shopifyOAuth.authorizationUrl(CALLBACK)); + expect(url.origin).toBe(SELF); + expect(url.pathname).toBe(OAUTH_CONNECT_PATH); + expect(url.searchParams.get("callback_url")).toBe(CALLBACK); + }); +}); + +describe("GET /oauth/custom", () => { + test("renders the store-domain form when no shop is given", async () => { + const res = await handleOAuthRoute( + new Request( + `${SELF}${OAUTH_CONNECT_PATH}?callback_url=${encodeURIComponent(CALLBACK)}`, + ), + ); + expect(res?.status).toBe(200); + expect(res?.headers.get("Content-Type")).toContain("text/html"); + expect(await res!.text()).toContain("Connect your Shopify store"); + }); + + test("rejects an off-origin callback URL", async () => { + const res = await handleOAuthRoute( + new Request( + `${SELF}${OAUTH_CONNECT_PATH}?callback_url=${encodeURIComponent("https://evil.example.com/x")}`, + ), + ); + expect(res?.status).toBe(400); + }); + + test("redirects to Shopify authorize once a shop is provided", async () => { + const res = await handleOAuthRoute( + new Request( + `${SELF}${OAUTH_CONNECT_PATH}?shop=my-store.myshopify.com&callback_url=${encodeURIComponent(CALLBACK)}`, + ), + ); + expect(res?.status).toBe(302); + const location = new URL(res!.headers.get("Location")!); + expect(location.origin).toBe("https://my-store.myshopify.com"); + expect(location.pathname).toBe("/admin/oauth/authorize"); + expect(location.searchParams.get("client_id")).toBe(CLIENT_ID); + expect(location.searchParams.get("redirect_uri")).toBe( + `${SELF}${OAUTH_CALLBACK_PATH}`, + ); + expect(location.searchParams.get("scope")).toContain("read_products"); + // offline grant → no per-user grant option + expect(location.searchParams.get("grant_options[]")).toBeNull(); + // state carries the mesh callback, tamper-proof + expect( + verifyState<{ cb: string }>(location.searchParams.get("state")!, SECRET), + ).toEqual({ cb: CALLBACK }); + }); + + test("rejects a non-myshopify shop", async () => { + const res = await handleOAuthRoute( + new Request( + `${SELF}${OAUTH_CONNECT_PATH}?shop=evil.example.com&callback_url=${encodeURIComponent(CALLBACK)}`, + ), + ); + expect(res?.status).toBe(400); + }); + + async function requestedScopes(): Promise { + const res = await handleOAuthRoute( + new Request( + `${SELF}${OAUTH_CONNECT_PATH}?shop=my-store.myshopify.com&callback_url=${encodeURIComponent(CALLBACK)}`, + ), + ); + return new URL(res!.headers.get("Location")!).searchParams.get("scope")!; + } + + test("default scopes omit Plus/Payments-gated ones", async () => { + const scope = await requestedScopes(); + expect(scope).toContain("read_products"); + expect(scope).not.toContain("read_users"); + expect(scope).not.toContain("read_companies"); + expect(scope).not.toContain("read_shopify_payments"); + }); + + test("SHOPIFY_SCOPES env overrides the default", async () => { + process.env.SHOPIFY_SCOPES = "read_products,read_orders"; + expect(await requestedScopes()).toBe("read_products,read_orders"); + }); +}); + +describe("GET /oauth/shopify/callback", () => { + function callbackUrl(overrides: Record = {}): string { + const state = signState({ cb: CALLBACK }, SECRET); + const base: Record = { + code: "shopify-auth-code", + shop: "my-store.myshopify.com", + state, + ...overrides, + }; + if (!("hmac" in overrides)) base.hmac = shopifyHmac(base); + const params = new URLSearchParams(base); + return `${SELF}${OAUTH_CALLBACK_PATH}?${params.toString()}`; + } + + test("exchanges the code and bounces a sealed credential to the mesh", async () => { + let exchangeBody: unknown; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + expect(String(input)).toBe( + "https://my-store.myshopify.com/admin/oauth/access_token", + ); + exchangeBody = JSON.parse(String(init?.body)); + return Response.json({ access_token: "shpat_offline" }); + }) as typeof fetch; + + const res = await handleOAuthRoute(new Request(callbackUrl())); + expect(res?.status).toBe(302); + expect(exchangeBody).toEqual({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: "shopify-auth-code", + }); + + const dest = new URL(res!.headers.get("Location")!); + expect(dest.origin).toBe(MESH); + expect(dest.searchParams.get("state")).toBe("mesh-state"); + const sealed = dest.searchParams.get("code")!; + expect( + decryptCredential<{ shop: string; token: string }>(sealed, SECRET), + ).toEqual({ + shop: "my-store.myshopify.com", + token: "shpat_offline", + }); + }); + + test("rejects a bad Shopify HMAC", async () => { + const res = await handleOAuthRoute( + new Request(callbackUrl({ hmac: "deadbeef" })), + ); + expect(res?.status).toBe(400); + }); + + test("rejects a tampered state", async () => { + const res = await handleOAuthRoute( + new Request(callbackUrl({ state: "forged" })), + ); + expect(res?.status).toBe(400); + }); +}); + +describe("exchangeCode", () => { + test("validates and echoes the sealed credential as the access token", async () => { + // A sealed credential minted by the callback is passed back as `code`. + const sealed = encryptCredential( + { shop: "s.myshopify.com", token: "shpat_x" }, + SECRET, + ); + const result = await shopifyOAuth.exchangeCode({ code: sealed }); + expect(result).toEqual({ access_token: sealed, token_type: "Bearer" }); + }); + + test("throws on a tampered code", async () => { + await expect( + shopifyOAuth.exchangeCode({ code: "not-a-real-credential" }), + ).rejects.toThrow(/Invalid or tampered/); + }); +}); + +describe("handleOAuthRoute passthrough", () => { + test("returns null for unrelated paths", async () => { + expect(await handleOAuthRoute(new Request(`${SELF}/mcp`))).toBeNull(); + }); + + test("returns null for non-GET requests", async () => { + expect( + await handleOAuthRoute( + new Request(`${SELF}${OAUTH_CONNECT_PATH}`, { method: "POST" }), + ), + ).toBeNull(); + }); +}); diff --git a/shopify/server/lib/oauth.ts b/shopify/server/lib/oauth.ts new file mode 100644 index 00000000..6ba3f494 --- /dev/null +++ b/shopify/server/lib/oauth.ts @@ -0,0 +1,307 @@ +/** + * Shopify OAuth (authorization code grant, offline access token). + * + * Shopify's authorize endpoint is per-store (`https://{shop}.myshopify.com/...`) + * but the mesh runtime's `authorizationUrl(callbackUrl)` hook doesn't know which + * store the user wants — so we can't build it up front. We borrow the WhatsApp + * MCP's trick: point the runtime at our own `/oauth/custom` page, ask the + * merchant for their shop, then drive the real Shopify grant ourselves and hand + * the mesh a sealed credential. + * + * Flow: + * 1. runtime → `authorizationUrl` → 302 to `/oauth/custom?callback_url=…` + * 2. `/oauth/custom` renders a form; on submit it 302s to Shopify's + * `/admin/oauth/authorize` with `redirect_uri` pointing back at us and the + * mesh callback URL signed into `state`. + * 3. Shopify → `/oauth/shopify/callback?code&shop&state&hmac`. We verify the + * HMAC + state, exchange the code for an offline token, seal `{shop, token}` + * into an encrypted credential, and 302 back to the mesh callback with it + * as `code`. + * 4. runtime → `exchangeCode({ code })` → we validate and return the sealed + * credential as the connection's `access_token`. + * + * The token never expires (offline grant), so there's no `refreshToken`. + */ +import { normalizeStoreDomain } from "./client.ts"; +import { + decryptCredential, + encryptCredential, + type SealedCredential, + signState, + verifyShopifyHmac, + verifyState, +} from "./token.ts"; + +export const OAUTH_CONNECT_PATH = "/oauth/custom"; +export const OAUTH_CALLBACK_PATH = "/oauth/shopify/callback"; + +/** + * Read scopes requested during the grant. Kept to what a standard store can + * grant — Shopify rejects the whole authorize request (missing_shopify_permission) + * if the app asks for a scope the store isn't entitled to. + * + * Deliberately excluded from the default because they're plan/entitlement-gated: + * - read_users, read_companies → Shopify Plus only + * - read_shopify_payments_payouts/… → require Shopify Payments + * Add them (or trim further) per deployment via the SHOPIFY_SCOPES env var. + */ +export const DEFAULT_SCOPES = [ + "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", +].join(","); + +/** Requested scopes — SHOPIFY_SCOPES env (comma-separated) overrides the default. */ +export function getScopes(): string { + return process.env.SHOPIFY_SCOPES?.trim() || DEFAULT_SCOPES; +} + +interface OAuthEnv { + clientId: string; + clientSecret: string; + tokenSecret: string; + selfUrl: string; + meshUrl: string; +} + +/** Resolve OAuth config lazily (process.env isn't populated at module init on + * some platforms). Throws a clear error if the core secrets are missing. */ +function getOAuthEnv(): OAuthEnv { + const clientId = process.env.SHOPIFY_CLIENT_ID || ""; + const clientSecret = process.env.SHOPIFY_CLIENT_SECRET || ""; + const tokenSecret = process.env.SHOPIFY_TOKEN_SECRET || ""; + if (!clientId || !clientSecret || !tokenSecret) { + throw new Error( + "Shopify OAuth is not configured — set SHOPIFY_CLIENT_ID, " + + "SHOPIFY_CLIENT_SECRET and SHOPIFY_TOKEN_SECRET.", + ); + } + return { + clientId, + clientSecret, + tokenSecret, + selfUrl: process.env.SELF_URL || "http://localhost:8000", + meshUrl: process.env.MESH_URL || "http://localhost:3000", + }; +} + +const SHOP_DOMAIN_RE = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i; + +function isValidShopDomain(shop: string): boolean { + return SHOP_DOMAIN_RE.test(shop); +} + +/** Only ever redirect the OAuth code back to the mesh (or our own) origin. */ +function isAllowedCallback(callbackUrl: string, env: OAuthEnv): boolean { + try { + const origin = new URL(callbackUrl).origin; + return ( + origin === new URL(env.meshUrl).origin || + origin === new URL(env.selfUrl).origin + ); + } catch { + return false; + } +} + +// ── Runtime oauth hook ──────────────────────────────────────────────────────── + +export const shopifyOAuth = { + mode: "PKCE" as const, + authorizationServer: "https://admin.shopify.com", + authorizationUrl: (callbackUrl: string): string => { + const env = getOAuthEnv(); + const url = new URL(OAUTH_CONNECT_PATH, env.selfUrl); + url.searchParams.set("callback_url", callbackUrl); + return url.toString(); + }, + exchangeCode: async ({ code }: { code: string }) => { + const env = getOAuthEnv(); + const sealed = decryptCredential(code, env.tokenSecret); + if (!sealed?.shop || !sealed?.token) { + throw new Error("Invalid or tampered Shopify authorization code"); + } + // `code` is already the sealed credential minted in the callback — it + // becomes the connection's long-lived (offline) access token verbatim. + return { access_token: code, token_type: "Bearer" }; + }, +}; + +// ── Custom route handlers ───────────────────────────────────────────────────── + +function htmlResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} + +function connectForm(callbackUrl: string): Response { + const safeCallback = callbackUrl.replace(/"/g, """); + return htmlResponse(` + + + + + Connect your Shopify store + + + +
+

Connect your Shopify store

+

Enter your store's .myshopify.com domain to authorize read-only access.

+ + + + +
+ +`); +} + +/** GET /oauth/custom — render the form, or (once `shop` is provided) redirect + * into Shopify's authorize endpoint. */ +function handleConnect(url: URL, env: OAuthEnv): Response { + const callbackUrl = url.searchParams.get("callback_url"); + if (!callbackUrl || !isAllowedCallback(callbackUrl, env)) { + return htmlResponse("Invalid or missing callback URL.", 400); + } + + const rawShop = url.searchParams.get("shop"); + if (!rawShop) return connectForm(callbackUrl); + + const shop = normalizeStoreDomain(rawShop); + if (!isValidShopDomain(shop)) { + return htmlResponse( + "Enter a valid Shopify domain, e.g. my-store.myshopify.com.", + 400, + ); + } + + const state = signState({ cb: callbackUrl }, env.tokenSecret); + const authorize = new URL(`https://${shop}/admin/oauth/authorize`); + authorize.searchParams.set("client_id", env.clientId); + authorize.searchParams.set("scope", getScopes()); + authorize.searchParams.set( + "redirect_uri", + new URL(OAUTH_CALLBACK_PATH, env.selfUrl).toString(), + ); + authorize.searchParams.set("state", state); + // No `grant_options[]=per-user` → offline (non-expiring) access token. + return Response.redirect(authorize.toString(), 302); +} + +/** GET /oauth/shopify/callback — validate Shopify's redirect, exchange the code + * for an offline token, seal it, and bounce back to the mesh callback. */ +async function handleCallback(url: URL, env: OAuthEnv): Promise { + const params = url.searchParams; + + const state = verifyState<{ cb: string }>( + params.get("state") ?? "", + env.tokenSecret, + ); + if (!state?.cb || !isAllowedCallback(state.cb, env)) { + return htmlResponse("Invalid OAuth state.", 400); + } + + if (!verifyShopifyHmac(params, env.clientSecret)) { + return htmlResponse("Shopify HMAC validation failed.", 400); + } + + const shop = normalizeStoreDomain(params.get("shop") ?? ""); + const code = params.get("code"); + if (!isValidShopDomain(shop) || !code) { + return htmlResponse("Missing shop or authorization code.", 400); + } + + const tokenResponse = await fetch( + `https://${shop}/admin/oauth/access_token`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: env.clientId, + client_secret: env.clientSecret, + code, + }), + }, + ); + + if (!tokenResponse.ok) { + const detail = (await tokenResponse.text()).slice(0, 300); + return htmlResponse( + `Shopify token exchange failed (${tokenResponse.status}): ${detail}`, + 502, + ); + } + + const { access_token: accessToken } = (await tokenResponse.json()) as { + access_token?: string; + }; + if (!accessToken) { + return htmlResponse("Shopify did not return an access token.", 502); + } + + const sealed = encryptCredential( + { shop, token: accessToken }, + env.tokenSecret, + ); + const dest = new URL(state.cb); + dest.searchParams.set("code", sealed); + return Response.redirect(dest.toString(), 302); +} + +/** Dispatch the two custom OAuth routes; returns null for everything else so + * the caller falls through to the runtime. */ +export async function handleOAuthRoute(req: Request): Promise { + if (req.method !== "GET") return null; + const url = new URL(req.url); + if ( + url.pathname !== OAUTH_CONNECT_PATH && + url.pathname !== OAUTH_CALLBACK_PATH + ) { + return null; + } + + let env: OAuthEnv; + try { + env = getOAuthEnv(); + } catch (error) { + return htmlResponse( + error instanceof Error ? error.message : "OAuth is not configured.", + 500, + ); + } + + return url.pathname === OAUTH_CONNECT_PATH + ? handleConnect(url, env) + : handleCallback(url, env); +} diff --git a/shopify/server/lib/token.test.ts b/shopify/server/lib/token.test.ts new file mode 100644 index 00000000..28d769df --- /dev/null +++ b/shopify/server/lib/token.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { + decryptCredential, + encryptCredential, + signState, + verifyShopifyHmac, + verifyState, +} from "./token.ts"; +import { createHmac } from "node:crypto"; + +const SECRET = "test-secret-please-ignore"; + +describe("signState / verifyState", () => { + test("round-trips a payload", () => { + const token = signState( + { cb: "https://mesh.example.com/oauth/callback" }, + SECRET, + ); + expect(verifyState<{ cb: string }>(token, SECRET)).toEqual({ + cb: "https://mesh.example.com/oauth/callback", + }); + }); + + test("rejects a tampered body", () => { + const token = signState({ cb: "https://good.example.com" }, SECRET); + const [, sig] = token.split("."); + const forged = `${Buffer.from(JSON.stringify({ cb: "https://evil.example.com" })).toString("base64url")}.${sig}`; + expect(verifyState(forged, SECRET)).toBeNull(); + }); + + test("rejects a wrong secret", () => { + const token = signState({ cb: "x" }, SECRET); + expect(verifyState(token, "other-secret")).toBeNull(); + }); + + test("rejects a malformed token", () => { + expect(verifyState("not-a-token", SECRET)).toBeNull(); + }); +}); + +describe("encryptCredential / decryptCredential", () => { + test("round-trips a sealed credential", () => { + const sealed = encryptCredential( + { shop: "my-store.myshopify.com", token: "shpat_secret" }, + SECRET, + ); + expect( + decryptCredential<{ shop: string; token: string }>(sealed, SECRET), + ).toEqual({ + shop: "my-store.myshopify.com", + token: "shpat_secret", + }); + }); + + test("does not leak the token in plaintext", () => { + const sealed = encryptCredential({ token: "shpat_secret" }, SECRET); + expect(sealed).not.toContain("shpat_secret"); + }); + + test("produces a fresh ciphertext each time (random IV)", () => { + const payload = { shop: "s.myshopify.com", token: "t" }; + expect(encryptCredential(payload, SECRET)).not.toBe( + encryptCredential(payload, SECRET), + ); + }); + + test("returns null on a wrong secret", () => { + const sealed = encryptCredential({ token: "t" }, SECRET); + expect(decryptCredential(sealed, "other-secret")).toBeNull(); + }); + + test("returns null on a non-credential string (e.g. a raw token)", () => { + expect(decryptCredential("shpat_rawtoken", SECRET)).toBeNull(); + }); +}); + +describe("verifyShopifyHmac", () => { + function sign(params: Record, secret: string): string { + const message = Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .sort() + .join("&"); + return createHmac("sha256", secret).update(message).digest("hex"); + } + + test("accepts a valid Shopify HMAC", () => { + const base = { code: "abc", shop: "s.myshopify.com", state: "xyz" }; + const params = new URLSearchParams({ ...base, hmac: sign(base, SECRET) }); + expect(verifyShopifyHmac(params, SECRET)).toBe(true); + }); + + test("rejects a bad HMAC", () => { + const params = new URLSearchParams({ + code: "abc", + shop: "s.myshopify.com", + hmac: "deadbeef", + }); + expect(verifyShopifyHmac(params, SECRET)).toBe(false); + }); + + test("rejects when hmac is absent", () => { + const params = new URLSearchParams({ code: "abc" }); + expect(verifyShopifyHmac(params, SECRET)).toBe(false); + }); +}); diff --git a/shopify/server/lib/token.ts b/shopify/server/lib/token.ts new file mode 100644 index 00000000..2a9e46bf --- /dev/null +++ b/shopify/server/lib/token.ts @@ -0,0 +1,132 @@ +/** + * Stateless token helpers for the Shopify OAuth flow. + * + * Two primitives, both keyed off a single shared secret (SHOPIFY_TOKEN_SECRET): + * + * - `signState` / `verifyState` — HMAC-signed (integrity only) blobs used to + * carry the mesh callback URL through Shopify's `state` round-trip. The value + * isn't secret, it just must not be tampered with. + * + * - `encryptCredential` / `decryptCredential` — AES-256-GCM sealed blobs that + * hold `{ shop, token }`. This becomes the connection's access token, so it + * travels back through a browser redirect; encrypting (not just signing) + * keeps the Shopify Admin API token unreadable in URLs, history and logs. + * + * Plus `verifyShopifyHmac`, which validates the HMAC Shopify appends to the + * OAuth callback query so we only trust genuine Shopify redirects. + */ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomBytes, + timingSafeEqual, +} from "node:crypto"; + +const IV_BYTES = 12; +const TAG_BYTES = 16; + +/** The `{ shop, token }` pair sealed into an OAuth connection's access token. */ +export interface SealedCredential { + shop: string; + token: string; +} + +/** The shared secret used to seal/open credentials and sign state. */ +export function getTokenSecret(): string { + return process.env.SHOPIFY_TOKEN_SECRET || ""; +} + +/** Derive a stable 32-byte AES key from the shared secret. */ +function keyFrom(secret: string): Buffer { + return createHash("sha256").update(secret).digest(); +} + +function safeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + return bufA.length === bufB.length && timingSafeEqual(bufA, bufB); +} + +// ── Signed state (integrity only) ──────────────────────────────────────────── + +export function signState(payload: unknown, secret: string): string { + const body = Buffer.from(JSON.stringify(payload), "utf8").toString( + "base64url", + ); + const sig = createHmac("sha256", secret).update(body).digest("base64url"); + return `${body}.${sig}`; +} + +export function verifyState(token: string, secret: string): T | null { + const dot = token.lastIndexOf("."); + if (dot < 0) return null; + const body = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = createHmac("sha256", secret) + .update(body) + .digest("base64url"); + if (!safeEqual(sig, expected)) return null; + try { + return JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as T; + } catch { + return null; + } +} + +// ── Sealed credential (confidentiality + integrity) ────────────────────────── + +export function encryptCredential(payload: unknown, secret: string): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv("aes-256-gcm", keyFrom(secret), iv); + const plaintext = Buffer.from(JSON.stringify(payload), "utf8"); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, ciphertext]).toString("base64url"); +} + +export function decryptCredential(token: string, secret: string): T | null { + try { + const raw = Buffer.from(token, "base64url"); + if (raw.length <= IV_BYTES + TAG_BYTES) return null; + const iv = raw.subarray(0, IV_BYTES); + const tag = raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES); + const ciphertext = raw.subarray(IV_BYTES + TAG_BYTES); + const decipher = createDecipheriv("aes-256-gcm", keyFrom(secret), iv); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + return JSON.parse(plaintext.toString("utf8")) as T; + } catch { + return null; + } +} + +// ── Shopify callback HMAC ───────────────────────────────────────────────────── + +/** + * Verify the `hmac` Shopify appends to OAuth redirects. Per Shopify's spec: + * drop `hmac`/`signature`, sort the remaining params, join as `k=v` with `&`, + * and HMAC-SHA256 with the app's client secret. + * See https://shopify.dev/docs/apps/auth/oauth/getting-started#step-3-validate-the-authorization-code + */ +export function verifyShopifyHmac( + params: URLSearchParams, + clientSecret: string, +): boolean { + const hmac = params.get("hmac"); + if (!hmac) return false; + const entries: string[] = []; + for (const [key, value] of params) { + if (key === "hmac" || key === "signature") continue; + entries.push(`${key}=${value}`); + } + entries.sort(); + const digest = createHmac("sha256", clientSecret) + .update(entries.join("&")) + .digest("hex"); + return safeEqual(digest, hmac); +} diff --git a/shopify/server/main.ts b/shopify/server/main.ts index 19ae4f95..bd0ecd65 100644 --- a/shopify/server/main.ts +++ b/shopify/server/main.ts @@ -9,6 +9,7 @@ import { serve } from "@decocms/mcps-shared/serve"; import { withRuntime } from "@decocms/runtime"; import { tools } from "./tools/index.ts"; import { type Env, StateSchema } from "./types/env.ts"; +import { handleOAuthRoute, shopifyOAuth } from "./lib/oauth.ts"; import packageJson from "../package.json" with { type: "json" }; console.log(`Shopify MCP v${packageJson.version}`); @@ -17,10 +18,17 @@ export type { Env }; export { StateSchema }; const runtime = withRuntime({ + oauth: shopifyOAuth, configuration: { state: StateSchema, }, tools, }); -serve(runtime.fetch); +// Intercept the two custom OAuth routes (store-domain prompt + Shopify +// callback); everything else falls through to the runtime. +serve(async (req, env, ctx) => { + const oauthResponse = await handleOAuthRoute(req); + if (oauthResponse) return oauthResponse; + return runtime.fetch(req, env, ctx); +}); diff --git a/shopify/server/types/env.ts b/shopify/server/types/env.ts index b1acadb0..efa9c2a3 100644 --- a/shopify/server/types/env.ts +++ b/shopify/server/types/env.ts @@ -5,11 +5,6 @@ import { type DefaultEnv } from "@decocms/runtime"; import { z } from "zod"; export const StateSchema = z.object({ - storeDomain: z - .string() - .describe( - "Shopify store domain, e.g. my-store.myshopify.com (the Admin API access token goes in the connection's Authorization field)", - ), apiVersion: z .string() .optional() From 92f5379471545616ed1f7d270a8fc9d1c86afc49 Mon Sep 17 00:00:00 2001 From: guitavano Date: Tue, 28 Jul 2026 12:52:04 -0300 Subject: [PATCH 2/3] chore: rebuild registry.json for shopify OAuth description Co-Authored-By: Claude Opus 4.8 --- registry.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry.json b/registry.json index 9529f1b4..3a2f0950 100644 --- a/registry.json +++ b/registry.json @@ -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": { From 735f84d84799c643c73ce07e4ecc9da8accbd060 Mon Sep 17 00:00:00 2001 From: guitavano Date: Tue, 28 Jul 2026 12:54:53 -0300 Subject: [PATCH 3/3] =?UTF-8?q?refactor(shopify):=20drop=20apiVersion=20co?= =?UTF-8?q?nfig=20field=20=E2=80=94=20zero=20connection=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP now has no connection config: auth is OAuth and the store comes from the token. The Admin API version still defaults to DEFAULT_API_VERSION and can be overridden per deploy via the SHOPIFY_API_VERSION env var. Co-Authored-By: Claude Opus 4.8 --- registry.json | 2 +- shopify/README.md | 4 +++- shopify/app.json | 12 +----------- shopify/server/types/env.ts | 10 ++++------ 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/registry.json b/registry.json index 3a2f0950..1824c3b1 100644 --- a/registry.json +++ b/registry.json @@ -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", diff --git a/shopify/README.md b/shopify/README.md index 1a3e79bc..4d227ac2 100644 --- a/shopify/README.md +++ b/shopify/README.md @@ -18,7 +18,9 @@ Connecting is a one-click **OAuth** flow — no manual token copying: 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). -3. **API Version** (optional config field): defaults to `2026-07`. + +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`, diff --git a/shopify/app.json b/shopify/app.json index 50c3c414..a76309f2 100644 --- a/shopify/app.json +++ b/shopify/app.json @@ -4,17 +4,7 @@ "friendlyName": "Shopify", "connection": { "type": "HTTP", - "url": "https://sites-shopify.deco.site/mcp", - "configSchema": { - "type": "object", - "properties": { - "apiVersion": { - "type": "string", - "title": "API Version", - "description": "Admin GraphQL API version (default 2026-07)" - } - } - } + "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", diff --git a/shopify/server/types/env.ts b/shopify/server/types/env.ts index efa9c2a3..596f3fa6 100644 --- a/shopify/server/types/env.ts +++ b/shopify/server/types/env.ts @@ -4,12 +4,10 @@ import { type DefaultEnv } from "@decocms/runtime"; import { z } from "zod"; -export const StateSchema = z.object({ - apiVersion: z - .string() - .optional() - .describe("Admin GraphQL API version (default 2026-07)"), -}); +// No connection config — auth is OAuth and the store comes from the token. +// The Admin API version defaults to DEFAULT_API_VERSION; override per deploy +// with the SHOPIFY_API_VERSION env var (see resolveCredentials). +export const StateSchema = z.object({}); export type Env = DefaultEnv;