@avidian/mcp-openapi 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +105 -27
  2. package/dist/index.js +793 -253
  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,83 +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
- const suffix = `_${counter}`;
281
- name = `${base.slice(0, 128 - suffix.length)}${suffix}`;
282
- counter += 1;
283
- }
284
- used.add(name);
285
- return { ...draft, toolName: name };
286
- });
299
+
300
+ // src/registry.ts
301
+ var DEFAULT_MAX_SPECS = 20;
302
+ function maxSpecsReachedMessage(maxSpecs) {
303
+ return `The maximum of ${maxSpecs} loaded specs has been reached. ` + "Unload a spec with unload_spec (see list_specs) before loading another.";
287
304
  }
288
305
 
289
- // src/openapi/search.ts
290
- import MiniSearch from "minisearch";
291
- var SEARCH_TOOL_NAME = "search_operations";
292
- function buildSearchIndex(operations) {
293
- const index = new MiniSearch({
294
- idField: "id",
295
- fields: ["toolName", "summary", "description", "tags", "path"],
296
- storeFields: ["toolName", "method", "path", "summary", "deprecated"],
297
- searchOptions: {
298
- prefix: true,
299
- fuzzy: 0.2,
300
- boost: { toolName: 3, summary: 2, tags: 1.5 }
306
+ class SpecRegistry {
307
+ specs = new Map;
308
+ maxSpecs;
309
+ constructor(options = {}) {
310
+ const maxSpecs = options.maxSpecs ?? DEFAULT_MAX_SPECS;
311
+ if (!Number.isInteger(maxSpecs) || maxSpecs < 1) {
312
+ throw new Error(`maxSpecs must be a positive integer, got ${maxSpecs}.`);
301
313
  }
302
- });
303
- index.addAll(operations.map((operation) => ({
304
- id: operation.toolName,
305
- toolName: operation.toolName,
306
- method: operation.method,
307
- path: operation.path,
308
- summary: operation.summary ?? "",
309
- description: operation.description ?? "",
310
- tags: operation.tags.join(" "),
311
- deprecated: operation.deprecated
312
- })));
313
- return index;
314
- }
315
- function searchOperations(index, query, limit) {
316
- 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
+ }
317
361
  }
318
- function toSearchMatch(result) {
319
- const summary = typeof result.summary === "string" && result.summary.length > 0 ? result.summary : undefined;
362
+ function specSummary(spec) {
320
363
  return {
321
- toolName: String(result.toolName),
322
- method: String(result.method),
323
- path: String(result.path),
324
- deprecated: result.deprecated === true,
325
- score: result.score,
326
- ...summary !== undefined && { summary }
364
+ id: spec.id,
365
+ title: spec.title,
366
+ version: spec.version,
367
+ operationCount: spec.operations.length,
368
+ servers: spec.baseUrl !== undefined ? [spec.baseUrl] : spec.servers
327
369
  };
328
370
  }
329
371
 
330
- // src/server.ts
331
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
332
- import { z as z2 } from "zod/v4";
333
-
334
372
  // src/openapi/auth.ts
335
- function resolveAuth(doc, env) {
336
- const schemes = 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;
337
376
  const resolved = [];
338
377
  for (const [name, scheme] of Object.entries(schemes)) {
339
- 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);
340
382
  if (credential)
341
383
  resolved.push(credential);
342
384
  }
343
385
  return resolved;
344
386
  }
387
+ function hasSupportedSecuritySchemes(doc) {
388
+ return Object.keys(normalizedSchemes(doc)).length > 0;
389
+ }
390
+ function normalizedSchemes(doc) {
391
+ return isSwagger2Document(doc) ? swagger2SecuritySchemes(doc) : openApi3SecuritySchemes(doc);
392
+ }
393
+ function requiredSchemeNames(doc) {
394
+ const security = doc.security;
395
+ if (!security || security.length === 0)
396
+ return;
397
+ const names = new Set;
398
+ for (const requirement of security) {
399
+ for (const name of Object.keys(requirement))
400
+ names.add(name);
401
+ }
402
+ return names;
403
+ }
404
+ function hasConfiguredCredential(env, ref) {
405
+ const base = credentialEnvBase(ref);
406
+ return [base, `${base}_TOKEN`, `${base}_USERNAME`, `${base}_PASSWORD`].some((key) => env[key] !== undefined);
407
+ }
408
+ function credentialEnvBase(ref) {
409
+ return `OPENAPI_CRED_${screamingSnake(ref)}`;
410
+ }
345
411
  function applyAuth(auth, target) {
346
412
  for (const entry of auth) {
347
413
  switch (entry.kind) {
@@ -366,8 +432,7 @@ function applyApiKey(entry, target) {
366
432
  target.headers.set(entry.name, entry.value);
367
433
  }
368
434
  }
369
- function resolveSchemeCredential(name, scheme, env) {
370
- const base = envVarName(name);
435
+ function resolveSchemeCredential(base, scheme, env) {
371
436
  switch (scheme.kind) {
372
437
  case "bearer": {
373
438
  const token = env[`${base}_TOKEN`] ?? env[base];
@@ -384,9 +449,11 @@ function resolveSchemeCredential(name, scheme, env) {
384
449
  }
385
450
  }
386
451
  }
387
- function envVarName(schemeName) {
388
- const upper = schemeName.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase();
389
- 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();
390
457
  }
391
458
  function openApi3SecuritySchemes(doc) {
392
459
  const schemes = doc.components?.securitySchemes ?? {};
@@ -423,6 +490,7 @@ function swagger2SecuritySchemes(doc) {
423
490
  var MAX_RESPONSE_BODY_BYTES = 1e5;
424
491
  async function executeOperation(options) {
425
492
  const { operation, args, auth, requestTimeoutMs } = options;
493
+ const maxResponseBytes = options.maxResponseBytes ?? MAX_RESPONSE_BODY_BYTES;
426
494
  const baseUrl = resolveBaseUrl(operation, options.baseUrlOverride);
427
495
  const target = {
428
496
  url: buildUrl(operation, args, baseUrl),
@@ -436,13 +504,13 @@ async function executeOperation(options) {
436
504
  target.headers.set("Cookie", [...target.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
437
505
  }
438
506
  const response = await performFetch(target.url, operation, target.headers, body, requestTimeoutMs);
439
- const { bytes, truncated } = await readBoundedBody(response, MAX_RESPONSE_BODY_BYTES);
507
+ const { bytes, truncated } = await readBoundedBody(response, maxResponseBytes);
440
508
  return {
441
509
  ok: response.ok,
442
510
  status: response.status,
443
511
  statusText: response.statusText,
444
512
  truncated,
445
- body: decodeBody(bytes, truncated, response.headers.get("content-type"))
513
+ body: decodeBody(bytes, truncated, maxResponseBytes, response.headers.get("content-type"))
446
514
  };
447
515
  }
448
516
  async function performFetch(url, operation, headers, body, requestTimeoutMs) {
@@ -455,9 +523,7 @@ async function performFetch(url, operation, headers, body, requestTimeoutMs) {
455
523
  });
456
524
  } catch (error) {
457
525
  if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) {
458
- throw new Error(`Request to ${url.toString()} timed out after ${requestTimeoutMs}ms.`, {
459
- cause: error
460
- });
526
+ throw new Error(`Request to ${url.origin}${url.pathname} timed out after ${requestTimeoutMs}ms.`, { cause: error });
461
527
  }
462
528
  throw error;
463
529
  }
@@ -503,13 +569,13 @@ function isTextContentType(contentType) {
503
569
  const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
504
570
  return type.startsWith("text/") || type === "application/json" || type === "application/xml" || type === "application/x-www-form-urlencoded" || type.endsWith("+json") || type.endsWith("+xml");
505
571
  }
506
- function decodeBody(bytes, truncated, contentType) {
572
+ function decodeBody(bytes, truncated, maxResponseBytes, contentType) {
507
573
  if (bytes.length === 0)
508
574
  return "";
509
575
  if (isTextContentType(contentType)) {
510
576
  const text = new TextDecoder().decode(bytes);
511
577
  return truncated ? `${text}
512
- ...[response truncated, exceeded the ${MAX_RESPONSE_BODY_BYTES} byte cap]` : text;
578
+ ...[response truncated, exceeded the ${maxResponseBytes} byte cap]` : text;
513
579
  }
514
580
  const base64 = Buffer.from(bytes).toString("base64");
515
581
  const label = contentType ?? "application/octet-stream";
@@ -519,7 +585,7 @@ ${base64}`;
519
585
  function resolveBaseUrl(operation, baseUrlOverride) {
520
586
  const base = baseUrlOverride ?? operation.servers[0];
521
587
  if (base === undefined) {
522
- throw new Error(`No server URL is available for operation "${operation.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.`);
523
589
  }
524
590
  return base;
525
591
  }
@@ -632,172 +698,360 @@ function base64ToBlob(base64) {
632
698
  return new Blob([bytes]);
633
699
  }
634
700
 
635
- // src/openapi/schema.ts
636
- import { z } from "zod/v4";
637
- import { convertJsonSchemaToZod } from "zod-from-json-schema";
638
- function normalizeJsonSchema(schema) {
639
- const seen = new WeakSet;
640
- return normalizeNode(schema, seen);
641
- }
642
- function normalizeNode(node, seen) {
643
- if (Array.isArray(node)) {
644
- if (seen.has(node))
645
- return node;
646
- seen.add(node);
647
- return node.map((item) => normalizeNode(item, seen));
648
- }
649
- if (node === null || typeof node !== "object") {
650
- return node;
651
- }
652
- if (seen.has(node))
653
- return node;
654
- seen.add(node);
655
- const source = node;
656
- const normalized = {};
657
- for (const [key, value] of Object.entries(source)) {
658
- normalized[key] = normalizeNode(value, seen);
659
- }
660
- const nullable = normalized.nullable === true || normalized["x-nullable"] === true;
661
- delete normalized.nullable;
662
- delete normalized["x-nullable"];
663
- if (nullable) {
664
- const { type } = normalized;
665
- if (typeof type === "string") {
666
- normalized.type = [type, "null"];
667
- } else if (Array.isArray(type)) {
668
- const types = type;
669
- normalized.type = types.includes("null") ? types : [...types, "null"];
670
- } else if (type === undefined) {
671
- normalized.type = ["null"];
701
+ // src/config.ts
702
+ var DEFAULT_REQUEST_TIMEOUT_MS = 30000;
703
+ var ConfigFileSchema = z.strictObject({
704
+ specs: z.array(z.strictObject({
705
+ source: z.string().min(1),
706
+ base_url: z.string().min(1).optional(),
707
+ credential: z.string().min(1).optional()
708
+ })).optional(),
709
+ max_specs: z.number().int().positive().optional(),
710
+ request_timeout_ms: z.number().positive().optional(),
711
+ max_response_bytes: z.number().int().positive().optional()
712
+ });
713
+ function resolveConfig(argv, env) {
714
+ const { values, positionals } = parseArgs({
715
+ args: argv,
716
+ allowPositionals: true,
717
+ options: {
718
+ "base-url": { type: "string" },
719
+ credential: { type: "string" },
720
+ timeout: { type: "string" },
721
+ config: { type: "string" }
672
722
  }
723
+ });
724
+ const configPath = clean(values.config ?? env.OPENAPI_CONFIG);
725
+ const configFile = configPath !== undefined ? readConfigFile(configPath) : {};
726
+ const preloads = [...(configFile.specs ?? []).map(fromFileSpec)];
727
+ const shorthandSource = clean(positionals[0] ?? env.OPENAPI_URL);
728
+ if (shorthandSource !== undefined) {
729
+ const baseUrl = clean(values["base-url"] ?? env.OPENAPI_BASE_URL);
730
+ const credential = clean(values.credential ?? env.OPENAPI_CREDENTIAL);
731
+ preloads.push({
732
+ source: shorthandSource,
733
+ ...baseUrl !== undefined && { baseUrl },
734
+ ...credential !== undefined && { credential }
735
+ });
673
736
  }
674
- return 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
+ };
675
743
  }
676
- function toZodType(schema, options) {
677
- let base;
678
- if (!schema) {
679
- base = z.unknown();
680
- } else {
681
- try {
682
- base = convertJsonSchemaToZod(normalizeJsonSchema(schema));
683
- } catch {
684
- 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"}.`);
685
778
  }
779
+ return parsed;
686
780
  }
687
- const described = options.description !== undefined ? base.describe(options.description) : base;
688
- return options.required ? described : described.optional();
781
+ return fromConfig ?? fallback;
689
782
  }
690
783
 
691
784
  // src/server.ts
692
- function buildServer(doc, operations, config, env) {
693
- const server = new McpServer({ name: "mcp-openapi", version: "0.1.0", title: specTitle(doc) });
694
- registerSpecResource(server, doc);
695
- if (operations.length > 0)
696
- registerSearchTool(server, operations);
697
- const auth = resolveAuth(doc, env);
698
- for (const operation of operations) {
699
- 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);
700
841
  }
701
- return server;
702
842
  }
703
- function specTitle(doc) {
704
- return doc.info.title;
705
- }
706
- function registerSpecResource(server, doc) {
707
- server.registerResource("openapi-spec", "openapi://spec", {
708
- title: "OpenAPI document",
709
- description: "The full, dereferenced OpenAPI/Swagger document this server exposes tools for.",
710
- mimeType: "application/json"
711
- }, (uri) => ({
712
- contents: [
713
- { uri: uri.href, mimeType: "application/json", text: JSON.stringify(doc, null, 2) }
714
- ]
715
- }));
716
- }
717
- function registerSearchTool(server, operations) {
718
- const index = buildSearchIndex(operations);
719
- server.registerTool(SEARCH_TOOL_NAME, {
720
- title: "Search API operations",
721
- description: "Search the available API operations by keyword (matches operation names, summaries, descriptions, tags, and paths). " + "Use this to find the right tool when there are many operations, then call that tool directly by name.",
843
+
844
+ // src/tools/describe.ts
845
+ var MAX_TYPE_LABEL_DEPTH = 4;
846
+ function registerDescribeTool(server, registry) {
847
+ server.registerTool("describe_operation", {
848
+ title: "Describe one operation",
849
+ description: "Return the full detail needed to call one operation of a loaded spec, identified by " + "spec_id + method + path (as shown by search_operations). Parameters and the request-body " + "schema are returned in full; responses are summarized (status codes and content types, " + "plus the schema of the 2xx success response only). Large or deeply nested schemas are " + "truncated with a marker.",
722
850
  inputSchema: {
723
- query: z2.string().min(1).describe('Keywords to search for, e.g. "create pet" or "delete order".'),
724
- 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}".')
725
854
  },
726
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
855
+ annotations: { readOnlyHint: true, openWorldHint: false }
727
856
  }, (args) => {
728
- const matches = searchOperations(index, args.query, args.limit ?? 10);
729
- 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) }] };
730
866
  });
731
867
  }
732
- function formatSearchResults(query, matches) {
733
- if (matches.length === 0)
734
- return `No operations matched "${query}".`;
735
- const lines = matches.map((match, index) => {
736
- const flags = match.deprecated ? " [deprecated]" : "";
737
- const summary = match.summary ? `
738
- ${match.summary}` : "";
739
- return `${index + 1}. ${match.toolName} (${match.method.toUpperCase()} ${match.path})${flags}${summary}`;
740
- });
741
- 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(`
742
893
 
894
+ `);
895
+ }
896
+ function formatParameters(parameters) {
897
+ if (parameters.length === 0)
898
+ return "Parameters: none";
899
+ const lines = parameters.map((param) => {
900
+ const required = param.required ? "required" : "optional";
901
+ const desc = param.description !== undefined ? ` - ${param.description}` : "";
902
+ return `- ${param.name} (${param.in}, ${required}): ${schemaTypeLabel(param.schema)}${desc}`;
903
+ });
904
+ return `Parameters:
743
905
  ${lines.join(`
744
906
  `)}`;
745
907
  }
746
- function registerOperationTool(server, operation, auth, config) {
747
- server.registerTool(operation.toolName, {
748
- title: operation.summary ?? operation.toolName,
749
- description: toolDescription(operation),
750
- inputSchema: buildInputShape(operation),
751
- annotations: {
752
- readOnlyHint: operation.method === "get" || operation.method === "head",
753
- destructiveHint: operation.method === "delete",
754
- idempotentHint: ["get", "put", "delete", "head"].includes(operation.method),
755
- openWorldHint: true
756
- }
908
+ function formatResponses(responses) {
909
+ if (responses.length === 0)
910
+ return "Responses: none documented";
911
+ const lines = responses.map((response) => {
912
+ const types = response.contentTypes.length > 0 ? ` (${response.contentTypes.join(", ")})` : "";
913
+ const desc = response.description !== undefined ? ` - ${response.description}` : "";
914
+ return `- ${response.status}${types}${desc}`;
915
+ });
916
+ const success = responses.find((r) => /^2(\d\d|xx)$/i.test(r.status) && r.schema !== undefined);
917
+ const successBlock = success?.schema !== undefined ? `
918
+
919
+ Success response body (${success.status}):
920
+ ${renderSchema(success.schema)}` : "";
921
+ return `Responses:
922
+ ${lines.join(`
923
+ `)}${successBlock}`;
924
+ }
925
+ function schemaTypeLabel(schema, depth = 0) {
926
+ if (Array.isArray(schema.enum))
927
+ return `enum(${schema.enum.map((v) => JSON.stringify(v)).join(", ")})`;
928
+ const type = schema.type;
929
+ if (type === "array") {
930
+ const items = schema.items;
931
+ if (!items || depth >= MAX_TYPE_LABEL_DEPTH)
932
+ return "array<any>";
933
+ return `array<${schemaTypeLabel(items, depth + 1)}>`;
934
+ }
935
+ if (typeof type === "string")
936
+ return type;
937
+ if (Array.isArray(type))
938
+ return type.join("|");
939
+ return "any";
940
+ }
941
+
942
+ // src/tools/execute.ts
943
+ import { z as z3 } from "zod/v4";
944
+ function registerExecuteTool(server, registry, context) {
945
+ server.registerTool("execute_operation", {
946
+ title: "Execute one operation",
947
+ description: "Perform the real HTTP request for one operation of a loaded spec, identified by spec_id " + "+ method + path (as shown by search_operations). Supply arguments grouped by location: " + "path_params, query, headers (each an object of name -> value) and body (the request body). " + "Only parameters declared by the operation are sent; the body is sent as-is, so extra body " + "fields pass through to the API. Required parameters and body must be present. Returns the " + "HTTP status and response body.",
948
+ inputSchema: {
949
+ spec_id: z3.string().min(1).describe("The id of a loaded spec (from load_spec/list_specs)."),
950
+ method: z3.string().min(1).describe('HTTP method, e.g. "get" or "post".'),
951
+ path: z3.string().min(1).describe('Raw path template, e.g. "/pets/{petId}".'),
952
+ path_params: z3.record(z3.string(), z3.unknown()).optional().describe('Path template values, keyed by parameter name, e.g. { "petId": 42 }.'),
953
+ query: z3.record(z3.string(), z3.unknown()).optional().describe("Query string parameters, keyed by name."),
954
+ headers: z3.record(z3.string(), z3.unknown()).optional().describe("Request header parameters, keyed by name."),
955
+ body: z3.unknown().optional().describe("Request body, matching the operation’s schema.")
956
+ },
957
+ annotations: { openWorldHint: true }
757
958
  }, async (args) => {
959
+ const spec = registry.get(args.spec_id);
960
+ if (!spec) {
961
+ return errorResult2(`No spec is loaded with id "${args.spec_id}". Call list_specs to see loaded specs.`);
962
+ }
963
+ const operation = findOperation(spec, args.method, args.path);
964
+ if (!operation) {
965
+ return errorResult2(`No operation ${args.method.toUpperCase()} ${args.path} in spec "${args.spec_id}". ` + "Use search_operations to find the exact method and path.");
966
+ }
967
+ const groups = args;
968
+ const requiredCookies = operation.parameters.filter((p) => p.in === "cookie" && p.required);
969
+ if (requiredCookies.length > 0) {
970
+ return errorResult2(`Operation ${operation.method.toUpperCase()} ${operation.path} requires cookie ` + `parameter(s) (${requiredCookies.map((p) => p.name).join(", ")}), which ` + "execute_operation does not support.");
971
+ }
972
+ const missing = missingRequired(operation, groups);
973
+ if (missing.length > 0) {
974
+ return errorResult2(`Missing required argument(s): ${missing.join(", ")}. ` + "Call describe_operation for this operation’s parameters.");
975
+ }
758
976
  try {
759
977
  const result = await executeOperation({
760
978
  operation,
761
- args,
762
- auth,
763
- baseUrlOverride: config.baseUrlOverride,
764
- 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
765
984
  });
766
985
  return { isError: !result.ok, content: [{ type: "text", text: formatResult(result) }] };
767
986
  } catch (error) {
768
- return {
769
- isError: true,
770
- content: [{ type: "text", text: `Request failed: ${errorMessage(error)}` }]
771
- };
987
+ return errorResult2(`Request failed: ${errorMessage(error)}`);
772
988
  }
773
989
  });
774
990
  }
775
- function buildInputShape(operation) {
776
- 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 = [];
777
1008
  for (const param of operation.parameters) {
778
- shape[param.argName] = toZodType(param.schema, {
779
- required: param.required,
780
- ...param.description !== undefined && { description: param.description }
781
- });
1009
+ if (!param.required || param.in === "cookie")
1010
+ continue;
1011
+ if (groupFor(param.in, groups)?.[param.name] === undefined) {
1012
+ missing.push(`${param.name} (${param.in})`);
1013
+ }
782
1014
  }
783
- if (operation.requestBody) {
784
- shape.requestBody = toZodType(operation.requestBody.schema, {
785
- required: operation.requestBody.required,
786
- ...operation.requestBody.description !== undefined && {
787
- description: operation.requestBody.description
788
- }
789
- });
1015
+ if (operation.requestBody?.required && groups.body === undefined)
1016
+ missing.push("body");
1017
+ return missing;
1018
+ }
1019
+ function toFlatArgs(operation, groups) {
1020
+ const flat = {};
1021
+ for (const param of operation.parameters) {
1022
+ const value = groupFor(param.in, groups)?.[param.name];
1023
+ if (value !== undefined)
1024
+ flat[param.argName] = coerceScalar(value, param);
790
1025
  }
791
- return shape;
1026
+ if (groups.body !== undefined)
1027
+ flat.requestBody = groups.body;
1028
+ return flat;
792
1029
  }
793
- function toolDescription(operation) {
794
- const summary = operation.description ?? operation.summary ?? `${operation.method.toUpperCase()} ${operation.path}`;
795
- const parts = [summary, `HTTP ${operation.method.toUpperCase()} ${operation.path}`];
796
- if (operation.deprecated)
797
- parts.push("This operation is marked deprecated in the OpenAPI document.");
798
- return parts.join(`
799
-
800
- `);
1030
+ function coerceScalar(value, param) {
1031
+ if (typeof value !== "string")
1032
+ return value;
1033
+ const type = schemaType(param.schema);
1034
+ if (type === "number" || type === "integer") {
1035
+ const trimmed = value.trim();
1036
+ const num = Number(trimmed);
1037
+ if (trimmed === "" || String(num) !== trimmed)
1038
+ return value;
1039
+ if (type === "integer" && !Number.isSafeInteger(num))
1040
+ return value;
1041
+ return num;
1042
+ }
1043
+ if (type === "boolean" && (value === "true" || value === "false")) {
1044
+ return value === "true";
1045
+ }
1046
+ return value;
1047
+ }
1048
+ function schemaType(schema) {
1049
+ const type = schema.type;
1050
+ if (typeof type === "string")
1051
+ return type;
1052
+ if (Array.isArray(type))
1053
+ return type.find((t) => typeof t === "string" && t !== "null");
1054
+ return;
801
1055
  }
802
1056
  function formatResult(result) {
803
1057
  const header = `HTTP ${result.status} ${result.statusText}`;
@@ -806,18 +1060,303 @@ function formatResult(result) {
806
1060
  ${result.body}` : header;
807
1061
  }
808
1062
 
1063
+ // src/tools/search.ts
1064
+ import { z as z4 } from "zod/v4";
1065
+
1066
+ // src/openapi/search.ts
1067
+ import MiniSearch from "minisearch";
1068
+ function operationKey(operation) {
1069
+ return `${operation.method} ${operation.path}`;
1070
+ }
1071
+ function buildSearchIndex(operations) {
1072
+ const index = new MiniSearch({
1073
+ idField: "id",
1074
+ fields: ["operationId", "summary", "description", "tags", "path"],
1075
+ storeFields: ["operationId", "method", "path", "summary", "deprecated"],
1076
+ searchOptions: {
1077
+ prefix: true,
1078
+ fuzzy: 0.2,
1079
+ boost: { operationId: 3, summary: 2, tags: 1.5 }
1080
+ }
1081
+ });
1082
+ index.addAll(operations.map((operation) => ({
1083
+ id: operationKey(operation),
1084
+ operationId: operation.operationId ?? "",
1085
+ method: operation.method,
1086
+ path: operation.path,
1087
+ summary: operation.summary ?? "",
1088
+ description: operation.description ?? "",
1089
+ tags: operation.tags.join(" "),
1090
+ deprecated: operation.deprecated
1091
+ })));
1092
+ return index;
1093
+ }
1094
+ function searchOperations(index, query) {
1095
+ return index.search(query).map(toSearchMatch);
1096
+ }
1097
+ function toSearchMatch(result) {
1098
+ const summary = typeof result.summary === "string" && result.summary.length > 0 ? result.summary : undefined;
1099
+ const operationId = typeof result.operationId === "string" && result.operationId.length > 0 ? result.operationId : undefined;
1100
+ return {
1101
+ method: String(result.method),
1102
+ path: String(result.path),
1103
+ deprecated: result.deprecated === true,
1104
+ score: result.score,
1105
+ ...operationId !== undefined && { operationId },
1106
+ ...summary !== undefined && { summary }
1107
+ };
1108
+ }
1109
+
1110
+ // src/tools/search.ts
1111
+ var DEFAULT_LIMIT = 20;
1112
+ var indexCache = new WeakMap;
1113
+ function getIndex(spec) {
1114
+ let index = indexCache.get(spec);
1115
+ if (!index) {
1116
+ index = buildSearchIndex(spec.operations);
1117
+ indexCache.set(spec, index);
1118
+ }
1119
+ return index;
1120
+ }
1121
+ function registerSearchTool(server, registry) {
1122
+ server.registerTool("search_operations", {
1123
+ title: "Search a spec’s operations",
1124
+ description: "Find operations within one loaded spec (identified by spec_id). With a query, ranks " + "operations by keyword match against their operationId, summary, description, tags, and " + "path. Without a query, browses every operation in natural order. Results are compact " + "one-liners (method, path, operationId, summary) with no parameter or response schemas; " + "call describe_operation for the full detail of a specific operation. Use limit/offset " + "to page through large specs.",
1125
+ inputSchema: {
1126
+ spec_id: z4.string().min(1).describe("The id of a loaded spec (from load_spec/list_specs)."),
1127
+ query: z4.string().optional().describe('Keywords, e.g. "create pet". Omit or leave empty to browse all operations.'),
1128
+ limit: z4.number().int().positive().max(100).optional().describe(`Maximum results to return (default ${DEFAULT_LIMIT}).`),
1129
+ offset: z4.number().int().nonnegative().optional().describe("Number of results to skip, for pagination (default 0).")
1130
+ },
1131
+ annotations: { readOnlyHint: true, openWorldHint: false }
1132
+ }, (args) => {
1133
+ const spec = registry.get(args.spec_id);
1134
+ if (!spec) {
1135
+ return {
1136
+ isError: true,
1137
+ content: [
1138
+ {
1139
+ type: "text",
1140
+ text: `No spec is loaded with id "${args.spec_id}". Call list_specs to see loaded specs.`
1141
+ }
1142
+ ]
1143
+ };
1144
+ }
1145
+ const query = args.query?.trim() ?? "";
1146
+ const limit = args.limit ?? DEFAULT_LIMIT;
1147
+ const offset = args.offset ?? 0;
1148
+ const all = query ? searchOperations(getIndex(spec), query) : spec.operations.map(toLine);
1149
+ const page = all.slice(offset, offset + limit);
1150
+ return {
1151
+ content: [
1152
+ { type: "text", text: formatResults(page, { total: all.length, offset, query }) }
1153
+ ]
1154
+ };
1155
+ });
1156
+ }
1157
+ function toLine(operation) {
1158
+ return {
1159
+ method: operation.method,
1160
+ path: operation.path,
1161
+ deprecated: operation.deprecated,
1162
+ ...operation.operationId !== undefined && { operationId: operation.operationId },
1163
+ ...operation.summary !== undefined && { summary: operation.summary }
1164
+ };
1165
+ }
1166
+ function formatResults(lines, ctx) {
1167
+ const scope = ctx.query ? `matching "${ctx.query}"` : "in this spec";
1168
+ if (ctx.total === 0) {
1169
+ return ctx.query ? `No operations matched "${ctx.query}".` : "This spec has no operations.";
1170
+ }
1171
+ if (lines.length === 0) {
1172
+ return `Offset ${ctx.offset} is past the ${ctx.total} operation(s) ${scope}. Use a smaller offset.`;
1173
+ }
1174
+ const first = ctx.offset + 1;
1175
+ const last = ctx.offset + lines.length;
1176
+ const header = `${ctx.total} operation(s) ${scope} (showing ${first}-${last}):`;
1177
+ const body = lines.map(formatLine).join(`
1178
+ `);
1179
+ const shownThrough = ctx.offset + lines.length;
1180
+ const footer = shownThrough < ctx.total ? `
1181
+
1182
+ ${ctx.total - shownThrough} more. Pass offset: ${shownThrough} to see the next page.` : "";
1183
+ return `${header}
1184
+
1185
+ ${body}${footer}`;
1186
+ }
1187
+ function formatLine(line) {
1188
+ const parts = [`${line.method.toUpperCase()} ${line.path}`];
1189
+ if (line.operationId !== undefined)
1190
+ parts.push(`[${line.operationId}]`);
1191
+ if (line.summary !== undefined)
1192
+ parts.push(`- ${line.summary}`);
1193
+ if (line.deprecated)
1194
+ parts.push("(deprecated)");
1195
+ return parts.join(" ");
1196
+ }
1197
+
1198
+ // src/tools/specs.ts
1199
+ import { z as z5 } from "zod/v4";
1200
+ function registerSpecTools(server, registry, env) {
1201
+ registerLoadSpec(server, registry, env);
1202
+ registerListSpecs(server, registry);
1203
+ registerUnloadSpec(server, registry);
1204
+ }
1205
+ function registerLoadSpec(server, registry, env) {
1206
+ server.registerTool("load_spec", {
1207
+ title: "Load an OpenAPI/Swagger spec",
1208
+ description: "Load an OpenAPI/Swagger document (2.0/3.0/3.1) so its operations can be searched, " + "described, and executed. The source may be an http(s) URL, a local file path, or the " + "raw JSON/YAML content itself. Returns a generated spec_id (a lowercase ULID) plus a " + "compact summary; pass that spec_id to the other tools. Loading the same document twice " + "produces two independent specs with different ids.",
1209
+ inputSchema: {
1210
+ source: z5.string().min(1).describe("An http(s) URL, a local file path, or the raw JSON/YAML content of the document."),
1211
+ base_url: z5.string().min(1).optional().describe("Override the base URL this spec's operations are called against (wins over the " + "servers declared in the document)."),
1212
+ credential: z5.string().min(1).optional().describe('Name of a credential the operator provisioned in the environment (e.g. "github" for ' + "OPENAPI_CRED_GITHUB). Pass the reference name only, never a secret value. When " + "omitted, credentials are matched by the document's security scheme names.")
1213
+ },
1214
+ annotations: { readOnlyHint: false, idempotentHint: false, openWorldHint: true }
1215
+ }, async (args) => {
1216
+ if (registry.isFull) {
1217
+ return {
1218
+ isError: true,
1219
+ content: [
1220
+ { type: "text", text: `Cannot load. ${maxSpecsReachedMessage(registry.maxSpecs)}` }
1221
+ ]
1222
+ };
1223
+ }
1224
+ if (args.credential !== undefined && !hasConfiguredCredential(env, args.credential)) {
1225
+ return {
1226
+ isError: true,
1227
+ content: [
1228
+ {
1229
+ type: "text",
1230
+ text: `No credential named "${args.credential}" is configured on this server. ` + `Ask the operator to provision it (e.g. ${credentialEnvBase(args.credential)}), ` + "or omit the credential argument."
1231
+ }
1232
+ ]
1233
+ };
1234
+ }
1235
+ try {
1236
+ const spec = await registry.load(args.source, {
1237
+ ...args.base_url !== undefined && { baseUrl: args.base_url },
1238
+ ...args.credential !== undefined && { credential: args.credential }
1239
+ });
1240
+ if (args.credential !== undefined && hasSupportedSecuritySchemes(spec.doc) && resolveAuth(spec.doc, env, args.credential).length === 0) {
1241
+ registry.unload(spec.id);
1242
+ return {
1243
+ isError: true,
1244
+ content: [
1245
+ {
1246
+ type: "text",
1247
+ text: `Credential "${args.credential}" is configured but does not provide what any of ` + `this spec's security schemes require. Check the variant under ${credentialEnvBase(args.credential)} ` + "(bearer needs _TOKEN or the bare value, basic needs _USERNAME and _PASSWORD, apiKey needs the bare value)."
1248
+ }
1249
+ ]
1250
+ };
1251
+ }
1252
+ return { content: [{ type: "text", text: formatLoaded(specSummary(spec)) }] };
1253
+ } catch (error) {
1254
+ return {
1255
+ isError: true,
1256
+ content: [{ type: "text", text: `Failed to load spec: ${errorMessage(error)}` }]
1257
+ };
1258
+ }
1259
+ });
1260
+ }
1261
+ function registerUnloadSpec(server, registry) {
1262
+ server.registerTool("unload_spec", {
1263
+ title: "Unload a spec",
1264
+ description: "Remove a loaded spec from the server, freeing its operations and cached search index. " + "Use this to reclaim capacity when the maximum number of loaded specs is reached, or to " + "drop a spec you no longer need.",
1265
+ inputSchema: {
1266
+ spec_id: z5.string().min(1).describe("The id of the loaded spec to unload.")
1267
+ },
1268
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false }
1269
+ }, (args) => {
1270
+ if (!registry.unload(args.spec_id)) {
1271
+ return {
1272
+ isError: true,
1273
+ content: [
1274
+ {
1275
+ type: "text",
1276
+ text: `No spec is loaded with id "${args.spec_id}". Call list_specs to see loaded specs.`
1277
+ }
1278
+ ]
1279
+ };
1280
+ }
1281
+ return { content: [{ type: "text", text: `Unloaded spec ${args.spec_id}.` }] };
1282
+ });
1283
+ }
1284
+ function registerListSpecs(server, registry) {
1285
+ server.registerTool("list_specs", {
1286
+ title: "List loaded specs",
1287
+ description: "List every OpenAPI/Swagger spec currently loaded into this server, with its spec_id " + "and a compact summary. Use this to discover the spec_id to pass to the other tools.",
1288
+ inputSchema: {},
1289
+ annotations: { readOnlyHint: true, openWorldHint: false }
1290
+ }, () => {
1291
+ const summaries = registry.list().map(specSummary);
1292
+ return { content: [{ type: "text", text: formatList(summaries) }] };
1293
+ });
1294
+ }
1295
+ function formatLoaded(summary) {
1296
+ return `Loaded spec.
1297
+
1298
+ ${formatSummary(summary)}
1299
+
1300
+ Pass this spec_id to the other meta-tools to work with its operations.`;
1301
+ }
1302
+ function formatList(summaries) {
1303
+ if (summaries.length === 0) {
1304
+ return "No specs are currently loaded. Use load_spec to load one.";
1305
+ }
1306
+ const blocks = summaries.map((summary, index) => `${index + 1}. ${formatSummary(summary)}`);
1307
+ return `${summaries.length} spec(s) loaded:
1308
+
1309
+ ${blocks.join(`
1310
+
1311
+ `)}`;
1312
+ }
1313
+ function formatSummary(summary) {
1314
+ const servers = summary.servers.length > 0 ? summary.servers.join(", ") : "(none declared)";
1315
+ return [
1316
+ `spec_id: ${summary.id}`,
1317
+ `${summary.title} (v${summary.version}) - ${summary.operationCount} operation(s)`,
1318
+ `base URL(s): ${servers}`
1319
+ ].join(`
1320
+ `);
1321
+ }
1322
+
1323
+ // src/server.ts
1324
+ function buildServer(registry, options) {
1325
+ const server = new McpServer({ name: "mcp-openapi", version: "0.2.0" });
1326
+ const env = options.env ?? {};
1327
+ registerSpecTools(server, registry, env);
1328
+ registerSearchTool(server, registry);
1329
+ registerDescribeTool(server, registry);
1330
+ registerExecuteTool(server, registry, {
1331
+ requestTimeoutMs: options.requestTimeoutMs,
1332
+ maxResponseBytes: options.maxResponseBytes ?? MAX_RESPONSE_BODY_BYTES,
1333
+ env
1334
+ });
1335
+ return server;
1336
+ }
1337
+
809
1338
  // src/index.ts
810
- 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
+ }
811
1351
  async function main() {
812
1352
  const config = resolveConfig(process.argv.slice(2), process.env);
813
- const doc = await loadOpenApiDocument(config.source);
814
- const operations = extractOperations(doc, [SEARCH_TOOL_NAME]);
815
- if (operations.length === 0) {
816
- console.warn(`mcp-openapi: no operations were found in "${config.source}".`);
817
- } else if (operations.length > LARGE_TOOL_COUNT_WARNING_THRESHOLD) {
818
- console.warn(`mcp-openapi: "${config.source}" defines ${operations.length} operations, which will register that many tools and may overwhelm the connected agent.`);
819
- }
820
- const server = buildServer(doc, operations, config, process.env);
1353
+ const registry = new SpecRegistry({ maxSpecs: config.maxSpecs });
1354
+ await preloadSpecs(registry, config.preloads);
1355
+ const server = buildServer(registry, {
1356
+ requestTimeoutMs: config.requestTimeoutMs,
1357
+ maxResponseBytes: config.maxResponseBytes,
1358
+ env: process.env
1359
+ });
821
1360
  const transport = new StdioServerTransport;
822
1361
  await server.connect(transport);
823
1362
  }
@@ -837,5 +1376,6 @@ if (isMainModule()) {
837
1376
  });
838
1377
  }
839
1378
  export {
1379
+ preloadSpecs,
840
1380
  main
841
1381
  };