@alfe.ai/shopify-mcp 0.2.2 → 0.2.3

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.
Files changed (3) hide show
  1. package/README.md +20 -0
  2. package/dist/server.js +434 -145
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -10,6 +10,26 @@ Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: buil
10
10
  npm install @alfe.ai/shopify-mcp
11
11
  ```
12
12
 
13
+ ## Safety model
14
+
15
+ Every store operation requires the canonical `your-store.myshopify.com` domain
16
+ shown by `shopify_list_shops`; custom hosts and URL paths are rejected before a
17
+ Shopify access token is sent. GraphQL requests have bounded input, deadline,
18
+ retry, and response limits, and redirects are disabled.
19
+
20
+ Commerce-critical mutations require exact confirmation values in addition to
21
+ their target arguments:
22
+
23
+ - price updates confirm the variant ID and price;
24
+ - fulfillment confirms the fulfillment-order ID;
25
+ - refunds and cancellations confirm the order ID; and
26
+ - inventory adjustments confirm the inventory item, location, and signed
27
+ quantity delta.
28
+
29
+ These confirmations are designed to bind an approval to the action's actual
30
+ consequence. Large reads must be narrowed or paginated rather than returned as
31
+ one unbounded MCP result.
32
+
13
33
  ## Links
14
34
 
15
35
  - 🌐 Website: <https://alfe.ai>
package/dist/server.js CHANGED
@@ -1,11 +1,84 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { resolveConfig } from "@alfe.ai/config";
5
6
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
6
7
  import { z } from "zod";
8
+ //#region src/validation.ts
9
+ const MAX_GRAPHQL_QUERY_CHARS = 1e5;
10
+ const MAX_GRAPHQL_REQUEST_BYTES = 1024 * 1024;
11
+ const MAX_TOOL_TEXT_CHARS = 1e5;
12
+ const SHOP_DOMAIN_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.myshopify\.com$/u;
13
+ const SHOPIFY_GID_PATTERN = /^gid:\/\/shopify\/([A-Za-z][A-Za-z0-9]*)\/([A-Za-z0-9]+)$/u;
14
+ const MONEY_PATTERN = /^(?:0|[1-9]\d{0,12})(?:\.\d{1,2})?$/u;
15
+ const MAX_JSON_DEPTH = 8;
16
+ const MAX_JSON_NODES = 1e4;
17
+ const MAX_JSON_ARRAY_ITEMS = 1e3;
18
+ const MAX_JSON_STRING_CHARS = 1e5;
19
+ const UNSAFE_KEYS = new Set([
20
+ "__proto__",
21
+ "constructor",
22
+ "prototype"
23
+ ]);
24
+ function validateShopDomain(value) {
25
+ if (typeof value !== "string") throw new Error("shop must be a string");
26
+ const normalized = value.trim().toLowerCase();
27
+ if (!SHOP_DOMAIN_PATTERN.test(normalized)) throw new Error("shop must be a canonical your-store.myshopify.com domain");
28
+ return normalized;
29
+ }
30
+ function validateShopifyApiVersion(value) {
31
+ if (typeof value !== "string" || !/^\d{4}-(?:01|04|07|10)$/u.test(value)) throw new Error("Shopify API version must use a quarterly YYYY-MM value");
32
+ return value;
33
+ }
34
+ function validateVariables(value) {
35
+ if (value === void 0) return Object.create(null);
36
+ if (!isRecord(value)) throw new Error("GraphQL variables must be an object");
37
+ const result = cloneRecord(value, 0, { nodes: 0 });
38
+ const encoded = JSON.stringify(result);
39
+ if (Buffer.byteLength(encoded, "utf-8") > 1048576) throw new Error(`GraphQL variables exceed the ${String(MAX_GRAPHQL_REQUEST_BYTES)} byte limit`);
40
+ return result;
41
+ }
42
+ function cloneJson(value, depth, budget) {
43
+ budget.nodes += 1;
44
+ if (budget.nodes > MAX_JSON_NODES) throw new Error("GraphQL variables contain too many values");
45
+ if (depth > MAX_JSON_DEPTH) throw new Error("GraphQL variables exceed the JSON depth limit");
46
+ if (value === null || typeof value === "boolean") return value;
47
+ if (typeof value === "number") {
48
+ if (!Number.isFinite(value)) throw new Error("GraphQL variables contain a non-finite number");
49
+ return value;
50
+ }
51
+ if (typeof value === "string") {
52
+ if (value.length > MAX_JSON_STRING_CHARS) throw new Error("GraphQL variables contain an oversized string");
53
+ return value;
54
+ }
55
+ if (Array.isArray(value)) {
56
+ if (value.length > MAX_JSON_ARRAY_ITEMS) throw new Error("GraphQL variables contain an oversized array");
57
+ return value.map((item) => cloneJson(item, depth + 1, budget));
58
+ }
59
+ if (!isRecord(value)) throw new Error("GraphQL variables must contain JSON-only values");
60
+ return cloneRecord(value, depth, budget);
61
+ }
62
+ function cloneRecord(value, depth, budget) {
63
+ const result = Object.create(null);
64
+ for (const [key, nested] of Object.entries(value)) {
65
+ if (key.length < 1 || key.length > 256 || UNSAFE_KEYS.has(key)) throw new Error("GraphQL variables contain an unsafe key");
66
+ result[key] = cloneJson(nested, depth + 1, budget);
67
+ }
68
+ return result;
69
+ }
70
+ function isRecord(value) {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+ //#endregion
7
74
  //#region src/shopify-client.ts
8
- /** Error carrying the raw GraphQL error entries so the tool layer can surface them. */
75
+ /** Bounded Shopify Admin GraphQL client for one canonical myshopify domain. */
76
+ const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
77
+ const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
78
+ const MAX_ERROR_RESPONSE_BYTES = 64 * 1024;
79
+ const MAX_ACCESS_TOKEN_LENGTH = 16384;
80
+ const MAX_RETRIES = 5;
81
+ const MAX_RETRY_DELAY_MS = 3e4;
9
82
  var ShopifyGraphQLError = class extends Error {
10
83
  errors;
11
84
  constructor(message, errors) {
@@ -14,7 +87,9 @@ var ShopifyGraphQLError = class extends Error {
14
87
  this.errors = errors;
15
88
  }
16
89
  };
17
- const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
90
+ const defaultSleep = (ms) => new Promise((resolve) => {
91
+ setTimeout(resolve, ms).unref();
92
+ });
18
93
  var ShopifyClient = class {
19
94
  shopDomain;
20
95
  accessToken;
@@ -23,90 +98,185 @@ var ShopifyClient = class {
23
98
  retryBaseMs;
24
99
  sleep;
25
100
  fetchImpl;
101
+ requestTimeoutMs;
26
102
  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;
103
+ this.shopDomain = validateShopDomain(config.shopDomain);
104
+ this.accessToken = validateAccessToken(config.accessToken);
105
+ this.apiVersion = validateShopifyApiVersion(config.apiVersion);
106
+ this.maxRetries = boundedInteger("maxRetries", config.maxRetries ?? 2, 0, MAX_RETRIES);
107
+ this.retryBaseMs = boundedInteger("retryBaseMs", config.retryBaseMs ?? 500, 1, MAX_RETRY_DELAY_MS);
32
108
  this.sleep = config.sleep ?? defaultSleep;
33
109
  this.fetchImpl = config.fetchImpl ?? fetch;
110
+ this.requestTimeoutMs = boundedInteger("requestTimeoutMs", config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, 1e3, 12e4);
34
111
  }
35
112
  get endpoint() {
36
113
  return `https://${this.shopDomain}/admin/api/${this.apiVersion}/graphql.json`;
