@mcpfn/testing 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/dist/auth.cjs +35 -10
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +30 -1
- package/dist/auth.d.ts +30 -1
- package/dist/auth.js +35 -11
- package/dist/auth.js.map +1 -1
- package/dist/index.cjs +956 -148
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +242 -26
- package/dist/index.d.ts +242 -26
- package/dist/index.js +954 -151
- package/dist/index.js.map +1 -1
- package/dist/playwright.cjs +33 -10
- package/dist/playwright.cjs.map +1 -1
- package/dist/playwright.js +34 -11
- package/dist/playwright.js.map +1 -1
- package/package.json +12 -13
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
|
-
|
|
518
|
-
return value.toString("base64url");
|
|
519
|
-
}
|
|
547
|
+
var import_oauth_core = require("@superfunctions/oauth-core");
|
|
520
548
|
function createPkcePair() {
|
|
521
|
-
const
|
|
549
|
+
const pair = (0, import_oauth_core.generatePkcePair)(48);
|
|
522
550
|
return {
|
|
523
|
-
verifier,
|
|
524
|
-
challenge:
|
|
525
|
-
method:
|
|
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,
|
|
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
|
-
|
|
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("@
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
this.
|
|
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
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
-
|
|
915
|
-
return this.
|
|
944
|
+
listTools(options) {
|
|
945
|
+
return this.session.tools.listAll(options);
|
|
916
946
|
}
|
|
917
|
-
|
|
918
|
-
return this.
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
924
|
-
return this.
|
|
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) =>
|
|
1149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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.
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
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
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
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
|