mcp-from-openapi 2.3.0 → 2.4.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/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}`;
@@ -858,6 +856,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
858
856
  */
859
857
  static async fromFile(filePath, options = {}) {
860
858
  try {
859
+ const [path, fs] = await Promise.all([import("path"), import("fs/promises")]);
861
860
  const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
862
861
  const content = await fs.readFile(absolutePath, "utf-8");
863
862
  const ext = path.extname(filePath).toLowerCase();
@@ -948,6 +947,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
948
947
  /^\[fe80:/i
949
948
  // bracketed IPv6 link-local
950
949
  ];
950
+ /**
951
+ * Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
952
+ * `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4,
953
+ * or `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
954
+ * `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
955
+ * dotted-quad blocklist patterns miss — letting an attacker reach a private /
956
+ * metadata IPv4 through the v6 mapping. We re-check the decoded v4.
957
+ */
958
+ static mappedIPv4FromIPv6(hostname) {
959
+ let h = hostname;
960
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
961
+ const lower = h.toLowerCase();
962
+ const marker = lower.lastIndexOf("::ffff:");
963
+ if (marker === -1) return null;
964
+ const tail = lower.slice(marker + "::ffff:".length);
965
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
966
+ const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
967
+ if (hex) {
968
+ const hi = parseInt(hex[1], 16);
969
+ const lo = parseInt(hex[2], 16);
970
+ if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
971
+ return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
972
+ }
973
+ return null;
974
+ }
951
975
  /**
952
976
  * Check whether a hostname is blocked (internal/private IP or explicit blocklist).
953
977
  */
@@ -958,11 +982,16 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
958
982
  if (refOpts.blockedHosts.includes(hostname)) {
959
983
  return true;
960
984
  }
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;
985
+ const candidates = [hostname];
986
+ const mapped = _OpenAPIToolGenerator.mappedIPv4FromIPv6(hostname);
987
+ if (mapped) candidates.push(mapped);
988
+ for (const candidate of candidates) {
989
+ for (const pattern of _OpenAPIToolGenerator.BLOCKED_HOSTNAME_PATTERNS) {
990
+ if (typeof pattern === "string") {
991
+ if (candidate === pattern) return true;
992
+ } else {
993
+ if (pattern.test(candidate)) return true;
994
+ }
966
995
  }
967
996
  }
968
997
  return false;
@@ -992,6 +1021,14 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
992
1021
  const hasHostAllowlist = refOpts.allowedHosts.length > 0;
993
1022
  const hostAllowSet = new Set(refOpts.allowedHosts);
