cito-mcp 0.2.4 → 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 +36 -4
- package/dist/client.js +14 -0
- package/dist/index.js +45 -6
- package/dist/instructions.js +1 -0
- package/dist/tools/index.js +3 -2
- package/dist/tools/insight.js +83 -12
- package/dist/tools/live.js +176 -25
- package/dist/tools/match.js +76 -29
- package/dist/tools/meta.js +182 -8
- package/dist/tools/normalize.js +205 -10
- package/dist/tools/player.js +28 -2
- package/dist/tools/resolve.js +66 -37
- package/dist/tools/standings.js +34 -1
- package/dist/tools/team.js +111 -47
- package/dist/tools/types.js +73 -0
- package/dist/version.js +29 -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 |
|
|
@@ -170,6 +201,7 @@ Hardened agent-facing UFC shapes (offline-tested):
|
|
|
170
201
|
|
|
171
202
|
- **`normalizeMatch`** reads `fighters[]` (`corner` + `fighterName` / `profile.name`), live `red`/`blue`, and `boutId` / `dataId` / bare `id`. Never keeps label `? vs ?` when fighter names exist (including blue-first arrays).
|
|
172
203
|
- **`live_matches`** uses `extractLiveRows`: prefer `liveBouts` (empty array = honest empty board); never promote supervisor `events[]` shells into match rows.
|
|
204
|
+
- **UFC empty board honesty:** when `count === 0`, section includes `note`, `emptyReason`, slim `health` (`workerAlive`, lag), optional non-live `supervisor` strip and `nextCard` (armed only). Also `data.note` + `meta.warnings` for single-game UFC. Do not treat empty as HTTP failure; do not invent `? vs ?` from event shells.
|
|
173
205
|
- **`upcoming_schedule`** expands event → bout rows with fighter labels; applies client-side **`hours` / `from` / `to`** (API has no `hours`); event shells without bouts use the **event name**, not `? vs ?`.
|
|
174
206
|
- **`event_card`** bouts share the same `normalizeMatch` path.
|
|
175
207
|
- **`standings`** maps division champions to rank **`C`** (`rankText: "C"`, interim **`IC`**); contender `#1` stays numeric `1` — no dual numeric `#1`.
|
package/dist/client.js
CHANGED
|
@@ -2,16 +2,30 @@
|
|
|
2
2
|
* Authenticated Cito REST client for curated tools.
|
|
3
3
|
* Auth: CITO_API_KEY → x-api-key. Logs never include the key.
|
|
4
4
|
*/
|
|
5
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
5
6
|
export const DEFAULT_API_BASE = 'https://api.citoapi.com/api/v1';
|
|
6
7
|
export const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024;
|
|
7
8
|
/** stderr ONLY — stdout is the MCP stdio channel. */
|
|
8
9
|
export function log(message) {
|
|
9
10
|
console.error(`[cito-mcp] ${message}`);
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Identify MCP traffic.
|
|
14
|
+
*
|
|
15
|
+
* Without this every MCP request arrives as Node's default agent - "node" -
|
|
16
|
+
* which is the single largest bucket in api_usage_logs (2.78M requests across
|
|
17
|
+
* 182 keys) and indistinguishable from any other script. That made MCP adoption
|
|
18
|
+
* unmeasurable: we could not tell whether an agent-driven user activates faster,
|
|
19
|
+
* consumes more, or converts better than someone hand-rolling curl.
|
|
20
|
+
*
|
|
21
|
+
* Versioned so a bad release can be isolated in the logs.
|
|
22
|
+
*/
|
|
23
|
+
export const CITO_MCP_USER_AGENT = `cito-mcp/${PACKAGE_VERSION} (+https://citoapi.com)`;
|
|
11
24
|
export function authHeaders(apiKey, extra) {
|
|
12
25
|
return {
|
|
13
26
|
'x-api-key': apiKey,
|
|
14
27
|
accept: 'application/json',
|
|
28
|
+
'user-agent': CITO_MCP_USER_AGENT,
|
|
15
29
|
...extra,
|
|
16
30
|
};
|
|
17
31
|
}
|
package/dist/index.js
CHANGED
|
@@ -18,17 +18,37 @@ import { errorEnvelope, toMcpResult } from './envelope.js';
|
|
|
18
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
22
|
const API_KEY = process.env.CITO_API_KEY;
|
|
23
|
+
/**
|
|
24
|
+
* Tools that need no API key. These must keep working with the server
|
|
25
|
+
* unconfigured, so a user can add it and immediately see what it does.
|
|
26
|
+
*/
|
|
27
|
+
const OFFLINE_TOOLS = new Set(['list_capabilities']);
|
|
28
|
+
/**
|
|
29
|
+
* Missing key is NOT fatal.
|
|
30
|
+
*
|
|
31
|
+
* This used to process.exit(1) before the transport was even created, so the
|
|
32
|
+
* handshake never completed and the client showed only "server failed to
|
|
33
|
+
* start" — no tool list, no reason, nothing the model could relay. The user's
|
|
34
|
+
* first experience of a mistyped env var was a dead server.
|
|
35
|
+
*
|
|
36
|
+
* Now the server boots, completes initialize, and advertises its full catalog.
|
|
37
|
+
* Tools that need the API return a structured MISSING_API_KEY envelope with
|
|
38
|
+
* recovery steps, which the model can read out; list_capabilities keeps working
|
|
39
|
+
* offline so the server is browsable before it is configured.
|
|
40
|
+
*/
|
|
23
41
|
if (!API_KEY) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
'
|
|
27
|
-
|
|
42
|
+
// stderr only — stdout is the JSON-RPC channel and must stay clean.
|
|
43
|
+
console.error('[cito-mcp] CITO_API_KEY is not set. Serving tool catalog only; ' +
|
|
44
|
+
'API-backed tools will return MISSING_API_KEY until a key is provided. ' +
|
|
45
|
+
'Get one at https://citoapi.com/dashboard and set CITO_API_KEY.');
|
|
28
46
|
}
|
|
29
47
|
const API_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
30
48
|
const ctx = {
|
|
31
|
-
|
|
49
|
+
// Empty string when unconfigured; the dispatcher blocks API-backed tools
|
|
50
|
+
// before any request is attempted, so this is never sent as a credential.
|
|
51
|
+
apiKey: API_KEY ?? '',
|
|
32
52
|
baseUrl: API_BASE,
|
|
33
53
|
};
|
|
34
54
|
async function main() {
|
|
@@ -59,6 +79,25 @@ async function main() {
|
|
|
59
79
|
recover: ['Call list_capabilities to see available tools'],
|
|
60
80
|
}));
|
|
61
81
|
}
|
|
82
|
+
// Fail the CALL, not the process. The model gets an actionable envelope it
|
|
83
|
+
// can relay verbatim instead of the client reporting a dead server.
|
|
84
|
+
if (!API_KEY && !OFFLINE_TOOLS.has(name)) {
|
|
85
|
+
return toMcpResult(errorEnvelope({
|
|
86
|
+
code: 'UNAUTHORIZED',
|
|
87
|
+
message: 'CITO_API_KEY is not set, so this tool cannot reach the Cito API. ' +
|
|
88
|
+
'The server is running and list_capabilities still works.',
|
|
89
|
+
game: null,
|
|
90
|
+
source: 'cito-mcp',
|
|
91
|
+
httpStatus: 401,
|
|
92
|
+
retryable: false,
|
|
93
|
+
recover: [
|
|
94
|
+
'Create a key at https://citoapi.com/dashboard',
|
|
95
|
+
'claude mcp add cito -e CITO_API_KEY=cito_... -- npx cito-mcp',
|
|
96
|
+
'Or set CITO_API_KEY in the mcpServers env block of your client config',
|
|
97
|
+
'Call list_capabilities to browse available tools without a key',
|
|
98
|
+
],
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
62
101
|
return runTool(tool, args, ctx);
|
|
63
102
|
});
|
|
64
103
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
package/dist/instructions.js
CHANGED
|
@@ -57,6 +57,7 @@ resolve_entity ambiguity is soft: ok:true with data.needsDisambiguation and data
|
|
|
57
57
|
## Recipes
|
|
58
58
|
|
|
59
59
|
Live board: api_health (optional) → live_matches → match_summary for selected matchId.
|
|
60
|
+
UFC empty live (count=0): read section.note / emptyReason / health (workerAlive, lag) / supervisor / nextCard — do not claim "API offline" without workerAlive/lag; never invent matchups from supervisor shells.
|
|
60
61
|
Team page: resolve_entity {type:team} → team_profile.
|
|
61
62
|
Player card: resolve_entity {type:player|fighter} → player_profile.
|
|
62
63
|
Fight night / event card: resolve_entity {type:event} → event_card {includeBouts:true} → match_preview for a featured bout.
|
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/live.js
CHANGED
|
@@ -16,6 +16,8 @@ const LIVE_PATHS = {
|
|
|
16
16
|
* Extract live match/bout rows. UFC /ufc/live returns
|
|
17
17
|
* { liveBouts, events, ... } — events are supervisor shells without fighters.
|
|
18
18
|
* Prefer liveBouts (and nested tracking on events) over plain events[].
|
|
19
|
+
* Never promote bare events[] to match rows (avoids fake "? vs ?").
|
|
20
|
+
* Nested tracking with status armed is excluded from live items (use nextCard).
|
|
19
21
|
* Exported for offline UFC projection tests.
|
|
20
22
|
*/
|
|
21
23
|
export function extractLiveRows(data, game) {
|
|
@@ -24,6 +26,20 @@ export function extractLiveRows(data, game) {
|
|
|
24
26
|
if (!payload)
|
|
25
27
|
return extractRows(data);
|
|
26
28
|
if (game === 'ufc') {
|
|
29
|
+
const pushLiveTracking = (tracking, er, out) => {
|
|
30
|
+
for (const t of tracking) {
|
|
31
|
+
const tr = asRecord(t) ?? {};
|
|
32
|
+
const status = String(pickString(tr.status) ?? '').toLowerCase();
|
|
33
|
+
// Board items = live|watching only; armed is surfaced as nextCard, not items.
|
|
34
|
+
if (status === 'armed' || status === 'completed' || status === 'stale')
|
|
35
|
+
continue;
|
|
36
|
+
out.push({
|
|
37
|
+
...tr,
|
|
38
|
+
eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
|
|
39
|
+
eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
27
43
|
// Prefer liveBouts when the key is present (including empty [] — honest empty board).
|
|
28
44
|
if (Array.isArray(payload.liveBouts)) {
|
|
29
45
|
const liveBouts = payload.liveBouts;
|
|
@@ -37,16 +53,9 @@ export function extractLiveRows(data, game) {
|
|
|
37
53
|
for (const ev of eventsWithEmptyLive) {
|
|
38
54
|
const er = asRecord(ev);
|
|
39
55
|
const tracking = Array.isArray(er?.tracking) ? er.tracking : [];
|
|
40
|
-
|
|
41
|
-
const tr = asRecord(t) ?? {};
|
|
42
|
-
nested.push({
|
|
43
|
-
...tr,
|
|
44
|
-
eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
|
|
45
|
-
eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
|
|
46
|
-
});
|
|
47
|
-
}
|
|
56
|
+
pushLiveTracking(tracking, er, nested);
|
|
48
57
|
}
|
|
49
|
-
return nested; // [] when only supervisor shells
|
|
58
|
+
return nested; // [] when only supervisor shells / armed-only
|
|
50
59
|
}
|
|
51
60
|
// liveBouts key missing — try nested tracking under events
|
|
52
61
|
const events = Array.isArray(payload.events) ? payload.events : [];
|
|
@@ -54,14 +63,7 @@ export function extractLiveRows(data, game) {
|
|
|
54
63
|
for (const ev of events) {
|
|
55
64
|
const er = asRecord(ev);
|
|
56
65
|
const tracking = Array.isArray(er?.tracking) ? er.tracking : [];
|
|
57
|
-
|
|
58
|
-
const tr = asRecord(t) ?? {};
|
|
59
|
-
fromTracking.push({
|
|
60
|
-
...tr,
|
|
61
|
-
eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
|
|
62
|
-
eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
|
|
63
|
-
});
|
|
64
|
-
}
|
|
66
|
+
pushLiveTracking(tracking, er, fromTracking);
|
|
65
67
|
}
|
|
66
68
|
if (fromTracking.length)
|
|
67
69
|
return fromTracking;
|
|
@@ -70,6 +72,103 @@ export function extractLiveRows(data, game) {
|
|
|
70
72
|
}
|
|
71
73
|
return extractRows(data);
|
|
72
74
|
}
|
|
75
|
+
/** Machine emptyReason codes from API health / meta (or inferred). */
|
|
76
|
+
export function inferUfcEmptyReason(events, health, liveCount) {
|
|
77
|
+
if (liveCount > 0)
|
|
78
|
+
return undefined;
|
|
79
|
+
const fromHealth = pickString(health?.emptyReason);
|
|
80
|
+
if (fromHealth)
|
|
81
|
+
return fromHealth;
|
|
82
|
+
const pulses = events.map((ev) => asRecord(ev) ?? {});
|
|
83
|
+
if (!pulses.length) {
|
|
84
|
+
const alive = health?.workerAlive ?? health?.workerStarted;
|
|
85
|
+
if (alive === false)
|
|
86
|
+
return 'worker_down_inferred';
|
|
87
|
+
return 'idle_no_supervisor';
|
|
88
|
+
}
|
|
89
|
+
const statuses = pulses.map((p) => String(pickString(p.status, p.eventStatus) ?? '').toLowerCase());
|
|
90
|
+
const lags = pulses
|
|
91
|
+
.map((p) => (typeof p.lagSeconds === 'number' ? p.lagSeconds : null))
|
|
92
|
+
.filter((n) => n != null);
|
|
93
|
+
const freshest = lags.length ? Math.min(...lags) : null;
|
|
94
|
+
const workerAlive = health?.workerAlive ?? health?.workerStarted;
|
|
95
|
+
const hot = statuses.some((s) => ['live', 'warming', 'between_bouts', 'degraded'].includes(s));
|
|
96
|
+
if (workerAlive === false && (hot || (freshest != null && freshest > 120))) {
|
|
97
|
+
return 'worker_stale';
|
|
98
|
+
}
|
|
99
|
+
if (statuses.some((s) => s === 'degraded'))
|
|
100
|
+
return 'event_degraded';
|
|
101
|
+
if (statuses.some((s) => s === 'warming'))
|
|
102
|
+
return 'supervisor_warming';
|
|
103
|
+
if (statuses.some((s) => s === 'between_bouts'))
|
|
104
|
+
return 'between_bouts';
|
|
105
|
+
if (statuses.every((s) => s === 'completed'))
|
|
106
|
+
return 'not_fight_night';
|
|
107
|
+
if (hot)
|
|
108
|
+
return 'between_bouts';
|
|
109
|
+
return 'not_fight_night';
|
|
110
|
+
}
|
|
111
|
+
/** Human note for empty UFC live section — never invent matchups. */
|
|
112
|
+
export function humanUfcEmptyNote(emptyReason, health) {
|
|
113
|
+
const alive = health?.workerAlive ?? health?.workerStarted;
|
|
114
|
+
const lag = typeof health?.freshestHeartbeatLagSeconds === 'number'
|
|
115
|
+
? health.freshestHeartbeatLagSeconds
|
|
116
|
+
: typeof health?.lagSeconds === 'number'
|
|
117
|
+
? health.lagSeconds
|
|
118
|
+
: null;
|
|
119
|
+
const lagPart = typeof lag === 'number' ? ` lag=${lag}s` : '';
|
|
120
|
+
const workerPart = alive === true ? 'workerAlive=true' : alive === false ? 'workerAlive=false' : 'workerAlive=unknown';
|
|
121
|
+
const reason = emptyReason || 'unknown';
|
|
122
|
+
const human = {
|
|
123
|
+
not_fight_night: 'No UFC live card right now',
|
|
124
|
+
idle_no_supervisor: 'Supervisor has no live event rows',
|
|
125
|
+
supervisor_warming: 'Card open / pre-gate; no bout live yet',
|
|
126
|
+
between_bouts: 'Between bouts; next may be armed',
|
|
127
|
+
armed_only: 'Next bout armed; not started',
|
|
128
|
+
worker_stale: 'Worker heartbeat stale — check pm2 ufc-live on VPS 169',
|
|
129
|
+
worker_down_inferred: 'Worker appears down — check pm2 ufc-live on VPS 169',
|
|
130
|
+
event_degraded: 'Sources empty clock / event degraded; not inventing stats',
|
|
131
|
+
};
|
|
132
|
+
const msg = human[reason] || `No live|watching bouts (${reason})`;
|
|
133
|
+
return `UFC: ${msg} (${workerPart}${lagPart}; emptyReason=${reason})`;
|
|
134
|
+
}
|
|
135
|
+
function slimUfcSupervisorEvents(events, limit = 3) {
|
|
136
|
+
return events.slice(0, limit).map((ev) => {
|
|
137
|
+
const er = asRecord(ev) ?? {};
|
|
138
|
+
return {
|
|
139
|
+
kind: 'supervisor',
|
|
140
|
+
eventSlug: pickString(er.eventSlug, er.slug) ?? null,
|
|
141
|
+
status: pickString(er.status, er.eventStatus) ?? null,
|
|
142
|
+
currentLiveFmid: er.currentLiveFmid ?? null,
|
|
143
|
+
nextCandidateFmid: er.nextCandidateFmid ?? null,
|
|
144
|
+
lagSeconds: typeof er.lagSeconds === 'number' ? er.lagSeconds : null,
|
|
145
|
+
recommendedPollSeconds: typeof er.recommendedPollSeconds === 'number' ? er.recommendedPollSeconds : null,
|
|
146
|
+
degradedReason: pickString(er.degradedReason) ?? null,
|
|
147
|
+
note: 'Non-live supervisor shell — not a matchup',
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function slimUfcNextCard(payload) {
|
|
152
|
+
if (!payload)
|
|
153
|
+
return undefined;
|
|
154
|
+
const nextBouts = Array.isArray(payload.nextBouts) ? payload.nextBouts : [];
|
|
155
|
+
const first = asRecord(payload.nextArmedBout) ??
|
|
156
|
+
(nextBouts[0] ? asRecord(nextBouts[0]) : null);
|
|
157
|
+
if (!first)
|
|
158
|
+
return undefined;
|
|
159
|
+
return {
|
|
160
|
+
kind: 'next_armed',
|
|
161
|
+
status: pickString(first.status) ?? 'armed',
|
|
162
|
+
boutId: pickString(first.boutId, first.id) ?? null,
|
|
163
|
+
eventSlug: pickString(first.eventSlug) ?? null,
|
|
164
|
+
fightMetricId: first.fightMetricId ?? null,
|
|
165
|
+
// Names only if API already provided them — never invent.
|
|
166
|
+
red: first.red ?? null,
|
|
167
|
+
blue: first.blue ?? null,
|
|
168
|
+
degradedReason: pickString(first.degradedReason) ?? null,
|
|
169
|
+
note: 'Armed / up-next only — not currently live',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
73
172
|
export const liveMatches = {
|
|
74
173
|
name: 'live_matches',
|
|
75
174
|
description: `Live matches board across primary games, or a single game filter. Normalized labels, scores, and matchIds.
|
|
@@ -83,6 +182,7 @@ Prefer over: sequential per-game call_api live probes.
|
|
|
83
182
|
Do not use when: user wants upcoming fixtures → upcoming_schedule; historical results → match_summary.
|
|
84
183
|
|
|
85
184
|
CS2 live path is /cs2/live; UFC is included in multi-game fan-out.
|
|
185
|
+
UFC empty board: section.note + emptyReason + health (workerAlive/lag) + optional supervisor/nextCard (non-live); never fake match items from events[].
|
|
86
186
|
|
|
87
187
|
Parallel-safe: yes. Upstream cost: 1–5 (allSettled).
|
|
88
188
|
Example: { "game": "all", "limitPerGame": 10 }`,
|
|
@@ -127,6 +227,7 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
127
227
|
return { game, path, res };
|
|
128
228
|
}));
|
|
129
229
|
const sections = [];
|
|
230
|
+
const warnings = [];
|
|
130
231
|
for (const { game, res } of settled) {
|
|
131
232
|
upstreamCalls += 1;
|
|
132
233
|
rateLimit = { ...rateLimit, ...res.headers };
|
|
@@ -148,21 +249,26 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
148
249
|
}
|
|
149
250
|
// Prefer real match/bout rows over supervisor event shells (UFC /ufc/live).
|
|
150
251
|
let rows = extractLiveRows(res.data, game);
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
252
|
+
const root = asRecord(res.data);
|
|
253
|
+
const payload = asRecord(root?.data) ?? root;
|
|
254
|
+
const meta = asRecord(root?.meta);
|
|
255
|
+
if (rows.length === 0 && Array.isArray(root?.matches))
|
|
256
|
+
rows = root.matches;
|
|
154
257
|
const normalized = rows
|
|
155
258
|
.slice(0, limitPerGame)
|
|
156
259
|
.map((row) => normalizeMatch(game, row, 'live'))
|
|
157
|
-
// Drop hollow UFC
|
|
260
|
+
// Drop hollow UFC rows agents cannot use (placeholder labels / no sides).
|
|
261
|
+
// normalizeMatch already enriches label from eventSlug/weight/bout id when
|
|
262
|
+
// fighters are pending — keep those; drop pure "? vs ?" only.
|
|
158
263
|
.filter((m) => {
|
|
159
264
|
if (game !== 'ufc')
|
|
160
265
|
return true;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
266
|
+
const placeholder = /^\?\s*vs\s*\?$/i.test(m.label.trim());
|
|
267
|
+
if (placeholder)
|
|
268
|
+
return false;
|
|
269
|
+
if (m.team1?.name || m.team2?.name)
|
|
164
270
|
return true;
|
|
165
|
-
//
|
|
271
|
+
// Identity-only live row (enriched Bout {id} / event name) is ok
|
|
166
272
|
return Boolean(m.matchId && m.matchId !== 'unknown' && !String(m.matchId).startsWith('event'));
|
|
167
273
|
});
|
|
168
274
|
const items = labelsOnly
|
|
@@ -174,6 +280,44 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
174
280
|
startTime: m.startTime,
|
|
175
281
|
}))
|
|
176
282
|
: normalized;
|
|
283
|
+
if (game === 'ufc') {
|
|
284
|
+
const health = asRecord(meta?.health) ?? asRecord(payload?.health);
|
|
285
|
+
const events = Array.isArray(payload?.events) ? payload.events : [];
|
|
286
|
+
const emptyReason = normalized.length === 0
|
|
287
|
+
? pickString(meta?.emptyReason, health?.emptyReason) ??
|
|
288
|
+
inferUfcEmptyReason(events, health, normalized.length)
|
|
289
|
+
: undefined;
|
|
290
|
+
let note;
|
|
291
|
+
if (normalized.length === 0) {
|
|
292
|
+
note = humanUfcEmptyNote(emptyReason, health);
|
|
293
|
+
warnings.push(note);
|
|
294
|
+
}
|
|
295
|
+
const healthStrip = normalized.length === 0 || health?.workerAlive === false || health?.workerStarted === false
|
|
296
|
+
? {
|
|
297
|
+
workerStarted: health?.workerStarted ?? health?.workerAlive ?? null,
|
|
298
|
+
workerAlive: health?.workerAlive ?? health?.workerStarted ?? null,
|
|
299
|
+
freshestHeartbeatLagSeconds: health?.freshestHeartbeatLagSeconds ?? meta?.lagSeconds ?? null,
|
|
300
|
+
emptyReason: emptyReason ?? health?.emptyReason ?? null,
|
|
301
|
+
degradedReason: health?.degradedReason ?? null,
|
|
302
|
+
ok: health?.ok ?? null,
|
|
303
|
+
redis_ready: health?.redis_ready ?? meta?.redis_ready ?? meta?.redis_configured ?? null,
|
|
304
|
+
}
|
|
305
|
+
: undefined;
|
|
306
|
+
const supervisor = normalized.length === 0 && events.length ? slimUfcSupervisorEvents(events) : undefined;
|
|
307
|
+
const nextCard = normalized.length === 0 ? slimUfcNextCard(payload) : undefined;
|
|
308
|
+
sections.push({
|
|
309
|
+
game,
|
|
310
|
+
count: normalized.length,
|
|
311
|
+
ok: true,
|
|
312
|
+
note,
|
|
313
|
+
emptyReason,
|
|
314
|
+
health: healthStrip,
|
|
315
|
+
supervisor,
|
|
316
|
+
nextCard,
|
|
317
|
+
items,
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
177
321
|
sections.push({
|
|
178
322
|
game,
|
|
179
323
|
count: normalized.length,
|
|
@@ -182,6 +326,11 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
182
326
|
});
|
|
183
327
|
}
|
|
184
328
|
const totalLive = sections.reduce((sum, s) => sum + (s.ok ? s.count : 0), 0);
|
|
329
|
+
// Top-level note when single-game UFC empty so agents don't need deep section walk.
|
|
330
|
+
const ufcSection = sections.find((s) => s.game === 'ufc');
|
|
331
|
+
const dataNote = games.length === 1 && games[0] === 'ufc' && ufcSection && ufcSection.count === 0
|
|
332
|
+
? ufcSection.note
|
|
333
|
+
: undefined;
|
|
185
334
|
return successEnvelope({
|
|
186
335
|
source: 'live_matches',
|
|
187
336
|
game: games.length === 1 ? games[0] : null,
|
|
@@ -190,10 +339,12 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
190
339
|
upstreamCalls,
|
|
191
340
|
rateLimit,
|
|
192
341
|
partial: partial.length ? partial : undefined,
|
|
342
|
+
warnings: warnings.length ? warnings : undefined,
|
|
193
343
|
data: {
|
|
194
344
|
sections,
|
|
195
345
|
totalLive,
|
|
196
346
|
asOf: new Date().toISOString(),
|
|
347
|
+
...(dataNote ? { note: dataNote } : {}),
|
|
197
348
|
},
|
|
198
349
|
});
|
|
199
350
|
},
|