cito-mcp 0.3.21 → 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/index.js +8 -0
- package/dist/tools/meta.js +60 -0
- 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/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];
|
|
@@ -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"
|