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 +142 -51
- package/esm/package.json +2 -1
- package/generator.d.ts +23 -0
- package/index.js +142 -51
- package/package.json +2 -1
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 [
|
|
537
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
540
538
|
if (!pathItem) continue;
|
|
541
|
-
if (!
|
|
539
|
+
if (!path.startsWith("/")) {
|
|
542
540
|
errors.push({
|
|
543
|
-
message: `Path must start with '/': ${
|
|
544
|
-
path: `/paths/${
|
|
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,
|
|
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: ${
|
|
560
|
-
path: `/paths/${
|
|
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,
|
|
570
|
-
const basePath = `/paths/${
|
|
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()} ${
|
|
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()} ${
|
|
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,
|
|
584
|
+
this.validateParameters(operation.parameters, path, method, errors, warnings);
|
|
587
585
|
}
|
|
588
|
-
const pathParams =
|
|
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()} ${
|
|
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,
|
|
606
|
-
const basePath = `/paths/${
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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,
|
|
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
|
|
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(
|
|
1248
|
+
generateToolName(path, method, operationId, options = {}) {
|
|
1160
1249
|
if (options.namingStrategy?.toolNameGenerator) {
|
|
1161
|
-
return options.namingStrategy.toolNameGenerator(
|
|
1250
|
+
return options.namingStrategy.toolNameGenerator(path, method, operationId);
|
|
1162
1251
|
}
|
|
1163
1252
|
if (operationId) {
|
|
1164
1253
|
return operationId;
|
|
1165
1254
|
}
|
|
1166
|
-
const sanitized =
|
|
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(
|
|
1261
|
+
extractMetadata(path, method, operation, document, outputSchema) {
|
|
1173
1262
|
const metadata = {
|
|
1174
|
-
path
|
|
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
|
+
"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 [
|
|
589
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
592
590
|
if (!pathItem) continue;
|
|
593
|
-
if (!
|
|
591
|
+
if (!path.startsWith("/")) {
|
|
594
592
|
errors.push({
|
|
595
|
-
message: `Path must start with '/': ${
|
|
596
|
-
path: `/paths/${
|
|
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,
|
|
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: ${
|
|
612
|
-
path: `/paths/${
|
|
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,
|
|
622
|
-
const basePath = `/paths/${
|
|
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()} ${
|
|
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()} ${
|
|
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,
|
|
636
|
+
this.validateParameters(operation.parameters, path, method, errors, warnings);
|
|
639
637
|
}
|
|
640
|
-
const pathParams =
|
|
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()} ${
|
|
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,
|
|
658
|
-
const basePath = `/paths/${
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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,
|
|
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
|
|
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(
|
|
1300
|
+
generateToolName(path, method, operationId, options = {}) {
|
|
1212
1301
|
if (options.namingStrategy?.toolNameGenerator) {
|
|
1213
|
-
return options.namingStrategy.toolNameGenerator(
|
|
1302
|
+
return options.namingStrategy.toolNameGenerator(path, method, operationId);
|
|
1214
1303
|
}
|
|
1215
1304
|
if (operationId) {
|
|
1216
1305
|
return operationId;
|
|
1217
1306
|
}
|
|
1218
|
-
const sanitized =
|
|
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(
|
|
1313
|
+
extractMetadata(path, method, operation, document, outputSchema) {
|
|
1225
1314
|
const metadata = {
|
|
1226
|
-
path
|
|
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
|
+
"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",
|