@mcpfn/testing 0.0.4 → 0.0.6
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/README.md +178 -2
- package/dist/auth.cjs +1 -1
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.js +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/index.cjs +3016 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +426 -89
- package/dist/index.d.ts +426 -89
- package/dist/index.js +2993 -155
- package/dist/index.js.map +1 -1
- package/dist/playwright.cjs +1 -1
- package/dist/playwright.cjs.map +1 -1
- package/dist/playwright.js +1 -1
- package/dist/playwright.js.map +1 -1
- package/package.json +43 -16
package/dist/index.cjs
CHANGED
|
@@ -33,37 +33,66 @@ __export(index_exports, {
|
|
|
33
33
|
MCPFN_HOST_PROFILES: () => MCPFN_HOST_PROFILES,
|
|
34
34
|
MCPFN_NAMED_OAUTH_HOST_FIXTURES: () => MCPFN_NAMED_OAUTH_HOST_FIXTURES,
|
|
35
35
|
MCPFN_OAUTH_EXTENSION_GRANTS: () => MCPFN_OAUTH_EXTENSION_GRANTS,
|
|
36
|
+
MCPFN_REPORT_SCHEMA_VERSION: () => MCPFN_REPORT_SCHEMA_VERSION,
|
|
37
|
+
MCPFN_TESTING_VERSION: () => MCPFN_TESTING_VERSION,
|
|
36
38
|
McpFnAssertionError: () => McpFnAssertionError,
|
|
37
39
|
McpFnAuthAssertionError: () => McpFnAuthAssertionError,
|
|
40
|
+
McpFnClientProfileContractCleanupError: () => McpFnClientProfileContractCleanupError,
|
|
41
|
+
McpFnConformanceCleanupError: () => McpFnConformanceCleanupError,
|
|
38
42
|
McpFnMockOAuthAuthorizationServer: () => McpFnMockOAuthAuthorizationServer,
|
|
43
|
+
McpFnRedactionLimitError: () => McpFnRedactionLimitError,
|
|
44
|
+
McpFnStructuralCredentialCollisionError: () => McpFnStructuralCredentialCollisionError,
|
|
45
|
+
McpFnTargetSuiteArtifactCleanupError: () => McpFnTargetSuiteArtifactCleanupError,
|
|
46
|
+
McpFnTargetSuiteCleanupError: () => McpFnTargetSuiteCleanupError,
|
|
39
47
|
McpFnTestClient: () => McpFnTestClient,
|
|
48
|
+
McpFnTestClientCleanupError: () => McpFnTestClientCleanupError,
|
|
40
49
|
OFFICIAL_CONFORMANCE_VERSION: () => OFFICIAL_CONFORMANCE_VERSION,
|
|
50
|
+
acquireRemoteCredential: () => acquireRemoteCredential,
|
|
41
51
|
apiKeyCredential: () => apiKeyCredential,
|
|
42
52
|
assertAuthRegressionSuite: () => assertAuthRegressionSuite,
|
|
43
53
|
assertAuthorizationCodeClientMetadata: () => assertAuthorizationCodeClientMetadata,
|
|
44
54
|
assertBearerResourceChallenge: () => assertBearerResourceChallenge,
|
|
45
55
|
assertManifestContract: () => assertManifestContract,
|
|
46
56
|
assertStructuredTextParity: () => assertStructuredTextParity,
|
|
57
|
+
assertTargetArtifactSafe: () => assertTargetArtifactSafe,
|
|
58
|
+
authenticatedHttpTarget: () => authenticatedHttpTarget,
|
|
47
59
|
bearerCredential: () => bearerCredential,
|
|
60
|
+
beginTargetCredentialRedaction: () => beginTargetCredentialRedaction,
|
|
48
61
|
buildOfficialConformanceArgs: () => buildOfficialConformanceArgs,
|
|
62
|
+
buildOfficialConformanceEnvironment: () => buildOfficialConformanceEnvironment,
|
|
49
63
|
checkHostCompatibility: () => checkHostCompatibility,
|
|
50
64
|
createAuthenticatedConformanceProxy: () => createAuthenticatedConformanceProxy,
|
|
51
65
|
createFetchAuthTarget: () => createFetchAuthTarget,
|
|
66
|
+
createHostedAuthorizationFixtures: () => createHostedAuthorizationFixtures,
|
|
67
|
+
createMcpFnClientProfileSnapshot: () => createMcpFnClientProfileSnapshot,
|
|
52
68
|
createMcpFnScenario: () => createMcpFnScenario,
|
|
53
69
|
createMcpFnScenarioArtifact: () => createMcpFnScenarioArtifact,
|
|
54
70
|
createMcpFnScenarioReport: () => createMcpFnScenarioReport,
|
|
71
|
+
createMcpFnTargetSuiteJUnit: () => createMcpFnTargetSuiteJUnit,
|
|
55
72
|
createOAuthClientMetadataFixture: () => createOAuthClientMetadataFixture,
|
|
56
73
|
createOAuthClientMetadataVariants: () => createOAuthClientMetadataVariants,
|
|
57
74
|
createPkcePair: () => createPkcePair,
|
|
75
|
+
diffMcpFnClientProfileSnapshots: () => diffMcpFnClientProfileSnapshots,
|
|
76
|
+
disposeMcpFnTargetSuiteReport: () => disposeMcpFnTargetSuiteReport,
|
|
77
|
+
normalizeMcpFnReportFailure: () => normalizeMcpFnReportFailure,
|
|
58
78
|
parseBearerChallenge: () => parseBearerChallenge,
|
|
79
|
+
redactRemoteCredential: () => redactRemoteCredential,
|
|
80
|
+
redactTargetCredentials: () => redactTargetCredentials,
|
|
59
81
|
runAuthRegressionSuite: () => runAuthRegressionSuite,
|
|
60
82
|
runAuthenticatedOfficialConformance: () => runAuthenticatedOfficialConformance,
|
|
83
|
+
runHostedAuthorizationRegression: () => runHostedAuthorizationRegression,
|
|
84
|
+
runMcpFnClientProfileContracts: () => runMcpFnClientProfileContracts,
|
|
61
85
|
runMcpFnTargetSuite: () => runMcpFnTargetSuite,
|
|
62
86
|
runOfficialConformance: () => runOfficialConformance,
|
|
63
87
|
runScenarios: () => runScenarios,
|
|
88
|
+
serializeMcpFnTargetSuiteReport: () => serializeMcpFnTargetSuiteReport,
|
|
64
89
|
stableJson: () => stableJson,
|
|
65
90
|
startMockOAuthAuthorizationServer: () => startMockOAuthAuthorizationServer,
|
|
66
|
-
|
|
91
|
+
staticRemoteCredentialProvider: () => staticRemoteCredentialProvider,
|
|
92
|
+
validateMcpFnClientProfileSnapshot: () => validateMcpFnClientProfileSnapshot,
|
|
93
|
+
validateMcpFnScenarios: () => validateMcpFnScenarios,
|
|
94
|
+
validateMcpFnSchemaPortability: () => validateMcpFnSchemaPortability,
|
|
95
|
+
validateRemoteCredentialHeaders: () => validateRemoteCredentialHeaders
|
|
67
96
|
});
|
|
68
97
|
module.exports = __toCommonJS(index_exports);
|
|
69
98
|
|
|
@@ -559,7 +588,7 @@ function uniqueScopes(value) {
|
|
|
559
588
|
function json(status, body) {
|
|
560
589
|
return Response.json(body, {
|
|
561
590
|
status,
|
|
562
|
-
headers: { "cache-control": "no-store" }
|
|
591
|
+
headers: { "cache-control": "no-store", pragma: "no-cache" }
|
|
563
592
|
});
|
|
564
593
|
}
|
|
565
594
|
function oauthError(status, error, description) {
|
|
@@ -664,8 +693,8 @@ var McpFnMockOAuthAuthorizationServer = class {
|
|
|
664
693
|
}
|
|
665
694
|
async handle(request) {
|
|
666
695
|
const url = new URL(request.url);
|
|
667
|
-
const
|
|
668
|
-
if (request.method === "GET" &&
|
|
696
|
+
const path3 = url.pathname.replace(this.issuer.pathname.replace(/\/$/, ""), "") || "/";
|
|
697
|
+
if (request.method === "GET" && path3 === "/.well-known/oauth-authorization-server") {
|
|
669
698
|
return json(200, {
|
|
670
699
|
issuer: this.issuer.toString(),
|
|
671
700
|
authorization_endpoint: this.authorizationEndpoint,
|
|
@@ -679,20 +708,20 @@ var McpFnMockOAuthAuthorizationServer = class {
|
|
|
679
708
|
client_id_metadata_document_supported: true
|
|
680
709
|
});
|
|
681
710
|
}
|
|
682
|
-
if (request.method === "GET" &&
|
|
711
|
+
if (request.method === "GET" && path3.startsWith("/client-metadata/")) {
|
|
683
712
|
const metadata = Object.values(this.clientMetadata).find((candidate) => new URL(candidate.client_id).pathname === url.pathname);
|
|
684
713
|
return metadata ? json(200, metadata) : json(404, { error: "not_found" });
|
|
685
714
|
}
|
|
686
|
-
if (request.method === "GET" &&
|
|
715
|
+
if (request.method === "GET" && path3 === "/authorize") {
|
|
687
716
|
return this.authorize(url);
|
|
688
717
|
}
|
|
689
|
-
if (request.method === "POST" &&
|
|
718
|
+
if (request.method === "POST" && path3 === "/token") {
|
|
690
719
|
return this.token(await request.text());
|
|
691
720
|
}
|
|
692
|
-
if (request.method === "POST" &&
|
|
721
|
+
if (request.method === "POST" && path3 === "/revoke") {
|
|
693
722
|
return this.revoke(await request.text());
|
|
694
723
|
}
|
|
695
|
-
if (request.method === "GET" &&
|
|
724
|
+
if (request.method === "GET" && path3 === "/callback") {
|
|
696
725
|
const parameters = Object.fromEntries(url.searchParams);
|
|
697
726
|
this.callbacks.push({ url: url.toString(), parameters });
|
|
698
727
|
return new Response(
|
|
@@ -903,6 +932,34 @@ async function startMockOAuthAuthorizationServer(options = {}) {
|
|
|
903
932
|
// src/client.ts
|
|
904
933
|
var import_client = require("@mcpfn/client");
|
|
905
934
|
var import_inMemory = require("@modelcontextprotocol/sdk/inMemory.js");
|
|
935
|
+
var testClientCleanupOwners = /* @__PURE__ */ new WeakMap();
|
|
936
|
+
var McpFnTestClientCleanupError = class extends Error {
|
|
937
|
+
constructor(close, cause) {
|
|
938
|
+
super(
|
|
939
|
+
"Test client cleanup remains pending; retain this error and retryCleanup()",
|
|
940
|
+
{ cause }
|
|
941
|
+
);
|
|
942
|
+
this.name = "McpFnTestClientCleanupError";
|
|
943
|
+
testClientCleanupOwners.set(this, { close });
|
|
944
|
+
}
|
|
945
|
+
retryCleanup() {
|
|
946
|
+
const owner = testClientCleanupOwners.get(this);
|
|
947
|
+
if (!owner) return Promise.resolve();
|
|
948
|
+
if (owner.pending) return owner.pending;
|
|
949
|
+
const pending = Promise.resolve().then(owner.close).then(
|
|
950
|
+
() => {
|
|
951
|
+
testClientCleanupOwners.delete(this);
|
|
952
|
+
},
|
|
953
|
+
() => {
|
|
954
|
+
throw this;
|
|
955
|
+
}
|
|
956
|
+
).finally(() => {
|
|
957
|
+
owner.pending = void 0;
|
|
958
|
+
});
|
|
959
|
+
owner.pending = pending;
|
|
960
|
+
return pending;
|
|
961
|
+
}
|
|
962
|
+
};
|
|
906
963
|
var McpFnTestClient = class _McpFnTestClient {
|
|
907
964
|
session;
|
|
908
965
|
constructor(session) {
|
|
@@ -923,8 +980,9 @@ var McpFnTestClient = class _McpFnTestClient {
|
|
|
923
980
|
}
|
|
924
981
|
}), info, options);
|
|
925
982
|
}
|
|
926
|
-
|
|
927
|
-
|
|
983
|
+
/** Create the session owner before connecting, so failed initialization remains closeable. */
|
|
984
|
+
static createTarget(target, info, options = {}) {
|
|
985
|
+
return new _McpFnTestClient((0, import_client.createMcpFnClient)({
|
|
928
986
|
target,
|
|
929
987
|
info: info ?? { name: "mcpfn-test-client", version: "1.0.0" },
|
|
930
988
|
capabilities: options.capabilities,
|
|
@@ -932,12 +990,20 @@ var McpFnTestClient = class _McpFnTestClient {
|
|
|
932
990
|
events: options.events,
|
|
933
991
|
configure: options.configure,
|
|
934
992
|
diagnostics: options.diagnostics
|
|
935
|
-
});
|
|
993
|
+
}));
|
|
994
|
+
}
|
|
995
|
+
static async connectTarget(target, info, options = {}) {
|
|
996
|
+
const client = this.createTarget(target, info, options);
|
|
936
997
|
try {
|
|
937
|
-
await session.connect();
|
|
938
|
-
return
|
|
998
|
+
await client.session.connect();
|
|
999
|
+
return client;
|
|
939
1000
|
} catch (error) {
|
|
940
|
-
|
|
1001
|
+
const connectionFailure = error instanceof Error && error.cause !== void 0 ? error.cause : error;
|
|
1002
|
+
try {
|
|
1003
|
+
await client.session.close();
|
|
1004
|
+
} catch {
|
|
1005
|
+
throw new McpFnTestClientCleanupError(() => client.session.close(), connectionFailure);
|
|
1006
|
+
}
|
|
941
1007
|
throw error;
|
|
942
1008
|
}
|
|
943
1009
|
}
|
|
@@ -986,20 +1052,1578 @@ var McpFnTestClient = class _McpFnTestClient {
|
|
|
986
1052
|
complete(params, options) {
|
|
987
1053
|
return this.session.prompts.complete(params, options);
|
|
988
1054
|
}
|
|
989
|
-
close() {
|
|
990
|
-
|
|
1055
|
+
async close() {
|
|
1056
|
+
try {
|
|
1057
|
+
await this.session.close();
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
throw new McpFnTestClientCleanupError(() => this.session.close(), error);
|
|
1060
|
+
}
|
|
991
1061
|
}
|
|
992
1062
|
};
|
|
993
1063
|
|
|
994
|
-
// src/
|
|
1064
|
+
// src/client-profile-contracts.ts
|
|
1065
|
+
var import_node_crypto2 = require("crypto");
|
|
1066
|
+
var import_ajv = __toESM(require("ajv"), 1);
|
|
1067
|
+
var import__ = __toESM(require("ajv/dist/2019.js"), 1);
|
|
1068
|
+
var import__2 = __toESM(require("ajv/dist/2020.js"), 1);
|
|
1069
|
+
var import_oauth_core2 = require("@superfunctions/oauth-core");
|
|
1070
|
+
var McpFnClientProfileContractCleanupError = class extends Error {
|
|
1071
|
+
constructor(report, owners) {
|
|
1072
|
+
super("Client profile target cleanup remains pending; retain this error and retryCleanup()");
|
|
1073
|
+
this.report = report;
|
|
1074
|
+
this.owners = owners;
|
|
1075
|
+
this.name = "McpFnClientProfileContractCleanupError";
|
|
1076
|
+
}
|
|
1077
|
+
report;
|
|
1078
|
+
owners;
|
|
1079
|
+
async retryCleanup() {
|
|
1080
|
+
const results = await Promise.allSettled(this.owners.map((owner) => owner.retryCleanup()));
|
|
1081
|
+
if (results.some((result) => result.status === "rejected")) throw this;
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
var ProfileCaseCleanupError = class extends Error {
|
|
1085
|
+
constructor(owner, result) {
|
|
1086
|
+
super("Client profile target cleanup remains pending");
|
|
1087
|
+
this.owner = owner;
|
|
1088
|
+
this.result = result;
|
|
1089
|
+
}
|
|
1090
|
+
owner;
|
|
1091
|
+
result;
|
|
1092
|
+
};
|
|
1093
|
+
var PORTABILITY_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1094
|
+
"$anchor",
|
|
1095
|
+
"$recursiveAnchor",
|
|
1096
|
+
"$recursiveRef",
|
|
1097
|
+
"$dynamicAnchor",
|
|
1098
|
+
"$dynamicRef",
|
|
1099
|
+
"dependentSchemas",
|
|
1100
|
+
"dependentRequired",
|
|
1101
|
+
"minContains",
|
|
1102
|
+
"maxContains",
|
|
1103
|
+
"prefixItems",
|
|
1104
|
+
"unevaluatedItems",
|
|
1105
|
+
"unevaluatedProperties"
|
|
1106
|
+
]);
|
|
1107
|
+
function hash(value) {
|
|
1108
|
+
return (0, import_node_crypto2.createHash)("sha256").update(stableJson(value)).digest("hex");
|
|
1109
|
+
}
|
|
1110
|
+
function boundedError(value) {
|
|
1111
|
+
const redacted = String(
|
|
1112
|
+
(0, import_oauth_core2.redactOAuthValue)(value instanceof Error ? value.message : value)
|
|
1113
|
+
);
|
|
1114
|
+
return redacted.slice(0, 512);
|
|
1115
|
+
}
|
|
1116
|
+
function assertProfileReference(id, version) {
|
|
1117
|
+
for (const [label, value] of [
|
|
1118
|
+
["id", id],
|
|
1119
|
+
["version", version]
|
|
1120
|
+
]) {
|
|
1121
|
+
if (typeof value !== "string" || !value || value.length > 128 || !/^[A-Za-z0-9._/-]+$/.test(value)) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
`Client profile ${label} must be a non-empty stable identifier`
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
function createMcpFnClientProfileSnapshot(profile, tools) {
|
|
1129
|
+
assertProfileReference(profile.id, profile.version);
|
|
1130
|
+
const normalized = [...tools].map((tool) => structuredClone(tool)).sort(
|
|
1131
|
+
(left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
|
1132
|
+
);
|
|
1133
|
+
if (normalized.some((tool, index) => index > 0 && tool.name === normalized[index - 1].name)) {
|
|
1134
|
+
throw new Error("Effective catalog contains duplicate tool names");
|
|
1135
|
+
}
|
|
1136
|
+
return {
|
|
1137
|
+
formatVersion: 1,
|
|
1138
|
+
kind: "mcpfn.client-profile-snapshot",
|
|
1139
|
+
profile: { id: profile.id, version: profile.version },
|
|
1140
|
+
catalogHash: hash(normalized),
|
|
1141
|
+
tools: normalized.map((tool) => ({ name: tool.name, hash: hash(tool) }))
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
function assertSnapshotFields(value, fields, location) {
|
|
1145
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1146
|
+
throw new Error(`Client profile snapshot ${location} must be an object`);
|
|
1147
|
+
}
|
|
1148
|
+
const key = Object.keys(value).find((key2) => !fields.includes(key2));
|
|
1149
|
+
if (key !== void 0) {
|
|
1150
|
+
throw new Error(`Client profile snapshot contains unsupported fields at ${location}: ${JSON.stringify(key.slice(0, 128))}`);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function validateMcpFnClientProfileSnapshot(value) {
|
|
1154
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1155
|
+
throw new Error("Client profile snapshot must be an object");
|
|
1156
|
+
}
|
|
1157
|
+
assertSnapshotFields(value, ["formatVersion", "kind", "profile", "catalogHash", "tools"], "root");
|
|
1158
|
+
const snapshot = value;
|
|
1159
|
+
if (snapshot.formatVersion !== 1 || snapshot.kind !== "mcpfn.client-profile-snapshot") {
|
|
1160
|
+
throw new Error("Unsupported client profile snapshot format");
|
|
1161
|
+
}
|
|
1162
|
+
if (!snapshot.profile)
|
|
1163
|
+
throw new Error("Client profile snapshot requires profile metadata");
|
|
1164
|
+
assertSnapshotFields(snapshot.profile, ["id", "version"], "profile");
|
|
1165
|
+
assertProfileReference(snapshot.profile.id, snapshot.profile.version);
|
|
1166
|
+
if (typeof snapshot.catalogHash !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.catalogHash)) {
|
|
1167
|
+
throw new Error("Client profile snapshot requires a SHA-256 catalogHash");
|
|
1168
|
+
}
|
|
1169
|
+
if (!Array.isArray(snapshot.tools))
|
|
1170
|
+
throw new Error("Client profile snapshot tools must be an array");
|
|
1171
|
+
let prior = "";
|
|
1172
|
+
for (const [index, tool] of snapshot.tools.entries()) {
|
|
1173
|
+
assertSnapshotFields(tool, ["name", "hash"], `tools[${index}]`);
|
|
1174
|
+
if (!tool || typeof tool.name !== "string" || !tool.name || !/^[a-f0-9]{64}$/.test(tool.hash)) {
|
|
1175
|
+
throw new Error("Client profile snapshot contains an invalid tool entry");
|
|
1176
|
+
}
|
|
1177
|
+
if (tool.name <= prior)
|
|
1178
|
+
throw new Error(
|
|
1179
|
+
"Client profile snapshot tools must be sorted and unique"
|
|
1180
|
+
);
|
|
1181
|
+
prior = tool.name;
|
|
1182
|
+
}
|
|
1183
|
+
return snapshot;
|
|
1184
|
+
}
|
|
1185
|
+
function diffMcpFnClientProfileSnapshots(beforeValue, afterValue) {
|
|
1186
|
+
const before = validateMcpFnClientProfileSnapshot(beforeValue);
|
|
1187
|
+
const after = validateMcpFnClientProfileSnapshot(afterValue);
|
|
1188
|
+
if (before.profile.id !== after.profile.id) {
|
|
1189
|
+
throw new Error("Cannot diff snapshots for different client profile ids");
|
|
1190
|
+
}
|
|
1191
|
+
const beforeByName = new Map(
|
|
1192
|
+
before.tools.map((tool) => [tool.name, tool.hash])
|
|
1193
|
+
);
|
|
1194
|
+
const afterByName = new Map(
|
|
1195
|
+
after.tools.map((tool) => [tool.name, tool.hash])
|
|
1196
|
+
);
|
|
1197
|
+
const names = [
|
|
1198
|
+
.../* @__PURE__ */ new Set([...beforeByName.keys(), ...afterByName.keys()])
|
|
1199
|
+
].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
1200
|
+
const changes = [];
|
|
1201
|
+
for (const tool of names) {
|
|
1202
|
+
const beforeHash = beforeByName.get(tool);
|
|
1203
|
+
const afterHash = afterByName.get(tool);
|
|
1204
|
+
if (!beforeHash) changes.push({ kind: "added", tool, afterHash });
|
|
1205
|
+
else if (!afterHash) changes.push({ kind: "removed", tool, beforeHash });
|
|
1206
|
+
else if (beforeHash !== afterHash) {
|
|
1207
|
+
changes.push({ kind: "modified", tool, beforeHash, afterHash });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
if (changes.length === 0 !== (before.catalogHash === after.catalogHash)) {
|
|
1211
|
+
throw new Error("Inconsistent catalog hashes for tool entries");
|
|
1212
|
+
}
|
|
1213
|
+
const summary = {
|
|
1214
|
+
added: changes.filter(({ kind }) => kind === "added").length,
|
|
1215
|
+
removed: changes.filter(({ kind }) => kind === "removed").length,
|
|
1216
|
+
modified: changes.filter(({ kind }) => kind === "modified").length
|
|
1217
|
+
};
|
|
1218
|
+
return { compatible: summary.removed === 0, changes, summary };
|
|
1219
|
+
}
|
|
1220
|
+
function createDialectValidator(dialect) {
|
|
1221
|
+
const normalized = dialect.replace(/#$/, "");
|
|
1222
|
+
if (normalized === "http://json-schema.org/draft-07/schema" || normalized === "https://json-schema.org/draft-07/schema")
|
|
1223
|
+
return new import_ajv.default({ allErrors: true, strict: false, validateFormats: false });
|
|
1224
|
+
if (normalized === "http://json-schema.org/draft/2019-09/schema" || normalized === "https://json-schema.org/draft/2019-09/schema")
|
|
1225
|
+
return new import__.default({
|
|
1226
|
+
allErrors: true,
|
|
1227
|
+
strict: false,
|
|
1228
|
+
validateFormats: false
|
|
1229
|
+
});
|
|
1230
|
+
if (normalized === "http://json-schema.org/draft/2020-12/schema" || normalized === "https://json-schema.org/draft/2020-12/schema")
|
|
1231
|
+
return new import__2.default({
|
|
1232
|
+
allErrors: true,
|
|
1233
|
+
strict: false,
|
|
1234
|
+
validateFormats: false
|
|
1235
|
+
});
|
|
1236
|
+
return void 0;
|
|
1237
|
+
}
|
|
1238
|
+
function pointerSegment(value) {
|
|
1239
|
+
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
1240
|
+
}
|
|
1241
|
+
function walkSchema(value, path3, visit) {
|
|
1242
|
+
if (!value || typeof value !== "object") return;
|
|
1243
|
+
const schema = value;
|
|
1244
|
+
for (const keyword of Object.keys(schema)) {
|
|
1245
|
+
visit(keyword, `${path3}/${pointerSegment(keyword)}`, schema);
|
|
1246
|
+
}
|
|
1247
|
+
for (const mapKeyword of [
|
|
1248
|
+
"$defs",
|
|
1249
|
+
"definitions",
|
|
1250
|
+
"dependencies",
|
|
1251
|
+
"dependentSchemas",
|
|
1252
|
+
"patternProperties",
|
|
1253
|
+
"properties"
|
|
1254
|
+
]) {
|
|
1255
|
+
const entries = schema[mapKeyword];
|
|
1256
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
|
1257
|
+
continue;
|
|
1258
|
+
for (const [name, child] of Object.entries(
|
|
1259
|
+
entries
|
|
1260
|
+
)) {
|
|
1261
|
+
walkSchema(child, `${path3}/${mapKeyword}/${pointerSegment(name)}`, visit);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
for (const arrayKeyword of ["allOf", "anyOf", "oneOf", "prefixItems", "items"]) {
|
|
1265
|
+
const entries = schema[arrayKeyword];
|
|
1266
|
+
if (!Array.isArray(entries)) continue;
|
|
1267
|
+
entries.forEach(
|
|
1268
|
+
(child, index) => walkSchema(child, `${path3}/${arrayKeyword}/${index}`, visit)
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
for (const schemaKeyword of [
|
|
1272
|
+
"additionalItems",
|
|
1273
|
+
"additionalProperties",
|
|
1274
|
+
"contains",
|
|
1275
|
+
"contentSchema",
|
|
1276
|
+
"else",
|
|
1277
|
+
"if",
|
|
1278
|
+
"items",
|
|
1279
|
+
"not",
|
|
1280
|
+
"propertyNames",
|
|
1281
|
+
"then",
|
|
1282
|
+
"unevaluatedItems",
|
|
1283
|
+
"unevaluatedProperties"
|
|
1284
|
+
]) {
|
|
1285
|
+
const child = schema[schemaKeyword];
|
|
1286
|
+
if (child && typeof child === "object" && !Array.isArray(child)) {
|
|
1287
|
+
walkSchema(child, `${path3}/${schemaKeyword}`, visit);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
function validateMcpFnSchemaPortability(schema, path3, policy = {}) {
|
|
1292
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
1293
|
+
return [
|
|
1294
|
+
{
|
|
1295
|
+
severity: "error",
|
|
1296
|
+
code: "schema-invalid",
|
|
1297
|
+
path: path3,
|
|
1298
|
+
message: "Tool schema must be an object"
|
|
1299
|
+
}
|
|
1300
|
+
];
|
|
1301
|
+
}
|
|
1302
|
+
const declared = schema.$schema;
|
|
1303
|
+
const dialect = typeof declared === "string" ? declared : "http://json-schema.org/draft-07/schema#";
|
|
1304
|
+
const validator = createDialectValidator(dialect);
|
|
1305
|
+
if (!validator) {
|
|
1306
|
+
return [
|
|
1307
|
+
{
|
|
1308
|
+
severity: "error",
|
|
1309
|
+
code: "schema-dialect-unsupported",
|
|
1310
|
+
path: `${path3}/$schema`,
|
|
1311
|
+
message: boundedError(`Unsupported JSON Schema dialect ${dialect}`)
|
|
1312
|
+
}
|
|
1313
|
+
];
|
|
1314
|
+
}
|
|
1315
|
+
const issues = [];
|
|
1316
|
+
try {
|
|
1317
|
+
validator.compile(schema);
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
issues.push({
|
|
1320
|
+
severity: "error",
|
|
1321
|
+
code: "schema-invalid",
|
|
1322
|
+
path: path3,
|
|
1323
|
+
message: boundedError(error)
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
const allowed = new Set(policy.allowKeywords ?? []);
|
|
1327
|
+
const modernDialect = /draft\/(2019-09|2020-12)\/schema#?$/.test(dialect);
|
|
1328
|
+
const assertionKeywords = /* @__PURE__ */ new Set([
|
|
1329
|
+
"type",
|
|
1330
|
+
"enum",
|
|
1331
|
+
"const",
|
|
1332
|
+
"multipleOf",
|
|
1333
|
+
"maximum",
|
|
1334
|
+
"exclusiveMaximum",
|
|
1335
|
+
"minimum",
|
|
1336
|
+
"exclusiveMinimum",
|
|
1337
|
+
"maxLength",
|
|
1338
|
+
"minLength",
|
|
1339
|
+
"pattern",
|
|
1340
|
+
"format",
|
|
1341
|
+
"maxItems",
|
|
1342
|
+
"minItems",
|
|
1343
|
+
"uniqueItems",
|
|
1344
|
+
"contains",
|
|
1345
|
+
"minContains",
|
|
1346
|
+
"maxContains",
|
|
1347
|
+
"maxProperties",
|
|
1348
|
+
"minProperties",
|
|
1349
|
+
"required",
|
|
1350
|
+
"dependentRequired",
|
|
1351
|
+
"properties",
|
|
1352
|
+
"patternProperties",
|
|
1353
|
+
"additionalProperties",
|
|
1354
|
+
"propertyNames",
|
|
1355
|
+
"dependentSchemas",
|
|
1356
|
+
"items",
|
|
1357
|
+
"prefixItems",
|
|
1358
|
+
"additionalItems",
|
|
1359
|
+
"unevaluatedItems",
|
|
1360
|
+
"unevaluatedProperties",
|
|
1361
|
+
"allOf",
|
|
1362
|
+
"anyOf",
|
|
1363
|
+
"oneOf",
|
|
1364
|
+
"not",
|
|
1365
|
+
"if",
|
|
1366
|
+
"then",
|
|
1367
|
+
"else"
|
|
1368
|
+
]);
|
|
1369
|
+
walkSchema(schema, path3, (keyword, keywordPath, node) => {
|
|
1370
|
+
if (modernDialect && keyword === "$ref" && !allowed.has(keyword) && Object.keys(node).some((sibling) => assertionKeywords.has(sibling))) {
|
|
1371
|
+
issues.push({
|
|
1372
|
+
severity: policy.warningsAsErrors ? "error" : "warning",
|
|
1373
|
+
code: "schema-portability-warning",
|
|
1374
|
+
path: keywordPath,
|
|
1375
|
+
keyword,
|
|
1376
|
+
message: "$ref assertion siblings are evaluated by modern dialects but ignored by draft-07 clients"
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
if (!PORTABILITY_KEYWORDS.has(keyword) || allowed.has(keyword)) return;
|
|
1380
|
+
issues.push({
|
|
1381
|
+
severity: policy.warningsAsErrors ? "error" : "warning",
|
|
1382
|
+
code: "schema-portability-warning",
|
|
1383
|
+
path: keywordPath,
|
|
1384
|
+
keyword,
|
|
1385
|
+
message: `${keyword} is valid but may reduce client portability`
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
return issues.sort(
|
|
1389
|
+
(left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
function extractError(result) {
|
|
1393
|
+
const structured = result.structuredContent;
|
|
1394
|
+
if (structured?.error && typeof structured.error === "object") {
|
|
1395
|
+
return structured.error;
|
|
1396
|
+
}
|
|
1397
|
+
const text = result.content.find((content) => content.type === "text")?.text;
|
|
1398
|
+
if (!text) return void 0;
|
|
1399
|
+
try {
|
|
1400
|
+
const parsed = JSON.parse(text);
|
|
1401
|
+
return parsed.error && typeof parsed.error === "object" ? parsed.error : void 0;
|
|
1402
|
+
} catch {
|
|
1403
|
+
return void 0;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function fixtureFailure(result, fixture) {
|
|
1407
|
+
const expected = fixture.expect;
|
|
1408
|
+
if (fixture.source !== "captured-failure" && result.isError) return "Minimal-valid fixture returned isError=true";
|
|
1409
|
+
if (fixture.source === "captured-failure" && !result.isError) return "Captured failure did not reproduce an error";
|
|
1410
|
+
if (!expected)
|
|
1411
|
+
return result.isError ? "Tool returned isError=true" : void 0;
|
|
1412
|
+
if (expected.isError !== void 0 && Boolean(result.isError) !== expected.isError) {
|
|
1413
|
+
return `Expected isError=${expected.isError}, received ${Boolean(result.isError)}`;
|
|
1414
|
+
}
|
|
1415
|
+
if (expected.structuredContent !== void 0 && stableJson(result.structuredContent) !== stableJson(expected.structuredContent))
|
|
1416
|
+
return "Structured content did not match the fixture expectation";
|
|
1417
|
+
const error = extractError(result);
|
|
1418
|
+
if (expected.errorCode !== void 0 && error?.code !== expected.errorCode) {
|
|
1419
|
+
return "Error code did not match the declared expectation";
|
|
1420
|
+
}
|
|
1421
|
+
const details = error?.details && typeof error.details === "object" ? error.details : void 0;
|
|
1422
|
+
if (expected.lifecycleStage !== void 0 && details?.lifecycleStage !== expected.lifecycleStage)
|
|
1423
|
+
return `Expected lifecycle stage ${expected.lifecycleStage}`;
|
|
1424
|
+
if (expected.validationIssue) {
|
|
1425
|
+
const issues = Array.isArray(details?.issues) ? details.issues : [];
|
|
1426
|
+
const fields = Object.entries(expected.validationIssue).filter(([, value]) => value !== void 0);
|
|
1427
|
+
const matched = fields.length > 0 && issues.some(
|
|
1428
|
+
(issue) => fields.every(
|
|
1429
|
+
([key, value]) => issue[key] === value
|
|
1430
|
+
)
|
|
1431
|
+
);
|
|
1432
|
+
if (!matched)
|
|
1433
|
+
return "Expected structured validation issue was not reported";
|
|
1434
|
+
}
|
|
1435
|
+
return void 0;
|
|
1436
|
+
}
|
|
1437
|
+
async function runProfileCase(profileCase, allowSideEffects) {
|
|
1438
|
+
const profile = { id: profileCase.id, version: profileCase.version };
|
|
1439
|
+
let client;
|
|
1440
|
+
let configurationError;
|
|
1441
|
+
let pendingFailure;
|
|
1442
|
+
let hasPendingFailure = false;
|
|
1443
|
+
let cleanupOwner;
|
|
1444
|
+
let result = {
|
|
1445
|
+
profile,
|
|
1446
|
+
status: "complete",
|
|
1447
|
+
ok: false,
|
|
1448
|
+
portability: [],
|
|
1449
|
+
fixtures: []
|
|
1450
|
+
};
|
|
1451
|
+
try {
|
|
1452
|
+
try {
|
|
1453
|
+
client = await McpFnTestClient.connectTarget(
|
|
1454
|
+
profileCase.target,
|
|
1455
|
+
profileCase.clientInfo ?? {
|
|
1456
|
+
name: "mcpfn-profile-suite",
|
|
1457
|
+
version: "1.0.0"
|
|
1458
|
+
},
|
|
1459
|
+
{ ...profileCase.client, capabilities: profileCase.capabilities }
|
|
1460
|
+
);
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
if (error instanceof McpFnTestClientCleanupError) throw error;
|
|
1463
|
+
return { ...result, phase: "connect", error: "Target connection failed" };
|
|
1464
|
+
}
|
|
1465
|
+
let tools;
|
|
1466
|
+
try {
|
|
1467
|
+
tools = await client.listTools();
|
|
1468
|
+
const repeatedTools = await client.listTools();
|
|
1469
|
+
if (stableJson(tools) !== stableJson(repeatedTools)) {
|
|
1470
|
+
throw new Error(
|
|
1471
|
+
"Effective catalog changed across identical profile list requests"
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
result.snapshot = createMcpFnClientProfileSnapshot(profile, tools);
|
|
1475
|
+
result.snapshotMatches = profileCase.expectedSnapshot ? stableJson(result.snapshot) === stableJson(profileCase.expectedSnapshot) : void 0;
|
|
1476
|
+
for (const tool of tools) {
|
|
1477
|
+
result.portability.push(
|
|
1478
|
+
...validateMcpFnSchemaPortability(
|
|
1479
|
+
tool.inputSchema,
|
|
1480
|
+
`tools/${tool.name}/inputSchema`,
|
|
1481
|
+
profileCase.portability
|
|
1482
|
+
)
|
|
1483
|
+
);
|
|
1484
|
+
if (tool.outputSchema) {
|
|
1485
|
+
result.portability.push(
|
|
1486
|
+
...validateMcpFnSchemaPortability(
|
|
1487
|
+
tool.outputSchema,
|
|
1488
|
+
`tools/${tool.name}/outputSchema`,
|
|
1489
|
+
profileCase.portability
|
|
1490
|
+
)
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
tools = void 0;
|
|
1496
|
+
result = { ...result, phase: "catalog", error: "Target catalog validation failed" };
|
|
1497
|
+
}
|
|
1498
|
+
if (tools) {
|
|
1499
|
+
const advertised = new Set(tools.map(({ name }) => name));
|
|
1500
|
+
const taskRequired = new Set(tools.filter((tool) => tool.execution?.taskSupport === "required").map((tool) => tool.name));
|
|
1501
|
+
if (profileCase.fixtures?.some((fixture) => taskRequired.has(fixture.tool))) {
|
|
1502
|
+
configurationError = new Error(`Client profile ${profile.id}@${profile.version} task-required fixtures are unsupported by this contract runner`);
|
|
1503
|
+
throw configurationError;
|
|
1504
|
+
}
|
|
1505
|
+
for (const fixture of profileCase.fixtures ?? []) {
|
|
1506
|
+
const base = {
|
|
1507
|
+
name: fixture.name,
|
|
1508
|
+
tool: fixture.tool,
|
|
1509
|
+
source: fixture.source ?? "minimal-valid"
|
|
1510
|
+
};
|
|
1511
|
+
if (!advertised.has(fixture.tool)) {
|
|
1512
|
+
result.fixtures.push({
|
|
1513
|
+
...base,
|
|
1514
|
+
status: "failed",
|
|
1515
|
+
code: "fixture-tool-not-advertised",
|
|
1516
|
+
error: "Fixture references a tool absent from the effective catalog"
|
|
1517
|
+
});
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
if (fixture.sideEffect !== "read-only" && !allowSideEffects) {
|
|
1521
|
+
result.fixtures.push({
|
|
1522
|
+
...base,
|
|
1523
|
+
status: "incomplete",
|
|
1524
|
+
code: "side-effect-not-authorized",
|
|
1525
|
+
error: "Fixture was not executed because side effects were not explicitly allowed"
|
|
1526
|
+
});
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
try {
|
|
1530
|
+
const callResult = await client.callTool(
|
|
1531
|
+
fixture.tool,
|
|
1532
|
+
fixture.arguments ?? {}
|
|
1533
|
+
);
|
|
1534
|
+
const failure = fixtureFailure(callResult, fixture);
|
|
1535
|
+
result.fixtures.push(
|
|
1536
|
+
failure ? {
|
|
1537
|
+
...base,
|
|
1538
|
+
status: "failed",
|
|
1539
|
+
code: "fixture-expectation-failed",
|
|
1540
|
+
error: failure
|
|
1541
|
+
} : { ...base, status: "passed" }
|
|
1542
|
+
);
|
|
1543
|
+
} catch (error) {
|
|
1544
|
+
result.fixtures.push({
|
|
1545
|
+
...base,
|
|
1546
|
+
status: "failed",
|
|
1547
|
+
code: "fixture-call-failed",
|
|
1548
|
+
error: "Tool invocation failed; argument values and exception text are omitted"
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
const incomplete = result.fixtures.some(
|
|
1553
|
+
({ status }) => status === "incomplete"
|
|
1554
|
+
);
|
|
1555
|
+
const failed = result.fixtures.some(({ status }) => status === "failed") || result.portability.some(({ severity }) => severity === "error") || result.snapshotMatches === false;
|
|
1556
|
+
result = {
|
|
1557
|
+
...result,
|
|
1558
|
+
phase: result.snapshotMatches === false || result.portability.some(({ severity }) => severity === "error") ? "catalog" : failed || incomplete ? "fixtures" : void 0,
|
|
1559
|
+
status: incomplete ? "incomplete" : "complete",
|
|
1560
|
+
ok: !failed && !incomplete,
|
|
1561
|
+
...result.snapshotMatches === false ? {
|
|
1562
|
+
error: "Effective catalog does not match the reviewed profile snapshot"
|
|
1563
|
+
} : {}
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
pendingFailure = error;
|
|
1568
|
+
hasPendingFailure = true;
|
|
1569
|
+
} finally {
|
|
1570
|
+
if (client) {
|
|
1571
|
+
try {
|
|
1572
|
+
await client.close();
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
if (error instanceof McpFnTestClientCleanupError) cleanupOwner = error;
|
|
1575
|
+
else {
|
|
1576
|
+
if (configurationError) configurationError.message = `${configurationError.message}; target cleanup failed`;
|
|
1577
|
+
result = {
|
|
1578
|
+
...result,
|
|
1579
|
+
ok: false,
|
|
1580
|
+
phase: result.phase ?? "close",
|
|
1581
|
+
error: result.error ? `${result.error}; target cleanup failed`.slice(0, 512) : "Target cleanup failed"
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
if (cleanupOwner) throw new ProfileCaseCleanupError(cleanupOwner, {
|
|
1588
|
+
...result,
|
|
1589
|
+
ok: false,
|
|
1590
|
+
status: "incomplete",
|
|
1591
|
+
phase: result.phase ?? (hasPendingFailure ? "fixtures" : "close"),
|
|
1592
|
+
error: hasPendingFailure ? "Profile execution failed; target cleanup remains pending" : "Target cleanup remains pending"
|
|
1593
|
+
});
|
|
1594
|
+
if (hasPendingFailure) {
|
|
1595
|
+
if (pendingFailure instanceof McpFnTestClientCleanupError) {
|
|
1596
|
+
throw new ProfileCaseCleanupError(pendingFailure, {
|
|
1597
|
+
...result,
|
|
1598
|
+
ok: false,
|
|
1599
|
+
status: "incomplete",
|
|
1600
|
+
phase: "connect",
|
|
1601
|
+
error: "Target cleanup remains pending"
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
throw pendingFailure;
|
|
1605
|
+
}
|
|
1606
|
+
return result;
|
|
1607
|
+
}
|
|
1608
|
+
async function runMcpFnClientProfileContracts(options) {
|
|
1609
|
+
const maxReportBytes = options.maxReportBytes ?? 1048576;
|
|
1610
|
+
if (!Number.isSafeInteger(maxReportBytes) || maxReportBytes < 2048) {
|
|
1611
|
+
throw new Error("maxReportBytes must be an integer of at least 2048");
|
|
1612
|
+
}
|
|
1613
|
+
if (!Array.isArray(options.profiles) || options.profiles.length === 0) {
|
|
1614
|
+
throw new Error(
|
|
1615
|
+
"At least one generic or configured client profile is required"
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
if (options.profiles.length > 100)
|
|
1619
|
+
throw new Error("At most 100 client profiles may be tested");
|
|
1620
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1621
|
+
for (const profile of options.profiles) {
|
|
1622
|
+
assertProfileReference(profile.id, profile.version);
|
|
1623
|
+
const key = `${profile.id}@${profile.version}`;
|
|
1624
|
+
if (keys.has(key)) throw new Error(`Duplicate client profile case ${key}`);
|
|
1625
|
+
keys.add(key);
|
|
1626
|
+
if (profile.expectedSnapshot)
|
|
1627
|
+
validateMcpFnClientProfileSnapshot(profile.expectedSnapshot);
|
|
1628
|
+
for (const fixture of profile.fixtures ?? []) {
|
|
1629
|
+
if (fixture.source === "captured-failure" && (!fixture.expect || fixture.expect.isError === false || !(fixture.expect.structuredContent !== void 0 || fixture.expect.errorCode || fixture.expect.lifecycleStage || fixture.expect.validationIssue && Object.values(fixture.expect.validationIssue).some((value) => value !== void 0)))) {
|
|
1630
|
+
throw new Error(`Captured-failure fixture ${fixture.name} requires meaningful error expectations`);
|
|
1631
|
+
}
|
|
1632
|
+
if (fixture.source !== void 0 && !["minimal-valid", "captured-failure"].includes(fixture.source)) throw new Error("Invalid fixture source");
|
|
1633
|
+
if (!fixture.name || !fixture.tool || !["read-only", "idempotent", "non-idempotent"].includes(fixture.sideEffect)) {
|
|
1634
|
+
throw new Error(`Client profile ${key} contains an invalid fixture`);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
const profiles = [];
|
|
1639
|
+
const cleanupOwners = [];
|
|
1640
|
+
for (const profile of [...options.profiles].sort((left, right) => {
|
|
1641
|
+
const leftKey = `${left.id}@${left.version}`;
|
|
1642
|
+
const rightKey = `${right.id}@${right.version}`;
|
|
1643
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
1644
|
+
})) {
|
|
1645
|
+
try {
|
|
1646
|
+
profiles.push(await runProfileCase(profile, options.allowSideEffects === true));
|
|
1647
|
+
} catch (error) {
|
|
1648
|
+
if (error instanceof ProfileCaseCleanupError) {
|
|
1649
|
+
profiles.push(error.result);
|
|
1650
|
+
cleanupOwners.push(error.owner);
|
|
1651
|
+
} else {
|
|
1652
|
+
if (cleanupOwners.length === 0) throw error;
|
|
1653
|
+
profiles.push({
|
|
1654
|
+
profile: { id: profile.id, version: profile.version },
|
|
1655
|
+
status: "incomplete",
|
|
1656
|
+
ok: false,
|
|
1657
|
+
phase: "fixtures",
|
|
1658
|
+
portability: [],
|
|
1659
|
+
fixtures: [],
|
|
1660
|
+
error: "Profile execution failed"
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
const report = {
|
|
1666
|
+
formatVersion: 1,
|
|
1667
|
+
kind: "mcpfn.client-profile-contract-report",
|
|
1668
|
+
status: profiles.some(({ status }) => status === "incomplete") ? "incomplete" : "complete",
|
|
1669
|
+
ok: profiles.every(({ ok }) => ok),
|
|
1670
|
+
profiles,
|
|
1671
|
+
droppedProfiles: 0
|
|
1672
|
+
};
|
|
1673
|
+
if (new TextEncoder().encode(JSON.stringify(report)).byteLength <= maxReportBytes)
|
|
1674
|
+
return finishReport(report, cleanupOwners);
|
|
1675
|
+
report.ok = false;
|
|
1676
|
+
report.status = "incomplete";
|
|
1677
|
+
report.incompleteReason = "Report content exceeded maxReportBytes and was truncated";
|
|
1678
|
+
while (new TextEncoder().encode(JSON.stringify(report)).byteLength > maxReportBytes && report.profiles.length > 0) {
|
|
1679
|
+
report.profiles.pop();
|
|
1680
|
+
report.droppedProfiles += 1;
|
|
1681
|
+
}
|
|
1682
|
+
if (new TextEncoder().encode(JSON.stringify(report)).byteLength > maxReportBytes) {
|
|
1683
|
+
throw new Error("The minimum client profile report exceeds maxReportBytes");
|
|
1684
|
+
}
|
|
1685
|
+
return finishReport(report, cleanupOwners);
|
|
1686
|
+
}
|
|
1687
|
+
function finishReport(report, cleanupOwners) {
|
|
1688
|
+
if (cleanupOwners.length) throw new McpFnClientProfileContractCleanupError(report, cleanupOwners);
|
|
1689
|
+
return report;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/conformance-process.ts
|
|
995
1693
|
var import_node_child_process = require("child_process");
|
|
1694
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
1695
|
+
async function terminateConformanceRunner(child, platform = process.platform, systemRoot = process.env.SystemRoot) {
|
|
1696
|
+
const killWrapper = () => {
|
|
1697
|
+
try {
|
|
1698
|
+
child.kill("SIGKILL");
|
|
1699
|
+
} catch {
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
try {
|
|
1703
|
+
if (platform === "win32" && child.pid) {
|
|
1704
|
+
if (!systemRoot || !import_node_path.default.win32.isAbsolute(systemRoot)) {
|
|
1705
|
+
killWrapper();
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
await new Promise((resolve) => {
|
|
1709
|
+
const timer = setTimeout(() => {
|
|
1710
|
+
killWrapper();
|
|
1711
|
+
resolve();
|
|
1712
|
+
}, 2500);
|
|
1713
|
+
const done = (error) => {
|
|
1714
|
+
clearTimeout(timer);
|
|
1715
|
+
if (error) killWrapper();
|
|
1716
|
+
resolve();
|
|
1717
|
+
};
|
|
1718
|
+
try {
|
|
1719
|
+
(0, import_node_child_process.execFile)(
|
|
1720
|
+
import_node_path.default.win32.join(systemRoot, "System32", "taskkill.exe"),
|
|
1721
|
+
["/pid", String(child.pid), "/T", "/F"],
|
|
1722
|
+
{ timeout: 2e3, windowsHide: true },
|
|
1723
|
+
done
|
|
1724
|
+
);
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
done(error);
|
|
1727
|
+
}
|
|
1728
|
+
});
|
|
1729
|
+
} else if (child.pid) {
|
|
1730
|
+
try {
|
|
1731
|
+
process.kill(-child.pid, "SIGKILL");
|
|
1732
|
+
} catch (error) {
|
|
1733
|
+
if (error.code !== "ESRCH") killWrapper();
|
|
1734
|
+
}
|
|
1735
|
+
} else killWrapper();
|
|
1736
|
+
} finally {
|
|
1737
|
+
child.stdout?.destroy();
|
|
1738
|
+
child.stderr?.destroy();
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// src/conformance.ts
|
|
1743
|
+
var import_node_child_process2 = require("child_process");
|
|
1744
|
+
var import_node_crypto3 = require("crypto");
|
|
996
1745
|
var import_node_fs = require("fs");
|
|
997
1746
|
var import_node_http2 = require("http");
|
|
998
1747
|
var import_node_https = require("https");
|
|
999
|
-
var
|
|
1748
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
1749
|
+
var import_oauth_core5 = require("@superfunctions/oauth-core");
|
|
1750
|
+
|
|
1751
|
+
// src/remote-target.ts
|
|
1752
|
+
var import_client3 = require("@mcpfn/client");
|
|
1753
|
+
var import_oauth_core3 = require("@superfunctions/oauth-core");
|
|
1754
|
+
var targetSecrets = /* @__PURE__ */ new WeakMap();
|
|
1755
|
+
var utf8Encoder = new TextEncoder();
|
|
1756
|
+
function beginTargetCredentialRedaction(target) {
|
|
1757
|
+
const state = targetSecrets.get(target);
|
|
1758
|
+
if (!state) return () => void 0;
|
|
1759
|
+
const scope = new Set(state.active.keys());
|
|
1760
|
+
state.scopes.add(scope);
|
|
1761
|
+
return () => {
|
|
1762
|
+
state.scopes.delete(scope);
|
|
1763
|
+
scope.clear();
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
function credentialValues(headers) {
|
|
1767
|
+
const values = boundedCredentialEntries(headers);
|
|
1768
|
+
const secrets = /* @__PURE__ */ new Set();
|
|
1769
|
+
for (const [header, raw] of values) {
|
|
1770
|
+
if (!raw.trim()) continue;
|
|
1771
|
+
secrets.add(raw);
|
|
1772
|
+
secrets.add(raw.trim());
|
|
1773
|
+
const normalizedHeader = header.toLowerCase();
|
|
1774
|
+
if (["authorization", "proxy-authorization"].includes(normalizedHeader)) {
|
|
1775
|
+
addAuthorizationSecrets(raw, secrets);
|
|
1776
|
+
}
|
|
1777
|
+
if (normalizedHeader === "cookie") addCookieSecrets(raw, secrets);
|
|
1778
|
+
}
|
|
1779
|
+
return secrets;
|
|
1780
|
+
}
|
|
1781
|
+
function addAuthorizationSecrets(raw, secrets) {
|
|
1782
|
+
const trimmed = raw.trim();
|
|
1783
|
+
const separator = trimmed.search(/\s/);
|
|
1784
|
+
if (separator <= 0) return;
|
|
1785
|
+
const scheme = trimmed.slice(0, separator).toLowerCase();
|
|
1786
|
+
const token = trimmed.slice(separator).trim();
|
|
1787
|
+
if (token) secrets.add(token);
|
|
1788
|
+
if (scheme !== "basic") {
|
|
1789
|
+
if (scheme !== "bearer" && !authorizationToken68.test(token)) {
|
|
1790
|
+
addAuthorizationParameterSecrets(token, secrets);
|
|
1791
|
+
}
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
addBasicAuthorizationSecrets(token, secrets);
|
|
1795
|
+
}
|
|
1796
|
+
function addBasicAuthorizationSecrets(token, secrets) {
|
|
1797
|
+
if (!/^[\dA-Za-z+/]+={0,2}$/.test(token)) return;
|
|
1798
|
+
const decoded = Buffer.from(token, "base64");
|
|
1799
|
+
if (stripBase64Padding(decoded.toString("base64")) !== stripBase64Padding(token)) return;
|
|
1800
|
+
for (const encoding of ["utf8", "latin1"]) {
|
|
1801
|
+
const pair = decoded.toString(encoding);
|
|
1802
|
+
const colon = pair.indexOf(":");
|
|
1803
|
+
if (colon < 0) continue;
|
|
1804
|
+
for (const value of [pair, pair.slice(0, colon), pair.slice(colon + 1)]) {
|
|
1805
|
+
if (value) secrets.add(value);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
var authorizationToken68 = /^[\dA-Za-z._~+\x2F-]+=*$/;
|
|
1810
|
+
var authorizationParameterName = /^[!#$%&'*+.^_`|~\dA-Za-z-]+$/;
|
|
1811
|
+
function skipAuthorizationWhitespace(token, cursor) {
|
|
1812
|
+
while (token[cursor] === " " || token[cursor] === " ") cursor += 1;
|
|
1813
|
+
return cursor;
|
|
1814
|
+
}
|
|
1815
|
+
function parseQuotedAuthorizationValue(token, start) {
|
|
1816
|
+
let cursor = start + 1;
|
|
1817
|
+
let decoded = "";
|
|
1818
|
+
while (cursor < token.length && token[cursor] !== '"') {
|
|
1819
|
+
if (token[cursor] === "\\") {
|
|
1820
|
+
cursor += 1;
|
|
1821
|
+
if (cursor >= token.length) {
|
|
1822
|
+
throw new TypeError("Authorization credential parameters must use balanced quotes");
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
decoded += token[cursor];
|
|
1826
|
+
cursor += 1;
|
|
1827
|
+
}
|
|
1828
|
+
if (token[cursor] !== '"') {
|
|
1829
|
+
throw new TypeError("Authorization credential parameters must use balanced quotes");
|
|
1830
|
+
}
|
|
1831
|
+
cursor += 1;
|
|
1832
|
+
const rawValue = token.slice(start, cursor);
|
|
1833
|
+
return {
|
|
1834
|
+
cursor,
|
|
1835
|
+
components: [rawValue, rawValue.slice(1, -1), decoded].filter(Boolean)
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
function parseUnquotedAuthorizationValue(token, start) {
|
|
1839
|
+
let cursor = start;
|
|
1840
|
+
while (cursor < token.length && token[cursor] !== ",") cursor += 1;
|
|
1841
|
+
const value = token.slice(start, cursor).trim();
|
|
1842
|
+
if (!value || /\s/.test(value) || value.includes('"')) {
|
|
1843
|
+
throw new TypeError("Authorization credential parameters must contain valid values");
|
|
1844
|
+
}
|
|
1845
|
+
return { cursor, components: [value] };
|
|
1846
|
+
}
|
|
1847
|
+
function parseAuthorizationParameter(token, start) {
|
|
1848
|
+
let cursor = skipAuthorizationWhitespace(token, start);
|
|
1849
|
+
const nameStart = cursor;
|
|
1850
|
+
while (cursor < token.length && token[cursor] !== "=" && token[cursor] !== " " && token[cursor] !== " " && token[cursor] !== ",") cursor += 1;
|
|
1851
|
+
const name = token.slice(nameStart, cursor);
|
|
1852
|
+
cursor = skipAuthorizationWhitespace(token, cursor);
|
|
1853
|
+
if (!authorizationParameterName.test(name) || token[cursor] !== "=") {
|
|
1854
|
+
throw new TypeError("Authorization credential parameters must use name=value syntax");
|
|
1855
|
+
}
|
|
1856
|
+
cursor = skipAuthorizationWhitespace(token, cursor + 1);
|
|
1857
|
+
if (cursor >= token.length) {
|
|
1858
|
+
throw new TypeError("Authorization credential parameters must contain values");
|
|
1859
|
+
}
|
|
1860
|
+
const parsed = token[cursor] === '"' ? parseQuotedAuthorizationValue(token, cursor) : parseUnquotedAuthorizationValue(token, cursor);
|
|
1861
|
+
return { ...parsed, cursor: skipAuthorizationWhitespace(token, parsed.cursor) };
|
|
1862
|
+
}
|
|
1863
|
+
function addAuthorizationParameterSecrets(token, secrets) {
|
|
1864
|
+
const firstEquals = token.indexOf("=");
|
|
1865
|
+
if (firstEquals < 1 || !authorizationParameterName.test(token.slice(0, firstEquals).trim())) return;
|
|
1866
|
+
let cursor = 0;
|
|
1867
|
+
while (cursor < token.length) {
|
|
1868
|
+
const parsed = parseAuthorizationParameter(token, cursor);
|
|
1869
|
+
for (const component of parsed.components) secrets.add(component);
|
|
1870
|
+
cursor = parsed.cursor;
|
|
1871
|
+
if (cursor === token.length) return;
|
|
1872
|
+
if (token[cursor] !== ",") {
|
|
1873
|
+
throw new TypeError("Authorization credential parameters must be comma separated");
|
|
1874
|
+
}
|
|
1875
|
+
cursor = skipAuthorizationWhitespace(token, cursor + 1);
|
|
1876
|
+
if (cursor === token.length) {
|
|
1877
|
+
throw new TypeError("Authorization credential parameters must not end with a comma");
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
function stripBase64Padding(value) {
|
|
1882
|
+
let end = value.length;
|
|
1883
|
+
while (end > 0 && value[end - 1] === "=") end -= 1;
|
|
1884
|
+
return value.slice(0, end);
|
|
1885
|
+
}
|
|
1886
|
+
function addCookieSecrets(raw, secrets) {
|
|
1887
|
+
for (const part of raw.split(/[;,]/)) {
|
|
1888
|
+
const pair = part.trim();
|
|
1889
|
+
if (!pair) continue;
|
|
1890
|
+
const equals = pair.indexOf("=");
|
|
1891
|
+
if (equals < 1) {
|
|
1892
|
+
throw new TypeError("Cookie credential headers must contain name=value pairs");
|
|
1893
|
+
}
|
|
1894
|
+
const value = pair.slice(equals + 1).trim();
|
|
1895
|
+
if (!value) continue;
|
|
1896
|
+
secrets.add(value);
|
|
1897
|
+
addUnquotedCookieSecret(value, secrets);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
function addUnquotedCookieSecret(value, secrets) {
|
|
1901
|
+
if (!value.startsWith('"') && !value.endsWith('"')) return;
|
|
1902
|
+
if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) {
|
|
1903
|
+
throw new TypeError("Cookie credential values must use balanced quotes");
|
|
1904
|
+
}
|
|
1905
|
+
const unquoted = value.slice(1, -1);
|
|
1906
|
+
if (unquoted.includes('"') || unquoted.includes("\\")) {
|
|
1907
|
+
throw new TypeError("Cookie credential values must use RFC 6265 syntax");
|
|
1908
|
+
}
|
|
1909
|
+
if (unquoted) secrets.add(unquoted);
|
|
1910
|
+
}
|
|
1911
|
+
var McpFnRedactionLimitError = class extends Error {
|
|
1912
|
+
constructor(message = "Credential redaction exceeded its traversal budget") {
|
|
1913
|
+
super(message);
|
|
1914
|
+
}
|
|
1915
|
+
};
|
|
1916
|
+
var McpFnStructuralCredentialCollisionError = class extends McpFnRedactionLimitError {
|
|
1917
|
+
constructor() {
|
|
1918
|
+
super("Credential collides with required artifact structure");
|
|
1919
|
+
}
|
|
1920
|
+
};
|
|
1921
|
+
var envelopeKeys = Object.fromEntries(Object.entries({
|
|
1922
|
+
root: "formatVersion kind artifactValidation status runtime ok target server capabilities manifestChecked manifestHash total passed failed incomplete droppedResults droppedObservedEvents redactionOmittedObservedEvents incompleteReason failure timeline droppedTimelineEvents results count clientState tools resources resourceTemplates prompts droppedEvents timelineComplete droppedInventoryEntries inventoryComplete suiteVersion exitCode stdout stderr phase outcome code requestId at details payload",
|
|
1923
|
+
result: "formatVersion name operation tool status sideEffect durationMs error droppedObservedEvents redactionOmittedObservedEvents",
|
|
1924
|
+
diagnostic: "phase outcome code requestId at target details",
|
|
1925
|
+
inspectorEvent: "formatVersion source kind at event",
|
|
1926
|
+
failure: "name message layer code phase details",
|
|
1927
|
+
runtime: "node scenarioFormatVersion reportSchemaVersion packages",
|
|
1928
|
+
packages: "testing",
|
|
1929
|
+
droppedInventoryEntries: "tools resources resourceTemplates prompts",
|
|
1930
|
+
target: "kind"
|
|
1931
|
+
}).map(([role, keys]) => [role, new Set(keys.split(" "))]));
|
|
1932
|
+
var structuralArtifactValues = new Map([
|
|
1933
|
+
["root:kind", /* @__PURE__ */ new Set(["logging.message", "progress", "tasks.status", "resources.updated", "tools.list_changed", "resources.list_changed", "prompts.list_changed", "resources.subscribed", "resources.unsubscribed", "client.roots", "client.sampling", "client.elicitation", "mcpfn.target-suite-report", "mcpfn.inspector-snapshot", "mcpfn.official-conformance-report"])],
|
|
1934
|
+
["inspectorEvent:kind", /* @__PURE__ */ new Set(["logging.message", "progress", "tasks.status", "resources.updated", "tools.list_changed", "resources.list_changed", "prompts.list_changed", "resources.subscribed", "resources.unsubscribed", "client.roots", "client.sampling", "client.elicitation"])],
|
|
1935
|
+
["root:clientState", /* @__PURE__ */ new Set(["idle", "connecting", "authorization-required", "connected", "closing", "closed"])],
|
|
1936
|
+
["target:kind", /* @__PURE__ */ new Set(["authenticated-streamable-http", "streamable-http", "stdio", "in-memory", "custom"])],
|
|
1937
|
+
["root:status", /* @__PURE__ */ new Set(["passed", "failed", "incomplete", "complete"])],
|
|
1938
|
+
["result:status", /* @__PURE__ */ new Set(["passed", "failed", "incomplete", "complete"])],
|
|
1939
|
+
["root:outcome", /* @__PURE__ */ new Set(["started", "succeeded", "failed"])],
|
|
1940
|
+
["diagnostic:outcome", /* @__PURE__ */ new Set(["started", "succeeded", "failed"])],
|
|
1941
|
+
...["root", "diagnostic", "failure"].map((role) => [
|
|
1942
|
+
`${role}:phase`,
|
|
1943
|
+
/* @__PURE__ */ new Set(["resource-discovery", "authorization-server-discovery", "client-registration", "authorization-request", "authorization-callback", "token-exchange", "token-refresh", "token-revocation", "transport-connect", "mcp-initialize", "capability-operation", "transport-close"])
|
|
1944
|
+
]),
|
|
1945
|
+
["failure:layer", /* @__PURE__ */ new Set(["mcpfn-preflight", "authorization-server", "resource-server", "mcp-initialization", "scenario", "upstream-conformance"])],
|
|
1946
|
+
["inspectorEvent:source", /* @__PURE__ */ new Set(["diagnostic", "client"])],
|
|
1947
|
+
["result:sideEffect", /* @__PURE__ */ new Set(["none", "idempotent", "non-idempotent"])]
|
|
1948
|
+
]);
|
|
1949
|
+
var unrestrictedStructuralValueFields = /* @__PURE__ */ new Set([
|
|
1950
|
+
"packages:testing",
|
|
1951
|
+
"runtime:node",
|
|
1952
|
+
"runtime:reportSchemaVersion",
|
|
1953
|
+
"root:suiteVersion"
|
|
1954
|
+
]);
|
|
1955
|
+
function childEnvelopeRole(fixed, key, role, input, rootKind) {
|
|
1956
|
+
if (!fixed) return "payload";
|
|
1957
|
+
if (key === "results") return "result";
|
|
1958
|
+
if (key === "timeline") {
|
|
1959
|
+
return rootKind === "mcpfn.inspector-snapshot" ? "inspectorEvent" : "diagnostic";
|
|
1960
|
+
}
|
|
1961
|
+
if (["failure", "runtime", "packages", "droppedInventoryEntries", "target"].includes(key)) {
|
|
1962
|
+
return key;
|
|
1963
|
+
}
|
|
1964
|
+
if (role === "inspectorEvent" && key === "event") {
|
|
1965
|
+
return input.source === "client" ? "root" : "diagnostic";
|
|
1966
|
+
}
|
|
1967
|
+
return "payload";
|
|
1968
|
+
}
|
|
1969
|
+
function specialValue(input) {
|
|
1970
|
+
if (input instanceof Error) return normalizeErrorValue(input);
|
|
1971
|
+
if (input instanceof Date) return Number.isNaN(input.getTime()) ? "Invalid Date" : input.toISOString();
|
|
1972
|
+
if (input instanceof URL) return input.href;
|
|
1973
|
+
if (input instanceof Map) {
|
|
1974
|
+
if (Object.getOwnPropertyDescriptor(Map.prototype, "size").get.call(input) > 1e5) throw new McpFnRedactionLimitError();
|
|
1975
|
+
return { type: "Map", entries: [...Map.prototype.entries.call(input)] };
|
|
1976
|
+
}
|
|
1977
|
+
if (input instanceof Set) {
|
|
1978
|
+
if (Object.getOwnPropertyDescriptor(Set.prototype, "size").get.call(input) > 1e5) throw new McpFnRedactionLimitError();
|
|
1979
|
+
return { type: "Set", values: [...Set.prototype.values.call(input)] };
|
|
1980
|
+
}
|
|
1981
|
+
return input;
|
|
1982
|
+
}
|
|
1983
|
+
function normalizeErrorValue(input) {
|
|
1984
|
+
const normalized = /* @__PURE__ */ Object.create(null);
|
|
1985
|
+
let enumerableFields = 0;
|
|
1986
|
+
for (const key in input) {
|
|
1987
|
+
if (!Object.hasOwn(input, key)) continue;
|
|
1988
|
+
if (++enumerableFields > 1e5) throw new McpFnRedactionLimitError();
|
|
1989
|
+
normalized[key] = input[key];
|
|
1990
|
+
}
|
|
1991
|
+
normalized.name = input.name;
|
|
1992
|
+
normalized.message = input.message;
|
|
1993
|
+
normalized.stack = input.stack;
|
|
1994
|
+
if (input.cause !== void 0) normalized.cause = input.cause;
|
|
1995
|
+
return normalized;
|
|
1996
|
+
}
|
|
1997
|
+
function credentialPatterns(values) {
|
|
1998
|
+
const variants = [...values].filter(Boolean).flatMap((secret) => [
|
|
1999
|
+
{ value: secret, encoded: false },
|
|
2000
|
+
{ value: JSON.stringify(secret).slice(1, -1), encoded: false },
|
|
2001
|
+
{ value: encodeURIComponent(secret), encoded: true },
|
|
2002
|
+
{ value: new URLSearchParams({ value: secret }).toString().slice("value=".length), encoded: true }
|
|
2003
|
+
]).sort((a, b) => b.value.length - a.value.length);
|
|
2004
|
+
const patterns = variants.map(({ value, encoded }) => {
|
|
2005
|
+
const literal = value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
2006
|
+
return encoded ? literal.replace(/%[\dA-Fa-f]{2}/g, (escape) => escape.replace(/[A-Fa-f]/g, (hex) => `[${hex.toLowerCase()}${hex.toUpperCase()}]`)) : literal;
|
|
2007
|
+
});
|
|
2008
|
+
return {
|
|
2009
|
+
secretPattern: patterns.length ? new RegExp(patterns.join("|"), "g") : void 0,
|
|
2010
|
+
replacementPattern: patterns.length ? new RegExp(patterns.join("|")) : void 0
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
function targetCredentialValues(target) {
|
|
2014
|
+
const state = targetSecrets.get(target);
|
|
2015
|
+
return /* @__PURE__ */ new Set([
|
|
2016
|
+
...state?.active.keys() ?? [],
|
|
2017
|
+
...[...state?.scopes ?? []].flatMap((scope) => [...scope])
|
|
2018
|
+
]);
|
|
2019
|
+
}
|
|
2020
|
+
function scrubCredentials(value, values, preserveKeys = false, redactionMarker) {
|
|
2021
|
+
let entries = 0, stringBytes = 0;
|
|
2022
|
+
const budget = (input, depth = 0) => {
|
|
2023
|
+
input = specialValue(input);
|
|
2024
|
+
if (Array.isArray(input) && input.length > 1e5 || ++entries > 1e5 || depth > 32) throw new McpFnRedactionLimitError();
|
|
2025
|
+
if (typeof input === "string") {
|
|
2026
|
+
stringBytes += Buffer.byteLength(input);
|
|
2027
|
+
if (input.length > 262144 || stringBytes > 2097152) throw new McpFnRedactionLimitError();
|
|
2028
|
+
} else if (input && typeof input === "object") {
|
|
2029
|
+
for (const key in input) if (Object.hasOwn(input, key)) {
|
|
2030
|
+
budget(key, depth + 1);
|
|
2031
|
+
budget(input[key], depth + 1);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
};
|
|
2035
|
+
budget(value);
|
|
2036
|
+
const { secretPattern, replacementPattern } = credentialPatterns(values);
|
|
2037
|
+
const preserveStructural = (input) => {
|
|
2038
|
+
if (replacementPattern?.test(input)) {
|
|
2039
|
+
throw new McpFnRedactionLimitError(
|
|
2040
|
+
"Credential collides with a required structural artifact field"
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
return input;
|
|
2044
|
+
};
|
|
2045
|
+
const preserveStructuralKey = (input) => {
|
|
2046
|
+
if (replacementPattern?.test(input)) {
|
|
2047
|
+
throw new McpFnStructuralCredentialCollisionError();
|
|
2048
|
+
}
|
|
2049
|
+
return input;
|
|
2050
|
+
};
|
|
2051
|
+
if (secretPattern) {
|
|
2052
|
+
const markerContainsSecret = secretPattern.test(redactionMarker ?? "[REDACTED]");
|
|
2053
|
+
secretPattern.lastIndex = 0;
|
|
2054
|
+
if (markerContainsSecret) redactionMarker = "";
|
|
2055
|
+
}
|
|
2056
|
+
const scrubString = (input, role2, field, finalPass) => {
|
|
2057
|
+
const structuralField = `${role2}:${field}`;
|
|
2058
|
+
if (unrestrictedStructuralValueFields.has(structuralField) || structuralArtifactValues.get(structuralField)?.has(input)) {
|
|
2059
|
+
return preserveStructural(input);
|
|
2060
|
+
}
|
|
2061
|
+
if (finalPass) return replacementPattern?.test(input) ? "" : input;
|
|
2062
|
+
if (!secretPattern) return input;
|
|
2063
|
+
return input.replace(secretPattern, (secret) => {
|
|
2064
|
+
const maskCharacter = secret.includes("*") ? "#" : "*";
|
|
2065
|
+
const defaultReplacement = secret.length < 10 ? maskCharacter.repeat(secret.length) : "[REDACTED]";
|
|
2066
|
+
const replacement = redactionMarker ?? defaultReplacement;
|
|
2067
|
+
return replacementPattern.test(replacement) ? "" : replacement;
|
|
2068
|
+
});
|
|
2069
|
+
};
|
|
2070
|
+
function scrubArray(input, role2, finalPass) {
|
|
2071
|
+
const result2 = input.map((entry) => scrub(entry, role2, "", finalPass));
|
|
2072
|
+
assertPayloadSerialization(result2, role2, finalPass, replacementPattern);
|
|
2073
|
+
return result2;
|
|
2074
|
+
}
|
|
2075
|
+
function scrubObject(input, role2, finalPass) {
|
|
2076
|
+
const entries2 = [];
|
|
2077
|
+
const keys = /* @__PURE__ */ new Set();
|
|
2078
|
+
for (const [key, entry] of Object.entries(input)) {
|
|
2079
|
+
const fixed = envelopeKeys[role2]?.has(key) ?? false;
|
|
2080
|
+
if (fixed) preserveStructuralKey(key);
|
|
2081
|
+
const childRole = childEnvelopeRole(
|
|
2082
|
+
fixed,
|
|
2083
|
+
key,
|
|
2084
|
+
role2,
|
|
2085
|
+
input,
|
|
2086
|
+
value?.kind
|
|
2087
|
+
);
|
|
2088
|
+
const scrubbedKey = fixed ? key : scrub(key, "payload", "", finalPass);
|
|
2089
|
+
if (keys.has(scrubbedKey)) {
|
|
2090
|
+
throw new McpFnRedactionLimitError(
|
|
2091
|
+
"Credential redaction created duplicate object keys"
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
keys.add(scrubbedKey);
|
|
2095
|
+
const entryRole = fixed ? role2 : "payload";
|
|
2096
|
+
const scrubbedEntry = typeof entry === "string" ? scrub(entry, entryRole, key, finalPass) : scrub(entry, childRole, "", finalPass);
|
|
2097
|
+
entries2.push([scrubbedKey, scrubbedEntry]);
|
|
2098
|
+
}
|
|
2099
|
+
const result2 = Object.fromEntries(entries2);
|
|
2100
|
+
assertPayloadSerialization(result2, role2, finalPass, replacementPattern);
|
|
2101
|
+
return result2;
|
|
2102
|
+
}
|
|
2103
|
+
function scrub(input, role2 = "payload", field = "", finalPass = false) {
|
|
2104
|
+
input = specialValue(input);
|
|
2105
|
+
if (typeof input === "string") return scrubString(input, role2, field, finalPass);
|
|
2106
|
+
if (Array.isArray(input)) return scrubArray(input, role2, finalPass);
|
|
2107
|
+
if (input && typeof input === "object") {
|
|
2108
|
+
return scrubObject(input, role2, finalPass);
|
|
2109
|
+
}
|
|
2110
|
+
return input;
|
|
2111
|
+
}
|
|
2112
|
+
const timelineEvent = value && typeof value === "object" && ["client", "diagnostic"].includes(value.source ?? "");
|
|
2113
|
+
let role = "payload";
|
|
2114
|
+
if (preserveKeys) role = timelineEvent ? "inspectorEvent" : "root";
|
|
2115
|
+
const scrubbed = scrub(value, role);
|
|
2116
|
+
const generic = (0, import_oauth_core3.redactOAuthValue)(scrubbed, { maxStringLength: 262144, maxDepth: 64, maxArrayEntries: 1e5, maxObjectEntries: 1e5, ...redactionMarker !== void 0 ? { redactionMarker } : {} });
|
|
2117
|
+
const result = scrub(generic, role, "", true);
|
|
2118
|
+
assertPayloadSerialization(
|
|
2119
|
+
result,
|
|
2120
|
+
"payload",
|
|
2121
|
+
true,
|
|
2122
|
+
replacementPattern
|
|
2123
|
+
);
|
|
2124
|
+
return result;
|
|
2125
|
+
}
|
|
2126
|
+
function assertPayloadSerialization(value, role, finalPass, credentialPattern) {
|
|
2127
|
+
if (!finalPass || role !== "payload" || !credentialPattern) return;
|
|
2128
|
+
const serialized = JSON.stringify(value);
|
|
2129
|
+
if (serialized !== void 0 && credentialPattern.test(serialized)) {
|
|
2130
|
+
throw new McpFnRedactionLimitError(
|
|
2131
|
+
"Credential redaction could not guarantee safe serialized output"
|
|
2132
|
+
);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
function redactTargetCredentials(target, value, options = {}) {
|
|
2136
|
+
return scrubCredentials(
|
|
2137
|
+
value,
|
|
2138
|
+
targetCredentialValues(target),
|
|
2139
|
+
options.preserveKeys,
|
|
2140
|
+
options.redactionMarker
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
function assertTargetArtifactSafe(target, value) {
|
|
2144
|
+
const { replacementPattern } = credentialPatterns(targetCredentialValues(target));
|
|
2145
|
+
if (replacementPattern?.test(value)) {
|
|
2146
|
+
throw new McpFnRedactionLimitError(
|
|
2147
|
+
"Credential redaction could not guarantee safe serialized output"
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
function redactRemoteCredential(credential, value, options = {}) {
|
|
2152
|
+
return scrubCredentials(value, credentialValues(credential.headers), options.preserveKeys);
|
|
2153
|
+
}
|
|
2154
|
+
var MAX_CREDENTIAL_HEADERS = 32;
|
|
2155
|
+
var MAX_CREDENTIAL_HEADER_BYTES = 16384;
|
|
2156
|
+
var MAX_CREDENTIAL_HEADER_VALUE_BYTES = 8192;
|
|
2157
|
+
var FORBIDDEN_CREDENTIAL_HEADERS = /* @__PURE__ */ new Set([
|
|
2158
|
+
"connection",
|
|
2159
|
+
"content-length",
|
|
2160
|
+
"host",
|
|
2161
|
+
"proxy-connection",
|
|
2162
|
+
"te",
|
|
2163
|
+
"trailer",
|
|
2164
|
+
"transfer-encoding",
|
|
2165
|
+
"upgrade"
|
|
2166
|
+
]);
|
|
2167
|
+
function staticRemoteCredentialProvider(credential) {
|
|
2168
|
+
return { acquire: () => credential };
|
|
2169
|
+
}
|
|
2170
|
+
async function acquireRemoteCredential(source, context) {
|
|
2171
|
+
let provider;
|
|
2172
|
+
let acquired;
|
|
2173
|
+
try {
|
|
2174
|
+
provider = isCredentialProvider(source) ? source : staticRemoteCredentialProvider(source);
|
|
2175
|
+
acquired = await provider.acquire(context);
|
|
2176
|
+
} catch {
|
|
2177
|
+
throw new Error("Target credential acquisition failed");
|
|
2178
|
+
}
|
|
2179
|
+
let credential = acquired;
|
|
2180
|
+
try {
|
|
2181
|
+
credential = { ...acquired, headers: boundedCredentialEntries(acquired.headers) };
|
|
2182
|
+
} catch {
|
|
2183
|
+
}
|
|
2184
|
+
let releasePromise;
|
|
2185
|
+
let revoked = false;
|
|
2186
|
+
let disposed = false;
|
|
2187
|
+
let cleanupPhase = credential.kind === "oauth" ? "token-revocation" : "transport-close";
|
|
2188
|
+
return {
|
|
2189
|
+
credential,
|
|
2190
|
+
cleanupPhase: () => cleanupPhase,
|
|
2191
|
+
release() {
|
|
2192
|
+
releasePromise ??= (async () => {
|
|
2193
|
+
const cleanupContext = { ...context, signal: new AbortController().signal };
|
|
2194
|
+
try {
|
|
2195
|
+
if (!revoked) {
|
|
2196
|
+
cleanupPhase = credential.kind === "oauth" ? "token-revocation" : "transport-close";
|
|
2197
|
+
await provider.revoke?.(acquired, cleanupContext);
|
|
2198
|
+
revoked = true;
|
|
2199
|
+
}
|
|
2200
|
+
if (!disposed) {
|
|
2201
|
+
cleanupPhase = "transport-close";
|
|
2202
|
+
await provider.dispose?.(acquired, cleanupContext);
|
|
2203
|
+
disposed = true;
|
|
2204
|
+
}
|
|
2205
|
+
} catch {
|
|
2206
|
+
throw new import_client3.McpFnClientError(
|
|
2207
|
+
"MCPFN_OPERATION_FAILED",
|
|
2208
|
+
"Target credential cleanup failed",
|
|
2209
|
+
{ phase: cleanupPhase, retryable: true }
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
2212
|
+
})().catch((error) => {
|
|
2213
|
+
releasePromise = void 0;
|
|
2214
|
+
throw error;
|
|
2215
|
+
});
|
|
2216
|
+
return releasePromise;
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
function authenticatedHttpTarget(url, options) {
|
|
2221
|
+
const targetUrl = normalizeRemoteTargetUrl(url);
|
|
2222
|
+
const descriptorUrl = new URL(targetUrl);
|
|
2223
|
+
descriptorUrl.search = "";
|
|
2224
|
+
const { credential: _credential, requestInit, ...transportOptions } = options;
|
|
2225
|
+
const state = { active: /* @__PURE__ */ new Map(), scopes: /* @__PURE__ */ new Set() };
|
|
2226
|
+
const pendingReleases = /* @__PURE__ */ new Set();
|
|
2227
|
+
const authenticated = (0, import_client3.customTarget)({
|
|
2228
|
+
kind: "authenticated-streamable-http",
|
|
2229
|
+
descriptor: {
|
|
2230
|
+
url: descriptorUrl.toString(),
|
|
2231
|
+
authenticated: true
|
|
2232
|
+
},
|
|
2233
|
+
redact: (value, redaction) => redactTargetCredentials(authenticated, value, { preserveKeys: true, ...redaction }),
|
|
2234
|
+
assertArtifactSafe: (value) => assertTargetArtifactSafe(authenticated, value),
|
|
2235
|
+
beginRedactionScope: () => beginTargetCredentialRedaction(authenticated),
|
|
2236
|
+
async cleanup() {
|
|
2237
|
+
const results = await Promise.allSettled([...pendingReleases].map((release) => release()));
|
|
2238
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
2239
|
+
const failure = failures.find(({ reason }) => reason && typeof reason === "object" && reason.phase === "token-revocation") ?? failures[0];
|
|
2240
|
+
if (failure) throw failure.reason;
|
|
2241
|
+
},
|
|
2242
|
+
async open(targetContext) {
|
|
2243
|
+
const context = {
|
|
2244
|
+
url: targetUrl.toString(),
|
|
2245
|
+
requestId: targetContext.requestId,
|
|
2246
|
+
signal: targetContext.signal
|
|
2247
|
+
};
|
|
2248
|
+
const lease = await acquireRemoteCredential(options.credential, context);
|
|
2249
|
+
const secrets = /* @__PURE__ */ new Set();
|
|
2250
|
+
const release = async () => {
|
|
2251
|
+
try {
|
|
2252
|
+
await lease.release();
|
|
2253
|
+
} catch (error) {
|
|
2254
|
+
pendingReleases.add(release);
|
|
2255
|
+
throw error;
|
|
2256
|
+
}
|
|
2257
|
+
pendingReleases.delete(release);
|
|
2258
|
+
for (const secret of secrets) {
|
|
2259
|
+
const count = (state.active.get(secret) ?? 1) - 1;
|
|
2260
|
+
if (count) state.active.set(secret, count);
|
|
2261
|
+
else state.active.delete(secret);
|
|
2262
|
+
}
|
|
2263
|
+
secrets.clear();
|
|
2264
|
+
};
|
|
2265
|
+
let handle;
|
|
2266
|
+
try {
|
|
2267
|
+
for (const secret of credentialValues(lease.credential.headers)) {
|
|
2268
|
+
secrets.add(secret);
|
|
2269
|
+
state.active.set(secret, (state.active.get(secret) ?? 0) + 1);
|
|
2270
|
+
for (const scope of state.scopes) scope.add(secret);
|
|
2271
|
+
}
|
|
2272
|
+
const credentialHeaders = validateRemoteCredentialHeaders(lease.credential.headers);
|
|
2273
|
+
const headers = new Headers(requestInit?.headers);
|
|
2274
|
+
credentialHeaders.forEach((value, name) => headers.set(name, value));
|
|
2275
|
+
const target = (0, import_client3.streamableHttpTarget)(targetUrl, {
|
|
2276
|
+
...transportOptions,
|
|
2277
|
+
requestInit: {
|
|
2278
|
+
...requestInit,
|
|
2279
|
+
headers,
|
|
2280
|
+
// Fetch must not replay credentials to a redirect destination.
|
|
2281
|
+
redirect: "error"
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
handle = await target.open(targetContext);
|
|
2285
|
+
} catch (error) {
|
|
2286
|
+
let message = "Authenticated target could not be opened";
|
|
2287
|
+
try {
|
|
2288
|
+
message = redactRemoteCredential(lease.credential, error instanceof Error ? error.message : String(error));
|
|
2289
|
+
} catch {
|
|
2290
|
+
}
|
|
2291
|
+
pendingReleases.add(release);
|
|
2292
|
+
await release();
|
|
2293
|
+
throw new Error(message);
|
|
2294
|
+
}
|
|
2295
|
+
let closePromise;
|
|
2296
|
+
let transportClosed = false;
|
|
2297
|
+
return {
|
|
2298
|
+
transport: handle.transport,
|
|
2299
|
+
finishAuthorization: handle.finishAuthorization,
|
|
2300
|
+
terminateSession: handle.terminateSession,
|
|
2301
|
+
close() {
|
|
2302
|
+
closePromise ??= Promise.resolve().then(async () => {
|
|
2303
|
+
if (!transportClosed) {
|
|
2304
|
+
if (handle.close) await handle.close();
|
|
2305
|
+
else await handle.transport.close();
|
|
2306
|
+
transportClosed = true;
|
|
2307
|
+
}
|
|
2308
|
+
await release();
|
|
2309
|
+
}).catch(async (error) => {
|
|
2310
|
+
closePromise = void 0;
|
|
2311
|
+
const phase = transportClosed ? lease.cleanupPhase() : "transport-close";
|
|
2312
|
+
await targetContext.diagnostic({
|
|
2313
|
+
phase,
|
|
2314
|
+
outcome: "failed",
|
|
2315
|
+
code: "MCPFN_CREDENTIAL_CLEANUP_FAILED",
|
|
2316
|
+
requestId: targetContext.requestId,
|
|
2317
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2318
|
+
target: { kind: "authenticated-streamable-http", url: descriptorUrl.toString() },
|
|
2319
|
+
details: { message: "Target credential cleanup failed" }
|
|
2320
|
+
});
|
|
2321
|
+
throw error;
|
|
2322
|
+
});
|
|
2323
|
+
return closePromise;
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
});
|
|
2328
|
+
targetSecrets.set(authenticated, state);
|
|
2329
|
+
return authenticated;
|
|
2330
|
+
}
|
|
2331
|
+
function normalizeRemoteTargetUrl(value) {
|
|
2332
|
+
const url = new URL(value.toString());
|
|
2333
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2334
|
+
throw new TypeError("Authenticated MCP targets must use HTTP or HTTPS");
|
|
2335
|
+
}
|
|
2336
|
+
if (url.protocol === "http:" && url.hostname !== "[::1]" && !/^127(?:\.\d{1,3}){3}$/.test(url.hostname)) {
|
|
2337
|
+
throw new TypeError("Credential-bearing HTTP targets require a literal loopback address; use HTTPS remotely");
|
|
2338
|
+
}
|
|
2339
|
+
if (url.username || url.password || url.hash) {
|
|
2340
|
+
throw new TypeError(
|
|
2341
|
+
"Authenticated MCP target URLs must not contain userinfo or a fragment"
|
|
2342
|
+
);
|
|
2343
|
+
}
|
|
2344
|
+
return url;
|
|
2345
|
+
}
|
|
2346
|
+
function isCredentialProvider(value) {
|
|
2347
|
+
return "acquire" in value && typeof value.acquire === "function";
|
|
2348
|
+
}
|
|
2349
|
+
function boundedCredentialEntries(value) {
|
|
2350
|
+
const entries = [];
|
|
2351
|
+
let bytes2 = 0;
|
|
2352
|
+
const append = (name, headerValue) => {
|
|
2353
|
+
if (entries.length >= MAX_CREDENTIAL_HEADERS) throw new TypeError("Credential headers exceed the header limit");
|
|
2354
|
+
if (typeof name !== "string" || typeof headerValue !== "string") throw new TypeError("Credential header values must be strings");
|
|
2355
|
+
if (name.length > MAX_CREDENTIAL_HEADER_BYTES || headerValue.length > MAX_CREDENTIAL_HEADER_VALUE_BYTES) throw new TypeError("Credential header exceeds the value-size limit");
|
|
2356
|
+
if (utf8Encoder.encode(headerValue).byteLength > MAX_CREDENTIAL_HEADER_VALUE_BYTES) throw new TypeError("Credential header exceeds the value-size limit");
|
|
2357
|
+
bytes2 += utf8Encoder.encode(`${name}: ${headerValue}\r
|
|
2358
|
+
`).byteLength;
|
|
2359
|
+
if (bytes2 > MAX_CREDENTIAL_HEADER_BYTES) throw new TypeError("Credential headers exceed the aggregate size limit");
|
|
2360
|
+
entries.push([name, headerValue]);
|
|
2361
|
+
};
|
|
2362
|
+
if (value instanceof Headers) value.forEach((v, k) => append(k, v));
|
|
2363
|
+
else if (Array.isArray(value)) {
|
|
2364
|
+
if (value.length > MAX_CREDENTIAL_HEADERS) throw new TypeError("Credential headers exceed the header limit");
|
|
2365
|
+
for (const entry of value) append(entry[0], entry[1]);
|
|
2366
|
+
} else {
|
|
2367
|
+
for (const name in value) if (Object.hasOwn(value, name)) append(name, value[name]);
|
|
2368
|
+
}
|
|
2369
|
+
return entries;
|
|
2370
|
+
}
|
|
2371
|
+
function validateRemoteCredentialHeaders(value) {
|
|
2372
|
+
const headers = new Headers(boundedCredentialEntries(value));
|
|
2373
|
+
const entries = [];
|
|
2374
|
+
headers.forEach((headerValue, name) => entries.push([name, headerValue]));
|
|
2375
|
+
if (entries.length === 0) {
|
|
2376
|
+
throw new TypeError("Authenticated MCP targets require at least one credential header");
|
|
2377
|
+
}
|
|
2378
|
+
if (entries.length > MAX_CREDENTIAL_HEADERS) {
|
|
2379
|
+
throw new TypeError(`Credential headers exceed the ${MAX_CREDENTIAL_HEADERS}-header limit`);
|
|
2380
|
+
}
|
|
2381
|
+
let bytes2 = 0;
|
|
2382
|
+
for (const [name, headerValue] of entries) {
|
|
2383
|
+
if (!headerValue.trim()) throw new TypeError("Credential header values must not be blank");
|
|
2384
|
+
if (FORBIDDEN_CREDENTIAL_HEADERS.has(name)) {
|
|
2385
|
+
throw new TypeError(`Credential header ${name} is not allowed`);
|
|
2386
|
+
}
|
|
2387
|
+
const valueBytes = utf8Encoder.encode(headerValue).byteLength;
|
|
2388
|
+
if (valueBytes > MAX_CREDENTIAL_HEADER_VALUE_BYTES) {
|
|
2389
|
+
throw new TypeError(`Credential header ${name} exceeds the value-size limit`);
|
|
2390
|
+
}
|
|
2391
|
+
bytes2 += utf8Encoder.encode(`${name}: ${headerValue}\r
|
|
2392
|
+
`).byteLength;
|
|
2393
|
+
}
|
|
2394
|
+
if (bytes2 > MAX_CREDENTIAL_HEADER_BYTES) {
|
|
2395
|
+
throw new TypeError("Credential headers exceed the aggregate size limit");
|
|
2396
|
+
}
|
|
2397
|
+
return headers;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// src/reports.ts
|
|
2401
|
+
var import_oauth_core4 = require("@superfunctions/oauth-core");
|
|
2402
|
+
|
|
2403
|
+
// src/artifact-guards.ts
|
|
2404
|
+
var import_client4 = require("@mcpfn/client");
|
|
2405
|
+
var targetSuiteArtifactGuards = /* @__PURE__ */ new WeakMap();
|
|
2406
|
+
var targetSuiteArtifactFinalizer = new FinalizationRegistry((release) => {
|
|
2407
|
+
try {
|
|
2408
|
+
release();
|
|
2409
|
+
} catch {
|
|
2410
|
+
}
|
|
2411
|
+
});
|
|
2412
|
+
function registerMcpFnTargetSuiteArtifactGuard(report, validate, release, maxBytes) {
|
|
2413
|
+
const previous = targetSuiteArtifactGuards.get(report);
|
|
2414
|
+
if (previous) {
|
|
2415
|
+
targetSuiteArtifactFinalizer.unregister(report);
|
|
2416
|
+
targetSuiteArtifactGuards.delete(report);
|
|
2417
|
+
previous.release();
|
|
2418
|
+
}
|
|
2419
|
+
let active = true;
|
|
2420
|
+
const releaseOnce = () => {
|
|
2421
|
+
if (!active) return;
|
|
2422
|
+
active = false;
|
|
2423
|
+
release();
|
|
2424
|
+
};
|
|
2425
|
+
targetSuiteArtifactGuards.set(report, { maxBytes, validate, release: releaseOnce });
|
|
2426
|
+
targetSuiteArtifactFinalizer.register(report, releaseOnce, report);
|
|
2427
|
+
}
|
|
2428
|
+
function disposeMcpFnTargetSuiteArtifactGuard(report) {
|
|
2429
|
+
const guard = targetSuiteArtifactGuards.get(report);
|
|
2430
|
+
if (!guard) return;
|
|
2431
|
+
targetSuiteArtifactGuards.delete(report);
|
|
2432
|
+
targetSuiteArtifactFinalizer.unregister(report);
|
|
2433
|
+
guard.release();
|
|
2434
|
+
}
|
|
2435
|
+
function preserveMcpFnTargetSuiteArtifact(report, artifact) {
|
|
2436
|
+
const guard = targetSuiteArtifactGuards.get(report);
|
|
2437
|
+
if (!guard) {
|
|
2438
|
+
if (report.artifactValidation === "target-aware") {
|
|
2439
|
+
throw new import_client4.McpFnClientError(
|
|
2440
|
+
"MCPFN_OPERATION_FAILED",
|
|
2441
|
+
"MCP target artifact proof is unavailable",
|
|
2442
|
+
{ phase: "capability-operation" }
|
|
2443
|
+
);
|
|
2444
|
+
}
|
|
2445
|
+
return artifact;
|
|
2446
|
+
}
|
|
2447
|
+
if (new TextEncoder().encode(artifact).byteLength > guard.maxBytes) {
|
|
2448
|
+
throw new Error("Serialized target suite artifact exceeds maxReportBytes");
|
|
2449
|
+
}
|
|
2450
|
+
try {
|
|
2451
|
+
if (!guard.validate(artifact)) throw new Error("unsafe serialized artifact");
|
|
2452
|
+
return artifact;
|
|
2453
|
+
} catch {
|
|
2454
|
+
throw new import_client4.McpFnClientError(
|
|
2455
|
+
"MCPFN_OPERATION_FAILED",
|
|
2456
|
+
"MCP artifact structure conflicts with credential redaction",
|
|
2457
|
+
{ phase: "capability-operation" }
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
// src/reports.ts
|
|
2463
|
+
var MCPFN_TESTING_VERSION = "0.0.6";
|
|
2464
|
+
var MCPFN_REPORT_SCHEMA_VERSION = "1.0.0";
|
|
2465
|
+
function serializeMcpFnTargetSuiteReport(report, options = {}) {
|
|
2466
|
+
const space = options.space ?? 0;
|
|
2467
|
+
if (!Number.isInteger(space) || space < 0 || space > 10) {
|
|
2468
|
+
throw new TypeError("space must be an integer from 0 through 10");
|
|
2469
|
+
}
|
|
2470
|
+
const serialized = JSON.stringify(report, null, space) + (options.trailingNewline ? "\n" : "");
|
|
2471
|
+
return preserveMcpFnTargetSuiteArtifact(report, serialized);
|
|
2472
|
+
}
|
|
2473
|
+
function disposeMcpFnTargetSuiteReport(report) {
|
|
2474
|
+
disposeMcpFnTargetSuiteArtifactGuard(report);
|
|
2475
|
+
}
|
|
2476
|
+
function objectRecord(value) {
|
|
2477
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
2478
|
+
}
|
|
2479
|
+
function redactedFailureCause(error) {
|
|
2480
|
+
if (!error || typeof error !== "object") return void 0;
|
|
2481
|
+
const cause = error.cause;
|
|
2482
|
+
if (cause === void 0) return void 0;
|
|
2483
|
+
return objectRecord((0, import_oauth_core4.redactOAuthValue)(cause, {
|
|
2484
|
+
maxDepth: 4,
|
|
2485
|
+
maxArrayEntries: 20,
|
|
2486
|
+
maxObjectEntries: 20,
|
|
2487
|
+
maxStringLength: 1024
|
|
2488
|
+
}));
|
|
2489
|
+
}
|
|
2490
|
+
function resourceDenied(layer, record, cause) {
|
|
2491
|
+
if (layer === "authorization-server") return false;
|
|
2492
|
+
return [cause?.code, cause?.status, record.code, record.status].some((value) => [401, 403].includes(Number(value)));
|
|
2493
|
+
}
|
|
2494
|
+
function normalizeMcpFnReportFailure(error, fallbackPhase) {
|
|
2495
|
+
const redacted = (0, import_oauth_core4.redactOAuthValue)(error, {
|
|
2496
|
+
maxDepth: 6,
|
|
2497
|
+
maxArrayEntries: 50,
|
|
2498
|
+
maxObjectEntries: 50,
|
|
2499
|
+
maxStringLength: 2048
|
|
2500
|
+
});
|
|
2501
|
+
const record = objectRecord(redacted) ?? {};
|
|
2502
|
+
const phase = stringField(record.phase) ?? fallbackPhase;
|
|
2503
|
+
const details = objectRecord(record.details);
|
|
2504
|
+
let message = stringField(record.message) ?? String(
|
|
2505
|
+
(0, import_oauth_core4.redactOAuthValue)(error instanceof Error ? error.message : String(error))
|
|
2506
|
+
);
|
|
2507
|
+
const causeRecord = redactedFailureCause(error);
|
|
2508
|
+
const causeMessage = stringField(causeRecord?.message);
|
|
2509
|
+
if (causeMessage && !message.includes(causeMessage)) message = `${message}: ${causeMessage}`;
|
|
2510
|
+
const combinedDetails = causeRecord ? { ...details, cause: causeRecord } : details;
|
|
2511
|
+
const layer = failureLayer(phase);
|
|
2512
|
+
const deniedByResource = resourceDenied(layer, record, causeRecord);
|
|
2513
|
+
return {
|
|
2514
|
+
name: stringField(record.name) ?? "Error",
|
|
2515
|
+
message,
|
|
2516
|
+
layer: deniedByResource ? "resource-server" : layer,
|
|
2517
|
+
...stringField(record.code) ? { code: stringField(record.code) } : {},
|
|
2518
|
+
...phase ? { phase } : {},
|
|
2519
|
+
...combinedDetails ? { details: combinedDetails } : {}
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
function createMcpFnTargetSuiteJUnit(report, options = {}) {
|
|
2523
|
+
const maxBytes = options.maxBytes ?? 1048576;
|
|
2524
|
+
validateArtifactCap(maxBytes);
|
|
2525
|
+
const safe = (0, import_oauth_core4.redactOAuthValue)(report, {
|
|
2526
|
+
maxDepth: 10,
|
|
2527
|
+
maxArrayEntries: 1e3,
|
|
2528
|
+
maxObjectEntries: 200,
|
|
2529
|
+
maxStringLength: 4096
|
|
2530
|
+
});
|
|
2531
|
+
const results = safe.results.filter((result) => result && typeof result === "object" && typeof result.name === "string");
|
|
2532
|
+
const omitted = report.results.length - results.length;
|
|
2533
|
+
const cases = results.map((result) => {
|
|
2534
|
+
const duration = Math.max(0, result.durationMs ?? 0) / 1e3;
|
|
2535
|
+
let failure = "";
|
|
2536
|
+
if (result.status !== "passed") {
|
|
2537
|
+
const failureType = result.status === "failed" ? "scenario" : "incomplete";
|
|
2538
|
+
failure = junitFailure(result.error ?? `Scenario ${result.status}`, failureType);
|
|
2539
|
+
}
|
|
2540
|
+
return ` <testcase name="${xml(result.name)}" classname="mcpfn.scenario" time="${duration.toFixed(3)}">${failure}</testcase>`;
|
|
2541
|
+
});
|
|
2542
|
+
if (safe.failure) {
|
|
2543
|
+
cases.unshift(
|
|
2544
|
+
` <testcase name="${xml(safe.failure.phase ?? "target")}" classname="mcpfn.${xml(safe.failure.layer)}" time="0.000">${junitFailure(safe.failure.message, safe.failure.code ?? safe.failure.layer)}</testcase>`
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
if (safe.status === "incomplete" || omitted > 0 || !safe.ok && !safe.failure && results.every((result) => result.status === "passed")) {
|
|
2548
|
+
cases.push(` <testcase name="suite-incomplete" classname="mcpfn.report" time="0.000">${junitFailure(safe.incompleteReason ?? "Suite evidence is incomplete or omitted", "incomplete")}</testcase>`);
|
|
2549
|
+
}
|
|
2550
|
+
if (cases.length === 0) {
|
|
2551
|
+
cases.push(
|
|
2552
|
+
' <testcase name="target-suite" classname="mcpfn.target" time="0.000"></testcase>'
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
const failures = cases.filter((entry) => entry.includes("<failure ")).length;
|
|
2556
|
+
let serialized = junitDocument(safe, cases, failures);
|
|
2557
|
+
if (bytes(serialized) <= maxBytes) {
|
|
2558
|
+
return preserveMcpFnTargetSuiteArtifact(report, serialized);
|
|
2559
|
+
}
|
|
2560
|
+
const boundedCases = [
|
|
2561
|
+
` <testcase name="artifact-cap" classname="mcpfn.report" time="0.000">${junitFailure("JUnit content exceeded maxBytes and was truncated", "incomplete")}</testcase>`
|
|
2562
|
+
];
|
|
2563
|
+
serialized = junitDocument(safe, boundedCases, 1, true);
|
|
2564
|
+
if (bytes(serialized) > maxBytes) {
|
|
2565
|
+
throw new Error("The minimum McpFn JUnit report exceeds maxBytes");
|
|
2566
|
+
}
|
|
2567
|
+
return preserveMcpFnTargetSuiteArtifact(report, serialized);
|
|
2568
|
+
}
|
|
2569
|
+
function junitDocument(report, cases, failures, truncated = false) {
|
|
2570
|
+
const suiteName = String((0, import_oauth_core4.redactOAuthValue)(`mcpfn:${report.target.kind}`));
|
|
2571
|
+
return [
|
|
2572
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
2573
|
+
`<testsuite name="${xml(suiteName)}" tests="${cases.length}" failures="${failures}" errors="0" skipped="0">`,
|
|
2574
|
+
" <properties>",
|
|
2575
|
+
` <property name="mcpfn.report.schema" value="${xml(MCPFN_REPORT_SCHEMA_VERSION)}"/>`,
|
|
2576
|
+
` <property name="mcpfn.testing.version" value="${xml(MCPFN_TESTING_VERSION)}"/>`,
|
|
2577
|
+
` <property name="node.version" value="${xml(report.runtime.node)}"/>`,
|
|
2578
|
+
` <property name="artifact.truncated" value="${truncated}"/>`,
|
|
2579
|
+
" </properties>",
|
|
2580
|
+
...cases,
|
|
2581
|
+
"</testsuite>",
|
|
2582
|
+
""
|
|
2583
|
+
].join("\n");
|
|
2584
|
+
}
|
|
2585
|
+
function junitFailure(message, type) {
|
|
2586
|
+
const safe = String((0, import_oauth_core4.redactOAuthValue)(message, { maxStringLength: 4096 }));
|
|
2587
|
+
return `<failure type="${xml(type)}" message="${xml(safe)}">${xml(safe)}</failure>`;
|
|
2588
|
+
}
|
|
2589
|
+
function failureLayer(phase) {
|
|
2590
|
+
if (phase === "resource-discovery" || phase === "transport-connect") {
|
|
2591
|
+
return "resource-server";
|
|
2592
|
+
}
|
|
2593
|
+
if (phase === "authorization-server-discovery" || phase === "client-registration" || phase === "authorization-request" || phase === "authorization-callback" || phase === "token-exchange" || phase === "token-refresh" || phase === "token-revocation") {
|
|
2594
|
+
return "authorization-server";
|
|
2595
|
+
}
|
|
2596
|
+
if (phase === "mcp-initialize" || phase === "capability-operation") {
|
|
2597
|
+
return "mcp-initialization";
|
|
2598
|
+
}
|
|
2599
|
+
if (phase === "scenario") return "scenario";
|
|
2600
|
+
if (phase === "upstream-conformance") return "upstream-conformance";
|
|
2601
|
+
return "mcpfn-preflight";
|
|
2602
|
+
}
|
|
2603
|
+
function stringField(value) {
|
|
2604
|
+
if (typeof value === "string") return value;
|
|
2605
|
+
if (typeof value === "number") return String(value);
|
|
2606
|
+
return void 0;
|
|
2607
|
+
}
|
|
2608
|
+
function xml(value) {
|
|
2609
|
+
return stripInvalidXmlControls(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
2610
|
+
}
|
|
2611
|
+
function stripInvalidXmlControls(value) {
|
|
2612
|
+
return value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFE\uFFFF\uD800-\uDFFF]/gu, "");
|
|
2613
|
+
}
|
|
2614
|
+
function validateArtifactCap(maxBytes) {
|
|
2615
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1024) {
|
|
2616
|
+
throw new Error("maxBytes must be an integer of at least 1024");
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
function bytes(value) {
|
|
2620
|
+
return new TextEncoder().encode(value).byteLength;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// src/conformance.ts
|
|
1000
2624
|
var OFFICIAL_CONFORMANCE_VERSION = "0.1.16";
|
|
1001
|
-
|
|
1002
|
-
const upstream = new URL(
|
|
2625
|
+
function validateAuthenticatedConformanceUrl(url) {
|
|
2626
|
+
const upstream = new URL(url);
|
|
1003
2627
|
if (!["http:", "https:"].includes(upstream.protocol)) {
|
|
1004
2628
|
throw new TypeError(
|
|
1005
2629
|
"Authenticated conformance upstream must use HTTP or HTTPS"
|
|
@@ -1016,10 +2640,14 @@ async function createAuthenticatedConformanceProxy(options) {
|
|
|
1016
2640
|
"Authenticated conformance upstream must use a literal loopback address"
|
|
1017
2641
|
);
|
|
1018
2642
|
}
|
|
2643
|
+
return { upstream, hostname };
|
|
2644
|
+
}
|
|
2645
|
+
async function createAuthenticatedConformanceProxy(options) {
|
|
2646
|
+
const { upstream, hostname } = validateAuthenticatedConformanceUrl(options.url);
|
|
1019
2647
|
const protocol = upstream.protocol === "https:" ? "https:" : "http:";
|
|
1020
2648
|
const port = upstream.port === "" ? void 0 : Number(upstream.port);
|
|
1021
2649
|
const requestPath = `${upstream.pathname}${upstream.search}`;
|
|
1022
|
-
const injected =
|
|
2650
|
+
const injected = validateRemoteCredentialHeaders(options.headers);
|
|
1023
2651
|
const activeRequests = /* @__PURE__ */ new Set();
|
|
1024
2652
|
const activeSockets = /* @__PURE__ */ new Set();
|
|
1025
2653
|
let proxyAuthority;
|
|
@@ -1082,20 +2710,25 @@ async function createAuthenticatedConformanceProxy(options) {
|
|
|
1082
2710
|
`http://127.0.0.1:${address.port}`
|
|
1083
2711
|
);
|
|
1084
2712
|
proxyAuthority = url.host;
|
|
2713
|
+
let closePromise;
|
|
1085
2714
|
return {
|
|
1086
2715
|
url: url.toString(),
|
|
1087
|
-
close:
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
2716
|
+
close: () => {
|
|
2717
|
+
closePromise ??= (async () => {
|
|
2718
|
+
for (const request of activeRequests) request.destroy();
|
|
2719
|
+
for (const socket of activeSockets) socket.destroy();
|
|
2720
|
+
if (!server.listening) return;
|
|
2721
|
+
await new Promise((resolve, reject) => {
|
|
2722
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
2723
|
+
});
|
|
2724
|
+
})();
|
|
2725
|
+
return closePromise;
|
|
1094
2726
|
}
|
|
1095
2727
|
};
|
|
1096
2728
|
}
|
|
1097
2729
|
function normalizeLoopbackHostname(hostname) {
|
|
1098
|
-
|
|
2730
|
+
const parts = hostname.split(".");
|
|
2731
|
+
if (parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return hostname;
|
|
1099
2732
|
if (hostname === "[::1]") return "::1";
|
|
1100
2733
|
return void 0;
|
|
1101
2734
|
}
|
|
@@ -1103,9 +2736,9 @@ function npxInvocation(args) {
|
|
|
1103
2736
|
if (process.platform !== "win32") return { command: "npx", args };
|
|
1104
2737
|
const npmExecPath = process.env.npm_execpath;
|
|
1105
2738
|
const candidates = [
|
|
1106
|
-
npmExecPath ?
|
|
1107
|
-
|
|
1108
|
-
|
|
2739
|
+
npmExecPath ? import_node_path2.default.join(import_node_path2.default.dirname(npmExecPath), "npx-cli.js") : void 0,
|
|
2740
|
+
import_node_path2.default.resolve(import_node_path2.default.dirname(process.execPath), "node_modules/npm/bin/npx-cli.js"),
|
|
2741
|
+
import_node_path2.default.resolve(import_node_path2.default.dirname(process.execPath), "../lib/node_modules/npm/bin/npx-cli.js")
|
|
1109
2742
|
].filter((candidate) => Boolean(candidate));
|
|
1110
2743
|
const npxCli = candidates.find((candidate) => (0, import_node_fs.existsSync)(candidate));
|
|
1111
2744
|
if (!npxCli) {
|
|
@@ -1131,7 +2764,19 @@ function buildOfficialConformanceArgs(options) {
|
|
|
1131
2764
|
if (options.verbose) args.push("--verbose");
|
|
1132
2765
|
return args;
|
|
1133
2766
|
}
|
|
2767
|
+
function buildOfficialConformanceEnvironment(sensitiveNames = [], source = process.env) {
|
|
2768
|
+
const environment = { ...source };
|
|
2769
|
+
const sensitive = new Set(sensitiveNames.map((name) => name.toLowerCase()));
|
|
2770
|
+
for (const name of Object.keys(environment)) {
|
|
2771
|
+
if (sensitive.has(name.toLowerCase())) delete environment[name];
|
|
2772
|
+
}
|
|
2773
|
+
environment.PATH = [import_node_path2.default.dirname(process.execPath), environment.PATH].filter(Boolean).join(import_node_path2.default.delimiter);
|
|
2774
|
+
return environment;
|
|
2775
|
+
}
|
|
1134
2776
|
async function runOfficialConformance(options) {
|
|
2777
|
+
return runConformance(options, (value) => String((0, import_oauth_core5.redactOAuthValue)(value, { maxStringLength: 262144 })));
|
|
2778
|
+
}
|
|
2779
|
+
async function runConformance(options, redactOutput) {
|
|
1135
2780
|
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
|
|
1136
2781
|
if (nodeMajor < 22) {
|
|
1137
2782
|
throw new Error(
|
|
@@ -1140,40 +2785,170 @@ async function runOfficialConformance(options) {
|
|
|
1140
2785
|
}
|
|
1141
2786
|
const args = buildOfficialConformanceArgs(options);
|
|
1142
2787
|
const invocation = npxInvocation(args);
|
|
1143
|
-
|
|
1144
|
-
|
|
2788
|
+
const childEnvironment = buildOfficialConformanceEnvironment(
|
|
2789
|
+
options.sensitiveEnvironmentVariables
|
|
2790
|
+
);
|
|
2791
|
+
return await new Promise((resolve) => {
|
|
2792
|
+
const child = (0, import_node_child_process2.spawn)(invocation.command, invocation.args, {
|
|
2793
|
+
detached: process.platform !== "win32",
|
|
1145
2794
|
cwd: options.cwd,
|
|
1146
|
-
env:
|
|
1147
|
-
...process.env,
|
|
1148
|
-
PATH: [import_node_path.default.dirname(process.execPath), process.env.PATH].filter(Boolean).join(import_node_path.default.delimiter)
|
|
1149
|
-
},
|
|
2795
|
+
env: childEnvironment,
|
|
1150
2796
|
stdio: options.stdio === "inherit" ? "inherit" : "pipe"
|
|
1151
2797
|
});
|
|
1152
2798
|
let stdout = "";
|
|
1153
2799
|
let stderr = "";
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
2800
|
+
let capturedBytes = 0;
|
|
2801
|
+
let outputExceeded = false;
|
|
2802
|
+
const capture = (chunk, stream) => {
|
|
2803
|
+
if (outputExceeded) return;
|
|
2804
|
+
capturedBytes += Buffer.byteLength(chunk, "utf8");
|
|
2805
|
+
if (capturedBytes > 262144) {
|
|
2806
|
+
outputExceeded = true;
|
|
2807
|
+
stdout = "";
|
|
2808
|
+
stderr = "";
|
|
2809
|
+
void terminateConformanceRunner(child).finally(() => finish(1));
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
if (stream === "stdout") stdout += chunk.toString();
|
|
2813
|
+
else stderr += chunk.toString();
|
|
2814
|
+
};
|
|
2815
|
+
child.stdout?.setEncoding("utf8");
|
|
2816
|
+
child.stderr?.setEncoding("utf8");
|
|
2817
|
+
child.stdout?.on("data", (chunk) => capture(chunk, "stdout"));
|
|
2818
|
+
child.stderr?.on("data", (chunk) => capture(chunk, "stderr"));
|
|
2819
|
+
let settled = false;
|
|
2820
|
+
child.once("error", (error) => {
|
|
2821
|
+
if (settled) return;
|
|
2822
|
+
settled = true;
|
|
2823
|
+
const failure = normalizeMcpFnReportFailure(error, "upstream-conformance");
|
|
2824
|
+
resolve({
|
|
2825
|
+
formatVersion: 1,
|
|
2826
|
+
kind: "mcpfn.official-conformance-report",
|
|
2827
|
+
ok: false,
|
|
2828
|
+
suiteVersion: OFFICIAL_CONFORMANCE_VERSION,
|
|
2829
|
+
exitCode: 1,
|
|
2830
|
+
stdout: "",
|
|
2831
|
+
stderr: failure.message,
|
|
2832
|
+
failure
|
|
2833
|
+
});
|
|
1163
2834
|
});
|
|
2835
|
+
const finish = (code) => {
|
|
2836
|
+
if (settled) return;
|
|
2837
|
+
settled = true;
|
|
2838
|
+
let safeStdout = "";
|
|
2839
|
+
let safeStderr = "";
|
|
2840
|
+
let outputFailure = false;
|
|
2841
|
+
try {
|
|
2842
|
+
if (outputExceeded) throw new Error("capture limit");
|
|
2843
|
+
safeStdout = redactOutput(stdout);
|
|
2844
|
+
safeStderr = redactOutput(stderr);
|
|
2845
|
+
} catch {
|
|
2846
|
+
outputFailure = true;
|
|
2847
|
+
safeStdout = "";
|
|
2848
|
+
safeStderr = outputExceeded ? "Conformance output exceeded its capture limit" : "Conformance output omitted because credential redaction exceeded its bounds";
|
|
2849
|
+
}
|
|
2850
|
+
const exitCode = outputFailure ? 1 : code ?? 1;
|
|
2851
|
+
const failure = exitCode === 0 ? void 0 : normalizeMcpFnReportFailure(
|
|
2852
|
+
new Error(safeStderr || safeStdout || `Official conformance exited ${exitCode}`),
|
|
2853
|
+
"upstream-conformance"
|
|
2854
|
+
);
|
|
2855
|
+
resolve({
|
|
2856
|
+
formatVersion: 1,
|
|
2857
|
+
kind: "mcpfn.official-conformance-report",
|
|
2858
|
+
ok: exitCode === 0,
|
|
2859
|
+
suiteVersion: OFFICIAL_CONFORMANCE_VERSION,
|
|
2860
|
+
exitCode,
|
|
2861
|
+
stdout: safeStdout,
|
|
2862
|
+
stderr: safeStderr,
|
|
2863
|
+
...failure ? { failure } : {}
|
|
2864
|
+
});
|
|
2865
|
+
};
|
|
2866
|
+
child.once("close", finish);
|
|
1164
2867
|
});
|
|
1165
2868
|
}
|
|
2869
|
+
var retainedConformanceCleanup = /* @__PURE__ */ new WeakMap();
|
|
2870
|
+
var McpFnConformanceCleanupError = class extends Error {
|
|
2871
|
+
constructor(release, result) {
|
|
2872
|
+
super("Authenticated conformance credential cleanup failed; retry cleanup");
|
|
2873
|
+
this.result = result;
|
|
2874
|
+
this.name = "McpFnConformanceCleanupError";
|
|
2875
|
+
retainedConformanceCleanup.set(this, release);
|
|
2876
|
+
}
|
|
2877
|
+
result;
|
|
2878
|
+
async retryCleanup() {
|
|
2879
|
+
const release = retainedConformanceCleanup.get(this);
|
|
2880
|
+
if (!release) return;
|
|
2881
|
+
try {
|
|
2882
|
+
await release();
|
|
2883
|
+
} catch {
|
|
2884
|
+
throw this;
|
|
2885
|
+
}
|
|
2886
|
+
retainedConformanceCleanup.delete(this);
|
|
2887
|
+
}
|
|
2888
|
+
};
|
|
1166
2889
|
async function runAuthenticatedOfficialConformance(options) {
|
|
1167
|
-
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
headers
|
|
1171
|
-
}
|
|
2890
|
+
if (options.outputDir !== void 0) throw new TypeError("Authenticated conformance does not support outputDir; serialize the redacted result instead");
|
|
2891
|
+
const { headers, credential, ...conformance } = options;
|
|
2892
|
+
if (headers === void 0 === (credential === void 0)) {
|
|
2893
|
+
throw new TypeError("Provide exactly one of credential or headers for authenticated conformance");
|
|
2894
|
+
}
|
|
2895
|
+
validateAuthenticatedConformanceUrl(conformance.url);
|
|
2896
|
+
const lease = await acquireRemoteCredential(
|
|
2897
|
+
credential ?? { headers },
|
|
2898
|
+
{
|
|
2899
|
+
url: conformance.url,
|
|
2900
|
+
requestId: (0, import_node_crypto3.randomUUID)()
|
|
2901
|
+
}
|
|
2902
|
+
);
|
|
2903
|
+
let proxy;
|
|
2904
|
+
let result;
|
|
2905
|
+
let operationFailure;
|
|
2906
|
+
const safeFailure = (error) => {
|
|
2907
|
+
let message = "Authenticated conformance operation failed";
|
|
2908
|
+
try {
|
|
2909
|
+
message = redactRemoteCredential(lease.credential, error instanceof Error ? error.message : String(error));
|
|
2910
|
+
} catch {
|
|
2911
|
+
}
|
|
2912
|
+
return { error: new Error(message) };
|
|
2913
|
+
};
|
|
1172
2914
|
try {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
2915
|
+
proxy = await createAuthenticatedConformanceProxy({
|
|
2916
|
+
url: conformance.url,
|
|
2917
|
+
headers: lease.credential.headers
|
|
2918
|
+
});
|
|
2919
|
+
result = redactRemoteCredential(lease.credential, await runConformance({ ...conformance, stdio: "pipe", url: proxy.url }, (value) => String(redactRemoteCredential(lease.credential, value))), { preserveKeys: true });
|
|
2920
|
+
} catch (error) {
|
|
2921
|
+
operationFailure = safeFailure(error);
|
|
2922
|
+
}
|
|
2923
|
+
try {
|
|
2924
|
+
await proxy?.close();
|
|
2925
|
+
} catch (error) {
|
|
2926
|
+
operationFailure ??= safeFailure(error);
|
|
1176
2927
|
}
|
|
2928
|
+
let released = false;
|
|
2929
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
2930
|
+
try {
|
|
2931
|
+
await lease.release();
|
|
2932
|
+
released = true;
|
|
2933
|
+
break;
|
|
2934
|
+
} catch {
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
if (!released) {
|
|
2938
|
+
const message = "Authenticated conformance credential cleanup failed; retry cleanup";
|
|
2939
|
+
const cleanupFailure = normalizeMcpFnReportFailure(new Error(message), lease.cleanupPhase());
|
|
2940
|
+
const failedResult = result ? {
|
|
2941
|
+
...result,
|
|
2942
|
+
ok: false,
|
|
2943
|
+
exitCode: result.exitCode || 1,
|
|
2944
|
+
cleanupFailure,
|
|
2945
|
+
...result.failure ? {} : { failure: cleanupFailure }
|
|
2946
|
+
} : void 0;
|
|
2947
|
+
throw new McpFnConformanceCleanupError(() => lease.release(), failedResult);
|
|
2948
|
+
}
|
|
2949
|
+
if (operationFailure) throw operationFailure.error;
|
|
2950
|
+
if (!result) throw new Error("Conformance produced no result");
|
|
2951
|
+
return result;
|
|
1177
2952
|
}
|
|
1178
2953
|
|
|
1179
2954
|
// src/host-profiles.ts
|
|
@@ -1258,37 +3033,405 @@ function checkHostCompatibility(manifest, profile) {
|
|
|
1258
3033
|
path: `extensions.${extension}`,
|
|
1259
3034
|
message: `${profile.name} does not support ${extension}`
|
|
1260
3035
|
});
|
|
1261
|
-
}
|
|
1262
|
-
if (!extensions.has(import_core.MCP_APP_EXTENSION_ID)) {
|
|
1263
|
-
for (const tool of manifest.tools) {
|
|
1264
|
-
const ui = tool.metadata?.ui;
|
|
1265
|
-
if (ui?.visibility?.length && !ui.visibility.includes("model")) {
|
|
1266
|
-
issues.push({
|
|
1267
|
-
severity: "incompatible",
|
|
1268
|
-
code: "app-only-tool-unavailable",
|
|
1269
|
-
path: `tools.${tool.name}.metadata.ui.visibility`,
|
|
1270
|
-
message: `${tool.name} is app-only but ${profile.name} does not support MCP Apps`
|
|
1271
|
-
});
|
|
1272
|
-
}
|
|
3036
|
+
}
|
|
3037
|
+
if (!extensions.has(import_core.MCP_APP_EXTENSION_ID)) {
|
|
3038
|
+
for (const tool of manifest.tools) {
|
|
3039
|
+
const ui = tool.metadata?.ui;
|
|
3040
|
+
if (ui?.visibility?.length && !ui.visibility.includes("model")) {
|
|
3041
|
+
issues.push({
|
|
3042
|
+
severity: "incompatible",
|
|
3043
|
+
code: "app-only-tool-unavailable",
|
|
3044
|
+
path: `tools.${tool.name}.metadata.ui.visibility`,
|
|
3045
|
+
message: `${tool.name} is app-only but ${profile.name} does not support MCP Apps`
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
issues.sort((left, right) => {
|
|
3051
|
+
if (left.path !== right.path) return left.path < right.path ? -1 : 1;
|
|
3052
|
+
if (left.code < right.code) return -1;
|
|
3053
|
+
if (left.code > right.code) return 1;
|
|
3054
|
+
return 0;
|
|
3055
|
+
});
|
|
3056
|
+
let status = "compatible";
|
|
3057
|
+
if (issues.some((issue) => issue.severity === "incompatible")) {
|
|
3058
|
+
status = "incompatible";
|
|
3059
|
+
} else if (issues.length > 0) {
|
|
3060
|
+
status = "degraded";
|
|
3061
|
+
}
|
|
3062
|
+
return { status, compatible: status !== "incompatible", issues };
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
// src/host-authorization.ts
|
|
3066
|
+
var import_oauth_core6 = require("@superfunctions/oauth-core");
|
|
3067
|
+
function createHostedAuthorizationFixtures(options) {
|
|
3068
|
+
const issuer = new URL(options.issuer);
|
|
3069
|
+
const resource = new URL(options.resource).toString();
|
|
3070
|
+
const chatgptRedirect = options.chatgptRedirectUri ?? "https://chatgpt.com/connector_platform_oauth_redirect";
|
|
3071
|
+
const claudeRedirect = options.claudeRedirectUri ?? "https://claude.example.com/oauth/callback";
|
|
3072
|
+
const verifier = "mcpfn-role3-shared-pkce-verifier-000000000000000000000000";
|
|
3073
|
+
const registration = (clientId, source, redirectUri, grantTypes) => ({
|
|
3074
|
+
clientId,
|
|
3075
|
+
source,
|
|
3076
|
+
metadata: {
|
|
3077
|
+
client_name: `McpFn ${source} fixture`,
|
|
3078
|
+
redirect_uris: [redirectUri],
|
|
3079
|
+
response_types: ["code"],
|
|
3080
|
+
grant_types: grantTypes,
|
|
3081
|
+
token_endpoint_auth_method: "none"
|
|
3082
|
+
}
|
|
3083
|
+
});
|
|
3084
|
+
const authorization = (clientId, redirectUri) => ({
|
|
3085
|
+
clientId,
|
|
3086
|
+
redirectUri,
|
|
3087
|
+
resource,
|
|
3088
|
+
scopes: ["mcp:read"],
|
|
3089
|
+
state: "mcpfn-role3-state",
|
|
3090
|
+
codeVerifier: verifier
|
|
3091
|
+
});
|
|
3092
|
+
const chatgptId = "chatgpt-client";
|
|
3093
|
+
const claudeId = new URL("/client-metadata/claude", issuer).toString();
|
|
3094
|
+
const dcrId = "dcr-client";
|
|
3095
|
+
const supported = ["authorization_code", "refresh_token"];
|
|
3096
|
+
return [
|
|
3097
|
+
{
|
|
3098
|
+
id: "chatgpt-pre-registered-code-pkce",
|
|
3099
|
+
host: "chatgpt",
|
|
3100
|
+
registration: registration(chatgptId, "pre-registered", chatgptRedirect, supported),
|
|
3101
|
+
authorization: authorization(chatgptId, chatgptRedirect),
|
|
3102
|
+
token: {
|
|
3103
|
+
grantType: "authorization_code",
|
|
3104
|
+
refreshAfterExchange: true
|
|
3105
|
+
},
|
|
3106
|
+
expected: { outcome: "allowed" }
|
|
3107
|
+
},
|
|
3108
|
+
{
|
|
3109
|
+
id: "claude-client-metadata-extensible-grants",
|
|
3110
|
+
host: "claude",
|
|
3111
|
+
registration: registration(claudeId, "client-metadata-document", claudeRedirect, [
|
|
3112
|
+
...supported,
|
|
3113
|
+
"urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
3114
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
3115
|
+
"urn:example:params:oauth:grant-type:extension"
|
|
3116
|
+
]),
|
|
3117
|
+
authorization: authorization(claudeId, claudeRedirect),
|
|
3118
|
+
token: {
|
|
3119
|
+
grantType: "authorization_code",
|
|
3120
|
+
refreshAfterExchange: true
|
|
3121
|
+
},
|
|
3122
|
+
expected: { outcome: "allowed" }
|
|
3123
|
+
},
|
|
3124
|
+
{
|
|
3125
|
+
id: "dynamic-registration-code-pkce",
|
|
3126
|
+
host: "generic",
|
|
3127
|
+
registration: registration(dcrId, "dynamic", "http://127.0.0.1/callback", supported),
|
|
3128
|
+
authorization: authorization(dcrId, "http://127.0.0.1/callback"),
|
|
3129
|
+
token: { grantType: "authorization_code" },
|
|
3130
|
+
expected: { outcome: "allowed" }
|
|
3131
|
+
},
|
|
3132
|
+
{
|
|
3133
|
+
id: "chatgpt-unregistered-redirect",
|
|
3134
|
+
host: "chatgpt",
|
|
3135
|
+
registration: registration(chatgptId, "pre-registered", chatgptRedirect, supported),
|
|
3136
|
+
authorization: authorization(chatgptId, `${chatgptRedirect}/drift`),
|
|
3137
|
+
expected: {
|
|
3138
|
+
outcome: "rejected",
|
|
3139
|
+
errorCode: "invalid_request",
|
|
3140
|
+
phase: "authorization-request"
|
|
3141
|
+
}
|
|
3142
|
+
},
|
|
3143
|
+
{
|
|
3144
|
+
id: "incompatible-registration-grants",
|
|
3145
|
+
host: "generic",
|
|
3146
|
+
registration: registration(
|
|
3147
|
+
"incompatible-client",
|
|
3148
|
+
"pre-registered",
|
|
3149
|
+
"https://client.example.com/callback",
|
|
3150
|
+
["urn:ietf:params:oauth:grant-type:jwt-bearer"]
|
|
3151
|
+
),
|
|
3152
|
+
authorization: authorization(
|
|
3153
|
+
"incompatible-client",
|
|
3154
|
+
"https://client.example.com/callback"
|
|
3155
|
+
),
|
|
3156
|
+
expected: {
|
|
3157
|
+
outcome: "rejected",
|
|
3158
|
+
errorCode: "invalid_client",
|
|
3159
|
+
phase: "authorization-request"
|
|
3160
|
+
}
|
|
3161
|
+
},
|
|
3162
|
+
{
|
|
3163
|
+
id: "actual-unsupported-token-grant",
|
|
3164
|
+
host: "generic",
|
|
3165
|
+
registration: registration(
|
|
3166
|
+
"unsupported-token-client",
|
|
3167
|
+
"pre-registered",
|
|
3168
|
+
"https://client.example.com/callback",
|
|
3169
|
+
supported
|
|
3170
|
+
),
|
|
3171
|
+
authorization: authorization(
|
|
3172
|
+
"unsupported-token-client",
|
|
3173
|
+
"https://client.example.com/callback"
|
|
3174
|
+
),
|
|
3175
|
+
token: { grantType: "urn:example:unsupported-token-grant" },
|
|
3176
|
+
expected: {
|
|
3177
|
+
outcome: "rejected",
|
|
3178
|
+
errorCode: "unsupported_grant_type",
|
|
3179
|
+
phase: "token-exchange"
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
];
|
|
3183
|
+
}
|
|
3184
|
+
async function runHostedAuthorizationRegression(target, fixtures) {
|
|
3185
|
+
const results = [];
|
|
3186
|
+
for (const fixture of fixtures) {
|
|
3187
|
+
results.push(await runHostedCase(target, fixture));
|
|
3188
|
+
}
|
|
3189
|
+
return results;
|
|
3190
|
+
}
|
|
3191
|
+
async function runHostedCase(target, fixture) {
|
|
3192
|
+
let phase = "client-registration";
|
|
3193
|
+
let responseStatus;
|
|
3194
|
+
try {
|
|
3195
|
+
await target.prepareRegistration(structuredClone(fixture.registration));
|
|
3196
|
+
phase = "authorization-request";
|
|
3197
|
+
const authorization = hostedAuthorizationUrl(target.issuer, fixture);
|
|
3198
|
+
responseStatus = void 0;
|
|
3199
|
+
const authorizationResponse = await target.request(new Request(authorization, {
|
|
3200
|
+
redirect: "manual"
|
|
3201
|
+
}));
|
|
3202
|
+
responseStatus = authorizationResponse.status;
|
|
3203
|
+
const authorizationError = await oauthError2(authorizationResponse);
|
|
3204
|
+
if (authorizationError) {
|
|
3205
|
+
await validateOAuthRejection(authorizationResponse, fixture, target.issuer, "authorization");
|
|
3206
|
+
return assessHostedCase(fixture, phase, authorizationResponse.status, authorizationError);
|
|
3207
|
+
}
|
|
3208
|
+
if (!isRedirect(authorizationResponse.status)) {
|
|
3209
|
+
throw new Error(`Authorization request did not redirect (HTTP ${authorizationResponse.status})`);
|
|
3210
|
+
}
|
|
3211
|
+
const code = validatedRedirectCode(authorizationResponse, fixture, target.issuer);
|
|
3212
|
+
if (fixture.token) return runHostedTokenCase(target, fixture, fixture.token, code);
|
|
3213
|
+
return assessHostedCase(fixture, phase, responseStatus);
|
|
3214
|
+
} catch (error) {
|
|
3215
|
+
return assessHostedFailure(fixture, phase, responseStatus, error);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
async function runHostedTokenCase(target, fixture, token, code) {
|
|
3219
|
+
let phase = token.grantType === "refresh_token" ? "token-refresh" : "token-exchange";
|
|
3220
|
+
let responseStatus;
|
|
3221
|
+
try {
|
|
3222
|
+
const tokenResponse = await target.request(hostedTokenRequest(
|
|
3223
|
+
target.issuer,
|
|
3224
|
+
tokenRequestBody(fixture, token, code)
|
|
3225
|
+
));
|
|
3226
|
+
responseStatus = tokenResponse.status;
|
|
3227
|
+
const tokenError = await oauthError2(tokenResponse);
|
|
3228
|
+
if (tokenError) {
|
|
3229
|
+
await validateOAuthRejection(tokenResponse, fixture, target.issuer, "token");
|
|
3230
|
+
return assessHostedCase(fixture, phase, responseStatus, tokenError);
|
|
3231
|
+
}
|
|
3232
|
+
if (!tokenResponse.ok) throw new Error(`Token request returned HTTP ${tokenResponse.status}`);
|
|
3233
|
+
const tokenSet = await validatedTokenSet(tokenResponse);
|
|
3234
|
+
if (!token.refreshAfterExchange) {
|
|
3235
|
+
return assessHostedCase(fixture, phase, responseStatus);
|
|
3236
|
+
}
|
|
3237
|
+
if (typeof tokenSet.refresh_token !== "string" || !tokenSet.refresh_token) {
|
|
3238
|
+
throw new Error("Authorization-code response did not include a refresh token");
|
|
3239
|
+
}
|
|
3240
|
+
phase = "token-refresh";
|
|
3241
|
+
responseStatus = void 0;
|
|
3242
|
+
const refreshBody = new URLSearchParams({
|
|
3243
|
+
grant_type: "refresh_token",
|
|
3244
|
+
client_id: fixture.authorization.clientId,
|
|
3245
|
+
refresh_token: tokenSet.refresh_token,
|
|
3246
|
+
resource: fixture.authorization.resource
|
|
3247
|
+
});
|
|
3248
|
+
const refreshResponse = await target.request(hostedTokenRequest(target.issuer, refreshBody));
|
|
3249
|
+
responseStatus = refreshResponse.status;
|
|
3250
|
+
const refreshError = await oauthError2(refreshResponse);
|
|
3251
|
+
if (refreshError) {
|
|
3252
|
+
await validateOAuthRejection(refreshResponse, fixture, target.issuer, "token");
|
|
3253
|
+
return assessHostedCase(fixture, phase, responseStatus, refreshError);
|
|
3254
|
+
}
|
|
3255
|
+
if (!refreshResponse.ok) {
|
|
3256
|
+
throw new Error(`Refresh request returned HTTP ${refreshResponse.status}`);
|
|
1273
3257
|
}
|
|
3258
|
+
await validatedTokenSet(refreshResponse);
|
|
3259
|
+
return assessHostedCase(fixture, phase, responseStatus);
|
|
3260
|
+
} catch (error) {
|
|
3261
|
+
return assessHostedFailure(fixture, phase, responseStatus, error);
|
|
1274
3262
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
3263
|
+
}
|
|
3264
|
+
function tokenRequestBody(fixture, token, code) {
|
|
3265
|
+
const body = new URLSearchParams({
|
|
3266
|
+
grant_type: token.grantType,
|
|
3267
|
+
client_id: fixture.authorization.clientId
|
|
1280
3268
|
});
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
3269
|
+
if (token.grantType === "authorization_code") {
|
|
3270
|
+
body.set("code", token.code ?? code);
|
|
3271
|
+
body.set("redirect_uri", fixture.authorization.redirectUri);
|
|
3272
|
+
body.set("code_verifier", fixture.authorization.codeVerifier);
|
|
3273
|
+
body.set("resource", fixture.authorization.resource);
|
|
3274
|
+
} else if (token.refreshToken) {
|
|
3275
|
+
body.set("refresh_token", token.refreshToken);
|
|
3276
|
+
body.set("resource", fixture.authorization.resource);
|
|
3277
|
+
}
|
|
3278
|
+
return body;
|
|
3279
|
+
}
|
|
3280
|
+
function hostedTokenRequest(issuer, body) {
|
|
3281
|
+
return new Request(new URL("token", ensureTrailingSlash(issuer)), {
|
|
3282
|
+
method: "POST",
|
|
3283
|
+
redirect: "manual",
|
|
3284
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3285
|
+
body
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
3288
|
+
function assessHostedFailure(fixture, phase, responseStatus, error) {
|
|
3289
|
+
const redacted = (0, import_oauth_core6.redactOAuthValue)(error);
|
|
3290
|
+
const safe = redacted && typeof redacted === "object" ? redacted : {};
|
|
3291
|
+
const code = typeof safe.code === "string" ? safe.code : void 0;
|
|
3292
|
+
const message = typeof safe.message === "string" ? safe.message : String(redacted);
|
|
3293
|
+
return assessHostedCase(
|
|
3294
|
+
fixture,
|
|
3295
|
+
phase,
|
|
3296
|
+
responseStatus,
|
|
3297
|
+
code,
|
|
3298
|
+
message || "Hosted adapter failed"
|
|
3299
|
+
);
|
|
3300
|
+
}
|
|
3301
|
+
function hostedAuthorizationUrl(issuer, fixture) {
|
|
3302
|
+
const authorization = new URL("authorize", ensureTrailingSlash(issuer));
|
|
3303
|
+
authorization.searchParams.set("response_type", "code");
|
|
3304
|
+
authorization.searchParams.set("client_id", fixture.authorization.clientId);
|
|
3305
|
+
authorization.searchParams.set("redirect_uri", fixture.authorization.redirectUri);
|
|
3306
|
+
authorization.searchParams.set(
|
|
3307
|
+
"code_challenge",
|
|
3308
|
+
(0, import_oauth_core6.derivePkceS256Challenge)(fixture.authorization.codeVerifier)
|
|
3309
|
+
);
|
|
3310
|
+
authorization.searchParams.set("code_challenge_method", "S256");
|
|
3311
|
+
authorization.searchParams.set("state", fixture.authorization.state);
|
|
3312
|
+
authorization.searchParams.set("resource", fixture.authorization.resource);
|
|
3313
|
+
authorization.searchParams.set("scope", fixture.authorization.scopes.join(" "));
|
|
3314
|
+
return authorization;
|
|
3315
|
+
}
|
|
3316
|
+
function assessHostedCase(fixture, phase, responseStatus, errorCode, error) {
|
|
3317
|
+
const observedOutcome = errorCode || error ? "rejected" : "allowed";
|
|
3318
|
+
const expected = fixture.expected;
|
|
3319
|
+
const passed = !error && observedOutcome === expected.outcome && (expected.errorCode === void 0 || expected.errorCode === errorCode) && (expected.phase === void 0 || expected.phase === phase);
|
|
3320
|
+
const expectedCode = expected.errorCode ? `:${expected.errorCode}` : "";
|
|
3321
|
+
const observedCode = errorCode ? `:${errorCode}` : "";
|
|
3322
|
+
const mismatch = `Expected ${expected.outcome}${expectedCode}, received ${observedOutcome}${observedCode}`;
|
|
3323
|
+
return {
|
|
3324
|
+
formatVersion: 1,
|
|
3325
|
+
id: fixture.id,
|
|
3326
|
+
host: fixture.host,
|
|
3327
|
+
status: passed ? "passed" : "failed",
|
|
3328
|
+
phase,
|
|
3329
|
+
layer: phase === "client-registration" ? "mcpfn-preflight" : "authorization-server",
|
|
3330
|
+
...responseStatus === void 0 ? {} : { responseStatus },
|
|
3331
|
+
...errorCode ? { errorCode } : {},
|
|
3332
|
+
...!passed || error ? {
|
|
3333
|
+
error: error ?? mismatch
|
|
3334
|
+
} : {}
|
|
3335
|
+
};
|
|
3336
|
+
}
|
|
3337
|
+
function isJsonResponse(response) {
|
|
3338
|
+
const mediaType = (response.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
3339
|
+
return mediaType === "application/json" || /^application\/[a-z0-9!#$&^_.+-]+\+json$/.test(mediaType);
|
|
3340
|
+
}
|
|
3341
|
+
async function oauthError2(response) {
|
|
3342
|
+
const location = response.headers.get("location");
|
|
3343
|
+
if (location) {
|
|
3344
|
+
const code = new URL(location).searchParams.get("error");
|
|
3345
|
+
if (code) return code;
|
|
3346
|
+
}
|
|
3347
|
+
if (!isJsonResponse(response)) return void 0;
|
|
3348
|
+
const body = await response.clone().json().catch(() => void 0);
|
|
3349
|
+
return typeof body?.error === "string" ? body.error : void 0;
|
|
3350
|
+
}
|
|
3351
|
+
async function validateOAuthRejection(response, fixture, issuer, context) {
|
|
3352
|
+
if (context === "authorization" && isRedirect(response.status)) {
|
|
3353
|
+
validateAuthorizationErrorRedirect(response, fixture, issuer);
|
|
3354
|
+
return;
|
|
1286
3355
|
}
|
|
1287
|
-
|
|
3356
|
+
const body = await response.clone().json().catch(() => void 0);
|
|
3357
|
+
const registered = fixture.registration.metadata.redirect_uris?.includes(fixture.authorization.redirectUri);
|
|
3358
|
+
if (context === "authorization" && body?.error !== "invalid_client" && registered) {
|
|
3359
|
+
throw new Error("Registered authorization requests require an OAuth error callback");
|
|
3360
|
+
}
|
|
3361
|
+
if (response.status !== 400) throw new Error("OAuth error response has invalid HTTP status");
|
|
3362
|
+
if (response.headers.has("location") || !isJsonResponse(response) || !body || typeof body.error !== "string" || !body.error) {
|
|
3363
|
+
throw new Error("OAuth error response must be a JSON error envelope");
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
function validateAuthorizationErrorRedirect(response, fixture, issuer) {
|
|
3367
|
+
const callback = new URL(response.headers.get("location") ?? "");
|
|
3368
|
+
const registered = fixture.registration.metadata.redirect_uris;
|
|
3369
|
+
if (!registered?.includes(fixture.authorization.redirectUri)) {
|
|
3370
|
+
throw new Error("Rejected redirect URI must not receive a callback");
|
|
3371
|
+
}
|
|
3372
|
+
const errors = callback.searchParams.getAll("error");
|
|
3373
|
+
if (errors.length !== 1 || !errors[0] || callback.searchParams.has("code")) {
|
|
3374
|
+
throw new Error("Invalid OAuth error callback");
|
|
3375
|
+
}
|
|
3376
|
+
if (errors[0] === "invalid_client") {
|
|
3377
|
+
throw new Error("Invalid-client authorization failures must not redirect");
|
|
3378
|
+
}
|
|
3379
|
+
callback.searchParams.delete("error");
|
|
3380
|
+
callback.searchParams.delete("error_description");
|
|
3381
|
+
callback.searchParams.delete("error_uri");
|
|
3382
|
+
callback.searchParams.set("code", "error-envelope-validation");
|
|
3383
|
+
validatedRedirectCode(
|
|
3384
|
+
new Response(null, { status: response.status, headers: { location: callback.toString() } }),
|
|
3385
|
+
fixture,
|
|
3386
|
+
issuer
|
|
3387
|
+
);
|
|
3388
|
+
}
|
|
3389
|
+
function validatedRedirectCode(response, fixture, issuer) {
|
|
3390
|
+
const location = response.headers.get("location");
|
|
3391
|
+
if (!location) throw new Error("Authorization callback is missing");
|
|
3392
|
+
const callback = new URL(location);
|
|
3393
|
+
const expected = new URL(fixture.authorization.redirectUri);
|
|
3394
|
+
const codes = callback.searchParams.getAll("code");
|
|
3395
|
+
const states = callback.searchParams.getAll("state");
|
|
3396
|
+
if (codes.length !== 1 || !codes[0] || states.length !== 1 || states[0] !== fixture.authorization.state) {
|
|
3397
|
+
throw new Error("Authorization callback code or state is invalid");
|
|
3398
|
+
}
|
|
3399
|
+
const issuers = callback.searchParams.getAll("iss");
|
|
3400
|
+
if (issuers.length > 1 || issuers.length === 1 && issuers[0] !== issuer) {
|
|
3401
|
+
throw new Error("Authorization callback issuer is invalid");
|
|
3402
|
+
}
|
|
3403
|
+
if (callback.protocol !== expected.protocol || callback.host !== expected.host || callback.username !== expected.username || callback.password !== expected.password || callback.pathname !== expected.pathname || callback.hash !== expected.hash || [...new Set(expected.searchParams.keys())].some((key) => JSON.stringify(callback.searchParams.getAll(key)) !== JSON.stringify(expected.searchParams.getAll(key)))) {
|
|
3404
|
+
throw new Error("Authorization callback destination is invalid");
|
|
3405
|
+
}
|
|
3406
|
+
return codes[0];
|
|
3407
|
+
}
|
|
3408
|
+
async function validatedTokenSet(response) {
|
|
3409
|
+
if (response.status !== 200) throw new Error("Successful token responses must use HTTP 200");
|
|
3410
|
+
if (!isJsonResponse(response)) throw new Error("Token response must use a JSON media type");
|
|
3411
|
+
if (!hasHeaderDirective(response.headers.get("cache-control"), "no-store") || !hasHeaderDirective(response.headers.get("pragma"), "no-cache")) {
|
|
3412
|
+
throw new Error("Token responses must disable caching with Cache-Control: no-store and Pragma: no-cache");
|
|
3413
|
+
}
|
|
3414
|
+
const value = await response.clone().json();
|
|
3415
|
+
if (!value || typeof value.access_token !== "string" || !/^[\dA-Za-z._~+/-]+=*$/.test(value.access_token) || typeof value.token_type !== "string" || value.token_type.toLowerCase() !== "bearer") {
|
|
3416
|
+
throw new Error("Token response requires an access token and Bearer token type");
|
|
3417
|
+
}
|
|
3418
|
+
return value;
|
|
3419
|
+
}
|
|
3420
|
+
function hasHeaderDirective(value, expected) {
|
|
3421
|
+
return (value ?? "").split(",").some(
|
|
3422
|
+
(directive) => directive.trim().toLowerCase() === expected
|
|
3423
|
+
);
|
|
3424
|
+
}
|
|
3425
|
+
function isRedirect(status) {
|
|
3426
|
+
return [301, 302, 303, 307, 308].includes(status);
|
|
3427
|
+
}
|
|
3428
|
+
function ensureTrailingSlash(value) {
|
|
3429
|
+
const url = new URL(value);
|
|
3430
|
+
if (!url.pathname.endsWith("/")) url.pathname += "/";
|
|
3431
|
+
return url;
|
|
1288
3432
|
}
|
|
1289
3433
|
|
|
1290
3434
|
// src/scenarios.ts
|
|
1291
|
-
var import_oauth_core2 = require("@superfunctions/oauth-core");
|
|
1292
3435
|
function createMcpFnScenario(name, operation, result) {
|
|
1293
3436
|
switch (operation.kind) {
|
|
1294
3437
|
case "tools.call":
|
|
@@ -1517,16 +3660,31 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1517
3660
|
const observedEvents = [];
|
|
1518
3661
|
const maxObservedEvents = options.maxObservedEvents ?? 500;
|
|
1519
3662
|
let droppedObservedEvents = 0;
|
|
3663
|
+
let redactionOmittedObservedEvents = 0;
|
|
3664
|
+
let observedRedactionOmissionEvents = 0;
|
|
1520
3665
|
let attributedDroppedObservedEvents = 0;
|
|
3666
|
+
let attributedRedactionOmittedObservedEvents = 0;
|
|
3667
|
+
const initialClientEventRedactionOmissions = client.session.getRedactionOmissionCounts().clientEvents;
|
|
1521
3668
|
const pushResult = (result) => {
|
|
1522
3669
|
const newlyDropped = droppedObservedEvents - attributedDroppedObservedEvents;
|
|
3670
|
+
const newlyRedactionOmitted = redactionOmittedObservedEvents - attributedRedactionOmittedObservedEvents;
|
|
1523
3671
|
results.push({
|
|
1524
3672
|
...result,
|
|
1525
|
-
...newlyDropped > 0 ? { droppedObservedEvents: (result.droppedObservedEvents ?? 0) + newlyDropped } : {}
|
|
3673
|
+
...newlyDropped > 0 ? { droppedObservedEvents: (result.droppedObservedEvents ?? 0) + newlyDropped } : {},
|
|
3674
|
+
...newlyRedactionOmitted > 0 ? {
|
|
3675
|
+
redactionOmittedObservedEvents: (result.redactionOmittedObservedEvents ?? 0) + newlyRedactionOmitted
|
|
3676
|
+
} : {}
|
|
1526
3677
|
});
|
|
1527
3678
|
attributedDroppedObservedEvents = droppedObservedEvents;
|
|
3679
|
+
attributedRedactionOmittedObservedEvents = redactionOmittedObservedEvents;
|
|
1528
3680
|
};
|
|
1529
3681
|
const unsubscribe = client.session.onEvent((event) => {
|
|
3682
|
+
if (client.session.isRedactionOmission(event)) {
|
|
3683
|
+
observedRedactionOmissionEvents += 1;
|
|
3684
|
+
droppedObservedEvents += 1;
|
|
3685
|
+
redactionOmittedObservedEvents += 1;
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
1530
3688
|
observedEvents.push(event);
|
|
1531
3689
|
if (observedEvents.length > maxObservedEvents) {
|
|
1532
3690
|
observedEvents.shift();
|
|
@@ -1550,7 +3708,7 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1550
3708
|
...common,
|
|
1551
3709
|
status: "incomplete",
|
|
1552
3710
|
durationMs: 0,
|
|
1553
|
-
error: truncateError(`Skipped after timed-out scenario: ${timedOutScenario}`, options)
|
|
3711
|
+
error: truncateError(`Skipped after timed-out scenario: ${timedOutScenario}`, options, client)
|
|
1554
3712
|
});
|
|
1555
3713
|
continue;
|
|
1556
3714
|
}
|
|
@@ -1562,7 +3720,8 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1562
3720
|
durationMs: 0,
|
|
1563
3721
|
error: truncateError(
|
|
1564
3722
|
`Missing scenario variables: ${resolved.missing.join(", ")}`,
|
|
1565
|
-
options
|
|
3723
|
+
options,
|
|
3724
|
+
client
|
|
1566
3725
|
)
|
|
1567
3726
|
});
|
|
1568
3727
|
continue;
|
|
@@ -1572,7 +3731,7 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1572
3731
|
...common,
|
|
1573
3732
|
status: "incomplete",
|
|
1574
3733
|
durationMs: 0,
|
|
1575
|
-
error: truncateError(scenario.incompleteReason ?? "Scenario is incomplete", options)
|
|
3734
|
+
error: truncateError(scenario.incompleteReason ?? "Scenario is incomplete", options, client)
|
|
1576
3735
|
});
|
|
1577
3736
|
continue;
|
|
1578
3737
|
}
|
|
@@ -1604,7 +3763,7 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1604
3763
|
...common,
|
|
1605
3764
|
status: "failed",
|
|
1606
3765
|
durationMs: performance.now() - startedAt,
|
|
1607
|
-
error: truncateError(error instanceof Error ? error.message : String(error), options)
|
|
3766
|
+
error: truncateError(error instanceof Error ? error.message : String(error), options, client)
|
|
1608
3767
|
});
|
|
1609
3768
|
if (timedOut) timedOutScenario = scenario.name;
|
|
1610
3769
|
} finally {
|
|
@@ -1612,11 +3771,21 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1612
3771
|
}
|
|
1613
3772
|
}
|
|
1614
3773
|
} finally {
|
|
3774
|
+
const unobservedRedactionOmissions = Math.max(
|
|
3775
|
+
0,
|
|
3776
|
+
client.session.getRedactionOmissionCounts().clientEvents - initialClientEventRedactionOmissions - observedRedactionOmissionEvents
|
|
3777
|
+
);
|
|
3778
|
+
droppedObservedEvents += unobservedRedactionOmissions;
|
|
3779
|
+
redactionOmittedObservedEvents += unobservedRedactionOmissions;
|
|
1615
3780
|
const unattributed = droppedObservedEvents - attributedDroppedObservedEvents;
|
|
1616
3781
|
const lastResult = results.at(-1);
|
|
1617
3782
|
if (lastResult && unattributed > 0) {
|
|
1618
3783
|
lastResult.droppedObservedEvents = (lastResult.droppedObservedEvents ?? 0) + unattributed;
|
|
1619
3784
|
}
|
|
3785
|
+
const unattributedRedaction = redactionOmittedObservedEvents - attributedRedactionOmittedObservedEvents;
|
|
3786
|
+
if (lastResult && unattributedRedaction > 0) {
|
|
3787
|
+
lastResult.redactionOmittedObservedEvents = (lastResult.redactionOmittedObservedEvents ?? 0) + unattributedRedaction;
|
|
3788
|
+
}
|
|
1620
3789
|
unsubscribe();
|
|
1621
3790
|
}
|
|
1622
3791
|
return results;
|
|
@@ -1626,20 +3795,29 @@ function resolveScenarioVariables(scenario, supplied) {
|
|
|
1626
3795
|
const required = new Set(scenario.variables ?? []);
|
|
1627
3796
|
const marker = /\$\{([A-Z][A-Z0-9_]*)\}/g;
|
|
1628
3797
|
const encodedMarker = /%24%7[Bb]([A-Z][A-Z0-9_]*)%7[Dd]/g;
|
|
3798
|
+
const visitString = (value) => {
|
|
3799
|
+
for (const match of value.matchAll(marker)) required.add(match[1]);
|
|
3800
|
+
for (const match of value.matchAll(encodedMarker)) required.add(match[1]);
|
|
3801
|
+
return value.replace(marker, (original, name) => values[name] ?? original).replace(
|
|
3802
|
+
encodedMarker,
|
|
3803
|
+
(original, name) => values[name] === void 0 ? original : encodeURIComponent(values[name])
|
|
3804
|
+
);
|
|
3805
|
+
};
|
|
1629
3806
|
const visit = (value) => {
|
|
1630
|
-
if (typeof value === "string")
|
|
1631
|
-
for (const match of value.matchAll(marker)) required.add(match[1]);
|
|
1632
|
-
for (const match of value.matchAll(encodedMarker)) required.add(match[1]);
|
|
1633
|
-
return value.replace(marker, (original, name) => values[name] ?? original).replace(
|
|
1634
|
-
encodedMarker,
|
|
1635
|
-
(original, name) => values[name] === void 0 ? original : encodeURIComponent(values[name])
|
|
1636
|
-
);
|
|
1637
|
-
}
|
|
3807
|
+
if (typeof value === "string") return visitString(value);
|
|
1638
3808
|
if (Array.isArray(value)) return value.map(visit);
|
|
1639
3809
|
if (value && typeof value === "object") {
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
)
|
|
3810
|
+
const entries = [];
|
|
3811
|
+
const keys = /* @__PURE__ */ new Set();
|
|
3812
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
3813
|
+
const resolvedKey = visitString(key);
|
|
3814
|
+
if (keys.has(resolvedKey)) {
|
|
3815
|
+
throw new Error("Scenario variable substitution creates duplicate object key");
|
|
3816
|
+
}
|
|
3817
|
+
keys.add(resolvedKey);
|
|
3818
|
+
entries.push([resolvedKey, visit(entry)]);
|
|
3819
|
+
}
|
|
3820
|
+
return Object.fromEntries(entries);
|
|
1643
3821
|
}
|
|
1644
3822
|
return value;
|
|
1645
3823
|
};
|
|
@@ -1656,6 +3834,11 @@ function createMcpFnScenarioReport(results, options = {}) {
|
|
|
1656
3834
|
(total, result) => total + (result.droppedObservedEvents ?? 0),
|
|
1657
3835
|
0
|
|
1658
3836
|
);
|
|
3837
|
+
const redactionOmittedObservedEvents = results.reduce(
|
|
3838
|
+
(total, result) => total + (result.redactionOmittedObservedEvents ?? 0),
|
|
3839
|
+
0
|
|
3840
|
+
);
|
|
3841
|
+
const overflowedObservedEvents = droppedObservedEvents - redactionOmittedObservedEvents;
|
|
1659
3842
|
const report = {
|
|
1660
3843
|
formatVersion: 1,
|
|
1661
3844
|
kind: "mcpfn.scenario-report",
|
|
@@ -1667,7 +3850,13 @@ function createMcpFnScenarioReport(results, options = {}) {
|
|
|
1667
3850
|
incomplete,
|
|
1668
3851
|
droppedResults: 0,
|
|
1669
3852
|
droppedObservedEvents,
|
|
1670
|
-
...
|
|
3853
|
+
...redactionOmittedObservedEvents > 0 ? { redactionOmittedObservedEvents } : {},
|
|
3854
|
+
...droppedObservedEvents > 0 ? {
|
|
3855
|
+
incompleteReason: [
|
|
3856
|
+
...redactionOmittedObservedEvents > 0 ? ["Observed client events were omitted because credential redaction failed"] : [],
|
|
3857
|
+
...overflowedObservedEvents > 0 ? ["Observed client events exceeded maxObservedEvents"] : []
|
|
3858
|
+
].join("; ")
|
|
3859
|
+
} : {},
|
|
1671
3860
|
results: structuredClone(results)
|
|
1672
3861
|
};
|
|
1673
3862
|
const maxBytes = options.maxBytes ?? 1048576;
|
|
@@ -1808,12 +3997,16 @@ async function executeAuthScenario(scenario, signal, runOptions) {
|
|
|
1808
3997
|
}
|
|
1809
3998
|
assertExpected(await runOptions.auth(scenario, signal), scenario.expect);
|
|
1810
3999
|
}
|
|
1811
|
-
function truncateError(value, options) {
|
|
1812
|
-
|
|
4000
|
+
function truncateError(value, options, client) {
|
|
4001
|
+
let redacted = "Scenario error omitted because credential redaction failed";
|
|
4002
|
+
try {
|
|
4003
|
+
redacted = String(client.session.redact(value));
|
|
4004
|
+
} catch {
|
|
4005
|
+
}
|
|
1813
4006
|
const maxBytes = options.maxErrorBytes ?? 4096;
|
|
1814
|
-
const
|
|
1815
|
-
if (
|
|
1816
|
-
return `${new TextDecoder().decode(
|
|
4007
|
+
const bytes2 = new TextEncoder().encode(redacted);
|
|
4008
|
+
if (bytes2.byteLength <= maxBytes) return redacted;
|
|
4009
|
+
return `${new TextDecoder().decode(bytes2.slice(0, Math.max(0, maxBytes - 16)))}...[TRUNCATED]`;
|
|
1817
4010
|
}
|
|
1818
4011
|
function defaultSideEffect(operation) {
|
|
1819
4012
|
if (operation === "tools.call" || operation === "tools.call:task") {
|
|
@@ -1877,87 +4070,719 @@ async function listScenarioInventory(client, kind, requestOptions) {
|
|
|
1877
4070
|
}
|
|
1878
4071
|
|
|
1879
4072
|
// src/suite.ts
|
|
1880
|
-
var
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
if (
|
|
1886
|
-
|
|
4073
|
+
var import_client5 = require("@mcpfn/client");
|
|
4074
|
+
var suiteCleanupOwners = /* @__PURE__ */ new WeakMap();
|
|
4075
|
+
function retryTargetSuiteCleanup(error) {
|
|
4076
|
+
const owner = suiteCleanupOwners.get(error);
|
|
4077
|
+
if (!owner) return Promise.resolve();
|
|
4078
|
+
if (owner.pending) return owner.pending;
|
|
4079
|
+
const pending = Promise.resolve().then(owner.close).then(
|
|
4080
|
+
() => {
|
|
4081
|
+
suiteCleanupOwners.delete(error);
|
|
4082
|
+
},
|
|
4083
|
+
() => {
|
|
4084
|
+
throw error;
|
|
4085
|
+
}
|
|
4086
|
+
).finally(() => {
|
|
4087
|
+
owner.pending = void 0;
|
|
4088
|
+
});
|
|
4089
|
+
owner.pending = pending;
|
|
4090
|
+
return pending;
|
|
4091
|
+
}
|
|
4092
|
+
var McpFnTargetSuiteCleanupError = class extends Error {
|
|
4093
|
+
constructor(report, close) {
|
|
4094
|
+
super("Target suite cleanup failed; retain this error and retryCleanup()");
|
|
4095
|
+
this.report = report;
|
|
4096
|
+
this.name = "McpFnTargetSuiteCleanupError";
|
|
4097
|
+
suiteCleanupOwners.set(this, { close });
|
|
1887
4098
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
4099
|
+
report;
|
|
4100
|
+
retryCleanup() {
|
|
4101
|
+
return retryTargetSuiteCleanup(this);
|
|
4102
|
+
}
|
|
4103
|
+
};
|
|
4104
|
+
var McpFnTargetSuiteArtifactCleanupError = class extends Error {
|
|
4105
|
+
constructor(close, cause) {
|
|
4106
|
+
super(`${cause.message}; retain this error and retryCleanup()`, { cause });
|
|
4107
|
+
this.name = "McpFnTargetSuiteArtifactCleanupError";
|
|
4108
|
+
suiteCleanupOwners.set(this, { close });
|
|
4109
|
+
}
|
|
4110
|
+
retryCleanup() {
|
|
4111
|
+
return retryTargetSuiteCleanup(this);
|
|
4112
|
+
}
|
|
4113
|
+
};
|
|
4114
|
+
async function runMcpFnTargetSuite(options) {
|
|
4115
|
+
const artifactRedaction = beginSuiteArtifactRedaction(options.target);
|
|
4116
|
+
let finishRedaction = artifactRedaction.finish;
|
|
4117
|
+
const retainArtifactGuard = (report) => {
|
|
4118
|
+
registerMcpFnTargetSuiteArtifactGuard(
|
|
4119
|
+
report,
|
|
4120
|
+
(artifact) => {
|
|
4121
|
+
if (!artifactRedaction.retained) return false;
|
|
4122
|
+
if (options.target.assertArtifactSafe) {
|
|
4123
|
+
options.target.assertArtifactSafe(artifact);
|
|
4124
|
+
return true;
|
|
1901
4125
|
}
|
|
1902
|
-
|
|
4126
|
+
if (!options.target.redact) return true;
|
|
4127
|
+
return Object.is(
|
|
4128
|
+
redactSuiteArtifact(options.target, artifact, { preserveKeys: false }),
|
|
4129
|
+
artifact
|
|
4130
|
+
);
|
|
4131
|
+
},
|
|
4132
|
+
finishRedaction,
|
|
4133
|
+
options.maxReportBytes ?? 1048576
|
|
4134
|
+
);
|
|
4135
|
+
finishRedaction = () => void 0;
|
|
4136
|
+
try {
|
|
4137
|
+
preserveMcpFnTargetSuiteArtifact(report, JSON.stringify(report));
|
|
4138
|
+
} catch (error) {
|
|
4139
|
+
disposeMcpFnTargetSuiteArtifactGuard(report);
|
|
4140
|
+
throw error;
|
|
4141
|
+
}
|
|
4142
|
+
};
|
|
4143
|
+
try {
|
|
4144
|
+
const report = await runTargetSuite(options);
|
|
4145
|
+
retainArtifactGuard(report);
|
|
4146
|
+
return report;
|
|
4147
|
+
} catch (error) {
|
|
4148
|
+
if (error instanceof McpFnTargetSuiteCleanupError) {
|
|
4149
|
+
try {
|
|
4150
|
+
retainArtifactGuard(error.report);
|
|
4151
|
+
} catch (artifactError) {
|
|
4152
|
+
const cause = artifactError instanceof Error ? artifactError : new Error("Target report artifact proof failed");
|
|
4153
|
+
throw new McpFnTargetSuiteArtifactCleanupError(
|
|
4154
|
+
() => error.retryCleanup(),
|
|
4155
|
+
cause
|
|
4156
|
+
);
|
|
1903
4157
|
}
|
|
1904
4158
|
}
|
|
1905
|
-
|
|
1906
|
-
|
|
4159
|
+
throw error;
|
|
4160
|
+
} finally {
|
|
4161
|
+
finishRedaction();
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
function beginSuiteArtifactRedaction(target) {
|
|
4165
|
+
let finish;
|
|
4166
|
+
if (target.beginRedactionScope) {
|
|
1907
4167
|
try {
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
4168
|
+
finish = target.beginRedactionScope();
|
|
4169
|
+
} catch {
|
|
4170
|
+
return { finish: () => void 0, retained: false };
|
|
4171
|
+
}
|
|
4172
|
+
if (!finish) return { finish: () => void 0, retained: false };
|
|
4173
|
+
} else {
|
|
4174
|
+
finish = beginTargetCredentialRedaction(target);
|
|
4175
|
+
}
|
|
4176
|
+
return {
|
|
4177
|
+
finish: () => {
|
|
4178
|
+
try {
|
|
4179
|
+
finish?.();
|
|
4180
|
+
} catch {
|
|
1912
4181
|
}
|
|
1913
|
-
|
|
4182
|
+
},
|
|
4183
|
+
retained: true
|
|
4184
|
+
};
|
|
4185
|
+
}
|
|
4186
|
+
function redactSuiteArtifact(target, value, options = {}) {
|
|
4187
|
+
const scrubbed = target.redact ? target.redact(value, options) : value;
|
|
4188
|
+
return redactTargetCredentials(target, scrubbed, options);
|
|
4189
|
+
}
|
|
4190
|
+
function compareInventoryNames(left, right) {
|
|
4191
|
+
if (left === right) return 0;
|
|
4192
|
+
return left < right ? -1 : 1;
|
|
4193
|
+
}
|
|
4194
|
+
function reportProjectionFailureReason(error, projectionFailure) {
|
|
4195
|
+
if (error instanceof McpFnRedactionLimitError) {
|
|
4196
|
+
return "Credential redaction exceeded its traversal budget";
|
|
4197
|
+
}
|
|
4198
|
+
if (projectionFailure === "redaction") {
|
|
4199
|
+
return "Credential redaction failed; report content omitted because safe serialization failed";
|
|
4200
|
+
}
|
|
4201
|
+
return "Report content omitted because safe serialization failed";
|
|
4202
|
+
}
|
|
4203
|
+
var SuiteDiagnosticCollector = class {
|
|
4204
|
+
constructor(maxEvents, maxBytes, target, consumer) {
|
|
4205
|
+
this.maxEvents = maxEvents;
|
|
4206
|
+
this.maxBytes = maxBytes;
|
|
4207
|
+
this.target = target;
|
|
4208
|
+
this.consumer = consumer;
|
|
4209
|
+
}
|
|
4210
|
+
maxEvents;
|
|
4211
|
+
maxBytes;
|
|
4212
|
+
target;
|
|
4213
|
+
consumer;
|
|
4214
|
+
timeline = [];
|
|
4215
|
+
timelineSizes = [];
|
|
4216
|
+
timelineBytes = 0;
|
|
4217
|
+
observedRedactionOmissions = 0;
|
|
4218
|
+
initialRedactionOmissions = 0;
|
|
4219
|
+
dropped = 0;
|
|
4220
|
+
countExceeded = false;
|
|
4221
|
+
bytesExceeded = false;
|
|
4222
|
+
serializationFailed = false;
|
|
4223
|
+
redactionFailed = false;
|
|
4224
|
+
cleanupFailure;
|
|
4225
|
+
captureBaseline(client) {
|
|
4226
|
+
this.initialRedactionOmissions = client.session.getRedactionOmissionCounts().diagnostics;
|
|
4227
|
+
}
|
|
4228
|
+
captureUnobserved(client) {
|
|
4229
|
+
const total = client?.session.getRedactionOmissionCounts().diagnostics ?? this.initialRedactionOmissions;
|
|
4230
|
+
const unobserved = Math.max(
|
|
4231
|
+
0,
|
|
4232
|
+
total - this.initialRedactionOmissions - this.observedRedactionOmissions
|
|
4233
|
+
);
|
|
4234
|
+
if (unobserved > 0) {
|
|
4235
|
+
this.redactionFailed = true;
|
|
4236
|
+
this.dropped += unobserved;
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
async record(client, event) {
|
|
4240
|
+
if (client?.session.isRedactionOmission(event)) {
|
|
4241
|
+
this.observedRedactionOmissions += 1;
|
|
4242
|
+
this.redactionFailed = true;
|
|
4243
|
+
this.dropped += 1;
|
|
4244
|
+
}
|
|
4245
|
+
if (["token-revocation", "transport-close"].includes(event.phase) && event.outcome === "failed") {
|
|
4246
|
+
this.cleanupFailure ??= normalizeMcpFnReportFailure({
|
|
4247
|
+
name: "CleanupError",
|
|
4248
|
+
message: "Target cleanup failed",
|
|
4249
|
+
code: event.code,
|
|
4250
|
+
phase: event.phase
|
|
4251
|
+
});
|
|
4252
|
+
}
|
|
4253
|
+
const safeEvent = redactTargetCredentials(this.target, event, { preserveKeys: true });
|
|
4254
|
+
const bytes2 = this.serializedBytes(safeEvent);
|
|
4255
|
+
if (bytes2 === void 0) {
|
|
4256
|
+
await this.consumer?.(safeEvent);
|
|
4257
|
+
return;
|
|
4258
|
+
}
|
|
4259
|
+
if (bytes2 > this.maxBytes) {
|
|
4260
|
+
this.bytesExceeded = true;
|
|
4261
|
+
this.dropped += 1;
|
|
4262
|
+
} else {
|
|
4263
|
+
this.timeline.push(safeEvent);
|
|
4264
|
+
this.timelineSizes.push(bytes2);
|
|
4265
|
+
this.timelineBytes += bytes2;
|
|
4266
|
+
this.enforceBounds();
|
|
4267
|
+
}
|
|
4268
|
+
await this.consumer?.(safeEvent);
|
|
4269
|
+
}
|
|
4270
|
+
serializedBytes(event) {
|
|
4271
|
+
try {
|
|
4272
|
+
const json2 = JSON.stringify(event, (_key, value) => {
|
|
4273
|
+
if (value === void 0 || typeof value === "number" && !Number.isFinite(value)) {
|
|
4274
|
+
throw new Error("Diagnostic contains a non-JSON value");
|
|
4275
|
+
}
|
|
4276
|
+
return value;
|
|
4277
|
+
});
|
|
4278
|
+
structuredClone(event);
|
|
4279
|
+
return Buffer.byteLength(json2);
|
|
4280
|
+
} catch {
|
|
4281
|
+
this.serializationFailed = true;
|
|
4282
|
+
this.dropped += 1;
|
|
4283
|
+
return void 0;
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
enforceBounds() {
|
|
4287
|
+
while (this.timeline.length > this.maxEvents || this.timelineBytes > this.maxBytes) {
|
|
4288
|
+
this.countExceeded ||= this.timeline.length > this.maxEvents;
|
|
4289
|
+
this.bytesExceeded ||= this.timelineBytes > this.maxBytes;
|
|
4290
|
+
this.timeline.shift();
|
|
4291
|
+
this.timelineBytes -= this.timelineSizes.shift();
|
|
4292
|
+
this.dropped += 1;
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
};
|
|
4296
|
+
async function executeConnectedSuite(client, options) {
|
|
4297
|
+
let manifestChecked = false;
|
|
4298
|
+
if (options.manifest) {
|
|
4299
|
+
manifestChecked = true;
|
|
4300
|
+
await assertManifestContract(client, options.manifest, {
|
|
4301
|
+
expectedToolNames: options.expectedToolNames
|
|
4302
|
+
});
|
|
4303
|
+
} else if (options.expectedToolNames) {
|
|
4304
|
+
const actual = (await client.listTools()).map((tool) => tool.name).sort(compareInventoryNames);
|
|
4305
|
+
const expected = [...options.expectedToolNames].sort(compareInventoryNames);
|
|
4306
|
+
if (stableJson(actual) !== stableJson(expected)) {
|
|
4307
|
+
throw new McpFnAssertionError(
|
|
4308
|
+
`Tool inventory mismatch: expected ${stableJson(expected)}, actual ${stableJson(actual)}`
|
|
4309
|
+
);
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
const results = await runScenarios(client, options.scenarios ?? [], options.scenarioRun);
|
|
4313
|
+
return {
|
|
4314
|
+
manifestChecked,
|
|
4315
|
+
execution: {
|
|
4316
|
+
results,
|
|
4317
|
+
server: client.session.getServerVersion(),
|
|
4318
|
+
capabilities: client.session.getServerCapabilities()
|
|
4319
|
+
}
|
|
4320
|
+
};
|
|
4321
|
+
}
|
|
4322
|
+
function safeTargetFailure(target, error) {
|
|
4323
|
+
try {
|
|
4324
|
+
return normalizeMcpFnReportFailure(redactSuiteArtifact(target, error, { preserveKeys: true }));
|
|
4325
|
+
} catch {
|
|
4326
|
+
return normalizeMcpFnReportFailure({
|
|
4327
|
+
name: "RedactionError",
|
|
4328
|
+
code: "MCPFN_REDACTION_FAILED",
|
|
4329
|
+
message: "Target failure omitted because credential redaction failed"
|
|
4330
|
+
});
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
function initialTargetDescriptor(target) {
|
|
4334
|
+
const kind = target.kind;
|
|
4335
|
+
return ["stdio", "streamable-http", "authenticated-streamable-http", "in-memory", "custom"].includes(kind) ? { kind } : { kind: "custom" };
|
|
4336
|
+
}
|
|
4337
|
+
function safeInitialTargetDescriptor(target) {
|
|
4338
|
+
try {
|
|
4339
|
+
return initialTargetDescriptor(target);
|
|
4340
|
+
} catch {
|
|
4341
|
+
return { kind: "custom" };
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
function projectSuiteArtifacts(options, client, execution) {
|
|
4345
|
+
const fallback = {
|
|
4346
|
+
target: safeInitialTargetDescriptor(options.target),
|
|
4347
|
+
execution
|
|
4348
|
+
};
|
|
4349
|
+
try {
|
|
4350
|
+
if (client?.session.state !== "connected") {
|
|
4351
|
+
return execution.results.length || execution.server || execution.capabilities ? { ...fallback, failure: "serialization" } : fallback;
|
|
4352
|
+
}
|
|
4353
|
+
let described;
|
|
4354
|
+
try {
|
|
4355
|
+
described = options.target.describe();
|
|
4356
|
+
} catch {
|
|
4357
|
+
return { ...fallback, failure: "serialization" };
|
|
4358
|
+
}
|
|
4359
|
+
try {
|
|
4360
|
+
const { kind, ...descriptor } = described;
|
|
1914
4361
|
return {
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
4362
|
+
target: {
|
|
4363
|
+
...redactSuiteArtifact(options.target, descriptor, { preserveKeys: false }),
|
|
4364
|
+
kind: client.session.preserveArtifactStructure(kind)
|
|
4365
|
+
},
|
|
4366
|
+
manifestHash: options.manifest ? redactSuiteArtifact(options.target, options.manifest.hash, { preserveKeys: false }) : void 0,
|
|
4367
|
+
execution: {
|
|
4368
|
+
server: execution.server === void 0 ? void 0 : redactSuiteArtifact(options.target, execution.server, { preserveKeys: false }),
|
|
4369
|
+
capabilities: execution.capabilities === void 0 ? void 0 : redactSuiteArtifact(options.target, execution.capabilities, { preserveKeys: false }),
|
|
4370
|
+
results: execution.results.map((result) => ({
|
|
4371
|
+
...result,
|
|
4372
|
+
name: redactSuiteArtifact(options.target, result.name, { preserveKeys: false }),
|
|
4373
|
+
operation: redactSuiteArtifact(options.target, result.operation, { preserveKeys: false }),
|
|
4374
|
+
...result.tool === void 0 ? {} : {
|
|
4375
|
+
tool: redactSuiteArtifact(options.target, result.tool, { preserveKeys: false })
|
|
4376
|
+
}
|
|
4377
|
+
}))
|
|
4378
|
+
}
|
|
1918
4379
|
};
|
|
1919
|
-
}
|
|
1920
|
-
|
|
4380
|
+
} catch {
|
|
4381
|
+
return { ...fallback, failure: "redaction" };
|
|
4382
|
+
}
|
|
4383
|
+
} catch {
|
|
4384
|
+
return { ...fallback, failure: "serialization" };
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
var suiteStructureKeys = [
|
|
4388
|
+
"formatVersion",
|
|
4389
|
+
"kind",
|
|
4390
|
+
"artifactValidation",
|
|
4391
|
+
"status",
|
|
4392
|
+
"runtime",
|
|
4393
|
+
"ok",
|
|
4394
|
+
"target",
|
|
4395
|
+
"server",
|
|
4396
|
+
"capabilities",
|
|
4397
|
+
"manifestChecked",
|
|
4398
|
+
"manifestHash",
|
|
4399
|
+
"total",
|
|
4400
|
+
"passed",
|
|
4401
|
+
"failed",
|
|
4402
|
+
"incomplete",
|
|
4403
|
+
"droppedResults",
|
|
4404
|
+
"droppedObservedEvents",
|
|
4405
|
+
"redactionOmittedObservedEvents",
|
|
4406
|
+
"incompleteReason",
|
|
4407
|
+
"failure",
|
|
4408
|
+
"timeline",
|
|
4409
|
+
"droppedTimelineEvents",
|
|
4410
|
+
"results",
|
|
4411
|
+
"node",
|
|
4412
|
+
"scenarioFormatVersion",
|
|
4413
|
+
"reportSchemaVersion",
|
|
4414
|
+
"packages",
|
|
4415
|
+
"testing",
|
|
4416
|
+
"name",
|
|
4417
|
+
"message",
|
|
4418
|
+
"layer",
|
|
4419
|
+
"code",
|
|
4420
|
+
"phase",
|
|
4421
|
+
"details",
|
|
4422
|
+
"outcome",
|
|
4423
|
+
"requestId",
|
|
4424
|
+
"at",
|
|
4425
|
+
"sideEffect",
|
|
4426
|
+
"durationMs",
|
|
4427
|
+
"error",
|
|
4428
|
+
"operation",
|
|
4429
|
+
"tool"
|
|
4430
|
+
];
|
|
4431
|
+
var suiteStructureValues = [
|
|
4432
|
+
"mcpfn.target-suite-report",
|
|
4433
|
+
"target-aware",
|
|
4434
|
+
"complete",
|
|
4435
|
+
"incomplete",
|
|
4436
|
+
"passed",
|
|
4437
|
+
"failed",
|
|
4438
|
+
"started",
|
|
4439
|
+
"succeeded",
|
|
4440
|
+
"none",
|
|
4441
|
+
"idempotent",
|
|
4442
|
+
"non-idempotent",
|
|
4443
|
+
"mcpfn-preflight",
|
|
4444
|
+
"authorization-server",
|
|
4445
|
+
"resource-server",
|
|
4446
|
+
"mcp-initialization",
|
|
4447
|
+
"scenario",
|
|
4448
|
+
"upstream-conformance",
|
|
4449
|
+
"resource-discovery",
|
|
4450
|
+
"authorization-server-discovery",
|
|
4451
|
+
"client-registration",
|
|
4452
|
+
"authorization-request",
|
|
4453
|
+
"authorization-callback",
|
|
4454
|
+
"token-exchange",
|
|
4455
|
+
"token-refresh",
|
|
4456
|
+
"token-revocation",
|
|
4457
|
+
"transport-connect",
|
|
4458
|
+
"mcp-initialize",
|
|
4459
|
+
"capability-operation",
|
|
4460
|
+
"transport-close",
|
|
4461
|
+
"authenticated-streamable-http",
|
|
4462
|
+
"streamable-http",
|
|
4463
|
+
"stdio",
|
|
4464
|
+
"in-memory",
|
|
4465
|
+
"custom"
|
|
4466
|
+
];
|
|
4467
|
+
function captureSuiteStructureGuard(target, projection, diagnostics, failure) {
|
|
4468
|
+
const candidates = /* @__PURE__ */ new Set([
|
|
4469
|
+
...suiteStructureKeys,
|
|
4470
|
+
...suiteStructureValues,
|
|
4471
|
+
process.version,
|
|
4472
|
+
MCPFN_REPORT_SCHEMA_VERSION,
|
|
4473
|
+
MCPFN_TESTING_VERSION,
|
|
4474
|
+
projection.target.kind
|
|
4475
|
+
]);
|
|
4476
|
+
if (failure?.phase) candidates.add(failure.phase);
|
|
4477
|
+
for (const result of projection.execution.results) {
|
|
4478
|
+
candidates.add(result.status);
|
|
4479
|
+
if (result.sideEffect) candidates.add(result.sideEffect);
|
|
4480
|
+
}
|
|
4481
|
+
for (const event of diagnostics.timeline) {
|
|
4482
|
+
candidates.add(event.phase);
|
|
4483
|
+
candidates.add(event.outcome);
|
|
4484
|
+
if (event.target?.kind) candidates.add(event.target.kind);
|
|
4485
|
+
}
|
|
4486
|
+
const safe = /* @__PURE__ */ new Set();
|
|
4487
|
+
for (const candidate of candidates) {
|
|
4488
|
+
try {
|
|
4489
|
+
if (redactSuiteArtifact(target, candidate, { preserveKeys: false }) === candidate) {
|
|
4490
|
+
safe.add(candidate);
|
|
4491
|
+
}
|
|
4492
|
+
} catch {
|
|
4493
|
+
}
|
|
4494
|
+
}
|
|
4495
|
+
return {
|
|
4496
|
+
assert(value) {
|
|
4497
|
+
if (!safe.has(value)) throw new McpFnStructuralCredentialCollisionError();
|
|
4498
|
+
}
|
|
4499
|
+
};
|
|
4500
|
+
}
|
|
4501
|
+
function assertStructureKeys(value, guard) {
|
|
4502
|
+
for (const key of Object.keys(value)) guard.assert(key);
|
|
4503
|
+
}
|
|
4504
|
+
function assertTargetStructure(target, guard) {
|
|
4505
|
+
guard.assert("kind");
|
|
4506
|
+
guard.assert(target.kind);
|
|
4507
|
+
}
|
|
4508
|
+
function assertSuiteReportStructure(report, guard) {
|
|
4509
|
+
assertStructureKeys(report, guard);
|
|
4510
|
+
guard.assert(report.kind);
|
|
4511
|
+
if (report.artifactValidation) guard.assert(report.artifactValidation);
|
|
4512
|
+
guard.assert(report.status);
|
|
4513
|
+
assertStructureKeys(report.runtime, guard);
|
|
4514
|
+
guard.assert(report.runtime.node);
|
|
4515
|
+
guard.assert(report.runtime.reportSchemaVersion);
|
|
4516
|
+
assertStructureKeys(report.runtime.packages, guard);
|
|
4517
|
+
guard.assert(report.runtime.packages.testing);
|
|
4518
|
+
assertTargetStructure(report.target, guard);
|
|
4519
|
+
if (report.failure) {
|
|
4520
|
+
assertStructureKeys(report.failure, guard);
|
|
4521
|
+
guard.assert(report.failure.layer);
|
|
4522
|
+
if (report.failure.phase) guard.assert(report.failure.phase);
|
|
4523
|
+
}
|
|
4524
|
+
for (const event of report.timeline) {
|
|
4525
|
+
assertStructureKeys(event, guard);
|
|
4526
|
+
guard.assert(event.phase);
|
|
4527
|
+
guard.assert(event.outcome);
|
|
4528
|
+
if (event.target) assertTargetStructure(event.target, guard);
|
|
4529
|
+
}
|
|
4530
|
+
for (const result of report.results) {
|
|
4531
|
+
assertStructureKeys(result, guard);
|
|
4532
|
+
guard.assert(result.status);
|
|
4533
|
+
if (result.sideEffect) guard.assert(result.sideEffect);
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
async function closeSuiteClient(client) {
|
|
4537
|
+
try {
|
|
4538
|
+
await client?.close();
|
|
4539
|
+
return {};
|
|
4540
|
+
} catch (error) {
|
|
4541
|
+
let retainedCleanup;
|
|
4542
|
+
if (error instanceof McpFnTestClientCleanupError) {
|
|
4543
|
+
retainedCleanup = () => error.retryCleanup();
|
|
4544
|
+
} else if (client) {
|
|
4545
|
+
retainedCleanup = () => client.close();
|
|
1921
4546
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
4547
|
+
const terminal = error instanceof McpFnTestClientCleanupError && error.cause !== void 0 ? error.cause : error;
|
|
4548
|
+
const phase = terminal && typeof terminal === "object" && terminal.phase === "token-revocation" ? "token-revocation" : "transport-close";
|
|
4549
|
+
return {
|
|
4550
|
+
retainedCleanup,
|
|
4551
|
+
cleanupFailure: normalizeMcpFnReportFailure({
|
|
4552
|
+
name: "CleanupError",
|
|
4553
|
+
message: "Target cleanup failed",
|
|
4554
|
+
code: "MCPFN_TARGET_CLEANUP_FAILED",
|
|
4555
|
+
phase
|
|
4556
|
+
})
|
|
4557
|
+
};
|
|
4558
|
+
}
|
|
4559
|
+
}
|
|
4560
|
+
async function connectSuite(options, diagnostics) {
|
|
4561
|
+
let client;
|
|
4562
|
+
let initialClientEventRedactionOmissions = 0;
|
|
4563
|
+
let manifestChecked = false;
|
|
4564
|
+
let failure;
|
|
4565
|
+
let execution = { results: [] };
|
|
4566
|
+
try {
|
|
4567
|
+
client = McpFnTestClient.createTarget(
|
|
4568
|
+
options.target,
|
|
4569
|
+
options.clientInfo ?? { name: "mcpfn-suite", version: "0.0.1" },
|
|
4570
|
+
{
|
|
4571
|
+
...options.client,
|
|
4572
|
+
diagnostics: (event) => diagnostics.record(client, event)
|
|
4573
|
+
}
|
|
4574
|
+
);
|
|
4575
|
+
initialClientEventRedactionOmissions = client.session.getRedactionOmissionCounts().clientEvents;
|
|
4576
|
+
diagnostics.captureBaseline(client);
|
|
4577
|
+
await client.session.connect();
|
|
4578
|
+
({ execution, manifestChecked } = await executeConnectedSuite(client, options));
|
|
4579
|
+
} catch (error) {
|
|
4580
|
+
failure = safeTargetFailure(options.target, error);
|
|
4581
|
+
}
|
|
4582
|
+
return {
|
|
4583
|
+
client,
|
|
4584
|
+
initialClientEventRedactionOmissions,
|
|
4585
|
+
manifestChecked,
|
|
4586
|
+
failure,
|
|
4587
|
+
execution
|
|
4588
|
+
};
|
|
4589
|
+
}
|
|
4590
|
+
async function projectAndCloseSuite(options, diagnostics, connection) {
|
|
4591
|
+
let projection;
|
|
4592
|
+
let structureGuard;
|
|
4593
|
+
let closeResult = {};
|
|
4594
|
+
try {
|
|
4595
|
+
projection = projectSuiteArtifacts(options, connection.client, connection.execution);
|
|
4596
|
+
structureGuard = captureSuiteStructureGuard(
|
|
4597
|
+
options.target,
|
|
4598
|
+
projection,
|
|
4599
|
+
diagnostics,
|
|
4600
|
+
connection.failure
|
|
4601
|
+
);
|
|
4602
|
+
} finally {
|
|
4603
|
+
closeResult = await closeSuiteClient(connection.client);
|
|
4604
|
+
}
|
|
4605
|
+
return { projection, structureGuard, closeResult };
|
|
4606
|
+
}
|
|
4607
|
+
function suiteIncompleteReasons(failure, cleanupFailure, diagnostics, redactionOmittedObservedEvents, overflowedObservedEvents) {
|
|
4608
|
+
const reasons = [
|
|
4609
|
+
...failure ? [`${failure.layer}: ${failure.message}`] : [],
|
|
4610
|
+
...cleanupFailure ? [`Cleanup: ${cleanupFailure.message}`] : [],
|
|
4611
|
+
...diagnostics.serializationFailed ? ["Diagnostic timeline contained non-JSON data"] : [],
|
|
4612
|
+
...diagnostics.redactionFailed ? ["Diagnostic timeline redaction failed; original events omitted"] : [],
|
|
4613
|
+
...diagnostics.countExceeded ? ["Diagnostic timeline exceeded maxTimelineEvents"] : [],
|
|
4614
|
+
...diagnostics.bytesExceeded ? ["Diagnostic timeline exceeded maxReportBytes"] : [],
|
|
4615
|
+
...redactionOmittedObservedEvents > 0 ? ["Observed client events were omitted because credential redaction failed"] : [],
|
|
4616
|
+
...overflowedObservedEvents > 0 ? ["Observed client events exceeded maxObservedEvents"] : []
|
|
4617
|
+
];
|
|
4618
|
+
return reasons.length ? reasons.join("; ") : void 0;
|
|
4619
|
+
}
|
|
4620
|
+
function buildSuiteReport(projection, manifestChecked, failure, cleanupFailure, diagnostics, lifecycleRedactionOmissions) {
|
|
4621
|
+
const results = projection.execution.results;
|
|
1924
4622
|
const failed = results.filter((result) => result.status === "failed").length;
|
|
1925
4623
|
const incomplete = results.filter((result) => result.status === "incomplete").length;
|
|
1926
|
-
const
|
|
4624
|
+
const scenarioDroppedObservedEvents = results.reduce(
|
|
1927
4625
|
(total, result) => total + (result.droppedObservedEvents ?? 0),
|
|
1928
4626
|
0
|
|
1929
4627
|
);
|
|
1930
|
-
const
|
|
1931
|
-
|
|
4628
|
+
const scenarioRedactionOmissions = results.reduce(
|
|
4629
|
+
(total, result) => total + (result.redactionOmittedObservedEvents ?? 0),
|
|
4630
|
+
0
|
|
4631
|
+
);
|
|
4632
|
+
const redactionOmittedObservedEvents = Math.max(
|
|
4633
|
+
lifecycleRedactionOmissions,
|
|
4634
|
+
scenarioRedactionOmissions
|
|
4635
|
+
);
|
|
4636
|
+
const overflowedObservedEvents = Math.max(
|
|
4637
|
+
0,
|
|
4638
|
+
scenarioDroppedObservedEvents - scenarioRedactionOmissions
|
|
4639
|
+
);
|
|
4640
|
+
const droppedObservedEvents = redactionOmittedObservedEvents + overflowedObservedEvents;
|
|
4641
|
+
const artifactIncomplete = Boolean(failure) || incomplete > 0 || diagnostics.dropped > 0 || droppedObservedEvents > 0;
|
|
4642
|
+
const incompleteReason = suiteIncompleteReasons(
|
|
4643
|
+
failure,
|
|
4644
|
+
cleanupFailure,
|
|
4645
|
+
diagnostics,
|
|
4646
|
+
redactionOmittedObservedEvents,
|
|
4647
|
+
overflowedObservedEvents
|
|
4648
|
+
);
|
|
4649
|
+
return {
|
|
1932
4650
|
formatVersion: 1,
|
|
1933
4651
|
kind: "mcpfn.target-suite-report",
|
|
4652
|
+
artifactValidation: "target-aware",
|
|
1934
4653
|
status: artifactIncomplete ? "incomplete" : "complete",
|
|
1935
|
-
runtime: {
|
|
4654
|
+
runtime: {
|
|
4655
|
+
node: process.version,
|
|
4656
|
+
scenarioFormatVersion: 1,
|
|
4657
|
+
reportSchemaVersion: MCPFN_REPORT_SCHEMA_VERSION,
|
|
4658
|
+
packages: { testing: MCPFN_TESTING_VERSION }
|
|
4659
|
+
},
|
|
1936
4660
|
ok: failed === 0 && !artifactIncomplete,
|
|
1937
|
-
target:
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
manifestChecked: Boolean(options.manifest),
|
|
1943
|
-
...options.manifest ? { manifestHash: options.manifest.hash } : {},
|
|
4661
|
+
target: projection.target,
|
|
4662
|
+
server: projection.execution.server,
|
|
4663
|
+
capabilities: projection.execution.capabilities,
|
|
4664
|
+
manifestChecked,
|
|
4665
|
+
...projection.manifestHash === void 0 ? {} : { manifestHash: projection.manifestHash },
|
|
1944
4666
|
total: results.length,
|
|
1945
4667
|
passed: results.length - failed - incomplete,
|
|
1946
4668
|
failed,
|
|
1947
4669
|
incomplete,
|
|
1948
4670
|
droppedResults: 0,
|
|
1949
4671
|
droppedObservedEvents,
|
|
1950
|
-
...
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
} : {},
|
|
1956
|
-
timeline,
|
|
1957
|
-
droppedTimelineEvents,
|
|
4672
|
+
...redactionOmittedObservedEvents > 0 ? { redactionOmittedObservedEvents } : {},
|
|
4673
|
+
...incompleteReason ? { incompleteReason } : {},
|
|
4674
|
+
...failure ? { failure } : {},
|
|
4675
|
+
timeline: diagnostics.timeline,
|
|
4676
|
+
droppedTimelineEvents: diagnostics.dropped,
|
|
1958
4677
|
results
|
|
1959
4678
|
};
|
|
1960
|
-
|
|
4679
|
+
}
|
|
4680
|
+
function structuralReportFailure(retainedCleanup) {
|
|
4681
|
+
const failure = new import_client5.McpFnClientError(
|
|
4682
|
+
"MCPFN_OPERATION_FAILED",
|
|
4683
|
+
"Target report cannot be serialized because a credential conflicts with required artifact structure",
|
|
4684
|
+
{ phase: "capability-operation" }
|
|
4685
|
+
);
|
|
4686
|
+
if (retainedCleanup) {
|
|
4687
|
+
throw new McpFnTargetSuiteArtifactCleanupError(retainedCleanup, failure);
|
|
4688
|
+
}
|
|
4689
|
+
throw failure;
|
|
4690
|
+
}
|
|
4691
|
+
function boundedSuiteReport(report, guard, maxReportBytes) {
|
|
4692
|
+
const bounded = enforceReportCap(report, maxReportBytes);
|
|
4693
|
+
assertSuiteReportStructure(bounded, guard);
|
|
4694
|
+
return bounded;
|
|
4695
|
+
}
|
|
4696
|
+
function finalizeSuiteReport(options, projection, report, guard, maxReportBytes, retainedCleanup) {
|
|
4697
|
+
let finalized;
|
|
4698
|
+
try {
|
|
4699
|
+
if (projection.failure) throw new Error("Report payload projection failed");
|
|
4700
|
+
assertSuiteReportStructure(report, guard);
|
|
4701
|
+
finalized = boundedSuiteReport(
|
|
4702
|
+
redactTargetCredentials(options.target, report, { preserveKeys: true }),
|
|
4703
|
+
guard,
|
|
4704
|
+
maxReportBytes
|
|
4705
|
+
);
|
|
4706
|
+
} catch (error) {
|
|
4707
|
+
if (error instanceof McpFnStructuralCredentialCollisionError) {
|
|
4708
|
+
return structuralReportFailure(retainedCleanup);
|
|
4709
|
+
}
|
|
4710
|
+
const fallback = {
|
|
4711
|
+
...report,
|
|
4712
|
+
ok: false,
|
|
4713
|
+
status: "incomplete",
|
|
4714
|
+
incompleteReason: reportProjectionFailureReason(error, projection.failure),
|
|
4715
|
+
target: { kind: "custom" },
|
|
4716
|
+
server: void 0,
|
|
4717
|
+
capabilities: void 0,
|
|
4718
|
+
manifestHash: void 0,
|
|
4719
|
+
failure: void 0,
|
|
4720
|
+
results: [],
|
|
4721
|
+
timeline: [],
|
|
4722
|
+
droppedResults: report.results.length,
|
|
4723
|
+
droppedTimelineEvents: report.droppedTimelineEvents + report.timeline.length
|
|
4724
|
+
};
|
|
4725
|
+
try {
|
|
4726
|
+
finalized = boundedSuiteReport(
|
|
4727
|
+
redactTargetCredentials(options.target, fallback, { preserveKeys: true }),
|
|
4728
|
+
guard,
|
|
4729
|
+
maxReportBytes
|
|
4730
|
+
);
|
|
4731
|
+
} catch (fallbackError) {
|
|
4732
|
+
if (fallbackError instanceof McpFnRedactionLimitError) {
|
|
4733
|
+
return structuralReportFailure(retainedCleanup);
|
|
4734
|
+
}
|
|
4735
|
+
throw fallbackError;
|
|
4736
|
+
}
|
|
4737
|
+
}
|
|
4738
|
+
if (retainedCleanup) throw new McpFnTargetSuiteCleanupError(finalized, retainedCleanup);
|
|
4739
|
+
return finalized;
|
|
4740
|
+
}
|
|
4741
|
+
async function runTargetSuite(options) {
|
|
4742
|
+
const maxTimelineEvents = options.maxTimelineEvents ?? 500;
|
|
4743
|
+
if (!Number.isInteger(maxTimelineEvents) || maxTimelineEvents < 1) {
|
|
4744
|
+
throw new Error("maxTimelineEvents must be a positive integer");
|
|
4745
|
+
}
|
|
4746
|
+
const maxReportBytes = options.maxReportBytes ?? 1048576;
|
|
4747
|
+
validateReportCap(maxReportBytes);
|
|
4748
|
+
const consumerDiagnostic = options.client?.diagnostics;
|
|
4749
|
+
const diagnostics = new SuiteDiagnosticCollector(
|
|
4750
|
+
maxTimelineEvents,
|
|
4751
|
+
maxReportBytes,
|
|
4752
|
+
options.target,
|
|
4753
|
+
consumerDiagnostic
|
|
4754
|
+
);
|
|
4755
|
+
const connection = await connectSuite(options, diagnostics);
|
|
4756
|
+
const { projection, structureGuard, closeResult } = await projectAndCloseSuite(
|
|
4757
|
+
options,
|
|
4758
|
+
diagnostics,
|
|
4759
|
+
connection
|
|
4760
|
+
);
|
|
4761
|
+
const { cleanupFailure: closeFailure, retainedCleanup } = closeResult;
|
|
4762
|
+
diagnostics.captureUnobserved(connection.client);
|
|
4763
|
+
const lifecycleRedactionOmissions = Math.max(
|
|
4764
|
+
0,
|
|
4765
|
+
(connection.client?.session.getRedactionOmissionCounts().clientEvents ?? connection.initialClientEventRedactionOmissions) - connection.initialClientEventRedactionOmissions
|
|
4766
|
+
);
|
|
4767
|
+
let cleanupFailure = closeFailure;
|
|
4768
|
+
cleanupFailure ??= diagnostics.cleanupFailure;
|
|
4769
|
+
const failure = connection.failure ?? cleanupFailure;
|
|
4770
|
+
const report = buildSuiteReport(
|
|
4771
|
+
projection,
|
|
4772
|
+
connection.manifestChecked,
|
|
4773
|
+
failure,
|
|
4774
|
+
cleanupFailure,
|
|
4775
|
+
diagnostics,
|
|
4776
|
+
lifecycleRedactionOmissions
|
|
4777
|
+
);
|
|
4778
|
+
return finalizeSuiteReport(
|
|
4779
|
+
options,
|
|
4780
|
+
projection,
|
|
4781
|
+
report,
|
|
4782
|
+
structureGuard,
|
|
4783
|
+
maxReportBytes,
|
|
4784
|
+
retainedCleanup
|
|
4785
|
+
);
|
|
1961
4786
|
}
|
|
1962
4787
|
function enforceReportCap(report, maxBytes) {
|
|
1963
4788
|
validateReportCap(maxBytes);
|
|
@@ -1977,6 +4802,10 @@ function enforceReportCap(report, maxBytes) {
|
|
|
1977
4802
|
bounded.capabilities = void 0;
|
|
1978
4803
|
bounded.timeline = [];
|
|
1979
4804
|
bounded.droppedTimelineEvents += report.timeline.length;
|
|
4805
|
+
if (bounded.failure) {
|
|
4806
|
+
bounded.failure.details = void 0;
|
|
4807
|
+
bounded.failure.message = "Target operation failed; details omitted to fit report budget";
|
|
4808
|
+
}
|
|
1980
4809
|
}
|
|
1981
4810
|
if (bounded.droppedResults > 0 || jsonBytes(bounded) > maxBytes) {
|
|
1982
4811
|
bounded.ok = false;
|
|
@@ -2001,36 +4830,65 @@ function jsonBytes(value) {
|
|
|
2001
4830
|
MCPFN_HOST_PROFILES,
|
|
2002
4831
|
MCPFN_NAMED_OAUTH_HOST_FIXTURES,
|
|
2003
4832
|
MCPFN_OAUTH_EXTENSION_GRANTS,
|
|
4833
|
+
MCPFN_REPORT_SCHEMA_VERSION,
|
|
4834
|
+
MCPFN_TESTING_VERSION,
|
|
2004
4835
|
McpFnAssertionError,
|
|
2005
4836
|
McpFnAuthAssertionError,
|
|
4837
|
+
McpFnClientProfileContractCleanupError,
|
|
4838
|
+
McpFnConformanceCleanupError,
|
|
2006
4839
|
McpFnMockOAuthAuthorizationServer,
|
|
4840
|
+
McpFnRedactionLimitError,
|
|
4841
|
+
McpFnStructuralCredentialCollisionError,
|
|
4842
|
+
McpFnTargetSuiteArtifactCleanupError,
|
|
4843
|
+
McpFnTargetSuiteCleanupError,
|
|
2007
4844
|
McpFnTestClient,
|
|
4845
|
+
McpFnTestClientCleanupError,
|
|
2008
4846
|
OFFICIAL_CONFORMANCE_VERSION,
|
|
4847
|
+
acquireRemoteCredential,
|
|
2009
4848
|
apiKeyCredential,
|
|
2010
4849
|
assertAuthRegressionSuite,
|
|
2011
4850
|
assertAuthorizationCodeClientMetadata,
|
|
2012
4851
|
assertBearerResourceChallenge,
|
|
2013
4852
|
assertManifestContract,
|
|
2014
4853
|
assertStructuredTextParity,
|
|
4854
|
+
assertTargetArtifactSafe,
|
|
4855
|
+
authenticatedHttpTarget,
|
|
2015
4856
|
bearerCredential,
|
|
4857
|
+
beginTargetCredentialRedaction,
|
|
2016
4858
|
buildOfficialConformanceArgs,
|
|
4859
|
+
buildOfficialConformanceEnvironment,
|
|
2017
4860
|
checkHostCompatibility,
|
|
2018
4861
|
createAuthenticatedConformanceProxy,
|
|
2019
4862
|
createFetchAuthTarget,
|
|
4863
|
+
createHostedAuthorizationFixtures,
|
|
4864
|
+
createMcpFnClientProfileSnapshot,
|
|
2020
4865
|
createMcpFnScenario,
|
|
2021
4866
|
createMcpFnScenarioArtifact,
|
|
2022
4867
|
createMcpFnScenarioReport,
|
|
4868
|
+
createMcpFnTargetSuiteJUnit,
|
|
2023
4869
|
createOAuthClientMetadataFixture,
|
|
2024
4870
|
createOAuthClientMetadataVariants,
|
|
2025
4871
|
createPkcePair,
|
|
4872
|
+
diffMcpFnClientProfileSnapshots,
|
|
4873
|
+
disposeMcpFnTargetSuiteReport,
|
|
4874
|
+
normalizeMcpFnReportFailure,
|
|
2026
4875
|
parseBearerChallenge,
|
|
4876
|
+
redactRemoteCredential,
|
|
4877
|
+
redactTargetCredentials,
|
|
2027
4878
|
runAuthRegressionSuite,
|
|
2028
4879
|
runAuthenticatedOfficialConformance,
|
|
4880
|
+
runHostedAuthorizationRegression,
|
|
4881
|
+
runMcpFnClientProfileContracts,
|
|
2029
4882
|
runMcpFnTargetSuite,
|
|
2030
4883
|
runOfficialConformance,
|
|
2031
4884
|
runScenarios,
|
|
4885
|
+
serializeMcpFnTargetSuiteReport,
|
|
2032
4886
|
stableJson,
|
|
2033
4887
|
startMockOAuthAuthorizationServer,
|
|
2034
|
-
|
|
4888
|
+
staticRemoteCredentialProvider,
|
|
4889
|
+
validateMcpFnClientProfileSnapshot,
|
|
4890
|
+
validateMcpFnScenarios,
|
|
4891
|
+
validateMcpFnSchemaPortability,
|
|
4892
|
+
validateRemoteCredentialHeaders
|
|
2035
4893
|
});
|
|
2036
4894
|
//# sourceMappingURL=index.cjs.map
|