cito-mcp 0.2.7 → 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 +3 -0
- package/dist/envelope.js +23 -0
- package/dist/http.js +150 -0
- package/dist/index.js +101 -33
- package/dist/install.js +9 -1
- 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/README.md
CHANGED
|
@@ -382,6 +382,9 @@ CITO_API_KEY=cito_your_key_here node dist/index.js --http 8787
|
|
|
382
382
|
# → http://127.0.0.1:8787/mcp
|
|
383
383
|
```
|
|
384
384
|
|
|
385
|
+
Hosted (production): `https://api.citoapi.com/mcp`
|
|
386
|
+
Send the user's Cito key as `x-api-key`. Initialize and `list_capabilities` work without a key so Smithery can scan.
|
|
387
|
+
|
|
385
388
|
---
|
|
386
389
|
|
|
387
390
|
## Migration from 0.1 (auto-generated tools)
|
package/dist/envelope.js
CHANGED
|
@@ -201,10 +201,33 @@ export function sealEnvelope(envelope) {
|
|
|
201
201
|
},
|
|
202
202
|
};
|
|
203
203
|
}
|
|
204
|
+
/** Shared tool output for OpenAI plugin scan. Matches the JSON envelope. */
|
|
205
|
+
export const ENVELOPE_OUTPUT_SCHEMA = {
|
|
206
|
+
type: 'object',
|
|
207
|
+
additionalProperties: true,
|
|
208
|
+
required: ['ok', 'meta'],
|
|
209
|
+
properties: {
|
|
210
|
+
ok: { type: 'boolean', description: 'true if the tool succeeded' },
|
|
211
|
+
data: { description: 'Result payload when ok is true; null on error' },
|
|
212
|
+
error: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
additionalProperties: true,
|
|
215
|
+
properties: {
|
|
216
|
+
code: { type: 'string' },
|
|
217
|
+
message: { type: 'string' },
|
|
218
|
+
recover: { type: 'array', items: { type: 'string' } },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
meta: { type: 'object', additionalProperties: true },
|
|
222
|
+
pagination: { type: 'object', additionalProperties: true },
|
|
223
|
+
partial: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
224
|
+
},
|
|
225
|
+
};
|
|
204
226
|
export function toMcpResult(envelope) {
|
|
205
227
|
const sealed = sealEnvelope(envelope);
|
|
206
228
|
return {
|
|
207
229
|
content: [{ type: 'text', text: JSON.stringify(sealed, null, 2) }],
|
|
230
|
+
structuredContent: sealed,
|
|
208
231
|
...(sealed.ok ? {} : { isError: true }),
|
|
209
232
|
};
|
|
210
233
|
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosted Streamable HTTP helpers.
|
|
3
|
+
*
|
|
4
|
+
* stdio is unchanged. This is only the public HTTPS surface:
|
|
5
|
+
* pathname matching, per-request API key, CORS, Smithery well-known docs.
|
|
6
|
+
* Never log the key.
|
|
7
|
+
*/
|
|
8
|
+
import { allTools } from './tools/index.js';
|
|
9
|
+
import { toolListing } from './tools/types.js';
|
|
10
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
11
|
+
export const MCP_PATH = '/mcp';
|
|
12
|
+
export const MCP_CONFIG_PATH = '/.well-known/mcp-config';
|
|
13
|
+
export const MCP_SERVER_CARD_PATH = '/.well-known/mcp/server-card.json';
|
|
14
|
+
const CORS_HEADERS = {
|
|
15
|
+
'access-control-allow-origin': '*',
|
|
16
|
+
'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS, HEAD',
|
|
17
|
+
'access-control-allow-headers': 'Content-Type, Accept, Authorization, x-api-key, x-cito-api-key, mcp-session-id, mcp-protocol-version, last-event-id',
|
|
18
|
+
'access-control-expose-headers': 'mcp-session-id, mcp-protocol-version',
|
|
19
|
+
};
|
|
20
|
+
export function requestPathname(url) {
|
|
21
|
+
if (!url)
|
|
22
|
+
return '';
|
|
23
|
+
try {
|
|
24
|
+
return new URL(url, 'http://127.0.0.1').pathname;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
const q = url.indexOf('?');
|
|
28
|
+
return q === -1 ? url : url.slice(0, q);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function headerValue(headers, name) {
|
|
32
|
+
const raw = headers[name] ?? headers[name.toLowerCase()];
|
|
33
|
+
if (Array.isArray(raw))
|
|
34
|
+
return (raw[0] ?? '').trim();
|
|
35
|
+
return typeof raw === 'string' ? raw.trim() : '';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Per-request Cito key. Header first (Smithery x-from), then Bearer, then query.
|
|
39
|
+
* Query is last so nginx access logs are not the intended path.
|
|
40
|
+
*/
|
|
41
|
+
export function extractApiKey(req) {
|
|
42
|
+
const fromHeader = headerValue(req.headers, 'x-api-key') || headerValue(req.headers, 'x-cito-api-key');
|
|
43
|
+
if (fromHeader)
|
|
44
|
+
return fromHeader;
|
|
45
|
+
const auth = headerValue(req.headers, 'authorization');
|
|
46
|
+
const bearer = /^bearer\s+/i.exec(auth);
|
|
47
|
+
if (bearer) {
|
|
48
|
+
const token = auth.slice(bearer[0].length).trim();
|
|
49
|
+
if (token)
|
|
50
|
+
return token;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const u = new URL(req.url ?? '', 'http://127.0.0.1');
|
|
54
|
+
return (u.searchParams.get('apiKey') ?? u.searchParams.get('CITO_API_KEY') ?? '').trim();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function corsHeaders() {
|
|
61
|
+
return { ...CORS_HEADERS };
|
|
62
|
+
}
|
|
63
|
+
const MCP_ACCEPT = 'application/json, text/event-stream';
|
|
64
|
+
const LEGACY_PROTOCOL = '2025-11-25';
|
|
65
|
+
/** Node reads Accept from rawHeaders; mutating headers.accept alone is not enough. */
|
|
66
|
+
export function setIncomingHeader(req, name, value) {
|
|
67
|
+
req.headers[name.toLowerCase()] = value;
|
|
68
|
+
const raw = req.rawHeaders;
|
|
69
|
+
if (!Array.isArray(raw))
|
|
70
|
+
return;
|
|
71
|
+
const lower = name.toLowerCase();
|
|
72
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
73
|
+
if (String(raw[i]).toLowerCase() === lower) {
|
|
74
|
+
raw[i + 1] = value;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
raw.push(name, value);
|
|
79
|
+
}
|
|
80
|
+
/** SDK 406s POSTs that omit MCP Accept. OpenAI's scanner often omits it. */
|
|
81
|
+
export function ensureMcpAccept(headers, req) {
|
|
82
|
+
const raw = headers.accept ?? headers.Accept;
|
|
83
|
+
const value = Array.isArray(raw) ? raw.join(',') : typeof raw === 'string' ? raw : '';
|
|
84
|
+
const lower = value.toLowerCase();
|
|
85
|
+
if (lower.includes('application/json') && lower.includes('text/event-stream'))
|
|
86
|
+
return;
|
|
87
|
+
if (req)
|
|
88
|
+
setIncomingHeader(req, 'accept', MCP_ACCEPT);
|
|
89
|
+
else
|
|
90
|
+
headers.accept = MCP_ACCEPT;
|
|
91
|
+
}
|
|
92
|
+
/** OpenAI Scan Tools sends 2026-07-28; this SDK only speaks 2025-era. */
|
|
93
|
+
export function rewriteMcpProtocolMessage(body) {
|
|
94
|
+
if (!body || typeof body !== 'object')
|
|
95
|
+
return body;
|
|
96
|
+
const msg = body;
|
|
97
|
+
const params = msg.params;
|
|
98
|
+
if (params && typeof params === 'object') {
|
|
99
|
+
const p = params;
|
|
100
|
+
if (p.protocolVersion === '2026-07-28')
|
|
101
|
+
p.protocolVersion = LEGACY_PROTOCOL;
|
|
102
|
+
const meta = p._meta;
|
|
103
|
+
if (meta && typeof meta === 'object') {
|
|
104
|
+
const m = meta;
|
|
105
|
+
const key = 'io.modelcontextprotocol/protocolVersion';
|
|
106
|
+
if (m[key] === '2026-07-28')
|
|
107
|
+
m[key] = LEGACY_PROTOCOL;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
112
|
+
/** Smithery session config: API key as x-api-key, not OAuth. */
|
|
113
|
+
export function mcpConfigSchema() {
|
|
114
|
+
return {
|
|
115
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
116
|
+
type: 'object',
|
|
117
|
+
title: 'Cito API',
|
|
118
|
+
description: 'Cito esports data for agents. Get a key at https://citoapi.com/dashboard',
|
|
119
|
+
required: ['apiKey'],
|
|
120
|
+
properties: {
|
|
121
|
+
apiKey: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
title: 'Cito API Key',
|
|
124
|
+
description: 'Your Cito API key from https://citoapi.com/dashboard',
|
|
125
|
+
'x-from': { header: 'x-api-key' },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** Scan fallback if Smithery cannot complete initialize. No OAuth. */
|
|
131
|
+
export function mcpServerCard() {
|
|
132
|
+
return {
|
|
133
|
+
serverInfo: {
|
|
134
|
+
name: 'cito-mcp',
|
|
135
|
+
version: PACKAGE_VERSION,
|
|
136
|
+
title: 'Cito API',
|
|
137
|
+
websiteUrl: 'https://citoapi.com',
|
|
138
|
+
},
|
|
139
|
+
authentication: {
|
|
140
|
+
required: false,
|
|
141
|
+
},
|
|
142
|
+
tools: allTools.map(toolListing),
|
|
143
|
+
resources: [
|
|
144
|
+
{ uri: 'cito://capabilities', name: 'cito-mcp capabilities summary' },
|
|
145
|
+
{ uri: 'cito://llms.txt', name: 'Cito API agent context (llms.txt)' },
|
|
146
|
+
{ uri: 'cito://openapi.json', name: 'Cito OpenAPI (public fetch)' },
|
|
147
|
+
],
|
|
148
|
+
prompts: [],
|
|
149
|
+
};
|
|
150
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* 14 outcome tools (not OpenAPI mass-generation). stdio by default;
|
|
6
6
|
* `--http <port>` serves a stateless Streamable HTTP endpoint.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* Optional
|
|
8
|
+
* Env: CITO_API_KEY (stdio / local). Hosted HTTP reads x-api-key per request.
|
|
9
|
+
* Optional: CITO_API_BASE, CITO_MCP_LISTEN (default 127.0.0.1).
|
|
10
10
|
*/
|
|
11
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
11
12
|
import { createServer as createHttpServer } from 'node:http';
|
|
12
13
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
13
14
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
@@ -17,8 +18,9 @@ import { DEFAULT_API_BASE, log } from './client.js';
|
|
|
17
18
|
import { errorEnvelope, toMcpResult } from './envelope.js';
|
|
18
19
|
import { SERVER_INSTRUCTIONS } from './instructions.js';
|
|
19
20
|
import { allTools, getTool } from './tools/index.js';
|
|
20
|
-
import { runTool } from './tools/types.js';
|
|
21
|
+
import { runTool, toolListing } from './tools/types.js';
|
|
21
22
|
import { PACKAGE_VERSION } from './version.js';
|
|
23
|
+
import { MCP_CONFIG_PATH, MCP_PATH, MCP_SERVER_CARD_PATH, corsHeaders, ensureMcpAccept, extractApiKey, rewriteMcpProtocolMessage, setIncomingHeader, mcpConfigSchema, mcpServerCard, requestPathname, } from './http.js';
|
|
22
24
|
/**
|
|
23
25
|
* Subcommands run and exit before any transport exists.
|
|
24
26
|
*
|
|
@@ -31,7 +33,11 @@ if (process.argv[2] === 'install') {
|
|
|
31
33
|
const { runInstall } = await import('./install.js');
|
|
32
34
|
process.exit(await runInstall(process.argv.slice(3)));
|
|
33
35
|
}
|
|
34
|
-
const
|
|
36
|
+
const ENV_API_KEY = process.env.CITO_API_KEY ?? '';
|
|
37
|
+
const requestAuth = new AsyncLocalStorage();
|
|
38
|
+
function currentApiKey() {
|
|
39
|
+
return requestAuth.getStore()?.apiKey || ENV_API_KEY;
|
|
40
|
+
}
|
|
35
41
|
/**
|
|
36
42
|
* Tools that need no API key. These must keep working with the server
|
|
37
43
|
* unconfigured, so a user can add it and immediately see what it does.
|
|
@@ -50,20 +56,21 @@ const OFFLINE_TOOLS = new Set(['list_capabilities']);
|
|
|
50
56
|
* recovery steps, which the model can read out; list_capabilities keeps working
|
|
51
57
|
* offline so the server is browsable before it is configured.
|
|
52
58
|
*/
|
|
53
|
-
if (!
|
|
59
|
+
if (!ENV_API_KEY) {
|
|
54
60
|
// stderr only — stdout is the JSON-RPC channel and must stay clean.
|
|
55
61
|
console.error('[cito-mcp] CITO_API_KEY is not set. Serving tool catalog only; ' +
|
|
56
|
-
'API-backed tools will return MISSING_API_KEY until a key is provided
|
|
57
|
-
'
|
|
62
|
+
'API-backed tools will return MISSING_API_KEY until a key is provided ' +
|
|
63
|
+
'(env CITO_API_KEY, or x-api-key on hosted HTTP). ' +
|
|
64
|
+
'Get one at https://citoapi.com/dashboard.');
|
|
58
65
|
}
|
|
59
66
|
const API_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
60
67
|
const ctx = {
|
|
61
68
|
// Empty string when unconfigured; the dispatcher blocks API-backed tools
|
|
62
69
|
// before any request is attempted, so this is never sent as a credential.
|
|
63
|
-
apiKey:
|
|
70
|
+
apiKey: ENV_API_KEY,
|
|
64
71
|
baseUrl: API_BASE,
|
|
65
72
|
};
|
|
66
|
-
|
|
73
|
+
function createCitoServer() {
|
|
67
74
|
const server = new Server({
|
|
68
75
|
name: 'cito-mcp',
|
|
69
76
|
version: PACKAGE_VERSION,
|
|
@@ -72,11 +79,7 @@ async function main() {
|
|
|
72
79
|
instructions: SERVER_INSTRUCTIONS,
|
|
73
80
|
});
|
|
74
81
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
75
|
-
tools: allTools.map(
|
|
76
|
-
name,
|
|
77
|
-
description,
|
|
78
|
-
inputSchema,
|
|
79
|
-
})),
|
|
82
|
+
tools: allTools.map(toolListing),
|
|
80
83
|
}));
|
|
81
84
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
82
85
|
const { name } = request.params;
|
|
@@ -93,7 +96,8 @@ async function main() {
|
|
|
93
96
|
}
|
|
94
97
|
// Fail the CALL, not the process. The model gets an actionable envelope it
|
|
95
98
|
// can relay verbatim instead of the client reporting a dead server.
|
|
96
|
-
|
|
99
|
+
const apiKey = currentApiKey();
|
|
100
|
+
if (!apiKey && !OFFLINE_TOOLS.has(name)) {
|
|
97
101
|
return toMcpResult(errorEnvelope({
|
|
98
102
|
code: 'UNAUTHORIZED',
|
|
99
103
|
message: 'CITO_API_KEY is not set, so this tool cannot reach the Cito API. ' +
|
|
@@ -104,13 +108,14 @@ async function main() {
|
|
|
104
108
|
retryable: false,
|
|
105
109
|
recover: [
|
|
106
110
|
'Create a key at https://citoapi.com/dashboard',
|
|
107
|
-
'
|
|
111
|
+
'Hosted: send it as the x-api-key header to https://api.citoapi.com/mcp',
|
|
112
|
+
'Local: claude mcp add cito -e CITO_API_KEY=cito_... -- npx cito-mcp',
|
|
108
113
|
'Or set CITO_API_KEY in the mcpServers env block of your client config',
|
|
109
114
|
'Call list_capabilities to browse available tools without a key',
|
|
110
115
|
],
|
|
111
116
|
}));
|
|
112
117
|
}
|
|
113
|
-
return runTool(tool, args, ctx);
|
|
118
|
+
return runTool(tool, args, { ...ctx, apiKey });
|
|
114
119
|
});
|
|
115
120
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
116
121
|
resources: [
|
|
@@ -181,7 +186,7 @@ async function main() {
|
|
|
181
186
|
if (uri === 'cito://openapi.json') {
|
|
182
187
|
try {
|
|
183
188
|
const response = await fetch(`${API_BASE}/openapi.json`, {
|
|
184
|
-
headers: { 'x-api-key':
|
|
189
|
+
headers: { 'x-api-key': currentApiKey(), accept: 'application/json' },
|
|
185
190
|
});
|
|
186
191
|
const text = await response.text();
|
|
187
192
|
return {
|
|
@@ -202,6 +207,9 @@ async function main() {
|
|
|
202
207
|
}
|
|
203
208
|
throw new Error(`unknown resource: ${uri}`);
|
|
204
209
|
});
|
|
210
|
+
return server;
|
|
211
|
+
}
|
|
212
|
+
async function main() {
|
|
205
213
|
const httpPort = (() => {
|
|
206
214
|
const index = process.argv.indexOf('--http');
|
|
207
215
|
if (index === -1)
|
|
@@ -210,33 +218,93 @@ async function main() {
|
|
|
210
218
|
return Number.isInteger(port) && port > 0 ? port : null;
|
|
211
219
|
})();
|
|
212
220
|
if (httpPort) {
|
|
213
|
-
const
|
|
214
|
-
await server.connect(transport);
|
|
221
|
+
const listenHost = process.env.CITO_MCP_LISTEN || '127.0.0.1';
|
|
215
222
|
const httpServer = createHttpServer(async (req, res) => {
|
|
216
|
-
|
|
223
|
+
const path = requestPathname(req.url);
|
|
224
|
+
const cors = corsHeaders();
|
|
225
|
+
for (const [k, v] of Object.entries(cors))
|
|
226
|
+
res.setHeader(k, v);
|
|
227
|
+
if (req.method === 'OPTIONS') {
|
|
228
|
+
res.writeHead(204);
|
|
229
|
+
res.end();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if ((path === MCP_CONFIG_PATH || path === MCP_SERVER_CARD_PATH) &&
|
|
233
|
+
(req.method === 'GET' || req.method === 'HEAD')) {
|
|
234
|
+
const payload = path === MCP_CONFIG_PATH ? mcpConfigSchema() : mcpServerCard();
|
|
235
|
+
const body = JSON.stringify(payload);
|
|
236
|
+
res.writeHead(200, {
|
|
237
|
+
'content-type': 'application/json; charset=utf-8',
|
|
238
|
+
'cache-control': 'public, max-age=60',
|
|
239
|
+
});
|
|
240
|
+
if (req.method === 'HEAD') {
|
|
241
|
+
res.end();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
res.end(body);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (path !== MCP_PATH) {
|
|
217
248
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
218
249
|
res.end(JSON.stringify({ error: 'use POST /mcp' }));
|
|
219
250
|
return;
|
|
220
251
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
252
|
+
const apiKey = extractApiKey(req);
|
|
253
|
+
ensureMcpAccept(req.headers, req);
|
|
254
|
+
const proto = req.headers['mcp-protocol-version'];
|
|
255
|
+
const protoVal = Array.isArray(proto) ? proto[0] : proto;
|
|
256
|
+
if (protoVal === '2026-07-28') {
|
|
257
|
+
setIncomingHeader(req, 'mcp-protocol-version', '2025-11-25');
|
|
258
|
+
}
|
|
259
|
+
const server = createCitoServer();
|
|
260
|
+
const transport = new StreamableHTTPServerTransport({
|
|
261
|
+
sessionIdGenerator: undefined,
|
|
262
|
+
enableJsonResponse: true,
|
|
263
|
+
});
|
|
264
|
+
transport.onerror = (error) => {
|
|
265
|
+
log(`http transport: ${error instanceof Error ? error.message : String(error)}`);
|
|
266
|
+
};
|
|
267
|
+
res.on('close', () => {
|
|
268
|
+
void transport.close();
|
|
269
|
+
});
|
|
225
270
|
try {
|
|
226
|
-
|
|
271
|
+
await server.connect(transport);
|
|
272
|
+
await requestAuth.run({ apiKey }, async () => {
|
|
273
|
+
if (req.method === 'POST') {
|
|
274
|
+
let raw = '';
|
|
275
|
+
for await (const chunk of req)
|
|
276
|
+
raw += chunk;
|
|
277
|
+
let parsed = null;
|
|
278
|
+
if (raw.trim()) {
|
|
279
|
+
try {
|
|
280
|
+
parsed = rewriteMcpProtocolMessage(JSON.parse(raw));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
284
|
+
res.end(JSON.stringify({ error: 'invalid JSON body' }));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
await transport.handleRequest(req, res, parsed);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
await transport.handleRequest(req, res);
|
|
292
|
+
});
|
|
227
293
|
}
|
|
228
|
-
catch {
|
|
229
|
-
|
|
230
|
-
res.
|
|
231
|
-
|
|
294
|
+
catch (err) {
|
|
295
|
+
log(`http handler error: ${err.message}`);
|
|
296
|
+
if (!res.headersSent) {
|
|
297
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
298
|
+
res.end(JSON.stringify({ error: 'mcp handler failed' }));
|
|
299
|
+
}
|
|
232
300
|
}
|
|
233
|
-
await transport.handleRequest(req, res, body);
|
|
234
301
|
});
|
|
235
|
-
httpServer.listen(httpPort, () => {
|
|
236
|
-
log(`cito-mcp ${PACKAGE_VERSION} listening on http
|
|
302
|
+
httpServer.listen(httpPort, listenHost, () => {
|
|
303
|
+
log(`cito-mcp ${PACKAGE_VERSION} listening on http://${listenHost}:${httpPort}/mcp (${allTools.length} curated tools)`);
|
|
237
304
|
});
|
|
238
305
|
}
|
|
239
306
|
else {
|
|
307
|
+
const server = createCitoServer();
|
|
240
308
|
await server.connect(new StdioServerTransport());
|
|
241
309
|
log(`cito-mcp ${PACKAGE_VERSION} on stdio (${allTools.length} curated tools)`);
|
|
242
310
|
}
|
package/dist/install.js
CHANGED
|
@@ -75,6 +75,14 @@ function clientsFor() {
|
|
|
75
75
|
detectPath: join(home, '.codex'),
|
|
76
76
|
format: 'toml-mcp_servers',
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
id: 'grok',
|
|
80
|
+
label: 'Grok CLI',
|
|
81
|
+
configPath: join(home, '.grok', 'config.toml'),
|
|
82
|
+
detectPath: join(home, '.grok'),
|
|
83
|
+
// Same [mcp_servers.x] / [mcp_servers.x.env] layout as Codex.
|
|
84
|
+
format: 'toml-mcp_servers',
|
|
85
|
+
},
|
|
78
86
|
];
|
|
79
87
|
}
|
|
80
88
|
export function maskKey(key) {
|
|
@@ -265,7 +273,7 @@ Usage:
|
|
|
265
273
|
|
|
266
274
|
Options:
|
|
267
275
|
--key <key> Cito API key. Falls back to $CITO_API_KEY.
|
|
268
|
-
--client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex.
|
|
276
|
+
--client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex, grok.
|
|
269
277
|
Default: every client detected on this machine.
|
|
270
278
|
--base <url> Override API base (staging / self-hosted).
|
|
271
279
|
--dry-run, -n Show what would change; write nothing.
|
package/dist/instructions.js
CHANGED
|
@@ -16,7 +16,7 @@ You are connected to Cito esports data (read-only). Prefer curated outcome tools
|
|
|
16
16
|
|
|
17
17
|
## Game parameter
|
|
18
18
|
|
|
19
|
-
game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | all
|
|
19
|
+
game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | tennis | all
|
|
20
20
|
|
|
21
21
|
- Default when the user named one title: that game. Default for "what's live?" / multi-title: omit game or all on tools that accept it.
|
|
22
22
|
- On UNSUPPORTED_GAME / 403 for a title: stop retrying that game; call api_health; report plan gaps.
|
package/dist/tools/live.js
CHANGED
|
@@ -11,6 +11,7 @@ const LIVE_PATHS = {
|
|
|
11
11
|
dota2: '/dota2/matches/live',
|
|
12
12
|
cod: '/cod/matches/live',
|
|
13
13
|
ufc: '/ufc/live',
|
|
14
|
+
tennis: '/tennis/matches/live',
|
|
14
15
|
};
|
|
15
16
|
/**
|
|
16
17
|
* Extract live match/bout rows. UFC /ufc/live returns
|
|
@@ -502,6 +503,22 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
502
503
|
if (tournamentId)
|
|
503
504
|
noteIgnored('tournamentId', 'pass event slug via event_card instead');
|
|
504
505
|
}
|
|
506
|
+
else if (game === 'tennis') {
|
|
507
|
+
// Tennis has no upcoming-fixtures endpoint; the archive is results-only.
|
|
508
|
+
return errorEnvelope({
|
|
509
|
+
code: 'UNSUPPORTED_GAME',
|
|
510
|
+
message: 'Tennis has no upcoming-fixtures feed. Live matches: live_matches { game: "tennis" }. Recent results: call_api GET /tennis/matches/recent. Season calendar: call_api GET /tennis/tournaments/calendar?year=YYYY.',
|
|
511
|
+
game,
|
|
512
|
+
source: 'upcoming_schedule',
|
|
513
|
+
requestId,
|
|
514
|
+
tookMs: Date.now() - started,
|
|
515
|
+
recover: [
|
|
516
|
+
'Use live_matches with game "tennis" for in-progress matches',
|
|
517
|
+
'Use call_api GET /tennis/matches/recent for latest results',
|
|
518
|
+
'Use call_api GET /tennis/tournaments/calendar?year=YYYY for the season schedule',
|
|
519
|
+
],
|
|
520
|
+
});
|
|
521
|
+
}
|
|
505
522
|
const res = await fetchJson(ctx, path, { query });
|
|
506
523
|
if (!res.ok) {
|
|
507
524
|
return errorEnvelope({
|
package/dist/tools/match.js
CHANGED
|
@@ -99,22 +99,24 @@ function primaryPath(game, matchId) {
|
|
|
99
99
|
return `/cod/matches/${encodeURIComponent(matchId)}`;
|
|
100
100
|
case 'ufc':
|
|
101
101
|
return `/ufc/bouts/${encodeURIComponent(matchId)}`;
|
|
102
|
+
case 'tennis':
|
|
103
|
+
return `/tennis/matches/${encodeURIComponent(matchId)}`;
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
export const matchSummary = {
|
|
105
107
|
name: 'match_summary',
|
|
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.
|
|
108
|
+
description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
|
|
109
|
+
|
|
110
|
+
When to use:
|
|
111
|
+
- Match recap / default match UI
|
|
112
|
+
- After user selects a live or completed matchId
|
|
113
|
+
|
|
114
|
+
Prefer over match_details for chat answers and default UIs.
|
|
115
|
+
Prefer match_details for timelines, full map trees, live state, advanced packages.
|
|
116
|
+
|
|
117
|
+
Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
|
|
118
|
+
|
|
119
|
+
Parallel-safe: yes. Upstream cost: 2–5.
|
|
118
120
|
Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
|
|
119
121
|
inputSchema: {
|
|
120
122
|
type: 'object',
|
|
@@ -204,6 +206,8 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
204
206
|
paths.push(`/cod/matches/${encodeURIComponent(matchId)}/player-stats`);
|
|
205
207
|
if (game === 'ufc')
|
|
206
208
|
paths.push(`/ufc/bouts/${encodeURIComponent(matchId)}/stats`);
|
|
209
|
+
if (game === 'tennis')
|
|
210
|
+
paths.push(`/tennis/matches/${encodeURIComponent(matchId)}/stats`);
|
|
207
211
|
for (const p of paths) {
|
|
208
212
|
const res = await getSection(ctx, p);
|
|
209
213
|
upstreamCalls += 1;
|
|
@@ -298,22 +302,22 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
298
302
|
};
|
|
299
303
|
export const matchDetails = {
|
|
300
304
|
name: 'match_details',
|
|
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).
|
|
305
|
+
description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
|
|
306
|
+
|
|
307
|
+
When to use:
|
|
308
|
+
- Analyst deep dive
|
|
309
|
+
- Live in-game window (LoL/CS2/UFC)
|
|
310
|
+
- Full demo list
|
|
311
|
+
|
|
312
|
+
Prefer over match_summary only when summary is insufficient.
|
|
313
|
+
Prefer match_summary for short answers and default cards.
|
|
314
|
+
|
|
315
|
+
Do not use when: first-pass live board (use live_matches + match_summary).
|
|
316
|
+
|
|
317
|
+
Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
|
|
318
|
+
If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
|
|
319
|
+
|
|
320
|
+
Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
|
|
317
321
|
Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
|
|
318
322
|
inputSchema: {
|
|
319
323
|
type: 'object',
|
package/dist/tools/meta.js
CHANGED
|
@@ -480,6 +480,7 @@ const ALLOWLIST_PREFIXES = [
|
|
|
480
480
|
'/cod',
|
|
481
481
|
'/ufc',
|
|
482
482
|
'/fortnite',
|
|
483
|
+
'/tennis',
|
|
483
484
|
// The spec describes the surface call_api is allowed to reach; refusing to
|
|
484
485
|
// serve it left route discovery impossible except by guessing.
|
|
485
486
|
'/openapi.json',
|
|
@@ -505,7 +506,7 @@ Prefer curated tools for all standard jobs (live, schedule, profiles, standings,
|
|
|
505
506
|
|
|
506
507
|
Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
|
|
507
508
|
|
|
508
|
-
Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite.
|
|
509
|
+
Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis.
|
|
509
510
|
Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
|
|
510
511
|
|
|
511
512
|
Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
|
|
@@ -566,7 +567,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
|
|
|
566
567
|
source: 'call_api',
|
|
567
568
|
requestId,
|
|
568
569
|
tookMs: Date.now() - started,
|
|
569
|
-
hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite',
|
|
570
|
+
hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite /tennis',
|
|
570
571
|
});
|
|
571
572
|
}
|
|
572
573
|
let query = {};
|
|
@@ -646,6 +647,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
|
|
|
646
647
|
* explicitly rather than returning an empty list that reads as a verdict.
|
|
647
648
|
*/
|
|
648
649
|
const SPEC_OMITS = {
|
|
650
|
+
tennis: 'The published OpenAPI spec documents no /tennis paths, but tennis routes exist and work (players, matches, h2h, rankings, tournaments, live). Use the curated tools with game tennis, or call_api with /tennis/* paths.',
|
|
649
651
|
lol: 'The published OpenAPI spec documents no /lol paths, but LoL routes exist and work. Use the curated LoL tools (live_matches, upcoming_schedule, team_profile, standings, player_profile); for raw access, /lol/* is allowlisted for call_api even though it is undocumented.',
|
|
650
652
|
};
|
|
651
653
|
export const listRoutes = {
|
package/dist/tools/normalize.js
CHANGED
|
@@ -154,7 +154,7 @@ function scoreNum(v) {
|
|
|
154
154
|
export function normalizeMatch(game, row, forcedStatus) {
|
|
155
155
|
// Always peel { success, data } so UFC bout fighters[] / status are visible.
|
|
156
156
|
const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
|
|
157
|
-
const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId) ?? 'unknown';
|
|
157
|
+
const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId, r.live_match_id) ?? 'unknown';
|
|
158
158
|
let team1 = nestedSide(r.team1, game) ??
|
|
159
159
|
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));
|
|
160
160
|
let team2 = nestedSide(r.team2, game) ??
|
|
@@ -171,6 +171,24 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
171
171
|
if (s != null)
|
|
172
172
|
team2 = { ...team2, score: s };
|
|
173
173
|
}
|
|
174
|
+
// Tennis: live rows carry player1_name/player2_name; archive rows only carry
|
|
175
|
+
// winner_id/loser_id (ids, no names). Map both onto sides so labels are never "? vs ?".
|
|
176
|
+
if (game === 'tennis' && !team1 && !team2) {
|
|
177
|
+
const p1 = pickString(r.player1_name);
|
|
178
|
+
const p2 = pickString(r.player2_name);
|
|
179
|
+
if (p1 || p2) {
|
|
180
|
+
team1 = sideFrom(p1, pickString(r.player1_id), undefined, undefined);
|
|
181
|
+
team2 = sideFrom(p2, pickString(r.player2_id), undefined, undefined);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const w = pickString(r.winner_id);
|
|
185
|
+
const l = pickString(r.loser_id);
|
|
186
|
+
if (w || l) {
|
|
187
|
+
team1 = sideFrom(w, w, undefined, undefined);
|
|
188
|
+
team2 = sideFrom(l, l, undefined, undefined);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
174
192
|
// UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
|
|
175
193
|
// Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
|
|
176
194
|
// Live tracking uses red/blue + fighters[] (not team1/team2).
|
|
@@ -373,12 +391,12 @@ export function sortByCardOrder(rows) {
|
|
|
373
391
|
}
|
|
374
392
|
export function entityRef(row, type, game) {
|
|
375
393
|
const r = asRecord(row) ?? {};
|
|
376
|
-
const id = pickString(r.id, r.teamId, r.playerId, r.lolPlayerId, r.codPlayerId, r.matchId, r.boutId, r.eventId, r.tournamentId, r.leagueId) ??
|
|
394
|
+
const id = pickString(r.id, r.teamId, r.playerId, r.player_id, r.lolPlayerId, r.codPlayerId, r.matchId, r.boutId, r.eventId, r.tournamentId, r.leagueId) ??
|
|
377
395
|
pickString(r.slug) ??
|
|
378
396
|
'unknown';
|
|
379
397
|
const slug = pickString(r.slug, r.orgSlug, r.teamSlug);
|
|
380
398
|
// Events often use `title` not `name` (UFC)
|
|
381
|
-
const name = pickString(r.name, r.title, r.nickname, r.displayName, r.tag, r.code, slug, id) ?? id;
|
|
399
|
+
const name = pickString(r.name, r.full_name, r.player_name, r.title, r.nickname, r.displayName, r.tag, r.code, slug, id) ?? id;
|
|
382
400
|
const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname);
|
|
383
401
|
return {
|
|
384
402
|
game,
|
package/dist/tools/player.js
CHANGED
|
@@ -9,7 +9,7 @@ function identityFrom(game, raw, idHint, slugHint) {
|
|
|
9
9
|
const r = asRecord(raw) ?? {};
|
|
10
10
|
const id = pickString(r.id, r.playerId, r.lolPlayerId, r.codPlayerId, idHint, slugHint) ?? 'unknown';
|
|
11
11
|
const slug = pickString(r.slug, slugHint);
|
|
12
|
-
const name = pickString(r.name, r.nickname, r.displayName, r.tag, slug, id) ?? id;
|
|
12
|
+
const name = pickString(r.name, r.full_name, r.nickname, r.displayName, r.tag, slug, id) ?? id;
|
|
13
13
|
const team = asRecord(r.team) ??
|
|
14
14
|
asRecord(r.currentTeam) ??
|
|
15
15
|
(r.teamName || r.orgSlug
|
|
@@ -146,6 +146,8 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
146
146
|
primaryPath = `/cod/players/${encodeURIComponent(idOrSlug)}`;
|
|
147
147
|
else if (game === 'ufc')
|
|
148
148
|
primaryPath = `/ufc/fighters/${encodeURIComponent(slug || idOrSlug)}`;
|
|
149
|
+
else if (game === 'tennis')
|
|
150
|
+
primaryPath = `/tennis/players/${encodeURIComponent(idOrSlug)}`;
|
|
149
151
|
let playerRaw = null;
|
|
150
152
|
if (game === 'dota2') {
|
|
151
153
|
// Prefer radar as primary signal; try list filter
|
|
@@ -218,6 +220,43 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
218
220
|
}
|
|
219
221
|
})());
|
|
220
222
|
}
|
|
223
|
+
if (game === 'tennis') {
|
|
224
|
+
if (includeTrends) {
|
|
225
|
+
tasks.push((async () => {
|
|
226
|
+
const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(idOrSlug)}/stats`);
|
|
227
|
+
upstreamCalls += 1;
|
|
228
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
229
|
+
if (res.ok)
|
|
230
|
+
career = unwrapPayload(res.data);
|
|
231
|
+
else {
|
|
232
|
+
partial.push(partialFromRejection('career', {
|
|
233
|
+
code: mapHttpToCode(res.status),
|
|
234
|
+
message: `player stats HTTP ${res.status}`,
|
|
235
|
+
httpStatus: res.status,
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
})());
|
|
239
|
+
}
|
|
240
|
+
tasks.push((async () => {
|
|
241
|
+
const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(idOrSlug)}/matches`, {
|
|
242
|
+
query: { page_size: recentLimit },
|
|
243
|
+
});
|
|
244
|
+
upstreamCalls += 1;
|
|
245
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
246
|
+
if (res.ok) {
|
|
247
|
+
const envelope = asRecord(res.data) ?? {};
|
|
248
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
249
|
+
recentMatches = extractRows(data.items ?? data).slice(0, recentLimit);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
partial.push(partialFromRejection('recentMatches', {
|
|
253
|
+
code: mapHttpToCode(res.status),
|
|
254
|
+
message: `player matches HTTP ${res.status}`,
|
|
255
|
+
httpStatus: res.status,
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
})());
|
|
259
|
+
}
|
|
221
260
|
if (game === 'cs2' && includeTrends) {
|
|
222
261
|
for (const [section, path] of [
|
|
223
262
|
['career', `/cs2/players/${encodeURIComponent(idOrSlug)}/career`],
|
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