cito-mcp 0.3.18 → 0.3.20

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,9 +2,9 @@
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.4` · **Node:** `>=20` · **Install:** `npx cito-mcp`
5
+ **Version:** `0.3.20` · **Node:** `>=20` · **Install:** `npx cito-mcp`
6
6
 
7
- Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail REST stay available via `call_api`.
7
+ Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis**. Long-tail REST stays available via `call_api`.
8
8
 
9
9
  ---
10
10
 
@@ -12,7 +12,7 @@ Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail RES
12
12
 
13
13
  v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among thin path wrappers, invent IDs, and stitch multi-call UI screens themselves. That catalog was hard to select against and brittle across games.
14
14
 
15
- **v0.2 ships 16 hand-authored tools** that answer *jobs* instead of mirroring REST:
15
+ **v0.3 ships 18 hand-authored tools** that answer *jobs* instead of mirroring REST:
16
16
 
17
17
  | Job | Tool |
18
18
  | --- | --- |
@@ -21,9 +21,11 @@ v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among t
21
21
  | Build a match card / recap | `match_summary` |
22
22
  | Deep timeline / live state | `match_details` |
23
23
  | Team page | `team_profile` |
24
- | Player / fighter form | `player_profile` |
24
+ | Player / fighter profile | `player_profile` |
25
+ | Player form & win streak | `player_form` |
25
26
  | Table / rankings | `standings` |
26
- | Pre-match briefing | `match_preview` |
27
+ | Ranking climbers & droppers | `rankings_movers` |
28
+ | Pre-match briefing (with surface) | `match_preview` |
27
29
  | Event / fight-night card | `event_card` |
28
30
  | Rivalry record | `head_to_head` |
29
31
  | Fighter / team photos | `event_card` · `player_profile` |
@@ -5,8 +5,9 @@ import { matchTools } from './match.js';
5
5
  import { playerTools } from './player.js';
6
6
  import { teamTools } from './team.js';
7
7
  import { standingsTools } from './standings.js';
8
+ import { rankingsTools } from './rankings.js';
8
9
  import { insightTools } from './insight.js';
9
- /** Curated outcome-tool catalog (16 tools). Order matches preferred cold-start ladder. */
10
+ /** Curated outcome-tool catalog (17 tools). Order matches preferred cold-start ladder. */
10
11
  export const allTools = [
11
12
  ...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
12
13
  ...resolveTools,
@@ -15,6 +16,7 @@ export const allTools = [
15
16
  ...playerTools,
16
17
  ...teamTools,
17
18
  ...standingsTools,
19
+ ...rankingsTools,
18
20
  ...insightTools,
19
21
  // Escape-hatch pair last: discover routes, then call one.
20
22
  ...metaTools.filter((t) => t.name === 'list_routes' || t.name === 'call_api'),
@@ -291,6 +291,7 @@ Prefer match_summary when match is completed; match_details for live in-game.
291
291
  Do not use when: user wants final score/recap of a finished match.
292
292
 
293
293
  Parallel-safe: yes. Upstream cost: 4–8.
294
+ Tennis H2H accepts an optional surface filter (Hard, Clay, Grass).
294
295
  Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "recentLimit": 5 }`,
295
296
  inputSchema: {
296
297
  type: 'object',
@@ -305,6 +306,7 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
305
306
  includeH2H: boolSchema('Include composed H2H stub.', true),
306
307
  includeRosters: boolSchema('Include roster snippets when available.', true),
307
308
  recentLimit: limitSchema({ default: 5, max: 15, description: 'Form window per side (default 5, max 15).' }),
309
+ surface: stringSchema('Optional tennis H2H surface filter: Hard, Clay, or Grass. Ignored for other games.', 'Clay'),
308
310
  },
309
311
  },
