cito-mcp 0.2.7 → 0.3.1
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 +41 -29
- package/dist/tools/meta.js +7 -4
- package/dist/tools/normalize.js +28 -4
- package/dist/tools/player.js +40 -1
- package/dist/tools/resolve.js +55 -1
- package/dist/tools/standings.js +10 -2
- 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',
|
|
@@ -165,9 +167,17 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
165
167
|
const partial = [];
|
|
166
168
|
let upstreamCalls = 0;
|
|
167
169
|
let rateLimit = {};
|
|
168
|
-
|
|
170
|
+
let primary = await getSection(ctx, primaryPath(game, matchId));
|
|
169
171
|
upstreamCalls += 1;
|
|
170
172
|
rateLimit = primary.headers;
|
|
173
|
+
// Tennis live board hands out s365_* ids that live at /matches/live/{id}
|
|
174
|
+
// until the match finishes and lands in the archive. Without this fallback
|
|
175
|
+
// the board gives out ids that match_summary immediately 404s on.
|
|
176
|
+
if (!primary.ok && game === 'tennis' && primary.status === 404) {
|
|
177
|
+
primary = await getSection(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
|
|
178
|
+
upstreamCalls += 1;
|
|
179
|
+
rateLimit = { ...rateLimit, ...primary.headers };
|
|
180
|
+
}
|
|
171
181
|
if (!primary.ok) {
|
|
172
182
|
return errorEnvelope({
|
|
173
183
|
code: mapHttpToCode(primary.status, { gameNotIncluded: gameNotIncludedHint(primary.data) }),
|
|
@@ -204,6 +214,8 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
204
214
|
paths.push(`/cod/matches/${encodeURIComponent(matchId)}/player-stats`);
|
|
205
215
|
if (game === 'ufc')
|
|
206
216
|
paths.push(`/ufc/bouts/${encodeURIComponent(matchId)}/stats`);
|
|
217
|
+
if (game === 'tennis')
|
|
218
|
+
paths.push(`/tennis/matches/${encodeURIComponent(matchId)}/stats`);
|
|
207
219
|
for (const p of paths) {
|
|
208
220
|
const res = await getSection(ctx, p);
|
|
209
221
|
upstreamCalls += 1;
|
|
@@ -298,22 +310,22 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
298
310
|
};
|
|
299
311
|
export const matchDetails = {
|
|
300
312
|
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).
|
|
313
|
+
description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
|
|
314
|
+
|
|
315
|
+
When to use:
|
|
316
|
+
- Analyst deep dive
|
|
317
|
+
- Live in-game window (LoL/CS2/UFC)
|
|
318
|
+
- Full demo list
|
|
319
|
+
|
|
320
|
+
Prefer over match_summary only when summary is insufficient.
|
|
321
|
+
Prefer match_summary for short answers and default cards.
|
|
322
|
+
|
|
323
|
+
Do not use when: first-pass live board (use live_matches + match_summary).
|
|
324
|
+
|
|
325
|
+
Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
|
|
326
|
+
If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
|
|
327
|
+
|
|
328
|
+
Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
|
|
317
329
|
Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
|
|
318
330
|
inputSchema: {
|
|
319
331
|
type: 'object',
|
package/dist/tools/meta.js
CHANGED
|
@@ -430,6 +430,7 @@ Example: { "includeGameProbes": true }`,
|
|
|
430
430
|
{ game: 'dota2', path: '/dota2' },
|
|
431
431
|
{ game: 'cod', path: '/cod' },
|
|
432
432
|
{ game: 'ufc', path: '/ufc/live/health' },
|
|
433
|
+
{ game: 'tennis', path: '/tennis/rankings/top?tour=ATP&top_n=1' },
|
|
433
434
|
];
|
|
434
435
|
const results = await Promise.all(paths.map(async ({ game, path }) => {
|
|
435
436
|
const res = await fetchJson(ctx, path);
|
|
@@ -480,6 +481,7 @@ const ALLOWLIST_PREFIXES = [
|
|
|
480
481
|
'/cod',
|
|
481
482
|
'/ufc',
|
|
482
483
|
'/fortnite',
|
|
484
|
+
'/tennis',
|
|
483
485
|
// The spec describes the surface call_api is allowed to reach; refusing to
|
|
484
486
|
// serve it left route discovery impossible except by guessing.
|
|
485
487
|
'/openapi.json',
|
|
@@ -505,7 +507,7 @@ Prefer curated tools for all standard jobs (live, schedule, profiles, standings,
|
|
|
505
507
|
|
|
506
508
|
Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
|
|
507
509
|
|
|
508
|
-
Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite.
|
|
510
|
+
Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis.
|
|
509
511
|
Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
|
|
510
512
|
|
|
511
513
|
Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
|
|
@@ -566,7 +568,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
|
|
|
566
568
|
source: 'call_api',
|
|
567
569
|
requestId,
|
|
568
570
|
tookMs: Date.now() - started,
|
|
569
|
-
hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite',
|
|
571
|
+
hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite /tennis',
|
|
570
572
|
});
|
|
571
573
|
}
|
|
572
574
|
let query = {};
|
|
@@ -646,6 +648,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
|
|
|
646
648
|
* explicitly rather than returning an empty list that reads as a verdict.
|
|
647
649
|
*/
|
|
648
650
|
const SPEC_OMITS = {
|
|
651
|
+
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
652
|
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
653
|
};
|
|
651
654
|
export const listRoutes = {
|
|
@@ -690,10 +693,10 @@ Example: { "game": "ufc", "q": "rankings" }`,
|
|
|
690
693
|
// documented paths and no curated tools, so it is exactly what call_api
|
|
691
694
|
// callers come looking for.
|
|
692
695
|
const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
|
|
693
|
-
if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite)$/)) {
|
|
696
|
+
if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis)$/)) {
|
|
694
697
|
return errorEnvelope({
|
|
695
698
|
code: 'UNSUPPORTED_GAME',
|
|
696
|
-
message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite`,
|
|
699
|
+
message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis`,
|
|
697
700
|
game: null,
|
|
698
701
|
source: 'list_routes',
|
|
699
702
|
requestId,
|
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,30 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
171
171
|
if (s != null)
|
|
172
172
|
team2 = { ...team2, score: s };
|
|
173
173
|
}
|
|
174
|
+
// Tennis: the live board nests player1/player2 objects ({id,name,sets_won});
|
|
175
|
+
// raw live rows carry player1_name/player2_name; archive rows only carry
|
|
176
|
+
// winner_id/loser_id (ids, no names). Map all three onto sides so labels are
|
|
177
|
+
// never "? vs ?" and live scores surface as sets won.
|
|
178
|
+
if (game === 'tennis' && !team1 && !team2) {
|
|
179
|
+
const p1obj = asRecord(r.player1);
|
|
180
|
+
const p2obj = asRecord(r.player2);
|
|
181
|
+
const p1 = pickString(p1obj?.name, r.player1_name);
|
|
182
|
+
const p2 = pickString(p2obj?.name, r.player2_name);
|
|
183
|
+
if (p1 || p2) {
|
|
184
|
+
const score1 = typeof p1obj?.sets_won === 'number' ? p1obj.sets_won : undefined;
|
|
185
|
+
const score2 = typeof p2obj?.sets_won === 'number' ? p2obj.sets_won : undefined;
|
|
186
|
+
team1 = sideFrom(p1, pickString(p1obj?.id, r.player1_id), undefined, score1);
|
|
187
|
+
team2 = sideFrom(p2, pickString(p2obj?.id, r.player2_id), undefined, score2);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const w = pickString(r.winner_id);
|
|
191
|
+
const l = pickString(r.loser_id);
|
|
192
|
+
if (w || l) {
|
|
193
|
+
team1 = sideFrom(w, w, undefined, undefined);
|
|
194
|
+
team2 = sideFrom(l, l, undefined, undefined);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
174
198
|
// UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
|
|
175
199
|
// Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
|
|
176
200
|
// Live tracking uses red/blue + fighters[] (not team1/team2).
|
|
@@ -268,7 +292,7 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
268
292
|
}
|
|
269
293
|
}
|
|
270
294
|
const ev = asRecord(r.event);
|
|
271
|
-
const eventName = pickString(ev?.name, ev?.title, r.eventName, typeof r.event === 'string' ? r.event : undefined, asRecord(r.tournament)?.name, asRecord(r.tournament)?.title, r.tournamentName);
|
|
295
|
+
const eventName = pickString(ev?.name, ev?.title, r.eventName, typeof r.event === 'string' ? r.event : undefined, asRecord(r.tournament)?.name, asRecord(r.tournament)?.title, r.tournamentName, r.tournament_name);
|
|
272
296
|
const eventId = pickString(ev?.id, r.eventId, asRecord(r.tournament)?.id, r.tournamentId);
|
|
273
297
|
const eventSlug = pickString(ev?.slug, r.eventSlug, asRecord(r.tournament)?.slug);
|
|
274
298
|
const leagueName = pickString(asRecord(r.league)?.name, r.leagueName, r.league, r.leagueSlug);
|
|
@@ -373,12 +397,12 @@ export function sortByCardOrder(rows) {
|
|
|
373
397
|
}
|
|
374
398
|
export function entityRef(row, type, game) {
|
|
375
399
|
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) ??
|
|
400
|
+
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
401
|
pickString(r.slug) ??
|
|
378
402
|
'unknown';
|
|
379
403
|
const slug = pickString(r.slug, r.orgSlug, r.teamSlug);
|
|
380
404
|
// 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;
|
|
405
|
+
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
406
|
const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname);
|
|
383
407
|
return {
|
|
384
408
|
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
|
@@ -10,7 +10,14 @@ function pushCandidates(out, game, type, rows, q, limit) {
|
|
|
10
10
|
const ref = entityRef(row, type, game);
|
|
11
11
|
const r = asRecord(row) ?? {};
|
|
12
12
|
const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname, ref.meta?.nickname);
|
|
13
|
-
|
|
13
|
+
let score = rankScore(q, ref.name, ref.id, ref.slug, nickname);
|
|
14
|
+
// Ranked-player tiebreaker: a surname query like "Alcaraz" fuzzy-ties the
|
|
15
|
+
// world #2 with a 1991 journeyman; the row's current_rank breaks the tie
|
|
16
|
+
// toward whoever is actually active/ranked (tennis search supplies it).
|
|
17
|
+
const currentRank = Number(r.current_rank);
|
|
18
|
+
if (score > 0 && Number.isFinite(currentRank) && currentRank > 0) {
|
|
19
|
+
score += currentRank <= 100 ? 8 : currentRank <= 1000 ? 4 : 2;
|
|
20
|
+
}
|
|
14
21
|
// Prefer positive fuzzy hits; keep weak API hits at floor 1 so real search results are not wiped.
|
|
15
22
|
if (q && score <= 0) {
|
|
16
23
|
// still allow through at floor so dedicated search endpoints aren't empty on odd nicknames
|
|
@@ -230,6 +237,33 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
230
237
|
}
|
|
231
238
|
pushCandidates(candidates, game, type === 'any' ? 'unknown' : type, extractRows(res.data), q, limit);
|
|
232
239
|
}
|
|
240
|
+
else if (game === 'tennis') {
|
|
241
|
+
// Tennis has no combined /search; players and competitions are separate lookups.
|
|
242
|
+
const tasks = [];
|
|
243
|
+
if (type === 'any' || type === 'player') {
|
|
244
|
+
tasks.push((async () => {
|
|
245
|
+
const res = await fetchJson(ctx, '/tennis/players/search', { query: { q, limit } });
|
|
246
|
+
calls += 1;
|
|
247
|
+
if (res.ok) {
|
|
248
|
+
const envelope = asRecord(res.data) ?? {};
|
|
249
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
250
|
+
pushCandidates(candidates, game, 'player', extractRows(data.items ?? data), q, limit);
|
|
251
|
+
}
|
|
252
|
+
})());
|
|
253
|
+
}
|
|
254
|
+
if (type === 'any' || type === 'tournament' || type === 'event') {
|
|
255
|
+
tasks.push((async () => {
|
|
256
|
+
const res = await fetchJson(ctx, '/tennis/competitions', { query: { q, page_size: limit } });
|
|
257
|
+
calls += 1;
|
|
258
|
+
if (res.ok) {
|
|
259
|
+
const envelope = asRecord(res.data) ?? {};
|
|
260
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
261
|
+
pushCandidates(candidates, game, 'tournament', extractRows(data.items ?? data), q, limit);
|
|
262
|
+
}
|
|
263
|
+
})());
|
|
264
|
+
}
|
|
265
|
+
await Promise.all(tasks);
|
|
266
|
+
}
|
|
233
267
|
else if (game === 'lol') {
|
|
234
268
|
const tasks = [];
|
|
235
269
|
if (type === 'any' || type === 'team') {
|
|
@@ -593,6 +627,26 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
593
627
|
await listPath('/dota2/tournaments', 'tournament');
|
|
594
628
|
}
|
|
595
629
|
}
|
|
630
|
+
else if (game === 'tennis') {
|
|
631
|
+
if (type === 'player' || type === 'any') {
|
|
632
|
+
const res = await fetchJson(ctx, '/tennis/players/search', { query: { q, limit } });
|
|
633
|
+
upstreamCalls += 1;
|
|
634
|
+
if (res.ok) {
|
|
635
|
+
const envelope = asRecord(res.data) ?? {};
|
|
636
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
637
|
+
items.push(...extractRows(data.items ?? data).map((r) => entityRef(r, 'player', game)));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (type === 'tournament' || type === 'event' || type === 'any') {
|
|
641
|
+
const res = await fetchJson(ctx, '/tennis/competitions', { query: { q, page_size: limit } });
|
|
642
|
+
upstreamCalls += 1;
|
|
643
|
+
if (res.ok) {
|
|
644
|
+
const envelope = asRecord(res.data) ?? {};
|
|
645
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
646
|
+
items.push(...extractRows(data.items ?? data).map((r) => entityRef(r, 'tournament', game)));
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
596
650
|
else if (game === 'cod') {
|
|
597
651
|
if (q) {
|
|
598
652
|
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);
|
|
@@ -62,7 +62,7 @@ export function normalizeStandingRow(row, index) {
|
|
|
62
62
|
: null),
|
|
63
63
|
championStatus: resolvedChampionStatus,
|
|
64
64
|
teamOrFighter: {
|
|
65
|
-
id: pickString(asRecord(entity)?.id, r.teamId, r.fighterId, r.orgId, r.id),
|
|
65
|
+
id: pickString(asRecord(entity)?.id, r.teamId, r.fighterId, r.orgId, r.id, r.player_id),
|
|
66
66
|
slug: pickString(asRecord(entity)?.slug, r.orgSlug, r.teamSlug, r.slug, r.fighterSlug),
|
|
67
67
|
name,
|
|
68
68
|
},
|
|
@@ -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