shopify-mcp

shopify-mcp

An MCP server boilerplate for Shopify apps that handles OAuth authentication, allowing merchants to log in through Shopify instead of copy-pasting a token.

Category
访问服务器

README

shopify-mcp

An MCP server boilerplate for Shopify apps, where the merchant logs in through Shopify instead of copy-pasting a token.

Two packages:

Package What it is
shopify-mcp-oauth The OAuth layer: authorization server, resource server, and requireAuth
create-shopify-mcp npx create-shopify-mcp my-mcp — scaffolds a running server

Plus examples/basic-server, which is both the demo and the scaffolder's template.

Quickstart

npx create-shopify-mcp my-mcp
cd my-mcp && pnpm install
cp .env.example .env      # add your Shopify API key and secret
docker compose up -d
pnpm db:migrate && pnpm db:seed
pnpm dev

Then expose it with a tunnel, set MCP_HOST, add <MCP_HOST>/oauth/shopify-callback to your Partner app's allowed redirection URLs, and connect:

claude mcp add --transport http my-mcp https://<your-tunnel>/mcp

Why this exists

The hard part of building an MCP server for a Shopify app is not the tools — it is the auth. The MCP specification requires the server to be both an OAuth resource server and an OAuth authorization server, to support PKCE, to accept two different client-identification schemes, and to serve six discovery documents that different clients look for in different places. Get one wrong and Claude Code, VS Code, Cursor, and ChatGPT each fail in a different, silent way.

This ships that layer, and leaves the tools to you.

Using the package directly

import express from "express";
import { createShopifyMcpOAuth, prismaStorage, shopifySessionStorage, redisCache } from "shopify-mcp-oauth";

const oauth = createShopifyMcpOAuth({
  host: "https://mcp.example.com",
  shopify: {
    apiKey: process.env.SHOPIFY_API_KEY!,
    apiSecret: process.env.SHOPIFY_API_SECRET!,
    scopes: "read_products,write_products",
  },
  stateSecret: process.env.OAUTH_STATE_SECRET!,
  storage: {
    ...prismaStorage(prisma),
    findShopByDomain: shopifySessionStorage(sessionStorage),
  },
  cache: redisCache(redis),
});

const app = express();
app.use(express.json());
app.use(oauth.router);
app.post("/mcp", oauth.requireAuth, myMcpHandler);

// Must be the LAST app.use(...) call, after every body-parser and route above (oauth.router
// included) -- a body-parser's SyntaxError on malformed JSON is thrown before Express ever
// reaches oauth.router, so an error handler mounted inside that router can't catch it. Only an
// error handler registered here, at this app's own outermost level, sees it.
app.use(oauth.errorHandler);

requireAuth sets req.mcp = { shopId, shopDomain, tokenId }.

Configuration

Field Required Default Notes
host yes — Public origin, HTTPS in production, no trailing slash. Every issued URL derives from this.
shopify.apiKey / apiSecret yes — Your Shopify app's credentials — the same app the merchant installed.
shopify.scopes yes — Comma-separated. Must match the installed app's scopes or Shopify re-prompts.
stateSecret yes — HS256 signing key for the state JWT. At least 32 characters.
storage yes — An OAuthStorage. See docs/storage-adapters.md.
cache no memoryCache() Holds authorization codes and fetched client metadata documents.
cimdFetchConcurrency no 10 Caps concurrent fetches of client-metadata documents; requests beyond the cap queue for a free slot.
tokenTtl.access no 3600 Seconds.
tokenTtl.refresh no 2592000 Seconds — 30 days.
openaiAppsChallengeToken no null Only needed to list the server as a ChatGPT app. The route is omitted when null.
registerRateLimit no { limit: 20, windowMs: 3600000 } Dynamic client registration is unauthenticated by definition.
revokeRateLimit no { limit: 20, windowMs: 3600000 } /revoke is also unauthenticated by design (RFC 7009) — its own field, tuned independently of registerRateLimit.
logger no console Anything with info / warn / error.
fetchImpl no the global fetch Test seam: the package uses this for Shopify's own token exchange and for fetching client-metadata documents.

Configuration is validated when you construct it. A missing or malformed value throws immediately, naming the field — never at the first request.

How login works

1. Client reads /.well-known/oauth-protected-resource (or is pointed there by a 401).
2. Client identifies itself — either a client-metadata URL, or by registering at /register.
3. Client opens a browser at /authorize with a PKCE challenge.
4. We redirect to Shopify's shop picker. The merchant chooses a store and approves.
5. Shopify calls /oauth/shopify-callback. We verify its HMAC and exchange the code —
   proof the merchant controls that shop. The Shopify token is then discarded.
6. We check the shop is one you know. If not: 403 shop_not_installed.
7. We issue a 60-second authorization code, the client redeems it at /token with its
   PKCE verifier, and gets an access token and a refresh token.
8. The client calls POST /mcp with `Authorization: Bearer <access_token>`.

The merchant's browser is the only participant that talks to Shopify during consent.

The install gate

Step 6 is load-bearing. Completing Shopify's flow does not prove the app was already installed — Shopify installs it on approval if it was not. Without the shop lookup, any merchant on Shopify could mint a token for your server. allowAnyShop() removes that check; only use it if your app genuinely keeps no per-shop record.

Read this before deploying

The default cache is single-process. Authorization codes live in the cache, so with more than one instance a login started on one instance fails on another, and the client reports an opaque invalid_grant. Pass cache: redisCache(redis) before you scale past one process.

Other decisions worth knowing:

  • Access and refresh tokens are stored as SHA-256 hashes — a database leak yields no usable tokens.
  • PKCE S256 only. plain is rejected.
  • Redirect URIs must match exactly, except that localhost and 127.0.0.1 ignore the port, which RFC 8252 requires for native clients binding an ephemeral port.
  • Client-metadata documents are fetched with SSRF guards: HTTPS only, no private or loopback addresses, no redirects followed, a size cap, and a hard timeout.
  • Refresh rotates: redeeming a refresh token revokes it and issues a new pair.
  • /revoke answers 200 whether or not the submitted token existed, per RFC 7009, so it cannot be used to probe which tokens exist. A rate limiter sits in front of the endpoint (revokeRateLimit above) and can answer 429 under heavy call volume from one caller — that carries no information about any particular token's validity, so it doesn't reopen the oracle RFC 7009 guards against.

Development

pnpm install
pnpm build        # required before the example's tests — they import the built package
pnpm test
pnpm typecheck
pnpm lint

License

MIT.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选