cito-mcp 0.5.7 → 0.6.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.
@@ -31,7 +31,7 @@ function pushCandidates(out, game, type, rows, q, limit) {
31
31
  let matchedBy;
32
32
  if (q && score <= 0 && index === 0) {
33
33
  score = 95;
34
- matchedBy = 'upstream-first';
34
+ matchedBy = 'top-hit';
35
35
  }
36
36
  const secondary = {};
37
37
  for (const key of ['lolPlayerId', 'codPlayerId', 'matchId', 'boutId', 'tournamentId', 'eventId']) {
@@ -366,7 +366,7 @@ async function searchGame(ctx, game, q, type, limit) {
366
366
  candidates,
367
367
  calls,
368
368
  error: partialFromRejection(`game:${game}`, {
369
- code: 'UPSTREAM',
369
+ code: 'SERVICE_ERROR',
370
370
  message: e.message,
371
371
  }),
372
372
  };
@@ -389,7 +389,7 @@ Do not use when: you already have a stable id/slug from a prior tool.
389
389
 
390
390
  Empty/ambiguous results still return ok:true with best=null or needsDisambiguation=true — pick from candidates or refine q/game/type. Does not emit AMBIGUOUS_ENTITY as a hard error.
391
391
 
392
- Parallel-safe: yes. Upstream cost: 1–5.
392
+ Parallel-safe: yes. API calls: 1–5.
393
393
  Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
394
394
  inputSchema: {
395
395
  type: 'object',
@@ -507,7 +507,7 @@ Do not use when: fetching a known entity profile — use team_profile or player_
507
507
 
508
508
  UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
509
509
 
510
- Parallel-safe: yes. Upstream cost: 1–3.
510
+ Parallel-safe: yes. API calls: 1–3.
511
511
  Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
512
512
  inputSchema: {
513
513
  type: 'object',
@@ -792,11 +792,11 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
792
792
  meta: {
793
793
  ...(it.meta ?? {}),
794
794
  score: raw,
795
- ...(isProtected && raw < RELEVANCE_FLOOR ? { matchedBy: 'upstream-first' } : {}),
795
+ ...(isProtected && raw < RELEVANCE_FLOOR ? { matchedBy: 'top-hit' } : {}),
796
796
  },
797
797
  };
798
798
  });
799
- const keep = (it) => it.meta?.score >= RELEVANCE_FLOOR || it.meta?.matchedBy === 'upstream-first';
799
+ const keep = (it) => it.meta?.score >= RELEVANCE_FLOOR || it.meta?.matchedBy === 'top-hit';
800
800
  droppedBelowFloor = items
801
801
  .filter((it) => !keep(it))
802
802
  .map((it) => ({ id: it.id, name: it.name, score: it.meta?.score }));
@@ -805,8 +805,8 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
805
805
  }
806
806
  if (q && !preserveUpstreamOrder) {
807
807
  items.sort((a, b) => {
808
- const am = a.meta?.matchedBy === 'upstream-first' ? 1 : 0;
809
- const bm = b.meta?.matchedBy === 'upstream-first' ? 1 : 0;
808
+ const am = a.meta?.matchedBy === 'top-hit' ? 1 : 0;
809
+ const bm = b.meta?.matchedBy === 'top-hit' ? 1 : 0;
810
810
  return bm - am || scoreOf(b) - scoreOf(a) || a.name.localeCompare(b.name);
811
811
  });
812
812
  }
@@ -820,8 +820,8 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
820
820
  .slice(0, 5)
821
821
  .map((d) => `${d.name} (${d.id})`)
822
822
  .join(', ');
823
- floorWarnings.push(`Dropped ${droppedBelowFloor.length} result(s) that do not match "${q}" at all — the upstream search is a ` +
824
- `substring match with no relevance floor, so it pads every page: ${sample}. ` +
823
+ floorWarnings.push(`Dropped ${droppedBelowFloor.length} result(s) that do not match "${q}" at all (the name search is a ` +
824
+ `substring match with no relevance floor, so it pads every page): ${sample}. ` +
825
825
  `If one of these is what you wanted, query it by name or id directly.`);
826
826
  }
827
827
  return successEnvelope({
@@ -849,7 +849,7 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
849
849
  matched: items.length,
850
850
  droppedBelowFloor: droppedBelowFloor.length,
851
851
  dropped: droppedBelowFloor.slice(0, 25),
852
- upstreamTotal: upstreamTotal ?? null,
852
+ unfilteredTotal: upstreamTotal ?? null,
853
853
  },
854
854
  }
855
855
  : {}),
