@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.js
CHANGED
|
@@ -404,6 +404,28 @@ var MCPFN_OAUTH_EXTENSION_GRANTS = {
|
|
|
404
404
|
deviceCode: "urn:ietf:params:oauth:grant-type:device_code",
|
|
405
405
|
generic: "urn:example:params:oauth:grant-type:extension"
|
|
406
406
|
};
|
|
407
|
+
var MCPFN_NAMED_OAUTH_HOST_FIXTURES = {
|
|
408
|
+
chatgpt: {
|
|
409
|
+
id: "chatgpt",
|
|
410
|
+
name: "ChatGPT connector",
|
|
411
|
+
redirectUris: ["https://chatgpt.com/connector_platform_oauth_redirect"],
|
|
412
|
+
responseTypes: ["code"],
|
|
413
|
+
grantTypes: ["authorization_code", "refresh_token"],
|
|
414
|
+
tokenEndpointAuthMethod: "none"
|
|
415
|
+
},
|
|
416
|
+
claude: {
|
|
417
|
+
id: "claude",
|
|
418
|
+
name: "Claude connector",
|
|
419
|
+
redirectUris: ["https://claude.example.com/oauth/callback"],
|
|
420
|
+
responseTypes: ["code"],
|
|
421
|
+
grantTypes: [
|
|
422
|
+
"authorization_code",
|
|
423
|
+
"refresh_token",
|
|
424
|
+
MCPFN_OAUTH_EXTENSION_GRANTS.jwtBearer
|
|
425
|
+
],
|
|
426
|
+
tokenEndpointAuthMethod: "none"
|
|
427
|
+
}
|
|
428
|
+
};
|
|
407
429
|
function createOAuthClientMetadataFixture(options) {
|
|
408
430
|
return {
|
|
409
431
|
...options.extraMetadata,
|
|
@@ -451,17 +473,15 @@ function assertAuthorizationCodeClientMetadata(metadata, expectedClientId) {
|
|
|
451
473
|
}
|
|
452
474
|
|
|
453
475
|
// src/mock-oauth-server.ts
|
|
454
|
-
import {
|
|
476
|
+
import { randomUUID } from "crypto";
|
|
455
477
|
import { createServer } from "http";
|
|
456
|
-
|
|
457
|
-
return value.toString("base64url");
|
|
458
|
-
}
|
|
478
|
+
import { derivePkceS256Challenge, generatePkcePair } from "@superfunctions/oauth-core";
|
|
459
479
|
function createPkcePair() {
|
|
460
|
-
const
|
|
480
|
+
const pair = generatePkcePair(48);
|
|
461
481
|
return {
|
|
462
|
-
verifier,
|
|
463
|
-
challenge:
|
|
464
|
-
method:
|
|
482
|
+
verifier: pair.codeVerifier,
|
|
483
|
+
challenge: pair.codeChallenge,
|
|
484
|
+
method: pair.codeChallengeMethod
|
|
465
485
|
};
|
|
466
486
|
}
|
|
467
487
|
function uniqueScopes(value) {
|
|
@@ -586,7 +606,8 @@ var McpFnMockOAuthAuthorizationServer = class {
|
|
|
586
606
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
587
607
|
code_challenge_methods_supported: ["S256"],
|
|
588
608
|
token_endpoint_auth_methods_supported: ["none"],
|
|
589
|
-
scopes_supported: this.scopesSupported
|
|
609
|
+
scopes_supported: this.scopesSupported,
|
|
610
|
+
client_id_metadata_document_supported: true
|
|
590
611
|
});
|
|
591
612
|
}
|
|
592
613
|
if (request.method === "GET" && path2.startsWith("/client-metadata/")) {
|
|
@@ -698,7 +719,7 @@ var McpFnMockOAuthAuthorizationServer = class {
|
|
|
698
719
|
return oauthError(400, "invalid_grant", "Authorization code binding does not match");
|
|
699
720
|
}
|
|
700
721
|
const verifier = formValue(form, "code_verifier");
|
|
701
|
-
const challenge = verifier ?
|
|
722
|
+
const challenge = verifier ? derivePkceS256Challenge(verifier) : void 0;
|
|
702
723
|
if (challenge !== record.codeChallenge) {
|
|
703
724
|
return oauthError(400, "invalid_grant", "PKCE verification failed");
|
|
704
725
|
}
|
|
@@ -779,7 +800,9 @@ async function startMockOAuthAuthorizationServer(options = {}) {
|
|
|
779
800
|
const server = createServer(async (req, res) => {
|
|
780
801
|
try {
|
|
781
802
|
if (!oauth) throw new Error("Mock OAuth server is not initialized");
|
|
782
|
-
|
|
803
|
+
const request = await toRequest(req, origin);
|
|
804
|
+
const custom = await options.handle?.(request.clone(), oauth);
|
|
805
|
+
await writeResponse(res, custom ?? await oauth.handle(request));
|
|
783
806
|
} catch {
|
|
784
807
|
res.statusCode = 500;
|
|
785
808
|
res.setHeader("content-type", "application/json");
|
|
@@ -809,123 +832,210 @@ async function startMockOAuthAuthorizationServer(options = {}) {
|
|
|
809
832
|
}
|
|
810
833
|
|
|
811
834
|
// src/client.ts
|
|
812
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
813
|
-
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
814
835
|
import {
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
} from "@
|
|
836
|
+
createMcpFnClient,
|
|
837
|
+
customTarget
|
|
838
|
+
} from "@mcpfn/client";
|
|
839
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
818
840
|
var McpFnTestClient = class _McpFnTestClient {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
this.
|
|
841
|
+
session;
|
|
842
|
+
constructor(session) {
|
|
843
|
+
this.session = session;
|
|
844
|
+
}
|
|
845
|
+
get client() {
|
|
846
|
+
return this.session.protocol;
|
|
825
847
|
}
|
|
826
848
|
static async connect(server, info = { name: "mcpfn-test-client", version: "1.0.0" }, options = {}) {
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
849
|
+
const serverName = server.info?.name ?? "mcpfn-server";
|
|
850
|
+
return this.connectTarget(customTarget({
|
|
851
|
+
kind: "in-memory",
|
|
852
|
+
descriptor: { server: serverName },
|
|
853
|
+
open: async () => {
|
|
854
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
855
|
+
await server.connect(serverTransport);
|
|
856
|
+
return { transport: clientTransport, close: () => server.close() };
|
|
857
|
+
}
|
|
858
|
+
}), info, options);
|
|
859
|
+
}
|
|
860
|
+
static async connectTarget(target, info, options = {}) {
|
|
861
|
+
const session = createMcpFnClient({
|
|
862
|
+
target,
|
|
863
|
+
info: info ?? { name: "mcpfn-test-client", version: "1.0.0" },
|
|
864
|
+
capabilities: options.capabilities,
|
|
865
|
+
handlers: options.handlers,
|
|
866
|
+
events: options.events,
|
|
867
|
+
configure: options.configure,
|
|
868
|
+
diagnostics: options.diagnostics
|
|
869
|
+
});
|
|
870
|
+
try {
|
|
871
|
+
await session.connect();
|
|
872
|
+
return new _McpFnTestClient(session);
|
|
873
|
+
} catch (error) {
|
|
874
|
+
await session.close(true).catch(() => void 0);
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
855
877
|
}
|
|
856
|
-
|
|
857
|
-
return this.
|
|
878
|
+
listTools(options) {
|
|
879
|
+
return this.session.tools.listAll(options);
|
|
858
880
|
}
|
|
859
|
-
|
|
860
|
-
return this.
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
);
|
|
881
|
+
callTool(name, args = {}, options) {
|
|
882
|
+
return this.session.tools.call(name, args, options);
|
|
883
|
+
}
|
|
884
|
+
createToolTask(name, args = {}, task = {}, options) {
|
|
885
|
+
return this.session.tools.createTask(name, args, task, options);
|
|
886
|
+
}
|
|
887
|
+
getTask(taskId, options) {
|
|
888
|
+
return this.session.tasks.get(taskId, options);
|
|
889
|
+
}
|
|
890
|
+
getTaskResult(taskId, options) {
|
|
891
|
+
return this.session.tasks.result(taskId, options);
|
|
892
|
+
}
|
|
893
|
+
listTasks(cursor, options) {
|
|
894
|
+
return this.session.tasks.list(cursor, options);
|
|
895
|
+
}
|
|
896
|
+
cancelTask(taskId, options) {
|
|
897
|
+
return this.session.tasks.cancel(taskId, options);
|
|
898
|
+
}
|
|
899
|
+
listResources(options) {
|
|
900
|
+
return this.session.resources.listAll(options);
|
|
901
|
+
}
|
|
902
|
+
listResourceTemplates(options) {
|
|
903
|
+
return this.session.resources.listTemplatesAll(options);
|
|
904
|
+
}
|
|
905
|
+
readResource(uri, options) {
|
|
906
|
+
return this.session.resources.read(uri, options);
|
|
907
|
+
}
|
|
908
|
+
subscribeResource(uri, options) {
|
|
909
|
+
return this.session.resources.subscribe(uri, options);
|
|
910
|
+
}
|
|
911
|
+
unsubscribeResource(uri, options) {
|
|
912
|
+
return this.session.resources.unsubscribe(uri, options);
|
|
913
|
+
}
|
|
914
|
+
listPrompts(options) {
|
|
915
|
+
return this.session.prompts.listAll(options);
|
|
916
|
+
}
|
|
917
|
+
getPrompt(name, args, options) {
|
|
918
|
+
return this.session.prompts.get(name, args, options);
|
|
864
919
|
}
|
|
865
|
-
|
|
866
|
-
return this.
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
return this.
|
|
870
|
-
}
|
|
871
|
-
async listResources() {
|
|
872
|
-
const values = [];
|
|
873
|
-
let cursor;
|
|
874
|
-
do {
|
|
875
|
-
const page = await this.client.listResources(cursor ? { cursor } : void 0);
|
|
876
|
-
values.push(...page.resources);
|
|
877
|
-
cursor = page.nextCursor;
|
|
878
|
-
} while (cursor);
|
|
879
|
-
return values;
|
|
880
|
-
}
|
|
881
|
-
async listResourceTemplates() {
|
|
882
|
-
const values = [];
|
|
883
|
-
let cursor;
|
|
884
|
-
do {
|
|
885
|
-
const page = await this.client.listResourceTemplates(cursor ? { cursor } : void 0);
|
|
886
|
-
values.push(...page.resourceTemplates);
|
|
887
|
-
cursor = page.nextCursor;
|
|
888
|
-
} while (cursor);
|
|
889
|
-
return values;
|
|
890
|
-
}
|
|
891
|
-
readResource(uri) {
|
|
892
|
-
return this.client.readResource({ uri });
|
|
893
|
-
}
|
|
894
|
-
async subscribeResource(uri) {
|
|
895
|
-
await this.client.subscribeResource({ uri });
|
|
896
|
-
}
|
|
897
|
-
async unsubscribeResource(uri) {
|
|
898
|
-
await this.client.unsubscribeResource({ uri });
|
|
899
|
-
}
|
|
900
|
-
async listPrompts() {
|
|
901
|
-
const values = [];
|
|
902
|
-
let cursor;
|
|
903
|
-
do {
|
|
904
|
-
const page = await this.client.listPrompts(cursor ? { cursor } : void 0);
|
|
905
|
-
values.push(...page.prompts);
|
|
906
|
-
cursor = page.nextCursor;
|
|
907
|
-
} while (cursor);
|
|
908
|
-
return values;
|
|
909
|
-
}
|
|
910
|
-
getPrompt(name, args) {
|
|
911
|
-
return this.client.getPrompt({ name, ...args ? { arguments: args } : {} });
|
|
912
|
-
}
|
|
913
|
-
complete(params) {
|
|
914
|
-
return this.client.complete(params);
|
|
915
|
-
}
|
|
916
|
-
async close() {
|
|
917
|
-
if (this.closed) return;
|
|
918
|
-
this.closed = true;
|
|
919
|
-
await this.client.close().catch(() => void 0);
|
|
920
|
-
await this.server.close().catch(() => void 0);
|
|
920
|
+
complete(params, options) {
|
|
921
|
+
return this.session.prompts.complete(params, options);
|
|
922
|
+
}
|
|
923
|
+
close() {
|
|
924
|
+
return this.session.close();
|
|
921
925
|
}
|
|
922
926
|
};
|
|
923
927
|
|
|
924
928
|
// src/conformance.ts
|
|
925
929
|
import { spawn } from "child_process";
|
|
926
930
|
import { existsSync } from "fs";
|
|
931
|
+
import {
|
|
932
|
+
createServer as createServer2,
|
|
933
|
+
request as httpRequest
|
|
934
|
+
} from "http";
|
|
935
|
+
import { request as httpsRequest } from "https";
|
|
927
936
|
import path from "path";
|
|
928
937
|
var OFFICIAL_CONFORMANCE_VERSION = "0.1.16";
|
|
938
|
+
async function createAuthenticatedConformanceProxy(options) {
|
|
939
|
+
const upstream = new URL(options.url);
|
|
940
|
+
if (!["http:", "https:"].includes(upstream.protocol)) {
|
|
941
|
+
throw new TypeError(
|
|
942
|
+
"Authenticated conformance upstream must use HTTP or HTTPS"
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
if (upstream.username || upstream.password || upstream.hash) {
|
|
946
|
+
throw new TypeError(
|
|
947
|
+
"Authenticated conformance upstream must not contain userinfo or a fragment"
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
const hostname = normalizeLoopbackHostname(upstream.hostname);
|
|
951
|
+
if (!hostname) {
|
|
952
|
+
throw new TypeError(
|
|
953
|
+
"Authenticated conformance upstream must use a literal loopback address"
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
const protocol = upstream.protocol === "https:" ? "https:" : "http:";
|
|
957
|
+
const port = upstream.port === "" ? void 0 : Number(upstream.port);
|
|
958
|
+
const requestPath = `${upstream.pathname}${upstream.search}`;
|
|
959
|
+
const injected = new Headers(options.headers);
|
|
960
|
+
const activeRequests = /* @__PURE__ */ new Set();
|
|
961
|
+
const activeSockets = /* @__PURE__ */ new Set();
|
|
962
|
+
let proxyAuthority;
|
|
963
|
+
const server = createServer2((incoming, outgoing) => {
|
|
964
|
+
if (!incoming.url?.startsWith("/")) {
|
|
965
|
+
outgoing.writeHead(400).end();
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const headers = { ...incoming.headers };
|
|
969
|
+
delete headers.connection;
|
|
970
|
+
const usesProxyAuthority = incoming.headers.host === proxyAuthority;
|
|
971
|
+
injected.forEach((value, name) => {
|
|
972
|
+
const normalizedName = name.toLowerCase();
|
|
973
|
+
delete headers[normalizedName];
|
|
974
|
+
if (usesProxyAuthority) headers[normalizedName] = value;
|
|
975
|
+
});
|
|
976
|
+
if (usesProxyAuthority) headers.host = upstream.host;
|
|
977
|
+
const transport = protocol === "https:" ? httpsRequest : httpRequest;
|
|
978
|
+
const proxied = transport(
|
|
979
|
+
{
|
|
980
|
+
protocol,
|
|
981
|
+
hostname,
|
|
982
|
+
port,
|
|
983
|
+
path: requestPath,
|
|
984
|
+
method: incoming.method,
|
|
985
|
+
headers
|
|
986
|
+
},
|
|
987
|
+
(response) => {
|
|
988
|
+
outgoing.writeHead(response.statusCode ?? 502, response.headers);
|
|
989
|
+
response.pipe(outgoing);
|
|
990
|
+
}
|
|
991
|
+
);
|
|
992
|
+
activeRequests.add(proxied);
|
|
993
|
+
proxied.once("close", () => activeRequests.delete(proxied));
|
|
994
|
+
proxied.once("error", () => {
|
|
995
|
+
if (!outgoing.headersSent) outgoing.writeHead(502);
|
|
996
|
+
outgoing.end();
|
|
997
|
+
});
|
|
998
|
+
incoming.once("aborted", () => proxied.destroy());
|
|
999
|
+
incoming.pipe(proxied);
|
|
1000
|
+
});
|
|
1001
|
+
server.on("connection", (socket) => {
|
|
1002
|
+
activeSockets.add(socket);
|
|
1003
|
+
socket.once("close", () => activeSockets.delete(socket));
|
|
1004
|
+
});
|
|
1005
|
+
await new Promise((resolve, reject) => {
|
|
1006
|
+
server.once("error", reject);
|
|
1007
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1008
|
+
server.off("error", reject);
|
|
1009
|
+
resolve();
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
const address = server.address();
|
|
1013
|
+
if (!address || typeof address === "string") {
|
|
1014
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1015
|
+
throw new Error("Authenticated conformance proxy did not bind a TCP port");
|
|
1016
|
+
}
|
|
1017
|
+
const url = new URL(
|
|
1018
|
+
upstream.pathname + upstream.search,
|
|
1019
|
+
`http://127.0.0.1:${address.port}`
|
|
1020
|
+
);
|
|
1021
|
+
proxyAuthority = url.host;
|
|
1022
|
+
return {
|
|
1023
|
+
url: url.toString(),
|
|
1024
|
+
close: async () => {
|
|
1025
|
+
for (const request of activeRequests) request.destroy();
|
|
1026
|
+
for (const socket of activeSockets) socket.destroy();
|
|
1027
|
+
if (!server.listening) return;
|
|
1028
|
+
await new Promise((resolve, reject) => {
|
|
1029
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
function normalizeLoopbackHostname(hostname) {
|
|
1035
|
+
if (hostname === "127.0.0.1") return "127.0.0.1";
|
|
1036
|
+
if (hostname === "[::1]") return "::1";
|
|
1037
|
+
return void 0;
|
|
1038
|
+
}
|
|
929
1039
|
function npxInvocation(args) {
|
|
930
1040
|
if (process.platform !== "win32") return { command: "npx", args };
|
|
931
1041
|
const npmExecPath = process.env.npm_execpath;
|
|
@@ -990,6 +1100,18 @@ async function runOfficialConformance(options) {
|
|
|
990
1100
|
});
|
|
991
1101
|
});
|
|
992
1102
|
}
|
|
1103
|
+
async function runAuthenticatedOfficialConformance(options) {
|
|
1104
|
+
const { headers, ...conformance } = options;
|
|
1105
|
+
const proxy = await createAuthenticatedConformanceProxy({
|
|
1106
|
+
url: conformance.url,
|
|
1107
|
+
headers
|
|
1108
|
+
});
|
|
1109
|
+
try {
|
|
1110
|
+
return await runOfficialConformance({ ...conformance, url: proxy.url });
|
|
1111
|
+
} finally {
|
|
1112
|
+
await proxy.close();
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
993
1115
|
|
|
994
1116
|
// src/host-profiles.ts
|
|
995
1117
|
import {
|
|
@@ -1089,59 +1211,733 @@ function checkHostCompatibility(manifest, profile) {
|
|
|
1089
1211
|
}
|
|
1090
1212
|
}
|
|
1091
1213
|
}
|
|
1092
|
-
issues.sort((left, right) =>
|
|
1093
|
-
|
|
1214
|
+
issues.sort((left, right) => {
|
|
1215
|
+
if (left.path !== right.path) return left.path < right.path ? -1 : 1;
|
|
1216
|
+
if (left.code < right.code) return -1;
|
|
1217
|
+
if (left.code > right.code) return 1;
|
|
1218
|
+
return 0;
|
|
1219
|
+
});
|
|
1220
|
+
let status = "compatible";
|
|
1221
|
+
if (issues.some((issue) => issue.severity === "incompatible")) {
|
|
1222
|
+
status = "incompatible";
|
|
1223
|
+
} else if (issues.length > 0) {
|
|
1224
|
+
status = "degraded";
|
|
1225
|
+
}
|
|
1094
1226
|
return { status, compatible: status !== "incompatible", issues };
|
|
1095
1227
|
}
|
|
1096
1228
|
|
|
1097
1229
|
// src/scenarios.ts
|
|
1098
|
-
|
|
1230
|
+
import { redactOAuthValue } from "@superfunctions/oauth-core";
|
|
1231
|
+
function createMcpFnScenario(name, operation, result) {
|
|
1232
|
+
switch (operation.kind) {
|
|
1233
|
+
case "tools.call":
|
|
1234
|
+
return createToolCallScenario(name, operation, result);
|
|
1235
|
+
case "tools.call:task":
|
|
1236
|
+
return createToolTaskScenario(name, operation);
|
|
1237
|
+
case "resources.read":
|
|
1238
|
+
return createResourceReadScenario(name, operation, result);
|
|
1239
|
+
case "resources.subscribe":
|
|
1240
|
+
case "resources.unsubscribe":
|
|
1241
|
+
return createResourceLifecycleScenario(name, operation);
|
|
1242
|
+
case "tasks.get":
|
|
1243
|
+
case "tasks.cancel":
|
|
1244
|
+
return createTaskLifecycleScenario(name, operation);
|
|
1245
|
+
case "tasks.result":
|
|
1246
|
+
return createTaskResultScenario(name, operation, result);
|
|
1247
|
+
case "tasks.list":
|
|
1248
|
+
return createTaskListScenario(name, operation);
|
|
1249
|
+
case "prompts.get":
|
|
1250
|
+
return createPromptScenario(name, operation, result);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
function scenarioBase(name, sideEffect) {
|
|
1254
|
+
return { formatVersion: 1, name, sideEffect };
|
|
1255
|
+
}
|
|
1256
|
+
function createToolCallScenario(name, operation, result) {
|
|
1257
|
+
return {
|
|
1258
|
+
...scenarioBase(name, "non-idempotent"),
|
|
1259
|
+
kind: "tools.call",
|
|
1260
|
+
tool: operation.name,
|
|
1261
|
+
...operation.arguments ? { arguments: operation.arguments } : {},
|
|
1262
|
+
expect: {
|
|
1263
|
+
isError: Boolean(result.isError),
|
|
1264
|
+
...result.structuredContent !== void 0 ? { structuredContent: result.structuredContent } : {}
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
function createToolTaskScenario(name, operation) {
|
|
1269
|
+
return {
|
|
1270
|
+
...scenarioBase(name, "non-idempotent"),
|
|
1271
|
+
kind: operation.kind,
|
|
1272
|
+
tool: operation.name,
|
|
1273
|
+
...operation.arguments ? { arguments: operation.arguments } : {},
|
|
1274
|
+
...operation.task ? { task: operation.task } : {}
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
function createResourceReadScenario(name, operation, result) {
|
|
1278
|
+
return {
|
|
1279
|
+
...scenarioBase(name, "none"),
|
|
1280
|
+
kind: operation.kind,
|
|
1281
|
+
uri: operation.uri,
|
|
1282
|
+
expect: result
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
function createResourceLifecycleScenario(name, operation) {
|
|
1286
|
+
return {
|
|
1287
|
+
...scenarioBase(name, "idempotent"),
|
|
1288
|
+
kind: operation.kind,
|
|
1289
|
+
uri: operation.uri
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
function createTaskLifecycleScenario(name, operation) {
|
|
1293
|
+
return {
|
|
1294
|
+
...scenarioBase(name, operation.kind === "tasks.cancel" ? "idempotent" : "none"),
|
|
1295
|
+
kind: operation.kind,
|
|
1296
|
+
taskId: operation.taskId
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
function createTaskResultScenario(name, operation, result) {
|
|
1300
|
+
return {
|
|
1301
|
+
...scenarioBase(name, "none"),
|
|
1302
|
+
kind: operation.kind,
|
|
1303
|
+
taskId: operation.taskId,
|
|
1304
|
+
expect: result
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
function createTaskListScenario(name, operation) {
|
|
1308
|
+
return {
|
|
1309
|
+
...scenarioBase(name, "none"),
|
|
1310
|
+
kind: operation.kind,
|
|
1311
|
+
...operation.cursor ? { cursor: operation.cursor } : {}
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
function createPromptScenario(name, operation, result) {
|
|
1315
|
+
return {
|
|
1316
|
+
...scenarioBase(name, "none"),
|
|
1317
|
+
kind: operation.kind,
|
|
1318
|
+
prompt: operation.name,
|
|
1319
|
+
...operation.arguments ? { arguments: operation.arguments } : {},
|
|
1320
|
+
expect: result
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
function validateMcpFnScenarios(value) {
|
|
1324
|
+
const artifact = isScenarioArtifact(value) ? value : void 0;
|
|
1325
|
+
if (artifact?.status === "incomplete") {
|
|
1326
|
+
throw new Error(`McpFn scenario artifact is incomplete: ${artifact.incompleteReason}`);
|
|
1327
|
+
}
|
|
1328
|
+
const scenarios = artifact?.scenarios ?? value;
|
|
1329
|
+
if (!Array.isArray(scenarios)) {
|
|
1330
|
+
throw new TypeError("Scenario module must export an array or mcpfn.scenarios artifact");
|
|
1331
|
+
}
|
|
1332
|
+
for (const [index, scenario] of scenarios.entries()) {
|
|
1333
|
+
validateScenarioCandidate(scenario, index);
|
|
1334
|
+
}
|
|
1335
|
+
return scenarios;
|
|
1336
|
+
}
|
|
1337
|
+
var SCENARIO_KINDS_WITHOUT_REQUIRED_FIELDS = /* @__PURE__ */ new Set([
|
|
1338
|
+
"initialize",
|
|
1339
|
+
"tools.list",
|
|
1340
|
+
"resources.list",
|
|
1341
|
+
"resources.templates.list",
|
|
1342
|
+
"prompts.list",
|
|
1343
|
+
"tasks.list",
|
|
1344
|
+
"auth.assert"
|
|
1345
|
+
]);
|
|
1346
|
+
function validateScenarioCandidate(scenario, index) {
|
|
1347
|
+
if (!scenario || typeof scenario !== "object" || Array.isArray(scenario)) {
|
|
1348
|
+
throw new TypeError(`Invalid scenario at index ${index}`);
|
|
1349
|
+
}
|
|
1350
|
+
const candidate = scenario;
|
|
1351
|
+
validateScenarioIdentity(candidate, index);
|
|
1352
|
+
const kind = scenarioKind(candidate, index);
|
|
1353
|
+
validateScenarioOperation(candidate, kind, index);
|
|
1354
|
+
validateScenarioLimits(candidate, index);
|
|
1355
|
+
if (kind === "events.expect") validateEventScenario(candidate, index);
|
|
1356
|
+
validateScenarioMetadata(candidate, index);
|
|
1357
|
+
if (kind === "auth.assert") validateAuthScenario(candidate, index);
|
|
1358
|
+
}
|
|
1359
|
+
function validateScenarioIdentity(candidate, index) {
|
|
1360
|
+
if (candidate.formatVersion !== void 0 && candidate.formatVersion !== 1) {
|
|
1361
|
+
throw new TypeError(`Invalid scenario at index ${index}: unsupported formatVersion`);
|
|
1362
|
+
}
|
|
1363
|
+
if (typeof candidate.name !== "string" || !candidate.name.trim()) {
|
|
1364
|
+
throw new TypeError(`Invalid scenario at index ${index}: name is required`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
function scenarioKind(candidate, index) {
|
|
1368
|
+
const kind = candidate.kind ?? "tools.call";
|
|
1369
|
+
if (typeof kind !== "string") {
|
|
1370
|
+
throw new TypeError(`Invalid scenario at index ${index}: kind must be a string`);
|
|
1371
|
+
}
|
|
1372
|
+
return kind;
|
|
1373
|
+
}
|
|
1374
|
+
function validateScenarioOperation(candidate, kind, index) {
|
|
1375
|
+
const requiredField = requiredScenarioField(kind);
|
|
1376
|
+
if (requiredField && typeof candidate[requiredField] !== "string") {
|
|
1377
|
+
throw new TypeError(`Invalid scenario at index ${index}: ${kind} requires ${requiredField}`);
|
|
1378
|
+
}
|
|
1379
|
+
if (!requiredField && !SCENARIO_KINDS_WITHOUT_REQUIRED_FIELDS.has(kind)) {
|
|
1380
|
+
throw new TypeError(`Invalid scenario at index ${index}: unsupported kind ${kind}`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
function validateScenarioLimits(candidate, index) {
|
|
1384
|
+
if (candidate.timeoutMs !== void 0 && (!Number.isInteger(candidate.timeoutMs) || Number(candidate.timeoutMs) < 1 || Number(candidate.timeoutMs) > 3e5)) {
|
|
1385
|
+
throw new TypeError(`Invalid scenario at index ${index}: timeoutMs must be 1..300000`);
|
|
1386
|
+
}
|
|
1387
|
+
if (candidate.variables !== void 0 && (!Array.isArray(candidate.variables) || candidate.variables.some((entry) => typeof entry !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(entry)))) {
|
|
1388
|
+
throw new TypeError(`Invalid scenario at index ${index}: variables must be environment names`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
function validateScenarioMetadata(candidate, index) {
|
|
1392
|
+
if (candidate.status !== void 0 && candidate.status !== "complete" && candidate.status !== "incomplete") {
|
|
1393
|
+
throw new TypeError(`Invalid scenario at index ${index}: status must be complete or incomplete`);
|
|
1394
|
+
}
|
|
1395
|
+
if (candidate.status === "incomplete" && typeof candidate.incompleteReason !== "string") {
|
|
1396
|
+
throw new TypeError(`Invalid scenario at index ${index}: incompleteReason is required`);
|
|
1397
|
+
}
|
|
1398
|
+
if (candidate.sideEffect !== void 0 && (typeof candidate.sideEffect !== "string" || !["none", "idempotent", "non-idempotent"].includes(candidate.sideEffect))) {
|
|
1399
|
+
throw new TypeError(`Invalid scenario at index ${index}: sideEffect is unsupported`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
function validateEventScenario(candidate, index) {
|
|
1403
|
+
if (candidate.minimum !== void 0 && (!Number.isSafeInteger(candidate.minimum) || Number(candidate.minimum) < 1)) {
|
|
1404
|
+
throw new TypeError(
|
|
1405
|
+
`Invalid scenario at index ${index}: events.expect minimum must be a positive integer`
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
function validateAuthScenario(candidate, index) {
|
|
1410
|
+
const expectation = candidate.expect;
|
|
1411
|
+
if (typeof candidate.phase !== "string" || !expectation || typeof expectation.outcome !== "string" || !["allowed", "denied"].includes(expectation.outcome)) {
|
|
1412
|
+
throw new TypeError(`Invalid scenario at index ${index}: auth.assert expectation is required`);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
function createMcpFnScenarioArtifact(scenarios, options) {
|
|
1416
|
+
const artifactOptions = options ?? { status: "complete" };
|
|
1417
|
+
return {
|
|
1418
|
+
formatVersion: 1,
|
|
1419
|
+
kind: "mcpfn.scenarios",
|
|
1420
|
+
...artifactOptions,
|
|
1421
|
+
scenarios: validateMcpFnScenarios(scenarios)
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
function isScenarioArtifact(value) {
|
|
1425
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1426
|
+
const candidate = value;
|
|
1427
|
+
if (candidate.kind !== "mcpfn.scenarios") return false;
|
|
1428
|
+
if (candidate.formatVersion !== 1) {
|
|
1429
|
+
throw new Error("Unsupported McpFn scenario artifact formatVersion");
|
|
1430
|
+
}
|
|
1431
|
+
if (candidate.status !== "complete" && candidate.status !== "incomplete") {
|
|
1432
|
+
throw new Error("McpFn scenario artifact status must be complete or incomplete");
|
|
1433
|
+
}
|
|
1434
|
+
if (candidate.status === "incomplete" && typeof candidate.incompleteReason !== "string") {
|
|
1435
|
+
throw new Error("Incomplete McpFn scenario artifact requires incompleteReason");
|
|
1436
|
+
}
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1439
|
+
async function runScenarios(client, scenarios, options = {}) {
|
|
1440
|
+
if (options.defaultTimeoutMs !== void 0 && (!Number.isInteger(options.defaultTimeoutMs) || options.defaultTimeoutMs < 1 || options.defaultTimeoutMs > 3e5)) {
|
|
1441
|
+
throw new Error("defaultTimeoutMs must be an integer from 1 through 300000");
|
|
1442
|
+
}
|
|
1443
|
+
if (options.maxScenarios !== void 0 && (!Number.isInteger(options.maxScenarios) || options.maxScenarios < 1)) {
|
|
1444
|
+
throw new Error("maxScenarios must be a positive integer");
|
|
1445
|
+
}
|
|
1446
|
+
if (options.maxErrorBytes !== void 0 && (!Number.isInteger(options.maxErrorBytes) || options.maxErrorBytes < 64)) {
|
|
1447
|
+
throw new Error("maxErrorBytes must be an integer of at least 64");
|
|
1448
|
+
}
|
|
1449
|
+
if (options.maxObservedEvents !== void 0 && (!Number.isInteger(options.maxObservedEvents) || options.maxObservedEvents < 1)) {
|
|
1450
|
+
throw new Error("maxObservedEvents must be a positive integer");
|
|
1451
|
+
}
|
|
1452
|
+
if (scenarios.length > (options.maxScenarios ?? 1e3)) {
|
|
1453
|
+
throw new Error(`Scenario count exceeds the configured cap of ${options.maxScenarios ?? 1e3}`);
|
|
1454
|
+
}
|
|
1099
1455
|
const results = [];
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1456
|
+
const observedEvents = [];
|
|
1457
|
+
const maxObservedEvents = options.maxObservedEvents ?? 500;
|
|
1458
|
+
let droppedObservedEvents = 0;
|
|
1459
|
+
let attributedDroppedObservedEvents = 0;
|
|
1460
|
+
const pushResult = (result) => {
|
|
1461
|
+
const newlyDropped = droppedObservedEvents - attributedDroppedObservedEvents;
|
|
1462
|
+
results.push({
|
|
1463
|
+
...result,
|
|
1464
|
+
...newlyDropped > 0 ? { droppedObservedEvents: (result.droppedObservedEvents ?? 0) + newlyDropped } : {}
|
|
1465
|
+
});
|
|
1466
|
+
attributedDroppedObservedEvents = droppedObservedEvents;
|
|
1467
|
+
};
|
|
1468
|
+
const unsubscribe = client.session.onEvent((event) => {
|
|
1469
|
+
observedEvents.push(event);
|
|
1470
|
+
if (observedEvents.length > maxObservedEvents) {
|
|
1471
|
+
observedEvents.shift();
|
|
1472
|
+
droppedObservedEvents += 1;
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
let timedOutScenario;
|
|
1476
|
+
try {
|
|
1477
|
+
for (const scenario of scenarios) {
|
|
1478
|
+
const startedAt = performance.now();
|
|
1479
|
+
const operation = scenario.kind ?? "tools.call";
|
|
1480
|
+
const common = {
|
|
1481
|
+
formatVersion: 1,
|
|
1482
|
+
name: scenario.name,
|
|
1483
|
+
operation,
|
|
1484
|
+
...isToolScenario(scenario) ? { tool: scenario.tool } : {},
|
|
1485
|
+
sideEffect: scenario.sideEffect ?? defaultSideEffect(operation)
|
|
1486
|
+
};
|
|
1487
|
+
if (timedOutScenario) {
|
|
1488
|
+
pushResult({
|
|
1489
|
+
...common,
|
|
1490
|
+
status: "incomplete",
|
|
1491
|
+
durationMs: 0,
|
|
1492
|
+
error: truncateError(`Skipped after timed-out scenario: ${timedOutScenario}`, options)
|
|
1493
|
+
});
|
|
1494
|
+
continue;
|
|
1108
1495
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1496
|
+
const resolved = resolveScenarioVariables(scenario, options.variables);
|
|
1497
|
+
if (resolved.missing.length > 0) {
|
|
1498
|
+
pushResult({
|
|
1499
|
+
...common,
|
|
1500
|
+
status: "incomplete",
|
|
1501
|
+
durationMs: 0,
|
|
1502
|
+
error: truncateError(
|
|
1503
|
+
`Missing scenario variables: ${resolved.missing.join(", ")}`,
|
|
1504
|
+
options
|
|
1505
|
+
)
|
|
1506
|
+
});
|
|
1507
|
+
continue;
|
|
1113
1508
|
}
|
|
1114
|
-
if (scenario.
|
|
1115
|
-
|
|
1116
|
-
|
|
1509
|
+
if (scenario.status === "incomplete") {
|
|
1510
|
+
pushResult({
|
|
1511
|
+
...common,
|
|
1512
|
+
status: "incomplete",
|
|
1513
|
+
durationMs: 0,
|
|
1514
|
+
error: truncateError(scenario.incompleteReason ?? "Scenario is incomplete", options)
|
|
1515
|
+
});
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
const timeoutMs = scenario.timeoutMs ?? options.defaultTimeoutMs ?? 3e4;
|
|
1519
|
+
const controller = new AbortController();
|
|
1520
|
+
let timer;
|
|
1521
|
+
let timedOut = false;
|
|
1522
|
+
try {
|
|
1523
|
+
await Promise.race([
|
|
1524
|
+
executeScenario(client, resolved.scenario, {
|
|
1525
|
+
signal: controller.signal,
|
|
1526
|
+
timeout: timeoutMs
|
|
1527
|
+
}, observedEvents, options),
|
|
1528
|
+
new Promise((_resolve, reject) => {
|
|
1529
|
+
timer = setTimeout(() => {
|
|
1530
|
+
timedOut = true;
|
|
1531
|
+
controller.abort();
|
|
1532
|
+
reject(new McpFnAssertionError(`Scenario timed out after ${timeoutMs}ms`));
|
|
1533
|
+
}, timeoutMs);
|
|
1534
|
+
})
|
|
1535
|
+
]);
|
|
1536
|
+
pushResult({
|
|
1537
|
+
...common,
|
|
1538
|
+
status: "passed",
|
|
1539
|
+
durationMs: performance.now() - startedAt
|
|
1540
|
+
});
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
pushResult({
|
|
1543
|
+
...common,
|
|
1544
|
+
status: "failed",
|
|
1545
|
+
durationMs: performance.now() - startedAt,
|
|
1546
|
+
error: truncateError(error instanceof Error ? error.message : String(error), options)
|
|
1547
|
+
});
|
|
1548
|
+
if (timedOut) timedOutScenario = scenario.name;
|
|
1549
|
+
} finally {
|
|
1550
|
+
if (timer) clearTimeout(timer);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
} finally {
|
|
1554
|
+
const unattributed = droppedObservedEvents - attributedDroppedObservedEvents;
|
|
1555
|
+
const lastResult = results.at(-1);
|
|
1556
|
+
if (lastResult && unattributed > 0) {
|
|
1557
|
+
lastResult.droppedObservedEvents = (lastResult.droppedObservedEvents ?? 0) + unattributed;
|
|
1558
|
+
}
|
|
1559
|
+
unsubscribe();
|
|
1560
|
+
}
|
|
1561
|
+
return results;
|
|
1562
|
+
}
|
|
1563
|
+
function resolveScenarioVariables(scenario, supplied) {
|
|
1564
|
+
const values = { ...process.env, ...supplied };
|
|
1565
|
+
const required = new Set(scenario.variables ?? []);
|
|
1566
|
+
const marker = /\$\{([A-Z][A-Z0-9_]*)\}/g;
|
|
1567
|
+
const encodedMarker = /%24%7[Bb]([A-Z][A-Z0-9_]*)%7[Dd]/g;
|
|
1568
|
+
const visit = (value) => {
|
|
1569
|
+
if (typeof value === "string") {
|
|
1570
|
+
for (const match of value.matchAll(marker)) required.add(match[1]);
|
|
1571
|
+
for (const match of value.matchAll(encodedMarker)) required.add(match[1]);
|
|
1572
|
+
return value.replace(marker, (original, name) => values[name] ?? original).replace(
|
|
1573
|
+
encodedMarker,
|
|
1574
|
+
(original, name) => values[name] === void 0 ? original : encodeURIComponent(values[name])
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
1578
|
+
if (value && typeof value === "object") {
|
|
1579
|
+
return Object.fromEntries(
|
|
1580
|
+
Object.entries(value).map(([key, entry]) => [key, visit(entry)])
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
return value;
|
|
1584
|
+
};
|
|
1585
|
+
const resolved = visit(scenario);
|
|
1586
|
+
return {
|
|
1587
|
+
scenario: resolved,
|
|
1588
|
+
missing: [...required].filter((name) => values[name] === void 0).sort(compareCodeUnits)
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
function createMcpFnScenarioReport(results, options = {}) {
|
|
1592
|
+
const failed = results.filter((result) => result.status === "failed").length;
|
|
1593
|
+
const incomplete = results.filter((result) => result.status === "incomplete").length;
|
|
1594
|
+
const droppedObservedEvents = results.reduce(
|
|
1595
|
+
(total, result) => total + (result.droppedObservedEvents ?? 0),
|
|
1596
|
+
0
|
|
1597
|
+
);
|
|
1598
|
+
const report = {
|
|
1599
|
+
formatVersion: 1,
|
|
1600
|
+
kind: "mcpfn.scenario-report",
|
|
1601
|
+
status: incomplete === 0 && droppedObservedEvents === 0 ? "complete" : "incomplete",
|
|
1602
|
+
...options.manifestHash ? { manifestHash: options.manifestHash } : {},
|
|
1603
|
+
total: results.length,
|
|
1604
|
+
passed: results.length - failed - incomplete,
|
|
1605
|
+
failed,
|
|
1606
|
+
incomplete,
|
|
1607
|
+
droppedResults: 0,
|
|
1608
|
+
droppedObservedEvents,
|
|
1609
|
+
...droppedObservedEvents > 0 ? { incompleteReason: "Observed client events exceeded maxObservedEvents" } : {},
|
|
1610
|
+
results: structuredClone(results)
|
|
1611
|
+
};
|
|
1612
|
+
const maxBytes = options.maxBytes ?? 1048576;
|
|
1613
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1024) {
|
|
1614
|
+
throw new Error("maxBytes must be an integer of at least 1024");
|
|
1615
|
+
}
|
|
1616
|
+
if (serializedBytes(report) > maxBytes) {
|
|
1617
|
+
report.status = "incomplete";
|
|
1618
|
+
report.incompleteReason = "Report content exceeded maxBytes and was truncated";
|
|
1619
|
+
}
|
|
1620
|
+
while (serializedBytes(report) > maxBytes && report.results.length > 0) {
|
|
1621
|
+
report.results.pop();
|
|
1622
|
+
report.droppedResults += 1;
|
|
1623
|
+
}
|
|
1624
|
+
if (report.droppedResults > 0) {
|
|
1625
|
+
report.status = "incomplete";
|
|
1626
|
+
}
|
|
1627
|
+
if (serializedBytes(report) > maxBytes) {
|
|
1628
|
+
throw new Error("The minimum scenario report exceeds maxBytes");
|
|
1629
|
+
}
|
|
1630
|
+
return report;
|
|
1631
|
+
}
|
|
1632
|
+
function serializedBytes(value) {
|
|
1633
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
1634
|
+
}
|
|
1635
|
+
async function executeScenario(client, scenario, requestOptions, observedEvents, runOptions) {
|
|
1636
|
+
const kind = scenario.kind ?? "tools.call";
|
|
1637
|
+
if (isInventoryScenario(scenario)) {
|
|
1638
|
+
await executeInventoryScenario(client, scenario, requestOptions);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
switch (scenario.kind) {
|
|
1642
|
+
case void 0:
|
|
1643
|
+
case "tools.call":
|
|
1644
|
+
return executeToolScenario(client, scenario, requestOptions);
|
|
1645
|
+
case "initialize":
|
|
1646
|
+
return executeInitializeScenario(client, scenario);
|
|
1647
|
+
case "resources.read":
|
|
1648
|
+
return assertExpectedAsync(client.readResource(scenario.uri, requestOptions), scenario.expect);
|
|
1649
|
+
case "resources.subscribe":
|
|
1650
|
+
return client.subscribeResource(scenario.uri, requestOptions);
|
|
1651
|
+
case "resources.unsubscribe":
|
|
1652
|
+
return client.unsubscribeResource(scenario.uri, requestOptions);
|
|
1653
|
+
case "prompts.get":
|
|
1654
|
+
return assertExpectedAsync(
|
|
1655
|
+
client.getPrompt(scenario.prompt, scenario.arguments, requestOptions),
|
|
1656
|
+
scenario.expect
|
|
1657
|
+
);
|
|
1658
|
+
case "tools.call:task":
|
|
1659
|
+
return assertExpectedAsync(
|
|
1660
|
+
client.createToolTask(scenario.tool, scenario.arguments, scenario.task, requestOptions),
|
|
1661
|
+
scenario.expect
|
|
1662
|
+
);
|
|
1663
|
+
case "tasks.get":
|
|
1664
|
+
return assertExpectedAsync(client.getTask(scenario.taskId, requestOptions), scenario.expect);
|
|
1665
|
+
case "tasks.result":
|
|
1666
|
+
return assertExpectedAsync(client.getTaskResult(scenario.taskId, requestOptions), scenario.expect);
|
|
1667
|
+
case "tasks.cancel":
|
|
1668
|
+
return assertExpectedAsync(client.cancelTask(scenario.taskId, requestOptions), scenario.expect);
|
|
1669
|
+
case "tasks.list":
|
|
1670
|
+
return assertExpectedAsync(client.listTasks(scenario.cursor, requestOptions), scenario.expect);
|
|
1671
|
+
case "events.expect":
|
|
1672
|
+
return executeEventScenario(scenario, observedEvents);
|
|
1673
|
+
case "auth.assert":
|
|
1674
|
+
return executeAuthScenario(scenario, requestOptions.signal, runOptions);
|
|
1675
|
+
}
|
|
1676
|
+
throw new McpFnAssertionError(`Unsupported scenario operation ${kind}`);
|
|
1677
|
+
}
|
|
1678
|
+
async function executeToolScenario(client, scenario, requestOptions) {
|
|
1679
|
+
const result = await client.callTool(
|
|
1680
|
+
scenario.tool,
|
|
1681
|
+
scenario.arguments,
|
|
1682
|
+
requestOptions
|
|
1683
|
+
);
|
|
1684
|
+
assertToolResult(result, scenario);
|
|
1685
|
+
await scenario.verify?.(result);
|
|
1686
|
+
}
|
|
1687
|
+
function assertToolResult(result, scenario) {
|
|
1688
|
+
if (result.isError && scenario.expect?.isError === void 0) {
|
|
1689
|
+
throw new McpFnAssertionError(
|
|
1690
|
+
"Tool returned isError=true without an explicit expect.isError=true assertion"
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
if (scenario.expect?.isError !== void 0 && Boolean(result.isError) !== scenario.expect.isError) {
|
|
1694
|
+
throw new McpFnAssertionError(
|
|
1695
|
+
`Expected isError=${scenario.expect.isError}, received ${Boolean(result.isError)}`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
if (scenario.expect && Object.hasOwn(scenario.expect, "structuredContent") && stableJson(result.structuredContent) !== stableJson(scenario.expect.structuredContent)) {
|
|
1699
|
+
throw new McpFnAssertionError(
|
|
1700
|
+
`Structured result mismatch
|
|
1117
1701
|
expected: ${stableJson(scenario.expect.structuredContent)}
|
|
1118
1702
|
actual: ${stableJson(result.structuredContent)}`
|
|
1119
|
-
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
if (scenario.expect?.structuredTextParity) assertStructuredTextParity(result);
|
|
1706
|
+
}
|
|
1707
|
+
function executeInitializeScenario(client, scenario) {
|
|
1708
|
+
const actual = client.client.getServerCapabilities() ?? {};
|
|
1709
|
+
if (scenario.expectCapabilities && stableJson(actual) !== stableJson(scenario.expectCapabilities)) {
|
|
1710
|
+
throw new McpFnAssertionError(
|
|
1711
|
+
`Capability mismatch
|
|
1712
|
+
expected: ${stableJson(scenario.expectCapabilities)}
|
|
1713
|
+
actual: ${stableJson(actual)}`
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
async function executeInventoryScenario(client, scenario, requestOptions) {
|
|
1718
|
+
const values = await listScenarioInventory(client, scenario.kind, requestOptions);
|
|
1719
|
+
if (!scenario.expectNames) return;
|
|
1720
|
+
const actual = values.map((value) => value.name).sort(compareCodeUnits);
|
|
1721
|
+
const expected = [...scenario.expectNames].sort(compareCodeUnits);
|
|
1722
|
+
if (stableJson(actual) !== stableJson(expected)) {
|
|
1723
|
+
throw new McpFnAssertionError(
|
|
1724
|
+
`Inventory mismatch
|
|
1725
|
+
expected: ${stableJson(expected)}
|
|
1726
|
+
actual: ${stableJson(actual)}`
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
async function assertExpectedAsync(actual, expected) {
|
|
1731
|
+
assertExpected(await actual, expected);
|
|
1732
|
+
}
|
|
1733
|
+
function executeEventScenario(scenario, observedEvents) {
|
|
1734
|
+
const matching = observedEvents.filter((event) => event.kind === scenario.event);
|
|
1735
|
+
if (matching.length < (scenario.minimum ?? 1)) {
|
|
1736
|
+
throw new McpFnAssertionError(
|
|
1737
|
+
`Expected at least ${scenario.minimum ?? 1} ${scenario.event} event(s), received ${matching.length}`
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
if (scenario.expectPayload !== void 0) {
|
|
1741
|
+
assertExpected(matching.at(-1)?.payload, scenario.expectPayload);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
async function executeAuthScenario(scenario, signal, runOptions) {
|
|
1745
|
+
if (!runOptions.auth) {
|
|
1746
|
+
throw new McpFnAssertionError("auth.assert requires a scenario auth adapter");
|
|
1747
|
+
}
|
|
1748
|
+
assertExpected(await runOptions.auth(scenario, signal), scenario.expect);
|
|
1749
|
+
}
|
|
1750
|
+
function truncateError(value, options) {
|
|
1751
|
+
const redacted = String(redactOAuthValue(value));
|
|
1752
|
+
const maxBytes = options.maxErrorBytes ?? 4096;
|
|
1753
|
+
const bytes = new TextEncoder().encode(redacted);
|
|
1754
|
+
if (bytes.byteLength <= maxBytes) return redacted;
|
|
1755
|
+
return `${new TextDecoder().decode(bytes.slice(0, Math.max(0, maxBytes - 16)))}...[TRUNCATED]`;
|
|
1756
|
+
}
|
|
1757
|
+
function defaultSideEffect(operation) {
|
|
1758
|
+
if (operation === "tools.call" || operation === "tools.call:task") {
|
|
1759
|
+
return "non-idempotent";
|
|
1760
|
+
}
|
|
1761
|
+
if (operation === "tasks.cancel") return "idempotent";
|
|
1762
|
+
return "none";
|
|
1763
|
+
}
|
|
1764
|
+
function compareCodeUnits(left, right) {
|
|
1765
|
+
if (left < right) return -1;
|
|
1766
|
+
if (left > right) return 1;
|
|
1767
|
+
return 0;
|
|
1768
|
+
}
|
|
1769
|
+
function assertExpected(actual, expected) {
|
|
1770
|
+
if (expected !== void 0 && stableJson(actual) !== stableJson(expected)) {
|
|
1771
|
+
throw new McpFnAssertionError(
|
|
1772
|
+
`Scenario result mismatch
|
|
1773
|
+
expected: ${stableJson(expected)}
|
|
1774
|
+
actual: ${stableJson(actual)}`
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
function isToolScenario(scenario) {
|
|
1779
|
+
return "tool" in scenario;
|
|
1780
|
+
}
|
|
1781
|
+
function isInventoryScenario(scenario) {
|
|
1782
|
+
return ["tools.list", "resources.list", "resources.templates.list", "prompts.list"].includes(scenario.kind ?? "");
|
|
1783
|
+
}
|
|
1784
|
+
function requiredScenarioField(kind) {
|
|
1785
|
+
switch (kind) {
|
|
1786
|
+
case "tools.call":
|
|
1787
|
+
case "tools.call:task":
|
|
1788
|
+
return "tool";
|
|
1789
|
+
case "resources.read":
|
|
1790
|
+
case "resources.subscribe":
|
|
1791
|
+
case "resources.unsubscribe":
|
|
1792
|
+
return "uri";
|
|
1793
|
+
case "prompts.get":
|
|
1794
|
+
return "prompt";
|
|
1795
|
+
case "tasks.get":
|
|
1796
|
+
case "tasks.result":
|
|
1797
|
+
case "tasks.cancel":
|
|
1798
|
+
return "taskId";
|
|
1799
|
+
case "events.expect":
|
|
1800
|
+
return "event";
|
|
1801
|
+
default:
|
|
1802
|
+
return void 0;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
async function listScenarioInventory(client, kind, requestOptions) {
|
|
1806
|
+
switch (kind) {
|
|
1807
|
+
case "tools.list":
|
|
1808
|
+
return client.listTools(requestOptions);
|
|
1809
|
+
case "resources.list":
|
|
1810
|
+
return client.listResources(requestOptions);
|
|
1811
|
+
case "resources.templates.list":
|
|
1812
|
+
return client.listResourceTemplates(requestOptions);
|
|
1813
|
+
case "prompts.list":
|
|
1814
|
+
return client.listPrompts(requestOptions);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// src/suite.ts
|
|
1819
|
+
import { redactOAuthValue as redactOAuthValue2 } from "@superfunctions/oauth-core";
|
|
1820
|
+
async function runMcpFnTargetSuite(options) {
|
|
1821
|
+
const timeline = [];
|
|
1822
|
+
let droppedTimelineEvents = 0;
|
|
1823
|
+
const maxTimelineEvents = options.maxTimelineEvents ?? 500;
|
|
1824
|
+
if (!Number.isInteger(maxTimelineEvents) || maxTimelineEvents < 1) {
|
|
1825
|
+
throw new Error("maxTimelineEvents must be a positive integer");
|
|
1826
|
+
}
|
|
1827
|
+
const maxReportBytes = options.maxReportBytes ?? 1048576;
|
|
1828
|
+
validateReportCap(maxReportBytes);
|
|
1829
|
+
const consumerDiagnostic = options.client?.diagnostics;
|
|
1830
|
+
const client = await McpFnTestClient.connectTarget(
|
|
1831
|
+
options.target,
|
|
1832
|
+
options.clientInfo ?? { name: "mcpfn-suite", version: "0.0.1" },
|
|
1833
|
+
{
|
|
1834
|
+
...options.client,
|
|
1835
|
+
diagnostics: async (event) => {
|
|
1836
|
+
timeline.push(redactOAuthValue2(event));
|
|
1837
|
+
if (timeline.length > maxTimelineEvents) {
|
|
1838
|
+
timeline.shift();
|
|
1839
|
+
droppedTimelineEvents += 1;
|
|
1840
|
+
}
|
|
1841
|
+
await consumerDiagnostic?.(event);
|
|
1120
1842
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1843
|
+
}
|
|
1844
|
+
);
|
|
1845
|
+
const execution = await (async () => {
|
|
1846
|
+
try {
|
|
1847
|
+
if (options.manifest) {
|
|
1848
|
+
await assertManifestContract(client, options.manifest, {
|
|
1849
|
+
expectedToolNames: options.expectedToolNames
|
|
1850
|
+
});
|
|
1123
1851
|
}
|
|
1124
|
-
await
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
results.push({
|
|
1133
|
-
name: scenario.name,
|
|
1134
|
-
tool: scenario.tool,
|
|
1135
|
-
status: "failed",
|
|
1136
|
-
durationMs: performance.now() - startedAt,
|
|
1137
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1138
|
-
});
|
|
1852
|
+
const results2 = await runScenarios(client, options.scenarios ?? [], options.scenarioRun);
|
|
1853
|
+
return {
|
|
1854
|
+
results: results2,
|
|
1855
|
+
server: client.session.getServerVersion(),
|
|
1856
|
+
capabilities: client.session.getServerCapabilities()
|
|
1857
|
+
};
|
|
1858
|
+
} finally {
|
|
1859
|
+
await client.close();
|
|
1139
1860
|
}
|
|
1861
|
+
})();
|
|
1862
|
+
const results = execution.results;
|
|
1863
|
+
const failed = results.filter((result) => result.status === "failed").length;
|
|
1864
|
+
const incomplete = results.filter((result) => result.status === "incomplete").length;
|
|
1865
|
+
const droppedObservedEvents = results.reduce(
|
|
1866
|
+
(total, result) => total + (result.droppedObservedEvents ?? 0),
|
|
1867
|
+
0
|
|
1868
|
+
);
|
|
1869
|
+
const artifactIncomplete = incomplete > 0 || droppedTimelineEvents > 0 || droppedObservedEvents > 0;
|
|
1870
|
+
const report = {
|
|
1871
|
+
formatVersion: 1,
|
|
1872
|
+
kind: "mcpfn.target-suite-report",
|
|
1873
|
+
status: artifactIncomplete ? "incomplete" : "complete",
|
|
1874
|
+
runtime: { node: process.version, scenarioFormatVersion: 1 },
|
|
1875
|
+
ok: failed === 0 && !artifactIncomplete,
|
|
1876
|
+
target: redactOAuthValue2(
|
|
1877
|
+
options.target.describe()
|
|
1878
|
+
),
|
|
1879
|
+
server: execution.server,
|
|
1880
|
+
capabilities: execution.capabilities,
|
|
1881
|
+
manifestChecked: Boolean(options.manifest),
|
|
1882
|
+
...options.manifest ? { manifestHash: options.manifest.hash } : {},
|
|
1883
|
+
total: results.length,
|
|
1884
|
+
passed: results.length - failed - incomplete,
|
|
1885
|
+
failed,
|
|
1886
|
+
incomplete,
|
|
1887
|
+
droppedResults: 0,
|
|
1888
|
+
droppedObservedEvents,
|
|
1889
|
+
...droppedTimelineEvents > 0 || droppedObservedEvents > 0 ? {
|
|
1890
|
+
incompleteReason: [
|
|
1891
|
+
...droppedTimelineEvents > 0 ? ["Diagnostic timeline exceeded maxTimelineEvents"] : [],
|
|
1892
|
+
...droppedObservedEvents > 0 ? ["Observed client events exceeded maxObservedEvents"] : []
|
|
1893
|
+
].join("; ")
|
|
1894
|
+
} : {},
|
|
1895
|
+
timeline,
|
|
1896
|
+
droppedTimelineEvents,
|
|
1897
|
+
results
|
|
1898
|
+
};
|
|
1899
|
+
return enforceReportCap(report, maxReportBytes);
|
|
1900
|
+
}
|
|
1901
|
+
function enforceReportCap(report, maxBytes) {
|
|
1902
|
+
validateReportCap(maxBytes);
|
|
1903
|
+
const bounded = structuredClone(report);
|
|
1904
|
+
if (jsonBytes(bounded) > maxBytes) {
|
|
1905
|
+
bounded.ok = false;
|
|
1906
|
+
bounded.status = "incomplete";
|
|
1907
|
+
bounded.incompleteReason = "Report content exceeded maxReportBytes and was truncated";
|
|
1908
|
+
}
|
|
1909
|
+
while (jsonBytes(bounded) > maxBytes && bounded.results.length > 0) {
|
|
1910
|
+
bounded.results.pop();
|
|
1911
|
+
bounded.droppedResults += 1;
|
|
1912
|
+
}
|
|
1913
|
+
if (jsonBytes(bounded) > maxBytes) {
|
|
1914
|
+
bounded.target = { kind: report.target.kind };
|
|
1915
|
+
bounded.server = void 0;
|
|
1916
|
+
bounded.capabilities = void 0;
|
|
1917
|
+
bounded.timeline = [];
|
|
1918
|
+
bounded.droppedTimelineEvents += report.timeline.length;
|
|
1919
|
+
}
|
|
1920
|
+
if (bounded.droppedResults > 0 || jsonBytes(bounded) > maxBytes) {
|
|
1921
|
+
bounded.ok = false;
|
|
1922
|
+
bounded.status = "incomplete";
|
|
1923
|
+
bounded.incompleteReason = "Report content exceeded maxReportBytes and was truncated";
|
|
1924
|
+
}
|
|
1925
|
+
if (jsonBytes(bounded) > maxBytes) {
|
|
1926
|
+
throw new Error("The minimum target suite report exceeds maxReportBytes");
|
|
1927
|
+
}
|
|
1928
|
+
return bounded;
|
|
1929
|
+
}
|
|
1930
|
+
function validateReportCap(maxBytes) {
|
|
1931
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1024) {
|
|
1932
|
+
throw new Error("maxReportBytes must be an integer of at least 1024");
|
|
1140
1933
|
}
|
|
1141
|
-
|
|
1934
|
+
}
|
|
1935
|
+
function jsonBytes(value) {
|
|
1936
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
1142
1937
|
}
|
|
1143
1938
|
export {
|
|
1144
1939
|
MCPFN_HOST_PROFILES,
|
|
1940
|
+
MCPFN_NAMED_OAUTH_HOST_FIXTURES,
|
|
1145
1941
|
MCPFN_OAUTH_EXTENSION_GRANTS,
|
|
1146
1942
|
McpFnAssertionError,
|
|
1147
1943
|
McpFnAuthAssertionError,
|
|
@@ -1157,15 +1953,22 @@ export {
|
|
|
1157
1953
|
bearerCredential,
|
|
1158
1954
|
buildOfficialConformanceArgs,
|
|
1159
1955
|
checkHostCompatibility,
|
|
1956
|
+
createAuthenticatedConformanceProxy,
|
|
1160
1957
|
createFetchAuthTarget,
|
|
1958
|
+
createMcpFnScenario,
|
|
1959
|
+
createMcpFnScenarioArtifact,
|
|
1960
|
+
createMcpFnScenarioReport,
|
|
1161
1961
|
createOAuthClientMetadataFixture,
|
|
1162
1962
|
createOAuthClientMetadataVariants,
|
|
1163
1963
|
createPkcePair,
|
|
1164
1964
|
parseBearerChallenge,
|
|
1165
1965
|
runAuthRegressionSuite,
|
|
1966
|
+
runAuthenticatedOfficialConformance,
|
|
1967
|
+
runMcpFnTargetSuite,
|
|
1166
1968
|
runOfficialConformance,
|
|
1167
1969
|
runScenarios,
|
|
1168
1970
|
stableJson,
|
|
1169
|
-
startMockOAuthAuthorizationServer
|
|
1971
|
+
startMockOAuthAuthorizationServer,
|
|
1972
|
+
validateMcpFnScenarios
|
|
1170
1973
|
};
|
|
1171
1974
|
//# sourceMappingURL=index.js.map
|