mcp-from-openapi 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -5,7 +5,9 @@ export { ResponseBuilder } from './response-builder';
5
5
  export { Validator } from './validator';
6
6
  export { SecurityResolver, createSecurityContext } from './security-resolver';
7
7
  export { BUILTIN_FORMAT_RESOLVERS, resolveSchemaFormats } from './format-resolver';
8
- export { OpenAPIToolError, LoadError, ParseError, ValidationError, GenerationError, SchemaError } from './errors';
8
+ export { OpenAPIToolError, LoadError, SsrfError, ParseError, ValidationError, GenerationError, SchemaError } from './errors';
9
+ export { assertUrlSafe, safeFetch, isBlockedAddress, isBlockedHostname, decodeIpv4MappedIpv6, normalizeSsrfOptions, defaultLookup, BLOCKED_HOSTNAMES, } from './ssrf';
10
+ export type { ResolvedSsrfOptions, ResolvedAddress, SsrfHostLookup, SafeFetchOptions } from './ssrf';
9
11
  export type { McpOpenAPITool, ParameterMapper, ToolMetadata, FrontMcpExtensionData, SerializationInfo, SecurityRequirement, SecurityParameterInfo, ServerInfo, RefResolutionOptions, LoadOptions, GenerateOptions, FormatResolver, JsonSchema, NamingStrategy, OperationWithContext, OpenAPIDocument, OpenAPIVersion, HTTPMethod, ParameterLocation, AuthType, OperationObject, ParameterObject, RequestBodyObject, ResponseObject, ResponsesObject, MediaTypeObject, HeaderObject, ExampleObject, PathItemObject, PathsObject, ServerObject, SecuritySchemeObject, ReferenceObject, TagObject, ExternalDocumentationObject, ServerVariableObject, EncodingObject, SecurityRequirementObject, SchemaObject, ValidationResult, ValidationErrorDetail, ValidationWarning, } from './types';
10
12
  export type { SecurityContext, ResolvedSecurity, DigestAuthCredentials, ClientCertificate, AWSCredentials, SignatureData, } from './security-resolver';
11
13
  export { isReferenceObject, toJsonSchema } from './types';
package/index.js CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ BLOCKED_HOSTNAMES: () => BLOCKED_HOSTNAMES,
33
34
  BUILTIN_FORMAT_RESOLVERS: () => BUILTIN_FORMAT_RESOLVERS,
34
35
  GenerationError: () => GenerationError,
35
36
  LoadError: () => LoadError,
@@ -41,19 +42,25 @@ __export(index_exports, {
41
42
  SchemaBuilder: () => SchemaBuilder,
42
43
  SchemaError: () => SchemaError,
43
44
  SecurityResolver: () => SecurityResolver,
45
+ SsrfError: () => SsrfError,
44
46
  ValidationError: () => ValidationError,
45
47
  Validator: () => Validator,
48
+ assertUrlSafe: () => assertUrlSafe,
46
49
  createSecurityContext: () => createSecurityContext,
50
+ decodeIpv4MappedIpv6: () => decodeIpv4MappedIpv6,
51
+ defaultLookup: () => defaultLookup,
52
+ isBlockedAddress: () => isBlockedAddress,
53
+ isBlockedHostname: () => isBlockedHostname,
47
54
  isReferenceObject: () => isReferenceObject,
55
+ normalizeSsrfOptions: () => normalizeSsrfOptions,
48
56
  resolveSchemaFormats: () => resolveSchemaFormats,
57
+ safeFetch: () => safeFetch,
49
58
  toJsonSchema: () => toJsonSchema
50
59
  });
51
60
  module.exports = __toCommonJS(index_exports);
52
61
 
53
62
  // src/generator.ts
54
63
  var yaml = __toESM(require("yaml"));
55
- var path = __toESM(require("path"));
56
- var fs = __toESM(require("fs/promises"));
57
64
 
58
65
  // src/types.ts
