cito-mcp 0.3.17 → 0.3.19

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.
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
- import { normalizeMatch, presentSides, sortByCardOrder } from './normalize.js';
6
+ import { chooseNamedEntity, normalizeMatch, presentSides, sortByCardOrder } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
8
8
  async function loadSide(ctx, game, side, recentLimit, includeRosters) {
9
9
  const partial = [];
@@ -663,9 +663,17 @@ async function resolveEventKey(ctx, game, q) {
663
663
  calls += 1;
664
664
  rateLimit = res.headers;
665
665
  const rows = extractRows(unwrapPayload(res.data) ?? res.data).map((r) => asRecord(r) ?? {});
666
+ // .find() returned whatever the calendar happened to list first, and the
667
+ // calendar lists the WTA row before the ATP one for a shared name, while
668
+ // /tennis/competitions (which resolve_entity reads) lists them the other
669
+ // way round. Same query, two tools, two ids. One shared rule now settles
670
+ // both; see chooseNamedEntity.
666
671
  hit =
667
- rows.find((r) => String(pickString(r.name) ?? '').toLowerCase() === needle) ??
668
- rows.find((r) => String(pickString(r.name) ?? '').toLowerCase().includes(needle));
672
+ chooseNamedEntity(rows, needle, (r) => ({
673
+ id: pickString(r.id, r.tournament_id) ?? '',
674
+ name: pickString(r.name) ?? '',
675
+ year: r.year,
676
+ })) ?? undefined;
669
677
  if (hit)
670
678
  break;
671
679
  }
@@ -638,3 +638,51 @@ export function rankScore(query, name, id, slug, ...extra) {
638
638
  }
639
639
  return best;
640
640
  }
641
+ /**
642
+ * Edition year encoded in a tennis-style entity id ("atp_2026_580" -> 2026).
643
+ *
644
+ * `/tennis/competitions` returns no `year` field while the calendar does, so
645
+ * the id is the only signal available on both, and the two tools that disagreed
646
+ * about "Australian Open" were each reading a different one of those endpoints.
647
+ */
648
+ export function editionYear(id, explicit) {
649
+ const direct = Number(explicit);
650
+ if (Number.isFinite(direct) && direct > 1800)
651
+ return direct;
652
+ const match = /^[a-z]+_(\d{4})_/i.exec(String(id ?? ''));
653
+ return match ? Number(match[1]) : null;
654
+ }
655
+ /**
656
+ * Deterministic winner among rows whose names match a query equally well.
657
+ *
658
+ * "Australian Open" is two rows in every season, one ATP and one WTA, sharing a
659
+ * name exactly. resolve_entity read /tennis/competitions, which lists ATP
660
+ * first; event_card read /tennis/tournaments/calendar, which lists WTA first;
661
+ * and neither applied a tiebreak, so the same question got atp_2026_580 from
662
+ * one tool and wta_2026_580 from the other. Neither answer was wrong on its
663
+ * own. The absence of a rule was the bug.
664
+ *
665
+ * Order: an exact name beats a substring, a newer edition beats an older one,
666
+ * and the id settles whatever is left. That last step is arbitrary, and that is
667
+ * fine -- it only has to be FIXED, which is the property that was missing.
668
+ * Callers that need the alternatives still surface them; this only decides
669
+ * which single row is called "best".
670
+ */
671
+ export function chooseNamedEntity(rows, needle, read) {
672
+ const want = needle.trim().toLowerCase();
673
+ if (!want || !rows.length)
674
+ return null;
675
+ const scored = rows.map((row) => ({ row, ref: read(row) }));
676
+ const exact = scored.filter((s) => s.ref.name.trim().toLowerCase() === want);
677
+ const pool = exact.length
678
+ ? exact
679
+ : scored.filter((s) => s.ref.name.trim().toLowerCase().includes(want));
680
+ if (!pool.length)
681
+ return null;
682
+ pool.sort((a, b) => {
683
+ const ay = editionYear(a.ref.id, a.ref.year) ?? -1;
684
+ const by = editionYear(b.ref.id, b.ref.year) ?? -1;
685
+ return by - ay || a.ref.id.localeCompare(b.ref.id);
686
+ });
687
+ return pool[0].row;
688
+ }
@@ -3,8 +3,25 @@
3
3
  */
4
4
  import { clampInt, decodeCursor, encodeCursor, extractRows, fetchJson, gameNotIncludedHint, pickString, asRecord, } from '../client.js';
5
5
  import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
