cito-mcp 0.2.3 → 0.2.4

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 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.3` · **Node:** `>=20` · **Install:** `npx cito-mcp`
5
+ **Version:** `0.2.4` · **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
 
@@ -164,6 +164,16 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
164
164
  | `cod` | medium | Org slugs; CDL standings; UUID match ids |
165
165
  | `ufc` | medium | Fighter slugs; `boutId`; live + rankings |
166
166
 
167
+ ### UFC projection notes (0.2.4)
168
+
169
+ Hardened agent-facing UFC shapes (offline-tested):
170
+
171
+ - **`normalizeMatch`** reads `fighters[]` (`corner` + `fighterName` / `profile.name`), live `red`/`blue`, and `boutId` / `dataId` / bare `id`. Never keeps label `? vs ?` when fighter names exist (including blue-first arrays).
172
+ - **`live_matches`** uses `extractLiveRows`: prefer `liveBouts` (empty array = honest empty board); never promote supervisor `events[]` shells into match rows.
173
+ - **`upcoming_schedule`** expands event → bout rows with fighter labels; applies client-side **`hours` / `from` / `to`** (API has no `hours`); event shells without bouts use the **event name**, not `? vs ?`.
174
+ - **`event_card`** bouts share the same `normalizeMatch` path.
175
+ - **`standings`** maps division champions to rank **`C`** (`rankText: "C"`, interim **`IC`**); contender `#1` stays numeric `1` — no dual numeric `#1`.
176
+
167
177
  Fortnite: **`call_api` only** until promoted into the primary enum.
168
178
 
169
179
  ### `call_api` allowlist
@@ -398,7 +408,7 @@ src/
398
408
 
399
409
  ## Publish notes
400
410
 
401
- Package: **`cito-mcp@0.2.2`**
411
+ Package: **`cito-mcp@0.2.4`**
402
412
 
403
413
  | Item | Value |
404
414
  | --- | --- |
@@ -433,7 +443,7 @@ claude mcp add cito -e CITO_API_KEY=cito_… -- npx cito-mcp
433
443
 
434
444
  ### Semver expectations
435
445
 
436
- - **0.2.0** is a **major surface break** vs 0.1 (tool rename + removal of OpenAPI mass-generation).
446
+ - **0.2.4** is a **major surface break** vs 0.1 (tool rename + removal of OpenAPI mass-generation).
437
447
  - Further 0.2.x patches may refine envelopes and composite quality without renaming the 15 tools.
438
448
  - Promoting Fortnite (or other titles) into the primary `game` enum would be a minor feature bump with catalog/docs updates.
439
449
 
@@ -441,7 +451,16 @@ claude mcp add cito -e CITO_API_KEY=cito_… -- npx cito-mcp
441
451
 
442
452
  ## Changelog (summary)
443
453
 
444
- ### 0.2.0
454
+ ### 0.2.4
455
+
456
+ - **UFC projection hardening** (offline-tested):
457
+ - `normalizeMatch`: `fighters[]` corners + profile, live red/blue, `boutId`/`dataId`; never keep `? vs ?` when names exist
458
+ - `extractRows` / `extractLiveRows`: prefer `liveBouts` (including empty); never fake match rows from supervisor `events[]`
459
+ - `upcoming_schedule`: client-side `hours`/`from`/`to`; bout expansion with fighter labels; event shells labeled by event name
460
+ - `event_card` bouts share the same normalize path
461
+ - `standings`: champion rank `C` / interim `IC`; no dual numeric `#1` for champ + contender
462
+
463
+ ### 0.2.4
445
464
 
446
465
  - **Breaking:** replaced OpenAPI mass-generated tools with **15 curated outcome tools**
447
466
  - Stable JSON envelope + MCP server instructions
package/dist/client.js CHANGED
@@ -172,6 +172,42 @@ export function asRecord(value) {
172
172
  }
173
173
  return null;
174
174
  }
