cito-mcp 0.3.17 → 0.3.18

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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
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": {