994
1023
  resolveConfig["http"] = {
1024
+ // SECURITY: never auto-follow HTTP redirects when resolving external
1025
+ // `$ref`s. `canRead` (below) validates only the INITIAL URL; the
1026
+ // resolver's default redirect-following (up to 5 hops) re-fetches the
1027
+ // `Location` target WITHOUT re-invoking `canRead`, so an allowlisted host
1028
+ // could 302 → `http://169.254.169.254/...` and smuggle a blocked target
1029
+ // past the allowlist/blocklist. Setting `redirects: 0` refuses the first
1030
+ // redirect; legitimate refs resolve in one hop.
1031
+ redirects: 0,
995
1032
  canRead: (file) => {
996
1033
  try {
997
1034
  const parsed = new URL(file.url);
@@ -1016,23 +1053,75 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1016
1053
  }
1017
1054
  return { resolve: resolveConfig };
1018
1055
  }
1056
+ /**
1057
+ * Does the document contain any EXTERNAL `$ref` (a ref that is not a local
1058
+ * JSON-pointer beginning with `#`)? Only external refs require the full
1059
+ * `$RefParser` (file/http resolvers, which pull Node builtins). A document
1060
+ * with only internal refs can be dereferenced with the runtime-agnostic
1061
+ * resolver below — so it works on V8 isolates (Cloudflare Workers) too.
1062
+ */
1063
+ static hasExternalRefs(node, seen = /* @__PURE__ */ new Set()) {
1064
+ if (node === null || typeof node !== "object") return false;
1065
+ if (seen.has(node)) return false;
1066
+ seen.add(node);
1067
+ if (Array.isArray(node)) return node.some((n) => _OpenAPIToolGenerator.hasExternalRefs(n, seen));
1068
+ const ref = node.$ref;
1069
+ if (typeof ref === "string" && !ref.startsWith("#")) return true;
1070
+ return Object.values(node).some(
1071
+ (v) => _OpenAPIToolGenerator.hasExternalRefs(v, seen)
1072
+ );
1073
+ }
1074
+ /**
1075
+ * Dereference local (`#/...`) `$ref`s without `$RefParser` — pure, dependency-
1076
+ * free, runtime-agnostic. A pointer cache makes circular schemas resolve to a
1077
+ * shared reference instead of recursing forever (same contract as `$RefParser`).
1078
+ */
1079
+ static dereferenceInternal(root) {
1080
+ const cache = /* @__PURE__ */ new Map();
1081
+ const resolvePointer = (ptr) => {
1082
+ const parts = ptr.replace(/^#\/?/, "").split("/").filter((p) => p.length > 0).map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
1083
+ let cur = root;
1084
+ for (const p of parts) cur = cur?.[p];
1085
+ return cur;
1086
+ };
1087
+ const walk = (node) => {
1088
+ if (node === null || typeof node !== "object") return node;
1089
+ if (Array.isArray(node)) return node.map(walk);
1090
+ const ref = node.$ref;
1091
+ if (typeof ref === "string" && ref.startsWith("#")) {
1092
+ const cached = cache.get(ref);
1093
+ if (cached !== void 0) return cached;
1094
+ const placeholder = {};
1095
+ cache.set(ref, placeholder);
1096
+ const resolved = walk(resolvePointer(ref));
1097
+ if (resolved && typeof resolved === "object") Object.assign(placeholder, resolved);
1098
+ return placeholder;
1099
+ }
1100
+ const out = {};
1101
+ for (const [k, v] of Object.entries(node)) out[k] = walk(v);
1102
+ return out;
1103
+ };
1104
+ return walk(root);
1105
+ }
1019
1106
  /**
1020
1107
  * Initialize the generator (dereference if needed, then validate)
1021
1108
  */
1022
1109
  async initialize() {
1023
1110
  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
- });
1111
+ const cloned = JSON.parse(JSON.stringify(this.document));
1112
+ if (!_OpenAPIToolGenerator.hasExternalRefs(cloned)) {
1113
+ this.dereferencedDocument = _OpenAPIToolGenerator.dereferenceInternal(cloned);
1114
+ } else {
1115
+ try {
1116
+ const { default: $RefParser } = await import("@apidevtools/json-schema-ref-parser");
1117
+ const refParserOptions = this.buildRefParserOptions();
1118
+ this.dereferencedDocument = await $RefParser.dereference(cloned, refParserOptions);
1119
+ } catch (error) {
1120
+ const errorMessage = error instanceof Error ? error.message : String(error);
1121
+ throw new ParseError(`Failed to dereference OpenAPI document: ${errorMessage}`, {
1122
+ originalError: error
1123
+ });
1124
+ }
1036
1125
  }
1037
1126
  }