175
+ /**
176
+ * Peel Cito `{ success, data: T }` (and one nested `data`) so tools see the entity,
177
+ * not the envelope. Arrays and primitives pass through. Critical for UFC bouts/fighters
178
+ * where fighters[] / name live under data, not the top-level response.
179
+ */
180
+ export function unwrapPayload(value) {
181
+ let cur = value;
182
+ for (let depth = 0; depth < 3; depth += 1) {
183
+ const rec = asRecord(cur);
184
+ if (!rec)
185
+ return cur;
186
+ // Classic withMeta: { success: true, data: { ...entity } }
187
+ if ('data' in rec && rec.data != null && typeof rec.data === 'object' && !Array.isArray(rec.data)) {
188
+ const inner = rec.data;
189
+ // Prefer entity-shaped inner objects over list wrappers
190
+ const looksLikeEntity = 'id' in inner ||
191
+ 'slug' in inner ||
192
+ 'name' in inner ||
193
+ 'title' in inner ||
194
+ 'fighters' in inner ||
195
+ 'boutId' in inner ||
196
+ 'matchId' in inner ||
197
+ 'team1' in inner ||
198
+ 'red' in inner ||
199
+ 'blue' in inner ||
200
+ 'record' in inner ||
201
+ 'nickname' in inner;
202
+ if (looksLikeEntity || rec.success === true) {
203
+ cur = rec.data;
204
+ continue;
205
+ }
206
+ }
207
+ break;
208
+ }
209
+ return cur;
210
+ }
175
211
  export function pickString(...values) {
176
212
  for (const v of values) {
177
213
  if (typeof v === 'string' && v.length > 0)
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.3';
21
+ const PACKAGE_VERSION = '0.2.4';
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' +
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * match_preview — pre-match briefing composite
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';
@@ -18,11 +18,15 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
18
18
  calls += 1;
19
19
  rateLimit = res.headers;
20
20
  if (res.ok) {
21
- const data = asRecord(res.data) ?? {};
21
+ const data = asRecord(unwrapPayload(res.data)) ?? {};
22
22
  entity = {
23
- id: pickString(data.id, side),
23
+ id: pickString(data.id, data.slug, side),
24
24
  slug: pickString(data.slug, side),
25
- name: pickString(data.name, side),
25
+ // Prefer real display name; never leave entity.name as the slug when name exists
26
+ name: pickString(data.name, data.displayName, data.nickname, side) ?? side,
27
+ nickname: pickString(data.nickname) ?? null,
28
+ division: pickString(data.division) ?? null,
29
+ recordText: pickString(data.recordText, asRecord(data.record)?.text) ?? null,
26
30
  game,
27
31
  };
28
32
  }
@@ -33,11 +37,33 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
33
37
  httpStatus: res.status,
34
38
  }));
35
39
  }
40
+ // Fight-by-fight form (not aggregate stats alone)
41
+ const fights = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}/fights`, {
42
+ query: { limit: recentLimit },
43
+ });
44
+ calls += 1;
45
+ rateLimit = { ...rateLimit, ...fights.headers };
46
+ if (fights.ok) {
47
+ recentForm = extractRows(unwrapPayload(fights.data) ?? fights.data)
48
+ .slice(0, recentLimit)
49
+ .map((row) => {
50
+ // history rows nest bout
51
+ const r = asRecord(row) ?? {};
52
+ const bout = asRecord(r.bout) ?? r;
53
+ return normalizeMatch('ufc', { ...bout, ...(r.fighterName ? {} : {}) }, 'completed');
54
+ });
55
+ }
36
56
  const stats = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}/stats`);
37
57
  calls += 1;
38
58
  rateLimit = { ...rateLimit, ...stats.headers };
39
- if (stats.ok)
40
- recentForm = [stats.data];
59
+ if (stats.ok && recentForm.length === 0) {
60
+ recentForm = []; // keep empty rather than stuffing aggregate as "matches"
61
+ keyPlayers = [];
62
+ }
63
+ if (stats.ok) {
64
+ // stash aggregate on entity for talking points
65
+ entity = { ...entity, stats: unwrapPayload(stats.data) };
66
+ }
41
67
  return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
42
68
  }
