cito-mcp 0.1.0 → 0.2.2

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/dist/index.js CHANGED
@@ -1,196 +1,84 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * cito-mcp — standalone MCP server for the Cito esports API.
3
+ * cito-mcp — curated MCP server for the Cito esports API.
4
4
  *
5
- * Tools are generated from the live OpenAPI spec at boot (zero per-endpoint
6
- * code) and refreshed every CITO_SPEC_REFRESH_MINUTES. stdio transport by
7
- * default (Claude Code / Cursor); `--http <port>` serves a stateless
8
- * Streamable HTTP endpoint instead.
5
+ * 14 outcome tools (not OpenAPI mass-generation). stdio by default;
6
+ * `--http <port>` serves a stateless Streamable HTTP endpoint.
9
7
  *
10
8
  * Required env: CITO_API_KEY (never logged).
11
- * Optional env: CITO_API_BASE, CITO_OPENAPI_URL, CITO_SPEC_REFRESH_MINUTES.
9
+ * Optional env: CITO_API_BASE (default https://api.citoapi.com/api/v1).
12
10
  */
13
11
  import { createServer as createHttpServer } from 'node:http';
14
- import { fileURLToPath } from 'node:url';
15
- import { dirname, join } from 'node:path';
16
12
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
17
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
14
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
19
15
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
20
- import { DEFAULT_API_BASE, OPPORTUNISTIC_SPEC_KEYS, defaultSourceDefs, loadSpecSources, log, refreshSpecSourcesIfDue, specRefreshMinutes, } from './spec.js';
21
- import { generateAllTools } from './tools.js';
22
- import { executeApiCall, present } from './executor.js';
16
+ import { DEFAULT_API_BASE, log } from './client.js';
17
+ import { errorEnvelope, toMcpResult } from './envelope.js';
18
+ import { SERVER_INSTRUCTIONS } from './instructions.js';
19
+ import { allTools, getTool } from './tools/index.js';
20
+ import { runTool } from './tools/types.js';
21
+ const PACKAGE_VERSION = '0.2.2';
23
22
  const API_KEY = process.env.CITO_API_KEY;
24
23
  if (!API_KEY) {
25
24
  console.error('[cito-mcp] CITO_API_KEY is required.\n' +
26
25
  'Set it to your Cito API key, e.g.:\n' +
27
- ' claude mcp add cito -- env CITO_API_KEY=cito_... npx cito-mcp');
26
+ ' claude mcp add cito -e CITO_API_KEY=cito_... -- npx cito-mcp');
28
27
  process.exit(1);
29
28
  }
30
29
  const API_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
