mcp-metacritic 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
8
8
  import process from "process";
9
9
 
10
10
  // src/version.ts
11
- var PKG_VERSION = "2.0.1";
11
+ var PKG_VERSION = "2.1.0";
12
12
  var REPO_URL = "https://github.com/smeet666/mcp-metacritic";
13
13
 
14
14
  // src/config.ts
@@ -306,10 +306,28 @@ function sleep(ms) {
306
306
  var BACKOFF_BASE_MS = 2e3;
307
307
  var BACKOFF_FACTOR = 2;
308
308
  var BACKOFF_MAX_MS = 2e4;
309
+ var MAX_BODY_BYTES = 8e6;
309
310
  function backoffDelay(attempt, random = Math.random) {
310
311
  const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
311
312
  return Math.round(capped * (0.5 + random() * 0.5));
312
313
  }
314
+ async function readBounded(response, url) {
315
+ const declared = Number(response.headers.get("content-length"));
316
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
317
+ throw parseFailure(
318
+ url,
319
+ `the response declares ${declared} bytes, past the ${MAX_BODY_BYTES} this server reads`
320
+ );
321
+ }
322
+ const body = await response.text();
323
+ if (body.length > MAX_BODY_BYTES) {
324
+ throw parseFailure(
325
+ url,
326
+ `the response carries more than the ${MAX_BODY_BYTES} bytes this server reads`
327
+ );
328
+ }
329
+ return body;
330
+ }
313
331
  function readAnswer(url, status, body, retryAfterMs, ownGuessMs) {
314
332
  if (status === 429 || status === 503 || status === 403) {
315
333
  return {
@@ -365,10 +383,10 @@ async function fetchText(url, deps) {
365
383
  let askedWaitMs = null;
366
384
  for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
367
385
  if (attempt > 0) {
368
- const delay = askedWaitMs ?? backoffDelay(attempt - 1);
386
+ const delay = askedWaitMs ?? Math.min(backoffDelay(attempt - 1), BACKOFF_MAX_MS);
369
387
  askedWaitMs = null;
370
388
  logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
371
- await sleep(Math.min(delay, BACKOFF_MAX_MS));
389
+ await sleep(delay);
372
390
  }
373
391
  let status;
374
392
  let body;
@@ -385,7 +403,7 @@ async function fetchText(url, deps) {
385
403
  });
386
404
  status = response.status;
387
405
  retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
388
- body = await response.text();
406
+ body = await readBounded(response, url);
389
407
  } catch (error) {
390
408
  lastError = asTransportError(error, url);
391
409
  logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
@@ -600,6 +618,14 @@ var str = (value) => {
600
618
  const trimmed = value.trim();
601
619
  return trimmed === "" ? null : trimmed;
602
620
  };
621
+ var line = (value) => {
622
+ const text = str(value);
623
+ return text === null ? null : text.replace(/\s*[\r\n]+\s*/g, " ");
624
+ };
625
+ var link = (value) => {
626
+ const text = str(value);
627
+ return text !== null && ABSOLUTE_URL.test(text) ? text : null;
628
+ };
603
629
  var num = (value) => {
604
630
  if (typeof value === "number" && Number.isFinite(value)) {
605
631
  return value;
@@ -638,8 +664,8 @@ function toSummary(node) {
638
664
  return null;
639
665
  }
640
666
  const id = intOf(node.id);
641
- const slug = str(node.slug);
642
- const title = str(node.title);
667
+ const slug = line(node.slug);
668
+ const title = line(node.title);
643
669
  const kind = kindOf(node.type);
644
670
  if (id === null || !slug || !title || !kind) {
645
671
  return null;
@@ -679,7 +705,7 @@ function parseTitlePage(raw, url, what) {
679
705
  }
680
706
  return {
681
707
  titles,
682
- totalResults: intOf(data[FIELD.totalResults]) ?? titles.length,
708
+ totalResults: intOf(data[FIELD.totalResults]),
683
709
  itemCount: items.length
684
710
  };
685
711
  }
@@ -745,7 +771,7 @@ function parseDetail(raw, url, kind, slug) {
745
771
  throw parseFailure(url, "the response carries no item object");
746
772
  }
747
773
  const id = intOf(item.id);
748
- const title = str(item.title);
774
+ const title = line(item.title);
749
775
  if (id === null || !title) {
750
776
  throw parseFailure(url, "the entry has no id or no title");
751
777
  }
@@ -758,7 +784,7 @@ function parseDetail(raw, url, kind, slug) {
758
784
  year: intOf(item.premiereYear),
759
785
  releaseDate: str(item.releaseDate),
760
786
  rating: str(item.rating),
761
- metascore: num(critic.score),
787
+ metascore: aggregate(critic.score),
762
788
  userScore: null,
763
789
  sourceUrl: titlePageUrl(kind, slug),
764
790
  description: str(item.description),
@@ -783,10 +809,14 @@ function parseScore(raw, url, what) {
783
809
  if (max === null) {
784
810
  throw parseFailure(url, "the score carries no scale");
785
811
  }
812
+ const reviewCount = intOf(item.reviewCount);
786
813
  return {
787
- score: aggregate(item.score),
814
+ // A zero here is read against what stands behind it. An entry nobody has
815
+ // rated is published as 0 out of 10 from 0 ratings, which is an absence; a
816
+ // zero with reviews counted behind it is a verdict those reviewers gave.
817
+ score: reviewCount ? num(item.score) : aggregate(item.score),
788
818
  max,
789
- reviewCount: intOf(item.reviewCount),
819
+ reviewCount,
790
820
  positiveCount: intOf(item.positiveCount),
791
821
  neutralCount: intOf(item.neutralCount),
792
822
  negativeCount: intOf(item.negativeCount),
@@ -813,7 +843,7 @@ function parseCriticReviews(raw, url, what, sentiment) {
813
843
  continue;
814
844
  }
815
845
  const quote = str(node.quote);
816
- const publication = str(node.publicationName);
846
+ const publication = line(node.publicationName);
817
847
  if (!(quote && publication)) {
818
848
  continue;
819
849
  }
@@ -821,8 +851,8 @@ function parseCriticReviews(raw, url, what, sentiment) {
821
851
  quote,
822
852
  score: num(node.score),
823
853
  publication,
824
- author: str(node.author),
825
- url: str(node.url),
854
+ author: line(node.author),
855
+ url: link(node.url),
826
856
  date: str(node.date)
827
857
  });
828
858
  }
@@ -831,7 +861,7 @@ function parseCriticReviews(raw, url, what, sentiment) {
831
861
  }
832
862
  return {
833
863
  reviews,
834
- totalResults: intOf(data[FIELD.totalResults]) ?? reviews.length,
864
+ totalResults: intOf(data[FIELD.totalResults]),
835
865
  itemCount: bucket.length
836
866
  };
837
867
  }
@@ -854,22 +884,22 @@ function parseUserReviews(raw, url, what, sentiment) {
854
884
  }
855
885
  return {
856
886
  reviews,
857
- totalResults: intOf(data[FIELD.totalResults]) ?? reviews.length,
887
+ totalResults: intOf(data[FIELD.totalResults]),
858
888
  itemCount: bucket.length
859
889
  };
860
890
  }
861
- function unwrapLink(link) {
862
- if (!link) {
891
+ function unwrapLink(wrapper) {
892
+ if (!wrapper) {
863
893
  return null;
864
894
  }
865
895
  try {
866
- const target = new URL(link).searchParams.get("r");
896
+ const target = new URL(wrapper).searchParams.get("r");
867
897
  if (target && ABSOLUTE_URL.test(target)) {
868
898
  return target;
869
899
  }
870
900
  } catch {
871
901
  }
872
- return link;
902
+ return ABSOLUTE_URL.test(wrapper) ? wrapper : null;
873
903
  }
874
904
  function parseOffers(raw, url, what) {
875
905
  const data = envelope(raw, url, what);
@@ -886,7 +916,7 @@ function parseOffers(raw, url, what) {
886
916
  if (!isObject(node)) {
887
917
  continue;
888
918
  }
889
- const provider = str(node.providerName);
919
+ const provider = line(node.providerName);
890
920
  if (!provider) {
891
921
  continue;
892
922
  }
@@ -897,16 +927,23 @@ function parseOffers(raw, url, what) {
897
927
  }
898
928
 
899
929
  // src/mc/client.ts
900
- var BROWSER_IDENTITY = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i;
901
930
  function withGuarantees(config) {
902
- const userAgent = BROWSER_IDENTITY.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
931
+ const userAgent = config.userAgent.includes(DEFAULT_USER_AGENT) ? config.userAgent : `${config.userAgent} ${DEFAULT_USER_AGENT}`;
932
+ const asked = Number.isFinite(config.minIntervalMs) ? config.minIntervalMs : 0;
903
933
  return {
904
934
  ...config,
905
935
  userAgent,
906
- minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs)
936
+ minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, asked)
907
937
  };
908
938
  }
909
939
  var McClient = class {
940
+ /**
941
+ * The reads that have gone out and not come back, by URL.
942
+ *
943
+ * A route answering several variants is read once, so this is keyed on the
944
+ * URL rather than on the cache key a variant makes.
945
+ */
946
+ reading = /* @__PURE__ */ new Map();
910
947
  config;
911
948
  logger;
912
949
  limiter;
@@ -949,9 +986,10 @@ var McClient = class {
949
986
  }
950
987
  async browse(options) {
951
988
  const url = browseUrl(options);
989
+ const cache = options.sort === "score" ? this.catalogueCache : this.scoresCache;
952
990
  return await this.fetchParsed(
953
991
  url,
954
- this.catalogueCache,
992
+ cache,
955
993
  (body) => parseTitlePage(body, url, `that ${options.kind} listing`)
956
994
  );
957
995
  }
@@ -1020,12 +1058,24 @@ var McClient = class {
1020
1058
  this.logger.debug(`cache hit ${key}`);
1021
1059
  return { data: hit, cached: true };
1022
1060
  }
1023
- const body = await fetchText(url, {
1061
+ const inFlight = this.reading.get(url);
1062
+ if (inFlight !== void 0) {
1063
+ const body2 = await inFlight;
1064
+ return { data: parse(body2), cached: true };
1065
+ }
1066
+ const reading = fetchText(url, {
1024
1067
  config: this.config,
1025
1068
  limiter: this.limiter,
1026
1069
  logger: this.logger,
1027
1070
  ...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
1028
1071
  });
1072
+ this.reading.set(url, reading);
1073
+ let body;
1074
+ try {
1075
+ body = await reading;
1076
+ } finally {
1077
+ this.reading.delete(url);
1078
+ }
1029
1079
  const data = parse(body);
1030
1080
  cache.set(key, data);
1031
1081
  return { data, cached: false };
@@ -1171,7 +1221,7 @@ function indentMarkerLines(body) {
1171
1221
  return body.replace(/^(Note:|Source:)/gm, " $1");
1172
1222
  }
1173
1223
  function ok(structured, body, options = {}) {
1174
- const attribution = options.sourceUrl ? `${ATTRIBUTION} \u2014 ${options.sourceUrl}` : ATTRIBUTION;
1224
+ const attribution = options.sourceUrl ? `${ATTRIBUTION} (${options.sourceUrl})` : ATTRIBUTION;
1175
1225
  const noteLines = (options.notes ?? []).map((note) => `Note: ${note}`);
1176
1226
  while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_MIRROR_CHARS / 2) {
1177
1227
  noteLines.pop();
@@ -1202,7 +1252,8 @@ function toToolError(error) {
1202
1252
  if (known.details.hint) {
1203
1253
  lines.push(`Hint: ${known.details.hint}`);
1204
1254
  }
1205
- return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
1255
+ const safe = truncate(indentMarkerLines(lines.join("\n")), MAX_TEXT_MIRROR_CHARS);
1256
+ return { content: [{ type: "text", text: safe }], isError: true };
1206
1257
  }
1207
1258
  function truncate(text, maxChars) {
1208
1259
  if (text.length <= maxChars) {
@@ -1290,7 +1341,7 @@ async function runBrowseTitles(client, args) {
1290
1341
  limit: args.limit,
1291
1342
  offset: args.offset
1292
1343
  });
1293
- const results = data.titles.map(toTitleSummaryOut);
1344
+ const results = data.titles.slice(0, args.limit).map(toTitleSummaryOut);
1294
1345
  const notes = [];
1295
1346
  if (cached) {
1296
1347
  notes.push("Served from this server's short-lived in-memory cache.");
@@ -1305,13 +1356,14 @@ async function runBrowseTitles(client, args) {
1305
1356
  `${data.itemCount - data.titles.length} entries on this page could not be read and were skipped.`
1306
1357
  );
1307
1358
  }
1308
- const nextOffset = data.itemCount < args.limit ? null : args.offset + data.itemCount;
1359
+ const nextOffset = data.itemCount < args.limit ? null : args.offset + args.limit;
1309
1360
  const heading = [
1310
1361
  args.genre ? `${args.genre} ` : "",
1311
1362
  `${args.kind}s`,
1312
1363
  orderedBy(args.sort)
1313
1364
  ].join("");
1314
- const summary = results.length === 0 ? `No rows for ${heading}.` : `${results.length} of ${data.totalResults} ${heading}:
1365
+ const outOf = data.totalResults === null ? "" : ` of ${data.totalResults}`;
1366
+ const summary = results.length === 0 ? `No rows for ${heading}.` : `${results.length}${outOf} ${heading}:
1315
1367
  ${renderTitleList(results)}`;
1316
1368
  return ok(
1317
1369
  {
@@ -1351,15 +1403,20 @@ var getReviewsInput = strictInput({
1351
1403
  limit: z4.number().int().min(1).max(50).default(10).describe("How many reviews to return from the sample Metacritic publishes."),
1352
1404
  offset: z4.number().int().min(0).default(0).describe("How many of the sampled reviews to skip. The sample itself cannot be paged past.")
1353
1405
  });
1354
- var reviewSchema = z4.object({
1406
+ var saidSchema = {
1355
1407
  quote: z4.string().nullable().describe("Excerpt as published, truncated if long."),
1356
1408
  score: z4.number().nullable(),
1357
1409
  max: z4.number().describe("Scale for this review's score: 100 for critics, 10 for users."),
1410
+ date: z4.string().nullable()
1411
+ };
1412
+ var criticReviewSchema = z4.object({
1413
+ ...saidSchema,
1358
1414
  publication: z4.string().nullable().describe("Who ran it. Name it when quoting."),
1359
1415
  author: z4.string().nullable(),
1360
- url: z4.string().nullable().describe("Original article. Link it when quoting."),
1361
- date: z4.string().nullable()
1416
+ url: z4.string().nullable().describe("Original article. Link it when quoting.")
1362
1417
  });
1418
+ var userReviewSchema = z4.object(saidSchema);
1419
+ var reviewSchema = z4.union([criticReviewSchema, userReviewSchema]);
1363
1420
  var getReviewsOutputShape = {
1364
1421
  slug: z4.string(),
1365
1422
  kind: z4.string(),
@@ -1385,15 +1442,22 @@ async function runGetReviews(client, args) {
1385
1442
  const max = args.source === "critic" ? 100 : 10;
1386
1443
  const { data, cached } = args.source === "critic" ? await client.getCriticReviews(request) : await client.getUserReviews(request);
1387
1444
  const sampled = data.reviews.slice(args.offset, args.offset + args.limit);
1388
- const reviews = sampled.map((review) => ({
1389
- quote: review.quote === null ? null : truncate(review.quote, MAX_QUOTE_CHARS),
1390
- score: review.score,
1391
- max,
1392
- publication: "publication" in review ? review.publication : null,
1393
- author: "author" in review ? review.author : null,
1394
- url: "url" in review ? review.url : null,
1395
- date: review.date
1396
- }));
1445
+ const credits = (review) => "publication" in review ? review : null;
1446
+ const reviews = sampled.map((review) => {
1447
+ const said = {
1448
+ quote: review.quote === null ? null : truncate(review.quote, MAX_QUOTE_CHARS),
1449
+ score: review.score,
1450
+ max,
1451
+ date: review.date
1452
+ };
1453
+ const credited = credits(review);
1454
+ return credited === null ? said : {
1455
+ ...said,
1456
+ publication: credited.publication,
1457
+ author: credited.author,
1458
+ url: credited.url
1459
+ };
1460
+ });
1397
1461
  const notes = [];
1398
1462
  if (cached) {
1399
1463
  notes.push("Served from this server's short-lived in-memory cache.");
@@ -1416,7 +1480,11 @@ async function runGetReviews(client, args) {
1416
1480
  `${data.itemCount - data.reviews.length} entries in the sample could not be attributed and were skipped.`
1417
1481
  );
1418
1482
  }
1419
- if (data.totalResults > data.reviews.length) {
1483
+ if (data.totalResults === null) {
1484
+ notes.push(
1485
+ "Metacritic published no count of the reviews it holds for this entry, so the length of this sample is not evidence of how many exist."
1486
+ );
1487
+ } else if (data.totalResults > data.reviews.length) {
1420
1488
  const slice = args.sentiment === "all" ? "" : ` in the ${args.sentiment} slice`;
1421
1489
  notes.push(
1422
1490
  `Metacritic counts ${data.totalResults} ${args.source} reviews for this entry and serves ${data.reviews.length}${slice} here. The rest are only on the website: ${sourceUrl}`
@@ -1425,12 +1493,13 @@ async function runGetReviews(client, args) {
1425
1493
  const consumed = args.offset + reviews.length;
1426
1494
  const nextOffset = consumed < data.reviews.length ? consumed : null;
1427
1495
  const listing = reviews.map((review, index) => {
1428
- const who = [review.publication, review.author].filter(Boolean).join(", ");
1496
+ const credited = credits(sampled[index]);
1497
+ const who = [credited?.publication, credited?.author].filter(Boolean).join(", ");
1429
1498
  const head = `${index + 1}. ${review.score === null ? "" : `${review.score}/${max} `}${who || "anonymous"}`;
1430
- const quoted = (review.quote ?? "").split("\n").map((line) => ` ${line}`).join("\n");
1499
+ const quoted = (review.quote ?? "").split("\n").map((line2) => ` ${line2}`).join("\n");
1431
1500
  return `${head}
1432
1501
  ${quoted}
1433
- ${review.url ?? sourceUrl}`;
1502
+ ${credited?.url ?? sourceUrl}`;
1434
1503
  }).join("\n");
1435
1504
  const summary = reviews.length === 0 ? `No ${args.source} review to show for "${args.slug}".` : `${reviews.length} ${args.source} review${reviews.length === 1 ? "" : "s"} of "${args.slug}":
1436
1505
  ${listing}`;
@@ -1515,16 +1584,16 @@ var getTitleInput = strictInput({
1515
1584
  });
1516
1585
  var getTitleOutputShape = {
1517
1586
  title: titleSummarySchema,
1518
- description: z5.string().nullable(),
1519
- tagline: z5.string().nullable(),
1520
- genres: z5.array(z5.string()),
1521
- duration_minutes: z5.number().int().nullable().describe("Runtime in minutes for a film, or the length of a typical episode for a show."),
1522
- imdb_id: z5.string().nullable().describe("IMDb identifier, usable to cross-reference other sources."),
1523
- total_chars: z5.number().int().describe("Length of the full description."),
1524
- returned_chars: z5.number().int(),
1587
+ description: z5.string().nullable().optional(),
1588
+ tagline: z5.string().nullable().optional(),
1589
+ genres: z5.array(z5.string()).optional(),
1590
+ duration_minutes: z5.number().int().nullable().optional().describe("Runtime in minutes for a film, or the length of a typical episode for a show."),
1591
+ imdb_id: z5.string().nullable().optional().describe("IMDb identifier, usable to cross-reference other sources."),
1592
+ total_chars: z5.number().int().optional().describe("Length of the full description."),
1593
+ returned_chars: z5.number().int().optional(),
1525
1594
  offset: z5.number().int(),
1526
- next_offset: z5.number().int().nullable().describe("Pass as 'offset' to read the rest."),
1527
- truncated: z5.boolean(),
1595
+ next_offset: z5.number().int().nullable().optional().describe("Pass as 'offset' to read the rest."),
1596
+ truncated: z5.boolean().optional(),
1528
1597
  critic_score: scoreSchema.nullable(),
1529
1598
  user_score: scoreSchema.nullable(),
1530
1599
  awards: z5.array(
@@ -1552,8 +1621,14 @@ async function runGetTitle(client, args) {
1552
1621
  let userScore = null;
1553
1622
  if (wanted.has("scores")) {
1554
1623
  [criticScore, userScore] = await Promise.all([
1555
- optionalScore(client, args.kind, args.slug, "critic", notes),
1556
- optionalScore(client, args.kind, args.slug, "user", notes)
1624
+ optionalScore(client, args.kind, args.slug, "critic", {
1625
+ notes,
1626
+ onEntryPage: item.metascore
1627
+ }),
1628
+ optionalScore(client, args.kind, args.slug, "user", {
1629
+ notes,
1630
+ onEntryPage: item.userScore
1631
+ })
1557
1632
  ]);
1558
1633
  }
1559
1634
  const basic = wanted.has("basic");
@@ -1570,29 +1645,32 @@ async function runGetTitle(client, args) {
1570
1645
  maxChars: args.max_chars
1571
1646
  })
1572
1647
  );
1648
+ const entry = basic ? {
1649
+ description: slice === "" ? null : slice,
1650
+ tagline: item.tagline,
1651
+ genres: item.genres,
1652
+ duration_minutes: item.duration,
1653
+ imdb_id: item.imdbId,
1654
+ total_chars: full.length,
1655
+ returned_chars: slice.length,
1656
+ next_offset: nextOffset,
1657
+ truncated: nextOffset !== null
1658
+ } : {};
1573
1659
  const structured = {
1574
1660
  title: {
1575
1661
  ...toTitleSummaryOut(item),
1576
1662
  metascore: criticScore?.score ?? item.metascore,
1577
1663
  user_score: userScore?.score ?? null
1578
1664
  },
1579
- description: basic && slice !== "" ? slice : null,
1580
- tagline: basic ? item.tagline : null,
1581
- genres: basic ? item.genres : [],
1582
- duration_minutes: basic ? item.duration : null,
1583
- imdb_id: basic ? item.imdbId : null,
1584
- total_chars: basic ? full.length : 0,
1585
- returned_chars: basic ? slice.length : 0,
1665
+ ...entry,
1586
1666
  offset: args.offset,
1587
- next_offset: basic ? nextOffset : null,
1588
- truncated: basic && nextOffset !== null,
1589
1667
  critic_score: criticScore ? toScoreOut(criticScore) : null,
1590
1668
  user_score: userScore ? toScoreOut(userScore) : null,
1591
1669
  notes
1592
1670
  };
1593
1671
  const production = attachAskedSections(structured, item, wanted, notes);
1594
- const offers = wanted.has("where_to_watch") ? await watchOffers(client, item, args.kind, notes) : [];
1595
- if (wanted.has("where_to_watch")) {
1672
+ const offers = wanted.has("where_to_watch") ? await watchOffers(client, item, args.kind, notes) : null;
1673
+ if (offers !== null) {
1596
1674
  structured.where_to_watch = offers;
1597
1675
  }
1598
1676
  return ok(
@@ -1600,7 +1678,8 @@ async function runGetTitle(client, args) {
1600
1678
  render(item, slice, wanted, {
1601
1679
  critic: criticScore,
1602
1680
  user: userScore,
1603
- offers,
1681
+ offers: offers ?? [],
1682
+ offersRead: offers !== null,
1604
1683
  production
1605
1684
  }),
1606
1685
  {
@@ -1612,13 +1691,16 @@ async function runGetTitle(client, args) {
1612
1691
  return toToolError(error);
1613
1692
  }
1614
1693
  }
1615
- async function optionalScore(client, kind, slug, source, notes) {
1694
+ async function optionalScore(client, kind, slug, source, said) {
1695
+ const { notes, onEntryPage } = said;
1616
1696
  try {
1617
1697
  const { data } = await client.getScore(kind, slug, source);
1618
1698
  return data;
1619
1699
  } catch (error) {
1620
1700
  if (error instanceof McError && error.code === "not_found") {
1621
- notes.push(`Metacritic publishes no ${source} score for this entry.`);
1701
+ notes.push(
1702
+ onEntryPage === null ? `Metacritic publishes no ${source} score for this entry.` : `Metacritic publishes no ${source} score breakdown for this entry, so the number stands without the review counts behind it.`
1703
+ );
1622
1704
  } else {
1623
1705
  const reason = error instanceof McError ? error.code : "an unexpected error";
1624
1706
  notes.push(
@@ -1641,8 +1723,10 @@ async function watchOffers(client, item, kind, notes) {
1641
1723
  const { data } = await client.getWatchOffers(item.imdbId, kind);
1642
1724
  return data;
1643
1725
  } catch {
1644
- notes.push("Streaming offers could not be read for this entry.");
1645
- return [];
1726
+ notes.push(
1727
+ "Streaming offers could not be read for this entry, so they are missing here rather than absent from Metacritic. Call again to retry."
1728
+ );
1729
+ return null;
1646
1730
  }
1647
1731
  }
1648
1732
  function renderAwards(item, wanted) {
@@ -1661,7 +1745,7 @@ function renderAwards(item, wanted) {
1661
1745
  })
1662
1746
  ];
1663
1747
  }
1664
- function renderAskedSections(item, wanted, offers, production) {
1748
+ function renderAskedSections(item, wanted, offers, offersRead, production) {
1665
1749
  const lines = [];
1666
1750
  lines.push(...renderAwards(item, wanted));
1667
1751
  if (wanted.has("networks") && item.networks.length > 0) {
@@ -1673,16 +1757,16 @@ function renderAskedSections(item, wanted, offers, production) {
1673
1757
  production.length === 0 ? "Production: no company credited." : `Production: ${production.map((company) => company.name).join(", ")}`
1674
1758
  );
1675
1759
  }
1676
- if (wanted.has("where_to_watch")) {
1760
+ if (wanted.has("where_to_watch") && offersRead) {
1677
1761
  lines.push("", offers.length === 0 ? "Where to watch: nothing listed." : "Where to watch:");
1678
1762
  for (const o of offers) {
1679
- lines.push(` ${o.provider} (${o.kind})${o.url ? ` \u2014 ${o.url}` : ""}`);
1763
+ lines.push(` ${o.provider} (${o.kind})${o.url ? `: ${o.url}` : ""}`);
1680
1764
  }
1681
1765
  }
1682
1766
  return lines;
1683
1767
  }
1684
1768
  function render(item, description, wanted, said) {
1685
- const { critic, user, offers, production } = said;
1769
+ const { critic, user, offers, offersRead, production } = said;
1686
1770
  const yearShown = item.year !== null && !item.title.includes(`(${item.year})`);
1687
1771
  const header = [item.title, yearShown ? `(${item.year})` : "", `\xB7 ${item.kind}`].filter(Boolean).join(" ");
1688
1772
  const lines = [header];
@@ -1707,7 +1791,7 @@ function render(item, description, wanted, said) {
1707
1791
  if (description) {
1708
1792
  lines.push("", description);
1709
1793
  }
1710
- lines.push(...renderAskedSections(item, wanted, offers, production));
1794
+ lines.push(...renderAskedSections(item, wanted, offers, offersRead, production));
1711
1795
  return lines.join("\n");
1712
1796
  }
1713
1797
 
@@ -1746,11 +1830,25 @@ async function runSearchTitles(client, args) {
1746
1830
  if (cached) {
1747
1831
  notes.push("Served from this server's short-lived in-memory cache.");
1748
1832
  }
1833
+ if (data.itemCount > data.titles.length) {
1834
+ notes.push(
1835
+ `${data.itemCount - data.titles.length} entries in this page could not be read and were skipped.`
1836
+ );
1837
+ }
1838
+ if (args.kind !== "any" && data.totalResults !== null) {
1839
+ notes.push(
1840
+ `total_available counts every kind Metacritic matched for this query, and the rows here are the ${args.kind}s among them.`
1841
+ );
1842
+ }
1749
1843
  const looseCount = WHITESPACE.test(args.query.trim());
1750
1844
  if (looseCount) {
1751
1845
  notes.push(
1752
1846
  "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."
1753
1847
  );
1848
+ } else if (data.totalResults === null) {
1849
+ notes.push(
1850
+ "Metacritic published no count of what this search matched, so a short list here is not evidence that little exists."
1851
+ );
1754
1852
  } else if (data.totalResults > results.length) {
1755
1853
  notes.push(
1756
1854
  `${data.totalResults} entries matched and ${results.length} are shown. Raise 'limit' for more, or narrow the query: this tool cannot page.`