37
114
  }
38
115
  async graphql(query, variables) {
116
+ if (typeof query !== "string" || query.trim().length < 1 || query.length > 1e5) throw new Error(`GraphQL query must contain 1 to ${String(MAX_GRAPHQL_QUERY_CHARS)} characters`);
117
+ const requestBody = encodeRequestBody(query, validateVariables(variables));
39
118
  let attempt = 0;
40
119
  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;
120
+ const response = await this.request(requestBody);
121
+ if (response.status === 429 && attempt < this.maxRetries) {
122
+ await cancelBody(response);
123
+ const waitMs = retryDelay(response.headers.get("Retry-After"), this.retryBaseMs, attempt);
56
124
  attempt += 1;
57
125
  await this.sleep(waitMs);
58
126
  continue;
59
127
  }
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}`);
128
+ if (!response.ok) {
129
+ await readResponseText(response, MAX_ERROR_RESPONSE_BYTES);
130
+ throw new Error(`Shopify Admin API HTTP ${String(response.status)} request failed`);
63
131
  }
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;
132
+ const parsed = parseGraphQLResponse(await readResponseText(response, MAX_RESPONSE_BYTES));
133
+ if (parsed.errors.length > 0) {
134
+ if (parsed.errors.some((error) => error.extensions?.code === "THROTTLED") && attempt < this.maxRetries) {
135
+ const waitMs = retryDelay(null, this.retryBaseMs, attempt);
68
136
  attempt += 1;
69
137
  await this.sleep(waitMs);
70
138
  continue;
71
139
  }
72
- const messages = body.errors.map((e) => e.message).join("; ");
73
- throw new ShopifyGraphQLError(`Shopify GraphQL error for ${this.shopDomain}: ${messages}`, body.errors);
140
+ throw new ShopifyGraphQLError(`Shopify GraphQL request failed: ${parsed.errors.map((error) => error.message).join("; ")}`, parsed.errors);
74
141
  }
75
- if (body.data === void 0) throw new Error(`Shopify GraphQL response for ${this.shopDomain} had no data`);
76
- return body.data;
142
+ if (parsed.data === void 0) throw new Error("Shopify GraphQL response had no data");
143
+ return parsed.data;
144
+ }
145
+ }
146
+ async request(body) {
147
+ try {
148
+ return await this.fetchImpl(this.endpoint, {
149
+ method: "POST",
150
+ headers: {
151
+ "Content-Type": "application/json",
152
+ Accept: "application/json",
153
+ "X-Shopify-Access-Token": this.accessToken
154
+ },
155
+ body,
156
+ redirect: "error",
157
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
158
+ });
159
+ } catch (error) {
160
+ throw new Error("Shopify Admin API request failed", { cause: error });
77
161
  }
78
162
  }
79
163
  };
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}`);
164
+ function assertNoUserErrors(data, payloadKey, errorKey = "userErrors") {
165
+ if (!isRecord(data) || !isRecord(data[payloadKey])) throw new Error(`Shopify ${payloadKey} response was malformed`);
166
+ const rawErrors = data[payloadKey][errorKey];
167
+ if (!Array.isArray(rawErrors) || rawErrors.length > 100) throw new Error(`Shopify ${payloadKey} response had malformed ${errorKey}`);
168
+ const details = rawErrors.map((rawError) => {
169
+ if (!isRecord(rawError) || typeof rawError.message !== "string") throw new Error(`Shopify ${payloadKey} response had malformed ${errorKey}`);
170
+ const message = rawError.message.slice(0, 1024);
171
+ if (rawError.field === null || rawError.field === void 0) return message;
172
+ if (!Array.isArray(rawError.field) || rawError.field.length > 32 || !rawError.field.every((field) => typeof field === "string" && field.length <= 128)) throw new Error(`Shopify ${payloadKey} response had malformed ${errorKey}`);
173
+ return rawError.field.length > 0 ? `${rawError.field.join(".")}: ${message}` : message;
174
+ });
175
+ if (details.length > 0) throw new Error(`Shopify ${payloadKey} failed: ${details.join("; ")}`);
176
+ }
177
+ function validateAccessToken(value) {
178
+ if (typeof value !== "string" || value.length < 1 || value.length > MAX_ACCESS_TOKEN_LENGTH || value !== value.trim() || /[\r\n]/u.test(value)) throw new Error("Shopify access token is invalid");
179
+ return value;
180
+ }
181
+ function boundedInteger(label, value, min, max) {
182
+ if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${String(min)} to ${String(max)}`);
183
+ return value;
184
+ }
185
+ function encodeRequestBody(query, variables) {
186
+ const body = JSON.stringify({
187
+ query,
188
+ variables
189
+ });
190
+ if (Buffer.byteLength(body, "utf-8") > 1048576) throw new Error(`GraphQL request exceeds the ${String(MAX_GRAPHQL_REQUEST_BYTES)} byte limit`);
191
+ return body;
192
+ }
193
+ function retryDelay(retryAfter, baseMs, attempt) {
194
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
195
+ const requested = Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : baseMs * 2 ** attempt;
196
+ return Math.min(Math.max(1, Math.round(requested)), MAX_RETRY_DELAY_MS);
197
+ }
198
+ async function cancelBody(response) {
199
+ try {
200
+ await response.body?.cancel("retrying throttled request");
201
+ } catch {}
202
+ }
203
+ async function readResponseText(response, maxBytes) {
204
+ const declaredLength = response.headers.get("content-length");
205
+ if (declaredLength !== null) {
206
+ const length = Number(declaredLength);
207
+ if (!Number.isSafeInteger(length) || length < 0 || length > maxBytes) throw new Error(`Shopify response exceeds ${String(maxBytes)} bytes`);
92
208
  }
209
+ if (!response.body) return "";
210
+ const reader = response.body.getReader();
211
+ const chunks = [];
212
+ let total = 0;
213
+ try {
214
+ for (;;) {
215
+ const chunk = await reader.read();
216
+ if (chunk.done) break;
217
+ if (chunk.value === void 0) throw new Error("Shopify response stream returned an invalid chunk");
218
+ total += chunk.value.byteLength;
219
+ if (total > maxBytes) {
220
+ await reader.cancel("response exceeds limit");
221
+ throw new Error(`Shopify response exceeds ${String(maxBytes)} bytes`);
222
+ }
223
+ chunks.push(chunk.value);
224
+ }
225
+ } finally {
226
+ reader.releaseLock();
227
+ }
228
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total).toString("utf-8");
229
+ }
230
+ function parseGraphQLResponse(text) {
231
+ let parsed;
232
+ try {
233
+ parsed = JSON.parse(text);
234
+ } catch (error) {
235
+ throw new Error("Shopify GraphQL response was not valid JSON", { cause: error });
236
+ }
237
+ if (!isRecord(parsed)) throw new Error("Shopify GraphQL response was malformed");
238
+ const errors = normalizeGraphQLErrors(parsed.errors);
239
+ return {
240
+ data: parsed.data,
241
+ errors
242
+ };
243
+ }
244
+ function normalizeGraphQLErrors(value) {
245
+ if (value === void 0) return [];
246
+ if (!Array.isArray(value) || value.length > 100) throw new Error("Shopify GraphQL errors were malformed");
247
+ return value.map((entry) => {
248
+ if (!isRecord(entry) || typeof entry.message !== "string") throw new Error("Shopify GraphQL errors were malformed");
249
+ let extensions;
250
+ if (entry.extensions !== void 0) {
251
+ if (!isRecord(entry.extensions)) throw new Error("Shopify GraphQL errors were malformed");
252
+ if (entry.extensions.code !== void 0 && typeof entry.extensions.code !== "string") throw new Error("Shopify GraphQL errors were malformed");
253
+ extensions = entry.extensions.code === void 0 ? {} : { code: entry.extensions.code.slice(0, 128) };
254
+ }
255
+ return {
256
+ message: entry.message.slice(0, 1024),
257
+ ...extensions ? { extensions } : {}
258
+ };
259
+ });
93
260
  }
94
261
  //#endregion
95
262
  //#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. */
263
+ const shopField = z.coerce.string().trim().toLowerCase().regex(SHOP_DOMAIN_PATTERN, "Expected a canonical your-store.myshopify.com domain").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.");
264
+ const pageSizeField = z.coerce.number().int().min(1).max(250).optional();
265
+ const cursorField = z.coerce.string().trim().min(1).max(4096).optional();
266
+ const searchField = z.coerce.string().trim().min(1).max(1e4).optional();
267
+ const boundedText = (maxChars = MAX_TOOL_TEXT_CHARS) => z.coerce.string().min(1).max(maxChars);
268
+ const optionalText = (maxChars = MAX_TOOL_TEXT_CHARS) => boundedText(maxChars).optional();
269
+ const tagsField = z.array(boundedText(255)).max(250).optional();
270
+ const shopifyGidField = (resource) => z.coerce.string().trim().max(256).regex(SHOPIFY_GID_PATTERN).refine((value) => SHOPIFY_GID_PATTERN.exec(value)?.[1] === resource, { message: `Expected a gid://shopify/${resource}/... identifier` });
271
+ const moneyField = z.coerce.string().trim().regex(MONEY_PATTERN, "Expected a non-negative decimal with at most two fractional digits");
272
+ const safeHttpsUrlField = z.coerce.string().trim().max(2048).refine((value) => {
273
+ try {
274
+ const url = new URL(value);
275
+ return url.protocol === "https:" && !url.username && !url.password;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }, "Expected an HTTPS URL without embedded credentials");
110
280
  const LIST_SHOPS_TOOL_NAME = "shopify_list_shops";
111
281
  //#endregion
112
282
  //#region src/tools.ts
@@ -127,14 +297,19 @@ const LIST_SHOPS_TOOL_NAME = "shopify_list_shops";
127
297
  * mutation/query names were verified against shopify.dev on 2026-07-20;
128
298
  * uncertain ones are flagged inline.
129
299
  */
130
- const ok = (data) => ({ content: [{
131
- type: "text",
132
- text: JSON.stringify(data, null, 2)
133
- }] });
300
+ const MAX_TOOL_RESULT_BYTES = 5 * 1024 * 1024;
301
+ const ok = (data) => {
302
+ const text = JSON.stringify(data, null, 2);
303
+ if (Buffer.byteLength(text, "utf-8") > MAX_TOOL_RESULT_BYTES) throw new Error(`Shopify tool result exceeds ${String(MAX_TOOL_RESULT_BYTES)} bytes`);
304
+ return { content: [{
305
+ type: "text",
306
+ text
307
+ }] };
308
+ };
134
309
  const fail = (err) => ({
135
310
  content: [{
136
311
  type: "text",
137
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
312
+ text: JSON.stringify({ error: (err instanceof Error ? err.message : String(err)).slice(0, 4096) })
138
313
  }],
139
314
  isError: true
140
315
  });
@@ -153,14 +328,24 @@ function registerTools(server, resolveClient, listShops) {
153
328
  return fail(err);
154
329
  }
155
330
  };
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() }));
331
+ register(LIST_SHOPS_TOOL_NAME, {
332
+ 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.",
333
+ annotations: {
334
+ readOnlyHint: true,
335
+ idempotentHint: true
336
+ }
337
+ }, () => ok({ shops: listShops() }));
157
338
  register("shopify_list_products", {
158
339
  description: "List products in a connected Shopify store. Supports a search `query` (Shopify search syntax, e.g. \"title:Shirt status:active\") and cursor pagination.",
340
+ annotations: {
341
+ readOnlyHint: true,
342
+ idempotentHint: true
343
+ },
159
344
  inputSchema: {
160
345
  shop: shopField,
161
- first: z.coerce.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.")
346
+ first: pageSizeField.describe("Page size (default 50, max 250)."),
347
+ after: cursorField.describe("Pagination cursor from a previous page's pageInfo.endCursor."),
348
+ query: searchField.describe("Optional Shopify product search query.")
164
349
  }
165
350
  }, guarded(async (client, args) => {
166
351
  return ok(await client.graphql(`query ListProducts($first: Int!, $after: String, $query: String) {
@@ -179,9 +364,13 @@ function registerTools(server, resolveClient, listShops) {
179
364
  }));
180
365
  register("shopify_get_product", {
181
366
  description: "Fetch one product (with its variants) by GID from a connected Shopify store.",
367
+ annotations: {
368
+ readOnlyHint: true,
369
+ idempotentHint: true
370
+ },
182
371
  inputSchema: {
183
372
  shop: shopField,
184
- id: z.string().describe("The product GID, e.g. gid://shopify/Product/1234567890.")
373
+ id: shopifyGidField("Product").describe("The product GID, e.g. gid://shopify/Product/1234567890.")
185
374
  }
186
375
  }, guarded(async (client, args) => {
187
376
  return ok(await client.graphql(`query GetProduct($id: ID!) {
@@ -195,13 +384,17 @@ function registerTools(server, resolveClient, listShops) {
195
384
  }));
196
385
  register("shopify_create_product", {
197
386
  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).",
387
+ annotations: {
388
+ readOnlyHint: false,
389
+ idempotentHint: false
390
+ },
198
391
  inputSchema: {
199
392
  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(),
393
+ title: boundedText(255).describe("Product title (required)."),
394
+ descriptionHtml: optionalText(5e5),
395
+ vendor: optionalText(255),
396
+ productType: optionalText(255),
397
+ tags: tagsField,
205
398
  status: z.enum([
206
399
  "ACTIVE",
207
400
  "DRAFT",
@@ -226,14 +419,18 @@ function registerTools(server, resolveClient, listShops) {
226
419
  }));
227
420
  register("shopify_update_product", {
228
421
  description: "Update fields on an existing product in a connected Shopify store. Uses the productUpdate mutation. Only the supplied fields change.",
422
+ annotations: {
423
+ readOnlyHint: false,
424
+ idempotentHint: true
425
+ },
229
426
  inputSchema: {
230
427
  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(),
428
+ id: shopifyGidField("Product").describe("The product GID to update."),
429
+ title: optionalText(255),
430
+ descriptionHtml: optionalText(5e5),
431
+ vendor: optionalText(255),
432
+ productType: optionalText(255),
433
+ tags: tagsField,
237
434
  status: z.enum([
238
435
  "ACTIVE",
239
436
  "DRAFT",
@@ -258,14 +455,21 @@ function registerTools(server, resolveClient, listShops) {
258
455
  return ok(data);
259
456
  }));
260
457
  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.",
458
+ description: "Set the price of a single product variant. Copy the exact variant and price into confirmVariantId and confirmPrice to confirm the commerce change.",
459
+ annotations: {
460
+ readOnlyHint: false,
461
+ idempotentHint: true
462
+ },
262
463
  inputSchema: {
263
464
  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\".")
465
+ productId: shopifyGidField("Product").describe("The parent product GID."),
466
+ variantId: shopifyGidField("ProductVariant").describe("The variant GID to reprice."),
467
+ price: moneyField.describe("The new price as a decimal string, e.g. \"19.99\"."),
468
+ confirmVariantId: shopifyGidField("ProductVariant").describe("Exact variant GID confirming the price target."),
469
+ confirmPrice: moneyField.describe("Exact decimal price confirming the new value.")
267
470
  }
268
471
  }, guarded(async (client, args) => {
472
+ if (args.confirmVariantId !== args.variantId || args.confirmPrice !== args.price) throw new Error("Variant price confirmation must exactly match variantId and price");
269
473
  const data = await client.graphql(`mutation SetVariantPrice($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
270
474
  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
271
475
  productVariants { id price }
@@ -283,11 +487,15 @@ function registerTools(server, resolveClient, listShops) {
283
487
  }));
284
488
  register("shopify_list_orders", {
285
489
  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.",
490
+ annotations: {
491
+ readOnlyHint: true,
492
+ idempotentHint: true
493
+ },
286
494
  inputSchema: {
287
495
  shop: shopField,
288
- first: z.coerce.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).")
496
+ first: pageSizeField.describe("Page size (default 50, max 250)."),
497
+ after: cursorField.describe("Pagination cursor."),
498
+ query: searchField.describe("Optional Shopify order search query (status/date filters).")
291
499
  }
292
500
  }, guarded(async (client, args) => {
293
501
  return ok(await client.graphql(`query ListOrders($first: Int!, $after: String, $query: String) {
@@ -310,9 +518,13 @@ function registerTools(server, resolveClient, listShops) {
310
518
  }));
311
519
  register("shopify_get_order", {
312
520
  description: "Fetch one order (with line items and fulfillment orders) by GID from a connected Shopify store.",
521
+ annotations: {
522
+ readOnlyHint: true,
523
+ idempotentHint: true
524
+ },
313
525
  inputSchema: {
314
526
  shop: shopField,
315
- id: z.string().describe("The order GID, e.g. gid://shopify/Order/1234567890.")
527
+ id: shopifyGidField("Order").describe("The order GID, e.g. gid://shopify/Order/1234567890.")
316
528
  }
317
529
  }, guarded(async (client, args) => {
318
530
  return ok(await client.graphql(`query GetOrder($id: ID!) {
@@ -328,16 +540,23 @@ function registerTools(server, resolveClient, listShops) {
328
540
  }`, { id: args.id }));
329
541
  }));
330
542
  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.",
543
+ description: "Fulfill a fulfillment order. Copy the exact fulfillmentOrderId into confirmFulfillmentOrderId to confirm this irreversible shipment action.",
544
+ annotations: {
545
+ readOnlyHint: false,
546
+ destructiveHint: true,
547
+ idempotentHint: false
548
+ },
332
549
  inputSchema: {
333
550
  shop: shopField,
334
- fulfillmentOrderId: z.string().describe("The fulfillment order GID (from order.fulfillmentOrders)."),
551
+ fulfillmentOrderId: shopifyGidField("FulfillmentOrder").describe("The fulfillment order GID (from order.fulfillmentOrders)."),
552
+ confirmFulfillmentOrderId: shopifyGidField("FulfillmentOrder").describe("Exact fulfillment-order GID confirming the shipment target."),
335
553
  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()
554
+ trackingNumber: optionalText(255),
555
+ trackingUrl: safeHttpsUrlField.optional(),
556
+ trackingCompany: optionalText(255)
339
557
  }
340
558
  }, guarded(async (client, args) => {
559
+ if (args.confirmFulfillmentOrderId !== args.fulfillmentOrderId) throw new Error("confirmFulfillmentOrderId must exactly match fulfillmentOrderId");
341
560
  const fulfillment = {
342
561
  lineItemsByFulfillmentOrder: [{ fulfillmentOrderId: args.fulfillmentOrderId }],
343
562
  notifyCustomer: args.notifyCustomer ?? false
@@ -359,24 +578,31 @@ function registerTools(server, resolveClient, listShops) {
359
578
  return ok(data);
360
579
  }));
361
580
  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.",
581
+ description: "Create a refund record against an order. Copy the exact orderId into confirmOrderId to confirm the financial/inventory target.",
582
+ annotations: {
583
+ readOnlyHint: false,
584
+ destructiveHint: true,
585
+ idempotentHint: false
586
+ },
363
587
  inputSchema: {
364
588
  shop: shopField,
365
- orderId: z.string().describe("The order GID to refund."),
366
- note: z.string().optional(),
589
+ orderId: shopifyGidField("Order").describe("The order GID to refund."),
590
+ confirmOrderId: shopifyGidField("Order").describe("Exact order GID confirming the refund target."),
591
+ note: optionalText(1e4),
367
592
  notify: z.boolean().optional().describe("Notify the customer (default false)."),
368
593
  refundLineItems: z.array(z.object({
369
- lineItemId: z.string().describe("The order line item GID to refund."),
370
- quantity: z.coerce.number().int().min(1),
594
+ lineItemId: shopifyGidField("LineItem").describe("The order line item GID to refund."),
595
+ quantity: z.coerce.number().int().min(1).max(1e6),
371
596
  restockType: z.enum([
372
597
  "NO_RESTOCK",
373
598
  "CANCEL",
374
599
  "RETURN",
375
600
  "LEGACY_RESTOCK"
376
601
  ]).optional()
377
- })).optional().describe("Line items to refund. Omit for a shipping-only or note-only refund.")
602
+ })).min(1).max(250).optional().describe("Line items to refund. Omit for a shipping-only or note-only refund.")
378
603
  }
379
604
  }, guarded(async (client, args) => {
605
+ if (args.confirmOrderId !== args.orderId) throw new Error("confirmOrderId must exactly match the order being refunded");
380
606
  const input = {
381
607
  orderId: args.orderId,
382
608
  notify: args.notify ?? false
@@ -400,10 +626,16 @@ function registerTools(server, resolveClient, listShops) {
400
626
  return ok(data);
401
627
  }));
402
628
  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.",
629
+ description: "Cancel an order (async job). Copy the exact orderId into confirmOrderId to confirm the irreversible target; reason and restock are required.",
630
+ annotations: {
631
+ readOnlyHint: false,
632
+ destructiveHint: true,
633
+ idempotentHint: false
634
+ },
404
635
  inputSchema: {
405
636
  shop: shopField,
406
- orderId: z.string().describe("The order GID to cancel."),
637
+ orderId: shopifyGidField("Order").describe("The order GID to cancel."),
638
+ confirmOrderId: shopifyGidField("Order").describe("Exact order GID confirming cancellation."),
407
639
  reason: z.enum([
408
640
  "CUSTOMER",
409
641
  "DECLINED",
@@ -415,9 +647,10 @@ function registerTools(server, resolveClient, listShops) {
415
647
  restock: z.boolean().describe("Whether to restock the order's inventory (required)."),
416
648
  refund: z.boolean().optional().describe("Whether to refund the order's payment."),
417
649
  notifyCustomer: z.boolean().optional().describe("Notify the customer (default false)."),
418
- staffNote: z.string().optional().describe("Internal note (max 255 chars).")
650
+ staffNote: optionalText(255).describe("Internal note (max 255 chars).")
419
651
  }
420
652
  }, guarded(async (client, args) => {
653
+ if (args.confirmOrderId !== args.orderId) throw new Error("confirmOrderId must exactly match the order being cancelled");
421
654
  const variables = {
422
655
  orderId: args.orderId,
423
656
  reason: args.reason,
@@ -438,17 +671,20 @@ function registerTools(server, resolveClient, listShops) {
438
671
  orderCancelUserErrors { field message code }
439
672
  }
440
673
  }`, 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("; ")}`);
674
+ assertNoUserErrors(data, "orderCancel", "orderCancelUserErrors");
443
675
  return ok(data);
444
676
  }));
445
677
  register("shopify_list_customers", {
446
678
  description: "List customers in a connected Shopify store. Supports a Shopify search `query` (e.g. \"email:*@example.com\") and cursor pagination.",
679
+ annotations: {
680
+ readOnlyHint: true,
681
+ idempotentHint: true
682
+ },
447
683
  inputSchema: {
448
684
  shop: shopField,
449
- first: z.coerce.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.")
685
+ first: pageSizeField.describe("Page size (default 50, max 250)."),
686
+ after: cursorField,
687
+ query: searchField.describe("Optional Shopify customer search query.")
452
688
  }
453
689
  }, guarded(async (client, args) => {
454
690
  return ok(await client.graphql(`query ListCustomers($first: Int!, $after: String, $query: String) {
@@ -464,9 +700,13 @@ function registerTools(server, resolveClient, listShops) {
464
700
  }));
465
701
  register("shopify_get_customer", {
466
702
  description: "Fetch one customer by GID from a connected Shopify store.",
703
+ annotations: {
704
+ readOnlyHint: true,
705
+ idempotentHint: true
706
+ },
467
707
  inputSchema: {
468
708
  shop: shopField,
469
- id: z.string().describe("The customer GID, e.g. gid://shopify/Customer/1234567890.")
709
+ id: shopifyGidField("Customer").describe("The customer GID, e.g. gid://shopify/Customer/1234567890.")
470
710
  }
471
711
  }, guarded(async (client, args) => {
472
712
  return ok(await client.graphql(`query GetCustomer($id: ID!) {
@@ -478,14 +718,18 @@ function registerTools(server, resolveClient, listShops) {
478
718
  }));
479
719
  register("shopify_create_customer", {
480
720
  description: "Create a customer in a connected Shopify store using the customerCreate mutation.",
721
+ annotations: {
722
+ readOnlyHint: false,
723
+ idempotentHint: false
724
+ },
481
725
  inputSchema: {
482
726
  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()
727
+ firstName: optionalText(255),
728
+ lastName: optionalText(255),
729
+ email: z.email().max(320).optional(),
730
+ phone: optionalText(64),
731
+ note: optionalText(1e5),
732
+ tags: tagsField
489
733
  }
490
734
  }, guarded(async (client, args) => {
491
735
  const input = {};
@@ -506,15 +750,19 @@ function registerTools(server, resolveClient, listShops) {
506
750
  }));
507
751
  register("shopify_update_customer", {
508
752
  description: "Update fields on an existing customer in a connected Shopify store using customerUpdate. Only supplied fields change.",
753
+ annotations: {
754
+ readOnlyHint: false,
755
+ idempotentHint: true
756
+ },
509
757
  inputSchema: {
510
758
  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()
759
+ id: shopifyGidField("Customer").describe("The customer GID to update."),
760
+ firstName: optionalText(255),
761
+ lastName: optionalText(255),
762
+ email: z.email().max(320).optional(),
763
+ phone: optionalText(64),
764
+ note: optionalText(1e5),
765
+ tags: tagsField
518
766
  }
519
767
  }, guarded(async (client, args) => {
520
768
  const input = { id: args.id };
@@ -535,9 +783,13 @@ function registerTools(server, resolveClient, listShops) {
535
783
  }));
536
784
  register("shopify_list_locations", {
537
785
  description: "List the inventory locations of a connected Shopify store (needed to adjust inventory levels).",
786
+ annotations: {
787
+ readOnlyHint: true,
788
+ idempotentHint: true
789
+ },
538
790
  inputSchema: {
539
791
  shop: shopField,
540
- first: z.coerce.number().int().min(1).max(250).optional().describe("Page size (default 50).")
792
+ first: pageSizeField.describe("Page size (default 50).")
541
793
  }
542
794
  }, guarded(async (client, args) => {
543
795
  return ok(await client.graphql(`query ListLocations($first: Int!) {
@@ -548,10 +800,14 @@ function registerTools(server, resolveClient, listShops) {
548
800
  }));
549
801
  register("shopify_get_inventory_levels", {
550
802
  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).",
803
+ annotations: {
804
+ readOnlyHint: true,
805
+ idempotentHint: true
806
+ },
551
807
  inputSchema: {
552
808
  shop: shopField,
553
- inventoryItemId: z.string().describe("The inventory item GID (variant.inventoryItem.id)."),
554
- first: z.coerce.number().int().min(1).max(250).optional().describe("Page size (default 50).")
809
+ inventoryItemId: shopifyGidField("InventoryItem").describe("The inventory item GID (variant.inventoryItem.id)."),
810
+ first: pageSizeField.describe("Page size (default 50).")
555
811
  }
556
812
  }, guarded(async (client, args) => {
557
813
  return ok(await client.graphql(`query InventoryLevels($id: ID!, $first: Int!) {
@@ -572,15 +828,23 @@ function registerTools(server, resolveClient, listShops) {
572
828
  }));
573
829
  }));
574
830
  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.",
831
+ description: "Adjust available inventory. Copy inventoryItemId, locationId, and delta into the matching confirmation fields to confirm the stock change.",
832
+ annotations: {
833
+ readOnlyHint: false,
834
+ idempotentHint: false
835
+ },
576
836
  inputSchema: {
577
837
  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.coerce.number().int().describe("Signed quantity change, e.g. 10 or -3."),
581
- reason: z.string().optional().describe("Adjustment reason code (default \"correction\").")
838
+ inventoryItemId: shopifyGidField("InventoryItem").describe("The inventory item GID."),
839
+ locationId: shopifyGidField("Location").describe("The location GID (from shopify_list_locations)."),
840
+ delta: z.coerce.number().int().min(-1e6).max(1e6).refine((value) => value !== 0).describe("Non-zero signed quantity change, e.g. 10 or -3."),
841
+ confirmInventoryItemId: shopifyGidField("InventoryItem"),
842
+ confirmLocationId: shopifyGidField("Location"),
843
+ confirmDelta: z.coerce.number().int().min(-1e6).max(1e6),
844
+ reason: optionalText(255).describe("Adjustment reason code (default \"correction\").")
582
845
  }
583
846
  }, guarded(async (client, args) => {
847
+ if (args.confirmInventoryItemId !== args.inventoryItemId || args.confirmLocationId !== args.locationId || args.confirmDelta !== args.delta) throw new Error("Inventory confirmation must exactly match inventoryItemId, locationId, and delta");
584
848
  const data = await client.graphql(`mutation AdjustInventory($input: InventoryAdjustQuantitiesInput!, $key: String!) {
585
849
  inventoryAdjustQuantities(input: $input) @idempotent(key: $key) {
586
850
  inventoryAdjustmentGroup { createdAt reason changes { name delta } }
@@ -621,6 +885,7 @@ function registerTools(server, resolveClient, listShops) {
621
885
  * Architecture:
622
886
  * OpenClaw ←(stdio)→ this server ←(https)→ Shopify Admin GraphQL API
623
887
  */
888
+ const packageMetadata = createRequire(import.meta.url)("../package.json");
624
889
  /**
625
890
  * Fallback Admin API version. The connect provider stamps the pinned version
626
891
  * (`2026-07`) onto every account via `buildCredentialsResponse`, so this is only
@@ -639,11 +904,12 @@ function log(msg) {
639
904
  * to ask the user to reconnect rather than retrying blindly.
640
905
  */
641
906
  function resolveClient(shop) {
642
- const c = clients.get(shop);
907
+ const normalizedShop = validateShopDomain(shop);
908
+ const c = clients.get(normalizedShop);
643
909
  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).`);
910
+ const known = shopSnapshot.find((s) => s.shopDomain === normalizedShop);
911
+ if (known && !known.connected) throw new Error(`Shop ${normalizedShop} 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.`);
912
+ throw new Error(`Unknown shop: ${normalizedShop}. Call ${LIST_SHOPS_TOOL_NAME} to see the connected Shopify stores on this agent (pass the shopDomain, e.g. your-store.myshopify.com).`);
647
913
  }
648
914
  async function main() {
649
915
  const config = resolveConfig();
@@ -652,12 +918,17 @@ async function main() {
652
918
  apiUrl: config.apiUrl
653
919
  }).getShopifyAccounts();
654
920
  if (accounts.length === 0) log(`No Shopify stores connected — server will start with ${LIST_SHOPS_TOOL_NAME} only (returns a "no store connected" hint)`);
921
+ const seenShops = /* @__PURE__ */ new Set();
655
922
  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`);
923
+ const candidateDomain = acct.shopDomain || (acct.accountIdentifier.endsWith(".myshopify.com") ? acct.accountIdentifier : "");
924
+ if (!acct.accessToken || !candidateDomain) {
925
+ log("Skipping Shopify account — missing access token or shop domain");
926
+ let deadShopDomain = "unavailable.invalid";
927
+ if (candidateDomain) try {
928
+ deadShopDomain = validateShopDomain(candidateDomain);
929
+ } catch {}
659
930
  shopSnapshot.push({
660
- shopDomain: shopDomain || acct.accountIdentifier,
931
+ shopDomain: deadShopDomain,
661
932
  shopName: acct.shopName || acct.displayName,
662
933
  shopGid: acct.shopGid || null,
663
934
  connectedAt: acct.connectedAt,
@@ -666,15 +937,33 @@ async function main() {
666
937
  });
667
938
  continue;
668
939
  }
669
- if (clients.has(shopDomain)) {
940
+ let shopDomain;
941
+ let client;
942
+ try {
943
+ shopDomain = validateShopDomain(candidateDomain);
944
+ client = new ShopifyClient({
945
+ accessToken: acct.accessToken,
946
+ shopDomain,
947
+ apiVersion: acct.apiVersion || FALLBACK_API_VERSION
948
+ });
949
+ } catch {
950
+ log("Skipping Shopify account — invalid credential metadata");
951
+ shopSnapshot.push({
952
+ shopDomain: "unavailable.invalid",
953
+ shopName: acct.shopName || acct.displayName,
954
+ shopGid: acct.shopGid || null,
955
+ connectedAt: acct.connectedAt,
956
+ connected: false,
957
+ reason: "invalid_credential_metadata"
958
+ });
959
+ continue;
960
+ }
961
+ if (seenShops.has(shopDomain)) {
670
962
  log(`Duplicate shop ${shopDomain} returned by getShopifyAccounts() — keeping the first cached client`);
671
963
  continue;
672
964
  }
673
- clients.set(shopDomain, new ShopifyClient({
674
- accessToken: acct.accessToken,
675
- shopDomain,
676
- apiVersion: acct.apiVersion || FALLBACK_API_VERSION
677
- }));
965
+ seenShops.add(shopDomain);
966
+ clients.set(shopDomain, client);
678
967
  shopSnapshot.push({
679
968
  shopDomain,
680
969
  shopName: acct.shopName || acct.displayName,
@@ -682,11 +971,11 @@ async function main() {
682
971
  connectedAt: acct.connectedAt,
683
972
  connected: true
684
973
  });
685
- log(`Cached client for store ${shopDomain} (${acct.shopName || acct.displayName || "no name"})`);
974
+ log(`Cached client for store ${shopDomain}`);
686
975
  }
687
976
  const server = new McpServer({
688
977
  name: "shopify-mcp-server",
689
- version: "0.1.0"
978
+ version: packageMetadata.version
690
979
  });
691
980
  registerTools(server, resolveClient, () => shopSnapshot);
692
981
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/shopify-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
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
5
  "type": "module",
6
6
  "main": "./dist/server.js",
@@ -19,8 +19,8 @@
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": "^1.29.0",
21
21
  "zod": "^4.0.5",
22
- "@alfe.ai/config": "0.4.0",
23
- "@alfe.ai/agent-api-client": "0.14.0"
22
+ "@alfe.ai/config": "0.4.1",
23
+ "@alfe.ai/agent-api-client": "0.15.0"
24
24
  },
25
25
  "license": "UNLICENSED",
26
26
  "homepage": "https://alfe.ai",