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