cito-mcp 0.2.3 → 0.2.5

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Standalone [MCP](https://modelcontextprotocol.io) server for the [Cito esports API](https://api.citoapi.com) — **curated outcome tools** for agents building esports apps, dashboards, bots, and research flows.
4
4
 
5
- **Version:** `0.2.3` · **Node:** `>=20` · **Install:** `npx cito-mcp`
5
+ **Version:** `0.2.4` · **Node:** `>=20` · **Install:** `npx cito-mcp`
6
6
 
7
7
  Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail REST stay available via `call_api`.
8
8
 
@@ -164,6 +164,17 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
164
164
  | `cod` | medium | Org slugs; CDL standings; UUID match ids |
165
165
  | `ufc` | medium | Fighter slugs; `boutId`; live + rankings |
166
166
 
167
+ ### UFC projection notes (0.2.4)
168
+
169
+ Hardened agent-facing UFC shapes (offline-tested):
170
+
171
+ - **`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
+ - **`live_matches`** uses `extractLiveRows`: prefer `liveBouts` (empty array = honest empty board); never promote supervisor `events[]` shells into match rows.
173
+ - **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.
174
+ - **`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 ?`.
175
+ - **`event_card`** bouts share the same `normalizeMatch` path.
176
+ - **`standings`** maps division champions to rank **`C`** (`rankText: "C"`, interim **`IC`**); contender `#1` stays numeric `1` — no dual numeric `#1`.
177
+
167
178
  Fortnite: **`call_api` only** until promoted into the primary enum.
168
179
 
169
180
  ### `call_api` allowlist
@@ -398,7 +409,7 @@ src/
398
409
 
399
410
  ## Publish notes
400
411
 
401
- Package: **`cito-mcp@0.2.2`**
412
+ Package: **`cito-mcp@0.2.4`**
402
413
 
403
414
  | Item | Value |
404
415
  | --- | --- |
@@ -433,7 +444,7 @@ claude mcp add cito -e CITO_API_KEY=cito_… -- npx cito-mcp
433
444
 
434
445
  ### Semver expectations
435
446
 
436
- - **0.2.0** is a **major surface break** vs 0.1 (tool rename + removal of OpenAPI mass-generation).
447
+ - **0.2.4** is a **major surface break** vs 0.1 (tool rename + removal of OpenAPI mass-generation).
437
448
  - Further 0.2.x patches may refine envelopes and composite quality without renaming the 15 tools.
438
449
  - Promoting Fortnite (or other titles) into the primary `game` enum would be a minor feature bump with catalog/docs updates.
439
450
 
@@ -441,7 +452,16 @@ claude mcp add cito -e CITO_API_KEY=cito_… -- npx cito-mcp
441
452
 
442
453
  ## Changelog (summary)
443
454
 
444
- ### 0.2.0
455
+ ### 0.2.4
456
+
457
+ - **UFC projection hardening** (offline-tested):
458
+ - `normalizeMatch`: `fighters[]` corners + profile, live red/blue, `boutId`/`dataId`; never keep `? vs ?` when names exist
459
+ - `extractRows` / `extractLiveRows`: prefer `liveBouts` (including empty); never fake match rows from supervisor `events[]`
460
+ - `upcoming_schedule`: client-side `hours`/`from`/`to`; bout expansion with fighter labels; event shells labeled by event name
461
+ - `event_card` bouts share the same normalize path
462
+ - `standings`: champion rank `C` / interim `IC`; no dual numeric `#1` for champ + contender
463
+
464
+ ### 0.2.4
445
465
 
446
466
  - **Breaking:** replaced OpenAPI mass-generated tools with **15 curated outcome tools**
447
467
  - Stable JSON envelope + MCP server instructions
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
  }
@@ -172,6 +186,42 @@ export function asRecord(value) {
172
186
  }
173
187
  return null;
174
188
  }
189
+ /**
190
+ * Peel Cito `{ success, data: T }` (and one nested `data`) so tools see the entity,
191
+ * not the envelope. Arrays and primitives pass through. Critical for UFC bouts/fighters
192
+ * where fighters[] / name live under data, not the top-level response.
193
+ */
194
+ export function unwrapPayload(value) {
195
+ let cur = value;
196
+ for (let depth = 0; depth < 3; depth += 1) {
197
+ const rec = asRecord(cur);
198
+ if (!rec)
199
+ return cur;
200
+ // Classic withMeta: { success: true, data: { ...entity } }
201
+ if ('data' in rec && rec.data != null && typeof rec.data === 'object' && !Array.isArray(rec.data)) {
202
+ const inner = rec.data;
203
+ // Prefer entity-shaped inner objects over list wrappers
204
+ const looksLikeEntity = 'id' in inner ||
205
+ 'slug' in inner ||
206
+ 'name' in inner ||
207
+ 'title' in inner ||
208
+ 'fighters' in inner ||
209
+ 'boutId' in inner ||
210
+ 'matchId' in inner ||
211
+ 'team1' in inner ||
212
+ 'red' in inner ||
213
+ 'blue' in inner ||
214
+ 'record' in inner ||
215
+ 'nickname' in inner;
216
+ if (looksLikeEntity || rec.success === true) {
217
+ cur = rec.data;
218
+ continue;
219
+ }
220
+ }
221
+ break;
222
+ }
223
+ return cur;
224
+ }
175
225
  export function pickString(...values) {
176
226
  for (const v of values) {
177
227
  if (typeof v === 'string' && v.length > 0)
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
- const PACKAGE_VERSION = '0.2.3';
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
- console.error('[cito-mcp] CITO_API_KEY is required.\n' +
25
- 'Set it to your Cito API key, e.g.:\n' +
26
- ' claude mcp add cito -e CITO_API_KEY=cito_... -- npx cito-mcp');
27
- process.exit(1);
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
- apiKey: API_KEY,
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 () => ({
@@ -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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * match_preview — pre-match briefing composite
3
3
  */
4
- import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
4
+ import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
6
  import { normalizeMatch } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
@@ -18,11 +18,15 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
18
18
  calls += 1;
19
19
  rateLimit = res.headers;
20
20
  if (res.ok) {
21
- const data = asRecord(res.data) ?? {};
21
+ const data = asRecord(unwrapPayload(res.data)) ?? {};
22
22
  entity = {
23
- id: pickString(data.id, side),
23
+ id: pickString(data.id, data.slug, side),
24
24
  slug: pickString(data.slug, side),
25
- name: pickString(data.name, side),
25
+ // Prefer real display name; never leave entity.name as the slug when name exists
26
+ name: pickString(data.name, data.displayName, data.nickname, side) ?? side,
27
+ nickname: pickString(data.nickname) ?? null,
28
+ division: pickString(data.division) ?? null,
29
+ recordText: pickString(data.recordText, asRecord(data.record)?.text) ?? null,
26
30
  game,
27
31
  };
28
32
  }
@@ -33,11 +37,33 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
33
37
  httpStatus: res.status,
34
38
  }));
35
39
  }
