@frontmcp/testing 1.7.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fixtures/index.js CHANGED
@@ -215,236 +215,281 @@ 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;
492
+ var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
448
493
  var StreamableHttpTransport = class {
449
494
  config;
450
495
  state = "disconnected";
@@ -456,10 +501,15 @@ var StreamableHttpTransport = class {
456
501
  interceptors;
457
502
  publicMode;
458
503
  elicitationHandler;
504
+ notificationHandler;
505
+ notificationStream;
506
+ notificationProtocolVersion = "";
507
+ notificationStreamReopenTimer;
459
508
  constructor(config) {
460
509
  this.config = {
461
510
  baseUrl: config.baseUrl.replace(/\/$/, ""),
462
511
  // Remove trailing slash
512
+ entryPath: config.entryPath,
463
513
  timeout: config.timeout ?? DEFAULT_TIMEOUT,
464
514
  auth: config.auth ?? {},
465
515
  publicMode: config.publicMode ?? false,
@@ -471,6 +521,7 @@ var StreamableHttpTransport = class {
471
521
  this.interceptors = config.interceptors;
472
522
  this.publicMode = config.publicMode ?? false;
473
523
  this.elicitationHandler = config.elicitationHandler;
524
+ this.notificationHandler = config.notificationHandler;
474
525
  }
475
526
  async connect() {
476
527
  this.state = "connecting";
@@ -552,7 +603,7 @@ var StreamableHttpTransport = class {
552
603
  }
553
604
  const headers = this.buildHeaders();
554
605
  this.lastRequestHeaders = headers;
555
- const url = `${this.config.baseUrl}/`;
606
+ const url = this.mcpUrl();
556
607
  this.log(`POST ${url}`, message);
557
608
  const controller = new AbortController();
558
609
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -622,7 +673,7 @@ var StreamableHttpTransport = class {
622
673
  this.ensureConnected();
623
674
  const headers = this.buildHeaders();
624
675
  this.lastRequestHeaders = headers;
625
- const url = `${this.config.baseUrl}/`;
676
+ const url = this.mcpUrl();
626
677
  this.log(`POST ${url} (notification)`, message);
627
678
  const controller = new AbortController();
628
679
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -653,7 +704,7 @@ var StreamableHttpTransport = class {
653
704
  this.ensureConnected();
654
705
  const headers = this.buildHeaders();
655
706
  this.lastRequestHeaders = headers;
656
- const url = `${this.config.baseUrl}/`;
707
+ const url = this.mcpUrl();
657
708
  this.log(`POST ${url} (raw)`, data);
658
709
  const controller = new AbortController();
659
710
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -690,7 +741,12 @@ var StreamableHttpTransport = class {
690
741
  };
691
742
  }
692
743
  }
744
+ async openNotificationStream(protocolVersion) {
745
+ this.notificationProtocolVersion = protocolVersion;
746
+ await this.connectNotificationStream();
747
+ }
693
748
  async close() {
749
+ this.closeNotificationStream();
694
750
  this.state = "disconnected";
695
751
  this.sessionId = void 0;
696
752
  this.log("StreamableHTTP transport closed");
@@ -729,6 +785,7 @@ var StreamableHttpTransport = class {
729
785
  return { ...this.lastRequestHeaders };
730
786
  }
731
787
  async simulateDisconnect() {
788
+ this.closeNotificationStream();
732
789
  this.state = "disconnected";
733
790
  this.sessionId = void 0;
734
791
  }
@@ -747,6 +804,72 @@ var StreamableHttpTransport = class {
747
804
  // ═══════════════════════════════════════════════════════════════════
748
805
  // PRIVATE HELPERS
749
806
  // ═══════════════════════════════════════════════════════════════════
807
+ closeNotificationStream() {
808
+ clearTimeout(this.notificationStreamReopenTimer);
809
+ this.notificationStream?.abort();
810
+ this.notificationStream = void 0;
811
+ }
812
+ /** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
813
+ async connectNotificationStream() {
814
+ if (!this.sessionId || this.notificationStream) return;
815
+ const controller = new AbortController();
816
+ this.notificationStream = controller;
817
+ const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
818
+ const established = fetch(this.mcpUrl(), {
819
+ method: "GET",
820
+ headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
821
+ signal: controller.signal
822
+ }).then(
823
+ async (response) => {
824
+ const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
825
+ if (!response.ok || !response.body || !isEventStream) {
826
+ await response.body?.cancel();
827
+ this.log(`Server offers no notification stream (HTTP ${response.status})`);
828
+ this.releaseNotificationStream(controller);
829
+ return;
830
+ }
831
+ void this.readNotificationStream(controller, response.body.getReader());
832
+ },
833
+ (error) => {
834
+ this.log("Failed to open the notification stream:", error);
835
+ this.releaseNotificationStream(controller);
836
+ }
837
+ );
838
+ let establishTimer;
839
+ const establishDeadline = new Promise((resolve) => {
840
+ establishTimer = setTimeout(resolve, this.config.timeout);
841
+ });
842
+ await Promise.race([established, establishDeadline]);
843
+ clearTimeout(establishTimer);
844
+ }
845
+ releaseNotificationStream(controller) {
846
+ if (this.notificationStream === controller) this.notificationStream = void 0;
847
+ }
848
+ async readNotificationStream(controller, reader) {
849
+ const decoder = new TextDecoder();
850
+ let buffer = "";
851
+ try {
852
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
853
+ const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
854
+ buffer = remainder;
855
+ for (const eventText of completeEvents) {
856
+ for (const event of this.parseSSEEvents(eventText, void 0).events) {
857
+ await this.handleSSEEvent(event);
858
+ }
859
+ }
860
+ }
861
+ } catch (error) {
862
+ this.log("Notification stream ended:", error);
863
+ } finally {
864
+ reader.releaseLock();
865
+ }
866
+ if (controller.signal.aborted || this.notificationStream !== controller) return;
867
+ this.releaseNotificationStream(controller);
868
+ this.notificationStreamReopenTimer = setTimeout(
869
+ () => void this.connectNotificationStream(),
870
+ NOTIFICATION_STREAM_REOPEN_DELAY_MS
871
+ );
872
+ }
750
873
  /**
751
874
  * Handle SSE response with elicitation support.
752
875
  *
@@ -884,6 +1007,13 @@ var StreamableHttpTransport = class {
884
1007
  response: { jsonrpc: "2.0", id: null, result: void 0 }
885
1008
  };
886
1009
  }
1010
+ if ("method" in parsed && parsed.id === void 0) {
1011
+ this.notificationHandler?.(parsed);
1012
+ return {
1013
+ isFinal: false,
1014
+ response: { jsonrpc: "2.0", id: null, result: void 0 }
1015
+ };
1016
+ }
887
1017
  if ("result" in parsed || "error" in parsed) {
888
1018
  return { isFinal: true, response: parsed };
889
1019
  }
@@ -936,7 +1066,7 @@ var StreamableHttpTransport = class {
936
1066
  */
937
1067
  async sendElicitationResponse(requestId, response) {
938
1068
  const headers = this.buildHeaders();
939
- const url = `${this.config.baseUrl}/`;
1069
+ const url = this.mcpUrl();
940
1070
  const rpcResponse = {
941
1071
  jsonrpc: "2.0",
942
1072
  id: requestId,
@@ -956,6 +1086,18 @@ var StreamableHttpTransport = class {
956
1086
  this.log("Failed to send elicitation response:", error);
957
1087
  }
958
1088
  }
1089
+ /**
1090
+ * The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
1091
+ * trailing slash is kept because both hosts normalize it and the original
1092
+ * behaviour (`${baseUrl}/`) relied on it (issue #543).
1093
+ */
1094
+ mcpUrl() {
1095
+ const url = new URL(this.config.baseUrl);
1096
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
1097
+ const basePath = url.pathname.replace(/\/+$/, "");
1098
+ url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
1099
+ return url.toString();
1100
+ }
959
1101
  buildHeaders() {
960
1102
  const headers = {
961
1103
  "Content-Type": "application/json",
@@ -1042,286 +1184,266 @@ var StreamableHttpTransport = class {
1042
1184
  };
1043
1185
  }
1044
1186
  };
1187
+ function splitSSEEvents(buffer) {
1188
+ const endsWithCarriageReturn = buffer.endsWith("\r");
1189
+ const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
1190
+ const completeEvents = normalized.split("\n\n");
1191
+ const remainder = completeEvents.pop() ?? "";
1192
+ return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
1193
+ }
1045
1194
 
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);
1195
+ // libs/testing/src/platform/platform-client-info.ts
1196
+ var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
1197
+ function getPlatformClientInfo(platform) {
1198
+ switch (platform) {
1199
+ case "openai":
1200
+ return {
1201
+ name: "ChatGPT",
1202
+ version: "1.0"
1203
+ };
1204
+ case "ext-apps":
1205
+ return {
1206
+ name: "mcp-ext-apps",
1207
+ version: "1.0"
1208
+ };
1209
+ case "claude":
1210
+ return {
1211
+ name: "claude-desktop",
1212
+ version: "1.0"
1213
+ };
1214
+ case "cursor":
1215
+ return {
1216
+ name: "cursor",
1217
+ version: "1.0"
1218
+ };
1219
+ case "continue":
1220
+ return {
1221
+ name: "continue",
1222
+ version: "1.0"
1223
+ };
1224
+ case "cody":
1225
+ return {
1226
+ name: "cody",
1227
+ version: "1.0"
1228
+ };
1229
+ case "gemini":
1230
+ return {
1231
+ name: "gemini",
1232
+ version: "1.0"
1233
+ };
1234
+ case "generic-mcp":
1235
+ return {
1236
+ name: "generic-mcp-client",
1237
+ version: "1.0"
1238
+ };
1239
+ case "unknown":
1240
+ default:
1241
+ return {
1242
+ name: "mcp-test-client",
1243
+ version: "1.0"
1244
+ };
1245
+ }
1246
+ }
1247
+ function getPlatformCapabilities(platform) {
1248
+ const baseCapabilities = {
1249
+ sampling: {},
1250
+ // Include elicitation.form by default for testing elicitation workflows
1251
+ // Note: MCP SDK expects form to be an object, not boolean
1252
+ elicitation: {
1253
+ form: {}
1254
+ }
1255
+ };
1256
+ if (platform === "ext-apps") {
1057
1257
  return {
1058
- remove: () => {
1059
- const index = this.mocks.indexOf(entry);
1060
- if (index !== -1) {
1061
- this.mocks.splice(index, 1);
1258
+ ...baseCapabilities,
1259
+ experimental: {
1260
+ [MCP_APPS_EXTENSION_KEY]: {
1261
+ mimeTypes: ["text/html;profile=mcp-app"]
1062
1262
  }
1063
- },
1064
- callCount: () => entry.callCount,
1065
- calls: () => [...entry.calls]
1263
+ }
1066
1264
  };
1067
1265
  }
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;
1266
+ return baseCapabilities;
1267
+ }
1268
+
1269
+ // libs/testing/src/client/mcp-test-client.builder.ts
1270
+ var McpTestClientBuilder = class {
1271
+ config;
1272
+ constructor(config) {
1273
+ this.config = { ...config };
1093
1274
  }
1094
1275
  /**
1095
- * Check if request params match the mock params definition
1276
+ * Set the authentication configuration
1096
1277
  */
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;
1278
+ withAuth(auth) {
1279
+ this.config.auth = { ...this.config.auth, ...auth };
1280
+ return this;
1126
1281
  }
1127
- };
1128
- var mockResponse = {
1129
1282
  /**
1130
- * Create a successful JSON-RPC response
1283
+ * Set the bearer token for authentication
1131
1284
  */
1132
- success(result, id = 1) {
1133
- return {
1134
- jsonrpc: "2.0",
1135
- id,
1136
- result
1137
- };
1138
- },
1285
+ withToken(token) {
1286
+ this.config.auth = { ...this.config.auth, token };
1287
+ return this;
1288
+ }
1139
1289
  /**
1140
- * Create an error JSON-RPC response
1290
+ * Add custom headers to all requests
1141
1291
  */
1142
- error(code, message, data, id = 1) {
1143
- return {
1144
- jsonrpc: "2.0",
1145
- id,
1146
- error: { code, message, data }
1292
+ withHeaders(headers) {
1293
+ this.config.auth = {
1294
+ ...this.config.auth,
1295
+ headers: { ...this.config.auth?.headers, ...headers }
1147
1296
  };
1148
- },
1297
+ return this;
1298
+ }
1149
1299
  /**
1150
- * Create a tool result response
1300
+ * Set the transport type
1151
1301
  */
1152
- toolResult(content, id = 1) {
1153
- return {
1154
- jsonrpc: "2.0",
1155
- id,
1156
- result: { content }
1157
- };
1158
- },
1302
+ withTransport(transport) {
1303
+ this.config.transport = transport;
1304
+ return this;
1305
+ }
1159
1306
  /**
1160
- * Create a tools/list response
1307
+ * Set the request timeout in milliseconds
1161
1308
  */
1162
- toolsList(tools, id = 1) {
1163
- return {
1164
- jsonrpc: "2.0",
1165
- id,
1166
- result: { tools }
1167
- };
1168
- },
1309
+ withTimeout(timeoutMs) {
1310
+ this.config.timeout = timeoutMs;
1311
+ return this;
1312
+ }
1169
1313
  /**
1170
- * Create a resources/list response
1314
+ * Enable debug logging
1171
1315
  */
1172
- resourcesList(resources, id = 1) {
1173
- return {
1174
- jsonrpc: "2.0",
1175
- id,
1176
- result: { resources }
1177
- };
1178
- },
1316
+ withDebug(enabled = true) {
1317
+ this.config.debug = enabled;
1318
+ return this;
1319
+ }
1179
1320
  /**
1180
- * Create a resources/read response
1321
+ * Enable public mode - skip authentication entirely.
1322
+ * When true, no Authorization header is sent and anonymous token is not requested.
1323
+ * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
1181
1324
  */
1182
- resourceRead(contents, id = 1) {
1183
- return {
1184
- jsonrpc: "2.0",
1185
- id,
1186
- result: { contents }
1187
- };
1188
- },
1325
+ withPublicMode(enabled = true) {
1326
+ this.config.publicMode = enabled;
1327
+ return this;
1328
+ }
1189
1329
  /**
1190
- * Common MCP errors
1330
+ * Set the MCP protocol version to request (default '2025-06-18').
1331
+ * '2026-07-28' has no initialize handshake, so `build()` throws for it.
1191
1332
  */
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)
1333
+ withProtocolVersion(version) {
1334
+ this.config.protocolVersion = version;
1335
+ return this;
1200
1336
  }
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();
1337
+ /**
1338
+ * Set the client info sent during initialization
1339
+ */
1340
+ withClientInfo(info) {
1341
+ this.config.clientInfo = info;
1342
+ return this;
1210
1343
  }
1211
1344
  /**
1212
- * Add a request interceptor
1345
+ * Set the platform type for testing platform-specific meta keys.
1346
+ * Automatically configures clientInfo and capabilities for platform detection.
1347
+ *
1348
+ * Platform-specific behavior:
1349
+ * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
1350
+ * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
1351
+ * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
1352
+ * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
1353
+ * - Other platforms follow similar patterns
1354
+ *
1355
+ * @example
1356
+ * ```typescript
1357
+ * const client = await McpTestClient.create({ baseUrl })
1358
+ * .withPlatform('openai')
1359
+ * .buildAndConnect();
1360
+ *
1361
+ * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
1362
+ * const extAppsClient = await McpTestClient.create({ baseUrl })
1363
+ * .withPlatform('ext-apps')
1364
+ * .buildAndConnect();
1365
+ * ```
1213
1366
  */
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
- };
1367
+ withPlatform(platform) {
1368
+ this.config.platform = platform;
1369
+ this.config.clientInfo = getPlatformClientInfo(platform);
1370
+ this.config.capabilities = getPlatformCapabilities(platform);
1371
+ return this;
1222
1372
  }
1223
1373
  /**
1224
- * Add a response interceptor
1374
+ * Set custom client capabilities for MCP initialization.
1375
+ * Use this for fine-grained control over capabilities sent during initialization.
1376
+ *
1377
+ * @example
1378
+ * ```typescript
1379
+ * const client = await McpTestClient.create({ baseUrl })
1380
+ * .withCapabilities({
1381
+ * sampling: {},
1382
+ * experimental: {
1383
+ * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
1384
+ * }
1385
+ * })
1386
+ * .buildAndConnect();
1387
+ * ```
1225
1388
  */
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
- };
1389
+ withCapabilities(capabilities) {
1390
+ this.config.capabilities = capabilities;
1391
+ return this;
1234
1392
  }
1235
1393
  /**
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
1394
+ * Set query parameters to append to the connection URL.
1395
+ * Useful for testing mode switches like `?mode=skills_only`.
1396
+ *
1397
+ * @example
1398
+ * ```typescript
1399
+ * const client = await McpTestClient.create({ baseUrl })
1400
+ * .withQueryParams({ mode: 'skills_only' })
1401
+ * .buildAndConnect();
1402
+ * ```
1241
1403
  */
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 };
1404
+ withQueryParams(params) {
1405
+ this.config.queryParams = { ...this.config.queryParams, ...params };
1406
+ return this;
1282
1407
  }
1283
1408
  /**
1284
- * Process a response through the interceptor chain
1409
+ * Build the McpTestClient instance (does not connect)
1285
1410
  */
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;
1411
+ build() {
1412
+ return new McpTestClient(this.config);
1304
1413
  }
1305
1414
  /**
1306
- * Clear all interceptors and mocks
1415
+ * Build the McpTestClient and connect to the server
1307
1416
  */
1308
- clear() {
1309
- this.request = [];
1310
- this.response = [];
1311
- this.mocks.clear();
1417
+ async buildAndConnect() {
1418
+ const client = this.build();
1419
+ await client.connect();
1420
+ return client;
1312
1421
  }
1313
1422
  };
1314
- function sleep(ms) {
1315
- return new Promise((resolve) => setTimeout(resolve, ms));
1316
- }
1317
1423
 
1318
1424
  // libs/testing/src/client/mcp-test-client.ts
1319
1425
  var DEFAULT_TIMEOUT2 = 3e4;
1320
1426
  var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
1427
+ var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
1321
1428
  var DEFAULT_CLIENT_INFO = {
1322
1429
  name: "@frontmcp/testing",
1323
1430
  version: "0.4.0"
1324
1431
  };
1432
+ function extractEntryPaths(data) {
1433
+ let payload = data;
1434
+ if (typeof payload === "string") {
1435
+ try {
1436
+ payload = JSON.parse(payload);
1437
+ } catch {
1438
+ return void 0;
1439
+ }
1440
+ }
1441
+ if (!payload || typeof payload !== "object") return void 0;
1442
+ const paths = payload.entryPaths;
1443
+ if (!Array.isArray(paths)) return void 0;
1444
+ const strings = paths.filter((p) => typeof p === "string");
1445
+ return strings.length > 0 ? strings : void 0;
1446
+ }
1325
1447
  var McpTestClient = class {
1326
1448
  // Platform, capabilities, and queryParams are optional - only set when needed
1327
1449
  config;
@@ -1330,6 +1452,8 @@ var McpTestClient = class {
1330
1452
  requestIdCounter = 0;
1331
1453
  _lastRequestId = 0;
1332
1454
  _sessionId;
1455
+ /** #543 — discovery is attempted at most once per connect. */
1456
+ triedDiscoveredEntryPath = false;
1333
1457
  _sessionInfo = null;
1334
1458
  _authState = { isAnonymous: true, scopes: [] };
1335
1459
  // Logging and tracing
@@ -1337,6 +1461,8 @@ var McpTestClient = class {
1337
1461
  _traces = [];
1338
1462
  _notifications = [];
1339
1463
  _progressUpdates = [];
1464
+ collectingProgress = false;
1465
+ progressTokenCounter = 0;
1340
1466
  // Interceptor chain
1341
1467
  _interceptors;
1342
1468
  // Elicitation handler for server→client elicit requests
@@ -1345,8 +1471,14 @@ var McpTestClient = class {
1345
1471
  // CONSTRUCTOR & FACTORY
1346
1472
  // ═══════════════════════════════════════════════════════════════════
1347
1473
  constructor(config) {
1474
+ if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
1475
+ throw new Error(
1476
+ `McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
1477
+ );
1478
+ }
1348
1479
  this.config = {
1349
1480
  baseUrl: config.baseUrl,
1481
+ entryPath: config.entryPath,
1350
1482
  transport: config.transport ?? "streamable-http",
1351
1483
  auth: config.auth ?? {},
1352
1484
  publicMode: config.publicMode ?? false,
@@ -1382,11 +1514,13 @@ var McpTestClient = class {
1382
1514
  */
1383
1515
  async connect() {
1384
1516
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
1517
+ this.triedDiscoveredEntryPath = false;
1385
1518
  this.transport = this.createTransport();
1386
1519
  await this.transport.connect();
1387
1520
  const maxRetries = 3;
1388
1521
  const retryDelayMs = 500;
1389
1522
  let lastError;
1523
+ let reportedEntryPaths;
1390
1524
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1391
1525
  const initResponse = await this.initialize();
1392
1526
  if (initResponse.success && initResponse.data) {
@@ -1394,13 +1528,27 @@ var McpTestClient = class {
1394
1528
  break;
1395
1529
  }
1396
1530
  lastError = initResponse.error?.message ?? "Unknown error";
1531
+ reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
1532
+ if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
1533
+ this.triedDiscoveredEntryPath = true;
1534
+ const discovered = reportedEntryPaths[0];
1535
+ this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
1536
+ this.config.entryPath = discovered;
1537
+ await this.transport.close();
1538
+ this.transport = this.createTransport();
1539
+ await this.transport.connect();
1540
+ continue;
1541
+ }
1397
1542
  if (attempt < maxRetries) {
1398
1543
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
1399
1544
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
1400
1545
  }
1401
1546
  }
1402
1547
  if (!this.initResult) {
1403
- throw new Error(`Failed to initialize MCP connection after ${maxRetries} attempts: ${lastError}`);
1548
+ 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.` : "";
1549
+ throw new Error(
1550
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
1551
+ );
1404
1552
  }
1405
1553
  this._sessionId = this.transport.getSessionId();
1406
1554
  this._sessionInfo = {
@@ -1413,6 +1561,7 @@ var McpTestClient = class {
1413
1561
  jsonrpc: "2.0",
1414
1562
  method: "notifications/initialized"
1415
1563
  });
1564
+ await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
1416
1565
  this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
1417
1566
  return this.initResult;
1418
1567
  }
@@ -1523,8 +1672,8 @@ var McpTestClient = class {
1523
1672
  /**
1524
1673
  * Call a tool by name with arguments
1525
1674
  */
1526
- call: async (name, args) => {
1527
- const response = await this.callTool(name, args);
1675
+ call: async (name, args, options = {}) => {
1676
+ const response = await this.callTool(name, args, this.progressTokenFor(options));
1528
1677
  return this.wrapToolResult(response);
1529
1678
  }
1530
1679
  };
@@ -1666,6 +1815,7 @@ var McpTestClient = class {
1666
1815
  * Collect progress notifications specifically
1667
1816
  */
1668
1817
  collectProgress: () => {
1818
+ this.collectingProgress = true;
1669
1819
  return new ProgressCollector(this._progressUpdates);
1670
1820
  },
1671
1821
  /**
@@ -1971,12 +2121,29 @@ var McpTestClient = class {
1971
2121
  async listTools() {
1972
2122
  return this.request("tools/list", {});
1973
2123
  }
1974
- async callTool(name, args) {
2124
+ async callTool(name, args, progressToken) {
1975
2125
  return this.request("tools/call", {
1976
2126
  name,
1977
- arguments: args ?? {}
2127
+ arguments: args ?? {},
2128
+ ...progressToken !== void 0 && { _meta: { progressToken } }
1978
2129
  });
1979
2130
  }
2131
+ progressTokenFor(options) {
2132
+ if (options.progressToken !== void 0) return options.progressToken;
2133
+ return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
2134
+ }
2135
+ recordNotification = (notification) => {
2136
+ const timestamp = /* @__PURE__ */ new Date();
2137
+ this._notifications.push({ method: notification.method, params: notification.params, timestamp });
2138
+ if (notification.method !== "notifications/progress") return;
2139
+ const { progress, total, progressToken } = notification.params ?? {};
2140
+ this._progressUpdates.push({
2141
+ progress: Number(progress),
2142
+ total: typeof total === "number" ? total : void 0,
2143
+ progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
2144
+ timestamp
2145
+ });
2146
+ };
1980
2147
  async listResources() {
1981
2148
  return this.request("resources/list", {});
1982
2149
  }
@@ -1998,6 +2165,11 @@ var McpTestClient = class {
1998
2165
  // ═══════════════════════════════════════════════════════════════════
1999
2166
  // PRIVATE: TRANSPORT & REQUEST HELPERS
2000
2167
  // ═══════════════════════════════════════════════════════════════════
2168
+ /** The MCP path this client is currently pointed at, for log and error text. */
2169
+ mcpPathLabel() {
2170
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
2171
+ return entry ? `/${entry}` : "/";
2172
+ }
2001
2173
  createTransport() {
2002
2174
  let baseUrl = this.config.baseUrl;
2003
2175
  if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
@@ -2011,13 +2183,15 @@ var McpTestClient = class {
2011
2183
  case "streamable-http":
2012
2184
  return new StreamableHttpTransport({
2013
2185
  baseUrl,
2186
+ entryPath: this.config.entryPath,
2014
2187
  timeout: this.config.timeout,
2015
2188
  auth: this.config.auth,
2016
2189
  publicMode: this.config.publicMode,
2017
2190
  debug: this.config.debug,
2018
2191
  interceptors: this._interceptors,
2019
2192
  clientInfo: this.config.clientInfo,
2020
- elicitationHandler: this._elicitationHandler
2193
+ elicitationHandler: this._elicitationHandler,
2194
+ notificationHandler: this.recordNotification
2021
2195
  });
2022
2196
  case "sse":
2023
2197
  throw new Error("SSE transport not yet implemented");
@@ -3002,13 +3176,17 @@ var currentConfig = {};
3002
3176
  var serverInstance = null;
3003
3177
  var tokenFactory = null;
3004
3178
  var serverStartedByUs = false;
3179
+ var skipScopes = [{ skipped: false }];
3180
+ function currentSkipScope() {
3181
+ return skipScopes[skipScopes.length - 1];
3182
+ }
3005
3183
  async function initializeSharedResources() {
3006
3184
  if (!tokenFactory) {
3007
3185
  const sharedSecret = currentConfig.env?.["JWT_SECRET"];
3008
3186
  tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
3009
3187
  }
3010
3188
  if (!serverInstance) {
3011
- if (currentConfig.baseUrl) {
3189
+ if (currentConfig.baseUrl && !currentConfig.server) {
3012
3190
  serverInstance = TestServer.connect(currentConfig.baseUrl);
3013
3191
  serverStartedByUs = false;
3014
3192
  } else if (currentConfig.server) {
@@ -3058,7 +3236,8 @@ async function createTestFixtures() {
3058
3236
  throw new Error("Token factory not initialized");
3059
3237
  }
3060
3238
  const clientInstance = McpTestClient.create({
3061
- baseUrl: serverInstance.info.baseUrl,
3239
+ baseUrl: resolveClientBaseUrl(serverInstance),
3240
+ entryPath: currentConfig.entryPath,
3062
3241
  transport: currentConfig.transport ?? "streamable-http",
3063
3242
  publicMode: currentConfig.publicMode
3064
3243
  }).build();
@@ -3107,6 +3286,9 @@ async function cleanupSharedResources() {
3107
3286
  tokenFactory = null;
3108
3287
  serverStartedByUs = false;
3109
3288
  }
3289
+ function resolveClientBaseUrl(server) {
3290
+ return currentConfig.baseUrl ?? server.info.baseUrl;
3291
+ }
3110
3292
  function createAuthFixture(factory) {
3111
3293
  const users = {
3112
3294
  admin: {
@@ -3156,7 +3338,8 @@ function createServerFixture(server) {
3156
3338
  info: server.info,
3157
3339
  createClient: async (opts) => {
3158
3340
  return McpTestClient.create({
3159
- baseUrl: server.info.baseUrl,
3341
+ baseUrl: resolveClientBaseUrl(server),
3342
+ entryPath: opts?.entryPath ?? currentConfig.entryPath,
3160
3343
  transport: opts?.transport ?? "streamable-http",
3161
3344
  auth: opts?.token ? { token: opts.token } : void 0,
3162
3345
  clientInfo: opts?.clientInfo,
@@ -3165,7 +3348,8 @@ function createServerFixture(server) {
3165
3348
  },
3166
3349
  createClientBuilder: () => {
3167
3350
  const builder = new McpTestClientBuilder({
3168
- baseUrl: server.info.baseUrl,
3351
+ baseUrl: resolveClientBaseUrl(server),
3352
+ entryPath: currentConfig.entryPath,
3169
3353
  publicMode: currentConfig.publicMode
3170
3354
  });
3171
3355
  return builder;
@@ -3181,8 +3365,8 @@ function resolveServerCommand(server) {
3181
3365
  }
3182
3366
  return `npx tsx ${server}`;
3183
3367
  }
3184
- function testWithFixtures(name, fn) {
3185
- it(name, async () => {
3368
+ function runWithFixtures(fn) {
3369
+ return async () => {
3186
3370
  const fixtures = await createTestFixtures();
3187
3371
  let testFailed = false;
3188
3372
  try {
@@ -3193,7 +3377,15 @@ function testWithFixtures(name, fn) {
3193
3377
  } finally {
3194
3378
  await cleanupTestFixtures(fixtures, testFailed);
3195
3379
  }
3196
- });
3380
+ };
3381
+ }
3382
+ function testWithFixtures(name, fn) {
3383
+ const scope = currentSkipScope();
3384
+ if (scope.skipped) {
3385
+ it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
3386
+ return;
3387
+ }
3388
+ it(name, runWithFixtures(fn));
3197
3389
  }
3198
3390
  function use(config) {
3199
3391
  currentConfig = { ...currentConfig, ...config };
@@ -3201,40 +3393,60 @@ function use(config) {
3201
3393
  await cleanupSharedResources();
3202
3394
  });
3203
3395
  }
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);
3396
+ function skip(nameOrCondition, fnOrReason) {
3397
+ if (typeof nameOrCondition === "boolean") {
3398
+ if (nameOrCondition) {
3399
+ const scope = currentSkipScope();
3400
+ scope.skipped = true;
3401
+ scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
3215
3402
  }
3216
- });
3403
+ return;
3404
+ }
3405
+ if (typeof fnOrReason !== "function") {
3406
+ throw new TypeError(
3407
+ `test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
3408
+ );
3409
+ }
3410
+ it.skip(nameOrCondition, runWithFixtures(fnOrReason));
3217
3411
  }
3218
3412
  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
- });
3413
+ const scope = currentSkipScope();
3414
+ if (scope.skipped) {
3415
+ it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
3416
+ return;
3417
+ }
3418
+ it.only(name, runWithFixtures(fn));
3231
3419
  }
3232
3420
  function todo(name) {
3233
3421
  it.todo(name);
3234
3422
  }
3423
+ function withSkipScope(register) {
3424
+ return (name, fn) => {
3425
+ register(name, () => {
3426
+ const parent = currentSkipScope();
3427
+ skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
3428
+ try {
3429
+ fn();
3430
+ } finally {
3431
+ skipScopes.pop();
3432
+ }
3433
+ });
3434
+ };
3435
+ }
3436
+ function eachWithSkipScope(each) {
3437
+ return ((...eachArgs) => {
3438
+ const register = each(...eachArgs);
3439
+ return withSkipScope(register);
3440
+ });
3441
+ }
3442
+ var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
3443
+ only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
3444
+ skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
3445
+ each: eachWithSkipScope(describe.each)
3446
+ });
3235
3447
  var test = testWithFixtures;
3236
3448
  test.use = use;
3237
- test.describe = describe;
3449
+ test.describe = describeWithSkipScope;
3238
3450
  test.beforeAll = beforeAll;
3239
3451
  test.beforeEach = beforeEach;
3240
3452
  test.afterEach = afterEach;