cito-mcp 0.2.6 → 0.3.0
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 +26 -10
- package/dist/envelope.js +23 -0
- package/dist/http.js +150 -0
- package/dist/index.js +113 -33
- package/dist/install.js +348 -0
- package/dist/instructions.js +1 -1
- package/dist/tools/live.js +17 -0
- package/dist/tools/match.js +32 -28
- package/dist/tools/meta.js +4 -2
- package/dist/tools/normalize.js +21 -3
- package/dist/tools/player.js +40 -1
- package/dist/tools/resolve.js +47 -0
- package/dist/tools/standings.js +9 -1
- package/dist/tools/team.js +109 -27
- package/dist/tools/types.js +21 -6
- package/package.json +1 -1
package/dist/tools/resolve.js
CHANGED
|
@@ -230,6 +230,33 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
230
230
|
}
|
|
231
231
|
pushCandidates(candidates, game, type === 'any' ? 'unknown' : type, extractRows(res.data), q, limit);
|
|
232
232
|
}
|
|
233
|
+
else if (game === 'tennis') {
|
|
234
|
+
// Tennis has no combined /search; players and competitions are separate lookups.
|
|
235
|
+
const tasks = [];
|
|
236
|
+
if (type === 'any' || type === 'player') {
|
|
237
|
+
tasks.push((async () => {
|
|
238
|
+
const res = await fetchJson(ctx, '/tennis/players/search', { query: { q, limit } });
|
|
239
|
+
calls += 1;
|
|
240
|
+
if (res.ok) {
|
|
241
|
+
const envelope = asRecord(res.data) ?? {};
|
|
242
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
243
|
+
pushCandidates(candidates, game, 'player', extractRows(data.items ?? data), q, limit);
|
|
244
|
+
}
|
|
245
|
+
})());
|
|
246
|
+
}
|
|
247
|
+
if (type === 'any' || type === 'tournament' || type === 'event') {
|
|
248
|
+
tasks.push((async () => {
|
|
249
|
+
const res = await fetchJson(ctx, '/tennis/competitions', { query: { q, page_size: limit } });
|
|
250
|
+
calls += 1;
|
|
251
|
+
if (res.ok) {
|
|
252
|
+
const envelope = asRecord(res.data) ?? {};
|
|
253
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
254
|
+
pushCandidates(candidates, game, 'tournament', extractRows(data.items ?? data), q, limit);
|
|
255
|
+
}
|
|
256
|
+
})());
|
|
257
|
+
}
|
|
258
|
+
await Promise.all(tasks);
|
|
259
|
+
}
|
|
233
260
|
else if (game === 'lol') {
|
|
234
261
|
const tasks = [];
|
|
235
262
|
if (type === 'any' || type === 'team') {
|
|
@@ -593,6 +620,26 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
593
620
|
await listPath('/dota2/tournaments', 'tournament');
|
|
594
621
|
}
|
|
595
622
|
}
|
|
623
|
+
else if (game === 'tennis') {
|
|
624
|
+
if (type === 'player' || type === 'any') {
|
|
625
|
+
const res = await fetchJson(ctx, '/tennis/players/search', { query: { q, limit } });
|
|
626
|
+
upstreamCalls += 1;
|
|
627
|
+
if (res.ok) {
|
|
628
|
+
const envelope = asRecord(res.data) ?? {};
|
|
629
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
630
|
+
items.push(...extractRows(data.items ?? data).map((r) => entityRef(r, 'player', game)));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (type === 'tournament' || type === 'event' || type === 'any') {
|
|
634
|
+
const res = await fetchJson(ctx, '/tennis/competitions', { query: { q, page_size: limit } });
|
|
635
|
+
upstreamCalls += 1;
|
|
636
|
+
if (res.ok) {
|
|
637
|
+
const envelope = asRecord(res.data) ?? {};
|
|
638
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
639
|
+
items.push(...extractRows(data.items ?? data).map((r) => entityRef(r, 'tournament', game)));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
596
643
|
else if (game === 'cod') {
|
|
597
644
|
if (q) {
|
|
598
645
|
const res = await fetchJson(ctx, '/cod/search', {
|
package/dist/tools/standings.js
CHANGED
|
@@ -8,7 +8,7 @@ import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from
|
|
|
8
8
|
export function normalizeStandingRow(row, index) {
|
|
9
9
|
const r = asRecord(row) ?? {};
|
|
10
10
|
const entity = asRecord(r.team) ?? asRecord(r.fighter) ?? asRecord(r.org) ?? r;
|
|
11
|
-
const name = pickString(asRecord(entity)?.name, r.teamName, r.name, r.orgName, r.fighterName, asRecord(entity)?.slug) ?? `row-${index + 1}`;
|
|
11
|
+
const name = pickString(asRecord(entity)?.name, r.teamName, r.name, r.orgName, r.fighterName, r.player_name, asRecord(entity)?.slug) ?? `row-${index + 1}`;
|
|
12
12
|
// UFC official lists: champion has rank=null + rankText="C" (interim "IC"); contenders 1..15.
|
|
13
13
|
// Never fall back to index+1 for explicit null ranks — that produced two "#1" rows (champ + #1).
|
|
14
14
|
const championStatus = pickString(r.championStatus, asRecord(entity)?.championStatus);
|
|
@@ -234,6 +234,14 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
234
234
|
title = 'UFC rankings';
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
+
else if (game === 'tennis') {
|
|
238
|
+
// Tennis "standings" = the latest ATP/WTA singles rankings. Pass tour via division.
|
|
239
|
+
const tour = String(division ?? 'ATP').toUpperCase() === 'WTA' ? 'WTA' : 'ATP';
|
|
240
|
+
path = '/tennis/rankings/top';
|
|
241
|
+
query = { tour, top_n: Math.min(limit, 100) };
|
|
242
|
+
effectiveScope = 'world';
|
|
243
|
+
title = `${tour} singles rankings`;
|
|
244
|
+
}
|
|
237
245
|
else if (game === 'dota2') {
|
|
238
246
|
// No first-class standings — try team list worldRanking
|
|
239
247
|
const res = await fetchJson(ctx, '/dota2/teams', { query: { limit } });
|
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',
|
|
@@ -81,6 +81,20 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
|
81
81
|
],
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
|
+
if (game === 'tennis') {
|
|
85
|
+
return errorEnvelope({
|
|
86
|
+
code: 'NOT_IMPLEMENTED',
|
|
87
|
+
message: 'Tennis has players, not team profiles',
|
|
88
|
+
game,
|
|
89
|
+
source: 'team_profile',
|
|
90
|
+
requestId,
|
|
91
|
+
tookMs: Date.now() - started,
|
|
92
|
+
recover: [
|
|
93
|
+
'Use player_profile with a tennis player id (e.g. atp_104745)',
|
|
94
|
+
'Use standings with game "tennis" for ATP/WTA rankings',
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
84
98
|
const teamId = typeof args.teamId === 'string' ? args.teamId.trim() : '';
|
|
85
99
|
const slug = typeof args.slug === 'string' ? args.slug.trim() : '';
|
|
86
100
|
if (!teamId && !slug) {
|
|
@@ -486,19 +500,19 @@ function winnerSide(match, a) {
|
|
|
486
500
|
}
|
|
487
501
|
export const headToHead = {
|
|
488
502
|
name: 'head_to_head',
|
|
489
|
-
description: `Composed head-to-head record between two teams
|
|
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.
|
|
503
|
+
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.
|
|
504
|
+
|
|
505
|
+
When to use:
|
|
506
|
+
- Rivalry / series record questions
|
|
507
|
+
- Supporting context for previews
|
|
508
|
+
|
|
509
|
+
Prefer over: agent-side double match-list filtering.
|
|
510
|
+
|
|
511
|
+
Do not use when: single-side form only → team_profile or player_profile.
|
|
512
|
+
|
|
513
|
+
Caveat: Dota filters are weaker; expect meta.warnings when data is sparse.
|
|
514
|
+
|
|
515
|
+
Parallel-safe: yes. Upstream cost: 2–4.
|
|
502
516
|
Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
503
517
|
inputSchema: {
|
|
504
518
|
type: 'object',
|
|
@@ -508,9 +522,9 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
508
522
|
game: gameSchema({ allowAll: false, required: true }),
|
|
509
523
|
entityType: {
|
|
510
524
|
type: 'string',
|
|
511
|
-
enum: ['team', 'fighter'],
|
|
525
|
+
enum: ['team', 'fighter', 'player'],
|
|
512
526
|
default: 'team',
|
|
513
|
-
description: 'team (default)
|
|
527
|
+
description: 'team (default), fighter (UFC), or player (tennis).',
|
|
514
528
|
},
|
|
515
529
|
sideA: stringSchema('Id or slug for side A.', 'faze'),
|
|
516
530
|
sideB: stringSchema('Id or slug for side B.', 'navi'),
|
|
@@ -734,6 +748,74 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
734
748
|
warnings.push('No fight history rows returned for either fighter; H2H may be empty');
|
|
735
749
|
}
|
|
736
750
|
}
|
|
751
|
+
if (game === 'tennis') {
|
|
752
|
+
// Tennis has a first-class H2H endpoint; resolve names to player ids first when needed.
|
|
753
|
+
const resolveTennisSide = async (side) => {
|
|
754
|
+
if (/^(atp|wta)_\d+$/i.test(side))
|
|
755
|
+
return { id: side, name: side };
|
|
756
|
+
const res = await fetchJson(ctx, '/tennis/players/search', { query: { q: side, limit: 3 } });
|
|
757
|
+
upstreamCalls += 1;
|
|
758
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
759
|
+
if (!res.ok)
|
|
760
|
+
return null;
|
|
761
|
+
const envelope = asRecord(res.data) ?? {};
|
|
762
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
763
|
+
const first = asRecord(extractRows(data.items ?? data)[0]);
|
|
764
|
+
const id = pickString(first?.id, first?.player_id);
|
|
765
|
+
return id ? { id, name: pickString(first?.full_name, first?.name) ?? side } : null;
|
|
766
|
+
};
|
|
767
|
+
const [tA, tB] = await Promise.all([resolveTennisSide(sideA), resolveTennisSide(sideB)]);
|
|
768
|
+
if (!tA || !tB) {
|
|
769
|
+
return errorEnvelope({
|
|
770
|
+
code: 'NOT_FOUND',
|
|
771
|
+
message: 'One or both tennis players not found — pass names or ids like atp_104745',
|
|
772
|
+
game,
|
|
773
|
+
source: 'head_to_head',
|
|
774
|
+
requestId,
|
|
775
|
+
tookMs: Date.now() - started,
|
|
776
|
+
upstreamCalls,
|
|
777
|
+
rateLimit,
|
|
778
|
+
recover: [
|
|
779
|
+
'resolve_entity { game: "tennis", type: "player", q } for each name',
|
|
780
|
+
'Retry head_to_head with returned ids as sideA/sideB',
|
|
781
|
+
],
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
const res = await fetchJson(ctx, '/tennis/h2h', {
|
|
785
|
+
query: { player1_id: tA.id, player2_id: tB.id },
|
|
786
|
+
});
|
|
787
|
+
upstreamCalls += 1;
|
|
788
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
789
|
+
if (!res.ok) {
|
|
790
|
+
return errorEnvelope({
|
|
791
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
792
|
+
message: `Tennis H2H fetch failed (HTTP ${res.status})`,
|
|
793
|
+
game,
|
|
794
|
+
source: 'head_to_head',
|
|
795
|
+
requestId,
|
|
796
|
+
tookMs: Date.now() - started,
|
|
797
|
+
upstreamCalls,
|
|
798
|
+
httpStatus: res.status,
|
|
799
|
+
rateLimit,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
const envelope = asRecord(res.data) ?? {};
|
|
803
|
+
const h2h = asRecord(envelope.data) ?? envelope;
|
|
804
|
+
return successEnvelope({
|
|
805
|
+
source: 'head_to_head',
|
|
806
|
+
game,
|
|
807
|
+
requestId,
|
|
808
|
+
tookMs: Date.now() - started,
|
|
809
|
+
upstreamCalls,
|
|
810
|
+
rateLimit,
|
|
811
|
+
data: {
|
|
812
|
+
sideA: { idOrSlug: tA.id, name: tA.name },
|
|
813
|
+
sideB: { idOrSlug: tB.id, name: tB.name },
|
|
814
|
+
h2h,
|
|
815
|
+
notes: ['Record and meetings come from the first-class /tennis/h2h endpoint'],
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
}
|
|
737
819
|
const meetings = rows
|
|
738
820
|
.map((row) => normalizeMatch(game, row))
|
|
739
821
|
.filter((m) => sidesMatch(m, matchA, matchB))
|
package/dist/tools/types.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
import { toMcpResult } from '../envelope.js';
|
|
2
|
-
|
|
1
|
+
import { ENVELOPE_OUTPUT_SCHEMA, toMcpResult } from '../envelope.js';
|
|
2
|
+
/** All curated tools fetch Cito data. None delete, post, or mutate user state. */
|
|
3
|
+
export const READ_TOOL_ANNOTATIONS = {
|
|
4
|
+
readOnlyHint: true,
|
|
5
|
+
destructiveHint: false,
|
|
6
|
+
openWorldHint: true,
|
|
7
|
+
};
|
|
8
|
+
export function toolListing(def) {
|
|
9
|
+
return {
|
|
10
|
+
name: def.name,
|
|
11
|
+
description: def.description,
|
|
12
|
+
inputSchema: def.inputSchema,
|
|
13
|
+
outputSchema: ENVELOPE_OUTPUT_SCHEMA,
|
|
14
|
+
annotations: def.annotations ?? READ_TOOL_ANNOTATIONS,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export const PRIMARY_GAMES = ['lol', 'cs2', 'dota2', 'cod', 'ufc', 'tennis'];
|
|
3
18
|
export const GAME_ENUM = [...PRIMARY_GAMES, 'all'];
|
|
4
19
|
export function isPrimaryGame(value) {
|
|
5
20
|
return typeof value === 'string' && PRIMARY_GAMES.includes(value);
|
|
@@ -15,7 +30,7 @@ export function parseGame(value, opts) {
|
|
|
15
30
|
const g = value.toLowerCase();
|
|
16
31
|
if (g === 'all') {
|
|
17
32
|
if (opts?.allowAll === false) {
|
|
18
|
-
return { error: 'game=all is not valid for this tool; pick lol|cs2|dota2|cod|ufc' };
|
|
33
|
+
return { error: 'game=all is not valid for this tool; pick lol|cs2|dota2|cod|ufc|tennis' };
|
|
19
34
|
}
|
|
20
35
|
return { game: 'all' };
|
|
21
36
|
}
|
|
@@ -23,7 +38,7 @@ export function parseGame(value, opts) {
|
|
|
23
38
|
return { game: g };
|
|
24
39
|
}
|
|
25
40
|
return {
|
|
26
|
-
error: `unsupported game "${value}"; use lol|cs2|dota2|cod|ufc${opts?.allowAll !== false ? '|all' : ''}`,
|
|
41
|
+
error: `unsupported game "${value}"; use lol|cs2|dota2|cod|ufc|tennis${opts?.allowAll !== false ? '|all' : ''}`,
|
|
27
42
|
};
|
|
28
43
|
}
|
|
29
44
|
export function gameSchema(opts) {
|
|
@@ -33,8 +48,8 @@ export function gameSchema(opts) {
|
|
|
33
48
|
enum: values,
|
|
34
49
|
description: opts?.description ??
|
|
35
50
|
(opts?.allowAll === false
|
|
36
|
-
? 'Game title: lol | cs2 | dota2 | cod | ufc. Example: "cs2".'
|
|
37
|
-
: 'Game title: lol | cs2 | dota2 | cod | ufc | all. Omit or all for multi-game tools. Example: "lol".'),
|
|
51
|
+
? 'Game title: lol | cs2 | dota2 | cod | ufc | tennis. Example: "cs2".'
|
|
52
|
+
: 'Game title: lol | cs2 | dota2 | cod | ufc | tennis | all. Omit or all for multi-game tools. Example: "lol".'),
|
|
38
53
|
};
|
|
39
54
|
}
|
|
40
55
|
export function limitSchema(opts) {
|
package/package.json
CHANGED