cito-mcp 0.5.8 → 0.6.1

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/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { MAX_RATE_LIMIT_RETRIES, MAX_UPSTREAM_RETRIES, MAX_WAIT_MS, UPSTREAM_BACKOFF_MS, classify429, getClock, jitterMs, newCallBudget, pacerFor, } from './pacing.js';
1
+ import { MAX_RATE_LIMIT_RETRIES, MAX_SERVER_RETRIES, MAX_WAIT_MS, SERVER_BACKOFF_MS, classify429, getClock, jitterMs, newCallBudget, pacerFor, } from './pacing.js';
2
2
  import { PACKAGE_VERSION } from './version.js';
3
3
  export const DEFAULT_API_BASE = 'https://api.citoapi.com/api/v1';
4
4
  export const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024;
@@ -87,7 +87,7 @@ export async function fetchJson(ctx, path, opts) {
87
87
  const pacer = pacerFor(ctx.apiKey);
88
88
  const clock = getClock();
89
89
  let rateLimitRetries = 0;
90
- let upstreamRetries = 0;
90
+ let serverRetries = 0;
91
91
  for (;;) {
92
92
  const gate = await pacer.acquire(budget);
93
93
  if (!gate.ok) {
@@ -117,7 +117,7 @@ export async function fetchJson(ctx, path, opts) {
117
117
  text,
118
118
  url,
119
119
  rateLimit: event,
120
- retries: rateLimitRetries + upstreamRetries,
120
+ retries: rateLimitRetries + serverRetries,
121
121
  };
122
122
  }
123
123
  let response;
@@ -162,9 +162,9 @@ export async function fetchJson(ctx, path, opts) {
162
162
  }
163
163
  }
164
164
  else if (response.status === 502 || response.status === 503) {
165
- const wait = UPSTREAM_BACKOFF_MS + jitterMs();
166
- if (upstreamRetries < MAX_UPSTREAM_RETRIES && clock.now() + wait < budget.deadline) {
167
- upstreamRetries += 1;
165
+ const wait = SERVER_BACKOFF_MS + jitterMs();
166
+ if (serverRetries < MAX_SERVER_RETRIES && clock.now() + wait < budget.deadline) {
167
+ serverRetries += 1;
168
168
  budget.retries += 1;
169
169
  budget.waitedMs += wait;
170
170
  log(`${response.status} on ${path}; retry after ${wait} ms`);
@@ -184,8 +184,8 @@ export async function fetchJson(ctx, path, opts) {
184
184
  };
185
185
  if (limited)
186
186
  result.rateLimit = limited;
187
- if (rateLimitRetries + upstreamRetries > 0)
188
- result.retries = rateLimitRetries + upstreamRetries;
187
+ if (rateLimitRetries + serverRetries > 0)
188
+ result.retries = rateLimitRetries + serverRetries;
189
189
  return result;
190
190
  }
191
191
  }
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,15 +73,15 @@ 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)
80
80
  meta.requestId = args.requestId;
81
81
  if (args.tookMs !== undefined)
82
82
  meta.tookMs = args.tookMs;
83
- if (args.upstreamCalls !== undefined)
84
- meta.upstreamCalls = args.upstreamCalls;
83
+ if (args.apiCalls !== undefined)
84
+ meta.apiCalls = args.apiCalls;
85
85
  if (args.rateLimit)
86
86
  meta.rateLimit = args.rateLimit;
87
87
  if (args.warnings?.length)
@@ -103,18 +103,18 @@ 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)
113
113
  meta.requestId = args.requestId;
114
114
  if (args.tookMs !== undefined)
115
115
  meta.tookMs = args.tookMs;
116
- if (args.upstreamCalls !== undefined)
117
- meta.upstreamCalls = args.upstreamCalls;
116
+ if (args.apiCalls !== undefined)
117
+ meta.apiCalls = args.apiCalls;
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
  ],
package/dist/install.js CHANGED
@@ -222,21 +222,21 @@ function parseArgs(argv) {
222
222
  }
223
223
  return out;
224
224
  }
