mcp-marmiton 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +310 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1334 -0
- package/dist/index.js.map +1 -0
- package/dist/marmiton/client.d.ts +122 -0
- package/dist/marmiton/client.js +631 -0
- package/dist/marmiton/client.js.map +1 -0
- package/package.json +82 -0
- package/server.json +35 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1334 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/version.ts
|
|
7
|
+
var PKG_VERSION = "0.1.0";
|
|
8
|
+
var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
|
|
9
|
+
|
|
10
|
+
// src/config.ts
|
|
11
|
+
var DEFAULT_USER_AGENT = `mcp-marmiton v${PKG_VERSION} (${REPO_URL})`;
|
|
12
|
+
var DEFAULTS = {
|
|
13
|
+
minIntervalMs: 1e3,
|
|
14
|
+
timeoutMs: 15e3,
|
|
15
|
+
maxRetries: 3,
|
|
16
|
+
cacheTtlMs: 15 * 60 * 1e3,
|
|
17
|
+
cacheMaxEntries: 200,
|
|
18
|
+
logLevel: "error"
|
|
19
|
+
};
|
|
20
|
+
var MIN_ALLOWED_INTERVAL_MS = 500;
|
|
21
|
+
var LOG_LEVELS = ["silent", "error", "info", "debug"];
|
|
22
|
+
function readNumber(name, env, range) {
|
|
23
|
+
const raw = env[name];
|
|
24
|
+
if (raw === void 0 || raw.trim() === "") return range.fallback;
|
|
25
|
+
const parsed = Number(raw);
|
|
26
|
+
if (!Number.isFinite(parsed)) {
|
|
27
|
+
warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
|
|
28
|
+
return range.fallback;
|
|
29
|
+
}
|
|
30
|
+
const rounded = Math.round(parsed);
|
|
31
|
+
if (rounded < range.min || rounded > range.max) {
|
|
32
|
+
warn(
|
|
33
|
+
`${name}=${raw} is outside the accepted range ${range.min}-${range.max} and was ignored; using ${range.fallback}`
|
|
34
|
+
);
|
|
35
|
+
return range.fallback;
|
|
36
|
+
}
|
|
37
|
+
return rounded;
|
|
38
|
+
}
|
|
39
|
+
function warn(message) {
|
|
40
|
+
process.stderr.write(`[mcp-marmiton] ${message}
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
function readInterval(env) {
|
|
44
|
+
const raw = env.MARMITON_MIN_INTERVAL_MS;
|
|
45
|
+
if (raw === void 0 || raw.trim() === "") return DEFAULTS.minIntervalMs;
|
|
46
|
+
const parsed = Number(raw);
|
|
47
|
+
if (!Number.isFinite(parsed)) {
|
|
48
|
+
warn(`MARMITON_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
|
|
49
|
+
return DEFAULTS.minIntervalMs;
|
|
50
|
+
}
|
|
51
|
+
const rounded = Math.round(parsed);
|
|
52
|
+
if (rounded < MIN_ALLOWED_INTERVAL_MS) {
|
|
53
|
+
warn(
|
|
54
|
+
`MARMITON_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; using ${DEFAULTS.minIntervalMs}ms. Marmiton serves these pages for readers, so this client paces itself.`
|
|
55
|
+
);
|
|
56
|
+
return DEFAULTS.minIntervalMs;
|
|
57
|
+
}
|
|
58
|
+
return Math.min(6e4, rounded);
|
|
59
|
+
}
|
|
60
|
+
function loadConfig(env = process.env) {
|
|
61
|
+
const rawUserAgent = env.MARMITON_USER_AGENT?.trim();
|
|
62
|
+
const rawLogLevel = env.MARMITON_LOG_LEVEL?.trim().toLowerCase();
|
|
63
|
+
let logLevel = DEFAULTS.logLevel;
|
|
64
|
+
if (rawLogLevel) {
|
|
65
|
+
if (LOG_LEVELS.includes(rawLogLevel)) {
|
|
66
|
+
logLevel = rawLogLevel;
|
|
67
|
+
} else {
|
|
68
|
+
warn(`MARMITON_LOG_LEVEL="${rawLogLevel}" is unknown, using "${DEFAULTS.logLevel}"`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
userAgent: rawUserAgent || DEFAULT_USER_AGENT,
|
|
73
|
+
minIntervalMs: readInterval(env),
|
|
74
|
+
timeoutMs: readNumber("MARMITON_TIMEOUT_MS", env, {
|
|
75
|
+
min: 1e3,
|
|
76
|
+
max: 12e4,
|
|
77
|
+
fallback: DEFAULTS.timeoutMs
|
|
78
|
+
}),
|
|
79
|
+
maxRetries: readNumber("MARMITON_MAX_RETRIES", env, {
|
|
80
|
+
min: 0,
|
|
81
|
+
max: 10,
|
|
82
|
+
fallback: DEFAULTS.maxRetries
|
|
83
|
+
}),
|
|
84
|
+
cacheTtlMs: readNumber("MARMITON_CACHE_TTL_MS", env, {
|
|
85
|
+
min: 0,
|
|
86
|
+
max: 24 * 60 * 60 * 1e3,
|
|
87
|
+
fallback: DEFAULTS.cacheTtlMs
|
|
88
|
+
}),
|
|
89
|
+
cacheMaxEntries: readNumber("MARMITON_CACHE_MAX_ENTRIES", env, {
|
|
90
|
+
min: 0,
|
|
91
|
+
max: 1e4,
|
|
92
|
+
fallback: DEFAULTS.cacheMaxEntries
|
|
93
|
+
}),
|
|
94
|
+
logLevel
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
var LEVEL_RANK = { silent: 0, error: 1, info: 2, debug: 3 };
|
|
98
|
+
function createLogger(level) {
|
|
99
|
+
const emit = (at, message) => {
|
|
100
|
+
if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {
|
|
101
|
+
process.stderr.write(`[mcp-marmiton] ${message}
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
error: (message) => emit("error", message),
|
|
107
|
+
info: (message) => emit("info", message),
|
|
108
|
+
debug: (message) => emit("debug", message)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/server.ts
|
|
113
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
114
|
+
|
|
115
|
+
// src/marmiton/cache.ts
|
|
116
|
+
var TtlLruCache = class {
|
|
117
|
+
constructor(maxEntries, ttlMs) {
|
|
118
|
+
this.maxEntries = maxEntries;
|
|
119
|
+
this.ttlMs = ttlMs;
|
|
120
|
+
}
|
|
121
|
+
maxEntries;
|
|
122
|
+
ttlMs;
|
|
123
|
+
store = /* @__PURE__ */ new Map();
|
|
124
|
+
get(key) {
|
|
125
|
+
const entry = this.store.get(key);
|
|
126
|
+
if (!entry) return void 0;
|
|
127
|
+
if (Date.now() >= entry.expiresAt) {
|
|
128
|
+
this.store.delete(key);
|
|
129
|
+
return void 0;
|
|
130
|
+
}
|
|
131
|
+
this.store.delete(key);
|
|
132
|
+
this.store.set(key, entry);
|
|
133
|
+
return entry.value;
|
|
134
|
+
}
|
|
135
|
+
set(key, value) {
|
|
136
|
+
if (this.maxEntries <= 0 || this.ttlMs <= 0) return;
|
|
137
|
+
this.store.delete(key);
|
|
138
|
+
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
139
|
+
while (this.store.size > this.maxEntries) {
|
|
140
|
+
const oldest = this.store.keys().next();
|
|
141
|
+
if (oldest.done) break;
|
|
142
|
+
this.store.delete(oldest.value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
get size() {
|
|
146
|
+
return this.store.size;
|
|
147
|
+
}
|
|
148
|
+
clear() {
|
|
149
|
+
this.store.clear();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// src/errors.ts
|
|
154
|
+
var MarmitonError = class extends Error {
|
|
155
|
+
constructor(code, message, details = {}) {
|
|
156
|
+
super(message);
|
|
157
|
+
this.code = code;
|
|
158
|
+
this.details = details;
|
|
159
|
+
this.name = "MarmitonError";
|
|
160
|
+
}
|
|
161
|
+
code;
|
|
162
|
+
details;
|
|
163
|
+
};
|
|
164
|
+
var ISSUES_URL = "https://github.com/smeet666/mcp-marmiton/issues";
|
|
165
|
+
function notFound(url, what) {
|
|
166
|
+
return new MarmitonError("not_found", `Marmiton has no recipe at ${what}.`, {
|
|
167
|
+
url,
|
|
168
|
+
status: 404,
|
|
169
|
+
hint: "Use search_recipes to find a recipe and its id, then call this tool with that id."
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function invalidInput(message, hint) {
|
|
173
|
+
return new MarmitonError("invalid_input", message, hint ? { hint } : {});
|
|
174
|
+
}
|
|
175
|
+
function rateLimited(url, retryAfterMs) {
|
|
176
|
+
return new MarmitonError(
|
|
177
|
+
"rate_limited",
|
|
178
|
+
"Marmiton is rate limiting this client. This does NOT mean the recipe does not exist.",
|
|
179
|
+
{
|
|
180
|
+
url,
|
|
181
|
+
retryAfterMs,
|
|
182
|
+
hint: `Wait about ${Math.ceil(retryAfterMs / 1e3)} seconds, then call the same tool again with the same arguments. If it keeps happening, raise MARMITON_MIN_INTERVAL_MS.`
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
function parseFailure(url, what) {
|
|
187
|
+
return new MarmitonError(
|
|
188
|
+
"parse_failure",
|
|
189
|
+
`The page loaded but the expected structured recipe data was not found (${what}). Marmiton may have changed how it publishes it.`,
|
|
190
|
+
{ url, hint: `Please report this, with the recipe you asked for, at ${ISSUES_URL}` }
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
function upstreamError(url, status) {
|
|
194
|
+
return new MarmitonError("network_error", `Marmiton returned HTTP ${status}.`, {
|
|
195
|
+
url,
|
|
196
|
+
status,
|
|
197
|
+
...status >= 500 ? { hint: "This is a problem on Marmiton's side. Try again shortly." } : {}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/marmiton/rateLimiter.ts
|
|
202
|
+
var DEFAULT_MAX_INTERVAL_MS = 1e4;
|
|
203
|
+
var RateLimiter = class {
|
|
204
|
+
baseIntervalMs;
|
|
205
|
+
maxIntervalMs;
|
|
206
|
+
intervalMs;
|
|
207
|
+
/** Tail of the queue: each task chains onto the previous one. */
|
|
208
|
+
tail = Promise.resolve();
|
|
209
|
+
lastStart = 0;
|
|
210
|
+
constructor(options) {
|
|
211
|
+
this.baseIntervalMs = Math.max(0, options.minIntervalMs);
|
|
212
|
+
this.maxIntervalMs = Math.max(
|
|
213
|
+
this.baseIntervalMs,
|
|
214
|
+
options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS
|
|
215
|
+
);
|
|
216
|
+
this.intervalMs = this.baseIntervalMs;
|
|
217
|
+
}
|
|
218
|
+
get currentIntervalMs() {
|
|
219
|
+
return this.intervalMs;
|
|
220
|
+
}
|
|
221
|
+
/** Queue a task. Tasks run in call order, one at a time. */
|
|
222
|
+
schedule(task) {
|
|
223
|
+
const run = this.tail.then(async () => {
|
|
224
|
+
await this.waitForSlot();
|
|
225
|
+
this.lastStart = Date.now();
|
|
226
|
+
return task();
|
|
227
|
+
});
|
|
228
|
+
this.tail = run.then(
|
|
229
|
+
() => void 0,
|
|
230
|
+
() => void 0
|
|
231
|
+
);
|
|
232
|
+
return run;
|
|
233
|
+
}
|
|
234
|
+
/** Called after the site signals it is under load. */
|
|
235
|
+
penalize() {
|
|
236
|
+
const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;
|
|
237
|
+
this.intervalMs = Math.min(this.maxIntervalMs, next);
|
|
238
|
+
}
|
|
239
|
+
/** Called after a success, so a single hiccup does not slow things down forever. */
|
|
240
|
+
relax() {
|
|
241
|
+
this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
|
|
242
|
+
}
|
|
243
|
+
async waitForSlot() {
|
|
244
|
+
if (this.intervalMs === 0 || this.lastStart === 0) return;
|
|
245
|
+
const elapsed = Date.now() - this.lastStart;
|
|
246
|
+
const remaining = this.intervalMs - elapsed;
|
|
247
|
+
if (remaining > 0) await sleep(remaining);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
function sleep(ms) {
|
|
251
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/marmiton/http.ts
|
|
255
|
+
var BACKOFF_BASE_MS = 2e3;
|
|
256
|
+
var BACKOFF_FACTOR = 2;
|
|
257
|
+
var BACKOFF_MAX_MS = 2e4;
|
|
258
|
+
var MIN_PLAUSIBLE_HTML = 2e3;
|
|
259
|
+
function backoffDelay(attempt, random = Math.random) {
|
|
260
|
+
const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
|
|
261
|
+
return Math.round(capped * (0.5 + random() * 0.5));
|
|
262
|
+
}
|
|
263
|
+
async function fetchHtml(url, deps) {
|
|
264
|
+
const { config, limiter, logger } = deps;
|
|
265
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
266
|
+
return limiter.schedule(async () => {
|
|
267
|
+
let lastError;
|
|
268
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
|
|
269
|
+
if (attempt > 0) {
|
|
270
|
+
const delay = backoffDelay(attempt - 1);
|
|
271
|
+
logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
|
|
272
|
+
await sleep(delay);
|
|
273
|
+
}
|
|
274
|
+
let status;
|
|
275
|
+
let body;
|
|
276
|
+
try {
|
|
277
|
+
const response = await doFetch(url, {
|
|
278
|
+
headers: {
|
|
279
|
+
"User-Agent": config.userAgent,
|
|
280
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
281
|
+
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8"
|
|
282
|
+
},
|
|
283
|
+
redirect: "follow",
|
|
284
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
285
|
+
});
|
|
286
|
+
status = response.status;
|
|
287
|
+
body = await response.text();
|
|
288
|
+
} catch (error) {
|
|
289
|
+
lastError = asTransportError(error, url);
|
|
290
|
+
logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (status === 429 || status === 503) {
|
|
294
|
+
limiter.penalize();
|
|
295
|
+
lastError = rateLimited(url, backoffDelay(attempt));
|
|
296
|
+
logger.info(`rate limited on ${url}, interval now ${limiter.currentIntervalMs}ms`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (status === 404) throw notFound(url, "that address");
|
|
300
|
+
if (status >= 500) {
|
|
301
|
+
lastError = upstreamError(url, status);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (status >= 400) throw upstreamError(url, status);
|
|
305
|
+
const trimmed = body.trim();
|
|
306
|
+
if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\/html>/i.test(trimmed)) {
|
|
307
|
+
limiter.penalize();
|
|
308
|
+
lastError = rateLimited(url, backoffDelay(attempt));
|
|
309
|
+
logger.info(`implausibly short body on ${url}, treating as rate limiting`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
limiter.relax();
|
|
313
|
+
return body;
|
|
314
|
+
}
|
|
315
|
+
throw lastError ?? new MarmitonError("network_error", `Could not fetch ${url}.`, { url });
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
function asTransportError(error, url) {
|
|
319
|
+
if (error instanceof MarmitonError) return error;
|
|
320
|
+
const name = error instanceof Error ? error.name : "";
|
|
321
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
322
|
+
return new MarmitonError("timeout", "Marmiton did not answer in time.", {
|
|
323
|
+
url,
|
|
324
|
+
hint: "Raise MARMITON_TIMEOUT_MS if this happens often on a slow connection."
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
328
|
+
return new MarmitonError("network_error", `Could not reach Marmiton: ${message}`, { url });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/recipe/duration.ts
|
|
332
|
+
var ISO_DURATION = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
|
333
|
+
function parseIsoDuration(value) {
|
|
334
|
+
if (typeof value !== "string") return null;
|
|
335
|
+
const match = ISO_DURATION.exec(value.trim().toUpperCase());
|
|
336
|
+
if (!match) return null;
|
|
337
|
+
const [, days, hours, minutes, seconds] = match;
|
|
338
|
+
const total = Number(days ?? 0) * 1440 + Number(hours ?? 0) * 60 + Number(minutes ?? 0) + Number(seconds ?? 0) / 60;
|
|
339
|
+
if (total === 0 && !minutes && !hours && !days && !seconds) return null;
|
|
340
|
+
return Math.round(total);
|
|
341
|
+
}
|
|
342
|
+
function formatMinutes(minutes) {
|
|
343
|
+
if (minutes === null || !Number.isFinite(minutes) || minutes <= 0) return null;
|
|
344
|
+
const hours = Math.floor(minutes / 60);
|
|
345
|
+
const rest = minutes % 60;
|
|
346
|
+
if (hours === 0) return `${rest} min`;
|
|
347
|
+
if (rest === 0) return `${hours} h`;
|
|
348
|
+
return `${hours} h ${String(rest).padStart(2, "0")}`;
|
|
349
|
+
}
|
|
350
|
+
function parseYield(value) {
|
|
351
|
+
const text = typeof value === "number" && Number.isFinite(value) ? String(value) : typeof value === "string" ? value.trim() : Array.isArray(value) ? String(value[0] ?? "").trim() : "";
|
|
352
|
+
if (!text) return { count: null, unit: null, text: "" };
|
|
353
|
+
const match = /^(\d+(?:[.,]\d+)?)\s*(.*)$/.exec(text);
|
|
354
|
+
if (!match) return { count: null, unit: null, text };
|
|
355
|
+
const count = Number(match[1].replace(",", "."));
|
|
356
|
+
const unit = (match[2] ?? "").trim() || null;
|
|
357
|
+
return { count: Number.isFinite(count) ? count : null, unit, text };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/marmiton/jsonld.ts
|
|
361
|
+
var LD_JSON_BLOCK = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
362
|
+
function extractJsonLdNodes(html) {
|
|
363
|
+
const nodes = [];
|
|
364
|
+
LD_JSON_BLOCK.lastIndex = 0;
|
|
365
|
+
let match;
|
|
366
|
+
while ((match = LD_JSON_BLOCK.exec(html)) !== null) {
|
|
367
|
+
const raw = match[1]?.trim();
|
|
368
|
+
if (!raw) continue;
|
|
369
|
+
let parsed;
|
|
370
|
+
try {
|
|
371
|
+
parsed = JSON.parse(raw);
|
|
372
|
+
} catch {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
for (const node of flatten(parsed)) nodes.push(node);
|
|
376
|
+
}
|
|
377
|
+
return nodes;
|
|
378
|
+
}
|
|
379
|
+
function flatten(value) {
|
|
380
|
+
if (Array.isArray(value)) return value.flatMap(flatten);
|
|
381
|
+
if (typeof value !== "object" || value === null) return [];
|
|
382
|
+
const node = value;
|
|
383
|
+
const graph = node["@graph"];
|
|
384
|
+
if (graph !== void 0) return flatten(graph);
|
|
385
|
+
return [node];
|
|
386
|
+
}
|
|
387
|
+
function hasType(node, type) {
|
|
388
|
+
if (typeof node !== "object" || node === null) return false;
|
|
389
|
+
const declared = node["@type"];
|
|
390
|
+
if (typeof declared === "string") return declared === type;
|
|
391
|
+
if (Array.isArray(declared)) return declared.includes(type);
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
function findNodeOfType(nodes, type) {
|
|
395
|
+
const found = nodes.find((node) => hasType(node, type));
|
|
396
|
+
return found ?? null;
|
|
397
|
+
}
|
|
398
|
+
function asString(value) {
|
|
399
|
+
if (typeof value === "string") return value.trim() || null;
|
|
400
|
+
if (Array.isArray(value)) {
|
|
401
|
+
for (const entry of value) {
|
|
402
|
+
const found = asString(entry);
|
|
403
|
+
if (found) return found;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (typeof value === "object" && value !== null) {
|
|
407
|
+
const url = value.url;
|
|
408
|
+
if (typeof url === "string") return url.trim() || null;
|
|
409
|
+
const name = value.name;
|
|
410
|
+
if (typeof name === "string") return name.trim() || null;
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
function asNumber(value) {
|
|
415
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
416
|
+
if (typeof value === "string") {
|
|
417
|
+
const parsed = Number(value.replace(",", "."));
|
|
418
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
function asStringList(value) {
|
|
423
|
+
if (typeof value === "string") return [value.trim()].filter(Boolean);
|
|
424
|
+
if (!Array.isArray(value)) return [];
|
|
425
|
+
const out = [];
|
|
426
|
+
for (const entry of value) {
|
|
427
|
+
if (typeof entry === "string") {
|
|
428
|
+
const text = entry.trim();
|
|
429
|
+
if (text) out.push(text);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (typeof entry === "object" && entry !== null) {
|
|
433
|
+
const record = entry;
|
|
434
|
+
const text = asString(record.text) ?? asString(record.name);
|
|
435
|
+
if (text) out.push(text);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/marmiton/urls.ts
|
|
442
|
+
var BASE_URL = "https://www.marmiton.org";
|
|
443
|
+
var RECIPE_PATH_RE = /\/recettes\/recette[_-][a-z0-9%\-.]*?_(\d+)\.aspx/i;
|
|
444
|
+
var ALLOWED_HOSTS = /* @__PURE__ */ new Set(["marmiton.org", "www.marmiton.org"]);
|
|
445
|
+
function buildSearchUrl(query) {
|
|
446
|
+
const url = new URL(`${BASE_URL}/recettes/recherche.aspx`);
|
|
447
|
+
url.searchParams.set("aqt", query);
|
|
448
|
+
return url.toString();
|
|
449
|
+
}
|
|
450
|
+
function buildRecipeUrl(id) {
|
|
451
|
+
return `${BASE_URL}/recettes/recette_r_${id}.aspx`;
|
|
452
|
+
}
|
|
453
|
+
function isMarmitonHost(rawUrl) {
|
|
454
|
+
let parsed;
|
|
455
|
+
try {
|
|
456
|
+
parsed = new URL(rawUrl);
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
|
461
|
+
return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());
|
|
462
|
+
}
|
|
463
|
+
function extractRecipeId(hrefOrUrl) {
|
|
464
|
+
let path = hrefOrUrl;
|
|
465
|
+
if (/^https?:\/\//i.test(hrefOrUrl)) {
|
|
466
|
+
if (!isMarmitonHost(hrefOrUrl)) return null;
|
|
467
|
+
path = new URL(hrefOrUrl).pathname;
|
|
468
|
+
}
|
|
469
|
+
const match = RECIPE_PATH_RE.exec(path);
|
|
470
|
+
return match?.[1] ?? null;
|
|
471
|
+
}
|
|
472
|
+
function resolveRecipeRef(input) {
|
|
473
|
+
if (input.id) {
|
|
474
|
+
if (!/^\d+$/.test(input.id)) {
|
|
475
|
+
throw invalidInput(
|
|
476
|
+
`"${input.id}" is not a Marmiton recipe id.`,
|
|
477
|
+
"Ids are digits only, as returned by search_recipes."
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return { id: input.id, url: buildRecipeUrl(input.id) };
|
|
481
|
+
}
|
|
482
|
+
if (input.url) {
|
|
483
|
+
if (!isMarmitonHost(input.url)) {
|
|
484
|
+
throw invalidInput(
|
|
485
|
+
"Only marmiton.org URLs are accepted.",
|
|
486
|
+
"Pass the url returned by search_recipes, or use the numeric id instead."
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const id = extractRecipeId(input.url);
|
|
490
|
+
if (!id) {
|
|
491
|
+
throw invalidInput(
|
|
492
|
+
"That Marmiton URL is not a recipe page.",
|
|
493
|
+
"Recipe pages look like https://www.marmiton.org/recettes/recette_tarte-aux-pommes_18588.aspx"
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
return { id, url: input.url };
|
|
497
|
+
}
|
|
498
|
+
throw invalidInput("Either 'id' or 'url' must be provided.");
|
|
499
|
+
}
|
|
500
|
+
function toAbsoluteUrl(href) {
|
|
501
|
+
return new URL(href, BASE_URL).toString();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/marmiton/parseRecipe.ts
|
|
505
|
+
function parseRecipePage(html, options) {
|
|
506
|
+
const nodes = extractJsonLdNodes(html);
|
|
507
|
+
const node = findNodeOfType(nodes, "Recipe");
|
|
508
|
+
if (!node) {
|
|
509
|
+
throw parseFailure(options.url, "no schema.org Recipe node in the page");
|
|
510
|
+
}
|
|
511
|
+
const title = asString(node.name);
|
|
512
|
+
const ingredients = asStringList(node.recipeIngredient);
|
|
513
|
+
const steps = asStringList(node.recipeInstructions);
|
|
514
|
+
if (!title) throw parseFailure(options.url, "the Recipe node has no name");
|
|
515
|
+
if (ingredients.length === 0) {
|
|
516
|
+
throw parseFailure(options.url, "the Recipe node carries no ingredient list");
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
id: options.id,
|
|
520
|
+
title,
|
|
521
|
+
url: asString(node.mainEntityOfPage) ?? options.url,
|
|
522
|
+
imageUrl: asString(node.image),
|
|
523
|
+
ingredients,
|
|
524
|
+
steps,
|
|
525
|
+
recipeYield: parseYield(node.recipeYield),
|
|
526
|
+
prepMinutes: parseIsoDuration(node.prepTime),
|
|
527
|
+
cookMinutes: parseIsoDuration(node.cookTime),
|
|
528
|
+
totalMinutes: parseIsoDuration(node.totalTime),
|
|
529
|
+
category: asString(node.recipeCategory),
|
|
530
|
+
author: asString(node.author),
|
|
531
|
+
rating: parseRating(node.aggregateRating),
|
|
532
|
+
nutrition: parseNutrition(node.nutrition)
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function parseRating(value) {
|
|
536
|
+
if (typeof value !== "object" || value === null) return null;
|
|
537
|
+
const record = value;
|
|
538
|
+
const ratingValue = asNumber(record.ratingValue);
|
|
539
|
+
if (ratingValue === null) return null;
|
|
540
|
+
return {
|
|
541
|
+
value: ratingValue,
|
|
542
|
+
count: asNumber(record.ratingCount) ?? asNumber(record.reviewCount),
|
|
543
|
+
best: asNumber(record.bestRating)
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function parseNutrition(value) {
|
|
547
|
+
if (typeof value !== "object" || value === null) return null;
|
|
548
|
+
const record = value;
|
|
549
|
+
const nutrition = {
|
|
550
|
+
calories: asString(record.calories),
|
|
551
|
+
protein: asString(record.proteinContent),
|
|
552
|
+
fat: asString(record.fatContent),
|
|
553
|
+
carbohydrate: asString(record.carbohydrateContent),
|
|
554
|
+
fiber: asString(record.fiberContent),
|
|
555
|
+
sodium: asString(record.sodiumContent),
|
|
556
|
+
servingSize: asString(record.servingSize)
|
|
557
|
+
};
|
|
558
|
+
const hasAny = Object.values(nutrition).some((entry) => entry !== null);
|
|
559
|
+
return hasAny ? nutrition : null;
|
|
560
|
+
}
|
|
561
|
+
function parseSearchPage(html, url) {
|
|
562
|
+
const nodes = extractJsonLdNodes(html);
|
|
563
|
+
const list = findNodeOfType(nodes, "ItemList");
|
|
564
|
+
if (!list) {
|
|
565
|
+
throw parseFailure(url, "no schema.org ItemList node on the search page");
|
|
566
|
+
}
|
|
567
|
+
const elements = list.itemListElement;
|
|
568
|
+
if (!Array.isArray(elements)) {
|
|
569
|
+
throw parseFailure(url, "the ItemList node carries no itemListElement array");
|
|
570
|
+
}
|
|
571
|
+
const results = [];
|
|
572
|
+
let skipped = 0;
|
|
573
|
+
for (const element of elements) {
|
|
574
|
+
if (typeof element !== "object" || element === null) {
|
|
575
|
+
skipped += 1;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const record = element;
|
|
579
|
+
const href = asString(record.url);
|
|
580
|
+
const title = asString(record.name);
|
|
581
|
+
const id = href ? extractRecipeId(href) : null;
|
|
582
|
+
if (!href || !title || !id) {
|
|
583
|
+
skipped += 1;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
results.push({ id, title, url: toAbsoluteUrl(href), imageUrl: asString(record.image) });
|
|
587
|
+
}
|
|
588
|
+
if (results.length === 0 && elements.length > 0) {
|
|
589
|
+
throw parseFailure(url, `${elements.length} list entries but none could be read`);
|
|
590
|
+
}
|
|
591
|
+
if (skipped > 0) {
|
|
592
|
+
process.stderr.write(`[mcp-marmiton] skipped ${skipped} unreadable search entries on ${url}
|
|
593
|
+
`);
|
|
594
|
+
}
|
|
595
|
+
return results;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/marmiton/client.ts
|
|
599
|
+
var MarmitonClient = class {
|
|
600
|
+
config;
|
|
601
|
+
logger;
|
|
602
|
+
limiter;
|
|
603
|
+
cache;
|
|
604
|
+
fetchImpl;
|
|
605
|
+
constructor(options = {}) {
|
|
606
|
+
this.config = options.config ?? loadConfig();
|
|
607
|
+
this.logger = options.logger ?? createLogger(this.config.logLevel);
|
|
608
|
+
this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });
|
|
609
|
+
this.cache = new TtlLruCache(this.config.cacheMaxEntries, this.config.cacheTtlMs);
|
|
610
|
+
this.fetchImpl = options.fetchImpl;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Search recipes.
|
|
614
|
+
*
|
|
615
|
+
* One page only: Marmiton's robots.txt disallows the paginated form of this
|
|
616
|
+
* URL, so there is no page parameter to pass.
|
|
617
|
+
*/
|
|
618
|
+
async search(query) {
|
|
619
|
+
const url = buildSearchUrl(query);
|
|
620
|
+
const { html, cached } = await this.fetchPage(url);
|
|
621
|
+
return { data: parseSearchPage(html, url), cached };
|
|
622
|
+
}
|
|
623
|
+
async getRecipe(ref) {
|
|
624
|
+
const { id, url } = resolveRecipeRef(ref);
|
|
625
|
+
const { html, cached } = await this.fetchPage(url);
|
|
626
|
+
return { data: parseRecipePage(html, { id, url }), cached };
|
|
627
|
+
}
|
|
628
|
+
async fetchPage(url) {
|
|
629
|
+
const hit = this.cache.get(url);
|
|
630
|
+
if (hit !== void 0) {
|
|
631
|
+
this.logger.debug(`cache hit ${url}`);
|
|
632
|
+
return { html: hit, cached: true };
|
|
633
|
+
}
|
|
634
|
+
const html = await fetchHtml(url, {
|
|
635
|
+
config: this.config,
|
|
636
|
+
limiter: this.limiter,
|
|
637
|
+
logger: this.logger,
|
|
638
|
+
...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
|
|
639
|
+
});
|
|
640
|
+
this.cache.set(url, html);
|
|
641
|
+
return { html, cached: false };
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
// src/tools/getRecipe.ts
|
|
646
|
+
import { z as z2 } from "zod";
|
|
647
|
+
|
|
648
|
+
// src/recipe/units.ts
|
|
649
|
+
var UNITS = {
|
|
650
|
+
// Mass
|
|
651
|
+
g: { canonical: "g", kind: "measured" },
|
|
652
|
+
gr: { canonical: "g", kind: "measured" },
|
|
653
|
+
gramme: { canonical: "g", kind: "measured" },
|
|
654
|
+
grammes: { canonical: "g", kind: "measured" },
|
|
655
|
+
kg: { canonical: "kg", kind: "measured" },
|
|
656
|
+
kilo: { canonical: "kg", kind: "measured" },
|
|
657
|
+
kilos: { canonical: "kg", kind: "measured" },
|
|
658
|
+
kilogramme: { canonical: "kg", kind: "measured" },
|
|
659
|
+
mg: { canonical: "mg", kind: "measured" },
|
|
660
|
+
// Volume
|
|
661
|
+
ml: { canonical: "ml", kind: "measured" },
|
|
662
|
+
cl: { canonical: "cl", kind: "measured" },
|
|
663
|
+
dl: { canonical: "dl", kind: "measured" },
|
|
664
|
+
l: { canonical: "l", kind: "measured" },
|
|
665
|
+
litre: { canonical: "l", kind: "measured" },
|
|
666
|
+
litres: { canonical: "l", kind: "measured" },
|
|
667
|
+
// Spoons and cups: real measures, but only in sensible fractions.
|
|
668
|
+
"cuillere a soupe": {
|
|
669
|
+
canonical: "cuill\xE8re \xE0 soupe",
|
|
670
|
+
kind: "portioned",
|
|
671
|
+
plural: "cuill\xE8res \xE0 soupe"
|
|
672
|
+
},
|
|
673
|
+
"cuilleres a soupe": {
|
|
674
|
+
canonical: "cuill\xE8re \xE0 soupe",
|
|
675
|
+
kind: "portioned",
|
|
676
|
+
plural: "cuill\xE8res \xE0 soupe"
|
|
677
|
+
},
|
|
678
|
+
"c a soupe": { canonical: "cuill\xE8re \xE0 soupe", kind: "portioned", plural: "cuill\xE8res \xE0 soupe" },
|
|
679
|
+
"c a s": { canonical: "cuill\xE8re \xE0 soupe", kind: "portioned", plural: "cuill\xE8res \xE0 soupe" },
|
|
680
|
+
cas: { canonical: "cuill\xE8re \xE0 soupe", kind: "portioned", plural: "cuill\xE8res \xE0 soupe" },
|
|
681
|
+
"cuillere a cafe": {
|
|
682
|
+
canonical: "cuill\xE8re \xE0 caf\xE9",
|
|
683
|
+
kind: "portioned",
|
|
684
|
+
plural: "cuill\xE8res \xE0 caf\xE9"
|
|
685
|
+
},
|
|
686
|
+
"cuilleres a cafe": {
|
|
687
|
+
canonical: "cuill\xE8re \xE0 caf\xE9",
|
|
688
|
+
kind: "portioned",
|
|
689
|
+
plural: "cuill\xE8res \xE0 caf\xE9"
|
|
690
|
+
},
|
|
691
|
+
"c a cafe": { canonical: "cuill\xE8re \xE0 caf\xE9", kind: "portioned", plural: "cuill\xE8res \xE0 caf\xE9" },
|
|
692
|
+
"c a c": { canonical: "cuill\xE8re \xE0 caf\xE9", kind: "portioned", plural: "cuill\xE8res \xE0 caf\xE9" },
|
|
693
|
+
cac: { canonical: "cuill\xE8re \xE0 caf\xE9", kind: "portioned", plural: "cuill\xE8res \xE0 caf\xE9" },
|
|
694
|
+
verre: { canonical: "verre", kind: "portioned" },
|
|
695
|
+
verres: { canonical: "verre", kind: "portioned" },
|
|
696
|
+
bol: { canonical: "bol", kind: "portioned" },
|
|
697
|
+
tasse: { canonical: "tasse", kind: "portioned" },
|
|
698
|
+
tasses: { canonical: "tasse", kind: "portioned" },
|
|
699
|
+
// Packaging and natural units: countable, so they round to whole things.
|
|
700
|
+
sachet: { canonical: "sachet", kind: "portioned" },
|
|
701
|
+
sachets: { canonical: "sachet", kind: "portioned" },
|
|
702
|
+
gousse: { canonical: "gousse", kind: "portioned" },
|
|
703
|
+
gousses: { canonical: "gousse", kind: "portioned" },
|
|
704
|
+
tranche: { canonical: "tranche", kind: "portioned" },
|
|
705
|
+
tranches: { canonical: "tranche", kind: "portioned" },
|
|
706
|
+
botte: { canonical: "botte", kind: "portioned" },
|
|
707
|
+
bottes: { canonical: "botte", kind: "portioned" },
|
|
708
|
+
boite: { canonical: "bo\xEEte", kind: "portioned" },
|
|
709
|
+
boites: { canonical: "bo\xEEte", kind: "portioned" },
|
|
710
|
+
pot: { canonical: "pot", kind: "portioned" },
|
|
711
|
+
pots: { canonical: "pot", kind: "portioned" },
|
|
712
|
+
brique: { canonical: "brique", kind: "portioned" },
|
|
713
|
+
feuille: { canonical: "feuille", kind: "portioned" },
|
|
714
|
+
feuilles: { canonical: "feuille", kind: "portioned" },
|
|
715
|
+
branche: { canonical: "branche", kind: "portioned" },
|
|
716
|
+
branches: { canonical: "branche", kind: "portioned" },
|
|
717
|
+
// Deliberately imprecise: the number is a figure of speech.
|
|
718
|
+
pincee: { canonical: "pinc\xE9e", kind: "vague", plural: "pinc\xE9es" },
|
|
719
|
+
pincees: { canonical: "pinc\xE9e", kind: "vague", plural: "pinc\xE9es" },
|
|
720
|
+
trait: { canonical: "trait", kind: "vague" },
|
|
721
|
+
traits: { canonical: "trait", kind: "vague" },
|
|
722
|
+
filet: { canonical: "filet", kind: "vague" },
|
|
723
|
+
filets: { canonical: "filet", kind: "vague" },
|
|
724
|
+
goutte: { canonical: "goutte", kind: "vague", plural: "gouttes" },
|
|
725
|
+
gouttes: { canonical: "goutte", kind: "vague", plural: "gouttes" },
|
|
726
|
+
poignee: { canonical: "poign\xE9e", kind: "vague", plural: "poign\xE9es" },
|
|
727
|
+
poignees: { canonical: "poign\xE9e", kind: "vague", plural: "poign\xE9es" }
|
|
728
|
+
};
|
|
729
|
+
function normalizeUnitKey(text) {
|
|
730
|
+
return text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\./g, " ").replace(/\s+/g, " ").trim();
|
|
731
|
+
}
|
|
732
|
+
var UNIT_KEYS = Object.keys(UNITS).sort((a, b) => b.length - a.length);
|
|
733
|
+
function lookupUnit(text) {
|
|
734
|
+
return UNITS[normalizeUnitKey(text)] ?? null;
|
|
735
|
+
}
|
|
736
|
+
function formatUnit(unit, amount) {
|
|
737
|
+
if (amount < 2) return unit.canonical;
|
|
738
|
+
if (unit.plural) return unit.plural;
|
|
739
|
+
if (/^[a-z]{1,3}$/.test(unit.canonical)) return unit.canonical;
|
|
740
|
+
return `${unit.canonical}s`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/recipe/quantity.ts
|
|
744
|
+
var VULGAR_FRACTIONS = {
|
|
745
|
+
"\xBD": 0.5,
|
|
746
|
+
"\u2153": 1 / 3,
|
|
747
|
+
"\u2154": 2 / 3,
|
|
748
|
+
"\xBC": 0.25,
|
|
749
|
+
"\xBE": 0.75,
|
|
750
|
+
"\u2155": 0.2,
|
|
751
|
+
"\u2159": 1 / 6,
|
|
752
|
+
"\u215B": 0.125
|
|
753
|
+
};
|
|
754
|
+
function parseLeadingQuantity(text) {
|
|
755
|
+
const trimmed = text.trimStart();
|
|
756
|
+
const offset = text.length - trimmed.length;
|
|
757
|
+
const glyph = trimmed[0];
|
|
758
|
+
if (glyph && glyph in VULGAR_FRACTIONS) {
|
|
759
|
+
return { amount: VULGAR_FRACTIONS[glyph], length: offset + 1 };
|
|
760
|
+
}
|
|
761
|
+
const mixed = /^(\d+)\s+(\d+)\s*\/\s*(\d+)/.exec(trimmed);
|
|
762
|
+
if (mixed) {
|
|
763
|
+
const whole = Number(mixed[1]);
|
|
764
|
+
const numerator = Number(mixed[2]);
|
|
765
|
+
const denominator = Number(mixed[3]);
|
|
766
|
+
if (denominator !== 0) {
|
|
767
|
+
return { amount: whole + numerator / denominator, length: offset + mixed[0].length };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const fraction = /^(\d+)\s*\/\s*(\d+)/.exec(trimmed);
|
|
771
|
+
if (fraction) {
|
|
772
|
+
const denominator = Number(fraction[2]);
|
|
773
|
+
if (denominator !== 0) {
|
|
774
|
+
return { amount: Number(fraction[1]) / denominator, length: offset + fraction[0].length };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const decimal = /^(\d+(?:[.,]\d+)?)/.exec(trimmed);
|
|
778
|
+
if (decimal) {
|
|
779
|
+
const amount = Number(decimal[1].replace(",", "."));
|
|
780
|
+
if (Number.isFinite(amount)) {
|
|
781
|
+
return { amount, length: offset + decimal[0].length };
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
function parseIngredient(line) {
|
|
787
|
+
const original = line;
|
|
788
|
+
const text = line.trim();
|
|
789
|
+
const quantity = parseLeadingQuantity(text);
|
|
790
|
+
if (!quantity) {
|
|
791
|
+
return { original, amount: null, unit: null, unitText: null, item: text };
|
|
792
|
+
}
|
|
793
|
+
let rest = text.slice(quantity.length).trimStart();
|
|
794
|
+
let unit = null;
|
|
795
|
+
let unitText = null;
|
|
796
|
+
const normalizedRest = normalizeUnitKey(rest);
|
|
797
|
+
for (const key of UNIT_KEYS) {
|
|
798
|
+
if (normalizedRest === key || normalizedRest.startsWith(`${key} `)) {
|
|
799
|
+
unit = lookupUnit(key);
|
|
800
|
+
const wordCount = key.split(" ").length;
|
|
801
|
+
const words = rest.split(/\s+/);
|
|
802
|
+
unitText = words.slice(0, wordCount).join(" ");
|
|
803
|
+
rest = words.slice(wordCount).join(" ");
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").trim();
|
|
808
|
+
return { original, amount: quantity.amount, unit, unitText, item };
|
|
809
|
+
}
|
|
810
|
+
function formatAmount(amount) {
|
|
811
|
+
if (!Number.isFinite(amount)) return "";
|
|
812
|
+
if (Number.isInteger(amount)) return String(amount);
|
|
813
|
+
const whole = Math.floor(amount);
|
|
814
|
+
const rest = amount - whole;
|
|
815
|
+
const known = [
|
|
816
|
+
[0.25, "1/4"],
|
|
817
|
+
[1 / 3, "1/3"],
|
|
818
|
+
[0.5, "1/2"],
|
|
819
|
+
[2 / 3, "2/3"],
|
|
820
|
+
[0.75, "3/4"]
|
|
821
|
+
];
|
|
822
|
+
for (const [value, label] of known) {
|
|
823
|
+
if (Math.abs(rest - value) < 0.02) {
|
|
824
|
+
return whole > 0 ? `${whole} ${label}` : label;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return String(Math.round(amount * 100) / 100).replace(".", ",");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/recipe/scale.ts
|
|
831
|
+
function roundTo(value, step) {
|
|
832
|
+
return Math.round(Math.round(value / step) * step * 100) / 100;
|
|
833
|
+
}
|
|
834
|
+
function roundMeasured(value) {
|
|
835
|
+
if (value >= 100) return roundTo(value, 5);
|
|
836
|
+
if (value >= 10) return roundTo(value, 1);
|
|
837
|
+
if (value >= 1) return roundTo(value, 0.5);
|
|
838
|
+
return Math.round(value * 100) / 100;
|
|
839
|
+
}
|
|
840
|
+
function roundCountable(value, allowHalves) {
|
|
841
|
+
if (value <= 0) return 0;
|
|
842
|
+
if (value < 1) {
|
|
843
|
+
const USABLE_FRACTIONS = [0.25, 1 / 3, 0.5, 2 / 3, 0.75];
|
|
844
|
+
let closest = USABLE_FRACTIONS[0];
|
|
845
|
+
for (const fraction of USABLE_FRACTIONS) {
|
|
846
|
+
if (Math.abs(value - fraction) < Math.abs(value - closest)) closest = fraction;
|
|
847
|
+
}
|
|
848
|
+
return Math.round(closest * 100) / 100;
|
|
849
|
+
}
|
|
850
|
+
return allowHalves ? roundTo(value, 0.5) : Math.round(value);
|
|
851
|
+
}
|
|
852
|
+
function singularizeIfNeeded(item, amount) {
|
|
853
|
+
if (amount >= 2 || !item) return item;
|
|
854
|
+
const words = item.split(" ");
|
|
855
|
+
const first = words[0] ?? "";
|
|
856
|
+
if (first.length > 3 && /s$/i.test(first) && !/[aiou]s$/i.test(first)) {
|
|
857
|
+
words[0] = first.slice(0, -1);
|
|
858
|
+
}
|
|
859
|
+
return words.join(" ");
|
|
860
|
+
}
|
|
861
|
+
function scaleIngredient(line, options) {
|
|
862
|
+
const { factor } = options;
|
|
863
|
+
const parsed = parseIngredient(line);
|
|
864
|
+
if (parsed.amount === null) {
|
|
865
|
+
return {
|
|
866
|
+
original: parsed.original,
|
|
867
|
+
text: parsed.original,
|
|
868
|
+
amount: null,
|
|
869
|
+
unit: null,
|
|
870
|
+
scaling: "unscaled",
|
|
871
|
+
note: "No quantity given; adjust to taste."
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
const kind = parsed.unit?.kind ?? "countable";
|
|
875
|
+
if (kind === "vague") {
|
|
876
|
+
return {
|
|
877
|
+
original: parsed.original,
|
|
878
|
+
text: parsed.original,
|
|
879
|
+
amount: parsed.amount,
|
|
880
|
+
unit: parsed.unit?.canonical ?? null,
|
|
881
|
+
scaling: "unscaled",
|
|
882
|
+
note: "Measure is approximate by nature; adjust to taste."
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
const raw = parsed.amount * factor;
|
|
886
|
+
let amount;
|
|
887
|
+
let scaling;
|
|
888
|
+
if (kind === "measured") {
|
|
889
|
+
amount = roundMeasured(raw);
|
|
890
|
+
scaling = "scaled";
|
|
891
|
+
} else if (kind === "portioned") {
|
|
892
|
+
const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? "");
|
|
893
|
+
amount = roundCountable(raw, allowHalves);
|
|
894
|
+
scaling = "rounded";
|
|
895
|
+
} else {
|
|
896
|
+
amount = roundCountable(raw, false);
|
|
897
|
+
scaling = "rounded";
|
|
898
|
+
}
|
|
899
|
+
const unitLabel = parsed.unit ? ` ${formatUnit(parsed.unit, amount)}` : "";
|
|
900
|
+
const separator = parsed.unit ? " de " : " ";
|
|
901
|
+
const itemText = parsed.unit ? parsed.item : singularizeIfNeeded(parsed.item, amount);
|
|
902
|
+
const item = itemText ? `${separator}${itemText}` : "";
|
|
903
|
+
const text = `${formatAmount(amount)}${unitLabel}${item}`.trim();
|
|
904
|
+
const result = {
|
|
905
|
+
original: parsed.original,
|
|
906
|
+
text,
|
|
907
|
+
amount,
|
|
908
|
+
unit: parsed.unit?.canonical ?? null,
|
|
909
|
+
scaling
|
|
910
|
+
};
|
|
911
|
+
if (scaling === "rounded" && Math.abs(raw - amount) > 0.01) {
|
|
912
|
+
result.note = `Rounded from ${formatAmount(Math.round(raw * 100) / 100)}.`;
|
|
913
|
+
}
|
|
914
|
+
return result;
|
|
915
|
+
}
|
|
916
|
+
function scaleIngredients(lines, options) {
|
|
917
|
+
return lines.map((line) => scaleIngredient(line, options));
|
|
918
|
+
}
|
|
919
|
+
function passthroughIngredients(lines) {
|
|
920
|
+
return lines.map((line) => {
|
|
921
|
+
const parsed = parseIngredient(line);
|
|
922
|
+
return {
|
|
923
|
+
original: parsed.original,
|
|
924
|
+
text: parsed.original,
|
|
925
|
+
amount: parsed.amount,
|
|
926
|
+
unit: parsed.unit?.canonical ?? null,
|
|
927
|
+
scaling: "unscaled"
|
|
928
|
+
};
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// src/tools/shared.ts
|
|
933
|
+
import { z } from "zod";
|
|
934
|
+
var MAX_TEXT_MIRROR_CHARS = 2e3;
|
|
935
|
+
var scaledIngredientSchema = z.object({
|
|
936
|
+
original: z.string().describe("The ingredient line exactly as Marmiton publishes it."),
|
|
937
|
+
text: z.string().describe("The line after scaling, identical to 'original' when unscaled."),
|
|
938
|
+
amount: z.number().nullable(),
|
|
939
|
+
unit: z.string().nullable(),
|
|
940
|
+
scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
|
|
941
|
+
"'scaled' was multiplied cleanly. 'rounded' was multiplied then rounded to something countable. 'unscaled' was left alone, either because it carries no quantity or because the measure is approximate by nature."
|
|
942
|
+
),
|
|
943
|
+
note: z.string().optional().describe("Why the line was rounded or left alone.")
|
|
944
|
+
});
|
|
945
|
+
function ok(structured, text) {
|
|
946
|
+
return {
|
|
947
|
+
content: [{ type: "text", text: truncate(text, MAX_TEXT_MIRROR_CHARS) }],
|
|
948
|
+
structuredContent: structured
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
function toToolError(error) {
|
|
952
|
+
const known = error instanceof MarmitonError ? error : new MarmitonError("network_error", error instanceof Error ? error.message : String(error));
|
|
953
|
+
const lines = [`[${known.code}] ${known.message}`];
|
|
954
|
+
if (known.details.hint) lines.push(`Hint: ${known.details.hint}`);
|
|
955
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
|
|
956
|
+
}
|
|
957
|
+
function truncate(text, maxChars) {
|
|
958
|
+
if (text.length <= maxChars) return text;
|
|
959
|
+
return `${text.slice(0, maxChars - 1).trimEnd()}\u2026`;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/tools/getRecipe.ts
|
|
963
|
+
var getRecipeDescription = [
|
|
964
|
+
"Read one Marmiton recipe: ingredients, steps, times, category, rating and nutrition.",
|
|
965
|
+
"Give the id returned by search_recipes, or a marmiton.org recipe URL.",
|
|
966
|
+
"Set 'servings' to rescale the quantities. Each ingredient reports how it was handled: 'scaled' for grams",
|
|
967
|
+
"and millilitres, 'rounded' for countable things such as eggs or spoons, and 'unscaled' for lines with no",
|
|
968
|
+
"quantity or with an approximate one such as a pinch of salt. Trust that flag rather than recomputing:",
|
|
969
|
+
"the point of scaling here is to avoid answers like '2.4 eggs'.",
|
|
970
|
+
"Recipes yielding pieces rather than servings are rescaled the same way; check 'yield' to see which.",
|
|
971
|
+
"Always cite 'attribution' when showing a recipe to a user."
|
|
972
|
+
].join(" ");
|
|
973
|
+
var getRecipeInputShape = {
|
|
974
|
+
id: z2.string().regex(/^\d+$/, "Recipe ids are digits only.").optional().describe("Marmiton recipe id, as returned by search_recipes. Preferred over 'url'."),
|
|
975
|
+
url: z2.string().url().optional().describe(
|
|
976
|
+
"Full marmiton.org recipe URL. Only marmiton.org is accepted. Ignored when 'id' is given."
|
|
977
|
+
),
|
|
978
|
+
servings: z2.number().positive().max(500).optional().describe(
|
|
979
|
+
"Rescale the ingredients to this many servings. Omit to get the recipe exactly as published."
|
|
980
|
+
)
|
|
981
|
+
};
|
|
982
|
+
var getRecipeOutputShape = {
|
|
983
|
+
id: z2.string(),
|
|
984
|
+
title: z2.string(),
|
|
985
|
+
url: z2.string(),
|
|
986
|
+
yield: z2.object({
|
|
987
|
+
original_count: z2.number().nullable(),
|
|
988
|
+
original_text: z2.string(),
|
|
989
|
+
requested: z2.number().nullable(),
|
|
990
|
+
unit: z2.string().nullable().describe("What is being counted: 'personnes', 'pi\xE8ces'."),
|
|
991
|
+
factor: z2.number().nullable().describe("Multiplier applied to the quantities.")
|
|
992
|
+
}),
|
|
993
|
+
ingredients: z2.array(scaledIngredientSchema),
|
|
994
|
+
steps: z2.array(z2.string()),
|
|
995
|
+
prep_minutes: z2.number().int().nullable(),
|
|
996
|
+
cook_minutes: z2.number().int().nullable(),
|
|
997
|
+
total_minutes: z2.number().int().nullable(),
|
|
998
|
+
category: z2.string().nullable(),
|
|
999
|
+
author: z2.string().nullable(),
|
|
1000
|
+
rating: z2.object({ value: z2.number(), count: z2.number().nullable(), best: z2.number().nullable() }).nullable(),
|
|
1001
|
+
nutrition: z2.object({
|
|
1002
|
+
calories: z2.string().nullable(),
|
|
1003
|
+
protein: z2.string().nullable(),
|
|
1004
|
+
fat: z2.string().nullable(),
|
|
1005
|
+
carbohydrate: z2.string().nullable(),
|
|
1006
|
+
fiber: z2.string().nullable(),
|
|
1007
|
+
sodium: z2.string().nullable(),
|
|
1008
|
+
serving_size: z2.string().nullable()
|
|
1009
|
+
}).nullable().describe("Nutrition is given for the recipe as published, not rescaled."),
|
|
1010
|
+
attribution: z2.string(),
|
|
1011
|
+
source: z2.literal("marmiton.org"),
|
|
1012
|
+
notes: z2.array(z2.string())
|
|
1013
|
+
};
|
|
1014
|
+
async function runGetRecipe(client, args) {
|
|
1015
|
+
try {
|
|
1016
|
+
const ref = {};
|
|
1017
|
+
if (args.id) ref.id = args.id;
|
|
1018
|
+
else if (args.url) ref.url = args.url;
|
|
1019
|
+
const { data, cached } = await client.getRecipe(ref);
|
|
1020
|
+
const notes = [];
|
|
1021
|
+
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
1022
|
+
const originalCount = data.recipeYield.count;
|
|
1023
|
+
let factor = null;
|
|
1024
|
+
let ingredients;
|
|
1025
|
+
if (args.servings === void 0) {
|
|
1026
|
+
ingredients = passthroughIngredients(data.ingredients);
|
|
1027
|
+
} else if (originalCount === null || originalCount <= 0) {
|
|
1028
|
+
ingredients = passthroughIngredients(data.ingredients);
|
|
1029
|
+
notes.push(
|
|
1030
|
+
`Marmiton does not state how many servings this recipe makes ("${data.recipeYield.text}"), so the quantities are unchanged.`
|
|
1031
|
+
);
|
|
1032
|
+
} else {
|
|
1033
|
+
factor = args.servings / originalCount;
|
|
1034
|
+
ingredients = scaleIngredients(data.ingredients, { factor });
|
|
1035
|
+
const unit = data.recipeYield.unit ?? "portions";
|
|
1036
|
+
notes.push(
|
|
1037
|
+
`Quantities rescaled from ${originalCount} to ${args.servings} ${unit} (factor ${Math.round(factor * 100) / 100}).`
|
|
1038
|
+
);
|
|
1039
|
+
if (ingredients.some((entry) => entry.scaling === "rounded")) {
|
|
1040
|
+
notes.push("Some quantities were rounded to remain usable; see the 'scaling' field.");
|
|
1041
|
+
}
|
|
1042
|
+
if (ingredients.some((entry) => entry.scaling === "unscaled")) {
|
|
1043
|
+
notes.push("Some ingredients carry no usable quantity and were left as published.");
|
|
1044
|
+
}
|
|
1045
|
+
if (data.nutrition) {
|
|
1046
|
+
notes.push(
|
|
1047
|
+
"Nutrition values are for the recipe as published, not for the rescaled amounts."
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
const attribution = `${data.title} \u2014 recette Marmiton \u2014 ${data.url}`;
|
|
1052
|
+
const structured = {
|
|
1053
|
+
id: data.id,
|
|
1054
|
+
title: data.title,
|
|
1055
|
+
url: data.url,
|
|
1056
|
+
yield: {
|
|
1057
|
+
original_count: originalCount,
|
|
1058
|
+
original_text: data.recipeYield.text,
|
|
1059
|
+
requested: args.servings ?? null,
|
|
1060
|
+
unit: data.recipeYield.unit,
|
|
1061
|
+
factor: factor === null ? null : Math.round(factor * 1e3) / 1e3
|
|
1062
|
+
},
|
|
1063
|
+
ingredients,
|
|
1064
|
+
steps: data.steps,
|
|
1065
|
+
prep_minutes: data.prepMinutes,
|
|
1066
|
+
cook_minutes: data.cookMinutes,
|
|
1067
|
+
total_minutes: data.totalMinutes,
|
|
1068
|
+
category: data.category,
|
|
1069
|
+
author: data.author,
|
|
1070
|
+
rating: data.rating,
|
|
1071
|
+
nutrition: data.nutrition ? {
|
|
1072
|
+
calories: data.nutrition.calories,
|
|
1073
|
+
protein: data.nutrition.protein,
|
|
1074
|
+
fat: data.nutrition.fat,
|
|
1075
|
+
carbohydrate: data.nutrition.carbohydrate,
|
|
1076
|
+
fiber: data.nutrition.fiber,
|
|
1077
|
+
sodium: data.nutrition.sodium,
|
|
1078
|
+
serving_size: data.nutrition.servingSize
|
|
1079
|
+
} : null,
|
|
1080
|
+
attribution,
|
|
1081
|
+
source: "marmiton.org",
|
|
1082
|
+
notes
|
|
1083
|
+
};
|
|
1084
|
+
const times = [
|
|
1085
|
+
data.prepMinutes ? `pr\xE9paration ${formatMinutes(data.prepMinutes)}` : "",
|
|
1086
|
+
data.cookMinutes ? `cuisson ${formatMinutes(data.cookMinutes)}` : ""
|
|
1087
|
+
].filter(Boolean).join(", ");
|
|
1088
|
+
const header = [
|
|
1089
|
+
attribution,
|
|
1090
|
+
args.servings !== void 0 && factor !== null ? `Pour ${args.servings} ${data.recipeYield.unit ?? "portions"} (recette pr\xE9vue pour ${data.recipeYield.text}).` : `Recette pour ${data.recipeYield.text || "portions non pr\xE9cis\xE9es"}.`,
|
|
1091
|
+
times
|
|
1092
|
+
].filter(Boolean).join("\n");
|
|
1093
|
+
const ingredientLines = ingredients.map((entry) => {
|
|
1094
|
+
const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
|
|
1095
|
+
return `- ${entry.text}${flag}`;
|
|
1096
|
+
}).join("\n");
|
|
1097
|
+
return ok(structured, `${header}
|
|
1098
|
+
|
|
1099
|
+
Ingr\xE9dients:
|
|
1100
|
+
${ingredientLines}`);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
return toToolError(error);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/tools/scaleIngredients.ts
|
|
1107
|
+
import { z as z3 } from "zod";
|
|
1108
|
+
var scaleIngredientsDescription = [
|
|
1109
|
+
"Rescale a list of ingredient lines to a different number of servings, without contacting any website.",
|
|
1110
|
+
"Give either 'factor' directly, or 'from_servings' and 'to_servings' and the factor is computed.",
|
|
1111
|
+
"Works on any French ingredient list, whatever its source, so it also serves a recipe the user pasted in.",
|
|
1112
|
+
"Quantities in grams or millilitres are multiplied and rounded to readable values; countable things such as",
|
|
1113
|
+
"eggs or spoons are rounded to whole or half units; lines with no quantity, or with an approximate one such",
|
|
1114
|
+
"as a pinch, are returned untouched and flagged. Prefer this over doing the arithmetic yourself."
|
|
1115
|
+
].join(" ");
|
|
1116
|
+
var scaleIngredientsInputShape = {
|
|
1117
|
+
ingredients: z3.array(z3.string().max(300)).min(1).max(100).describe("Ingredient lines, for example ['200 g de farine', '3 oeufs', 'sel']."),
|
|
1118
|
+
factor: z3.number().positive().max(100).optional().describe("Multiplier to apply. Use this or the from/to pair."),
|
|
1119
|
+
from_servings: z3.number().positive().max(500).optional().describe("How many servings the list is written for."),
|
|
1120
|
+
to_servings: z3.number().positive().max(500).optional().describe("How many servings are wanted.")
|
|
1121
|
+
};
|
|
1122
|
+
var scaleIngredientsOutputShape = {
|
|
1123
|
+
factor: z3.number(),
|
|
1124
|
+
ingredients: z3.array(scaledIngredientSchema),
|
|
1125
|
+
scaled_count: z3.number().int(),
|
|
1126
|
+
rounded_count: z3.number().int(),
|
|
1127
|
+
unscaled_count: z3.number().int(),
|
|
1128
|
+
notes: z3.array(z3.string())
|
|
1129
|
+
};
|
|
1130
|
+
function runScaleIngredients(args) {
|
|
1131
|
+
try {
|
|
1132
|
+
let factor;
|
|
1133
|
+
if (args.factor !== void 0) {
|
|
1134
|
+
factor = args.factor;
|
|
1135
|
+
} else if (args.from_servings !== void 0 && args.to_servings !== void 0) {
|
|
1136
|
+
factor = args.to_servings / args.from_servings;
|
|
1137
|
+
} else {
|
|
1138
|
+
throw invalidInput(
|
|
1139
|
+
"Provide either 'factor', or both 'from_servings' and 'to_servings'.",
|
|
1140
|
+
"For example from_servings=6 and to_servings=4, or factor=0.667."
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
const ingredients = scaleIngredients(args.ingredients, { factor });
|
|
1144
|
+
const counts = {
|
|
1145
|
+
scaled: ingredients.filter((entry) => entry.scaling === "scaled").length,
|
|
1146
|
+
rounded: ingredients.filter((entry) => entry.scaling === "rounded").length,
|
|
1147
|
+
unscaled: ingredients.filter((entry) => entry.scaling === "unscaled").length
|
|
1148
|
+
};
|
|
1149
|
+
const notes = [];
|
|
1150
|
+
if (counts.rounded > 0) {
|
|
1151
|
+
notes.push(
|
|
1152
|
+
`${counts.rounded} quantity(ies) were rounded to stay usable, rather than left as fractions.`
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
if (counts.unscaled > 0) {
|
|
1156
|
+
notes.push(
|
|
1157
|
+
`${counts.unscaled} line(s) carry no usable quantity and were returned unchanged; adjust to taste.`
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
const structured = {
|
|
1161
|
+
factor: Math.round(factor * 1e3) / 1e3,
|
|
1162
|
+
ingredients,
|
|
1163
|
+
scaled_count: counts.scaled,
|
|
1164
|
+
rounded_count: counts.rounded,
|
|
1165
|
+
unscaled_count: counts.unscaled,
|
|
1166
|
+
notes
|
|
1167
|
+
};
|
|
1168
|
+
const lines = ingredients.map((entry) => {
|
|
1169
|
+
const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
|
|
1170
|
+
return `- ${entry.text}${flag}`;
|
|
1171
|
+
}).join("\n");
|
|
1172
|
+
return ok(structured, `Facteur ${Math.round(factor * 100) / 100}:
|
|
1173
|
+
${lines}`);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
return toToolError(error);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// src/tools/searchRecipes.ts
|
|
1180
|
+
import { z as z4 } from "zod";
|
|
1181
|
+
var searchRecipesDescription = [
|
|
1182
|
+
"Search Marmiton for recipes by free text: a dish, an ingredient, or both, for example 'tarte aux pommes'",
|
|
1183
|
+
"or 'poulet curry coco'. Marmiton is a French site, so French terms work best.",
|
|
1184
|
+
"Returns candidate recipes with the id and URL needed to read one with get_recipe.",
|
|
1185
|
+
"One call returns one page of results. Marmiton's robots.txt disallows paginating search results, so this",
|
|
1186
|
+
"server does not, and there is no page parameter: narrow the query instead of asking for more pages."
|
|
1187
|
+
].join(" ");
|
|
1188
|
+
var searchRecipesInputShape = {
|
|
1189
|
+
// Deliberately no min(1): an empty string would be rejected by the schema with
|
|
1190
|
+
// a protocol-level validation error, while a whitespace-only one would reach
|
|
1191
|
+
// the tool and come back as invalid_input. Letting both through to the same
|
|
1192
|
+
// check gives callers one error code for one problem.
|
|
1193
|
+
query: z4.string().max(200).describe("What to search for, in French, for example 'tarte aux pommes'."),
|
|
1194
|
+
limit: z4.number().int().min(1).max(30).default(10).describe("Maximum recipes to return from this page of results.")
|
|
1195
|
+
};
|
|
1196
|
+
var searchRecipesOutputShape = {
|
|
1197
|
+
query: z4.string(),
|
|
1198
|
+
results: z4.array(
|
|
1199
|
+
z4.object({
|
|
1200
|
+
id: z4.string().describe("Marmiton recipe id. Pass this to get_recipe."),
|
|
1201
|
+
title: z4.string(),
|
|
1202
|
+
url: z4.string(),
|
|
1203
|
+
image_url: z4.string().nullable()
|
|
1204
|
+
})
|
|
1205
|
+
),
|
|
1206
|
+
result_count: z4.number().int(),
|
|
1207
|
+
total_available: z4.number().int().describe("Recipes on this page before applying 'limit'."),
|
|
1208
|
+
source: z4.literal("marmiton.org"),
|
|
1209
|
+
notes: z4.array(z4.string())
|
|
1210
|
+
};
|
|
1211
|
+
async function runSearchRecipes(client, args) {
|
|
1212
|
+
try {
|
|
1213
|
+
const query = args.query.trim();
|
|
1214
|
+
if (!query) {
|
|
1215
|
+
throw invalidInput(
|
|
1216
|
+
"'query' cannot be empty.",
|
|
1217
|
+
'Give a dish or an ingredient, for example query="tarte aux pommes".'
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
const { data, cached } = await client.search(query);
|
|
1221
|
+
const notes = [];
|
|
1222
|
+
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
1223
|
+
if (data.length > args.limit) {
|
|
1224
|
+
notes.push(`This page holds ${data.length} recipes; showing the first ${args.limit}.`);
|
|
1225
|
+
}
|
|
1226
|
+
if (data.length === 0) {
|
|
1227
|
+
notes.push(
|
|
1228
|
+
"Marmiton found no recipe for this query. Try French terms, or a broader wording. Note that only one page of results is available."
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
const results = data.slice(0, args.limit).map((recipe) => ({
|
|
1232
|
+
id: recipe.id,
|
|
1233
|
+
title: recipe.title,
|
|
1234
|
+
url: recipe.url,
|
|
1235
|
+
image_url: recipe.imageUrl
|
|
1236
|
+
}));
|
|
1237
|
+
const structured = {
|
|
1238
|
+
query,
|
|
1239
|
+
results,
|
|
1240
|
+
result_count: results.length,
|
|
1241
|
+
total_available: data.length,
|
|
1242
|
+
source: "marmiton.org",
|
|
1243
|
+
notes
|
|
1244
|
+
};
|
|
1245
|
+
const header = results.length > 0 ? `${results.length} recette(s) pour "${query}":` : `Aucune recette trouv\xE9e pour "${query}".`;
|
|
1246
|
+
const list = results.map((recipe, index) => `${index + 1}. ${recipe.title} \xB7 id: ${recipe.id}`).join("\n");
|
|
1247
|
+
return ok(structured, `${header}
|
|
1248
|
+
${list}`);
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
return toToolError(error);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/server.ts
|
|
1255
|
+
var READ_ONLY = {
|
|
1256
|
+
readOnlyHint: true,
|
|
1257
|
+
destructiveHint: false,
|
|
1258
|
+
idempotentHint: true,
|
|
1259
|
+
openWorldHint: true
|
|
1260
|
+
};
|
|
1261
|
+
function createServer(options = {}) {
|
|
1262
|
+
const config = options.config ?? loadConfig();
|
|
1263
|
+
const logger = options.logger ?? createLogger(config.logLevel);
|
|
1264
|
+
const client = new MarmitonClient({
|
|
1265
|
+
config,
|
|
1266
|
+
logger,
|
|
1267
|
+
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
1268
|
+
});
|
|
1269
|
+
const server = new McpServer(
|
|
1270
|
+
{ name: "mcp-marmiton", version: PKG_VERSION },
|
|
1271
|
+
{
|
|
1272
|
+
instructions: "Tools for looking up French recipes on Marmiton. No API key is needed. Typical flow: search_recipes to find a recipe and its id, then get_recipe with that id, passing 'servings' when the user wants a different number of people. Do not rescale quantities yourself: get_recipe and scale_ingredients already round countable items sensibly and flag what cannot be scaled, which is what stops answers like '2.4 eggs'. Marmiton disallows paginating search results, so narrow the query instead of asking for more pages. When you show a recipe to a user, credit it and link the source URL."
|
|
1273
|
+
}
|
|
1274
|
+
);
|
|
1275
|
+
server.registerTool(
|
|
1276
|
+
"search_recipes",
|
|
1277
|
+
{
|
|
1278
|
+
title: "Search recipes",
|
|
1279
|
+
description: searchRecipesDescription,
|
|
1280
|
+
inputSchema: searchRecipesInputShape,
|
|
1281
|
+
outputSchema: searchRecipesOutputShape,
|
|
1282
|
+
annotations: READ_ONLY
|
|
1283
|
+
},
|
|
1284
|
+
async (args) => runSearchRecipes(client, args)
|
|
1285
|
+
);
|
|
1286
|
+
server.registerTool(
|
|
1287
|
+
"get_recipe",
|
|
1288
|
+
{
|
|
1289
|
+
title: "Get a recipe",
|
|
1290
|
+
description: getRecipeDescription,
|
|
1291
|
+
inputSchema: getRecipeInputShape,
|
|
1292
|
+
outputSchema: getRecipeOutputShape,
|
|
1293
|
+
annotations: READ_ONLY
|
|
1294
|
+
},
|
|
1295
|
+
async (args) => runGetRecipe(client, args)
|
|
1296
|
+
);
|
|
1297
|
+
server.registerTool(
|
|
1298
|
+
"scale_ingredients",
|
|
1299
|
+
{
|
|
1300
|
+
title: "Scale an ingredient list",
|
|
1301
|
+
description: scaleIngredientsDescription,
|
|
1302
|
+
inputSchema: scaleIngredientsInputShape,
|
|
1303
|
+
outputSchema: scaleIngredientsOutputShape,
|
|
1304
|
+
annotations: READ_ONLY
|
|
1305
|
+
},
|
|
1306
|
+
async (args) => runScaleIngredients(args)
|
|
1307
|
+
);
|
|
1308
|
+
logger.info(
|
|
1309
|
+
`ready: user-agent="${config.userAgent}", min interval ${config.minIntervalMs}ms, cache ${config.cacheTtlMs}ms`
|
|
1310
|
+
);
|
|
1311
|
+
return server;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// src/index.ts
|
|
1315
|
+
async function main() {
|
|
1316
|
+
const config = loadConfig();
|
|
1317
|
+
const logger = createLogger(config.logLevel);
|
|
1318
|
+
const server = createServer({ config, logger });
|
|
1319
|
+
const transport = new StdioServerTransport();
|
|
1320
|
+
await server.connect(transport);
|
|
1321
|
+
const shutdown = () => {
|
|
1322
|
+
void server.close().finally(() => process.exit(0));
|
|
1323
|
+
};
|
|
1324
|
+
process.on("SIGINT", shutdown);
|
|
1325
|
+
process.on("SIGTERM", shutdown);
|
|
1326
|
+
}
|
|
1327
|
+
main().catch((error) => {
|
|
1328
|
+
process.stderr.write(
|
|
1329
|
+
`[mcp-marmiton] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}
|
|
1330
|
+
`
|
|
1331
|
+
);
|
|
1332
|
+
process.exit(1);
|
|
1333
|
+
});
|
|
1334
|
+
//# sourceMappingURL=index.js.map
|