31
- const CACHE_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '.spec-cache.json');
32
- const REFRESH_MINUTES = specRefreshMinutes();
33
- const PACKAGE_VERSION = '0.1.0';
34
- function textResult(text, isError = false) {
35
- return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
36
- }
37
- async function fetchJson(url) {
38
- const response = await fetch(url, {
39
- headers: { 'x-api-key': API_KEY, accept: 'application/json' },
40
- });
41
- const text = await response.text();
42
- let data = null;
43
- try {
44
- data = JSON.parse(text);
45
- }
46
- catch {
47
- data = text;
48
- }
49
- return { ok: response.ok, status: response.status, data };
50
- }
51
- /** Curated: one merged "what's live right now" answer across all games. */
52
- async function liveOverview() {
53
- const games = [
54
- { game: 'lol', path: '/lol/live', label: (m) => `${m.league ?? ''} ${m.team1 ?? m.teams?.[0] ?? '?'} vs ${m.team2 ?? m.teams?.[1] ?? '?'}` },
55
- { game: 'dota2', path: '/dota2/matches/live', label: (m) => `${m.team1 ?? m.team1Name ?? '?'} vs ${m.team2 ?? m.team2Name ?? '?'}` },
56
- { game: 'cs2', path: '/cs2/matches/live', label: (m) => `${m.team1 ?? m.team1Name ?? '?'} vs ${m.team2 ?? m.team2Name ?? '?'}` },
57
- { game: 'cod', path: '/cod/matches/live', label: (m) => `${m.team1 ?? m.team1Name ?? '?'} vs ${m.team2 ?? m.team2Name ?? '?'}` },
58
- ];
59
- const sections = await Promise.all(games.map(async ({ game, path, label }) => {
60
- const { ok, status, data } = await fetchJson(`${API_BASE}${path}`);
61
- if (!ok)
62
- return { game, count: null, note: `unavailable (HTTP ${status})`, matches: [] };
63
- const rows = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
64
- return {
65
- game,
66
- count: rows.length,
67
- note: null,
68
- matches: rows.slice(0, 5).map((row) => label(row).replace(/\s+/g, ' ').trim()),
69
- };
70
- }));
71
- return textResult(JSON.stringify({ liveNow: sections }, null, 2));
72
- }
73
- /** Curated: /health plus a fallback authed probe to prove the key works. */
74
- async function apiHealth() {
75
- async function probe(path) {
76
- try {
77
- const response = await fetch(`${API_BASE}${path}`, {
78
- headers: { 'x-api-key': API_KEY, accept: 'application/json' },
79
- });
80
- return { ok: response.ok, status: response.status, body: await response.text(), headers: response.headers };
81
- }
82
- catch (error) {
83
- return { ok: false, status: 0, body: error.message };
84
- }
85
- }
86
- let answeredBy = 'health';
87
- let result = await probe('/health');
88
- if (!result.ok) {
89
- const fallback = await probe('/lol/leagues');
90
- if (fallback.ok) {
91
- answeredBy = 'lol/leagues';
92
- result = fallback;
93
- }
94
- }
95
- return textResult(JSON.stringify({
96
- probe: {
97
- answeredBy,
98
- ok: result.ok,
99
- status: result.status,
100
- body: present(result.body, 4096),
101
- },
102
- apiKey: {
103
- valid: result.ok,
104
- ...(result.ok ? {} : { hint: 'check CITO_API_KEY' }),
105
- },
106
- rateLimit: result.headers
107
- ? {
108
- tier: result.headers.get('x-cito-tier') ?? undefined,
109
- limit: result.headers.get('x-ratelimit-limit') ?? undefined,
110
- remaining: result.headers.get('x-ratelimit-remaining') ?? undefined,
111
- }
112
- : undefined,
113
- }, null, 2));
114
- }
30
+ const ctx = {
31
+ apiKey: API_KEY,
32
+ baseUrl: API_BASE,
33
+ };
115
34
  async function main() {
116
- const defs = defaultSourceDefs(API_BASE);
117
- let state;
118
- try {
119
- state = await loadSpecSources({
120
- defs,
121
- apiBase: API_BASE,
122
- cachePath: CACHE_PATH,
123
- apiKey: API_KEY,
124
- opportunisticKeys: OPPORTUNISTIC_SPEC_KEYS,
125
- });
126
- }
127
- catch (error) {
128
- console.error(`[cito-mcp] could not load any OpenAPI spec (no cache at ${CACHE_PATH}): ${error.message}`);
129
- process.exit(1);
130
- }
131
- if (state.sources.length === 0) {
132
- console.error(`[cito-mcp] no OpenAPI specs available (all skipped, no cache at ${CACHE_PATH})`);
133
- process.exit(1);
134
- }
135
- for (const source of state.sources) {
136
- log(`spec '${source.key}' loaded from ${source.origin} (${Object.keys(source.spec.paths).length} paths, base ${source.baseUrl})`);
137
- }
138
- log(`refreshing specs every ${REFRESH_MINUTES}min`);
139
- let tools = new Map(generateAllTools(state.sources).map((tool) => [tool.name, tool]));
140
- log(`generated ${tools.size} tools from ${state.sources.length} spec source(s)`);
141
- const server = new Server({ name: 'cito-mcp', version: PACKAGE_VERSION }, { capabilities: { tools: {}, resources: {} } });
142
- const CURATED_TOOLS = [
143
- {
144
- name: 'cito_live_overview',
145
- description: 'Merged "what is live right now" across all games (LoL, Dota 2, CS2, COD): per-game live match count and the first few match labels. Use this before diving into per-game live endpoints.',
146
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
147
- handler: liveOverview,
148
- },
149
- {
150
- name: 'cito_api_health',
151
- description: 'API gateway health plus an authenticated probe proving CITO_API_KEY works (returns key validity and rate-limit/tier headers when present).',
152
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
153
- handler: apiHealth,
154
- },
155
- ];
35
+ const server = new Server({
36
+ name: 'cito-mcp',
37
+ version: PACKAGE_VERSION,
38
+ }, {
39
+ capabilities: { tools: {}, resources: {} },
40
+ instructions: SERVER_INSTRUCTIONS,
41
+ });
156
42
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
157
- tools: [
158
- ...[...tools.values()].map((tool) => ({
159
- name: tool.name,
160
- description: tool.description,
161
- inputSchema: tool.inputSchema,
162
- })),
163
- ...CURATED_TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
164
- ],
43
+ tools: allTools.map(({ name, description, inputSchema }) => ({
44
+ name,
45
+ description,
46
+ inputSchema,
47
+ })),
165
48
  }));
