@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.
@@ -199,233 +199,277 @@ var TestTokenFactory = class {
199
199
  }
200
200
  };
201
201
 
202
- // libs/testing/src/platform/platform-client-info.ts
203
- var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
204
- function getPlatformClientInfo(platform) {
205
- switch (platform) {
206
- case "openai":
207
- return {
208
- name: "ChatGPT",
209
- version: "1.0"
210
- };
211
- case "ext-apps":
212
- return {
213
- name: "mcp-ext-apps",
214
- version: "1.0"
215
- };
216
- case "claude":
217
- return {
218
- name: "claude-desktop",
219
- version: "1.0"
220
- };
221
- case "cursor":
222
- return {
223
- name: "cursor",
224
- version: "1.0"
225
- };
226
- case "continue":
227
- return {
228
- name: "continue",
229
- version: "1.0"
230
- };
231
- case "cody":
232
- return {
233
- name: "cody",
234
- version: "1.0"
235
- };
236
- case "gemini":
237
- return {
238
- name: "gemini",
239
- version: "1.0"
240
- };
241
- case "generic-mcp":
242
- return {
243
- name: "generic-mcp-client",
244
- version: "1.0"
245
- };
246
- case "unknown":
247
- default:
248
- return {
249
- name: "mcp-test-client",
250
- version: "1.0"
251
- };
252
- }
253
- }
254
- function getPlatformCapabilities(platform) {
255
- const baseCapabilities = {
256
- sampling: {},
257
- // Include elicitation.form by default for testing elicitation workflows
258
- // Note: MCP SDK expects form to be an object, not boolean
259
- elicitation: {
260
- form: {}
261
- }
262
- };
263
- if (platform === "ext-apps") {
202
+ // libs/testing/src/interceptor/mock-registry.ts
203
+ var DefaultMockRegistry = class {
204
+ mocks = [];
205
+ add(mock) {
206
+ const entry = {
207
+ definition: mock,
208
+ callCount: 0,
209
+ calls: [],
210
+ remainingUses: mock.times ?? Infinity
211
+ };
212
+ this.mocks.push(entry);
264
213
  return {
265
- ...baseCapabilities,
266
- experimental: {
267
- [MCP_APPS_EXTENSION_KEY]: {
268
- mimeTypes: ["text/html;profile=mcp-app"]
214
+ remove: () => {
215
+ const index = this.mocks.indexOf(entry);
216
+ if (index !== -1) {
217
+ this.mocks.splice(index, 1);
269
218
  }
270
- }
219
+ },
220
+ callCount: () => entry.callCount,
221
+ calls: () => [...entry.calls]
271
222
  };
272
223
  }
273
- return baseCapabilities;
274
- }
275
-
276
- // libs/testing/src/client/mcp-test-client.builder.ts
277
- var McpTestClientBuilder = class {
278
- config;
279
- constructor(config) {
280
- this.config = { ...config };
224
+ clear() {
225
+ this.mocks = [];
281
226
  }
282
- /**
283
- * Set the authentication configuration
284
- */
285
- withAuth(auth) {
286
- this.config.auth = { ...this.config.auth, ...auth };
287
- return this;
227
+ getAll() {
228
+ return this.mocks.map((e) => e.definition);
229
+ }
230
+ match(request) {
231
+ for (const entry of this.mocks) {
232
+ if (entry.remainingUses <= 0) continue;
233
+ const { definition } = entry;
234
+ if (definition.method !== request.method) continue;
235
+ if (definition.params !== void 0) {
236
+ const params = request.params ?? {};
237
+ if (typeof definition.params === "function") {
238
+ if (!definition.params(params)) continue;
239
+ } else {
240
+ if (!this.paramsMatch(definition.params, params)) continue;
241
+ }
242
+ }
243
+ entry.callCount++;
244
+ entry.calls.push(request);
245
+ entry.remainingUses--;
246
+ return definition;
247
+ }
248
+ return void 0;
288
249
  }
289
250
  /**
290
- * Set the bearer token for authentication
251
+ * Check if request params match the mock params definition
291
252
  */
292
- withToken(token) {
293
- this.config.auth = { ...this.config.auth, token };
294
- return this;
253
+ paramsMatch(expected, actual) {
254
+ for (const [key, value] of Object.entries(expected)) {
255
+ if (!(key in actual)) return false;
256
+ const actualValue = actual[key];
257
+ if (Array.isArray(value)) {
258
+ if (!Array.isArray(actualValue)) return false;
259
+ if (value.length !== actualValue.length) return false;
260
+ for (let i = 0; i < value.length; i++) {
261
+ const expectedItem = value[i];
262
+ const actualItem = actualValue[i];
263
+ if (typeof expectedItem === "object" && expectedItem !== null) {
264
+ if (typeof actualItem !== "object" || actualItem === null) return false;
265
+ if (!this.paramsMatch(expectedItem, actualItem)) {
266
+ return false;
267
+ }
268
+ } else if (actualItem !== expectedItem) {
269
+ return false;
270
+ }
271
+ }
272
+ } else if (typeof value === "object" && value !== null) {
273
+ if (typeof actualValue !== "object" || actualValue === null) return false;
274
+ if (!this.paramsMatch(value, actualValue)) {
275
+ return false;
276
+ }
277
+ } else if (actualValue !== value) {
278
+ return false;
279
+ }
280
+ }
281
+ return true;
295
282
  }
283
+ };
284
+ var mockResponse = {
296
285
  /**
297
- * Add custom headers to all requests
286
+ * Create a successful JSON-RPC response
298
287
  */
299
- withHeaders(headers) {
300
- this.config.auth = {
301
- ...this.config.auth,
302
- headers: { ...this.config.auth?.headers, ...headers }
288
+ success(result, id = 1) {
289
+ return {
290
+ jsonrpc: "2.0",
291
+ id,
292
+ result
303
293
  };
304
- return this;
305
- }
294
+ },
306
295
  /**
307
- * Set the transport type
296
+ * Create an error JSON-RPC response
308
297
  */
309
- withTransport(transport) {
310
- this.config.transport = transport;
311
- return this;
312
- }
298
+ error(code, message, data, id = 1) {
299
+ return {
300
+ jsonrpc: "2.0",
301
+ id,
302
+ error: { code, message, data }
303
+ };
304
+ },
313
305
  /**
314
- * Set the request timeout in milliseconds
306
+ * Create a tool result response
315
307
  */
316
- withTimeout(timeoutMs) {
317
- this.config.timeout = timeoutMs;
318
- return this;
319
- }
308
+ toolResult(content, id = 1) {
309
+ return {
310
+ jsonrpc: "2.0",
311
+ id,
312
+ result: { content }
313
+ };
314
+ },
320
315
  /**
321
- * Enable debug logging
316
+ * Create a tools/list response
322
317
  */
323
- withDebug(enabled = true) {
324
- this.config.debug = enabled;
325
- return this;
326
- }
318
+ toolsList(tools, id = 1) {
319
+ return {
320
+ jsonrpc: "2.0",
321
+ id,
322
+ result: { tools }
323
+ };
324
+ },
327
325
  /**
328
- * Enable public mode - skip authentication entirely.
329
- * When true, no Authorization header is sent and anonymous token is not requested.
330
- * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
326
+ * Create a resources/list response
331
327
  */
332
- withPublicMode(enabled = true) {
333
- this.config.publicMode = enabled;
334
- return this;
335
- }
328
+ resourcesList(resources, id = 1) {
329
+ return {
330
+ jsonrpc: "2.0",
331
+ id,
332
+ result: { resources }
333
+ };
334
+ },
336
335
  /**
337
- * Set the MCP protocol version to request
336
+ * Create a resources/read response
338
337
  */
339
- withProtocolVersion(version) {
340
- this.config.protocolVersion = version;
341
- return this;
342
- }
338
+ resourceRead(contents, id = 1) {
339
+ return {
340
+ jsonrpc: "2.0",
341
+ id,
342
+ result: { contents }
343
+ };
344
+ },
343
345
  /**
344
- * Set the client info sent during initialization
346
+ * Common MCP errors
345
347
  */
346
- withClientInfo(info) {
347
- this.config.clientInfo = info;
348
- return this;
348
+ errors: {
349
+ methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
350
+ invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
351
+ internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
352
+ resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
353
+ toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
354
+ unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
355
+ forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
356
+ }
357
+ };
358
+
359
+ // libs/testing/src/interceptor/interceptor-chain.ts
360
+ var DefaultInterceptorChain = class {
361
+ request = [];
362
+ response = [];
363
+ mocks;
364
+ constructor() {
365
+ this.mocks = new DefaultMockRegistry();
349
366
  }
350
367
  /**
351
- * Set the platform type for testing platform-specific meta keys.
352
- * Automatically configures clientInfo and capabilities for platform detection.
353
- *
354
- * Platform-specific behavior:
355
- * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
356
- * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
357
- * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
358
- * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
359
- * - Other platforms follow similar patterns
360
- *
361
- * @example
362
- * ```typescript
363
- * const client = await McpTestClient.create({ baseUrl })
364
- * .withPlatform('openai')
365
- * .buildAndConnect();
366
- *
367
- * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
368
- * const extAppsClient = await McpTestClient.create({ baseUrl })
369
- * .withPlatform('ext-apps')
370
- * .buildAndConnect();
371
- * ```
368
+ * Add a request interceptor
372
369
  */
373
- withPlatform(platform) {
374
- this.config.platform = platform;
375
- this.config.clientInfo = getPlatformClientInfo(platform);
376
- this.config.capabilities = getPlatformCapabilities(platform);
377
- return this;
370
+ addRequestInterceptor(interceptor) {
371
+ this.request.push(interceptor);
372
+ return () => {
373
+ const index = this.request.indexOf(interceptor);
374
+ if (index !== -1) {
375
+ this.request.splice(index, 1);
376
+ }
377
+ };
378
378
  }
379
379
  /**
380
- * Set custom client capabilities for MCP initialization.
381
- * Use this for fine-grained control over capabilities sent during initialization.
382
- *
383
- * @example
384
- * ```typescript
385
- * const client = await McpTestClient.create({ baseUrl })
386
- * .withCapabilities({
387
- * sampling: {},
388
- * experimental: {
389
- * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
390
- * }
391
- * })
392
- * .buildAndConnect();
393
- * ```
380
+ * Add a response interceptor
394
381
  */
395
- withCapabilities(capabilities) {
396
- this.config.capabilities = capabilities;
397
- return this;
382
+ addResponseInterceptor(interceptor) {
383
+ this.response.push(interceptor);
384
+ return () => {
385
+ const index = this.response.indexOf(interceptor);
386
+ if (index !== -1) {
387
+ this.response.splice(index, 1);
388
+ }
389
+ };
398
390
  }
399
391
  /**
400
- * Set query parameters to append to the connection URL.
401
- * Useful for testing mode switches like `?mode=skills_only`.
402
- *
403
- * @example
404
- * ```typescript
405
- * const client = await McpTestClient.create({ baseUrl })
406
- * .withQueryParams({ mode: 'skills_only' })
407
- * .buildAndConnect();
408
- * ```
392
+ * Process a request through the interceptor chain
393
+ * Returns either:
394
+ * - { type: 'continue', request } - continue with (possibly modified) request
395
+ * - { type: 'mock', response } - return mock response immediately
396
+ * - { type: 'error', error } - throw error
409
397
  */
410
- withQueryParams(params) {
411
- this.config.queryParams = { ...this.config.queryParams, ...params };
412
- return this;
398
+ async processRequest(request, meta) {
399
+ let currentRequest = request;
400
+ const mockDef = this.mocks.match(request);
401
+ if (mockDef) {
402
+ if (mockDef.delay && mockDef.delay > 0) {
403
+ await sleep(mockDef.delay);
404
+ }
405
+ let mockResponse2;
406
+ if (typeof mockDef.response === "function") {
407
+ mockResponse2 = await mockDef.response(request);
408
+ } else {
409
+ mockResponse2 = mockDef.response;
410
+ }
411
+ return {
412
+ type: "mock",
413
+ response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
414
+ };
415
+ }
416
+ for (const interceptor of this.request) {
417
+ const ctx = {
418
+ request: currentRequest,
419
+ meta
420
+ };
421
+ const result = await interceptor(ctx);
422
+ switch (result.action) {
423
+ case "passthrough":
424
+ break;
425
+ case "modify":
426
+ currentRequest = result.request;
427
+ break;
428
+ case "mock":
429
+ return {
430
+ type: "mock",
431
+ response: { ...result.response, id: request.id ?? result.response.id }
432
+ };
433
+ case "error":
434
+ return { type: "error", error: result.error };
435
+ }
436
+ }
437
+ return { type: "continue", request: currentRequest };
413
438
  }
414
439
  /**
415
- * Build the McpTestClient instance (does not connect)
440
+ * Process a response through the interceptor chain
416
441
  */
417
- build() {
418
- return new McpTestClient(this.config);
442
+ async processResponse(request, response, durationMs) {
443
+ let currentResponse = response;
444
+ for (const interceptor of this.response) {
445
+ const ctx = {
446
+ request,
447
+ response: currentResponse,
448
+ durationMs
449
+ };
450
+ const result = await interceptor(ctx);
451
+ switch (result.action) {
452
+ case "passthrough":
453
+ break;
454
+ case "modify":
455
+ currentResponse = result.response;
456
+ break;
457
+ }
458
+ }
459
+ return currentResponse;
419
460
  }
420
461
  /**
421
- * Build the McpTestClient and connect to the server
462
+ * Clear all interceptors and mocks
422
463
  */
423
- async buildAndConnect() {
424
- const client = this.build();
425
- await client.connect();
426
- return client;
464
+ clear() {
465
+ this.request = [];
466
+ this.response = [];
467
+ this.mocks.clear();
427
468
  }
428
469
  };
470
+ function sleep(ms) {
471
+ return new Promise((resolve) => setTimeout(resolve, ms));
472
+ }
429
473
 
430
474
  // libs/testing/src/transport/streamable-http.transport.ts
431
475
  var DEFAULT_TIMEOUT = 3e4;
@@ -444,6 +488,7 @@ var StreamableHttpTransport = class {
444
488
  this.config = {
445
489
  baseUrl: config.baseUrl.replace(/\/$/, ""),
446
490
  // Remove trailing slash
491
+ entryPath: config.entryPath,
447
492
  timeout: config.timeout ?? DEFAULT_TIMEOUT,
448
493
  auth: config.auth ?? {},
449
494
  publicMode: config.publicMode ?? false,
@@ -536,7 +581,7 @@ var StreamableHttpTransport = class {
536
581
  }
537
582
  const headers = this.buildHeaders();
538
583
  this.lastRequestHeaders = headers;
539
- const url = `${this.config.baseUrl}/`;
584
+ const url = this.mcpUrl();
540
585
  this.log(`POST ${url}`, message);
541
586
  const controller = new AbortController();
542
587
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -606,7 +651,7 @@ var StreamableHttpTransport = class {
606
651
  this.ensureConnected();
607
652
  const headers = this.buildHeaders();
608
653
  this.lastRequestHeaders = headers;
609
- const url = `${this.config.baseUrl}/`;
654
+ const url = this.mcpUrl();
610
655
  this.log(`POST ${url} (notification)`, message);
611
656
  const controller = new AbortController();
612
657
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -637,7 +682,7 @@ var StreamableHttpTransport = class {
637
682
  this.ensureConnected();
638
683
  const headers = this.buildHeaders();
639
684
  this.lastRequestHeaders = headers;
640
- const url = `${this.config.baseUrl}/`;
685
+ const url = this.mcpUrl();
641
686
  this.log(`POST ${url} (raw)`, data);
642
687
  const controller = new AbortController();
643
688
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -920,7 +965,7 @@ var StreamableHttpTransport = class {
920
965
  */
921
966
  async sendElicitationResponse(requestId, response) {
922
967
  const headers = this.buildHeaders();
923
- const url = `${this.config.baseUrl}/`;
968
+ const url = this.mcpUrl();
924
969
  const rpcResponse = {
925
970
  jsonrpc: "2.0",
926
971
  id: requestId,
@@ -940,6 +985,18 @@ var StreamableHttpTransport = class {
940
985
  this.log("Failed to send elicitation response:", error);
941
986
  }
942
987
  }
988
+ /**
989
+ * The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
990
+ * trailing slash is kept because both hosts normalize it and the original
991
+ * behaviour (`${baseUrl}/`) relied on it (issue #543).
992
+ */
993
+ mcpUrl() {
994
+ const url = new URL(this.config.baseUrl);
995
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
996
+ const basePath = url.pathname.replace(/\/+$/, "");
997
+ url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
998
+ return url.toString();
999
+ }
943
1000
  buildHeaders() {
944
1001
  const headers = {
945
1002
  "Content-Type": "application/json",
@@ -1027,277 +1084,233 @@ var StreamableHttpTransport = class {
1027
1084
  }
1028
1085
  };
1029
1086
 
1030
- // libs/testing/src/interceptor/mock-registry.ts
1031
- var DefaultMockRegistry = class {
1032
- mocks = [];
1033
- add(mock) {
1034
- const entry = {
1035
- definition: mock,
1036
- callCount: 0,
1037
- calls: [],
1038
- remainingUses: mock.times ?? Infinity
1039
- };
1040
- this.mocks.push(entry);
1087
+ // libs/testing/src/platform/platform-client-info.ts
1088
+ var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
1089
+ function getPlatformClientInfo(platform) {
1090
+ switch (platform) {
1091
+ case "openai":
1092
+ return {
1093
+ name: "ChatGPT",
1094
+ version: "1.0"
1095
+ };
1096
+ case "ext-apps":
1097
+ return {
1098
+ name: "mcp-ext-apps",
1099
+ version: "1.0"
1100
+ };
1101
+ case "claude":
1102
+ return {
1103
+ name: "claude-desktop",
1104
+ version: "1.0"
1105
+ };
1106
+ case "cursor":
1107
+ return {
1108
+ name: "cursor",
1109
+ version: "1.0"
1110
+ };
1111
+ case "continue":
1112
+ return {
1113
+ name: "continue",
1114
+ version: "1.0"
1115
+ };
1116
+ case "cody":
1117
+ return {
1118
+ name: "cody",
1119
+ version: "1.0"
1120
+ };
1121
+ case "gemini":
1122
+ return {
1123
+ name: "gemini",
1124
+ version: "1.0"
1125
+ };
1126
+ case "generic-mcp":
1127
+ return {
1128
+ name: "generic-mcp-client",
1129
+ version: "1.0"
1130
+ };
1131
+ case "unknown":
1132
+ default:
1133
+ return {
1134
+ name: "mcp-test-client",
1135
+ version: "1.0"
1136
+ };
1137
+ }
1138
+ }
1139
+ function getPlatformCapabilities(platform) {
1140
+ const baseCapabilities = {
1141
+ sampling: {},
1142
+ // Include elicitation.form by default for testing elicitation workflows
1143
+ // Note: MCP SDK expects form to be an object, not boolean
1144
+ elicitation: {
1145
+ form: {}
1146
+ }
1147
+ };
1148
+ if (platform === "ext-apps") {
1041
1149
  return {
1042
- remove: () => {
1043
- const index = this.mocks.indexOf(entry);
1044
- if (index !== -1) {
1045
- this.mocks.splice(index, 1);
1150
+ ...baseCapabilities,
1151
+ experimental: {
1152
+ [MCP_APPS_EXTENSION_KEY]: {
1153
+ mimeTypes: ["text/html;profile=mcp-app"]
1046
1154
  }
1047
- },
1048
- callCount: () => entry.callCount,
1049
- calls: () => [...entry.calls]
1155
+ }
1050
1156
  };
1051
1157
  }
1052
- clear() {
1053
- this.mocks = [];
1054
- }
1055
- getAll() {
1056
- return this.mocks.map((e) => e.definition);
1057
- }
1058
- match(request) {
1059
- for (const entry of this.mocks) {
1060
- if (entry.remainingUses <= 0) continue;
1061
- const { definition } = entry;
1062
- if (definition.method !== request.method) continue;
1063
- if (definition.params !== void 0) {
1064
- const params = request.params ?? {};
1065
- if (typeof definition.params === "function") {
1066
- if (!definition.params(params)) continue;
1067
- } else {
1068
- if (!this.paramsMatch(definition.params, params)) continue;
1069
- }
1070
- }
1071
- entry.callCount++;
1072
- entry.calls.push(request);
1073
- entry.remainingUses--;
1074
- return definition;
1075
- }
1076
- return void 0;
1158
+ return baseCapabilities;
1159
+ }
1160
+
1161
+ // libs/testing/src/client/mcp-test-client.builder.ts
1162
+ var McpTestClientBuilder = class {
1163
+ config;
1164
+ constructor(config) {
1165
+ this.config = { ...config };
1077
1166
  }
1078
1167
  /**
1079
- * Check if request params match the mock params definition
1168
+ * Set the authentication configuration
1080
1169
  */
1081
- paramsMatch(expected, actual) {
1082
- for (const [key, value] of Object.entries(expected)) {
1083
- if (!(key in actual)) return false;
1084
- const actualValue = actual[key];
1085
- if (Array.isArray(value)) {
1086
- if (!Array.isArray(actualValue)) return false;
1087
- if (value.length !== actualValue.length) return false;
1088
- for (let i = 0; i < value.length; i++) {
1089
- const expectedItem = value[i];
1090
- const actualItem = actualValue[i];
1091
- if (typeof expectedItem === "object" && expectedItem !== null) {
1092
- if (typeof actualItem !== "object" || actualItem === null) return false;
1093
- if (!this.paramsMatch(expectedItem, actualItem)) {
1094
- return false;
1095
- }
1096
- } else if (actualItem !== expectedItem) {
1097
- return false;
1098
- }
1099
- }
1100
- } else if (typeof value === "object" && value !== null) {
1101
- if (typeof actualValue !== "object" || actualValue === null) return false;
1102
- if (!this.paramsMatch(value, actualValue)) {
1103
- return false;
1104
- }
1105
- } else if (actualValue !== value) {
1106
- return false;
1107
- }
1108
- }
1109
- return true;
1170
+ withAuth(auth) {
1171
+ this.config.auth = { ...this.config.auth, ...auth };
1172
+ return this;
1110
1173
  }
1111
- };
1112
- var mockResponse = {
1113
1174
  /**
1114
- * Create a successful JSON-RPC response
1175
+ * Set the bearer token for authentication
1115
1176
  */
1116
- success(result, id = 1) {
1117
- return {
1118
- jsonrpc: "2.0",
1119
- id,
1120
- result
1121
- };
1122
- },
1177
+ withToken(token) {
1178
+ this.config.auth = { ...this.config.auth, token };
1179
+ return this;
1180
+ }
1123
1181
  /**
1124
- * Create an error JSON-RPC response
1182
+ * Add custom headers to all requests
1125
1183
  */
1126
- error(code, message, data, id = 1) {
1127
- return {
1128
- jsonrpc: "2.0",
1129
- id,
1130
- error: { code, message, data }
1184
+ withHeaders(headers) {
1185
+ this.config.auth = {
1186
+ ...this.config.auth,
1187
+ headers: { ...this.config.auth?.headers, ...headers }
1131
1188
  };
1132
- },
1189
+ return this;
1190
+ }
1133
1191
  /**
1134
- * Create a tool result response
1192
+ * Set the transport type
1135
1193
  */
1136
- toolResult(content, id = 1) {
1137
- return {
1138
- jsonrpc: "2.0",
1139
- id,
1140
- result: { content }
1141
- };
1142
- },
1194
+ withTransport(transport) {
1195
+ this.config.transport = transport;
1196
+ return this;
1197
+ }
1143
1198
  /**
1144
- * Create a tools/list response
1199
+ * Set the request timeout in milliseconds
1145
1200
  */
1146
- toolsList(tools, id = 1) {
1147
- return {
1148
- jsonrpc: "2.0",
1149
- id,
1150
- result: { tools }
1151
- };
1152
- },
1201
+ withTimeout(timeoutMs) {
1202
+ this.config.timeout = timeoutMs;
1203
+ return this;
1204
+ }
1153
1205
  /**
1154
- * Create a resources/list response
1155
- */
1156
- resourcesList(resources, id = 1) {
1157
- return {
1158
- jsonrpc: "2.0",
1159
- id,
1160
- result: { resources }
1161
- };
1162
- },
1206
+ * Enable debug logging
1207
+ */
1208
+ withDebug(enabled = true) {
1209
+ this.config.debug = enabled;
1210
+ return this;
1211
+ }
1163
1212
  /**
1164
- * Create a resources/read response
1213
+ * Enable public mode - skip authentication entirely.
1214
+ * When true, no Authorization header is sent and anonymous token is not requested.
1215
+ * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
1165
1216
  */
1166
- resourceRead(contents, id = 1) {
1167
- return {
1168
- jsonrpc: "2.0",
1169
- id,
1170
- result: { contents }
1171
- };
1172
- },
1217
+ withPublicMode(enabled = true) {
1218
+ this.config.publicMode = enabled;
1219
+ return this;
1220
+ }
1173
1221
  /**
1174
- * Common MCP errors
1222
+ * Set the MCP protocol version to request
1175
1223
  */
1176
- errors: {
1177
- methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
1178
- invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
1179
- internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
1180
- resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
1181
- toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
1182
- unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
1183
- forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
1224
+ withProtocolVersion(version) {
1225
+ this.config.protocolVersion = version;
1226
+ return this;
1184
1227
  }
1185
- };
1186
-
1187
- // libs/testing/src/interceptor/interceptor-chain.ts
1188
- var DefaultInterceptorChain = class {
1189
- request = [];
1190
- response = [];
1191
- mocks;
1192
- constructor() {
1193
- this.mocks = new DefaultMockRegistry();
1228
+ /**
1229
+ * Set the client info sent during initialization
1230
+ */
1231
+ withClientInfo(info) {
1232
+ this.config.clientInfo = info;
1233
+ return this;
1194
1234
  }
1195
1235
  /**
1196
- * Add a request interceptor
1236
+ * Set the platform type for testing platform-specific meta keys.
1237
+ * Automatically configures clientInfo and capabilities for platform detection.
1238
+ *
1239
+ * Platform-specific behavior:
1240
+ * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
1241
+ * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
1242
+ * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
1243
+ * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
1244
+ * - Other platforms follow similar patterns
1245
+ *
1246
+ * @example
1247
+ * ```typescript
1248
+ * const client = await McpTestClient.create({ baseUrl })
1249
+ * .withPlatform('openai')
1250
+ * .buildAndConnect();
1251
+ *
1252
+ * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
1253
+ * const extAppsClient = await McpTestClient.create({ baseUrl })
1254
+ * .withPlatform('ext-apps')
1255
+ * .buildAndConnect();
1256
+ * ```
1197
1257
  */
1198
- addRequestInterceptor(interceptor) {
1199
- this.request.push(interceptor);
1200
- return () => {
1201
- const index = this.request.indexOf(interceptor);
1202
- if (index !== -1) {
1203
- this.request.splice(index, 1);
1204
- }
1205
- };
1258
+ withPlatform(platform) {
1259
+ this.config.platform = platform;
1260
+ this.config.clientInfo = getPlatformClientInfo(platform);
1261
+ this.config.capabilities = getPlatformCapabilities(platform);
1262
+ return this;
1206
1263
  }
1207
1264
  /**
1208
- * Add a response interceptor
1265
+ * Set custom client capabilities for MCP initialization.
1266
+ * Use this for fine-grained control over capabilities sent during initialization.
1267
+ *
1268
+ * @example
1269
+ * ```typescript
1270
+ * const client = await McpTestClient.create({ baseUrl })
1271
+ * .withCapabilities({
1272
+ * sampling: {},
1273
+ * experimental: {
1274
+ * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
1275
+ * }
1276
+ * })
1277
+ * .buildAndConnect();
1278
+ * ```
1209
1279
  */
1210
- addResponseInterceptor(interceptor) {
1211
- this.response.push(interceptor);
1212
- return () => {
1213
- const index = this.response.indexOf(interceptor);
1214
- if (index !== -1) {
1215
- this.response.splice(index, 1);
1216
- }
1217
- };
1280
+ withCapabilities(capabilities) {
1281
+ this.config.capabilities = capabilities;
1282
+ return this;
1218
1283
  }
1219
1284
  /**
1220
- * Process a request through the interceptor chain
1221
- * Returns either:
1222
- * - { type: 'continue', request } - continue with (possibly modified) request
1223
- * - { type: 'mock', response } - return mock response immediately
1224
- * - { type: 'error', error } - throw error
1285
+ * Set query parameters to append to the connection URL.
1286
+ * Useful for testing mode switches like `?mode=skills_only`.
1287
+ *
1288
+ * @example
1289
+ * ```typescript
1290
+ * const client = await McpTestClient.create({ baseUrl })
1291
+ * .withQueryParams({ mode: 'skills_only' })
1292
+ * .buildAndConnect();
1293
+ * ```
1225
1294
  */
1226
- async processRequest(request, meta) {
1227
- let currentRequest = request;
1228
- const mockDef = this.mocks.match(request);
1229
- if (mockDef) {
1230
- if (mockDef.delay && mockDef.delay > 0) {
1231
- await sleep(mockDef.delay);
1232
- }
1233
- let mockResponse2;
1234
- if (typeof mockDef.response === "function") {
1235
- mockResponse2 = await mockDef.response(request);
1236
- } else {
1237
- mockResponse2 = mockDef.response;
1238
- }
1239
- return {
1240
- type: "mock",
1241
- response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
1242
- };
1243
- }
1244
- for (const interceptor of this.request) {
1245
- const ctx = {
1246
- request: currentRequest,
1247
- meta
1248
- };
1249
- const result = await interceptor(ctx);
1250
- switch (result.action) {
1251
- case "passthrough":
1252
- break;
1253
- case "modify":
1254
- currentRequest = result.request;
1255
- break;
1256
- case "mock":
1257
- return {
1258
- type: "mock",
1259
- response: { ...result.response, id: request.id ?? result.response.id }
1260
- };
1261
- case "error":
1262
- return { type: "error", error: result.error };
1263
- }
1264
- }
1265
- return { type: "continue", request: currentRequest };
1295
+ withQueryParams(params) {
1296
+ this.config.queryParams = { ...this.config.queryParams, ...params };
1297
+ return this;
1266
1298
  }
1267
1299
  /**
1268
- * Process a response through the interceptor chain
1300
+ * Build the McpTestClient instance (does not connect)
1269
1301
  */
1270
- async processResponse(request, response, durationMs) {
1271
- let currentResponse = response;
1272
- for (const interceptor of this.response) {
1273
- const ctx = {
1274
- request,
1275
- response: currentResponse,
1276
- durationMs
1277
- };
1278
- const result = await interceptor(ctx);
1279
- switch (result.action) {
1280
- case "passthrough":
1281
- break;
1282
- case "modify":
1283
- currentResponse = result.response;
1284
- break;
1285
- }
1286
- }
1287
- return currentResponse;
1302
+ build() {
1303
+ return new McpTestClient(this.config);
1288
1304
  }
1289
1305
  /**
1290
- * Clear all interceptors and mocks
1306
+ * Build the McpTestClient and connect to the server
1291
1307
  */
1292
- clear() {
1293
- this.request = [];
1294
- this.response = [];
1295
- this.mocks.clear();
1308
+ async buildAndConnect() {
1309
+ const client = this.build();
1310
+ await client.connect();
1311
+ return client;
1296
1312
  }
1297
1313
  };
1298
- function sleep(ms) {
1299
- return new Promise((resolve) => setTimeout(resolve, ms));
1300
- }
1301
1314
 
1302
1315
  // libs/testing/src/client/mcp-test-client.ts
1303
1316
  var DEFAULT_TIMEOUT2 = 3e4;
@@ -1306,6 +1319,21 @@ var DEFAULT_CLIENT_INFO = {
1306
1319
  name: "@frontmcp/testing",
1307
1320
  version: "0.4.0"
1308
1321
  };
1322
+ function extractEntryPaths(data) {
1323
+ let payload = data;
1324
+ if (typeof payload === "string") {
1325
+ try {
1326
+ payload = JSON.parse(payload);
1327
+ } catch {
1328
+ return void 0;
1329
+ }
1330
+ }
1331
+ if (!payload || typeof payload !== "object") return void 0;
1332
+ const paths = payload.entryPaths;
1333
+ if (!Array.isArray(paths)) return void 0;
1334
+ const strings = paths.filter((p) => typeof p === "string");
1335
+ return strings.length > 0 ? strings : void 0;
1336
+ }
1309
1337
  var McpTestClient = class {
1310
1338
  // Platform, capabilities, and queryParams are optional - only set when needed
1311
1339
  config;
@@ -1314,6 +1342,8 @@ var McpTestClient = class {
1314
1342
  requestIdCounter = 0;
1315
1343
  _lastRequestId = 0;
1316
1344
  _sessionId;
1345
+ /** #543 — discovery is attempted at most once per connect. */
1346
+ triedDiscoveredEntryPath = false;
1317
1347
  _sessionInfo = null;
1318
1348
  _authState = { isAnonymous: true, scopes: [] };
1319
1349
  // Logging and tracing
@@ -1331,6 +1361,7 @@ var McpTestClient = class {
1331
1361
  constructor(config) {
1332
1362
  this.config = {
1333
1363
  baseUrl: config.baseUrl,
1364
+ entryPath: config.entryPath,
1334
1365
  transport: config.transport ?? "streamable-http",
1335
1366
  auth: config.auth ?? {},
1336
1367
  publicMode: config.publicMode ?? false,
@@ -1366,11 +1397,13 @@ var McpTestClient = class {
1366
1397
  */
1367
1398
  async connect() {
1368
1399
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
1400
+ this.triedDiscoveredEntryPath = false;
1369
1401
  this.transport = this.createTransport();
1370
1402
  await this.transport.connect();
1371
1403
  const maxRetries = 3;
1372
1404
  const retryDelayMs = 500;
1373
1405
  let lastError;
1406
+ let reportedEntryPaths;
1374
1407
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1375
1408
  const initResponse = await this.initialize();
1376
1409
  if (initResponse.success && initResponse.data) {
@@ -1378,13 +1411,27 @@ var McpTestClient = class {
1378
1411
  break;
1379
1412
  }
1380
1413
  lastError = initResponse.error?.message ?? "Unknown error";
1414
+ reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
1415
+ if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
1416
+ this.triedDiscoveredEntryPath = true;
1417
+ const discovered = reportedEntryPaths[0];
1418
+ this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
1419
+ this.config.entryPath = discovered;
1420
+ await this.transport.close();
1421
+ this.transport = this.createTransport();
1422
+ await this.transport.connect();
1423
+ continue;
1424
+ }
1381
1425
  if (attempt < maxRetries) {
1382
1426
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
1383
1427
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
1384
1428
  }
1385
1429
  }
1386
1430
  if (!this.initResult) {
1387
- throw new Error(`Failed to initialize MCP connection after ${maxRetries} attempts: ${lastError}`);
1431
+ 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.` : "";
1432
+ throw new Error(
1433
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
1434
+ );
1388
1435
  }
1389
1436
  this._sessionId = this.transport.getSessionId();
1390
1437
  this._sessionInfo = {
@@ -1982,6 +2029,11 @@ var McpTestClient = class {
1982
2029
  // ═══════════════════════════════════════════════════════════════════
1983
2030
  // PRIVATE: TRANSPORT & REQUEST HELPERS
1984
2031
  // ═══════════════════════════════════════════════════════════════════
2032
+ /** The MCP path this client is currently pointed at, for log and error text. */
2033
+ mcpPathLabel() {
2034
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
2035
+ return entry ? `/${entry}` : "/";
2036
+ }
1985
2037
  createTransport() {
1986
2038
  let baseUrl = this.config.baseUrl;
1987
2039
  if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
@@ -1995,6 +2047,7 @@ var McpTestClient = class {
1995
2047
  case "streamable-http":
1996
2048
  return new StreamableHttpTransport({
1997
2049
  baseUrl,
2050
+ entryPath: this.config.entryPath,
1998
2051
  timeout: this.config.timeout,
1999
2052
  auth: this.config.auth,
2000
2053
  publicMode: this.config.publicMode,
@@ -2986,13 +3039,17 @@ var currentConfig = {};
2986
3039
  var serverInstance = null;
2987
3040
  var tokenFactory = null;
2988
3041
  var serverStartedByUs = false;
3042
+ var skipScopes = [{ skipped: false }];
3043
+ function currentSkipScope() {
3044
+ return skipScopes[skipScopes.length - 1];
3045
+ }
2989
3046
  async function initializeSharedResources() {
2990
3047
  if (!tokenFactory) {
2991
3048
  const sharedSecret = currentConfig.env?.["JWT_SECRET"];
2992
3049
  tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
2993
3050
  }
2994
3051
  if (!serverInstance) {
2995
- if (currentConfig.baseUrl) {
3052
+ if (currentConfig.baseUrl && !currentConfig.server) {
2996
3053
  serverInstance = TestServer.connect(currentConfig.baseUrl);
2997
3054
  serverStartedByUs = false;
2998
3055
  } else if (currentConfig.server) {
@@ -3042,7 +3099,8 @@ async function createTestFixtures() {
3042
3099
  throw new Error("Token factory not initialized");
3043
3100
  }
3044
3101
  const clientInstance = McpTestClient.create({
3045
- baseUrl: serverInstance.info.baseUrl,
3102
+ baseUrl: resolveClientBaseUrl(serverInstance),
3103
+ entryPath: currentConfig.entryPath,
3046
3104
  transport: currentConfig.transport ?? "streamable-http",
3047
3105
  publicMode: currentConfig.publicMode
3048
3106
  }).build();
@@ -3091,6 +3149,9 @@ async function cleanupSharedResources() {
3091
3149
  tokenFactory = null;
3092
3150
  serverStartedByUs = false;
3093
3151
  }
3152
+ function resolveClientBaseUrl(server) {
3153
+ return currentConfig.baseUrl ?? server.info.baseUrl;
3154
+ }
3094
3155
  function createAuthFixture(factory) {
3095
3156
  const users = {
3096
3157
  admin: {
@@ -3140,7 +3201,8 @@ function createServerFixture(server) {
3140
3201
  info: server.info,
3141
3202
  createClient: async (opts) => {
3142
3203
  return McpTestClient.create({
3143
- baseUrl: server.info.baseUrl,
3204
+ baseUrl: resolveClientBaseUrl(server),
3205
+ entryPath: opts?.entryPath ?? currentConfig.entryPath,
3144
3206
  transport: opts?.transport ?? "streamable-http",
3145
3207
  auth: opts?.token ? { token: opts.token } : void 0,
3146
3208
  clientInfo: opts?.clientInfo,
@@ -3149,7 +3211,8 @@ function createServerFixture(server) {
3149
3211
  },
3150
3212
  createClientBuilder: () => {
3151
3213
  const builder = new McpTestClientBuilder({
3152
- baseUrl: server.info.baseUrl,
3214
+ baseUrl: resolveClientBaseUrl(server),
3215
+ entryPath: currentConfig.entryPath,
3153
3216
  publicMode: currentConfig.publicMode
3154
3217
  });
3155
3218
  return builder;
@@ -3165,8 +3228,8 @@ function resolveServerCommand(server) {
3165
3228
  }
3166
3229
  return `npx tsx ${server}`;
3167
3230
  }
3168
- function testWithFixtures(name, fn) {
3169
- it(name, async () => {
3231
+ function runWithFixtures(fn) {
3232
+ return async () => {
3170
3233
  const fixtures = await createTestFixtures();
3171
3234
  let testFailed = false;
3172
3235
  try {
@@ -3177,7 +3240,15 @@ function testWithFixtures(name, fn) {
3177
3240
  } finally {
3178
3241
  await cleanupTestFixtures(fixtures, testFailed);
3179
3242
  }
3180
- });
3243
+ };
3244
+ }
3245
+ function testWithFixtures(name, fn) {
3246
+ const scope = currentSkipScope();
3247
+ if (scope.skipped) {
3248
+ it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
3249
+ return;
3250
+ }
3251
+ it(name, runWithFixtures(fn));
3181
3252
  }
3182
3253
  function use(config) {
3183
3254
  currentConfig = { ...currentConfig, ...config };
@@ -3185,40 +3256,60 @@ function use(config) {
3185
3256
  await cleanupSharedResources();
3186
3257
  });
3187
3258
  }
3188
- function skip(name, fn) {
3189
- it.skip(name, async () => {
3190
- const fixtures = await createTestFixtures();
3191
- let testFailed = false;
3192
- try {
3193
- await fn(fixtures);
3194
- } catch (error) {
3195
- testFailed = true;
3196
- throw error;
3197
- } finally {
3198
- await cleanupTestFixtures(fixtures, testFailed);
3259
+ function skip(nameOrCondition, fnOrReason) {
3260
+ if (typeof nameOrCondition === "boolean") {
3261
+ if (nameOrCondition) {
3262
+ const scope = currentSkipScope();
3263
+ scope.skipped = true;
3264
+ scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
3199
3265
  }
3200
- });
3266
+ return;
3267
+ }
3268
+ if (typeof fnOrReason !== "function") {
3269
+ throw new TypeError(
3270
+ `test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
3271
+ );
3272
+ }
3273
+ it.skip(nameOrCondition, runWithFixtures(fnOrReason));
3201
3274
  }
3202
3275
  function only(name, fn) {
3203
- it.only(name, async () => {
3204
- const fixtures = await createTestFixtures();
3205
- let testFailed = false;
3206
- try {
3207
- await fn(fixtures);
3208
- } catch (error) {
3209
- testFailed = true;
3210
- throw error;
3211
- } finally {
3212
- await cleanupTestFixtures(fixtures, testFailed);
3213
- }
3214
- });
3276
+ const scope = currentSkipScope();
3277
+ if (scope.skipped) {
3278
+ it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
3279
+ return;
3280
+ }
3281
+ it.only(name, runWithFixtures(fn));
3215
3282
  }
3216
3283
  function todo(name) {
3217
3284
  it.todo(name);
3218
3285
  }
3286
+ function withSkipScope(register) {
3287
+ return (name, fn) => {
3288
+ register(name, () => {
3289
+ const parent = currentSkipScope();
3290
+ skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
3291
+ try {
3292
+ fn();
3293
+ } finally {
3294
+ skipScopes.pop();
3295
+ }
3296
+ });
3297
+ };
3298
+ }
3299
+ function eachWithSkipScope(each) {
3300
+ return ((...eachArgs) => {
3301
+ const register = each(...eachArgs);
3302
+ return withSkipScope(register);
3303
+ });
3304
+ }
3305
+ var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
3306
+ only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
3307
+ skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
3308
+ each: eachWithSkipScope(describe.each)
3309
+ });
3219
3310
  var test = testWithFixtures;
3220
3311
  test.use = use;
3221
- test.describe = describe;
3312
+ test.describe = describeWithSkipScope;
3222
3313
  test.beforeAll = beforeAll;
3223
3314
  test.beforeEach = beforeEach;
3224
3315
  test.afterEach = afterEach;