43
69
  if (game === 'lol') {
@@ -285,14 +311,19 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
285
311
  upstreamCalls += 1;
286
312
  rateLimit = res.headers;
287
313
  if (res.ok) {
288
- const m = normalizeMatch(game, res.data, 'upcoming');
314
+ const entity = unwrapPayload(res.data);
315
+ // Do not force "upcoming" — completed bouts (e.g. UFC 300) should keep completed.
316
+ const m = normalizeMatch(game, entity);
289
317
  context = {
290
- matchId: m.matchId,
318
+ matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
291
319
  startTime: m.startTime,
292
320
  event: m.event,
321
+ eventName: m.event?.name ?? null,
293
322
  league: m.league,
294
323
  status: m.status,
324
+ label: m.label,
295
325
  };
326
+ // Prefer stable slugs for follow-up fighter fetches
296
327
  if (!teamA)
297
328
  teamA = m.team1?.slug || m.team1?.id || m.team1?.name || '';
298
329
  if (!teamB)
@@ -366,6 +397,27 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
366
397
  if (res.ok)
367
398
  rows = extractRows(res.data);
368
399
  }
400
+ else if (game === 'ufc') {
401
+ // Fighter fight history is the H2H source of truth (not global /bouts page 1).
402
+ const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(teamA)}/fights`, {
403
+ query: { limit: 50 },
404
+ });
405
+ upstreamCalls += 1;
406
+ rateLimit = { ...rateLimit, ...hist.headers };
407
+ if (hist.ok) {
408
+ rows = extractRows(unwrapPayload(hist.data) ?? hist.data).map((row) => {
409
+ const r = asRecord(row) ?? {};
410
+ return asRecord(r.bout) ?? row;
411
+ });
412
+ }
413
+ else {
414
+ partial.push(partialFromRejection('h2h', {
415
+ code: mapHttpToCode(hist.status),
416
+ message: `fighter fights HTTP ${hist.status}`,
417
+ httpStatus: hist.status,
418
+ }));
419
+ }
420
+ }
369
421
  h2h = composeH2H(game, teamA, teamB, rows, 10);
370
422
  }
371
423
  catch (e) {
@@ -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,19 @@ 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: pickString(r.winnerFighterSlug, r.winnerSlug, r.winner) ?? null,
39
+ weightClass: pickString(r.weightClass, r.division) ?? null,
29
40
  rawStatus: pickString(r.status, r.state) ?? null,
30
41
  };
31
42
  }
@@ -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
+ const CATALOG_VERSION = '0.2.4';
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,8 +39,9 @@ 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) ??
@@ -96,22 +97,55 @@ export function normalizeMatch(game, row, forcedStatus) {
96
97
  team1 = team1 ?? nestedSide(r.teams[0]);
97
98
  team2 = team2 ?? nestedSide(r.teams[1]);
98
99
  }
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() ?? '';
100
+ 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;
101
+ const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus)?.toLowerCase() ?? '';
101
102
  let status = forcedStatus ?? 'unknown';
102
103
  if (!forcedStatus) {
103
- if (/live|running|in_?progress|ongoing|started/.test(statusRaw))
104
+ const hasResult = Boolean(pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.method, r.result)) ||
105
+ r.resultRound != null ||
106
+ r.isComplete === true ||
107
+ r.completed === true;
108
+ if (/live|running|in_?progress|ongoing|started|watching/.test(statusRaw))
104
109
  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))
110
+ else if (/complete|finished|ended|final|closed|done|official|result/.test(statusRaw) || hasResult) {
108
111
  status = 'completed';
109
- else if (forcedStatus)
110
- status = forcedStatus;
112
+ }
113
+ else if (/upcoming|scheduled|not_started|pending|soon|booked|confirmed|announced/.test(statusRaw) ||
114
+ (game === 'ufc' && !hasResult && (startTime || statusRaw === '' || statusRaw === 'unknown'))) {
115
+ // UFC bouts often omit status or use sparse values; default upcoming when no result yet.
116
+ if (hasResult)
117
+ status = 'completed';
118
+ else if (startTime) {
119
+ const t = Date.parse(startTime);
120
+ status = Number.isFinite(t) && t < Date.now() - 3 * 3600_000 ? 'completed' : 'upcoming';
121
+ }
122
+ else {
123
+ status = 'upcoming';
124
+ }
125
+ }
126
+ else if (hasResult)
127
+ status = 'completed';
128
+ }
129
+ // Winner → score 1-0 for UFC card display when numeric scores absent
130
+ if (game === 'ufc' && team1 && team2 && team1.score == null && team2.score == null) {
131
+ const winner = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner)?.toLowerCase();
132
+ if (winner) {
133
+ 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())));
134
+ 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())));
135
+ if (t1hit && !t2hit) {
136
+ team1 = { ...team1, score: 1 };
137
+ team2 = { ...team2, score: 0 };
138
+ }
139
+ else if (t2hit && !t1hit) {
140
+ team1 = { ...team1, score: 0 };
141
+ team2 = { ...team2, score: 1 };
142
+ }
143
+ }
111
144
  }
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);
145
+ const ev = asRecord(r.event);
146
+ 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);
147
+ const eventId = pickString(ev?.id, r.eventId, asRecord(r.tournament)?.id, r.tournamentId);
148
+ const eventSlug = pickString(ev?.slug, r.eventSlug, asRecord(r.tournament)?.slug);
115
149
  const leagueName = pickString(asRecord(r.league)?.name, r.leagueName, r.league, r.leagueSlug);
116
150
  const leagueId = pickString(asRecord(r.league)?.id, r.leagueId);
117
151
  const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
@@ -148,7 +182,8 @@ export function entityRef(row, type, game) {
148
182
  pickString(r.slug) ??
149
183
  'unknown';
150
184
  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;
185
+ // Events often use `title` not `name` (UFC)
186
+ const name = pickString(r.name, r.title, r.nickname, r.displayName, r.tag, r.code, slug, id) ?? id;
152
187
  const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname);
153
188
  return {
154
189
  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) {
@@ -37,36 +37,94 @@ function pushCandidates(out, game, type, rows, q, limit) {
37
37
  if (out.length > limit * 3)
38
38
  out.length = limit * 3;
39
39
  }
40
- /** UFC dedicated search (list /fighters ignores `q`). */
40
+ /** Drop noise tokens so "UFC Fight Night Medic" can match "UFC Fight Night: Medic vs Rodriguez". */
41
+ function ufcSearchQueries(q) {
42
+ const raw = q.trim();
43
+ if (!raw)
44
+ return [];
45
+ const queries = [raw];
46
+ const stop = new Set(['ufc', 'fight', 'night', 'event', 'card', 'vs', 'the', 'and', 'mma']);
47
+ const tokens = raw
48
+ .toLowerCase()
49
+ .replace(/[^a-z0-9\s-]/g, ' ')
50
+ .split(/\s+/)
51
+ .filter((t) => t.length >= 3 && !stop.has(t));
52
+ if (tokens.length) {
53
+ // distinctive tokens alone (e.g. "medic", "rodriguez")
54
+ for (const t of tokens.slice(0, 4))
55
+ queries.push(t);
56
+ // pair of last two distinctive tokens
57
+ if (tokens.length >= 2)
58
+ queries.push(tokens.slice(-2).join(' '));
59
+ }
60
+ // slug-ish form
61
+ const slugish = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
62
+ if (slugish && slugish !== raw.toLowerCase())
63
+ queries.push(slugish);
64
+ return [...new Set(queries.map((s) => s.trim()).filter(Boolean))];
65
+ }
66
+ /** UFC dedicated search (list /fighters ignores `q`). Multi-query for event fuzzy match. */
41
67
  async function ufcSearch(ctx, q, type, limit) {
42
68
  const candidates = [];
43
- const res = await fetchJson(ctx, '/ufc/search', { query: { q } });
44
- if (!res.ok) {
45
- return { candidates, calls: 1, ok: false, status: res.status };
46
- }
47
- const data = asRecord(res.data) ?? {};
48
- // withMeta nests under data; extractRows may return fighters only — read buckets explicitly
49
- const payload = asRecord(data.data) ?? data;
50
- const fighters = Array.isArray(payload.fighters)
51
- ? payload.fighters
52
- : extractRows(payload.fighters);
53
- const events = Array.isArray(payload.events)
54
- ? payload.events
55
- : extractRows(payload.events);
56
- const bouts = Array.isArray(payload.bouts)
57
- ? payload.bouts
58
- : extractRows(payload.bouts);
59
- if (type === 'any' || type === 'fighter' || type === 'player') {
60
- pushCandidates(candidates, 'ufc', 'fighter', fighters, q, limit);
69
+ let calls = 0;
70
+ let lastStatus = 200;
71
+ let anyOk = false;
72
+ const wantFighters = type === 'any' || type === 'fighter' || type === 'player';
73
+ const wantEvents = type === 'any' || type === 'event' || type === 'tournament';
74
+ const wantMatches = type === 'any' || type === 'match';
75
+ for (const query of ufcSearchQueries(q)) {
76
+ const res = await fetchJson(ctx, '/ufc/search', { query: { q: query } });
77
+ calls += 1;
78
+ lastStatus = res.status;
79
+ if (!res.ok)
80
+ continue;
81
+ anyOk = true;
82
+ const data = asRecord(res.data) ?? {};
83
+ const payload = asRecord(data.data) ?? data;
84
+ const fighters = Array.isArray(payload.fighters)
85
+ ? payload.fighters
86
+ : extractRows(payload.fighters);
87
+ const events = Array.isArray(payload.events)
88
+ ? payload.events
89
+ : extractRows(payload.events);
90
+ const bouts = Array.isArray(payload.bouts)
91
+ ? payload.bouts
92
+ : extractRows(payload.bouts);
93
+ // Always score against the *original* user query for ranking quality
94
+ if (wantFighters)
95
+ pushCandidates(candidates, 'ufc', 'fighter', fighters, q, limit);
96
+ if (wantEvents)
97
+ pushCandidates(candidates, 'ufc', 'event', events, q, limit);
98
+ if (wantMatches)
99
+ pushCandidates(candidates, 'ufc', 'match', bouts, q, limit);
61
100
  }
62
- if (type === 'any' || type === 'event' || type === 'tournament') {
63
- pushCandidates(candidates, 'ufc', 'event', events, q, limit);
101
+ // Event soft-miss fallback: scan upcoming + recent event lists client-side
102
+ if (wantEvents && !candidates.some((c) => c.type === 'event' && c.score >= 40)) {
103
+ for (const path of ['/ufc/events/upcoming', '/ufc/events/recent', '/ufc/events']) {
104
+ const res = await fetchJson(ctx, path, { query: { limit: 40, page: 1 } });
105
+ calls += 1;
106
+ if (!res.ok)
107
+ continue;
108
+ anyOk = true;
109
+ pushCandidates(candidates, 'ufc', 'event', extractRows(res.data), q, limit);
110
+ }
64
111
  }
65
- if (type === 'any' || type === 'match') {
66
- pushCandidates(candidates, 'ufc', 'match', bouts, q, limit);
112
+ // Dedupe by type+id
113
+ const seen = new Set();
114
+ const deduped = [];
115
+ for (const c of candidates.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))) {
116
+ const key = `${c.type}:${c.id}:${c.slug ?? ''}`;
117
+ if (seen.has(key))
118
+ continue;
119
+ seen.add(key);
120
+ deduped.push(c);
67
121
  }
68
- candidates.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
69
- return { candidates: candidates.slice(0, limit), calls: 1, ok: true };
122
+ return {
123
+ candidates: deduped.slice(0, limit),
124
+ calls,
125
+ ok: anyOk || deduped.length > 0,
126
+ status: anyOk ? 200 : lastStatus,
127
+ };
70
128
  }
71
129
  async function searchGame(ctx, game, q, type, limit) {
72
130
  const candidates = [];
@@ -627,26 +627,48 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
627
627
  rows = extractRows(res.data);
628
628
  }
629
629
  else if (game === 'ufc') {
630
- // try fighter stats + bouts list
631
- const [aRes, bRes, boutsRes] = await Promise.all([
630
+ // Use fighter fight history (not global /bouts page 1, which rarely contains both fighters).
631
+ const [aRes, bRes, histA, histB] = await Promise.all([
632
632
  fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideA)}`),
