mcp-metacritic 1.2.1 → 2.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/dist/index.js CHANGED
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import process2 from "process";
4
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
6
 
7
+ // src/config.ts
8
+ import process from "process";
9
+
6
10
  // src/version.ts
7
- var PKG_VERSION = "1.2.1";
11
+ var PKG_VERSION = "2.0.0";
8
12
  var REPO_URL = "https://github.com/smeet666/mcp-metacritic";
9
13
 
10
14
  // src/config.ts
@@ -28,7 +32,9 @@ var MAX_ALLOWED_INTERVAL_MS = 6e4;
28
32
  var LOG_LEVELS = ["silent", "error", "info", "debug"];
29
33
  function readNumber(name, env, range) {
30
34
  const raw = env[name];
31
- if (raw === void 0 || raw.trim() === "") return range.fallback;
35
+ if (raw === void 0 || raw.trim() === "") {
36
+ return range.fallback;
37
+ }
32
38
  const parsed = Number(raw);
33
39
  if (!Number.isFinite(parsed)) {
34
40
  warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
@@ -49,7 +55,9 @@ function warn(message) {
49
55
  }
50
56
  function readInterval(env) {
51
57
  const raw = env.MC_MIN_INTERVAL_MS;
52
- if (raw === void 0 || raw.trim() === "") return DEFAULTS.minIntervalMs;
58
+ if (raw === void 0 || raw.trim() === "") {
59
+ return DEFAULTS.minIntervalMs;
60
+ }
53
61
  const parsed = Number(raw);
54
62
  if (!Number.isFinite(parsed)) {
55
63
  warn(`MC_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
@@ -142,7 +150,9 @@ var TtlLruCache = class {
142
150
  store = /* @__PURE__ */ new Map();
143
151
  get(key) {
144
152
  const entry = this.store.get(key);
145
- if (!entry) return void 0;
153
+ if (!entry) {
154
+ return void 0;
155
+ }
146
156
  if (Date.now() >= entry.expiresAt) {
147
157
  this.store.delete(key);
148
158
  return void 0;
@@ -152,12 +162,16 @@ var TtlLruCache = class {
152
162
  return entry.value;
153
163
  }
154
164
  set(key, value) {
155
- if (this.maxEntries <= 0 || this.ttlMs <= 0) return;
165
+ if (this.maxEntries <= 0 || this.ttlMs <= 0) {
166
+ return;
167
+ }
156
168
  this.store.delete(key);
157
169
  this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
158
170
  while (this.store.size > this.maxEntries) {
159
171
  const oldest = this.store.keys().next();
160
- if (oldest.done) break;
172
+ if (oldest.done) {
173
+ break;
174
+ }
161
175
  this.store.delete(oldest.value);
162
176
  }
163
177
  }
@@ -172,7 +186,7 @@ var TtlLruCache = class {
172
186
  // src/errors.ts
173
187
  var McError = class extends Error {
174
188
  constructor(code, message, details = {}) {
175
- super(message);
189
+ super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
176
190
  this.code = code;
177
191
  this.details = details;
178
192
  this.name = "McError";
@@ -202,11 +216,11 @@ function rateLimited(url, retryAfterMs) {
202
216
  }
203
217
  );
204
218
  }
205
- function parseFailure(url, what) {
219
+ function parseFailure(url, what, cause) {
206
220
  return new McError(
207
221
  "parse_failure",
208
222
  `Metacritic answered, but the response did not have the expected shape (${what}).`,
209
- { url, hint: `Please report this, with the request you made, at ${ISSUES_URL}` }
223
+ { url, hint: `Please report this, with the request you made, at ${ISSUES_URL}`, cause }
210
224
  );
211
225
  }
212
226
  function upstreamError(url, status) {
@@ -274,10 +288,14 @@ var RateLimiter = class {
274
288
  this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
275
289
  }
276
290
  async waitForSlot() {
277
- if (this.intervalMs === 0 || this.lastStart === 0) return;
291
+ if (this.intervalMs === 0 || this.lastStart === 0) {
292
+ return;
293
+ }
278
294
  const elapsed = Date.now() - this.lastStart;
279
295
  const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
280
- if (remaining > 0) await sleep(remaining);
296
+ if (remaining > 0) {
297
+ await sleep(remaining);
298
+ }
281
299
  }
282
300
  };
283
301
  function sleep(ms) {
@@ -292,10 +310,57 @@ function backoffDelay(attempt, random = Math.random) {
292
310
  const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
293
311
  return Math.round(capped * (0.5 + random() * 0.5));
294
312
  }
313
+ function readAnswer(url, status, body, retryAfterMs, ownGuessMs) {
314
+ if (status === 429 || status === 503 || status === 403) {
315
+ return {
316
+ kind: "again",
317
+ error: rateLimited(url, retryAfterMs ?? ownGuessMs),
318
+ // A server that says when to come back knows better than our own guess.
319
+ waitMs: retryAfterMs,
320
+ penalise: true,
321
+ because: `refused with ${status}`
322
+ };
323
+ }
324
+ if (status >= 500) {
325
+ return {
326
+ kind: "again",
327
+ error: upstreamError(url, status),
328
+ waitMs: null,
329
+ penalise: false,
330
+ because: `status ${status}`
331
+ };
332
+ }
333
+ if (status === 404) {
334
+ return { kind: "refused", error: notFound(url, "that request") };
335
+ }
336
+ if (status >= 400) {
337
+ return { kind: "refused", error: upstreamError(url, status) };
338
+ }
339
+ const trimmed = body.trim();
340
+ if (trimmed === "") {
341
+ return {
342
+ kind: "again",
343
+ error: rateLimited(url, ownGuessMs),
344
+ waitMs: null,
345
+ penalise: true,
346
+ because: "empty body, treated as a refusal"
347
+ };
348
+ }
349
+ if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) {
350
+ return {
351
+ kind: "again",
352
+ error: parseFailure(url, "the body is not JSON"),
353
+ waitMs: retryAfterMs,
354
+ penalise: true,
355
+ because: "non-JSON body"
356
+ };
357
+ }
358
+ return { kind: "usable" };
359
+ }
295
360
  async function fetchText(url, deps) {
296
361
  const { config, limiter, logger } = deps;
297
362
  const doFetch = deps.fetchImpl ?? fetch;
298
- return limiter.schedule(async () => {
363
+ return await limiter.schedule(async () => {
299
364
  let lastError;
300
365
  let askedWaitMs = null;
301
366
  for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
@@ -326,33 +391,17 @@ async function fetchText(url, deps) {
326
391
  logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
327
392
  continue;
328
393
  }
329
- if (status === 429 || status === 503 || status === 403) {
330
- limiter.penalize();
331
- askedWaitMs = retryAfterMs;
332
- lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));
333
- logger.info(
334
- `refused on ${url} with ${status}, interval now ${limiter.currentIntervalMs}ms`
335
- );
336
- continue;
337
- }
338
- if (status >= 500) {
339
- lastError = upstreamError(url, status);
340
- continue;
394
+ const verdict = readAnswer(url, status, body, retryAfterMs, backoffDelay(attempt));
395
+ if (verdict.kind === "refused") {
396
+ throw verdict.error;
341
397
  }
342
- if (status === 404) throw notFound(url, "that request");
343
- if (status >= 400) throw upstreamError(url, status);
344
- const trimmed = body.trim();
345
- if (trimmed === "") {
346
- limiter.penalize();
347
- lastError = rateLimited(url, backoffDelay(attempt));
348
- logger.info(`empty body on ${url}, treating as a refusal`);
349
- continue;
350
- }
351
- if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
352
- limiter.penalize();
353
- lastError = parseFailure(url, "the body is not JSON");
354
- askedWaitMs = retryAfterMs;
355
- logger.info(`non-JSON body on ${url}, retrying`);
398
+ if (verdict.kind === "again") {
399
+ if (verdict.penalise) {
400
+ limiter.penalize();
401
+ logger.info(`${verdict.because} on ${url}, interval now ${limiter.currentIntervalMs}ms`);
402
+ }
403
+ askedWaitMs = verdict.waitMs;
404
+ lastError = verdict.error;
356
405
  continue;
357
406
  }
358
407
  limiter.relax();
@@ -362,15 +411,23 @@ async function fetchText(url, deps) {
362
411
  });
363
412
  }
364
413
  function parseRetryAfter(raw) {
365
- if (!raw) return null;
414
+ if (!raw) {
415
+ return null;
416
+ }
366
417
  const seconds = Number(raw.trim());
367
- if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
418
+ if (Number.isFinite(seconds) && seconds >= 0) {
419
+ return Math.round(seconds * 1e3);
420
+ }
368
421
  const when = Date.parse(raw);
369
- if (Number.isNaN(when)) return null;
422
+ if (Number.isNaN(when)) {
423
+ return null;
424
+ }
370
425
  return Math.max(0, when - Date.now());
371
426
  }
372
427
  function asTransportError(error, url) {
373
- if (error instanceof McError) return error;
428
+ if (error instanceof McError) {
429
+ return error;
430
+ }
374
431
  const name = error instanceof Error ? error.name : "";
375
432
  if (name === "TimeoutError" || name === "AbortError") {
376
433
  return new McError("timeout", "Metacritic did not answer in time.", {
@@ -428,10 +485,14 @@ var FIELD = {
428
485
  };
429
486
 
430
487
  // src/mc/urls.ts
488
+ var SLUG = /^[a-z0-9][a-z0-9-]*$/i;
489
+ var IMDB_ID = /^tt\d+$/;
431
490
  function build(path, params) {
432
491
  const query = new URLSearchParams();
433
492
  for (const [key, value] of Object.entries(params)) {
434
- if (value !== void 0 && value !== "") query.set(key, String(value));
493
+ if (value !== void 0 && value !== "") {
494
+ query.set(key, String(value));
495
+ }
435
496
  }
436
497
  const suffix = query.toString();
437
498
  return suffix ? `${API_BASE}${path}?${suffix}` : `${API_BASE}${path}`;
@@ -441,7 +502,7 @@ function checkSlug(slug) {
441
502
  if (trimmed === "") {
442
503
  throw invalidInput("The slug is empty.", "Take it from a search_titles result.");
443
504
  }
444
- if (!/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) {
505
+ if (!SLUG.test(trimmed)) {
445
506
  throw invalidInput(
446
507
  `"${slug}" does not look like a Metacritic slug.`,
447
508
  "Slugs are lowercase words joined by hyphens, such as the-matrix. Use search_titles to get one."
@@ -484,7 +545,7 @@ function reviewsUrl(options) {
484
545
  }
485
546
  function watchUrl(imdbId, kind) {
486
547
  const trimmed = imdbId.trim();
487
- if (!/^tt\d+$/.test(trimmed)) {
548
+ if (!IMDB_ID.test(trimmed)) {
488
549
  throw invalidInput(
489
550
  `"${imdbId}" is not an IMDb id.`,
490
551
  "IMDb ids look like tt0133093. get_title returns one as imdb_id."
@@ -503,38 +564,51 @@ function bucketFor(sentiment) {
503
564
  }
504
565
 
505
566
  // src/mc/parse.ts
567
+ var ABSOLUTE_URL = /^https?:\/\//i;
506
568
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
507
569
  function envelope(raw, url, what) {
508
570
  let body;
509
571
  try {
510
572
  body = JSON.parse(raw);
511
- } catch {
512
- throw parseFailure(url, "the body is not valid JSON");
573
+ } catch (cause) {
574
+ throw parseFailure(url, "the body is not valid JSON", cause);
575
+ }
576
+ if (!isObject(body)) {
577
+ throw parseFailure(url, "the body is not an object");
513
578
  }
514
- if (!isObject(body)) throw parseFailure(url, "the body is not an object");
515
579
  const errors = body[FIELD.errors];
516
580
  if (Array.isArray(errors) && errors.length > 0) {
517
581
  const first = isObject(errors[0]) ? errors[0] : {};
518
- if (first.code === 404) throw notFound(url, what);
582
+ if (first.code === 404) {
583
+ throw notFound(url, what);
584
+ }
519
585
  throw parseFailure(
520
586
  url,
521
587
  `the site reported ${String(first.reason ?? first.code ?? "an error")}`
522
588
  );
523
589
  }
524
590
  const data = body[FIELD.data];
525
- if (!isObject(data)) throw parseFailure(url, "the response carries no data object");
591
+ if (!isObject(data)) {
592
+ throw parseFailure(url, "the response carries no data object");
593
+ }
526
594
  return data;
527
595
  }
528
596
  var str = (value) => {
529
- if (typeof value !== "string") return null;
597
+ if (typeof value !== "string") {
598
+ return null;
599
+ }
530
600
  const trimmed = value.trim();
531
601
  return trimmed === "" ? null : trimmed;
532
602
  };
533
603
  var num = (value) => {
534
- if (typeof value === "number" && Number.isFinite(value)) return value;
604
+ if (typeof value === "number" && Number.isFinite(value)) {
605
+ return value;
606
+ }
535
607
  if (typeof value === "string") {
536
608
  const parsed = Number(value);
537
- if (Number.isFinite(parsed)) return parsed;
609
+ if (Number.isFinite(parsed)) {
610
+ return parsed;
611
+ }
538
612
  }
539
613
  return null;
540
614
  };
@@ -548,18 +622,28 @@ var aggregate = (value) => {
548
622
  };
549
623
  function kindOf(value) {
550
624
  const type = str(value);
551
- if (type === SEARCH_TYPE.movie) return "movie";
552
- if (type === SEARCH_TYPE.show) return "show";
553
- if (type === SEARCH_TYPE.game) return "game";
625
+ if (type === SEARCH_TYPE.movie) {
626
+ return "movie";
627
+ }
628
+ if (type === SEARCH_TYPE.show) {
629
+ return "show";
630
+ }
631
+ if (type === SEARCH_TYPE.game) {
632
+ return "game";
633
+ }
554
634
  return null;
555
635
  }
556
636
  function toSummary(node) {
557
- if (!isObject(node)) return null;
637
+ if (!isObject(node)) {
638
+ return null;
639
+ }
558
640
  const id = intOf(node.id);
559
641
  const slug = str(node.slug);
560
642
  const title = str(node.title);
561
643
  const kind = kindOf(node.type);
562
- if (id === null || !slug || !title || !kind) return null;
644
+ if (id === null || !slug || !title || !kind) {
645
+ return null;
646
+ }
563
647
  const critic = isObject(node.criticScoreSummary) ? node.criticScoreSummary : {};
564
648
  const userSummary = isObject(node.userScoreSummary) ? node.userScoreSummary : {};
565
649
  const userObject = isObject(node.userScore) ? node.userScore : {};
@@ -580,11 +664,15 @@ function toSummary(node) {
580
664
  function parseTitlePage(raw, url, what) {
581
665
  const data = envelope(raw, url, what);
582
666
  const items = data[FIELD.items];
583
- if (!Array.isArray(items)) throw parseFailure(url, "the response carries no items array");
667
+ if (!Array.isArray(items)) {
668
+ throw parseFailure(url, "the response carries no items array");
669
+ }
584
670
  const titles = [];
585
671
  for (const node of items) {
586
672
  const row = toSummary(node);
587
- if (row) titles.push(row);
673
+ if (row) {
674
+ titles.push(row);
675
+ }
588
676
  }
589
677
  if (items.length > 0 && titles.length === 0) {
590
678
  throw parseFailure(url, `${items.length} rows but none could be read`);
@@ -597,35 +685,51 @@ function parseTitlePage(raw, url, what) {
597
685
  }
598
686
  function toCompanies(value) {
599
687
  const source = isObject(value) ? value.companies : value;
600
- if (!Array.isArray(source)) return [];
688
+ if (!Array.isArray(source)) {
689
+ return [];
690
+ }
601
691
  const seen = /* @__PURE__ */ new Set();
602
692
  const out = [];
603
693
  for (const node of source) {
604
694
  const name = isObject(node) ? str(node.name) : str(node);
605
- if (!name) continue;
695
+ if (!name) {
696
+ continue;
697
+ }
606
698
  const key = name.toLowerCase();
607
- if (seen.has(key)) continue;
699
+ if (seen.has(key)) {
700
+ continue;
701
+ }
608
702
  seen.add(key);
609
703
  out.push({ name, id: isObject(node) ? intOf(node.id) : null });
610
704
  }
611
705
  return out;
612
706
  }
613
707
  function toStringList(value) {
614
- if (!Array.isArray(value)) return [];
708
+ if (!Array.isArray(value)) {
709
+ return [];
710
+ }
615
711
  const out = [];
616
712
  for (const node of value) {
617
713
  const name = isObject(node) ? str(node.name) : str(node);
618
- if (name) out.push(name);
714
+ if (name) {
715
+ out.push(name);
716
+ }
619
717
  }
620
718
  return out;
621
719
  }
622
720
  function toAwards(value) {
623
- if (!Array.isArray(value)) return [];
721
+ if (!Array.isArray(value)) {
722
+ return [];
723
+ }
624
724
  const out = [];
625
725
  for (const node of value) {
626
- if (!isObject(node)) continue;
726
+ if (!isObject(node)) {
727
+ continue;
728
+ }
627
729
  const ceremony = str(node.awardEvent);
628
- if (!ceremony) continue;
730
+ if (!ceremony) {
731
+ continue;
732
+ }
629
733
  out.push({
630
734
  ceremony,
631
735
  wins: intOf(node.wins),
@@ -637,10 +741,14 @@ function toAwards(value) {
637
741
  function parseDetail(raw, url, kind, slug) {
638
742
  const data = envelope(raw, url, `${kind} "${slug}"`);
639
743
  const item = data[FIELD.item];
640
- if (!isObject(item)) throw parseFailure(url, "the response carries no item object");
744
+ if (!isObject(item)) {
745
+ throw parseFailure(url, "the response carries no item object");
746
+ }
641
747
  const id = intOf(item.id);
642
748
  const title = str(item.title);
643
- if (id === null || !title) throw parseFailure(url, "the entry has no id or no title");
749
+ if (id === null || !title) {
750
+ throw parseFailure(url, "the entry has no id or no title");
751
+ }
644
752
  const critic = isObject(item.criticScoreSummary) ? item.criticScoreSummary : {};
645
753
  return {
646
754
  id,
@@ -668,9 +776,13 @@ function parseDetail(raw, url, kind, slug) {
668
776
  function parseScore(raw, url, what) {
669
777
  const data = envelope(raw, url, what);
670
778
  const item = data[FIELD.item];
671
- if (!isObject(item)) throw parseFailure(url, "the response carries no score object");
779
+ if (!isObject(item)) {
780
+ throw parseFailure(url, "the response carries no score object");
781
+ }
672
782
  const max = num(item.max);
673
- if (max === null) throw parseFailure(url, "the score carries no scale");
783
+ if (max === null) {
784
+ throw parseFailure(url, "the score carries no scale");
785
+ }
674
786
  return {
675
787
  score: aggregate(item.score),
676
788
  max,
@@ -683,7 +795,9 @@ function parseScore(raw, url, what) {
683
795
  }
684
796
  function reviewBucket(data, url, sentiment) {
685
797
  const item = data[FIELD.item];
686
- if (!isObject(item)) throw parseFailure(url, "the response carries no reviews object");
798
+ if (!isObject(item)) {
799
+ throw parseFailure(url, "the response carries no reviews object");
800
+ }
687
801
  const bucket = item[bucketFor(sentiment)];
688
802
  if (!Array.isArray(bucket)) {
689
803
  throw parseFailure(url, `there is no "${bucketFor(sentiment)}" group of reviews`);
@@ -695,10 +809,14 @@ function parseCriticReviews(raw, url, what, sentiment) {
695
809
  const bucket = reviewBucket(data, url, sentiment);
696
810
  const reviews = [];
697
811
  for (const node of bucket) {
698
- if (!isObject(node)) continue;
812
+ if (!isObject(node)) {
813
+ continue;
814
+ }
699
815
  const quote = str(node.quote);
700
816
  const publication = str(node.publicationName);
701
- if (!quote || !publication) continue;
817
+ if (!(quote && publication)) {
818
+ continue;
819
+ }
702
820
  reviews.push({
703
821
  quote,
704
822
  score: num(node.score),
@@ -722,9 +840,13 @@ function parseUserReviews(raw, url, what, sentiment) {
722
840
  const bucket = reviewBucket(data, url, sentiment);
723
841
  const reviews = [];
724
842
  for (const node of bucket) {
725
- if (!isObject(node)) continue;
843
+ if (!isObject(node)) {
844
+ continue;
845
+ }
726
846
  const quote = str(node.quote);
727
- if (!quote) continue;
847
+ if (!quote) {
848
+ continue;
849
+ }
728
850
  reviews.push({ quote, score: num(node.score), date: str(node.date) });
729
851
  }
730
852
  if (bucket.length > 0 && reviews.length === 0) {
@@ -737,10 +859,14 @@ function parseUserReviews(raw, url, what, sentiment) {
737
859
  };
738
860
  }
739
861
  function unwrapLink(link) {
740
- if (!link) return null;
862
+ if (!link) {
863
+ return null;
864
+ }
741
865
  try {
742
866
  const target = new URL(link).searchParams.get("r");
743
- if (target && /^https?:\/\//i.test(target)) return target;
867
+ if (target && ABSOLUTE_URL.test(target)) {
868
+ return target;
869
+ }
744
870
  } catch {
745
871
  }
746
872
  return link;
@@ -748,14 +874,22 @@ function unwrapLink(link) {
748
874
  function parseOffers(raw, url, what) {
749
875
  const data = envelope(raw, url, what);
750
876
  const item = data[FIELD.item];
751
- if (!isObject(item)) throw parseFailure(url, "the response carries no offers object");
877
+ if (!isObject(item)) {
878
+ throw parseFailure(url, "the response carries no offers object");
879
+ }
752
880
  const offers = [];
753
881
  for (const [group, value] of Object.entries(item)) {
754
- if (!Array.isArray(value)) continue;
882
+ if (!Array.isArray(value)) {
883
+ continue;
884
+ }
755
885
  for (const node of value) {
756
- if (!isObject(node)) continue;
886
+ if (!isObject(node)) {
887
+ continue;
888
+ }
757
889
  const provider = str(node.providerName);
758
- if (!provider) continue;
890
+ if (!provider) {
891
+ continue;
892
+ }
759
893
  offers.push({ provider, kind: group, url: unwrapLink(str(node.link)) });
760
894
  }
761
895
  }
@@ -763,8 +897,9 @@ function parseOffers(raw, url, what) {
763
897
  }
764
898
 
765
899
  // src/mc/client.ts
900
+ var BROWSER_IDENTITY = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i;
766
901
  function withGuarantees(config) {
767
- const userAgent = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
902
+ const userAgent = BROWSER_IDENTITY.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
768
903
  return {
769
904
  ...config,
770
905
  userAgent,
@@ -806,7 +941,7 @@ var McClient = class {
806
941
  */
807
942
  async search(query, limit, offset) {
808
943
  const url = searchUrl(query, limit, offset);
809
- return this.fetchParsed(
944
+ return await this.fetchParsed(
810
945
  url,
811
946
  this.catalogueCache,
812
947
  (body) => parseTitlePage(body, url, `the query "${query}"`)
@@ -814,7 +949,7 @@ var McClient = class {
814
949
  }
815
950
  async browse(options) {
816
951
  const url = browseUrl(options);
817
- return this.fetchParsed(
952
+ return await this.fetchParsed(
818
953
  url,
819
954
  this.catalogueCache,
820
955
  (body) => parseTitlePage(body, url, `that ${options.kind} listing`)
@@ -823,7 +958,7 @@ var McClient = class {
823
958
  async getDetail(kind, slug) {
824
959
  const url = detailUrl(kind, slug);
825
960
  const clean = slug.trim();
826
- return this.fetchParsed(
961
+ return await this.fetchParsed(
827
962
  url,
828
963
  this.catalogueCache,
829
964
  (body) => parseDetail(body, url, kind, clean)
@@ -831,7 +966,7 @@ var McClient = class {
831
966
  }
832
967
  async getScore(kind, slug, source) {
833
968
  const url = scoreUrl(kind, slug, source);
834
- return this.fetchParsed(
969
+ return await this.fetchParsed(
835
970
  url,
836
971
  this.scoresCache,
837
972
  (body) => parseScore(body, url, `the ${source} score of ${kind} "${slug}"`)
@@ -839,7 +974,7 @@ var McClient = class {
839
974
  }
840
975
  async getCriticReviews(options) {
841
976
  const url = reviewsUrl({ ...options, source: "critic" });
842
- return this.fetchParsed(
977
+ return await this.fetchParsed(
843
978
  url,
844
979
  this.scoresCache,
845
980
  (body) => parseCriticReviews(
@@ -853,7 +988,7 @@ var McClient = class {
853
988
  }
854
989
  async getUserReviews(options) {
855
990
  const url = reviewsUrl({ ...options, source: "user" });
856
- return this.fetchParsed(
991
+ return await this.fetchParsed(
857
992
  url,
858
993
  this.scoresCache,
859
994
  (body) => parseUserReviews(
@@ -867,7 +1002,7 @@ var McClient = class {
867
1002
  }
868
1003
  async getWatchOffers(imdbId, kind) {
869
1004
  const url = watchUrl(imdbId, kind);
870
- return this.fetchParsed(
1005
+ return await this.fetchParsed(
871
1006
  url,
872
1007
  this.scoresCache,
873
1008
  (body) => parseOffers(body, url, `streaming offers for ${imdbId}`)
@@ -903,10 +1038,24 @@ import { z as z3 } from "zod";
903
1038
  // src/tools/arguments.ts
904
1039
  import { z } from "zod";
905
1040
  var INVALID_INPUT = "invalid_input";
1041
+ var defaultMessage = z.core.locales.en().localeError;
906
1042
  function strictInput(shape) {
907
1043
  const declared = Object.keys(shape);
908
- const errorMap = (issue, ctx) => issue.code === z.ZodIssueCode.unrecognized_keys ? { message: unknownArgumentMessage(issue.keys, declared) } : { message: ctx.defaultError };
909
- return z.strictObject(shape, { errorMap });
1044
+ const refuse = (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : `[${INVALID_INPUT}] ${defaultMessage(issue)}`;
1045
+ for (const argument of Object.values(shape)) {
1046
+ carryTheCode(argument, refuse);
1047
+ }
1048
+ return z.strictObject(shape, { error: refuse });
1049
+ }
1050
+ function carryTheCode(schema, refuse) {
1051
+ const definition = schema._zod.def;
1052
+ definition.error = refuse;
1053
+ for (const check of definition.checks ?? []) {
1054
+ check._zod.def.error = refuse;
1055
+ }
1056
+ if (definition.innerType) {
1057
+ carryTheCode(definition.innerType, refuse);
1058
+ }
910
1059
  }
911
1060
  function unknownArgumentMessage(keys, declared) {
912
1061
  const named = keys.map((key) => {
@@ -918,15 +1067,21 @@ function unknownArgumentMessage(keys, declared) {
918
1067
  function nearestArgument(key, declared) {
919
1068
  const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
920
1069
  const flat = flatten(key);
921
- if (flat.length === 0) return void 0;
1070
+ if (flat.length === 0) {
1071
+ return void 0;
1072
+ }
922
1073
  const sameName = declared.find((name) => flatten(name) === flat);
923
- if (sameName) return sameName;
1074
+ if (sameName) {
1075
+ return sameName;
1076
+ }
924
1077
  const overlapping = declared.find((name) => {
925
1078
  const other = flatten(name);
926
1079
  const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
927
1080
  return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
928
1081
  });
929
- if (overlapping) return overlapping;
1082
+ if (overlapping) {
1083
+ return overlapping;
1084
+ }
930
1085
  let closest;
931
1086
  let shortest = Number.POSITIVE_INFINITY;
932
1087
  for (const name of declared) {
@@ -940,15 +1095,25 @@ function nearestArgument(key, declared) {
940
1095
  }
941
1096
  function editDistance(left, right) {
942
1097
  let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
1098
+ let distance = right.length;
943
1099
  for (let row = 1; row <= left.length; row += 1) {
944
1100
  const current = [row];
945
- for (let column = 1; column <= right.length; column += 1) {
946
- const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
947
- current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
1101
+ let diagonal = row - 1;
1102
+ let leftward = row;
1103
+ for (const [column, above] of previous.entries()) {
1104
+ if (column === 0) {
1105
+ continue;
1106
+ }
1107
+ const substitution = diagonal + (left[row - 1] === right[column - 1] ? 0 : 1);
1108
+ const cell = Math.min(substitution, above + 1, leftward + 1);
1109
+ current.push(cell);
1110
+ diagonal = above;
1111
+ leftward = cell;
948
1112
  }
949
1113
  previous = current;
1114
+ distance = leftward;
950
1115
  }
951
- return previous[right.length];
1116
+ return distance;
952
1117
  }
953
1118
 
954
1119
  // src/tools/shared.ts
@@ -1034,21 +1199,29 @@ ${trailer}`;
1034
1199
  function toToolError(error) {
1035
1200
  const known = error instanceof McError ? error : new McError("network_error", error instanceof Error ? error.message : String(error));
1036
1201
  const lines = [`[${known.code}] ${known.message}`];
1037
- if (known.details.hint) lines.push(`Hint: ${known.details.hint}`);
1202
+ if (known.details.hint) {
1203
+ lines.push(`Hint: ${known.details.hint}`);
1204
+ }
1038
1205
  return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
1039
1206
  }
1040
1207
  function truncate(text, maxChars) {
1041
- if (text.length <= maxChars) return text;
1208
+ if (text.length <= maxChars) {
1209
+ return text;
1210
+ }
1042
1211
  return `${text.slice(0, maxChars - 1).trimEnd()}\u2026`;
1043
1212
  }
1044
1213
  function sliceAtLineBoundary(text, offset, maxChars) {
1045
1214
  const rest = text.slice(offset);
1046
- if (rest.length <= maxChars) return { slice: rest, nextOffset: null };
1215
+ if (rest.length <= maxChars) {
1216
+ return { slice: rest, nextOffset: null };
1217
+ }
1047
1218
  const window = rest.slice(0, maxChars);
1048
1219
  const lastBreak = window.lastIndexOf("\n");
1049
1220
  let cut = lastBreak > 0 ? lastBreak : maxChars;
1050
1221
  const code = rest.charCodeAt(cut - 1);
1051
- if (code >= 55296 && code <= 56319) cut -= 1;
1222
+ if (code >= 55296 && code <= 56319) {
1223
+ cut -= 1;
1224
+ }
1052
1225
  return { slice: rest.slice(0, cut), nextOffset: offset + cut };
1053
1226
  }
1054
1227
  function renderTitleList(titles) {
@@ -1070,6 +1243,15 @@ function renderTitleList(titles) {
1070
1243
  }
1071
1244
 
1072
1245
  // src/tools/browseTitles.ts
1246
+ function orderedBy(sort) {
1247
+ if (sort === "score") {
1248
+ return " by critic score";
1249
+ }
1250
+ if (sort === "recent") {
1251
+ return " by release date";
1252
+ }
1253
+ return " by current popularity";
1254
+ }
1073
1255
  var browseTitlesDescription = [
1074
1256
  "List films, shows or games by score, by release date or by current popularity.",
1075
1257
  "Use this when there is no specific title to look up: best rated, newest, what people are looking at now.",
@@ -1110,7 +1292,9 @@ async function runBrowseTitles(client, args) {
1110
1292
  });
1111
1293
  const results = data.titles.map(toTitleSummaryOut);
1112
1294
  const notes = [];
1113
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1295
+ if (cached) {
1296
+ notes.push("Served from this server's short-lived in-memory cache.");
1297
+ }
1114
1298
  if (results.length === 0) {
1115
1299
  notes.push(
1116
1300
  args.genre ? `No ${args.kind} matched the genre "${args.genre}" at this offset. Check the spelling, or drop the genre.` : "The listing returned no rows at this offset."
@@ -1125,7 +1309,7 @@ async function runBrowseTitles(client, args) {
1125
1309
  const heading = [
1126
1310
  args.genre ? `${args.genre} ` : "",
1127
1311
  `${args.kind}s`,
1128
- args.sort === "score" ? " by critic score" : args.sort === "recent" ? " by release date" : " by current popularity"
1312
+ orderedBy(args.sort)
1129
1313
  ].join("");
1130
1314
  const summary = results.length === 0 ? `No rows for ${heading}.` : `${results.length} of ${data.totalResults} ${heading}:
1131
1315
  ${renderTitleList(results)}`;
@@ -1211,7 +1395,9 @@ async function runGetReviews(client, args) {
1211
1395
  date: review.date
1212
1396
  }));
1213
1397
  const notes = [];
1214
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1398
+ if (cached) {
1399
+ notes.push("Served from this server's short-lived in-memory cache.");
1400
+ }
1215
1401
  if (reviews.length === 0) {
1216
1402
  if (args.offset > 0 && args.offset >= data.reviews.length) {
1217
1403
  notes.push(
@@ -1271,6 +1457,46 @@ ${listing}`;
1271
1457
 
1272
1458
  // src/tools/getTitle.ts
1273
1459
  import { z as z5 } from "zod";
1460
+ function notesOnWhatWasReadAndAsked(read) {
1461
+ const { basic, wanted, slice, nextOffset, fullLength, offset, maxChars } = read;
1462
+ const notes = [];
1463
+ if (basic && nextOffset !== null) {
1464
+ notes.push(
1465
+ `The description is longer than ${maxChars} characters. Call again with offset=${nextOffset} for the rest.`
1466
+ );
1467
+ }
1468
+ if (basic && slice === "" && offset > 0 && fullLength > 0) {
1469
+ notes.push(
1470
+ `offset=${offset} is past the end of a description of ${fullLength} characters. Call again with offset=0 to read it from the start.`
1471
+ );
1472
+ }
1473
+ if (!wanted.has("scores")) {
1474
+ notes.push(
1475
+ "Scores were not requested, so critic_score and user_score are null here regardless of what Metacritic publishes. Add 'scores' to sections to read them."
1476
+ );
1477
+ }
1478
+ return notes;
1479
+ }
1480
+ var PRODUCTION_CAP = 25;
1481
+ function attachAskedSections(structured, item, wanted, notes) {
1482
+ if (wanted.has("awards")) {
1483
+ structured.awards = item.awards;
1484
+ }
1485
+ if (wanted.has("networks")) {
1486
+ structured.networks = item.networks;
1487
+ }
1488
+ if (!wanted.has("production")) {
1489
+ return [];
1490
+ }
1491
+ const production = item.production.slice(0, PRODUCTION_CAP);
1492
+ structured.production = production;
1493
+ if (item.production.length > PRODUCTION_CAP) {
1494
+ notes.push(
1495
+ `${item.production.length} companies are credited and the first ${PRODUCTION_CAP} are shown. The list mixes producers with distributors and home-video labels across every territory.`
1496
+ );
1497
+ }
1498
+ return production;
1499
+ }
1274
1500
  var SECTIONS = ["basic", "scores", "awards", "production", "networks", "where_to_watch"];
1275
1501
  var getTitleDescription = [
1276
1502
  "Read one Metacritic entry by slug and kind, both from search_titles.",
@@ -1318,7 +1544,9 @@ async function runGetTitle(client, args) {
1318
1544
  const wanted = new Set(args.sections);
1319
1545
  const notes = [];
1320
1546
  const detail = await client.getDetail(args.kind, args.slug);
1321
- if (detail.cached) notes.push("Served from this server's short-lived in-memory cache.");
1547
+ if (detail.cached) {
1548
+ notes.push("Served from this server's short-lived in-memory cache.");
1549
+ }
1322
1550
  const item = detail.data;
1323
1551
  let criticScore = null;
1324
1552
  let userScore = null;
@@ -1331,21 +1559,17 @@ async function runGetTitle(client, args) {
1331
1559
  const basic = wanted.has("basic");
1332
1560
  const full = basic ? item.description ?? "" : "";
1333
1561
  const { slice, nextOffset } = sliceAtLineBoundary(full, args.offset, args.max_chars);
1334
- if (basic && nextOffset !== null) {
1335
- notes.push(
1336
- `The description is longer than ${args.max_chars} characters. Call again with offset=${nextOffset} for the rest.`
1337
- );
1338
- }
1339
- if (basic && slice === "" && args.offset > 0 && full.length > 0) {
1340
- notes.push(
1341
- `offset=${args.offset} is past the end of a description of ${full.length} characters. Call again with offset=0 to read it from the start.`
1342
- );
1343
- }
1344
- if (!wanted.has("scores")) {
1345
- notes.push(
1346
- "Scores were not requested, so critic_score and user_score are null here regardless of what Metacritic publishes. Add 'scores' to sections to read them."
1347
- );
1348
- }
1562
+ notes.push(
1563
+ ...notesOnWhatWasReadAndAsked({
1564
+ basic,
1565
+ wanted,
1566
+ slice,
1567
+ nextOffset,
1568
+ fullLength: full.length,
1569
+ offset: args.offset,
1570
+ maxChars: args.max_chars
1571
+ })
1572
+ );
1349
1573
  const structured = {
1350
1574
  title: {
1351
1575
  ...toTitleSummaryOut(item),
@@ -1366,24 +1590,24 @@ async function runGetTitle(client, args) {
1366
1590
  user_score: userScore ? toScoreOut(userScore) : null,
1367
1591
  notes
1368
1592
  };
1369
- if (wanted.has("awards")) structured.awards = item.awards;
1370
- const PRODUCTION_CAP = 25;
1371
- const production = wanted.has("production") ? item.production.slice(0, PRODUCTION_CAP) : [];
1372
- if (wanted.has("production")) {
1373
- structured.production = production;
1374
- if (item.production.length > PRODUCTION_CAP) {
1375
- notes.push(
1376
- `${item.production.length} companies are credited and the first ${PRODUCTION_CAP} are shown. The list mixes producers with distributors and home-video labels across every territory.`
1377
- );
1378
- }
1379
- }
1380
- if (wanted.has("networks")) structured.networks = item.networks;
1593
+ const production = attachAskedSections(structured, item, wanted, notes);
1381
1594
  const offers = wanted.has("where_to_watch") ? await watchOffers(client, item, args.kind, notes) : [];
1382
- if (wanted.has("where_to_watch")) structured.where_to_watch = offers;
1383
- return ok(structured, render(item, slice, criticScore, userScore, wanted, offers, production), {
1384
- notes,
1385
- sourceUrl: item.sourceUrl
1386
- });
1595
+ if (wanted.has("where_to_watch")) {
1596
+ structured.where_to_watch = offers;
1597
+ }
1598
+ return ok(
1599
+ structured,
1600
+ render(item, slice, wanted, {
1601
+ critic: criticScore,
1602
+ user: userScore,
1603
+ offers,
1604
+ production
1605
+ }),
1606
+ {
1607
+ notes,
1608
+ sourceUrl: item.sourceUrl
1609
+ }
1610
+ );
1387
1611
  } catch (error) {
1388
1612
  return toToolError(error);
1389
1613
  }
@@ -1421,35 +1645,25 @@ async function watchOffers(client, item, kind, notes) {
1421
1645
  return [];
1422
1646
  }
1423
1647
  }
1424
- function render(item, description, critic, user, wanted, offers, production) {
1425
- const yearShown = item.year !== null && !item.title.includes(`(${item.year})`);
1426
- const header = [item.title, yearShown ? `(${item.year})` : "", `\xB7 ${item.kind}`].filter(Boolean).join(" ");
1427
- const lines = [header];
1428
- if (wanted.has("basic")) {
1429
- if (item.genres.length > 0) lines.push(`Genres: ${item.genres.join(", ")}`);
1430
- if (item.duration !== null) lines.push(`Runtime: ${item.duration} min`);
1431
- }
1432
- if (critic?.score !== null && critic !== null) {
1433
- lines.push(
1434
- `Critics: ${critic.score}/${critic.max} from ${critic.reviewCount ?? "?"} reviews${critic.sentiment ? ` (${critic.sentiment})` : ""}`
1435
- );
1436
- }
1437
- if (user?.score !== null && user !== null) {
1438
- lines.push(
1439
- `Users: ${user.score}/${user.max} from ${user.reviewCount ?? "?"} ratings${user.sentiment ? ` (${user.sentiment})` : ""}`
1440
- );
1648
+ function renderAwards(item, wanted) {
1649
+ if (!wanted.has("awards") || item.awards.length === 0) {
1650
+ return [];
1441
1651
  }
1442
- if (description) lines.push("", description);
1443
- if (wanted.has("awards") && item.awards.length > 0) {
1444
- lines.push("", "Awards:");
1445
- for (const a of item.awards) {
1652
+ return [
1653
+ "",
1654
+ "Awards:",
1655
+ ...item.awards.map((a) => {
1446
1656
  const tally = [
1447
1657
  a.wins === null ? "" : `${a.wins} win${a.wins === 1 ? "" : "s"}`,
1448
1658
  a.nominations === null ? "" : `${a.nominations} nomination${a.nominations === 1 ? "" : "s"}`
1449
1659
  ].filter(Boolean);
1450
- lines.push(` ${a.ceremony}${tally.length > 0 ? `: ${tally.join(", ")}` : ""}`);
1451
- }
1452
- }
1660
+ return ` ${a.ceremony}${tally.length > 0 ? `: ${tally.join(", ")}` : ""}`;
1661
+ })
1662
+ ];
1663
+ }
1664
+ function renderAskedSections(item, wanted, offers, production) {
1665
+ const lines = [];
1666
+ lines.push(...renderAwards(item, wanted));
1453
1667
  if (wanted.has("networks") && item.networks.length > 0) {
1454
1668
  lines.push("", `Networks: ${item.networks.join(", ")}`);
1455
1669
  }
@@ -1461,13 +1675,45 @@ function render(item, description, critic, user, wanted, offers, production) {
1461
1675
  }
1462
1676
  if (wanted.has("where_to_watch")) {
1463
1677
  lines.push("", offers.length === 0 ? "Where to watch: nothing listed." : "Where to watch:");
1464
- for (const o of offers) lines.push(` ${o.provider} (${o.kind})${o.url ? ` \u2014 ${o.url}` : ""}`);
1678
+ for (const o of offers) {
1679
+ lines.push(` ${o.provider} (${o.kind})${o.url ? ` \u2014 ${o.url}` : ""}`);
1680
+ }
1681
+ }
1682
+ return lines;
1683
+ }
1684
+ function render(item, description, wanted, said) {
1685
+ const { critic, user, offers, production } = said;
1686
+ const yearShown = item.year !== null && !item.title.includes(`(${item.year})`);
1687
+ const header = [item.title, yearShown ? `(${item.year})` : "", `\xB7 ${item.kind}`].filter(Boolean).join(" ");
1688
+ const lines = [header];
1689
+ if (wanted.has("basic")) {
1690
+ if (item.genres.length > 0) {
1691
+ lines.push(`Genres: ${item.genres.join(", ")}`);
1692
+ }
1693
+ if (item.duration !== null) {
1694
+ lines.push(`Runtime: ${item.duration} min`);
1695
+ }
1696
+ }
1697
+ if (critic?.score !== null && critic !== null) {
1698
+ lines.push(
1699
+ `Critics: ${critic.score}/${critic.max} from ${critic.reviewCount ?? "?"} reviews${critic.sentiment ? ` (${critic.sentiment})` : ""}`
1700
+ );
1465
1701
  }
1702
+ if (user?.score !== null && user !== null) {
1703
+ lines.push(
1704
+ `Users: ${user.score}/${user.max} from ${user.reviewCount ?? "?"} ratings${user.sentiment ? ` (${user.sentiment})` : ""}`
1705
+ );
1706
+ }
1707
+ if (description) {
1708
+ lines.push("", description);
1709
+ }
1710
+ lines.push(...renderAskedSections(item, wanted, offers, production));
1466
1711
  return lines.join("\n");
1467
1712
  }
1468
1713
 
1469
1714
  // src/tools/searchTitles.ts
1470
1715
  import { z as z6 } from "zod";
1716
+ var WHITESPACE = /\s/;
1471
1717
  var searchTitlesDescription = [
1472
1718
  "Search Metacritic for films, shows and games by title.",
1473
1719
  "Returns one compact row per match, carrying the critic Metascore, so a question about the critical verdict needs no second call.",
@@ -1497,8 +1743,10 @@ async function runSearchTitles(client, args) {
1497
1743
  const results = matching.slice(0, args.limit).map(toTitleSummaryOut);
1498
1744
  const windowExhausted = args.kind !== "any" && matching.length === results.length;
1499
1745
  const notes = [];
1500
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1501
- const looseCount = /\s/.test(args.query.trim());
1746
+ if (cached) {
1747
+ notes.push("Served from this server's short-lived in-memory cache.");
1748
+ }
1749
+ const looseCount = WHITESPACE.test(args.query.trim());
1502
1750
  if (looseCount) {
1503
1751
  notes.push(
1504
1752
  "Metacritic counts a multi-word query loosely, so total_available is far larger than the number of real matches. The rows themselves are ordered by relevance."
@@ -1606,16 +1854,16 @@ async function main() {
1606
1854
  const transport = new StdioServerTransport();
1607
1855
  await server.connect(transport);
1608
1856
  const shutdown = () => {
1609
- void server.close().finally(() => process.exit(0));
1857
+ server.close().catch(() => void 0).finally(() => process2.exit(0));
1610
1858
  };
1611
- process.on("SIGINT", shutdown);
1612
- process.on("SIGTERM", shutdown);
1859
+ process2.on("SIGINT", shutdown);
1860
+ process2.on("SIGTERM", shutdown);
1613
1861
  }
1614
1862
  main().catch((error) => {
1615
- process.stderr.write(
1863
+ process2.stderr.write(
1616
1864
  `[mcp-metacritic] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}
1617
1865
  `
1618
1866
  );
1619
- process.exit(1);
1867
+ process2.exit(1);
1620
1868
  });
1621
1869
  //# sourceMappingURL=index.js.map