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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * match_summary + match_details
3
3
  */
4
- import { extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
4
+ import { 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, parseGame, stringSchema, } from './types.js';
@@ -9,8 +9,14 @@ async function getSection(ctx, path, query) {
9
9
  return fetchJson(ctx, path, { query });
10
10
  }
11
11
  function matchCore(game, matchId, raw) {
12
- const m = normalizeMatch(game, raw);
13
- const r = asRecord(raw) ?? {};
12
+ const entity = unwrapPayload(raw);
13
+ const m = normalizeMatch(game, entity);
14
+ const r = asRecord(entity) ?? {};
15
+ const s1 = m.team1?.score ?? null;
16
+ const s2 = m.team2?.score ?? null;
17
+ const scoreline = m.team1 || m.team2
18
+ ? `${m.team1?.name ?? '?'} ${s1 ?? '-'} : ${s2 ?? '-'} ${m.team2?.name ?? '?'}`
19
+ : null;
14
20
  return {
15
21
  matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
16
22
  game,
@@ -18,14 +24,66 @@ function matchCore(game, matchId, raw) {
18
24
  startTime: m.startTime,
19
25
  endTime: pickString(r.endTime, r.endedAt, r.finishedAt) ?? null,
20
26
  bestOf: r.bestOf ?? r.bo ?? null,
27
+ label: m.label,
28
+ scoreline,
21
29
  score: {
22
- team1: m.team1?.score ?? null,
23
- team2: m.team2?.score ?? null,
30
+ team1: s1,
31
+ team2: s2,
24
32
  },
25
33
  team1: m.team1,
26
34
  team2: m.team2,
27
35
  event: m.event,
28
36
  league: m.league,
37
+ method: pickString(r.method, r.resultMethod) ?? null,
38
+ winner: (() => {
39
+ // UFC rows carry an explicit winner slug/fighter ref.
40
+ const explicit = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner);
41
+ if (explicit)
42
+ return explicit;
43
+ if (game === 'ufc')
44
+ return null;
45
+ // CS2 rows expose winnerTeamId instead; map it to a side, else fall back to scores.
46
+ const sideLabel = (side) => side ? pickString(side.slug, side.id, side.name) ?? null : null;
47
+ const winnerTeamId = pickString(r.winnerTeamId, r.winner_team_id);
48
+ if (winnerTeamId) {
49
+ const t1Ids = [m.team1?.id, m.team1?.slug, pickString(r.team1Id, r.team1_id)].filter(Boolean).map(String);
50
+ const t2Ids = [m.team2?.id, m.team2?.slug, pickString(r.team2Id, r.team2_id)].filter(Boolean).map(String);
51
+ if (t1Ids.includes(winnerTeamId))
52
+ return sideLabel(m.team1);
53
+ if (t2Ids.includes(winnerTeamId))
54
+ return sideLabel(m.team2);
55
+ }
56
+ if (s1 != null && s2 != null && s1 !== s2)
57
+ return sideLabel(s1 > s2 ? m.team1 : m.team2);
58
+ return null;
59
+ })(),
60
+ weightClass: pickString(r.weightClass, r.division) ?? null,
61
+ referee: (() => {
62
+ const nested = asRecord(r.referee);
63
+ if (nested) {
64
+ const joined = [nested.firstName, nested.lastName].filter(Boolean).join(' ').trim();
65
+ const name = pickString(nested.name, joined || undefined) ?? null;
66
+ const id = pickString(nested.id, nested.refereeId) ?? null;
67
+ if (name || id) {
68
+ return {
69
+ id,
70
+ name,
71
+ firstName: pickString(nested.firstName) ?? null,
72
+ lastName: pickString(nested.lastName) ?? null,
73
+ };
74
+ }
75
+ }
76
+ const name = pickString(r.refereeName) ?? null;
77
+ const id = pickString(r.refereeId) ?? null;
78
+ if (!name && !id)
79
+ return null;
80
+ return {
81
+ id,
82
+ name,
83
+ firstName: pickString(r.refereeFirstName) ?? null,
84
+ lastName: pickString(r.refereeLastName) ?? null,
85
+ };
86
+ })(),
29
87
  rawStatus: pickString(r.status, r.state) ?? null,
30
88
  };
31
89
  }
@@ -45,18 +103,18 @@ function primaryPath(game, matchId) {
45
103
  }
46
104
  export const matchSummary = {
47
105
  name: 'match_summary',
48
- description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
49
-
50
- When to use:
51
- - Match recap / default match UI
52
- - After user selects a live or completed matchId
53
-
54
- Prefer over match_details for chat answers and default UIs.
55
- Prefer match_details for timelines, full map trees, live state, advanced packages.
56
-
57
- Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
58
-
59
- Parallel-safe: yes. Upstream cost: 2–5.
106
+ description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
107
+
108
+ When to use:
109
+ - Match recap / default match UI
110
+ - After user selects a live or completed matchId
111
+
112
+ Prefer over match_details for chat answers and default UIs.
113
+ Prefer match_details for timelines, full map trees, live state, advanced packages.
114
+
115
+ Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
116
+
117
+ Parallel-safe: yes. Upstream cost: 2–5.
60
118
  Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
61
119
  inputSchema: {
62
120
  type: 'object',
@@ -240,22 +298,22 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
240
298
  };
241
299
  export const matchDetails = {
242
300
  name: 'match_details',
243
- description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
244
-
245
- When to use:
246
- - Analyst deep dive
247
- - Live in-game window (LoL/CS2/UFC)
248
- - Full demo list
249
-
250
- Prefer over match_summary only when summary is insufficient.
251
- Prefer match_summary for short answers and default cards.
252
-
253
- Do not use when: first-pass live board (use live_matches + match_summary).
254
-
255
- Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
256
- If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
257
-
258
- Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
301
+ description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
302
+
303
+ When to use:
304
+ - Analyst deep dive
305
+ - Live in-game window (LoL/CS2/UFC)
306
+ - Full demo list
307
+
308
+ Prefer over match_summary only when summary is insufficient.
309
+ Prefer match_summary for short answers and default cards.
310
+
311
+ Do not use when: first-pass live board (use live_matches + match_summary).
312
+
313
+ Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
314
+ If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
315
+
316
+ Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
259
317
  Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
260
318
  inputSchema: {
261
319
  type: 'object',
@@ -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.3';
7
+ import { PACKAGE_VERSION as CATALOG_VERSION } from '../version.js';
8
8
  const TOOL_CATALOG = [
9
9
  {
10
10
  name: 'list_capabilities',
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cross-game row normalization for live/schedule boards and entity cards.
3
3
  */
4
- import { asRecord, pickString } from '../client.js';
4
+ import { asRecord, pickString, unwrapPayload } from '../client.js';
5
5
  function sideFrom(name, id, slug, score) {
6
6
  if (!name && !id && !slug)
7
7
  return null;
@@ -39,12 +39,25 @@ function scoreNum(v) {
39
39
  return null;
40
40
  }
41
41
  export function normalizeMatch(game, row, forcedStatus) {
42
- const r = asRecord(row) ?? {};
43
- const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId) ?? 'unknown';
42
+ // Always peel { success, data } so UFC bout fighters[] / status are visible.
43
+ const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
44
+ const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId) ?? 'unknown';
44
45
  let team1 = nestedSide(r.team1) ??
45
46
  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));
46
47
  let team2 = nestedSide(r.team2) ??
47
48
  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));
49
+ // CS2 rows nest team1/team2 objects that lack a score; a nested side must not
50
+ // shadow the flat score fields (team1Score/score1/team1Maps) with score:null.
51
+ if (team1 && team1.score == null) {
52
+ const s = scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore);
53
+ if (s != null)
54
+ team1 = { ...team1, score: s };
55
+ }
56
+ if (team2 && team2.score == null) {
57
+ const s = scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore);
58
+ if (s != null)
59
+ team2 = { ...team2, score: s };
60
+ }
48
61
  // UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
