cito-mcp 0.2.4 → 0.2.6

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.
@@ -29,6 +29,24 @@ function identityFrom(game, raw, idHint, slugHint) {
29
29
  : null,
30
30
  role: pickString(r.role, r.position) ?? null,
31
31
  nationality: pickString(r.nationality, r.country) ?? null,
32
+ /**
33
+ * Always present, keys always present, null when the upstream has no photo.
34
+ *
35
+ * REST has carried headshot/body/proxied images on /ufc/fighters/{slug} all
36
+ * along; this shaper silently dropped them, so every agent concluded the
37
+ * product had no images and either shipped faceless UIs or N+1'd call_api
38
+ * per fighter to dig them out. Emitting explicit nulls is the point: silence
39
+ * reads as "not supported", null reads as "not available for this one".
40
+ *
41
+ * proxiedImageUrl is the one builders should prefer — it is served from our
42
+ * own domain, so it works from a browser without hotlink/CORS trouble.
43
+ */
44
+ images: {
45
+ headshotUrl: pickString(r.headshotUrl, r.headshot) ?? null,
46
+ bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
47
+ imageUrl: pickString(r.imageUrl, r.image, r.photoUrl) ?? null,
48
+ proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
49
+ },
32
50
  };
33
51
  }
34
52
  export const playerProfile = {
@@ -332,7 +350,14 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
332
350
  .map((row) => {
333
351
  const r = asRecord(row) ?? {};
334
352
  const bout = asRecord(r.bout) ?? row;
335
- const m = normalizeMatch('ufc', bout, 'completed');
353
+ // Never force 'completed'. A fighter's history endpoint also
354
+ // returns bouts that are booked but not yet fought, and forcing
355
+ // the status told agents that a future main event had already
356
+ // happened (Hernandez vs Rodrigues, ufc-12928: status
357
+ // 'confirmed', no method, no winner, event still scheduled,
358
+ // reported as completed with result null). Let normalizeMatch
359
+ // derive it from the result and the start time instead.
360
+ const m = normalizeMatch('ufc', bout);
336
361
  return {
337
362
  ...m,
338
363
  result: pickString(r.result, r.outcome, asRecord(bout)?.result) ?? null,
@@ -354,7 +379,8 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
354
379
  .map((row) => {
355
380
  const r = asRecord(row) ?? {};
356
381
  const bout = asRecord(r.bout) ?? row;
357
- return normalizeMatch('ufc', bout, 'completed');
382
+ // Same as above: derive, never assert. See the note there.
383
+ return normalizeMatch('ufc', bout);
358
384
  });
359
385
  }
360
386
  else {
@@ -144,7 +144,9 @@ async function searchGame(ctx, game, q, type, limit) {
144
144
  }),
145
145
  };
146
146
  }
147
- const data = asRecord(res.data) ?? {};
147
+ // Unwrap the { success, data, meta } envelope before reading buckets.
148
+ const envelope = asRecord(res.data) ?? {};
149
+ const data = asRecord(envelope.data) ?? envelope;
148
150
  const buckets = [
149
151
  ['team', data.teams],
150
152
  ['player', data.players],
@@ -159,7 +161,15 @@ async function searchGame(ctx, game, q, type, limit) {
159
161
  pushCandidates(candidates, game, t, rows, q, limit);
160
162
  }
161
163
  if (candidates.length === 0) {
162
- pushCandidates(candidates, game, type === 'any' ? 'unknown' : type, extractRows(res.data), q, limit);
164
+ const fallbackType = type === 'any' ? 'unknown' : type;
165
+ let rows = extractRows(data);
166
+ // extractRows prefers the `matches` bucket; never mislabel those rows as a non-match type.
167
+ if (fallbackType !== 'match' && fallbackType !== 'unknown') {
168
+ const matchRows = extractRows(data.matches);
169
+ if (matchRows.length && rows[0] === matchRows[0])
170
+ rows = [];
171
+ }
172
+ pushCandidates(candidates, game, fallbackType, rows, q, limit);
163
173
  }
164
174
  }
165
175
  else if (game === 'dota2') {
@@ -176,7 +186,9 @@ async function searchGame(ctx, game, q, type, limit) {
176
186
  }),
177
187
  };