166
49
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
167
50
  const { name } = request.params;
168
51
  const args = (request.params.arguments ?? {});
169
- const curated = CURATED_TOOLS.find((tool) => tool.name === name);
170
- if (curated)
171
- return curated.handler();
172
- const tool = tools.get(name);
173
- if (!tool)
174
- return textResult(`unknown tool: ${name}`, true);
175
- try {
176
- return await executeApiCall({ tool, args, baseUrl: API_BASE, apiKey: API_KEY });
177
- }
178
- catch (error) {
179
- return textResult(`request failed: ${error.message}`, true);
52
+ const tool = getTool(name);
53
+ if (!tool) {
54
+ return toMcpResult(errorEnvelope({
55
+ code: 'VALIDATION',
56
+ message: `Unknown tool: ${name}`,
57
+ game: null,
58
+ source: 'cito-mcp',
59
+ recover: ['Call list_capabilities to see available tools'],
60
+ }));
180
61
  }
62
+ return runTool(tool, args, ctx);
181
63
  });
182
64
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
183
65
  resources: [
184
66
  {
185
67
  uri: 'cito://llms.txt',
186
68
  name: 'Cito API agent context (llms.txt)',
187
- description: 'The Cito API llms.txt file — curated context about the API for AI agents.',
69
+ description: 'The Cito API llms.txt file — curated context about the API for AI agents (fetched on read).',
188
70
  mimeType: 'text/plain',
189
71
  },
72
+ {
73
+ uri: 'cito://capabilities',
74
+ name: 'cito-mcp capabilities summary',
75
+ description: 'Static summary of curated tools, games, and preferred workflow.',
76
+ mimeType: 'application/json',
77
+ },
190
78
  {
191
79
  uri: 'cito://openapi.json',
192
- name: 'Cito OpenAPI spec (current)',
193
- description: 'The OpenAPI spec this server generated its tools from (post-refresh).',
80
+ name: 'Cito OpenAPI (public fetch)',
81
+ description: 'Live public OpenAPI snapshot for typed clients (not the tool catalog source).',
194
82
  mimeType: 'application/json',
195
83
  },
196
84
  ],
@@ -198,56 +86,71 @@ async function main() {
198
86
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
199
87
  const { uri } = request.params;
200
88
  if (uri === 'cito://llms.txt') {
201
- const origin = new URL(API_BASE).origin;
202
- const response = await fetch(`${origin}/llms.txt`);
203
- return {
204
- contents: [{ uri, mimeType: 'text/plain', text: await response.text() }],
205
- };
89
+ const origin = new URL(API_BASE.endsWith('/api/v1') ? API_BASE.slice(0, -'/api/v1'.length) || API_BASE : API_BASE).origin;
90
+ try {
91
+ const response = await fetch(`${origin}/llms.txt`);
92
+ return {
93
+ contents: [{ uri, mimeType: 'text/plain', text: await response.text() }],
94
+ };
95
+ }
96
+ catch (error) {
97
+ return {
98
+ contents: [
99
+ {
100
+ uri,
101
+ mimeType: 'text/plain',
102
+ text: `llms.txt unavailable: ${error.message}`,
103
+ },
104
+ ],
105
+ };
106
+ }
206
107
  }
207
- if (uri === 'cito://openapi.json') {
108
+ if (uri === 'cito://capabilities') {
208
109
  return {
209
- contents: [{
110
+ contents: [
111
+ {
210
112
  uri,
211
113
  mimeType: 'application/json',
212
114
  text: JSON.stringify({
213
- sources: state.sources.map((source) => ({
214
- key: source.key,
215
- url: source.url,
216
- baseUrl: source.baseUrl,
217
- origin: source.origin,
218
- paths: Object.keys(source.spec.paths).length,
219
- })),
220
- specs: Object.fromEntries(state.sources.map((source) => [source.key, source.spec])),
115
+ version: PACKAGE_VERSION,
116
+ tools: allTools.map((t) => t.name),
117
+ games: ['lol', 'cs2', 'dota2', 'cod', 'ufc'],
118
+ workflow: [
119
+ 'list_capabilities / api_health',
120
+ 'resolve_entity → profiles / match tools',
121
+ 'live_matches / upcoming_schedule',
122
+ 'match_summary → match_details',
123
+ 'call_api escape hatch only',
124
+ ],
221
125
  }, null, 2),
222
- }],
126
+ },
127
+ ],
223
128
  };
224
129
  }
130
+ if (uri === 'cito://openapi.json') {
131
+ try {
132
+ const response = await fetch(`${API_BASE}/openapi.json`, {
133
+ headers: { 'x-api-key': API_KEY, accept: 'application/json' },
134
+ });
135
+ const text = await response.text();
136
+ return {
137
+ contents: [{ uri, mimeType: 'application/json', text }],
138
+ };
139
+ }
140
+ catch (error) {
141
+ return {
142
+ contents: [
143
+ {
144
+ uri,
145
+ mimeType: 'application/json',
146
+ text: JSON.stringify({ error: error.message }),
147
+ },
148
+ ],
149
+ };
150
+ }
151
+ }
225
152
  throw new Error(`unknown resource: ${uri}`);
226
153
  });
