mcp-metacritic 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/LICENSE +21 -0
- package/README.md +345 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1526 -0
- package/dist/index.js.map +1 -0
- package/dist/mc/client.d.ts +221 -0
- package/dist/mc/client.js +889 -0
- package/dist/mc/client.js.map +1 -0
- package/package.json +81 -0
- package/server.json +35 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/version.ts","../../src/config.ts","../../src/mc/cache.ts","../../src/errors.ts","../../src/mc/rateLimiter.ts","../../src/mc/http.ts","../../src/mc/paths.ts","../../src/mc/urls.ts","../../src/mc/parse.ts","../../src/mc/client.ts"],"sourcesContent":["/**\n * Kept in sync with package.json and server.json. The User-Agent embeds it, so\n * a caller reading their logs can tell which build reached them.\n */\nexport const PKG_VERSION = \"1.0.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-metacritic\";\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 reported on stderr and ignored.\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 scoresCacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\nexport const DEFAULT_USER_AGENT = `mcp-metacritic v${PKG_VERSION} (${REPO_URL})`;\n\nexport const DEFAULTS = {\n minIntervalMs: 1000,\n timeoutMs: 15_000,\n maxRetries: 3,\n // A catalogue entry moves when a title is edited, which is rare, so a day is\n // safe. The cache lives in memory and dies with the process, so this is a\n // ceiling on one session rather than a stored copy of the site.\n cacheTtlMs: 24 * 60 * 60 * 1000,\n // Scores and reviews move as reviews come in, especially around a release,\n // so they get their own much shorter lifetime.\n scoresCacheTtlMs: 60 * 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 * Metacritic publishes no rate limit and did not throttle ten consecutive\n * requests during testing. That silence is a reason to pace deliberately rather\n * than an invitation to go fast, so a value below this floor is refused.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n\n/** Ceiling on the request interval, so a typo cannot stall the server for hours. */\nexport const MAX_ALLOWED_INTERVAL_MS = 60_000;\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-metacritic] ${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 1000, 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.MC_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(`MC_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 `MC_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; ` +\n `using ${DEFAULTS.minIntervalMs}ms. Metacritic publishes no rate limit, so this client ` +\n `sets its own.`,\n );\n return DEFAULTS.minIntervalMs;\n }\n\n // The upper bound is a guard against a typo that would stall the server for\n // hours. Unlike readNumber, it clamps rather than falling back, because the\n // default would be far faster than the value asked for: someone who wrote ten\n // minutes wants slow, and answering that with 1000ms gets politeness backwards.\n if (rounded > MAX_ALLOWED_INTERVAL_MS) {\n warn(\n `MC_MIN_INTERVAL_MS=${raw} exceeds the ${MAX_ALLOWED_INTERVAL_MS}ms ceiling; ` +\n `using ${MAX_ALLOWED_INTERVAL_MS}ms.`,\n );\n return MAX_ALLOWED_INTERVAL_MS;\n }\n\n return rounded;\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const rawUserAgent = env.MC_USER_AGENT?.trim();\n const rawLogLevel = env.MC_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(`MC_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(\"MC_TIMEOUT_MS\", env, {\n min: 1000,\n max: 120_000,\n fallback: DEFAULTS.timeoutMs,\n }),\n maxRetries: readNumber(\"MC_MAX_RETRIES\", env, {\n min: 0,\n max: 10,\n fallback: DEFAULTS.maxRetries,\n }),\n cacheTtlMs: readNumber(\"MC_CACHE_TTL_MS\", env, {\n min: 0,\n max: 7 * 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.cacheTtlMs,\n }),\n scoresCacheTtlMs: readNumber(\"MC_SCORES_CACHE_TTL_MS\", env, {\n min: 0,\n max: 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.scoresCacheTtlMs,\n }),\n cacheMaxEntries: readNumber(\"MC_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-metacritic] ${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 title inside one conversation do not hit Metacritic again, not to\n * build a local copy of their catalogue.\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 this file exists to enforce: a failure must never be reported as an\n * empty result. A model that sees \"no title found\" cannot tell that apart from\n * a genuine absence, and will confidently tell the user the film does not exist.\n *\n * Metacritic makes that distinction readable. A successful response carries a\n * `data` object; a failure carries an `errors` array instead, with its own code.\n * Anything matching neither shape is a parse failure rather than an empty answer.\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 McError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"McError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-metacritic/issues\";\n\nexport function notFound(url: string, what: string): McError {\n return new McError(\"not_found\", `Metacritic has no entry for ${what}.`, {\n url,\n status: 404,\n hint: \"Use search_titles to find an entry, then pass its slug and kind to this tool.\",\n });\n}\n\nexport function invalidInput(message: string, hint?: string): McError {\n return new McError(\"invalid_input\", message, hint ? { hint } : {});\n}\n\nexport function rateLimited(url: string, retryAfterMs: number): McError {\n return new McError(\n \"rate_limited\",\n \"Metacritic is refusing requests from this client for now. This does NOT mean the title is missing.\",\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 MC_MIN_INTERVAL_MS.\",\n },\n );\n}\n\n/**\n * Raised when a response arrives but does not have the shape this server reads.\n *\n * Metacritic publishes no API documentation, so its response shapes can change\n * without notice. Failing here is what turns such a change into a report rather\n * than into answers that look empty.\n */\nexport function parseFailure(url: string, what: string): McError {\n return new McError(\n \"parse_failure\",\n `Metacritic answered, but the response did not have the expected shape (${what}).`,\n { url, hint: `Please report this, with the request you made, at ${ISSUES_URL}` },\n );\n}\n\nexport function upstreamError(url: string, status: number): McError {\n return new McError(\"network_error\", `Metacritic returned HTTP ${status}.`, {\n url,\n status,\n ...(status >= 500\n ? { hint: \"This is a problem on Metacritic's side. Try again shortly.\" }\n : {}),\n });\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * Metacritic publishes no rate limit and did not throttle consecutive requests\n * during testing, so the gap between requests is a choice this client makes\n * rather than a rule it is given. Requests run one at a time with a floor on\n * that gap. If the site ever does push back, the interval doubles and then\n * decays back down as requests succeed, which recovers faster than a fixed\n * delay and 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 /**\n * Queue a task. Tasks run in call order, one at a time.\n *\n * A task may send several requests, since the retry loop lives inside its\n * slot. It must call `beforeRequest` around each of them: the pacing is\n * measured between requests, and stamping only the start of the task would\n * let the next one follow a long retry chain's final request immediately.\n */\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, so every\n * request is paced from the previous one whether or not they belong to the\n * same retry chain.\n */\n async beforeRequest(): Promise<void> {\n await this.waitForSlot();\n this.lastStart = Date.now();\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n // Clamped to the interval: a clock stepped backwards, by NTP or a resumed\n // VM, would otherwise make this wait for the size of the step, and the\n // queue is serial so every pending request would wait behind it.\n const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, with backoff.\n *\n * Status codes are meaningful here, so they are read: 404 marks an unknown\n * slug, 429 and 503 mark push-back, and 5xx is worth retrying. What this layer\n * deliberately does not do is inspect the body, which is the parser's job.\n *\n * Metacritic advertises no rate limit and did not throttle during testing, so\n * the pacing is entirely self-imposed and the retry path is written for the day\n * that changes rather than for observed behaviour.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { McError, notFound, parseFailure, 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/** 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 URL as text, retrying transient conditions.\n *\n * The retry loop runs inside a single limiter slot, so a queued request cannot\n * interleave with a chain that is backing off. Each attempt claims its own slot\n * through `beforeRequest`, which is what keeps the pacing honest between the\n * last request of one chain and the first request of the next.\n */\nexport async function fetchText(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: McError | 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 = askedWaitMs ?? backoffDelay(attempt - 1);\n askedWaitMs = null;\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(Math.min(delay, BACKOFF_MAX_MS));\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: \"application/json,*/*;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n retryAfterMs = parseRetryAfter(response.headers.get(\"retry-after\"));\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n if (status === 429 || status === 503 || status === 403) {\n limiter.penalize();\n // A server that says when to come back knows better than our own guess.\n askedWaitMs = retryAfterMs;\n lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));\n logger.info(\n `refused on ${url} with ${status}, interval now ${limiter.currentIntervalMs}ms`,\n );\n continue;\n }\n if (status >= 500) {\n lastError = upstreamError(url, status);\n continue;\n }\n // An unknown slug is answered with a 404 carrying an error envelope. It is\n // reported as an absence rather than as a transport problem, because the\n // caller asked for something specific and needs to know it is not there.\n if (status === 404) throw notFound(url, \"that request\");\n if (status >= 400) throw upstreamError(url, status);\n\n // An empty body is not a valid answer from any of these endpoints, and is\n // how a stressed edge sometimes refuses. Retrying is safer than handing an\n // empty document to the parser, which would read as \"nothing found\".\n const trimmed = body.trim();\n if (trimmed === \"\") {\n limiter.penalize();\n lastError = rateLimited(url, backoffDelay(attempt));\n logger.info(`empty body on ${url}, treating as a refusal`);\n continue;\n }\n\n // Every route answers with JSON. An HTML body under a success status is\n // the edge answering instead of the application, usually a challenge or\n // an error page, and it clears on its own. Retrying is right; reporting a\n // parse failure would send the caller to the bug tracker for an outage.\n if (!trimmed.startsWith(\"{\") && !trimmed.startsWith(\"[\")) {\n limiter.penalize();\n // Retried because an edge challenge clears on its own, but reported as a\n // parse failure if it never does: a body that is persistently not JSON\n // means the shape moved, and that is worth a bug report.\n lastError = parseFailure(url, \"the body is not JSON\");\n askedWaitMs = retryAfterMs;\n logger.info(`non-JSON body on ${url}, retrying`);\n continue;\n }\n\n limiter.relax();\n return body;\n }\n\n throw lastError ?? new McError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\n/** `Retry-After` carries either seconds or an HTTP date. */\nfunction parseRetryAfter(raw: string | null): number | null {\n if (!raw) return null;\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);\n const when = Date.parse(raw);\n if (Number.isNaN(when)) return null;\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, url: string): McError {\n if (error instanceof McError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new McError(\"timeout\", \"Metacritic did not answer in time.\", {\n url,\n hint: \"Raise MC_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 McError(\"network_error\", `Could not reach Metacritic: ${message}`, { url });\n}\n","/**\n * Every route and every response field this server reads, in one place.\n *\n * Metacritic documents none of this: the routes are the ones its own web\n * frontend calls, and the field names come from reading those responses. That\n * makes a rename upstream both likely and silent, so collecting them here keeps\n * the repair to a single file, and the live canary asserts each one by name so\n * a change surfaces as a named failure rather than as empty answers.\n */\n\nexport const API_BASE = \"https://backend.metacritic.com\";\nexport const SITE_BASE = \"https://www.metacritic.com\";\n\n/**\n * The API and the website disagree on plurality: the backend serves /movies/,\n * /shows/ and /games/, while the pages a reader should be linked to live at\n * /movie/, /tv/ and /game/. Both forms are needed and neither can be derived\n * from the other, so both are written out.\n */\nexport const API_SEGMENT = {\n movie: \"movies\",\n show: \"shows\",\n game: \"games\",\n} as const;\n\nexport const SITE_SEGMENT = {\n movie: \"movie\",\n show: \"tv\",\n game: \"game\",\n} as const;\n\n/** Internal type ids the browse route filters on. */\nexport const MCO_TYPE_ID = {\n show: 1,\n movie: 2,\n game: 13,\n} as const;\n\n/** The `type` string search results carry, which does not match our own naming. */\nexport const SEARCH_TYPE = {\n movie: \"movie\",\n show: \"show\",\n game: \"game-title\",\n} as const;\n\n/** Sort orders the browse route accepts. */\nexport const SORT_BY = {\n score: \"-metaScore\",\n recent: \"-releaseDate\",\n popular: \"-popularityCount\",\n} as const;\n\n/**\n * Component identifiers the backend requires on several routes. They describe\n * the widget the response was built for, and requests omitting them are refused.\n */\nexport const COMPONENT = {\n product: { componentName: \"product\", componentType: \"Product\" },\n criticStats: { componentName: \"critic-score-summary\", componentType: \"MetaScoreSummary\" },\n userStats: { componentName: \"user-score-summary\", componentType: \"MetaScoreSummary\" },\n criticReviews: { componentName: \"critic-reviews\", componentType: \"ProductReviewsSummary\" },\n userReviews: { componentName: \"user-reviews\", componentType: \"ProductReviewsSummary\" },\n} as const;\n\n/** Field names read off responses. */\nexport const FIELD = {\n data: \"data\",\n errors: \"errors\",\n item: \"item\",\n items: \"items\",\n totalResults: \"totalResults\",\n /** Reviews arrive pre-sorted into these buckets, which is the sentiment filter. */\n reviewBuckets: { all: \"default\", positive: \"positive\", neutral: \"neutral\", negative: \"negative\" },\n} as const;\n","/**\n * URL building.\n *\n * The API lives on a backend host, while the pages a reader should be sent to\n * live on the main site. Attribution links must point at the latter, which is\n * the only thing this server gives back in exchange for the data.\n */\n\nimport { invalidInput } from \"../errors.js\";\nimport type { Kind, Sentiment } from \"../types.js\";\nimport {\n API_BASE,\n API_SEGMENT,\n COMPONENT,\n MCO_TYPE_ID,\n SITE_BASE,\n SITE_SEGMENT,\n SORT_BY,\n} from \"./paths.js\";\n\ntype Params = Record<string, string | number | undefined>;\n\nfunction build(path: string, params: Params): string {\n const query = new URLSearchParams();\n for (const [key, value] of Object.entries(params)) {\n if (value !== undefined && value !== \"\") query.set(key, String(value));\n }\n const suffix = query.toString();\n return suffix ? `${API_BASE}${path}?${suffix}` : `${API_BASE}${path}`;\n}\n\n/**\n * Slugs go into the path, so a malformed one would silently change which route\n * is called. Rejecting here keeps that from reaching the network.\n */\nfunction checkSlug(slug: string): string {\n const trimmed = slug.trim();\n if (trimmed === \"\") {\n throw invalidInput(\"The slug is empty.\", \"Take it from a search_titles result.\");\n }\n if (!/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) {\n throw invalidInput(\n `\"${slug}\" does not look like a Metacritic slug.`,\n \"Slugs are lowercase words joined by hyphens, such as the-matrix. Use search_titles to get one.\",\n );\n }\n return trimmed;\n}\n\nexport function searchUrl(query: string, limit: number, offset: number): string {\n const trimmed = query.trim();\n if (trimmed === \"\") {\n throw invalidInput(\"The search query is empty.\", \"Pass a title or part of one.\");\n }\n return build(`/finder/metacritic/search/${encodeURIComponent(trimmed)}/web`, { limit, offset });\n}\n\nexport function browseUrl(options: {\n kind: Kind;\n sort: keyof typeof SORT_BY;\n genre?: string;\n limit: number;\n offset: number;\n}): string {\n return build(\"/finder/metacritic/web\", {\n sortBy: SORT_BY[options.sort],\n mcoTypeId: MCO_TYPE_ID[options.kind],\n genres: options.genre,\n limit: options.limit,\n offset: options.offset,\n });\n}\n\nexport function detailUrl(kind: Kind, slug: string): string {\n return build(`/${API_SEGMENT[kind]}/metacritic/${checkSlug(slug)}/web`, COMPONENT.product);\n}\n\nexport function scoreUrl(kind: Kind, slug: string, source: \"critic\" | \"user\"): string {\n const component = source === \"critic\" ? COMPONENT.criticStats : COMPONENT.userStats;\n return build(\n `/reviews/metacritic/${source}/${API_SEGMENT[kind]}/${checkSlug(slug)}/stats/web`,\n component,\n );\n}\n\n/**\n * Reviews for one entry.\n *\n * No limit or offset is sent. The route ignores both and always answers with\n * the same sample, so including them would only vary the URL, and with it the\n * cache key, making every page of a sample already held in memory cost a fresh\n * request. Paging happens over the sample the caller already has.\n */\nexport function reviewsUrl(options: {\n kind: Kind;\n slug: string;\n source: \"critic\" | \"user\";\n}): string {\n const component = options.source === \"critic\" ? COMPONENT.criticReviews : COMPONENT.userReviews;\n return build(\n `/reviews/metacritic/${options.source}/${API_SEGMENT[options.kind]}/${checkSlug(options.slug)}/summary/web`,\n component,\n );\n}\n\n/** Streaming offers, keyed by IMDb id rather than by Metacritic slug. */\nexport function watchUrl(imdbId: string, kind: Kind): string {\n const trimmed = imdbId.trim();\n if (!/^tt\\d+$/.test(trimmed)) {\n throw invalidInput(\n `\"${imdbId}\" is not an IMDb id.`,\n \"IMDb ids look like tt0133093. get_title returns one as imdb_id.\",\n );\n }\n return build(\"/justwatch/metacritic/offersByIMDbId\", {\n id: trimmed,\n type: kind === \"show\" ? \"show\" : \"movie\",\n });\n}\n\n/** The page a reader should be sent to, and the link attribution requires. */\nexport function titlePageUrl(kind: Kind, slug: string): string {\n return `${SITE_BASE}/${SITE_SEGMENT[kind]}/${slug}/`;\n}\n\n/** Which pre-sorted bucket of reviews to read. */\nexport function bucketFor(sentiment: Sentiment): string {\n return sentiment === \"all\" ? \"default\" : sentiment;\n}\n","/**\n * Responses to domain types.\n *\n * Every route answers with the same envelope: `{\"data\": …}` on success, or\n * `{\"errors\":[{\"code\":404,…}]}` on failure. Reading that envelope is the whole\n * job of the first half of this file, because getting it wrong is how a failure\n * turns into an answer that looks empty.\n *\n * The second half maps fields. Listing rows are trimmed here rather than in a\n * tool, so the heavy payload has no path out of this module.\n */\n\nimport { notFound, parseFailure } from \"../errors.js\";\nimport type {\n Award,\n Company,\n CriticReview,\n Kind,\n ReviewPage,\n ScoreSummary,\n Sentiment,\n TitleDetail,\n TitlePage,\n TitleSummary,\n UserReview,\n WatchOffer,\n} from \"../types.js\";\nimport { FIELD, SEARCH_TYPE } from \"./paths.js\";\nimport { bucketFor, titlePageUrl } from \"./urls.js\";\n\ntype Json = Record<string, unknown>;\n\nconst isObject = (value: unknown): value is Json =>\n typeof value === \"object\" && value !== null && !Array.isArray(value);\n\n/**\n * Read the envelope and return the payload.\n *\n * An `errors` array is the site saying it could not serve the request, which is\n * an absence when the caller named something specific. Anything that is neither\n * shape means the response is not what this server knows how to read.\n */\nexport function envelope(raw: string, url: string, what: string): Json {\n let body: unknown;\n try {\n body = JSON.parse(raw);\n } catch {\n throw parseFailure(url, \"the body is not valid JSON\");\n }\n\n if (!isObject(body)) throw parseFailure(url, \"the body is not an object\");\n\n const errors = body[FIELD.errors];\n if (Array.isArray(errors) && errors.length > 0) {\n const first = isObject(errors[0]) ? errors[0] : {};\n if (first.code === 404) throw notFound(url, what);\n throw parseFailure(\n url,\n `the site reported ${String(first.reason ?? first.code ?? \"an error\")}`,\n );\n }\n\n const data = body[FIELD.data];\n if (!isObject(data)) throw parseFailure(url, \"the response carries no data object\");\n return data;\n}\n\nconst str = (value: unknown): string | null => {\n if (typeof value !== \"string\") return null;\n const trimmed = value.trim();\n return trimmed === \"\" ? null : trimmed;\n};\n\nconst num = (value: unknown): number | null => {\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n if (typeof value === \"string\") {\n const parsed = Number(value);\n if (Number.isFinite(parsed)) return parsed;\n }\n return null;\n};\n\nconst intOf = (value: unknown): number | null => {\n const parsed = num(value);\n return parsed === null ? null : Math.trunc(parsed);\n};\n\n/** Map the site's own type wording onto ours. */\nfunction kindOf(value: unknown): Kind | null {\n const type = str(value);\n if (type === SEARCH_TYPE.movie) return \"movie\";\n if (type === SEARCH_TYPE.show) return \"show\";\n if (type === SEARCH_TYPE.game) return \"game\";\n return null;\n}\n\n/**\n * A row without an id, a slug or a kind cannot be looked up or linked, so it is\n * not a row. Returning null lets the caller skip one bad entry among many while\n * still failing loudly when every entry is unreadable.\n */\nfunction toSummary(node: unknown): TitleSummary | null {\n if (!isObject(node)) return null;\n\n const id = intOf(node.id);\n const slug = str(node.slug);\n const title = str(node.title);\n const kind = kindOf(node.type);\n if (id === null || !slug || !title || !kind) return null;\n\n const critic = isObject(node.criticScoreSummary) ? node.criticScoreSummary : {};\n\n // The two listing routes report the audience score differently: browse rows\n // carry `userScore: {score}`, search rows carry nothing at all. Both forms are\n // read, and a row with neither keeps null rather than inventing a zero.\n const userSummary = isObject(node.userScoreSummary) ? node.userScoreSummary : {};\n const userObject = isObject(node.userScore) ? node.userScore : {};\n const userScore = num(userSummary.score) ?? num(userObject.score) ?? num(node.userScore);\n\n return {\n id,\n kind,\n title,\n slug,\n year: intOf(node.premiereYear),\n releaseDate: str(node.releaseDate),\n rating: str(node.rating),\n metascore: num(critic.score),\n userScore,\n sourceUrl: titlePageUrl(kind, slug),\n };\n}\n\n/** Search and browse share this shape, so they share one reader. */\nexport function parseTitlePage(raw: string, url: string, what: string): TitlePage {\n const data = envelope(raw, url, what);\n const items = data[FIELD.items];\n if (!Array.isArray(items)) throw parseFailure(url, \"the response carries no items array\");\n\n const titles: TitleSummary[] = [];\n for (const node of items) {\n const row = toSummary(node);\n if (row) titles.push(row);\n }\n\n if (items.length > 0 && titles.length === 0) {\n throw parseFailure(url, `${items.length} rows but none could be read`);\n }\n\n return {\n titles,\n totalResults: intOf(data[FIELD.totalResults]) ?? titles.length,\n itemCount: items.length,\n };\n}\n\n/**\n * Companies attached to a title, deduplicated by name.\n *\n * The list mixes producers with every distributor, broadcaster and home-video\n * label in every territory, so a well-travelled film carries the same name a\n * dozen times under different ids: Blade Runner lists 128 entries of which\n * \"Warner Home Video\" is fifteen. The first id for a name is kept, since they\n * differ only by the release they were attached to.\n */\nfunction toCompanies(value: unknown): Company[] {\n const source = isObject(value) ? value.companies : value;\n if (!Array.isArray(source)) return [];\n\n const seen = new Set<string>();\n const out: Company[] = [];\n for (const node of source) {\n const name = isObject(node) ? str(node.name) : str(node);\n if (!name) continue;\n const key = name.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n out.push({ name, id: isObject(node) ? intOf(node.id) : null });\n }\n return out;\n}\n\nfunction toStringList(value: unknown): string[] {\n if (!Array.isArray(value)) return [];\n const out: string[] = [];\n for (const node of value) {\n const name = isObject(node) ? str(node.name) : str(node);\n if (name) out.push(name);\n }\n return out;\n}\n\n/**\n * Awards are reported one entry per ceremony, as a tally rather than a list of\n * individual prizes. A ceremony where nothing was won carries `wins: null`, so\n * that is preserved instead of being flattened to zero: \"no win recorded\" and\n * \"zero wins\" are the same here, but only the source can say which it meant.\n */\nfunction toAwards(value: unknown): Award[] {\n if (!Array.isArray(value)) return [];\n const out: Award[] = [];\n for (const node of value) {\n if (!isObject(node)) continue;\n const ceremony = str(node.awardEvent);\n if (!ceremony) continue;\n out.push({\n ceremony,\n wins: intOf(node.wins),\n nominations: intOf(node.nominations),\n });\n }\n return out;\n}\n\nexport function parseDetail(raw: string, url: string, kind: Kind, slug: string): TitleDetail {\n const data = envelope(raw, url, `${kind} \"${slug}\"`);\n const item = data[FIELD.item];\n if (!isObject(item)) throw parseFailure(url, \"the response carries no item object\");\n\n // The detail route omits `type`, so the kind comes from the caller, who chose\n // the route. Building the summary by hand keeps that difference explicit.\n const id = intOf(item.id);\n const title = str(item.title);\n if (id === null || !title) throw parseFailure(url, \"the entry has no id or no title\");\n\n const critic = isObject(item.criticScoreSummary) ? item.criticScoreSummary : {};\n\n return {\n id,\n kind,\n title,\n slug,\n year: intOf(item.premiereYear),\n releaseDate: str(item.releaseDate),\n rating: str(item.rating),\n metascore: num(critic.score),\n userScore: null,\n sourceUrl: titlePageUrl(kind, slug),\n description: str(item.description),\n tagline: str(item.tagline),\n genres: toStringList(item.genres),\n duration: intOf(item.duration),\n imdbId: str(item.imdbId),\n networks: toStringList(item.networks),\n production: toCompanies(item.production),\n awards: toAwards(item.awards),\n criticScoreDetail: null,\n userScoreDetail: null,\n };\n}\n\n/**\n * A score summary, with its scale.\n *\n * `max` comes from the response rather than from the source name, because the\n * two scales differ and hard-coding them here would be a guess that survives\n * quietly if the site ever changes one.\n */\nexport function parseScore(raw: string, url: string, what: string): ScoreSummary {\n const data = envelope(raw, url, what);\n const item = data[FIELD.item];\n if (!isObject(item)) throw parseFailure(url, \"the response carries no score object\");\n\n const max = num(item.max);\n if (max === null) throw parseFailure(url, \"the score carries no scale\");\n\n return {\n score: num(item.score),\n max,\n reviewCount: intOf(item.reviewCount),\n positiveCount: intOf(item.positiveCount),\n neutralCount: intOf(item.neutralCount),\n negativeCount: intOf(item.negativeCount),\n sentiment: str(item.sentiment),\n };\n}\n\nfunction reviewBucket(data: Json, url: string, sentiment: Sentiment): unknown[] {\n const item = data[FIELD.item];\n if (!isObject(item)) throw parseFailure(url, \"the response carries no reviews object\");\n\n const bucket = item[bucketFor(sentiment)];\n // A bucket that does not exist means the site stopped grouping reviews this\n // way, which is a change worth reporting rather than an absence of reviews.\n if (!Array.isArray(bucket)) {\n throw parseFailure(url, `there is no \"${bucketFor(sentiment)}\" group of reviews`);\n }\n return bucket;\n}\n\nexport function parseCriticReviews(\n raw: string,\n url: string,\n what: string,\n sentiment: Sentiment,\n): ReviewPage<CriticReview> {\n const data = envelope(raw, url, what);\n const bucket = reviewBucket(data, url, sentiment);\n\n const reviews: CriticReview[] = [];\n for (const node of bucket) {\n if (!isObject(node)) continue;\n const quote = str(node.quote);\n const publication = str(node.publicationName);\n // A quote with no publication cannot be attributed, and attribution is the\n // condition under which this content is worth passing on at all.\n if (!quote || !publication) continue;\n reviews.push({\n quote,\n score: num(node.score),\n publication,\n author: str(node.author),\n url: str(node.url),\n date: str(node.date),\n });\n }\n\n if (bucket.length > 0 && reviews.length === 0) {\n throw parseFailure(url, `${bucket.length} reviews but none could be read`);\n }\n\n return {\n reviews,\n totalResults: intOf(data[FIELD.totalResults]) ?? reviews.length,\n itemCount: bucket.length,\n };\n}\n\nexport function parseUserReviews(\n raw: string,\n url: string,\n what: string,\n sentiment: Sentiment,\n): ReviewPage<UserReview> {\n const data = envelope(raw, url, what);\n const bucket = reviewBucket(data, url, sentiment);\n\n const reviews: UserReview[] = [];\n for (const node of bucket) {\n if (!isObject(node)) continue;\n const quote = str(node.quote);\n if (!quote) continue;\n reviews.push({ quote, score: num(node.score), date: str(node.date) });\n }\n\n if (bucket.length > 0 && reviews.length === 0) {\n throw parseFailure(url, `${bucket.length} reviews but none could be read`);\n }\n\n return {\n reviews,\n totalResults: intOf(data[FIELD.totalResults]) ?? reviews.length,\n itemCount: bucket.length,\n };\n}\n\n/**\n * Unwrap a click-tracking redirect.\n *\n * Offer links arrive as tracking URLs of about 800 characters, most of it a\n * base64 analytics payload, with the real destination in the `r` parameter.\n * Nine offers on one film came to 7 KB of redirects against 300 bytes of actual\n * links, so the destination is extracted and the wrapper dropped.\n */\nfunction unwrapLink(link: string | null): string | null {\n if (!link) return null;\n try {\n const target = new URL(link).searchParams.get(\"r\");\n if (target && /^https?:\\/\\//i.test(target)) return target;\n } catch {\n // Not a URL this function understands; the original is still usable.\n }\n return link;\n}\n\n/** Streaming offers, grouped upstream by how you pay for them. */\nexport function parseOffers(raw: string, url: string, what: string): WatchOffer[] {\n const data = envelope(raw, url, what);\n const item = data[FIELD.item];\n if (!isObject(item)) throw parseFailure(url, \"the response carries no offers object\");\n\n const offers: WatchOffer[] = [];\n for (const [group, value] of Object.entries(item)) {\n if (!Array.isArray(value)) continue;\n for (const node of value) {\n if (!isObject(node)) continue;\n const provider = str(node.providerName);\n if (!provider) continue;\n offers.push({ provider, kind: group, url: unwrapLink(str(node.link)) });\n }\n }\n return offers;\n}\n","/**\n * High-level Metacritic client.\n *\n * This module knows nothing about MCP, which keeps it testable against plain\n * strings and usable as a library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n DEFAULT_USER_AGENT,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport type {\n CriticReview,\n Kind,\n ReviewPage,\n ScoreSummary,\n Sentiment,\n TitleDetail,\n TitlePage,\n UserReview,\n WatchOffer,\n} from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchText } from \"./http.js\";\nimport {\n parseCriticReviews,\n parseDetail,\n parseOffers,\n parseScore,\n parseTitlePage,\n parseUserReviews,\n} from \"./parse.js\";\nimport type { SORT_BY } from \"./paths.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { browseUrl, detailUrl, reviewsUrl, scoreUrl, searchUrl, watchUrl } from \"./urls.js\";\n\nexport interface McClientOptions {\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 type Sort = keyof typeof SORT_BY;\n\n/**\n * Apply the guarantees this project makes about its own traffic.\n *\n * The environment parser already enforces both, but `McClient` is published as\n * a 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. They are the two things this server offers in exchange for\n * reading a source that publishes no terms, so they hold on every path.\n *\n * A caller may still name their own application in the User-Agent, and there\n * are good reasons to. Passing the traffic off as a browser is a different\n * thing, and gets the project's own identity appended so it stays attributable.\n */\nfunction withGuarantees(config: Config): Config {\n const userAgent = /mozilla\\/|applewebkit|chrome\\/|safari\\/|gecko/i.test(config.userAgent)\n ? `${config.userAgent} ${DEFAULT_USER_AGENT}`\n : config.userAgent;\n return {\n ...config,\n userAgent,\n minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs),\n };\n}\n\nexport class McClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n /**\n * Two caches, because the two kinds of data age at different speeds. A\n * catalogue entry changes when someone edits it, which is rare. A score moves\n * every time a review lands, which around a release is constantly.\n */\n private readonly catalogueCache: TtlLruCache<unknown>;\n private readonly scoresCache: TtlLruCache<unknown>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: McClientOptions = {}) {\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.catalogueCache = new TtlLruCache<unknown>(\n this.config.cacheMaxEntries,\n this.config.cacheTtlMs,\n );\n this.scoresCache = new TtlLruCache<unknown>(\n this.config.cacheMaxEntries,\n this.config.scoresCacheTtlMs,\n );\n this.fetchImpl = options.fetchImpl;\n }\n\n /**\n * Search across films, shows and games.\n *\n * The return type carries rows only. The upstream response embeds artwork\n * variants on every match, and dropping them here rather than in a tool means\n * they have no path out of this layer.\n */\n async search(query: string, limit: number, offset: number): Promise<Outcome<TitlePage>> {\n const url = searchUrl(query, limit, offset);\n return this.fetchParsed(url, this.catalogueCache, (body) =>\n parseTitlePage(body, url, `the query \"${query}\"`),\n );\n }\n\n async browse(options: {\n kind: Kind;\n sort: Sort;\n genre?: string;\n limit: number;\n offset: number;\n }): Promise<Outcome<TitlePage>> {\n const url = browseUrl(options);\n return this.fetchParsed(url, this.catalogueCache, (body) =>\n parseTitlePage(body, url, `that ${options.kind} listing`),\n );\n }\n\n async getDetail(kind: Kind, slug: string): Promise<Outcome<TitleDetail>> {\n const url = detailUrl(kind, slug);\n // The trimmed form is what reached the network, so it is also what the\n // public link and the echoed slug must be built from.\n const clean = slug.trim();\n return this.fetchParsed(url, this.catalogueCache, (body) =>\n parseDetail(body, url, kind, clean),\n );\n }\n\n async getScore(\n kind: Kind,\n slug: string,\n source: \"critic\" | \"user\",\n ): Promise<Outcome<ScoreSummary>> {\n const url = scoreUrl(kind, slug, source);\n return this.fetchParsed(url, this.scoresCache, (body) =>\n parseScore(body, url, `the ${source} score of ${kind} \"${slug}\"`),\n );\n }\n\n async getCriticReviews(options: {\n kind: Kind;\n slug: string;\n sentiment: Sentiment;\n limit: number;\n offset: number;\n }): Promise<Outcome<ReviewPage<CriticReview>>> {\n const url = reviewsUrl({ ...options, source: \"critic\" });\n return this.fetchParsed(\n url,\n this.scoresCache,\n (body) =>\n parseCriticReviews(\n body,\n url,\n `reviews of ${options.kind} \"${options.slug}\"`,\n options.sentiment,\n ),\n options.sentiment,\n );\n }\n\n async getUserReviews(options: {\n kind: Kind;\n slug: string;\n sentiment: Sentiment;\n limit: number;\n offset: number;\n }): Promise<Outcome<ReviewPage<UserReview>>> {\n const url = reviewsUrl({ ...options, source: \"user\" });\n return this.fetchParsed(\n url,\n this.scoresCache,\n (body) =>\n parseUserReviews(\n body,\n url,\n `reviews of ${options.kind} \"${options.slug}\"`,\n options.sentiment,\n ),\n options.sentiment,\n );\n }\n\n async getWatchOffers(imdbId: string, kind: Kind): Promise<Outcome<WatchOffer[]>> {\n const url = watchUrl(imdbId, kind);\n return this.fetchParsed(url, this.scoresCache, (body) =>\n parseOffers(body, url, `streaming offers for ${imdbId}`),\n );\n }\n\n /**\n * Fetch, parse, then cache. In that order: a response that could not be read\n * is never stored, so a bad minute upstream cannot be replayed from memory\n * for the rest of the cache lifetime.\n */\n private async fetchParsed<T>(\n url: string,\n cache: TtlLruCache<unknown>,\n parse: (body: string) => T,\n /**\n * Distinguishes parses of the same response. The reviews route returns every\n * sentiment in one payload, so two calls differing only by sentiment share a\n * URL: keying on the URL alone would serve the first answer to the second.\n */\n variant = \"\",\n ): Promise<Outcome<T>> {\n const key = variant ? `${url}#${variant}` : url;\n const hit = cache.get(key);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${key}`);\n return { data: hit as T, cached: true };\n }\n\n const body = await fetchText(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(body);\n cache.set(key, data);\n return { data, cached: false };\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-metacritic",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Metacritic. Search films, shows and games, read Metascores and critic reviews with attribution. No API key required.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "smeet666",
|
|
8
|
+
"homepage": "https://github.com/smeet666/mcp-metacritic#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/smeet666/mcp-metacritic.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/smeet666/mcp-metacritic/issues"
|
|
15
|
+
},
|
|
16
|
+
"mcpName": "io.github.smeet666/mcp-metacritic",
|
|
17
|
+
"bin": {
|
|
18
|
+
"mcp-metacritic": "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/mc/client.d.ts",
|
|
29
|
+
"import": "./dist/mc/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
|
+
"metacritic",
|
|
58
|
+
"metascore",
|
|
59
|
+
"movies",
|
|
60
|
+
"tv",
|
|
61
|
+
"games",
|
|
62
|
+
"reviews",
|
|
63
|
+
"ratings",
|
|
64
|
+
"claude",
|
|
65
|
+
"cursor",
|
|
66
|
+
"llm",
|
|
67
|
+
"ai",
|
|
68
|
+
"no-api-key"
|
|
69
|
+
],
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
72
|
+
"zod": "^3.25.76"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@types/node": "^22.10.2",
|
|
76
|
+
"prettier": "^3.4.2",
|
|
77
|
+
"tsup": "^8.5.1",
|
|
78
|
+
"typescript": "^5.7.2",
|
|
79
|
+
"vitest": "^2.1.8"
|
|
80
|
+
}
|
|
81
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.smeet666/mcp-metacritic",
|
|
4
|
+
"description": "Search Metacritic films, shows and games, read scores and critic reviews. No API key.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/smeet666/mcp-metacritic",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "mcp-metacritic",
|
|
14
|
+
"version": "1.0.0",
|
|
15
|
+
"transport": { "type": "stdio" },
|
|
16
|
+
"environmentVariables": [
|
|
17
|
+
{
|
|
18
|
+
"name": "MC_USER_AGENT",
|
|
19
|
+
"description": "Override the User-Agent sent to Metacritic.",
|
|
20
|
+
"isRequired": false
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "MC_MIN_INTERVAL_MS",
|
|
24
|
+
"description": "Minimum milliseconds between requests. Default 1000, floor 500.",
|
|
25
|
+
"isRequired": false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "MC_LOG_LEVEL",
|
|
29
|
+
"description": "silent, error, info or debug. Default error.",
|
|
30
|
+
"isRequired": false
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|