@mcpfn/testing 0.0.4 → 0.0.5
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 +6 -5
package/dist/index.js
CHANGED
|
@@ -490,7 +490,7 @@ function uniqueScopes(value) {
|
|
|
490
490
|
function json(status, body) {
|
|
491
491
|
return Response.json(body, {
|
|
492
492
|
status,
|
|
493
|
-
headers: { "cache-control": "no-store" }
|
|
493
|
+
headers: { "cache-control": "no-store", pragma: "no-cache" }
|
|
494
494
|
});
|
|
495
495
|
}
|
|
496
496
|
function oauthError(status, error, description) {
|
|
@@ -595,8 +595,8 @@ var McpFnMockOAuthAuthorizationServer = class {
|
|
|
595
595
|
}
|
|
596
596
|
async handle(request) {
|
|
597
597
|
const url = new URL(request.url);
|
|
598
|
-
const
|
|
599
|
-
if (request.method === "GET" &&
|
|
598
|
+
const path3 = url.pathname.replace(this.issuer.pathname.replace(/\/$/, ""), "") || "/";
|
|
599
|
+
if (request.method === "GET" && path3 === "/.well-known/oauth-authorization-server") {
|
|
600
600
|
return json(200, {
|
|
601
601
|
issuer: this.issuer.toString(),
|
|
602
602
|
authorization_endpoint: this.authorizationEndpoint,
|
|
@@ -610,20 +610,20 @@ var McpFnMockOAuthAuthorizationServer = class {
|
|
|
610
610
|
client_id_metadata_document_supported: true
|
|
611
611
|
});
|
|
612
612
|
}
|
|
613
|
-
if (request.method === "GET" &&
|
|
613
|
+
if (request.method === "GET" && path3.startsWith("/client-metadata/")) {
|
|
614
614
|
const metadata = Object.values(this.clientMetadata).find((candidate) => new URL(candidate.client_id).pathname === url.pathname);
|
|
615
615
|
return metadata ? json(200, metadata) : json(404, { error: "not_found" });
|
|
616
616
|
}
|
|
617
|
-
if (request.method === "GET" &&
|
|
617
|
+
if (request.method === "GET" && path3 === "/authorize") {
|
|
618
618
|
return this.authorize(url);
|
|
619
619
|
}
|
|
620
|
-
if (request.method === "POST" &&
|
|
620
|
+
if (request.method === "POST" && path3 === "/token") {
|
|
621
621
|
return this.token(await request.text());
|
|
622
622
|
}
|
|
623
|
-
if (request.method === "POST" &&
|
|
623
|
+
if (request.method === "POST" && path3 === "/revoke") {
|
|
624
624
|
return this.revoke(await request.text());
|
|
625
625
|
}
|
|
626
|
-
if (request.method === "GET" &&
|
|
626
|
+
if (request.method === "GET" && path3 === "/callback") {
|
|
627
627
|
const parameters = Object.fromEntries(url.searchParams);
|
|
628
628
|
this.callbacks.push({ url: url.toString(), parameters });
|
|
629
629
|
return new Response(
|
|
@@ -837,6 +837,34 @@ import {
|
|
|
837
837
|
customTarget
|
|
838
838
|
} from "@mcpfn/client";
|
|
839
839
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
840
|
+
var testClientCleanupOwners = /* @__PURE__ */ new WeakMap();
|
|
841
|
+
var McpFnTestClientCleanupError = class extends Error {
|
|
842
|
+
constructor(close, cause) {
|
|
843
|
+
super(
|
|
844
|
+
"Test client cleanup remains pending; retain this error and retryCleanup()",
|
|
845
|
+
{ cause }
|
|
846
|
+
);
|
|
847
|
+
this.name = "McpFnTestClientCleanupError";
|
|
848
|
+
testClientCleanupOwners.set(this, { close });
|
|
849
|
+
}
|
|
850
|
+
retryCleanup() {
|
|
851
|
+
const owner = testClientCleanupOwners.get(this);
|
|
852
|
+
if (!owner) return Promise.resolve();
|
|
853
|
+
if (owner.pending) return owner.pending;
|
|
854
|
+
const pending = Promise.resolve().then(owner.close).then(
|
|
855
|
+
() => {
|
|
856
|
+
testClientCleanupOwners.delete(this);
|
|
857
|
+
},
|
|
858
|
+
() => {
|
|
859
|
+
throw this;
|
|
860
|
+
}
|
|
861
|
+
).finally(() => {
|
|
862
|
+
owner.pending = void 0;
|
|
863
|
+
});
|
|
864
|
+
owner.pending = pending;
|
|
865
|
+
return pending;
|
|
866
|
+
}
|
|
867
|
+
};
|
|
840
868
|
var McpFnTestClient = class _McpFnTestClient {
|
|
841
869
|
session;
|
|
842
870
|
constructor(session) {
|
|
@@ -857,8 +885,9 @@ var McpFnTestClient = class _McpFnTestClient {
|
|
|
857
885
|
}
|
|
858
886
|
}), info, options);
|
|
859
887
|
}
|
|
860
|
-
|
|
861
|
-
|
|
888
|
+
/** Create the session owner before connecting, so failed initialization remains closeable. */
|
|
889
|
+
static createTarget(target, info, options = {}) {
|
|
890
|
+
return new _McpFnTestClient(createMcpFnClient({
|
|
862
891
|
target,
|
|
863
892
|
info: info ?? { name: "mcpfn-test-client", version: "1.0.0" },
|
|
864
893
|
capabilities: options.capabilities,
|
|
@@ -866,12 +895,20 @@ var McpFnTestClient = class _McpFnTestClient {
|
|
|
866
895
|
events: options.events,
|
|
867
896
|
configure: options.configure,
|
|
868
897
|
diagnostics: options.diagnostics
|
|
869
|
-
});
|
|
898
|
+
}));
|
|
899
|
+
}
|
|
900
|
+
static async connectTarget(target, info, options = {}) {
|
|
901
|
+
const client = this.createTarget(target, info, options);
|
|
870
902
|
try {
|
|
871
|
-
await session.connect();
|
|
872
|
-
return
|
|
903
|
+
await client.session.connect();
|
|
904
|
+
return client;
|
|
873
905
|
} catch (error) {
|
|
874
|
-
|
|
906
|
+
const connectionFailure = error instanceof Error && error.cause !== void 0 ? error.cause : error;
|
|
907
|
+
try {
|
|
908
|
+
await client.session.close();
|
|
909
|
+
} catch {
|
|
910
|
+
throw new McpFnTestClientCleanupError(() => client.session.close(), connectionFailure);
|
|
911
|
+
}
|
|
875
912
|
throw error;
|
|
876
913
|
}
|
|
877
914
|
}
|
|
@@ -920,23 +957,1585 @@ var McpFnTestClient = class _McpFnTestClient {
|
|
|
920
957
|
complete(params, options) {
|
|
921
958
|
return this.session.prompts.complete(params, options);
|
|
922
959
|
}
|
|
923
|
-
close() {
|
|
924
|
-
|
|
960
|
+
async close() {
|
|
961
|
+
try {
|
|
962
|
+
await this.session.close();
|
|
963
|
+
} catch (error) {
|
|
964
|
+
throw new McpFnTestClientCleanupError(() => this.session.close(), error);
|
|
965
|
+
}
|
|
925
966
|
}
|
|
926
967
|
};
|
|
927
968
|
|
|
969
|
+
// src/client-profile-contracts.ts
|
|
970
|
+
import { createHash } from "crypto";
|
|
971
|
+
import Ajv from "ajv";
|
|
972
|
+
import Ajv2019 from "ajv/dist/2019.js";
|
|
973
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
974
|
+
import { redactOAuthValue } from "@superfunctions/oauth-core";
|
|
975
|
+
var McpFnClientProfileContractCleanupError = class extends Error {
|
|
976
|
+
constructor(report, owners) {
|
|
977
|
+
super("Client profile target cleanup remains pending; retain this error and retryCleanup()");
|
|
978
|
+
this.report = report;
|
|
979
|
+
this.owners = owners;
|
|
980
|
+
this.name = "McpFnClientProfileContractCleanupError";
|
|
981
|
+
}
|
|
982
|
+
report;
|
|
983
|
+
owners;
|
|
984
|
+
async retryCleanup() {
|
|
985
|
+
const results = await Promise.allSettled(this.owners.map((owner) => owner.retryCleanup()));
|
|
986
|
+
if (results.some((result) => result.status === "rejected")) throw this;
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
var ProfileCaseCleanupError = class extends Error {
|
|
990
|
+
constructor(owner, result) {
|
|
991
|
+
super("Client profile target cleanup remains pending");
|
|
992
|
+
this.owner = owner;
|
|
993
|
+
this.result = result;
|
|
994
|
+
}
|
|
995
|
+
owner;
|
|
996
|
+
result;
|
|
997
|
+
};
|
|
998
|
+
var PORTABILITY_KEYWORDS = /* @__PURE__ */ new Set([
|
|
999
|
+
"$anchor",
|
|
1000
|
+
"$recursiveAnchor",
|
|
1001
|
+
"$recursiveRef",
|
|
1002
|
+
"$dynamicAnchor",
|
|
1003
|
+
"$dynamicRef",
|
|
1004
|
+
"dependentSchemas",
|
|
1005
|
+
"dependentRequired",
|
|
1006
|
+
"minContains",
|
|
1007
|
+
"maxContains",
|
|
1008
|
+
"prefixItems",
|
|
1009
|
+
"unevaluatedItems",
|
|
1010
|
+
"unevaluatedProperties"
|
|
1011
|
+
]);
|
|
1012
|
+
function hash(value) {
|
|
1013
|
+
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
1014
|
+
}
|
|
1015
|
+
function boundedError(value) {
|
|
1016
|
+
const redacted = String(
|
|
1017
|
+
redactOAuthValue(value instanceof Error ? value.message : value)
|
|
1018
|
+
);
|
|
1019
|
+
return redacted.slice(0, 512);
|
|
1020
|
+
}
|
|
1021
|
+
function assertProfileReference(id, version) {
|
|
1022
|
+
for (const [label, value] of [
|
|
1023
|
+
["id", id],
|
|
1024
|
+
["version", version]
|
|
1025
|
+
]) {
|
|
1026
|
+
if (typeof value !== "string" || !value || value.length > 128 || !/^[A-Za-z0-9._/-]+$/.test(value)) {
|
|
1027
|
+
throw new Error(
|
|
1028
|
+
`Client profile ${label} must be a non-empty stable identifier`
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function createMcpFnClientProfileSnapshot(profile, tools) {
|
|
1034
|
+
assertProfileReference(profile.id, profile.version);
|
|
1035
|
+
const normalized = [...tools].map((tool) => structuredClone(tool)).sort(
|
|
1036
|
+
(left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
|
1037
|
+
);
|
|
1038
|
+
if (normalized.some((tool, index) => index > 0 && tool.name === normalized[index - 1].name)) {
|
|
1039
|
+
throw new Error("Effective catalog contains duplicate tool names");
|
|
1040
|
+
}
|
|
1041
|
+
return {
|
|
1042
|
+
formatVersion: 1,
|
|
1043
|
+
kind: "mcpfn.client-profile-snapshot",
|
|
1044
|
+
profile: { id: profile.id, version: profile.version },
|
|
1045
|
+
catalogHash: hash(normalized),
|
|
1046
|
+
tools: normalized.map((tool) => ({ name: tool.name, hash: hash(tool) }))
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
function assertSnapshotFields(value, fields, location) {
|
|
1050
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1051
|
+
throw new Error(`Client profile snapshot ${location} must be an object`);
|
|
1052
|
+
}
|
|
1053
|
+
const key = Object.keys(value).find((key2) => !fields.includes(key2));
|
|
1054
|
+
if (key !== void 0) {
|
|
1055
|
+
throw new Error(`Client profile snapshot contains unsupported fields at ${location}: ${JSON.stringify(key.slice(0, 128))}`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
function validateMcpFnClientProfileSnapshot(value) {
|
|
1059
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1060
|
+
throw new Error("Client profile snapshot must be an object");
|
|
1061
|
+
}
|
|
1062
|
+
assertSnapshotFields(value, ["formatVersion", "kind", "profile", "catalogHash", "tools"], "root");
|
|
1063
|
+
const snapshot = value;
|
|
1064
|
+
if (snapshot.formatVersion !== 1 || snapshot.kind !== "mcpfn.client-profile-snapshot") {
|
|
1065
|
+
throw new Error("Unsupported client profile snapshot format");
|
|
1066
|
+
}
|
|
1067
|
+
if (!snapshot.profile)
|
|
1068
|
+
throw new Error("Client profile snapshot requires profile metadata");
|
|
1069
|
+
assertSnapshotFields(snapshot.profile, ["id", "version"], "profile");
|
|
1070
|
+
assertProfileReference(snapshot.profile.id, snapshot.profile.version);
|
|
1071
|
+
if (typeof snapshot.catalogHash !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.catalogHash)) {
|
|
1072
|
+
throw new Error("Client profile snapshot requires a SHA-256 catalogHash");
|
|
1073
|
+
}
|
|
1074
|
+
if (!Array.isArray(snapshot.tools))
|
|
1075
|
+
throw new Error("Client profile snapshot tools must be an array");
|
|
1076
|
+
let prior = "";
|
|
1077
|
+
for (const [index, tool] of snapshot.tools.entries()) {
|
|
1078
|
+
assertSnapshotFields(tool, ["name", "hash"], `tools[${index}]`);
|
|
1079
|
+
if (!tool || typeof tool.name !== "string" || !tool.name || !/^[a-f0-9]{64}$/.test(tool.hash)) {
|
|
1080
|
+
throw new Error("Client profile snapshot contains an invalid tool entry");
|
|
1081
|
+
}
|
|
1082
|
+
if (tool.name <= prior)
|
|
1083
|
+
throw new Error(
|
|
1084
|
+
"Client profile snapshot tools must be sorted and unique"
|
|
1085
|
+
);
|
|
1086
|
+
prior = tool.name;
|
|
1087
|
+
}
|
|
1088
|
+
return snapshot;
|
|
1089
|
+
}
|
|
1090
|
+
function diffMcpFnClientProfileSnapshots(beforeValue, afterValue) {
|
|
1091
|
+
const before = validateMcpFnClientProfileSnapshot(beforeValue);
|
|
1092
|
+
const after = validateMcpFnClientProfileSnapshot(afterValue);
|
|
1093
|
+
if (before.profile.id !== after.profile.id) {
|
|
1094
|
+
throw new Error("Cannot diff snapshots for different client profile ids");
|
|
1095
|
+
}
|
|
1096
|
+
const beforeByName = new Map(
|
|
1097
|
+
before.tools.map((tool) => [tool.name, tool.hash])
|
|
1098
|
+
);
|
|
1099
|
+
const afterByName = new Map(
|
|
1100
|
+
after.tools.map((tool) => [tool.name, tool.hash])
|
|
1101
|
+
);
|
|
1102
|
+
const names = [
|
|
1103
|
+
.../* @__PURE__ */ new Set([...beforeByName.keys(), ...afterByName.keys()])
|
|
1104
|
+
].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
1105
|
+
const changes = [];
|
|
1106
|
+
for (const tool of names) {
|
|
1107
|
+
const beforeHash = beforeByName.get(tool);
|
|
1108
|
+
const afterHash = afterByName.get(tool);
|
|
1109
|
+
if (!beforeHash) changes.push({ kind: "added", tool, afterHash });
|
|
1110
|
+
else if (!afterHash) changes.push({ kind: "removed", tool, beforeHash });
|
|
1111
|
+
else if (beforeHash !== afterHash) {
|
|
1112
|
+
changes.push({ kind: "modified", tool, beforeHash, afterHash });
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (changes.length === 0 !== (before.catalogHash === after.catalogHash)) {
|
|
1116
|
+
throw new Error("Inconsistent catalog hashes for tool entries");
|
|
1117
|
+
}
|
|
1118
|
+
const summary = {
|
|
1119
|
+
added: changes.filter(({ kind }) => kind === "added").length,
|
|
1120
|
+
removed: changes.filter(({ kind }) => kind === "removed").length,
|
|
1121
|
+
modified: changes.filter(({ kind }) => kind === "modified").length
|
|
1122
|
+
};
|
|
1123
|
+
return { compatible: summary.removed === 0, changes, summary };
|
|
1124
|
+
}
|
|
1125
|
+
function createDialectValidator(dialect) {
|
|
1126
|
+
const normalized = dialect.replace(/#$/, "");
|
|
1127
|
+
if (normalized === "http://json-schema.org/draft-07/schema" || normalized === "https://json-schema.org/draft-07/schema")
|
|
1128
|
+
return new Ajv({ allErrors: true, strict: false, validateFormats: false });
|
|
1129
|
+
if (normalized === "http://json-schema.org/draft/2019-09/schema" || normalized === "https://json-schema.org/draft/2019-09/schema")
|
|
1130
|
+
return new Ajv2019({
|
|
1131
|
+
allErrors: true,
|
|
1132
|
+
strict: false,
|
|
1133
|
+
validateFormats: false
|
|
1134
|
+
});
|
|
1135
|
+
if (normalized === "http://json-schema.org/draft/2020-12/schema" || normalized === "https://json-schema.org/draft/2020-12/schema")
|
|
1136
|
+
return new Ajv2020({
|
|
1137
|
+
allErrors: true,
|
|
1138
|
+
strict: false,
|
|
1139
|
+
validateFormats: false
|
|
1140
|
+
});
|
|
1141
|
+
return void 0;
|
|
1142
|
+
}
|
|
1143
|
+
function pointerSegment(value) {
|
|
1144
|
+
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
1145
|
+
}
|
|
1146
|
+
function walkSchema(value, path3, visit) {
|
|
1147
|
+
if (!value || typeof value !== "object") return;
|
|
1148
|
+
const schema = value;
|
|
1149
|
+
for (const keyword of Object.keys(schema)) {
|
|
1150
|
+
visit(keyword, `${path3}/${pointerSegment(keyword)}`, schema);
|
|
1151
|
+
}
|
|
1152
|
+
for (const mapKeyword of [
|
|
1153
|
+
"$defs",
|
|
1154
|
+
"definitions",
|
|
1155
|
+
"dependencies",
|
|
1156
|
+
"dependentSchemas",
|
|
1157
|
+
"patternProperties",
|
|
1158
|
+
"properties"
|
|
1159
|
+
]) {
|
|
1160
|
+
const entries = schema[mapKeyword];
|
|
1161
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
|
1162
|
+
continue;
|
|
1163
|
+
for (const [name, child] of Object.entries(
|
|
1164
|
+
entries
|
|
1165
|
+
)) {
|
|
1166
|
+
walkSchema(child, `${path3}/${mapKeyword}/${pointerSegment(name)}`, visit);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
for (const arrayKeyword of ["allOf", "anyOf", "oneOf", "prefixItems", "items"]) {
|
|
1170
|
+
const entries = schema[arrayKeyword];
|
|
1171
|
+
if (!Array.isArray(entries)) continue;
|
|
1172
|
+
entries.forEach(
|
|
1173
|
+
(child, index) => walkSchema(child, `${path3}/${arrayKeyword}/${index}`, visit)
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
for (const schemaKeyword of [
|
|
1177
|
+
"additionalItems",
|
|
1178
|
+
"additionalProperties",
|
|
1179
|
+
"contains",
|
|
1180
|
+
"contentSchema",
|
|
1181
|
+
"else",
|
|
1182
|
+
"if",
|
|
1183
|
+
"items",
|
|
1184
|
+
"not",
|
|
1185
|
+
"propertyNames",
|
|
1186
|
+
"then",
|
|
1187
|
+
"unevaluatedItems",
|
|
1188
|
+
"unevaluatedProperties"
|
|
1189
|
+
]) {
|
|
1190
|
+
const child = schema[schemaKeyword];
|
|
1191
|
+
if (child && typeof child === "object" && !Array.isArray(child)) {
|
|
1192
|
+
walkSchema(child, `${path3}/${schemaKeyword}`, visit);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
function validateMcpFnSchemaPortability(schema, path3, policy = {}) {
|
|
1197
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
1198
|
+
return [
|
|
1199
|
+
{
|
|
1200
|
+
severity: "error",
|
|
1201
|
+
code: "schema-invalid",
|
|
1202
|
+
path: path3,
|
|
1203
|
+
message: "Tool schema must be an object"
|
|
1204
|
+
}
|
|
1205
|
+
];
|
|
1206
|
+
}
|
|
1207
|
+
const declared = schema.$schema;
|
|
1208
|
+
const dialect = typeof declared === "string" ? declared : "http://json-schema.org/draft-07/schema#";
|
|
1209
|
+
const validator = createDialectValidator(dialect);
|
|
1210
|
+
if (!validator) {
|
|
1211
|
+
return [
|
|
1212
|
+
{
|
|
1213
|
+
severity: "error",
|
|
1214
|
+
code: "schema-dialect-unsupported",
|
|
1215
|
+
path: `${path3}/$schema`,
|
|
1216
|
+
message: boundedError(`Unsupported JSON Schema dialect ${dialect}`)
|
|
1217
|
+
}
|
|
1218
|
+
];
|
|
1219
|
+
}
|
|
1220
|
+
const issues = [];
|
|
1221
|
+
try {
|
|
1222
|
+
validator.compile(schema);
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
issues.push({
|
|
1225
|
+
severity: "error",
|
|
1226
|
+
code: "schema-invalid",
|
|
1227
|
+
path: path3,
|
|
1228
|
+
message: boundedError(error)
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
const allowed = new Set(policy.allowKeywords ?? []);
|
|
1232
|
+
const modernDialect = /draft\/(2019-09|2020-12)\/schema#?$/.test(dialect);
|
|
1233
|
+
const assertionKeywords = /* @__PURE__ */ new Set([
|
|
1234
|
+
"type",
|
|
1235
|
+
"enum",
|
|
1236
|
+
"const",
|
|
1237
|
+
"multipleOf",
|
|
1238
|
+
"maximum",
|
|
1239
|
+
"exclusiveMaximum",
|
|
1240
|
+
"minimum",
|
|
1241
|
+
"exclusiveMinimum",
|
|
1242
|
+
"maxLength",
|
|
1243
|
+
"minLength",
|
|
1244
|
+
"pattern",
|
|
1245
|
+
"format",
|
|
1246
|
+
"maxItems",
|
|
1247
|
+
"minItems",
|
|
1248
|
+
"uniqueItems",
|
|
1249
|
+
"contains",
|
|
1250
|
+
"minContains",
|
|
1251
|
+
"maxContains",
|
|
1252
|
+
"maxProperties",
|
|
1253
|
+
"minProperties",
|
|
1254
|
+
"required",
|
|
1255
|
+
"dependentRequired",
|
|
1256
|
+
"properties",
|
|
1257
|
+
"patternProperties",
|
|
1258
|
+
"additionalProperties",
|
|
1259
|
+
"propertyNames",
|
|
1260
|
+
"dependentSchemas",
|
|
1261
|
+
"items",
|
|
1262
|
+
"prefixItems",
|
|
1263
|
+
"additionalItems",
|
|
1264
|
+
"unevaluatedItems",
|
|
1265
|
+
"unevaluatedProperties",
|
|
1266
|
+
"allOf",
|
|
1267
|
+
"anyOf",
|
|
1268
|
+
"oneOf",
|
|
1269
|
+
"not",
|
|
1270
|
+
"if",
|
|
1271
|
+
"then",
|
|
1272
|
+
"else"
|
|
1273
|
+
]);
|
|
1274
|
+
walkSchema(schema, path3, (keyword, keywordPath, node) => {
|
|
1275
|
+
if (modernDialect && keyword === "$ref" && !allowed.has(keyword) && Object.keys(node).some((sibling) => assertionKeywords.has(sibling))) {
|
|
1276
|
+
issues.push({
|
|
1277
|
+
severity: policy.warningsAsErrors ? "error" : "warning",
|
|
1278
|
+
code: "schema-portability-warning",
|
|
1279
|
+
path: keywordPath,
|
|
1280
|
+
keyword,
|
|
1281
|
+
message: "$ref assertion siblings are evaluated by modern dialects but ignored by draft-07 clients"
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
if (!PORTABILITY_KEYWORDS.has(keyword) || allowed.has(keyword)) return;
|
|
1285
|
+
issues.push({
|
|
1286
|
+
severity: policy.warningsAsErrors ? "error" : "warning",
|
|
1287
|
+
code: "schema-portability-warning",
|
|
1288
|
+
path: keywordPath,
|
|
1289
|
+
keyword,
|
|
1290
|
+
message: `${keyword} is valid but may reduce client portability`
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
return issues.sort(
|
|
1294
|
+
(left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
function extractError(result) {
|
|
1298
|
+
const structured = result.structuredContent;
|
|
1299
|
+
if (structured?.error && typeof structured.error === "object") {
|
|
1300
|
+
return structured.error;
|
|
1301
|
+
}
|
|
1302
|
+
const text = result.content.find((content) => content.type === "text")?.text;
|
|
1303
|
+
if (!text) return void 0;
|
|
1304
|
+
try {
|
|
1305
|
+
const parsed = JSON.parse(text);
|
|
1306
|
+
return parsed.error && typeof parsed.error === "object" ? parsed.error : void 0;
|
|
1307
|
+
} catch {
|
|
1308
|
+
return void 0;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
function fixtureFailure(result, fixture) {
|
|
1312
|
+
const expected = fixture.expect;
|
|
1313
|
+
if (fixture.source !== "captured-failure" && result.isError) return "Minimal-valid fixture returned isError=true";
|
|
1314
|
+
if (fixture.source === "captured-failure" && !result.isError) return "Captured failure did not reproduce an error";
|
|
1315
|
+
if (!expected)
|
|
1316
|
+
return result.isError ? "Tool returned isError=true" : void 0;
|
|
1317
|
+
if (expected.isError !== void 0 && Boolean(result.isError) !== expected.isError) {
|
|
1318
|
+
return `Expected isError=${expected.isError}, received ${Boolean(result.isError)}`;
|
|
1319
|
+
}
|
|
1320
|
+
if (expected.structuredContent !== void 0 && stableJson(result.structuredContent) !== stableJson(expected.structuredContent))
|
|
1321
|
+
return "Structured content did not match the fixture expectation";
|
|
1322
|
+
const error = extractError(result);
|
|
1323
|
+
if (expected.errorCode !== void 0 && error?.code !== expected.errorCode) {
|
|
1324
|
+
return "Error code did not match the declared expectation";
|
|
1325
|
+
}
|
|
1326
|
+
const details = error?.details && typeof error.details === "object" ? error.details : void 0;
|
|
1327
|
+
if (expected.lifecycleStage !== void 0 && details?.lifecycleStage !== expected.lifecycleStage)
|
|
1328
|
+
return `Expected lifecycle stage ${expected.lifecycleStage}`;
|
|
1329
|
+
if (expected.validationIssue) {
|
|
1330
|
+
const issues = Array.isArray(details?.issues) ? details.issues : [];
|
|
1331
|
+
const fields = Object.entries(expected.validationIssue).filter(([, value]) => value !== void 0);
|
|
1332
|
+
const matched = fields.length > 0 && issues.some(
|
|
1333
|
+
(issue) => fields.every(
|
|
1334
|
+
([key, value]) => issue[key] === value
|
|
1335
|
+
)
|
|
1336
|
+
);
|
|
1337
|
+
if (!matched)
|
|
1338
|
+
return "Expected structured validation issue was not reported";
|
|
1339
|
+
}
|
|
1340
|
+
return void 0;
|
|
1341
|
+
}
|
|
1342
|
+
async function runProfileCase(profileCase, allowSideEffects) {
|
|
1343
|
+
const profile = { id: profileCase.id, version: profileCase.version };
|
|
1344
|
+
let client;
|
|
1345
|
+
let configurationError;
|
|
1346
|
+
let pendingFailure;
|
|
1347
|
+
let hasPendingFailure = false;
|
|
1348
|
+
let cleanupOwner;
|
|
1349
|
+
let result = {
|
|
1350
|
+
profile,
|
|
1351
|
+
status: "complete",
|
|
1352
|
+
ok: false,
|
|
1353
|
+
portability: [],
|
|
1354
|
+
fixtures: []
|
|
1355
|
+
};
|
|
1356
|
+
try {
|
|
1357
|
+
try {
|
|
1358
|
+
client = await McpFnTestClient.connectTarget(
|
|
1359
|
+
profileCase.target,
|
|
1360
|
+
profileCase.clientInfo ?? {
|
|
1361
|
+
name: "mcpfn-profile-suite",
|
|
1362
|
+
version: "1.0.0"
|
|
1363
|
+
},
|
|
1364
|
+
{ ...profileCase.client, capabilities: profileCase.capabilities }
|
|
1365
|
+
);
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
if (error instanceof McpFnTestClientCleanupError) throw error;
|
|
1368
|
+
return { ...result, phase: "connect", error: "Target connection failed" };
|
|
1369
|
+
}
|
|
1370
|
+
let tools;
|
|
1371
|
+
try {
|
|
1372
|
+
tools = await client.listTools();
|
|
1373
|
+
const repeatedTools = await client.listTools();
|
|
1374
|
+
if (stableJson(tools) !== stableJson(repeatedTools)) {
|
|
1375
|
+
throw new Error(
|
|
1376
|
+
"Effective catalog changed across identical profile list requests"
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
result.snapshot = createMcpFnClientProfileSnapshot(profile, tools);
|
|
1380
|
+
result.snapshotMatches = profileCase.expectedSnapshot ? stableJson(result.snapshot) === stableJson(profileCase.expectedSnapshot) : void 0;
|
|
1381
|
+
for (const tool of tools) {
|
|
1382
|
+
result.portability.push(
|
|
1383
|
+
...validateMcpFnSchemaPortability(
|
|
1384
|
+
tool.inputSchema,
|
|
1385
|
+
`tools/${tool.name}/inputSchema`,
|
|
1386
|
+
profileCase.portability
|
|
1387
|
+
)
|
|
1388
|
+
);
|
|
1389
|
+
if (tool.outputSchema) {
|
|
1390
|
+
result.portability.push(
|
|
1391
|
+
...validateMcpFnSchemaPortability(
|
|
1392
|
+
tool.outputSchema,
|
|
1393
|
+
`tools/${tool.name}/outputSchema`,
|
|
1394
|
+
profileCase.portability
|
|
1395
|
+
)
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
tools = void 0;
|
|
1401
|
+
result = { ...result, phase: "catalog", error: "Target catalog validation failed" };
|
|
1402
|
+
}
|
|
1403
|
+
if (tools) {
|
|
1404
|
+
const advertised = new Set(tools.map(({ name }) => name));
|
|
1405
|
+
const taskRequired = new Set(tools.filter((tool) => tool.execution?.taskSupport === "required").map((tool) => tool.name));
|
|
1406
|
+
if (profileCase.fixtures?.some((fixture) => taskRequired.has(fixture.tool))) {
|
|
1407
|
+
configurationError = new Error(`Client profile ${profile.id}@${profile.version} task-required fixtures are unsupported by this contract runner`);
|
|
1408
|
+
throw configurationError;
|
|
1409
|
+
}
|
|
1410
|
+
for (const fixture of profileCase.fixtures ?? []) {
|
|
1411
|
+
const base = {
|
|
1412
|
+
name: fixture.name,
|
|
1413
|
+
tool: fixture.tool,
|
|
1414
|
+
source: fixture.source ?? "minimal-valid"
|
|
1415
|
+
};
|
|
1416
|
+
if (!advertised.has(fixture.tool)) {
|
|
1417
|
+
result.fixtures.push({
|
|
1418
|
+
...base,
|
|
1419
|
+
status: "failed",
|
|
1420
|
+
code: "fixture-tool-not-advertised",
|
|
1421
|
+
error: "Fixture references a tool absent from the effective catalog"
|
|
1422
|
+
});
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
if (fixture.sideEffect !== "read-only" && !allowSideEffects) {
|
|
1426
|
+
result.fixtures.push({
|
|
1427
|
+
...base,
|
|
1428
|
+
status: "incomplete",
|
|
1429
|
+
code: "side-effect-not-authorized",
|
|
1430
|
+
error: "Fixture was not executed because side effects were not explicitly allowed"
|
|
1431
|
+
});
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
try {
|
|
1435
|
+
const callResult = await client.callTool(
|
|
1436
|
+
fixture.tool,
|
|
1437
|
+
fixture.arguments ?? {}
|
|
1438
|
+
);
|
|
1439
|
+
const failure = fixtureFailure(callResult, fixture);
|
|
1440
|
+
result.fixtures.push(
|
|
1441
|
+
failure ? {
|
|
1442
|
+
...base,
|
|
1443
|
+
status: "failed",
|
|
1444
|
+
code: "fixture-expectation-failed",
|
|
1445
|
+
error: failure
|
|
1446
|
+
} : { ...base, status: "passed" }
|
|
1447
|
+
);
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
result.fixtures.push({
|
|
1450
|
+
...base,
|
|
1451
|
+
status: "failed",
|
|
1452
|
+
code: "fixture-call-failed",
|
|
1453
|
+
error: "Tool invocation failed; argument values and exception text are omitted"
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
const incomplete = result.fixtures.some(
|
|
1458
|
+
({ status }) => status === "incomplete"
|
|
1459
|
+
);
|
|
1460
|
+
const failed = result.fixtures.some(({ status }) => status === "failed") || result.portability.some(({ severity }) => severity === "error") || result.snapshotMatches === false;
|
|
1461
|
+
result = {
|
|
1462
|
+
...result,
|
|
1463
|
+
phase: result.snapshotMatches === false || result.portability.some(({ severity }) => severity === "error") ? "catalog" : failed || incomplete ? "fixtures" : void 0,
|
|
1464
|
+
status: incomplete ? "incomplete" : "complete",
|
|
1465
|
+
ok: !failed && !incomplete,
|
|
1466
|
+
...result.snapshotMatches === false ? {
|
|
1467
|
+
error: "Effective catalog does not match the reviewed profile snapshot"
|
|
1468
|
+
} : {}
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
pendingFailure = error;
|
|
1473
|
+
hasPendingFailure = true;
|
|
1474
|
+
} finally {
|
|
1475
|
+
if (client) {
|
|
1476
|
+
try {
|
|
1477
|
+
await client.close();
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
if (error instanceof McpFnTestClientCleanupError) cleanupOwner = error;
|
|
1480
|
+
else {
|
|
1481
|
+
if (configurationError) configurationError.message = `${configurationError.message}; target cleanup failed`;
|
|
1482
|
+
result = {
|
|
1483
|
+
...result,
|
|
1484
|
+
ok: false,
|
|
1485
|
+
phase: result.phase ?? "close",
|
|
1486
|
+
error: result.error ? `${result.error}; target cleanup failed`.slice(0, 512) : "Target cleanup failed"
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
if (cleanupOwner) throw new ProfileCaseCleanupError(cleanupOwner, {
|
|
1493
|
+
...result,
|
|
1494
|
+
ok: false,
|
|
1495
|
+
status: "incomplete",
|
|
1496
|
+
phase: result.phase ?? (hasPendingFailure ? "fixtures" : "close"),
|
|
1497
|
+
error: hasPendingFailure ? "Profile execution failed; target cleanup remains pending" : "Target cleanup remains pending"
|
|
1498
|
+
});
|
|
1499
|
+
if (hasPendingFailure) {
|
|
1500
|
+
if (pendingFailure instanceof McpFnTestClientCleanupError) {
|
|
1501
|
+
throw new ProfileCaseCleanupError(pendingFailure, {
|
|
1502
|
+
...result,
|
|
1503
|
+
ok: false,
|
|
1504
|
+
status: "incomplete",
|
|
1505
|
+
phase: "connect",
|
|
1506
|
+
error: "Target cleanup remains pending"
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
throw pendingFailure;
|
|
1510
|
+
}
|
|
1511
|
+
return result;
|
|
1512
|
+
}
|
|
1513
|
+
async function runMcpFnClientProfileContracts(options) {
|
|
1514
|
+
const maxReportBytes = options.maxReportBytes ?? 1048576;
|
|
1515
|
+
if (!Number.isSafeInteger(maxReportBytes) || maxReportBytes < 2048) {
|
|
1516
|
+
throw new Error("maxReportBytes must be an integer of at least 2048");
|
|
1517
|
+
}
|
|
1518
|
+
if (!Array.isArray(options.profiles) || options.profiles.length === 0) {
|
|
1519
|
+
throw new Error(
|
|
1520
|
+
"At least one generic or configured client profile is required"
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
if (options.profiles.length > 100)
|
|
1524
|
+
throw new Error("At most 100 client profiles may be tested");
|
|
1525
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1526
|
+
for (const profile of options.profiles) {
|
|
1527
|
+
assertProfileReference(profile.id, profile.version);
|
|
1528
|
+
const key = `${profile.id}@${profile.version}`;
|
|
1529
|
+
if (keys.has(key)) throw new Error(`Duplicate client profile case ${key}`);
|
|
1530
|
+
keys.add(key);
|
|
1531
|
+
if (profile.expectedSnapshot)
|
|
1532
|
+
validateMcpFnClientProfileSnapshot(profile.expectedSnapshot);
|
|
1533
|
+
for (const fixture of profile.fixtures ?? []) {
|
|
1534
|
+
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)))) {
|
|
1535
|
+
throw new Error(`Captured-failure fixture ${fixture.name} requires meaningful error expectations`);
|
|
1536
|
+
}
|
|
1537
|
+
if (fixture.source !== void 0 && !["minimal-valid", "captured-failure"].includes(fixture.source)) throw new Error("Invalid fixture source");
|
|
1538
|
+
if (!fixture.name || !fixture.tool || !["read-only", "idempotent", "non-idempotent"].includes(fixture.sideEffect)) {
|
|
1539
|
+
throw new Error(`Client profile ${key} contains an invalid fixture`);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
const profiles = [];
|
|
1544
|
+
const cleanupOwners = [];
|
|
1545
|
+
for (const profile of [...options.profiles].sort((left, right) => {
|
|
1546
|
+
const leftKey = `${left.id}@${left.version}`;
|
|
1547
|
+
const rightKey = `${right.id}@${right.version}`;
|
|
1548
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
1549
|
+
})) {
|
|
1550
|
+
try {
|
|
1551
|
+
profiles.push(await runProfileCase(profile, options.allowSideEffects === true));
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
if (error instanceof ProfileCaseCleanupError) {
|
|
1554
|
+
profiles.push(error.result);
|
|
1555
|
+
cleanupOwners.push(error.owner);
|
|
1556
|
+
} else {
|
|
1557
|
+
if (cleanupOwners.length === 0) throw error;
|
|
1558
|
+
profiles.push({
|
|
1559
|
+
profile: { id: profile.id, version: profile.version },
|
|
1560
|
+
status: "incomplete",
|
|
1561
|
+
ok: false,
|
|
1562
|
+
phase: "fixtures",
|
|
1563
|
+
portability: [],
|
|
1564
|
+
fixtures: [],
|
|
1565
|
+
error: "Profile execution failed"
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
const report = {
|
|
1571
|
+
formatVersion: 1,
|
|
1572
|
+
kind: "mcpfn.client-profile-contract-report",
|
|
1573
|
+
status: profiles.some(({ status }) => status === "incomplete") ? "incomplete" : "complete",
|
|
1574
|
+
ok: profiles.every(({ ok }) => ok),
|
|
1575
|
+
profiles,
|
|
1576
|
+
droppedProfiles: 0
|
|
1577
|
+
};
|
|
1578
|
+
if (new TextEncoder().encode(JSON.stringify(report)).byteLength <= maxReportBytes)
|
|
1579
|
+
return finishReport(report, cleanupOwners);
|
|
1580
|
+
report.ok = false;
|
|
1581
|
+
report.status = "incomplete";
|
|
1582
|
+
report.incompleteReason = "Report content exceeded maxReportBytes and was truncated";
|
|
1583
|
+
while (new TextEncoder().encode(JSON.stringify(report)).byteLength > maxReportBytes && report.profiles.length > 0) {
|
|
1584
|
+
report.profiles.pop();
|
|
1585
|
+
report.droppedProfiles += 1;
|
|
1586
|
+
}
|
|
1587
|
+
if (new TextEncoder().encode(JSON.stringify(report)).byteLength > maxReportBytes) {
|
|
1588
|
+
throw new Error("The minimum client profile report exceeds maxReportBytes");
|
|
1589
|
+
}
|
|
1590
|
+
return finishReport(report, cleanupOwners);
|
|
1591
|
+
}
|
|
1592
|
+
function finishReport(report, cleanupOwners) {
|
|
1593
|
+
if (cleanupOwners.length) throw new McpFnClientProfileContractCleanupError(report, cleanupOwners);
|
|
1594
|
+
return report;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// src/conformance-process.ts
|
|
1598
|
+
import { execFile } from "child_process";
|
|
1599
|
+
import path from "path";
|
|
1600
|
+
async function terminateConformanceRunner(child, platform = process.platform, systemRoot = process.env.SystemRoot) {
|
|
1601
|
+
const killWrapper = () => {
|
|
1602
|
+
try {
|
|
1603
|
+
child.kill("SIGKILL");
|
|
1604
|
+
} catch {
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1607
|
+
try {
|
|
1608
|
+
if (platform === "win32" && child.pid) {
|
|
1609
|
+
if (!systemRoot || !path.win32.isAbsolute(systemRoot)) {
|
|
1610
|
+
killWrapper();
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
await new Promise((resolve) => {
|
|
1614
|
+
const timer = setTimeout(() => {
|
|
1615
|
+
killWrapper();
|
|
1616
|
+
resolve();
|
|
1617
|
+
}, 2500);
|
|
1618
|
+
const done = (error) => {
|
|
1619
|
+
clearTimeout(timer);
|
|
1620
|
+
if (error) killWrapper();
|
|
1621
|
+
resolve();
|
|
1622
|
+
};
|
|
1623
|
+
try {
|
|
1624
|
+
execFile(
|
|
1625
|
+
path.win32.join(systemRoot, "System32", "taskkill.exe"),
|
|
1626
|
+
["/pid", String(child.pid), "/T", "/F"],
|
|
1627
|
+
{ timeout: 2e3, windowsHide: true },
|
|
1628
|
+
done
|
|
1629
|
+
);
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
done(error);
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
} else if (child.pid) {
|
|
1635
|
+
try {
|
|
1636
|
+
process.kill(-child.pid, "SIGKILL");
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
if (error.code !== "ESRCH") killWrapper();
|
|
1639
|
+
}
|
|
1640
|
+
} else killWrapper();
|
|
1641
|
+
} finally {
|
|
1642
|
+
child.stdout?.destroy();
|
|
1643
|
+
child.stderr?.destroy();
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
928
1647
|
// src/conformance.ts
|
|
929
1648
|
import { spawn } from "child_process";
|
|
1649
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
930
1650
|
import { existsSync } from "fs";
|
|
931
1651
|
import {
|
|
932
1652
|
createServer as createServer2,
|
|
933
1653
|
request as httpRequest
|
|
934
1654
|
} from "http";
|
|
935
1655
|
import { request as httpsRequest } from "https";
|
|
936
|
-
import
|
|
1656
|
+
import path2 from "path";
|
|
1657
|
+
import { redactOAuthValue as redactOAuthValue4 } from "@superfunctions/oauth-core";
|
|
1658
|
+
|
|
1659
|
+
// src/remote-target.ts
|
|
1660
|
+
import {
|
|
1661
|
+
McpFnClientError,
|
|
1662
|
+
customTarget as customTarget2,
|
|
1663
|
+
streamableHttpTarget
|
|
1664
|
+
} from "@mcpfn/client";
|
|
1665
|
+
import { redactOAuthValue as redactOAuthValue2 } from "@superfunctions/oauth-core";
|
|
1666
|
+
var targetSecrets = /* @__PURE__ */ new WeakMap();
|
|
1667
|
+
var utf8Encoder = new TextEncoder();
|
|
1668
|
+
function beginTargetCredentialRedaction(target) {
|
|
1669
|
+
const state = targetSecrets.get(target);
|
|
1670
|
+
if (!state) return () => void 0;
|
|
1671
|
+
const scope = new Set(state.active.keys());
|
|
1672
|
+
state.scopes.add(scope);
|
|
1673
|
+
return () => {
|
|
1674
|
+
state.scopes.delete(scope);
|
|
1675
|
+
scope.clear();
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
function credentialValues(headers) {
|
|
1679
|
+
const values = boundedCredentialEntries(headers);
|
|
1680
|
+
const secrets = /* @__PURE__ */ new Set();
|
|
1681
|
+
for (const [header, raw] of values) {
|
|
1682
|
+
if (!raw.trim()) continue;
|
|
1683
|
+
secrets.add(raw);
|
|
1684
|
+
secrets.add(raw.trim());
|
|
1685
|
+
const normalizedHeader = header.toLowerCase();
|
|
1686
|
+
if (["authorization", "proxy-authorization"].includes(normalizedHeader)) {
|
|
1687
|
+
addAuthorizationSecrets(raw, secrets);
|
|
1688
|
+
}
|
|
1689
|
+
if (normalizedHeader === "cookie") addCookieSecrets(raw, secrets);
|
|
1690
|
+
}
|
|
1691
|
+
return secrets;
|
|
1692
|
+
}
|
|
1693
|
+
function addAuthorizationSecrets(raw, secrets) {
|
|
1694
|
+
const trimmed = raw.trim();
|
|
1695
|
+
const separator = trimmed.search(/\s/);
|
|
1696
|
+
if (separator <= 0) return;
|
|
1697
|
+
const scheme = trimmed.slice(0, separator).toLowerCase();
|
|
1698
|
+
const token = trimmed.slice(separator).trim();
|
|
1699
|
+
if (token) secrets.add(token);
|
|
1700
|
+
if (scheme !== "basic") {
|
|
1701
|
+
if (scheme !== "bearer" && !authorizationToken68.test(token)) {
|
|
1702
|
+
addAuthorizationParameterSecrets(token, secrets);
|
|
1703
|
+
}
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
addBasicAuthorizationSecrets(token, secrets);
|
|
1707
|
+
}
|
|
1708
|
+
function addBasicAuthorizationSecrets(token, secrets) {
|
|
1709
|
+
if (!/^[\dA-Za-z+/]+={0,2}$/.test(token)) return;
|
|
1710
|
+
const decoded = Buffer.from(token, "base64");
|
|
1711
|
+
if (stripBase64Padding(decoded.toString("base64")) !== stripBase64Padding(token)) return;
|
|
1712
|
+
for (const encoding of ["utf8", "latin1"]) {
|
|
1713
|
+
const pair = decoded.toString(encoding);
|
|
1714
|
+
const colon = pair.indexOf(":");
|
|
1715
|
+
if (colon < 0) continue;
|
|
1716
|
+
for (const value of [pair, pair.slice(0, colon), pair.slice(colon + 1)]) {
|
|
1717
|
+
if (value) secrets.add(value);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
var authorizationToken68 = /^[\dA-Za-z._~+\x2F-]+=*$/;
|
|
1722
|
+
var authorizationParameterName = /^[!#$%&'*+.^_`|~\dA-Za-z-]+$/;
|
|
1723
|
+
function skipAuthorizationWhitespace(token, cursor) {
|
|
1724
|
+
while (token[cursor] === " " || token[cursor] === " ") cursor += 1;
|
|
1725
|
+
return cursor;
|
|
1726
|
+
}
|
|
1727
|
+
function parseQuotedAuthorizationValue(token, start) {
|
|
1728
|
+
let cursor = start + 1;
|
|
1729
|
+
let decoded = "";
|
|
1730
|
+
while (cursor < token.length && token[cursor] !== '"') {
|
|
1731
|
+
if (token[cursor] === "\\") {
|
|
1732
|
+
cursor += 1;
|
|
1733
|
+
if (cursor >= token.length) {
|
|
1734
|
+
throw new TypeError("Authorization credential parameters must use balanced quotes");
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
decoded += token[cursor];
|
|
1738
|
+
cursor += 1;
|
|
1739
|
+
}
|
|
1740
|
+
if (token[cursor] !== '"') {
|
|
1741
|
+
throw new TypeError("Authorization credential parameters must use balanced quotes");
|
|
1742
|
+
}
|
|
1743
|
+
cursor += 1;
|
|
1744
|
+
const rawValue = token.slice(start, cursor);
|
|
1745
|
+
return {
|
|
1746
|
+
cursor,
|
|
1747
|
+
components: [rawValue, rawValue.slice(1, -1), decoded].filter(Boolean)
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
function parseUnquotedAuthorizationValue(token, start) {
|
|
1751
|
+
let cursor = start;
|
|
1752
|
+
while (cursor < token.length && token[cursor] !== ",") cursor += 1;
|
|
1753
|
+
const value = token.slice(start, cursor).trim();
|
|
1754
|
+
if (!value || /\s/.test(value) || value.includes('"')) {
|
|
1755
|
+
throw new TypeError("Authorization credential parameters must contain valid values");
|
|
1756
|
+
}
|
|
1757
|
+
return { cursor, components: [value] };
|
|
1758
|
+
}
|
|
1759
|
+
function parseAuthorizationParameter(token, start) {
|
|
1760
|
+
let cursor = skipAuthorizationWhitespace(token, start);
|
|
1761
|
+
const nameStart = cursor;
|
|
1762
|
+
while (cursor < token.length && token[cursor] !== "=" && token[cursor] !== " " && token[cursor] !== " " && token[cursor] !== ",") cursor += 1;
|
|
1763
|
+
const name = token.slice(nameStart, cursor);
|
|
1764
|
+
cursor = skipAuthorizationWhitespace(token, cursor);
|
|
1765
|
+
if (!authorizationParameterName.test(name) || token[cursor] !== "=") {
|
|
1766
|
+
throw new TypeError("Authorization credential parameters must use name=value syntax");
|
|
1767
|
+
}
|
|
1768
|
+
cursor = skipAuthorizationWhitespace(token, cursor + 1);
|
|
1769
|
+
if (cursor >= token.length) {
|
|
1770
|
+
throw new TypeError("Authorization credential parameters must contain values");
|
|
1771
|
+
}
|
|
1772
|
+
const parsed = token[cursor] === '"' ? parseQuotedAuthorizationValue(token, cursor) : parseUnquotedAuthorizationValue(token, cursor);
|
|
1773
|
+
return { ...parsed, cursor: skipAuthorizationWhitespace(token, parsed.cursor) };
|
|
1774
|
+
}
|
|
1775
|
+
function addAuthorizationParameterSecrets(token, secrets) {
|
|
1776
|
+
const firstEquals = token.indexOf("=");
|
|
1777
|
+
if (firstEquals < 1 || !authorizationParameterName.test(token.slice(0, firstEquals).trim())) return;
|
|
1778
|
+
let cursor = 0;
|
|
1779
|
+
while (cursor < token.length) {
|
|
1780
|
+
const parsed = parseAuthorizationParameter(token, cursor);
|
|
1781
|
+
for (const component of parsed.components) secrets.add(component);
|
|
1782
|
+
cursor = parsed.cursor;
|
|
1783
|
+
if (cursor === token.length) return;
|
|
1784
|
+
if (token[cursor] !== ",") {
|
|
1785
|
+
throw new TypeError("Authorization credential parameters must be comma separated");
|
|
1786
|
+
}
|
|
1787
|
+
cursor = skipAuthorizationWhitespace(token, cursor + 1);
|
|
1788
|
+
if (cursor === token.length) {
|
|
1789
|
+
throw new TypeError("Authorization credential parameters must not end with a comma");
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
function stripBase64Padding(value) {
|
|
1794
|
+
let end = value.length;
|
|
1795
|
+
while (end > 0 && value[end - 1] === "=") end -= 1;
|
|
1796
|
+
return value.slice(0, end);
|
|
1797
|
+
}
|
|
1798
|
+
function addCookieSecrets(raw, secrets) {
|
|
1799
|
+
for (const part of raw.split(/[;,]/)) {
|
|
1800
|
+
const pair = part.trim();
|
|
1801
|
+
if (!pair) continue;
|
|
1802
|
+
const equals = pair.indexOf("=");
|
|
1803
|
+
if (equals < 1) {
|
|
1804
|
+
throw new TypeError("Cookie credential headers must contain name=value pairs");
|
|
1805
|
+
}
|
|
1806
|
+
const value = pair.slice(equals + 1).trim();
|
|
1807
|
+
if (!value) continue;
|
|
1808
|
+
secrets.add(value);
|
|
1809
|
+
addUnquotedCookieSecret(value, secrets);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
function addUnquotedCookieSecret(value, secrets) {
|
|
1813
|
+
if (!value.startsWith('"') && !value.endsWith('"')) return;
|
|
1814
|
+
if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) {
|
|
1815
|
+
throw new TypeError("Cookie credential values must use balanced quotes");
|
|
1816
|
+
}
|
|
1817
|
+
const unquoted = value.slice(1, -1);
|
|
1818
|
+
if (unquoted.includes('"') || unquoted.includes("\\")) {
|
|
1819
|
+
throw new TypeError("Cookie credential values must use RFC 6265 syntax");
|
|
1820
|
+
}
|
|
1821
|
+
if (unquoted) secrets.add(unquoted);
|
|
1822
|
+
}
|
|
1823
|
+
var McpFnRedactionLimitError = class extends Error {
|
|
1824
|
+
constructor(message = "Credential redaction exceeded its traversal budget") {
|
|
1825
|
+
super(message);
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
var McpFnStructuralCredentialCollisionError = class extends McpFnRedactionLimitError {
|
|
1829
|
+
constructor() {
|
|
1830
|
+
super("Credential collides with required artifact structure");
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
var envelopeKeys = Object.fromEntries(Object.entries({
|
|
1834
|
+
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",
|
|
1835
|
+
result: "formatVersion name operation tool status sideEffect durationMs error droppedObservedEvents redactionOmittedObservedEvents",
|
|
1836
|
+
diagnostic: "phase outcome code requestId at target details",
|
|
1837
|
+
inspectorEvent: "formatVersion source kind at event",
|
|
1838
|
+
failure: "name message layer code phase details",
|
|
1839
|
+
runtime: "node scenarioFormatVersion reportSchemaVersion packages",
|
|
1840
|
+
packages: "testing",
|
|
1841
|
+
droppedInventoryEntries: "tools resources resourceTemplates prompts",
|
|
1842
|
+
target: "kind"
|
|
1843
|
+
}).map(([role, keys]) => [role, new Set(keys.split(" "))]));
|
|
1844
|
+
var structuralArtifactValues = new Map([
|
|
1845
|
+
["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"])],
|
|
1846
|
+
["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"])],
|
|
1847
|
+
["root:clientState", /* @__PURE__ */ new Set(["idle", "connecting", "authorization-required", "connected", "closing", "closed"])],
|
|
1848
|
+
["target:kind", /* @__PURE__ */ new Set(["authenticated-streamable-http", "streamable-http", "stdio", "in-memory", "custom"])],
|
|
1849
|
+
["root:status", /* @__PURE__ */ new Set(["passed", "failed", "incomplete", "complete"])],
|
|
1850
|
+
["result:status", /* @__PURE__ */ new Set(["passed", "failed", "incomplete", "complete"])],
|
|
1851
|
+
["root:outcome", /* @__PURE__ */ new Set(["started", "succeeded", "failed"])],
|
|
1852
|
+
["diagnostic:outcome", /* @__PURE__ */ new Set(["started", "succeeded", "failed"])],
|
|
1853
|
+
...["root", "diagnostic", "failure"].map((role) => [
|
|
1854
|
+
`${role}:phase`,
|
|
1855
|
+
/* @__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"])
|
|
1856
|
+
]),
|
|
1857
|
+
["failure:layer", /* @__PURE__ */ new Set(["mcpfn-preflight", "authorization-server", "resource-server", "mcp-initialization", "scenario", "upstream-conformance"])],
|
|
1858
|
+
["inspectorEvent:source", /* @__PURE__ */ new Set(["diagnostic", "client"])],
|
|
1859
|
+
["result:sideEffect", /* @__PURE__ */ new Set(["none", "idempotent", "non-idempotent"])]
|
|
1860
|
+
]);
|
|
1861
|
+
var unrestrictedStructuralValueFields = /* @__PURE__ */ new Set([
|
|
1862
|
+
"packages:testing",
|
|
1863
|
+
"runtime:node",
|
|
1864
|
+
"runtime:reportSchemaVersion",
|
|
1865
|
+
"root:suiteVersion"
|
|
1866
|
+
]);
|
|
1867
|
+
function childEnvelopeRole(fixed, key, role, input, rootKind) {
|
|
1868
|
+
if (!fixed) return "payload";
|
|
1869
|
+
if (key === "results") return "result";
|
|
1870
|
+
if (key === "timeline") {
|
|
1871
|
+
return rootKind === "mcpfn.inspector-snapshot" ? "inspectorEvent" : "diagnostic";
|
|
1872
|
+
}
|
|
1873
|
+
if (["failure", "runtime", "packages", "droppedInventoryEntries", "target"].includes(key)) {
|
|
1874
|
+
return key;
|
|
1875
|
+
}
|
|
1876
|
+
if (role === "inspectorEvent" && key === "event") {
|
|
1877
|
+
return input.source === "client" ? "root" : "diagnostic";
|
|
1878
|
+
}
|
|
1879
|
+
return "payload";
|
|
1880
|
+
}
|
|
1881
|
+
function specialValue(input) {
|
|
1882
|
+
if (input instanceof Error) return normalizeErrorValue(input);
|
|
1883
|
+
if (input instanceof Date) return Number.isNaN(input.getTime()) ? "Invalid Date" : input.toISOString();
|
|
1884
|
+
if (input instanceof URL) return input.href;
|
|
1885
|
+
if (input instanceof Map) {
|
|
1886
|
+
if (Object.getOwnPropertyDescriptor(Map.prototype, "size").get.call(input) > 1e5) throw new McpFnRedactionLimitError();
|
|
1887
|
+
return { type: "Map", entries: [...Map.prototype.entries.call(input)] };
|
|
1888
|
+
}
|
|
1889
|
+
if (input instanceof Set) {
|
|
1890
|
+
if (Object.getOwnPropertyDescriptor(Set.prototype, "size").get.call(input) > 1e5) throw new McpFnRedactionLimitError();
|
|
1891
|
+
return { type: "Set", values: [...Set.prototype.values.call(input)] };
|
|
1892
|
+
}
|
|
1893
|
+
return input;
|
|
1894
|
+
}
|
|
1895
|
+
function normalizeErrorValue(input) {
|
|
1896
|
+
const normalized = /* @__PURE__ */ Object.create(null);
|
|
1897
|
+
let enumerableFields = 0;
|
|
1898
|
+
for (const key in input) {
|
|
1899
|
+
if (!Object.hasOwn(input, key)) continue;
|
|
1900
|
+
if (++enumerableFields > 1e5) throw new McpFnRedactionLimitError();
|
|
1901
|
+
normalized[key] = input[key];
|
|
1902
|
+
}
|
|
1903
|
+
normalized.name = input.name;
|
|
1904
|
+
normalized.message = input.message;
|
|
1905
|
+
normalized.stack = input.stack;
|
|
1906
|
+
if (input.cause !== void 0) normalized.cause = input.cause;
|
|
1907
|
+
return normalized;
|
|
1908
|
+
}
|
|
1909
|
+
function credentialPatterns(values) {
|
|
1910
|
+
const variants = [...values].filter(Boolean).flatMap((secret) => [
|
|
1911
|
+
{ value: secret, encoded: false },
|
|
1912
|
+
{ value: JSON.stringify(secret).slice(1, -1), encoded: false },
|
|
1913
|
+
{ value: encodeURIComponent(secret), encoded: true },
|
|
1914
|
+
{ value: new URLSearchParams({ value: secret }).toString().slice("value=".length), encoded: true }
|
|
1915
|
+
]).sort((a, b) => b.value.length - a.value.length);
|
|
1916
|
+
const patterns = variants.map(({ value, encoded }) => {
|
|
1917
|
+
const literal = value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
1918
|
+
return encoded ? literal.replace(/%[\dA-Fa-f]{2}/g, (escape) => escape.replace(/[A-Fa-f]/g, (hex) => `[${hex.toLowerCase()}${hex.toUpperCase()}]`)) : literal;
|
|
1919
|
+
});
|
|
1920
|
+
return {
|
|
1921
|
+
secretPattern: patterns.length ? new RegExp(patterns.join("|"), "g") : void 0,
|
|
1922
|
+
replacementPattern: patterns.length ? new RegExp(patterns.join("|")) : void 0
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
function targetCredentialValues(target) {
|
|
1926
|
+
const state = targetSecrets.get(target);
|
|
1927
|
+
return /* @__PURE__ */ new Set([
|
|
1928
|
+
...state?.active.keys() ?? [],
|
|
1929
|
+
...[...state?.scopes ?? []].flatMap((scope) => [...scope])
|
|
1930
|
+
]);
|
|
1931
|
+
}
|
|
1932
|
+
function scrubCredentials(value, values, preserveKeys = false, redactionMarker) {
|
|
1933
|
+
let entries = 0, stringBytes = 0;
|
|
1934
|
+
const budget = (input, depth = 0) => {
|
|
1935
|
+
input = specialValue(input);
|
|
1936
|
+
if (Array.isArray(input) && input.length > 1e5 || ++entries > 1e5 || depth > 32) throw new McpFnRedactionLimitError();
|
|
1937
|
+
if (typeof input === "string") {
|
|
1938
|
+
stringBytes += Buffer.byteLength(input);
|
|
1939
|
+
if (input.length > 262144 || stringBytes > 2097152) throw new McpFnRedactionLimitError();
|
|
1940
|
+
} else if (input && typeof input === "object") {
|
|
1941
|
+
for (const key in input) if (Object.hasOwn(input, key)) {
|
|
1942
|
+
budget(key, depth + 1);
|
|
1943
|
+
budget(input[key], depth + 1);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1947
|
+
budget(value);
|
|
1948
|
+
const { secretPattern, replacementPattern } = credentialPatterns(values);
|
|
1949
|
+
const preserveStructural = (input) => {
|
|
1950
|
+
if (replacementPattern?.test(input)) {
|
|
1951
|
+
throw new McpFnRedactionLimitError(
|
|
1952
|
+
"Credential collides with a required structural artifact field"
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
return input;
|
|
1956
|
+
};
|
|
1957
|
+
const preserveStructuralKey = (input) => {
|
|
1958
|
+
if (replacementPattern?.test(input)) {
|
|
1959
|
+
throw new McpFnStructuralCredentialCollisionError();
|
|
1960
|
+
}
|
|
1961
|
+
return input;
|
|
1962
|
+
};
|
|
1963
|
+
if (secretPattern) {
|
|
1964
|
+
const markerContainsSecret = secretPattern.test(redactionMarker ?? "[REDACTED]");
|
|
1965
|
+
secretPattern.lastIndex = 0;
|
|
1966
|
+
if (markerContainsSecret) redactionMarker = "";
|
|
1967
|
+
}
|
|
1968
|
+
const scrubString = (input, role2, field, finalPass) => {
|
|
1969
|
+
const structuralField = `${role2}:${field}`;
|
|
1970
|
+
if (unrestrictedStructuralValueFields.has(structuralField) || structuralArtifactValues.get(structuralField)?.has(input)) {
|
|
1971
|
+
return preserveStructural(input);
|
|
1972
|
+
}
|
|
1973
|
+
if (finalPass) return replacementPattern?.test(input) ? "" : input;
|
|
1974
|
+
if (!secretPattern) return input;
|
|
1975
|
+
return input.replace(secretPattern, (secret) => {
|
|
1976
|
+
const maskCharacter = secret.includes("*") ? "#" : "*";
|
|
1977
|
+
const defaultReplacement = secret.length < 10 ? maskCharacter.repeat(secret.length) : "[REDACTED]";
|
|
1978
|
+
const replacement = redactionMarker ?? defaultReplacement;
|
|
1979
|
+
return replacementPattern.test(replacement) ? "" : replacement;
|
|
1980
|
+
});
|
|
1981
|
+
};
|
|
1982
|
+
function scrubArray(input, role2, finalPass) {
|
|
1983
|
+
const result2 = input.map((entry) => scrub(entry, role2, "", finalPass));
|
|
1984
|
+
assertPayloadSerialization(result2, role2, finalPass, replacementPattern);
|
|
1985
|
+
return result2;
|
|
1986
|
+
}
|
|
1987
|
+
function scrubObject(input, role2, finalPass) {
|
|
1988
|
+
const entries2 = [];
|
|
1989
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1990
|
+
for (const [key, entry] of Object.entries(input)) {
|
|
1991
|
+
const fixed = envelopeKeys[role2]?.has(key) ?? false;
|
|
1992
|
+
if (fixed) preserveStructuralKey(key);
|
|
1993
|
+
const childRole = childEnvelopeRole(
|
|
1994
|
+
fixed,
|
|
1995
|
+
key,
|
|
1996
|
+
role2,
|
|
1997
|
+
input,
|
|
1998
|
+
value?.kind
|
|
1999
|
+
);
|
|
2000
|
+
const scrubbedKey = fixed ? key : scrub(key, "payload", "", finalPass);
|
|
2001
|
+
if (keys.has(scrubbedKey)) {
|
|
2002
|
+
throw new McpFnRedactionLimitError(
|
|
2003
|
+
"Credential redaction created duplicate object keys"
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
keys.add(scrubbedKey);
|
|
2007
|
+
const entryRole = fixed ? role2 : "payload";
|
|
2008
|
+
const scrubbedEntry = typeof entry === "string" ? scrub(entry, entryRole, key, finalPass) : scrub(entry, childRole, "", finalPass);
|
|
2009
|
+
entries2.push([scrubbedKey, scrubbedEntry]);
|
|
2010
|
+
}
|
|
2011
|
+
const result2 = Object.fromEntries(entries2);
|
|
2012
|
+
assertPayloadSerialization(result2, role2, finalPass, replacementPattern);
|
|
2013
|
+
return result2;
|
|
2014
|
+
}
|
|
2015
|
+
function scrub(input, role2 = "payload", field = "", finalPass = false) {
|
|
2016
|
+
input = specialValue(input);
|
|
2017
|
+
if (typeof input === "string") return scrubString(input, role2, field, finalPass);
|
|
2018
|
+
if (Array.isArray(input)) return scrubArray(input, role2, finalPass);
|
|
2019
|
+
if (input && typeof input === "object") {
|
|
2020
|
+
return scrubObject(input, role2, finalPass);
|
|
2021
|
+
}
|
|
2022
|
+
return input;
|
|
2023
|
+
}
|
|
2024
|
+
const timelineEvent = value && typeof value === "object" && ["client", "diagnostic"].includes(value.source ?? "");
|
|
2025
|
+
let role = "payload";
|
|
2026
|
+
if (preserveKeys) role = timelineEvent ? "inspectorEvent" : "root";
|
|
2027
|
+
const scrubbed = scrub(value, role);
|
|
2028
|
+
const generic = redactOAuthValue2(scrubbed, { maxStringLength: 262144, maxDepth: 64, maxArrayEntries: 1e5, maxObjectEntries: 1e5, ...redactionMarker !== void 0 ? { redactionMarker } : {} });
|
|
2029
|
+
const result = scrub(generic, role, "", true);
|
|
2030
|
+
assertPayloadSerialization(
|
|
2031
|
+
result,
|
|
2032
|
+
"payload",
|
|
2033
|
+
true,
|
|
2034
|
+
replacementPattern
|
|
2035
|
+
);
|
|
2036
|
+
return result;
|
|
2037
|
+
}
|
|
2038
|
+
function assertPayloadSerialization(value, role, finalPass, credentialPattern) {
|
|
2039
|
+
if (!finalPass || role !== "payload" || !credentialPattern) return;
|
|
2040
|
+
const serialized = JSON.stringify(value);
|
|
2041
|
+
if (serialized !== void 0 && credentialPattern.test(serialized)) {
|
|
2042
|
+
throw new McpFnRedactionLimitError(
|
|
2043
|
+
"Credential redaction could not guarantee safe serialized output"
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
function redactTargetCredentials(target, value, options = {}) {
|
|
2048
|
+
return scrubCredentials(
|
|
2049
|
+
value,
|
|
2050
|
+
targetCredentialValues(target),
|
|
2051
|
+
options.preserveKeys,
|
|
2052
|
+
options.redactionMarker
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
function assertTargetArtifactSafe(target, value) {
|
|
2056
|
+
const { replacementPattern } = credentialPatterns(targetCredentialValues(target));
|
|
2057
|
+
if (replacementPattern?.test(value)) {
|
|
2058
|
+
throw new McpFnRedactionLimitError(
|
|
2059
|
+
"Credential redaction could not guarantee safe serialized output"
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
function redactRemoteCredential(credential, value, options = {}) {
|
|
2064
|
+
return scrubCredentials(value, credentialValues(credential.headers), options.preserveKeys);
|
|
2065
|
+
}
|
|
2066
|
+
var MAX_CREDENTIAL_HEADERS = 32;
|
|
2067
|
+
var MAX_CREDENTIAL_HEADER_BYTES = 16384;
|
|
2068
|
+
var MAX_CREDENTIAL_HEADER_VALUE_BYTES = 8192;
|
|
2069
|
+
var FORBIDDEN_CREDENTIAL_HEADERS = /* @__PURE__ */ new Set([
|
|
2070
|
+
"connection",
|
|
2071
|
+
"content-length",
|
|
2072
|
+
"host",
|
|
2073
|
+
"proxy-connection",
|
|
2074
|
+
"te",
|
|
2075
|
+
"trailer",
|
|
2076
|
+
"transfer-encoding",
|
|
2077
|
+
"upgrade"
|
|
2078
|
+
]);
|
|
2079
|
+
function staticRemoteCredentialProvider(credential) {
|
|
2080
|
+
return { acquire: () => credential };
|
|
2081
|
+
}
|
|
2082
|
+
async function acquireRemoteCredential(source, context) {
|
|
2083
|
+
let provider;
|
|
2084
|
+
let acquired;
|
|
2085
|
+
try {
|
|
2086
|
+
provider = isCredentialProvider(source) ? source : staticRemoteCredentialProvider(source);
|
|
2087
|
+
acquired = await provider.acquire(context);
|
|
2088
|
+
} catch {
|
|
2089
|
+
throw new Error("Target credential acquisition failed");
|
|
2090
|
+
}
|
|
2091
|
+
let credential = acquired;
|
|
2092
|
+
try {
|
|
2093
|
+
credential = { ...acquired, headers: boundedCredentialEntries(acquired.headers) };
|
|
2094
|
+
} catch {
|
|
2095
|
+
}
|
|
2096
|
+
let releasePromise;
|
|
2097
|
+
let revoked = false;
|
|
2098
|
+
let disposed = false;
|
|
2099
|
+
let cleanupPhase = credential.kind === "oauth" ? "token-revocation" : "transport-close";
|
|
2100
|
+
return {
|
|
2101
|
+
credential,
|
|
2102
|
+
cleanupPhase: () => cleanupPhase,
|
|
2103
|
+
release() {
|
|
2104
|
+
releasePromise ??= (async () => {
|
|
2105
|
+
const cleanupContext = { ...context, signal: new AbortController().signal };
|
|
2106
|
+
try {
|
|
2107
|
+
if (!revoked) {
|
|
2108
|
+
cleanupPhase = credential.kind === "oauth" ? "token-revocation" : "transport-close";
|
|
2109
|
+
await provider.revoke?.(acquired, cleanupContext);
|
|
2110
|
+
revoked = true;
|
|
2111
|
+
}
|
|
2112
|
+
if (!disposed) {
|
|
2113
|
+
cleanupPhase = "transport-close";
|
|
2114
|
+
await provider.dispose?.(acquired, cleanupContext);
|
|
2115
|
+
disposed = true;
|
|
2116
|
+
}
|
|
2117
|
+
} catch {
|
|
2118
|
+
throw new McpFnClientError(
|
|
2119
|
+
"MCPFN_OPERATION_FAILED",
|
|
2120
|
+
"Target credential cleanup failed",
|
|
2121
|
+
{ phase: cleanupPhase, retryable: true }
|
|
2122
|
+
);
|
|
2123
|
+
}
|
|
2124
|
+
})().catch((error) => {
|
|
2125
|
+
releasePromise = void 0;
|
|
2126
|
+
throw error;
|
|
2127
|
+
});
|
|
2128
|
+
return releasePromise;
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
function authenticatedHttpTarget(url, options) {
|
|
2133
|
+
const targetUrl = normalizeRemoteTargetUrl(url);
|
|
2134
|
+
const descriptorUrl = new URL(targetUrl);
|
|
2135
|
+
descriptorUrl.search = "";
|
|
2136
|
+
const { credential: _credential, requestInit, ...transportOptions } = options;
|
|
2137
|
+
const state = { active: /* @__PURE__ */ new Map(), scopes: /* @__PURE__ */ new Set() };
|
|
2138
|
+
const pendingReleases = /* @__PURE__ */ new Set();
|
|
2139
|
+
const authenticated = customTarget2({
|
|
2140
|
+
kind: "authenticated-streamable-http",
|
|
2141
|
+
descriptor: {
|
|
2142
|
+
url: descriptorUrl.toString(),
|
|
2143
|
+
authenticated: true
|
|
2144
|
+
},
|
|
2145
|
+
redact: (value, redaction) => redactTargetCredentials(authenticated, value, { preserveKeys: true, ...redaction }),
|
|
2146
|
+
assertArtifactSafe: (value) => assertTargetArtifactSafe(authenticated, value),
|
|
2147
|
+
beginRedactionScope: () => beginTargetCredentialRedaction(authenticated),
|
|
2148
|
+
async cleanup() {
|
|
2149
|
+
const results = await Promise.allSettled([...pendingReleases].map((release) => release()));
|
|
2150
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
2151
|
+
const failure = failures.find(({ reason }) => reason && typeof reason === "object" && reason.phase === "token-revocation") ?? failures[0];
|
|
2152
|
+
if (failure) throw failure.reason;
|
|
2153
|
+
},
|
|
2154
|
+
async open(targetContext) {
|
|
2155
|
+
const context = {
|
|
2156
|
+
url: targetUrl.toString(),
|
|
2157
|
+
requestId: targetContext.requestId,
|
|
2158
|
+
signal: targetContext.signal
|
|
2159
|
+
};
|
|
2160
|
+
const lease = await acquireRemoteCredential(options.credential, context);
|
|
2161
|
+
const secrets = /* @__PURE__ */ new Set();
|
|
2162
|
+
const release = async () => {
|
|
2163
|
+
try {
|
|
2164
|
+
await lease.release();
|
|
2165
|
+
} catch (error) {
|
|
2166
|
+
pendingReleases.add(release);
|
|
2167
|
+
throw error;
|
|
2168
|
+
}
|
|
2169
|
+
pendingReleases.delete(release);
|
|
2170
|
+
for (const secret of secrets) {
|
|
2171
|
+
const count = (state.active.get(secret) ?? 1) - 1;
|
|
2172
|
+
if (count) state.active.set(secret, count);
|
|
2173
|
+
else state.active.delete(secret);
|
|
2174
|
+
}
|
|
2175
|
+
secrets.clear();
|
|
2176
|
+
};
|
|
2177
|
+
let handle;
|
|
2178
|
+
try {
|
|
2179
|
+
for (const secret of credentialValues(lease.credential.headers)) {
|
|
2180
|
+
secrets.add(secret);
|
|
2181
|
+
state.active.set(secret, (state.active.get(secret) ?? 0) + 1);
|
|
2182
|
+
for (const scope of state.scopes) scope.add(secret);
|
|
2183
|
+
}
|
|
2184
|
+
const credentialHeaders = validateRemoteCredentialHeaders(lease.credential.headers);
|
|
2185
|
+
const headers = new Headers(requestInit?.headers);
|
|
2186
|
+
credentialHeaders.forEach((value, name) => headers.set(name, value));
|
|
2187
|
+
const target = streamableHttpTarget(targetUrl, {
|
|
2188
|
+
...transportOptions,
|
|
2189
|
+
requestInit: {
|
|
2190
|
+
...requestInit,
|
|
2191
|
+
headers,
|
|
2192
|
+
// Fetch must not replay credentials to a redirect destination.
|
|
2193
|
+
redirect: "error"
|
|
2194
|
+
}
|
|
2195
|
+
});
|
|
2196
|
+
handle = await target.open(targetContext);
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
let message = "Authenticated target could not be opened";
|
|
2199
|
+
try {
|
|
2200
|
+
message = redactRemoteCredential(lease.credential, error instanceof Error ? error.message : String(error));
|
|
2201
|
+
} catch {
|
|
2202
|
+
}
|
|
2203
|
+
pendingReleases.add(release);
|
|
2204
|
+
await release();
|
|
2205
|
+
throw new Error(message);
|
|
2206
|
+
}
|
|
2207
|
+
let closePromise;
|
|
2208
|
+
let transportClosed = false;
|
|
2209
|
+
return {
|
|
2210
|
+
transport: handle.transport,
|
|
2211
|
+
finishAuthorization: handle.finishAuthorization,
|
|
2212
|
+
terminateSession: handle.terminateSession,
|
|
2213
|
+
close() {
|
|
2214
|
+
closePromise ??= Promise.resolve().then(async () => {
|
|
2215
|
+
if (!transportClosed) {
|
|
2216
|
+
if (handle.close) await handle.close();
|
|
2217
|
+
else await handle.transport.close();
|
|
2218
|
+
transportClosed = true;
|
|
2219
|
+
}
|
|
2220
|
+
await release();
|
|
2221
|
+
}).catch(async (error) => {
|
|
2222
|
+
closePromise = void 0;
|
|
2223
|
+
const phase = transportClosed ? lease.cleanupPhase() : "transport-close";
|
|
2224
|
+
await targetContext.diagnostic({
|
|
2225
|
+
phase,
|
|
2226
|
+
outcome: "failed",
|
|
2227
|
+
code: "MCPFN_CREDENTIAL_CLEANUP_FAILED",
|
|
2228
|
+
requestId: targetContext.requestId,
|
|
2229
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2230
|
+
target: { kind: "authenticated-streamable-http", url: descriptorUrl.toString() },
|
|
2231
|
+
details: { message: "Target credential cleanup failed" }
|
|
2232
|
+
});
|
|
2233
|
+
throw error;
|
|
2234
|
+
});
|
|
2235
|
+
return closePromise;
|
|
2236
|
+
}
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
});
|
|
2240
|
+
targetSecrets.set(authenticated, state);
|
|
2241
|
+
return authenticated;
|
|
2242
|
+
}
|
|
2243
|
+
function normalizeRemoteTargetUrl(value) {
|
|
2244
|
+
const url = new URL(value.toString());
|
|
2245
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2246
|
+
throw new TypeError("Authenticated MCP targets must use HTTP or HTTPS");
|
|
2247
|
+
}
|
|
2248
|
+
if (url.protocol === "http:" && url.hostname !== "[::1]" && !/^127(?:\.\d{1,3}){3}$/.test(url.hostname)) {
|
|
2249
|
+
throw new TypeError("Credential-bearing HTTP targets require a literal loopback address; use HTTPS remotely");
|
|
2250
|
+
}
|
|
2251
|
+
if (url.username || url.password || url.hash) {
|
|
2252
|
+
throw new TypeError(
|
|
2253
|
+
"Authenticated MCP target URLs must not contain userinfo or a fragment"
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
return url;
|
|
2257
|
+
}
|
|
2258
|
+
function isCredentialProvider(value) {
|
|
2259
|
+
return "acquire" in value && typeof value.acquire === "function";
|
|
2260
|
+
}
|
|
2261
|
+
function boundedCredentialEntries(value) {
|
|
2262
|
+
const entries = [];
|
|
2263
|
+
let bytes2 = 0;
|
|
2264
|
+
const append = (name, headerValue) => {
|
|
2265
|
+
if (entries.length >= MAX_CREDENTIAL_HEADERS) throw new TypeError("Credential headers exceed the header limit");
|
|
2266
|
+
if (typeof name !== "string" || typeof headerValue !== "string") throw new TypeError("Credential header values must be strings");
|
|
2267
|
+
if (name.length > MAX_CREDENTIAL_HEADER_BYTES || headerValue.length > MAX_CREDENTIAL_HEADER_VALUE_BYTES) throw new TypeError("Credential header exceeds the value-size limit");
|
|
2268
|
+
if (utf8Encoder.encode(headerValue).byteLength > MAX_CREDENTIAL_HEADER_VALUE_BYTES) throw new TypeError("Credential header exceeds the value-size limit");
|
|
2269
|
+
bytes2 += utf8Encoder.encode(`${name}: ${headerValue}\r
|
|
2270
|
+
`).byteLength;
|
|
2271
|
+
if (bytes2 > MAX_CREDENTIAL_HEADER_BYTES) throw new TypeError("Credential headers exceed the aggregate size limit");
|
|
2272
|
+
entries.push([name, headerValue]);
|
|
2273
|
+
};
|
|
2274
|
+
if (value instanceof Headers) value.forEach((v, k) => append(k, v));
|
|
2275
|
+
else if (Array.isArray(value)) {
|
|
2276
|
+
if (value.length > MAX_CREDENTIAL_HEADERS) throw new TypeError("Credential headers exceed the header limit");
|
|
2277
|
+
for (const entry of value) append(entry[0], entry[1]);
|
|
2278
|
+
} else {
|
|
2279
|
+
for (const name in value) if (Object.hasOwn(value, name)) append(name, value[name]);
|
|
2280
|
+
}
|
|
2281
|
+
return entries;
|
|
2282
|
+
}
|
|
2283
|
+
function validateRemoteCredentialHeaders(value) {
|
|
2284
|
+
const headers = new Headers(boundedCredentialEntries(value));
|
|
2285
|
+
const entries = [];
|
|
2286
|
+
headers.forEach((headerValue, name) => entries.push([name, headerValue]));
|
|
2287
|
+
if (entries.length === 0) {
|
|
2288
|
+
throw new TypeError("Authenticated MCP targets require at least one credential header");
|
|
2289
|
+
}
|
|
2290
|
+
if (entries.length > MAX_CREDENTIAL_HEADERS) {
|
|
2291
|
+
throw new TypeError(`Credential headers exceed the ${MAX_CREDENTIAL_HEADERS}-header limit`);
|
|
2292
|
+
}
|
|
2293
|
+
let bytes2 = 0;
|
|
2294
|
+
for (const [name, headerValue] of entries) {
|
|
2295
|
+
if (!headerValue.trim()) throw new TypeError("Credential header values must not be blank");
|
|
2296
|
+
if (FORBIDDEN_CREDENTIAL_HEADERS.has(name)) {
|
|
2297
|
+
throw new TypeError(`Credential header ${name} is not allowed`);
|
|
2298
|
+
}
|
|
2299
|
+
const valueBytes = utf8Encoder.encode(headerValue).byteLength;
|
|
2300
|
+
if (valueBytes > MAX_CREDENTIAL_HEADER_VALUE_BYTES) {
|
|
2301
|
+
throw new TypeError(`Credential header ${name} exceeds the value-size limit`);
|
|
2302
|
+
}
|
|
2303
|
+
bytes2 += utf8Encoder.encode(`${name}: ${headerValue}\r
|
|
2304
|
+
`).byteLength;
|
|
2305
|
+
}
|
|
2306
|
+
if (bytes2 > MAX_CREDENTIAL_HEADER_BYTES) {
|
|
2307
|
+
throw new TypeError("Credential headers exceed the aggregate size limit");
|
|
2308
|
+
}
|
|
2309
|
+
return headers;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// src/reports.ts
|
|
2313
|
+
import { redactOAuthValue as redactOAuthValue3 } from "@superfunctions/oauth-core";
|
|
2314
|
+
|
|
2315
|
+
// src/artifact-guards.ts
|
|
2316
|
+
import { McpFnClientError as McpFnClientError2 } from "@mcpfn/client";
|
|
2317
|
+
var targetSuiteArtifactGuards = /* @__PURE__ */ new WeakMap();
|
|
2318
|
+
var targetSuiteArtifactFinalizer = new FinalizationRegistry((release) => {
|
|
2319
|
+
try {
|
|
2320
|
+
release();
|
|
2321
|
+
} catch {
|
|
2322
|
+
}
|
|
2323
|
+
});
|
|
2324
|
+
function registerMcpFnTargetSuiteArtifactGuard(report, validate, release, maxBytes) {
|
|
2325
|
+
const previous = targetSuiteArtifactGuards.get(report);
|
|
2326
|
+
if (previous) {
|
|
2327
|
+
targetSuiteArtifactFinalizer.unregister(report);
|
|
2328
|
+
targetSuiteArtifactGuards.delete(report);
|
|
2329
|
+
previous.release();
|
|
2330
|
+
}
|
|
2331
|
+
let active = true;
|
|
2332
|
+
const releaseOnce = () => {
|
|
2333
|
+
if (!active) return;
|
|
2334
|
+
active = false;
|
|
2335
|
+
release();
|
|
2336
|
+
};
|
|
2337
|
+
targetSuiteArtifactGuards.set(report, { maxBytes, validate, release: releaseOnce });
|
|
2338
|
+
targetSuiteArtifactFinalizer.register(report, releaseOnce, report);
|
|
2339
|
+
}
|
|
2340
|
+
function disposeMcpFnTargetSuiteArtifactGuard(report) {
|
|
2341
|
+
const guard = targetSuiteArtifactGuards.get(report);
|
|
2342
|
+
if (!guard) return;
|
|
2343
|
+
targetSuiteArtifactGuards.delete(report);
|
|
2344
|
+
targetSuiteArtifactFinalizer.unregister(report);
|
|
2345
|
+
guard.release();
|
|
2346
|
+
}
|
|
2347
|
+
function preserveMcpFnTargetSuiteArtifact(report, artifact) {
|
|
2348
|
+
const guard = targetSuiteArtifactGuards.get(report);
|
|
2349
|
+
if (!guard) {
|
|
2350
|
+
if (report.artifactValidation === "target-aware") {
|
|
2351
|
+
throw new McpFnClientError2(
|
|
2352
|
+
"MCPFN_OPERATION_FAILED",
|
|
2353
|
+
"MCP target artifact proof is unavailable",
|
|
2354
|
+
{ phase: "capability-operation" }
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
return artifact;
|
|
2358
|
+
}
|
|
2359
|
+
if (new TextEncoder().encode(artifact).byteLength > guard.maxBytes) {
|
|
2360
|
+
throw new Error("Serialized target suite artifact exceeds maxReportBytes");
|
|
2361
|
+
}
|
|
2362
|
+
try {
|
|
2363
|
+
if (!guard.validate(artifact)) throw new Error("unsafe serialized artifact");
|
|
2364
|
+
return artifact;
|
|
2365
|
+
} catch {
|
|
2366
|
+
throw new McpFnClientError2(
|
|
2367
|
+
"MCPFN_OPERATION_FAILED",
|
|
2368
|
+
"MCP artifact structure conflicts with credential redaction",
|
|
2369
|
+
{ phase: "capability-operation" }
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// src/reports.ts
|
|
2375
|
+
var MCPFN_TESTING_VERSION = "0.0.5";
|
|
2376
|
+
var MCPFN_REPORT_SCHEMA_VERSION = "1.0.0";
|
|
2377
|
+
function serializeMcpFnTargetSuiteReport(report, options = {}) {
|
|
2378
|
+
const space = options.space ?? 0;
|
|
2379
|
+
if (!Number.isInteger(space) || space < 0 || space > 10) {
|
|
2380
|
+
throw new TypeError("space must be an integer from 0 through 10");
|
|
2381
|
+
}
|
|
2382
|
+
const serialized = JSON.stringify(report, null, space) + (options.trailingNewline ? "\n" : "");
|
|
2383
|
+
return preserveMcpFnTargetSuiteArtifact(report, serialized);
|
|
2384
|
+
}
|
|
2385
|
+
function disposeMcpFnTargetSuiteReport(report) {
|
|
2386
|
+
disposeMcpFnTargetSuiteArtifactGuard(report);
|
|
2387
|
+
}
|
|
2388
|
+
function objectRecord(value) {
|
|
2389
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
2390
|
+
}
|
|
2391
|
+
function redactedFailureCause(error) {
|
|
2392
|
+
if (!error || typeof error !== "object") return void 0;
|
|
2393
|
+
const cause = error.cause;
|
|
2394
|
+
if (cause === void 0) return void 0;
|
|
2395
|
+
return objectRecord(redactOAuthValue3(cause, {
|
|
2396
|
+
maxDepth: 4,
|
|
2397
|
+
maxArrayEntries: 20,
|
|
2398
|
+
maxObjectEntries: 20,
|
|
2399
|
+
maxStringLength: 1024
|
|
2400
|
+
}));
|
|
2401
|
+
}
|
|
2402
|
+
function resourceDenied(layer, record, cause) {
|
|
2403
|
+
if (layer === "authorization-server") return false;
|
|
2404
|
+
return [cause?.code, cause?.status, record.code, record.status].some((value) => [401, 403].includes(Number(value)));
|
|
2405
|
+
}
|
|
2406
|
+
function normalizeMcpFnReportFailure(error, fallbackPhase) {
|
|
2407
|
+
const redacted = redactOAuthValue3(error, {
|
|
2408
|
+
maxDepth: 6,
|
|
2409
|
+
maxArrayEntries: 50,
|
|
2410
|
+
maxObjectEntries: 50,
|
|
2411
|
+
maxStringLength: 2048
|
|
2412
|
+
});
|
|
2413
|
+
const record = objectRecord(redacted) ?? {};
|
|
2414
|
+
const phase = stringField(record.phase) ?? fallbackPhase;
|
|
2415
|
+
const details = objectRecord(record.details);
|
|
2416
|
+
let message = stringField(record.message) ?? String(
|
|
2417
|
+
redactOAuthValue3(error instanceof Error ? error.message : String(error))
|
|
2418
|
+
);
|
|
2419
|
+
const causeRecord = redactedFailureCause(error);
|
|
2420
|
+
const causeMessage = stringField(causeRecord?.message);
|
|
2421
|
+
if (causeMessage && !message.includes(causeMessage)) message = `${message}: ${causeMessage}`;
|
|
2422
|
+
const combinedDetails = causeRecord ? { ...details, cause: causeRecord } : details;
|
|
2423
|
+
const layer = failureLayer(phase);
|
|
2424
|
+
const deniedByResource = resourceDenied(layer, record, causeRecord);
|
|
2425
|
+
return {
|
|
2426
|
+
name: stringField(record.name) ?? "Error",
|
|
2427
|
+
message,
|
|
2428
|
+
layer: deniedByResource ? "resource-server" : layer,
|
|
2429
|
+
...stringField(record.code) ? { code: stringField(record.code) } : {},
|
|
2430
|
+
...phase ? { phase } : {},
|
|
2431
|
+
...combinedDetails ? { details: combinedDetails } : {}
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
function createMcpFnTargetSuiteJUnit(report, options = {}) {
|
|
2435
|
+
const maxBytes = options.maxBytes ?? 1048576;
|
|
2436
|
+
validateArtifactCap(maxBytes);
|
|
2437
|
+
const safe = redactOAuthValue3(report, {
|
|
2438
|
+
maxDepth: 10,
|
|
2439
|
+
maxArrayEntries: 1e3,
|
|
2440
|
+
maxObjectEntries: 200,
|
|
2441
|
+
maxStringLength: 4096
|
|
2442
|
+
});
|
|
2443
|
+
const results = safe.results.filter((result) => result && typeof result === "object" && typeof result.name === "string");
|
|
2444
|
+
const omitted = report.results.length - results.length;
|
|
2445
|
+
const cases = results.map((result) => {
|
|
2446
|
+
const duration = Math.max(0, result.durationMs ?? 0) / 1e3;
|
|
2447
|
+
let failure = "";
|
|
2448
|
+
if (result.status !== "passed") {
|
|
2449
|
+
const failureType = result.status === "failed" ? "scenario" : "incomplete";
|
|
2450
|
+
failure = junitFailure(result.error ?? `Scenario ${result.status}`, failureType);
|
|
2451
|
+
}
|
|
2452
|
+
return ` <testcase name="${xml(result.name)}" classname="mcpfn.scenario" time="${duration.toFixed(3)}">${failure}</testcase>`;
|
|
2453
|
+
});
|
|
2454
|
+
if (safe.failure) {
|
|
2455
|
+
cases.unshift(
|
|
2456
|
+
` <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>`
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
if (safe.status === "incomplete" || omitted > 0 || !safe.ok && !safe.failure && results.every((result) => result.status === "passed")) {
|
|
2460
|
+
cases.push(` <testcase name="suite-incomplete" classname="mcpfn.report" time="0.000">${junitFailure(safe.incompleteReason ?? "Suite evidence is incomplete or omitted", "incomplete")}</testcase>`);
|
|
2461
|
+
}
|
|
2462
|
+
if (cases.length === 0) {
|
|
2463
|
+
cases.push(
|
|
2464
|
+
' <testcase name="target-suite" classname="mcpfn.target" time="0.000"></testcase>'
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
2467
|
+
const failures = cases.filter((entry) => entry.includes("<failure ")).length;
|
|
2468
|
+
let serialized = junitDocument(safe, cases, failures);
|
|
2469
|
+
if (bytes(serialized) <= maxBytes) {
|
|
2470
|
+
return preserveMcpFnTargetSuiteArtifact(report, serialized);
|
|
2471
|
+
}
|
|
2472
|
+
const boundedCases = [
|
|
2473
|
+
` <testcase name="artifact-cap" classname="mcpfn.report" time="0.000">${junitFailure("JUnit content exceeded maxBytes and was truncated", "incomplete")}</testcase>`
|
|
2474
|
+
];
|
|
2475
|
+
serialized = junitDocument(safe, boundedCases, 1, true);
|
|
2476
|
+
if (bytes(serialized) > maxBytes) {
|
|
2477
|
+
throw new Error("The minimum McpFn JUnit report exceeds maxBytes");
|
|
2478
|
+
}
|
|
2479
|
+
return preserveMcpFnTargetSuiteArtifact(report, serialized);
|
|
2480
|
+
}
|
|
2481
|
+
function junitDocument(report, cases, failures, truncated = false) {
|
|
2482
|
+
const suiteName = String(redactOAuthValue3(`mcpfn:${report.target.kind}`));
|
|
2483
|
+
return [
|
|
2484
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
2485
|
+
`<testsuite name="${xml(suiteName)}" tests="${cases.length}" failures="${failures}" errors="0" skipped="0">`,
|
|
2486
|
+
" <properties>",
|
|
2487
|
+
` <property name="mcpfn.report.schema" value="${xml(MCPFN_REPORT_SCHEMA_VERSION)}"/>`,
|
|
2488
|
+
` <property name="mcpfn.testing.version" value="${xml(MCPFN_TESTING_VERSION)}"/>`,
|
|
2489
|
+
` <property name="node.version" value="${xml(report.runtime.node)}"/>`,
|
|
2490
|
+
` <property name="artifact.truncated" value="${truncated}"/>`,
|
|
2491
|
+
" </properties>",
|
|
2492
|
+
...cases,
|
|
2493
|
+
"</testsuite>",
|
|
2494
|
+
""
|
|
2495
|
+
].join("\n");
|
|
2496
|
+
}
|
|
2497
|
+
function junitFailure(message, type) {
|
|
2498
|
+
const safe = String(redactOAuthValue3(message, { maxStringLength: 4096 }));
|
|
2499
|
+
return `<failure type="${xml(type)}" message="${xml(safe)}">${xml(safe)}</failure>`;
|
|
2500
|
+
}
|
|
2501
|
+
function failureLayer(phase) {
|
|
2502
|
+
if (phase === "resource-discovery" || phase === "transport-connect") {
|
|
2503
|
+
return "resource-server";
|
|
2504
|
+
}
|
|
2505
|
+
if (phase === "authorization-server-discovery" || phase === "client-registration" || phase === "authorization-request" || phase === "authorization-callback" || phase === "token-exchange" || phase === "token-refresh" || phase === "token-revocation") {
|
|
2506
|
+
return "authorization-server";
|
|
2507
|
+
}
|
|
2508
|
+
if (phase === "mcp-initialize" || phase === "capability-operation") {
|
|
2509
|
+
return "mcp-initialization";
|
|
2510
|
+
}
|
|
2511
|
+
if (phase === "scenario") return "scenario";
|
|
2512
|
+
if (phase === "upstream-conformance") return "upstream-conformance";
|
|
2513
|
+
return "mcpfn-preflight";
|
|
2514
|
+
}
|
|
2515
|
+
function stringField(value) {
|
|
2516
|
+
if (typeof value === "string") return value;
|
|
2517
|
+
if (typeof value === "number") return String(value);
|
|
2518
|
+
return void 0;
|
|
2519
|
+
}
|
|
2520
|
+
function xml(value) {
|
|
2521
|
+
return stripInvalidXmlControls(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
2522
|
+
}
|
|
2523
|
+
function stripInvalidXmlControls(value) {
|
|
2524
|
+
return value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFE\uFFFF\uD800-\uDFFF]/gu, "");
|
|
2525
|
+
}
|
|
2526
|
+
function validateArtifactCap(maxBytes) {
|
|
2527
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1024) {
|
|
2528
|
+
throw new Error("maxBytes must be an integer of at least 1024");
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
function bytes(value) {
|
|
2532
|
+
return new TextEncoder().encode(value).byteLength;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// src/conformance.ts
|
|
937
2536
|
var OFFICIAL_CONFORMANCE_VERSION = "0.1.16";
|
|
938
|
-
|
|
939
|
-
const upstream = new URL(
|
|
2537
|
+
function validateAuthenticatedConformanceUrl(url) {
|
|
2538
|
+
const upstream = new URL(url);
|
|
940
2539
|
if (!["http:", "https:"].includes(upstream.protocol)) {
|
|
941
2540
|
throw new TypeError(
|
|
942
2541
|
"Authenticated conformance upstream must use HTTP or HTTPS"
|
|
@@ -953,10 +2552,14 @@ async function createAuthenticatedConformanceProxy(options) {
|
|
|
953
2552
|
"Authenticated conformance upstream must use a literal loopback address"
|
|
954
2553
|
);
|
|
955
2554
|
}
|
|
2555
|
+
return { upstream, hostname };
|
|
2556
|
+
}
|
|
2557
|
+
async function createAuthenticatedConformanceProxy(options) {
|
|
2558
|
+
const { upstream, hostname } = validateAuthenticatedConformanceUrl(options.url);
|
|
956
2559
|
const protocol = upstream.protocol === "https:" ? "https:" : "http:";
|
|
957
2560
|
const port = upstream.port === "" ? void 0 : Number(upstream.port);
|
|
958
2561
|
const requestPath = `${upstream.pathname}${upstream.search}`;
|
|
959
|
-
const injected =
|
|
2562
|
+
const injected = validateRemoteCredentialHeaders(options.headers);
|
|
960
2563
|
const activeRequests = /* @__PURE__ */ new Set();
|
|
961
2564
|
const activeSockets = /* @__PURE__ */ new Set();
|
|
962
2565
|
let proxyAuthority;
|
|
@@ -1019,20 +2622,25 @@ async function createAuthenticatedConformanceProxy(options) {
|
|
|
1019
2622
|
`http://127.0.0.1:${address.port}`
|
|
1020
2623
|
);
|
|
1021
2624
|
proxyAuthority = url.host;
|
|
2625
|
+
let closePromise;
|
|
1022
2626
|
return {
|
|
1023
2627
|
url: url.toString(),
|
|
1024
|
-
close:
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
2628
|
+
close: () => {
|
|
2629
|
+
closePromise ??= (async () => {
|
|
2630
|
+
for (const request of activeRequests) request.destroy();
|
|
2631
|
+
for (const socket of activeSockets) socket.destroy();
|
|
2632
|
+
if (!server.listening) return;
|
|
2633
|
+
await new Promise((resolve, reject) => {
|
|
2634
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
2635
|
+
});
|
|
2636
|
+
})();
|
|
2637
|
+
return closePromise;
|
|
1031
2638
|
}
|
|
1032
2639
|
};
|
|
1033
2640
|
}
|
|
1034
2641
|
function normalizeLoopbackHostname(hostname) {
|
|
1035
|
-
|
|
2642
|
+
const parts = hostname.split(".");
|
|
2643
|
+
if (parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return hostname;
|
|
1036
2644
|
if (hostname === "[::1]") return "::1";
|
|
1037
2645
|
return void 0;
|
|
1038
2646
|
}
|
|
@@ -1040,9 +2648,9 @@ function npxInvocation(args) {
|
|
|
1040
2648
|
if (process.platform !== "win32") return { command: "npx", args };
|
|
1041
2649
|
const npmExecPath = process.env.npm_execpath;
|
|
1042
2650
|
const candidates = [
|
|
1043
|
-
npmExecPath ?
|
|
1044
|
-
|
|
1045
|
-
|
|
2651
|
+
npmExecPath ? path2.join(path2.dirname(npmExecPath), "npx-cli.js") : void 0,
|
|
2652
|
+
path2.resolve(path2.dirname(process.execPath), "node_modules/npm/bin/npx-cli.js"),
|
|
2653
|
+
path2.resolve(path2.dirname(process.execPath), "../lib/node_modules/npm/bin/npx-cli.js")
|
|
1046
2654
|
].filter((candidate) => Boolean(candidate));
|
|
1047
2655
|
const npxCli = candidates.find((candidate) => existsSync(candidate));
|
|
1048
2656
|
if (!npxCli) {
|
|
@@ -1068,7 +2676,19 @@ function buildOfficialConformanceArgs(options) {
|
|
|
1068
2676
|
if (options.verbose) args.push("--verbose");
|
|
1069
2677
|
return args;
|
|
1070
2678
|
}
|
|
2679
|
+
function buildOfficialConformanceEnvironment(sensitiveNames = [], source = process.env) {
|
|
2680
|
+
const environment = { ...source };
|
|
2681
|
+
const sensitive = new Set(sensitiveNames.map((name) => name.toLowerCase()));
|
|
2682
|
+
for (const name of Object.keys(environment)) {
|
|
2683
|
+
if (sensitive.has(name.toLowerCase())) delete environment[name];
|
|
2684
|
+
}
|
|
2685
|
+
environment.PATH = [path2.dirname(process.execPath), environment.PATH].filter(Boolean).join(path2.delimiter);
|
|
2686
|
+
return environment;
|
|
2687
|
+
}
|
|
1071
2688
|
async function runOfficialConformance(options) {
|
|
2689
|
+
return runConformance(options, (value) => String(redactOAuthValue4(value, { maxStringLength: 262144 })));
|
|
2690
|
+
}
|
|
2691
|
+
async function runConformance(options, redactOutput) {
|
|
1072
2692
|
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
|
|
1073
2693
|
if (nodeMajor < 22) {
|
|
1074
2694
|
throw new Error(
|
|
@@ -1077,40 +2697,170 @@ async function runOfficialConformance(options) {
|
|
|
1077
2697
|
}
|
|
1078
2698
|
const args = buildOfficialConformanceArgs(options);
|
|
1079
2699
|
const invocation = npxInvocation(args);
|
|
1080
|
-
|
|
2700
|
+
const childEnvironment = buildOfficialConformanceEnvironment(
|
|
2701
|
+
options.sensitiveEnvironmentVariables
|
|
2702
|
+
);
|
|
2703
|
+
return await new Promise((resolve) => {
|
|
1081
2704
|
const child = spawn(invocation.command, invocation.args, {
|
|
2705
|
+
detached: process.platform !== "win32",
|
|
1082
2706
|
cwd: options.cwd,
|
|
1083
|
-
env:
|
|
1084
|
-
...process.env,
|
|
1085
|
-
PATH: [path.dirname(process.execPath), process.env.PATH].filter(Boolean).join(path.delimiter)
|
|
1086
|
-
},
|
|
2707
|
+
env: childEnvironment,
|
|
1087
2708
|
stdio: options.stdio === "inherit" ? "inherit" : "pipe"
|
|
1088
2709
|
});
|
|
1089
2710
|
let stdout = "";
|
|
1090
2711
|
let stderr = "";
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
2712
|
+
let capturedBytes = 0;
|
|
2713
|
+
let outputExceeded = false;
|
|
2714
|
+
const capture = (chunk, stream) => {
|
|
2715
|
+
if (outputExceeded) return;
|
|
2716
|
+
capturedBytes += Buffer.byteLength(chunk, "utf8");
|
|
2717
|
+
if (capturedBytes > 262144) {
|
|
2718
|
+
outputExceeded = true;
|
|
2719
|
+
stdout = "";
|
|
2720
|
+
stderr = "";
|
|
2721
|
+
void terminateConformanceRunner(child).finally(() => finish(1));
|
|
2722
|
+
return;
|
|
2723
|
+
}
|
|
2724
|
+
if (stream === "stdout") stdout += chunk.toString();
|
|
2725
|
+
else stderr += chunk.toString();
|
|
2726
|
+
};
|
|
2727
|
+
child.stdout?.setEncoding("utf8");
|
|
2728
|
+
child.stderr?.setEncoding("utf8");
|
|
2729
|
+
child.stdout?.on("data", (chunk) => capture(chunk, "stdout"));
|
|
2730
|
+
child.stderr?.on("data", (chunk) => capture(chunk, "stderr"));
|
|
2731
|
+
let settled = false;
|
|
2732
|
+
child.once("error", (error) => {
|
|
2733
|
+
if (settled) return;
|
|
2734
|
+
settled = true;
|
|
2735
|
+
const failure = normalizeMcpFnReportFailure(error, "upstream-conformance");
|
|
2736
|
+
resolve({
|
|
2737
|
+
formatVersion: 1,
|
|
2738
|
+
kind: "mcpfn.official-conformance-report",
|
|
2739
|
+
ok: false,
|
|
2740
|
+
suiteVersion: OFFICIAL_CONFORMANCE_VERSION,
|
|
2741
|
+
exitCode: 1,
|
|
2742
|
+
stdout: "",
|
|
2743
|
+
stderr: failure.message,
|
|
2744
|
+
failure
|
|
2745
|
+
});
|
|
1100
2746
|
});
|
|
2747
|
+
const finish = (code) => {
|
|
2748
|
+
if (settled) return;
|
|
2749
|
+
settled = true;
|
|
2750
|
+
let safeStdout = "";
|
|
2751
|
+
let safeStderr = "";
|
|
2752
|
+
let outputFailure = false;
|
|
2753
|
+
try {
|
|
2754
|
+
if (outputExceeded) throw new Error("capture limit");
|
|
2755
|
+
safeStdout = redactOutput(stdout);
|
|
2756
|
+
safeStderr = redactOutput(stderr);
|
|
2757
|
+
} catch {
|
|
2758
|
+
outputFailure = true;
|
|
2759
|
+
safeStdout = "";
|
|
2760
|
+
safeStderr = outputExceeded ? "Conformance output exceeded its capture limit" : "Conformance output omitted because credential redaction exceeded its bounds";
|
|
2761
|
+
}
|
|
2762
|
+
const exitCode = outputFailure ? 1 : code ?? 1;
|
|
2763
|
+
const failure = exitCode === 0 ? void 0 : normalizeMcpFnReportFailure(
|
|
2764
|
+
new Error(safeStderr || safeStdout || `Official conformance exited ${exitCode}`),
|
|
2765
|
+
"upstream-conformance"
|
|
2766
|
+
);
|
|
2767
|
+
resolve({
|
|
2768
|
+
formatVersion: 1,
|
|
2769
|
+
kind: "mcpfn.official-conformance-report",
|
|
2770
|
+
ok: exitCode === 0,
|
|
2771
|
+
suiteVersion: OFFICIAL_CONFORMANCE_VERSION,
|
|
2772
|
+
exitCode,
|
|
2773
|
+
stdout: safeStdout,
|
|
2774
|
+
stderr: safeStderr,
|
|
2775
|
+
...failure ? { failure } : {}
|
|
2776
|
+
});
|
|
2777
|
+
};
|
|
2778
|
+
child.once("close", finish);
|
|
1101
2779
|
});
|
|
1102
2780
|
}
|
|
2781
|
+
var retainedConformanceCleanup = /* @__PURE__ */ new WeakMap();
|
|
2782
|
+
var McpFnConformanceCleanupError = class extends Error {
|
|
2783
|
+
constructor(release, result) {
|
|
2784
|
+
super("Authenticated conformance credential cleanup failed; retry cleanup");
|
|
2785
|
+
this.result = result;
|
|
2786
|
+
this.name = "McpFnConformanceCleanupError";
|
|
2787
|
+
retainedConformanceCleanup.set(this, release);
|
|
2788
|
+
}
|
|
2789
|
+
result;
|
|
2790
|
+
async retryCleanup() {
|
|
2791
|
+
const release = retainedConformanceCleanup.get(this);
|
|
2792
|
+
if (!release) return;
|
|
2793
|
+
try {
|
|
2794
|
+
await release();
|
|
2795
|
+
} catch {
|
|
2796
|
+
throw this;
|
|
2797
|
+
}
|
|
2798
|
+
retainedConformanceCleanup.delete(this);
|
|
2799
|
+
}
|
|
2800
|
+
};
|
|
1103
2801
|
async function runAuthenticatedOfficialConformance(options) {
|
|
1104
|
-
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
headers
|
|
1108
|
-
}
|
|
2802
|
+
if (options.outputDir !== void 0) throw new TypeError("Authenticated conformance does not support outputDir; serialize the redacted result instead");
|
|
2803
|
+
const { headers, credential, ...conformance } = options;
|
|
2804
|
+
if (headers === void 0 === (credential === void 0)) {
|
|
2805
|
+
throw new TypeError("Provide exactly one of credential or headers for authenticated conformance");
|
|
2806
|
+
}
|
|
2807
|
+
validateAuthenticatedConformanceUrl(conformance.url);
|
|
2808
|
+
const lease = await acquireRemoteCredential(
|
|
2809
|
+
credential ?? { headers },
|
|
2810
|
+
{
|
|
2811
|
+
url: conformance.url,
|
|
2812
|
+
requestId: randomUUID2()
|
|
2813
|
+
}
|
|
2814
|
+
);
|
|
2815
|
+
let proxy;
|
|
2816
|
+
let result;
|
|
2817
|
+
let operationFailure;
|
|
2818
|
+
const safeFailure = (error) => {
|
|
2819
|
+
let message = "Authenticated conformance operation failed";
|
|
2820
|
+
try {
|
|
2821
|
+
message = redactRemoteCredential(lease.credential, error instanceof Error ? error.message : String(error));
|
|
2822
|
+
} catch {
|
|
2823
|
+
}
|
|
2824
|
+
return { error: new Error(message) };
|
|
2825
|
+
};
|
|
1109
2826
|
try {
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
2827
|
+
proxy = await createAuthenticatedConformanceProxy({
|
|
2828
|
+
url: conformance.url,
|
|
2829
|
+
headers: lease.credential.headers
|
|
2830
|
+
});
|
|
2831
|
+
result = redactRemoteCredential(lease.credential, await runConformance({ ...conformance, stdio: "pipe", url: proxy.url }, (value) => String(redactRemoteCredential(lease.credential, value))), { preserveKeys: true });
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
operationFailure = safeFailure(error);
|
|
2834
|
+
}
|
|
2835
|
+
try {
|
|
2836
|
+
await proxy?.close();
|
|
2837
|
+
} catch (error) {
|
|
2838
|
+
operationFailure ??= safeFailure(error);
|
|
2839
|
+
}
|
|
2840
|
+
let released = false;
|
|
2841
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
2842
|
+
try {
|
|
2843
|
+
await lease.release();
|
|
2844
|
+
released = true;
|
|
2845
|
+
break;
|
|
2846
|
+
} catch {
|
|
2847
|
+
}
|
|
1113
2848
|
}
|
|
2849
|
+
if (!released) {
|
|
2850
|
+
const message = "Authenticated conformance credential cleanup failed; retry cleanup";
|
|
2851
|
+
const cleanupFailure = normalizeMcpFnReportFailure(new Error(message), lease.cleanupPhase());
|
|
2852
|
+
const failedResult = result ? {
|
|
2853
|
+
...result,
|
|
2854
|
+
ok: false,
|
|
2855
|
+
exitCode: result.exitCode || 1,
|
|
2856
|
+
cleanupFailure,
|
|
2857
|
+
...result.failure ? {} : { failure: cleanupFailure }
|
|
2858
|
+
} : void 0;
|
|
2859
|
+
throw new McpFnConformanceCleanupError(() => lease.release(), failedResult);
|
|
2860
|
+
}
|
|
2861
|
+
if (operationFailure) throw operationFailure.error;
|
|
2862
|
+
if (!result) throw new Error("Conformance produced no result");
|
|
2863
|
+
return result;
|
|
1114
2864
|
}
|
|
1115
2865
|
|
|
1116
2866
|
// src/host-profiles.ts
|
|
@@ -1197,37 +2947,408 @@ function checkHostCompatibility(manifest, profile) {
|
|
|
1197
2947
|
path: `extensions.${extension}`,
|
|
1198
2948
|
message: `${profile.name} does not support ${extension}`
|
|
1199
2949
|
});
|
|
1200
|
-
}
|
|
1201
|
-
if (!extensions.has(MCP_APP_EXTENSION_ID)) {
|
|
1202
|
-
for (const tool of manifest.tools) {
|
|
1203
|
-
const ui = tool.metadata?.ui;
|
|
1204
|
-
if (ui?.visibility?.length && !ui.visibility.includes("model")) {
|
|
1205
|
-
issues.push({
|
|
1206
|
-
severity: "incompatible",
|
|
1207
|
-
code: "app-only-tool-unavailable",
|
|
1208
|
-
path: `tools.${tool.name}.metadata.ui.visibility`,
|
|
1209
|
-
message: `${tool.name} is app-only but ${profile.name} does not support MCP Apps`
|
|
1210
|
-
});
|
|
1211
|
-
}
|
|
2950
|
+
}
|
|
2951
|
+
if (!extensions.has(MCP_APP_EXTENSION_ID)) {
|
|
2952
|
+
for (const tool of manifest.tools) {
|
|
2953
|
+
const ui = tool.metadata?.ui;
|
|
2954
|
+
if (ui?.visibility?.length && !ui.visibility.includes("model")) {
|
|
2955
|
+
issues.push({
|
|
2956
|
+
severity: "incompatible",
|
|
2957
|
+
code: "app-only-tool-unavailable",
|
|
2958
|
+
path: `tools.${tool.name}.metadata.ui.visibility`,
|
|
2959
|
+
message: `${tool.name} is app-only but ${profile.name} does not support MCP Apps`
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
issues.sort((left, right) => {
|
|
2965
|
+
if (left.path !== right.path) return left.path < right.path ? -1 : 1;
|
|
2966
|
+
if (left.code < right.code) return -1;
|
|
2967
|
+
if (left.code > right.code) return 1;
|
|
2968
|
+
return 0;
|
|
2969
|
+
});
|
|
2970
|
+
let status = "compatible";
|
|
2971
|
+
if (issues.some((issue) => issue.severity === "incompatible")) {
|
|
2972
|
+
status = "incompatible";
|
|
2973
|
+
} else if (issues.length > 0) {
|
|
2974
|
+
status = "degraded";
|
|
2975
|
+
}
|
|
2976
|
+
return { status, compatible: status !== "incompatible", issues };
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
// src/host-authorization.ts
|
|
2980
|
+
import {
|
|
2981
|
+
derivePkceS256Challenge as derivePkceS256Challenge2,
|
|
2982
|
+
redactOAuthValue as redactOAuthValue5
|
|
2983
|
+
} from "@superfunctions/oauth-core";
|
|
2984
|
+
function createHostedAuthorizationFixtures(options) {
|
|
2985
|
+
const issuer = new URL(options.issuer);
|
|
2986
|
+
const resource = new URL(options.resource).toString();
|
|
2987
|
+
const chatgptRedirect = options.chatgptRedirectUri ?? "https://chatgpt.com/connector_platform_oauth_redirect";
|
|
2988
|
+
const claudeRedirect = options.claudeRedirectUri ?? "https://claude.example.com/oauth/callback";
|
|
2989
|
+
const verifier = "mcpfn-role3-shared-pkce-verifier-000000000000000000000000";
|
|
2990
|
+
const registration = (clientId, source, redirectUri, grantTypes) => ({
|
|
2991
|
+
clientId,
|
|
2992
|
+
source,
|
|
2993
|
+
metadata: {
|
|
2994
|
+
client_name: `McpFn ${source} fixture`,
|
|
2995
|
+
redirect_uris: [redirectUri],
|
|
2996
|
+
response_types: ["code"],
|
|
2997
|
+
grant_types: grantTypes,
|
|
2998
|
+
token_endpoint_auth_method: "none"
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
3001
|
+
const authorization = (clientId, redirectUri) => ({
|
|
3002
|
+
clientId,
|
|
3003
|
+
redirectUri,
|
|
3004
|
+
resource,
|
|
3005
|
+
scopes: ["mcp:read"],
|
|
3006
|
+
state: "mcpfn-role3-state",
|
|
3007
|
+
codeVerifier: verifier
|
|
3008
|
+
});
|
|
3009
|
+
const chatgptId = "chatgpt-client";
|
|
3010
|
+
const claudeId = new URL("/client-metadata/claude", issuer).toString();
|
|
3011
|
+
const dcrId = "dcr-client";
|
|
3012
|
+
const supported = ["authorization_code", "refresh_token"];
|
|
3013
|
+
return [
|
|
3014
|
+
{
|
|
3015
|
+
id: "chatgpt-pre-registered-code-pkce",
|
|
3016
|
+
host: "chatgpt",
|
|
3017
|
+
registration: registration(chatgptId, "pre-registered", chatgptRedirect, supported),
|
|
3018
|
+
authorization: authorization(chatgptId, chatgptRedirect),
|
|
3019
|
+
token: {
|
|
3020
|
+
grantType: "authorization_code",
|
|
3021
|
+
refreshAfterExchange: true
|
|
3022
|
+
},
|
|
3023
|
+
expected: { outcome: "allowed" }
|
|
3024
|
+
},
|
|
3025
|
+
{
|
|
3026
|
+
id: "claude-client-metadata-extensible-grants",
|
|
3027
|
+
host: "claude",
|
|
3028
|
+
registration: registration(claudeId, "client-metadata-document", claudeRedirect, [
|
|
3029
|
+
...supported,
|
|
3030
|
+
"urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
3031
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
3032
|
+
"urn:example:params:oauth:grant-type:extension"
|
|
3033
|
+
]),
|
|
3034
|
+
authorization: authorization(claudeId, claudeRedirect),
|
|
3035
|
+
token: {
|
|
3036
|
+
grantType: "authorization_code",
|
|
3037
|
+
refreshAfterExchange: true
|
|
3038
|
+
},
|
|
3039
|
+
expected: { outcome: "allowed" }
|
|
3040
|
+
},
|
|
3041
|
+
{
|
|
3042
|
+
id: "dynamic-registration-code-pkce",
|
|
3043
|
+
host: "generic",
|
|
3044
|
+
registration: registration(dcrId, "dynamic", "http://127.0.0.1/callback", supported),
|
|
3045
|
+
authorization: authorization(dcrId, "http://127.0.0.1/callback"),
|
|
3046
|
+
token: { grantType: "authorization_code" },
|
|
3047
|
+
expected: { outcome: "allowed" }
|
|
3048
|
+
},
|
|
3049
|
+
{
|
|
3050
|
+
id: "chatgpt-unregistered-redirect",
|
|
3051
|
+
host: "chatgpt",
|
|
3052
|
+
registration: registration(chatgptId, "pre-registered", chatgptRedirect, supported),
|
|
3053
|
+
authorization: authorization(chatgptId, `${chatgptRedirect}/drift`),
|
|
3054
|
+
expected: {
|
|
3055
|
+
outcome: "rejected",
|
|
3056
|
+
errorCode: "invalid_request",
|
|
3057
|
+
phase: "authorization-request"
|
|
3058
|
+
}
|
|
3059
|
+
},
|
|
3060
|
+
{
|
|
3061
|
+
id: "incompatible-registration-grants",
|
|
3062
|
+
host: "generic",
|
|
3063
|
+
registration: registration(
|
|
3064
|
+
"incompatible-client",
|
|
3065
|
+
"pre-registered",
|
|
3066
|
+
"https://client.example.com/callback",
|
|
3067
|
+
["urn:ietf:params:oauth:grant-type:jwt-bearer"]
|
|
3068
|
+
),
|
|
3069
|
+
authorization: authorization(
|
|
3070
|
+
"incompatible-client",
|
|
3071
|
+
"https://client.example.com/callback"
|
|
3072
|
+
),
|
|
3073
|
+
expected: {
|
|
3074
|
+
outcome: "rejected",
|
|
3075
|
+
errorCode: "invalid_client",
|
|
3076
|
+
phase: "authorization-request"
|
|
3077
|
+
}
|
|
3078
|
+
},
|
|
3079
|
+
{
|
|
3080
|
+
id: "actual-unsupported-token-grant",
|
|
3081
|
+
host: "generic",
|
|
3082
|
+
registration: registration(
|
|
3083
|
+
"unsupported-token-client",
|
|
3084
|
+
"pre-registered",
|
|
3085
|
+
"https://client.example.com/callback",
|
|
3086
|
+
supported
|
|
3087
|
+
),
|
|
3088
|
+
authorization: authorization(
|
|
3089
|
+
"unsupported-token-client",
|
|
3090
|
+
"https://client.example.com/callback"
|
|
3091
|
+
),
|
|
3092
|
+
token: { grantType: "urn:example:unsupported-token-grant" },
|
|
3093
|
+
expected: {
|
|
3094
|
+
outcome: "rejected",
|
|
3095
|
+
errorCode: "unsupported_grant_type",
|
|
3096
|
+
phase: "token-exchange"
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
];
|
|
3100
|
+
}
|
|
3101
|
+
async function runHostedAuthorizationRegression(target, fixtures) {
|
|
3102
|
+
const results = [];
|
|
3103
|
+
for (const fixture of fixtures) {
|
|
3104
|
+
results.push(await runHostedCase(target, fixture));
|
|
3105
|
+
}
|
|
3106
|
+
return results;
|
|
3107
|
+
}
|
|
3108
|
+
async function runHostedCase(target, fixture) {
|
|
3109
|
+
let phase = "client-registration";
|
|
3110
|
+
let responseStatus;
|
|
3111
|
+
try {
|
|
3112
|
+
await target.prepareRegistration(structuredClone(fixture.registration));
|
|
3113
|
+
phase = "authorization-request";
|
|
3114
|
+
const authorization = hostedAuthorizationUrl(target.issuer, fixture);
|
|
3115
|
+
responseStatus = void 0;
|
|
3116
|
+
const authorizationResponse = await target.request(new Request(authorization, {
|
|
3117
|
+
redirect: "manual"
|
|
3118
|
+
}));
|
|
3119
|
+
responseStatus = authorizationResponse.status;
|
|
3120
|
+
const authorizationError = await oauthError2(authorizationResponse);
|
|
3121
|
+
if (authorizationError) {
|
|
3122
|
+
await validateOAuthRejection(authorizationResponse, fixture, target.issuer, "authorization");
|
|
3123
|
+
return assessHostedCase(fixture, phase, authorizationResponse.status, authorizationError);
|
|
3124
|
+
}
|
|
3125
|
+
if (!isRedirect(authorizationResponse.status)) {
|
|
3126
|
+
throw new Error(`Authorization request did not redirect (HTTP ${authorizationResponse.status})`);
|
|
3127
|
+
}
|
|
3128
|
+
const code = validatedRedirectCode(authorizationResponse, fixture, target.issuer);
|
|
3129
|
+
if (fixture.token) return runHostedTokenCase(target, fixture, fixture.token, code);
|
|
3130
|
+
return assessHostedCase(fixture, phase, responseStatus);
|
|
3131
|
+
} catch (error) {
|
|
3132
|
+
return assessHostedFailure(fixture, phase, responseStatus, error);
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
async function runHostedTokenCase(target, fixture, token, code) {
|
|
3136
|
+
let phase = token.grantType === "refresh_token" ? "token-refresh" : "token-exchange";
|
|
3137
|
+
let responseStatus;
|
|
3138
|
+
try {
|
|
3139
|
+
const tokenResponse = await target.request(hostedTokenRequest(
|
|
3140
|
+
target.issuer,
|
|
3141
|
+
tokenRequestBody(fixture, token, code)
|
|
3142
|
+
));
|
|
3143
|
+
responseStatus = tokenResponse.status;
|
|
3144
|
+
const tokenError = await oauthError2(tokenResponse);
|
|
3145
|
+
if (tokenError) {
|
|
3146
|
+
await validateOAuthRejection(tokenResponse, fixture, target.issuer, "token");
|
|
3147
|
+
return assessHostedCase(fixture, phase, responseStatus, tokenError);
|
|
3148
|
+
}
|
|
3149
|
+
if (!tokenResponse.ok) throw new Error(`Token request returned HTTP ${tokenResponse.status}`);
|
|
3150
|
+
const tokenSet = await validatedTokenSet(tokenResponse);
|
|
3151
|
+
if (!token.refreshAfterExchange) {
|
|
3152
|
+
return assessHostedCase(fixture, phase, responseStatus);
|
|
3153
|
+
}
|
|
3154
|
+
if (typeof tokenSet.refresh_token !== "string" || !tokenSet.refresh_token) {
|
|
3155
|
+
throw new Error("Authorization-code response did not include a refresh token");
|
|
3156
|
+
}
|
|
3157
|
+
phase = "token-refresh";
|
|
3158
|
+
responseStatus = void 0;
|
|
3159
|
+
const refreshBody = new URLSearchParams({
|
|
3160
|
+
grant_type: "refresh_token",
|
|
3161
|
+
client_id: fixture.authorization.clientId,
|
|
3162
|
+
refresh_token: tokenSet.refresh_token,
|
|
3163
|
+
resource: fixture.authorization.resource
|
|
3164
|
+
});
|
|
3165
|
+
const refreshResponse = await target.request(hostedTokenRequest(target.issuer, refreshBody));
|
|
3166
|
+
responseStatus = refreshResponse.status;
|
|
3167
|
+
const refreshError = await oauthError2(refreshResponse);
|
|
3168
|
+
if (refreshError) {
|
|
3169
|
+
await validateOAuthRejection(refreshResponse, fixture, target.issuer, "token");
|
|
3170
|
+
return assessHostedCase(fixture, phase, responseStatus, refreshError);
|
|
3171
|
+
}
|
|
3172
|
+
if (!refreshResponse.ok) {
|
|
3173
|
+
throw new Error(`Refresh request returned HTTP ${refreshResponse.status}`);
|
|
1212
3174
|
}
|
|
3175
|
+
await validatedTokenSet(refreshResponse);
|
|
3176
|
+
return assessHostedCase(fixture, phase, responseStatus);
|
|
3177
|
+
} catch (error) {
|
|
3178
|
+
return assessHostedFailure(fixture, phase, responseStatus, error);
|
|
1213
3179
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
3180
|
+
}
|
|
3181
|
+
function tokenRequestBody(fixture, token, code) {
|
|
3182
|
+
const body = new URLSearchParams({
|
|
3183
|
+
grant_type: token.grantType,
|
|
3184
|
+
client_id: fixture.authorization.clientId
|
|
1219
3185
|
});
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
3186
|
+
if (token.grantType === "authorization_code") {
|
|
3187
|
+
body.set("code", token.code ?? code);
|
|
3188
|
+
body.set("redirect_uri", fixture.authorization.redirectUri);
|
|
3189
|
+
body.set("code_verifier", fixture.authorization.codeVerifier);
|
|
3190
|
+
body.set("resource", fixture.authorization.resource);
|
|
3191
|
+
} else if (token.refreshToken) {
|
|
3192
|
+
body.set("refresh_token", token.refreshToken);
|
|
3193
|
+
body.set("resource", fixture.authorization.resource);
|
|
3194
|
+
}
|
|
3195
|
+
return body;
|
|
3196
|
+
}
|
|
3197
|
+
function hostedTokenRequest(issuer, body) {
|
|
3198
|
+
return new Request(new URL("token", ensureTrailingSlash(issuer)), {
|
|
3199
|
+
method: "POST",
|
|
3200
|
+
redirect: "manual",
|
|
3201
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3202
|
+
body
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
3205
|
+
function assessHostedFailure(fixture, phase, responseStatus, error) {
|
|
3206
|
+
const redacted = redactOAuthValue5(error);
|
|
3207
|
+
const safe = redacted && typeof redacted === "object" ? redacted : {};
|
|
3208
|
+
const code = typeof safe.code === "string" ? safe.code : void 0;
|
|
3209
|
+
const message = typeof safe.message === "string" ? safe.message : String(redacted);
|
|
3210
|
+
return assessHostedCase(
|
|
3211
|
+
fixture,
|
|
3212
|
+
phase,
|
|
3213
|
+
responseStatus,
|
|
3214
|
+
code,
|
|
3215
|
+
message || "Hosted adapter failed"
|
|
3216
|
+
);
|
|
3217
|
+
}
|
|
3218
|
+
function hostedAuthorizationUrl(issuer, fixture) {
|
|
3219
|
+
const authorization = new URL("authorize", ensureTrailingSlash(issuer));
|
|
3220
|
+
authorization.searchParams.set("response_type", "code");
|
|
3221
|
+
authorization.searchParams.set("client_id", fixture.authorization.clientId);
|
|
3222
|
+
authorization.searchParams.set("redirect_uri", fixture.authorization.redirectUri);
|
|
3223
|
+
authorization.searchParams.set(
|
|
3224
|
+
"code_challenge",
|
|
3225
|
+
derivePkceS256Challenge2(fixture.authorization.codeVerifier)
|
|
3226
|
+
);
|
|
3227
|
+
authorization.searchParams.set("code_challenge_method", "S256");
|
|
3228
|
+
authorization.searchParams.set("state", fixture.authorization.state);
|
|
3229
|
+
authorization.searchParams.set("resource", fixture.authorization.resource);
|
|
3230
|
+
authorization.searchParams.set("scope", fixture.authorization.scopes.join(" "));
|
|
3231
|
+
return authorization;
|
|
3232
|
+
}
|
|
3233
|
+
function assessHostedCase(fixture, phase, responseStatus, errorCode, error) {
|
|
3234
|
+
const observedOutcome = errorCode || error ? "rejected" : "allowed";
|
|
3235
|
+
const expected = fixture.expected;
|
|
3236
|
+
const passed = !error && observedOutcome === expected.outcome && (expected.errorCode === void 0 || expected.errorCode === errorCode) && (expected.phase === void 0 || expected.phase === phase);
|
|
3237
|
+
const expectedCode = expected.errorCode ? `:${expected.errorCode}` : "";
|
|
3238
|
+
const observedCode = errorCode ? `:${errorCode}` : "";
|
|
3239
|
+
const mismatch = `Expected ${expected.outcome}${expectedCode}, received ${observedOutcome}${observedCode}`;
|
|
3240
|
+
return {
|
|
3241
|
+
formatVersion: 1,
|
|
3242
|
+
id: fixture.id,
|
|
3243
|
+
host: fixture.host,
|
|
3244
|
+
status: passed ? "passed" : "failed",
|
|
3245
|
+
phase,
|
|
3246
|
+
layer: phase === "client-registration" ? "mcpfn-preflight" : "authorization-server",
|
|
3247
|
+
...responseStatus === void 0 ? {} : { responseStatus },
|
|
3248
|
+
...errorCode ? { errorCode } : {},
|
|
3249
|
+
...!passed || error ? {
|
|
3250
|
+
error: error ?? mismatch
|
|
3251
|
+
} : {}
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
function isJsonResponse(response) {
|
|
3255
|
+
const mediaType = (response.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
3256
|
+
return mediaType === "application/json" || /^application\/[a-z0-9!#$&^_.+-]+\+json$/.test(mediaType);
|
|
3257
|
+
}
|
|
3258
|
+
async function oauthError2(response) {
|
|
3259
|
+
const location = response.headers.get("location");
|
|
3260
|
+
if (location) {
|
|
3261
|
+
const code = new URL(location).searchParams.get("error");
|
|
3262
|
+
if (code) return code;
|
|
3263
|
+
}
|
|
3264
|
+
if (!isJsonResponse(response)) return void 0;
|
|
3265
|
+
const body = await response.clone().json().catch(() => void 0);
|
|
3266
|
+
return typeof body?.error === "string" ? body.error : void 0;
|
|
3267
|
+
}
|
|
3268
|
+
async function validateOAuthRejection(response, fixture, issuer, context) {
|
|
3269
|
+
if (context === "authorization" && isRedirect(response.status)) {
|
|
3270
|
+
validateAuthorizationErrorRedirect(response, fixture, issuer);
|
|
3271
|
+
return;
|
|
1225
3272
|
}
|
|
1226
|
-
|
|
3273
|
+
const body = await response.clone().json().catch(() => void 0);
|
|
3274
|
+
const registered = fixture.registration.metadata.redirect_uris?.includes(fixture.authorization.redirectUri);
|
|
3275
|
+
if (context === "authorization" && body?.error !== "invalid_client" && registered) {
|
|
3276
|
+
throw new Error("Registered authorization requests require an OAuth error callback");
|
|
3277
|
+
}
|
|
3278
|
+
if (response.status !== 400) throw new Error("OAuth error response has invalid HTTP status");
|
|
3279
|
+
if (response.headers.has("location") || !isJsonResponse(response) || !body || typeof body.error !== "string" || !body.error) {
|
|
3280
|
+
throw new Error("OAuth error response must be a JSON error envelope");
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
function validateAuthorizationErrorRedirect(response, fixture, issuer) {
|
|
3284
|
+
const callback = new URL(response.headers.get("location") ?? "");
|
|
3285
|
+
const registered = fixture.registration.metadata.redirect_uris;
|
|
3286
|
+
if (!registered?.includes(fixture.authorization.redirectUri)) {
|
|
3287
|
+
throw new Error("Rejected redirect URI must not receive a callback");
|
|
3288
|
+
}
|
|
3289
|
+
const errors = callback.searchParams.getAll("error");
|
|
3290
|
+
if (errors.length !== 1 || !errors[0] || callback.searchParams.has("code")) {
|
|
3291
|
+
throw new Error("Invalid OAuth error callback");
|
|
3292
|
+
}
|
|
3293
|
+
if (errors[0] === "invalid_client") {
|
|
3294
|
+
throw new Error("Invalid-client authorization failures must not redirect");
|
|
3295
|
+
}
|
|
3296
|
+
callback.searchParams.delete("error");
|
|
3297
|
+
callback.searchParams.delete("error_description");
|
|
3298
|
+
callback.searchParams.delete("error_uri");
|
|
3299
|
+
callback.searchParams.set("code", "error-envelope-validation");
|
|
3300
|
+
validatedRedirectCode(
|
|
3301
|
+
new Response(null, { status: response.status, headers: { location: callback.toString() } }),
|
|
3302
|
+
fixture,
|
|
3303
|
+
issuer
|
|
3304
|
+
);
|
|
3305
|
+
}
|
|
3306
|
+
function validatedRedirectCode(response, fixture, issuer) {
|
|
3307
|
+
const location = response.headers.get("location");
|
|
3308
|
+
if (!location) throw new Error("Authorization callback is missing");
|
|
3309
|
+
const callback = new URL(location);
|
|
3310
|
+
const expected = new URL(fixture.authorization.redirectUri);
|
|
3311
|
+
const codes = callback.searchParams.getAll("code");
|
|
3312
|
+
const states = callback.searchParams.getAll("state");
|
|
3313
|
+
if (codes.length !== 1 || !codes[0] || states.length !== 1 || states[0] !== fixture.authorization.state) {
|
|
3314
|
+
throw new Error("Authorization callback code or state is invalid");
|
|
3315
|
+
}
|
|
3316
|
+
const issuers = callback.searchParams.getAll("iss");
|
|
3317
|
+
if (issuers.length > 1 || issuers.length === 1 && issuers[0] !== issuer) {
|
|
3318
|
+
throw new Error("Authorization callback issuer is invalid");
|
|
3319
|
+
}
|
|
3320
|
+
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)))) {
|
|
3321
|
+
throw new Error("Authorization callback destination is invalid");
|
|
3322
|
+
}
|
|
3323
|
+
return codes[0];
|
|
3324
|
+
}
|
|
3325
|
+
async function validatedTokenSet(response) {
|
|
3326
|
+
if (response.status !== 200) throw new Error("Successful token responses must use HTTP 200");
|
|
3327
|
+
if (!isJsonResponse(response)) throw new Error("Token response must use a JSON media type");
|
|
3328
|
+
if (!hasHeaderDirective(response.headers.get("cache-control"), "no-store") || !hasHeaderDirective(response.headers.get("pragma"), "no-cache")) {
|
|
3329
|
+
throw new Error("Token responses must disable caching with Cache-Control: no-store and Pragma: no-cache");
|
|
3330
|
+
}
|
|
3331
|
+
const value = await response.clone().json();
|
|
3332
|
+
if (!value || typeof value.access_token !== "string" || !/^[\dA-Za-z._~+/-]+=*$/.test(value.access_token) || typeof value.token_type !== "string" || value.token_type.toLowerCase() !== "bearer") {
|
|
3333
|
+
throw new Error("Token response requires an access token and Bearer token type");
|
|
3334
|
+
}
|
|
3335
|
+
return value;
|
|
3336
|
+
}
|
|
3337
|
+
function hasHeaderDirective(value, expected) {
|
|
3338
|
+
return (value ?? "").split(",").some(
|
|
3339
|
+
(directive) => directive.trim().toLowerCase() === expected
|
|
3340
|
+
);
|
|
3341
|
+
}
|
|
3342
|
+
function isRedirect(status) {
|
|
3343
|
+
return [301, 302, 303, 307, 308].includes(status);
|
|
3344
|
+
}
|
|
3345
|
+
function ensureTrailingSlash(value) {
|
|
3346
|
+
const url = new URL(value);
|
|
3347
|
+
if (!url.pathname.endsWith("/")) url.pathname += "/";
|
|
3348
|
+
return url;
|
|
1227
3349
|
}
|
|
1228
3350
|
|
|
1229
3351
|
// src/scenarios.ts
|
|
1230
|
-
import { redactOAuthValue } from "@superfunctions/oauth-core";
|
|
1231
3352
|
function createMcpFnScenario(name, operation, result) {
|
|
1232
3353
|
switch (operation.kind) {
|
|
1233
3354
|
case "tools.call":
|
|
@@ -1456,16 +3577,31 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1456
3577
|
const observedEvents = [];
|
|
1457
3578
|
const maxObservedEvents = options.maxObservedEvents ?? 500;
|
|
1458
3579
|
let droppedObservedEvents = 0;
|
|
3580
|
+
let redactionOmittedObservedEvents = 0;
|
|
3581
|
+
let observedRedactionOmissionEvents = 0;
|
|
1459
3582
|
let attributedDroppedObservedEvents = 0;
|
|
3583
|
+
let attributedRedactionOmittedObservedEvents = 0;
|
|
3584
|
+
const initialClientEventRedactionOmissions = client.session.getRedactionOmissionCounts().clientEvents;
|
|
1460
3585
|
const pushResult = (result) => {
|
|
1461
3586
|
const newlyDropped = droppedObservedEvents - attributedDroppedObservedEvents;
|
|
3587
|
+
const newlyRedactionOmitted = redactionOmittedObservedEvents - attributedRedactionOmittedObservedEvents;
|
|
1462
3588
|
results.push({
|
|
1463
3589
|
...result,
|
|
1464
|
-
...newlyDropped > 0 ? { droppedObservedEvents: (result.droppedObservedEvents ?? 0) + newlyDropped } : {}
|
|
3590
|
+
...newlyDropped > 0 ? { droppedObservedEvents: (result.droppedObservedEvents ?? 0) + newlyDropped } : {},
|
|
3591
|
+
...newlyRedactionOmitted > 0 ? {
|
|
3592
|
+
redactionOmittedObservedEvents: (result.redactionOmittedObservedEvents ?? 0) + newlyRedactionOmitted
|
|
3593
|
+
} : {}
|
|
1465
3594
|
});
|
|
1466
3595
|
attributedDroppedObservedEvents = droppedObservedEvents;
|
|
3596
|
+
attributedRedactionOmittedObservedEvents = redactionOmittedObservedEvents;
|
|
1467
3597
|
};
|
|
1468
3598
|
const unsubscribe = client.session.onEvent((event) => {
|
|
3599
|
+
if (client.session.isRedactionOmission(event)) {
|
|
3600
|
+
observedRedactionOmissionEvents += 1;
|
|
3601
|
+
droppedObservedEvents += 1;
|
|
3602
|
+
redactionOmittedObservedEvents += 1;
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
1469
3605
|
observedEvents.push(event);
|
|
1470
3606
|
if (observedEvents.length > maxObservedEvents) {
|
|
1471
3607
|
observedEvents.shift();
|
|
@@ -1489,7 +3625,7 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1489
3625
|
...common,
|
|
1490
3626
|
status: "incomplete",
|
|
1491
3627
|
durationMs: 0,
|
|
1492
|
-
error: truncateError(`Skipped after timed-out scenario: ${timedOutScenario}`, options)
|
|
3628
|
+
error: truncateError(`Skipped after timed-out scenario: ${timedOutScenario}`, options, client)
|
|
1493
3629
|
});
|
|
1494
3630
|
continue;
|
|
1495
3631
|
}
|
|
@@ -1501,7 +3637,8 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1501
3637
|
durationMs: 0,
|
|
1502
3638
|
error: truncateError(
|
|
1503
3639
|
`Missing scenario variables: ${resolved.missing.join(", ")}`,
|
|
1504
|
-
options
|
|
3640
|
+
options,
|
|
3641
|
+
client
|
|
1505
3642
|
)
|
|
1506
3643
|
});
|
|
1507
3644
|
continue;
|
|
@@ -1511,7 +3648,7 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1511
3648
|
...common,
|
|
1512
3649
|
status: "incomplete",
|
|
1513
3650
|
durationMs: 0,
|
|
1514
|
-
error: truncateError(scenario.incompleteReason ?? "Scenario is incomplete", options)
|
|
3651
|
+
error: truncateError(scenario.incompleteReason ?? "Scenario is incomplete", options, client)
|
|
1515
3652
|
});
|
|
1516
3653
|
continue;
|
|
1517
3654
|
}
|
|
@@ -1543,7 +3680,7 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1543
3680
|
...common,
|
|
1544
3681
|
status: "failed",
|
|
1545
3682
|
durationMs: performance.now() - startedAt,
|
|
1546
|
-
error: truncateError(error instanceof Error ? error.message : String(error), options)
|
|
3683
|
+
error: truncateError(error instanceof Error ? error.message : String(error), options, client)
|
|
1547
3684
|
});
|
|
1548
3685
|
if (timedOut) timedOutScenario = scenario.name;
|
|
1549
3686
|
} finally {
|
|
@@ -1551,11 +3688,21 @@ async function runScenarios(client, scenarios, options = {}) {
|
|
|
1551
3688
|
}
|
|
1552
3689
|
}
|
|
1553
3690
|
} finally {
|
|
3691
|
+
const unobservedRedactionOmissions = Math.max(
|
|
3692
|
+
0,
|
|
3693
|
+
client.session.getRedactionOmissionCounts().clientEvents - initialClientEventRedactionOmissions - observedRedactionOmissionEvents
|
|
3694
|
+
);
|
|
3695
|
+
droppedObservedEvents += unobservedRedactionOmissions;
|
|
3696
|
+
redactionOmittedObservedEvents += unobservedRedactionOmissions;
|
|
1554
3697
|
const unattributed = droppedObservedEvents - attributedDroppedObservedEvents;
|
|
1555
3698
|
const lastResult = results.at(-1);
|
|
1556
3699
|
if (lastResult && unattributed > 0) {
|
|
1557
3700
|
lastResult.droppedObservedEvents = (lastResult.droppedObservedEvents ?? 0) + unattributed;
|
|
1558
3701
|
}
|
|
3702
|
+
const unattributedRedaction = redactionOmittedObservedEvents - attributedRedactionOmittedObservedEvents;
|
|
3703
|
+
if (lastResult && unattributedRedaction > 0) {
|
|
3704
|
+
lastResult.redactionOmittedObservedEvents = (lastResult.redactionOmittedObservedEvents ?? 0) + unattributedRedaction;
|
|
3705
|
+
}
|
|
1559
3706
|
unsubscribe();
|
|
1560
3707
|
}
|
|
1561
3708
|
return results;
|
|
@@ -1565,20 +3712,29 @@ function resolveScenarioVariables(scenario, supplied) {
|
|
|
1565
3712
|
const required = new Set(scenario.variables ?? []);
|
|
1566
3713
|
const marker = /\$\{([A-Z][A-Z0-9_]*)\}/g;
|
|
1567
3714
|
const encodedMarker = /%24%7[Bb]([A-Z][A-Z0-9_]*)%7[Dd]/g;
|
|
3715
|
+
const visitString = (value) => {
|
|
3716
|
+
for (const match of value.matchAll(marker)) required.add(match[1]);
|
|
3717
|
+
for (const match of value.matchAll(encodedMarker)) required.add(match[1]);
|
|
3718
|
+
return value.replace(marker, (original, name) => values[name] ?? original).replace(
|
|
3719
|
+
encodedMarker,
|
|
3720
|
+
(original, name) => values[name] === void 0 ? original : encodeURIComponent(values[name])
|
|
3721
|
+
);
|
|
3722
|
+
};
|
|
1568
3723
|
const visit = (value) => {
|
|
1569
|
-
if (typeof value === "string")
|
|
1570
|
-
for (const match of value.matchAll(marker)) required.add(match[1]);
|
|
1571
|
-
for (const match of value.matchAll(encodedMarker)) required.add(match[1]);
|
|
1572
|
-
return value.replace(marker, (original, name) => values[name] ?? original).replace(
|
|
1573
|
-
encodedMarker,
|
|
1574
|
-
(original, name) => values[name] === void 0 ? original : encodeURIComponent(values[name])
|
|
1575
|
-
);
|
|
1576
|
-
}
|
|
3724
|
+
if (typeof value === "string") return visitString(value);
|
|
1577
3725
|
if (Array.isArray(value)) return value.map(visit);
|
|
1578
3726
|
if (value && typeof value === "object") {
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
)
|
|
3727
|
+
const entries = [];
|
|
3728
|
+
const keys = /* @__PURE__ */ new Set();
|
|
3729
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
3730
|
+
const resolvedKey = visitString(key);
|
|
3731
|
+
if (keys.has(resolvedKey)) {
|
|
3732
|
+
throw new Error("Scenario variable substitution creates duplicate object key");
|
|
3733
|
+
}
|
|
3734
|
+
keys.add(resolvedKey);
|
|
3735
|
+
entries.push([resolvedKey, visit(entry)]);
|
|
3736
|
+
}
|
|
3737
|
+
return Object.fromEntries(entries);
|
|
1582
3738
|
}
|
|
1583
3739
|
return value;
|
|
1584
3740
|
};
|
|
@@ -1595,6 +3751,11 @@ function createMcpFnScenarioReport(results, options = {}) {
|
|
|
1595
3751
|
(total, result) => total + (result.droppedObservedEvents ?? 0),
|
|
1596
3752
|
0
|
|
1597
3753
|
);
|
|
3754
|
+
const redactionOmittedObservedEvents = results.reduce(
|
|
3755
|
+
(total, result) => total + (result.redactionOmittedObservedEvents ?? 0),
|
|
3756
|
+
0
|
|
3757
|
+
);
|
|
3758
|
+
const overflowedObservedEvents = droppedObservedEvents - redactionOmittedObservedEvents;
|
|
1598
3759
|
const report = {
|
|
1599
3760
|
formatVersion: 1,
|
|
1600
3761
|
kind: "mcpfn.scenario-report",
|
|
@@ -1606,7 +3767,13 @@ function createMcpFnScenarioReport(results, options = {}) {
|
|
|
1606
3767
|
incomplete,
|
|
1607
3768
|
droppedResults: 0,
|
|
1608
3769
|
droppedObservedEvents,
|
|
1609
|
-
...
|
|
3770
|
+
...redactionOmittedObservedEvents > 0 ? { redactionOmittedObservedEvents } : {},
|
|
3771
|
+
...droppedObservedEvents > 0 ? {
|
|
3772
|
+
incompleteReason: [
|
|
3773
|
+
...redactionOmittedObservedEvents > 0 ? ["Observed client events were omitted because credential redaction failed"] : [],
|
|
3774
|
+
...overflowedObservedEvents > 0 ? ["Observed client events exceeded maxObservedEvents"] : []
|
|
3775
|
+
].join("; ")
|
|
3776
|
+
} : {},
|
|
1610
3777
|
results: structuredClone(results)
|
|
1611
3778
|
};
|
|
1612
3779
|
const maxBytes = options.maxBytes ?? 1048576;
|
|
@@ -1747,12 +3914,16 @@ async function executeAuthScenario(scenario, signal, runOptions) {
|
|
|
1747
3914
|
}
|
|
1748
3915
|
assertExpected(await runOptions.auth(scenario, signal), scenario.expect);
|
|
1749
3916
|
}
|
|
1750
|
-
function truncateError(value, options) {
|
|
1751
|
-
|
|
3917
|
+
function truncateError(value, options, client) {
|
|
3918
|
+
let redacted = "Scenario error omitted because credential redaction failed";
|
|
3919
|
+
try {
|
|
3920
|
+
redacted = String(client.session.redact(value));
|
|
3921
|
+
} catch {
|
|
3922
|
+
}
|
|
1752
3923
|
const maxBytes = options.maxErrorBytes ?? 4096;
|
|
1753
|
-
const
|
|
1754
|
-
if (
|
|
1755
|
-
return `${new TextDecoder().decode(
|
|
3924
|
+
const bytes2 = new TextEncoder().encode(redacted);
|
|
3925
|
+
if (bytes2.byteLength <= maxBytes) return redacted;
|
|
3926
|
+
return `${new TextDecoder().decode(bytes2.slice(0, Math.max(0, maxBytes - 16)))}...[TRUNCATED]`;
|
|
1756
3927
|
}
|
|
1757
3928
|
function defaultSideEffect(operation) {
|
|
1758
3929
|
if (operation === "tools.call" || operation === "tools.call:task") {
|
|
@@ -1816,87 +3987,721 @@ async function listScenarioInventory(client, kind, requestOptions) {
|
|
|
1816
3987
|
}
|
|
1817
3988
|
|
|
1818
3989
|
// src/suite.ts
|
|
1819
|
-
import {
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
3990
|
+
import {
|
|
3991
|
+
McpFnClientError as McpFnClientError3
|
|
3992
|
+
} from "@mcpfn/client";
|
|
3993
|
+
var suiteCleanupOwners = /* @__PURE__ */ new WeakMap();
|
|
3994
|
+
function retryTargetSuiteCleanup(error) {
|
|
3995
|
+
const owner = suiteCleanupOwners.get(error);
|
|
3996
|
+
if (!owner) return Promise.resolve();
|
|
3997
|
+
if (owner.pending) return owner.pending;
|
|
3998
|
+
const pending = Promise.resolve().then(owner.close).then(
|
|
3999
|
+
() => {
|
|
4000
|
+
suiteCleanupOwners.delete(error);
|
|
4001
|
+
},
|
|
4002
|
+
() => {
|
|
4003
|
+
throw error;
|
|
4004
|
+
}
|
|
4005
|
+
).finally(() => {
|
|
4006
|
+
owner.pending = void 0;
|
|
4007
|
+
});
|
|
4008
|
+
owner.pending = pending;
|
|
4009
|
+
return pending;
|
|
4010
|
+
}
|
|
4011
|
+
var McpFnTargetSuiteCleanupError = class extends Error {
|
|
4012
|
+
constructor(report, close) {
|
|
4013
|
+
super("Target suite cleanup failed; retain this error and retryCleanup()");
|
|
4014
|
+
this.report = report;
|
|
4015
|
+
this.name = "McpFnTargetSuiteCleanupError";
|
|
4016
|
+
suiteCleanupOwners.set(this, { close });
|
|
1826
4017
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
4018
|
+
report;
|
|
4019
|
+
retryCleanup() {
|
|
4020
|
+
return retryTargetSuiteCleanup(this);
|
|
4021
|
+
}
|
|
4022
|
+
};
|
|
4023
|
+
var McpFnTargetSuiteArtifactCleanupError = class extends Error {
|
|
4024
|
+
constructor(close, cause) {
|
|
4025
|
+
super(`${cause.message}; retain this error and retryCleanup()`, { cause });
|
|
4026
|
+
this.name = "McpFnTargetSuiteArtifactCleanupError";
|
|
4027
|
+
suiteCleanupOwners.set(this, { close });
|
|
4028
|
+
}
|
|
4029
|
+
retryCleanup() {
|
|
4030
|
+
return retryTargetSuiteCleanup(this);
|
|
4031
|
+
}
|
|
4032
|
+
};
|
|
4033
|
+
async function runMcpFnTargetSuite(options) {
|
|
4034
|
+
const artifactRedaction = beginSuiteArtifactRedaction(options.target);
|
|
4035
|
+
let finishRedaction = artifactRedaction.finish;
|
|
4036
|
+
const retainArtifactGuard = (report) => {
|
|
4037
|
+
registerMcpFnTargetSuiteArtifactGuard(
|
|
4038
|
+
report,
|
|
4039
|
+
(artifact) => {
|
|
4040
|
+
if (!artifactRedaction.retained) return false;
|
|
4041
|
+
if (options.target.assertArtifactSafe) {
|
|
4042
|
+
options.target.assertArtifactSafe(artifact);
|
|
4043
|
+
return true;
|
|
1840
4044
|
}
|
|
1841
|
-
|
|
4045
|
+
if (!options.target.redact) return true;
|
|
4046
|
+
return Object.is(
|
|
4047
|
+
redactSuiteArtifact(options.target, artifact, { preserveKeys: false }),
|
|
4048
|
+
artifact
|
|
4049
|
+
);
|
|
4050
|
+
},
|
|
4051
|
+
finishRedaction,
|
|
4052
|
+
options.maxReportBytes ?? 1048576
|
|
4053
|
+
);
|
|
4054
|
+
finishRedaction = () => void 0;
|
|
4055
|
+
try {
|
|
4056
|
+
preserveMcpFnTargetSuiteArtifact(report, JSON.stringify(report));
|
|
4057
|
+
} catch (error) {
|
|
4058
|
+
disposeMcpFnTargetSuiteArtifactGuard(report);
|
|
4059
|
+
throw error;
|
|
4060
|
+
}
|
|
4061
|
+
};
|
|
4062
|
+
try {
|
|
4063
|
+
const report = await runTargetSuite(options);
|
|
4064
|
+
retainArtifactGuard(report);
|
|
4065
|
+
return report;
|
|
4066
|
+
} catch (error) {
|
|
4067
|
+
if (error instanceof McpFnTargetSuiteCleanupError) {
|
|
4068
|
+
try {
|
|
4069
|
+
retainArtifactGuard(error.report);
|
|
4070
|
+
} catch (artifactError) {
|
|
4071
|
+
const cause = artifactError instanceof Error ? artifactError : new Error("Target report artifact proof failed");
|
|
4072
|
+
throw new McpFnTargetSuiteArtifactCleanupError(
|
|
4073
|
+
() => error.retryCleanup(),
|
|
4074
|
+
cause
|
|
4075
|
+
);
|
|
1842
4076
|
}
|
|
1843
4077
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
4078
|
+
throw error;
|
|
4079
|
+
} finally {
|
|
4080
|
+
finishRedaction();
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
function beginSuiteArtifactRedaction(target) {
|
|
4084
|
+
let finish;
|
|
4085
|
+
if (target.beginRedactionScope) {
|
|
1846
4086
|
try {
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
4087
|
+
finish = target.beginRedactionScope();
|
|
4088
|
+
} catch {
|
|
4089
|
+
return { finish: () => void 0, retained: false };
|
|
4090
|
+
}
|
|
4091
|
+
if (!finish) return { finish: () => void 0, retained: false };
|
|
4092
|
+
} else {
|
|
4093
|
+
finish = beginTargetCredentialRedaction(target);
|
|
4094
|
+
}
|
|
4095
|
+
return {
|
|
4096
|
+
finish: () => {
|
|
4097
|
+
try {
|
|
4098
|
+
finish?.();
|
|
4099
|
+
} catch {
|
|
1851
4100
|
}
|
|
1852
|
-
|
|
4101
|
+
},
|
|
4102
|
+
retained: true
|
|
4103
|
+
};
|
|
4104
|
+
}
|
|
4105
|
+
function redactSuiteArtifact(target, value, options = {}) {
|
|
4106
|
+
const scrubbed = target.redact ? target.redact(value, options) : value;
|
|
4107
|
+
return redactTargetCredentials(target, scrubbed, options);
|
|
4108
|
+
}
|
|
4109
|
+
function compareInventoryNames(left, right) {
|
|
4110
|
+
if (left === right) return 0;
|
|
4111
|
+
return left < right ? -1 : 1;
|
|
4112
|
+
}
|
|
4113
|
+
function reportProjectionFailureReason(error, projectionFailure) {
|
|
4114
|
+
if (error instanceof McpFnRedactionLimitError) {
|
|
4115
|
+
return "Credential redaction exceeded its traversal budget";
|
|
4116
|
+
}
|
|
4117
|
+
if (projectionFailure === "redaction") {
|
|
4118
|
+
return "Credential redaction failed; report content omitted because safe serialization failed";
|
|
4119
|
+
}
|
|
4120
|
+
return "Report content omitted because safe serialization failed";
|
|
4121
|
+
}
|
|
4122
|
+
var SuiteDiagnosticCollector = class {
|
|
4123
|
+
constructor(maxEvents, maxBytes, target, consumer) {
|
|
4124
|
+
this.maxEvents = maxEvents;
|
|
4125
|
+
this.maxBytes = maxBytes;
|
|
4126
|
+
this.target = target;
|
|
4127
|
+
this.consumer = consumer;
|
|
4128
|
+
}
|
|
4129
|
+
maxEvents;
|
|
4130
|
+
maxBytes;
|
|
4131
|
+
target;
|
|
4132
|
+
consumer;
|
|
4133
|
+
timeline = [];
|
|
4134
|
+
timelineSizes = [];
|
|
4135
|
+
timelineBytes = 0;
|
|
4136
|
+
observedRedactionOmissions = 0;
|
|
4137
|
+
initialRedactionOmissions = 0;
|
|
4138
|
+
dropped = 0;
|
|
4139
|
+
countExceeded = false;
|
|
4140
|
+
bytesExceeded = false;
|
|
4141
|
+
serializationFailed = false;
|
|
4142
|
+
redactionFailed = false;
|
|
4143
|
+
cleanupFailure;
|
|
4144
|
+
captureBaseline(client) {
|
|
4145
|
+
this.initialRedactionOmissions = client.session.getRedactionOmissionCounts().diagnostics;
|
|
4146
|
+
}
|
|
4147
|
+
captureUnobserved(client) {
|
|
4148
|
+
const total = client?.session.getRedactionOmissionCounts().diagnostics ?? this.initialRedactionOmissions;
|
|
4149
|
+
const unobserved = Math.max(
|
|
4150
|
+
0,
|
|
4151
|
+
total - this.initialRedactionOmissions - this.observedRedactionOmissions
|
|
4152
|
+
);
|
|
4153
|
+
if (unobserved > 0) {
|
|
4154
|
+
this.redactionFailed = true;
|
|
4155
|
+
this.dropped += unobserved;
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
async record(client, event) {
|
|
4159
|
+
if (client?.session.isRedactionOmission(event)) {
|
|
4160
|
+
this.observedRedactionOmissions += 1;
|
|
4161
|
+
this.redactionFailed = true;
|
|
4162
|
+
this.dropped += 1;
|
|
4163
|
+
}
|
|
4164
|
+
if (["token-revocation", "transport-close"].includes(event.phase) && event.outcome === "failed") {
|
|
4165
|
+
this.cleanupFailure ??= normalizeMcpFnReportFailure({
|
|
4166
|
+
name: "CleanupError",
|
|
4167
|
+
message: "Target cleanup failed",
|
|
4168
|
+
code: event.code,
|
|
4169
|
+
phase: event.phase
|
|
4170
|
+
});
|
|
4171
|
+
}
|
|
4172
|
+
const safeEvent = redactTargetCredentials(this.target, event, { preserveKeys: true });
|
|
4173
|
+
const bytes2 = this.serializedBytes(safeEvent);
|
|
4174
|
+
if (bytes2 === void 0) {
|
|
4175
|
+
await this.consumer?.(safeEvent);
|
|
4176
|
+
return;
|
|
4177
|
+
}
|
|
4178
|
+
if (bytes2 > this.maxBytes) {
|
|
4179
|
+
this.bytesExceeded = true;
|
|
4180
|
+
this.dropped += 1;
|
|
4181
|
+
} else {
|
|
4182
|
+
this.timeline.push(safeEvent);
|
|
4183
|
+
this.timelineSizes.push(bytes2);
|
|
4184
|
+
this.timelineBytes += bytes2;
|
|
4185
|
+
this.enforceBounds();
|
|
4186
|
+
}
|
|
4187
|
+
await this.consumer?.(safeEvent);
|
|
4188
|
+
}
|
|
4189
|
+
serializedBytes(event) {
|
|
4190
|
+
try {
|
|
4191
|
+
const json2 = JSON.stringify(event, (_key, value) => {
|
|
4192
|
+
if (value === void 0 || typeof value === "number" && !Number.isFinite(value)) {
|
|
4193
|
+
throw new Error("Diagnostic contains a non-JSON value");
|
|
4194
|
+
}
|
|
4195
|
+
return value;
|
|
4196
|
+
});
|
|
4197
|
+
structuredClone(event);
|
|
4198
|
+
return Buffer.byteLength(json2);
|
|
4199
|
+
} catch {
|
|
4200
|
+
this.serializationFailed = true;
|
|
4201
|
+
this.dropped += 1;
|
|
4202
|
+
return void 0;
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
enforceBounds() {
|
|
4206
|
+
while (this.timeline.length > this.maxEvents || this.timelineBytes > this.maxBytes) {
|
|
4207
|
+
this.countExceeded ||= this.timeline.length > this.maxEvents;
|
|
4208
|
+
this.bytesExceeded ||= this.timelineBytes > this.maxBytes;
|
|
4209
|
+
this.timeline.shift();
|
|
4210
|
+
this.timelineBytes -= this.timelineSizes.shift();
|
|
4211
|
+
this.dropped += 1;
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
};
|
|
4215
|
+
async function executeConnectedSuite(client, options) {
|
|
4216
|
+
let manifestChecked = false;
|
|
4217
|
+
if (options.manifest) {
|
|
4218
|
+
manifestChecked = true;
|
|
4219
|
+
await assertManifestContract(client, options.manifest, {
|
|
4220
|
+
expectedToolNames: options.expectedToolNames
|
|
4221
|
+
});
|
|
4222
|
+
} else if (options.expectedToolNames) {
|
|
4223
|
+
const actual = (await client.listTools()).map((tool) => tool.name).sort(compareInventoryNames);
|
|
4224
|
+
const expected = [...options.expectedToolNames].sort(compareInventoryNames);
|
|
4225
|
+
if (stableJson(actual) !== stableJson(expected)) {
|
|
4226
|
+
throw new McpFnAssertionError(
|
|
4227
|
+
`Tool inventory mismatch: expected ${stableJson(expected)}, actual ${stableJson(actual)}`
|
|
4228
|
+
);
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
const results = await runScenarios(client, options.scenarios ?? [], options.scenarioRun);
|
|
4232
|
+
return {
|
|
4233
|
+
manifestChecked,
|
|
4234
|
+
execution: {
|
|
4235
|
+
results,
|
|
4236
|
+
server: client.session.getServerVersion(),
|
|
4237
|
+
capabilities: client.session.getServerCapabilities()
|
|
4238
|
+
}
|
|
4239
|
+
};
|
|
4240
|
+
}
|
|
4241
|
+
function safeTargetFailure(target, error) {
|
|
4242
|
+
try {
|
|
4243
|
+
return normalizeMcpFnReportFailure(redactSuiteArtifact(target, error, { preserveKeys: true }));
|
|
4244
|
+
} catch {
|
|
4245
|
+
return normalizeMcpFnReportFailure({
|
|
4246
|
+
name: "RedactionError",
|
|
4247
|
+
code: "MCPFN_REDACTION_FAILED",
|
|
4248
|
+
message: "Target failure omitted because credential redaction failed"
|
|
4249
|
+
});
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
function initialTargetDescriptor(target) {
|
|
4253
|
+
const kind = target.kind;
|
|
4254
|
+
return ["stdio", "streamable-http", "authenticated-streamable-http", "in-memory", "custom"].includes(kind) ? { kind } : { kind: "custom" };
|
|
4255
|
+
}
|
|
4256
|
+
function safeInitialTargetDescriptor(target) {
|
|
4257
|
+
try {
|
|
4258
|
+
return initialTargetDescriptor(target);
|
|
4259
|
+
} catch {
|
|
4260
|
+
return { kind: "custom" };
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
function projectSuiteArtifacts(options, client, execution) {
|
|
4264
|
+
const fallback = {
|
|
4265
|
+
target: safeInitialTargetDescriptor(options.target),
|
|
4266
|
+
execution
|
|
4267
|
+
};
|
|
4268
|
+
try {
|
|
4269
|
+
if (client?.session.state !== "connected") {
|
|
4270
|
+
return execution.results.length || execution.server || execution.capabilities ? { ...fallback, failure: "serialization" } : fallback;
|
|
4271
|
+
}
|
|
4272
|
+
let described;
|
|
4273
|
+
try {
|
|
4274
|
+
described = options.target.describe();
|
|
4275
|
+
} catch {
|
|
4276
|
+
return { ...fallback, failure: "serialization" };
|
|
4277
|
+
}
|
|
4278
|
+
try {
|
|
4279
|
+
const { kind, ...descriptor } = described;
|
|
1853
4280
|
return {
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
4281
|
+
target: {
|
|
4282
|
+
...redactSuiteArtifact(options.target, descriptor, { preserveKeys: false }),
|
|
4283
|
+
kind: client.session.preserveArtifactStructure(kind)
|
|
4284
|
+
},
|
|
4285
|
+
manifestHash: options.manifest ? redactSuiteArtifact(options.target, options.manifest.hash, { preserveKeys: false }) : void 0,
|
|
4286
|
+
execution: {
|
|
4287
|
+
server: execution.server === void 0 ? void 0 : redactSuiteArtifact(options.target, execution.server, { preserveKeys: false }),
|
|
4288
|
+
capabilities: execution.capabilities === void 0 ? void 0 : redactSuiteArtifact(options.target, execution.capabilities, { preserveKeys: false }),
|
|
4289
|
+
results: execution.results.map((result) => ({
|
|
4290
|
+
...result,
|
|
4291
|
+
name: redactSuiteArtifact(options.target, result.name, { preserveKeys: false }),
|
|
4292
|
+
operation: redactSuiteArtifact(options.target, result.operation, { preserveKeys: false }),
|
|
4293
|
+
...result.tool === void 0 ? {} : {
|
|
4294
|
+
tool: redactSuiteArtifact(options.target, result.tool, { preserveKeys: false })
|
|
4295
|
+
}
|
|
4296
|
+
}))
|
|
4297
|
+
}
|
|
1857
4298
|
};
|
|
1858
|
-
}
|
|
1859
|
-
|
|
4299
|
+
} catch {
|
|
4300
|
+
return { ...fallback, failure: "redaction" };
|
|
4301
|
+
}
|
|
4302
|
+
} catch {
|
|
4303
|
+
return { ...fallback, failure: "serialization" };
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
var suiteStructureKeys = [
|
|
4307
|
+
"formatVersion",
|
|
4308
|
+
"kind",
|
|
4309
|
+
"artifactValidation",
|
|
4310
|
+
"status",
|
|
4311
|
+
"runtime",
|
|
4312
|
+
"ok",
|
|
4313
|
+
"target",
|
|
4314
|
+
"server",
|
|
4315
|
+
"capabilities",
|
|
4316
|
+
"manifestChecked",
|
|
4317
|
+
"manifestHash",
|
|
4318
|
+
"total",
|
|
4319
|
+
"passed",
|
|
4320
|
+
"failed",
|
|
4321
|
+
"incomplete",
|
|
4322
|
+
"droppedResults",
|
|
4323
|
+
"droppedObservedEvents",
|
|
4324
|
+
"redactionOmittedObservedEvents",
|
|
4325
|
+
"incompleteReason",
|
|
4326
|
+
"failure",
|
|
4327
|
+
"timeline",
|
|
4328
|
+
"droppedTimelineEvents",
|
|
4329
|
+
"results",
|
|
4330
|
+
"node",
|
|
4331
|
+
"scenarioFormatVersion",
|
|
4332
|
+
"reportSchemaVersion",
|
|
4333
|
+
"packages",
|
|
4334
|
+
"testing",
|
|
4335
|
+
"name",
|
|
4336
|
+
"message",
|
|
4337
|
+
"layer",
|
|
4338
|
+
"code",
|
|
4339
|
+
"phase",
|
|
4340
|
+
"details",
|
|
4341
|
+
"outcome",
|
|
4342
|
+
"requestId",
|
|
4343
|
+
"at",
|
|
4344
|
+
"sideEffect",
|
|
4345
|
+
"durationMs",
|
|
4346
|
+
"error",
|
|
4347
|
+
"operation",
|
|
4348
|
+
"tool"
|
|
4349
|
+
];
|
|
4350
|
+
var suiteStructureValues = [
|
|
4351
|
+
"mcpfn.target-suite-report",
|
|
4352
|
+
"target-aware",
|
|
4353
|
+
"complete",
|
|
4354
|
+
"incomplete",
|
|
4355
|
+
"passed",
|
|
4356
|
+
"failed",
|
|
4357
|
+
"started",
|
|
4358
|
+
"succeeded",
|
|
4359
|
+
"none",
|
|
4360
|
+
"idempotent",
|
|
4361
|
+
"non-idempotent",
|
|
4362
|
+
"mcpfn-preflight",
|
|
4363
|
+
"authorization-server",
|
|
4364
|
+
"resource-server",
|
|
4365
|
+
"mcp-initialization",
|
|
4366
|
+
"scenario",
|
|
4367
|
+
"upstream-conformance",
|
|
4368
|
+
"resource-discovery",
|
|
4369
|
+
"authorization-server-discovery",
|
|
4370
|
+
"client-registration",
|
|
4371
|
+
"authorization-request",
|
|
4372
|
+
"authorization-callback",
|
|
4373
|
+
"token-exchange",
|
|
4374
|
+
"token-refresh",
|
|
4375
|
+
"token-revocation",
|
|
4376
|
+
"transport-connect",
|
|
4377
|
+
"mcp-initialize",
|
|
4378
|
+
"capability-operation",
|
|
4379
|
+
"transport-close",
|
|
4380
|
+
"authenticated-streamable-http",
|
|
4381
|
+
"streamable-http",
|
|
4382
|
+
"stdio",
|
|
4383
|
+
"in-memory",
|
|
4384
|
+
"custom"
|
|
4385
|
+
];
|
|
4386
|
+
function captureSuiteStructureGuard(target, projection, diagnostics, failure) {
|
|
4387
|
+
const candidates = /* @__PURE__ */ new Set([
|
|
4388
|
+
...suiteStructureKeys,
|
|
4389
|
+
...suiteStructureValues,
|
|
4390
|
+
process.version,
|
|
4391
|
+
MCPFN_REPORT_SCHEMA_VERSION,
|
|
4392
|
+
MCPFN_TESTING_VERSION,
|
|
4393
|
+
projection.target.kind
|
|
4394
|
+
]);
|
|
4395
|
+
if (failure?.phase) candidates.add(failure.phase);
|
|
4396
|
+
for (const result of projection.execution.results) {
|
|
4397
|
+
candidates.add(result.status);
|
|
4398
|
+
if (result.sideEffect) candidates.add(result.sideEffect);
|
|
4399
|
+
}
|
|
4400
|
+
for (const event of diagnostics.timeline) {
|
|
4401
|
+
candidates.add(event.phase);
|
|
4402
|
+
candidates.add(event.outcome);
|
|
4403
|
+
if (event.target?.kind) candidates.add(event.target.kind);
|
|
4404
|
+
}
|
|
4405
|
+
const safe = /* @__PURE__ */ new Set();
|
|
4406
|
+
for (const candidate of candidates) {
|
|
4407
|
+
try {
|
|
4408
|
+
if (redactSuiteArtifact(target, candidate, { preserveKeys: false }) === candidate) {
|
|
4409
|
+
safe.add(candidate);
|
|
4410
|
+
}
|
|
4411
|
+
} catch {
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
return {
|
|
4415
|
+
assert(value) {
|
|
4416
|
+
if (!safe.has(value)) throw new McpFnStructuralCredentialCollisionError();
|
|
4417
|
+
}
|
|
4418
|
+
};
|
|
4419
|
+
}
|
|
4420
|
+
function assertStructureKeys(value, guard) {
|
|
4421
|
+
for (const key of Object.keys(value)) guard.assert(key);
|
|
4422
|
+
}
|
|
4423
|
+
function assertTargetStructure(target, guard) {
|
|
4424
|
+
guard.assert("kind");
|
|
4425
|
+
guard.assert(target.kind);
|
|
4426
|
+
}
|
|
4427
|
+
function assertSuiteReportStructure(report, guard) {
|
|
4428
|
+
assertStructureKeys(report, guard);
|
|
4429
|
+
guard.assert(report.kind);
|
|
4430
|
+
if (report.artifactValidation) guard.assert(report.artifactValidation);
|
|
4431
|
+
guard.assert(report.status);
|
|
4432
|
+
assertStructureKeys(report.runtime, guard);
|
|
4433
|
+
guard.assert(report.runtime.node);
|
|
4434
|
+
guard.assert(report.runtime.reportSchemaVersion);
|
|
4435
|
+
assertStructureKeys(report.runtime.packages, guard);
|
|
4436
|
+
guard.assert(report.runtime.packages.testing);
|
|
4437
|
+
assertTargetStructure(report.target, guard);
|
|
4438
|
+
if (report.failure) {
|
|
4439
|
+
assertStructureKeys(report.failure, guard);
|
|
4440
|
+
guard.assert(report.failure.layer);
|
|
4441
|
+
if (report.failure.phase) guard.assert(report.failure.phase);
|
|
4442
|
+
}
|
|
4443
|
+
for (const event of report.timeline) {
|
|
4444
|
+
assertStructureKeys(event, guard);
|
|
4445
|
+
guard.assert(event.phase);
|
|
4446
|
+
guard.assert(event.outcome);
|
|
4447
|
+
if (event.target) assertTargetStructure(event.target, guard);
|
|
4448
|
+
}
|
|
4449
|
+
for (const result of report.results) {
|
|
4450
|
+
assertStructureKeys(result, guard);
|
|
4451
|
+
guard.assert(result.status);
|
|
4452
|
+
if (result.sideEffect) guard.assert(result.sideEffect);
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4455
|
+
async function closeSuiteClient(client) {
|
|
4456
|
+
try {
|
|
4457
|
+
await client?.close();
|
|
4458
|
+
return {};
|
|
4459
|
+
} catch (error) {
|
|
4460
|
+
let retainedCleanup;
|
|
4461
|
+
if (error instanceof McpFnTestClientCleanupError) {
|
|
4462
|
+
retainedCleanup = () => error.retryCleanup();
|
|
4463
|
+
} else if (client) {
|
|
4464
|
+
retainedCleanup = () => client.close();
|
|
1860
4465
|
}
|
|
1861
|
-
|
|
1862
|
-
|
|
4466
|
+
const terminal = error instanceof McpFnTestClientCleanupError && error.cause !== void 0 ? error.cause : error;
|
|
4467
|
+
const phase = terminal && typeof terminal === "object" && terminal.phase === "token-revocation" ? "token-revocation" : "transport-close";
|
|
4468
|
+
return {
|
|
4469
|
+
retainedCleanup,
|
|
4470
|
+
cleanupFailure: normalizeMcpFnReportFailure({
|
|
4471
|
+
name: "CleanupError",
|
|
4472
|
+
message: "Target cleanup failed",
|
|
4473
|
+
code: "MCPFN_TARGET_CLEANUP_FAILED",
|
|
4474
|
+
phase
|
|
4475
|
+
})
|
|
4476
|
+
};
|
|
4477
|
+
}
|
|
4478
|
+
}
|
|
4479
|
+
async function connectSuite(options, diagnostics) {
|
|
4480
|
+
let client;
|
|
4481
|
+
let initialClientEventRedactionOmissions = 0;
|
|
4482
|
+
let manifestChecked = false;
|
|
4483
|
+
let failure;
|
|
4484
|
+
let execution = { results: [] };
|
|
4485
|
+
try {
|
|
4486
|
+
client = McpFnTestClient.createTarget(
|
|
4487
|
+
options.target,
|
|
4488
|
+
options.clientInfo ?? { name: "mcpfn-suite", version: "0.0.1" },
|
|
4489
|
+
{
|
|
4490
|
+
...options.client,
|
|
4491
|
+
diagnostics: (event) => diagnostics.record(client, event)
|
|
4492
|
+
}
|
|
4493
|
+
);
|
|
4494
|
+
initialClientEventRedactionOmissions = client.session.getRedactionOmissionCounts().clientEvents;
|
|
4495
|
+
diagnostics.captureBaseline(client);
|
|
4496
|
+
await client.session.connect();
|
|
4497
|
+
({ execution, manifestChecked } = await executeConnectedSuite(client, options));
|
|
4498
|
+
} catch (error) {
|
|
4499
|
+
failure = safeTargetFailure(options.target, error);
|
|
4500
|
+
}
|
|
4501
|
+
return {
|
|
4502
|
+
client,
|
|
4503
|
+
initialClientEventRedactionOmissions,
|
|
4504
|
+
manifestChecked,
|
|
4505
|
+
failure,
|
|
4506
|
+
execution
|
|
4507
|
+
};
|
|
4508
|
+
}
|
|
4509
|
+
async function projectAndCloseSuite(options, diagnostics, connection) {
|
|
4510
|
+
let projection;
|
|
4511
|
+
let structureGuard;
|
|
4512
|
+
let closeResult = {};
|
|
4513
|
+
try {
|
|
4514
|
+
projection = projectSuiteArtifacts(options, connection.client, connection.execution);
|
|
4515
|
+
structureGuard = captureSuiteStructureGuard(
|
|
4516
|
+
options.target,
|
|
4517
|
+
projection,
|
|
4518
|
+
diagnostics,
|
|
4519
|
+
connection.failure
|
|
4520
|
+
);
|
|
4521
|
+
} finally {
|
|
4522
|
+
closeResult = await closeSuiteClient(connection.client);
|
|
4523
|
+
}
|
|
4524
|
+
return { projection, structureGuard, closeResult };
|
|
4525
|
+
}
|
|
4526
|
+
function suiteIncompleteReasons(failure, cleanupFailure, diagnostics, redactionOmittedObservedEvents, overflowedObservedEvents) {
|
|
4527
|
+
const reasons = [
|
|
4528
|
+
...failure ? [`${failure.layer}: ${failure.message}`] : [],
|
|
4529
|
+
...cleanupFailure ? [`Cleanup: ${cleanupFailure.message}`] : [],
|
|
4530
|
+
...diagnostics.serializationFailed ? ["Diagnostic timeline contained non-JSON data"] : [],
|
|
4531
|
+
...diagnostics.redactionFailed ? ["Diagnostic timeline redaction failed; original events omitted"] : [],
|
|
4532
|
+
...diagnostics.countExceeded ? ["Diagnostic timeline exceeded maxTimelineEvents"] : [],
|
|
4533
|
+
...diagnostics.bytesExceeded ? ["Diagnostic timeline exceeded maxReportBytes"] : [],
|
|
4534
|
+
...redactionOmittedObservedEvents > 0 ? ["Observed client events were omitted because credential redaction failed"] : [],
|
|
4535
|
+
...overflowedObservedEvents > 0 ? ["Observed client events exceeded maxObservedEvents"] : []
|
|
4536
|
+
];
|
|
4537
|
+
return reasons.length ? reasons.join("; ") : void 0;
|
|
4538
|
+
}
|
|
4539
|
+
function buildSuiteReport(projection, manifestChecked, failure, cleanupFailure, diagnostics, lifecycleRedactionOmissions) {
|
|
4540
|
+
const results = projection.execution.results;
|
|
1863
4541
|
const failed = results.filter((result) => result.status === "failed").length;
|
|
1864
4542
|
const incomplete = results.filter((result) => result.status === "incomplete").length;
|
|
1865
|
-
const
|
|
4543
|
+
const scenarioDroppedObservedEvents = results.reduce(
|
|
1866
4544
|
(total, result) => total + (result.droppedObservedEvents ?? 0),
|
|
1867
4545
|
0
|
|
1868
4546
|
);
|
|
1869
|
-
const
|
|
1870
|
-
|
|
4547
|
+
const scenarioRedactionOmissions = results.reduce(
|
|
4548
|
+
(total, result) => total + (result.redactionOmittedObservedEvents ?? 0),
|
|
4549
|
+
0
|
|
4550
|
+
);
|
|
4551
|
+
const redactionOmittedObservedEvents = Math.max(
|
|
4552
|
+
lifecycleRedactionOmissions,
|
|
4553
|
+
scenarioRedactionOmissions
|
|
4554
|
+
);
|
|
4555
|
+
const overflowedObservedEvents = Math.max(
|
|
4556
|
+
0,
|
|
4557
|
+
scenarioDroppedObservedEvents - scenarioRedactionOmissions
|
|
4558
|
+
);
|
|
4559
|
+
const droppedObservedEvents = redactionOmittedObservedEvents + overflowedObservedEvents;
|
|
4560
|
+
const artifactIncomplete = Boolean(failure) || incomplete > 0 || diagnostics.dropped > 0 || droppedObservedEvents > 0;
|
|
4561
|
+
const incompleteReason = suiteIncompleteReasons(
|
|
4562
|
+
failure,
|
|
4563
|
+
cleanupFailure,
|
|
4564
|
+
diagnostics,
|
|
4565
|
+
redactionOmittedObservedEvents,
|
|
4566
|
+
overflowedObservedEvents
|
|
4567
|
+
);
|
|
4568
|
+
return {
|
|
1871
4569
|
formatVersion: 1,
|
|
1872
4570
|
kind: "mcpfn.target-suite-report",
|
|
4571
|
+
artifactValidation: "target-aware",
|
|
1873
4572
|
status: artifactIncomplete ? "incomplete" : "complete",
|
|
1874
|
-
runtime: {
|
|
4573
|
+
runtime: {
|
|
4574
|
+
node: process.version,
|
|
4575
|
+
scenarioFormatVersion: 1,
|
|
4576
|
+
reportSchemaVersion: MCPFN_REPORT_SCHEMA_VERSION,
|
|
4577
|
+
packages: { testing: MCPFN_TESTING_VERSION }
|
|
4578
|
+
},
|
|
1875
4579
|
ok: failed === 0 && !artifactIncomplete,
|
|
1876
|
-
target:
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
manifestChecked: Boolean(options.manifest),
|
|
1882
|
-
...options.manifest ? { manifestHash: options.manifest.hash } : {},
|
|
4580
|
+
target: projection.target,
|
|
4581
|
+
server: projection.execution.server,
|
|
4582
|
+
capabilities: projection.execution.capabilities,
|
|
4583
|
+
manifestChecked,
|
|
4584
|
+
...projection.manifestHash === void 0 ? {} : { manifestHash: projection.manifestHash },
|
|
1883
4585
|
total: results.length,
|
|
1884
4586
|
passed: results.length - failed - incomplete,
|
|
1885
4587
|
failed,
|
|
1886
4588
|
incomplete,
|
|
1887
4589
|
droppedResults: 0,
|
|
1888
4590
|
droppedObservedEvents,
|
|
1889
|
-
...
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
} : {},
|
|
1895
|
-
timeline,
|
|
1896
|
-
droppedTimelineEvents,
|
|
4591
|
+
...redactionOmittedObservedEvents > 0 ? { redactionOmittedObservedEvents } : {},
|
|
4592
|
+
...incompleteReason ? { incompleteReason } : {},
|
|
4593
|
+
...failure ? { failure } : {},
|
|
4594
|
+
timeline: diagnostics.timeline,
|
|
4595
|
+
droppedTimelineEvents: diagnostics.dropped,
|
|
1897
4596
|
results
|
|
1898
4597
|
};
|
|
1899
|
-
|
|
4598
|
+
}
|
|
4599
|
+
function structuralReportFailure(retainedCleanup) {
|
|
4600
|
+
const failure = new McpFnClientError3(
|
|
4601
|
+
"MCPFN_OPERATION_FAILED",
|
|
4602
|
+
"Target report cannot be serialized because a credential conflicts with required artifact structure",
|
|
4603
|
+
{ phase: "capability-operation" }
|
|
4604
|
+
);
|
|
4605
|
+
if (retainedCleanup) {
|
|
4606
|
+
throw new McpFnTargetSuiteArtifactCleanupError(retainedCleanup, failure);
|
|
4607
|
+
}
|
|
4608
|
+
throw failure;
|
|
4609
|
+
}
|
|
4610
|
+
function boundedSuiteReport(report, guard, maxReportBytes) {
|
|
4611
|
+
const bounded = enforceReportCap(report, maxReportBytes);
|
|
4612
|
+
assertSuiteReportStructure(bounded, guard);
|
|
4613
|
+
return bounded;
|
|
4614
|
+
}
|
|
4615
|
+
function finalizeSuiteReport(options, projection, report, guard, maxReportBytes, retainedCleanup) {
|
|
4616
|
+
let finalized;
|
|
4617
|
+
try {
|
|
4618
|
+
if (projection.failure) throw new Error("Report payload projection failed");
|
|
4619
|
+
assertSuiteReportStructure(report, guard);
|
|
4620
|
+
finalized = boundedSuiteReport(
|
|
4621
|
+
redactTargetCredentials(options.target, report, { preserveKeys: true }),
|
|
4622
|
+
guard,
|
|
4623
|
+
maxReportBytes
|
|
4624
|
+
);
|
|
4625
|
+
} catch (error) {
|
|
4626
|
+
if (error instanceof McpFnStructuralCredentialCollisionError) {
|
|
4627
|
+
return structuralReportFailure(retainedCleanup);
|
|
4628
|
+
}
|
|
4629
|
+
const fallback = {
|
|
4630
|
+
...report,
|
|
4631
|
+
ok: false,
|
|
4632
|
+
status: "incomplete",
|
|
4633
|
+
incompleteReason: reportProjectionFailureReason(error, projection.failure),
|
|
4634
|
+
target: { kind: "custom" },
|
|
4635
|
+
server: void 0,
|
|
4636
|
+
capabilities: void 0,
|
|
4637
|
+
manifestHash: void 0,
|
|
4638
|
+
failure: void 0,
|
|
4639
|
+
results: [],
|
|
4640
|
+
timeline: [],
|
|
4641
|
+
droppedResults: report.results.length,
|
|
4642
|
+
droppedTimelineEvents: report.droppedTimelineEvents + report.timeline.length
|
|
4643
|
+
};
|
|
4644
|
+
try {
|
|
4645
|
+
finalized = boundedSuiteReport(
|
|
4646
|
+
redactTargetCredentials(options.target, fallback, { preserveKeys: true }),
|
|
4647
|
+
guard,
|
|
4648
|
+
maxReportBytes
|
|
4649
|
+
);
|
|
4650
|
+
} catch (fallbackError) {
|
|
4651
|
+
if (fallbackError instanceof McpFnRedactionLimitError) {
|
|
4652
|
+
return structuralReportFailure(retainedCleanup);
|
|
4653
|
+
}
|
|
4654
|
+
throw fallbackError;
|
|
4655
|
+
}
|
|
4656
|
+
}
|
|
4657
|
+
if (retainedCleanup) throw new McpFnTargetSuiteCleanupError(finalized, retainedCleanup);
|
|
4658
|
+
return finalized;
|
|
4659
|
+
}
|
|
4660
|
+
async function runTargetSuite(options) {
|
|
4661
|
+
const maxTimelineEvents = options.maxTimelineEvents ?? 500;
|
|
4662
|
+
if (!Number.isInteger(maxTimelineEvents) || maxTimelineEvents < 1) {
|
|
4663
|
+
throw new Error("maxTimelineEvents must be a positive integer");
|
|
4664
|
+
}
|
|
4665
|
+
const maxReportBytes = options.maxReportBytes ?? 1048576;
|
|
4666
|
+
validateReportCap(maxReportBytes);
|
|
4667
|
+
const consumerDiagnostic = options.client?.diagnostics;
|
|
4668
|
+
const diagnostics = new SuiteDiagnosticCollector(
|
|
4669
|
+
maxTimelineEvents,
|
|
4670
|
+
maxReportBytes,
|
|
4671
|
+
options.target,
|
|
4672
|
+
consumerDiagnostic
|
|
4673
|
+
);
|
|
4674
|
+
const connection = await connectSuite(options, diagnostics);
|
|
4675
|
+
const { projection, structureGuard, closeResult } = await projectAndCloseSuite(
|
|
4676
|
+
options,
|
|
4677
|
+
diagnostics,
|
|
4678
|
+
connection
|
|
4679
|
+
);
|
|
4680
|
+
const { cleanupFailure: closeFailure, retainedCleanup } = closeResult;
|
|
4681
|
+
diagnostics.captureUnobserved(connection.client);
|
|
4682
|
+
const lifecycleRedactionOmissions = Math.max(
|
|
4683
|
+
0,
|
|
4684
|
+
(connection.client?.session.getRedactionOmissionCounts().clientEvents ?? connection.initialClientEventRedactionOmissions) - connection.initialClientEventRedactionOmissions
|
|
4685
|
+
);
|
|
4686
|
+
let cleanupFailure = closeFailure;
|
|
4687
|
+
cleanupFailure ??= diagnostics.cleanupFailure;
|
|
4688
|
+
const failure = connection.failure ?? cleanupFailure;
|
|
4689
|
+
const report = buildSuiteReport(
|
|
4690
|
+
projection,
|
|
4691
|
+
connection.manifestChecked,
|
|
4692
|
+
failure,
|
|
4693
|
+
cleanupFailure,
|
|
4694
|
+
diagnostics,
|
|
4695
|
+
lifecycleRedactionOmissions
|
|
4696
|
+
);
|
|
4697
|
+
return finalizeSuiteReport(
|
|
4698
|
+
options,
|
|
4699
|
+
projection,
|
|
4700
|
+
report,
|
|
4701
|
+
structureGuard,
|
|
4702
|
+
maxReportBytes,
|
|
4703
|
+
retainedCleanup
|
|
4704
|
+
);
|
|
1900
4705
|
}
|
|
1901
4706
|
function enforceReportCap(report, maxBytes) {
|
|
1902
4707
|
validateReportCap(maxBytes);
|
|
@@ -1916,6 +4721,10 @@ function enforceReportCap(report, maxBytes) {
|
|
|
1916
4721
|
bounded.capabilities = void 0;
|
|
1917
4722
|
bounded.timeline = [];
|
|
1918
4723
|
bounded.droppedTimelineEvents += report.timeline.length;
|
|
4724
|
+
if (bounded.failure) {
|
|
4725
|
+
bounded.failure.details = void 0;
|
|
4726
|
+
bounded.failure.message = "Target operation failed; details omitted to fit report budget";
|
|
4727
|
+
}
|
|
1919
4728
|
}
|
|
1920
4729
|
if (bounded.droppedResults > 0 || jsonBytes(bounded) > maxBytes) {
|
|
1921
4730
|
bounded.ok = false;
|
|
@@ -1939,36 +4748,65 @@ export {
|
|
|
1939
4748
|
MCPFN_HOST_PROFILES,
|
|
1940
4749
|
MCPFN_NAMED_OAUTH_HOST_FIXTURES,
|
|
1941
4750
|
MCPFN_OAUTH_EXTENSION_GRANTS,
|
|
4751
|
+
MCPFN_REPORT_SCHEMA_VERSION,
|
|
4752
|
+
MCPFN_TESTING_VERSION,
|
|
1942
4753
|
McpFnAssertionError,
|
|
1943
4754
|
McpFnAuthAssertionError,
|
|
4755
|
+
McpFnClientProfileContractCleanupError,
|
|
4756
|
+
McpFnConformanceCleanupError,
|
|
1944
4757
|
McpFnMockOAuthAuthorizationServer,
|
|
4758
|
+
McpFnRedactionLimitError,
|
|
4759
|
+
McpFnStructuralCredentialCollisionError,
|
|
4760
|
+
McpFnTargetSuiteArtifactCleanupError,
|
|
4761
|
+
McpFnTargetSuiteCleanupError,
|
|
1945
4762
|
McpFnTestClient,
|
|
4763
|
+
McpFnTestClientCleanupError,
|
|
1946
4764
|
OFFICIAL_CONFORMANCE_VERSION,
|
|
4765
|
+
acquireRemoteCredential,
|
|
1947
4766
|
apiKeyCredential,
|
|
1948
4767
|
assertAuthRegressionSuite,
|
|
1949
4768
|
assertAuthorizationCodeClientMetadata,
|
|
1950
4769
|
assertBearerResourceChallenge,
|
|
1951
4770
|
assertManifestContract,
|
|
1952
4771
|
assertStructuredTextParity,
|
|
4772
|
+
assertTargetArtifactSafe,
|
|
4773
|
+
authenticatedHttpTarget,
|
|
1953
4774
|
bearerCredential,
|
|
4775
|
+
beginTargetCredentialRedaction,
|
|
1954
4776
|
buildOfficialConformanceArgs,
|
|
4777
|
+
buildOfficialConformanceEnvironment,
|
|
1955
4778
|
checkHostCompatibility,
|
|
1956
4779
|
createAuthenticatedConformanceProxy,
|
|
1957
4780
|
createFetchAuthTarget,
|
|
4781
|
+
createHostedAuthorizationFixtures,
|
|
4782
|
+
createMcpFnClientProfileSnapshot,
|
|
1958
4783
|
createMcpFnScenario,
|
|
1959
4784
|
createMcpFnScenarioArtifact,
|
|
1960
4785
|
createMcpFnScenarioReport,
|
|
4786
|
+
createMcpFnTargetSuiteJUnit,
|
|
1961
4787
|
createOAuthClientMetadataFixture,
|
|
1962
4788
|
createOAuthClientMetadataVariants,
|
|
1963
4789
|
createPkcePair,
|
|
4790
|
+
diffMcpFnClientProfileSnapshots,
|
|
4791
|
+
disposeMcpFnTargetSuiteReport,
|
|
4792
|
+
normalizeMcpFnReportFailure,
|
|
1964
4793
|
parseBearerChallenge,
|
|
4794
|
+
redactRemoteCredential,
|
|
4795
|
+
redactTargetCredentials,
|
|
1965
4796
|
runAuthRegressionSuite,
|
|
1966
4797
|
runAuthenticatedOfficialConformance,
|
|
4798
|
+
runHostedAuthorizationRegression,
|
|
4799
|
+
runMcpFnClientProfileContracts,
|
|
1967
4800
|
runMcpFnTargetSuite,
|
|
1968
4801
|
runOfficialConformance,
|
|
1969
4802
|
runScenarios,
|
|
4803
|
+
serializeMcpFnTargetSuiteReport,
|
|
1970
4804
|
stableJson,
|
|
1971
4805
|
startMockOAuthAuthorizationServer,
|
|
1972
|
-
|
|
4806
|
+
staticRemoteCredentialProvider,
|
|
4807
|
+
validateMcpFnClientProfileSnapshot,
|
|
4808
|
+
validateMcpFnScenarios,
|
|
4809
|
+
validateMcpFnSchemaPortability,
|
|
4810
|
+
validateRemoteCredentialHeaders
|
|
1973
4811
|
};
|
|
1974
4812
|
//# sourceMappingURL=index.js.map
|