cito-mcp 0.1.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 +100 -0
- package/dist/executor.js +75 -0
- package/dist/index.js +291 -0
- package/dist/spec.js +193 -0
- package/dist/tools.js +203 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# cito-mcp
|
|
2
|
+
|
|
3
|
+
Standalone [MCP](https://modelcontextprotocol.io) server for the [Cito esports API](https://api.citoapi.com) (LoL, Dota 2, CS2, COD, Fortnite, UFC, and more).
|
|
4
|
+
|
|
5
|
+
**Zero per-endpoint code:** on boot the server fetches the live OpenAPI specs from the API and generates one MCP tool per operation (105 tools today from the global + LoL specs). When the API ships a new endpoint, it appears here automatically on the next spec refresh — restart to pick it up immediately.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js >= 20
|
|
10
|
+
- A Cito API key (`CITO_API_KEY`)
|
|
11
|
+
|
|
12
|
+
## Quickstart (Claude Code)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
claude mcp add cito -- env CITO_API_KEY=cito_your_key_here npx cito-mcp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or run it straight from a clone of this repo (no publish needed):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd mcp && npm install && npm run build
|
|
22
|
+
claude mcp add cito -- env CITO_API_KEY=cito_your_key_here node "$PWD/dist/index.js"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Cursor / Windsurf
|
|
26
|
+
|
|
27
|
+
Add to your MCP config JSON (e.g. `~/.cursor/mcp.json`):
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"cito": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["cito-mcp"],
|
|
35
|
+
"env": {
|
|
36
|
+
"CITO_API_KEY": "cito_your_key_here"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The server exits immediately with a clear stderr message if `CITO_API_KEY` is missing. The key is sent only as the `x-api-key` header to the API and is never logged.
|
|
44
|
+
|
|
45
|
+
## How tool updates work (multi-spec)
|
|
46
|
+
|
|
47
|
+
The server merges **multiple OpenAPI specs** at boot — the global spec does not cover every game:
|
|
48
|
+
|
|
49
|
+
- **Default sources**: the global spec (`$CITO_API_BASE/openapi.json`) + the LoL spec (`$CITO_API_BASE/lol/openapi.json`). Today: 71 + 34 = 105 generated tools.
|
|
50
|
+
- **Opportunistic per-game specs**: at boot the server also probes `$CITO_API_BASE/{cod,fortnite,dota2,cs2,ufc}/openapi.json` **with your `x-api-key`** and merges any that return 200. 401/404s are skipped quietly (one stderr line each).
|
|
51
|
+
- **Add or replace sources via env**:
|
|
52
|
+
- `CITO_OPENAPI_URLS` — comma-separated list, replaces the defaults entirely (e.g. `CITO_OPENAPI_URLS=https://api.citoapi.com/api/v1/openapi.json,https://api.citoapi.com/api/v1/cod/openapi.json`).
|
|
53
|
+
- `CITO_OPENAPI_URL` (singular) — single-spec override (legacy, still works).
|
|
54
|
+
- **Per-spec base URLs**: each spec's `servers[0].url` is used as the base for its tools (the global spec's paths are relative to `/api/v1`; the LoL spec's paths already include `/api/v1/lol` and its servers entry is the bare origin — so `servers[0].url` + verbatim paths is correct for both). Escape hatch: `CITO_SPEC_BASE_<KEY>` (e.g. `CITO_SPEC_BASE_LOL=https://staging.example.com/api/v1`).
|
|
55
|
+
- **Collisions**: merge is path/operation concatenation. On an `operationId` collision across specs, the later spec's tool is prefixed with its game key (`cito_lol_…`) and logged to stderr.
|
|
56
|
+
- **No operationId?** Fallback names are derived from method + path (`get /api/v1/lol/live/{gameId}/stats` → `cito_getlollivebygameidstats`). The real LoL spec currently ships no operationIds, so all its tools use fallback names.
|
|
57
|
+
- The last good specs are cached to `mcp/.spec-cache.json` (one merged file, per-source entries) — a failed fetch falls back to the cached copy per source.
|
|
58
|
+
- Specs are refreshed every `CITO_SPEC_REFRESH_MINUTES` (default 60); tools are regenerated in place and clients are notified via `tools/list_changed`. **Restart the server to pick up brand-new endpoints immediately.**
|
|
59
|
+
- Tool names: `cito_` + sanitized name. Descriptions preserve the spec's summaries and per-parameter docs.
|
|
60
|
+
- All logs go to **stderr only** (stdout is the MCP channel).
|
|
61
|
+
|
|
62
|
+
## Curated extras
|
|
63
|
+
|
|
64
|
+
Hand-written, on top of the generated tools:
|
|
65
|
+
|
|
66
|
+
| Tool | Purpose |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `cito_live_overview` | One merged "what's live right now" across all games (`/lol/live`, `/dota2/matches/live`, `/cs2/matches/live`, `/cod/matches/live`): per-game count + first few match labels. Games that 4xx are skipped, not fatal. |
|
|
69
|
+
| `cito_api_health` | Authenticated probe proving your key works: tries `$CITO_API_BASE/health`, falls back to `$CITO_API_BASE/lol/leagues`, reports which probe answered, plus rate-limit/tier headers when present. |
|
|
70
|
+
|
|
71
|
+
Resources:
|
|
72
|
+
|
|
73
|
+
- `cito://llms.txt` — the API's agent-context file, fetched on read.
|
|
74
|
+
- `cito://openapi.json` — the current spec this server generated its tools from.
|
|
75
|
+
|
|
76
|
+
## Behavior notes
|
|
77
|
+
|
|
78
|
+
- Path params are interpolated into the URL, query props become the query string, `body` becomes the JSON body.
|
|
79
|
+
- Non-2xx responses are returned as-is with `isError: true` — agents can read the API's own error codes (quota, tier gates, 404s).
|
|
80
|
+
- Responses over ~100KB are truncated with a note telling the agent to narrow with query parameters.
|
|
81
|
+
- Adding new endpoints to the API = **zero MCP code**. The spec is the contract.
|
|
82
|
+
|
|
83
|
+
## HTTP transport (optional)
|
|
84
|
+
|
|
85
|
+
stdio is the default (what Claude Code / Cursor use). A stateless Streamable HTTP endpoint is available:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
CITO_API_KEY=cito_your_key_here node dist/index.js --http 8787
|
|
89
|
+
# → http://127.0.0.1:8787/mcp
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm install
|
|
96
|
+
npm run build # tsc → dist/
|
|
97
|
+
npm test # node:test via tsx, no network
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Environment variables: `CITO_API_KEY` (required), `CITO_API_BASE` (default `https://api.citoapi.com/api/v1`), `CITO_OPENAPI_URLS` / `CITO_OPENAPI_URL` (spec source overrides), `CITO_SPEC_BASE_<KEY>` (per-spec base override), `CITO_SPEC_REFRESH_MINUTES` (default 60).
|
package/dist/executor.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024;
|
|
2
|
+
export function buildRequestUrl(baseUrl, tool, args) {
|
|
3
|
+
let path = tool.path;
|
|
4
|
+
for (const param of tool.pathParams) {
|
|
5
|
+
const value = args[param];
|
|
6
|
+
if (value === undefined || value === null) {
|
|
7
|
+
throw new Error(`missing required path parameter: ${param}`);
|
|
8
|
+
}
|
|
9
|
+
path = path.split(`{${param}}`).join(encodeURIComponent(String(value)));
|
|
10
|
+
}
|
|
11
|
+
const query = new URLSearchParams();
|
|
12
|
+
for (const param of tool.queryParams) {
|
|
13
|
+
const value = args[param];
|
|
14
|
+
if (value === undefined || value === null)
|
|
15
|
+
continue;
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
for (const item of value)
|
|
18
|
+
query.append(param, String(item));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
query.append(param, String(value));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const qs = query.toString();
|
|
25
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
26
|
+
return `${base}${path}${qs ? `?${qs}` : ''}`;
|
|
27
|
+
}
|
|
28
|
+
export async function executeApiCall(opts) {
|
|
29
|
+
const fetcher = opts.fetchImpl ?? fetch;
|
|
30
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
// Per-tool base: each source spec declares its own servers[0].url (LoL
|
|
34
|
+
// paths already include /api/v1/lol; global paths are relative to /api/v1).
|
|
35
|
+
const baseUrl = opts.tool.baseUrl || opts.baseUrl;
|
|
36
|
+
url = buildRequestUrl(baseUrl, opts.tool, opts.args);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return { content: [{ type: 'text', text: error.message }], isError: true };
|
|
40
|
+
}
|
|
41
|
+
const headers = {
|
|
42
|
+
'x-api-key': opts.apiKey,
|
|
43
|
+
accept: 'application/json',
|
|
44
|
+
};
|
|
45
|
+
let body;
|
|
46
|
+
if (opts.tool.hasBody && opts.args.body !== undefined) {
|
|
47
|
+
headers['content-type'] = 'application/json';
|
|
48
|
+
body = JSON.stringify(opts.args.body);
|
|
49
|
+
}
|
|
50
|
+
const response = await fetcher(url, { method: opts.tool.method, headers, body });
|
|
51
|
+
const text = await response.text();
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: 'text', text: `HTTP ${response.status}\n\n${text}` }],
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { content: [{ type: 'text', text: present(text, maxBytes) }] };
|
|
59
|
+
}
|
|
60
|
+
/** Pretty-print JSON; truncate oversized bodies with an agent-actionable note. */
|
|
61
|
+
export function present(text, maxBytes) {
|
|
62
|
+
let out = text;
|
|
63
|
+
try {
|
|
64
|
+
out = JSON.stringify(JSON.parse(text), null, 2);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Not JSON — return as-is.
|
|
68
|
+
}
|
|
69
|
+
if (out.length > maxBytes) {
|
|
70
|
+
return (`${out.slice(0, maxBytes)}\n\n` +
|
|
71
|
+
`[cito-mcp] Response truncated at ${maxBytes} bytes (full body was ${out.length}). ` +
|
|
72
|
+
`Narrow the result with query parameters (e.g. smaller limit, date range, or an id filter) and retry.`);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cito-mcp — standalone MCP server for the Cito esports API.
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* Required env: CITO_API_KEY (never logged).
|
|
11
|
+
* Optional env: CITO_API_BASE, CITO_OPENAPI_URL, CITO_SPEC_REFRESH_MINUTES.
|
|
12
|
+
*/
|
|
13
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
17
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
18
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
19
|
+
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';
|
|
23
|
+
const API_KEY = process.env.CITO_API_KEY;
|
|
24
|
+
if (!API_KEY) {
|
|
25
|
+
console.error('[cito-mcp] CITO_API_KEY is required.\n' +
|
|
26
|
+
'Set it to your Cito API key, e.g.:\n' +
|
|
27
|
+
' claude mcp add cito -- env CITO_API_KEY=cito_... npx cito-mcp');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
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
|
+
}
|
|
115
|
+
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
|
+
];
|
|
156
|
+
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
|
+
],
|
|
165
|
+
}));
|
|
166
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
167
|
+
const { name } = request.params;
|
|
168
|
+
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);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
183
|
+
resources: [
|
|
184
|
+
{
|
|
185
|
+
uri: 'cito://llms.txt',
|
|
186
|
+
name: 'Cito API agent context (llms.txt)',
|
|
187
|
+
description: 'The Cito API llms.txt file — curated context about the API for AI agents.',
|
|
188
|
+
mimeType: 'text/plain',
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
uri: 'cito://openapi.json',
|
|
192
|
+
name: 'Cito OpenAPI spec (current)',
|
|
193
|
+
description: 'The OpenAPI spec this server generated its tools from (post-refresh).',
|
|
194
|
+
mimeType: 'application/json',
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
}));
|
|
198
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
199
|
+
const { uri } = request.params;
|
|
200
|
+
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
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (uri === 'cito://openapi.json') {
|
|
208
|
+
return {
|
|
209
|
+
contents: [{
|
|
210
|
+
uri,
|
|
211
|
+
mimeType: 'application/json',
|
|
212
|
+
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])),
|
|
221
|
+
}, null, 2),
|
|
222
|
+
}],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
throw new Error(`unknown resource: ${uri}`);
|
|
226
|
+
});
|
|
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
|
+
const httpPort = (() => {
|
|
252
|
+
const index = process.argv.indexOf('--http');
|
|
253
|
+
if (index === -1)
|
|
254
|
+
return null;
|
|
255
|
+
const port = Number(process.argv[index + 1]);
|
|
256
|
+
return Number.isInteger(port) && port > 0 ? port : null;
|
|
257
|
+
})();
|
|
258
|
+
if (httpPort) {
|
|
259
|
+
// Stateless Streamable HTTP: one transport, no session tracking.
|
|
260
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
261
|
+
await server.connect(transport);
|
|
262
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
263
|
+
if (req.url !== '/mcp') {
|
|
264
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
265
|
+
res.end(JSON.stringify({ error: 'use POST /mcp' }));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
let raw = '';
|
|
269
|
+
for await (const chunk of req)
|
|
270
|
+
raw += chunk;
|
|
271
|
+
let body;
|
|
272
|
+
try {
|
|
273
|
+
body = JSON.parse(raw || 'null');
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
277
|
+
res.end(JSON.stringify({ error: 'invalid JSON body' }));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
await transport.handleRequest(req, res, body);
|
|
281
|
+
});
|
|
282
|
+
httpServer.listen(httpPort, () => {
|
|
283
|
+
log(`cito-mcp listening on http://127.0.0.1:${httpPort}/mcp (${tools.size} spec tools + ${CURATED_TOOLS.length} curated)`);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
await server.connect(new StdioServerTransport());
|
|
288
|
+
log(`cito-mcp on stdio (${tools.size} spec tools + ${CURATED_TOOLS.length} curated)`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
await main();
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-spec loading for cito-mcp.
|
|
3
|
+
*
|
|
4
|
+
* The global spec (…/api/v1/openapi.json) does NOT cover every game — LoL has
|
|
5
|
+
* its own spec (…/api/v1/lol/openapi.json) and other games may ship keyed
|
|
6
|
+
* specs later. Boot fetches a list of spec sources, opportunistically probes
|
|
7
|
+
* per-game spec URLs WITH the x-api-key header (skips 401/404 quietly), and
|
|
8
|
+
* merges everything that answered.
|
|
9
|
+
*
|
|
10
|
+
* Per-source base URL rule (data-driven, documented in README):
|
|
11
|
+
* baseUrl = spec.servers[0].url when absolute, else CITO_API_BASE.
|
|
12
|
+
* The global spec's paths are relative to /api/v1 (its servers entry); the
|
|
13
|
+
* LoL spec's paths already include /api/v1/lol and its servers entry is the
|
|
14
|
+
* bare origin — so using servers[0].url + verbatim paths is correct for both.
|
|
15
|
+
* Escape hatch: CITO_SPEC_BASE_<KEY> (uppercased key, dashes → underscores).
|
|
16
|
+
*
|
|
17
|
+
* Cache: one merged .spec-cache.json storing per-source entries; any source
|
|
18
|
+
* that fails to fetch falls back to its cached entry.
|
|
19
|
+
*/
|
|
20
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
21
|
+
export const DEFAULT_API_BASE = 'https://api.citoapi.com/api/v1';
|
|
22
|
+
/** Games probed opportunistically for keyed specs at boot (with the API key). */
|
|
23
|
+
export const OPPORTUNISTIC_SPEC_KEYS = ['cod', 'fortnite', 'dota2', 'cs2', 'ufc'];
|
|
24
|
+
/** stderr ONLY — stdout is the MCP stdio channel. */
|
|
25
|
+
export function log(message) {
|
|
26
|
+
console.error(`[cito-mcp] ${message}`);
|
|
27
|
+
}
|
|
28
|
+
export function specRefreshMinutes() {
|
|
29
|
+
const parsed = Number(process.env.CITO_SPEC_REFRESH_MINUTES);
|
|
30
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 60;
|
|
31
|
+
}
|
|
32
|
+
/** Key from a spec URL: the path segment before openapi.json ('global' when none). */
|
|
33
|
+
export function specKeyFromUrl(url, apiBase) {
|
|
34
|
+
const base = apiBase.replace(/\/+$/, '');
|
|
35
|
+
if (url === `${base}/openapi.json`)
|
|
36
|
+
return 'global';
|
|
37
|
+
const match = url.replace(/\/+$/, '').match(/\/([^/]+)\/openapi\.json$/);
|
|
38
|
+
return match?.[1] ?? 'global';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Source list resolution:
|
|
42
|
+
* - CITO_OPENAPI_URLS (comma-separated) overrides entirely.
|
|
43
|
+
* - CITO_OPENAPI_URL (singular) overrides to a single source.
|
|
44
|
+
* - Default: global + lol specs on CITO_API_BASE.
|
|
45
|
+
*/
|
|
46
|
+
export function defaultSourceDefs(apiBase, env = process.env) {
|
|
47
|
+
const base = apiBase.replace(/\/+$/, '');
|
|
48
|
+
if (env.CITO_OPENAPI_URLS) {
|
|
49
|
+
return env.CITO_OPENAPI_URLS
|
|
50
|
+
.split(',')
|
|
51
|
+
.map((url) => url.trim())
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.map((url) => ({ key: specKeyFromUrl(url, base), url }));
|
|
54
|
+
}
|
|
55
|
+
if (env.CITO_OPENAPI_URL) {
|
|
56
|
+
const url = env.CITO_OPENAPI_URL.trim();
|
|
57
|
+
return [{ key: specKeyFromUrl(url, base), url }];
|
|
58
|
+
}
|
|
59
|
+
return [
|
|
60
|
+
{ key: 'global', url: `${base}/openapi.json` },
|
|
61
|
+
{ key: 'lol', url: `${base}/lol/openapi.json` },
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
/** Per-source base URL: env escape hatch → servers[0].url when absolute → apiBase. */
|
|
65
|
+
export function baseUrlForSpec(key, spec, apiBase, env = process.env) {
|
|
66
|
+
const envKey = `CITO_SPEC_BASE_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
|
|
67
|
+
const override = env[envKey];
|
|
68
|
+
if (override)
|
|
69
|
+
return override.replace(/\/+$/, '');
|
|
70
|
+
const server = spec.servers?.[0]?.url;
|
|
71
|
+
if (server && /^https?:\/\//.test(server))
|
|
72
|
+
return server.replace(/\/+$/, '');
|
|
73
|
+
return apiBase.replace(/\/+$/, '');
|
|
74
|
+
}
|
|
75
|
+
export async function fetchSpec(url, opts = {}) {
|
|
76
|
+
const fetcher = opts.fetchImpl ?? fetch;
|
|
77
|
+
const headers = { accept: 'application/json' };
|
|
78
|
+
if (opts.apiKey)
|
|
79
|
+
headers['x-api-key'] = opts.apiKey;
|
|
80
|
+
const response = await fetcher(url, { headers });
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(`HTTP ${response.status}`);
|
|
83
|
+
}
|
|
84
|
+
const spec = (await response.json());
|
|
85
|
+
if (!spec || typeof spec !== 'object' || !spec.paths) {
|
|
86
|
+
throw new Error('response is not an OpenAPI document');
|
|
87
|
+
}
|
|
88
|
+
return spec;
|
|
89
|
+
}
|
|
90
|
+
export async function readSpecCache(cachePath) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = await readFile(cachePath, 'utf8');
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
return parsed && Array.isArray(parsed.sources) ? parsed : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function writeSpecCache(cachePath, sources) {
|
|
101
|
+
try {
|
|
102
|
+
const file = {
|
|
103
|
+
fetchedAt: new Date().toISOString(),
|
|
104
|
+
sources: sources.map((source) => ({
|
|
105
|
+
key: source.key,
|
|
106
|
+
url: source.url,
|
|
107
|
+
baseUrl: source.baseUrl,
|
|
108
|
+
spec: source.spec,
|
|
109
|
+
})),
|
|
110
|
+
};
|
|
111
|
+
await writeFile(cachePath, JSON.stringify(file), 'utf8');
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
log(`spec cache write failed: ${error.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Load all sources: declared defs (network → cache fallback per source), then
|
|
119
|
+
* opportunistic per-game probes with the API key (skip non-200 quietly).
|
|
120
|
+
*/
|
|
121
|
+
export async function loadSpecSources(opts) {
|
|
122
|
+
const cache = await readSpecCache(opts.cachePath);
|
|
123
|
+
const sources = [];
|
|
124
|
+
const skipped = [];
|
|
125
|
+
let cacheDirty = false;
|
|
126
|
+
for (const def of opts.defs) {
|
|
127
|
+
try {
|
|
128
|
+
const spec = await fetchSpec(def.url, { apiKey: opts.apiKey, fetchImpl: opts.fetchImpl });
|
|
129
|
+
sources.push({
|
|
130
|
+
...def,
|
|
131
|
+
spec,
|
|
132
|
+
baseUrl: baseUrlForSpec(def.key, spec, opts.apiBase),
|
|
133
|
+
origin: 'network',
|
|
134
|
+
});
|
|
135
|
+
cacheDirty = true;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
const cached = cache?.sources.find((entry) => entry.key === def.key);
|
|
139
|
+
if (cached) {
|
|
140
|
+
log(`spec '${def.key}' fetch failed (${error.message}) — using cached copy`);
|
|
141
|
+
sources.push({ ...def, spec: cached.spec, baseUrl: cached.baseUrl, origin: 'cache' });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
skipped.push({ ...def, reason: error.message });
|
|
145
|
+
log(`spec '${def.key}' skipped: ${error.message} (no cache)`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const declaredKeys = new Set(opts.defs.map((def) => def.key));
|
|
150
|
+
const base = opts.apiBase.replace(/\/+$/, '');
|
|
151
|
+
for (const key of opts.opportunisticKeys ?? []) {
|
|
152
|
+
if (declaredKeys.has(key))
|
|
153
|
+
continue;
|
|
154
|
+
const url = `${base}/${key}/openapi.json`;
|
|
155
|
+
try {
|
|
156
|
+
const spec = await fetchSpec(url, { apiKey: opts.apiKey, fetchImpl: opts.fetchImpl });
|
|
157
|
+
log(`spec '${key}' discovered at ${url}`);
|
|
158
|
+
sources.push({ key, url, spec, baseUrl: baseUrlForSpec(key, spec, opts.apiBase), origin: 'network' });
|
|
159
|
+
cacheDirty = true;
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
skipped.push({ key, url, reason: error.message });
|
|
163
|
+
log(`spec '${key}' skipped: ${error.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const networkSources = sources.filter((source) => source.origin === 'network');
|
|
167
|
+
if (cacheDirty && networkSources.length > 0) {
|
|
168
|
+
// Merge fresh sources over the previous cache so skipped keys keep theirs.
|
|
169
|
+
const merged = [...networkSources];
|
|
170
|
+
for (const cachedEntry of cache?.sources ?? []) {
|
|
171
|
+
if (!merged.some((source) => source.key === cachedEntry.key)) {
|
|
172
|
+
merged.push({ key: cachedEntry.key, url: cachedEntry.url, spec: cachedEntry.spec, baseUrl: cachedEntry.baseUrl, origin: 'cache' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
await writeSpecCache(opts.cachePath, merged);
|
|
176
|
+
}
|
|
177
|
+
return { sources, skipped, fetchedAt: Date.now() };
|
|
178
|
+
}
|
|
179
|
+
/** Re-fetch all sources when the refresh interval elapsed; null = not due. */
|
|
180
|
+
export async function refreshSpecSourcesIfDue(state, opts) {
|
|
181
|
+
if (Date.now() - state.fetchedAt < opts.refreshMinutes * 60_000)
|
|
182
|
+
return null;
|
|
183
|
+
const next = await loadSpecSources(opts);
|
|
184
|
+
const changed = next.sources.length !== state.sources.length
|
|
185
|
+
|| next.sources.some((source) => {
|
|
186
|
+
const previous = state.sources.find((entry) => entry.key === source.key);
|
|
187
|
+
return !previous || previous.spec !== source.spec;
|
|
188
|
+
});
|
|
189
|
+
if (!changed)
|
|
190
|
+
return { ...next, sources: state.sources, fetchedAt: Date.now() };
|
|
191
|
+
log(`specs refreshed (${next.sources.length} sources)`);
|
|
192
|
+
return next;
|
|
193
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { log } from './spec.js';
|
|
2
|
+
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);
|
|
3
|
+
const MAX_REF_DEPTH = 4;
|
|
4
|
+
/** Resolve local #/components/... $refs (bounded depth; cycles returned as-is). */
|
|
5
|
+
export function resolveRefs(node, spec, depth = 0) {
|
|
6
|
+
if (depth > MAX_REF_DEPTH || node === null || typeof node !== 'object')
|
|
7
|
+
return node;
|
|
8
|
+
if (Array.isArray(node))
|
|
9
|
+
return node.map((item) => resolveRefs(item, spec, depth + 1));
|
|
10
|
+
const record = node;
|
|
11
|
+
if (typeof record.$ref === 'string' && record.$ref.startsWith('#/')) {
|
|
12
|
+
const target = record.$ref
|
|
13
|
+
.slice(2)
|
|
14
|
+
.split('/')
|
|
15
|
+
.reduce((acc, key) => {
|
|
16
|
+
if (acc && typeof acc === 'object')
|
|
17
|
+
return acc[key];
|
|
18
|
+
return undefined;
|
|
19
|
+
}, spec);
|
|
20
|
+
if (target === undefined)
|
|
21
|
+
return record;
|
|
22
|
+
return resolveRefs(target, spec, depth + 1);
|
|
23
|
+
}
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const [key, value] of Object.entries(record)) {
|
|
26
|
+
out[key] = resolveRefs(value, spec, depth + 1);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** Body schema passthrough: refs resolved, readOnly stripped (agents can't send those). */
|
|
31
|
+
export function simplifyBodySchema(schema, spec) {
|
|
32
|
+
const resolved = resolveRefs(schema, spec);
|
|
33
|
+
if (resolved === null || typeof resolved !== 'object')
|
|
34
|
+
return resolved;
|
|
35
|
+
if (Array.isArray(resolved))
|
|
36
|
+
return resolved.map((item) => simplifyBodySchema(item, spec));
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
39
|
+
if (key === 'readOnly')
|
|
40
|
+
continue;
|
|
41
|
+
out[key] = simplifyBodySchema(value, spec);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function capitalize(segment) {
|
|
46
|
+
return segment ? segment[0].toUpperCase() + segment.slice(1) : segment;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Fallback operation name when the spec has no operationId (the LoL spec
|
|
50
|
+
* ships none): method + capitalized path segments, {param} → By<Param>,
|
|
51
|
+
* leading api/version segments dropped.
|
|
52
|
+
* get /api/v1/lol/live/{gameId}/stats → getLolLiveStatsByGameId
|
|
53
|
+
*/
|
|
54
|
+
export function operationNameFromPath(method, path) {
|
|
55
|
+
const segments = path.split('/').filter(Boolean);
|
|
56
|
+
const parts = [method.toLowerCase()];
|
|
57
|
+
for (const segment of segments) {
|
|
58
|
+
if (segment === 'api' || /^v\d+$/.test(segment))
|
|
59
|
+
continue; // api + version segments
|
|
60
|
+
const param = segment.match(/^\{(.+)\}$/);
|
|
61
|
+
if (param) {
|
|
62
|
+
parts.push(`By${capitalize(param[1])}`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
parts.push(capitalize(segment));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return parts.join('');
|
|
69
|
+
}
|
|
70
|
+
function sanitizeBase(operationName) {
|
|
71
|
+
return operationName.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Tool name: cito_ + sanitized operation name. Exact sanitized collisions
|
|
75
|
+
* append the HTTP method, then a counter. (Cross-spec operationId collisions
|
|
76
|
+
* are handled earlier with the spec-key prefix — see generateAllTools.)
|
|
77
|
+
*/
|
|
78
|
+
export function sanitizeToolName(operationName, method, used) {
|
|
79
|
+
const base = `cito_${sanitizeBase(operationName)}`;
|
|
80
|
+
if (!used.has(base)) {
|
|
81
|
+
used.add(base);
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
84
|
+
const withMethod = `${base}_${method.toLowerCase()}`;
|
|
85
|
+
if (!used.has(withMethod)) {
|
|
86
|
+
used.add(withMethod);
|
|
87
|
+
return withMethod;
|
|
88
|
+
}
|
|
89
|
+
let counter = 2;
|
|
90
|
+
while (used.has(`${withMethod}_${counter}`))
|
|
91
|
+
counter += 1;
|
|
92
|
+
const name = `${withMethod}_${counter}`;
|
|
93
|
+
used.add(name);
|
|
94
|
+
return name;
|
|
95
|
+
}
|
|
96
|
+
function parameterDescription(param) {
|
|
97
|
+
const required = param.in === 'path' ? true : param.required === true;
|
|
98
|
+
const flags = [param.in, required ? 'required' : 'optional'].join(', ');
|
|
99
|
+
return `- ${param.name} (${flags})${param.description ? `: ${param.description}` : ''}`;
|
|
100
|
+
}
|
|
101
|
+
export function buildToolDescription(op) {
|
|
102
|
+
const parts = [];
|
|
103
|
+
if (op.summary)
|
|
104
|
+
parts.push(op.summary);
|
|
105
|
+
if (op.description && op.description !== op.summary)
|
|
106
|
+
parts.push(op.description);
|
|
107
|
+
const params = op.parameters ?? [];
|
|
108
|
+
if (params.length > 0) {
|
|
109
|
+
parts.push(`Parameters:\n${params.map(parameterDescription).join('\n')}`);
|
|
110
|
+
}
|
|
111
|
+
if (op.requestBody)
|
|
112
|
+
parts.push('Accepts a JSON request body (see the `body` argument).');
|
|
113
|
+
return parts.join('\n\n') || 'Cito API operation';
|
|
114
|
+
}
|
|
115
|
+
function jsonBodySchema(op, spec) {
|
|
116
|
+
const content = op.requestBody?.content ?? {};
|
|
117
|
+
const json = content['application/json'] ?? Object.values(content)[0];
|
|
118
|
+
return json?.schema ? simplifyBodySchema(json.schema, spec) : { type: 'object' };
|
|
119
|
+
}
|
|
120
|
+
export function buildOperationTool(method, path, op, source, used, namePrefix = '') {
|
|
121
|
+
const operationName = op.operationId || operationNameFromPath(method, path);
|
|
122
|
+
const pathParams = [];
|
|
123
|
+
const queryParams = [];
|
|
124
|
+
const properties = {};
|
|
125
|
+
const required = [];
|
|
126
|
+
for (const param of op.parameters ?? []) {
|
|
127
|
+
const schema = resolveRefs(param.schema ?? { type: 'string' }, source.spec);
|
|
128
|
+
properties[param.name] = {
|
|
129
|
+
...schema,
|
|
130
|
+
...(param.description ? { description: param.description } : {}),
|
|
131
|
+
};
|
|
132
|
+
if (param.in === 'path') {
|
|
133
|
+
pathParams.push(param.name);
|
|
134
|
+
required.push(param.name);
|
|
135
|
+
}
|
|
136
|
+
else if (param.in === 'query') {
|
|
137
|
+
queryParams.push(param.name);
|
|
138
|
+
if (param.required === true)
|
|
139
|
+
required.push(param.name);
|
|
140
|
+
}
|
|
141
|
+
// header/cookie params are not exposed as tool args (auth is server-side).
|
|
142
|
+
}
|
|
143
|
+
const hasBody = Boolean(op.requestBody);
|
|
144
|
+
if (hasBody) {
|
|
145
|
+
properties.body = {
|
|
146
|
+
...jsonBodySchema(op, source.spec),
|
|
147
|
+
description: 'JSON request body.',
|
|
148
|
+
};
|
|
149
|
+
if (op.requestBody?.required === true)
|
|
150
|
+
required.push('body');
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
name: sanitizeToolName(`${namePrefix}${operationName}`, method, used),
|
|
154
|
+
description: buildToolDescription(op),
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties,
|
|
158
|
+
...(required.length > 0 ? { required } : {}),
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
},
|
|
161
|
+
method: method.toUpperCase(),
|
|
162
|
+
path,
|
|
163
|
+
baseUrl: source.baseUrl,
|
|
164
|
+
sourceKey: source.key,
|
|
165
|
+
pathParams,
|
|
166
|
+
queryParams,
|
|
167
|
+
hasBody,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Generate tools for all loaded spec sources. On an operationId collision
|
|
172
|
+
* across sources, the LATER source's tool is prefixed with its spec key
|
|
173
|
+
* (cito_<key>_...) and the collision is logged to stderr.
|
|
174
|
+
*/
|
|
175
|
+
export function generateAllTools(sources) {
|
|
176
|
+
const tools = [];
|
|
177
|
+
const used = new Set();
|
|
178
|
+
const claimedOperationIds = new Set();
|
|
179
|
+
for (const source of sources) {
|
|
180
|
+
for (const [path, item] of Object.entries(source.spec.paths ?? {})) {
|
|
181
|
+
for (const [method, op] of Object.entries(item ?? {})) {
|
|
182
|
+
if (!HTTP_METHODS.has(method.toLowerCase()) || !op || typeof op !== 'object')
|
|
183
|
+
continue;
|
|
184
|
+
let namePrefix = '';
|
|
185
|
+
if (op.operationId && claimedOperationIds.has(op.operationId)) {
|
|
186
|
+
namePrefix = `${source.key}_`;
|
|
187
|
+
log(`operationId collision: '${op.operationId}' also in spec '${source.key}' — tool prefixed cito_${source.key}_…`);
|
|
188
|
+
}
|
|
189
|
+
const tool = buildOperationTool(method, path, op, source, used, namePrefix);
|
|
190
|
+
if (tool) {
|
|
191
|
+
tools.push(tool);
|
|
192
|
+
if (op.operationId)
|
|
193
|
+
claimedOperationIds.add(op.operationId);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return tools;
|
|
199
|
+
}
|
|
200
|
+
/** Single-spec convenience wrapper (tests / single-source setups). */
|
|
201
|
+
export function generateTools(spec, baseUrl = '') {
|
|
202
|
+
return generateAllTools([{ key: 'global', baseUrl, spec }]);
|
|
203
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cito-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standalone MCP server for the Cito esports API — tools generated from the live OpenAPI spec, zero per-endpoint code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cito-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"test": "tsx --test src/*.test.ts",
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "1.29.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.10.0",
|
|
27
|
+
"tsx": "^4.23.1",
|
|
28
|
+
"typescript": "^5.9.2"
|
|
29
|
+
}
|
|
30
|
+
}
|