49
62
  // Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
50
63
  // Live tracking uses red/blue + fighters[] (not team1/team2).
@@ -96,30 +109,66 @@ export function normalizeMatch(game, row, forcedStatus) {
96
109
  team1 = team1 ?? nestedSide(r.teams[0]);
97
110
  team2 = team2 ?? nestedSide(r.teams[1]);
98
111
  }
99
- const startTime = pickString(r.startTime, r.scheduledAt, r.startsAt, r.date, r.startDate, r.beginAt) ?? null;
100
- const statusRaw = pickString(r.status, r.state, r.matchStatus)?.toLowerCase() ?? '';
112
+ const startTime = pickString(r.startTime, r.scheduledAt, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
113
+ const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus)?.toLowerCase() ?? '';
101
114
  let status = forcedStatus ?? 'unknown';
102
115
  if (!forcedStatus) {
103
- if (/live|running|in_?progress|ongoing|started/.test(statusRaw))
116
+ const hasResult = Boolean(pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.method, r.result)) ||
117
+ r.resultRound != null ||
118
+ r.isComplete === true ||
119
+ r.completed === true;
120
+ if (/live|running|in_?progress|ongoing|started|watching/.test(statusRaw))
104
121
  status = 'live';
105
- else if (/upcoming|scheduled|not_started|pending|soon/.test(statusRaw))
106
- status = 'upcoming';
107
- else if (/complete|finished|ended|final|closed|done/.test(statusRaw))
122
+ else if (/complete|finished|ended|final|closed|done|official|result/.test(statusRaw) || hasResult) {
108
123
  status = 'completed';
109
- else if (forcedStatus)
110
- status = forcedStatus;
124
+ }
125
+ else if (/upcoming|scheduled|not_started|pending|soon|booked|confirmed|announced/.test(statusRaw) ||
126
+ (game === 'ufc' && !hasResult && (startTime || statusRaw === '' || statusRaw === 'unknown'))) {
127
+ // UFC bouts often omit status or use sparse values; default upcoming when no result yet.
128
+ if (hasResult)
129
+ status = 'completed';
130
+ else if (startTime) {
131
+ const t = Date.parse(startTime);
132
+ status = Number.isFinite(t) && t < Date.now() - 3 * 3600_000 ? 'completed' : 'upcoming';
133
+ }
134
+ else {
135
+ status = 'upcoming';
136
+ }
137
+ }
138
+ else if (hasResult)
139
+ status = 'completed';
140
+ }
141
+ // Winner → score 1-0 for UFC card display when numeric scores absent
142
+ if (game === 'ufc' && team1 && team2 && team1.score == null && team2.score == null) {
143
+ const winner = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner)?.toLowerCase();
144
+ if (winner) {
145
+ const t1hit = [team1.slug, team1.id, team1.name].some((x) => x && (String(x).toLowerCase() === winner || String(x).toLowerCase().includes(winner) || winner.includes(String(x).toLowerCase())));
146
+ const t2hit = [team2.slug, team2.id, team2.name].some((x) => x && (String(x).toLowerCase() === winner || String(x).toLowerCase().includes(winner) || winner.includes(String(x).toLowerCase())));
147
+ if (t1hit && !t2hit) {
148
+ team1 = { ...team1, score: 1 };
149
+ team2 = { ...team2, score: 0 };
150
+ }
151
+ else if (t2hit && !t1hit) {
152
+ team1 = { ...team1, score: 0 };
153
+ team2 = { ...team2, score: 1 };
154
+ }
155
+ }
111
156
  }
