fendi-mcp 1.0.0 → 1.0.1
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 +8 -5
- package/dist/api.d.ts +5 -2
- package/dist/api.js +233 -33
- package/dist/server.js +61 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# fendi-mcp
|
|
2
2
|
|
|
3
|
-
Fendi resale listings aggregated from Fashionphile and Rebag public catalogs (prices in USD). No API key required. Not affiliated with or endorsed by Fendi; prices are asking prices, not appraisals.
|
|
3
|
+
Fendi resale listings aggregated from Fashionphile and Rebag public catalogs (prices in USD). No API key required. Condition/color/material attributes are parsed from Rebag catalog data (Fashionphile does not publish them); condition filters apply to Rebag data only. Not affiliated with or endorsed by Fendi; prices are asking prices, not appraisals.
|
|
4
4
|
|
|
5
5
|
## Tools
|
|
6
6
|
|
|
7
|
-
- `search_listings` —
|
|
8
|
-
- `list_newest` — Newest catalog listings
|
|
9
|
-
- `get_listing` — Full
|
|
10
|
-
- `price_overview` — Ask-price
|
|
7
|
+
- `search_listings` — Keyword search across Fashionphile and Rebag. Condition filter is Rebag-data only.
|
|
8
|
+
- `list_newest` — Newest catalog listings, sorted by publish date.
|
|
9
|
+
- `get_listing` — Full details: variants, images, parsed condition/color/material, description.
|
|
10
|
+
- `price_overview` — Ask-price stats overall + by condition.
|
|
11
|
+
- `compare_prices` — Group same-model listings across sources with min/max/spread.
|
|
12
|
+
- `find_deals` — Available listings priced below the brand median.
|
|
13
|
+
- `brand_overview` — Catalog sample counts by source, type and condition.
|
|
11
14
|
|
|
12
15
|
## Source
|
|
13
16
|
|
package/dist/api.d.ts
CHANGED
|
@@ -2,7 +2,10 @@ export declare class FendiError extends Error {
|
|
|
2
2
|
constructor(message: string);
|
|
3
3
|
}
|
|
4
4
|
export declare function errorMessage(e: unknown): string;
|
|
5
|
-
export declare function searchListings(query: string, limit?: string): Promise<string>;
|
|
6
|
-
export declare function listNewest(limit?: string, page?: string): Promise<string>;
|
|
5
|
+
export declare function searchListings(query: string, limit?: string, maxPrice?: string, condition?: string): Promise<string>;
|
|
6
|
+
export declare function listNewest(limit?: string, page?: string, productType?: string, maxPrice?: string, condition?: string): Promise<string>;
|
|
7
7
|
export declare function getListing(handle: string, source?: string): Promise<string>;
|
|
8
8
|
export declare function priceOverview(limit?: string): Promise<string>;
|
|
9
|
+
export declare function comparePrices(query: string, limit?: string): Promise<string>;
|
|
10
|
+
export declare function findDeals(limit?: string, sample?: string): Promise<string>;
|
|
11
|
+
export declare function brandOverview(): Promise<string>;
|
package/dist/api.js
CHANGED
|
@@ -9,29 +9,67 @@ export function errorMessage(e) {
|
|
|
9
9
|
}
|
|
10
10
|
const UA = { "User-Agent": "awesome-mcps/1.0" };
|
|
11
11
|
const VENDORS = ["fendi"];
|
|
12
|
+
const CONDITIONS = ["excellent", "great", "very good", "good", "fair"];
|
|
12
13
|
const SOURCES = [
|
|
13
14
|
{
|
|
14
15
|
name: "fashionphile",
|
|
15
16
|
catalog: "https://www.fashionphile.com/products.json",
|
|
16
17
|
suggest: "https://www.fashionphile.com/search/suggest.json",
|
|
17
18
|
product: (handle) => `https://www.fashionphile.com/products/${encodeURIComponent(handle)}.js`,
|
|
19
|
+
base: "https://www.fashionphile.com/products",
|
|
18
20
|
},
|
|
19
21
|
{
|
|
20
22
|
name: "rebag",
|
|
21
23
|
catalog: "https://shop.rebag.com/products.json",
|
|
22
24
|
suggest: "https://shop.rebag.com/search/suggest.json",
|
|
23
25
|
product: (handle) => `https://shop.rebag.com/products/${encodeURIComponent(handle)}.js`,
|
|
26
|
+
base: "https://shop.rebag.com/products",
|
|
24
27
|
},
|
|
25
28
|
];
|
|
26
29
|
function matchesBrand(vendor) {
|
|
27
30
|
const v = String(vendor ?? "").toLowerCase();
|
|
28
31
|
return VENDORS.some((s) => v.includes(s));
|
|
29
32
|
}
|
|
30
|
-
function
|
|
33
|
+
function parseCondition(title, tags) {
|
|
34
|
+
const t = String(title ?? "");
|
|
35
|
+
const m = t.match(/^(Excellent|Great|Very Good|Good|Fair)\b/i);
|
|
36
|
+
if (m) {
|
|
37
|
+
const c = m[1].toLowerCase();
|
|
38
|
+
return c === "great" ? "great" : c;
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(tags)) {
|
|
41
|
+
for (const tag of tags) {
|
|
42
|
+
const tm = String(tag).match(/^bc-filter-(Excellent|Great|Very Good|Good|Fair)$/i);
|
|
43
|
+
if (tm)
|
|
44
|
+
return tm[1].toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function parseTag(tags, prefix) {
|
|
50
|
+
if (!Array.isArray(tags))
|
|
51
|
+
return null;
|
|
52
|
+
for (const tag of tags) {
|
|
53
|
+
const s = String(tag);
|
|
54
|
+
if (s.toLowerCase().startsWith(prefix))
|
|
55
|
+
return s.slice(prefix.length).replace(/‚/g, ",");
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function slim(source, base, p, cents = false) {
|
|
31
60
|
const variants = Array.isArray(p.variants) ? p.variants : [];
|
|
32
|
-
const
|
|
33
|
-
|
|
61
|
+
const varPrices = variants.map((v) => Number(v.price)).filter((n) => Number.isFinite(n));
|
|
62
|
+
// Suggest-shaped items carry no variants: fall back to top-level price fields.
|
|
63
|
+
const topPrice = Number(p.price ?? p.price_min);
|
|
64
|
+
const raw = varPrices.length ? varPrices : (Number.isFinite(topPrice) ? [topPrice] : []);
|
|
65
|
+
// /products/{handle}.js quotes money in cents; catalog + suggest quote dollars.
|
|
66
|
+
const prices = cents ? raw.map((x) => Math.round(x) / 100) : raw;
|
|
34
67
|
const images = Array.isArray(p.images) ? p.images : [];
|
|
68
|
+
const tags = Array.isArray(p.tags) ? p.tags : [];
|
|
69
|
+
const firstTitle = variants.length ? variants[0].title : "";
|
|
70
|
+
let material = parseTag(tags, "bc-filter-exterior-material-");
|
|
71
|
+
if (!material && tags.some((t) => String(t).toLowerCase() === "exotic"))
|
|
72
|
+
material = "exotic";
|
|
35
73
|
return {
|
|
36
74
|
source,
|
|
37
75
|
id: p.id,
|
|
@@ -40,12 +78,19 @@ function slim(source, base, p) {
|
|
|
40
78
|
vendor: p.vendor,
|
|
41
79
|
product_type: p.product_type,
|
|
42
80
|
price_usd: prices.length ? Math.min(...prices) : null,
|
|
43
|
-
|
|
44
|
-
available: variants.some((v) => v.available),
|
|
81
|
+
available: variants.some((v) => v.available) || p.available === true,
|
|
45
82
|
url: `${base}/${p.handle}`,
|
|
46
|
-
image: images.length ? images[0].src ?? null : null,
|
|
83
|
+
image: images.length ? (typeof images[0] === "string" ? images[0] : images[0].src ?? null) : null,
|
|
84
|
+
condition: parseCondition(firstTitle, tags),
|
|
85
|
+
color: parseTag(tags, "bc-filter-exterior-color-"),
|
|
86
|
+
material,
|
|
87
|
+
published_at: p.published_at ?? null,
|
|
47
88
|
};
|
|
48
89
|
}
|
|
90
|
+
function normCondition(c) {
|
|
91
|
+
const v = c.trim().toLowerCase();
|
|
92
|
+
return CONDITIONS.includes(v) ? v : null;
|
|
93
|
+
}
|
|
49
94
|
function pretty(data) {
|
|
50
95
|
const t = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
51
96
|
return t.length > 12000 ? t.slice(0, 12000) + "\n…(truncated)" : t;
|
|
@@ -67,17 +112,64 @@ async function catalog(source, page) {
|
|
|
67
112
|
const products = Array.isArray(data.products) ? data.products : [];
|
|
68
113
|
return products.filter((p) => matchesBrand(p.vendor));
|
|
69
114
|
}
|
|
70
|
-
|
|
115
|
+
// Distilled-pages fetch: several catalog pages in parallel, brand-filtered.
|
|
116
|
+
async function catalogPages(source, pages) {
|
|
117
|
+
const batches = await Promise.all(pages.map((pg) => catalog(source, pg).catch(() => [])));
|
|
118
|
+
return batches.flat();
|
|
119
|
+
}
|
|
120
|
+
// Round-robin sampling across sources and pages: keeps the brand sample
|
|
121
|
+
// source-balanced (page 1 alone is often 250 Fashionphile items).
|
|
122
|
+
async function sampleCatalog(maxItems, pages) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const pg of pages) {
|
|
125
|
+
for (const s of SOURCES) {
|
|
126
|
+
if (out.length >= maxItems)
|
|
127
|
+
break;
|
|
128
|
+
try {
|
|
129
|
+
for (const p of await catalog(s, pg)) {
|
|
130
|
+
out.push({ source: s, p });
|
|
131
|
+
if (out.length >= maxItems)
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch { /* source down — keep the other */ }
|
|
136
|
+
}
|
|
137
|
+
if (out.length >= maxItems)
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
function passes(s, maxPrice, condition, productType) {
|
|
143
|
+
if (maxPrice !== undefined && (s.price_usd === null || s.price_usd > maxPrice))
|
|
144
|
+
return false;
|
|
145
|
+
if (condition && s.condition !== condition)
|
|
146
|
+
return false;
|
|
147
|
+
if (productType && !s.product_type.toLowerCase().includes(productType.toLowerCase()))
|
|
148
|
+
return false;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
export async function searchListings(query, limit, maxPrice, condition) {
|
|
71
152
|
const n = Math.min(Number(limit ?? 10) || 10, 25);
|
|
153
|
+
const mp = maxPrice !== undefined ? Number(maxPrice) : undefined;
|
|
154
|
+
const cond = condition !== undefined ? normCondition(condition) : undefined;
|
|
155
|
+
if (condition !== undefined && cond === null)
|
|
156
|
+
throw new FendiError(`Unknown condition '${condition}'. Use: ${CONDITIONS.join(", ")}.`);
|
|
72
157
|
const out = [];
|
|
73
158
|
for (const s of SOURCES) {
|
|
74
159
|
try {
|
|
75
160
|
const data = await getJson(`${s.suggest}?q=${encodeURIComponent(query)}&resources[type]=product&resources[limit]=${n}`);
|
|
76
161
|
const products = data?.resources?.results?.products ?? [];
|
|
77
|
-
const base = s.catalog.replace("/products.json", "");
|
|
78
162
|
for (const p of products) {
|
|
79
|
-
if (matchesBrand(p.vendor))
|
|
80
|
-
|
|
163
|
+
if (!matchesBrand(p.vendor))
|
|
164
|
+
continue;
|
|
165
|
+
// Suggest items carry no variant titles/tags: condition unknown outside Rebag catalog data.
|
|
166
|
+
// When a condition filter is set, only items with a known matching condition pass
|
|
167
|
+
// (Fashionphile suggest hits are omitted — documented limitation).
|
|
168
|
+
const sl = slim(s.name, s.base, p);
|
|
169
|
+
if (cond && sl.condition !== cond)
|
|
170
|
+
continue;
|
|
171
|
+
if (passes(sl, mp, null))
|
|
172
|
+
out.push(sl);
|
|
81
173
|
if (out.length >= n)
|
|
82
174
|
break;
|
|
83
175
|
}
|
|
@@ -86,20 +178,27 @@ export async function searchListings(query, limit) {
|
|
|
86
178
|
if (out.length >= n)
|
|
87
179
|
break;
|
|
88
180
|
}
|
|
89
|
-
return pretty({ brand: "Fendi", query, count: out.length, listings: out.slice(0, n) });
|
|
181
|
+
return pretty({ brand: "Fendi", query, count: out.length, condition_filter: cond ?? null, listings: out.slice(0, n) });
|
|
90
182
|
}
|
|
91
|
-
export async function listNewest(limit, page) {
|
|
183
|
+
export async function listNewest(limit, page, productType, maxPrice, condition) {
|
|
92
184
|
const n = Math.min(Number(limit ?? 20) || 20, 50);
|
|
93
185
|
const pg = Math.max(Number(page ?? 1) || 1, 1);
|
|
186
|
+
const mp = maxPrice !== undefined ? Number(maxPrice) : undefined;
|
|
187
|
+
const cond = condition !== undefined ? normCondition(condition) : undefined;
|
|
188
|
+
if (condition !== undefined && cond === null)
|
|
189
|
+
throw new FendiError(`Unknown condition '${condition}'. Use: ${CONDITIONS.join(", ")}.`);
|
|
94
190
|
const out = [];
|
|
95
191
|
for (const s of SOURCES) {
|
|
96
192
|
try {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
193
|
+
for (const p of await catalog(s, pg)) {
|
|
194
|
+
const sl = slim(s.name, s.base, p);
|
|
195
|
+
if (passes(sl, mp, cond ?? undefined, productType))
|
|
196
|
+
out.push(sl);
|
|
197
|
+
}
|
|
100
198
|
}
|
|
101
199
|
catch { /* source down — keep the other */ }
|
|
102
200
|
}
|
|
201
|
+
out.sort((a, b) => String(b.published_at ?? "").localeCompare(String(a.published_at ?? "")));
|
|
103
202
|
return pretty({ brand: "Fendi", page: pg, count: out.length, listings: out.slice(0, n) });
|
|
104
203
|
}
|
|
105
204
|
export async function getListing(handle, source) {
|
|
@@ -108,7 +207,21 @@ export async function getListing(handle, source) {
|
|
|
108
207
|
throw new FendiError(`Unknown source '${source}'. Use fashionphile or rebag.`);
|
|
109
208
|
for (const s of sources) {
|
|
110
209
|
try {
|
|
111
|
-
|
|
210
|
+
const p = await getJson(s.product(handle));
|
|
211
|
+
const sl = slim(s.name, s.base, p, true);
|
|
212
|
+
const rawVars = Array.isArray(p.variants) ? p.variants : [];
|
|
213
|
+
const variants = rawVars.map((v) => ({
|
|
214
|
+
title: v.title, price_usd: Math.round(Number(v.price)) / 100, available: v.available,
|
|
215
|
+
condition: parseCondition(v.title, p.tags),
|
|
216
|
+
}));
|
|
217
|
+
const images = (Array.isArray(p.images) ? p.images : []).map((i) => typeof i === "string" ? i : i.src).filter(Boolean);
|
|
218
|
+
const html = typeof p.body_html === "string" ? p.body_html : (typeof p.description === "string" ? p.description : "");
|
|
219
|
+
const dollar = variants.length && Number.isFinite(variants[0].price_usd) ? variants[0].price_usd : sl.price_usd;
|
|
220
|
+
return pretty({
|
|
221
|
+
brand: "Fendi", ...sl, price_usd: dollar, variants, images,
|
|
222
|
+
description: html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 1500) || null,
|
|
223
|
+
tags: p.tags,
|
|
224
|
+
});
|
|
112
225
|
}
|
|
113
226
|
catch { /* try next source */ }
|
|
114
227
|
}
|
|
@@ -117,29 +230,116 @@ export async function getListing(handle, source) {
|
|
|
117
230
|
export async function priceOverview(limit) {
|
|
118
231
|
const n = Math.min(Number(limit ?? 200) || 200, 500);
|
|
119
232
|
const prices = [];
|
|
233
|
+
const byCondition = {};
|
|
234
|
+
for (const { source: s, p } of await sampleCatalog(n, [1, 2, 3, 4, 5])) {
|
|
235
|
+
const sl = slim(s.name, s.base, p);
|
|
236
|
+
if (sl.price_usd === null)
|
|
237
|
+
continue;
|
|
238
|
+
prices.push(sl.price_usd);
|
|
239
|
+
const c = sl.condition ?? "unknown";
|
|
240
|
+
if (!byCondition[c])
|
|
241
|
+
byCondition[c] = [];
|
|
242
|
+
byCondition[c].push(sl.price_usd);
|
|
243
|
+
}
|
|
244
|
+
if (!prices.length)
|
|
245
|
+
throw new FendiError("No priced listings found.");
|
|
246
|
+
const stats = (arr) => {
|
|
247
|
+
const a = [...arr].sort((x, y) => x - y);
|
|
248
|
+
const mid = a.length % 2 ? a[(a.length - 1) / 2] : (a[a.length / 2 - 1] + a[a.length / 2]) / 2;
|
|
249
|
+
return { n: a.length, min: a[0], median: mid, max: a[a.length - 1] };
|
|
250
|
+
};
|
|
251
|
+
const condStats = {};
|
|
252
|
+
for (const [c, arr] of Object.entries(byCondition))
|
|
253
|
+
condStats[c] = stats(arr);
|
|
254
|
+
return pretty({
|
|
255
|
+
brand: "Fendi", currency: "USD", overall: stats(prices), by_condition: condStats,
|
|
256
|
+
note: "Asking prices on resale sources, not appraisals. Not affiliated with Fendi.",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function normTitle(t) {
|
|
260
|
+
return t.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
|
|
261
|
+
}
|
|
262
|
+
export async function comparePrices(query, limit) {
|
|
263
|
+
const n = Math.min(Number(limit ?? 30) || 30, 60);
|
|
264
|
+
const all = [];
|
|
120
265
|
for (const s of SOURCES) {
|
|
121
266
|
try {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
if (prices.length >= n)
|
|
128
|
-
break;
|
|
267
|
+
const data = await getJson(`${s.suggest}?q=${encodeURIComponent(query)}&resources[type]=product&resources[limit]=${n}`);
|
|
268
|
+
const products = data?.resources?.results?.products ?? [];
|
|
269
|
+
for (const p of products) {
|
|
270
|
+
if (matchesBrand(p.vendor))
|
|
271
|
+
all.push(slim(s.name, s.base, p));
|
|
129
272
|
}
|
|
130
273
|
}
|
|
131
274
|
catch { /* source down — keep the other */ }
|
|
132
|
-
if (prices.length >= n)
|
|
133
|
-
break;
|
|
134
275
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
276
|
+
const groups = new Map();
|
|
277
|
+
for (const sl of all) {
|
|
278
|
+
const key = normTitle(sl.title);
|
|
279
|
+
if (!groups.has(key))
|
|
280
|
+
groups.set(key, []);
|
|
281
|
+
groups.get(key).push(sl);
|
|
282
|
+
}
|
|
283
|
+
const rows = [...groups.entries()].map(([model, items]) => {
|
|
284
|
+
const ps = items.map((i) => i.price_usd).filter((x) => x !== null);
|
|
285
|
+
return {
|
|
286
|
+
model: items[0].title, count: items.length,
|
|
287
|
+
sources: [...new Set(items.map((i) => i.source))],
|
|
288
|
+
min_usd: ps.length ? Math.min(...ps) : null,
|
|
289
|
+
max_usd: ps.length ? Math.max(...ps) : null,
|
|
290
|
+
spread_usd: ps.length > 1 ? Math.max(...ps) - Math.min(...ps) : 0,
|
|
291
|
+
listings: items.map((i) => ({ source: i.source, price_usd: i.price_usd, condition: i.condition, url: i.url })),
|
|
292
|
+
};
|
|
293
|
+
}).sort((a, b) => (b.spread_usd ?? 0) - (a.spread_usd ?? 0));
|
|
294
|
+
return pretty({ brand: "Fendi", query, groups: rows.length, rows: rows.slice(0, 15) });
|
|
295
|
+
}
|
|
296
|
+
export async function findDeals(limit, sample) {
|
|
297
|
+
const n = Math.min(Number(limit ?? 10) || 10, 25);
|
|
298
|
+
const per = Math.min(Number(sample ?? 200) || 200, 500);
|
|
299
|
+
const all = [];
|
|
300
|
+
for (const { source: s, p } of await sampleCatalog(per, [1, 2, 3, 4, 5])) {
|
|
301
|
+
const sl = slim(s.name, s.base, p);
|
|
302
|
+
if (sl.price_usd !== null && sl.available)
|
|
303
|
+
all.push(sl);
|
|
304
|
+
}
|
|
305
|
+
if (!all.length)
|
|
306
|
+
throw new FendiError("No available priced listings found.");
|
|
307
|
+
const ps = all.map((s) => s.price_usd).sort((a, b) => a - b);
|
|
308
|
+
const median = ps.length % 2 ? ps[(ps.length - 1) / 2] : (ps[ps.length / 2 - 1] + ps[ps.length / 2]) / 2;
|
|
309
|
+
const deals = all
|
|
310
|
+
.filter((s) => s.price_usd < median)
|
|
311
|
+
.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 }))
|
|
312
|
+
.sort((a, b) => a.vs_median_pct - b.vs_median_pct);
|
|
139
313
|
return pretty({
|
|
140
|
-
brand: "Fendi",
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
314
|
+
brand: "Fendi", sample: all.length, brand_median_usd: median, count: Math.min(deals.length, n),
|
|
315
|
+
deals: deals.slice(0, n),
|
|
316
|
+
note: "Below-median asks, not appraisals or authenticity guarantees. Not affiliated with Fendi.",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
export async function brandOverview() {
|
|
320
|
+
const bySource = {};
|
|
321
|
+
const byType = {};
|
|
322
|
+
const byCondition = {};
|
|
323
|
+
let total = 0;
|
|
324
|
+
for (const s of SOURCES) {
|
|
325
|
+
try {
|
|
326
|
+
const items = await catalogPages(s, [1, 2]);
|
|
327
|
+
bySource[s.name] = items.length;
|
|
328
|
+
for (const p of items) {
|
|
329
|
+
const sl = slim(s.name, s.base, p);
|
|
330
|
+
total++;
|
|
331
|
+
byType[sl.product_type || "unknown"] = (byType[sl.product_type || "unknown"] ?? 0) + 1;
|
|
332
|
+
const c = sl.condition ?? "unknown";
|
|
333
|
+
byCondition[c] = (byCondition[c] ?? 0) + 1;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
bySource[s.name] = -1;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return pretty({
|
|
341
|
+
brand: "Fendi", catalog_sample: total, by_source: bySource,
|
|
342
|
+
by_product_type: byType, by_condition: byCondition,
|
|
343
|
+
note: "Sample of newest ~500 listings per source. Not affiliated with Fendi.",
|
|
144
344
|
});
|
|
145
345
|
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { errorMessage, searchListings, listNewest, getListing, priceOverview, } from "./api.js";
|
|
3
|
+
import { errorMessage, searchListings, listNewest, getListing, priceOverview, comparePrices, findDeals, brandOverview, } from "./api.js";
|
|
4
4
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
5
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
6
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -11,15 +11,17 @@ export function createServer() {
|
|
|
11
11
|
});
|
|
12
12
|
server.registerTool("search_listings", {
|
|
13
13
|
title: "Search listings",
|
|
14
|
-
description: "
|
|
14
|
+
description: "Keyword search across Fashionphile and Rebag. Condition filter is Rebag-data only.",
|
|
15
15
|
inputSchema: z.object({
|
|
16
16
|
query: z.string().describe("Keyword, e.g. speedy, birkin, submariner."),
|
|
17
|
-
limit: z.string().describe("Max results (default 10, max 25).").optional()
|
|
17
|
+
limit: z.string().describe("Max results (default 10, max 25).").optional(),
|
|
18
|
+
max_price: z.string().describe("Max USD price.").optional(),
|
|
19
|
+
condition: z.string().describe("excellent, great, very good, good, fair (Rebag data only).").optional()
|
|
18
20
|
}),
|
|
19
21
|
annotations: READ_ONLY,
|
|
20
|
-
}, async ({ query, limit }) => {
|
|
22
|
+
}, async ({ query, limit, max_price: maxPrice, condition }) => {
|
|
21
23
|
try {
|
|
22
|
-
return text(await searchListings(query, limit));
|
|
24
|
+
return text(await searchListings(query, limit, maxPrice, condition));
|
|
23
25
|
}
|
|
24
26
|
catch (e) {
|
|
25
27
|
return textError(errorMessage(e));
|
|
@@ -27,15 +29,18 @@ export function createServer() {
|
|
|
27
29
|
});
|
|
28
30
|
server.registerTool("list_newest", {
|
|
29
31
|
title: "Newest listings",
|
|
30
|
-
description: "Newest catalog listings
|
|
32
|
+
description: "Newest catalog listings, sorted by publish date.",
|
|
31
33
|
inputSchema: z.object({
|
|
32
34
|
limit: z.string().describe("Max results (default 20, max 50).").optional(),
|
|
33
|
-
page: z.string().describe("Catalog page (default 1).").optional()
|
|
35
|
+
page: z.string().describe("Catalog page (default 1).").optional(),
|
|
36
|
+
product_type: z.string().describe("Filter, e.g. Bags, Watches, Jewelry.").optional(),
|
|
37
|
+
max_price: z.string().describe("Max USD price.").optional(),
|
|
38
|
+
condition: z.string().describe("excellent, great, very good, good, fair (Rebag data only).").optional()
|
|
34
39
|
}),
|
|
35
40
|
annotations: READ_ONLY,
|
|
36
|
-
}, async ({ limit, page }) => {
|
|
41
|
+
}, async ({ limit, page, product_type: productType, max_price: maxPrice, condition }) => {
|
|
37
42
|
try {
|
|
38
|
-
return text(await listNewest(limit, page));
|
|
43
|
+
return text(await listNewest(limit, page, productType, maxPrice, condition));
|
|
39
44
|
}
|
|
40
45
|
catch (e) {
|
|
41
46
|
return textError(errorMessage(e));
|
|
@@ -43,7 +48,7 @@ export function createServer() {
|
|
|
43
48
|
});
|
|
44
49
|
server.registerTool("get_listing", {
|
|
45
50
|
title: "Get listing",
|
|
46
|
-
description: "Full
|
|
51
|
+
description: "Full details: variants, images, parsed condition/color/material, description.",
|
|
47
52
|
inputSchema: z.object({
|
|
48
53
|
handle: z.string().describe("Product handle/slug."),
|
|
49
54
|
source: z.string().describe("fashionphile or rebag (tries both).").optional()
|
|
@@ -59,7 +64,7 @@ export function createServer() {
|
|
|
59
64
|
});
|
|
60
65
|
server.registerTool("price_overview", {
|
|
61
66
|
title: "Price overview",
|
|
62
|
-
description: "Ask-price
|
|
67
|
+
description: "Ask-price stats overall + by condition.",
|
|
63
68
|
inputSchema: z.object({
|
|
64
69
|
limit: z.string().describe("Sample size (default 200, max 500).").optional()
|
|
65
70
|
}),
|
|
@@ -72,5 +77,50 @@ export function createServer() {
|
|
|
72
77
|
return textError(errorMessage(e));
|
|
73
78
|
}
|
|
74
79
|
});
|
|
80
|
+
server.registerTool("compare_prices", {
|
|
81
|
+
title: "Compare prices",
|
|
82
|
+
description: "Group same-model listings across sources with min/max/spread.",
|
|
83
|
+
inputSchema: z.object({
|
|
84
|
+
query: z.string().describe("Model keyword, e.g. neverfull, daytona."),
|
|
85
|
+
limit: z.string().describe("Suggest depth per source (default 30, max 60).").optional()
|
|
86
|
+
}),
|
|
87
|
+
annotations: READ_ONLY,
|
|
88
|
+
}, async ({ query, limit }) => {
|
|
89
|
+
try {
|
|
90
|
+
return text(await comparePrices(query, limit));
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
return textError(errorMessage(e));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
server.registerTool("find_deals", {
|
|
97
|
+
title: "Find deals",
|
|
98
|
+
description: "Available listings priced below the brand median.",
|
|
99
|
+
inputSchema: z.object({
|
|
100
|
+
limit: z.string().describe("Max deals (default 10, max 25).").optional(),
|
|
101
|
+
sample: z.string().describe("Listings sampled (default 200, max 500).").optional()
|
|
102
|
+
}),
|
|
103
|
+
annotations: READ_ONLY,
|
|
104
|
+
}, async ({ limit, sample }) => {
|
|
105
|
+
try {
|
|
106
|
+
return text(await findDeals(limit, sample));
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
return textError(errorMessage(e));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
server.registerTool("brand_overview", {
|
|
113
|
+
title: "Brand overview",
|
|
114
|
+
description: "Catalog sample counts by source, type and condition.",
|
|
115
|
+
inputSchema: z.object({}),
|
|
116
|
+
annotations: READ_ONLY,
|
|
117
|
+
}, async ({}) => {
|
|
118
|
+
try {
|
|
119
|
+
return text(await brandOverview());
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
return textError(errorMessage(e));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
75
125
|
return server;
|
|
76
126
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fendi-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Fendi resale listings from Fashionphile and Rebag. Keyless.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"mcpName": "io.github.mrfentmen/fendi-mcp",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/mrfentmen/
|
|
9
|
+
"url": "https://github.com/mrfentmen/premium-mcps.git"
|
|
10
10
|
},
|
|
11
11
|
"bin": {
|
|
12
12
|
"fendi-mcp": "./dist/index.js"
|