cito-mcp 0.2.5 → 0.2.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
@@ -12,7 +12,7 @@ Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail RES
12
12
 
13
13
  v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among thin path wrappers, invent IDs, and stitch multi-call UI screens themselves. That catalog was hard to select against and brittle across games.
14
14
 
15
- **v0.2 ships 15 hand-authored tools** that answer *jobs* instead of mirroring REST:
15
+ **v0.2 ships 16 hand-authored tools** that answer *jobs* instead of mirroring REST:
16
16
 
17
17
  | Job | Tool |
18
18
  | --- | --- |
@@ -26,6 +26,8 @@ v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among t
26
26
  | Pre-match briefing | `match_preview` |
27
27
  | Event / fight-night card | `event_card` |
28
28
  | Rivalry record | `head_to_head` |
29
+ | Fighter / team photos | `event_card` · `player_profile` |
30
+ | Which raw REST route exists? | `list_routes` |
29
31
  | Name → ID | `resolve_entity` / `search_entities` |
30
32
 
31
33
  Composites multi-fetch server-side, return a **stable JSON envelope**, and isolate partial failures in `partial[]` so one bad secondary section does not fail the whole page.
@@ -36,33 +38,39 @@ Composites multi-fetch server-side, return a **stable JSON envelope**, and isola
36
38
 
37
39
  ## Install
38
40
 
39
- ### One-liner (published package)
41
+ ### One command (recommended)
40
42
 
41
43
  ```bash
42
- npx cito-mcp
44
+ npx cito-mcp install --key cito_your_key_here
43
45
  ```
44
46
 
