openapi-explorer-mcp 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/auth.d.ts +90 -0
- package/dist/auth.js +140 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.js +164 -0
- package/dist/http.d.ts +24 -0
- package/dist/http.js +52 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -0
- package/dist/journal.d.ts +8 -0
- package/dist/journal.js +26 -0
- package/dist/operations.d.ts +24 -0
- package/dist/operations.js +82 -0
- package/dist/recipes.d.ts +15 -0
- package/dist/recipes.js +16 -0
- package/dist/risk.d.ts +28 -0
- package/dist/risk.js +57 -0
- package/dist/schema-view.d.ts +33 -0
- package/dist/schema-view.js +153 -0
- package/dist/schemas.d.ts +82 -0
- package/dist/schemas.js +61 -0
- package/dist/server.d.ts +46 -0
- package/dist/server.js +442 -0
- package/dist/spec-index.d.ts +104 -0
- package/dist/spec-index.js +101 -0
- package/dist/spec-store.d.ts +82 -0
- package/dist/spec-store.js +147 -0
- package/dist/types-gen.d.ts +12 -0
- package/dist/types-gen.js +54 -0
- package/package.json +59 -0
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type AuthProvider } from './auth.js';
|
|
2
|
+
import { type ExplorerConfig } from './config.js';
|
|
3
|
+
import { type DangerRules } from './risk.js';
|
|
4
|
+
/**
|
|
5
|
+
* MCP server exploring an OpenAPI spec over stdio.
|
|
6
|
+
*/
|
|
7
|
+
export declare class OpenApiExplorerServer {
|
|
8
|
+
private readonly config;
|
|
9
|
+
private readonly provider;
|
|
10
|
+
private readonly server;
|
|
11
|
+
private readonly store;
|
|
12
|
+
private readonly credentials;
|
|
13
|
+
constructor(config: ExplorerConfig, rules: DangerRules, provider: AuthProvider | undefined, extraInstructions: string);
|
|
14
|
+
/**
|
|
15
|
+
* Builds the server from environment variables; exits with a readable message when they are wrong.
|
|
16
|
+
*/
|
|
17
|
+
static fromEnvironment(): Promise<OpenApiExplorerServer>;
|
|
18
|
+
/**
|
|
19
|
+
* Connects the server to stdio.
|
|
20
|
+
*/
|
|
21
|
+
start(): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Serializes a tool result, cutting responses that would flood the context.
|
|
24
|
+
*/
|
|
25
|
+
private result;
|
|
26
|
+
/**
|
|
27
|
+
* Runs a handler and turns a thrown error into a tool error instead of a protocol error.
|
|
28
|
+
*/
|
|
29
|
+
private run;
|
|
30
|
+
/**
|
|
31
|
+
* Adds a `spec` note when there is something to say: offline, stale or a changed version.
|
|
32
|
+
*/
|
|
33
|
+
private specNote;
|
|
34
|
+
/**
|
|
35
|
+
* Base URL for calls: OPENAPI_BASE_URL, otherwise the first server of the spec.
|
|
36
|
+
*/
|
|
37
|
+
private baseUrl;
|
|
38
|
+
/**
|
|
39
|
+
* Calls an operation: picks credentials, checks the origin, retries once on 401 with fresh provider tokens.
|
|
40
|
+
*/
|
|
41
|
+
private performCall;
|
|
42
|
+
/**
|
|
43
|
+
* Registers every tool; api_request, api_auth and recipe only when configured.
|
|
44
|
+
*/
|
|
45
|
+
private registerTools;
|
|
46
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { Credentials, loadAuthProvider } from './auth.js';
|
|
7
|
+
import { readConfig, schemeEnvName } from './config.js';
|
|
8
|
+
import { buildUrl, send } from './http.js';
|
|
9
|
+
import { appendJsonl, tailJsonl } from './journal.js';
|
|
10
|
+
import { componentRef, operationTypeName, paramSummary, renderParams, resolveEndpoint } from './operations.js';
|
|
11
|
+
import { listRecipes } from './recipes.js';
|
|
12
|
+
import { loadDangerRules } from './risk.js';
|
|
13
|
+
import { renderOutline, resolveJson } from './schema-view.js';
|
|
14
|
+
import * as schemas from './schemas.js';
|
|
15
|
+
import { SpecStore } from './spec-store.js';
|
|
16
|
+
import { getTypeMap, renameDeclaration } from './types-gen.js';
|
|
17
|
+
const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
18
|
+
const READ_ONLY = { readOnlyHint: true };
|
|
19
|
+
const DANGER_ORDER = { safe: 0, write: 1, destructive: 2 };
|
|
20
|
+
const BASE_INSTRUCTIONS = [
|
|
21
|
+
'An index of an OpenAPI spec with tools to inspect and call its endpoints.',
|
|
22
|
+
'',
|
|
23
|
+
'Order: api_search finds an endpoint → api_endpoint shows parameters and shapes → api_types gives TypeScript types → api_get / api_request call it.',
|
|
24
|
+
'',
|
|
25
|
+
'- Refer to endpoints as "METHOD /path"; operationIds are not always unique.',
|
|
26
|
+
'- Summaries can be missing or wrong — check the path, method and response shape.',
|
|
27
|
+
'- Authentication follows the security schemes of the spec. api_spec_info shows which schemes have credentials; `as` picks one explicitly.',
|
|
28
|
+
'- Destructive endpoints need confirm_danger: true in api_request.',
|
|
29
|
+
].join('\n');
|
|
30
|
+
/**
|
|
31
|
+
* MCP server exploring an OpenAPI spec over stdio.
|
|
32
|
+
*/
|
|
33
|
+
export class OpenApiExplorerServer {
|
|
34
|
+
config;
|
|
35
|
+
provider;
|
|
36
|
+
server;
|
|
37
|
+
store;
|
|
38
|
+
credentials;
|
|
39
|
+
constructor(config, rules, provider, extraInstructions) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
this.provider = provider;
|
|
42
|
+
this.store = new SpecStore(config, rules);
|
|
43
|
+
this.credentials = new Credentials(config, provider);
|
|
44
|
+
const instructions = extraInstructions ? `${BASE_INSTRUCTIONS}\n\n${extraInstructions}` : BASE_INSTRUCTIONS;
|
|
45
|
+
this.server = new McpServer({ name: config.serverName, version: VERSION }, { capabilities: { tools: {} }, instructions });
|
|
46
|
+
this.registerTools();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Builds the server from environment variables; exits with a readable message when they are wrong.
|
|
50
|
+
*/
|
|
51
|
+
static async fromEnvironment() {
|
|
52
|
+
try {
|
|
53
|
+
const config = readConfig();
|
|
54
|
+
const rules = loadDangerRules(config.dangerFile);
|
|
55
|
+
const provider = await loadAuthProvider(config);
|
|
56
|
+
const extra = config.instructionsFile ? readFileSync(config.instructionsFile, 'utf8').trim() : '';
|
|
57
|
+
return new OpenApiExplorerServer(config, rules, provider, extra);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// stdout carries JSON-RPC, so startup errors go to stderr.
|
|
61
|
+
process.stderr.write(`openapi-explorer-mcp: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Connects the server to stdio.
|
|
67
|
+
*/
|
|
68
|
+
async start() {
|
|
69
|
+
await this.server.connect(new StdioServerTransport());
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Serializes a tool result, cutting responses that would flood the context.
|
|
73
|
+
*/
|
|
74
|
+
result(value) {
|
|
75
|
+
const text = typeof value === 'string' ? value : (JSON.stringify(value, null, 2) ?? '');
|
|
76
|
+
const limit = this.config.maxResponseChars;
|
|
77
|
+
const capped = text.length <= limit ? text : `${text.slice(0, limit)}\n\n… response truncated (${text.length} characters). Narrow it down: a smaller depth, a specific endpoint, or query parameters.`;
|
|
78
|
+
return { content: [{ type: 'text', text: capped }] };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Runs a handler and turns a thrown error into a tool error instead of a protocol error.
|
|
82
|
+
*/
|
|
83
|
+
async run(handler) {
|
|
84
|
+
try {
|
|
85
|
+
return this.result(await handler());
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Adds a `spec` note when there is something to say: offline, stale or a changed version.
|
|
93
|
+
*/
|
|
94
|
+
specNote(state) {
|
|
95
|
+
const note = {};
|
|
96
|
+
if (state.offline) {
|
|
97
|
+
note.warning = `the spec source is unreachable (${state.offline.reason}); serving the cached copy from ${new Date(state.meta.fetchedAt).toISOString()}, version ${state.meta.version}`;
|
|
98
|
+
}
|
|
99
|
+
if (state.meta.changed)
|
|
100
|
+
note.changed = state.meta.changed;
|
|
101
|
+
const ageHours = (Date.now() - state.meta.fetchedAt) / 3.6e6;
|
|
102
|
+
if (this.config.specIsUrl && !state.offline && ageHours > 24)
|
|
103
|
+
note.stale = `the spec was fetched ${ageHours.toFixed(0)} h ago`;
|
|
104
|
+
return Object.keys(note).length ? { spec: note } : {};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Base URL for calls: OPENAPI_BASE_URL, otherwise the first server of the spec.
|
|
108
|
+
*/
|
|
109
|
+
baseUrl(state) {
|
|
110
|
+
if (this.config.baseUrl)
|
|
111
|
+
return this.config.baseUrl;
|
|
112
|
+
const first = state.index.servers[0];
|
|
113
|
+
if (!first)
|
|
114
|
+
throw new Error('no base URL: the spec lists no servers; set OPENAPI_BASE_URL');
|
|
115
|
+
if (/^https?:\/\//i.test(first))
|
|
116
|
+
return first.replace(/\/+$/, '');
|
|
117
|
+
if (this.config.specIsUrl)
|
|
118
|
+
return new URL(first, this.config.specSource).toString().replace(/\/+$/, '');
|
|
119
|
+
throw new Error(`the spec server "${first}" is relative and the spec is a local file; set OPENAPI_BASE_URL`);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Calls an operation: picks credentials, checks the origin, retries once on 401 with fresh provider tokens.
|
|
123
|
+
*/
|
|
124
|
+
async performCall(state, op, args) {
|
|
125
|
+
const base = this.baseUrl(state);
|
|
126
|
+
const origin = new URL(base).origin;
|
|
127
|
+
const context = { identity: args.identity };
|
|
128
|
+
const schemes = state.index.securitySchemes;
|
|
129
|
+
const selection = this.credentials.select(op, args.as, context, schemes);
|
|
130
|
+
const attempt = async (force) => {
|
|
131
|
+
const url = buildUrl(base, op.path, args.pathParams, args.query);
|
|
132
|
+
const headers = { ...this.config.staticHeaders };
|
|
133
|
+
const applied = await this.credentials.apply(selection, schemes, { ...context, force }, headers, url);
|
|
134
|
+
if (url.origin !== origin)
|
|
135
|
+
throw new Error(`refusing to send the request to ${url.origin}; only ${origin} is allowed`);
|
|
136
|
+
return { result: await send(args.method, url, headers, args.body, this.config.timeoutMs), applied };
|
|
137
|
+
};
|
|
138
|
+
let { result, applied } = await attempt(false);
|
|
139
|
+
if (result.status === 401 && applied.fromProvider)
|
|
140
|
+
({ result, applied } = await attempt(true));
|
|
141
|
+
const note = {};
|
|
142
|
+
if (selection.mode === 'anonymous' && selection.note)
|
|
143
|
+
note.auth = selection.note;
|
|
144
|
+
if (result.status === 404) {
|
|
145
|
+
const fresh = await this.store.forceRevalidate().catch(() => null);
|
|
146
|
+
note.specRecheck = fresh?.index.byKey.has(op.key)
|
|
147
|
+
? 'the path is still in the spec, so the 404 is real — check path parameters'
|
|
148
|
+
: 'the path is gone from the spec — the API has probably changed';
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
auth: selection.mode === 'anonymous' ? 'anonymous' : selection.schemes.join(' + '),
|
|
152
|
+
...result,
|
|
153
|
+
...(Object.keys(note).length ? { note } : {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Registers every tool; api_request, api_auth and recipe only when configured.
|
|
158
|
+
*/
|
|
159
|
+
registerTools() {
|
|
160
|
+
this.server.registerTool('api_spec_info', {
|
|
161
|
+
title: 'Spec info',
|
|
162
|
+
description: 'Spec version and age, counts, groups, security schemes with credential status, and changes since the previous version.',
|
|
163
|
+
inputSchema: schemas.specInfoInput,
|
|
164
|
+
annotations: READ_ONLY,
|
|
165
|
+
}, ({ refresh }) => this.run(async () => {
|
|
166
|
+
const state = refresh ? await this.store.forceRevalidate() : await this.store.load();
|
|
167
|
+
const groups = {};
|
|
168
|
+
for (const op of state.index.operations)
|
|
169
|
+
groups[op.group] = (groups[op.group] ?? 0) + 1;
|
|
170
|
+
return {
|
|
171
|
+
title: state.index.title,
|
|
172
|
+
version: state.meta.version,
|
|
173
|
+
source: this.config.specSource,
|
|
174
|
+
fetchedAt: new Date(state.meta.fetchedAt).toISOString(),
|
|
175
|
+
baseUrl: this.config.baseUrl ?? state.index.servers[0] ?? null,
|
|
176
|
+
counts: state.index.counts,
|
|
177
|
+
groups,
|
|
178
|
+
securitySchemes: Object.entries(state.index.securitySchemes).map(([name, scheme]) => ({
|
|
179
|
+
name,
|
|
180
|
+
type: scheme.type,
|
|
181
|
+
...(scheme.in ? { in: scheme.in } : {}),
|
|
182
|
+
...(scheme.name ? { parameter: scheme.name } : {}),
|
|
183
|
+
...(scheme.scheme ? { scheme: scheme.scheme } : {}),
|
|
184
|
+
credential: this.credentials.describe(name),
|
|
185
|
+
env: schemeEnvName(name),
|
|
186
|
+
})),
|
|
187
|
+
writes: this.config.allowWrite ? 'enabled' : 'disabled (set OPENAPI_ALLOW_WRITE to register api_request)',
|
|
188
|
+
...(state.meta.changed ? { changed: state.meta.changed } : {}),
|
|
189
|
+
...(state.offline ? { offline: { since: new Date(state.offline.since).toISOString(), reason: state.offline.reason } } : {}),
|
|
190
|
+
};
|
|
191
|
+
}));
|
|
192
|
+
this.server.registerTool('api_search', {
|
|
193
|
+
title: 'Find endpoints',
|
|
194
|
+
description: 'Searches method, path, operationId, summary, tags and parameter names. One line per endpoint, no schemas. ' +
|
|
195
|
+
'Admin endpoints come last. Summaries can be wrong — check the path and method.',
|
|
196
|
+
inputSchema: schemas.searchInput,
|
|
197
|
+
annotations: READ_ONLY,
|
|
198
|
+
}, ({ query, method, group, include_admin, has_body, limit }) => this.run(async () => {
|
|
199
|
+
const state = await this.store.load();
|
|
200
|
+
const tokens = (query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
201
|
+
let pool = state.index.operations;
|
|
202
|
+
if (method)
|
|
203
|
+
pool = pool.filter((o) => o.method === method);
|
|
204
|
+
if (group)
|
|
205
|
+
pool = pool.filter((o) => o.group === group || o.path.startsWith(`/${group}`));
|
|
206
|
+
if (!include_admin)
|
|
207
|
+
pool = pool.filter((o) => !o.admin);
|
|
208
|
+
if (has_body !== undefined)
|
|
209
|
+
pool = pool.filter((o) => Boolean(o.request) === has_body);
|
|
210
|
+
const score = (op) => {
|
|
211
|
+
const p = op.path.toLowerCase();
|
|
212
|
+
const id = (op.operationId ?? '').toLowerCase();
|
|
213
|
+
const summary = op.summary.toLowerCase();
|
|
214
|
+
return tokens.reduce((acc, t) => acc + (p.includes(t) ? 3 : 0) + (id.includes(t) ? 2 : 0) + (summary.includes(t) ? 1 : 0) + (op.searchText.includes(t) ? 1 : 0), 0);
|
|
215
|
+
};
|
|
216
|
+
let matched = tokens.length ? pool.filter((o) => tokens.every((t) => o.searchText.includes(t))) : pool;
|
|
217
|
+
let matchMode = 'all';
|
|
218
|
+
if (tokens.length && matched.length === 0) {
|
|
219
|
+
matched = pool.filter((o) => tokens.some((t) => o.searchText.includes(t)));
|
|
220
|
+
matchMode = 'any';
|
|
221
|
+
}
|
|
222
|
+
matched = [...matched].sort((a, b) => Number(a.admin) - Number(b.admin) || score(b) - score(a) || DANGER_ORDER[a.danger] - DANGER_ORDER[b.danger] || a.path.localeCompare(b.path));
|
|
223
|
+
const groups = {};
|
|
224
|
+
for (const op of matched)
|
|
225
|
+
groups[op.group] = (groups[op.group] ?? 0) + 1;
|
|
226
|
+
return {
|
|
227
|
+
total: matched.length,
|
|
228
|
+
shown: Math.min(matched.length, limit),
|
|
229
|
+
...(matchMode === 'any' ? { matched: 'any word (nothing matched all of them)' } : {}),
|
|
230
|
+
groups,
|
|
231
|
+
endpoints: matched.slice(0, limit).map((o) => ({
|
|
232
|
+
key: o.key,
|
|
233
|
+
summary: o.summary || undefined,
|
|
234
|
+
auth: o.authSchemes.length ? o.authSchemes : undefined,
|
|
235
|
+
danger: o.danger === 'safe' ? undefined : o.danger,
|
|
236
|
+
admin: o.admin || undefined,
|
|
237
|
+
params: paramSummary(o.params),
|
|
238
|
+
})),
|
|
239
|
+
...this.specNote(state),
|
|
240
|
+
};
|
|
241
|
+
}));
|
|
242
|
+
this.server.registerTool('api_endpoint', {
|
|
243
|
+
title: 'Describe an endpoint',
|
|
244
|
+
description: 'Parameters, request and response shapes (compact, depth-limited), danger level, security alternatives and URL of one endpoint.',
|
|
245
|
+
inputSchema: schemas.endpointInput,
|
|
246
|
+
annotations: READ_ONLY,
|
|
247
|
+
}, ({ endpoint, depth, mode }) => this.run(async () => {
|
|
248
|
+
const state = await this.store.load();
|
|
249
|
+
const op = resolveEndpoint(state.index, endpoint);
|
|
250
|
+
const spec = { components: { schemas: state.index.schemas } };
|
|
251
|
+
const render = (schema) => {
|
|
252
|
+
if (!schema)
|
|
253
|
+
return undefined;
|
|
254
|
+
if (mode === 'json')
|
|
255
|
+
return resolveJson(spec, schema, depth);
|
|
256
|
+
const outline = renderOutline(spec, schema, { depth });
|
|
257
|
+
return outline.truncated ? `${outline.text}\n// partly cut — increase depth` : outline.text;
|
|
258
|
+
};
|
|
259
|
+
let url;
|
|
260
|
+
try {
|
|
261
|
+
url = `${this.baseUrl(state)}${op.path}`;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
url = undefined;
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
key: op.key,
|
|
268
|
+
operationId: op.operationId,
|
|
269
|
+
summary: op.summary || undefined,
|
|
270
|
+
group: op.group,
|
|
271
|
+
admin: op.admin || undefined,
|
|
272
|
+
danger: op.danger,
|
|
273
|
+
dangerReason: op.dangerReason,
|
|
274
|
+
auth: op.security.length ? op.security.map((alternative) => (alternative.length ? alternative.join(' + ') : 'anonymous')) : ['anonymous'],
|
|
275
|
+
url,
|
|
276
|
+
parameters: paramSummary(op.params),
|
|
277
|
+
request: render(op.request),
|
|
278
|
+
response: op.response ? { status: op.responseStatus, schema: render(op.response) } : null,
|
|
279
|
+
...this.specNote(state),
|
|
280
|
+
};
|
|
281
|
+
}));
|
|
282
|
+
this.server.registerTool('api_schema', {
|
|
283
|
+
title: 'Describe a schema',
|
|
284
|
+
description: 'A schema from components by name, compact and depth-limited. `path` drills into a nested field; usedBy lists the endpoints that reference it.',
|
|
285
|
+
inputSchema: schemas.schemaInput,
|
|
286
|
+
annotations: READ_ONLY,
|
|
287
|
+
}, ({ name, path: drill, depth, mode }) => this.run(async () => {
|
|
288
|
+
const state = await this.store.load();
|
|
289
|
+
if (!state.index.schemas[name]) {
|
|
290
|
+
const near = Object.keys(state.index.schemas)
|
|
291
|
+
.filter((n) => n.toLowerCase().includes(name.toLowerCase()))
|
|
292
|
+
.slice(0, 10);
|
|
293
|
+
throw new Error(`no schema "${name}"${near.length ? `; similar: ${near.join(', ')}` : ''}`);
|
|
294
|
+
}
|
|
295
|
+
const spec = { components: { schemas: state.index.schemas } };
|
|
296
|
+
let base = { $ref: `#/components/schemas/${name}` };
|
|
297
|
+
if (drill) {
|
|
298
|
+
let node = resolveJson(spec, base, 12);
|
|
299
|
+
for (const segment of drill.split('.')) {
|
|
300
|
+
node = node?.properties?.[segment] ?? node?.items?.properties?.[segment] ?? node?.[segment];
|
|
301
|
+
if (!node)
|
|
302
|
+
throw new Error(`path ${drill}: segment "${segment}" not found`);
|
|
303
|
+
}
|
|
304
|
+
base = node;
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
name,
|
|
308
|
+
path: drill,
|
|
309
|
+
usedBy: state.index.schemaUsedBy.get(name) ?? [],
|
|
310
|
+
schema: mode === 'json' ? resolveJson(spec, base, depth) : renderOutline(spec, base, { depth }).text,
|
|
311
|
+
...this.specNote(state),
|
|
312
|
+
};
|
|
313
|
+
}));
|
|
314
|
+
this.server.registerTool('api_types', {
|
|
315
|
+
title: 'TypeScript types of an endpoint',
|
|
316
|
+
description: 'Ready-to-paste TypeScript types for the request, response and parameters of an endpoint, generated from the spec with @hey-api/openapi-ts.',
|
|
317
|
+
inputSchema: schemas.typesInput,
|
|
318
|
+
annotations: READ_ONLY,
|
|
319
|
+
}, ({ endpoint, include, name_prefix }) => this.run(async () => {
|
|
320
|
+
const state = await this.store.load();
|
|
321
|
+
const op = resolveEndpoint(state.index, endpoint);
|
|
322
|
+
const typeMap = await getTypeMap(state.specPath, path.join(this.config.cacheDir, 'types'), `${state.meta.etag ?? ''}:${state.meta.fetchedAt}`);
|
|
323
|
+
const opName = operationTypeName(name_prefix, op);
|
|
324
|
+
const blocks = [];
|
|
325
|
+
const names = [];
|
|
326
|
+
const emitComponent = (node, kind) => {
|
|
327
|
+
const ref = componentRef(node);
|
|
328
|
+
if (!ref)
|
|
329
|
+
return `// ${kind}: an unnamed schema — see its shape with api_endpoint`;
|
|
330
|
+
const declaration = typeMap.get(ref.name);
|
|
331
|
+
if (!declaration)
|
|
332
|
+
return `// ${kind}: type ${ref.name} is missing from the generated set`;
|
|
333
|
+
const finalName = `${name_prefix}${ref.name}`;
|
|
334
|
+
names.push(finalName);
|
|
335
|
+
const renamed = renameDeclaration(declaration, ref.name, finalName);
|
|
336
|
+
return ref.array ? `${renamed}\n\nexport type ${opName}Response = ${finalName}[];` : renamed;
|
|
337
|
+
};
|
|
338
|
+
if (include.includes('params')) {
|
|
339
|
+
const params = renderParams(op, `${opName}Params`);
|
|
340
|
+
if (params) {
|
|
341
|
+
blocks.push(params);
|
|
342
|
+
names.push(`${opName}Params`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (include.includes('request') && op.request)
|
|
346
|
+
blocks.push(emitComponent(op.request, 'request'));
|
|
347
|
+
if (include.includes('response') && op.response)
|
|
348
|
+
blocks.push(emitComponent(op.response, 'response'));
|
|
349
|
+
if (blocks.length === 0)
|
|
350
|
+
return { key: op.key, note: 'the endpoint has no request body, response schema or parameters' };
|
|
351
|
+
return { key: op.key, names, source: '@hey-api/openapi-ts', types: blocks.join('\n\n'), ...this.specNote(state) };
|
|
352
|
+
}));
|
|
353
|
+
this.server.registerTool('api_get', {
|
|
354
|
+
title: 'Call a GET endpoint',
|
|
355
|
+
description: 'Calls a GET endpoint and returns the response. Read-only: the method is fixed.',
|
|
356
|
+
inputSchema: schemas.getInput,
|
|
357
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
358
|
+
}, ({ endpoint, path_params, query, as, identity }) => this.run(async () => {
|
|
359
|
+
const state = await this.store.load();
|
|
360
|
+
const op = resolveEndpoint(state.index, endpoint);
|
|
361
|
+
if (op.method !== 'GET')
|
|
362
|
+
throw new Error(`${op.key} is not a GET endpoint${this.config.allowWrite ? '; use api_request' : ''}`);
|
|
363
|
+
const response = await this.performCall(state, op, { method: 'GET', pathParams: path_params, query, as, identity });
|
|
364
|
+
return { key: op.key, ...response, ...this.specNote(state) };
|
|
365
|
+
}));
|
|
366
|
+
if (this.config.allowWrite) {
|
|
367
|
+
this.server.registerTool('api_request', {
|
|
368
|
+
title: 'Call an endpoint with any method',
|
|
369
|
+
description: 'Calls an endpoint with any method, including writes. Destructive endpoints need confirm_danger: true. Calls are recorded in api_call_log.',
|
|
370
|
+
inputSchema: schemas.requestInput,
|
|
371
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
372
|
+
}, ({ method, endpoint, path_params, query, body, as, identity, reason, confirm_danger }) => this.run(async () => {
|
|
373
|
+
const state = await this.store.load();
|
|
374
|
+
const op = resolveEndpoint(state.index, endpoint);
|
|
375
|
+
if (method !== op.method)
|
|
376
|
+
throw new Error(`method ${method} does not match the endpoint ${op.key}`);
|
|
377
|
+
if (op.danger === 'destructive' && !confirm_danger) {
|
|
378
|
+
throw new Error(`${op.key} is destructive: ${op.dangerReason}. Repeat with confirm_danger: true if this is intended.`);
|
|
379
|
+
}
|
|
380
|
+
const response = await this.performCall(state, op, { method, pathParams: path_params, query, body, as, identity });
|
|
381
|
+
const responseBody = response.body;
|
|
382
|
+
appendJsonl(this.config.callLog, {
|
|
383
|
+
ts: new Date().toISOString(),
|
|
384
|
+
key: op.key,
|
|
385
|
+
reason,
|
|
386
|
+
auth: response.auth,
|
|
387
|
+
confirmDanger: confirm_danger || undefined,
|
|
388
|
+
status: response.status,
|
|
389
|
+
durationMs: response.durationMs,
|
|
390
|
+
pathParams: path_params,
|
|
391
|
+
responseIds: responseBody && typeof responseBody === 'object' && responseBody.id !== undefined ? [responseBody.id] : undefined,
|
|
392
|
+
});
|
|
393
|
+
return { key: op.key, journaled: true, ...response, ...this.specNote(state) };
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
const provider = this.provider;
|
|
397
|
+
if (provider?.authenticate) {
|
|
398
|
+
this.server.registerTool('api_auth', {
|
|
399
|
+
title: 'Mint tokens',
|
|
400
|
+
description: 'Mints or refreshes tokens through the auth module. show_token returns full tokens instead of previews.',
|
|
401
|
+
inputSchema: schemas.authInput,
|
|
402
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
403
|
+
}, ({ identity, refresh, show_token }) => this.run(async () => {
|
|
404
|
+
const session = await provider.authenticate({ identity, force: refresh });
|
|
405
|
+
const preview = (token) => (token ? `${token.slice(0, 12)}…(${token.length})` : undefined);
|
|
406
|
+
return {
|
|
407
|
+
identity: session.identity,
|
|
408
|
+
expiresAt: session.expiresAt,
|
|
409
|
+
accessToken: show_token ? session.accessToken : preview(session.accessToken),
|
|
410
|
+
refreshToken: show_token ? session.refreshToken : preview(session.refreshToken),
|
|
411
|
+
...(show_token ? {} : { note: 'show_token: true returns the full tokens' }),
|
|
412
|
+
};
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
this.server.registerTool('api_call_log', {
|
|
416
|
+
title: 'Call journal',
|
|
417
|
+
description: 'What api_request has called: endpoint, status and ids from responses — use it to clean up what was created.',
|
|
418
|
+
inputSchema: schemas.callLogInput,
|
|
419
|
+
annotations: READ_ONLY,
|
|
420
|
+
}, ({ limit }) => this.run(async () => {
|
|
421
|
+
const entries = tailJsonl(this.config.callLog, limit);
|
|
422
|
+
return entries.length ? { file: this.config.callLog, entries } : { entries: [], note: 'the journal is empty' };
|
|
423
|
+
}));
|
|
424
|
+
const recipesDir = this.config.recipesDir;
|
|
425
|
+
if (recipesDir) {
|
|
426
|
+
this.server.registerTool('recipe', {
|
|
427
|
+
title: 'Recipes',
|
|
428
|
+
description: 'Worked scenarios for this API. Without a name, lists the recipes.',
|
|
429
|
+
inputSchema: schemas.recipeInput,
|
|
430
|
+
annotations: READ_ONLY,
|
|
431
|
+
}, ({ name }) => this.run(async () => {
|
|
432
|
+
const recipes = listRecipes(recipesDir);
|
|
433
|
+
if (!name)
|
|
434
|
+
return { recipes: recipes.map(({ name: id, description }) => ({ name: id, description })) };
|
|
435
|
+
const found = recipes.find((r) => r.name === name);
|
|
436
|
+
if (!found)
|
|
437
|
+
throw new Error(`no recipe "${name}"; available: ${recipes.map((r) => r.name).join(', ') || 'none'}`);
|
|
438
|
+
return found.text;
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type Danger, type DangerRules } from './risk.js';
|
|
2
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
3
|
+
export type SchemaNode = Record<string, any>;
|
|
4
|
+
export type OpenApiSpec = Record<string, any>;
|
|
5
|
+
/**
|
|
6
|
+
* An operation parameter.
|
|
7
|
+
*/
|
|
8
|
+
export interface Parameter {
|
|
9
|
+
/** Parameter name. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Location: path, query, header or cookie. */
|
|
12
|
+
in: string;
|
|
13
|
+
/** Whether the parameter is required. */
|
|
14
|
+
required?: boolean;
|
|
15
|
+
/** Human description from the spec. */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Parameter schema. */
|
|
18
|
+
schema?: SchemaNode;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A security scheme from components.securitySchemes.
|
|
22
|
+
*/
|
|
23
|
+
export interface SecurityScheme {
|
|
24
|
+
/** apiKey, http, oauth2 or openIdConnect. */
|
|
25
|
+
type: string;
|
|
26
|
+
/** For apiKey: header, query or cookie. */
|
|
27
|
+
in?: string;
|
|
28
|
+
/** For apiKey: the header, query or cookie name. */
|
|
29
|
+
name?: string;
|
|
30
|
+
/** For http: bearer or basic. */
|
|
31
|
+
scheme?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* An indexed operation.
|
|
35
|
+
*/
|
|
36
|
+
export interface Operation {
|
|
37
|
+
/** "METHOD /path". */
|
|
38
|
+
key: string;
|
|
39
|
+
/** HTTP method. */
|
|
40
|
+
method: HttpMethod;
|
|
41
|
+
/** Path template. */
|
|
42
|
+
path: string;
|
|
43
|
+
/** operationId, not necessarily unique. */
|
|
44
|
+
operationId?: string;
|
|
45
|
+
/** Summary, possibly empty. */
|
|
46
|
+
summary: string;
|
|
47
|
+
/** Tags. */
|
|
48
|
+
tags: string[];
|
|
49
|
+
/** Group for browsing. */
|
|
50
|
+
group: string;
|
|
51
|
+
/** Whether the path is under /admin. */
|
|
52
|
+
admin: boolean;
|
|
53
|
+
/** Danger level. */
|
|
54
|
+
danger: Danger;
|
|
55
|
+
/** Why the operation is destructive. */
|
|
56
|
+
dangerReason?: string;
|
|
57
|
+
/** Security alternatives: each lists schemes that must all be satisfied; an empty one means anonymous. */
|
|
58
|
+
security: string[][];
|
|
59
|
+
/** Every scheme mentioned in the alternatives. */
|
|
60
|
+
authSchemes: string[];
|
|
61
|
+
/** Parameters by location. */
|
|
62
|
+
params: Record<'path' | 'query' | 'header' | 'cookie', Parameter[]>;
|
|
63
|
+
/** JSON request body schema. */
|
|
64
|
+
request?: SchemaNode;
|
|
65
|
+
/** Status of the documented success response. */
|
|
66
|
+
responseStatus: string | null;
|
|
67
|
+
/** Success response schema. */
|
|
68
|
+
response: SchemaNode | null;
|
|
69
|
+
/** Lower-cased text for search. */
|
|
70
|
+
searchText: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Searchable index of a spec.
|
|
74
|
+
*/
|
|
75
|
+
export interface SpecIndex {
|
|
76
|
+
/** info.version. */
|
|
77
|
+
version?: string;
|
|
78
|
+
/** info.title. */
|
|
79
|
+
title?: string;
|
|
80
|
+
/** All operations. */
|
|
81
|
+
operations: Operation[];
|
|
82
|
+
/** Operations by "METHOD /path". */
|
|
83
|
+
byKey: Map<string, Operation>;
|
|
84
|
+
/** Operation keys by operationId. */
|
|
85
|
+
byOperationId: Map<string, string[]>;
|
|
86
|
+
/** Operation keys by the component schemas they mention. */
|
|
87
|
+
schemaUsedBy: Map<string, string[]>;
|
|
88
|
+
/** components.schemas. */
|
|
89
|
+
schemas: Record<string, SchemaNode>;
|
|
90
|
+
/** components.securitySchemes. */
|
|
91
|
+
securitySchemes: Record<string, SecurityScheme>;
|
|
92
|
+
/** servers[].url. */
|
|
93
|
+
servers: string[];
|
|
94
|
+
/** Counts for spec info. */
|
|
95
|
+
counts: {
|
|
96
|
+
paths: number;
|
|
97
|
+
operations: number;
|
|
98
|
+
schemas: number;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Builds the searchable index of a spec.
|
|
103
|
+
*/
|
|
104
|
+
export declare function buildIndex(spec: OpenApiSpec, rules: DangerRules): SpecIndex;
|