@frontmcp/testing 1.7.2 → 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/fixtures/index.js CHANGED
@@ -215,233 +215,277 @@ var TestTokenFactory = class {
215
215
  }
216
216
  };
217
217
 
218
- // libs/testing/src/platform/platform-client-info.ts
219
- var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
220
- function getPlatformClientInfo(platform) {
221
- switch (platform) {
222
- case "openai":
223
- return {
224
- name: "ChatGPT",
225
- version: "1.0"
226
- };
227
- case "ext-apps":
228
- return {
229
- name: "mcp-ext-apps",
230
- version: "1.0"
231
- };
232
- case "claude":
233
- return {
234
- name: "claude-desktop",
235
- version: "1.0"
236
- };
237
- case "cursor":
238
- return {
239
- name: "cursor",
240
- version: "1.0"
241
- };
242
- case "continue":
243
- return {
244
- name: "continue",
245
- version: "1.0"
246
- };
247
- case "cody":
248
- return {
249
- name: "cody",
250
- version: "1.0"
251
- };
252
- case "gemini":
253
- return {
254
- name: "gemini",
255
- version: "1.0"
256
- };
257
- case "generic-mcp":
258
- return {
259
- name: "generic-mcp-client",
260
- version: "1.0"
261
- };
262
- case "unknown":
263
- default:
264
- return {
265
- name: "mcp-test-client",
266
- version: "1.0"
267
- };
268
- }
269
- }
270
- function getPlatformCapabilities(platform) {
271
- const baseCapabilities = {
272
- sampling: {},
273
- // Include elicitation.form by default for testing elicitation workflows
274
- // Note: MCP SDK expects form to be an object, not boolean
275
- elicitation: {
276
- form: {}
277
- }
278
- };
279
- if (platform === "ext-apps") {
218
+ // libs/testing/src/interceptor/mock-registry.ts
219
+ var DefaultMockRegistry = class {
220
+ mocks = [];
221
+ add(mock) {
222
+ const entry = {
223
+ definition: mock,
224
+ callCount: 0,
225
+ calls: [],
226
+ remainingUses: mock.times ?? Infinity
227
+ };
228
+ this.mocks.push(entry);
280
229
  return {
281
- ...baseCapabilities,
282
- experimental: {
283
- [MCP_APPS_EXTENSION_KEY]: {
284
- mimeTypes: ["text/html;profile=mcp-app"]
230
+ remove: () => {
231
+ const index = this.mocks.indexOf(entry);
232
+ if (index !== -1) {
233
+ this.mocks.splice(index, 1);
285
234
  }
286
- }
235
+ },
236
+ callCount: () => entry.callCount,
237
+ calls: () => [...entry.calls]
287
238
  };
288
239
  }
289
- return baseCapabilities;
290
- }
291
-
292
- // libs/testing/src/client/mcp-test-client.builder.ts
293
- var McpTestClientBuilder = class {
294
- config;
295
- constructor(config) {
296
- this.config = { ...config };
240
+ clear() {
241
+ this.mocks = [];
297
242
  }
298
- /**
299
- * Set the authentication configuration
300
- */
301
- withAuth(auth) {
302
- this.config.auth = { ...this.config.auth, ...auth };
303
- return this;
243
+ getAll() {
244
+ return this.mocks.map((e) => e.definition);
245
+ }
246
+ match(request) {
247
+ for (const entry of this.mocks) {
248
+ if (entry.remainingUses <= 0) continue;
249
+ const { definition } = entry;
250
+ if (definition.method !== request.method) continue;
251
+ if (definition.params !== void 0) {
252
+ const params = request.params ?? {};
253
+ if (typeof definition.params === "function") {
254
+ if (!definition.params(params)) continue;
255
+ } else {
256
+ if (!this.paramsMatch(definition.params, params)) continue;
257
+ }
258
+ }
259
+ entry.callCount++;
260
+ entry.calls.push(request);
261
+ entry.remainingUses--;
262
+ return definition;
263
+ }
264
+ return void 0;
304
265
  }
305
266
  /**
306
- * Set the bearer token for authentication
267
+ * Check if request params match the mock params definition
307
268
  */
308
- withToken(token) {
309
- this.config.auth = { ...this.config.auth, token };
310
- return this;
269
+ paramsMatch(expected, actual) {
270
+ for (const [key, value] of Object.entries(expected)) {
271
+ if (!(key in actual)) return false;
272
+ const actualValue = actual[key];
273
+ if (Array.isArray(value)) {
274
+ if (!Array.isArray(actualValue)) return false;
275
+ if (value.length !== actualValue.length) return false;
276
+ for (let i = 0; i < value.length; i++) {
277
+ const expectedItem = value[i];
278
+ const actualItem = actualValue[i];
279
+ if (typeof expectedItem === "object" && expectedItem !== null) {
280
+ if (typeof actualItem !== "object" || actualItem === null) return false;
281
+ if (!this.paramsMatch(expectedItem, actualItem)) {
282
+ return false;
283
+ }
284
+ } else if (actualItem !== expectedItem) {
285
+ return false;
286
+ }
287
+ }
288
+ } else if (typeof value === "object" && value !== null) {
289
+ if (typeof actualValue !== "object" || actualValue === null) return false;
290
+ if (!this.paramsMatch(value, actualValue)) {
291
+ return false;
292
+ }
293
+ } else if (actualValue !== value) {
294
+ return false;
295
+ }
296
+ }
297
+ return true;
311
298
  }
299
+ };
300
+ var mockResponse = {
312
301
  /**
313
- * Add custom headers to all requests
302
+ * Create a successful JSON-RPC response
314
303
  */
315
- withHeaders(headers) {
316
- this.config.auth = {
317
- ...this.config.auth,
318
- headers: { ...this.config.auth?.headers, ...headers }
304
+ success(result, id = 1) {
305
+ return {
306
+ jsonrpc: "2.0",
307
+ id,
308
+ result
319
309
  };
320
- return this;
321
- }
310
+ },
322
311
  /**
323
- * Set the transport type
312
+ * Create an error JSON-RPC response
324
313
  */
325
- withTransport(transport) {
326
- this.config.transport = transport;
327
- return this;
328
- }
314
+ error(code, message, data, id = 1) {
315
+ return {
316
+ jsonrpc: "2.0",
317
+ id,
318
+ error: { code, message, data }
319
+ };
320
+ },
329
321
  /**
330
- * Set the request timeout in milliseconds
322
+ * Create a tool result response
331
323
  */
332
- withTimeout(timeoutMs) {
333
- this.config.timeout = timeoutMs;
334
- return this;
335
- }
324
+ toolResult(content, id = 1) {
325
+ return {
326
+ jsonrpc: "2.0",
327
+ id,
328
+ result: { content }
329
+ };
330
+ },
336
331
  /**
337
- * Enable debug logging
332
+ * Create a tools/list response
338
333
  */
339
- withDebug(enabled = true) {
340
- this.config.debug = enabled;
341
- return this;
342
- }
334
+ toolsList(tools, id = 1) {
335
+ return {
336
+ jsonrpc: "2.0",
337
+ id,
338
+ result: { tools }
339
+ };
340
+ },
343
341
  /**
344
- * Enable public mode - skip authentication entirely.
345
- * When true, no Authorization header is sent and anonymous token is not requested.
346
- * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
342
+ * Create a resources/list response
347
343
  */
348
- withPublicMode(enabled = true) {
349
- this.config.publicMode = enabled;
350
- return this;
351
- }
344
+ resourcesList(resources, id = 1) {
345
+ return {
346
+ jsonrpc: "2.0",
347
+ id,
348
+ result: { resources }
349
+ };
350
+ },
352
351
  /**
353
- * Set the MCP protocol version to request
352
+ * Create a resources/read response
354
353
  */
355
- withProtocolVersion(version) {
356
- this.config.protocolVersion = version;
357
- return this;
358
- }
354
+ resourceRead(contents, id = 1) {
355
+ return {
356
+ jsonrpc: "2.0",
357
+ id,
358
+ result: { contents }
359
+ };
360
+ },
359
361
  /**
360
- * Set the client info sent during initialization
362
+ * Common MCP errors
361
363
  */
362
- withClientInfo(info) {
363
- this.config.clientInfo = info;
364
- return this;
364
+ errors: {
365
+ methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
366
+ invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
367
+ internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
368
+ resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
369
+ toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
370
+ unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
371
+ forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
372
+ }
373
+ };
374
+
375
+ // libs/testing/src/interceptor/interceptor-chain.ts
376
+ var DefaultInterceptorChain = class {
377
+ request = [];
378
+ response = [];
379
+ mocks;
380
+ constructor() {
381
+ this.mocks = new DefaultMockRegistry();
365
382
  }
366
383
  /**
367
- * Set the platform type for testing platform-specific meta keys.
368
- * Automatically configures clientInfo and capabilities for platform detection.
369
- *
370
- * Platform-specific behavior:
371
- * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
372
- * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
373
- * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
374
- * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
375
- * - Other platforms follow similar patterns
376
- *
377
- * @example
378
- * ```typescript
379
- * const client = await McpTestClient.create({ baseUrl })
380
- * .withPlatform('openai')
381
- * .buildAndConnect();
382
- *
383
- * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
384
- * const extAppsClient = await McpTestClient.create({ baseUrl })
385
- * .withPlatform('ext-apps')
386
- * .buildAndConnect();
387
- * ```
384
+ * Add a request interceptor
388
385
  */
389
- withPlatform(platform) {
390
- this.config.platform = platform;
391
- this.config.clientInfo = getPlatformClientInfo(platform);
392
- this.config.capabilities = getPlatformCapabilities(platform);
393
- return this;
386
+ addRequestInterceptor(interceptor) {
387
+ this.request.push(interceptor);
388
+ return () => {
389
+ const index = this.request.indexOf(interceptor);
390
+ if (index !== -1) {
391
+ this.request.splice(index, 1);
392
+ }
393
+ };
394
394
  }
395
395
  /**
396
- * Set custom client capabilities for MCP initialization.
397
- * Use this for fine-grained control over capabilities sent during initialization.
398
- *
399
- * @example
400
- * ```typescript
401
- * const client = await McpTestClient.create({ baseUrl })
402
- * .withCapabilities({
403
- * sampling: {},
404
- * experimental: {
405
- * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
406
- * }
407
- * })
408
- * .buildAndConnect();
409
- * ```
396
+ * Add a response interceptor
410
397
  */
411
- withCapabilities(capabilities) {
412
- this.config.capabilities = capabilities;
413
- return this;
398
+ addResponseInterceptor(interceptor) {
399
+ this.response.push(interceptor);
400
+ return () => {
401
+ const index = this.response.indexOf(interceptor);
402
+ if (index !== -1) {
403
+ this.response.splice(index, 1);
404
+ }
405
+ };
414
406
  }
415
407
  /**
416
- * Set query parameters to append to the connection URL.
417
- * Useful for testing mode switches like `?mode=skills_only`.
418
- *
419
- * @example
420
- * ```typescript
421
- * const client = await McpTestClient.create({ baseUrl })
422
- * .withQueryParams({ mode: 'skills_only' })
423
- * .buildAndConnect();
424
- * ```
408
+ * Process a request through the interceptor chain
409
+ * Returns either:
410
+ * - { type: 'continue', request } - continue with (possibly modified) request
411
+ * - { type: 'mock', response } - return mock response immediately
412
+ * - { type: 'error', error } - throw error
425
413
  */
426
- withQueryParams(params) {
427
- this.config.queryParams = { ...this.config.queryParams, ...params };
428
- return this;
414
+ async processRequest(request, meta) {
415
+ let currentRequest = request;
416
+ const mockDef = this.mocks.match(request);
417
+ if (mockDef) {
418
+ if (mockDef.delay && mockDef.delay > 0) {
419
+ await sleep(mockDef.delay);
420
+ }
421
+ let mockResponse2;
422
+ if (typeof mockDef.response === "function") {
423
+ mockResponse2 = await mockDef.response(request);
424
+ } else {
425
+ mockResponse2 = mockDef.response;
426
+ }
427
+ return {
428
+ type: "mock",
429
+ response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
430
+ };
431
+ }
432
+ for (const interceptor of this.request) {
433
+ const ctx = {
434
+ request: currentRequest,
435
+ meta
436
+ };
437
+ const result = await interceptor(ctx);
438
+ switch (result.action) {
439
+ case "passthrough":
440
+ break;
441
+ case "modify":
442
+ currentRequest = result.request;
443
+ break;
444
+ case "mock":
445
+ return {
446
+ type: "mock",
447
+ response: { ...result.response, id: request.id ?? result.response.id }
448
+ };
449
+ case "error":
450
+ return { type: "error", error: result.error };
451
+ }
452
+ }
453
+ return { type: "continue", request: currentRequest };
429
454
  }
430
455
  /**
431
- * Build the McpTestClient instance (does not connect)
456
+ * Process a response through the interceptor chain
432
457
  */
433
- build() {
434
- return new McpTestClient(this.config);
458
+ async processResponse(request, response, durationMs) {
459
+ let currentResponse = response;
460
+ for (const interceptor of this.response) {
461
+ const ctx = {
462
+ request,
463
+ response: currentResponse,
464
+ durationMs
465
+ };
466
+ const result = await interceptor(ctx);
467
+ switch (result.action) {
468
+ case "passthrough":
469
+ break;
470
+ case "modify":
471
+ currentResponse = result.response;
472
+ break;
473
+ }
474
+ }
475
+ return currentResponse;
435
476
  }
436
477
  /**
437
- * Build the McpTestClient and connect to the server
478
+ * Clear all interceptors and mocks
438
479
  */
439
- async buildAndConnect() {
440
- const client = this.build();
441
- await client.connect();
442
- return client;
480
+ clear() {
481
+ this.request = [];
482
+ this.response = [];
483
+ this.mocks.clear();
443
484
  }
444
485
  };
486
+ function sleep(ms) {
487
+ return new Promise((resolve) => setTimeout(resolve, ms));
488
+ }
445
489
 
446
490
  // libs/testing/src/transport/streamable-http.transport.ts
447
491
  var DEFAULT_TIMEOUT = 3e4;
@@ -460,6 +504,7 @@ var StreamableHttpTransport = class {
460
504
  this.config = {
461
505
  baseUrl: config.baseUrl.replace(/\/$/, ""),
462
506
  // Remove trailing slash
507
+ entryPath: config.entryPath,
463
508
  timeout: config.timeout ?? DEFAULT_TIMEOUT,
464
509
  auth: config.auth ?? {},
465
510
  publicMode: config.publicMode ?? false,
@@ -552,7 +597,7 @@ var StreamableHttpTransport = class {
552
597
  }
553
598
  const headers = this.buildHeaders();
554
599
  this.lastRequestHeaders = headers;
555
- const url = `${this.config.baseUrl}/`;
600
+ const url = this.mcpUrl();
556
601
  this.log(`POST ${url}`, message);
557
602
  const controller = new AbortController();
558
603
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -622,7 +667,7 @@ var StreamableHttpTransport = class {
622
667
  this.ensureConnected();
623
668
  const headers = this.buildHeaders();
624
669
  this.lastRequestHeaders = headers;
625
- const url = `${this.config.baseUrl}/`;
670
+ const url = this.mcpUrl();
626
671
  this.log(`POST ${url} (notification)`, message);
627
672
  const controller = new AbortController();
628
673
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -653,7 +698,7 @@ var StreamableHttpTransport = class {
653
698
  this.ensureConnected();
654
699
  const headers = this.buildHeaders();
655
700
  this.lastRequestHeaders = headers;
656
- const url = `${this.config.baseUrl}/`;
701
+ const url = this.mcpUrl();
657
702
  this.log(`POST ${url} (raw)`, data);
658
703
  const controller = new AbortController();
659
704
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -936,7 +981,7 @@ var StreamableHttpTransport = class {
936
981
  */
937
982
  async sendElicitationResponse(requestId, response) {
938
983
  const headers = this.buildHeaders();
939
- const url = `${this.config.baseUrl}/`;
984
+ const url = this.mcpUrl();
940
985
  const rpcResponse = {
941
986
  jsonrpc: "2.0",
942
987
  id: requestId,
@@ -956,6 +1001,18 @@ var StreamableHttpTransport = class {
956
1001
  this.log("Failed to send elicitation response:", error);
957
1002
  }
958
1003
  }
1004
+ /**
1005
+ * The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
1006
+ * trailing slash is kept because both hosts normalize it and the original
1007
+ * behaviour (`${baseUrl}/`) relied on it (issue #543).
1008
+ */
1009
+ mcpUrl() {
1010
+ const url = new URL(this.config.baseUrl);
1011
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
1012
+ const basePath = url.pathname.replace(/\/+$/, "");
1013
+ url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
1014
+ return url.toString();
1015
+ }
959
1016
  buildHeaders() {
960
1017
  const headers = {
961
1018
  "Content-Type": "application/json",
@@ -1043,277 +1100,233 @@ var StreamableHttpTransport = class {
1043
1100
  }
1044
1101
  };
1045
1102
 
1046
- // libs/testing/src/interceptor/mock-registry.ts
1047
- var DefaultMockRegistry = class {
1048
- mocks = [];
1049
- add(mock) {
1050
- const entry = {
1051
- definition: mock,
1052
- callCount: 0,
1053
- calls: [],
1054
- remainingUses: mock.times ?? Infinity
1055
- };
1056
- this.mocks.push(entry);
1103
+ // libs/testing/src/platform/platform-client-info.ts
1104
+ var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
1105
+ function getPlatformClientInfo(platform) {
1106
+ switch (platform) {
1107
+ case "openai":
1108
+ return {
1109
+ name: "ChatGPT",
1110
+ version: "1.0"
1111
+ };
1112
+ case "ext-apps":
1113
+ return {
1114
+ name: "mcp-ext-apps",
1115
+ version: "1.0"
1116
+ };
1117
+ case "claude":
1118
+ return {
1119
+ name: "claude-desktop",
1120
+ version: "1.0"
1121
+ };
1122
+ case "cursor":
1123
+ return {
1124
+ name: "cursor",
1125
+ version: "1.0"
1126
+ };
1127
+ case "continue":
1128
+ return {
1129
+ name: "continue",
1130
+ version: "1.0"
1131
+ };
1132
+ case "cody":
1133
+ return {
1134
+ name: "cody",
1135
+ version: "1.0"
1136
+ };
1137
+ case "gemini":
1138
+ return {
1139
+ name: "gemini",
1140
+ version: "1.0"
1141
+ };
1142
+ case "generic-mcp":
1143
+ return {
1144
+ name: "generic-mcp-client",
1145
+ version: "1.0"
1146
+ };
1147
+ case "unknown":
1148
+ default:
1149
+ return {
1150
+ name: "mcp-test-client",
1151
+ version: "1.0"
1152
+ };
1153
+ }
1154
+ }
1155
+ function getPlatformCapabilities(platform) {
1156
+ const baseCapabilities = {
1157
+ sampling: {},
1158
+ // Include elicitation.form by default for testing elicitation workflows
1159
+ // Note: MCP SDK expects form to be an object, not boolean
1160
+ elicitation: {
1161
+ form: {}
1162
+ }
1163
+ };
1164
+ if (platform === "ext-apps") {
1057
1165
  return {
1058
- remove: () => {
1059
- const index = this.mocks.indexOf(entry);
1060
- if (index !== -1) {
1061
- this.mocks.splice(index, 1);
1166
+ ...baseCapabilities,
1167
+ experimental: {
1168
+ [MCP_APPS_EXTENSION_KEY]: {
1169
+ mimeTypes: ["text/html;profile=mcp-app"]
1062
1170
  }
1063
- },
1064
- callCount: () => entry.callCount,
1065
- calls: () => [...entry.calls]
1171
+ }
1066
1172
  };
1067
1173
  }
1068
- clear() {
1069
- this.mocks = [];
1070
- }
1071
- getAll() {
1072
- return this.mocks.map((e) => e.definition);
1073
- }
1074
- match(request) {
1075
- for (const entry of this.mocks) {
1076
- if (entry.remainingUses <= 0) continue;
1077
- const { definition } = entry;
1078
- if (definition.method !== request.method) continue;
1079
- if (definition.params !== void 0) {
1080
- const params = request.params ?? {};
1081
- if (typeof definition.params === "function") {
1082
- if (!definition.params(params)) continue;
1083
- } else {
1084
- if (!this.paramsMatch(definition.params, params)) continue;
1085
- }
1086
- }
1087
- entry.callCount++;
1088
- entry.calls.push(request);
1089
- entry.remainingUses--;
1090
- return definition;
1091
- }
1092
- return void 0;
1174
+ return baseCapabilities;
1175
+ }
1176
+
1177
+ // libs/testing/src/client/mcp-test-client.builder.ts
1178
+ var McpTestClientBuilder = class {
1179
+ config;
1180
+ constructor(config) {
1181
+ this.config = { ...config };
1093
1182
  }
1094
1183
  /**
1095
- * Check if request params match the mock params definition
1184
+ * Set the authentication configuration
1096
1185
  */
1097
- paramsMatch(expected, actual) {
1098
- for (const [key, value] of Object.entries(expected)) {
1099
- if (!(key in actual)) return false;
1100
- const actualValue = actual[key];
1101
- if (Array.isArray(value)) {
1102
- if (!Array.isArray(actualValue)) return false;
1103
- if (value.length !== actualValue.length) return false;
1104
- for (let i = 0; i < value.length; i++) {
1105
- const expectedItem = value[i];
1106
- const actualItem = actualValue[i];
1107
- if (typeof expectedItem === "object" && expectedItem !== null) {
1108
- if (typeof actualItem !== "object" || actualItem === null) return false;
1109
- if (!this.paramsMatch(expectedItem, actualItem)) {
1110
- return false;
1111
- }
1112
- } else if (actualItem !== expectedItem) {
1113
- return false;
1114
- }
1115
- }
1116
- } else if (typeof value === "object" && value !== null) {
1117
- if (typeof actualValue !== "object" || actualValue === null) return false;
1118
- if (!this.paramsMatch(value, actualValue)) {
1119
- return false;
1120
- }
1121
- } else if (actualValue !== value) {
1122
- return false;
1123
- }
1124
- }
1125
- return true;
1186
+ withAuth(auth) {
1187
+ this.config.auth = { ...this.config.auth, ...auth };
1188
+ return this;
1126
1189
  }
1127
- };
1128
- var mockResponse = {
1129
1190
  /**
1130
- * Create a successful JSON-RPC response
1191
+ * Set the bearer token for authentication
1131
1192
  */
1132
- success(result, id = 1) {
1133
- return {
1134
- jsonrpc: "2.0",
1135
- id,
1136
- result
1137
- };
1138
- },
1193
+ withToken(token) {
1194
+ this.config.auth = { ...this.config.auth, token };
1195
+ return this;
1196
+ }
1139
1197
  /**
1140
- * Create an error JSON-RPC response
1198
+ * Add custom headers to all requests
1141
1199
  */
1142
- error(code, message, data, id = 1) {
1143
- return {
1144
- jsonrpc: "2.0",
1145
- id,
1146
- error: { code, message, data }
1200
+ withHeaders(headers) {
1201
+ this.config.auth = {
1202
+ ...this.config.auth,
1203
+ headers: { ...this.config.auth?.headers, ...headers }
1147
1204
  };
1148
- },
1205
+ return this;
1206
+ }
1149
1207
  /**
1150
- * Create a tool result response
1208
+ * Set the transport type
1151
1209
  */
1152
- toolResult(content, id = 1) {
1153
- return {
1154
- jsonrpc: "2.0",
1155
- id,
1156
- result: { content }
1157
- };
1158
- },
1210
+ withTransport(transport) {
1211
+ this.config.transport = transport;
1212
+ return this;
1213
+ }
1159
1214
  /**
1160
- * Create a tools/list response
1215
+ * Set the request timeout in milliseconds
1161
1216
  */
1162
- toolsList(tools, id = 1) {
1163
- return {
1164
- jsonrpc: "2.0",
1165
- id,
1166
- result: { tools }
1167
- };
1168
- },
1217
+ withTimeout(timeoutMs) {
1218
+ this.config.timeout = timeoutMs;
1219
+ return this;
1220
+ }
1169
1221
  /**
1170
- * Create a resources/list response
1171
- */
1172
- resourcesList(resources, id = 1) {
1173
- return {
1174
- jsonrpc: "2.0",
1175
- id,
1176
- result: { resources }
1177
- };
1178
- },
1222
+ * Enable debug logging
1223
+ */
1224
+ withDebug(enabled = true) {
1225
+ this.config.debug = enabled;
1226
+ return this;
1227
+ }
1179
1228
  /**
1180
- * Create a resources/read response
1229
+ * Enable public mode - skip authentication entirely.
1230
+ * When true, no Authorization header is sent and anonymous token is not requested.
1231
+ * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
1181
1232
  */
1182
- resourceRead(contents, id = 1) {
1183
- return {
1184
- jsonrpc: "2.0",
1185
- id,
1186
- result: { contents }
1187
- };
1188
- },
1233
+ withPublicMode(enabled = true) {
1234
+ this.config.publicMode = enabled;
1235
+ return this;
1236
+ }
1189
1237
  /**
1190
- * Common MCP errors
1238
+ * Set the MCP protocol version to request
1191
1239
  */
1192
- errors: {
1193
- methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
1194
- invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
1195
- internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
1196
- resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
1197
- toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
1198
- unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
1199
- forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
1240
+ withProtocolVersion(version) {
1241
+ this.config.protocolVersion = version;
1242
+ return this;
1200
1243
  }
1201
- };
1202
-
1203
- // libs/testing/src/interceptor/interceptor-chain.ts
1204
- var DefaultInterceptorChain = class {
1205
- request = [];
1206
- response = [];
1207
- mocks;
1208
- constructor() {
1209
- this.mocks = new DefaultMockRegistry();
1244
+ /**
1245
+ * Set the client info sent during initialization
1246
+ */
1247
+ withClientInfo(info) {
1248
+ this.config.clientInfo = info;
1249
+ return this;
1210
1250
  }
1211
1251
  /**
1212
- * Add a request interceptor
1252
+ * Set the platform type for testing platform-specific meta keys.
1253
+ * Automatically configures clientInfo and capabilities for platform detection.
1254
+ *
1255
+ * Platform-specific behavior:
1256
+ * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
1257
+ * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
1258
+ * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
1259
+ * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
1260
+ * - Other platforms follow similar patterns
1261
+ *
1262
+ * @example
1263
+ * ```typescript
1264
+ * const client = await McpTestClient.create({ baseUrl })
1265
+ * .withPlatform('openai')
1266
+ * .buildAndConnect();
1267
+ *
1268
+ * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
1269
+ * const extAppsClient = await McpTestClient.create({ baseUrl })
1270
+ * .withPlatform('ext-apps')
1271
+ * .buildAndConnect();
1272
+ * ```
1213
1273
  */
1214
- addRequestInterceptor(interceptor) {
1215
- this.request.push(interceptor);
1216
- return () => {
1217
- const index = this.request.indexOf(interceptor);
1218
- if (index !== -1) {
1219
- this.request.splice(index, 1);
1220
- }
1221
- };
1274
+ withPlatform(platform) {
1275
+ this.config.platform = platform;
1276
+ this.config.clientInfo = getPlatformClientInfo(platform);
1277
+ this.config.capabilities = getPlatformCapabilities(platform);
1278
+ return this;
1222
1279
  }
1223
1280
  /**
1224
- * Add a response interceptor
1281
+ * Set custom client capabilities for MCP initialization.
1282
+ * Use this for fine-grained control over capabilities sent during initialization.
1283
+ *
1284
+ * @example
1285
+ * ```typescript
1286
+ * const client = await McpTestClient.create({ baseUrl })
1287
+ * .withCapabilities({
1288
+ * sampling: {},
1289
+ * experimental: {
1290
+ * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
1291
+ * }
1292
+ * })
1293
+ * .buildAndConnect();
1294
+ * ```
1225
1295
  */
1226
- addResponseInterceptor(interceptor) {
1227
- this.response.push(interceptor);
1228
- return () => {
1229
- const index = this.response.indexOf(interceptor);
1230
- if (index !== -1) {
1231
- this.response.splice(index, 1);
1232
- }
1233
- };
1296
+ withCapabilities(capabilities) {
1297
+ this.config.capabilities = capabilities;
1298
+ return this;
1234
1299
  }
1235
1300
  /**
1236
- * Process a request through the interceptor chain
1237
- * Returns either:
1238
- * - { type: 'continue', request } - continue with (possibly modified) request
1239
- * - { type: 'mock', response } - return mock response immediately
1240
- * - { type: 'error', error } - throw error
1301
+ * Set query parameters to append to the connection URL.
1302
+ * Useful for testing mode switches like `?mode=skills_only`.
1303
+ *
1304
+ * @example
1305
+ * ```typescript
1306
+ * const client = await McpTestClient.create({ baseUrl })
1307
+ * .withQueryParams({ mode: 'skills_only' })
1308
+ * .buildAndConnect();
1309
+ * ```
1241
1310
  */
1242
- async processRequest(request, meta) {
1243
- let currentRequest = request;
1244
- const mockDef = this.mocks.match(request);
1245
- if (mockDef) {
1246
- if (mockDef.delay && mockDef.delay > 0) {
1247
- await sleep(mockDef.delay);
1248
- }
1249
- let mockResponse2;
1250
- if (typeof mockDef.response === "function") {
1251
- mockResponse2 = await mockDef.response(request);
1252
- } else {
1253
- mockResponse2 = mockDef.response;
1254
- }
1255
- return {
1256
- type: "mock",
1257
- response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
1258
- };
1259
- }
1260
- for (const interceptor of this.request) {
1261
- const ctx = {
1262
- request: currentRequest,
1263
- meta
1264
- };
1265
- const result = await interceptor(ctx);
1266
- switch (result.action) {
1267
- case "passthrough":
1268
- break;
1269
- case "modify":
1270
- currentRequest = result.request;
1271
- break;
1272
- case "mock":
1273
- return {
1274
- type: "mock",
1275
- response: { ...result.response, id: request.id ?? result.response.id }
1276
- };
1277
- case "error":
1278
- return { type: "error", error: result.error };
1279
- }
1280
- }
1281
- return { type: "continue", request: currentRequest };
1311
+ withQueryParams(params) {
1312
+ this.config.queryParams = { ...this.config.queryParams, ...params };
1313
+ return this;
1282
1314
  }
1283
1315
  /**
1284
- * Process a response through the interceptor chain
1316
+ * Build the McpTestClient instance (does not connect)
1285
1317
  */
1286
- async processResponse(request, response, durationMs) {
1287
- let currentResponse = response;
1288
- for (const interceptor of this.response) {
1289
- const ctx = {
1290
- request,
1291
- response: currentResponse,
1292
- durationMs
1293
- };
1294
- const result = await interceptor(ctx);
1295
- switch (result.action) {
1296
- case "passthrough":
1297
- break;
1298
- case "modify":
1299
- currentResponse = result.response;
1300
- break;
1301
- }
1302
- }
1303
- return currentResponse;
1318
+ build() {
1319
+ return new McpTestClient(this.config);
1304
1320
  }
1305
1321
  /**
1306
- * Clear all interceptors and mocks
1322
+ * Build the McpTestClient and connect to the server
1307
1323
  */
1308
- clear() {
1309
- this.request = [];
1310
- this.response = [];
1311
- this.mocks.clear();
1324
+ async buildAndConnect() {
1325
+ const client = this.build();
1326
+ await client.connect();
1327
+ return client;
1312
1328
  }
1313
1329
  };
1314
- function sleep(ms) {
1315
- return new Promise((resolve) => setTimeout(resolve, ms));
1316
- }
1317
1330
 
1318
1331
  // libs/testing/src/client/mcp-test-client.ts
1319
1332
  var DEFAULT_TIMEOUT2 = 3e4;
@@ -1322,6 +1335,21 @@ var DEFAULT_CLIENT_INFO = {
1322
1335
  name: "@frontmcp/testing",
1323
1336
  version: "0.4.0"
1324
1337
  };
1338
+ function extractEntryPaths(data) {
1339
+ let payload = data;
1340
+ if (typeof payload === "string") {
1341
+ try {
1342
+ payload = JSON.parse(payload);
1343
+ } catch {
1344
+ return void 0;
1345
+ }
1346
+ }
1347
+ if (!payload || typeof payload !== "object") return void 0;
1348
+ const paths = payload.entryPaths;
1349
+ if (!Array.isArray(paths)) return void 0;
1350
+ const strings = paths.filter((p) => typeof p === "string");
1351
+ return strings.length > 0 ? strings : void 0;
1352
+ }
1325
1353
  var McpTestClient = class {
1326
1354
  // Platform, capabilities, and queryParams are optional - only set when needed
1327
1355
  config;
@@ -1330,6 +1358,8 @@ var McpTestClient = class {
1330
1358
  requestIdCounter = 0;
1331
1359
  _lastRequestId = 0;
1332
1360
  _sessionId;
1361
+ /** #543 — discovery is attempted at most once per connect. */
1362
+ triedDiscoveredEntryPath = false;
1333
1363
  _sessionInfo = null;
1334
1364
  _authState = { isAnonymous: true, scopes: [] };
1335
1365
  // Logging and tracing
@@ -1347,6 +1377,7 @@ var McpTestClient = class {
1347
1377
  constructor(config) {
1348
1378
  this.config = {
1349
1379
  baseUrl: config.baseUrl,
1380
+ entryPath: config.entryPath,
1350
1381
  transport: config.transport ?? "streamable-http",
1351
1382
  auth: config.auth ?? {},
1352
1383
  publicMode: config.publicMode ?? false,
@@ -1382,11 +1413,13 @@ var McpTestClient = class {
1382
1413
  */
1383
1414
  async connect() {
1384
1415
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
1416
+ this.triedDiscoveredEntryPath = false;
1385
1417
  this.transport = this.createTransport();
1386
1418
  await this.transport.connect();
1387
1419
  const maxRetries = 3;
1388
1420
  const retryDelayMs = 500;
1389
1421
  let lastError;
1422
+ let reportedEntryPaths;
1390
1423
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1391
1424
  const initResponse = await this.initialize();
1392
1425
  if (initResponse.success && initResponse.data) {
@@ -1394,13 +1427,27 @@ var McpTestClient = class {
1394
1427
  break;
1395
1428
  }
1396
1429
  lastError = initResponse.error?.message ?? "Unknown error";
1430
+ reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
1431
+ if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
1432
+ this.triedDiscoveredEntryPath = true;
1433
+ const discovered = reportedEntryPaths[0];
1434
+ this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
1435
+ this.config.entryPath = discovered;
1436
+ await this.transport.close();
1437
+ this.transport = this.createTransport();
1438
+ await this.transport.connect();
1439
+ continue;
1440
+ }
1397
1441
  if (attempt < maxRetries) {
1398
1442
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
1399
1443
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
1400
1444
  }
1401
1445
  }
1402
1446
  if (!this.initResult) {
1403
- throw new Error(`Failed to initialize MCP connection after ${maxRetries} attempts: ${lastError}`);
1447
+ 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.` : "";
1448
+ throw new Error(
1449
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
1450
+ );
1404
1451
  }
1405
1452
  this._sessionId = this.transport.getSessionId();
1406
1453
  this._sessionInfo = {
@@ -1998,6 +2045,11 @@ var McpTestClient = class {
1998
2045
  // ═══════════════════════════════════════════════════════════════════
1999
2046
  // PRIVATE: TRANSPORT & REQUEST HELPERS
2000
2047
  // ═══════════════════════════════════════════════════════════════════
2048
+ /** The MCP path this client is currently pointed at, for log and error text. */
2049
+ mcpPathLabel() {
2050
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
2051
+ return entry ? `/${entry}` : "/";
2052
+ }
2001
2053
  createTransport() {
2002
2054
  let baseUrl = this.config.baseUrl;
2003
2055
  if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
@@ -2011,6 +2063,7 @@ var McpTestClient = class {
2011
2063
  case "streamable-http":
2012
2064
  return new StreamableHttpTransport({
2013
2065
  baseUrl,
2066
+ entryPath: this.config.entryPath,
2014
2067
  timeout: this.config.timeout,
2015
2068
  auth: this.config.auth,
2016
2069
  publicMode: this.config.publicMode,
@@ -3002,13 +3055,17 @@ var currentConfig = {};
3002
3055
  var serverInstance = null;
3003
3056
  var tokenFactory = null;
3004
3057
  var serverStartedByUs = false;
3058
+ var skipScopes = [{ skipped: false }];
3059
+ function currentSkipScope() {
3060
+ return skipScopes[skipScopes.length - 1];
3061
+ }
3005
3062
  async function initializeSharedResources() {
3006
3063
  if (!tokenFactory) {
3007
3064
  const sharedSecret = currentConfig.env?.["JWT_SECRET"];
3008
3065
  tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
3009
3066
  }
3010
3067
  if (!serverInstance) {
3011
- if (currentConfig.baseUrl) {
3068
+ if (currentConfig.baseUrl && !currentConfig.server) {
3012
3069
  serverInstance = TestServer.connect(currentConfig.baseUrl);
3013
3070
  serverStartedByUs = false;
3014
3071
  } else if (currentConfig.server) {
@@ -3058,7 +3115,8 @@ async function createTestFixtures() {
3058
3115
  throw new Error("Token factory not initialized");
3059
3116
  }
3060
3117
  const clientInstance = McpTestClient.create({
3061
- baseUrl: serverInstance.info.baseUrl,
3118
+ baseUrl: resolveClientBaseUrl(serverInstance),
3119
+ entryPath: currentConfig.entryPath,
3062
3120
  transport: currentConfig.transport ?? "streamable-http",
3063
3121
  publicMode: currentConfig.publicMode
3064
3122
  }).build();
@@ -3107,6 +3165,9 @@ async function cleanupSharedResources() {
3107
3165
  tokenFactory = null;
3108
3166
  serverStartedByUs = false;
3109
3167
  }
3168
+ function resolveClientBaseUrl(server) {
3169
+ return currentConfig.baseUrl ?? server.info.baseUrl;
3170
+ }
3110
3171
  function createAuthFixture(factory) {
3111
3172
  const users = {
3112
3173
  admin: {
@@ -3156,7 +3217,8 @@ function createServerFixture(server) {
3156
3217
  info: server.info,
3157
3218
  createClient: async (opts) => {
3158
3219
  return McpTestClient.create({
3159
- baseUrl: server.info.baseUrl,
3220
+ baseUrl: resolveClientBaseUrl(server),
3221
+ entryPath: opts?.entryPath ?? currentConfig.entryPath,
3160
3222
  transport: opts?.transport ?? "streamable-http",
3161
3223
  auth: opts?.token ? { token: opts.token } : void 0,
3162
3224
  clientInfo: opts?.clientInfo,
@@ -3165,7 +3227,8 @@ function createServerFixture(server) {
3165
3227
  },
3166
3228
  createClientBuilder: () => {
3167
3229
  const builder = new McpTestClientBuilder({
3168
- baseUrl: server.info.baseUrl,
3230
+ baseUrl: resolveClientBaseUrl(server),
3231
+ entryPath: currentConfig.entryPath,
3169
3232
  publicMode: currentConfig.publicMode
3170
3233
  });
3171
3234
  return builder;
@@ -3181,8 +3244,8 @@ function resolveServerCommand(server) {
3181
3244
  }
3182
3245
  return `npx tsx ${server}`;
3183
3246
  }
3184
- function testWithFixtures(name, fn) {
3185
- it(name, async () => {
3247
+ function runWithFixtures(fn) {
3248
+ return async () => {
3186
3249
  const fixtures = await createTestFixtures();
3187
3250
  let testFailed = false;
3188
3251
  try {
@@ -3193,7 +3256,15 @@ function testWithFixtures(name, fn) {
3193
3256
  } finally {
3194
3257
  await cleanupTestFixtures(fixtures, testFailed);
3195
3258
  }
3196
- });
3259
+ };
3260
+ }
3261
+ function testWithFixtures(name, fn) {
3262
+ const scope = currentSkipScope();
3263
+ if (scope.skipped) {
3264
+ it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
3265
+ return;
3266
+ }
3267
+ it(name, runWithFixtures(fn));
3197
3268
  }
3198
3269
  function use(config) {
3199
3270
  currentConfig = { ...currentConfig, ...config };
@@ -3201,40 +3272,60 @@ function use(config) {
3201
3272
  await cleanupSharedResources();
3202
3273
  });
3203
3274
  }
3204
- function skip(name, fn) {
3205
- it.skip(name, async () => {
3206
- const fixtures = await createTestFixtures();
3207
- let testFailed = false;
3208
- try {
3209
- await fn(fixtures);
3210
- } catch (error) {
3211
- testFailed = true;
3212
- throw error;
3213
- } finally {
3214
- await cleanupTestFixtures(fixtures, testFailed);
3275
+ function skip(nameOrCondition, fnOrReason) {
3276
+ if (typeof nameOrCondition === "boolean") {
3277
+ if (nameOrCondition) {
3278
+ const scope = currentSkipScope();
3279
+ scope.skipped = true;
3280
+ scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
3215
3281
  }
3216
- });
3282
+ return;
3283
+ }
3284
+ if (typeof fnOrReason !== "function") {
3285
+ throw new TypeError(
3286
+ `test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
3287
+ );
3288
+ }
3289
+ it.skip(nameOrCondition, runWithFixtures(fnOrReason));
3217
3290
  }
3218
3291
  function only(name, fn) {
3219
- it.only(name, async () => {
3220
- const fixtures = await createTestFixtures();
3221
- let testFailed = false;
3222
- try {
3223
- await fn(fixtures);
3224
- } catch (error) {
3225
- testFailed = true;
3226
- throw error;
3227
- } finally {
3228
- await cleanupTestFixtures(fixtures, testFailed);
3229
- }
3230
- });
3292
+ const scope = currentSkipScope();
3293
+ if (scope.skipped) {
3294
+ it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
3295
+ return;
3296
+ }
3297
+ it.only(name, runWithFixtures(fn));
3231
3298
  }
3232
3299
  function todo(name) {
3233
3300
  it.todo(name);
3234
3301
  }
3302
+ function withSkipScope(register) {
3303
+ return (name, fn) => {
3304
+ register(name, () => {
3305
+ const parent = currentSkipScope();
3306
+ skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
3307
+ try {
3308
+ fn();
3309
+ } finally {
3310
+ skipScopes.pop();
3311
+ }
3312
+ });
3313
+ };
3314
+ }
3315
+ function eachWithSkipScope(each) {
3316
+ return ((...eachArgs) => {
3317
+ const register = each(...eachArgs);
3318
+ return withSkipScope(register);
3319
+ });
3320
+ }
3321
+ var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
3322
+ only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
3323
+ skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
3324
+ each: eachWithSkipScope(describe.each)
3325
+ });
3235
3326
  var test = testWithFixtures;
3236
3327
  test.use = use;
3237
- test.describe = describe;
3328
+ test.describe = describeWithSkipScope;
3238
3329
  test.beforeAll = beforeAll;
3239
3330
  test.beforeEach = beforeEach;
3240
3331
  test.afterEach = afterEach;