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/README.md +396 -44
- package/dist/client.js +208 -0
- package/dist/envelope.js +210 -0
- package/dist/index.js +103 -201
- package/dist/instructions.js +80 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/insight.js +1013 -0
- package/dist/tools/live.js +447 -0
- package/dist/tools/match.js +464 -0
- package/dist/tools/meta.js +609 -0
- package/dist/tools/normalize.js +177 -0
- package/dist/tools/player.js +357 -0
- package/dist/tools/resolve.js +518 -0
- package/dist/tools/standings.js +319 -0
- package/dist/tools/team.js +704 -0
- package/dist/tools/types.js +85 -0
- package/package.json +3 -3
- package/dist/executor.js +0 -75
- package/dist/spec.js +0 -193
- package/dist/tools.js +0 -203
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { toMcpResult } from '../envelope.js';
|
|
2
|
+
export const PRIMARY_GAMES = ['lol', 'cs2', 'dota2', 'cod', 'ufc'];
|
|
3
|
+
export const GAME_ENUM = [...PRIMARY_GAMES, 'all'];
|
|
4
|
+
export function isPrimaryGame(value) {
|
|
5
|
+
return typeof value === 'string' && PRIMARY_GAMES.includes(value);
|
|
6
|
+
}
|
|
7
|
+
export function parseGame(value, opts) {
|
|
8
|
+
if (value === undefined || value === null || value === '') {
|
|
9
|
+
if (opts?.required)
|
|
10
|
+
return { error: 'game is required' };
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
if (typeof value !== 'string')
|
|
14
|
+
return { error: 'game must be a string' };
|
|
15
|
+
const g = value.toLowerCase();
|
|
16
|
+
if (g === 'all') {
|
|
17
|
+
if (opts?.allowAll === false) {
|
|
18
|
+
return { error: 'game=all is not valid for this tool; pick lol|cs2|dota2|cod|ufc' };
|
|
19
|
+
}
|
|
20
|
+
return { game: 'all' };
|
|
21
|
+
}
|
|
22
|
+
if (PRIMARY_GAMES.includes(g)) {
|
|
23
|
+
return { game: g };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
error: `unsupported game "${value}"; use lol|cs2|dota2|cod|ufc${opts?.allowAll !== false ? '|all' : ''}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function gameSchema(opts) {
|
|
30
|
+
const values = opts?.allowAll === false ? [...PRIMARY_GAMES] : [...GAME_ENUM];
|
|
31
|
+
return {
|
|
32
|
+
type: 'string',
|
|
33
|
+
enum: values,
|
|
34
|
+
description: opts?.description ??
|
|
35
|
+
(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".'),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function limitSchema(opts) {
|
|
41
|
+
const def = opts?.default ?? 20;
|
|
42
|
+
const max = opts?.max ?? 50;
|
|
43
|
+
return {
|
|
44
|
+
type: 'integer',
|
|
45
|
+
minimum: 1,
|
|
46
|
+
maximum: max,
|
|
47
|
+
default: def,
|
|
48
|
+
description: opts?.description ??
|
|
49
|
+
`Max items to return (default ${def}, max ${max}). Example: ${def}.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function boolSchema(description, defaultValue) {
|
|
53
|
+
const schema = {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
description,
|
|
56
|
+
};
|
|
57
|
+
if (defaultValue !== undefined)
|
|
58
|
+
schema.default = defaultValue;
|
|
59
|
+
return schema;
|
|
60
|
+
}
|
|
61
|
+
export function stringSchema(description, example) {
|
|
62
|
+
const schema = { type: 'string', description };
|
|
63
|
+
if (example)
|
|
64
|
+
schema.examples = [example];
|
|
65
|
+
return schema;
|
|
66
|
+
}
|
|
67
|
+
/** Normalize MCP handler return to MCP content result. */
|
|
68
|
+
export async function runTool(def, args, ctx) {
|
|
69
|
+
try {
|
|
70
|
+
const result = await def.handler(args ?? {}, ctx);
|
|
71
|
+
if (result && typeof result === 'object' && 'content' in result) {
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
return toMcpResult(result);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const { errorEnvelope, toMcpResult: toResult } = await import('../envelope.js');
|
|
78
|
+
return toResult(errorEnvelope({
|
|
79
|
+
code: 'UPSTREAM',
|
|
80
|
+
message: error.message || 'Unhandled tool error',
|
|
81
|
+
game: null,
|
|
82
|
+
source: def.name,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cito-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Standalone MCP server for the Cito esports API — tools
|
|
3
|
+
"version": "0.2.2",
|
|
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": {
|
|
7
7
|
"cito-mcp": "dist/index.js"
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc -p tsconfig.json",
|
|
18
|
-
"test": "tsx --test src/*.test.ts",
|
|
18
|
+
"test": "tsx --test src/**/*.test.ts src/*.test.ts",
|
|
19
19
|
"start": "node dist/index.js",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
package/dist/executor.js
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
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/spec.js
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
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
|
-
}
|