@mcpfn/testing 0.0.1 → 0.0.2

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.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  MCPFN_HOST_PROFILES: () => MCPFN_HOST_PROFILES,
34
+ MCPFN_NAMED_OAUTH_HOST_FIXTURES: () => MCPFN_NAMED_OAUTH_HOST_FIXTURES,
34
35
  MCPFN_OAUTH_EXTENSION_GRANTS: () => MCPFN_OAUTH_EXTENSION_GRANTS,
35
36
  McpFnAssertionError: () => McpFnAssertionError,
36
37
  McpFnAuthAssertionError: () => McpFnAuthAssertionError,
@@ -46,16 +47,23 @@ __export(index_exports, {
46
47
  bearerCredential: () => bearerCredential,
47
48
  buildOfficialConformanceArgs: () => buildOfficialConformanceArgs,
48
49
  checkHostCompatibility: () => checkHostCompatibility,
50
+ createAuthenticatedConformanceProxy: () => createAuthenticatedConformanceProxy,
49
51
  createFetchAuthTarget: () => createFetchAuthTarget,
52
+ createMcpFnScenario: () => createMcpFnScenario,
53
+ createMcpFnScenarioArtifact: () => createMcpFnScenarioArtifact,
54
+ createMcpFnScenarioReport: () => createMcpFnScenarioReport,
50
55
  createOAuthClientMetadataFixture: () => createOAuthClientMetadataFixture,
51
56
  createOAuthClientMetadataVariants: () => createOAuthClientMetadataVariants,
52
57
  createPkcePair: () => createPkcePair,
53
58
  parseBearerChallenge: () => parseBearerChallenge,
54
59
  runAuthRegressionSuite: () => runAuthRegressionSuite,
60
+ runAuthenticatedOfficialConformance: () => runAuthenticatedOfficialConformance,
61
+ runMcpFnTargetSuite: () => runMcpFnTargetSuite,
55
62
  runOfficialConformance: () => runOfficialConformance,
56
63
  runScenarios: () => runScenarios,
57
64
  stableJson: () => stableJson,
58
- startMockOAuthAuthorizationServer: () => startMockOAuthAuthorizationServer
65
+ startMockOAuthAuthorizationServer: () => startMockOAuthAuthorizationServer,
66
+ validateMcpFnScenarios: () => validateMcpFnScenarios
59
67
  });
60
68
  module.exports = __toCommonJS(index_exports);
61
69
 
@@ -465,6 +473,28 @@ var MCPFN_OAUTH_EXTENSION_GRANTS = {
465
473
  deviceCode: "urn:ietf:params:oauth:grant-type:device_code",
466
474
  generic: "urn:example:params:oauth:grant-type:extension"
467
475
  };
