cito-mcp 0.4.3 → 0.4.5

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,21 +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
- import { summarizeTennisOdds } from './odds.js';
8
4
  import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from './types.js';
9
5
  async function getSection(ctx, path, query) {
10
6
  return fetchJson(ctx, path, { query });
11
7
  }
12
- /**
13
- * Series length. The LoL feed states it as `strategy: "Bo5"`, which nothing was
14
- * reading, so every match reported bestOf: null. Accept the spelled forms and
15
- * the object shape other titles use, and fall back to the declared game count.
16
- * Only 1/3/5/7/9 are real series lengths, so anything else is refused rather
17
- * than guessed.
18
- */
19
8
  export function parseBestOf(r) {
20
9
  const fromNumber = (v) => {
21
10
  const n = Number(v);
@@ -33,7 +22,6 @@ export function parseBestOf(r) {
33
22
  return n;
34
23
  }
35
24
  }
36
- // { type: "bestOf", count: 5 }
37
25
  if (strat && typeof strat === 'object') {
38
26
  const o = strat;
39
27
  const n = fromNumber(o.count) ?? fromNumber(o.value) ?? fromNumber(o.bestOf);
@@ -62,11 +50,6 @@ function matchCore(game, matchId, raw) {
62
50
  const scoreline = m.team1 || m.team2
63
51
  ? `${m.team1?.name ?? '?'} ${s1 ?? '-'} : ${s2 ?? '-'} ${m.team2?.name ?? '?'}`
64
52
  : null;
65
- /**
66
- * Tennis: `score` is the set-by-set game score ("4-6 6-3 6-3 7-5"), which is
67
- * a DIFFERENT fact from the sets-won scoreline. "3 : 1" is the result; it
68
- * cannot distinguish 6-0 6-0 from three tiebreaks. Both are carried.
69
- */
70
53
  const scoreDetail = game === 'tennis' ? (pickString(r.score, r.score_raw) ?? null) : null;
71
54
  const core = {
72
55
  matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
@@ -86,11 +69,6 @@ function matchCore(game, matchId, raw) {
86
69
  team2: m.team2,
87
70
  event: m.event,
88
71
  league: m.league,
89
- // method/methodRaw/weightClass/referee are combat-sport vocabulary. They
90
- // stayed on every match_summary/match_details card regardless of game, so
91
- // a tennis card carried referee:null and weightClass:null — foreign
92
- // fields, not merely empty ones. UFC keeps them; every other game omits
93
- // the keys entirely rather than shipping them as null.
94
72
  ...(game === 'ufc'
95
73
  ? {
96
74
  method: normalizeUfcMethod(pickString(r.method, r.resultMethod)) ?? null,
@@ -98,18 +76,13 @@ function matchCore(game, matchId, raw) {
98
76
  }
99
77
  : {}),
100
78
  winner: (() => {
101
- // Never name a winner while play is in progress: the score fallback
102
- // below reads "who is ahead", which mid-match is a lead, not a result.
103
79
  if (m.status === 'live' || m.status === 'upcoming')
104
80
  return null;
105
- // UFC rows carry an explicit winner slug/fighter ref; tennis archive
106
- // rows nest a winner object.
107
81
  const explicit = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, asRecord(r.winner)?.id);
108
82
  if (explicit)
109
83
  return explicit;
110
84
  if (game === 'ufc')
111
85
  return null;
112
- // CS2 rows expose winnerTeamId instead; map it to a side, else fall back to scores.
113
86
  const sideLabel = (side) => side ? pickString(side.slug, side.id, side.name) ?? null : null;
114
87
  const winnerTeamId = pickString(r.winnerTeamId, r.winner_team_id);
115
88
  if (winnerTeamId) {
@@ -156,8 +129,6 @@ function matchCore(game, matchId, raw) {
156
129
  }
157
130
  : {}),
158
131
  rawStatus: pickString(r.status, r.state) ?? null,
159
- // Point-level live state (tennis): current game score like "40-AD", which
160
- // side is serving, and the set in progress. Absent for other games.
161
132
  ...(game === 'tennis' && (r.game_score || r.server)
162
133
  ? {
163
134
  liveState: {
@@ -171,13 +142,6 @@ function matchCore(game, matchId, raw) {
171
142
  };
172
143
  return core;
173
144
  }
174
- /**
175
- * Rename team1/team2 -> player1/player2 (top level and the nested score
176
- * sub-object) for tennis only, at the point a matchCore object leaves this
177
- * module. Kept separate from matchCore itself so every internal reader
178
- * (scoreline strings, winner lookup, sideLabel) keeps working against the
179
- * stable team1/team2 shape; only the client-facing copy is renamed.
180
- */
181
145
  function toClientMatch(core, game) {
182
146
  return presentSides({ ...core, score: presentSides(core.score, game) }, game);
183
147
  }
@@ -264,15 +228,10 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
264
228
  let primary = await getSection(ctx, primaryPath(game, matchId));
265
229
  upstreamCalls += 1;
266
230
  rateLimit = primary.headers;
267
- // Tennis live board hands out s365_* ids that live at /matches/live/{id}
268
- // until the match finishes and lands in the archive. Without this fallback
269
- // the board gives out ids that match_summary immediately 404s on.
270
231
  if (!primary.ok && game === 'tennis' && primary.status === 404) {
271
232
  primary = await getSection(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
272
233
  upstreamCalls += 1;
273
234
  rateLimit = { ...rateLimit, ...primary.headers };
274
- // Finished live matches archive under s365_{year}_{gid} while the live
275
- // board handed out s365_{gid} — insert the year before giving up.
276
235
  const gid = matchId.match(/^s365_(\d+)$/)?.[1];
277
236
  if (!primary.ok && gid) {
278
237
  const year = new Date().getUTCFullYear();
@@ -324,9 +283,6 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
324
283
  upstreamCalls += 1;
325
284
  rateLimit = { ...rateLimit, ...res.headers };
326
285
  if (!res.ok) {
327
- // Live tennis matches have no archive stats row yet, but the live
328
- // detail payload (already fetched as `primary`) carries the full
329
- // per-side stat block — serve that instead of a 404 partial.
330
286
  if (game === 'tennis' && res.status === 404) {
331
287
  const livePayload = asRecord(asRecord(primary.data)?.data) ?? asRecord(primary.data);
332
288
  const liveStats = livePayload?.stats;
@@ -366,25 +322,12 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
366
322
  }
367
323
  })());
368
324
  }
369
- // games/maps
370
325
  enrich.push((async () => {
371
- // Tennis has no per-set endpoint of its own; the set-by-set score is
372
- // already sitting in `primary` (the same payload matchCore built the
373
- // scoreline from). Pulling it out here means match_summary keeps the
374
- // set breakdown instead of shipping gamesOrMaps: [] next to a fully
375
- // populated `sets` array one field over.
376
326
  if (game === 'tennis') {
377
327
  const payload = asRecord(asRecord(primary.data)?.data) ?? asRecord(primary.data);
378
328
  const sets = Array.isArray(payload?.sets) ? payload.sets : [];
379
329
  gamesOrMaps = sets.map((s) => {
380
330
  const sr = asRecord(s) ?? {};
381
- // The live route (/tennis/matches/live/{id}) spells this set_num +
382
- // score + is_completed; the archive route (/tennis/matches/{id})
383
- // spells the same set set_number, with no per-set score string
384
- // (the match-level `score` already holds "6-4, 6-4, 2-1") and no
385
- // completion flag. Read both spellings rather than nulling out
386
- // real data because match_summary happened to resolve the other
387
- // route this time.
388
331
  const p1g = typeof sr.player1_games === 'number' ? sr.player1_games : null;
389
332
  const p2g = typeof sr.player2_games === 'number' ? sr.player2_games : null;
390
333
  return {
@@ -393,9 +336,6 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
393
336
  player1Games: p1g,
394
337
  player2Games: p2g,
395
338
  tiebreak: sr.tiebreak ?? null,
396
- // is_completed is a LIVE-route field. The archive route omits it,
397
- // so every set of a finished match read completed:false. Fall
398
- // back to the games, which always decide whether a set is over.
399
339
  completed: tennisSetCompleted(sr),
400
340
  };
401
341
  });
@@ -456,28 +396,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
456
396
  });
457
397
  },
458
398
  };
459
- /**
460
- * Fold a UFC bout's raw odds payload into something an agent can read.
461
- *
462
- * The REST response is deliberately complete: every bookmaker, every market, up
463
- * to roughly 950 outcomes for a single bout. Handing that back whole would bury
464
- * the two numbers a caller almost always wants, and the server's own
465
- * instructions say not to flood odds. So the moneyline is summarised per fighter
466
- * and every other market is reduced to a count, with the raw endpoint named so
467
- * anyone who genuinely needs the full book can go and get it.
468
- *
469
- * isAvailable is carried through as currentlyOffered rather than filtered on. It
470
- * means "a book is offering this right now", so a finished fight's closing line
471
- * comes back with it false, and that is information rather than absence.
472
- * Filtering here would recreate the bug where whole settled cards looked like
473
- * they had no odds at all.
474
- */
475
399
  export function summarizeUfcOdds(data) {
476
- // fetchJson hands back the whole REST envelope ({ success, data, meta }), not
477
- // the inner payload, so unwrap one level when it is there. Accepting both
478
- // shapes because the unit tests feed the inner object directly; reading only
479
- // the outer one returned a perfectly shaped, perfectly empty summary, which
480
- // looked like "this bout has no odds" rather than like a bug.
481
400
  const root = (data ?? {});
482
401
  const payload = (root.data && typeof root.data === 'object' && !Array.isArray(root.data)
483
402
  ? root.data
@@ -520,9 +439,6 @@ export function summarizeUfcOdds(data) {
520
439
  otherMarkets.set(type, other);
521
440
  }
522
441
  }
523
- // American odds: the best price is the highest number both for an underdog
524
- // (+250 beats +180) and for a favourite (-110 beats -200), so it is simply the
525
- // maximum in both cases.
526
442
  const moneyline = [...byFighter.values()].map((entry) => {
527
443
  const sorted = [...entry.prices].sort((a, b) => a - b);
528
444
  const mid = sorted.length
@@ -552,21 +468,90 @@ export function summarizeUfcOdds(data) {
552
468
  fullBookPath: '/ufc/bouts/{boutId}/odds',
553
469
  };
554
470
  }
555
- /**
556
- * Tennis per-match stats projection.
557
- *
558
- * `/tennis/matches/{id}/stats` does NOT return a row per player the way every
559
- * other game's player-stats route does. It returns
560
- * `{ stats: { winner: {...}, loser: {...} }, sets: [...], score }` — two objects
561
- * keyed by OUTCOME. `match_details sections:["playerStats"]` had no tennis arm
562
- * at all, so it answered `playerStats: null` while `match_summary` was filling
563
- * `playerPerformances` from the same route.
564
- *
565
- * The sides stay keyed by outcome rather than being mislabelled as player1 /
566
- * player2, because the payload carries no names and the ordering is not
567
- * guaranteed to match the base row. `match.player1.is_winner` on the base
568
- * section is what maps a name onto these numbers, and `sidesKeyedBy` says so.
569
- */
471
+ export function summarizeTennisSetStats(block) {
472
+ const b = asRecord(block);
473
+ if (!b)
474
+ return null;
475
+ const arr = (v) => (Array.isArray(v) ? v : []);
476
+ const strings = (v) => arr(v).map(String);
477
+ if (!arr(b.periods).length && !arr(b.gaps).length && !arr(b.sources).length
478
+ && typeof b.note !== 'string') {
479
+ return null;
480
+ }
481
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
482
+ const str = (v) => (typeof v === 'string' ? v : null);
483
+ const line = (side) => {
484
+ const s = asRecord(side) ?? {};
485
+ return {
486
+ aces: num(s.aces),
487
+ doubleFaults: num(s.double_faults),
488
+ firstServePct: num(s.first_serve_pct),
489
+ firstServeWonPct: num(s.first_serve_win_pct),
490
+ secondServeWonPct: num(s.second_serve_win_pct),
491
+ breakPointsSaved: num(s.break_points_saved),
492
+ breakPointsFaced: num(s.break_points_faced),
493
+ breakPointsConverted: num(s.break_points_converted),
494
+ breakPointOpportunities: num(s.break_point_opportunities),
495
+ totalPointsWon: num(s.total_points_won),
496
+ servicePointsPlayed: num(s.serve_points),
497
+ servicePointsWon: num(s.service_points_won),
498
+ gamesWon: num(s.games_won),
499
+ };
500
+ };
501
+ return {
502
+ periods: arr(b.periods).map((p) => {
503
+ const pr = asRecord(p) ?? {};
504
+ const missing = asRecord(pr.missing) ?? {};
505
+ return {
506
+ period: str(pr.period) ?? 'ALL',
507
+ setNumber: num(pr.set_number) ?? 0,
508
+ status: str(pr.status) ?? 'partial',
509
+ sources: strings(pr.sources),
510
+ winner: line(pr.winner),
511
+ loser: line(pr.loser),
512
+ missing: { winner: strings(missing.winner), loser: strings(missing.loser) },
513
+ };
514
+ }),
515
+ sources: arr(b.sources).map((s) => {
516
+ const sr = asRecord(s) ?? {};
517
+ return {
518
+ source: str(sr.source) ?? 'unknown',
519
+ status: str(sr.status) ?? 'unknown',
520
+ fields: strings(sr.fields),
521
+ detail: str(sr.detail),
522
+ };
523
+ }),
524
+ gaps: arr(b.gaps).map((g) => {
525
+ const gr = asRecord(g) ?? {};
526
+ return {
527
+ code: str(gr.code) ?? 'UNKNOWN',
528
+ period: str(gr.period),
529
+ setNumber: num(gr.set_number),
530
+ side: str(gr.side),
531
+ fields: strings(gr.fields),
532
+ source: str(gr.source),
533
+ status: str(gr.status),
534
+ message: str(gr.message) ?? '',
535
+ };
536
+ }),
537
+ note: str(b.note),
538
+ };
539
+ }
540
+ export function tennisSetStatsWarnings(block) {
541
+ const shaped = summarizeTennisSetStats(block);
542
+ if (!shaped)
543
+ return [];
544
+ const gaps = shaped.gaps ?? [];
545
+ const out = [];
546
+ for (const gap of gaps) {
547
+ const where = [gap.period, gap.side].filter(Boolean).join(' ');
548
+ out.push(`set_stats ${gap.code}${where ? ` (${where})` : ''}: ${gap.message}`);
549
+ }
550
+ const note = shaped.note;
551
+ if (gaps.length && typeof note === 'string')
552
+ out.unshift(`set_stats: ${note}`);
553
+ return out.slice(0, 6);
554
+ }
570
555
  export function summarizeTennisMatchStats(data) {
571
556
  const root = asRecord(data) ?? {};
572
557
  const payload = asRecord(root.data) ?? root;
@@ -595,6 +580,7 @@ export function summarizeTennisMatchStats(data) {
595
580
  completed: tennisSetCompleted(sr),
596
581
  };
597
582
  }),
583
+ setStats: summarizeTennisSetStats(payload.set_stats),
598
584
  rawPath: '/tennis/matches/{matchId}/stats',
599
585
  };
600
586
  }
@@ -632,7 +618,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
632
618
  type: 'string',
633
619
  enum: ['base', 'playerStats', 'gamesOrMaps', 'timeline', 'liveState', 'media', 'advanced', 'odds'],
634
620
  },
635
- description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" is opt-in: UFC returns moneyline summarised per fighter with bookmaker count, best and median American price and implied probability plus a count of every other market (closing lines for a finished fight come back with currentlyOffered=false rather than being omitted); tennis returns the same bookmaker/market/outcome projection that tennis_odds serves, including oddsAvailable=false when no book is quoting. Odds exist for UFC and tennis only.',
621
+ description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" is opt-in: UFC returns moneyline summarised per fighter with bookmaker count, best and median American price and implied probability plus a count of every other market (closing lines for a finished fight come back with currentlyOffered=false rather than being omitted). Tennis odds were withdrawn alongside the /tennis/odds/* routes, so for any other game the section reports NOT_IMPLEMENTED. Odds exist for UFC only.',
636
622
  },
637
623
  includeTimeline: boolSchema('Include timeline section (heavy).', false),
638
624
  includeLiveState: boolSchema('Include live state/snapshots.', false),
@@ -692,19 +678,11 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
692
678
  media: null,
693
679
  advanced: null,
694
680
  };
695
- // base is ALWAYS fetched, whatever sections were asked for. Requesting
696
- // sections:["timeline"] returned match:null, so a caller got a timeline with
697
- // nothing to attach it to and no way to tell which match it belonged to.
698
- // The header is the identity of the response, not an optional section.
699
681
  wanted.add('base');
700
682
  if (wanted.has('base')) {
701
683
  let res = await getSection(ctx, primaryPath(game, matchId));
702
684
  upstreamCalls += 1;
703
685
  rateLimit = res.headers;
704
- // Same fallback match_summary already has: the live board hands out
705
- // s365_* ids that only resolve at /matches/live/{id} until the match
706
- // finishes and archives. Without this, a matchId taken straight off
707
- // live_matches 404s here even though match_summary resolves it fine.
708
686
  if (!res.ok && game === 'tennis' && res.status === 404) {
709
687
  res = await getSection(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
710
688
  upstreamCalls += 1;
@@ -751,10 +729,6 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
751
729
  }
752
730
  })());
753
731
  };
754
- // Same as load(), but folds the payload through a shaper first. Raw odds are
755
- // far too big to hand back whole: one bout carries up to ~950 outcomes across
756
- // every bookmaker and prop market, which would bury the answer it was fetched
757
- // to give.
758
732
  const loadWith = (section, path, shape, query) => {
759
733
  tasks.push((async () => {
760
734
  const res = await getSection(ctx, path, query);
@@ -784,8 +758,6 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
784
758
  load('playerStats', `/cod/matches/${encodeURIComponent(matchId)}/player-stats`);
785
759
  if (game === 'ufc')
786
760
  load('playerStats', `/ufc/bouts/${encodeURIComponent(matchId)}/stats`);
787
- // Tennis has a real per-match stats route; the section simply had no arm
788
- // for it and shipped playerStats:null forever.
789
761
  if (game === 'tennis') {
790
762
  loadWith('playerStats', `/tennis/matches/${encodeURIComponent(matchId)}/stats`, summarizeTennisMatchStats);
791
763
  }
@@ -798,7 +770,6 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
798
770
  if (game === 'ufc')
799
771
  load('gamesOrMaps', `/ufc/bouts/${encodeURIComponent(matchId)}/rounds`);
800
772
  if (game === 'cs2' || game === 'dota2') {
801
- // no dedicated maps list — leave null with note via partial only if requested alone
802
773
  sections.gamesOrMaps = sections.base;
803
774
  }
804
775
  }
@@ -835,17 +806,10 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
835
806
  if (game === 'ufc') {
836
807
  loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
837
808
  }
838
- else if (game === 'tennis') {
839
- // The gate that produced "Odds not curated for tennis; UFC only today"
840
- // was written before /tennis/odds/{id} existed. It does: the same match
841
- // returned FanDuel 1.105 / Matchbook 1.13 from tennis_odds while this
842
- // section denied odds existed. Both surfaces now share one projection.
843
- loadWith('odds', `/tennis/odds/${encodeURIComponent(matchId)}`, summarizeTennisOdds);
844
- }
845
809
  else {
846
810
  partial.push(partialFromRejection('odds', {
847
811
  code: 'NOT_IMPLEMENTED',
848
- message: `Odds are not curated for ${game}. Available: UFC (/ufc/bouts/{id}/odds) and tennis (/tennis/odds/{id}).`,
812
+ message: `Odds are not curated for ${game}. Currently available: UFC (/ufc/bouts/{id}/odds). Tennis odds were withdrawn.`,
849
813
  }));
850
814
  }
851
815
  }
@@ -872,6 +836,12 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
872
836
  }
873
837
  }
874
838
  await Promise.all(tasks);
839
+ const warnings = [];
840
+ if (game === 'tennis') {
841
+ const ps = asRecord(sections.playerStats);
842
+ if (ps)
843
+ warnings.push(...tennisSetStatsWarnings(ps.setStats));
844
+ }
875
845
  return successEnvelope({
876
846
  source: 'match_details',
877
847
  game,
@@ -879,6 +849,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
879
849
  tookMs: Date.now() - started,
880
850
  upstreamCalls,
881
851
  rateLimit,
852
+ warnings: warnings.length ? warnings : undefined,
882
853
  partial: partial.length ? partial : undefined,
883
854
  entities: { games: [game], ids: { matchId, ...(gameId ? { gameId } : {}) } },
884
855
  data: {
@@ -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';
@@ -186,16 +183,6 @@ const TOOL_CATALOG = [
186
183
  preferOver: ['raw rankings history via call_api'],
187
184
  doNotUse: 'Latest snapshot → standings; week-over-week delta → rankings_movers',
188
185
  },
189
- {
190
- name: 'tennis_odds',
191
- outcome: 'Tennis betting odds: upcoming matches with prices, plus pre-match and in-play for one match; upcoming rows carry a joinKey and playerIds because the feed leaves match_id null',
192
- parallelSafe: true,
193
- games: ['tennis'],
194
- jobs: ['preview', 'match_page', 'odds'],
195
- exampleArgs: { game: 'tennis', scope: 'upcoming', limit: 20 },
196
- preferOver: ['raw odds via call_api', 'match_preview (no prices)'],
197
- doNotUse: 'The result → match_details; rankings → standings',
198
- },
199
186
  {
200
187
  name: 'tournaments',
201
188
  outcome: 'Tennis tournament catalog: filter by year, tour, level, surface or country; a level name that names a tour also fixes it',
@@ -378,7 +365,7 @@ const TOOL_CATALOG = [
378
365
  parallelSafe: true,
379
366
  games: ['cs2'],
380
367
  jobs: ['team_page', 'preview'],
381
- exampleArgs: { teamId: 'hltv-team-4608', days: 90 },
368
+ exampleArgs: { teamId: 'cs2-team-4608', days: 90 },
382
369
  preferOver: ['manual match history scraping'],
383
370
  doNotUse: 'For non-CS2 games',
384
371
  },
@@ -443,8 +430,6 @@ const JOBS = [
443
430
  { id: 'schedule', description: 'Upcoming fixtures/events', recommendedTools: ['upcoming_schedule', 'event_card'] },
444
431
  { id: 'preview', description: 'Pre-match briefing', recommendedTools: ['match_preview'] },
445
432
  { id: 'event_card', description: 'Event / fight-night card page', recommendedTools: ['event_card', 'resolve_entity', 'match_preview'] },
446
- // Agents asked "does this API have photos?" and, finding no job for it,
447
- // assumed no. Photos ship on every UFC corner and profile; make that findable.
448
433
  {
449
434
  id: 'media',
450
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.',
@@ -653,7 +638,6 @@ Example: { "includeGameProbes": true }`,
653
638
  });
654
639
  }
655
640
  else {
656
- // try product root
657
641
  const cs2 = await fetchJson(ctx, '/cs2');
658
642
  upstreamCalls += 1;
659
643
  rateLimit = cs2.headers;
@@ -670,11 +654,10 @@ Example: { "includeGameProbes": true }`,
670
654
  }
671
655
  }
672
656
  else {
673
- // health may be unauthenticated — confirm key with light authed probe
674
657
  const probe = await fetchJson(ctx, '/lol/leagues');
675
658
  upstreamCalls += 1;
676
659
  rateLimit = { ...rateLimit, ...probe.headers };
677
- keyValid = probe.ok || probe.status === 403; // 403 may still mean key accepted but gated
660
+ keyValid = probe.ok || probe.status === 403;
678
661
  if (probe.ok)
679
662
  answeredBy = '/health+/lol/leagues';
680
663
  if (probe.status === 401) {
@@ -703,10 +686,8 @@ Example: { "includeGameProbes": true }`,
703
686
  if (res.ok)
704
687
  includedGames.push(game);
705
688
  else if (res.status === 403 && gameNotIncludedHint(res.data)) {
706
- // not included
707
689
  }
708
690
  else if (res.status !== 401 && res.status !== 404) {
709
- // ambiguous — still list as probed
710
691
  }
711
692
  rateLimit = { ...rateLimit, ...res.headers };
712
693
  }
@@ -743,8 +724,6 @@ const ALLOWLIST_PREFIXES = [
743
724
  '/ufc',
744
725
  '/fortnite',
745
726
  '/tennis',
746
- // The spec describes the surface call_api is allowed to reach; refusing to
747
- // serve it left route discovery impossible except by guessing.
748
727
  '/openapi.json',
749
728
  ];
750
729
  function pathAllowed(path) {
@@ -780,9 +759,9 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
780
759
  properties: {
781
760
  method: {
782
761
  type: 'string',
783
- enum: ['GET', 'POST'],
762
+ enum: ['GET'],
784
763
  default: 'GET',
785
- description: 'HTTP method. Prefer GET. Example: "GET".',
764
+ description: 'HTTP method. GET only: call_api is read-only. Example: "GET".',
786
765
  },
787
766
  path: stringSchema('Path starting with /. Allowlisted prefixes only. Example: "/cs2/rankings/teams".', '/cs2/rankings/teams'),
788
767
  queryJson: {
@@ -794,10 +773,6 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
794
773
  type: 'object',
795
774
  description: 'Query params as a plain object (alternative to queryJson). Example: {"year":2026}.',
796
775
  },
797
- bodyJson: {
798
- type: 'string',
799
- description: 'Stringified JSON body for POST only (rare).',
800
- },
801
776
  },
802
777
  },
803
778
  handler: async (args, ctx) => {
@@ -815,10 +790,10 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
815
790
  tookMs: Date.now() - started,
816
791
  });
817
792
  }
818
- if (method !== 'GET' && method !== 'POST') {
793
+ if (method !== 'GET') {
819
794
  return errorEnvelope({
820
795
  code: 'VALIDATION',
821
- message: 'method must be GET or POST',
796
+ message: 'method must be GET; call_api is read-only',
822
797
  game: null,
823
798
  source: 'call_api',
824
799
  requestId,
@@ -837,8 +812,6 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
837
812
  });
838
813
  }
839
814
  let query = {};
840
- // Agents pass query as a plain object at least as often as the stringified
841
- // queryJson form; silently dropping it produced confusing upstream 422s.
842
815
  if (args.query && typeof args.query === 'object' && !Array.isArray(args.query)) {
843
816
  query = { ...args.query };
844
817
  }
@@ -861,23 +834,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
861
834
  });
862
835
  }
863
836
  }
864
- let body;
865
- if (method === 'POST' && typeof args.bodyJson === 'string' && args.bodyJson.trim()) {
866
- try {
867
- body = JSON.parse(args.bodyJson);
868
- }
869
- catch (e) {
870
- return errorEnvelope({
871
- code: 'VALIDATION',
872
- message: `Invalid bodyJson: ${e.message}`,
873
- game: null,
874
- source: 'call_api',
875
- requestId,
876
- tookMs: Date.now() - started,
877
- });
878
- }
879
- }
880
- const res = await fetchJson(ctx, path, { method, query, body });
837
+ const res = await fetchJson(ctx, path, { method, query });
881
838
  if (!res.ok) {
882
839
  return errorEnvelope({
883
840
  code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
@@ -909,14 +866,6 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
909
866
  });
910
867
  },
911
868
  };
912
- /**
913
- * Games whose routes the published spec does not describe.
914
- *
915
- * The spec has 123 paths and zero under /lol, yet every LoL route works —
916
- * api_health itself answers partly from /lol/leagues. An agent that treats the
917
- * spec as the whole surface concludes LoL is unsupported and stops, so say so
918
- * explicitly rather than returning an empty list that reads as a verdict.
919
- */
920
869
  const SPEC_OMITS = {
921
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.',
922
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.',
@@ -959,9 +908,6 @@ Example: { "game": "ufc", "q": "rankings" }`,
959
908
  handler: async (args, ctx) => {
960
909
  const started = Date.now();
961
910
  const requestId = newRequestId();
962
- // Accept fortnite here even though it is not a PRIMARY_GAME: it has 21
963
- // documented paths and no curated tools, so it is exactly what call_api
964
- // callers come looking for.
965
911
  const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
966
912
  if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis)$/)) {
967
913
  return errorEnvelope({
@@ -1052,5 +998,4 @@ Example: { "game": "ufc", "q": "rankings" }`,
1052
998
  },
1053
999
  };
1054
1000
  export const metaTools = [listCapabilities, apiHealth, listRoutes, callApi];
1055
- // silence unused import in case extractRows needed later
1056
1001
  void extractRows;