cito-mcp 0.2.3 → 0.2.5
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 +24 -4
- package/dist/client.js +50 -0
- package/dist/index.js +45 -6
- package/dist/instructions.js +1 -0
- package/dist/tools/insight.js +60 -8
- package/dist/tools/live.js +176 -25
- package/dist/tools/match.js +91 -33
- package/dist/tools/meta.js +1 -1
- package/dist/tools/normalize.js +70 -16
- package/dist/tools/player.js +54 -10
- package/dist/tools/resolve.js +149 -62
- package/dist/tools/standings.js +34 -1
- package/dist/tools/team.js +140 -54
- package/dist/version.js +29 -0
- package/package.json +1 -1
package/dist/tools/resolve.js
CHANGED
|
@@ -37,36 +37,94 @@ function pushCandidates(out, game, type, rows, q, limit) {
|
|
|
37
37
|
if (out.length > limit * 3)
|
|
38
38
|
out.length = limit * 3;
|
|
39
39
|
}
|
|
40
|
-
/** UFC
|
|
40
|
+
/** Drop noise tokens so "UFC Fight Night Medic" can match "UFC Fight Night: Medic vs Rodriguez". */
|
|
41
|
+
function ufcSearchQueries(q) {
|
|
42
|
+
const raw = q.trim();
|
|
43
|
+
if (!raw)
|
|
44
|
+
return [];
|
|
45
|
+
const queries = [raw];
|
|
46
|
+
const stop = new Set(['ufc', 'fight', 'night', 'event', 'card', 'vs', 'the', 'and', 'mma']);
|
|
47
|
+
const tokens = raw
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
50
|
+
.split(/\s+/)
|
|
51
|
+
.filter((t) => t.length >= 3 && !stop.has(t));
|
|
52
|
+
if (tokens.length) {
|
|
53
|
+
// distinctive tokens alone (e.g. "medic", "rodriguez")
|
|
54
|
+
for (const t of tokens.slice(0, 4))
|
|
55
|
+
queries.push(t);
|
|
56
|
+
// pair of last two distinctive tokens
|
|
57
|
+
if (tokens.length >= 2)
|
|
58
|
+
queries.push(tokens.slice(-2).join(' '));
|
|
59
|
+
}
|
|
60
|
+
// slug-ish form
|
|
61
|
+
const slugish = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
62
|
+
if (slugish && slugish !== raw.toLowerCase())
|
|
63
|
+
queries.push(slugish);
|
|
64
|
+
return [...new Set(queries.map((s) => s.trim()).filter(Boolean))];
|
|
65
|
+
}
|
|
66
|
+
/** UFC dedicated search (list /fighters ignores `q`). Multi-query for event fuzzy match. */
|
|
41
67
|
async function ufcSearch(ctx, q, type, limit) {
|
|
42
68
|
const candidates = [];
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
let calls = 0;
|
|
70
|
+
let lastStatus = 200;
|
|
71
|
+
let anyOk = false;
|
|
72
|
+
const wantFighters = type === 'any' || type === 'fighter' || type === 'player';
|
|
73
|
+
const wantEvents = type === 'any' || type === 'event' || type === 'tournament';
|
|
74
|
+
const wantMatches = type === 'any' || type === 'match';
|
|
75
|
+
for (const query of ufcSearchQueries(q)) {
|
|
76
|
+
const res = await fetchJson(ctx, '/ufc/search', { query: { q: query } });
|
|
77
|
+
calls += 1;
|
|
78
|
+
lastStatus = res.status;
|
|
79
|
+
if (!res.ok)
|
|
80
|
+
continue;
|
|
81
|
+
anyOk = true;
|
|
82
|
+
const data = asRecord(res.data) ?? {};
|
|
83
|
+
const payload = asRecord(data.data) ?? data;
|
|
84
|
+
const fighters = Array.isArray(payload.fighters)
|
|
85
|
+
? payload.fighters
|
|
86
|
+
: extractRows(payload.fighters);
|
|
87
|
+
const events = Array.isArray(payload.events)
|
|
88
|
+
? payload.events
|
|
89
|
+
: extractRows(payload.events);
|
|
90
|
+
const bouts = Array.isArray(payload.bouts)
|
|
91
|
+
? payload.bouts
|
|
92
|
+
: extractRows(payload.bouts);
|
|
93
|
+
// Always score against the *original* user query for ranking quality
|
|
94
|
+
if (wantFighters)
|
|
95
|
+
pushCandidates(candidates, 'ufc', 'fighter', fighters, q, limit);
|
|
96
|
+
if (wantEvents)
|
|
97
|
+
pushCandidates(candidates, 'ufc', 'event', events, q, limit);
|
|
98
|
+
if (wantMatches)
|
|
99
|
+
pushCandidates(candidates, 'ufc', 'match', bouts, q, limit);
|
|
61
100
|
}
|
|
62
|
-
|
|
63
|
-
|
|
101
|
+
// Event soft-miss fallback: scan upcoming + recent event lists client-side
|
|
102
|
+
if (wantEvents && !candidates.some((c) => c.type === 'event' && c.score >= 40)) {
|
|
103
|
+
for (const path of ['/ufc/events/upcoming', '/ufc/events/recent', '/ufc/events']) {
|
|
104
|
+
const res = await fetchJson(ctx, path, { query: { limit: 40, page: 1 } });
|
|
105
|
+
calls += 1;
|
|
106
|
+
if (!res.ok)
|
|
107
|
+
continue;
|
|
108
|
+
anyOk = true;
|
|
109
|
+
pushCandidates(candidates, 'ufc', 'event', extractRows(res.data), q, limit);
|
|
110
|
+
}
|
|
64
111
|
}
|
|
65
|
-
|
|
66
|
-
|
|
112
|
+
// Dedupe by type+id
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const deduped = [];
|
|
115
|
+
for (const c of candidates.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))) {
|
|
116
|
+
const key = `${c.type}:${c.id}:${c.slug ?? ''}`;
|
|
117
|
+
if (seen.has(key))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
deduped.push(c);
|
|
67
121
|
}
|
|
68
|
-
|
|
69
|
-
|
|
122
|
+
return {
|
|
123
|
+
candidates: deduped.slice(0, limit),
|
|
124
|
+
calls,
|
|
125
|
+
ok: anyOk || deduped.length > 0,
|
|
126
|
+
status: anyOk ? 200 : lastStatus,
|
|
127
|
+
};
|
|
70
128
|
}
|
|
71
129
|
async function searchGame(ctx, game, q, type, limit) {
|
|
72
130
|
const candidates = [];
|
|
@@ -86,7 +144,9 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
86
144
|
}),
|
|
87
145
|
};
|
|
88
146
|
}
|
|
89
|
-
|
|
147
|
+
// Unwrap the { success, data, meta } envelope before reading buckets.
|
|
148
|
+
const envelope = asRecord(res.data) ?? {};
|
|
149
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
90
150
|
const buckets = [
|
|
91
151
|
['team', data.teams],
|
|
92
152
|
['player', data.players],
|
|
@@ -101,7 +161,15 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
101
161
|
pushCandidates(candidates, game, t, rows, q, limit);
|
|
102
162
|
}
|
|
103
163
|
if (candidates.length === 0) {
|
|
104
|
-
|
|
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);
|
|
105
173
|
}
|
|
106
174
|
}
|
|
107
175
|
else if (game === 'dota2') {
|
|
@@ -118,7 +186,9 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
118
186
|
}),
|
|
119
187
|
};
|
|
120
188
|
}
|
|
121
|
-
|
|
189
|
+
// Unwrap the { success, data, meta } envelope before reading buckets.
|
|
190
|
+
const envelope = asRecord(res.data) ?? {};
|
|
191
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
122
192
|
for (const [t, key] of [
|
|
123
193
|
['team', 'teams'],
|
|
124
194
|
['player', 'players'],
|
|
@@ -130,7 +200,15 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
130
200
|
pushCandidates(candidates, game, t, extractRows(data[key] ?? data), q, limit);
|
|
131
201
|
}
|
|
132
202
|
if (candidates.length === 0) {
|
|
133
|
-
|
|
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);
|
|
134
212
|
}
|
|
135
213
|
}
|
|
136
214
|
else if (game === 'cod') {
|
|
@@ -255,20 +333,20 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
255
333
|
}
|
|
256
334
|
export const resolveEntity = {
|
|
257
335
|
name: 'resolve_entity',
|
|
258
|
-
description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
|
|
259
|
-
|
|
260
|
-
When to use:
|
|
261
|
-
- User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
|
|
262
|
-
- Need a canonical id/slug before profile or match tools
|
|
263
|
-
|
|
264
|
-
Prefer over search_entities when you want one best match (or small ranked set) to chain.
|
|
265
|
-
Prefer search_entities when browsing many results with pagination.
|
|
266
|
-
|
|
267
|
-
Do not use when: you already have a stable id/slug from a prior tool.
|
|
268
|
-
|
|
269
|
-
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.
|
|
270
|
-
|
|
271
|
-
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.
|
|
272
350
|
Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
|
|
273
351
|
inputSchema: {
|
|
274
352
|
type: 'object',
|
|
@@ -362,22 +440,22 @@ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
|
|
|
362
440
|
};
|
|
363
441
|
export const searchEntities = {
|
|
364
442
|
name: 'search_entities',
|
|
365
|
-
description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
|
|
366
|
-
|
|
367
|
-
When to use:
|
|
368
|
-
- Typeahead / pickers
|
|
369
|
-
- "List teams matching…"
|
|
370
|
-
- Exploring entities without committing to one ID
|
|
371
|
-
- UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
|
|
372
|
-
|
|
373
|
-
Prefer over resolve_entity when the user wants a list.
|
|
374
|
-
Prefer resolve_entity when chaining one name into a profile tool.
|
|
375
|
-
|
|
376
|
-
Do not use when: fetching a known entity profile — use team_profile or player_profile.
|
|
377
|
-
|
|
378
|
-
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.
|
|
379
|
-
|
|
380
|
-
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.
|
|
381
459
|
Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
382
460
|
inputSchema: {
|
|
383
461
|
type: 'object',
|
|
@@ -462,7 +540,9 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
462
540
|
const res = await fetchJson(ctx, '/cs2/search', { query: { q, limit } });
|
|
463
541
|
upstreamCalls += 1;
|
|
464
542
|
if (res.ok) {
|
|
465
|
-
|
|
543
|
+
// Unwrap the { success, data, meta } envelope before reading buckets.
|
|
544
|
+
const envelope = asRecord(res.data) ?? {};
|
|
545
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
466
546
|
if (type === 'any' || type === 'team') {
|
|
467
547
|
items.push(...extractRows(data.teams).map((r) => entityRef(r, 'team', game)));
|
|
468
548
|
}
|
|
@@ -477,8 +557,15 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
477
557
|
else {
|
|
478
558
|
if (type === 'team' || type === 'any')
|
|
479
559
|
await listPath('/cs2/teams', 'team');
|
|
480
|
-
if (type === 'player' || type === 'any')
|
|
481
|
-
|
|
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
|
+
}
|
|
482
569
|
if (type === 'event' || type === 'tournament' || type === 'any')
|
|
483
570
|
await listPath('/cs2/events', 'event');
|
|
484
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 } });
|
|
@@ -627,30 +691,52 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
627
691
|
rows = extractRows(res.data);
|
|
628
692
|
}
|
|
629
693
|
else if (game === 'ufc') {
|
|
630
|
-
//
|
|
631
|
-
const [aRes, bRes,
|
|
694
|
+
// Use fighter fight history (not global /bouts page 1, which rarely contains both fighters).
|
|
695
|
+
const [aRes, bRes, histA, histB] = await Promise.all([
|
|
632
696
|
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideA)}`),
|
|
633
697
|
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideB)}`),
|
|
634
|
-
fetchJson(ctx,
|
|
698
|
+
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideA)}/fights`, { query: { limit: 50 } }),
|
|
699
|
+
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideB)}/fights`, { query: { limit: 50 } }),
|
|
635
700
|
]);
|
|
636
|
-
upstreamCalls +=
|
|
637
|
-
rateLimit = { ...aRes.headers, ...bRes.headers, ...
|
|
701
|
+
upstreamCalls += 4;
|
|
702
|
+
rateLimit = { ...aRes.headers, ...bRes.headers, ...histA.headers, ...histB.headers };
|
|
638
703
|
if (!aRes.ok || !bRes.ok) {
|
|
639
704
|
return errorEnvelope({
|
|
640
705
|
code: 'NOT_FOUND',
|
|
641
|
-
message: 'One or both fighters not found',
|
|
706
|
+
message: 'One or both fighters not found — pass fighter slugs from resolve_entity',
|
|
642
707
|
game,
|
|
643
708
|
source: 'head_to_head',
|
|
644
709
|
requestId,
|
|
645
710
|
tookMs: Date.now() - started,
|
|
646
711
|
upstreamCalls,
|
|
712
|
+
recover: [
|
|
713
|
+
'resolve_entity { game: "ufc", type: "fighter", q } for each name',
|
|
714
|
+
'Retry head_to_head with returned slugs as sideA/sideB',
|
|
715
|
+
],
|
|
647
716
|
});
|
|
648
717
|
}
|
|
649
|
-
|
|
718
|
+
const toBouts = (payload) => extractRows(payload).map((row) => {
|
|
719
|
+
const r = asRecord(row) ?? {};
|
|
720
|
+
return asRecord(r.bout) ?? row;
|
|
721
|
+
});
|
|
722
|
+
const fromA = histA.ok ? toBouts(histA.data) : [];
|
|
723
|
+
const fromB = histB.ok ? toBouts(histB.data) : [];
|
|
724
|
+
// Union by bout id
|
|
725
|
+
const byId = new Map();
|
|
726
|
+
for (const bout of [...fromA, ...fromB]) {
|
|
727
|
+
const m = normalizeMatch('ufc', bout);
|
|
728
|
+
const key = m.matchId !== 'unknown' ? m.matchId : JSON.stringify(bout).slice(0, 80);
|
|
729
|
+
if (!byId.has(key))
|
|
730
|
+
byId.set(key, bout);
|
|
731
|
+
}
|
|
732
|
+
rows = [...byId.values()];
|
|
733
|
+
if (rows.length === 0) {
|
|
734
|
+
warnings.push('No fight history rows returned for either fighter; H2H may be empty');
|
|
735
|
+
}
|
|
650
736
|
}
|
|
651
737
|
const meetings = rows
|
|
652
738
|
.map((row) => normalizeMatch(game, row))
|
|
653
|
-
.filter((m) => sidesMatch(m,
|
|
739
|
+
.filter((m) => sidesMatch(m, matchA, matchB))
|
|
654
740
|
.filter((m) => {
|
|
655
741
|
if (!fromIso && !toIso)
|
|
656
742
|
return true;
|
|
@@ -670,7 +756,7 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
670
756
|
let winsB = 0;
|
|
671
757
|
let draws = 0;
|
|
672
758
|
for (const m of meetings) {
|
|
673
|
-
const w = winnerSide(m,
|
|
759
|
+
const w = winnerSide(m, matchA);
|
|
674
760
|
if (w === 'A')
|
|
675
761
|
winsA += 1;
|
|
676
762
|
else if (w === 'B')
|
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