mcp-marmiton 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.2.0";
7
+ var PKG_VERSION = "1.2.1";
8
8
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
9
9
 
10
10
  // src/config.ts
@@ -111,6 +111,7 @@ function createLogger(level) {
111
111
 
112
112
  // src/server.ts
113
113
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
114
+ import { z as z5 } from "zod";
114
115
 
115
116
  // src/marmiton/cache.ts
116
117
  var TtlLruCache = class {
@@ -1460,6 +1461,7 @@ var READ_ONLY = {
1460
1461
  idempotentHint: true,
1461
1462
  openWorldHint: true
1462
1463
  };
1464
+ var OFFLINE = { ...READ_ONLY, openWorldHint: false };
1463
1465
  function createServer(options = {}) {
1464
1466
  const config = options.config ?? loadConfig();
1465
1467
  const logger = options.logger ?? createLogger(config.logLevel);
@@ -1479,8 +1481,8 @@ function createServer(options = {}) {
1479
1481
  {
1480
1482
  title: "Search recipes",
1481
1483
  description: searchRecipesDescription,
1482
- inputSchema: searchRecipesInputShape,
1483
- outputSchema: searchRecipesOutputShape,
1484
+ inputSchema: z5.object(searchRecipesInputShape),
1485
+ outputSchema: z5.object(searchRecipesOutputShape),
1484
1486
  annotations: READ_ONLY
1485
1487
  },
1486
1488
  async (args) => runSearchRecipes(client, args)
@@ -1490,8 +1492,8 @@ function createServer(options = {}) {
1490
1492
  {
1491
1493
  title: "Get a recipe",
1492
1494
  description: getRecipeDescription,
1493
- inputSchema: getRecipeInputShape,
1494
- outputSchema: getRecipeOutputShape,
1495
+ inputSchema: z5.object(getRecipeInputShape),
1496
+ outputSchema: z5.object(getRecipeOutputShape),
1495
1497
  annotations: READ_ONLY
1496
1498
  },
1497
1499
  async (args) => runGetRecipe(client, args)
@@ -1501,9 +1503,9 @@ function createServer(options = {}) {
1501
1503
  {
1502
1504
  title: "Scale an ingredient list",
1503
1505
  description: scaleIngredientsDescription,
1504
- inputSchema: scaleIngredientsInputShape,
1505
- outputSchema: scaleIngredientsOutputShape,
1506
- annotations: READ_ONLY
1506
+ inputSchema: z5.object(scaleIngredientsInputShape),
1507
+ outputSchema: z5.object(scaleIngredientsOutputShape),
1508
+ annotations: OFFLINE
1507
1509
  },
1508
1510
  async (args) => runScaleIngredients(args)
1509
1511
  );
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.2.0\";\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<unknown>;\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<unknown>(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 return this.fetchParsed(url, (html) => parseSearchPage(html, url));\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n return this.fetchParsed(url, (html) => parseRecipePage(html, { id, url }));\n }\n\n /**\n * Fetch, parse, then cache. In that order: a page that could not be read is\n * never stored, so a bad minute at Marmiton cannot be replayed from memory for\n * the rest of the cache lifetime, leaving the tool unable to recover after\n * the site comes back.\n *\n * The cached value is the parsed result rather than the raw page, which also\n * keeps a few hundred kilobytes of markup per entry out of memory.\n */\n private async fetchParsed<T>(url: string, parse: (html: string) => T): Promise<Outcome<T>> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { data: hit as T, 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\n const data = parse(html);\n this.cache.set(url, data);\n return { data, 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 // Lines that were actually moved, rather than lines that merely belong to\n // the roundable category: doubling a recipe lands every egg whole.\n const roundedCount = ingredients.filter(\n (entry) => entry.scaling === \"rounded\" && entry.adjusted,\n ).length;\n if (roundedCount > 0) {\n notes.push(\n `${roundedCount} quantity(ies) were rounded to remain usable; see the 'scaling' field.`,\n );\n }\n const clampedCount = ingredients.filter((entry) =>\n /clamped up/i.test(entry.note ?? \"\"),\n ).length;\n if (clampedCount > 0) {\n notes.push(\n `${clampedCount} quantity(ies) fell below the smallest amount worth measuring and were ` +\n \"clamped up, so their proportions no longer match the published recipe.\",\n );\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 // The flag answers \"why is this line unchanged\", which is only a question\n // when a rescaling was asked for. Without 'servings' it would read as a\n // statement about the ingredient itself.\n const rescaled = args.servings !== undefined && factor !== null;\n const ingredientLines = ingredients\n .map((entry) => {\n const flag = rescaled && entry.scaling === \"unscaled\" ? \" (non ajusté)\" : \"\";\n return `- ${entry.text}${flag}`;\n })\n .join(\"\\n\");\n\n const stepLines = data.steps.map((step, index) => `${index + 1}. ${step}`).join(\"\\n\");\n const body = [\n header,\n `Ingrédients:\\n${ingredientLines}`,\n stepLines ? `Préparation:\\n${stepLines}` : \"\",\n ]\n .filter(Boolean)\n .join(\"\\n\\n\");\n\n return ok(structured, body, notes);\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 /** A metric symbol such as \"g\" or \"cl\", which never takes a plural mark. */\n symbol?: true;\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\", symbol: true },\n gr: { canonical: \"g\", kind: \"measured\", symbol: true },\n gramme: { canonical: \"g\", kind: \"measured\", symbol: true },\n grammes: { canonical: \"g\", kind: \"measured\", symbol: true },\n kg: { canonical: \"kg\", kind: \"measured\", symbol: true },\n kilo: { canonical: \"kg\", kind: \"measured\", symbol: true },\n kilos: { canonical: \"kg\", kind: \"measured\", symbol: true },\n kilogramme: { canonical: \"kg\", kind: \"measured\", symbol: true },\n mg: { canonical: \"mg\", kind: \"measured\", symbol: true },\n\n // Volume\n ml: { canonical: \"ml\", kind: \"measured\", symbol: true },\n cl: { canonical: \"cl\", kind: \"measured\", symbol: true },\n dl: { canonical: \"dl\", kind: \"measured\", symbol: true },\n l: { canonical: \"l\", kind: \"measured\", symbol: true },\n litre: { canonical: \"l\", kind: \"measured\", symbol: true },\n litres: { canonical: \"l\", kind: \"measured\", symbol: true },\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\n/**\n * The unit one step down the metric ladder, with how many of it fit in one of\n * the current unit. Null at the bottom of a ladder, where there is nothing\n * smaller to express the amount in.\n */\nexport function demoteUnit(unit: UnitInfo): { unit: UnitInfo; per: number } | null {\n const step = DEMOTIONS[unit.canonical];\n if (!step) return null;\n const target = lookupUnit(step.to);\n return target ? { unit: target, per: step.per } : null;\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 (unit.symbol) return unit.canonical;\n if (amount < 2) return unit.canonical;\n if (unit.plural) return unit.plural;\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 /**\n * Upper bound when the line gives a range, as in \"2 à 3 gousses\". Null for a\n * single amount. `amount` holds the lower bound, so the two must be scaled\n * together: multiplying only one turns \"2 à 3\" into the nonsense \"4 à 3\".\n */\n amountMax: number | null;\n /** The word or sign the range was written with: \"à\", \"-\", \"ou\". */\n rangeSeparator: string | 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 range = parseLeadingRange(text);\n const quantity = range ?? parseLeadingQuantity(text);\n if (!quantity) {\n return {\n original,\n amount: null,\n amountMax: null,\n rangeSeparator: null,\n unit: null,\n unitText: null,\n item: text,\n };\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 {\n original,\n amount: quantity.amount,\n amountMax: range?.max ?? null,\n rangeSeparator: range?.separator ?? null,\n unit,\n unitText,\n item,\n };\n}\n\nexport interface ParsedRange extends ParsedQuantity {\n /** Upper bound. `amount` carries the lower one. */\n max: number;\n /** How the range was written, so the rewrite can keep the same shape. */\n separator: string;\n}\n\n/**\n * Read a leading range such as \"2 à 3\", \"2-3\" or \"3 ou 4\".\n *\n * Recipes use ranges where the exact amount is the cook's call, and both bounds\n * describe the same quantity. Reading only the first one is worse than reading\n * neither: the second number survives unscaled into the answer and contradicts\n * it.\n *\n * A descending pair is not a range. \"1/2 3\" is two amounts the parser has no\n * business joining, and a dash between two numbers is a range only when the\n * second is the larger.\n */\nexport function parseLeadingRange(text: string): ParsedRange | null {\n const low = parseLeadingQuantity(text);\n if (!low) return null;\n\n const after = text.slice(low.length);\n const separator = /^\\s*(à|a|ou|-|–|—|\\/)\\s*/.exec(after);\n if (!separator) return null;\n // A slash between two numbers is a fraction, which parseLeadingQuantity has\n // already consumed if it was one.\n if (separator[1] === \"/\") return null;\n\n const high = parseLeadingQuantity(after.slice(separator[0].length));\n if (!high || high.amount <= low.amount) return null;\n\n return {\n amount: low.amount,\n max: high.amount,\n separator: separator[1]!,\n length: low.length + separator[0].length + high.length,\n };\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 type { UnitInfo } from \"./units.js\";\nimport { convertToReadableUnit, demoteUnit, 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 /**\n * Whether rounding moved the value away from the exact product.\n *\n * Read this rather than `scaling` to know if a number was touched: a line can\n * be classified `rounded` and still land on the exact result, as three eggs\n * doubled land on six.\n */\n adjusted: boolean;\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/** Below this there is nothing a kitchen can measure out of a countable thing. */\nconst SMALLEST_USABLE_FRACTION = 0.25;\n\ninterface CountableResult {\n value: number;\n /** The floor was hit, so this line no longer holds its share of the recipe. */\n clamped: boolean;\n}\n\n/**\n * Round a countable amount to something a cook can act on.\n *\n * Below one whole unit the amount snaps to a fraction a kitchen can measure: a\n * raw 0,15 of an egg is no more usable than zero. One whole is among the\n * candidates, so an egg shrunk to 0,9 comes back as one egg rather than three\n * quarters of one, and the ceiling stops that from ever asking for more than\n * the recipe started with.\n *\n * A quarter is the floor. Under it the amount is clamped up rather than shrunk\n * towards nothing, which keeps the ingredient in the recipe at the cost of its\n * proportion, and the caller is told through `clamped`.\n */\nfunction roundCountable(value: number, allowHalves: boolean, ceiling: number): CountableResult {\n if (value <= 0) return { value: 0, clamped: false };\n\n if (value < 1) {\n const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(\n (candidate) => candidate <= Math.max(ceiling, SMALLEST_USABLE_FRACTION),\n );\n let closest = candidates[0]!;\n for (const candidate of candidates) {\n if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;\n }\n return {\n value: Math.round(closest * 100) / 100,\n clamped: value < SMALLEST_USABLE_FRACTION,\n };\n }\n\n return { value: allowHalves ? roundTo(value, 0.5) : Math.round(value), clamped: false };\n}\n\n/**\n * Round a measured amount in the smallest unit that still holds it whole.\n *\n * Rounding before converting throws away the precision the conversion was going\n * to need: a kilo divided by a thousand rounds to zero kilos, stating that the\n * recipe needs none of it, and a quarter of a millilitre rounded in centilitres\n * first comes back three tenths too large. Walking down the ladder first means\n * every rounding happens on a number big enough to survive it.\n */\nfunction roundMeasuredInUsableUnit(\n unit: UnitInfo,\n raw: number,\n): { amount: number; unit: UnitInfo } {\n let current = unit;\n let value = raw;\n\n while (value > 0 && value < 1) {\n const step = demoteUnit(current);\n if (!step) break;\n value *= step.per;\n current = step.unit;\n }\n\n const rounded = roundMeasured(value);\n // At the bottom of the ladder, keep what precision is left rather than\n // deleting the ingredient.\n return {\n amount: rounded === 0 && value > 0 ? Number(value.toPrecision(2)) : rounded,\n unit: current,\n };\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 */\n/**\n * \"de\" becomes \"d'\" before a vowel sound.\n *\n * The h is the hard case: it is silent in \"huile\" and sounded in \"haricot\", and\n * only a word list separates them. Elision is therefore limited to vowels plus\n * the handful of h-words a recipe actually uses, because \"de haricots\" merely\n * reads as careless while \"d'haricots\" reads as wrong.\n */\nconst MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\\b/i;\n\nfunction joinItem(item: string): string {\n if (!item) return \"\";\n const elides = /^[aeiouàâäéèêëîïôöûü]/i.test(item) || MUTE_H_WORDS.test(item);\n return elides ? ` d'${item}` : ` de ${item}`;\n}\n\n/**\n * Scale one ingredient line.\n *\n * Countable items are rounded to something a kitchen can measure, ranges are\n * scaled at both ends, and vague measures such as pinches are returned\n * 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 adjusted: false,\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 adjusted: false,\n note: \"Measure is approximate by nature; adjust to taste.\",\n };\n }\n\n /** Scale one bound, keeping it at or below what the recipe already asked for. */\n const scaleBound = (source: number) => {\n const raw = source * factor;\n\n if (kind === \"measured\") {\n const converted = roundMeasuredInUsableUnit(parsed.unit!, raw);\n const readable = convertToReadableUnit(converted.unit, converted.amount);\n return { amount: readable.amount, unit: readable.unit, raw, clamped: false };\n }\n\n // Spoons tolerate halves; boxes, sachets and eggs do not.\n const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? \"\");\n // Scaling down must never end up asking for more than the original.\n const ceiling = factor < 1 ? source : Number.POSITIVE_INFINITY;\n const rounded = roundCountable(raw, allowHalves, ceiling);\n return { amount: rounded.value, unit: parsed.unit, raw, clamped: rounded.clamped };\n };\n\n const low = scaleBound(parsed.amount);\n const high = parsed.amountMax === null ? null : scaleBound(parsed.amountMax);\n\n // A range is reported by its upper bound: that is what a cook buys, and half\n // a range in `amount` would be read as the whole answer.\n const amount = high?.amount ?? low.amount;\n const unit = high?.unit ?? low.unit;\n const scaling: ScalingKind = kind === \"measured\" ? \"scaled\" : \"rounded\";\n\n const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : \"\";\n // A counted item agrees with its number: \"1/3 oeuf\", \"3 brioches\".\n const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);\n const item = unit ? joinItem(parsed.item) : itemText ? ` ${itemText}` : \"\";\n // Mass and volume read as decimals; counted and spooned things read as\n // fractions.\n const asText = (value: number) => formatAmount(value, { fractions: kind !== \"measured\" });\n const amountText =\n high === null\n ? asText(low.amount)\n : parsed.rangeSeparator === \"-\" ||\n parsed.rangeSeparator === \"–\" ||\n parsed.rangeSeparator === \"—\"\n ? `${asText(low.amount)}${parsed.rangeSeparator}${asText(high.amount)}`\n : `${asText(low.amount)} ${parsed.rangeSeparator} ${asText(high.amount)}`;\n const text = `${amountText}${unitLabel}${item}`.trim();\n\n const bounds = high === null ? [low] : [low, high];\n const adjusted = bounds.some((bound) => Math.abs(bound.raw - bound.amount) > 0.01);\n\n const result: ScaledIngredient = {\n original: parsed.original,\n text,\n amount,\n unit: unit?.canonical ?? null,\n scaling,\n adjusted,\n };\n\n if (bounds.some((bound) => bound.clamped)) {\n result.note =\n `Clamped up to ${formatAmount(SMALLEST_USABLE_FRACTION)} from ` +\n `${formatAmount(Math.round(low.raw * 1000) / 1000)}, the smallest amount worth measuring. ` +\n \"This line no longer holds its share of the recipe.\";\n } else if (scaling === \"rounded\" && adjusted) {\n result.note = `Rounded from ${formatAmount(Math.round(low.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 adjusted: false,\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 adjusted: z\n .boolean()\n .describe(\n \"True when rounding moved the value away from the exact product. A line can be 'rounded' and \" +\n \"still land exactly, as three eggs doubled land on six.\",\n ),\n note: z.string().optional().describe(\"Why the line was rounded, clamped 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\n/**\n * Build a result whose text block ends with the notes.\n *\n * The notes are what qualifies the answer: that a quantity was clamped and no\n * longer holds its share, that the nutrition figures describe the recipe as\n * published rather than the amounts printed above them, that two ways of asking\n * for the same thing disagreed. A client rendering only the text read the\n * unqualified answer without them.\n *\n * They are appended after the body is trimmed, so they survive a long\n * ingredient list.\n */\nexport function ok(\n structured: Record<string, unknown>,\n text: string,\n notes: string[] = [],\n): ToolResult {\n const trailer = notes.map((note) => `Note: ${note}`).join(\"\\n\");\n const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);\n const body = truncate(text, Math.max(0, budget));\n\n return {\n content: [{ type: \"text\", text: trailer ? `${body}\\n\\n${trailer}` : body }],\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\n .number()\n .int()\n .describe(\n \"Lines whose value rounding moved away from the exact product, not lines that could have been rounded.\",\n ),\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\n/**\n * Print a factor without rounding it out of existence.\n *\n * Two decimals turn 0.001 into \"0\", which states that nothing was applied while\n * every quantity in the list was divided by a thousand.\n */\nfunction formatFactor(factor: number): string {\n return String(Number(factor.toPrecision(3)));\n}\n\nexport function runScaleIngredients(args: ScaleIngredientsArgs): ToolResult {\n try {\n let factor: number;\n const notes: string[] = [];\n\n if (args.factor !== undefined) {\n factor = args.factor;\n if (args.from_servings !== undefined || args.to_servings !== undefined) {\n notes.push(\n \"'factor' was given alongside 'from_servings'/'to_servings'. 'factor' was applied and the \" +\n \"pair ignored; send only one of the two to remove the ambiguity.\",\n );\n }\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 // Lines that were actually moved, rather than lines that merely belong to\n // the roundable category: at a factor of 100 every egg lands whole.\n rounded: ingredients.filter((entry) => entry.scaling === \"rounded\" && entry.adjusted).length,\n unscaled: ingredients.filter((entry) => entry.scaling === \"unscaled\").length,\n clamped: ingredients.filter((entry) => /clamped up/i.test(entry.note ?? \"\")).length,\n };\n\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 if (counts.clamped > 0) {\n notes.push(\n `${counts.clamped} quantity(ies) fell below the smallest amount worth measuring and were clamped up, ` +\n \"so their proportions no longer match the original recipe.\",\n );\n }\n\n const structured = {\n factor: Number(factor.toPrecision(3)),\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 ${formatFactor(factor)}:\\n${lines}`, notes);\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.2.1\";\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 { z } from \"zod\";\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\n/**\n * scale_ingredients does arithmetic on the arguments it was handed and contacts\n * nothing, so its world is closed: the same list and the same factor give the\n * same answer forever, whatever Marmiton publishes.\n */\nconst OFFLINE = { ...READ_ONLY, openWorldHint: false } 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: z.object(searchRecipesInputShape),\n outputSchema: z.object(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: z.object(getRecipeInputShape),\n outputSchema: z.object(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: z.object(scaleIngredientsInputShape),\n outputSchema: z.object(scaleIngredientsOutputShape),\n annotations: OFFLINE,\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<unknown>;\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<unknown>(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 return this.fetchParsed(url, (html) => parseSearchPage(html, url));\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n return this.fetchParsed(url, (html) => parseRecipePage(html, { id, url }));\n }\n\n /**\n * Fetch, parse, then cache. In that order: a page that could not be read is\n * never stored, so a bad minute at Marmiton cannot be replayed from memory for\n * the rest of the cache lifetime, leaving the tool unable to recover after\n * the site comes back.\n *\n * The cached value is the parsed result rather than the raw page, which also\n * keeps a few hundred kilobytes of markup per entry out of memory.\n */\n private async fetchParsed<T>(url: string, parse: (html: string) => T): Promise<Outcome<T>> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { data: hit as T, 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\n const data = parse(html);\n this.cache.set(url, data);\n return { data, 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 // Lines that were actually moved, rather than lines that merely belong to\n // the roundable category: doubling a recipe lands every egg whole.\n const roundedCount = ingredients.filter(\n (entry) => entry.scaling === \"rounded\" && entry.adjusted,\n ).length;\n if (roundedCount > 0) {\n notes.push(\n `${roundedCount} quantity(ies) were rounded to remain usable; see the 'scaling' field.`,\n );\n }\n const clampedCount = ingredients.filter((entry) =>\n /clamped up/i.test(entry.note ?? \"\"),\n ).length;\n if (clampedCount > 0) {\n notes.push(\n `${clampedCount} quantity(ies) fell below the smallest amount worth measuring and were ` +\n \"clamped up, so their proportions no longer match the published recipe.\",\n );\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 // The flag answers \"why is this line unchanged\", which is only a question\n // when a rescaling was asked for. Without 'servings' it would read as a\n // statement about the ingredient itself.\n const rescaled = args.servings !== undefined && factor !== null;\n const ingredientLines = ingredients\n .map((entry) => {\n const flag = rescaled && entry.scaling === \"unscaled\" ? \" (non ajusté)\" : \"\";\n return `- ${entry.text}${flag}`;\n })\n .join(\"\\n\");\n\n const stepLines = data.steps.map((step, index) => `${index + 1}. ${step}`).join(\"\\n\");\n const body = [\n header,\n `Ingrédients:\\n${ingredientLines}`,\n stepLines ? `Préparation:\\n${stepLines}` : \"\",\n ]\n .filter(Boolean)\n .join(\"\\n\\n\");\n\n return ok(structured, body, notes);\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 /** A metric symbol such as \"g\" or \"cl\", which never takes a plural mark. */\n symbol?: true;\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\", symbol: true },\n gr: { canonical: \"g\", kind: \"measured\", symbol: true },\n gramme: { canonical: \"g\", kind: \"measured\", symbol: true },\n grammes: { canonical: \"g\", kind: \"measured\", symbol: true },\n kg: { canonical: \"kg\", kind: \"measured\", symbol: true },\n kilo: { canonical: \"kg\", kind: \"measured\", symbol: true },\n kilos: { canonical: \"kg\", kind: \"measured\", symbol: true },\n kilogramme: { canonical: \"kg\", kind: \"measured\", symbol: true },\n mg: { canonical: \"mg\", kind: \"measured\", symbol: true },\n\n // Volume\n ml: { canonical: \"ml\", kind: \"measured\", symbol: true },\n cl: { canonical: \"cl\", kind: \"measured\", symbol: true },\n dl: { canonical: \"dl\", kind: \"measured\", symbol: true },\n l: { canonical: \"l\", kind: \"measured\", symbol: true },\n litre: { canonical: \"l\", kind: \"measured\", symbol: true },\n litres: { canonical: \"l\", kind: \"measured\", symbol: true },\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\n/**\n * The unit one step down the metric ladder, with how many of it fit in one of\n * the current unit. Null at the bottom of a ladder, where there is nothing\n * smaller to express the amount in.\n */\nexport function demoteUnit(unit: UnitInfo): { unit: UnitInfo; per: number } | null {\n const step = DEMOTIONS[unit.canonical];\n if (!step) return null;\n const target = lookupUnit(step.to);\n return target ? { unit: target, per: step.per } : null;\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 (unit.symbol) return unit.canonical;\n if (amount < 2) return unit.canonical;\n if (unit.plural) return unit.plural;\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 /**\n * Upper bound when the line gives a range, as in \"2 à 3 gousses\". Null for a\n * single amount. `amount` holds the lower bound, so the two must be scaled\n * together: multiplying only one turns \"2 à 3\" into the nonsense \"4 à 3\".\n */\n amountMax: number | null;\n /** The word or sign the range was written with: \"à\", \"-\", \"ou\". */\n rangeSeparator: string | 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 range = parseLeadingRange(text);\n const quantity = range ?? parseLeadingQuantity(text);\n if (!quantity) {\n return {\n original,\n amount: null,\n amountMax: null,\n rangeSeparator: null,\n unit: null,\n unitText: null,\n item: text,\n };\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 {\n original,\n amount: quantity.amount,\n amountMax: range?.max ?? null,\n rangeSeparator: range?.separator ?? null,\n unit,\n unitText,\n item,\n };\n}\n\nexport interface ParsedRange extends ParsedQuantity {\n /** Upper bound. `amount` carries the lower one. */\n max: number;\n /** How the range was written, so the rewrite can keep the same shape. */\n separator: string;\n}\n\n/**\n * Read a leading range such as \"2 à 3\", \"2-3\" or \"3 ou 4\".\n *\n * Recipes use ranges where the exact amount is the cook's call, and both bounds\n * describe the same quantity. Reading only the first one is worse than reading\n * neither: the second number survives unscaled into the answer and contradicts\n * it.\n *\n * A descending pair is not a range. \"1/2 3\" is two amounts the parser has no\n * business joining, and a dash between two numbers is a range only when the\n * second is the larger.\n */\nexport function parseLeadingRange(text: string): ParsedRange | null {\n const low = parseLeadingQuantity(text);\n if (!low) return null;\n\n const after = text.slice(low.length);\n const separator = /^\\s*(à|a|ou|-|–|—|\\/)\\s*/.exec(after);\n if (!separator) return null;\n // A slash between two numbers is a fraction, which parseLeadingQuantity has\n // already consumed if it was one.\n if (separator[1] === \"/\") return null;\n\n const high = parseLeadingQuantity(after.slice(separator[0].length));\n if (!high || high.amount <= low.amount) return null;\n\n return {\n amount: low.amount,\n max: high.amount,\n separator: separator[1]!,\n length: low.length + separator[0].length + high.length,\n };\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 type { UnitInfo } from \"./units.js\";\nimport { convertToReadableUnit, demoteUnit, 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 /**\n * Whether rounding moved the value away from the exact product.\n *\n * Read this rather than `scaling` to know if a number was touched: a line can\n * be classified `rounded` and still land on the exact result, as three eggs\n * doubled land on six.\n */\n adjusted: boolean;\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/** Below this there is nothing a kitchen can measure out of a countable thing. */\nconst SMALLEST_USABLE_FRACTION = 0.25;\n\ninterface CountableResult {\n value: number;\n /** The floor was hit, so this line no longer holds its share of the recipe. */\n clamped: boolean;\n}\n\n/**\n * Round a countable amount to something a cook can act on.\n *\n * Below one whole unit the amount snaps to a fraction a kitchen can measure: a\n * raw 0,15 of an egg is no more usable than zero. One whole is among the\n * candidates, so an egg shrunk to 0,9 comes back as one egg rather than three\n * quarters of one, and the ceiling stops that from ever asking for more than\n * the recipe started with.\n *\n * A quarter is the floor. Under it the amount is clamped up rather than shrunk\n * towards nothing, which keeps the ingredient in the recipe at the cost of its\n * proportion, and the caller is told through `clamped`.\n */\nfunction roundCountable(value: number, allowHalves: boolean, ceiling: number): CountableResult {\n if (value <= 0) return { value: 0, clamped: false };\n\n if (value < 1) {\n const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(\n (candidate) => candidate <= Math.max(ceiling, SMALLEST_USABLE_FRACTION),\n );\n let closest = candidates[0]!;\n for (const candidate of candidates) {\n if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;\n }\n return {\n value: Math.round(closest * 100) / 100,\n clamped: value < SMALLEST_USABLE_FRACTION,\n };\n }\n\n return { value: allowHalves ? roundTo(value, 0.5) : Math.round(value), clamped: false };\n}\n\n/**\n * Round a measured amount in the smallest unit that still holds it whole.\n *\n * Rounding before converting throws away the precision the conversion was going\n * to need: a kilo divided by a thousand rounds to zero kilos, stating that the\n * recipe needs none of it, and a quarter of a millilitre rounded in centilitres\n * first comes back three tenths too large. Walking down the ladder first means\n * every rounding happens on a number big enough to survive it.\n */\nfunction roundMeasuredInUsableUnit(\n unit: UnitInfo,\n raw: number,\n): { amount: number; unit: UnitInfo } {\n let current = unit;\n let value = raw;\n\n while (value > 0 && value < 1) {\n const step = demoteUnit(current);\n if (!step) break;\n value *= step.per;\n current = step.unit;\n }\n\n const rounded = roundMeasured(value);\n // At the bottom of the ladder, keep what precision is left rather than\n // deleting the ingredient.\n return {\n amount: rounded === 0 && value > 0 ? Number(value.toPrecision(2)) : rounded,\n unit: current,\n };\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 */\n/**\n * \"de\" becomes \"d'\" before a vowel sound.\n *\n * The h is the hard case: it is silent in \"huile\" and sounded in \"haricot\", and\n * only a word list separates them. Elision is therefore limited to vowels plus\n * the handful of h-words a recipe actually uses, because \"de haricots\" merely\n * reads as careless while \"d'haricots\" reads as wrong.\n */\nconst MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\\b/i;\n\nfunction joinItem(item: string): string {\n if (!item) return \"\";\n const elides = /^[aeiouàâäéèêëîïôöûü]/i.test(item) || MUTE_H_WORDS.test(item);\n return elides ? ` d'${item}` : ` de ${item}`;\n}\n\n/**\n * Scale one ingredient line.\n *\n * Countable items are rounded to something a kitchen can measure, ranges are\n * scaled at both ends, and vague measures such as pinches are returned\n * 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 adjusted: false,\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 adjusted: false,\n note: \"Measure is approximate by nature; adjust to taste.\",\n };\n }\n\n /** Scale one bound, keeping it at or below what the recipe already asked for. */\n const scaleBound = (source: number) => {\n const raw = source * factor;\n\n if (kind === \"measured\") {\n const converted = roundMeasuredInUsableUnit(parsed.unit!, raw);\n const readable = convertToReadableUnit(converted.unit, converted.amount);\n return { amount: readable.amount, unit: readable.unit, raw, clamped: false };\n }\n\n // Spoons tolerate halves; boxes, sachets and eggs do not.\n const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? \"\");\n // Scaling down must never end up asking for more than the original.\n const ceiling = factor < 1 ? source : Number.POSITIVE_INFINITY;\n const rounded = roundCountable(raw, allowHalves, ceiling);\n return { amount: rounded.value, unit: parsed.unit, raw, clamped: rounded.clamped };\n };\n\n const low = scaleBound(parsed.amount);\n const high = parsed.amountMax === null ? null : scaleBound(parsed.amountMax);\n\n // A range is reported by its upper bound: that is what a cook buys, and half\n // a range in `amount` would be read as the whole answer.\n const amount = high?.amount ?? low.amount;\n const unit = high?.unit ?? low.unit;\n const scaling: ScalingKind = kind === \"measured\" ? \"scaled\" : \"rounded\";\n\n const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : \"\";\n // A counted item agrees with its number: \"1/3 oeuf\", \"3 brioches\".\n const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);\n const item = unit ? joinItem(parsed.item) : itemText ? ` ${itemText}` : \"\";\n // Mass and volume read as decimals; counted and spooned things read as\n // fractions.\n const asText = (value: number) => formatAmount(value, { fractions: kind !== \"measured\" });\n const amountText =\n high === null\n ? asText(low.amount)\n : parsed.rangeSeparator === \"-\" ||\n parsed.rangeSeparator === \"–\" ||\n parsed.rangeSeparator === \"—\"\n ? `${asText(low.amount)}${parsed.rangeSeparator}${asText(high.amount)}`\n : `${asText(low.amount)} ${parsed.rangeSeparator} ${asText(high.amount)}`;\n const text = `${amountText}${unitLabel}${item}`.trim();\n\n const bounds = high === null ? [low] : [low, high];\n const adjusted = bounds.some((bound) => Math.abs(bound.raw - bound.amount) > 0.01);\n\n const result: ScaledIngredient = {\n original: parsed.original,\n text,\n amount,\n unit: unit?.canonical ?? null,\n scaling,\n adjusted,\n };\n\n if (bounds.some((bound) => bound.clamped)) {\n result.note =\n `Clamped up to ${formatAmount(SMALLEST_USABLE_FRACTION)} from ` +\n `${formatAmount(Math.round(low.raw * 1000) / 1000)}, the smallest amount worth measuring. ` +\n \"This line no longer holds its share of the recipe.\";\n } else if (scaling === \"rounded\" && adjusted) {\n result.note = `Rounded from ${formatAmount(Math.round(low.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 adjusted: false,\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 adjusted: z\n .boolean()\n .describe(\n \"True when rounding moved the value away from the exact product. A line can be 'rounded' and \" +\n \"still land exactly, as three eggs doubled land on six.\",\n ),\n note: z.string().optional().describe(\"Why the line was rounded, clamped 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\n/**\n * Build a result whose text block ends with the notes.\n *\n * The notes are what qualifies the answer: that a quantity was clamped and no\n * longer holds its share, that the nutrition figures describe the recipe as\n * published rather than the amounts printed above them, that two ways of asking\n * for the same thing disagreed. A client rendering only the text read the\n * unqualified answer without them.\n *\n * They are appended after the body is trimmed, so they survive a long\n * ingredient list.\n */\nexport function ok(\n structured: Record<string, unknown>,\n text: string,\n notes: string[] = [],\n): ToolResult {\n const trailer = notes.map((note) => `Note: ${note}`).join(\"\\n\");\n const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);\n const body = truncate(text, Math.max(0, budget));\n\n return {\n content: [{ type: \"text\", text: trailer ? `${body}\\n\\n${trailer}` : body }],\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\n .number()\n .int()\n .describe(\n \"Lines whose value rounding moved away from the exact product, not lines that could have been rounded.\",\n ),\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\n/**\n * Print a factor without rounding it out of existence.\n *\n * Two decimals turn 0.001 into \"0\", which states that nothing was applied while\n * every quantity in the list was divided by a thousand.\n */\nfunction formatFactor(factor: number): string {\n return String(Number(factor.toPrecision(3)));\n}\n\nexport function runScaleIngredients(args: ScaleIngredientsArgs): ToolResult {\n try {\n let factor: number;\n const notes: string[] = [];\n\n if (args.factor !== undefined) {\n factor = args.factor;\n if (args.from_servings !== undefined || args.to_servings !== undefined) {\n notes.push(\n \"'factor' was given alongside 'from_servings'/'to_servings'. 'factor' was applied and the \" +\n \"pair ignored; send only one of the two to remove the ambiguity.\",\n );\n }\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 // Lines that were actually moved, rather than lines that merely belong to\n // the roundable category: at a factor of 100 every egg lands whole.\n rounded: ingredients.filter((entry) => entry.scaling === \"rounded\" && entry.adjusted).length,\n unscaled: ingredients.filter((entry) => entry.scaling === \"unscaled\").length,\n clamped: ingredients.filter((entry) => /clamped up/i.test(entry.note ?? \"\")).length,\n };\n\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 if (counts.clamped > 0) {\n notes.push(\n `${counts.clamped} quantity(ies) fell below the smallest amount worth measuring and were clamped up, ` +\n \"so their proportions no longer match the original recipe.\",\n );\n }\n\n const structured = {\n factor: Number(factor.toPrecision(3)),\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 ${formatFactor(factor)}:\\n${lines}`, notes);\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","z","z"]}
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var PKG_VERSION = "1.2.0";
2
+ var PKG_VERSION = "1.2.1";
3
3
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
4
4
 
5
5
  // src/config.ts
@@ -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.2.0\";\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<unknown>;\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<unknown>(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 return this.fetchParsed(url, (html) => parseSearchPage(html, url));\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n return this.fetchParsed(url, (html) => parseRecipePage(html, { id, url }));\n }\n\n /**\n * Fetch, parse, then cache. In that order: a page that could not be read is\n * never stored, so a bad minute at Marmiton cannot be replayed from memory for\n * the rest of the cache lifetime, leaving the tool unable to recover after\n * the site comes back.\n *\n * The cached value is the parsed result rather than the raw page, which also\n * keeps a few hundred kilobytes of markup per entry out of memory.\n */\n private async fetchParsed<T>(url: string, parse: (html: string) => T): Promise<Outcome<T>> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { data: hit as T, 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\n const data = parse(html);\n this.cache.set(url, data);\n return { data, 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.2.1\";\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<unknown>;\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<unknown>(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 return this.fetchParsed(url, (html) => parseSearchPage(html, url));\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n return this.fetchParsed(url, (html) => parseRecipePage(html, { id, url }));\n }\n\n /**\n * Fetch, parse, then cache. In that order: a page that could not be read is\n * never stored, so a bad minute at Marmiton cannot be replayed from memory for\n * the rest of the cache lifetime, leaving the tool unable to recover after\n * the site comes back.\n *\n * The cached value is the parsed result rather than the raw page, which also\n * keeps a few hundred kilobytes of markup per entry out of memory.\n */\n private async fetchParsed<T>(url: string, parse: (html: string) => T): Promise<Outcome<T>> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { data: hit as T, 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\n const data = parse(html);\n this.cache.set(url, data);\n return { data, 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.2.0",
3
+ "version": "1.2.1",
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
@@ -2,17 +2,32 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.smeet666/mcp-marmiton",
4
4
  "description": "Search Marmiton recipes, read ingredients and steps, and scale quantities to any servings.",
5
+ "websiteUrl": "https://github.com/smeet666/mcp-marmiton#readme",
6
+ "icons": [
7
+ {
8
+ "src": "https://raw.githubusercontent.com/smeet666/mcp-marmiton/main/assets/icon-128.png",
9
+ "mimeType": "image/png",
10
+ "sizes": ["128x128"]
11
+ },
12
+ {
13
+ "src": "https://raw.githubusercontent.com/smeet666/mcp-marmiton/main/assets/icon-512.png",
14
+ "mimeType": "image/png",
15
+ "sizes": ["512x512"]
16
+ }
17
+ ],
5
18
  "repository": {
6
19
  "url": "https://github.com/smeet666/mcp-marmiton",
7
20
  "source": "github"
8
21
  },
9
- "version": "1.2.0",
22
+ "version": "1.2.1",
10
23
  "packages": [
11
24
  {
12
25
  "registryType": "npm",
13
26
  "identifier": "mcp-marmiton",
14
- "version": "1.2.0",
15
- "transport": { "type": "stdio" },
27
+ "version": "1.2.1",
28
+ "transport": {
29
+ "type": "stdio"
30
+ },
16
31
  "environmentVariables": [
17
32
  {
18
33
  "name": "MARMITON_USER_AGENT",