cito-mcp 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,33 +38,39 @@ Composites multi-fetch server-side, return a **stable JSON envelope**, and isola
38
38
 
39
39
  ## Install
40
40
 
41
- ### One-liner (published package)
41
+ ### One command (recommended)
42
42
 
43
43
  ```bash
44
- npx cito-mcp
44
+ npx cito-mcp install --key cito_your_key_here
45
45
  ```
46
46
 
47
- Requires `CITO_API_KEY` in the environment (see [Environment](#environment)). Most hosts inject env via their MCP config rather than a bare shell.
47
+ Detects the MCP clients installed on your machine Claude Code, Claude Desktop, Cursor, Windsurf, Codex CLI — and writes the config for each. Then restart your editor.
48
48
 
49
- ### Claude Code
49
+ It is safe to re-run: existing config is merged, not replaced, your other MCP servers are left alone, a `.cito-bak` backup is written before the first change, and running it again just rotates the key rather than adding a second entry.
50
50
 
51
51
  ```bash
52
- claude mcp add cito -e CITO_API_KEY=cito_your_key_here -- npx cito-mcp
52
+ npx cito-mcp install --dry-run # show the plan, write nothing
53
+ npx cito-mcp install --key … --client cursor # one client only
54
+ npx cito-mcp install --help
53
55
  ```
54
56
 
55
- Windows if `npx` fails under Claude:
57
+ Get a key at [citoapi.com/dashboard](https://citoapi.com/dashboard).
58
+
59
+ ### Manual — Claude Code
56
60
 
57
61
  ```bash
58
- claude mcp add cito -e CITO_API_KEY=cito_your_key_here -- cmd /c npx cito-mcp
62
+ claude mcp add cito -e CITO_API_KEY=cito_your_key_here "--" npx -y cito-mcp
59
63
  ```
60
64
 
65
+ **Quote the `--`.** PowerShell 5.1 strips a bare `--` before the CLI sees it; because `-e` takes a variable number of values it then swallows `npx -y cito-mcp` as env vars and fails with `unknown option '-y'`. The quoted form works in PowerShell, cmd, bash and zsh alike. `npx cito-mcp install` avoids the problem entirely.
66
+
61
67
  From a local clone (development):
62
68
 
63
69
  ```bash
64
70
  cd mcp
65
71
  npm install
66
72
  npm run build
67
- claude mcp add cito -e CITO_API_KEY=cito_your_key_here -- node "%CD%\dist\index.js"
73
+ claude mcp add cito -e CITO_API_KEY=cito_your_key_here "--" node "%CD%\dist\index.js"
68
74
  ```
69
75
 
70
76
  ### Cursor
@@ -376,6 +382,9 @@ CITO_API_KEY=cito_your_key_here node dist/index.js --http 8787
376
382
  # → http://127.0.0.1:8787/mcp
377
383
  ```
378
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
+
379
388
  ---
380
389
 
381
390
  ## Migration from 0.1 (auto-generated tools)
@@ -460,13 +469,20 @@ Package: **`cito-mcp@0.2.4`**
460
469
  - `live_matches`
461
470
  - `resolve_entity` (e.g. T1 / s1mple)
462
471
  - `match_summary` with a real `matchId` from live/schedule
463
- 4. Confirm `package.json` version and README match the shipped tool list (15).
472
+ 4. Confirm `package.json` version and README match the shipped tool list.
464
473
  5. `npm publish` from `mcp/` (or your release pipeline) with appropriate npm auth / access.
474
+ `prepublishOnly` runs build + tests + smoke, so a broken build cannot ship.
465
475
 
466
476
  ### Install line for docs & marketing
467
477
 
468
478
  ```bash
469
- claude mcp add cito -e CITO_API_KEY=cito_… -- npx cito-mcp
479
+ npx cito-mcp install --key cito_…
480
+ ```
481
+
482
+ Manual fallback (note the quoted `--`, required for PowerShell):
483
+
484
+ ```bash
485
+ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
470
486
  ```
471
487
 
472
488
  ```json
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,9 +18,26 @@ 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';
22
- const API_KEY = process.env.CITO_API_KEY;
23
+ import { MCP_CONFIG_PATH, MCP_PATH, MCP_SERVER_CARD_PATH, corsHeaders, ensureMcpAccept, extractApiKey, rewriteMcpProtocolMessage, setIncomingHeader, mcpConfigSchema, mcpServerCard, requestPathname, } from './http.js';
24
+ /**
25
+ * Subcommands run and exit before any transport exists.
26
+ *
27
+ * `install` writes editor config and prints to stdout, which would corrupt the
28
+ * JSON-RPC channel if it ran alongside the server — hence the early return.
29
+ * Handled here rather than in a second binary so the documented entry point
30
+ * stays `npx cito-mcp`.
31
+ */
32
+ if (process.argv[2] === 'install') {
33
+ const { runInstall } = await import('./install.js');
34
+ process.exit(await runInstall(process.argv.slice(3)));
35
+ }
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
+ }
23
41
  /**
24
42
  * Tools that need no API key. These must keep working with the server
25
43
  * unconfigured, so a user can add it and immediately see what it does.
@@ -38,20 +56,21 @@ const OFFLINE_TOOLS = new Set(['list_capabilities']);
38
56
  * recovery steps, which the model can read out; list_capabilities keeps working
39
57
  * offline so the server is browsable before it is configured.
40
58
  */
41
- if (!API_KEY) {
59
+ if (!ENV_API_KEY) {
42
60
  // stderr only — stdout is the JSON-RPC channel and must stay clean.
43
61
  console.error('[cito-mcp] CITO_API_KEY is not set. Serving tool catalog only; ' +
44
- 'API-backed tools will return MISSING_API_KEY until a key is provided. ' +
45
- '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.');
46
65
  }
47
66
  const API_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
48
67
  const ctx = {
49
68
  // Empty string when unconfigured; the dispatcher blocks API-backed tools
50
69
  // before any request is attempted, so this is never sent as a credential.
51
- apiKey: API_KEY ?? '',
70
+ apiKey: ENV_API_KEY,
52
71
  baseUrl: API_BASE,
53
72
  };
54
- async function main() {
73
+ function createCitoServer() {
55
74
  const server = new Server({
56
75
  name: 'cito-mcp',
57
76
  version: PACKAGE_VERSION,
@@ -60,11 +79,7 @@ async function main() {
60
79
  instructions: SERVER_INSTRUCTIONS,
61
80
  });
62
81
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
63
- tools: allTools.map(({ name, description, inputSchema }) => ({
64
- name,
65
- description,
66
- inputSchema,
67
- })),
82
+ tools: allTools.map(toolListing),
68
83
  }));
69
84
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
70
85
  const { name } = request.params;
@@ -81,7 +96,8 @@ async function main() {
81
96
  }
82
97
  // Fail the CALL, not the process. The model gets an actionable envelope it
83
98
  // can relay verbatim instead of the client reporting a dead server.
84
- if (!API_KEY && !OFFLINE_TOOLS.has(name)) {
99
+ const apiKey = currentApiKey();
100
+ if (!apiKey && !OFFLINE_TOOLS.has(name)) {
85
101
  return toMcpResult(errorEnvelope({
86
102
  code: 'UNAUTHORIZED',
87
103
  message: 'CITO_API_KEY is not set, so this tool cannot reach the Cito API. ' +
@@ -92,13 +108,14 @@ async function main() {
92
108
  retryable: false,
93
109
  recover: [
94
110
  'Create a key at https://citoapi.com/dashboard',
95
- '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',
96
113
  'Or set CITO_API_KEY in the mcpServers env block of your client config',
97
114
  'Call list_capabilities to browse available tools without a key',
98
115
  ],
99
116
  }));
100
117
  }
101
- return runTool(tool, args, ctx);
118
+ return runTool(tool, args, { ...ctx, apiKey });
102
119
  });
103
120
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
104
121
  resources: [
@@ -169,7 +186,7 @@ async function main() {
169
186
  if (uri === 'cito://openapi.json') {
170
187
  try {
171
188
  const response = await fetch(`${API_BASE}/openapi.json`, {
172
- headers: { 'x-api-key': API_KEY, accept: 'application/json' },
189
+ headers: { 'x-api-key': currentApiKey(), accept: 'application/json' },
173
190
  });
174
191
  const text = await response.text();
175
192
  return {
@@ -190,6 +207,9 @@ async function main() {
190
207
  }
191
208
  throw new Error(`unknown resource: ${uri}`);
192
209
  });
210
+ return server;
211
+ }
212
+ async function main() {
193
213
  const httpPort = (() => {
194
214
  const index = process.argv.indexOf('--http');
195
215
  if (index === -1)
@@ -198,33 +218,93 @@ async function main() {
198
218
  return Number.isInteger(port) && port > 0 ? port : null;
199
219
  })();
200
220
  if (httpPort) {
201
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
202
- await server.connect(transport);
221
+ const listenHost = process.env.CITO_MCP_LISTEN || '127.0.0.1';
203
222
  const httpServer = createHttpServer(async (req, res) => {
204
- 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) {
205
248
  res.writeHead(404, { 'content-type': 'application/json' });
206
249
  res.end(JSON.stringify({ error: 'use POST /mcp' }));
207
250
  return;
208
251
  }
209
- let raw = '';
210
- for await (const chunk of req)
211
- raw += chunk;
212
- 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
+ });
213
270
  try {
214
- 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
+ });
215
293
  }
216
- catch {
217
- res.writeHead(400, { 'content-type': 'application/json' });
218
- res.end(JSON.stringify({ error: 'invalid JSON body' }));
219
- 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
+ }
220
300
  }
221
- await transport.handleRequest(req, res, body);
222
301
  });
223
- httpServer.listen(httpPort, () => {
224
- 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)`);
225
304
  });
226
305
  }
227
306
  else {
307
+ const server = createCitoServer();
228
308
  await server.connect(new StdioServerTransport());
229
309
  log(`cito-mcp ${PACKAGE_VERSION} on stdio (${allTools.length} curated tools)`);
230
310
  }