cito-mcp 0.2.5 → 0.2.6
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 +35 -4
- 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.
|
|
@@ -124,7 +126,7 @@ The server **exits immediately** if `CITO_API_KEY` is missing. The key is sent o
|
|
|
124
126
|
4. Match card → `match_summary` (then `match_details` if needed)
|
|
125
127
|
5. Team / player pages → `team_profile` / `player_profile`
|
|
126
128
|
6. Tables / rivalry / preview / event card → `standings` / `head_to_head` / `match_preview` / `event_card`
|
|
127
|
-
7. Escape hatch → `call_api` (allowlisted paths only)
|
|
129
|
+
7. Escape hatch → `list_routes` to find a path, then `call_api` (allowlisted paths only)
|
|
128
130
|
|
|
129
131
|
**Never invent IDs** — resolve them or take them from live / schedule / search results.
|
|
130
132
|
|
|
@@ -132,7 +134,7 @@ Mnemonic: **resolve → live/schedule → summary → deep**.
|
|
|
132
134
|
|
|
133
135
|
---
|
|
134
136
|
|
|
135
|
-
## Tool catalog (
|
|
137
|
+
## Tool catalog (16)
|
|
136
138
|
|
|
137
139
|
All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (the server name already brands the surface).
|
|
138
140
|
|
|
@@ -151,9 +153,38 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
|
|
|
151
153
|
| `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
154
|
| `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
155
|
| `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,
|
|
156
|
+
| `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?` |
|
|
157
|
+
| `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
158
|
| `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
159
|
|
|
160
|
+
### Images
|
|
161
|
+
|
|
162
|
+
Fighter and team sides carry an `images` object wherever upstream supplies one —
|
|
163
|
+
on `event_card` bout corners and on `player_profile`. No extra call, no N+1.
|
|
164
|
+
|
|
165
|
+
```jsonc
|
|
166
|
+
"team1": {
|
|
167
|
+
"name": "Islam Makhachev",
|
|
168
|
+
"slug": "islam-makhachev",
|
|
169
|
+
"record": { "wins": 28, "losses": 1, "draws": 0, "text": "28-1-0 (W-L-D)" },
|
|
170
|
+
"championStatus": "champion",
|
|
171
|
+
"images": {
|
|
172
|
+
"headshotUrl": "https://ufc.com/…/MAKHACHEV_ISLAM_BELT_01-18.png",
|
|
173
|
+
"bodyImageUrl": "https://ufc.com/…/athlete_bio_full_body/…",
|
|
174
|
+
"imageUrl": "https://ufc.com/…/event_fight_card_upper_body/…",
|
|
175
|
+
"proxiedImageUrl": "https://api.citoapi.com/api/v1/public/images/ufc/aHR0cHM6…"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Use `proxiedImageUrl` in a browser.** `ufc.com` sends no CORS header and can
|
|
181
|
+
hotlink-block, so raw URLs render as broken images in a web UI. The proxied URL
|
|
182
|
+
is served by the API and is safe to put in an `<img src>`.
|
|
183
|
+
|
|
184
|
+
When a key is `null` the image does not exist for that entity; when the whole
|
|
185
|
+
`images` object is absent, upstream sent nothing for that side. The object is
|
|
186
|
+
never partially shaped — if any image exists, all four keys are present.
|
|
187
|
+
|
|
157
188
|
### Games
|
|
158
189
|
|
|
159
190
|
| Game | Depth | Notes |
|
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.6",
|
|
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"
|