Skip to content
Open
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
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
110 changes: 110 additions & 0 deletions packages/cli-kit/src/public/node/mcp.ts
Original file line number Diff line number Diff line change
@@ -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<DeviceCodeResponse> {
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<string, unknown>
try {
result = JSON.parse(responseText) as Record<string, unknown>
} 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<AdminSession> {
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}
}
50 changes: 50 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
@@ -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" } }, ...] } }
```
2 changes: 2 additions & 0 deletions packages/mcp/bin/shopify-mcp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/index.js'
72 changes: 72 additions & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"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"
},
"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
}
46 changes: 46 additions & 0 deletions packages/mcp/project.json
Original file line number Diff line number Diff line change
@@ -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",
"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"
}
}
}
}
15 changes: 15 additions & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled startup failure can crash the MCP process (missing connect error handling)

await server.connect(transport) runs at top-level without try/catch. If connect() rejects (stdio issues, SDK incompatibility, permissions), Node will treat it as an unhandled rejection during module initialization and terminate the process, making the tool disappear in agent environments.


const shutdown = () => {
const _closing = server
.close()
.then(() => process.exit(0))
.catch(() => process.exit(1))
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
23 changes: 23 additions & 0 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {SessionManager} from './session-manager.js'
import {registerAuthTool} from './tools/auth.js'
import {registerGraphqlTool} from './tools/graphql.js'
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'

import {createRequire} from 'module'

const require = createRequire(import.meta.url)
const {version} = require('../package.json') as {version: string}

export function createServer(): McpServer {
const server = new McpServer({
name: 'shopify',
version,
})

const sessionManager = new SessionManager()

registerAuthTool(server, sessionManager)
registerGraphqlTool(server, sessionManager)

return server
}
Loading
Loading