cito-mcp 0.2.6 → 0.3.0

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.
@@ -230,6 +230,33 @@ async function searchGame(ctx, game, q, type, limit) {
230
230
  }
231
231
  pushCandidates(candidates, game, type === 'any' ? 'unknown' : type, extractRows(res.data), q, limit);
232
232
  }
233
+ else if (game === 'tennis') {
234
+ // Tennis has no combined /search; players and competitions are separate lookups.
235
+ const tasks = [];
236
+ if (type === 'any' || type === 'player') {
237
+ tasks.push((async () => {
238
+ const res = await fetchJson(ctx, '/tennis/players/search', { query: { q, limit } });
239
+ calls += 1;
240
+ if (res.ok) {
241
+ const envelope = asRecord(res.data) ?? {};
242
+ const data = asRecord(envelope.data) ?? envelope;
243
+ pushCandidates(candidates, game, 'player', extractRows(data.items ?? data), q, limit);
244
+ }
245
+ })());
246
+ }
247
+ if (type === 'any' || type === 'tournament' || type === 'event') {
248
+ tasks.push((async () => {
249
+ const res = await fetchJson(ctx, '/tennis/competitions', { query: { q, page_size: limit } });
250
+ calls += 1;
251
+ if (res.ok) {
252
+ const envelope = asRecord(res.data) ?? {};
253
+ const data = asRecord(envelope.data) ?? envelope;
254
+ pushCandidates(candidates, game, 'tournament', extractRows(data.items ?? data), q, limit);
255
+ }
256
+ })());
257
+ }
258
+ await Promise.all(tasks);
259
+ }
233
260
  else if (game === 'lol') {
234
261
  const tasks = [];
235
262
  if (type === 'any' || type === 'team') {
@@ -593,6 +620,26 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
593
620
  await listPath('/dota2/tournaments', 'tournament');
594
621
  }
595
622
  }
623
+ else if (game === 'tennis') {
624
+ if (type === 'player' || type === 'any') {
625
+ const res = await fetchJson(ctx, '/tennis/players/search', { query: { q, limit } });
626
+ upstreamCalls += 1;
627
+ if (res.ok) {
628
+ const envelope = asRecord(res.data) ?? {};
629
+ const data = asRecord(envelope.data) ?? envelope;
630
+ items.push(...extractRows(data.items ?? data).map((r) => entityRef(r, 'player', game)));
631
+ }
632
+ }
633
+ if (type === 'tournament' || type === 'event' || type === 'any') {
634
+ const res = await fetchJson(ctx, '/tennis/competitions', { query: { q, page_size: limit } });
635
+ upstreamCalls += 1;
636
+ if (res.ok) {
637
+ const envelope = asRecord(res.data) ?? {};
638
+ const data = asRecord(envelope.data) ?? envelope;
639
+ items.push(...extractRows(data.items ?? data).map((r) => entityRef(r, 'tournament', game)));
640
+ }
641
+ }
642
+ }
596
643
  else if (game === 'cod') {
597
644
  if (q) {
598
645
  const res = await fetchJson(ctx, '/cod/search', {
@@ -8,7 +8,7 @@ import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from
8
8
  export function normalizeStandingRow(row, index) {
9
9
  const r = asRecord(row) ?? {};
10
10
  const entity = asRecord(r.team) ?? asRecord(r.fighter) ?? asRecord(r.org) ?? r;
11
- const name = pickString(asRecord(entity)?.name, r.teamName, r.name, r.orgName, r.fighterName, asRecord(entity)?.slug) ?? `row-${index + 1}`;
11
+ const name = pickString(asRecord(entity)?.name, r.teamName, r.name, r.orgName, r.fighterName, r.player_name, asRecord(entity)?.slug) ?? `row-${index + 1}`;
12
12
  // UFC official lists: champion has rank=null + rankText="C" (interim "IC"); contenders 1..15.
13
13
  // Never fall back to index+1 for explicit null ranks — that produced two "#1" rows (champ + #1).
14
14
  const championStatus = pickString(r.championStatus, asRecord(entity)?.championStatus);
@@ -234,6 +234,14 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
234
234
  title = 'UFC rankings';
235
235
  }
236
236
  }
237
+ else if (game === 'tennis') {
238
+ // Tennis "standings" = the latest ATP/WTA singles rankings. Pass tour via division.
239
+ const tour = String(division ?? 'ATP').toUpperCase() === 'WTA' ? 'WTA' : 'ATP';
240
+ path = '/tennis/rankings/top';
241
+ query = { tour, top_n: Math.min(limit, 100) };
242
+ effectiveScope = 'world';
243
+ title = `${tour} singles rankings`;
244
+ }
237
245
  else if (game === 'dota2') {
238
246
  // No first-class standings — try team list worldRanking
239
247
  const res = await fetchJson(ctx, '/dota2/teams', { query: { limit } });
@@ -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',
@@ -81,6 +81,20 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
81
81
  ],
82
82
  });
83
83
  }