40
+ // Fight-by-fight form (not aggregate stats alone)
41
+ const fights = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}/fights`, {
42
+ query: { limit: recentLimit },
43
+ });
44
+ calls += 1;
45
+ rateLimit = { ...rateLimit, ...fights.headers };
46
+ if (fights.ok) {
47
+ recentForm = extractRows(unwrapPayload(fights.data) ?? fights.data)
48
+ .slice(0, recentLimit)
49
+ .map((row) => {
50
+ // history rows nest bout
51
+ const r = asRecord(row) ?? {};
52
+ const bout = asRecord(r.bout) ?? r;
53
+ return normalizeMatch('ufc', { ...bout, ...(r.fighterName ? {} : {}) }, 'completed');
54
+ });
55
+ }
36
56
  const stats = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}/stats`);
37
57
  calls += 1;
38
58
  rateLimit = { ...rateLimit, ...stats.headers };
39
- if (stats.ok)
40
- recentForm = [stats.data];
59
+ if (stats.ok && recentForm.length === 0) {
60
+ recentForm = []; // keep empty rather than stuffing aggregate as "matches"
61
+ keyPlayers = [];
62
+ }
63
+ if (stats.ok) {
64
+ // stash aggregate on entity for talking points
65
+ entity = { ...entity, stats: unwrapPayload(stats.data) };
66
+ }
41
67
  return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
42
68
  }
43
69
  if (game === 'lol') {
@@ -285,14 +311,19 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
285
311
  upstreamCalls += 1;
286
312
  rateLimit = res.headers;
287
313
  if (res.ok) {
288
- const m = normalizeMatch(game, res.data, 'upcoming');
314
+ const entity = unwrapPayload(res.data);
315
+ // Do not force "upcoming" — completed bouts (e.g. UFC 300) should keep completed.
316
+ const m = normalizeMatch(game, entity);
289
317
  context = {
290
- matchId: m.matchId,
318
+ matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
291
319
  startTime: m.startTime,
292
320
  event: m.event,
321
+ eventName: m.event?.name ?? null,
293
322
  league: m.league,
294
323
  status: m.status,
324
+ label: m.label,
295
325
  };
326
+ // Prefer stable slugs for follow-up fighter fetches
296
327
  if (!teamA)
297
328
  teamA = m.team1?.slug || m.team1?.id || m.team1?.name || '';
298
329
  if (!teamB)
@@ -366,6 +397,27 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
366
397
  if (res.ok)
367
398
  rows = extractRows(res.data);
368
399
  }
400
+ else if (game === 'ufc') {
401
+ // Fighter fight history is the H2H source of truth (not global /bouts page 1).
402
+ const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(teamA)}/fights`, {
403
+ query: { limit: 50 },
404
+ });
405
+ upstreamCalls += 1;
406
+ rateLimit = { ...rateLimit, ...hist.headers };
407
+ if (hist.ok) {
408
+ rows = extractRows(unwrapPayload(hist.data) ?? hist.data).map((row) => {
409
+ const r = asRecord(row) ?? {};
410
+ return asRecord(r.bout) ?? row;
411
+ });
412
+ }
413
+ else {
414
+ partial.push(partialFromRejection('h2h', {
415
+ code: mapHttpToCode(hist.status),
416
+ message: `fighter fights HTTP ${hist.status}`,
417
+ httpStatus: hist.status,
418
+ }));
419
+ }
420
+ }
369
421
  h2h = composeH2H(game, teamA, teamB, rows, 10);
370
422
  }
371
423
  catch (e) {
@@ -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
- for (const t of tracking) {
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
- for (const t of tracking) {
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 obj = asRecord(res.data);
152
- if (rows.length === 0 && Array.isArray(obj?.matches))
153
- rows = obj.matches;
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 event-supervisor rows mistaken for bouts (no bout id + no sides).
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
- if (m.matchId && m.matchId !== 'unknown' && (m.team1?.name || m.team2?.name))
162
- return true;
163
- if (m.team1?.name && m.team2?.name)
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
- // Keep if at least a real bout id even when fighters pending
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
  },