mcp-recipes 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/LICENSE +21 -0
- package/README.md +684 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3037 -0
- package/dist/index.js.map +1 -0
- package/dist/language-BgfmjwtF.d.ts +14 -0
- package/dist/recipe/scale.d.ts +86 -0
- package/dist/recipe/scale.js +1367 -0
- package/dist/recipe/scale.js.map +1 -0
- package/dist/sources/client.d.ts +448 -0
- package/dist/sources/client.js +697 -0
- package/dist/sources/client.js.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/version.ts","../../src/config.ts","../../src/errors.ts","../../src/sources/adapter.ts","../../src/sources/ids.ts","../../src/sources/registry.ts","../../src/sources/cookbook.ts","../../src/sources/marmiton.ts","../../src/sources/client.ts"],"sourcesContent":["/**\n * Kept in step with the version in package.json. The User-Agent embeds it, so\n * the sites this server reads can tell which build is calling from their own\n * logs.\n */\nexport const PKG_VERSION = \"1.0.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-recipes\";\nexport const ISSUES_URL = \"https://github.com/smeet666/mcp-recipes/issues\";\n","/**\n * Settings, read from the environment.\n *\n * A value that cannot be read warns and falls back rather than stopping the\n * server: a typo in one variable should not take away every tool. Warnings go\n * to stderr, because stdout carries the protocol and anything written there\n * corrupts the session.\n *\n * One setting governs every source. Each is read through its own client, and\n * each of those keeps a pacing floor of its own that nothing here can lower, so\n * a value set here can only make this server more patient than it already is.\n */\n\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport const LOG_LEVELS = [\"silent\", \"error\", \"info\", \"debug\"] as const;\nexport type LogLevel = (typeof LOG_LEVELS)[number];\n\n/**\n * Spacing between two requests to the same site.\n *\n * No source publishes a ceiling for a client like this one, so the number is\n * self-imposed politeness: a second between requests is far below what a person\n * browsing one of these sites generates, and the answers are cached, so a\n * conversation that circles back to a recipe costs no traffic at all.\n */\nexport const DEFAULT_INTERVAL_MS = 1000;\n/**\n * Configuration can slow the server down. It cannot take the spacing below this\n * floor, whether the setting arrives through the environment or through a\n * configuration object handed to the published client entry point.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n/** Beyond this a request would look hung rather than paced. */\nexport const MAX_ALLOWED_INTERVAL_MS = 60_000;\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\nexport const DEFAULT_USER_AGENT = `mcp-recipes/${PKG_VERSION} (+${REPO_URL})`;\n\nexport interface Logger {\n debug(message: string): void;\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n}\n\nexport function createLogger(level: LogLevel): Logger {\n const rank = LOG_LEVELS.indexOf(level);\n const write = (at: LogLevel, message: string) => {\n if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) return;\n process.stderr.write(`[mcp-recipes] ${at}: ${message}\\n`);\n };\n return {\n debug: (m) => write(\"debug\", m),\n info: (m) => write(\"info\", m),\n // A warning goes out at the error level so it survives the default\n // setting: a caller has to know that a site was left out of an answer.\n warn: (m) => write(\"error\", m),\n error: (m) => write(\"error\", m),\n };\n}\n\nfunction readInteger(\n env: NodeJS.ProcessEnv,\n name: string,\n fallback: number,\n min: number,\n max: number,\n): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") return fallback;\n\n const value = Number(raw);\n if (!Number.isFinite(value) || !Number.isInteger(value)) {\n process.stderr.write(\n `[mcp-recipes] error: ${name}=\"${raw}\" is not a whole number; using ${fallback}.\\n`,\n );\n return fallback;\n }\n if (value < min || value > max) {\n // Clamping silently would let a caller believe a setting took effect when\n // the opposite is true, so the refusal is stated and the default stands.\n process.stderr.write(\n `[mcp-recipes] error: ${name}=${value} is outside ${min}..${max}; using ${fallback}.\\n`,\n );\n return fallback;\n }\n return value;\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const level = env.RECIPES_LOG_LEVEL as LogLevel | undefined;\n const logLevel = level && LOG_LEVELS.includes(level) ? level : \"error\";\n if (level && !LOG_LEVELS.includes(level)) {\n process.stderr.write(\n `[mcp-recipes] error: RECIPES_LOG_LEVEL=\"${level}\" is not one of ${LOG_LEVELS.join(\", \")}; using error.\\n`,\n );\n }\n\n const custom = env.RECIPES_USER_AGENT?.trim();\n\n return {\n // A caller who wants to be recognised may say who they are, and the\n // contact address stays attached: a site has to be able to reach a human\n // about traffic it did not expect.\n userAgent: custom ? `${custom} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT,\n minIntervalMs: readInteger(\n env,\n \"RECIPES_MIN_INTERVAL_MS\",\n DEFAULT_INTERVAL_MS,\n MIN_ALLOWED_INTERVAL_MS,\n MAX_ALLOWED_INTERVAL_MS,\n ),\n timeoutMs: readInteger(env, \"RECIPES_TIMEOUT_MS\", 20_000, 1000, 120_000),\n maxRetries: readInteger(env, \"RECIPES_MAX_RETRIES\", 3, 0, 8),\n cacheTtlMs: readInteger(env, \"RECIPES_CACHE_TTL_MS\", 900_000, 0, 86_400_000),\n cacheMaxEntries: readInteger(env, \"RECIPES_CACHE_MAX_ENTRIES\", 200, 1, 5000),\n logLevel,\n };\n}\n","/**\n * One error type, carrying a code the caller can branch on.\n *\n * The distinction that matters most is between \"no site holds this\" and \"the\n * question could not be asked\". Collapsing the two lets a model report an\n * absence it never established, which is a false statement about the world\n * rather than a missing feature.\n */\n\nimport { ISSUES_URL } from \"./version.js\";\n\nexport type ErrorCode =\n /** A site answered, and holds no such recipe. */\n | \"not_found\"\n /** The arguments cannot produce a request. */\n | \"invalid_input\"\n /** A site asked this client to slow down. */\n | \"rate_limited\"\n /** A response arrived in a shape this server cannot read. */\n | \"parse_failure\"\n /** The request could not be completed. */\n | \"network_error\"\n /** The request was abandoned before an answer arrived. */\n | \"timeout\";\n\nexport const ERROR_CODES: readonly ErrorCode[] = [\n \"not_found\",\n \"invalid_input\",\n \"rate_limited\",\n \"parse_failure\",\n \"network_error\",\n \"timeout\",\n];\n\nexport interface ErrorDetails {\n /** What the caller can do about it, when there is something. */\n hint?: string;\n /** The address that produced the failure, for a bug report. */\n url?: string;\n status?: number;\n}\n\nexport class RecipesError extends Error {\n readonly code: ErrorCode;\n readonly details: ErrorDetails;\n\n constructor(code: ErrorCode, message: string, details: ErrorDetails = {}) {\n super(message);\n this.name = \"RecipesError\";\n this.code = code;\n this.details = details;\n }\n}\n\nexport const notFound = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"not_found\", message, details ?? {});\n\nexport const invalidInput = (message: string, hint?: string) =>\n new RecipesError(\"invalid_input\", message, hint ? { hint } : {});\n\nexport const rateLimited = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"rate_limited\", message, {\n hint: \"Wait a moment and ask again. This says nothing about whether the recipe exists.\",\n ...details,\n });\n\nexport const parseFailure = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"parse_failure\", message, {\n hint: `A site may have changed how it answers. Please report this at ${ISSUES_URL} with the arguments you used.`,\n ...details,\n });\n\nexport const networkError = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"network_error\", message, details ?? {});\n\nexport const timeout = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"timeout\", message, details ?? {});\n\n/**\n * Read the code off a failure raised by the library that reads a source.\n *\n * Those libraries throw their own error classes carrying the six codes above,\n * and the classes are not part of the interface this server imports. Reading\n * the field keeps the taxonomy intact without depending on a class identity a\n * library is free to change, and anything unrecognisable is reported as a\n * network failure, which is the reading that claims least.\n */\nexport function toRecipesError(error: unknown): RecipesError {\n if (error instanceof RecipesError) return error;\n\n const message = error instanceof Error ? error.message : String(error);\n const raw = error as { code?: unknown; details?: unknown } | null;\n const code = typeof raw?.code === \"string\" ? raw.code : \"\";\n const known = ERROR_CODES.find((candidate) => candidate === code);\n\n const details =\n raw && typeof raw.details === \"object\" && raw.details !== null\n ? (raw.details as ErrorDetails)\n : {};\n\n return new RecipesError(known ?? \"network_error\", message, details);\n}\n","/**\n * What a source has to provide to be one.\n *\n * Everything above this file is written for however many sources the registry\n * holds. A source knows four things nothing else does: what it is called, how\n * to recognise one of its own identifiers, how to search it, and how to read one\n * recipe off it. Adding a corpus is writing those four and registering it.\n *\n * The helpers below are the ones every adapter needs, because no source answers\n * with a contract: a field can arrive missing, null, or holding something other\n * than the type it usually holds.\n */\n\nimport { parseFailure } from \"../errors.js\";\nimport type { Language } from \"../recipe/language.js\";\nimport type { RecipeDetail, RecipeRow, SourceId, SourceProfile } from \"../types.js\";\n\n/** Rows that could be read, and how many could not. */\nexport interface ReadRows {\n rows: RecipeRow[];\n /** Rows the source sent in a shape this server could not read. */\n skipped: number;\n /** Rows the source said it saw, in the terms that source counts in. */\n reportedTotal: number | null;\n /** What `reportedTotal` counts on this source, in words. Null when it states none. */\n reportedTotalMeans: string | null;\n cached: boolean;\n}\n\nexport interface ReadRecipe {\n recipe: RecipeDetail;\n cached: boolean;\n}\n\n/** How a source recognised a raw identifier as one of its own. */\nexport interface Claim {\n /** The string this source's own reader takes. */\n reference: string;\n /** Why the shape was read this way, for an answer that has to say so. */\n why: string;\n /**\n * Whether reading it this way is a guess. A shape only this source mints is\n * not; a shape it would merely accept is, and the answer says so.\n */\n guess: boolean;\n}\n\nexport interface SourceAdapter extends SourceProfile {\n /**\n * Recognise a raw identifier, or decline it.\n *\n * A source claims a shape it mints and declines everything else, so a string\n * no source claims is refused, instead of being sent somewhere that would\n * answer it with a confident absence.\n */\n claims(raw: string): Claim | null;\n search(query: string, limit: number): Promise<ReadRows>;\n getRecipe(reference: string): Promise<ReadRecipe>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Reading a value a source sent, without trusting its shape */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The distinction these draw is between a row and a record. One row this server\n * cannot read is dropped and counted, because the rest of the list is still a\n * good answer. A record it cannot read is `parse_failure`, because there is\n * nothing left to return and a caller told \"network_error\" would retry a source\n * that has actually changed.\n */\nexport function text(value: unknown): string | null {\n return typeof value === \"string\" && value.trim() !== \"\" ? value : null;\n}\n\nexport function textList(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((entry): entry is string => typeof entry === \"string\")\n : [];\n}\n\nexport function count(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nexport function required(value: unknown, field: string, source: SourceProfile): string {\n const found = text(value);\n if (found === null) {\n throw parseFailure(\n `${source.name} answered without a readable \"${field}\", so there is no recipe to return.`,\n );\n }\n return found;\n}\n\nexport function rowsOf<T>(value: unknown, source: SourceProfile): T[] {\n if (!Array.isArray(value)) {\n throw parseFailure(\n `${source.name} answered in a shape this server could not read: the list of results was missing.`,\n );\n }\n return value as T[];\n}\n\n/** Build the identifier this server hands out for a row from a source. */\nexport function namespacedId(source: SourceId, reference: string): string {\n return `${source}:${reference}`;\n}\n\n/**\n * Keep the entries a page actually published.\n *\n * A nutrition panel arrives with a slot for every figure the source knows how\n * to print, and the slots a page left empty are null. Passing those through\n * would put \"protein: null\" in front of a reader as though the page had said\n * something about protein.\n *\n * The panel is walked rather than typed, because each source names its slots\n * differently and a figure is repeated as the page wrote it either way.\n */\nexport function publishedFigures(panel: unknown): Record<string, string> | null {\n if (typeof panel !== \"object\" || panel === null) return null;\n const kept: Record<string, string> = {};\n for (const [key, value] of Object.entries(panel)) {\n if (typeof value === \"string\" && value.trim() !== \"\") kept[key] = value.trim();\n }\n return Object.keys(kept).length > 0 ? kept : null;\n}\n\n/**\n * Read a yield that names a span rather than a number.\n *\n * \"4 à 6 personnes\" and \"4 personnes\" are different claims, and a source that\n * reports the first as a plain count of four hands this server a factor that is\n * too large for anyone at the upper end. The span is read back off the published\n * wording so the answer can say which end it scaled from.\n */\nexport function readYieldSpan(published: string | null): {\n count: number | null;\n max: number | null;\n unit: string | null;\n} {\n if (published === null) return { count: null, max: null, unit: null };\n\n const span = /^\\s*(\\d+(?:[.,]\\d+)?)\\s*(?:à|a|to|-|–|—|ou|or)\\s*(\\d+(?:[.,]\\d+)?)\\s*(.*)$/i.exec(\n published,\n );\n if (span) {\n const low = Number(span[1]!.replace(\",\", \".\"));\n const high = Number(span[2]!.replace(\",\", \".\"));\n if (Number.isFinite(low) && Number.isFinite(high) && high > low) {\n return { count: low, max: high, unit: text(span[3]) };\n }\n }\n\n const single = /^\\s*(\\d+(?:[.,]\\d+)?)\\s*(.*)$/.exec(published);\n if (single) {\n const value = Number(single[1]!.replace(\",\", \".\"));\n if (Number.isFinite(value)) return { count: value, max: null, unit: text(single[2]) };\n }\n\n return { count: null, max: null, unit: null };\n}\n\n/** Make the language a source publishes in available to whatever reads its lines. */\nexport function detailBase(source: SourceProfile): {\n source: SourceId;\n sourceName: string;\n language: Language;\n attribution: string;\n} {\n return {\n source: source.id,\n sourceName: source.name,\n language: source.language,\n attribution: source.attribution,\n };\n}\n","/**\n * Identifiers that name the source they came from.\n *\n * Every source hands back an opaque string, and a caller holding a merged list\n * has no way to tell one source's string from another's. Every identifier this\n * server returns therefore carries a prefix, and `get_recipe` routes on it\n * rather than trying each source in turn.\n */\n\nimport { invalidInput } from \"../errors.js\";\nimport type { SourceId } from \"../types.js\";\nimport type { SourceAdapter } from \"./adapter.js\";\n\nexport { namespacedId } from \"./adapter.js\";\n\nconst SEPARATOR = \":\";\n\nexport interface ResolvedId {\n source: SourceAdapter;\n /** The string that source's own reader takes. */\n reference: string;\n /** Set when the shape was read rather than stated, so the answer can say so. */\n inferred: string | null;\n}\n\n/**\n * Work out which source an identifier belongs to.\n *\n * A prefixed identifier is routed on what it says. A raw one is offered to each\n * source in turn, and a source claims only the shapes it mints: digits for one,\n * a namespaced page key for another. Exactly one claim routes the read and the\n * answer says which reading it used.\n *\n * More than one claim is refused rather than resolved, because picking a winner\n * would send the read somewhere a caller did not intend and answer with a\n * confident recipe for the wrong dish. No claim at all is refused too: a bare\n * dish name is a search, not an identifier, and guessing a page name from it\n * returns whatever that guess happens to hit.\n */\nexport function resolveId(rawId: string, sources: readonly SourceAdapter[]): ResolvedId {\n const trimmed = rawId.trim();\n if (trimmed === \"\") {\n throw invalidInput(\n \"A recipe identifier is required.\",\n `Use one of the ids search_recipes returned, such as ${example(sources)}.`,\n );\n }\n\n for (const source of sources) {\n const prefix = `${source.id}${SEPARATOR}`;\n if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) continue;\n const reference = trimmed.slice(prefix.length).trim();\n if (reference === \"\") {\n throw invalidInput(\n `\"${trimmed}\" names a source and no recipe.`,\n \"Put the source's own identifier after the colon.\",\n );\n }\n return { source, reference, inferred: null };\n }\n\n const claimed = sources\n .map((source) => ({ source, claim: source.claims(trimmed) }))\n .filter((entry): entry is { source: SourceAdapter; claim: NonNullable<typeof entry.claim> } =>\n Boolean(entry.claim),\n );\n\n if (claimed.length > 1) {\n throw invalidInput(\n `\"${trimmed}\" could be an identifier on ${names(claimed.map((entry) => entry.source))}, so it names no one recipe.`,\n `Spell it with its source, as in ${claimed.map((entry) => `${entry.source.id}:${trimmed}`).join(\" or \")}.`,\n );\n }\n\n const only = claimed[0];\n if (!only) {\n throw invalidInput(\n `\"${trimmed}\" is not an identifier any of the sources this server reads would mint.`,\n `Call search_recipes with \"${trimmed}\" as the query and pass an id from a row, ` +\n `which reads like ${example(sources)}.`,\n );\n }\n\n return {\n source: only.source,\n reference: only.claim.reference,\n inferred: only.claim.guess\n ? `${only.claim.why}, which is a guess: check the recipe that comes back is the one you meant`\n : only.claim.why,\n };\n}\n\n/** Sources named the way a sentence names them. */\nfunction names(sources: readonly { name: string }[]): string {\n const all = sources.map((source) => source.name);\n if (all.length <= 1) return all.join(\"\");\n return `${all.slice(0, -1).join(\", \")} and ${all[all.length - 1]}`;\n}\n\n/** An identifier of the shape this server hands out, for a hint. */\nfunction example(sources: readonly SourceAdapter[]): string {\n const first = sources[0];\n return first ? `${first.id}${SEPARATOR}<the source's own id>` : \"<source>:<id>\";\n}\n\n/** Which source an identifier names, without resolving what it points at. */\nexport function sourceOf(rawId: string, sources: readonly SourceAdapter[]): SourceId | null {\n const prefix = rawId.split(SEPARATOR)[0]?.toLowerCase();\n return sources.find((source) => source.id.toLowerCase() === prefix)?.id ?? null;\n}\n","/**\n * The sources this server reads.\n *\n * One list, and everything above it is written for however long the list is.\n * Adding a corpus means writing an adapter and adding it here; no tool, no\n * merge and no error path has a branch per source.\n */\n\nimport { MarmitonClient } from \"mcp-marmiton/client\";\nimport type { MarmitonClientOptions } from \"mcp-marmiton/client\";\nimport { CookbookClient } from \"mcp-wikibooks-cookbook/client\";\nimport type { ClientOptions as CookbookClientOptions } from \"mcp-wikibooks-cookbook/client\";\n\nimport type { Config } from \"../config.js\";\nimport { invalidInput } from \"../errors.js\";\nimport type { SourceId } from \"../types.js\";\nimport type { SourceAdapter } from \"./adapter.js\";\nimport { COOKBOOK_PROFILE, cookbookAdapter } from \"./cookbook.js\";\nimport type { CookbookReader } from \"./cookbook.js\";\nimport { MARMITON_PROFILE, marmitonAdapter } from \"./marmiton.js\";\nimport type { MarmitonReader } from \"./marmiton.js\";\n\n/**\n * A reader may be supplied in place of the one that talks to a site, so a\n * program embedding this server can put its own cache in front of a corpus, and\n * a test can drive it from fixed answers.\n */\nexport interface Readers {\n marmiton?: MarmitonReader;\n cookbook?: CookbookReader;\n}\n\n/** The sources a caller can name, in the order an answer takes them. */\nexport const SOURCE_IDS: readonly SourceId[] = [MARMITON_PROFILE.id, COOKBOOK_PROFILE.id];\n\nexport function buildSources(config: Config, readers: Readers = {}): SourceAdapter[] {\n const shared = {\n userAgent: config.userAgent,\n minIntervalMs: config.minIntervalMs,\n timeoutMs: config.timeoutMs,\n maxRetries: config.maxRetries,\n cacheTtlMs: config.cacheTtlMs,\n cacheMaxEntries: config.cacheMaxEntries,\n logLevel: config.logLevel,\n };\n\n const marmitonOptions: MarmitonClientOptions = { config: shared };\n const cookbookOptions: CookbookClientOptions = { config: shared };\n\n return [\n marmitonAdapter(readers.marmiton ?? new MarmitonClient(marmitonOptions)),\n cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions)),\n ];\n}\n\n/** The sources a caller asked for, in the registry's own order. */\nexport function selectSources(\n sources: SourceAdapter[],\n wanted: readonly SourceId[] | undefined,\n): SourceAdapter[] {\n if (!wanted) return sources;\n\n const unknown = wanted.filter((id) => !sources.some((source) => source.id === id));\n if (unknown.length > 0) {\n throw invalidInput(\n `This server reads no source called ${unknown.map((id) => `\"${id}\"`).join(\", \")}.`,\n `It reads ${sources.map((source) => source.id).join(\", \")}.`,\n );\n }\n\n const chosen = sources.filter((source) => wanted.includes(source.id));\n if (chosen.length === 0) {\n throw invalidInput(\n \"A search needs at least one source.\",\n `Name one of ${sources.map((source) => source.id).join(\", \")}, or leave the argument out to ask them all.`,\n );\n }\n return chosen;\n}\n","/**\n * The Cookbook on the English Wikibooks, as a source.\n *\n * A wiki written by whoever edits it, published under a licence that requires\n * attribution, with no author and no reader rating by nature. Its identifiers\n * are page keys carrying the namespace every Cookbook page lives in.\n */\n\nimport { invalidInput } from \"../errors.js\";\nimport type { RecipeDetail } from \"../types.js\";\nimport type { Claim, ReadRecipe, ReadRows, SourceAdapter } from \"./adapter.js\";\nimport {\n count,\n detailBase,\n namespacedId,\n publishedFigures,\n readYieldSpan,\n required,\n rowsOf,\n text,\n textList,\n} from \"./adapter.js\";\n\n/** A search row, as the Cookbook's own reader publishes one. */\nexport interface CookbookSummary {\n key: string;\n title: string;\n description: string | null;\n excerpt: string | null;\n imageUrl: string | null;\n sourceUrl: string;\n}\n\n/** A page, as the Cookbook's own reader publishes one. */\nexport interface CookbookRecipe {\n key: string;\n title: string;\n sourceUrl: string;\n license: { title: string; url: string } | null;\n description: string | null;\n category: string | null;\n servings: number | null;\n yieldText: string | null;\n yieldUnit: string | null;\n totalMinutes: number | null;\n ingredients: string[];\n equipment: string[];\n steps: string[];\n tips: string[];\n nutrition: unknown;\n}\n\n/** The part of the Cookbook's client this server uses. */\nexport interface CookbookReader {\n search(\n query: string,\n limit: number,\n ): Promise<{ data: { results: CookbookSummary[] }; cached: boolean }>;\n getRecipe(reference: string): Promise<{ data: CookbookRecipe; cached: boolean }>;\n}\n\nexport const COOKBOOK_PROFILE = {\n id: \"cookbook\",\n name: \"Wikibooks Cookbook\",\n homeUrl: \"https://en.wikibooks.org/wiki/Cookbook:Table_of_Contents\",\n language: \"en\" as const,\n attribution: \"Source: Wikibooks Cookbook\",\n // The namespace holds pages about salt and about a dish made with it alike.\n mixesReferencePages: true,\n};\n\nconst SITE_URL = /^https?:\\/\\/en\\.wikibooks\\.org\\/wiki\\//i;\n/** The namespace every page in the Cookbook lives under. */\nconst NAMESPACE = /^cookbook\\s*:/i;\n\n/**\n * Every page in the Cookbook is filed under one namespace, so its title arrives\n * as \"Cookbook:Spaghetti alla Carbonara\". The prefix is where the page lives on\n * the wiki rather than what the dish is called, and repeating it on every row of\n * a list says nothing and crowds out the names. The key keeps it, because that\n * is the string the site resolves.\n */\nfunction dishName(title: string): string {\n return title.replace(NAMESPACE, \"\").trim() || title;\n}\n\nexport function cookbookAdapter(reader: CookbookReader): SourceAdapter {\n return {\n ...COOKBOOK_PROFILE,\n\n claims(raw: string): Claim | null {\n if (SITE_URL.test(raw)) {\n let key: string;\n try {\n key = decodeURIComponent(raw.replace(SITE_URL, \"\")).replace(/_/g, \" \");\n } catch {\n // A percent sign that opens no escape is common in a wiki title, and\n // it is the caller's string rather than a site that failed.\n throw invalidInput(\n `\"${raw}\" carries a percent sign that is not a valid escape, so the page name cannot be read.`,\n \"Pass the id search_recipes returned instead of the address.\",\n );\n }\n return {\n reference: key,\n why: \"the address is a page on the English Wikibooks\",\n guess: false,\n };\n }\n\n if (NAMESPACE.test(raw)) {\n return {\n reference: raw,\n why: \"the name opens with the namespace every Cookbook page lives under\",\n guess: false,\n };\n }\n\n return null;\n },\n\n async search(query: string, limit: number): Promise<ReadRows> {\n const outcome = await reader.search(query, limit);\n const list = rowsOf<Partial<CookbookSummary>>(\n (outcome.data as { results?: unknown })?.results,\n COOKBOOK_PROFILE,\n );\n const rows = [];\n let skipped = 0;\n\n for (const summary of list) {\n const key = text(summary?.key);\n const title = text(summary?.title) ?? key;\n const url = text(summary?.sourceUrl);\n if (!key || !title || !url) {\n skipped += 1;\n continue;\n }\n rows.push({\n id: namespacedId(COOKBOOK_PROFILE.id, key),\n source: COOKBOOK_PROFILE.id,\n sourceName: COOKBOOK_PROFILE.name,\n title: dishName(title),\n url,\n imageUrl: text(summary?.imageUrl),\n excerpt: text(summary?.excerpt) ?? text(summary?.description),\n });\n }\n\n return {\n rows,\n skipped,\n // The gateway states no total and offers no second page, so there is no\n // number to report. Counting the rows returned and calling it a total\n // would invent one.\n reportedTotal: null,\n reportedTotalMeans: null,\n cached: outcome.cached,\n };\n },\n\n async getRecipe(reference: string): Promise<ReadRecipe> {\n const outcome = await reader.getRecipe(reference);\n return { recipe: cookbookDetail(outcome.data), cached: outcome.cached };\n },\n };\n}\n\nexport function cookbookDetail(payload: unknown): RecipeDetail {\n const recipe = (payload ?? {}) as Partial<CookbookRecipe>;\n const license = recipe.license as Partial<NonNullable<CookbookRecipe[\"license\"]>> | null;\n const licenseTitle = text(license?.title);\n const licenseUrl = text(license?.url);\n\n const published = text(recipe.yieldText);\n const span = readYieldSpan(published);\n\n return {\n ...detailBase(COOKBOOK_PROFILE),\n id: namespacedId(COOKBOOK_PROFILE.id, required(recipe.key, \"key\", COOKBOOK_PROFILE)),\n title: dishName(required(recipe.title ?? recipe.key, \"title\", COOKBOOK_PROFILE)),\n url: required(recipe.sourceUrl, \"url\", COOKBOOK_PROFILE),\n // The page source carries no picture this server can address.\n imageUrl: null,\n yieldCount: count(recipe.servings) ?? span.count,\n yieldMax: span.max,\n yieldText: published,\n yieldUnit: text(recipe.yieldUnit) ?? span.unit,\n ingredients: textList(recipe.ingredients),\n steps: textList(recipe.steps),\n // The Cookbook publishes one time for a recipe rather than splitting it.\n prepMinutes: null,\n cookMinutes: null,\n totalMinutes: count(recipe.totalMinutes),\n category: text(recipe.category),\n // The page is written by everyone who edited it, so it credits nobody in\n // particular and carries no reader rating.\n author: null,\n rating: null,\n nutrition: publishedFigures(recipe.nutrition),\n equipment: textList(recipe.equipment),\n tips: textList(recipe.tips),\n // A licence is only stated when both halves of it arrived: a name with no\n // address, or an address with no name, is not terms anyone can follow.\n license: licenseTitle && licenseUrl ? { title: licenseTitle, url: licenseUrl } : null,\n };\n}\n","/**\n * Marmiton, as a source.\n *\n * A French recipe site with reader ratings, whose identifiers are digits and\n * whose search serves a single page it disallows paging past.\n */\n\nimport type { RecipeDetail } from \"../types.js\";\nimport type { Claim, ReadRecipe, ReadRows, SourceAdapter } from \"./adapter.js\";\nimport {\n count,\n detailBase,\n namespacedId,\n publishedFigures,\n readYieldSpan,\n required,\n rowsOf,\n text,\n textList,\n} from \"./adapter.js\";\n\n/** A search row, as Marmiton's own reader publishes one. */\nexport interface MarmitonSummary {\n id: string;\n title: string;\n url: string;\n imageUrl: string | null;\n}\n\n/** A recipe, as Marmiton's own reader publishes one. */\nexport interface MarmitonRecipe {\n id: string;\n title: string;\n url: string;\n imageUrl: string | null;\n ingredients: string[];\n steps: string[];\n recipeYield: { count: number | null; unit: string | null; text: string };\n prepMinutes: number | null;\n cookMinutes: number | null;\n totalMinutes: number | null;\n category: string | null;\n author: string | null;\n rating: { value: number; count: number | null; best: number | null } | null;\n nutrition: unknown;\n}\n\n/** The part of Marmiton's client this server uses. */\nexport interface MarmitonReader {\n search(query: string): Promise<{ data: MarmitonSummary[]; cached: boolean }>;\n getRecipe(ref: { id?: string; url?: string }): Promise<{ data: MarmitonRecipe; cached: boolean }>;\n}\n\nexport const MARMITON_PROFILE = {\n id: \"marmiton\",\n name: \"Marmiton\",\n homeUrl: \"https://www.marmiton.org\",\n language: \"fr\" as const,\n attribution: \"Source: Marmiton\",\n // Every page the search returns is a recipe.\n mixesReferencePages: false,\n};\n\n/** Recipe pages, whose address ends in the numeric identifier. */\nconst SITE_URL = /^https?:\\/\\/(?:www\\.)?marmiton\\.org\\//i;\n/** The shape Marmiton mints: digits and nothing else. */\nconst REFERENCE = /^\\d+$/;\n\nexport function marmitonAdapter(reader: MarmitonReader): SourceAdapter {\n return {\n ...MARMITON_PROFILE,\n\n claims(raw: string): Claim | null {\n if (SITE_URL.test(raw)) {\n return { reference: raw, why: \"the address is a Marmiton page\", guess: false };\n }\n if (REFERENCE.test(raw)) {\n return {\n reference: raw,\n why: \"a bare number is the shape Marmiton mints\",\n guess: false,\n };\n }\n return null;\n },\n\n async search(query: string): Promise<ReadRows> {\n const outcome = await reader.search(query);\n const list = rowsOf<Partial<MarmitonSummary>>(outcome.data, MARMITON_PROFILE);\n const rows = [];\n let skipped = 0;\n\n for (const summary of list) {\n // A row needs something to call it, somewhere to send a reader, and an\n // identifier that reads back. A row missing any of them is dropped\n // rather than returned with a hole in it.\n const id = text(summary?.id);\n const title = text(summary?.title);\n const url = text(summary?.url);\n if (!id || !title || !url) {\n skipped += 1;\n continue;\n }\n rows.push({\n id: namespacedId(MARMITON_PROFILE.id, id),\n source: MARMITON_PROFILE.id,\n sourceName: MARMITON_PROFILE.name,\n title,\n url,\n imageUrl: text(summary?.imageUrl),\n // The result list carries a title and a picture and no prose, so\n // there is nothing to quote here.\n excerpt: null,\n });\n }\n\n return {\n rows,\n skipped,\n // Rows on the page, including the ones this server could not read: the\n // site saw them, and reporting only the readable ones would understate\n // what it holds.\n reportedTotal: rows.length + skipped,\n reportedTotalMeans:\n \"rows on the single page of results Marmiton serves; the site disallows paging, so this is not a catalogue count\",\n cached: outcome.cached,\n };\n },\n\n async getRecipe(reference: string): Promise<ReadRecipe> {\n // The reader takes an identifier or an address and tells them apart.\n const ref = /^https?:\\/\\//i.test(reference) ? { url: reference } : { id: reference };\n const outcome = await reader.getRecipe(ref);\n return { recipe: marmitonDetail(outcome.data), cached: outcome.cached };\n },\n };\n}\n\nexport function marmitonDetail(payload: unknown): RecipeDetail {\n const recipe = (payload ?? {}) as Partial<MarmitonRecipe>;\n const yields = (recipe.recipeYield ?? {}) as Partial<MarmitonRecipe[\"recipeYield\"]>;\n const rating = recipe.rating as Partial<NonNullable<MarmitonRecipe[\"rating\"]>> | null | undefined;\n const value = count(rating?.value);\n\n // The reader splits the published yield at the first number, which leaves the\n // rest of a span sitting where the word being counted belongs: \"4 à 6\n // personnes\" arrives as a count of four and a unit of \"à 6 personnes\". The\n // wording is read again here so both ends of the span are known.\n const published = text(yields.text);\n const span = readYieldSpan(published);\n\n return {\n ...detailBase(MARMITON_PROFILE),\n id: namespacedId(MARMITON_PROFILE.id, required(recipe.id, \"id\", MARMITON_PROFILE)),\n title: required(recipe.title, \"title\", MARMITON_PROFILE),\n url: required(recipe.url, \"url\", MARMITON_PROFILE),\n imageUrl: text(recipe.imageUrl),\n yieldCount: span.count ?? count(yields.count),\n yieldMax: span.max,\n yieldText: published,\n yieldUnit: span.unit ?? text(yields.unit),\n ingredients: textList(recipe.ingredients),\n steps: textList(recipe.steps),\n prepMinutes: count(recipe.prepMinutes),\n cookMinutes: count(recipe.cookMinutes),\n totalMinutes: count(recipe.totalMinutes),\n category: text(recipe.category),\n author: text(recipe.author),\n rating: value === null ? null : { value, count: count(rating?.count), max: count(rating?.best) },\n nutrition: publishedFigures(recipe.nutrition),\n equipment: [],\n tips: [],\n // Marmiton states no licence on a recipe page, and silence is not a grant.\n license: null,\n };\n}\n","/**\n * The one place that talks to the sources.\n *\n * It asks them all at once and merges what comes back, so a question is\n * answered by whichever of them holds something. It imports nothing from the\n * MCP layer and is published on its own, so the same code serves a plain\n * script.\n *\n * A source that fails is reported as a source that failed. It is never folded\n * into the answer as an absence: \"Marmiton was unreachable\" and \"Marmiton holds\n * no such recipe\" are different statements about the world, and a caller that\n * cannot tell them apart will say the second when only the first is true.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n MAX_ALLOWED_INTERVAL_MS,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport { invalidInput, timeout as timedOut, toRecipesError } from \"../errors.js\";\nimport type { MergedSearch, RecipeDetail, RecipeRow, SourceId, SourceReport } from \"../types.js\";\nimport type { SourceAdapter } from \"./adapter.js\";\nimport { resolveId } from \"./ids.js\";\nimport type { ResolvedId } from \"./ids.js\";\nimport { buildSources, selectSources } from \"./registry.js\";\nimport type { Readers } from \"./registry.js\";\n\nexport type { SourceAdapter, ReadRecipe, ReadRows } from \"./adapter.js\";\nexport type { CookbookReader } from \"./cookbook.js\";\nexport type { MarmitonReader } from \"./marmiton.js\";\nexport type { Readers } from \"./registry.js\";\nexport type {\n MergedSearch,\n RecipeDetail,\n RecipeRow,\n SourceId,\n SourceProfile,\n SourceReport,\n} from \"../types.js\";\nexport { SOURCE_IDS } from \"./registry.js\";\nexport { namespacedId, resolveId } from \"./ids.js\";\n\nexport interface RecipesClientOptions {\n config?: Partial<Config>;\n logger?: Logger;\n /** Stands in for one or more of the site readers. */\n readers?: Readers;\n /** Replaces the registry outright, for a program bringing its own corpora. */\n sources?: SourceAdapter[];\n}\n\n/** The largest number of rows any one source is asked for in a single call. */\nexport const MAX_LIMIT_PER_SOURCE = 25;\n\n/**\n * The pacing this server owes each source, applied to whatever it is handed.\n *\n * A configuration object assembled by a caller has not been through\n * `loadConfig`, so it can carry a missing value, a value of the wrong shape, or\n * a User-Agent that names somebody else. Every setting is held to the same range\n * the environment parser enforces: `timeoutMs: 0` is the usual way of writing\n * \"no deadline\", and a retry count of a hundred thousand aimed at a site paced\n * in milliseconds is hours of traffic from a single call.\n */\nfunction withGuarantees(config: Config): Config {\n const defaults = loadConfig({});\n\n const bounded = (value: unknown, fallback: number, min: number, max: number): number => {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.min(max, Math.max(min, parsed));\n };\n\n const claimed = typeof config.userAgent === \"string\" ? config.userAgent.trim() : \"\";\n const identifier = defaults.userAgent;\n\n return {\n ...config,\n userAgent:\n claimed === \"\" || claimed.includes(identifier) ? identifier : `${claimed} ${identifier}`,\n minIntervalMs: bounded(\n config.minIntervalMs,\n defaults.minIntervalMs,\n MIN_ALLOWED_INTERVAL_MS,\n MAX_ALLOWED_INTERVAL_MS,\n ),\n timeoutMs: bounded(config.timeoutMs, defaults.timeoutMs, 1000, 120_000),\n maxRetries: bounded(config.maxRetries, defaults.maxRetries, 0, 8),\n cacheTtlMs: bounded(config.cacheTtlMs, defaults.cacheTtlMs, 0, 86_400_000),\n cacheMaxEntries: bounded(config.cacheMaxEntries, defaults.cacheMaxEntries, 1, 5000),\n logLevel: config.logLevel ?? defaults.logLevel,\n };\n}\n\n/**\n * A wall clock over one source, as a backstop under the deadline that source's\n * own reader keeps.\n *\n * That reader times out its own requests, so this only matters when it cannot:\n * a socket that stalls without erroring, a stand-in reader, a bug in a\n * dependency free to change under a caret range. Without it, one source that\n * never answers holds the whole call open and the rows the others had ready in\n * milliseconds are never returned.\n *\n * The allowance covers every attempt a reader is entitled to make, plus the\n * pacing between them, so this never fires before the reader's own deadline has.\n */\nfunction withDeadline<T>(work: Promise<T>, ms: number, source: SourceAdapter): Promise<T> {\n let timer: ReturnType<typeof setTimeout>;\n const alarm = new Promise<never>((_resolve, reject) => {\n timer = setTimeout(\n () =>\n reject(\n timedOut(\n `${source.name} did not answer within ${ms}ms and was left out of this answer.`,\n ),\n ),\n ms,\n );\n // A pending timer must not hold the process open once the answer is out.\n timer.unref?.();\n });\n return Promise.race([work, alarm]).finally(() => clearTimeout(timer));\n}\n\n/** What one source produced for one question, whether or not it produced anything. */\ninterface Attempt {\n source: SourceAdapter;\n rows: RecipeRow[];\n cached: boolean;\n reportedTotal: number | null;\n reportedTotalMeans: string | null;\n skipped: number;\n error: { code: string; message: string; hint?: string } | null;\n}\n\nfunction reportOf(attempt: Attempt, count: number): SourceReport {\n return {\n source: attempt.source.id,\n name: attempt.source.name,\n status: attempt.error ? \"failed\" : \"answered\",\n count,\n reportedTotal: attempt.reportedTotal,\n reportedTotalMeans: attempt.reportedTotalMeans,\n skipped: attempt.skipped,\n mixesReferencePages: attempt.source.mixesReferencePages,\n cached: attempt.cached,\n error: attempt.error,\n };\n}\n\n/**\n * Interleave rows so no source opens the list twice before another has opened\n * it once.\n *\n * Ranking them against each other would need a score every source carries, and\n * there is none: one has reader ratings, another has no author and no rating by\n * nature, and a single ordering would rank on a field most rows cannot have.\n * Taking one from each in turn is an order a caller can describe exactly, which\n * is what the answer does.\n */\nexport function interleave(groups: RecipeRow[][]): RecipeRow[] {\n const merged: RecipeRow[] = [];\n const longest = Math.max(0, ...groups.map((group) => group.length));\n for (let index = 0; index < longest; index += 1) {\n for (const group of groups) {\n const row = group[index];\n if (row) merged.push(row);\n }\n }\n return merged;\n}\n\nexport class RecipesClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly sources: SourceAdapter[];\n\n constructor(options: RecipesClientOptions = {}) {\n this.config = withGuarantees({ ...loadConfig(), ...options.config });\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.sources = options.sources ?? buildSources(this.config, options.readers ?? {});\n }\n\n /** What every source sees this client call itself. */\n get userAgent(): string {\n return this.config.userAgent;\n }\n\n get intervalMs(): number {\n return this.config.minIntervalMs;\n }\n\n get timeoutMs(): number {\n return this.config.timeoutMs;\n }\n\n get maxRetries(): number {\n return this.config.maxRetries;\n }\n\n /** The sources this server reads, in the order an answer takes them. */\n get profiles(): Array<{ id: SourceId; name: string; language: string; homeUrl: string }> {\n return this.sources.map((source) => ({\n id: source.id,\n name: source.name,\n language: source.language,\n homeUrl: source.homeUrl,\n }));\n }\n\n /**\n * The backstop deadline over one source, covering every attempt it is\n * entitled to make and the pacing between them.\n */\n private get deadlineMs(): number {\n const attempts = this.config.maxRetries + 1;\n return this.config.timeoutMs * attempts + this.config.minIntervalMs * this.config.maxRetries;\n }\n\n /**\n * Ask every source the same question and merge the rows.\n *\n * The calls go out together, so the answer takes as long as the slowest\n * source rather than as long as all of them. Whichever of them fail, the rest\n * still come back, and the report says which was which.\n */\n async searchRecipes(\n query: string,\n limitPerSource: number,\n wanted?: readonly SourceId[],\n ): Promise<MergedSearch> {\n const trimmed = query.trim();\n if (trimmed === \"\") {\n throw invalidInput(\"A search needs something to look for.\", \"Name a dish or an ingredient.\");\n }\n\n const chosen = selectSources(this.sources, wanted);\n const limit = Math.max(1, Math.min(MAX_LIMIT_PER_SOURCE, Math.trunc(limitPerSource)));\n\n const attempts = await Promise.all(\n chosen.map((source) => this.searchOne(source, trimmed, limit)),\n );\n\n const groups = attempts.map((attempt) => attempt.rows.slice(0, limit));\n return {\n rows: interleave(groups),\n reports: attempts.map((attempt, index) => reportOf(attempt, groups[index]!.length)),\n };\n }\n\n private async searchOne(\n source: SourceAdapter,\n query: string,\n limit: number,\n ): Promise<Attempt> {\n try {\n const read = await withDeadline(source.search(query, limit), this.deadlineMs, source);\n if (read.skipped > 0) {\n this.logger.warn(\n `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`,\n );\n }\n return {\n source,\n rows: read.rows,\n cached: read.cached,\n reportedTotal: read.reportedTotal,\n reportedTotalMeans: read.reportedTotalMeans,\n skipped: read.skipped,\n error: null,\n };\n } catch (error) {\n const known = toRecipesError(error);\n this.logger.warn(`${source.name} did not answer: [${known.code}] ${known.message}`);\n return {\n source,\n rows: [],\n cached: false,\n reportedTotal: null,\n reportedTotalMeans: null,\n skipped: 0,\n error: {\n code: known.code,\n message: known.message,\n ...(known.details.hint ? { hint: known.details.hint } : {}),\n },\n };\n }\n }\n\n /**\n * Read one recipe, from whichever source the identifier names.\n *\n * Only that source is called. Trying another after a miss would turn \"this\n * source has no page by this name\" into somebody else's recipe with a\n * different title, which is a worse answer than the absence it replaced.\n */\n async getRecipe(id: string): Promise<{ recipe: RecipeDetail; cached: boolean; read: ResolvedId }> {\n const read = resolveId(id, this.sources);\n try {\n const outcome = await withDeadline(\n read.source.getRecipe(read.reference),\n this.deadlineMs,\n read.source,\n );\n return { recipe: outcome.recipe, cached: outcome.cached, read };\n } catch (error) {\n throw toRecipesError(error);\n }\n }\n}\n"],"mappings":"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","names":["SITE_URL","count"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-recipes",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server that asks Marmiton and the Wikibooks Cookbook at once, merges the answers, and scales quantities in French and English. No API key required.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"mcp-server",
|
|
9
|
+
"recipes",
|
|
10
|
+
"cooking",
|
|
11
|
+
"marmiton",
|
|
12
|
+
"wikibooks",
|
|
13
|
+
"cookbook",
|
|
14
|
+
"ingredient-scaling",
|
|
15
|
+
"bilingual"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/smeet666/mcp-recipes#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/smeet666/mcp-recipes/issues"
|
|
20
|
+
},
|
|
21
|
+
"funding": {
|
|
22
|
+
"type": "buymeacoffee",
|
|
23
|
+
"url": "https://buymeacoffee.com/smeet666"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/smeet666/mcp-recipes.git"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "Damien Bouissou",
|
|
31
|
+
"type": "module",
|
|
32
|
+
"bin": {
|
|
33
|
+
"mcp-recipes": "dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./dist/index.js",
|
|
37
|
+
"./client": {
|
|
38
|
+
"types": "./dist/sources/client.d.ts",
|
|
39
|
+
"import": "./dist/sources/client.js"
|
|
40
|
+
},
|
|
41
|
+
"./scale": {
|
|
42
|
+
"types": "./dist/recipe/scale.d.ts",
|
|
43
|
+
"import": "./dist/recipe/scale.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE",
|
|
50
|
+
"CHANGELOG.md"
|
|
51
|
+
],
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20.0.0"
|
|
54
|
+
},
|
|
55
|
+
"mcpName": "io.github.smeet666/mcp-recipes",
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsup",
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"test": "vitest run test/unit",
|
|
60
|
+
"test:live": "vitest run test/live --testTimeout=120000",
|
|
61
|
+
"format": "prettier --write .",
|
|
62
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build",
|
|
63
|
+
"start": "node dist/index.js",
|
|
64
|
+
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
68
|
+
"mcp-marmiton": "^1.5.0",
|
|
69
|
+
"mcp-wikibooks-cookbook": "^1.0.0",
|
|
70
|
+
"zod": "^4.4.3"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@types/node": "^22.18.0",
|
|
74
|
+
"prettier": "^3.9.6",
|
|
75
|
+
"tsup": "^8.5.1",
|
|
76
|
+
"typescript": "^5.9.3",
|
|
77
|
+
"vitest": "^4.1.10"
|
|
78
|
+
}
|
|
79
|
+
}
|