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/errors.d.ts CHANGED
@@ -11,6 +11,19 @@ export declare class OpenAPIToolError extends Error {
11
11
  export declare class LoadError extends OpenAPIToolError {
12
12
  constructor(message: string, context?: Record<string, any>);
13
13
  }
14
+ /**
15
+ * Error thrown when a spec URL or external `$ref` target is refused by the
16
+ * SSRF guard: it targets — or resolves to — a loopback / private / link-local /
17
+ * cloud-metadata address, uses a disallowed protocol, or is outside the
18
+ * configured allow-list.
19
+ *
20
+ * Subclasses {@link LoadError} so it propagates cleanly out of `fromURL` (whose
21
+ * catch re-throws `LoadError`) and so existing `instanceof LoadError` handling
22
+ * keeps working, while still being independently catchable.
23
+ */
24
+ export declare class SsrfError extends LoadError {
25
+ constructor(message: string, context?: Record<string, any>);
26
+ }
14
27
  /**
15
28
  * Error thrown when parsing an OpenAPI specification fails
16
29
  */
package/esm/index.mjs CHANGED
@@ -1,7 +1,5 @@
1
1
  // src/generator.ts
2
2
  import * as yaml from "yaml";
3
- import * as path from "path";
4
- import * as fs from "fs/promises";
5
3
 
6
4
  // src/types.ts
7
5
  function isReferenceObject(obj) {
@@ -536,12 +534,12 @@ var Validator = class {
536
534
  * Validate paths
537
535
  */
538
536
  validatePaths(paths, errors, warnings) {
539
- for (const [path2, pathItem] of Object.entries(paths)) {
537
+ for (const [path, pathItem] of Object.entries(paths)) {
540
538
  if (!pathItem) continue;
541
- if (!path2.startsWith("/")) {
539
+ if (!path.startsWith("/")) {
542
540
  errors.push({
543
- message: `Path must start with '/': ${path2}`,
544
- path: `/paths/${path2}`,
541
+ message: `Path must start with '/': ${path}`,
542
+ path: `/paths/${path}`,
545
543
  code: "INVALID_PATH_FORMAT"
546
544
  });
547
545
  }
@@ -551,13 +549,13 @@ var Validator = class {
551
549
  const operation = pathItem[method];
552
550
  if (operation) {
553
551
  hasOperations = true;
554
- this.validateOperation(operation, path2, method, errors, warnings);
552
+ this.validateOperation(operation, path, method, errors, warnings);
555
553
  }
556
554
  }
557
555
  if (!hasOperations && !pathItem.$ref) {
558
556
  warnings.push({
559
- message: `Path has no operations: ${path2}`,
560
- path: `/paths/${path2}`,
557
+ message: `Path has no operations: ${path}`,
558
+ path: `/paths/${path}`,
561
559
  code: "NO_OPERATIONS"
562
560
  });
563
561
  }
@@ -566,33 +564,33 @@ var Validator = class {
566
564
  /**
567
565
  * Validate an operation
568
566
  */
569
- validateOperation(operation, path2, method, errors, warnings) {
570
- const basePath = `/paths/${path2}/${method}`;
567
+ validateOperation(operation, path, method, errors, warnings) {
568
+ const basePath = `/paths/${path}/${method}`;
571
569
  if (!operation.operationId) {
572
570
  warnings.push({
573
- message: `Operation missing operationId: ${method.toUpperCase()} ${path2}`,
571
+ message: `Operation missing operationId: ${method.toUpperCase()} ${path}`,
574
572
  path: `${basePath}/operationId`,
575
573
  code: "NO_OPERATION_ID"
576
574
  });
577
575
  }
578
576
  if (!operation.responses || Object.keys(operation.responses).length === 0) {
579
577
  errors.push({
580
- message: `Operation missing responses: ${method.toUpperCase()} ${path2}`,
578
+ message: `Operation missing responses: ${method.toUpperCase()} ${path}`,
581
579
  path: `${basePath}/responses`,
582
580
  code: "NO_RESPONSES"
583
581
  });
584
582
  }
585
583
  if (operation.parameters) {
586
- this.validateParameters(operation.parameters, path2, method, errors, warnings);
584
+ this.validateParameters(operation.parameters, path, method, errors, warnings);
587
585
  }
588
- const pathParams = path2.match(/\{([^}]+)\}/g)?.map((p) => p.slice(1, -1)) ?? [];
586
+ const pathParams = path.match(/\{([^}]+)\}/g)?.map((p) => p.slice(1, -1)) ?? [];
589
587
  const definedPathParams = new Set(
590
588
  operation.parameters?.filter((p) => p.in === "path").map((p) => p.name) ?? []
591
589
  );
592
590
  for (const param of pathParams) {
593
591
  if (!definedPathParams.has(param)) {
594
592
  errors.push({
595
- message: `Path parameter '${param}' not defined in parameters: ${method.toUpperCase()} ${path2}`,
593
+ message: `Path parameter '${param}' not defined in parameters: ${method.toUpperCase()} ${path}`,
596
594
  path: `${basePath}/parameters`,
597
595
  code: "MISSING_PATH_PARAMETER"
598
596
  });
@@ -602,8 +600,8 @@ var Validator = class {
602
600
  /**
603
601
  * Validate parameters
604
602
  */
605
- validateParameters(parameters, path2, method, errors, warnings) {
606
- const basePath = `/paths/${path2}/${method}/parameters`;
603
+ validateParameters(parameters, path, method, errors, warnings) {
604
+ const basePath = `/paths/${path}/${method}/parameters`;
607
605
  for (let i = 0; i < parameters.length; i++) {
608
606
  const param = parameters[i];
609
607
  const paramPath = `${basePath}/${i}`;
@@ -662,6 +660,11 @@ var LoadError = class extends OpenAPIToolError {
662
660
  super(message, context);
663
661
  }
664
662
  };
663
+ var SsrfError = class extends LoadError {
664
+ constructor(message, context) {
665
+ super(message, context);
666
+ }
667
+ };
665
668
  var ParseError = class extends OpenAPIToolError {
666
669
  constructor(message, context) {
667
670
  super(message, context);
@@ -794,6 +797,178 @@ function resolveSchemaFormats(schema, resolvers) {
794
797
  return result;
795
798
  }
796
799
 
800
+ // src/ssrf.ts
801
+ var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
802
+ "localhost",
803
+ "localhost.localdomain",
804
+ "ip6-localhost",
805
+ "ip6-loopback",
806
+ "metadata",
807
+ "metadata.google.internal",
808
+ "metadata.goog"
809
+ ]);
810
+ function normalizeSsrfOptions(refResolution) {
811
+ return {
812
+ allowedHosts: refResolution?.allowedHosts ?? [],
813
+ blockedHosts: refResolution?.blockedHosts ?? [],
814
+ allowInternalIPs: refResolution?.allowInternalIPs ?? false
815
+ };
816
+ }
817
+ function decodeIpv4MappedIpv6(hostname) {
818
+ let h = hostname;
819
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
820
+ const lower = h.toLowerCase();
821
+ const marker = lower.lastIndexOf("::ffff:");
822
+ if (marker === -1) return null;
823
+ const tail = lower.slice(marker + "::ffff:".length);
824
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
825
+ const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
826
+ if (hex) {
827
+ const hi = parseInt(hex[1], 16);
828
+ const lo = parseInt(hex[2], 16);
829
+ if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
830
+ return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
831
+ }
832
+ return null;
833
+ }
834
+ function parseIpv4(host) {
835
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
836
+ if (!m) return null;
837
+ const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
838
+ if (octets.some((n) => n > 255)) return null;
839
+ return octets;
840
+ }
841
+ function isBlockedIpv4(octets) {
842
+ const [a, b, c] = octets;
843
+ if (a === 0) return true;
844
+ if (a === 10) return true;
845
+ if (a === 127) return true;
846
+ if (a === 100 && b >= 64 && b <= 127) return true;
847
+ if (a === 169 && b === 254) return true;
848
+ if (a === 172 && b >= 16 && b <= 31) return true;
849
+ if (a === 192 && b === 0 && c === 0) return true;
850
+ if (a === 192 && b === 168) return true;
851
+ if (a === 198 && (b === 18 || b === 19)) return true;
852
+ if (a >= 224) return true;
853
+ return false;
854
+ }
855
+ function isBlockedIpv6(host) {
856
+ let h = host;
857
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
858
+ const zone = h.indexOf("%");
859
+ if (zone !== -1) h = h.slice(0, zone);
860
+ const lower = h.toLowerCase();
861
+ if (lower === "::" || lower === "::0") return true;
862
+ if (lower === "::1") return true;
863
+ if (/^f[cd]/.test(lower)) return true;
864
+ if (/^fe[89a-f]/.test(lower)) return true;
865
+ if (/^ff/.test(lower)) return true;
866
+ return false;
867
+ }
868
+ function isBlockedAddress(host) {
869
+ let h = host;
870
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
871
+ const mapped = decodeIpv4MappedIpv6(host);
872
+ if (mapped) {
873
+ const o = parseIpv4(mapped);
874
+ if (o) return isBlockedIpv4(o);
875
+ }
876
+ const v4 = parseIpv4(h);
877
+ if (v4) return isBlockedIpv4(v4);
878
+ if (h.includes(":")) return isBlockedIpv6(h);
879
+ return false;
880
+ }
881
+ function isIpLiteral(hostname) {
882
+ if (hostname.startsWith("[") && hostname.endsWith("]")) return true;
883
+ return parseIpv4(hostname) !== null;
884
+ }
885
+ function isBlockedHostname(hostname, ssrf) {
886
+ if (ssrf.allowInternalIPs) {
887
+ return ssrf.blockedHosts.includes(hostname);
888
+ }
889
+ if (ssrf.blockedHosts.includes(hostname)) return true;
890
+ const lower = hostname.toLowerCase();
891
+ const stripped = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
892
+ if (BLOCKED_HOSTNAMES.has(lower) || BLOCKED_HOSTNAMES.has(stripped)) return true;
893
+ return isBlockedAddress(hostname);
894
+ }
895
+ var defaultLookup = async (hostname) => {
896
+ const dns = await import("node:dns");
897
+ return dns.promises.lookup(hostname, { all: true });
898
+ };
899
+ async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
900
+ let parsed;
901
+ try {
902
+ parsed = new URL(url);
903
+ } catch {
904
+ throw new SsrfError(`Invalid spec URL: ${url}`, { url });
905
+ }
906
+ const protocol = parsed.protocol.replace(/:$/, "");
907
+ if (protocol !== "http" && protocol !== "https") {
908
+ throw new SsrfError(`Protocol "${protocol}" is not allowed for network spec loading (only http/https)`, { url });
909
+ }
910
+ const hostname = parsed.hostname;
911
+ if (ssrf.allowedHosts.length > 0 && !ssrf.allowedHosts.includes(hostname)) {
912
+ throw new SsrfError(`Host "${hostname}" is not in the allowed-hosts list`, { url });
913
+ }
914
+ if (ssrf.allowInternalIPs) {
915
+ if (ssrf.blockedHosts.includes(hostname)) {
916
+ throw new SsrfError(`Host "${hostname}" is blocked`, { url });
917
+ }
918
+ return;
919
+ }
920
+ if (isBlockedHostname(hostname, ssrf)) {
921
+ throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
922
+ }
923
+ if (!isIpLiteral(hostname)) {
924
+ let addresses;
925
+ try {
926
+ addresses = await lookup(hostname);
927
+ } catch {
928
+ return;
929
+ }
930
+ for (const { address } of addresses) {
931
+ if (isBlockedAddress(address)) {
932
+ throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
933
+ }
934
+ }
935
+ }
936
+ }
937
+ async function safeFetch(url, opts) {
938
+ const { headers, timeoutMs = 3e4, followRedirects = true, maxRedirects = 5, ssrf, lookup } = opts;
939
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
940
+ if (typeof fetchImpl !== "function") {
941
+ throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
942
+ }
943
+ let current = url;
944
+ for (let hop = 0; hop <= maxRedirects; hop++) {
945
+ await assertUrlSafe(current, ssrf, lookup);
946
+ const controller = new AbortController();
947
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
948
+ let response;
949
+ try {
950
+ response = await fetchImpl(current, {
951
+ headers,
952
+ signal: controller.signal,
953
+ redirect: "manual"
954
+ });
955
+ } finally {
956
+ clearTimeout(timer);
957
+ }
958
+ const status = typeof response.status === "number" ? response.status : 0;
959
+ const isRedirect = status >= 300 && status < 400 && status !== 304;
960
+ if (!isRedirect || !followRedirects) {
961
+ return response;
962
+ }
963
+ const location = response.headers?.get?.("location") ?? void 0;
964
+ if (!location) {
965
+ return response;
966
+ }
967
+ current = new URL(location, current).toString();
968
+ }
969
+ throw new SsrfError(`Too many redirects while loading OpenAPI spec (max ${maxRedirects})`, { url });
970
+ }
971
+
797
972
  // src/generator.ts
798
973
  var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
799
974
  document;
@@ -819,14 +994,12 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
819
994
  */
820
995
  static async fromURL(url, options = {}) {
821
996
  try {
822
- const controller = new AbortController();
823
- const timeout = setTimeout(() => controller.abort(), options.timeout ?? 3e4);
824
- const response = await fetch(url, {
997
+ const response = await safeFetch(url, {
825
998
  headers: options.headers,
826
- signal: controller.signal,
827
- redirect: options.followRedirects ?? true ? "follow" : "manual"
999
+ timeoutMs: options.timeout ?? 3e4,
1000
+ followRedirects: options.followRedirects ?? true,
1001
+ ssrf: normalizeSsrfOptions(options.refResolution)
828
1002
  });
829
- clearTimeout(timeout);
830
1003
  if (!response.ok) {
831
1004
  throw new LoadError(`Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, {
832
1005
  url,
@@ -858,6 +1031,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
858
1031
  */
859
1032
  static async fromFile(filePath, options = {}) {
860
1033
  try {
1034
+ const [path, fs] = await Promise.all([import("path"), import("fs/promises")]);
861
1035
  const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
862
1036
  const content = await fs.readFile(absolutePath, "utf-8");
863
1037
  const ext = path.extname(filePath).toLowerCase();
@@ -916,57 +1090,11 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
916
1090
  const validator = new Validator();
917
1091
  return validator.validate(this.document);
918
1092
  }
919
- /**
920
- * Hostnames and IP patterns that are blocked by default to prevent SSRF.
921
- * Covers RFC 1918/6598 private ranges, link-local, loopback, and cloud metadata endpoints.
922
- */
923
- static BLOCKED_HOSTNAME_PATTERNS = [
924
- "localhost",
925
- "metadata.google.internal",
926
- /^127\.\d+\.\d+\.\d+$/,
927
- // 127.0.0.0/8 loopback
928
- /^10\.\d+\.\d+\.\d+$/,
929
- // 10.0.0.0/8 private
930
- /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
931
- // 172.16.0.0/12 private
932
- /^192\.168\.\d+\.\d+$/,
933
- // 192.168.0.0/16 private
934
- /^169\.254\.\d+\.\d+$/,
935
- // 169.254.0.0/16 link-local / cloud metadata
936
- /^0\.0\.0\.0$/,
937
- // unspecified
938
- "::1",
939
- // IPv6 loopback
940
- /^fd[0-9a-f]{2}:/i,
941
- // fd00::/8 IPv6 ULA
942
- /^fe80:/i,
943
- // fe80::/10 IPv6 link-local
944
- /^\[::1\]$/,
945
- // bracketed IPv6 loopback
946
- /^\[fd[0-9a-f]{2}:/i,
947
- // bracketed IPv6 ULA
948
- /^\[fe80:/i
949
- // bracketed IPv6 link-local
950
- ];
951
- /**
952
- * Check whether a hostname is blocked (internal/private IP or explicit blocklist).
953
- */
954
- isBlockedHost(hostname, refOpts) {
955
- if (refOpts.allowInternalIPs) {
956
- return refOpts.blockedHosts.includes(hostname);
957
- }
958
- if (refOpts.blockedHosts.includes(hostname)) {
959
- return true;
960
- }
961
- for (const pattern of _OpenAPIToolGenerator.BLOCKED_HOSTNAME_PATTERNS) {
962
- if (typeof pattern === "string") {
963
- if (hostname === pattern) return true;
964
- } else {
965
- if (pattern.test(hostname)) return true;
966
- }
967
- }
968
- return false;
969
- }
1093
+ // NOTE: internal/private-address blocking + IPv4-mapped-IPv6 decoding now live
1094
+ // in `ssrf.ts` (`isBlockedHostname` / `isBlockedAddress` / `decodeIpv4MappedIpv6`),
1095
+ // shared by the spec-URL fetch (`fromURL`) and the `$ref` resolver below, and
1096
+ // augmented there with DNS resolution (closing the DNS-name-to-internal bypass)
1097
+ // and per-hop redirect re-validation (`safeFetch`).
970
1098
  /**
971
1099
  * Build $RefParser options based on refResolution configuration.
972
1100
  * Defaults: allow http/https, block file://, block internal IPs.
@@ -992,6 +1120,18 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
992
1120
  const hasHostAllowlist = refOpts.allowedHosts.length > 0;
993
1121
  const hostAllowSet = new Set(refOpts.allowedHosts);
994
1122
  resolveConfig["http"] = {
1123
+ // SECURITY: never auto-follow HTTP redirects when resolving external
1124
+ // `$ref`s. `canRead` validates only the INITIAL URL; the resolver's
1125
+ // default redirect-following (up to 5 hops) re-fetches the `Location`
1126
+ // target WITHOUT re-invoking `canRead`, so an allowlisted host could
1127
+ // 302 → `http://169.254.169.254/...` and smuggle a blocked target past
1128
+ // the allow/deny lists. `redirects: 0` refuses the first redirect, and
1129
+ // our custom `read` (below) additionally refuses redirects itself.
1130
+ redirects: 0,
1131
+ // Synchronous gate: protocol, host allow-list, and literal/known
1132
+ // internal hosts. DNS names that *resolve* to internal addresses pass
1133
+ // here (canRead cannot be async) and are caught in `read` via DNS
1134
+ // resolution — closing the `127.0.0.1.nip.io` bypass for `$ref`s too.
995
1135
  canRead: (file) => {
996
1136
  try {
997
1137
  const parsed = new URL(file.url);
@@ -1002,13 +1142,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1002
1142
  if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) {
1003
1143
  return false;
1004
1144
  }
1005
- if (this.isBlockedHost(parsed.hostname, refOpts)) {
1145
+ if (isBlockedHostname(parsed.hostname, refOpts)) {
1006
1146
  return false;
1007
1147
  }
1008
1148
  return true;
1009
1149
  } catch {
1010
1150
  return false;
1011
1151
  }
1152
+ },
1153
+ // SSRF-safe fetch: resolves DNS and rejects names that map to internal
1154
+ // addresses, and refuses redirects. NOTE: deliberately does NOT forward
1155
+ // `this.options.headers` (the spec-load credentials) to third-party
1156
+ // `$ref` hosts — that would leak the spec's auth token cross-origin.
1157
+ read: async (file) => {
1158
+ const response = await safeFetch(file.url, {
1159
+ timeoutMs: this.options.timeout,
1160
+ followRedirects: false,
1161
+ ssrf: refOpts
1162
+ });
1163
+ if (!response.ok) {
1164
+ throw new LoadError(
1165
+ `Failed to resolve external $ref "${file.url}": ${response.status} ${response.statusText}`,
1166
+ { url: file.url, status: response.status }
1167
+ );
1168
+ }
1169
+ return response.text();
1012
1170
  }
1013
1171
  };
1014
1172
  } else {
@@ -1016,23 +1174,75 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1016
1174
  }
1017
1175
  return { resolve: resolveConfig };
1018
1176
  }
1177
+ /**
1178
+ * Does the document contain any EXTERNAL `$ref` (a ref that is not a local
1179
+ * JSON-pointer beginning with `#`)? Only external refs require the full
1180
+ * `$RefParser` (file/http resolvers, which pull Node builtins). A document
1181
+ * with only internal refs can be dereferenced with the runtime-agnostic
1182
+ * resolver below — so it works on V8 isolates (Cloudflare Workers) too.
1183
+ */
1184
+ static hasExternalRefs(node, seen = /* @__PURE__ */ new Set()) {
1185
+ if (node === null || typeof node !== "object") return false;
1186
+ if (seen.has(node)) return false;
1187
+ seen.add(node);
1188
+ if (Array.isArray(node)) return node.some((n) => _OpenAPIToolGenerator.hasExternalRefs(n, seen));
1189
+ const ref = node.$ref;
1190
+ if (typeof ref === "string" && !ref.startsWith("#")) return true;
1191
+ return Object.values(node).some(
1192
+ (v) => _OpenAPIToolGenerator.hasExternalRefs(v, seen)
1193
+ );
1194
+ }
1195
+ /**
1196
+ * Dereference local (`#/...`) `$ref`s without `$RefParser` — pure, dependency-
1197
+ * free, runtime-agnostic. A pointer cache makes circular schemas resolve to a
1198
+ * shared reference instead of recursing forever (same contract as `$RefParser`).
1199
+ */
1200
+ static dereferenceInternal(root) {
1201
+ const cache = /* @__PURE__ */ new Map();
1202
+ const resolvePointer = (ptr) => {
1203
+ const parts = ptr.replace(/^#\/?/, "").split("/").filter((p) => p.length > 0).map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
1204
+ let cur = root;
1205
+ for (const p of parts) cur = cur?.[p];
1206
+ return cur;
1207
+ };
1208
+ const walk = (node) => {
1209
+ if (node === null || typeof node !== "object") return node;
1210
+ if (Array.isArray(node)) return node.map(walk);
1211
+ const ref = node.$ref;
1212
+ if (typeof ref === "string" && ref.startsWith("#")) {
1213
+ const cached = cache.get(ref);
1214
+ if (cached !== void 0) return cached;
1215
+ const placeholder = {};
1216
+ cache.set(ref, placeholder);
1217
+ const resolved = walk(resolvePointer(ref));
1218
+ if (resolved && typeof resolved === "object") Object.assign(placeholder, resolved);
1219
+ return placeholder;
1220
+ }
1221
+ const out = {};
1222
+ for (const [k, v] of Object.entries(node)) out[k] = walk(v);
1223
+ return out;
1224
+ };
1225
+ return walk(root);
1226
+ }
1019
1227
  /**
1020
1228
  * Initialize the generator (dereference if needed, then validate)
1021
1229
  */
1022
1230
  async initialize() {
1023
1231
  if (this.options.dereference && !this.dereferencedDocument) {
1024
- try {
1025
- const { default: $RefParser } = await import("@apidevtools/json-schema-ref-parser");
1026
- const refParserOptions = this.buildRefParserOptions();
1027
- this.dereferencedDocument = await $RefParser.dereference(
1028
- JSON.parse(JSON.stringify(this.document)),
1029
- refParserOptions
1030
- );
1031
- } catch (error) {
1032
- const errorMessage = error instanceof Error ? error.message : String(error);
1033
- throw new ParseError(`Failed to dereference OpenAPI document: ${errorMessage}`, {
1034
- originalError: error
1035
- });
1232
+ const cloned = JSON.parse(JSON.stringify(this.document));
1233
+ if (!_OpenAPIToolGenerator.hasExternalRefs(cloned)) {
1234
+ this.dereferencedDocument = _OpenAPIToolGenerator.dereferenceInternal(cloned);
1235
+ } else {
1236
+ try {
1237
+ const { default: $RefParser } = await import("@apidevtools/json-schema-ref-parser");
1238
+ const refParserOptions = this.buildRefParserOptions();
1239
+ this.dereferencedDocument = await $RefParser.dereference(cloned, refParserOptions);
1240
+ } catch (error) {
1241
+ const errorMessage = error instanceof Error ? error.message : String(error);
1242
+ throw new ParseError(`Failed to dereference OpenAPI document: ${errorMessage}`, {
1243
+ originalError: error
1244
+ });
1245
+ }
1036
1246
  }
1037
1247
  }
1038
1248
  if (this.options.validate) {
@@ -1130,7 +1340,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1130
1340
  /**
1131
1341
  * Check if an operation should be included
1132
1342
  */
1133
- shouldIncludeOperation(operation, path2, method, options) {
1343
+ shouldIncludeOperation(operation, path, method, options) {
1134
1344
  if (operation.deprecated && !options.includeDeprecated) {
1135
1345
  return false;
1136
1346
  }
@@ -1147,7 +1357,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1147
1357
  if (options.filterFn) {
1148
1358
  return options.filterFn({
1149
1359
  ...operation,
1150
- path: path2,
1360
+ path,
1151
1361
  method
1152
1362
  });
1153
1363
  }
@@ -1156,22 +1366,22 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1156
1366
  /**
1157
1367
  * Generate a tool name
1158
1368
  */
1159
- generateToolName(path2, method, operationId, options = {}) {
1369
+ generateToolName(path, method, operationId, options = {}) {
1160
1370
  if (options.namingStrategy?.toolNameGenerator) {
1161
- return options.namingStrategy.toolNameGenerator(path2, method, operationId);
1371
+ return options.namingStrategy.toolNameGenerator(path, method, operationId);
1162
1372
  }
1163
1373
  if (operationId) {
1164
1374
  return operationId;
1165
1375
  }
1166
- const sanitized = path2.replace(/\{([^}]+)\}/g, "By_$1").replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
1376
+ const sanitized = path.replace(/\{([^}]+)\}/g, "By_$1").replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
1167
1377
  return `${method}_${sanitized}`;
1168
1378
  }
1169
1379
  /**
1170
1380
  * Extract metadata from operation
1171
1381
  */
1172
- extractMetadata(path2, method, operation, document, outputSchema) {
1382
+ extractMetadata(path, method, operation, document, outputSchema) {
1173
1383
  const metadata = {
1174
- path: path2,
1384
+ path,
1175
1385
  method,
1176
1386
  operationId: operation.operationId,
1177
1387
  operationSummary: operation.summary,
@@ -1651,16 +1861,18 @@ var SecurityResolver = class {
1651
1861
  resolveDigestAuth(context) {
1652
1862
  const digest = context.digest;
1653
1863
  if (!digest) return void 0;
1864
+ const quoted = (v) => String(v).replace(/[\r\n]/g, "").replace(/"/g, '\\"');
1865
+ const token = (v) => String(v).replace(/[\r\n",]/g, "");
1654
1866
  const parts = [
1655
- `username="${digest.username}"`,
1656
- digest.realm ? `realm="${digest.realm}"` : "",
1657
- digest.nonce ? `nonce="${digest.nonce}"` : "",
1658
- digest.uri ? `uri="${digest.uri}"` : "",
1659
- digest.response ? `response="${digest.response}"` : "",
1660
- digest.opaque ? `opaque="${digest.opaque}"` : "",
1661
- digest.qop ? `qop=${digest.qop}` : "",
1662
- digest.nc ? `nc=${digest.nc}` : "",
1663
- digest.cnonce ? `cnonce="${digest.cnonce}"` : ""
1867
+ `username="${quoted(digest.username)}"`,
1868
+ digest.realm ? `realm="${quoted(digest.realm)}"` : "",
1869
+ digest.nonce ? `nonce="${quoted(digest.nonce)}"` : "",
1870
+ digest.uri ? `uri="${quoted(digest.uri)}"` : "",
1871
+ digest.response ? `response="${quoted(digest.response)}"` : "",
1872
+ digest.opaque ? `opaque="${quoted(digest.opaque)}"` : "",
1873
+ digest.qop ? `qop=${token(digest.qop)}` : "",
1874
+ digest.nc ? `nc=${token(digest.nc)}` : "",
1875
+ digest.cnonce ? `cnonce="${quoted(digest.cnonce)}"` : ""
1664
1876
  ].filter(Boolean);
1665
1877
  return `Digest ${parts.join(", ")}`;
1666
1878
  }
@@ -1766,6 +1978,7 @@ function createSecurityContext(auth) {
1766
1978
  };
1767
1979
  }
1768
1980
  export {
1981
+ BLOCKED_HOSTNAMES,
1769
1982
  BUILTIN_FORMAT_RESOLVERS,
1770
1983
  GenerationError,
1771
1984
  LoadError,
@@ -1777,10 +1990,18 @@ export {
1777
1990
  SchemaBuilder,
1778
1991
  SchemaError,
1779
1992
  SecurityResolver,
1993
+ SsrfError,
1780
1994
  ValidationError,
1781
1995
  Validator,
1996
+ assertUrlSafe,
1782
1997
  createSecurityContext,
1998
+ decodeIpv4MappedIpv6,
1999
+ defaultLookup,
2000
+ isBlockedAddress,
2001
+ isBlockedHostname,
1783
2002
  isReferenceObject,
2003
+ normalizeSsrfOptions,
1784
2004
  resolveSchemaFormats,
2005
+ safeFetch,
1785
2006
  toJsonSchema
1786
2007
  };
package/esm/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",
package/generator.d.ts CHANGED
@@ -35,19 +35,24 @@ export declare class OpenAPIToolGenerator {
35
35
  */
36
36
  validate(): Promise<ValidationResult>;
37
37
  /**
38
- * Hostnames and IP patterns that are blocked by default to prevent SSRF.
39
- * Covers RFC 1918/6598 private ranges, link-local, loopback, and cloud metadata endpoints.
38
+ * Build $RefParser options based on refResolution configuration.
39
+ * Defaults: allow http/https, block file://, block internal IPs.
40
40
  */
41
- private static readonly BLOCKED_HOSTNAME_PATTERNS;
41
+ private buildRefParserOptions;
42
42
  /**
43
- * Check whether a hostname is blocked (internal/private IP or explicit blocklist).
43
+ * Does the document contain any EXTERNAL `$ref` (a ref that is not a local
44
+ * JSON-pointer beginning with `#`)? Only external refs require the full
45
+ * `$RefParser` (file/http resolvers, which pull Node builtins). A document
46
+ * with only internal refs can be dereferenced with the runtime-agnostic
47
+ * resolver below — so it works on V8 isolates (Cloudflare Workers) too.
44
48
  */
45
- private isBlockedHost;
49
+ private static hasExternalRefs;
46
50
  /**
47
- * Build $RefParser options based on refResolution configuration.
48
- * Defaults: allow http/https, block file://, block internal IPs.
51
+ * Dereference local (`#/...`) `$ref`s without `$RefParser` — pure, dependency-
52
+ * free, runtime-agnostic. A pointer cache makes circular schemas resolve to a
53
+ * shared reference instead of recursing forever (same contract as `$RefParser`).
49
54
  */
50
- private buildRefParserOptions;
55
+ private static dereferenceInternal;
51
56
  /**
52
57
  * Initialize the generator (dereference if needed, then validate)
53
58
  */