@mcpfn/testing 0.0.3 → 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/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 path2 = url.pathname.replace(this.issuer.pathname.replace(/\/$/, ""), "") || "/";
599
- if (request.method === "GET" && path2 === "/.well-known/oauth-authorization-server") {
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" && path2.startsWith("/client-metadata/")) {
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" && path2 === "/authorize") {
617
+ if (request.method === "GET" && path3 === "/authorize") {
618
618
  return this.authorize(url);
619
619
  }
620
- if (request.method === "POST" && path2 === "/token") {
620
+ if (request.method === "POST" && path3 === "/token") {
621
621
  return this.token(await request.text());
622
622
  }
623
- if (request.method === "POST" && path2 === "/revoke") {
623
+ if (request.method === "POST" && path3 === "/revoke") {
624
624
  return this.revoke(await request.text());
625
625
  }
626
- if (request.method === "GET" && path2 === "/callback") {
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
- static async connectTarget(target, info, options = {}) {
861
- const session = createMcpFnClient({
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 new _McpFnTestClient(session);
903
+ await client.session.connect();
904
+ return client;
873
905
  } catch (error) {
874
- await session.close(true).catch(() => void 0);
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
- return this.session.close();
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 path from "path";
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
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
- async function createAuthenticatedConformanceProxy(options) {
939
- const upstream = new URL(options.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 = new Headers(options.headers);
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: async () => {
1025
- for (const request of activeRequests) request.destroy();
1026
- for (const socket of activeSockets) socket.destroy();
1027
- if (!server.listening) return;
1028
- await new Promise((resolve, reject) => {
1029
- server.close((error) => error ? reject(error) : resolve());
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
- if (hostname === "127.0.0.1") return "127.0.0.1";
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 ? path.join(path.dirname(npmExecPath), "npx-cli.js") : void 0,
1044
- path.resolve(path.dirname(process.execPath), "node_modules/npm/bin/npx-cli.js"),
1045
- path.resolve(path.dirname(process.execPath), "../lib/node_modules/npm/bin/npx-cli.js")
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
- return await new Promise((resolve, reject) => {
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
- child.stdout?.on("data", (chunk) => {
1092
- stdout += chunk.toString();
1093
- });
1094
- child.stderr?.on("data", (chunk) => {
1095
- stderr += chunk.toString();
1096
- });
1097
- child.once("error", reject);
1098
- child.once("close", (code) => {
1099
- resolve({ exitCode: code ?? 1, stdout, stderr });
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
- const { headers, ...conformance } = options;
1105
- const proxy = await createAuthenticatedConformanceProxy({
1106
- url: conformance.url,
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
- return await runOfficialConformance({ ...conformance, url: proxy.url });
1111
- } finally {
1112
- await proxy.close();
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
- issues.sort((left, right) => {
1215
- if (left.path !== right.path) return left.path < right.path ? -1 : 1;
1216
- if (left.code < right.code) return -1;
1217
- if (left.code > right.code) return 1;
1218
- return 0;
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
- let status = "compatible";
1221
- if (issues.some((issue) => issue.severity === "incompatible")) {
1222
- status = "incompatible";
1223
- } else if (issues.length > 0) {
1224
- status = "degraded";
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
- return { status, compatible: status !== "incompatible", issues };
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
- return Object.fromEntries(
1580
- Object.entries(value).map(([key, entry]) => [key, visit(entry)])
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
- ...droppedObservedEvents > 0 ? { incompleteReason: "Observed client events exceeded maxObservedEvents" } : {},
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
- const redacted = String(redactOAuthValue(value));
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 bytes = new TextEncoder().encode(redacted);
1754
- if (bytes.byteLength <= maxBytes) return redacted;
1755
- return `${new TextDecoder().decode(bytes.slice(0, Math.max(0, maxBytes - 16)))}...[TRUNCATED]`;
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 { redactOAuthValue as redactOAuthValue2 } from "@superfunctions/oauth-core";
1820
- async function runMcpFnTargetSuite(options) {
1821
- const timeline = [];
1822
- let droppedTimelineEvents = 0;
1823
- const maxTimelineEvents = options.maxTimelineEvents ?? 500;
1824
- if (!Number.isInteger(maxTimelineEvents) || maxTimelineEvents < 1) {
1825
- throw new Error("maxTimelineEvents must be a positive integer");
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
- const maxReportBytes = options.maxReportBytes ?? 1048576;
1828
- validateReportCap(maxReportBytes);
1829
- const consumerDiagnostic = options.client?.diagnostics;
1830
- const client = await McpFnTestClient.connectTarget(
1831
- options.target,
1832
- options.clientInfo ?? { name: "mcpfn-suite", version: "0.0.1" },
1833
- {
1834
- ...options.client,
1835
- diagnostics: async (event) => {
1836
- timeline.push(redactOAuthValue2(event));
1837
- if (timeline.length > maxTimelineEvents) {
1838
- timeline.shift();
1839
- droppedTimelineEvents += 1;
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
- await consumerDiagnostic?.(event);
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
- const execution = await (async () => {
4078
+ throw error;
4079
+ } finally {
4080
+ finishRedaction();
4081
+ }
4082
+ }
4083
+ function beginSuiteArtifactRedaction(target) {
4084
+ let finish;
4085
+ if (target.beginRedactionScope) {
1846
4086
  try {
1847
- if (options.manifest) {
1848
- await assertManifestContract(client, options.manifest, {
1849
- expectedToolNames: options.expectedToolNames
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
- const results2 = await runScenarios(client, options.scenarios ?? [], options.scenarioRun);
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
- results: results2,
1855
- server: client.session.getServerVersion(),
1856
- capabilities: client.session.getServerCapabilities()
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
- } finally {
1859
- await client.close();
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
- const results = execution.results;
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 droppedObservedEvents = results.reduce(
4543
+ const scenarioDroppedObservedEvents = results.reduce(
1866
4544
  (total, result) => total + (result.droppedObservedEvents ?? 0),
1867
4545
  0
1868
4546
  );
1869
- const artifactIncomplete = incomplete > 0 || droppedTimelineEvents > 0 || droppedObservedEvents > 0;
1870
- const report = {
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: { node: process.version, scenarioFormatVersion: 1 },
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: redactOAuthValue2(
1877
- options.target.describe()
1878
- ),
1879
- server: execution.server,
1880
- capabilities: execution.capabilities,
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
- ...droppedTimelineEvents > 0 || droppedObservedEvents > 0 ? {
1890
- incompleteReason: [
1891
- ...droppedTimelineEvents > 0 ? ["Diagnostic timeline exceeded maxTimelineEvents"] : [],
1892
- ...droppedObservedEvents > 0 ? ["Observed client events exceeded maxObservedEvents"] : []
1893
- ].join("; ")
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
- return enforceReportCap(report, maxReportBytes);
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
- validateMcpFnScenarios
4806
+ staticRemoteCredentialProvider,
4807
+ validateMcpFnClientProfileSnapshot,
4808
+ validateMcpFnScenarios,
4809
+ validateMcpFnSchemaPortability,
4810
+ validateRemoteCredentialHeaders
1973
4811
  };
1974
4812
  //# sourceMappingURL=index.js.map