225
- const HELP = `cito-mcp install — configure the Cito MCP server for your editors
226
-
227
- Usage:
228
- npx cito-mcp install --key cito_xxx
229
- npx cito-mcp install --key cito_xxx --client cursor,claude-code
230
- npx cito-mcp install --dry-run
231
-
232
- Options:
233
- --key <key> Cito API key. Falls back to $CITO_API_KEY.
234
- --client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex, grok.
235
- Default: every client detected on this machine.
236
- --base <url> Override API base (staging / self-hosted).
237
- --dry-run, -n Show what would change; write nothing.
238
- --help, -h This message.
239
-
225
+ const HELP = `cito-mcp install — configure the Cito MCP server for your editors
226
+
227
+ Usage:
228
+ npx cito-mcp install --key cito_xxx
229
+ npx cito-mcp install --key cito_xxx --client cursor,claude-code
230
+ npx cito-mcp install --dry-run
231
+
232
+ Options:
233
+ --key <key> Cito API key. Falls back to $CITO_API_KEY.
234
+ --client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex, grok.
235
+ Default: every client detected on this machine.
236
+ --base <url> Override API base (staging / self-hosted).
237
+ --dry-run, -n Show what would change; write nothing.
238
+ --help, -h This message.
239
+
240
240
  Get a key at https://citoapi.com/dashboard`;