178
188
  }
179
- const data = asRecord(res.data) ?? {};
189
+ // Unwrap the { success, data, meta } envelope before reading buckets.
190
+ const envelope = asRecord(res.data) ?? {};
191
+ const data = asRecord(envelope.data) ?? envelope;
180
192
  for (const [t, key] of [
181
193
  ['team', 'teams'],
182
194
  ['player', 'players'],
@@ -188,7 +200,15 @@ async function searchGame(ctx, game, q, type, limit) {
188
200
  pushCandidates(candidates, game, t, extractRows(data[key] ?? data), q, limit);
189
201
  }
190
202
  if (candidates.length === 0) {
191
- pushCandidates(candidates, game, type === 'any' ? 'unknown' : type, extractRows(res.data), q, limit);
203
+ const fallbackType = type === 'any' ? 'unknown' : type;
204
+ let rows = extractRows(data);
205
+ // extractRows prefers the `matches` bucket; never mislabel those rows as a non-match type.
206
+ if (fallbackType !== 'match' && fallbackType !== 'unknown') {
207
+ const matchRows = extractRows(data.matches);
208
+ if (matchRows.length && rows[0] === matchRows[0])
209
+ rows = [];
210
+ }
211
+ pushCandidates(candidates, game, fallbackType, rows, q, limit);
192
212
  }
193
213
  }
194
214
  else if (game === 'cod') {
@@ -313,20 +333,20 @@ async function searchGame(ctx, game, q, type, limit) {
313
333
  }
314
334
  export const resolveEntity = {
315
335
  name: 'resolve_entity',
316
- description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
317
-
318
- When to use:
319
- - User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
320
- - Need a canonical id/slug before profile or match tools
321
-
322
- Prefer over search_entities when you want one best match (or small ranked set) to chain.
323
- Prefer search_entities when browsing many results with pagination.
324
-
325
- Do not use when: you already have a stable id/slug from a prior tool.
326
-
327
- Empty/ambiguous results still return ok:true with best=null or needsDisambiguation=true — pick from candidates or refine q/game/type. Does not emit AMBIGUOUS_ENTITY as a hard error.
328
-
329
- Parallel-safe: yes. Upstream cost: 1–5.
336
+ description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
337
+
338
+ When to use:
339
+ - User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
340
+ - Need a canonical id/slug before profile or match tools
341
+
342
+ Prefer over search_entities when you want one best match (or small ranked set) to chain.
343
+ Prefer search_entities when browsing many results with pagination.
344
+
345
+ Do not use when: you already have a stable id/slug from a prior tool.
346
+
347
+ Empty/ambiguous results still return ok:true with best=null or needsDisambiguation=true — pick from candidates or refine q/game/type. Does not emit AMBIGUOUS_ENTITY as a hard error.
348
+
349
+ Parallel-safe: yes. Upstream cost: 1–5.
330
350
  Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
331
351
  inputSchema: {
332
352
  type: 'object',
@@ -420,22 +440,22 @@ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
420
440
  };
421
441
  export const searchEntities = {
422
442
  name: 'search_entities',
423
- description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
424
-
425
- When to use:
426
- - Typeahead / pickers
427
- - "List teams matching…"
428
- - Exploring entities without committing to one ID
429
- - UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
430
-
431
- Prefer over resolve_entity when the user wants a list.
432
- Prefer resolve_entity when chaining one name into a profile tool.
433
-
434
- Do not use when: fetching a known entity profile — use team_profile or player_profile.
435
-
436
- UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
437
-
438
- Parallel-safe: yes. Upstream cost: 1–3.
443
+ description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
444
+
445
+ When to use:
446
+ - Typeahead / pickers
447
+ - "List teams matching…"
448
+ - Exploring entities without committing to one ID
449
+ - UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
450
+
451
+ Prefer over resolve_entity when the user wants a list.
452
+ Prefer resolve_entity when chaining one name into a profile tool.
453
+
454
+ Do not use when: fetching a known entity profile — use team_profile or player_profile.
455
+
456
+ UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
457
+
458
+ Parallel-safe: yes. Upstream cost: 1–3.
439
459
  Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
440
460
  inputSchema: {
441
461
  type: 'object',
@@ -520,7 +540,9 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
520
540
  const res = await fetchJson(ctx, '/cs2/search', { query: { q, limit } });
521
541
  upstreamCalls += 1;
522
542
  if (res.ok) {
523
- const data = asRecord(res.data) ?? {};
543
+ // Unwrap the { success, data, meta } envelope before reading buckets.
544
+ const envelope = asRecord(res.data) ?? {};
545
+ const data = asRecord(envelope.data) ?? envelope;
524
546
  if (type === 'any' || type === 'team') {
525
547
  items.push(...extractRows(data.teams).map((r) => entityRef(r, 'team', game)));
526
548
  }
@@ -535,8 +557,15 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
535
557
  else {
536
558
  if (type === 'team' || type === 'any')
537
559
  await listPath('/cs2/teams', 'team');
538
- if (type === 'player' || type === 'any')
539
- await listPath('/cs2/players', 'player');
560
+ if (type === 'player' || type === 'any') {
561
+ if (q) {
562
+ // /cs2/players list filters ignore q/search — use the dedicated search endpoint.
563
+ await listPath('/cs2/players/search', 'player');
564
+ }
565
+ else {
566
+ await listPath('/cs2/players', 'player');
567
+ }
568
+ }
540
569
  if (type === 'event' || type === 'tournament' || type === 'any')
541
570
  await listPath('/cs2/events', 'event');
542
571
  }
@@ -321,6 +321,32 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
321
321
  rows = nested.map((row, i) => normalizeStandingRow(row, i)).slice(0, limit);
322
322
  }
323
323
  const obj = asRecord(res.data);
324
+ const upstreamMeta = asRecord(obj?.meta) ?? {};
325
+ const dataQuality = asRecord(upstreamMeta.dataQuality) ?? {};
326
+ const warnings = [];
327
+ const pushWarning = (value) => {
328
+ if (typeof value !== 'string')
329
+ return;
330
+ const trimmed = value.trim();
331
+ if (trimmed && !warnings.includes(trimmed))
332
+ warnings.push(trimmed);
333
+ };
334
+ pushWarning(upstreamMeta.warning);
335
+ pushWarning(dataQuality.warning);
336
+ if (Array.isArray(upstreamMeta.warnings)) {
337
+ for (const w of upstreamMeta.warnings)
338
+ pushWarning(w);
339
+ }
340
+ const dataFreshness = pickString(upstreamMeta.dataFreshness, upstreamMeta.freshnessStatus, dataQuality.dataFreshness);
341
+ const status = pickString(upstreamMeta.status, dataQuality.status);
342
+ if (status === 'fallback') {
343
+ pushWarning('Standings served from last-known-good rankings (fallback).');
344
+ }
345
+ if (dataFreshness === 'cached' || upstreamMeta.stale === true) {
346
+ pushWarning(typeof upstreamMeta.dataAgeHours === 'number'
347
+ ? `Rankings dataFreshness=cached (ageHours=${upstreamMeta.dataAgeHours}).`
348
+ : 'Rankings dataFreshness=cached or stale.');
349
+ }
324
350
  return successEnvelope({
325
351
  source: 'standings',
326
352
  game,
@@ -328,13 +354,20 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
328
354
  tookMs: Date.now() - started,
329
355
  upstreamCalls: 1,
330
356
  rateLimit: res.headers,
357
+ warnings: warnings.length ? warnings : undefined,
331
358
  data: {
332
359
  scope: effectiveScope,
333
360
  title,
334
361
  season: season ?? null,
335
362
  stage: stage ?? null,
336
363
  rows,
337
- updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated) ?? null,
364
+ updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt) ??
365
+ null,
366
+ ...(dataFreshness ? { dataFreshness } : {}),
367
+ ...(status ? { status } : {}),
368
+ ...(upstreamMeta.warning || dataQuality.warning
369
+ ? { warning: pickString(upstreamMeta.warning, dataQuality.warning) }
370
+ : {}),
338
371
  },
339
372
  });
340
373
  },
@@ -21,18 +21,18 @@ function teamIdentity(game, raw, idHint, slugHint) {
21
21
  }
22
22
  export const teamProfile = {
23
23
  name: 'team_profile',
24
- description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
25
-
26
- When to use:
27
- - Team page / "who is on this roster?"
28
- - Builder team screen sample
29
-
30
- Prefer over: separate roster + matches + detail via call_api.
31
-
32
- Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
33
- Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
34
-
35
- Parallel-safe: yes. Upstream cost: 2–4.
24
+ description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
25
+
26
+ When to use:
27
+ - Team page / "who is on this roster?"
28
+ - Builder team screen sample
29
+
30
+ Prefer over: separate roster + matches + detail via call_api.
31
+
32
+ Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
33
+ Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
34
+
35
+ Parallel-safe: yes. Upstream cost: 2–4.
36
36
  Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
37
37
  inputSchema: {
38
38
  type: 'object',
@@ -123,7 +123,12 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
123
123
  teamRaw = res.data;
124
124
  }
125
125
  else if (game === 'cs2') {
126
- const res = await fetchJson(ctx, '/cs2/teams', { query: { search: idOrSlug, limit: 5 } });
126
+ // A cs2-team-<n> / hltv-team-<n> id resolves 1:1 fetch it directly instead of
127
+ // name-searching, which could silently fall back to rows[0] of an unrelated search.
128
+ const directId = /^(cs2|hltv)-team-\d+$/i.test(idOrSlug);
129
+ const res = directId
130
+ ? await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(idOrSlug)}`)
131
+ : await fetchJson(ctx, '/cs2/teams', { query: { search: idOrSlug, limit: 5 } });
127
132
  upstreamCalls += 1;
128
133
  rateLimit = res.headers;
129
134
  if (!res.ok) {
@@ -143,7 +148,10 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
143
148
  ],
144
149
  });
145
150
  }
146
- {
151
+ if (directId) {
152
+ teamRaw = res.data;
153
+ }
154
+ else {
147
155
  const rows = extractRows(res.data);
148
156
  teamRaw =
149
157
  rows.find((row) => {
@@ -285,20 +293,20 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
285
293
  if (game === 'cs2') {
286
294
  const tid = team.id !== 'unknown' ? team.id : idOrSlug;
287
295
  tasks.push((async () => {
288
- const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(tid)}/roster-history`);
296
+ const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(tid)}/roster`);
289
297
  upstreamCalls += 1;
290
298
  rateLimit = { ...rateLimit, ...res.headers };
291
299
  if (res.ok) {
292
300
  roster = {
293
301
  items: extractRows(res.data).slice(0, 20),
294
302
  asOf: new Date().toISOString(),
295
- quality: 'history',
303
+ quality: 'current',
296
304
  };
297
305
  }
298
306
  else {
299
307
  partial.push(partialFromRejection('roster', {
300
308
  code: mapHttpToCode(res.status),
301
- message: `roster-history HTTP ${res.status}`,
309
+ message: `roster HTTP ${res.status}`,
302
310
  httpStatus: res.status,
303
311
  }));
304
312
  }
@@ -478,19 +486,19 @@ function winnerSide(match, a) {
478
486
  }
479
487
  export const headToHead = {
480
488
  name: 'head_to_head',
481
- description: `Composed head-to-head record between two teams (or two UFC fighters). No first-class REST H2H exists — this tool filters match history server-side.
482
-
483
- When to use:
484
- - Rivalry / series record questions
485
- - Supporting context for previews
486
-
487
- Prefer over: agent-side double match-list filtering.
488
-
489
- Do not use when: single-side form only → team_profile or player_profile.
490
-
491
- Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
492
-
493
- Parallel-safe: yes. Upstream cost: 2–4.
489
+ description: `Composed head-to-head record between two teams (or two UFC fighters). No first-class REST H2H exists — this tool filters match history server-side.
490
+
491
+ When to use:
492
+ - Rivalry / series record questions
493
+ - Supporting context for previews
494
+
495
+ Prefer over: agent-side double match-list filtering.
496
+
497
+ Do not use when: single-side form only → team_profile or player_profile.
498
+
499
+ Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
500
+
501
+ Parallel-safe: yes. Upstream cost: 2–4.
494
502
  Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
495
503
  inputSchema: {
496
504
  type: 'object',
@@ -547,24 +555,80 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
547
555
  let upstreamCalls = 0;
548
556
  let rateLimit = {};
549
557
  let rows = [];
558
+ // Names used for client-side side matching; replaced with resolved team names
559
+ // when the id-based REST H2H succeeds (short inputs like "navi" never substring-match "Natus Vincere").
560
+ let matchA = sideA;
561
+ let matchB = sideB;
550
562
  if (game === 'cs2') {
551
- const res = await fetchJson(ctx, '/cs2/matches', { query: { team: sideA, limit: 50 } });
552
- upstreamCalls += 1;
553
- rateLimit = res.headers;
554
- if (!res.ok) {
555
- return errorEnvelope({
556
- code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
557
- message: `H2H match fetch failed (HTTP ${res.status})`,
558
- game,
559
- source: 'head_to_head',
560
- requestId,
561
- tookMs: Date.now() - started,
562
- upstreamCalls,
563
- httpStatus: res.status,
564
- rateLimit: res.headers,
563
+ // Prefer the purpose-built id-based H2H endpoint; /cs2/matches?team= name-substring
564
+ // filtering misses short names ("navi" vs stored "Natus Vincere").
565
+ const resolveCs2Side = async (side) => {
566
+ const res = await fetchJson(ctx, '/cs2/teams', { query: { search: side, limit: 5 } });
567
+ upstreamCalls += 1;
568
+ rateLimit = { ...rateLimit, ...res.headers };
569
+ if (!res.ok)
570
+ return null;
571
+ const found = extractRows(res.data);
572
+ const hit = found.find((row) => {
573
+ const r = asRecord(row) ?? {};
574
+ return [r.id, r.slug, r.name].map((x) => String(x ?? '').toLowerCase()).includes(side.toLowerCase());
575
+ }) ?? found[0];
576
+ const r = asRecord(hit);
577
+ const id = pickString(r?.id, r?.teamId);
578
+ return id ? { id, name: pickString(r?.name) ?? side } : null;
579
+ };
580
+ const [cs2SideA, cs2SideB] = await Promise.all([resolveCs2Side(sideA), resolveCs2Side(sideB)]);
581
+ let restH2hOk = false;
582
+ if (cs2SideA && cs2SideB) {
583
+ const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(cs2SideA.id)}/vs/${encodeURIComponent(cs2SideB.id)}`, {
584
+ query: { limit: 50 },
565
585
  });
