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,518 @@
1
+ /**
2
+ * resolve_entity + search_entities
3
+ */
4
+ import { clampInt, decodeCursor, encodeCursor, extractRows, fetchJson, gameNotIncludedHint, pickString, asRecord, } from '../client.js';
5
+ import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
+ import { entityRef, rankScore } from './normalize.js';
7
+ import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
8
+ function pushCandidates(out, game, type, rows, q, limit) {
9
+ for (const row of rows) {
10
+ const ref = entityRef(row, type, game);
11
+ const score = rankScore(q, ref.name, ref.id, ref.slug);
12
+ if (q && score <= 0 && type !== 'any') {
13
+ // still include search API hits even if local rank is weak
14
+ }
15
+ const r = asRecord(row) ?? {};
16
+ const secondary = {};
17
+ for (const key of ['lolPlayerId', 'codPlayerId', 'matchId', 'boutId', 'tournamentId', 'eventId']) {
18
+ const v = pickString(r[key]);
19
+ if (v)
20
+ secondary[key] = v;
21
+ }
22
+ out.push({
23
+ game,
24
+ type: pickString(r.entity_type, r.type, type) ?? type,
25
+ id: ref.id,
26
+ slug: ref.slug,
27
+ name: ref.name,
28
+ score: q ? (score || 10) : 10,
29
+ ...(Object.keys(secondary).length ? { secondaryIds: secondary } : {}),
30
+ });
31
+ }
32
+ out.sort((a, b) => b.score - a.score);
33
+ if (out.length > limit * 3)
34
+ out.length = limit * 3;
35
+ }
36
+ async function searchGame(ctx, game, q, type, limit) {
37
+ const candidates = [];
38
+ let calls = 0;
39
+ try {
40
+ if (game === 'cs2') {
41
+ const res = await fetchJson(ctx, '/cs2/search', { query: { q, limit } });
42
+ calls += 1;
43
+ if (!res.ok) {
44
+ return {
45
+ candidates,
46
+ calls,
47
+ error: partialFromRejection(`game:${game}`, {
48
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
49
+ message: `cs2 search HTTP ${res.status}`,
50
+ httpStatus: res.status,
51
+ }),
52
+ };
53
+ }
54
+ const data = asRecord(res.data) ?? {};
55
+ const buckets = [
56
+ ['team', data.teams],
57
+ ['player', data.players],
58
+ ['event', data.events],
59
+ ['match', data.matches],
60
+ ['tournament', data.tournaments],
61
+ ];
62
+ for (const [t, bucket] of buckets) {
63
+ if (type !== 'any' && type !== t)
64
+ continue;
65
+ const rows = Array.isArray(bucket) ? bucket : extractRows(bucket);
66
+ pushCandidates(candidates, game, t, rows, q, limit);
67
+ }
68
+ if (candidates.length === 0) {
69
+ pushCandidates(candidates, game, type === 'any' ? 'unknown' : type, extractRows(res.data), q, limit);
70
+ }
71
+ }
72
+ else if (game === 'dota2') {
73
+ const res = await fetchJson(ctx, '/dota2/search', { query: { q, limit } });
74
+ calls += 1;
75
+ if (!res.ok) {
76
+ return {
77
+ candidates,
78
+ calls,
79
+ error: partialFromRejection(`game:${game}`, {
80
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
81
+ message: `dota2 search HTTP ${res.status}`,
82
+ httpStatus: res.status,
83
+ }),
84
+ };
85
+ }
86
+ const data = asRecord(res.data) ?? {};
87
+ for (const [t, key] of [
88
+ ['team', 'teams'],
89
+ ['player', 'players'],
90
+ ['tournament', 'tournaments'],
91
+ ['match', 'matches'],
92
+ ]) {
93
+ if (type !== 'any' && type !== t)
94
+ continue;
95
+ pushCandidates(candidates, game, t, extractRows(data[key] ?? data), q, limit);
96
+ }
97
+ if (candidates.length === 0) {
98
+ pushCandidates(candidates, game, type === 'any' ? 'unknown' : type, extractRows(res.data), q, limit);
99
+ }
100
+ }
101
+ else if (game === 'cod') {
102
+ const query = { q, limit };
103
+ if (type !== 'any')
104
+ query.type = type === 'team' ? 'org' : type;
105
+ const res = await fetchJson(ctx, '/cod/search', { query });
106
+ calls += 1;
107
+ if (!res.ok) {
108
+ return {
109
+ candidates,
110
+ calls,
111
+ error: partialFromRejection(`game:${game}`, {
112
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
113
+ message: `cod search HTTP ${res.status}`,
114
+ httpStatus: res.status,
115
+ }),
116
+ };
117
+ }
118
+ pushCandidates(candidates, game, type === 'any' ? 'unknown' : type, extractRows(res.data), q, limit);
119
+ }
120
+ else if (game === 'lol') {
121
+ const tasks = [];
122
+ if (type === 'any' || type === 'team') {
123
+ tasks.push((async () => {
124
+ const res = await fetchJson(ctx, '/lol/teams', { query: { search: q, limit: String(limit) } });
125
+ calls += 1;
126
+ if (res.ok)
127
+ pushCandidates(candidates, game, 'team', extractRows(res.data), q, limit);
128
+ })());
129
+ }
130
+ if (type === 'any' || type === 'player') {
131
+ tasks.push((async () => {
132
+ const res = await fetchJson(ctx, '/lol/players', { query: { search: q, limit: String(limit) } });
133
+ calls += 1;
134
+ if (res.ok)
135
+ pushCandidates(candidates, game, 'player', extractRows(res.data), q, limit);
136
+ })());
137
+ }
138
+ if (type === 'any' || type === 'tournament') {
139
+ tasks.push((async () => {
140
+ const res = await fetchJson(ctx, '/lol/tournaments', { query: { limit: String(limit) } });
141
+ calls += 1;
142
+ if (res.ok) {
143
+ const rows = extractRows(res.data).filter((row) => {
144
+ const name = pickString(asRecord(row)?.name, asRecord(row)?.slug) ?? '';
145
+ return !q || rankScore(q, name) > 0;
146
+ });
147
+ pushCandidates(candidates, game, 'tournament', rows, q, limit);
148
+ }
149
+ })());
150
+ }
151
+ if (type === 'any' || type === 'event' || type === 'league') {
152
+ tasks.push((async () => {
153
+ const res = await fetchJson(ctx, '/lol/leagues', {});
154
+ calls += 1;
155
+ if (res.ok) {
156
+ const rows = extractRows(res.data).filter((row) => {
157
+ const name = pickString(asRecord(row)?.name, asRecord(row)?.slug) ?? '';
158
+ return !q || rankScore(q, name) > 0;
159
+ });
160
+ pushCandidates(candidates, game, 'league', rows, q, limit);
161
+ }
162
+ })());
163
+ }
164
+ await Promise.all(tasks);
165
+ }
166
+ else if (game === 'ufc') {
167
+ const tasks = [];
168
+ if (type === 'any' || type === 'fighter' || type === 'player') {
169
+ tasks.push((async () => {
170
+ const res = await fetchJson(ctx, '/ufc/fighters', { query: { q, limit, page: 1 } });
171
+ calls += 1;
172
+ if (res.ok)
173
+ pushCandidates(candidates, game, 'fighter', extractRows(res.data), q, limit);
174
+ })());
175
+ }
176
+ if (type === 'any' || type === 'event' || type === 'tournament') {
177
+ tasks.push((async () => {
178
+ const res = await fetchJson(ctx, '/ufc/events', { query: { q, limit, page: 1 } });
179
+ calls += 1;
180
+ if (res.ok)
181
+ pushCandidates(candidates, game, 'event', extractRows(res.data), q, limit);
182
+ })());
183
+ }
184
+ await Promise.all(tasks);
185
+ }
186
+ }
187
+ catch (e) {
188
+ return {
189
+ candidates,
190
+ calls,
191
+ error: partialFromRejection(`game:${game}`, {
192
+ code: 'UPSTREAM',
193
+ message: e.message,
194
+ }),
195
+ };
196
+ }
197
+ candidates.sort((a, b) => b.score - a.score);
198
+ return { candidates: candidates.slice(0, limit), calls };
199
+ }
200
+ export const resolveEntity = {
201
+ name: 'resolve_entity',
202
+ description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
203
+
204
+ When to use:
205
+ - User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
206
+ - Need a canonical id/slug before profile or match tools
207
+
208
+ Prefer over search_entities when you want one best match (or small ranked set) to chain.
209
+ Prefer search_entities when browsing many results with pagination.
210
+
211
+ Do not use when: you already have a stable id/slug from a prior tool.
212
+
213
+ Empty/ambiguous results still return ok:true with best=null or needsDisambiguation=true — pick from candidates or refine q/game/type. Does not emit AMBIGUOUS_ENTITY as a hard error.
214
+
215
+ Parallel-safe: yes. Upstream cost: 1–5.
216
+ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
217
+ inputSchema: {
218
+ type: 'object',
219
+ additionalProperties: false,
220
+ required: ['q'],
221
+ properties: {
222
+ q: stringSchema('Search text (name or tag). Keep short.', 'T1'),
223
+ game: gameSchema({
224
+ allowAll: false,
225
+ description: 'Strongly recommended. If omitted, fans out across primary games and ranks candidates.',
226
+ }),
227
+ type: {
228
+ type: 'string',
229
+ enum: ['player', 'team', 'event', 'tournament', 'match', 'fighter', 'any'],
230
+ default: 'any',
231
+ description: 'Entity type bias. Example: "team".',
232
+ },
233
+ limit: limitSchema({ default: 5, max: 10, description: 'Max ranked candidates (default 5, max 10).' }),
234
+ },
235
+ },
236
+ handler: async (args, ctx) => {
237
+ const started = Date.now();
238
+ const requestId = newRequestId();
239
+ const q = typeof args.q === 'string' ? args.q.trim() : '';
240
+ if (!q) {
241
+ return errorEnvelope({
242
+ code: 'VALIDATION',
243
+ message: 'q is required and must be a non-empty string',
244
+ game: null,
245
+ source: 'resolve_entity',
246
+ requestId,
247
+ tookMs: Date.now() - started,
248
+ });
249
+ }
250
+ const gameParse = parseGame(args.game, { allowAll: false });
251
+ if (gameParse.error && args.game != null && args.game !== '') {
252
+ return errorEnvelope({
253
+ code: 'UNSUPPORTED_GAME',
254
+ message: gameParse.error,
255
+ game: null,
256
+ source: 'resolve_entity',
257
+ requestId,
258
+ tookMs: Date.now() - started,
259
+ });
260
+ }
261
+ const type = typeof args.type === 'string' ? args.type : 'any';
262
+ const limit = clampInt(args.limit, 5, 1, 10);
263
+ const games = gameParse.game && isPrimaryGame(gameParse.game)
264
+ ? [gameParse.game]
265
+ : [...PRIMARY_GAMES];
266
+ const partial = [];
267
+ let upstreamCalls = 0;
268
+ const all = [];
269
+ const results = await Promise.all(games.map((g) => searchGame(ctx, g, q, type, limit)));
270
+ for (const r of results) {
271
+ upstreamCalls += r.calls;
272
+ all.push(...r.candidates);
273
+ if (r.error)
274
+ partial.push(r.error);
275
+ }
276
+ all.sort((a, b) => b.score - a.score);
277
+ const candidates = all.slice(0, limit);
278
+ const best = candidates[0] ?? null;
279
+ const needsDisambiguation = !best ||
280
+ (candidates.length > 1 && (candidates[1].score >= best.score - 5 || best.score < 50));
281
+ return successEnvelope({
282
+ source: 'resolve_entity',
283
+ game: best?.game ?? gameParse.game ?? null,
284
+ requestId,
285
+ tookMs: Date.now() - started,
286
+ upstreamCalls,
287
+ partial: partial.length ? partial : undefined,
288
+ entities: best
289
+ ? {
290
+ games: [best.game],
291
+ ids: {
292
+ ...(best.id ? { id: best.id } : {}),
293
+ ...(best.slug ? { slug: best.slug } : {}),
294
+ },
295
+ }
296
+ : undefined,
297
+ data: {
298
+ query: q,
299
+ resolvedGame: best?.game ?? null,
300
+ best,
301
+ candidates,
302
+ needsDisambiguation,
303
+ },
304
+ });
305
+ },
306
+ };
307
+ export const searchEntities = {
308
+ name: 'search_entities',
309
+ description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
310
+
311
+ When to use:
312
+ - Typeahead / pickers
313
+ - "List teams matching…"
314
+ - Exploring entities without committing to one ID
315
+
316
+ Prefer over resolve_entity when the user wants a list.
317
+ Prefer resolve_entity when chaining one name into a profile tool.
318
+
319
+ Do not use when: fetching a known entity profile — use team_profile or player_profile.
320
+
321
+ Parallel-safe: yes. Upstream cost: 1–3.
322
+ Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
323
+ inputSchema: {
324
+ type: 'object',
325
+ additionalProperties: false,
326
+ required: ['game'],
327
+ properties: {
328
+ game: gameSchema({ allowAll: false, required: true }),
329
+ q: stringSchema('Optional search text.', 'vitality'),
330
+ type: {
331
+ type: 'string',
332
+ enum: ['team', 'player', 'tournament', 'event', 'fighter', 'league', 'any'],
333
+ default: 'any',
334
+ description: 'Entity type filter. Example: "team".',
335
+ },
336
+ limit: limitSchema({ default: DEFAULT_PAGE_LIMIT, max: MAX_PAGE_LIMIT }),
337
+ cursor: stringSchema('Opaque cursor from pagination.nextCursor only.'),
338
+ activeOnly: boolSchema('Prefer active entities when the API supports it.', true),
339
+ },
340
+ },
341
+ handler: async (args, ctx) => {
342
+ const started = Date.now();
343
+ const requestId = newRequestId();
344
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
345
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
346
+ return errorEnvelope({
347
+ code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
348
+ message: gameParse.error ?? 'game is required',
349
+ game: null,
350
+ source: 'search_entities',
351
+ requestId,
352
+ tookMs: Date.now() - started,
353
+ });
354
+ }
355
+ const game = gameParse.game;
356
+ const q = typeof args.q === 'string' ? args.q.trim() : '';
357
+ const type = typeof args.type === 'string' ? args.type : 'any';
358
+ const limit = clampInt(args.limit, DEFAULT_PAGE_LIMIT, 1, MAX_PAGE_LIMIT);
359
+ const cursor = decodeCursor(typeof args.cursor === 'string' ? args.cursor : null);
360
+ const offset = typeof cursor?.offset === 'number' ? cursor.offset : 0;
361
+ const activeOnly = args.activeOnly !== false;
362
+ let items = [];
363
+ let upstreamCalls = 0;
364
+ let total = null;
365
+ const pageQuery = (extra = {}) => ({
366
+ limit,
367
+ offset,
368
+ page: Math.floor(offset / limit) + 1,
369
+ ...(q ? { q, search: q } : {}),
370
+ ...(activeOnly ? { active: true } : {}),
371
+ ...extra,
372
+ });
373
+ async function listPath(path, entityType, extra = {}) {
374
+ const res = await fetchJson(ctx, path, { query: pageQuery(extra) });
375
+ upstreamCalls += 1;
376
+ if (!res.ok) {
377
+ throw Object.assign(new Error(`HTTP ${res.status}`), {
378
+ status: res.status,
379
+ data: res.data,
380
+ headers: res.headers,
381
+ });
382
+ }
383
+ const rows = extractRows(res.data);
384
+ const obj = asRecord(res.data);
385
+ if (typeof obj?.total === 'number')
386
+ total = obj.total;
387
+ items.push(...rows.map((row) => entityRef(row, entityType, game)));
388
+ return res;
389
+ }
390
+ try {
391
+ if (game === 'lol') {
392
+ if (type === 'team' || type === 'any')
393
+ await listPath('/lol/teams', 'team');
394
+ if (type === 'player' || type === 'any')
395
+ await listPath('/lol/players', 'player');
396
+ if (type === 'tournament' || type === 'any')
397
+ await listPath('/lol/tournaments', 'tournament');
398
+ if (type === 'league' || type === 'any')
399
+ await listPath('/lol/leagues', 'league');
400
+ }
401
+ else if (game === 'cs2') {
402
+ if (q && (type === 'any' || !['team', 'player', 'event'].includes(type))) {
403
+ const res = await fetchJson(ctx, '/cs2/search', { query: { q, limit } });
404
+ upstreamCalls += 1;
405
+ if (res.ok) {
406
+ const data = asRecord(res.data) ?? {};
407
+ if (type === 'any' || type === 'team') {
408
+ items.push(...extractRows(data.teams).map((r) => entityRef(r, 'team', game)));
409
+ }
410
+ if (type === 'any' || type === 'player') {
411
+ items.push(...extractRows(data.players).map((r) => entityRef(r, 'player', game)));
412
+ }
413
+ if (type === 'any' || type === 'event') {
414
+ items.push(...extractRows(data.events).map((r) => entityRef(r, 'event', game)));
415
+ }
416
+ }
417
+ }
418
+ else {
419
+ if (type === 'team' || type === 'any')
420
+ await listPath('/cs2/teams', 'team');
421
+ if (type === 'player' || type === 'any')
422
+ await listPath('/cs2/players', 'player');
423
+ if (type === 'event' || type === 'tournament' || type === 'any')
424
+ await listPath('/cs2/events', 'event');
425
+ }
426
+ }
427
+ else if (game === 'dota2') {
428
+ if (q) {
429
+ const res = await fetchJson(ctx, '/dota2/search', { query: { q, limit } });
430
+ upstreamCalls += 1;
431
+ if (res.ok) {
432
+ const data = asRecord(res.data) ?? {};
433
+ if (type === 'team' || type === 'any')
434
+ items.push(...extractRows(data.teams ?? res.data).map((r) => entityRef(r, 'team', game)));
435
+ if (type === 'player' || type === 'any')
436
+ items.push(...extractRows(data.players).map((r) => entityRef(r, 'player', game)));
437
+ if (type === 'tournament' || type === 'any')
438
+ items.push(...extractRows(data.tournaments).map((r) => entityRef(r, 'tournament', game)));
439
+ }
440
+ }
441
+ else {
442
+ if (type === 'team' || type === 'any')
443
+ await listPath('/dota2/teams', 'team');
444
+ if (type === 'player' || type === 'any')
445
+ await listPath('/dota2/players', 'player');
446
+ if (type === 'tournament' || type === 'any')
447
+ await listPath('/dota2/tournaments', 'tournament');
448
+ }
449
+ }
450
+ else if (game === 'cod') {
451
+ if (q) {
452
+ const res = await fetchJson(ctx, '/cod/search', {
453
+ query: { q, limit, ...(type !== 'any' ? { type: type === 'team' ? 'org' : type } : {}) },
454
+ });
455
+ upstreamCalls += 1;
456
+ if (res.ok)
457
+ items.push(...extractRows(res.data).map((r) => entityRef(r, type === 'any' ? 'unknown' : type, game)));
458
+ }
459
+ else {
460
+ if (type === 'player' || type === 'any')
461
+ await listPath('/cod/players', 'player');
462
+ if (type === 'team' || type === 'any')
463
+ await listPath('/cod/orgs', 'team');
464
+ if (type === 'tournament' || type === 'any')
465
+ await listPath('/cod/tournaments', 'tournament');
466
+ }
467
+ }
468
+ else if (game === 'ufc') {
469
+ if (type === 'fighter' || type === 'player' || type === 'any') {
470
+ await listPath('/ufc/fighters', 'fighter', q ? { q } : {});
471
+ }
472
+ if (type === 'event' || type === 'tournament' || type === 'any') {
473
+ await listPath('/ufc/events', 'event', q ? { q } : {});
474
+ }
475
+ }
476
+ }
477
+ catch (e) {
478
+ return errorEnvelope({
479
+ code: mapHttpToCode(e.status ?? 0, { gameNotIncluded: gameNotIncludedHint(e.data) }),
480
+ message: e.message ?? 'search failed',
481
+ game,
482
+ source: 'search_entities',
483
+ requestId,
484
+ tookMs: Date.now() - started,
485
+ upstreamCalls,
486
+ httpStatus: e.status,
487
+ rateLimit: e.headers,
488
+ });
489
+ }
490
+ // de-dupe by game+type+id
491
+ const seen = new Set();
492
+ items = items.filter((it) => {
493
+ const key = `${it.game}:${it.type}:${it.id}`;
494
+ if (seen.has(key))
495
+ return false;
496
+ seen.add(key);
497
+ return true;
498
+ }).slice(0, limit);
499
+ const hasMore = items.length >= limit;
500
+ return successEnvelope({
501
+ source: 'search_entities',
502
+ game,
503
+ requestId,
504
+ tookMs: Date.now() - started,
505
+ upstreamCalls,
506
+ pagination: {
507
+ limit,
508
+ offset,
509
+ total,
510
+ hasMore,
511
+ nextCursor: hasMore ? encodeCursor({ offset: offset + limit }) : null,
512
+ prevCursor: offset > 0 ? encodeCursor({ offset: Math.max(0, offset - limit) }) : null,
513
+ },
514
+ data: { items },
515
+ });
516
+ },
517
+ };
518
+ export const resolveTools = [resolveEntity, searchEntities];