cito-mcp 0.4.4 → 0.4.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.
@@ -1,26 +1,15 @@
1
- /**
2
- * live_matches + upcoming_schedule
3
- */
4
1
  import { clampInt, decodeCursor, encodeCursor, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
5
2
  import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
3
  import { normalizeMatch, presentSides } from './normalize.js';
7
4
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
8
5
  const LIVE_PATHS = {
9
6
  lol: '/lol/live',
10
- cs2: '/cs2/live', // fixed: not /cs2/matches/live
7
+ cs2: '/cs2/live',
11
8
  dota2: '/dota2/matches/live',
12
9
  cod: '/cod/matches/live',
13
10
  ufc: '/ufc/live',
14
11
  tennis: '/tennis/matches/live',
15
12
  };
16
- /**
17
- * Extract live match/bout rows. UFC /ufc/live returns
18
- * { liveBouts, events, ... } — events are supervisor shells without fighters.
19
- * Prefer liveBouts (and nested tracking on events) over plain events[].
20
- * Never promote bare events[] to match rows (avoids fake "? vs ?").
21
- * Nested tracking with status armed is excluded from live items (use nextCard).
22
- * Exported for offline UFC projection tests.
23
- */
24
13
  export function extractLiveRows(data, game) {
25
14
  const root = asRecord(data);
26
15
  const payload = asRecord(root?.data) ?? root;
@@ -31,7 +20,6 @@ export function extractLiveRows(data, game) {
31
20
  for (const t of tracking) {
32
21
  const tr = asRecord(t) ?? {};
33
22
  const status = String(pickString(tr.status) ?? '').toLowerCase();
34
- // Board items = live|watching only; armed is surfaced as nextCard, not items.
35
23
  if (status === 'armed' || status === 'completed' || status === 'stale')
36
24
  continue;
37
25
  out.push({
@@ -41,12 +29,10 @@ export function extractLiveRows(data, game) {
41
29
  });
42
30
  }
43
31
  };
44
- // Prefer liveBouts when the key is present (including empty [] — honest empty board).
45
32
  if (Array.isArray(payload.liveBouts)) {
46
33
  const liveBouts = payload.liveBouts;
47
34
  if (liveBouts.length)
48
35
  return liveBouts;
49
- // Empty liveBouts: still check nested tracking, but never bare events[]
50
36
  const eventsWithEmptyLive = Array.isArray(payload.events)
51
37
  ? payload.events
52
38
  : [];
@@ -56,9 +42,8 @@ export function extractLiveRows(data, game) {
56
42
  const tracking = Array.isArray(er?.tracking) ? er.tracking : [];
57
43
  pushLiveTracking(tracking, er, nested);
58
44
  }
59
- return nested; // [] when only supervisor shells / armed-only
45
+ return nested;
60
46
  }
61
- // liveBouts key missing — try nested tracking under events
62
47
  const events = Array.isArray(payload.events) ? payload.events : [];
63
48
  const fromTracking = [];
64
49
  for (const ev of events) {
@@ -68,21 +53,15 @@ export function extractLiveRows(data, game) {
68
53
  }
69
54
  if (fromTracking.length)
70
55
  return fromTracking;
71
- // Last resort: empty live board rather than fake ? vs ? event shells
72
56
  return [];
73
57
  }
74
58
  return extractRows(data);
75
59
  }
76
- /** Machine emptyReason codes from API health / meta (or inferred). */
77
60
  export function inferUfcEmptyReason(events, health, liveCount) {
78
61
  if (liveCount > 0)
79
62
  return undefined;
80
63
  const pulses = events.map((ev) => asRecord(ev) ?? {});
81
64
  const hotNow = pulses.some((p) => ['live', 'warming', 'between_bouts', 'degraded'].includes(String(pickString(p.status, p.eventStatus) ?? '').toLowerCase()));
82
- // The API's own health block is trusted first, EXCEPT when it blames the
83
- // worker on a day nothing is live. It computes worker_stale from heartbeat
84
- // lag alone, so an idle Thursday reads the same as a dead worker on fight
85
- // night. With no hot card there is nothing to be stale about.
86
65
  const fromHealth = pickString(health?.emptyReason);
87
66
  if (fromHealth && !(['worker_stale', 'worker_down_inferred'].includes(fromHealth) && !hotNow)) {
88
67
  return fromHealth;
@@ -100,10 +79,6 @@ export function inferUfcEmptyReason(events, health, liveCount) {
100
79
  const freshest = lags.length ? Math.min(...lags) : null;
101
80
  const workerAlive = health?.workerAlive ?? health?.workerStarted;
102
81
  const hot = statuses.some((s) => ['live', 'warming', 'between_bouts', 'degraded'].includes(s));
103
- // Only a HOT card can be stale. On an idle day every event is "scheduled"
104
- // and the heartbeat lags because there is nothing to beat about; reporting
105
- // that as worker_stale made a quiet Thursday indistinguishable from a dead
106
- // worker on fight night. freshest stays computed for the health block.
107
82
  void freshest;
108
83
  if (workerAlive === false && hot)
109
84
  return 'worker_stale';
@@ -119,7 +94,6 @@ export function inferUfcEmptyReason(events, health, liveCount) {
119
94
  return 'between_bouts';
120
95
  return 'not_fight_night';
121
96
  }
122
- /** Human note for empty UFC live section — never invent matchups. */
123
97
  export function humanUfcEmptyNote(emptyReason, health) {
124
98
  const alive = health?.workerAlive ?? health?.workerStarted;
125
99
  const lag = typeof health?.freshestHeartbeatLagSeconds === 'number'
@@ -136,8 +110,6 @@ export function humanUfcEmptyNote(emptyReason, health) {
136
110
  supervisor_warming: 'Card open / pre-gate; no bout live yet',
137
111
  between_bouts: 'Between bouts; next may be armed',
138
112
  armed_only: 'Next bout armed; not started',
139
- // Customer-facing. The pm2/VPS operator hint used to be in here and went
140
- // out in every empty board; health{} already carries the numbers for us.
141
113
  worker_stale: 'Live feed is behind; scores may lag',
142
114
  worker_down_inferred: 'Live feed unavailable right now',
143
115
  event_degraded: 'Sources empty clock / event degraded; not inventing stats',
@@ -174,8 +146,6 @@ function slimUfcNextCard(payload) {
174
146
  status: pickString(first.status) ?? 'armed',
175
147
  boutId: pickString(first.boutId, first.id) ?? null,
176
148
  eventSlug: pickString(first.eventSlug) ?? null,
177
- fightMetricId: first.fightMetricId ?? null,
178
- // Names only if API already provided them — never invent.
179
149
  red: first.red ?? null,
180
150
  blue: first.blue ?? null,
181
151
  degradedReason: pickString(first.degradedReason) ?? null,
@@ -184,20 +154,20 @@ function slimUfcNextCard(payload) {
184
154
  }
185
155
  export const liveMatches = {
186
156
  name: 'live_matches',
187
- description: `Live matches board across primary games, or a single game filter. Normalized labels, scores, and matchIds.
188
-
189
- When to use:
190
- - "What's live right now?"
191
- - Ops/dashboard live strip
192
-
193
- Prefer over: sequential per-game call_api live probes.
194
-
195
- Do not use when: user wants upcoming fixtures → upcoming_schedule; historical results → match_summary.
196
-
197
- CS2 live path is /cs2/live; UFC is included in multi-game fan-out.
198
- UFC empty board: section.note + emptyReason + health (workerAlive/lag) + optional supervisor/nextCard (non-live); never fake match items from events[].
199
-
200
- Parallel-safe: yes. Upstream cost: 1–5 (allSettled).
157
+ description: `Live matches board across primary games, or a single game filter. Normalized labels, scores, and matchIds.
158
+
159
+ When to use:
160
+ - "What's live right now?"
161
+ - Ops/dashboard live strip
162
+
163
+ Prefer over: sequential per-game call_api live probes.
164
+
165
+ Do not use when: user wants upcoming fixtures → upcoming_schedule; historical results → match_summary.
166
+
167
+ CS2 live path is /cs2/live; UFC is included in multi-game fan-out.
168
+ UFC empty board: section.note + emptyReason + health (workerAlive/lag) + optional supervisor/nextCard (non-live); never fake match items from events[].
169
+
170
+ Parallel-safe: yes. Upstream cost: 1–5 (allSettled).
201
171
  Example: { "game": "all", "limitPerGame": 10 }`,
202
172
  inputSchema: {
203
173
  type: 'object',
@@ -260,7 +230,6 @@ Example: { "game": "all", "limitPerGame": 10 }`,
260
230
  });
261
231
  continue;
262
232
  }
263
- // Prefer real match/bout rows over supervisor event shells (UFC /ufc/live).
264
233
  let rows = extractLiveRows(res.data, game);
265
234
  const root = asRecord(res.data);
266
235
  const payload = asRecord(root?.data) ?? root;
@@ -270,9 +239,6 @@ Example: { "game": "all", "limitPerGame": 10 }`,
270
239
  const normalized = rows
271
240
  .slice(0, limitPerGame)
272
241
  .map((row) => normalizeMatch(game, row, 'live'))
273
- // Drop hollow UFC rows agents cannot use (placeholder labels / no sides).
274
- // normalizeMatch already enriches label from eventSlug/weight/bout id when
275
- // fighters are pending — keep those; drop pure "? vs ?" only.
276
242
  .filter((m) => {
277
243
  if (game !== 'ufc')
278
244
  return true;
@@ -281,7 +247,6 @@ Example: { "game": "all", "limitPerGame": 10 }`,
281
247
  return false;
282
248
  if (m.team1?.name || m.team2?.name)
283
249
  return true;
284
- // Identity-only live row (enriched Bout {id} / event name) is ok
285
250
  return Boolean(m.matchId && m.matchId !== 'unknown' && !String(m.matchId).startsWith('event'));
286
251
  });
287
252
  const items = labelsOnly
@@ -292,17 +257,10 @@ Example: { "game": "all", "limitPerGame": 10 }`,
292
257
  label: m.label,
293
258
  startTime: m.startTime,
294
259
  }))
295
- // Tennis has players, not teams: rename team1/team2 -> player1/player2
296
- // at this boundary only. Every other game is untouched.
297
260
  : normalized.map((m) => presentSides(m, m.game));
298
261
  if (game === 'ufc') {
299
262
  const health = asRecord(meta?.health) ?? asRecord(payload?.health);
300
263
  const events = Array.isArray(payload?.events) ? payload.events : [];
301
- // One decision point. This used to short-circuit on the API's own
302
- // emptyReason and only fall back to inferUfcEmptyReason when the API
303
- // said nothing, so the idle-day rule inside it never ran: the API
304
- // blames the worker on heartbeat lag alone and an empty Thursday came
305
- // back as worker_stale. Hand the hint in and let the function weigh it.
306
264
  const upstreamReason = pickString(meta?.emptyReason, health?.emptyReason);
307
265
  const emptyReason = normalized.length === 0
308
266
  ? inferUfcEmptyReason(events, { ...(health ?? {}), ...(upstreamReason ? { emptyReason: upstreamReason } : {}) }, normalized.length)
@@ -325,12 +283,6 @@ Example: { "game": "all", "limitPerGame": 10 }`,
325
283
  : undefined;
326
284
  const supervisor = normalized.length === 0 && events.length ? slimUfcSupervisorEvents(events) : undefined;
327
285
  const nextCard = normalized.length === 0 ? slimUfcNextCard(payload) : undefined;
328
- // A one-word answer to "is anything wrong?". health{} is the worker's
329
- // raw view and on an idle day it reads ok:false / workerAlive:false,
330
- // which looks broken. state says what the board means: idle (nothing
331
- // on, nothing due), live, stale (a hot card with a dead feed), or
332
- // degraded. nextEvent tells an idle board what comes next, fetched
333
- // only when the board is empty so a live night pays nothing for it.
334
286
  const state = normalized.length > 0 ? 'live'
335
287
  : emptyReason === 'worker_stale' || emptyReason === 'worker_down_inferred' ? 'stale'
336
288
  : emptyReason === 'event_degraded' ? 'degraded'
@@ -371,7 +323,6 @@ Example: { "game": "all", "limitPerGame": 10 }`,
371
323
  });
372
324
  }
373
325
  const totalLive = sections.reduce((sum, s) => sum + (s.ok ? s.count : 0), 0);
374
- // Top-level note when single-game UFC empty so agents don't need deep section walk.
375
326
  const ufcSection = sections.find((s) => s.game === 'ufc');
376
327
  const dataNote = games.length === 1 && games[0] === 'ufc' && ufcSection && ufcSection.count === 0
377
328
  ? ufcSection.note
@@ -396,24 +347,24 @@ Example: { "game": "all", "limitPerGame": 10 }`,
396
347
  };
397
348
  export const upcomingSchedule = {
398
349
  name: 'upcoming_schedule',
399
- description: `Upcoming matches/events for one game, with game-specific filters.
400
-
401
- When to use:
402
- - "What's on this week?"
403
- - Calendar UI; team next matches
404
-
405
- Prefer over: live_matches for not-yet-started fixtures.
406
-
407
- Do not use when: only in-progress matches needed → live_matches.
408
-
409
- Filter support (unsupported params are ignored with meta.warnings — do not assume filtering worked):
410
- - lol: hours, team (slug), league (slug)
411
- - cs2: team, from, to (ISO); hours not applied upstream
412
- - cod: team, tournamentId
413
- - dota2: limit/cursor primarily; team may be client-filtered where data allows
414
- - ufc: hours / from / to applied client-side after bout expansion (API has no hours); event shells labeled by event name; bouts use fighters[] corners
415
-
416
- Parallel-safe: yes. Upstream cost: 1–2.
350
+ description: `Upcoming matches/events for one game, with game-specific filters.
351
+
352
+ When to use:
353
+ - "What's on this week?"
354
+ - Calendar UI; team next matches
355
+
356
+ Prefer over: live_matches for not-yet-started fixtures.
357
+
358
+ Do not use when: only in-progress matches needed → live_matches.
359
+
360
+ Filter support (unsupported params are ignored with meta.warnings — do not assume filtering worked):
361
+ - lol: hours, team (slug), league (slug)
362
+ - cs2: team, from, to (ISO); hours not applied upstream
363
+ - cod: team, tournamentId
364
+ - dota2: limit/cursor primarily; team may be client-filtered where data allows
365
+ - ufc: hours / from / to applied client-side after bout expansion (API has no hours); event shells labeled by event name; bouts use fighters[] corners
366
+
367
+ Parallel-safe: yes. Upstream cost: 1–2.
417
368
  Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
418
369
  inputSchema: {
419
370
  type: 'object',
@@ -466,19 +417,10 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
466
417
  warnings.push(`Ignored filter "${param}": ${reason}`);
467
418
  };
468
419
  let path = '';
469
- // Set when this tool, not the API, is responsible for the team filter. It
470
- // also moves paging client-side, because an upstream offset would be
471
- // counted against rows we are about to discard.
472
420
  let clientTeamFilter = false;
473
- // Set when this tool, not the upstream, is responsible for paging.
474
421
  let clientDatePaging = false;
475
422
  let query = { limit, offset };
476
423
  lolBranch: if (game === 'lol') {
477
- // from/to means a date WINDOW, which /lol/schedule/upcoming cannot serve:
478
- // it only knows `hours` forward from now, so asking for last month
479
- // silently returned next week's fixtures with an "ignored filter"
480
- // warning. /lol/schedule does accept from/to, so route there instead and
481
- // let callers list completed matches.
482
424
  if (from || to) {
483
425
  path = '/lol/schedule';
484
426
  clientDatePaging = true;
@@ -496,17 +438,8 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
496
438
  break lolBranch;
497
439
  }
498
440
  path = '/lol/schedule/upcoming';
499
- // The upstream accepts teamSlug but does not apply it: asking for t1
500
- // returned Dplus KIA, Team WE and NightBirds. That is a silently wrong
501
- // answer, which is worse than an error, so the filter is enforced below.
502
- // Filtering client-side means the upstream offset would be counted
503
- // against unfiltered rows, so when a team is named we over-fetch and page
504
- // here instead.
505
441
  if (team)
506
442
  clientTeamFilter = true;
507
- // The upstream accepts `offset` and ignores it, so page two came back
508
- // byte for byte identical to page one while the cursor advanced in the
509
- // metadata. Over-fetch and slice here instead of trusting it.
510
443
  clientDatePaging = true;
511
444
  query = {
512
445
  hours: String(hours),
@@ -571,13 +504,11 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
571
504
  }
572
505
  else if (game === 'ufc') {
573
506
  path = '/ufc/events/upcoming';
574
- // Request more events than limit so client-side hours filter still has a card pool.
575
507
  query = {
576
508
  limit: Math.min(50, Math.max(limit * 3, limit)),
577
509
  page: Math.floor(offset / limit) + 1,
578
510
  includeBouts: true,
579
511
  };
580
- // hours / from / to applied client-side after expand (below) — do not warn as ignored.
581
512
  if (team)
582
513
  noteIgnored('team', 'UFC uses fighter filters via resolve/event_card, not team on schedule');
583
514
  if (league)
@@ -586,8 +517,6 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
586
517
  noteIgnored('tournamentId', 'pass event slug via event_card instead');
587
518
  }
588
519
  else if (game === 'tennis') {
589
- // Scheduled fixtures for ~3 days out; ids are stable across
590
- // upcoming -> live -> finished (same s365_{gid} id-space).
591
520
  path = '/tennis/matches/upcoming';
592
521
  query = { limit: Math.min(500, Math.max(limit, 1)) };
593
522
  if (league)
@@ -596,7 +525,6 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
596
525
  noteIgnored('tournamentId', 'tennis fixtures have no tournamentId filter');
597
526
  if (team)
598
527
  noteIgnored('team', 'tennis fixtures have no team filter; match player names client-side');
599
- // hours / from / to applied client-side after fetch (below) — not warned as ignored.
600
528
  }
601
529
  const res = await fetchJson(ctx, path, { query });
602
530
  if (!res.ok) {
@@ -613,7 +541,6 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
613
541
  });
614
542
  }
615
543
  let rows = extractRows(res.data);
616
- // UFC events → expand to bout rows (fighters[] shape) so labels are not "? vs ?"
617
544
  if (game === 'ufc') {
618
545
  const expanded = [];
619
546
  for (const event of rows) {
@@ -636,7 +563,6 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
636
563
  }
637
564
  }
638
565
  else {
639
- // Keep event shell as a schedule card with a real label (not fighter matchup)
640
566
  expanded.push({
641
567
  id: eventId ?? eventSlug,
642
568
  matchId: eventId ?? eventSlug,
@@ -652,7 +578,6 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
652
578
  }
653
579
  }
654
580
  rows = expanded;
655
- // Client-side hours / from / to window (API list has no hours param)
656
581
  const now = Date.now();
657
582
  const fromMs = from ? Date.parse(from) : now;
658
583
  const toMs = to
@@ -666,7 +591,7 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
666
591
  const r = asRecord(row) ?? {};
667
592
  const ts = pickString(r.startTime, r.date, r.startsAt, r.scheduledAt);
668
593
  if (!ts)
669
- return true; // keep undated rows rather than drop whole card
594
+ return true;
670
595
  const t = Date.parse(ts);
671
596
  if (!Number.isFinite(t))
672
597
  return true;
@@ -677,7 +602,6 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
677
602
  }
678
603
  }
679
604
  }
680
- // Tennis: client-side hours / from / to window (fixtures list has no time params)
681
605
  if (game === 'tennis' && (args.hours !== undefined || from || to)) {
682
606
  const now = Date.now();
683
607
  const fromMs = from ? Date.parse(from) : now;
@@ -688,7 +612,7 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
688
612
  const r = asRecord(row) ?? {};
689
613
  const ts = pickString(r.startTime, r.start_time, r.startsAt);
690
614
  if (!ts)
691
- return true; // keep undated rows rather than drop them
615
+ return true;
692
616
  const t = Date.parse(ts);
693
617
  if (!Number.isFinite(t))
694
618
  return true;
@@ -699,16 +623,10 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
699
623
  }
700
624
  }