84
+ if (game === 'tennis') {
85
+ return errorEnvelope({
86
+ code: 'NOT_IMPLEMENTED',
87
+ message: 'Tennis has players, not team profiles',
88
+ game,
89
+ source: 'team_profile',
90
+ requestId,
91
+ tookMs: Date.now() - started,
92
+ recover: [
93
+ 'Use player_profile with a tennis player id (e.g. atp_104745)',
94
+ 'Use standings with game "tennis" for ATP/WTA rankings',
95
+ ],
96
+ });
97
+ }
84
98
  const teamId = typeof args.teamId === 'string' ? args.teamId.trim() : '';
85
99
  const slug = typeof args.slug === 'string' ? args.slug.trim() : '';
86
100
  if (!teamId && !slug) {
@@ -486,19 +500,19 @@ function winnerSide(match, a) {
486
500
  }
487
501
  export const headToHead = {
488
502
  name: 'head_to_head',
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.
503
+ 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.
504
+
505
+ When to use:
506
+ - Rivalry / series record questions
507
+ - Supporting context for previews
508
+
509
+ Prefer over: agent-side double match-list filtering.
510
+
511
+ Do not use when: single-side form only → team_profile or player_profile.
512
+
513
+ Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
514
+
515
+ Parallel-safe: yes. Upstream cost: 2–4.
502
516
  Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
503
517
  inputSchema: {
504
518
  type: 'object',
@@ -508,9 +522,9 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
508
522
  game: gameSchema({ allowAll: false, required: true }),
509
523
  entityType: {
510
524
  type: 'string',
511
- enum: ['team', 'fighter'],
525
+ enum: ['team', 'fighter', 'player'],
512
526
  default: 'team',
513
- description: 'team (default) or fighter (UFC).',
527
+ description: 'team (default), fighter (UFC), or player (tennis).',
514
528
  },
515
529
  sideA: stringSchema('Id or slug for side A.', 'faze'),
516
530
  sideB: stringSchema('Id or slug for side B.', 'navi'),
@@ -734,6 +748,74 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
734
748
  warnings.push('No fight history rows returned for either fighter; H2H may be empty');
735
749
  }
736
750
  }
751
+ if (game === 'tennis') {
752
+ // Tennis has a first-class H2H endpoint; resolve names to player ids first when needed.
753
+ const resolveTennisSide = async (side) => {
754
+ if (/^(atp|wta)_\d+$/i.test(side))
755
+ return { id: side, name: side };
756
+ const res = await fetchJson(ctx, '/tennis/players/search', { query: { q: side, limit: 3 } });
757
+ upstreamCalls += 1;
758
+ rateLimit = { ...rateLimit, ...res.headers };
759
+ if (!res.ok)
760
+ return null;
761
+ const envelope = asRecord(res.data) ?? {};
762
+ const data = asRecord(envelope.data) ?? envelope;
763
+ const first = asRecord(extractRows(data.items ?? data)[0]);
764
+ const id = pickString(first?.id, first?.player_id);
765
+ return id ? { id, name: pickString(first?.full_name, first?.name) ?? side } : null;
766
+ };
767
+ const [tA, tB] = await Promise.all([resolveTennisSide(sideA), resolveTennisSide(sideB)]);
768
+ if (!tA || !tB) {
769
+ return errorEnvelope({
770
+ code: 'NOT_FOUND',
771
+ message: 'One or both tennis players not found — pass names or ids like atp_104745',
772
+ game,
773
+ source: 'head_to_head',
774
+ requestId,
775
+ tookMs: Date.now() - started,
776
+ upstreamCalls,
777
+ rateLimit,
778
+ recover: [
779
+ 'resolve_entity { game: "tennis", type: "player", q } for each name',
780
+ 'Retry head_to_head with returned ids as sideA/sideB',
781
+ ],
782
+ });
783
+ }
784
+ const res = await fetchJson(ctx, '/tennis/h2h', {
785
+ query: { player1_id: tA.id, player2_id: tB.id },
786
+ });
787
+ upstreamCalls += 1;
788
+ rateLimit = { ...rateLimit, ...res.headers };
789
+ if (!res.ok) {
790
+ return errorEnvelope({
791
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
792
+ message: `Tennis H2H fetch failed (HTTP ${res.status})`,
793
+ game,
794
+ source: 'head_to_head',
795
+ requestId,
796
+ tookMs: Date.now() - started,
797
+ upstreamCalls,
798
+ httpStatus: res.status,
799
+ rateLimit,
800
+ });
801
+ }
802
+ const envelope = asRecord(res.data) ?? {};
803
+ const h2h = asRecord(envelope.data) ?? envelope;
804
+ return successEnvelope({
805
+ source: 'head_to_head',
806
+ game,
807
+ requestId,
808
+ tookMs: Date.now() - started,
809
+ upstreamCalls,
810
+ rateLimit,
811
+ data: {
812
+ sideA: { idOrSlug: tA.id, name: tA.name },
813
+ sideB: { idOrSlug: tB.id, name: tB.name },
814
+ h2h,
815
+ notes: ['Record and meetings come from the first-class /tennis/h2h endpoint'],
816
+ },
817
+ });
818
+ }
737
819
  const meetings = rows
738
820
  .map((row) => normalizeMatch(game, row))
739
821
  .filter((m) => sidesMatch(m, matchA, matchB))
@@ -1,5 +1,20 @@
1
- import { toMcpResult } from '../envelope.js';
2
- export const PRIMARY_GAMES = ['lol', 'cs2', 'dota2', 'cod', 'ufc'];
1
+ import { ENVELOPE_OUTPUT_SCHEMA, toMcpResult } from '../envelope.js';
2
+ /** All curated tools fetch Cito data. None delete, post, or mutate user state. */
3
+ export const READ_TOOL_ANNOTATIONS = {
4
+ readOnlyHint: true,
5
+ destructiveHint: false,
6
+ openWorldHint: true,
7
+ };
8
+ export function toolListing(def) {
9
+ return {
10
+ name: def.name,
11
+ description: def.description,
12
+ inputSchema: def.inputSchema,
13
+ outputSchema: ENVELOPE_OUTPUT_SCHEMA,
14
+ annotations: def.annotations ?? READ_TOOL_ANNOTATIONS,
15
+ };
16
+ }
17
+ export const PRIMARY_GAMES = ['lol', 'cs2', 'dota2', 'cod', 'ufc', 'tennis'];
3
18
  export const GAME_ENUM = [...PRIMARY_GAMES, 'all'];
4
19
  export function isPrimaryGame(value) {
5
20
  return typeof value === 'string' && PRIMARY_GAMES.includes(value);
@@ -15,7 +30,7 @@ export function parseGame(value, opts) {
15
30
  const g = value.toLowerCase();
16
31
  if (g === 'all') {
17
32
  if (opts?.allowAll === false) {
18
- return { error: 'game=all is not valid for this tool; pick lol|cs2|dota2|cod|ufc' };
33
+ return { error: 'game=all is not valid for this tool; pick lol|cs2|dota2|cod|ufc|tennis' };
19
34
  }
20
35
  return { game: 'all' };
21
36
  }
@@ -23,7 +38,7 @@ export function parseGame(value, opts) {
23
38
  return { game: g };
24
39
  }
25
40
  return {
26
- error: `unsupported game "${value}"; use lol|cs2|dota2|cod|ufc${opts?.allowAll !== false ? '|all' : ''}`,
41
+ error: `unsupported game "${value}"; use lol|cs2|dota2|cod|ufc|tennis${opts?.allowAll !== false ? '|all' : ''}`,
27
42
  };
28
43
  }
29
44
  export function gameSchema(opts) {
@@ -33,8 +48,8 @@ export function gameSchema(opts) {
33
48
  enum: values,
34
49
  description: opts?.description ??
35
50
  (opts?.allowAll === false
36
- ? 'Game title: lol | cs2 | dota2 | cod | ufc. Example: "cs2".'
37
- : 'Game title: lol | cs2 | dota2 | cod | ufc | all. Omit or all for multi-game tools. Example: "lol".'),
51
+ ? 'Game title: lol | cs2 | dota2 | cod | ufc | tennis. Example: "cs2".'
52
+ : 'Game title: lol | cs2 | dota2 | cod | ufc | tennis | all. Omit or all for multi-game tools. Example: "lol".'),
38
53
  };
39
54
  }
40
55
  export function limitSchema(opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
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": {