@binaryandthread/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -0
- package/dist/bin.js +1076 -0
- package/dist/bin.js.map +1 -0
- package/package.json +39 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../store-client/dist/index.js
|
|
4
|
+
var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
|
|
5
|
+
"authorization",
|
|
6
|
+
"token",
|
|
7
|
+
"access_token",
|
|
8
|
+
"refresh_token",
|
|
9
|
+
"jwt",
|
|
10
|
+
"idempotency-key",
|
|
11
|
+
"x-publishable-api-key",
|
|
12
|
+
"password",
|
|
13
|
+
"secret",
|
|
14
|
+
"cookie",
|
|
15
|
+
"set-cookie"
|
|
16
|
+
]);
|
|
17
|
+
var MASK = "[REDACTED]";
|
|
18
|
+
var BEARER_RE = /Bearer\s+[A-Za-z0-9._\-]+/gi;
|
|
19
|
+
var EMAIL_RE = /([^\s@]+)@([^\s@]+\.[^\s@]+)/g;
|
|
20
|
+
function maskEmail(email) {
|
|
21
|
+
const at = email.indexOf("@");
|
|
22
|
+
if (at <= 0) {
|
|
23
|
+
return email;
|
|
24
|
+
}
|
|
25
|
+
const local = email.slice(0, at);
|
|
26
|
+
const host = email.slice(at + 1);
|
|
27
|
+
const localMasked = local[0] + "*".repeat(Math.max(2, local.length - 1));
|
|
28
|
+
const hostMasked = host.length > 0 ? host[0] : "x";
|
|
29
|
+
return `${localMasked}@${hostMasked}`;
|
|
30
|
+
}
|
|
31
|
+
function redactString(s) {
|
|
32
|
+
return s.replace(BEARER_RE, "Bearer [REDACTED]").replace(
|
|
33
|
+
EMAIL_RE,
|
|
34
|
+
(_m, _l, _h) => maskEmail(`${_l}@${_h}`)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
function redact(input, _seen) {
|
|
38
|
+
if (input == null) {
|
|
39
|
+
return input;
|
|
40
|
+
}
|
|
41
|
+
if (typeof input === "string") {
|
|
42
|
+
return redactString(input);
|
|
43
|
+
}
|
|
44
|
+
if (typeof input !== "object") {
|
|
45
|
+
return input;
|
|
46
|
+
}
|
|
47
|
+
const seen = _seen ?? /* @__PURE__ */ new WeakSet();
|
|
48
|
+
if (seen.has(input)) {
|
|
49
|
+
return input;
|
|
50
|
+
}
|
|
51
|
+
seen.add(input);
|
|
52
|
+
if (Array.isArray(input)) {
|
|
53
|
+
return input.map((v) => redact(v, seen));
|
|
54
|
+
}
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [key, value] of Object.entries(input)) {
|
|
57
|
+
if (SENSITIVE_KEYS.has(key.toLowerCase())) {
|
|
58
|
+
out[key] = MASK;
|
|
59
|
+
} else if (key.toLowerCase() === "email" && typeof value === "string") {
|
|
60
|
+
out[key] = maskEmail(value);
|
|
61
|
+
} else {
|
|
62
|
+
out[key] = redact(value, seen);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
var DEFAULT_RETRY = {
|
|
68
|
+
attempts: 3,
|
|
69
|
+
baseDelayMs: 200,
|
|
70
|
+
maxDelayMs: 2e3
|
|
71
|
+
};
|
|
72
|
+
var RETRY_AFTER_MAX_MS = 1e4;
|
|
73
|
+
function parseRetryAfterMs(res) {
|
|
74
|
+
let raw = null;
|
|
75
|
+
try {
|
|
76
|
+
raw = res.headers?.get?.("retry-after") ?? null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (!raw) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const secs = Number(raw);
|
|
84
|
+
let ms;
|
|
85
|
+
if (Number.isFinite(secs)) {
|
|
86
|
+
ms = secs * 1e3;
|
|
87
|
+
} else {
|
|
88
|
+
const dateMs = Date.parse(raw);
|
|
89
|
+
if (!Number.isFinite(dateMs)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
ms = dateMs - Date.now();
|
|
93
|
+
}
|
|
94
|
+
return Math.min(Math.max(0, ms), RETRY_AFTER_MAX_MS);
|
|
95
|
+
}
|
|
96
|
+
var RecoverableCartError = class extends Error {
|
|
97
|
+
recoverable = true;
|
|
98
|
+
status;
|
|
99
|
+
constructor(message, status) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.name = "RecoverableCartError";
|
|
102
|
+
this.status = status;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
var StoreApiError = class extends Error {
|
|
106
|
+
status;
|
|
107
|
+
path;
|
|
108
|
+
constructor(message, status, path) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = "StoreApiError";
|
|
111
|
+
this.status = status;
|
|
112
|
+
this.path = path;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
function sleep(ms) {
|
|
116
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
117
|
+
}
|
|
118
|
+
function newUuid() {
|
|
119
|
+
const g = globalThis;
|
|
120
|
+
if (g.crypto?.randomUUID) {
|
|
121
|
+
return g.crypto.randomUUID();
|
|
122
|
+
}
|
|
123
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
124
|
+
const r = Math.random() * 16 | 0;
|
|
125
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
126
|
+
return v.toString(16);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
var StoreClient = class {
|
|
130
|
+
baseUrl;
|
|
131
|
+
publishableKey;
|
|
132
|
+
jwt;
|
|
133
|
+
fetchImpl;
|
|
134
|
+
logger;
|
|
135
|
+
retry;
|
|
136
|
+
sleepImpl;
|
|
137
|
+
/** Cached resolved regions (read-only catalog optimisation). */
|
|
138
|
+
regionsCache = null;
|
|
139
|
+
constructor(opts) {
|
|
140
|
+
if (!opts.baseUrl) {
|
|
141
|
+
throw new Error("StoreClient: baseUrl is required");
|
|
142
|
+
}
|
|
143
|
+
if (!opts.publishableKey) {
|
|
144
|
+
throw new Error("StoreClient: publishableKey is required");
|
|
145
|
+
}
|
|
146
|
+
if (/^sk_/.test(opts.publishableKey)) {
|
|
147
|
+
throw new Error("StoreClient: refusing a secret key \u2014 the client carries only the publishable key (T6)");
|
|
148
|
+
}
|
|
149
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
150
|
+
this.publishableKey = opts.publishableKey;
|
|
151
|
+
this.jwt = opts.jwt;
|
|
152
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
153
|
+
this.logger = opts.logger ?? console;
|
|
154
|
+
this.retry = { ...DEFAULT_RETRY, ...opts.retry ?? {} };
|
|
155
|
+
this.sleepImpl = opts.sleepImpl ?? sleep;
|
|
156
|
+
}
|
|
157
|
+
// ----- the request engine --------------------------------------------------
|
|
158
|
+
buildUrl(path, query) {
|
|
159
|
+
const url = new URL(this.baseUrl + path);
|
|
160
|
+
if (query) {
|
|
161
|
+
for (const [k, v] of Object.entries(query)) {
|
|
162
|
+
if (v !== void 0 && v !== null && v !== "") {
|
|
163
|
+
url.searchParams.set(k, String(v));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return url.toString();
|
|
168
|
+
}
|
|
169
|
+
baseHeaders() {
|
|
170
|
+
const headers = {
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
"x-publishable-api-key": this.publishableKey
|
|
173
|
+
};
|
|
174
|
+
if (this.jwt) {
|
|
175
|
+
headers["Authorization"] = `Bearer ${this.jwt}`;
|
|
176
|
+
}
|
|
177
|
+
return headers;
|
|
178
|
+
}
|
|
179
|
+
backoffMs(attemptIndex) {
|
|
180
|
+
const exp = this.retry.baseDelayMs * 2 ** attemptIndex;
|
|
181
|
+
const capped = Math.min(exp, this.retry.maxDelayMs);
|
|
182
|
+
return Math.floor(Math.random() * (capped + 1));
|
|
183
|
+
}
|
|
184
|
+
async request(method, path, opts = {}) {
|
|
185
|
+
const headers = this.baseHeaders();
|
|
186
|
+
if (opts.idempotent) {
|
|
187
|
+
headers["Idempotency-Key"] = newUuid();
|
|
188
|
+
}
|
|
189
|
+
const url = this.buildUrl(path, opts.query);
|
|
190
|
+
const init = {
|
|
191
|
+
method,
|
|
192
|
+
headers,
|
|
193
|
+
...opts.body !== void 0 ? { body: JSON.stringify(opts.body) } : {}
|
|
194
|
+
};
|
|
195
|
+
let lastErr;
|
|
196
|
+
for (let attempt = 0; attempt < this.retry.attempts; attempt++) {
|
|
197
|
+
let retryAfterMs = null;
|
|
198
|
+
try {
|
|
199
|
+
const res = await this.fetchImpl(url, init);
|
|
200
|
+
if (res.ok) {
|
|
201
|
+
const text = await res.text();
|
|
202
|
+
return text ? JSON.parse(text) : {};
|
|
203
|
+
}
|
|
204
|
+
let parsed = {};
|
|
205
|
+
try {
|
|
206
|
+
const t = await res.text();
|
|
207
|
+
parsed = t ? JSON.parse(t) : {};
|
|
208
|
+
} catch {
|
|
209
|
+
parsed = {};
|
|
210
|
+
}
|
|
211
|
+
if (opts.cartRecovery && (res.status === 404 || res.status === 409 || String(parsed.type ?? "").includes("cart.completed") || /cart.*complete/i.test(String(parsed.message ?? "")))) {
|
|
212
|
+
throw new RecoverableCartError(
|
|
213
|
+
`cart is stale or completed (HTTP ${res.status})`,
|
|
214
|
+
res.status
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (res.status === 429) {
|
|
218
|
+
retryAfterMs = parseRetryAfterMs(res);
|
|
219
|
+
lastErr = new StoreApiError(`rate limited (HTTP 429)`, 429, path);
|
|
220
|
+
this.logWarn("store.request.rate_limited", {
|
|
221
|
+
path,
|
|
222
|
+
attempt: attempt + 1,
|
|
223
|
+
retryAfterMs
|
|
224
|
+
});
|
|
225
|
+
} else if (res.status >= 400 && res.status < 500) {
|
|
226
|
+
if (!(opts.expected4xx ?? []).includes(res.status)) {
|
|
227
|
+
this.logError("store.request.client_error", { path, status: res.status, body: parsed });
|
|
228
|
+
}
|
|
229
|
+
throw new StoreApiError(
|
|
230
|
+
`request failed (HTTP ${res.status})`,
|
|
231
|
+
res.status,
|
|
232
|
+
path
|
|
233
|
+
);
|
|
234
|
+
} else {
|
|
235
|
+
lastErr = new StoreApiError(`upstream error (HTTP ${res.status})`, res.status, path);
|
|
236
|
+
this.logWarn("store.request.retryable", { path, status: res.status, attempt: attempt + 1 });
|
|
237
|
+
}
|
|
238
|
+
} catch (e) {
|
|
239
|
+
if (e instanceof RecoverableCartError) {
|
|
240
|
+
throw e;
|
|
241
|
+
}
|
|
242
|
+
if (e instanceof StoreApiError && e.status >= 400 && e.status < 500) {
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
lastErr = e;
|
|
246
|
+
this.logWarn("store.request.network_retry", { path, attempt: attempt + 1 });
|
|
247
|
+
}
|
|
248
|
+
if (attempt < this.retry.attempts - 1) {
|
|
249
|
+
await this.sleepImpl(retryAfterMs ?? this.backoffMs(attempt));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
this.logError("store.request.exhausted", { path, attempts: this.retry.attempts });
|
|
253
|
+
if (lastErr instanceof StoreApiError) {
|
|
254
|
+
throw lastErr;
|
|
255
|
+
}
|
|
256
|
+
throw new StoreApiError(
|
|
257
|
+
`request failed after ${this.retry.attempts} attempts: ${path}`,
|
|
258
|
+
0,
|
|
259
|
+
path
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
logWarn(event, ctx) {
|
|
263
|
+
this.logger.warn(event, redact(ctx));
|
|
264
|
+
}
|
|
265
|
+
logError(event, ctx) {
|
|
266
|
+
this.logger.error(event, redact(ctx));
|
|
267
|
+
}
|
|
268
|
+
// ----- regions (F-A2 / INV-11) ---------------------------------------------
|
|
269
|
+
async getRegions() {
|
|
270
|
+
const data = await this.request("GET", "/store/regions");
|
|
271
|
+
this.regionsCache = data.regions ?? [];
|
|
272
|
+
return this.regionsCache;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Resolve a region by country code, falling back to region #1.
|
|
276
|
+
* SEK is region #1 by configuration — NEVER a hardcoded literal.
|
|
277
|
+
*/
|
|
278
|
+
async resolveRegion(countryCode) {
|
|
279
|
+
const regions = this.regionsCache ?? await this.getRegions();
|
|
280
|
+
if (regions.length === 0) {
|
|
281
|
+
throw new Error("no regions configured \u2014 cannot resolve a region for pricing");
|
|
282
|
+
}
|
|
283
|
+
if (countryCode) {
|
|
284
|
+
const cc = countryCode.toLowerCase();
|
|
285
|
+
const match = regions.find(
|
|
286
|
+
(r) => (r.countries ?? []).some((c) => (c.iso_2 ?? "").toLowerCase() === cc)
|
|
287
|
+
);
|
|
288
|
+
if (match) {
|
|
289
|
+
return match;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return regions[0];
|
|
293
|
+
}
|
|
294
|
+
// ----- products (region resolved BEFORE pricing) ---------------------------
|
|
295
|
+
async listProducts(opts) {
|
|
296
|
+
const region = opts?.region ?? await this.resolveRegion(opts?.countryCode);
|
|
297
|
+
const data = await this.request("GET", "/store/products", {
|
|
298
|
+
query: { region_id: region.id, limit: 100 }
|
|
299
|
+
});
|
|
300
|
+
return data.products ?? [];
|
|
301
|
+
}
|
|
302
|
+
async getProduct(handle, opts) {
|
|
303
|
+
const region = opts?.region ?? await this.resolveRegion(opts?.countryCode);
|
|
304
|
+
const data = await this.request("GET", "/store/products", {
|
|
305
|
+
query: { handle, region_id: region.id, limit: 1 }
|
|
306
|
+
});
|
|
307
|
+
return (data.products ?? [])[0] ?? null;
|
|
308
|
+
}
|
|
309
|
+
// ----- cart (mutations carry Idempotency-Key) ------------------------------
|
|
310
|
+
async createCart(region) {
|
|
311
|
+
const data = await this.request("POST", "/store/carts", {
|
|
312
|
+
idempotent: true,
|
|
313
|
+
body: { region_id: region.id }
|
|
314
|
+
});
|
|
315
|
+
return data.cart;
|
|
316
|
+
}
|
|
317
|
+
async addLineItem(cartId, variantId, quantity) {
|
|
318
|
+
const data = await this.request(
|
|
319
|
+
"POST",
|
|
320
|
+
`/store/carts/${encodeURIComponent(cartId)}/line-items`,
|
|
321
|
+
{
|
|
322
|
+
idempotent: true,
|
|
323
|
+
cartRecovery: true,
|
|
324
|
+
body: { variant_id: variantId, quantity }
|
|
325
|
+
}
|
|
326
|
+
);
|
|
327
|
+
return data.cart;
|
|
328
|
+
}
|
|
329
|
+
async getCart(cartId) {
|
|
330
|
+
const data = await this.request(
|
|
331
|
+
"GET",
|
|
332
|
+
`/store/carts/${encodeURIComponent(cartId)}`,
|
|
333
|
+
{ cartRecovery: true }
|
|
334
|
+
);
|
|
335
|
+
return data.cart;
|
|
336
|
+
}
|
|
337
|
+
/** Remove a staged line item (bt unstage). Returns the updated cart (Medusa's `parent`). */
|
|
338
|
+
async removeLineItem(cartId, itemId) {
|
|
339
|
+
const data = await this.request(
|
|
340
|
+
"DELETE",
|
|
341
|
+
`/store/carts/${encodeURIComponent(cartId)}/line-items/${encodeURIComponent(itemId)}`,
|
|
342
|
+
{ idempotent: true, cartRecovery: true }
|
|
343
|
+
);
|
|
344
|
+
return data.parent ?? data.cart;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Ensure the cart has a shipping method before checkout (BUG 1 — the headless
|
|
348
|
+
* /store/headless/checkout route guards on it, and neither client ever set one).
|
|
349
|
+
*
|
|
350
|
+
* - Idempotent: if the cart already carries a shipping method, no-op (no POST).
|
|
351
|
+
* - Lists options via the STANDARD store route GET /store/shipping-options?cart_id=…
|
|
352
|
+
* (NOT a /headless route — standard routes are always available, no kill-switch).
|
|
353
|
+
* - Picks deterministically: exactly one → use it; several → the cheapest by amount.
|
|
354
|
+
* - The SE launch option is region-scoped (price_type:"flat"), so listing by cart_id
|
|
355
|
+
* returns it without a shipping address being set first (confirmed: seed-shipping.ts).
|
|
356
|
+
*
|
|
357
|
+
* TODO(04.5): buyer-choice / intl multiple shipping options — today we auto-pick the
|
|
358
|
+
* cheapest; an explicit buyer chooser belongs to the Phase-6 international pass.
|
|
359
|
+
*/
|
|
360
|
+
async ensureCartShippingMethod(cartId) {
|
|
361
|
+
const cart = await this.getCart(cartId);
|
|
362
|
+
if (cart.shipping_methods && cart.shipping_methods.length > 0) {
|
|
363
|
+
return cart;
|
|
364
|
+
}
|
|
365
|
+
const data = await this.request(
|
|
366
|
+
"GET",
|
|
367
|
+
"/store/shipping-options",
|
|
368
|
+
{ query: { cart_id: cartId } }
|
|
369
|
+
);
|
|
370
|
+
const options = data.shipping_options ?? [];
|
|
371
|
+
if (options.length === 0) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
"no shipping option is available for this cart \u2014 cannot attach a shipping method"
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const chosen = options.length === 1 ? options[0] : [...options].sort(
|
|
377
|
+
(a, b) => (a.amount ?? Number.POSITIVE_INFINITY) - (b.amount ?? Number.POSITIVE_INFINITY)
|
|
378
|
+
)[0];
|
|
379
|
+
const res = await this.request(
|
|
380
|
+
"POST",
|
|
381
|
+
`/store/carts/${encodeURIComponent(cartId)}/shipping-methods`,
|
|
382
|
+
{
|
|
383
|
+
idempotent: true,
|
|
384
|
+
cartRecovery: true,
|
|
385
|
+
body: { option_id: chosen.id }
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
return res.cart;
|
|
389
|
+
}
|
|
390
|
+
// ----- headless contract (04.5-01) -----------------------------------------
|
|
391
|
+
async mintCheckout(cartId, email, channel = "cli") {
|
|
392
|
+
return this.request("POST", "/store/headless/checkout", {
|
|
393
|
+
idempotent: true,
|
|
394
|
+
body: { cart_id: cartId, email, channel }
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async poll(sessionId) {
|
|
398
|
+
return this.request(
|
|
399
|
+
"GET",
|
|
400
|
+
`/store/headless/poll/${encodeURIComponent(sessionId)}`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
/** JWT-bound: returns the authenticated customer's own profile (G2). */
|
|
404
|
+
async whoami() {
|
|
405
|
+
return this.request("GET", "/store/headless/whoami", { expected4xx: [401, 403] });
|
|
406
|
+
}
|
|
407
|
+
// ----- magic-link auth (04.5-05) -------------------------------------------
|
|
408
|
+
// Passwordless login — magic-link is the only grant (Medusa has no OAuth pairing-grant, G1).
|
|
409
|
+
// request-link emails a single-use link; verify exchanges the link's token for a customer-scoped
|
|
410
|
+
// JWT. The JWT is stored in the OS keychain by the CLI — never here, never in plaintext config (G7/G8).
|
|
411
|
+
/** Start a magic-link login. Returns a UNIFORM ack regardless of whether the email exists (G12). */
|
|
412
|
+
async requestMagicLink(email) {
|
|
413
|
+
return this.request("POST", "/store/headless/auth/request-link", {
|
|
414
|
+
idempotent: true,
|
|
415
|
+
body: { email }
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/** Exchange a magic-link token for a customer-scoped JWT (the customer is materialized server-side). */
|
|
419
|
+
async verifyMagicLink(token) {
|
|
420
|
+
return this.request("POST", "/store/headless/auth/verify", {
|
|
421
|
+
idempotent: true,
|
|
422
|
+
body: { token }
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
async listDrops() {
|
|
426
|
+
const data = await this.request(
|
|
427
|
+
"GET",
|
|
428
|
+
"/store/headless/drops"
|
|
429
|
+
);
|
|
430
|
+
return data.drops ?? [];
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* File a developer bug report (d14 — `bt report` / MCP report_issue). Returns a correlation
|
|
434
|
+
* reference. Additive: no money/cart path. The backend redacts authoritatively; callers SHOULD
|
|
435
|
+
* pass an already-redacted context (the doctor snapshot). A mutation → carries an Idempotency-Key
|
|
436
|
+
* so a retry can't email the report twice.
|
|
437
|
+
*/
|
|
438
|
+
async report(message, context) {
|
|
439
|
+
return this.request("POST", "/store/headless/report", {
|
|
440
|
+
idempotent: true,
|
|
441
|
+
body: { message, ...context !== void 0 ? { context } : {} }
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The authenticated customer's order history (d13 — `bt log`). JWT-bound; the backend derives the
|
|
446
|
+
* customer from the bearer token and returns MINIMAL fields (status/timeline only — never
|
|
447
|
+
* email/address, F-H3). A guest (no/expired JWT) gets a 401 — the EXPECTED unauthenticated branch
|
|
448
|
+
* we turn into the honest empty history (`[]`), NOT an error to dump (BUG 3).
|
|
449
|
+
*/
|
|
450
|
+
async listOrders() {
|
|
451
|
+
try {
|
|
452
|
+
const data = await this.request("GET", "/store/headless/orders", {
|
|
453
|
+
expected4xx: [401, 403]
|
|
454
|
+
});
|
|
455
|
+
return data.orders ?? [];
|
|
456
|
+
} catch (e) {
|
|
457
|
+
if (e instanceof StoreApiError && (e.status === 401 || e.status === 403)) {
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
throw e;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Guest order lookup — requires order display id + EXACT email (F-H1).
|
|
465
|
+
* The backend returns 404 on a mismatch (no enumeration); the client surfaces null.
|
|
466
|
+
*/
|
|
467
|
+
async getOrder(displayId, email) {
|
|
468
|
+
try {
|
|
469
|
+
return await this.request("GET", "/store/orders", {
|
|
470
|
+
query: { display_id: String(displayId), email }
|
|
471
|
+
});
|
|
472
|
+
} catch (e) {
|
|
473
|
+
if (e instanceof StoreApiError && e.status === 404) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
throw e;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
var BT_DEFAULT_STORE_URL = "https://backend-production-511f5.up.railway.app";
|
|
481
|
+
var BT_DEFAULT_PUBLISHABLE_KEY = "pk_0d7d706860568a0414dff0436aadb9713a2609de3a1b153ef2a6f9694d23f907";
|
|
482
|
+
var BLOB_HOST_ALLOWLIST = [
|
|
483
|
+
"blob.vercel-storage.com"
|
|
484
|
+
// matches the exact host + *.blob.vercel-storage.com (dot-delimited subdomains only)
|
|
485
|
+
];
|
|
486
|
+
function isAllowedImageHost(url) {
|
|
487
|
+
let host;
|
|
488
|
+
try {
|
|
489
|
+
host = new URL(url).hostname.toLowerCase();
|
|
490
|
+
} catch {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
return BLOB_HOST_ALLOWLIST.some(
|
|
494
|
+
(allowed) => host === allowed || host.endsWith("." + allowed)
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
function variantColour(v) {
|
|
498
|
+
const opt = (v.options ?? []).find(
|
|
499
|
+
(o) => (o.option?.title ?? "").toLowerCase() === "colour" || (o.option?.title ?? "").toLowerCase() === "color"
|
|
500
|
+
);
|
|
501
|
+
if (opt && typeof opt.value === "string" && opt.value.trim()) {
|
|
502
|
+
return opt.value.trim().toLowerCase();
|
|
503
|
+
}
|
|
504
|
+
const title = (v.title ?? "").trim();
|
|
505
|
+
if (title.includes("/")) {
|
|
506
|
+
const parts = title.split("/").map((p) => p.trim());
|
|
507
|
+
const tail = parts[parts.length - 1];
|
|
508
|
+
if (tail) return tail.toLowerCase();
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
function productColours(p) {
|
|
513
|
+
const seen = [];
|
|
514
|
+
for (const v of p.variants ?? []) {
|
|
515
|
+
const c = variantColour(v);
|
|
516
|
+
if (c && !seen.includes(c)) {
|
|
517
|
+
seen.push(c);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return seen;
|
|
521
|
+
}
|
|
522
|
+
function sizesForColour(p, colour) {
|
|
523
|
+
const target = colour.trim().toLowerCase();
|
|
524
|
+
const sizes = [];
|
|
525
|
+
for (const v of p.variants ?? []) {
|
|
526
|
+
if (variantColour(v) !== target) continue;
|
|
527
|
+
const size = sizeOfVariant(v);
|
|
528
|
+
if (size && !sizes.includes(size)) {
|
|
529
|
+
sizes.push(size);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return sizes;
|
|
533
|
+
}
|
|
534
|
+
function sizeOfVariant(v) {
|
|
535
|
+
const opt = (v.options ?? []).find((o) => (o.option?.title ?? "").toLowerCase() === "size");
|
|
536
|
+
if (opt && typeof opt.value === "string" && opt.value.trim()) {
|
|
537
|
+
return opt.value.trim();
|
|
538
|
+
}
|
|
539
|
+
const title = (v.title ?? "").trim();
|
|
540
|
+
if (title.includes("/")) {
|
|
541
|
+
const head = title.split("/")[0]?.trim();
|
|
542
|
+
if (head) return head;
|
|
543
|
+
}
|
|
544
|
+
return title || null;
|
|
545
|
+
}
|
|
546
|
+
function findVariant(p, opts = {}) {
|
|
547
|
+
const variants = p.variants ?? [];
|
|
548
|
+
const size = opts.size?.trim().toLowerCase();
|
|
549
|
+
const colour = opts.colour?.trim().toLowerCase();
|
|
550
|
+
const sizeMatches = (v) => {
|
|
551
|
+
if (!size) return true;
|
|
552
|
+
return (sizeOfVariant(v) ?? "").toLowerCase() === size || (v.title ?? "").toLowerCase() === size;
|
|
553
|
+
};
|
|
554
|
+
const colourMatches = (v) => {
|
|
555
|
+
if (!colour) return true;
|
|
556
|
+
return variantColour(v) === colour;
|
|
557
|
+
};
|
|
558
|
+
if (size && colour) {
|
|
559
|
+
return variants.find((v) => sizeMatches(v) && colourMatches(v));
|
|
560
|
+
}
|
|
561
|
+
if (colour && !size) {
|
|
562
|
+
return variants.find((v) => colourMatches(v));
|
|
563
|
+
}
|
|
564
|
+
if (size && !colour) {
|
|
565
|
+
return variants.find((v) => sizeMatches(v)) ?? variants[0];
|
|
566
|
+
}
|
|
567
|
+
return variants[0];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/server.ts
|
|
571
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
572
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
573
|
+
var SERVER_INFO = {
|
|
574
|
+
name: "binary-and-thread",
|
|
575
|
+
version: "0.1.0"
|
|
576
|
+
};
|
|
577
|
+
function buildServer(tools) {
|
|
578
|
+
const server = new McpServer(SERVER_INFO);
|
|
579
|
+
const toolNames = [];
|
|
580
|
+
const register = server.registerTool.bind(server);
|
|
581
|
+
for (const t of tools) {
|
|
582
|
+
register(
|
|
583
|
+
t.name,
|
|
584
|
+
{ description: t.description, inputSchema: t.inputSchema },
|
|
585
|
+
// The SDK validates args against inputSchema before calling us. We hand the parsed args to
|
|
586
|
+
// the deterministic handler and return its content blocks (+ optional structuredContent, d9).
|
|
587
|
+
async (args) => {
|
|
588
|
+
const res = await t.handler(args ?? {});
|
|
589
|
+
return {
|
|
590
|
+
content: res.content,
|
|
591
|
+
...res.isError ? { isError: true } : {},
|
|
592
|
+
...res.structuredContent ? { structuredContent: res.structuredContent } : {}
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
);
|
|
596
|
+
toolNames.push(t.name);
|
|
597
|
+
}
|
|
598
|
+
return { server, toolNames };
|
|
599
|
+
}
|
|
600
|
+
async function startServer(tools) {
|
|
601
|
+
const { server } = buildServer(tools);
|
|
602
|
+
const transport = new StdioServerTransport();
|
|
603
|
+
await server.connect(transport);
|
|
604
|
+
return server;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/tools.ts
|
|
608
|
+
import { z } from "zod";
|
|
609
|
+
|
|
610
|
+
// src/sanitize.ts
|
|
611
|
+
var DEFAULT_MAX = 500;
|
|
612
|
+
var CONTROL_RE = /[\x00-\x1F\x7F-\x9F]/g;
|
|
613
|
+
function sanitize(s, max = DEFAULT_MAX) {
|
|
614
|
+
if (typeof s !== "string") {
|
|
615
|
+
return "";
|
|
616
|
+
}
|
|
617
|
+
const stripped = s.replace(CONTROL_RE, " ").replace(/\s+/g, " ").trim();
|
|
618
|
+
if (stripped.length <= max) {
|
|
619
|
+
return stripped;
|
|
620
|
+
}
|
|
621
|
+
return stripped.slice(0, max) + "\u2026";
|
|
622
|
+
}
|
|
623
|
+
function redactForOutput(obj) {
|
|
624
|
+
return redact(obj);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/images.ts
|
|
628
|
+
var IMAGE_BYTE_CAP = 2 * 1024 * 1024;
|
|
629
|
+
var IMAGE_FETCH_TIMEOUT_MS = 5e3;
|
|
630
|
+
function urlOnly(blobUrl) {
|
|
631
|
+
return [{ type: "text", text: blobUrl }];
|
|
632
|
+
}
|
|
633
|
+
function unavailable(blobUrl) {
|
|
634
|
+
return [{ type: "text", text: `(image unavailable) ${blobUrl}` }];
|
|
635
|
+
}
|
|
636
|
+
async function buildImageContent(blobUrl, opts = {}) {
|
|
637
|
+
if (typeof blobUrl !== "string" || blobUrl.trim().length === 0) {
|
|
638
|
+
return urlOnly(String(blobUrl ?? ""));
|
|
639
|
+
}
|
|
640
|
+
let scheme;
|
|
641
|
+
try {
|
|
642
|
+
scheme = new URL(blobUrl).protocol;
|
|
643
|
+
} catch {
|
|
644
|
+
return urlOnly(blobUrl);
|
|
645
|
+
}
|
|
646
|
+
if (scheme !== "https:") {
|
|
647
|
+
return urlOnly(blobUrl);
|
|
648
|
+
}
|
|
649
|
+
if (!isAllowedImageHost(blobUrl)) {
|
|
650
|
+
return urlOnly(blobUrl);
|
|
651
|
+
}
|
|
652
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
653
|
+
const byteCap = opts.byteCap ?? IMAGE_BYTE_CAP;
|
|
654
|
+
const timeoutMs = opts.timeoutMs ?? IMAGE_FETCH_TIMEOUT_MS;
|
|
655
|
+
const controller = new AbortController();
|
|
656
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
657
|
+
try {
|
|
658
|
+
const res = await fetchImpl(blobUrl, { signal: controller.signal });
|
|
659
|
+
if (!res.ok) {
|
|
660
|
+
return urlOnly(blobUrl);
|
|
661
|
+
}
|
|
662
|
+
const mimeType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
663
|
+
if (!mimeType.startsWith("image/")) {
|
|
664
|
+
return urlOnly(blobUrl);
|
|
665
|
+
}
|
|
666
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
667
|
+
if (buf.byteLength === 0 || buf.byteLength > byteCap) {
|
|
668
|
+
return urlOnly(blobUrl);
|
|
669
|
+
}
|
|
670
|
+
return [
|
|
671
|
+
{ type: "image", data: buf.toString("base64"), mimeType },
|
|
672
|
+
{ type: "text", text: blobUrl }
|
|
673
|
+
// the reliable floor (bug #31208)
|
|
674
|
+
];
|
|
675
|
+
} catch {
|
|
676
|
+
return unavailable(blobUrl);
|
|
677
|
+
} finally {
|
|
678
|
+
clearTimeout(timer);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/eggs.ts
|
|
683
|
+
var TEAPOT_TEXT = "418 i'm a teapot. we make clothing, not coffee. (RFC 2324)";
|
|
684
|
+
var FORTUNES = [
|
|
685
|
+
'// "there are only two hard things in computer science: cache invalidation and naming things." \u2014 phil karlton',
|
|
686
|
+
'// "premature optimization is the root of all evil." \u2014 donald knuth',
|
|
687
|
+
'// "weeks of programming can save you hours of planning."',
|
|
688
|
+
'// "make it work, make it right, make it fast." \u2014 kent beck',
|
|
689
|
+
'// "talk is cheap. show me the code." \u2014 linus torvalds',
|
|
690
|
+
`// "given enough eyeballs, all bugs are shallow." \u2014 linus's law`,
|
|
691
|
+
'// "always code as if the person maintaining it is a violent psychopath who knows where you live." \u2014 john woods',
|
|
692
|
+
'// "it works on my machine."'
|
|
693
|
+
];
|
|
694
|
+
function fortune(seed = 0) {
|
|
695
|
+
const n = Number.isFinite(seed) ? Math.abs(Math.floor(seed)) : 0;
|
|
696
|
+
return FORTUNES[n % FORTUNES.length];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// src/tools.ts
|
|
700
|
+
function errorText(s) {
|
|
701
|
+
return { content: [{ type: "text", text: s }], isError: true };
|
|
702
|
+
}
|
|
703
|
+
function result(textBody, structured) {
|
|
704
|
+
return {
|
|
705
|
+
content: [{ type: "text", text: textBody }],
|
|
706
|
+
structuredContent: redactForOutput(structured)
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function fmtPrice(amount, currency) {
|
|
710
|
+
if (amount == null) {
|
|
711
|
+
return "\u2014";
|
|
712
|
+
}
|
|
713
|
+
const cur = (currency ?? "").toUpperCase();
|
|
714
|
+
return cur ? `${amount} ${cur}` : `${amount}`;
|
|
715
|
+
}
|
|
716
|
+
function firstPrice(p) {
|
|
717
|
+
const v = (p.variants ?? [])[0];
|
|
718
|
+
return {
|
|
719
|
+
amount: v?.calculated_price?.calculated_amount ?? null,
|
|
720
|
+
currency: v?.calculated_price?.currency_code ?? null
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function cartSummary(cart, region) {
|
|
724
|
+
const lines = [];
|
|
725
|
+
lines.push(`cart ${cart.id}`);
|
|
726
|
+
const items = cart.items ?? [];
|
|
727
|
+
if (items.length === 0) {
|
|
728
|
+
lines.push(" (empty)");
|
|
729
|
+
} else {
|
|
730
|
+
for (const li of items) {
|
|
731
|
+
const title = sanitize(li.product_title ?? li.title ?? "item", 80);
|
|
732
|
+
lines.push(` ${li.quantity}x ${title} \u2014 ${fmtPrice(li.total, cart.currency_code ?? region?.currency_code)}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
lines.push(` total ${fmtPrice(cart.total, cart.currency_code ?? region?.currency_code)} incl. VAT`);
|
|
736
|
+
return lines.join("\n");
|
|
737
|
+
}
|
|
738
|
+
function makeTools(client, opts = {}) {
|
|
739
|
+
const regionCC = opts.region;
|
|
740
|
+
const list_products = {
|
|
741
|
+
name: "list_products",
|
|
742
|
+
description: "List the pieces in stock (text-only \u2014 no images, to keep the agent's context lean). Returns each product's handle, price incl. VAT, and stage. Use get_product for one item's image.",
|
|
743
|
+
inputSchema: { stage: z.string().optional().describe("filter by channel stage, e.g. 'main' or 'next'") },
|
|
744
|
+
handler: async (args) => {
|
|
745
|
+
const region = await client.resolveRegion(regionCC);
|
|
746
|
+
const products = await client.listProducts({ region });
|
|
747
|
+
const stage = typeof args.stage === "string" ? args.stage.toLowerCase() : void 0;
|
|
748
|
+
const rows = products.map((p) => ({ p, stage: String(p.metadata?.["channel_stage"] ?? "main") })).filter((r) => stage ? r.stage.toLowerCase() === stage : true);
|
|
749
|
+
if (rows.length === 0) {
|
|
750
|
+
return result("nothing in stock on this branch yet.", { products: [] });
|
|
751
|
+
}
|
|
752
|
+
const lines = rows.map(({ p, stage: stage2 }) => {
|
|
753
|
+
const { amount, currency } = firstPrice(p);
|
|
754
|
+
const handle = sanitize(p.handle ?? p.id, 60);
|
|
755
|
+
const title = sanitize(p.title ?? "", 80);
|
|
756
|
+
return `${handle} ${fmtPrice(amount, currency)} incl. VAT [${stage2}] ${title}`.trimEnd();
|
|
757
|
+
});
|
|
758
|
+
const productList = rows.map(({ p, stage: stage2 }) => {
|
|
759
|
+
const { amount, currency } = firstPrice(p);
|
|
760
|
+
return { handle: p.handle ?? p.id, title: p.title ?? "", price: amount, currency, stage: stage2 };
|
|
761
|
+
});
|
|
762
|
+
return result(lines.join("\n"), { products: productList });
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
const get_product = {
|
|
766
|
+
name: "get_product",
|
|
767
|
+
// M3 ★ — a hidden line for the schema-readers (devs read tool descriptions). Rewards the
|
|
768
|
+
// contributor instinct + hints at a real feature (the next branch / drops). Never alters behaviour.
|
|
769
|
+
description: "Get one piece's specs (sizes, price incl. VAT) plus its image (rendered + the Blob URL as a text floor). // you read the schema. that's the contributor instinct. ask me about the next branch.",
|
|
770
|
+
inputSchema: { handle: z.string().describe("the product handle/slug") },
|
|
771
|
+
handler: async (args) => {
|
|
772
|
+
const handle = String(args.handle ?? "");
|
|
773
|
+
const region = await client.resolveRegion(regionCC);
|
|
774
|
+
const product = await client.getProduct(handle, { region });
|
|
775
|
+
if (!product) {
|
|
776
|
+
return errorText(`no product with handle "${sanitize(handle, 60)}". try list_products.`);
|
|
777
|
+
}
|
|
778
|
+
const lines = [];
|
|
779
|
+
lines.push(sanitize(product.title ?? handle, 120));
|
|
780
|
+
if (product.description) {
|
|
781
|
+
lines.push(sanitize(product.description, 400));
|
|
782
|
+
}
|
|
783
|
+
const fabric = product.metadata?.["fabric"];
|
|
784
|
+
if (typeof fabric === "string") {
|
|
785
|
+
lines.push(`fabric: ${sanitize(fabric, 80)}`);
|
|
786
|
+
}
|
|
787
|
+
const colours = productColours(product);
|
|
788
|
+
if (colours.length > 0) {
|
|
789
|
+
lines.push(`available colours: ${colours.map((c) => sanitize(c, 20)).join(", ")}`);
|
|
790
|
+
for (const c of colours) {
|
|
791
|
+
const cs = sizesForColour(product, c).map((s) => sanitize(s, 20));
|
|
792
|
+
lines.push(` ${sanitize(c, 20)}: ${cs.join(" ")}`);
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
const sizes = (product.variants ?? []).map((v) => sanitize(v.title ?? "?", 20));
|
|
796
|
+
lines.push(`sizes: ${sizes.join(" ")}`);
|
|
797
|
+
}
|
|
798
|
+
const v0 = (product.variants ?? [])[0];
|
|
799
|
+
if (v0?.calculated_price) {
|
|
800
|
+
lines.push(`price: ${fmtPrice(v0.calculated_price.calculated_amount, v0.calculated_price.currency_code)} incl. VAT`);
|
|
801
|
+
}
|
|
802
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
803
|
+
if (product.thumbnail) {
|
|
804
|
+
const img = await buildImageContent(product.thumbnail);
|
|
805
|
+
content.push(...img);
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
content,
|
|
809
|
+
structuredContent: redactForOutput({
|
|
810
|
+
handle: product.handle ?? handle,
|
|
811
|
+
title: product.title ?? "",
|
|
812
|
+
price: v0?.calculated_price?.calculated_amount ?? null,
|
|
813
|
+
currency: v0?.calculated_price?.currency_code ?? null,
|
|
814
|
+
colours,
|
|
815
|
+
image_url: product.thumbnail ?? null
|
|
816
|
+
})
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
const add_to_cart = {
|
|
821
|
+
name: "add_to_cart",
|
|
822
|
+
description: "Add a piece to a cart. Pass cart_id to reuse an existing cart (the durable handle), or omit it to start a fresh one. Returns the cart_id and the server-side cart summary (price incl. VAT).",
|
|
823
|
+
inputSchema: {
|
|
824
|
+
handle: z.string().optional().describe("product handle (resolves a variant by size + colour)"),
|
|
825
|
+
variant_id: z.string().optional().describe("a specific variant id (overrides handle/size/colour)"),
|
|
826
|
+
size: z.string().optional().describe("size label, e.g. M / L / XL"),
|
|
827
|
+
colour: z.string().optional().describe("piece colour, e.g. white / black (default: the product's first colour)"),
|
|
828
|
+
qty: z.number().int().positive().max(20).optional().describe("quantity (default 1)"),
|
|
829
|
+
cart_id: z.string().optional().describe("reuse this cart; omit to create a new one")
|
|
830
|
+
},
|
|
831
|
+
handler: async (args) => {
|
|
832
|
+
const region = await client.resolveRegion(regionCC);
|
|
833
|
+
const qty = typeof args.qty === "number" ? args.qty : 1;
|
|
834
|
+
let variantId = typeof args.variant_id === "string" ? args.variant_id : void 0;
|
|
835
|
+
if (!variantId) {
|
|
836
|
+
const handle = typeof args.handle === "string" ? args.handle : "";
|
|
837
|
+
if (!handle) {
|
|
838
|
+
return errorText("add_to_cart needs a variant_id or a handle (+ optional size/colour).");
|
|
839
|
+
}
|
|
840
|
+
const product = await client.getProduct(handle, { region });
|
|
841
|
+
if (!product) {
|
|
842
|
+
return errorText(`no product with handle "${sanitize(handle, 60)}".`);
|
|
843
|
+
}
|
|
844
|
+
const size = typeof args.size === "string" ? args.size : void 0;
|
|
845
|
+
const colours = productColours(product);
|
|
846
|
+
const colour = typeof args.colour === "string" ? args.colour : colours.length > 0 ? colours[0] : void 0;
|
|
847
|
+
const variant = findVariant(product, { size, colour });
|
|
848
|
+
if (!variant) {
|
|
849
|
+
const have = colours.length ? ` available colours: ${colours.map((c) => sanitize(c, 20)).join(", ")}.` : "";
|
|
850
|
+
return errorText(
|
|
851
|
+
`no variant for "${sanitize(handle, 60)}"${size ? ` size ${sanitize(size, 20)}` : ""}${colour ? ` (${sanitize(colour, 20)})` : ""}.${have}`
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
variantId = variant.id;
|
|
855
|
+
}
|
|
856
|
+
const reuse = typeof args.cart_id === "string" ? args.cart_id : void 0;
|
|
857
|
+
const addTo = async (cartId) => client.addLineItem(cartId, variantId, qty);
|
|
858
|
+
let cart;
|
|
859
|
+
try {
|
|
860
|
+
const targetId = reuse ?? (await client.createCart(region)).id;
|
|
861
|
+
cart = await addTo(targetId);
|
|
862
|
+
} catch (e) {
|
|
863
|
+
if (e instanceof RecoverableCartError && reuse) {
|
|
864
|
+
const fresh = await client.createCart(region);
|
|
865
|
+
cart = await addTo(fresh.id);
|
|
866
|
+
} else {
|
|
867
|
+
throw e;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const itemCount = (cart.items ?? []).reduce((n, li) => n + (li.quantity ?? 1), 0);
|
|
871
|
+
return result(
|
|
872
|
+
`added.
|
|
873
|
+
cart_id: ${cart.id} (reuse this handle on the next add / checkout)
|
|
874
|
+
${cartSummary(cart, region)}`,
|
|
875
|
+
{
|
|
876
|
+
cart_id: cart.id,
|
|
877
|
+
total: cart.total ?? null,
|
|
878
|
+
currency: cart.currency_code ?? region.currency_code ?? null,
|
|
879
|
+
item_count: itemCount
|
|
880
|
+
}
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
const get_cart = {
|
|
885
|
+
name: "get_cart",
|
|
886
|
+
description: "Show a cart's current contents and total (price incl. VAT), straight from the server.",
|
|
887
|
+
inputSchema: { cart_id: z.string().describe("the cart handle from add_to_cart") },
|
|
888
|
+
handler: async (args) => {
|
|
889
|
+
const cartId = String(args.cart_id ?? "");
|
|
890
|
+
const region = await client.resolveRegion(regionCC);
|
|
891
|
+
try {
|
|
892
|
+
const cart = await client.getCart(cartId);
|
|
893
|
+
const itemCount = (cart.items ?? []).reduce((n, li) => n + (li.quantity ?? 1), 0);
|
|
894
|
+
return result(cartSummary(cart, region), {
|
|
895
|
+
cart_id: cart.id,
|
|
896
|
+
total: cart.total ?? null,
|
|
897
|
+
currency: cart.currency_code ?? region.currency_code ?? null,
|
|
898
|
+
item_count: itemCount
|
|
899
|
+
});
|
|
900
|
+
} catch (e) {
|
|
901
|
+
if (e instanceof RecoverableCartError) {
|
|
902
|
+
return errorText("that cart is no longer valid (expired or already committed). add an item to start a fresh one.");
|
|
903
|
+
}
|
|
904
|
+
throw e;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
const checkout = {
|
|
909
|
+
name: "checkout",
|
|
910
|
+
description: "Mint a checkout hand-off for a cart. Returns a checkout_url + a session_id INSTANTLY \u2014 it does NOT poll and does NOT complete the purchase. The human must open the link in their own browser to enter their payment + ship-to details on the storefront (those details never enter this chat). After they pay, call check_payment with the session_id to see the status.",
|
|
911
|
+
inputSchema: {
|
|
912
|
+
cart_id: z.string().describe("the cart handle to check out"),
|
|
913
|
+
email: z.string().optional().describe("the buyer's email for the receipt (optional)")
|
|
914
|
+
},
|
|
915
|
+
handler: async (args) => {
|
|
916
|
+
const cartId = String(args.cart_id ?? "");
|
|
917
|
+
const email = typeof args.email === "string" ? args.email : void 0;
|
|
918
|
+
try {
|
|
919
|
+
await client.ensureCartShippingMethod(cartId);
|
|
920
|
+
} catch (e) {
|
|
921
|
+
if (e instanceof RecoverableCartError) {
|
|
922
|
+
return errorText("that cart is no longer valid (expired or already committed). add an item to start a fresh one.");
|
|
923
|
+
}
|
|
924
|
+
return errorText("couldn't attach a shipping method to this cart \u2014 checkout isn't available for it right now.");
|
|
925
|
+
}
|
|
926
|
+
const { checkout_url, session_id } = await client.mintCheckout(cartId, email, "mcp");
|
|
927
|
+
const body = [
|
|
928
|
+
"checkout ready. open this link in your browser to pay:",
|
|
929
|
+
` ${checkout_url}`,
|
|
930
|
+
"",
|
|
931
|
+
"payment details are entered on the storefront (Stripe) \u2014 they never enter this chat, and",
|
|
932
|
+
"I can't complete this for you. once you've paid, call check_payment with:",
|
|
933
|
+
` session_id: ${session_id}`,
|
|
934
|
+
"",
|
|
935
|
+
// brand-delight #9 — the deadpan flourish. The boundary stays honest (the line above), then
|
|
936
|
+
// one dry true line: an agent can do everything up to the card. The card is the human's.
|
|
937
|
+
"I staged it, priced it, and walked you to the door. the card swipe is yours \u2014 by design."
|
|
938
|
+
].join("\n");
|
|
939
|
+
return result(redactForOutput(body), { checkout_url, session_id });
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
const check_payment = {
|
|
943
|
+
name: "check_payment",
|
|
944
|
+
description: "Check a checkout's payment status ONCE (call again later to re-check \u2014 this never blocks/loops). Returns the status and, once placed, the order_id.",
|
|
945
|
+
inputSchema: { session_id: z.string().describe("the session_id from checkout") },
|
|
946
|
+
handler: async (args) => {
|
|
947
|
+
const sessionId = String(args.session_id ?? "");
|
|
948
|
+
const res = await client.poll(sessionId);
|
|
949
|
+
const out = redactForOutput({ status: res.status, order_id: res.order_id });
|
|
950
|
+
const human = res.status === "placed" ? `paid \u2014 order placed. order_id: ${out.order_id ?? "\u2014"}` : `status: ${out.status} (call check_payment again in a moment if still pending)`;
|
|
951
|
+
return { content: [{ type: "text", text: human }], structuredContent: out };
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
const get_order_status = {
|
|
955
|
+
name: "get_order_status",
|
|
956
|
+
description: "Look up an order's status by its display id + the EXACT email on the order. Returns status only (no shipping address). A wrong id/email returns not-found (no enumeration).",
|
|
957
|
+
inputSchema: {
|
|
958
|
+
display_id: z.string().describe("the order's display id (e.g. 1042)"),
|
|
959
|
+
email: z.string().describe("the exact email on the order")
|
|
960
|
+
},
|
|
961
|
+
handler: async (args) => {
|
|
962
|
+
const displayId = String(args.display_id ?? "");
|
|
963
|
+
const email = String(args.email ?? "");
|
|
964
|
+
let looked;
|
|
965
|
+
try {
|
|
966
|
+
looked = await client.getOrder(displayId, email);
|
|
967
|
+
} catch (e) {
|
|
968
|
+
if (e instanceof StoreApiError && e.status === 404) {
|
|
969
|
+
looked = null;
|
|
970
|
+
} else {
|
|
971
|
+
throw e;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (!looked) {
|
|
975
|
+
return errorText(`no order matches that id + email.`);
|
|
976
|
+
}
|
|
977
|
+
const order = looked.order ?? looked;
|
|
978
|
+
const status = sanitize(String(order["status"] ?? order["fulfillment_status"] ?? "unknown"), 60);
|
|
979
|
+
const display = sanitize(String(order["display_id"] ?? displayId), 30);
|
|
980
|
+
const lines = [`order #${display}`, `status: ${status}`];
|
|
981
|
+
const fulfillment = order["fulfillment_status"];
|
|
982
|
+
if (typeof fulfillment === "string" && fulfillment) {
|
|
983
|
+
lines.push(`fulfillment: ${sanitize(fulfillment, 60)}`);
|
|
984
|
+
}
|
|
985
|
+
return result(redactForOutput(lines.join("\n")), {
|
|
986
|
+
display_id: order["display_id"] ?? display,
|
|
987
|
+
status,
|
|
988
|
+
fulfillment_status: typeof fulfillment === "string" ? fulfillment : null
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
const report_issue = {
|
|
993
|
+
name: "report_issue",
|
|
994
|
+
description: "File a bug report or feedback to the maintainers. Pass a short message (and optional structured context like versions/ids). Returns a reference id. It sends no payment details and cannot change any order \u2014 it just files the report. Tokens/emails are redacted server-side.",
|
|
995
|
+
inputSchema: {
|
|
996
|
+
message: z.string().describe("what went wrong / the feedback (one or two sentences)"),
|
|
997
|
+
context: z.record(z.string(), z.unknown()).optional().describe("optional structured context (versions, ids) \u2014 tokens/emails are redacted server-side")
|
|
998
|
+
},
|
|
999
|
+
handler: async (args) => {
|
|
1000
|
+
const message = String(args.message ?? "").trim();
|
|
1001
|
+
if (!message) {
|
|
1002
|
+
return errorText("report_issue needs a non-empty message describing the problem.");
|
|
1003
|
+
}
|
|
1004
|
+
const context = args.context && typeof args.context === "object" && !Array.isArray(args.context) ? args.context : void 0;
|
|
1005
|
+
const { reference } = await client.report(message, context);
|
|
1006
|
+
return result(`reported. thank you \u2014 reference: ${reference}`, { reference });
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
const teapot = {
|
|
1010
|
+
name: "teapot",
|
|
1011
|
+
description: "Returns 418. Always. It's a teapot. (RFC 2324)",
|
|
1012
|
+
inputSchema: {},
|
|
1013
|
+
handler: async () => {
|
|
1014
|
+
return result(TEAPOT_TEXT, { status: 418, rfc: "https://www.rfc-editor.org/rfc/rfc2324" });
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
const fortune_tool = {
|
|
1018
|
+
name: "fortune",
|
|
1019
|
+
description: "A coding fortune \u2014 a real dev aphorism. Optional seed for a specific one.",
|
|
1020
|
+
inputSchema: { seed: z.number().int().optional().describe("pick a specific fortune (0..N); omit for the first") },
|
|
1021
|
+
handler: async (args) => {
|
|
1022
|
+
const seed = typeof args.seed === "number" ? args.seed : 0;
|
|
1023
|
+
const f = fortune(seed);
|
|
1024
|
+
return result(f, { fortune: f, pool_size: FORTUNES.length });
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
return [
|
|
1028
|
+
list_products,
|
|
1029
|
+
get_product,
|
|
1030
|
+
add_to_cart,
|
|
1031
|
+
get_cart,
|
|
1032
|
+
checkout,
|
|
1033
|
+
check_payment,
|
|
1034
|
+
get_order_status,
|
|
1035
|
+
report_issue,
|
|
1036
|
+
teapot,
|
|
1037
|
+
fortune_tool
|
|
1038
|
+
];
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// src/config.ts
|
|
1042
|
+
function resolveServerConfig(env = process.env) {
|
|
1043
|
+
return {
|
|
1044
|
+
baseUrl: env.BT_STORE_URL || BT_DEFAULT_STORE_URL,
|
|
1045
|
+
publishableKey: env.BT_PUBLISHABLE_KEY || BT_DEFAULT_PUBLISHABLE_KEY,
|
|
1046
|
+
jwt: env.BT_TOKEN || void 0,
|
|
1047
|
+
region: env.BT_REGION || void 0
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// src/bin.ts
|
|
1052
|
+
async function main() {
|
|
1053
|
+
const { baseUrl, publishableKey, jwt, region } = resolveServerConfig();
|
|
1054
|
+
const client = new StoreClient({
|
|
1055
|
+
baseUrl,
|
|
1056
|
+
publishableKey,
|
|
1057
|
+
jwt,
|
|
1058
|
+
// All client logs route to stderr through redact() — stdout is reserved for the transport,
|
|
1059
|
+
// and no token/PII ever reaches a log line (G10).
|
|
1060
|
+
logger: {
|
|
1061
|
+
info: (...a) => process.stderr.write(JSON.stringify(redact(a)) + "\n"),
|
|
1062
|
+
warn: (...a) => process.stderr.write(JSON.stringify(redact(a)) + "\n"),
|
|
1063
|
+
error: (...a) => process.stderr.write(JSON.stringify(redact(a)) + "\n")
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
const tools = makeTools(client, { region });
|
|
1067
|
+
await startServer(tools);
|
|
1068
|
+
process.stderr.write(`bt-mcp: ready on stdio \u2014 ${tools.length} tools registered.
|
|
1069
|
+
`);
|
|
1070
|
+
}
|
|
1071
|
+
main().catch((e) => {
|
|
1072
|
+
process.stderr.write(`bt-mcp: fatal: ${JSON.stringify(redact(String(e?.message ?? e)))}
|
|
1073
|
+
`);
|
|
1074
|
+
process.exit(1);
|
|
1075
|
+
});
|
|
1076
|
+
//# sourceMappingURL=bin.js.map
|