
This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.
To build an MCP server for your Shopify store, you create an app in Shopify's Dev Dashboard, exchange its client ID and secret for a 24-hour Admin API token, and wrap a few GraphQL queries in MCP tools. This guide builds two: one that searches orders, and one that adds a tag to an order. Your staff can then ask their AI app, "Which paid orders from this week are still unfulfilled?"
First, check whether Shopify already covers your need. Shopify runs several official MCP servers: Dev MCP for developers, storefront servers for shopping agents, and a Customer Accounts server. For store admin work, Shopify documents connectors for AI tools in its help center. Our Shopify MCP server profile compares them. Build your own when you want a small, fixed set of admin tools with your own rules.
Checked against: Shopify's client credentials grant docs, GraphQL Admin API reference (version 2026-10) and the Python MCP SDK docs on 2 November 2026, with mcp 2.3.0 and httpx 0.28.1. The server ran locally on 2 November 2026 against a mock of Shopify's token and GraphQL endpoints, not a live store.
Key Takeaways
- Since 1 January 2026, new custom apps are created in Shopify's Dev Dashboard, not in the store admin.
- For your own store, the client credentials grant returns an Admin API token that expires after 24 hours. Cache it and refresh it.
- The token carries every scope the store granted your app, so request only
read_orders(pluswrite_ordersif you add the tagging tool). - Keep write tools narrow. Tagging an order is low risk; refunds and cancellations should stay with people.
- Pin the API version in the URL, such as
2026-10, and plan to update it.
Prerequisites
- Python 3.11 or newer.
- A Shopify store, and the "App developer" role in your Shopify organization.
- Node.js 22.19+ for the MCP Inspector.
New to MCP tools? Start with lesson 14 of the Learn MCP course.
Step 1: Create the app in the Dev Dashboard
Shopify's custom apps help page says: "You create and manage custom apps using the Dev Dashboard." It adds that legacy custom apps created before 1 January 2026 can still be managed from the Shopify admin. Users need the "App developer" role to create custom apps in the Dev Dashboard.
In the app's configuration, request the Admin API scopes you need. Shopify's access scopes page says "read_orders provides read access and write_orders provides read and write access," and that both "cover orders created within the last 60 days." Add read_all_orders only if you need older orders.
Install the app on your store, then copy the client ID and client secret.
Step 2: Understand the token
The client credentials grant is for "a server-side app acting on stores in your own Shopify organization," per Shopify's docs. The rules that shape the code:
- You POST to
https://{SHOP}.myshopify.com/admin/oauth/access_tokenwithgrant_type=client_credentials,client_idandclient_secret. - Do not send a
scope. Shopify says it ignores it. - Tokens expire after 24 hours, and
expires_inis always 86399 seconds. Shopify's example refreshes 60 seconds early. - The token carries every scope the store granted your installation, which may be broader than you expect.
- An app and store in different organizations get
shop_not_permitted.
Install
uv init shopify-mcp --python 3.12
cd shopify-mcp
uv add "mcp[cli]" httpx
Minimal working server
Create server.py:
import os
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Annotated, Any
import httpx
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
SHOP_URL = os.environ["SHOPIFY_SHOP_URL"] # e.g. https://your-store.myshopify.com
API_VERSION = "2026-10"
CLIENT_ID = os.environ["SHOPIFY_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPIFY_CLIENT_SECRET"]
@dataclass
class Shopify:
http: httpx.AsyncClient
token: str | None = None
expires_at: float = 0.0
async def access_token(self) -> str:
"""Client credentials grant; tokens last 24 hours, so refresh a minute early."""
if self.token and time.time() < self.expires_at - 60:
return self.token
resp = await self.http.post(
"/admin/oauth/access_token",
data={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
)
if resp.is_error:
raise ToolError(f"Shopify token request failed ({resp.status_code}): {resp.text[:200]}")
body = resp.json()
self.token, self.expires_at = body["access_token"], time.time() + body["expires_in"]
return self.token
async def graphql(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
resp = await self.http.post(
f"/admin/api/{API_VERSION}/graphql.json",
json={"query": query, "variables": variables},
headers={"X-Shopify-Access-Token": await self.access_token()},
)
if resp.is_error:
raise ToolError(f"Shopify returned {resp.status_code}")
body = resp.json()
if body.get("errors"):
raise ToolError(f"GraphQL error: {body['errors'][0].get('message')}")
return body["data"]
@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[Shopify]:
async with httpx.AsyncClient(base_url=SHOP_URL, timeout=20) as http:
yield Shopify(http=http)
mcp = MCPServer("shopify-store", lifespan=lifespan)
ORDERS_QUERY = """
query Orders($first: Int!, $query: String) {
orders(first: $first, query: $query, reverse: true) {
edges { node {
id name createdAt displayFinancialStatus displayFulfillmentStatus
totalPriceSet { shopMoney { amount currencyCode } }
} }
}
}
"""
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
async def search_orders(
ctx: Context[Shopify],
query: Annotated[str | None, Field(description="Shopify search syntax, e.g. 'fulfillment_status:unfulfilled' or 'name:#1001'.")] = None,
first: Annotated[int, Field(ge=1, le=25)] = 10,
) -> list[dict[str, Any]]:
"""Find recent orders (newest first) with their payment and fulfillment status."""
shop = ctx.request_context.lifespan_context
data = await shop.graphql(ORDERS_QUERY, {"first": first, "query": query})
return [
{
"id": e["node"]["id"],
"name": e["node"]["name"],
"created": e["node"]["createdAt"],
"payment": e["node"]["displayFinancialStatus"],
"fulfillment": e["node"]["displayFulfillmentStatus"],
"total": f'{e["node"]["totalPriceSet"]["shopMoney"]["amount"]} {e["node"]["totalPriceSet"]["shopMoney"]["currencyCode"]}',
}
for e in data["orders"]["edges"]
]
if __name__ == "__main__":
mcp.run()
How it fits together:
- The
Shopifyhelper holds one HTTP client, caches the token and refreshes it a minute before expiry. Tools never see the client secret or the token. - The lifespan creates it once. The SDK's lifespan guide says whatever it yields is shared by every tool call.
- The GraphQL endpoint follows the reference:
POST https://{store}.myshopify.com/admin/api/2026-10/graphql.jsonwith the token in theX-Shopify-Access-Tokenheader. - The query uses fields from Shopify's orders query reference, which documents a
queryargument with search syntax and areverseflag. Combined with the default sort key,PROCESSED_AT,reverse: truereturns newest first. - The output is flattened. The model gets name, date, statuses and total, not nested edges and nodes.
Add a safe write tool
Tagging is a good first write. It changes nothing about money or shipping, and staff can see the tag on the order in the admin. Add:
TAGS_ADD = """
mutation addTags($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { message } }
}
"""
@mcp.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True))
async def tag_order(
order_id: Annotated[str, Field(pattern=r"^gid://shopify/Order/\d+$", description="Order GID from search_orders.")],
tag: Annotated[str, Field(pattern=r"^[A-Za-z0-9 _-]{1,40}$")],
ctx: Context[Shopify],
) -> str:
"""Add one tag to an order, e.g. 'needs-follow-up'. Does not change payment or fulfillment."""
shop = ctx.request_context.lifespan_context
data = await shop.graphql(TAGS_ADD, {"id": order_id, "tags": [tag]})
if errors := data["tagsAdd"]["userErrors"]:
raise ToolError(f"Shopify refused the tag: {errors[0]['message']}")
return f"Tagged {order_id} with {tag!r}."
The mutation matches Shopify's tagsAdd reference, which takes an id and a list of tags and returns userErrors. The patterns mean the model can only tag orders, with short plain tags. Adding the same tag twice leaves the order the same, so the tool is marked idempotent.
Test it
On 2 November 2026 the server ran against a local mock of Shopify's token and GraphQL endpoints. The mock checked that every GraphQL call carried the cached token. The results:
{'result': [{'id': 'gid://shopify/Order/1001', 'name': '#1001', 'created': '2026-11-01T10:00:00Z', 'payment': 'PAID', 'fulfillment': 'UNFULFILLED', 'total': '84.00 USD'}]}
Tagged gid://shopify/Order/1001 with 'needs-follow-up'.
True Error executing tool tag_order: 1 validation error for tag_orderArguments
The last call passed "1001" instead of a full order GID, and validation stopped it before any request. The mock confirms the plumbing, not Shopify's real responses. Before going live, run the same calls against a development store.
Then test end to end with the MCP Inspector:
npx @modelcontextprotocol/inspector --cli \
-e SHOPIFY_SHOP_URL=https://your-store.myshopify.com \
-e SHOPIFY_CLIENT_ID=... -e SHOPIFY_CLIENT_SECRET=... \
uv --directory /ABSOLUTE/PATH/shopify-mcp run server.py \
--method tools/call --tool-name search_orders \
--tool-args-json '{"query":"fulfillment_status:unfulfilled","first":5}' --format json
Connect it to a host
Use our MCP config generator for Claude Desktop, Cursor or VS Code, with the three variables in the env block. For Claude Code:
claude mcp add -e SHOPIFY_SHOP_URL=https://your-store.myshopify.com \
-e SHOPIFY_CLIENT_ID=... -e SHOPIFY_CLIENT_SECRET=... \
--transport stdio shopify-store -- uv run --directory /ABSOLUTE/PATH/shopify-mcp server.py
Deploy notes
- Rate limits. Shopify's reference says the GraphQL Admin API is "rate-limited using calculated query costs, measured in cost points." Keep
firstsmall and request only the fields you use. - API versions. The version sits in the URL. When Shopify publishes a newer version, test against it and then bump
API_VERSION. - Secrets. The client secret can mint tokens with every granted scope. Keep it in a secret manager, not in a shared config file.
- Team access. For several staff, run the server remotely over Streamable HTTP with sign-in, and log each tool call with the user. See adding OAuth to a remote MCP server.
Pitfalls
- Following old tutorials. Guides that tell you to "Develop apps" in the admin and copy a static
shpat_token describe legacy custom apps. New apps use the Dev Dashboard and short-lived tokens. - Sending the client secret to GraphQL. Shopify's docs say to send the access token, not your client ID or secret. A 401 often means this mistake.
- Too many scopes. The token carries everything the store granted. Remove unused scopes from the app.
- Risky writes. Do not add refund, cancel or price tools without a human approval step. Our MCP for ecommerce guide covers what to keep human.
- v1 MCP imports.
mcp.server.fastmcpis gone in SDK v2. Usefrom mcp.server import MCPServer.
Next step
Build your MCP skills step by step in the free Learn MCP course. For a store-specific server with inventory, order and reporting tools plus approvals, talk to eSEOspace MCP development. Our inventory reorder playbook shows how a team might use one day to day.
More in the MCP Guide
Free tools: server.json Validator · MCP Error Code Lookup
Latest research: State of MCP: October 2026
Frequently asked questions
Can I use this for a client's store?
Not with this setup. Shopify says the client credentials grant works only when the app and store are in the same organization. Its docs say reaching a store outside your organization requires public distribution, a client assertion and a merchant install, and recommend Shopify CLI for apps built for other merchants.
Why does the token expire every day?
Shopify sets client credentials tokens to 24 hours. The helper in this guide refreshes automatically, so the server keeps working.
Can the model see customer details?
Only fields you query. This server returns order name, date, status and total. Avoid adding customer names, emails or addresses unless the job needs them.
Is Shopify's Storefront MCP the same thing?
No. Storefront servers serve shoppers: product search, carts and checkout. This server works on the admin side, for staff. Shopify moved its storefront catalog and cart tools to a UCP endpoint, which our profile covers.
How do I find orders older than 60 days?
Request the read_all_orders scope alongside read_orders. Shopify's access scopes page says it extends access to older orders but grants nothing on its own.











