Building Custom MCP Servers for Shopify
Existing MCP servers cover common use cases, but every Shopify business has unique needs. Maybe you need an MCP server that combines inventory data with your proprietary demand forecasting model. Or one that enforces your specific business rules when creating products. Or one that integrates Shopify with an internal ERP system.
This guide walks you through building a custom MCP server from scratch using the official TypeScript SDK, implementing Shopify Admin API tools, and publishing it for your team.
When to Build Custom vs Use Existing
Build a custom MCP server when:
- Existing servers don't cover your use case: You need tools that combine Shopify data with internal systems
- You need business logic enforcement: Product creation must follow specific naming conventions, pricing rules, or approval workflows
- Security requirements demand it: You need to control exactly which API operations are exposed and audit their usage
- You want domain-specific abstractions: Instead of raw CRUD, you want tools like "create_seasonal_collection" that encode your business processes
- Performance optimization: You need to batch or cache Shopify API calls in ways generic servers don't support
Use existing servers when:
- Standard CRUD operations are sufficient
- You're prototyping or exploring
- The community server is actively maintained and covers your needs
Use the community shopify-mcp server for 80% of your needs (standard operations), and build a custom server for the 20% that's unique to your business. They can run simultaneously -- Claude Code supports multiple MCP servers.
MCP SDK Setup (TypeScript)
Project Initialization
mkdir shopify-custom-mcp
cd shopify-custom-mcp
npm init -y
npm install @modelcontextprotocol/sdk @shopify/shopify-api zod
npm install -D typescript @types/node tsx
npx tsc --init
Update your tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"declaration": true
},
"include": ["src/**/*"]
}
Update package.json:
{
"name": "shopify-custom-mcp",
"version": "1.0.0",
"type": "module",
"bin": {
"shopify-custom-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
}
}
Basic Server Structure
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Parse command-line arguments
const args = process.argv.slice(2);
const accessToken = args.find(a => a.startsWith("--token="))?.split("=")[1]
|| process.env.SHOPIFY_ACCESS_TOKEN;
const storeDomain = args.find(a => a.startsWith("--domain="))?.split("=")[1]
|| process.env.SHOPIFY_STORE_DOMAIN;
if (!accessToken || !storeDomain) {
console.error("Usage: shopify-custom-mcp --token=shpat_xxx --domain=store.myshopify.com");
process.exit(1);
}
// Create the MCP server
const server = new McpServer({
name: "shopify-custom-mcp",
version: "1.0.0",
});
// Shopify API helper
async function shopifyGraphQL(query: string, variables?: Record<string, unknown>) {
const response = await fetch(
`https://${storeDomain}/admin/api/2025-01/graphql.json`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": accessToken!,
},
body: JSON.stringify({ query, variables }),
}
);
if (!response.ok) {
throw new Error(`Shopify API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// --- Register tools, resources, and prompts here ---
// (See sections below)
// Start the server with stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Shopify Custom MCP server running on stdio");
}
main().catch(console.error);
Implementing Shopify Admin API Tools
Tool 1: Get Low Inventory Products
This tool combines a Shopify query with business logic (custom threshold):
// Register a tool to find products with low inventory
server.tool(
"get_low_inventory_products",
"Find products where total inventory is below a threshold",
{
threshold: z.number().default(10).describe("Inventory threshold (default: 10)"),
location_id: z.string().optional().describe("Filter by location ID (optional)"),
},
async ({ threshold, location_id }) => {
const query = `
query GetProducts($first: Int!, $after: String) {
products(first: 50, after: $after) {
edges {
node {
id
title
totalInventory
variants(first: 100) {
edges {
node {
id
title
inventoryQuantity
sku
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
// Paginate through all products
let allProducts: any[] = [];
let hasNextPage = true;
let cursor: string | null = null;
while (hasNextPage) {
const result = await shopifyGraphQL(query, {
first: 50,
after: cursor,
});
const products = result.data.products.edges.map((e: any) => e.node);
allProducts.push(...products);
hasNextPage = result.data.products.pageInfo.hasNextPage;
cursor = result.data.products.pageInfo.endCursor;
}
// Filter by threshold
const lowInventory = allProducts
.filter((p: any) => p.totalInventory < threshold)
.map((p: any) => ({
id: p.id,
title: p.title,
totalInventory: p.totalInventory,
variants: p.variants.edges.map((v: any) => ({
title: v.node.title,
sku: v.node.sku,
quantity: v.node.inventoryQuantity,
})),
}));
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
threshold,
count: lowInventory.length,
products: lowInventory,
}, null, 2),
},
],
};
}
);
Tool 2: Create Product with Business Rules
This tool enforces your company's product creation standards:
server.tool(
"create_standard_product",
"Create a product following company standards (auto-generates SKU, applies naming conventions, sets required metafields)",
{
title: z.string().describe("Product title"),
category: z.enum(["apparel", "accessories", "home", "electronics"]).describe("Product category"),
base_price: z.number().positive().describe("Base price in dollars"),
description: z.string().describe("Product description"),
vendor: z.string().optional().describe("Vendor name (defaults to company name)"),
},
async ({ title, category, base_price, description, vendor }) => {
// Business rule: Title must be title case
const formattedTitle = title
.split(" ")
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
// Business rule: Auto-generate SKU prefix by category
const skuPrefixes: Record<string, string> = {
apparel: "APR",
accessories: "ACC",
home: "HOM",
electronics: "ELC",
};
const skuPrefix = skuPrefixes[category];
const skuNumber = Date.now().toString(36).toUpperCase();
const sku = `${skuPrefix}-${skuNumber}`;
// Business rule: Minimum description length
if (description.length < 50) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({
error: "Description must be at least 50 characters for SEO compliance",
currentLength: description.length,
}),
}],
isError: true,
};
}
// Business rule: Price must be in standard tiers
const validPrices = [19.99, 29.99, 39.99, 49.99, 59.99, 79.99, 99.99, 149.99, 199.99];
const nearestPrice = validPrices.reduce((prev, curr) =>
Math.abs(curr - base_price) < Math.abs(prev - base_price) ? curr : prev
);
const mutation = `
mutation CreateProduct($input: ProductInput!) {
productCreate(input: $input) {
product {
id
title
handle
variants(first: 1) {
edges {
node {
id
sku
price
}
}
}
}
userErrors {
field
message
}
}
}
`;
const result = await shopifyGraphQL(mutation, {
input: {
title: formattedTitle,
descriptionHtml: `<p>${description}</p>`,
vendor: vendor || "Your Company Name",
productType: category,
tags: [category, "new-arrival"],
variants: [{
price: nearestPrice.toString(),
sku,
inventoryManagement: "SHOPIFY",
}],
metafields: [
{
namespace: "custom",
key: "category",
value: category,
type: "single_line_text_field",
},
{
namespace: "custom",
key: "created_via",
value: "mcp-server",
type: "single_line_text_field",
},
],
},
});
const { productCreate } = result.data;
if (productCreate.userErrors.length > 0) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({ errors: productCreate.userErrors }),
}],
isError: true,
};
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({
message: "Product created successfully",
product: productCreate.product,
appliedRules: {
titleFormatted: formattedTitle !== title,
skuGenerated: sku,
priceAdjusted: nearestPrice !== base_price
? `Adjusted from $${base_price} to nearest tier $${nearestPrice}`
: "No adjustment needed",
},
}, null, 2),
}],
};
}
);
Adding Resources
Resources provide read-only context that helps the AI understand your Shopify store's configuration:
// Static resource: Store configuration
server.resource(
"store-config",
"shopify://store/config",
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
domain: storeDomain,
apiVersion: "2025-01",
categories: ["apparel", "accessories", "home", "electronics"],
priceTiers: [19.99, 29.99, 39.99, 49.99, 59.99, 79.99, 99.99, 149.99, 199.99],
skuPrefixes: {
apparel: "APR",
accessories: "ACC",
home: "HOM",
electronics: "ELC",
},
businessRules: {
minDescriptionLength: 50,
requiredMetafields: ["custom.category"],
defaultVendor: "Your Company Name",
},
}, null, 2),
}],
})
);
// Dynamic resource: Store summary (fetched live)
server.resource(
"store-summary",
"shopify://store/summary",
async (uri) => {
const result = await shopifyGraphQL(`{
shop {
name
email
currencyCode
primaryDomain { url }
}
products(first: 1) { edges { cursor } }
orders(first: 1) { edges { cursor } }
}`);
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(result.data, null, 2),
}],
};
}
);
Adding Prompts
Prompts provide reusable conversation starters for common workflows:
server.prompt(
"weekly_inventory_report",
"Generate a weekly inventory status report",
{
threshold: z.string().default("10").describe("Low stock threshold"),
},
async ({ threshold }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Generate a weekly inventory report for our Shopify store.
1. Use the get_low_inventory_products tool with threshold ${threshold}
2. Organize results by category
3. For each low-stock product, include:
- Product title and SKU
- Current inventory level
- How far below threshold it is
4. Prioritize products with 0 inventory (out of stock) at the top
5. Include a summary with total products checked, products below threshold, and out-of-stock count
6. Format as a clean Markdown table`,
},
},
],
})
);
server.prompt(
"new_product_launch",
"Guided workflow for launching a new product",
{
product_name: z.string().describe("Name of the product to launch"),
},
async ({ product_name }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Help me launch a new product: "${product_name}"
Walk me through this checklist:
1. Create the product using create_standard_product with proper category and pricing
2. Verify the product was created correctly
3. Check that SKU was generated and metafields were set
4. Suggest collections to add it to based on the category
5. Draft a product description if one wasn't provided (minimum 50 chars)
6. Recommend initial inventory levels based on similar products
Ask me for any required information before proceeding.`,
},
},
],
})
);
Testing with MCP Inspector
The MCP Inspector is a visual testing tool that lets you interact with your server without connecting it to an AI host.
Install and Run
npx @modelcontextprotocol/inspector tsx src/index.ts -- --token=shpat_xxx --domain=store.myshopify.com
This opens a web interface where you can:
- View available tools: See all registered tools with their schemas
- Test tool calls: Fill in parameters and execute tools
- Inspect responses: View the JSON responses from each tool
- Check resources: Browse and read registered resources
- Test prompts: Execute prompts and see the generated messages
Always test your MCP server with the Inspector before connecting it to Claude Code. This catches schema errors, authentication issues, and runtime errors without the overhead of an AI conversation.
Unit Testing
Write proper tests for your tool logic:
import { describe, it, expect, vi } from 'vitest';
// Test the business logic separately from the MCP layer
describe('Product creation rules', () => {
it('formats titles to title case', () => {
const input = "organic COTTON t-shirt";
const result = input
.split(" ")
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
expect(result).toBe("Organic Cotton T-shirt");
});
it('generates correct SKU prefix for category', () => {
const prefixes: Record<string, string> = {
apparel: "APR",
accessories: "ACC",
home: "HOM",
electronics: "ELC",
};
expect(prefixes["apparel"]).toBe("APR");
expect(prefixes["electronics"]).toBe("ELC");
});
it('snaps price to nearest tier', () => {
const tiers = [19.99, 29.99, 39.99, 49.99];
const input = 35;
const nearest = tiers.reduce((prev, curr) =>
Math.abs(curr - input) < Math.abs(prev - input) ? curr : prev
);
expect(nearest).toBe(39.99);
});
it('rejects short descriptions', () => {
const description = "A nice shirt";
expect(description.length).toBeLessThan(50);
});
});
Publishing
For Your Team (npm)
npm run build
npm publish
Team members install it:
claude mcp add your-custom-mcp -- npx -- -y shopify-custom-mcp --token=shpat_xxx --domain=store.myshopify.com
For Your Team (Git)
If you don't want to publish to npm, team members can use it directly from your repository:
claude mcp add custom-shopify -- npx -- tsx /path/to/shopify-custom-mcp/src/index.ts -- --token=shpat_xxx --domain=store.myshopify.com
Docker Deployment (SSE Transport)
For a shared team server, you can modify the transport to SSE and deploy with Docker:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
const app = express();
const server = new McpServer({ name: "shopify-custom-mcp", version: "1.0.0" });
// ... register tools, resources, prompts (same as above) ...
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/messages", res);
await server.connect(transport);
});
app.post("/messages", async (req, res) => {
// Handle incoming messages
});
app.listen(3100, () => {
console.log("MCP SSE server running on port 3100");
});
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY dist/ ./dist/
EXPOSE 3100
CMD ["node", "dist/index-sse.js"]
If you deploy an MCP server as a network service (SSE or HTTP), it has direct access to your Shopify store's Admin API. Always:
- Run behind authentication (API key, OAuth, or VPN)
- Use HTTPS in production
- Log all tool invocations for audit
- Rate limit incoming connections
- Never expose it to the public internet without auth
Complete Project Structure
shopify-custom-mcp/
src/
index.ts # Main entry point (server + tools + resources + prompts)
__tests__/
tools.test.ts # Unit tests for business logic
dist/ # Compiled output
package.json
tsconfig.json
README.md
For larger servers, split tools into separate files:
shopify-custom-mcp/
src/
index.ts # Server setup and transport
shopify-client.ts # Shared Shopify API helper
tools/
inventory.ts # Inventory-related tools
products.ts # Product-related tools
orders.ts # Order-related tools
resources/
store-config.ts # Static store configuration
store-summary.ts # Dynamic store data
prompts/
reports.ts # Report generation prompts
workflows.ts # Workflow prompts
__tests__/
inventory.test.ts
products.test.ts
Summary
Building a custom MCP server for Shopify is straightforward with the TypeScript SDK. The key steps are:
- Initialize the project with the MCP SDK and Shopify API client
- Implement tools that encapsulate your business logic and Shopify API calls
- Add resources for static configuration and dynamic store data
- Add prompts for common workflow templates
- Test with MCP Inspector and unit tests
- Deploy via npm, Git, or Docker depending on your team's needs
The effort pays off quickly -- a custom MCP server with 5-10 well-designed tools can save hours of repetitive work per week by giving AI assistants direct, controlled access to your Shopify store with your business rules baked in.