cito-mcp 0.2.4 → 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 +1 -0
- package/dist/client.js +14 -0
- package/dist/index.js +45 -6
- package/dist/instructions.js +1 -0
- package/dist/tools/live.js +176 -25
- package/dist/tools/match.js +76 -29
- package/dist/tools/meta.js +1 -1
- package/dist/tools/normalize.js +20 -1
- package/dist/tools/resolve.js +66 -37
- package/dist/tools/standings.js +34 -1
- package/dist/tools/team.js +111 -47
- package/dist/version.js +29 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -170,6 +170,7 @@ Hardened agent-facing UFC shapes (offline-tested):
|
|
|
170
170
|
|
|
171
171
|
- **`normalizeMatch`** reads `fighters[]` (`corner` + `fighterName` / `profile.name`), live `red`/`blue`, and `boutId` / `dataId` / bare `id`. Never keeps label `? vs ?` when fighter names exist (including blue-first arrays).
|
|
172
172
|
- **`live_matches`** uses `extractLiveRows`: prefer `liveBouts` (empty array = honest empty board); never promote supervisor `events[]` shells into match rows.
|
|
173
|
+
- **UFC empty board honesty:** when `count === 0`, section includes `note`, `emptyReason`, slim `health` (`workerAlive`, lag), optional non-live `supervisor` strip and `nextCard` (armed only). Also `data.note` + `meta.warnings` for single-game UFC. Do not treat empty as HTTP failure; do not invent `? vs ?` from event shells.
|
|
173
174
|
- **`upcoming_schedule`** expands event → bout rows with fighter labels; applies client-side **`hours` / `from` / `to`** (API has no `hours`); event shells without bouts use the **event name**, not `? vs ?`.
|
|
174
175
|
- **`event_card`** bouts share the same `normalizeMatch` path.
|
|
175
176
|
- **`standings`** maps division champions to rank **`C`** (`rankText: "C"`, interim **`IC`**); contender `#1` stays numeric `1` — no dual numeric `#1`.
|
package/dist/client.js
CHANGED
|
@@ -2,16 +2,30 @@
|
|
|
2
2
|
* Authenticated Cito REST client for curated tools.
|
|
3
3
|
* Auth: CITO_API_KEY → x-api-key. Logs never include the key.
|
|
4
4
|
*/
|
|
5
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
5
6
|
export const DEFAULT_API_BASE = 'https://api.citoapi.com/api/v1';
|
|
6
7
|
export const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024;
|
|
7
8
|
/** stderr ONLY — stdout is the MCP stdio channel. */
|
|
8
9
|
export function log(message) {
|
|
9
10
|
console.error(`[cito-mcp] ${message}`);
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Identify MCP traffic.
|
|
14
|
+
*
|
|
15
|
+
* Without this every MCP request arrives as Node's default agent - "node" -
|
|
16
|
+
* which is the single largest bucket in api_usage_logs (2.78M requests across
|
|
17
|
+
* 182 keys) and indistinguishable from any other script. That made MCP adoption
|
|
18
|
+
* unmeasurable: we could not tell whether an agent-driven user activates faster,
|
|
19
|
+
* consumes more, or converts better than someone hand-rolling curl.
|
|
20
|
+
*
|
|
21
|
+
* Versioned so a bad release can be isolated in the logs.
|
|
22
|
+
*/
|
|
23
|
+
export const CITO_MCP_USER_AGENT = `cito-mcp/${PACKAGE_VERSION} (+https://citoapi.com)`;
|
|
11
24
|
export function authHeaders(apiKey, extra) {
|
|
12
25
|
return {
|
|
13
26
|
'x-api-key': apiKey,
|
|
14
27
|
accept: 'application/json',
|
|
28
|
+
'user-agent': CITO_MCP_USER_AGENT,
|
|
15
29
|
...extra,
|
|
16
30
|
};
|
|
17
31
|
}
|
package/dist/index.js
CHANGED
|
@@ -18,17 +18,37 @@ import { errorEnvelope, toMcpResult } from './envelope.js';
|
|
|
18
18
|
import { SERVER_INSTRUCTIONS } from './instructions.js';
|
|
19
19
|
import { allTools, getTool } from './tools/index.js';
|
|
20
20
|
import { runTool } from './tools/types.js';
|
|
21
|
-
|
|
21
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
22
22
|
const API_KEY = process.env.CITO_API_KEY;
|
|
23
|
+
/**
|
|
24
|
+
* Tools that need no API key. These must keep working with the server
|
|
25
|
+
* unconfigured, so a user can add it and immediately see what it does.
|
|
26
|
+
*/
|
|
27
|
+
const OFFLINE_TOOLS = new Set(['list_capabilities']);
|
|
28
|
+
/**
|
|
29
|
+
* Missing key is NOT fatal.
|
|
30
|
+
*
|
|
31
|
+
* This used to process.exit(1) before the transport was even created, so the
|
|
32
|
+
* handshake never completed and the client showed only "server failed to
|
|
33
|
+
* start" — no tool list, no reason, nothing the model could relay. The user's
|
|
34
|
+
* first experience of a mistyped env var was a dead server.
|
|
35
|
+
*
|
|
36
|
+
* Now the server boots, completes initialize, and advertises its full catalog.
|
|
37
|
+
* Tools that need the API return a structured MISSING_API_KEY envelope with
|
|
38
|
+
* recovery steps, which the model can read out; list_capabilities keeps working
|
|
39
|
+
* offline so the server is browsable before it is configured.
|
|
40
|
+
*/
|
|
23
41
|
if (!API_KEY) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
'
|
|
27
|
-
|
|
42
|
+
// stderr only — stdout is the JSON-RPC channel and must stay clean.
|
|
43
|
+
console.error('[cito-mcp] CITO_API_KEY is not set. Serving tool catalog only; ' +
|
|
44
|
+
'API-backed tools will return MISSING_API_KEY until a key is provided. ' +
|
|
45
|
+
'Get one at https://citoapi.com/dashboard and set CITO_API_KEY.');
|
|
28
46
|
}
|
|
29
47
|
const API_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
30
48
|
const ctx = {
|
|
31
|
-
|
|
49
|
+
// Empty string when unconfigured; the dispatcher blocks API-backed tools
|
|
50
|
+
// before any request is attempted, so this is never sent as a credential.
|
|
51
|
+
apiKey: API_KEY ?? '',
|
|
32
52
|
baseUrl: API_BASE,
|
|
33
53
|
};
|
|
34
54
|
async function main() {
|
|
@@ -59,6 +79,25 @@ async function main() {
|
|
|
59
79
|
recover: ['Call list_capabilities to see available tools'],
|
|
60
80
|
}));
|
|
61
81
|
}
|
|
82
|
+
// Fail the CALL, not the process. The model gets an actionable envelope it
|
|
83
|
+
// can relay verbatim instead of the client reporting a dead server.
|
|
84
|
+
if (!API_KEY && !OFFLINE_TOOLS.has(name)) {
|
|
85
|
+
return toMcpResult(errorEnvelope({
|
|
86
|
+
code: 'UNAUTHORIZED',
|
|
87
|
+
message: 'CITO_API_KEY is not set, so this tool cannot reach the Cito API. ' +
|
|
88
|
+
'The server is running and list_capabilities still works.',
|
|
89
|
+
game: null,
|
|
90
|
+
source: 'cito-mcp',
|
|
91
|
+
httpStatus: 401,
|
|
92
|
+
retryable: false,
|
|
93
|
+
recover: [
|
|
94
|
+
'Create a key at https://citoapi.com/dashboard',
|
|
95
|
+
'claude mcp add cito -e CITO_API_KEY=cito_... -- npx cito-mcp',
|
|
96
|
+
'Or set CITO_API_KEY in the mcpServers env block of your client config',
|
|
97
|
+
'Call list_capabilities to browse available tools without a key',
|
|
98
|
+
],
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
62
101
|
return runTool(tool, args, ctx);
|
|
63
102
|
});
|
|
64
103
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
package/dist/instructions.js
CHANGED
|
@@ -57,6 +57,7 @@ resolve_entity ambiguity is soft: ok:true with data.needsDisambiguation and data
|
|
|
57
57
|
## Recipes
|
|
58
58
|
|
|
59
59
|
Live board: api_health (optional) → live_matches → match_summary for selected matchId.
|
|
60
|
+
UFC empty live (count=0): read section.note / emptyReason / health (workerAlive, lag) / supervisor / nextCard — do not claim "API offline" without workerAlive/lag; never invent matchups from supervisor shells.
|
|
60
61
|
Team page: resolve_entity {type:team} → team_profile.
|
|
61
62
|
Player card: resolve_entity {type:player|fighter} → player_profile.
|
|
62
63
|
Fight night / event card: resolve_entity {type:event} → event_card {includeBouts:true} → match_preview for a featured bout.
|
package/dist/tools/live.js
CHANGED
|
@@ -16,6 +16,8 @@ const LIVE_PATHS = {
|
|
|
16
16
|
* Extract live match/bout rows. UFC /ufc/live returns
|
|
17
17
|
* { liveBouts, events, ... } — events are supervisor shells without fighters.
|
|
18
18
|
* Prefer liveBouts (and nested tracking on events) over plain events[].
|
|
19
|
+
* Never promote bare events[] to match rows (avoids fake "? vs ?").
|
|
20
|
+
* Nested tracking with status armed is excluded from live items (use nextCard).
|
|
19
21
|
* Exported for offline UFC projection tests.
|
|
20
22
|
*/
|
|
21
23
|
export function extractLiveRows(data, game) {
|
|
@@ -24,6 +26,20 @@ export function extractLiveRows(data, game) {
|
|
|
24
26
|
if (!payload)
|
|
25
27
|
return extractRows(data);
|
|
26
28
|
if (game === 'ufc') {
|
|
29
|
+
const pushLiveTracking = (tracking, er, out) => {
|
|
30
|
+
for (const t of tracking) {
|
|
31
|
+
const tr = asRecord(t) ?? {};
|
|
32
|
+
const status = String(pickString(tr.status) ?? '').toLowerCase();
|
|
33
|
+
// Board items = live|watching only; armed is surfaced as nextCard, not items.
|
|
34
|
+
if (status === 'armed' || status === 'completed' || status === 'stale')
|
|
35
|
+
continue;
|
|
36
|
+
out.push({
|
|
37
|
+
...tr,
|
|
38
|
+
eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
|
|
39
|
+
eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
27
43
|
// Prefer liveBouts when the key is present (including empty [] — honest empty board).
|
|
28
44
|
if (Array.isArray(payload.liveBouts)) {
|
|
29
45
|
const liveBouts = payload.liveBouts;
|
|
@@ -37,16 +53,9 @@ export function extractLiveRows(data, game) {
|
|
|
37
53
|
for (const ev of eventsWithEmptyLive) {
|
|
38
54
|
const er = asRecord(ev);
|
|
39
55
|
const tracking = Array.isArray(er?.tracking) ? er.tracking : [];
|
|
40
|
-
|
|
41
|
-
const tr = asRecord(t) ?? {};
|
|
42
|
-
nested.push({
|
|
43
|
-
...tr,
|
|
44
|
-
eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
|
|
45
|
-
eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
|
|
46
|
-
});
|
|
47
|
-
}
|
|
56
|
+
pushLiveTracking(tracking, er, nested);
|
|
48
57
|
}
|
|
49
|
-
return nested; // [] when only supervisor shells
|
|
58
|
+
return nested; // [] when only supervisor shells / armed-only
|
|
50
59
|
}
|
|
51
60
|
// liveBouts key missing — try nested tracking under events
|
|
52
61
|
const events = Array.isArray(payload.events) ? payload.events : [];
|
|
@@ -54,14 +63,7 @@ export function extractLiveRows(data, game) {
|
|
|
54
63
|
for (const ev of events) {
|
|
55
64
|
const er = asRecord(ev);
|
|
56
65
|
const tracking = Array.isArray(er?.tracking) ? er.tracking : [];
|
|
57
|
-
|
|
58
|
-
const tr = asRecord(t) ?? {};
|
|
59
|
-
fromTracking.push({
|
|
60
|
-
...tr,
|
|
61
|
-
eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
|
|
62
|
-
eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
|
|
63
|
-
});
|
|
64
|
-
}
|
|
66
|
+
pushLiveTracking(tracking, er, fromTracking);
|
|
65
67
|
}
|
|
66
68
|
if (fromTracking.length)
|
|
67
69
|
return fromTracking;
|
|
@@ -70,6 +72,103 @@ export function extractLiveRows(data, game) {
|
|
|
70
72
|
}
|
|
71
73
|
return extractRows(data);
|
|
72
74
|
}
|
|
75
|
+
/** Machine emptyReason codes from API health / meta (or inferred). */
|
|
76
|
+
export function inferUfcEmptyReason(events, health, liveCount) {
|
|
77
|
+
if (liveCount > 0)
|
|
78
|
+
return undefined;
|
|
79
|
+
const fromHealth = pickString(health?.emptyReason);
|
|
80
|
+
if (fromHealth)
|
|
81
|
+
return fromHealth;
|
|
82
|
+
const pulses = events.map((ev) => asRecord(ev) ?? {});
|
|
83
|
+
if (!pulses.length) {
|
|
84
|
+
const alive = health?.workerAlive ?? health?.workerStarted;
|
|
85
|
+
if (alive === false)
|
|
86
|
+
return 'worker_down_inferred';
|
|
87
|
+
return 'idle_no_supervisor';
|
|
88
|
+
}
|
|
89
|
+
const statuses = pulses.map((p) => String(pickString(p.status, p.eventStatus) ?? '').toLowerCase());
|
|
90
|
+
const lags = pulses
|
|
91
|
+
.map((p) => (typeof p.lagSeconds === 'number' ? p.lagSeconds : null))
|
|
92
|
+
.filter((n) => n != null);
|
|
93
|
+
const freshest = lags.length ? Math.min(...lags) : null;
|
|
94
|
+
const workerAlive = health?.workerAlive ?? health?.workerStarted;
|
|
95
|
+
const hot = statuses.some((s) => ['live', 'warming', 'between_bouts', 'degraded'].includes(s));
|
|
96
|
+
if (workerAlive === false && (hot || (freshest != null && freshest > 120))) {
|
|
97
|
+
return 'worker_stale';
|
|
98
|
+
}
|
|
99
|
+
if (statuses.some((s) => s === 'degraded'))
|
|
100
|
+
return 'event_degraded';
|
|
101
|
+
if (statuses.some((s) => s === 'warming'))
|
|
102
|
+
return 'supervisor_warming';
|
|
103
|
+
if (statuses.some((s) => s === 'between_bouts'))
|
|
104
|
+
return 'between_bouts';
|
|
105
|
+
if (statuses.every((s) => s === 'completed'))
|
|
106
|
+
return 'not_fight_night';
|
|
107
|
+
if (hot)
|
|
108
|
+
return 'between_bouts';
|
|
109
|
+
return 'not_fight_night';
|
|
110
|
+
}
|
|
111
|
+
/** Human note for empty UFC live section — never invent matchups. */
|
|
112
|
+
export function humanUfcEmptyNote(emptyReason, health) {
|
|
113
|
+
const alive = health?.workerAlive ?? health?.workerStarted;
|
|
114
|
+
const lag = typeof health?.freshestHeartbeatLagSeconds === 'number'
|
|
115
|
+
? health.freshestHeartbeatLagSeconds
|
|
116
|
+
: typeof health?.lagSeconds === 'number'
|
|
117
|
+
? health.lagSeconds
|
|
118
|
+
: null;
|
|
119
|
+
const lagPart = typeof lag === 'number' ? ` lag=${lag}s` : '';
|
|
120
|
+
const workerPart = alive === true ? 'workerAlive=true' : alive === false ? 'workerAlive=false' : 'workerAlive=unknown';
|
|
121
|
+
const reason = emptyReason || 'unknown';
|
|
122
|
+
const human = {
|
|
123
|
+
not_fight_night: 'No UFC live card right now',
|
|
124
|
+
idle_no_supervisor: 'Supervisor has no live event rows',
|
|
125
|
+
supervisor_warming: 'Card open / pre-gate; no bout live yet',
|
|
126
|
+
between_bouts: 'Between bouts; next may be armed',
|
|
127
|
+
armed_only: 'Next bout armed; not started',
|
|
128
|
+
worker_stale: 'Worker heartbeat stale — check pm2 ufc-live on VPS 169',
|
|
129
|
+
worker_down_inferred: 'Worker appears down — check pm2 ufc-live on VPS 169',
|
|
130
|
+
event_degraded: 'Sources empty clock / event degraded; not inventing stats',
|
|
131
|
+
};
|
|
132
|
+
const msg = human[reason] || `No live|watching bouts (${reason})`;
|
|
133
|
+
return `UFC: ${msg} (${workerPart}${lagPart}; emptyReason=${reason})`;
|
|
134
|
+
}
|
|
135
|
+
function slimUfcSupervisorEvents(events, limit = 3) {
|
|
136
|
+
return events.slice(0, limit).map((ev) => {
|
|
137
|
+
const er = asRecord(ev) ?? {};
|
|
138
|
+
return {
|
|
139
|
+
kind: 'supervisor',
|
|
140
|
+
eventSlug: pickString(er.eventSlug, er.slug) ?? null,
|
|
141
|
+
status: pickString(er.status, er.eventStatus) ?? null,
|
|
142
|
+
currentLiveFmid: er.currentLiveFmid ?? null,
|
|
143
|
+
nextCandidateFmid: er.nextCandidateFmid ?? null,
|
|
144
|
+
lagSeconds: typeof er.lagSeconds === 'number' ? er.lagSeconds : null,
|
|
145
|
+
recommendedPollSeconds: typeof er.recommendedPollSeconds === 'number' ? er.recommendedPollSeconds : null,
|
|
146
|
+
degradedReason: pickString(er.degradedReason) ?? null,
|
|
147
|
+
note: 'Non-live supervisor shell — not a matchup',
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function slimUfcNextCard(payload) {
|
|
152
|
+
if (!payload)
|
|
153
|
+
return undefined;
|
|
154
|
+
const nextBouts = Array.isArray(payload.nextBouts) ? payload.nextBouts : [];
|
|
155
|
+
const first = asRecord(payload.nextArmedBout) ??
|
|
156
|
+
(nextBouts[0] ? asRecord(nextBouts[0]) : null);
|
|
157
|
+
if (!first)
|
|
158
|
+
return undefined;
|
|
159
|
+
return {
|
|
160
|
+
kind: 'next_armed',
|
|
161
|
+
status: pickString(first.status) ?? 'armed',
|
|
162
|
+
boutId: pickString(first.boutId, first.id) ?? null,
|
|
163
|
+
eventSlug: pickString(first.eventSlug) ?? null,
|
|
164
|
+
fightMetricId: first.fightMetricId ?? null,
|
|
165
|
+
// Names only if API already provided them — never invent.
|
|
166
|
+
red: first.red ?? null,
|
|
167
|
+
blue: first.blue ?? null,
|
|
168
|
+
degradedReason: pickString(first.degradedReason) ?? null,
|
|
169
|
+
note: 'Armed / up-next only — not currently live',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
73
172
|
export const liveMatches = {
|
|
74
173
|
name: 'live_matches',
|
|
75
174
|
description: `Live matches board across primary games, or a single game filter. Normalized labels, scores, and matchIds.
|
|
@@ -83,6 +182,7 @@ Prefer over: sequential per-game call_api live probes.
|
|
|
83
182
|
Do not use when: user wants upcoming fixtures → upcoming_schedule; historical results → match_summary.
|
|
84
183
|
|
|
85
184
|
CS2 live path is /cs2/live; UFC is included in multi-game fan-out.
|
|
185
|
+
UFC empty board: section.note + emptyReason + health (workerAlive/lag) + optional supervisor/nextCard (non-live); never fake match items from events[].
|
|
86
186
|
|
|
87
187
|
Parallel-safe: yes. Upstream cost: 1–5 (allSettled).
|
|
88
188
|
Example: { "game": "all", "limitPerGame": 10 }`,
|
|
@@ -127,6 +227,7 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
127
227
|
return { game, path, res };
|
|
128
228
|
}));
|
|
129
229
|
const sections = [];
|
|
230
|
+
const warnings = [];
|
|
130
231
|
for (const { game, res } of settled) {
|
|
131
232
|
upstreamCalls += 1;
|
|
132
233
|
rateLimit = { ...rateLimit, ...res.headers };
|
|
@@ -148,21 +249,26 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
148
249
|
}
|
|
149
250
|
// Prefer real match/bout rows over supervisor event shells (UFC /ufc/live).
|
|
150
251
|
let rows = extractLiveRows(res.data, game);
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
252
|
+
const root = asRecord(res.data);
|
|
253
|
+
const payload = asRecord(root?.data) ?? root;
|
|
254
|
+
const meta = asRecord(root?.meta);
|
|
255
|
+
if (rows.length === 0 && Array.isArray(root?.matches))
|
|
256
|
+
rows = root.matches;
|
|
154
257
|
const normalized = rows
|
|
155
258
|
.slice(0, limitPerGame)
|
|
156
259
|
.map((row) => normalizeMatch(game, row, 'live'))
|
|
157
|
-
// Drop hollow UFC
|
|
260
|
+
// Drop hollow UFC rows agents cannot use (placeholder labels / no sides).
|
|
261
|
+
// normalizeMatch already enriches label from eventSlug/weight/bout id when
|
|
262
|
+
// fighters are pending — keep those; drop pure "? vs ?" only.
|
|
158
263
|
.filter((m) => {
|
|
159
264
|
if (game !== 'ufc')
|
|
160
265
|
return true;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
266
|
+
const placeholder = /^\?\s*vs\s*\?$/i.test(m.label.trim());
|
|
267
|
+
if (placeholder)
|
|
268
|
+
return false;
|
|
269
|
+
if (m.team1?.name || m.team2?.name)
|
|
164
270
|
return true;
|
|
165
|
-
//
|
|
271
|
+
// Identity-only live row (enriched Bout {id} / event name) is ok
|
|
166
272
|
return Boolean(m.matchId && m.matchId !== 'unknown' && !String(m.matchId).startsWith('event'));
|
|
167
273
|
});
|
|
168
274
|
const items = labelsOnly
|
|
@@ -174,6 +280,44 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
174
280
|
startTime: m.startTime,
|
|
175
281
|
}))
|
|
176
282
|
: normalized;
|
|
283
|
+
if (game === 'ufc') {
|
|
284
|
+
const health = asRecord(meta?.health) ?? asRecord(payload?.health);
|
|
285
|
+
const events = Array.isArray(payload?.events) ? payload.events : [];
|
|
286
|
+
const emptyReason = normalized.length === 0
|
|
287
|
+
? pickString(meta?.emptyReason, health?.emptyReason) ??
|
|
288
|
+
inferUfcEmptyReason(events, health, normalized.length)
|
|
289
|
+
: undefined;
|
|
290
|
+
let note;
|
|
291
|
+
if (normalized.length === 0) {
|
|
292
|
+
note = humanUfcEmptyNote(emptyReason, health);
|
|
293
|
+
warnings.push(note);
|
|
294
|
+
}
|
|
295
|
+
const healthStrip = normalized.length === 0 || health?.workerAlive === false || health?.workerStarted === false
|
|
296
|
+
? {
|
|
297
|
+
workerStarted: health?.workerStarted ?? health?.workerAlive ?? null,
|
|
298
|
+
workerAlive: health?.workerAlive ?? health?.workerStarted ?? null,
|
|
299
|
+
freshestHeartbeatLagSeconds: health?.freshestHeartbeatLagSeconds ?? meta?.lagSeconds ?? null,
|
|
300
|
+
emptyReason: emptyReason ?? health?.emptyReason ?? null,
|
|
301
|
+
degradedReason: health?.degradedReason ?? null,
|
|
302
|
+
ok: health?.ok ?? null,
|
|
303
|
+
redis_ready: health?.redis_ready ?? meta?.redis_ready ?? meta?.redis_configured ?? null,
|
|
304
|
+
}
|
|
305
|
+
: undefined;
|
|
306
|
+
const supervisor = normalized.length === 0 && events.length ? slimUfcSupervisorEvents(events) : undefined;
|
|
307
|
+
const nextCard = normalized.length === 0 ? slimUfcNextCard(payload) : undefined;
|
|
308
|
+
sections.push({
|
|
309
|
+
game,
|
|
310
|
+
count: normalized.length,
|
|
311
|
+
ok: true,
|
|
312
|
+
note,
|
|
313
|
+
emptyReason,
|
|
314
|
+
health: healthStrip,
|
|
315
|
+
supervisor,
|
|
316
|
+
nextCard,
|
|
317
|
+
items,
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
177
321
|
sections.push({
|
|
178
322
|
game,
|
|
179
323
|
count: normalized.length,
|
|
@@ -182,6 +326,11 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
182
326
|
});
|
|
183
327
|
}
|
|
184
328
|
const totalLive = sections.reduce((sum, s) => sum + (s.ok ? s.count : 0), 0);
|
|
329
|
+
// Top-level note when single-game UFC empty so agents don't need deep section walk.
|
|
330
|
+
const ufcSection = sections.find((s) => s.game === 'ufc');
|
|
331
|
+
const dataNote = games.length === 1 && games[0] === 'ufc' && ufcSection && ufcSection.count === 0
|
|
332
|
+
? ufcSection.note
|
|
333
|
+
: undefined;
|
|
185
334
|
return successEnvelope({
|
|
186
335
|
source: 'live_matches',
|
|
187
336
|
game: games.length === 1 ? games[0] : null,
|
|
@@ -190,10 +339,12 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
190
339
|
upstreamCalls,
|
|
191
340
|
rateLimit,
|
|
192
341
|
partial: partial.length ? partial : undefined,
|
|
342
|
+
warnings: warnings.length ? warnings : undefined,
|
|
193
343
|
data: {
|
|
194
344
|
sections,
|
|
195
345
|
totalLive,
|
|
196
346
|
asOf: new Date().toISOString(),
|
|
347
|
+
...(dataNote ? { note: dataNote } : {}),
|
|
197
348
|
},
|
|
198
349
|
});
|
|
199
350
|
},
|
package/dist/tools/match.js
CHANGED
|
@@ -35,8 +35,55 @@ function matchCore(game, matchId, raw) {
|
|
|
35
35
|
event: m.event,
|
|
36
36
|
league: m.league,
|
|
37
37
|
method: pickString(r.method, r.resultMethod) ?? null,
|
|
38
|
-
winner:
|
|
38
|
+
winner: (() => {
|
|
39
|
+
// UFC rows carry an explicit winner slug/fighter ref.
|
|
40
|
+
const explicit = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner);
|
|
41
|
+
if (explicit)
|
|
42
|
+
return explicit;
|
|
43
|
+
if (game === 'ufc')
|
|
44
|
+
return null;
|
|
45
|
+
// CS2 rows expose winnerTeamId instead; map it to a side, else fall back to scores.
|
|
46
|
+
const sideLabel = (side) => side ? pickString(side.slug, side.id, side.name) ?? null : null;
|
|
47
|
+
const winnerTeamId = pickString(r.winnerTeamId, r.winner_team_id);
|
|
48
|
+
if (winnerTeamId) {
|
|
49
|
+
const t1Ids = [m.team1?.id, m.team1?.slug, pickString(r.team1Id, r.team1_id)].filter(Boolean).map(String);
|
|
50
|
+
const t2Ids = [m.team2?.id, m.team2?.slug, pickString(r.team2Id, r.team2_id)].filter(Boolean).map(String);
|
|
51
|
+
if (t1Ids.includes(winnerTeamId))
|
|
52
|
+
return sideLabel(m.team1);
|
|
53
|
+
if (t2Ids.includes(winnerTeamId))
|
|
54
|
+
return sideLabel(m.team2);
|
|
55
|
+
}
|
|
56
|
+
if (s1 != null && s2 != null && s1 !== s2)
|
|
57
|
+
return sideLabel(s1 > s2 ? m.team1 : m.team2);
|
|
58
|
+
return null;
|
|
59
|
+
})(),
|
|
39
60
|
weightClass: pickString(r.weightClass, r.division) ?? null,
|
|
61
|
+
referee: (() => {
|
|
62
|
+
const nested = asRecord(r.referee);
|
|
63
|
+
if (nested) {
|
|
64
|
+
const joined = [nested.firstName, nested.lastName].filter(Boolean).join(' ').trim();
|
|
65
|
+
const name = pickString(nested.name, joined || undefined) ?? null;
|
|
66
|
+
const id = pickString(nested.id, nested.refereeId) ?? null;
|
|
67
|
+
if (name || id) {
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
name,
|
|
71
|
+
firstName: pickString(nested.firstName) ?? null,
|
|
72
|
+
lastName: pickString(nested.lastName) ?? null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const name = pickString(r.refereeName) ?? null;
|
|
77
|
+
const id = pickString(r.refereeId) ?? null;
|
|
78
|
+
if (!name && !id)
|
|
79
|
+
return null;
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
name,
|
|
83
|
+
firstName: pickString(r.refereeFirstName) ?? null,
|
|
84
|
+
lastName: pickString(r.refereeLastName) ?? null,
|
|
85
|
+
};
|
|
86
|
+
})(),
|
|
40
87
|
rawStatus: pickString(r.status, r.state) ?? null,
|
|
41
88
|
};
|
|
42
89
|
}
|
|
@@ -56,18 +103,18 @@ function primaryPath(game, matchId) {
|
|
|
56
103
|
}
|
|
57
104
|
export const matchSummary = {
|
|
58
105
|
name: 'match_summary',
|
|
59
|
-
description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
|
|
60
|
-
|
|
61
|
-
When to use:
|
|
62
|
-
- Match recap / default match UI
|
|
63
|
-
- After user selects a live or completed matchId
|
|
64
|
-
|
|
65
|
-
Prefer over match_details for chat answers and default UIs.
|
|
66
|
-
Prefer match_details for timelines, full map trees, live state, advanced packages.
|
|
67
|
-
|
|
68
|
-
Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
|
|
69
|
-
|
|
70
|
-
Parallel-safe: yes. Upstream cost: 2–5.
|
|
106
|
+
description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
|
|
107
|
+
|
|
108
|
+
When to use:
|
|
109
|
+
- Match recap / default match UI
|
|
110
|
+
- After user selects a live or completed matchId
|
|
111
|
+
|
|
112
|
+
Prefer over match_details for chat answers and default UIs.
|
|
113
|
+
Prefer match_details for timelines, full map trees, live state, advanced packages.
|
|
114
|
+
|
|
115
|
+
Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
|
|
116
|
+
|
|
117
|
+
Parallel-safe: yes. Upstream cost: 2–5.
|
|
71
118
|
Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
|
|
72
119
|
inputSchema: {
|
|
73
120
|
type: 'object',
|
|
@@ -251,22 +298,22 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
251
298
|
};
|
|
252
299
|
export const matchDetails = {
|
|
253
300
|
name: 'match_details',
|
|
254
|
-
description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
|
|
255
|
-
|
|
256
|
-
When to use:
|
|
257
|
-
- Analyst deep dive
|
|
258
|
-
- Live in-game window (LoL/CS2/UFC)
|
|
259
|
-
- Full demo list
|
|
260
|
-
|
|
261
|
-
Prefer over match_summary only when summary is insufficient.
|
|
262
|
-
Prefer match_summary for short answers and default cards.
|
|
263
|
-
|
|
264
|
-
Do not use when: first-pass live board (use live_matches + match_summary).
|
|
265
|
-
|
|
266
|
-
Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
|
|
267
|
-
If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
|
|
268
|
-
|
|
269
|
-
Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
|
|
301
|
+
description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
|
|
302
|
+
|
|
303
|
+
When to use:
|
|
304
|
+
- Analyst deep dive
|
|
305
|
+
- Live in-game window (LoL/CS2/UFC)
|
|
306
|
+
- Full demo list
|
|
307
|
+
|
|
308
|
+
Prefer over match_summary only when summary is insufficient.
|
|
309
|
+
Prefer match_summary for short answers and default cards.
|
|
310
|
+
|
|
311
|
+
Do not use when: first-pass live board (use live_matches + match_summary).
|
|
312
|
+
|
|
313
|
+
Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
|
|
314
|
+
If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
|
|
315
|
+
|
|
316
|
+
Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
|
|
270
317
|
Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
|
|
271
318
|
inputSchema: {
|
|
272
319
|
type: 'object',
|
package/dist/tools/meta.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { extractRows, fetchJson, gameNotIncludedHint, present } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { boolSchema, gameSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
|
|
7
|
-
|
|
7
|
+
import { PACKAGE_VERSION as CATALOG_VERSION } from '../version.js';
|
|
8
8
|
const TOOL_CATALOG = [
|
|
9
9
|
{
|
|
10
10
|
name: 'list_capabilities',
|
package/dist/tools/normalize.js
CHANGED
|
@@ -46,6 +46,18 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
46
46
|
sideFrom(pickString(r.team1Name, r.team_a_name, r.redName, r.fighter1Name, r.homeName), pickString(r.team1Id, r.team1_id, r.redId, r.fighter1Id), pickString(r.team1Slug, r.redSlug, r.fighter1Slug), scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore));
|
|
47
47
|
let team2 = nestedSide(r.team2) ??
|
|
48
48
|
sideFrom(pickString(r.team2Name, r.team_b_name, r.blueName, r.fighter2Name, r.awayName), pickString(r.team2Id, r.team2_id, r.blueId, r.fighter2Id), pickString(r.team2Slug, r.blueSlug, r.fighter2Slug), scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore));
|
|
49
|
+
// CS2 rows nest team1/team2 objects that lack a score; a nested side must not
|
|
50
|
+
// shadow the flat score fields (team1Score/score1/team1Maps) with score:null.
|
|
51
|
+
if (team1 && team1.score == null) {
|
|
52
|
+
const s = scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore);
|
|
53
|
+
if (s != null)
|
|
54
|
+
team1 = { ...team1, score: s };
|
|
55
|
+
}
|
|
56
|
+
if (team2 && team2.score == null) {
|
|
57
|
+
const s = scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore);
|
|
58
|
+
if (s != null)
|
|
59
|
+
team2 = { ...team2, score: s };
|
|
60
|
+
}
|
|
49
61
|
// UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
|
|
50
62
|
// Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
|
|
51
63
|
// Live tracking uses red/blue + fighters[] (not team1/team2).
|
|
@@ -151,9 +163,12 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
151
163
|
const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
|
|
152
164
|
// Prefer explicit title/label when present, but never keep the placeholder
|
|
153
165
|
// "? vs ?" once fighter/team names were resolved (UFC fighters[] / red-blue).
|
|
166
|
+
// When both sides lack names, prefer event / weight / bout id over opaque "? vs ?"
|
|
167
|
+
// so agent boards (live_matches, upcoming_schedule, event_card) stay legible.
|
|
154
168
|
const vsLabel = `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
|
|
155
169
|
const explicit = pickString(r.label, r.title, r.name);
|
|
156
170
|
const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
|
|
171
|
+
const weightOrClass = pickString(r.weightClass, r.division, r.weight_class, r.boutClass);
|
|
157
172
|
let label;
|
|
158
173
|
if (explicit && !explicitIsPlaceholder) {
|
|
159
174
|
label = explicit;
|
|
@@ -162,7 +177,11 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
162
177
|
label = vsLabel;
|
|
163
178
|
}
|
|
164
179
|
else {
|
|
165
|
-
|
|
180
|
+
// Identity fallbacks — never invent fighter names, but avoid bare "? vs ?"
|
|
181
|
+
label =
|
|
182
|
+
pickString(eventName, eventSlug, weightOrClass) ??
|
|
183
|
+
(matchId && matchId !== 'unknown' ? `Bout ${matchId}` : null) ??
|
|
184
|
+
vsLabel;
|
|
166
185
|
}
|
|
167
186
|
return {
|
|
168
187
|
game,
|
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/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