diff --git a/.changeset/config.json b/.changeset/config.json index b7767ea2657..de23273b2ab 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -11,7 +11,8 @@ "@shopify/theme", "@shopify/ui-extensions-dev-console-app", "@shopify/plugin-cloudflare", - "@shopify/plugin-did-you-mean" + "@shopify/plugin-did-you-mean", + "@shopify/mcp" ]], "access": "public", "baseBranch": "main", diff --git a/package.json b/package.json index 43063efb40a..e6922c9f2f8 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,20 @@ ] } }, + "packages/mcp": { + "entry": [ + "**/src/index.ts!" + ], + "project": "**/*.ts!", + "ignoreDependencies": [ + "zod-to-json-schema" + ], + "vite": { + "config": [ + "vite.config.ts" + ] + } + }, "packages/plugin-did-you-mean": { "entry": [ "**/{commands,hooks}/**/*.ts!", diff --git a/packages/cli-kit/src/public/node/mcp.ts b/packages/cli-kit/src/public/node/mcp.ts new file mode 100644 index 00000000000..c77d6ccb5e5 --- /dev/null +++ b/packages/cli-kit/src/public/node/mcp.ts @@ -0,0 +1,110 @@ +import {identityFqdn} from './context/fqdn.js' +import {BugError} from './error.js' +import {shopifyFetch} from './http.js' +import {clientId, applicationId} from '../../private/node/session/identity.js' +import {pollForDeviceAuthorization} from '../../private/node/session/device-authorization.js' +import {exchangeAccessForApplicationTokens, ExchangeScopes} from '../../private/node/session/exchange.js' +import {allDefaultScopes, apiScopes} from '../../private/node/session/scopes.js' +import * as sessionStore from '../../private/node/session/store.js' +import {setCurrentSessionId} from '../../private/node/conf-store.js' + +import type {AdminSession} from './session.js' + +export interface DeviceCodeResponse { + deviceCode: string + userCode: string + verificationUri: string + verificationUriComplete: string + expiresIn: number + interval: number +} + +/** + * Requests a device authorization code for MCP non-interactive auth. + * + * @returns The device code response with verification URL. + */ +export async function requestDeviceCode(): Promise { + const fqdn = await identityFqdn() + const identityClientId = clientId() + const scopes = allDefaultScopes() + const params = new URLSearchParams({client_id: identityClientId, scope: scopes.join(' ')}).toString() + const url = `https://${fqdn}/oauth/device_authorization` + + const response = await shopifyFetch(url, { + method: 'POST', + headers: {'Content-type': 'application/x-www-form-urlencoded'}, + body: params, + }) + + const responseText = await response.text() + let result: Record + try { + result = JSON.parse(responseText) as Record + } catch { + throw new BugError(`Invalid response from authorization service (HTTP ${response.status})`) + } + + if (!result.device_code || !result.verification_uri_complete) { + throw new BugError('Failed to start device authorization') + } + + return { + deviceCode: result.device_code as string, + userCode: result.user_code as string, + verificationUri: result.verification_uri as string, + verificationUriComplete: result.verification_uri_complete as string, + expiresIn: result.expires_in as number, + interval: (result.interval as number) ?? 5, + } +} + +/** + * Completes device authorization by polling for approval and exchanging tokens. + * + * @param deviceCode - The device code from requestDeviceCode. + * @param interval - Polling interval in seconds. + * @param storeFqdn - The normalized store FQDN. + * @returns An admin session with token and store FQDN. + */ +export async function completeDeviceAuth( + deviceCode: string, + interval: number, + storeFqdn: string, +): Promise { + const identityToken = await pollForDeviceAuthorization(deviceCode, interval) + + const exchangeScopes: ExchangeScopes = { + admin: apiScopes('admin'), + partners: apiScopes('partners'), + storefront: apiScopes('storefront-renderer'), + businessPlatform: apiScopes('business-platform'), + appManagement: apiScopes('app-management'), + } + + const appTokens = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, storeFqdn) + + const fqdn = await identityFqdn() + const sessions = (await sessionStore.fetch()) ?? {} + const newSession = { + identity: identityToken, + applications: appTokens, + } + + const updatedSessions = { + ...sessions, + [fqdn]: {...sessions[fqdn], [identityToken.userId]: newSession}, + } + await sessionStore.store(updatedSessions) + setCurrentSessionId(identityToken.userId) + + const adminAppId = applicationId('admin') + const adminKey = `${storeFqdn}-${adminAppId}` + const adminToken = appTokens[adminKey] + + if (!adminToken) { + throw new BugError(`No admin token received for store ${storeFqdn}`) + } + + return {token: adminToken.accessToken, storeFqdn} +} diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 00000000000..ba91abb7765 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,50 @@ +# @shopify/mcp + +MCP server for the Shopify Admin API. Connects AI coding agents (Claude, Cursor, etc.) to your Shopify store via the [Model Context Protocol](https://modelcontextprotocol.io). + +## Setup + +```bash +claude mcp add shopify -- npx -y -p @shopify/mcp +``` + +Optionally set a default store so you don't have to pass it with every request: + +```bash +export SHOPIFY_FLAG_STORE=my-store.myshopify.com +``` + +## Tools + +### `shopify_auth_login` + +Authenticate with a Shopify store. Returns a URL the user must visit to complete login via device auth. After approval, subsequent `shopify_graphql` calls will use the session automatically. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `store` | string | No | Store domain. Defaults to `SHOPIFY_FLAG_STORE` env var. | + +### `shopify_graphql` + +Execute a GraphQL query or mutation against the Shopify Admin API. Uses the latest supported API version. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `query` | string | Yes | GraphQL query or mutation string | +| `variables` | object | No | GraphQL variables | +| `store` | string | No | Store domain override. Defaults to `SHOPIFY_FLAG_STORE` env var. | +| `allowMutations` | boolean | No | Must be `true` to execute mutations. Safety measure to prevent unintended changes. | + +## Example + +``` +Agent: "List my products" + +→ shopify_auth_login(store: "my-store.myshopify.com") +← "Open this URL to authenticate: https://accounts.shopify.com/activate?user_code=ABCD-EFGH" + +[user approves in browser] + +→ shopify_graphql(query: "{ products(first: 5) { edges { node { title } } } }") +← { "products": { "edges": [{ "node": { "title": "T-Shirt" } }, ...] } } +``` diff --git a/packages/mcp/bin/shopify-mcp-http.js b/packages/mcp/bin/shopify-mcp-http.js new file mode 100644 index 00000000000..abddbf9abc5 --- /dev/null +++ b/packages/mcp/bin/shopify-mcp-http.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/http.js' diff --git a/packages/mcp/bin/shopify-mcp.js b/packages/mcp/bin/shopify-mcp.js new file mode 100755 index 00000000000..33a61be8e60 --- /dev/null +++ b/packages/mcp/bin/shopify-mcp.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/index.js' diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 00000000000..f95f6b5d47c --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,73 @@ +{ + "name": "@shopify/mcp", + "version": "3.91.0", + "description": "MCP server for the Shopify Admin API", + "packageManager": "pnpm@10.11.1", + "private": false, + "keywords": [ + "shopify", + "mcp", + "model-context-protocol" + ], + "homepage": "https://github.com/shopify/cli#readme", + "bugs": { + "url": "https://community.shopify.dev/c/shopify-cli-libraries/14" + }, + "repository": { + "type": "git", + "url": "https://github.com/Shopify/cli.git", + "directory": "packages/mcp" + }, + "license": "MIT", + "type": "module", + "bin": { + "shopify-mcp": "./bin/shopify-mcp.js", + "shopify-mcp-http": "./bin/shopify-mcp-http.js" + }, + "files": [ + "/bin", + "/dist" + ], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "nx build", + "clean": "nx clean", + "lint": "nx lint", + "lint:fix": "nx lint:fix", + "prepack": "NODE_ENV=production pnpm nx build && cp ../../README.md README.md", + "vitest": "vitest", + "type-check": "nx type-check" + }, + "eslintConfig": { + "extends": [ + "../../.eslintrc.cjs" + ] + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.22.0", + "@shopify/cli-kit": "3.91.0", + "zod": "3.24.1", + "zod-to-json-schema": "3.24.5" + }, + "devDependencies": { + "@vitest/coverage-istanbul": "^3.1.4" + }, + "engines": { + "node": ">=20.10.0" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "publishConfig": { + "@shopify:registry": "https://registry.npmjs.org", + "access": "public" + }, + "engine-strict": true +} diff --git a/packages/mcp/project.json b/packages/mcp/project.json new file mode 100644 index 00000000000..733415ff483 --- /dev/null +++ b/packages/mcp/project.json @@ -0,0 +1,46 @@ +{ + "name": "mcp", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/mcp/src", + "projectType": "library", + "tags": ["scope:feature"], + "targets": { + "clean": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm rimraf dist/", + "cwd": "packages/mcp" + } + }, + "build": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist"], + "inputs": ["{projectRoot}/src/**/*", "{projectRoot}/package.json"], + "options": { + "command": "pnpm tsc -b ./tsconfig.build.json && cp -r src/prompts/skills dist/prompts/skills", + "cwd": "packages/mcp" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm eslint \"src/**/*.ts\"", + "cwd": "packages/mcp" + } + }, + "lint:fix": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm eslint 'src/**/*.ts' --fix", + "cwd": "packages/mcp" + } + }, + "type-check": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm tsc --noEmit", + "cwd": "packages/mcp" + } + } + } +} diff --git a/packages/mcp/src/http.ts b/packages/mcp/src/http.ts new file mode 100644 index 00000000000..bfea7f9ce49 --- /dev/null +++ b/packages/mcp/src/http.ts @@ -0,0 +1,120 @@ +import {createServer} from './server.js' +import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import {createServer as createHttpServer, type IncomingMessage, type ServerResponse} from 'node:http' +import {randomUUID} from 'node:crypto' + +const server = createServer() +const transports = new Map() + +const PORT = parseInt(process.env.PORT || '3000', 10) + +function isInitializeRequest(body: unknown): boolean { + if (Array.isArray(body)) { + return body.some( + (msg) => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize', + ) + } + return typeof body === 'object' && body !== null && 'method' in body && (body as {method: string}).method === 'initialize' +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => resolve(Buffer.concat(chunks).toString())) + req.on('error', reject) + }) +} + +async function handlePost(req: IncomingMessage, res: ServerResponse) { + const rawBody = await readBody(req) + const body = JSON.parse(rawBody) as unknown + const sessionId = req.headers['mcp-session-id'] as string | undefined + + if (sessionId && transports.has(sessionId)) { + await transports.get(sessionId)!.handleRequest(req, res, body) + return + } + + if (!sessionId && isInitializeRequest(body)) { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + transports.set(id, transport) + }, + }) + + transport.onclose = () => { + if (transport.sessionId) { + transports.delete(transport.sessionId) + } + } + + await server.connect(transport) + await transport.handleRequest(req, res, body) + return + } + + res.writeHead(400, {'Content-Type': 'application/json'}) + res.end(JSON.stringify({jsonrpc: '2.0', error: {code: -32600, message: 'Bad request: missing or invalid session'}, id: null})) +} + +async function handleGet(req: IncomingMessage, res: ServerResponse) { + const sessionId = req.headers['mcp-session-id'] as string | undefined + if (!sessionId || !transports.has(sessionId)) { + res.writeHead(400, {'Content-Type': 'application/json'}) + res.end(JSON.stringify({jsonrpc: '2.0', error: {code: -32600, message: 'Invalid or missing session'}, id: null})) + return + } + await transports.get(sessionId)!.handleRequest(req, res) +} + +async function handleDelete(req: IncomingMessage, res: ServerResponse) { + const sessionId = req.headers['mcp-session-id'] as string | undefined + if (!sessionId || !transports.has(sessionId)) { + res.writeHead(400, {'Content-Type': 'application/json'}) + res.end(JSON.stringify({jsonrpc: '2.0', error: {code: -32600, message: 'Invalid or missing session'}, id: null})) + return + } + await transports.get(sessionId)!.handleRequest(req, res) +} + +const httpServer = createHttpServer(async (req, res) => { + if (req.url !== '/mcp') { + res.writeHead(404, {'Content-Type': 'application/json'}) + res.end(JSON.stringify({error: 'Not found'})) + return + } + + try { + if (req.method === 'POST') { + await handlePost(req, res) + } else if (req.method === 'GET') { + await handleGet(req, res) + } else if (req.method === 'DELETE') { + await handleDelete(req, res) + } else { + res.writeHead(405, {'Content-Type': 'application/json'}) + res.end(JSON.stringify({error: 'Method not allowed'})) + } + } catch (error) { + if (!res.headersSent) { + res.writeHead(500, {'Content-Type': 'application/json'}) + res.end(JSON.stringify({jsonrpc: '2.0', error: {code: -32603, message: 'Internal server error'}, id: null})) + } + } +}) + +httpServer.listen(PORT, () => { + console.error(`Shopify MCP server (HTTP) listening on http://localhost:${PORT}/mcp`) +}) + +const shutdown = () => { + httpServer.close() + const _closing = server + .close() + .then(() => process.exit(0)) + .catch(() => process.exit(1)) +} +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 00000000000..8fb32f37e44 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,15 @@ +import {createServer} from './server.js' +import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js' + +const server = createServer() +const transport = new StdioServerTransport() +await server.connect(transport) + +const shutdown = () => { + const _closing = server + .close() + .then(() => process.exit(0)) + .catch(() => process.exit(1)) +} +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) diff --git a/packages/mcp/src/prompts/liquid_themes.test.ts b/packages/mcp/src/prompts/liquid_themes.test.ts new file mode 100644 index 00000000000..6af994d973a --- /dev/null +++ b/packages/mcp/src/prompts/liquid_themes.test.ts @@ -0,0 +1,32 @@ +import {handleLiquidThemesSkill} from './liquid_themes.js' +import {describe, test, expect} from 'vitest' + +describe('handleLiquidThemesSkill', () => { + test('returns a single text content item', () => { + const result = handleLiquidThemesSkill() + + expect(result.content).toHaveLength(1) + expect(result.content[0]!.type).toBe('text') + expect(result.content[0]!.text.length).toBeGreaterThan(0) + }) + + test('includes SKILL.md and reference files separated by ---', () => { + const result = handleLiquidThemesSkill() + const sections = result.content[0]!.text.split('\n\n---\n\n') + + expect(sections.length).toBe(11) + for (const section of sections.slice(1)) { + expect(section).toMatch(/^# Reference: /) + } + }) + + test('reference files are sorted alphabetically', () => { + const result = handleLiquidThemesSkill() + const refNames = result.content[0]!.text + .split('\n\n---\n\n') + .slice(1) + .map((s) => s.split('\n')[0]) + + expect(refNames).toEqual([...refNames].sort()) + }) +}) diff --git a/packages/mcp/src/prompts/liquid_themes.ts b/packages/mcp/src/prompts/liquid_themes.ts new file mode 100644 index 00000000000..593251f0967 --- /dev/null +++ b/packages/mcp/src/prompts/liquid_themes.ts @@ -0,0 +1,35 @@ +import {readFileSync, readdirSync} from 'fs' +import {join, dirname} from 'path' +import {fileURLToPath} from 'url' + +import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' + +const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), 'skills', 'shopify-liquid-themes') + +function readSkillFile(): string { + return readFileSync(join(SKILL_DIR, 'SKILL.md'), 'utf-8') +} + +function readReferenceFiles(): Array<{name: string; content: string}> { + const refsDir = join(SKILL_DIR, 'references') + return readdirSync(refsDir) + .filter((f) => f.endsWith('.md')) + .sort() + .map((name) => ({name, content: readFileSync(join(refsDir, name), 'utf-8')})) +} + +export function handleLiquidThemesSkill(): {content: Array<{type: 'text'; text: string}>} { + console.error('[tool_call] shopify_liquid_themes') + const skill = readSkillFile() + const refs = readReferenceFiles() + const text = [skill, ...refs.map((r) => `# Reference: ${r.name}\n\n${r.content}`)].join('\n\n---\n\n') + return {content: [{type: 'text', text}]} +} + +export function registerLiquidThemesTool(server: McpServer) { + server.tool( + 'shopify_liquid_themes', + 'Liquid syntax, filters, tags, objects, schema, and settings for Shopify themes. Call this tool to get comprehensive Liquid theme development guidance.', + () => handleLiquidThemesSkill(), + ) +} diff --git a/packages/mcp/src/prompts/skills/liquid-theme-a11y/SKILL.md b/packages/mcp/src/prompts/skills/liquid-theme-a11y/SKILL.md new file mode 100644 index 00000000000..22607e1224e --- /dev/null +++ b/packages/mcp/src/prompts/skills/liquid-theme-a11y/SKILL.md @@ -0,0 +1,434 @@ +--- +name: liquid-theme-a11y +description: "Implement WCAG 2.2 accessibility patterns in Shopify Liquid themes. Covers e-commerce-specific components including product cards, carousels, cart drawers, price display, forms, filters, and modals. Use when building accessible theme components, fixing accessibility issues, or reviewing ARIA patterns in .liquid files." +--- + +# Accessibility for Shopify Liquid Themes + +## Core Principle + +Every interactive component must work with keyboard only, screen readers, and reduced-motion preferences. Start with semantic HTML — add ARIA only when native semantics are insufficient. + +## Decision Table: Which Pattern? + +| Component | HTML Element | ARIA Pattern | Reference | +|-----------|-------------|-------------|-----------| +| Expandable content | `
/` | None needed | [Accordion](#accordion) | +| Modal/dialog | `` | `aria-modal="true"` | [Modal](#modal) | +| Tooltip/popup | `[popover]` attribute | `role="tooltip"` fallback | [Tooltip](#tooltip) | +| Dropdown menu | `