cito-mcp 0.3.20 → 0.4.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.
- package/README.md +7 -1
- package/dist/tools/cs2.js +421 -0
- package/dist/tools/index.js +13 -1
- package/dist/tools/leaderboard.js +427 -0
- package/dist/tools/meta.js +241 -1
- package/dist/tools/odds.js +251 -0
- package/dist/tools/player.js +340 -1
- package/dist/tools/rankings.js +147 -1
- package/dist/tools/schedule.js +234 -0
- package/dist/tools/team.js +183 -1
- package/dist/tools/tournaments.js +253 -0
- package/package.json +2 -2
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tennis_schedule — one day of tennis, scheduled and completed (TENNIS-26).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* /tennis/schedule and /tennis/matches/completed are the two routes that require
|
|
6
|
+
* ?date=. They were the exact pair producing "route is broken" support tickets
|
|
7
|
+
* from callers who omitted it, which is why the REST layer now returns a hint
|
|
8
|
+
* with a working example URL. But neither route had a tool either — so an agent
|
|
9
|
+
* driving the MCP had no way to ask "what tennis is on today?" at all.
|
|
10
|
+
*
|
|
11
|
+
* This tool owns the required date, defaults it to today, validates the format
|
|
12
|
+
* before spending an upstream call, and on a bad date returns the same
|
|
13
|
+
* paste-able example the REST hint does.
|
|
14
|
+
*/
|
|
15
|
+
import { asRecord, clampInt, fetchJson, pickString, } from '../client.js';
|
|
16
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
17
|
+
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
18
|
+
/** ISO date, no time component, already validated by the caller. */
|
|
19
|
+
function todayIso() {
|
|
20
|
+
return new Date().toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
22
|
+
function normalizeScheduled(row) {
|
|
23
|
+
const r = asRecord(row) ?? {};
|
|
24
|
+
const p1 = asRecord(r.player1) ?? asRecord(r.home) ?? {};
|
|
25
|
+
const p2 = asRecord(r.player2) ?? asRecord(r.away) ?? {};
|
|
26
|
+
return {
|
|
27
|
+
matchId: pickString(r.id, r.match_id) ?? null,
|
|
28
|
+
tournamentId: pickString(r.tournament_id) ?? null,
|
|
29
|
+
tournamentName: pickString(r.tournament_name, r.tournament) ?? null,
|
|
30
|
+
tour: pickString(r.tour) ?? null,
|
|
31
|
+
level: pickString(r.level, r.tier) ?? null,
|
|
32
|
+
surface: pickString(r.surface) ?? null,
|
|
33
|
+
round: pickString(r.round) ?? null,
|
|
34
|
+
roundName: pickString(r.round_name) ?? null,
|
|
35
|
+
startsAt: pickString(r.starts_at, r.commence_time, r.match_date) ?? null,
|
|
36
|
+
status: pickString(r.status, r.outcome) ?? null,
|
|
37
|
+
player1: { id: pickString(p1.id, p1.player_id) ?? null, name: pickString(p1.name) ?? null },
|
|
38
|
+
player2: { id: pickString(p2.id, p2.player_id) ?? null, name: pickString(p2.name) ?? null },
|
|
39
|
+
score: pickString(r.score, r.score_raw) ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export const tennisSchedule = {
|
|
43
|
+
name: 'tennis_schedule',
|
|
44
|
+
description: `One day of tennis: the day's schedule and/or the matches completed that day, across ATP and WTA.
|
|
45
|
+
|
|
46
|
+
When to use:
|
|
47
|
+
- "What tennis is on today?"; a specific day's fixtures; results completed on a given date; building a daily digest.
|
|
48
|
+
|
|
49
|
+
Prefer over: call_api for /tennis/schedule and /tennis/matches/completed (both require ?date=, which this tool supplies and validates); live_matches (in-progress only, right now); upcoming_schedule (a forward window, not a specific day).
|
|
50
|
+
|
|
51
|
+
Do not use when: matches currently in play → live_matches; a named player's history → player_matches.
|
|
52
|
+
|
|
53
|
+
Tennis-only. 'date' defaults to today (UTC) and MUST be YYYY-MM-DD. Choose with
|
|
54
|
+
'include': both (default), scheduled, or completed. Both halves are fetched in
|
|
55
|
+
parallel, so either can fail on its own without losing the other.
|
|
56
|
+
|
|
57
|
+
Parallel-safe: yes. Upstream cost: 1 or 2.`,
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
required: ['game'],
|
|
62
|
+
properties: {
|
|
63
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
64
|
+
date: stringSchema('Day to fetch, YYYY-MM-DD. Defaults to today (UTC).', '2026-09-12'),
|
|
65
|
+
include: stringSchema('both (default) | scheduled | completed.', 'both'),
|
|
66
|
+
limit: limitSchema({ default: 20, max: 50, description: 'Rows per half (default 20, max 50).' }),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
handler: async (args, ctx) => {
|
|
70
|
+
const started = Date.now();
|
|
71
|
+
const requestId = newRequestId();
|
|
72
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
73
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
74
|
+
return errorEnvelope({
|
|
75
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
76
|
+
message: gameParse.error ?? 'game is required',
|
|
77
|
+
game: null,
|
|
78
|
+
source: 'tennis_schedule',
|
|
79
|
+
requestId,
|
|
80
|
+
tookMs: Date.now() - started,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const game = gameParse.game;
|
|
84
|
+
if (game !== 'tennis') {
|
|
85
|
+
return errorEnvelope({
|
|
86
|
+
code: 'NOT_IMPLEMENTED',
|
|
87
|
+
message: `tennis_schedule is tennis-only (game '${game}' has its own schedule tool)`,
|
|
88
|
+
game,
|
|
89
|
+
source: 'tennis_schedule',
|
|
90
|
+
requestId,
|
|
91
|
+
tookMs: Date.now() - started,
|
|
92
|
+
recover: ['Use upcoming_schedule for this game', 'Retry with game tennis'],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const rawDate = typeof args.date === 'string' ? args.date.trim() : '';
|
|
96
|
+
const date = rawDate || todayIso();
|
|
97
|
+
// Validate before spending an upstream call, and echo the same paste-able
|
|
98
|
+
// example the REST layer returns so the two surfaces teach identically.
|
|
99
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
100
|
+
return errorEnvelope({
|
|
101
|
+
code: 'VALIDATION',
|
|
102
|
+
message: `date must be YYYY-MM-DD (got '${rawDate}')`,
|
|
103
|
+
game,
|
|
104
|
+
source: 'tennis_schedule',
|
|
105
|
+
requestId,
|
|
106
|
+
tookMs: Date.now() - started,
|
|
107
|
+
recover: [`Try date=${todayIso()}`, 'This route requires a date; omit it to default to today'],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const includeRaw = typeof args.include === 'string' ? args.include.trim().toLowerCase() : 'both';
|
|
111
|
+
if (!['both', 'scheduled', 'completed'].includes(includeRaw)) {
|
|
112
|
+
return errorEnvelope({
|
|
113
|
+
code: 'VALIDATION',
|
|
114
|
+
message: `include must be both, scheduled, or completed (got '${args.include}')`,
|
|
115
|
+
game,
|
|
116
|
+
source: 'tennis_schedule',
|
|
117
|
+
requestId,
|
|
118
|
+
tookMs: Date.now() - started,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const limit = clampInt(args.limit, 20, 1, 50);
|
|
122
|
+
const wantScheduled = includeRaw === 'both' || includeRaw === 'scheduled';
|
|
123
|
+
const wantCompleted = includeRaw === 'both' || includeRaw === 'completed';
|
|
124
|
+
const settled = await Promise.allSettled([
|
|
125
|
+
wantScheduled
|
|
126
|
+
? fetchJson(ctx, '/tennis/schedule', { query: { date, limit } })
|
|
127
|
+
: Promise.resolve(null),
|
|
128
|
+
wantCompleted
|
|
129
|
+
? fetchJson(ctx, '/tennis/matches/completed', { query: { date, limit } })
|
|
130
|
+
: Promise.resolve(null),
|
|
131
|
+
]);
|
|
132
|
+
const rejected = [];
|
|
133
|
+
const rows = (value) => {
|
|
134
|
+
const root = asRecord(value);
|
|
135
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
136
|
+
return Array.isArray(body.items) ? body.items : [];
|
|
137
|
+
};
|
|
138
|
+
let scheduled = [];
|
|
139
|
+
let completed = [];
|
|
140
|
+
const [schedRes, compRes] = settled;
|
|
141
|
+
if (schedRes.status === 'fulfilled' && schedRes.value) {
|
|
142
|
+
const v = schedRes.value;
|
|
143
|
+
if (v.ok)
|
|
144
|
+
scheduled = rows(v.data);
|
|
145
|
+
else
|
|
146
|
+
rejected.push({
|
|
147
|
+
section: 'schedule',
|
|
148
|
+
code: mapHttpToCode(v.status),
|
|
149
|
+
message: `schedule HTTP ${v.status}`,
|
|
150
|
+
httpStatus: v.status,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (schedRes.status === 'rejected') {
|
|
154
|
+
rejected.push({
|
|
155
|
+
section: 'schedule',
|
|
156
|
+
code: 'UPSTREAM',
|
|
157
|
+
message: String(schedRes.reason).slice(0, 200),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (compRes.status === 'fulfilled' && compRes.value) {
|
|
161
|
+
const v = compRes.value;
|
|
162
|
+
if (v.ok)
|
|
163
|
+
completed = rows(v.data);
|
|
164
|
+
else
|
|
165
|
+
rejected.push({
|
|
166
|
+
section: 'completed',
|
|
167
|
+
code: mapHttpToCode(v.status),
|
|
168
|
+
message: `completed HTTP ${v.status}`,
|
|
169
|
+
httpStatus: v.status,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
else if (compRes.status === 'rejected') {
|
|
173
|
+
rejected.push({
|
|
174
|
+
section: 'completed',
|
|
175
|
+
code: 'UPSTREAM',
|
|
176
|
+
message: String(compRes.reason).slice(0, 200),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// Both halves failed -> a real error, not a partial.
|
|
180
|
+
if (rejected.length > 0 && scheduled.length === 0 && completed.length === 0) {
|
|
181
|
+
const first = rejected[0];
|
|
182
|
+
return errorEnvelope({
|
|
183
|
+
code: first.code,
|
|
184
|
+
message: `Tennis schedule failed for ${date}: ${rejected.map((r) => r.message).join('; ')}`,
|
|
185
|
+
game,
|
|
186
|
+
source: 'tennis_schedule',
|
|
187
|
+
requestId,
|
|
188
|
+
tookMs: Date.now() - started,
|
|
189
|
+
upstreamCalls: rejected.length,
|
|
190
|
+
recover: ['Check the date is a real calendar day', 'Retry, or widen include to both'],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const data = {
|
|
194
|
+
title: `Tennis — ${date}`,
|
|
195
|
+
date,
|
|
196
|
+
include: includeRaw,
|
|
197
|
+
scheduled: scheduled.map(normalizeScheduled),
|
|
198
|
+
scheduledCount: scheduled.length,
|
|
199
|
+
completed: completed.map(normalizeScheduled),
|
|
200
|
+
completedCount: completed.length,
|
|
201
|
+
timezoneNote: 'The API groups by UTC date. For a local "today", pass the date explicitly rather than relying on the default.',
|
|
202
|
+
};
|
|
203
|
+
const upstreamCalls = (wantScheduled ? 1 : 0) + (wantCompleted ? 1 : 0);
|
|
204
|
+
// A half that failed is reported alongside the half that worked rather than
|
|
205
|
+
// being silently dropped: an empty scheduled[] means two different things.
|
|
206
|
+
if (rejected.length > 0) {
|
|
207
|
+
return successEnvelope({
|
|
208
|
+
source: 'tennis_schedule',
|
|
209
|
+
game,
|
|
210
|
+
requestId,
|
|
211
|
+
tookMs: Date.now() - started,
|
|
212
|
+
upstreamCalls,
|
|
213
|
+
data,
|
|
214
|
+
partial: rejected.map((r) => partialFromRejection(r.section, {
|
|
215
|
+
code: r.code,
|
|
216
|
+
message: r.message,
|
|
217
|
+
...(r.httpStatus !== undefined ? { httpStatus: r.httpStatus } : {}),
|
|
218
|
+
})),
|
|
219
|
+
warnings: [
|
|
220
|
+
`${rejected.length} of ${upstreamCalls} schedule section(s) failed; the rest of the day is still returned.`,
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return successEnvelope({
|
|
225
|
+
source: 'tennis_schedule',
|
|
226
|
+
game,
|
|
227
|
+
requestId,
|
|
228
|
+
tookMs: Date.now() - started,
|
|
229
|
+
upstreamCalls,
|
|
230
|
+
data,
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
export const scheduleTools = [tennisSchedule];
|
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];
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tournaments — tennis tournament discovery (TENNIS-25).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* event_card already renders a tournament Profile/Draw, but only once you know a
|
|
6
|
+
* tournament id. There was no way to ASK for tournaments — so "list the 2026
|
|
7
|
+
* WTA 1000 events" or "what ATP 250s are on clay" was impossible without
|
|
8
|
+
* call_api, even though /tennis/tournaments supports exactly those filters
|
|
9
|
+
* (year, tour, level, surface, country) and the level filter was recently made
|
|
10
|
+
* to accept names like "WTA 1000" rather than only raw tier codes.
|
|
11
|
+
*
|
|
12
|
+
* This tool is the discovery half; event_card stays the detail half.
|
|
13
|
+
*/
|
|
14
|
+
import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
|
|
15
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
16
|
+
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
17
|
+
const SURFACES = ['Hard', 'Clay', 'Grass', 'Carpet'];
|
|
18
|
+
/** Human level names the API accepts on ?level=, plus the raw tier codes. */
|
|
19
|
+
const LEVEL_NAMES = [
|
|
20
|
+
'Grand Slam',
|
|
21
|
+
'Masters 1000',
|
|
22
|
+
'ATP Tour',
|
|
23
|
+
'ATP 500',
|
|
24
|
+
'ATP 250',
|
|
25
|
+
'WTA 1000',
|
|
26
|
+
'WTA 500',
|
|
27
|
+
'WTA 250',
|
|
28
|
+
'WTA 125',
|
|
29
|
+
'Challenger',
|
|
30
|
+
'ITF World Tennis Tour',
|
|
31
|
+
'Davis Cup',
|
|
32
|
+
'Billie Jean King Cup',
|
|
33
|
+
'Tour Finals',
|
|
34
|
+
'WTA Finals',
|
|
35
|
+
'Olympics',
|
|
36
|
+
];
|
|
37
|
+
function normalizeTournament(row) {
|
|
38
|
+
const r = asRecord(row) ?? {};
|
|
39
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
40
|
+
return {
|
|
41
|
+
id: pickString(r.id) ?? null,
|
|
42
|
+
name: pickString(r.name) ?? null,
|
|
43
|
+
tour: pickString(r.tour) ?? null,
|
|
44
|
+
year: num(r.year),
|
|
45
|
+
// `tier` is the human level the API derives from the tier code; `level` is
|
|
46
|
+
// the raw code. Both are surfaced because callers filter on the name but
|
|
47
|
+
// cross-reference by the code.
|
|
48
|
+
level: pickString(r.tier) ?? pickString(r.level) ?? null,
|
|
49
|
+
levelCode: pickString(r.tier_code, r.level) ?? null,
|
|
50
|
+
category: pickString(r.category) ?? null,
|
|
51
|
+
surface: pickString(r.surface) ?? null,
|
|
52
|
+
drawSize: num(r.draw_size),
|
|
53
|
+
city: pickString(r.city) ?? null,
|
|
54
|
+
country: pickString(r.country_code) ?? null,
|
|
55
|
+
firstEditionYear: num(r.first_edition_year),
|
|
56
|
+
mostRecentEditionYear: num(r.most_recent_edition_year),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export const tournaments = {
|
|
60
|
+
name: 'tournaments',
|
|
61
|
+
description: `Tennis tournament catalog: find tournaments by year, tour, level, surface, or country.
|
|
62
|
+
|
|
63
|
+
When to use:
|
|
64
|
+
- "Which ATP 500s are on clay in 2026?"; "list the WTA 1000 events"; "what Grand Slams are in 2026?"; finding a tournament id before calling event_card.
|
|
65
|
+
|
|
66
|
+
Prefer over: event_card when you do NOT already have a tournament id (event_card needs one); call_api for /tennis/tournaments.
|
|
67
|
+
|
|
68
|
+
Do not use when: the draw bracket or a specific edition → event_card with the id this returns; the season calendar by month → event_card.
|
|
69
|
+
|
|
70
|
+
Tennis-only. Levels accept names (Grand Slam, WTA 1000, ATP 500, Challenger,
|
|
71
|
+
ITF World Tennis Tour, Davis Cup, ...) or raw tier codes (G, M, A, C, D).
|
|
72
|
+
Note the source does not separate ATP 500 from ATP 250 — both are ATP Tour.
|
|
73
|
+
|
|
74
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
required: ['game'],
|
|
79
|
+
properties: {
|
|
80
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
81
|
+
year: {
|
|
82
|
+
type: 'integer',
|
|
83
|
+
minimum: 1877,
|
|
84
|
+
maximum: 2100,
|
|
85
|
+
description: 'Edition year, e.g. 2026. Omit for all years.',
|
|
86
|
+
},
|
|
87
|
+
tour: stringSchema('ATP or WTA.', 'WTA'),
|
|
88
|
+
level: stringSchema(`Filter by level. Names: ${LEVEL_NAMES.slice(0, 6).join(', ')}, ... or codes G|M|A|C|D.`, 'WTA 1000'),
|
|
89
|
+
surface: stringSchema('Hard, Clay, Grass, or Carpet.', 'Clay'),
|
|
90
|
+
countryCode: stringSchema('3-letter IOC country code, e.g. FRA.', 'FRA'),
|
|
91
|
+
q: stringSchema('Case-insensitive name search, e.g. "open".', 'open'),
|
|
92
|
+
limit: limitSchema({ default: 20, max: 50 }),
|
|
93
|
+
page: { type: 'integer', minimum: 1, default: 1, description: 'Page number (1-indexed).' },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
handler: async (args, ctx) => {
|
|
97
|
+
const started = Date.now();
|
|
98
|
+
const requestId = newRequestId();
|
|
99
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
100
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
101
|
+
return errorEnvelope({
|
|
102
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
103
|
+
message: gameParse.error ?? 'game is required',
|
|
104
|
+
game: null,
|
|
105
|
+
source: 'tournaments',
|
|
106
|
+
requestId,
|
|
107
|
+
tookMs: Date.now() - started,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const game = gameParse.game;
|
|
111
|
+
if (game !== 'tennis') {
|
|
112
|
+
return errorEnvelope({
|
|
113
|
+
code: 'NOT_IMPLEMENTED',
|
|
114
|
+
message: `tournaments is tennis-only (game '${game}' has no tournament catalog on this endpoint)`,
|
|
115
|
+
game,
|
|
116
|
+
source: 'tournaments',
|
|
117
|
+
requestId,
|
|
118
|
+
tookMs: Date.now() - started,
|
|
119
|
+
recover: ['Use event_card for this game', 'Retry with game tennis'],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const year = typeof args.year === 'number' && Number.isFinite(args.year) ? Math.trunc(args.year) : null;
|
|
123
|
+
if (year !== null && (year < 1877 || year > 2100)) {
|
|
124
|
+
return errorEnvelope({
|
|
125
|
+
code: 'VALIDATION',
|
|
126
|
+
message: `year must be between 1877 and 2100 (got ${year})`,
|
|
127
|
+
game,
|
|
128
|
+
source: 'tournaments',
|
|
129
|
+
requestId,
|
|
130
|
+
tookMs: Date.now() - started,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const tourRaw = typeof args.tour === 'string' ? args.tour.trim().toUpperCase() : '';
|
|
134
|
+
if (tourRaw && tourRaw !== 'ATP' && tourRaw !== 'WTA') {
|
|
135
|
+
return errorEnvelope({
|
|
136
|
+
code: 'VALIDATION',
|
|
137
|
+
message: `tour must be ATP or WTA (got '${args.tour}')`,
|
|
138
|
+
game,
|
|
139
|
+
source: 'tournaments',
|
|
140
|
+
requestId,
|
|
141
|
+
tookMs: Date.now() - started,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
|
|
145
|
+
const surface = surfaceRaw
|
|
146
|
+
? SURFACES.find((s) => s.toLowerCase() === surfaceRaw.toLowerCase())
|
|
147
|
+
: undefined;
|
|
148
|
+
if (surfaceRaw && !surface) {
|
|
149
|
+
return errorEnvelope({
|
|
150
|
+
code: 'VALIDATION',
|
|
151
|
+
message: `surface must be one of ${SURFACES.join(', ')} (got '${args.surface}')`,
|
|
152
|
+
game,
|
|
153
|
+
source: 'tournaments',
|
|
154
|
+
requestId,
|
|
155
|
+
tookMs: Date.now() - started,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const levelRaw = typeof args.level === 'string' ? args.level.trim() : '';
|
|
159
|
+
if (levelRaw) {
|
|
160
|
+
const known = LEVEL_NAMES.some((l) => l.toLowerCase() === levelRaw.toLowerCase()) ||
|
|
161
|
+
/^[A-Za-z]$/.test(levelRaw);
|
|
162
|
+
if (!known) {
|
|
163
|
+
return errorEnvelope({
|
|
164
|
+
code: 'VALIDATION',
|
|
165
|
+
message: `Unknown level '${levelRaw}'. Use a name or a tier code.`,
|
|
166
|
+
game,
|
|
167
|
+
source: 'tournaments',
|
|
168
|
+
requestId,
|
|
169
|
+
tookMs: Date.now() - started,
|
|
170
|
+
recover: [`Valid names include: ${LEVEL_NAMES.slice(0, 8).join(', ')}`, 'Or a code: G, M, A, C, D'],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const limit = clampInt(args.limit, 20, 1, 50);
|
|
175
|
+
const page = clampInt(args.page, 1, 1, 10000);
|
|
176
|
+
const countryCode = typeof args.countryCode === 'string' && args.countryCode.trim()
|
|
177
|
+
? args.countryCode.trim().toUpperCase()
|
|
178
|
+
: '';
|
|
179
|
+
const q = typeof args.q === 'string' && args.q.trim() ? args.q.trim() : '';
|
|
180
|
+
const res = await fetchJson(ctx, '/tennis/tournaments', {
|
|
181
|
+
query: {
|
|
182
|
+
limit,
|
|
183
|
+
page,
|
|
184
|
+
...(year !== null ? { year } : {}),
|
|
185
|
+
...(tourRaw ? { tour: tourRaw } : {}),
|
|
186
|
+
...(levelRaw ? { level: levelRaw } : {}),
|
|
187
|
+
...(surface ? { surface } : {}),
|
|
188
|
+
...(countryCode ? { country_code: countryCode } : {}),
|
|
189
|
+
...(q ? { q } : {}),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
return errorEnvelope({
|
|
194
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
195
|
+
message: `Tournament catalog failed (HTTP ${res.status})`,
|
|
196
|
+
game,
|
|
197
|
+
source: 'tournaments',
|
|
198
|
+
requestId,
|
|
199
|
+
tookMs: Date.now() - started,
|
|
200
|
+
upstreamCalls: 1,
|
|
201
|
+
httpStatus: res.status,
|
|
202
|
+
rateLimit: res.headers,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const root = asRecord(res.data) ?? {};
|
|
206
|
+
const body = asRecord(root.data) ?? root;
|
|
207
|
+
const items = (Array.isArray(body.items) ? body.items : []).map(normalizeTournament);
|
|
208
|
+
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
209
|
+
const pageSize = typeof body.page_size === 'number' ? body.page_size : limit;
|
|
210
|
+
const filters = [
|
|
211
|
+
year !== null ? String(year) : null,
|
|
212
|
+
tourRaw || null,
|
|
213
|
+
levelRaw || null,
|
|
214
|
+
surface || null,
|
|
215
|
+
countryCode || null,
|
|
216
|
+
q ? `"${q}"` : null,
|
|
217
|
+
].filter(Boolean);
|
|
218
|
+
return successEnvelope({
|
|
219
|
+
pagination: {
|
|
220
|
+
limit: pageSize,
|
|
221
|
+
offset: (page - 1) * pageSize,
|
|
222
|
+
total,
|
|
223
|
+
hasMore: body.has_next === true,
|
|
224
|
+
nextCursor: null,
|
|
225
|
+
prevCursor: null,
|
|
226
|
+
},
|
|
227
|
+
source: 'tournaments',
|
|
228
|
+
game,
|
|
229
|
+
requestId,
|
|
230
|
+
tookMs: Date.now() - started,
|
|
231
|
+
upstreamCalls: 1,
|
|
232
|
+
rateLimit: res.headers,
|
|
233
|
+
data: {
|
|
234
|
+
title: filters.length > 0
|
|
235
|
+
? `Tennis tournaments — ${filters.join(' ')}`
|
|
236
|
+
: 'Tennis tournaments',
|
|
237
|
+
filters: {
|
|
238
|
+
year,
|
|
239
|
+
tour: tourRaw || null,
|
|
240
|
+
level: levelRaw || null,
|
|
241
|
+
surface: surface ?? null,
|
|
242
|
+
countryCode: countryCode || null,
|
|
243
|
+
q: q || null,
|
|
244
|
+
},
|
|
245
|
+
items,
|
|
246
|
+
total,
|
|
247
|
+
page,
|
|
248
|
+
totalPages: typeof body.total_pages === 'number' ? body.total_pages : null,
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
export const tournamentTools = [tournaments];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cito-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Standalone MCP server for the Cito esports and sports API —
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Standalone MCP server for the Cito esports and sports API — 42 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"
|