45
- Requires `CITO_API_KEY` in the environment (see [Environment](#environment)). Most hosts inject env via their MCP config rather than a bare shell.
47
+ Detects the MCP clients installed on your machine Claude Code, Claude Desktop, Cursor, Windsurf, Codex CLI — and writes the config for each. Then restart your editor.
46
48
 
47
- ### Claude Code
49
+ It is safe to re-run: existing config is merged, not replaced, your other MCP servers are left alone, a `.cito-bak` backup is written before the first change, and running it again just rotates the key rather than adding a second entry.
48
50
 
49
51
  ```bash
50
- claude mcp add cito -e CITO_API_KEY=cito_your_key_here -- npx cito-mcp
52
+ npx cito-mcp install --dry-run # show the plan, write nothing
53
+ npx cito-mcp install --key … --client cursor # one client only
54
+ npx cito-mcp install --help
51
55
  ```
52
56
 
53
- Windows if `npx` fails under Claude:
57
+ Get a key at [citoapi.com/dashboard](https://citoapi.com/dashboard).
58
+
59
+ ### Manual — Claude Code
54
60
 
55
61
  ```bash
56
- claude mcp add cito -e CITO_API_KEY=cito_your_key_here -- cmd /c npx cito-mcp
62
+ claude mcp add cito -e CITO_API_KEY=cito_your_key_here "--" npx -y cito-mcp
57
63
  ```
58
64
 
65
+ **Quote the `--`.** PowerShell 5.1 strips a bare `--` before the CLI sees it; because `-e` takes a variable number of values it then swallows `npx -y cito-mcp` as env vars and fails with `unknown option '-y'`. The quoted form works in PowerShell, cmd, bash and zsh alike. `npx cito-mcp install` avoids the problem entirely.
66
+
59
67
  From a local clone (development):
60
68
 
61
69
  ```bash
62
70
  cd mcp
63
71
  npm install
64
72
  npm run build
65
- claude mcp add cito -e CITO_API_KEY=cito_your_key_here -- node "%CD%\dist\index.js"
73
+ claude mcp add cito -e CITO_API_KEY=cito_your_key_here "--" node "%CD%\dist\index.js"
66
74
  ```
67
75
 
68
76
  ### Cursor
@@ -124,7 +132,7 @@ The server **exits immediately** if `CITO_API_KEY` is missing. The key is sent o
124
132
  4. Match card → `match_summary` (then `match_details` if needed)
125
133
  5. Team / player pages → `team_profile` / `player_profile`
126
134
  6. Tables / rivalry / preview / event card → `standings` / `head_to_head` / `match_preview` / `event_card`
127
- 7. Escape hatch → `call_api` (allowlisted paths only)
135
+ 7. Escape hatch → `list_routes` to find a path, then `call_api` (allowlisted paths only)
128
136
 
129
137
  **Never invent IDs** — resolve them or take them from live / schedule / search results.
130
138
 
@@ -132,7 +140,7 @@ Mnemonic: **resolve → live/schedule → summary → deep**.
132
140
 
133
141
  ---
134
142
 
135
- ## Tool catalog (15)
143
+ ## Tool catalog (16)
136
144
 
137
145
  All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (the server name already brands the surface).
138
146
 
@@ -151,9 +159,38 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
151
159
  | `head_to_head` | Composed H2H (no first-class REST H2H) | Rivalry / series record; preview context | Single-side form only; live scores; standings | `game`, `sideA`, `sideB`, `entityType?`, `limit?`, `from?`, `to?` |
152
160
  | `standings` | League/event tables or world/division rankings | Playoff picture; UFC rankings; CDL / CS2 tables | Team form; live scores; match recaps | `game`, `scope?`, `leagueId?`, `tournamentId?`, `eventId?`, `season?`, `stage?`, `division?`, `limit?` |
153
161
  | `match_preview` | Pre-match briefing: sides, rosters/form, H2H stub | Upcoming deep link; pick’ems; preview cards | Completed recaps; deep live state | `game`, `matchId` **or** (`teamA` + `teamB`), `eventId?`, `includeH2H?`, `includeRosters?`, `recentLimit?` |
154
- | `event_card` | Event / fight-night card: identity, bout/match list, optional standings | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | `game`, `eventIdOrSlug` **or** `q`, `includeBouts?`, `includeStandings?`, `limit?` |
162
+ | `event_card` | Event / fight-night card: identity + bouts in card order (main event first), each corner with photos, record, nickname, weight class; optional rankings | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | `game`, `eventIdOrSlug` **or** `q`, `includeBouts?`, `includeStandings?`, `limit?` |
163
+ | `list_routes` | Index of raw REST routes from the live OpenAPI spec (method, path, summary, tag) | Finding a long-tail path before `call_api`; checking an endpoint exists | A curated tool covers the outcome | `game?`, `q?`, `limit?` |
155
164
  | `call_api` | Allowlisted raw REST (`data.raw`) | Fortnite / long-tail paths; payload debugging | Any job covered by a curated tool | `path`, `method?`, `queryJson?`, `bodyJson?` |
156
165
 
166
+ ### Images
167
+
168
+ Fighter and team sides carry an `images` object wherever upstream supplies one —
169
+ on `event_card` bout corners and on `player_profile`. No extra call, no N+1.
170
+
171
+ ```jsonc
172
+ "team1": {
173
+ "name": "Islam Makhachev",
174
+ "slug": "islam-makhachev",
175
+ "record": { "wins": 28, "losses": 1, "draws": 0, "text": "28-1-0 (W-L-D)" },
176
+ "championStatus": "champion",
177
+ "images": {
178
+ "headshotUrl": "https://ufc.com/…/MAKHACHEV_ISLAM_BELT_01-18.png",
179
+ "bodyImageUrl": "https://ufc.com/…/athlete_bio_full_body/…",
180
+ "imageUrl": "https://ufc.com/…/event_fight_card_upper_body/…",
181
+ "proxiedImageUrl": "https://api.citoapi.com/api/v1/public/images/ufc/aHR0cHM6…"
182
+ }
183
+ }
184
+ ```
185
+
186
+ **Use `proxiedImageUrl` in a browser.** `ufc.com` sends no CORS header and can
187
+ hotlink-block, so raw URLs render as broken images in a web UI. The proxied URL
188
+ is served by the API and is safe to put in an `<img src>`.
189
+
190
+ When a key is `null` the image does not exist for that entity; when the whole
191
+ `images` object is absent, upstream sent nothing for that side. The object is
192
+ never partially shaped — if any image exists, all four keys are present.
193
+
157
194
  ### Games
158
195
 
159
196
  | Game | Depth | Notes |
@@ -429,13 +466,20 @@ Package: **`cito-mcp@0.2.4`**
429
466
  - `live_matches`
430
467
  - `resolve_entity` (e.g. T1 / s1mple)
431
468
  - `match_summary` with a real `matchId` from live/schedule
432
- 4. Confirm `package.json` version and README match the shipped tool list (15).
469
+ 4. Confirm `package.json` version and README match the shipped tool list.
433
470
  5. `npm publish` from `mcp/` (or your release pipeline) with appropriate npm auth / access.
471
+ `prepublishOnly` runs build + tests + smoke, so a broken build cannot ship.
434
472
 
435
473
  ### Install line for docs & marketing
436
474
 
437
475
  ```bash
438
- claude mcp add cito -e CITO_API_KEY=cito_… -- npx cito-mcp
476
+ npx cito-mcp install --key cito_…
477
+ ```
478
+
479
+ Manual fallback (note the quoted `--`, required for PowerShell):
480
+
481
+ ```bash
482
+ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
439
483
  ```
440
484
 
441
485
  ```json
package/dist/index.js CHANGED
@@ -19,6 +19,18 @@ import { SERVER_INSTRUCTIONS } from './instructions.js';
19
19
  import { allTools, getTool } from './tools/index.js';
20
20
  import { runTool } from './tools/types.js';
21
21
  import { PACKAGE_VERSION } from './version.js';
22
+ /**
23
+ * Subcommands run and exit before any transport exists.
24
+ *
25
+ * `install` writes editor config and prints to stdout, which would corrupt the
26
+ * JSON-RPC channel if it ran alongside the server — hence the early return.
27
+ * Handled here rather than in a second binary so the documented entry point
28
+ * stays `npx cito-mcp`.
29
+ */
30
+ if (process.argv[2] === 'install') {
31
+ const { runInstall } = await import('./install.js');
32
+ process.exit(await runInstall(process.argv.slice(3)));
33
+ }
22
34
  const API_KEY = process.env.CITO_API_KEY;
23
35
  /**
24
36
  * Tools that need no API key. These must keep working with the server
@@ -0,0 +1,340 @@
1
+ /**
2
+ * `npx cito-mcp install` — write the server config for whichever MCP clients
3
+ * are actually installed.
4
+ *
5
+ * Why this exists: the documented one-liner
6
+ *
7
+ * claude mcp add cito -e CITO_API_KEY=… -- npx -y cito-mcp
8
+ *
9
+ * is broken in PowerShell 5.1, which strips the bare `--`. Because `-e` is a
10
+ * variadic option, it then swallows `npx -y cito-mcp` as extra env values and
11
+ * the CLI dies on "unknown option '-y'". Quoting the separator fixes it, but
12
+ * expecting every Windows user to know that is not onboarding — it is a trap.
13
+ * A subcommand has no separator to mangle and works identically everywhere.
14
+ *
15
+ * Rules this follows, because it edits files the user did not open:
16
+ * - only touch clients that are already installed
17
+ * - back up before the first write of a run
18
+ * - merge into existing config; never rewrite a file wholesale
19
+ * - idempotent: re-running replaces our entry and nothing else
20
+ * - --dry-run prints the plan and writes nothing
21
+ * - never print the full API key
22
+ */
23
+ import { execFileSync } from 'node:child_process';
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
25
+ import { homedir, platform } from 'node:os';
26
+ import { dirname, join } from 'node:path';
27
+ import { PACKAGE_VERSION } from './version.js';
28
+ const SERVER_NAME = 'cito';
29
+ function envHome(name, fallback) {
30
+ const v = process.env[name];
31
+ return v && v.trim() ? v : fallback;
32
+ }
33
+ function clientsFor() {
34
+ const home = homedir();
35
+ const os = platform();
36
+ const appData = envHome('APPDATA', join(home, 'AppData', 'Roaming'));
37
+ const desktopConfig = os === 'win32'
38
+ ? join(appData, 'Claude', 'claude_desktop_config.json')
39
+ : os === 'darwin'
40
+ ? join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
41
+ : join(home, '.config', 'Claude', 'claude_desktop_config.json');
42
+ return [
43
+ {
44
+ id: 'claude-code',
45
+ label: 'Claude Code',
46
+ configPath: join(home, '.claude.json'),
47
+ detectPath: join(home, '.claude.json'),
48
+ format: 'json-mcpServers',
49
+ },
50
+ {
51
+ id: 'claude-desktop',
52
+ label: 'Claude Desktop',
53
+ configPath: desktopConfig,
54
+ detectPath: dirname(desktopConfig),
55
+ format: 'json-mcpServers',
56
+ },
57
+ {
58
+ id: 'cursor',
59
+ label: 'Cursor',
60
+ configPath: join(home, '.cursor', 'mcp.json'),
61
+ detectPath: join(home, '.cursor'),
62
+ format: 'json-mcpServers',
63
+ },
64
+ {
65
+ id: 'windsurf',
66
+ label: 'Windsurf',
67
+ configPath: join(home, '.codeium', 'windsurf', 'mcp_config.json'),
68
+ detectPath: join(home, '.codeium', 'windsurf'),
69
+ format: 'json-mcpServers',
70
+ },
71
+ {
72
+ id: 'codex',
73
+ label: 'Codex CLI',
74
+ configPath: join(home, '.codex', 'config.toml'),
75
+ detectPath: join(home, '.codex'),
76
+ format: 'toml-mcp_servers',
77
+ },
78
+ ];
79
+ }
80
+ export function maskKey(key) {
81
+ if (key.length <= 12)
82
+ return '****';
83
+ return `${key.slice(0, 9)}…${key.slice(-4)}`;
84
+ }
85
+ /** The stdio entry every JSON-based client understands. */
86
+ function serverEntry(key, base) {
87
+ return {
88
+ type: 'stdio',
89
+ command: 'npx',
90
+ args: ['-y', 'cito-mcp'],
91
+ env: { CITO_API_KEY: key, ...(base ? { CITO_API_BASE: base } : {}) },
92
+ };
93
+ }
94
+ function readJson(path) {
95
+ if (!existsSync(path))
96
+ return null;
97
+ try {
98
+ const raw = readFileSync(path, 'utf8').trim();
99
+ if (!raw)
100
+ return {};
101
+ const parsed = JSON.parse(raw);
102
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * Escape a TOML basic string. Only the characters TOML actually requires —
110
+ * an API key with a quote or backslash would otherwise produce a file the
111
+ * client cannot parse.
112
+ */
113
+ function tomlString(v) {
114
+ return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
115
+ }
116
+ function tomlBlock(key, base) {
117
+ const envLines = [`CITO_API_KEY = ${tomlString(key)}`]
118
+ .concat(base ? [`CITO_API_BASE = ${tomlString(base)}`] : [])
119
+ .join('\n');
120
+ return [
121
+ `[mcp_servers.${SERVER_NAME}]`,
122
+ `command = "npx"`,
123
+ `args = ["-y", "cito-mcp"]`,
124
+ ``,
125
+ `[mcp_servers.${SERVER_NAME}.env]`,
126
+ envLines,
127
+ ``,
128
+ ].join('\n');
129
+ }
130
+ /**
131
+ * Replace an existing [mcp_servers.cito] block (and its .env subtable) or
132
+ * append a new one. Deliberately conservative: it only recognises the exact
133
+ * headers it writes, so a hand-edited file is appended to rather than mangled.
134
+ */
135
+ export function upsertTomlBlock(existing, key, base) {
136
+ const block = tomlBlock(key, base);
137
+ const header = new RegExp(`^\\[mcp_servers\\.${SERVER_NAME}(\\.[A-Za-z_]+)?\\]\\s*$`);
138
+ const anyHeader = /^\[[^\]]+\]\s*$/;
139
+ const lines = existing.split(/\r?\n/);
140
+ const kept = [];
141
+ let skipping = false;
142
+ for (const line of lines) {
143
+ if (header.test(line)) {
144
+ skipping = true;
145
+ continue;
146
+ }
147
+ if (skipping && anyHeader.test(line))
148
+ skipping = false;
149
+ if (!skipping)
150
+ kept.push(line);
151
+ }
152
+ const body = kept.join('\n').replace(/\n{3,}$/, '\n\n').replace(/\s*$/, '');
153
+ return (body ? `${body}\n\n` : '') + block;
154
+ }
155
+ /**
156
+ * Claude Code owns the schema of ~/.claude.json and stores far more than MCP
157
+ * config in it, so let its own CLI do the write when it is on PATH. Direct
158
+ * editing is the fallback, not the default.
159
+ */
160
+ function tryClaudeCli(key, base, dryRun) {
161
+ const json = JSON.stringify(serverEntry(key, base));
162
+ // On Windows `claude` is a .cmd shim, which execFileSync cannot spawn
163
+ // directly — it needs a shell. Quoting matters there because the JSON
164
+ // payload contains spaces and double quotes.
165
+ const isWin = platform() === 'win32';
166
+ const run = (args) => {
167
+ if (!isWin) {
168
+ execFileSync('claude', args, { stdio: 'ignore' });
169
+ return;
170
+ }
171
+ execFileSync('cmd', ['/c', 'claude', ...args], { stdio: 'ignore' });
172
+ };
173
+ try {
174
+ if (dryRun) {
175
+ run(['--version']);
176
+ return 'would use `claude mcp add-json` (CLI detected)';
177
+ }
178
+ run(['mcp', 'add-json', SERVER_NAME, json, '-s', 'user']);
179
+ return 'via `claude mcp add-json` (user scope)';
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ function writeJsonClient(c, key, base, dryRun) {
186
+ const current = readJson(c.configPath);
187
+ if (current === null && existsSync(c.configPath)) {
188
+ return {
189
+ client: c,
190
+ status: 'failed',
191
+ note: `${c.configPath} is not valid JSON — left untouched`,
192
+ };
193
+ }
194
+ const config = current ?? {};
195
+ const servers = (config.mcpServers ?? {});
196
+ const existed = Object.prototype.hasOwnProperty.call(servers, SERVER_NAME);
197
+ const next = { ...config, mcpServers: { ...servers, [SERVER_NAME]: serverEntry(key, base) } };
198
+ if (dryRun) {
199
+ return {
200
+ client: c,
201
+ status: 'planned',
202
+ note: `${existed ? 'replace' : 'add'} "${SERVER_NAME}" in ${c.configPath}`,
203
+ };
204
+ }
205
+ mkdirSync(dirname(c.configPath), { recursive: true });
206
+ if (existsSync(c.configPath))
207
+ copyFileSync(c.configPath, `${c.configPath}.cito-bak`);
208
+ writeFileSync(c.configPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
209
+ return {
210
+ client: c,
211
+ status: 'written',
212
+ note: `${existed ? 'updated' : 'added'} in ${c.configPath}`,
213
+ };
214
+ }
215
+ function writeTomlClient(c, key, base, dryRun) {
216
+ const existing = existsSync(c.configPath) ? readFileSync(c.configPath, 'utf8') : '';
217
+ const existed = new RegExp(`\\[mcp_servers\\.${SERVER_NAME}\\]`).test(existing);
218
+ if (dryRun) {
219
+ return {
220
+ client: c,
221
+ status: 'planned',
222
+ note: `${existed ? 'replace' : 'add'} [mcp_servers.${SERVER_NAME}] in ${c.configPath}`,
223
+ };
224
+ }
225
+ mkdirSync(dirname(c.configPath), { recursive: true });
226
+ if (existsSync(c.configPath))
227
+ copyFileSync(c.configPath, `${c.configPath}.cito-bak`);
228
+ writeFileSync(c.configPath, upsertTomlBlock(existing, key, base), 'utf8');
229
+ return {
230
+ client: c,
231
+ status: 'written',
232
+ note: `${existed ? 'updated' : 'added'} in ${c.configPath}`,
233
+ };
234
+ }
235
+ function parseArgs(argv) {
236
+ const out = { key: undefined, base: undefined, only: [], dryRun: false, help: false };
237
+ for (let i = 0; i < argv.length; i++) {
238
+ const a = argv[i];
239
+ const eat = () => argv[++i];
240
+ if (a === '--help' || a === '-h')
241
+ out.help = true;
242
+ else if (a === '--dry-run' || a === '-n')
243
+ out.dryRun = true;
244
+ else if (a === '--key')
245
+ out.key = eat();
246
+ else if (a?.startsWith('--key='))
247
+ out.key = a.slice(6);
248
+ else if (a === '--base')
249
+ out.base = eat();
250
+ else if (a?.startsWith('--base='))
251
+ out.base = a.slice(7);
252
+ else if (a === '--client')
253
+ out.only.push(...(eat() ?? '').split(',').filter(Boolean));
254
+ else if (a?.startsWith('--client='))
255
+ out.only.push(...a.slice(9).split(',').filter(Boolean));
256
+ }
257
+ return out;
258
+ }
259
+ const HELP = `cito-mcp install — configure the Cito MCP server for your editors
260
+
261
+ Usage:
262
+ npx cito-mcp install --key cito_xxx
263
+ npx cito-mcp install --key cito_xxx --client cursor,claude-code
264
+ npx cito-mcp install --dry-run
265
+
266
+ Options:
267
+ --key <key> Cito API key. Falls back to $CITO_API_KEY.
268
+ --client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex.
269
+ Default: every client detected on this machine.
270
+ --base <url> Override API base (staging / self-hosted).
271
+ --dry-run, -n Show what would change; write nothing.
272
+ --help, -h This message.
273
+
274
+ Get a key at https://citoapi.com/dashboard`;
275
+ export async function runInstall(argv) {
276
+ const args = parseArgs(argv);
277
+ if (args.help) {
278
+ console.log(HELP);
279
+ return 0;
280
+ }
281
+ const key = args.key ?? process.env.CITO_API_KEY;
282
+ if (!key && !args.dryRun) {
283
+ console.error('No API key. Pass --key cito_xxx or set CITO_API_KEY.\n');
284
+ console.error('Get one at https://citoapi.com/dashboard');
285
+ return 1;
286
+ }
287
+ const all = clientsFor();
288
+ const unknown = args.only.filter((id) => !all.some((c) => c.id === id));
289
+ if (unknown.length) {
290
+ console.error(`Unknown client(s): ${unknown.join(', ')}`);
291
+ console.error(`Valid: ${all.map((c) => c.id).join(', ')}`);
292
+ return 1;
293
+ }
294
+ // An explicit --client is a instruction, not a guess: honour it even if we
295
+ // cannot detect the client (fresh install, portable install, unusual path).
296
+ const selected = args.only.length
297
+ ? all.filter((c) => args.only.includes(c.id))
298
+ : all.filter((c) => existsSync(c.detectPath));
299
+ if (selected.length === 0) {
300
+ console.error('No supported MCP clients detected.');
301
+ console.error(`Looked for: ${all.map((c) => c.label).join(', ')}`);
302
+ console.error('Use --client <id> to configure one anyway.');
303
+ return 1;
304
+ }
305
+ console.log(`cito-mcp ${PACKAGE_VERSION} installer`);
306
+ console.log(key ? `key: ${maskKey(key)}` : 'key: (none — dry run)');
307
+ console.log(args.dryRun ? 'mode: dry run, nothing will be written\n' : '');
308
+ const effectiveKey = key ?? 'CITO_API_KEY';
309
+ const results = [];
310
+ for (const c of selected) {
311
+ if (c.id === 'claude-code') {
312
+ const viaCli = tryClaudeCli(effectiveKey, args.base, args.dryRun);
313
+ if (viaCli) {
314
+ results.push({ client: c, status: args.dryRun ? 'planned' : 'written', note: viaCli });
315
+ continue;
316
+ }
317
+ }
318
+ results.push(c.format === 'toml-mcp_servers'
319
+ ? writeTomlClient(c, effectiveKey, args.base, args.dryRun)
320
+ : writeJsonClient(c, effectiveKey, args.base, args.dryRun));
321
+ }
322
+ for (const r of results) {
323
+ const mark = r.status === 'failed' ? '✗' : r.status === 'skipped' ? '–' : '✓';
324
+ console.log(` ${mark} ${r.client.label.padEnd(16)} ${r.note}`);
325
+ }
326
+ const failed = results.filter((r) => r.status === 'failed');
327
+ console.log('');
328
+ if (args.dryRun) {
329
+ console.log('Dry run complete. Re-run without --dry-run to apply.');
330
+ }
331
+ else {
332
+ console.log('Restart your editor to load the server, then ask it:');
333
+ console.log(' "what UFC events are coming up?"');
334
+ }
335
+ if (failed.length) {
336
+ console.log(`\n${failed.length} client(s) could not be configured (see above).`);
337
+ return 1;
338
+ }
339
+ return 0;
340
+ }
@@ -6,7 +6,7 @@ import { playerTools } from './player.js';
6
6
  import { teamTools } from './team.js';
7
7
  import { standingsTools } from './standings.js';
8
8
  import { insightTools } from './insight.js';
9
- /** Curated outcome-tool catalog (15 tools). Order matches preferred cold-start ladder. */
9
+ /** Curated outcome-tool catalog (16 tools). Order matches preferred cold-start ladder. */
10
10
  export const allTools = [
11
11
  ...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
12
12
  ...resolveTools,
@@ -16,7 +16,8 @@ export const allTools = [
16
16
  ...teamTools,
17
17
  ...standingsTools,
18
18
  ...insightTools,
19
- ...metaTools.filter((t) => t.name === 'call_api'),
19
+ // Escape-hatch pair last: discover routes, then call one.
20
+ ...metaTools.filter((t) => t.name === 'list_routes' || t.name === 'call_api'),
20
21
  ];
21
22
  export function getTool(name) {
22
23
  return allTools.find((t) => t.name === name);
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
- import { normalizeMatch } from './normalize.js';
6
+ import { normalizeMatch, sortByCardOrder } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
8
8
  async function loadSide(ctx, game, side, recentLimit, includeRosters) {
9
9
  const partial = [];
@@ -573,7 +573,14 @@ When to use:
573
573
  - CS2 event hub with match list
574
574
  - Tournament/event overview before match_preview drill-down
575
575
 
576
- Prefer over: agent-side resolve + call_api /ufc/events + bout expansion; N+1 match_summary for the card list only.
576
+ Bouts come back in card order main event first, then prelims, then early prelims.
577
+ Each bout carries weightClass, titleBout, card placement, and (once fought) result
578
+ { method, round, time, referee, winnerSlug }. Each corner carries images
579
+ { headshotUrl, bodyImageUrl, imageUrl, proxiedImageUrl }, record, nickname, rank,
580
+ championStatus, country and flag when upstream supplies them. Use proxiedImageUrl in
581
+ browsers — ufc.com sends no CORS header. You do not need call_api per fighter for faces.
582
+
583
+ 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.
577
584
 
578
585
  Prefer match_preview for one bout/match briefing; match_summary for completed recaps; live_matches for live-only strips; standings alone for pure tables.
579
586
 
@@ -585,12 +592,17 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
585
592
  type: 'object',
586
593
  additionalProperties: false,
587
594
  required: ['game'],
595
+ // The handler needs an event to look up, so `game` alone always fails.
596
+ // Say so in the schema rather than letting a client believe game suffices.
597
+ anyOf: [{ required: ['eventIdOrSlug'] }, { required: ['q'] }],
588
598
  properties: {
589
599
  game: gameSchema({ allowAll: false, required: true }),
590
- eventIdOrSlug: stringSchema('Event or tournament id/slug. Prefer over q when known. Example: "ufc-300".', 'ufc-300'),
600
+ eventIdOrSlug: stringSchema('Event or tournament id/slug. Prefer over q when known. Required unless q is given. Example: "ufc-300".', 'ufc-300'),
591
601
  q: stringSchema('Free-text event name when id/slug unknown. Example: "UFC 300". Resolves within this tool — still prefer resolve_entity when disambiguating many hits.', 'UFC 300'),
592
602
  includeBouts: boolSchema('Include bout/match list (default true).', true),
593
- includeStandings: boolSchema('Include standings/rankings snippet when API supports event/tournament/division scope (default false).', false),
603
+ includeStandings: boolSchema('Include standings/rankings snippet when API supports event/tournament/division scope (default false). ' +
604
+ 'For UFC this also joins divisional rank and movement onto each bout corner as team.rank / team.rankMovement — ' +
605
+ 'bout rows themselves carry no rank. Costs one extra upstream call.', false),
594
606
  limit: limitSchema({
595
607
  default: 20,
596
608
  max: 50,
@@ -684,12 +696,14 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
684
696
  if (includeBouts) {
685
697
  const embedded = extractRows(inner.bouts ?? inner.fights ?? data.bouts);
686
698
  if (embedded.length) {
687
- bouts = embedded.slice(0, limit).map((row) => normalizeMatch('ufc', {
699
+ // Order the full card before slicing — truncating upstream order
700
+ // first can drop the main event and keep an early prelim.
701
+ bouts = sortByCardOrder(embedded.map((row) => normalizeMatch('ufc', {
688
702
  ...(asRecord(row) ?? {}),
689
703
  eventName: event.name,
690
704
  eventId: event.id,
691
705
  eventSlug: event.slug,
692
- }, undefined));
706
+ }, undefined))).slice(0, limit);
693
707
  }
694
708
  }
695
709
  }
@@ -728,14 +742,12 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
728
742
  upstreamCalls += 1;
729
743
  rateLimit = { ...rateLimit, ...boutsRes.headers };
730
744
  if (boutsRes.ok) {
731
- bouts = extractRows(boutsRes.data)
732
- .slice(0, limit)
733
- .map((row) => normalizeMatch('ufc', {
745
+ bouts = sortByCardOrder(extractRows(boutsRes.data).map((row) => normalizeMatch('ufc', {
734
746
  ...(asRecord(row) ?? {}),
735
747
  eventName: event.name,
736
748
  eventId: event.id,
737
749
  eventSlug: event.slug,
738
- }, undefined));
750
+ }, undefined))).slice(0, limit);
739
751
  }
740
752
  else {
741
753
  partial.push(partialFromRejection('bouts', {
@@ -750,10 +762,69 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
750
762
  upstreamCalls += 1;
751
763
  rateLimit = { ...rateLimit, ...ranks.headers };
752
764
  if (ranks.ok) {
765
+ // Bout rows carry rankText: null, so a card built from them alone is
766
+ // rank-blind. The rankings payload is one row per fighter keyed by
767
+ // slug, so join it onto the corners we already have instead of
768
+ // handing back a raw blob the caller has to re-index itself.
769
+ const rankRows = extractRows(ranks.data)
770
+ .map((row) => asRecord(row))
771
+ .filter((r) => Boolean(r));
772
+ const bySlug = new Map();
773
+ for (const r of rankRows) {
774
+ const slug = pickString(r.fighterSlug, asRecord(r.fighter)?.slug);
775
+ if (slug)
776
+ bySlug.set(slug.toLowerCase(), r);
777
+ }
778
+ const withRank = (side) => {
779
+ if (!side?.slug)
780
+ return side;
781
+ const hit = bySlug.get(side.slug.toLowerCase());
782
+ if (!hit)
783
+ return side;
784
+ const rankText = pickString(hit.rankText);
785
+ const movement = pickString(asRecord(hit.movement)?.label, hit.rankChangeText);
786
+ return {
787
+ ...side,
788
+ ...(side.rank == null && rankText ? { rank: rankText } : {}),
789
+ ...(movement ? { rankMovement: movement } : {}),
790
+ };
791
+ };
792
+ bouts = bouts.map((b) => ({ ...b, team1: withRank(b.team1), team2: withRank(b.team2) }));
793
+ // Scope the table to divisions actually on this card. The full ladder
794
+ // is 176 rows (~42KB) and doubles the response; a card with no
795
+ // bantamweight bout has no use for the bantamweight ladder, and that
796
+ // context is not free for the agent reading it.
797
+ const cardDivisions = new Set(bouts
798
+ .flatMap((b) => [b.weightClass, b.team1?.division, b.team2?.division])
799
+ .filter((d) => Boolean(d))
800
+ // "Welterweight Title" must still match the Welterweight ladder.
801
+ .map((d) => d.toLowerCase().replace(/\s+title$/, '').trim()));
802
+ const inScope = (r) => {
803
+ if (cardDivisions.size === 0)
804
+ return true;
805
+ const d = pickString(r.division, r.normalizedDivision)?.toLowerCase().trim();
806
+ return d ? cardDivisions.has(d) : false;
807
+ };
808
+ const scoped = rankRows.filter(inScope);
809
+ const shown = scoped.length > 0 ? scoped : rankRows;
810
+ const shownDivisions = [...new Set(shown.map((r) => pickString(r.division)).filter(Boolean))];
753
811
  standingsSnippet = {
754
812
  scope: 'division',
755
- note: 'UFC global rankings (not event-specific bracket)',
756
- raw: ranks.data,
813
+ note: 'UFC global rankings (not an event bracket), joined onto bout corners as team.rank / team.rankMovement. ' +
814
+ (scoped.length > 0 && scoped.length < rankRows.length
815
+ ? // Count the ladders actually returned, not the card's weight
816
+ // classes — catchweight bouts have no ranking ladder.
817
+ `Rows filtered to the ${shownDivisions.length} ranked division(s) on this card; ${rankRows.length} rows exist across all divisions.`
818
+ : 'All divisions shown.'),
819
+ divisions: shownDivisions,
820
+ rows: shown.map((r) => ({
821
+ division: pickString(r.division) ?? null,
822
+ rank: pickString(r.rankText) ?? null,
823
+ fighterSlug: pickString(r.fighterSlug) ?? null,
824
+ fighterName: pickString(r.fighterName) ?? null,
825
+ isChampion: r.isChampion === true,
826
+ movement: pickString(asRecord(r.movement)?.label) ?? null,
827
+ })),
757
828
  };
758
829
  }
759
830
  else {
@@ -88,10 +88,10 @@ const TOOL_CATALOG = [
88
88
  },
89
89
  {
90
90
  name: 'player_profile',
91
- outcome: 'Player/fighter identity + recent form',
91
+ outcome: 'Player/fighter identity + recent form, including images (headshot, body, CORS-safe proxied)',
92
92
  parallelSafe: true,
93
93
  games: [...PRIMARY_GAMES],
94
- jobs: ['player_form'],
94
+ jobs: ['player_form', 'media'],
95
95
  exampleArgs: { game: 'cs2', playerId: 'cs2-player-1', recentLimit: 10 },
96
96
  preferOver: ['manual multi-call career/trends via call_api'],
97
97
  doNotUse: 'Full team roster → team_profile; unresolved name → resolve_entity',
@@ -138,14 +138,29 @@ const TOOL_CATALOG = [
138
138
  },
139
139
  {
140
140
  name: 'event_card',
141
- outcome: 'Event / fight-night card: identity + bout/match list (+ optional standings)',
141
+ outcome: 'Event / fight-night card: identity + bout list, card-ordered (main event first), ' +
142
+ 'each corner carrying photos, record, nickname, country and weight class (+ optional standings)',
142
143
  parallelSafe: true,
143
144
  games: [...PRIMARY_GAMES],
144
- jobs: ['event_card', 'schedule', 'app_scaffold'],
145
+ jobs: ['event_card', 'schedule', 'media', 'app_scaffold'],
145
146
  exampleArgs: { game: 'ufc', eventIdOrSlug: 'ufc-300', includeBouts: true },
146
- preferOver: ['call_api /ufc/events + bout expansion', 'N+1 match_summary for card list'],
147
+ preferOver: [
148
+ 'call_api /ufc/events + bout expansion',
149
+ 'N+1 match_summary for card list',
150
+ 'N+1 call_api per fighter for headshots — corners already include images',
151
+ ],
147
152
  doNotUse: 'Live-only strip → live_matches; single match recap → match_summary',
148
153
  },
154
+ {
155
+ name: 'list_routes',
156
+ outcome: 'Index of raw REST routes from the live OpenAPI spec (method, path, summary)',
157
+ parallelSafe: true,
158
+ games: [...PRIMARY_GAMES, 'fortnite'],
159
+ jobs: ['app_scaffold'],
160
+ exampleArgs: { game: 'ufc', q: 'rankings' },
161
+ preferOver: ['guessing a REST path for call_api', 'fetching the full 90KB spec by hand'],
162
+ doNotUse: 'A curated tool covers the outcome → list_capabilities',
163
+ },
149
164
  {
150
165
  name: 'call_api',
151
166
  outcome: 'Allowlisted raw REST escape hatch (unshaped data.raw)',
@@ -167,6 +182,13 @@ const JOBS = [
167
182
  { id: 'schedule', description: 'Upcoming fixtures/events', recommendedTools: ['upcoming_schedule', 'event_card'] },
168
183
  { id: 'preview', description: 'Pre-match briefing', recommendedTools: ['match_preview'] },
169
184
  { id: 'event_card', description: 'Event / fight-night card page', recommendedTools: ['event_card', 'resolve_entity', 'match_preview'] },
185
+ // Agents asked "does this API have photos?" and, finding no job for it,
186
+ // assumed no. Photos ship on every UFC corner and profile; make that findable.
187
+ {
188
+ id: 'media',
189
+ description: 'Headshots, full-body shots and team logos for visual UIs. UFC fighters carry headshotUrl / bodyImageUrl / imageUrl plus a CORS-safe proxiedImageUrl; use the proxied URL in a browser. Available on event_card corners and player_profile without any extra call.',
190
+ recommendedTools: ['event_card', 'player_profile', 'resolve_entity'],
191
+ },
170
192
  { id: 'app_scaffold', description: 'Design-time multi-screen prototype', recommendedTools: ['list_capabilities', 'api_health', 'live_matches'] },
171
193
  ];
172
194
  const RECIPES = [
@@ -450,7 +472,18 @@ Example: { "includeGameProbes": true }`,
450
472
  });
451
473
  },
452
474
  };
453
- const ALLOWLIST_PREFIXES = ['/health', '/lol', '/cs2', '/dota2', '/cod', '/ufc', '/fortnite'];
475
+ const ALLOWLIST_PREFIXES = [
476
+ '/health',
477
+ '/lol',
478
+ '/cs2',
479
+ '/dota2',
480
+ '/cod',
481
+ '/ufc',
482
+ '/fortnite',
483
+ // The spec describes the surface call_api is allowed to reach; refusing to
484
+ // serve it left route discovery impossible except by guessing.
485
+ '/openapi.json',
486
+ ];
454
487
  function pathAllowed(path) {
455
488
  if (!path.startsWith('/') || path.startsWith('//'))
456
489
  return false;
@@ -604,6 +637,147 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
604
637
  });
605
638
  },
606
639
  };
607
- export const metaTools = [listCapabilities, apiHealth, callApi];
640
+ /**
641
+ * Games whose routes the published spec does not describe.
642
+ *
643
+ * The spec has 123 paths and zero under /lol, yet every LoL route works —
644
+ * api_health itself answers partly from /lol/leagues. An agent that treats the
645
+ * spec as the whole surface concludes LoL is unsupported and stops, so say so
646
+ * explicitly rather than returning an empty list that reads as a verdict.
647
+ */
648
+ const SPEC_OMITS = {
649
+ lol: 'The published OpenAPI spec documents no /lol paths, but LoL routes exist and work. Use the curated LoL tools (live_matches, upcoming_schedule, team_profile, standings, player_profile); for raw access, /lol/* is allowlisted for call_api even though it is undocumented.',
650
+ };
651
+ export const listRoutes = {
652
+ name: 'list_routes',
653
+ description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
654
+
655
+ When to use:
656
+ - You need a long-tail path for call_api and do not want to guess
657
+ - Checking whether an endpoint exists before building around it
658
+ - Mapping what raw data backs a curated tool
659
+
660
+ Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
661
+
662
+ Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
663
+
664
+ Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
665
+
666
+ Parallel-safe: yes. Upstream cost: 1.
667
+ Example: { "game": "ufc", "q": "rankings" }`,
668
+ inputSchema: {
669
+ type: 'object',
670
+ additionalProperties: false,
671
+ properties: {
672
+ game: gameSchema({
673
+ allowAll: false,
674
+ description: 'Filter to one game prefix (also accepts fortnite). Omit for all routes.',
675
+ }),
676
+ q: stringSchema('Free-text filter over path and summary.', 'rankings'),
677
+ limit: {
678
+ type: 'integer',
679
+ minimum: 1,
680
+ maximum: 200,
681
+ default: 60,
682
+ description: 'Max routes to return (default 60, max 200).',
683
+ },
684
+ },
685
+ },
686
+ handler: async (args, ctx) => {
687
+ const started = Date.now();
688
+ const requestId = newRequestId();
689
+ // Accept fortnite here even though it is not a PRIMARY_GAME: it has 21
690
+ // documented paths and no curated tools, so it is exactly what call_api
691
+ // callers come looking for.
692
+ const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
693
+ if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite)$/)) {
694
+ return errorEnvelope({
695
+ code: 'UNSUPPORTED_GAME',
696
+ message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite`,
697
+ game: null,
698
+ source: 'list_routes',
699
+ requestId,
700
+ tookMs: Date.now() - started,
701
+ });
702
+ }
703
+ const q = typeof args.q === 'string' ? args.q.toLowerCase().trim() : '';
704
+ const limit = Math.min(Math.max(Number(args.limit) || 60, 1), 200);
705
+ const res = await fetchJson(ctx, '/openapi.json');
706
+ if (!res.ok) {
707
+ return errorEnvelope({
708
+ code: mapHttpToCode(res.status),
709
+ message: `openapi.json HTTP ${res.status}`,
710
+ game: gameRaw || null,
711
+ source: 'list_routes',
712
+ requestId,
713
+ tookMs: Date.now() - started,
714
+ upstreamCalls: 1,
715
+ rateLimit: res.headers,
716
+ httpStatus: res.status,
717
+ recover: [
718
+ 'Call list_capabilities for curated tools that need no route knowledge',
719
+ 'call_api accepts /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite',
720
+ ],
721
+ });
722
+ }
723
+ const spec = (res.data ?? {});
724
+ const paths = (spec.paths ?? {});
725
+ const rows = [];
726
+ for (const [path, ops] of Object.entries(paths)) {
727
+ if (!ops || typeof ops !== 'object')
728
+ continue;
729
+ for (const [method, opRaw] of Object.entries(ops)) {
730
+ if (!/^(get|post|put|patch|delete)$/i.test(method))
731
+ continue;
732
+ const op = (opRaw ?? {});
733
+ const tags = Array.isArray(op.tags) ? op.tags : [];
734
+ rows.push({
735
+ method: method.toUpperCase(),
736
+ path,
737
+ summary: typeof op.summary === 'string' ? op.summary : null,
738
+ tag: typeof tags[0] === 'string' ? tags[0] : null,
739
+ });
740
+ }
741
+ }
742
+ let filtered = rows;
743
+ if (gameRaw)
744
+ filtered = filtered.filter((r) => r.path.startsWith(`/${gameRaw}`));
745
+ if (q) {
746
+ filtered = filtered.filter((r) => r.path.toLowerCase().includes(q) || (r.summary ?? '').toLowerCase().includes(q));
747
+ }
748
+ filtered.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
749
+ const notes = [];
750
+ if (gameRaw && filtered.length === 0 && SPEC_OMITS[gameRaw])
751
+ notes.push(SPEC_OMITS[gameRaw]);
752
+ if (!gameRaw)
753
+ notes.push(SPEC_OMITS.lol);
754
+ if (filtered.length > limit) {
755
+ notes.push(`${filtered.length} routes matched; showing ${limit}. Narrow with game or q.`);
756
+ }
757
+ return successEnvelope({
758
+ data: {
759
+ specVersion: typeof spec.info?.version === 'string'
760
+ ? spec.info.version
761
+ : null,
762
+ totalDocumented: rows.length,
763
+ matched: filtered.length,
764
+ routes: filtered.slice(0, limit),
765
+ allowlistedPrefixes: ALLOWLIST_PREFIXES,
766
+ notes,
767
+ nextSteps: [
768
+ 'Prefer a curated tool when one covers the outcome (list_capabilities)',
769
+ 'call_api { path } for a route with no curated equivalent',
770
+ ],
771
+ },
772
+ game: gameRaw || null,
773
+ source: 'list_routes',
774
+ requestId,
775
+ tookMs: Date.now() - started,
776
+ upstreamCalls: 1,
777
+ rateLimit: res.headers,
778
+ });
779
+ },
780
+ };
781
+ export const metaTools = [listCapabilities, apiHealth, listRoutes, callApi];
608
782
  // silence unused import in case extractRows needed later
609
783
  void extractRows;
@@ -1,8 +1,34 @@
1
1
  /**
2
2
  * Cross-game row normalization for live/schedule boards and entity cards.
3
3
  */
4
- import { asRecord, pickString, unwrapPayload } from '../client.js';
5
- function sideFrom(name, id, slug, score) {
4
+ import { asRecord, pickString, unwrapPayload, DEFAULT_API_BASE } from '../client.js';
5
+ const IMAGE_PROXY_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
6
+ /**
7
+ * Bout rows carry raw ufc.com URLs but no proxied variant (only the fighter
8
+ * detail endpoint includes one). ufc.com can hotlink-block and sends no CORS
9
+ * header, so a browser-side card built straight off those URLs shows broken
10
+ * images. The proxy token is base64url of the source URL — verified to
11
+ * round-trip and serve HTTP 200 — so derive it rather than making the caller
12
+ * N+1 the fighter endpoint just to get a loadable image.
13
+ *
14
+ * UFC only: /public/images/lol/<token> returns HTTP 400, so other games get
15
+ * their raw URL and a null proxy rather than a fabricated link.
16
+ */
17
+ function deriveProxiedImageUrl(rawUrl, game) {
18
+ if (!rawUrl || game !== 'ufc')
19
+ return null;
20
+ let host;
21
+ try {
22
+ host = new URL(rawUrl).hostname.toLowerCase();
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ if (host !== 'ufc.com' && !host.endsWith('.ufc.com'))
28
+ return null;
29
+ return `${IMAGE_PROXY_BASE}/public/images/ufc/${Buffer.from(rawUrl, 'utf8').toString('base64url')}`;
30
+ }
31
+ function sideFrom(name, id, slug, score, extra) {
6
32
  if (!name && !id && !slug)
7
33
  return null;
8
34
  return {
@@ -10,9 +36,96 @@ function sideFrom(name, id, slug, score) {
10
36
  ...(id ? { id } : {}),
11
37
  ...(slug ? { slug } : {}),
12
38
  ...(score !== undefined ? { score } : {}),
39
+ ...(extra ?? {}),
40
+ };
41
+ }
42
+ function numOrNull(v) {
43
+ if (typeof v === 'number' && Number.isFinite(v))
44
+ return v;
45
+ if (typeof v === 'string' && v !== '' && Number.isFinite(Number(v)))
46
+ return Number(v);
47
+ return null;
48
+ }
49
+ /**
50
+ * Collect image URLs from a corner/team row and its nested profile. Returns
51
+ * undefined when the upstream carried none, so lean rows stay lean; when any
52
+ * image exists every key is present (null where absent) — a missing key would
53
+ * read as "this API has no images" rather than "no image for this one".
54
+ */
55
+ function imagesFrom(game, ...sources) {
56
+ const pick = (...keys) => {
57
+ for (const src of sources) {
58
+ if (!src)
59
+ continue;
60
+ for (const k of keys) {
61
+ const v = pickString(src[k]);
62
+ if (v)
63
+ return v;
64
+ }
65
+ }
66
+ return null;
67
+ };
68
+ const images = {
69
+ headshotUrl: pick('headshotUrl', 'headshot'),
70
+ bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
71
+ imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo'),
72
+ proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
73
+ };
74
+ if (!images.proxiedImageUrl) {
75
+ // Prefer the headshot for the proxied variant — it is the crop a card UI wants.
76
+ images.proxiedImageUrl =
77
+ deriveProxiedImageUrl(images.headshotUrl, game) ??
78
+ deriveProxiedImageUrl(images.imageUrl, game);
79
+ }
80
+ return Object.values(images).some(Boolean) ? images : undefined;
81
+ }
82
+ function recordFrom(...sources) {
83
+ for (const src of sources) {
84
+ if (!src)
85
+ continue;
86
+ const rec = asRecord(src.record);
87
+ const text = pickString(src.recordText, rec?.text);
88
+ if (!rec && !text)
89
+ continue;
90
+ const out = {
91
+ wins: numOrNull(rec?.wins),
92
+ losses: numOrNull(rec?.losses),
93
+ draws: numOrNull(rec?.draws),
94
+ noContest: numOrNull(rec?.noContest),
95
+ text: text ?? null,
96
+ };
97
+ if (Object.values(out).some((v) => v !== null))
98
+ return out;
99
+ }
100
+ return undefined;
101
+ }
102
+ /** Enrichment shared by UFC corners and (where upstream supplies it) team rows. */
103
+ function sideExtras(o, game) {
104
+ const profile = asRecord(o.profile) ?? undefined;
105
+ const fighter = asRecord(o.fighter) ?? undefined;
106
+ const images = imagesFrom(game, o, profile, fighter);
107
+ const record = recordFrom(o, profile, fighter);
108
+ const nickname = pickString(profile?.nickname, fighter?.nickname, o.nickname);
109
+ const rank = pickString(o.rankText, o.rank, profile?.rankText);
110
+ const championStatus = pickString(o.championStatus, profile?.championStatus);
111
+ const division = pickString(profile?.division, o.division, o.weightClass);
112
+ const country = pickString(o.country, profile?.country);
113
+ const flag = pickString(o.flag, profile?.flag);
114
+ const outcome = pickString(o.outcome);
115
+ return {
116
+ ...(nickname ? { nickname } : {}),
117
+ ...(images ? { images } : {}),
118
+ ...(record ? { record } : {}),
119
+ ...(rank ? { rank } : {}),
120
+ // "none" carries no signal for a page; only surface an actual belt state.
121
+ ...(championStatus && championStatus.toLowerCase() !== 'none' ? { championStatus } : {}),
122
+ ...(division ? { division } : {}),
123
+ ...(country ? { country } : {}),
124
+ ...(flag ? { flag } : {}),
125
+ ...(outcome ? { outcome } : {}),
13
126
  };
14
127
  }
15
- function nestedSide(raw) {
128
+ function nestedSide(raw, game) {
16
129
  const o = asRecord(raw);
17
130
  if (!o) {
18
131
  if (typeof raw === 'string')
@@ -29,7 +142,7 @@ function nestedSide(raw) {
29
142
  : typeof scoreRaw === 'string' && scoreRaw !== ''
30
143
  ? Number(scoreRaw)
31
144
  : null;
32
- return sideFrom(name, id, slug, Number.isFinite(score) ? score : null);
145
+ return sideFrom(name, id, slug, Number.isFinite(score) ? score : null, sideExtras(o, game));
33
146
  }
34
147
  function scoreNum(v) {
35
148
  if (typeof v === 'number' && Number.isFinite(v))
@@ -42,9 +155,9 @@ export function normalizeMatch(game, row, forcedStatus) {
42
155
  // Always peel { success, data } so UFC bout fighters[] / status are visible.
43
156
  const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
44
157
  const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId) ?? 'unknown';
45
- let team1 = nestedSide(r.team1) ??
158
+ let team1 = nestedSide(r.team1, game) ??
46
159
  sideFrom(pickString(r.team1Name, r.team_a_name, r.redName, r.fighter1Name, r.homeName), pickString(r.team1Id, r.team1_id, r.redId, r.fighter1Id), pickString(r.team1Slug, r.redSlug, r.fighter1Slug), scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore));
47
- let team2 = nestedSide(r.team2) ??
160
+ let team2 = nestedSide(r.team2, game) ??
48
161
  sideFrom(pickString(r.team2Name, r.team_b_name, r.blueName, r.fighter2Name, r.awayName), pickString(r.team2Id, r.team2_id, r.blueId, r.fighter2Id), pickString(r.team2Slug, r.blueSlug, r.fighter2Slug), scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore));
49
162
  // CS2 rows nest team1/team2 objects that lack a score; a nested side must not
50
163
  // shadow the flat score fields (team1Score/score1/team1Maps) with score:null.
@@ -76,7 +189,7 @@ export function normalizeMatch(game, row, forcedStatus) {
76
189
  const id = pickString(o.id, o.fighterId, profile?.id, fighter?.id);
77
190
  const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
78
191
  const score = scoreNum(o.score ?? o.points);
79
- return sideFrom(name, id, slug, score);
192
+ return sideFrom(name, id, slug, score, sideExtras(o, game));
80
193
  };
81
194
  team1 =
82
195
  team1 ??
@@ -106,8 +219,8 @@ export function normalizeMatch(game, row, forcedStatus) {
106
219
  }
107
220
  // COD sometimes uses teams[]
108
221
  if ((!team1 || !team2) && Array.isArray(r.teams)) {
109
- team1 = team1 ?? nestedSide(r.teams[0]);
110
- team2 = team2 ?? nestedSide(r.teams[1]);
222
+ team1 = team1 ?? nestedSide(r.teams[0], game);
223
+ team2 = team2 ?? nestedSide(r.teams[1], game);
111
224
  }
112
225
  const startTime = pickString(r.startTime, r.scheduledAt, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
113
226
  const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus)?.toLowerCase() ?? '';
@@ -183,6 +296,22 @@ export function normalizeMatch(game, row, forcedStatus) {
183
296
  (matchId && matchId !== 'unknown' ? `Bout ${matchId}` : null) ??
184
297
  vsLabel;
185
298
  }
299
+ // Bout metadata already present on the upstream row. Passing it through here
300
+ // is what stops an agent from N+1'ing call_api to rebuild a fight card.
301
+ const cardSection = pickString(r.cardSection, r.cardSegment, r.segment);
302
+ const cardPosition = pickString(r.cardPosition);
303
+ const cardSectionOrder = numOrNull(r.cardSectionOrder);
304
+ const boutOrder = numOrNull(r.boutOrder);
305
+ const hasCard = cardSection != null || cardPosition != null || cardSectionOrder != null || boutOrder != null;
306
+ const method = pickString(r.method);
307
+ const methodDetails = pickString(r.methodDetails);
308
+ const resultTime = pickString(r.resultTime);
309
+ // UFC sends referee as { id, name, firstName, lastName } — not a string.
310
+ const referee = pickString(r.referee, asRecord(r.referee)?.name);
311
+ const winnerSlug = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner);
312
+ const resultRound = numOrNull(r.resultRound);
313
+ const hasResultDetail = method != null || resultRound != null || winnerSlug != null || resultTime != null;
314
+ const isCancelled = r.isCancelled === true;
186
315
  return {
187
316
  game,
188
317
  matchId,
@@ -193,8 +322,55 @@ export function normalizeMatch(game, row, forcedStatus) {
193
322
  team2,
194
323
  event: eventName || eventId || eventSlug ? { id: eventId, slug: eventSlug, name: eventName } : null,
195
324
  league: leagueName || leagueId || leagueSlug ? { id: leagueId, slug: leagueSlug, name: leagueName } : null,
325
+ ...(weightOrClass ? { weightClass: weightOrClass } : {}),
326
+ ...(r.titleBout != null ? { titleBout: Boolean(r.titleBout) } : {}),
327
+ ...(hasCard
328
+ ? {
329
+ card: {
330
+ section: cardSection ?? null,
331
+ sectionOrder: cardSectionOrder,
332
+ position: cardPosition ?? null,
333
+ order: boutOrder,
334
+ },
335
+ }
336
+ : {}),
337
+ ...(hasResultDetail
338
+ ? {
339
+ result: {
340
+ method: method ?? null,
341
+ methodDetails: methodDetails ?? null,
342
+ round: resultRound,
343
+ time: resultTime ?? null,
344
+ referee: referee ?? null,
345
+ winnerSlug: winnerSlug ?? null,
346
+ },
347
+ }
348
+ : {}),
349
+ ...(isCancelled
350
+ ? { cancelled: { isCancelled: true, reason: pickString(r.cancellationReason) ?? null } }
351
+ : {}),
196
352
  };
197
353
  }
354
+ /**
355
+ * Sort a fight card the way it is presented: main card before prelims, and the
356
+ * main event at the top of its section. Rows without placement keep their
357
+ * upstream order behind those that have it.
358
+ */
359
+ export function sortByCardOrder(rows) {
360
+ const rank = (x, i) => ({
361
+ section: x.card?.sectionOrder ?? Number.MAX_SAFE_INTEGER,
362
+ order: x.card?.order ?? Number.MAX_SAFE_INTEGER,
363
+ i,
364
+ });
365
+ return rows
366
+ .map((x, i) => ({ x, k: rank(x, i) }))
367
+ .sort((a, b) => a.k.section !== b.k.section
368
+ ? a.k.section - b.k.section
369
+ : a.k.order !== b.k.order
370
+ ? a.k.order - b.k.order
371
+ : a.k.i - b.k.i)
372
+ .map((e) => e.x);
373
+ }
198
374
  export function entityRef(row, type, game) {
199
375
  const r = asRecord(row) ?? {};
200
376
  const id = pickString(r.id, r.teamId, r.playerId, r.lolPlayerId, r.codPlayerId, r.matchId, r.boutId, r.eventId, r.tournamentId, r.leagueId) ??
@@ -29,6 +29,24 @@ function identityFrom(game, raw, idHint, slugHint) {
29
29
  : null,
30
30
  role: pickString(r.role, r.position) ?? null,
31
31
  nationality: pickString(r.nationality, r.country) ?? null,
32
+ /**
33
+ * Always present, keys always present, null when the upstream has no photo.
34
+ *
35
+ * REST has carried headshot/body/proxied images on /ufc/fighters/{slug} all
36
+ * along; this shaper silently dropped them, so every agent concluded the
37
+ * product had no images and either shipped faceless UIs or N+1'd call_api
38
+ * per fighter to dig them out. Emitting explicit nulls is the point: silence
39
+ * reads as "not supported", null reads as "not available for this one".
40
+ *
41
+ * proxiedImageUrl is the one builders should prefer — it is served from our
42
+ * own domain, so it works from a browser without hotlink/CORS trouble.
43
+ */
44
+ images: {
45
+ headshotUrl: pickString(r.headshotUrl, r.headshot) ?? null,
46
+ bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
47
+ imageUrl: pickString(r.imageUrl, r.image, r.photoUrl) ?? null,
48
+ proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
49
+ },
32
50
  };
33
51
  }
34
52
  export const playerProfile = {
@@ -332,7 +350,14 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
332
350
  .map((row) => {
333
351
  const r = asRecord(row) ?? {};
334
352
  const bout = asRecord(r.bout) ?? row;
335
- const m = normalizeMatch('ufc', bout, 'completed');
353
+ // Never force 'completed'. A fighter's history endpoint also
354
+ // returns bouts that are booked but not yet fought, and forcing
355
+ // the status told agents that a future main event had already
356
+ // happened (Hernandez vs Rodrigues, ufc-12928: status
357
+ // 'confirmed', no method, no winner, event still scheduled,
358
+ // reported as completed with result null). Let normalizeMatch
359
+ // derive it from the result and the start time instead.
360
+ const m = normalizeMatch('ufc', bout);
336
361
  return {
337
362
  ...m,
338
363
  result: pickString(r.result, r.outcome, asRecord(bout)?.result) ?? null,
@@ -354,7 +379,8 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
354
379
  .map((row) => {
355
380
  const r = asRecord(row) ?? {};
356
381
  const bout = asRecord(r.bout) ?? row;
357
- return normalizeMatch('ufc', bout, 'completed');
382
+ // Same as above: derive, never assert. See the note there.
383
+ return normalizeMatch('ufc', bout);
358
384
  });
359
385
  }
360
386
  else {
@@ -64,9 +64,82 @@ export function stringSchema(description, example) {
64
64
  schema.examples = [example];
65
65
  return schema;
66
66
  }
67
+ /** Levenshtein distance, capped for short arg names. */
68
+ function editDistance(a, b) {
69
+ const m = a.length;
70
+ const n = b.length;
71
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
72
+ for (let i = 1; i <= m; i++) {
73
+ const cur = [i];
74
+ for (let j = 1; j <= n; j++) {
75
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
76
+ }
77
+ prev = cur;
78
+ }
79
+ return prev[n];
80
+ }
81
+ /** Closest known parameter to a mistyped one, if it is plausibly a typo. */
82
+ function suggestParam(unknown, known) {
83
+ const u = unknown.toLowerCase();
84
+ let best = null;
85
+ for (const k of known) {
86
+ const d = editDistance(u, k.toLowerCase());
87
+ if (!best || d < best.d)
88
+ best = { name: k, d };
89
+ }
90
+ if (!best)
91
+ return null;
92
+ // Accept near-misses, plus prefix relationships like query -> q.
93
+ const threshold = Math.max(2, Math.floor(Math.max(u.length, best.name.length) / 3));
94
+ if (best.d <= threshold)
95
+ return best.name;
96
+ const prefixHit = known.find((k) => u.startsWith(k.toLowerCase()) || k.toLowerCase().startsWith(u));
97
+ return prefixHit ?? null;
98
+ }
99
+ /**
100
+ * Args the tool does not declare, when its schema says additionalProperties:false.
101
+ *
102
+ * Every tool already declared that, but nothing enforced it: the MCP SDK does
103
+ * not validate arguments against inputSchema, so a plausible-but-wrong name was
104
+ * dropped on the floor and the tool ran with a default. search_entities
105
+ * { game:"ufc", query:"Jon Jones" } — `q` is the real parameter — returned
106
+ * ok:true with an unrelated roster. Silently wrong beats loudly wrong for a
107
+ * human skimming output, but for an agent it is far worse: it has no way to
108
+ * tell a real answer from a discarded question.
109
+ */
110
+ export function unknownArgKeys(def, args) {
111
+ const schema = def.inputSchema ?? {};
112
+ if (schema.additionalProperties !== false)
113
+ return [];
114
+ const props = schema.properties;
115
+ if (!props || typeof props !== 'object')
116
+ return [];
117
+ const known = Object.keys(props);
118
+ return Object.keys(args ?? {}).filter((k) => !known.includes(k));
119
+ }
67
120
  /** Normalize MCP handler return to MCP content result. */
68
121
  export async function runTool(def, args, ctx) {
69
122
  try {
123
+ const unknown = unknownArgKeys(def, args ?? {});
124
+ if (unknown.length > 0) {
125
+ const known = Object.keys((def.inputSchema.properties ?? {}));
126
+ const pairs = unknown.map((k) => ({ k, s: suggestParam(k, known) }));
127
+ const hints = pairs.map(({ k, s }) => s ? `"${k}" — did you mean "${s}"?` : `"${k}" is not accepted`);
128
+ const { errorEnvelope, toMcpResult: toResult } = await import('../envelope.js');
129
+ return toResult(errorEnvelope({
130
+ code: 'VALIDATION',
131
+ message: `${def.name}: unknown argument${unknown.length > 1 ? 's' : ''} ` +
132
+ // Avoid "?." when the last hint is a did-you-mean question.
133
+ `${hints.join('; ')}${hints[hints.length - 1].endsWith('?') ? '' : '.'} ` +
134
+ `Accepted: ${known.join(', ')}.`,
135
+ game: typeof args?.game === 'string' ? args.game : null,
136
+ source: def.name,
137
+ recover: [
138
+ ...pairs.map(({ k, s }) => s ? `Rename "${k}" to "${s}" and retry` : `Remove "${k}" and retry`),
139
+ `Accepted arguments: ${known.join(', ')}`,
140
+ ],
141
+ }));
142
+ }
70
143
  const result = await def.handler(args ?? {}, ctx);
71
144
  if (result && typeof result === 'object' && 'content' in result) {
72
145
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Standalone MCP server for the Cito esports API — 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,8 @@
17
17
  "build": "tsc -p tsconfig.json",
18
18
  "test": "tsx --test src/**/*.test.ts src/*.test.ts",
19
19
  "start": "node dist/index.js",
20
- "prepublishOnly": "npm run build"
20
+ "prepublishOnly": "npm run build && npm test && npm run smoke",
21
+ "smoke": "node scripts/smoke.mjs"
21
22
  },
22
23
  "dependencies": {
23
24
  "@modelcontextprotocol/sdk": "1.29.0"