@frontmcp/testing 1.7.1 → 1.8.0

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/perf/index.js CHANGED
@@ -834,233 +834,277 @@ function getMeasurementsForProject(project) {
834
834
  return getGlobalMeasurementsArray().filter((m) => m.project === project);
835
835
  }
836
836
 
837
- // libs/testing/src/platform/platform-client-info.ts
838
- var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
839
- function getPlatformClientInfo(platform) {
840
- switch (platform) {
841
- case "openai":
842
- return {
843
- name: "ChatGPT",
844
- version: "1.0"
845
- };
846
- case "ext-apps":
847
- return {
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
- ...baseCapabilities,
901
- experimental: {
902
- [MCP_APPS_EXTENSION_KEY]: {
903
- mimeTypes: ["text/html;profile=mcp-app"]
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
- return baseCapabilities;
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
- * Set the authentication configuration
919
- */
920
- withAuth(auth) {
921
- this.config.auth = { ...this.config.auth, ...auth };
922
- return this;
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
- * Set the bearer token for authentication
886
+ * Check if request params match the mock params definition
926
887
  */
927
- withToken(token) {
928
- this.config.auth = { ...this.config.auth, token };
929
- return this;
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
- * Add custom headers to all requests
921
+ * Create a successful JSON-RPC response
933
922
  */
934
- withHeaders(headers) {
935
- this.config.auth = {
936
- ...this.config.auth,
937
- headers: { ...this.config.auth?.headers, ...headers }
923
+ success(result, id = 1) {
924
+ return {
925
+ jsonrpc: "2.0",
926
+ id,
927
+ result
938
928
  };
939
- return this;
940
- }
929
+ },
941
930
  /**
942
- * Set the transport type
931
+ * Create an error JSON-RPC response
943
932
  */
944
- withTransport(transport) {
945
- this.config.transport = transport;
946
- return this;
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
- * Set the request timeout in milliseconds
941
+ * Create a tool result response
950
942
  */
951
- withTimeout(timeoutMs) {
952
- this.config.timeout = timeoutMs;
953
- return this;
954
- }
943
+ toolResult(content, id = 1) {
944
+ return {
945
+ jsonrpc: "2.0",
946
+ id,
947
+ result: { content }
948
+ };
949
+ },
955
950
  /**
956
- * Enable debug logging
951
+ * Create a tools/list response
957
952
  */
958
- withDebug(enabled = true) {
959
- this.config.debug = enabled;
960
- return this;
961
- }
953
+ toolsList(tools, id = 1) {
954
+ return {
955
+ jsonrpc: "2.0",
956
+ id,
957
+ result: { tools }
958
+ };
959
+ },
962
960
  /**
963
- * Enable public mode - skip authentication entirely.
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
- withPublicMode(enabled = true) {
968
- this.config.publicMode = enabled;
969
- return this;
970
- }
963
+ resourcesList(resources, id = 1) {
964
+ return {
965
+ jsonrpc: "2.0",
966
+ id,
967
+ result: { resources }
968
+ };
969
+ },
971
970
  /**
972
- * Set the MCP protocol version to request
971
+ * Create a resources/read response
973
972
  */
974
- withProtocolVersion(version) {
975
- this.config.protocolVersion = version;
976
- return this;
977
- }
973
+ resourceRead(contents, id = 1) {
974
+ return {
975
+ jsonrpc: "2.0",
976
+ id,
977
+ result: { contents }
978
+ };
979
+ },
978
980
  /**
979
- * Set the client info sent during initialization
981
+ * Common MCP errors
980
982
  */
981
- withClientInfo(info) {
982
- this.config.clientInfo = info;
983
- return this;
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
- * Set the platform type for testing platform-specific meta keys.
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
- withPlatform(platform) {
1009
- this.config.platform = platform;
1010
- this.config.clientInfo = getPlatformClientInfo(platform);
1011
- this.config.capabilities = getPlatformCapabilities(platform);
1012
- return this;
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
- * Set custom client capabilities for MCP initialization.
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
- withCapabilities(capabilities) {
1031
- this.config.capabilities = capabilities;
1032
- return this;
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
- * Set query parameters to append to the connection URL.
1036
- * Useful for testing mode switches like `?mode=skills_only`.
1037
- *
1038
- * @example
1039
- * ```typescript
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
- withQueryParams(params) {
1046
- this.config.queryParams = { ...this.config.queryParams, ...params };
1047
- return this;
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
- * Build the McpTestClient instance (does not connect)
1075
+ * Process a response through the interceptor chain
1051
1076
  */
1052
- build() {
1053
- return new McpTestClient(this.config);
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
- * Build the McpTestClient and connect to the server
1097
+ * Clear all interceptors and mocks
1057
1098
  */
1058
- async buildAndConnect() {
1059
- const client = this.build();
1060
- await client.connect();
1061
- return client;
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;
@@ -1079,6 +1123,7 @@ var StreamableHttpTransport = class {
1079
1123
  this.config = {
1080
1124
  baseUrl: config.baseUrl.replace(/\/$/, ""),
1081
1125
  // Remove trailing slash
1126
+ entryPath: config.entryPath,
1082
1127
  timeout: config.timeout ?? DEFAULT_TIMEOUT,
1083
1128
  auth: config.auth ?? {},
1084
1129
  publicMode: config.publicMode ?? false,
@@ -1171,7 +1216,7 @@ var StreamableHttpTransport = class {
1171
1216
  }
1172
1217
  const headers = this.buildHeaders();
1173
1218
  this.lastRequestHeaders = headers;
1174
- const url = `${this.config.baseUrl}/`;
1219
+ const url = this.mcpUrl();
1175
1220
  this.log(`POST ${url}`, message);
1176
1221
  const controller = new AbortController();
1177
1222
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -1241,7 +1286,7 @@ var StreamableHttpTransport = class {
1241
1286
  this.ensureConnected();
1242
1287
  const headers = this.buildHeaders();
1243
1288
  this.lastRequestHeaders = headers;
1244
- const url = `${this.config.baseUrl}/`;
1289
+ const url = this.mcpUrl();
1245
1290
  this.log(`POST ${url} (notification)`, message);
1246
1291
  const controller = new AbortController();
1247
1292
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -1272,7 +1317,7 @@ var StreamableHttpTransport = class {
1272
1317
  this.ensureConnected();
1273
1318
  const headers = this.buildHeaders();
1274
1319
  this.lastRequestHeaders = headers;
1275
- const url = `${this.config.baseUrl}/`;
1320
+ const url = this.mcpUrl();
1276
1321
  this.log(`POST ${url} (raw)`, data);
1277
1322
  const controller = new AbortController();
1278
1323
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -1555,7 +1600,7 @@ var StreamableHttpTransport = class {
1555
1600
  */
1556
1601
  async sendElicitationResponse(requestId, response) {
1557
1602
  const headers = this.buildHeaders();
1558
- const url = `${this.config.baseUrl}/`;
1603
+ const url = this.mcpUrl();
1559
1604
  const rpcResponse = {
1560
1605
  jsonrpc: "2.0",
1561
1606
  id: requestId,
@@ -1575,6 +1620,18 @@ var StreamableHttpTransport = class {
1575
1620
  this.log("Failed to send elicitation response:", error);
1576
1621
  }
1577
1622
  }
1623
+ /**
1624
+ * The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
1625
+ * trailing slash is kept because both hosts normalize it and the original
1626
+ * behaviour (`${baseUrl}/`) relied on it (issue #543).
1627
+ */
1628
+ mcpUrl() {
1629
+ const url = new URL(this.config.baseUrl);
1630
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
1631
+ const basePath = url.pathname.replace(/\/+$/, "");
1632
+ url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
1633
+ return url.toString();
1634
+ }
1578
1635
  buildHeaders() {
1579
1636
  const headers = {
1580
1637
  "Content-Type": "application/json",
@@ -1662,277 +1719,233 @@ var StreamableHttpTransport = class {
1662
1719
  }
1663
1720
  };
1664
1721
 
1665
- // libs/testing/src/interceptor/mock-registry.ts
1666
- var DefaultMockRegistry = class {
1667
- mocks = [];
1668
- add(mock) {
1669
- const entry = {
1670
- definition: mock,
1671
- callCount: 0,
1672
- calls: [],
1673
- remainingUses: mock.times ?? Infinity
1674
- };
1675
- this.mocks.push(entry);
1722
+ // libs/testing/src/platform/platform-client-info.ts
1723
+ var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
1724
+ function getPlatformClientInfo(platform) {
1725
+ switch (platform) {
1726
+ case "openai":
1727
+ return {
1728
+ name: "ChatGPT",
1729
+ version: "1.0"
1730
+ };
1731
+ case "ext-apps":
1732
+ return {
1733
+ name: "mcp-ext-apps",
1734
+ version: "1.0"
1735
+ };
1736
+ case "claude":
1737
+ return {
1738
+ name: "claude-desktop",
1739
+ version: "1.0"
1740
+ };
1741
+ case "cursor":
1742
+ return {
1743
+ name: "cursor",
1744
+ version: "1.0"
1745
+ };
1746
+ case "continue":
1747
+ return {
1748
+ name: "continue",
1749
+ version: "1.0"
1750
+ };
1751
+ case "cody":
1752
+ return {
1753
+ name: "cody",
1754
+ version: "1.0"
1755
+ };
1756
+ case "gemini":
1757
+ return {
1758
+ name: "gemini",
1759
+ version: "1.0"
1760
+ };
1761
+ case "generic-mcp":
1762
+ return {
1763
+ name: "generic-mcp-client",
1764
+ version: "1.0"
1765
+ };
1766
+ case "unknown":
1767
+ default:
1768
+ return {
1769
+ name: "mcp-test-client",
1770
+ version: "1.0"
1771
+ };
1772
+ }
1773
+ }
1774
+ function getPlatformCapabilities(platform) {
1775
+ const baseCapabilities = {
1776
+ sampling: {},
1777
+ // Include elicitation.form by default for testing elicitation workflows
1778
+ // Note: MCP SDK expects form to be an object, not boolean
1779
+ elicitation: {
1780
+ form: {}
1781
+ }
1782
+ };
1783
+ if (platform === "ext-apps") {
1676
1784
  return {
1677
- remove: () => {
1678
- const index = this.mocks.indexOf(entry);
1679
- if (index !== -1) {
1680
- this.mocks.splice(index, 1);
1785
+ ...baseCapabilities,
1786
+ experimental: {
1787
+ [MCP_APPS_EXTENSION_KEY]: {
1788
+ mimeTypes: ["text/html;profile=mcp-app"]
1681
1789
  }
1682
- },
1683
- callCount: () => entry.callCount,
1684
- calls: () => [...entry.calls]
1790
+ }
1685
1791
  };
1686
1792
  }
1687
- clear() {
1688
- this.mocks = [];
1689
- }
1690
- getAll() {
1691
- return this.mocks.map((e) => e.definition);
1793
+ return baseCapabilities;
1794
+ }
1795
+
1796
+ // libs/testing/src/client/mcp-test-client.builder.ts
1797
+ var McpTestClientBuilder = class {
1798
+ config;
1799
+ constructor(config) {
1800
+ this.config = { ...config };
1692
1801
  }
1693
- match(request) {
1694
- for (const entry of this.mocks) {
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;
1802
+ /**
1803
+ * Set the authentication configuration
1804
+ */
1805
+ withAuth(auth) {
1806
+ this.config.auth = { ...this.config.auth, ...auth };
1807
+ return this;
1712
1808
  }
1713
1809
  /**
1714
- * Check if request params match the mock params definition
1810
+ * Set the bearer token for authentication
1715
1811
  */
1716
- paramsMatch(expected, actual) {
1717
- for (const [key, value] of Object.entries(expected)) {
1718
- if (!(key in actual)) return false;
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;
1812
+ withToken(token) {
1813
+ this.config.auth = { ...this.config.auth, token };
1814
+ return this;
1745
1815
  }
1746
- };
1747
- var mockResponse = {
1748
1816
  /**
1749
- * Create a successful JSON-RPC response
1817
+ * Add custom headers to all requests
1750
1818
  */
1751
- success(result, id = 1) {
1752
- return {
1753
- jsonrpc: "2.0",
1754
- id,
1755
- result
1819
+ withHeaders(headers) {
1820
+ this.config.auth = {
1821
+ ...this.config.auth,
1822
+ headers: { ...this.config.auth?.headers, ...headers }
1756
1823
  };
1757
- },
1824
+ return this;
1825
+ }
1758
1826
  /**
1759
- * Create an error JSON-RPC response
1827
+ * Set the transport type
1760
1828
  */
1761
- error(code, message, data, id = 1) {
1762
- return {
1763
- jsonrpc: "2.0",
1764
- id,
1765
- error: { code, message, data }
1766
- };
1767
- },
1829
+ withTransport(transport) {
1830
+ this.config.transport = transport;
1831
+ return this;
1832
+ }
1768
1833
  /**
1769
- * Create a tool result response
1834
+ * Set the request timeout in milliseconds
1770
1835
  */
1771
- toolResult(content, id = 1) {
1772
- return {
1773
- jsonrpc: "2.0",
1774
- id,
1775
- result: { content }
1776
- };
1777
- },
1836
+ withTimeout(timeoutMs) {
1837
+ this.config.timeout = timeoutMs;
1838
+ return this;
1839
+ }
1778
1840
  /**
1779
- * Create a tools/list response
1841
+ * Enable debug logging
1780
1842
  */
1781
- toolsList(tools, id = 1) {
1782
- return {
1783
- jsonrpc: "2.0",
1784
- id,
1785
- result: { tools }
1786
- };
1787
- },
1843
+ withDebug(enabled = true) {
1844
+ this.config.debug = enabled;
1845
+ return this;
1846
+ }
1788
1847
  /**
1789
- * Create a resources/list response
1848
+ * Enable public mode - skip authentication entirely.
1849
+ * When true, no Authorization header is sent and anonymous token is not requested.
1850
+ * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
1790
1851
  */
1791
- resourcesList(resources, id = 1) {
1792
- return {
1793
- jsonrpc: "2.0",
1794
- id,
1795
- result: { resources }
1796
- };
1797
- },
1852
+ withPublicMode(enabled = true) {
1853
+ this.config.publicMode = enabled;
1854
+ return this;
1855
+ }
1798
1856
  /**
1799
- * Create a resources/read response
1857
+ * Set the MCP protocol version to request
1800
1858
  */
1801
- resourceRead(contents, id = 1) {
1802
- return {
1803
- jsonrpc: "2.0",
1804
- id,
1805
- result: { contents }
1806
- };
1807
- },
1859
+ withProtocolVersion(version) {
1860
+ this.config.protocolVersion = version;
1861
+ return this;
1862
+ }
1808
1863
  /**
1809
- * Common MCP errors
1864
+ * Set the client info sent during initialization
1810
1865
  */
1811
- errors: {
1812
- methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
1813
- invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
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)
1819
- }
1820
- };
1821
-
1822
- // libs/testing/src/interceptor/interceptor-chain.ts
1823
- var DefaultInterceptorChain = class {
1824
- request = [];
1825
- response = [];
1826
- mocks;
1827
- constructor() {
1828
- this.mocks = new DefaultMockRegistry();
1866
+ withClientInfo(info) {
1867
+ this.config.clientInfo = info;
1868
+ return this;
1829
1869
  }
1830
1870
  /**
1831
- * Add a request interceptor
1871
+ * Set the platform type for testing platform-specific meta keys.
1872
+ * Automatically configures clientInfo and capabilities for platform detection.
1873
+ *
1874
+ * Platform-specific behavior:
1875
+ * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
1876
+ * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
1877
+ * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
1878
+ * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
1879
+ * - Other platforms follow similar patterns
1880
+ *
1881
+ * @example
1882
+ * ```typescript
1883
+ * const client = await McpTestClient.create({ baseUrl })
1884
+ * .withPlatform('openai')
1885
+ * .buildAndConnect();
1886
+ *
1887
+ * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
1888
+ * const extAppsClient = await McpTestClient.create({ baseUrl })
1889
+ * .withPlatform('ext-apps')
1890
+ * .buildAndConnect();
1891
+ * ```
1832
1892
  */
1833
- addRequestInterceptor(interceptor) {
1834
- this.request.push(interceptor);
1835
- return () => {
1836
- const index = this.request.indexOf(interceptor);
1837
- if (index !== -1) {
1838
- this.request.splice(index, 1);
1839
- }
1840
- };
1893
+ withPlatform(platform) {
1894
+ this.config.platform = platform;
1895
+ this.config.clientInfo = getPlatformClientInfo(platform);
1896
+ this.config.capabilities = getPlatformCapabilities(platform);
1897
+ return this;
1841
1898
  }
1842
1899
  /**
1843
- * Add a response interceptor
1900
+ * Set custom client capabilities for MCP initialization.
1901
+ * Use this for fine-grained control over capabilities sent during initialization.
1902
+ *
1903
+ * @example
1904
+ * ```typescript
1905
+ * const client = await McpTestClient.create({ baseUrl })
1906
+ * .withCapabilities({
1907
+ * sampling: {},
1908
+ * experimental: {
1909
+ * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
1910
+ * }
1911
+ * })
1912
+ * .buildAndConnect();
1913
+ * ```
1844
1914
  */
1845
- addResponseInterceptor(interceptor) {
1846
- this.response.push(interceptor);
1847
- return () => {
1848
- const index = this.response.indexOf(interceptor);
1849
- if (index !== -1) {
1850
- this.response.splice(index, 1);
1851
- }
1852
- };
1915
+ withCapabilities(capabilities) {
1916
+ this.config.capabilities = capabilities;
1917
+ return this;
1853
1918
  }
1854
1919
  /**
1855
- * Process a request through the interceptor chain
1856
- * Returns either:
1857
- * - { type: 'continue', request } - continue with (possibly modified) request
1858
- * - { type: 'mock', response } - return mock response immediately
1859
- * - { type: 'error', error } - throw error
1920
+ * Set query parameters to append to the connection URL.
1921
+ * Useful for testing mode switches like `?mode=skills_only`.
1922
+ *
1923
+ * @example
1924
+ * ```typescript
1925
+ * const client = await McpTestClient.create({ baseUrl })
1926
+ * .withQueryParams({ mode: 'skills_only' })
1927
+ * .buildAndConnect();
1928
+ * ```
1860
1929
  */
1861
- async processRequest(request, meta) {
1862
- let currentRequest = request;
1863
- const mockDef = this.mocks.match(request);
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 };
1930
+ withQueryParams(params) {
1931
+ this.config.queryParams = { ...this.config.queryParams, ...params };
1932
+ return this;
1901
1933
  }
1902
1934
  /**
1903
- * Process a response through the interceptor chain
1935
+ * Build the McpTestClient instance (does not connect)
1904
1936
  */
1905
- async processResponse(request, response, durationMs) {
1906
- let currentResponse = response;
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;
1937
+ build() {
1938
+ return new McpTestClient(this.config);
1923
1939
  }
1924
1940
  /**
1925
- * Clear all interceptors and mocks
1941
+ * Build the McpTestClient and connect to the server
1926
1942
  */
1927
- clear() {
1928
- this.request = [];
1929
- this.response = [];
1930
- this.mocks.clear();
1943
+ async buildAndConnect() {
1944
+ const client = this.build();
1945
+ await client.connect();
1946
+ return client;
1931
1947
  }
1932
1948
  };
1933
- function sleep3(ms) {
1934
- return new Promise((resolve) => setTimeout(resolve, ms));
1935
- }
1936
1949
 
1937
1950
  // libs/testing/src/client/mcp-test-client.ts
1938
1951
  var DEFAULT_TIMEOUT2 = 3e4;
@@ -1941,6 +1954,21 @@ var DEFAULT_CLIENT_INFO = {
1941
1954
  name: "@frontmcp/testing",
1942
1955
  version: "0.4.0"
1943
1956
  };
1957
+ function extractEntryPaths(data) {
1958
+ let payload = data;
1959
+ if (typeof payload === "string") {
1960
+ try {
1961
+ payload = JSON.parse(payload);
1962
+ } catch {
1963
+ return void 0;
1964
+ }
1965
+ }
1966
+ if (!payload || typeof payload !== "object") return void 0;
1967
+ const paths = payload.entryPaths;
1968
+ if (!Array.isArray(paths)) return void 0;
1969
+ const strings = paths.filter((p) => typeof p === "string");
1970
+ return strings.length > 0 ? strings : void 0;
1971
+ }
1944
1972
  var McpTestClient = class {
1945
1973
  // Platform, capabilities, and queryParams are optional - only set when needed
1946
1974
  config;
@@ -1949,6 +1977,8 @@ var McpTestClient = class {
1949
1977
  requestIdCounter = 0;
1950
1978
  _lastRequestId = 0;
1951
1979
  _sessionId;
1980
+ /** #543 — discovery is attempted at most once per connect. */
1981
+ triedDiscoveredEntryPath = false;
1952
1982
  _sessionInfo = null;
1953
1983
  _authState = { isAnonymous: true, scopes: [] };
1954
1984
  // Logging and tracing
@@ -1966,6 +1996,7 @@ var McpTestClient = class {
1966
1996
  constructor(config) {
1967
1997
  this.config = {
1968
1998
  baseUrl: config.baseUrl,
1999
+ entryPath: config.entryPath,
1969
2000
  transport: config.transport ?? "streamable-http",
1970
2001
  auth: config.auth ?? {},
1971
2002
  publicMode: config.publicMode ?? false,
@@ -2001,11 +2032,13 @@ var McpTestClient = class {
2001
2032
  */
2002
2033
  async connect() {
2003
2034
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
2035
+ this.triedDiscoveredEntryPath = false;
2004
2036
  this.transport = this.createTransport();
2005
2037
  await this.transport.connect();
2006
2038
  const maxRetries = 3;
2007
2039
  const retryDelayMs = 500;
2008
2040
  let lastError;
2041
+ let reportedEntryPaths;
2009
2042
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
2010
2043
  const initResponse = await this.initialize();
2011
2044
  if (initResponse.success && initResponse.data) {
@@ -2013,13 +2046,27 @@ var McpTestClient = class {
2013
2046
  break;
2014
2047
  }
2015
2048
  lastError = initResponse.error?.message ?? "Unknown error";
2049
+ reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
2050
+ if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
2051
+ this.triedDiscoveredEntryPath = true;
2052
+ const discovered = reportedEntryPaths[0];
2053
+ this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
2054
+ this.config.entryPath = discovered;
2055
+ await this.transport.close();
2056
+ this.transport = this.createTransport();
2057
+ await this.transport.connect();
2058
+ continue;
2059
+ }
2016
2060
  if (attempt < maxRetries) {
2017
2061
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
2018
2062
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
2019
2063
  }
2020
2064
  }
2021
2065
  if (!this.initResult) {
2022
- throw new Error(`Failed to initialize MCP connection after ${maxRetries} attempts: ${lastError}`);
2066
+ 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.` : "";
2067
+ throw new Error(
2068
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
2069
+ );
2023
2070
  }
2024
2071
  this._sessionId = this.transport.getSessionId();
2025
2072
  this._sessionInfo = {
@@ -2617,6 +2664,11 @@ var McpTestClient = class {
2617
2664
  // ═══════════════════════════════════════════════════════════════════
2618
2665
  // PRIVATE: TRANSPORT & REQUEST HELPERS
2619
2666
  // ═══════════════════════════════════════════════════════════════════
2667
+ /** The MCP path this client is currently pointed at, for log and error text. */
2668
+ mcpPathLabel() {
2669
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
2670
+ return entry ? `/${entry}` : "/";
2671
+ }
2620
2672
  createTransport() {
2621
2673
  let baseUrl = this.config.baseUrl;
2622
2674
  if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
@@ -2630,6 +2682,7 @@ var McpTestClient = class {
2630
2682
  case "streamable-http":
2631
2683
  return new StreamableHttpTransport({
2632
2684
  baseUrl,
2685
+ entryPath: this.config.entryPath,
2633
2686
  timeout: this.config.timeout,
2634
2687
  auth: this.config.auth,
2635
2688
  publicMode: this.config.publicMode,