cito-mcp 0.3.19 → 0.3.21
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 +7 -5
- package/dist/tools/cs2.js +421 -0
- package/dist/tools/index.js +7 -1
- package/dist/tools/insight.js +28 -1
- package/dist/tools/leaderboard.js +427 -0
- package/dist/tools/meta.js +201 -1
- package/dist/tools/player.js +195 -1
- package/dist/tools/rankings.js +228 -0
- package/dist/tools/standings.js +4 -2
- package/dist/tools/team.js +183 -1
- package/package.json +2 -2
package/dist/tools/player.js
CHANGED
|
@@ -575,4 +575,198 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
575
575
|
});
|
|
576
576
|
},
|
|
577
577
|
};
|
|
578
|
-
|
|
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];
|
package/dist/tools/standings.js
CHANGED
|
@@ -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
|
|
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/
|
|
249
|
+
path = '/tennis/standings';
|
|
248
250
|
query = { tour, top_n: Math.min(limit, 100) };
|
|
249
251
|
effectiveScope = 'world';
|
|
250
252
|
title = `${tour} singles rankings`;
|
package/dist/tools/team.js
CHANGED
|
@@ -1022,4 +1022,186 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
1022
1022
|
});
|
|
1023
1023
|
},
|
|
1024
1024
|
};
|
|
1025
|
-
|
|
1025
|
+
/**
|
|
1026
|
+
* h2h_matrix (TENNIS-24, cycle 60).
|
|
1027
|
+
*
|
|
1028
|
+
* Tennis-only: multi-player head-to-head comparison grid from the first-class
|
|
1029
|
+
* GET /tennis/h2h/matrix route (2-16 player ids, comma-separated). Upstream
|
|
1030
|
+
* shape (verified 2026-09-09): { players: [{ id, name, ioc, wins }], matrix:
|
|
1031
|
+
* { [rowId]: { [colId]: "W-L" | "-" } } }. Cell "7-6" at [A][B] means A won 7
|
|
1032
|
+
* and B won 6 of their meetings; "-" marks the self diagonal. Unknown ids get
|
|
1033
|
+
* honest 0-0 cells and no players entry (no 404); fewer than 2 or more than 16
|
|
1034
|
+
* ids 422, which surfaces as VALIDATION here.
|
|
1035
|
+
*
|
|
1036
|
+
* Names are resolved to atp_/wta_ ids via /tennis/players/search first (same
|
|
1037
|
+
* id-shaped shortcut as the head_to_head tennis branch); duplicates collapse
|
|
1038
|
+
* to one grid row.
|
|
1039
|
+
*/
|
|
1040
|
+
export const h2hMatrix = {
|
|
1041
|
+
name: 'h2h_matrix',
|
|
1042
|
+
description: `Multi-player tennis head-to-head grid: every pair's series record in one comparison matrix.
|
|
1043
|
+
|
|
1044
|
+
When to use:
|
|
1045
|
+
- Draw/field analysis: how each contender fares against every other (e.g. Alcaraz vs Zverev vs Sinner round-robin records)
|
|
1046
|
+
- Group-stage or semifinal-field comparisons
|
|
1047
|
+
|
|
1048
|
+
Prefer over: N head_to_head calls for an N-player field; raw matrix via call_api.
|
|
1049
|
+
|
|
1050
|
+
Do not use when: a two-player rivalry deep-dive with tiebreak/decider splits → head_to_head; season stat leaders → leaderboard_*; rankings → standings.
|
|
1051
|
+
|
|
1052
|
+
Tennis-only. players takes 2-16 ids or names (names resolve via player search). Each matrix cell is "W-L" from the row player's perspective, "-" on the diagonal.
|
|
1053
|
+
|
|
1054
|
+
Parallel-safe: yes. Upstream cost: 1 + one search per unresolved name.`,
|
|
1055
|
+
inputSchema: {
|
|
1056
|
+
type: 'object',
|
|
1057
|
+
additionalProperties: false,
|
|
1058
|
+
required: ['game', 'players'],
|
|
1059
|
+
properties: {
|
|
1060
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
1061
|
+
players: {
|
|
1062
|
+
type: 'array',
|
|
1063
|
+
items: { type: 'string' },
|
|
1064
|
+
minItems: 2,
|
|
1065
|
+
maxItems: 16,
|
|
1066
|
+
description: '2-16 tennis player ids (atp_207989) or names ("Carlos Alcaraz"); duplicates collapse.',
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
handler: async (args, ctx) => {
|
|
1071
|
+
const started = Date.now();
|
|
1072
|
+
const requestId = newRequestId();
|
|
1073
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
1074
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
1075
|
+
return errorEnvelope({
|
|
1076
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
1077
|
+
message: gameParse.error ?? 'game is required',
|
|
1078
|
+
game: null,
|
|
1079
|
+
source: 'h2h_matrix',
|
|
1080
|
+
requestId,
|
|
1081
|
+
tookMs: Date.now() - started,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
const game = gameParse.game;
|
|
1085
|
+
if (game !== 'tennis') {
|
|
1086
|
+
return errorEnvelope({
|
|
1087
|
+
code: 'NOT_IMPLEMENTED',
|
|
1088
|
+
message: `h2h_matrix is tennis-only (game '${game}' has no H2H matrix)`,
|
|
1089
|
+
game,
|
|
1090
|
+
source: 'h2h_matrix',
|
|
1091
|
+
requestId,
|
|
1092
|
+
tookMs: Date.now() - started,
|
|
1093
|
+
recover: [
|
|
1094
|
+
'Use head_to_head for a two-side rivalry record in this game',
|
|
1095
|
+
'Retry h2h_matrix with game tennis and 2-16 player ids or names',
|
|
1096
|
+
],
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
const rawPlayers = Array.isArray(args.players) ? args.players : [];
|
|
1100
|
+
const cleaned = rawPlayers
|
|
1101
|
+
.filter((p) => typeof p === 'string' && p.trim().length > 0)
|
|
1102
|
+
.map((p) => p.trim());
|
|
1103
|
+
if (cleaned.length < 2 || cleaned.length > 16) {
|
|
1104
|
+
return errorEnvelope({
|
|
1105
|
+
code: 'VALIDATION',
|
|
1106
|
+
message: `players must contain between 2 and 16 names or ids (got ${cleaned.length})`,
|
|
1107
|
+
game,
|
|
1108
|
+
source: 'h2h_matrix',
|
|
1109
|
+
requestId,
|
|
1110
|
+
tookMs: Date.now() - started,
|
|
1111
|
+
recover: ['Pass 2-16 player ids (atp_207989) or names ("Carlos Alcaraz")'],
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
let upstreamCalls = 0;
|
|
1115
|
+
let rateLimit = {};
|
|
1116
|
+
const resolveTennisSide = async (side) => {
|
|
1117
|
+
if (/^(atp|wta)_\d+$/i.test(side))
|
|
1118
|
+
return { id: side, name: side };
|
|
1119
|
+
const res = await fetchJson(ctx, '/tennis/players/search', { query: { q: side, limit: 3 } });
|
|
1120
|
+
upstreamCalls += 1;
|
|
1121
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
1122
|
+
if (!res.ok)
|
|
1123
|
+
return null;
|
|
1124
|
+
const envelope = asRecord(res.data) ?? {};
|
|
1125
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
1126
|
+
const first = asRecord(extractRows(data.items ?? data)[0]);
|
|
1127
|
+
const id = pickString(first?.id, first?.player_id);
|
|
1128
|
+
return id ? { id, name: pickString(first?.full_name, first?.name) ?? side } : null;
|
|
1129
|
+
};
|
|
1130
|
+
const resolved = await Promise.all(cleaned.map((side) => resolveTennisSide(side)));
|
|
1131
|
+
const missing = cleaned.filter((_, i) => !resolved[i]);
|
|
1132
|
+
if (missing.length > 0) {
|
|
1133
|
+
return errorEnvelope({
|
|
1134
|
+
code: 'NOT_FOUND',
|
|
1135
|
+
message: `Tennis player(s) not found: ${missing.join(', ')} — pass names or ids like atp_104745`,
|
|
1136
|
+
game,
|
|
1137
|
+
source: 'h2h_matrix',
|
|
1138
|
+
requestId,
|
|
1139
|
+
tookMs: Date.now() - started,
|
|
1140
|
+
upstreamCalls,
|
|
1141
|
+
rateLimit,
|
|
1142
|
+
recover: [
|
|
1143
|
+
'resolve_entity { game: "tennis", type: "player", q } for each missing name',
|
|
1144
|
+
'Retry h2h_matrix with returned ids',
|
|
1145
|
+
],
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
// Dedupe resolved ids preserving order: REST dict keys would collapse
|
|
1149
|
+
// anyway, but players_info would list the duplicate twice.
|
|
1150
|
+
const ids = [...new Set(resolved.map((r) => r.id))];
|
|
1151
|
+
if (ids.length < 2) {
|
|
1152
|
+
return errorEnvelope({
|
|
1153
|
+
code: 'VALIDATION',
|
|
1154
|
+
message: 'players resolve to fewer than 2 distinct players — pass at least 2 different players',
|
|
1155
|
+
game,
|
|
1156
|
+
source: 'h2h_matrix',
|
|
1157
|
+
requestId,
|
|
1158
|
+
tookMs: Date.now() - started,
|
|
1159
|
+
upstreamCalls,
|
|
1160
|
+
rateLimit,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
const res = await fetchJson(ctx, '/tennis/h2h/matrix', {
|
|
1164
|
+
query: { player_ids: ids.join(',') },
|
|
1165
|
+
});
|
|
1166
|
+
upstreamCalls += 1;
|
|
1167
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
1168
|
+
if (!res.ok) {
|
|
1169
|
+
return errorEnvelope({
|
|
1170
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
1171
|
+
message: `Tennis H2H matrix fetch failed (HTTP ${res.status})`,
|
|
1172
|
+
game,
|
|
1173
|
+
source: 'h2h_matrix',
|
|
1174
|
+
requestId,
|
|
1175
|
+
tookMs: Date.now() - started,
|
|
1176
|
+
upstreamCalls,
|
|
1177
|
+
httpStatus: res.status,
|
|
1178
|
+
rateLimit,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
const envelope = asRecord(res.data) ?? {};
|
|
1182
|
+
const payload = asRecord(envelope.data) ?? envelope;
|
|
1183
|
+
const players = extractRows(payload.players ?? []).map((row) => {
|
|
1184
|
+
const r = asRecord(row) ?? {};
|
|
1185
|
+
return {
|
|
1186
|
+
id: pickString(r.id, r.player_id) ?? null,
|
|
1187
|
+
name: pickString(r.full_name, r.name) ?? null,
|
|
1188
|
+
ioc: pickString(r.ioc) ?? null,
|
|
1189
|
+
};
|
|
1190
|
+
});
|
|
1191
|
+
const matrix = asRecord(payload.matrix) ?? {};
|
|
1192
|
+
return successEnvelope({
|
|
1193
|
+
source: 'h2h_matrix',
|
|
1194
|
+
game,
|
|
1195
|
+
requestId,
|
|
1196
|
+
tookMs: Date.now() - started,
|
|
1197
|
+
upstreamCalls,
|
|
1198
|
+
rateLimit,
|
|
1199
|
+
data: {
|
|
1200
|
+
players,
|
|
1201
|
+
matrix,
|
|
1202
|
+
notes: ['Cells are "W-L" from the row player vs the column player; "-" is the self diagonal'],
|
|
1203
|
+
},
|
|
1204
|
+
});
|
|
1205
|
+
},
|
|
1206
|
+
};
|
|
1207
|
+
export const teamTools = [teamProfile, headToHead, h2hMatrix];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cito-mcp",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Standalone MCP server for the Cito esports API —
|
|
3
|
+
"version": "0.3.21",
|
|
4
|
+
"description": "Standalone MCP server for the Cito esports and sports API — 36 curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cito-mcp": "dist/index.js"
|