633
633
  fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideB)}`),
634
- fetchJson(ctx, '/ufc/bouts', { query: { limit: 50, page: 1 } }),
634
+ fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideA)}/fights`, { query: { limit: 50 } }),
635
+ fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideB)}/fights`, { query: { limit: 50 } }),
635
636
  ]);
636
- upstreamCalls += 3;
637
- rateLimit = { ...aRes.headers, ...bRes.headers, ...boutsRes.headers };
637
+ upstreamCalls += 4;
638
+ rateLimit = { ...aRes.headers, ...bRes.headers, ...histA.headers, ...histB.headers };
638
639
  if (!aRes.ok || !bRes.ok) {
639
640
  return errorEnvelope({
640
641
  code: 'NOT_FOUND',
641
- message: 'One or both fighters not found',
642
+ message: 'One or both fighters not found — pass fighter slugs from resolve_entity',
642
643
  game,
643
644
  source: 'head_to_head',
644
645
  requestId,
645
646
  tookMs: Date.now() - started,
646
647
  upstreamCalls,
648
+ recover: [
649
+ 'resolve_entity { game: "ufc", type: "fighter", q } for each name',
650
+ 'Retry head_to_head with returned slugs as sideA/sideB',
651
+ ],
647
652
  });
648
653
  }
649
- rows = boutsRes.ok ? extractRows(boutsRes.data) : [];
654
+ const toBouts = (payload) => extractRows(payload).map((row) => {
655
+ const r = asRecord(row) ?? {};
656
+ return asRecord(r.bout) ?? row;
657
+ });
658
+ const fromA = histA.ok ? toBouts(histA.data) : [];
659
+ const fromB = histB.ok ? toBouts(histB.data) : [];
660
+ // Union by bout id
661
+ const byId = new Map();
662
+ for (const bout of [...fromA, ...fromB]) {
663
+ const m = normalizeMatch('ufc', bout);
664
+ const key = m.matchId !== 'unknown' ? m.matchId : JSON.stringify(bout).slice(0, 80);
665
+ if (!byId.has(key))
666
+ byId.set(key, bout);
667
+ }
668
+ rows = [...byId.values()];
669
+ if (rows.length === 0) {
670
+ warnings.push('No fight history rows returned for either fighter; H2H may be empty');
671
+ }
650
672
  }
651
673
  const meetings = rows
652
674
  .map((row) => normalizeMatch(game, row))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Standalone MCP server for the Cito esports API — 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
5
5
  "type": "module",
6
6
  "bin": {