cito-mcp 0.5.8 → 0.6.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.
@@ -1,3 +1,4 @@
1
+ import { normalizeCoverage } from './rankings.js';
1
2
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
2
3
  import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
3
4
  import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
@@ -77,26 +78,26 @@ export function normalizeStandingRow(row, index) {
77
78
  }
78
79
  export const standings = {
79
80
  name: 'standings',
80
- description: `League/event standings or world/division rankings normalized to ranked rows.
81
-
82
- When to use:
83
- - Table / playoff picture
84
- - UFC rankings; CS2 world or event standings; CDL standings; LoL league/tournament tables
85
-
86
- Prefer over: raw standings via call_api for agent-normalized rows.
87
-
88
- Do not use when: single team form → team_profile; live scores → live_matches.
89
- Dota has no first-class standings (may NOT_IMPLEMENTED or weak worldRanking).
90
-
91
- Required scope keys by game:
92
- - lol: leagueId OR tournamentId
93
- - cs2: omit for world rankings; eventId for event standings
94
- - cod: optional season/stage
95
- - ufc: optional division (scope=division)
96
- - tennis: optional division (ATP or WTA tour; default ATP)
97
- - dota2: best-effort worldRanking only
98
-
99
- Parallel-safe: yes. Upstream cost: 1–2.
81
+ description: `League/event standings or world/division rankings normalized to ranked rows.
82
+
83
+ When to use:
84
+ - Table / playoff picture
85
+ - UFC rankings; CS2 world or event standings; CDL standings; LoL league/tournament tables
86
+
87
+ Prefer over: raw standings via call_api for agent-normalized rows.
88
+
89
+ Do not use when: single team form → team_profile; live scores → live_matches.
90
+ Dota has no first-class standings (may NOT_IMPLEMENTED or weak worldRanking).
91
+
92
+ Required scope keys by game:
93
+ - lol: leagueId OR tournamentId
94
+ - cs2: omit for world rankings; eventId for event standings
95
+ - cod: optional season/stage
96
+ - ufc: optional division (scope=division)
97
+ - tennis: optional division (ATP or WTA tour; default ATP); optional season YYYY = that season's last published list (default: latest). listCoverage says when an archived list is held only in part
98
+ - dota2: best-effort worldRanking only
99
+
100
+ Parallel-safe: yes. API calls: 1–2.
100
101
  Example: { "game": "cod", "season": "2026", "limit": 50 }`,
101
102
  inputSchema: {
102
103
  type: 'object',
@@ -112,7 +113,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
112
113
  leagueId: stringSchema('LoL league id for league standings.'),
113
114
  tournamentId: stringSchema('LoL/COD tournament id.'),
114
115
  eventId: stringSchema('CS2 event id for event standings.'),
115
- season: stringSchema('Season key (COD).'),
116
+ season: stringSchema('Season key (COD). Tennis: a year (YYYY) gives that season\'s last published ranking list.'),
116
117
  stage: stringSchema('Stage key (COD / LoL).'),
117
118
  division: stringSchema('UFC division key.'),
118
119
  limit: limitSchema({ default: 50, max: 100 }),
@@ -145,7 +146,11 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
145
146
  const leagueId = typeof args.leagueId === 'string' ? args.leagueId : undefined;
146
147
  const tournamentId = typeof args.tournamentId === 'string' ? args.tournamentId : undefined;
147
148
  const eventId = typeof args.eventId === 'string' ? args.eventId : undefined;
148
- const season = typeof args.season === 'string' ? args.season : undefined;
149
+ const season = typeof args.season === 'string'
150
+ ? args.season
151
+ : typeof args.season === 'number' && Number.isFinite(args.season)
152
+ ? String(Math.trunc(args.season))
153
+ : undefined;
149
154
  const stage = typeof args.stage === 'string' ? args.stage : undefined;
150
155
  const division = typeof args.division === 'string' ? args.division : undefined;
151
156
  const tennisPage = typeof args.page === 'number' && Number.isFinite(args.page) && args.page >= 1
@@ -241,7 +246,18 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
241
246
  else if (game === 'tennis') {
242
247
  const tour = String(division ?? 'ATP').toUpperCase() === 'WTA' ? 'WTA' : 'ATP';
243
248
  path = '/tennis/standings';
244
- query = { tour, top_n: Math.min(limit, 100), page: tennisPage };
249
+ const tennisSeason = season && /^\d{4}$/.test(season.trim()) ? season.trim() : undefined;
250
+ if (season && !tennisSeason) {
251
+ return errorEnvelope({
252
+ code: 'VALIDATION',
253
+ message: `season must be a year (YYYY) for tennis (got '${season}')`,
254
+ game,
255
+ source: 'standings',
256
+ requestId,
257
+ tookMs: Date.now() - started,
258
+ });
259
+ }
260
+ query = { tour, top_n: Math.min(limit, 100), page: tennisPage, ...(tennisSeason ? { season: tennisSeason } : {}) };
245
261
  effectiveScope = 'world';
246
262
  title = `${tour} singles rankings`;
247
263
  }
@@ -371,6 +387,9 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
371
387
  };
372
388
  pushWarning(upstreamMeta.warning);
373
389
  pushWarning(dataQuality.warning);
390
+ const tennisCoverage = game === 'tennis' ? asRecord(obj?.list_coverage) : null;
391
+ if (tennisCoverage && tennisCoverage.status === 'partial')
392
+ pushWarning(tennisCoverage.note);
374
393
  if (Array.isArray(upstreamMeta.warnings)) {
375
394
  for (const w of upstreamMeta.warnings)
376
395
  pushWarning(w);
@@ -412,6 +431,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
412
431
  ...(game === 'tennis'
413
432
  ? {
414
433
  rankingDate: pickString(obj?.ranking_date) ?? null,
434
+ listCoverage: tennisCoverage ? normalizeCoverage(tennisCoverage) : null,
415
435
  updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt, obj?.ranking_date) ?? null,
416
436
  }
417
437
  : {
@@ -42,7 +42,7 @@ Prefer over: separate roster + matches + detail via call_api.
42
42
  Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
43
43
  Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
44
44
 
45
- Parallel-safe: yes. Upstream cost: 2–4.
45
+ Parallel-safe: yes. API calls: 2–4.
46
46
  Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
47
47
  inputSchema: {
48
48
  type: 'object',
@@ -532,7 +532,7 @@ function winnerSide(match, a) {
532
532
  }
533
533
  export const headToHead = {
534
534
  name: 'head_to_head',
535
- description: `Composed head-to-head record between two teams, two UFC fighters, or two tennis players. No first-class REST H2H exists — this tool filters match history server-side.
535
+ description: `Head-to-head record between two teams, two UFC fighters, or two tennis players. Tennis reads the tennis head-to-head route; other games filter match history server-side.
536
536
 
537
537
  When to use:
538
538
  - Rivalry / series record questions
@@ -544,7 +544,9 @@ Do not use when: single-side form only → team_profile or player_profile.
544
544
 
545
545
  Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
546
546
 
547
- Parallel-safe: yes. Upstream cost: 2–4.
547
+ Tennis: pass entityType "player" with sideA and sideB (ids like atp_207989, or names). This tool has no players param; for three or more players use h2h_matrix, which does. limit pages the meetings list only (max 50); every tally covers all meetings. In the payload current_rank is null when a player is not on the tour's latest list: read last_rank. The tiebreak and level_breakdown keys are in data.h2h.
548
+
549
+ Parallel-safe: yes. API calls: 2–4.
548
550
  Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
549
551
  inputSchema: {
550
552
  type: 'object',
@@ -558,8 +560,8 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
558
560
  default: 'team',
559
561
  description: 'team (default), fighter (UFC), or player (tennis).',
560
562
  },
561
- sideA: stringSchema('Id or slug for side A.', 'faze'),
562
- sideB: stringSchema('Id or slug for side B.', 'navi'),
563
+ sideA: stringSchema('Id or slug for side A (tennis: atp_207989 or a player name).', 'faze'),
564
+ sideB: stringSchema('Id or slug for side B (tennis: atp_206173 or a player name).', 'navi'),
563
565
  limit: limitSchema({ default: 20, max: 50, description: 'Max meetings returned.' }),
564
566
  from: stringSchema('ISO-8601 lower bound on meeting startTime (client filter).'),
565
567
  to: stringSchema('ISO-8601 upper bound on meeting startTime (client filter).'),
@@ -982,7 +984,7 @@ Do not use when: a two-player rivalry deep-dive with tiebreak/decider splits →
982
984
 
983
985
  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.
984
986
 
985
- Parallel-safe: yes. Upstream cost: 1 + one search per unresolved name.`,
987
+ Parallel-safe: yes. API calls: 1 + one search per unresolved name.`,
986
988
  inputSchema: {
987
989
  type: 'object',
988
990
  additionalProperties: false,
@@ -54,10 +54,10 @@ Tennis-only. Levels accept names (Grand Slam, WTA 1000, ATP 500, Challenger,
54
54
  ITF World Tennis Tour, Davis Cup, ...) or raw tier codes (G, M, A, C, D).
55
55
  A level name that starts with ATP or WTA also fixes the tour (and conflicts with
56
56
  a contradicting tour= argument are rejected), so "WTA 1000" cannot return ATP
57
- Masters events even though the upstream filter is not tour-aware.
58
- Note the source does not separate ATP 500 from ATP 250 — both are ATP Tour.
57
+ Masters events.
58
+ The category field reports ATP 500 and ATP 250 both as atp_tour (read tier to tell them apart), and WTA 1000 events as wta_1000.
59
59
 
60
- Parallel-safe: yes. Upstream cost: 1.`,
60
+ Parallel-safe: yes. API calls: 1.`,
61
61
  inputSchema: {
62
62
  type: 'object',
63
63
  additionalProperties: false,
@@ -232,10 +232,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
232
232
  .slice(0, 5)
233
233
  .map((t) => `${t.id} (${t.tour})`)
234
234
  .join(', ');
235
- warnings.push(`The upstream level filter returned ${contradictions.length} tournament(s) that contradict level="${levelRaw}": ` +
236
- `${sample}. They were dropped. The API's level reverse-map is not tour-aware (WTA 1000 resolves to tier codes ` +
237
- `PM and M), which is a server-side defect; this tool sends tour=${levelTourPrefix} as well, so a repeat of this ` +
238
- `warning means the upstream filter is widening again.`);
235
+ warnings.push(`The level filter returned ${contradictions.length} tournament(s) that contradict level="${levelRaw}": ` +
236
+ `${sample}. They were dropped; this tool also sends tour=${levelTourPrefix}.`);
239
237
  }
