mcp-marmiton 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +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 = \"0.1.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 song inside one conversation do not hit lyrics.com again, not to\n * build a local copy of the site.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) return;\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) break;\n this.store.delete(oldest.value);\n }\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Error taxonomy surfaced to the calling model.\n *\n * The rule carried over from the sibling projects: a failure must never be\n * reported as an empty result. A model that sees \"no recipe found\" cannot tell\n * that apart from a genuine absence, and will confidently tell the user the\n * recipe does not exist.\n */\n\nexport type ErrorCode =\n \"not_found\" | \"invalid_input\" | \"rate_limited\" | \"parse_failure\" | \"network_error\" | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class MarmitonError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"MarmitonError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-marmiton/issues\";\n\nexport function notFound(url: string, what: string): MarmitonError {\n return new MarmitonError(\"not_found\", `Marmiton has no recipe at ${what}.`, {\n url,\n status: 404,\n hint: \"Use search_recipes to find a recipe and its id, then call this tool with that id.\",\n });\n}\n\nexport function invalidInput(message: string, hint?: string): MarmitonError {\n return new MarmitonError(\"invalid_input\", message, hint ? { hint } : {});\n}\n\nexport function rateLimited(url: string, retryAfterMs: number): MarmitonError {\n return new MarmitonError(\n \"rate_limited\",\n \"Marmiton is rate limiting this client. This does NOT mean the recipe does not exist.\",\n {\n url,\n retryAfterMs,\n hint:\n `Wait about ${Math.ceil(retryAfterMs / 1000)} seconds, then call the same tool again with the ` +\n \"same arguments. If it keeps happening, raise MARMITON_MIN_INTERVAL_MS.\",\n },\n );\n}\n\nexport function parseFailure(url: string, what: string): MarmitonError {\n return new MarmitonError(\n \"parse_failure\",\n `The page loaded but the expected structured recipe data was not found (${what}). ` +\n \"Marmiton may have changed how it publishes it.\",\n { url, hint: `Please report this, with the recipe you asked for, at ${ISSUES_URL}` },\n );\n}\n\nexport function upstreamError(url: string, status: number): MarmitonError {\n return new MarmitonError(\"network_error\", `Marmiton returned HTTP ${status}.`, {\n url,\n status,\n ...(status >= 500 ? { hint: \"This is a problem on Marmiton's side. Try again shortly.\" } : {}),\n });\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * lyrics.com tolerates a steady trickle but stops answering after a short burst,\n * so requests are run one at a time with a floor on the gap between starts.\n * When the site does push back, the interval doubles and then decays back down\n * as requests succeed, which recovers faster than a fixed delay and behaves\n * better than hammering at a constant rate.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 10_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => {\n await this.waitForSlot();\n this.lastStart = Date.now();\n return task();\n });\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n const remaining = this.intervalMs - elapsed;\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, with backoff.\n *\n * Marmiton answers with ordinary status codes, so this layer mostly translates\n * them. The one judgement call is on a body too small to be a real page, which is\n * how a site under load often refuses without saying so.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { MarmitonError, notFound, rateLimited, upstreamError } from \"../errors.js\";\nimport { RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 20_000;\n\n/** A real Marmiton page weighs well over 100 KB. */\nconst MIN_PLAUSIBLE_HTML = 2000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one page as HTML, retrying transient conditions.\n *\n * The retry loop and its sleeps run inside a single limiter slot, so a queued\n * request cannot slip into the window the current one is backing away from.\n */\nexport async function fetchHtml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n\n return limiter.schedule(async () => {\n let lastError: MarmitonError | undefined;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = backoffDelay(attempt - 1);\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n try {\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"fr-FR,fr;q=0.9,en;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n if (status === 429 || status === 503) {\n limiter.penalize();\n lastError = rateLimited(url, backoffDelay(attempt));\n logger.info(`rate limited on ${url}, interval now ${limiter.currentIntervalMs}ms`);\n continue;\n }\n if (status === 404) throw notFound(url, \"that address\");\n if (status >= 500) {\n lastError = upstreamError(url, status);\n continue;\n }\n if (status >= 400) throw upstreamError(url, status);\n\n const trimmed = body.trim();\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\\/html>/i.test(trimmed)) {\n // Too small to be a page: treat it as a refusal rather than parse it and\n // report an empty result.\n limiter.penalize();\n lastError = rateLimited(url, backoffDelay(attempt));\n logger.info(`implausibly short body on ${url}, treating as rate limiting`);\n continue;\n }\n\n limiter.relax();\n return body;\n }\n\n throw lastError ?? new MarmitonError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\nfunction asTransportError(error: unknown, url: string): MarmitonError {\n if (error instanceof MarmitonError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new MarmitonError(\"timeout\", \"Marmiton did not answer in time.\", {\n url,\n hint: \"Raise MARMITON_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new MarmitonError(\"network_error\", `Could not reach Marmiton: ${message}`, { url });\n}\n","/**\n * ISO 8601 durations, which is how schema.org expresses cooking times.\n *\n * Marmiton writes \"PT25M\" for 25 minutes and \"PT1H30M\" for an hour and a half.\n */\n\nconst ISO_DURATION = /^P(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?$/;\n\n/** Minutes, or null when the value is absent or unreadable. */\nexport function parseIsoDuration(value: unknown): number | null {\n if (typeof value !== \"string\") return null;\n const match = ISO_DURATION.exec(value.trim().toUpperCase());\n if (!match) return null;\n\n const [, days, hours, minutes, seconds] = match;\n const total =\n Number(days ?? 0) * 1440 +\n Number(hours ?? 0) * 60 +\n Number(minutes ?? 0) +\n Number(seconds ?? 0) / 60;\n\n // \"P\" alone parses but means nothing useful.\n if (total === 0 && !minutes && !hours && !days && !seconds) return null;\n return Math.round(total);\n}\n\n/** Minutes as \"1 h 30\" or \"25 min\", for the human-readable mirror. */\nexport function formatMinutes(minutes: number | null): string | null {\n if (minutes === null || !Number.isFinite(minutes) || minutes <= 0) return null;\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n if (hours === 0) return `${rest} min`;\n if (rest === 0) return `${hours} h`;\n return `${hours} h ${String(rest).padStart(2, \"0\")}`;\n}\n\nexport interface ParsedYield {\n /** Number of servings, or null when the text carries no number. */\n count: number | null;\n /** What is being counted: \"personnes\", \"pièces\", \"parts\". */\n unit: string | null;\n /** The original text, always preserved. */\n text: string;\n}\n\n/**\n * Read a schema.org recipeYield.\n *\n * Marmiton writes \"6 personnes\" but also \"15 pièces\", so the unit is kept: a\n * recipe yielding pieces cannot be described as serving people.\n */\nexport function parseYield(value: unknown): ParsedYield {\n // schema.org allows recipeYield as a number, a string, or an array of either.\n const text =\n typeof value === \"number\" && Number.isFinite(value)\n ? String(value)\n : typeof value === \"string\"\n ? value.trim()\n : Array.isArray(value)\n ? String(value[0] ?? \"\").trim()\n : \"\";\n if (!text) return { count: null, unit: null, text: \"\" };\n\n const match = /^(\\d+(?:[.,]\\d+)?)\\s*(.*)$/.exec(text);\n if (!match) return { count: null, unit: null, text };\n\n const count = Number(match[1]!.replace(\",\", \".\"));\n const unit = (match[2] ?? \"\").trim() || null;\n return { count: Number.isFinite(count) ? count : null, unit, text };\n}\n","/**\n * JSON-LD extraction.\n *\n * Marmiton embeds a schema.org graph in every page, which is data the site\n * publishes deliberately for machines to read. Using it instead of CSS selectors\n * removes the whole class of breakage where a redesign renames a class and the\n * parser silently returns nothing.\n */\n\nconst LD_JSON_BLOCK = /<script[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi;\n\n/** Every JSON-LD object on the page, flattened out of any `@graph` wrappers. */\nexport function extractJsonLdNodes(html: string): unknown[] {\n const nodes: unknown[] = [];\n LD_JSON_BLOCK.lastIndex = 0;\n\n let match: RegExpExecArray | null;\n while ((match = LD_JSON_BLOCK.exec(html)) !== null) {\n const raw = match[1]?.trim();\n if (!raw) continue;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // A malformed block is skipped rather than fatal: pages carry several,\n // and one broken analytics blob must not hide the recipe.\n continue;\n }\n\n for (const node of flatten(parsed)) nodes.push(node);\n }\n\n return nodes;\n}\n\nfunction flatten(value: unknown): unknown[] {\n if (Array.isArray(value)) return value.flatMap(flatten);\n if (typeof value !== \"object\" || value === null) return [];\n\n const node = value as Record<string, unknown>;\n const graph = node[\"@graph\"];\n if (graph !== undefined) return flatten(graph);\n return [node];\n}\n\n/** True when a node declares the given schema.org type. */\nexport function hasType(node: unknown, type: string): boolean {\n if (typeof node !== \"object\" || node === null) return false;\n const declared = (node as Record<string, unknown>)[\"@type\"];\n if (typeof declared === \"string\") return declared === type;\n if (Array.isArray(declared)) return declared.includes(type);\n return false;\n}\n\nexport function findNodeOfType(nodes: unknown[], type: string): Record<string, unknown> | null {\n const found = nodes.find((node) => hasType(node, type));\n return (found as Record<string, unknown>) ?? null;\n}\n\n/** Read a string field, tolerating the array form schema.org allows. */\nexport function asString(value: unknown): string | null {\n if (typeof value === \"string\") return value.trim() || null;\n if (Array.isArray(value)) {\n for (const entry of value) {\n const found = asString(entry);\n if (found) return found;\n }\n }\n if (typeof value === \"object\" && value !== null) {\n // Fields such as `image` are often an ImageObject rather than a plain URL.\n const url = (value as Record<string, unknown>).url;\n if (typeof url === \"string\") return url.trim() || null;\n const name = (value as Record<string, unknown>).name;\n if (typeof name === \"string\") return name.trim() || null;\n }\n return null;\n}\n\nexport function asNumber(value: unknown): number | null {\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n if (typeof value === \"string\") {\n const parsed = Number(value.replace(\",\", \".\"));\n if (Number.isFinite(parsed)) return parsed;\n }\n return null;\n}\n\n/** Read a list of strings, which is how ingredients and steps are expressed. */\nexport function asStringList(value: unknown): string[] {\n if (typeof value === \"string\") return [value.trim()].filter(Boolean);\n if (!Array.isArray(value)) return [];\n\n const out: string[] = [];\n for (const entry of value) {\n if (typeof entry === \"string\") {\n const text = entry.trim();\n if (text) out.push(text);\n continue;\n }\n if (typeof entry === \"object\" && entry !== null) {\n // HowToStep carries the sentence under `text`.\n const record = entry as Record<string, unknown>;\n const text = asString(record.text) ?? asString(record.name);\n if (text) out.push(text);\n }\n }\n return out;\n}\n","/** URL construction and validation for marmiton.org. */\n\nimport { invalidInput } from \"../errors.js\";\n\nexport const BASE_URL = \"https://www.marmiton.org\";\n\n/** Recipe pages look like /recettes/recette_<slug>_<id>.aspx */\nexport const RECIPE_PATH_RE = /\\/recettes\\/recette[_-][a-z0-9%\\-.]*?_(\\d+)\\.aspx/i;\n\nconst ALLOWED_HOSTS = new Set([\"marmiton.org\", \"www.marmiton.org\"]);\n\n/**\n * Search URL.\n *\n * Only the `aqt` parameter is used. Marmiton's robots.txt disallows\n * `recherche.aspx?aqt=*page*` and `?aqt=*start*`, so this client never paginates\n * and the tool description says so rather than pretending the limit does not\n * exist.\n */\nexport function buildSearchUrl(query: string): string {\n const url = new URL(`${BASE_URL}/recettes/recherche.aspx`);\n url.searchParams.set(\"aqt\", query);\n return url.toString();\n}\n\n/**\n * Recipe URL from an id alone.\n *\n * Marmiton keys the page on the trailing id and ignores the slug, redirecting to\n * the canonical address whatever the slug says. It does require the slug to be\n * non-empty though: `recette__18588.aspx` answers 404 while `recette_r_18588.aspx`\n * redirects to the real page. Hence the placeholder, which lets a caller holding\n * only an id skip having to know the title.\n */\nexport function buildRecipeUrl(id: string): string {\n return `${BASE_URL}/recettes/recette_r_${id}.aspx`;\n}\n\n/** True only for marmiton.org, so a hostile URL cannot be used as a proxy. */\nexport function isMarmitonHost(rawUrl: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return false;\n return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());\n}\n\n/** Extract the numeric recipe id from a Marmiton path or absolute URL. */\nexport function extractRecipeId(hrefOrUrl: string): string | null {\n let path = hrefOrUrl;\n if (/^https?:\\/\\//i.test(hrefOrUrl)) {\n if (!isMarmitonHost(hrefOrUrl)) return null;\n path = new URL(hrefOrUrl).pathname;\n }\n const match = RECIPE_PATH_RE.exec(path);\n return match?.[1] ?? null;\n}\n\n/** Resolve the `id` / `url` pair accepted by get_recipe. `id` wins. */\nexport function resolveRecipeRef(input: { id?: string; url?: string }): {\n id: string;\n url: string;\n} {\n if (input.id) {\n if (!/^\\d+$/.test(input.id)) {\n throw invalidInput(\n `\"${input.id}\" is not a Marmiton recipe id.`,\n \"Ids are digits only, as returned by search_recipes.\",\n );\n }\n return { id: input.id, url: buildRecipeUrl(input.id) };\n }\n\n if (input.url) {\n if (!isMarmitonHost(input.url)) {\n throw invalidInput(\n \"Only marmiton.org URLs are accepted.\",\n \"Pass the url returned by search_recipes, or use the numeric id instead.\",\n );\n }\n const id = extractRecipeId(input.url);\n if (!id) {\n throw invalidInput(\n \"That Marmiton URL is not a recipe page.\",\n \"Recipe pages look like https://www.marmiton.org/recettes/recette_tarte-aux-pommes_18588.aspx\",\n );\n }\n return { id, url: input.url };\n }\n\n throw invalidInput(\"Either 'id' or 'url' must be provided.\");\n}\n\nexport function toAbsoluteUrl(href: string): string {\n return new URL(href, BASE_URL).toString();\n}\n","/**\n * Turning Marmiton's JSON-LD into the domain types.\n *\n * The editorial `description` is deliberately not read here. Ingredient lists and\n * cooking steps are facts and procedures, and a public tool can hand them on with\n * a link to the source; the author's descriptive prose is their writing, and this\n * server has no business redistributing it.\n */\n\nimport { parseFailure } from \"../errors.js\";\nimport { parseIsoDuration, parseYield } from \"../recipe/duration.js\";\nimport type { Nutrition, Rating, Recipe, RecipeSummary } from \"../types.js\";\nimport { asNumber, asString, asStringList, extractJsonLdNodes, findNodeOfType } from \"./jsonld.js\";\nimport { extractRecipeId, toAbsoluteUrl } from \"./urls.js\";\n\nexport interface ParseRecipeOptions {\n id: string;\n url: string;\n}\n\nexport function parseRecipePage(html: string, options: ParseRecipeOptions): Recipe {\n const nodes = extractJsonLdNodes(html);\n const node = findNodeOfType(nodes, \"Recipe\");\n\n // A page that loaded but carries no Recipe node means Marmiton changed how it\n // publishes its data. Reporting that as \"no recipe\" would be indistinguishable\n // from a genuine absence, so it has to be loud.\n if (!node) {\n throw parseFailure(options.url, \"no schema.org Recipe node in the page\");\n }\n\n const title = asString(node.name);\n const ingredients = asStringList(node.recipeIngredient);\n const steps = asStringList(node.recipeInstructions);\n\n if (!title) throw parseFailure(options.url, \"the Recipe node has no name\");\n if (ingredients.length === 0) {\n throw parseFailure(options.url, \"the Recipe node carries no ingredient list\");\n }\n\n return {\n id: options.id,\n title,\n url: asString(node.mainEntityOfPage) ?? options.url,\n imageUrl: asString(node.image),\n ingredients,\n steps,\n recipeYield: parseYield(node.recipeYield),\n prepMinutes: parseIsoDuration(node.prepTime),\n cookMinutes: parseIsoDuration(node.cookTime),\n totalMinutes: parseIsoDuration(node.totalTime),\n category: asString(node.recipeCategory),\n author: asString(node.author),\n rating: parseRating(node.aggregateRating),\n nutrition: parseNutrition(node.nutrition),\n };\n}\n\nfunction parseRating(value: unknown): Rating | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const ratingValue = asNumber(record.ratingValue);\n if (ratingValue === null) return null;\n return {\n value: ratingValue,\n count: asNumber(record.ratingCount) ?? asNumber(record.reviewCount),\n best: asNumber(record.bestRating),\n };\n}\n\nfunction parseNutrition(value: unknown): Nutrition | null {\n if (typeof value !== \"object\" || value === null) return null;\n const record = value as Record<string, unknown>;\n const nutrition: Nutrition = {\n calories: asString(record.calories),\n protein: asString(record.proteinContent),\n fat: asString(record.fatContent),\n carbohydrate: asString(record.carbohydrateContent),\n fiber: asString(record.fiberContent),\n sodium: asString(record.sodiumContent),\n servingSize: asString(record.servingSize),\n };\n const hasAny = Object.values(nutrition).some((entry) => entry !== null);\n return hasAny ? nutrition : null;\n}\n\n/**\n * Search results, read from the ItemList the search page publishes.\n *\n * The page also lists more recipes in its HTML, but reaching them would mean\n * reintroducing CSS selectors and the breakage that comes with them. The\n * structured list is what the site offers machines, so that is what is used.\n */\nexport function parseSearchPage(html: string, url: string): RecipeSummary[] {\n const nodes = extractJsonLdNodes(html);\n const list = findNodeOfType(nodes, \"ItemList\");\n\n if (!list) {\n throw parseFailure(url, \"no schema.org ItemList node on the search page\");\n }\n\n const elements = list.itemListElement;\n if (!Array.isArray(elements)) {\n throw parseFailure(url, \"the ItemList node carries no itemListElement array\");\n }\n\n const results: RecipeSummary[] = [];\n let skipped = 0;\n\n for (const element of elements) {\n if (typeof element !== \"object\" || element === null) {\n skipped += 1;\n continue;\n }\n const record = element as Record<string, unknown>;\n const href = asString(record.url);\n const title = asString(record.name);\n const id = href ? extractRecipeId(href) : null;\n\n if (!href || !title || !id) {\n skipped += 1;\n continue;\n }\n\n results.push({ id, title, url: toAbsoluteUrl(href), imageUrl: asString(record.image) });\n }\n\n // Every entry failing while the list itself was present means the entry shape\n // changed, which is breakage rather than an empty search.\n if (results.length === 0 && elements.length > 0) {\n throw parseFailure(url, `${elements.length} list entries but none could be read`);\n }\n if (skipped > 0) {\n process.stderr.write(`[mcp-marmiton] skipped ${skipped} unreadable search entries on ${url}\\n`);\n }\n\n return results;\n}\n","/**\n * High-level Marmiton client.\n *\n * This module knows nothing about MCP, which keeps it testable against plain\n * strings and usable as a library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { createLogger, loadConfig } from \"../config.js\";\nimport type { Recipe, RecipeSummary } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchHtml } from \"./http.js\";\nimport { parseRecipePage, parseSearchPage } from \"./parseRecipe.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { buildSearchUrl, resolveRecipeRef } from \"./urls.js\";\n\nexport interface MarmitonClientOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport interface Outcome<T> {\n data: T;\n /** True when served from the in-memory cache rather than the network. */\n cached: boolean;\n}\n\nexport class MarmitonClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<string>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: MarmitonClientOptions = {}) {\n this.config = options.config ?? loadConfig();\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });\n this.cache = new TtlLruCache<string>(this.config.cacheMaxEntries, this.config.cacheTtlMs);\n this.fetchImpl = options.fetchImpl;\n }\n\n /**\n * Search recipes.\n *\n * One page only: Marmiton's robots.txt disallows the paginated form of this\n * URL, so there is no page parameter to pass.\n */\n async search(query: string): Promise<Outcome<RecipeSummary[]>> {\n const url = buildSearchUrl(query);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseSearchPage(html, url), cached };\n }\n\n async getRecipe(ref: { id?: string; url?: string }): Promise<Outcome<Recipe>> {\n const { id, url } = resolveRecipeRef(ref);\n const { html, cached } = await this.fetchPage(url);\n return { data: parseRecipePage(html, { id, url }), cached };\n }\n\n private async fetchPage(url: string): Promise<{ html: string; cached: boolean }> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { html: hit, cached: true };\n }\n\n const html = await fetchHtml(url, {\n config: this.config,\n limiter: this.limiter,\n logger: this.logger,\n ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),\n });\n this.cache.set(url, html);\n return { html, cached: false };\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "mcp-marmiton",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "smeet666",
8
+ "homepage": "https://github.com/smeet666/mcp-marmiton#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/smeet666/mcp-marmiton.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/smeet666/mcp-marmiton/issues"
15
+ },
16
+ "mcpName": "io.github.smeet666/mcp-marmiton",
17
+ "bin": {
18
+ "mcp-marmiton": "dist/index.js"
19
+ },
20
+ "main": "dist/index.js",
21
+ "types": "dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ },
27
+ "./client": {
28
+ "types": "./dist/marmiton/client.d.ts",
29
+ "import": "./dist/marmiton/client.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "LICENSE",
36
+ "server.json"
37
+ ],
38
+ "engines": {
39
+ "node": ">=20.18.1"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run test/unit",
45
+ "test:live": "vitest run test/live --testTimeout=60000",
46
+ "build:fixtures": "node scripts/build-fixtures.mjs",
47
+ "format": "prettier --write .",
48
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build",
49
+ "start": "node dist/index.js",
50
+ "inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
51
+ },
52
+ "keywords": [
53
+ "mcp",
54
+ "modelcontextprotocol",
55
+ "model-context-protocol",
56
+ "mcp-server",
57
+ "marmiton",
58
+ "recipe",
59
+ "recipes",
60
+ "cooking",
61
+ "food",
62
+ "french",
63
+ "scaling",
64
+ "servings",
65
+ "claude",
66
+ "cursor",
67
+ "llm",
68
+ "ai",
69
+ "no-api-key"
70
+ ],
71
+ "dependencies": {
72
+ "@modelcontextprotocol/sdk": "^1.30.0",
73
+ "zod": "^3.25.76"
74
+ },
75
+ "devDependencies": {
76
+ "@types/node": "^22.10.2",
77
+ "prettier": "^3.4.2",
78
+ "tsup": "^8.5.1",
79
+ "typescript": "^5.7.2",
80
+ "vitest": "^2.1.8"
81
+ }
82
+ }
package/server.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
3
+ "name": "io.github.smeet666/mcp-marmiton",
4
+ "description": "Search Marmiton recipes, read ingredients and steps, and scale quantities to any number of servings. No API key.",
5
+ "repository": {
6
+ "url": "https://github.com/smeet666/mcp-marmiton",
7
+ "source": "github"
8
+ },
9
+ "version": "0.1.0",
10
+ "packages": [
11
+ {
12
+ "registryType": "npm",
13
+ "identifier": "mcp-marmiton",
14
+ "version": "0.1.0",
15
+ "transport": { "type": "stdio" },
16
+ "environmentVariables": [
17
+ {
18
+ "name": "MARMITON_USER_AGENT",
19
+ "description": "Override the User-Agent sent to Marmiton.",
20
+ "isRequired": false
21
+ },
22
+ {
23
+ "name": "MARMITON_MIN_INTERVAL_MS",
24
+ "description": "Minimum milliseconds between requests. Default 1000, floor 500.",
25
+ "isRequired": false
26
+ },
27
+ {
28
+ "name": "MARMITON_LOG_LEVEL",
29
+ "description": "silent, error, info or debug. Default error.",
30
+ "isRequired": false
31
+ }
32
+ ]
33
+ }
34
+ ]
35
+ }