476
+ var MCPFN_NAMED_OAUTH_HOST_FIXTURES = {
477
+ chatgpt: {
478
+ id: "chatgpt",
479
+ name: "ChatGPT connector",
480
+ redirectUris: ["https://chatgpt.com/connector_platform_oauth_redirect"],
481
+ responseTypes: ["code"],
482
+ grantTypes: ["authorization_code", "refresh_token"],
483
+ tokenEndpointAuthMethod: "none"
484
+ },
485
+ claude: {
486
+ id: "claude",
487
+ name: "Claude connector",
488
+ redirectUris: ["https://claude.example.com/oauth/callback"],
489
+ responseTypes: ["code"],
490
+ grantTypes: [
491
+ "authorization_code",
492
+ "refresh_token",
493
+ MCPFN_OAUTH_EXTENSION_GRANTS.jwtBearer
494
+ ],
495
+ tokenEndpointAuthMethod: "none"
496
+ }
497
+ };
468
498
  function createOAuthClientMetadataFixture(options) {
469
499
  return {
470
500
  ...options.extraMetadata,
@@ -514,15 +544,13 @@ function assertAuthorizationCodeClientMetadata(metadata, expectedClientId) {
514
544
  // src/mock-oauth-server.ts
515
545
  var import_node_crypto = require("crypto");
516
546
  var import_node_http = require("http");
517
- function base64Url(value) {
518
- return value.toString("base64url");
519
- }
547
+ var import_oauth_core = require("@superfunctions/oauth-core");
520
548
  function createPkcePair() {
521
- const verifier = base64Url((0, import_node_crypto.randomBytes)(48));
549
+ const pair = (0, import_oauth_core.generatePkcePair)(48);
522
550
  return {
523
- verifier,
524
- challenge: (0, import_node_crypto.createHash)("sha256").update(verifier).digest("base64url"),
525
- method: "S256"
551
+ verifier: pair.codeVerifier,
552
+ challenge: pair.codeChallenge,
553
+ method: pair.codeChallengeMethod
526
554
  };
527
555
  }
528
556
  function uniqueScopes(value) {
@@ -647,7 +675,8 @@ var McpFnMockOAuthAuthorizationServer = class {
647
675
  grant_types_supported: ["authorization_code", "refresh_token"],
648
676
  code_challenge_methods_supported: ["S256"],
649
677
  token_endpoint_auth_methods_supported: ["none"],
650
- scopes_supported: this.scopesSupported
678
+ scopes_supported: this.scopesSupported,
679
+ client_id_metadata_document_supported: true
651
680
  });
652
681
  }
653
682
  if (request.method === "GET" && path2.startsWith("/client-metadata/")) {
@@ -759,7 +788,7 @@ var McpFnMockOAuthAuthorizationServer = class {
759
788
  return oauthError(400, "invalid_grant", "Authorization code binding does not match");
760
789
  }
761
790
  const verifier = formValue(form, "code_verifier");
762
- const challenge = verifier ? (0, import_node_crypto.createHash)("sha256").update(verifier).digest("base64url") : void 0;
791
+ const challenge = verifier ? (0, import_oauth_core.derivePkceS256Challenge)(verifier) : void 0;
763
792
  if (challenge !== record.codeChallenge) {
764
793
  return oauthError(400, "invalid_grant", "PKCE verification failed");
765
794
  }
@@ -840,7 +869,9 @@ async function startMockOAuthAuthorizationServer(options = {}) {
840
869
  const server = (0, import_node_http.createServer)(async (req, res) => {
841
870
  try {
842
871
  if (!oauth) throw new Error("Mock OAuth server is not initialized");
843
- await writeResponse(res, await oauth.handle(await toRequest(req, origin)));
872
+ const request = await toRequest(req, origin);
873
+ const custom = await options.handle?.(request.clone(), oauth);
874
+ await writeResponse(res, custom ?? await oauth.handle(request));
844
875
  } catch {
845
876
  res.statusCode = 500;
846
877
  res.setHeader("content-type", "application/json");
@@ -870,120 +901,204 @@ async function startMockOAuthAuthorizationServer(options = {}) {
870
901
  }
871
902
 
872
903
  // src/client.ts
873
- var import_client = require("@modelcontextprotocol/sdk/client/index.js");
904
+ var import_client = require("@mcpfn/client");
874
905
  var import_inMemory = require("@modelcontextprotocol/sdk/inMemory.js");
875
- var import_types = require("@modelcontextprotocol/sdk/types.js");
876
906
  var McpFnTestClient = class _McpFnTestClient {
877
- client;
878
- server;
879
- closed = false;
880
- constructor(client, server) {
881
- this.client = client;
882
- this.server = server;
907
+ session;
908
+ constructor(session) {
909
+ this.session = session;
910
+ }
911
+ get client() {
912
+ return this.session.protocol;
883
913
  }
884
914
  static async connect(server, info = { name: "mcpfn-test-client", version: "1.0.0" }, options = {}) {
885
- const [clientTransport, serverTransport] = import_inMemory.InMemoryTransport.createLinkedPair();
886
- const client = new import_client.Client(info, { capabilities: options.capabilities ?? {} });
887
- await options.configure?.(client);
888
- await server.connect(serverTransport);
889
- await client.connect(clientTransport);
890
- return new _McpFnTestClient(client, server);
891
- }
892
- async listTools() {
893
- const values = [];
894
- let cursor;
895
- do {
896
- const page = await this.client.listTools(cursor ? { cursor } : void 0);
897
- values.push(...page.tools);
898
- cursor = page.nextCursor;
899
- } while (cursor);
900
- return values;
901
- }
902
- async callTool(name, args = {}) {
903
- return await this.client.callTool({ name, arguments: args });
904
- }
905
- async createToolTask(name, args = {}, options = {}) {
906
- return this.client.request(
907
- {
908
- method: "tools/call",
909
- params: { name, arguments: args, task: options }
910
- },
911
- import_types.CreateTaskResultSchema
912
- );
915
+ const serverName = server.info?.name ?? "mcpfn-server";
916
+ return this.connectTarget((0, import_client.customTarget)({
917
+ kind: "in-memory",
918
+ descriptor: { server: serverName },
919
+ open: async () => {
920
+ const [clientTransport, serverTransport] = import_inMemory.InMemoryTransport.createLinkedPair();
921
+ await server.connect(serverTransport);
922
+ return { transport: clientTransport, close: () => server.close() };
923
+ }
924
+ }), info, options);
925
+ }
926
+ static async connectTarget(target, info, options = {}) {
927
+ const session = (0, import_client.createMcpFnClient)({
928
+ target,
929
+ info: info ?? { name: "mcpfn-test-client", version: "1.0.0" },
930
+ capabilities: options.capabilities,
931
+ handlers: options.handlers,
932
+ events: options.events,
933
+ configure: options.configure,
934
+ diagnostics: options.diagnostics
935
+ });
936
+ try {
937
+ await session.connect();
938
+ return new _McpFnTestClient(session);
939
+ } catch (error) {
940
+ await session.close(true).catch(() => void 0);
941
+ throw error;
942
+ }
913
943
  }
914
- getTask(taskId) {
915
- return this.client.experimental.tasks.getTask(taskId);
944
+ listTools(options) {
945
+ return this.session.tools.listAll(options);
916
946
  }
917
- getTaskResult(taskId) {
918
- return this.client.experimental.tasks.getTaskResult(
919
- taskId,
920
- import_types.CallToolResultSchema
921
- );
947
+ callTool(name, args = {}, options) {
948
+ return this.session.tools.call(name, args, options);
949
+ }
950
+ createToolTask(name, args = {}, task = {}, options) {
951
+ return this.session.tools.createTask(name, args, task, options);
952
+ }
953
+ getTask(taskId, options) {
954
+ return this.session.tasks.get(taskId, options);
955
+ }
956
+ getTaskResult(taskId, options) {
957
+ return this.session.tasks.result(taskId, options);
958
+ }
959
+ listTasks(cursor, options) {
960
+ return this.session.tasks.list(cursor, options);
961
+ }
962
+ cancelTask(taskId, options) {
963
+ return this.session.tasks.cancel(taskId, options);
964
+ }
965
+ listResources(options) {
966
+ return this.session.resources.listAll(options);
967
+ }
968
+ listResourceTemplates(options) {
969
+ return this.session.resources.listTemplatesAll(options);
970
+ }
971
+ readResource(uri, options) {
972
+ return this.session.resources.read(uri, options);
973
+ }
974
+ subscribeResource(uri, options) {
975
+ return this.session.resources.subscribe(uri, options);
976
+ }
977
+ unsubscribeResource(uri, options) {
978
+ return this.session.resources.unsubscribe(uri, options);
979
+ }
980
+ listPrompts(options) {
981
+ return this.session.prompts.listAll(options);
982
+ }
983
+ getPrompt(name, args, options) {
984
+ return this.session.prompts.get(name, args, options);
985
+ }
986
+ complete(params, options) {
987
+ return this.session.prompts.complete(params, options);
922
988
  }
923
- listTasks(cursor) {
924
- return this.client.experimental.tasks.listTasks(cursor);
925
- }
926
- cancelTask(taskId) {
927
- return this.client.experimental.tasks.cancelTask(taskId);
928
- }
929
- async listResources() {
930
- const values = [];
931
- let cursor;
932
- do {
933
- const page = await this.client.listResources(cursor ? { cursor } : void 0);
934
- values.push(...page.resources);
935
- cursor = page.nextCursor;
936
- } while (cursor);
937
- return values;
938
- }
939
- async listResourceTemplates() {
940
- const values = [];
941
- let cursor;
942
- do {
943
- const page = await this.client.listResourceTemplates(cursor ? { cursor } : void 0);
944
- values.push(...page.resourceTemplates);
945
- cursor = page.nextCursor;
946
- } while (cursor);
947
- return values;
948
- }
949
- readResource(uri) {
950
- return this.client.readResource({ uri });
951
- }
952
- async subscribeResource(uri) {
953
- await this.client.subscribeResource({ uri });
954
- }
955
- async unsubscribeResource(uri) {
956
- await this.client.unsubscribeResource({ uri });
957
- }
958
- async listPrompts() {
959
- const values = [];
960
- let cursor;
961
- do {
962
- const page = await this.client.listPrompts(cursor ? { cursor } : void 0);
963
- values.push(...page.prompts);
964
- cursor = page.nextCursor;
965
- } while (cursor);
966
- return values;
967
- }
968
- getPrompt(name, args) {
969
- return this.client.getPrompt({ name, ...args ? { arguments: args } : {} });
970
- }
971
- complete(params) {
972
- return this.client.complete(params);
973
- }
974
- async close() {
975
- if (this.closed) return;
976
- this.closed = true;
977
- await this.client.close().catch(() => void 0);
978
- await this.server.close().catch(() => void 0);
989
+ close() {
990
+ return this.session.close();
979
991
  }
980
992
  };
981
993
 
982
994
  // src/conformance.ts
983
995
  var import_node_child_process = require("child_process");
984
996
  var import_node_fs = require("fs");
997
+ var import_node_http2 = require("http");
998
+ var import_node_https = require("https");
985
999
  var import_node_path = __toESM(require("path"), 1);
986
1000
  var OFFICIAL_CONFORMANCE_VERSION = "0.1.16";
1001
+ async function createAuthenticatedConformanceProxy(options) {
1002
+ const upstream = new URL(options.url);
1003
+ if (!["http:", "https:"].includes(upstream.protocol)) {
1004
+ throw new TypeError(
1005
+ "Authenticated conformance upstream must use HTTP or HTTPS"
1006
+ );
1007
+ }
1008
+ if (upstream.username || upstream.password || upstream.hash) {
1009
+ throw new TypeError(
1010
+ "Authenticated conformance upstream must not contain userinfo or a fragment"
1011
+ );
1012
+ }
1013
+ const hostname = normalizeLoopbackHostname(upstream.hostname);
1014
+ if (!hostname) {
1015
+ throw new TypeError(
1016
+ "Authenticated conformance upstream must use a literal loopback address"
1017
+ );
1018
+ }
1019
+ const protocol = upstream.protocol === "https:" ? "https:" : "http:";
1020
+ const port = upstream.port === "" ? void 0 : Number(upstream.port);
1021
+ const requestPath = `${upstream.pathname}${upstream.search}`;
1022
+ const injected = new Headers(options.headers);
1023
+ const activeRequests = /* @__PURE__ */ new Set();
1024
+ const activeSockets = /* @__PURE__ */ new Set();
1025
+ let proxyAuthority;
1026
+ const server = (0, import_node_http2.createServer)((incoming, outgoing) => {
1027
+ if (!incoming.url?.startsWith("/")) {
1028
+ outgoing.writeHead(400).end();
1029
+ return;
1030
+ }
1031
+ const headers = { ...incoming.headers };
1032
+ delete headers.connection;
1033
+ const usesProxyAuthority = incoming.headers.host === proxyAuthority;
1034
+ injected.forEach((value, name) => {
1035
+ const normalizedName = name.toLowerCase();
1036
+ delete headers[normalizedName];
1037
+ if (usesProxyAuthority) headers[normalizedName] = value;
1038
+ });
1039
+ if (usesProxyAuthority) headers.host = upstream.host;
1040
+ const transport = protocol === "https:" ? import_node_https.request : import_node_http2.request;
1041
+ const proxied = transport(
1042
+ {
1043
+ protocol,
1044
+ hostname,
1045
+ port,
1046
+ path: requestPath,
1047
+ method: incoming.method,
1048
+ headers
1049
+ },
1050
+ (response) => {
1051
+ outgoing.writeHead(response.statusCode ?? 502, response.headers);
1052
+ response.pipe(outgoing);
1053
+ }
1054
+ );
1055
+ activeRequests.add(proxied);
1056
+ proxied.once("close", () => activeRequests.delete(proxied));
1057
+ proxied.once("error", () => {
1058
+ if (!outgoing.headersSent) outgoing.writeHead(502);
1059
+ outgoing.end();
1060
+ });
1061
+ incoming.once("aborted", () => proxied.destroy());
1062
+ incoming.pipe(proxied);
1063
+ });
1064
+ server.on("connection", (socket) => {
1065
+ activeSockets.add(socket);
1066
+ socket.once("close", () => activeSockets.delete(socket));
1067
+ });
1068
+ await new Promise((resolve, reject) => {
1069
+ server.once("error", reject);
1070
+ server.listen(0, "127.0.0.1", () => {
1071
+ server.off("error", reject);
1072
+ resolve();
1073
+ });
1074
+ });
1075
+ const address = server.address();
1076
+ if (!address || typeof address === "string") {
1077
+ await new Promise((resolve) => server.close(() => resolve()));
1078
+ throw new Error("Authenticated conformance proxy did not bind a TCP port");
1079
+ }
1080
+ const url = new URL(
1081
+ upstream.pathname + upstream.search,
1082
+ `http://127.0.0.1:${address.port}`
1083
+ );
1084
+ proxyAuthority = url.host;
1085
+ return {
1086
+ url: url.toString(),
1087
+ close: async () => {
1088
+ for (const request of activeRequests) request.destroy();
1089
+ for (const socket of activeSockets) socket.destroy();
1090
+ if (!server.listening) return;
1091
+ await new Promise((resolve, reject) => {
1092
+ server.close((error) => error ? reject(error) : resolve());
1093
+ });
1094
+ }
1095
+ };
1096
+ }
1097
+ function normalizeLoopbackHostname(hostname) {
1098
+ if (hostname === "127.0.0.1") return "127.0.0.1";
1099
+ if (hostname === "[::1]") return "::1";
1100
+ return void 0;
1101
+ }
987
1102
  function npxInvocation(args) {
988
1103
  if (process.platform !== "win32") return { command: "npx", args };
989
1104
  const npmExecPath = process.env.npm_execpath;
@@ -1048,6 +1163,18 @@ async function runOfficialConformance(options) {
1048
1163
  });
1049
1164
  });
1050
1165
  }
1166
+ async function runAuthenticatedOfficialConformance(options) {
1167
+ const { headers, ...conformance } = options;
1168
+ const proxy = await createAuthenticatedConformanceProxy({
1169
+ url: conformance.url,
1170
+ headers
1171
+ });
1172
+ try {
1173
+ return await runOfficialConformance({ ...conformance, url: proxy.url });
1174
+ } finally {
1175
+ await proxy.close();
1176
+ }
1177
+ }
1051
1178
 
1052
1179
  // src/host-profiles.ts
1053
1180
  var import_core = require("@mcpfn/core");
@@ -1145,60 +1272,734 @@ function checkHostCompatibility(manifest, profile) {
1145
1272
  }
1146
1273
  }
1147
1274
  }
1148
- issues.sort((left, right) => left.path.localeCompare(right.path) || left.code.localeCompare(right.code));
1149
- const status = issues.some((issue) => issue.severity === "incompatible") ? "incompatible" : issues.length ? "degraded" : "compatible";
1275
+ issues.sort((left, right) => {
1276
+ if (left.path !== right.path) return left.path < right.path ? -1 : 1;
1277
+ if (left.code < right.code) return -1;
1278
+ if (left.code > right.code) return 1;
1279
+ return 0;
1280
+ });
1281
+ let status = "compatible";
1282
+ if (issues.some((issue) => issue.severity === "incompatible")) {
1283
+ status = "incompatible";
1284
+ } else if (issues.length > 0) {
1285
+ status = "degraded";
1286
+ }
1150
1287
  return { status, compatible: status !== "incompatible", issues };
1151
1288
  }
1152
1289
 
1153
1290
  // src/scenarios.ts
1154
- async function runScenarios(client, scenarios) {
1291
+ var import_oauth_core2 = require("@superfunctions/oauth-core");
1292
+ function createMcpFnScenario(name, operation, result) {
1293
+ switch (operation.kind) {
1294
+ case "tools.call":
1295
+ return createToolCallScenario(name, operation, result);
1296
+ case "tools.call:task":
1297
+ return createToolTaskScenario(name, operation);
1298
+ case "resources.read":
1299
+ return createResourceReadScenario(name, operation, result);
1300
+ case "resources.subscribe":
1301
+ case "resources.unsubscribe":
1302
+ return createResourceLifecycleScenario(name, operation);
1303
+ case "tasks.get":
1304
+ case "tasks.cancel":
1305
+ return createTaskLifecycleScenario(name, operation);
1306
+ case "tasks.result":
1307
+ return createTaskResultScenario(name, operation, result);
1308
+ case "tasks.list":
1309
+ return createTaskListScenario(name, operation);
1310
+ case "prompts.get":
1311
+ return createPromptScenario(name, operation, result);
1312
+ }
1313
+ }
1314
+ function scenarioBase(name, sideEffect) {
1315
+ return { formatVersion: 1, name, sideEffect };
1316
+ }
1317
+ function createToolCallScenario(name, operation, result) {
1318
+ return {
1319
+ ...scenarioBase(name, "non-idempotent"),
1320
+ kind: "tools.call",
1321
+ tool: operation.name,
1322
+ ...operation.arguments ? { arguments: operation.arguments } : {},
1323
+ expect: {
1324
+ isError: Boolean(result.isError),
1325
+ ...result.structuredContent !== void 0 ? { structuredContent: result.structuredContent } : {}
1326
+ }
1327
+ };
1328
+ }
1329
+ function createToolTaskScenario(name, operation) {
1330
+ return {
1331
+ ...scenarioBase(name, "non-idempotent"),
1332
+ kind: operation.kind,
1333
+ tool: operation.name,
1334
+ ...operation.arguments ? { arguments: operation.arguments } : {},
1335
+ ...operation.task ? { task: operation.task } : {}
1336
+ };
1337
+ }
1338
+ function createResourceReadScenario(name, operation, result) {
1339
+ return {
1340
+ ...scenarioBase(name, "none"),
1341
+ kind: operation.kind,
1342
+ uri: operation.uri,
1343
+ expect: result
1344
+ };
1345
+ }
1346
+ function createResourceLifecycleScenario(name, operation) {
1347
+ return {
1348
+ ...scenarioBase(name, "idempotent"),
1349
+ kind: operation.kind,
1350
+ uri: operation.uri
1351
+ };
1352
+ }
1353
+ function createTaskLifecycleScenario(name, operation) {
1354
+ return {
1355
+ ...scenarioBase(name, operation.kind === "tasks.cancel" ? "idempotent" : "none"),
1356
+ kind: operation.kind,
1357
+ taskId: operation.taskId
1358
+ };
1359
+ }
1360
+ function createTaskResultScenario(name, operation, result) {
1361
+ return {
1362
+ ...scenarioBase(name, "none"),
1363
+ kind: operation.kind,
1364
+ taskId: operation.taskId,
1365
+ expect: result
1366
+ };
1367
+ }
1368
+ function createTaskListScenario(name, operation) {
1369
+ return {
1370
+ ...scenarioBase(name, "none"),
1371
+ kind: operation.kind,
1372
+ ...operation.cursor ? { cursor: operation.cursor } : {}
1373
+ };
1374
+ }
1375
+ function createPromptScenario(name, operation, result) {
1376
+ return {
1377
+ ...scenarioBase(name, "none"),
1378
+ kind: operation.kind,
1379
+ prompt: operation.name,
1380
+ ...operation.arguments ? { arguments: operation.arguments } : {},
1381
+ expect: result
1382
+ };
1383
+ }
1384
+ function validateMcpFnScenarios(value) {
1385
+ const artifact = isScenarioArtifact(value) ? value : void 0;
1386
+ if (artifact?.status === "incomplete") {
1387
+ throw new Error(`McpFn scenario artifact is incomplete: ${artifact.incompleteReason}`);
1388
+ }
1389
+ const scenarios = artifact?.scenarios ?? value;
1390
+ if (!Array.isArray(scenarios)) {
1391
+ throw new TypeError("Scenario module must export an array or mcpfn.scenarios artifact");
1392
+ }
1393
+ for (const [index, scenario] of scenarios.entries()) {
1394
+ validateScenarioCandidate(scenario, index);
1395
+ }
1396
+ return scenarios;
1397
+ }
1398
+ var SCENARIO_KINDS_WITHOUT_REQUIRED_FIELDS = /* @__PURE__ */ new Set([
1399
+ "initialize",
1400
+ "tools.list",
1401
+ "resources.list",
1402
+ "resources.templates.list",
1403
+ "prompts.list",
1404
+ "tasks.list",
1405
+ "auth.assert"
1406
+ ]);
1407
+ function validateScenarioCandidate(scenario, index) {
1408
+ if (!scenario || typeof scenario !== "object" || Array.isArray(scenario)) {
1409
+ throw new TypeError(`Invalid scenario at index ${index}`);
1410
+ }
1411
+ const candidate = scenario;
1412
+ validateScenarioIdentity(candidate, index);
1413
+ const kind = scenarioKind(candidate, index);
1414
+ validateScenarioOperation(candidate, kind, index);
1415
+ validateScenarioLimits(candidate, index);
1416
+ if (kind === "events.expect") validateEventScenario(candidate, index);
1417
+ validateScenarioMetadata(candidate, index);
1418
+ if (kind === "auth.assert") validateAuthScenario(candidate, index);
1419
+ }
1420
+ function validateScenarioIdentity(candidate, index) {
1421
+ if (candidate.formatVersion !== void 0 && candidate.formatVersion !== 1) {
1422
+ throw new TypeError(`Invalid scenario at index ${index}: unsupported formatVersion`);
1423
+ }
1424
+ if (typeof candidate.name !== "string" || !candidate.name.trim()) {
1425
+ throw new TypeError(`Invalid scenario at index ${index}: name is required`);
1426
+ }
1427
+ }
1428
+ function scenarioKind(candidate, index) {
1429
+ const kind = candidate.kind ?? "tools.call";
1430
+ if (typeof kind !== "string") {
1431
+ throw new TypeError(`Invalid scenario at index ${index}: kind must be a string`);
1432
+ }
1433
+ return kind;
1434
+ }
1435
+ function validateScenarioOperation(candidate, kind, index) {
1436
+ const requiredField = requiredScenarioField(kind);
1437
+ if (requiredField && typeof candidate[requiredField] !== "string") {
1438
+ throw new TypeError(`Invalid scenario at index ${index}: ${kind} requires ${requiredField}`);
1439
+ }
1440
+ if (!requiredField && !SCENARIO_KINDS_WITHOUT_REQUIRED_FIELDS.has(kind)) {
1441
+ throw new TypeError(`Invalid scenario at index ${index}: unsupported kind ${kind}`);
1442
+ }
1443
+ }
1444
+ function validateScenarioLimits(candidate, index) {
1445
+ if (candidate.timeoutMs !== void 0 && (!Number.isInteger(candidate.timeoutMs) || Number(candidate.timeoutMs) < 1 || Number(candidate.timeoutMs) > 3e5)) {
1446
+ throw new TypeError(`Invalid scenario at index ${index}: timeoutMs must be 1..300000`);
1447
+ }
1448
+ if (candidate.variables !== void 0 && (!Array.isArray(candidate.variables) || candidate.variables.some((entry) => typeof entry !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(entry)))) {
1449
+ throw new TypeError(`Invalid scenario at index ${index}: variables must be environment names`);
1450
+ }
1451
+ }
1452
+ function validateScenarioMetadata(candidate, index) {
1453
+ if (candidate.status !== void 0 && candidate.status !== "complete" && candidate.status !== "incomplete") {
1454
+ throw new TypeError(`Invalid scenario at index ${index}: status must be complete or incomplete`);
1455
+ }
1456
+ if (candidate.status === "incomplete" && typeof candidate.incompleteReason !== "string") {
1457
+ throw new TypeError(`Invalid scenario at index ${index}: incompleteReason is required`);
1458
+ }
1459
+ if (candidate.sideEffect !== void 0 && (typeof candidate.sideEffect !== "string" || !["none", "idempotent", "non-idempotent"].includes(candidate.sideEffect))) {
1460
+ throw new TypeError(`Invalid scenario at index ${index}: sideEffect is unsupported`);
1461
+ }
1462
+ }
1463
+ function validateEventScenario(candidate, index) {
1464
+ if (candidate.minimum !== void 0 && (!Number.isSafeInteger(candidate.minimum) || Number(candidate.minimum) < 1)) {
1465
+ throw new TypeError(
1466
+ `Invalid scenario at index ${index}: events.expect minimum must be a positive integer`
1467
+ );
1468
+ }
1469
+ }
1470
+ function validateAuthScenario(candidate, index) {
1471
+ const expectation = candidate.expect;
1472
+ if (typeof candidate.phase !== "string" || !expectation || typeof expectation.outcome !== "string" || !["allowed", "denied"].includes(expectation.outcome)) {
1473
+ throw new TypeError(`Invalid scenario at index ${index}: auth.assert expectation is required`);
1474
+ }
1475
+ }
1476
+ function createMcpFnScenarioArtifact(scenarios, options) {
1477
+ const artifactOptions = options ?? { status: "complete" };
1478
+ return {
1479
+ formatVersion: 1,
1480
+ kind: "mcpfn.scenarios",
1481
+ ...artifactOptions,
1482
+ scenarios: validateMcpFnScenarios(scenarios)
1483
+ };
1484
+ }
1485
+ function isScenarioArtifact(value) {
1486
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1487
+ const candidate = value;
1488
+ if (candidate.kind !== "mcpfn.scenarios") return false;
1489
+ if (candidate.formatVersion !== 1) {
1490
+ throw new Error("Unsupported McpFn scenario artifact formatVersion");
1491
+ }
1492
+ if (candidate.status !== "complete" && candidate.status !== "incomplete") {
1493
+ throw new Error("McpFn scenario artifact status must be complete or incomplete");
1494
+ }
1495
+ if (candidate.status === "incomplete" && typeof candidate.incompleteReason !== "string") {
1496
+ throw new Error("Incomplete McpFn scenario artifact requires incompleteReason");
1497
+ }
1498
+ return true;
1499
+ }
1500
+ async function runScenarios(client, scenarios, options = {}) {
1501
+ if (options.defaultTimeoutMs !== void 0 && (!Number.isInteger(options.defaultTimeoutMs) || options.defaultTimeoutMs < 1 || options.defaultTimeoutMs > 3e5)) {
1502
+ throw new Error("defaultTimeoutMs must be an integer from 1 through 300000");
1503
+ }
1504
+ if (options.maxScenarios !== void 0 && (!Number.isInteger(options.maxScenarios) || options.maxScenarios < 1)) {
1505
+ throw new Error("maxScenarios must be a positive integer");
1506
+ }
1507
+ if (options.maxErrorBytes !== void 0 && (!Number.isInteger(options.maxErrorBytes) || options.maxErrorBytes < 64)) {
1508
+ throw new Error("maxErrorBytes must be an integer of at least 64");
1509
+ }
1510
+ if (options.maxObservedEvents !== void 0 && (!Number.isInteger(options.maxObservedEvents) || options.maxObservedEvents < 1)) {
1511
+ throw new Error("maxObservedEvents must be a positive integer");
1512
+ }
1513
+ if (scenarios.length > (options.maxScenarios ?? 1e3)) {
1514
+ throw new Error(`Scenario count exceeds the configured cap of ${options.maxScenarios ?? 1e3}`);
1515
+ }
1155
1516
  const results = [];
1156
- for (const scenario of scenarios) {
1157
- const startedAt = performance.now();
1158
- try {
1159
- const result = await client.callTool(scenario.tool, scenario.arguments);
1160
- if (result.isError && scenario.expect?.isError === void 0) {
1161
- throw new McpFnAssertionError(
1162
- "Tool returned isError=true without an explicit expect.isError=true assertion"
1163
- );
1517
+ const observedEvents = [];
1518
+ const maxObservedEvents = options.maxObservedEvents ?? 500;
1519
+ let droppedObservedEvents = 0;
1520
+ let attributedDroppedObservedEvents = 0;
1521
+ const pushResult = (result) => {
1522
+ const newlyDropped = droppedObservedEvents - attributedDroppedObservedEvents;
1523
+ results.push({
1524
+ ...result,
1525
+ ...newlyDropped > 0 ? { droppedObservedEvents: (result.droppedObservedEvents ?? 0) + newlyDropped } : {}
1526
+ });
1527
+ attributedDroppedObservedEvents = droppedObservedEvents;
1528
+ };
1529
+ const unsubscribe = client.session.onEvent((event) => {
1530
+ observedEvents.push(event);
1531
+ if (observedEvents.length > maxObservedEvents) {
1532
+ observedEvents.shift();
1533
+ droppedObservedEvents += 1;
1534
+ }
1535
+ });
1536
+ let timedOutScenario;
1537
+ try {
1538
+ for (const scenario of scenarios) {
1539
+ const startedAt = performance.now();
1540
+ const operation = scenario.kind ?? "tools.call";
1541
+ const common = {
1542
+ formatVersion: 1,
1543
+ name: scenario.name,
1544
+ operation,
1545
+ ...isToolScenario(scenario) ? { tool: scenario.tool } : {},
1546
+ sideEffect: scenario.sideEffect ?? defaultSideEffect(operation)
1547
+ };
1548
+ if (timedOutScenario) {
1549
+ pushResult({
1550
+ ...common,
1551
+ status: "incomplete",
1552
+ durationMs: 0,
1553
+ error: truncateError(`Skipped after timed-out scenario: ${timedOutScenario}`, options)
1554
+ });
1555
+ continue;
1164
1556
  }
1165
- if (scenario.expect?.isError !== void 0 && Boolean(result.isError) !== scenario.expect.isError) {
1166
- throw new McpFnAssertionError(
1167
- `Expected isError=${scenario.expect.isError}, received ${Boolean(result.isError)}`
1168
- );
1557
+ const resolved = resolveScenarioVariables(scenario, options.variables);
1558
+ if (resolved.missing.length > 0) {
1559
+ pushResult({
1560
+ ...common,
1561
+ status: "incomplete",
1562
+ durationMs: 0,
1563
+ error: truncateError(
1564
+ `Missing scenario variables: ${resolved.missing.join(", ")}`,
1565
+ options
1566
+ )
1567
+ });
1568
+ continue;
1169
1569
  }
1170
- if (scenario.expect && Object.prototype.hasOwnProperty.call(scenario.expect, "structuredContent") && stableJson(result.structuredContent) !== stableJson(scenario.expect.structuredContent)) {
1171
- throw new McpFnAssertionError(
1172
- `Structured result mismatch
1570
+ if (scenario.status === "incomplete") {
1571
+ pushResult({
1572
+ ...common,
1573
+ status: "incomplete",
1574
+ durationMs: 0,
1575
+ error: truncateError(scenario.incompleteReason ?? "Scenario is incomplete", options)
1576
+ });
1577
+ continue;
1578
+ }
1579
+ const timeoutMs = scenario.timeoutMs ?? options.defaultTimeoutMs ?? 3e4;
1580
+ const controller = new AbortController();
1581
+ let timer;
1582
+ let timedOut = false;
1583
+ try {
1584
+ await Promise.race([
1585
+ executeScenario(client, resolved.scenario, {
1586
+ signal: controller.signal,
1587
+ timeout: timeoutMs
1588
+ }, observedEvents, options),
1589
+ new Promise((_resolve, reject) => {
1590
+ timer = setTimeout(() => {
1591
+ timedOut = true;
1592
+ controller.abort();
1593
+ reject(new McpFnAssertionError(`Scenario timed out after ${timeoutMs}ms`));
1594
+ }, timeoutMs);
1595
+ })
1596
+ ]);
1597
+ pushResult({
1598
+ ...common,
1599
+ status: "passed",
1600
+ durationMs: performance.now() - startedAt
1601
+ });
1602
+ } catch (error) {
1603
+ pushResult({
1604
+ ...common,
1605
+ status: "failed",
1606
+ durationMs: performance.now() - startedAt,
1607
+ error: truncateError(error instanceof Error ? error.message : String(error), options)
1608
+ });
1609
+ if (timedOut) timedOutScenario = scenario.name;
1610
+ } finally {
1611
+ if (timer) clearTimeout(timer);
1612
+ }
1613
+ }
1614
+ } finally {
1615
+ const unattributed = droppedObservedEvents - attributedDroppedObservedEvents;
1616
+ const lastResult = results.at(-1);
1617
+ if (lastResult && unattributed > 0) {
1618
+ lastResult.droppedObservedEvents = (lastResult.droppedObservedEvents ?? 0) + unattributed;
1619
+ }
1620
+ unsubscribe();
1621
+ }
1622
+ return results;
1623
+ }
1624
+ function resolveScenarioVariables(scenario, supplied) {
1625
+ const values = { ...process.env, ...supplied };
1626
+ const required = new Set(scenario.variables ?? []);
1627
+ const marker = /\$\{([A-Z][A-Z0-9_]*)\}/g;
1628
+ const encodedMarker = /%24%7[Bb]([A-Z][A-Z0-9_]*)%7[Dd]/g;
1629
+ const visit = (value) => {
1630
+ if (typeof value === "string") {
1631
+ for (const match of value.matchAll(marker)) required.add(match[1]);
1632
+ for (const match of value.matchAll(encodedMarker)) required.add(match[1]);
1633
+ return value.replace(marker, (original, name) => values[name] ?? original).replace(
1634
+ encodedMarker,
1635
+ (original, name) => values[name] === void 0 ? original : encodeURIComponent(values[name])
1636
+ );
1637
+ }
1638
+ if (Array.isArray(value)) return value.map(visit);
1639
+ if (value && typeof value === "object") {
1640
+ return Object.fromEntries(
1641
+ Object.entries(value).map(([key, entry]) => [key, visit(entry)])
1642
+ );
1643
+ }
1644
+ return value;
1645
+ };
1646
+ const resolved = visit(scenario);
1647
+ return {
1648
+ scenario: resolved,
1649
+ missing: [...required].filter((name) => values[name] === void 0).sort(compareCodeUnits)
1650
+ };
1651
+ }
1652
+ function createMcpFnScenarioReport(results, options = {}) {
1653
+ const failed = results.filter((result) => result.status === "failed").length;
1654
+ const incomplete = results.filter((result) => result.status === "incomplete").length;
1655
+ const droppedObservedEvents = results.reduce(
1656
+ (total, result) => total + (result.droppedObservedEvents ?? 0),
1657
+ 0
1658
+ );
1659
+ const report = {
1660
+ formatVersion: 1,
1661
+ kind: "mcpfn.scenario-report",
1662
+ status: incomplete === 0 && droppedObservedEvents === 0 ? "complete" : "incomplete",
1663
+ ...options.manifestHash ? { manifestHash: options.manifestHash } : {},
1664
+ total: results.length,
1665
+ passed: results.length - failed - incomplete,
1666
+ failed,
1667
+ incomplete,
1668
+ droppedResults: 0,
1669
+ droppedObservedEvents,
1670
+ ...droppedObservedEvents > 0 ? { incompleteReason: "Observed client events exceeded maxObservedEvents" } : {},
1671
+ results: structuredClone(results)
1672
+ };
1673
+ const maxBytes = options.maxBytes ?? 1048576;
1674
+ if (!Number.isInteger(maxBytes) || maxBytes < 1024) {
1675
+ throw new Error("maxBytes must be an integer of at least 1024");
1676
+ }
1677
+ if (serializedBytes(report) > maxBytes) {
1678
+ report.status = "incomplete";
1679
+ report.incompleteReason = "Report content exceeded maxBytes and was truncated";
1680
+ }
1681
+ while (serializedBytes(report) > maxBytes && report.results.length > 0) {
1682
+ report.results.pop();
1683
+ report.droppedResults += 1;
1684
+ }
1685
+ if (report.droppedResults > 0) {
1686
+ report.status = "incomplete";
1687
+ }
1688
+ if (serializedBytes(report) > maxBytes) {
1689
+ throw new Error("The minimum scenario report exceeds maxBytes");
1690
+ }
1691
+ return report;
1692
+ }
1693
+ function serializedBytes(value) {
1694
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
1695
+ }
1696
+ async function executeScenario(client, scenario, requestOptions, observedEvents, runOptions) {
1697
+ const kind = scenario.kind ?? "tools.call";
1698
+ if (isInventoryScenario(scenario)) {
1699
+ await executeInventoryScenario(client, scenario, requestOptions);
1700
+ return;
1701
+ }
1702
+ switch (scenario.kind) {
1703
+ case void 0:
1704
+ case "tools.call":
1705
+ return executeToolScenario(client, scenario, requestOptions);
1706
+ case "initialize":
1707
+ return executeInitializeScenario(client, scenario);
1708
+ case "resources.read":
1709
+ return assertExpectedAsync(client.readResource(scenario.uri, requestOptions), scenario.expect);
1710
+ case "resources.subscribe":
1711
+ return client.subscribeResource(scenario.uri, requestOptions);
1712
+ case "resources.unsubscribe":
1713
+ return client.unsubscribeResource(scenario.uri, requestOptions);
1714
+ case "prompts.get":
1715
+ return assertExpectedAsync(
1716
+ client.getPrompt(scenario.prompt, scenario.arguments, requestOptions),
1717
+ scenario.expect
1718
+ );
1719
+ case "tools.call:task":
1720
+ return assertExpectedAsync(
1721
+ client.createToolTask(scenario.tool, scenario.arguments, scenario.task, requestOptions),
1722
+ scenario.expect
1723
+ );
1724
+ case "tasks.get":
1725
+ return assertExpectedAsync(client.getTask(scenario.taskId, requestOptions), scenario.expect);
1726
+ case "tasks.result":
1727
+ return assertExpectedAsync(client.getTaskResult(scenario.taskId, requestOptions), scenario.expect);
1728
+ case "tasks.cancel":
1729
+ return assertExpectedAsync(client.cancelTask(scenario.taskId, requestOptions), scenario.expect);
1730
+ case "tasks.list":
1731
+ return assertExpectedAsync(client.listTasks(scenario.cursor, requestOptions), scenario.expect);
1732
+ case "events.expect":
1733
+ return executeEventScenario(scenario, observedEvents);
1734
+ case "auth.assert":
1735
+ return executeAuthScenario(scenario, requestOptions.signal, runOptions);
1736
+ }
1737
+ throw new McpFnAssertionError(`Unsupported scenario operation ${kind}`);
1738
+ }
1739
+ async function executeToolScenario(client, scenario, requestOptions) {
1740
+ const result = await client.callTool(
1741
+ scenario.tool,
1742
+ scenario.arguments,
1743
+ requestOptions
1744
+ );
1745
+ assertToolResult(result, scenario);
1746
+ await scenario.verify?.(result);
1747
+ }
1748
+ function assertToolResult(result, scenario) {
1749
+ if (result.isError && scenario.expect?.isError === void 0) {
1750
+ throw new McpFnAssertionError(
1751
+ "Tool returned isError=true without an explicit expect.isError=true assertion"
1752
+ );
1753
+ }
1754
+ if (scenario.expect?.isError !== void 0 && Boolean(result.isError) !== scenario.expect.isError) {
1755
+ throw new McpFnAssertionError(
1756
+ `Expected isError=${scenario.expect.isError}, received ${Boolean(result.isError)}`
1757
+ );
1758
+ }
1759
+ if (scenario.expect && Object.hasOwn(scenario.expect, "structuredContent") && stableJson(result.structuredContent) !== stableJson(scenario.expect.structuredContent)) {
1760
+ throw new McpFnAssertionError(
1761
+ `Structured result mismatch
1173
1762
  expected: ${stableJson(scenario.expect.structuredContent)}
1174
1763
  actual: ${stableJson(result.structuredContent)}`
1175
- );
1764
+ );
1765
+ }
1766
+ if (scenario.expect?.structuredTextParity) assertStructuredTextParity(result);
1767
+ }
1768
+ function executeInitializeScenario(client, scenario) {
1769
+ const actual = client.client.getServerCapabilities() ?? {};
1770
+ if (scenario.expectCapabilities && stableJson(actual) !== stableJson(scenario.expectCapabilities)) {
1771
+ throw new McpFnAssertionError(
1772
+ `Capability mismatch
1773
+ expected: ${stableJson(scenario.expectCapabilities)}
1774
+ actual: ${stableJson(actual)}`
1775
+ );
1776
+ }
1777
+ }
1778
+ async function executeInventoryScenario(client, scenario, requestOptions) {
1779
+ const values = await listScenarioInventory(client, scenario.kind, requestOptions);
1780
+ if (!scenario.expectNames) return;
1781
+ const actual = values.map((value) => value.name).sort(compareCodeUnits);
1782
+ const expected = [...scenario.expectNames].sort(compareCodeUnits);
1783
+ if (stableJson(actual) !== stableJson(expected)) {
1784
+ throw new McpFnAssertionError(
1785
+ `Inventory mismatch
1786
+ expected: ${stableJson(expected)}
1787
+ actual: ${stableJson(actual)}`
1788
+ );
1789
+ }
1790
+ }
1791
+ async function assertExpectedAsync(actual, expected) {
1792
+ assertExpected(await actual, expected);
1793
+ }
1794
+ function executeEventScenario(scenario, observedEvents) {
1795
+ const matching = observedEvents.filter((event) => event.kind === scenario.event);
1796
+ if (matching.length < (scenario.minimum ?? 1)) {
1797
+ throw new McpFnAssertionError(
1798
+ `Expected at least ${scenario.minimum ?? 1} ${scenario.event} event(s), received ${matching.length}`
1799
+ );
1800
+ }
1801
+ if (scenario.expectPayload !== void 0) {
1802
+ assertExpected(matching.at(-1)?.payload, scenario.expectPayload);
1803
+ }
1804
+ }
1805
+ async function executeAuthScenario(scenario, signal, runOptions) {
1806
+ if (!runOptions.auth) {
1807
+ throw new McpFnAssertionError("auth.assert requires a scenario auth adapter");
1808
+ }
1809
+ assertExpected(await runOptions.auth(scenario, signal), scenario.expect);
1810
+ }
1811
+ function truncateError(value, options) {
1812
+ const redacted = String((0, import_oauth_core2.redactOAuthValue)(value));
1813
+ const maxBytes = options.maxErrorBytes ?? 4096;
1814
+ const bytes = new TextEncoder().encode(redacted);
1815
+ if (bytes.byteLength <= maxBytes) return redacted;
1816
+ return `${new TextDecoder().decode(bytes.slice(0, Math.max(0, maxBytes - 16)))}...[TRUNCATED]`;
1817
+ }
1818
+ function defaultSideEffect(operation) {
1819
+ if (operation === "tools.call" || operation === "tools.call:task") {
1820
+ return "non-idempotent";
1821
+ }
1822
+ if (operation === "tasks.cancel") return "idempotent";
1823
+ return "none";
1824
+ }
1825
+ function compareCodeUnits(left, right) {
1826
+ if (left < right) return -1;
1827
+ if (left > right) return 1;
1828
+ return 0;
1829
+ }
1830
+ function assertExpected(actual, expected) {
1831
+ if (expected !== void 0 && stableJson(actual) !== stableJson(expected)) {
1832
+ throw new McpFnAssertionError(
1833
+ `Scenario result mismatch
1834
+ expected: ${stableJson(expected)}
1835
+ actual: ${stableJson(actual)}`
1836
+ );
1837
+ }
1838
+ }
1839
+ function isToolScenario(scenario) {
1840
+ return "tool" in scenario;
1841
+ }
1842
+ function isInventoryScenario(scenario) {
1843
+ return ["tools.list", "resources.list", "resources.templates.list", "prompts.list"].includes(scenario.kind ?? "");
1844
+ }
1845
+ function requiredScenarioField(kind) {
1846
+ switch (kind) {
1847
+ case "tools.call":
1848
+ case "tools.call:task":
1849
+ return "tool";
1850
+ case "resources.read":
1851
+ case "resources.subscribe":
1852
+ case "resources.unsubscribe":
1853
+ return "uri";
1854
+ case "prompts.get":
1855
+ return "prompt";
1856
+ case "tasks.get":
1857
+ case "tasks.result":
1858
+ case "tasks.cancel":
1859
+ return "taskId";
1860
+ case "events.expect":
1861
+ return "event";
1862
+ default:
1863
+ return void 0;
1864
+ }
1865
+ }
1866
+ async function listScenarioInventory(client, kind, requestOptions) {
1867
+ switch (kind) {
1868
+ case "tools.list":
1869
+ return client.listTools(requestOptions);
1870
+ case "resources.list":
1871
+ return client.listResources(requestOptions);
1872
+ case "resources.templates.list":
1873
+ return client.listResourceTemplates(requestOptions);
1874
+ case "prompts.list":
1875
+ return client.listPrompts(requestOptions);
1876
+ }
1877
+ }
1878
+
1879
+ // src/suite.ts
1880
+ var import_oauth_core3 = require("@superfunctions/oauth-core");
1881
+ async function runMcpFnTargetSuite(options) {
1882
+ const timeline = [];
1883
+ let droppedTimelineEvents = 0;
1884
+ const maxTimelineEvents = options.maxTimelineEvents ?? 500;
1885
+ if (!Number.isInteger(maxTimelineEvents) || maxTimelineEvents < 1) {
1886
+ throw new Error("maxTimelineEvents must be a positive integer");
1887
+ }
1888
+ const maxReportBytes = options.maxReportBytes ?? 1048576;
1889
+ validateReportCap(maxReportBytes);
1890
+ const consumerDiagnostic = options.client?.diagnostics;
1891
+ const client = await McpFnTestClient.connectTarget(
1892
+ options.target,
1893
+ options.clientInfo ?? { name: "mcpfn-suite", version: "0.0.1" },
1894
+ {
1895
+ ...options.client,
1896
+ diagnostics: async (event) => {
1897
+ timeline.push((0, import_oauth_core3.redactOAuthValue)(event));
1898
+ if (timeline.length > maxTimelineEvents) {
1899
+ timeline.shift();
1900
+ droppedTimelineEvents += 1;
1901
+ }
1902
+ await consumerDiagnostic?.(event);
1176
1903
  }
1177
- if (scenario.expect?.structuredTextParity) {
1178
- assertStructuredTextParity(result);
1904
+ }
1905
+ );
1906
+ const execution = await (async () => {
1907
+ try {
1908
+ if (options.manifest) {
1909
+ await assertManifestContract(client, options.manifest, {
1910
+ expectedToolNames: options.expectedToolNames
1911
+ });
1179
1912
  }
1180
- await scenario.verify?.(result);
1181
- results.push({
1182
- name: scenario.name,
1183
- tool: scenario.tool,
1184
- status: "passed",
1185
- durationMs: performance.now() - startedAt
1186
- });
1187
- } catch (error) {
1188
- results.push({
1189
- name: scenario.name,
1190
- tool: scenario.tool,
1191
- status: "failed",
1192
- durationMs: performance.now() - startedAt,
1193
- error: error instanceof Error ? error.message : String(error)
1194
- });
1913
+ const results2 = await runScenarios(client, options.scenarios ?? [], options.scenarioRun);
1914
+ return {
1915
+ results: results2,
1916
+ server: client.session.getServerVersion(),
1917
+ capabilities: client.session.getServerCapabilities()
1918
+ };
1919
+ } finally {
1920
+ await client.close();
1195
1921
  }
1922
+ })();
1923
+ const results = execution.results;
1924
+ const failed = results.filter((result) => result.status === "failed").length;
1925
+ const incomplete = results.filter((result) => result.status === "incomplete").length;
1926
+ const droppedObservedEvents = results.reduce(
1927
+ (total, result) => total + (result.droppedObservedEvents ?? 0),
1928
+ 0
1929
+ );
1930
+ const artifactIncomplete = incomplete > 0 || droppedTimelineEvents > 0 || droppedObservedEvents > 0;
1931
+ const report = {
1932
+ formatVersion: 1,
1933
+ kind: "mcpfn.target-suite-report",
1934
+ status: artifactIncomplete ? "incomplete" : "complete",
1935
+ runtime: { node: process.version, scenarioFormatVersion: 1 },
1936
+ ok: failed === 0 && !artifactIncomplete,
1937
+ target: (0, import_oauth_core3.redactOAuthValue)(
1938
+ options.target.describe()
1939
+ ),
1940
+ server: execution.server,
1941
+ capabilities: execution.capabilities,
1942
+ manifestChecked: Boolean(options.manifest),
1943
+ ...options.manifest ? { manifestHash: options.manifest.hash } : {},
1944
+ total: results.length,
1945
+ passed: results.length - failed - incomplete,
1946
+ failed,
1947
+ incomplete,
1948
+ droppedResults: 0,
1949
+ droppedObservedEvents,
1950
+ ...droppedTimelineEvents > 0 || droppedObservedEvents > 0 ? {
1951
+ incompleteReason: [
1952
+ ...droppedTimelineEvents > 0 ? ["Diagnostic timeline exceeded maxTimelineEvents"] : [],
1953
+ ...droppedObservedEvents > 0 ? ["Observed client events exceeded maxObservedEvents"] : []
1954
+ ].join("; ")
1955
+ } : {},
1956
+ timeline,
1957
+ droppedTimelineEvents,
1958
+ results
1959
+ };
1960
+ return enforceReportCap(report, maxReportBytes);
1961
+ }
1962
+ function enforceReportCap(report, maxBytes) {
1963
+ validateReportCap(maxBytes);
1964
+ const bounded = structuredClone(report);
1965
+ if (jsonBytes(bounded) > maxBytes) {
1966
+ bounded.ok = false;
1967
+ bounded.status = "incomplete";
1968
+ bounded.incompleteReason = "Report content exceeded maxReportBytes and was truncated";
1969
+ }
1970
+ while (jsonBytes(bounded) > maxBytes && bounded.results.length > 0) {
1971
+ bounded.results.pop();
1972
+ bounded.droppedResults += 1;
1973
+ }
1974
+ if (jsonBytes(bounded) > maxBytes) {
1975
+ bounded.target = { kind: report.target.kind };
1976
+ bounded.server = void 0;
1977
+ bounded.capabilities = void 0;
1978
+ bounded.timeline = [];
1979
+ bounded.droppedTimelineEvents += report.timeline.length;
1980
+ }
1981
+ if (bounded.droppedResults > 0 || jsonBytes(bounded) > maxBytes) {
1982
+ bounded.ok = false;
1983
+ bounded.status = "incomplete";
1984
+ bounded.incompleteReason = "Report content exceeded maxReportBytes and was truncated";
1985
+ }
1986
+ if (jsonBytes(bounded) > maxBytes) {
1987
+ throw new Error("The minimum target suite report exceeds maxReportBytes");
1988
+ }
1989
+ return bounded;
1990
+ }
1991
+ function validateReportCap(maxBytes) {
1992
+ if (!Number.isInteger(maxBytes) || maxBytes < 1024) {
1993
+ throw new Error("maxReportBytes must be an integer of at least 1024");
1196
1994
  }
1197
- return results;
1995
+ }
1996
+ function jsonBytes(value) {
1997
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
1198
1998
  }
1199
1999
  // Annotate the CommonJS export names for ESM import in node:
1200
2000
  0 && (module.exports = {
1201
2001
  MCPFN_HOST_PROFILES,
2002
+ MCPFN_NAMED_OAUTH_HOST_FIXTURES,
1202
2003
  MCPFN_OAUTH_EXTENSION_GRANTS,
1203
2004
  McpFnAssertionError,
1204
2005
  McpFnAuthAssertionError,
@@ -1214,15 +2015,22 @@ actual: ${stableJson(result.structuredContent)}`
1214
2015
  bearerCredential,
1215
2016
  buildOfficialConformanceArgs,
1216
2017
  checkHostCompatibility,
2018
+ createAuthenticatedConformanceProxy,
1217
2019
  createFetchAuthTarget,
2020
+ createMcpFnScenario,
2021
+ createMcpFnScenarioArtifact,
2022
+ createMcpFnScenarioReport,
1218
2023
  createOAuthClientMetadataFixture,
1219
2024
  createOAuthClientMetadataVariants,
1220
2025
  createPkcePair,
1221
2026
  parseBearerChallenge,
1222
2027
  runAuthRegressionSuite,
2028
+ runAuthenticatedOfficialConformance,
2029
+ runMcpFnTargetSuite,
1223
2030
  runOfficialConformance,
1224
2031
  runScenarios,
1225
2032
  stableJson,
1226
- startMockOAuthAuthorizationServer
2033
+ startMockOAuthAuthorizationServer,
2034
+ validateMcpFnScenarios
1227
2035
  });
1228
2036
  //# sourceMappingURL=index.cjs.map