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.
- package/README.md +26 -10
- package/dist/envelope.js +23 -0
- package/dist/http.js +150 -0
- package/dist/index.js +113 -33
- package/dist/install.js +348 -0
- package/dist/instructions.js +1 -1
- package/dist/tools/live.js +17 -0
- package/dist/tools/match.js +32 -28
- package/dist/tools/meta.js +4 -2
- package/dist/tools/normalize.js +21 -3
- package/dist/tools/player.js +40 -1
- package/dist/tools/resolve.js +47 -0
- package/dist/tools/standings.js +9 -1
- package/dist/tools/team.js +109 -27
- package/dist/tools/types.js +21 -6
- package/package.json +1 -1
package/dist/install.js
ADDED
|
@@ -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
|
+
}
|
package/dist/instructions.js
CHANGED
|
@@ -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.
|
package/dist/tools/live.js
CHANGED
|
@@ -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({
|
package/dist/tools/match.js
CHANGED
|
@@ -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',
|
package/dist/tools/meta.js
CHANGED
|
@@ -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 = {
|
package/dist/tools/normalize.js
CHANGED
|
@@ -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,
|
package/dist/tools/player.js
CHANGED
|
@@ -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`],
|