cito-mcp 0.4.5 → 0.4.7

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Standalone [MCP](https://modelcontextprotocol.io) server for the [Cito esports API](https://api.citoapi.com) — **curated outcome tools** for agents building esports apps, dashboards, bots, and research flows.
4
4
 
5
- **Version:** `0.3.20` · **Node:** `>=20` · **Install:** `npx cito-mcp`
5
+ **Version:** `0.4.7` · **Node:** `>=20` · **Install:** `npx cito-mcp`
6
6
 
7
7
  Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis**. Long-tail REST stays available via `call_api`.
8
8
 
@@ -50,8 +50,6 @@ Where the source data is partial, the tool says so instead of shipping a plausib
50
50
  | The two ranking lists compared by `rankings_movers` are not a week apart | `gapDays` + `comparisonWindow` (`"12-week gap"`), `comparisonsAreWeekly: false`, and a warning naming both dates |
51
51
  | A tennis tournament draw is missing matches | `draw.shortRounds` (`["R128 45/64"]`), `draw.missingRounds`, `draw.complete` |
52
52
  | A stats section the endpoint does not publish | `player_stats.unavailable` names the section and why, rather than a block of nulls |
53
- | No bookmaker is quoting a match | `oddsAvailable: false` plus a note that it is a coverage gap, not an error |
54
- | The odds feed leaves `match_id` null | `joinKey` (`{kind:"oddsEventId", …}`) and `playerIds`, so the row is still joinable |
55
53
  | `search_entities` results that do not match at all | dropped, counted under `data.relevance.droppedBelowFloor`, with a warning naming them |
56
54
 
57
55
  ---
@@ -535,18 +533,21 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
535
533
  ### Semver expectations
536
534
 
537
535
  - **0.2.4** is a **major surface break** vs 0.1 (tool rename + removal of OpenAPI mass-generation).
538
- - Further 0.2.x patches may refine envelopes and composite quality without renaming the 15 tools.
536
+ - Further 0.2.x patches may refine envelopes and composite quality without renaming the shipped tools. (This line previously said "15 tools" while the catalog shipped 42 — the count here is intentionally not restated, because a number duplicated in prose drifts the moment the catalog changes.)
539
537
  - Promoting Fortnite (or other titles) into the primary `game` enum would be a minor feature bump with catalog/docs updates.
540
538
 
541
539
  ---
542
540
 
543
541
  ## Changelog (summary)
544
542
 
543
+ ### 0.4.7
544
+
545
+ - Removed the unregistered tennis odds module and its README entries; `match_details` `sections:["odds"]` stays UFC-only and answers `NOT_IMPLEMENTED` for other games.
546
+
545
547
  ### 0.4.2
546
548
 
547
549
  Second tennis correctness pass, from the 2026-09-12 re-sweep. Five defects, each reproduced from a raw payload and re-verified after the fix. Two were upstream (fixed on the API and verified with the 40/40 invariant gate); three were MCP-side.
548
550
 
549
- - **`tennis_odds` called a nonexistent match "no coverage".** `/tennis/odds/{id}` answers **HTTP 200 with `coverage.odds=false` for every id it does not know** — verified for `s365_2026_0000000` and for the literal `total-garbage-id`. So a typo and a genuine no-book gap were indistinguishable and both came back `ok:true`. `scope=match`/`live` now verify the id against `/tennis/matches/{id}` (which does 404) when no odds are quoted: unknown id → `NOT_FOUND` with recovery steps, real match → an honest coverage gap with `matchExists: true`.
550
551
  - **Tennis `standings` published a fake total and could not be paged.** `/tennis/standings?top_n=5` set `rank_max=top_n`, so the count (which carried the rank bounds) equalled the page size: `total:5`, `hasMore:false` for a list of **150** ranked players. The API now counts the population without the rank window and returns `ranked_players`/`total_pages`/`has_next`, `/standings` takes a `page` argument, and the tool reports `total`/`rankedPlayers`/`totalPages`/`hasMore` — page 2 of the ATP list now starts at rank 6. The same `allRows.length` mistake in the `pagination` block was fixed too, so the two counts cannot disagree.
551
552
  - **`event_card` published the requested id as the event name.** With a bad id it answered `ok:true` and `event.name = "atp_2026_999999"` over a 404 that `partial[]` reported correctly. Names now come from the payload or are `null`, with `event.resolved` recording whether the detail fetch succeeded.
552
553
  - **`leaderboard_*` reported the page size as the population.** Every board returned `"total": len(items)` after applying `.limit(limit)`, so `limit=2` answered `total:2` — for boards whose own docs record thousands of qualifiers. Upstream now carries `count(*) OVER ()` (Postgres evaluates it after `GROUP BY`/`HAVING`, so it is exactly the qualifying population, at no extra round trip): aces **2488**, break-conversion **1089** (matching the figure in its own docstring), 1st-serve-won 1141, bp-saved 1186, return-games 987, tiebreak 364, deciders 334, comebacks 611, finals 66. Each tool also reports `returned` and the board's `minimumAttempts`.
@@ -560,12 +561,10 @@ Second tennis correctness pass, from the 2026-09-12 re-sweep. Five defects, each
560
561
  Tennis correctness pass, driven by the 2026-09-12 sweep (`reports/tennis-mcp-sweep-2026-09-12.md`). Every item was reproduced against live ids and re-verified after the fix.
561
562
 
562
563
  - **`match_summary` tennis scoreline was always `- : -`.** `/tennis/matches/{id}` puts no `sets_won` on its player objects, so the sets-won score was null for every completed match while the same payload held the real score in `score` and `sets[]`. Sets won are now derived (explicit → `winner_sets_won` → `sets[]` → `score` string), `score.detail` carries the game score, and a set's `completed` is inferred when the archive omits `is_completed`. The winner-oriented `winner_games`/`loser_games` pair is deliberately not used as a side score. `bestOf` now reads `best_of`.
563
- - **`match_details` denied tennis odds existed.** The `odds` section was gated to UFC; the same match returned bookmaker prices from `tennis_odds`. Both surfaces now share one projection (`summarizeTennisOdds`), and `playerStats` (previously always `null` for tennis) is filled from `/tennis/matches/{id}/stats`.
564
564
  - **`player_stats.bySurface` was always four nulls.** The endpoint publishes one object per surface and the projector coerced each to a number. Real per-surface blocks, `servingStats`, and an explicit `unavailable` map for sections the endpoint does not publish.
565
565
  - **`limit` was ignored** by `player_rankings_history` (asked 4, got 342) and by `/tennis/matches/completed`. Both are bounded locally now, with `*Returned` versus `*Count` so the day total is never confused with the page.
566
566
  - **`tournaments level="WTA 1000"` returned ATP Masters events.** The API's level reverse-map is not tour-aware (`_TIER_WTA` maps both `PM` and `M` to "WTA 1000"), so the tool now sends and enforces the tour the level name states, and drops + counts contradicting rows. Fixed at the source too — the same widening also leaked `WTA 125`, `Challenger`, `Davis Cup` and `Billie Jean King Cup`.
567
567
  - **`rankings_movers` called an 84-day gap "week-over-week".** `gapDays`/`comparisonWindow`/`comparisonsAreWeekly` now state the real interval, with a warning. `standings` reports the tennis `ranking_date` as `updatedAt` instead of `null`.
568
- - **`tennis_odds upcoming` rows carried `matchId: null`** with no alternative key. `joinKey` and `playerIds` make the row joinable without inventing an id.
569
568
  - **`tennis_schedule`** now counts the day's list/completed overlap, summarises statuses, and flags date-only `startsAt` placeholders.
570
569
  - **`live_matches` labelled tennis rows by tournament**, not players, because the live route's `name` is the event.
571
570
  - **Tennis players had no images** although `/tennis/players/{id}` carries `portrait_url`. Now surfaced (plus `ioc` as the side country on boards).
package/dist/client.js CHANGED
@@ -102,6 +102,8 @@ export async function fetchJson(ctx, path, opts) {
102
102
  catch {
103
103
  data = text;
104
104
  }
105
+ if (!opts?.raw && response.ok && isTennisPath(path))
106
+ data = flattenTennisEnvelope(data);
105
107
  return {
106
108
  ok: response.ok,
107
109
  status: response.status,
@@ -158,6 +160,43 @@ export function asRecord(value) {
158
160
  }
159
161
  return null;
160
162
  }
163
+ const TENNIS_LIST_META_KEYS = new Set(['count', 'limit', 'total', 'page', 'totalPages', 'hasNext', 'hasPrev']);
164
+ function isTennisPath(path) {
165
+ return /^(\/api\/v1)?\/tennis(\/|\?|$)/.test(path);
166
+ }
167
+ function snakeKey(key) {
168
+ return key.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
169
+ }
170
+ export function flattenTennisEnvelope(body) {
171
+ const env = asRecord(body);
172
+ if (!env || !('data' in env) || 'items' in env)
173
+ return body;
174
+ const meta = asRecord(env.meta);
175
+ if (!meta)
176
+ return body;
177
+ const success = env.success ?? true;
178
+ if (Array.isArray(env.data)) {
179
+ const out = { success, items: env.data };
180
+ for (const [key, value] of Object.entries(meta)) {
181
+ if (!TENNIS_LIST_META_KEYS.has(key))
182
+ out[snakeKey(key)] = value;
183
+ }
184
+ out.total = typeof meta.total === 'number' ? meta.total : env.data.length;
185
+ out.page = typeof meta.page === 'number' ? meta.page : 1;
186
+ out.page_size = typeof meta.limit === 'number' ? meta.limit : env.data.length;
187
+ if (typeof meta.totalPages === 'number')
188
+ out.total_pages = meta.totalPages;
189
+ if (typeof meta.hasNext === 'boolean')
190
+ out.has_next = meta.hasNext;
191
+ if (typeof meta.hasPrev === 'boolean')
192
+ out.has_prev = meta.hasPrev;
193
+ return out;
194
+ }
195
+ const resource = asRecord(env.data);
196
+ if (resource)
197
+ return { success, ...resource };
198
+ return body;
199
+ }
161
200
  export function unwrapPayload(value) {
162
201
  let cur = value;
163
202
  for (let depth = 0; depth < 3; depth += 1) {
@@ -1,77 +1,77 @@
1
- export const SERVER_INSTRUCTIONS = `# Cito MCP — agent operating manual
2
-
3
- You are connected to Cito esports data (read-only). Prefer curated outcome tools over raw REST. Production apps must call Cito REST with the user's API key; use this MCP to design, prototype, and resolve IDs — not as a multi-tenant runtime bus.
4
-
5
- ## Hard rules
6
-
7
- 1. **Never invent IDs.** Do not guess matchId, gameId, playerId, team slug, eventId, or boutId. Always obtain IDs from a prior tool result (resolve, live, schedule, search, or list). If the user gives a name ("T1", "s1mple", "UFC 300"), call resolve_entity/search_entities first.
8
- 2. **Resolve before deep.** Name → ID/slug → summary/page → deep stats. Skip resolve only when the user already provided a Cito ID/slug.
9
- 3. **One screen, one composite.** Prefer a page/summary tool over stitching 4–6 thin GETs.
10
- 4. **Honor the envelope.** Parse ok, data, pagination, partial, error, meta.rateLimit, meta.entities. Partial section failures are not total failures — use successful sections.
11
- 5. **Read-only.** All curated tools are safe to retry except where error.retryable is false for bad args / entitlement.
12
-
13
- ## Game parameter
14
-
15
- game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | tennis | all
16
-
17
- - Default when the user named one title: that game. Default for "what's live?" / multi-title: omit game or all on tools that accept it.
18
- - On UNSUPPORTED_GAME / 403 for a title: stop retrying that game; call api_health; report plan gaps.
19
- - Fortnite and other titles may appear via call_api/resources; do not assume they are in the curated enum until list_capabilities says so.
20
-
21
- ## Preferred tool order
22
-
23
- 1. Unsure → list_capabilities (filter by game or job). Optional: api_health for key/tier/included games.
24
- 2. Name without ID → resolve_entity (best match) or search_entities (browse). Reuse returned id/slug.
25
- 3. Live / upcoming → live_matches; upcoming_schedule.
26
- 4. Match UI / recap → match_summary first. match_details only for timelines, full maps, demos, live state.
27
- 5. Team page → team_profile. Player form → player_profile. Tables/ranks → standings.
28
- 6. Pre-match → match_preview. Rivalry → head_to_head. Event / fight-night card → event_card.
29
- 7. Escape hatch → call_api (allowlisted path prefixes; prefer GET). Prefer curated tools.
30
-
31
- Mnemonic: resolve → live/schedule → summary → deep.
32
-
33
- ## Parallel vs sequence
34
-
35
- Parallel-safe: independent reads (team_profile A ∥ team_profile B; live_matches ∥ api_health). Cap ~3–5 concurrent agent tools; check meta.rateLimit.remaining.
36
-
37
- Serial required: resolve → detail; pagination (cursor from page N only); live board → selected match deep-dive.
38
-
39
- Prefer server-side fan-out inside composites (partial[] recovery) over agent N+1.
40
-
41
- ## Pagination
42
-
43
- Lists use pagination: { limit, offset?, total?, hasMore, nextCursor, prevCursor }. Default limit 20, max 50. Loop with cursor: pagination.nextCursor and the same filters. Never invent cursors. Empty list is ok:true with items:[].
44
-
45
- ## Errors
46
-
47
- ok:false → read error.code (VALIDATION, NOT_FOUND, UNSUPPORTED_GAME, RATE_LIMIT, UNAUTHORIZED, UPSTREAM, PATH_NOT_ALLOWED, NOT_IMPLEMENTED). Follow error.recover[]. Retry only when retryable.
48
-
49
- ok:true with partial[] → use successful sections; do not treat partial as total fail.
50
- ok:true with meta.warnings[] → filters or depth degraded; do not assume ignored filters applied.
51
- resolve_entity ambiguity is soft: ok:true with data.needsDisambiguation and data.candidates — pick a candidate; do not wait for AMBIGUOUS_ENTITY.
52
-
53
- ## Recipes
54
-
55
- Live board: api_health (optional) → live_matches → match_summary for selected matchId.
56
- UFC empty live (count=0): read section.note / emptyReason / health (workerAlive, lag) / supervisor / nextCard — do not claim "API offline" without workerAlive/lag; never invent matchups from supervisor shells.
57
- Team page: resolve_entity {type:team} → team_profile.
58
- Player card: resolve_entity {type:player|fighter} → player_profile.
59
- Fight night / event card: resolve_entity {type:event} → event_card {includeMatches:true} → match_preview for a featured bout.
60
- Match preview (named sides): resolve_entity each side (optional) → match_preview {teamA, teamB}.
61
- App scaffold: list_capabilities ∥ api_health ∥ live_matches, then one composite per screen.
62
-
63
- ## Resources
64
-
65
- - cito://llms.txt — product context when available
66
- - cito://capabilities — catalog summary
67
- - cito://openapi.json — optional public OpenAPI fetch for typed clients
68
-
69
- ## What not to do
70
-
71
- - Invent match/player/team IDs from memory.
72
- - Parallel-paginate the same list with different cursors.
73
- - N+1 match_details for every live row.
74
- - Retry UNSUPPORTED_GAME or VALIDATION unchanged.
75
- - Ship production traffic through MCP.
76
- - Flood odds/timelines when a summary answers the question.
1
+ export const SERVER_INSTRUCTIONS = `# Cito MCP — agent operating manual
2
+
3
+ You are connected to Cito esports data (read-only). Prefer curated outcome tools over raw REST. Production apps must call Cito REST with the user's API key; use this MCP to design, prototype, and resolve IDs — not as a multi-tenant runtime bus.
4
+
5
+ ## Hard rules
6
+
7
+ 1. **Never invent IDs.** Do not guess matchId, gameId, playerId, team slug, eventId, or boutId. Always obtain IDs from a prior tool result (resolve, live, schedule, search, or list). If the user gives a name ("T1", "s1mple", "UFC 300"), call resolve_entity/search_entities first.
8
+ 2. **Resolve before deep.** Name → ID/slug → summary/page → deep stats. Skip resolve only when the user already provided a Cito ID/slug.
9
+ 3. **One screen, one composite.** Prefer a page/summary tool over stitching 4–6 thin GETs.
10
+ 4. **Honor the envelope.** Parse ok, data, pagination, partial, error, meta.rateLimit, meta.entities. Partial section failures are not total failures — use successful sections.
11
+ 5. **Read-only.** All curated tools are safe to retry except where error.retryable is false for bad args / entitlement.
12
+
13
+ ## Game parameter
14
+
15
+ game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | tennis | all
16
+
17
+ - Default when the user named one title: that game. Default for "what's live?" / multi-title: omit game or all on tools that accept it.
18
+ - On UNSUPPORTED_GAME / 403 for a title: stop retrying that game; call api_health; report plan gaps.
19
+ - Fortnite and other titles may appear via call_api/resources; do not assume they are in the curated enum until list_capabilities says so.
20
+
21
+ ## Preferred tool order
22
+
23
+ 1. Unsure → list_capabilities (filter by game or job). Optional: api_health for key/tier/included games.
24
+ 2. Name without ID → resolve_entity (best match) or search_entities (browse). Reuse returned id/slug.
25
+ 3. Live / upcoming → live_matches; upcoming_schedule.
26
+ 4. Match UI / recap → match_summary first. match_details only for timelines, full maps, demos, live state.
27
+ 5. Team page → team_profile. Player form → player_profile. Tables/ranks → standings.
28
+ 6. Pre-match → match_preview. Rivalry → head_to_head. Event / fight-night card → event_card.
29
+ 7. Escape hatch → call_api (allowlisted path prefixes; prefer GET). Prefer curated tools.
30
+
31
+ Mnemonic: resolve → live/schedule → summary → deep.
32
+
33
+ ## Parallel vs sequence
34
+
35
+ Parallel-safe: independent reads (team_profile A ∥ team_profile B; live_matches ∥ api_health). Cap ~3–5 concurrent agent tools; check meta.rateLimit.remaining.
36
+
37
+ Serial required: resolve → detail; pagination (cursor from page N only); live board → selected match deep-dive.
38
+
39
+ Prefer server-side fan-out inside composites (partial[] recovery) over agent N+1.
40
+
41
+ ## Pagination
42
+
43
+ Lists use pagination: { limit, offset?, total?, hasMore, nextCursor, prevCursor }. Default limit 20, max 50. Loop with cursor: pagination.nextCursor and the same filters. Never invent cursors. Empty list is ok:true with items:[].
44
+
45
+ ## Errors
46
+
47
+ ok:false → read error.code (VALIDATION, NOT_FOUND, UNSUPPORTED_GAME, RATE_LIMIT, UNAUTHORIZED, UPSTREAM, PATH_NOT_ALLOWED, NOT_IMPLEMENTED). Follow error.recover[]. Retry only when retryable.
48
+
49
+ ok:true with partial[] → use successful sections; do not treat partial as total fail.
50
+ ok:true with meta.warnings[] → filters or depth degraded; do not assume ignored filters applied.
51
+ resolve_entity ambiguity is soft: ok:true with data.needsDisambiguation and data.candidates — pick a candidate; do not wait for AMBIGUOUS_ENTITY.
52
+
53
+ ## Recipes
54
+
55
+ Live board: api_health (optional) → live_matches → match_summary for selected matchId.
56
+ UFC empty live (count=0): read section.note / emptyReason / health (workerAlive, lag) / supervisor / nextCard — do not claim "API offline" without workerAlive/lag; never invent matchups from supervisor shells.
57
+ Team page: resolve_entity {type:team} → team_profile.
58
+ Player card: resolve_entity {type:player|fighter} → player_profile.
59
+ Fight night / event card: resolve_entity {type:event} → event_card {includeMatches:true} → match_preview for a featured bout.
60
+ Match preview (named sides): resolve_entity each side (optional) → match_preview {teamA, teamB}.
61
+ App scaffold: list_capabilities ∥ api_health ∥ live_matches, then one composite per screen.
62
+
63
+ ## Resources
64
+
65
+ - cito://llms.txt — product context when available
66
+ - cito://capabilities — catalog summary
67
+ - cito://openapi.json — optional public OpenAPI fetch for typed clients
68
+
69
+ ## What not to do
70
+
71
+ - Invent match/player/team IDs from memory.
72
+ - Parallel-paginate the same list with different cursors.
73
+ - N+1 match_details for every live row.
74
+ - Retry UNSUPPORTED_GAME or VALIDATION unchanged.
75
+ - Ship production traffic through MCP.
76
+ - Flood odds/timelines when a summary answers the question.
77
77
  `;
package/dist/scrub.js CHANGED
@@ -15,6 +15,50 @@ const INTERNAL_KEYS = new Set([
15
15
  'wikiUrl',
16
16
  'wiki_url',
17
17
  'jsonLd',
18
+ '_mergeSource',
19
+ '_provenance',
20
+ 'duplicateOf',
21
+ 'duplicate_of',
22
+ 'identityConfidence',
23
+ 'identity_confidence',
24
+ 'roleSource',
25
+ 'role_source',
26
+ 'verifiedCompetitive',
27
+ 'verified_competitive',
28
+ 'profileAttemptedAt',
29
+ 'profileFailureMessage',
30
+ 'profileFailureReason',
31
+ 'profileFetchStatus',
32
+ 'profileImageAttemptedAt',
33
+ 'profileImageAttempts',
34
+ 'profileImageError',
35
+ 'profileImageSteamAttemptedAt',
36
+ 'profileImageWorker',
37
+ 'ratingSource',
38
+ 'requiredSourceData',
39
+ 'rawText',
40
+ 'raw_text',
41
+ 'scrapedAt',
42
+ 'scraped_at',
43
+ 'sourceSummary',
44
+ 'sourceNote',
45
+ 'durableStatsTables',
46
+ 'lineupSource',
47
+ 'mapMediaSource',
48
+ 'rosterSource',
49
+ 'upstream',
50
+ 'liveCandidateWindowMinutes',
51
+ 'rowHash',
52
+ 'row_hash',
53
+ 'snapshotHash',
54
+ 'snapshot_hash',
55
+ 'syncRunId',
56
+ 'sync_run_id',
57
+ 'discoveredSources',
58
+ 'sidecarAvailable',
59
+ 'imageCandidates',
60
+ 'image_candidates',
61
+ 'source_url',
18
62
  ]);
19
63
  const SCRAPE_PARENTS = new Set(['dataAvailability', 'health']);
20
64
  const SCRAPE_KEYS = new Set(['strategy', 'samples']);
@@ -260,20 +260,20 @@ export function composeH2H(game, sideA, sideB, rows, limit) {
260
260
  }
261
261
  export const matchPreview = {
262
262
  name: 'match_preview',
263
- description: `COMPOSITE pre-match briefing: sides, roster/form snippets, H2H stub, event context — for pick'ems, articles, and match-page before state.
264
-
265
- When to use:
266
- - Upcoming match deep link
267
- - "Who should I watch before this game?"
268
- - App scaffold for preview cards
269
-
270
- Prefer over: manually chaining team_profile ×2 + head_to_head + schedule.
271
- Prefer match_summary when match is completed; match_details for live in-game.
272
-
273
- Do not use when: user wants final score/recap of a finished match.
274
-
275
- Parallel-safe: yes. Upstream cost: 4–8.
276
- Tennis H2H accepts an optional surface filter (Hard, Clay, Grass).
263
+ description: `COMPOSITE pre-match briefing: sides, roster/form snippets, H2H stub, event context — for pick'ems, articles, and match-page before state.
264
+
265
+ When to use:
266
+ - Upcoming match deep link
267
+ - "Who should I watch before this game?"
268
+ - App scaffold for preview cards
269
+
270
+ Prefer over: manually chaining team_profile ×2 + head_to_head + schedule.
271
+ Prefer match_summary when match is completed; match_details for live in-game.
272
+
273
+ Do not use when: user wants final score/recap of a finished match.
274
+
275
+ Parallel-safe: yes. Upstream cost: 4–8.
276
+ Tennis H2H accepts an optional surface filter (Hard, Clay, Grass).
277
277
  Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "recentLimit": 5 }`,
278
278
  inputSchema: {
279
279
  type: 'object',
@@ -707,31 +707,31 @@ async function resolveEventKey(ctx, game, q) {
707
707
  }
708
708
  export const eventCard = {
709
709
  name: 'event_card',
710
- description: `COMPOSITE event / fight-night card: identity, bout or match list, optional standings snippet.
711
-
712
- When to use:
713
- - UFC fight night / numbered event page ("UFC 300 card", "Fight Night")
714
- - CS2 event hub with match list
715
- - Tournament/event overview before match_preview drill-down
716
-
717
- Bouts come back in card order — main event first, then prelims, then early prelims.
718
- Each bout carries weightClass, titleBout, card placement, and (once fought) result
719
- { method, round, time, referee, winnerSlug }. Each corner carries images
720
- { headshotUrl, bodyImageUrl, imageUrl, proxiedImageUrl }, record, nickname, rank,
721
- championStatus, country and flag when upstream supplies them. Use proxiedImageUrl in
722
- browsers — the image host sends no CORS header. You do not need call_api per fighter for faces.
723
-
724
- Prefer over: agent-side resolve + call_api /ufc/events + bout expansion; N+1 match_summary for the card list only; per-fighter call_api just to fetch headshots.
725
-
726
- Prefer match_preview for one bout/match briefing; match_summary for completed recaps; live_matches for live-only strips; standings alone for pure tables.
727
-
728
- Do not use when: you only need live scores (live_matches); single finished match recap (match_summary); no event name/id yet and game unknown.
729
-
730
- Tennis: pass a tournament id/slug to get the draw bracket — rounds carry a round
731
- code (Q1..R128, QF, SF, F) with each match's players and score. Set is the
732
- primary tennis path here; there is no separate draw tool.
733
-
734
- Parallel-safe: yes. Upstream cost: 1–4.
710
+ description: `COMPOSITE event / fight-night card: identity, bout or match list, optional standings snippet.
711
+
712
+ When to use:
713
+ - UFC fight night / numbered event page ("UFC 300 card", "Fight Night")
714
+ - CS2 event hub with match list
715
+ - Tournament/event overview before match_preview drill-down
716
+
717
+ Bouts come back in card order — main event first, then prelims, then early prelims.
718
+ Each bout carries weightClass, titleBout, card placement, and (once fought) result
719
+ { method, round, time, referee, winnerSlug }. Each corner carries images
720
+ { headshotUrl, bodyImageUrl, imageUrl, proxiedImageUrl }, record, nickname, rank,
721
+ championStatus, country and flag when upstream supplies them. Use proxiedImageUrl in
722
+ browsers — the image host sends no CORS header. You do not need call_api per fighter for faces.
723
+
724
+ Prefer over: agent-side resolve + call_api /ufc/events + bout expansion; N+1 match_summary for the card list only; per-fighter call_api just to fetch headshots.
725
+
726
+ Prefer match_preview for one bout/match briefing; match_summary for completed recaps; live_matches for live-only strips; standings alone for pure tables.
727
+
728
+ Do not use when: you only need live scores (live_matches); single finished match recap (match_summary); no event name/id yet and game unknown.
729
+
730
+ Tennis: pass a tournament id/slug to get the draw bracket — rounds carry a round
731
+ code (Q1..R128, QF, SF, F) with each match's players and score. Set is the
732
+ primary tennis path here; there is no separate draw tool.
733
+
734
+ Parallel-safe: yes. Upstream cost: 1–4.
735
735
  Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "includeStandings": false }`,
736
736
  inputSchema: {
737
737
  type: 'object',
@@ -154,20 +154,20 @@ function slimUfcNextCard(payload) {
154
154
  }
155
155
  export const liveMatches = {
156
156
  name: 'live_matches',
157
- description: `Live matches board across primary games, or a single game filter. Normalized labels, scores, and matchIds.
158
-
159
- When to use:
160
- - "What's live right now?"
161
- - Ops/dashboard live strip
162
-
163
- Prefer over: sequential per-game call_api live probes.
164
-
165
- Do not use when: user wants upcoming fixtures → upcoming_schedule; historical results → match_summary.
166
-
167
- CS2 live path is /cs2/live; UFC is included in multi-game fan-out.
168
- UFC empty board: section.note + emptyReason + health (workerAlive/lag) + optional supervisor/nextCard (non-live); never fake match items from events[].
169
-
170
- Parallel-safe: yes. Upstream cost: 1–5 (allSettled).
157
+ description: `Live matches board across primary games, or a single game filter. Normalized labels, scores, and matchIds.
158
+
159
+ When to use:
160
+ - "What's live right now?"
161
+ - Ops/dashboard live strip
162
+
163
+ Prefer over: sequential per-game call_api live probes.
164
+
165
+ Do not use when: user wants upcoming fixtures → upcoming_schedule; historical results → match_summary.
166
+
167
+ CS2 live path is /cs2/live; UFC is included in multi-game fan-out.
168
+ UFC empty board: section.note + emptyReason + health (workerAlive/lag) + optional supervisor/nextCard (non-live); never fake match items from events[].
169
+
170
+ Parallel-safe: yes. Upstream cost: 1–5 (allSettled).
171
171
  Example: { "game": "all", "limitPerGame": 10 }`,
172
172
  inputSchema: {
173
173
  type: 'object',
@@ -347,24 +347,24 @@ Example: { "game": "all", "limitPerGame": 10 }`,
347
347
  };
348
348
  export const upcomingSchedule = {
349
349
  name: 'upcoming_schedule',
350
- description: `Upcoming matches/events for one game, with game-specific filters.
351
-
352
- When to use:
353
- - "What's on this week?"
354
- - Calendar UI; team next matches
355
-
356
- Prefer over: live_matches for not-yet-started fixtures.
357
-
358
- Do not use when: only in-progress matches needed → live_matches.
359
-
360
- Filter support (unsupported params are ignored with meta.warnings — do not assume filtering worked):
361
- - lol: hours, team (slug), league (slug)
362
- - cs2: team, from, to (ISO); hours not applied upstream
363
- - cod: team, tournamentId
364
- - dota2: limit/cursor primarily; team may be client-filtered where data allows
365
- - ufc: hours / from / to applied client-side after bout expansion (API has no hours); event shells labeled by event name; bouts use fighters[] corners
366
-
367
- Parallel-safe: yes. Upstream cost: 1–2.
350
+ description: `Upcoming matches/events for one game, with game-specific filters.
351
+
352
+ When to use:
353
+ - "What's on this week?"
354
+ - Calendar UI; team next matches
355
+
356
+ Prefer over: live_matches for not-yet-started fixtures.
357
+
358
+ Do not use when: only in-progress matches needed → live_matches.
359
+
360
+ Filter support (unsupported params are ignored with meta.warnings — do not assume filtering worked):
361
+ - lol: hours, team (slug), league (slug)
362
+ - cs2: team, from, to (ISO); hours not applied upstream
363
+ - cod: team, tournamentId
364
+ - dota2: limit/cursor primarily; team may be client-filtered where data allows
365
+ - ufc: hours / from / to applied client-side after bout expansion (API has no hours); event shells labeled by event name; bouts use fighters[] corners
366
+
367
+ Parallel-safe: yes. Upstream cost: 1–2.
368
368
  Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
369
369
  inputSchema: {
370
370
  type: 'object',
@@ -163,18 +163,18 @@ function primaryPath(game, matchId) {
163
163
  }
164
164
  export const matchSummary = {
165
165
  name: 'match_summary',
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.
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.
178
178
  Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
179
179
  inputSchema: {
180
180
  type: 'object',
@@ -586,23 +586,23 @@ export function summarizeTennisMatchStats(data) {
586
586
  }
587
587
  export const matchDetails = {
588
588
  name: 'match_details',
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).
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).
606
606
  Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
607
607
  inputSchema: {
608
608
  type: 'object',
@@ -809,7 +809,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
809
809
  else {
810
810
  partial.push(partialFromRejection('odds', {
811
811
  code: 'NOT_IMPLEMENTED',
812
- message: `Odds are not curated for ${game}. Currently available: UFC (/ufc/bouts/{id}/odds). Tennis odds were withdrawn.`,
812
+ message: `Odds are only available for UFC (/ufc/bouts/{id}/odds).`,
813
813
  }));
814
814
  }
815
815
  }
@@ -488,18 +488,18 @@ const GAMES_META = [
488
488
  ];
489
489
  export const listCapabilities = {
490
490
  name: 'list_capabilities',
491
- description: `Curated catalog of cito-mcp tools, games, jobs, and builder recipes.
492
-
493
- When to use:
494
- - Session start or "what can you do?"
495
- - Mapping app screens to tools
496
- - Filtering by game or job (live_board, match_page, team_page, player_form, standings, h2h, schedule, preview, event_card, app_scaffold)
497
-
498
- Prefer over: guessing from memory; exploring raw OpenAPI via call_api.
499
-
500
- Do not use when: you already know the tool and have IDs — call that tool directly.
501
-
502
- Parallel-safe: yes. Upstream cost: 0.
491
+ description: `Curated catalog of cito-mcp tools, games, jobs, and builder recipes.
492
+
493
+ When to use:
494
+ - Session start or "what can you do?"
495
+ - Mapping app screens to tools
496
+ - Filtering by game or job (live_board, match_page, team_page, player_form, standings, h2h, schedule, preview, event_card, app_scaffold)
497
+
498
+ Prefer over: guessing from memory; exploring raw OpenAPI via call_api.
499
+
500
+ Do not use when: you already know the tool and have IDs — call that tool directly.
501
+
502
+ Parallel-safe: yes. Upstream cost: 0.
503
503
  Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
504
504
  inputSchema: {
505
505
  type: 'object',
@@ -582,18 +582,18 @@ Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
582
582
  };
583
583
  export const apiHealth = {
584
584
  name: 'api_health',
585
- description: `API reachability, API key validity, plan tier, rate-limit headers, and best-effort included games.
586
-
587
- When to use:
588
- - Once per session before heavy work
589
- - After 401/403/UNSUPPORTED_GAME/RATE_LIMIT
590
- - App scaffolding entitlement checks
591
-
592
- Prefer over: probing random game endpoints to test the key.
593
-
594
- Do not use when: you only need live scores — use live_matches.
595
-
596
- Parallel-safe: yes. Upstream cost: 1–6.
585
+ description: `API reachability, API key validity, plan tier, rate-limit headers, and best-effort included games.
586
+
587
+ When to use:
588
+ - Once per session before heavy work
589
+ - After 401/403/UNSUPPORTED_GAME/RATE_LIMIT
590
+ - App scaffolding entitlement checks
591
+
592
+ Prefer over: probing random game endpoints to test the key.
593
+
594
+ Do not use when: you only need live scores — use live_matches.
595
+
596
+ Parallel-safe: yes. Upstream cost: 1–6.
597
597
  Example: { "includeGameProbes": true }`,
598
598
  inputSchema: {
599
599
  type: 'object',
@@ -736,21 +736,21 @@ function pathAllowed(path) {
736
736
  }
737
737
  export const callApi = {
738
738
  name: 'call_api',
739
- description: `Power escape hatch: allowlisted Cito REST call with unshaped raw JSON in data.raw.
740
-
741
- When to use:
742
- - Long-tail paths not yet curated (Fortnite, CS2 leaderboards, niche stats)
743
- - Debugging payloads while building an app
744
- - User explicitly knows an OpenAPI path
745
-
746
- Prefer curated tools for all standard jobs (live, schedule, profiles, standings, H2H, previews).
747
-
748
- Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
749
-
750
- Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis.
751
- Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
752
-
753
- Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
739
+ description: `Power escape hatch: allowlisted Cito REST call with unshaped raw JSON in data.raw.
740
+
741
+ When to use:
742
+ - Long-tail paths not yet curated (Fortnite, CS2 leaderboards, niche stats)
743
+ - Debugging payloads while building an app
744
+ - User explicitly knows an OpenAPI path
745
+
746
+ Prefer curated tools for all standard jobs (live, schedule, profiles, standings, H2H, previews).
747
+
748
+ Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
749
+
750
+ Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis.
751
+ Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
752
+
753
+ Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
754
754
  Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"page\\":1,\\"limit\\":20}" }`,
755
755
  inputSchema: {
756
756
  type: 'object',
@@ -834,7 +834,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
834
834
  });
835
835
  }
836
836
  }
837
- const res = await fetchJson(ctx, path, { method, query });
837
+ const res = await fetchJson(ctx, path, { method, query, raw: true });
838
838
  if (!res.ok) {
839
839
  return errorEnvelope({
840
840
  code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
@@ -872,20 +872,20 @@ const SPEC_OMITS = {
872
872
  };
873
873
  export const listRoutes = {
874
874
  name: 'list_routes',
875
- description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
876
-
877
- When to use:
878
- - You need a long-tail path for call_api and do not want to guess
879
- - Checking whether an endpoint exists before building around it
880
- - Mapping what raw data backs a curated tool
881
-
882
- Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
883
-
884
- Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
885
-
886
- Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
887
-
888
- Parallel-safe: yes. Upstream cost: 1.
875
+ description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
876
+
877
+ When to use:
878
+ - You need a long-tail path for call_api and do not want to guess
879
+ - Checking whether an endpoint exists before building around it
880
+ - Mapping what raw data backs a curated tool
881
+
882
+ Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
883
+
884
+ Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
885
+
886
+ Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
887
+
888
+ Parallel-safe: yes. Upstream cost: 1.
889
889
  Example: { "game": "ufc", "q": "rankings" }`,
890
890
  inputSchema: {
891
891
  type: 'object',
@@ -44,17 +44,17 @@ function identityFrom(game, raw, idHint, slugHint) {
44
44
  }
45
45
  export const playerProfile = {
46
46
  name: 'player_profile',
47
- description: `Player or UFC fighter profile: identity, current team, recent matches, and form/trends/radar when available.
48
-
49
- When to use:
50
- - "How is X playing lately?"
51
- - Player page scaffold; form inputs for previews
52
-
53
- Prefer over: manual multi-call career/trends/matches via call_api.
54
-
55
- Do not use when: full team roster needed → team_profile; unresolved name → resolve_entity first.
56
-
57
- Parallel-safe: yes. Upstream cost: 2–5.
47
+ description: `Player or UFC fighter profile: identity, current team, recent matches, and form/trends/radar when available.
48
+
49
+ When to use:
50
+ - "How is X playing lately?"
51
+ - Player page scaffold; form inputs for previews
52
+
53
+ Prefer over: manual multi-call career/trends/matches via call_api.
54
+
55
+ Do not use when: full team roster needed → team_profile; unresolved name → resolve_entity first.
56
+
57
+ Parallel-safe: yes. Upstream cost: 2–5.
58
58
  Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includeTrends": true }`,
59
59
  inputSchema: {
60
60
  type: 'object',
@@ -541,17 +541,17 @@ function normalizeFormMatch(row) {
541
541
  }
542
542
  export const playerForm = {
543
543
  name: 'player_form',
544
- description: `Tennis player form: W/L record over the last N completed matches, current win/loss streak, and per-match rows (opponent, score, surface). Optional surface filter (Hard/Clay/Grass).
545
-
546
- When to use:
547
- - "How is X playing lately?"; current streak; surface-specific record (e.g. clay last 5).
548
-
549
- Prefer over: player_profile (identity + career aggregates, no streak); raw form via call_api for agent-normalized rows.
550
-
551
- Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
552
-
553
- Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
554
-
544
+ description: `Tennis player form: W/L record over the last N completed matches, current win/loss streak, and per-match rows (opponent, score, surface). Optional surface filter (Hard/Clay/Grass).
545
+
546
+ When to use:
547
+ - "How is X playing lately?"; current streak; surface-specific record (e.g. clay last 5).
548
+
549
+ Prefer over: player_profile (identity + career aggregates, no streak); raw form via call_api for agent-normalized rows.
550
+
551
+ Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
552
+
553
+ Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
554
+
555
555
  Parallel-safe: yes. Upstream cost: 1.`,
556
556
  inputSchema: {
557
557
  type: 'object',
@@ -700,18 +700,18 @@ Parallel-safe: yes. Upstream cost: 1.`,
700
700
  };
701
701
  export const playerMatches = {
702
702
  name: 'player_matches',
703
- description: `Tennis player match log: every archived match for one player, newest first, with opponent, tournament, round, surface and score.
704
-
705
- When to use:
706
- - "Show me Shelton's last 20 matches"; full match history; every match at a given tournament; filtering a player's record by surface.
707
-
708
- Prefer over: player_form (a W/L summary with a small window and a streak, not the log); call_api for /tennis/players/{id}/matches.
709
-
710
- Do not use when: aggregate totals, titles, or win% → player_stats; identity → player_profile; one match's box score → match_details.
711
-
712
- Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
713
- surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
714
-
703
+ description: `Tennis player match log: every archived match for one player, newest first, with opponent, tournament, round, surface and score.
704
+
705
+ When to use:
706
+ - "Show me Shelton's last 20 matches"; full match history; every match at a given tournament; filtering a player's record by surface.
707
+
708
+ Prefer over: player_form (a W/L summary with a small window and a streak, not the log); call_api for /tennis/players/{id}/matches.
709
+
710
+ Do not use when: aggregate totals, titles, or win% → player_stats; identity → player_profile; one match's box score → match_details.
711
+
712
+ Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
713
+ surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
714
+
715
715
  Parallel-safe: yes. Upstream cost: 1.`,
716
716
  inputSchema: {
717
717
  type: 'object',
@@ -869,16 +869,16 @@ Parallel-safe: yes. Upstream cost: 1.`,
869
869
  };
870
870
  export const playerStats = {
871
871
  name: 'player_stats',
872
- description: `Tennis player career statistics: W/L totals, win percentage, titles, Grand Slam and Masters titles, plus per-surface and per-level breakdowns. Optional surface/year filters.
873
-
874
- When to use:
875
- - "How many titles does Alcaraz have?"; career win%; Grand Slam title count; record on clay; a season-scoped record.
876
-
877
- Prefer over: player_profile (identity + a summary block, no breakdowns); player_matches (individual rows, no aggregates).
878
-
879
- Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
880
-
881
- Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
872
+ description: `Tennis player career statistics: W/L totals, win percentage, titles, Grand Slam and Masters titles, plus per-surface and per-level breakdowns. Optional surface/year filters.
873
+
874
+ When to use:
875
+ - "How many titles does Alcaraz have?"; career win%; Grand Slam title count; record on clay; a season-scoped record.
876
+
877
+ Prefer over: player_profile (identity + a summary block, no breakdowns); player_matches (individual rows, no aggregates).
878
+
879
+ Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
880
+
881
+ Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
882
882
  Upstream cost: 1.`,
883
883
  inputSchema: {
884
884
  type: 'object',
@@ -77,26 +77,26 @@ export function normalizeStandingRow(row, index) {
77
77
  }
78
78
  export const standings = {
79
79
  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.
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.
100
100
  Example: { "game": "cod", "season": "2026", "limit": 50 }`,
101
101
  inputSchema: {
102
102
  type: 'object',
@@ -31,18 +31,18 @@ function numberOrNull(value) {
31
31
  }
32
32
  export const teamProfile = {
33
33
  name: 'team_profile',
34
- description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
35
-
36
- When to use:
37
- - Team page / "who is on this roster?"
38
- - Builder team screen sample
39
-
40
- Prefer over: separate roster + matches + detail via call_api.
41
-
42
- Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
43
- Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
44
-
45
- Parallel-safe: yes. Upstream cost: 2–4.
34
+ description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
35
+
36
+ When to use:
37
+ - Team page / "who is on this roster?"
38
+ - Builder team screen sample
39
+
40
+ Prefer over: separate roster + matches + detail via call_api.
41
+
42
+ Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
43
+ Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
44
+
45
+ Parallel-safe: yes. Upstream cost: 2–4.
46
46
  Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
47
47
  inputSchema: {
48
48
  type: 'object',
@@ -532,19 +532,19 @@ 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.
536
-
537
- When to use:
538
- - Rivalry / series record questions
539
- - Supporting context for previews
540
-
541
- Prefer over: agent-side double match-list filtering.
542
-
543
- Do not use when: single-side form only → team_profile or player_profile.
544
-
545
- Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
546
-
547
- Parallel-safe: yes. Upstream cost: 2–4.
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.
536
+
537
+ When to use:
538
+ - Rivalry / series record questions
539
+ - Supporting context for previews
540
+
541
+ Prefer over: agent-side double match-list filtering.
542
+
543
+ Do not use when: single-side form only → team_profile or player_profile.
544
+
545
+ Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
546
+
547
+ Parallel-safe: yes. Upstream cost: 2–4.
548
548
  Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
549
549
  inputSchema: {
550
550
  type: 'object',
@@ -960,18 +960,18 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
960
960
  };
961
961
  export const h2hMatrix = {
962
962
  name: 'h2h_matrix',
963
- description: `Multi-player tennis head-to-head grid: every pair's series record in one comparison matrix.
964
-
965
- When to use:
966
- - Draw/field analysis: how each contender fares against every other (e.g. Alcaraz vs Zverev vs Sinner round-robin records)
967
- - Group-stage or semifinal-field comparisons
968
-
969
- Prefer over: N head_to_head calls for an N-player field; raw matrix via call_api.
970
-
971
- Do not use when: a two-player rivalry deep-dive with tiebreak/decider splits → head_to_head; season stat leaders → leaderboard_*; rankings → standings.
972
-
973
- 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.
974
-
963
+ description: `Multi-player tennis head-to-head grid: every pair's series record in one comparison matrix.
964
+
965
+ When to use:
966
+ - Draw/field analysis: how each contender fares against every other (e.g. Alcaraz vs Zverev vs Sinner round-robin records)
967
+ - Group-stage or semifinal-field comparisons
968
+
969
+ Prefer over: N head_to_head calls for an N-player field; raw matrix via call_api.
970
+
971
+ Do not use when: a two-player rivalry deep-dive with tiebreak/decider splits → head_to_head; season stat leaders → leaderboard_*; rankings → standings.
972
+
973
+ 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.
974
+
975
975
  Parallel-safe: yes. Upstream cost: 1 + one search per unresolved name.`,
976
976
  inputSchema: {
977
977
  type: 'object',
package/package.json CHANGED
@@ -1,8 +1,28 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.4.5",
4
- "description": "Standalone MCP server for the Cito esports and sports API — 42 curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
3
+ "version": "0.4.7",
4
+ "description": "Standalone MCP server for the Cito esports and sports API — curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
5
5
  "type": "module",
6
+ "homepage": "https://cito.gg",
7
+ "//REPOSITORY": "Intentionally NOT set. cito-api-scraper and cito.gg are PRIVATE repos and must stay that way, so a repository URL pointing at them would render as a broken 404 link on the public npm page and would not satisfy MCP Registry npm ownership validation. Set this only if a PUBLIC repo for this package is ever created (e.g. achillesscriptsvip/cito-mcp with this directory as root) — then use `git+https://github.com/OWNER/REPO.git` and mirror it in the `mcpName`/server.json `name` pair. Do not point it at the private monorepo.",
8
+ "keywords": [
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "esports",
12
+ "cs2",
13
+ "counter-strike",
14
+ "counter-strike-2",
15
+ "lol",
16
+ "league-of-legends",
17
+ "dota2",
18
+ "cod",
19
+ "ufc",
20
+ "mma",
21
+ "tennis",
22
+ "live-scores",
23
+ "sports-data",
24
+ "esports-api"
25
+ ],
6
26
  "bin": {
7
27
  "cito-mcp": "dist/index.js"
8
28
  },
@@ -1,287 +0,0 @@
1
- import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
2
- import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
3
- import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
4
- function normalizeBookmaker(row) {
5
- const r = asRecord(row) ?? {};
6
- const markets = (Array.isArray(r.markets) ? r.markets : []).map((mk) => {
7
- const m = asRecord(mk) ?? {};
8
- const outcomes = (Array.isArray(m.outcomes) ? m.outcomes : []).map((o) => {
9
- const oc = asRecord(o) ?? {};
10
- return {
11
- name: pickString(oc.name) ?? null,
12
- price: typeof oc.price === 'number' && Number.isFinite(oc.price) ? oc.price : null,
13
- point: typeof oc.point === 'number' && Number.isFinite(oc.point) ? oc.point : null,
14
- };
15
- });
16
- return {
17
- market: pickString(m.key) ?? null,
18
- outcomes,
19
- impliedProbabilities: (() => {
20
- const prices = outcomes.map((o) => o.price).filter((p) => p !== null && p > 1);
21
- if (prices.length !== outcomes.length || prices.length === 0)
22
- return null;
23
- const inv = prices.map((p) => 1 / p);
24
- const sum = inv.reduce((a, b) => a + b, 0);
25
- return inv.map((v) => Number((v / sum).toFixed(4)));
26
- })(),
27
- };
28
- });
29
- return {
30
- key: pickString(r.key) ?? null,
31
- title: pickString(r.title) ?? null,
32
- lastUpdate: pickString(r.last_update) ?? null,
33
- markets,
34
- };
35
- }
36
- export function summarizeTennisOdds(data) {
37
- const root = asRecord(data) ?? {};
38
- const payload = asRecord(root.data) ?? root;
39
- const coverage = asRecord(payload.coverage) ?? {};
40
- const bookmakers = (Array.isArray(payload.bookmakers) ? payload.bookmakers : []).map(normalizeBookmaker);
41
- const hasOdds = coverage.odds === true || bookmakers.length > 0;
42
- return {
43
- matchId: pickString(payload.match_id) ?? null,
44
- oddsFormat: pickString(payload.odds_format) ?? 'decimal',
45
- commenceTime: pickString(payload.commence_time) ?? null,
46
- stale: payload.stale === true,
47
- bookmakers,
48
- oddsAvailable: hasOdds,
49
- note: hasOdds
50
- ? null
51
- : 'No bookmaker is currently quoting this match. This is an upstream coverage gap, not an error; retry closer to the start time.',
52
- fullBookPath: '/tennis/odds/{matchId}',
53
- };
54
- }
55
- function parseTennis(args, tool) {
56
- const gameParse = parseGame(args.game, { allowAll: false, required: true });
57
- if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
58
- return { error: gameParse.error ?? 'game is required', game: null };
59
- }
60
- const game = gameParse.game;
61
- if (game !== 'tennis') {
62
- return {
63
- error: `${tool} is tennis-only (odds for '${game}' is not served by this endpoint)`,
64
- game,
65
- };
66
- }
67
- return { game };
68
- }
69
- function requireTennisId(args, tool) {
70
- const id = typeof args.matchId === 'string' ? args.matchId.trim() : '';
71
- if (!id)
72
- return { error: `${tool} requires matchId` };
73
- return { matchId: id };
74
- }
75
- export const tennisOdds = {
76
- name: 'tennis_odds',
77
- description: `Tennis betting odds: upcoming matches with prices, plus pre-match and in-play odds for one match.
78
-
79
- Modes (pick with 'scope'):
80
- - upcoming (default): matches with odds available, each with the bookmakers quoting it.
81
- - match: pre-match odds for one match_id.
82
- - live: in-play odds for one match_id.
83
-
84
- When to use:
85
- - "What are the odds on X vs Y?"; pre-match prices; in-play prices; which matches have odds today.
86
-
87
- Prefer over: call_api for /tennis/odds/*; match_preview (no prices).
88
-
89
- Do not use when: the match result → match_details; rankings → standings.
90
-
91
- Tennis-only. Odds are decimal. Coverage is partial upstream: a match with no
92
- priced bookmaker returns coverage.odds=false and an empty bookmakers[] rather
93
- than invented numbers, and this tool surfaces that distinction explicitly.
94
- A match id that does not exist is a NOT_FOUND error, not a coverage gap: the
95
- odds route answers 200/odds:false for every unknown id, so scope=match and
96
- scope=live verify the match against /tennis/matches/{id} before reporting a gap,
97
- and set matchExists.
98
-
99
- Parallel-safe: yes. Upstream cost: 1.`,
100
- inputSchema: {
101
- type: 'object',
102
- additionalProperties: false,
103
- required: ['game'],
104
- properties: {
105
- game: gameSchema({ allowAll: false, required: true }),
106
- scope: stringSchema('upcoming (default) | match | live.', 'upcoming'),
107
- matchId: stringSchema('Match id for scope=match or scope=live, e.g. "s365_2026_4853596".', 's365_2026_4853596'),
108
- limit: limitSchema({ default: 20, max: 50, description: 'Rows for scope=upcoming (default 20, max 50).' }),
109
- },
110
- },
111
- handler: async (args, ctx) => {
112
- const started = Date.now();
113
- const requestId = newRequestId();
114
- const parsed = parseTennis(args, 'tennis_odds');
115
- if (parsed.error) {
116
- return errorEnvelope({
117
- code: parsed.error.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
118
- message: parsed.error,
119
- game: parsed.game,
120
- source: 'tennis_odds',
121
- requestId,
122
- tookMs: Date.now() - started,
123
- });
124
- }
125
- const game = parsed.game;
126
- const scopeRaw = typeof args.scope === 'string' ? args.scope.trim().toLowerCase() : 'upcoming';
127
- if (!['upcoming', 'match', 'live'].includes(scopeRaw)) {
128
- return errorEnvelope({
129
- code: 'VALIDATION',
130
- message: `scope must be upcoming, match, or live (got '${args.scope}')`,
131
- game,
132
- source: 'tennis_odds',
133
- requestId,
134
- tookMs: Date.now() - started,
135
- recover: ['Use scope=upcoming for matches with odds', 'Use scope=match or scope=live with a matchId'],
136
- });
137
- }
138
- const matchId = typeof args.matchId === 'string' ? args.matchId.trim() : '';
139
- if (scopeRaw !== 'upcoming' && !matchId) {
140
- return errorEnvelope({
141
- code: 'VALIDATION',
142
- message: `matchId is required when scope=${scopeRaw}`,
143
- game,
144
- source: 'tennis_odds',
145
- requestId,
146
- tookMs: Date.now() - started,
147
- recover: [`Pass matchId, or use scope=upcoming with no id`],
148
- });
149
- }
150
- const limit = clampInt(args.limit, 20, 1, 50);
151
- if (scopeRaw === 'upcoming') {
152
- const res = await fetchJson(ctx, '/tennis/odds/upcoming', { query: { limit } });
153
- if (!res.ok) {
154
- return errorEnvelope({
155
- code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
156
- message: `Upcoming tennis odds failed (HTTP ${res.status})`,
157
- game,
158
- source: 'tennis_odds',
159
- requestId,
160
- tookMs: Date.now() - started,
161
- upstreamCalls: 1,
162
- httpStatus: res.status,
163
- rateLimit: res.headers,
164
- });
165
- }
166
- const root = asRecord(res.data) ?? {};
167
- const body = asRecord(root.data) ?? root;
168
- const items = (Array.isArray(body.items) ? body.items : []).map((row) => {
169
- const r = asRecord(row) ?? {};
170
- const home = asRecord(r.home) ?? {};
171
- const away = asRecord(r.away) ?? {};
172
- const homeId = pickString(home.player_id, home.id) ?? null;
173
- const awayId = pickString(away.player_id, away.id) ?? null;
174
- const matchId = pickString(r.match_id, r.id) ?? null;
175
- const oddsEventId = pickString(r.odds_event_id) ?? null;
176
- return {
177
- matchId,
178
- oddsEventId,
179
- tournament: pickString(r.tournament) ?? null,
180
- commenceTime: pickString(r.commence_time, r.starts_at) ?? null,
181
- player1: { name: pickString(home.name) ?? null, id: homeId },
182
- player2: { name: pickString(away.name) ?? null, id: awayId },
183
- bookmakers: Array.isArray(r.bookmakers) ? r.bookmakers.map((b) => pickString(b) ?? null) : [],
184
- live: r.live === true,
185
- joinKey: matchId
186
- ? { kind: 'matchId', matchId }
187
- : oddsEventId
188
- ? { kind: 'oddsEventId', oddsEventId }
189
- : null,
190
- playerIds: [homeId, awayId].filter((x) => Boolean(x)),
191
- };
192
- });
193
- const total = typeof body.total === 'number' ? body.total : items.length;
194
- return successEnvelope({
195
- pagination: {
196
- limit,
197
- offset: 0,
198
- total,
199
- hasMore: body.has_next === true,
200
- nextCursor: null,
201
- prevCursor: null,
202
- },
203
- source: 'tennis_odds',
204
- game,
205
- requestId,
206
- tookMs: Date.now() - started,
207
- upstreamCalls: 1,
208
- rateLimit: res.headers,
209
- data: {
210
- title: `Tennis odds — upcoming (${items.length} of ${total})`,
211
- scope: scopeRaw,
212
- oddsFormat: 'decimal',
213
- items,
214
- total,
215
- },
216
- });
217
- }
218
- const path = scopeRaw === 'match'
219
- ? `/tennis/odds/${encodeURIComponent(matchId)}`
220
- : `/tennis/odds/${encodeURIComponent(matchId)}/live`;
221
- const res = await fetchJson(ctx, path, {});
222
- let upstreamCalls = 1;
223
- let rateLimit = res.headers;
224
- if (!res.ok) {
225
- return errorEnvelope({
226
- code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
227
- message: `Tennis ${scopeRaw} odds failed for '${matchId}' (HTTP ${res.status})`,
228
- game,
229
- source: 'tennis_odds',
230
- requestId,
231
- tookMs: Date.now() - started,
232
- upstreamCalls: 1,
233
- httpStatus: res.status,
234
- rateLimit: res.headers,
235
- recover: ['Check the match id with live_matches or match_details'],
236
- });
237
- }
238
- const summary = summarizeTennisOdds(res.data);
239
- let matchExists = null;
240
- if (summary.oddsAvailable !== true) {
241
- const probe = await fetchJson(ctx, `/tennis/matches/${encodeURIComponent(matchId)}`);
242
- upstreamCalls += 1;
243
- rateLimit = { ...rateLimit, ...probe.headers };
244
- matchExists = probe.ok;
245
- if (!matchExists) {
246
- const liveProbe = await fetchJson(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
247
- upstreamCalls += 1;
248
- rateLimit = { ...rateLimit, ...liveProbe.headers };
249
- matchExists = liveProbe.ok;
250
- }
251
- if (!matchExists) {
252
- return errorEnvelope({
253
- code: 'NOT_FOUND',
254
- message: `No tennis match with id '${matchId}' — the odds feed has no event for it because the id itself does not exist`,
255
- game,
256
- source: 'tennis_odds',
257
- requestId,
258
- tookMs: Date.now() - started,
259
- upstreamCalls,
260
- rateLimit,
261
- httpStatus: 200,
262
- recover: [
263
- 'Check the id with live_matches or tennis_schedule / match_summary',
264
- 'Ids come from live_matches (s365_*) or the archive (s365_{year}_{gid})',
265
- `For the pre-match odds list instead, call tennis_odds { game: "tennis", scope: "upcoming" }`,
266
- ],
267
- });
268
- }
269
- }
270
- return successEnvelope({
271
- source: 'tennis_odds',
272
- game,
273
- requestId,
274
- tookMs: Date.now() - started,
275
- upstreamCalls,
276
- rateLimit,
277
- data: {
278
- title: `Tennis ${scopeRaw} odds — ${matchId}`,
279
- scope: scopeRaw,
280
- ...summary,
281
- matchId: summary.matchId ?? matchId,
282
- matchExists,
283
- },
284
- });
285
- },
286
- };
287
- export const oddsTools = [tennisOdds];