cito-mcp 0.5.8 → 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.
package/README.md CHANGED
@@ -43,7 +43,7 @@ Composites multi-fetch server-side, return a **stable JSON envelope**, and isola
43
43
 
44
44
  ### Honest gaps, in-band
45
45
 
46
- Where the source data is partial, the tool says so instead of shipping a plausible-looking wrong value:
46
+ Where data is partial, the tool says so instead of shipping a plausible-looking wrong value:
47
47
 
48
48
  | Situation | What the tool returns |
49
49
  | --- | --- |
@@ -116,7 +116,7 @@ Windows local clone:
116
116
  "mcpServers": {
117
117
  "cito": {
118
118
  "command": "node",
119
- "args": ["C:/Users/Owner/Projects/cito-api-scraper-windows/mcp/dist/index.js"],
119
+ "args": ["/absolute/path/to/cito-mcp/dist/index.js"],
120
120
  "env": {
121
121
  "CITO_API_KEY": "cito_your_key_here"
122
122
  }
@@ -137,7 +137,7 @@ The server **exits immediately** if `CITO_API_KEY` is missing. The key is sent o
137
137
 
138
138
  | Variable | Required | Default | Description |
139
139
  | --- | --- | --- | --- |
140
- | `CITO_API_KEY` | **yes** | — | Cito API key → `x-api-key` on every upstream call |
140
+ | `CITO_API_KEY` | **yes** | — | Cito API key → `x-api-key` on every API call |
141
141
  | `CITO_API_BASE` | no | `https://api.citoapi.com/api/v1` | Override base URL (staging / self-hosted) |
142
142
 
143
143
  ---
@@ -160,7 +160,7 @@ Mnemonic: **resolve → live/schedule → summary → deep**.
160
160
 
161
161
  ## Tool catalog (16)
162
162
 
163
- All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (the server name already brands the surface).
163
+ All tools are **read-only** except the webhook tools (`create_webhook`, `update_webhook`, `delete_webhook`, `regenerate_webhook_secret`, `test_webhook`, `replay_webhook`, `catch_up_webhook`, and the inbox tools), which only touch your own webhooks and test inboxes. Names are `snake_case` with **no** `cito_` prefix (the server name already brands the surface).
164
164
 
165
165
  | Tool | Outcome | When to use | When not to use | Key inputs |
166
166
  | --- | --- | --- | --- | --- |
@@ -183,7 +183,7 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
183
183
 
184
184
  ### Images
185
185
 
186
- Fighter and team sides carry an `images` object wherever upstream supplies one —
186
+ Fighter and team sides carry an `images` object wherever one exists —
187
187
  on `event_card` bout corners and on `player_profile`. No extra call, no N+1.
188
188
 
189
189
  ```jsonc
@@ -206,11 +206,11 @@ hotlink-block, so raw URLs render as broken images in a web UI. The proxied URL
206
206
  is served by the API and is safe to put in an `<img src>`.
207
207
 
208
208
  When a key is `null` the image does not exist for that entity; when the whole
209
- `images` object is absent, upstream sent nothing for that side. The object is
209
+ `images` object is absent, there is no image for that side. The object is
210
210
  never partially shaped — if any image exists, all four keys are present.
211
211
 
212
- Tennis is the exception on purpose: the feed supplies a single `portrait_url`
213
- (Wikimedia Commons) and no square crop, so that one URL fills both `headshotUrl`
212
+ Tennis is the exception on purpose: there is a single `portrait_url`
213
+ and no square crop, so that one URL fills both `headshotUrl`
214
214
  and `imageUrl`, and `proxiedImageUrl` stays `null` because only fighter image URLs have
215
215
  a proxy.
216
216
 
@@ -229,8 +229,8 @@ them:
229
229
  Commons portraits are mostly CC BY / CC BY-SA, which for commercial display
230
230
  generally requires naming the author and stating the licence. `attribution` is
231
231
  `null` for public-domain and CC0 images, which need no credit — that is a fact,
232
- not missing data. Anything without a defensible licence has its URL removed
233
- upstream, so a non-null `headshotUrl` always has a `license` beside it.
232
+ not missing data. Anything without a defensible licence has its URL removed,
233
+ so a non-null `headshotUrl` always has a `license` beside it.
234
234
 
235
235
  ### Games
236
236
 
@@ -285,11 +285,11 @@ Every tool returns one JSON object:
285
285
  "data": {},
286
286
  "meta": {
287
287
  "game": "cs2",
288
- "source": "match_summary",
288
+ "tool": "match_summary",
289
289
  "fetchedAt": "2026-07-27T12:00:00.000Z",
290
290
  "requestId": "mcp_…",
291
291
  "tookMs": 120,
292
- "upstreamCalls": 3,
292
+ "apiCalls": 3,
293
293
  "rateLimit": { "tier": "pro", "remaining": 54 }
294
294
  },
295
295
  "pagination": { "limit": 20, "hasMore": false, "nextCursor": null },
@@ -304,11 +304,11 @@ Every tool returns one JSON object:
304
304
  | `ok: true` with `partial[]` | Use successful sections; **do not** treat as total failure |
305
305
  | List tools | `data.items` + `pagination` (`limit`, `hasMore`, `nextCursor`, …) |
306
306
 
307
- **Error codes:** `VALIDATION` · `NOT_FOUND` · `UNSUPPORTED_GAME` · `UNAUTHORIZED` · `RATE_LIMIT` · `UPSTREAM` · `PATH_NOT_ALLOWED` · `NOT_IMPLEMENTED`
307
+ **Error codes:** `VALIDATION` · `NOT_FOUND` · `UNSUPPORTED_GAME` · `UNAUTHORIZED` · `RATE_LIMIT` · `SERVICE_ERROR` · `PATH_NOT_ALLOWED` · `NOT_IMPLEMENTED`
308
308
 
309
309
  **Ambiguity:** `resolve_entity` returns `ok: true` with `data.needsDisambiguation` + `data.candidates` — pick a candidate; do not wait for a hard error.
310
310
 
311
- Retry only when `error.retryable` is true (typically `RATE_LIMIT`, `UPSTREAM`).
311
+ Retry only when `error.retryable` is true (typically `RATE_LIMIT`, `SERVICE_ERROR`).
312
312
 
313
313
  **Pagination:** default limit 20, max 50 (some tools clamp differently). Honor `pagination.hasMore` and pass `pagination.nextCursor` only — never invent cursors or reuse them after changing filters.
314
314
 
@@ -417,7 +417,7 @@ Use these as agent playbooks when scaffolding apps. Prefer one composite per scr
417
417
  | URI | Purpose |
418
418
  | --- | --- |
419
419
  | `cito://capabilities` | Static tool / game / workflow summary |
420
- | `cito://llms.txt` | Live product agent context (may 404 upstream) |
420
+ | `cito://llms.txt` | Live product agent context |
421
421
  | `cito://openapi.json` | Live public OpenAPI for **typed clients** — not for tool generation |
422
422
 
423
423
  ---
@@ -560,9 +560,9 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
560
560
 
561
561
  ### 0.5.6
562
562
 
563
- - **Tennis `event_card` draw is a real bracket** (customer audit 2026-11-03). `draw.complete` is true only when every main round to the Final is played (it said true beside `missingRounds` F/SF/QF); every main round is listed with `status` (`completed` / `in_progress` / `not_started` / `incomplete_archive`), `expected` (byes excluded: a 56 draw's R64 holds 24), `byes`, `walkovers`, and `pending` (scheduled / live matches, never presented as results). `drawSize` is the real field (Paris 56, `drawSizeSource`, `bracketSize`, `byes`); `matchesExpected` counts the main draw and is compared with `mainDrawMatchesReturned` (qualifying is reported apart). Each bout carries `winnerId` / `loserId` / `outcome` (a "6-3 6-7 RET" retirement says who won), `startTime`, `nextMatchId` (the bracket edge) and `bracketPosition`; rounds report `included` / `omitted` when `drawMaxMatches` trims them.
563
+ - **Tennis `event_card` draw is a real bracket** (customer audit 2026-11-03). `draw.complete` is true only when every main round to the Final is played (it said true beside `missingRounds` F/SF/QF); every main round is listed with `status` (`completed` / `in_progress` / `not_started` / `incomplete_archive`), `expected` (byes excluded: a 56 draw's R64 holds 24), `byes`, `walkovers`, and `pending` (scheduled / live matches, never presented as results). `drawSize` is the real field (Paris 56, `bracketSize`, `byes`); `matchesExpected` counts the main draw and is compared with `mainDrawMatchesReturned` (qualifying is reported apart). Each bout carries `winnerId` / `loserId` / `outcome` (a "6-3 6-7 RET" retirement says who won), `startTime`, `nextMatchId` (the bracket edge) and `bracketPosition`; rounds report `included` / `omitted` when `drawMaxMatches` trims them.
564
564
  - **Tennis `head_to_head` honours `limit`**: it is sent to `/tennis/h2h`, which pages the meetings while every tally still covers all of them (`meetingsReturned` / `meetingsTotal`; the H2H `level_breakdown` now always sums to `total_matches`).
565
- - Other tennis audit fixes in the same release: live stats as structured numerator / denominator / pct, `isSimulated` / `liveFeed` on live rows, `setNumber` from `set_number`, and the schedule / rankings / player readers updated for the API changes of 2026-11-03.
565
+ - Other tennis audit fixes in the same release: live stats as structured numerator / denominator / pct, `isSimulated` on live rows, `setNumber` from `set_number`, and the schedule / rankings / player readers updated for the API changes of 2026-11-03.
566
566
 
567
567
  ### 0.5.5
568
568
 
@@ -597,12 +597,12 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
597
597
 
598
598
  ### 0.4.2
599
599
 
600
- 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.
600
+ 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 fixed on the API; three in the MCP.
601
601
 
602
602
  - **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.
603
603
  - **`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.
604
- - **`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`.
605
- - **`resolve_entity { q: "Nole" }` answered "M Canoles".** The upstream name search puts Novak Djokovic first for `Nole` — it knows a nickname nothing in the payload carries, so `rankScore` scores it 0 — and the tool floored every unmatched row to 1 and then *sorted*, letting a substring collision win. `pushCandidates` now lets the upstream's own first row stand as the alias answer (labelled `meta.matchedBy: "upstream-first"`) when our scorer is silent, and `search_entities` exempts exactly that one row from its relevance floor. Padding is still dropped: `Sinner` still returns only Sinner.
604
+ - **`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. The total is now exactly the qualifying population: 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`.
605
+ - **`resolve_entity { q: "Nole" }` answered "M Canoles".** The name search puts Novak Djokovic first for `Nole` — it knows a nickname nothing in the payload carries, so `rankScore` scores it 0 — and the tool floored every unmatched row to 1 and then *sorted*, letting a substring collision win. `pushCandidates` now lets the search's own first row stand as the alias answer (labelled `meta.matchedBy: "top-hit"`) when our scorer is silent, and `search_entities` exempts exactly that one row from its relevance floor. Padding is still dropped: `Sinner` still returns only Sinner.
606
606
  - **`/tennis/tournaments/calendar` silently ignored `?month=`.** FastAPI drops unknown query parameters, so `?month=9` and `?month=3` returned the identical 621-event season list and a caller asking "what is on in September" got the whole year. The route now takes `month` and filters on the event's start month (2026-09 → 12 events).
607
607
  - **`standings` and `leaderboards` were never cached**, so every call re-ran its grouped scan and paid db1's ~90ms RTT per query: 474–922ms on *every* request while every cached route answered in <20ms. That held eleven tools permanently over the 400ms latency budget. Both prefixes now have TTLs (1800s / 600s); repeat calls measure **4–5ms**.
608
608
  - **`bestOf`** now reads `best_of`, the only spelling the tennis route emits.
@@ -614,7 +614,7 @@ Tennis correctness pass, driven by the 2026-09-12 sweep (`reports/tennis-mcp-swe
614
614
  - **`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`.
615
615
  - **`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.
616
616
  - **`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.
617
- - **`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`.
617
+ - **`tournaments level="WTA 1000"` returned ATP Masters events.** 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`.
618
618
  - **`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`.
619
619
  - **`tennis_schedule`** now counts the day's list/completed overlap, summarises statuses, and flags date-only `startsAt` placeholders.
620
620
  - **`live_matches` labelled tennis rows by tournament**, not players, because the live route's `name` is the event.
package/dist/envelope.js CHANGED
@@ -25,7 +25,7 @@ export const DEFAULT_RECOVER = {
25
25
  'Verify CITO_API_KEY is set and valid',
26
26
  'Call api_health to confirm tier and included games',
27
27
  ],
28
- UPSTREAM: [
28
+ SERVICE_ERROR: [
29
29
  'Retry this tool once',
30
30
  'If it still fails, narrow params (limit, date range) or use a smaller tool',
31
31
  ],
@@ -64,8 +64,8 @@ export function mapHttpToCode(status, hints) {
64
64
  if (status === 400 || status === 422)
65
65
  return 'VALIDATION';
66
66
  if (status >= 500 || status === 0)
67
- return 'UPSTREAM';
68
- return 'UPSTREAM';
67
+ return 'SERVICE_ERROR';
68
+ return 'SERVICE_ERROR';
69
69
  }
70
70
  export function newRequestId() {
71
71
  return `mcp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
@@ -73,7 +73,7 @@ export function newRequestId() {
73
73
  export function successEnvelope(args) {
74
74
  const meta = {
75
75
  game: args.game,
76
- source: args.source,
76
+ tool: args.source,
77
77
  fetchedAt: args.fetchedAt ?? new Date().toISOString(),
78
78
  };
79
79
  if (args.requestId)
@@ -81,7 +81,7 @@ export function successEnvelope(args) {
81
81
  if (args.tookMs !== undefined)
82
82
  meta.tookMs = args.tookMs;
83
83
  if (args.upstreamCalls !== undefined)
84
- meta.upstreamCalls = args.upstreamCalls;
84
+ meta.apiCalls = args.upstreamCalls;
85
85
  if (args.rateLimit)
86
86
  meta.rateLimit = args.rateLimit;
87
87
  if (args.warnings?.length)
@@ -103,10 +103,10 @@ export function successEnvelope(args) {
103
103
  }
104
104
  export function errorEnvelope(args) {
105
105
  const retryable = args.retryable ??
106
- (args.code === 'RATE_LIMIT' || args.code === 'UPSTREAM');
106
+ (args.code === 'RATE_LIMIT' || args.code === 'SERVICE_ERROR');
107
107
  const meta = {
108
108
  game: args.game,
109
- source: args.source,
109
+ tool: args.source,
110
110
  fetchedAt: args.fetchedAt ?? new Date().toISOString(),
111
111
  };
112
112
  if (args.requestId)
@@ -114,7 +114,7 @@ export function errorEnvelope(args) {
114
114
  if (args.tookMs !== undefined)
115
115
  meta.tookMs = args.tookMs;
116
116
  if (args.upstreamCalls !== undefined)
117
- meta.upstreamCalls = args.upstreamCalls;
117
+ meta.apiCalls = args.upstreamCalls;
118
118
  if (args.rateLimit)
119
119
  meta.rateLimit = args.rateLimit;
120
120
  const error = {
@@ -139,7 +139,7 @@ export function errorEnvelope(args) {
139
139
  };
140
140
  }
141
141
  export function partialFromRejection(section, err) {
142
- const code = err.code ?? 'UPSTREAM';
142
+ const code = err.code ?? 'SERVICE_ERROR';
143
143
  return {
144
144
  section,
145
145
  ok: false,
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ const requestAuth = new AsyncLocalStorage();
21
21
  function currentApiKey() {
22
22
  return requestAuth.getStore()?.apiKey || ENV_API_KEY;
23
23
  }
24
- const OFFLINE_TOOLS = new Set(['list_capabilities']);
24
+ const OFFLINE_TOOLS = new Set(['list_capabilities', 'list_webhook_events', 'explain_webhook_setup']);
25
25
  if (!ENV_API_KEY) {
26
26
  console.error('[cito-mcp] CITO_API_KEY is not set. Serving tool catalog only; ' +
27
27
  'API-backed tools will return MISSING_API_KEY until a key is provided ' +
@@ -95,7 +95,7 @@ function createCitoServer() {
95
95
  {
96
96
  uri: 'cito://openapi.json',
97
97
  name: 'Cito OpenAPI (public fetch)',
98
- description: 'Live public OpenAPI snapshot for typed clients (not the tool catalog source).',
98
+ description: 'Live public OpenAPI snapshot for typed clients (separate from the tool catalog).',
99
99
  mimeType: 'application/json',
100
100
  },
101
101
  ],
@@ -1,6 +1,6 @@
1
1
  export const SERVER_INSTRUCTIONS = `# Cito MCP — agent operating manual
2
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.
3
+ You are connected to Cito esports and sports data. Reads are safe. The only writes are the webhook tools, and they only touch this user's own webhooks and test inboxes. 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
4
 
5
5
  ## Hard rules
6
6
 
@@ -8,7 +8,7 @@ You are connected to Cito esports data (read-only). Prefer curated outcome tools
8
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
9
  3. **One screen, one composite.** Prefer a page/summary tool over stitching 4–6 thin GETs.
10
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.
11
+ 5. **Reads are safe to retry** except where error.retryable is false for bad args / entitlement. **Writes** exist only for webhooks: create_webhook, update_webhook, delete_webhook, regenerate_webhook_secret, test_webhook, replay_webhook, catch_up_webhook and the inbox tools. Never call delete_webhook unless the user clearly asked to delete that webhook (it needs confirm: true). Do not retry create_webhook blindly: call list_webhooks first. Never repeat a webhook secret in chat: have the user store it.
12
12
 
13
13
  ## Game parameter
14
14
 
@@ -21,7 +21,8 @@ game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | t
21
21
  - RAF (Real American Freestyle wrestling) and BKFC (Bare Knuckle Fighting Championship) are NOT on the game enum: use the combat_* tools with league raf|bkfc (combat_events, combat_card, combat_bout, combat_athlete, combat_h2h, combat_rankings), or call_api /raf/... and /bkfc/.... RAF scores per PERIOD (wrestling points, takedowns); BKFC per ROUND (punches landed/thrown, knockdowns). Never mix either into UFC answers.
22
22
  - MMA archives: Bellator, PRIDE, Strikeforce, WEC, DREAM, EliteXC, Affliction, plus /mma for a fighter's whole non-UFC career (recordByPromotion). combat_* tools with league bellator|pride|strikeforce|wec|dream|elitexc|affliction|mma, or call_api /{league}/.... Closed archives (1997-2017) with official per-ROUND stats in UFC's shape ("13 of 18", round 0 = whole fight); no live, rankings or upcoming cards (combat_card "last" = most recent card). Fighters share their UFC slug (ufcFighterSlug). Never mix these into UFC answers. Their stats predate the Free/Starter window: HISTORY_WINDOW_EXCEEDED means Pro, not a bad key.
23
23
  - PFL (Professional Fighters League): combat_* with league pfl, or call_api /pfl/.... Every card since 2018, upcoming cards (combat_events when upcoming, combat_card "next") and live fight status (/pfl/live: bout status "live" with live.round / live.clock). Results only (winner, method, round, time): no strike or round stats exist for PFL. league mma includes PFL. Never mix into UFC answers.
24
- - OKTAGON MMA: combat_* with league oktagon, or call_api /oktagon/.... Every card since Oktagon 1 (2016), upcoming cards and live fight status. Per-ROUND stats on most bouts, LANDED ONLY: significant/total strikes, head/body/leg and takedowns as plain counts (landed.*), plus submission attempts; attempts, accuracy, knockdowns and control time are null because the source does not log them (each bout's statsCoverage says which fields exist). Fighters share their UFC slug when proven. league mma includes OKTAGON. Never mix into UFC answers.
24
+ - OKTAGON MMA: combat_* with league oktagon, or call_api /oktagon/.... Every card since Oktagon 1 (2016), upcoming cards and live fight status. Per-ROUND stats on most bouts, LANDED ONLY: significant/total strikes, head/body/leg and takedowns as plain counts (landed.*), plus submission attempts; attempts, accuracy, knockdowns and control time are null because these aren't recorded for this promotion (each bout's statsCoverage says which fields exist). Fighters share their UFC slug when proven. league mma includes OKTAGON. Never mix into UFC answers.
25
+ - Tennis (ATP, WTA, Challenger, ITF, junior slams): career_summary and surface_breakdown count singles only (career_summary.doubles and .combined hold the other scopes). current_rank is null when a player is not on the tour's latest list, which is normal for former players: read last_rank and last_rank_date. status is active, inactive or retired (status_basis says why); is_active is true exactly when status is active. sets[].tiebreak.winner_points is the match winner's points; tiebreak_points.winner is the tiebreak winner's. Rankings and draws are singles only. /tennis/matches and /tennis/players/{id}/matches return singles by default: pass match_type=doubles for doubles or match_type=all for both. Point-by-point exists from May 2026 (WTA) and June 2026 (ATP) on tour-level matches, and from 2026 on Challenger, WTA 125 and ITF; nothing older. Tennis odds are not offered.
25
26
  - UFC includes DWCS and Road to UFC (2022-2026, all 19 cards with official round stats; slugs like road-to-ufc-2-7, "Road to UFC 2.7").
26
27
 
27
28
  ## Preferred tool order
@@ -50,7 +51,7 @@ Lists use pagination: { limit, offset?, total?, hasMore, nextCursor, prevCursor
50
51
 
51
52
  ## Errors
52
53
 
53
- 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. RATE_LIMIT means cito-mcp already paced and retried: wait error.retryAfterMs before one more try, call tools one at a time, and never retry when retryable is false (monthly allowance used up: relay the upgrade link).
54
+ ok:false → read error.code (VALIDATION, NOT_FOUND, UNSUPPORTED_GAME, RATE_LIMIT, UNAUTHORIZED, SERVICE_ERROR, PATH_NOT_ALLOWED, NOT_IMPLEMENTED). Follow error.recover[]. Retry only when retryable. RATE_LIMIT means cito-mcp already paced and retried: wait error.retryAfterMs before one more try, call tools one at a time, and never retry when retryable is false (monthly allowance used up: relay the upgrade link).
54
55
 
55
56
  ok:true with partial[] → use successful sections; do not treat partial as total fail.
56
57
  ok:true with meta.warnings[] → filters or depth degraded; do not assume ignored filters applied.
@@ -65,6 +66,11 @@ Player card: resolve_entity {type:player|fighter} → player_profile.
65
66
  Fight night / event card: resolve_entity {type:event} → event_card {includeMatches:true} → match_preview for a featured bout.
66
67
  Match preview (named sides): resolve_entity each side (optional) → match_preview {teamA, teamB}.
67
68
  App scaffold: list_capabilities ∥ api_health ∥ live_matches, then one composite per screen.
69
+ Odds: match_details sections:["odds"] (UFC, CS2) or call_api /lol/matches/{id}/odds. CS2, LoL and UFC prediction-market odds update the moment the price changes (in play and between rounds/games); for an app that must react to every change, point the user at the sport's live WebSocket (wss://api.citoapi.com/api/v1/{cs2|lol|ufc}/live/ws, messages cs2.odds.updated / lol.odds.updated / ufc.odds.updated; odds plan + Live WebSockets), not MCP polling. Per-round / per-game prices: /cs2/matches/{id}/odds/rounds, /lol/matches/{id}/odds/games, /ufc/bouts/{id}/odds/rounds.
70
+
71
+ ## Webhooks
72
+
73
+ When a user wants to react to changes, or describes polling, recommend webhooks: explain_webhook_setup shows their plan, existing webhooks and next steps. Flow: list_webhook_events → create_webhook → store the secret → test_webhook with event → webhook_deliveries. Only recommend events whose status is "active". Webhooks need Pro or Scale; on a plan error relay error.details.upgradeUrl. Any plan can use create_webhook_inbox and send_inbox_sample to see real payloads with no server. Outage recovery: events during a pause are not queued (status skipped_backoff); catch_up_webhook re-sends the match and result events.
68
74
 
69
75
  ## Resources
70
76
 
package/dist/scrub.js CHANGED
@@ -59,17 +59,53 @@ const INTERNAL_KEYS = new Set([
59
59
  'imageCandidates',
60
60
  'image_candidates',
61
61
  'source_url',
62
+ 'source',
63
+ 'sources',
64
+ '_liveSource',
65
+ 'liveSource',
66
+ 'live_feed',
67
+ 'liveFeed',
68
+ 'gameStateSource',
69
+ 'bridge_source',
70
+ 'rank_source',
71
+ 'rankSource',
72
+ 'dataSource',
73
+ 'data_source',
74
+ 'date_source',
75
+ 'meta_source',
76
+ 'esportsMatchId',
77
+ 'esportsGameId',
78
+ 'esports_match_id',
79
+ 'riotId',
80
+ 'riot_id',
81
+ 'steamId',
82
+ 'steam_id',
83
+ 'oddsKey',
84
+ 'odds_key',
85
+ 'provider',
86
+ 'providers',
62
87
  ]);
63
- const SCRAPE_PARENTS = new Set(['dataAvailability', 'health']);
64
- const SCRAPE_KEYS = new Set(['strategy', 'samples']);
88
+ const ORIGIN_WORD_KEY = /(^|_)sources?($|_)|[a-z]Sources?($|[A-Z])|^_?sources?[A-Z]/;
89
+ const PROVIDER_PREFIX_KEY = /^_?(hltv|open_?dota|polymarket|fight_?odds|ufc_?stats)/i;
90
+ const PORTRAIT_LICENCE_KEY = /^(portrait|image|photo|picture)_?source_?url$/i;
91
+ const STATUS_PARENTS = new Set(['dataAvailability', 'health']);
92
+ const STATUS_KEYS = new Set(['strategy', 'samples']);
65
93
  const MEDIA_PARENT = /image|photo|portrait|picture/i;
66
94
  const ERROR_KEYS = new Set(['errors', 'error', 'lastError', 'bodyPreview']);
67
95
  const INTERNAL_ERROR_TEXT = /prisma|raw query failed|invocation|ECONNREFUSED|\bat \S+ \(/i;
68
96
  export const INTERNAL_ERROR_PLACEHOLDER = 'internal_error';
97
+ const NO_RESULT_REASON_KEY = /^result_?unavailable_?reason$/i;
98
+ const RAW_NO_RESULT_REASON = 'source_published_no_result';
99
+ const NO_RESULT_REASON = 'published_no_result';
69
100
  function isInternal(key, parentKey) {
70
101
  if (key === 'sourceUrl' && MEDIA_PARENT.test(parentKey))
71
102
  return false;
72
- return INTERNAL_KEYS.has(key) || (SCRAPE_KEYS.has(key) && SCRAPE_PARENTS.has(parentKey));
103
+ if (PORTRAIT_LICENCE_KEY.test(key))
104
+ return false;
105
+ return (INTERNAL_KEYS.has(key) ||
106
+ ORIGIN_WORD_KEY.test(key) ||
107
+ PROVIDER_PREFIX_KEY.test(key) ||
108
+ (STATUS_KEYS.has(key) && STATUS_PARENTS.has(parentKey)));
73
109
  }
74
110
  function scrub(node, parentKey, inError) {
75
111
  if (typeof node === 'string') {
@@ -98,7 +134,9 @@ function scrub(node, parentKey, inError) {
98
134
  delete out[key];
99
135
  continue;
100
136
  }
101
- const next = scrub(value, key, inError || ERROR_KEYS.has(key));
137
+ const next = value === RAW_NO_RESULT_REASON && NO_RESULT_REASON_KEY.test(key)
138
+ ? NO_RESULT_REASON
139
+ : scrub(value, key, inError || ERROR_KEYS.has(key));
102
140
  if (next !== value) {
103
141
  out ??= { ...src };
104
142
  out[key] = next;
@@ -192,7 +192,7 @@ returns an empty list with data.note instead of failing.
192
192
  Prefer over: call_api /{league}/events.
193
193
  Do not use when: you already have the event slug and want its bouts (combat_card).
194
194
 
195
- Parallel-safe: yes. Upstream cost: 1.
195
+ Parallel-safe: yes. API calls: 1.
196
196
  Example: { "league": "bkfc", "when": "upcoming", "limit": 5 }`,
197
197
  inputSchema: {
198
198
  type: 'object',
@@ -264,7 +264,7 @@ MMA archives are closed: event defaults to "last" (the most recent card; for mma
264
264
  Prefer over: combat_events + call_api per bout.
265
265
  Do not use when: you need one bout's period/round breakdown (combat_bout).
266
266
 
267
- Parallel-safe: yes. Upstream cost: 2–3.
267
+ Parallel-safe: yes. API calls: 2–3.
268
268
  Example: { "league": "raf", "event": "last" }`,
269
269
  inputSchema: {
270
270
  type: 'object',
@@ -364,7 +364,7 @@ note while the result still returns.
364
364
  Get boutId from combat_card or combat_athlete (e.g. "raf13--belal-muhammad--colby-covington", "bellator-5600").
365
365
  MMA archive bout ids are "<promotion>-<fightId>"; a bare fight id resolves in any MMA archive league.
366
366
 
367
- Parallel-safe: yes. Upstream cost: 1–2.
367
+ Parallel-safe: yes. API calls: 1–2.
368
368
  Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cisneros--ryan-petersen" }`,
369
369
  inputSchema: {
370
370
  type: 'object',
@@ -439,7 +439,7 @@ Keep the records apart: archive fights never belong in a UFC answer.
439
439
 
440
440
  Prefer over: call_api profile + history + stats separately.
441
441
 
442
- Parallel-safe: yes. Upstream cost: 2–4.
442
+ Parallel-safe: yes. API calls: 2–4.
443
443
  Example: { "league": "raf", "athlete": "Kyle Snyder", "historyLimit": 5 }`,
444
444
  inputSchema: {
445
445
  type: 'object',
@@ -516,7 +516,7 @@ Accepts slugs or names for both sides (names resolve through the league's search
516
516
  the ones on that promotion's cards (league "mma": every archived promotion); UFC meetings are a UFC question
517
517
  (head_to_head).
518
518
 
519
- Parallel-safe: yes. Upstream cost: 1–3.
519
+ Parallel-safe: yes. API calls: 1–3.
520
520
  Example: { "league": "bkfc", "athleteA": "ryan-petersen", "athleteB": "gregoris-cisneros" }`,
521
521
  inputSchema: {
522
522
  type: 'object',
@@ -573,7 +573,7 @@ The MMA archives (bellator, pride, strikeforce, wec, dream, elitexc, affliction,
573
573
  non-retryable NOT_IMPLEMENTED.
574
574
  Never answer with UFC rankings instead.
575
575
 
576
- Parallel-safe: yes. Upstream cost: 1.
576
+ Parallel-safe: yes. API calls: 1.
577
577
  Example: { "league": "bkfc", "division": "heavyweight" }`,
578
578
  inputSchema: {
579
579
  type: 'object',
@@ -12,6 +12,7 @@ import { cs2Tools } from './cs2.js';
12
12
  import { tournamentTools } from './tournaments.js';
13
13
  import { scheduleTools } from './schedule.js';
14
14
  import { combatTools } from './combat.js';
15
+ import { webhookTools } from './webhooks.js';
15
16
  export const allTools = [
16
17
  ...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
17
18
  ...resolveTools,
@@ -27,6 +28,7 @@ export const allTools = [
27
28
  ...insightTools,
28
29
  ...cs2Tools,
29
30
  ...combatTools,
31
+ ...webhookTools,
30
32
  ...metaTools.filter((t) => t.name === 'list_routes' || t.name === 'call_api'),
31
33
  ];
32
34
  export function getTool(name) {
@@ -26,6 +26,9 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
26
26
  hand: pickString(data.hand) ?? null,
27
27
  age: typeof data.age === 'number' ? data.age : null,
28
28
  currentRank: typeof data.current_rank === 'number' ? data.current_rank : null,
29
+ lastRank: typeof data.last_rank === 'number' ? data.last_rank : null,
30
+ lastRankDate: pickString(data.last_rank_date) ?? null,
31
+ status: pickString(data.status) ?? null,
29
32
  careerHighRank: typeof data.career_high_rank === 'number' ? data.career_high_rank : null,
30
33
  careerRecord: career.matches_won != null && career.matches_lost != null
31
34
  ? `${career.matches_won}-${career.matches_lost}`
@@ -272,7 +275,7 @@ Prefer match_summary when match is completed; match_details for live in-game.
272
275
 
273
276
  Do not use when: user wants final score/recap of a finished match.
274
277
 
275
- Parallel-safe: yes. Upstream cost: 4–8.
278
+ Parallel-safe: yes. API calls: 4–8.
276
279
  Tennis H2H accepts an optional surface filter (Hard, Clay, Grass).
277
280
  Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "recentLimit": 5 }`,
278
281
  inputSchema: {
@@ -520,7 +523,7 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
520
523
  }
521
524
  catch (e) {
522
525
  partial.push(partialFromRejection('h2h', {
523
- code: 'UPSTREAM',
526
+ code: 'SERVICE_ERROR',
524
527
  message: e.message,
525
528
  }));
526
529
  }
@@ -718,7 +721,7 @@ Bouts come back in card order — main event first, then prelims, then early pre
718
721
  Each bout carries weightClass, titleBout, card placement, and (once fought) result
719
722
  { method, round, time, referee, winnerSlug }. Each corner carries images
720
723
  { headshotUrl, bodyImageUrl, imageUrl, proxiedImageUrl }, record, nickname, rank,
721
- championStatus, country and flag when upstream supplies them. Use proxiedImageUrl in
724
+ championStatus, country and flag when available. Use proxiedImageUrl in
722
725
  browsers — the image host sends no CORS header. You do not need call_api per fighter for faces.
723
726
 
724
727
  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.
@@ -730,14 +733,14 @@ Do not use when: you only need live scores (live_matches); single finished match
730
733
  Tennis: pass a tournament id/slug to get the draw bracket — every main round from
731
734
  the first to the Final (status completed / in_progress / not_started), then
732
735
  qualifying (Q1..Q3). Each result carries winnerId/loserId/outcome (retirements
733
- included), startTime (null with startTimeUnavailableReason when the source has no
736
+ included), startTime (null with startTimeUnavailableReason when no start time is
734
737
  clock), nextMatchId (the bracket edge) and bracketPosition; scheduled and live
735
738
  matches sit in rounds[].pending. draw carries drawSize (real field size, byes
736
739
  excluded, e.g. 56), byes, matchesExpected (main draw) and complete (true only
737
740
  when every main round to the Final is played). This is the primary tennis path
738
741
  here; there is no separate draw tool.
739
742
 
740
- Parallel-safe: yes. Upstream cost: 1–4.
743
+ Parallel-safe: yes. API calls: 1–4.
741
744
  Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "includeStandings": false }`,
742
745
  inputSchema: {
743
746
  type: 'object',
@@ -751,7 +754,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
751
754
  includeMatches: boolSchema('Include the bout/match list, or the tennis draw bracket (default true).', true),
752
755
  includeStandings: boolSchema('Include standings/rankings snippet when API supports event/tournament/division scope (default false). ' +
753
756
  'For UFC this also joins divisional rank and movement onto each bout corner as team.rank / team.rankMovement — ' +
754
- 'bout rows themselves carry no rank. Costs one extra upstream call.', false),
757
+ 'bout rows themselves carry no rank. Costs one extra API call.', false),
755
758
  limit: limitSchema({
756
759
  default: 20,
757
760
  max: 50,
@@ -1290,7 +1293,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1290
1293
  ? { startTimeUnavailableReason: pickString(mr.start_time_unavailable_reason) }
1291
1294
  : {}),
1292
1295
  nextMatchId: pickString(mr.next_match_id) ?? null,
1293
- ...(pickString(mr.next_match_source) ? { nextMatchSource: pickString(mr.next_match_source) } : {}),
1296
+ ...(pickString(mr.next_match_source) ? { nextMatchBasis: pickString(mr.next_match_source)?.replace(/^source_/, '') } : {}),
1294
1297
  bracketPosition: num(mr.bracket_position),
1295
1298
  nextSlot: asRecord(mr.next_slot)
1296
1299
  ? {
@@ -1298,7 +1301,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1298
1301
  position: num(asRecord(mr.next_slot)?.position),
1299
1302
  }
1300
1303
  : null,
1301
- ...(pickString(mr.source_round) ? { sourceRound: pickString(mr.source_round) } : {}),
1304
+ ...(pickString(mr.source_round) ? { rawRound: pickString(mr.source_round) } : {}),
1302
1305
  }, 'tennis');
1303
1306
  };
1304
1307
  const pendingMatch = (pr) => {
@@ -1315,7 +1318,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1315
1318
  startTimeKnown: pr.start_time_known === true,
1316
1319
  liveScore: pickString(pr.live_score) ?? null,
1317
1320
  winnerId: null,
1318
- ...(pickString(pr.source_winner_id) ? { sourceWinnerId: pickString(pr.source_winner_id) } : {}),
1321
+ ...(pickString(pr.source_winner_id) ? { reportedWinnerId: pickString(pr.source_winner_id) } : {}),
1319
1322
  nextMatchId: pickString(pr.next_match_id) ?? null,
1320
1323
  bracketPosition: num(pr.bracket_position),
1321
1324
  };
@@ -1397,7 +1400,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1397
1400
  }
1398
1401
  drawSummary = {
1399
1402
  drawSize,
1400
- drawSizeSource: pickString(dd.draw_size_source) ?? null,
1401
1403
  drawSizeVerified: typeof dd.draw_size_verified === 'boolean' ? dd.draw_size_verified : null,
1402
1404
  drawSizeDerived: num(dd.draw_size_derived),
1403
1405
  bracketSize: num(dd.bracket_size),
@@ -53,7 +53,7 @@ Do not use when: ranking position → standings with game tennis; week-over-week
53
53
 
54
54
  Tennis-only. Season defaults to 2026; tour (ATP|WTA) is optional — omit it for the combined board. Rows carry aces + matches played, ordered most aces first.
55
55
 
56
- Parallel-safe: yes. Upstream cost: 1.`,
56
+ Parallel-safe: yes. API calls: 1.`,
57
57
  inputSchema: {
58
58
  type: 'object',
59
59
  additionalProperties: false,
@@ -205,7 +205,7 @@ Do not use when: ranking position → standings with game tennis; week-over-week
205
205
 
206
206
  Tennis-only. Season defaults to 2026; tour (ATP|WTA) is optional — omit it for the combined board. ${spec.minAttemptsNote}
207
207
 
208
- Parallel-safe: yes. Upstream cost: 1.`,
208
+ Parallel-safe: yes. API calls: 1.`,
209
209
  inputSchema: {
210
210
  type: 'object',
211
211
  additionalProperties: false,
@@ -110,9 +110,9 @@ export function humanUfcEmptyNote(emptyReason, health) {
110
110
  supervisor_warming: 'Card open / pre-gate; no bout live yet',
111
111
  between_bouts: 'Between bouts; next may be armed',
112
112
  armed_only: 'Next bout armed; not started',
113
- worker_stale: 'Live feed is behind; scores may lag',
114
- worker_down_inferred: 'Live feed unavailable right now',
115
- event_degraded: 'Sources empty clock / event degraded; not inventing stats',
113
+ worker_stale: 'Live data is behind; scores may lag',
114
+ worker_down_inferred: 'Live data unavailable right now',
115
+ event_degraded: 'Live clock unavailable; not inventing stats',
116
116
  };
117
117
  const msg = human[reason] || `No live|watching bouts (${reason})`;
118
118
  return `UFC: ${msg} (${workerPart}${lagPart}; emptyReason=${reason})`;
@@ -165,9 +165,10 @@ Prefer over: sequential per-game call_api live probes.
165
165
  Do not use when: user wants upcoming fixtures → upcoming_schedule; historical results → match_summary.
166
166
 
167
167
  CS2 live path is /cs2/live; UFC is included in multi-game fan-out.
168
+ Tennis: ITF and junior rows have no serve statistics (stats_unavailable_code), so a missing stat there is expected. For push updates point the user at wss://api.citoapi.com/api/v1/tennis/live/ws.
168
169
  UFC empty board: section.note + emptyReason + health (workerAlive/lag) + optional supervisor/nextCard (non-live); never fake match items from events[].
169
170
 
170
- Parallel-safe: yes. Upstream cost: 1–5 (allSettled).
171
+ Parallel-safe: yes. API calls: 1–5 (allSettled).
171
172
  Example: { "game": "all", "limitPerGame": 10 }`,
172
173
  inputSchema: {
173
174
  type: 'object',
@@ -359,12 +360,12 @@ Do not use when: only in-progress matches needed → live_matches.
359
360
 
360
361
  Filter support (unsupported params are ignored with meta.warnings — do not assume filtering worked):
361
362
  - lol: hours, team (slug), league (slug)
362
- - cs2: team, from, to (ISO); hours not applied upstream
363
+ - cs2: team, from, to (ISO); hours is not applied
363
364
  - cod: team, tournamentId
364
365
  - dota2: limit/cursor primarily; team may be client-filtered where data allows
365
366
  - 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
 
367
- Parallel-safe: yes. Upstream cost: 1–2.
368
+ Parallel-safe: yes. API calls: 1–2.
368
369
  Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
369
370
  inputSchema: {
370
371
  type: 'object',
@@ -465,7 +466,7 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
465
466
  ...(to ? { to } : {}),
466
467
  };
467
468
  if (args.hours !== undefined)
468
- noteIgnored('hours', 'CS2 uses from/to or unfiltered upcoming; hours is not applied upstream');
469
+ noteIgnored('hours', 'CS2 uses from/to or unfiltered upcoming; hours is not applied');
469
470
  if (league)
470
471
  noteIgnored('league', 'CS2 schedule does not accept league here — filter client-side or use event tools');
471
472
  if (tournamentId)