cito-mcp 0.2.4 → 0.2.6
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 +36 -4
- package/dist/client.js +14 -0
- package/dist/index.js +45 -6
- package/dist/instructions.js +1 -0
- package/dist/tools/index.js +3 -2
- package/dist/tools/insight.js +83 -12
- package/dist/tools/live.js +176 -25
- package/dist/tools/match.js +76 -29
- package/dist/tools/meta.js +182 -8
- package/dist/tools/normalize.js +205 -10
- package/dist/tools/player.js +28 -2
- package/dist/tools/resolve.js +66 -37
- package/dist/tools/standings.js +34 -1
- package/dist/tools/team.js +111 -47
- package/dist/tools/types.js +73 -0
- package/dist/version.js +29 -0
- package/package.json +3 -2
package/dist/tools/player.js
CHANGED
|
@@ -29,6 +29,24 @@ function identityFrom(game, raw, idHint, slugHint) {
|
|
|
29
29
|
: null,
|
|
30
30
|
role: pickString(r.role, r.position) ?? null,
|
|
31
31
|
nationality: pickString(r.nationality, r.country) ?? null,
|
|
32
|
+
/**
|
|
33
|
+
* Always present, keys always present, null when the upstream has no photo.
|
|
34
|
+
*
|
|
35
|
+
* REST has carried headshot/body/proxied images on /ufc/fighters/{slug} all
|
|
36
|
+
* along; this shaper silently dropped them, so every agent concluded the
|
|
37
|
+
* product had no images and either shipped faceless UIs or N+1'd call_api
|
|
38
|
+
* per fighter to dig them out. Emitting explicit nulls is the point: silence
|
|
39
|
+
* reads as "not supported", null reads as "not available for this one".
|
|
40
|
+
*
|
|
41
|
+
* proxiedImageUrl is the one builders should prefer — it is served from our
|
|
42
|
+
* own domain, so it works from a browser without hotlink/CORS trouble.
|
|
43
|
+
*/
|
|
44
|
+
images: {
|
|
45
|
+
headshotUrl: pickString(r.headshotUrl, r.headshot) ?? null,
|
|
46
|
+
bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
|
|
47
|
+
imageUrl: pickString(r.imageUrl, r.image, r.photoUrl) ?? null,
|
|
48
|
+
proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
|
|
49
|
+
},
|
|
32
50
|
};
|
|
33
51
|
}
|
|
34
52
|
export const playerProfile = {
|
|
@@ -332,7 +350,14 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
332
350
|
.map((row) => {
|
|
333
351
|
const r = asRecord(row) ?? {};
|
|
334
352
|
const bout = asRecord(r.bout) ?? row;
|
|
335
|
-
|
|
353
|
+
// Never force 'completed'. A fighter's history endpoint also
|
|
354
|
+
// returns bouts that are booked but not yet fought, and forcing
|
|
355
|
+
// the status told agents that a future main event had already
|
|
356
|
+
// happened (Hernandez vs Rodrigues, ufc-12928: status
|
|
357
|
+
// 'confirmed', no method, no winner, event still scheduled,
|
|
358
|
+
// reported as completed with result null). Let normalizeMatch
|
|
359
|
+
// derive it from the result and the start time instead.
|
|
360
|
+
const m = normalizeMatch('ufc', bout);
|
|
336
361
|
return {
|
|
337
362
|
...m,
|
|
338
363
|
result: pickString(r.result, r.outcome, asRecord(bout)?.result) ?? null,
|
|
@@ -354,7 +379,8 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
354
379
|
.map((row) => {
|
|
355
380
|
const r = asRecord(row) ?? {};
|
|
356
381
|
const bout = asRecord(r.bout) ?? row;
|
|
357
|
-
|
|
382
|
+
// Same as above: derive, never assert. See the note there.
|
|
383
|
+
return normalizeMatch('ufc', bout);
|
|
358
384
|
});
|
|
359
385
|
}
|
|
360
386
|
else {
|
package/dist/tools/resolve.js
CHANGED
|
@@ -144,7 +144,9 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
144
144
|
}),
|
|
145
145
|
};
|
|
146
146
|
}
|
|
147
|
-
|
|
147
|
+
// Unwrap the { success, data, meta } envelope before reading buckets.
|
|
148
|
+
const envelope = asRecord(res.data) ?? {};
|
|
149
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
148
150
|
const buckets = [
|
|
149
151
|
['team', data.teams],
|
|
150
152
|
['player', data.players],
|
|
@@ -159,7 +161,15 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
159
161
|
pushCandidates(candidates, game, t, rows, q, limit);
|
|
160
162
|
}
|
|
161
163
|
if (candidates.length === 0) {
|
|
162
|
-
|
|
164
|
+
const fallbackType = type === 'any' ? 'unknown' : type;
|
|
165
|
+
let rows = extractRows(data);
|
|
166
|
+
// extractRows prefers the `matches` bucket; never mislabel those rows as a non-match type.
|
|
167
|
+
if (fallbackType !== 'match' && fallbackType !== 'unknown') {
|
|
168
|
+
const matchRows = extractRows(data.matches);
|
|
169
|
+
if (matchRows.length && rows[0] === matchRows[0])
|
|
170
|
+
rows = [];
|
|
171
|
+
}
|
|
172
|
+
pushCandidates(candidates, game, fallbackType, rows, q, limit);
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
175
|
else if (game === 'dota2') {
|
|
@@ -176,7 +186,9 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
176
186
|
}),
|
|
177
187
|
};
|
|
178
188
|
}
|
|
179
|
-
|
|
189
|
+
// Unwrap the { success, data, meta } envelope before reading buckets.
|
|
190
|
+
const envelope = asRecord(res.data) ?? {};
|
|
191
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
180
192
|
for (const [t, key] of [
|
|
181
193
|
['team', 'teams'],
|
|
182
194
|
['player', 'players'],
|
|
@@ -188,7 +200,15 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
188
200
|
pushCandidates(candidates, game, t, extractRows(data[key] ?? data), q, limit);
|
|
189
201
|
}
|
|
190
202
|
if (candidates.length === 0) {
|
|
191
|
-
|
|
203
|
+
const fallbackType = type === 'any' ? 'unknown' : type;
|
|
204
|
+
let rows = extractRows(data);
|
|
205
|
+
// extractRows prefers the `matches` bucket; never mislabel those rows as a non-match type.
|
|
206
|
+
if (fallbackType !== 'match' && fallbackType !== 'unknown') {
|
|
207
|
+
const matchRows = extractRows(data.matches);
|
|
208
|
+
if (matchRows.length && rows[0] === matchRows[0])
|
|
209
|
+
rows = [];
|
|
210
|
+
}
|
|
211
|
+
pushCandidates(candidates, game, fallbackType, rows, q, limit);
|
|
192
212
|
}
|
|
193
213
|
}
|
|
194
214
|
else if (game === 'cod') {
|
|
@@ -313,20 +333,20 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
313
333
|
}
|
|
314
334
|
export const resolveEntity = {
|
|
315
335
|
name: 'resolve_entity',
|
|
316
|
-
description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
|
|
317
|
-
|
|
318
|
-
When to use:
|
|
319
|
-
- User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
|
|
320
|
-
- Need a canonical id/slug before profile or match tools
|
|
321
|
-
|
|
322
|
-
Prefer over search_entities when you want one best match (or small ranked set) to chain.
|
|
323
|
-
Prefer search_entities when browsing many results with pagination.
|
|
324
|
-
|
|
325
|
-
Do not use when: you already have a stable id/slug from a prior tool.
|
|
326
|
-
|
|
327
|
-
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.
|
|
328
|
-
|
|
329
|
-
Parallel-safe: yes. Upstream cost: 1–5.
|
|
336
|
+
description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
|
|
337
|
+
|
|
338
|
+
When to use:
|
|
339
|
+
- User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
|
|
340
|
+
- Need a canonical id/slug before profile or match tools
|
|
341
|
+
|
|
342
|
+
Prefer over search_entities when you want one best match (or small ranked set) to chain.
|
|
343
|
+
Prefer search_entities when browsing many results with pagination.
|
|
344
|
+
|
|
345
|
+
Do not use when: you already have a stable id/slug from a prior tool.
|
|
346
|
+
|
|
347
|
+
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.
|
|
348
|
+
|
|
349
|
+
Parallel-safe: yes. Upstream cost: 1–5.
|
|
330
350
|
Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
|
|
331
351
|
inputSchema: {
|
|
332
352
|
type: 'object',
|
|
@@ -420,22 +440,22 @@ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
|
|
|
420
440
|
};
|
|
421
441
|
export const searchEntities = {
|
|
422
442
|
name: 'search_entities',
|
|
423
|
-
description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
|
|
424
|
-
|
|
425
|
-
When to use:
|
|
426
|
-
- Typeahead / pickers
|
|
427
|
-
- "List teams matching…"
|
|
428
|
-
- Exploring entities without committing to one ID
|
|
429
|
-
- UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
|
|
430
|
-
|
|
431
|
-
Prefer over resolve_entity when the user wants a list.
|
|
432
|
-
Prefer resolve_entity when chaining one name into a profile tool.
|
|
433
|
-
|
|
434
|
-
Do not use when: fetching a known entity profile — use team_profile or player_profile.
|
|
435
|
-
|
|
436
|
-
UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
|
|
437
|
-
|
|
438
|
-
Parallel-safe: yes. Upstream cost: 1–3.
|
|
443
|
+
description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
|
|
444
|
+
|
|
445
|
+
When to use:
|
|
446
|
+
- Typeahead / pickers
|
|
447
|
+
- "List teams matching…"
|
|
448
|
+
- Exploring entities without committing to one ID
|
|
449
|
+
- UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
|
|
450
|
+
|
|
451
|
+
Prefer over resolve_entity when the user wants a list.
|
|
452
|
+
Prefer resolve_entity when chaining one name into a profile tool.
|
|
453
|
+
|
|
454
|
+
Do not use when: fetching a known entity profile — use team_profile or player_profile.
|
|
455
|
+
|
|
456
|
+
UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
|
|
457
|
+
|
|
458
|
+
Parallel-safe: yes. Upstream cost: 1–3.
|
|
439
459
|
Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
440
460
|
inputSchema: {
|
|
441
461
|
type: 'object',
|
|
@@ -520,7 +540,9 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
520
540
|
const res = await fetchJson(ctx, '/cs2/search', { query: { q, limit } });
|
|
521
541
|
upstreamCalls += 1;
|
|
522
542
|
if (res.ok) {
|
|
523
|
-
|
|
543
|
+
// Unwrap the { success, data, meta } envelope before reading buckets.
|
|
544
|
+
const envelope = asRecord(res.data) ?? {};
|
|
545
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
524
546
|
if (type === 'any' || type === 'team') {
|
|
525
547
|
items.push(...extractRows(data.teams).map((r) => entityRef(r, 'team', game)));
|
|
526
548
|
}
|
|
@@ -535,8 +557,15 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
535
557
|
else {
|
|
536
558
|
if (type === 'team' || type === 'any')
|
|
537
559
|
await listPath('/cs2/teams', 'team');
|
|
538
|
-
if (type === 'player' || type === 'any')
|
|
539
|
-
|
|
560
|
+
if (type === 'player' || type === 'any') {
|
|
561
|
+
if (q) {
|
|
562
|
+
// /cs2/players list filters ignore q/search — use the dedicated search endpoint.
|
|
563
|
+
await listPath('/cs2/players/search', 'player');
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
await listPath('/cs2/players', 'player');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
540
569
|
if (type === 'event' || type === 'tournament' || type === 'any')
|
|
541
570
|
await listPath('/cs2/events', 'event');
|
|
542
571
|
}
|
package/dist/tools/standings.js
CHANGED
|
@@ -321,6 +321,32 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
321
321
|
rows = nested.map((row, i) => normalizeStandingRow(row, i)).slice(0, limit);
|
|
322
322
|
}
|
|
323
323
|
const obj = asRecord(res.data);
|
|
324
|
+
const upstreamMeta = asRecord(obj?.meta) ?? {};
|
|
325
|
+
const dataQuality = asRecord(upstreamMeta.dataQuality) ?? {};
|
|
326
|
+
const warnings = [];
|
|
327
|
+
const pushWarning = (value) => {
|
|
328
|
+
if (typeof value !== 'string')
|
|
329
|
+
return;
|
|
330
|
+
const trimmed = value.trim();
|
|
331
|
+
if (trimmed && !warnings.includes(trimmed))
|
|
332
|
+
warnings.push(trimmed);
|
|
333
|
+
};
|
|
334
|
+
pushWarning(upstreamMeta.warning);
|
|
335
|
+
pushWarning(dataQuality.warning);
|
|
336
|
+
if (Array.isArray(upstreamMeta.warnings)) {
|
|
337
|
+
for (const w of upstreamMeta.warnings)
|
|
338
|
+
pushWarning(w);
|
|
339
|
+
}
|
|
340
|
+
const dataFreshness = pickString(upstreamMeta.dataFreshness, upstreamMeta.freshnessStatus, dataQuality.dataFreshness);
|
|
341
|
+
const status = pickString(upstreamMeta.status, dataQuality.status);
|
|
342
|
+
if (status === 'fallback') {
|
|
343
|
+
pushWarning('Standings served from last-known-good rankings (fallback).');
|
|
344
|
+
}
|
|
345
|
+
if (dataFreshness === 'cached' || upstreamMeta.stale === true) {
|
|
346
|
+
pushWarning(typeof upstreamMeta.dataAgeHours === 'number'
|
|
347
|
+
? `Rankings dataFreshness=cached (ageHours=${upstreamMeta.dataAgeHours}).`
|
|
348
|
+
: 'Rankings dataFreshness=cached or stale.');
|
|
349
|
+
}
|
|
324
350
|
return successEnvelope({
|
|
325
351
|
source: 'standings',
|
|
326
352
|
game,
|
|
@@ -328,13 +354,20 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
328
354
|
tookMs: Date.now() - started,
|
|
329
355
|
upstreamCalls: 1,
|
|
330
356
|
rateLimit: res.headers,
|
|
357
|
+
warnings: warnings.length ? warnings : undefined,
|
|
331
358
|
data: {
|
|
332
359
|
scope: effectiveScope,
|
|
333
360
|
title,
|
|
334
361
|
season: season ?? null,
|
|
335
362
|
stage: stage ?? null,
|
|
336
363
|
rows,
|
|
337
|
-
updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated) ??
|
|
364
|
+
updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt) ??
|
|
365
|
+
null,
|
|
366
|
+
...(dataFreshness ? { dataFreshness } : {}),
|
|
367
|
+
...(status ? { status } : {}),
|
|
368
|
+
...(upstreamMeta.warning || dataQuality.warning
|
|
369
|
+
? { warning: pickString(upstreamMeta.warning, dataQuality.warning) }
|
|
370
|
+
: {}),
|
|
338
371
|
},
|
|
339
372
|
});
|
|
340
373
|
},
|
package/dist/tools/team.js
CHANGED
|
@@ -21,18 +21,18 @@ function teamIdentity(game, raw, idHint, slugHint) {
|
|
|
21
21
|
}
|
|
22
22
|
export const teamProfile = {
|
|
23
23
|
name: 'team_profile',
|
|
24
|
-
description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
|
|
25
|
-
|
|
26
|
-
When to use:
|
|
27
|
-
- Team page / "who is on this roster?"
|
|
28
|
-
- Builder team screen sample
|
|
29
|
-
|
|
30
|
-
Prefer over: separate roster + matches + detail via call_api.
|
|
31
|
-
|
|
32
|
-
Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
|
|
33
|
-
Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
|
|
34
|
-
|
|
35
|
-
Parallel-safe: yes. Upstream cost: 2–4.
|
|
24
|
+
description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
|
|
25
|
+
|
|
26
|
+
When to use:
|
|
27
|
+
- Team page / "who is on this roster?"
|
|
28
|
+
- Builder team screen sample
|
|
29
|
+
|
|
30
|
+
Prefer over: separate roster + matches + detail via call_api.
|
|
31
|
+
|
|
32
|
+
Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
|
|
33
|
+
Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
|
|
34
|
+
|
|
35
|
+
Parallel-safe: yes. Upstream cost: 2–4.
|
|
36
36
|
Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
37
37
|
inputSchema: {
|
|
38
38
|
type: 'object',
|
|
@@ -123,7 +123,12 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
|
123
123
|
teamRaw = res.data;
|
|
124
124
|
}
|
|
125
125
|
else if (game === 'cs2') {
|
|
126
|
-
|
|
126
|
+
// A cs2-team-<n> / hltv-team-<n> id resolves 1:1 — fetch it directly instead of
|
|
127
|
+
// name-searching, which could silently fall back to rows[0] of an unrelated search.
|
|
128
|
+
const directId = /^(cs2|hltv)-team-\d+$/i.test(idOrSlug);
|
|
129
|
+
const res = directId
|
|
130
|
+
? await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(idOrSlug)}`)
|
|
131
|
+
: await fetchJson(ctx, '/cs2/teams', { query: { search: idOrSlug, limit: 5 } });
|
|
127
132
|
upstreamCalls += 1;
|
|
128
133
|
rateLimit = res.headers;
|
|
129
134
|
if (!res.ok) {
|
|
@@ -143,7 +148,10 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
|
143
148
|
],
|
|
144
149
|
});
|
|
145
150
|
}
|
|
146
|
-
{
|
|
151
|
+
if (directId) {
|
|
152
|
+
teamRaw = res.data;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
147
155
|
const rows = extractRows(res.data);
|
|
148
156
|
teamRaw =
|
|
149
157
|
rows.find((row) => {
|
|
@@ -285,20 +293,20 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
|
285
293
|
if (game === 'cs2') {
|
|
286
294
|
const tid = team.id !== 'unknown' ? team.id : idOrSlug;
|
|
287
295
|
tasks.push((async () => {
|
|
288
|
-
const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(tid)}/roster
|
|
296
|
+
const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(tid)}/roster`);
|
|
289
297
|
upstreamCalls += 1;
|
|
290
298
|
rateLimit = { ...rateLimit, ...res.headers };
|
|
291
299
|
if (res.ok) {
|
|
292
300
|
roster = {
|
|
293
301
|
items: extractRows(res.data).slice(0, 20),
|
|
294
302
|
asOf: new Date().toISOString(),
|
|
295
|
-
quality: '
|
|
303
|
+
quality: 'current',
|
|
296
304
|
};
|
|
297
305
|
}
|
|
298
306
|
else {
|
|
299
307
|
partial.push(partialFromRejection('roster', {
|
|
300
308
|
code: mapHttpToCode(res.status),
|
|
301
|
-
message: `roster
|
|
309
|
+
message: `roster HTTP ${res.status}`,
|
|
302
310
|
httpStatus: res.status,
|
|
303
311
|
}));
|
|
304
312
|
}
|
|
@@ -478,19 +486,19 @@ function winnerSide(match, a) {
|
|
|
478
486
|
}
|
|
479
487
|
export const headToHead = {
|
|
480
488
|
name: 'head_to_head',
|
|
481
|
-
description: `Composed head-to-head record between two teams (or two UFC fighters). No first-class REST H2H exists — this tool filters match history server-side.
|
|
482
|
-
|
|
483
|
-
When to use:
|
|
484
|
-
- Rivalry / series record questions
|
|
485
|
-
- Supporting context for previews
|
|
486
|
-
|
|
487
|
-
Prefer over: agent-side double match-list filtering.
|
|
488
|
-
|
|
489
|
-
Do not use when: single-side form only → team_profile or player_profile.
|
|
490
|
-
|
|
491
|
-
Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
|
|
492
|
-
|
|
493
|
-
Parallel-safe: yes. Upstream cost: 2–4.
|
|
489
|
+
description: `Composed head-to-head record between two teams (or two UFC fighters). No first-class REST H2H exists — this tool filters match history server-side.
|
|
490
|
+
|
|
491
|
+
When to use:
|
|
492
|
+
- Rivalry / series record questions
|
|
493
|
+
- Supporting context for previews
|
|
494
|
+
|
|
495
|
+
Prefer over: agent-side double match-list filtering.
|
|
496
|
+
|
|
497
|
+
Do not use when: single-side form only → team_profile or player_profile.
|
|
498
|
+
|
|
499
|
+
Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
|
|
500
|
+
|
|
501
|
+
Parallel-safe: yes. Upstream cost: 2–4.
|
|
494
502
|
Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
495
503
|
inputSchema: {
|
|
496
504
|
type: 'object',
|
|
@@ -547,24 +555,80 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
547
555
|
let upstreamCalls = 0;
|
|
548
556
|
let rateLimit = {};
|
|
549
557
|
let rows = [];
|
|
558
|
+
// Names used for client-side side matching; replaced with resolved team names
|
|
559
|
+
// when the id-based REST H2H succeeds (short inputs like "navi" never substring-match "Natus Vincere").
|
|
560
|
+
let matchA = sideA;
|
|
561
|
+
let matchB = sideB;
|
|
550
562
|
if (game === 'cs2') {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
563
|
+
// Prefer the purpose-built id-based H2H endpoint; /cs2/matches?team= name-substring
|
|
564
|
+
// filtering misses short names ("navi" vs stored "Natus Vincere").
|
|
565
|
+
const resolveCs2Side = async (side) => {
|
|
566
|
+
const res = await fetchJson(ctx, '/cs2/teams', { query: { search: side, limit: 5 } });
|
|
567
|
+
upstreamCalls += 1;
|
|
568
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
569
|
+
if (!res.ok)
|
|
570
|
+
return null;
|
|
571
|
+
const found = extractRows(res.data);
|
|
572
|
+
const hit = found.find((row) => {
|
|
573
|
+
const r = asRecord(row) ?? {};
|
|
574
|
+
return [r.id, r.slug, r.name].map((x) => String(x ?? '').toLowerCase()).includes(side.toLowerCase());
|
|
575
|
+
}) ?? found[0];
|
|
576
|
+
const r = asRecord(hit);
|
|
577
|
+
const id = pickString(r?.id, r?.teamId);
|
|
578
|
+
return id ? { id, name: pickString(r?.name) ?? side } : null;
|
|
579
|
+
};
|
|
580
|
+
const [cs2SideA, cs2SideB] = await Promise.all([resolveCs2Side(sideA), resolveCs2Side(sideB)]);
|
|
581
|
+
let restH2hOk = false;
|
|
582
|
+
if (cs2SideA && cs2SideB) {
|
|
583
|
+
const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(cs2SideA.id)}/vs/${encodeURIComponent(cs2SideB.id)}`, {
|
|
584
|
+
query: { limit: 50 },
|
|
565
585
|
});
|
|
586
|
+
upstreamCalls += 1;
|
|
587
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
588
|
+
if (res.ok) {
|
|
589
|
+
// Rows are side-A-perspective summaries (summarizeMatchForTeam); reshape into
|
|
590
|
+
// flat match rows so the shared normalize/score pipeline applies unchanged.
|
|
591
|
+
rows = extractRows(res.data).map((row) => {
|
|
592
|
+
const r = asRecord(row) ?? {};
|
|
593
|
+
return {
|
|
594
|
+
matchId: r.match_id,
|
|
595
|
+
status: r.status,
|
|
596
|
+
startsAt: r.starts_at,
|
|
597
|
+
eventName: r.event_name,
|
|
598
|
+
bestOf: r.best_of,
|
|
599
|
+
team1Name: cs2SideA.name,
|
|
600
|
+
team1Score: r.team_score,
|
|
601
|
+
team2Name: pickString(r.opponent_name) ?? cs2SideB.name,
|
|
602
|
+
team2Score: r.opponent_score,
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
matchA = cs2SideA.name;
|
|
606
|
+
matchB = cs2SideB.name;
|
|
607
|
+
restH2hOk = true;
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
warnings.push(`REST H2H HTTP ${res.status}; fell back to match-history name filtering`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (!restH2hOk) {
|
|
614
|
+
const res = await fetchJson(ctx, '/cs2/matches', { query: { team: sideA, limit: 50 } });
|
|
615
|
+
upstreamCalls += 1;
|
|
616
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
617
|
+
if (!res.ok) {
|
|
618
|
+
return errorEnvelope({
|
|
619
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
620
|
+
message: `H2H match fetch failed (HTTP ${res.status})`,
|
|
621
|
+
game,
|
|
622
|
+
source: 'head_to_head',
|
|
623
|
+
requestId,
|
|
624
|
+
tookMs: Date.now() - started,
|
|
625
|
+
upstreamCalls,
|
|
626
|
+
httpStatus: res.status,
|
|
627
|
+
rateLimit: res.headers,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
rows = extractRows(res.data);
|
|
566
631
|
}
|
|
567
|
-
rows = extractRows(res.data);
|
|
568
632
|
}
|
|
569
633
|
else if (game === 'cod') {
|
|
570
634
|
const res = await fetchJson(ctx, '/cod/matches', { query: { team: sideA, limit: 50 } });
|
|
@@ -672,7 +736,7 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
672
736
|
}
|
|
673
737
|
const meetings = rows
|
|
674
738
|
.map((row) => normalizeMatch(game, row))
|
|
675
|
-
.filter((m) => sidesMatch(m,
|
|
739
|
+
.filter((m) => sidesMatch(m, matchA, matchB))
|
|
676
740
|
.filter((m) => {
|
|
677
741
|
if (!fromIso && !toIso)
|
|
678
742
|
return true;
|
|
@@ -692,7 +756,7 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
692
756
|
let winsB = 0;
|
|
693
757
|
let draws = 0;
|
|
694
758
|
for (const m of meetings) {
|
|
695
|
-
const w = winnerSide(m,
|
|
759
|
+
const w = winnerSide(m, matchA);
|
|
696
760
|
if (w === 'A')
|
|
697
761
|
winsA += 1;
|
|
698
762
|
else if (w === 'B')
|
package/dist/tools/types.js
CHANGED
|
@@ -64,9 +64,82 @@ export function stringSchema(description, example) {
|
|
|
64
64
|
schema.examples = [example];
|
|
65
65
|
return schema;
|
|
66
66
|
}
|
|
67
|
+
/** Levenshtein distance, capped for short arg names. */
|
|
68
|
+
function editDistance(a, b) {
|
|
69
|
+
const m = a.length;
|
|
70
|
+
const n = b.length;
|
|
71
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
72
|
+
for (let i = 1; i <= m; i++) {
|
|
73
|
+
const cur = [i];
|
|
74
|
+
for (let j = 1; j <= n; j++) {
|
|
75
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
76
|
+
}
|
|
77
|
+
prev = cur;
|
|
78
|
+
}
|
|
79
|
+
return prev[n];
|
|
80
|
+
}
|
|
81
|
+
/** Closest known parameter to a mistyped one, if it is plausibly a typo. */
|
|
82
|
+
function suggestParam(unknown, known) {
|
|
83
|
+
const u = unknown.toLowerCase();
|
|
84
|
+
let best = null;
|
|
85
|
+
for (const k of known) {
|
|
86
|
+
const d = editDistance(u, k.toLowerCase());
|
|
87
|
+
if (!best || d < best.d)
|
|
88
|
+
best = { name: k, d };
|
|
89
|
+
}
|
|
90
|
+
if (!best)
|
|
91
|
+
return null;
|
|
92
|
+
// Accept near-misses, plus prefix relationships like query -> q.
|
|
93
|
+
const threshold = Math.max(2, Math.floor(Math.max(u.length, best.name.length) / 3));
|
|
94
|
+
if (best.d <= threshold)
|
|
95
|
+
return best.name;
|
|
96
|
+
const prefixHit = known.find((k) => u.startsWith(k.toLowerCase()) || k.toLowerCase().startsWith(u));
|
|
97
|
+
return prefixHit ?? null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Args the tool does not declare, when its schema says additionalProperties:false.
|
|
101
|
+
*
|
|
102
|
+
* Every tool already declared that, but nothing enforced it: the MCP SDK does
|
|
103
|
+
* not validate arguments against inputSchema, so a plausible-but-wrong name was
|
|
104
|
+
* dropped on the floor and the tool ran with a default. search_entities
|
|
105
|
+
* { game:"ufc", query:"Jon Jones" } — `q` is the real parameter — returned
|
|
106
|
+
* ok:true with an unrelated roster. Silently wrong beats loudly wrong for a
|
|
107
|
+
* human skimming output, but for an agent it is far worse: it has no way to
|
|
108
|
+
* tell a real answer from a discarded question.
|
|
109
|
+
*/
|
|
110
|
+
export function unknownArgKeys(def, args) {
|
|
111
|
+
const schema = def.inputSchema ?? {};
|
|
112
|
+
if (schema.additionalProperties !== false)
|
|
113
|
+
return [];
|
|
114
|
+
const props = schema.properties;
|
|
115
|
+
if (!props || typeof props !== 'object')
|
|
116
|
+
return [];
|
|
117
|
+
const known = Object.keys(props);
|
|
118
|
+
return Object.keys(args ?? {}).filter((k) => !known.includes(k));
|
|
119
|
+
}
|
|
67
120
|
/** Normalize MCP handler return to MCP content result. */
|
|
68
121
|
export async function runTool(def, args, ctx) {
|
|
69
122
|
try {
|
|
123
|
+
const unknown = unknownArgKeys(def, args ?? {});
|
|
124
|
+
if (unknown.length > 0) {
|
|
125
|
+
const known = Object.keys((def.inputSchema.properties ?? {}));
|
|
126
|
+
const pairs = unknown.map((k) => ({ k, s: suggestParam(k, known) }));
|
|
127
|
+
const hints = pairs.map(({ k, s }) => s ? `"${k}" — did you mean "${s}"?` : `"${k}" is not accepted`);
|
|
128
|
+
const { errorEnvelope, toMcpResult: toResult } = await import('../envelope.js');
|
|
129
|
+
return toResult(errorEnvelope({
|
|
130
|
+
code: 'VALIDATION',
|
|
131
|
+
message: `${def.name}: unknown argument${unknown.length > 1 ? 's' : ''} ` +
|
|
132
|
+
// Avoid "?." when the last hint is a did-you-mean question.
|
|
133
|
+
`${hints.join('; ')}${hints[hints.length - 1].endsWith('?') ? '' : '.'} ` +
|
|
134
|
+
`Accepted: ${known.join(', ')}.`,
|
|
135
|
+
game: typeof args?.game === 'string' ? args.game : null,
|
|
136
|
+
source: def.name,
|
|
137
|
+
recover: [
|
|
138
|
+
...pairs.map(({ k, s }) => s ? `Rename "${k}" to "${s}" and retry` : `Remove "${k}" and retry`),
|
|
139
|
+
`Accepted arguments: ${known.join(', ')}`,
|
|
140
|
+
],
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
70
143
|
const result = await def.handler(args ?? {}, ctx);
|
|
71
144
|
if (result && typeof result === 'object' && 'content' in result) {
|
|
72
145
|
return result;
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
/**
|
|
3
|
+
* Single source of truth for the server version.
|
|
4
|
+
*
|
|
5
|
+
* This used to be hardcoded in three places — package.json, PACKAGE_VERSION in
|
|
6
|
+
* index.ts, and CATALOG_VERSION in tools/meta.ts — which had already drifted:
|
|
7
|
+
* a test asserted 0.2.4 while the package shipped 0.2.5. Agents read the version
|
|
8
|
+
* out of list_capabilities, so a stale value there is a support problem, not a
|
|
9
|
+
* cosmetic one. Read it from the manifest instead, so `npm version` is the only
|
|
10
|
+
* edit a release needs.
|
|
11
|
+
*
|
|
12
|
+
* createRequire rather than a JSON import: import assertions are still awkward
|
|
13
|
+
* across the Node versions this package supports (>=20), and this resolves the
|
|
14
|
+
* same from src/ under tsx and from dist/ once built, because package.json is
|
|
15
|
+
* always published alongside dist.
|
|
16
|
+
*/
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
function readVersion() {
|
|
19
|
+
try {
|
|
20
|
+
const pkg = require('../package.json');
|
|
21
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Never let a packaging quirk crash the server on boot; a wrong-but-present
|
|
25
|
+
// version is far better than a failed start.
|
|
26
|
+
return '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const PACKAGE_VERSION = readVersion();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cito-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Standalone MCP server for the Cito esports API — 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"build": "tsc -p tsconfig.json",
|
|
18
18
|
"test": "tsx --test src/**/*.test.ts src/*.test.ts",
|
|
19
19
|
"start": "node dist/index.js",
|
|
20
|
-
"prepublishOnly": "npm run build"
|
|
20
|
+
"prepublishOnly": "npm run build && npm test && npm run smoke",
|
|
21
|
+
"smoke": "node scripts/smoke.mjs"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@modelcontextprotocol/sdk": "1.29.0"
|