cito-mcp 0.3.18 → 0.3.20
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 +7 -5
- package/dist/tools/index.js +3 -1
- package/dist/tools/insight.js +28 -1
- package/dist/tools/meta.js +20 -0
- package/dist/tools/player.js +195 -1
- package/dist/tools/rankings.js +228 -0
- package/dist/tools/standings.js +4 -2
- package/dist/tools/team.js +44 -27
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Standalone [MCP](https://modelcontextprotocol.io) server for the [Cito esports API](https://api.citoapi.com) — **curated outcome tools** for agents building esports apps, dashboards, bots, and research flows.
|
|
4
4
|
|
|
5
|
-
**Version:** `0.
|
|
5
|
+
**Version:** `0.3.20` · **Node:** `>=20` · **Install:** `npx cito-mcp`
|
|
6
6
|
|
|
7
|
-
Primary games: **lol · cs2 · dota2 · cod · ufc
|
|
7
|
+
Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis**. Long-tail REST stays available via `call_api`.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail RES
|
|
|
12
12
|
|
|
13
13
|
v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among thin path wrappers, invent IDs, and stitch multi-call UI screens themselves. That catalog was hard to select against and brittle across games.
|
|
14
14
|
|
|
15
|
-
**v0.
|
|
15
|
+
**v0.3 ships 18 hand-authored tools** that answer *jobs* instead of mirroring REST:
|
|
16
16
|
|
|
17
17
|
| Job | Tool |
|
|
18
18
|
| --- | --- |
|
|
@@ -21,9 +21,11 @@ v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among t
|
|
|
21
21
|
| Build a match card / recap | `match_summary` |
|
|
22
22
|
| Deep timeline / live state | `match_details` |
|
|
23
23
|
| Team page | `team_profile` |
|
|
24
|
-
| Player / fighter
|
|
24
|
+
| Player / fighter profile | `player_profile` |
|
|
25
|
+
| Player form & win streak | `player_form` |
|
|
25
26
|
| Table / rankings | `standings` |
|
|
26
|
-
|
|
|
27
|
+
| Ranking climbers & droppers | `rankings_movers` |
|
|
28
|
+
| Pre-match briefing (with surface) | `match_preview` |
|
|
27
29
|
| Event / fight-night card | `event_card` |
|
|
28
30
|
| Rivalry record | `head_to_head` |
|
|
29
31
|
| Fighter / team photos | `event_card` · `player_profile` |
|
package/dist/tools/index.js
CHANGED
|
@@ -5,8 +5,9 @@ import { matchTools } from './match.js';
|
|
|
5
5
|
import { playerTools } from './player.js';
|
|
6
6
|
import { teamTools } from './team.js';
|
|
7
7
|
import { standingsTools } from './standings.js';
|
|
8
|
+
import { rankingsTools } from './rankings.js';
|
|
8
9
|
import { insightTools } from './insight.js';
|
|
9
|
-
/** Curated outcome-tool catalog (
|
|
10
|
+
/** Curated outcome-tool catalog (17 tools). Order matches preferred cold-start ladder. */
|
|
10
11
|
export const allTools = [
|
|
11
12
|
...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
|
|
12
13
|
...resolveTools,
|
|
@@ -15,6 +16,7 @@ export const allTools = [
|
|
|
15
16
|
...playerTools,
|
|
16
17
|
...teamTools,
|
|
17
18
|
...standingsTools,
|
|
19
|
+
...rankingsTools,
|
|
18
20
|
...insightTools,
|
|
19
21
|
// Escape-hatch pair last: discover routes, then call one.
|
|
20
22
|
...metaTools.filter((t) => t.name === 'list_routes' || t.name === 'call_api'),
|
package/dist/tools/insight.js
CHANGED
|
@@ -291,6 +291,7 @@ Prefer match_summary when match is completed; match_details for live in-game.
|
|
|
291
291
|
Do not use when: user wants final score/recap of a finished match.
|
|
292
292
|
|
|
293
293
|
Parallel-safe: yes. Upstream cost: 4–8.
|
|
294
|
+
Tennis H2H accepts an optional surface filter (Hard, Clay, Grass).
|
|
294
295
|
Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "recentLimit": 5 }`,
|
|
295
296
|
inputSchema: {
|
|
296
297
|
type: 'object',
|
|
@@ -305,6 +306,7 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
305
306
|
includeH2H: boolSchema('Include composed H2H stub.', true),
|
|
306
307
|
includeRosters: boolSchema('Include roster snippets when available.', true),
|
|
307
308
|
recentLimit: limitSchema({ default: 5, max: 15, description: 'Form window per side (default 5, max 15).' }),
|
|
309
|
+
surface: stringSchema('Optional tennis H2H surface filter: Hard, Clay, or Grass. Ignored for other games.', 'Clay'),
|
|
308
310
|
},
|
|
309
311
|
},
|
|
310
312
|
handler: async (args, ctx) => {
|
|
@@ -329,6 +331,26 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
329
331
|
const includeH2H = args.includeH2H !== false;
|
|
330
332
|
const includeRosters = args.includeRosters !== false;
|
|
331
333
|
const recentLimit = clampInt(args.recentLimit, 5, 1, 15);
|
|
334
|
+
// Optional tennis H2H surface filter (TENNIS-04 follow-through: the
|
|
335
|
+
// /tennis/h2h endpoint accepts surface=Hard|Clay|Grass). Canonicalise
|
|
336
|
+
// case so 'clay'/'CLAY' still hit; anything else is a validation error
|
|
337
|
+
// rather than a silently ignored filter.
|
|
338
|
+
const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
|
|
339
|
+
let surface;
|
|
340
|
+
if (surfaceRaw) {
|
|
341
|
+
const canon = surfaceRaw.charAt(0).toUpperCase() + surfaceRaw.slice(1).toLowerCase();
|
|
342
|
+
if (game === 'tennis' && canon !== 'Hard' && canon !== 'Clay' && canon !== 'Grass') {
|
|
343
|
+
return errorEnvelope({
|
|
344
|
+
code: 'VALIDATION',
|
|
345
|
+
message: `surface must be one of Hard, Clay, Grass (got '${surfaceRaw}')`,
|
|
346
|
+
game,
|
|
347
|
+
source: 'match_preview',
|
|
348
|
+
requestId,
|
|
349
|
+
tookMs: Date.now() - started,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
surface = canon;
|
|
353
|
+
}
|
|
332
354
|
if (!matchId && (!teamA || !teamB)) {
|
|
333
355
|
return errorEnvelope({
|
|
334
356
|
code: 'VALIDATION',
|
|
@@ -469,7 +491,10 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
469
491
|
// Tennis has a first-class H2H endpoint with the full rivalry; use it
|
|
470
492
|
// rather than composing from a short form window, which reported
|
|
471
493
|
// Sinner vs Alcaraz as never having met.
|
|
472
|
-
const
|
|
494
|
+
const h2hQuery = { player1_id: teamA, player2_id: teamB };
|
|
495
|
+
if (game === 'tennis' && surface)
|
|
496
|
+
h2hQuery.surface = surface;
|
|
497
|
+
const h2hRes = await fetchJson(ctx, '/tennis/h2h', { query: h2hQuery });
|
|
473
498
|
upstreamCalls += 1;
|
|
474
499
|
rateLimit = { ...rateLimit, ...h2hRes.headers };
|
|
475
500
|
if (h2hRes.ok) {
|
|
@@ -594,6 +619,8 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
594
619
|
talkingPoints.push(`${sideBName} key names: ${names.join(', ')}`);
|
|
595
620
|
}
|
|
596
621
|
const h2hRec = asRecord(h2h);
|
|
622
|
+
if (game === 'tennis' && surface && includeH2H)
|
|
623
|
+
talkingPoints.push(`H2H filtered to ${surface} courts`);
|
|
597
624
|
if (h2hRec?.recordNote)
|
|
598
625
|
talkingPoints.push(String(h2hRec.recordNote));
|
|
599
626
|
else if (Array.isArray(h2hRec?.meetings) && h2hRec.meetings.length) {
|
package/dist/tools/meta.js
CHANGED
|
@@ -126,6 +126,26 @@ const TOOL_CATALOG = [
|
|
|
126
126
|
preferOver: ['raw standings via call_api'],
|
|
127
127
|
doNotUse: 'Single team form → team_profile; live scores → live_matches',
|
|
128
128
|
},
|
|
129
|
+
{
|
|
130
|
+
name: 'rankings_movers',
|
|
131
|
+
outcome: 'Tennis ranking movers: climbers/fallers plus new entries and drop-outs',
|
|
132
|
+
parallelSafe: true,
|
|
133
|
+
games: ['tennis'],
|
|
134
|
+
jobs: ['standings'],
|
|
135
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', direction: 'up', limit: 20 },
|
|
136
|
+
preferOver: ['raw movers via call_api'],
|
|
137
|
+
doNotUse: 'Latest snapshot without deltas → standings',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'player_form',
|
|
141
|
+
outcome: 'Tennis recent form: W/L record, current streak, per-match rows',
|
|
142
|
+
parallelSafe: true,
|
|
143
|
+
games: ['tennis'],
|
|
144
|
+
jobs: ['player_form', 'preview', 'match_page'],
|
|
145
|
+
exampleArgs: { game: 'tennis', playerId: 'atp_207989', surface: 'Clay', limit: 5 },
|
|
146
|
+
preferOver: ['raw form via call_api'],
|
|
147
|
+
doNotUse: 'Career totals/titles → player_profile; ranking deltas → rankings_movers',
|
|
148
|
+
},
|
|
129
149
|
{
|
|
130
150
|
name: 'match_preview',
|
|
131
151
|
outcome: 'Pre-match briefing: sides, rosters/form, H2H stub',
|
package/dist/tools/player.js
CHANGED
|
@@ -575,4 +575,198 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
575
575
|
});
|
|
576
576
|
},
|
|
577
577
|
};
|
|
578
|
-
|
|
578
|
+
/**
|
|
579
|
+
* player_form (TENNIS-12).
|
|
580
|
+
*
|
|
581
|
+
* Tennis-only: recent-match form for one player (GET /tennis/players/{id}/form).
|
|
582
|
+
* The upstream answers wins/losses/win_pct over the last N completed matches
|
|
583
|
+
* plus the per-match rows (result, opponent, score, surface) and the player's
|
|
584
|
+
* current rank + movement. This tool normalizes those rows and derives the
|
|
585
|
+
* current W/L streak from the leading run, newest first.
|
|
586
|
+
*/
|
|
587
|
+
const SURFACES = ['Hard', 'Clay', 'Grass'];
|
|
588
|
+
function parseFormSurface(args) {
|
|
589
|
+
const raw = typeof args.surface === 'string' ? args.surface.trim() : '';
|
|
590
|
+
if (!raw)
|
|
591
|
+
return {};
|
|
592
|
+
const canon = raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
|
|
593
|
+
if (!SURFACES.includes(canon)) {
|
|
594
|
+
return { error: `surface must be one of Hard, Clay, Grass (got '${args.surface}')` };
|
|
595
|
+
}
|
|
596
|
+
return { surface: canon };
|
|
597
|
+
}
|
|
598
|
+
function normalizeFormMatch(row) {
|
|
599
|
+
const r = asRecord(row) ?? {};
|
|
600
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
601
|
+
return {
|
|
602
|
+
matchId: pickString(r.match_id) ?? null,
|
|
603
|
+
date: pickString(r.date) ?? null,
|
|
604
|
+
result: pickString(r.result) ?? null,
|
|
605
|
+
opponentId: pickString(r.opponent_id) ?? null,
|
|
606
|
+
opponentRank: num(r.opponent_rank),
|
|
607
|
+
score: pickString(r.score) ?? null,
|
|
608
|
+
surface: pickString(r.surface) ?? null,
|
|
609
|
+
tournamentId: pickString(r.tournament_id) ?? null,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
export const playerForm = {
|
|
613
|
+
name: 'player_form',
|
|
614
|
+
description: `Tennis player form: W/L record over the last N completed matches, current win/loss streak, and per-match rows (opponent, score, surface). Optional surface filter (Hard/Clay/Grass).
|
|
615
|
+
|
|
616
|
+
When to use:
|
|
617
|
+
- "How is X playing lately?"; current streak; surface-specific record (e.g. clay last 5).
|
|
618
|
+
|
|
619
|
+
Prefer over: player_profile (identity + career aggregates, no streak); raw form via call_api for agent-normalized rows.
|
|
620
|
+
|
|
621
|
+
Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
|
|
622
|
+
|
|
623
|
+
Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
|
|
624
|
+
|
|
625
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
626
|
+
inputSchema: {
|
|
627
|
+
type: 'object',
|
|
628
|
+
additionalProperties: false,
|
|
629
|
+
required: ['game', 'playerId'],
|
|
630
|
+
properties: {
|
|
631
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
632
|
+
playerId: stringSchema('Tennis player id, e.g. "atp_207989".', 'atp_207989'),
|
|
633
|
+
surface: stringSchema('Filter to one surface: Hard, Clay, or Grass.', 'Clay'),
|
|
634
|
+
limit: limitSchema({ default: 10, max: 50, description: 'Recent-match window (default 10, max 50).' }),
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
handler: async (args, ctx) => {
|
|
638
|
+
const started = Date.now();
|
|
639
|
+
const requestId = newRequestId();
|
|
640
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
641
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
642
|
+
return errorEnvelope({
|
|
643
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
644
|
+
message: gameParse.error ?? 'game is required',
|
|
645
|
+
game: null,
|
|
646
|
+
source: 'player_form',
|
|
647
|
+
requestId,
|
|
648
|
+
tookMs: Date.now() - started,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
const game = gameParse.game;
|
|
652
|
+
if (game !== 'tennis') {
|
|
653
|
+
return errorEnvelope({
|
|
654
|
+
code: 'NOT_IMPLEMENTED',
|
|
655
|
+
message: `player_form is tennis-only (game '${game}' has no recent-form endpoint)`,
|
|
656
|
+
game,
|
|
657
|
+
source: 'player_form',
|
|
658
|
+
requestId,
|
|
659
|
+
tookMs: Date.now() - started,
|
|
660
|
+
recover: [
|
|
661
|
+
'Use player_profile for this game (form/trends where available)',
|
|
662
|
+
'Retry player_form with game tennis and a tennis playerId',
|
|
663
|
+
],
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
|
|
667
|
+
if (!playerId) {
|
|
668
|
+
return errorEnvelope({
|
|
669
|
+
code: 'VALIDATION',
|
|
670
|
+
message: 'playerId is required (e.g. "atp_207989")',
|
|
671
|
+
game,
|
|
672
|
+
source: 'player_form',
|
|
673
|
+
requestId,
|
|
674
|
+
tookMs: Date.now() - started,
|
|
675
|
+
recover: [
|
|
676
|
+
'Call resolve_entity with the player name',
|
|
677
|
+
'Retry player_form with the returned id',
|
|
678
|
+
],
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
const surfaceParse = parseFormSurface(args);
|
|
682
|
+
if (surfaceParse.error) {
|
|
683
|
+
return errorEnvelope({
|
|
684
|
+
code: 'VALIDATION',
|
|
685
|
+
message: surfaceParse.error,
|
|
686
|
+
game,
|
|
687
|
+
source: 'player_form',
|
|
688
|
+
requestId,
|
|
689
|
+
tookMs: Date.now() - started,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
const limit = clampInt(args.limit, 10, 1, 50);
|
|
693
|
+
const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(playerId)}/form`, {
|
|
694
|
+
query: {
|
|
695
|
+
limit,
|
|
696
|
+
...(surfaceParse.surface ? { surface: surfaceParse.surface } : {}),
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
if (!res.ok) {
|
|
700
|
+
return errorEnvelope({
|
|
701
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
702
|
+
message: `Player form failed (HTTP ${res.status})`,
|
|
703
|
+
game,
|
|
704
|
+
source: 'player_form',
|
|
705
|
+
requestId,
|
|
706
|
+
tookMs: Date.now() - started,
|
|
707
|
+
upstreamCalls: 1,
|
|
708
|
+
httpStatus: res.status,
|
|
709
|
+
rateLimit: res.headers,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
const root = asRecord(res.data);
|
|
713
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
714
|
+
const matches = (Array.isArray(body.matches) ? body.matches : []).map(normalizeFormMatch);
|
|
715
|
+
// Upstream rows arrive newest-first: the streak is the leading run.
|
|
716
|
+
let streakCount = 0;
|
|
717
|
+
let streakResult = null;
|
|
718
|
+
for (const m of matches) {
|
|
719
|
+
if (streakResult === null) {
|
|
720
|
+
if (m.result !== 'W' && m.result !== 'L')
|
|
721
|
+
break;
|
|
722
|
+
streakResult = m.result;
|
|
723
|
+
streakCount = 1;
|
|
724
|
+
}
|
|
725
|
+
else if (m.result === streakResult) {
|
|
726
|
+
streakCount += 1;
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const rank = asRecord(body.ranking) ?? {};
|
|
733
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
734
|
+
return successEnvelope({
|
|
735
|
+
pagination: {
|
|
736
|
+
limit,
|
|
737
|
+
offset: 0,
|
|
738
|
+
total: matches.length,
|
|
739
|
+
hasMore: false,
|
|
740
|
+
nextCursor: null,
|
|
741
|
+
prevCursor: null,
|
|
742
|
+
},
|
|
743
|
+
source: 'player_form',
|
|
744
|
+
game,
|
|
745
|
+
requestId,
|
|
746
|
+
tookMs: Date.now() - started,
|
|
747
|
+
upstreamCalls: 1,
|
|
748
|
+
rateLimit: res.headers,
|
|
749
|
+
entities: {
|
|
750
|
+
games: [game],
|
|
751
|
+
ids: { playerId },
|
|
752
|
+
},
|
|
753
|
+
data: {
|
|
754
|
+
title: `${playerId} recent form (last ${matches.length})`,
|
|
755
|
+
playerId,
|
|
756
|
+
sampleSize: num(body.sample_size) ?? matches.length,
|
|
757
|
+
wins: num(body.wins) ?? null,
|
|
758
|
+
losses: num(body.losses) ?? null,
|
|
759
|
+
winPct: num(body.win_pct) ?? null,
|
|
760
|
+
surfaceFilter: pickString(body.surface_filter) ?? null,
|
|
761
|
+
streak: streakResult ? { result: streakResult, count: streakCount } : null,
|
|
762
|
+
ranking: {
|
|
763
|
+
current: num(rank.current),
|
|
764
|
+
movement: num(rank.movement),
|
|
765
|
+
asOf: pickString(rank.as_of) ?? null,
|
|
766
|
+
},
|
|
767
|
+
matches,
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
export const playerTools = [playerProfile, playerForm];
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rankings_movers (TENNIS-07).
|
|
3
|
+
*
|
|
4
|
+
* Tennis-only: biggest ranking movers between the latest ranking list and its
|
|
5
|
+
* predecessor (GET /tennis/rankings/movers). The upstream payload already
|
|
6
|
+
* carries new_entries[] and dropped[] alongside items[], so one tool covers
|
|
7
|
+
* both halves of the deferred TENNIS-07 ask — there is no standalone
|
|
8
|
+
* /rankings/new_entries route (it 404s; verified 2026-09-08).
|
|
9
|
+
*/
|
|
10
|
+
import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
|
|
11
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
12
|
+
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
13
|
+
const DIRECTIONS = ['up', 'down', 'both'];
|
|
14
|
+
function parseTour(args) {
|
|
15
|
+
const rawTour = typeof args.tour === 'string' ? args.tour.trim() : '';
|
|
16
|
+
const rawDivision = typeof args.division === 'string' ? args.division.trim() : '';
|
|
17
|
+
if (rawTour && rawDivision && rawTour.toUpperCase() !== rawDivision.toUpperCase()) {
|
|
18
|
+
return { error: `tour ('${rawTour}') and division ('${rawDivision}') disagree; pass one of ATP|WTA` };
|
|
19
|
+
}
|
|
20
|
+
const raw = rawTour || rawDivision;
|
|
21
|
+
if (!raw)
|
|
22
|
+
return { tour: 'ATP' };
|
|
23
|
+
const upper = raw.toUpperCase();
|
|
24
|
+
if (upper !== 'ATP' && upper !== 'WTA') {
|
|
25
|
+
return { error: `tour must be ATP or WTA (got '${raw}')` };
|
|
26
|
+
}
|
|
27
|
+
return { tour: upper };
|
|
28
|
+
}
|
|
29
|
+
function parseDirection(args) {
|
|
30
|
+
const raw = typeof args.direction === 'string' ? args.direction.trim().toLowerCase() : '';
|
|
31
|
+
if (!raw)
|
|
32
|
+
return { direction: 'up' };
|
|
33
|
+
if (!DIRECTIONS.includes(raw)) {
|
|
34
|
+
return { error: `direction must be one of up, down, both (got '${args.direction}')` };
|
|
35
|
+
}
|
|
36
|
+
return { direction: raw };
|
|
37
|
+
}
|
|
38
|
+
function parseDate(args) {
|
|
39
|
+
const raw = typeof args.date === 'string' ? args.date.trim() : '';
|
|
40
|
+
if (!raw)
|
|
41
|
+
return {};
|
|
42
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
43
|
+
return { error: `date must be YYYY-MM-DD (got '${args.date}')` };
|
|
44
|
+
}
|
|
45
|
+
return { date: raw };
|
|
46
|
+
}
|
|
47
|
+
function normalizeMover(row) {
|
|
48
|
+
const r = asRecord(row) ?? {};
|
|
49
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
50
|
+
return {
|
|
51
|
+
playerId: pickString(r.player_id) ?? null,
|
|
52
|
+
playerName: pickString(r.player_name) ?? null,
|
|
53
|
+
country: pickString(r.ioc, r.country_name) ?? null,
|
|
54
|
+
rank: num(r.rank),
|
|
55
|
+
previousRank: num(r.previous_rank),
|
|
56
|
+
movement: num(r.movement),
|
|
57
|
+
points: num(r.points),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function normalizeEdge(row) {
|
|
61
|
+
const r = asRecord(row) ?? {};
|
|
62
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
63
|
+
return {
|
|
64
|
+
playerId: pickString(r.player_id) ?? null,
|
|
65
|
+
playerName: pickString(r.player_name) ?? null,
|
|
66
|
+
country: pickString(r.ioc, r.country_name) ?? null,
|
|
67
|
+
rank: num(r.rank),
|
|
68
|
+
previousRank: num(r.previous_rank),
|
|
69
|
+
points: num(r.points),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export const rankingsMovers = {
|
|
73
|
+
name: 'rankings_movers',
|
|
74
|
+
description: `Tennis ranking movers: biggest climbers/fallers between the latest ATP/WTA list and its predecessor, plus new entries and drop-outs.
|
|
75
|
+
|
|
76
|
+
When to use:
|
|
77
|
+
- Who climbed or fell this week; new top-100 entrants; who dropped out.
|
|
78
|
+
|
|
79
|
+
Prefer over: standings (latest snapshot only, no week-over-week delta); raw movers via call_api for agent-normalized rows.
|
|
80
|
+
|
|
81
|
+
Do not use when: current top-N snapshot → standings with game tennis; player history → player_profile.
|
|
82
|
+
|
|
83
|
+
Tennis-only. Direction up (climbers, default), down (fallers), or both (largest absolute change). Date pins an older ranking list (YYYY-MM-DD); default is the latest, which the API resolves past zero-diff cloned lists.
|
|
84
|
+
|
|
85
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
required: ['game'],
|
|
90
|
+
properties: {
|
|
91
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
92
|
+
tour: stringSchema('ATP or WTA tour; default ATP. Synonym: division.', 'WTA'),
|
|
93
|
+
division: stringSchema('Synonym for tour (ATP or WTA), matching the standings tool spelling.'),
|
|
94
|
+
direction: stringSchema('up (climbers), down (fallers), or both (largest absolute change).', 'up'),
|
|
95
|
+
within: {
|
|
96
|
+
type: 'integer',
|
|
97
|
+
minimum: 1,
|
|
98
|
+
maximum: 2000,
|
|
99
|
+
default: 100,
|
|
100
|
+
description: 'Only players inside the top N on either list.',
|
|
101
|
+
},
|
|
102
|
+
date: stringSchema('Ranking date to compare against its predecessor (YYYY-MM-DD). Default: latest.'),
|
|
103
|
+
limit: limitSchema({ default: 20, max: 500 }),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
handler: async (args, ctx) => {
|
|
107
|
+
const started = Date.now();
|
|
108
|
+
const requestId = newRequestId();
|
|
109
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
110
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
111
|
+
return errorEnvelope({
|
|
112
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
113
|
+
message: gameParse.error ?? 'game is required',
|
|
114
|
+
game: null,
|
|
115
|
+
source: 'rankings_movers',
|
|
116
|
+
requestId,
|
|
117
|
+
tookMs: Date.now() - started,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const game = gameParse.game;
|
|
121
|
+
if (game !== 'tennis') {
|
|
122
|
+
return errorEnvelope({
|
|
123
|
+
code: 'NOT_IMPLEMENTED',
|
|
124
|
+
message: `rankings_movers is tennis-only (game '${game}' has no ranking-movers endpoint)`,
|
|
125
|
+
game,
|
|
126
|
+
source: 'rankings_movers',
|
|
127
|
+
requestId,
|
|
128
|
+
tookMs: Date.now() - started,
|
|
129
|
+
recover: [
|
|
130
|
+
'Use standings for the latest snapshot of this game',
|
|
131
|
+
'Retry rankings_movers with game tennis and tour ATP|WTA',
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const tourParse = parseTour(args);
|
|
136
|
+
if (tourParse.error || !tourParse.tour) {
|
|
137
|
+
return errorEnvelope({
|
|
138
|
+
code: 'VALIDATION',
|
|
139
|
+
message: tourParse.error ?? 'tour is required',
|
|
140
|
+
game,
|
|
141
|
+
source: 'rankings_movers',
|
|
142
|
+
requestId,
|
|
143
|
+
tookMs: Date.now() - started,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const dirParse = parseDirection(args);
|
|
147
|
+
if (dirParse.error || !dirParse.direction) {
|
|
148
|
+
return errorEnvelope({
|
|
149
|
+
code: 'VALIDATION',
|
|
150
|
+
message: dirParse.error ?? 'direction is invalid',
|
|
151
|
+
game,
|
|
152
|
+
source: 'rankings_movers',
|
|
153
|
+
requestId,
|
|
154
|
+
tookMs: Date.now() - started,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const dateParse = parseDate(args);
|
|
158
|
+
if (dateParse.error) {
|
|
159
|
+
return errorEnvelope({
|
|
160
|
+
code: 'VALIDATION',
|
|
161
|
+
message: dateParse.error,
|
|
162
|
+
game,
|
|
163
|
+
source: 'rankings_movers',
|
|
164
|
+
requestId,
|
|
165
|
+
tookMs: Date.now() - started,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const within = clampInt(args.within, 100, 1, 2000);
|
|
169
|
+
const limit = clampInt(args.limit, 20, 1, 500);
|
|
170
|
+
const res = await fetchJson(ctx, '/tennis/rankings/movers', {
|
|
171
|
+
query: {
|
|
172
|
+
tour: tourParse.tour,
|
|
173
|
+
direction: dirParse.direction,
|
|
174
|
+
within,
|
|
175
|
+
limit,
|
|
176
|
+
...(dateParse.date ? { date: dateParse.date } : {}),
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
return errorEnvelope({
|
|
181
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
182
|
+
message: `Rankings movers failed (HTTP ${res.status})`,
|
|
183
|
+
game,
|
|
184
|
+
source: 'rankings_movers',
|
|
185
|
+
requestId,
|
|
186
|
+
tookMs: Date.now() - started,
|
|
187
|
+
upstreamCalls: 1,
|
|
188
|
+
httpStatus: res.status,
|
|
189
|
+
rateLimit: res.headers,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const root = asRecord(res.data);
|
|
193
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
194
|
+
const items = (Array.isArray(body.items) ? body.items : []).map(normalizeMover);
|
|
195
|
+
const newEntries = (Array.isArray(body.new_entries) ? body.new_entries : []).map(normalizeEdge);
|
|
196
|
+
const dropped = (Array.isArray(body.dropped) ? body.dropped : []).map(normalizeEdge);
|
|
197
|
+
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
198
|
+
return successEnvelope({
|
|
199
|
+
pagination: {
|
|
200
|
+
limit,
|
|
201
|
+
offset: 0,
|
|
202
|
+
total,
|
|
203
|
+
hasMore: false,
|
|
204
|
+
nextCursor: null,
|
|
205
|
+
prevCursor: null,
|
|
206
|
+
},
|
|
207
|
+
source: 'rankings_movers',
|
|
208
|
+
game,
|
|
209
|
+
requestId,
|
|
210
|
+
tookMs: Date.now() - started,
|
|
211
|
+
upstreamCalls: 1,
|
|
212
|
+
rateLimit: res.headers,
|
|
213
|
+
data: {
|
|
214
|
+
title: `${tourParse.tour} ranking movers (${dirParse.direction})`,
|
|
215
|
+
tour: tourParse.tour,
|
|
216
|
+
direction: dirParse.direction,
|
|
217
|
+
within,
|
|
218
|
+
rankingDate: pickString(body.ranking_date) ?? null,
|
|
219
|
+
previousDate: pickString(body.previous_date) ?? null,
|
|
220
|
+
items,
|
|
221
|
+
newEntries,
|
|
222
|
+
dropped,
|
|
223
|
+
total,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
export const rankingsTools = [rankingsMovers];
|
package/dist/tools/standings.js
CHANGED
|
@@ -103,6 +103,7 @@ Required scope keys by game:
|
|
|
103
103
|
- cs2: omit for world rankings; eventId for event standings
|
|
104
104
|
- cod: optional season/stage
|
|
105
105
|
- ufc: optional division (scope=division)
|
|
106
|
+
- tennis: optional division (ATP or WTA tour; default ATP)
|
|
106
107
|
- dota2: best-effort worldRanking only
|
|
107
108
|
|
|
108
109
|
Parallel-safe: yes. Upstream cost: 1–2.
|
|
@@ -242,9 +243,10 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
242
243
|
}
|
|
243
244
|
}
|
|
244
245
|
else if (game === 'tennis') {
|
|
245
|
-
// Tennis "standings" = the latest ATP/WTA singles rankings
|
|
246
|
+
// Tennis "standings" = the latest ATP/WTA singles rankings, with
|
|
247
|
+
// rank_movement/previous_rank on every row. Pass tour via division.
|
|
246
248
|
const tour = String(division ?? 'ATP').toUpperCase() === 'WTA' ? 'WTA' : 'ATP';
|
|
247
|
-
path = '/tennis/
|
|
249
|
+
path = '/tennis/standings';
|
|
248
250
|
query = { tour, top_n: Math.min(limit, 100) };
|
|
249
251
|
effectiveScope = 'world';
|
|
250
252
|
title = `${tour} singles rankings`;
|
package/dist/tools/team.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* team_profile + head_to_head
|
|
3
3
|
*/
|
|
4
|
-
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
|
|
4
|
+
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { normalizeMatch } from './normalize.js';
|
|
7
7
|
import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
|
|
@@ -24,22 +24,36 @@ function teamIdentity(game, raw, idHint, slugHint) {
|
|
|
24
24
|
name,
|
|
25
25
|
game,
|
|
26
26
|
region: pickString(nested.region, nested.country) ?? null,
|
|
27
|
+
// CS2 publishes countryName/countryCode and worldRanking on the team
|
|
28
|
+
// resource and none of it reached this card: a team page rendered with no
|
|
29
|
+
// flag and no ladder position while the API had both. Spirit came back as
|
|
30
|
+
// {id, slug, name, game, region} with world #1 nowhere in sight.
|
|
31
|
+
country: pickString(nested.countryName, nested.country, nested.countryCode) ?? null,
|
|
32
|
+
countryCode: pickString(nested.countryCode) ?? null,
|
|
33
|
+
worldRanking: numberOrNull(nested.worldRanking ?? nested.ranking ?? nested.rank),
|
|
27
34
|
};
|
|
28
35
|
}
|
|
36
|
+
/** A finite number, or null. Ranks arrive as number or numeric string. */
|
|
37
|
+
function numberOrNull(value) {
|
|
38
|
+
if (value === null || value === undefined || value === '')
|
|
39
|
+
return null;
|
|
40
|
+
const parsed = Number(value);
|
|
41
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
42
|
+
}
|
|
29
43
|
export const teamProfile = {
|
|
30
44
|
name: 'team_profile',
|
|
31
|
-
description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
|
|
32
|
-
|
|
33
|
-
When to use:
|
|
34
|
-
- Team page / "who is on this roster?"
|
|
35
|
-
- Builder team screen sample
|
|
36
|
-
|
|
37
|
-
Prefer over: separate roster + matches + detail via call_api.
|
|
38
|
-
|
|
39
|
-
Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
|
|
40
|
-
Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
|
|
41
|
-
|
|
42
|
-
Parallel-safe: yes. Upstream cost: 2–4.
|
|
45
|
+
description: `Team/org card: identity, roster, recent matches, and form/trends/radar when available.
|
|
46
|
+
|
|
47
|
+
When to use:
|
|
48
|
+
- Team page / "who is on this roster?"
|
|
49
|
+
- Builder team screen sample
|
|
50
|
+
|
|
51
|
+
Prefer over: separate roster + matches + detail via call_api.
|
|
52
|
+
|
|
53
|
+
Do not use when: UFC fighters → player_profile; unknown name → resolve_entity first.
|
|
54
|
+
Dota may return partial roster (API gap). Prefer slug for lol/cod; teamId for cs2.
|
|
55
|
+
|
|
56
|
+
Parallel-safe: yes. Upstream cost: 2–4.
|
|
43
57
|
Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
44
58
|
inputSchema: {
|
|
45
59
|
type: 'object',
|
|
@@ -374,8 +388,11 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
|
374
388
|
const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(tid)}/trends`);
|
|
375
389
|
upstreamCalls += 1;
|
|
376
390
|
rateLimit = { ...rateLimit, ...res.headers };
|
|
391
|
+
// res.data is the whole upstream body, so this nested
|
|
392
|
+
// {success, data:{...}} inside our own envelope. Every other field
|
|
393
|
+
// here is normalized; this one leaked the REST wrapper to the agent.
|
|
377
394
|
if (res.ok)
|
|
378
|
-
form = res.data;
|
|
395
|
+
form = unwrapPayload(res.data) ?? res.data;
|
|
379
396
|
else {
|
|
380
397
|
partial.push(partialFromRejection('form', {
|
|
381
398
|
code: mapHttpToCode(res.status),
|
|
@@ -545,19 +562,19 @@ function winnerSide(match, a) {
|
|
|
545
562
|
}
|
|
546
563
|
export const headToHead = {
|
|
547
564
|
name: 'head_to_head',
|
|
548
|
-
description: `Composed head-to-head record between two teams, two UFC fighters, or two tennis players. No first-class REST H2H exists — this tool filters match history server-side.
|
|
549
|
-
|
|
550
|
-
When to use:
|
|
551
|
-
- Rivalry / series record questions
|
|
552
|
-
- Supporting context for previews
|
|
553
|
-
|
|
554
|
-
Prefer over: agent-side double match-list filtering.
|
|
555
|
-
|
|
556
|
-
Do not use when: single-side form only → team_profile or player_profile.
|
|
557
|
-
|
|
558
|
-
Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
|
|
559
|
-
|
|
560
|
-
Parallel-safe: yes. Upstream cost: 2–4.
|
|
565
|
+
description: `Composed head-to-head record between two teams, two UFC fighters, or two tennis players. No first-class REST H2H exists — this tool filters match history server-side.
|
|
566
|
+
|
|
567
|
+
When to use:
|
|
568
|
+
- Rivalry / series record questions
|
|
569
|
+
- Supporting context for previews
|
|
570
|
+
|
|
571
|
+
Prefer over: agent-side double match-list filtering.
|
|
572
|
+
|
|
573
|
+
Do not use when: single-side form only → team_profile or player_profile.
|
|
574
|
+
|
|
575
|
+
Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
|
|
576
|
+
|
|
577
|
+
Parallel-safe: yes. Upstream cost: 2–4.
|
|
561
578
|
Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
562
579
|
inputSchema: {
|
|
563
580
|
type: 'object',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cito-mcp",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Standalone MCP server for the Cito esports API —
|
|
3
|
+
"version": "0.3.20",
|
|
4
|
+
"description": "Standalone MCP server for the Cito esports and sports API — 18 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards, player form, rankings movers).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cito-mcp": "dist/index.js"
|