@avidian/mcp-openapi 0.1.3 → 0.2.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 +105 -27
- package/dist/index.js +793 -253
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -5,56 +5,51 @@ import { realpathSync } from "node:fs";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
// src/config.ts
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { parseArgs } from "node:util";
|
|
9
|
-
|
|
10
|
-
function resolveConfig(argv, env) {
|
|
11
|
-
const { values, positionals } = parseArgs({
|
|
12
|
-
args: argv,
|
|
13
|
-
allowPositionals: true,
|
|
14
|
-
options: {
|
|
15
|
-
"base-url": { type: "string" },
|
|
16
|
-
timeout: { type: "string" }
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
const source = positionals[0] ?? env.OPENAPI_URL;
|
|
20
|
-
if (!source) {
|
|
21
|
-
throw new Error(`Missing OpenAPI source. Pass it as the first argument or set OPENAPI_URL, e.g.:
|
|
22
|
-
` + " mcp-openapi https://example.com/openapi.json");
|
|
23
|
-
}
|
|
24
|
-
const baseUrlOverride = values["base-url"] ?? env.OPENAPI_BASE_URL;
|
|
25
|
-
const requestTimeoutMs = parseTimeout(values.timeout ?? env.OPENAPI_REQUEST_TIMEOUT_MS);
|
|
26
|
-
return {
|
|
27
|
-
source,
|
|
28
|
-
requestTimeoutMs,
|
|
29
|
-
...baseUrlOverride !== undefined && { baseUrlOverride }
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
function parseTimeout(raw) {
|
|
33
|
-
if (raw === undefined)
|
|
34
|
-
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
35
|
-
const parsed = Number(raw);
|
|
36
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
37
|
-
throw new Error(`Invalid request timeout "${raw}". Pass a positive number of milliseconds.`);
|
|
38
|
-
}
|
|
39
|
-
return parsed;
|
|
40
|
-
}
|
|
10
|
+
import { z } from "zod/v4";
|
|
41
11
|
|
|
42
12
|
// src/errors.ts
|
|
43
13
|
function errorMessage(error) {
|
|
44
14
|
return error instanceof Error ? error.message : String(error);
|
|
45
15
|
}
|
|
46
16
|
|
|
17
|
+
// src/registry.ts
|
|
18
|
+
import { ulid } from "ulid";
|
|
19
|
+
|
|
47
20
|
// src/openapi/load.ts
|
|
48
21
|
import SwaggerParser from "@apidevtools/swagger-parser";
|
|
22
|
+
import { parse as parseYaml } from "yaml";
|
|
23
|
+
function detectSourceKind(source) {
|
|
24
|
+
const trimmed = source.trimStart();
|
|
25
|
+
if (/^https?:\/\//i.test(trimmed))
|
|
26
|
+
return "url";
|
|
27
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("["))
|
|
28
|
+
return "inline";
|
|
29
|
+
if (source.includes(`
|
|
30
|
+
`))
|
|
31
|
+
return "inline";
|
|
32
|
+
return "file";
|
|
33
|
+
}
|
|
49
34
|
async function loadOpenApiDocument(source) {
|
|
50
35
|
try {
|
|
51
|
-
|
|
36
|
+
if (detectSourceKind(source) === "inline") {
|
|
37
|
+
const parsed = parseYaml(source);
|
|
38
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
39
|
+
throw new Error("inline content did not parse to an OpenAPI/Swagger object");
|
|
40
|
+
}
|
|
41
|
+
return await SwaggerParser.dereference(parsed);
|
|
42
|
+
}
|
|
43
|
+
return await SwaggerParser.dereference(source.trim());
|
|
52
44
|
} catch (cause) {
|
|
53
|
-
throw new Error(`Failed to load OpenAPI document from "${source}": ${errorMessage(cause)}`, {
|
|
45
|
+
throw new Error(`Failed to load OpenAPI document from "${describeSource(source)}": ${errorMessage(cause)}`, {
|
|
54
46
|
cause
|
|
55
47
|
});
|
|
56
48
|
}
|
|
57
49
|
}
|
|
50
|
+
function describeSource(source) {
|
|
51
|
+
return detectSourceKind(source) === "inline" ? `inline content (${source.length} chars)` : source;
|
|
52
|
+
}
|
|
58
53
|
function isSwagger2Document(doc) {
|
|
59
54
|
return "swagger" in doc;
|
|
60
55
|
}
|
|
@@ -70,9 +65,11 @@ var HTTP_METHODS = [
|
|
|
70
65
|
"patch",
|
|
71
66
|
"trace"
|
|
72
67
|
];
|
|
73
|
-
function extractOperations(doc
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
function extractOperations(doc) {
|
|
69
|
+
return isSwagger2Document(doc) ? extractSwagger2Operations(doc) : extractOpenApi3Operations(doc);
|
|
70
|
+
}
|
|
71
|
+
function extractDocumentServers(doc) {
|
|
72
|
+
return isSwagger2Document(doc) ? swagger2ServerUrls(doc) : serverUrls(doc.servers);
|
|
76
73
|
}
|
|
77
74
|
function extractOpenApi3Operations(doc) {
|
|
78
75
|
const docServers = serverUrls(doc.servers);
|
|
@@ -95,6 +92,7 @@ function extractOpenApi3Operations(doc) {
|
|
|
95
92
|
path,
|
|
96
93
|
deprecated: operation.deprecated ?? false,
|
|
97
94
|
parameters: buildParameters(rawParams),
|
|
95
|
+
responses: buildResponsesV3(operation.responses),
|
|
98
96
|
servers,
|
|
99
97
|
tags: operation.tags ?? [],
|
|
100
98
|
...requestBody !== undefined && { requestBody },
|
|
@@ -129,6 +127,23 @@ function buildParameters(raw) {
|
|
|
129
127
|
};
|
|
130
128
|
});
|
|
131
129
|
}
|
|
130
|
+
function buildResponsesV3(responses) {
|
|
131
|
+
if (!responses)
|
|
132
|
+
return [];
|
|
133
|
+
return Object.entries(responses).map(([status, response]) => {
|
|
134
|
+
const resp = response;
|
|
135
|
+
const content = resp.content ?? {};
|
|
136
|
+
const contentTypes = Object.keys(content);
|
|
137
|
+
const jsonMedia = content["application/json"] ?? Object.entries(content).find(([type]) => /\bjson\b/i.test(type))?.[1];
|
|
138
|
+
const schema = jsonMedia?.schema;
|
|
139
|
+
return {
|
|
140
|
+
status,
|
|
141
|
+
contentTypes,
|
|
142
|
+
...resp.description !== undefined && { description: resp.description },
|
|
143
|
+
...schema !== undefined && { schema }
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
132
147
|
function buildRequestBodyV3(requestBody) {
|
|
133
148
|
if (!requestBody)
|
|
134
149
|
return;
|
|
@@ -186,11 +201,13 @@ function extractSwagger2Operations(doc) {
|
|
|
186
201
|
const rawParams = mergeSwagger2Parameters(pathParams, operation.parameters ?? []);
|
|
187
202
|
const consumesMultipart = (operation.consumes ?? doc.consumes ?? []).includes("multipart/form-data");
|
|
188
203
|
const requestBody = buildRequestBodySwagger2(rawParams, consumesMultipart);
|
|
204
|
+
const produces = operation.produces ?? doc.produces ?? [];
|
|
189
205
|
operations.push({
|
|
190
206
|
method,
|
|
191
207
|
path,
|
|
192
208
|
deprecated: operation.deprecated ?? false,
|
|
193
209
|
parameters: buildSwagger2Parameters(rawParams),
|
|
210
|
+
responses: buildResponsesSwagger2(operation.responses, produces),
|
|
194
211
|
servers,
|
|
195
212
|
tags: operation.tags ?? [],
|
|
196
213
|
...requestBody !== undefined && { requestBody },
|
|
@@ -233,6 +250,20 @@ function itemsObjectToJsonSchema(param) {
|
|
|
233
250
|
const { name: _name, in: _in, description: _description, required: _required, ...rest } = param;
|
|
234
251
|
return rest;
|
|
235
252
|
}
|
|
253
|
+
function buildResponsesSwagger2(responses, produces) {
|
|
254
|
+
if (!responses)
|
|
255
|
+
return [];
|
|
256
|
+
return Object.entries(responses).map(([status, response]) => {
|
|
257
|
+
const resp = response;
|
|
258
|
+
const schema = resp.schema;
|
|
259
|
+
return {
|
|
260
|
+
status,
|
|
261
|
+
contentTypes: schema !== undefined ? produces : [],
|
|
262
|
+
...resp.description !== undefined && { description: resp.description },
|
|
263
|
+
...schema !== undefined && { schema }
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|
|
236
267
|
function buildRequestBodySwagger2(raw, consumesMultipart) {
|
|
237
268
|
const bodyParam = raw.find((param) => param.in === "body");
|
|
238
269
|
if (bodyParam) {
|
|
@@ -265,83 +296,118 @@ function swagger2ServerUrls(doc) {
|
|
|
265
296
|
const basePath = doc.basePath ?? "";
|
|
266
297
|
return schemes.map((scheme) => `${scheme}://${host}${basePath}`);
|
|
267
298
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
function assignToolNames(drafts, reservedNames) {
|
|
274
|
-
const used = new Set(reservedNames);
|
|
275
|
-
return drafts.map((draft) => {
|
|
276
|
-
const base = sanitizeToolName(draft.operationId ?? `${draft.method}_${draft.path}`);
|
|
277
|
-
let name = base;
|
|
278
|
-
let counter = 2;
|
|
279
|
-
while (used.has(name)) {
|
|
280
|
-
const suffix = `_${counter}`;
|
|
281
|
-
name = `${base.slice(0, 128 - suffix.length)}${suffix}`;
|
|
282
|
-
counter += 1;
|
|
283
|
-
}
|
|
284
|
-
used.add(name);
|
|
285
|
-
return { ...draft, toolName: name };
|
|
286
|
-
});
|
|
299
|
+
|
|
300
|
+
// src/registry.ts
|
|
301
|
+
var DEFAULT_MAX_SPECS = 20;
|
|
302
|
+
function maxSpecsReachedMessage(maxSpecs) {
|
|
303
|
+
return `The maximum of ${maxSpecs} loaded specs has been reached. ` + "Unload a spec with unload_spec (see list_specs) before loading another.";
|
|
287
304
|
}
|
|
288
305
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
storeFields: ["toolName", "method", "path", "summary", "deprecated"],
|
|
297
|
-
searchOptions: {
|
|
298
|
-
prefix: true,
|
|
299
|
-
fuzzy: 0.2,
|
|
300
|
-
boost: { toolName: 3, summary: 2, tags: 1.5 }
|
|
306
|
+
class SpecRegistry {
|
|
307
|
+
specs = new Map;
|
|
308
|
+
maxSpecs;
|
|
309
|
+
constructor(options = {}) {
|
|
310
|
+
const maxSpecs = options.maxSpecs ?? DEFAULT_MAX_SPECS;
|
|
311
|
+
if (!Number.isInteger(maxSpecs) || maxSpecs < 1) {
|
|
312
|
+
throw new Error(`maxSpecs must be a positive integer, got ${maxSpecs}.`);
|
|
301
313
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
this.maxSpecs = maxSpecs;
|
|
315
|
+
}
|
|
316
|
+
async load(source, options = {}) {
|
|
317
|
+
this.assertHasCapacity();
|
|
318
|
+
const doc = await loadOpenApiDocument(source);
|
|
319
|
+
const spec = this.buildSpec(doc, options);
|
|
320
|
+
this.assertHasCapacity();
|
|
321
|
+
this.specs.set(spec.id, spec);
|
|
322
|
+
return spec;
|
|
323
|
+
}
|
|
324
|
+
assertHasCapacity() {
|
|
325
|
+
if (this.specs.size >= this.maxSpecs) {
|
|
326
|
+
throw new Error(maxSpecsReachedMessage(this.maxSpecs));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
get(id) {
|
|
330
|
+
return this.specs.get(id);
|
|
331
|
+
}
|
|
332
|
+
unload(id) {
|
|
333
|
+
return this.specs.delete(id);
|
|
334
|
+
}
|
|
335
|
+
get isFull() {
|
|
336
|
+
return this.specs.size >= this.maxSpecs;
|
|
337
|
+
}
|
|
338
|
+
list() {
|
|
339
|
+
return [...this.specs.values()];
|
|
340
|
+
}
|
|
341
|
+
get size() {
|
|
342
|
+
return this.specs.size;
|
|
343
|
+
}
|
|
344
|
+
buildSpec(doc, options) {
|
|
345
|
+
const operations = extractOperations(doc);
|
|
346
|
+
const servers = extractDocumentServers(doc);
|
|
347
|
+
const info = doc.info;
|
|
348
|
+
const title = typeof info === "object" && info !== null && typeof info.title === "string" ? info.title : "Untitled API";
|
|
349
|
+
const version = typeof info === "object" && info !== null && typeof info.version === "string" ? info.version : "unknown";
|
|
350
|
+
return {
|
|
351
|
+
id: ulid().toLowerCase(),
|
|
352
|
+
doc,
|
|
353
|
+
operations,
|
|
354
|
+
title,
|
|
355
|
+
version,
|
|
356
|
+
servers,
|
|
357
|
+
...options.baseUrl !== undefined && { baseUrl: options.baseUrl },
|
|
358
|
+
...options.credential !== undefined && { credential: options.credential }
|
|
359
|
+
};
|
|
360
|
+
}
|
|
317
361
|
}
|
|
318
|
-
function
|
|
319
|
-
const summary = typeof result.summary === "string" && result.summary.length > 0 ? result.summary : undefined;
|
|
362
|
+
function specSummary(spec) {
|
|
320
363
|
return {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
...summary !== undefined && { summary }
|
|
364
|
+
id: spec.id,
|
|
365
|
+
title: spec.title,
|
|
366
|
+
version: spec.version,
|
|
367
|
+
operationCount: spec.operations.length,
|
|
368
|
+
servers: spec.baseUrl !== undefined ? [spec.baseUrl] : spec.servers
|
|
327
369
|
};
|
|
328
370
|
}
|
|
329
371
|
|
|
330
|
-
// src/server.ts
|
|
331
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
332
|
-
import { z as z2 } from "zod/v4";
|
|
333
|
-
|
|
334
372
|
// src/openapi/auth.ts
|
|
335
|
-
function resolveAuth(doc, env) {
|
|
336
|
-
const schemes =
|
|
373
|
+
function resolveAuth(doc, env, credentialRef) {
|
|
374
|
+
const schemes = normalizedSchemes(doc);
|
|
375
|
+
const names = credentialRef !== undefined ? requiredSchemeNames(doc) : undefined;
|
|
337
376
|
const resolved = [];
|
|
338
377
|
for (const [name, scheme] of Object.entries(schemes)) {
|
|
339
|
-
|
|
378
|
+
if (names && !names.has(name))
|
|
379
|
+
continue;
|
|
380
|
+
const base = credentialRef !== undefined ? credentialEnvBase(credentialRef) : authEnvBase(name);
|
|
381
|
+
const credential = resolveSchemeCredential(base, scheme, env);
|
|
340
382
|
if (credential)
|
|
341
383
|
resolved.push(credential);
|
|
342
384
|
}
|
|
343
385
|
return resolved;
|
|
344
386
|
}
|
|
387
|
+
function hasSupportedSecuritySchemes(doc) {
|
|
388
|
+
return Object.keys(normalizedSchemes(doc)).length > 0;
|
|
389
|
+
}
|
|
390
|
+
function normalizedSchemes(doc) {
|
|
391
|
+
return isSwagger2Document(doc) ? swagger2SecuritySchemes(doc) : openApi3SecuritySchemes(doc);
|
|
392
|
+
}
|
|
393
|
+
function requiredSchemeNames(doc) {
|
|
394
|
+
const security = doc.security;
|
|
395
|
+
if (!security || security.length === 0)
|
|
396
|
+
return;
|
|
397
|
+
const names = new Set;
|
|
398
|
+
for (const requirement of security) {
|
|
399
|
+
for (const name of Object.keys(requirement))
|
|
400
|
+
names.add(name);
|
|
401
|
+
}
|
|
402
|
+
return names;
|
|
403
|
+
}
|
|
404
|
+
function hasConfiguredCredential(env, ref) {
|
|
405
|
+
const base = credentialEnvBase(ref);
|
|
406
|
+
return [base, `${base}_TOKEN`, `${base}_USERNAME`, `${base}_PASSWORD`].some((key) => env[key] !== undefined);
|
|
407
|
+
}
|
|
408
|
+
function credentialEnvBase(ref) {
|
|
409
|
+
return `OPENAPI_CRED_${screamingSnake(ref)}`;
|
|
410
|
+
}
|
|
345
411
|
function applyAuth(auth, target) {
|
|
346
412
|
for (const entry of auth) {
|
|
347
413
|
switch (entry.kind) {
|
|
@@ -366,8 +432,7 @@ function applyApiKey(entry, target) {
|
|
|
366
432
|
target.headers.set(entry.name, entry.value);
|
|
367
433
|
}
|
|
368
434
|
}
|
|
369
|
-
function resolveSchemeCredential(
|
|
370
|
-
const base = envVarName(name);
|
|
435
|
+
function resolveSchemeCredential(base, scheme, env) {
|
|
371
436
|
switch (scheme.kind) {
|
|
372
437
|
case "bearer": {
|
|
373
438
|
const token = env[`${base}_TOKEN`] ?? env[base];
|
|
@@ -384,9 +449,11 @@ function resolveSchemeCredential(name, scheme, env) {
|
|
|
384
449
|
}
|
|
385
450
|
}
|
|
386
451
|
}
|
|
387
|
-
function
|
|
388
|
-
|
|
389
|
-
|
|
452
|
+
function authEnvBase(schemeName) {
|
|
453
|
+
return `OPENAPI_AUTH_${screamingSnake(schemeName)}`;
|
|
454
|
+
}
|
|
455
|
+
function screamingSnake(value) {
|
|
456
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase();
|
|
390
457
|
}
|
|
391
458
|
function openApi3SecuritySchemes(doc) {
|
|
392
459
|
const schemes = doc.components?.securitySchemes ?? {};
|
|
@@ -423,6 +490,7 @@ function swagger2SecuritySchemes(doc) {
|
|
|
423
490
|
var MAX_RESPONSE_BODY_BYTES = 1e5;
|
|
424
491
|
async function executeOperation(options) {
|
|
425
492
|
const { operation, args, auth, requestTimeoutMs } = options;
|
|
493
|
+
const maxResponseBytes = options.maxResponseBytes ?? MAX_RESPONSE_BODY_BYTES;
|
|
426
494
|
const baseUrl = resolveBaseUrl(operation, options.baseUrlOverride);
|
|
427
495
|
const target = {
|
|
428
496
|
url: buildUrl(operation, args, baseUrl),
|
|
@@ -436,13 +504,13 @@ async function executeOperation(options) {
|
|
|
436
504
|
target.headers.set("Cookie", [...target.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
|
|
437
505
|
}
|
|
438
506
|
const response = await performFetch(target.url, operation, target.headers, body, requestTimeoutMs);
|
|
439
|
-
const { bytes, truncated } = await readBoundedBody(response,
|
|
507
|
+
const { bytes, truncated } = await readBoundedBody(response, maxResponseBytes);
|
|
440
508
|
return {
|
|
441
509
|
ok: response.ok,
|
|
442
510
|
status: response.status,
|
|
443
511
|
statusText: response.statusText,
|
|
444
512
|
truncated,
|
|
445
|
-
body: decodeBody(bytes, truncated, response.headers.get("content-type"))
|
|
513
|
+
body: decodeBody(bytes, truncated, maxResponseBytes, response.headers.get("content-type"))
|
|
446
514
|
};
|
|
447
515
|
}
|
|
448
516
|
async function performFetch(url, operation, headers, body, requestTimeoutMs) {
|
|
@@ -455,9 +523,7 @@ async function performFetch(url, operation, headers, body, requestTimeoutMs) {
|
|
|
455
523
|
});
|
|
456
524
|
} catch (error) {
|
|
457
525
|
if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) {
|
|
458
|
-
throw new Error(`Request to ${url.
|
|
459
|
-
cause: error
|
|
460
|
-
});
|
|
526
|
+
throw new Error(`Request to ${url.origin}${url.pathname} timed out after ${requestTimeoutMs}ms.`, { cause: error });
|
|
461
527
|
}
|
|
462
528
|
throw error;
|
|
463
529
|
}
|
|
@@ -503,13 +569,13 @@ function isTextContentType(contentType) {
|
|
|
503
569
|
const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
504
570
|
return type.startsWith("text/") || type === "application/json" || type === "application/xml" || type === "application/x-www-form-urlencoded" || type.endsWith("+json") || type.endsWith("+xml");
|
|
505
571
|
}
|
|
506
|
-
function decodeBody(bytes, truncated, contentType) {
|
|
572
|
+
function decodeBody(bytes, truncated, maxResponseBytes, contentType) {
|
|
507
573
|
if (bytes.length === 0)
|
|
508
574
|
return "";
|
|
509
575
|
if (isTextContentType(contentType)) {
|
|
510
576
|
const text = new TextDecoder().decode(bytes);
|
|
511
577
|
return truncated ? `${text}
|
|
512
|
-
...[response truncated, exceeded the ${
|
|
578
|
+
...[response truncated, exceeded the ${maxResponseBytes} byte cap]` : text;
|
|
513
579
|
}
|
|
514
580
|
const base64 = Buffer.from(bytes).toString("base64");
|
|
515
581
|
const label = contentType ?? "application/octet-stream";
|
|
@@ -519,7 +585,7 @@ ${base64}`;
|
|
|
519
585
|
function resolveBaseUrl(operation, baseUrlOverride) {
|
|
520
586
|
const base = baseUrlOverride ?? operation.servers[0];
|
|
521
587
|
if (base === undefined) {
|
|
522
|
-
throw new Error(`No server URL is available for operation "${operation.
|
|
588
|
+
throw new Error(`No server URL is available for operation "${operation.method} ${operation.path}". Set a base URL when loading the spec.`);
|
|
523
589
|
}
|
|
524
590
|
return base;
|
|
525
591
|
}
|
|
@@ -632,172 +698,360 @@ function base64ToBlob(base64) {
|
|
|
632
698
|
return new Blob([bytes]);
|
|
633
699
|
}
|
|
634
700
|
|
|
635
|
-
// src/
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const normalized = {};
|
|
657
|
-
for (const [key, value] of Object.entries(source)) {
|
|
658
|
-
normalized[key] = normalizeNode(value, seen);
|
|
659
|
-
}
|
|
660
|
-
const nullable = normalized.nullable === true || normalized["x-nullable"] === true;
|
|
661
|
-
delete normalized.nullable;
|
|
662
|
-
delete normalized["x-nullable"];
|
|
663
|
-
if (nullable) {
|
|
664
|
-
const { type } = normalized;
|
|
665
|
-
if (typeof type === "string") {
|
|
666
|
-
normalized.type = [type, "null"];
|
|
667
|
-
} else if (Array.isArray(type)) {
|
|
668
|
-
const types = type;
|
|
669
|
-
normalized.type = types.includes("null") ? types : [...types, "null"];
|
|
670
|
-
} else if (type === undefined) {
|
|
671
|
-
normalized.type = ["null"];
|
|
701
|
+
// src/config.ts
|
|
702
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
703
|
+
var ConfigFileSchema = z.strictObject({
|
|
704
|
+
specs: z.array(z.strictObject({
|
|
705
|
+
source: z.string().min(1),
|
|
706
|
+
base_url: z.string().min(1).optional(),
|
|
707
|
+
credential: z.string().min(1).optional()
|
|
708
|
+
})).optional(),
|
|
709
|
+
max_specs: z.number().int().positive().optional(),
|
|
710
|
+
request_timeout_ms: z.number().positive().optional(),
|
|
711
|
+
max_response_bytes: z.number().int().positive().optional()
|
|
712
|
+
});
|
|
713
|
+
function resolveConfig(argv, env) {
|
|
714
|
+
const { values, positionals } = parseArgs({
|
|
715
|
+
args: argv,
|
|
716
|
+
allowPositionals: true,
|
|
717
|
+
options: {
|
|
718
|
+
"base-url": { type: "string" },
|
|
719
|
+
credential: { type: "string" },
|
|
720
|
+
timeout: { type: "string" },
|
|
721
|
+
config: { type: "string" }
|
|
672
722
|
}
|
|
723
|
+
});
|
|
724
|
+
const configPath = clean(values.config ?? env.OPENAPI_CONFIG);
|
|
725
|
+
const configFile = configPath !== undefined ? readConfigFile(configPath) : {};
|
|
726
|
+
const preloads = [...(configFile.specs ?? []).map(fromFileSpec)];
|
|
727
|
+
const shorthandSource = clean(positionals[0] ?? env.OPENAPI_URL);
|
|
728
|
+
if (shorthandSource !== undefined) {
|
|
729
|
+
const baseUrl = clean(values["base-url"] ?? env.OPENAPI_BASE_URL);
|
|
730
|
+
const credential = clean(values.credential ?? env.OPENAPI_CREDENTIAL);
|
|
731
|
+
preloads.push({
|
|
732
|
+
source: shorthandSource,
|
|
733
|
+
...baseUrl !== undefined && { baseUrl },
|
|
734
|
+
...credential !== undefined && { credential }
|
|
735
|
+
});
|
|
673
736
|
}
|
|
674
|
-
return
|
|
737
|
+
return {
|
|
738
|
+
preloads,
|
|
739
|
+
requestTimeoutMs: parsePositive(clean(values.timeout ?? env.OPENAPI_REQUEST_TIMEOUT_MS), configFile.request_timeout_ms, DEFAULT_REQUEST_TIMEOUT_MS, "request timeout", false),
|
|
740
|
+
maxResponseBytes: parsePositive(clean(env.OPENAPI_MAX_RESPONSE_BYTES), configFile.max_response_bytes, MAX_RESPONSE_BODY_BYTES, "OPENAPI_MAX_RESPONSE_BYTES", true),
|
|
741
|
+
maxSpecs: parsePositive(clean(env.OPENAPI_MAX_SPECS), configFile.max_specs, DEFAULT_MAX_SPECS, "OPENAPI_MAX_SPECS", true)
|
|
742
|
+
};
|
|
675
743
|
}
|
|
676
|
-
function
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
744
|
+
function clean(value) {
|
|
745
|
+
return value === undefined || value === "" ? undefined : value;
|
|
746
|
+
}
|
|
747
|
+
function fromFileSpec(spec) {
|
|
748
|
+
return {
|
|
749
|
+
source: spec.source,
|
|
750
|
+
...spec.base_url !== undefined && { baseUrl: spec.base_url },
|
|
751
|
+
...spec.credential !== undefined && { credential: spec.credential }
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
function readConfigFile(path) {
|
|
755
|
+
let raw;
|
|
756
|
+
try {
|
|
757
|
+
raw = readFileSync(path, "utf8");
|
|
758
|
+
} catch (cause) {
|
|
759
|
+
throw new Error(`Failed to read config file "${path}": ${errorMessage(cause)}`, { cause });
|
|
760
|
+
}
|
|
761
|
+
let parsed;
|
|
762
|
+
try {
|
|
763
|
+
parsed = JSON.parse(raw);
|
|
764
|
+
} catch (cause) {
|
|
765
|
+
throw new Error(`Config file "${path}" is not valid JSON: ${errorMessage(cause)}`, { cause });
|
|
766
|
+
}
|
|
767
|
+
const result = ConfigFileSchema.safeParse(parsed);
|
|
768
|
+
if (!result.success) {
|
|
769
|
+
throw new Error(`Config file "${path}" is invalid: ${result.error.message}`);
|
|
770
|
+
}
|
|
771
|
+
return result.data;
|
|
772
|
+
}
|
|
773
|
+
function parsePositive(raw, fromConfig, fallback, label, integer) {
|
|
774
|
+
if (raw !== undefined) {
|
|
775
|
+
const parsed = Number(raw);
|
|
776
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || integer && !Number.isInteger(parsed)) {
|
|
777
|
+
throw new Error(`Invalid ${label} "${raw}". Pass a positive ${integer ? "integer" : "number"}.`);
|
|
685
778
|
}
|
|
779
|
+
return parsed;
|
|
686
780
|
}
|
|
687
|
-
|
|
688
|
-
return options.required ? described : described.optional();
|
|
781
|
+
return fromConfig ?? fallback;
|
|
689
782
|
}
|
|
690
783
|
|
|
691
784
|
// src/server.ts
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
785
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
786
|
+
|
|
787
|
+
// src/tools/describe.ts
|
|
788
|
+
import { z as z2 } from "zod/v4";
|
|
789
|
+
|
|
790
|
+
// src/openapi/lookup.ts
|
|
791
|
+
function findOperation(spec, method, path) {
|
|
792
|
+
const wanted = method.toLowerCase();
|
|
793
|
+
return spec.operations.find((op) => op.method === wanted && op.path === path);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// src/openapi/render-schema.ts
|
|
797
|
+
var MAX_SCHEMA_DEPTH = 6;
|
|
798
|
+
var MAX_ARRAY_ITEMS = 50;
|
|
799
|
+
var MAX_SCHEMA_NODES = 500;
|
|
800
|
+
var MAX_SCHEMA_CHARS = 4000;
|
|
801
|
+
var NOISY_KEYS = new Set(["example", "examples", "xml", "externalDocs", "discriminator"]);
|
|
802
|
+
function renderSchema(schema) {
|
|
803
|
+
const capped = capSchema(schema, 0, new WeakSet, { nodes: 0 });
|
|
804
|
+
const json = JSON.stringify(capped, null, 2);
|
|
805
|
+
if (json.length <= MAX_SCHEMA_CHARS)
|
|
806
|
+
return json;
|
|
807
|
+
return `${json.slice(0, MAX_SCHEMA_CHARS)}
|
|
808
|
+
... [schema truncated: exceeded ${MAX_SCHEMA_CHARS} chars]`;
|
|
809
|
+
}
|
|
810
|
+
function capSchema(node, depth, seen, budget) {
|
|
811
|
+
if (node === null || typeof node !== "object")
|
|
812
|
+
return node;
|
|
813
|
+
if (seen.has(node))
|
|
814
|
+
return "[circular reference]";
|
|
815
|
+
if (depth >= MAX_SCHEMA_DEPTH)
|
|
816
|
+
return "[truncated: schema nested too deeply]";
|
|
817
|
+
if (budget.nodes >= MAX_SCHEMA_NODES)
|
|
818
|
+
return "[truncated: schema too large]";
|
|
819
|
+
budget.nodes += 1;
|
|
820
|
+
seen.add(node);
|
|
821
|
+
try {
|
|
822
|
+
if (Array.isArray(node)) {
|
|
823
|
+
const capped = node.slice(0, MAX_ARRAY_ITEMS).map((item) => capSchema(item, depth + 1, seen, budget));
|
|
824
|
+
if (node.length > MAX_ARRAY_ITEMS)
|
|
825
|
+
capped.push(`[+${node.length - MAX_ARRAY_ITEMS} more]`);
|
|
826
|
+
return capped;
|
|
827
|
+
}
|
|
828
|
+
const result = {};
|
|
829
|
+
for (const [key, value] of Object.entries(node)) {
|
|
830
|
+
if (NOISY_KEYS.has(key) || key.startsWith("x-"))
|
|
831
|
+
continue;
|
|
832
|
+
if (budget.nodes >= MAX_SCHEMA_NODES) {
|
|
833
|
+
result["..."] = "[truncated: schema too large]";
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
result[key] = capSchema(value, depth + 1, seen, budget);
|
|
837
|
+
}
|
|
838
|
+
return result;
|
|
839
|
+
} finally {
|
|
840
|
+
seen.delete(node);
|
|
700
841
|
}
|
|
701
|
-
return server;
|
|
702
842
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
function
|
|
707
|
-
server.
|
|
708
|
-
title: "
|
|
709
|
-
description: "
|
|
710
|
-
mimeType: "application/json"
|
|
711
|
-
}, (uri) => ({
|
|
712
|
-
contents: [
|
|
713
|
-
{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(doc, null, 2) }
|
|
714
|
-
]
|
|
715
|
-
}));
|
|
716
|
-
}
|
|
717
|
-
function registerSearchTool(server, operations) {
|
|
718
|
-
const index = buildSearchIndex(operations);
|
|
719
|
-
server.registerTool(SEARCH_TOOL_NAME, {
|
|
720
|
-
title: "Search API operations",
|
|
721
|
-
description: "Search the available API operations by keyword (matches operation names, summaries, descriptions, tags, and paths). " + "Use this to find the right tool when there are many operations, then call that tool directly by name.",
|
|
843
|
+
|
|
844
|
+
// src/tools/describe.ts
|
|
845
|
+
var MAX_TYPE_LABEL_DEPTH = 4;
|
|
846
|
+
function registerDescribeTool(server, registry) {
|
|
847
|
+
server.registerTool("describe_operation", {
|
|
848
|
+
title: "Describe one operation",
|
|
849
|
+
description: "Return the full detail needed to call one operation of a loaded spec, identified by " + "spec_id + method + path (as shown by search_operations). Parameters and the request-body " + "schema are returned in full; responses are summarized (status codes and content types, " + "plus the schema of the 2xx success response only). Large or deeply nested schemas are " + "truncated with a marker.",
|
|
722
850
|
inputSchema: {
|
|
723
|
-
|
|
724
|
-
|
|
851
|
+
spec_id: z2.string().min(1).describe("The id of a loaded spec (from load_spec/list_specs)."),
|
|
852
|
+
method: z2.string().min(1).describe('HTTP method, e.g. "get" or "post".'),
|
|
853
|
+
path: z2.string().min(1).describe('Raw path template, e.g. "/pets/{petId}".')
|
|
725
854
|
},
|
|
726
|
-
annotations: { readOnlyHint: true,
|
|
855
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
727
856
|
}, (args) => {
|
|
728
|
-
const
|
|
729
|
-
|
|
857
|
+
const spec = registry.get(args.spec_id);
|
|
858
|
+
if (!spec) {
|
|
859
|
+
return errorResult(`No spec is loaded with id "${args.spec_id}". Call list_specs to see loaded specs.`);
|
|
860
|
+
}
|
|
861
|
+
const operation = findOperation(spec, args.method, args.path);
|
|
862
|
+
if (!operation) {
|
|
863
|
+
return errorResult(`No operation ${args.method.toUpperCase()} ${args.path} in spec "${args.spec_id}". ` + "Use search_operations to find the exact method and path.");
|
|
864
|
+
}
|
|
865
|
+
return { content: [{ type: "text", text: formatOperation(operation) }] };
|
|
730
866
|
});
|
|
731
867
|
}
|
|
732
|
-
function
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
});
|
|
741
|
-
|
|
868
|
+
function errorResult(text) {
|
|
869
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
870
|
+
}
|
|
871
|
+
function formatOperation(operation) {
|
|
872
|
+
const sections = [];
|
|
873
|
+
const heading = `${operation.method.toUpperCase()} ${operation.path}`;
|
|
874
|
+
const idPart = operation.operationId !== undefined ? ` [${operation.operationId}]` : "";
|
|
875
|
+
const deprecatedPart = operation.deprecated ? " (deprecated)" : "";
|
|
876
|
+
sections.push(`${heading}${idPart}${deprecatedPart}`);
|
|
877
|
+
if (operation.summary !== undefined)
|
|
878
|
+
sections.push(operation.summary);
|
|
879
|
+
if (operation.description !== undefined && operation.description !== operation.summary) {
|
|
880
|
+
sections.push(operation.description);
|
|
881
|
+
}
|
|
882
|
+
sections.push(formatParameters(operation.parameters));
|
|
883
|
+
if (operation.requestBody) {
|
|
884
|
+
const rb = operation.requestBody;
|
|
885
|
+
const required = rb.required ? "required" : "optional";
|
|
886
|
+
const descLine = rb.description !== undefined ? `${rb.description}
|
|
887
|
+
` : "";
|
|
888
|
+
sections.push(`Request body (${rb.encoding}, ${required}):
|
|
889
|
+
${descLine}${renderSchema(rb.schema)}`);
|
|
890
|
+
}
|
|
891
|
+
sections.push(formatResponses(operation.responses));
|
|
892
|
+
return sections.join(`
|
|
742
893
|
|
|
894
|
+
`);
|
|
895
|
+
}
|
|
896
|
+
function formatParameters(parameters) {
|
|
897
|
+
if (parameters.length === 0)
|
|
898
|
+
return "Parameters: none";
|
|
899
|
+
const lines = parameters.map((param) => {
|
|
900
|
+
const required = param.required ? "required" : "optional";
|
|
901
|
+
const desc = param.description !== undefined ? ` - ${param.description}` : "";
|
|
902
|
+
return `- ${param.name} (${param.in}, ${required}): ${schemaTypeLabel(param.schema)}${desc}`;
|
|
903
|
+
});
|
|
904
|
+
return `Parameters:
|
|
743
905
|
${lines.join(`
|
|
744
906
|
`)}`;
|
|
745
907
|
}
|
|
746
|
-
function
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
908
|
+
function formatResponses(responses) {
|
|
909
|
+
if (responses.length === 0)
|
|
910
|
+
return "Responses: none documented";
|
|
911
|
+
const lines = responses.map((response) => {
|
|
912
|
+
const types = response.contentTypes.length > 0 ? ` (${response.contentTypes.join(", ")})` : "";
|
|
913
|
+
const desc = response.description !== undefined ? ` - ${response.description}` : "";
|
|
914
|
+
return `- ${response.status}${types}${desc}`;
|
|
915
|
+
});
|
|
916
|
+
const success = responses.find((r) => /^2(\d\d|xx)$/i.test(r.status) && r.schema !== undefined);
|
|
917
|
+
const successBlock = success?.schema !== undefined ? `
|
|
918
|
+
|
|
919
|
+
Success response body (${success.status}):
|
|
920
|
+
${renderSchema(success.schema)}` : "";
|
|
921
|
+
return `Responses:
|
|
922
|
+
${lines.join(`
|
|
923
|
+
`)}${successBlock}`;
|
|
924
|
+
}
|
|
925
|
+
function schemaTypeLabel(schema, depth = 0) {
|
|
926
|
+
if (Array.isArray(schema.enum))
|
|
927
|
+
return `enum(${schema.enum.map((v) => JSON.stringify(v)).join(", ")})`;
|
|
928
|
+
const type = schema.type;
|
|
929
|
+
if (type === "array") {
|
|
930
|
+
const items = schema.items;
|
|
931
|
+
if (!items || depth >= MAX_TYPE_LABEL_DEPTH)
|
|
932
|
+
return "array<any>";
|
|
933
|
+
return `array<${schemaTypeLabel(items, depth + 1)}>`;
|
|
934
|
+
}
|
|
935
|
+
if (typeof type === "string")
|
|
936
|
+
return type;
|
|
937
|
+
if (Array.isArray(type))
|
|
938
|
+
return type.join("|");
|
|
939
|
+
return "any";
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/tools/execute.ts
|
|
943
|
+
import { z as z3 } from "zod/v4";
|
|
944
|
+
function registerExecuteTool(server, registry, context) {
|
|
945
|
+
server.registerTool("execute_operation", {
|
|
946
|
+
title: "Execute one operation",
|
|
947
|
+
description: "Perform the real HTTP request for one operation of a loaded spec, identified by spec_id " + "+ method + path (as shown by search_operations). Supply arguments grouped by location: " + "path_params, query, headers (each an object of name -> value) and body (the request body). " + "Only parameters declared by the operation are sent; the body is sent as-is, so extra body " + "fields pass through to the API. Required parameters and body must be present. Returns the " + "HTTP status and response body.",
|
|
948
|
+
inputSchema: {
|
|
949
|
+
spec_id: z3.string().min(1).describe("The id of a loaded spec (from load_spec/list_specs)."),
|
|
950
|
+
method: z3.string().min(1).describe('HTTP method, e.g. "get" or "post".'),
|
|
951
|
+
path: z3.string().min(1).describe('Raw path template, e.g. "/pets/{petId}".'),
|
|
952
|
+
path_params: z3.record(z3.string(), z3.unknown()).optional().describe('Path template values, keyed by parameter name, e.g. { "petId": 42 }.'),
|
|
953
|
+
query: z3.record(z3.string(), z3.unknown()).optional().describe("Query string parameters, keyed by name."),
|
|
954
|
+
headers: z3.record(z3.string(), z3.unknown()).optional().describe("Request header parameters, keyed by name."),
|
|
955
|
+
body: z3.unknown().optional().describe("Request body, matching the operation’s schema.")
|
|
956
|
+
},
|
|
957
|
+
annotations: { openWorldHint: true }
|
|
757
958
|
}, async (args) => {
|
|
959
|
+
const spec = registry.get(args.spec_id);
|
|
960
|
+
if (!spec) {
|
|
961
|
+
return errorResult2(`No spec is loaded with id "${args.spec_id}". Call list_specs to see loaded specs.`);
|
|
962
|
+
}
|
|
963
|
+
const operation = findOperation(spec, args.method, args.path);
|
|
964
|
+
if (!operation) {
|
|
965
|
+
return errorResult2(`No operation ${args.method.toUpperCase()} ${args.path} in spec "${args.spec_id}". ` + "Use search_operations to find the exact method and path.");
|
|
966
|
+
}
|
|
967
|
+
const groups = args;
|
|
968
|
+
const requiredCookies = operation.parameters.filter((p) => p.in === "cookie" && p.required);
|
|
969
|
+
if (requiredCookies.length > 0) {
|
|
970
|
+
return errorResult2(`Operation ${operation.method.toUpperCase()} ${operation.path} requires cookie ` + `parameter(s) (${requiredCookies.map((p) => p.name).join(", ")}), which ` + "execute_operation does not support.");
|
|
971
|
+
}
|
|
972
|
+
const missing = missingRequired(operation, groups);
|
|
973
|
+
if (missing.length > 0) {
|
|
974
|
+
return errorResult2(`Missing required argument(s): ${missing.join(", ")}. ` + "Call describe_operation for this operation’s parameters.");
|
|
975
|
+
}
|
|
758
976
|
try {
|
|
759
977
|
const result = await executeOperation({
|
|
760
978
|
operation,
|
|
761
|
-
args,
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
requestTimeoutMs:
|
|
979
|
+
args: toFlatArgs(operation, groups),
|
|
980
|
+
baseUrlOverride: spec.baseUrl,
|
|
981
|
+
auth: resolveAuth(spec.doc, context.env, spec.credential),
|
|
982
|
+
requestTimeoutMs: context.requestTimeoutMs,
|
|
983
|
+
maxResponseBytes: context.maxResponseBytes
|
|
765
984
|
});
|
|
766
985
|
return { isError: !result.ok, content: [{ type: "text", text: formatResult(result) }] };
|
|
767
986
|
} catch (error) {
|
|
768
|
-
return {
|
|
769
|
-
isError: true,
|
|
770
|
-
content: [{ type: "text", text: `Request failed: ${errorMessage(error)}` }]
|
|
771
|
-
};
|
|
987
|
+
return errorResult2(`Request failed: ${errorMessage(error)}`);
|
|
772
988
|
}
|
|
773
989
|
});
|
|
774
990
|
}
|
|
775
|
-
function
|
|
776
|
-
|
|
991
|
+
function errorResult2(text) {
|
|
992
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
993
|
+
}
|
|
994
|
+
function groupFor(location, groups) {
|
|
995
|
+
switch (location) {
|
|
996
|
+
case "path":
|
|
997
|
+
return groups.path_params;
|
|
998
|
+
case "query":
|
|
999
|
+
return groups.query;
|
|
1000
|
+
case "header":
|
|
1001
|
+
return groups.headers;
|
|
1002
|
+
case "cookie":
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
function missingRequired(operation, groups) {
|
|
1007
|
+
const missing = [];
|
|
777
1008
|
for (const param of operation.parameters) {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1009
|
+
if (!param.required || param.in === "cookie")
|
|
1010
|
+
continue;
|
|
1011
|
+
if (groupFor(param.in, groups)?.[param.name] === undefined) {
|
|
1012
|
+
missing.push(`${param.name} (${param.in})`);
|
|
1013
|
+
}
|
|
782
1014
|
}
|
|
783
|
-
if (operation.requestBody)
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1015
|
+
if (operation.requestBody?.required && groups.body === undefined)
|
|
1016
|
+
missing.push("body");
|
|
1017
|
+
return missing;
|
|
1018
|
+
}
|
|
1019
|
+
function toFlatArgs(operation, groups) {
|
|
1020
|
+
const flat = {};
|
|
1021
|
+
for (const param of operation.parameters) {
|
|
1022
|
+
const value = groupFor(param.in, groups)?.[param.name];
|
|
1023
|
+
if (value !== undefined)
|
|
1024
|
+
flat[param.argName] = coerceScalar(value, param);
|
|
790
1025
|
}
|
|
791
|
-
|
|
1026
|
+
if (groups.body !== undefined)
|
|
1027
|
+
flat.requestBody = groups.body;
|
|
1028
|
+
return flat;
|
|
792
1029
|
}
|
|
793
|
-
function
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1030
|
+
function coerceScalar(value, param) {
|
|
1031
|
+
if (typeof value !== "string")
|
|
1032
|
+
return value;
|
|
1033
|
+
const type = schemaType(param.schema);
|
|
1034
|
+
if (type === "number" || type === "integer") {
|
|
1035
|
+
const trimmed = value.trim();
|
|
1036
|
+
const num = Number(trimmed);
|
|
1037
|
+
if (trimmed === "" || String(num) !== trimmed)
|
|
1038
|
+
return value;
|
|
1039
|
+
if (type === "integer" && !Number.isSafeInteger(num))
|
|
1040
|
+
return value;
|
|
1041
|
+
return num;
|
|
1042
|
+
}
|
|
1043
|
+
if (type === "boolean" && (value === "true" || value === "false")) {
|
|
1044
|
+
return value === "true";
|
|
1045
|
+
}
|
|
1046
|
+
return value;
|
|
1047
|
+
}
|
|
1048
|
+
function schemaType(schema) {
|
|
1049
|
+
const type = schema.type;
|
|
1050
|
+
if (typeof type === "string")
|
|
1051
|
+
return type;
|
|
1052
|
+
if (Array.isArray(type))
|
|
1053
|
+
return type.find((t) => typeof t === "string" && t !== "null");
|
|
1054
|
+
return;
|
|
801
1055
|
}
|
|
802
1056
|
function formatResult(result) {
|
|
803
1057
|
const header = `HTTP ${result.status} ${result.statusText}`;
|
|
@@ -806,18 +1060,303 @@ function formatResult(result) {
|
|
|
806
1060
|
${result.body}` : header;
|
|
807
1061
|
}
|
|
808
1062
|
|
|
1063
|
+
// src/tools/search.ts
|
|
1064
|
+
import { z as z4 } from "zod/v4";
|
|
1065
|
+
|
|
1066
|
+
// src/openapi/search.ts
|
|
1067
|
+
import MiniSearch from "minisearch";
|
|
1068
|
+
function operationKey(operation) {
|
|
1069
|
+
return `${operation.method} ${operation.path}`;
|
|
1070
|
+
}
|
|
1071
|
+
function buildSearchIndex(operations) {
|
|
1072
|
+
const index = new MiniSearch({
|
|
1073
|
+
idField: "id",
|
|
1074
|
+
fields: ["operationId", "summary", "description", "tags", "path"],
|
|
1075
|
+
storeFields: ["operationId", "method", "path", "summary", "deprecated"],
|
|
1076
|
+
searchOptions: {
|
|
1077
|
+
prefix: true,
|
|
1078
|
+
fuzzy: 0.2,
|
|
1079
|
+
boost: { operationId: 3, summary: 2, tags: 1.5 }
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
index.addAll(operations.map((operation) => ({
|
|
1083
|
+
id: operationKey(operation),
|
|
1084
|
+
operationId: operation.operationId ?? "",
|
|
1085
|
+
method: operation.method,
|
|
1086
|
+
path: operation.path,
|
|
1087
|
+
summary: operation.summary ?? "",
|
|
1088
|
+
description: operation.description ?? "",
|
|
1089
|
+
tags: operation.tags.join(" "),
|
|
1090
|
+
deprecated: operation.deprecated
|
|
1091
|
+
})));
|
|
1092
|
+
return index;
|
|
1093
|
+
}
|
|
1094
|
+
function searchOperations(index, query) {
|
|
1095
|
+
return index.search(query).map(toSearchMatch);
|
|
1096
|
+
}
|
|
1097
|
+
function toSearchMatch(result) {
|
|
1098
|
+
const summary = typeof result.summary === "string" && result.summary.length > 0 ? result.summary : undefined;
|
|
1099
|
+
const operationId = typeof result.operationId === "string" && result.operationId.length > 0 ? result.operationId : undefined;
|
|
1100
|
+
return {
|
|
1101
|
+
method: String(result.method),
|
|
1102
|
+
path: String(result.path),
|
|
1103
|
+
deprecated: result.deprecated === true,
|
|
1104
|
+
score: result.score,
|
|
1105
|
+
...operationId !== undefined && { operationId },
|
|
1106
|
+
...summary !== undefined && { summary }
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// src/tools/search.ts
|
|
1111
|
+
var DEFAULT_LIMIT = 20;
|
|
1112
|
+
var indexCache = new WeakMap;
|
|
1113
|
+
function getIndex(spec) {
|
|
1114
|
+
let index = indexCache.get(spec);
|
|
1115
|
+
if (!index) {
|
|
1116
|
+
index = buildSearchIndex(spec.operations);
|
|
1117
|
+
indexCache.set(spec, index);
|
|
1118
|
+
}
|
|
1119
|
+
return index;
|
|
1120
|
+
}
|
|
1121
|
+
function registerSearchTool(server, registry) {
|
|
1122
|
+
server.registerTool("search_operations", {
|
|
1123
|
+
title: "Search a spec’s operations",
|
|
1124
|
+
description: "Find operations within one loaded spec (identified by spec_id). With a query, ranks " + "operations by keyword match against their operationId, summary, description, tags, and " + "path. Without a query, browses every operation in natural order. Results are compact " + "one-liners (method, path, operationId, summary) with no parameter or response schemas; " + "call describe_operation for the full detail of a specific operation. Use limit/offset " + "to page through large specs.",
|
|
1125
|
+
inputSchema: {
|
|
1126
|
+
spec_id: z4.string().min(1).describe("The id of a loaded spec (from load_spec/list_specs)."),
|
|
1127
|
+
query: z4.string().optional().describe('Keywords, e.g. "create pet". Omit or leave empty to browse all operations.'),
|
|
1128
|
+
limit: z4.number().int().positive().max(100).optional().describe(`Maximum results to return (default ${DEFAULT_LIMIT}).`),
|
|
1129
|
+
offset: z4.number().int().nonnegative().optional().describe("Number of results to skip, for pagination (default 0).")
|
|
1130
|
+
},
|
|
1131
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
1132
|
+
}, (args) => {
|
|
1133
|
+
const spec = registry.get(args.spec_id);
|
|
1134
|
+
if (!spec) {
|
|
1135
|
+
return {
|
|
1136
|
+
isError: true,
|
|
1137
|
+
content: [
|
|
1138
|
+
{
|
|
1139
|
+
type: "text",
|
|
1140
|
+
text: `No spec is loaded with id "${args.spec_id}". Call list_specs to see loaded specs.`
|
|
1141
|
+
}
|
|
1142
|
+
]
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
const query = args.query?.trim() ?? "";
|
|
1146
|
+
const limit = args.limit ?? DEFAULT_LIMIT;
|
|
1147
|
+
const offset = args.offset ?? 0;
|
|
1148
|
+
const all = query ? searchOperations(getIndex(spec), query) : spec.operations.map(toLine);
|
|
1149
|
+
const page = all.slice(offset, offset + limit);
|
|
1150
|
+
return {
|
|
1151
|
+
content: [
|
|
1152
|
+
{ type: "text", text: formatResults(page, { total: all.length, offset, query }) }
|
|
1153
|
+
]
|
|
1154
|
+
};
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
function toLine(operation) {
|
|
1158
|
+
return {
|
|
1159
|
+
method: operation.method,
|
|
1160
|
+
path: operation.path,
|
|
1161
|
+
deprecated: operation.deprecated,
|
|
1162
|
+
...operation.operationId !== undefined && { operationId: operation.operationId },
|
|
1163
|
+
...operation.summary !== undefined && { summary: operation.summary }
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
function formatResults(lines, ctx) {
|
|
1167
|
+
const scope = ctx.query ? `matching "${ctx.query}"` : "in this spec";
|
|
1168
|
+
if (ctx.total === 0) {
|
|
1169
|
+
return ctx.query ? `No operations matched "${ctx.query}".` : "This spec has no operations.";
|
|
1170
|
+
}
|
|
1171
|
+
if (lines.length === 0) {
|
|
1172
|
+
return `Offset ${ctx.offset} is past the ${ctx.total} operation(s) ${scope}. Use a smaller offset.`;
|
|
1173
|
+
}
|
|
1174
|
+
const first = ctx.offset + 1;
|
|
1175
|
+
const last = ctx.offset + lines.length;
|
|
1176
|
+
const header = `${ctx.total} operation(s) ${scope} (showing ${first}-${last}):`;
|
|
1177
|
+
const body = lines.map(formatLine).join(`
|
|
1178
|
+
`);
|
|
1179
|
+
const shownThrough = ctx.offset + lines.length;
|
|
1180
|
+
const footer = shownThrough < ctx.total ? `
|
|
1181
|
+
|
|
1182
|
+
${ctx.total - shownThrough} more. Pass offset: ${shownThrough} to see the next page.` : "";
|
|
1183
|
+
return `${header}
|
|
1184
|
+
|
|
1185
|
+
${body}${footer}`;
|
|
1186
|
+
}
|
|
1187
|
+
function formatLine(line) {
|
|
1188
|
+
const parts = [`${line.method.toUpperCase()} ${line.path}`];
|
|
1189
|
+
if (line.operationId !== undefined)
|
|
1190
|
+
parts.push(`[${line.operationId}]`);
|
|
1191
|
+
if (line.summary !== undefined)
|
|
1192
|
+
parts.push(`- ${line.summary}`);
|
|
1193
|
+
if (line.deprecated)
|
|
1194
|
+
parts.push("(deprecated)");
|
|
1195
|
+
return parts.join(" ");
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// src/tools/specs.ts
|
|
1199
|
+
import { z as z5 } from "zod/v4";
|
|
1200
|
+
function registerSpecTools(server, registry, env) {
|
|
1201
|
+
registerLoadSpec(server, registry, env);
|
|
1202
|
+
registerListSpecs(server, registry);
|
|
1203
|
+
registerUnloadSpec(server, registry);
|
|
1204
|
+
}
|
|
1205
|
+
function registerLoadSpec(server, registry, env) {
|
|
1206
|
+
server.registerTool("load_spec", {
|
|
1207
|
+
title: "Load an OpenAPI/Swagger spec",
|
|
1208
|
+
description: "Load an OpenAPI/Swagger document (2.0/3.0/3.1) so its operations can be searched, " + "described, and executed. The source may be an http(s) URL, a local file path, or the " + "raw JSON/YAML content itself. Returns a generated spec_id (a lowercase ULID) plus a " + "compact summary; pass that spec_id to the other tools. Loading the same document twice " + "produces two independent specs with different ids.",
|
|
1209
|
+
inputSchema: {
|
|
1210
|
+
source: z5.string().min(1).describe("An http(s) URL, a local file path, or the raw JSON/YAML content of the document."),
|
|
1211
|
+
base_url: z5.string().min(1).optional().describe("Override the base URL this spec's operations are called against (wins over the " + "servers declared in the document)."),
|
|
1212
|
+
credential: z5.string().min(1).optional().describe('Name of a credential the operator provisioned in the environment (e.g. "github" for ' + "OPENAPI_CRED_GITHUB). Pass the reference name only, never a secret value. When " + "omitted, credentials are matched by the document's security scheme names.")
|
|
1213
|
+
},
|
|
1214
|
+
annotations: { readOnlyHint: false, idempotentHint: false, openWorldHint: true }
|
|
1215
|
+
}, async (args) => {
|
|
1216
|
+
if (registry.isFull) {
|
|
1217
|
+
return {
|
|
1218
|
+
isError: true,
|
|
1219
|
+
content: [
|
|
1220
|
+
{ type: "text", text: `Cannot load. ${maxSpecsReachedMessage(registry.maxSpecs)}` }
|
|
1221
|
+
]
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
if (args.credential !== undefined && !hasConfiguredCredential(env, args.credential)) {
|
|
1225
|
+
return {
|
|
1226
|
+
isError: true,
|
|
1227
|
+
content: [
|
|
1228
|
+
{
|
|
1229
|
+
type: "text",
|
|
1230
|
+
text: `No credential named "${args.credential}" is configured on this server. ` + `Ask the operator to provision it (e.g. ${credentialEnvBase(args.credential)}), ` + "or omit the credential argument."
|
|
1231
|
+
}
|
|
1232
|
+
]
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
try {
|
|
1236
|
+
const spec = await registry.load(args.source, {
|
|
1237
|
+
...args.base_url !== undefined && { baseUrl: args.base_url },
|
|
1238
|
+
...args.credential !== undefined && { credential: args.credential }
|
|
1239
|
+
});
|
|
1240
|
+
if (args.credential !== undefined && hasSupportedSecuritySchemes(spec.doc) && resolveAuth(spec.doc, env, args.credential).length === 0) {
|
|
1241
|
+
registry.unload(spec.id);
|
|
1242
|
+
return {
|
|
1243
|
+
isError: true,
|
|
1244
|
+
content: [
|
|
1245
|
+
{
|
|
1246
|
+
type: "text",
|
|
1247
|
+
text: `Credential "${args.credential}" is configured but does not provide what any of ` + `this spec's security schemes require. Check the variant under ${credentialEnvBase(args.credential)} ` + "(bearer needs _TOKEN or the bare value, basic needs _USERNAME and _PASSWORD, apiKey needs the bare value)."
|
|
1248
|
+
}
|
|
1249
|
+
]
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
return { content: [{ type: "text", text: formatLoaded(specSummary(spec)) }] };
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
return {
|
|
1255
|
+
isError: true,
|
|
1256
|
+
content: [{ type: "text", text: `Failed to load spec: ${errorMessage(error)}` }]
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
function registerUnloadSpec(server, registry) {
|
|
1262
|
+
server.registerTool("unload_spec", {
|
|
1263
|
+
title: "Unload a spec",
|
|
1264
|
+
description: "Remove a loaded spec from the server, freeing its operations and cached search index. " + "Use this to reclaim capacity when the maximum number of loaded specs is reached, or to " + "drop a spec you no longer need.",
|
|
1265
|
+
inputSchema: {
|
|
1266
|
+
spec_id: z5.string().min(1).describe("The id of the loaded spec to unload.")
|
|
1267
|
+
},
|
|
1268
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false }
|
|
1269
|
+
}, (args) => {
|
|
1270
|
+
if (!registry.unload(args.spec_id)) {
|
|
1271
|
+
return {
|
|
1272
|
+
isError: true,
|
|
1273
|
+
content: [
|
|
1274
|
+
{
|
|
1275
|
+
type: "text",
|
|
1276
|
+
text: `No spec is loaded with id "${args.spec_id}". Call list_specs to see loaded specs.`
|
|
1277
|
+
}
|
|
1278
|
+
]
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
return { content: [{ type: "text", text: `Unloaded spec ${args.spec_id}.` }] };
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
function registerListSpecs(server, registry) {
|
|
1285
|
+
server.registerTool("list_specs", {
|
|
1286
|
+
title: "List loaded specs",
|
|
1287
|
+
description: "List every OpenAPI/Swagger spec currently loaded into this server, with its spec_id " + "and a compact summary. Use this to discover the spec_id to pass to the other tools.",
|
|
1288
|
+
inputSchema: {},
|
|
1289
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
1290
|
+
}, () => {
|
|
1291
|
+
const summaries = registry.list().map(specSummary);
|
|
1292
|
+
return { content: [{ type: "text", text: formatList(summaries) }] };
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
function formatLoaded(summary) {
|
|
1296
|
+
return `Loaded spec.
|
|
1297
|
+
|
|
1298
|
+
${formatSummary(summary)}
|
|
1299
|
+
|
|
1300
|
+
Pass this spec_id to the other meta-tools to work with its operations.`;
|
|
1301
|
+
}
|
|
1302
|
+
function formatList(summaries) {
|
|
1303
|
+
if (summaries.length === 0) {
|
|
1304
|
+
return "No specs are currently loaded. Use load_spec to load one.";
|
|
1305
|
+
}
|
|
1306
|
+
const blocks = summaries.map((summary, index) => `${index + 1}. ${formatSummary(summary)}`);
|
|
1307
|
+
return `${summaries.length} spec(s) loaded:
|
|
1308
|
+
|
|
1309
|
+
${blocks.join(`
|
|
1310
|
+
|
|
1311
|
+
`)}`;
|
|
1312
|
+
}
|
|
1313
|
+
function formatSummary(summary) {
|
|
1314
|
+
const servers = summary.servers.length > 0 ? summary.servers.join(", ") : "(none declared)";
|
|
1315
|
+
return [
|
|
1316
|
+
`spec_id: ${summary.id}`,
|
|
1317
|
+
`${summary.title} (v${summary.version}) - ${summary.operationCount} operation(s)`,
|
|
1318
|
+
`base URL(s): ${servers}`
|
|
1319
|
+
].join(`
|
|
1320
|
+
`);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// src/server.ts
|
|
1324
|
+
function buildServer(registry, options) {
|
|
1325
|
+
const server = new McpServer({ name: "mcp-openapi", version: "0.2.0" });
|
|
1326
|
+
const env = options.env ?? {};
|
|
1327
|
+
registerSpecTools(server, registry, env);
|
|
1328
|
+
registerSearchTool(server, registry);
|
|
1329
|
+
registerDescribeTool(server, registry);
|
|
1330
|
+
registerExecuteTool(server, registry, {
|
|
1331
|
+
requestTimeoutMs: options.requestTimeoutMs,
|
|
1332
|
+
maxResponseBytes: options.maxResponseBytes ?? MAX_RESPONSE_BODY_BYTES,
|
|
1333
|
+
env
|
|
1334
|
+
});
|
|
1335
|
+
return server;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
809
1338
|
// src/index.ts
|
|
810
|
-
|
|
1339
|
+
async function preloadSpecs(registry, preloads) {
|
|
1340
|
+
for (const preload of preloads) {
|
|
1341
|
+
try {
|
|
1342
|
+
await registry.load(preload.source, {
|
|
1343
|
+
...preload.baseUrl !== undefined && { baseUrl: preload.baseUrl },
|
|
1344
|
+
...preload.credential !== undefined && { credential: preload.credential }
|
|
1345
|
+
});
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
console.warn(`mcp-openapi: failed to preload "${preload.source}": ${errorMessage(error)}`);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
811
1351
|
async function main() {
|
|
812
1352
|
const config = resolveConfig(process.argv.slice(2), process.env);
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
820
|
-
const server = buildServer(doc, operations, config, process.env);
|
|
1353
|
+
const registry = new SpecRegistry({ maxSpecs: config.maxSpecs });
|
|
1354
|
+
await preloadSpecs(registry, config.preloads);
|
|
1355
|
+
const server = buildServer(registry, {
|
|
1356
|
+
requestTimeoutMs: config.requestTimeoutMs,
|
|
1357
|
+
maxResponseBytes: config.maxResponseBytes,
|
|
1358
|
+
env: process.env
|
|
1359
|
+
});
|
|
821
1360
|
const transport = new StdioServerTransport;
|
|
822
1361
|
await server.connect(transport);
|
|
823
1362
|
}
|
|
@@ -837,5 +1376,6 @@ if (isMainModule()) {
|
|
|
837
1376
|
});
|
|
838
1377
|
}
|
|
839
1378
|
export {
|
|
1379
|
+
preloadSpecs,
|
|
840
1380
|
main
|
|
841
1381
|
};
|