@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.
Files changed (3) hide show
  1. package/README.md +105 -27
  2. package/dist/index.js +793 -252
  3. 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
- var DEFAULT_REQUEST_TIMEOUT_MS = 30000;
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
- return await SwaggerParser.dereference(source);
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, reservedNames = []) {
74
- const drafts = isSwagger2Document(doc) ? extractSwagger2Operations(doc) : extractOpenApi3Operations(doc);
75
- return assignToolNames(drafts, reservedNames);
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
- function sanitizeToolName(raw) {
269
- const cleaned = raw.replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
270
- const truncated = cleaned.slice(0, 128);
271
- return truncated.length > 0 ? truncated : "operation";
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
- // src/openapi/search.ts
289
- import MiniSearch from "minisearch";
290
- var SEARCH_TOOL_NAME = "search_operations";
291
- function buildSearchIndex(operations) {
292
- const index = new MiniSearch({
293
- idField: "id",
294
- fields: ["toolName", "summary", "description", "tags", "path"],
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
- index.addAll(operations.map((operation) => ({
303
- id: operation.toolName,
304
- toolName: operation.toolName,
305
- method: operation.method,
306
- path: operation.path,
307
- summary: operation.summary ?? "",
308
- description: operation.description ?? "",
309
- tags: operation.tags.join(" "),
310
- deprecated: operation.deprecated
311
- })));
312
- return index;
313
- }
314
- function searchOperations(index, query, limit) {
315
- return index.search(query).slice(0, limit).map(toSearchMatch);
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 toSearchMatch(result) {
318
- const summary = typeof result.summary === "string" && result.summary.length > 0 ? result.summary : undefined;
362
+ function specSummary(spec) {
319
363
  return {
320
- toolName: String(result.toolName),
321
- method: String(result.method),
322
- path: String(result.path),
323
- deprecated: result.deprecated === true,
324
- score: result.score,
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 = isSwagger2Document(doc) ? swagger2SecuritySchemes(doc) : openApi3SecuritySchemes(doc);
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
- const credential = resolveSchemeCredential(name, scheme, env);
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(name, scheme, env) {
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 envVarName(schemeName) {
387
- const upper = schemeName.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase();
388
- return `OPENAPI_AUTH_${upper}`;
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, MAX_RESPONSE_BODY_BYTES);
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.toString()} timed out after ${requestTimeoutMs}ms.`, {
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 ${MAX_RESPONSE_BODY_BYTES} byte cap]` : text;
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.toolName}". Set OPENAPI_BASE_URL or pass --base-url.`);
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/openapi/schema.ts
635
- import { z } from "zod/v4";
636
- import { convertJsonSchemaToZod } from "zod-from-json-schema";
637
- function normalizeJsonSchema(schema) {
638
- const seen = new WeakSet;
639
- return normalizeNode(schema, seen);
640
- }
641
- function normalizeNode(node, seen) {
642
- if (Array.isArray(node)) {
643
- if (seen.has(node))
644
- return node;
645
- seen.add(node);
646
- return node.map((item) => normalizeNode(item, seen));
647
- }
648
- if (node === null || typeof node !== "object") {
649
- return node;
650
- }
651
- if (seen.has(node))
652
- return node;
653
- seen.add(node);
654
- const source = node;
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 normalized;
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 toZodType(schema, options) {
676
- let base;
677
- if (!schema) {
678
- base = z.unknown();
679
- } else {
680
- try {
681
- base = convertJsonSchemaToZod(normalizeJsonSchema(schema));
682
- } catch {
683
- base = z.unknown();
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
- const described = options.description !== undefined ? base.describe(options.description) : base;
687
- return options.required ? described : described.optional();
781
+ return fromConfig ?? fallback;
688
782
  }
689
783
 
690
784
  // src/server.ts
691
- function buildServer(doc, operations, config, env) {
692
- const server = new McpServer({ name: "mcp-openapi", version: "0.1.0", title: specTitle(doc) });
693
- registerSpecResource(server, doc);
694
- if (operations.length > 0)
695
- registerSearchTool(server, operations);
696
- const auth = resolveAuth(doc, env);
697
- for (const operation of operations) {
698
- registerOperationTool(server, operation, auth, config);
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
- function specTitle(doc) {
703
- return doc.info.title;
704
- }
705
- function registerSpecResource(server, doc) {
706
- server.registerResource("openapi-spec", "openapi://spec", {
707
- title: "OpenAPI document",
708
- description: "The full, dereferenced OpenAPI/Swagger document this server exposes tools for.",
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
- query: z2.string().min(1).describe('Keywords to search for, e.g. "create pet" or "delete order".'),
723
- limit: z2.number().int().positive().max(50).optional().describe("Maximum number of results to return (default 10).")
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, idempotentHint: true, openWorldHint: false }
855
+ annotations: { readOnlyHint: true, openWorldHint: false }
726
856
  }, (args) => {
727
- const matches = searchOperations(index, args.query, args.limit ?? 10);
728
- return { content: [{ type: "text", text: formatSearchResults(args.query, matches) }] };
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 formatSearchResults(query, matches) {
732
- if (matches.length === 0)
733
- return `No operations matched "${query}".`;
734
- const lines = matches.map((match, index) => {
735
- const flags = match.deprecated ? " [deprecated]" : "";
736
- const summary = match.summary ? `
737
- ${match.summary}` : "";
738
- return `${index + 1}. ${match.toolName} (${match.method.toUpperCase()} ${match.path})${flags}${summary}`;
739
- });
740
- return `Found ${matches.length} matching operation(s) for "${query}":
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 registerOperationTool(server, operation, auth, config) {
746
- server.registerTool(operation.toolName, {
747
- title: operation.summary ?? operation.toolName,
748
- description: toolDescription(operation),
749
- inputSchema: buildInputShape(operation),
750
- annotations: {
751
- readOnlyHint: operation.method === "get" || operation.method === "head",
752
- destructiveHint: operation.method === "delete",
753
- idempotentHint: ["get", "put", "delete", "head"].includes(operation.method),
754
- openWorldHint: true
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
- auth,
762
- baseUrlOverride: config.baseUrlOverride,
763
- requestTimeoutMs: config.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 buildInputShape(operation) {
775
- const shape = {};
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
- shape[param.argName] = toZodType(param.schema, {
778
- required: param.required,
779
- ...param.description !== undefined && { description: param.description }
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
- shape.requestBody = toZodType(operation.requestBody.schema, {
784
- required: operation.requestBody.required,
785
- ...operation.requestBody.description !== undefined && {
786
- description: operation.requestBody.description
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
- return shape;
1026
+ if (groups.body !== undefined)
1027
+ flat.requestBody = groups.body;
1028
+ return flat;
791
1029
  }
792
- function toolDescription(operation) {
793
- const summary = operation.description ?? operation.summary ?? `${operation.method.toUpperCase()} ${operation.path}`;
794
- const parts = [summary, `HTTP ${operation.method.toUpperCase()} ${operation.path}`];
795
- if (operation.deprecated)
796
- parts.push("This operation is marked deprecated in the OpenAPI document.");
797
- return parts.join(`
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
- var LARGE_TOOL_COUNT_WARNING_THRESHOLD = 200;
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 doc = await loadOpenApiDocument(config.source);
813
- const operations = extractOperations(doc, [SEARCH_TOOL_NAME]);
814
- if (operations.length === 0) {
815
- console.warn(`mcp-openapi: no operations were found in "${config.source}".`);
816
- } else if (operations.length > LARGE_TOOL_COUNT_WARNING_THRESHOLD) {
817
- console.warn(`mcp-openapi: "${config.source}" defines ${operations.length} operations, which will register that many tools and may overwhelm the connected agent.`);
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
  };