227
- const refreshTimer = setInterval(() => {
228
- void (async () => {
229
- const next = await refreshSpecSourcesIfDue(state, {
230
- defs,
231
- apiBase: API_BASE,
232
- cachePath: CACHE_PATH,
233
- apiKey: API_KEY,
234
- refreshMinutes: REFRESH_MINUTES,
235
- opportunisticKeys: OPPORTUNISTIC_SPEC_KEYS,
236
- });
237
- if (next) {
238
- state = next;
239
- tools = new Map(generateAllTools(state.sources).map((tool) => [tool.name, tool]));
240
- log(`tools regenerated (${tools.size}) after spec refresh`);
241
- try {
242
- void server.sendToolListChanged();
243
- }
244
- catch {
245
- // Client may not support list_changed — it will re-list on next use.
246
- }
247
- }
248
- })();
249
- }, 60_000);
250
- refreshTimer.unref();
251
154
  const httpPort = (() => {
252
155
  const index = process.argv.indexOf('--http');
253
156
  if (index === -1)
@@ -256,7 +159,6 @@ async function main() {
256
159
  return Number.isInteger(port) && port > 0 ? port : null;
257
160
  })();
258
161
  if (httpPort) {
259
- // Stateless Streamable HTTP: one transport, no session tracking.
260
162
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
261
163
  await server.connect(transport);
262
164
  const httpServer = createHttpServer(async (req, res) => {
@@ -280,12 +182,12 @@ async function main() {
280
182
  await transport.handleRequest(req, res, body);
281
183
  });
282
184
  httpServer.listen(httpPort, () => {
283
- log(`cito-mcp listening on http://127.0.0.1:${httpPort}/mcp (${tools.size} spec tools + ${CURATED_TOOLS.length} curated)`);
185
+ log(`cito-mcp ${PACKAGE_VERSION} listening on http://127.0.0.1:${httpPort}/mcp (${allTools.length} curated tools)`);
284
186
  });
285
187
  }
286
188
  else {
287
189
  await server.connect(new StdioServerTransport());
288
- log(`cito-mcp on stdio (${tools.size} spec tools + ${CURATED_TOOLS.length} curated)`);
190
+ log(`cito-mcp ${PACKAGE_VERSION} on stdio (${allTools.length} curated tools)`);
289
191
  }
290
192
  }
291
193
  await main();
@@ -0,0 +1,80 @@
1
+ /**
2
+ * MCP server instructions — agent operating manual (embedded at connect).
3
+ * Keep under ~1.5k tokens. Tool names match the curated catalog (no cito_ prefix).
4
+ */
5
+ export const SERVER_INSTRUCTIONS = `# Cito MCP — agent operating manual
6
+
7
+ You are connected to Cito esports data (read-only). Prefer curated outcome tools over raw REST. Production apps must call Cito REST with the user's API key; use this MCP to design, prototype, and resolve IDs — not as a multi-tenant runtime bus.
8
+
9
+ ## Hard rules
10
+
11
+ 1. **Never invent IDs.** Do not guess matchId, gameId, playerId, team slug, eventId, or boutId. Always obtain IDs from a prior tool result (resolve, live, schedule, search, or list). If the user gives a name ("T1", "s1mple", "UFC 300"), call resolve_entity/search_entities first.
12
+ 2. **Resolve before deep.** Name → ID/slug → summary/page → deep stats. Skip resolve only when the user already provided a Cito ID/slug.
13
+ 3. **One screen, one composite.** Prefer a page/summary tool over stitching 4–6 thin GETs.
14
+ 4. **Honor the envelope.** Parse ok, data, pagination, partial, error, meta.rateLimit, meta.entities. Partial section failures are not total failures — use successful sections.
15
+ 5. **Read-only.** All curated tools are safe to retry except where error.retryable is false for bad args / entitlement.
16
+
17
+ ## Game parameter
18
+
19
+ game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | all
20
+
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
+ - On UNSUPPORTED_GAME / 403 for a title: stop retrying that game; call api_health; report plan gaps.
23
+ - Fortnite and other titles may appear via call_api/resources; do not assume they are in the curated enum until list_capabilities says so.
24
+
25
+ ## Preferred tool order
26
+
27
+ 1. Unsure → list_capabilities (filter by game or job). Optional: api_health for key/tier/included games.
28
+ 2. Name without ID → resolve_entity (best match) or search_entities (browse). Reuse returned id/slug.
29
+ 3. Live / upcoming → live_matches; upcoming_schedule.
30
+ 4. Match UI / recap → match_summary first. match_details only for timelines, full maps, demos, live state.
31
+ 5. Team page → team_profile. Player form → player_profile. Tables/ranks → standings.
32
+ 6. Pre-match → match_preview. Rivalry → head_to_head. Event / fight-night card → event_card.
33
+ 7. Escape hatch → call_api (allowlisted path prefixes; prefer GET). Prefer curated tools.
34
+
35
+ Mnemonic: resolve → live/schedule → summary → deep.
36
+
37
+ ## Parallel vs sequence
38
+
39
+ Parallel-safe: independent reads (team_profile A ∥ team_profile B; live_matches ∥ api_health). Cap ~3–5 concurrent agent tools; check meta.rateLimit.remaining.
40
+
41
+ Serial required: resolve → detail; pagination (cursor from page N only); live board → selected match deep-dive.
42
+
43
+ Prefer server-side fan-out inside composites (partial[] recovery) over agent N+1.
44
+
45
+ ## Pagination
46
+
47
+ Lists use pagination: { limit, offset?, total?, hasMore, nextCursor, prevCursor }. Default limit 20, max 50. Loop with cursor: pagination.nextCursor and the same filters. Never invent cursors. Empty list is ok:true with items:[].
48
+
49
+ ## Errors
50
+
51
+ ok:false → read error.code (VALIDATION, NOT_FOUND, UNSUPPORTED_GAME, RATE_LIMIT, UNAUTHORIZED, UPSTREAM, PATH_NOT_ALLOWED, NOT_IMPLEMENTED). Follow error.recover[]. Retry only when retryable.
52
+
53
+ ok:true with partial[] → use successful sections; do not treat partial as total fail.
54
+ ok:true with meta.warnings[] → filters or depth degraded; do not assume ignored filters applied.
55
+ resolve_entity ambiguity is soft: ok:true with data.needsDisambiguation and data.candidates — pick a candidate; do not wait for AMBIGUOUS_ENTITY.
56
+
57
+ ## Recipes
58
+
59
+ Live board: api_health (optional) → live_matches → match_summary for selected matchId.
60
+ Team page: resolve_entity {type:team} → team_profile.
61
+ Player card: resolve_entity {type:player|fighter} → player_profile.
62
+ Fight night / event card: resolve_entity {type:event} → event_card {includeBouts:true} → match_preview for a featured bout.
63
+ Match preview (named sides): resolve_entity each side (optional) → match_preview {teamA, teamB}.
64
+ App scaffold: list_capabilities ∥ api_health ∥ live_matches, then one composite per screen.
65
+
66
+ ## Resources
67
+
68
+ - cito://llms.txt — product context when available
69
+ - cito://capabilities — catalog summary
70
+ - cito://openapi.json — optional public OpenAPI fetch for typed clients
71
+
72
+ ## What not to do
73
+
74
+ - Invent match/player/team IDs from memory.
75
+ - Parallel-paginate the same list with different cursors.
76
+ - N+1 match_details for every live row.
77
+ - Retry UNSUPPORTED_GAME or VALIDATION unchanged.
78
+ - Ship production traffic through MCP.
79
+ - Flood odds/timelines when a summary answers the question.
80
+ `;
@@ -0,0 +1,23 @@
1
+ import { metaTools } from './meta.js';
2
+ import { resolveTools } from './resolve.js';
3
+ import { liveTools } from './live.js';
4
+ import { matchTools } from './match.js';
5
+ import { playerTools } from './player.js';
6
+ import { teamTools } from './team.js';
7
+ import { standingsTools } from './standings.js';
8
+ import { insightTools } from './insight.js';
9
+ /** Curated outcome-tool catalog (15 tools). Order matches preferred cold-start ladder. */
10
+ export const allTools = [
11
+ ...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
12
+ ...resolveTools,
13
+ ...liveTools,
14
+ ...matchTools,
15
+ ...playerTools,
16
+ ...teamTools,
17
+ ...standingsTools,
18
+ ...insightTools,
19
+ ...metaTools.filter((t) => t.name === 'call_api'),
20
+ ];
21
+ export function getTool(name) {
22
+ return allTools.find((t) => t.name === name);
23
+ }