@alfe.ai/shopify-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +704 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @alfe.ai/shopify-mcp
|
|
2
|
+
|
|
3
|
+
Shopify MCP server — full store management (products, orders incl. fulfil/refund, customers, inventory) over the Admin GraphQL API with Alfe OAuth credentials.
|
|
4
|
+
|
|
5
|
+
Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: build, deploy, and run agents with persistent memory, identity, integrations, and channels. See the [documentation](https://docs.alfe.ai) to get started.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @alfe.ai/shopify-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Links
|
|
14
|
+
|
|
15
|
+
- 🌐 Website: <https://alfe.ai>
|
|
16
|
+
- 📚 Docs: <https://docs.alfe.ai>
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { resolveConfig } from "@alfe.ai/config";
|
|
5
|
+
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/shopify-client.ts
|
|
8
|
+
/** Error carrying the raw GraphQL error entries so the tool layer can surface them. */
|
|
9
|
+
var ShopifyGraphQLError = class extends Error {
|
|
10
|
+
errors;
|
|
11
|
+
constructor(message, errors) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ShopifyGraphQLError";
|
|
14
|
+
this.errors = errors;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
var ShopifyClient = class {
|
|
19
|
+
shopDomain;
|
|
20
|
+
accessToken;
|
|
21
|
+
apiVersion;
|
|
22
|
+
maxRetries;
|
|
23
|
+
retryBaseMs;
|
|
24
|
+
sleep;
|
|
25
|
+
fetchImpl;
|
|
26
|
+
constructor(config) {
|
|
27
|
+
this.shopDomain = config.shopDomain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
28
|
+
this.accessToken = config.accessToken;
|
|
29
|
+
this.apiVersion = config.apiVersion;
|
|
30
|
+
this.maxRetries = config.maxRetries ?? 2;
|
|
31
|
+
this.retryBaseMs = config.retryBaseMs ?? 500;
|
|
32
|
+
this.sleep = config.sleep ?? defaultSleep;
|
|
33
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
34
|
+
}
|
|
35
|
+
get endpoint() {
|
|
36
|
+
return `https://${this.shopDomain}/admin/api/${this.apiVersion}/graphql.json`;
|
|
37
|
+
}
|
|
38
|
+
async graphql(query, variables) {
|
|
39
|
+
let attempt = 0;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const res = await this.fetchImpl(this.endpoint, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
Accept: "application/json",
|
|
46
|
+
"X-Shopify-Access-Token": this.accessToken
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
query,
|
|
50
|
+
variables: variables ?? {}
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
if (res.status === 429 && attempt < this.maxRetries) {
|
|
54
|
+
const retryAfter = Number(res.headers.get("Retry-After"));
|
|
55
|
+
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : this.retryBaseMs * 2 ** attempt;
|
|
56
|
+
attempt += 1;
|
|
57
|
+
await this.sleep(waitMs);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const text = await res.text();
|
|
62
|
+
throw new Error(`Shopify Admin API HTTP ${String(res.status)} for ${this.shopDomain}: ${text}`);
|
|
63
|
+
}
|
|
64
|
+
const body = await res.json();
|
|
65
|
+
if (body.errors && body.errors.length > 0) {
|
|
66
|
+
if (body.errors.some((e) => e.extensions?.code === "THROTTLED") && attempt < this.maxRetries) {
|
|
67
|
+
const waitMs = this.retryBaseMs * 2 ** attempt;
|
|
68
|
+
attempt += 1;
|
|
69
|
+
await this.sleep(waitMs);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const messages = body.errors.map((e) => e.message).join("; ");
|
|
73
|
+
throw new ShopifyGraphQLError(`Shopify GraphQL error for ${this.shopDomain}: ${messages}`, body.errors);
|
|
74
|
+
}
|
|
75
|
+
if (body.data === void 0) throw new Error(`Shopify GraphQL response for ${this.shopDomain} had no data`);
|
|
76
|
+
return body.data;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Read a mutation's `userErrors` array out of a typed payload and throw a single
|
|
82
|
+
* descriptive Error if non-empty. Every write tool funnels through this so a
|
|
83
|
+
* failed validation (`userErrors`) fails closed as an `isError` tool result
|
|
84
|
+
* rather than silently returning a null record. `payloadKey` is the mutation's
|
|
85
|
+
* top-level field name (e.g. `productCreate`).
|
|
86
|
+
*/
|
|
87
|
+
function assertNoUserErrors(data, payloadKey) {
|
|
88
|
+
const userErrors = data[payloadKey]?.userErrors ?? [];
|
|
89
|
+
if (userErrors.length > 0) {
|
|
90
|
+
const detail = userErrors.map((e) => e.field?.length ? `${e.field.join(".")}: ${e.message}` : e.message).join("; ");
|
|
91
|
+
throw new Error(`Shopify ${payloadKey} failed: ${detail}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/shared.ts
|
|
96
|
+
/**
|
|
97
|
+
* Shared `shop` selector field. Every credential-touching tool requires it
|
|
98
|
+
* (Pattern A per-call dispatch) — one Shopify OAuth grant maps to one store,
|
|
99
|
+
* and the store's myshopify domain (`your-store.myshopify.com`) is the stable,
|
|
100
|
+
* human-legible account selector. `McpServer.registerTool` expects a raw Zod
|
|
101
|
+
* shape (`{ field: z.string() }`), NOT a JSON-Schema object.
|
|
102
|
+
*
|
|
103
|
+
* NOTE: the selector is `shop` (the myshopify domain), NOT the connect
|
|
104
|
+
* provider's `accountIdentifier` (which keys on the immutable shop GID). The
|
|
105
|
+
* GraphQL/token requests route on the domain, and it's what the LLM sees from
|
|
106
|
+
* `shopify_list_shops`.
|
|
107
|
+
*/
|
|
108
|
+
const shopField = z.string().describe("The store's myshopify domain (e.g. your-store.myshopify.com) — use a value from shopify_list_shops to pick which connected store this call targets.");
|
|
109
|
+
/** The discovery tool name, referenced in unknown-shop errors and Pattern A exemptions. */
|
|
110
|
+
const LIST_SHOPS_TOOL_NAME = "shopify_list_shops";
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/tools.ts
|
|
113
|
+
/**
|
|
114
|
+
* MCP tool registration for the Shopify MCP server (Pattern A multi-store).
|
|
115
|
+
*
|
|
116
|
+
* Every credential-touching tool takes a REQUIRED `shop` selector (the
|
|
117
|
+
* myshopify domain) so the LLM deliberately chooses which connected store a
|
|
118
|
+
* call targets — the server keeps one `ShopifyClient` per store and dispatches
|
|
119
|
+
* per-call. `shopify_list_shops` is the only exempt (discovery) tool.
|
|
120
|
+
*
|
|
121
|
+
* Errors fail closed: a tool that throws (unknown shop, GraphQL `errors`,
|
|
122
|
+
* mutation `userErrors`) returns `{ isError: true }` with the message, never a
|
|
123
|
+
* partial/empty success. Unknown-shop errors name `shopify_list_shops` so the
|
|
124
|
+
* LLM can recover.
|
|
125
|
+
*
|
|
126
|
+
* All operations use the Admin GraphQL API (pinned 2026-07). GraphQL
|
|
127
|
+
* mutation/query names were verified against shopify.dev on 2026-07-20;
|
|
128
|
+
* uncertain ones are flagged inline.
|
|
129
|
+
*/
|
|
130
|
+
const ok = (data) => ({ content: [{
|
|
131
|
+
type: "text",
|
|
132
|
+
text: JSON.stringify(data, null, 2)
|
|
133
|
+
}] });
|
|
134
|
+
const fail = (err) => ({
|
|
135
|
+
content: [{
|
|
136
|
+
type: "text",
|
|
137
|
+
text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
|
|
138
|
+
}],
|
|
139
|
+
isError: true
|
|
140
|
+
});
|
|
141
|
+
/**
|
|
142
|
+
* Register every tool. `resolveClient(shop)` throws an actionable Error for an
|
|
143
|
+
* unknown/dead store; `listShops()` returns the discovery snapshot. Both are
|
|
144
|
+
* injected so the whole surface is testable with a fake client map.
|
|
145
|
+
*/
|
|
146
|
+
function registerTools(server, resolveClient, listShops) {
|
|
147
|
+
const register = server.registerTool.bind(server);
|
|
148
|
+
/** Wrap a credential-touching handler so any throw becomes an isError result. */
|
|
149
|
+
const guarded = (fn) => async (args) => {
|
|
150
|
+
try {
|
|
151
|
+
return await fn(resolveClient(args.shop), args);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return fail(err);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
register(LIST_SHOPS_TOOL_NAME, { description: "List the Shopify stores this agent has connected. Returns one entry per store — use the returned shopDomain (e.g. your-store.myshopify.com) as the `shop` selector on every other shopify_* tool." }, () => ok({ shops: listShops() }));
|
|
157
|
+
register("shopify_list_products", {
|
|
158
|
+
description: "List products in a connected Shopify store. Supports a search `query` (Shopify search syntax, e.g. \"title:Shirt status:active\") and cursor pagination.",
|
|
159
|
+
inputSchema: {
|
|
160
|
+
shop: shopField,
|
|
161
|
+
first: z.number().int().min(1).max(250).optional().describe("Page size (default 50, max 250)."),
|
|
162
|
+
after: z.string().optional().describe("Pagination cursor from a previous page's pageInfo.endCursor."),
|
|
163
|
+
query: z.string().optional().describe("Optional Shopify product search query.")
|
|
164
|
+
}
|
|
165
|
+
}, guarded(async (client, args) => {
|
|
166
|
+
return ok(await client.graphql(`query ListProducts($first: Int!, $after: String, $query: String) {
|
|
167
|
+
products(first: $first, after: $after, query: $query) {
|
|
168
|
+
edges {
|
|
169
|
+
cursor
|
|
170
|
+
node { id title handle status totalInventory vendor productType createdAt updatedAt }
|
|
171
|
+
}
|
|
172
|
+
pageInfo { hasNextPage endCursor }
|
|
173
|
+
}
|
|
174
|
+
}`, {
|
|
175
|
+
first: args.first ?? 50,
|
|
176
|
+
after: args.after,
|
|
177
|
+
query: args.query
|
|
178
|
+
}));
|
|
179
|
+
}));
|
|
180
|
+
register("shopify_get_product", {
|
|
181
|
+
description: "Fetch one product (with its variants) by GID from a connected Shopify store.",
|
|
182
|
+
inputSchema: {
|
|
183
|
+
shop: shopField,
|
|
184
|
+
id: z.string().describe("The product GID, e.g. gid://shopify/Product/1234567890.")
|
|
185
|
+
}
|
|
186
|
+
}, guarded(async (client, args) => {
|
|
187
|
+
return ok(await client.graphql(`query GetProduct($id: ID!) {
|
|
188
|
+
product(id: $id) {
|
|
189
|
+
id title handle descriptionHtml status vendor productType tags totalInventory
|
|
190
|
+
variants(first: 100) {
|
|
191
|
+
edges { node { id title sku price inventoryQuantity inventoryItem { id } selectedOptions { name value } } }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}`, { id: args.id }));
|
|
195
|
+
}));
|
|
196
|
+
register("shopify_create_product", {
|
|
197
|
+
description: "Create a product in a connected Shopify store. Uses the productCreate mutation. Pass a `title` (required) plus optional descriptionHtml, vendor, productType, tags, and status (ACTIVE/DRAFT/ARCHIVED).",
|
|
198
|
+
inputSchema: {
|
|
199
|
+
shop: shopField,
|
|
200
|
+
title: z.string().describe("Product title (required)."),
|
|
201
|
+
descriptionHtml: z.string().optional(),
|
|
202
|
+
vendor: z.string().optional(),
|
|
203
|
+
productType: z.string().optional(),
|
|
204
|
+
tags: z.array(z.string()).optional(),
|
|
205
|
+
status: z.enum([
|
|
206
|
+
"ACTIVE",
|
|
207
|
+
"DRAFT",
|
|
208
|
+
"ARCHIVED"
|
|
209
|
+
]).optional().describe("Defaults to ACTIVE in Shopify.")
|
|
210
|
+
}
|
|
211
|
+
}, guarded(async (client, args) => {
|
|
212
|
+
const input = { title: args.title };
|
|
213
|
+
if (args.descriptionHtml !== void 0) input.descriptionHtml = args.descriptionHtml;
|
|
214
|
+
if (args.vendor !== void 0) input.vendor = args.vendor;
|
|
215
|
+
if (args.productType !== void 0) input.productType = args.productType;
|
|
216
|
+
if (args.tags !== void 0) input.tags = args.tags;
|
|
217
|
+
if (args.status !== void 0) input.status = args.status;
|
|
218
|
+
const data = await client.graphql(`mutation CreateProduct($input: ProductInput!) {
|
|
219
|
+
productCreate(input: $input) {
|
|
220
|
+
product { id title handle status }
|
|
221
|
+
userErrors { field message }
|
|
222
|
+
}
|
|
223
|
+
}`, { input });
|
|
224
|
+
assertNoUserErrors(data, "productCreate");
|
|
225
|
+
return ok(data);
|
|
226
|
+
}));
|
|
227
|
+
register("shopify_update_product", {
|
|
228
|
+
description: "Update fields on an existing product in a connected Shopify store. Uses the productUpdate mutation. Only the supplied fields change.",
|
|
229
|
+
inputSchema: {
|
|
230
|
+
shop: shopField,
|
|
231
|
+
id: z.string().describe("The product GID to update."),
|
|
232
|
+
title: z.string().optional(),
|
|
233
|
+
descriptionHtml: z.string().optional(),
|
|
234
|
+
vendor: z.string().optional(),
|
|
235
|
+
productType: z.string().optional(),
|
|
236
|
+
tags: z.array(z.string()).optional(),
|
|
237
|
+
status: z.enum([
|
|
238
|
+
"ACTIVE",
|
|
239
|
+
"DRAFT",
|
|
240
|
+
"ARCHIVED"
|
|
241
|
+
]).optional()
|
|
242
|
+
}
|
|
243
|
+
}, guarded(async (client, args) => {
|
|
244
|
+
const input = { id: args.id };
|
|
245
|
+
if (args.title !== void 0) input.title = args.title;
|
|
246
|
+
if (args.descriptionHtml !== void 0) input.descriptionHtml = args.descriptionHtml;
|
|
247
|
+
if (args.vendor !== void 0) input.vendor = args.vendor;
|
|
248
|
+
if (args.productType !== void 0) input.productType = args.productType;
|
|
249
|
+
if (args.tags !== void 0) input.tags = args.tags;
|
|
250
|
+
if (args.status !== void 0) input.status = args.status;
|
|
251
|
+
const data = await client.graphql(`mutation UpdateProduct($input: ProductInput!) {
|
|
252
|
+
productUpdate(input: $input) {
|
|
253
|
+
product { id title handle status }
|
|
254
|
+
userErrors { field message }
|
|
255
|
+
}
|
|
256
|
+
}`, { input });
|
|
257
|
+
assertNoUserErrors(data, "productUpdate");
|
|
258
|
+
return ok(data);
|
|
259
|
+
}));
|
|
260
|
+
register("shopify_set_variant_price", {
|
|
261
|
+
description: "Set the price of a single product variant in a connected Shopify store. Uses productVariantsBulkUpdate (the productVariantUpdate mutation was removed; bulk-update is the current single/multi-variant path). Requires the parent product GID.",
|
|
262
|
+
inputSchema: {
|
|
263
|
+
shop: shopField,
|
|
264
|
+
productId: z.string().describe("The parent product GID."),
|
|
265
|
+
variantId: z.string().describe("The variant GID to reprice."),
|
|
266
|
+
price: z.string().describe("The new price as a decimal string, e.g. \"19.99\".")
|
|
267
|
+
}
|
|
268
|
+
}, guarded(async (client, args) => {
|
|
269
|
+
const data = await client.graphql(`mutation SetVariantPrice($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
|
270
|
+
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
|
271
|
+
productVariants { id price }
|
|
272
|
+
userErrors { field message }
|
|
273
|
+
}
|
|
274
|
+
}`, {
|
|
275
|
+
productId: args.productId,
|
|
276
|
+
variants: [{
|
|
277
|
+
id: args.variantId,
|
|
278
|
+
price: args.price
|
|
279
|
+
}]
|
|
280
|
+
});
|
|
281
|
+
assertNoUserErrors(data, "productVariantsBulkUpdate");
|
|
282
|
+
return ok(data);
|
|
283
|
+
}));
|
|
284
|
+
register("shopify_list_orders", {
|
|
285
|
+
description: "List orders in a connected Shopify store. Supports a Shopify search `query` for status/date filters (e.g. \"financial_status:paid created_at:>2026-01-01 fulfillment_status:unfulfilled\") and cursor pagination.",
|
|
286
|
+
inputSchema: {
|
|
287
|
+
shop: shopField,
|
|
288
|
+
first: z.number().int().min(1).max(250).optional().describe("Page size (default 50, max 250)."),
|
|
289
|
+
after: z.string().optional().describe("Pagination cursor."),
|
|
290
|
+
query: z.string().optional().describe("Optional Shopify order search query (status/date filters).")
|
|
291
|
+
}
|
|
292
|
+
}, guarded(async (client, args) => {
|
|
293
|
+
return ok(await client.graphql(`query ListOrders($first: Int!, $after: String, $query: String) {
|
|
294
|
+
orders(first: $first, after: $after, query: $query, sortKey: CREATED_AT, reverse: true) {
|
|
295
|
+
edges {
|
|
296
|
+
cursor
|
|
297
|
+
node {
|
|
298
|
+
id name createdAt displayFinancialStatus displayFulfillmentStatus
|
|
299
|
+
totalPriceSet { shopMoney { amount currencyCode } }
|
|
300
|
+
customer { id displayName email }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
pageInfo { hasNextPage endCursor }
|
|
304
|
+
}
|
|
305
|
+
}`, {
|
|
306
|
+
first: args.first ?? 50,
|
|
307
|
+
after: args.after,
|
|
308
|
+
query: args.query
|
|
309
|
+
}));
|
|
310
|
+
}));
|
|
311
|
+
register("shopify_get_order", {
|
|
312
|
+
description: "Fetch one order (with line items and fulfillment orders) by GID from a connected Shopify store.",
|
|
313
|
+
inputSchema: {
|
|
314
|
+
shop: shopField,
|
|
315
|
+
id: z.string().describe("The order GID, e.g. gid://shopify/Order/1234567890.")
|
|
316
|
+
}
|
|
317
|
+
}, guarded(async (client, args) => {
|
|
318
|
+
return ok(await client.graphql(`query GetOrder($id: ID!) {
|
|
319
|
+
order(id: $id) {
|
|
320
|
+
id name createdAt displayFinancialStatus displayFulfillmentStatus note
|
|
321
|
+
totalPriceSet { shopMoney { amount currencyCode } }
|
|
322
|
+
customer { id displayName email }
|
|
323
|
+
lineItems(first: 100) { edges { node { id title quantity sku variant { id } } } }
|
|
324
|
+
fulfillmentOrders(first: 20) {
|
|
325
|
+
edges { node { id status lineItems(first: 100) { edges { node { id remainingQuantity } } } } }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}`, { id: args.id }));
|
|
329
|
+
}));
|
|
330
|
+
register("shopify_fulfill_order", {
|
|
331
|
+
description: "Fulfill a fulfillment order in a connected Shopify store using the fulfillmentCreate mutation (fulfillmentOrder-based — the legacy order-based fulfillment API is removed). Pass the FULFILLMENT ORDER GID (from shopify_get_order → fulfillmentOrders), not the order GID. Omit line items to fulfill everything.",
|
|
332
|
+
inputSchema: {
|
|
333
|
+
shop: shopField,
|
|
334
|
+
fulfillmentOrderId: z.string().describe("The fulfillment order GID (from order.fulfillmentOrders)."),
|
|
335
|
+
notifyCustomer: z.boolean().optional().describe("Send the shipping notification (default false)."),
|
|
336
|
+
trackingNumber: z.string().optional(),
|
|
337
|
+
trackingUrl: z.string().optional(),
|
|
338
|
+
trackingCompany: z.string().optional()
|
|
339
|
+
}
|
|
340
|
+
}, guarded(async (client, args) => {
|
|
341
|
+
const fulfillment = {
|
|
342
|
+
lineItemsByFulfillmentOrder: [{ fulfillmentOrderId: args.fulfillmentOrderId }],
|
|
343
|
+
notifyCustomer: args.notifyCustomer ?? false
|
|
344
|
+
};
|
|
345
|
+
if (args.trackingNumber || args.trackingUrl || args.trackingCompany) {
|
|
346
|
+
const trackingInfo = {};
|
|
347
|
+
if (args.trackingNumber !== void 0) trackingInfo.number = args.trackingNumber;
|
|
348
|
+
if (args.trackingUrl !== void 0) trackingInfo.url = args.trackingUrl;
|
|
349
|
+
if (args.trackingCompany !== void 0) trackingInfo.company = args.trackingCompany;
|
|
350
|
+
fulfillment.trackingInfo = trackingInfo;
|
|
351
|
+
}
|
|
352
|
+
const data = await client.graphql(`mutation FulfillOrder($fulfillment: FulfillmentInput!) {
|
|
353
|
+
fulfillmentCreate(fulfillment: $fulfillment) {
|
|
354
|
+
fulfillment { id status trackingInfo { number url company } }
|
|
355
|
+
userErrors { field message }
|
|
356
|
+
}
|
|
357
|
+
}`, { fulfillment });
|
|
358
|
+
assertNoUserErrors(data, "fulfillmentCreate");
|
|
359
|
+
return ok(data);
|
|
360
|
+
}));
|
|
361
|
+
register("shopify_refund_order", {
|
|
362
|
+
description: "Create a refund against an order in a connected Shopify store using the refundCreate mutation. Provide refundLineItems and/or a shipping amount. Without transactions Shopify records the refund without moving money — supply refund line items for a real restock/refund.",
|
|
363
|
+
inputSchema: {
|
|
364
|
+
shop: shopField,
|
|
365
|
+
orderId: z.string().describe("The order GID to refund."),
|
|
366
|
+
note: z.string().optional(),
|
|
367
|
+
notify: z.boolean().optional().describe("Notify the customer (default false)."),
|
|
368
|
+
refundLineItems: z.array(z.object({
|
|
369
|
+
lineItemId: z.string().describe("The order line item GID to refund."),
|
|
370
|
+
quantity: z.number().int().min(1),
|
|
371
|
+
restockType: z.enum([
|
|
372
|
+
"NO_RESTOCK",
|
|
373
|
+
"CANCEL",
|
|
374
|
+
"RETURN",
|
|
375
|
+
"LEGACY_RESTOCK"
|
|
376
|
+
]).optional()
|
|
377
|
+
})).optional().describe("Line items to refund. Omit for a shipping-only or note-only refund.")
|
|
378
|
+
}
|
|
379
|
+
}, guarded(async (client, args) => {
|
|
380
|
+
const input = {
|
|
381
|
+
orderId: args.orderId,
|
|
382
|
+
notify: args.notify ?? false
|
|
383
|
+
};
|
|
384
|
+
if (args.note !== void 0) input.note = args.note;
|
|
385
|
+
if (args.refundLineItems?.length) input.refundLineItems = args.refundLineItems.map((li) => {
|
|
386
|
+
const entry = {
|
|
387
|
+
lineItemId: li.lineItemId,
|
|
388
|
+
quantity: li.quantity
|
|
389
|
+
};
|
|
390
|
+
if (li.restockType !== void 0) entry.restockType = li.restockType;
|
|
391
|
+
return entry;
|
|
392
|
+
});
|
|
393
|
+
const data = await client.graphql(`mutation RefundOrder($input: RefundInput!) {
|
|
394
|
+
refundCreate(input: $input) {
|
|
395
|
+
refund { id totalRefundedSet { shopMoney { amount currencyCode } } }
|
|
396
|
+
userErrors { field message }
|
|
397
|
+
}
|
|
398
|
+
}`, { input });
|
|
399
|
+
assertNoUserErrors(data, "refundCreate");
|
|
400
|
+
return ok(data);
|
|
401
|
+
}));
|
|
402
|
+
register("shopify_cancel_order", {
|
|
403
|
+
description: "Cancel an order in a connected Shopify store using the orderCancel mutation (async — returns a job). `reason` and `restock` are required by Shopify.",
|
|
404
|
+
inputSchema: {
|
|
405
|
+
shop: shopField,
|
|
406
|
+
orderId: z.string().describe("The order GID to cancel."),
|
|
407
|
+
reason: z.enum([
|
|
408
|
+
"CUSTOMER",
|
|
409
|
+
"DECLINED",
|
|
410
|
+
"FRAUD",
|
|
411
|
+
"INVENTORY",
|
|
412
|
+
"OTHER",
|
|
413
|
+
"STAFF"
|
|
414
|
+
]).describe("OrderCancelReason (required)."),
|
|
415
|
+
restock: z.boolean().describe("Whether to restock the order's inventory (required)."),
|
|
416
|
+
refund: z.boolean().optional().describe("Whether to refund the order's payment."),
|
|
417
|
+
notifyCustomer: z.boolean().optional().describe("Notify the customer (default false)."),
|
|
418
|
+
staffNote: z.string().optional().describe("Internal note (max 255 chars).")
|
|
419
|
+
}
|
|
420
|
+
}, guarded(async (client, args) => {
|
|
421
|
+
const variables = {
|
|
422
|
+
orderId: args.orderId,
|
|
423
|
+
reason: args.reason,
|
|
424
|
+
restock: args.restock,
|
|
425
|
+
notifyCustomer: args.notifyCustomer ?? false
|
|
426
|
+
};
|
|
427
|
+
if (args.staffNote !== void 0) variables.staffNote = args.staffNote;
|
|
428
|
+
if (args.refund) variables.refundMethod = { originalPaymentMethodsRefund: { fullRefund: true } };
|
|
429
|
+
const data = await client.graphql(`mutation CancelOrder(
|
|
430
|
+
$orderId: ID!, $reason: OrderCancelReason!, $restock: Boolean!,
|
|
431
|
+
$notifyCustomer: Boolean, $staffNote: String, $refundMethod: OrderCancelRefundMethodInput
|
|
432
|
+
) {
|
|
433
|
+
orderCancel(
|
|
434
|
+
orderId: $orderId, reason: $reason, restock: $restock,
|
|
435
|
+
notifyCustomer: $notifyCustomer, staffNote: $staffNote, refundMethod: $refundMethod
|
|
436
|
+
) {
|
|
437
|
+
job { id done }
|
|
438
|
+
orderCancelUserErrors { field message code }
|
|
439
|
+
}
|
|
440
|
+
}`, variables);
|
|
441
|
+
const errs = data.orderCancel?.orderCancelUserErrors ?? [];
|
|
442
|
+
if (errs.length > 0) throw new Error(`Shopify orderCancel failed: ${errs.map((e) => e.field?.length ? `${e.field.join(".")}: ${e.message}` : e.message).join("; ")}`);
|
|
443
|
+
return ok(data);
|
|
444
|
+
}));
|
|
445
|
+
register("shopify_list_customers", {
|
|
446
|
+
description: "List customers in a connected Shopify store. Supports a Shopify search `query` (e.g. \"email:*@example.com\") and cursor pagination.",
|
|
447
|
+
inputSchema: {
|
|
448
|
+
shop: shopField,
|
|
449
|
+
first: z.number().int().min(1).max(250).optional().describe("Page size (default 50, max 250)."),
|
|
450
|
+
after: z.string().optional(),
|
|
451
|
+
query: z.string().optional().describe("Optional Shopify customer search query.")
|
|
452
|
+
}
|
|
453
|
+
}, guarded(async (client, args) => {
|
|
454
|
+
return ok(await client.graphql(`query ListCustomers($first: Int!, $after: String, $query: String) {
|
|
455
|
+
customers(first: $first, after: $after, query: $query) {
|
|
456
|
+
edges { cursor node { id displayName email phone numberOfOrders createdAt } }
|
|
457
|
+
pageInfo { hasNextPage endCursor }
|
|
458
|
+
}
|
|
459
|
+
}`, {
|
|
460
|
+
first: args.first ?? 50,
|
|
461
|
+
after: args.after,
|
|
462
|
+
query: args.query
|
|
463
|
+
}));
|
|
464
|
+
}));
|
|
465
|
+
register("shopify_get_customer", {
|
|
466
|
+
description: "Fetch one customer by GID from a connected Shopify store.",
|
|
467
|
+
inputSchema: {
|
|
468
|
+
shop: shopField,
|
|
469
|
+
id: z.string().describe("The customer GID, e.g. gid://shopify/Customer/1234567890.")
|
|
470
|
+
}
|
|
471
|
+
}, guarded(async (client, args) => {
|
|
472
|
+
return ok(await client.graphql(`query GetCustomer($id: ID!) {
|
|
473
|
+
customer(id: $id) {
|
|
474
|
+
id displayName firstName lastName email phone note numberOfOrders
|
|
475
|
+
defaultAddress { address1 address2 city province country zip }
|
|
476
|
+
}
|
|
477
|
+
}`, { id: args.id }));
|
|
478
|
+
}));
|
|
479
|
+
register("shopify_create_customer", {
|
|
480
|
+
description: "Create a customer in a connected Shopify store using the customerCreate mutation.",
|
|
481
|
+
inputSchema: {
|
|
482
|
+
shop: shopField,
|
|
483
|
+
firstName: z.string().optional(),
|
|
484
|
+
lastName: z.string().optional(),
|
|
485
|
+
email: z.string().optional(),
|
|
486
|
+
phone: z.string().optional(),
|
|
487
|
+
note: z.string().optional(),
|
|
488
|
+
tags: z.array(z.string()).optional()
|
|
489
|
+
}
|
|
490
|
+
}, guarded(async (client, args) => {
|
|
491
|
+
const input = {};
|
|
492
|
+
if (args.firstName !== void 0) input.firstName = args.firstName;
|
|
493
|
+
if (args.lastName !== void 0) input.lastName = args.lastName;
|
|
494
|
+
if (args.email !== void 0) input.email = args.email;
|
|
495
|
+
if (args.phone !== void 0) input.phone = args.phone;
|
|
496
|
+
if (args.note !== void 0) input.note = args.note;
|
|
497
|
+
if (args.tags !== void 0) input.tags = args.tags;
|
|
498
|
+
const data = await client.graphql(`mutation CreateCustomer($input: CustomerInput!) {
|
|
499
|
+
customerCreate(input: $input) {
|
|
500
|
+
customer { id displayName email }
|
|
501
|
+
userErrors { field message }
|
|
502
|
+
}
|
|
503
|
+
}`, { input });
|
|
504
|
+
assertNoUserErrors(data, "customerCreate");
|
|
505
|
+
return ok(data);
|
|
506
|
+
}));
|
|
507
|
+
register("shopify_update_customer", {
|
|
508
|
+
description: "Update fields on an existing customer in a connected Shopify store using customerUpdate. Only supplied fields change.",
|
|
509
|
+
inputSchema: {
|
|
510
|
+
shop: shopField,
|
|
511
|
+
id: z.string().describe("The customer GID to update."),
|
|
512
|
+
firstName: z.string().optional(),
|
|
513
|
+
lastName: z.string().optional(),
|
|
514
|
+
email: z.string().optional(),
|
|
515
|
+
phone: z.string().optional(),
|
|
516
|
+
note: z.string().optional(),
|
|
517
|
+
tags: z.array(z.string()).optional()
|
|
518
|
+
}
|
|
519
|
+
}, guarded(async (client, args) => {
|
|
520
|
+
const input = { id: args.id };
|
|
521
|
+
if (args.firstName !== void 0) input.firstName = args.firstName;
|
|
522
|
+
if (args.lastName !== void 0) input.lastName = args.lastName;
|
|
523
|
+
if (args.email !== void 0) input.email = args.email;
|
|
524
|
+
if (args.phone !== void 0) input.phone = args.phone;
|
|
525
|
+
if (args.note !== void 0) input.note = args.note;
|
|
526
|
+
if (args.tags !== void 0) input.tags = args.tags;
|
|
527
|
+
const data = await client.graphql(`mutation UpdateCustomer($input: CustomerInput!) {
|
|
528
|
+
customerUpdate(input: $input) {
|
|
529
|
+
customer { id displayName email }
|
|
530
|
+
userErrors { field message }
|
|
531
|
+
}
|
|
532
|
+
}`, { input });
|
|
533
|
+
assertNoUserErrors(data, "customerUpdate");
|
|
534
|
+
return ok(data);
|
|
535
|
+
}));
|
|
536
|
+
register("shopify_list_locations", {
|
|
537
|
+
description: "List the inventory locations of a connected Shopify store (needed to adjust inventory levels).",
|
|
538
|
+
inputSchema: {
|
|
539
|
+
shop: shopField,
|
|
540
|
+
first: z.number().int().min(1).max(250).optional().describe("Page size (default 50).")
|
|
541
|
+
}
|
|
542
|
+
}, guarded(async (client, args) => {
|
|
543
|
+
return ok(await client.graphql(`query ListLocations($first: Int!) {
|
|
544
|
+
locations(first: $first) {
|
|
545
|
+
edges { node { id name isActive address { city province country } } }
|
|
546
|
+
}
|
|
547
|
+
}`, { first: args.first ?? 50 }));
|
|
548
|
+
}));
|
|
549
|
+
register("shopify_get_inventory_levels", {
|
|
550
|
+
description: "Get the inventory levels (available quantities per location) for an inventory item in a connected Shopify store. Pass the inventoryItem GID (from a product variant's inventoryItem.id).",
|
|
551
|
+
inputSchema: {
|
|
552
|
+
shop: shopField,
|
|
553
|
+
inventoryItemId: z.string().describe("The inventory item GID (variant.inventoryItem.id)."),
|
|
554
|
+
first: z.number().int().min(1).max(250).optional().describe("Page size (default 50).")
|
|
555
|
+
}
|
|
556
|
+
}, guarded(async (client, args) => {
|
|
557
|
+
return ok(await client.graphql(`query InventoryLevels($id: ID!, $first: Int!) {
|
|
558
|
+
inventoryItem(id: $id) {
|
|
559
|
+
id sku tracked
|
|
560
|
+
inventoryLevels(first: $first) {
|
|
561
|
+
edges {
|
|
562
|
+
node {
|
|
563
|
+
id location { id name }
|
|
564
|
+
quantities(names: ["available"]) { name quantity }
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}`, {
|
|
570
|
+
id: args.inventoryItemId,
|
|
571
|
+
first: args.first ?? 50
|
|
572
|
+
}));
|
|
573
|
+
}));
|
|
574
|
+
register("shopify_adjust_inventory", {
|
|
575
|
+
description: "Adjust the available inventory quantity for an inventory item at a location in a connected Shopify store, using inventoryAdjustQuantities. `delta` is the signed change (e.g. -3 to decrement). Requires the inventoryItem GID and the location GID.",
|
|
576
|
+
inputSchema: {
|
|
577
|
+
shop: shopField,
|
|
578
|
+
inventoryItemId: z.string().describe("The inventory item GID."),
|
|
579
|
+
locationId: z.string().describe("The location GID (from shopify_list_locations)."),
|
|
580
|
+
delta: z.number().int().describe("Signed quantity change, e.g. 10 or -3."),
|
|
581
|
+
reason: z.string().optional().describe("Adjustment reason code (default \"correction\").")
|
|
582
|
+
}
|
|
583
|
+
}, guarded(async (client, args) => {
|
|
584
|
+
const data = await client.graphql(`mutation AdjustInventory($input: InventoryAdjustQuantitiesInput!, $key: String!) {
|
|
585
|
+
inventoryAdjustQuantities(input: $input) @idempotent(key: $key) {
|
|
586
|
+
inventoryAdjustmentGroup { createdAt reason changes { name delta } }
|
|
587
|
+
userErrors { field message }
|
|
588
|
+
}
|
|
589
|
+
}`, {
|
|
590
|
+
input: {
|
|
591
|
+
name: "available",
|
|
592
|
+
reason: args.reason ?? "correction",
|
|
593
|
+
changes: [{
|
|
594
|
+
delta: args.delta,
|
|
595
|
+
inventoryItemId: args.inventoryItemId,
|
|
596
|
+
locationId: args.locationId
|
|
597
|
+
}]
|
|
598
|
+
},
|
|
599
|
+
key: globalThis.crypto.randomUUID()
|
|
600
|
+
});
|
|
601
|
+
assertNoUserErrors(data, "inventoryAdjustQuantities");
|
|
602
|
+
return ok(data);
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
//#endregion
|
|
606
|
+
//#region src/server.ts
|
|
607
|
+
/**
|
|
608
|
+
* Shopify MCP Server (Pattern A multi-store)
|
|
609
|
+
*
|
|
610
|
+
* Custom MCP server implementing Shopify store management directly over the
|
|
611
|
+
* Admin GraphQL API (like the Salesforce server; unlike the Xero proxy that
|
|
612
|
+
* wraps a child MCP). Every tool that touches credentials requires the LLM to
|
|
613
|
+
* pass `shop` (the myshopify domain) explicitly — the server keeps one
|
|
614
|
+
* `ShopifyClient` per connected store and dispatches per-call.
|
|
615
|
+
*
|
|
616
|
+
* Shopify offline access tokens NEVER expire and carry no refresh token, so
|
|
617
|
+
* there is no refresh machinery at all (unlike Salesforce's per-org refresh).
|
|
618
|
+
* An app-uninstall silently invalidates the token; v1 fails closed and the
|
|
619
|
+
* user reconnects from the dashboard.
|
|
620
|
+
*
|
|
621
|
+
* Architecture:
|
|
622
|
+
* OpenClaw ←(stdio)→ this server ←(https)→ Shopify Admin GraphQL API
|
|
623
|
+
*/
|
|
624
|
+
/**
|
|
625
|
+
* Fallback Admin API version. The connect provider stamps the pinned version
|
|
626
|
+
* (`2026-07`) onto every account via `buildCredentialsResponse`, so this is only
|
|
627
|
+
* used if a legacy Connection row predates that field.
|
|
628
|
+
*/
|
|
629
|
+
const FALLBACK_API_VERSION = "2026-07";
|
|
630
|
+
const clients = /* @__PURE__ */ new Map();
|
|
631
|
+
const shopSnapshot = [];
|
|
632
|
+
function log(msg) {
|
|
633
|
+
process.stderr.write(`[shopify-mcp-server] ${msg}\n`);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Resolve a `shop` selector to its `ShopifyClient`. Throws if unknown — the LLM
|
|
637
|
+
* should call `shopify_list_shops` to discover the valid set. If the store is
|
|
638
|
+
* connected on the agent but couldn't be initialised, the error tells the LLM
|
|
639
|
+
* to ask the user to reconnect rather than retrying blindly.
|
|
640
|
+
*/
|
|
641
|
+
function resolveClient(shop) {
|
|
642
|
+
const c = clients.get(shop);
|
|
643
|
+
if (c) return c;
|
|
644
|
+
const known = shopSnapshot.find((s) => s.shopDomain === shop);
|
|
645
|
+
if (known && !known.connected) throw new Error(`Shop ${shop} is connected on this agent but the server could not initialise a client for it (reason: ${known.reason ?? "unknown"}). Ask the user to reconnect this Shopify store from the dashboard.`);
|
|
646
|
+
throw new Error(`Unknown shop: ${shop}. Call ${LIST_SHOPS_TOOL_NAME} to see the connected Shopify stores on this agent (pass the shopDomain, e.g. your-store.myshopify.com).`);
|
|
647
|
+
}
|
|
648
|
+
async function main() {
|
|
649
|
+
const config = resolveConfig();
|
|
650
|
+
const { accounts } = await new AgentApiClient({
|
|
651
|
+
apiKey: config.apiKey,
|
|
652
|
+
apiUrl: config.apiUrl
|
|
653
|
+
}).getShopifyAccounts();
|
|
654
|
+
if (accounts.length === 0) log(`No Shopify stores connected — server will start with ${LIST_SHOPS_TOOL_NAME} only (returns a "no store connected" hint)`);
|
|
655
|
+
for (const acct of accounts) {
|
|
656
|
+
const shopDomain = acct.shopDomain || (acct.accountIdentifier.endsWith(".myshopify.com") ? acct.accountIdentifier : "");
|
|
657
|
+
if (!acct.accessToken || !shopDomain) {
|
|
658
|
+
log(`Skipping store ${acct.accountIdentifier} — missing access token or shop domain`);
|
|
659
|
+
shopSnapshot.push({
|
|
660
|
+
shopDomain: shopDomain || acct.accountIdentifier,
|
|
661
|
+
shopName: acct.shopName || acct.displayName,
|
|
662
|
+
shopGid: acct.shopGid || null,
|
|
663
|
+
connectedAt: acct.connectedAt,
|
|
664
|
+
connected: false,
|
|
665
|
+
reason: "missing_access_token_or_shop_domain"
|
|
666
|
+
});
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (clients.has(shopDomain)) {
|
|
670
|
+
log(`Duplicate shop ${shopDomain} returned by getShopifyAccounts() — keeping the first cached client`);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
clients.set(shopDomain, new ShopifyClient({
|
|
674
|
+
accessToken: acct.accessToken,
|
|
675
|
+
shopDomain,
|
|
676
|
+
apiVersion: acct.apiVersion || FALLBACK_API_VERSION
|
|
677
|
+
}));
|
|
678
|
+
shopSnapshot.push({
|
|
679
|
+
shopDomain,
|
|
680
|
+
shopName: acct.shopName || acct.displayName,
|
|
681
|
+
shopGid: acct.shopGid || null,
|
|
682
|
+
connectedAt: acct.connectedAt,
|
|
683
|
+
connected: true
|
|
684
|
+
});
|
|
685
|
+
log(`Cached client for store ${shopDomain} (${acct.shopName || acct.displayName || "no name"})`);
|
|
686
|
+
}
|
|
687
|
+
const server = new McpServer({
|
|
688
|
+
name: "shopify-mcp-server",
|
|
689
|
+
version: "0.1.0"
|
|
690
|
+
});
|
|
691
|
+
registerTools(server, resolveClient, () => shopSnapshot);
|
|
692
|
+
const transport = new StdioServerTransport();
|
|
693
|
+
await server.connect(transport);
|
|
694
|
+
log(`Shopify MCP server running with ${String(clients.size)} connected store(s) and Pattern A selector enforcement`);
|
|
695
|
+
}
|
|
696
|
+
for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
|
|
697
|
+
process.exit(0);
|
|
698
|
+
});
|
|
699
|
+
main().catch((err) => {
|
|
700
|
+
log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
|
701
|
+
process.exit(1);
|
|
702
|
+
});
|
|
703
|
+
//#endregion
|
|
704
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfe.ai/shopify-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Shopify MCP server — full store management (products, orders incl. fulfil/refund, customers, inventory) over the Admin GraphQL API with Alfe OAuth credentials",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"shopify-mcp-server": "./dist/server.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/server.d.ts",
|
|
13
|
+
"import": "./dist/server.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
21
|
+
"zod": "^4.0.5",
|
|
22
|
+
"@alfe.ai/config": "0.3.0",
|
|
23
|
+
"@alfe.ai/agent-api-client": "0.12.0"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"homepage": "https://alfe.ai",
|
|
27
|
+
"author": "Alfe (https://alfe.ai)",
|
|
28
|
+
"keywords": [
|
|
29
|
+
"alfe",
|
|
30
|
+
"ai-agents",
|
|
31
|
+
"agent",
|
|
32
|
+
"llm",
|
|
33
|
+
"mcp",
|
|
34
|
+
"model-context-protocol",
|
|
35
|
+
"shopify",
|
|
36
|
+
"ecommerce"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsdown",
|
|
40
|
+
"dev": "tsdown --watch",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"lint": "eslint ."
|
|
44
|
+
}
|
|
45
|
+
}
|