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 +13 -0
- package/esm/index.mjs +325 -104
- package/esm/package.json +2 -1
- package/generator.d.ts +13 -8
- package/index.d.ts +3 -1
- package/index.js +334 -104
- package/package.json +2 -1
- package/ssrf.d.ts +108 -0
- package/types.d.ts +16 -6
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 [
|
|
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}`;
|
|
@@ -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
|
|
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
|
-
|
|
827
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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 (
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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,
|
|
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
|
|
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(
|
|
1369
|
+
generateToolName(path, method, operationId, options = {}) {
|
|
1160
1370
|
if (options.namingStrategy?.toolNameGenerator) {
|
|
1161
|
-
return options.namingStrategy.toolNameGenerator(
|
|
1371
|
+
return options.namingStrategy.toolNameGenerator(path, method, operationId);
|
|
1162
1372
|
}
|
|
1163
1373
|
if (operationId) {
|
|
1164
1374
|
return operationId;
|
|
1165
1375
|
}
|
|
1166
|
-
const sanitized =
|
|
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(
|
|
1382
|
+
extractMetadata(path, method, operation, document, outputSchema) {
|
|
1173
1383
|
const metadata = {
|
|
1174
|
-
path
|
|
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
|
+
"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
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* Build $RefParser options based on refResolution configuration.
|
|
39
|
+
* Defaults: allow http/https, block file://, block internal IPs.
|
|
40
40
|
*/
|
|
41
|
-
private
|
|
41
|
+
private buildRefParserOptions;
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
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
|
|
49
|
+
private static hasExternalRefs;
|
|
46
50
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
55
|
+
private static dereferenceInternal;
|
|
51
56
|
/**
|
|
52
57
|
* Initialize the generator (dereference if needed, then validate)
|
|
53
58
|
*/
|