@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/esm/perf/index.mjs
CHANGED
|
@@ -772,236 +772,281 @@ function getMeasurementsForProject(project) {
|
|
|
772
772
|
return getGlobalMeasurementsArray().filter((m) => m.project === project);
|
|
773
773
|
}
|
|
774
774
|
|
|
775
|
-
// libs/testing/src/
|
|
776
|
-
var
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
name: "mcp-ext-apps",
|
|
787
|
-
version: "1.0"
|
|
788
|
-
};
|
|
789
|
-
case "claude":
|
|
790
|
-
return {
|
|
791
|
-
name: "claude-desktop",
|
|
792
|
-
version: "1.0"
|
|
793
|
-
};
|
|
794
|
-
case "cursor":
|
|
795
|
-
return {
|
|
796
|
-
name: "cursor",
|
|
797
|
-
version: "1.0"
|
|
798
|
-
};
|
|
799
|
-
case "continue":
|
|
800
|
-
return {
|
|
801
|
-
name: "continue",
|
|
802
|
-
version: "1.0"
|
|
803
|
-
};
|
|
804
|
-
case "cody":
|
|
805
|
-
return {
|
|
806
|
-
name: "cody",
|
|
807
|
-
version: "1.0"
|
|
808
|
-
};
|
|
809
|
-
case "gemini":
|
|
810
|
-
return {
|
|
811
|
-
name: "gemini",
|
|
812
|
-
version: "1.0"
|
|
813
|
-
};
|
|
814
|
-
case "generic-mcp":
|
|
815
|
-
return {
|
|
816
|
-
name: "generic-mcp-client",
|
|
817
|
-
version: "1.0"
|
|
818
|
-
};
|
|
819
|
-
case "unknown":
|
|
820
|
-
default:
|
|
821
|
-
return {
|
|
822
|
-
name: "mcp-test-client",
|
|
823
|
-
version: "1.0"
|
|
824
|
-
};
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
function getPlatformCapabilities(platform) {
|
|
828
|
-
const baseCapabilities = {
|
|
829
|
-
sampling: {},
|
|
830
|
-
// Include elicitation.form by default for testing elicitation workflows
|
|
831
|
-
// Note: MCP SDK expects form to be an object, not boolean
|
|
832
|
-
elicitation: {
|
|
833
|
-
form: {}
|
|
834
|
-
}
|
|
835
|
-
};
|
|
836
|
-
if (platform === "ext-apps") {
|
|
775
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
776
|
+
var DefaultMockRegistry = class {
|
|
777
|
+
mocks = [];
|
|
778
|
+
add(mock) {
|
|
779
|
+
const entry = {
|
|
780
|
+
definition: mock,
|
|
781
|
+
callCount: 0,
|
|
782
|
+
calls: [],
|
|
783
|
+
remainingUses: mock.times ?? Infinity
|
|
784
|
+
};
|
|
785
|
+
this.mocks.push(entry);
|
|
837
786
|
return {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
787
|
+
remove: () => {
|
|
788
|
+
const index = this.mocks.indexOf(entry);
|
|
789
|
+
if (index !== -1) {
|
|
790
|
+
this.mocks.splice(index, 1);
|
|
842
791
|
}
|
|
843
|
-
}
|
|
792
|
+
},
|
|
793
|
+
callCount: () => entry.callCount,
|
|
794
|
+
calls: () => [...entry.calls]
|
|
844
795
|
};
|
|
845
796
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
850
|
-
var McpTestClientBuilder = class {
|
|
851
|
-
config;
|
|
852
|
-
constructor(config) {
|
|
853
|
-
this.config = { ...config };
|
|
797
|
+
clear() {
|
|
798
|
+
this.mocks = [];
|
|
854
799
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
800
|
+
getAll() {
|
|
801
|
+
return this.mocks.map((e) => e.definition);
|
|
802
|
+
}
|
|
803
|
+
match(request) {
|
|
804
|
+
for (const entry of this.mocks) {
|
|
805
|
+
if (entry.remainingUses <= 0) continue;
|
|
806
|
+
const { definition } = entry;
|
|
807
|
+
if (definition.method !== request.method) continue;
|
|
808
|
+
if (definition.params !== void 0) {
|
|
809
|
+
const params = request.params ?? {};
|
|
810
|
+
if (typeof definition.params === "function") {
|
|
811
|
+
if (!definition.params(params)) continue;
|
|
812
|
+
} else {
|
|
813
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
entry.callCount++;
|
|
817
|
+
entry.calls.push(request);
|
|
818
|
+
entry.remainingUses--;
|
|
819
|
+
return definition;
|
|
820
|
+
}
|
|
821
|
+
return void 0;
|
|
861
822
|
}
|
|
862
823
|
/**
|
|
863
|
-
*
|
|
824
|
+
* Check if request params match the mock params definition
|
|
864
825
|
*/
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
826
|
+
paramsMatch(expected, actual) {
|
|
827
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
828
|
+
if (!(key in actual)) return false;
|
|
829
|
+
const actualValue = actual[key];
|
|
830
|
+
if (Array.isArray(value)) {
|
|
831
|
+
if (!Array.isArray(actualValue)) return false;
|
|
832
|
+
if (value.length !== actualValue.length) return false;
|
|
833
|
+
for (let i = 0; i < value.length; i++) {
|
|
834
|
+
const expectedItem = value[i];
|
|
835
|
+
const actualItem = actualValue[i];
|
|
836
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
837
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
838
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
} else if (actualItem !== expectedItem) {
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} else if (typeof value === "object" && value !== null) {
|
|
846
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
847
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
} else if (actualValue !== value) {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return true;
|
|
868
855
|
}
|
|
856
|
+
};
|
|
857
|
+
var mockResponse = {
|
|
869
858
|
/**
|
|
870
|
-
*
|
|
859
|
+
* Create a successful JSON-RPC response
|
|
871
860
|
*/
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
861
|
+
success(result, id = 1) {
|
|
862
|
+
return {
|
|
863
|
+
jsonrpc: "2.0",
|
|
864
|
+
id,
|
|
865
|
+
result
|
|
876
866
|
};
|
|
877
|
-
|
|
878
|
-
}
|
|
867
|
+
},
|
|
879
868
|
/**
|
|
880
|
-
*
|
|
869
|
+
* Create an error JSON-RPC response
|
|
881
870
|
*/
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
871
|
+
error(code, message, data, id = 1) {
|
|
872
|
+
return {
|
|
873
|
+
jsonrpc: "2.0",
|
|
874
|
+
id,
|
|
875
|
+
error: { code, message, data }
|
|
876
|
+
};
|
|
877
|
+
},
|
|
886
878
|
/**
|
|
887
|
-
*
|
|
879
|
+
* Create a tool result response
|
|
888
880
|
*/
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
881
|
+
toolResult(content, id = 1) {
|
|
882
|
+
return {
|
|
883
|
+
jsonrpc: "2.0",
|
|
884
|
+
id,
|
|
885
|
+
result: { content }
|
|
886
|
+
};
|
|
887
|
+
},
|
|
893
888
|
/**
|
|
894
|
-
*
|
|
889
|
+
* Create a tools/list response
|
|
895
890
|
*/
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
891
|
+
toolsList(tools, id = 1) {
|
|
892
|
+
return {
|
|
893
|
+
jsonrpc: "2.0",
|
|
894
|
+
id,
|
|
895
|
+
result: { tools }
|
|
896
|
+
};
|
|
897
|
+
},
|
|
900
898
|
/**
|
|
901
|
-
*
|
|
902
|
-
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
903
|
-
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
899
|
+
* Create a resources/list response
|
|
904
900
|
*/
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
901
|
+
resourcesList(resources, id = 1) {
|
|
902
|
+
return {
|
|
903
|
+
jsonrpc: "2.0",
|
|
904
|
+
id,
|
|
905
|
+
result: { resources }
|
|
906
|
+
};
|
|
907
|
+
},
|
|
909
908
|
/**
|
|
910
|
-
*
|
|
909
|
+
* Create a resources/read response
|
|
911
910
|
*/
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
911
|
+
resourceRead(contents, id = 1) {
|
|
912
|
+
return {
|
|
913
|
+
jsonrpc: "2.0",
|
|
914
|
+
id,
|
|
915
|
+
result: { contents }
|
|
916
|
+
};
|
|
917
|
+
},
|
|
916
918
|
/**
|
|
917
|
-
*
|
|
919
|
+
* Common MCP errors
|
|
918
920
|
*/
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
921
|
+
errors: {
|
|
922
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
923
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
924
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
925
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
926
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
927
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
928
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
933
|
+
var DefaultInterceptorChain = class {
|
|
934
|
+
request = [];
|
|
935
|
+
response = [];
|
|
936
|
+
mocks;
|
|
937
|
+
constructor() {
|
|
938
|
+
this.mocks = new DefaultMockRegistry();
|
|
922
939
|
}
|
|
923
940
|
/**
|
|
924
|
-
*
|
|
925
|
-
* Automatically configures clientInfo and capabilities for platform detection.
|
|
926
|
-
*
|
|
927
|
-
* Platform-specific behavior:
|
|
928
|
-
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
929
|
-
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
930
|
-
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
931
|
-
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
932
|
-
* - Other platforms follow similar patterns
|
|
933
|
-
*
|
|
934
|
-
* @example
|
|
935
|
-
* ```typescript
|
|
936
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
937
|
-
* .withPlatform('openai')
|
|
938
|
-
* .buildAndConnect();
|
|
939
|
-
*
|
|
940
|
-
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
941
|
-
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
942
|
-
* .withPlatform('ext-apps')
|
|
943
|
-
* .buildAndConnect();
|
|
944
|
-
* ```
|
|
941
|
+
* Add a request interceptor
|
|
945
942
|
*/
|
|
946
|
-
|
|
947
|
-
this.
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
943
|
+
addRequestInterceptor(interceptor) {
|
|
944
|
+
this.request.push(interceptor);
|
|
945
|
+
return () => {
|
|
946
|
+
const index = this.request.indexOf(interceptor);
|
|
947
|
+
if (index !== -1) {
|
|
948
|
+
this.request.splice(index, 1);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
951
|
}
|
|
952
952
|
/**
|
|
953
|
-
*
|
|
954
|
-
* Use this for fine-grained control over capabilities sent during initialization.
|
|
955
|
-
*
|
|
956
|
-
* @example
|
|
957
|
-
* ```typescript
|
|
958
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
959
|
-
* .withCapabilities({
|
|
960
|
-
* sampling: {},
|
|
961
|
-
* experimental: {
|
|
962
|
-
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
963
|
-
* }
|
|
964
|
-
* })
|
|
965
|
-
* .buildAndConnect();
|
|
966
|
-
* ```
|
|
953
|
+
* Add a response interceptor
|
|
967
954
|
*/
|
|
968
|
-
|
|
969
|
-
this.
|
|
970
|
-
return
|
|
955
|
+
addResponseInterceptor(interceptor) {
|
|
956
|
+
this.response.push(interceptor);
|
|
957
|
+
return () => {
|
|
958
|
+
const index = this.response.indexOf(interceptor);
|
|
959
|
+
if (index !== -1) {
|
|
960
|
+
this.response.splice(index, 1);
|
|
961
|
+
}
|
|
962
|
+
};
|
|
971
963
|
}
|
|
972
964
|
/**
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
979
|
-
* .withQueryParams({ mode: 'skills_only' })
|
|
980
|
-
* .buildAndConnect();
|
|
981
|
-
* ```
|
|
965
|
+
* Process a request through the interceptor chain
|
|
966
|
+
* Returns either:
|
|
967
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
968
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
969
|
+
* - { type: 'error', error } - throw error
|
|
982
970
|
*/
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
971
|
+
async processRequest(request, meta) {
|
|
972
|
+
let currentRequest = request;
|
|
973
|
+
const mockDef = this.mocks.match(request);
|
|
974
|
+
if (mockDef) {
|
|
975
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
976
|
+
await sleep3(mockDef.delay);
|
|
977
|
+
}
|
|
978
|
+
let mockResponse2;
|
|
979
|
+
if (typeof mockDef.response === "function") {
|
|
980
|
+
mockResponse2 = await mockDef.response(request);
|
|
981
|
+
} else {
|
|
982
|
+
mockResponse2 = mockDef.response;
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
type: "mock",
|
|
986
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
for (const interceptor of this.request) {
|
|
990
|
+
const ctx = {
|
|
991
|
+
request: currentRequest,
|
|
992
|
+
meta
|
|
993
|
+
};
|
|
994
|
+
const result = await interceptor(ctx);
|
|
995
|
+
switch (result.action) {
|
|
996
|
+
case "passthrough":
|
|
997
|
+
break;
|
|
998
|
+
case "modify":
|
|
999
|
+
currentRequest = result.request;
|
|
1000
|
+
break;
|
|
1001
|
+
case "mock":
|
|
1002
|
+
return {
|
|
1003
|
+
type: "mock",
|
|
1004
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1005
|
+
};
|
|
1006
|
+
case "error":
|
|
1007
|
+
return { type: "error", error: result.error };
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return { type: "continue", request: currentRequest };
|
|
986
1011
|
}
|
|
987
1012
|
/**
|
|
988
|
-
*
|
|
1013
|
+
* Process a response through the interceptor chain
|
|
989
1014
|
*/
|
|
990
|
-
|
|
991
|
-
|
|
1015
|
+
async processResponse(request, response, durationMs) {
|
|
1016
|
+
let currentResponse = response;
|
|
1017
|
+
for (const interceptor of this.response) {
|
|
1018
|
+
const ctx = {
|
|
1019
|
+
request,
|
|
1020
|
+
response: currentResponse,
|
|
1021
|
+
durationMs
|
|
1022
|
+
};
|
|
1023
|
+
const result = await interceptor(ctx);
|
|
1024
|
+
switch (result.action) {
|
|
1025
|
+
case "passthrough":
|
|
1026
|
+
break;
|
|
1027
|
+
case "modify":
|
|
1028
|
+
currentResponse = result.response;
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return currentResponse;
|
|
992
1033
|
}
|
|
993
1034
|
/**
|
|
994
|
-
*
|
|
1035
|
+
* Clear all interceptors and mocks
|
|
995
1036
|
*/
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1037
|
+
clear() {
|
|
1038
|
+
this.request = [];
|
|
1039
|
+
this.response = [];
|
|
1040
|
+
this.mocks.clear();
|
|
1000
1041
|
}
|
|
1001
1042
|
};
|
|
1043
|
+
function sleep3(ms) {
|
|
1044
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1045
|
+
}
|
|
1002
1046
|
|
|
1003
1047
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
1004
1048
|
var DEFAULT_TIMEOUT = 3e4;
|
|
1049
|
+
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
1005
1050
|
var StreamableHttpTransport = class {
|
|
1006
1051
|
config;
|
|
1007
1052
|
state = "disconnected";
|
|
@@ -1013,10 +1058,15 @@ var StreamableHttpTransport = class {
|
|
|
1013
1058
|
interceptors;
|
|
1014
1059
|
publicMode;
|
|
1015
1060
|
elicitationHandler;
|
|
1061
|
+
notificationHandler;
|
|
1062
|
+
notificationStream;
|
|
1063
|
+
notificationProtocolVersion = "";
|
|
1064
|
+
notificationStreamReopenTimer;
|
|
1016
1065
|
constructor(config) {
|
|
1017
1066
|
this.config = {
|
|
1018
1067
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
1019
1068
|
// Remove trailing slash
|
|
1069
|
+
entryPath: config.entryPath,
|
|
1020
1070
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
1021
1071
|
auth: config.auth ?? {},
|
|
1022
1072
|
publicMode: config.publicMode ?? false,
|
|
@@ -1028,6 +1078,7 @@ var StreamableHttpTransport = class {
|
|
|
1028
1078
|
this.interceptors = config.interceptors;
|
|
1029
1079
|
this.publicMode = config.publicMode ?? false;
|
|
1030
1080
|
this.elicitationHandler = config.elicitationHandler;
|
|
1081
|
+
this.notificationHandler = config.notificationHandler;
|
|
1031
1082
|
}
|
|
1032
1083
|
async connect() {
|
|
1033
1084
|
this.state = "connecting";
|
|
@@ -1109,7 +1160,7 @@ var StreamableHttpTransport = class {
|
|
|
1109
1160
|
}
|
|
1110
1161
|
const headers = this.buildHeaders();
|
|
1111
1162
|
this.lastRequestHeaders = headers;
|
|
1112
|
-
const url =
|
|
1163
|
+
const url = this.mcpUrl();
|
|
1113
1164
|
this.log(`POST ${url}`, message);
|
|
1114
1165
|
const controller = new AbortController();
|
|
1115
1166
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -1179,7 +1230,7 @@ var StreamableHttpTransport = class {
|
|
|
1179
1230
|
this.ensureConnected();
|
|
1180
1231
|
const headers = this.buildHeaders();
|
|
1181
1232
|
this.lastRequestHeaders = headers;
|
|
1182
|
-
const url =
|
|
1233
|
+
const url = this.mcpUrl();
|
|
1183
1234
|
this.log(`POST ${url} (notification)`, message);
|
|
1184
1235
|
const controller = new AbortController();
|
|
1185
1236
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -1210,7 +1261,7 @@ var StreamableHttpTransport = class {
|
|
|
1210
1261
|
this.ensureConnected();
|
|
1211
1262
|
const headers = this.buildHeaders();
|
|
1212
1263
|
this.lastRequestHeaders = headers;
|
|
1213
|
-
const url =
|
|
1264
|
+
const url = this.mcpUrl();
|
|
1214
1265
|
this.log(`POST ${url} (raw)`, data);
|
|
1215
1266
|
const controller = new AbortController();
|
|
1216
1267
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -1247,7 +1298,12 @@ var StreamableHttpTransport = class {
|
|
|
1247
1298
|
};
|
|
1248
1299
|
}
|
|
1249
1300
|
}
|
|
1301
|
+
async openNotificationStream(protocolVersion) {
|
|
1302
|
+
this.notificationProtocolVersion = protocolVersion;
|
|
1303
|
+
await this.connectNotificationStream();
|
|
1304
|
+
}
|
|
1250
1305
|
async close() {
|
|
1306
|
+
this.closeNotificationStream();
|
|
1251
1307
|
this.state = "disconnected";
|
|
1252
1308
|
this.sessionId = void 0;
|
|
1253
1309
|
this.log("StreamableHTTP transport closed");
|
|
@@ -1286,6 +1342,7 @@ var StreamableHttpTransport = class {
|
|
|
1286
1342
|
return { ...this.lastRequestHeaders };
|
|
1287
1343
|
}
|
|
1288
1344
|
async simulateDisconnect() {
|
|
1345
|
+
this.closeNotificationStream();
|
|
1289
1346
|
this.state = "disconnected";
|
|
1290
1347
|
this.sessionId = void 0;
|
|
1291
1348
|
}
|
|
@@ -1304,6 +1361,72 @@ var StreamableHttpTransport = class {
|
|
|
1304
1361
|
// ═══════════════════════════════════════════════════════════════════
|
|
1305
1362
|
// PRIVATE HELPERS
|
|
1306
1363
|
// ═══════════════════════════════════════════════════════════════════
|
|
1364
|
+
closeNotificationStream() {
|
|
1365
|
+
clearTimeout(this.notificationStreamReopenTimer);
|
|
1366
|
+
this.notificationStream?.abort();
|
|
1367
|
+
this.notificationStream = void 0;
|
|
1368
|
+
}
|
|
1369
|
+
/** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
|
|
1370
|
+
async connectNotificationStream() {
|
|
1371
|
+
if (!this.sessionId || this.notificationStream) return;
|
|
1372
|
+
const controller = new AbortController();
|
|
1373
|
+
this.notificationStream = controller;
|
|
1374
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
1375
|
+
const established = fetch(this.mcpUrl(), {
|
|
1376
|
+
method: "GET",
|
|
1377
|
+
headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
|
|
1378
|
+
signal: controller.signal
|
|
1379
|
+
}).then(
|
|
1380
|
+
async (response) => {
|
|
1381
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
1382
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
1383
|
+
await response.body?.cancel();
|
|
1384
|
+
this.log(`Server offers no notification stream (HTTP ${response.status})`);
|
|
1385
|
+
this.releaseNotificationStream(controller);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
void this.readNotificationStream(controller, response.body.getReader());
|
|
1389
|
+
},
|
|
1390
|
+
(error) => {
|
|
1391
|
+
this.log("Failed to open the notification stream:", error);
|
|
1392
|
+
this.releaseNotificationStream(controller);
|
|
1393
|
+
}
|
|
1394
|
+
);
|
|
1395
|
+
let establishTimer;
|
|
1396
|
+
const establishDeadline = new Promise((resolve) => {
|
|
1397
|
+
establishTimer = setTimeout(resolve, this.config.timeout);
|
|
1398
|
+
});
|
|
1399
|
+
await Promise.race([established, establishDeadline]);
|
|
1400
|
+
clearTimeout(establishTimer);
|
|
1401
|
+
}
|
|
1402
|
+
releaseNotificationStream(controller) {
|
|
1403
|
+
if (this.notificationStream === controller) this.notificationStream = void 0;
|
|
1404
|
+
}
|
|
1405
|
+
async readNotificationStream(controller, reader) {
|
|
1406
|
+
const decoder = new TextDecoder();
|
|
1407
|
+
let buffer = "";
|
|
1408
|
+
try {
|
|
1409
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
1410
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
1411
|
+
buffer = remainder;
|
|
1412
|
+
for (const eventText of completeEvents) {
|
|
1413
|
+
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
1414
|
+
await this.handleSSEEvent(event);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
this.log("Notification stream ended:", error);
|
|
1420
|
+
} finally {
|
|
1421
|
+
reader.releaseLock();
|
|
1422
|
+
}
|
|
1423
|
+
if (controller.signal.aborted || this.notificationStream !== controller) return;
|
|
1424
|
+
this.releaseNotificationStream(controller);
|
|
1425
|
+
this.notificationStreamReopenTimer = setTimeout(
|
|
1426
|
+
() => void this.connectNotificationStream(),
|
|
1427
|
+
NOTIFICATION_STREAM_REOPEN_DELAY_MS
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1307
1430
|
/**
|
|
1308
1431
|
* Handle SSE response with elicitation support.
|
|
1309
1432
|
*
|
|
@@ -1441,6 +1564,13 @@ var StreamableHttpTransport = class {
|
|
|
1441
1564
|
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1442
1565
|
};
|
|
1443
1566
|
}
|
|
1567
|
+
if ("method" in parsed && parsed.id === void 0) {
|
|
1568
|
+
this.notificationHandler?.(parsed);
|
|
1569
|
+
return {
|
|
1570
|
+
isFinal: false,
|
|
1571
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1444
1574
|
if ("result" in parsed || "error" in parsed) {
|
|
1445
1575
|
return { isFinal: true, response: parsed };
|
|
1446
1576
|
}
|
|
@@ -1493,7 +1623,7 @@ var StreamableHttpTransport = class {
|
|
|
1493
1623
|
*/
|
|
1494
1624
|
async sendElicitationResponse(requestId, response) {
|
|
1495
1625
|
const headers = this.buildHeaders();
|
|
1496
|
-
const url =
|
|
1626
|
+
const url = this.mcpUrl();
|
|
1497
1627
|
const rpcResponse = {
|
|
1498
1628
|
jsonrpc: "2.0",
|
|
1499
1629
|
id: requestId,
|
|
@@ -1513,6 +1643,18 @@ var StreamableHttpTransport = class {
|
|
|
1513
1643
|
this.log("Failed to send elicitation response:", error);
|
|
1514
1644
|
}
|
|
1515
1645
|
}
|
|
1646
|
+
/**
|
|
1647
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
1648
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
1649
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
1650
|
+
*/
|
|
1651
|
+
mcpUrl() {
|
|
1652
|
+
const url = new URL(this.config.baseUrl);
|
|
1653
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
1654
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1655
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
1656
|
+
return url.toString();
|
|
1657
|
+
}
|
|
1516
1658
|
buildHeaders() {
|
|
1517
1659
|
const headers = {
|
|
1518
1660
|
"Content-Type": "application/json",
|
|
@@ -1599,286 +1741,266 @@ var StreamableHttpTransport = class {
|
|
|
1599
1741
|
};
|
|
1600
1742
|
}
|
|
1601
1743
|
};
|
|
1744
|
+
function splitSSEEvents(buffer) {
|
|
1745
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1746
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1747
|
+
const completeEvents = normalized.split("\n\n");
|
|
1748
|
+
const remainder = completeEvents.pop() ?? "";
|
|
1749
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
1750
|
+
}
|
|
1602
1751
|
|
|
1603
|
-
// libs/testing/src/
|
|
1604
|
-
var
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1752
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
1753
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1754
|
+
function getPlatformClientInfo(platform) {
|
|
1755
|
+
switch (platform) {
|
|
1756
|
+
case "openai":
|
|
1757
|
+
return {
|
|
1758
|
+
name: "ChatGPT",
|
|
1759
|
+
version: "1.0"
|
|
1760
|
+
};
|
|
1761
|
+
case "ext-apps":
|
|
1762
|
+
return {
|
|
1763
|
+
name: "mcp-ext-apps",
|
|
1764
|
+
version: "1.0"
|
|
1765
|
+
};
|
|
1766
|
+
case "claude":
|
|
1767
|
+
return {
|
|
1768
|
+
name: "claude-desktop",
|
|
1769
|
+
version: "1.0"
|
|
1770
|
+
};
|
|
1771
|
+
case "cursor":
|
|
1772
|
+
return {
|
|
1773
|
+
name: "cursor",
|
|
1774
|
+
version: "1.0"
|
|
1775
|
+
};
|
|
1776
|
+
case "continue":
|
|
1777
|
+
return {
|
|
1778
|
+
name: "continue",
|
|
1779
|
+
version: "1.0"
|
|
1780
|
+
};
|
|
1781
|
+
case "cody":
|
|
1782
|
+
return {
|
|
1783
|
+
name: "cody",
|
|
1784
|
+
version: "1.0"
|
|
1785
|
+
};
|
|
1786
|
+
case "gemini":
|
|
1787
|
+
return {
|
|
1788
|
+
name: "gemini",
|
|
1789
|
+
version: "1.0"
|
|
1790
|
+
};
|
|
1791
|
+
case "generic-mcp":
|
|
1792
|
+
return {
|
|
1793
|
+
name: "generic-mcp-client",
|
|
1794
|
+
version: "1.0"
|
|
1795
|
+
};
|
|
1796
|
+
case "unknown":
|
|
1797
|
+
default:
|
|
1798
|
+
return {
|
|
1799
|
+
name: "mcp-test-client",
|
|
1800
|
+
version: "1.0"
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
function getPlatformCapabilities(platform) {
|
|
1805
|
+
const baseCapabilities = {
|
|
1806
|
+
sampling: {},
|
|
1807
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1808
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1809
|
+
elicitation: {
|
|
1810
|
+
form: {}
|
|
1811
|
+
}
|
|
1812
|
+
};
|
|
1813
|
+
if (platform === "ext-apps") {
|
|
1614
1814
|
return {
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1815
|
+
...baseCapabilities,
|
|
1816
|
+
experimental: {
|
|
1817
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1818
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
1619
1819
|
}
|
|
1620
|
-
}
|
|
1621
|
-
callCount: () => entry.callCount,
|
|
1622
|
-
calls: () => [...entry.calls]
|
|
1820
|
+
}
|
|
1623
1821
|
};
|
|
1624
1822
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
if (entry.remainingUses <= 0) continue;
|
|
1634
|
-
const { definition } = entry;
|
|
1635
|
-
if (definition.method !== request.method) continue;
|
|
1636
|
-
if (definition.params !== void 0) {
|
|
1637
|
-
const params = request.params ?? {};
|
|
1638
|
-
if (typeof definition.params === "function") {
|
|
1639
|
-
if (!definition.params(params)) continue;
|
|
1640
|
-
} else {
|
|
1641
|
-
if (!this.paramsMatch(definition.params, params)) continue;
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
entry.callCount++;
|
|
1645
|
-
entry.calls.push(request);
|
|
1646
|
-
entry.remainingUses--;
|
|
1647
|
-
return definition;
|
|
1648
|
-
}
|
|
1649
|
-
return void 0;
|
|
1823
|
+
return baseCapabilities;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1827
|
+
var McpTestClientBuilder = class {
|
|
1828
|
+
config;
|
|
1829
|
+
constructor(config) {
|
|
1830
|
+
this.config = { ...config };
|
|
1650
1831
|
}
|
|
1651
1832
|
/**
|
|
1652
|
-
*
|
|
1833
|
+
* Set the authentication configuration
|
|
1653
1834
|
*/
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
const actualValue = actual[key];
|
|
1658
|
-
if (Array.isArray(value)) {
|
|
1659
|
-
if (!Array.isArray(actualValue)) return false;
|
|
1660
|
-
if (value.length !== actualValue.length) return false;
|
|
1661
|
-
for (let i = 0; i < value.length; i++) {
|
|
1662
|
-
const expectedItem = value[i];
|
|
1663
|
-
const actualItem = actualValue[i];
|
|
1664
|
-
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
1665
|
-
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
1666
|
-
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
1667
|
-
return false;
|
|
1668
|
-
}
|
|
1669
|
-
} else if (actualItem !== expectedItem) {
|
|
1670
|
-
return false;
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
} else if (typeof value === "object" && value !== null) {
|
|
1674
|
-
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
1675
|
-
if (!this.paramsMatch(value, actualValue)) {
|
|
1676
|
-
return false;
|
|
1677
|
-
}
|
|
1678
|
-
} else if (actualValue !== value) {
|
|
1679
|
-
return false;
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
return true;
|
|
1835
|
+
withAuth(auth) {
|
|
1836
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1837
|
+
return this;
|
|
1683
1838
|
}
|
|
1684
|
-
};
|
|
1685
|
-
var mockResponse = {
|
|
1686
1839
|
/**
|
|
1687
|
-
*
|
|
1840
|
+
* Set the bearer token for authentication
|
|
1688
1841
|
*/
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
result
|
|
1694
|
-
};
|
|
1695
|
-
},
|
|
1842
|
+
withToken(token) {
|
|
1843
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1844
|
+
return this;
|
|
1845
|
+
}
|
|
1696
1846
|
/**
|
|
1697
|
-
*
|
|
1847
|
+
* Add custom headers to all requests
|
|
1698
1848
|
*/
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
error: { code, message, data }
|
|
1849
|
+
withHeaders(headers) {
|
|
1850
|
+
this.config.auth = {
|
|
1851
|
+
...this.config.auth,
|
|
1852
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1704
1853
|
};
|
|
1705
|
-
|
|
1854
|
+
return this;
|
|
1855
|
+
}
|
|
1706
1856
|
/**
|
|
1707
|
-
*
|
|
1857
|
+
* Set the transport type
|
|
1708
1858
|
*/
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
result: { content }
|
|
1714
|
-
};
|
|
1715
|
-
},
|
|
1859
|
+
withTransport(transport) {
|
|
1860
|
+
this.config.transport = transport;
|
|
1861
|
+
return this;
|
|
1862
|
+
}
|
|
1716
1863
|
/**
|
|
1717
|
-
*
|
|
1864
|
+
* Set the request timeout in milliseconds
|
|
1718
1865
|
*/
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
result: { tools }
|
|
1724
|
-
};
|
|
1725
|
-
},
|
|
1866
|
+
withTimeout(timeoutMs) {
|
|
1867
|
+
this.config.timeout = timeoutMs;
|
|
1868
|
+
return this;
|
|
1869
|
+
}
|
|
1726
1870
|
/**
|
|
1727
|
-
*
|
|
1871
|
+
* Enable debug logging
|
|
1728
1872
|
*/
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
result: { resources }
|
|
1734
|
-
};
|
|
1735
|
-
},
|
|
1873
|
+
withDebug(enabled = true) {
|
|
1874
|
+
this.config.debug = enabled;
|
|
1875
|
+
return this;
|
|
1876
|
+
}
|
|
1736
1877
|
/**
|
|
1737
|
-
*
|
|
1878
|
+
* Enable public mode - skip authentication entirely.
|
|
1879
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1880
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1738
1881
|
*/
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
result: { contents }
|
|
1744
|
-
};
|
|
1745
|
-
},
|
|
1882
|
+
withPublicMode(enabled = true) {
|
|
1883
|
+
this.config.publicMode = enabled;
|
|
1884
|
+
return this;
|
|
1885
|
+
}
|
|
1746
1886
|
/**
|
|
1747
|
-
*
|
|
1887
|
+
* Set the MCP protocol version to request (default '2025-06-18').
|
|
1888
|
+
* '2026-07-28' has no initialize handshake, so `build()` throws for it.
|
|
1748
1889
|
*/
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
1753
|
-
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
1754
|
-
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
1755
|
-
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
1756
|
-
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
1890
|
+
withProtocolVersion(version) {
|
|
1891
|
+
this.config.protocolVersion = version;
|
|
1892
|
+
return this;
|
|
1757
1893
|
}
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
mocks;
|
|
1765
|
-
constructor() {
|
|
1766
|
-
this.mocks = new DefaultMockRegistry();
|
|
1894
|
+
/**
|
|
1895
|
+
* Set the client info sent during initialization
|
|
1896
|
+
*/
|
|
1897
|
+
withClientInfo(info) {
|
|
1898
|
+
this.config.clientInfo = info;
|
|
1899
|
+
return this;
|
|
1767
1900
|
}
|
|
1768
1901
|
/**
|
|
1769
|
-
*
|
|
1902
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1903
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1904
|
+
*
|
|
1905
|
+
* Platform-specific behavior:
|
|
1906
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1907
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1908
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1909
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1910
|
+
* - Other platforms follow similar patterns
|
|
1911
|
+
*
|
|
1912
|
+
* @example
|
|
1913
|
+
* ```typescript
|
|
1914
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1915
|
+
* .withPlatform('openai')
|
|
1916
|
+
* .buildAndConnect();
|
|
1917
|
+
*
|
|
1918
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1919
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1920
|
+
* .withPlatform('ext-apps')
|
|
1921
|
+
* .buildAndConnect();
|
|
1922
|
+
* ```
|
|
1770
1923
|
*/
|
|
1771
|
-
|
|
1772
|
-
this.
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
this.request.splice(index, 1);
|
|
1777
|
-
}
|
|
1778
|
-
};
|
|
1924
|
+
withPlatform(platform) {
|
|
1925
|
+
this.config.platform = platform;
|
|
1926
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1927
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1928
|
+
return this;
|
|
1779
1929
|
}
|
|
1780
1930
|
/**
|
|
1781
|
-
*
|
|
1931
|
+
* Set custom client capabilities for MCP initialization.
|
|
1932
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1933
|
+
*
|
|
1934
|
+
* @example
|
|
1935
|
+
* ```typescript
|
|
1936
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1937
|
+
* .withCapabilities({
|
|
1938
|
+
* sampling: {},
|
|
1939
|
+
* experimental: {
|
|
1940
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1941
|
+
* }
|
|
1942
|
+
* })
|
|
1943
|
+
* .buildAndConnect();
|
|
1944
|
+
* ```
|
|
1782
1945
|
*/
|
|
1783
|
-
|
|
1784
|
-
this.
|
|
1785
|
-
return
|
|
1786
|
-
const index = this.response.indexOf(interceptor);
|
|
1787
|
-
if (index !== -1) {
|
|
1788
|
-
this.response.splice(index, 1);
|
|
1789
|
-
}
|
|
1790
|
-
};
|
|
1946
|
+
withCapabilities(capabilities) {
|
|
1947
|
+
this.config.capabilities = capabilities;
|
|
1948
|
+
return this;
|
|
1791
1949
|
}
|
|
1792
1950
|
/**
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
1797
|
-
*
|
|
1951
|
+
* Set query parameters to append to the connection URL.
|
|
1952
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1953
|
+
*
|
|
1954
|
+
* @example
|
|
1955
|
+
* ```typescript
|
|
1956
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1957
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
1958
|
+
* .buildAndConnect();
|
|
1959
|
+
* ```
|
|
1798
1960
|
*/
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
if (mockDef) {
|
|
1803
|
-
if (mockDef.delay && mockDef.delay > 0) {
|
|
1804
|
-
await sleep3(mockDef.delay);
|
|
1805
|
-
}
|
|
1806
|
-
let mockResponse2;
|
|
1807
|
-
if (typeof mockDef.response === "function") {
|
|
1808
|
-
mockResponse2 = await mockDef.response(request);
|
|
1809
|
-
} else {
|
|
1810
|
-
mockResponse2 = mockDef.response;
|
|
1811
|
-
}
|
|
1812
|
-
return {
|
|
1813
|
-
type: "mock",
|
|
1814
|
-
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1815
|
-
};
|
|
1816
|
-
}
|
|
1817
|
-
for (const interceptor of this.request) {
|
|
1818
|
-
const ctx = {
|
|
1819
|
-
request: currentRequest,
|
|
1820
|
-
meta
|
|
1821
|
-
};
|
|
1822
|
-
const result = await interceptor(ctx);
|
|
1823
|
-
switch (result.action) {
|
|
1824
|
-
case "passthrough":
|
|
1825
|
-
break;
|
|
1826
|
-
case "modify":
|
|
1827
|
-
currentRequest = result.request;
|
|
1828
|
-
break;
|
|
1829
|
-
case "mock":
|
|
1830
|
-
return {
|
|
1831
|
-
type: "mock",
|
|
1832
|
-
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1833
|
-
};
|
|
1834
|
-
case "error":
|
|
1835
|
-
return { type: "error", error: result.error };
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
return { type: "continue", request: currentRequest };
|
|
1961
|
+
withQueryParams(params) {
|
|
1962
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1963
|
+
return this;
|
|
1839
1964
|
}
|
|
1840
1965
|
/**
|
|
1841
|
-
*
|
|
1966
|
+
* Build the McpTestClient instance (does not connect)
|
|
1842
1967
|
*/
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
for (const interceptor of this.response) {
|
|
1846
|
-
const ctx = {
|
|
1847
|
-
request,
|
|
1848
|
-
response: currentResponse,
|
|
1849
|
-
durationMs
|
|
1850
|
-
};
|
|
1851
|
-
const result = await interceptor(ctx);
|
|
1852
|
-
switch (result.action) {
|
|
1853
|
-
case "passthrough":
|
|
1854
|
-
break;
|
|
1855
|
-
case "modify":
|
|
1856
|
-
currentResponse = result.response;
|
|
1857
|
-
break;
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
return currentResponse;
|
|
1968
|
+
build() {
|
|
1969
|
+
return new McpTestClient(this.config);
|
|
1861
1970
|
}
|
|
1862
1971
|
/**
|
|
1863
|
-
*
|
|
1972
|
+
* Build the McpTestClient and connect to the server
|
|
1864
1973
|
*/
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1974
|
+
async buildAndConnect() {
|
|
1975
|
+
const client = this.build();
|
|
1976
|
+
await client.connect();
|
|
1977
|
+
return client;
|
|
1869
1978
|
}
|
|
1870
1979
|
};
|
|
1871
|
-
function sleep3(ms) {
|
|
1872
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1873
|
-
}
|
|
1874
1980
|
|
|
1875
1981
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1876
1982
|
var DEFAULT_TIMEOUT2 = 3e4;
|
|
1877
1983
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1984
|
+
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1878
1985
|
var DEFAULT_CLIENT_INFO = {
|
|
1879
1986
|
name: "@frontmcp/testing",
|
|
1880
1987
|
version: "0.4.0"
|
|
1881
1988
|
};
|
|
1989
|
+
function extractEntryPaths(data) {
|
|
1990
|
+
let payload = data;
|
|
1991
|
+
if (typeof payload === "string") {
|
|
1992
|
+
try {
|
|
1993
|
+
payload = JSON.parse(payload);
|
|
1994
|
+
} catch {
|
|
1995
|
+
return void 0;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
1999
|
+
const paths = payload.entryPaths;
|
|
2000
|
+
if (!Array.isArray(paths)) return void 0;
|
|
2001
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
2002
|
+
return strings.length > 0 ? strings : void 0;
|
|
2003
|
+
}
|
|
1882
2004
|
var McpTestClient = class {
|
|
1883
2005
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1884
2006
|
config;
|
|
@@ -1887,6 +2009,8 @@ var McpTestClient = class {
|
|
|
1887
2009
|
requestIdCounter = 0;
|
|
1888
2010
|
_lastRequestId = 0;
|
|
1889
2011
|
_sessionId;
|
|
2012
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
2013
|
+
triedDiscoveredEntryPath = false;
|
|
1890
2014
|
_sessionInfo = null;
|
|
1891
2015
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1892
2016
|
// Logging and tracing
|
|
@@ -1894,6 +2018,8 @@ var McpTestClient = class {
|
|
|
1894
2018
|
_traces = [];
|
|
1895
2019
|
_notifications = [];
|
|
1896
2020
|
_progressUpdates = [];
|
|
2021
|
+
collectingProgress = false;
|
|
2022
|
+
progressTokenCounter = 0;
|
|
1897
2023
|
// Interceptor chain
|
|
1898
2024
|
_interceptors;
|
|
1899
2025
|
// Elicitation handler for server→client elicit requests
|
|
@@ -1902,8 +2028,14 @@ var McpTestClient = class {
|
|
|
1902
2028
|
// CONSTRUCTOR & FACTORY
|
|
1903
2029
|
// ═══════════════════════════════════════════════════════════════════
|
|
1904
2030
|
constructor(config) {
|
|
2031
|
+
if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
|
|
2032
|
+
throw new Error(
|
|
2033
|
+
`McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
1905
2036
|
this.config = {
|
|
1906
2037
|
baseUrl: config.baseUrl,
|
|
2038
|
+
entryPath: config.entryPath,
|
|
1907
2039
|
transport: config.transport ?? "streamable-http",
|
|
1908
2040
|
auth: config.auth ?? {},
|
|
1909
2041
|
publicMode: config.publicMode ?? false,
|
|
@@ -1939,11 +2071,13 @@ var McpTestClient = class {
|
|
|
1939
2071
|
*/
|
|
1940
2072
|
async connect() {
|
|
1941
2073
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
2074
|
+
this.triedDiscoveredEntryPath = false;
|
|
1942
2075
|
this.transport = this.createTransport();
|
|
1943
2076
|
await this.transport.connect();
|
|
1944
2077
|
const maxRetries = 3;
|
|
1945
2078
|
const retryDelayMs = 500;
|
|
1946
2079
|
let lastError;
|
|
2080
|
+
let reportedEntryPaths;
|
|
1947
2081
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1948
2082
|
const initResponse = await this.initialize();
|
|
1949
2083
|
if (initResponse.success && initResponse.data) {
|
|
@@ -1951,13 +2085,27 @@ var McpTestClient = class {
|
|
|
1951
2085
|
break;
|
|
1952
2086
|
}
|
|
1953
2087
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
2088
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
2089
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
2090
|
+
this.triedDiscoveredEntryPath = true;
|
|
2091
|
+
const discovered = reportedEntryPaths[0];
|
|
2092
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
2093
|
+
this.config.entryPath = discovered;
|
|
2094
|
+
await this.transport.close();
|
|
2095
|
+
this.transport = this.createTransport();
|
|
2096
|
+
await this.transport.connect();
|
|
2097
|
+
continue;
|
|
2098
|
+
}
|
|
1954
2099
|
if (attempt < maxRetries) {
|
|
1955
2100
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1956
2101
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1957
2102
|
}
|
|
1958
2103
|
}
|
|
1959
2104
|
if (!this.initResult) {
|
|
1960
|
-
|
|
2105
|
+
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.` : "";
|
|
2106
|
+
throw new Error(
|
|
2107
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
2108
|
+
);
|
|
1961
2109
|
}
|
|
1962
2110
|
this._sessionId = this.transport.getSessionId();
|
|
1963
2111
|
this._sessionInfo = {
|
|
@@ -1970,6 +2118,7 @@ var McpTestClient = class {
|
|
|
1970
2118
|
jsonrpc: "2.0",
|
|
1971
2119
|
method: "notifications/initialized"
|
|
1972
2120
|
});
|
|
2121
|
+
await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
|
|
1973
2122
|
this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
|
|
1974
2123
|
return this.initResult;
|
|
1975
2124
|
}
|
|
@@ -2080,8 +2229,8 @@ var McpTestClient = class {
|
|
|
2080
2229
|
/**
|
|
2081
2230
|
* Call a tool by name with arguments
|
|
2082
2231
|
*/
|
|
2083
|
-
call: async (name, args) => {
|
|
2084
|
-
const response = await this.callTool(name, args);
|
|
2232
|
+
call: async (name, args, options = {}) => {
|
|
2233
|
+
const response = await this.callTool(name, args, this.progressTokenFor(options));
|
|
2085
2234
|
return this.wrapToolResult(response);
|
|
2086
2235
|
}
|
|
2087
2236
|
};
|
|
@@ -2223,6 +2372,7 @@ var McpTestClient = class {
|
|
|
2223
2372
|
* Collect progress notifications specifically
|
|
2224
2373
|
*/
|
|
2225
2374
|
collectProgress: () => {
|
|
2375
|
+
this.collectingProgress = true;
|
|
2226
2376
|
return new ProgressCollector(this._progressUpdates);
|
|
2227
2377
|
},
|
|
2228
2378
|
/**
|
|
@@ -2528,12 +2678,29 @@ var McpTestClient = class {
|
|
|
2528
2678
|
async listTools() {
|
|
2529
2679
|
return this.request("tools/list", {});
|
|
2530
2680
|
}
|
|
2531
|
-
async callTool(name, args) {
|
|
2681
|
+
async callTool(name, args, progressToken) {
|
|
2532
2682
|
return this.request("tools/call", {
|
|
2533
2683
|
name,
|
|
2534
|
-
arguments: args ?? {}
|
|
2684
|
+
arguments: args ?? {},
|
|
2685
|
+
...progressToken !== void 0 && { _meta: { progressToken } }
|
|
2535
2686
|
});
|
|
2536
2687
|
}
|
|
2688
|
+
progressTokenFor(options) {
|
|
2689
|
+
if (options.progressToken !== void 0) return options.progressToken;
|
|
2690
|
+
return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
|
|
2691
|
+
}
|
|
2692
|
+
recordNotification = (notification) => {
|
|
2693
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
2694
|
+
this._notifications.push({ method: notification.method, params: notification.params, timestamp });
|
|
2695
|
+
if (notification.method !== "notifications/progress") return;
|
|
2696
|
+
const { progress, total, progressToken } = notification.params ?? {};
|
|
2697
|
+
this._progressUpdates.push({
|
|
2698
|
+
progress: Number(progress),
|
|
2699
|
+
total: typeof total === "number" ? total : void 0,
|
|
2700
|
+
progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
|
|
2701
|
+
timestamp
|
|
2702
|
+
});
|
|
2703
|
+
};
|
|
2537
2704
|
async listResources() {
|
|
2538
2705
|
return this.request("resources/list", {});
|
|
2539
2706
|
}
|
|
@@ -2555,6 +2722,11 @@ var McpTestClient = class {
|
|
|
2555
2722
|
// ═══════════════════════════════════════════════════════════════════
|
|
2556
2723
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
2557
2724
|
// ═══════════════════════════════════════════════════════════════════
|
|
2725
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2726
|
+
mcpPathLabel() {
|
|
2727
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2728
|
+
return entry ? `/${entry}` : "/";
|
|
2729
|
+
}
|
|
2558
2730
|
createTransport() {
|
|
2559
2731
|
let baseUrl = this.config.baseUrl;
|
|
2560
2732
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -2568,13 +2740,15 @@ var McpTestClient = class {
|
|
|
2568
2740
|
case "streamable-http":
|
|
2569
2741
|
return new StreamableHttpTransport({
|
|
2570
2742
|
baseUrl,
|
|
2743
|
+
entryPath: this.config.entryPath,
|
|
2571
2744
|
timeout: this.config.timeout,
|
|
2572
2745
|
auth: this.config.auth,
|
|
2573
2746
|
publicMode: this.config.publicMode,
|
|
2574
2747
|
debug: this.config.debug,
|
|
2575
2748
|
interceptors: this._interceptors,
|
|
2576
2749
|
clientInfo: this.config.clientInfo,
|
|
2577
|
-
elicitationHandler: this._elicitationHandler
|
|
2750
|
+
elicitationHandler: this._elicitationHandler,
|
|
2751
|
+
notificationHandler: this.recordNotification
|
|
2578
2752
|
});
|
|
2579
2753
|
case "sse":
|
|
2580
2754
|
throw new Error("SSE transport not yet implemented");
|