cito-mcp 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +396 -44
- package/dist/client.js +208 -0
- package/dist/envelope.js +210 -0
- package/dist/index.js +103 -201
- package/dist/instructions.js +80 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/insight.js +1013 -0
- package/dist/tools/live.js +447 -0
- package/dist/tools/match.js +464 -0
- package/dist/tools/meta.js +609 -0
- package/dist/tools/normalize.js +177 -0
- package/dist/tools/player.js +357 -0
- package/dist/tools/resolve.js +518 -0
- package/dist/tools/standings.js +319 -0
- package/dist/tools/team.js +704 -0
- package/dist/tools/types.js +85 -0
- package/package.json +3 -3
- package/dist/executor.js +0 -75
- package/dist/spec.js +0 -193
- package/dist/tools.js +0 -203
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* team_profile + head_to_head
|
|
3
|
+
*/
|
|
4
|
+
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
|
|
5
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
|
+
import { normalizeMatch } from './normalize.js';
|
|
7
|
+
import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
8
|
+
function teamIdentity(game, raw, idHint, slugHint) {
|
|
9
|
+
const r = asRecord(raw) ?? {};
|
|
10
|
+
const nested = asRecord(r.data) ?? r;
|
|
11
|
+
const id = pickString(nested.id, nested.teamId, idHint, slugHint) ?? 'unknown';
|
|
12
|
+
const slug = pickString(nested.slug, nested.orgSlug, slugHint);
|
|
13
|
+
const name = pickString(nested.name, nested.tag, nested.code, slug, id) ?? id;
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
slug,
|
|
17
|
+
name,
|
|
18
|
+
game,
|
|
19
|
+
region: pickString(nested.region, nested.country) ?? null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export const teamProfile = {
|
|
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.
|
|
36
|
+
Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
required: ['game'],
|
|
41
|
+
properties: {
|
|
42
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
43
|
+
teamId: stringSchema('Team id. Prefer for cs2. Pass teamId and/or slug (at least one required). Either works for dota2.'),
|
|
44
|
+
slug: stringSchema('Team/org slug. Prefer for lol and cod orgs. Pass teamId and/or slug (at least one required). Example: "t1".', 't1'),
|
|
45
|
+
recentLimit: limitSchema({ default: 10, max: 25 }),
|
|
46
|
+
includeFormer: boolSchema('COD roster former players.', false),
|
|
47
|
+
view: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
enum: ['summary', 'full'],
|
|
50
|
+
default: 'summary',
|
|
51
|
+
description: 'Payload weight. Default summary.',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
handler: async (args, ctx) => {
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
const requestId = newRequestId();
|
|
58
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
59
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
60
|
+
return errorEnvelope({
|
|
61
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
62
|
+
message: gameParse.error ?? 'game is required',
|
|
63
|
+
game: null,
|
|
64
|
+
source: 'team_profile',
|
|
65
|
+
requestId,
|
|
66
|
+
tookMs: Date.now() - started,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const game = gameParse.game;
|
|
70
|
+
if (game === 'ufc') {
|
|
71
|
+
return errorEnvelope({
|
|
72
|
+
code: 'NOT_IMPLEMENTED',
|
|
73
|
+
message: 'UFC has fighters, not team profiles',
|
|
74
|
+
game,
|
|
75
|
+
source: 'team_profile',
|
|
76
|
+
requestId,
|
|
77
|
+
tookMs: Date.now() - started,
|
|
78
|
+
recover: [
|
|
79
|
+
'Use player_profile with fighter slug',
|
|
80
|
+
'Use standings with scope=division for rankings',
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const teamId = typeof args.teamId === 'string' ? args.teamId.trim() : '';
|
|
85
|
+
const slug = typeof args.slug === 'string' ? args.slug.trim() : '';
|
|
86
|
+
if (!teamId && !slug) {
|
|
87
|
+
return errorEnvelope({
|
|
88
|
+
code: 'VALIDATION',
|
|
89
|
+
message: 'Pass teamId and/or slug (at least one required)',
|
|
90
|
+
game,
|
|
91
|
+
source: 'team_profile',
|
|
92
|
+
requestId,
|
|
93
|
+
tookMs: Date.now() - started,
|
|
94
|
+
recover: [
|
|
95
|
+
'Call resolve_entity with the team name and type=team',
|
|
96
|
+
'Retry team_profile with the returned id or slug',
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const idOrSlug = teamId || slug;
|
|
101
|
+
const recentLimit = clampInt(args.recentLimit, 10, 1, 25);
|
|
102
|
+
const partial = [];
|
|
103
|
+
let upstreamCalls = 0;
|
|
104
|
+
let rateLimit = {};
|
|
105
|
+
let teamRaw = null;
|
|
106
|
+
if (game === 'lol') {
|
|
107
|
+
const res = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(slug || idOrSlug)}`);
|
|
108
|
+
upstreamCalls += 1;
|
|
109
|
+
rateLimit = res.headers;
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
return errorEnvelope({
|
|
112
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
113
|
+
message: `Team not found: ${idOrSlug}`,
|
|
114
|
+
game,
|
|
115
|
+
source: 'team_profile',
|
|
116
|
+
requestId,
|
|
117
|
+
tookMs: Date.now() - started,
|
|
118
|
+
upstreamCalls,
|
|
119
|
+
httpStatus: res.status,
|
|
120
|
+
rateLimit: res.headers,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
teamRaw = res.data;
|
|
124
|
+
}
|
|
125
|
+
else if (game === 'cs2') {
|
|
126
|
+
const res = await fetchJson(ctx, '/cs2/teams', { query: { search: idOrSlug, limit: 5 } });
|
|
127
|
+
upstreamCalls += 1;
|
|
128
|
+
rateLimit = res.headers;
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
return errorEnvelope({
|
|
131
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
132
|
+
message: `Team not found: ${idOrSlug}`,
|
|
133
|
+
game,
|
|
134
|
+
source: 'team_profile',
|
|
135
|
+
requestId,
|
|
136
|
+
tookMs: Date.now() - started,
|
|
137
|
+
upstreamCalls,
|
|
138
|
+
httpStatus: res.status,
|
|
139
|
+
rateLimit: res.headers,
|
|
140
|
+
recover: [
|
|
141
|
+
'Call resolve_entity with the team name and type=team',
|
|
142
|
+
'Retry team_profile with the returned id/slug',
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
{
|
|
147
|
+
const rows = extractRows(res.data);
|
|
148
|
+
teamRaw =
|
|
149
|
+
rows.find((row) => {
|
|
150
|
+
const r = asRecord(row) ?? {};
|
|
151
|
+
return [r.id, r.slug, r.name].map((x) => String(x ?? '').toLowerCase()).includes(idOrSlug.toLowerCase());
|
|
152
|
+
}) ?? rows[0];
|
|
153
|
+
if (!teamRaw) {
|
|
154
|
+
return errorEnvelope({
|
|
155
|
+
code: 'NOT_FOUND',
|
|
156
|
+
message: `Team not found: ${idOrSlug}`,
|
|
157
|
+
game,
|
|
158
|
+
source: 'team_profile',
|
|
159
|
+
requestId,
|
|
160
|
+
tookMs: Date.now() - started,
|
|
161
|
+
upstreamCalls,
|
|
162
|
+
rateLimit: res.headers,
|
|
163
|
+
recover: [
|
|
164
|
+
'Call resolve_entity with the team name and type=team',
|
|
165
|
+
'Retry team_profile with the returned id/slug',
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (game === 'dota2') {
|
|
172
|
+
const res = await fetchJson(ctx, '/dota2/teams', { query: { search: idOrSlug, limit: 5 } });
|
|
173
|
+
upstreamCalls += 1;
|
|
174
|
+
rateLimit = res.headers;
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
return errorEnvelope({
|
|
177
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
178
|
+
message: `Team not found: ${idOrSlug}`,
|
|
179
|
+
game,
|
|
180
|
+
source: 'team_profile',
|
|
181
|
+
requestId,
|
|
182
|
+
tookMs: Date.now() - started,
|
|
183
|
+
upstreamCalls,
|
|
184
|
+
httpStatus: res.status,
|
|
185
|
+
rateLimit: res.headers,
|
|
186
|
+
recover: [
|
|
187
|
+
'Call resolve_entity with the team name and type=team',
|
|
188
|
+
'Retry team_profile with the returned id/slug',
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
{
|
|
193
|
+
const rows = extractRows(res.data);
|
|
194
|
+
teamRaw =
|
|
195
|
+
rows.find((row) => {
|
|
196
|
+
const r = asRecord(row) ?? {};
|
|
197
|
+
return [r.id, r.slug, r.name].map((x) => String(x ?? '').toLowerCase()).includes(idOrSlug.toLowerCase());
|
|
198
|
+
}) ?? rows[0];
|
|
199
|
+
if (!teamRaw) {
|
|
200
|
+
return errorEnvelope({
|
|
201
|
+
code: 'NOT_FOUND',
|
|
202
|
+
message: `Team not found: ${idOrSlug}`,
|
|
203
|
+
game,
|
|
204
|
+
source: 'team_profile',
|
|
205
|
+
requestId,
|
|
206
|
+
tookMs: Date.now() - started,
|
|
207
|
+
upstreamCalls,
|
|
208
|
+
rateLimit: res.headers,
|
|
209
|
+
recover: [
|
|
210
|
+
'Call resolve_entity with the team name and type=team',
|
|
211
|
+
'Retry team_profile with the returned id/slug',
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else if (game === 'cod') {
|
|
218
|
+
const res = await fetchJson(ctx, `/cod/orgs/${encodeURIComponent(slug || idOrSlug)}`);
|
|
219
|
+
upstreamCalls += 1;
|
|
220
|
+
rateLimit = res.headers;
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
return errorEnvelope({
|
|
223
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
224
|
+
message: `Org not found: ${idOrSlug}`,
|
|
225
|
+
game,
|
|
226
|
+
source: 'team_profile',
|
|
227
|
+
requestId,
|
|
228
|
+
tookMs: Date.now() - started,
|
|
229
|
+
upstreamCalls,
|
|
230
|
+
httpStatus: res.status,
|
|
231
|
+
rateLimit: res.headers,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
teamRaw = res.data;
|
|
235
|
+
}
|
|
236
|
+
const team = teamIdentity(game, teamRaw, teamId || undefined, slug || undefined);
|
|
237
|
+
let roster = {
|
|
238
|
+
items: [],
|
|
239
|
+
asOf: new Date().toISOString(),
|
|
240
|
+
quality: 'unknown',
|
|
241
|
+
};
|
|
242
|
+
let recentMatches = [];
|
|
243
|
+
let form = null;
|
|
244
|
+
const tasks = [];
|
|
245
|
+
if (game === 'lol') {
|
|
246
|
+
tasks.push((async () => {
|
|
247
|
+
const res = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(team.slug || idOrSlug)}/roster`);
|
|
248
|
+
upstreamCalls += 1;
|
|
249
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
250
|
+
if (res.ok) {
|
|
251
|
+
roster = {
|
|
252
|
+
items: extractRows(res.data),
|
|
253
|
+
asOf: new Date().toISOString(),
|
|
254
|
+
quality: 'current',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
partial.push(partialFromRejection('roster', {
|
|
259
|
+
code: mapHttpToCode(res.status),
|
|
260
|
+
message: `roster HTTP ${res.status}`,
|
|
261
|
+
httpStatus: res.status,
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
})());
|
|
265
|
+
tasks.push((async () => {
|
|
266
|
+
const res = await fetchJson(ctx, '/lol/schedule', {
|
|
267
|
+
query: { teamSlug: team.slug || idOrSlug, completed: 'true', limit: String(recentLimit) },
|
|
268
|
+
});
|
|
269
|
+
upstreamCalls += 1;
|
|
270
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
271
|
+
if (res.ok) {
|
|
272
|
+
recentMatches = extractRows(res.data)
|
|
273
|
+
.slice(0, recentLimit)
|
|
274
|
+
.map((row) => normalizeMatch('lol', row, 'completed'));
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
partial.push(partialFromRejection('recentMatches', {
|
|
278
|
+
code: mapHttpToCode(res.status),
|
|
279
|
+
message: `schedule HTTP ${res.status}`,
|
|
280
|
+
httpStatus: res.status,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
})());
|
|
284
|
+
}
|
|
285
|
+
if (game === 'cs2') {
|
|
286
|
+
const tid = team.id !== 'unknown' ? team.id : idOrSlug;
|
|
287
|
+
tasks.push((async () => {
|
|
288
|
+
const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(tid)}/roster-history`);
|
|
289
|
+
upstreamCalls += 1;
|
|
290
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
291
|
+
if (res.ok) {
|
|
292
|
+
roster = {
|
|
293
|
+
items: extractRows(res.data).slice(0, 20),
|
|
294
|
+
asOf: new Date().toISOString(),
|
|
295
|
+
quality: 'history',
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
partial.push(partialFromRejection('roster', {
|
|
300
|
+
code: mapHttpToCode(res.status),
|
|
301
|
+
message: `roster-history HTTP ${res.status}`,
|
|
302
|
+
httpStatus: res.status,
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
})());
|
|
306
|
+
tasks.push((async () => {
|
|
307
|
+
const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(tid)}/trends`);
|
|
308
|
+
upstreamCalls += 1;
|
|
309
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
310
|
+
if (res.ok)
|
|
311
|
+
form = res.data;
|
|
312
|
+
else {
|
|
313
|
+
partial.push(partialFromRejection('form', {
|
|
314
|
+
code: mapHttpToCode(res.status),
|
|
315
|
+
message: `trends HTTP ${res.status}`,
|
|
316
|
+
httpStatus: res.status,
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
})());
|
|
320
|
+
tasks.push((async () => {
|
|
321
|
+
const res = await fetchJson(ctx, '/cs2/matches', {
|
|
322
|
+
query: { team: tid, limit: recentLimit, status: 'completed' },
|
|
323
|
+
});
|
|
324
|
+
upstreamCalls += 1;
|
|
325
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
326
|
+
if (res.ok) {
|
|
327
|
+
recentMatches = extractRows(res.data)
|
|
328
|
+
.slice(0, recentLimit)
|
|
329
|
+
.map((row) => normalizeMatch('cs2', row, 'completed'));
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
partial.push(partialFromRejection('recentMatches', {
|
|
333
|
+
code: mapHttpToCode(res.status),
|
|
334
|
+
message: `matches HTTP ${res.status}`,
|
|
335
|
+
httpStatus: res.status,
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
})());
|
|
339
|
+
}
|
|
340
|
+
if (game === 'dota2') {
|
|
341
|
+
roster = { items: [], asOf: new Date().toISOString(), quality: 'unavailable' };
|
|
342
|
+
partial.push(partialFromRejection('roster', {
|
|
343
|
+
code: 'NOT_IMPLEMENTED',
|
|
344
|
+
message: 'Dota 2 has no dedicated roster endpoint',
|
|
345
|
+
recover: ['Use team radar and recent matches', 'Call call_api only if a new roster path ships'],
|
|
346
|
+
}));
|
|
347
|
+
tasks.push((async () => {
|
|
348
|
+
const res = await fetchJson(ctx, `/dota2/teams/${encodeURIComponent(idOrSlug)}/radar`);
|
|
349
|
+
upstreamCalls += 1;
|
|
350
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
351
|
+
if (res.ok)
|
|
352
|
+
form = { radar: res.data };
|
|
353
|
+
else {
|
|
354
|
+
partial.push(partialFromRejection('form', {
|
|
355
|
+
code: mapHttpToCode(res.status),
|
|
356
|
+
message: `radar HTTP ${res.status}`,
|
|
357
|
+
httpStatus: res.status,
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
})());
|
|
361
|
+
tasks.push((async () => {
|
|
362
|
+
const res = await fetchJson(ctx, '/dota2/matches/recent', { query: { limit: recentLimit } });
|
|
363
|
+
upstreamCalls += 1;
|
|
364
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
365
|
+
if (res.ok) {
|
|
366
|
+
const t = idOrSlug.toLowerCase();
|
|
367
|
+
recentMatches = extractRows(res.data)
|
|
368
|
+
.map((row) => normalizeMatch('dota2', row, 'completed'))
|
|
369
|
+
.filter((m) => {
|
|
370
|
+
const hay = [m.team1?.id, m.team1?.name, m.team2?.id, m.team2?.name]
|
|
371
|
+
.filter(Boolean)
|
|
372
|
+
.join(' ')
|
|
373
|
+
.toLowerCase();
|
|
374
|
+
return hay.includes(t);
|
|
375
|
+
})
|
|
376
|
+
.slice(0, recentLimit);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
partial.push(partialFromRejection('recentMatches', {
|
|
380
|
+
code: mapHttpToCode(res.status),
|
|
381
|
+
message: `recent matches HTTP ${res.status}`,
|
|
382
|
+
httpStatus: res.status,
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
})());
|
|
386
|
+
}
|
|
387
|
+
if (game === 'cod') {
|
|
388
|
+
tasks.push((async () => {
|
|
389
|
+
const res = await fetchJson(ctx, `/cod/orgs/${encodeURIComponent(team.slug || idOrSlug)}/roster`, {
|
|
390
|
+
query: { includeFormer: args.includeFormer === true },
|
|
391
|
+
});
|
|
392
|
+
upstreamCalls += 1;
|
|
393
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
394
|
+
if (res.ok) {
|
|
395
|
+
roster = {
|
|
396
|
+
items: extractRows(res.data),
|
|
397
|
+
asOf: new Date().toISOString(),
|
|
398
|
+
quality: 'current',
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
partial.push(partialFromRejection('roster', {
|
|
403
|
+
code: mapHttpToCode(res.status),
|
|
404
|
+
message: `roster HTTP ${res.status}`,
|
|
405
|
+
httpStatus: res.status,
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
})());
|
|
409
|
+
tasks.push((async () => {
|
|
410
|
+
const res = await fetchJson(ctx, '/cod/matches', {
|
|
411
|
+
query: { team: team.slug || idOrSlug, limit: recentLimit },
|
|
412
|
+
});
|
|
413
|
+
upstreamCalls += 1;
|
|
414
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
415
|
+
if (res.ok) {
|
|
416
|
+
recentMatches = extractRows(res.data)
|
|
417
|
+
.slice(0, recentLimit)
|
|
418
|
+
.map((row) => normalizeMatch('cod', row));
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
partial.push(partialFromRejection('recentMatches', {
|
|
422
|
+
code: mapHttpToCode(res.status),
|
|
423
|
+
message: `matches HTTP ${res.status}`,
|
|
424
|
+
httpStatus: res.status,
|
|
425
|
+
}));
|
|
426
|
+
}
|
|
427
|
+
})());
|
|
428
|
+
}
|
|
429
|
+
await Promise.all(tasks);
|
|
430
|
+
return successEnvelope({
|
|
431
|
+
source: 'team_profile',
|
|
432
|
+
game,
|
|
433
|
+
requestId,
|
|
434
|
+
tookMs: Date.now() - started,
|
|
435
|
+
upstreamCalls,
|
|
436
|
+
rateLimit,
|
|
437
|
+
partial: partial.length ? partial : undefined,
|
|
438
|
+
entities: {
|
|
439
|
+
games: [game],
|
|
440
|
+
ids: {
|
|
441
|
+
teamId: team.id,
|
|
442
|
+
...(team.slug ? { teamSlug: team.slug } : {}),
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
data: {
|
|
446
|
+
team,
|
|
447
|
+
roster,
|
|
448
|
+
recentMatches,
|
|
449
|
+
form,
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
function sidesMatch(match, a, b) {
|
|
455
|
+
const al = a.toLowerCase();
|
|
456
|
+
const bl = b.toLowerCase();
|
|
457
|
+
const s1 = [match.team1?.id, match.team1?.slug, match.team1?.name].filter(Boolean).map((x) => String(x).toLowerCase());
|
|
458
|
+
const s2 = [match.team2?.id, match.team2?.slug, match.team2?.name].filter(Boolean).map((x) => String(x).toLowerCase());
|
|
459
|
+
const hasA1 = s1.some((x) => x.includes(al) || al.includes(x));
|
|
460
|
+
const hasB1 = s1.some((x) => x.includes(bl) || bl.includes(x));
|
|
461
|
+
const hasA2 = s2.some((x) => x.includes(al) || al.includes(x));
|
|
462
|
+
const hasB2 = s2.some((x) => x.includes(bl) || bl.includes(x));
|
|
463
|
+
return (hasA1 && hasB2) || (hasB1 && hasA2);
|
|
464
|
+
}
|
|
465
|
+
function winnerSide(match, a) {
|
|
466
|
+
const s1 = match.team1?.score;
|
|
467
|
+
const s2 = match.team2?.score;
|
|
468
|
+
if (s1 == null || s2 == null)
|
|
469
|
+
return 'unknown';
|
|
470
|
+
if (s1 === s2)
|
|
471
|
+
return 'draw';
|
|
472
|
+
const al = a.toLowerCase();
|
|
473
|
+
const t1 = [match.team1?.id, match.team1?.slug, match.team1?.name].filter(Boolean).map((x) => String(x).toLowerCase());
|
|
474
|
+
const aIsTeam1 = t1.some((x) => x.includes(al) || al.includes(x));
|
|
475
|
+
if (s1 > s2)
|
|
476
|
+
return aIsTeam1 ? 'A' : 'B';
|
|
477
|
+
return aIsTeam1 ? 'B' : 'A';
|
|
478
|
+
}
|
|
479
|
+
export const headToHead = {
|
|
480
|
+
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.
|
|
494
|
+
Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
495
|
+
inputSchema: {
|
|
496
|
+
type: 'object',
|
|
497
|
+
additionalProperties: false,
|
|
498
|
+
required: ['game', 'sideA', 'sideB'],
|
|
499
|
+
properties: {
|
|
500
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
501
|
+
entityType: {
|
|
502
|
+
type: 'string',
|
|
503
|
+
enum: ['team', 'fighter'],
|
|
504
|
+
default: 'team',
|
|
505
|
+
description: 'team (default) or fighter (UFC).',
|
|
506
|
+
},
|
|
507
|
+
sideA: stringSchema('Id or slug for side A.', 'faze'),
|
|
508
|
+
sideB: stringSchema('Id or slug for side B.', 'navi'),
|
|
509
|
+
limit: limitSchema({ default: 20, max: 50, description: 'Max meetings returned.' }),
|
|
510
|
+
from: stringSchema('ISO-8601 lower bound on meeting startTime (client filter).'),
|
|
511
|
+
to: stringSchema('ISO-8601 upper bound on meeting startTime (client filter).'),
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
handler: async (args, ctx) => {
|
|
515
|
+
const started = Date.now();
|
|
516
|
+
const requestId = newRequestId();
|
|
517
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
518
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
519
|
+
return errorEnvelope({
|
|
520
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
521
|
+
message: gameParse.error ?? 'game is required',
|
|
522
|
+
game: null,
|
|
523
|
+
source: 'head_to_head',
|
|
524
|
+
requestId,
|
|
525
|
+
tookMs: Date.now() - started,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const game = gameParse.game;
|
|
529
|
+
const sideA = typeof args.sideA === 'string' ? args.sideA.trim() : '';
|
|
530
|
+
const sideB = typeof args.sideB === 'string' ? args.sideB.trim() : '';
|
|
531
|
+
if (!sideA || !sideB) {
|
|
532
|
+
return errorEnvelope({
|
|
533
|
+
code: 'VALIDATION',
|
|
534
|
+
message: 'sideA and sideB are required',
|
|
535
|
+
game,
|
|
536
|
+
source: 'head_to_head',
|
|
537
|
+
requestId,
|
|
538
|
+
tookMs: Date.now() - started,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
const limit = clampInt(args.limit, 20, 1, 50);
|
|
542
|
+
const fromIso = typeof args.from === 'string' ? args.from : undefined;
|
|
543
|
+
const toIso = typeof args.to === 'string' ? args.to : undefined;
|
|
544
|
+
const fromMs = fromIso ? Date.parse(fromIso) : NaN;
|
|
545
|
+
const toMs = toIso ? Date.parse(toIso) : NaN;
|
|
546
|
+
const warnings = [];
|
|
547
|
+
let upstreamCalls = 0;
|
|
548
|
+
let rateLimit = {};
|
|
549
|
+
let rows = [];
|
|
550
|
+
if (game === 'cs2') {
|
|
551
|
+
const res = await fetchJson(ctx, '/cs2/matches', { query: { team: sideA, limit: 50 } });
|
|
552
|
+
upstreamCalls += 1;
|
|
553
|
+
rateLimit = res.headers;
|
|
554
|
+
if (!res.ok) {
|
|
555
|
+
return errorEnvelope({
|
|
556
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
557
|
+
message: `H2H match fetch failed (HTTP ${res.status})`,
|
|
558
|
+
game,
|
|
559
|
+
source: 'head_to_head',
|
|
560
|
+
requestId,
|
|
561
|
+
tookMs: Date.now() - started,
|
|
562
|
+
upstreamCalls,
|
|
563
|
+
httpStatus: res.status,
|
|
564
|
+
rateLimit: res.headers,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
rows = extractRows(res.data);
|
|
568
|
+
}
|
|
569
|
+
else if (game === 'cod') {
|
|
570
|
+
const res = await fetchJson(ctx, '/cod/matches', { query: { team: sideA, limit: 50 } });
|
|
571
|
+
upstreamCalls += 1;
|
|
572
|
+
rateLimit = res.headers;
|
|
573
|
+
if (!res.ok) {
|
|
574
|
+
return errorEnvelope({
|
|
575
|
+
code: mapHttpToCode(res.status),
|
|
576
|
+
message: `H2H match fetch failed (HTTP ${res.status})`,
|
|
577
|
+
game,
|
|
578
|
+
source: 'head_to_head',
|
|
579
|
+
requestId,
|
|
580
|
+
tookMs: Date.now() - started,
|
|
581
|
+
upstreamCalls,
|
|
582
|
+
httpStatus: res.status,
|
|
583
|
+
rateLimit: res.headers,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
rows = extractRows(res.data);
|
|
587
|
+
}
|
|
588
|
+
else if (game === 'lol') {
|
|
589
|
+
const res = await fetchJson(ctx, '/lol/schedule', {
|
|
590
|
+
query: { teamSlug: sideA, completed: 'true', limit: '50' },
|
|
591
|
+
});
|
|
592
|
+
upstreamCalls += 1;
|
|
593
|
+
rateLimit = res.headers;
|
|
594
|
+
if (!res.ok) {
|
|
595
|
+
return errorEnvelope({
|
|
596
|
+
code: mapHttpToCode(res.status),
|
|
597
|
+
message: `H2H schedule fetch failed (HTTP ${res.status})`,
|
|
598
|
+
game,
|
|
599
|
+
source: 'head_to_head',
|
|
600
|
+
requestId,
|
|
601
|
+
tookMs: Date.now() - started,
|
|
602
|
+
upstreamCalls,
|
|
603
|
+
httpStatus: res.status,
|
|
604
|
+
rateLimit: res.headers,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
rows = extractRows(res.data);
|
|
608
|
+
}
|
|
609
|
+
else if (game === 'dota2') {
|
|
610
|
+
warnings.push('Dota H2H uses recent matches with weak filters; record may be incomplete');
|
|
611
|
+
const res = await fetchJson(ctx, '/dota2/matches/recent', { query: { limit: 50 } });
|
|
612
|
+
upstreamCalls += 1;
|
|
613
|
+
rateLimit = res.headers;
|
|
614
|
+
if (!res.ok) {
|
|
615
|
+
return errorEnvelope({
|
|
616
|
+
code: mapHttpToCode(res.status),
|
|
617
|
+
message: `H2H recent fetch failed (HTTP ${res.status})`,
|
|
618
|
+
game,
|
|
619
|
+
source: 'head_to_head',
|
|
620
|
+
requestId,
|
|
621
|
+
tookMs: Date.now() - started,
|
|
622
|
+
upstreamCalls,
|
|
623
|
+
httpStatus: res.status,
|
|
624
|
+
rateLimit: res.headers,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
rows = extractRows(res.data);
|
|
628
|
+
}
|
|
629
|
+
else if (game === 'ufc') {
|
|
630
|
+
// try fighter stats + bouts list
|
|
631
|
+
const [aRes, bRes, boutsRes] = await Promise.all([
|
|
632
|
+
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideA)}`),
|
|
633
|
+
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideB)}`),
|
|
634
|
+
fetchJson(ctx, '/ufc/bouts', { query: { limit: 50, page: 1 } }),
|
|
635
|
+
]);
|
|
636
|
+
upstreamCalls += 3;
|
|
637
|
+
rateLimit = { ...aRes.headers, ...bRes.headers, ...boutsRes.headers };
|
|
638
|
+
if (!aRes.ok || !bRes.ok) {
|
|
639
|
+
return errorEnvelope({
|
|
640
|
+
code: 'NOT_FOUND',
|
|
641
|
+
message: 'One or both fighters not found',
|
|
642
|
+
game,
|
|
643
|
+
source: 'head_to_head',
|
|
644
|
+
requestId,
|
|
645
|
+
tookMs: Date.now() - started,
|
|
646
|
+
upstreamCalls,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
rows = boutsRes.ok ? extractRows(boutsRes.data) : [];
|
|
650
|
+
}
|
|
651
|
+
const meetings = rows
|
|
652
|
+
.map((row) => normalizeMatch(game, row))
|
|
653
|
+
.filter((m) => sidesMatch(m, sideA, sideB))
|
|
654
|
+
.filter((m) => {
|
|
655
|
+
if (!fromIso && !toIso)
|
|
656
|
+
return true;
|
|
657
|
+
if (!m.startTime)
|
|
658
|
+
return true; // keep undated rows; date filter is best-effort
|
|
659
|
+
const t = Date.parse(m.startTime);
|
|
660
|
+
if (Number.isNaN(t))
|
|
661
|
+
return true;
|
|
662
|
+
if (!Number.isNaN(fromMs) && t < fromMs)
|
|
663
|
+
return false;
|
|
664
|
+
if (!Number.isNaN(toMs) && t > toMs)
|
|
665
|
+
return false;
|
|
666
|
+
return true;
|
|
667
|
+
})
|
|
668
|
+
.slice(0, limit);
|
|
669
|
+
let winsA = 0;
|
|
670
|
+
let winsB = 0;
|
|
671
|
+
let draws = 0;
|
|
672
|
+
for (const m of meetings) {
|
|
673
|
+
const w = winnerSide(m, sideA);
|
|
674
|
+
if (w === 'A')
|
|
675
|
+
winsA += 1;
|
|
676
|
+
else if (w === 'B')
|
|
677
|
+
winsB += 1;
|
|
678
|
+
else if (w === 'draw')
|
|
679
|
+
draws += 1;
|
|
680
|
+
}
|
|
681
|
+
return successEnvelope({
|
|
682
|
+
source: 'head_to_head',
|
|
683
|
+
game,
|
|
684
|
+
requestId,
|
|
685
|
+
tookMs: Date.now() - started,
|
|
686
|
+
upstreamCalls,
|
|
687
|
+
rateLimit,
|
|
688
|
+
warnings: warnings.length ? warnings : undefined,
|
|
689
|
+
data: {
|
|
690
|
+
sideA: { idOrSlug: sideA },
|
|
691
|
+
sideB: { idOrSlug: sideB },
|
|
692
|
+
record: { winsA, winsB, draws, meetings: meetings.length },
|
|
693
|
+
meetings,
|
|
694
|
+
lastMeeting: meetings[0] ?? null,
|
|
695
|
+
notes: warnings.length
|
|
696
|
+
? warnings
|
|
697
|
+
: meetings.length === 0
|
|
698
|
+
? ['No meetings found in available history window']
|
|
699
|
+
: [],
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
export const teamTools = [teamProfile, headToHead];
|