cito-mcp 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
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
+ id: 'grok',
80
+ label: 'Grok CLI',
81
+ configPath: join(home, '.grok', 'config.toml'),
82
+ detectPath: join(home, '.grok'),
83
+ // Same [mcp_servers.x] / [mcp_servers.x.env] layout as Codex.
84
+ format: 'toml-mcp_servers',
85
+ },
86
+ ];
87
+ }
88
+ export function maskKey(key) {
89
+ if (key.length <= 12)
90
+ return '****';
91
+ return `${key.slice(0, 9)}…${key.slice(-4)}`;
92
+ }
93
+ /** The stdio entry every JSON-based client understands. */
94
+ function serverEntry(key, base) {
95
+ return {
96
+ type: 'stdio',
97
+ command: 'npx',
98
+ args: ['-y', 'cito-mcp'],
99
+ env: { CITO_API_KEY: key, ...(base ? { CITO_API_BASE: base } : {}) },
100
+ };
101
+ }
102
+ function readJson(path) {
103
+ if (!existsSync(path))
104
+ return null;
105
+ try {
106
+ const raw = readFileSync(path, 'utf8').trim();
107
+ if (!raw)
108
+ return {};
109
+ const parsed = JSON.parse(raw);
110
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ /**
117
+ * Escape a TOML basic string. Only the characters TOML actually requires —
118
+ * an API key with a quote or backslash would otherwise produce a file the
119
+ * client cannot parse.
120
+ */
121
+ function tomlString(v) {
122
+ return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
123
+ }
124
+ function tomlBlock(key, base) {
125
+ const envLines = [`CITO_API_KEY = ${tomlString(key)}`]
126
+ .concat(base ? [`CITO_API_BASE = ${tomlString(base)}`] : [])
127
+ .join('\n');
128
+ return [
129
+ `[mcp_servers.${SERVER_NAME}]`,
130
+ `command = "npx"`,
131
+ `args = ["-y", "cito-mcp"]`,
132
+ ``,
133
+ `[mcp_servers.${SERVER_NAME}.env]`,
134
+ envLines,
135
+ ``,
136
+ ].join('\n');
137
+ }
138
+ /**
139
+ * Replace an existing [mcp_servers.cito] block (and its .env subtable) or
140
+ * append a new one. Deliberately conservative: it only recognises the exact
141
+ * headers it writes, so a hand-edited file is appended to rather than mangled.
142
+ */
143
+ export function upsertTomlBlock(existing, key, base) {
144
+ const block = tomlBlock(key, base);
145
+ const header = new RegExp(`^\\[mcp_servers\\.${SERVER_NAME}(\\.[A-Za-z_]+)?\\]\\s*$`);
146
+ const anyHeader = /^\[[^\]]+\]\s*$/;
147
+ const lines = existing.split(/\r?\n/);
148
+ const kept = [];
149
+ let skipping = false;
150
+ for (const line of lines) {
151
+ if (header.test(line)) {
152
+ skipping = true;
153
+ continue;
154
+ }
155
+ if (skipping && anyHeader.test(line))
156
+ skipping = false;
157
+ if (!skipping)
158
+ kept.push(line);
159
+ }
160
+ const body = kept.join('\n').replace(/\n{3,}$/, '\n\n').replace(/\s*$/, '');
161
+ return (body ? `${body}\n\n` : '') + block;
162
+ }
163
+ /**
164
+ * Claude Code owns the schema of ~/.claude.json and stores far more than MCP
165
+ * config in it, so let its own CLI do the write when it is on PATH. Direct
166
+ * editing is the fallback, not the default.
167
+ */
168
+ function tryClaudeCli(key, base, dryRun) {
169
+ const json = JSON.stringify(serverEntry(key, base));
170
+ // On Windows `claude` is a .cmd shim, which execFileSync cannot spawn
171
+ // directly — it needs a shell. Quoting matters there because the JSON
172
+ // payload contains spaces and double quotes.
173
+ const isWin = platform() === 'win32';
174
+ const run = (args) => {
175
+ if (!isWin) {
176
+ execFileSync('claude', args, { stdio: 'ignore' });
177
+ return;
178
+ }
179
+ execFileSync('cmd', ['/c', 'claude', ...args], { stdio: 'ignore' });
180
+ };
181
+ try {
182
+ if (dryRun) {
183
+ run(['--version']);
184
+ return 'would use `claude mcp add-json` (CLI detected)';
185
+ }
186
+ run(['mcp', 'add-json', SERVER_NAME, json, '-s', 'user']);
187
+ return 'via `claude mcp add-json` (user scope)';
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ function writeJsonClient(c, key, base, dryRun) {
194
+ const current = readJson(c.configPath);
195
+ if (current === null && existsSync(c.configPath)) {
196
+ return {
197
+ client: c,
198
+ status: 'failed',
199
+ note: `${c.configPath} is not valid JSON — left untouched`,
200
+ };
201
+ }
202
+ const config = current ?? {};
203
+ const servers = (config.mcpServers ?? {});
204
+ const existed = Object.prototype.hasOwnProperty.call(servers, SERVER_NAME);
205
+ const next = { ...config, mcpServers: { ...servers, [SERVER_NAME]: serverEntry(key, base) } };
206
+ if (dryRun) {
207
+ return {
208
+ client: c,
209
+ status: 'planned',
210
+ note: `${existed ? 'replace' : 'add'} "${SERVER_NAME}" in ${c.configPath}`,
211
+ };
212
+ }
213
+ mkdirSync(dirname(c.configPath), { recursive: true });
214
+ if (existsSync(c.configPath))
215
+ copyFileSync(c.configPath, `${c.configPath}.cito-bak`);
216
+ writeFileSync(c.configPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
217
+ return {
218
+ client: c,
219
+ status: 'written',
220
+ note: `${existed ? 'updated' : 'added'} in ${c.configPath}`,
221
+ };
222
+ }
223
+ function writeTomlClient(c, key, base, dryRun) {
224
+ const existing = existsSync(c.configPath) ? readFileSync(c.configPath, 'utf8') : '';
225
+ const existed = new RegExp(`\\[mcp_servers\\.${SERVER_NAME}\\]`).test(existing);
226
+ if (dryRun) {
227
+ return {
228
+ client: c,
229
+ status: 'planned',
230
+ note: `${existed ? 'replace' : 'add'} [mcp_servers.${SERVER_NAME}] in ${c.configPath}`,
231
+ };
232
+ }
233
+ mkdirSync(dirname(c.configPath), { recursive: true });
234
+ if (existsSync(c.configPath))
235
+ copyFileSync(c.configPath, `${c.configPath}.cito-bak`);
236
+ writeFileSync(c.configPath, upsertTomlBlock(existing, key, base), 'utf8');
237
+ return {
238
+ client: c,
239
+ status: 'written',
240
+ note: `${existed ? 'updated' : 'added'} in ${c.configPath}`,
241
+ };
242
+ }
243
+ function parseArgs(argv) {
244
+ const out = { key: undefined, base: undefined, only: [], dryRun: false, help: false };
245
+ for (let i = 0; i < argv.length; i++) {
246
+ const a = argv[i];
247
+ const eat = () => argv[++i];
248
+ if (a === '--help' || a === '-h')
249
+ out.help = true;
250
+ else if (a === '--dry-run' || a === '-n')
251
+ out.dryRun = true;
252
+ else if (a === '--key')
253
+ out.key = eat();
254
+ else if (a?.startsWith('--key='))
255
+ out.key = a.slice(6);
256
+ else if (a === '--base')
257
+ out.base = eat();
258
+ else if (a?.startsWith('--base='))
259
+ out.base = a.slice(7);
260
+ else if (a === '--client')
261
+ out.only.push(...(eat() ?? '').split(',').filter(Boolean));
262
+ else if (a?.startsWith('--client='))
263
+ out.only.push(...a.slice(9).split(',').filter(Boolean));
264
+ }
265
+ return out;
266
+ }
267
+ const HELP = `cito-mcp install — configure the Cito MCP server for your editors
268
+
269
+ Usage:
270
+ npx cito-mcp install --key cito_xxx
271
+ npx cito-mcp install --key cito_xxx --client cursor,claude-code
272
+ npx cito-mcp install --dry-run
273
+
274
+ Options:
275
+ --key <key> Cito API key. Falls back to $CITO_API_KEY.
276
+ --client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex, grok.
277
+ Default: every client detected on this machine.
278
+ --base <url> Override API base (staging / self-hosted).
279
+ --dry-run, -n Show what would change; write nothing.
280
+ --help, -h This message.
281
+
282
+ Get a key at https://citoapi.com/dashboard`;
283
+ export async function runInstall(argv) {
284
+ const args = parseArgs(argv);
285
+ if (args.help) {
286
+ console.log(HELP);
287
+ return 0;
288
+ }
289
+ const key = args.key ?? process.env.CITO_API_KEY;
290
+ if (!key && !args.dryRun) {
291
+ console.error('No API key. Pass --key cito_xxx or set CITO_API_KEY.\n');
292
+ console.error('Get one at https://citoapi.com/dashboard');
293
+ return 1;
294
+ }
295
+ const all = clientsFor();
296
+ const unknown = args.only.filter((id) => !all.some((c) => c.id === id));
297
+ if (unknown.length) {
298
+ console.error(`Unknown client(s): ${unknown.join(', ')}`);
299
+ console.error(`Valid: ${all.map((c) => c.id).join(', ')}`);
300
+ return 1;
301
+ }
302
+ // An explicit --client is a instruction, not a guess: honour it even if we
303
+ // cannot detect the client (fresh install, portable install, unusual path).
304
+ const selected = args.only.length
305
+ ? all.filter((c) => args.only.includes(c.id))
306
+ : all.filter((c) => existsSync(c.detectPath));
307
+ if (selected.length === 0) {
308
+ console.error('No supported MCP clients detected.');
309
+ console.error(`Looked for: ${all.map((c) => c.label).join(', ')}`);
310
+ console.error('Use --client <id> to configure one anyway.');
311
+ return 1;
312
+ }
313
+ console.log(`cito-mcp ${PACKAGE_VERSION} installer`);
314
+ console.log(key ? `key: ${maskKey(key)}` : 'key: (none — dry run)');
315
+ console.log(args.dryRun ? 'mode: dry run, nothing will be written\n' : '');
316
+ const effectiveKey = key ?? 'CITO_API_KEY';
317
+ const results = [];
318
+ for (const c of selected) {
319
+ if (c.id === 'claude-code') {
320
+ const viaCli = tryClaudeCli(effectiveKey, args.base, args.dryRun);
321
+ if (viaCli) {
322
+ results.push({ client: c, status: args.dryRun ? 'planned' : 'written', note: viaCli });
323
+ continue;
324
+ }
325
+ }
326
+ results.push(c.format === 'toml-mcp_servers'
327
+ ? writeTomlClient(c, effectiveKey, args.base, args.dryRun)
328
+ : writeJsonClient(c, effectiveKey, args.base, args.dryRun));
329
+ }
330
+ for (const r of results) {
331
+ const mark = r.status === 'failed' ? '✗' : r.status === 'skipped' ? '–' : '✓';
332
+ console.log(` ${mark} ${r.client.label.padEnd(16)} ${r.note}`);
333
+ }
334
+ const failed = results.filter((r) => r.status === 'failed');
335
+ console.log('');
336
+ if (args.dryRun) {
337
+ console.log('Dry run complete. Re-run without --dry-run to apply.');
338
+ }
339
+ else {
340
+ console.log('Restart your editor to load the server, then ask it:');
341
+ console.log(' "what UFC events are coming up?"');
342
+ }
343
+ if (failed.length) {
344
+ console.log(`\n${failed.length} client(s) could not be configured (see above).`);
345
+ return 1;
346
+ }
347
+ return 0;
348
+ }
@@ -16,7 +16,7 @@ You are connected to Cito esports data (read-only). Prefer curated outcome tools
16
16
 
17
17
  ## Game parameter
18
18
 
19
- game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | all
19
+ game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | tennis | all
20
20
 
21
21
  - Default when the user named one title: that game. Default for "what's live?" / multi-title: omit game or all on tools that accept it.
22
22
  - On UNSUPPORTED_GAME / 403 for a title: stop retrying that game; call api_health; report plan gaps.
@@ -11,6 +11,7 @@ const LIVE_PATHS = {
11
11
  dota2: '/dota2/matches/live',
12
12
  cod: '/cod/matches/live',
13
13
  ufc: '/ufc/live',
14
+ tennis: '/tennis/matches/live',
14
15
  };
15
16
  /**
16
17
  * Extract live match/bout rows. UFC /ufc/live returns
@@ -502,6 +503,22 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
502
503
  if (tournamentId)
503
504
  noteIgnored('tournamentId', 'pass event slug via event_card instead');
504
505
  }
506
+ else if (game === 'tennis') {
507
+ // Tennis has no upcoming-fixtures endpoint; the archive is results-only.
508
+ return errorEnvelope({
509
+ code: 'UNSUPPORTED_GAME',
510
+ message: 'Tennis has no upcoming-fixtures feed. Live matches: live_matches { game: "tennis" }. Recent results: call_api GET /tennis/matches/recent. Season calendar: call_api GET /tennis/tournaments/calendar?year=YYYY.',
511
+ game,
512
+ source: 'upcoming_schedule',
513
+ requestId,
514
+ tookMs: Date.now() - started,
515
+ recover: [
516
+ 'Use live_matches with game "tennis" for in-progress matches',
517
+ 'Use call_api GET /tennis/matches/recent for latest results',
518
+ 'Use call_api GET /tennis/tournaments/calendar?year=YYYY for the season schedule',
519
+ ],
520
+ });
521
+ }
505
522
  const res = await fetchJson(ctx, path, { query });
506
523
  if (!res.ok) {
507
524
  return errorEnvelope({
@@ -99,22 +99,24 @@ function primaryPath(game, matchId) {
99
99
  return `/cod/matches/${encodeURIComponent(matchId)}`;
100
100
  case 'ufc':
101
101
  return `/ufc/bouts/${encodeURIComponent(matchId)}`;
102
+ case 'tennis':
103
+ return `/tennis/matches/${encodeURIComponent(matchId)}`;
102
104
  }
103
105
  }
104
106
  export const matchSummary = {
105
107
  name: 'match_summary',
106
- description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
107
-
108
- When to use:
109
- - Match recap / default match UI
110
- - After user selects a live or completed matchId
111
-
112
- Prefer over match_details for chat answers and default UIs.
113
- Prefer match_details for timelines, full map trees, live state, advanced packages.
114
-
115
- Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
116
-
117
- Parallel-safe: yes. Upstream cost: 2–5.
108
+ description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
109
+
110
+ When to use:
111
+ - Match recap / default match UI
112
+ - After user selects a live or completed matchId
113
+
114
+ Prefer over match_details for chat answers and default UIs.
115
+ Prefer match_details for timelines, full map trees, live state, advanced packages.
116
+
117
+ Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
118
+
119
+ Parallel-safe: yes. Upstream cost: 2–5.
118
120
  Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
119
121
  inputSchema: {
120
122
  type: 'object',
@@ -204,6 +206,8 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
204
206
  paths.push(`/cod/matches/${encodeURIComponent(matchId)}/player-stats`);
205
207
  if (game === 'ufc')
206
208
  paths.push(`/ufc/bouts/${encodeURIComponent(matchId)}/stats`);
209
+ if (game === 'tennis')
210
+ paths.push(`/tennis/matches/${encodeURIComponent(matchId)}/stats`);
207
211
  for (const p of paths) {
208
212
  const res = await getSection(ctx, p);
209
213
  upstreamCalls += 1;
@@ -298,22 +302,22 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
298
302
  };
299
303
  export const matchDetails = {
300
304
  name: 'match_details',
301
- description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
302
-
303
- When to use:
304
- - Analyst deep dive
305
- - Live in-game window (LoL/CS2/UFC)
306
- - Full demo list
307
-
308
- Prefer over match_summary only when summary is insufficient.
309
- Prefer match_summary for short answers and default cards.
310
-
311
- Do not use when: first-pass live board (use live_matches + match_summary).
312
-
313
- Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
314
- If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
315
-
316
- Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
305
+ description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
306
+
307
+ When to use:
308
+ - Analyst deep dive
309
+ - Live in-game window (LoL/CS2/UFC)
310
+ - Full demo list
311
+
312
+ Prefer over match_summary only when summary is insufficient.
313
+ Prefer match_summary for short answers and default cards.
314
+
315
+ Do not use when: first-pass live board (use live_matches + match_summary).
316
+
317
+ Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
318
+ If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
319
+
320
+ Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
317
321
  Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
318
322
  inputSchema: {
319
323
  type: 'object',
@@ -480,6 +480,7 @@ const ALLOWLIST_PREFIXES = [
480
480
  '/cod',
481
481
  '/ufc',
482
482
  '/fortnite',
483
+ '/tennis',
483
484
  // The spec describes the surface call_api is allowed to reach; refusing to
484
485
  // serve it left route discovery impossible except by guessing.
485
486
  '/openapi.json',
@@ -505,7 +506,7 @@ Prefer curated tools for all standard jobs (live, schedule, profiles, standings,
505
506
 
506
507
  Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
507
508
 
508
- Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite.
509
+ Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis.
509
510
  Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
510
511
 
511
512
  Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
@@ -566,7 +567,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
566
567
  source: 'call_api',
567
568
  requestId,
568
569
  tookMs: Date.now() - started,
569
- hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite',
570
+ hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite /tennis',
570
571
  });
571
572
  }
572
573
  let query = {};
@@ -646,6 +647,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
646
647
  * explicitly rather than returning an empty list that reads as a verdict.
647
648
  */
648
649
  const SPEC_OMITS = {
650
+ tennis: 'The published OpenAPI spec documents no /tennis paths, but tennis routes exist and work (players, matches, h2h, rankings, tournaments, live). Use the curated tools with game tennis, or call_api with /tennis/* paths.',
649
651
  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
652
  };
651
653
  export const listRoutes = {
@@ -154,7 +154,7 @@ function scoreNum(v) {
154
154
  export function normalizeMatch(game, row, forcedStatus) {
155
155
  // Always peel { success, data } so UFC bout fighters[] / status are visible.
156
156
  const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
157
- const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId) ?? 'unknown';
157
+ const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId, r.live_match_id) ?? 'unknown';
158
158
  let team1 = nestedSide(r.team1, game) ??
159
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));
160
160
  let team2 = nestedSide(r.team2, game) ??
@@ -171,6 +171,24 @@ export function normalizeMatch(game, row, forcedStatus) {
171
171
  if (s != null)
172
172
  team2 = { ...team2, score: s };
173
173
  }
174
+ // Tennis: live rows carry player1_name/player2_name; archive rows only carry
175
+ // winner_id/loser_id (ids, no names). Map both onto sides so labels are never "? vs ?".
176
+ if (game === 'tennis' && !team1 && !team2) {
177
+ const p1 = pickString(r.player1_name);
178
+ const p2 = pickString(r.player2_name);
179
+ if (p1 || p2) {
180
+ team1 = sideFrom(p1, pickString(r.player1_id), undefined, undefined);
181
+ team2 = sideFrom(p2, pickString(r.player2_id), undefined, undefined);
182
+ }
183
+ else {
184
+ const w = pickString(r.winner_id);
185
+ const l = pickString(r.loser_id);
186
+ if (w || l) {
187
+ team1 = sideFrom(w, w, undefined, undefined);
188
+ team2 = sideFrom(l, l, undefined, undefined);
189
+ }
190
+ }
191
+ }
174
192
  // UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
175
193
  // Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
176
194
  // Live tracking uses red/blue + fighters[] (not team1/team2).
@@ -373,12 +391,12 @@ export function sortByCardOrder(rows) {
373
391
  }
374
392
  export function entityRef(row, type, game) {
375
393
  const r = asRecord(row) ?? {};
376
- const id = pickString(r.id, r.teamId, r.playerId, r.lolPlayerId, r.codPlayerId, r.matchId, r.boutId, r.eventId, r.tournamentId, r.leagueId) ??
394
+ const id = pickString(r.id, r.teamId, r.playerId, r.player_id, r.lolPlayerId, r.codPlayerId, r.matchId, r.boutId, r.eventId, r.tournamentId, r.leagueId) ??
377
395
  pickString(r.slug) ??
378
396
  'unknown';
379
397
  const slug = pickString(r.slug, r.orgSlug, r.teamSlug);
380
398
  // Events often use `title` not `name` (UFC)
381
- const name = pickString(r.name, r.title, r.nickname, r.displayName, r.tag, r.code, slug, id) ?? id;
399
+ const name = pickString(r.name, r.full_name, r.player_name, r.title, r.nickname, r.displayName, r.tag, r.code, slug, id) ?? id;
382
400
  const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname);
383
401
  return {
384
402
  game,
@@ -9,7 +9,7 @@ function identityFrom(game, raw, idHint, slugHint) {
9
9
  const r = asRecord(raw) ?? {};
10
10
  const id = pickString(r.id, r.playerId, r.lolPlayerId, r.codPlayerId, idHint, slugHint) ?? 'unknown';
11
11
  const slug = pickString(r.slug, slugHint);
12
- const name = pickString(r.name, r.nickname, r.displayName, r.tag, slug, id) ?? id;
12
+ const name = pickString(r.name, r.full_name, r.nickname, r.displayName, r.tag, slug, id) ?? id;
13
13
  const team = asRecord(r.team) ??
14
14
  asRecord(r.currentTeam) ??
15
15
  (r.teamName || r.orgSlug
@@ -146,6 +146,8 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
146
146
  primaryPath = `/cod/players/${encodeURIComponent(idOrSlug)}`;
147
147
  else if (game === 'ufc')
148
148
  primaryPath = `/ufc/fighters/${encodeURIComponent(slug || idOrSlug)}`;
149
+ else if (game === 'tennis')
150
+ primaryPath = `/tennis/players/${encodeURIComponent(idOrSlug)}`;
149
151
  let playerRaw = null;
150
152
  if (game === 'dota2') {
151
153
  // Prefer radar as primary signal; try list filter
@@ -218,6 +220,43 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
218
220
  }
219
221
  })());
220
222
  }
223
+ if (game === 'tennis') {
224
+ if (includeTrends) {
225
+ tasks.push((async () => {
226
+ const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(idOrSlug)}/stats`);
227
+ upstreamCalls += 1;
228
+ rateLimit = { ...rateLimit, ...res.headers };
229
+ if (res.ok)
230
+ career = unwrapPayload(res.data);
231
+ else {
232
+ partial.push(partialFromRejection('career', {
233
+ code: mapHttpToCode(res.status),
234
+ message: `player stats HTTP ${res.status}`,
235
+ httpStatus: res.status,
236
+ }));
237
+ }
238
+ })());
239
+ }
240
+ tasks.push((async () => {
241
+ const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(idOrSlug)}/matches`, {
242
+ query: { page_size: recentLimit },
243
+ });
244
+ upstreamCalls += 1;
245
+ rateLimit = { ...rateLimit, ...res.headers };
246
+ if (res.ok) {
247
+ const envelope = asRecord(res.data) ?? {};
248
+ const data = asRecord(envelope.data) ?? envelope;
249
+ recentMatches = extractRows(data.items ?? data).slice(0, recentLimit);
250
+ }
251
+ else {
252
+ partial.push(partialFromRejection('recentMatches', {
253
+ code: mapHttpToCode(res.status),
254
+ message: `player matches HTTP ${res.status}`,
255
+ httpStatus: res.status,
256
+ }));
257
+ }
258
+ })());
259
+ }
221
260
  if (game === 'cs2' && includeTrends) {
222
261
  for (const [section, path] of [
223
262
  ['career', `/cs2/players/${encodeURIComponent(idOrSlug)}/career`],