cito-mcp 0.2.1 → 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.
- package/README.md +2 -2
- package/dist/client.js +5 -2
- package/dist/index.js +1 -1
- package/dist/tools/live.js +105 -16
- package/dist/tools/meta.js +1 -1
- package/dist/tools/normalize.js +50 -10
- package/dist/tools/standings.js +33 -2
- package/package.json +1 -1
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.
|
|
5
|
+
**Version:** `0.2.2` · **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
|
|
|
@@ -398,7 +398,7 @@ src/
|
|
|
398
398
|
|
|
399
399
|
## Publish notes
|
|
400
400
|
|
|
401
|
-
Package: **`cito-mcp@0.2.
|
|
401
|
+
Package: **`cito-mcp@0.2.2`**
|
|
402
402
|
|
|
403
403
|
| Item | Value |
|
|
404
404
|
| --- | --- |
|
package/dist/client.js
CHANGED
|
@@ -137,19 +137,22 @@ export function extractRows(data) {
|
|
|
137
137
|
if (typeof data !== 'object')
|
|
138
138
|
return [];
|
|
139
139
|
const obj = data;
|
|
140
|
+
// Order matters: UFC /ufc/live nests real bouts under liveBouts while also
|
|
141
|
+
// shipping supervisor `events` (no fighter names). Prefer bout-like keys first.
|
|
140
142
|
for (const key of [
|
|
141
143
|
'data',
|
|
144
|
+
'liveBouts',
|
|
145
|
+
'bouts',
|
|
142
146
|
'matches',
|
|
143
147
|
'items',
|
|
144
148
|
'results',
|
|
145
149
|
'teams',
|
|
146
150
|
'players',
|
|
147
|
-
'events',
|
|
148
151
|
'fighters',
|
|
149
152
|
'tournaments',
|
|
150
153
|
'leagues',
|
|
151
154
|
'orgs',
|
|
152
|
-
'
|
|
155
|
+
'events',
|
|
153
156
|
'rankings',
|
|
154
157
|
'standings',
|
|
155
158
|
'rows',
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ 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.
|
|
21
|
+
const PACKAGE_VERSION = '0.2.2';
|
|
22
22
|
const API_KEY = process.env.CITO_API_KEY;
|
|
23
23
|
if (!API_KEY) {
|
|
24
24
|
console.error('[cito-mcp] CITO_API_KEY is required.\n' +
|
package/dist/tools/live.js
CHANGED
|
@@ -12,6 +12,42 @@ const LIVE_PATHS = {
|
|
|
12
12
|
cod: '/cod/matches/live',
|
|
13
13
|
ufc: '/ufc/live',
|
|
14
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
|
+
}
|
|
15
51
|
export const liveMatches = {
|
|
16
52
|
name: 'live_matches',
|
|
17
53
|
description: `Live matches board across primary games, or a single game filter. Normalized labels, scores, and matchIds.
|
|
@@ -88,12 +124,25 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
88
124
|
});
|
|
89
125
|
continue;
|
|
90
126
|
}
|
|
91
|
-
//
|
|
92
|
-
let rows =
|
|
127
|
+
// Prefer real match/bout rows over supervisor event shells (UFC /ufc/live).
|
|
128
|
+
let rows = extractLiveRows(res.data, game);
|
|
93
129
|
const obj = asRecord(res.data);
|
|
94
130
|
if (rows.length === 0 && Array.isArray(obj?.matches))
|
|
95
131
|
rows = obj.matches;
|
|
96
|
-
const normalized = rows
|
|
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
|
+
});
|
|
97
146
|
const items = labelsOnly
|
|
98
147
|
? normalized.map((m) => ({
|
|
99
148
|
game: m.game,
|
|
@@ -265,19 +314,19 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
265
314
|
}
|
|
266
315
|
else if (game === 'ufc') {
|
|
267
316
|
path = '/ufc/events/upcoming';
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
271
324
|
if (team)
|
|
272
325
|
noteIgnored('team', 'UFC uses fighter filters via resolve/event_card, not team on schedule');
|
|
273
326
|
if (league)
|
|
274
327
|
noteIgnored('league', 'UFC has no league filter on upcoming events');
|
|
275
328
|
if (tournamentId)
|
|
276
329
|
noteIgnored('tournamentId', 'pass event slug via event_card instead');
|
|
277
|
-
if (from)
|
|
278
|
-
noteIgnored('from', 'UFC upcoming does not accept from');
|
|
279
|
-
if (to)
|
|
280
|
-
noteIgnored('to', 'UFC upcoming does not accept to');
|
|
281
330
|
}
|
|
282
331
|
const res = await fetchJson(ctx, path, { query });
|
|
283
332
|
if (!res.ok) {
|
|
@@ -294,29 +343,69 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
294
343
|
});
|
|
295
344
|
}
|
|
296
345
|
let rows = extractRows(res.data);
|
|
297
|
-
// UFC events
|
|
346
|
+
// UFC events → expand to bout rows (fighters[] shape) so labels are not "? vs ?"
|
|
298
347
|
if (game === 'ufc') {
|
|
299
348
|
const expanded = [];
|
|
300
349
|
for (const event of rows) {
|
|
301
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);
|
|
302
355
|
const bouts = extractRows(er.bouts ?? er.fights);
|
|
303
356
|
if (bouts.length) {
|
|
304
357
|
for (const bout of bouts) {
|
|
305
358
|
const br = asRecord(bout) ?? {};
|
|
306
359
|
expanded.push({
|
|
307
360
|
...br,
|
|
308
|
-
eventName
|
|
309
|
-
eventId
|
|
310
|
-
eventSlug
|
|
311
|
-
startTime: pickString(br.startTime,
|
|
361
|
+
eventName,
|
|
362
|
+
eventId,
|
|
363
|
+
eventSlug,
|
|
364
|
+
startTime: pickString(br.startTime, br.date, eventStart),
|
|
312
365
|
});
|
|
313
366
|
}
|
|
314
367
|
}
|
|
315
368
|
else {
|
|
316
|
-
|
|
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
|
+
});
|
|
317
382
|
}
|
|
318
383
|
}
|
|
319
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
|
+
}
|
|
320
409
|
}
|
|
321
410
|
// Client-side team filter when API ignored it
|
|
322
411
|
if (team && (game === 'dota2' || game === 'cs2')) {
|
package/dist/tools/meta.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { extractRows, fetchJson, gameNotIncludedHint, present } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { boolSchema, gameSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
|
|
7
|
-
const CATALOG_VERSION = '0.2.
|
|
7
|
+
const CATALOG_VERSION = '0.2.2';
|
|
8
8
|
const TOOL_CATALOG = [
|
|
9
9
|
{
|
|
10
10
|
name: 'list_capabilities',
|
package/dist/tools/normalize.js
CHANGED
|
@@ -19,9 +19,10 @@ function nestedSide(raw) {
|
|
|
19
19
|
return { name: raw };
|
|
20
20
|
return null;
|
|
21
21
|
}
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
22
|
+
const profile = asRecord(o.profile);
|
|
23
|
+
const name = pickString(o.name, o.fighterName, o.code, o.shortName, o.nickname, o.displayName, profile?.name, profile?.nickname);
|
|
24
|
+
const id = pickString(o.id, o.teamId, o.orgId, o.fighterId, profile?.id);
|
|
25
|
+
const slug = pickString(o.slug, o.orgSlug, o.fighterSlug, profile?.slug);
|
|
25
26
|
const scoreRaw = o.score ?? o.mapsWon ?? o.gamesWon;
|
|
26
27
|
const score = typeof scoreRaw === 'number'
|
|
27
28
|
? scoreRaw
|
|
@@ -39,17 +40,56 @@ function scoreNum(v) {
|
|
|
39
40
|
}
|
|
40
41
|
export function normalizeMatch(game, row, forcedStatus) {
|
|
41
42
|
const r = asRecord(row) ?? {};
|
|
42
|
-
const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id) ?? 'unknown';
|
|
43
|
+
const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId) ?? 'unknown';
|
|
43
44
|
let team1 = nestedSide(r.team1) ??
|
|
44
45
|
sideFrom(pickString(r.team1Name, r.team_a_name, r.redName, r.fighter1Name, r.homeName), pickString(r.team1Id, r.team1_id, r.redId, r.fighter1Id), pickString(r.team1Slug, r.redSlug, r.fighter1Slug), scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore));
|
|
45
46
|
let team2 = nestedSide(r.team2) ??
|
|
46
47
|
sideFrom(pickString(r.team2Name, r.team_b_name, r.blueName, r.fighter2Name, r.awayName), pickString(r.team2Id, r.team2_id, r.blueId, r.fighter2Id), pickString(r.team2Slug, r.blueSlug, r.fighter2Slug), scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore));
|
|
47
|
-
// UFC fighters
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
// UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
|
|
49
|
+
// Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
|
|
50
|
+
// Live tracking uses red/blue + fighters[] (not team1/team2).
|
|
51
|
+
if (game === 'ufc' || (!team1 && !team2 && (r.red || r.blue || Array.isArray(r.fighters)))) {
|
|
52
|
+
const fromCornerObj = (raw) => {
|
|
53
|
+
const o = asRecord(raw);
|
|
54
|
+
if (!o) {
|
|
55
|
+
if (typeof raw === 'string' && raw.trim())
|
|
56
|
+
return { name: raw.trim() };
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// Nested profile from serializeBoutFighter
|
|
60
|
+
const profile = asRecord(o.profile);
|
|
61
|
+
const fighter = asRecord(o.fighter);
|
|
62
|
+
const name = pickString(o.name, o.fighterName, o.displayName, o.nickname, profile?.name, profile?.nickname, fighter?.name, fighter?.nickname);
|
|
63
|
+
const id = pickString(o.id, o.fighterId, profile?.id, fighter?.id);
|
|
64
|
+
const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
|
|
65
|
+
const score = scoreNum(o.score ?? o.points);
|
|
66
|
+
return sideFrom(name, id, slug, score);
|
|
67
|
+
};
|
|
68
|
+
team1 =
|
|
69
|
+
team1 ??
|
|
70
|
+
fromCornerObj(r.red) ??
|
|
71
|
+
fromCornerObj(r.redCorner) ??
|
|
72
|
+
fromCornerObj(r.fighter1) ??
|
|
73
|
+
fromCornerObj(r.fighterA);
|
|
74
|
+
team2 =
|
|
75
|
+
team2 ??
|
|
76
|
+
fromCornerObj(r.blue) ??
|
|
77
|
+
fromCornerObj(r.blueCorner) ??
|
|
78
|
+
fromCornerObj(r.fighter2) ??
|
|
79
|
+
fromCornerObj(r.fighterB);
|
|
80
|
+
const fightersList = Array.isArray(r.fighters) ? r.fighters : [];
|
|
81
|
+
if (fightersList.length) {
|
|
82
|
+
const byCorner = (want) => fightersList.find((f) => {
|
|
83
|
+
const c = pickString(asRecord(f)?.corner, asRecord(f)?.side)?.toLowerCase();
|
|
84
|
+
return c === want || c === want[0]; // "red" / "r"
|
|
85
|
+
});
|
|
86
|
+
const redF = byCorner('red') ?? byCorner('r') ?? fightersList[0];
|
|
87
|
+
const blueF = byCorner('blue') ??
|
|
88
|
+
byCorner('b') ??
|
|
89
|
+
(fightersList.length > 1 ? fightersList[1] : undefined);
|
|
90
|
+
team1 = team1 ?? fromCornerObj(redF);
|
|
91
|
+
team2 = team2 ?? fromCornerObj(blueF);
|
|
92
|
+
}
|
|
53
93
|
}
|
|
54
94
|
// COD sometimes uses teams[]
|
|
55
95
|
if ((!team1 || !team2) && Array.isArray(r.teams)) {
|
package/dist/tools/standings.js
CHANGED
|
@@ -8,11 +8,40 @@ function normalizeStandingRow(row, index) {
|
|
|
8
8
|
const r = asRecord(row) ?? {};
|
|
9
9
|
const entity = asRecord(r.team) ?? asRecord(r.fighter) ?? asRecord(r.org) ?? r;
|
|
10
10
|
const name = pickString(asRecord(entity)?.name, r.teamName, r.name, r.orgName, r.fighterName, asRecord(entity)?.slug) ?? `row-${index + 1}`;
|
|
11
|
+
// UFC official lists: champion has rank=null + rankText="C"; contenders 1..15.
|
|
12
|
+
// Never fall back to index+1 for null ranks — that produced two "#1" rows (champ + #1).
|
|
13
|
+
const championStatus = pickString(r.championStatus, asRecord(entity)?.championStatus);
|
|
14
|
+
const rankText = pickString(r.rankText);
|
|
15
|
+
const isChampion = championStatus === 'champion' ||
|
|
16
|
+
rankText === 'C' ||
|
|
17
|
+
r.isChampion === true ||
|
|
18
|
+
r.isTitleHolder === true;
|
|
19
|
+
let rank;
|
|
20
|
+
if (isChampion) {
|
|
21
|
+
rank = 'C';
|
|
22
|
+
}
|
|
23
|
+
else if (typeof r.rank === 'number') {
|
|
24
|
+
rank = r.rank;
|
|
25
|
+
}
|
|
26
|
+
else if (typeof r.position === 'number') {
|
|
27
|
+
rank = r.position;
|
|
28
|
+
}
|
|
29
|
+
else if (rankText && /^\d+$/.test(rankText)) {
|
|
30
|
+
rank = Number(rankText);
|
|
31
|
+
}
|
|
32
|
+
else if (r.rank === null && rankText) {
|
|
33
|
+
rank = rankText;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
rank = index + 1;
|
|
37
|
+
}
|
|
11
38
|
return {
|
|
12
|
-
rank
|
|
39
|
+
rank,
|
|
40
|
+
rankText: rankText ?? (typeof rank === 'number' || typeof rank === 'string' ? String(rank) : null),
|
|
41
|
+
championStatus: isChampion ? 'champion' : championStatus === 'interim' ? 'interim' : 'none',
|
|
13
42
|
teamOrFighter: {
|
|
14
43
|
id: pickString(asRecord(entity)?.id, r.teamId, r.fighterId, r.orgId, r.id),
|
|
15
|
-
slug: pickString(asRecord(entity)?.slug, r.orgSlug, r.teamSlug, r.slug),
|
|
44
|
+
slug: pickString(asRecord(entity)?.slug, r.orgSlug, r.teamSlug, r.slug, r.fighterSlug),
|
|
16
45
|
name,
|
|
17
46
|
},
|
|
18
47
|
played: r.played ?? r.gamesPlayed ?? r.matches ?? null,
|
|
@@ -24,7 +53,9 @@ function normalizeStandingRow(row, index) {
|
|
|
24
53
|
streak: r.streak ?? null,
|
|
25
54
|
meta: {
|
|
26
55
|
...(r.division ? { division: r.division } : {}),
|
|
56
|
+
...(r.normalizedDivision ? { normalizedDivision: r.normalizedDivision } : {}),
|
|
27
57
|
...(r.region ? { region: r.region } : {}),
|
|
58
|
+
...(isChampion ? { isChampion: true } : {}),
|
|
28
59
|
},
|
|
29
60
|
};
|
|
30
61
|
}
|
package/package.json
CHANGED