cito-mcp 0.3.21 → 0.4.1
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 +42 -2
- package/dist/tools/index.js +8 -0
- package/dist/tools/insight.js +95 -4
- package/dist/tools/match.js +75 -5
- package/dist/tools/meta.js +62 -2
- package/dist/tools/normalize.js +221 -15
- package/dist/tools/odds.js +295 -0
- package/dist/tools/player.js +423 -3
- package/dist/tools/rankings.js +214 -3
- package/dist/tools/resolve.js +67 -7
- package/dist/tools/schedule.js +323 -0
- package/dist/tools/standings.js +18 -2
- package/dist/tools/tournaments.js +319 -0
- package/package.json +2 -2
|
@@ -0,0 +1,319 @@
|
|
|
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
|
+
A level name that starts with ATP or WTA also fixes the tour (and conflicts with
|
|
73
|
+
a contradicting tour= argument are rejected), so "WTA 1000" cannot return ATP
|
|
74
|
+
Masters events even though the upstream filter is not tour-aware.
|
|
75
|
+
Note the source does not separate ATP 500 from ATP 250 — both are ATP Tour.
|
|
76
|
+
|
|
77
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
required: ['game'],
|
|
82
|
+
properties: {
|
|
83
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
84
|
+
year: {
|
|
85
|
+
type: 'integer',
|
|
86
|
+
minimum: 1877,
|
|
87
|
+
maximum: 2100,
|
|
88
|
+
description: 'Edition year, e.g. 2026. Omit for all years.',
|
|
89
|
+
},
|
|
90
|
+
tour: stringSchema('ATP or WTA.', 'WTA'),
|
|
91
|
+
level: stringSchema(`Filter by level. Names: ${LEVEL_NAMES.slice(0, 6).join(', ')}, ... or codes G|M|A|C|D.`, 'WTA 1000'),
|
|
92
|
+
surface: stringSchema('Hard, Clay, Grass, or Carpet.', 'Clay'),
|
|
93
|
+
countryCode: stringSchema('3-letter IOC country code, e.g. FRA.', 'FRA'),
|
|
94
|
+
q: stringSchema('Case-insensitive name search, e.g. "open".', 'open'),
|
|
95
|
+
limit: limitSchema({ default: 20, max: 50 }),
|
|
96
|
+
page: { type: 'integer', minimum: 1, default: 1, description: 'Page number (1-indexed).' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
handler: async (args, ctx) => {
|
|
100
|
+
const started = Date.now();
|
|
101
|
+
const requestId = newRequestId();
|
|
102
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
103
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
104
|
+
return errorEnvelope({
|
|
105
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
106
|
+
message: gameParse.error ?? 'game is required',
|
|
107
|
+
game: null,
|
|
108
|
+
source: 'tournaments',
|
|
109
|
+
requestId,
|
|
110
|
+
tookMs: Date.now() - started,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const game = gameParse.game;
|
|
114
|
+
if (game !== 'tennis') {
|
|
115
|
+
return errorEnvelope({
|
|
116
|
+
code: 'NOT_IMPLEMENTED',
|
|
117
|
+
message: `tournaments is tennis-only (game '${game}' has no tournament catalog on this endpoint)`,
|
|
118
|
+
game,
|
|
119
|
+
source: 'tournaments',
|
|
120
|
+
requestId,
|
|
121
|
+
tookMs: Date.now() - started,
|
|
122
|
+
recover: ['Use event_card for this game', 'Retry with game tennis'],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const year = typeof args.year === 'number' && Number.isFinite(args.year) ? Math.trunc(args.year) : null;
|
|
126
|
+
if (year !== null && (year < 1877 || year > 2100)) {
|
|
127
|
+
return errorEnvelope({
|
|
128
|
+
code: 'VALIDATION',
|
|
129
|
+
message: `year must be between 1877 and 2100 (got ${year})`,
|
|
130
|
+
game,
|
|
131
|
+
source: 'tournaments',
|
|
132
|
+
requestId,
|
|
133
|
+
tookMs: Date.now() - started,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const tourRaw = typeof args.tour === 'string' ? args.tour.trim().toUpperCase() : '';
|
|
137
|
+
if (tourRaw && tourRaw !== 'ATP' && tourRaw !== 'WTA') {
|
|
138
|
+
return errorEnvelope({
|
|
139
|
+
code: 'VALIDATION',
|
|
140
|
+
message: `tour must be ATP or WTA (got '${args.tour}')`,
|
|
141
|
+
game,
|
|
142
|
+
source: 'tournaments',
|
|
143
|
+
requestId,
|
|
144
|
+
tookMs: Date.now() - started,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
|
|
148
|
+
const surface = surfaceRaw
|
|
149
|
+
? SURFACES.find((s) => s.toLowerCase() === surfaceRaw.toLowerCase())
|
|
150
|
+
: undefined;
|
|
151
|
+
if (surfaceRaw && !surface) {
|
|
152
|
+
return errorEnvelope({
|
|
153
|
+
code: 'VALIDATION',
|
|
154
|
+
message: `surface must be one of ${SURFACES.join(', ')} (got '${args.surface}')`,
|
|
155
|
+
game,
|
|
156
|
+
source: 'tournaments',
|
|
157
|
+
requestId,
|
|
158
|
+
tookMs: Date.now() - started,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const levelRaw = typeof args.level === 'string' ? args.level.trim() : '';
|
|
162
|
+
if (levelRaw) {
|
|
163
|
+
const known = LEVEL_NAMES.some((l) => l.toLowerCase() === levelRaw.toLowerCase()) ||
|
|
164
|
+
/^[A-Za-z]$/.test(levelRaw);
|
|
165
|
+
if (!known) {
|
|
166
|
+
return errorEnvelope({
|
|
167
|
+
code: 'VALIDATION',
|
|
168
|
+
message: `Unknown level '${levelRaw}'. Use a name or a tier code.`,
|
|
169
|
+
game,
|
|
170
|
+
source: 'tournaments',
|
|
171
|
+
requestId,
|
|
172
|
+
tookMs: Date.now() - started,
|
|
173
|
+
recover: [`Valid names include: ${LEVEL_NAMES.slice(0, 8).join(', ')}`, 'Or a code: G, M, A, C, D'],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A level name that starts with a tour token constrains the tour, and the
|
|
179
|
+
* upstream filter does not know that.
|
|
180
|
+
*
|
|
181
|
+
* The API's reverse level map is not tour-aware: `_TIER_WTA` carries BOTH
|
|
182
|
+
* "PM" and "M" as "WTA 1000" (M is the fallback label for a WTA row stored
|
|
183
|
+
* with the ATP code), so `?level=WTA 1000` resolves to `tier_code IN
|
|
184
|
+
* ('PM','M')` and returns every ATP Masters 1000 event alongside the WTA
|
|
185
|
+
* ones. Verified live: asking for "WTA 1000" returned Rome Masters, Madrid
|
|
186
|
+
* Masters, Monte Carlo, Miami Masters and Indian Wells Masters (all ATP).
|
|
187
|
+
* The same widening hits "WTA 125" (code C, shared with ATP Challengers) and
|
|
188
|
+
* "ATP 500"/"ATP 250" (code A, shared with WTA tour-level rows).
|
|
189
|
+
*
|
|
190
|
+
* The tool therefore asserts the invariant the label states: it sends the
|
|
191
|
+
* tour, and it verifies the rows that come back. Rows that contradict the
|
|
192
|
+
* requested level are dropped and counted rather than passed through as
|
|
193
|
+
* "close enough" — a filter that returns other-tour events is worse than a
|
|
194
|
+
* filter that returns nothing, because the caller has no way to notice.
|
|
195
|
+
*/
|
|
196
|
+
const levelTourPrefix = (() => {
|
|
197
|
+
const m = /^(ATP|WTA)\b/i.exec(levelRaw);
|
|
198
|
+
return m ? m[1].toUpperCase() : null;
|
|
199
|
+
})();
|
|
200
|
+
if (levelTourPrefix && tourRaw && tourRaw !== levelTourPrefix) {
|
|
201
|
+
return errorEnvelope({
|
|
202
|
+
code: 'VALIDATION',
|
|
203
|
+
message: `tour ('${tourRaw}') contradicts level ('${levelRaw}'), which names the ${levelTourPrefix} tour`,
|
|
204
|
+
game,
|
|
205
|
+
source: 'tournaments',
|
|
206
|
+
requestId,
|
|
207
|
+
tookMs: Date.now() - started,
|
|
208
|
+
recover: [
|
|
209
|
+
`Drop tour, or set tour=${levelTourPrefix}`,
|
|
210
|
+
'A level name that starts with ATP/WTA already determines the tour',
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const effectiveTour = tourRaw || levelTourPrefix || '';
|
|
215
|
+
const limit = clampInt(args.limit, 20, 1, 50);
|
|
216
|
+
const page = clampInt(args.page, 1, 1, 10000);
|
|
217
|
+
const countryCode = typeof args.countryCode === 'string' && args.countryCode.trim()
|
|
218
|
+
? args.countryCode.trim().toUpperCase()
|
|
219
|
+
: '';
|
|
220
|
+
const q = typeof args.q === 'string' && args.q.trim() ? args.q.trim() : '';
|
|
221
|
+
const res = await fetchJson(ctx, '/tennis/tournaments', {
|
|
222
|
+
query: {
|
|
223
|
+
limit,
|
|
224
|
+
page,
|
|
225
|
+
...(year !== null ? { year } : {}),
|
|
226
|
+
...(effectiveTour ? { tour: effectiveTour } : {}),
|
|
227
|
+
...(levelRaw ? { level: levelRaw } : {}),
|
|
228
|
+
...(surface ? { surface } : {}),
|
|
229
|
+
...(countryCode ? { country_code: countryCode } : {}),
|
|
230
|
+
...(q ? { q } : {}),
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
if (!res.ok) {
|
|
234
|
+
return errorEnvelope({
|
|
235
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
236
|
+
message: `Tournament catalog failed (HTTP ${res.status})`,
|
|
237
|
+
game,
|
|
238
|
+
source: 'tournaments',
|
|
239
|
+
requestId,
|
|
240
|
+
tookMs: Date.now() - started,
|
|
241
|
+
upstreamCalls: 1,
|
|
242
|
+
httpStatus: res.status,
|
|
243
|
+
rateLimit: res.headers,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const root = asRecord(res.data) ?? {};
|
|
247
|
+
const body = asRecord(root.data) ?? root;
|
|
248
|
+
const rawItems = (Array.isArray(body.items) ? body.items : []).map(normalizeTournament);
|
|
249
|
+
// Verify the invariant the level label states. See levelTourPrefix above.
|
|
250
|
+
const contradictions = levelTourPrefix
|
|
251
|
+
? rawItems.filter((t) => t.tour && t.tour.toUpperCase() !== levelTourPrefix)
|
|
252
|
+
: [];
|
|
253
|
+
const items = contradictions.length
|
|
254
|
+
? rawItems.filter((t) => !t.tour || t.tour.toUpperCase() === levelTourPrefix)
|
|
255
|
+
: rawItems;
|
|
256
|
+
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
257
|
+
const pageSize = typeof body.page_size === 'number' ? body.page_size : limit;
|
|
258
|
+
const filters = [
|
|
259
|
+
year !== null ? String(year) : null,
|
|
260
|
+
effectiveTour || null,
|
|
261
|
+
levelRaw || null,
|
|
262
|
+
surface || null,
|
|
263
|
+
countryCode || null,
|
|
264
|
+
q ? `"${q}"` : null,
|
|
265
|
+
].filter(Boolean);
|
|
266
|
+
const warnings = [];
|
|
267
|
+
if (contradictions.length) {
|
|
268
|
+
const sample = contradictions
|
|
269
|
+
.slice(0, 5)
|
|
270
|
+
.map((t) => `${t.id} (${t.tour})`)
|
|
271
|
+
.join(', ');
|
|
272
|
+
warnings.push(`The upstream level filter returned ${contradictions.length} tournament(s) that contradict level="${levelRaw}": ` +
|
|
273
|
+
`${sample}. They were dropped. The API's level reverse-map is not tour-aware (WTA 1000 resolves to tier codes ` +
|
|
274
|
+
`PM and M), which is a server-side defect; this tool sends tour=${levelTourPrefix} as well, so a repeat of this ` +
|
|
275
|
+
`warning means the upstream filter is widening again.`);
|
|
276
|
+
}
|
|
277
|
+
return successEnvelope({
|
|
278
|
+
pagination: {
|
|
279
|
+
limit: pageSize,
|
|
280
|
+
offset: (page - 1) * pageSize,
|
|
281
|
+
total,
|
|
282
|
+
hasMore: body.has_next === true,
|
|
283
|
+
nextCursor: null,
|
|
284
|
+
prevCursor: null,
|
|
285
|
+
},
|
|
286
|
+
source: 'tournaments',
|
|
287
|
+
game,
|
|
288
|
+
requestId,
|
|
289
|
+
tookMs: Date.now() - started,
|
|
290
|
+
upstreamCalls: 1,
|
|
291
|
+
rateLimit: res.headers,
|
|
292
|
+
warnings: warnings.length ? warnings : undefined,
|
|
293
|
+
data: {
|
|
294
|
+
title: filters.length > 0
|
|
295
|
+
? `Tennis tournaments — ${filters.join(' ')}`
|
|
296
|
+
: 'Tennis tournaments',
|
|
297
|
+
filters: {
|
|
298
|
+
year,
|
|
299
|
+
tour: effectiveTour || null,
|
|
300
|
+
level: levelRaw || null,
|
|
301
|
+
surface: surface ?? null,
|
|
302
|
+
countryCode: countryCode || null,
|
|
303
|
+
q: q || null,
|
|
304
|
+
},
|
|
305
|
+
levelFilterIntegrity: {
|
|
306
|
+
levelNamesTour: levelTourPrefix,
|
|
307
|
+
rowsChecked: rawItems.length,
|
|
308
|
+
contradictionsDropped: contradictions.length,
|
|
309
|
+
ok: contradictions.length === 0,
|
|
310
|
+
},
|
|
311
|
+
items,
|
|
312
|
+
total,
|
|
313
|
+
page,
|
|
314
|
+
totalPages: typeof body.total_pages === 'number' ? body.total_pages : null,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
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.1",
|
|
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"
|