112
- const eventName = pickString(asRecord(r.event)?.name, r.eventName, r.event, asRecord(r.tournament)?.name, r.tournamentName);
113
- const eventId = pickString(asRecord(r.event)?.id, r.eventId, asRecord(r.tournament)?.id, r.tournamentId);
114
- const eventSlug = pickString(asRecord(r.event)?.slug, r.eventSlug, asRecord(r.tournament)?.slug);
157
+ const ev = asRecord(r.event);
158
+ const eventName = pickString(ev?.name, ev?.title, r.eventName, typeof r.event === 'string' ? r.event : undefined, asRecord(r.tournament)?.name, asRecord(r.tournament)?.title, r.tournamentName);
159
+ const eventId = pickString(ev?.id, r.eventId, asRecord(r.tournament)?.id, r.tournamentId);
160
+ const eventSlug = pickString(ev?.slug, r.eventSlug, asRecord(r.tournament)?.slug);
115
161
  const leagueName = pickString(asRecord(r.league)?.name, r.leagueName, r.league, r.leagueSlug);
116
162
  const leagueId = pickString(asRecord(r.league)?.id, r.leagueId);
117
163
  const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
118
164
  // Prefer explicit title/label when present, but never keep the placeholder
119
165
  // "? vs ?" once fighter/team names were resolved (UFC fighters[] / red-blue).
