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 +58 -14
- package/dist/index.js +12 -0
- package/dist/install.js +340 -0
- package/dist/tools/index.js +3 -2
- package/dist/tools/insight.js +83 -12
- package/dist/tools/meta.js +181 -7
- package/dist/tools/normalize.js +185 -9
- package/dist/tools/player.js +28 -2
- package/dist/tools/types.js +73 -0
- package/package.json +3 -2
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
|
+
**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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 --
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/install.js
ADDED
|
@@ -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
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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);
|
package/dist/tools/insight.js
CHANGED
|
@@ -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
|
-
|
|
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).'
|
|
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
|
-
|
|
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
|
|
756
|
-
|
|
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 {
|
package/dist/tools/meta.js
CHANGED
|
@@ -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
|
|
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: [
|
|
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 = [
|
|
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
|
-
|
|
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;
|
package/dist/tools/normalize.js
CHANGED
|
@@ -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
|
-
|
|
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) ??
|
package/dist/tools/player.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
382
|
+
// Same as above: derive, never assert. See the note there.
|
|
383
|
+
return normalizeMatch('ufc', bout);
|
|
358
384
|
});
|
|
359
385
|
}
|
|
360
386
|
else {
|
package/dist/tools/types.js
CHANGED
|
@@ -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.
|
|
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"
|