586
+ upstreamCalls += 1;
587
+ rateLimit = { ...rateLimit, ...res.headers };
588
+ if (res.ok) {
589
+ // Rows are side-A-perspective summaries (summarizeMatchForTeam); reshape into
590
+ // flat match rows so the shared normalize/score pipeline applies unchanged.
591
+ rows = extractRows(res.data).map((row) => {
592
+ const r = asRecord(row) ?? {};
593
+ return {
594
+ matchId: r.match_id,
595
+ status: r.status,
596
+ startsAt: r.starts_at,
597
+ eventName: r.event_name,
598
+ bestOf: r.best_of,
599
+ team1Name: cs2SideA.name,
600
+ team1Score: r.team_score,
601
+ team2Name: pickString(r.opponent_name) ?? cs2SideB.name,
602
+ team2Score: r.opponent_score,
603
+ };
604
+ });
605
+ matchA = cs2SideA.name;
606
+ matchB = cs2SideB.name;
607
+ restH2hOk = true;
608
+ }
609
+ else {
610
+ warnings.push(`REST H2H HTTP ${res.status}; fell back to match-history name filtering`);
611
+ }
612
+ }
613
+ if (!restH2hOk) {
614
+ const res = await fetchJson(ctx, '/cs2/matches', { query: { team: sideA, limit: 50 } });
615
+ upstreamCalls += 1;
616
+ rateLimit = { ...rateLimit, ...res.headers };
617
+ if (!res.ok) {
618
+ return errorEnvelope({
619
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
620
+ message: `H2H match fetch failed (HTTP ${res.status})`,
621
+ game,
622
+ source: 'head_to_head',
623
+ requestId,
624
+ tookMs: Date.now() - started,
625
+ upstreamCalls,
626
+ httpStatus: res.status,
627
+ rateLimit: res.headers,
628
+ });
629
+ }
630
+ rows = extractRows(res.data);
566
631
  }