166
+ // When both sides lack names, prefer event / weight / bout id over opaque "? vs ?"
167
+ // so agent boards (live_matches, upcoming_schedule, event_card) stay legible.
120
168
  const vsLabel = `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
121
169
  const explicit = pickString(r.label, r.title, r.name);
122
170
  const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
171
+ const weightOrClass = pickString(r.weightClass, r.division, r.weight_class, r.boutClass);
123
172
  let label;
124
173
  if (explicit && !explicitIsPlaceholder) {
125
174
  label = explicit;
@@ -128,7 +177,11 @@ export function normalizeMatch(game, row, forcedStatus) {
128
177
  label = vsLabel;
129
178
  }
130
179
  else {
131
- label = explicit ?? vsLabel;
180
+ // Identity fallbacks never invent fighter names, but avoid bare "? vs ?"
181
+ label =
182
+ pickString(eventName, eventSlug, weightOrClass) ??
183
+ (matchId && matchId !== 'unknown' ? `Bout ${matchId}` : null) ??
184
+ vsLabel;
132
185
  }
133
186
  return {
134
187
  game,
@@ -148,7 +201,8 @@ export function entityRef(row, type, game) {
148
201
  pickString(r.slug) ??
149
202
  'unknown';
150
203
  const slug = pickString(r.slug, r.orgSlug, r.teamSlug);
151
- const name = pickString(r.name, r.nickname, r.displayName, r.tag, r.code, r.title, slug, id) ?? id;
204
+ // Events often use `title` not `name` (UFC)
205
+ const name = pickString(r.name, r.title, r.nickname, r.displayName, r.tag, r.code, slug, id) ?? id;
152
206
  const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname);
153
207
  return {
154
208
  game,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * player_profile
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';
@@ -165,12 +165,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
165
165
  httpStatus: res.status,
166
166
  });
167
167
  }
168
- playerRaw = res.data;
169
- // some APIs wrap in data
170
- const obj = asRecord(res.data);
171
- if (obj?.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
172
- playerRaw = obj.data;
173
- }
168
+ playerRaw = unwrapPayload(res.data);
174
169
  }
175
170
  const player = identityFrom(game, playerRaw, playerId || undefined, slug || undefined);
176
171
  let currentTeam = player.team;
@@ -306,13 +301,15 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
306
301
  })());
307
302
  }
308
303
  if (game === 'ufc') {
304
+ const fighterKey = slug || idOrSlug;
309
305
  tasks.push((async () => {
310
- const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(slug || idOrSlug)}/stats`);
306
+ const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/stats`);
311
307
  upstreamCalls += 1;
312
308
  rateLimit = { ...rateLimit, ...res.headers };
313
309
  if (res.ok) {
314
- form = { summary: 'UFC fighter stats', trend: null, window: null, metrics: res.data };
315
- seasonStats = res.data;
310
+ const metrics = unwrapPayload(res.data);
311
+ form = { summary: 'UFC fighter stats', trend: null, window: null, metrics };
312
+ seasonStats = metrics;
316
313
  }
317
314
  else {
318
315
  partial.push(partialFromRejection('form', {
@@ -322,6 +319,53 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
322
319
  }));
323
320
  }
324
321
  })());
322
+ tasks.push((async () => {
323
+ // Fight-by-fight history — /fights and /history are aliases on the API
324
+ const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/fights`, {
325
+ query: { limit: recentLimit },
326
+ });
327
+ upstreamCalls += 1;
328
+ rateLimit = { ...rateLimit, ...res.headers };
329
+ if (res.ok) {
330
+ recentMatches = extractRows(res.data)
331
+ .slice(0, recentLimit)
332
+ .map((row) => {
333
+ const r = asRecord(row) ?? {};
334
+ const bout = asRecord(r.bout) ?? row;
335
+ const m = normalizeMatch('ufc', bout, 'completed');
336
+ return {
337
+ ...m,
338
+ result: pickString(r.result, r.outcome, asRecord(bout)?.result) ?? null,
339
+ opponent: pickString(r.opponentName, asRecord(r.opponent)?.name) ?? null,
340
+ method: pickString(asRecord(bout)?.method, r.method) ?? null,
341
+ };
342
+ });
343
+ }
344
+ else {
345
+ // fallback history path
346
+ const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/history`, {
347
+ query: { limit: recentLimit },
348
+ });
349
+ upstreamCalls += 1;
350
+ rateLimit = { ...rateLimit, ...hist.headers };
351
+ if (hist.ok) {
352
+ recentMatches = extractRows(hist.data)
353
+ .slice(0, recentLimit)
354
+ .map((row) => {
355
+ const r = asRecord(row) ?? {};
356
+ const bout = asRecord(r.bout) ?? row;
357
+ return normalizeMatch('ufc', bout, 'completed');
358
+ });
359
+ }
360
+ else {
361
+ partial.push(partialFromRejection('recentMatches', {
362
+ code: mapHttpToCode(res.status),
363
+ message: `fighter fights HTTP ${res.status}`,
364
+ httpStatus: res.status,
365
+ }));
366
+ }
367
+ }
368
+ })());
325
369
  }
326
370
  await Promise.all(tasks);
327
371
  if (view === 'summary' && recentMatches.length > recentLimit) {