1038
1127
  if (this.options.validate) {
@@ -1130,7 +1219,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1130
1219
  /**
1131
1220
  * Check if an operation should be included
1132
1221
  */
1133
- shouldIncludeOperation(operation, path2, method, options) {
1222
+ shouldIncludeOperation(operation, path, method, options) {
1134
1223
  if (operation.deprecated && !options.includeDeprecated) {
1135
1224
  return false;
1136
1225
  }
@@ -1147,7 +1236,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1147
1236
  if (options.filterFn) {
1148
1237
  return options.filterFn({
1149
1238
  ...operation,
1150
- path: path2,
1239
+ path,
1151
1240
  method
1152
1241
  });
1153
1242
  }
@@ -1156,22 +1245,22 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1156
1245
  /**
1157
1246
  * Generate a tool name
1158
1247
  */
1159
- generateToolName(path2, method, operationId, options = {}) {
1248
+ generateToolName(path, method, operationId, options = {}) {
1160
1249
  if (options.namingStrategy?.toolNameGenerator) {
1161
- return options.namingStrategy.toolNameGenerator(path2, method, operationId);
1250
+ return options.namingStrategy.toolNameGenerator(path, method, operationId);
1162
1251
  }
1163
1252
  if (operationId) {
1164
1253
  return operationId;
1165
1254
  }
1166
- const sanitized = path2.replace(/\{([^}]+)\}/g, "By_$1").replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
1255
+ const sanitized = path.replace(/\{([^}]+)\}/g, "By_$1").replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
1167
1256
  return `${method}_${sanitized}`;
1168
1257
  }
1169
1258
  /**
1170
1259
  * Extract metadata from operation
1171
1260
  */
1172
- extractMetadata(path2, method, operation, document, outputSchema) {
1261
+ extractMetadata(path, method, operation, document, outputSchema) {
1173
1262
  const metadata = {
1174
- path: path2,
1263
+ path,
1175
1264
  method,
1176
1265
  operationId: operation.operationId,
1177
1266
  operationSummary: operation.summary,
@@ -1651,16 +1740,18 @@ var SecurityResolver = class {
1651
1740
  resolveDigestAuth(context) {
1652
1741
  const digest = context.digest;
1653
1742
  if (!digest) return void 0;
1743
+ const quoted = (v) => String(v).replace(/[\r\n]/g, "").replace(/"/g, '\\"');
1744
+ const token = (v) => String(v).replace(/[\r\n",]/g, "");
1654
1745
  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}"` : ""
1746
+ `username="${quoted(digest.username)}"`,
1747
+ digest.realm ? `realm="${quoted(digest.realm)}"` : "",
1748
+ digest.nonce ? `nonce="${quoted(digest.nonce)}"` : "",
1749
+ digest.uri ? `uri="${quoted(digest.uri)}"` : "",
1750
+ digest.response ? `response="${quoted(digest.response)}"` : "",
1751
+ digest.opaque ? `opaque="${quoted(digest.opaque)}"` : "",
1752
+ digest.qop ? `qop=${token(digest.qop)}` : "",
1753
+ digest.nc ? `nc=${token(digest.nc)}` : "",
1754
+ digest.cnonce ? `cnonce="${quoted(digest.cnonce)}"` : ""
1664
1755
  ].filter(Boolean);
1665
1756
  return `Digest ${parts.join(", ")}`;
1666
1757
  }
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.4.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
@@ -39,6 +39,15 @@ export declare class OpenAPIToolGenerator {
39
39
  * Covers RFC 1918/6598 private ranges, link-local, loopback, and cloud metadata endpoints.
40
40
  */
41
41
  private static readonly BLOCKED_HOSTNAME_PATTERNS;
42
+ /**
43
+ * Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
44
+ * `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4,
45
+ * or `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
46
+ * `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
47
+ * dotted-quad blocklist patterns miss — letting an attacker reach a private /
48
+ * metadata IPv4 through the v6 mapping. We re-check the decoded v4.
49
+ */
50
+ private static mappedIPv4FromIPv6;
42
51
  /**
43
52
  * Check whether a hostname is blocked (internal/private IP or explicit blocklist).
44
53
  */
@@ -48,6 +57,20 @@ export declare class OpenAPIToolGenerator {
48
57
  * Defaults: allow http/https, block file://, block internal IPs.
49
58
  */
50
59
  private buildRefParserOptions;
60
+ /**
61
+ * Does the document contain any EXTERNAL `$ref` (a ref that is not a local
62
+ * JSON-pointer beginning with `#`)? Only external refs require the full
63
+ * `$RefParser` (file/http resolvers, which pull Node builtins). A document
64
+ * with only internal refs can be dereferenced with the runtime-agnostic
65
+ * resolver below — so it works on V8 isolates (Cloudflare Workers) too.
66
+ */
67
+ private static hasExternalRefs;
68
+ /**
69
+ * Dereference local (`#/...`) `$ref`s without `$RefParser` — pure, dependency-
70
+ * free, runtime-agnostic. A pointer cache makes circular schemas resolve to a
71
+ * shared reference instead of recursing forever (same contract as `$RefParser`).
72
+ */
73
+ private static dereferenceInternal;
51
74
  /**
52
75
  * Initialize the generator (dereference if needed, then validate)
53
76
  */
package/index.js CHANGED
@@ -52,8 +52,6 @@ module.exports = __toCommonJS(index_exports);
52
52
 
53
53
  // src/generator.ts
54
54
  var yaml = __toESM(require("yaml"));
55
- var path = __toESM(require("path"));
56
- var fs = __toESM(require("fs/promises"));
57
55
 
58
56
  // src/types.ts
59
57
  function isReferenceObject(obj) {
@@ -588,12 +586,12 @@ var Validator = class {
588
586
  * Validate paths
589
587
  */
590
588
  validatePaths(paths, errors, warnings) {
591
- for (const [path2, pathItem] of Object.entries(paths)) {
589
+ for (const [path, pathItem] of Object.entries(paths)) {
592
590
  if (!pathItem) continue;
593
- if (!path2.startsWith("/")) {
591
+ if (!path.startsWith("/")) {
594
592
  errors.push({
595
- message: `Path must start with '/': ${path2}`,
596
- path: `/paths/${path2}`,
593
+ message: `Path must start with '/': ${path}`,
594
+ path: `/paths/${path}`,
597
595
  code: "INVALID_PATH_FORMAT"
598
596
  });
599
597
  }
@@ -603,13 +601,13 @@ var Validator = class {
603
601
  const operation = pathItem[method];
604
602
  if (operation) {
605
603
  hasOperations = true;
606
- this.validateOperation(operation, path2, method, errors, warnings);
604
+ this.validateOperation(operation, path, method, errors, warnings);
607
605
  }
608
606
  }
609
607
  if (!hasOperations && !pathItem.$ref) {
610
608
  warnings.push({
611
- message: `Path has no operations: ${path2}`,
612
- path: `/paths/${path2}`,
609
+ message: `Path has no operations: ${path}`,
610
+ path: `/paths/${path}`,
613
611
  code: "NO_OPERATIONS"
614
612
  });
615
613
  }
@@ -618,33 +616,33 @@ var Validator = class {
618
616
  /**
619
617
  * Validate an operation
620
618
  */
621
- validateOperation(operation, path2, method, errors, warnings) {
622
- const basePath = `/paths/${path2}/${method}`;
619
+ validateOperation(operation, path, method, errors, warnings) {
620
+ const basePath = `/paths/${path}/${method}`;
623
621
  if (!operation.operationId) {
624
622
  warnings.push({
625
- message: `Operation missing operationId: ${method.toUpperCase()} ${path2}`,
623
+ message: `Operation missing operationId: ${method.toUpperCase()} ${path}`,
626
624
  path: `${basePath}/operationId`,
627
625
  code: "NO_OPERATION_ID"
628
626
  });
629
627
  }
630
628
  if (!operation.responses || Object.keys(operation.responses).length === 0) {
631
629
  errors.push({
632
- message: `Operation missing responses: ${method.toUpperCase()} ${path2}`,
630
+ message: `Operation missing responses: ${method.toUpperCase()} ${path}`,
633
631
  path: `${basePath}/responses`,
634
632
  code: "NO_RESPONSES"
635
633
  });
636
634
  }
637
635
  if (operation.parameters) {
638
- this.validateParameters(operation.parameters, path2, method, errors, warnings);
636
+ this.validateParameters(operation.parameters, path, method, errors, warnings);
639
637
  }
640
- const pathParams = path2.match(/\{([^}]+)\}/g)?.map((p) => p.slice(1, -1)) ?? [];
638
+ const pathParams = path.match(/\{([^}]+)\}/g)?.map((p) => p.slice(1, -1)) ?? [];
641
639
  const definedPathParams = new Set(
642
640
  operation.parameters?.filter((p) => p.in === "path").map((p) => p.name) ?? []
643
641
  );
644
642
  for (const param of pathParams) {
645
643
  if (!definedPathParams.has(param)) {
646
644
  errors.push({
647
- message: `Path parameter '${param}' not defined in parameters: ${method.toUpperCase()} ${path2}`,
645
+ message: `Path parameter '${param}' not defined in parameters: ${method.toUpperCase()} ${path}`,
648
646
  path: `${basePath}/parameters`,
649
647
  code: "MISSING_PATH_PARAMETER"
650
648
  });
@@ -654,8 +652,8 @@ var Validator = class {
654
652
  /**
655
653
  * Validate parameters
656
654
  */
657
- validateParameters(parameters, path2, method, errors, warnings) {
658
- const basePath = `/paths/${path2}/${method}/parameters`;
655
+ validateParameters(parameters, path, method, errors, warnings) {
656
+ const basePath = `/paths/${path}/${method}/parameters`;
659
657
  for (let i = 0; i < parameters.length; i++) {
660
658
  const param = parameters[i];
661
659
  const paramPath = `${basePath}/${i}`;
@@ -910,6 +908,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
910
908
  */
911
909
  static async fromFile(filePath, options = {}) {
912
910
  try {
911
+ const [path, fs] = await Promise.all([import("path"), import("fs/promises")]);
913
912
  const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
914
913
  const content = await fs.readFile(absolutePath, "utf-8");
915
914
  const ext = path.extname(filePath).toLowerCase();
@@ -1000,6 +999,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1000
999
  /^\[fe80:/i
1001
1000
  // bracketed IPv6 link-local
1002
1001
  ];
1002
+ /**
1003
+ * Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
1004
+ * `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4,
1005
+ * or `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
1006
+ * `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
1007
+ * dotted-quad blocklist patterns miss — letting an attacker reach a private /
1008
+ * metadata IPv4 through the v6 mapping. We re-check the decoded v4.
1009
+ */
1010
+ static mappedIPv4FromIPv6(hostname) {
1011
+ let h = hostname;
1012
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
1013
+ const lower = h.toLowerCase();
1014
+ const marker = lower.lastIndexOf("::ffff:");
1015
+ if (marker === -1) return null;
1016
+ const tail = lower.slice(marker + "::ffff:".length);
1017
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
1018
+ const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
1019
+ if (hex) {
1020
+ const hi = parseInt(hex[1], 16);
1021
+ const lo = parseInt(hex[2], 16);
1022
+ if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
1023
+ return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
1024
+ }
1025
+ return null;
1026
+ }
1003
1027
  /**
1004
1028
  * Check whether a hostname is blocked (internal/private IP or explicit blocklist).
1005
1029
  */
@@ -1010,11 +1034,16 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1010
1034
  if (refOpts.blockedHosts.includes(hostname)) {
1011
1035
  return true;
1012
1036
  }
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;
1037
+ const candidates = [hostname];
1038
+ const mapped = _OpenAPIToolGenerator.mappedIPv4FromIPv6(hostname);
1039
+ if (mapped) candidates.push(mapped);
1040
+ for (const candidate of candidates) {
1041
+ for (const pattern of _OpenAPIToolGenerator.BLOCKED_HOSTNAME_PATTERNS) {
1042
+ if (typeof pattern === "string") {
1043
+ if (candidate === pattern) return true;
1044
+ } else {
1045
+ if (pattern.test(candidate)) return true;
1046
+ }
1018
1047
  }
1019
1048
  }
1020
1049
  return false;
@@ -1044,6 +1073,14 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1044
1073
  const hasHostAllowlist = refOpts.allowedHosts.length > 0;
1045
1074
  const hostAllowSet = new Set(refOpts.allowedHosts);
1046
1075
  resolveConfig["http"] = {
1076
+ // SECURITY: never auto-follow HTTP redirects when resolving external
1077
+ // `$ref`s. `canRead` (below) validates only the INITIAL URL; the
1078
+ // resolver's default redirect-following (up to 5 hops) re-fetches the
1079
+ // `Location` target WITHOUT re-invoking `canRead`, so an allowlisted host
1080
+ // could 302 → `http://169.254.169.254/...` and smuggle a blocked target
1081
+ // past the allowlist/blocklist. Setting `redirects: 0` refuses the first
1082
+ // redirect; legitimate refs resolve in one hop.
1083
+ redirects: 0,
1047
1084
  canRead: (file) => {
1048
1085
  try {
1049
1086
  const parsed = new URL(file.url);
@@ -1068,23 +1105,75 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1068
1105
  }
1069
1106
  return { resolve: resolveConfig };
1070
1107
  }
1108
+ /**
1109
+ * Does the document contain any EXTERNAL `$ref` (a ref that is not a local
1110
+ * JSON-pointer beginning with `#`)? Only external refs require the full
1111
+ * `$RefParser` (file/http resolvers, which pull Node builtins). A document
1112
+ * with only internal refs can be dereferenced with the runtime-agnostic
1113
+ * resolver below — so it works on V8 isolates (Cloudflare Workers) too.
1114
+ */
1115
+ static hasExternalRefs(node, seen = /* @__PURE__ */ new Set()) {
1116
+ if (node === null || typeof node !== "object") return false;
1117
+ if (seen.has(node)) return false;
1118
+ seen.add(node);
1119
+ if (Array.isArray(node)) return node.some((n) => _OpenAPIToolGenerator.hasExternalRefs(n, seen));
1120
+ const ref = node.$ref;
1121
+ if (typeof ref === "string" && !ref.startsWith("#")) return true;
1122
+ return Object.values(node).some(
1123
+ (v) => _OpenAPIToolGenerator.hasExternalRefs(v, seen)
1124
+ );
1125
+ }
1126
+ /**
1127
+ * Dereference local (`#/...`) `$ref`s without `$RefParser` — pure, dependency-
1128
+ * free, runtime-agnostic. A pointer cache makes circular schemas resolve to a
1129
+ * shared reference instead of recursing forever (same contract as `$RefParser`).
1130
+ */
1131
+ static dereferenceInternal(root) {
1132
+ const cache = /* @__PURE__ */ new Map();
1133
+ const resolvePointer = (ptr) => {
1134
+ const parts = ptr.replace(/^#\/?/, "").split("/").filter((p) => p.length > 0).map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
1135
+ let cur = root;
1136
+ for (const p of parts) cur = cur?.[p];
1137
+ return cur;
1138
+ };
1139
+ const walk = (node) => {
1140
+ if (node === null || typeof node !== "object") return node;
1141
+ if (Array.isArray(node)) return node.map(walk);
1142
+ const ref = node.$ref;
1143
+ if (typeof ref === "string" && ref.startsWith("#")) {
1144
+ const cached = cache.get(ref);
1145
+ if (cached !== void 0) return cached;
1146
+ const placeholder = {};
1147
+ cache.set(ref, placeholder);
1148
+ const resolved = walk(resolvePointer(ref));
1149
+ if (resolved && typeof resolved === "object") Object.assign(placeholder, resolved);
1150
+ return placeholder;
1151
+ }
1152
+ const out = {};
1153
+ for (const [k, v] of Object.entries(node)) out[k] = walk(v);
1154
+ return out;
1155
+ };
1156
+ return walk(root);
1157
+ }
1071
1158
  /**
1072
1159
  * Initialize the generator (dereference if needed, then validate)
1073
1160
  */
1074
1161
  async initialize() {
1075
1162
  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
- });
1163
+ const cloned = JSON.parse(JSON.stringify(this.document));
1164
+ if (!_OpenAPIToolGenerator.hasExternalRefs(cloned)) {
1165
+ this.dereferencedDocument = _OpenAPIToolGenerator.dereferenceInternal(cloned);
1166
+ } else {
1167
+ try {
1168
+ const { default: $RefParser } = await import("@apidevtools/json-schema-ref-parser");
1169
+ const refParserOptions = this.buildRefParserOptions();
1170
+ this.dereferencedDocument = await $RefParser.dereference(cloned, refParserOptions);
1171
+ } catch (error) {
1172
+ const errorMessage = error instanceof Error ? error.message : String(error);
1173
+ throw new ParseError(`Failed to dereference OpenAPI document: ${errorMessage}`, {
1174
+ originalError: error
1175
+ });
1176
+ }
1088
1177
  }
1089
1178
  }
1090
1179
  if (this.options.validate) {
@@ -1182,7 +1271,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1182
1271
  /**
1183
1272
  * Check if an operation should be included
1184
1273
  */
1185
- shouldIncludeOperation(operation, path2, method, options) {
1274
+ shouldIncludeOperation(operation, path, method, options) {
1186
1275
  if (operation.deprecated && !options.includeDeprecated) {
1187
1276
  return false;
1188
1277
  }
@@ -1199,7 +1288,7 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1199
1288
  if (options.filterFn) {
1200
1289
  return options.filterFn({
1201
1290
  ...operation,
1202
- path: path2,
1291
+ path,
1203
1292
  method
1204
1293
  });
1205
1294
  }
@@ -1208,22 +1297,22 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1208
1297
  /**
1209
1298
  * Generate a tool name
1210
1299
  */
1211
- generateToolName(path2, method, operationId, options = {}) {
1300
+ generateToolName(path, method, operationId, options = {}) {
1212
1301
  if (options.namingStrategy?.toolNameGenerator) {
1213
- return options.namingStrategy.toolNameGenerator(path2, method, operationId);
1302
+ return options.namingStrategy.toolNameGenerator(path, method, operationId);
1214
1303
  }
1215
1304
  if (operationId) {
1216
1305
  return operationId;
1217
1306
  }
1218
- const sanitized = path2.replace(/\{([^}]+)\}/g, "By_$1").replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
1307
+ const sanitized = path.replace(/\{([^}]+)\}/g, "By_$1").replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
1219
1308
  return `${method}_${sanitized}`;
1220
1309
  }
1221
1310
  /**
1222
1311
  * Extract metadata from operation
1223
1312
  */
1224
- extractMetadata(path2, method, operation, document, outputSchema) {
1313
+ extractMetadata(path, method, operation, document, outputSchema) {
1225
1314
  const metadata = {
1226
- path: path2,
1315
+ path,
1227
1316
  method,
1228
1317
  operationId: operation.operationId,
1229
1318
  operationSummary: operation.summary,
@@ -1703,16 +1792,18 @@ var SecurityResolver = class {
1703
1792
  resolveDigestAuth(context) {
1704
1793
  const digest = context.digest;
1705
1794
  if (!digest) return void 0;
1795
+ const quoted = (v) => String(v).replace(/[\r\n]/g, "").replace(/"/g, '\\"');
1796
+ const token = (v) => String(v).replace(/[\r\n",]/g, "");
1706
1797
  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}"` : ""
1798
+ `username="${quoted(digest.username)}"`,
1799
+ digest.realm ? `realm="${quoted(digest.realm)}"` : "",
1800
+ digest.nonce ? `nonce="${quoted(digest.nonce)}"` : "",
1801
+ digest.uri ? `uri="${quoted(digest.uri)}"` : "",
1802
+ digest.response ? `response="${quoted(digest.response)}"` : "",
1803
+ digest.opaque ? `opaque="${quoted(digest.opaque)}"` : "",
1804
+ digest.qop ? `qop=${token(digest.qop)}` : "",
1805
+ digest.nc ? `nc=${token(digest.nc)}` : "",
1806
+ digest.cnonce ? `cnonce="${quoted(digest.cnonce)}"` : ""
1716
1807
  ].filter(Boolean);
1717
1808
  return `Digest ${parts.join(", ")}`;
1718
1809
  }
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "mcp-from-openapi",
3
- "version": "2.3.0",
3
+ "version": "2.4.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",