@frontmcp/testing 1.7.2 → 1.8.1
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/client/index.d.ts +1 -1
- package/client/index.d.ts.map +1 -1
- package/client/mcp-test-client.builder.d.ts +2 -1
- package/client/mcp-test-client.builder.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +11 -3
- package/client/mcp-test-client.d.ts.map +1 -1
- package/client/mcp-test-client.types.d.ts +17 -3
- package/client/mcp-test-client.types.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +670 -458
- package/esm/index.mjs +780 -569
- package/esm/matchers/index.mjs +5 -6
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +600 -426
- package/esm/setup.mjs +5 -6
- package/fixtures/fixture-types.d.ts +34 -3
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +670 -458
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +780 -569
- package/matchers/index.js +5 -6
- package/matchers/matcher-types.d.ts +3 -2
- package/matchers/matcher-types.d.ts.map +1 -1
- package/matchers/mcp-matchers.d.ts +1 -1
- package/matchers/mcp-matchers.d.ts.map +1 -1
- package/package.json +6 -6
- package/perf/index.js +600 -426
- package/setup.js +5 -6
- package/transport/index.d.ts +1 -1
- package/transport/index.d.ts.map +1 -1
- package/transport/streamable-http.transport.d.ts +18 -2
- package/transport/streamable-http.transport.d.ts.map +1 -1
- package/transport/transport.interface.d.ts +17 -1
- package/transport/transport.interface.d.ts.map +1 -1
package/perf/index.js
CHANGED
|
@@ -834,236 +834,281 @@ function getMeasurementsForProject(project) {
|
|
|
834
834
|
return getGlobalMeasurementsArray().filter((m) => m.project === project);
|
|
835
835
|
}
|
|
836
836
|
|
|
837
|
-
// libs/testing/src/
|
|
838
|
-
var
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
name: "mcp-ext-apps",
|
|
849
|
-
version: "1.0"
|
|
850
|
-
};
|
|
851
|
-
case "claude":
|
|
852
|
-
return {
|
|
853
|
-
name: "claude-desktop",
|
|
854
|
-
version: "1.0"
|
|
855
|
-
};
|
|
856
|
-
case "cursor":
|
|
857
|
-
return {
|
|
858
|
-
name: "cursor",
|
|
859
|
-
version: "1.0"
|
|
860
|
-
};
|
|
861
|
-
case "continue":
|
|
862
|
-
return {
|
|
863
|
-
name: "continue",
|
|
864
|
-
version: "1.0"
|
|
865
|
-
};
|
|
866
|
-
case "cody":
|
|
867
|
-
return {
|
|
868
|
-
name: "cody",
|
|
869
|
-
version: "1.0"
|
|
870
|
-
};
|
|
871
|
-
case "gemini":
|
|
872
|
-
return {
|
|
873
|
-
name: "gemini",
|
|
874
|
-
version: "1.0"
|
|
875
|
-
};
|
|
876
|
-
case "generic-mcp":
|
|
877
|
-
return {
|
|
878
|
-
name: "generic-mcp-client",
|
|
879
|
-
version: "1.0"
|
|
880
|
-
};
|
|
881
|
-
case "unknown":
|
|
882
|
-
default:
|
|
883
|
-
return {
|
|
884
|
-
name: "mcp-test-client",
|
|
885
|
-
version: "1.0"
|
|
886
|
-
};
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
function getPlatformCapabilities(platform) {
|
|
890
|
-
const baseCapabilities = {
|
|
891
|
-
sampling: {},
|
|
892
|
-
// Include elicitation.form by default for testing elicitation workflows
|
|
893
|
-
// Note: MCP SDK expects form to be an object, not boolean
|
|
894
|
-
elicitation: {
|
|
895
|
-
form: {}
|
|
896
|
-
}
|
|
897
|
-
};
|
|
898
|
-
if (platform === "ext-apps") {
|
|
837
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
838
|
+
var DefaultMockRegistry = class {
|
|
839
|
+
mocks = [];
|
|
840
|
+
add(mock) {
|
|
841
|
+
const entry = {
|
|
842
|
+
definition: mock,
|
|
843
|
+
callCount: 0,
|
|
844
|
+
calls: [],
|
|
845
|
+
remainingUses: mock.times ?? Infinity
|
|
846
|
+
};
|
|
847
|
+
this.mocks.push(entry);
|
|
899
848
|
return {
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
849
|
+
remove: () => {
|
|
850
|
+
const index = this.mocks.indexOf(entry);
|
|
851
|
+
if (index !== -1) {
|
|
852
|
+
this.mocks.splice(index, 1);
|
|
904
853
|
}
|
|
905
|
-
}
|
|
854
|
+
},
|
|
855
|
+
callCount: () => entry.callCount,
|
|
856
|
+
calls: () => [...entry.calls]
|
|
906
857
|
};
|
|
907
858
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
912
|
-
var McpTestClientBuilder = class {
|
|
913
|
-
config;
|
|
914
|
-
constructor(config) {
|
|
915
|
-
this.config = { ...config };
|
|
859
|
+
clear() {
|
|
860
|
+
this.mocks = [];
|
|
916
861
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
862
|
+
getAll() {
|
|
863
|
+
return this.mocks.map((e) => e.definition);
|
|
864
|
+
}
|
|
865
|
+
match(request) {
|
|
866
|
+
for (const entry of this.mocks) {
|
|
867
|
+
if (entry.remainingUses <= 0) continue;
|
|
868
|
+
const { definition } = entry;
|
|
869
|
+
if (definition.method !== request.method) continue;
|
|
870
|
+
if (definition.params !== void 0) {
|
|
871
|
+
const params = request.params ?? {};
|
|
872
|
+
if (typeof definition.params === "function") {
|
|
873
|
+
if (!definition.params(params)) continue;
|
|
874
|
+
} else {
|
|
875
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
entry.callCount++;
|
|
879
|
+
entry.calls.push(request);
|
|
880
|
+
entry.remainingUses--;
|
|
881
|
+
return definition;
|
|
882
|
+
}
|
|
883
|
+
return void 0;
|
|
923
884
|
}
|
|
924
885
|
/**
|
|
925
|
-
*
|
|
886
|
+
* Check if request params match the mock params definition
|
|
926
887
|
*/
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
888
|
+
paramsMatch(expected, actual) {
|
|
889
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
890
|
+
if (!(key in actual)) return false;
|
|
891
|
+
const actualValue = actual[key];
|
|
892
|
+
if (Array.isArray(value)) {
|
|
893
|
+
if (!Array.isArray(actualValue)) return false;
|
|
894
|
+
if (value.length !== actualValue.length) return false;
|
|
895
|
+
for (let i = 0; i < value.length; i++) {
|
|
896
|
+
const expectedItem = value[i];
|
|
897
|
+
const actualItem = actualValue[i];
|
|
898
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
899
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
900
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
} else if (actualItem !== expectedItem) {
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
} else if (typeof value === "object" && value !== null) {
|
|
908
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
909
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
} else if (actualValue !== value) {
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return true;
|
|
930
917
|
}
|
|
918
|
+
};
|
|
919
|
+
var mockResponse = {
|
|
931
920
|
/**
|
|
932
|
-
*
|
|
921
|
+
* Create a successful JSON-RPC response
|
|
933
922
|
*/
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
923
|
+
success(result, id = 1) {
|
|
924
|
+
return {
|
|
925
|
+
jsonrpc: "2.0",
|
|
926
|
+
id,
|
|
927
|
+
result
|
|
938
928
|
};
|
|
939
|
-
|
|
940
|
-
}
|
|
929
|
+
},
|
|
941
930
|
/**
|
|
942
|
-
*
|
|
931
|
+
* Create an error JSON-RPC response
|
|
943
932
|
*/
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
933
|
+
error(code, message, data, id = 1) {
|
|
934
|
+
return {
|
|
935
|
+
jsonrpc: "2.0",
|
|
936
|
+
id,
|
|
937
|
+
error: { code, message, data }
|
|
938
|
+
};
|
|
939
|
+
},
|
|
948
940
|
/**
|
|
949
|
-
*
|
|
941
|
+
* Create a tool result response
|
|
950
942
|
*/
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
943
|
+
toolResult(content, id = 1) {
|
|
944
|
+
return {
|
|
945
|
+
jsonrpc: "2.0",
|
|
946
|
+
id,
|
|
947
|
+
result: { content }
|
|
948
|
+
};
|
|
949
|
+
},
|
|
955
950
|
/**
|
|
956
|
-
*
|
|
951
|
+
* Create a tools/list response
|
|
957
952
|
*/
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
953
|
+
toolsList(tools, id = 1) {
|
|
954
|
+
return {
|
|
955
|
+
jsonrpc: "2.0",
|
|
956
|
+
id,
|
|
957
|
+
result: { tools }
|
|
958
|
+
};
|
|
959
|
+
},
|
|
962
960
|
/**
|
|
963
|
-
*
|
|
964
|
-
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
965
|
-
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
961
|
+
* Create a resources/list response
|
|
966
962
|
*/
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
963
|
+
resourcesList(resources, id = 1) {
|
|
964
|
+
return {
|
|
965
|
+
jsonrpc: "2.0",
|
|
966
|
+
id,
|
|
967
|
+
result: { resources }
|
|
968
|
+
};
|
|
969
|
+
},
|
|
971
970
|
/**
|
|
972
|
-
*
|
|
971
|
+
* Create a resources/read response
|
|
973
972
|
*/
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
973
|
+
resourceRead(contents, id = 1) {
|
|
974
|
+
return {
|
|
975
|
+
jsonrpc: "2.0",
|
|
976
|
+
id,
|
|
977
|
+
result: { contents }
|
|
978
|
+
};
|
|
979
|
+
},
|
|
978
980
|
/**
|
|
979
|
-
*
|
|
981
|
+
* Common MCP errors
|
|
980
982
|
*/
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
983
|
+
errors: {
|
|
984
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
985
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
986
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
987
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
988
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
989
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
990
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
995
|
+
var DefaultInterceptorChain = class {
|
|
996
|
+
request = [];
|
|
997
|
+
response = [];
|
|
998
|
+
mocks;
|
|
999
|
+
constructor() {
|
|
1000
|
+
this.mocks = new DefaultMockRegistry();
|
|
984
1001
|
}
|
|
985
1002
|
/**
|
|
986
|
-
*
|
|
987
|
-
* Automatically configures clientInfo and capabilities for platform detection.
|
|
988
|
-
*
|
|
989
|
-
* Platform-specific behavior:
|
|
990
|
-
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
991
|
-
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
992
|
-
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
993
|
-
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
994
|
-
* - Other platforms follow similar patterns
|
|
995
|
-
*
|
|
996
|
-
* @example
|
|
997
|
-
* ```typescript
|
|
998
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
999
|
-
* .withPlatform('openai')
|
|
1000
|
-
* .buildAndConnect();
|
|
1001
|
-
*
|
|
1002
|
-
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1003
|
-
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1004
|
-
* .withPlatform('ext-apps')
|
|
1005
|
-
* .buildAndConnect();
|
|
1006
|
-
* ```
|
|
1003
|
+
* Add a request interceptor
|
|
1007
1004
|
*/
|
|
1008
|
-
|
|
1009
|
-
this.
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1005
|
+
addRequestInterceptor(interceptor) {
|
|
1006
|
+
this.request.push(interceptor);
|
|
1007
|
+
return () => {
|
|
1008
|
+
const index = this.request.indexOf(interceptor);
|
|
1009
|
+
if (index !== -1) {
|
|
1010
|
+
this.request.splice(index, 1);
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
1013
|
}
|
|
1014
1014
|
/**
|
|
1015
|
-
*
|
|
1016
|
-
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1017
|
-
*
|
|
1018
|
-
* @example
|
|
1019
|
-
* ```typescript
|
|
1020
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
1021
|
-
* .withCapabilities({
|
|
1022
|
-
* sampling: {},
|
|
1023
|
-
* experimental: {
|
|
1024
|
-
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1025
|
-
* }
|
|
1026
|
-
* })
|
|
1027
|
-
* .buildAndConnect();
|
|
1028
|
-
* ```
|
|
1015
|
+
* Add a response interceptor
|
|
1029
1016
|
*/
|
|
1030
|
-
|
|
1031
|
-
this.
|
|
1032
|
-
return
|
|
1017
|
+
addResponseInterceptor(interceptor) {
|
|
1018
|
+
this.response.push(interceptor);
|
|
1019
|
+
return () => {
|
|
1020
|
+
const index = this.response.indexOf(interceptor);
|
|
1021
|
+
if (index !== -1) {
|
|
1022
|
+
this.response.splice(index, 1);
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1033
1025
|
}
|
|
1034
1026
|
/**
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
1041
|
-
* .withQueryParams({ mode: 'skills_only' })
|
|
1042
|
-
* .buildAndConnect();
|
|
1043
|
-
* ```
|
|
1027
|
+
* Process a request through the interceptor chain
|
|
1028
|
+
* Returns either:
|
|
1029
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
1030
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
1031
|
+
* - { type: 'error', error } - throw error
|
|
1044
1032
|
*/
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1033
|
+
async processRequest(request, meta) {
|
|
1034
|
+
let currentRequest = request;
|
|
1035
|
+
const mockDef = this.mocks.match(request);
|
|
1036
|
+
if (mockDef) {
|
|
1037
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
1038
|
+
await sleep3(mockDef.delay);
|
|
1039
|
+
}
|
|
1040
|
+
let mockResponse2;
|
|
1041
|
+
if (typeof mockDef.response === "function") {
|
|
1042
|
+
mockResponse2 = await mockDef.response(request);
|
|
1043
|
+
} else {
|
|
1044
|
+
mockResponse2 = mockDef.response;
|
|
1045
|
+
}
|
|
1046
|
+
return {
|
|
1047
|
+
type: "mock",
|
|
1048
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
for (const interceptor of this.request) {
|
|
1052
|
+
const ctx = {
|
|
1053
|
+
request: currentRequest,
|
|
1054
|
+
meta
|
|
1055
|
+
};
|
|
1056
|
+
const result = await interceptor(ctx);
|
|
1057
|
+
switch (result.action) {
|
|
1058
|
+
case "passthrough":
|
|
1059
|
+
break;
|
|
1060
|
+
case "modify":
|
|
1061
|
+
currentRequest = result.request;
|
|
1062
|
+
break;
|
|
1063
|
+
case "mock":
|
|
1064
|
+
return {
|
|
1065
|
+
type: "mock",
|
|
1066
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1067
|
+
};
|
|
1068
|
+
case "error":
|
|
1069
|
+
return { type: "error", error: result.error };
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return { type: "continue", request: currentRequest };
|
|
1048
1073
|
}
|
|
1049
1074
|
/**
|
|
1050
|
-
*
|
|
1075
|
+
* Process a response through the interceptor chain
|
|
1051
1076
|
*/
|
|
1052
|
-
|
|
1053
|
-
|
|
1077
|
+
async processResponse(request, response, durationMs) {
|
|
1078
|
+
let currentResponse = response;
|
|
1079
|
+
for (const interceptor of this.response) {
|
|
1080
|
+
const ctx = {
|
|
1081
|
+
request,
|
|
1082
|
+
response: currentResponse,
|
|
1083
|
+
durationMs
|
|
1084
|
+
};
|
|
1085
|
+
const result = await interceptor(ctx);
|
|
1086
|
+
switch (result.action) {
|
|
1087
|
+
case "passthrough":
|
|
1088
|
+
break;
|
|
1089
|
+
case "modify":
|
|
1090
|
+
currentResponse = result.response;
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return currentResponse;
|
|
1054
1095
|
}
|
|
1055
1096
|
/**
|
|
1056
|
-
*
|
|
1097
|
+
* Clear all interceptors and mocks
|
|
1057
1098
|
*/
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1099
|
+
clear() {
|
|
1100
|
+
this.request = [];
|
|
1101
|
+
this.response = [];
|
|
1102
|
+
this.mocks.clear();
|
|
1062
1103
|
}
|
|
1063
1104
|
};
|
|
1105
|
+
function sleep3(ms) {
|
|
1106
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1107
|
+
}
|
|
1064
1108
|
|
|
1065
1109
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
1066
1110
|
var DEFAULT_TIMEOUT = 3e4;
|
|
1111
|
+
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
1067
1112
|
var StreamableHttpTransport = class {
|
|
1068
1113
|
config;
|
|
1069
1114
|
state = "disconnected";
|
|
@@ -1075,10 +1120,15 @@ var StreamableHttpTransport = class {
|
|
|
1075
1120
|
interceptors;
|
|
1076
1121
|
publicMode;
|
|
1077
1122
|
elicitationHandler;
|
|
1123
|
+
notificationHandler;
|
|
1124
|
+
notificationStream;
|
|
1125
|
+
notificationProtocolVersion = "";
|
|
1126
|
+
notificationStreamReopenTimer;
|
|
1078
1127
|
constructor(config) {
|
|
1079
1128
|
this.config = {
|
|
1080
1129
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
1081
1130
|
// Remove trailing slash
|
|
1131
|
+
entryPath: config.entryPath,
|
|
1082
1132
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
1083
1133
|
auth: config.auth ?? {},
|
|
1084
1134
|
publicMode: config.publicMode ?? false,
|
|
@@ -1090,6 +1140,7 @@ var StreamableHttpTransport = class {
|
|
|
1090
1140
|
this.interceptors = config.interceptors;
|
|
1091
1141
|
this.publicMode = config.publicMode ?? false;
|
|
1092
1142
|
this.elicitationHandler = config.elicitationHandler;
|
|
1143
|
+
this.notificationHandler = config.notificationHandler;
|
|
1093
1144
|
}
|
|
1094
1145
|
async connect() {
|
|
1095
1146
|
this.state = "connecting";
|
|
@@ -1171,7 +1222,7 @@ var StreamableHttpTransport = class {
|
|
|
1171
1222
|
}
|
|
1172
1223
|
const headers = this.buildHeaders();
|
|
1173
1224
|
this.lastRequestHeaders = headers;
|
|
1174
|
-
const url =
|
|
1225
|
+
const url = this.mcpUrl();
|
|
1175
1226
|
this.log(`POST ${url}`, message);
|
|
1176
1227
|
const controller = new AbortController();
|
|
1177
1228
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -1241,7 +1292,7 @@ var StreamableHttpTransport = class {
|
|
|
1241
1292
|
this.ensureConnected();
|
|
1242
1293
|
const headers = this.buildHeaders();
|
|
1243
1294
|
this.lastRequestHeaders = headers;
|
|
1244
|
-
const url =
|
|
1295
|
+
const url = this.mcpUrl();
|
|
1245
1296
|
this.log(`POST ${url} (notification)`, message);
|
|
1246
1297
|
const controller = new AbortController();
|
|
1247
1298
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -1272,7 +1323,7 @@ var StreamableHttpTransport = class {
|
|
|
1272
1323
|
this.ensureConnected();
|
|
1273
1324
|
const headers = this.buildHeaders();
|
|
1274
1325
|
this.lastRequestHeaders = headers;
|
|
1275
|
-
const url =
|
|
1326
|
+
const url = this.mcpUrl();
|
|
1276
1327
|
this.log(`POST ${url} (raw)`, data);
|
|
1277
1328
|
const controller = new AbortController();
|
|
1278
1329
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -1309,7 +1360,12 @@ var StreamableHttpTransport = class {
|
|
|
1309
1360
|
};
|
|
1310
1361
|
}
|
|
1311
1362
|
}
|
|
1363
|
+
async openNotificationStream(protocolVersion) {
|
|
1364
|
+
this.notificationProtocolVersion = protocolVersion;
|
|
1365
|
+
await this.connectNotificationStream();
|
|
1366
|
+
}
|
|
1312
1367
|
async close() {
|
|
1368
|
+
this.closeNotificationStream();
|
|
1313
1369
|
this.state = "disconnected";
|
|
1314
1370
|
this.sessionId = void 0;
|
|
1315
1371
|
this.log("StreamableHTTP transport closed");
|
|
@@ -1348,6 +1404,7 @@ var StreamableHttpTransport = class {
|
|
|
1348
1404
|
return { ...this.lastRequestHeaders };
|
|
1349
1405
|
}
|
|
1350
1406
|
async simulateDisconnect() {
|
|
1407
|
+
this.closeNotificationStream();
|
|
1351
1408
|
this.state = "disconnected";
|
|
1352
1409
|
this.sessionId = void 0;
|
|
1353
1410
|
}
|
|
@@ -1366,6 +1423,72 @@ var StreamableHttpTransport = class {
|
|
|
1366
1423
|
// ═══════════════════════════════════════════════════════════════════
|
|
1367
1424
|
// PRIVATE HELPERS
|
|
1368
1425
|
// ═══════════════════════════════════════════════════════════════════
|
|
1426
|
+
closeNotificationStream() {
|
|
1427
|
+
clearTimeout(this.notificationStreamReopenTimer);
|
|
1428
|
+
this.notificationStream?.abort();
|
|
1429
|
+
this.notificationStream = void 0;
|
|
1430
|
+
}
|
|
1431
|
+
/** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
|
|
1432
|
+
async connectNotificationStream() {
|
|
1433
|
+
if (!this.sessionId || this.notificationStream) return;
|
|
1434
|
+
const controller = new AbortController();
|
|
1435
|
+
this.notificationStream = controller;
|
|
1436
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
1437
|
+
const established = fetch(this.mcpUrl(), {
|
|
1438
|
+
method: "GET",
|
|
1439
|
+
headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
|
|
1440
|
+
signal: controller.signal
|
|
1441
|
+
}).then(
|
|
1442
|
+
async (response) => {
|
|
1443
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
1444
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
1445
|
+
await response.body?.cancel();
|
|
1446
|
+
this.log(`Server offers no notification stream (HTTP ${response.status})`);
|
|
1447
|
+
this.releaseNotificationStream(controller);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
void this.readNotificationStream(controller, response.body.getReader());
|
|
1451
|
+
},
|
|
1452
|
+
(error) => {
|
|
1453
|
+
this.log("Failed to open the notification stream:", error);
|
|
1454
|
+
this.releaseNotificationStream(controller);
|
|
1455
|
+
}
|
|
1456
|
+
);
|
|
1457
|
+
let establishTimer;
|
|
1458
|
+
const establishDeadline = new Promise((resolve) => {
|
|
1459
|
+
establishTimer = setTimeout(resolve, this.config.timeout);
|
|
1460
|
+
});
|
|
1461
|
+
await Promise.race([established, establishDeadline]);
|
|
1462
|
+
clearTimeout(establishTimer);
|
|
1463
|
+
}
|
|
1464
|
+
releaseNotificationStream(controller) {
|
|
1465
|
+
if (this.notificationStream === controller) this.notificationStream = void 0;
|
|
1466
|
+
}
|
|
1467
|
+
async readNotificationStream(controller, reader) {
|
|
1468
|
+
const decoder = new TextDecoder();
|
|
1469
|
+
let buffer = "";
|
|
1470
|
+
try {
|
|
1471
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
1472
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
1473
|
+
buffer = remainder;
|
|
1474
|
+
for (const eventText of completeEvents) {
|
|
1475
|
+
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
1476
|
+
await this.handleSSEEvent(event);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
this.log("Notification stream ended:", error);
|
|
1482
|
+
} finally {
|
|
1483
|
+
reader.releaseLock();
|
|
1484
|
+
}
|
|
1485
|
+
if (controller.signal.aborted || this.notificationStream !== controller) return;
|
|
1486
|
+
this.releaseNotificationStream(controller);
|
|
1487
|
+
this.notificationStreamReopenTimer = setTimeout(
|
|
1488
|
+
() => void this.connectNotificationStream(),
|
|
1489
|
+
NOTIFICATION_STREAM_REOPEN_DELAY_MS
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1369
1492
|
/**
|
|
1370
1493
|
* Handle SSE response with elicitation support.
|
|
1371
1494
|
*
|
|
@@ -1503,6 +1626,13 @@ var StreamableHttpTransport = class {
|
|
|
1503
1626
|
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1504
1627
|
};
|
|
1505
1628
|
}
|
|
1629
|
+
if ("method" in parsed && parsed.id === void 0) {
|
|
1630
|
+
this.notificationHandler?.(parsed);
|
|
1631
|
+
return {
|
|
1632
|
+
isFinal: false,
|
|
1633
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1506
1636
|
if ("result" in parsed || "error" in parsed) {
|
|
1507
1637
|
return { isFinal: true, response: parsed };
|
|
1508
1638
|
}
|
|
@@ -1555,7 +1685,7 @@ var StreamableHttpTransport = class {
|
|
|
1555
1685
|
*/
|
|
1556
1686
|
async sendElicitationResponse(requestId, response) {
|
|
1557
1687
|
const headers = this.buildHeaders();
|
|
1558
|
-
const url =
|
|
1688
|
+
const url = this.mcpUrl();
|
|
1559
1689
|
const rpcResponse = {
|
|
1560
1690
|
jsonrpc: "2.0",
|
|
1561
1691
|
id: requestId,
|
|
@@ -1575,6 +1705,18 @@ var StreamableHttpTransport = class {
|
|
|
1575
1705
|
this.log("Failed to send elicitation response:", error);
|
|
1576
1706
|
}
|
|
1577
1707
|
}
|
|
1708
|
+
/**
|
|
1709
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
1710
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
1711
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
1712
|
+
*/
|
|
1713
|
+
mcpUrl() {
|
|
1714
|
+
const url = new URL(this.config.baseUrl);
|
|
1715
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
1716
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1717
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
1718
|
+
return url.toString();
|
|
1719
|
+
}
|
|
1578
1720
|
buildHeaders() {
|
|
1579
1721
|
const headers = {
|
|
1580
1722
|
"Content-Type": "application/json",
|
|
@@ -1661,286 +1803,266 @@ var StreamableHttpTransport = class {
|
|
|
1661
1803
|
};
|
|
1662
1804
|
}
|
|
1663
1805
|
};
|
|
1806
|
+
function splitSSEEvents(buffer) {
|
|
1807
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1808
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1809
|
+
const completeEvents = normalized.split("\n\n");
|
|
1810
|
+
const remainder = completeEvents.pop() ?? "";
|
|
1811
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
1812
|
+
}
|
|
1664
1813
|
|
|
1665
|
-
// libs/testing/src/
|
|
1666
|
-
var
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1814
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
1815
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1816
|
+
function getPlatformClientInfo(platform) {
|
|
1817
|
+
switch (platform) {
|
|
1818
|
+
case "openai":
|
|
1819
|
+
return {
|
|
1820
|
+
name: "ChatGPT",
|
|
1821
|
+
version: "1.0"
|
|
1822
|
+
};
|
|
1823
|
+
case "ext-apps":
|
|
1824
|
+
return {
|
|
1825
|
+
name: "mcp-ext-apps",
|
|
1826
|
+
version: "1.0"
|
|
1827
|
+
};
|
|
1828
|
+
case "claude":
|
|
1829
|
+
return {
|
|
1830
|
+
name: "claude-desktop",
|
|
1831
|
+
version: "1.0"
|
|
1832
|
+
};
|
|
1833
|
+
case "cursor":
|
|
1834
|
+
return {
|
|
1835
|
+
name: "cursor",
|
|
1836
|
+
version: "1.0"
|
|
1837
|
+
};
|
|
1838
|
+
case "continue":
|
|
1839
|
+
return {
|
|
1840
|
+
name: "continue",
|
|
1841
|
+
version: "1.0"
|
|
1842
|
+
};
|
|
1843
|
+
case "cody":
|
|
1844
|
+
return {
|
|
1845
|
+
name: "cody",
|
|
1846
|
+
version: "1.0"
|
|
1847
|
+
};
|
|
1848
|
+
case "gemini":
|
|
1849
|
+
return {
|
|
1850
|
+
name: "gemini",
|
|
1851
|
+
version: "1.0"
|
|
1852
|
+
};
|
|
1853
|
+
case "generic-mcp":
|
|
1854
|
+
return {
|
|
1855
|
+
name: "generic-mcp-client",
|
|
1856
|
+
version: "1.0"
|
|
1857
|
+
};
|
|
1858
|
+
case "unknown":
|
|
1859
|
+
default:
|
|
1860
|
+
return {
|
|
1861
|
+
name: "mcp-test-client",
|
|
1862
|
+
version: "1.0"
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
function getPlatformCapabilities(platform) {
|
|
1867
|
+
const baseCapabilities = {
|
|
1868
|
+
sampling: {},
|
|
1869
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1870
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1871
|
+
elicitation: {
|
|
1872
|
+
form: {}
|
|
1873
|
+
}
|
|
1874
|
+
};
|
|
1875
|
+
if (platform === "ext-apps") {
|
|
1676
1876
|
return {
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1877
|
+
...baseCapabilities,
|
|
1878
|
+
experimental: {
|
|
1879
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1880
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
1681
1881
|
}
|
|
1682
|
-
}
|
|
1683
|
-
callCount: () => entry.callCount,
|
|
1684
|
-
calls: () => [...entry.calls]
|
|
1882
|
+
}
|
|
1685
1883
|
};
|
|
1686
1884
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
if (entry.remainingUses <= 0) continue;
|
|
1696
|
-
const { definition } = entry;
|
|
1697
|
-
if (definition.method !== request.method) continue;
|
|
1698
|
-
if (definition.params !== void 0) {
|
|
1699
|
-
const params = request.params ?? {};
|
|
1700
|
-
if (typeof definition.params === "function") {
|
|
1701
|
-
if (!definition.params(params)) continue;
|
|
1702
|
-
} else {
|
|
1703
|
-
if (!this.paramsMatch(definition.params, params)) continue;
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
entry.callCount++;
|
|
1707
|
-
entry.calls.push(request);
|
|
1708
|
-
entry.remainingUses--;
|
|
1709
|
-
return definition;
|
|
1710
|
-
}
|
|
1711
|
-
return void 0;
|
|
1885
|
+
return baseCapabilities;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1889
|
+
var McpTestClientBuilder = class {
|
|
1890
|
+
config;
|
|
1891
|
+
constructor(config) {
|
|
1892
|
+
this.config = { ...config };
|
|
1712
1893
|
}
|
|
1713
1894
|
/**
|
|
1714
|
-
*
|
|
1895
|
+
* Set the authentication configuration
|
|
1715
1896
|
*/
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
const actualValue = actual[key];
|
|
1720
|
-
if (Array.isArray(value)) {
|
|
1721
|
-
if (!Array.isArray(actualValue)) return false;
|
|
1722
|
-
if (value.length !== actualValue.length) return false;
|
|
1723
|
-
for (let i = 0; i < value.length; i++) {
|
|
1724
|
-
const expectedItem = value[i];
|
|
1725
|
-
const actualItem = actualValue[i];
|
|
1726
|
-
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
1727
|
-
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
1728
|
-
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
1729
|
-
return false;
|
|
1730
|
-
}
|
|
1731
|
-
} else if (actualItem !== expectedItem) {
|
|
1732
|
-
return false;
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
} else if (typeof value === "object" && value !== null) {
|
|
1736
|
-
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
1737
|
-
if (!this.paramsMatch(value, actualValue)) {
|
|
1738
|
-
return false;
|
|
1739
|
-
}
|
|
1740
|
-
} else if (actualValue !== value) {
|
|
1741
|
-
return false;
|
|
1742
|
-
}
|
|
1743
|
-
}
|
|
1744
|
-
return true;
|
|
1897
|
+
withAuth(auth) {
|
|
1898
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1899
|
+
return this;
|
|
1745
1900
|
}
|
|
1746
|
-
};
|
|
1747
|
-
var mockResponse = {
|
|
1748
1901
|
/**
|
|
1749
|
-
*
|
|
1902
|
+
* Set the bearer token for authentication
|
|
1750
1903
|
*/
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
result
|
|
1756
|
-
};
|
|
1757
|
-
},
|
|
1904
|
+
withToken(token) {
|
|
1905
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1906
|
+
return this;
|
|
1907
|
+
}
|
|
1758
1908
|
/**
|
|
1759
|
-
*
|
|
1909
|
+
* Add custom headers to all requests
|
|
1760
1910
|
*/
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
error: { code, message, data }
|
|
1911
|
+
withHeaders(headers) {
|
|
1912
|
+
this.config.auth = {
|
|
1913
|
+
...this.config.auth,
|
|
1914
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1766
1915
|
};
|
|
1767
|
-
|
|
1916
|
+
return this;
|
|
1917
|
+
}
|
|
1768
1918
|
/**
|
|
1769
|
-
*
|
|
1919
|
+
* Set the transport type
|
|
1770
1920
|
*/
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
result: { content }
|
|
1776
|
-
};
|
|
1777
|
-
},
|
|
1921
|
+
withTransport(transport) {
|
|
1922
|
+
this.config.transport = transport;
|
|
1923
|
+
return this;
|
|
1924
|
+
}
|
|
1778
1925
|
/**
|
|
1779
|
-
*
|
|
1926
|
+
* Set the request timeout in milliseconds
|
|
1780
1927
|
*/
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
result: { tools }
|
|
1786
|
-
};
|
|
1787
|
-
},
|
|
1928
|
+
withTimeout(timeoutMs) {
|
|
1929
|
+
this.config.timeout = timeoutMs;
|
|
1930
|
+
return this;
|
|
1931
|
+
}
|
|
1788
1932
|
/**
|
|
1789
|
-
*
|
|
1933
|
+
* Enable debug logging
|
|
1790
1934
|
*/
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
result: { resources }
|
|
1796
|
-
};
|
|
1797
|
-
},
|
|
1935
|
+
withDebug(enabled = true) {
|
|
1936
|
+
this.config.debug = enabled;
|
|
1937
|
+
return this;
|
|
1938
|
+
}
|
|
1798
1939
|
/**
|
|
1799
|
-
*
|
|
1940
|
+
* Enable public mode - skip authentication entirely.
|
|
1941
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1942
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1800
1943
|
*/
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
result: { contents }
|
|
1806
|
-
};
|
|
1807
|
-
},
|
|
1944
|
+
withPublicMode(enabled = true) {
|
|
1945
|
+
this.config.publicMode = enabled;
|
|
1946
|
+
return this;
|
|
1947
|
+
}
|
|
1808
1948
|
/**
|
|
1809
|
-
*
|
|
1949
|
+
* Set the MCP protocol version to request (default '2025-06-18').
|
|
1950
|
+
* '2026-07-28' has no initialize handshake, so `build()` throws for it.
|
|
1810
1951
|
*/
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
1815
|
-
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
1816
|
-
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
1817
|
-
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
1818
|
-
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
1952
|
+
withProtocolVersion(version) {
|
|
1953
|
+
this.config.protocolVersion = version;
|
|
1954
|
+
return this;
|
|
1819
1955
|
}
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
mocks;
|
|
1827
|
-
constructor() {
|
|
1828
|
-
this.mocks = new DefaultMockRegistry();
|
|
1956
|
+
/**
|
|
1957
|
+
* Set the client info sent during initialization
|
|
1958
|
+
*/
|
|
1959
|
+
withClientInfo(info) {
|
|
1960
|
+
this.config.clientInfo = info;
|
|
1961
|
+
return this;
|
|
1829
1962
|
}
|
|
1830
1963
|
/**
|
|
1831
|
-
*
|
|
1964
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1965
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1966
|
+
*
|
|
1967
|
+
* Platform-specific behavior:
|
|
1968
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1969
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1970
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1971
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1972
|
+
* - Other platforms follow similar patterns
|
|
1973
|
+
*
|
|
1974
|
+
* @example
|
|
1975
|
+
* ```typescript
|
|
1976
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1977
|
+
* .withPlatform('openai')
|
|
1978
|
+
* .buildAndConnect();
|
|
1979
|
+
*
|
|
1980
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1981
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1982
|
+
* .withPlatform('ext-apps')
|
|
1983
|
+
* .buildAndConnect();
|
|
1984
|
+
* ```
|
|
1832
1985
|
*/
|
|
1833
|
-
|
|
1834
|
-
this.
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
this.request.splice(index, 1);
|
|
1839
|
-
}
|
|
1840
|
-
};
|
|
1986
|
+
withPlatform(platform) {
|
|
1987
|
+
this.config.platform = platform;
|
|
1988
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1989
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1990
|
+
return this;
|
|
1841
1991
|
}
|
|
1842
1992
|
/**
|
|
1843
|
-
*
|
|
1993
|
+
* Set custom client capabilities for MCP initialization.
|
|
1994
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1995
|
+
*
|
|
1996
|
+
* @example
|
|
1997
|
+
* ```typescript
|
|
1998
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1999
|
+
* .withCapabilities({
|
|
2000
|
+
* sampling: {},
|
|
2001
|
+
* experimental: {
|
|
2002
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
2003
|
+
* }
|
|
2004
|
+
* })
|
|
2005
|
+
* .buildAndConnect();
|
|
2006
|
+
* ```
|
|
1844
2007
|
*/
|
|
1845
|
-
|
|
1846
|
-
this.
|
|
1847
|
-
return
|
|
1848
|
-
const index = this.response.indexOf(interceptor);
|
|
1849
|
-
if (index !== -1) {
|
|
1850
|
-
this.response.splice(index, 1);
|
|
1851
|
-
}
|
|
1852
|
-
};
|
|
2008
|
+
withCapabilities(capabilities) {
|
|
2009
|
+
this.config.capabilities = capabilities;
|
|
2010
|
+
return this;
|
|
1853
2011
|
}
|
|
1854
2012
|
/**
|
|
1855
|
-
*
|
|
1856
|
-
*
|
|
1857
|
-
*
|
|
1858
|
-
*
|
|
1859
|
-
*
|
|
2013
|
+
* Set query parameters to append to the connection URL.
|
|
2014
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
2015
|
+
*
|
|
2016
|
+
* @example
|
|
2017
|
+
* ```typescript
|
|
2018
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
2019
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
2020
|
+
* .buildAndConnect();
|
|
2021
|
+
* ```
|
|
1860
2022
|
*/
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
if (mockDef) {
|
|
1865
|
-
if (mockDef.delay && mockDef.delay > 0) {
|
|
1866
|
-
await sleep3(mockDef.delay);
|
|
1867
|
-
}
|
|
1868
|
-
let mockResponse2;
|
|
1869
|
-
if (typeof mockDef.response === "function") {
|
|
1870
|
-
mockResponse2 = await mockDef.response(request);
|
|
1871
|
-
} else {
|
|
1872
|
-
mockResponse2 = mockDef.response;
|
|
1873
|
-
}
|
|
1874
|
-
return {
|
|
1875
|
-
type: "mock",
|
|
1876
|
-
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1877
|
-
};
|
|
1878
|
-
}
|
|
1879
|
-
for (const interceptor of this.request) {
|
|
1880
|
-
const ctx = {
|
|
1881
|
-
request: currentRequest,
|
|
1882
|
-
meta
|
|
1883
|
-
};
|
|
1884
|
-
const result = await interceptor(ctx);
|
|
1885
|
-
switch (result.action) {
|
|
1886
|
-
case "passthrough":
|
|
1887
|
-
break;
|
|
1888
|
-
case "modify":
|
|
1889
|
-
currentRequest = result.request;
|
|
1890
|
-
break;
|
|
1891
|
-
case "mock":
|
|
1892
|
-
return {
|
|
1893
|
-
type: "mock",
|
|
1894
|
-
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1895
|
-
};
|
|
1896
|
-
case "error":
|
|
1897
|
-
return { type: "error", error: result.error };
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
return { type: "continue", request: currentRequest };
|
|
2023
|
+
withQueryParams(params) {
|
|
2024
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
2025
|
+
return this;
|
|
1901
2026
|
}
|
|
1902
2027
|
/**
|
|
1903
|
-
*
|
|
2028
|
+
* Build the McpTestClient instance (does not connect)
|
|
1904
2029
|
*/
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
for (const interceptor of this.response) {
|
|
1908
|
-
const ctx = {
|
|
1909
|
-
request,
|
|
1910
|
-
response: currentResponse,
|
|
1911
|
-
durationMs
|
|
1912
|
-
};
|
|
1913
|
-
const result = await interceptor(ctx);
|
|
1914
|
-
switch (result.action) {
|
|
1915
|
-
case "passthrough":
|
|
1916
|
-
break;
|
|
1917
|
-
case "modify":
|
|
1918
|
-
currentResponse = result.response;
|
|
1919
|
-
break;
|
|
1920
|
-
}
|
|
1921
|
-
}
|
|
1922
|
-
return currentResponse;
|
|
2030
|
+
build() {
|
|
2031
|
+
return new McpTestClient(this.config);
|
|
1923
2032
|
}
|
|
1924
2033
|
/**
|
|
1925
|
-
*
|
|
2034
|
+
* Build the McpTestClient and connect to the server
|
|
1926
2035
|
*/
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2036
|
+
async buildAndConnect() {
|
|
2037
|
+
const client = this.build();
|
|
2038
|
+
await client.connect();
|
|
2039
|
+
return client;
|
|
1931
2040
|
}
|
|
1932
2041
|
};
|
|
1933
|
-
function sleep3(ms) {
|
|
1934
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1935
|
-
}
|
|
1936
2042
|
|
|
1937
2043
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1938
2044
|
var DEFAULT_TIMEOUT2 = 3e4;
|
|
1939
2045
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
2046
|
+
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1940
2047
|
var DEFAULT_CLIENT_INFO = {
|
|
1941
2048
|
name: "@frontmcp/testing",
|
|
1942
2049
|
version: "0.4.0"
|
|
1943
2050
|
};
|
|
2051
|
+
function extractEntryPaths(data) {
|
|
2052
|
+
let payload = data;
|
|
2053
|
+
if (typeof payload === "string") {
|
|
2054
|
+
try {
|
|
2055
|
+
payload = JSON.parse(payload);
|
|
2056
|
+
} catch {
|
|
2057
|
+
return void 0;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
2061
|
+
const paths = payload.entryPaths;
|
|
2062
|
+
if (!Array.isArray(paths)) return void 0;
|
|
2063
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
2064
|
+
return strings.length > 0 ? strings : void 0;
|
|
2065
|
+
}
|
|
1944
2066
|
var McpTestClient = class {
|
|
1945
2067
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1946
2068
|
config;
|
|
@@ -1949,6 +2071,8 @@ var McpTestClient = class {
|
|
|
1949
2071
|
requestIdCounter = 0;
|
|
1950
2072
|
_lastRequestId = 0;
|
|
1951
2073
|
_sessionId;
|
|
2074
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
2075
|
+
triedDiscoveredEntryPath = false;
|
|
1952
2076
|
_sessionInfo = null;
|
|
1953
2077
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1954
2078
|
// Logging and tracing
|
|
@@ -1956,6 +2080,8 @@ var McpTestClient = class {
|
|
|
1956
2080
|
_traces = [];
|
|
1957
2081
|
_notifications = [];
|
|
1958
2082
|
_progressUpdates = [];
|
|
2083
|
+
collectingProgress = false;
|
|
2084
|
+
progressTokenCounter = 0;
|
|
1959
2085
|
// Interceptor chain
|
|
1960
2086
|
_interceptors;
|
|
1961
2087
|
// Elicitation handler for server→client elicit requests
|
|
@@ -1964,8 +2090,14 @@ var McpTestClient = class {
|
|
|
1964
2090
|
// CONSTRUCTOR & FACTORY
|
|
1965
2091
|
// ═══════════════════════════════════════════════════════════════════
|
|
1966
2092
|
constructor(config) {
|
|
2093
|
+
if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
|
|
2094
|
+
throw new Error(
|
|
2095
|
+
`McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
1967
2098
|
this.config = {
|
|
1968
2099
|
baseUrl: config.baseUrl,
|
|
2100
|
+
entryPath: config.entryPath,
|
|
1969
2101
|
transport: config.transport ?? "streamable-http",
|
|
1970
2102
|
auth: config.auth ?? {},
|
|
1971
2103
|
publicMode: config.publicMode ?? false,
|
|
@@ -2001,11 +2133,13 @@ var McpTestClient = class {
|
|
|
2001
2133
|
*/
|
|
2002
2134
|
async connect() {
|
|
2003
2135
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
2136
|
+
this.triedDiscoveredEntryPath = false;
|
|
2004
2137
|
this.transport = this.createTransport();
|
|
2005
2138
|
await this.transport.connect();
|
|
2006
2139
|
const maxRetries = 3;
|
|
2007
2140
|
const retryDelayMs = 500;
|
|
2008
2141
|
let lastError;
|
|
2142
|
+
let reportedEntryPaths;
|
|
2009
2143
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
2010
2144
|
const initResponse = await this.initialize();
|
|
2011
2145
|
if (initResponse.success && initResponse.data) {
|
|
@@ -2013,13 +2147,27 @@ var McpTestClient = class {
|
|
|
2013
2147
|
break;
|
|
2014
2148
|
}
|
|
2015
2149
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
2150
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
2151
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
2152
|
+
this.triedDiscoveredEntryPath = true;
|
|
2153
|
+
const discovered = reportedEntryPaths[0];
|
|
2154
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
2155
|
+
this.config.entryPath = discovered;
|
|
2156
|
+
await this.transport.close();
|
|
2157
|
+
this.transport = this.createTransport();
|
|
2158
|
+
await this.transport.connect();
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2016
2161
|
if (attempt < maxRetries) {
|
|
2017
2162
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
2018
2163
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
2019
2164
|
}
|
|
2020
2165
|
}
|
|
2021
2166
|
if (!this.initResult) {
|
|
2022
|
-
|
|
2167
|
+
const served = reportedEntryPaths?.length ? ` The server reports it serves MCP at ${reportedEntryPaths.join(", ")}; set \`entryPath\` in test.use() (or \`http.entryPath\` on the server) to match.` : "";
|
|
2168
|
+
throw new Error(
|
|
2169
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
2170
|
+
);
|
|
2023
2171
|
}
|
|
2024
2172
|
this._sessionId = this.transport.getSessionId();
|
|
2025
2173
|
this._sessionInfo = {
|
|
@@ -2032,6 +2180,7 @@ var McpTestClient = class {
|
|
|
2032
2180
|
jsonrpc: "2.0",
|
|
2033
2181
|
method: "notifications/initialized"
|
|
2034
2182
|
});
|
|
2183
|
+
await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
|
|
2035
2184
|
this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
|
|
2036
2185
|
return this.initResult;
|
|
2037
2186
|
}
|
|
@@ -2142,8 +2291,8 @@ var McpTestClient = class {
|
|
|
2142
2291
|
/**
|
|
2143
2292
|
* Call a tool by name with arguments
|
|
2144
2293
|
*/
|
|
2145
|
-
call: async (name, args) => {
|
|
2146
|
-
const response = await this.callTool(name, args);
|
|
2294
|
+
call: async (name, args, options = {}) => {
|
|
2295
|
+
const response = await this.callTool(name, args, this.progressTokenFor(options));
|
|
2147
2296
|
return this.wrapToolResult(response);
|
|
2148
2297
|
}
|
|
2149
2298
|
};
|
|
@@ -2285,6 +2434,7 @@ var McpTestClient = class {
|
|
|
2285
2434
|
* Collect progress notifications specifically
|
|
2286
2435
|
*/
|
|
2287
2436
|
collectProgress: () => {
|
|
2437
|
+
this.collectingProgress = true;
|
|
2288
2438
|
return new ProgressCollector(this._progressUpdates);
|
|
2289
2439
|
},
|
|
2290
2440
|
/**
|
|
@@ -2590,12 +2740,29 @@ var McpTestClient = class {
|
|
|
2590
2740
|
async listTools() {
|
|
2591
2741
|
return this.request("tools/list", {});
|
|
2592
2742
|
}
|
|
2593
|
-
async callTool(name, args) {
|
|
2743
|
+
async callTool(name, args, progressToken) {
|
|
2594
2744
|
return this.request("tools/call", {
|
|
2595
2745
|
name,
|
|
2596
|
-
arguments: args ?? {}
|
|
2746
|
+
arguments: args ?? {},
|
|
2747
|
+
...progressToken !== void 0 && { _meta: { progressToken } }
|
|
2597
2748
|
});
|
|
2598
2749
|
}
|
|
2750
|
+
progressTokenFor(options) {
|
|
2751
|
+
if (options.progressToken !== void 0) return options.progressToken;
|
|
2752
|
+
return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
|
|
2753
|
+
}
|
|
2754
|
+
recordNotification = (notification) => {
|
|
2755
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
2756
|
+
this._notifications.push({ method: notification.method, params: notification.params, timestamp });
|
|
2757
|
+
if (notification.method !== "notifications/progress") return;
|
|
2758
|
+
const { progress, total, progressToken } = notification.params ?? {};
|
|
2759
|
+
this._progressUpdates.push({
|
|
2760
|
+
progress: Number(progress),
|
|
2761
|
+
total: typeof total === "number" ? total : void 0,
|
|
2762
|
+
progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
|
|
2763
|
+
timestamp
|
|
2764
|
+
});
|
|
2765
|
+
};
|
|
2599
2766
|
async listResources() {
|
|
2600
2767
|
return this.request("resources/list", {});
|
|
2601
2768
|
}
|
|
@@ -2617,6 +2784,11 @@ var McpTestClient = class {
|
|
|
2617
2784
|
// ═══════════════════════════════════════════════════════════════════
|
|
2618
2785
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
2619
2786
|
// ═══════════════════════════════════════════════════════════════════
|
|
2787
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2788
|
+
mcpPathLabel() {
|
|
2789
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2790
|
+
return entry ? `/${entry}` : "/";
|
|
2791
|
+
}
|
|
2620
2792
|
createTransport() {
|
|
2621
2793
|
let baseUrl = this.config.baseUrl;
|
|
2622
2794
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -2630,13 +2802,15 @@ var McpTestClient = class {
|
|
|
2630
2802
|
case "streamable-http":
|
|
2631
2803
|
return new StreamableHttpTransport({
|
|
2632
2804
|
baseUrl,
|
|
2805
|
+
entryPath: this.config.entryPath,
|
|
2633
2806
|
timeout: this.config.timeout,
|
|
2634
2807
|
auth: this.config.auth,
|
|
2635
2808
|
publicMode: this.config.publicMode,
|
|
2636
2809
|
debug: this.config.debug,
|
|
2637
2810
|
interceptors: this._interceptors,
|
|
2638
2811
|
clientInfo: this.config.clientInfo,
|
|
2639
|
-
elicitationHandler: this._elicitationHandler
|
|
2812
|
+
elicitationHandler: this._elicitationHandler,
|
|
2813
|
+
notificationHandler: this.recordNotification
|
|
2640
2814
|
});
|
|
2641
2815
|
case "sse":
|
|
2642
2816
|
throw new Error("SSE transport not yet implemented");
|