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.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * standings
3
+ */
4
+ import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
5
+ import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
6
+ import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
7
+ function normalizeStandingRow(row, index) {
8
+ const r = asRecord(row) ?? {};
9
+ const entity = asRecord(r.team) ?? asRecord(r.fighter) ?? asRecord(r.org) ?? r;
10
+ const name = pickString(asRecord(entity)?.name, r.teamName, r.name, r.orgName, r.fighterName, asRecord(entity)?.slug) ?? `row-${index + 1}`;
11
+ // UFC official lists: champion has rank=null + rankText="C"; contenders 1..15.
12
+ // Never fall back to index+1 for null ranks — that produced two "#1" rows (champ + #1).
13
+ const championStatus = pickString(r.championStatus, asRecord(entity)?.championStatus);
14
+ const rankText = pickString(r.rankText);
15
+ const isChampion = championStatus === 'champion' ||
16
+ rankText === 'C' ||
17
+ r.isChampion === true ||
18
+ r.isTitleHolder === true;
19
+ let rank;
20
+ if (isChampion) {
21
+ rank = 'C';
22
+ }
23
+ else if (typeof r.rank === 'number') {
24
+ rank = r.rank;
25
+ }
26
+ else if (typeof r.position === 'number') {
27
+ rank = r.position;
28
+ }
29
+ else if (rankText && /^\d+$/.test(rankText)) {
30
+ rank = Number(rankText);
31
+ }
32
+ else if (r.rank === null && rankText) {
33
+ rank = rankText;
34
+ }
35
+ else {
36
+ rank = index + 1;
37
+ }
38
+ return {
39
+ rank,
40
+ rankText: rankText ?? (typeof rank === 'number' || typeof rank === 'string' ? String(rank) : null),
41
+ championStatus: isChampion ? 'champion' : championStatus === 'interim' ? 'interim' : 'none',
42
+ teamOrFighter: {
43
+ id: pickString(asRecord(entity)?.id, r.teamId, r.fighterId, r.orgId, r.id),
44
+ slug: pickString(asRecord(entity)?.slug, r.orgSlug, r.teamSlug, r.slug, r.fighterSlug),
45
+ name,
46
+ },
47
+ played: r.played ?? r.gamesPlayed ?? r.matches ?? null,
48
+ wins: r.wins ?? r.w ?? null,
49
+ losses: r.losses ?? r.l ?? null,
50
+ draws: r.draws ?? r.ties ?? null,
51
+ points: r.points ?? r.pts ?? r.score ?? null,
52
+ mapDiff: r.mapDiff ?? r.gameDiff ?? r.diff ?? null,
53
+ streak: r.streak ?? null,
54
+ meta: {
55
+ ...(r.division ? { division: r.division } : {}),
56
+ ...(r.normalizedDivision ? { normalizedDivision: r.normalizedDivision } : {}),
57
+ ...(r.region ? { region: r.region } : {}),
58
+ ...(isChampion ? { isChampion: true } : {}),
59
+ },
60
+ };
61
+ }
62
+ export const standings = {
63
+ name: 'standings',
64
+ description: `League/event standings or world/division rankings normalized to ranked rows.
65
+
66
+ When to use:
67
+ - Table / playoff picture
68
+ - UFC rankings; CS2 world or event standings; CDL standings; LoL league/tournament tables
69
+
70
+ Prefer over: raw standings via call_api for agent-normalized rows.
71
+
72
+ Do not use when: single team form → team_profile; live scores → live_matches.
73
+ Dota has no first-class standings (may NOT_IMPLEMENTED or weak worldRanking).
74
+
75
+ Required scope keys by game:
76
+ - lol: leagueId OR tournamentId
77
+ - cs2: omit for world rankings; eventId for event standings
78
+ - cod: optional season/stage
79
+ - ufc: optional division (scope=division)
80
+ - dota2: best-effort worldRanking only
81
+
82
+ Parallel-safe: yes. Upstream cost: 1–2.
83
+ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
84
+ inputSchema: {
85
+ type: 'object',
86
+ additionalProperties: false,
87
+ required: ['game'],
88
+ properties: {
89
+ game: gameSchema({ allowAll: false, required: true }),
90
+ scope: {
91
+ type: 'string',
92
+ enum: ['league', 'tournament', 'event', 'world', 'division'],
93
+ description: 'Interpretation depends on game; defaults sensibly per game.',
94
+ },
95
+ leagueId: stringSchema('LoL league id for league standings.'),
96
+ tournamentId: stringSchema('LoL/COD tournament id.'),
97
+ eventId: stringSchema('CS2 event id for event standings.'),
98
+ season: stringSchema('Season key (COD).'),
99
+ stage: stringSchema('Stage key (COD / LoL).'),
100
+ division: stringSchema('UFC division key.'),
101
+ limit: limitSchema({ default: 50, max: 100 }),
102
+ },
103
+ },
104
+ handler: async (args, ctx) => {
105
+ const started = Date.now();
106
+ const requestId = newRequestId();
107
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
108
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
109
+ return errorEnvelope({
110
+ code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
111
+ message: gameParse.error ?? 'game is required',
112
+ game: null,
113
+ source: 'standings',
114
+ requestId,
115
+ tookMs: Date.now() - started,
116
+ });
117
+ }
118
+ const game = gameParse.game;
119
+ const limit = clampInt(args.limit, 50, 1, 100);
120
+ const scope = typeof args.scope === 'string' ? args.scope : undefined;
121
+ const leagueId = typeof args.leagueId === 'string' ? args.leagueId : undefined;
122
+ const tournamentId = typeof args.tournamentId === 'string' ? args.tournamentId : undefined;
123
+ const eventId = typeof args.eventId === 'string' ? args.eventId : undefined;
124
+ const season = typeof args.season === 'string' ? args.season : undefined;
125
+ const stage = typeof args.stage === 'string' ? args.stage : undefined;
126
+ const division = typeof args.division === 'string' ? args.division : undefined;
127
+ let path = '';
128
+ let query = {};
129
+ let title = `${game} standings`;
130
+ let effectiveScope = scope ?? 'world';
131
+ if (game === 'lol') {
132
+ if (tournamentId || scope === 'tournament') {
133
+ if (!tournamentId) {
134
+ return errorEnvelope({
135
+ code: 'VALIDATION',
136
+ message: 'tournamentId required for LoL tournament standings',
137
+ game,
138
+ source: 'standings',
139
+ requestId,
140
+ tookMs: Date.now() - started,
141
+ });
142
+ }
143
+ path = `/lol/tournaments/${encodeURIComponent(tournamentId)}/standings`;
144
+ effectiveScope = 'tournament';
145
+ title = `LoL tournament ${tournamentId}`;
146
+ }
147
+ else {
148
+ if (!leagueId) {
149
+ return errorEnvelope({
150
+ code: 'VALIDATION',
151
+ message: 'leagueId (or tournamentId) required for LoL standings',
152
+ game,
153
+ source: 'standings',
154
+ requestId,
155
+ tookMs: Date.now() - started,
156
+ recover: [
157
+ 'Call search_entities or resolve_entity for the league',
158
+ 'Retry standings with leagueId or tournamentId',
159
+ ],
160
+ });
161
+ }
162
+ path = `/lol/leagues/${encodeURIComponent(leagueId)}/standings`;
163
+ query = { ...(season ? { season } : {}), ...(stage ? { stage } : {}) };
164
+ effectiveScope = 'league';
165
+ title = `LoL league ${leagueId}`;
166
+ }
167
+ }
168
+ else if (game === 'cs2') {
169
+ if (eventId || scope === 'event') {
170
+ if (!eventId) {
171
+ return errorEnvelope({
172
+ code: 'VALIDATION',
173
+ message: 'eventId required for CS2 event standings',
174
+ game,
175
+ source: 'standings',
176
+ requestId,
177
+ tookMs: Date.now() - started,
178
+ });
179
+ }
180
+ path = `/cs2/events/${encodeURIComponent(eventId)}/standings`;
181
+ effectiveScope = 'event';
182
+ title = `CS2 event ${eventId}`;
183
+ }
184
+ else {
185
+ path = '/cs2/rankings/teams';
186
+ query = { limit };
187
+ effectiveScope = 'world';
188
+ title = 'CS2 world team rankings';
189
+ }
190
+ }
191
+ else if (game === 'cod') {
192
+ path = '/cod/standings';
193
+ query = {
194
+ ...(season ? { season } : {}),
195
+ ...(stage ? { stage } : {}),
196
+ };
197
+ effectiveScope = scope ?? 'league';
198
+ title = 'CDL standings';
199
+ }
200
+ else if (game === 'ufc') {
201
+ if (division || scope === 'division') {
202
+ path = division
203
+ ? `/ufc/rankings/media/${encodeURIComponent(division)}`
204
+ : '/ufc/rankings/media';
205
+ effectiveScope = 'division';
206
+ title = division ? `UFC ${division} rankings` : 'UFC media rankings';
207
+ }
208
+ else {
209
+ path = '/ufc/rankings';
210
+ effectiveScope = 'world';
211
+ title = 'UFC rankings';
212
+ }
213
+ }
214
+ else if (game === 'dota2') {
215
+ // No first-class standings — try team list worldRanking
216
+ const res = await fetchJson(ctx, '/dota2/teams', { query: { limit } });
217
+ if (!res.ok) {
218
+ return errorEnvelope({
219
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
220
+ message: 'Dota standings unavailable',
221
+ game,
222
+ source: 'standings',
223
+ requestId,
224
+ tookMs: Date.now() - started,
225
+ upstreamCalls: 1,
226
+ httpStatus: res.status,
227
+ rateLimit: res.headers,
228
+ });
229
+ }
230
+ const rows = extractRows(res.data)
231
+ .map((row, i) => {
232
+ const r = asRecord(row) ?? {};
233
+ const rank = r.worldRanking ?? r.rank ?? i + 1;
234
+ return normalizeStandingRow({ ...r, rank }, i);
235
+ })
236
+ .filter((row) => row.rank != null)
237
+ .sort((a, b) => Number(a.rank) - Number(b.rank))
238
+ .slice(0, limit);
239
+ if (rows.length === 0) {
240
+ return errorEnvelope({
241
+ code: 'NOT_IMPLEMENTED',
242
+ message: 'Dota 2 has no first-class standings endpoint',
243
+ game,
244
+ source: 'standings',
245
+ requestId,
246
+ tookMs: Date.now() - started,
247
+ upstreamCalls: 1,
248
+ recover: [
249
+ 'Use team_profile with radar for style/form',
250
+ 'Use call_api if a standings path is added later',
251
+ ],
252
+ });
253
+ }
254
+ return successEnvelope({
255
+ source: 'standings',
256
+ game,
257
+ requestId,
258
+ tookMs: Date.now() - started,
259
+ upstreamCalls: 1,
260
+ rateLimit: res.headers,
261
+ warnings: ['Dota standings composed from team worldRanking fields; sparse'],
262
+ data: {
263
+ scope: 'world',
264
+ title: 'Dota 2 world ranking (best-effort)',
265
+ season: null,
266
+ stage: null,
267
+ rows,
268
+ updatedAt: null,
269
+ },
270
+ });
271
+ }
272
+ const res = await fetchJson(ctx, path, { query });
273
+ if (!res.ok) {
274
+ return errorEnvelope({
275
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
276
+ message: `Standings failed (HTTP ${res.status})`,
277
+ game,
278
+ source: 'standings',
279
+ requestId,
280
+ tookMs: Date.now() - started,
281
+ upstreamCalls: 1,
282
+ httpStatus: res.status,
283
+ rateLimit: res.headers,
284
+ });
285
+ }
286
+ let rows = extractRows(res.data).map((row, i) => normalizeStandingRow(row, i)).slice(0, limit);
287
+ // UFC rankings may be nested by division
288
+ if (game === 'ufc' && rows.length === 0) {
289
+ const obj = asRecord(res.data) ?? {};
290
+ const nested = [];
291
+ for (const value of Object.values(obj)) {
292
+ if (Array.isArray(value))
293
+ nested.push(...value);
294
+ else if (asRecord(value) && Array.isArray(asRecord(value).rankings)) {
295
+ nested.push(...asRecord(value).rankings);
296
+ }
297
+ }
298
+ rows = nested.map((row, i) => normalizeStandingRow(row, i)).slice(0, limit);
299
+ }
300
+ const obj = asRecord(res.data);
301
+ return successEnvelope({
302
+ source: 'standings',
303
+ game,
304
+ requestId,
305
+ tookMs: Date.now() - started,
306
+ upstreamCalls: 1,
307
+ rateLimit: res.headers,
308
+ data: {
309
+ scope: effectiveScope,
310
+ title,
311
+ season: season ?? null,
312
+ stage: stage ?? null,
313
+ rows,
314
+ updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated) ?? null,
315
+ },
316
+ });
317
+ },
318
+ };
319
+ export const standingsTools = [standings];