cito-mcp 0.4.4 → 0.4.6

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.
@@ -1,24 +1,10 @@
1
- /**
2
- * match_summary + match_details
3
- */
4
1
  import { extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
2
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
3
  import { normalizeMatch, normalizeUfcMethod, presentSides, tennisSetCompleted } from './normalize.js';
7
- // summarizeTennisOdds was used here for the tennis odds section. Tennis odds were
8
- // withdrawn 2026-09-13 (see docs/tennis-risk-register.md), so this module no
9
- // longer imports it. The shaper still exists and is still exported from
10
- // ./odds.js for the day the surface is restored.
11
4
  import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from './types.js';
12
5
  async function getSection(ctx, path, query) {
13
6
  return fetchJson(ctx, path, { query });
14
7
  }
15
- /**
16
- * Series length. The LoL feed states it as `strategy: "Bo5"`, which nothing was
17
- * reading, so every match reported bestOf: null. Accept the spelled forms and
18
- * the object shape other titles use, and fall back to the declared game count.
19
- * Only 1/3/5/7/9 are real series lengths, so anything else is refused rather
20
- * than guessed.
21
- */
22
8
  export function parseBestOf(r) {
23
9
  const fromNumber = (v) => {
24
10
  const n = Number(v);
@@ -36,7 +22,6 @@ export function parseBestOf(r) {
36
22
  return n;
37
23
  }
38
24
  }
39
- // { type: "bestOf", count: 5 }
40
25
  if (strat && typeof strat === 'object') {
41
26
  const o = strat;
42
27
  const n = fromNumber(o.count) ?? fromNumber(o.value) ?? fromNumber(o.bestOf);
@@ -65,11 +50,6 @@ function matchCore(game, matchId, raw) {
65
50
  const scoreline = m.team1 || m.team2
66
51
  ? `${m.team1?.name ?? '?'} ${s1 ?? '-'} : ${s2 ?? '-'} ${m.team2?.name ?? '?'}`
67
52
  : null;
68
- /**
69
- * Tennis: `score` is the set-by-set game score ("4-6 6-3 6-3 7-5"), which is
70
- * a DIFFERENT fact from the sets-won scoreline. "3 : 1" is the result; it
71
- * cannot distinguish 6-0 6-0 from three tiebreaks. Both are carried.
72
- */
73
53
  const scoreDetail = game === 'tennis' ? (pickString(r.score, r.score_raw) ?? null) : null;
74
54
  const core = {
75
55
  matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
@@ -89,11 +69,6 @@ function matchCore(game, matchId, raw) {
89
69
  team2: m.team2,
90
70
  event: m.event,
91
71
  league: m.league,
92
- // method/methodRaw/weightClass/referee are combat-sport vocabulary. They
93
- // stayed on every match_summary/match_details card regardless of game, so
94
- // a tennis card carried referee:null and weightClass:null — foreign
95
- // fields, not merely empty ones. UFC keeps them; every other game omits
96
- // the keys entirely rather than shipping them as null.
97
72
  ...(game === 'ufc'
98
73
  ? {
99
74
  method: normalizeUfcMethod(pickString(r.method, r.resultMethod)) ?? null,
@@ -101,18 +76,13 @@ function matchCore(game, matchId, raw) {
101
76
  }
102
77
  : {}),
103
78
  winner: (() => {
104
- // Never name a winner while play is in progress: the score fallback
105
- // below reads "who is ahead", which mid-match is a lead, not a result.
106
79
  if (m.status === 'live' || m.status === 'upcoming')
107
80
  return null;
108
- // UFC rows carry an explicit winner slug/fighter ref; tennis archive
109
- // rows nest a winner object.
110
81
  const explicit = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, asRecord(r.winner)?.id);
111
82
  if (explicit)
112
83
  return explicit;
113
84
  if (game === 'ufc')
114
85
  return null;
115
- // CS2 rows expose winnerTeamId instead; map it to a side, else fall back to scores.
116
86
  const sideLabel = (side) => side ? pickString(side.slug, side.id, side.name) ?? null : null;
117
87
  const winnerTeamId = pickString(r.winnerTeamId, r.winner_team_id);
118
88
  if (winnerTeamId) {
@@ -159,8 +129,6 @@ function matchCore(game, matchId, raw) {
159
129
  }
160
130
  : {}),
161
131
  rawStatus: pickString(r.status, r.state) ?? null,
162
- // Point-level live state (tennis): current game score like "40-AD", which
163
- // side is serving, and the set in progress. Absent for other games.
164
132
  ...(game === 'tennis' && (r.game_score || r.server)
165
133
  ? {
166
134
  liveState: {
@@ -174,13 +142,6 @@ function matchCore(game, matchId, raw) {
174
142
  };
175
143
  return core;
176
144
  }
177
- /**
178
- * Rename team1/team2 -> player1/player2 (top level and the nested score
179
- * sub-object) for tennis only, at the point a matchCore object leaves this
180
- * module. Kept separate from matchCore itself so every internal reader
181
- * (scoreline strings, winner lookup, sideLabel) keeps working against the
182
- * stable team1/team2 shape; only the client-facing copy is renamed.
183
- */
184
145
  function toClientMatch(core, game) {
185
146
  return presentSides({ ...core, score: presentSides(core.score, game) }, game);
186
147
  }
@@ -202,18 +163,18 @@ function primaryPath(game, matchId) {
202
163
  }
203
164
  export const matchSummary = {
204
165
  name: 'match_summary',
205
- description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
206
-
207
- When to use:
208
- - Match recap / default match UI
209
- - After user selects a live or completed matchId
210
-
211
- Prefer over match_details for chat answers and default UIs.
212
- Prefer match_details for timelines, full map trees, live state, advanced packages.
213
-
214
- Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
215
-
216
- Parallel-safe: yes. Upstream cost: 2–5.
166
+ description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
167
+
168
+ When to use:
169
+ - Match recap / default match UI
170
+ - After user selects a live or completed matchId
171
+
172
+ Prefer over match_details for chat answers and default UIs.
173
+ Prefer match_details for timelines, full map trees, live state, advanced packages.
174
+
175
+ Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
176
+
177
+ Parallel-safe: yes. Upstream cost: 2–5.
217
178
  Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
218
179
  inputSchema: {
219
180
  type: 'object',
@@ -267,15 +228,10 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
267
228
  let primary = await getSection(ctx, primaryPath(game, matchId));
268
229
  upstreamCalls += 1;
269
230
  rateLimit = primary.headers;
270
- // Tennis live board hands out s365_* ids that live at /matches/live/{id}
271
- // until the match finishes and lands in the archive. Without this fallback
272
- // the board gives out ids that match_summary immediately 404s on.
273
231
  if (!primary.ok && game === 'tennis' && primary.status === 404) {
274
232
  primary = await getSection(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
275
233
  upstreamCalls += 1;
276
234
  rateLimit = { ...rateLimit, ...primary.headers };
277
- // Finished live matches archive under s365_{year}_{gid} while the live
278
- // board handed out s365_{gid} — insert the year before giving up.
279
235
  const gid = matchId.match(/^s365_(\d+)$/)?.[1];
280
236
  if (!primary.ok && gid) {
281
237
  const year = new Date().getUTCFullYear();
@@ -327,9 +283,6 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
327
283
  upstreamCalls += 1;
328
284
  rateLimit = { ...rateLimit, ...res.headers };
329
285
  if (!res.ok) {
330
- // Live tennis matches have no archive stats row yet, but the live
331
- // detail payload (already fetched as `primary`) carries the full
332
- // per-side stat block — serve that instead of a 404 partial.
333
286
  if (game === 'tennis' && res.status === 404) {
334
287
  const livePayload = asRecord(asRecord(primary.data)?.data) ?? asRecord(primary.data);
335
288
  const liveStats = livePayload?.stats;
@@ -369,25 +322,12 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
369
322
  }
370
323
  })());
371
324
  }
372
- // games/maps
373
325
  enrich.push((async () => {
374
- // Tennis has no per-set endpoint of its own; the set-by-set score is
375
- // already sitting in `primary` (the same payload matchCore built the
376
- // scoreline from). Pulling it out here means match_summary keeps the
377
- // set breakdown instead of shipping gamesOrMaps: [] next to a fully
378
- // populated `sets` array one field over.
379
326
  if (game === 'tennis') {
380
327
  const payload = asRecord(asRecord(primary.data)?.data) ?? asRecord(primary.data);
381
328
  const sets = Array.isArray(payload?.sets) ? payload.sets : [];
382
329
  gamesOrMaps = sets.map((s) => {
383
330
  const sr = asRecord(s) ?? {};
384
- // The live route (/tennis/matches/live/{id}) spells this set_num +
385
- // score + is_completed; the archive route (/tennis/matches/{id})
386
- // spells the same set set_number, with no per-set score string
387
- // (the match-level `score` already holds "6-4, 6-4, 2-1") and no
388
- // completion flag. Read both spellings rather than nulling out
389
- // real data because match_summary happened to resolve the other
390
- // route this time.
391
331
  const p1g = typeof sr.player1_games === 'number' ? sr.player1_games : null;
392
332
  const p2g = typeof sr.player2_games === 'number' ? sr.player2_games : null;
393
333
  return {
@@ -396,9 +336,6 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
396
336
  player1Games: p1g,
397
337
  player2Games: p2g,
398
338
  tiebreak: sr.tiebreak ?? null,
399
- // is_completed is a LIVE-route field. The archive route omits it,
400
- // so every set of a finished match read completed:false. Fall
401
- // back to the games, which always decide whether a set is over.
402
339
  completed: tennisSetCompleted(sr),
403
340
  };
404
341
  });
@@ -459,28 +396,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
459
396
  });
460
397
  },
461
398
  };
462
- /**
463
- * Fold a UFC bout's raw odds payload into something an agent can read.
464
- *
465
- * The REST response is deliberately complete: every bookmaker, every market, up
466
- * to roughly 950 outcomes for a single bout. Handing that back whole would bury
467
- * the two numbers a caller almost always wants, and the server's own
468
- * instructions say not to flood odds. So the moneyline is summarised per fighter
469
- * and every other market is reduced to a count, with the raw endpoint named so
470
- * anyone who genuinely needs the full book can go and get it.
471
- *
472
- * isAvailable is carried through as currentlyOffered rather than filtered on. It
473
- * means "a book is offering this right now", so a finished fight's closing line
474
- * comes back with it false, and that is information rather than absence.
475
- * Filtering here would recreate the bug where whole settled cards looked like
476
- * they had no odds at all.
477
- */
478
399
  export function summarizeUfcOdds(data) {
479
- // fetchJson hands back the whole REST envelope ({ success, data, meta }), not
480
- // the inner payload, so unwrap one level when it is there. Accepting both
481
- // shapes because the unit tests feed the inner object directly; reading only
482
- // the outer one returned a perfectly shaped, perfectly empty summary, which
483
- // looked like "this bout has no odds" rather than like a bug.
484
400
  const root = (data ?? {});
485
401
  const payload = (root.data && typeof root.data === 'object' && !Array.isArray(root.data)
486
402
  ? root.data
@@ -523,9 +439,6 @@ export function summarizeUfcOdds(data) {
523
439
  otherMarkets.set(type, other);
524
440
  }
525
441
  }
526
- // American odds: the best price is the highest number both for an underdog
527
- // (+250 beats +180) and for a favourite (-110 beats -200), so it is simply the
528
- // maximum in both cases.
529
442
  const moneyline = [...byFighter.values()].map((entry) => {
530
443
  const sorted = [...entry.prices].sort((a, b) => a - b);
531
444
  const mid = sorted.length
@@ -555,33 +468,12 @@ export function summarizeUfcOdds(data) {
555
468
  fullBookPath: '/ufc/bouts/{boutId}/odds',
556
469
  };
557
470
  }
558
- /**
559
- * Per-period ("ALL / 1ST / 2ND / 3RD") statistics.
560
- *
561
- * The REST route gained `set_stats`: the same serving/returning figures split by
562
- * set. Every field is nullable and a null is a STATEMENT, not a zero -- 0 aces is
563
- * a claim, "we do not know" is not. `missing` names the nulls per side and `gaps`
564
- * says why each one is absent, so a caller can tell these three apart:
565
- * PERIOD_NO_STATS no stats exist for that period at all
566
- * INCOMPLETE_PERIOD_STATS the period exists but a field is unknown
567
- * SOURCE_UNAVAILABLE / SOURCE_NOT_MAPPED
568
- * a source was consulted and did not deliver
569
- * Collapsing any of that into zero, or dropping `gaps`, would delete the very
570
- * information that makes the block trustworthy.
571
- */
572
471
  export function summarizeTennisSetStats(block) {
573
472
  const b = asRecord(block);
574
473
  if (!b)
575
474
  return null;
576
475
  const arr = (v) => (Array.isArray(v) ? v : []);
577
476
  const strings = (v) => arr(v).map(String);
578
- /**
579
- * An object carrying nothing at all is not a block: the API sends
580
- * `set_stats: null` when it has no rows, so `{}` can only be a caller's stub,
581
- * and `setStats: null` says "no information" more honestly than a block of
582
- * empty arrays. A block with ONLY gaps is kept, because the gaps are the
583
- * information.
584
- */
585
477
  if (!arr(b.periods).length && !arr(b.gaps).length && !arr(b.sources).length
586
478
  && typeof b.note !== 'string') {
587
479
  return null;
@@ -645,11 +537,6 @@ export function summarizeTennisSetStats(block) {
645
537
  note: str(b.note),
646
538
  };
647
539
  }
648
- /**
649
- * One line per stated absence, for the envelope's meta.warnings. The gaps also
650
- * ride inside the section; this is what makes them visible to a client that only
651
- * reads the envelope.
652
- */
653
540
  export function tennisSetStatsWarnings(block) {
654
541
  const shaped = summarizeTennisSetStats(block);
655
542
  if (!shaped)
@@ -660,28 +547,11 @@ export function tennisSetStatsWarnings(block) {
660
547
  const where = [gap.period, gap.side].filter(Boolean).join(' ');
661
548
  out.push(`set_stats ${gap.code}${where ? ` (${where})` : ''}: ${gap.message}`);
662
549
  }
663
- // One warning per distinct code is enough for a client that only needs to know
664
- // the block is not fully populated; the section carries the detail.
665
550
  const note = shaped.note;
666
551
  if (gaps.length && typeof note === 'string')
667
552
  out.unshift(`set_stats: ${note}`);
668
553
  return out.slice(0, 6);
669
554
  }
670
- /**
671
- * Tennis per-match stats projection.
672
- *
673
- * `/tennis/matches/{id}/stats` does NOT return a row per player the way every
674
- * other game's player-stats route does. It returns
675
- * `{ stats: { winner: {...}, loser: {...} }, sets: [...], score, set_stats }` —
676
- * two objects keyed by OUTCOME. `match_details sections:["playerStats"]` had no
677
- * tennis arm at all, so it answered `playerStats: null` while `match_summary`
678
- * was filling `playerPerformances` from the same route.
679
- *
680
- * The sides stay keyed by outcome rather than being mislabelled as player1 /
681
- * player2, because the payload carries no names and the ordering is not
682
- * guaranteed to match the base row. `match.player1.is_winner` on the base
683
- * section is what maps a name onto these numbers, and `sidesKeyedBy` says so.
684
- */
685
555
  export function summarizeTennisMatchStats(data) {
686
556
  const root = asRecord(data) ?? {};
687
557
  const payload = asRecord(root.data) ?? root;
@@ -710,31 +580,29 @@ export function summarizeTennisMatchStats(data) {
710
580
  completed: tennisSetCompleted(sr),
711
581
  };
712
582
  }),
713
- // ALL / 1ST / 2ND / 3RD ... Null when the API has no per-period rows for
714
- // this match, which is a different statement from an ingested empty period.
715
583
  setStats: summarizeTennisSetStats(payload.set_stats),
716
584
  rawPath: '/tennis/matches/{matchId}/stats',
717
585
  };
718
586
  }
719
587
  export const matchDetails = {
720
588
  name: 'match_details',
721
- description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
722
-
723
- When to use:
724
- - Analyst deep dive
725
- - Live in-game window (LoL/CS2/UFC)
726
- - Full demo list
727
-
728
- Prefer over match_summary only when summary is insufficient.
729
- Prefer match_summary for short answers and default cards.
730
-
731
- Do not use when: first-pass live board (use live_matches + match_summary).
732
-
733
- Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
734
- UFC betting lines: sections:["odds"] (opt-in, never in the default set).
735
- If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
736
-
737
- Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
589
+ description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
590
+
591
+ When to use:
592
+ - Analyst deep dive
593
+ - Live in-game window (LoL/CS2/UFC)
594
+ - Full demo list
595
+
596
+ Prefer over match_summary only when summary is insufficient.
597
+ Prefer match_summary for short answers and default cards.
598
+
599
+ Do not use when: first-pass live board (use live_matches + match_summary).
600
+
601
+ Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
602
+ UFC betting lines: sections:["odds"] (opt-in, never in the default set).
603
+ If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
604
+
605
+ Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
738
606
  Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
739
607
  inputSchema: {
740
608
  type: 'object',
@@ -810,19 +678,11 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
810
678
  media: null,
811
679
  advanced: null,
812
680
  };
813
- // base is ALWAYS fetched, whatever sections were asked for. Requesting
814
- // sections:["timeline"] returned match:null, so a caller got a timeline with
815
- // nothing to attach it to and no way to tell which match it belonged to.
816
- // The header is the identity of the response, not an optional section.
817
681
  wanted.add('base');
818
682
  if (wanted.has('base')) {
819
683
  let res = await getSection(ctx, primaryPath(game, matchId));
820
684
  upstreamCalls += 1;
821
685
  rateLimit = res.headers;
822
- // Same fallback match_summary already has: the live board hands out
823
- // s365_* ids that only resolve at /matches/live/{id} until the match
824
- // finishes and archives. Without this, a matchId taken straight off
825
- // live_matches 404s here even though match_summary resolves it fine.
826
686
  if (!res.ok && game === 'tennis' && res.status === 404) {
827
687
  res = await getSection(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
828
688
  upstreamCalls += 1;
@@ -869,10 +729,6 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
869
729
  }
870
730
  })());
871
731
  };
872
- // Same as load(), but folds the payload through a shaper first. Raw odds are
873
- // far too big to hand back whole: one bout carries up to ~950 outcomes across
874
- // every bookmaker and prop market, which would bury the answer it was fetched
875
- // to give.
876
732
  const loadWith = (section, path, shape, query) => {
877
733
  tasks.push((async () => {
878
734
  const res = await getSection(ctx, path, query);
@@ -902,8 +758,6 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
902
758
  load('playerStats', `/cod/matches/${encodeURIComponent(matchId)}/player-stats`);
903
759
  if (game === 'ufc')
904
760
  load('playerStats', `/ufc/bouts/${encodeURIComponent(matchId)}/stats`);
905
- // Tennis has a real per-match stats route; the section simply had no arm
906
- // for it and shipped playerStats:null forever.
907
761
  if (game === 'tennis') {
908
762
  loadWith('playerStats', `/tennis/matches/${encodeURIComponent(matchId)}/stats`, summarizeTennisMatchStats);
909
763
  }
@@ -916,7 +770,6 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
916
770
  if (game === 'ufc')
917
771
  load('gamesOrMaps', `/ufc/bouts/${encodeURIComponent(matchId)}/rounds`);
918
772
  if (game === 'cs2' || game === 'dota2') {
919
- // no dedicated maps list — leave null with note via partial only if requested alone
920
773
  sections.gamesOrMaps = sections.base;
921
774
  }
922
775
  }
@@ -954,11 +807,6 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
954
807
  loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
955
808
  }
956
809
  else {
957
- // Tennis odds were REMOVED 2026-09-13 alongside the /tennis/odds/* routes.
958
- // Betting data concentrates the legal risk and is the subject of every
959
- // significant dispute researched (see docs/tennis-risk-register.md), so
960
- // it is no longer offered. Requesting the section is now an explicit
961
- // NOT_IMPLEMENTED rather than a call to a route that returns 404.
962
810
  partial.push(partialFromRejection('odds', {
963
811
  code: 'NOT_IMPLEMENTED',
964
812
  message: `Odds are not curated for ${game}. Currently available: UFC (/ufc/bouts/{id}/odds). Tennis odds were withdrawn.`,
@@ -988,9 +836,6 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
988
836
  }
989
837
  }
990
838
  await Promise.all(tasks);
991
- // Per-period coverage gaps ride in meta.warnings as well as inside the
992
- // playerStats section, so a client that only reads the envelope still learns
993
- // that the ALL / 1ST / 2ND / 3RD block is not fully populated and why.
994
839
  const warnings = [];
995
840
  if (game === 'tennis') {
996
841
  const ps = asRecord(sections.playerStats);
@@ -1,6 +1,3 @@
1
- /**
2
- * Meta / power tools: list_capabilities, api_health, call_api.
3
- */
4
1
  import { extractRows, fetchJson, gameNotIncludedHint, present } from '../client.js';
5
2
  import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
6
3
  import { boolSchema, gameSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
@@ -368,7 +365,7 @@ const TOOL_CATALOG = [
368
365
  parallelSafe: true,
369
366
  games: ['cs2'],
370
367
  jobs: ['team_page', 'preview'],
371
- exampleArgs: { teamId: 'hltv-team-4608', days: 90 },
368
+ exampleArgs: { teamId: 'cs2-team-4608', days: 90 },
372
369
  preferOver: ['manual match history scraping'],
373
370
  doNotUse: 'For non-CS2 games',
374
371
  },
@@ -433,8 +430,6 @@ const JOBS = [
433
430
  { id: 'schedule', description: 'Upcoming fixtures/events', recommendedTools: ['upcoming_schedule', 'event_card'] },
434
431
  { id: 'preview', description: 'Pre-match briefing', recommendedTools: ['match_preview'] },
435
432
  { id: 'event_card', description: 'Event / fight-night card page', recommendedTools: ['event_card', 'resolve_entity', 'match_preview'] },
436
- // Agents asked "does this API have photos?" and, finding no job for it,
437
- // assumed no. Photos ship on every UFC corner and profile; make that findable.
438
433
  {
439
434
  id: 'media',
440
435
  description: 'Headshots, full-body shots and team logos for visual UIs. UFC fighters carry headshotUrl / bodyImageUrl / imageUrl plus a CORS-safe proxiedImageUrl; use the proxied URL in a browser. Available on event_card corners and player_profile without any extra call.',
@@ -643,7 +638,6 @@ Example: { "includeGameProbes": true }`,
643
638
  });
644
639
  }
645
640
  else {
646
- // try product root
647
641
  const cs2 = await fetchJson(ctx, '/cs2');
648
642
  upstreamCalls += 1;
649
643
  rateLimit = cs2.headers;
@@ -660,11 +654,10 @@ Example: { "includeGameProbes": true }`,
660
654
  }
661
655
  }
662
656
  else {
663
- // health may be unauthenticated — confirm key with light authed probe
664
657
  const probe = await fetchJson(ctx, '/lol/leagues');
665
658
  upstreamCalls += 1;
666
659
  rateLimit = { ...rateLimit, ...probe.headers };
667
- keyValid = probe.ok || probe.status === 403; // 403 may still mean key accepted but gated
660
+ keyValid = probe.ok || probe.status === 403;
668
661
  if (probe.ok)
669
662
  answeredBy = '/health+/lol/leagues';
670
663
  if (probe.status === 401) {
@@ -693,10 +686,8 @@ Example: { "includeGameProbes": true }`,
693
686
  if (res.ok)
694
687
  includedGames.push(game);
695
688
  else if (res.status === 403 && gameNotIncludedHint(res.data)) {
696
- // not included
697
689
  }
698
690
  else if (res.status !== 401 && res.status !== 404) {
699
- // ambiguous — still list as probed
700
691
  }
701
692
  rateLimit = { ...rateLimit, ...res.headers };
702
693
  }
@@ -733,8 +724,6 @@ const ALLOWLIST_PREFIXES = [
733
724
  '/ufc',
734
725
  '/fortnite',
735
726
  '/tennis',
736
- // The spec describes the surface call_api is allowed to reach; refusing to
737
- // serve it left route discovery impossible except by guessing.
738
727
  '/openapi.json',
739
728
  ];
740
729
  function pathAllowed(path) {
@@ -770,9 +759,9 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
770
759
  properties: {
771
760
  method: {
772
761
  type: 'string',
773
- enum: ['GET', 'POST'],
762
+ enum: ['GET'],
774
763
  default: 'GET',
775
- description: 'HTTP method. Prefer GET. Example: "GET".',
764
+ description: 'HTTP method. GET only: call_api is read-only. Example: "GET".',
776
765
  },
777
766
  path: stringSchema('Path starting with /. Allowlisted prefixes only. Example: "/cs2/rankings/teams".', '/cs2/rankings/teams'),
778
767
  queryJson: {
@@ -784,10 +773,6 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
784
773
  type: 'object',
785
774
  description: 'Query params as a plain object (alternative to queryJson). Example: {"year":2026}.',
786
775
  },
787
- bodyJson: {
788
- type: 'string',
789
- description: 'Stringified JSON body for POST only (rare).',
790
- },
791
776
  },
792
777
  },
793
778
  handler: async (args, ctx) => {
@@ -805,10 +790,10 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
805
790
  tookMs: Date.now() - started,
806
791
  });
807
792
  }
808
- if (method !== 'GET' && method !== 'POST') {
793
+ if (method !== 'GET') {
809
794
  return errorEnvelope({
810
795
  code: 'VALIDATION',
811
- message: 'method must be GET or POST',
796
+ message: 'method must be GET; call_api is read-only',
812
797
  game: null,
813
798
  source: 'call_api',
814
799
  requestId,
@@ -827,8 +812,6 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
827
812
  });
828
813
  }
829
814
  let query = {};
830
- // Agents pass query as a plain object at least as often as the stringified
831
- // queryJson form; silently dropping it produced confusing upstream 422s.
832
815
  if (args.query && typeof args.query === 'object' && !Array.isArray(args.query)) {
833
816
  query = { ...args.query };
834
817
  }
@@ -851,23 +834,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
851
834
  });
852
835
  }
853
836
  }
854
- let body;
855
- if (method === 'POST' && typeof args.bodyJson === 'string' && args.bodyJson.trim()) {
856
- try {
857
- body = JSON.parse(args.bodyJson);
858
- }
859
- catch (e) {
860
- return errorEnvelope({
861
- code: 'VALIDATION',
862
- message: `Invalid bodyJson: ${e.message}`,
863
- game: null,
864
- source: 'call_api',
865
- requestId,
866
- tookMs: Date.now() - started,
867
- });
868
- }
869
- }
870
- const res = await fetchJson(ctx, path, { method, query, body });
837
+ const res = await fetchJson(ctx, path, { method, query, raw: true });
871
838
  if (!res.ok) {
872
839
  return errorEnvelope({
873
840
  code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
@@ -899,14 +866,6 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
899
866
  });
900
867
  },
901
868
  };
902
- /**
903
- * Games whose routes the published spec does not describe.
904
- *
905
- * The spec has 123 paths and zero under /lol, yet every LoL route works —
906
- * api_health itself answers partly from /lol/leagues. An agent that treats the
907
- * spec as the whole surface concludes LoL is unsupported and stops, so say so
908
- * explicitly rather than returning an empty list that reads as a verdict.
909
- */
910
869
  const SPEC_OMITS = {
911
870
  tennis: 'The published OpenAPI spec documents no /tennis paths, but tennis routes exist and work (players, matches, h2h, rankings, tournaments, live). Use the curated tools with game tennis, or call_api with /tennis/* paths.',
912
871
  lol: 'The published OpenAPI spec documents no /lol paths, but LoL routes exist and work. Use the curated LoL tools (live_matches, upcoming_schedule, team_profile, standings, player_profile); for raw access, /lol/* is allowlisted for call_api even though it is undocumented.',
@@ -949,9 +908,6 @@ Example: { "game": "ufc", "q": "rankings" }`,
949
908
  handler: async (args, ctx) => {
950
909
  const started = Date.now();
951
910
  const requestId = newRequestId();
952
- // Accept fortnite here even though it is not a PRIMARY_GAME: it has 21
953
- // documented paths and no curated tools, so it is exactly what call_api
954
- // callers come looking for.
955
911
  const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
956
912
  if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis)$/)) {
957
913
  return errorEnvelope({
@@ -1042,5 +998,4 @@ Example: { "game": "ufc", "q": "rankings" }`,
1042
998
  },
1043
999
  };
1044
1000
  export const metaTools = [listCapabilities, apiHealth, listRoutes, callApi];
1045
- // silence unused import in case extractRows needed later
1046
1001
  void extractRows;