@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 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>
@@ -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
+ }