cito-mcp 0.3.10 → 0.3.12
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/dist/tools/live.js +36 -5
- package/dist/tools/match.js +138 -3
- package/dist/tools/team.js +27 -2
- package/package.json +1 -1
package/dist/tools/live.js
CHANGED
|
@@ -426,8 +426,31 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
426
426
|
// also moves paging client-side, because an upstream offset would be
|
|
427
427
|
// counted against rows we are about to discard.
|
|
428
428
|
let clientTeamFilter = false;
|
|
429
|
+
// Set when this tool, not the upstream, is responsible for paging.
|
|
430
|
+
let clientDatePaging = false;
|
|
429
431
|
let query = { limit, offset };
|
|
430
|
-
if (game === 'lol') {
|
|
432
|
+
lolBranch: if (game === 'lol') {
|
|
433
|
+
// from/to means a date WINDOW, which /lol/schedule/upcoming cannot serve:
|
|
434
|
+
// it only knows `hours` forward from now, so asking for last month
|
|
435
|
+
// silently returned next week's fixtures with an "ignored filter"
|
|
436
|
+
// warning. /lol/schedule does accept from/to, so route there instead and
|
|
437
|
+
// let callers list completed matches.
|
|
438
|
+
if (from || to) {
|
|
439
|
+
path = '/lol/schedule';
|
|
440
|
+
clientDatePaging = true;
|
|
441
|
+
query = {
|
|
442
|
+
limit: String(Math.min(200, (offset + limit) * 4)),
|
|
443
|
+
...(from ? { from } : {}),
|
|
444
|
+
...(to ? { to } : {}),
|
|
445
|
+
...(league ? { leagueSlug: league } : {}),
|
|
446
|
+
...(team ? { teamSlug: team } : {}),
|
|
447
|
+
};
|
|
448
|
+
if (team)
|
|
449
|
+
clientTeamFilter = true;
|
|
450
|
+
if (tournamentId)
|
|
451
|
+
noteIgnored('tournamentId', 'use league filter or standings for LoL tournaments');
|
|
452
|
+
break lolBranch;
|
|
453
|
+
}
|
|
431
454
|
path = '/lol/schedule/upcoming';
|
|
432
455
|
// The upstream accepts teamSlug but does not apply it: asking for t1
|
|
433
456
|
// returned Dplus KIA, Team WE and NightBirds. That is a silently wrong
|
|
@@ -437,10 +460,13 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
437
460
|
// here instead.
|
|
438
461
|
if (team)
|
|
439
462
|
clientTeamFilter = true;
|
|
463
|
+
// The upstream accepts `offset` and ignores it, so page two came back
|
|
464
|
+
// byte for byte identical to page one while the cursor advanced in the
|
|
465
|
+
// metadata. Over-fetch and slice here instead of trusting it.
|
|
466
|
+
clientDatePaging = true;
|
|
440
467
|
query = {
|
|
441
468
|
hours: String(hours),
|
|
442
|
-
limit: String(
|
|
443
|
-
...(clientTeamFilter ? {} : { offset: String(offset) }),
|
|
469
|
+
limit: String(Math.min(200, (offset + limit) * 4)),
|
|
444
470
|
...(league ? { leagueSlug: league } : {}),
|
|
445
471
|
...(team ? { teamSlug: team } : {}),
|
|
446
472
|
};
|
|
@@ -664,8 +690,13 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
664
690
|
if (team && beforeTeamFilter > 0 && rows.length === 0) {
|
|
665
691
|
warnings.push(`team "${team}" matched 0 of ${beforeTeamFilter} upcoming fixtures in this window — widen hours or check the slug`);
|
|
666
692
|
}
|
|
667
|
-
const
|
|
668
|
-
|
|
693
|
+
const pageHere = clientTeamFilter || clientDatePaging;
|
|
694
|
+
const windowed = pageHere
|
|
695
|
+
? rows.slice(Number(offset), Number(offset) + Number(limit))
|
|
696
|
+
: rows.slice(0, limit);
|
|
697
|
+
// A date window can contain finished matches, so do not force "upcoming"
|
|
698
|
+
// onto rows that already carry a real state.
|
|
699
|
+
const items = windowed.map((row) => from || to ? normalizeMatch(game, row) : normalizeMatch(game, row, 'upcoming'));
|
|
669
700
|
const obj = asRecord(res.data);
|
|
670
701
|
const total = typeof obj?.total === 'number' ? obj.total : null;
|
|
671
702
|
const hasMore = (typeof obj?.hasMore === 'boolean' ? obj.hasMore : items.length >= limit) ||
|
package/dist/tools/match.js
CHANGED
|
@@ -389,6 +389,102 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
389
389
|
});
|
|
390
390
|
},
|
|
391
391
|
};
|
|
392
|
+
/**
|
|
393
|
+
* Fold a UFC bout's raw odds payload into something an agent can read.
|
|
394
|
+
*
|
|
395
|
+
* The REST response is deliberately complete: every bookmaker, every market, up
|
|
396
|
+
* to roughly 950 outcomes for a single bout. Handing that back whole would bury
|
|
397
|
+
* the two numbers a caller almost always wants, and the server's own
|
|
398
|
+
* instructions say not to flood odds. So the moneyline is summarised per fighter
|
|
399
|
+
* and every other market is reduced to a count, with the raw endpoint named so
|
|
400
|
+
* anyone who genuinely needs the full book can go and get it.
|
|
401
|
+
*
|
|
402
|
+
* isAvailable is carried through as currentlyOffered rather than filtered on. It
|
|
403
|
+
* means "a book is offering this right now", so a finished fight's closing line
|
|
404
|
+
* comes back with it false, and that is information rather than absence.
|
|
405
|
+
* Filtering here would recreate the bug where whole settled cards looked like
|
|
406
|
+
* they had no odds at all.
|
|
407
|
+
*/
|
|
408
|
+
export function summarizeUfcOdds(data) {
|
|
409
|
+
// fetchJson hands back the whole REST envelope ({ success, data, meta }), not
|
|
410
|
+
// the inner payload, so unwrap one level when it is there. Accepting both
|
|
411
|
+
// shapes because the unit tests feed the inner object directly; reading only
|
|
412
|
+
// the outer one returned a perfectly shaped, perfectly empty summary, which
|
|
413
|
+
// looked like "this bout has no odds" rather than like a bug.
|
|
414
|
+
const root = (data ?? {});
|
|
415
|
+
const payload = (root.data && typeof root.data === 'object' && !Array.isArray(root.data)
|
|
416
|
+
? root.data
|
|
417
|
+
: root);
|
|
418
|
+
const markets = Array.isArray(payload.markets) ? payload.markets : [];
|
|
419
|
+
const byFighter = new Map();
|
|
420
|
+
const otherMarkets = new Map();
|
|
421
|
+
let observedAt = null;
|
|
422
|
+
for (const market of markets) {
|
|
423
|
+
const outcomes = Array.isArray(market.outcomes) ? market.outcomes : [];
|
|
424
|
+
for (const outcome of outcomes) {
|
|
425
|
+
const observed = typeof outcome.observedAt === 'string' ? outcome.observedAt : null;
|
|
426
|
+
if (observed && (!observedAt || observed > observedAt))
|
|
427
|
+
observedAt = observed;
|
|
428
|
+
const fighter = (outcome.fighter ?? {});
|
|
429
|
+
const bookmaker = (outcome.bookmaker ?? {});
|
|
430
|
+
const slug = typeof fighter.slug === 'string' ? fighter.slug : null;
|
|
431
|
+
const type = typeof outcome.marketType === 'string' ? outcome.marketType : 'unknown';
|
|
432
|
+
const book = typeof bookmaker.slug === 'string' ? bookmaker.slug : 'unknown';
|
|
433
|
+
if (type === 'moneyline' && slug) {
|
|
434
|
+
const entry = byFighter.get(slug) ?? {
|
|
435
|
+
slug,
|
|
436
|
+
name: typeof fighter.name === 'string' ? fighter.name : null,
|
|
437
|
+
side: typeof outcome.side === 'string' ? outcome.side : null,
|
|
438
|
+
prices: [],
|
|
439
|
+
books: new Set(),
|
|
440
|
+
live: false,
|
|
441
|
+
};
|
|
442
|
+
if (typeof outcome.moneyline === 'number')
|
|
443
|
+
entry.prices.push(outcome.moneyline);
|
|
444
|
+
entry.books.add(book);
|
|
445
|
+
if (outcome.isAvailable === true)
|
|
446
|
+
entry.live = true;
|
|
447
|
+
byFighter.set(slug, entry);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const other = otherMarkets.get(type) ?? { outcomes: 0, books: new Set() };
|
|
451
|
+
other.outcomes += 1;
|
|
452
|
+
other.books.add(book);
|
|
453
|
+
otherMarkets.set(type, other);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// American odds: the best price is the highest number both for an underdog
|
|
457
|
+
// (+250 beats +180) and for a favourite (-110 beats -200), so it is simply the
|
|
458
|
+
// maximum in both cases.
|
|
459
|
+
const moneyline = [...byFighter.values()].map((entry) => {
|
|
460
|
+
const sorted = [...entry.prices].sort((a, b) => a - b);
|
|
461
|
+
const mid = sorted.length
|
|
462
|
+
? sorted.length % 2
|
|
463
|
+
? sorted[(sorted.length - 1) / 2]
|
|
464
|
+
: Math.round((sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2)
|
|
465
|
+
: null;
|
|
466
|
+
return {
|
|
467
|
+
fighter: { slug: entry.slug, name: entry.name },
|
|
468
|
+
side: entry.side,
|
|
469
|
+
bookmakers: entry.books.size,
|
|
470
|
+
bestAmerican: sorted.length ? sorted[sorted.length - 1] : null,
|
|
471
|
+
medianAmerican: mid,
|
|
472
|
+
impliedProbability: mid === null ? null : Number((mid > 0 ? 100 / (mid + 100) : -mid / (-mid + 100)).toFixed(4)),
|
|
473
|
+
currentlyOffered: entry.live,
|
|
474
|
+
};
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
moneyline,
|
|
478
|
+
otherMarkets: [...otherMarkets.entries()]
|
|
479
|
+
.map(([type, value]) => ({ type, outcomes: value.outcomes, bookmakers: value.books.size }))
|
|
480
|
+
.sort((a, b) => b.outcomes - a.outcomes),
|
|
481
|
+
observedAt,
|
|
482
|
+
notes: moneyline.some((row) => !row.currentlyOffered)
|
|
483
|
+
? 'currentlyOffered=false means the market has closed; these are closing lines, not live prices.'
|
|
484
|
+
: undefined,
|
|
485
|
+
fullBookPath: '/ufc/bouts/{boutId}/odds',
|
|
486
|
+
};
|
|
487
|
+
}
|
|
392
488
|
export const matchDetails = {
|
|
393
489
|
name: 'match_details',
|
|
394
490
|
description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
|
|
@@ -404,6 +500,7 @@ Prefer match_summary for short answers and default cards.
|
|
|
404
500
|
Do not use when: first-pass live board (use live_matches + match_summary).
|
|
405
501
|
|
|
406
502
|
Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
|
|
503
|
+
UFC betting lines: sections:["odds"] (opt-in, never in the default set).
|
|
407
504
|
If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
|
|
408
505
|
|
|
409
506
|
Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
|
|
@@ -420,9 +517,9 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
420
517
|
type: 'array',
|
|
421
518
|
items: {
|
|
422
519
|
type: 'string',
|
|
423
|
-
enum: ['base', 'playerStats', 'gamesOrMaps', 'timeline', 'liveState', 'media', 'advanced'],
|
|
520
|
+
enum: ['base', 'playerStats', 'gamesOrMaps', 'timeline', 'liveState', 'media', 'advanced', 'odds'],
|
|
424
521
|
},
|
|
425
|
-
description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media.',
|
|
522
|
+
description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" (UFC) is opt-in: moneyline summarised per fighter with bookmaker count, best and median American price and implied probability, plus a count of every other market. Closing lines for a finished fight come back with currentlyOffered=false rather than being omitted.',
|
|
426
523
|
},
|
|
427
524
|
includeTimeline: boolSchema('Include timeline section (heavy).', false),
|
|
428
525
|
includeLiveState: boolSchema('Include live state/snapshots.', false),
|
|
@@ -476,12 +573,17 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
476
573
|
base: null,
|
|
477
574
|
playerStats: null,
|
|
478
575
|
gamesOrMaps: null,
|
|
576
|
+
odds: null,
|
|
479
577
|
timeline: null,
|
|
480
578
|
liveState: null,
|
|
481
579
|
media: null,
|
|
482
580
|
advanced: null,
|
|
483
581
|
};
|
|
484
|
-
// base is
|
|
582
|
+
// base is ALWAYS fetched, whatever sections were asked for. Requesting
|
|
583
|
+
// sections:["timeline"] returned match:null, so a caller got a timeline with
|
|
584
|
+
// nothing to attach it to and no way to tell which match it belonged to.
|
|
585
|
+
// The header is the identity of the response, not an optional section.
|
|
586
|
+
wanted.add('base');
|
|
485
587
|
if (wanted.has('base')) {
|
|
486
588
|
const res = await getSection(ctx, primaryPath(game, matchId));
|
|
487
589
|
upstreamCalls += 1;
|
|
@@ -520,6 +622,28 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
520
622
|
}
|
|
521
623
|
})());
|
|
522
624
|
};
|
|
625
|
+
// Same as load(), but folds the payload through a shaper first. Raw odds are
|
|
626
|
+
// far too big to hand back whole: one bout carries up to ~950 outcomes across
|
|
627
|
+
// every bookmaker and prop market, which would bury the answer it was fetched
|
|
628
|
+
// to give.
|
|
629
|
+
const loadWith = (section, path, shape, query) => {
|
|
630
|
+
tasks.push((async () => {
|
|
631
|
+
const res = await getSection(ctx, path, query);
|
|
632
|
+
upstreamCalls += 1;
|
|
633
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
634
|
+
if (!res.ok) {
|
|
635
|
+
partial.push(partialFromRejection(section, {
|
|
636
|
+
code: mapHttpToCode(res.status),
|
|
637
|
+
message: `${section} HTTP ${res.status}`,
|
|
638
|
+
httpStatus: res.status,
|
|
639
|
+
}));
|
|
640
|
+
sections[section] = null;
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
sections[section] = shape(res.data);
|
|
644
|
+
}
|
|
645
|
+
})());
|
|
646
|
+
};
|
|
523
647
|
if (wanted.has('playerStats')) {
|
|
524
648
|
if (game === 'lol')
|
|
525
649
|
load('playerStats', `/lol/matches/${encodeURIComponent(matchId)}/player-stats`);
|
|
@@ -573,6 +697,17 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
573
697
|
}));
|
|
574
698
|
}
|
|
575
699
|
}
|
|
700
|
+
if (wanted.has('odds')) {
|
|
701
|
+
if (game === 'ufc') {
|
|
702
|
+
loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
partial.push(partialFromRejection('odds', {
|
|
706
|
+
code: 'NOT_IMPLEMENTED',
|
|
707
|
+
message: `Odds not curated for ${game}; UFC only today`,
|
|
708
|
+
}));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
576
711
|
if (wanted.has('media')) {
|
|
577
712
|
if (game === 'cs2')
|
|
578
713
|
load('media', `/cs2/matches/${encodeURIComponent(matchId)}/demos`);
|
package/dist/tools/team.js
CHANGED
|
@@ -8,8 +8,15 @@ import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSc
|
|
|
8
8
|
function teamIdentity(game, raw, idHint, slugHint) {
|
|
9
9
|
const r = asRecord(raw) ?? {};
|
|
10
10
|
const nested = asRecord(r.data) ?? r;
|
|
11
|
-
const id = pickString(nested.id, nested.teamId, idHint, slugHint) ?? 'unknown';
|
|
12
11
|
const slug = pickString(nested.slug, nested.orgSlug, slugHint);
|
|
12
|
+
// When the caller asks for an alias the API resolves it, and the resolved
|
|
13
|
+
// slug is the truth. Falling back to the requested value for `id` produced
|
|
14
|
+
// id:"t1a" beside slug:"t1-challengers", a row that disagreed with itself.
|
|
15
|
+
const resolved = pickString(nested.slug, nested.orgSlug);
|
|
16
|
+
const id = pickString(nested.id, nested.teamId) ??
|
|
17
|
+
resolved ??
|
|
18
|
+
pickString(idHint, slugHint) ??
|
|
19
|
+
'unknown';
|
|
13
20
|
const name = pickString(nested.name, nested.tag, nested.code, slug, id) ?? id;
|
|
14
21
|
return {
|
|
15
22
|
id,
|
|
@@ -301,8 +308,26 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
|
301
308
|
const t = Date.parse(String(row?.startTime ?? ''));
|
|
302
309
|
return Number.isFinite(t) ? t : -Infinity;
|
|
303
310
|
};
|
|
311
|
+
// RECENT means finished AND in the past. The index also carries
|
|
312
|
+
// scheduled fixtures, and this path forced every row to
|
|
313
|
+
// "completed", so Gen.G and HLE both listed their 2026-09-05
|
|
314
|
+
// playoff match as a completed result days before it was played.
|
|
315
|
+
const nowMs = Date.now();
|
|
316
|
+
const isFinished = (row) => {
|
|
317
|
+
const r = asRecord(row) ?? {};
|
|
318
|
+
const t = Date.parse(String(r.startTime ?? ''));
|
|
319
|
+
if (Number.isFinite(t) && t > nowMs)
|
|
320
|
+
return false;
|
|
321
|
+
const state = String(r.state ?? r.status ?? '').toLowerCase();
|
|
322
|
+
if (/unstarted|scheduled|upcoming|not_?started|in_?progress|live/.test(state))
|
|
323
|
+
return false;
|
|
324
|
+
// No usable state: fall back to the clock plus a decided result.
|
|
325
|
+
if (!state)
|
|
326
|
+
return Number.isFinite(t) && t <= nowMs;
|
|
327
|
+
return /complete|finish|final|ended/.test(state);
|
|
328
|
+
};
|
|
304
329
|
recentMatches = extractRows(res.data)
|
|
305
|
-
.
|
|
330
|
+
.filter(isFinished)
|
|
306
331
|
.sort((a, b) => startMs(b) - startMs(a))
|
|
307
332
|
.slice(0, recentLimit)
|
|
308
333
|
.map((row) => normalizeMatch('lol', row, 'completed'));
|
package/package.json
CHANGED