@alfe.ai/shopify-mcp 0.2.1 → 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.
- package/README.md +20 -0
- package/dist/server.js +434 -145
- 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
|
-
/**
|
|
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) =>
|
|
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
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 (!
|
|
61
|
-
|
|
62
|
-
throw new Error(`Shopify Admin API HTTP ${String(
|
|
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
|
|
65
|
-
if (
|
|
66
|
-
if (
|
|
67
|
-
const waitMs = this.retryBaseMs
|
|
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
|
-
|
|
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 (
|
|
76
|
-
return
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
text
|
|
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, {
|
|
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:
|
|
162
|
-
after:
|
|
163
|
-
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:
|
|
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:
|
|
201
|
-
descriptionHtml:
|
|
202
|
-
vendor:
|
|
203
|
-
productType:
|
|
204
|
-
tags:
|
|
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:
|
|
232
|
-
title:
|
|
233
|
-
descriptionHtml:
|
|
234
|
-
vendor:
|
|
235
|
-
productType:
|
|
236
|
-
tags:
|
|
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
|
|
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:
|
|
265
|
-
variantId:
|
|
266
|
-
price:
|
|
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:
|
|
289
|
-
after:
|
|
290
|
-
query:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
337
|
-
trackingUrl:
|
|
338
|
-
trackingCompany:
|
|
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
|
|
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:
|
|
366
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
450
|
-
after:
|
|
451
|
-
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:
|
|
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:
|
|
484
|
-
lastName:
|
|
485
|
-
email: z.
|
|
486
|
-
phone:
|
|
487
|
-
note:
|
|
488
|
-
tags:
|
|
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:
|
|
512
|
-
firstName:
|
|
513
|
-
lastName:
|
|
514
|
-
email: z.
|
|
515
|
-
phone:
|
|
516
|
-
note:
|
|
517
|
-
tags:
|
|
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:
|
|
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:
|
|
554
|
-
first:
|
|
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
|
|
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:
|
|
579
|
-
locationId:
|
|
580
|
-
delta: z.coerce.number().int().describe("
|
|
581
|
-
|
|
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
|
|
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 ===
|
|
645
|
-
if (known && !known.connected) throw new Error(`Shop ${
|
|
646
|
-
throw new Error(`Unknown shop: ${
|
|
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
|
|
657
|
-
if (!acct.accessToken || !
|
|
658
|
-
log(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
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}
|
|
974
|
+
log(`Cached client for store ${shopDomain}`);
|
|
686
975
|
}
|
|
687
976
|
const server = new McpServer({
|
|
688
977
|
name: "shopify-mcp-server",
|
|
689
|
-
version:
|
|
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.
|
|
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.
|
|
23
|
-
"@alfe.ai/agent-api-client": "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",
|