701
625
  }
702
- // Client-side team filter when API ignored it
703
626
  let beforeTeamFilter = rows.length;
704
627
  if (team && (game === 'dota2' || game === 'cs2' || clientTeamFilter)) {
705
628
  beforeTeamFilter = rows.length;
706
629
  const t = team.toLowerCase();
707
- // Exact identity first. A joined-substring match made "t1" hit "T1
708
- // Esports Academy", so asking for T1 returned the academy's fixtures --
709
- // a different team, quietly. Substring is still useful for partial names
710
- // ("vitality"), so it stays as a fallback only when nothing matched
711
- // exactly.
712
630
  const sides = (row) => {
713
631
  const m = normalizeMatch(game, row, 'upcoming');
714
632
  return [m.team1, m.team2];
@@ -718,11 +636,6 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
718
636
  rows = exact;
719
637
  }
720
638
  else if (t.length <= 4) {
721
- // A short term is an identifier, not a search phrase, so it is exact or
722
- // nothing. T1 had no fixture in the window and the substring fallback
723
- // answered with "T1 Esports Academy" (slugs t1a and t1-challengers) --
724
- // a different team, presented as if it were the one asked for. An empty
725
- // list is the correct answer here.
726
639
  rows = [];
727
640
  }
728
641
  else {
@@ -738,8 +651,6 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
738
651
  const windowed = pageHere
739
652
  ? rows.slice(Number(offset), Number(offset) + Number(limit))
740
653
  : rows.slice(0, limit);
741
- // A date window can contain finished matches, so do not force "upcoming"
742
- // onto rows that already carry a real state.
743
654
  const items = windowed.map((row) => from || to ? normalizeMatch(game, row) : normalizeMatch(game, row, 'upcoming'));
744
655
  const obj = asRecord(res.data);
745
656
  const total = typeof obj?.total === 'number' ? obj.total : null;
@@ -761,8 +672,6 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
761
672
  nextCursor: hasMore ? encodeCursor({ offset: offset + limit }) : null,
762
673
  prevCursor: offset > 0 ? encodeCursor({ offset: Math.max(0, offset - limit) }) : null,
763
674
  },
764
- // Tennis has players, not teams: rename team1/team2 -> player1/player2
765
- // at this boundary only. Every other game is untouched.
766
675
  data: { items: items.map((m) => presentSides(m, game)) },
767
676
  });
768
677
  },