Skip to main content

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
The 80/20 Rule

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

Create a new MCP server project
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
Initialize TypeScript
npx tsc --init

Update your tsconfig.json:

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"declaration": true
},
"include": ["src/**/*"]
}

Update package.json:

package.json (relevant fields)
{
"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

src/index.ts
#!/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):

src/tools/inventory.ts (add to index.ts)
// 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:

src/tools/products.ts (add to index.ts)
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:

src/resources.ts (add to index.ts)
// 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:

src/prompts.ts (add to index.ts)
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

Test with MCP Inspector
npx @modelcontextprotocol/inspector tsx src/index.ts -- --token=shpat_xxx --domain=store.myshopify.com

This opens a web interface where you can:

  1. View available tools: See all registered tools with their schemas
  2. Test tool calls: Fill in parameters and execute tools
  3. Inspect responses: View the JSON responses from each tool
  4. Check resources: Browse and read registered resources
  5. Test prompts: Execute prompts and see the generated messages
Test Before Connecting

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:

src/__tests__/tools.test.ts
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)

Build and publish
npm run build
npm publish

Team members install it:

Team member setup
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:

Direct from Git
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:

src/index-sse.ts
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");
});
Dockerfile
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY dist/ ./dist/
EXPOSE 3100
CMD ["node", "dist/index-sse.js"]
Secure Your MCP Server

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

Final 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:

Scaled project structure
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:

  1. Initialize the project with the MCP SDK and Shopify API client
  2. Implement tools that encapsulate your business logic and Shopify API calls
  3. Add resources for static configuration and dynamic store data
  4. Add prompts for common workflow templates
  5. Test with MCP Inspector and unit tests
  6. 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.