cito-mcp 0.1.0 → 0.2.2

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.
@@ -0,0 +1,447 @@
1
+ /**
2
+ * live_matches + upcoming_schedule
3
+ */
4
+ import { clampInt, decodeCursor, encodeCursor, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
5
+ import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
+ import { normalizeMatch } from './normalize.js';
7
+ import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
8
+ const LIVE_PATHS = {
9
+ lol: '/lol/live',
10
+ cs2: '/cs2/live', // fixed: not /cs2/matches/live
11
+ dota2: '/dota2/matches/live',
12
+ cod: '/cod/matches/live',
13
+ ufc: '/ufc/live',
14
+ };
15
+ /**
16
+ * Extract live match/bout rows. UFC /ufc/live returns
17
+ * { liveBouts, events, ... } — events are supervisor shells without fighters.
18
+ * Prefer liveBouts (and nested tracking on events) over plain events[].
19
+ */
20
+ function extractLiveRows(data, game) {
21
+ const root = asRecord(data);
22
+ const payload = asRecord(root?.data) ?? root;
23
+ if (!payload)
24
+ return extractRows(data);
25
+ if (game === 'ufc') {
26
+ const liveBouts = Array.isArray(payload.liveBouts) ? payload.liveBouts : [];
27
+ if (liveBouts.length)
28
+ return liveBouts;
29
+ // Some shapes nest tracking under events[].tracking
30
+ const events = Array.isArray(payload.events) ? payload.events : [];
31
+ const fromTracking = [];
32
+ for (const ev of events) {
33
+ const er = asRecord(ev);
34
+ const tracking = Array.isArray(er?.tracking) ? er.tracking : [];
35
+ for (const t of tracking) {
36
+ const tr = asRecord(t) ?? {};
37
+ fromTracking.push({
38
+ ...tr,
39
+ eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
40
+ eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
41
+ });
42
+ }
43
+ }
44
+ if (fromTracking.length)
45
+ return fromTracking;
46
+ // Last resort: empty live board rather than fake ? vs ? event shells
47
+ return [];
48
+ }
49
+ return extractRows(data);
50
+ }
51
+ export const liveMatches = {
52
+ name: 'live_matches',
53
+ description: `Live matches board across primary games, or a single game filter. Normalized labels, scores, and matchIds.
54
+
55
+ When to use:
56
+ - "What's live right now?"
57
+ - Ops/dashboard live strip
58
+
59
+ Prefer over: sequential per-game call_api live probes.
60
+
61
+ Do not use when: user wants upcoming fixtures → upcoming_schedule; historical results → match_summary.
62
+
63
+ CS2 live path is /cs2/live; UFC is included in multi-game fan-out.
64
+
65
+ Parallel-safe: yes. Upstream cost: 1–5 (allSettled).
66
+ Example: { "game": "all", "limitPerGame": 10 }`,
67
+ inputSchema: {
68
+ type: 'object',
69
+ additionalProperties: false,
70
+ properties: {
71
+ game: gameSchema({
72
+ description: 'Single game or all. Omit for all primary games. Example: "lol".',
73
+ }),
74
+ limitPerGame: limitSchema({
75
+ default: 10,
76
+ max: 25,
77
+ description: 'Max matches kept per game (default 10, max 25).',
78
+ }),
79
+ includeLabelsOnly: boolSchema('Token-light mode: shorter item payloads (label + matchId + status).', false),
80
+ },
81
+ },
82
+ handler: async (args, ctx) => {
83
+ const started = Date.now();
84
+ const requestId = newRequestId();
85
+ const gameParse = parseGame(args.game, { allowAll: true });
86
+ if (gameParse.error && args.game != null && args.game !== '') {
87
+ return errorEnvelope({
88
+ code: 'UNSUPPORTED_GAME',
89
+ message: gameParse.error,
90
+ game: null,
91
+ source: 'live_matches',
92
+ requestId,
93
+ tookMs: Date.now() - started,
94
+ });
95
+ }
96
+ const games = gameParse.game && isPrimaryGame(gameParse.game) ? [gameParse.game] : [...PRIMARY_GAMES];
97
+ const limitPerGame = clampInt(args.limitPerGame, 10, 1, 25);
98
+ const labelsOnly = args.includeLabelsOnly === true;
99
+ const partial = [];
100
+ let upstreamCalls = 0;
101
+ let rateLimit = {};
102
+ const settled = await Promise.all(games.map(async (game) => {
103
+ const path = LIVE_PATHS[game];
104
+ const res = await fetchJson(ctx, path);
105
+ return { game, path, res };
106
+ }));
107
+ const sections = [];
108
+ for (const { game, res } of settled) {
109
+ upstreamCalls += 1;
110
+ rateLimit = { ...rateLimit, ...res.headers };
111
+ if (!res.ok) {
112
+ const code = mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) });
113
+ partial.push(partialFromRejection(`game:${game}`, {
114
+ code,
115
+ message: `Live board unavailable for ${game} (HTTP ${res.status})`,
116
+ httpStatus: res.status,
117
+ }));
118
+ sections.push({
119
+ game,
120
+ count: 0,
121
+ ok: false,
122
+ note: `unavailable (HTTP ${res.status})`,
123
+ items: [],
124
+ });
125
+ continue;
126
+ }
127
+ // Prefer real match/bout rows over supervisor event shells (UFC /ufc/live).
128
+ let rows = extractLiveRows(res.data, game);
129
+ const obj = asRecord(res.data);
130
+ if (rows.length === 0 && Array.isArray(obj?.matches))
131
+ rows = obj.matches;
132
+ const normalized = rows
133
+ .slice(0, limitPerGame)
134
+ .map((row) => normalizeMatch(game, row, 'live'))
135
+ // Drop hollow UFC event-supervisor rows mistaken for bouts (no bout id + no sides).
136
+ .filter((m) => {
137
+ if (game !== 'ufc')
138
+ return true;
139
+ if (m.matchId && m.matchId !== 'unknown' && (m.team1?.name || m.team2?.name))
140
+ return true;
141
+ if (m.team1?.name && m.team2?.name)
142
+ return true;
143
+ // Keep if at least a real bout id even when fighters pending
144
+ return Boolean(m.matchId && m.matchId !== 'unknown' && !String(m.matchId).startsWith('event'));
145
+ });
146
+ const items = labelsOnly
147
+ ? normalized.map((m) => ({
148
+ game: m.game,
149
+ matchId: m.matchId,
150
+ status: m.status,
151
+ label: m.label,
152
+ startTime: m.startTime,
153
+ }))
154
+ : normalized;
155
+ sections.push({
156
+ game,
157
+ count: normalized.length,
158
+ ok: true,
159
+ items,
160
+ });
161
+ }
162
+ const totalLive = sections.reduce((sum, s) => sum + (s.ok ? s.count : 0), 0);
163
+ return successEnvelope({
164
+ source: 'live_matches',
165
+ game: games.length === 1 ? games[0] : null,
166
+ requestId,
167
+ tookMs: Date.now() - started,
168
+ upstreamCalls,
169
+ rateLimit,
170
+ partial: partial.length ? partial : undefined,
171
+ data: {
172
+ sections,
173
+ totalLive,
174
+ asOf: new Date().toISOString(),
175
+ },
176
+ });
177
+ },
178
+ };
179
+ export const upcomingSchedule = {
180
+ name: 'upcoming_schedule',
181
+ description: `Upcoming matches/events for one game, with game-specific filters.
182
+
183
+ When to use:
184
+ - "What's on this week?"
185
+ - Calendar UI; team next matches
186
+
187
+ Prefer over: live_matches for not-yet-started fixtures.
188
+
189
+ Do not use when: only in-progress matches needed → live_matches.
190
+
191
+ Filter support (unsupported params are ignored with meta.warnings — do not assume filtering worked):
192
+ - lol: hours, team (slug), league (slug)
193
+ - cs2: team, from, to (ISO); hours not applied upstream
194
+ - cod: team, tournamentId
195
+ - dota2 / ufc: limit/cursor primarily; team may be client-filtered where data allows
196
+
197
+ Parallel-safe: yes. Upstream cost: 1–2.
198
+ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
199
+ inputSchema: {
200
+ type: 'object',
201
+ additionalProperties: false,
202
+ required: ['game'],
203
+ properties: {
204
+ game: gameSchema({ allowAll: false, required: true }),
205
+ hours: {
206
+ type: 'integer',
207
+ minimum: 1,
208
+ maximum: 336,
209
+ default: 72,
210
+ description: 'Horizon in hours when from/to not set (default 72). Example: 48.',
211
+ },
212
+ from: stringSchema('ISO-8601 start bound.', '2026-07-27T00:00:00.000Z'),
213
+ to: stringSchema('ISO-8601 end bound.'),
214
+ team: stringSchema('Team id or slug filter.', 't1'),
215
+ league: stringSchema('League id or slug filter.', 'lck'),
216
+ tournamentId: stringSchema('Tournament id filter.'),
217
+ limit: limitSchema(),
218
+ cursor: stringSchema('Opaque cursor from pagination.nextCursor only.'),
219
+ },
220
+ },
221
+ handler: async (args, ctx) => {
222
+ const started = Date.now();
223
+ const requestId = newRequestId();
224
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
225
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
226
+ return errorEnvelope({
227
+ code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
228
+ message: gameParse.error ?? 'game is required',
229
+ game: null,
230
+ source: 'upcoming_schedule',
231
+ requestId,
232
+ tookMs: Date.now() - started,
233
+ });
234
+ }
235
+ const game = gameParse.game;
236
+ const limit = clampInt(args.limit, DEFAULT_PAGE_LIMIT, 1, MAX_PAGE_LIMIT);
237
+ const hours = clampInt(args.hours, 72, 1, 336);
238
+ const cursor = decodeCursor(typeof args.cursor === 'string' ? args.cursor : null);
239
+ const offset = typeof cursor?.offset === 'number' ? cursor.offset : 0;
240
+ const team = typeof args.team === 'string' ? args.team : undefined;
241
+ const league = typeof args.league === 'string' ? args.league : undefined;
242
+ const tournamentId = typeof args.tournamentId === 'string' ? args.tournamentId : undefined;
243
+ const from = typeof args.from === 'string' ? args.from : undefined;
244
+ const to = typeof args.to === 'string' ? args.to : undefined;
245
+ const warnings = [];
246
+ const noteIgnored = (param, reason) => {
247
+ warnings.push(`Ignored filter "${param}": ${reason}`);
248
+ };
249
+ let path = '';
250
+ let query = { limit, offset };
251
+ if (game === 'lol') {
252
+ path = '/lol/schedule/upcoming';
253
+ query = {
254
+ hours: String(hours),
255
+ limit: String(limit),
256
+ offset: String(offset),
257
+ ...(league ? { leagueSlug: league } : {}),
258
+ ...(team ? { teamSlug: team } : {}),
259
+ };
260
+ if (from)
261
+ noteIgnored('from', 'LoL upcoming uses hours/team/league, not from');
262
+ if (to)
263
+ noteIgnored('to', 'LoL upcoming uses hours/team/league, not to');
264
+ if (tournamentId)
265
+ noteIgnored('tournamentId', 'use league filter or standings for LoL tournaments');
266
+ }
267
+ else if (game === 'cs2') {
268
+ path = '/cs2/matches';
269
+ query = {
270
+ status: 'upcoming',
271
+ limit,
272
+ page: Math.floor(offset / limit) + 1,
273
+ ...(team ? { team } : {}),
274
+ ...(from ? { from } : {}),
275
+ ...(to ? { to } : {}),
276
+ };
277
+ if (args.hours !== undefined)
278
+ noteIgnored('hours', 'CS2 uses from/to or unfiltered upcoming; hours is not applied upstream');
279
+ if (league)
280
+ noteIgnored('league', 'CS2 schedule does not accept league here — filter client-side or use event tools');
281
+ if (tournamentId)
282
+ noteIgnored('tournamentId', 'CS2 uses eventId via event_card / call_api, not this tool');
283
+ }
284
+ else if (game === 'dota2') {
285
+ path = '/dota2/matches/upcoming';
286
+ query = { limit, offset };
287
+ if (args.hours !== undefined)
288
+ noteIgnored('hours', 'Dota upcoming does not accept hours');
289
+ if (league)
290
+ noteIgnored('league', 'Dota upcoming does not accept league');
291
+ if (tournamentId)
292
+ noteIgnored('tournamentId', 'Dota upcoming does not accept tournamentId');
293
+ if (from)
294
+ noteIgnored('from', 'Dota upcoming does not accept from');
295
+ if (to)
296
+ noteIgnored('to', 'Dota upcoming does not accept to');
297
+ }
298
+ else if (game === 'cod') {
299
+ path = '/cod/matches/upcoming';
300
+ query = {
301
+ limit,
302
+ offset,
303
+ ...(team ? { team } : {}),
304
+ ...(tournamentId ? { tournamentId } : {}),
305
+ };
306
+ if (args.hours !== undefined)
307
+ noteIgnored('hours', 'COD upcoming does not accept hours');
308
+ if (league)
309
+ noteIgnored('league', 'COD uses tournamentId, not league');
310
+ if (from)
311
+ noteIgnored('from', 'COD upcoming does not accept from');
312
+ if (to)
313
+ noteIgnored('to', 'COD upcoming does not accept to');
314
+ }
315
+ else if (game === 'ufc') {
316
+ path = '/ufc/events/upcoming';
317
+ // Request more events than limit so client-side hours filter still has a card pool.
318
+ query = {
319
+ limit: Math.min(50, Math.max(limit * 3, limit)),
320
+ page: Math.floor(offset / limit) + 1,
321
+ includeBouts: true,
322
+ };
323
+ // hours / from / to applied client-side after expand (below) — do not warn as ignored.
324
+ if (team)
325
+ noteIgnored('team', 'UFC uses fighter filters via resolve/event_card, not team on schedule');
326
+ if (league)
327
+ noteIgnored('league', 'UFC has no league filter on upcoming events');
328
+ if (tournamentId)
329
+ noteIgnored('tournamentId', 'pass event slug via event_card instead');
330
+ }
331
+ const res = await fetchJson(ctx, path, { query });
332
+ if (!res.ok) {
333
+ return errorEnvelope({
334
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
335
+ message: `Upcoming schedule failed for ${game} (HTTP ${res.status})`,
336
+ game,
337
+ source: 'upcoming_schedule',
338
+ requestId,
339
+ tookMs: Date.now() - started,
340
+ upstreamCalls: 1,
341
+ rateLimit: res.headers,
342
+ httpStatus: res.status,
343
+ });
344
+ }
345
+ let rows = extractRows(res.data);
346
+ // UFC events → expand to bout rows (fighters[] shape) so labels are not "? vs ?"
347
+ if (game === 'ufc') {
348
+ const expanded = [];
349
+ for (const event of rows) {
350
+ const er = asRecord(event) ?? {};
351
+ const eventName = pickString(er.name, er.title);
352
+ const eventId = pickString(er.id, er.slug);
353
+ const eventSlug = pickString(er.slug);
354
+ const eventStart = pickString(er.startTime, er.date, er.startsAt);
355
+ const bouts = extractRows(er.bouts ?? er.fights);
356
+ if (bouts.length) {
357
+ for (const bout of bouts) {
358
+ const br = asRecord(bout) ?? {};
359
+ expanded.push({
360
+ ...br,
361
+ eventName,
362
+ eventId,
363
+ eventSlug,
364
+ startTime: pickString(br.startTime, br.date, eventStart),
365
+ });
366
+ }
367
+ }
368
+ else {
369
+ // Keep event shell as a schedule card with a real label (not fighter matchup)
370
+ expanded.push({
371
+ id: eventId ?? eventSlug,
372
+ matchId: eventId ?? eventSlug,
373
+ name: eventName,
374
+ title: eventName,
375
+ label: eventName ?? eventSlug ?? 'UFC event',
376
+ startTime: eventStart,
377
+ eventName,
378
+ eventId,
379
+ eventSlug,
380
+ status: pickString(er.status) ?? 'upcoming',
381
+ });
382
+ }
383
+ }
384
+ rows = expanded;
385
+ // Client-side hours / from / to window (API list has no hours param)
386
+ const now = Date.now();
387
+ const fromMs = from ? Date.parse(from) : now;
388
+ const toMs = to
389
+ ? Date.parse(to)
390
+ : args.hours !== undefined || !from
391
+ ? now + hours * 3600_000
392
+ : Number.POSITIVE_INFINITY;
393
+ if (Number.isFinite(fromMs) && Number.isFinite(toMs)) {
394
+ const before = rows.length;
395
+ rows = rows.filter((row) => {
396
+ const r = asRecord(row) ?? {};
397
+ const ts = pickString(r.startTime, r.date, r.startsAt, r.scheduledAt);
398
+ if (!ts)
399
+ return true; // keep undated rows rather than drop whole card
400
+ const t = Date.parse(ts);
401
+ if (!Number.isFinite(t))
402
+ return true;
403
+ return t >= fromMs && t <= toMs;
404
+ });
405
+ if (before > 0 && rows.length === 0) {
406
+ warnings.push(`hours/from/to window matched 0 of ${before} UFC rows — widen hours or omit time filters`);
407
+ }
408
+ }
409
+ }
410
+ // Client-side team filter when API ignored it
411
+ if (team && (game === 'dota2' || game === 'cs2')) {
412
+ const t = team.toLowerCase();
413
+ rows = rows.filter((row) => {
414
+ const m = normalizeMatch(game, row, 'upcoming');
415
+ const hay = [m.team1?.id, m.team1?.slug, m.team1?.name, m.team2?.id, m.team2?.slug, m.team2?.name]
416
+ .filter(Boolean)
417
+ .join(' ')
418
+ .toLowerCase();
419
+ return hay.includes(t);
420
+ });
421
+ }
422
+ const items = rows.slice(0, limit).map((row) => normalizeMatch(game, row, 'upcoming'));
423
+ const obj = asRecord(res.data);
424
+ const total = typeof obj?.total === 'number' ? obj.total : null;
425
+ const hasMore = (typeof obj?.hasMore === 'boolean' ? obj.hasMore : items.length >= limit) ||
426
+ (total != null && offset + items.length < total);
427
+ return successEnvelope({
428
+ source: 'upcoming_schedule',
429
+ game,
430
+ requestId,
431
+ tookMs: Date.now() - started,
432
+ upstreamCalls: 1,
433
+ rateLimit: res.headers,
434
+ warnings: warnings.length ? warnings : undefined,
435
+ pagination: {
436
+ limit,
437
+ offset,
438
+ total,
439
+ hasMore,
440
+ nextCursor: hasMore ? encodeCursor({ offset: offset + limit }) : null,
441
+ prevCursor: offset > 0 ? encodeCursor({ offset: Math.max(0, offset - limit) }) : null,
442
+ },
443
+ data: { items },
444
+ });
445
+ },
446
+ };
447
+ export const liveTools = [liveMatches, upcomingSchedule];