mcp-marmiton 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -130,4 +130,5 @@ declare class MarmitonClient {
130
130
  private fetchParsed;
131
131
  }
132
132
 
133
- export { MarmitonClient, type MarmitonClientOptions, type Outcome };
133
+ export { MarmitonClient };
134
+ export type { MarmitonClientOptions, Outcome };
@@ -2,7 +2,7 @@
2
2
  import process from "process";
3
3
 
4
4
  // src/version.ts
5
- var PKG_VERSION = "2.1.0";
5
+ var PKG_VERSION = "2.2.0";
6
6
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
7
7
 
8
8
  // src/config.ts
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/config.ts","../../src/version.ts","../../src/errors.ts","../../src/marmiton/cache.ts","../../src/marmiton/rateLimiter.ts","../../src/marmiton/http.ts","../../src/marmiton/parseRecipe.ts","../../src/recipe/duration.ts","../../src/marmiton/jsonld.ts","../../src/marmiton/urls.ts","../../src/marmiton/client.ts"],"sourcesContent":["/**\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 process from \"node:process\";\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 /** The largest page this reader holds, in bytes. */\n maxBodyBytes: 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 // Recipe pages run to tens of kilobytes. A body an order of magnitude past\n // that is a page this reader has no reading for, and finding that out by\n // holding it costs the caller the memory and the site the transfer.\n maxBodyBytes: 8_000_000,\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() === \"\") {\n return range.fallback;\n }\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() === \"\") {\n return DEFAULTS.minIntervalMs;\n }\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 maxBodyBytes: readNumber(\"MARMITON_MAX_BODY_BYTES\", env, {\n min: 100_000,\n max: 64_000_000,\n fallback: DEFAULTS.maxBodyBytes,\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 * 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 = \"2.1.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-marmiton\";\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\"\n | \"invalid_input\"\n | \"rate_limited\"\n | \"parse_failure\"\n | \"network_error\"\n | \"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 * 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) {\n return undefined;\n }\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) {\n return;\n }\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) {\n break;\n }\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 * 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) {\n return;\n }\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) {\n await sleep(remaining);\n }\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, parseFailure, rateLimited, upstreamError } from \"../errors.js\";\nimport { type RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst CLOSING_HTML_TAG = /<\\/html>/i;\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 * What one answer from the site amounts to.\n *\n * Three outcomes and no fourth: the page is usable, the address holds nothing\n * and never will, or the site is asking to be left alone for a while. Reading\n * this apart from the loop keeps the loop about attempts and waits, which is\n * the part where a mistake costs the site traffic it did not ask for.\n */\ntype Answer =\n | { kind: \"usable\" }\n | { kind: \"refused\"; error: MarmitonError }\n | {\n kind: \"again\";\n error: MarmitonError;\n waitMs: number | null;\n penalise: boolean;\n because: string;\n };\n\nfunction readAnswer(\n url: string,\n status: number,\n body: string,\n retryAfterMs: number | null,\n ownGuessMs: number,\n): Answer {\n if (status === 429 || status === 503 || status === 403) {\n return {\n kind: \"again\",\n error: rateLimited(url, retryAfterMs ?? ownGuessMs),\n waitMs: retryAfterMs,\n penalise: true,\n because: \"rate limited\",\n };\n }\n if (status === 404) {\n return { kind: \"refused\", error: notFound(url, \"that address\") };\n }\n if (status >= 500) {\n return {\n kind: \"again\",\n error: upstreamError(url, status),\n waitMs: null,\n penalise: false,\n because: `status ${status}`,\n };\n }\n if (status >= 400) {\n return { kind: \"refused\", error: upstreamError(url, status) };\n }\n\n const trimmed = body.trim();\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !CLOSING_HTML_TAG.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 return {\n kind: \"again\",\n error: rateLimited(url, ownGuessMs),\n waitMs: null,\n penalise: true,\n because: \"implausibly short body\",\n };\n }\n\n return { kind: \"usable\" };\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 */\n/**\n * The body, read in pieces and stopped at the size this reader holds.\n *\n * A deadline abandons a body that arrives slowly. One that arrives quickly and\n * large is never abandoned by it, and it lands in memory in one piece before\n * anything looks at it: a page of two hundred megabytes fits inside fifteen\n * seconds, and what it costs is the whole session rather than the one call.\n */\nasync function readBounded(response: Response, maxBytes: number, url: string): Promise<string> {\n const stream = response.body;\n // A response carrying no readable stream is read whole. Nothing arrives in\n // pieces to count, and the body still has to be waited for, so a caller\n // holding a deadline over this read keeps it.\n if (!stream) {\n return await response.text();\n }\n\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n const parts: string[] = [];\n let held = 0;\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) {\n break;\n }\n held += value.byteLength;\n if (held > maxBytes) {\n throw parseFailure(\n url,\n `the page carries more than the ${maxBytes} bytes this reads for one page`,\n );\n }\n parts.push(decoder.decode(value, { stream: true }));\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n\n parts.push(decoder.decode());\n return parts.join(\"\");\n}\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 await 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 readBounded(response, config.maxBodyBytes, url);\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n const verdict = readAnswer(url, status, body, retryAfterMs, backoffDelay(attempt));\n if (verdict.kind === \"refused\") {\n throw verdict.error;\n }\n if (verdict.kind === \"again\") {\n if (verdict.penalise) {\n limiter.penalize();\n logger.info(`${verdict.because} on ${url}, interval now ${limiter.currentIntervalMs}ms`);\n }\n // A server that says when to come back knows better than our own guess.\n askedWaitMs = verdict.waitMs;\n lastError = verdict.error;\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) {\n return null;\n }\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) {\n return Math.round(seconds * 1000);\n }\n const when = Date.parse(raw);\n if (Number.isNaN(when)) {\n return null;\n }\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, url: string): MarmitonError {\n if (error instanceof MarmitonError) {\n return error;\n }\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 * 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 process from \"node:process\";\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) {\n throw parseFailure(options.url, \"the Recipe node has no name\");\n }\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) {\n return null;\n }\n const record = value as Record<string, unknown>;\n const ratingValue = asNumber(record.ratingValue);\n if (ratingValue === null) {\n return null;\n }\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) {\n return null;\n }\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 * 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 NUMBER_AND_REST = /^(\\d+(?:[.,]\\d+)?)\\s*(.*)$/;\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\") {\n return null;\n }\n const match = ISO_DURATION.exec(value.trim().toUpperCase());\n if (!match) {\n return null;\n }\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) {\n return null;\n }\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) {\n return null;\n }\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n if (hours === 0) {\n return `${rest} min`;\n }\n if (rest === 0) {\n return `${hours} h`;\n }\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 * The yield as written, out of the three shapes schema.org allows for it: a\n * number, a string, or an array holding either.\n */\nfunction yieldText(value: unknown): string {\n if (typeof value === \"number\" && Number.isFinite(value)) {\n return String(value);\n }\n if (typeof value === \"string\") {\n return value.trim();\n }\n if (Array.isArray(value)) {\n return String(value[0] ?? \"\").trim();\n }\n return \"\";\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 const text = yieldText(value);\n if (!text) {\n return { count: null, unit: null, text: \"\" };\n }\n\n const match = NUMBER_AND_REST.exec(text);\n if (!match) {\n return { count: null, unit: null, text };\n }\n\n const [digits = \"\"] = match.slice(1);\n const count = Number(digits.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 for (let match = LD_JSON_BLOCK.exec(html); match !== null; match = LD_JSON_BLOCK.exec(html)) {\n const raw = match[1]?.trim();\n if (!raw) {\n continue;\n }\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)) {\n nodes.push(node);\n }\n }\n\n return nodes;\n}\n\nfunction flatten(value: unknown): unknown[] {\n if (Array.isArray(value)) {\n return value.flatMap(flatten);\n }\n if (typeof value !== \"object\" || value === null) {\n return [];\n }\n\n const node = value as Record<string, unknown>;\n const graph = node[\"@graph\"];\n if (graph !== undefined) {\n return flatten(graph);\n }\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) {\n return false;\n }\n const declared = (node as Record<string, unknown>)[\"@type\"];\n if (typeof declared === \"string\") {\n return declared === type;\n }\n if (Array.isArray(declared)) {\n return declared.includes(type);\n }\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 // The cast reads what the search found; putting it before the coalesce would\n // assert the node exists and leave the null branch unreachable.\n return found === undefined ? null : (found as Record<string, unknown>);\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\") {\n return value.trim() || null;\n }\n if (Array.isArray(value)) {\n for (const entry of value) {\n const found = asString(entry);\n if (found) {\n return found;\n }\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\") {\n return url.trim() || null;\n }\n const name = (value as Record<string, unknown>).name;\n if (typeof name === \"string\") {\n return name.trim() || null;\n }\n }\n return null;\n}\n\nexport function asNumber(value: unknown): number | null {\n if (typeof value === \"number\" && Number.isFinite(value)) {\n return value;\n }\n if (typeof value === \"string\") {\n const parsed = Number(value.replace(\",\", \".\"));\n if (Number.isFinite(parsed)) {\n return parsed;\n }\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\") {\n return [value.trim()].filter(Boolean);\n }\n if (!Array.isArray(value)) {\n return [];\n }\n\n const out: string[] = [];\n for (const entry of value) {\n if (typeof entry === \"string\") {\n const text = entry.trim();\n if (text) {\n out.push(text);\n }\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) {\n out.push(text);\n }\n }\n }\n return out;\n}\n","/** URL construction and validation for marmiton.org. */\n\nimport { invalidInput } from \"../errors.js\";\n\nconst ABSOLUTE_URL = /^https?:\\/\\//i;\nconst DIGITS_ONLY = /^\\d+$/;\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:\") {\n return false;\n }\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 (ABSOLUTE_URL.test(hrefOrUrl)) {\n if (!isMarmitonHost(hrefOrUrl)) {\n return null;\n }\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 (!DIGITS_ONLY.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 * 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 { MarmitonError } from \"../errors.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\n/** The names a User-Agent carries when it passes traffic off as a browser. */\nconst BROWSER_IDENTITY = /mozilla\\/|applewebkit|chrome\\/|safari\\/|gecko/i;\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 = BROWSER_IDENTITY.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 try {\n return await this.fetchParsed(url, (html) => parseSearchPage(html, url));\n } catch (error) {\n // Marmiton answers a search matching no recipe with a 404 on the results\n // page. That is the site stating an absence, and handing it back as a\n // failed read would let a caller say Marmiton could not be reached when\n // Marmiton answered.\n if (error instanceof MarmitonError && error.code === \"not_found\") {\n return { data: [], cached: false };\n }\n throw error;\n }\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n return await 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":["process","process"]}
1
+ {"version":3,"sources":["../../src/config.ts","../../src/version.ts","../../src/errors.ts","../../src/marmiton/cache.ts","../../src/marmiton/rateLimiter.ts","../../src/marmiton/http.ts","../../src/marmiton/parseRecipe.ts","../../src/recipe/duration.ts","../../src/marmiton/jsonld.ts","../../src/marmiton/urls.ts","../../src/marmiton/client.ts"],"sourcesContent":["/**\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 process from \"node:process\";\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 /** The largest page this reader holds, in bytes. */\n maxBodyBytes: 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 // Recipe pages run to tens of kilobytes. A body an order of magnitude past\n // that is a page this reader has no reading for, and finding that out by\n // holding it costs the caller the memory and the site the transfer.\n maxBodyBytes: 8_000_000,\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() === \"\") {\n return range.fallback;\n }\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() === \"\") {\n return DEFAULTS.minIntervalMs;\n }\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 maxBodyBytes: readNumber(\"MARMITON_MAX_BODY_BYTES\", env, {\n min: 100_000,\n max: 64_000_000,\n fallback: DEFAULTS.maxBodyBytes,\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 * 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 = \"2.2.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-marmiton\";\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\"\n | \"invalid_input\"\n | \"rate_limited\"\n | \"parse_failure\"\n | \"network_error\"\n | \"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 * 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) {\n return undefined;\n }\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) {\n return;\n }\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) {\n break;\n }\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 * 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) {\n return;\n }\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) {\n await sleep(remaining);\n }\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, parseFailure, rateLimited, upstreamError } from \"../errors.js\";\nimport { type RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst CLOSING_HTML_TAG = /<\\/html>/i;\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 * What one answer from the site amounts to.\n *\n * Three outcomes and no fourth: the page is usable, the address holds nothing\n * and never will, or the site is asking to be left alone for a while. Reading\n * this apart from the loop keeps the loop about attempts and waits, which is\n * the part where a mistake costs the site traffic it did not ask for.\n */\ntype Answer =\n | { kind: \"usable\" }\n | { kind: \"refused\"; error: MarmitonError }\n | {\n kind: \"again\";\n error: MarmitonError;\n waitMs: number | null;\n penalise: boolean;\n because: string;\n };\n\nfunction readAnswer(\n url: string,\n status: number,\n body: string,\n retryAfterMs: number | null,\n ownGuessMs: number,\n): Answer {\n if (status === 429 || status === 503 || status === 403) {\n return {\n kind: \"again\",\n error: rateLimited(url, retryAfterMs ?? ownGuessMs),\n waitMs: retryAfterMs,\n penalise: true,\n because: \"rate limited\",\n };\n }\n if (status === 404) {\n return { kind: \"refused\", error: notFound(url, \"that address\") };\n }\n if (status >= 500) {\n return {\n kind: \"again\",\n error: upstreamError(url, status),\n waitMs: null,\n penalise: false,\n because: `status ${status}`,\n };\n }\n if (status >= 400) {\n return { kind: \"refused\", error: upstreamError(url, status) };\n }\n\n const trimmed = body.trim();\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !CLOSING_HTML_TAG.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 return {\n kind: \"again\",\n error: rateLimited(url, ownGuessMs),\n waitMs: null,\n penalise: true,\n because: \"implausibly short body\",\n };\n }\n\n return { kind: \"usable\" };\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 */\n/**\n * The body, read in pieces and stopped at the size this reader holds.\n *\n * A deadline abandons a body that arrives slowly. One that arrives quickly and\n * large is never abandoned by it, and it lands in memory in one piece before\n * anything looks at it: a page of two hundred megabytes fits inside fifteen\n * seconds, and what it costs is the whole session rather than the one call.\n */\nasync function readBounded(response: Response, maxBytes: number, url: string): Promise<string> {\n const stream = response.body;\n // A response carrying no readable stream is read whole. Nothing arrives in\n // pieces to count, and the body still has to be waited for, so a caller\n // holding a deadline over this read keeps it.\n if (!stream) {\n return await response.text();\n }\n\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n const parts: string[] = [];\n let held = 0;\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) {\n break;\n }\n held += value.byteLength;\n if (held > maxBytes) {\n throw parseFailure(\n url,\n `the page carries more than the ${maxBytes} bytes this reads for one page`,\n );\n }\n parts.push(decoder.decode(value, { stream: true }));\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n\n parts.push(decoder.decode());\n return parts.join(\"\");\n}\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 await 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 readBounded(response, config.maxBodyBytes, url);\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n const verdict = readAnswer(url, status, body, retryAfterMs, backoffDelay(attempt));\n if (verdict.kind === \"refused\") {\n throw verdict.error;\n }\n if (verdict.kind === \"again\") {\n if (verdict.penalise) {\n limiter.penalize();\n logger.info(`${verdict.because} on ${url}, interval now ${limiter.currentIntervalMs}ms`);\n }\n // A server that says when to come back knows better than our own guess.\n askedWaitMs = verdict.waitMs;\n lastError = verdict.error;\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) {\n return null;\n }\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) {\n return Math.round(seconds * 1000);\n }\n const when = Date.parse(raw);\n if (Number.isNaN(when)) {\n return null;\n }\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, url: string): MarmitonError {\n if (error instanceof MarmitonError) {\n return error;\n }\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 * 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 process from \"node:process\";\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) {\n throw parseFailure(options.url, \"the Recipe node has no name\");\n }\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) {\n return null;\n }\n const record = value as Record<string, unknown>;\n const ratingValue = asNumber(record.ratingValue);\n if (ratingValue === null) {\n return null;\n }\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) {\n return null;\n }\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 * 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 NUMBER_AND_REST = /^(\\d+(?:[.,]\\d+)?)\\s*(.*)$/;\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\") {\n return null;\n }\n const match = ISO_DURATION.exec(value.trim().toUpperCase());\n if (!match) {\n return null;\n }\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) {\n return null;\n }\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) {\n return null;\n }\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n if (hours === 0) {\n return `${rest} min`;\n }\n if (rest === 0) {\n return `${hours} h`;\n }\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 * The yield as written, out of the three shapes schema.org allows for it: a\n * number, a string, or an array holding either.\n */\nfunction yieldText(value: unknown): string {\n if (typeof value === \"number\" && Number.isFinite(value)) {\n return String(value);\n }\n if (typeof value === \"string\") {\n return value.trim();\n }\n if (Array.isArray(value)) {\n return String(value[0] ?? \"\").trim();\n }\n return \"\";\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 const text = yieldText(value);\n if (!text) {\n return { count: null, unit: null, text: \"\" };\n }\n\n const match = NUMBER_AND_REST.exec(text);\n if (!match) {\n return { count: null, unit: null, text };\n }\n\n const [digits = \"\"] = match.slice(1);\n const count = Number(digits.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 for (let match = LD_JSON_BLOCK.exec(html); match !== null; match = LD_JSON_BLOCK.exec(html)) {\n const raw = match[1]?.trim();\n if (!raw) {\n continue;\n }\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)) {\n nodes.push(node);\n }\n }\n\n return nodes;\n}\n\nfunction flatten(value: unknown): unknown[] {\n if (Array.isArray(value)) {\n return value.flatMap(flatten);\n }\n if (typeof value !== \"object\" || value === null) {\n return [];\n }\n\n const node = value as Record<string, unknown>;\n const graph = node[\"@graph\"];\n if (graph !== undefined) {\n return flatten(graph);\n }\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) {\n return false;\n }\n const declared = (node as Record<string, unknown>)[\"@type\"];\n if (typeof declared === \"string\") {\n return declared === type;\n }\n if (Array.isArray(declared)) {\n return declared.includes(type);\n }\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 // The cast reads what the search found; putting it before the coalesce would\n // assert the node exists and leave the null branch unreachable.\n return found === undefined ? null : (found as Record<string, unknown>);\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\") {\n return value.trim() || null;\n }\n if (Array.isArray(value)) {\n for (const entry of value) {\n const found = asString(entry);\n if (found) {\n return found;\n }\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\") {\n return url.trim() || null;\n }\n const name = (value as Record<string, unknown>).name;\n if (typeof name === \"string\") {\n return name.trim() || null;\n }\n }\n return null;\n}\n\nexport function asNumber(value: unknown): number | null {\n if (typeof value === \"number\" && Number.isFinite(value)) {\n return value;\n }\n if (typeof value === \"string\") {\n const parsed = Number(value.replace(\",\", \".\"));\n if (Number.isFinite(parsed)) {\n return parsed;\n }\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\") {\n return [value.trim()].filter(Boolean);\n }\n if (!Array.isArray(value)) {\n return [];\n }\n\n const out: string[] = [];\n for (const entry of value) {\n if (typeof entry === \"string\") {\n const text = entry.trim();\n if (text) {\n out.push(text);\n }\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) {\n out.push(text);\n }\n }\n }\n return out;\n}\n","/** URL construction and validation for marmiton.org. */\n\nimport { invalidInput } from \"../errors.js\";\n\nconst ABSOLUTE_URL = /^https?:\\/\\//i;\nconst DIGITS_ONLY = /^\\d+$/;\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:\") {\n return false;\n }\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 (ABSOLUTE_URL.test(hrefOrUrl)) {\n if (!isMarmitonHost(hrefOrUrl)) {\n return null;\n }\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 (!DIGITS_ONLY.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 * 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 { MarmitonError } from \"../errors.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\n/** The names a User-Agent carries when it passes traffic off as a browser. */\nconst BROWSER_IDENTITY = /mozilla\\/|applewebkit|chrome\\/|safari\\/|gecko/i;\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 = BROWSER_IDENTITY.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 try {\n return await this.fetchParsed(url, (html) => parseSearchPage(html, url));\n } catch (error) {\n // Marmiton answers a search matching no recipe with a 404 on the results\n // page. That is the site stating an absence, and handing it back as a\n // failed read would let a caller say Marmiton could not be reached when\n // Marmiton answered.\n if (error instanceof MarmitonError && error.code === \"not_found\") {\n return { data: [], cached: false };\n }\n throw error;\n }\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n return await 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":["process","process"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-marmiton",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
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",
@@ -43,7 +43,7 @@
43
43
  "server.json"
44
44
  ],
45
45
  "engines": {
46
- "node": ">=24.21.0"
46
+ "node": ">=24.22.1"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "tsup",
@@ -80,16 +80,16 @@
80
80
  "no-api-key"
81
81
  ],
82
82
  "dependencies": {
83
- "@modelcontextprotocol/sdk": "^1.30.0",
84
- "zod": "^4.5.4"
83
+ "@modelcontextprotocol/sdk": "^1.32.2",
84
+ "zod": "^4.6.7"
85
85
  },
86
86
  "devDependencies": {
87
- "@biomejs/biome": "2.5.12",
88
- "@types/node": "24.13.3",
89
- "@vitest/coverage-v8": "4.1.11",
90
- "prettier": "3.9.6",
91
- "tsup": "8.5.1",
87
+ "@biomejs/biome": "2.6.3",
88
+ "@types/node": "24.19.2",
89
+ "@vitest/coverage-v8": "5.0.5",
90
+ "prettier": "3.9.10",
91
+ "tsup": "9.0.0",
92
92
  "typescript": "5.9.3",
93
- "vitest": "4.1.11"
93
+ "vitest": "5.0.5"
94
94
  }
95
95
  }
package/server.json CHANGED
@@ -19,12 +19,12 @@
19
19
  "url": "https://github.com/smeet666/mcp-marmiton",
20
20
  "source": "github"
21
21
  },
22
- "version": "2.1.0",
22
+ "version": "2.2.0",
23
23
  "packages": [
24
24
  {
25
25
  "registryType": "npm",
26
26
  "identifier": "mcp-marmiton",
27
- "version": "2.1.0",
27
+ "version": "2.2.0",
28
28
  "transport": {
29
29
  "type": "stdio"
30
30
  },
@@ -48,8 +48,8 @@
48
48
  },
49
49
  {
50
50
  "registryType": "mcpb",
51
- "identifier": "https://github.com/smeet666/mcp-marmiton/releases/download/v2.1.0/mcp-marmiton-2.1.0.mcpb",
52
- "version": "2.1.0",
51
+ "identifier": "https://github.com/smeet666/mcp-marmiton/releases/download/v2.2.0/mcp-marmiton-2.2.0.mcpb",
52
+ "version": "2.2.0",
53
53
  "transport": {
54
54
  "type": "stdio"
55
55
  }