cito-mcp 0.1.0 → 0.2.2
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 +396 -44
- package/dist/client.js +208 -0
- package/dist/envelope.js +210 -0
- package/dist/index.js +103 -201
- package/dist/instructions.js +80 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/insight.js +1013 -0
- package/dist/tools/live.js +447 -0
- package/dist/tools/match.js +464 -0
- package/dist/tools/meta.js +609 -0
- package/dist/tools/normalize.js +177 -0
- package/dist/tools/player.js +357 -0
- package/dist/tools/resolve.js +518 -0
- package/dist/tools/standings.js +319 -0
- package/dist/tools/team.js +704 -0
- package/dist/tools/types.js +85 -0
- package/package.json +3 -3
- package/dist/executor.js +0 -75
- package/dist/spec.js +0 -193
- package/dist/tools.js +0 -203
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta / power tools: list_capabilities, api_health, call_api.
|
|
3
|
+
*/
|
|
4
|
+
import { extractRows, fetchJson, gameNotIncludedHint, present } from '../client.js';
|
|
5
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
6
|
+
import { boolSchema, gameSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
|
|
7
|
+
const CATALOG_VERSION = '0.2.2';
|
|
8
|
+
const TOOL_CATALOG = [
|
|
9
|
+
{
|
|
10
|
+
name: 'list_capabilities',
|
|
11
|
+
outcome: 'Curated catalog of tools, games, jobs, and recipes',
|
|
12
|
+
parallelSafe: true,
|
|
13
|
+
games: [...PRIMARY_GAMES],
|
|
14
|
+
jobs: ['app_scaffold'],
|
|
15
|
+
exampleArgs: { game: 'cs2', job: 'live_board' },
|
|
16
|
+
preferOver: ['call_api for discovery'],
|
|
17
|
+
doNotUse: 'When you already know the tool and have IDs',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'api_health',
|
|
21
|
+
outcome: 'API reachability, key validity, tier, rate limits, included games',
|
|
22
|
+
parallelSafe: true,
|
|
23
|
+
games: [...PRIMARY_GAMES],
|
|
24
|
+
jobs: ['app_scaffold'],
|
|
25
|
+
exampleArgs: { includeGameProbes: true },
|
|
26
|
+
preferOver: ['probing random game endpoints'],
|
|
27
|
+
doNotUse: 'When you only need live scores — use live_matches',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'resolve_entity',
|
|
31
|
+
outcome: 'Name → best typed entity ID(s) + game',
|
|
32
|
+
parallelSafe: true,
|
|
33
|
+
games: [...PRIMARY_GAMES],
|
|
34
|
+
jobs: ['team_page', 'player_form', 'match_page'],
|
|
35
|
+
exampleArgs: { q: 'T1', game: 'lol', type: 'team', limit: 5 },
|
|
36
|
+
preferOver: ['search_entities when chaining one ID'],
|
|
37
|
+
doNotUse: 'When you already have a stable id/slug',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'search_entities',
|
|
41
|
+
outcome: 'Browse/search entities with type filter and pagination',
|
|
42
|
+
parallelSafe: true,
|
|
43
|
+
games: [...PRIMARY_GAMES],
|
|
44
|
+
jobs: ['team_page', 'player_form'],
|
|
45
|
+
exampleArgs: { game: 'cs2', q: 'vitality', type: 'team', limit: 20 },
|
|
46
|
+
preferOver: ['resolve_entity when browsing lists'],
|
|
47
|
+
doNotUse: 'For known entity profiles — use team_profile or player_profile',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'live_matches',
|
|
51
|
+
outcome: 'Live matches board (single game or all primary)',
|
|
52
|
+
parallelSafe: true,
|
|
53
|
+
games: [...PRIMARY_GAMES],
|
|
54
|
+
jobs: ['live_board'],
|
|
55
|
+
exampleArgs: { game: 'all', limitPerGame: 10 },
|
|
56
|
+
preferOver: ['per-game call_api live probes'],
|
|
57
|
+
doNotUse: 'For upcoming fixtures — use upcoming_schedule',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'upcoming_schedule',
|
|
61
|
+
outcome: 'Upcoming matches/events with filters',
|
|
62
|
+
parallelSafe: true,
|
|
63
|
+
games: [...PRIMARY_GAMES],
|
|
64
|
+
jobs: ['schedule'],
|
|
65
|
+
exampleArgs: { game: 'lol', hours: 72, team: 't1' },
|
|
66
|
+
preferOver: ['live_matches for future fixtures'],
|
|
67
|
+
doNotUse: 'For in-progress only — use live_matches',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'match_summary',
|
|
71
|
+
outcome: 'Scoreline + performances + media stubs (match card)',
|
|
72
|
+
parallelSafe: true,
|
|
73
|
+
games: [...PRIMARY_GAMES],
|
|
74
|
+
jobs: ['match_page'],
|
|
75
|
+
exampleArgs: { game: 'cs2', matchId: 'cs2-match-123', view: 'summary' },
|
|
76
|
+
preferOver: ['match_details for chat/default UI'],
|
|
77
|
+
doNotUse: 'Without matchId; pre-match only → match_preview',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'match_details',
|
|
81
|
+
outcome: 'Deep match package (timelines, live state, maps, media)',
|
|
82
|
+
parallelSafe: true,
|
|
83
|
+
games: [...PRIMARY_GAMES],
|
|
84
|
+
jobs: ['match_page'],
|
|
85
|
+
exampleArgs: { game: 'lol', matchId: 'lol-match-1', includeTimeline: true },
|
|
86
|
+
preferOver: ['match_summary only when summary is insufficient'],
|
|
87
|
+
doNotUse: 'First-pass live board — use live_matches + match_summary',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'player_profile',
|
|
91
|
+
outcome: 'Player/fighter identity + recent form',
|
|
92
|
+
parallelSafe: true,
|
|
93
|
+
games: [...PRIMARY_GAMES],
|
|
94
|
+
jobs: ['player_form'],
|
|
95
|
+
exampleArgs: { game: 'cs2', playerId: 'cs2-player-1', recentLimit: 10 },
|
|
96
|
+
preferOver: ['manual multi-call career/trends via call_api'],
|
|
97
|
+
doNotUse: 'Full team roster → team_profile; unresolved name → resolve_entity',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'team_profile',
|
|
101
|
+
outcome: 'Team/org card: identity, roster, recent form',
|
|
102
|
+
parallelSafe: true,
|
|
103
|
+
games: ['lol', 'cs2', 'dota2', 'cod'],
|
|
104
|
+
jobs: ['team_page'],
|
|
105
|
+
exampleArgs: { game: 'lol', slug: 't1' },
|
|
106
|
+
preferOver: ['separate roster + matches via call_api'],
|
|
107
|
+
doNotUse: 'UFC fighters → player_profile; unknown name → resolve_entity',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'head_to_head',
|
|
111
|
+
outcome: 'Composed H2H record between two teams or fighters',
|
|
112
|
+
parallelSafe: true,
|
|
113
|
+
games: [...PRIMARY_GAMES],
|
|
114
|
+
jobs: ['h2h', 'preview'],
|
|
115
|
+
exampleArgs: { game: 'cs2', sideA: 'faze', sideB: 'navi', limit: 20 },
|
|
116
|
+
preferOver: ['agent-side double match-list filtering'],
|
|
117
|
+
doNotUse: 'Single-side form only → team_profile or player_profile',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'standings',
|
|
121
|
+
outcome: 'League/event standings or world/division rankings',
|
|
122
|
+
parallelSafe: true,
|
|
123
|
+
games: [...PRIMARY_GAMES],
|
|
124
|
+
jobs: ['standings'],
|
|
125
|
+
exampleArgs: { game: 'cod', season: '2026' },
|
|
126
|
+
preferOver: ['raw standings via call_api'],
|
|
127
|
+
doNotUse: 'Single team form → team_profile; live scores → live_matches',
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'match_preview',
|
|
131
|
+
outcome: 'Pre-match briefing: sides, rosters/form, H2H stub',
|
|
132
|
+
parallelSafe: true,
|
|
133
|
+
games: [...PRIMARY_GAMES],
|
|
134
|
+
jobs: ['preview', 'match_page', 'app_scaffold'],
|
|
135
|
+
exampleArgs: { game: 'lol', teamA: 't1', teamB: 'gen-g', includeH2H: true },
|
|
136
|
+
preferOver: ['manual team_profile ×2 + head_to_head'],
|
|
137
|
+
doNotUse: 'Completed match recaps → match_summary',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'event_card',
|
|
141
|
+
outcome: 'Event / fight-night card: identity + bout/match list (+ optional standings)',
|
|
142
|
+
parallelSafe: true,
|
|
143
|
+
games: [...PRIMARY_GAMES],
|
|
144
|
+
jobs: ['event_card', 'schedule', 'app_scaffold'],
|
|
145
|
+
exampleArgs: { game: 'ufc', eventIdOrSlug: 'ufc-300', includeBouts: true },
|
|
146
|
+
preferOver: ['call_api /ufc/events + bout expansion', 'N+1 match_summary for card list'],
|
|
147
|
+
doNotUse: 'Live-only strip → live_matches; single match recap → match_summary',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'call_api',
|
|
151
|
+
outcome: 'Allowlisted raw REST escape hatch (unshaped data.raw)',
|
|
152
|
+
parallelSafe: true,
|
|
153
|
+
games: [...PRIMARY_GAMES, 'fortnite'],
|
|
154
|
+
jobs: ['app_scaffold'],
|
|
155
|
+
exampleArgs: { method: 'GET', path: '/cs2/rankings/teams', queryJson: '{"limit":20}' },
|
|
156
|
+
preferOver: [],
|
|
157
|
+
doNotUse: 'Any job covered by a curated tool',
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
const JOBS = [
|
|
161
|
+
{ id: 'live_board', description: 'Ops/dashboard live strip', recommendedTools: ['live_matches', 'match_summary'] },
|
|
162
|
+
{ id: 'match_page', description: 'Match center / recap', recommendedTools: ['match_summary', 'match_details', 'match_preview'] },
|
|
163
|
+
{ id: 'team_page', description: 'Team/org profile screen', recommendedTools: ['resolve_entity', 'team_profile'] },
|
|
164
|
+
{ id: 'player_form', description: 'Player/fighter form card', recommendedTools: ['resolve_entity', 'player_profile'] },
|
|
165
|
+
{ id: 'standings', description: 'Tables and rankings', recommendedTools: ['standings'] },
|
|
166
|
+
{ id: 'h2h', description: 'Historical rivalry record', recommendedTools: ['head_to_head'] },
|
|
167
|
+
{ id: 'schedule', description: 'Upcoming fixtures/events', recommendedTools: ['upcoming_schedule', 'event_card'] },
|
|
168
|
+
{ id: 'preview', description: 'Pre-match briefing', recommendedTools: ['match_preview'] },
|
|
169
|
+
{ id: 'event_card', description: 'Event / fight-night card page', recommendedTools: ['event_card', 'resolve_entity', 'match_preview'] },
|
|
170
|
+
{ id: 'app_scaffold', description: 'Design-time multi-screen prototype', recommendedTools: ['list_capabilities', 'api_health', 'live_matches'] },
|
|
171
|
+
];
|
|
172
|
+
const RECIPES = [
|
|
173
|
+
{
|
|
174
|
+
id: 'live_ops_board',
|
|
175
|
+
steps: ['api_health (optional)', 'live_matches', 'match_summary or match_details for selected matchId'],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'team_page',
|
|
179
|
+
steps: ['resolve_entity { q, game, type: "team" }', 'team_profile with returned id/slug'],
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'match_center_completed',
|
|
183
|
+
steps: ['upcoming_schedule or live_matches for matchId', 'match_summary', 'optional match_details'],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'ufc_fight_night_card',
|
|
187
|
+
steps: [
|
|
188
|
+
'resolve_entity { game: "ufc", type: "event", q } OR pass known eventIdOrSlug',
|
|
189
|
+
'event_card { game: "ufc", eventIdOrSlug, includeBouts: true }',
|
|
190
|
+
'optional match_preview for featured bout; standings { game: "ufc", scope: "division" }',
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: 'player_form_lately',
|
|
195
|
+
steps: ['resolve_entity { type: "player"|"fighter", q, game }', 'player_profile with playerId|slug, includeTrends: true'],
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'match_preview_sides',
|
|
199
|
+
steps: [
|
|
200
|
+
'resolve_entity for each side (or use known slugs)',
|
|
201
|
+
'match_preview { game, teamA, teamB, includeH2H: true }',
|
|
202
|
+
'optional head_to_head for deeper rivalry list',
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'app_scaffold',
|
|
207
|
+
steps: [
|
|
208
|
+
'list_capabilities + api_health (parallel)',
|
|
209
|
+
'Sample: live_matches, team_profile, standings, event_card',
|
|
210
|
+
'Production: typed REST client; MCP not in request path',
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
const GAMES_META = [
|
|
215
|
+
{ id: 'lol', label: 'League of Legends', depth: 'deep', notes: 'Slug teams; matchId lol-match-*; no native search' },
|
|
216
|
+
{ id: 'cs2', label: 'Counter-Strike 2', depth: 'deep', notes: 'Prefixed ids; live path /cs2/live; trends/rankings' },
|
|
217
|
+
{ id: 'dota2', label: 'Dota 2', depth: 'thin', notes: 'Numeric ids; radar; no standings/roster endpoints' },
|
|
218
|
+
{ id: 'cod', label: 'Call of Duty (CDL)', depth: 'medium', notes: 'Org slugs; CDL standings; UUID match ids' },
|
|
219
|
+
{ id: 'ufc', label: 'UFC / MMA', depth: 'medium', notes: 'Fighter slugs; boutId; live + rankings' },
|
|
220
|
+
];
|
|
221
|
+
export const listCapabilities = {
|
|
222
|
+
name: 'list_capabilities',
|
|
223
|
+
description: `Curated catalog of cito-mcp tools, games, jobs, and builder recipes.
|
|
224
|
+
|
|
225
|
+
When to use:
|
|
226
|
+
- Session start or "what can you do?"
|
|
227
|
+
- Mapping app screens to tools
|
|
228
|
+
- Filtering by game or job (live_board, match_page, team_page, player_form, standings, h2h, schedule, preview, event_card, app_scaffold)
|
|
229
|
+
|
|
230
|
+
Prefer over: guessing from memory; exploring raw OpenAPI via call_api.
|
|
231
|
+
|
|
232
|
+
Do not use when: you already know the tool and have IDs — call that tool directly.
|
|
233
|
+
|
|
234
|
+
Parallel-safe: yes. Upstream cost: 0.
|
|
235
|
+
Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
|
|
236
|
+
inputSchema: {
|
|
237
|
+
type: 'object',
|
|
238
|
+
additionalProperties: false,
|
|
239
|
+
properties: {
|
|
240
|
+
game: gameSchema({ allowAll: false, description: 'Filter catalog to one primary game; omit for all.' }),
|
|
241
|
+
job: {
|
|
242
|
+
type: 'string',
|
|
243
|
+
enum: JOBS.map((j) => j.id),
|
|
244
|
+
description: 'Filter by agent/builder job. Example: "team_page".',
|
|
245
|
+
},
|
|
246
|
+
q: stringSchema('Free-text filter over tool names and outcomes.', 'live'),
|
|
247
|
+
includeExamples: boolSchema('Include exampleArgs on each tool.', true),
|
|
248
|
+
includeRecipes: boolSchema('Include multi-step recipes.', true),
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
handler: async (args) => {
|
|
252
|
+
const started = Date.now();
|
|
253
|
+
const requestId = newRequestId();
|
|
254
|
+
const gameParse = parseGame(args.game, { allowAll: false });
|
|
255
|
+
if (gameParse.error && args.game != null && args.game !== '') {
|
|
256
|
+
return errorEnvelope({
|
|
257
|
+
code: 'UNSUPPORTED_GAME',
|
|
258
|
+
message: gameParse.error,
|
|
259
|
+
game: null,
|
|
260
|
+
source: 'list_capabilities',
|
|
261
|
+
requestId,
|
|
262
|
+
tookMs: Date.now() - started,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const job = typeof args.job === 'string' ? args.job : undefined;
|
|
266
|
+
const q = typeof args.q === 'string' ? args.q.toLowerCase() : '';
|
|
267
|
+
const includeExamples = args.includeExamples !== false;
|
|
268
|
+
const includeRecipes = args.includeRecipes !== false;
|
|
269
|
+
let tools = TOOL_CATALOG.map((t) => {
|
|
270
|
+
const row = {
|
|
271
|
+
name: t.name,
|
|
272
|
+
outcome: t.outcome,
|
|
273
|
+
parallelSafe: t.parallelSafe,
|
|
274
|
+
games: t.games,
|
|
275
|
+
preferOver: t.preferOver,
|
|
276
|
+
doNotUse: t.doNotUse,
|
|
277
|
+
};
|
|
278
|
+
if (includeExamples)
|
|
279
|
+
row.exampleArgs = t.exampleArgs;
|
|
280
|
+
return row;
|
|
281
|
+
});
|
|
282
|
+
if (gameParse.game) {
|
|
283
|
+
tools = tools.filter((t) => {
|
|
284
|
+
const games = t.games;
|
|
285
|
+
return games.includes(gameParse.game) || games.includes('fortnite');
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (job) {
|
|
289
|
+
const jobDef = JOBS.find((j) => j.id === job);
|
|
290
|
+
if (jobDef) {
|
|
291
|
+
const set = new Set(jobDef.recommendedTools);
|
|
292
|
+
tools = tools.filter((t) => set.has(t.name) || t.name === 'list_capabilities');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (q) {
|
|
296
|
+
tools = tools.filter((t) => String(t.name).includes(q) ||
|
|
297
|
+
String(t.outcome).toLowerCase().includes(q));
|
|
298
|
+
}
|
|
299
|
+
return successEnvelope({
|
|
300
|
+
source: 'list_capabilities',
|
|
301
|
+
game: gameParse.game ?? null,
|
|
302
|
+
requestId,
|
|
303
|
+
tookMs: Date.now() - started,
|
|
304
|
+
upstreamCalls: 0,
|
|
305
|
+
data: {
|
|
306
|
+
version: CATALOG_VERSION,
|
|
307
|
+
tools,
|
|
308
|
+
games: GAMES_META.filter((g) => !gameParse.game || g.id === gameParse.game),
|
|
309
|
+
jobs: job ? JOBS.filter((j) => j.id === job) : JOBS,
|
|
310
|
+
...(includeRecipes ? { recipes: RECIPES } : {}),
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
export const apiHealth = {
|
|
316
|
+
name: 'api_health',
|
|
317
|
+
description: `API reachability, API key validity, plan tier, rate-limit headers, and best-effort included games.
|
|
318
|
+
|
|
319
|
+
When to use:
|
|
320
|
+
- Once per session before heavy work
|
|
321
|
+
- After 401/403/UNSUPPORTED_GAME/RATE_LIMIT
|
|
322
|
+
- App scaffolding entitlement checks
|
|
323
|
+
|
|
324
|
+
Prefer over: probing random game endpoints to test the key.
|
|
325
|
+
|
|
326
|
+
Do not use when: you only need live scores — use live_matches.
|
|
327
|
+
|
|
328
|
+
Parallel-safe: yes. Upstream cost: 1–6.
|
|
329
|
+
Example: { "includeGameProbes": true }`,
|
|
330
|
+
inputSchema: {
|
|
331
|
+
type: 'object',
|
|
332
|
+
additionalProperties: false,
|
|
333
|
+
properties: {
|
|
334
|
+
includeGameProbes: boolSchema('If true, light allSettled probes per primary game product/status path.', false),
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
handler: async (args, ctx) => {
|
|
338
|
+
const started = Date.now();
|
|
339
|
+
const requestId = newRequestId();
|
|
340
|
+
let upstreamCalls = 0;
|
|
341
|
+
let rateLimit = {};
|
|
342
|
+
const health = await fetchJson(ctx, '/health');
|
|
343
|
+
upstreamCalls += 1;
|
|
344
|
+
rateLimit = health.headers;
|
|
345
|
+
let keyValid = false;
|
|
346
|
+
let reachable = health.ok;
|
|
347
|
+
let answeredBy = '/health';
|
|
348
|
+
let authedStatus = health.status;
|
|
349
|
+
if (!health.ok) {
|
|
350
|
+
const fallback = await fetchJson(ctx, '/lol/leagues');
|
|
351
|
+
upstreamCalls += 1;
|
|
352
|
+
rateLimit = fallback.headers;
|
|
353
|
+
if (fallback.ok) {
|
|
354
|
+
keyValid = true;
|
|
355
|
+
reachable = true;
|
|
356
|
+
answeredBy = '/lol/leagues';
|
|
357
|
+
authedStatus = fallback.status;
|
|
358
|
+
}
|
|
359
|
+
else if (fallback.status === 401 || fallback.status === 403) {
|
|
360
|
+
return errorEnvelope({
|
|
361
|
+
code: mapHttpToCode(fallback.status),
|
|
362
|
+
message: 'API key invalid or unauthorized',
|
|
363
|
+
game: null,
|
|
364
|
+
source: 'api_health',
|
|
365
|
+
requestId,
|
|
366
|
+
tookMs: Date.now() - started,
|
|
367
|
+
upstreamCalls,
|
|
368
|
+
rateLimit: fallback.headers,
|
|
369
|
+
httpStatus: fallback.status,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
// try product root
|
|
374
|
+
const cs2 = await fetchJson(ctx, '/cs2');
|
|
375
|
+
upstreamCalls += 1;
|
|
376
|
+
rateLimit = cs2.headers;
|
|
377
|
+
if (cs2.ok) {
|
|
378
|
+
keyValid = true;
|
|
379
|
+
reachable = true;
|
|
380
|
+
answeredBy = '/cs2';
|
|
381
|
+
authedStatus = cs2.status;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
keyValid = false;
|
|
385
|
+
authedStatus = cs2.status;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
// health may be unauthenticated — confirm key with light authed probe
|
|
391
|
+
const probe = await fetchJson(ctx, '/lol/leagues');
|
|
392
|
+
upstreamCalls += 1;
|
|
393
|
+
rateLimit = { ...rateLimit, ...probe.headers };
|
|
394
|
+
keyValid = probe.ok || probe.status === 403; // 403 may still mean key accepted but gated
|
|
395
|
+
if (probe.ok)
|
|
396
|
+
answeredBy = '/health+/lol/leagues';
|
|
397
|
+
if (probe.status === 401) {
|
|
398
|
+
keyValid = false;
|
|
399
|
+
}
|
|
400
|
+
authedStatus = probe.ok ? probe.status : health.status;
|
|
401
|
+
}
|
|
402
|
+
const includedGames = [];
|
|
403
|
+
const probes = [];
|
|
404
|
+
if (args.includeGameProbes === true) {
|
|
405
|
+
const paths = [
|
|
406
|
+
{ game: 'lol', path: '/lol/status' },
|
|
407
|
+
{ game: 'cs2', path: '/cs2' },
|
|
408
|
+
{ game: 'dota2', path: '/dota2' },
|
|
409
|
+
{ game: 'cod', path: '/cod' },
|
|
410
|
+
{ game: 'ufc', path: '/ufc/live/health' },
|
|
411
|
+
];
|
|
412
|
+
const results = await Promise.all(paths.map(async ({ game, path }) => {
|
|
413
|
+
const res = await fetchJson(ctx, path);
|
|
414
|
+
return { game, path, res };
|
|
415
|
+
}));
|
|
416
|
+
upstreamCalls += results.length;
|
|
417
|
+
for (const { game, res } of results) {
|
|
418
|
+
probes.push({ game, ok: res.ok, httpStatus: res.status });
|
|
419
|
+
if (res.ok)
|
|
420
|
+
includedGames.push(game);
|
|
421
|
+
else if (res.status === 403 && gameNotIncludedHint(res.data)) {
|
|
422
|
+
// not included
|
|
423
|
+
}
|
|
424
|
+
else if (res.status !== 401 && res.status !== 404) {
|
|
425
|
+
// ambiguous — still list as probed
|
|
426
|
+
}
|
|
427
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else if (keyValid) {
|
|
431
|
+
includedGames.push(...PRIMARY_GAMES);
|
|
432
|
+
}
|
|
433
|
+
return successEnvelope({
|
|
434
|
+
source: 'api_health',
|
|
435
|
+
game: null,
|
|
436
|
+
requestId,
|
|
437
|
+
tookMs: Date.now() - started,
|
|
438
|
+
upstreamCalls,
|
|
439
|
+
rateLimit,
|
|
440
|
+
data: {
|
|
441
|
+
reachable,
|
|
442
|
+
keyValid,
|
|
443
|
+
answeredBy,
|
|
444
|
+
httpStatus: authedStatus,
|
|
445
|
+
tier: rateLimit.tier ?? null,
|
|
446
|
+
rateLimit,
|
|
447
|
+
includedGames,
|
|
448
|
+
...(probes.length ? { probes } : {}),
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
const ALLOWLIST_PREFIXES = ['/health', '/lol', '/cs2', '/dota2', '/cod', '/ufc', '/fortnite'];
|
|
454
|
+
function pathAllowed(path) {
|
|
455
|
+
if (!path.startsWith('/') || path.startsWith('//'))
|
|
456
|
+
return false;
|
|
457
|
+
if (path.includes('://') || path.includes('..'))
|
|
458
|
+
return false;
|
|
459
|
+
const normalized = path.split('?')[0].replace(/\/+$/, '') || '/';
|
|
460
|
+
return ALLOWLIST_PREFIXES.some((p) => normalized === p || normalized.startsWith(`${p}/`));
|
|
461
|
+
}
|
|
462
|
+
export const callApi = {
|
|
463
|
+
name: 'call_api',
|
|
464
|
+
description: `Power escape hatch: allowlisted Cito REST call with unshaped raw JSON in data.raw.
|
|
465
|
+
|
|
466
|
+
When to use:
|
|
467
|
+
- Long-tail paths not yet curated (Fortnite, CS2 leaderboards, niche stats)
|
|
468
|
+
- Debugging payloads while building an app
|
|
469
|
+
- User explicitly knows an OpenAPI path
|
|
470
|
+
|
|
471
|
+
Prefer curated tools for all standard jobs (live, schedule, profiles, standings, H2H, previews).
|
|
472
|
+
|
|
473
|
+
Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
|
|
474
|
+
|
|
475
|
+
Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite.
|
|
476
|
+
Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
|
|
477
|
+
|
|
478
|
+
Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
|
|
479
|
+
Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"page\\":1,\\"limit\\":20}" }`,
|
|
480
|
+
inputSchema: {
|
|
481
|
+
type: 'object',
|
|
482
|
+
additionalProperties: false,
|
|
483
|
+
required: ['path'],
|
|
484
|
+
properties: {
|
|
485
|
+
method: {
|
|
486
|
+
type: 'string',
|
|
487
|
+
enum: ['GET', 'POST'],
|
|
488
|
+
default: 'GET',
|
|
489
|
+
description: 'HTTP method. Prefer GET. Example: "GET".',
|
|
490
|
+
},
|
|
491
|
+
path: stringSchema('Path starting with /. Allowlisted prefixes only. Example: "/cs2/rankings/teams".', '/cs2/rankings/teams'),
|
|
492
|
+
queryJson: {
|
|
493
|
+
type: 'string',
|
|
494
|
+
default: '{}',
|
|
495
|
+
description: 'Stringified JSON object of query params. Example: "{\\"page\\":1,\\"limit\\":20}".',
|
|
496
|
+
},
|
|
497
|
+
bodyJson: {
|
|
498
|
+
type: 'string',
|
|
499
|
+
description: 'Stringified JSON body for POST only (rare).',
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
handler: async (args, ctx) => {
|
|
504
|
+
const started = Date.now();
|
|
505
|
+
const requestId = newRequestId();
|
|
506
|
+
const method = String(args.method ?? 'GET').toUpperCase();
|
|
507
|
+
const path = typeof args.path === 'string' ? args.path.trim() : '';
|
|
508
|
+
if (!path) {
|
|
509
|
+
return errorEnvelope({
|
|
510
|
+
code: 'VALIDATION',
|
|
511
|
+
message: 'path is required',
|
|
512
|
+
game: null,
|
|
513
|
+
source: 'call_api',
|
|
514
|
+
requestId,
|
|
515
|
+
tookMs: Date.now() - started,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
if (method !== 'GET' && method !== 'POST') {
|
|
519
|
+
return errorEnvelope({
|
|
520
|
+
code: 'VALIDATION',
|
|
521
|
+
message: 'method must be GET or POST',
|
|
522
|
+
game: null,
|
|
523
|
+
source: 'call_api',
|
|
524
|
+
requestId,
|
|
525
|
+
tookMs: Date.now() - started,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
if (!pathAllowed(path)) {
|
|
529
|
+
return errorEnvelope({
|
|
530
|
+
code: 'PATH_NOT_ALLOWED',
|
|
531
|
+
message: `Path not allowlisted: ${path}`,
|
|
532
|
+
game: null,
|
|
533
|
+
source: 'call_api',
|
|
534
|
+
requestId,
|
|
535
|
+
tookMs: Date.now() - started,
|
|
536
|
+
hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
let query = {};
|
|
540
|
+
if (typeof args.queryJson === 'string' && args.queryJson.trim()) {
|
|
541
|
+
try {
|
|
542
|
+
const parsed = JSON.parse(args.queryJson);
|
|
543
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
544
|
+
throw new Error('queryJson must be a JSON object');
|
|
545
|
+
}
|
|
546
|
+
query = parsed;
|
|
547
|
+
}
|
|
548
|
+
catch (e) {
|
|
549
|
+
return errorEnvelope({
|
|
550
|
+
code: 'VALIDATION',
|
|
551
|
+
message: `Invalid queryJson: ${e.message}`,
|
|
552
|
+
game: null,
|
|
553
|
+
source: 'call_api',
|
|
554
|
+
requestId,
|
|
555
|
+
tookMs: Date.now() - started,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
let body;
|
|
560
|
+
if (method === 'POST' && typeof args.bodyJson === 'string' && args.bodyJson.trim()) {
|
|
561
|
+
try {
|
|
562
|
+
body = JSON.parse(args.bodyJson);
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
565
|
+
return errorEnvelope({
|
|
566
|
+
code: 'VALIDATION',
|
|
567
|
+
message: `Invalid bodyJson: ${e.message}`,
|
|
568
|
+
game: null,
|
|
569
|
+
source: 'call_api',
|
|
570
|
+
requestId,
|
|
571
|
+
tookMs: Date.now() - started,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const res = await fetchJson(ctx, path, { method, query, body });
|
|
576
|
+
if (!res.ok) {
|
|
577
|
+
return errorEnvelope({
|
|
578
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
579
|
+
message: `Upstream HTTP ${res.status} for ${method} ${path}`,
|
|
580
|
+
game: null,
|
|
581
|
+
source: 'call_api',
|
|
582
|
+
requestId,
|
|
583
|
+
tookMs: Date.now() - started,
|
|
584
|
+
upstreamCalls: 1,
|
|
585
|
+
rateLimit: res.headers,
|
|
586
|
+
httpStatus: res.status,
|
|
587
|
+
details: { path, method, bodyPreview: present(res.text, 2000) },
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
return successEnvelope({
|
|
591
|
+
source: 'call_api',
|
|
592
|
+
game: null,
|
|
593
|
+
requestId,
|
|
594
|
+
tookMs: Date.now() - started,
|
|
595
|
+
upstreamCalls: 1,
|
|
596
|
+
rateLimit: res.headers,
|
|
597
|
+
warnings: ['Unshaped raw response — prefer curated tools when available'],
|
|
598
|
+
data: {
|
|
599
|
+
raw: res.data,
|
|
600
|
+
httpStatus: res.status,
|
|
601
|
+
path,
|
|
602
|
+
method,
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
export const metaTools = [listCapabilities, apiHealth, callApi];
|
|
608
|
+
// silence unused import in case extractRows needed later
|
|
609
|
+
void extractRows;
|