@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.
@@ -772,233 +772,277 @@ function getMeasurementsForProject(project) {
772
772
  return getGlobalMeasurementsArray().filter((m) => m.project === project);
773
773
  }
774
774
 
775
- // libs/testing/src/platform/platform-client-info.ts
776
- var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
777
- function getPlatformClientInfo(platform) {
778
- switch (platform) {
779
- case "openai":
780
- return {
781
- name: "ChatGPT",
782
- version: "1.0"
783
- };
784
- case "ext-apps":
785
- return {
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
- ...baseCapabilities,
839
- experimental: {
840
- [MCP_APPS_EXTENSION_KEY]: {
841
- mimeTypes: ["text/html;profile=mcp-app"]
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
- return baseCapabilities;
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
- * Set the authentication configuration
857
- */
858
- withAuth(auth) {
859
- this.config.auth = { ...this.config.auth, ...auth };
860
- return this;
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
- * Set the bearer token for authentication
824
+ * Check if request params match the mock params definition
864
825
  */
865
- withToken(token) {
866
- this.config.auth = { ...this.config.auth, token };
867
- return this;
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
- * Add custom headers to all requests
859
+ * Create a successful JSON-RPC response
871
860
  */
872
- withHeaders(headers) {
873
- this.config.auth = {
874
- ...this.config.auth,
875
- headers: { ...this.config.auth?.headers, ...headers }
861
+ success(result, id = 1) {
862
+ return {
863
+ jsonrpc: "2.0",
864
+ id,
865
+ result
876
866
  };
877
- return this;
878
- }
867
+ },
879
868
  /**
880
- * Set the transport type
869
+ * Create an error JSON-RPC response
881
870
  */
882
- withTransport(transport) {
883
- this.config.transport = transport;
884
- return this;
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
- * Set the request timeout in milliseconds
879
+ * Create a tool result response
888
880
  */
889
- withTimeout(timeoutMs) {
890
- this.config.timeout = timeoutMs;
891
- return this;
892
- }
881
+ toolResult(content, id = 1) {
882
+ return {
883
+ jsonrpc: "2.0",
884
+ id,
885
+ result: { content }
886
+ };
887
+ },
893
888
  /**
894
- * Enable debug logging
889
+ * Create a tools/list response
895
890
  */
896
- withDebug(enabled = true) {
897
- this.config.debug = enabled;
898
- return this;
899
- }
891
+ toolsList(tools, id = 1) {
892
+ return {
893
+ jsonrpc: "2.0",
894
+ id,
895
+ result: { tools }
896
+ };
897
+ },
900
898
  /**
901
- * Enable public mode - skip authentication entirely.
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
- withPublicMode(enabled = true) {
906
- this.config.publicMode = enabled;
907
- return this;
908
- }
901
+ resourcesList(resources, id = 1) {
902
+ return {
903
+ jsonrpc: "2.0",
904
+ id,
905
+ result: { resources }
906
+ };
907
+ },
909
908
  /**
910
- * Set the MCP protocol version to request
909
+ * Create a resources/read response
911
910
  */
912
- withProtocolVersion(version) {
913
- this.config.protocolVersion = version;
914
- return this;
915
- }
911
+ resourceRead(contents, id = 1) {
912
+ return {
913
+ jsonrpc: "2.0",
914
+ id,
915
+ result: { contents }
916
+ };
917
+ },
916
918
  /**
917
- * Set the client info sent during initialization
919
+ * Common MCP errors
918
920
  */
919
- withClientInfo(info) {
920
- this.config.clientInfo = info;
921
- return this;
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
- * Set the platform type for testing platform-specific meta keys.
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
- withPlatform(platform) {
947
- this.config.platform = platform;
948
- this.config.clientInfo = getPlatformClientInfo(platform);
949
- this.config.capabilities = getPlatformCapabilities(platform);
950
- return this;
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
- * Set custom client capabilities for MCP initialization.
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
- withCapabilities(capabilities) {
969
- this.config.capabilities = capabilities;
970
- return this;
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
- * Set query parameters to append to the connection URL.
974
- * Useful for testing mode switches like `?mode=skills_only`.
975
- *
976
- * @example
977
- * ```typescript
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
- withQueryParams(params) {
984
- this.config.queryParams = { ...this.config.queryParams, ...params };
985
- return this;
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
- * Build the McpTestClient instance (does not connect)
1013
+ * Process a response through the interceptor chain
989
1014
  */
990
- build() {
991
- return new McpTestClient(this.config);
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
- * Build the McpTestClient and connect to the server
1035
+ * Clear all interceptors and mocks
995
1036
  */
996
- async buildAndConnect() {
997
- const client = this.build();
998
- await client.connect();
999
- return client;
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;
@@ -1017,6 +1061,7 @@ var StreamableHttpTransport = class {
1017
1061
  this.config = {
1018
1062
  baseUrl: config.baseUrl.replace(/\/$/, ""),
1019
1063
  // Remove trailing slash
1064
+ entryPath: config.entryPath,
1020
1065
  timeout: config.timeout ?? DEFAULT_TIMEOUT,
1021
1066
  auth: config.auth ?? {},
1022
1067
  publicMode: config.publicMode ?? false,
@@ -1109,7 +1154,7 @@ var StreamableHttpTransport = class {
1109
1154
  }
1110
1155
  const headers = this.buildHeaders();
1111
1156
  this.lastRequestHeaders = headers;
1112
- const url = `${this.config.baseUrl}/`;
1157
+ const url = this.mcpUrl();
1113
1158
  this.log(`POST ${url}`, message);
1114
1159
  const controller = new AbortController();
1115
1160
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -1179,7 +1224,7 @@ var StreamableHttpTransport = class {
1179
1224
  this.ensureConnected();
1180
1225
  const headers = this.buildHeaders();
1181
1226
  this.lastRequestHeaders = headers;
1182
- const url = `${this.config.baseUrl}/`;
1227
+ const url = this.mcpUrl();
1183
1228
  this.log(`POST ${url} (notification)`, message);
1184
1229
  const controller = new AbortController();
1185
1230
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -1210,7 +1255,7 @@ var StreamableHttpTransport = class {
1210
1255
  this.ensureConnected();
1211
1256
  const headers = this.buildHeaders();
1212
1257
  this.lastRequestHeaders = headers;
1213
- const url = `${this.config.baseUrl}/`;
1258
+ const url = this.mcpUrl();
1214
1259
  this.log(`POST ${url} (raw)`, data);
1215
1260
  const controller = new AbortController();
1216
1261
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -1493,7 +1538,7 @@ var StreamableHttpTransport = class {
1493
1538
  */
1494
1539
  async sendElicitationResponse(requestId, response) {
1495
1540
  const headers = this.buildHeaders();
1496
- const url = `${this.config.baseUrl}/`;
1541
+ const url = this.mcpUrl();
1497
1542
  const rpcResponse = {
1498
1543
  jsonrpc: "2.0",
1499
1544
  id: requestId,
@@ -1513,6 +1558,18 @@ var StreamableHttpTransport = class {
1513
1558
  this.log("Failed to send elicitation response:", error);
1514
1559
  }
1515
1560
  }
1561
+ /**
1562
+ * The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
1563
+ * trailing slash is kept because both hosts normalize it and the original
1564
+ * behaviour (`${baseUrl}/`) relied on it (issue #543).
1565
+ */
1566
+ mcpUrl() {
1567
+ const url = new URL(this.config.baseUrl);
1568
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
1569
+ const basePath = url.pathname.replace(/\/+$/, "");
1570
+ url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
1571
+ return url.toString();
1572
+ }
1516
1573
  buildHeaders() {
1517
1574
  const headers = {
1518
1575
  "Content-Type": "application/json",
@@ -1600,277 +1657,233 @@ var StreamableHttpTransport = class {
1600
1657
  }
1601
1658
  };
1602
1659
 
1603
- // libs/testing/src/interceptor/mock-registry.ts
1604
- var DefaultMockRegistry = class {
1605
- mocks = [];
1606
- add(mock) {
1607
- const entry = {
1608
- definition: mock,
1609
- callCount: 0,
1610
- calls: [],
1611
- remainingUses: mock.times ?? Infinity
1612
- };
1613
- this.mocks.push(entry);
1660
+ // libs/testing/src/platform/platform-client-info.ts
1661
+ var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
1662
+ function getPlatformClientInfo(platform) {
1663
+ switch (platform) {
1664
+ case "openai":
1665
+ return {
1666
+ name: "ChatGPT",
1667
+ version: "1.0"
1668
+ };
1669
+ case "ext-apps":
1670
+ return {
1671
+ name: "mcp-ext-apps",
1672
+ version: "1.0"
1673
+ };
1674
+ case "claude":
1675
+ return {
1676
+ name: "claude-desktop",
1677
+ version: "1.0"
1678
+ };
1679
+ case "cursor":
1680
+ return {
1681
+ name: "cursor",
1682
+ version: "1.0"
1683
+ };
1684
+ case "continue":
1685
+ return {
1686
+ name: "continue",
1687
+ version: "1.0"
1688
+ };
1689
+ case "cody":
1690
+ return {
1691
+ name: "cody",
1692
+ version: "1.0"
1693
+ };
1694
+ case "gemini":
1695
+ return {
1696
+ name: "gemini",
1697
+ version: "1.0"
1698
+ };
1699
+ case "generic-mcp":
1700
+ return {
1701
+ name: "generic-mcp-client",
1702
+ version: "1.0"
1703
+ };
1704
+ case "unknown":
1705
+ default:
1706
+ return {
1707
+ name: "mcp-test-client",
1708
+ version: "1.0"
1709
+ };
1710
+ }
1711
+ }
1712
+ function getPlatformCapabilities(platform) {
1713
+ const baseCapabilities = {
1714
+ sampling: {},
1715
+ // Include elicitation.form by default for testing elicitation workflows
1716
+ // Note: MCP SDK expects form to be an object, not boolean
1717
+ elicitation: {
1718
+ form: {}
1719
+ }
1720
+ };
1721
+ if (platform === "ext-apps") {
1614
1722
  return {
1615
- remove: () => {
1616
- const index = this.mocks.indexOf(entry);
1617
- if (index !== -1) {
1618
- this.mocks.splice(index, 1);
1723
+ ...baseCapabilities,
1724
+ experimental: {
1725
+ [MCP_APPS_EXTENSION_KEY]: {
1726
+ mimeTypes: ["text/html;profile=mcp-app"]
1619
1727
  }
1620
- },
1621
- callCount: () => entry.callCount,
1622
- calls: () => [...entry.calls]
1728
+ }
1623
1729
  };
1624
1730
  }
1625
- clear() {
1626
- this.mocks = [];
1627
- }
1628
- getAll() {
1629
- return this.mocks.map((e) => e.definition);
1731
+ return baseCapabilities;
1732
+ }
1733
+
1734
+ // libs/testing/src/client/mcp-test-client.builder.ts
1735
+ var McpTestClientBuilder = class {
1736
+ config;
1737
+ constructor(config) {
1738
+ this.config = { ...config };
1630
1739
  }
1631
- match(request) {
1632
- for (const entry of this.mocks) {
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;
1740
+ /**
1741
+ * Set the authentication configuration
1742
+ */
1743
+ withAuth(auth) {
1744
+ this.config.auth = { ...this.config.auth, ...auth };
1745
+ return this;
1650
1746
  }
1651
1747
  /**
1652
- * Check if request params match the mock params definition
1748
+ * Set the bearer token for authentication
1653
1749
  */
1654
- paramsMatch(expected, actual) {
1655
- for (const [key, value] of Object.entries(expected)) {
1656
- if (!(key in actual)) return false;
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;
1750
+ withToken(token) {
1751
+ this.config.auth = { ...this.config.auth, token };
1752
+ return this;
1683
1753
  }
1684
- };
1685
- var mockResponse = {
1686
1754
  /**
1687
- * Create a successful JSON-RPC response
1755
+ * Add custom headers to all requests
1688
1756
  */
1689
- success(result, id = 1) {
1690
- return {
1691
- jsonrpc: "2.0",
1692
- id,
1693
- result
1757
+ withHeaders(headers) {
1758
+ this.config.auth = {
1759
+ ...this.config.auth,
1760
+ headers: { ...this.config.auth?.headers, ...headers }
1694
1761
  };
1695
- },
1762
+ return this;
1763
+ }
1696
1764
  /**
1697
- * Create an error JSON-RPC response
1765
+ * Set the transport type
1698
1766
  */
1699
- error(code, message, data, id = 1) {
1700
- return {
1701
- jsonrpc: "2.0",
1702
- id,
1703
- error: { code, message, data }
1704
- };
1705
- },
1767
+ withTransport(transport) {
1768
+ this.config.transport = transport;
1769
+ return this;
1770
+ }
1706
1771
  /**
1707
- * Create a tool result response
1772
+ * Set the request timeout in milliseconds
1708
1773
  */
1709
- toolResult(content, id = 1) {
1710
- return {
1711
- jsonrpc: "2.0",
1712
- id,
1713
- result: { content }
1714
- };
1715
- },
1774
+ withTimeout(timeoutMs) {
1775
+ this.config.timeout = timeoutMs;
1776
+ return this;
1777
+ }
1716
1778
  /**
1717
- * Create a tools/list response
1779
+ * Enable debug logging
1718
1780
  */
1719
- toolsList(tools, id = 1) {
1720
- return {
1721
- jsonrpc: "2.0",
1722
- id,
1723
- result: { tools }
1724
- };
1725
- },
1781
+ withDebug(enabled = true) {
1782
+ this.config.debug = enabled;
1783
+ return this;
1784
+ }
1726
1785
  /**
1727
- * Create a resources/list response
1786
+ * Enable public mode - skip authentication entirely.
1787
+ * When true, no Authorization header is sent and anonymous token is not requested.
1788
+ * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
1728
1789
  */
1729
- resourcesList(resources, id = 1) {
1730
- return {
1731
- jsonrpc: "2.0",
1732
- id,
1733
- result: { resources }
1734
- };
1735
- },
1790
+ withPublicMode(enabled = true) {
1791
+ this.config.publicMode = enabled;
1792
+ return this;
1793
+ }
1736
1794
  /**
1737
- * Create a resources/read response
1795
+ * Set the MCP protocol version to request
1738
1796
  */
1739
- resourceRead(contents, id = 1) {
1740
- return {
1741
- jsonrpc: "2.0",
1742
- id,
1743
- result: { contents }
1744
- };
1745
- },
1797
+ withProtocolVersion(version) {
1798
+ this.config.protocolVersion = version;
1799
+ return this;
1800
+ }
1746
1801
  /**
1747
- * Common MCP errors
1802
+ * Set the client info sent during initialization
1748
1803
  */
1749
- errors: {
1750
- methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
1751
- invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
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)
1757
- }
1758
- };
1759
-
1760
- // libs/testing/src/interceptor/interceptor-chain.ts
1761
- var DefaultInterceptorChain = class {
1762
- request = [];
1763
- response = [];
1764
- mocks;
1765
- constructor() {
1766
- this.mocks = new DefaultMockRegistry();
1804
+ withClientInfo(info) {
1805
+ this.config.clientInfo = info;
1806
+ return this;
1767
1807
  }
1768
1808
  /**
1769
- * Add a request interceptor
1809
+ * Set the platform type for testing platform-specific meta keys.
1810
+ * Automatically configures clientInfo and capabilities for platform detection.
1811
+ *
1812
+ * Platform-specific behavior:
1813
+ * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
1814
+ * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
1815
+ * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
1816
+ * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
1817
+ * - Other platforms follow similar patterns
1818
+ *
1819
+ * @example
1820
+ * ```typescript
1821
+ * const client = await McpTestClient.create({ baseUrl })
1822
+ * .withPlatform('openai')
1823
+ * .buildAndConnect();
1824
+ *
1825
+ * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
1826
+ * const extAppsClient = await McpTestClient.create({ baseUrl })
1827
+ * .withPlatform('ext-apps')
1828
+ * .buildAndConnect();
1829
+ * ```
1770
1830
  */
1771
- addRequestInterceptor(interceptor) {
1772
- this.request.push(interceptor);
1773
- return () => {
1774
- const index = this.request.indexOf(interceptor);
1775
- if (index !== -1) {
1776
- this.request.splice(index, 1);
1777
- }
1778
- };
1831
+ withPlatform(platform) {
1832
+ this.config.platform = platform;
1833
+ this.config.clientInfo = getPlatformClientInfo(platform);
1834
+ this.config.capabilities = getPlatformCapabilities(platform);
1835
+ return this;
1779
1836
  }
1780
1837
  /**
1781
- * Add a response interceptor
1838
+ * Set custom client capabilities for MCP initialization.
1839
+ * Use this for fine-grained control over capabilities sent during initialization.
1840
+ *
1841
+ * @example
1842
+ * ```typescript
1843
+ * const client = await McpTestClient.create({ baseUrl })
1844
+ * .withCapabilities({
1845
+ * sampling: {},
1846
+ * experimental: {
1847
+ * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
1848
+ * }
1849
+ * })
1850
+ * .buildAndConnect();
1851
+ * ```
1782
1852
  */
1783
- addResponseInterceptor(interceptor) {
1784
- this.response.push(interceptor);
1785
- return () => {
1786
- const index = this.response.indexOf(interceptor);
1787
- if (index !== -1) {
1788
- this.response.splice(index, 1);
1789
- }
1790
- };
1853
+ withCapabilities(capabilities) {
1854
+ this.config.capabilities = capabilities;
1855
+ return this;
1791
1856
  }
1792
1857
  /**
1793
- * Process a request through the interceptor chain
1794
- * Returns either:
1795
- * - { type: 'continue', request } - continue with (possibly modified) request
1796
- * - { type: 'mock', response } - return mock response immediately
1797
- * - { type: 'error', error } - throw error
1858
+ * Set query parameters to append to the connection URL.
1859
+ * Useful for testing mode switches like `?mode=skills_only`.
1860
+ *
1861
+ * @example
1862
+ * ```typescript
1863
+ * const client = await McpTestClient.create({ baseUrl })
1864
+ * .withQueryParams({ mode: 'skills_only' })
1865
+ * .buildAndConnect();
1866
+ * ```
1798
1867
  */
1799
- async processRequest(request, meta) {
1800
- let currentRequest = request;
1801
- const mockDef = this.mocks.match(request);
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 };
1868
+ withQueryParams(params) {
1869
+ this.config.queryParams = { ...this.config.queryParams, ...params };
1870
+ return this;
1839
1871
  }
1840
1872
  /**
1841
- * Process a response through the interceptor chain
1873
+ * Build the McpTestClient instance (does not connect)
1842
1874
  */
1843
- async processResponse(request, response, durationMs) {
1844
- let currentResponse = response;
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;
1875
+ build() {
1876
+ return new McpTestClient(this.config);
1861
1877
  }
1862
1878
  /**
1863
- * Clear all interceptors and mocks
1879
+ * Build the McpTestClient and connect to the server
1864
1880
  */
1865
- clear() {
1866
- this.request = [];
1867
- this.response = [];
1868
- this.mocks.clear();
1881
+ async buildAndConnect() {
1882
+ const client = this.build();
1883
+ await client.connect();
1884
+ return client;
1869
1885
  }
1870
1886
  };
1871
- function sleep3(ms) {
1872
- return new Promise((resolve) => setTimeout(resolve, ms));
1873
- }
1874
1887
 
1875
1888
  // libs/testing/src/client/mcp-test-client.ts
1876
1889
  var DEFAULT_TIMEOUT2 = 3e4;
@@ -1879,6 +1892,21 @@ var DEFAULT_CLIENT_INFO = {
1879
1892
  name: "@frontmcp/testing",
1880
1893
  version: "0.4.0"
1881
1894
  };
1895
+ function extractEntryPaths(data) {
1896
+ let payload = data;
1897
+ if (typeof payload === "string") {
1898
+ try {
1899
+ payload = JSON.parse(payload);
1900
+ } catch {
1901
+ return void 0;
1902
+ }
1903
+ }
1904
+ if (!payload || typeof payload !== "object") return void 0;
1905
+ const paths = payload.entryPaths;
1906
+ if (!Array.isArray(paths)) return void 0;
1907
+ const strings = paths.filter((p) => typeof p === "string");
1908
+ return strings.length > 0 ? strings : void 0;
1909
+ }
1882
1910
  var McpTestClient = class {
1883
1911
  // Platform, capabilities, and queryParams are optional - only set when needed
1884
1912
  config;
@@ -1887,6 +1915,8 @@ var McpTestClient = class {
1887
1915
  requestIdCounter = 0;
1888
1916
  _lastRequestId = 0;
1889
1917
  _sessionId;
1918
+ /** #543 — discovery is attempted at most once per connect. */
1919
+ triedDiscoveredEntryPath = false;
1890
1920
  _sessionInfo = null;
1891
1921
  _authState = { isAnonymous: true, scopes: [] };
1892
1922
  // Logging and tracing
@@ -1904,6 +1934,7 @@ var McpTestClient = class {
1904
1934
  constructor(config) {
1905
1935
  this.config = {
1906
1936
  baseUrl: config.baseUrl,
1937
+ entryPath: config.entryPath,
1907
1938
  transport: config.transport ?? "streamable-http",
1908
1939
  auth: config.auth ?? {},
1909
1940
  publicMode: config.publicMode ?? false,
@@ -1939,11 +1970,13 @@ var McpTestClient = class {
1939
1970
  */
1940
1971
  async connect() {
1941
1972
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
1973
+ this.triedDiscoveredEntryPath = false;
1942
1974
  this.transport = this.createTransport();
1943
1975
  await this.transport.connect();
1944
1976
  const maxRetries = 3;
1945
1977
  const retryDelayMs = 500;
1946
1978
  let lastError;
1979
+ let reportedEntryPaths;
1947
1980
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1948
1981
  const initResponse = await this.initialize();
1949
1982
  if (initResponse.success && initResponse.data) {
@@ -1951,13 +1984,27 @@ var McpTestClient = class {
1951
1984
  break;
1952
1985
  }
1953
1986
  lastError = initResponse.error?.message ?? "Unknown error";
1987
+ reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
1988
+ if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
1989
+ this.triedDiscoveredEntryPath = true;
1990
+ const discovered = reportedEntryPaths[0];
1991
+ this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
1992
+ this.config.entryPath = discovered;
1993
+ await this.transport.close();
1994
+ this.transport = this.createTransport();
1995
+ await this.transport.connect();
1996
+ continue;
1997
+ }
1954
1998
  if (attempt < maxRetries) {
1955
1999
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
1956
2000
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
1957
2001
  }
1958
2002
  }
1959
2003
  if (!this.initResult) {
1960
- throw new Error(`Failed to initialize MCP connection after ${maxRetries} attempts: ${lastError}`);
2004
+ 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.` : "";
2005
+ throw new Error(
2006
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
2007
+ );
1961
2008
  }
1962
2009
  this._sessionId = this.transport.getSessionId();
1963
2010
  this._sessionInfo = {
@@ -2555,6 +2602,11 @@ var McpTestClient = class {
2555
2602
  // ═══════════════════════════════════════════════════════════════════
2556
2603
  // PRIVATE: TRANSPORT & REQUEST HELPERS
2557
2604
  // ═══════════════════════════════════════════════════════════════════
2605
+ /** The MCP path this client is currently pointed at, for log and error text. */
2606
+ mcpPathLabel() {
2607
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
2608
+ return entry ? `/${entry}` : "/";
2609
+ }
2558
2610
  createTransport() {
2559
2611
  let baseUrl = this.config.baseUrl;
2560
2612
  if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
@@ -2568,6 +2620,7 @@ var McpTestClient = class {
2568
2620
  case "streamable-http":
2569
2621
  return new StreamableHttpTransport({
2570
2622
  baseUrl,
2623
+ entryPath: this.config.entryPath,
2571
2624
  timeout: this.config.timeout,
2572
2625
  auth: this.config.auth,
2573
2626
  publicMode: this.config.publicMode,