cito-mcp 0.5.7 → 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.
@@ -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.7",
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",