icebox-mcp 1.0.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 +16 -0
- package/dist/api.d.ts +10 -0
- package/dist/api.js +174 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +12 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +107 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# icebox-mcp
|
|
2
|
+
|
|
3
|
+
Query the Icebox Diamonds & Watches catalog (icebox.com, NYC): search diamond jewelry and watches, newest arrivals by type, enriched product details (metal/size/clarity variants), price stats, below-median deals, and category counts. Public storefront data, no API key required. Not affiliated with Icebox.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
- `search_products` — Keyword search across the catalog.
|
|
8
|
+
- `list_newest` — Newest catalog products, sorted by publish date.
|
|
9
|
+
- `get_product` — Full details: variants with parsed options, images, description, tags.
|
|
10
|
+
- `price_overview` — Ask-price distribution (min/p25/median/p75/max).
|
|
11
|
+
- `find_deals` — Available products priced below the median.
|
|
12
|
+
- `list_categories` — Product-type counts in the catalog sample.
|
|
13
|
+
|
|
14
|
+
## Source
|
|
15
|
+
|
|
16
|
+
[icebox-mcp](https://github.com/mrfentmen/awesome-mcps/tree/main/servers/icebox-mcp)
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class IceboxError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare function errorMessage(e: unknown): string;
|
|
5
|
+
export declare function searchProducts(query: string, limit?: string, maxPrice?: string, productType?: string): Promise<string>;
|
|
6
|
+
export declare function listNewest(limit?: string, page?: string, productType?: string): Promise<string>;
|
|
7
|
+
export declare function getProduct(handle: string): Promise<string>;
|
|
8
|
+
export declare function priceOverview(limit?: string): Promise<string>;
|
|
9
|
+
export declare function findDeals(limit?: string, sample?: string): Promise<string>;
|
|
10
|
+
export declare function listCategories(): Promise<string>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
export class IceboxError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "IceboxError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export function errorMessage(e) {
|
|
8
|
+
return e instanceof Error ? e.message : String(e);
|
|
9
|
+
}
|
|
10
|
+
const UA = { "User-Agent": "awesome-mcps/1.0" };
|
|
11
|
+
const CATALOG = "https://icebox.com/products.json";
|
|
12
|
+
const SUGGEST = "https://icebox.com/search/suggest.json";
|
|
13
|
+
const PRODUCT_BASE = "https://icebox.com/products";
|
|
14
|
+
function pretty(data) {
|
|
15
|
+
const t = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
16
|
+
return t.length > 12000 ? t.slice(0, 12000) + "\n…(truncated)" : t;
|
|
17
|
+
}
|
|
18
|
+
async function getJson(url) {
|
|
19
|
+
let res;
|
|
20
|
+
try {
|
|
21
|
+
res = await fetch(url, { headers: { ...UA } });
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
throw new IceboxError(`Network error: ${e.message}`);
|
|
25
|
+
}
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
throw new IceboxError(`HTTP ${res.status} for ${new URL(url).hostname}`);
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
function slim(p) {
|
|
31
|
+
const variants = Array.isArray(p.variants) ? p.variants : [];
|
|
32
|
+
const varPrices = variants.map((v) => Number(v.price)).filter((n) => Number.isFinite(n));
|
|
33
|
+
const topPrice = Number(p.price ?? p.price_min);
|
|
34
|
+
const prices = varPrices.length ? varPrices : (Number.isFinite(topPrice) ? [topPrice] : []);
|
|
35
|
+
const images = Array.isArray(p.images) ? p.images : [];
|
|
36
|
+
const img0 = images.length ? images[0] : null;
|
|
37
|
+
return {
|
|
38
|
+
id: p.id,
|
|
39
|
+
title: p.title,
|
|
40
|
+
handle: p.handle,
|
|
41
|
+
vendor: p.vendor,
|
|
42
|
+
product_type: p.product_type,
|
|
43
|
+
price_usd: prices.length ? Math.min(...prices) : null,
|
|
44
|
+
available: variants.some((v) => v.available) || p.available === true,
|
|
45
|
+
url: `${PRODUCT_BASE}/${p.handle}`,
|
|
46
|
+
image: typeof img0 === "string" ? img0 : (img0?.src ?? null),
|
|
47
|
+
published_at: p.published_at ?? null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function passes(s, maxPrice, productType) {
|
|
51
|
+
if (maxPrice !== undefined && (s.price_usd === null || s.price_usd > maxPrice))
|
|
52
|
+
return false;
|
|
53
|
+
if (productType && !s.product_type.toLowerCase().includes(productType.toLowerCase()))
|
|
54
|
+
return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
async function catalogPage(page) {
|
|
58
|
+
const data = await getJson(`${CATALOG}?limit=250&page=${page}`);
|
|
59
|
+
return Array.isArray(data.products) ? data.products : [];
|
|
60
|
+
}
|
|
61
|
+
async function sampleCatalog(maxItems, pages) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const pg of pages) {
|
|
64
|
+
if (out.length >= maxItems)
|
|
65
|
+
break;
|
|
66
|
+
try {
|
|
67
|
+
for (const p of await catalogPage(pg)) {
|
|
68
|
+
out.push(p);
|
|
69
|
+
if (out.length >= maxItems)
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch { /* page failed — keep what we have */ }
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
export async function searchProducts(query, limit, maxPrice, productType) {
|
|
78
|
+
const n = Math.min(Number(limit ?? 10) || 10, 25);
|
|
79
|
+
const mp = maxPrice !== undefined ? Number(maxPrice) : undefined;
|
|
80
|
+
const data = await getJson(`${SUGGEST}?q=${encodeURIComponent(query)}&resources[type]=product&resources[limit]=${n}`);
|
|
81
|
+
const products = data?.resources?.results?.products ?? [];
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const p of products) {
|
|
84
|
+
const sl = slim(p);
|
|
85
|
+
if (passes(sl, mp, productType))
|
|
86
|
+
out.push(sl);
|
|
87
|
+
if (out.length >= n)
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
return pretty({ store: "Icebox", query, count: out.length, listings: out });
|
|
91
|
+
}
|
|
92
|
+
export async function listNewest(limit, page, productType) {
|
|
93
|
+
const n = Math.min(Number(limit ?? 20) || 20, 50);
|
|
94
|
+
const pg = Math.max(Number(page ?? 1) || 1, 1);
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const p of await catalogPage(pg)) {
|
|
97
|
+
const sl = slim(p);
|
|
98
|
+
if (sl.product_type && (!productType || sl.product_type.toLowerCase().includes(productType.toLowerCase())))
|
|
99
|
+
out.push(sl);
|
|
100
|
+
}
|
|
101
|
+
out.sort((a, b) => String(b.published_at ?? "").localeCompare(String(a.published_at ?? "")));
|
|
102
|
+
return pretty({ store: "Icebox", page: pg, count: out.length, listings: out.slice(0, n) });
|
|
103
|
+
}
|
|
104
|
+
function parseParts(title) {
|
|
105
|
+
return String(title ?? "").split("/").map((s) => s.trim()).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
export async function getProduct(handle) {
|
|
108
|
+
const p = await getJson(`${PRODUCT_BASE}/${encodeURIComponent(handle)}.js`);
|
|
109
|
+
const sl = slim(p);
|
|
110
|
+
// .js detail quotes money in cents; catalog/suggest quote dollars.
|
|
111
|
+
const variants = (Array.isArray(p.variants) ? p.variants : []).map((v) => ({
|
|
112
|
+
title: v.title, parts: parseParts(v.title),
|
|
113
|
+
price_usd: Math.round(Number(v.price)) / 100, compare_at_usd: v.compare_at_price !== null && v.compare_at_price !== undefined ? Math.round(Number(v.compare_at_price)) / 100 : null,
|
|
114
|
+
available: v.available,
|
|
115
|
+
}));
|
|
116
|
+
const images = (Array.isArray(p.images) ? p.images : []).map((i) => typeof i === "string" ? i : i.src).filter(Boolean);
|
|
117
|
+
const html = typeof p.body_html === "string" ? p.body_html : (typeof p.description === "string" ? p.description : "");
|
|
118
|
+
const dollar = variants.length && Number.isFinite(variants[0].price_usd) ? variants[0].price_usd : (sl.price_usd !== null ? Math.round(sl.price_usd) / 100 : null);
|
|
119
|
+
return pretty({
|
|
120
|
+
store: "Icebox", ...sl, price_usd: dollar, variants, images,
|
|
121
|
+
description: html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 1500) || null,
|
|
122
|
+
tags: p.tags,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export async function priceOverview(limit) {
|
|
126
|
+
const n = Math.min(Number(limit ?? 200) || 200, 500);
|
|
127
|
+
const prices = [];
|
|
128
|
+
for (const p of await sampleCatalog(n, [1, 2, 3])) {
|
|
129
|
+
const sl = slim(p);
|
|
130
|
+
if (sl.price_usd !== null)
|
|
131
|
+
prices.push(sl.price_usd);
|
|
132
|
+
}
|
|
133
|
+
if (!prices.length)
|
|
134
|
+
throw new IceboxError("No priced products found.");
|
|
135
|
+
const a = [...prices].sort((x, y) => x - y);
|
|
136
|
+
const mid = a.length % 2 ? a[(a.length - 1) / 2] : (a[a.length / 2 - 1] + a[a.length / 2]) / 2;
|
|
137
|
+
return pretty({
|
|
138
|
+
store: "Icebox", currency: "USD", sample: a.length,
|
|
139
|
+
min: a[0], p25: a[Math.floor(a.length * 0.25)], median: mid,
|
|
140
|
+
p75: a[Math.floor(a.length * 0.75)], max: a[a.length - 1],
|
|
141
|
+
note: "Storefront asking prices. Not affiliated with Icebox.",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
export async function findDeals(limit, sample) {
|
|
145
|
+
const n = Math.min(Number(limit ?? 10) || 10, 25);
|
|
146
|
+
const per = Math.min(Number(sample ?? 200) || 200, 500);
|
|
147
|
+
const all = [];
|
|
148
|
+
for (const p of await sampleCatalog(per, [1, 2, 3])) {
|
|
149
|
+
const sl = slim(p);
|
|
150
|
+
if (sl.price_usd !== null && sl.available)
|
|
151
|
+
all.push(sl);
|
|
152
|
+
}
|
|
153
|
+
if (!all.length)
|
|
154
|
+
throw new IceboxError("No available priced products found.");
|
|
155
|
+
const ps = all.map((s) => s.price_usd).sort((a, b) => a - b);
|
|
156
|
+
const median = ps.length % 2 ? ps[(ps.length - 1) / 2] : (ps[ps.length / 2 - 1] + ps[ps.length / 2]) / 2;
|
|
157
|
+
const deals = all
|
|
158
|
+
.filter((s) => s.price_usd < median)
|
|
159
|
+
.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 }))
|
|
160
|
+
.sort((a, b) => a.vs_median_pct - b.vs_median_pct);
|
|
161
|
+
return pretty({
|
|
162
|
+
store: "Icebox", sample: all.length, median_usd: median, count: Math.min(deals.length, n),
|
|
163
|
+
deals: deals.slice(0, n),
|
|
164
|
+
note: "Below-median asks, not appraisals. Not affiliated with Icebox.",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
export async function listCategories() {
|
|
168
|
+
const counts = {};
|
|
169
|
+
for (const p of await sampleCatalog(500, [1, 2])) {
|
|
170
|
+
const t = p.product_type || "unknown";
|
|
171
|
+
counts[t] = (counts[t] ?? 0) + 1;
|
|
172
|
+
}
|
|
173
|
+
return pretty({ store: "Icebox", sample: Object.values(counts).reduce((a, b) => a + b, 0), categories: counts });
|
|
174
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { createServer } from "./server.js";
|
|
3
|
+
async function main() {
|
|
4
|
+
const server = createServer();
|
|
5
|
+
const transport = new StdioServerTransport();
|
|
6
|
+
await server.connect(transport);
|
|
7
|
+
console.error("MCP server running on stdio");
|
|
8
|
+
}
|
|
9
|
+
main().catch((err) => {
|
|
10
|
+
console.error("Fatal error:", err);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
});
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { errorMessage, searchProducts, listNewest, getProduct, priceOverview, findDeals, listCategories, } from "./api.js";
|
|
4
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
|
+
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
|
+
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
7
|
+
export function createServer() {
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: "icebox-mcp",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
});
|
|
12
|
+
server.registerTool("search_products", {
|
|
13
|
+
title: "Search products",
|
|
14
|
+
description: "Keyword search across the catalog.",
|
|
15
|
+
inputSchema: z.object({
|
|
16
|
+
query: z.string().describe("Keyword, e.g. cuban link, datejust, pendant."),
|
|
17
|
+
limit: z.string().describe("Max results (default 10, max 25).").optional(),
|
|
18
|
+
max_price: z.string().describe("Max USD price.").optional(),
|
|
19
|
+
product_type: z.string().describe("Filter, e.g. Watch, Ring, Chain.").optional()
|
|
20
|
+
}),
|
|
21
|
+
annotations: READ_ONLY,
|
|
22
|
+
}, async ({ query, limit, max_price: maxPrice, product_type: productType }) => {
|
|
23
|
+
try {
|
|
24
|
+
return text(await searchProducts(query, limit, maxPrice, productType));
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return textError(errorMessage(e));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
server.registerTool("list_newest", {
|
|
31
|
+
title: "Newest arrivals",
|
|
32
|
+
description: "Newest catalog products, sorted by publish date.",
|
|
33
|
+
inputSchema: z.object({
|
|
34
|
+
limit: z.string().describe("Max results (default 20, max 50).").optional(),
|
|
35
|
+
page: z.string().describe("Catalog page (default 1).").optional(),
|
|
36
|
+
product_type: z.string().describe("Filter by type.").optional()
|
|
37
|
+
}),
|
|
38
|
+
annotations: READ_ONLY,
|
|
39
|
+
}, async ({ limit, page, product_type: productType }) => {
|
|
40
|
+
try {
|
|
41
|
+
return text(await listNewest(limit, page, productType));
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return textError(errorMessage(e));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
server.registerTool("get_product", {
|
|
48
|
+
title: "Get product",
|
|
49
|
+
description: "Full details: variants with parsed options, images, description, tags.",
|
|
50
|
+
inputSchema: z.object({
|
|
51
|
+
handle: z.string().describe("Product handle/slug.")
|
|
52
|
+
}),
|
|
53
|
+
annotations: READ_ONLY,
|
|
54
|
+
}, async ({ handle }) => {
|
|
55
|
+
try {
|
|
56
|
+
return text(await getProduct(handle));
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
return textError(errorMessage(e));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
server.registerTool("price_overview", {
|
|
63
|
+
title: "Price overview",
|
|
64
|
+
description: "Ask-price distribution (min/p25/median/p75/max).",
|
|
65
|
+
inputSchema: z.object({
|
|
66
|
+
limit: z.string().describe("Sample size (default 200, max 500).").optional()
|
|
67
|
+
}),
|
|
68
|
+
annotations: READ_ONLY,
|
|
69
|
+
}, async ({ limit }) => {
|
|
70
|
+
try {
|
|
71
|
+
return text(await priceOverview(limit));
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
return textError(errorMessage(e));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
server.registerTool("find_deals", {
|
|
78
|
+
title: "Find deals",
|
|
79
|
+
description: "Available products priced below the median.",
|
|
80
|
+
inputSchema: z.object({
|
|
81
|
+
limit: z.string().describe("Max deals (default 10, max 25).").optional(),
|
|
82
|
+
sample: z.string().describe("Products sampled (default 200, max 500).").optional()
|
|
83
|
+
}),
|
|
84
|
+
annotations: READ_ONLY,
|
|
85
|
+
}, async ({ limit, sample }) => {
|
|
86
|
+
try {
|
|
87
|
+
return text(await findDeals(limit, sample));
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return textError(errorMessage(e));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
server.registerTool("list_categories", {
|
|
94
|
+
title: "Categories",
|
|
95
|
+
description: "Product-type counts in the catalog sample.",
|
|
96
|
+
inputSchema: z.object({}),
|
|
97
|
+
annotations: READ_ONLY,
|
|
98
|
+
}, async ({}) => {
|
|
99
|
+
try {
|
|
100
|
+
return text(await listCategories());
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
return textError(errorMessage(e));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return server;
|
|
107
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "icebox-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Icebox Diamonds jewelry: search, products, prices. Keyless.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"mcpName": "io.github.mrfentmen/icebox-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/mrfentmen/awesome-mcps.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"icebox-mcp": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"start": "node dist/index.js",
|
|
21
|
+
"dev": "rm -rf dist && tsc -p tsconfig.json && node dist/index.js",
|
|
22
|
+
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"icebox",
|
|
27
|
+
"jewelry",
|
|
28
|
+
"diamonds",
|
|
29
|
+
"watches"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
34
|
+
"zod": "^3.23.8"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.0.0",
|
|
38
|
+
"typescript": "^5.6.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
}
|
|
43
|
+
}
|