fendi-mcp 1.0.0 → 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 +30 -5
- package/dist/api.d.ts +53 -2
- package/dist/api.js +762 -36
- package/dist/license.d.ts +8 -0
- package/dist/license.js +79 -0
- package/dist/server.js +178 -12
- package/package.json +3 -3
|
@@ -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 = "fendi-mcp";
|
|
5
|
+
// Tools that need a key. Everything else is free.
|
|
6
|
+
const PREMIUM = new Set([
|
|
7
|
+
"cross_market_match",
|
|
8
|
+
"price_ladder",
|
|
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, searchListings, listNewest, getListing, priceOverview, } from "./api.js";
|
|
3
|
+
import { errorMessage, searchListings, listNewest, getListing, priceOverview, comparePrices, findDeals, brandOverview, listCollections, browseCollection, find_by_attribute, crossMarketMatch, priceLadder, sourceTaxonomy } 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 };
|
|
@@ -11,15 +12,17 @@ export function createServer() {
|
|
|
11
12
|
});
|
|
12
13
|
server.registerTool("search_listings", {
|
|
13
14
|
title: "Search listings",
|
|
14
|
-
description: "
|
|
15
|
+
description: "Keyword search across Fashionphile and Rebag. Condition filter is Rebag-data only.",
|
|
15
16
|
inputSchema: z.object({
|
|
16
|
-
query: z.string().describe("Keyword, e.g.
|
|
17
|
-
limit: z.string().describe("Max results (default 10, max 25).").optional()
|
|
17
|
+
query: z.string().describe("Keyword, e.g. Baguette, Peekaboo, Mon Tresor."),
|
|
18
|
+
limit: z.string().describe("Max results (default 10, max 25).").optional(),
|
|
19
|
+
max_price: z.string().describe("Max USD price.").optional(),
|
|
20
|
+
condition: z.string().describe("excellent, great, very good, good, fair (Rebag data only).").optional()
|
|
18
21
|
}),
|
|
19
22
|
annotations: READ_ONLY,
|
|
20
|
-
}, async ({ query, limit }) => {
|
|
23
|
+
}, async ({ query, limit, max_price: maxPrice, condition }) => {
|
|
21
24
|
try {
|
|
22
|
-
return text(await searchListings(query, limit));
|
|
25
|
+
return text(await searchListings(query, limit, maxPrice, condition));
|
|
23
26
|
}
|
|
24
27
|
catch (e) {
|
|
25
28
|
return textError(errorMessage(e));
|
|
@@ -27,15 +30,18 @@ export function createServer() {
|
|
|
27
30
|
});
|
|
28
31
|
server.registerTool("list_newest", {
|
|
29
32
|
title: "Newest listings",
|
|
30
|
-
description: "Newest catalog listings
|
|
33
|
+
description: "Newest catalog listings, sorted by publish date.",
|
|
31
34
|
inputSchema: z.object({
|
|
32
35
|
limit: z.string().describe("Max results (default 20, max 50).").optional(),
|
|
33
|
-
page: z.string().describe("Catalog page (default 1).").optional()
|
|
36
|
+
page: z.string().describe("Catalog page (default 1).").optional(),
|
|
37
|
+
product_type: z.string().describe("Filter, e.g. Bags, Watches, Jewelry.").optional(),
|
|
38
|
+
max_price: z.string().describe("Max USD price.").optional(),
|
|
39
|
+
condition: z.string().describe("excellent, great, very good, good, fair (Rebag data only).").optional()
|
|
34
40
|
}),
|
|
35
41
|
annotations: READ_ONLY,
|
|
36
|
-
}, async ({ limit, page }) => {
|
|
42
|
+
}, async ({ limit, page, product_type: productType, max_price: maxPrice, condition }) => {
|
|
37
43
|
try {
|
|
38
|
-
return text(await listNewest(limit, page));
|
|
44
|
+
return text(await listNewest(limit, page, productType, maxPrice, condition));
|
|
39
45
|
}
|
|
40
46
|
catch (e) {
|
|
41
47
|
return textError(errorMessage(e));
|
|
@@ -43,7 +49,7 @@ export function createServer() {
|
|
|
43
49
|
});
|
|
44
50
|
server.registerTool("get_listing", {
|
|
45
51
|
title: "Get listing",
|
|
46
|
-
description: "Full
|
|
52
|
+
description: "Full details: variants, images, parsed condition/color/material, description.",
|
|
47
53
|
inputSchema: z.object({
|
|
48
54
|
handle: z.string().describe("Product handle/slug."),
|
|
49
55
|
source: z.string().describe("fashionphile or rebag (tries both).").optional()
|
|
@@ -59,7 +65,7 @@ export function createServer() {
|
|
|
59
65
|
});
|
|
60
66
|
server.registerTool("price_overview", {
|
|
61
67
|
title: "Price overview",
|
|
62
|
-
description: "Ask-price
|
|
68
|
+
description: "Ask-price stats overall + by condition.",
|
|
63
69
|
inputSchema: z.object({
|
|
64
70
|
limit: z.string().describe("Sample size (default 200, max 500).").optional()
|
|
65
71
|
}),
|
|
@@ -72,5 +78,165 @@ export function createServer() {
|
|
|
72
78
|
return textError(errorMessage(e));
|
|
73
79
|
}
|
|
74
80
|
});
|
|
81
|
+
server.registerTool("compare_prices", {
|
|
82
|
+
title: "Compare prices",
|
|
83
|
+
description: "Group same-model listings across sources with min/max/spread.",
|
|
84
|
+
inputSchema: z.object({
|
|
85
|
+
query: z.string().describe("Model keyword, e.g. Baguette, Peekaboo."),
|
|
86
|
+
limit: z.string().describe("Suggest depth per source (default 30, max 60).").optional()
|
|
87
|
+
}),
|
|
88
|
+
annotations: READ_ONLY,
|
|
89
|
+
}, async ({ query, limit }) => {
|
|
90
|
+
try {
|
|
91
|
+
return text(await comparePrices(query, limit));
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return textError(errorMessage(e));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
server.registerTool("find_deals", {
|
|
98
|
+
title: "Find deals",
|
|
99
|
+
description: "Available listings priced below the brand median.",
|
|
100
|
+
inputSchema: z.object({
|
|
101
|
+
limit: z.string().describe("Max deals (default 10, max 25).").optional(),
|
|
102
|
+
sample: z.string().describe("Listings sampled (default 200, max 500).").optional()
|
|
103
|
+
}),
|
|
104
|
+
annotations: READ_ONLY,
|
|
105
|
+
}, async ({ limit, sample }) => {
|
|
106
|
+
try {
|
|
107
|
+
return text(await findDeals(limit, sample));
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
return textError(errorMessage(e));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
server.registerTool("brand_overview", {
|
|
114
|
+
title: "Brand overview",
|
|
115
|
+
description: "Catalog sample counts by source, type and condition.",
|
|
116
|
+
inputSchema: z.object({}),
|
|
117
|
+
annotations: READ_ONLY,
|
|
118
|
+
}, async ({}) => {
|
|
119
|
+
try {
|
|
120
|
+
return text(await brandOverview());
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
return textError(errorMessage(e));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
server.registerTool("list_collections", {
|
|
127
|
+
title: "List collections",
|
|
128
|
+
description: "Curated store collections with handles for browse_collection, e.g. sale or category collections.",
|
|
129
|
+
inputSchema: z.object({
|
|
130
|
+
query: z.string().describe("Filter collection titles, e.g. sale, bags, watches.").optional()
|
|
131
|
+
}),
|
|
132
|
+
annotations: READ_ONLY,
|
|
133
|
+
}, async ({ query }) => {
|
|
134
|
+
try {
|
|
135
|
+
return text(await listCollections(query));
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
return textError(errorMessage(e));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
server.registerTool("browse_collection", {
|
|
142
|
+
title: "Browse collection",
|
|
143
|
+
description: "Listings inside one collection handle from list_collections, e.g. a sale or category collection.",
|
|
144
|
+
inputSchema: z.object({
|
|
145
|
+
handle: z.string().describe("Collection handle from list_collections, e.g. discounted-10-off."),
|
|
146
|
+
source: z.string().describe("fashionphile or rebag (default: both).").optional(),
|
|
147
|
+
limit: z.string().describe("Max results (default 10, max 25).").optional(),
|
|
148
|
+
max_price: z.string().describe("Max USD price.").optional(),
|
|
149
|
+
condition: z.string().describe("excellent, great, very good, good, fair (Rebag data only).").optional()
|
|
150
|
+
}),
|
|
151
|
+
annotations: READ_ONLY,
|
|
152
|
+
}, async ({ handle, source, limit, max_price: maxPrice, condition }) => {
|
|
153
|
+
try {
|
|
154
|
+
return text(await browseCollection(handle, source, limit, maxPrice, condition));
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
return textError(errorMessage(e));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
server.registerTool("find_by_attribute", {
|
|
161
|
+
title: "Find by attribute",
|
|
162
|
+
description: "Listings filtered by color, exterior material or condition, plus the common values in the sample.",
|
|
163
|
+
inputSchema: z.object({
|
|
164
|
+
color: z.string().describe("Color keyword, e.g. black, gold, blue.").optional(),
|
|
165
|
+
material: z.string().describe("Exterior material, e.g. leather, canvas, exotic.").optional(),
|
|
166
|
+
condition: z.string().describe("excellent, great, very good, good, fair.").optional(),
|
|
167
|
+
max_price: z.string().describe("Max USD price.").optional(),
|
|
168
|
+
limit: z.string().describe("Max results (default 10, max 25).").optional(),
|
|
169
|
+
sample: z.string().describe("Listings sampled (default 200, max 500).").optional()
|
|
170
|
+
}),
|
|
171
|
+
annotations: READ_ONLY,
|
|
172
|
+
}, async ({ color, material, condition, max_price: maxPrice, limit, sample }) => {
|
|
173
|
+
try {
|
|
174
|
+
return text(await find_by_attribute(color, material, condition, maxPrice, limit, sample));
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
return textError(errorMessage(e));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
server.registerTool("cross_market_match", {
|
|
181
|
+
title: "Same model priced on both resale sites",
|
|
182
|
+
description: "Finds the same Fendi model on both Fashionphile and Rebag and prices them side by side, with the cheapest listing, the cheapest one actually buyable, the spread, and which site is cheaper. The two sites have different handles for the same bag, so the existing tools cannot join the catalogs at all. The join is on the model name in the title and is explicitly limited: it cannot tell a Baguette from a Baguette II and treats every colourway of a model as one comparison.",
|
|
183
|
+
inputSchema: z.object({
|
|
184
|
+
model: z.string().optional().describe("Model name, e.g. Baguette, Marmont, Birkin, Kelly, Peekaboo, Pochette, Speedy, Monogram, Alma."),
|
|
185
|
+
query: z.string().optional().describe("Extra title words to narrow within the model, e.g. 'quilted flap'."),
|
|
186
|
+
pages: z.number().int().min(1).max(4).default(2).describe("Catalog pages per site, default 2. Each site caps a page at 250 products."),
|
|
187
|
+
limit: z.number().int().min(1).max(25).default(6).describe("Models to compare, default 6"),
|
|
188
|
+
}),
|
|
189
|
+
annotations: READ_ONLY,
|
|
190
|
+
}, async (args) => {
|
|
191
|
+
// ---- paywall: cross_market_match ----
|
|
192
|
+
const paywallMessage = await premiumRequired("cross_market_match");
|
|
193
|
+
if (paywallMessage)
|
|
194
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
195
|
+
// ---- paywall: end ----
|
|
196
|
+
try {
|
|
197
|
+
return text(await crossMarketMatch(args));
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return textError(errorMessage(e));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
server.registerTool("price_ladder", {
|
|
204
|
+
title: "Price ladder and the real buyable floor",
|
|
205
|
+
description: "For a model on each resale site, separates the cheapest asking price from the cheapest one that can actually be bought, since the cheapest listing on these sites is often already sold out and is therefore not a price anyone can pay. Breaks the range down by stated condition, and flags an available listing priced far below the median as a possible misprice, a defect, or a bait listing. Says that neither site publishes sold prices, so a low ask cannot be told from a high one that has not sold.",
|
|
206
|
+
inputSchema: z.object({
|
|
207
|
+
model: z.string().optional().describe("Model name, e.g. Baguette, Kelly, Peekaboo."),
|
|
208
|
+
query: z.string().optional().describe("Extra title words to narrow within the model."),
|
|
209
|
+
pages: z.number().int().min(1).max(4).default(2).describe("Catalog pages per site, default 2"),
|
|
210
|
+
}),
|
|
211
|
+
annotations: READ_ONLY,
|
|
212
|
+
}, async (args) => {
|
|
213
|
+
// ---- paywall: price_ladder ----
|
|
214
|
+
const paywallMessage = await premiumRequired("price_ladder");
|
|
215
|
+
if (paywallMessage)
|
|
216
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
217
|
+
// ---- paywall: end ----
|
|
218
|
+
try {
|
|
219
|
+
return text(await priceLadder(args));
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
return textError(errorMessage(e));
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
server.registerTool("source_taxonomy", {
|
|
226
|
+
title: "Compare the two sites' product taxonomies",
|
|
227
|
+
description: "Lists the product_type values each resale site uses with counts, then shows how little the two vocabularies overlap. They are disjoint, so a category filter on one site means nothing on the other and a cross-site count by category is not like-for-like, which is why cross_market_match joins on the model name instead. Also records that neither site's tags form a usable facet tree: they are campaign and syndication markers.",
|
|
228
|
+
inputSchema: z.object({
|
|
229
|
+
source: z.string().optional().describe("Restrict to one site: fashionphile or rebag. Omit for both."),
|
|
230
|
+
top: z.number().int().min(1).max(100).default(20).describe("Type values to list per site, default 20"),
|
|
231
|
+
}),
|
|
232
|
+
annotations: READ_ONLY,
|
|
233
|
+
}, async (args) => {
|
|
234
|
+
try {
|
|
235
|
+
return text(await sourceTaxonomy(args));
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
return textError(errorMessage(e));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
75
241
|
return server;
|
|
76
242
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fendi-mcp",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Fendi resale listings from Fashionphile and Rebag
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Fendi resale listings aggregated from Fashionphile and Rebag public catalogs (prices in USD).",
|
|
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"
|