310
312
  handler: async (args, ctx) => {
@@ -329,6 +331,26 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
329
331
  const includeH2H = args.includeH2H !== false;
330
332
  const includeRosters = args.includeRosters !== false;
331
333
  const recentLimit = clampInt(args.recentLimit, 5, 1, 15);
334
+ // Optional tennis H2H surface filter (TENNIS-04 follow-through: the
335
+ // /tennis/h2h endpoint accepts surface=Hard|Clay|Grass). Canonicalise
336
+ // case so 'clay'/'CLAY' still hit; anything else is a validation error
337
+ // rather than a silently ignored filter.
338
+ const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
339
+ let surface;
340
+ if (surfaceRaw) {
341
+ const canon = surfaceRaw.charAt(0).toUpperCase() + surfaceRaw.slice(1).toLowerCase();
342
+ if (game === 'tennis' && canon !== 'Hard' && canon !== 'Clay' && canon !== 'Grass') {
343
+ return errorEnvelope({
344
+ code: 'VALIDATION',
345
+ message: `surface must be one of Hard, Clay, Grass (got '${surfaceRaw}')`,
346
+ game,
347
+ source: 'match_preview',
348
+ requestId,
349
+ tookMs: Date.now() - started,
350
+ });
351
+ }
352
+ surface = canon;
353
+ }
332
354
  if (!matchId && (!teamA || !teamB)) {
333
355
  return errorEnvelope({
334
356
  code: 'VALIDATION',
@@ -469,7 +491,10 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
469
491
  // Tennis has a first-class H2H endpoint with the full rivalry; use it
470
492
  // rather than composing from a short form window, which reported
471
493
  // Sinner vs Alcaraz as never having met.
472
- const h2hRes = await fetchJson(ctx, '/tennis/h2h', { query: { player1_id: teamA, player2_id: teamB } });
494
+ const h2hQuery = { player1_id: teamA, player2_id: teamB };
495
+ if (game === 'tennis' && surface)
496
+ h2hQuery.surface = surface;
497
+ const h2hRes = await fetchJson(ctx, '/tennis/h2h', { query: h2hQuery });
473
498
  upstreamCalls += 1;
474
499
  rateLimit = { ...rateLimit, ...h2hRes.headers };
475
500
  if (h2hRes.ok) {
@@ -594,6 +619,8 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
594
619
  talkingPoints.push(`${sideBName} key names: ${names.join(', ')}`);
595
620
  }
596
621
  const h2hRec = asRecord(h2h);
622
+ if (game === 'tennis' && surface && includeH2H)
623
+ talkingPoints.push(`H2H filtered to ${surface} courts`);
597
624
  if (h2hRec?.recordNote)
598
625
  talkingPoints.push(String(h2hRec.recordNote));
599
626
  else if (Array.isArray(h2hRec?.meetings) && h2hRec.meetings.length) {
@@ -126,6 +126,26 @@ const TOOL_CATALOG = [
126
126
  preferOver: ['raw standings via call_api'],
127
127
  doNotUse: 'Single team form → team_profile; live scores → live_matches',
128
128
  },
129
+ {
130
+ name: 'rankings_movers',
131
+ outcome: 'Tennis ranking movers: climbers/fallers plus new entries and drop-outs',
132
+ parallelSafe: true,
133
+ games: ['tennis'],
134
+ jobs: ['standings'],
135
+ exampleArgs: { game: 'tennis', tour: 'ATP', direction: 'up', limit: 20 },
136
+ preferOver: ['raw movers via call_api'],
137
+ doNotUse: 'Latest snapshot without deltas → standings',
138
+ },
139
+ {
140
+ name: 'player_form',
141
+ outcome: 'Tennis recent form: W/L record, current streak, per-match rows',
142
+ parallelSafe: true,
143
+ games: ['tennis'],
144
+ jobs: ['player_form', 'preview', 'match_page'],
145
+ exampleArgs: { game: 'tennis', playerId: 'atp_207989', surface: 'Clay', limit: 5 },
146
+ preferOver: ['raw form via call_api'],
147
+ doNotUse: 'Career totals/titles → player_profile; ranking deltas → rankings_movers',
148
+ },
129
149
  {
130
150
  name: 'match_preview',
131
151
  outcome: 'Pre-match briefing: sides, rosters/form, H2H stub',
@@ -575,4 +575,198 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
575
575
  });
576
576
  },
577
577
  };
578
- export const playerTools = [playerProfile];
578
+ /**
579
+ * player_form (TENNIS-12).
580
+ *
581
+ * Tennis-only: recent-match form for one player (GET /tennis/players/{id}/form).
582
+ * The upstream answers wins/losses/win_pct over the last N completed matches
583
+ * plus the per-match rows (result, opponent, score, surface) and the player's
584
+ * current rank + movement. This tool normalizes those rows and derives the
585
+ * current W/L streak from the leading run, newest first.
586
+ */
587
+ const SURFACES = ['Hard', 'Clay', 'Grass'];
588
+ function parseFormSurface(args) {
589
+ const raw = typeof args.surface === 'string' ? args.surface.trim() : '';
590
+ if (!raw)
591
+ return {};
592
+ const canon = raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
593
+ if (!SURFACES.includes(canon)) {
594
+ return { error: `surface must be one of Hard, Clay, Grass (got '${args.surface}')` };
595
+ }
596
+ return { surface: canon };
597
+ }
598
+ function normalizeFormMatch(row) {
599
+ const r = asRecord(row) ?? {};
600
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
601
+ return {
602
+ matchId: pickString(r.match_id) ?? null,
603
+ date: pickString(r.date) ?? null,
604
+ result: pickString(r.result) ?? null,
605
+ opponentId: pickString(r.opponent_id) ?? null,
606
+ opponentRank: num(r.opponent_rank),
607
+ score: pickString(r.score) ?? null,
608
+ surface: pickString(r.surface) ?? null,
609
+ tournamentId: pickString(r.tournament_id) ?? null,
610
+ };
611
+ }
612
+ export const playerForm = {
613
+ name: 'player_form',
614
+ description: `Tennis player form: W/L record over the last N completed matches, current win/loss streak, and per-match rows (opponent, score, surface). Optional surface filter (Hard/Clay/Grass).
615
+
616
+ When to use:
617
+ - "How is X playing lately?"; current streak; surface-specific record (e.g. clay last 5).
618
+
619
+ Prefer over: player_profile (identity + career aggregates, no streak); raw form via call_api for agent-normalized rows.
620
+
621
+ Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
622
+
623
+ Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
624
+
625
+ Parallel-safe: yes. Upstream cost: 1.`,
626
+ inputSchema: {
627
+ type: 'object',
628
+ additionalProperties: false,
629
+ required: ['game', 'playerId'],
630
+ properties: {
631
+ game: gameSchema({ allowAll: false, required: true }),
632
+ playerId: stringSchema('Tennis player id, e.g. "atp_207989".', 'atp_207989'),
633
+ surface: stringSchema('Filter to one surface: Hard, Clay, or Grass.', 'Clay'),
634
+ limit: limitSchema({ default: 10, max: 50, description: 'Recent-match window (default 10, max 50).' }),
635
+ },
636
+ },
637
+ handler: async (args, ctx) => {
638
+ const started = Date.now();
639
+ const requestId = newRequestId();
640
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
641
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
642
+ return errorEnvelope({
643
+ code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
644
+ message: gameParse.error ?? 'game is required',
645
+ game: null,
646
+ source: 'player_form',
647
+ requestId,
648
+ tookMs: Date.now() - started,
649
+ });
650
+ }
651
+ const game = gameParse.game;
652
+ if (game !== 'tennis') {
653
+ return errorEnvelope({
654
+ code: 'NOT_IMPLEMENTED',
655
+ message: `player_form is tennis-only (game '${game}' has no recent-form endpoint)`,
656
+ game,
657
+ source: 'player_form',
658
+ requestId,
659
+ tookMs: Date.now() - started,
660
+ recover: [
661
+ 'Use player_profile for this game (form/trends where available)',
662
+ 'Retry player_form with game tennis and a tennis playerId',
663
+ ],
664
+ });
665
+ }
666
+ const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
667
+ if (!playerId) {
668
+ return errorEnvelope({
669
+ code: 'VALIDATION',
670
+ message: 'playerId is required (e.g. "atp_207989")',
671
+ game,
672
+ source: 'player_form',
673
+ requestId,
674
+ tookMs: Date.now() - started,
675
+ recover: [
676
+ 'Call resolve_entity with the player name',
677
+ 'Retry player_form with the returned id',
678
+ ],
679
+ });
680
+ }
681
+ const surfaceParse = parseFormSurface(args);
682
+ if (surfaceParse.error) {
683
+ return errorEnvelope({
684
+ code: 'VALIDATION',
685
+ message: surfaceParse.error,
686
+ game,
687
+ source: 'player_form',
688
+ requestId,
689
+ tookMs: Date.now() - started,
690
+ });
691
+ }
692
+ const limit = clampInt(args.limit, 10, 1, 50);
693
+ const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(playerId)}/form`, {
694
+ query: {
695
+ limit,
696
+ ...(surfaceParse.surface ? { surface: surfaceParse.surface } : {}),
697
+ },
698
+ });
699
+ if (!res.ok) {
700
+ return errorEnvelope({
701
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
702
+ message: `Player form failed (HTTP ${res.status})`,
703
+ game,
704
+ source: 'player_form',
705
+ requestId,
706
+ tookMs: Date.now() - started,
707
+ upstreamCalls: 1,
708
+ httpStatus: res.status,
709
+ rateLimit: res.headers,
710
+ });
711
+ }
712
+ const root = asRecord(res.data);
713
+ const body = asRecord(root?.data) ?? root ?? {};
714
+ const matches = (Array.isArray(body.matches) ? body.matches : []).map(normalizeFormMatch);
715
+ // Upstream rows arrive newest-first: the streak is the leading run.
716
+ let streakCount = 0;
717
+ let streakResult = null;
718
+ for (const m of matches) {
719
+ if (streakResult === null) {
720
+ if (m.result !== 'W' && m.result !== 'L')
721
+ break;
722
+ streakResult = m.result;
723
+ streakCount = 1;
724
+ }
725
+ else if (m.result === streakResult) {
726
+ streakCount += 1;
727
+ }
728
+ else {
729
+ break;
730
+ }
731
+ }
732
+ const rank = asRecord(body.ranking) ?? {};
733
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
734
+ return successEnvelope({
735
+ pagination: {
736
+ limit,
737
+ offset: 0,
738
+ total: matches.length,
739
+ hasMore: false,
740
+ nextCursor: null,
741
+ prevCursor: null,
742
+ },
743
+ source: 'player_form',
744
+ game,
745
+ requestId,
746
+ tookMs: Date.now() - started,
747
+ upstreamCalls: 1,
748
+ rateLimit: res.headers,
749
+ entities: {
750
+ games: [game],
751
+ ids: { playerId },
752
+ },
753
+ data: {
754
+ title: `${playerId} recent form (last ${matches.length})`,
755
+ playerId,
756
+ sampleSize: num(body.sample_size) ?? matches.length,
757
+ wins: num(body.wins) ?? null,
758
+ losses: num(body.losses) ?? null,
759
+ winPct: num(body.win_pct) ?? null,
760
+ surfaceFilter: pickString(body.surface_filter) ?? null,
761
+ streak: streakResult ? { result: streakResult, count: streakCount } : null,
762
+ ranking: {
763
+ current: num(rank.current),
764
+ movement: num(rank.movement),
765
+ asOf: pickString(rank.as_of) ?? null,
766
+ },
767
+ matches,
768
+ },
769
+ });
770
+ },
771
+ };
772
+ export const playerTools = [playerProfile, playerForm];
@@ -0,0 +1,228 @@
1
+ /**
2
+ * rankings_movers (TENNIS-07).
3
+ *
4
+ * Tennis-only: biggest ranking movers between the latest ranking list and its
5
+ * predecessor (GET /tennis/rankings/movers). The upstream payload already
6
+ * carries new_entries[] and dropped[] alongside items[], so one tool covers
7
+ * both halves of the deferred TENNIS-07 ask — there is no standalone
8
+ * /rankings/new_entries route (it 404s; verified 2026-09-08).
9
+ */
10
+ import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
11
+ import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
12
+ import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
13
+ const DIRECTIONS = ['up', 'down', 'both'];
14
+ function parseTour(args) {
15
+ const rawTour = typeof args.tour === 'string' ? args.tour.trim() : '';
16
+ const rawDivision = typeof args.division === 'string' ? args.division.trim() : '';
17
+ if (rawTour && rawDivision && rawTour.toUpperCase() !== rawDivision.toUpperCase()) {
18
+ return { error: `tour ('${rawTour}') and division ('${rawDivision}') disagree; pass one of ATP|WTA` };
19
+ }
20
+ const raw = rawTour || rawDivision;
21
+ if (!raw)
22
+ return { tour: 'ATP' };
23
+ const upper = raw.toUpperCase();
24
+ if (upper !== 'ATP' && upper !== 'WTA') {
25
+ return { error: `tour must be ATP or WTA (got '${raw}')` };
26
+ }
27
+ return { tour: upper };
28
+ }
29
+ function parseDirection(args) {
30
+ const raw = typeof args.direction === 'string' ? args.direction.trim().toLowerCase() : '';
31
+ if (!raw)
32
+ return { direction: 'up' };
33
+ if (!DIRECTIONS.includes(raw)) {
34
+ return { error: `direction must be one of up, down, both (got '${args.direction}')` };
35
+ }
36
+ return { direction: raw };
37
+ }
38
+ function parseDate(args) {
39
+ const raw = typeof args.date === 'string' ? args.date.trim() : '';
40
+ if (!raw)
41
+ return {};
42
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
43
+ return { error: `date must be YYYY-MM-DD (got '${args.date}')` };
44
+ }
45
+ return { date: raw };
46
+ }
47
+ function normalizeMover(row) {
48
+ const r = asRecord(row) ?? {};
49
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
50
+ return {
51
+ playerId: pickString(r.player_id) ?? null,
52
+ playerName: pickString(r.player_name) ?? null,
53
+ country: pickString(r.ioc, r.country_name) ?? null,
54
+ rank: num(r.rank),
55
+ previousRank: num(r.previous_rank),
56
+ movement: num(r.movement),
57
+ points: num(r.points),
58
+ };
59
+ }
60
+ function normalizeEdge(row) {
61
+ const r = asRecord(row) ?? {};
62
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
63
+ return {
64
+ playerId: pickString(r.player_id) ?? null,
65
+ playerName: pickString(r.player_name) ?? null,
66
+ country: pickString(r.ioc, r.country_name) ?? null,
67
+ rank: num(r.rank),
68
+ previousRank: num(r.previous_rank),
69
+ points: num(r.points),
70
+ };
71
+ }
72
+ export const rankingsMovers = {
73
+ name: 'rankings_movers',
74
+ description: `Tennis ranking movers: biggest climbers/fallers between the latest ATP/WTA list and its predecessor, plus new entries and drop-outs.
75
+
76
+ When to use:
77
+ - Who climbed or fell this week; new top-100 entrants; who dropped out.
78
+
79
+ Prefer over: standings (latest snapshot only, no week-over-week delta); raw movers via call_api for agent-normalized rows.
80
+
81
+ Do not use when: current top-N snapshot → standings with game tennis; player history → player_profile.
82
+
83
+ Tennis-only. Direction up (climbers, default), down (fallers), or both (largest absolute change). Date pins an older ranking list (YYYY-MM-DD); default is the latest, which the API resolves past zero-diff cloned lists.
84
+
85
+ Parallel-safe: yes. Upstream cost: 1.`,
86
+ inputSchema: {
87
+ type: 'object',
88
+ additionalProperties: false,
89
+ required: ['game'],
90
+ properties: {
91
+ game: gameSchema({ allowAll: false, required: true }),
92
+ tour: stringSchema('ATP or WTA tour; default ATP. Synonym: division.', 'WTA'),
93
+ division: stringSchema('Synonym for tour (ATP or WTA), matching the standings tool spelling.'),
94
+ direction: stringSchema('up (climbers), down (fallers), or both (largest absolute change).', 'up'),
95
+ within: {
96
+ type: 'integer',
97
+ minimum: 1,
98
+ maximum: 2000,
99
+ default: 100,
100
+ description: 'Only players inside the top N on either list.',
101
+ },
102
+ date: stringSchema('Ranking date to compare against its predecessor (YYYY-MM-DD). Default: latest.'),
103
+ limit: limitSchema({ default: 20, max: 500 }),
104
+ },
105
+ },
106
+ handler: async (args, ctx) => {
107
+ const started = Date.now();
108
+ const requestId = newRequestId();
109
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
110
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
111
+ return errorEnvelope({
112
+ code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
113
+ message: gameParse.error ?? 'game is required',
114
+ game: null,
115
+ source: 'rankings_movers',
116
+ requestId,
117
+ tookMs: Date.now() - started,
118
+ });
119
+ }
120
+ const game = gameParse.game;
121
+ if (game !== 'tennis') {
122
+ return errorEnvelope({
123
+ code: 'NOT_IMPLEMENTED',
124
+ message: `rankings_movers is tennis-only (game '${game}' has no ranking-movers endpoint)`,
125
+ game,
126
+ source: 'rankings_movers',
127
+ requestId,
128
+ tookMs: Date.now() - started,
129
+ recover: [
130
+ 'Use standings for the latest snapshot of this game',
131
+ 'Retry rankings_movers with game tennis and tour ATP|WTA',
132
+ ],
133
+ });
134
+ }
135
+ const tourParse = parseTour(args);
136
+ if (tourParse.error || !tourParse.tour) {
137
+ return errorEnvelope({
138
+ code: 'VALIDATION',
139
+ message: tourParse.error ?? 'tour is required',
140
+ game,
141
+ source: 'rankings_movers',
142
+ requestId,
143
+ tookMs: Date.now() - started,
144
+ });
145
+ }
146
+ const dirParse = parseDirection(args);
147
+ if (dirParse.error || !dirParse.direction) {
148
+ return errorEnvelope({
149
+ code: 'VALIDATION',
150
+ message: dirParse.error ?? 'direction is invalid',
151
+ game,
152
+ source: 'rankings_movers',
153
+ requestId,
154
+ tookMs: Date.now() - started,
155
+ });
156
+ }
157
+ const dateParse = parseDate(args);
158
+ if (dateParse.error) {
159
+ return errorEnvelope({
160
+ code: 'VALIDATION',
161
+ message: dateParse.error,
162
+ game,
163
+ source: 'rankings_movers',
164
+ requestId,
165
+ tookMs: Date.now() - started,
166
+ });
167
+ }
168
+ const within = clampInt(args.within, 100, 1, 2000);
169
+ const limit = clampInt(args.limit, 20, 1, 500);
170
+ const res = await fetchJson(ctx, '/tennis/rankings/movers', {
171
+ query: {
172
+ tour: tourParse.tour,
173
+ direction: dirParse.direction,
174
+ within,
175
+ limit,
176
+ ...(dateParse.date ? { date: dateParse.date } : {}),
177
+ },
178
+ });
179
+ if (!res.ok) {
180
+ return errorEnvelope({
181
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
182
+ message: `Rankings movers failed (HTTP ${res.status})`,
183
+ game,
184
+ source: 'rankings_movers',
185
+ requestId,
186
+ tookMs: Date.now() - started,
187
+ upstreamCalls: 1,
188
+ httpStatus: res.status,
189
+ rateLimit: res.headers,
190
+ });
191
+ }
192
+ const root = asRecord(res.data);
193
+ const body = asRecord(root?.data) ?? root ?? {};
194
+ const items = (Array.isArray(body.items) ? body.items : []).map(normalizeMover);
195
+ const newEntries = (Array.isArray(body.new_entries) ? body.new_entries : []).map(normalizeEdge);
196
+ const dropped = (Array.isArray(body.dropped) ? body.dropped : []).map(normalizeEdge);
197
+ const total = typeof body.total === 'number' ? body.total : items.length;
198
+ return successEnvelope({
199
+ pagination: {
200
+ limit,
201
+ offset: 0,
202
+ total,
203
+ hasMore: false,
204
+ nextCursor: null,
205
+ prevCursor: null,
206
+ },
207
+ source: 'rankings_movers',
208
+ game,
209
+ requestId,
210
+ tookMs: Date.now() - started,
211
+ upstreamCalls: 1,
212
+ rateLimit: res.headers,
213
+ data: {
214
+ title: `${tourParse.tour} ranking movers (${dirParse.direction})`,
215
+ tour: tourParse.tour,
216
+ direction: dirParse.direction,
217
+ within,
218
+ rankingDate: pickString(body.ranking_date) ?? null,
219
+ previousDate: pickString(body.previous_date) ?? null,
220
+ items,
221
+ newEntries,
222
+ dropped,
223
+ total,
224
+ },
225
+ });
226
+ },
227
+ };
228
+ export const rankingsTools = [rankingsMovers];
@@ -103,6 +103,7 @@ Required scope keys by game:
103
103
  - cs2: omit for world rankings; eventId for event standings
104
104
  - cod: optional season/stage
105
105
  - ufc: optional division (scope=division)
106
+ - tennis: optional division (ATP or WTA tour; default ATP)
106
107
  - dota2: best-effort worldRanking only
107
108
 
108
109
  Parallel-safe: yes. Upstream cost: 1–2.
@@ -242,9 +243,10 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
242
243
  }
243
244
  }
244
245
  else if (game === 'tennis') {
245
- // Tennis "standings" = the latest ATP/WTA singles rankings. Pass tour via division.
246
+ // Tennis "standings" = the latest ATP/WTA singles rankings, with
247
+ // rank_movement/previous_rank on every row. Pass tour via division.
246
248
  const tour = String(division ?? 'ATP').toUpperCase() === 'WTA' ? 'WTA' : 'ATP';
247
- path = '/tennis/rankings/top';
249
+ path = '/tennis/standings';
248
250
  query = { tour, top_n: Math.min(limit, 100) };
249
251
  effectiveScope = 'world';
250
252
  title = `${tour} singles rankings`;
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.18",
4
- "description": "Standalone MCP server for the Cito esports API — 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
3
+ "version": "0.3.20",
4
+ "description": "Standalone MCP server for the Cito esports and sports API — 18 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards, player form, rankings movers).",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cito-mcp": "dist/index.js"