ifandco-mcp 1.0.1 → 1.0.2
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 +22 -0
- package/dist/api.d.ts +13 -0
- package/dist/api.js +199 -7
- package/dist/license.d.ts +8 -0
- package/dist/license.js +79 -0
- package/dist/server.js +86 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,7 +10,29 @@ Query the IF & Co. catalog (ifandco.com, NYC): search custom pendants, chains, r
|
|
|
10
10
|
- `price_overview` — Ask-price distribution (min/p25/median/p75/max).
|
|
11
11
|
- `find_deals` — Available products priced below the median.
|
|
12
12
|
- `list_categories` — Product-type counts in the catalog sample.
|
|
13
|
+
- `list_collections` — Collections with handle, product count and URL, longest first.
|
|
14
|
+
- `collection_products` — Every product in one collection with live price and stock, sortable.
|
|
15
|
+
- `restock_check` — Stock and price for specific product handles: what is buyable right now and which options are left.
|
|
13
16
|
|
|
14
17
|
## Source
|
|
15
18
|
|
|
16
19
|
[ifandco-mcp](https://github.com/mrfentmen/premium-mcps/tree/main/servers/ifandco-mcp)
|
|
20
|
+
|
|
21
|
+
<!-- paywall -->
|
|
22
|
+
## Premium tools
|
|
23
|
+
|
|
24
|
+
These tools need a license key:
|
|
25
|
+
|
|
26
|
+
* `material_census`
|
|
27
|
+
* `personalisation_census`
|
|
28
|
+
|
|
29
|
+
Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-ifandco-mcp and set it in your MCP client config:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
"env": { "MCP_LICENSE_KEY": "mcp_live_..." }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The key is checked against MCP Marketplace, cached for 24 hours, and keeps
|
|
36
|
+
working offline once one check has succeeded. Every other tool on this server
|
|
37
|
+
stays free.
|
|
38
|
+
<!-- /paywall -->
|
package/dist/api.d.ts
CHANGED
|
@@ -8,3 +8,16 @@ export declare function getProduct(handle: string): Promise<string>;
|
|
|
8
8
|
export declare function priceOverview(limit?: string): Promise<string>;
|
|
9
9
|
export declare function findDeals(limit?: string, sample?: string): Promise<string>;
|
|
10
10
|
export declare function listCategories(): Promise<string>;
|
|
11
|
+
export declare function listCollections(limit?: string): Promise<string>;
|
|
12
|
+
export declare function collectionProducts(handle: string, limit?: string, sort?: string): Promise<string>;
|
|
13
|
+
export declare function restockCheck(handles: string): Promise<string>;
|
|
14
|
+
/** What the catalogue is actually made of, counted from the titles, tags and variant names. */
|
|
15
|
+
export declare function materialCensus(args: {
|
|
16
|
+
sample?: string;
|
|
17
|
+
limit?: string;
|
|
18
|
+
}): Promise<string>;
|
|
19
|
+
/** The personalisation the house offers, which the product tools only reveal one listing at a time. */
|
|
20
|
+
export declare function personalisationCensus(args: {
|
|
21
|
+
sample?: string;
|
|
22
|
+
limit?: string;
|
|
23
|
+
}): Promise<string>;
|
package/dist/api.js
CHANGED
|
@@ -18,7 +18,7 @@ function pretty(data) {
|
|
|
18
18
|
async function getJson(url) {
|
|
19
19
|
let res;
|
|
20
20
|
try {
|
|
21
|
-
res = await fetch(url, { headers: { ...UA } });
|
|
21
|
+
res = await fetch(url, { signal: AbortSignal.timeout(30000), headers: { ...UA } });
|
|
22
22
|
}
|
|
23
23
|
catch (e) {
|
|
24
24
|
throw new IfAndCoError(`Network error: ${e.message}`);
|
|
@@ -88,7 +88,7 @@ export async function searchProducts(query, limit, maxPrice, productType) {
|
|
|
88
88
|
if (out.length >= n)
|
|
89
89
|
break;
|
|
90
90
|
}
|
|
91
|
-
return pretty({ store: "IF & Co.", query,
|
|
91
|
+
return pretty({ store: "IF & Co.", query, returned: out.length, truncated: out.length >= n, listings: out });
|
|
92
92
|
}
|
|
93
93
|
export async function listNewest(limit, page, productType) {
|
|
94
94
|
const n = Math.min(Number(limit ?? 20) || 20, 50);
|
|
@@ -100,7 +100,7 @@ export async function listNewest(limit, page, productType) {
|
|
|
100
100
|
out.push(sl);
|
|
101
101
|
}
|
|
102
102
|
out.sort((a, b) => String(b.published_at ?? "").localeCompare(String(a.published_at ?? "")));
|
|
103
|
-
return pretty({ store: "IF & Co.", page: pg,
|
|
103
|
+
return pretty({ store: "IF & Co.", page: pg, matched_on_page: out.length, returned: Math.min(out.length, n), listings: out.slice(0, n) });
|
|
104
104
|
}
|
|
105
105
|
function parseParts(title) {
|
|
106
106
|
return String(title ?? "").split("/").map((s) => s.trim()).filter(Boolean);
|
|
@@ -126,9 +126,12 @@ export async function getProduct(handle) {
|
|
|
126
126
|
export async function priceOverview(limit) {
|
|
127
127
|
const n = Math.min(Number(limit ?? 200) || 200, 500);
|
|
128
128
|
const prices = [];
|
|
129
|
+
let unpriced = 0;
|
|
129
130
|
for (const p of await sampleCatalog(n, [1, 2, 3])) {
|
|
130
131
|
const sl = slim(p);
|
|
131
|
-
if (sl.price_usd
|
|
132
|
+
if (sl.price_usd === null || sl.price_usd <= 0)
|
|
133
|
+
unpriced += 1;
|
|
134
|
+
else
|
|
132
135
|
prices.push(sl.price_usd);
|
|
133
136
|
}
|
|
134
137
|
if (!prices.length)
|
|
@@ -136,7 +139,7 @@ export async function priceOverview(limit) {
|
|
|
136
139
|
const a = [...prices].sort((x, y) => x - y);
|
|
137
140
|
const mid = a.length % 2 ? a[(a.length - 1) / 2] : (a[a.length / 2 - 1] + a[a.length / 2]) / 2;
|
|
138
141
|
return pretty({
|
|
139
|
-
store: "IF & Co.", currency: "USD", sample: a.length,
|
|
142
|
+
store: "IF & Co.", currency: "USD", unpriced, sample: a.length,
|
|
140
143
|
min: a[0], p25: a[Math.floor(a.length * 0.25)], median: mid,
|
|
141
144
|
p75: a[Math.floor(a.length * 0.75)], max: a[a.length - 1],
|
|
142
145
|
note: "Storefront asking prices. Not affiliated with IF & Co..",
|
|
@@ -148,7 +151,7 @@ export async function findDeals(limit, sample) {
|
|
|
148
151
|
const all = [];
|
|
149
152
|
for (const p of await sampleCatalog(per, [1, 2, 3])) {
|
|
150
153
|
const sl = slim(p);
|
|
151
|
-
if (sl.price_usd !== null && sl.available)
|
|
154
|
+
if (sl.price_usd !== null && sl.price_usd > 0 && sl.available)
|
|
152
155
|
all.push(sl);
|
|
153
156
|
}
|
|
154
157
|
if (!all.length)
|
|
@@ -161,7 +164,7 @@ export async function findDeals(limit, sample) {
|
|
|
161
164
|
.map((s) => ({ ...s, vs_median_usd: Math.round((s.price_usd - median) * 100) / 100, vs_median_pct: Math.round(((s.price_usd - median) / median) * 1000) / 10 }))
|
|
162
165
|
.sort((a, b) => a.vs_median_pct - b.vs_median_pct);
|
|
163
166
|
return pretty({
|
|
164
|
-
store: "IF & Co.", sample: all.length, median_usd: median,
|
|
167
|
+
store: "IF & Co.", sample: all.length, median_usd: median, matched: deals.length, returned: Math.min(deals.length, n),
|
|
165
168
|
deals: deals.slice(0, n),
|
|
166
169
|
note: "Below-median asks, not appraisals. Not affiliated with IF & Co..",
|
|
167
170
|
});
|
|
@@ -174,3 +177,192 @@ export async function listCategories() {
|
|
|
174
177
|
}
|
|
175
178
|
return pretty({ store: "IF & Co.", sample: Object.values(counts).reduce((a, b) => a + b, 0), categories: counts });
|
|
176
179
|
}
|
|
180
|
+
// ---- batch-4 tools: collections and stock (2026-09-27) ----
|
|
181
|
+
const COLLECTIONS = "https://ifandco.com/collections";
|
|
182
|
+
export async function listCollections(limit) {
|
|
183
|
+
const n = Math.min(Number(limit ?? 40) || 40, 250);
|
|
184
|
+
const data = await getJson(`${COLLECTIONS}.json`);
|
|
185
|
+
const rows = (Array.isArray(data.collections) ? data.collections : [])
|
|
186
|
+
.filter((c) => typeof c?.handle === "string" && c.handle)
|
|
187
|
+
.map((c) => ({
|
|
188
|
+
title: String(c.title ?? c.handle),
|
|
189
|
+
handle: c.handle,
|
|
190
|
+
products_count: typeof c.products_count === "number" ? c.products_count : null,
|
|
191
|
+
url: `${COLLECTIONS}/${c.handle}`,
|
|
192
|
+
}))
|
|
193
|
+
.sort((a, b) => (b.products_count ?? 0) - (a.products_count ?? 0));
|
|
194
|
+
if (!rows.length)
|
|
195
|
+
throw new IfAndCoError("The store returned no collections.");
|
|
196
|
+
return pretty({
|
|
197
|
+
store: "IF & Co.", collections: rows.length, returned: Math.min(rows.length, n),
|
|
198
|
+
listings: rows.slice(0, n),
|
|
199
|
+
note: "Longest collections first; call collection_products with a handle.",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
export async function collectionProducts(handle, limit, sort) {
|
|
203
|
+
const clean = String(handle ?? "").trim();
|
|
204
|
+
if (!clean)
|
|
205
|
+
throw new IfAndCoError("Provide a collection handle - call list_collections first.");
|
|
206
|
+
const n = Math.min(Number(limit ?? 20) || 20, 50);
|
|
207
|
+
const data = await getJson(`${COLLECTIONS}/${encodeURIComponent(clean)}/products.json?limit=250`);
|
|
208
|
+
const items = Array.isArray(data.products) ? data.products : [];
|
|
209
|
+
if (!items.length)
|
|
210
|
+
throw new IfAndCoError(`Collection "${clean}" is empty or unknown - call list_collections.`);
|
|
211
|
+
const rows = items.map((p) => slim(p));
|
|
212
|
+
const order = String(sort ?? "featured").toLowerCase();
|
|
213
|
+
if (order === "price_low")
|
|
214
|
+
rows.sort((a, b) => (a.price_usd ?? Infinity) - (b.price_usd ?? Infinity));
|
|
215
|
+
else if (order === "price_high")
|
|
216
|
+
rows.sort((a, b) => (b.price_usd ?? -Infinity) - (a.price_usd ?? -Infinity));
|
|
217
|
+
else if (order === "newest")
|
|
218
|
+
rows.sort((a, b) => String(b.published_at ?? "").localeCompare(String(a.published_at ?? "")));
|
|
219
|
+
const inStock = rows.filter((r) => r.available).length;
|
|
220
|
+
const priced = rows.map((r) => r.price_usd).filter((p) => p !== null);
|
|
221
|
+
return pretty({
|
|
222
|
+
store: "IF & Co.", collection: clean, sort: order, products: rows.length, in_stock: inStock,
|
|
223
|
+
price_usd: priced.length ? { min: Math.min(...priced), max: Math.max(...priced) } : null,
|
|
224
|
+
returned: Math.min(rows.length, n), listings: rows.slice(0, n),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
export async function restockCheck(handles) {
|
|
228
|
+
const list = String(handles ?? "")
|
|
229
|
+
.split(/[,\s]+/)
|
|
230
|
+
.map((s) => s.trim())
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
.slice(0, 10);
|
|
233
|
+
if (!list.length)
|
|
234
|
+
throw new IfAndCoError("Provide up to 10 product handles, comma separated.");
|
|
235
|
+
const rows = [];
|
|
236
|
+
const problems = [];
|
|
237
|
+
// One handle at a time: the storefront rejects bursts of .js requests.
|
|
238
|
+
for (const h of list) {
|
|
239
|
+
try {
|
|
240
|
+
const p = await getJson(`${PRODUCT_BASE}/${encodeURIComponent(h)}.js`);
|
|
241
|
+
const variants = Array.isArray(p.variants) ? p.variants : [];
|
|
242
|
+
const live = variants.filter((v) => v.available === true);
|
|
243
|
+
const prices = variants.map((v) => Math.round(Number(v.price)) / 100).filter((n) => Number.isFinite(n) && n > 0);
|
|
244
|
+
rows.push({
|
|
245
|
+
handle: h,
|
|
246
|
+
title: p.title ?? h,
|
|
247
|
+
in_stock: live.length > 0,
|
|
248
|
+
variants: variants.length,
|
|
249
|
+
variants_in_stock: live.length,
|
|
250
|
+
min_price_usd: prices.length ? Math.min(...prices) : null,
|
|
251
|
+
options_in_stock: live.slice(0, 6).map((v) => String(v.title ?? "").trim()).filter(Boolean),
|
|
252
|
+
url: `${PRODUCT_BASE}/${h}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
problems.push(`${h}: ${errorMessage(e)}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (!rows.length)
|
|
260
|
+
throw new IfAndCoError(`No handle could be read. ${problems.join("; ")}`);
|
|
261
|
+
return pretty({
|
|
262
|
+
store: "IF & Co.", checked: rows.length, in_stock: rows.filter((r) => r.in_stock).length,
|
|
263
|
+
listings: rows, problems,
|
|
264
|
+
note: "Availability is the storefront's own flag, read at request time.",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// ---- muxG tools:
|
|
268
|
+
const IFCO_PAGES = [1, 2, 3, 4];
|
|
269
|
+
// The catalogue states its material in the title and in the variant titles,
|
|
270
|
+
// so a census over that text is a census over what the house actually sells.
|
|
271
|
+
const MATERIALS = [
|
|
272
|
+
{ key: "14k-gold", label: "14k gold", pattern: /\b14k\b|\b14 carat\b/i },
|
|
273
|
+
{ key: "18k-gold", label: "18k gold", pattern: /\b18k\b|\b18 carat\b/i },
|
|
274
|
+
{ key: "solid-gold", label: "solid gold (unqualified karat)", pattern: /\bsolid gold\b/i },
|
|
275
|
+
{ key: "sterling", label: "sterling silver / 925", pattern: /\b925\b|\bsterling\b/i },
|
|
276
|
+
{ key: "platinum", label: "platinum", pattern: /\bplatinum\b|\bplat\b/i },
|
|
277
|
+
{ key: "vermeil", label: "vermeil", pattern: /\bvermeil\b/i },
|
|
278
|
+
{ key: "gold-filled", label: "gold filled", pattern: /\bgold[- ]filled\b/i },
|
|
279
|
+
{ key: "diamond", label: "diamond", pattern: /\bdiamond(s)?\b/i },
|
|
280
|
+
{ key: "lab-diamond", label: "lab grown or lab created diamond", pattern: /\blab[- ](grown|created)\b/i },
|
|
281
|
+
{ key: "sapphire", label: "sapphire", pattern: /\bsapphire(s)?\b/i },
|
|
282
|
+
{ key: "emerald", label: "emerald", pattern: /\bemerald(s)?\b/i },
|
|
283
|
+
{ key: "ruby", label: "ruby", pattern: /\bruby\b|\brubies\b/i },
|
|
284
|
+
{ key: "pearl", label: "pearl", pattern: /\bpearl(s)?\b/i },
|
|
285
|
+
{ key: "morganite", label: "morganite", pattern: /\bmorganite\b/i },
|
|
286
|
+
{ key: "moissanite", label: "moissanite", pattern: /\bmoissanite\b/i },
|
|
287
|
+
{ key: "turquoise", label: "turquoise", pattern: /\bturquoise\b/i },
|
|
288
|
+
{ key: "onyx", label: "onyx", pattern: /\bonyx\b/i },
|
|
289
|
+
];
|
|
290
|
+
const PERSONALISATION = [
|
|
291
|
+
{ key: "engraving", label: "engraving mentioned", pattern: /\bengrav/i },
|
|
292
|
+
{ key: "initial", label: "initial or letter", pattern: /\binitial(s)?\b|\bletter(s)?\b|\bmonogram/i },
|
|
293
|
+
{ key: "birthstone", label: "birthstone", pattern: /\bbirthstone\b/i },
|
|
294
|
+
{ key: "custom-size", label: "custom or made to order sizing", pattern: /\bmade to order\b|\bcustom size\b|\bsize guide\b/i },
|
|
295
|
+
{ key: "gift-box", label: "gift packaging", pattern: /\bgift box\b|\bgift packaging\b/i },
|
|
296
|
+
{ key: "stackable", label: "stackable", pattern: /\bstackable\b/i },
|
|
297
|
+
{ key: "hypoallergenic", label: "hypoallergenic", pattern: /\bhypoallergenic\b|\bnickel[- ]free\b/i },
|
|
298
|
+
{ key: "waterproof", label: "waterproof", pattern: /\bwaterproof\b|\bwater[- ]resistant\b/i },
|
|
299
|
+
];
|
|
300
|
+
const stripHtml = (html) => String(html ?? "").replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim();
|
|
301
|
+
const ifcoMedian = (values) => {
|
|
302
|
+
if (!values.length)
|
|
303
|
+
return null;
|
|
304
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
305
|
+
const mid = Math.floor(sorted.length / 2);
|
|
306
|
+
return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
|
307
|
+
};
|
|
308
|
+
function censusOver(products, terms, textOf) {
|
|
309
|
+
const out = terms.map((term) => ({ label: term.label, products: new Set(), prices: [] }));
|
|
310
|
+
for (const product of products) {
|
|
311
|
+
const handle = String(product.handle ?? product.id ?? "?");
|
|
312
|
+
const text = textOf(product);
|
|
313
|
+
for (const [i, term] of terms.entries()) {
|
|
314
|
+
if (!term.pattern.test(text))
|
|
315
|
+
continue;
|
|
316
|
+
out[i].products.add(handle);
|
|
317
|
+
const variantPrices = (Array.isArray(product.variants) ? product.variants : [])
|
|
318
|
+
.map((variant) => Math.round(Number(variant?.price)))
|
|
319
|
+
.filter((price) => Number.isFinite(price) && price > 0);
|
|
320
|
+
const top = Math.round(Number(product.price));
|
|
321
|
+
if (variantPrices.length)
|
|
322
|
+
out[i].prices.push(...variantPrices);
|
|
323
|
+
else if (Number.isFinite(top) && top > 0)
|
|
324
|
+
out[i].prices.push(top);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
const censusLines = (rows, total, unit, n) => {
|
|
330
|
+
const lines = [`${total} product(s) scanned, matches are per listing and a listing can carry several`, ""];
|
|
331
|
+
for (const row of rows.filter((entry) => entry.products.size).sort((a, b) => b.products.size - a.products.size).slice(0, n)) {
|
|
332
|
+
const median = ifcoMedian(row.prices);
|
|
333
|
+
const share = ((row.products.size / Math.max(total, 1)) * 100).toFixed(1);
|
|
334
|
+
lines.push(` ${row.label}: ${row.products.size} listing(s) (${share}%) · ${row.prices.length} priced ${unit} · median ${median === null ? "not reported" : `$${median.toLocaleString("en-US")}`}`);
|
|
335
|
+
}
|
|
336
|
+
const missing = rows.filter((entry) => !entry.products.size).map((entry) => entry.label);
|
|
337
|
+
if (missing.length)
|
|
338
|
+
lines.push(` not found in the sample: ${missing.join(", ")}`);
|
|
339
|
+
return lines;
|
|
340
|
+
};
|
|
341
|
+
/** What the catalogue is actually made of, counted from the titles, tags and variant names. */
|
|
342
|
+
export async function materialCensus(args) {
|
|
343
|
+
const max = Math.min(Math.max(Number(args.sample ?? 400) || 400, 20), 2000);
|
|
344
|
+
const n = Math.min(Math.max(Number(args.limit ?? 16) || 16, 1), 30);
|
|
345
|
+
const pages = IFCO_PAGES.slice(0, Math.max(1, Math.ceil(max / 250)));
|
|
346
|
+
const products = await sampleCatalog(max, pages);
|
|
347
|
+
if (!products.length)
|
|
348
|
+
throw new Error("The catalogue returned no products.");
|
|
349
|
+
const rows = censusOver(products, MATERIALS, (product) => [String(product.title ?? ""), (Array.isArray(product.tags) ? product.tags.join(" ") : ""), stripHtml(product.body_html)].join(" "));
|
|
350
|
+
return [
|
|
351
|
+
`Material and stone census for the IF & Co. catalogue: ${products.length} listing(s) sampled`,
|
|
352
|
+
...censusLines(rows, products.length, "variant(s)", n),
|
|
353
|
+
].join("\n");
|
|
354
|
+
}
|
|
355
|
+
/** The personalisation the house offers, which the product tools only reveal one listing at a time. */
|
|
356
|
+
export async function personalisationCensus(args) {
|
|
357
|
+
const max = Math.min(Math.max(Number(args.sample ?? 400) || 400, 20), 2000);
|
|
358
|
+
const n = Math.min(Math.max(Number(args.limit ?? 10) || 10, 1), 20);
|
|
359
|
+
const pages = IFCO_PAGES.slice(0, Math.max(1, Math.ceil(max / 250)));
|
|
360
|
+
const products = await sampleCatalog(max, pages);
|
|
361
|
+
if (!products.length)
|
|
362
|
+
throw new Error("The catalogue returned no products.");
|
|
363
|
+
const rows = censusOver(products, PERSONALISATION, (product) => [String(product.title ?? ""), (Array.isArray(product.tags) ? product.tags.join(" ") : ""), stripHtml(product.body_html)].join(" "));
|
|
364
|
+
return [
|
|
365
|
+
`Personalisation census for the IF & Co. catalogue: ${products.length} listing(s) sampled`,
|
|
366
|
+
...censusLines(rows, products.length, "variant(s)", n),
|
|
367
|
+
].join("\n");
|
|
368
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns null when the caller may run the tool, or a short message telling
|
|
3
|
+
* them how to get a key.
|
|
4
|
+
*
|
|
5
|
+
* A good key is remembered for an hour, so a busy session does not hit the
|
|
6
|
+
* license server on every call and keeps working through a brief outage.
|
|
7
|
+
*/
|
|
8
|
+
export declare function premiumRequired(tool: string): Promise<string | null>;
|
package/dist/license.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Premium tools on this server need a license key bought from MCP Marketplace.
|
|
2
|
+
// Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
|
|
3
|
+
// that is not listed in PREMIUM keeps working without a key.
|
|
4
|
+
const SLUG = "ifandco-mcp";
|
|
5
|
+
// Tools that need a key. Everything else is free.
|
|
6
|
+
const PREMIUM = new Set([
|
|
7
|
+
"material_census",
|
|
8
|
+
"personalisation_census",
|
|
9
|
+
]);
|
|
10
|
+
const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
|
|
11
|
+
// The license check calls the marketplace's verify endpoint directly instead of
|
|
12
|
+
// using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
|
|
13
|
+
// check it makes is rejected by Supabase before it reaches the function. The
|
|
14
|
+
// publishable key below is public by design - it ships in mcp-marketplace.io's
|
|
15
|
+
// own JavaScript and only ever reaches their licence check.
|
|
16
|
+
const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
|
|
17
|
+
const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
|
|
18
|
+
const OK_CACHE_MS = 60 * 60 * 1000;
|
|
19
|
+
const BAD_CACHE_MS = 60 * 1000;
|
|
20
|
+
const seen = new Map();
|
|
21
|
+
const REASONS = {
|
|
22
|
+
missing_key: "no key is set",
|
|
23
|
+
invalid_format: "that key is not a valid MCP Marketplace key",
|
|
24
|
+
not_found: "that key was not found",
|
|
25
|
+
revoked: "that key has been revoked",
|
|
26
|
+
rotated: "that key was rotated, use your new one",
|
|
27
|
+
expired: "that key has expired, renew it",
|
|
28
|
+
rate_limited: "there were too many checks just now, try again shortly",
|
|
29
|
+
network_error: "the license server could not be reached",
|
|
30
|
+
};
|
|
31
|
+
function blocked(tool, reason) {
|
|
32
|
+
const why = REASONS[reason] ?? `the license server answered "${reason}"`;
|
|
33
|
+
return `"${tool}" needs a license key, but ${why}. ` +
|
|
34
|
+
`Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
|
|
35
|
+
}
|
|
36
|
+
async function verify(key) {
|
|
37
|
+
const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
apikey: PUBLISHABLE_KEY,
|
|
41
|
+
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ key, slug: SLUG }),
|
|
45
|
+
// an unusable key answers 400 with a JSON body, so read the body either way
|
|
46
|
+
signal: AbortSignal.timeout(15000),
|
|
47
|
+
});
|
|
48
|
+
const data = (await res.json());
|
|
49
|
+
if (typeof data?.valid !== "boolean")
|
|
50
|
+
return { valid: false, reason: "unexpected_response" };
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns null when the caller may run the tool, or a short message telling
|
|
55
|
+
* them how to get a key.
|
|
56
|
+
*
|
|
57
|
+
* A good key is remembered for an hour, so a busy session does not hit the
|
|
58
|
+
* license server on every call and keeps working through a brief outage.
|
|
59
|
+
*/
|
|
60
|
+
export async function premiumRequired(tool) {
|
|
61
|
+
if (!PREMIUM.has(tool))
|
|
62
|
+
return null;
|
|
63
|
+
const key = process.env.MCP_LICENSE_KEY;
|
|
64
|
+
if (!key)
|
|
65
|
+
return blocked(tool, "missing_key");
|
|
66
|
+
const hit = seen.get(key);
|
|
67
|
+
if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
|
|
68
|
+
return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
|
|
69
|
+
}
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = await verify(key);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
result = { valid: false, reason: "network_error" };
|
|
76
|
+
}
|
|
77
|
+
seen.set(key, { ...result, at: Date.now() });
|
|
78
|
+
return result.valid ? null : blocked(tool, result.reason ?? "invalid");
|
|
79
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { errorMessage, searchProducts, listNewest, getProduct, priceOverview, findDeals, listCategories, } from "./api.js";
|
|
3
|
+
import { errorMessage, searchProducts, listNewest, getProduct, priceOverview, findDeals, listCategories, listCollections, collectionProducts, restockCheck, materialCensus, personalisationCensus } from "./api.js";
|
|
4
|
+
import { premiumRequired } from "./license.js";
|
|
4
5
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
6
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
7
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -13,7 +14,7 @@ export function createServer() {
|
|
|
13
14
|
title: "Search products",
|
|
14
15
|
description: "Keyword search across the catalog.",
|
|
15
16
|
inputSchema: z.object({
|
|
16
|
-
query: z.string().describe("Keyword, e.g. pendant,
|
|
17
|
+
query: z.string().describe("Keyword, e.g. pendant, tennis chain, ring."),
|
|
17
18
|
limit: z.string().describe("Max results (default 10, max 25).").optional(),
|
|
18
19
|
max_price: z.string().describe("Max USD price.").optional(),
|
|
19
20
|
product_type: z.string().describe("Filter by type.").optional()
|
|
@@ -103,5 +104,88 @@ export function createServer() {
|
|
|
103
104
|
return textError(errorMessage(e));
|
|
104
105
|
}
|
|
105
106
|
});
|
|
107
|
+
server.registerTool("list_collections", {
|
|
108
|
+
title: "List collections",
|
|
109
|
+
description: "Collections with handle, product count and URL, longest first.",
|
|
110
|
+
inputSchema: z.object({
|
|
111
|
+
limit: z.string().describe("Max collections (default 40, max 250).").optional()
|
|
112
|
+
}),
|
|
113
|
+
annotations: READ_ONLY,
|
|
114
|
+
}, async ({ limit }) => {
|
|
115
|
+
try {
|
|
116
|
+
return text(await listCollections(limit));
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
return textError(errorMessage(e));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
server.registerTool("collection_products", {
|
|
123
|
+
title: "Collection products",
|
|
124
|
+
description: "Every product in one collection with live price and stock, sortable.",
|
|
125
|
+
inputSchema: z.object({
|
|
126
|
+
handle: z.string().describe("Collection handle from list_collections, e.g. balms."),
|
|
127
|
+
limit: z.string().describe("Max results (default 20, max 50).").optional(),
|
|
128
|
+
sort: z.string().describe("featured, price_low, price_high or newest.").optional()
|
|
129
|
+
}),
|
|
130
|
+
annotations: READ_ONLY,
|
|
131
|
+
}, async ({ handle, limit, sort }) => {
|
|
132
|
+
try {
|
|
133
|
+
return text(await collectionProducts(handle, limit, sort));
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
return textError(errorMessage(e));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
server.registerTool("restock_check", {
|
|
140
|
+
title: "Restock check",
|
|
141
|
+
description: "Stock and price for specific product handles: what is buyable right now and which options are left.",
|
|
142
|
+
inputSchema: z.object({
|
|
143
|
+
handles: z.string().describe("Up to 10 product handles, comma separated.")
|
|
144
|
+
}),
|
|
145
|
+
annotations: READ_ONLY,
|
|
146
|
+
}, async ({ handles }) => {
|
|
147
|
+
try {
|
|
148
|
+
return text(await restockCheck(handles));
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
return textError(errorMessage(e));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
server.registerTool("material_census", {
|
|
155
|
+
title: "Material census",
|
|
156
|
+
description: "What the catalogue is actually made of: how many listings mention each metal or stone, the share of the sample, and the median price of the variants that carry it. Counted from real titles, tags and descriptions.",
|
|
157
|
+
inputSchema: z.object({ sample: z.string().describe("How many products to sample (default 400, max 2000).").optional(), limit: z.string().describe("How many materials to print (default 16, max 30).").optional() }),
|
|
158
|
+
annotations: READ_ONLY,
|
|
159
|
+
}, async (args) => {
|
|
160
|
+
// ---- paywall: material_census ----
|
|
161
|
+
const paywallMessage = await premiumRequired("material_census");
|
|
162
|
+
if (paywallMessage)
|
|
163
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
164
|
+
// ---- paywall: end ----
|
|
165
|
+
try {
|
|
166
|
+
return text(await materialCensus(args));
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
return textError(errorMessage(e));
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
server.registerTool("personalisation_census", {
|
|
173
|
+
title: "Personalisation census",
|
|
174
|
+
description: "The personalisation the catalogue offers - engraving, initials, birthstones, made-to-order sizing, gift packaging, stackability, hypoallergenic, waterproof - with the share of listings that mention each.",
|
|
175
|
+
inputSchema: z.object({ sample: z.string().describe("How many products to sample (default 400, max 2000).").optional(), limit: z.string().describe("How many personalisation types to print (default 10, max 20).").optional() }),
|
|
176
|
+
annotations: READ_ONLY,
|
|
177
|
+
}, async (args) => {
|
|
178
|
+
// ---- paywall: personalisation_census ----
|
|
179
|
+
const paywallMessage = await premiumRequired("personalisation_census");
|
|
180
|
+
if (paywallMessage)
|
|
181
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
182
|
+
// ---- paywall: end ----
|
|
183
|
+
try {
|
|
184
|
+
return text(await personalisationCensus(args));
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
return textError(errorMessage(e));
|
|
188
|
+
}
|
|
189
|
+
});
|
|
106
190
|
return server;
|
|
107
191
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ifandco-mcp",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "IF & Co.
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Query the IF & Co. catalog: search custom pendants, chains, rings and bracelets, newest arrivals, enriched product details, price stats, below-median deals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"mcpName": "io.github.mrfentmen/ifandco-mcp",
|
|
7
7
|
"repository": {
|