59
66
  function isReferenceObject(obj) {
@@ -588,12 +595,12 @@ var Validator = class {
588
595
  * Validate paths
589
596
  */
590
597
  validatePaths(paths, errors, warnings) {
591
- for (const [path2, pathItem] of Object.entries(paths)) {
598
+ for (const [path, pathItem] of Object.entries(paths)) {
592
599
  if (!pathItem) continue;
593
- if (!path2.startsWith("/")) {
600
+ if (!path.startsWith("/")) {
594
601
  errors.push({
595
- message: `Path must start with '/': ${path2}`,
596
- path: `/paths/${path2}`,
602
+ message: `Path must start with '/': ${path}`,
603
+ path: `/paths/${path}`,
597
604
  code: "INVALID_PATH_FORMAT"
598
605
  });
599
606
  }
@@ -603,13 +610,13 @@ var Validator = class {
603
610
  const operation = pathItem[method];
604
611
  if (operation) {
605
612
  hasOperations = true;
606
- this.validateOperation(operation, path2, method, errors, warnings);
613
+ this.validateOperation(operation, path, method, errors, warnings);
607
614
  }
608
615
  }
609
616
  if (!hasOperations && !pathItem.$ref) {
610
617
  warnings.push({
611
- message: `Path has no operations: ${path2}`,
612
- path: `/paths/${path2}`,
618
+ message: `Path has no operations: ${path}`,
619
+ path: `/paths/${path}`,
613
620
  code: "NO_OPERATIONS"
614
621
  });
615
622
  }
@@ -618,33 +625,33 @@ var Validator = class {
618
625
  /**
619
626
  * Validate an operation
620
627
  */
621
- validateOperation(operation, path2, method, errors, warnings) {
622
- const basePath = `/paths/${path2}/${method}`;
628
+ validateOperation(operation, path, method, errors, warnings) {
629
+ const basePath = `/paths/${path}/${method}`;
623
630
  if (!operation.operationId) {
624
631
  warnings.push({
625
- message: `Operation missing operationId: ${method.toUpperCase()} ${path2}`,
632
+ message: `Operation missing operationId: ${method.toUpperCase()} ${path}`,
626
633
  path: `${basePath}/operationId`,
627
634
  code: "NO_OPERATION_ID"
628
635
  });
629
636
  }
630
637
  if (!operation.responses || Object.keys(operation.responses).length === 0) {
631
638
  errors.push({
632
- message: `Operation missing responses: ${method.toUpperCase()} ${path2}`,
639
+ message: `Operation missing responses: ${method.toUpperCase()} ${path}`,
633
640
  path: `${basePath}/responses`,
634
641
  code: "NO_RESPONSES"
635
642
  });
636
643
  }
637
644
  if (operation.parameters) {
638
- this.validateParameters(operation.parameters, path2, method, errors, warnings);
645
+ this.validateParameters(operation.parameters, path, method, errors, warnings);
639
646
  }
640
- const pathParams = path2.match(/\{([^}]+)\}/g)?.map((p) => p.slice(1, -1)) ?? [];
647
+ const pathParams = path.match(/\{([^}]+)\}/g)?.map((p) => p.slice(1, -1)) ?? [];
641
648
  const definedPathParams = new Set(
642
649
  operation.parameters?.filter((p) => p.in === "path").map((p) => p.name) ?? []
643
650
  );
644
651
  for (const param of pathParams) {
645
652
  if (!definedPathParams.has(param)) {
646
653
  errors.push({
647
- message: `Path parameter '${param}' not defined in parameters: ${method.toUpperCase()} ${path2}`,
654
+ message: `Path parameter '${param}' not defined in parameters: ${method.toUpperCase()} ${path}`,
648
655
  path: `${basePath}/parameters`,
649
656
  code: "MISSING_PATH_PARAMETER"
650
657
  });
@@ -654,8 +661,8 @@ var Validator = class {
654
661
  /**
655
662
  * Validate parameters
656
663
  */
657
- validateParameters(parameters, path2, method, errors, warnings) {
658
- const basePath = `/paths/${path2}/${method}/parameters`;
664
+ validateParameters(parameters, path, method, errors, warnings) {
665
+ const basePath = `/paths/${path}/${method}/parameters`;
659
666
  for (let i = 0; i < parameters.length; i++) {
660
667
  const param = parameters[i];
661
668
  const paramPath = `${basePath}/${i}`;
@@ -714,6 +721,11 @@ var LoadError = class extends OpenAPIToolError {
714
721
  super(message, context);
715
722
  }
716
723
  };
724
+ var SsrfError = class extends LoadError {
725
+ constructor(message, context) {
726
+ super(message, context);
727
+ }
728
+ };
717
729
  var ParseError = class extends OpenAPIToolError {
718
730
  constructor(message, context) {
719
731
  super(message, context);
@@ -846,6 +858,178 @@ function resolveSchemaFormats(schema, resolvers) {
846
858
  return result;
847
859
  }
848
860
 
861
+ // src/ssrf.ts
862
+ var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
863
+ "localhost",
864
+ "localhost.localdomain",
865
+ "ip6-localhost",
866
+ "ip6-loopback",
867
+ "metadata",
868
+ "metadata.google.internal",
869
+ "metadata.goog"
870
+ ]);
871
+ function normalizeSsrfOptions(refResolution) {
872
+ return {
873
+ allowedHosts: refResolution?.allowedHosts ?? [],
874
+ blockedHosts: refResolution?.blockedHosts ?? [],
875
+ allowInternalIPs: refResolution?.allowInternalIPs ?? false
876
+ };
877
+ }
878
+ function decodeIpv4MappedIpv6(hostname) {
879
+ let h = hostname;
880
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
881
+ const lower = h.toLowerCase();
882
+ const marker = lower.lastIndexOf("::ffff:");
883
+ if (marker === -1) return null;
884
+ const tail = lower.slice(marker + "::ffff:".length);
885
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
886
+ const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
887
+ if (hex) {
888
+ const hi = parseInt(hex[1], 16);
889
+ const lo = parseInt(hex[2], 16);
890
+ if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
891
+ return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
892
+ }
893
+ return null;
894
+ }
895
+ function parseIpv4(host) {
896
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
897
+ if (!m) return null;
898
+ const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
899
+ if (octets.some((n) => n > 255)) return null;
900
+ return octets;
901
+ }
902
+ function isBlockedIpv4(octets) {
903
+ const [a, b, c] = octets;
904
+ if (a === 0) return true;
905
+ if (a === 10) return true;
906
+ if (a === 127) return true;
907
+ if (a === 100 && b >= 64 && b <= 127) return true;
908
+ if (a === 169 && b === 254) return true;
909
+ if (a === 172 && b >= 16 && b <= 31) return true;
910
+ if (a === 192 && b === 0 && c === 0) return true;
911
+ if (a === 192 && b === 168) return true;
912
+ if (a === 198 && (b === 18 || b === 19)) return true;
913
+ if (a >= 224) return true;
914
+ return false;
915
+ }
916
+ function isBlockedIpv6(host) {
917
+ let h = host;
918
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
919
+ const zone = h.indexOf("%");
920
+ if (zone !== -1) h = h.slice(0, zone);
921
+ const lower = h.toLowerCase();
922
+ if (lower === "::" || lower === "::0") return true;
923
+ if (lower === "::1") return true;
924
+ if (/^f[cd]/.test(lower)) return true;
925
+ if (/^fe[89a-f]/.test(lower)) return true;
926
+ if (/^ff/.test(lower)) return true;
927
+ return false;
928
+ }
929
+ function isBlockedAddress(host) {
930
+ let h = host;
931
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
932
+ const mapped = decodeIpv4MappedIpv6(host);
933
+ if (mapped) {
934
+ const o = parseIpv4(mapped);
935
+ if (o) return isBlockedIpv4(o);
936
+ }
937
+ const v4 = parseIpv4(h);
938
+ if (v4) return isBlockedIpv4(v4);
939
+ if (h.includes(":")) return isBlockedIpv6(h);
940
+ return false;
941
+ }
942
+ function isIpLiteral(hostname) {
943
+ if (hostname.startsWith("[") && hostname.endsWith("]")) return true;
944
+ return parseIpv4(hostname) !== null;
945
+ }
946
+ function isBlockedHostname(hostname, ssrf) {
947
+ if (ssrf.allowInternalIPs) {
948
+ return ssrf.blockedHosts.includes(hostname);
949
+ }
950
+ if (ssrf.blockedHosts.includes(hostname)) return true;
951
+ const lower = hostname.toLowerCase();
952
+ const stripped = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
953
+ if (BLOCKED_HOSTNAMES.has(lower) || BLOCKED_HOSTNAMES.has(stripped)) return true;
954
+ return isBlockedAddress(hostname);
955
+ }
956
+ var defaultLookup = async (hostname) => {
957
+ const dns = await import("node:dns");
958
+ return dns.promises.lookup(hostname, { all: true });
959
+ };
960
+ async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
961
+ let parsed;
962
+ try {
963
+ parsed = new URL(url);
964
+ } catch {
965
+ throw new SsrfError(`Invalid spec URL: ${url}`, { url });
966
+ }
967
+ const protocol = parsed.protocol.replace(/:$/, "");
968
+ if (protocol !== "http" && protocol !== "https") {
969
+ throw new SsrfError(`Protocol "${protocol}" is not allowed for network spec loading (only http/https)`, { url });
970
+ }
971
+ const hostname = parsed.hostname;
972
+ if (ssrf.allowedHosts.length > 0 && !ssrf.allowedHosts.includes(hostname)) {
973
+ throw new SsrfError(`Host "${hostname}" is not in the allowed-hosts list`, { url });
974
+ }
975
+ if (ssrf.allowInternalIPs) {
976
+ if (ssrf.blockedHosts.includes(hostname)) {
977
+ throw new SsrfError(`Host "${hostname}" is blocked`, { url });
978
+ }
979
+ return;
980
+ }
981
+ if (isBlockedHostname(hostname, ssrf)) {
982
+ throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
983
+ }
984
+ if (!isIpLiteral(hostname)) {
985
+ let addresses;
986
+ try {
987
+ addresses = await lookup(hostname);
988
+ } catch {
989
+ return;
990
+ }
991
+ for (const { address } of addresses) {
992
+ if (isBlockedAddress(address)) {
993
+ throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
994
+ }
995
+ }
996
+ }
997
+ }
998
+ async function safeFetch(url, opts) {
999
+ const { headers, timeoutMs = 3e4, followRedirects = true, maxRedirects = 5, ssrf, lookup } = opts;
1000
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
1001
+ if (typeof fetchImpl !== "function") {
1002
+ throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
1003
+ }
1004
+ let current = url;
1005
+ for (let hop = 0; hop <= maxRedirects; hop++) {
1006
+ await assertUrlSafe(current, ssrf, lookup);
1007
+ const controller = new AbortController();
1008
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1009
+ let response;
1010
+ try {
1011
+ response = await fetchImpl(current, {
1012
+ headers,
1013
+ signal: controller.signal,
1014
+ redirect: "manual"
1015
+ });
1016
+ } finally {
1017
+ clearTimeout(timer);
1018
+ }
1019
+ const status = typeof response.status === "number" ? response.status : 0;
1020
+ const isRedirect = status >= 300 && status < 400 && status !== 304;
1021
+ if (!isRedirect || !followRedirects) {
1022
+ return response;
1023
+ }
1024
+ const location = response.headers?.get?.("location") ?? void 0;
1025
+ if (!location) {
1026
+ return response;
1027
+ }
1028
+ current = new URL(location, current).toString();
1029
+ }
1030
+ throw new SsrfError(`Too many redirects while loading OpenAPI spec (max ${maxRedirects})`, { url });
1031
+ }
1032
+
849
1033
  // src/generator.ts
850
1034
  var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
851
1035
  document;
@@ -871,14 +1055,12 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
871
1055
  */
872
1056
  static async fromURL(url, options = {}) {
873
1057
  try {
874
- const controller = new AbortController();
875
- const timeout = setTimeout(() => controller.abort(), options.timeout ?? 3e4);
876
- const response = await fetch(url, {
1058
+ const response = await safeFetch(url, {
877
1059
  headers: options.headers,
878
- signal: controller.signal,
879
- redirect: options.followRedirects ?? true ? "follow" : "manual"
1060
+ timeoutMs: options.timeout ?? 3e4,
1061
+ followRedirects: options.followRedirects ?? true,
1062
+ ssrf: normalizeSsrfOptions(options.refResolution)
880
1063
  });
881
- clearTimeout(timeout);
882
1064
  if (!response.ok) {
883
1065
  throw new LoadError(`Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, {
884
1066
  url,
@@ -910,6 +1092,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
910
1092
  */
911
1093
  static async fromFile(filePath, options = {}) {
912
1094
  try {
1095
+ const [path, fs] = await Promise.all([import("path"), import("fs/promises")]);
913
1096
  const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
914
1097
  const content = await fs.readFile(absolutePath, "utf-8");
915
1098
  const ext = path.extname(filePath).toLowerCase();
@@ -968,57 +1151,11 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
968
1151
  const validator = new Validator();
969
1152
  return validator.validate(this.document);
970
1153
  }
971
- /**
972
- * Hostnames and IP patterns that are blocked by default to prevent SSRF.
973
- * Covers RFC 1918/6598 private ranges, link-local, loopback, and cloud metadata endpoints.
974
- */
975
- static BLOCKED_HOSTNAME_PATTERNS = [
976
- "localhost",
977
- "metadata.google.internal",
978
- /^127\.\d+\.\d+\.\d+$/,
979
- // 127.0.0.0/8 loopback
980
- /^10\.\d+\.\d+\.\d+$/,
981
- // 10.0.0.0/8 private
982
- /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
983
- // 172.16.0.0/12 private
984
- /^192\.168\.\d+\.\d+$/,
985
- // 192.168.0.0/16 private
986
- /^169\.254\.\d+\.\d+$/,
987
- // 169.254.0.0/16 link-local / cloud metadata
988
- /^0\.0\.0\.0$/,
989
- // unspecified
990
- "::1",
991
- // IPv6 loopback
992
- /^fd[0-9a-f]{2}:/i,
993
- // fd00::/8 IPv6 ULA
994
- /^fe80:/i,
995
- // fe80::/10 IPv6 link-local
996
- /^\[::1\]$/,
997
- // bracketed IPv6 loopback
998
- /^\[fd[0-9a-f]{2}:/i,
999
- // bracketed IPv6 ULA
1000
- /^\[fe80:/i
1001
- // bracketed IPv6 link-local
1002
- ];
1003
- /**
1004
- * Check whether a hostname is blocked (internal/private IP or explicit blocklist).
1005
- */
1006
- isBlockedHost(hostname, refOpts) {
1007
- if (refOpts.allowInternalIPs) {
1008
- return refOpts.blockedHosts.includes(hostname);
1009
- }
1010
- if (refOpts.blockedHosts.includes(hostname)) {
1011
- return true;
1012
- }
1013
- for (const pattern of _OpenAPIToolGenerator.BLOCKED_HOSTNAME_PATTERNS) {
1014
- if (typeof pattern === "string") {
1015
- if (hostname === pattern) return true;
1016
- } else {
1017
- if (pattern.test(hostname)) return true;
1018
- }
1019
- }
1020
- return false;
1021
- }
1154
+ // NOTE: internal/private-address blocking + IPv4-mapped-IPv6 decoding now live
1155
+ // in `ssrf.ts` (`isBlockedHostname` / `isBlockedAddress` / `decodeIpv4MappedIpv6`),
1156
+ // shared by the spec-URL fetch (`fromURL`) and the `$ref` resolver below, and
1157
+ // augmented there with DNS resolution (closing the DNS-name-to-internal bypass)
1158
+ // and per-hop redirect re-validation (`safeFetch`).
1022
1159
  /**
1023
1160
  * Build $RefParser options based on refResolution configuration.
1024
1161
  * Defaults: allow http/https, block file://, block internal IPs.
@@ -1044,6 +1181,18 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1044
1181
  const hasHostAllowlist = refOpts.allowedHosts.length > 0;
1045
1182
  const hostAllowSet = new Set(refOpts.allowedHosts);
1046
1183
  resolveConfig["http"] = {
1184
+ // SECURITY: never auto-follow HTTP redirects when resolving external
1185
+ // `$ref`s. `canRead` validates only the INITIAL URL; the resolver's
1186
+ // default redirect-following (up to 5 hops) re-fetches the `Location`
1187
+ // target WITHOUT re-invoking `canRead`, so an allowlisted host could
1188
+ // 302 → `http://169.254.169.254/...` and smuggle a blocked target past
1189
+ // the allow/deny lists. `redirects: 0` refuses the first redirect, and
1190
+ // our custom `read` (below) additionally refuses redirects itself.
1191
+ redirects: 0,
1192
+ // Synchronous gate: protocol, host allow-list, and literal/known
1193
+ // internal hosts. DNS names that *resolve* to internal addresses pass
1194
+ // here (canRead cannot be async) and are caught in `read` via DNS
1195
+ // resolution — closing the `127.0.0.1.nip.io` bypass for `$ref`s too.
1047
1196
  canRead: (file) => {
1048
1197
  try {
1049
1198
  const parsed = new URL(file.url);
@@ -1054,13 +1203,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1054
1203
  if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) {
1055
1204
  return false;
1056
1205
  }
1057
- if (this.isBlockedHost(parsed.hostname, refOpts)) {
1206
+ if (isBlockedHostname(parsed.hostname, refOpts)) {
1058
1207
  return false;
1059
1208
  }
1060
1209
  return true;
1061
1210
  } catch {
1062
1211
  return false;
1063
1212
  }
1213
+ },
1214
+ // SSRF-safe fetch: resolves DNS and rejects names that map to internal
1215
+ // addresses, and refuses redirects. NOTE: deliberately does NOT forward
1216
+ // `this.options.headers` (the spec-load credentials) to third-party
1217
+ // `$ref` hosts — that would leak the spec's auth token cross-origin.
1218
+ read: async (file) => {
1219
+ const response = await safeFetch(file.url, {
1220
+ timeoutMs: this.options.timeout,
1221
+ followRedirects: false,
1222
+ ssrf: refOpts
1223
+ });
1224
+ if (!response.ok) {
1225
+ throw new LoadError(
1226
+ `Failed to resolve external $ref "${file.url}": ${response.status} ${response.statusText}`,
1227
+ { url: file.url, status: response.status }
1228
+ );
1229
+ }
1230
+ return response.text();
1064
1231
  }
1065
1232
  };
1066
1233
  } else {
@@ -1068,23 +1235,75 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1068
1235
  }
1069
1236
  return { resolve: resolveConfig };
1070
1237
  }
1238
+ /**
1239
+ * Does the document contain any EXTERNAL `$ref` (a ref that is not a local
1240
+ * JSON-pointer beginning with `#`)? Only external refs require the full
1241
+ * `$RefParser` (file/http resolvers, which pull Node builtins). A document
1242
+ * with only internal refs can be dereferenced with the runtime-agnostic
1243
+ * resolver below — so it works on V8 isolates (Cloudflare Workers) too.
1244
+ */
1245
+ static hasExternalRefs(node, seen = /* @__PURE__ */ new Set()) {
1246
+ if (node === null || typeof node !== "object") return false;
1247
+ if (seen.has(node)) return false;
1248
+ seen.add(node);
1249
+ if (Array.isArray(node)) return node.some((n) => _OpenAPIToolGenerator.hasExternalRefs(n, seen));
1250
+ const ref = node.$ref;
1251
+ if (typeof ref === "string" && !ref.startsWith("#")) return true;
1252
+ return Object.values(node).some(
1253
+ (v) => _OpenAPIToolGenerator.hasExternalRefs(v, seen)
1254
+ );
1255
+ }
1256
+ /**
1257
+ * Dereference local (`#/...`) `$ref`s without `$RefParser` — pure, dependency-
1258
+ * free, runtime-agnostic. A pointer cache makes circular schemas resolve to a
1259
+ * shared reference instead of recursing forever (same contract as `$RefParser`).
1260
+ */
1261
+ static dereferenceInternal(root) {
1262
+ const cache = /* @__PURE__ */ new Map();
1263
+ const resolvePointer = (ptr) => {
1264
+ const parts = ptr.replace(/^#\/?/, "").split("/").filter((p) => p.length > 0).map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
1265
+ let cur = root;
1266
+ for (const p of parts) cur = cur?.[p];
1267
+ return cur;
1268
+ };
1269
+ const walk = (node) => {
1270
+ if (node === null || typeof node !== "object") return node;
1271
+ if (Array.isArray(node)) return node.map(walk);
1272
+ const ref = node.$ref;
1273
+ if (typeof ref === "string" && ref.startsWith("#")) {
1274
+ const cached = cache.get(ref);
1275
+ if (cached !== void 0) return cached;
1276
+ const placeholder = {};
1277
+ cache.set(ref, placeholder);
1278
+ const resolved = walk(resolvePointer(ref));
1279
+ if (resolved && typeof resolved === "object") Object.assign(placeholder, resolved);
1280
+ return placeholder;
1281
+ }
1282
+ const out = {};
1283
+ for (const [k, v] of Object.entries(node)) out[k] = walk(v);
1284
+ return out;
1285
+ };
1286
+ return walk(root);
1287
+ }
1071
1288
  /**
1072
1289
  * Initialize the generator (dereference if needed, then validate)
1073
1290
  */
1074
1291
  async initialize() {
1075
1292
  if (this.options.dereference && !this.dereferencedDocument) {
1076
- try {
1077
- const { default: $RefParser } = await import("@apidevtools/json-schema-ref-parser");
1078
- const refParserOptions = this.buildRefParserOptions();
1079
- this.dereferencedDocument = await $RefParser.dereference(
1080
- JSON.parse(JSON.stringify(this.document)),
1081
- refParserOptions
1082
- );
1083
- } catch (error) {
1084
- const errorMessage = error instanceof Error ? error.message : String(error);
1085
- throw new ParseError(`Failed to dereference OpenAPI document: ${errorMessage}`, {
1086
- originalError: error
1087
- });
1293
+ const cloned = JSON.parse(JSON.stringify(this.document));
1294
+ if (!_OpenAPIToolGenerator.hasExternalRefs(cloned)) {
1295
+ this.dereferencedDocument = _OpenAPIToolGenerator.dereferenceInternal(cloned);
1296
+ } else {
1297
+ try {
1298
+ const { default: $RefParser } = await import("@apidevtools/json-schema-ref-parser");
1299
+ const refParserOptions = this.buildRefParserOptions();
1300
+ this.dereferencedDocument = await $RefParser.dereference(cloned, refParserOptions);
1301
+ } catch (error) {
1302
+ const errorMessage = error instanceof Error ? error.message : String(error);
1303
+ throw new ParseError(`Failed to dereference OpenAPI document: ${errorMessage}`, {
1304
+ originalError: error
1305
+ });
1306
+ }
1088
1307
  }
1089
1308
  }
1090
1309
  if (this.options.validate) {
@@ -1182,7 +1401,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1182
1401
  /**
1183
1402
  * Check if an operation should be included
1184
1403
  */
1185
- shouldIncludeOperation(operation, path2, method, options) {
1404
+ shouldIncludeOperation(operation, path, method, options) {
1186
1405
  if (operation.deprecated && !options.includeDeprecated) {
1187
1406
  return false;
1188
1407
  }
@@ -1199,7 +1418,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1199
1418
  if (options.filterFn) {
1200
1419
  return options.filterFn({
1201
1420
  ...operation,
1202
- path: path2,
1421
+ path,
1203
1422
  method
1204
1423
  });
1205
1424
  }
@@ -1208,22 +1427,22 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1208
1427
  /**
1209
1428
  * Generate a tool name
1210
1429
  */
1211
- generateToolName(path2, method, operationId, options = {}) {
1430
+ generateToolName(path, method, operationId, options = {}) {
1212
1431
  if (options.namingStrategy?.toolNameGenerator) {
1213
- return options.namingStrategy.toolNameGenerator(path2, method, operationId);
1432
+ return options.namingStrategy.toolNameGenerator(path, method, operationId);
1214
1433
  }
1215
1434
  if (operationId) {
1216
1435
  return operationId;
1217
1436
  }
1218
- const sanitized = path2.replace(/\{([^}]+)\}/g, "By_$1").replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
1437
+ const sanitized = path.replace(/\{([^}]+)\}/g, "By_$1").replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
1219
1438
  return `${method}_${sanitized}`;
1220
1439
  }
1221
1440
  /**
1222
1441
  * Extract metadata from operation
1223
1442
  */
1224
- extractMetadata(path2, method, operation, document, outputSchema) {
1443
+ extractMetadata(path, method, operation, document, outputSchema) {
1225
1444
  const metadata = {
1226
- path: path2,
1445
+ path,
1227
1446
  method,
1228
1447
  operationId: operation.operationId,
1229
1448
  operationSummary: operation.summary,
@@ -1703,16 +1922,18 @@ var SecurityResolver = class {
1703
1922
  resolveDigestAuth(context) {
1704
1923
  const digest = context.digest;
1705
1924
  if (!digest) return void 0;
1925
+ const quoted = (v) => String(v).replace(/[\r\n]/g, "").replace(/"/g, '\\"');
1926
+ const token = (v) => String(v).replace(/[\r\n",]/g, "");
1706
1927
  const parts = [
1707
- `username="${digest.username}"`,
1708
- digest.realm ? `realm="${digest.realm}"` : "",
1709
- digest.nonce ? `nonce="${digest.nonce}"` : "",
1710
- digest.uri ? `uri="${digest.uri}"` : "",
1711
- digest.response ? `response="${digest.response}"` : "",
1712
- digest.opaque ? `opaque="${digest.opaque}"` : "",
1713
- digest.qop ? `qop=${digest.qop}` : "",
1714
- digest.nc ? `nc=${digest.nc}` : "",
1715
- digest.cnonce ? `cnonce="${digest.cnonce}"` : ""
1928
+ `username="${quoted(digest.username)}"`,
1929
+ digest.realm ? `realm="${quoted(digest.realm)}"` : "",
1930
+ digest.nonce ? `nonce="${quoted(digest.nonce)}"` : "",
1931
+ digest.uri ? `uri="${quoted(digest.uri)}"` : "",
1932
+ digest.response ? `response="${quoted(digest.response)}"` : "",
1933
+ digest.opaque ? `opaque="${quoted(digest.opaque)}"` : "",
1934
+ digest.qop ? `qop=${token(digest.qop)}` : "",
1935
+ digest.nc ? `nc=${token(digest.nc)}` : "",
1936
+ digest.cnonce ? `cnonce="${quoted(digest.cnonce)}"` : ""
1716
1937
  ].filter(Boolean);
1717
1938
  return `Digest ${parts.join(", ")}`;
1718
1939
  }
@@ -1819,6 +2040,7 @@ function createSecurityContext(auth) {
1819
2040
  }
1820
2041
  // Annotate the CommonJS export names for ESM import in node:
1821
2042
  0 && (module.exports = {
2043
+ BLOCKED_HOSTNAMES,
1822
2044
  BUILTIN_FORMAT_RESOLVERS,
1823
2045
  GenerationError,
1824
2046
  LoadError,
@@ -1830,10 +2052,18 @@ function createSecurityContext(auth) {
1830
2052
  SchemaBuilder,
1831
2053
  SchemaError,
1832
2054
  SecurityResolver,
2055
+ SsrfError,
1833
2056
  ValidationError,
1834
2057
  Validator,
2058
+ assertUrlSafe,
1835
2059
  createSecurityContext,
2060
+ decodeIpv4MappedIpv6,
2061
+ defaultLookup,
2062
+ isBlockedAddress,
2063
+ isBlockedHostname,
1836
2064
  isReferenceObject,
2065
+ normalizeSsrfOptions,
1837
2066
  resolveSchemaFormats,
2067
+ safeFetch,
1838
2068
  toJsonSchema
1839
2069
  });
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "mcp-from-openapi",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Production-ready library for converting OpenAPI specifications into MCP tool definitions",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "license": "Apache-2.0",
7
+ "packageManager": "yarn@4.14.1",
7
8
  "keywords": [
8
9
  "mcp",
9
10
  "model-context-protocol",