- import { entityRef, rankScore } from './normalize.js';
6
+ import { editionYear, entityRef, rankScore } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
8
+ /**
9
+ * Ranking order whose tiebreak never depends on the order upstream happened to
10
+ * return rows in.
11
+ *
12
+ * "Australian Open" is two rows with byte-identical names and identical fuzzy
13
+ * scores, one ATP and one WTA. The old comparator ran out of criteria there and
14
+ * left the winner to the source ordering, and event_card reads a different
15
+ * source that orders them the other way round, so the two tools answered the
16
+ * same question with different ids. Newest edition, then id, matching
17
+ * chooseNamedEntity so both paths land on the same row.
18
+ */
19
+ function byRank(a, b) {
20
+ return (b.score - a.score
21
+ || a.name.localeCompare(b.name)
22
+ || ((editionYear(b.id) ?? -1) - (editionYear(a.id) ?? -1))
23
+ || a.id.localeCompare(b.id));
24
+ }
8
25
  /**
9
26
  * Query aliases for orgs whose common name is not their slug. Kept deliberately
10
27
  * small and one-directional: these map what a person types onto the canonical
@@ -54,7 +71,7 @@ function pushCandidates(out, game, type, rows, q, limit) {
54
71
  ...(Object.keys(secondary).length ? { secondaryIds: secondary } : {}),
55
72
  });
56
73
  }
57
- out.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
74
+ out.sort(byRank);
58
75
  if (out.length > limit * 3)
59
76
  out.length = limit * 3;
60
77
  }
@@ -133,7 +150,7 @@ async function ufcSearch(ctx, q, type, limit) {
133
150
  // Dedupe by type+id
134
151
  const seen = new Set();
135
152
  const deduped = [];
136
- for (const c of candidates.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))) {
153
+ for (const c of candidates.sort(byRank)) {
137
154
  const key = `${c.type}:${c.id}:${c.slug ?? ''}`;
138
155
  if (seen.has(key))
139
156
  continue;
@@ -394,25 +411,25 @@ async function searchGame(ctx, game, q, type, limit) {
394
411
  }),
395
412
  };
396
413
  }
397
- candidates.sort((a, b) => b.score - a.score);
414
+ candidates.sort(byRank);
398
415
  return { candidates: candidates.slice(0, limit), calls };
399
416
  }
400
417
  export const resolveEntity = {
401
418
  name: 'resolve_entity',
402
- description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
403
-
404
- When to use:
405
- - User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
406
- - Need a canonical id/slug before profile or match tools
407
-
408
- Prefer over search_entities when you want one best match (or small ranked set) to chain.
409
- Prefer search_entities when browsing many results with pagination.
410
-
411
- Do not use when: you already have a stable id/slug from a prior tool.
412
-
413
- 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.
414
-
415
- Parallel-safe: yes. Upstream cost: 1–5.
419
+ description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
420
+
421
+ When to use:
422
+ - User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
423
+ - Need a canonical id/slug before profile or match tools
424
+
425
+ Prefer over search_entities when you want one best match (or small ranked set) to chain.
426
+ Prefer search_entities when browsing many results with pagination.
427
+
428
+ Do not use when: you already have a stable id/slug from a prior tool.
429
+
430
+ 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.
431
+
432
+ Parallel-safe: yes. Upstream cost: 1–5.
416
433
  Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
417
434
  inputSchema: {
418
435
  type: 'object',
@@ -473,7 +490,7 @@ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
473
490
  if (r.error)
474
491
  partial.push(r.error);
475
492
  }
476
- all.sort((a, b) => b.score - a.score);
493
+ all.sort(byRank);
477
494
  const candidates = all.slice(0, limit);
478
495
  const best = candidates[0] ?? null;
479
496
  // An exact identity hit is not ambiguous, whatever else scored near it.
@@ -520,22 +537,22 @@ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
520
537
  };
521
538
  export const searchEntities = {
522
539
  name: 'search_entities',
523
- description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
524
-
525
- When to use:
526
- - Typeahead / pickers
527
- - "List teams matching…"
528
- - Exploring entities without committing to one ID
529
- - UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
530
-
531
- Prefer over resolve_entity when the user wants a list.
532
- Prefer resolve_entity when chaining one name into a profile tool.
533
-
534
- Do not use when: fetching a known entity profile — use team_profile or player_profile.
535
-
536
- 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.
537
-
538
- Parallel-safe: yes. Upstream cost: 1–3.
540
+ description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
541
+
542
+ When to use:
543
+ - Typeahead / pickers
544
+ - "List teams matching…"
545
+ - Exploring entities without committing to one ID
546
+ - UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
547
+
548
+ Prefer over resolve_entity when the user wants a list.
549
+ Prefer resolve_entity when chaining one name into a profile tool.
550
+
551
+ Do not use when: fetching a known entity profile — use team_profile or player_profile.
552
+
553
+ 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.
554
+
555
+ Parallel-safe: yes. Upstream cost: 1–3.
539
556
  Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
540
557
  inputSchema: {
541
558
  type: 'object',
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * team_profile + head_to_head
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, PRIMARY_GAMES, stringSchema, } from './types.js';
@@ -24,22 +24,36 @@ function teamIdentity(game, raw, idHint, slugHint) {
24
24
  name,
25
25
  game,
26
26
  region: pickString(nested.region, nested.country) ?? null,
27
+ // CS2 publishes countryName/countryCode and worldRanking on the team
28
+ // resource and none of it reached this card: a team page rendered with no
29
+ // flag and no ladder position while the API had both. Spirit came back as
30
+ // {id, slug, name, game, region} with world #1 nowhere in sight.
31
+ country: pickString(nested.countryName, nested.country, nested.countryCode) ?? null,
32
+ countryCode: pickString(nested.countryCode) ?? null,
33
+ worldRanking: numberOrNull(nested.worldRanking ?? nested.ranking ?? nested.rank),
27
34
  };
28
35
  }
36
+ /** A finite number, or null. Ranks arrive as number or numeric string. */
37
+ function numberOrNull(value) {
38
+ if (value === null || value === undefined || value === '')
39
+ return null;
40
+ const parsed = Number(value);
41
+ return Number.isFinite(parsed) ? parsed : null;
42
+ }
29
43
  export const teamProfile = {
30
44
  name: 'team_profile',
31
- description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
32
-
33
- When to use:
34
- - Team page / "who is on this roster?"
35
- - Builder team screen sample
36
-
37
- Prefer over: separate roster + matches + detail via call_api.
38
-
39
- Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
40
- Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
41
-
42
- Parallel-safe: yes. Upstream cost: 2–4.
45
+ description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
46
+
47
+ When to use:
48
+ - Team page / "who is on this roster?"
49
+ - Builder team screen sample
50
+
51
+ Prefer over: separate roster + matches + detail via call_api.
52
+
53
+ Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
54
+ Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
55
+
56
+ Parallel-safe: yes. Upstream cost: 2–4.
43
57
  Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
44
58
  inputSchema: {
45
59
  type: 'object',
@@ -374,8 +388,11 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
374
388
  const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(tid)}/trends`);
375
389
  upstreamCalls += 1;
376
390
  rateLimit = { ...rateLimit, ...res.headers };
391
+ // res.data is the whole upstream body, so this nested
392
+ // {success, data:{...}} inside our own envelope. Every other field
393
+ // here is normalized; this one leaked the REST wrapper to the agent.
377
394
  if (res.ok)
378
- form = res.data;
395
+ form = unwrapPayload(res.data) ?? res.data;
379
396
  else {
380
397
  partial.push(partialFromRejection('form', {
381
398
  code: mapHttpToCode(res.status),
@@ -545,19 +562,19 @@ function winnerSide(match, a) {
545
562
  }
546
563
  export const headToHead = {
547
564
  name: 'head_to_head',
548
- description: `Composed head-to-head record between two teams, two UFC fighters, or two tennis players. No first-class REST H2H exists — this tool filters match history server-side.
549
-
550
- When to use:
551
- - Rivalry / series record questions
552
- - Supporting context for previews
553
-
554
- Prefer over: agent-side double match-list filtering.
555
-
556
- Do not use when: single-side form only → team_profile or player_profile.
557
-
558
- Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
559
-
560
- Parallel-safe: yes. Upstream cost: 2–4.
565
+ description: `Composed head-to-head record between two teams, two UFC fighters, or two tennis players. No first-class REST H2H exists — this tool filters match history server-side.
566
+
567
+ When to use:
568
+ - Rivalry / series record questions
569
+ - Supporting context for previews
570
+
571
+ Prefer over: agent-side double match-list filtering.
572
+
573
+ Do not use when: single-side form only → team_profile or player_profile.
574
+
575
+ Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
576
+
577
+ Parallel-safe: yes. Upstream cost: 2–4.
561
578
  Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
562
579
  inputSchema: {
563
580
  type: 'object',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
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": {