241
241
  export async function runInstall(argv) {
242
242
  const args = parseArgs(argv);
@@ -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/pacing.js CHANGED
@@ -3,8 +3,8 @@ export const CONSTRAINED_CONCURRENT_PER_KEY = 2;
3
3
  export const CONSTRAINED_WINDOW_MS = 60_000;
4
4
  export const MAX_WAIT_MS = 20_000;
5
5
  export const MAX_RATE_LIMIT_RETRIES = 2;
6
- export const MAX_UPSTREAM_RETRIES = 1;
7
- export const UPSTREAM_BACKOFF_MS = 750;
6
+ export const MAX_SERVER_RETRIES = 1;
7
+ export const SERVER_BACKOFF_MS = 750;
8
8
  export const CALL_BUDGET_MS = 45_000;
9
9
  const DEFAULT_RETRY_AFTER_MS = 2_000;
10
10
  const WINDOW_MS = 60_000;
package/dist/scrub.js CHANGED
@@ -1,8 +1,8 @@
1
+ import { createHash } from 'node:crypto';
1
2
  const INTERNAL_KEYS = new Set([
2
3
  'emptyCardHeal',
3
4
  'llmValidation',
4
5
  'workerHostHint',
5
- 'scrapeMeta',
6
6
  'fetchMeta',
7
7
  'cacheKey',
8
8
  'failureSteps',
@@ -12,8 +12,6 @@ const INTERNAL_KEYS = new Set([
12
12
  'sourceUrl',
13
13
  'sourceUrls',
14
14
  'sourceIds',
15
- 'wikiUrl',
16
- 'wiki_url',
17
15
  'jsonLd',
18
16
  '_mergeSource',
19
17
  '_provenance',
@@ -32,21 +30,17 @@ const INTERNAL_KEYS = new Set([
32
30
  'profileImageAttemptedAt',
33
31
  'profileImageAttempts',
34
32
  'profileImageError',
35
- 'profileImageSteamAttemptedAt',
36
33
  'profileImageWorker',
37
34
  'ratingSource',
38
35
  'requiredSourceData',
39
36
  'rawText',
40
37
  'raw_text',
41
- 'scrapedAt',
42
- 'scraped_at',
43
38
  'sourceSummary',
44
39
  'sourceNote',
45
40
  'durableStatsTables',
46
41
  'lineupSource',
47
42
  'mapMediaSource',
48
43
  'rosterSource',
49
- 'upstream',
50
44
  'liveCandidateWindowMinutes',
51
45
  'rowHash',
52
46
  'row_hash',
@@ -59,17 +53,97 @@ const INTERNAL_KEYS = new Set([
59
53
  'imageCandidates',
60
54
  'image_candidates',
61
55
  'source_url',
56
+ 'source',
57
+ 'sources',
58
+ '_liveSource',
59
+ 'liveSource',
60
+ 'live_feed',
61
+ 'liveFeed',
62
+ 'gameStateSource',
63
+ 'bridge_source',
64
+ 'rank_source',
65
+ 'rankSource',
66
+ 'dataSource',
67
+ 'data_source',
68
+ 'date_source',
69
+ 'meta_source',
62
70
  ]);
63
- const SCRAPE_PARENTS = new Set(['dataAvailability', 'health']);
64
- const SCRAPE_KEYS = new Set(['strategy', 'samples']);
71
+ const ORIGIN_WORD_KEY = /(^|_)sources?($|_)|[a-z]Sources?($|[A-Z])|^_?sources?[A-Z]/;
72
+ const DENIED_WORDS = new Set([
73
+ '007d859ffc608f3a',
74
+ '017055b00735cdaa',
75
+ '0991545f91164c02',
76
+ '0a86e4e8778e5bc4',
77
+ '211022d4dba1354e',
78
+ '2273bc83a7916501',
79
+ '343406fa95fefe9a',
80
+ '3511b94bd7795e3b',
81
+ '3fbfbeeeaddc4fe5',
82
+ '417ccb9b693bc11d',
83
+ '438338424376242e',
84
+ '53a828d1313e2f24',
85
+ '56279b500aeff149',
86
+ '63919bdfdccc4e03',
87
+ '68ec9e984bd969e2',
88
+ '735bda6297e559c5',
89
+ '7a9040b69cc466a0',
90
+ '7e25e5e9baf788fe',
91
+ '827ce7e74edb79b2',
92
+ '82d2e789811fae32',
93
+ '93b0c3823ffe6d0b',
94
+ 'a85f3c568720d7da',
95
+ 'aab85fb91587ac06',
96
+ 'aaf4b5714130cfbd',
97
+ 'ac4399e79599c5f9',
98
+ 'b71515611a06c04e',
99
+ 'b769513d718b1bdd',
100
+ 'b8e91b77223e5c17',
101
+ 'c833542aacad261c',
102
+ 'cca8e34d07dda457',
103
+ 'd656bca8c6e48174',
104
+ 'dc2bcd7bbfe8773e',
105
+ 'e08ad232764c86ab',
106
+ 'e3bd135d984a0b44',
107
+ 'e924b9eb04be6af2',
108
+ 'f2b71a5a46b415e3',
109
+ 'f578f53f4c52c9c9',
110
+ 'fb25211c8b74e564',
111
+ ]);
112
+ const DENIED_MEMO = new Map();
113
+ function wordHash(word) {
114
+ return createHash('sha256').update(`cito-mcp-key-v1:${word}`).digest('hex').slice(0, 16);
115
+ }
116
+ function hasDeniedWord(key) {
117
+ let hit = DENIED_MEMO.get(key);
118
+ if (hit === undefined) {
119
+ const words = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[\s_-]+/).filter(Boolean).map((w) => w.toLowerCase());
120
+ const tries = [...words, ...words.slice(1).map((w, i) => `${words[i]}${w}`)];
121
+ hit = tries.some((w) => DENIED_WORDS.has(wordHash(w)));
122
+ if (DENIED_MEMO.size > 20_000)
123
+ DENIED_MEMO.clear();
124
+ DENIED_MEMO.set(key, hit);
125
+ }
126
+ return hit;
127
+ }
128
+ const PORTRAIT_LICENCE_KEY = /^(portrait|image|photo|picture)_?source_?url$/i;
129
+ const STATUS_PARENTS = new Set(['dataAvailability', 'health']);
130
+ const STATUS_KEYS = new Set(['strategy', 'samples']);
65
131
  const MEDIA_PARENT = /image|photo|portrait|picture/i;
66
132
  const ERROR_KEYS = new Set(['errors', 'error', 'lastError', 'bodyPreview']);
67
133
  const INTERNAL_ERROR_TEXT = /prisma|raw query failed|invocation|ECONNREFUSED|\bat \S+ \(/i;
68
134
  export const INTERNAL_ERROR_PLACEHOLDER = 'internal_error';
135
+ const NO_RESULT_REASON_KEY = /^result_?unavailable_?reason$/i;
136
+ const RAW_NO_RESULT_REASON = 'source_published_no_result';
137
+ const NO_RESULT_REASON = 'published_no_result';
69
138
  function isInternal(key, parentKey) {
70
139
  if (key === 'sourceUrl' && MEDIA_PARENT.test(parentKey))
71
140
  return false;
72
- return INTERNAL_KEYS.has(key) || (SCRAPE_KEYS.has(key) && SCRAPE_PARENTS.has(parentKey));
141
+ if (PORTRAIT_LICENCE_KEY.test(key))
142
+ return false;
143
+ return (INTERNAL_KEYS.has(key) ||
144
+ ORIGIN_WORD_KEY.test(key) ||
145
+ hasDeniedWord(key) ||
146
+ (STATUS_KEYS.has(key) && STATUS_PARENTS.has(parentKey)));
73
147
  }
74
148
  function scrub(node, parentKey, inError) {
75
149
  if (typeof node === 'string') {
@@ -98,7 +172,9 @@ function scrub(node, parentKey, inError) {
98
172
  delete out[key];
99
173
  continue;
100
174
  }
101
- const next = scrub(value, key, inError || ERROR_KEYS.has(key));
175
+ const next = value === RAW_NO_RESULT_REASON && NO_RESULT_REASON_KEY.test(key)
176
+ ? NO_RESULT_REASON
177
+ : scrub(value, key, inError || ERROR_KEYS.has(key));
102
178
  if (next !== value) {
103
179
  out ??= { ...src };
104
180
  out[key] = next;