567
- rows = extractRows(res.data);
568
632
  }
569
633
  else if (game === 'cod') {
570
634
  const res = await fetchJson(ctx, '/cod/matches', { query: { team: sideA, limit: 50 } });
@@ -672,7 +736,7 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
672
736
  }
673
737
  const meetings = rows
674
738
  .map((row) => normalizeMatch(game, row))
675
- .filter((m) => sidesMatch(m, sideA, sideB))
739
+ .filter((m) => sidesMatch(m, matchA, matchB))
676
740
  .filter((m) => {
677
741
  if (!fromIso && !toIso)
678
742
  return true;
@@ -692,7 +756,7 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
692
756
  let winsB = 0;
693
757
  let draws = 0;
694
758
  for (const m of meetings) {
695
- const w = winnerSide(m, sideA);
759
+ const w = winnerSide(m, matchA);
696
760
  if (w === 'A')
697
761
  winsA += 1;
698
762
  else if (w === 'B')
@@ -64,9 +64,82 @@ export function stringSchema(description, example) {
64
64
  schema.examples = [example];
65
65
  return schema;
66
66
  }
67
+ /** Levenshtein distance, capped for short arg names. */
68
+ function editDistance(a, b) {
69
+ const m = a.length;
70
+ const n = b.length;
71
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
72
+ for (let i = 1; i <= m; i++) {
73
+ const cur = [i];
74
+ for (let j = 1; j <= n; j++) {
75
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
76
+ }
77
+ prev = cur;
78
+ }
79
+ return prev[n];
80
+ }
81
+ /** Closest known parameter to a mistyped one, if it is plausibly a typo. */
82
+ function suggestParam(unknown, known) {
83
+ const u = unknown.toLowerCase();
84
+ let best = null;
85
+ for (const k of known) {
86
+ const d = editDistance(u, k.toLowerCase());
87
+ if (!best || d < best.d)
88
+ best = { name: k, d };
89
+ }
90
+ if (!best)
91
+ return null;
92
+ // Accept near-misses, plus prefix relationships like query -> q.
93
+ const threshold = Math.max(2, Math.floor(Math.max(u.length, best.name.length) / 3));
94
+ if (best.d <= threshold)
95
+ return best.name;
96
+ const prefixHit = known.find((k) => u.startsWith(k.toLowerCase()) || k.toLowerCase().startsWith(u));
97
+ return prefixHit ?? null;
98
+ }
99
+ /**
100
+ * Args the tool does not declare, when its schema says additionalProperties:false.
101
+ *
102
+ * Every tool already declared that, but nothing enforced it: the MCP SDK does
103
+ * not validate arguments against inputSchema, so a plausible-but-wrong name was
104
+ * dropped on the floor and the tool ran with a default. search_entities
105
+ * { game:"ufc", query:"Jon Jones" } — `q` is the real parameter — returned
106
+ * ok:true with an unrelated roster. Silently wrong beats loudly wrong for a
107
+ * human skimming output, but for an agent it is far worse: it has no way to
108
+ * tell a real answer from a discarded question.
109
+ */
110
+ export function unknownArgKeys(def, args) {
111
+ const schema = def.inputSchema ?? {};
112
+ if (schema.additionalProperties !== false)
113
+ return [];
114
+ const props = schema.properties;
115
+ if (!props || typeof props !== 'object')
116
+ return [];
117
+ const known = Object.keys(props);
118
+ return Object.keys(args ?? {}).filter((k) => !known.includes(k));
119
+ }
67
120
  /** Normalize MCP handler return to MCP content result. */
68
121
  export async function runTool(def, args, ctx) {
69
122
  try {
123
+ const unknown = unknownArgKeys(def, args ?? {});
124
+ if (unknown.length > 0) {
125
+ const known = Object.keys((def.inputSchema.properties ?? {}));
126
+ const pairs = unknown.map((k) => ({ k, s: suggestParam(k, known) }));
127
+ const hints = pairs.map(({ k, s }) => s ? `"${k}" — did you mean "${s}"?` : `"${k}" is not accepted`);
128
+ const { errorEnvelope, toMcpResult: toResult } = await import('../envelope.js');
129
+ return toResult(errorEnvelope({
130
+ code: 'VALIDATION',
131
+ message: `${def.name}: unknown argument${unknown.length > 1 ? 's' : ''} ` +
132
+ // Avoid "?." when the last hint is a did-you-mean question.
133
+ `${hints.join('; ')}${hints[hints.length - 1].endsWith('?') ? '' : '.'} ` +
134
+ `Accepted: ${known.join(', ')}.`,
135
+ game: typeof args?.game === 'string' ? args.game : null,
136
+ source: def.name,
137
+ recover: [
138
+ ...pairs.map(({ k, s }) => s ? `Rename "${k}" to "${s}" and retry` : `Remove "${k}" and retry`),
139
+ `Accepted arguments: ${known.join(', ')}`,
140
+ ],
141
+ }));
142
+ }
70
143
  const result = await def.handler(args ?? {}, ctx);
71
144
  if (result && typeof result === 'object' && 'content' in result) {
72
145
  return result;
@@ -0,0 +1,29 @@
1
+ import { createRequire } from 'node:module';
2
+ /**
3
+ * Single source of truth for the server version.
4
+ *
5
+ * This used to be hardcoded in three places — package.json, PACKAGE_VERSION in
6
+ * index.ts, and CATALOG_VERSION in tools/meta.ts — which had already drifted:
7
+ * a test asserted 0.2.4 while the package shipped 0.2.5. Agents read the version
8
+ * out of list_capabilities, so a stale value there is a support problem, not a
9
+ * cosmetic one. Read it from the manifest instead, so `npm version` is the only
10
+ * edit a release needs.
11
+ *
12
+ * createRequire rather than a JSON import: import assertions are still awkward
13
+ * across the Node versions this package supports (>=20), and this resolves the
14
+ * same from src/ under tsx and from dist/ once built, because package.json is
15
+ * always published alongside dist.
16
+ */
17
+ const require = createRequire(import.meta.url);
18
+ function readVersion() {
19
+ try {
20
+ const pkg = require('../package.json');
21
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
22
+ }
23
+ catch {
24
+ // Never let a packaging quirk crash the server on boot; a wrong-but-present
25
+ // version is far better than a failed start.
26
+ return '0.0.0';
27
+ }
28
+ }
29
+ export const PACKAGE_VERSION = readVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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": {
@@ -17,7 +17,8 @@
17
17
  "build": "tsc -p tsconfig.json",
18
18
  "test": "tsx --test src/**/*.test.ts src/*.test.ts",
19
19
  "start": "node dist/index.js",
20
- "prepublishOnly": "npm run build"
20
+ "prepublishOnly": "npm run build && npm test && npm run smoke",
21
+ "smoke": "node scripts/smoke.mjs"
21
22
  },
22
23
  "dependencies": {
23
24
  "@modelcontextprotocol/sdk": "1.29.0"