@avidian/mcp-openapi 0.1.2 → 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 -252
- 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,82 +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
|
-
name = `${base}_${counter}`;
|
|
281
|
-
counter += 1;
|
|
282
|
-
}
|
|
283
|
-
used.add(name);
|
|
284
|
-
return { ...draft, toolName: name };
|
|
285
|
-
});
|
|
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.";
|
|
286
304
|
}
|
|
287
305
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
storeFields: ["toolName", "method", "path", "summary", "deprecated"],
|
|
296
|
-
searchOptions: {
|
|
297
|
-
prefix: true,
|
|
298
|
-
fuzzy: 0.2,
|
|
299
|
-
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}.`);
|
|
300
313
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
+
}
|
|
316
361
|
}
|
|
317
|
-
function
|
|
318
|
-
const summary = typeof result.summary === "string" && result.summary.length > 0 ? result.summary : undefined;
|
|
362
|
+
function specSummary(spec) {
|
|
319
363
|
return {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
...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
|
|
326
369
|
};
|
|
327
370
|
}
|
|
328
371
|
|
|
329
|
-
// src/server.ts
|
|
330
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
331
|
-
import { z as z2 } from "zod/v4";
|
|
332
|
-
|
|
333
372
|
// src/openapi/auth.ts
|
|
334
|
-
function resolveAuth(doc, env) {
|
|
335
|
-
const schemes =
|
|
373
|
+
function resolveAuth(doc, env, credentialRef) {
|
|
374
|
+
const schemes = normalizedSchemes(doc);
|
|
375
|
+
const names = credentialRef !== undefined ? requiredSchemeNames(doc) : undefined;
|
|
336
376
|
const resolved = [];
|
|
337
377
|
for (const [name, scheme] of Object.entries(schemes)) {
|
|
338
|
-
|
|
378
|
+
if (names && !names.has(name))
|
|
379
|
+
continue;
|
|
380
|
+
const base = credentialRef !== undefined ? credentialEnvBase(credentialRef) : authEnvBase(name);
|
|
381
|
+
const credential = resolveSchemeCredential(base, scheme, env);
|
|
339
382
|
if (credential)
|
|
340
383
|
resolved.push(credential);
|
|
341
384
|
}
|
|
342
385
|
return resolved;
|
|
343
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
|
+
}
|
|
344
411
|
function applyAuth(auth, target) {
|
|
345
412
|
for (const entry of auth) {
|
|
346
413
|
switch (entry.kind) {
|
|
@@ -365,8 +432,7 @@ function applyApiKey(entry, target) {
|
|
|
365
432
|
target.headers.set(entry.name, entry.value);
|
|
366
433
|
}
|
|
367
434
|
}
|
|
368
|
-
function resolveSchemeCredential(
|
|
369
|
-
const base = envVarName(name);
|
|
435
|
+
function resolveSchemeCredential(base, scheme, env) {
|
|
370
436
|
switch (scheme.kind) {
|
|
371
437
|
case "bearer": {
|
|
372
438
|
const token = env[`${base}_TOKEN`] ?? env[base];
|
|
@@ -383,9 +449,11 @@ function resolveSchemeCredential(name, scheme, env) {
|
|
|
383
449
|
}
|
|
384
450
|
}
|
|
385
451
|
}
|
|
386
|
-
function
|
|
387
|
-
|
|
388
|
-
|
|
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();
|
|
389
457
|
}
|
|
390
458
|
function openApi3SecuritySchemes(doc) {
|
|
391
459
|
const schemes = doc.components?.securitySchemes ?? {};
|
|
@@ -422,6 +490,7 @@ function swagger2SecuritySchemes(doc) {
|
|
|
422
490
|
var MAX_RESPONSE_BODY_BYTES = 1e5;
|
|
423
491
|
async function executeOperation(options) {
|
|
424
492
|
const { operation, args, auth, requestTimeoutMs } = options;
|
|
493
|
+
const maxResponseBytes = options.maxResponseBytes ?? MAX_RESPONSE_BODY_BYTES;
|
|
425
494
|
const baseUrl = resolveBaseUrl(operation, options.baseUrlOverride);
|
|
426
495
|
const target = {
|
|
427
496
|
url: buildUrl(operation, args, baseUrl),
|
|
@@ -435,13 +504,13 @@ async function executeOperation(options) {
|
|
|
435
504
|
target.headers.set("Cookie", [...target.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
|
|
436
505
|
}
|
|
437
506
|
const response = await performFetch(target.url, operation, target.headers, body, requestTimeoutMs);
|
|
438
|
-
const { bytes, truncated } = await readBoundedBody(response,
|
|
507
|
+
const { bytes, truncated } = await readBoundedBody(response, maxResponseBytes);
|
|
439
508
|
return {
|
|
440
509
|
ok: response.ok,
|
|
441
510
|
status: response.status,
|
|
442
511
|
statusText: response.statusText,
|
|
443
512
|
truncated,
|
|
444
|
-
body: decodeBody(bytes, truncated, response.headers.get("content-type"))
|
|
513
|
+
body: decodeBody(bytes, truncated, maxResponseBytes, response.headers.get("content-type"))
|
|
445
514
|
};
|
|
446
515
|
}
|
|
447
516
|
async function performFetch(url, operation, headers, body, requestTimeoutMs) {
|
|
@@ -454,9 +523,7 @@ async function performFetch(url, operation, headers, body, requestTimeoutMs) {
|
|
|
454
523
|
});
|
|
455
524
|
} catch (error) {
|
|
456
525
|
if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) {
|
|
457
|
-
throw new Error(`Request to ${url.
|
|
458
|
-
cause: error
|
|
459
|
-
});
|
|
526
|
+
throw new Error(`Request to ${url.origin}${url.pathname} timed out after ${requestTimeoutMs}ms.`, { cause: error });
|
|
460
527
|
}
|
|
461
528
|
throw error;
|
|
462
529
|
}
|
|
@@ -502,13 +569,13 @@ function isTextContentType(contentType) {
|
|
|
502
569
|
const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
503
570
|
return type.startsWith("text/") || type === "application/json" || type === "application/xml" || type === "application/x-www-form-urlencoded" || type.endsWith("+json") || type.endsWith("+xml");
|
|
504
571
|
}
|
|
505
|
-
function decodeBody(bytes, truncated, contentType) {
|
|
572
|
+
function decodeBody(bytes, truncated, maxResponseBytes, contentType) {
|
|
506
573
|
if (bytes.length === 0)
|
|
507
574
|
return "";
|
|
508
575
|
if (isTextContentType(contentType)) {
|
|
509
576
|
const text = new TextDecoder().decode(bytes);
|
|
510
577
|
return truncated ? `${text}
|
|
511
|
-
...[response truncated, exceeded the ${
|
|
578
|
+
...[response truncated, exceeded the ${maxResponseBytes} byte cap]` : text;
|
|
512
579
|
}
|
|
513
580
|
const base64 = Buffer.from(bytes).toString("base64");
|
|
514
581
|
const label = contentType ?? "application/octet-stream";
|
|
@@ -518,7 +585,7 @@ ${base64}`;
|
|
|
518
585
|
function resolveBaseUrl(operation, baseUrlOverride) {
|
|
519
586
|
const base = baseUrlOverride ?? operation.servers[0];
|
|
520
587
|
if (base === undefined) {
|
|
521
|
-
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.`);
|
|
522
589
|
}
|
|
523
590
|
return base;
|
|
524
591
|
}
|
|
@@ -631,172 +698,360 @@ function base64ToBlob(base64) {
|
|
|
631
698
|
return new Blob([bytes]);
|
|
632
699
|
}
|
|
633
700
|
|
|
634
|
-
// src/
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const normalized = {};
|
|
656
|
-
for (const [key, value] of Object.entries(source)) {
|
|
657
|
-
normalized[key] = normalizeNode(value, seen);
|
|
658
|
-
}
|
|
659
|
-
const nullable = normalized.nullable === true || normalized["x-nullable"] === true;
|
|
660
|
-
delete normalized.nullable;
|
|
661
|
-
delete normalized["x-nullable"];
|
|
662
|
-
if (nullable) {
|
|
663
|
-
const { type } = normalized;
|
|
664
|
-
if (typeof type === "string") {
|
|
665
|
-
normalized.type = [type, "null"];
|
|
666
|
-
} else if (Array.isArray(type)) {
|
|
667
|
-
const types = type;
|
|
668
|
-
normalized.type = types.includes("null") ? types : [...types, "null"];
|
|
669
|
-
} else if (type === undefined) {
|
|
670
|
-
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" }
|
|
671
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
|
+
});
|
|
672
736
|
}
|
|
673
|
-
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
|
+
};
|
|
674
743
|
}
|
|
675
|
-
function
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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"}.`);
|
|
684
778
|
}
|
|
779
|
+
return parsed;
|
|
685
780
|
}
|
|
686
|
-
|
|
687
|
-
return options.required ? described : described.optional();
|
|
781
|
+
return fromConfig ?? fallback;
|
|
688
782
|
}
|
|
689
783
|
|
|
690
784
|
// src/server.ts
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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);
|
|
699
841
|
}
|
|
700
|
-
return server;
|
|
701
842
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
function
|
|
706
|
-
server.
|
|
707
|
-
title: "
|
|
708
|
-
description: "
|
|
709
|
-
mimeType: "application/json"
|
|
710
|
-
}, (uri) => ({
|
|
711
|
-
contents: [
|
|
712
|
-
{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(doc, null, 2) }
|
|
713
|
-
]
|
|
714
|
-
}));
|
|
715
|
-
}
|
|
716
|
-
function registerSearchTool(server, operations) {
|
|
717
|
-
const index = buildSearchIndex(operations);
|
|
718
|
-
server.registerTool(SEARCH_TOOL_NAME, {
|
|
719
|
-
title: "Search API operations",
|
|
720
|
-
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.",
|
|
721
850
|
inputSchema: {
|
|
722
|
-
|
|
723
|
-
|
|
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}".')
|
|
724
854
|
},
|
|
725
|
-
annotations: { readOnlyHint: true,
|
|
855
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
726
856
|
}, (args) => {
|
|
727
|
-
const
|
|
728
|
-
|
|
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) }] };
|
|
729
866
|
});
|
|
730
867
|
}
|
|
731
|
-
function
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
});
|
|
740
|
-
|
|
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(`
|
|
741
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:
|
|
742
905
|
${lines.join(`
|
|
743
906
|
`)}`;
|
|
744
907
|
}
|
|
745
|
-
function
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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 }
|
|
756
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
|
+
}
|
|
757
976
|
try {
|
|
758
977
|
const result = await executeOperation({
|
|
759
978
|
operation,
|
|
760
|
-
args,
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
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
|
|
764
984
|
});
|
|
765
985
|
return { isError: !result.ok, content: [{ type: "text", text: formatResult(result) }] };
|
|
766
986
|
} catch (error) {
|
|
767
|
-
return {
|
|
768
|
-
isError: true,
|
|
769
|
-
content: [{ type: "text", text: `Request failed: ${errorMessage(error)}` }]
|
|
770
|
-
};
|
|
987
|
+
return errorResult2(`Request failed: ${errorMessage(error)}`);
|
|
771
988
|
}
|
|
772
989
|
});
|
|
773
990
|
}
|
|
774
|
-
function
|
|
775
|
-
|
|
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 = [];
|
|
776
1008
|
for (const param of operation.parameters) {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
+
}
|
|
781
1014
|
}
|
|
782
|
-
if (operation.requestBody)
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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);
|
|
789
1025
|
}
|
|
790
|
-
|
|
1026
|
+
if (groups.body !== undefined)
|
|
1027
|
+
flat.requestBody = groups.body;
|
|
1028
|
+
return flat;
|
|
791
1029
|
}
|
|
792
|
-
function
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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;
|
|
800
1055
|
}
|
|
801
1056
|
function formatResult(result) {
|
|
802
1057
|
const header = `HTTP ${result.status} ${result.statusText}`;
|
|
@@ -805,18 +1060,303 @@ function formatResult(result) {
|
|
|
805
1060
|
${result.body}` : header;
|
|
806
1061
|
}
|
|
807
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
|
+
|
|
808
1338
|
// src/index.ts
|
|
809
|
-
|
|
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
|
+
}
|
|
810
1351
|
async function main() {
|
|
811
1352
|
const config = resolveConfig(process.argv.slice(2), process.env);
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
}
|
|
819
|
-
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
|
+
});
|
|
820
1360
|
const transport = new StdioServerTransport;
|
|
821
1361
|
await server.connect(transport);
|
|
822
1362
|
}
|
|
@@ -836,5 +1376,6 @@ if (isMainModule()) {
|
|
|
836
1376
|
});
|
|
837
1377
|
}
|
|
838
1378
|
export {
|
|
1379
|
+
preloadSpecs,
|
|
839
1380
|
main
|
|
840
1381
|
};
|