mcp-marmiton 1.0.2 → 1.0.4

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 CHANGED
@@ -19,7 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
-
23
- This license covers the source code of this project only. Song lyrics retrieved
24
- through this software are the property of their respective copyright holders and
25
- are not covered by this license.
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/v/mcp-marmiton.svg)](https://www.npmjs.com/package/mcp-marmiton)
4
4
  [![CI](https://github.com/smeet666/mcp-marmiton/actions/workflows/ci.yml/badge.svg)](https://github.com/smeet666/mcp-marmiton/actions/workflows/ci.yml)
5
5
  [![license](https://img.shields.io/npm/l/mcp-marmiton.svg)](./LICENSE)
6
+ [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-6E56CF)](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.smeet666/mcp-marmiton)
6
7
 
7
8
  An [MCP](https://modelcontextprotocol.io) server for [Marmiton](https://www.marmiton.org),
8
9
  the French recipe site. Search recipes, read their ingredients and steps, and
@@ -15,6 +16,11 @@ _(Version française plus bas / [French version below](#mcp-marmiton-français))
15
16
 
16
17
  ## Quickstart
17
18
 
19
+ **One-click install**
20
+
21
+ [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=marmiton&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1jcC1tYXJtaXRvbiJdfQ%3D%3D)
22
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=marmiton&config=%7B%22name%22%3A%22marmiton%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mcp-marmiton%22%5D%7D)
23
+
18
24
  **Claude Code**
19
25
 
20
26
  ```bash
@@ -168,6 +174,11 @@ quantités au nombre de convives**. **Sans clé d'API, sans compte, sans configu
168
174
 
169
175
  ## Démarrage rapide
170
176
 
177
+ **Installation en un clic**
178
+
179
+ [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=marmiton&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1jcC1tYXJtaXRvbiJdfQ%3D%3D)
180
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=marmiton&config=%7B%22name%22%3A%22marmiton%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mcp-marmiton%22%5D%7D)
181
+
171
182
  **Claude Code**
172
183
 
173
184
  ```bash
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
6
  // src/version.ts
7
- var PKG_VERSION = "1.0.2";
7
+ var PKG_VERSION = "1.0.4";
8
8
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
9
9
 
10
10
  // src/config.ts
@@ -220,17 +220,24 @@ var RateLimiter = class {
220
220
  }
221
221
  /** Queue a task. Tasks run in call order, one at a time. */
222
222
  schedule(task) {
223
- const run = this.tail.then(async () => {
224
- await this.waitForSlot();
225
- this.lastStart = Date.now();
226
- return task();
227
- });
223
+ const run = this.tail.then(async () => task());
228
224
  this.tail = run.then(
229
225
  () => void 0,
230
226
  () => void 0
231
227
  );
232
228
  return run;
233
229
  }
230
+ /**
231
+ * Wait for this request's slot, then claim it.
232
+ *
233
+ * Called once per upstream request rather than once per task, because a task
234
+ * runs a whole retry chain: stamping only its start would let the next task
235
+ * follow the chain's last request with no gap at all.
236
+ */
237
+ async beforeRequest() {
238
+ await this.waitForSlot();
239
+ this.lastStart = Date.now();
240
+ }
234
241
  /** Called after the site signals it is under load. */
235
242
  penalize() {
236
243
  const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;
@@ -243,7 +250,7 @@ var RateLimiter = class {
243
250
  async waitForSlot() {
244
251
  if (this.intervalMs === 0 || this.lastStart === 0) return;
245
252
  const elapsed = Date.now() - this.lastStart;
246
- const remaining = this.intervalMs - elapsed;
253
+ const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
247
254
  if (remaining > 0) await sleep(remaining);
248
255
  }
249
256
  };
@@ -265,15 +272,19 @@ async function fetchHtml(url, deps) {
265
272
  const doFetch = deps.fetchImpl ?? fetch;
266
273
  return limiter.schedule(async () => {
267
274
  let lastError;
275
+ let askedWaitMs = null;
268
276
  for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
269
277
  if (attempt > 0) {
270
- const delay = backoffDelay(attempt - 1);
278
+ const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);
279
+ askedWaitMs = null;
271
280
  logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
272
281
  await sleep(delay);
273
282
  }
274
283
  let status;
275
284
  let body;
285
+ let retryAfterMs = null;
276
286
  try {
287
+ await limiter.beforeRequest();
277
288
  const response = await doFetch(url, {
278
289
  headers: {
279
290
  "User-Agent": config.userAgent,
@@ -284,15 +295,17 @@ async function fetchHtml(url, deps) {
284
295
  signal: AbortSignal.timeout(config.timeoutMs)
285
296
  });
286
297
  status = response.status;
298
+ retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
287
299
  body = await response.text();
288
300
  } catch (error) {
289
301
  lastError = asTransportError(error, url);
290
302
  logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
291
303
  continue;
292
304
  }
293
- if (status === 429 || status === 503) {
305
+ if (status === 429 || status === 503 || status === 403) {
294
306
  limiter.penalize();
295
- lastError = rateLimited(url, backoffDelay(attempt));
307
+ askedWaitMs = retryAfterMs;
308
+ lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));
296
309
  logger.info(`rate limited on ${url}, interval now ${limiter.currentIntervalMs}ms`);
297
310
  continue;
298
311
  }
@@ -315,6 +328,14 @@ async function fetchHtml(url, deps) {
315
328
  throw lastError ?? new MarmitonError("network_error", `Could not fetch ${url}.`, { url });
316
329
  });
317
330
  }
331
+ function parseRetryAfter(raw) {
332
+ if (!raw) return null;
333
+ const seconds = Number(raw.trim());
334
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
335
+ const when = Date.parse(raw);
336
+ if (Number.isNaN(when)) return null;
337
+ return Math.max(0, when - Date.now());
338
+ }
318
339
  function asTransportError(error, url) {
319
340
  if (error instanceof MarmitonError) return error;
320
341
  const name = error instanceof Error ? error.name : "";
@@ -596,6 +617,14 @@ function parseSearchPage(html, url) {
596
617
  }
597
618
 
598
619
  // src/marmiton/client.ts
620
+ function withGuarantees(config) {
621
+ const userAgent = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
622
+ return {
623
+ ...config,
624
+ userAgent,
625
+ minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs)
626
+ };
627
+ }
599
628
  var MarmitonClient = class {
600
629
  config;
601
630
  logger;
@@ -603,7 +632,7 @@ var MarmitonClient = class {
603
632
  cache;
604
633
  fetchImpl;
605
634
  constructor(options = {}) {
606
- this.config = options.config ?? loadConfig();
635
+ this.config = withGuarantees(options.config ?? loadConfig());
607
636
  this.logger = options.logger ?? createLogger(this.config.logLevel);
608
637
  this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });
609
638
  this.cache = new TtlLruCache(this.config.cacheMaxEntries, this.config.cacheTtlMs);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/version.ts","../src/config.ts","../src/server.ts","../src/marmiton/cache.ts","../src/errors.ts","../src/marmiton/rateLimiter.ts","../src/marmiton/http.ts","../src/recipe/duration.ts","../src/marmiton/jsonld.ts","../src/marmiton/urls.ts","../src/marmiton/parseRecipe.ts","../src/marmiton/client.ts","../src/tools/getRecipe.ts","../src/recipe/units.ts","../src/recipe/quantity.ts","../src/recipe/scale.ts","../src/tools/shared.ts","../src/tools/scaleIngredients.ts","../src/tools/searchRecipes.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * Entry point: an MCP server for Marmiton over stdio.\n */\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { createLogger, loadConfig } from \"./config.js\";\nimport { createServer } from \"./server.js\";\n\nasync function main(): Promise<void> {\n const config = loadConfig();\n const logger = createLogger(config.logLevel);\n const server = createServer({ config, logger });\n\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n const shutdown = () => {\n void server.close().finally(() => process.exit(0));\n };\n process.on(\"SIGINT\", shutdown);\n process.on(\"SIGTERM\", shutdown);\n}\n\nmain().catch((error: unknown) => {\n process.stderr.write(\n `[mcp-marmiton] fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exit(1);\n});\n","/**\n * Kept in sync with package.json and server.json. The User-Agent embeds it, so it\n * must not drift; the release checklist bumps all three together.\n */\nexport const PKG_VERSION = \"1.0.2\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-marmiton\";\n","/**\n * Runtime configuration, read from environment variables.\n *\n * A bad value never crashes the process: an MCP server that dies at startup\n * because of a typo in a client config file is very hard to diagnose from the\n * host application, so invalid input is clamped and reported on stderr.\n */\n\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport type LogLevel = \"silent\" | \"error\" | \"info\" | \"debug\";\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\n/**\n * MARMITON asks third-party clients to identify themselves with an application\n * name, a version and a link to the project, so the default follows that format.\n */\nexport const DEFAULT_USER_AGENT = `mcp-marmiton v${PKG_VERSION} (${REPO_URL})`;\n\nexport const DEFAULTS = {\n minIntervalMs: 1000,\n timeoutMs: 15_000,\n maxRetries: 3,\n cacheTtlMs: 15 * 60 * 1000,\n cacheMaxEntries: 200,\n logLevel: \"error\" as LogLevel,\n};\n\n/**\n * Floor on the request interval, enforced regardless of configuration.\n *\n * MARMITON publishes no rate limit and asks for nothing in return for a free\n * service. Self-imposed pacing is the whole of the politeness contract here, so\n * a value below this floor is refused and the default is used instead.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n\nconst LOG_LEVELS: LogLevel[] = [\"silent\", \"error\", \"info\", \"debug\"];\n\ninterface NumericRange {\n min: number;\n max: number;\n fallback: number;\n}\n\nfunction readNumber(name: string, env: NodeJS.ProcessEnv, range: NumericRange): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") return range.fallback;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`${name}=\"${raw}\" is not a number, using ${range.fallback}`);\n return range.fallback;\n }\n\n // Out-of-range values fall back to the default rather than being clamped to\n // the nearest bound. Clamping turns a typo into a silent behaviour change:\n // -1 retries becomes \"never retry\", and -1 cache entries disables the cache\n // outright, both of which look like working configuration.\n const rounded = Math.round(parsed);\n if (rounded < range.min || rounded > range.max) {\n warn(\n `${name}=${raw} is outside the accepted range ${range.min}-${range.max} and was ignored; ` +\n `using ${range.fallback}`,\n );\n return range.fallback;\n }\n return rounded;\n}\n\nfunction warn(message: string): void {\n process.stderr.write(`[mcp-marmiton] ${message}\\n`);\n}\n\n/**\n * Read the request interval, refusing anything below the floor.\n *\n * A value under the floor falls back to the default rather than to the floor\n * itself: someone who set 0 was not asking for 200, they were asking for no\n * pacing at all, and the safe reading of that request is to ignore it.\n */\nfunction readInterval(env: NodeJS.ProcessEnv): number {\n const raw = env.MARMITON_MIN_INTERVAL_MS;\n if (raw === undefined || raw.trim() === \"\") return DEFAULTS.minIntervalMs;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`MARMITON_MIN_INTERVAL_MS=\"${raw}\" is not a number, using ${DEFAULTS.minIntervalMs}ms`);\n return DEFAULTS.minIntervalMs;\n }\n\n const rounded = Math.round(parsed);\n if (rounded < MIN_ALLOWED_INTERVAL_MS) {\n warn(\n `MARMITON_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; ` +\n `using ${DEFAULTS.minIntervalMs}ms. Marmiton serves these pages for readers, so this client paces itself.`,\n );\n return DEFAULTS.minIntervalMs;\n }\n\n return Math.min(60_000, rounded);\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const rawUserAgent = env.MARMITON_USER_AGENT?.trim();\n const rawLogLevel = env.MARMITON_LOG_LEVEL?.trim().toLowerCase();\n\n let logLevel = DEFAULTS.logLevel;\n if (rawLogLevel) {\n if (LOG_LEVELS.includes(rawLogLevel as LogLevel)) {\n logLevel = rawLogLevel as LogLevel;\n } else {\n warn(`MARMITON_LOG_LEVEL=\"${rawLogLevel}\" is unknown, using \"${DEFAULTS.logLevel}\"`);\n }\n }\n\n return {\n userAgent: rawUserAgent || DEFAULT_USER_AGENT,\n minIntervalMs: readInterval(env),\n timeoutMs: readNumber(\"MARMITON_TIMEOUT_MS\", env, {\n min: 1000,\n max: 120_000,\n fallback: DEFAULTS.timeoutMs,\n }),\n maxRetries: readNumber(\"MARMITON_MAX_RETRIES\", env, {\n min: 0,\n max: 10,\n fallback: DEFAULTS.maxRetries,\n }),\n cacheTtlMs: readNumber(\"MARMITON_CACHE_TTL_MS\", env, {\n min: 0,\n max: 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.cacheTtlMs,\n }),\n cacheMaxEntries: readNumber(\"MARMITON_CACHE_MAX_ENTRIES\", env, {\n min: 0,\n max: 10_000,\n fallback: DEFAULTS.cacheMaxEntries,\n }),\n logLevel,\n };\n}\n\nconst LEVEL_RANK: Record<LogLevel, number> = { silent: 0, error: 1, info: 2, debug: 3 };\n\n/**\n * Logs go to stderr without exception. On a stdio transport, stdout carries the\n * protocol and any stray write there corrupts the session.\n */\nexport function createLogger(level: LogLevel) {\n const emit = (at: LogLevel, message: string) => {\n if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {\n process.stderr.write(`[mcp-marmiton] ${message}\\n`);\n }\n };\n return {\n error: (message: string) => emit(\"error\", message),\n info: (message: string) => emit(\"info\", message),\n debug: (message: string) => emit(\"debug\", message),\n };\n}\n\nexport type Logger = ReturnType<typeof createLogger>;\n","/**\n * MCP server wiring.\n *\n * One client, one rate limiter and one cache are shared by all tools, so pacing\n * applies to the server as a whole rather than per tool.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { Config, Logger } from \"./config.js\";\nimport { createLogger, loadConfig } from \"./config.js\";\nimport { MarmitonClient } from \"./marmiton/client.js\";\nimport {\n getRecipeDescription,\n getRecipeInputShape,\n getRecipeOutputShape,\n runGetRecipe,\n} from \"./tools/getRecipe.js\";\nimport type { GetRecipeArgs } from \"./tools/getRecipe.js\";\nimport {\n runScaleIngredients,\n scaleIngredientsDescription,\n scaleIngredientsInputShape,\n scaleIngredientsOutputShape,\n} from \"./tools/scaleIngredients.js\";\nimport type { ScaleIngredientsArgs } from \"./tools/scaleIngredients.js\";\nimport {\n runSearchRecipes,\n searchRecipesDescription,\n searchRecipesInputShape,\n searchRecipesOutputShape,\n} from \"./tools/searchRecipes.js\";\nimport type { SearchRecipesArgs } from \"./tools/searchRecipes.js\";\nimport { PKG_VERSION } from \"./version.js\";\n\nexport interface CreateServerOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/** This server only reads, so every tool is read-only. */\nconst READ_ONLY = {\n readOnlyHint: true,\n destructiveHint: false,\n idempotentHint: true,\n openWorldHint: true,\n} as const;\n\nexport function createServer(options: CreateServerOptions = {}): McpServer {\n const config = options.config ?? loadConfig();\n const logger = options.logger ?? createLogger(config.logLevel);\n const client = new MarmitonClient({\n config,\n logger,\n ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),\n });\n\n const server = new McpServer(\n { name: \"mcp-marmiton\", version: PKG_VERSION },\n {\n instructions:\n \"Tools for looking up French recipes on Marmiton. No API key is needed. \" +\n \"Typical flow: search_recipes to find a recipe and its id, then get_recipe with that id, \" +\n \"passing 'servings' when the user wants a different number of people. \" +\n \"Do not rescale quantities yourself: get_recipe and scale_ingredients already round countable \" +\n \"items sensibly and flag what cannot be scaled, which is what stops answers like '2.4 eggs'. \" +\n \"Marmiton disallows paginating search results, so narrow the query instead of asking for more pages. \" +\n \"When you show a recipe to a user, credit it and link the source URL.\",\n },\n );\n\n server.registerTool(\n \"search_recipes\",\n {\n title: \"Search recipes\",\n description: searchRecipesDescription,\n inputSchema: searchRecipesInputShape,\n outputSchema: searchRecipesOutputShape,\n annotations: READ_ONLY,\n },\n async (args) => runSearchRecipes(client, args as SearchRecipesArgs),\n );\n\n server.registerTool(\n \"get_recipe\",\n {\n title: \"Get a recipe\",\n description: getRecipeDescription,\n inputSchema: getRecipeInputShape,\n outputSchema: getRecipeOutputShape,\n annotations: READ_ONLY,\n },\n async (args) => runGetRecipe(client, args as GetRecipeArgs),\n );\n\n server.registerTool(\n \"scale_ingredients\",\n {\n title: \"Scale an ingredient list\",\n description: scaleIngredientsDescription,\n inputSchema: scaleIngredientsInputShape,\n outputSchema: scaleIngredientsOutputShape,\n annotations: READ_ONLY,\n },\n async (args) => runScaleIngredients(args as ScaleIngredientsArgs),\n );\n\n logger.info(\n `ready: user-agent=\"${config.userAgent}\", min interval ${config.minIntervalMs}ms, cache ${config.cacheTtlMs}ms`,\n );\n\n return server;\n}\n","/**\n * In-memory TTL + LRU cache.\n *\n * Nothing is written to disk. The cache exists so that repeated questions about\n * the same song inside one conversation do not hit lyrics.com again, not to\n * build a local copy of the site.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) return;\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) break;\n this.store.delete(oldest.value);\n }\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Error taxonomy surfaced to the calling model.\n *\n * The rule carried over from the sibling projects: a failure must never be\n * reported as an empty result. A model that sees \"no recipe found\" cannot tell\n * that apart from a genuine absence, and will confidently tell the user the\n * recipe does not exist.\n */\n\nexport type ErrorCode =\n \"not_found\" | \"invalid_input\" | \"rate_limited\" | \"parse_failure\" | \"network_error\" | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class MarmitonError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"MarmitonError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-marmiton/issues\";\n\nexport function notFound(url: string, what: string): MarmitonError {\n return new MarmitonError(\"not_found\", `Marmiton has no recipe at ${what}.`, {\n url,\n status: 404,\n hint: \"Use search_recipes to find a recipe and its id, then call this tool with that id.\",\n });\n}\n\nexport function invalidInput(message: string, hint?: string): MarmitonError {\n return new MarmitonError(\"invalid_input\", message, hint ? { hint } : {});\n}\n\nexport function rateLimited(url: string, retryAfterMs: number): MarmitonError {\n return new MarmitonError(\n \"rate_limited\",\n \"Marmiton is rate limiting this client. This does NOT mean the recipe does not exist.\",\n {\n url,\n retryAfterMs,\n hint:\n `Wait about ${Math.ceil(retryAfterMs / 1000)} seconds, then call the same tool again with the ` +\n \"same arguments. If it keeps happening, raise MARMITON_MIN_INTERVAL_MS.\",\n },\n );\n}\n\nexport function parseFailure(url: string, what: string): MarmitonError {\n return new MarmitonError(\n \"parse_failure\",\n `The page loaded but the expected structured recipe data was not found (${what}). ` +\n \"Marmiton may have changed how it publishes it.\",\n { url, hint: `Please report this, with the recipe you asked for, at ${ISSUES_URL}` },\n );\n}\n\nexport function upstreamError(url: string, status: number): MarmitonError {\n return new MarmitonError(\"network_error\", `Marmiton returned HTTP ${status}.`, {\n url,\n status,\n ...(status >= 500 ? { hint: \"This is a problem on Marmiton's side. Try again shortly.\" } : {}),\n });\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * lyrics.com tolerates a steady trickle but stops answering after a short burst,\n * so requests are run one at a time with a floor on the gap between starts.\n * When the site does push back, the interval doubles and then decays back down\n * as requests succeed, which recovers faster than a fixed delay and behaves\n * better than hammering at a constant rate.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 10_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => {\n await this.waitForSlot();\n this.lastStart = Date.now();\n return task();\n });\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n const remaining = this.intervalMs - elapsed;\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, with backoff.\n *\n * Marmiton answers with ordinary status codes, so this layer mostly translates\n * them. The one judgement call is on a body too small to be a real page, which is\n * how a site under load often refuses without saying so.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { MarmitonError, notFound, rateLimited, upstreamError } from \"../errors.js\";\nimport { RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 20_000;\n\n/** A real Marmiton page weighs well over 100 KB. */\nconst MIN_PLAUSIBLE_HTML = 2000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one page as HTML, retrying transient conditions.\n *\n * The retry loop and its sleeps run inside a single limiter slot, so a queued\n * request cannot slip into the window the current one is backing away from.\n */\nexport async function fetchHtml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n\n return limiter.schedule(async () => {\n let lastError: MarmitonError | undefined;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = backoffDelay(attempt - 1);\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n try {\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"fr-FR,fr;q=0.9,en;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n if (status === 429 || status === 503) {\n limiter.penalize();\n lastError = rateLimited(url, backoffDelay(attempt));\n logger.info(`rate limited on ${url}, interval now ${limiter.currentIntervalMs}ms`);\n continue;\n }\n if (status === 404) throw notFound(url, \"that address\");\n if (status >= 500) {\n lastError = upstreamError(url, status);\n continue;\n }\n if (status >= 400) throw upstreamError(url, status);\n\n const trimmed = body.trim();\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\\/html>/i.test(trimmed)) {\n // Too small to be a page: treat it as a refusal rather than parse it and\n // report an empty result.\n limiter.penalize();\n lastError = rateLimited(url, backoffDelay(attempt));\n logger.info(`implausibly short body on ${url}, treating as rate limiting`);\n continue;\n }\n\n limiter.relax();\n return body;\n }\n\n throw lastError ?? new MarmitonError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\nfunction asTransportError(error: unknown, url: string): MarmitonError {\n if (error instanceof MarmitonError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new MarmitonError(\"timeout\", \"Marmiton did not answer in time.\", {\n url,\n hint: \"Raise MARMITON_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new MarmitonError(\"network_error\", `Could not reach Marmiton: ${message}`, { url });\n}\n","/**\n * ISO 8601 durations, which is how schema.org expresses cooking times.\n *\n * Marmiton writes \"PT25M\" for 25 minutes and \"PT1H30M\" for an hour and a half.\n */\n\nconst ISO_DURATION = /^P(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?$/;\n\n/** Minutes, or null when the value is absent or unreadable. */\nexport function parseIsoDuration(value: unknown): number | null {\n if (typeof value !== \"string\") return null;\n const match = ISO_DURATION.exec(value.trim().toUpperCase());\n if (!match) return null;\n\n const [, days, hours, minutes, seconds] = match;\n const total =\n Number(days ?? 0) * 1440 +\n Number(hours ?? 0) * 60 +\n Number(minutes ?? 0) +\n Number(seconds ?? 0) / 60;\n\n // \"P\" alone parses but means nothing useful.\n if (total === 0 && !minutes && !hours && !days && !seconds) return null;\n return Math.round(total);\n}\n\n/** Minutes as \"1 h 30\" or \"25 min\", for the human-readable mirror. */\nexport function formatMinutes(minutes: number | null): string | null {\n if (minutes === null || !Number.isFinite(minutes) || minutes <= 0) return null;\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n if (hours === 0) return `${rest} min`;\n if (rest === 0) return `${hours} h`;\n return `${hours} h ${String(rest).padStart(2, \"0\")}`;\n}\n\nexport interface ParsedYield {\n /** Number of servings, or null when the text carries no number. */\n count: number | null;\n /** What is being counted: \"personnes\", \"pièces\", \"parts\". */\n unit: string | null;\n /** The original text, always preserved. */\n text: string;\n}\n\n/**\n * Read a schema.org recipeYield.\n *\n * Marmiton writes \"6 personnes\" but also \"15 pièces\", so the unit is kept: a\n * recipe yielding pieces cannot be described as serving people.\n */\nexport function parseYield(value: unknown): ParsedYield {\n // schema.org allows recipeYield as a number, a string, or an array of either.\n const text =\n typeof value === \"number\" && Number.isFinite(value)\n ? String(value)\n : typeof value === \"string\"\n ? value.trim()\n : Array.isArray(value)\n ? String(value[0] ?? \"\").trim()\n : \"\";\n if (!text) return { count: null, unit: null, text: \"\" };\n\n const match = /^(\\d+(?:[.,]\\d+)?)\\s*(.*)$/.exec(text);\n if (!match) return { count: null, unit: null, text };\n\n const count = Number(match[1]!.replace(\",\", \".\"));\n const unit = (match[2] ?? \"\").trim() || null;\n return { count: Number.isFinite(count) ? count : null, unit, text };\n}\n","/**\n * JSON-LD extraction.\n *\n * Marmiton embeds a schema.org graph in every page, which is data the site\n * publishes deliberately for machines to read. Using it instead of CSS selectors\n * removes the whole class of breakage where a redesign renames a class and the\n * parser silently returns nothing.\n */\n\nconst LD_JSON_BLOCK = /<script[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi;\n\n/** Every JSON-LD object on the page, flattened out of any `@graph` wrappers. */\nexport function extractJsonLdNodes(html: string): unknown[] {\n const nodes: unknown[] = [];\n LD_JSON_BLOCK.lastIndex = 0;\n\n let match: RegExpExecArray | null;\n while ((match = LD_JSON_BLOCK.exec(html)) !== null) {\n const raw = match[1]?.trim();\n if (!raw) continue;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // A malformed block is skipped rather than fatal: pages carry several,\n // and one broken analytics blob must not hide the recipe.\n continue;\n }\n\n for (const node of flatten(parsed)) nodes.push(node);\n }\n\n return nodes;\n}\n\nfunction flatten(value: unknown): unknown[] {\n if (Array.isArray(value)) return value.flatMap(flatten);\n if (typeof value !== \"object\" || value === null) return [];\n\n const node = value as Record<string, unknown>;\n const graph = node[\"@graph\"];\n if (graph !== undefined) return flatten(graph);\n return [node];\n}\n\n/** True when a node declares the given schema.org type. */\nexport function hasType(node: unknown, type: string): boolean {\n if (typeof node !== \"object\" || node === null) return false;\n const declared = (node as Record<string, unknown>)[\"@type\"];\n if (typeof declared === \"string\") return declared === type;\n if (Array.isArray(declared)) return declared.includes(type);\n return false;\n}\n\nexport function findNodeOfType(nodes: unknown[], type: string): Record<string, unknown> | null {\n const found = nodes.find((node) => hasType(node, type));\n return (found as Record<string, unknown>) ?? null;\n}\n\n/** Read a string field, tolerating the array form schema.org allows. */\nexport function asString(value: unknown): string | null {\n if (typeof value === \"string\") return value.trim() || null;\n if (Array.isArray(value)) {\n for (const entry of value) {\n const found = asString(entry);\n if (found) return found;\n }\n }\n if (typeof value === \"object\" && value !== null) {\n // Fields such as `image` are often an ImageObject rather than a plain URL.\n const url = (value as Record<string, unknown>).url;\n if (typeof url === \"string\") return url.trim() || null;\n const name = (value as Record<string, unknown>).name;\n if (typeof name === \"string\") return name.trim() || null;\n }\n return null;\n}\n\nexport function asNumber(value: unknown): number | null {\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n if (typeof value === \"string\") {\n const parsed = Number(value.replace(\",\", \".\"));\n if (Number.isFinite(parsed)) return parsed;\n }\n return null;\n}\n\n/** Read a list of strings, which is how ingredients and steps are expressed. */\nexport function asStringList(value: unknown): string[] {\n if (typeof value === \"string\") return [value.trim()].filter(Boolean);\n if (!Array.isArray(value)) return [];\n\n const out: string[] = [];\n for (const entry of value) {\n if (typeof entry === \"string\") {\n const text = entry.trim();\n if (text) out.push(text);\n continue;\n }\n if (typeof entry === \"object\" && entry !== null) {\n // HowToStep carries the sentence under `text`.\n const record = entry as Record<string, unknown>;\n const text = asString(record.text) ?? asString(record.name);\n if (text) out.push(text);\n }\n }\n return out;\n}\n","/** URL construction and validation for marmiton.org. */\n\nimport { invalidInput } from \"../errors.js\";\n\nexport const BASE_URL = \"https://www.marmiton.org\";\n\n/** Recipe pages look like /recettes/recette_<slug>_<id>.aspx */\nexport const RECIPE_PATH_RE = /\\/recettes\\/recette[_-][a-z0-9%\\-.]*?_(\\d+)\\.aspx/i;\n\nconst ALLOWED_HOSTS = new Set([\"marmiton.org\", \"www.marmiton.org\"]);\n\n/**\n * Search URL.\n *\n * Only the `aqt` parameter is used. Marmiton's robots.txt disallows\n * `recherche.aspx?aqt=*page*` and `?aqt=*start*`, so this client never paginates\n * and the tool description says so rather than pretending the limit does not\n * exist.\n */\nexport function buildSearchUrl(query: string): string {\n const url = new URL(`${BASE_URL}/recettes/recherche.aspx`);\n url.searchParams.set(\"aqt\", query);\n return url.toString();\n}\n\n/**\n * Recipe URL from an id alone.\n *\n * Marmiton keys the page on the trailing id and ignores the slug, redirecting to\n * the canonical address whatever the slug says. It does require the slug to be\n * non-empty though: `recette__18588.aspx` answers 404 while `recette_r_18588.aspx`\n * redirects to the real page. Hence the placeholder, which lets a caller holding\n * only an id skip having to know the title.\n */\nexport function buildRecipeUrl(id: string): string {\n return `${BASE_URL}/recettes/recette_r_${id}.aspx`;\n}\n\n/** True only for marmiton.org, so a hostile URL cannot be used as a proxy. */\nexport function isMarmitonHost(rawUrl: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return false;\n return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());\n}\n\n/** Extract the numeric recipe id from a Marmiton path or absolute URL. */\nexport function extractRecipeId(hrefOrUrl: string): string | null {\n let path = hrefOrUrl;\n if (/^https?:\\/\\//i.test(hrefOrUrl)) {\n if (!isMarmitonHost(hrefOrUrl)) return null;\n path = new URL(hrefOrUrl).pathname;\n }\n const match = RECIPE_PATH_RE.exec(path);\n return match?.[1] ?? null;\n}\n\n/** Resolve the `id` / `url` pair accepted by get_recipe. `id` wins. */\nexport function resolveRecipeRef(input: { id?: string; url?: string }): {\n id: string;\n url: string;\n} {\n if (input.id) {\n if (!/^\\d+$/.test(input.id)) {\n throw invalidInput(\n `\"${input.id}\" is not a Marmiton recipe id.`,\n \"Ids are digits only, as returned by search_recipes.\",\n );\n }\n return { id: input.id, url: buildRecipeUrl(input.id) };\n }\n\n if (input.url) {\n if (!isMarmitonHost(input.url)) {\n throw invalidInput(\n \"Only marmiton.org URLs are accepted.\",\n \"Pass the url returned by search_recipes, or use the numeric id instead.\",\n );\n }\n const id = extractRecipeId(input.url);\n if (!id) {\n throw invalidInput(\n \"That Marmiton URL is not a recipe page.\",\n \"Recipe pages look like https://www.marmiton.org/recettes/recette_tarte-aux-pommes_18588.aspx\",\n );\n }\n return { id, url: input.url };\n }\n\n throw invalidInput(\"Either 'id' or 'url' must be provided.\");\n}\n\nexport function toAbsoluteUrl(href: string): string {\n return new URL(href, BASE_URL).toString();\n}\n","/**\n * Turning Marmiton's JSON-LD into the domain types.\n *\n * The editorial `description` is deliberately not read here. Ingredient lists and\n * cooking steps are facts and procedures, and a public tool can hand them on with\n * a link to the source; the author's descriptive prose is their writing, and this\n * server has no business redistributing it.\n */\n\nimport { parseFailure } from \"../errors.js\";\nimport { parseIsoDuration, parseYield } from \"../recipe/duration.js\";\nimport type { Nutrition, Rating, Recipe, RecipeSummary } from \"../types.js\";\nimport { asNumber, asString, asStringList, extractJsonLdNodes, findNodeOfType } from \"./jsonld.js\";\nimport { extractRecipeId, toAbsoluteUrl } from \"./urls.js\";\n\nexport interface ParseRecipeOptions {\n id: string;\n url: string;\n}\n\nexport function parseRecipePage(html: string, options: ParseRecipeOptions): Recipe {\n const nodes = extractJsonLdNodes(html);\n const node = findNodeOfType(nodes, \"Recipe\");\n\n // A page that loaded but carries no Recipe node means Marmiton changed how it\n // publishes its data. Reporting that as \"no recipe\" would be indistinguishable\n // from a genuine absence, so it has to be loud.\n if (!node) {\n throw parseFailure(options.url, \"no schema.org Recipe node in the page\");\n }\n\n const title = asString(node.name);\n const ingredients = asStringList(node.recipeIngredient);\n const steps = asStringList(node.recipeInstructions);\n\n if (!title) throw parseFailure(options.url, \"the Recipe node has no name\");\n if (ingredients.length === 0) {\n throw parseFailure(options.url, \"the Recipe node carries no ingredient list\");\n }\n\n return {\n id: options.id,\n title,\n url: asString(node.mainEntityOfPage) ?? options.url,\n imageUrl: asString(node.image),\n ingredients,\n steps,\n recipeYield: parseYield(node.recipeYield),\n prepMinutes: parseIsoDuration(node.prepTime),\n cookMinutes: parseIsoDuration(node.cookTime),\n totalMinutes: parseIsoDuration(node.totalTime),\n category: asString(node.recipeCategory),\n author: asString(node.author),\n rating: parseRating(node.aggregateRating),\n nutrition: parseNutrition(node.nutrition),\n };\n}\n\nfunction parseRating(value: unknown): Rating | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const ratingValue = asNumber(record.ratingValue);\n if (ratingValue === null) return null;\n return {\n value: ratingValue,\n count: asNumber(record.ratingCount) ?? asNumber(record.reviewCount),\n best: asNumber(record.bestRating),\n };\n}\n\nfunction parseNutrition(value: unknown): Nutrition | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const nutrition: Nutrition = {\n calories: asString(record.calories),\n protein: asString(record.proteinContent),\n fat: asString(record.fatContent),\n carbohydrate: asString(record.carbohydrateContent),\n fiber: asString(record.fiberContent),\n sodium: asString(record.sodiumContent),\n servingSize: asString(record.servingSize),\n };\n const hasAny = Object.values(nutrition).some((entry) => entry !== null);\n return hasAny ? nutrition : null;\n}\n\n/**\n * Search results, read from the ItemList the search page publishes.\n *\n * The page also lists more recipes in its HTML, but reaching them would mean\n * reintroducing CSS selectors and the breakage that comes with them. The\n * structured list is what the site offers machines, so that is what is used.\n */\nexport function parseSearchPage(html: string, url: string): RecipeSummary[] {\n const nodes = extractJsonLdNodes(html);\n const list = findNodeOfType(nodes, \"ItemList\");\n\n if (!list) {\n throw parseFailure(url, \"no schema.org ItemList node on the search page\");\n }\n\n const elements = list.itemListElement;\n if (!Array.isArray(elements)) {\n throw parseFailure(url, \"the ItemList node carries no itemListElement array\");\n }\n\n const results: RecipeSummary[] = [];\n let skipped = 0;\n\n for (const element of elements) {\n if (typeof element !== \"object\" || element === null) {\n skipped += 1;\n continue;\n }\n const record = element as Record<string, unknown>;\n const href = asString(record.url);\n const title = asString(record.name);\n const id = href ? extractRecipeId(href) : null;\n\n if (!href || !title || !id) {\n skipped += 1;\n continue;\n }\n\n results.push({ id, title, url: toAbsoluteUrl(href), imageUrl: asString(record.image) });\n }\n\n // Every entry failing while the list itself was present means the entry shape\n // changed, which is breakage rather than an empty search.\n if (results.length === 0 && elements.length > 0) {\n throw parseFailure(url, `${elements.length} list entries but none could be read`);\n }\n if (skipped > 0) {\n process.stderr.write(`[mcp-marmiton] skipped ${skipped} unreadable search entries on ${url}\\n`);\n }\n\n return results;\n}\n","/**\n * High-level Marmiton client.\n *\n * This module knows nothing about MCP, which keeps it testable against plain\n * strings and usable as a library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { createLogger, loadConfig } from \"../config.js\";\nimport type { Recipe, RecipeSummary } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchHtml } from \"./http.js\";\nimport { parseRecipePage, parseSearchPage } from \"./parseRecipe.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { buildSearchUrl, resolveRecipeRef } from \"./urls.js\";\n\nexport interface MarmitonClientOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport interface Outcome<T> {\n data: T;\n /** True when served from the in-memory cache rather than the network. */\n cached: boolean;\n}\n\nexport class MarmitonClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<string>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: MarmitonClientOptions = {}) {\n this.config = options.config ?? loadConfig();\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });\n this.cache = new TtlLruCache<string>(this.config.cacheMaxEntries, this.config.cacheTtlMs);\n this.fetchImpl = options.fetchImpl;\n }\n\n /**\n * Search recipes.\n *\n * One page only: Marmiton's robots.txt disallows the paginated form of this\n * URL, so there is no page parameter to pass.\n */\n async search(query: string): Promise<Outcome<RecipeSummary[]>> {\n const url = buildSearchUrl(query);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseSearchPage(html, url), cached };\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseRecipePage(html, { id, url }), cached };\n }\n\n private async fetchPage(url: string): Promise<{ html: string; cached: boolean }> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { html: hit, cached: true };\n }\n\n const html = await fetchHtml(url, {\n config: this.config,\n limiter: this.limiter,\n logger: this.logger,\n ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),\n });\n this.cache.set(url, html);\n return { html, cached: false };\n }\n}\n","/**\n * get_recipe: read one recipe, optionally scaled to a number of servings.\n */\n\nimport { z } from \"zod\";\nimport type { MarmitonClient } from \"../marmiton/client.js\";\nimport { formatMinutes } from \"../recipe/duration.js\";\nimport { passthroughIngredients, scaleIngredients } from \"../recipe/scale.js\";\nimport type { ScaledIngredient } from \"../recipe/scale.js\";\nimport { ok, scaledIngredientSchema, toToolError } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const getRecipeDescription = [\n \"Read one Marmiton recipe: ingredients, steps, times, category, rating and nutrition.\",\n \"Give the id returned by search_recipes, or a marmiton.org recipe URL.\",\n \"Set 'servings' to rescale the quantities. Each ingredient reports how it was handled: 'scaled' for grams\",\n \"and millilitres, 'rounded' for countable things such as eggs or spoons, and 'unscaled' for lines with no\",\n \"quantity or with an approximate one such as a pinch of salt. Trust that flag rather than recomputing:\",\n \"the point of scaling here is to avoid answers like '2.4 eggs'.\",\n \"Recipes yielding pieces rather than servings are rescaled the same way; check 'yield' to see which.\",\n \"Always cite 'attribution' when showing a recipe to a user.\",\n].join(\" \");\n\nexport const getRecipeInputShape = {\n id: z\n .string()\n .regex(/^\\d+$/, \"Recipe ids are digits only.\")\n .optional()\n .describe(\"Marmiton recipe id, as returned by search_recipes. Preferred over 'url'.\"),\n url: z\n .string()\n .url()\n .optional()\n .describe(\n \"Full marmiton.org recipe URL. Only marmiton.org is accepted. Ignored when 'id' is given.\",\n ),\n servings: z\n .number()\n .positive()\n .max(500)\n .optional()\n .describe(\n \"Rescale the ingredients to this many servings. Omit to get the recipe exactly as published.\",\n ),\n};\n\nexport const getRecipeOutputShape = {\n id: z.string(),\n title: z.string(),\n url: z.string(),\n yield: z.object({\n original_count: z.number().nullable(),\n original_text: z.string(),\n requested: z.number().nullable(),\n unit: z.string().nullable().describe(\"What is being counted: 'personnes', 'pièces'.\"),\n factor: z.number().nullable().describe(\"Multiplier applied to the quantities.\"),\n }),\n ingredients: z.array(scaledIngredientSchema),\n steps: z.array(z.string()),\n prep_minutes: z.number().int().nullable(),\n cook_minutes: z.number().int().nullable(),\n total_minutes: z.number().int().nullable(),\n category: z.string().nullable(),\n author: z.string().nullable(),\n rating: z\n .object({ value: z.number(), count: z.number().nullable(), best: z.number().nullable() })\n .nullable(),\n nutrition: z\n .object({\n calories: z.string().nullable(),\n protein: z.string().nullable(),\n fat: z.string().nullable(),\n carbohydrate: z.string().nullable(),\n fiber: z.string().nullable(),\n sodium: z.string().nullable(),\n serving_size: z.string().nullable(),\n })\n .nullable()\n .describe(\"Nutrition is given for the recipe as published, not rescaled.\"),\n attribution: z.string(),\n source: z.literal(\"marmiton.org\"),\n notes: z.array(z.string()),\n};\n\nexport interface GetRecipeArgs {\n id?: string;\n url?: string;\n servings?: number;\n}\n\nexport async function runGetRecipe(\n client: MarmitonClient,\n args: GetRecipeArgs,\n): Promise<ToolResult> {\n try {\n const ref: { id?: string; url?: string } = {};\n if (args.id) ref.id = args.id;\n else if (args.url) ref.url = args.url;\n\n const { data, cached } = await client.getRecipe(ref);\n const notes: string[] = [];\n if (cached) notes.push(\"Served from this server's short-lived in-memory cache.\");\n\n const originalCount = data.recipeYield.count;\n let factor: number | null = null;\n let ingredients: ScaledIngredient[];\n\n if (args.servings === undefined) {\n ingredients = passthroughIngredients(data.ingredients);\n } else if (originalCount === null || originalCount <= 0) {\n // Without a published yield there is nothing to scale from, and guessing\n // would produce quantities that look authoritative and are not.\n ingredients = passthroughIngredients(data.ingredients);\n notes.push(\n `Marmiton does not state how many servings this recipe makes (\"${data.recipeYield.text}\"), ` +\n \"so the quantities are unchanged.\",\n );\n } else {\n factor = args.servings / originalCount;\n ingredients = scaleIngredients(data.ingredients, { factor });\n\n const unit = data.recipeYield.unit ?? \"portions\";\n notes.push(\n `Quantities rescaled from ${originalCount} to ${args.servings} ${unit} (factor ${\n Math.round(factor * 100) / 100\n }).`,\n );\n if (ingredients.some((entry) => entry.scaling === \"rounded\")) {\n notes.push(\"Some quantities were rounded to remain usable; see the 'scaling' field.\");\n }\n if (ingredients.some((entry) => entry.scaling === \"unscaled\")) {\n notes.push(\"Some ingredients carry no usable quantity and were left as published.\");\n }\n if (data.nutrition) {\n notes.push(\n \"Nutrition values are for the recipe as published, not for the rescaled amounts.\",\n );\n }\n }\n\n const attribution = `${data.title} — recette Marmiton — ${data.url}`;\n\n const structured = {\n id: data.id,\n title: data.title,\n url: data.url,\n yield: {\n original_count: originalCount,\n original_text: data.recipeYield.text,\n requested: args.servings ?? null,\n unit: data.recipeYield.unit,\n factor: factor === null ? null : Math.round(factor * 1000) / 1000,\n },\n ingredients,\n steps: data.steps,\n prep_minutes: data.prepMinutes,\n cook_minutes: data.cookMinutes,\n total_minutes: data.totalMinutes,\n category: data.category,\n author: data.author,\n rating: data.rating,\n nutrition: data.nutrition\n ? {\n calories: data.nutrition.calories,\n protein: data.nutrition.protein,\n fat: data.nutrition.fat,\n carbohydrate: data.nutrition.carbohydrate,\n fiber: data.nutrition.fiber,\n sodium: data.nutrition.sodium,\n serving_size: data.nutrition.servingSize,\n }\n : null,\n attribution,\n source: \"marmiton.org\" as const,\n notes,\n };\n\n const times = [\n data.prepMinutes ? `préparation ${formatMinutes(data.prepMinutes)}` : \"\",\n data.cookMinutes ? `cuisson ${formatMinutes(data.cookMinutes)}` : \"\",\n ]\n .filter(Boolean)\n .join(\", \");\n\n const header = [\n attribution,\n args.servings !== undefined && factor !== null\n ? `Pour ${args.servings} ${data.recipeYield.unit ?? \"portions\"} (recette prévue pour ${data.recipeYield.text}).`\n : `Recette pour ${data.recipeYield.text || \"portions non précisées\"}.`,\n times,\n ]\n .filter(Boolean)\n .join(\"\\n\");\n\n const ingredientLines = ingredients\n .map((entry) => {\n const flag = entry.scaling === \"unscaled\" ? \" (non ajusté)\" : \"\";\n return `- ${entry.text}${flag}`;\n })\n .join(\"\\n\");\n\n return ok(structured, `${header}\\n\\nIngrédients:\\n${ingredientLines}`);\n } catch (error) {\n return toToolError(error);\n }\n}\n","/**\n * French cooking unit vocabulary and what scaling means for each.\n *\n * The distinction that matters is not metric versus imperial, it is whether\n * multiplying the number produces something a cook can act on. Doubling \"200 g\"\n * gives \"400 g\", which is useful. Doubling \"1 pincée\" gives \"2 pincées\", which is\n * noise: a pinch is already \"whatever your fingers hold\".\n */\n\nexport type UnitKind =\n /** Mass or volume: scales continuously and cleanly. */\n | \"measured\"\n /** Spoons, cups, sachets: scales, but only to sensible fractions. */\n | \"portioned\"\n /** Pinches, dashes, \"à volonté\": the number carries no real precision. */\n | \"vague\";\n\nexport interface UnitInfo {\n /** Canonical singular form, used when rewriting the ingredient line. */\n canonical: string;\n kind: UnitKind;\n /** Plural form when it is not simply the singular plus an \"s\". */\n plural?: string;\n}\n\n/**\n * Keys are matched lowercased and accent-stripped, so a single entry covers\n * \"cuillere\", \"cuillère\", \"Cuillères\".\n */\nconst UNITS: Record<string, UnitInfo> = {\n // Mass\n g: { canonical: \"g\", kind: \"measured\" },\n gr: { canonical: \"g\", kind: \"measured\" },\n gramme: { canonical: \"g\", kind: \"measured\" },\n grammes: { canonical: \"g\", kind: \"measured\" },\n kg: { canonical: \"kg\", kind: \"measured\" },\n kilo: { canonical: \"kg\", kind: \"measured\" },\n kilos: { canonical: \"kg\", kind: \"measured\" },\n kilogramme: { canonical: \"kg\", kind: \"measured\" },\n mg: { canonical: \"mg\", kind: \"measured\" },\n\n // Volume\n ml: { canonical: \"ml\", kind: \"measured\" },\n cl: { canonical: \"cl\", kind: \"measured\" },\n dl: { canonical: \"dl\", kind: \"measured\" },\n l: { canonical: \"l\", kind: \"measured\" },\n litre: { canonical: \"l\", kind: \"measured\" },\n litres: { canonical: \"l\", kind: \"measured\" },\n\n // Spoons and cups: real measures, but only in sensible fractions.\n \"cuillere a soupe\": {\n canonical: \"cuillère à soupe\",\n kind: \"portioned\",\n plural: \"cuillères à soupe\",\n },\n \"cuilleres a soupe\": {\n canonical: \"cuillère à soupe\",\n kind: \"portioned\",\n plural: \"cuillères à soupe\",\n },\n \"c a soupe\": { canonical: \"cuillère à soupe\", kind: \"portioned\", plural: \"cuillères à soupe\" },\n \"c a s\": { canonical: \"cuillère à soupe\", kind: \"portioned\", plural: \"cuillères à soupe\" },\n cas: { canonical: \"cuillère à soupe\", kind: \"portioned\", plural: \"cuillères à soupe\" },\n \"cuillere a cafe\": {\n canonical: \"cuillère à café\",\n kind: \"portioned\",\n plural: \"cuillères à café\",\n },\n \"cuilleres a cafe\": {\n canonical: \"cuillère à café\",\n kind: \"portioned\",\n plural: \"cuillères à café\",\n },\n \"c a cafe\": { canonical: \"cuillère à café\", kind: \"portioned\", plural: \"cuillères à café\" },\n \"c a c\": { canonical: \"cuillère à café\", kind: \"portioned\", plural: \"cuillères à café\" },\n cac: { canonical: \"cuillère à café\", kind: \"portioned\", plural: \"cuillères à café\" },\n verre: { canonical: \"verre\", kind: \"portioned\" },\n verres: { canonical: \"verre\", kind: \"portioned\" },\n bol: { canonical: \"bol\", kind: \"portioned\" },\n tasse: { canonical: \"tasse\", kind: \"portioned\" },\n tasses: { canonical: \"tasse\", kind: \"portioned\" },\n\n // Packaging and natural units: countable, so they round to whole things.\n sachet: { canonical: \"sachet\", kind: \"portioned\" },\n sachets: { canonical: \"sachet\", kind: \"portioned\" },\n gousse: { canonical: \"gousse\", kind: \"portioned\" },\n gousses: { canonical: \"gousse\", kind: \"portioned\" },\n tranche: { canonical: \"tranche\", kind: \"portioned\" },\n tranches: { canonical: \"tranche\", kind: \"portioned\" },\n botte: { canonical: \"botte\", kind: \"portioned\" },\n bottes: { canonical: \"botte\", kind: \"portioned\" },\n boite: { canonical: \"boîte\", kind: \"portioned\" },\n boites: { canonical: \"boîte\", kind: \"portioned\" },\n pot: { canonical: \"pot\", kind: \"portioned\" },\n pots: { canonical: \"pot\", kind: \"portioned\" },\n brique: { canonical: \"brique\", kind: \"portioned\" },\n feuille: { canonical: \"feuille\", kind: \"portioned\" },\n feuilles: { canonical: \"feuille\", kind: \"portioned\" },\n branche: { canonical: \"branche\", kind: \"portioned\" },\n branches: { canonical: \"branche\", kind: \"portioned\" },\n\n // Deliberately imprecise: the number is a figure of speech.\n pincee: { canonical: \"pincée\", kind: \"vague\", plural: \"pincées\" },\n pincees: { canonical: \"pincée\", kind: \"vague\", plural: \"pincées\" },\n trait: { canonical: \"trait\", kind: \"vague\" },\n traits: { canonical: \"trait\", kind: \"vague\" },\n filet: { canonical: \"filet\", kind: \"vague\" },\n filets: { canonical: \"filet\", kind: \"vague\" },\n goutte: { canonical: \"goutte\", kind: \"vague\", plural: \"gouttes\" },\n gouttes: { canonical: \"goutte\", kind: \"vague\", plural: \"gouttes\" },\n poignee: { canonical: \"poignée\", kind: \"vague\", plural: \"poignées\" },\n poignees: { canonical: \"poignée\", kind: \"vague\", plural: \"poignées\" },\n};\n\n/**\n * Lowercase, strip accents and drop abbreviation dots, so lookups survive French\n * spelling variants. Recipes write \"c. à soupe\" and \"c à s\" as readily as the\n * full \"cuillère à soupe\", and an unrecognised unit is worse than a wrong one:\n * the amount falls through to the countable branch and gets rounded as though a\n * spoonful were an indivisible object.\n */\nexport function normalizeUnitKey(text: string): string {\n return text\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/[̀-ͯ]/g, \"\")\n .replace(/\\./g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\n/** Longest keys first, so \"cuillere a soupe\" wins over \"cuillere\". */\nexport const UNIT_KEYS = Object.keys(UNITS).sort((a, b) => b.length - a.length);\n\nexport function lookupUnit(text: string): UnitInfo | null {\n return UNITS[normalizeUnitKey(text)] ?? null;\n}\n\n/**\n * Metric ladders, used to keep a scaled amount at a human size.\n *\n * Multiplying a recipe by thirty is arithmetically fine and practically poor:\n * \"8335 g de sucre\" is correct, and nobody weighs eight thousand grams. Each\n * measured unit therefore knows the unit above and below it, so a large amount\n * climbs the ladder and a small one comes back down.\n */\ninterface UnitStep {\n /** Unit to switch to, and how many of the current unit it holds. */\n to: string;\n per: number;\n}\n\nconst PROMOTIONS: Record<string, UnitStep> = {\n mg: { to: \"g\", per: 1000 },\n g: { to: \"kg\", per: 1000 },\n ml: { to: \"l\", per: 1000 },\n cl: { to: \"l\", per: 100 },\n dl: { to: \"l\", per: 10 },\n};\n\nconst DEMOTIONS: Record<string, UnitStep> = {\n kg: { to: \"g\", per: 1000 },\n l: { to: \"cl\", per: 100 },\n dl: { to: \"cl\", per: 10 },\n cl: { to: \"ml\", per: 10 },\n g: { to: \"mg\", per: 1000 },\n};\n\nexport interface ConvertedAmount {\n amount: number;\n unit: UnitInfo;\n}\n\n/**\n * Move an amount to the unit a cook would actually write it in.\n *\n * Promotion happens at a full unit of the next step up, so 999 g stays grams and\n * 1000 g becomes a kilo. Demotion happens below one, so half a litre reads\n * \"50 cl\" rather than \"1/2 l\". Only one step is taken in each direction, which\n * covers cooking quantities and keeps the result predictable.\n *\n * Rounding to two decimals after conversion keeps the error under a tenth of a\n * percent, well below what any kitchen scale resolves.\n */\nexport function convertToReadableUnit(unit: UnitInfo, amount: number): ConvertedAmount {\n if (unit.kind !== \"measured\" || !Number.isFinite(amount) || amount <= 0) {\n return { amount, unit };\n }\n\n const up = PROMOTIONS[unit.canonical];\n if (up && amount >= up.per) {\n const target = lookupUnit(up.to);\n if (target) return { amount: Math.round((amount / up.per) * 100) / 100, unit: target };\n }\n\n const down = DEMOTIONS[unit.canonical];\n if (down && amount < 1) {\n const target = lookupUnit(down.to);\n if (target) return { amount: Math.round(amount * down.per * 100) / 100, unit: target };\n }\n\n return { amount, unit };\n}\n\n/**\n * Render a unit for a given amount, choosing singular or plural.\n *\n * French takes the plural from two onwards, so 1,5 stays singular: \"1,5 cuillère\n * à soupe\", not \"1,5 cuillères\".\n */\nexport function formatUnit(unit: UnitInfo, amount: number): string {\n if (amount < 2) return unit.canonical;\n if (unit.plural) return unit.plural;\n // Symbols such as \"g\", \"kg\" and \"cl\" are invariable.\n if (/^[a-z]{1,3}$/.test(unit.canonical)) return unit.canonical;\n return `${unit.canonical}s`;\n}\n","/**\n * Parsing of French quantities out of free-text ingredient lines.\n *\n * Marmiton stores ingredients as plain strings: \"200 g de farine\", \"3 oeufs\",\n * \"0.5 oignon coupée en cubes\", \"sel\". There is no structured amount anywhere, so\n * everything downstream depends on reading these correctly.\n */\n\nimport type { UnitInfo } from \"./units.js\";\nimport { lookupUnit, normalizeUnitKey, UNIT_KEYS } from \"./units.js\";\n\nexport interface ParsedQuantity {\n amount: number;\n /** Characters consumed from the start of the line. */\n length: number;\n}\n\n/** Unicode vulgar fractions, which appear in hand-written recipes. */\nconst VULGAR_FRACTIONS: Record<string, number> = {\n \"½\": 0.5,\n \"⅓\": 1 / 3,\n \"⅔\": 2 / 3,\n \"¼\": 0.25,\n \"¾\": 0.75,\n \"⅕\": 0.2,\n \"⅙\": 1 / 6,\n \"⅛\": 0.125,\n};\n\n/**\n * Read a leading amount.\n *\n * Handles, in order of precedence: a vulgar fraction glyph, a simple fraction\n * such as \"1/2\", a mixed number such as \"1 1/2\", and a decimal written with\n * either a dot or a French comma. Returns null when the line does not start with\n * a number, which is the normal case for \"sel\" or \"coriandre\".\n */\nexport function parseLeadingQuantity(text: string): ParsedQuantity | null {\n const trimmed = text.trimStart();\n const offset = text.length - trimmed.length;\n\n const glyph = trimmed[0];\n if (glyph && glyph in VULGAR_FRACTIONS) {\n return { amount: VULGAR_FRACTIONS[glyph]!, length: offset + 1 };\n }\n\n // \"1 1/2\" before \"1/2\" before \"1,5\", so the longest reading wins.\n const mixed = /^(\\d+)\\s+(\\d+)\\s*\\/\\s*(\\d+)/.exec(trimmed);\n if (mixed) {\n const whole = Number(mixed[1]);\n const numerator = Number(mixed[2]);\n const denominator = Number(mixed[3]);\n if (denominator !== 0) {\n return { amount: whole + numerator / denominator, length: offset + mixed[0].length };\n }\n }\n\n const fraction = /^(\\d+)\\s*\\/\\s*(\\d+)/.exec(trimmed);\n if (fraction) {\n const denominator = Number(fraction[2]);\n if (denominator !== 0) {\n return { amount: Number(fraction[1]) / denominator, length: offset + fraction[0].length };\n }\n }\n\n const decimal = /^(\\d+(?:[.,]\\d+)?)/.exec(trimmed);\n if (decimal) {\n const amount = Number(decimal[1]!.replace(\",\", \".\"));\n if (Number.isFinite(amount)) {\n return { amount, length: offset + decimal[0].length };\n }\n }\n\n return null;\n}\n\nexport interface ParsedIngredient {\n /** The line exactly as Marmiton stores it. */\n original: string;\n amount: number | null;\n unit: UnitInfo | null;\n /** Raw unit text as written, kept so the rewrite can stay faithful. */\n unitText: string | null;\n /** What the amount and unit apply to, for example \"farine\" or \"oeufs\". */\n item: string;\n}\n\n/**\n * Split an ingredient line into amount, unit and item.\n *\n * A missing amount is normal and not an error: many lines are just \"sel\". A\n * missing unit is equally normal and means the item is counted, as in \"3 oeufs\".\n */\nexport function parseIngredient(line: string): ParsedIngredient {\n const original = line;\n const text = line.trim();\n\n const quantity = parseLeadingQuantity(text);\n if (!quantity) {\n return { original, amount: null, unit: null, unitText: null, item: text };\n }\n\n let rest = text.slice(quantity.length).trimStart();\n\n // Try the longest unit spellings first, so \"cuillère à soupe\" is not read as\n // the shorter \"cuillère\" with \"à soupe\" spilling into the item name.\n let unit: UnitInfo | null = null;\n let unitText: string | null = null;\n\n const normalizedRest = normalizeUnitKey(rest);\n for (const key of UNIT_KEYS) {\n if (normalizedRest === key || normalizedRest.startsWith(`${key} `)) {\n unit = lookupUnit(key);\n // Consume the same number of words from the original text, which may be\n // spelled with accents the normalized key has lost.\n const wordCount = key.split(\" \").length;\n const words = rest.split(/\\s+/);\n unitText = words.slice(0, wordCount).join(\" \");\n rest = words.slice(wordCount).join(\" \");\n break;\n }\n }\n\n // \"200 g de farine\" reads better as item \"farine\" than \"de farine\".\n const item = rest.replace(/^(?:de\\s+la\\s+|de\\s+l'|d'|de\\s+|du\\s+|des\\s+)/i, \"\").trim();\n\n return { original, amount: quantity.amount, unit, unitText, item };\n}\n\nexport interface FormatAmountOptions {\n /**\n * Whether to snap near-fractions to 1/4, 1/3, 1/2, 2/3 and 3/4.\n *\n * True for things a cook counts or spoons out: \"1/3 cuillère\" is how a kitchen\n * expresses it, \"0,33 cuillère\" is not. False for mass and volume, which are\n * decimal by nature: nobody weighs \"8 1/3 kg\" of sugar, they weigh 8,33 kg.\n */\n fractions?: boolean;\n}\n\n/**\n * Render an amount the way a recipe would write it.\n */\nexport function formatAmount(amount: number, options: FormatAmountOptions = {}): string {\n if (!Number.isFinite(amount)) return \"\";\n if (Number.isInteger(amount)) return String(amount);\n\n if (options.fractions === false) {\n // French recipes write decimals with a comma.\n return String(Math.round(amount * 100) / 100).replace(\".\", \",\");\n }\n\n const whole = Math.floor(amount);\n const rest = amount - whole;\n const known: Array<[number, string]> = [\n [0.25, \"1/4\"],\n [1 / 3, \"1/3\"],\n [0.5, \"1/2\"],\n [2 / 3, \"2/3\"],\n [0.75, \"3/4\"],\n ];\n for (const [value, label] of known) {\n if (Math.abs(rest - value) < 0.02) {\n return whole > 0 ? `${whole} ${label}` : label;\n }\n }\n\n // French recipes write decimals with a comma.\n return String(Math.round(amount * 100) / 100).replace(\".\", \",\");\n}\n","/**\n * Scaling ingredient quantities.\n *\n * The guiding rule is that a scaled quantity must be something a cook can act on.\n * Multiplying every number by the factor is arithmetically correct and practically\n * useless: it produces \"2,4 oeufs\" and \"0,67 pincée de sel\" with the same\n * confidence as \"267 g de farine\". Each line is therefore classified, and the\n * classification travels with the result so the caller can see what was computed\n * and what was left alone.\n */\n\nimport { formatAmount, parseIngredient } from \"./quantity.js\";\nimport { convertToReadableUnit, formatUnit } from \"./units.js\";\n\nexport type ScalingKind =\n /** Multiplied and rounded to a readable value. */\n | \"scaled\"\n /** Multiplied, then rounded to something countable. */\n | \"rounded\"\n /** Left untouched: no amount, or an amount too vague to be meaningful. */\n | \"unscaled\";\n\nexport interface ScaledIngredient {\n /** The line exactly as it was given. */\n original: string;\n /** The line after scaling, identical to `original` when unscaled. */\n text: string;\n /**\n * The scaled quantity, expressed in `unit`.\n *\n * Read it together with `unit`, never on its own: a large result is moved to a\n * bigger unit, so scaling \"200 g\" by ten gives an amount of 2 with a unit of\n * \"kg\". The bare number can therefore shrink while the quantity grows.\n */\n amount: number | null;\n /** The unit `amount` is in, which may differ from the one the recipe used. */\n unit: string | null;\n scaling: ScalingKind;\n /** Why the line was left alone, when it was. */\n note?: string;\n}\n\n/** Round to a step, keeping two decimals at most. */\nfunction roundTo(value: number, step: number): number {\n return Math.round(Math.round(value / step) * step * 100) / 100;\n}\n\n/**\n * Round a measured amount to something a kitchen scale can show.\n *\n * Large amounts do not need gram precision, and small ones do, so the step grows\n * with the value rather than being fixed.\n */\nfunction roundMeasured(value: number): number {\n if (value >= 100) return roundTo(value, 5);\n if (value >= 10) return roundTo(value, 1);\n if (value >= 1) return roundTo(value, 0.5);\n return Math.round(value * 100) / 100;\n}\n\n/**\n * Round a countable amount to something a cook can act on.\n *\n * Below one whole unit the amount is kept as a fraction rather than rounded to a\n * whole: rounding 1/3 of a sachet up to 1 would make a recipe scaled *down* call\n * for *more* than the original, which is worse than any fraction. Rounding it\n * down to zero would silently delete the ingredient, which is worse still.\n */\nfunction roundCountable(value: number, allowHalves: boolean): number {\n if (value <= 0) return 0;\n\n if (value < 1) {\n // Snap to a fraction a cook can act on. A raw decimal such as 0,15 is no\n // more usable than zero: nobody measures fifteen hundredths of an egg. A\n // quarter is the smallest fraction worth printing, so anything under it is\n // clamped up rather than shrinking towards nothing.\n const USABLE_FRACTIONS = [0.25, 1 / 3, 0.5, 2 / 3, 0.75];\n let closest = USABLE_FRACTIONS[0]!;\n for (const fraction of USABLE_FRACTIONS) {\n if (Math.abs(value - fraction) < Math.abs(value - closest)) closest = fraction;\n }\n return Math.round(closest * 100) / 100;\n }\n\n return allowHalves ? roundTo(value, 0.5) : Math.round(value);\n}\n\nexport interface ScaleOptions {\n /** Multiplier applied to the quantities. */\n factor: number;\n}\n\n/**\n * Make a counted item agree with its amount, in both directions.\n *\n * French takes the plural from two onwards, so \"2 oeufs\" halved reads \"1 oeuf\"\n * and \"1 brioche\" tripled reads \"3 brioches\". Only the head word is touched, and\n * only its trailing \"s\": nouns already ending in -s, -x or -z are invariable in\n * the plural (\"ananas\", \"choux\"), and forcing one would be worse than leaving the\n * word as the recipe wrote it.\n */\nfunction agreeWithAmount(item: string, amount: number): string {\n if (!item) return item;\n\n const words = item.split(\" \");\n const head = words[0] ?? \"\";\n if (head.length <= 3) return item;\n\n const wantsPlural = amount >= 2;\n const isPlural = /s$/i.test(head);\n\n if (wantsPlural && !isPlural) {\n // Words ending in -s, -x or -z do not take a plural mark.\n if (/[sxz]$/i.test(head)) return item;\n words[0] = `${head}s`;\n } else if (!wantsPlural && isPlural) {\n // \"ananas\", \"anis\", \"couscous\": the -s belongs to the singular.\n if (/[aiou]s$/i.test(head)) return item;\n words[0] = head.slice(0, -1);\n }\n\n return words.join(\" \");\n}\n\n/**\n * Scale one ingredient line.\n *\n * Whole eggs and other countable items are rounded up to a usable amount rather\n * than being reported as fractions, and vague measures such as pinches are\n * returned untouched with a note.\n */\nexport function scaleIngredient(line: string, options: ScaleOptions): ScaledIngredient {\n const { factor } = options;\n const parsed = parseIngredient(line);\n\n if (parsed.amount === null) {\n return {\n original: parsed.original,\n text: parsed.original,\n amount: null,\n unit: null,\n scaling: \"unscaled\",\n note: \"No quantity given; adjust to taste.\",\n };\n }\n\n const kind = parsed.unit?.kind ?? \"countable\";\n\n if (kind === \"vague\") {\n return {\n original: parsed.original,\n text: parsed.original,\n amount: parsed.amount,\n unit: parsed.unit?.canonical ?? null,\n scaling: \"unscaled\",\n note: \"Measure is approximate by nature; adjust to taste.\",\n };\n }\n\n const raw = parsed.amount * factor;\n let amount: number;\n let scaling: ScalingKind;\n let unit = parsed.unit;\n\n if (kind === \"measured\") {\n // Round first, then move up or down the metric ladder, so the value a cook\n // reads is both correctly rounded and at a human size: 8335 g becomes\n // 8,34 kg rather than staying eight thousand grams.\n const converted = convertToReadableUnit(parsed.unit!, roundMeasured(raw));\n amount = converted.amount;\n unit = converted.unit;\n scaling = \"scaled\";\n } else if (kind === \"portioned\") {\n // Spoons tolerate halves; boxes and sachets do not.\n const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? \"\");\n amount = roundCountable(raw, allowHalves);\n scaling = \"rounded\";\n } else {\n // Countable item with no unit: \"3 oeufs\", \"2 tomates\".\n amount = roundCountable(raw, false);\n scaling = \"rounded\";\n }\n\n const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : \"\";\n const separator = unit ? \" de \" : \" \";\n // A countable item agrees with its number: \"1/3 oeuf\", \"3 brioches\".\n const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);\n const item = itemText ? `${separator}${itemText}` : \"\";\n // Mass and volume read as decimals; counted and spooned things read as\n // fractions.\n const amountText = formatAmount(amount, { fractions: kind !== \"measured\" });\n const text = `${amountText}${unitLabel}${item}`.trim();\n\n const result: ScaledIngredient = {\n original: parsed.original,\n text,\n amount,\n unit: unit?.canonical ?? null,\n scaling,\n };\n\n if (scaling === \"rounded\" && Math.abs(raw - amount) > 0.01) {\n result.note = `Rounded from ${formatAmount(Math.round(raw * 100) / 100)}.`;\n }\n\n return result;\n}\n\nexport function scaleIngredients(lines: string[], options: ScaleOptions): ScaledIngredient[] {\n return lines.map((line) => scaleIngredient(line, options));\n}\n\n/** An ingredient list returned unchanged, for when no scaling was requested. */\nexport function passthroughIngredients(lines: string[]): ScaledIngredient[] {\n return lines.map((line) => {\n const parsed = parseIngredient(line);\n return {\n original: parsed.original,\n text: parsed.original,\n amount: parsed.amount,\n unit: parsed.unit?.canonical ?? null,\n scaling: \"unscaled\" as const,\n };\n });\n}\n","/** Pieces shared by the three tools: schemas, error mapping, text mirrors. */\n\nimport { z } from \"zod\";\nimport { MarmitonError } from \"../errors.js\";\n\n/** Many MCP clients render only the text block, so it must read on its own. */\nexport const MAX_TEXT_MIRROR_CHARS = 2000;\n\nexport const scaledIngredientSchema = z.object({\n original: z.string().describe(\"The ingredient line exactly as Marmiton publishes it.\"),\n text: z.string().describe(\"The line after scaling, identical to 'original' when unscaled.\"),\n amount: z\n .number()\n .nullable()\n .describe(\n \"The scaled quantity, expressed in 'unit'. Read the two together: a large result is moved to a \" +\n \"bigger unit, so 200 g scaled tenfold reads as 2 kg, and the bare number shrinks while the \" +\n \"quantity grows.\",\n ),\n unit: z\n .string()\n .nullable()\n .describe(\"The unit 'amount' is in, which may differ from the one the recipe used.\"),\n scaling: z\n .enum([\"scaled\", \"rounded\", \"unscaled\"])\n .describe(\n \"'scaled' was multiplied cleanly. 'rounded' was multiplied then rounded to something countable. \" +\n \"'unscaled' was left alone, either because it carries no quantity or because the measure is \" +\n \"approximate by nature.\",\n ),\n note: z.string().optional().describe(\"Why the line was rounded or left alone.\"),\n});\n\nexport interface ToolResult {\n // The SDK's CallToolResult carries an index signature for protocol extensions.\n [key: string]: unknown;\n content: Array<{ type: \"text\"; text: string }>;\n structuredContent?: Record<string, unknown>;\n isError?: boolean;\n}\n\nexport function ok(structured: Record<string, unknown>, text: string): ToolResult {\n return {\n content: [{ type: \"text\", text: truncate(text, MAX_TEXT_MIRROR_CHARS) }],\n structuredContent: structured,\n };\n}\n\n/**\n * Error results carry no structuredContent: the SDK validates it against the\n * tool's declared output schema, which an error payload does not satisfy.\n */\nexport function toToolError(error: unknown): ToolResult {\n const known =\n error instanceof MarmitonError\n ? error\n : new MarmitonError(\"network_error\", error instanceof Error ? error.message : String(error));\n\n const lines = [`[${known.code}] ${known.message}`];\n if (known.details.hint) lines.push(`Hint: ${known.details.hint}`);\n\n return { content: [{ type: \"text\", text: lines.join(\"\\n\") }], isError: true };\n}\n\nexport function truncate(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text;\n return `${text.slice(0, maxChars - 1).trimEnd()}…`;\n}\n","/**\n * scale_ingredients: rescale an arbitrary ingredient list, offline.\n *\n * This tool makes no network request. It exposes the quantity parser on its own,\n * so a list copied from anywhere, not only Marmiton, can be rescaled with the\n * same care about what is and is not safe to multiply.\n */\n\nimport { z } from \"zod\";\nimport { invalidInput } from \"../errors.js\";\nimport { scaleIngredients } from \"../recipe/scale.js\";\nimport { ok, scaledIngredientSchema, toToolError } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const scaleIngredientsDescription = [\n \"Rescale a list of ingredient lines to a different number of servings, without contacting any website.\",\n \"Give either 'factor' directly, or 'from_servings' and 'to_servings' and the factor is computed.\",\n \"Works on any French ingredient list, whatever its source, so it also serves a recipe the user pasted in.\",\n \"Quantities in grams or millilitres are multiplied and rounded to readable values; countable things such as\",\n \"eggs or spoons are rounded to whole or half units; lines with no quantity, or with an approximate one such\",\n \"as a pinch, are returned untouched and flagged. Prefer this over doing the arithmetic yourself.\",\n].join(\" \");\n\nexport const scaleIngredientsInputShape = {\n ingredients: z\n .array(z.string().max(300))\n .min(1)\n .max(100)\n .describe(\"Ingredient lines, for example ['200 g de farine', '3 oeufs', 'sel'].\"),\n factor: z\n .number()\n .positive()\n .max(100)\n .optional()\n .describe(\"Multiplier to apply. Use this or the from/to pair.\"),\n from_servings: z\n .number()\n .positive()\n .max(500)\n .optional()\n .describe(\"How many servings the list is written for.\"),\n to_servings: z.number().positive().max(500).optional().describe(\"How many servings are wanted.\"),\n};\n\nexport const scaleIngredientsOutputShape = {\n factor: z.number(),\n ingredients: z.array(scaledIngredientSchema),\n scaled_count: z.number().int(),\n rounded_count: z.number().int(),\n unscaled_count: z.number().int(),\n notes: z.array(z.string()),\n};\n\nexport interface ScaleIngredientsArgs {\n ingredients: string[];\n factor?: number;\n from_servings?: number;\n to_servings?: number;\n}\n\nexport function runScaleIngredients(args: ScaleIngredientsArgs): ToolResult {\n try {\n let factor: number;\n\n if (args.factor !== undefined) {\n factor = args.factor;\n } else if (args.from_servings !== undefined && args.to_servings !== undefined) {\n factor = args.to_servings / args.from_servings;\n } else {\n throw invalidInput(\n \"Provide either 'factor', or both 'from_servings' and 'to_servings'.\",\n \"For example from_servings=6 and to_servings=4, or factor=0.667.\",\n );\n }\n\n const ingredients = scaleIngredients(args.ingredients, { factor });\n const counts = {\n scaled: ingredients.filter((entry) => entry.scaling === \"scaled\").length,\n rounded: ingredients.filter((entry) => entry.scaling === \"rounded\").length,\n unscaled: ingredients.filter((entry) => entry.scaling === \"unscaled\").length,\n };\n\n const notes: string[] = [];\n if (counts.rounded > 0) {\n notes.push(\n `${counts.rounded} quantity(ies) were rounded to stay usable, rather than left as fractions.`,\n );\n }\n if (counts.unscaled > 0) {\n notes.push(\n `${counts.unscaled} line(s) carry no usable quantity and were returned unchanged; adjust to taste.`,\n );\n }\n\n const structured = {\n factor: Math.round(factor * 1000) / 1000,\n ingredients,\n scaled_count: counts.scaled,\n rounded_count: counts.rounded,\n unscaled_count: counts.unscaled,\n notes,\n };\n\n const lines = ingredients\n .map((entry) => {\n const flag = entry.scaling === \"unscaled\" ? \" (non ajusté)\" : \"\";\n return `- ${entry.text}${flag}`;\n })\n .join(\"\\n\");\n\n return ok(structured, `Facteur ${Math.round(factor * 100) / 100}:\\n${lines}`);\n } catch (error) {\n return toToolError(error);\n }\n}\n","/**\n * search_recipes: find recipes on Marmiton by free text.\n */\n\nimport { z } from \"zod\";\nimport { invalidInput } from \"../errors.js\";\nimport type { MarmitonClient } from \"../marmiton/client.js\";\nimport { ok, toToolError } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const searchRecipesDescription = [\n \"Search Marmiton for recipes by free text: a dish, an ingredient, or both, for example 'tarte aux pommes'\",\n \"or 'poulet curry coco'. Marmiton is a French site, so French terms work best.\",\n \"Returns candidate recipes with the id and URL needed to read one with get_recipe.\",\n \"One call returns one page of results. Marmiton's robots.txt disallows paginating search results, so this\",\n \"server does not, and there is no page parameter: narrow the query instead of asking for more pages.\",\n].join(\" \");\n\nexport const searchRecipesInputShape = {\n // Deliberately no min(1): an empty string would be rejected by the schema with\n // a protocol-level validation error, while a whitespace-only one would reach\n // the tool and come back as invalid_input. Letting both through to the same\n // check gives callers one error code for one problem.\n query: z\n .string()\n .max(200)\n .describe(\"What to search for, in French, for example 'tarte aux pommes'.\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(30)\n .default(10)\n .describe(\"Maximum recipes to return from this page of results.\"),\n};\n\nexport const searchRecipesOutputShape = {\n query: z.string(),\n results: z.array(\n z.object({\n id: z.string().describe(\"Marmiton recipe id. Pass this to get_recipe.\"),\n title: z.string(),\n url: z.string(),\n image_url: z.string().nullable(),\n }),\n ),\n result_count: z.number().int(),\n total_available: z.number().int().describe(\"Recipes on this page before applying 'limit'.\"),\n source: z.literal(\"marmiton.org\"),\n notes: z.array(z.string()),\n};\n\nexport interface SearchRecipesArgs {\n query: string;\n limit: number;\n}\n\nexport async function runSearchRecipes(\n client: MarmitonClient,\n args: SearchRecipesArgs,\n): Promise<ToolResult> {\n try {\n // Trimming happens before the emptiness check: a whitespace-only query is as\n // empty as a missing one, and letting it through would spend a request on a\n // search for nothing.\n const query = args.query.trim();\n if (!query) {\n throw invalidInput(\n \"'query' cannot be empty.\",\n 'Give a dish or an ingredient, for example query=\"tarte aux pommes\".',\n );\n }\n\n const { data, cached } = await client.search(query);\n const notes: string[] = [];\n if (cached) notes.push(\"Served from this server's short-lived in-memory cache.\");\n if (data.length > args.limit) {\n notes.push(`This page holds ${data.length} recipes; showing the first ${args.limit}.`);\n }\n if (data.length === 0) {\n notes.push(\n \"Marmiton found no recipe for this query. Try French terms, or a broader wording. \" +\n \"Note that only one page of results is available.\",\n );\n }\n\n const results = data.slice(0, args.limit).map((recipe) => ({\n id: recipe.id,\n title: recipe.title,\n url: recipe.url,\n image_url: recipe.imageUrl,\n }));\n\n const structured = {\n query,\n results,\n result_count: results.length,\n total_available: data.length,\n source: \"marmiton.org\" as const,\n notes,\n };\n\n const header =\n results.length > 0\n ? `${results.length} recette(s) pour \"${query}\":`\n : `Aucune recette trouvée pour \"${query}\".`;\n const list = results\n .map((recipe, index) => `${index + 1}. ${recipe.title} · id: ${recipe.id}`)\n .join(\"\\n\");\n\n return ok(structured, `${header}\\n${list}`);\n } catch (error) {\n return toToolError(error);\n }\n}\n"],"mappings":"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","names":["z","z","z","z","z","z"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/version.ts","../src/config.ts","../src/server.ts","../src/marmiton/cache.ts","../src/errors.ts","../src/marmiton/rateLimiter.ts","../src/marmiton/http.ts","../src/recipe/duration.ts","../src/marmiton/jsonld.ts","../src/marmiton/urls.ts","../src/marmiton/parseRecipe.ts","../src/marmiton/client.ts","../src/tools/getRecipe.ts","../src/recipe/units.ts","../src/recipe/quantity.ts","../src/recipe/scale.ts","../src/tools/shared.ts","../src/tools/scaleIngredients.ts","../src/tools/searchRecipes.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * Entry point: an MCP server for Marmiton over stdio.\n */\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { createLogger, loadConfig } from \"./config.js\";\nimport { createServer } from \"./server.js\";\n\nasync function main(): Promise<void> {\n const config = loadConfig();\n const logger = createLogger(config.logLevel);\n const server = createServer({ config, logger });\n\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n const shutdown = () => {\n void server.close().finally(() => process.exit(0));\n };\n process.on(\"SIGINT\", shutdown);\n process.on(\"SIGTERM\", shutdown);\n}\n\nmain().catch((error: unknown) => {\n process.stderr.write(\n `[mcp-marmiton] fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exit(1);\n});\n","/**\n * Kept in sync with package.json and server.json. The User-Agent embeds it, so it\n * must not drift; the release checklist bumps all three together.\n */\nexport const PKG_VERSION = \"1.0.4\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-marmiton\";\n","/**\n * Runtime configuration, read from environment variables.\n *\n * A bad value never crashes the process: an MCP server that dies at startup\n * because of a typo in a client config file is very hard to diagnose from the\n * host application, so invalid input is clamped and reported on stderr.\n */\n\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport type LogLevel = \"silent\" | \"error\" | \"info\" | \"debug\";\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\n/**\n * MARMITON asks third-party clients to identify themselves with an application\n * name, a version and a link to the project, so the default follows that format.\n */\nexport const DEFAULT_USER_AGENT = `mcp-marmiton v${PKG_VERSION} (${REPO_URL})`;\n\nexport const DEFAULTS = {\n minIntervalMs: 1000,\n timeoutMs: 15_000,\n maxRetries: 3,\n cacheTtlMs: 15 * 60 * 1000,\n cacheMaxEntries: 200,\n logLevel: \"error\" as LogLevel,\n};\n\n/**\n * Floor on the request interval, enforced regardless of configuration.\n *\n * MARMITON publishes no rate limit and asks for nothing in return for a free\n * service. Self-imposed pacing is the whole of the politeness contract here, so\n * a value below this floor is refused and the default is used instead.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n\nconst LOG_LEVELS: LogLevel[] = [\"silent\", \"error\", \"info\", \"debug\"];\n\ninterface NumericRange {\n min: number;\n max: number;\n fallback: number;\n}\n\nfunction readNumber(name: string, env: NodeJS.ProcessEnv, range: NumericRange): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") return range.fallback;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`${name}=\"${raw}\" is not a number, using ${range.fallback}`);\n return range.fallback;\n }\n\n // Out-of-range values fall back to the default rather than being clamped to\n // the nearest bound. Clamping turns a typo into a silent behaviour change:\n // -1 retries becomes \"never retry\", and -1 cache entries disables the cache\n // outright, both of which look like working configuration.\n const rounded = Math.round(parsed);\n if (rounded < range.min || rounded > range.max) {\n warn(\n `${name}=${raw} is outside the accepted range ${range.min}-${range.max} and was ignored; ` +\n `using ${range.fallback}`,\n );\n return range.fallback;\n }\n return rounded;\n}\n\nfunction warn(message: string): void {\n process.stderr.write(`[mcp-marmiton] ${message}\\n`);\n}\n\n/**\n * Read the request interval, refusing anything below the floor.\n *\n * A value under the floor falls back to the default rather than to the floor\n * itself: someone who set 0 was not asking for 200, they were asking for no\n * pacing at all, and the safe reading of that request is to ignore it.\n */\nfunction readInterval(env: NodeJS.ProcessEnv): number {\n const raw = env.MARMITON_MIN_INTERVAL_MS;\n if (raw === undefined || raw.trim() === \"\") return DEFAULTS.minIntervalMs;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`MARMITON_MIN_INTERVAL_MS=\"${raw}\" is not a number, using ${DEFAULTS.minIntervalMs}ms`);\n return DEFAULTS.minIntervalMs;\n }\n\n const rounded = Math.round(parsed);\n if (rounded < MIN_ALLOWED_INTERVAL_MS) {\n warn(\n `MARMITON_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; ` +\n `using ${DEFAULTS.minIntervalMs}ms. Marmiton serves these pages for readers, so this client paces itself.`,\n );\n return DEFAULTS.minIntervalMs;\n }\n\n return Math.min(60_000, rounded);\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const rawUserAgent = env.MARMITON_USER_AGENT?.trim();\n const rawLogLevel = env.MARMITON_LOG_LEVEL?.trim().toLowerCase();\n\n let logLevel = DEFAULTS.logLevel;\n if (rawLogLevel) {\n if (LOG_LEVELS.includes(rawLogLevel as LogLevel)) {\n logLevel = rawLogLevel as LogLevel;\n } else {\n warn(`MARMITON_LOG_LEVEL=\"${rawLogLevel}\" is unknown, using \"${DEFAULTS.logLevel}\"`);\n }\n }\n\n return {\n userAgent: rawUserAgent || DEFAULT_USER_AGENT,\n minIntervalMs: readInterval(env),\n timeoutMs: readNumber(\"MARMITON_TIMEOUT_MS\", env, {\n min: 1000,\n max: 120_000,\n fallback: DEFAULTS.timeoutMs,\n }),\n maxRetries: readNumber(\"MARMITON_MAX_RETRIES\", env, {\n min: 0,\n max: 10,\n fallback: DEFAULTS.maxRetries,\n }),\n cacheTtlMs: readNumber(\"MARMITON_CACHE_TTL_MS\", env, {\n min: 0,\n max: 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.cacheTtlMs,\n }),\n cacheMaxEntries: readNumber(\"MARMITON_CACHE_MAX_ENTRIES\", env, {\n min: 0,\n max: 10_000,\n fallback: DEFAULTS.cacheMaxEntries,\n }),\n logLevel,\n };\n}\n\nconst LEVEL_RANK: Record<LogLevel, number> = { silent: 0, error: 1, info: 2, debug: 3 };\n\n/**\n * Logs go to stderr without exception. On a stdio transport, stdout carries the\n * protocol and any stray write there corrupts the session.\n */\nexport function createLogger(level: LogLevel) {\n const emit = (at: LogLevel, message: string) => {\n if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {\n process.stderr.write(`[mcp-marmiton] ${message}\\n`);\n }\n };\n return {\n error: (message: string) => emit(\"error\", message),\n info: (message: string) => emit(\"info\", message),\n debug: (message: string) => emit(\"debug\", message),\n };\n}\n\nexport type Logger = ReturnType<typeof createLogger>;\n","/**\n * MCP server wiring.\n *\n * One client, one rate limiter and one cache are shared by all tools, so pacing\n * applies to the server as a whole rather than per tool.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { Config, Logger } from \"./config.js\";\nimport { createLogger, loadConfig } from \"./config.js\";\nimport { MarmitonClient } from \"./marmiton/client.js\";\nimport {\n getRecipeDescription,\n getRecipeInputShape,\n getRecipeOutputShape,\n runGetRecipe,\n} from \"./tools/getRecipe.js\";\nimport type { GetRecipeArgs } from \"./tools/getRecipe.js\";\nimport {\n runScaleIngredients,\n scaleIngredientsDescription,\n scaleIngredientsInputShape,\n scaleIngredientsOutputShape,\n} from \"./tools/scaleIngredients.js\";\nimport type { ScaleIngredientsArgs } from \"./tools/scaleIngredients.js\";\nimport {\n runSearchRecipes,\n searchRecipesDescription,\n searchRecipesInputShape,\n searchRecipesOutputShape,\n} from \"./tools/searchRecipes.js\";\nimport type { SearchRecipesArgs } from \"./tools/searchRecipes.js\";\nimport { PKG_VERSION } from \"./version.js\";\n\nexport interface CreateServerOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/** This server only reads, so every tool is read-only. */\nconst READ_ONLY = {\n readOnlyHint: true,\n destructiveHint: false,\n idempotentHint: true,\n openWorldHint: true,\n} as const;\n\nexport function createServer(options: CreateServerOptions = {}): McpServer {\n const config = options.config ?? loadConfig();\n const logger = options.logger ?? createLogger(config.logLevel);\n const client = new MarmitonClient({\n config,\n logger,\n ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),\n });\n\n const server = new McpServer(\n { name: \"mcp-marmiton\", version: PKG_VERSION },\n {\n instructions:\n \"Tools for looking up French recipes on Marmiton. No API key is needed. \" +\n \"Typical flow: search_recipes to find a recipe and its id, then get_recipe with that id, \" +\n \"passing 'servings' when the user wants a different number of people. \" +\n \"Do not rescale quantities yourself: get_recipe and scale_ingredients already round countable \" +\n \"items sensibly and flag what cannot be scaled, which is what stops answers like '2.4 eggs'. \" +\n \"Marmiton disallows paginating search results, so narrow the query instead of asking for more pages. \" +\n \"When you show a recipe to a user, credit it and link the source URL.\",\n },\n );\n\n server.registerTool(\n \"search_recipes\",\n {\n title: \"Search recipes\",\n description: searchRecipesDescription,\n inputSchema: searchRecipesInputShape,\n outputSchema: searchRecipesOutputShape,\n annotations: READ_ONLY,\n },\n async (args) => runSearchRecipes(client, args as SearchRecipesArgs),\n );\n\n server.registerTool(\n \"get_recipe\",\n {\n title: \"Get a recipe\",\n description: getRecipeDescription,\n inputSchema: getRecipeInputShape,\n outputSchema: getRecipeOutputShape,\n annotations: READ_ONLY,\n },\n async (args) => runGetRecipe(client, args as GetRecipeArgs),\n );\n\n server.registerTool(\n \"scale_ingredients\",\n {\n title: \"Scale an ingredient list\",\n description: scaleIngredientsDescription,\n inputSchema: scaleIngredientsInputShape,\n outputSchema: scaleIngredientsOutputShape,\n annotations: READ_ONLY,\n },\n async (args) => runScaleIngredients(args as ScaleIngredientsArgs),\n );\n\n logger.info(\n `ready: user-agent=\"${config.userAgent}\", min interval ${config.minIntervalMs}ms, cache ${config.cacheTtlMs}ms`,\n );\n\n return server;\n}\n","/**\n * In-memory TTL + LRU cache.\n *\n * Nothing is written to disk. The cache exists so that repeated questions about\n * the same recipe inside one conversation do not hit Marmiton again, not to\n * build a local copy of the site.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) return;\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) break;\n this.store.delete(oldest.value);\n }\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Error taxonomy surfaced to the calling model.\n *\n * The rule carried over from the sibling projects: a failure must never be\n * reported as an empty result. A model that sees \"no recipe found\" cannot tell\n * that apart from a genuine absence, and will confidently tell the user the\n * recipe does not exist.\n */\n\nexport type ErrorCode =\n \"not_found\" | \"invalid_input\" | \"rate_limited\" | \"parse_failure\" | \"network_error\" | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class MarmitonError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"MarmitonError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-marmiton/issues\";\n\nexport function notFound(url: string, what: string): MarmitonError {\n return new MarmitonError(\"not_found\", `Marmiton has no recipe at ${what}.`, {\n url,\n status: 404,\n hint: \"Use search_recipes to find a recipe and its id, then call this tool with that id.\",\n });\n}\n\nexport function invalidInput(message: string, hint?: string): MarmitonError {\n return new MarmitonError(\"invalid_input\", message, hint ? { hint } : {});\n}\n\nexport function rateLimited(url: string, retryAfterMs: number): MarmitonError {\n return new MarmitonError(\n \"rate_limited\",\n \"Marmiton is rate limiting this client. This does NOT mean the recipe does not exist.\",\n {\n url,\n retryAfterMs,\n hint:\n `Wait about ${Math.ceil(retryAfterMs / 1000)} seconds, then call the same tool again with the ` +\n \"same arguments. If it keeps happening, raise MARMITON_MIN_INTERVAL_MS.\",\n },\n );\n}\n\nexport function parseFailure(url: string, what: string): MarmitonError {\n return new MarmitonError(\n \"parse_failure\",\n `The page loaded but the expected structured recipe data was not found (${what}). ` +\n \"Marmiton may have changed how it publishes it.\",\n { url, hint: `Please report this, with the recipe you asked for, at ${ISSUES_URL}` },\n );\n}\n\nexport function upstreamError(url: string, status: number): MarmitonError {\n return new MarmitonError(\"network_error\", `Marmiton returned HTTP ${status}.`, {\n url,\n status,\n ...(status >= 500 ? { hint: \"This is a problem on Marmiton's side. Try again shortly.\" } : {}),\n });\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * Marmiton publishes no rate limit and answers a burst by refusing rather than\n * by slowing down, so requests run one at a time with a floor on the gap between\n * starts. When the site does push back, the interval doubles and then decays\n * back down as requests succeed, which recovers faster than a fixed delay and\n * behaves better than a constant rate.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 10_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => task());\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /**\n * Wait for this request's slot, then claim it.\n *\n * Called once per upstream request rather than once per task, because a task\n * runs a whole retry chain: stamping only its start would let the next task\n * follow the chain's last request with no gap at all.\n */\n async beforeRequest(): Promise<void> {\n await this.waitForSlot();\n this.lastStart = Date.now();\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n // Clamped to the interval: a clock stepped backwards, by NTP or a resumed\n // virtual machine, would otherwise make this wait for the size of the step,\n // and the queue is serial so every pending request would wait behind it.\n const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, with backoff.\n *\n * Marmiton answers with ordinary status codes, so this layer mostly translates\n * them. The one judgement call is on a body too small to be a real page, which is\n * how a site under load often refuses without saying so.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { MarmitonError, notFound, rateLimited, upstreamError } from \"../errors.js\";\nimport { RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 20_000;\n\n/** A real Marmiton page weighs well over 100 KB. */\nconst MIN_PLAUSIBLE_HTML = 2000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one page as HTML, retrying transient conditions.\n *\n * The retry loop and its sleeps run inside a single limiter slot, so a queued\n * request cannot slip into the window the current one is backing away from.\n */\nexport async function fetchHtml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n\n return limiter.schedule(async () => {\n let lastError: MarmitonError | undefined;\n\n // Set when the site says how long to stay away; it replaces our own guess\n // for the next attempt. Applied here rather than where it is read, so no\n // wait is ever served after the last attempt, when nobody would use it.\n let askedWaitMs: number | null = null;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);\n askedWaitMs = null;\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n let retryAfterMs: number | null = null;\n try {\n await limiter.beforeRequest();\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"fr-FR,fr;q=0.9,en;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n retryAfterMs = parseRetryAfter(response.headers.get(\"retry-after\"));\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n if (status === 429 || status === 503 || status === 403) {\n limiter.penalize();\n // A server that says when to come back knows better than our own guess.\n askedWaitMs = retryAfterMs;\n lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));\n logger.info(`rate limited on ${url}, interval now ${limiter.currentIntervalMs}ms`);\n continue;\n }\n if (status === 404) throw notFound(url, \"that address\");\n if (status >= 500) {\n lastError = upstreamError(url, status);\n continue;\n }\n if (status >= 400) throw upstreamError(url, status);\n\n const trimmed = body.trim();\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\\/html>/i.test(trimmed)) {\n // Too small to be a page: treat it as a refusal rather than parse it and\n // report an empty result.\n limiter.penalize();\n lastError = rateLimited(url, backoffDelay(attempt));\n logger.info(`implausibly short body on ${url}, treating as rate limiting`);\n continue;\n }\n\n limiter.relax();\n return body;\n }\n\n throw lastError ?? new MarmitonError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\n/** `Retry-After` carries either seconds or an HTTP date. */\nfunction parseRetryAfter(raw: string | null): number | null {\n if (!raw) return null;\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);\n const when = Date.parse(raw);\n if (Number.isNaN(when)) return null;\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, url: string): MarmitonError {\n if (error instanceof MarmitonError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new MarmitonError(\"timeout\", \"Marmiton did not answer in time.\", {\n url,\n hint: \"Raise MARMITON_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new MarmitonError(\"network_error\", `Could not reach Marmiton: ${message}`, { url });\n}\n","/**\n * ISO 8601 durations, which is how schema.org expresses cooking times.\n *\n * Marmiton writes \"PT25M\" for 25 minutes and \"PT1H30M\" for an hour and a half.\n */\n\nconst ISO_DURATION = /^P(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?$/;\n\n/** Minutes, or null when the value is absent or unreadable. */\nexport function parseIsoDuration(value: unknown): number | null {\n if (typeof value !== \"string\") return null;\n const match = ISO_DURATION.exec(value.trim().toUpperCase());\n if (!match) return null;\n\n const [, days, hours, minutes, seconds] = match;\n const total =\n Number(days ?? 0) * 1440 +\n Number(hours ?? 0) * 60 +\n Number(minutes ?? 0) +\n Number(seconds ?? 0) / 60;\n\n // \"P\" alone parses but means nothing useful.\n if (total === 0 && !minutes && !hours && !days && !seconds) return null;\n return Math.round(total);\n}\n\n/** Minutes as \"1 h 30\" or \"25 min\", for the human-readable mirror. */\nexport function formatMinutes(minutes: number | null): string | null {\n if (minutes === null || !Number.isFinite(minutes) || minutes <= 0) return null;\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n if (hours === 0) return `${rest} min`;\n if (rest === 0) return `${hours} h`;\n return `${hours} h ${String(rest).padStart(2, \"0\")}`;\n}\n\nexport interface ParsedYield {\n /** Number of servings, or null when the text carries no number. */\n count: number | null;\n /** What is being counted: \"personnes\", \"pièces\", \"parts\". */\n unit: string | null;\n /** The original text, always preserved. */\n text: string;\n}\n\n/**\n * Read a schema.org recipeYield.\n *\n * Marmiton writes \"6 personnes\" but also \"15 pièces\", so the unit is kept: a\n * recipe yielding pieces cannot be described as serving people.\n */\nexport function parseYield(value: unknown): ParsedYield {\n // schema.org allows recipeYield as a number, a string, or an array of either.\n const text =\n typeof value === \"number\" && Number.isFinite(value)\n ? String(value)\n : typeof value === \"string\"\n ? value.trim()\n : Array.isArray(value)\n ? String(value[0] ?? \"\").trim()\n : \"\";\n if (!text) return { count: null, unit: null, text: \"\" };\n\n const match = /^(\\d+(?:[.,]\\d+)?)\\s*(.*)$/.exec(text);\n if (!match) return { count: null, unit: null, text };\n\n const count = Number(match[1]!.replace(\",\", \".\"));\n const unit = (match[2] ?? \"\").trim() || null;\n return { count: Number.isFinite(count) ? count : null, unit, text };\n}\n","/**\n * JSON-LD extraction.\n *\n * Marmiton embeds a schema.org graph in every page, which is data the site\n * publishes deliberately for machines to read. Using it instead of CSS selectors\n * removes the whole class of breakage where a redesign renames a class and the\n * parser silently returns nothing.\n */\n\nconst LD_JSON_BLOCK = /<script[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi;\n\n/** Every JSON-LD object on the page, flattened out of any `@graph` wrappers. */\nexport function extractJsonLdNodes(html: string): unknown[] {\n const nodes: unknown[] = [];\n LD_JSON_BLOCK.lastIndex = 0;\n\n let match: RegExpExecArray | null;\n while ((match = LD_JSON_BLOCK.exec(html)) !== null) {\n const raw = match[1]?.trim();\n if (!raw) continue;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // A malformed block is skipped rather than fatal: pages carry several,\n // and one broken analytics blob must not hide the recipe.\n continue;\n }\n\n for (const node of flatten(parsed)) nodes.push(node);\n }\n\n return nodes;\n}\n\nfunction flatten(value: unknown): unknown[] {\n if (Array.isArray(value)) return value.flatMap(flatten);\n if (typeof value !== \"object\" || value === null) return [];\n\n const node = value as Record<string, unknown>;\n const graph = node[\"@graph\"];\n if (graph !== undefined) return flatten(graph);\n return [node];\n}\n\n/** True when a node declares the given schema.org type. */\nexport function hasType(node: unknown, type: string): boolean {\n if (typeof node !== \"object\" || node === null) return false;\n const declared = (node as Record<string, unknown>)[\"@type\"];\n if (typeof declared === \"string\") return declared === type;\n if (Array.isArray(declared)) return declared.includes(type);\n return false;\n}\n\nexport function findNodeOfType(nodes: unknown[], type: string): Record<string, unknown> | null {\n const found = nodes.find((node) => hasType(node, type));\n return (found as Record<string, unknown>) ?? null;\n}\n\n/** Read a string field, tolerating the array form schema.org allows. */\nexport function asString(value: unknown): string | null {\n if (typeof value === \"string\") return value.trim() || null;\n if (Array.isArray(value)) {\n for (const entry of value) {\n const found = asString(entry);\n if (found) return found;\n }\n }\n if (typeof value === \"object\" && value !== null) {\n // Fields such as `image` are often an ImageObject rather than a plain URL.\n const url = (value as Record<string, unknown>).url;\n if (typeof url === \"string\") return url.trim() || null;\n const name = (value as Record<string, unknown>).name;\n if (typeof name === \"string\") return name.trim() || null;\n }\n return null;\n}\n\nexport function asNumber(value: unknown): number | null {\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n if (typeof value === \"string\") {\n const parsed = Number(value.replace(\",\", \".\"));\n if (Number.isFinite(parsed)) return parsed;\n }\n return null;\n}\n\n/** Read a list of strings, which is how ingredients and steps are expressed. */\nexport function asStringList(value: unknown): string[] {\n if (typeof value === \"string\") return [value.trim()].filter(Boolean);\n if (!Array.isArray(value)) return [];\n\n const out: string[] = [];\n for (const entry of value) {\n if (typeof entry === \"string\") {\n const text = entry.trim();\n if (text) out.push(text);\n continue;\n }\n if (typeof entry === \"object\" && entry !== null) {\n // HowToStep carries the sentence under `text`.\n const record = entry as Record<string, unknown>;\n const text = asString(record.text) ?? asString(record.name);\n if (text) out.push(text);\n }\n }\n return out;\n}\n","/** URL construction and validation for marmiton.org. */\n\nimport { invalidInput } from \"../errors.js\";\n\nexport const BASE_URL = \"https://www.marmiton.org\";\n\n/** Recipe pages look like /recettes/recette_<slug>_<id>.aspx */\nexport const RECIPE_PATH_RE = /\\/recettes\\/recette[_-][a-z0-9%\\-.]*?_(\\d+)\\.aspx/i;\n\nconst ALLOWED_HOSTS = new Set([\"marmiton.org\", \"www.marmiton.org\"]);\n\n/**\n * Search URL.\n *\n * Only the `aqt` parameter is used. Marmiton's robots.txt disallows\n * `recherche.aspx?aqt=*page*` and `?aqt=*start*`, so this client never paginates\n * and the tool description says so rather than pretending the limit does not\n * exist.\n */\nexport function buildSearchUrl(query: string): string {\n const url = new URL(`${BASE_URL}/recettes/recherche.aspx`);\n url.searchParams.set(\"aqt\", query);\n return url.toString();\n}\n\n/**\n * Recipe URL from an id alone.\n *\n * Marmiton keys the page on the trailing id and ignores the slug, redirecting to\n * the canonical address whatever the slug says. It does require the slug to be\n * non-empty though: `recette__18588.aspx` answers 404 while `recette_r_18588.aspx`\n * redirects to the real page. Hence the placeholder, which lets a caller holding\n * only an id skip having to know the title.\n */\nexport function buildRecipeUrl(id: string): string {\n return `${BASE_URL}/recettes/recette_r_${id}.aspx`;\n}\n\n/** True only for marmiton.org, so a hostile URL cannot be used as a proxy. */\nexport function isMarmitonHost(rawUrl: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return false;\n return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());\n}\n\n/** Extract the numeric recipe id from a Marmiton path or absolute URL. */\nexport function extractRecipeId(hrefOrUrl: string): string | null {\n let path = hrefOrUrl;\n if (/^https?:\\/\\//i.test(hrefOrUrl)) {\n if (!isMarmitonHost(hrefOrUrl)) return null;\n path = new URL(hrefOrUrl).pathname;\n }\n const match = RECIPE_PATH_RE.exec(path);\n return match?.[1] ?? null;\n}\n\n/** Resolve the `id` / `url` pair accepted by get_recipe. `id` wins. */\nexport function resolveRecipeRef(input: { id?: string; url?: string }): {\n id: string;\n url: string;\n} {\n if (input.id) {\n if (!/^\\d+$/.test(input.id)) {\n throw invalidInput(\n `\"${input.id}\" is not a Marmiton recipe id.`,\n \"Ids are digits only, as returned by search_recipes.\",\n );\n }\n return { id: input.id, url: buildRecipeUrl(input.id) };\n }\n\n if (input.url) {\n if (!isMarmitonHost(input.url)) {\n throw invalidInput(\n \"Only marmiton.org URLs are accepted.\",\n \"Pass the url returned by search_recipes, or use the numeric id instead.\",\n );\n }\n const id = extractRecipeId(input.url);\n if (!id) {\n throw invalidInput(\n \"That Marmiton URL is not a recipe page.\",\n \"Recipe pages look like https://www.marmiton.org/recettes/recette_tarte-aux-pommes_18588.aspx\",\n );\n }\n return { id, url: input.url };\n }\n\n throw invalidInput(\"Either 'id' or 'url' must be provided.\");\n}\n\nexport function toAbsoluteUrl(href: string): string {\n return new URL(href, BASE_URL).toString();\n}\n","/**\n * Turning Marmiton's JSON-LD into the domain types.\n *\n * The editorial `description` is deliberately not read here. Ingredient lists and\n * cooking steps are facts and procedures, and a public tool can hand them on with\n * a link to the source; the author's descriptive prose is their writing, and this\n * server has no business redistributing it.\n */\n\nimport { parseFailure } from \"../errors.js\";\nimport { parseIsoDuration, parseYield } from \"../recipe/duration.js\";\nimport type { Nutrition, Rating, Recipe, RecipeSummary } from \"../types.js\";\nimport { asNumber, asString, asStringList, extractJsonLdNodes, findNodeOfType } from \"./jsonld.js\";\nimport { extractRecipeId, toAbsoluteUrl } from \"./urls.js\";\n\nexport interface ParseRecipeOptions {\n id: string;\n url: string;\n}\n\nexport function parseRecipePage(html: string, options: ParseRecipeOptions): Recipe {\n const nodes = extractJsonLdNodes(html);\n const node = findNodeOfType(nodes, \"Recipe\");\n\n // A page that loaded but carries no Recipe node means Marmiton changed how it\n // publishes its data. Reporting that as \"no recipe\" would be indistinguishable\n // from a genuine absence, so it has to be loud.\n if (!node) {\n throw parseFailure(options.url, \"no schema.org Recipe node in the page\");\n }\n\n const title = asString(node.name);\n const ingredients = asStringList(node.recipeIngredient);\n const steps = asStringList(node.recipeInstructions);\n\n if (!title) throw parseFailure(options.url, \"the Recipe node has no name\");\n if (ingredients.length === 0) {\n throw parseFailure(options.url, \"the Recipe node carries no ingredient list\");\n }\n\n return {\n id: options.id,\n title,\n url: asString(node.mainEntityOfPage) ?? options.url,\n imageUrl: asString(node.image),\n ingredients,\n steps,\n recipeYield: parseYield(node.recipeYield),\n prepMinutes: parseIsoDuration(node.prepTime),\n cookMinutes: parseIsoDuration(node.cookTime),\n totalMinutes: parseIsoDuration(node.totalTime),\n category: asString(node.recipeCategory),\n author: asString(node.author),\n rating: parseRating(node.aggregateRating),\n nutrition: parseNutrition(node.nutrition),\n };\n}\n\nfunction parseRating(value: unknown): Rating | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const ratingValue = asNumber(record.ratingValue);\n if (ratingValue === null) return null;\n return {\n value: ratingValue,\n count: asNumber(record.ratingCount) ?? asNumber(record.reviewCount),\n best: asNumber(record.bestRating),\n };\n}\n\nfunction parseNutrition(value: unknown): Nutrition | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const nutrition: Nutrition = {\n calories: asString(record.calories),\n protein: asString(record.proteinContent),\n fat: asString(record.fatContent),\n carbohydrate: asString(record.carbohydrateContent),\n fiber: asString(record.fiberContent),\n sodium: asString(record.sodiumContent),\n servingSize: asString(record.servingSize),\n };\n const hasAny = Object.values(nutrition).some((entry) => entry !== null);\n return hasAny ? nutrition : null;\n}\n\n/**\n * Search results, read from the ItemList the search page publishes.\n *\n * The page also lists more recipes in its HTML, but reaching them would mean\n * reintroducing CSS selectors and the breakage that comes with them. The\n * structured list is what the site offers machines, so that is what is used.\n */\nexport function parseSearchPage(html: string, url: string): RecipeSummary[] {\n const nodes = extractJsonLdNodes(html);\n const list = findNodeOfType(nodes, \"ItemList\");\n\n if (!list) {\n throw parseFailure(url, \"no schema.org ItemList node on the search page\");\n }\n\n const elements = list.itemListElement;\n if (!Array.isArray(elements)) {\n throw parseFailure(url, \"the ItemList node carries no itemListElement array\");\n }\n\n const results: RecipeSummary[] = [];\n let skipped = 0;\n\n for (const element of elements) {\n if (typeof element !== \"object\" || element === null) {\n skipped += 1;\n continue;\n }\n const record = element as Record<string, unknown>;\n const href = asString(record.url);\n const title = asString(record.name);\n const id = href ? extractRecipeId(href) : null;\n\n if (!href || !title || !id) {\n skipped += 1;\n continue;\n }\n\n results.push({ id, title, url: toAbsoluteUrl(href), imageUrl: asString(record.image) });\n }\n\n // Every entry failing while the list itself was present means the entry shape\n // changed, which is breakage rather than an empty search.\n if (results.length === 0 && elements.length > 0) {\n throw parseFailure(url, `${elements.length} list entries but none could be read`);\n }\n if (skipped > 0) {\n process.stderr.write(`[mcp-marmiton] skipped ${skipped} unreadable search entries on ${url}\\n`);\n }\n\n return results;\n}\n","/**\n * High-level Marmiton client.\n *\n * This module knows nothing about MCP, which keeps it testable against plain\n * strings and usable as a library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n DEFAULT_USER_AGENT,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport type { Recipe, RecipeSummary } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchHtml } from \"./http.js\";\nimport { parseRecipePage, parseSearchPage } from \"./parseRecipe.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { buildSearchUrl, resolveRecipeRef } from \"./urls.js\";\n\nexport interface MarmitonClientOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport interface Outcome<T> {\n data: T;\n /** True when served from the in-memory cache rather than the network. */\n cached: boolean;\n}\n\n/**\n * Apply the guarantees this project makes about its own traffic.\n *\n * The environment parser already enforces both, but `MarmitonClient` is published as a\n * library through the `./client` export and takes a caller-built config, so\n * without this the pacing floor and the honest identity are optional for anyone\n * importing it. Marmiton publishes no rate limit, so the pacing here is self-imposed politeness, and those promises hold on every path.\n *\n * A caller may still name their own application in the User-Agent. Passing the\n * traffic off as a browser is a different thing, and gets the project's own\n * identity appended so it stays attributable.\n */\nfunction withGuarantees(config: Config): Config {\n const userAgent = /mozilla\\/|applewebkit|chrome\\/|safari\\/|gecko/i.test(config.userAgent)\n ? `${config.userAgent} ${DEFAULT_USER_AGENT}`\n : config.userAgent;\n return {\n ...config,\n userAgent,\n minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs),\n };\n}\n\nexport class MarmitonClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<string>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: MarmitonClientOptions = {}) {\n this.config = withGuarantees(options.config ?? loadConfig());\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });\n this.cache = new TtlLruCache<string>(this.config.cacheMaxEntries, this.config.cacheTtlMs);\n this.fetchImpl = options.fetchImpl;\n }\n\n /**\n * Search recipes.\n *\n * One page only: Marmiton's robots.txt disallows the paginated form of this\n * URL, so there is no page parameter to pass.\n */\n async search(query: string): Promise<Outcome<RecipeSummary[]>> {\n const url = buildSearchUrl(query);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseSearchPage(html, url), cached };\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseRecipePage(html, { id, url }), cached };\n }\n\n private async fetchPage(url: string): Promise<{ html: string; cached: boolean }> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { html: hit, cached: true };\n }\n\n const html = await fetchHtml(url, {\n config: this.config,\n limiter: this.limiter,\n logger: this.logger,\n ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),\n });\n this.cache.set(url, html);\n return { html, cached: false };\n }\n}\n","/**\n * get_recipe: read one recipe, optionally scaled to a number of servings.\n */\n\nimport { z } from \"zod\";\nimport type { MarmitonClient } from \"../marmiton/client.js\";\nimport { formatMinutes } from \"../recipe/duration.js\";\nimport { passthroughIngredients, scaleIngredients } from \"../recipe/scale.js\";\nimport type { ScaledIngredient } from \"../recipe/scale.js\";\nimport { ok, scaledIngredientSchema, toToolError } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const getRecipeDescription = [\n \"Read one Marmiton recipe: ingredients, steps, times, category, rating and nutrition.\",\n \"Give the id returned by search_recipes, or a marmiton.org recipe URL.\",\n \"Set 'servings' to rescale the quantities. Each ingredient reports how it was handled: 'scaled' for grams\",\n \"and millilitres, 'rounded' for countable things such as eggs or spoons, and 'unscaled' for lines with no\",\n \"quantity or with an approximate one such as a pinch of salt. Trust that flag rather than recomputing:\",\n \"the point of scaling here is to avoid answers like '2.4 eggs'.\",\n \"Recipes yielding pieces rather than servings are rescaled the same way; check 'yield' to see which.\",\n \"Always cite 'attribution' when showing a recipe to a user.\",\n].join(\" \");\n\nexport const getRecipeInputShape = {\n id: z\n .string()\n .regex(/^\\d+$/, \"Recipe ids are digits only.\")\n .optional()\n .describe(\"Marmiton recipe id, as returned by search_recipes. Preferred over 'url'.\"),\n url: z\n .string()\n .url()\n .optional()\n .describe(\n \"Full marmiton.org recipe URL. Only marmiton.org is accepted. Ignored when 'id' is given.\",\n ),\n servings: z\n .number()\n .positive()\n .max(500)\n .optional()\n .describe(\n \"Rescale the ingredients to this many servings. Omit to get the recipe exactly as published.\",\n ),\n};\n\nexport const getRecipeOutputShape = {\n id: z.string(),\n title: z.string(),\n url: z.string(),\n yield: z.object({\n original_count: z.number().nullable(),\n original_text: z.string(),\n requested: z.number().nullable(),\n unit: z.string().nullable().describe(\"What is being counted: 'personnes', 'pièces'.\"),\n factor: z.number().nullable().describe(\"Multiplier applied to the quantities.\"),\n }),\n ingredients: z.array(scaledIngredientSchema),\n steps: z.array(z.string()),\n prep_minutes: z.number().int().nullable(),\n cook_minutes: z.number().int().nullable(),\n total_minutes: z.number().int().nullable(),\n category: z.string().nullable(),\n author: z.string().nullable(),\n rating: z\n .object({ value: z.number(), count: z.number().nullable(), best: z.number().nullable() })\n .nullable(),\n nutrition: z\n .object({\n calories: z.string().nullable(),\n protein: z.string().nullable(),\n fat: z.string().nullable(),\n carbohydrate: z.string().nullable(),\n fiber: z.string().nullable(),\n sodium: z.string().nullable(),\n serving_size: z.string().nullable(),\n })\n .nullable()\n .describe(\"Nutrition is given for the recipe as published, not rescaled.\"),\n attribution: z.string(),\n source: z.literal(\"marmiton.org\"),\n notes: z.array(z.string()),\n};\n\nexport interface GetRecipeArgs {\n id?: string;\n url?: string;\n servings?: number;\n}\n\nexport async function runGetRecipe(\n client: MarmitonClient,\n args: GetRecipeArgs,\n): Promise<ToolResult> {\n try {\n const ref: { id?: string; url?: string } = {};\n if (args.id) ref.id = args.id;\n else if (args.url) ref.url = args.url;\n\n const { data, cached } = await client.getRecipe(ref);\n const notes: string[] = [];\n if (cached) notes.push(\"Served from this server's short-lived in-memory cache.\");\n\n const originalCount = data.recipeYield.count;\n let factor: number | null = null;\n let ingredients: ScaledIngredient[];\n\n if (args.servings === undefined) {\n ingredients = passthroughIngredients(data.ingredients);\n } else if (originalCount === null || originalCount <= 0) {\n // Without a published yield there is nothing to scale from, and guessing\n // would produce quantities that look authoritative and are not.\n ingredients = passthroughIngredients(data.ingredients);\n notes.push(\n `Marmiton does not state how many servings this recipe makes (\"${data.recipeYield.text}\"), ` +\n \"so the quantities are unchanged.\",\n );\n } else {\n factor = args.servings / originalCount;\n ingredients = scaleIngredients(data.ingredients, { factor });\n\n const unit = data.recipeYield.unit ?? \"portions\";\n notes.push(\n `Quantities rescaled from ${originalCount} to ${args.servings} ${unit} (factor ${\n Math.round(factor * 100) / 100\n }).`,\n );\n if (ingredients.some((entry) => entry.scaling === \"rounded\")) {\n notes.push(\"Some quantities were rounded to remain usable; see the 'scaling' field.\");\n }\n if (ingredients.some((entry) => entry.scaling === \"unscaled\")) {\n notes.push(\"Some ingredients carry no usable quantity and were left as published.\");\n }\n if (data.nutrition) {\n notes.push(\n \"Nutrition values are for the recipe as published, not for the rescaled amounts.\",\n );\n }\n }\n\n const attribution = `${data.title} — recette Marmiton — ${data.url}`;\n\n const structured = {\n id: data.id,\n title: data.title,\n url: data.url,\n yield: {\n original_count: originalCount,\n original_text: data.recipeYield.text,\n requested: args.servings ?? null,\n unit: data.recipeYield.unit,\n factor: factor === null ? null : Math.round(factor * 1000) / 1000,\n },\n ingredients,\n steps: data.steps,\n prep_minutes: data.prepMinutes,\n cook_minutes: data.cookMinutes,\n total_minutes: data.totalMinutes,\n category: data.category,\n author: data.author,\n rating: data.rating,\n nutrition: data.nutrition\n ? {\n calories: data.nutrition.calories,\n protein: data.nutrition.protein,\n fat: data.nutrition.fat,\n carbohydrate: data.nutrition.carbohydrate,\n fiber: data.nutrition.fiber,\n sodium: data.nutrition.sodium,\n serving_size: data.nutrition.servingSize,\n }\n : null,\n attribution,\n source: \"marmiton.org\" as const,\n notes,\n };\n\n const times = [\n data.prepMinutes ? `préparation ${formatMinutes(data.prepMinutes)}` : \"\",\n data.cookMinutes ? `cuisson ${formatMinutes(data.cookMinutes)}` : \"\",\n ]\n .filter(Boolean)\n .join(\", \");\n\n const header = [\n attribution,\n args.servings !== undefined && factor !== null\n ? `Pour ${args.servings} ${data.recipeYield.unit ?? \"portions\"} (recette prévue pour ${data.recipeYield.text}).`\n : `Recette pour ${data.recipeYield.text || \"portions non précisées\"}.`,\n times,\n ]\n .filter(Boolean)\n .join(\"\\n\");\n\n const ingredientLines = ingredients\n .map((entry) => {\n const flag = entry.scaling === \"unscaled\" ? \" (non ajusté)\" : \"\";\n return `- ${entry.text}${flag}`;\n })\n .join(\"\\n\");\n\n return ok(structured, `${header}\\n\\nIngrédients:\\n${ingredientLines}`);\n } catch (error) {\n return toToolError(error);\n }\n}\n","/**\n * French cooking unit vocabulary and what scaling means for each.\n *\n * The distinction that matters is not metric versus imperial, it is whether\n * multiplying the number produces something a cook can act on. Doubling \"200 g\"\n * gives \"400 g\", which is useful. Doubling \"1 pincée\" gives \"2 pincées\", which is\n * noise: a pinch is already \"whatever your fingers hold\".\n */\n\nexport type UnitKind =\n /** Mass or volume: scales continuously and cleanly. */\n | \"measured\"\n /** Spoons, cups, sachets: scales, but only to sensible fractions. */\n | \"portioned\"\n /** Pinches, dashes, \"à volonté\": the number carries no real precision. */\n | \"vague\";\n\nexport interface UnitInfo {\n /** Canonical singular form, used when rewriting the ingredient line. */\n canonical: string;\n kind: UnitKind;\n /** Plural form when it is not simply the singular plus an \"s\". */\n plural?: string;\n}\n\n/**\n * Keys are matched lowercased and accent-stripped, so a single entry covers\n * \"cuillere\", \"cuillère\", \"Cuillères\".\n */\nconst UNITS: Record<string, UnitInfo> = {\n // Mass\n g: { canonical: \"g\", kind: \"measured\" },\n gr: { canonical: \"g\", kind: \"measured\" },\n gramme: { canonical: \"g\", kind: \"measured\" },\n grammes: { canonical: \"g\", kind: \"measured\" },\n kg: { canonical: \"kg\", kind: \"measured\" },\n kilo: { canonical: \"kg\", kind: \"measured\" },\n kilos: { canonical: \"kg\", kind: \"measured\" },\n kilogramme: { canonical: \"kg\", kind: \"measured\" },\n mg: { canonical: \"mg\", kind: \"measured\" },\n\n // Volume\n ml: { canonical: \"ml\", kind: \"measured\" },\n cl: { canonical: \"cl\", kind: \"measured\" },\n dl: { canonical: \"dl\", kind: \"measured\" },\n l: { canonical: \"l\", kind: \"measured\" },\n litre: { canonical: \"l\", kind: \"measured\" },\n litres: { canonical: \"l\", kind: \"measured\" },\n\n // Spoons and cups: real measures, but only in sensible fractions.\n \"cuillere a soupe\": {\n canonical: \"cuillère à soupe\",\n kind: \"portioned\",\n plural: \"cuillères à soupe\",\n },\n \"cuilleres a soupe\": {\n canonical: \"cuillère à soupe\",\n kind: \"portioned\",\n plural: \"cuillères à soupe\",\n },\n \"c a soupe\": { canonical: \"cuillère à soupe\", kind: \"portioned\", plural: \"cuillères à soupe\" },\n \"c a s\": { canonical: \"cuillère à soupe\", kind: \"portioned\", plural: \"cuillères à soupe\" },\n cas: { canonical: \"cuillère à soupe\", kind: \"portioned\", plural: \"cuillères à soupe\" },\n \"cuillere a cafe\": {\n canonical: \"cuillère à café\",\n kind: \"portioned\",\n plural: \"cuillères à café\",\n },\n \"cuilleres a cafe\": {\n canonical: \"cuillère à café\",\n kind: \"portioned\",\n plural: \"cuillères à café\",\n },\n \"c a cafe\": { canonical: \"cuillère à café\", kind: \"portioned\", plural: \"cuillères à café\" },\n \"c a c\": { canonical: \"cuillère à café\", kind: \"portioned\", plural: \"cuillères à café\" },\n cac: { canonical: \"cuillère à café\", kind: \"portioned\", plural: \"cuillères à café\" },\n verre: { canonical: \"verre\", kind: \"portioned\" },\n verres: { canonical: \"verre\", kind: \"portioned\" },\n bol: { canonical: \"bol\", kind: \"portioned\" },\n tasse: { canonical: \"tasse\", kind: \"portioned\" },\n tasses: { canonical: \"tasse\", kind: \"portioned\" },\n\n // Packaging and natural units: countable, so they round to whole things.\n sachet: { canonical: \"sachet\", kind: \"portioned\" },\n sachets: { canonical: \"sachet\", kind: \"portioned\" },\n gousse: { canonical: \"gousse\", kind: \"portioned\" },\n gousses: { canonical: \"gousse\", kind: \"portioned\" },\n tranche: { canonical: \"tranche\", kind: \"portioned\" },\n tranches: { canonical: \"tranche\", kind: \"portioned\" },\n botte: { canonical: \"botte\", kind: \"portioned\" },\n bottes: { canonical: \"botte\", kind: \"portioned\" },\n boite: { canonical: \"boîte\", kind: \"portioned\" },\n boites: { canonical: \"boîte\", kind: \"portioned\" },\n pot: { canonical: \"pot\", kind: \"portioned\" },\n pots: { canonical: \"pot\", kind: \"portioned\" },\n brique: { canonical: \"brique\", kind: \"portioned\" },\n feuille: { canonical: \"feuille\", kind: \"portioned\" },\n feuilles: { canonical: \"feuille\", kind: \"portioned\" },\n branche: { canonical: \"branche\", kind: \"portioned\" },\n branches: { canonical: \"branche\", kind: \"portioned\" },\n\n // Deliberately imprecise: the number is a figure of speech.\n pincee: { canonical: \"pincée\", kind: \"vague\", plural: \"pincées\" },\n pincees: { canonical: \"pincée\", kind: \"vague\", plural: \"pincées\" },\n trait: { canonical: \"trait\", kind: \"vague\" },\n traits: { canonical: \"trait\", kind: \"vague\" },\n filet: { canonical: \"filet\", kind: \"vague\" },\n filets: { canonical: \"filet\", kind: \"vague\" },\n goutte: { canonical: \"goutte\", kind: \"vague\", plural: \"gouttes\" },\n gouttes: { canonical: \"goutte\", kind: \"vague\", plural: \"gouttes\" },\n poignee: { canonical: \"poignée\", kind: \"vague\", plural: \"poignées\" },\n poignees: { canonical: \"poignée\", kind: \"vague\", plural: \"poignées\" },\n};\n\n/**\n * Lowercase, strip accents and drop abbreviation dots, so lookups survive French\n * spelling variants. Recipes write \"c. à soupe\" and \"c à s\" as readily as the\n * full \"cuillère à soupe\", and an unrecognised unit is worse than a wrong one:\n * the amount falls through to the countable branch and gets rounded as though a\n * spoonful were an indivisible object.\n */\nexport function normalizeUnitKey(text: string): string {\n return text\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/[̀-ͯ]/g, \"\")\n .replace(/\\./g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\n/** Longest keys first, so \"cuillere a soupe\" wins over \"cuillere\". */\nexport const UNIT_KEYS = Object.keys(UNITS).sort((a, b) => b.length - a.length);\n\nexport function lookupUnit(text: string): UnitInfo | null {\n return UNITS[normalizeUnitKey(text)] ?? null;\n}\n\n/**\n * Metric ladders, used to keep a scaled amount at a human size.\n *\n * Multiplying a recipe by thirty is arithmetically fine and practically poor:\n * \"8335 g de sucre\" is correct, and nobody weighs eight thousand grams. Each\n * measured unit therefore knows the unit above and below it, so a large amount\n * climbs the ladder and a small one comes back down.\n */\ninterface UnitStep {\n /** Unit to switch to, and how many of the current unit it holds. */\n to: string;\n per: number;\n}\n\nconst PROMOTIONS: Record<string, UnitStep> = {\n mg: { to: \"g\", per: 1000 },\n g: { to: \"kg\", per: 1000 },\n ml: { to: \"l\", per: 1000 },\n cl: { to: \"l\", per: 100 },\n dl: { to: \"l\", per: 10 },\n};\n\nconst DEMOTIONS: Record<string, UnitStep> = {\n kg: { to: \"g\", per: 1000 },\n l: { to: \"cl\", per: 100 },\n dl: { to: \"cl\", per: 10 },\n cl: { to: \"ml\", per: 10 },\n g: { to: \"mg\", per: 1000 },\n};\n\nexport interface ConvertedAmount {\n amount: number;\n unit: UnitInfo;\n}\n\n/**\n * Move an amount to the unit a cook would actually write it in.\n *\n * Promotion happens at a full unit of the next step up, so 999 g stays grams and\n * 1000 g becomes a kilo. Demotion happens below one, so half a litre reads\n * \"50 cl\" rather than \"1/2 l\". Only one step is taken in each direction, which\n * covers cooking quantities and keeps the result predictable.\n *\n * Rounding to two decimals after conversion keeps the error under a tenth of a\n * percent, well below what any kitchen scale resolves.\n */\nexport function convertToReadableUnit(unit: UnitInfo, amount: number): ConvertedAmount {\n if (unit.kind !== \"measured\" || !Number.isFinite(amount) || amount <= 0) {\n return { amount, unit };\n }\n\n const up = PROMOTIONS[unit.canonical];\n if (up && amount >= up.per) {\n const target = lookupUnit(up.to);\n if (target) return { amount: Math.round((amount / up.per) * 100) / 100, unit: target };\n }\n\n const down = DEMOTIONS[unit.canonical];\n if (down && amount < 1) {\n const target = lookupUnit(down.to);\n if (target) return { amount: Math.round(amount * down.per * 100) / 100, unit: target };\n }\n\n return { amount, unit };\n}\n\n/**\n * Render a unit for a given amount, choosing singular or plural.\n *\n * French takes the plural from two onwards, so 1,5 stays singular: \"1,5 cuillère\n * à soupe\", not \"1,5 cuillères\".\n */\nexport function formatUnit(unit: UnitInfo, amount: number): string {\n if (amount < 2) return unit.canonical;\n if (unit.plural) return unit.plural;\n // Symbols such as \"g\", \"kg\" and \"cl\" are invariable.\n if (/^[a-z]{1,3}$/.test(unit.canonical)) return unit.canonical;\n return `${unit.canonical}s`;\n}\n","/**\n * Parsing of French quantities out of free-text ingredient lines.\n *\n * Marmiton stores ingredients as plain strings: \"200 g de farine\", \"3 oeufs\",\n * \"0.5 oignon coupée en cubes\", \"sel\". There is no structured amount anywhere, so\n * everything downstream depends on reading these correctly.\n */\n\nimport type { UnitInfo } from \"./units.js\";\nimport { lookupUnit, normalizeUnitKey, UNIT_KEYS } from \"./units.js\";\n\nexport interface ParsedQuantity {\n amount: number;\n /** Characters consumed from the start of the line. */\n length: number;\n}\n\n/** Unicode vulgar fractions, which appear in hand-written recipes. */\nconst VULGAR_FRACTIONS: Record<string, number> = {\n \"½\": 0.5,\n \"⅓\": 1 / 3,\n \"⅔\": 2 / 3,\n \"¼\": 0.25,\n \"¾\": 0.75,\n \"⅕\": 0.2,\n \"⅙\": 1 / 6,\n \"⅛\": 0.125,\n};\n\n/**\n * Read a leading amount.\n *\n * Handles, in order of precedence: a vulgar fraction glyph, a simple fraction\n * such as \"1/2\", a mixed number such as \"1 1/2\", and a decimal written with\n * either a dot or a French comma. Returns null when the line does not start with\n * a number, which is the normal case for \"sel\" or \"coriandre\".\n */\nexport function parseLeadingQuantity(text: string): ParsedQuantity | null {\n const trimmed = text.trimStart();\n const offset = text.length - trimmed.length;\n\n const glyph = trimmed[0];\n if (glyph && glyph in VULGAR_FRACTIONS) {\n return { amount: VULGAR_FRACTIONS[glyph]!, length: offset + 1 };\n }\n\n // \"1 1/2\" before \"1/2\" before \"1,5\", so the longest reading wins.\n const mixed = /^(\\d+)\\s+(\\d+)\\s*\\/\\s*(\\d+)/.exec(trimmed);\n if (mixed) {\n const whole = Number(mixed[1]);\n const numerator = Number(mixed[2]);\n const denominator = Number(mixed[3]);\n if (denominator !== 0) {\n return { amount: whole + numerator / denominator, length: offset + mixed[0].length };\n }\n }\n\n const fraction = /^(\\d+)\\s*\\/\\s*(\\d+)/.exec(trimmed);\n if (fraction) {\n const denominator = Number(fraction[2]);\n if (denominator !== 0) {\n return { amount: Number(fraction[1]) / denominator, length: offset + fraction[0].length };\n }\n }\n\n const decimal = /^(\\d+(?:[.,]\\d+)?)/.exec(trimmed);\n if (decimal) {\n const amount = Number(decimal[1]!.replace(\",\", \".\"));\n if (Number.isFinite(amount)) {\n return { amount, length: offset + decimal[0].length };\n }\n }\n\n return null;\n}\n\nexport interface ParsedIngredient {\n /** The line exactly as Marmiton stores it. */\n original: string;\n amount: number | null;\n unit: UnitInfo | null;\n /** Raw unit text as written, kept so the rewrite can stay faithful. */\n unitText: string | null;\n /** What the amount and unit apply to, for example \"farine\" or \"oeufs\". */\n item: string;\n}\n\n/**\n * Split an ingredient line into amount, unit and item.\n *\n * A missing amount is normal and not an error: many lines are just \"sel\". A\n * missing unit is equally normal and means the item is counted, as in \"3 oeufs\".\n */\nexport function parseIngredient(line: string): ParsedIngredient {\n const original = line;\n const text = line.trim();\n\n const quantity = parseLeadingQuantity(text);\n if (!quantity) {\n return { original, amount: null, unit: null, unitText: null, item: text };\n }\n\n let rest = text.slice(quantity.length).trimStart();\n\n // Try the longest unit spellings first, so \"cuillère à soupe\" is not read as\n // the shorter \"cuillère\" with \"à soupe\" spilling into the item name.\n let unit: UnitInfo | null = null;\n let unitText: string | null = null;\n\n const normalizedRest = normalizeUnitKey(rest);\n for (const key of UNIT_KEYS) {\n if (normalizedRest === key || normalizedRest.startsWith(`${key} `)) {\n unit = lookupUnit(key);\n // Consume the same number of words from the original text, which may be\n // spelled with accents the normalized key has lost.\n const wordCount = key.split(\" \").length;\n const words = rest.split(/\\s+/);\n unitText = words.slice(0, wordCount).join(\" \");\n rest = words.slice(wordCount).join(\" \");\n break;\n }\n }\n\n // \"200 g de farine\" reads better as item \"farine\" than \"de farine\".\n const item = rest.replace(/^(?:de\\s+la\\s+|de\\s+l'|d'|de\\s+|du\\s+|des\\s+)/i, \"\").trim();\n\n return { original, amount: quantity.amount, unit, unitText, item };\n}\n\nexport interface FormatAmountOptions {\n /**\n * Whether to snap near-fractions to 1/4, 1/3, 1/2, 2/3 and 3/4.\n *\n * True for things a cook counts or spoons out: \"1/3 cuillère\" is how a kitchen\n * expresses it, \"0,33 cuillère\" is not. False for mass and volume, which are\n * decimal by nature: nobody weighs \"8 1/3 kg\" of sugar, they weigh 8,33 kg.\n */\n fractions?: boolean;\n}\n\n/**\n * Render an amount the way a recipe would write it.\n */\nexport function formatAmount(amount: number, options: FormatAmountOptions = {}): string {\n if (!Number.isFinite(amount)) return \"\";\n if (Number.isInteger(amount)) return String(amount);\n\n if (options.fractions === false) {\n // French recipes write decimals with a comma.\n return String(Math.round(amount * 100) / 100).replace(\".\", \",\");\n }\n\n const whole = Math.floor(amount);\n const rest = amount - whole;\n const known: Array<[number, string]> = [\n [0.25, \"1/4\"],\n [1 / 3, \"1/3\"],\n [0.5, \"1/2\"],\n [2 / 3, \"2/3\"],\n [0.75, \"3/4\"],\n ];\n for (const [value, label] of known) {\n if (Math.abs(rest - value) < 0.02) {\n return whole > 0 ? `${whole} ${label}` : label;\n }\n }\n\n // French recipes write decimals with a comma.\n return String(Math.round(amount * 100) / 100).replace(\".\", \",\");\n}\n","/**\n * Scaling ingredient quantities.\n *\n * The guiding rule is that a scaled quantity must be something a cook can act on.\n * Multiplying every number by the factor is arithmetically correct and practically\n * useless: it produces \"2,4 oeufs\" and \"0,67 pincée de sel\" with the same\n * confidence as \"267 g de farine\". Each line is therefore classified, and the\n * classification travels with the result so the caller can see what was computed\n * and what was left alone.\n */\n\nimport { formatAmount, parseIngredient } from \"./quantity.js\";\nimport { convertToReadableUnit, formatUnit } from \"./units.js\";\n\nexport type ScalingKind =\n /** Multiplied and rounded to a readable value. */\n | \"scaled\"\n /** Multiplied, then rounded to something countable. */\n | \"rounded\"\n /** Left untouched: no amount, or an amount too vague to be meaningful. */\n | \"unscaled\";\n\nexport interface ScaledIngredient {\n /** The line exactly as it was given. */\n original: string;\n /** The line after scaling, identical to `original` when unscaled. */\n text: string;\n /**\n * The scaled quantity, expressed in `unit`.\n *\n * Read it together with `unit`, never on its own: a large result is moved to a\n * bigger unit, so scaling \"200 g\" by ten gives an amount of 2 with a unit of\n * \"kg\". The bare number can therefore shrink while the quantity grows.\n */\n amount: number | null;\n /** The unit `amount` is in, which may differ from the one the recipe used. */\n unit: string | null;\n scaling: ScalingKind;\n /** Why the line was left alone, when it was. */\n note?: string;\n}\n\n/** Round to a step, keeping two decimals at most. */\nfunction roundTo(value: number, step: number): number {\n return Math.round(Math.round(value / step) * step * 100) / 100;\n}\n\n/**\n * Round a measured amount to something a kitchen scale can show.\n *\n * Large amounts do not need gram precision, and small ones do, so the step grows\n * with the value rather than being fixed.\n */\nfunction roundMeasured(value: number): number {\n if (value >= 100) return roundTo(value, 5);\n if (value >= 10) return roundTo(value, 1);\n if (value >= 1) return roundTo(value, 0.5);\n return Math.round(value * 100) / 100;\n}\n\n/**\n * Round a countable amount to something a cook can act on.\n *\n * Below one whole unit the amount is kept as a fraction rather than rounded to a\n * whole: rounding 1/3 of a sachet up to 1 would make a recipe scaled *down* call\n * for *more* than the original, which is worse than any fraction. Rounding it\n * down to zero would silently delete the ingredient, which is worse still.\n */\nfunction roundCountable(value: number, allowHalves: boolean): number {\n if (value <= 0) return 0;\n\n if (value < 1) {\n // Snap to a fraction a cook can act on. A raw decimal such as 0,15 is no\n // more usable than zero: nobody measures fifteen hundredths of an egg. A\n // quarter is the smallest fraction worth printing, so anything under it is\n // clamped up rather than shrinking towards nothing.\n const USABLE_FRACTIONS = [0.25, 1 / 3, 0.5, 2 / 3, 0.75];\n let closest = USABLE_FRACTIONS[0]!;\n for (const fraction of USABLE_FRACTIONS) {\n if (Math.abs(value - fraction) < Math.abs(value - closest)) closest = fraction;\n }\n return Math.round(closest * 100) / 100;\n }\n\n return allowHalves ? roundTo(value, 0.5) : Math.round(value);\n}\n\nexport interface ScaleOptions {\n /** Multiplier applied to the quantities. */\n factor: number;\n}\n\n/**\n * Make a counted item agree with its amount, in both directions.\n *\n * French takes the plural from two onwards, so \"2 oeufs\" halved reads \"1 oeuf\"\n * and \"1 brioche\" tripled reads \"3 brioches\". Only the head word is touched, and\n * only its trailing \"s\": nouns already ending in -s, -x or -z are invariable in\n * the plural (\"ananas\", \"choux\"), and forcing one would be worse than leaving the\n * word as the recipe wrote it.\n */\nfunction agreeWithAmount(item: string, amount: number): string {\n if (!item) return item;\n\n const words = item.split(\" \");\n const head = words[0] ?? \"\";\n if (head.length <= 3) return item;\n\n const wantsPlural = amount >= 2;\n const isPlural = /s$/i.test(head);\n\n if (wantsPlural && !isPlural) {\n // Words ending in -s, -x or -z do not take a plural mark.\n if (/[sxz]$/i.test(head)) return item;\n words[0] = `${head}s`;\n } else if (!wantsPlural && isPlural) {\n // \"ananas\", \"anis\", \"couscous\": the -s belongs to the singular.\n if (/[aiou]s$/i.test(head)) return item;\n words[0] = head.slice(0, -1);\n }\n\n return words.join(\" \");\n}\n\n/**\n * Scale one ingredient line.\n *\n * Whole eggs and other countable items are rounded up to a usable amount rather\n * than being reported as fractions, and vague measures such as pinches are\n * returned untouched with a note.\n */\nexport function scaleIngredient(line: string, options: ScaleOptions): ScaledIngredient {\n const { factor } = options;\n const parsed = parseIngredient(line);\n\n if (parsed.amount === null) {\n return {\n original: parsed.original,\n text: parsed.original,\n amount: null,\n unit: null,\n scaling: \"unscaled\",\n note: \"No quantity given; adjust to taste.\",\n };\n }\n\n const kind = parsed.unit?.kind ?? \"countable\";\n\n if (kind === \"vague\") {\n return {\n original: parsed.original,\n text: parsed.original,\n amount: parsed.amount,\n unit: parsed.unit?.canonical ?? null,\n scaling: \"unscaled\",\n note: \"Measure is approximate by nature; adjust to taste.\",\n };\n }\n\n const raw = parsed.amount * factor;\n let amount: number;\n let scaling: ScalingKind;\n let unit = parsed.unit;\n\n if (kind === \"measured\") {\n // Round first, then move up or down the metric ladder, so the value a cook\n // reads is both correctly rounded and at a human size: 8335 g becomes\n // 8,34 kg rather than staying eight thousand grams.\n const converted = convertToReadableUnit(parsed.unit!, roundMeasured(raw));\n amount = converted.amount;\n unit = converted.unit;\n scaling = \"scaled\";\n } else if (kind === \"portioned\") {\n // Spoons tolerate halves; boxes and sachets do not.\n const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? \"\");\n amount = roundCountable(raw, allowHalves);\n scaling = \"rounded\";\n } else {\n // Countable item with no unit: \"3 oeufs\", \"2 tomates\".\n amount = roundCountable(raw, false);\n scaling = \"rounded\";\n }\n\n const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : \"\";\n const separator = unit ? \" de \" : \" \";\n // A countable item agrees with its number: \"1/3 oeuf\", \"3 brioches\".\n const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);\n const item = itemText ? `${separator}${itemText}` : \"\";\n // Mass and volume read as decimals; counted and spooned things read as\n // fractions.\n const amountText = formatAmount(amount, { fractions: kind !== \"measured\" });\n const text = `${amountText}${unitLabel}${item}`.trim();\n\n const result: ScaledIngredient = {\n original: parsed.original,\n text,\n amount,\n unit: unit?.canonical ?? null,\n scaling,\n };\n\n if (scaling === \"rounded\" && Math.abs(raw - amount) > 0.01) {\n result.note = `Rounded from ${formatAmount(Math.round(raw * 100) / 100)}.`;\n }\n\n return result;\n}\n\nexport function scaleIngredients(lines: string[], options: ScaleOptions): ScaledIngredient[] {\n return lines.map((line) => scaleIngredient(line, options));\n}\n\n/** An ingredient list returned unchanged, for when no scaling was requested. */\nexport function passthroughIngredients(lines: string[]): ScaledIngredient[] {\n return lines.map((line) => {\n const parsed = parseIngredient(line);\n return {\n original: parsed.original,\n text: parsed.original,\n amount: parsed.amount,\n unit: parsed.unit?.canonical ?? null,\n scaling: \"unscaled\" as const,\n };\n });\n}\n","/** Pieces shared by the three tools: schemas, error mapping, text mirrors. */\n\nimport { z } from \"zod\";\nimport { MarmitonError } from \"../errors.js\";\n\n/** Many MCP clients render only the text block, so it must read on its own. */\nexport const MAX_TEXT_MIRROR_CHARS = 2000;\n\nexport const scaledIngredientSchema = z.object({\n original: z.string().describe(\"The ingredient line exactly as Marmiton publishes it.\"),\n text: z.string().describe(\"The line after scaling, identical to 'original' when unscaled.\"),\n amount: z\n .number()\n .nullable()\n .describe(\n \"The scaled quantity, expressed in 'unit'. Read the two together: a large result is moved to a \" +\n \"bigger unit, so 200 g scaled tenfold reads as 2 kg, and the bare number shrinks while the \" +\n \"quantity grows.\",\n ),\n unit: z\n .string()\n .nullable()\n .describe(\"The unit 'amount' is in, which may differ from the one the recipe used.\"),\n scaling: z\n .enum([\"scaled\", \"rounded\", \"unscaled\"])\n .describe(\n \"'scaled' was multiplied cleanly. 'rounded' was multiplied then rounded to something countable. \" +\n \"'unscaled' was left alone, either because it carries no quantity or because the measure is \" +\n \"approximate by nature.\",\n ),\n note: z.string().optional().describe(\"Why the line was rounded or left alone.\"),\n});\n\nexport interface ToolResult {\n // The SDK's CallToolResult carries an index signature for protocol extensions.\n [key: string]: unknown;\n content: Array<{ type: \"text\"; text: string }>;\n structuredContent?: Record<string, unknown>;\n isError?: boolean;\n}\n\nexport function ok(structured: Record<string, unknown>, text: string): ToolResult {\n return {\n content: [{ type: \"text\", text: truncate(text, MAX_TEXT_MIRROR_CHARS) }],\n structuredContent: structured,\n };\n}\n\n/**\n * Error results carry no structuredContent: the SDK validates it against the\n * tool's declared output schema, which an error payload does not satisfy.\n */\nexport function toToolError(error: unknown): ToolResult {\n const known =\n error instanceof MarmitonError\n ? error\n : new MarmitonError(\"network_error\", error instanceof Error ? error.message : String(error));\n\n const lines = [`[${known.code}] ${known.message}`];\n if (known.details.hint) lines.push(`Hint: ${known.details.hint}`);\n\n return { content: [{ type: \"text\", text: lines.join(\"\\n\") }], isError: true };\n}\n\nexport function truncate(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text;\n return `${text.slice(0, maxChars - 1).trimEnd()}…`;\n}\n","/**\n * scale_ingredients: rescale an arbitrary ingredient list, offline.\n *\n * This tool makes no network request. It exposes the quantity parser on its own,\n * so a list copied from anywhere, not only Marmiton, can be rescaled with the\n * same care about what is and is not safe to multiply.\n */\n\nimport { z } from \"zod\";\nimport { invalidInput } from \"../errors.js\";\nimport { scaleIngredients } from \"../recipe/scale.js\";\nimport { ok, scaledIngredientSchema, toToolError } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const scaleIngredientsDescription = [\n \"Rescale a list of ingredient lines to a different number of servings, without contacting any website.\",\n \"Give either 'factor' directly, or 'from_servings' and 'to_servings' and the factor is computed.\",\n \"Works on any French ingredient list, whatever its source, so it also serves a recipe the user pasted in.\",\n \"Quantities in grams or millilitres are multiplied and rounded to readable values; countable things such as\",\n \"eggs or spoons are rounded to whole or half units; lines with no quantity, or with an approximate one such\",\n \"as a pinch, are returned untouched and flagged. Prefer this over doing the arithmetic yourself.\",\n].join(\" \");\n\nexport const scaleIngredientsInputShape = {\n ingredients: z\n .array(z.string().max(300))\n .min(1)\n .max(100)\n .describe(\"Ingredient lines, for example ['200 g de farine', '3 oeufs', 'sel'].\"),\n factor: z\n .number()\n .positive()\n .max(100)\n .optional()\n .describe(\"Multiplier to apply. Use this or the from/to pair.\"),\n from_servings: z\n .number()\n .positive()\n .max(500)\n .optional()\n .describe(\"How many servings the list is written for.\"),\n to_servings: z.number().positive().max(500).optional().describe(\"How many servings are wanted.\"),\n};\n\nexport const scaleIngredientsOutputShape = {\n factor: z.number(),\n ingredients: z.array(scaledIngredientSchema),\n scaled_count: z.number().int(),\n rounded_count: z.number().int(),\n unscaled_count: z.number().int(),\n notes: z.array(z.string()),\n};\n\nexport interface ScaleIngredientsArgs {\n ingredients: string[];\n factor?: number;\n from_servings?: number;\n to_servings?: number;\n}\n\nexport function runScaleIngredients(args: ScaleIngredientsArgs): ToolResult {\n try {\n let factor: number;\n\n if (args.factor !== undefined) {\n factor = args.factor;\n } else if (args.from_servings !== undefined && args.to_servings !== undefined) {\n factor = args.to_servings / args.from_servings;\n } else {\n throw invalidInput(\n \"Provide either 'factor', or both 'from_servings' and 'to_servings'.\",\n \"For example from_servings=6 and to_servings=4, or factor=0.667.\",\n );\n }\n\n const ingredients = scaleIngredients(args.ingredients, { factor });\n const counts = {\n scaled: ingredients.filter((entry) => entry.scaling === \"scaled\").length,\n rounded: ingredients.filter((entry) => entry.scaling === \"rounded\").length,\n unscaled: ingredients.filter((entry) => entry.scaling === \"unscaled\").length,\n };\n\n const notes: string[] = [];\n if (counts.rounded > 0) {\n notes.push(\n `${counts.rounded} quantity(ies) were rounded to stay usable, rather than left as fractions.`,\n );\n }\n if (counts.unscaled > 0) {\n notes.push(\n `${counts.unscaled} line(s) carry no usable quantity and were returned unchanged; adjust to taste.`,\n );\n }\n\n const structured = {\n factor: Math.round(factor * 1000) / 1000,\n ingredients,\n scaled_count: counts.scaled,\n rounded_count: counts.rounded,\n unscaled_count: counts.unscaled,\n notes,\n };\n\n const lines = ingredients\n .map((entry) => {\n const flag = entry.scaling === \"unscaled\" ? \" (non ajusté)\" : \"\";\n return `- ${entry.text}${flag}`;\n })\n .join(\"\\n\");\n\n return ok(structured, `Facteur ${Math.round(factor * 100) / 100}:\\n${lines}`);\n } catch (error) {\n return toToolError(error);\n }\n}\n","/**\n * search_recipes: find recipes on Marmiton by free text.\n */\n\nimport { z } from \"zod\";\nimport { invalidInput } from \"../errors.js\";\nimport type { MarmitonClient } from \"../marmiton/client.js\";\nimport { ok, toToolError } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const searchRecipesDescription = [\n \"Search Marmiton for recipes by free text: a dish, an ingredient, or both, for example 'tarte aux pommes'\",\n \"or 'poulet curry coco'. Marmiton is a French site, so French terms work best.\",\n \"Returns candidate recipes with the id and URL needed to read one with get_recipe.\",\n \"One call returns one page of results. Marmiton's robots.txt disallows paginating search results, so this\",\n \"server does not, and there is no page parameter: narrow the query instead of asking for more pages.\",\n].join(\" \");\n\nexport const searchRecipesInputShape = {\n // Deliberately no min(1): an empty string would be rejected by the schema with\n // a protocol-level validation error, while a whitespace-only one would reach\n // the tool and come back as invalid_input. Letting both through to the same\n // check gives callers one error code for one problem.\n query: z\n .string()\n .max(200)\n .describe(\"What to search for, in French, for example 'tarte aux pommes'.\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(30)\n .default(10)\n .describe(\"Maximum recipes to return from this page of results.\"),\n};\n\nexport const searchRecipesOutputShape = {\n query: z.string(),\n results: z.array(\n z.object({\n id: z.string().describe(\"Marmiton recipe id. Pass this to get_recipe.\"),\n title: z.string(),\n url: z.string(),\n image_url: z.string().nullable(),\n }),\n ),\n result_count: z.number().int(),\n total_available: z.number().int().describe(\"Recipes on this page before applying 'limit'.\"),\n source: z.literal(\"marmiton.org\"),\n notes: z.array(z.string()),\n};\n\nexport interface SearchRecipesArgs {\n query: string;\n limit: number;\n}\n\nexport async function runSearchRecipes(\n client: MarmitonClient,\n args: SearchRecipesArgs,\n): Promise<ToolResult> {\n try {\n // Trimming happens before the emptiness check: a whitespace-only query is as\n // empty as a missing one, and letting it through would spend a request on a\n // search for nothing.\n const query = args.query.trim();\n if (!query) {\n throw invalidInput(\n \"'query' cannot be empty.\",\n 'Give a dish or an ingredient, for example query=\"tarte aux pommes\".',\n );\n }\n\n const { data, cached } = await client.search(query);\n const notes: string[] = [];\n if (cached) notes.push(\"Served from this server's short-lived in-memory cache.\");\n if (data.length > args.limit) {\n notes.push(`This page holds ${data.length} recipes; showing the first ${args.limit}.`);\n }\n if (data.length === 0) {\n notes.push(\n \"Marmiton found no recipe for this query. Try French terms, or a broader wording. \" +\n \"Note that only one page of results is available.\",\n );\n }\n\n const results = data.slice(0, args.limit).map((recipe) => ({\n id: recipe.id,\n title: recipe.title,\n url: recipe.url,\n image_url: recipe.imageUrl,\n }));\n\n const structured = {\n query,\n results,\n result_count: results.length,\n total_available: data.length,\n source: \"marmiton.org\" as const,\n notes,\n };\n\n const header =\n results.length > 0\n ? `${results.length} recette(s) pour \"${query}\":`\n : `Aucune recette trouvée pour \"${query}\".`;\n const list = results\n .map((recipe, index) => `${index + 1}. ${recipe.title} · id: ${recipe.id}`)\n .join(\"\\n\");\n\n return ok(structured, `${header}\\n${list}`);\n } catch (error) {\n return toToolError(error);\n }\n}\n"],"mappings":"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","names":["z","z","z","z","z","z"]}
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var PKG_VERSION = "1.0.2";
2
+ var PKG_VERSION = "1.0.4";
3
3
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
4
4
 
5
5
  // src/config.ts
@@ -212,17 +212,24 @@ var RateLimiter = class {
212
212
  }
213
213
  /** Queue a task. Tasks run in call order, one at a time. */
214
214
  schedule(task) {
215
- const run = this.tail.then(async () => {
216
- await this.waitForSlot();
217
- this.lastStart = Date.now();
218
- return task();
219
- });
215
+ const run = this.tail.then(async () => task());
220
216
  this.tail = run.then(
221
217
  () => void 0,
222
218
  () => void 0
223
219
  );
224
220
  return run;
225
221
  }
222
+ /**
223
+ * Wait for this request's slot, then claim it.
224
+ *
225
+ * Called once per upstream request rather than once per task, because a task
226
+ * runs a whole retry chain: stamping only its start would let the next task
227
+ * follow the chain's last request with no gap at all.
228
+ */
229
+ async beforeRequest() {
230
+ await this.waitForSlot();
231
+ this.lastStart = Date.now();
232
+ }
226
233
  /** Called after the site signals it is under load. */
227
234
  penalize() {
228
235
  const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;
@@ -235,7 +242,7 @@ var RateLimiter = class {
235
242
  async waitForSlot() {
236
243
  if (this.intervalMs === 0 || this.lastStart === 0) return;
237
244
  const elapsed = Date.now() - this.lastStart;
238
- const remaining = this.intervalMs - elapsed;
245
+ const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
239
246
  if (remaining > 0) await sleep(remaining);
240
247
  }
241
248
  };
@@ -257,15 +264,19 @@ async function fetchHtml(url, deps) {
257
264
  const doFetch = deps.fetchImpl ?? fetch;
258
265
  return limiter.schedule(async () => {
259
266
  let lastError;
267
+ let askedWaitMs = null;
260
268
  for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
261
269
  if (attempt > 0) {
262
- const delay = backoffDelay(attempt - 1);
270
+ const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);
271
+ askedWaitMs = null;
263
272
  logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
264
273
  await sleep(delay);
265
274
  }
266
275
  let status;
267
276
  let body;
277
+ let retryAfterMs = null;
268
278
  try {
279
+ await limiter.beforeRequest();
269
280
  const response = await doFetch(url, {
270
281
  headers: {
271
282
  "User-Agent": config.userAgent,
@@ -276,15 +287,17 @@ async function fetchHtml(url, deps) {
276
287
  signal: AbortSignal.timeout(config.timeoutMs)
277
288
  });
278
289
  status = response.status;
290
+ retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
279
291
  body = await response.text();
280
292
  } catch (error) {
281
293
  lastError = asTransportError(error, url);
282
294
  logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
283
295
  continue;
284
296
  }
285
- if (status === 429 || status === 503) {
297
+ if (status === 429 || status === 503 || status === 403) {
286
298
  limiter.penalize();
287
- lastError = rateLimited(url, backoffDelay(attempt));
299
+ askedWaitMs = retryAfterMs;
300
+ lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));
288
301
  logger.info(`rate limited on ${url}, interval now ${limiter.currentIntervalMs}ms`);
289
302
  continue;
290
303
  }
@@ -307,6 +320,14 @@ async function fetchHtml(url, deps) {
307
320
  throw lastError ?? new MarmitonError("network_error", `Could not fetch ${url}.`, { url });
308
321
  });
309
322
  }
323
+ function parseRetryAfter(raw) {
324
+ if (!raw) return null;
325
+ const seconds = Number(raw.trim());
326
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
327
+ const when = Date.parse(raw);
328
+ if (Number.isNaN(when)) return null;
329
+ return Math.max(0, when - Date.now());
330
+ }
310
331
  function asTransportError(error, url) {
311
332
  if (error instanceof MarmitonError) return error;
312
333
  const name = error instanceof Error ? error.name : "";
@@ -580,6 +601,14 @@ function parseSearchPage(html, url) {
580
601
  }
581
602
 
582
603
  // src/marmiton/client.ts
604
+ function withGuarantees(config) {
605
+ const userAgent = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
606
+ return {
607
+ ...config,
608
+ userAgent,
609
+ minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs)
610
+ };
611
+ }
583
612
  var MarmitonClient = class {
584
613
  config;
585
614
  logger;
@@ -587,7 +616,7 @@ var MarmitonClient = class {
587
616
  cache;
588
617
  fetchImpl;
589
618
  constructor(options = {}) {
590
- this.config = options.config ?? loadConfig();
619
+ this.config = withGuarantees(options.config ?? loadConfig());
591
620
  this.logger = options.logger ?? createLogger(this.config.logLevel);
592
621
  this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });
593
622
  this.cache = new TtlLruCache(this.config.cacheMaxEntries, this.config.cacheTtlMs);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/version.ts","../../src/config.ts","../../src/marmiton/cache.ts","../../src/errors.ts","../../src/marmiton/rateLimiter.ts","../../src/marmiton/http.ts","../../src/recipe/duration.ts","../../src/marmiton/jsonld.ts","../../src/marmiton/urls.ts","../../src/marmiton/parseRecipe.ts","../../src/marmiton/client.ts"],"sourcesContent":["/**\n * Kept in sync with package.json and server.json. The User-Agent embeds it, so it\n * must not drift; the release checklist bumps all three together.\n */\nexport const PKG_VERSION = \"1.0.2\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-marmiton\";\n","/**\n * Runtime configuration, read from environment variables.\n *\n * A bad value never crashes the process: an MCP server that dies at startup\n * because of a typo in a client config file is very hard to diagnose from the\n * host application, so invalid input is clamped and reported on stderr.\n */\n\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport type LogLevel = \"silent\" | \"error\" | \"info\" | \"debug\";\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\n/**\n * MARMITON asks third-party clients to identify themselves with an application\n * name, a version and a link to the project, so the default follows that format.\n */\nexport const DEFAULT_USER_AGENT = `mcp-marmiton v${PKG_VERSION} (${REPO_URL})`;\n\nexport const DEFAULTS = {\n minIntervalMs: 1000,\n timeoutMs: 15_000,\n maxRetries: 3,\n cacheTtlMs: 15 * 60 * 1000,\n cacheMaxEntries: 200,\n logLevel: \"error\" as LogLevel,\n};\n\n/**\n * Floor on the request interval, enforced regardless of configuration.\n *\n * MARMITON publishes no rate limit and asks for nothing in return for a free\n * service. Self-imposed pacing is the whole of the politeness contract here, so\n * a value below this floor is refused and the default is used instead.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n\nconst LOG_LEVELS: LogLevel[] = [\"silent\", \"error\", \"info\", \"debug\"];\n\ninterface NumericRange {\n min: number;\n max: number;\n fallback: number;\n}\n\nfunction readNumber(name: string, env: NodeJS.ProcessEnv, range: NumericRange): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") return range.fallback;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`${name}=\"${raw}\" is not a number, using ${range.fallback}`);\n return range.fallback;\n }\n\n // Out-of-range values fall back to the default rather than being clamped to\n // the nearest bound. Clamping turns a typo into a silent behaviour change:\n // -1 retries becomes \"never retry\", and -1 cache entries disables the cache\n // outright, both of which look like working configuration.\n const rounded = Math.round(parsed);\n if (rounded < range.min || rounded > range.max) {\n warn(\n `${name}=${raw} is outside the accepted range ${range.min}-${range.max} and was ignored; ` +\n `using ${range.fallback}`,\n );\n return range.fallback;\n }\n return rounded;\n}\n\nfunction warn(message: string): void {\n process.stderr.write(`[mcp-marmiton] ${message}\\n`);\n}\n\n/**\n * Read the request interval, refusing anything below the floor.\n *\n * A value under the floor falls back to the default rather than to the floor\n * itself: someone who set 0 was not asking for 200, they were asking for no\n * pacing at all, and the safe reading of that request is to ignore it.\n */\nfunction readInterval(env: NodeJS.ProcessEnv): number {\n const raw = env.MARMITON_MIN_INTERVAL_MS;\n if (raw === undefined || raw.trim() === \"\") return DEFAULTS.minIntervalMs;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`MARMITON_MIN_INTERVAL_MS=\"${raw}\" is not a number, using ${DEFAULTS.minIntervalMs}ms`);\n return DEFAULTS.minIntervalMs;\n }\n\n const rounded = Math.round(parsed);\n if (rounded < MIN_ALLOWED_INTERVAL_MS) {\n warn(\n `MARMITON_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; ` +\n `using ${DEFAULTS.minIntervalMs}ms. Marmiton serves these pages for readers, so this client paces itself.`,\n );\n return DEFAULTS.minIntervalMs;\n }\n\n return Math.min(60_000, rounded);\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const rawUserAgent = env.MARMITON_USER_AGENT?.trim();\n const rawLogLevel = env.MARMITON_LOG_LEVEL?.trim().toLowerCase();\n\n let logLevel = DEFAULTS.logLevel;\n if (rawLogLevel) {\n if (LOG_LEVELS.includes(rawLogLevel as LogLevel)) {\n logLevel = rawLogLevel as LogLevel;\n } else {\n warn(`MARMITON_LOG_LEVEL=\"${rawLogLevel}\" is unknown, using \"${DEFAULTS.logLevel}\"`);\n }\n }\n\n return {\n userAgent: rawUserAgent || DEFAULT_USER_AGENT,\n minIntervalMs: readInterval(env),\n timeoutMs: readNumber(\"MARMITON_TIMEOUT_MS\", env, {\n min: 1000,\n max: 120_000,\n fallback: DEFAULTS.timeoutMs,\n }),\n maxRetries: readNumber(\"MARMITON_MAX_RETRIES\", env, {\n min: 0,\n max: 10,\n fallback: DEFAULTS.maxRetries,\n }),\n cacheTtlMs: readNumber(\"MARMITON_CACHE_TTL_MS\", env, {\n min: 0,\n max: 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.cacheTtlMs,\n }),\n cacheMaxEntries: readNumber(\"MARMITON_CACHE_MAX_ENTRIES\", env, {\n min: 0,\n max: 10_000,\n fallback: DEFAULTS.cacheMaxEntries,\n }),\n logLevel,\n };\n}\n\nconst LEVEL_RANK: Record<LogLevel, number> = { silent: 0, error: 1, info: 2, debug: 3 };\n\n/**\n * Logs go to stderr without exception. On a stdio transport, stdout carries the\n * protocol and any stray write there corrupts the session.\n */\nexport function createLogger(level: LogLevel) {\n const emit = (at: LogLevel, message: string) => {\n if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {\n process.stderr.write(`[mcp-marmiton] ${message}\\n`);\n }\n };\n return {\n error: (message: string) => emit(\"error\", message),\n info: (message: string) => emit(\"info\", message),\n debug: (message: string) => emit(\"debug\", message),\n };\n}\n\nexport type Logger = ReturnType<typeof createLogger>;\n","/**\n * In-memory TTL + LRU cache.\n *\n * Nothing is written to disk. The cache exists so that repeated questions about\n * the same song inside one conversation do not hit lyrics.com again, not to\n * build a local copy of the site.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) return;\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) break;\n this.store.delete(oldest.value);\n }\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Error taxonomy surfaced to the calling model.\n *\n * The rule carried over from the sibling projects: a failure must never be\n * reported as an empty result. A model that sees \"no recipe found\" cannot tell\n * that apart from a genuine absence, and will confidently tell the user the\n * recipe does not exist.\n */\n\nexport type ErrorCode =\n \"not_found\" | \"invalid_input\" | \"rate_limited\" | \"parse_failure\" | \"network_error\" | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class MarmitonError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"MarmitonError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-marmiton/issues\";\n\nexport function notFound(url: string, what: string): MarmitonError {\n return new MarmitonError(\"not_found\", `Marmiton has no recipe at ${what}.`, {\n url,\n status: 404,\n hint: \"Use search_recipes to find a recipe and its id, then call this tool with that id.\",\n });\n}\n\nexport function invalidInput(message: string, hint?: string): MarmitonError {\n return new MarmitonError(\"invalid_input\", message, hint ? { hint } : {});\n}\n\nexport function rateLimited(url: string, retryAfterMs: number): MarmitonError {\n return new MarmitonError(\n \"rate_limited\",\n \"Marmiton is rate limiting this client. This does NOT mean the recipe does not exist.\",\n {\n url,\n retryAfterMs,\n hint:\n `Wait about ${Math.ceil(retryAfterMs / 1000)} seconds, then call the same tool again with the ` +\n \"same arguments. If it keeps happening, raise MARMITON_MIN_INTERVAL_MS.\",\n },\n );\n}\n\nexport function parseFailure(url: string, what: string): MarmitonError {\n return new MarmitonError(\n \"parse_failure\",\n `The page loaded but the expected structured recipe data was not found (${what}). ` +\n \"Marmiton may have changed how it publishes it.\",\n { url, hint: `Please report this, with the recipe you asked for, at ${ISSUES_URL}` },\n );\n}\n\nexport function upstreamError(url: string, status: number): MarmitonError {\n return new MarmitonError(\"network_error\", `Marmiton returned HTTP ${status}.`, {\n url,\n status,\n ...(status >= 500 ? { hint: \"This is a problem on Marmiton's side. Try again shortly.\" } : {}),\n });\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * lyrics.com tolerates a steady trickle but stops answering after a short burst,\n * so requests are run one at a time with a floor on the gap between starts.\n * When the site does push back, the interval doubles and then decays back down\n * as requests succeed, which recovers faster than a fixed delay and behaves\n * better than hammering at a constant rate.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 10_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => {\n await this.waitForSlot();\n this.lastStart = Date.now();\n return task();\n });\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n const remaining = this.intervalMs - elapsed;\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, with backoff.\n *\n * Marmiton answers with ordinary status codes, so this layer mostly translates\n * them. The one judgement call is on a body too small to be a real page, which is\n * how a site under load often refuses without saying so.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { MarmitonError, notFound, rateLimited, upstreamError } from \"../errors.js\";\nimport { RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 20_000;\n\n/** A real Marmiton page weighs well over 100 KB. */\nconst MIN_PLAUSIBLE_HTML = 2000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one page as HTML, retrying transient conditions.\n *\n * The retry loop and its sleeps run inside a single limiter slot, so a queued\n * request cannot slip into the window the current one is backing away from.\n */\nexport async function fetchHtml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n\n return limiter.schedule(async () => {\n let lastError: MarmitonError | undefined;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = backoffDelay(attempt - 1);\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n try {\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"fr-FR,fr;q=0.9,en;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n if (status === 429 || status === 503) {\n limiter.penalize();\n lastError = rateLimited(url, backoffDelay(attempt));\n logger.info(`rate limited on ${url}, interval now ${limiter.currentIntervalMs}ms`);\n continue;\n }\n if (status === 404) throw notFound(url, \"that address\");\n if (status >= 500) {\n lastError = upstreamError(url, status);\n continue;\n }\n if (status >= 400) throw upstreamError(url, status);\n\n const trimmed = body.trim();\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\\/html>/i.test(trimmed)) {\n // Too small to be a page: treat it as a refusal rather than parse it and\n // report an empty result.\n limiter.penalize();\n lastError = rateLimited(url, backoffDelay(attempt));\n logger.info(`implausibly short body on ${url}, treating as rate limiting`);\n continue;\n }\n\n limiter.relax();\n return body;\n }\n\n throw lastError ?? new MarmitonError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\nfunction asTransportError(error: unknown, url: string): MarmitonError {\n if (error instanceof MarmitonError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new MarmitonError(\"timeout\", \"Marmiton did not answer in time.\", {\n url,\n hint: \"Raise MARMITON_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new MarmitonError(\"network_error\", `Could not reach Marmiton: ${message}`, { url });\n}\n","/**\n * ISO 8601 durations, which is how schema.org expresses cooking times.\n *\n * Marmiton writes \"PT25M\" for 25 minutes and \"PT1H30M\" for an hour and a half.\n */\n\nconst ISO_DURATION = /^P(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?$/;\n\n/** Minutes, or null when the value is absent or unreadable. */\nexport function parseIsoDuration(value: unknown): number | null {\n if (typeof value !== \"string\") return null;\n const match = ISO_DURATION.exec(value.trim().toUpperCase());\n if (!match) return null;\n\n const [, days, hours, minutes, seconds] = match;\n const total =\n Number(days ?? 0) * 1440 +\n Number(hours ?? 0) * 60 +\n Number(minutes ?? 0) +\n Number(seconds ?? 0) / 60;\n\n // \"P\" alone parses but means nothing useful.\n if (total === 0 && !minutes && !hours && !days && !seconds) return null;\n return Math.round(total);\n}\n\n/** Minutes as \"1 h 30\" or \"25 min\", for the human-readable mirror. */\nexport function formatMinutes(minutes: number | null): string | null {\n if (minutes === null || !Number.isFinite(minutes) || minutes <= 0) return null;\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n if (hours === 0) return `${rest} min`;\n if (rest === 0) return `${hours} h`;\n return `${hours} h ${String(rest).padStart(2, \"0\")}`;\n}\n\nexport interface ParsedYield {\n /** Number of servings, or null when the text carries no number. */\n count: number | null;\n /** What is being counted: \"personnes\", \"pièces\", \"parts\". */\n unit: string | null;\n /** The original text, always preserved. */\n text: string;\n}\n\n/**\n * Read a schema.org recipeYield.\n *\n * Marmiton writes \"6 personnes\" but also \"15 pièces\", so the unit is kept: a\n * recipe yielding pieces cannot be described as serving people.\n */\nexport function parseYield(value: unknown): ParsedYield {\n // schema.org allows recipeYield as a number, a string, or an array of either.\n const text =\n typeof value === \"number\" && Number.isFinite(value)\n ? String(value)\n : typeof value === \"string\"\n ? value.trim()\n : Array.isArray(value)\n ? String(value[0] ?? \"\").trim()\n : \"\";\n if (!text) return { count: null, unit: null, text: \"\" };\n\n const match = /^(\\d+(?:[.,]\\d+)?)\\s*(.*)$/.exec(text);\n if (!match) return { count: null, unit: null, text };\n\n const count = Number(match[1]!.replace(\",\", \".\"));\n const unit = (match[2] ?? \"\").trim() || null;\n return { count: Number.isFinite(count) ? count : null, unit, text };\n}\n","/**\n * JSON-LD extraction.\n *\n * Marmiton embeds a schema.org graph in every page, which is data the site\n * publishes deliberately for machines to read. Using it instead of CSS selectors\n * removes the whole class of breakage where a redesign renames a class and the\n * parser silently returns nothing.\n */\n\nconst LD_JSON_BLOCK = /<script[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi;\n\n/** Every JSON-LD object on the page, flattened out of any `@graph` wrappers. */\nexport function extractJsonLdNodes(html: string): unknown[] {\n const nodes: unknown[] = [];\n LD_JSON_BLOCK.lastIndex = 0;\n\n let match: RegExpExecArray | null;\n while ((match = LD_JSON_BLOCK.exec(html)) !== null) {\n const raw = match[1]?.trim();\n if (!raw) continue;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // A malformed block is skipped rather than fatal: pages carry several,\n // and one broken analytics blob must not hide the recipe.\n continue;\n }\n\n for (const node of flatten(parsed)) nodes.push(node);\n }\n\n return nodes;\n}\n\nfunction flatten(value: unknown): unknown[] {\n if (Array.isArray(value)) return value.flatMap(flatten);\n if (typeof value !== \"object\" || value === null) return [];\n\n const node = value as Record<string, unknown>;\n const graph = node[\"@graph\"];\n if (graph !== undefined) return flatten(graph);\n return [node];\n}\n\n/** True when a node declares the given schema.org type. */\nexport function hasType(node: unknown, type: string): boolean {\n if (typeof node !== \"object\" || node === null) return false;\n const declared = (node as Record<string, unknown>)[\"@type\"];\n if (typeof declared === \"string\") return declared === type;\n if (Array.isArray(declared)) return declared.includes(type);\n return false;\n}\n\nexport function findNodeOfType(nodes: unknown[], type: string): Record<string, unknown> | null {\n const found = nodes.find((node) => hasType(node, type));\n return (found as Record<string, unknown>) ?? null;\n}\n\n/** Read a string field, tolerating the array form schema.org allows. */\nexport function asString(value: unknown): string | null {\n if (typeof value === \"string\") return value.trim() || null;\n if (Array.isArray(value)) {\n for (const entry of value) {\n const found = asString(entry);\n if (found) return found;\n }\n }\n if (typeof value === \"object\" && value !== null) {\n // Fields such as `image` are often an ImageObject rather than a plain URL.\n const url = (value as Record<string, unknown>).url;\n if (typeof url === \"string\") return url.trim() || null;\n const name = (value as Record<string, unknown>).name;\n if (typeof name === \"string\") return name.trim() || null;\n }\n return null;\n}\n\nexport function asNumber(value: unknown): number | null {\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n if (typeof value === \"string\") {\n const parsed = Number(value.replace(\",\", \".\"));\n if (Number.isFinite(parsed)) return parsed;\n }\n return null;\n}\n\n/** Read a list of strings, which is how ingredients and steps are expressed. */\nexport function asStringList(value: unknown): string[] {\n if (typeof value === \"string\") return [value.trim()].filter(Boolean);\n if (!Array.isArray(value)) return [];\n\n const out: string[] = [];\n for (const entry of value) {\n if (typeof entry === \"string\") {\n const text = entry.trim();\n if (text) out.push(text);\n continue;\n }\n if (typeof entry === \"object\" && entry !== null) {\n // HowToStep carries the sentence under `text`.\n const record = entry as Record<string, unknown>;\n const text = asString(record.text) ?? asString(record.name);\n if (text) out.push(text);\n }\n }\n return out;\n}\n","/** URL construction and validation for marmiton.org. */\n\nimport { invalidInput } from \"../errors.js\";\n\nexport const BASE_URL = \"https://www.marmiton.org\";\n\n/** Recipe pages look like /recettes/recette_<slug>_<id>.aspx */\nexport const RECIPE_PATH_RE = /\\/recettes\\/recette[_-][a-z0-9%\\-.]*?_(\\d+)\\.aspx/i;\n\nconst ALLOWED_HOSTS = new Set([\"marmiton.org\", \"www.marmiton.org\"]);\n\n/**\n * Search URL.\n *\n * Only the `aqt` parameter is used. Marmiton's robots.txt disallows\n * `recherche.aspx?aqt=*page*` and `?aqt=*start*`, so this client never paginates\n * and the tool description says so rather than pretending the limit does not\n * exist.\n */\nexport function buildSearchUrl(query: string): string {\n const url = new URL(`${BASE_URL}/recettes/recherche.aspx`);\n url.searchParams.set(\"aqt\", query);\n return url.toString();\n}\n\n/**\n * Recipe URL from an id alone.\n *\n * Marmiton keys the page on the trailing id and ignores the slug, redirecting to\n * the canonical address whatever the slug says. It does require the slug to be\n * non-empty though: `recette__18588.aspx` answers 404 while `recette_r_18588.aspx`\n * redirects to the real page. Hence the placeholder, which lets a caller holding\n * only an id skip having to know the title.\n */\nexport function buildRecipeUrl(id: string): string {\n return `${BASE_URL}/recettes/recette_r_${id}.aspx`;\n}\n\n/** True only for marmiton.org, so a hostile URL cannot be used as a proxy. */\nexport function isMarmitonHost(rawUrl: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return false;\n return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());\n}\n\n/** Extract the numeric recipe id from a Marmiton path or absolute URL. */\nexport function extractRecipeId(hrefOrUrl: string): string | null {\n let path = hrefOrUrl;\n if (/^https?:\\/\\//i.test(hrefOrUrl)) {\n if (!isMarmitonHost(hrefOrUrl)) return null;\n path = new URL(hrefOrUrl).pathname;\n }\n const match = RECIPE_PATH_RE.exec(path);\n return match?.[1] ?? null;\n}\n\n/** Resolve the `id` / `url` pair accepted by get_recipe. `id` wins. */\nexport function resolveRecipeRef(input: { id?: string; url?: string }): {\n id: string;\n url: string;\n} {\n if (input.id) {\n if (!/^\\d+$/.test(input.id)) {\n throw invalidInput(\n `\"${input.id}\" is not a Marmiton recipe id.`,\n \"Ids are digits only, as returned by search_recipes.\",\n );\n }\n return { id: input.id, url: buildRecipeUrl(input.id) };\n }\n\n if (input.url) {\n if (!isMarmitonHost(input.url)) {\n throw invalidInput(\n \"Only marmiton.org URLs are accepted.\",\n \"Pass the url returned by search_recipes, or use the numeric id instead.\",\n );\n }\n const id = extractRecipeId(input.url);\n if (!id) {\n throw invalidInput(\n \"That Marmiton URL is not a recipe page.\",\n \"Recipe pages look like https://www.marmiton.org/recettes/recette_tarte-aux-pommes_18588.aspx\",\n );\n }\n return { id, url: input.url };\n }\n\n throw invalidInput(\"Either 'id' or 'url' must be provided.\");\n}\n\nexport function toAbsoluteUrl(href: string): string {\n return new URL(href, BASE_URL).toString();\n}\n","/**\n * Turning Marmiton's JSON-LD into the domain types.\n *\n * The editorial `description` is deliberately not read here. Ingredient lists and\n * cooking steps are facts and procedures, and a public tool can hand them on with\n * a link to the source; the author's descriptive prose is their writing, and this\n * server has no business redistributing it.\n */\n\nimport { parseFailure } from \"../errors.js\";\nimport { parseIsoDuration, parseYield } from \"../recipe/duration.js\";\nimport type { Nutrition, Rating, Recipe, RecipeSummary } from \"../types.js\";\nimport { asNumber, asString, asStringList, extractJsonLdNodes, findNodeOfType } from \"./jsonld.js\";\nimport { extractRecipeId, toAbsoluteUrl } from \"./urls.js\";\n\nexport interface ParseRecipeOptions {\n id: string;\n url: string;\n}\n\nexport function parseRecipePage(html: string, options: ParseRecipeOptions): Recipe {\n const nodes = extractJsonLdNodes(html);\n const node = findNodeOfType(nodes, \"Recipe\");\n\n // A page that loaded but carries no Recipe node means Marmiton changed how it\n // publishes its data. Reporting that as \"no recipe\" would be indistinguishable\n // from a genuine absence, so it has to be loud.\n if (!node) {\n throw parseFailure(options.url, \"no schema.org Recipe node in the page\");\n }\n\n const title = asString(node.name);\n const ingredients = asStringList(node.recipeIngredient);\n const steps = asStringList(node.recipeInstructions);\n\n if (!title) throw parseFailure(options.url, \"the Recipe node has no name\");\n if (ingredients.length === 0) {\n throw parseFailure(options.url, \"the Recipe node carries no ingredient list\");\n }\n\n return {\n id: options.id,\n title,\n url: asString(node.mainEntityOfPage) ?? options.url,\n imageUrl: asString(node.image),\n ingredients,\n steps,\n recipeYield: parseYield(node.recipeYield),\n prepMinutes: parseIsoDuration(node.prepTime),\n cookMinutes: parseIsoDuration(node.cookTime),\n totalMinutes: parseIsoDuration(node.totalTime),\n category: asString(node.recipeCategory),\n author: asString(node.author),\n rating: parseRating(node.aggregateRating),\n nutrition: parseNutrition(node.nutrition),\n };\n}\n\nfunction parseRating(value: unknown): Rating | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const ratingValue = asNumber(record.ratingValue);\n if (ratingValue === null) return null;\n return {\n value: ratingValue,\n count: asNumber(record.ratingCount) ?? asNumber(record.reviewCount),\n best: asNumber(record.bestRating),\n };\n}\n\nfunction parseNutrition(value: unknown): Nutrition | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const nutrition: Nutrition = {\n calories: asString(record.calories),\n protein: asString(record.proteinContent),\n fat: asString(record.fatContent),\n carbohydrate: asString(record.carbohydrateContent),\n fiber: asString(record.fiberContent),\n sodium: asString(record.sodiumContent),\n servingSize: asString(record.servingSize),\n };\n const hasAny = Object.values(nutrition).some((entry) => entry !== null);\n return hasAny ? nutrition : null;\n}\n\n/**\n * Search results, read from the ItemList the search page publishes.\n *\n * The page also lists more recipes in its HTML, but reaching them would mean\n * reintroducing CSS selectors and the breakage that comes with them. The\n * structured list is what the site offers machines, so that is what is used.\n */\nexport function parseSearchPage(html: string, url: string): RecipeSummary[] {\n const nodes = extractJsonLdNodes(html);\n const list = findNodeOfType(nodes, \"ItemList\");\n\n if (!list) {\n throw parseFailure(url, \"no schema.org ItemList node on the search page\");\n }\n\n const elements = list.itemListElement;\n if (!Array.isArray(elements)) {\n throw parseFailure(url, \"the ItemList node carries no itemListElement array\");\n }\n\n const results: RecipeSummary[] = [];\n let skipped = 0;\n\n for (const element of elements) {\n if (typeof element !== \"object\" || element === null) {\n skipped += 1;\n continue;\n }\n const record = element as Record<string, unknown>;\n const href = asString(record.url);\n const title = asString(record.name);\n const id = href ? extractRecipeId(href) : null;\n\n if (!href || !title || !id) {\n skipped += 1;\n continue;\n }\n\n results.push({ id, title, url: toAbsoluteUrl(href), imageUrl: asString(record.image) });\n }\n\n // Every entry failing while the list itself was present means the entry shape\n // changed, which is breakage rather than an empty search.\n if (results.length === 0 && elements.length > 0) {\n throw parseFailure(url, `${elements.length} list entries but none could be read`);\n }\n if (skipped > 0) {\n process.stderr.write(`[mcp-marmiton] skipped ${skipped} unreadable search entries on ${url}\\n`);\n }\n\n return results;\n}\n","/**\n * High-level Marmiton client.\n *\n * This module knows nothing about MCP, which keeps it testable against plain\n * strings and usable as a library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { createLogger, loadConfig } from \"../config.js\";\nimport type { Recipe, RecipeSummary } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchHtml } from \"./http.js\";\nimport { parseRecipePage, parseSearchPage } from \"./parseRecipe.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { buildSearchUrl, resolveRecipeRef } from \"./urls.js\";\n\nexport interface MarmitonClientOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport interface Outcome<T> {\n data: T;\n /** True when served from the in-memory cache rather than the network. */\n cached: boolean;\n}\n\nexport class MarmitonClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<string>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: MarmitonClientOptions = {}) {\n this.config = options.config ?? loadConfig();\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });\n this.cache = new TtlLruCache<string>(this.config.cacheMaxEntries, this.config.cacheTtlMs);\n this.fetchImpl = options.fetchImpl;\n }\n\n /**\n * Search recipes.\n *\n * One page only: Marmiton's robots.txt disallows the paginated form of this\n * URL, so there is no page parameter to pass.\n */\n async search(query: string): Promise<Outcome<RecipeSummary[]>> {\n const url = buildSearchUrl(query);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseSearchPage(html, url), cached };\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseRecipePage(html, { id, url }), cached };\n }\n\n private async fetchPage(url: string): Promise<{ html: string; cached: boolean }> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { html: hit, cached: true };\n }\n\n const html = await fetchHtml(url, {\n config: this.config,\n limiter: this.limiter,\n logger: this.logger,\n ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),\n });\n this.cache.set(url, html);\n return { html, cached: false };\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/version.ts","../../src/config.ts","../../src/marmiton/cache.ts","../../src/errors.ts","../../src/marmiton/rateLimiter.ts","../../src/marmiton/http.ts","../../src/recipe/duration.ts","../../src/marmiton/jsonld.ts","../../src/marmiton/urls.ts","../../src/marmiton/parseRecipe.ts","../../src/marmiton/client.ts"],"sourcesContent":["/**\n * Kept in sync with package.json and server.json. The User-Agent embeds it, so it\n * must not drift; the release checklist bumps all three together.\n */\nexport const PKG_VERSION = \"1.0.4\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-marmiton\";\n","/**\n * Runtime configuration, read from environment variables.\n *\n * A bad value never crashes the process: an MCP server that dies at startup\n * because of a typo in a client config file is very hard to diagnose from the\n * host application, so invalid input is clamped and reported on stderr.\n */\n\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport type LogLevel = \"silent\" | \"error\" | \"info\" | \"debug\";\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\n/**\n * MARMITON asks third-party clients to identify themselves with an application\n * name, a version and a link to the project, so the default follows that format.\n */\nexport const DEFAULT_USER_AGENT = `mcp-marmiton v${PKG_VERSION} (${REPO_URL})`;\n\nexport const DEFAULTS = {\n minIntervalMs: 1000,\n timeoutMs: 15_000,\n maxRetries: 3,\n cacheTtlMs: 15 * 60 * 1000,\n cacheMaxEntries: 200,\n logLevel: \"error\" as LogLevel,\n};\n\n/**\n * Floor on the request interval, enforced regardless of configuration.\n *\n * MARMITON publishes no rate limit and asks for nothing in return for a free\n * service. Self-imposed pacing is the whole of the politeness contract here, so\n * a value below this floor is refused and the default is used instead.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n\nconst LOG_LEVELS: LogLevel[] = [\"silent\", \"error\", \"info\", \"debug\"];\n\ninterface NumericRange {\n min: number;\n max: number;\n fallback: number;\n}\n\nfunction readNumber(name: string, env: NodeJS.ProcessEnv, range: NumericRange): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") return range.fallback;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`${name}=\"${raw}\" is not a number, using ${range.fallback}`);\n return range.fallback;\n }\n\n // Out-of-range values fall back to the default rather than being clamped to\n // the nearest bound. Clamping turns a typo into a silent behaviour change:\n // -1 retries becomes \"never retry\", and -1 cache entries disables the cache\n // outright, both of which look like working configuration.\n const rounded = Math.round(parsed);\n if (rounded < range.min || rounded > range.max) {\n warn(\n `${name}=${raw} is outside the accepted range ${range.min}-${range.max} and was ignored; ` +\n `using ${range.fallback}`,\n );\n return range.fallback;\n }\n return rounded;\n}\n\nfunction warn(message: string): void {\n process.stderr.write(`[mcp-marmiton] ${message}\\n`);\n}\n\n/**\n * Read the request interval, refusing anything below the floor.\n *\n * A value under the floor falls back to the default rather than to the floor\n * itself: someone who set 0 was not asking for 200, they were asking for no\n * pacing at all, and the safe reading of that request is to ignore it.\n */\nfunction readInterval(env: NodeJS.ProcessEnv): number {\n const raw = env.MARMITON_MIN_INTERVAL_MS;\n if (raw === undefined || raw.trim() === \"\") return DEFAULTS.minIntervalMs;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`MARMITON_MIN_INTERVAL_MS=\"${raw}\" is not a number, using ${DEFAULTS.minIntervalMs}ms`);\n return DEFAULTS.minIntervalMs;\n }\n\n const rounded = Math.round(parsed);\n if (rounded < MIN_ALLOWED_INTERVAL_MS) {\n warn(\n `MARMITON_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; ` +\n `using ${DEFAULTS.minIntervalMs}ms. Marmiton serves these pages for readers, so this client paces itself.`,\n );\n return DEFAULTS.minIntervalMs;\n }\n\n return Math.min(60_000, rounded);\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const rawUserAgent = env.MARMITON_USER_AGENT?.trim();\n const rawLogLevel = env.MARMITON_LOG_LEVEL?.trim().toLowerCase();\n\n let logLevel = DEFAULTS.logLevel;\n if (rawLogLevel) {\n if (LOG_LEVELS.includes(rawLogLevel as LogLevel)) {\n logLevel = rawLogLevel as LogLevel;\n } else {\n warn(`MARMITON_LOG_LEVEL=\"${rawLogLevel}\" is unknown, using \"${DEFAULTS.logLevel}\"`);\n }\n }\n\n return {\n userAgent: rawUserAgent || DEFAULT_USER_AGENT,\n minIntervalMs: readInterval(env),\n timeoutMs: readNumber(\"MARMITON_TIMEOUT_MS\", env, {\n min: 1000,\n max: 120_000,\n fallback: DEFAULTS.timeoutMs,\n }),\n maxRetries: readNumber(\"MARMITON_MAX_RETRIES\", env, {\n min: 0,\n max: 10,\n fallback: DEFAULTS.maxRetries,\n }),\n cacheTtlMs: readNumber(\"MARMITON_CACHE_TTL_MS\", env, {\n min: 0,\n max: 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.cacheTtlMs,\n }),\n cacheMaxEntries: readNumber(\"MARMITON_CACHE_MAX_ENTRIES\", env, {\n min: 0,\n max: 10_000,\n fallback: DEFAULTS.cacheMaxEntries,\n }),\n logLevel,\n };\n}\n\nconst LEVEL_RANK: Record<LogLevel, number> = { silent: 0, error: 1, info: 2, debug: 3 };\n\n/**\n * Logs go to stderr without exception. On a stdio transport, stdout carries the\n * protocol and any stray write there corrupts the session.\n */\nexport function createLogger(level: LogLevel) {\n const emit = (at: LogLevel, message: string) => {\n if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {\n process.stderr.write(`[mcp-marmiton] ${message}\\n`);\n }\n };\n return {\n error: (message: string) => emit(\"error\", message),\n info: (message: string) => emit(\"info\", message),\n debug: (message: string) => emit(\"debug\", message),\n };\n}\n\nexport type Logger = ReturnType<typeof createLogger>;\n","/**\n * In-memory TTL + LRU cache.\n *\n * Nothing is written to disk. The cache exists so that repeated questions about\n * the same recipe inside one conversation do not hit Marmiton again, not to\n * build a local copy of the site.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) return;\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) break;\n this.store.delete(oldest.value);\n }\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Error taxonomy surfaced to the calling model.\n *\n * The rule carried over from the sibling projects: a failure must never be\n * reported as an empty result. A model that sees \"no recipe found\" cannot tell\n * that apart from a genuine absence, and will confidently tell the user the\n * recipe does not exist.\n */\n\nexport type ErrorCode =\n \"not_found\" | \"invalid_input\" | \"rate_limited\" | \"parse_failure\" | \"network_error\" | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class MarmitonError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"MarmitonError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-marmiton/issues\";\n\nexport function notFound(url: string, what: string): MarmitonError {\n return new MarmitonError(\"not_found\", `Marmiton has no recipe at ${what}.`, {\n url,\n status: 404,\n hint: \"Use search_recipes to find a recipe and its id, then call this tool with that id.\",\n });\n}\n\nexport function invalidInput(message: string, hint?: string): MarmitonError {\n return new MarmitonError(\"invalid_input\", message, hint ? { hint } : {});\n}\n\nexport function rateLimited(url: string, retryAfterMs: number): MarmitonError {\n return new MarmitonError(\n \"rate_limited\",\n \"Marmiton is rate limiting this client. This does NOT mean the recipe does not exist.\",\n {\n url,\n retryAfterMs,\n hint:\n `Wait about ${Math.ceil(retryAfterMs / 1000)} seconds, then call the same tool again with the ` +\n \"same arguments. If it keeps happening, raise MARMITON_MIN_INTERVAL_MS.\",\n },\n );\n}\n\nexport function parseFailure(url: string, what: string): MarmitonError {\n return new MarmitonError(\n \"parse_failure\",\n `The page loaded but the expected structured recipe data was not found (${what}). ` +\n \"Marmiton may have changed how it publishes it.\",\n { url, hint: `Please report this, with the recipe you asked for, at ${ISSUES_URL}` },\n );\n}\n\nexport function upstreamError(url: string, status: number): MarmitonError {\n return new MarmitonError(\"network_error\", `Marmiton returned HTTP ${status}.`, {\n url,\n status,\n ...(status >= 500 ? { hint: \"This is a problem on Marmiton's side. Try again shortly.\" } : {}),\n });\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * Marmiton publishes no rate limit and answers a burst by refusing rather than\n * by slowing down, so requests run one at a time with a floor on the gap between\n * starts. When the site does push back, the interval doubles and then decays\n * back down as requests succeed, which recovers faster than a fixed delay and\n * behaves better than a constant rate.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 10_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => task());\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /**\n * Wait for this request's slot, then claim it.\n *\n * Called once per upstream request rather than once per task, because a task\n * runs a whole retry chain: stamping only its start would let the next task\n * follow the chain's last request with no gap at all.\n */\n async beforeRequest(): Promise<void> {\n await this.waitForSlot();\n this.lastStart = Date.now();\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n // Clamped to the interval: a clock stepped backwards, by NTP or a resumed\n // virtual machine, would otherwise make this wait for the size of the step,\n // and the queue is serial so every pending request would wait behind it.\n const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, with backoff.\n *\n * Marmiton answers with ordinary status codes, so this layer mostly translates\n * them. The one judgement call is on a body too small to be a real page, which is\n * how a site under load often refuses without saying so.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { MarmitonError, notFound, rateLimited, upstreamError } from \"../errors.js\";\nimport { RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 20_000;\n\n/** A real Marmiton page weighs well over 100 KB. */\nconst MIN_PLAUSIBLE_HTML = 2000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one page as HTML, retrying transient conditions.\n *\n * The retry loop and its sleeps run inside a single limiter slot, so a queued\n * request cannot slip into the window the current one is backing away from.\n */\nexport async function fetchHtml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n\n return limiter.schedule(async () => {\n let lastError: MarmitonError | undefined;\n\n // Set when the site says how long to stay away; it replaces our own guess\n // for the next attempt. Applied here rather than where it is read, so no\n // wait is ever served after the last attempt, when nobody would use it.\n let askedWaitMs: number | null = null;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);\n askedWaitMs = null;\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n let retryAfterMs: number | null = null;\n try {\n await limiter.beforeRequest();\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"fr-FR,fr;q=0.9,en;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n retryAfterMs = parseRetryAfter(response.headers.get(\"retry-after\"));\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n if (status === 429 || status === 503 || status === 403) {\n limiter.penalize();\n // A server that says when to come back knows better than our own guess.\n askedWaitMs = retryAfterMs;\n lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));\n logger.info(`rate limited on ${url}, interval now ${limiter.currentIntervalMs}ms`);\n continue;\n }\n if (status === 404) throw notFound(url, \"that address\");\n if (status >= 500) {\n lastError = upstreamError(url, status);\n continue;\n }\n if (status >= 400) throw upstreamError(url, status);\n\n const trimmed = body.trim();\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\\/html>/i.test(trimmed)) {\n // Too small to be a page: treat it as a refusal rather than parse it and\n // report an empty result.\n limiter.penalize();\n lastError = rateLimited(url, backoffDelay(attempt));\n logger.info(`implausibly short body on ${url}, treating as rate limiting`);\n continue;\n }\n\n limiter.relax();\n return body;\n }\n\n throw lastError ?? new MarmitonError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\n/** `Retry-After` carries either seconds or an HTTP date. */\nfunction parseRetryAfter(raw: string | null): number | null {\n if (!raw) return null;\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);\n const when = Date.parse(raw);\n if (Number.isNaN(when)) return null;\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, url: string): MarmitonError {\n if (error instanceof MarmitonError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new MarmitonError(\"timeout\", \"Marmiton did not answer in time.\", {\n url,\n hint: \"Raise MARMITON_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new MarmitonError(\"network_error\", `Could not reach Marmiton: ${message}`, { url });\n}\n","/**\n * ISO 8601 durations, which is how schema.org expresses cooking times.\n *\n * Marmiton writes \"PT25M\" for 25 minutes and \"PT1H30M\" for an hour and a half.\n */\n\nconst ISO_DURATION = /^P(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?$/;\n\n/** Minutes, or null when the value is absent or unreadable. */\nexport function parseIsoDuration(value: unknown): number | null {\n if (typeof value !== \"string\") return null;\n const match = ISO_DURATION.exec(value.trim().toUpperCase());\n if (!match) return null;\n\n const [, days, hours, minutes, seconds] = match;\n const total =\n Number(days ?? 0) * 1440 +\n Number(hours ?? 0) * 60 +\n Number(minutes ?? 0) +\n Number(seconds ?? 0) / 60;\n\n // \"P\" alone parses but means nothing useful.\n if (total === 0 && !minutes && !hours && !days && !seconds) return null;\n return Math.round(total);\n}\n\n/** Minutes as \"1 h 30\" or \"25 min\", for the human-readable mirror. */\nexport function formatMinutes(minutes: number | null): string | null {\n if (minutes === null || !Number.isFinite(minutes) || minutes <= 0) return null;\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n if (hours === 0) return `${rest} min`;\n if (rest === 0) return `${hours} h`;\n return `${hours} h ${String(rest).padStart(2, \"0\")}`;\n}\n\nexport interface ParsedYield {\n /** Number of servings, or null when the text carries no number. */\n count: number | null;\n /** What is being counted: \"personnes\", \"pièces\", \"parts\". */\n unit: string | null;\n /** The original text, always preserved. */\n text: string;\n}\n\n/**\n * Read a schema.org recipeYield.\n *\n * Marmiton writes \"6 personnes\" but also \"15 pièces\", so the unit is kept: a\n * recipe yielding pieces cannot be described as serving people.\n */\nexport function parseYield(value: unknown): ParsedYield {\n // schema.org allows recipeYield as a number, a string, or an array of either.\n const text =\n typeof value === \"number\" && Number.isFinite(value)\n ? String(value)\n : typeof value === \"string\"\n ? value.trim()\n : Array.isArray(value)\n ? String(value[0] ?? \"\").trim()\n : \"\";\n if (!text) return { count: null, unit: null, text: \"\" };\n\n const match = /^(\\d+(?:[.,]\\d+)?)\\s*(.*)$/.exec(text);\n if (!match) return { count: null, unit: null, text };\n\n const count = Number(match[1]!.replace(\",\", \".\"));\n const unit = (match[2] ?? \"\").trim() || null;\n return { count: Number.isFinite(count) ? count : null, unit, text };\n}\n","/**\n * JSON-LD extraction.\n *\n * Marmiton embeds a schema.org graph in every page, which is data the site\n * publishes deliberately for machines to read. Using it instead of CSS selectors\n * removes the whole class of breakage where a redesign renames a class and the\n * parser silently returns nothing.\n */\n\nconst LD_JSON_BLOCK = /<script[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi;\n\n/** Every JSON-LD object on the page, flattened out of any `@graph` wrappers. */\nexport function extractJsonLdNodes(html: string): unknown[] {\n const nodes: unknown[] = [];\n LD_JSON_BLOCK.lastIndex = 0;\n\n let match: RegExpExecArray | null;\n while ((match = LD_JSON_BLOCK.exec(html)) !== null) {\n const raw = match[1]?.trim();\n if (!raw) continue;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // A malformed block is skipped rather than fatal: pages carry several,\n // and one broken analytics blob must not hide the recipe.\n continue;\n }\n\n for (const node of flatten(parsed)) nodes.push(node);\n }\n\n return nodes;\n}\n\nfunction flatten(value: unknown): unknown[] {\n if (Array.isArray(value)) return value.flatMap(flatten);\n if (typeof value !== \"object\" || value === null) return [];\n\n const node = value as Record<string, unknown>;\n const graph = node[\"@graph\"];\n if (graph !== undefined) return flatten(graph);\n return [node];\n}\n\n/** True when a node declares the given schema.org type. */\nexport function hasType(node: unknown, type: string): boolean {\n if (typeof node !== \"object\" || node === null) return false;\n const declared = (node as Record<string, unknown>)[\"@type\"];\n if (typeof declared === \"string\") return declared === type;\n if (Array.isArray(declared)) return declared.includes(type);\n return false;\n}\n\nexport function findNodeOfType(nodes: unknown[], type: string): Record<string, unknown> | null {\n const found = nodes.find((node) => hasType(node, type));\n return (found as Record<string, unknown>) ?? null;\n}\n\n/** Read a string field, tolerating the array form schema.org allows. */\nexport function asString(value: unknown): string | null {\n if (typeof value === \"string\") return value.trim() || null;\n if (Array.isArray(value)) {\n for (const entry of value) {\n const found = asString(entry);\n if (found) return found;\n }\n }\n if (typeof value === \"object\" && value !== null) {\n // Fields such as `image` are often an ImageObject rather than a plain URL.\n const url = (value as Record<string, unknown>).url;\n if (typeof url === \"string\") return url.trim() || null;\n const name = (value as Record<string, unknown>).name;\n if (typeof name === \"string\") return name.trim() || null;\n }\n return null;\n}\n\nexport function asNumber(value: unknown): number | null {\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n if (typeof value === \"string\") {\n const parsed = Number(value.replace(\",\", \".\"));\n if (Number.isFinite(parsed)) return parsed;\n }\n return null;\n}\n\n/** Read a list of strings, which is how ingredients and steps are expressed. */\nexport function asStringList(value: unknown): string[] {\n if (typeof value === \"string\") return [value.trim()].filter(Boolean);\n if (!Array.isArray(value)) return [];\n\n const out: string[] = [];\n for (const entry of value) {\n if (typeof entry === \"string\") {\n const text = entry.trim();\n if (text) out.push(text);\n continue;\n }\n if (typeof entry === \"object\" && entry !== null) {\n // HowToStep carries the sentence under `text`.\n const record = entry as Record<string, unknown>;\n const text = asString(record.text) ?? asString(record.name);\n if (text) out.push(text);\n }\n }\n return out;\n}\n","/** URL construction and validation for marmiton.org. */\n\nimport { invalidInput } from \"../errors.js\";\n\nexport const BASE_URL = \"https://www.marmiton.org\";\n\n/** Recipe pages look like /recettes/recette_<slug>_<id>.aspx */\nexport const RECIPE_PATH_RE = /\\/recettes\\/recette[_-][a-z0-9%\\-.]*?_(\\d+)\\.aspx/i;\n\nconst ALLOWED_HOSTS = new Set([\"marmiton.org\", \"www.marmiton.org\"]);\n\n/**\n * Search URL.\n *\n * Only the `aqt` parameter is used. Marmiton's robots.txt disallows\n * `recherche.aspx?aqt=*page*` and `?aqt=*start*`, so this client never paginates\n * and the tool description says so rather than pretending the limit does not\n * exist.\n */\nexport function buildSearchUrl(query: string): string {\n const url = new URL(`${BASE_URL}/recettes/recherche.aspx`);\n url.searchParams.set(\"aqt\", query);\n return url.toString();\n}\n\n/**\n * Recipe URL from an id alone.\n *\n * Marmiton keys the page on the trailing id and ignores the slug, redirecting to\n * the canonical address whatever the slug says. It does require the slug to be\n * non-empty though: `recette__18588.aspx` answers 404 while `recette_r_18588.aspx`\n * redirects to the real page. Hence the placeholder, which lets a caller holding\n * only an id skip having to know the title.\n */\nexport function buildRecipeUrl(id: string): string {\n return `${BASE_URL}/recettes/recette_r_${id}.aspx`;\n}\n\n/** True only for marmiton.org, so a hostile URL cannot be used as a proxy. */\nexport function isMarmitonHost(rawUrl: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return false;\n return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());\n}\n\n/** Extract the numeric recipe id from a Marmiton path or absolute URL. */\nexport function extractRecipeId(hrefOrUrl: string): string | null {\n let path = hrefOrUrl;\n if (/^https?:\\/\\//i.test(hrefOrUrl)) {\n if (!isMarmitonHost(hrefOrUrl)) return null;\n path = new URL(hrefOrUrl).pathname;\n }\n const match = RECIPE_PATH_RE.exec(path);\n return match?.[1] ?? null;\n}\n\n/** Resolve the `id` / `url` pair accepted by get_recipe. `id` wins. */\nexport function resolveRecipeRef(input: { id?: string; url?: string }): {\n id: string;\n url: string;\n} {\n if (input.id) {\n if (!/^\\d+$/.test(input.id)) {\n throw invalidInput(\n `\"${input.id}\" is not a Marmiton recipe id.`,\n \"Ids are digits only, as returned by search_recipes.\",\n );\n }\n return { id: input.id, url: buildRecipeUrl(input.id) };\n }\n\n if (input.url) {\n if (!isMarmitonHost(input.url)) {\n throw invalidInput(\n \"Only marmiton.org URLs are accepted.\",\n \"Pass the url returned by search_recipes, or use the numeric id instead.\",\n );\n }\n const id = extractRecipeId(input.url);\n if (!id) {\n throw invalidInput(\n \"That Marmiton URL is not a recipe page.\",\n \"Recipe pages look like https://www.marmiton.org/recettes/recette_tarte-aux-pommes_18588.aspx\",\n );\n }\n return { id, url: input.url };\n }\n\n throw invalidInput(\"Either 'id' or 'url' must be provided.\");\n}\n\nexport function toAbsoluteUrl(href: string): string {\n return new URL(href, BASE_URL).toString();\n}\n","/**\n * Turning Marmiton's JSON-LD into the domain types.\n *\n * The editorial `description` is deliberately not read here. Ingredient lists and\n * cooking steps are facts and procedures, and a public tool can hand them on with\n * a link to the source; the author's descriptive prose is their writing, and this\n * server has no business redistributing it.\n */\n\nimport { parseFailure } from \"../errors.js\";\nimport { parseIsoDuration, parseYield } from \"../recipe/duration.js\";\nimport type { Nutrition, Rating, Recipe, RecipeSummary } from \"../types.js\";\nimport { asNumber, asString, asStringList, extractJsonLdNodes, findNodeOfType } from \"./jsonld.js\";\nimport { extractRecipeId, toAbsoluteUrl } from \"./urls.js\";\n\nexport interface ParseRecipeOptions {\n id: string;\n url: string;\n}\n\nexport function parseRecipePage(html: string, options: ParseRecipeOptions): Recipe {\n const nodes = extractJsonLdNodes(html);\n const node = findNodeOfType(nodes, \"Recipe\");\n\n // A page that loaded but carries no Recipe node means Marmiton changed how it\n // publishes its data. Reporting that as \"no recipe\" would be indistinguishable\n // from a genuine absence, so it has to be loud.\n if (!node) {\n throw parseFailure(options.url, \"no schema.org Recipe node in the page\");\n }\n\n const title = asString(node.name);\n const ingredients = asStringList(node.recipeIngredient);\n const steps = asStringList(node.recipeInstructions);\n\n if (!title) throw parseFailure(options.url, \"the Recipe node has no name\");\n if (ingredients.length === 0) {\n throw parseFailure(options.url, \"the Recipe node carries no ingredient list\");\n }\n\n return {\n id: options.id,\n title,\n url: asString(node.mainEntityOfPage) ?? options.url,\n imageUrl: asString(node.image),\n ingredients,\n steps,\n recipeYield: parseYield(node.recipeYield),\n prepMinutes: parseIsoDuration(node.prepTime),\n cookMinutes: parseIsoDuration(node.cookTime),\n totalMinutes: parseIsoDuration(node.totalTime),\n category: asString(node.recipeCategory),\n author: asString(node.author),\n rating: parseRating(node.aggregateRating),\n nutrition: parseNutrition(node.nutrition),\n };\n}\n\nfunction parseRating(value: unknown): Rating | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const ratingValue = asNumber(record.ratingValue);\n if (ratingValue === null) return null;\n return {\n value: ratingValue,\n count: asNumber(record.ratingCount) ?? asNumber(record.reviewCount),\n best: asNumber(record.bestRating),\n };\n}\n\nfunction parseNutrition(value: unknown): Nutrition | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const nutrition: Nutrition = {\n calories: asString(record.calories),\n protein: asString(record.proteinContent),\n fat: asString(record.fatContent),\n carbohydrate: asString(record.carbohydrateContent),\n fiber: asString(record.fiberContent),\n sodium: asString(record.sodiumContent),\n servingSize: asString(record.servingSize),\n };\n const hasAny = Object.values(nutrition).some((entry) => entry !== null);\n return hasAny ? nutrition : null;\n}\n\n/**\n * Search results, read from the ItemList the search page publishes.\n *\n * The page also lists more recipes in its HTML, but reaching them would mean\n * reintroducing CSS selectors and the breakage that comes with them. The\n * structured list is what the site offers machines, so that is what is used.\n */\nexport function parseSearchPage(html: string, url: string): RecipeSummary[] {\n const nodes = extractJsonLdNodes(html);\n const list = findNodeOfType(nodes, \"ItemList\");\n\n if (!list) {\n throw parseFailure(url, \"no schema.org ItemList node on the search page\");\n }\n\n const elements = list.itemListElement;\n if (!Array.isArray(elements)) {\n throw parseFailure(url, \"the ItemList node carries no itemListElement array\");\n }\n\n const results: RecipeSummary[] = [];\n let skipped = 0;\n\n for (const element of elements) {\n if (typeof element !== \"object\" || element === null) {\n skipped += 1;\n continue;\n }\n const record = element as Record<string, unknown>;\n const href = asString(record.url);\n const title = asString(record.name);\n const id = href ? extractRecipeId(href) : null;\n\n if (!href || !title || !id) {\n skipped += 1;\n continue;\n }\n\n results.push({ id, title, url: toAbsoluteUrl(href), imageUrl: asString(record.image) });\n }\n\n // Every entry failing while the list itself was present means the entry shape\n // changed, which is breakage rather than an empty search.\n if (results.length === 0 && elements.length > 0) {\n throw parseFailure(url, `${elements.length} list entries but none could be read`);\n }\n if (skipped > 0) {\n process.stderr.write(`[mcp-marmiton] skipped ${skipped} unreadable search entries on ${url}\\n`);\n }\n\n return results;\n}\n","/**\n * High-level Marmiton client.\n *\n * This module knows nothing about MCP, which keeps it testable against plain\n * strings and usable as a library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n DEFAULT_USER_AGENT,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport type { Recipe, RecipeSummary } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchHtml } from \"./http.js\";\nimport { parseRecipePage, parseSearchPage } from \"./parseRecipe.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { buildSearchUrl, resolveRecipeRef } from \"./urls.js\";\n\nexport interface MarmitonClientOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport interface Outcome<T> {\n data: T;\n /** True when served from the in-memory cache rather than the network. */\n cached: boolean;\n}\n\n/**\n * Apply the guarantees this project makes about its own traffic.\n *\n * The environment parser already enforces both, but `MarmitonClient` is published as a\n * library through the `./client` export and takes a caller-built config, so\n * without this the pacing floor and the honest identity are optional for anyone\n * importing it. Marmiton publishes no rate limit, so the pacing here is self-imposed politeness, and those promises hold on every path.\n *\n * A caller may still name their own application in the User-Agent. Passing the\n * traffic off as a browser is a different thing, and gets the project's own\n * identity appended so it stays attributable.\n */\nfunction withGuarantees(config: Config): Config {\n const userAgent = /mozilla\\/|applewebkit|chrome\\/|safari\\/|gecko/i.test(config.userAgent)\n ? `${config.userAgent} ${DEFAULT_USER_AGENT}`\n : config.userAgent;\n return {\n ...config,\n userAgent,\n minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs),\n };\n}\n\nexport class MarmitonClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<string>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: MarmitonClientOptions = {}) {\n this.config = withGuarantees(options.config ?? loadConfig());\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });\n this.cache = new TtlLruCache<string>(this.config.cacheMaxEntries, this.config.cacheTtlMs);\n this.fetchImpl = options.fetchImpl;\n }\n\n /**\n * Search recipes.\n *\n * One page only: Marmiton's robots.txt disallows the paginated form of this\n * URL, so there is no page parameter to pass.\n */\n async search(query: string): Promise<Outcome<RecipeSummary[]>> {\n const url = buildSearchUrl(query);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseSearchPage(html, url), cached };\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseRecipePage(html, { id, url }), cached };\n }\n\n private async fetchPage(url: string): Promise<{ html: string; cached: boolean }> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { html: hit, cached: true };\n }\n\n const html = await fetchHtml(url, {\n config: this.config,\n limiter: this.limiter,\n logger: this.logger,\n ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),\n });\n this.cache.set(url, html);\n return { html, cached: false };\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-marmiton",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "MCP server for Marmiton. Search recipes, read ingredients and steps, and scale quantities to any number of servings. No API key required.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/server.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
- "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.smeet666/mcp-marmiton",
4
- "description": "Search Marmiton recipes, read ingredients and steps, and scale quantities to any number of servings. No API key.",
4
+ "description": "Search Marmiton recipes, read ingredients and steps, and scale quantities to any servings.",
5
5
  "repository": {
6
6
  "url": "https://github.com/smeet666/mcp-marmiton",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.2",
9
+ "version": "1.0.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "mcp-marmiton",
14
- "version": "1.0.2",
14
+ "version": "1.0.4",
15
15
  "transport": { "type": "stdio" },
16
16
  "environmentVariables": [
17
17
  {