240
238
  return successEnvelope({
241
239
  pagination: {
@@ -156,7 +156,7 @@ export async function runTool(def, args, ctx) {
156
156
  catch (error) {
157
157
  const { errorEnvelope, toMcpResult: toResult } = await import('../envelope.js');
158
158
  return toResult(errorEnvelope({
159
- code: 'UPSTREAM',
159
+ code: 'SERVICE_ERROR',
160
160
  message: error.message || 'Unhandled tool error',
161
161
  game: null,
162
162
  source: def.name,
@@ -0,0 +1,377 @@
1
+ import { fetchJson } from '../client.js';
2
+ import { errorEnvelope, mapHttpToCode, successEnvelope } from '../envelope.js';
3
+ import { READ_TOOL_ANNOTATIONS, boolSchema, limitSchema, stringSchema, } from './types.js';
4
+ const WRITE = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
5
+ const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
6
+ async function callApi(ctx, tool, method, path, opts = {}, shape = (b) => b) {
7
+ const started = Date.now();
8
+ const res = await fetchJson(ctx, path, { method, query: opts.query, body: opts.body, raw: true });
9
+ const body = (res.data && typeof res.data === 'object' ? res.data : {});
10
+ if (!res.ok) {
11
+ const nested = body.error && typeof body.error === 'object' ? body.error : null;
12
+ const message = typeof body.error === 'string' ? body.error : String(nested?.message ?? `Request failed (HTTP ${res.status})`);
13
+ const recover = [
14
+ ...(body.fix ? [String(body.fix)] : []),
15
+ ...(body.upgradeUrl ? [`Tell the user webhooks need Pro or Scale and give them this link: ${body.upgradeUrl}`] : []),
16
+ ];
17
+ return errorEnvelope({
18
+ code: mapHttpToCode(res.status),
19
+ message,
20
+ game: null,
21
+ source: tool,
22
+ httpStatus: res.status,
23
+ retryable: res.status === 429 || res.status >= 500,
24
+ ...(recover.length ? { recover } : {}),
25
+ ...(body.code || body.upgradeUrl ? { details: { ...(body.code ? { apiCode: body.code } : {}), ...(body.upgradeUrl ? { upgradeUrl: body.upgradeUrl, currentPlan: body.currentPlan, requiredPlan: body.requiredPlan } : {}) } } : {}),
26
+ });
27
+ }
28
+ return successEnvelope({ data: shape(body), game: null, source: tool, tookMs: Date.now() - started, upstreamCalls: 1 });
29
+ }
30
+ function bad(tool, message, recover) {
31
+ return errorEnvelope({ code: 'VALIDATION', message, game: null, source: tool, retryable: false, recover });
32
+ }
33
+ const idArg = stringSchema('The webhook id, from list_webhooks or create_webhook. Never invent one.');
34
+ const inboxIdArg = stringSchema('The inbox id, from create_webhook_inbox or list_webhook_inboxes.');
35
+ const eventsArg = {
36
+ type: 'array',
37
+ items: { type: 'string' },
38
+ description: 'Event names from list_webhook_events. Subscribe to the fewest that do the job. Example: ["cs2.match.completed"].',
39
+ };
40
+ const filtersArg = {
41
+ type: 'object',
42
+ description: 'Optional. Only events matching these are sent, for example {"league":"LEC"}. Check each event\'s filters in list_webhook_events: filtering on a field an event does not carry stops every delivery of it.',
43
+ };
44
+ const without = (body, ...keys) => {
45
+ const out = { ...body };
46
+ for (const k of keys)
47
+ delete out[k];
48
+ return out;
49
+ };
50
+ export const webhookTools = [
51
+ {
52
+ name: 'list_webhook_events',
53
+ description: 'Every webhook event Cito can send, for every game. Each has a description, when it fires, status (active, quiet, not_observed), measured frequency, minimum plan and the filters it supports. Pass event for one event\'s field names and a real example. Only recommend events whose status is "active". API calls: 1.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ game: stringSchema('Filter to one game: lol, cs2, cod, dota2, tennis, ufc, fortnite.', 'cs2'),
58
+ event: stringSchema('One event name. Adds its fields and a real example.', 'cs2.match.completed'),
59
+ },
60
+ additionalProperties: false,
61
+ },
62
+ annotations: READ_TOOL_ANNOTATIONS,
63
+ handler: async (args, ctx) => {
64
+ const detail = typeof args.event === 'string' && args.event.length > 0;
65
+ return callApi(ctx, 'list_webhook_events', 'GET', '/webhooks/events', {
66
+ query: { game: args.game, event: args.event, examples: detail ? undefined : 'false', fields: detail ? undefined : 'false' },
67
+ }, (b) => ({
68
+ events: b.events,
69
+ count: b.count,
70
+ games: b.games,
71
+ plans: b.plans,
72
+ ...(detail ? { envelope: b.envelope, delivery: b.delivery } : {}),
73
+ measuredAt: b.measured_at,
74
+ }));
75
+ },
76
+ },
77
+ {
78
+ name: 'explain_webhook_setup',
79
+ description: 'How Cito webhooks work and what to do next for this user: their plan, their existing webhooks and inboxes, setup steps, signature verification code, retries and what happens during an outage. Start here when a user asks about webhooks or describes polling for changes. API calls: 3.',
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: { game: stringSchema('Optional game, to list its active events.', 'tennis') },
83
+ additionalProperties: false,
84
+ },
85
+ annotations: READ_TOOL_ANNOTATIONS,
86
+ handler: async (args, ctx) => {
87
+ const catalog = await fetchJson(ctx, '/webhooks/events', { query: { game: args.game, examples: 'false', fields: 'false' }, raw: true });
88
+ if (!catalog.ok) {
89
+ return errorEnvelope({ code: mapHttpToCode(catalog.status), message: 'Could not read the webhook catalog.', game: null, source: 'explain_webhook_setup', httpStatus: catalog.status });
90
+ }
91
+ const c = catalog.data;
92
+ const hooks = ctx.apiKey ? await fetchJson(ctx, '/webhooks', { raw: true }) : null;
93
+ const inboxes = ctx.apiKey ? await fetchJson(ctx, '/webhook-inbox', { raw: true }) : null;
94
+ const hookBody = (hooks?.data ?? {});
95
+ const inboxBody = (inboxes?.data ?? {});
96
+ const planLocked = hooks?.status === 403;
97
+ return successEnvelope({
98
+ data: {
99
+ account: {
100
+ liveDeliveryAvailable: hooks ? hooks.ok : null,
101
+ ...(planLocked ? { upgradeUrl: hookBody.upgradeUrl, currentPlan: hookBody.currentPlan, whatYouCanDoNow: 'Create a test inbox and add sample events to see the real payloads. Live delivery needs Pro or Scale.' } : {}),
102
+ webhooks: hooks?.ok ? hookBody.webhooks : [],
103
+ inboxes: inboxes?.ok ? inboxBody.inboxes : [],
104
+ },
105
+ whatTheyAre: 'Cito POSTs a signed JSON event to your URL when something happens, so you stop polling and spending calls to find out.',
106
+ steps: c.setup,
107
+ plans: c.plans,
108
+ delivery: c.delivery,
109
+ filters: c.filters,
110
+ envelope: c.envelope,
111
+ activeEvents: c.events.filter((e) => e.status === 'active').map((e) => ({ event: e.event, typicalFrequency: e.typicalFrequency })),
112
+ verifySignature: {
113
+ node: "const ok = crypto.timingSafeEqual(Buffer.from(req.headers['x-cito-signature']), Buffer.from(crypto.createHmac('sha256', process.env.CITO_WEBHOOK_SECRET).update(rawBody).digest('hex')));",
114
+ python: "ok = hmac.compare_digest(headers['X-Cito-Signature'], hmac.new(os.environ['CITO_WEBHOOK_SECRET'].encode(), raw_body, hashlib.sha256).hexdigest())",
115
+ note: 'Hash the raw request bytes before parsing JSON.',
116
+ },
117
+ safety: 'Writes through these tools only touch this account\'s webhooks and test inboxes. The account owner gets an email when a key creates a webhook or changes its URL.',
118
+ docs: c.docs,
119
+ },
120
+ game: null,
121
+ source: 'explain_webhook_setup',
122
+ upstreamCalls: 3,
123
+ });
124
+ },
125
+ },
126
+ {
127
+ name: 'list_webhooks',
128
+ description: 'This account\'s webhooks with their health: failures, last success, last error, and whether sending is paused. Never includes secrets. Needs a plan with webhooks. API calls: 1.',
129
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
130
+ annotations: READ_TOOL_ANNOTATIONS,
131
+ handler: async (_args, ctx) => callApi(ctx, 'list_webhooks', 'GET', '/webhooks'),
132
+ },
133
+ {
134
+ name: 'create_webhook',
135
+ description: 'Create a webhook on this account: Cito starts POSTing the chosen events to the URL. Needs Pro or Scale; a plan error includes the upgrade link to relay. The URL must be public. The account owner is emailed. webhook.secret comes back ONCE: have the user store it in an environment variable such as CITO_WEBHOOK_SECRET, and do not repeat it in chat. Then call test_webhook. API calls: 1.',
136
+ inputSchema: {
137
+ type: 'object',
138
+ required: ['url', 'events'],
139
+ properties: {
140
+ url: stringSchema('Public URL that accepts POST and answers 2xx within 10 seconds. A test inbox url works too.', 'https://your-domain.com/webhooks/cito'),
141
+ events: eventsArg,
142
+ filters: filtersArg,
143
+ },
144
+ additionalProperties: false,
145
+ },
146
+ annotations: WRITE,
147
+ handler: async (args, ctx) => {
148
+ if (typeof args.url !== 'string' || !Array.isArray(args.events)) {
149
+ return bad('create_webhook', 'url and events are required.', ['Call list_webhook_events to pick events, then retry with url and events.']);
150
+ }
151
+ return callApi(ctx, 'create_webhook', 'POST', '/webhooks', { body: { url: args.url, events: args.events, ...(args.filters ? { filters: args.filters } : {}) } }, (b) => ({
152
+ ...b,
153
+ secretHandling: 'Store webhook.secret in an environment variable now. It is never shown again; regenerate_webhook_secret replaces it.',
154
+ }));
155
+ },
156
+ },
157
+ {
158
+ name: 'update_webhook',
159
+ description: 'Change a webhook\'s URL, events, filters or on/off state. Changing the URL emails the account owner. Send only what changes. API calls: 1.',
160
+ inputSchema: {
161
+ type: 'object',
162
+ required: ['id'],
163
+ properties: {
164
+ id: idArg,
165
+ url: stringSchema('New public URL.'),
166
+ events: eventsArg,
167
+ filters: { ...filtersArg, description: `${filtersArg.description} Send null to clear them.`, type: ['object', 'null'] },
168
+ isActive: boolSchema('false pauses the webhook; true turns it back on and clears its failures.'),
169
+ },
170
+ additionalProperties: false,
171
+ },
172
+ annotations: WRITE,
173
+ handler: async (args, ctx) => {
174
+ const body = {};
175
+ for (const k of ['url', 'events', 'filters', 'isActive'])
176
+ if (args[k] !== undefined)
177
+ body[k] = args[k];
178
+ if (typeof args.id !== 'string')
179
+ return bad('update_webhook', 'id is required.', ['Call list_webhooks to find the id.']);
180
+ if (Object.keys(body).length === 0)
181
+ return bad('update_webhook', 'Nothing to change.', ['Send at least one of url, events, filters, isActive.']);
182
+ return callApi(ctx, 'update_webhook', 'PATCH', `/webhooks/${encodeURIComponent(args.id)}`, { body });
183
+ },
184
+ },
185
+ {
186
+ name: 'delete_webhook',
187
+ description: 'Permanently delete a webhook on this account. Only call this when the user clearly asked to delete this specific webhook, and pass confirm: true. To stop deliveries without deleting, use update_webhook with isActive false. API calls: 1.',
188
+ inputSchema: {
189
+ type: 'object',
190
+ required: ['id', 'confirm'],
191
+ properties: { id: idArg, confirm: boolSchema('Must be true. Set it only after the user clearly asked to delete this webhook.') },
192
+ additionalProperties: false,
193
+ },
194
+ annotations: DESTRUCTIVE,
195
+ handler: async (args, ctx) => {
196
+ if (args.confirm !== true) {
197
+ return bad('delete_webhook', 'Not deleted: confirm must be true.', ['Ask the user whether to delete this webhook, naming its URL. Then retry with confirm: true.', 'To stop deliveries without deleting, use update_webhook with isActive false.']);
198
+ }
199
+ if (typeof args.id !== 'string')
200
+ return bad('delete_webhook', 'id is required.', ['Call list_webhooks to find the id.']);
201
+ return callApi(ctx, 'delete_webhook', 'DELETE', `/webhooks/${encodeURIComponent(args.id)}`);
202
+ },
203
+ },
204
+ {
205
+ name: 'regenerate_webhook_secret',
206
+ description: 'Replace a webhook\'s signing secret. The old secret stops verifying immediately, so only do this when the user asks. The new secret comes back once: have them store it, and do not repeat it in chat. API calls: 1.',
207
+ inputSchema: { type: 'object', required: ['id'], properties: { id: idArg }, additionalProperties: false },
208
+ annotations: WRITE,
209
+ handler: async (args, ctx) => {
210
+ if (typeof args.id !== 'string')
211
+ return bad('regenerate_webhook_secret', 'id is required.', ['Call list_webhooks to find the id.']);
212
+ return callApi(ctx, 'regenerate_webhook_secret', 'POST', `/webhooks/${encodeURIComponent(args.id)}/regenerate-secret`, { body: {} });
213
+ },
214
+ },
215
+ {
216
+ name: 'test_webhook',
217
+ description: 'Send a test to a webhook now and report what happened: status, statusCode, latencyMs and a plain-English diagnosis to relay. With event, sends a real recent sample of that event, signed and marked as a test (test: true, data.test: true, X-Cito-Test: true); without it, a short ping. API calls: 1.',
218
+ inputSchema: {
219
+ type: 'object',
220
+ required: ['id'],
221
+ properties: { id: idArg, event: stringSchema('Event to sample, from list_webhook_events.', 'cs2.match.completed') },
222
+ additionalProperties: false,
223
+ },
224
+ annotations: WRITE,
225
+ handler: async (args, ctx) => {
226
+ if (typeof args.id !== 'string')
227
+ return bad('test_webhook', 'id is required.', ['Call list_webhooks to find the id.']);
228
+ return callApi(ctx, 'test_webhook', 'POST', `/webhooks/${encodeURIComponent(args.id)}/test`, { body: args.event ? { event: args.event } : {} }, (b) => without(b, 'debug'));
229
+ },
230
+ },
231
+ {
232
+ name: 'webhook_deliveries',
233
+ description: 'A webhook\'s delivery log, newest first: event, status (delivered, failed, skipped_backoff), HTTP code, error and a diagnosis. Pass deliveryId for one delivery with its payload. skipped_backoff means the event was NOT sent because the webhook was paused; see catch_up_webhook. API calls: 1.',
234
+ inputSchema: {
235
+ type: 'object',
236
+ required: ['id'],
237
+ properties: {
238
+ id: idArg,
239
+ deliveryId: stringSchema('One delivery, with its payload.'),
240
+ status: stringSchema('delivered, failed, skipped_backoff; comma separated.', 'failed,skipped_backoff'),
241
+ event: stringSchema('Only this event name.'),
242
+ matchId: stringSchema('Only this match.'),
243
+ since: stringSchema('ISO 8601 time.'),
244
+ until: stringSchema('ISO 8601 time.'),
245
+ summary: boolSchema('Add 7-day counts by status.'),
246
+ limit: limitSchema({ default: 20, max: 100 }),
247
+ cursor: stringSchema('nextCursor from the previous page.'),
248
+ },
249
+ additionalProperties: false,
250
+ },
251
+ annotations: READ_TOOL_ANNOTATIONS,
252
+ handler: async (args, ctx) => {
253
+ if (typeof args.id !== 'string')
254
+ return bad('webhook_deliveries', 'id is required.', ['Call list_webhooks to find the id.']);
255
+ const base = `/webhooks/${encodeURIComponent(args.id)}/deliveries`;
256
+ if (typeof args.deliveryId === 'string') {
257
+ return callApi(ctx, 'webhook_deliveries', 'GET', `${base}/${encodeURIComponent(args.deliveryId)}`);
258
+ }
259
+ return callApi(ctx, 'webhook_deliveries', 'GET', base, {
260
+ query: { status: args.status, event: args.event, matchId: args.matchId, since: args.since, until: args.until, limit: args.limit ?? 20, cursor: args.cursor, include: args.summary ? 'summary' : undefined },
261
+ });
262
+ },
263
+ },
264
+ {
265
+ name: 'replay_webhook',
266
+ description: 'Re-send stored events to this webhook: one eventId, or a matchId (at most 10 events, optionally one eventType). Each is marked data.replayed true with data.original_event_id, so the receiver can deduplicate. Take the ids from webhook_deliveries. API calls: 1.',
267
+ inputSchema: {
268
+ type: 'object',
269
+ required: ['id'],
270
+ properties: { id: idArg, eventId: stringSchema('One event to re-send.'), matchId: stringSchema('Re-send this match\'s events.'), eventType: stringSchema('With matchId, only this event name.') },
271
+ additionalProperties: false,
272
+ },
273
+ annotations: WRITE,
274
+ handler: async (args, ctx) => {
275
+ if (typeof args.id !== 'string')
276
+ return bad('replay_webhook', 'id is required.', ['Call list_webhooks to find the id.']);
277
+ const body = {};
278
+ for (const k of ['eventId', 'matchId', 'eventType'])
279
+ if (args[k] !== undefined)
280
+ body[k] = args[k];
281
+ return callApi(ctx, 'replay_webhook', 'POST', `/webhooks/${encodeURIComponent(args.id)}/replay`, { body });
282
+ },
283
+ },
284
+ {
285
+ name: 'catch_up_webhook',
286
+ description: 'After an endpoint outage: re-send the match and result events this webhook missed (failed, or skipped during a pause) and never received. Live ticks are left out. Use dryRun first to see how many. Stops at the first failure. API calls: 1.',
287
+ inputSchema: {
288
+ type: 'object',
289
+ required: ['id'],
290
+ properties: {
291
+ id: idArg,
292
+ since: stringSchema('ISO 8601 time. Default 24 hours ago, at most 7 days.'),
293
+ limit: limitSchema({ default: 50, max: 100 }),
294
+ dryRun: boolSchema('Only report what is missing.'),
295
+ },
296
+ additionalProperties: false,
297
+ },
298
+ annotations: WRITE,
299
+ handler: async (args, ctx) => {
300
+ if (typeof args.id !== 'string')
301
+ return bad('catch_up_webhook', 'id is required.', ['Call list_webhooks to find the id.']);
302
+ const body = {};
303
+ for (const k of ['since', 'limit', 'dryRun'])
304
+ if (args[k] !== undefined)
305
+ body[k] = args[k];
306
+ return callApi(ctx, 'catch_up_webhook', 'POST', `/webhooks/${encodeURIComponent(args.id)}/catch-up`, { body });
307
+ },
308
+ },
309
+ {
310
+ name: 'create_webhook_inbox',
311
+ description: 'Create a hosted temporary URL that stores webhook requests, so no server or tunnel is needed. Works on every plan and lasts 24 hours. Free and Starter accounts add samples with send_inbox_sample; on Pro or Scale, a webhook can target its url to see real events. signingSecret comes back once. API calls: 1.',
312
+ inputSchema: { type: 'object', properties: { name: stringSchema('Optional label.') }, additionalProperties: false },
313
+ annotations: WRITE,
314
+ handler: async (args, ctx) => callApi(ctx, 'create_webhook_inbox', 'POST', '/webhook-inbox', { body: args.name ? { name: args.name } : {} }),
315
+ },
316
+ {
317
+ name: 'list_webhook_inboxes',
318
+ description: 'This account\'s live test inboxes, their limits and whether the plan includes live delivery. API calls: 1.',
319
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
320
+ annotations: READ_TOOL_ANNOTATIONS,
321
+ handler: async (_args, ctx) => callApi(ctx, 'list_webhook_inboxes', 'GET', '/webhook-inbox'),
322
+ },
323
+ {
324
+ name: 'read_webhook_inbox',
325
+ description: 'What an inbox received: headers, body and whether the signature was valid. To poll, pass latestRequestId from the previous call as after. API calls: 1.',
326
+ inputSchema: {
327
+ type: 'object',
328
+ required: ['id'],
329
+ properties: { id: inboxIdArg, after: stringSchema('Only requests newer than this id.'), limit: limitSchema({ default: 10, max: 100 }) },
330
+ additionalProperties: false,
331
+ },
332
+ annotations: READ_TOOL_ANNOTATIONS,
333
+ handler: async (args, ctx) => {
334
+ if (typeof args.id !== 'string')
335
+ return bad('read_webhook_inbox', 'id is required.', ['Call list_webhook_inboxes to find the id.']);
336
+ return callApi(ctx, 'read_webhook_inbox', 'GET', `/webhook-inbox/${encodeURIComponent(args.id)}/requests`, { query: { after: args.after, limit: args.limit ?? 10 } });
337
+ },
338
+ },
339
+ {
340
+ name: 'send_inbox_sample',
341
+ description: 'Put a real sample of an event into a test inbox, signed with the inbox secret and marked as a test. Any plan, any event; below Pro the sample is at least 2 hours old. Up to 30 an hour. API calls: 1.',
342
+ inputSchema: {
343
+ type: 'object',
344
+ required: ['id', 'event'],
345
+ properties: { id: inboxIdArg, event: stringSchema('Event name from list_webhook_events.', 'cs2.match.completed') },
346
+ additionalProperties: false,
347
+ },
348
+ annotations: WRITE,
349
+ handler: async (args, ctx) => {
350
+ if (typeof args.id !== 'string' || typeof args.event !== 'string')
351
+ return bad('send_inbox_sample', 'id and event are required.', ['Call list_webhook_inboxes and list_webhook_events.']);
352
+ return callApi(ctx, 'send_inbox_sample', 'POST', `/webhook-inbox/${encodeURIComponent(args.id)}/sample`, { body: { event: args.event } });
353
+ },
354
+ },
355
+ {
356
+ name: 'clear_webhook_inbox',
357
+ description: 'Remove everything stored in a test inbox. The inbox and its URL stay. API calls: 1.',
358
+ inputSchema: { type: 'object', required: ['id'], properties: { id: inboxIdArg }, additionalProperties: false },
359
+ annotations: DESTRUCTIVE,
360
+ handler: async (args, ctx) => {
361
+ if (typeof args.id !== 'string')
362
+ return bad('clear_webhook_inbox', 'id is required.', ['Call list_webhook_inboxes to find the id.']);
363
+ return callApi(ctx, 'clear_webhook_inbox', 'DELETE', `/webhook-inbox/${encodeURIComponent(args.id)}/requests`);
364
+ },
365
+ },
366
+ {
367
+ name: 'delete_webhook_inbox',
368
+ description: 'Delete a test inbox and what it holds. A webhook still pointing at it starts failing, so update or delete that webhook too. API calls: 1.',
369
+ inputSchema: { type: 'object', required: ['id'], properties: { id: inboxIdArg }, additionalProperties: false },
370
+ annotations: DESTRUCTIVE,
371
+ handler: async (args, ctx) => {
372
+ if (typeof args.id !== 'string')
373
+ return bad('delete_webhook_inbox', 'id is required.', ['Call list_webhook_inboxes to find the id.']);
374
+ return callApi(ctx, 'delete_webhook_inbox', 'DELETE', `/webhook-inbox/${encodeURIComponent(args.id)}`);
375
+ },
376
+ },
377
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "description": "Standalone MCP server for the Cito esports and sports API — curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
5
5
  "type": "module",
6
6
  "homepage": "https://cito.gg",