@@ -54,7 +54,7 @@ export const tennisSchedule = {
54
54
  When to use:
55
55
  - "What tennis is on today?"; a specific day's fixtures; results completed on a given date; building a daily digest.
56
56
 
57
- Prefer over: call_api for /tennis/schedule and /tennis/matches/completed (both require ?date=, which this tool supplies and validates); live_matches (in-progress only, right now); upcoming_schedule (a forward window, not a specific day).
57
+ Prefer over: call_api for /tennis/schedule and /tennis/matches/completed (this tool supplies and validates the date, and bounds the rows); live_matches (in-progress only, right now); upcoming_schedule (a forward window, not a specific day).
58
58
 
59
59
  Do not use when: matches currently in play → live_matches; a named player's history → player_matches.
60
60
 
@@ -62,13 +62,14 @@ Tennis-only. 'date' defaults to today (UTC) and MUST be YYYY-MM-DD. Choose with
62
62
  'include': both (default), scheduled, or completed. Both halves are fetched in
63
63
  parallel, so either can fail on its own without losing the other.
64
64
 
65
- 'limit' bounds EACH half (the completed route ignores ?limit= upstream, so the
66
- bound is applied here); scheduledCount/completedCount report how many rows the
65
+ 'limit' bounds EACH half (the bound is applied by this tool); scheduledCount/completedCount report how many rows the
67
66
  day held and *Returned how many came back. The two halves overlap: the day's
68
67
  list includes matches already played, so the same matchId can appear in both —
69
68
  see the overlap block before de-duplicating or counting.
70
69
 
71
- Parallel-safe: yes. Upstream cost: 1 or 2.`,
70
+ Rows: start_time_known false means no start time is held (scheduled_at is then midnight). start_time_kind says whether scheduled_at is when the match actually started (actual) or the published order-of-play time (scheduled). Read status for where a match is now and outcome for how it ended.
71
+
72
+ Parallel-safe: yes. API calls: 1 or 2.`,
72
73
  inputSchema: {
73
74
  type: 'object',
74
75
  additionalProperties: false,
@@ -172,7 +173,7 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
172
173
  else if (schedRes.status === 'rejected') {
173
174
  rejected.push({
174
175
  section: 'schedule',
175
- code: 'UPSTREAM',
176
+ code: 'SERVICE_ERROR',
176
177
  message: String(schedRes.reason).slice(0, 200),
177
178
  });
178
179
  }
@@ -194,7 +195,7 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
194
195
  else if (compRes.status === 'rejected') {
195
196
  rejected.push({
196
197
  section: 'completed',
197
- code: 'UPSTREAM',
198
+ code: 'SERVICE_ERROR',
198
199
  message: String(compRes.reason).slice(0, 200),
199
200
  });
200
201
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeCoverage } from './rankings.js';
1
2
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
2
3
  import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
3
4
  import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
@@ -77,26 +78,26 @@ export function normalizeStandingRow(row, index) {
77
78
  }
78
79
  export const standings = {
79
80
  name: 'standings',
80
- description: `League/event standings or world/division rankings normalized to ranked rows.
81
-
82
- When to use:
83
- - Table / playoff picture
84
- - UFC rankings; CS2 world or event standings; CDL standings; LoL league/tournament tables
85
-
86
- Prefer over: raw standings via call_api for agent-normalized rows.
87
-
88
- Do not use when: single team form → team_profile; live scores → live_matches.
89
- Dota has no first-class standings (may NOT_IMPLEMENTED or weak worldRanking).
90
-
91
- Required scope keys by game:
92
- - lol: leagueId OR tournamentId
93
- - cs2: omit for world rankings; eventId for event standings
94
- - cod: optional season/stage
95
- - ufc: optional division (scope=division)
96
- - tennis: optional division (ATP or WTA tour; default ATP)
97
- - dota2: best-effort worldRanking only
98
-
99
- Parallel-safe: yes. Upstream cost: 1–2.
81
+ description: `League/event standings or world/division rankings normalized to ranked rows.
82
+
83
+ When to use:
84
+ - Table / playoff picture
85
+ - UFC rankings; CS2 world or event standings; CDL standings; LoL league/tournament tables
86
+
87
+ Prefer over: raw standings via call_api for agent-normalized rows.
88
+
89
+ Do not use when: single team form → team_profile; live scores → live_matches.
90
+ Dota has no first-class standings (may NOT_IMPLEMENTED or weak worldRanking).
91
+
92
+ Required scope keys by game:
93
+ - lol: leagueId OR tournamentId
94
+ - cs2: omit for world rankings; eventId for event standings
95
+ - cod: optional season/stage
96
+ - ufc: optional division (scope=division)
97
+ - tennis: optional division (ATP or WTA tour; default ATP); optional season YYYY = that season's last published list (default: latest). listCoverage says when an archived list is held only in part
98
+ - dota2: best-effort worldRanking only
99
+
100
+ Parallel-safe: yes. API calls: 1–2.
100
101
  Example: { "game": "cod", "season": "2026", "limit": 50 }`,
101
102
  inputSchema: {
102
103
  type: 'object',
@@ -112,7 +113,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
112
113
  leagueId: stringSchema('LoL league id for league standings.'),
113
114
  tournamentId: stringSchema('LoL/COD tournament id.'),
114
115
  eventId: stringSchema('CS2 event id for event standings.'),
115
- season: stringSchema('Season key (COD).'),
116
+ season: stringSchema('Season key (COD). Tennis: a year (YYYY) gives that season\'s last published ranking list.'),
116
117
  stage: stringSchema('Stage key (COD / LoL).'),
117
118
  division: stringSchema('UFC division key.'),
118
119
  limit: limitSchema({ default: 50, max: 100 }),
@@ -145,7 +146,11 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
145
146
  const leagueId = typeof args.leagueId === 'string' ? args.leagueId : undefined;
146
147
  const tournamentId = typeof args.tournamentId === 'string' ? args.tournamentId : undefined;
147
148
  const eventId = typeof args.eventId === 'string' ? args.eventId : undefined;
148
- const season = typeof args.season === 'string' ? args.season : undefined;
149
+ const season = typeof args.season === 'string'
150
+ ? args.season
151
+ : typeof args.season === 'number' && Number.isFinite(args.season)
152
+ ? String(Math.trunc(args.season))
153
+ : undefined;
149
154
  const stage = typeof args.stage === 'string' ? args.stage : undefined;
150
155
  const division = typeof args.division === 'string' ? args.division : undefined;
151
156
  const tennisPage = typeof args.page === 'number' && Number.isFinite(args.page) && args.page >= 1
@@ -241,7 +246,18 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
241
246
  else if (game === 'tennis') {
242
247
  const tour = String(division ?? 'ATP').toUpperCase() === 'WTA' ? 'WTA' : 'ATP';
243
248
  path = '/tennis/standings';
244
- query = { tour, top_n: Math.min(limit, 100), page: tennisPage };
249
+ const tennisSeason = season && /^\d{4}$/.test(season.trim()) ? season.trim() : undefined;
250
+ if (season && !tennisSeason) {
251
+ return errorEnvelope({
252
+ code: 'VALIDATION',
253
+ message: `season must be a year (YYYY) for tennis (got '${season}')`,
254
+ game,
255
+ source: 'standings',
256
+ requestId,
257
+ tookMs: Date.now() - started,
258
+ });
259
+ }
260
+ query = { tour, top_n: Math.min(limit, 100), page: tennisPage, ...(tennisSeason ? { season: tennisSeason } : {}) };
245
261
  effectiveScope = 'world';
246
262
  title = `${tour} singles rankings`;
247
263
  }
@@ -371,6 +387,9 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
371
387
  };
372
388
  pushWarning(upstreamMeta.warning);
373
389
  pushWarning(dataQuality.warning);
390
+ const tennisCoverage = game === 'tennis' ? asRecord(obj?.list_coverage) : null;
391
+ if (tennisCoverage && tennisCoverage.status === 'partial')
392
+ pushWarning(tennisCoverage.note);
374
393
  if (Array.isArray(upstreamMeta.warnings)) {
375
394
  for (const w of upstreamMeta.warnings)
376
395
  pushWarning(w);
@@ -412,6 +431,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
412
431
  ...(game === 'tennis'
413
432
  ? {
414
433
  rankingDate: pickString(obj?.ranking_date) ?? null,
434
+ listCoverage: tennisCoverage ? normalizeCoverage(tennisCoverage) : null,
415
435
  updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt, obj?.ranking_date) ?? null,
416
436
  }
417
437
  : {
@@ -42,7 +42,7 @@ Prefer over: separate roster + matches + detail via call_api.
42
42
  Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
43
43
  Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
44
44
 
45
- Parallel-safe: yes. Upstream cost: 2–4.
45
+ Parallel-safe: yes. API calls: 2–4.
46
46
  Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
47
47
  inputSchema: {
48
48
  type: 'object',
@@ -532,7 +532,7 @@ function winnerSide(match, a) {
532
532
  }
533
533
  export const headToHead = {
534
534
  name: 'head_to_head',
535
- description: `Composed head-to-head record between two teams, two UFC fighters, or two tennis players. No first-class REST H2H exists — this tool filters match history server-side.
535
+ description: `Head-to-head record between two teams, two UFC fighters, or two tennis players. Tennis reads the tennis head-to-head route; other games filter match history server-side.
536
536
 
537
537
  When to use:
538
538
  - Rivalry / series record questions
@@ -544,7 +544,9 @@ Do not use when: single-side form only → team_profile or player_profile.
544
544
 
545
545
  Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
546
546
 
547
- Parallel-safe: yes. Upstream cost: 2–4.
547
+ Tennis: pass entityType "player" with sideA and sideB (ids like atp_207989, or names). This tool has no players param; for three or more players use h2h_matrix, which does. limit pages the meetings list only (max 50); every tally covers all meetings. In the payload current_rank is null when a player is not on the tour's latest list: read last_rank. The tiebreak and level_breakdown keys are in data.h2h.
548
+
549
+ Parallel-safe: yes. API calls: 2–4.
548
550
  Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
549
551
  inputSchema: {
550
552
  type: 'object',
@@ -558,8 +560,8 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
558
560
  default: 'team',
559
561
  description: 'team (default), fighter (UFC), or player (tennis).',
560
562
  },
561
- sideA: stringSchema('Id or slug for side A.', 'faze'),
562
- sideB: stringSchema('Id or slug for side B.', 'navi'),
563
+ sideA: stringSchema('Id or slug for side A (tennis: atp_207989 or a player name).', 'faze'),
564
+ sideB: stringSchema('Id or slug for side B (tennis: atp_206173 or a player name).', 'navi'),
563
565
  limit: limitSchema({ default: 20, max: 50, description: 'Max meetings returned.' }),
564
566
  from: stringSchema('ISO-8601 lower bound on meeting startTime (client filter).'),
565
567
  to: stringSchema('ISO-8601 upper bound on meeting startTime (client filter).'),
@@ -982,7 +984,7 @@ Do not use when: a two-player rivalry deep-dive with tiebreak/decider splits →
982
984
 
983
985
  Tennis-only. players takes 2-16 ids or names (names resolve via player search). Each matrix cell is "W-L" from the row player's perspective, "-" on the diagonal.
984
986
 
985
- Parallel-safe: yes. Upstream cost: 1 + one search per unresolved name.`,
987
+ Parallel-safe: yes. API calls: 1 + one search per unresolved name.`,
986
988
  inputSchema: {
987
989
  type: 'object',
988
990
  additionalProperties: false,
@@ -54,10 +54,10 @@ Tennis-only. Levels accept names (Grand Slam, WTA 1000, ATP 500, Challenger,
54
54
  ITF World Tennis Tour, Davis Cup, ...) or raw tier codes (G, M, A, C, D).
55
55
  A level name that starts with ATP or WTA also fixes the tour (and conflicts with
56
56
  a contradicting tour= argument are rejected), so "WTA 1000" cannot return ATP
57
- Masters events even though the upstream filter is not tour-aware.
58
- Note the source does not separate ATP 500 from ATP 250 — both are ATP Tour.
57
+ Masters events.
58
+ The category field reports ATP 500 and ATP 250 both as atp_tour (read tier to tell them apart), and WTA 1000 events as wta_1000.
59
59
 
60
- Parallel-safe: yes. Upstream cost: 1.`,
60
+ Parallel-safe: yes. API calls: 1.`,
61
61
  inputSchema: {
62
62
  type: 'object',
63
63
  additionalProperties: false,
@@ -232,10 +232,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
232
232
  .slice(0, 5)
233
233
  .map((t) => `${t.id} (${t.tour})`)
234
234
  .join(', ');
235
- warnings.push(`The upstream level filter returned ${contradictions.length} tournament(s) that contradict level="${levelRaw}": ` +
236
- `${sample}. They were dropped. The API's level reverse-map is not tour-aware (WTA 1000 resolves to tier codes ` +
237
- `PM and M), which is a server-side defect; this tool sends tour=${levelTourPrefix} as well, so a repeat of this ` +
238
- `warning means the upstream filter is widening again.`);
235
+ warnings.push(`The level filter returned ${contradictions.length} tournament(s) that contradict level="${levelRaw}": ` +
236
+ `${sample}. They were dropped; this tool also sends tour=${levelTourPrefix}.`);
239
237
  }
240
238
  return successEnvelope({
241
239
  pagination: {
@@ -156,7 +156,7 @@ export async function runTool(def, args, ctx) {
156
156
  catch (error) {
157
157
  const { errorEnvelope, toMcpResult: toResult } = await import('../envelope.js');
158
158
  return toResult(errorEnvelope({
159
- code: 'UPSTREAM',
159
+ code: 'SERVICE_ERROR',
160
160
  message: error.message || 'Unhandled tool error',
161
161
  game: null,
162
162
  source: def.name,