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 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
- * Required env: CITO_API_KEY (never logged).
9
- * Optional env: CITO_API_BASE (default https://api.citoapi.com/api/v1).
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 API_KEY = process.env.CITO_API_KEY;
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 (!API_KEY) {
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
- 'Get one at https://citoapi.com/dashboard and set CITO_API_KEY.');
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: API_KEY ?? '',
70
+ apiKey: ENV_API_KEY,
64
71
  baseUrl: API_BASE,
65
72
  };
66
- async function main() {
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(({ name, description, inputSchema }) => ({
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
- if (!API_KEY && !OFFLINE_TOOLS.has(name)) {
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
- 'claude mcp add cito -e CITO_API_KEY=cito_... -- npx cito-mcp',
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': API_KEY, accept: 'application/json' },
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 transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
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
- if (req.url !== '/mcp') {
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
- let raw = '';
222
- for await (const chunk of req)
223
- raw += chunk;
224
- let body;
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
- body = JSON.parse(raw || 'null');
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
- res.writeHead(400, { 'content-type': 'application/json' });
230
- res.end(JSON.stringify({ error: 'invalid JSON body' }));
231
- return;
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://127.0.0.1:${httpPort}/mcp (${allTools.length} curated tools)`);
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.
@@ -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.
@@ -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({
@@ -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
- const primary = await getSection(ctx, primaryPath(game, matchId));
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',
@@ -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,
@@ -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,
@@ -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`],
@@ -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
- const score = rankScore(q, ref.name, ref.id, ref.slug, nickname);
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', {
@@ -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 } });
@@ -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 (or two UFC fighters). No first-class REST H2H exists — this tool filters match history server-side.
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) or fighter (UFC).',
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))
@@ -1,5 +1,20 @@
1
- import { toMcpResult } from '../envelope.js';
2
- export const PRIMARY_GAMES = ['lol', 'cs2', 'dota2', 'cod', 'ufc'];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "description": "Standalone MCP server for the Cito esports API — 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
5
5
  "type": "module",
6
6
  "bin": {