@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.
@@ -199,236 +199,281 @@ var TestTokenFactory = class {
199
199
  }
200
200
  };
201
201
 
202
- // libs/testing/src/platform/platform-client-info.ts
203
- var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
204
- function getPlatformClientInfo(platform) {
205
- switch (platform) {
206
- case "openai":
207
- return {
208
- name: "ChatGPT",
209
- version: "1.0"
210
- };
211
- case "ext-apps":
212
- return {
213
- name: "mcp-ext-apps",
214
- version: "1.0"
215
- };
216
- case "claude":
217
- return {
218
- name: "claude-desktop",
219
- version: "1.0"
220
- };
221
- case "cursor":
222
- return {
223
- name: "cursor",
224
- version: "1.0"
225
- };
226
- case "continue":
227
- return {
228
- name: "continue",
229
- version: "1.0"
230
- };
231
- case "cody":
232
- return {
233
- name: "cody",
234
- version: "1.0"
235
- };
236
- case "gemini":
237
- return {
238
- name: "gemini",
239
- version: "1.0"
240
- };
241
- case "generic-mcp":
242
- return {
243
- name: "generic-mcp-client",
244
- version: "1.0"
245
- };
246
- case "unknown":
247
- default:
248
- return {
249
- name: "mcp-test-client",
250
- version: "1.0"
251
- };
252
- }
253
- }
254
- function getPlatformCapabilities(platform) {
255
- const baseCapabilities = {
256
- sampling: {},
257
- // Include elicitation.form by default for testing elicitation workflows
258
- // Note: MCP SDK expects form to be an object, not boolean
259
- elicitation: {
260
- form: {}
261
- }
262
- };
263
- if (platform === "ext-apps") {
202
+ // libs/testing/src/interceptor/mock-registry.ts
203
+ var DefaultMockRegistry = class {
204
+ mocks = [];
205
+ add(mock) {
206
+ const entry = {
207
+ definition: mock,
208
+ callCount: 0,
209
+ calls: [],
210
+ remainingUses: mock.times ?? Infinity
211
+ };
212
+ this.mocks.push(entry);
264
213
  return {
265
- ...baseCapabilities,
266
- experimental: {
267
- [MCP_APPS_EXTENSION_KEY]: {
268
- mimeTypes: ["text/html;profile=mcp-app"]
214
+ remove: () => {
215
+ const index = this.mocks.indexOf(entry);
216
+ if (index !== -1) {
217
+ this.mocks.splice(index, 1);
269
218
  }
270
- }
219
+ },
220
+ callCount: () => entry.callCount,
221
+ calls: () => [...entry.calls]
271
222
  };
272
223
  }
273
- return baseCapabilities;
274
- }
275
-
276
- // libs/testing/src/client/mcp-test-client.builder.ts
277
- var McpTestClientBuilder = class {
278
- config;
279
- constructor(config) {
280
- this.config = { ...config };
224
+ clear() {
225
+ this.mocks = [];
281
226
  }
282
- /**
283
- * Set the authentication configuration
284
- */
285
- withAuth(auth) {
286
- this.config.auth = { ...this.config.auth, ...auth };
287
- return this;
227
+ getAll() {
228
+ return this.mocks.map((e) => e.definition);
229
+ }
230
+ match(request) {
231
+ for (const entry of this.mocks) {
232
+ if (entry.remainingUses <= 0) continue;
233
+ const { definition } = entry;
234
+ if (definition.method !== request.method) continue;
235
+ if (definition.params !== void 0) {
236
+ const params = request.params ?? {};
237
+ if (typeof definition.params === "function") {
238
+ if (!definition.params(params)) continue;
239
+ } else {
240
+ if (!this.paramsMatch(definition.params, params)) continue;
241
+ }
242
+ }
243
+ entry.callCount++;
244
+ entry.calls.push(request);
245
+ entry.remainingUses--;
246
+ return definition;
247
+ }
248
+ return void 0;
288
249
  }
289
250
  /**
290
- * Set the bearer token for authentication
251
+ * Check if request params match the mock params definition
291
252
  */
292
- withToken(token) {
293
- this.config.auth = { ...this.config.auth, token };
294
- return this;
253
+ paramsMatch(expected, actual) {
254
+ for (const [key, value] of Object.entries(expected)) {
255
+ if (!(key in actual)) return false;
256
+ const actualValue = actual[key];
257
+ if (Array.isArray(value)) {
258
+ if (!Array.isArray(actualValue)) return false;
259
+ if (value.length !== actualValue.length) return false;
260
+ for (let i = 0; i < value.length; i++) {
261
+ const expectedItem = value[i];
262
+ const actualItem = actualValue[i];
263
+ if (typeof expectedItem === "object" && expectedItem !== null) {
264
+ if (typeof actualItem !== "object" || actualItem === null) return false;
265
+ if (!this.paramsMatch(expectedItem, actualItem)) {
266
+ return false;
267
+ }
268
+ } else if (actualItem !== expectedItem) {
269
+ return false;
270
+ }
271
+ }
272
+ } else if (typeof value === "object" && value !== null) {
273
+ if (typeof actualValue !== "object" || actualValue === null) return false;
274
+ if (!this.paramsMatch(value, actualValue)) {
275
+ return false;
276
+ }
277
+ } else if (actualValue !== value) {
278
+ return false;
279
+ }
280
+ }
281
+ return true;
295
282
  }
283
+ };
284
+ var mockResponse = {
296
285
  /**
297
- * Add custom headers to all requests
286
+ * Create a successful JSON-RPC response
298
287
  */
299
- withHeaders(headers) {
300
- this.config.auth = {
301
- ...this.config.auth,
302
- headers: { ...this.config.auth?.headers, ...headers }
288
+ success(result, id = 1) {
289
+ return {
290
+ jsonrpc: "2.0",
291
+ id,
292
+ result
303
293
  };
304
- return this;
305
- }
294
+ },
306
295
  /**
307
- * Set the transport type
296
+ * Create an error JSON-RPC response
308
297
  */
309
- withTransport(transport) {
310
- this.config.transport = transport;
311
- return this;
312
- }
298
+ error(code, message, data, id = 1) {
299
+ return {
300
+ jsonrpc: "2.0",
301
+ id,
302
+ error: { code, message, data }
303
+ };
304
+ },
313
305
  /**
314
- * Set the request timeout in milliseconds
306
+ * Create a tool result response
315
307
  */
316
- withTimeout(timeoutMs) {
317
- this.config.timeout = timeoutMs;
318
- return this;
319
- }
308
+ toolResult(content, id = 1) {
309
+ return {
310
+ jsonrpc: "2.0",
311
+ id,
312
+ result: { content }
313
+ };
314
+ },
320
315
  /**
321
- * Enable debug logging
316
+ * Create a tools/list response
322
317
  */
323
- withDebug(enabled = true) {
324
- this.config.debug = enabled;
325
- return this;
326
- }
318
+ toolsList(tools, id = 1) {
319
+ return {
320
+ jsonrpc: "2.0",
321
+ id,
322
+ result: { tools }
323
+ };
324
+ },
327
325
  /**
328
- * Enable public mode - skip authentication entirely.
329
- * When true, no Authorization header is sent and anonymous token is not requested.
330
- * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
326
+ * Create a resources/list response
331
327
  */
332
- withPublicMode(enabled = true) {
333
- this.config.publicMode = enabled;
334
- return this;
335
- }
328
+ resourcesList(resources, id = 1) {
329
+ return {
330
+ jsonrpc: "2.0",
331
+ id,
332
+ result: { resources }
333
+ };
334
+ },
336
335
  /**
337
- * Set the MCP protocol version to request
336
+ * Create a resources/read response
338
337
  */
339
- withProtocolVersion(version) {
340
- this.config.protocolVersion = version;
341
- return this;
342
- }
338
+ resourceRead(contents, id = 1) {
339
+ return {
340
+ jsonrpc: "2.0",
341
+ id,
342
+ result: { contents }
343
+ };
344
+ },
343
345
  /**
344
- * Set the client info sent during initialization
346
+ * Common MCP errors
345
347
  */
346
- withClientInfo(info) {
347
- this.config.clientInfo = info;
348
- return this;
348
+ errors: {
349
+ methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
350
+ invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
351
+ internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
352
+ resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
353
+ toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
354
+ unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
355
+ forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
356
+ }
357
+ };
358
+
359
+ // libs/testing/src/interceptor/interceptor-chain.ts
360
+ var DefaultInterceptorChain = class {
361
+ request = [];
362
+ response = [];
363
+ mocks;
364
+ constructor() {
365
+ this.mocks = new DefaultMockRegistry();
349
366
  }
350
367
  /**
351
- * Set the platform type for testing platform-specific meta keys.
352
- * Automatically configures clientInfo and capabilities for platform detection.
353
- *
354
- * Platform-specific behavior:
355
- * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
356
- * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
357
- * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
358
- * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
359
- * - Other platforms follow similar patterns
360
- *
361
- * @example
362
- * ```typescript
363
- * const client = await McpTestClient.create({ baseUrl })
364
- * .withPlatform('openai')
365
- * .buildAndConnect();
366
- *
367
- * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
368
- * const extAppsClient = await McpTestClient.create({ baseUrl })
369
- * .withPlatform('ext-apps')
370
- * .buildAndConnect();
371
- * ```
368
+ * Add a request interceptor
372
369
  */
373
- withPlatform(platform) {
374
- this.config.platform = platform;
375
- this.config.clientInfo = getPlatformClientInfo(platform);
376
- this.config.capabilities = getPlatformCapabilities(platform);
377
- return this;
370
+ addRequestInterceptor(interceptor) {
371
+ this.request.push(interceptor);
372
+ return () => {
373
+ const index = this.request.indexOf(interceptor);
374
+ if (index !== -1) {
375
+ this.request.splice(index, 1);
376
+ }
377
+ };
378
378
  }
379
379
  /**
380
- * Set custom client capabilities for MCP initialization.
381
- * Use this for fine-grained control over capabilities sent during initialization.
382
- *
383
- * @example
384
- * ```typescript
385
- * const client = await McpTestClient.create({ baseUrl })
386
- * .withCapabilities({
387
- * sampling: {},
388
- * experimental: {
389
- * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
390
- * }
391
- * })
392
- * .buildAndConnect();
393
- * ```
380
+ * Add a response interceptor
394
381
  */
395
- withCapabilities(capabilities) {
396
- this.config.capabilities = capabilities;
397
- return this;
382
+ addResponseInterceptor(interceptor) {
383
+ this.response.push(interceptor);
384
+ return () => {
385
+ const index = this.response.indexOf(interceptor);
386
+ if (index !== -1) {
387
+ this.response.splice(index, 1);
388
+ }
389
+ };
398
390
  }
399
391
  /**
400
- * Set query parameters to append to the connection URL.
401
- * Useful for testing mode switches like `?mode=skills_only`.
402
- *
403
- * @example
404
- * ```typescript
405
- * const client = await McpTestClient.create({ baseUrl })
406
- * .withQueryParams({ mode: 'skills_only' })
407
- * .buildAndConnect();
408
- * ```
392
+ * Process a request through the interceptor chain
393
+ * Returns either:
394
+ * - { type: 'continue', request } - continue with (possibly modified) request
395
+ * - { type: 'mock', response } - return mock response immediately
396
+ * - { type: 'error', error } - throw error
409
397
  */
410
- withQueryParams(params) {
411
- this.config.queryParams = { ...this.config.queryParams, ...params };
412
- return this;
398
+ async processRequest(request, meta) {
399
+ let currentRequest = request;
400
+ const mockDef = this.mocks.match(request);
401
+ if (mockDef) {
402
+ if (mockDef.delay && mockDef.delay > 0) {
403
+ await sleep(mockDef.delay);
404
+ }
405
+ let mockResponse2;
406
+ if (typeof mockDef.response === "function") {
407
+ mockResponse2 = await mockDef.response(request);
408
+ } else {
409
+ mockResponse2 = mockDef.response;
410
+ }
411
+ return {
412
+ type: "mock",
413
+ response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
414
+ };
415
+ }
416
+ for (const interceptor of this.request) {
417
+ const ctx = {
418
+ request: currentRequest,
419
+ meta
420
+ };
421
+ const result = await interceptor(ctx);
422
+ switch (result.action) {
423
+ case "passthrough":
424
+ break;
425
+ case "modify":
426
+ currentRequest = result.request;
427
+ break;
428
+ case "mock":
429
+ return {
430
+ type: "mock",
431
+ response: { ...result.response, id: request.id ?? result.response.id }
432
+ };
433
+ case "error":
434
+ return { type: "error", error: result.error };
435
+ }
436
+ }
437
+ return { type: "continue", request: currentRequest };
413
438
  }
414
439
  /**
415
- * Build the McpTestClient instance (does not connect)
440
+ * Process a response through the interceptor chain
416
441
  */
417
- build() {
418
- return new McpTestClient(this.config);
442
+ async processResponse(request, response, durationMs) {
443
+ let currentResponse = response;
444
+ for (const interceptor of this.response) {
445
+ const ctx = {
446
+ request,
447
+ response: currentResponse,
448
+ durationMs
449
+ };
450
+ const result = await interceptor(ctx);
451
+ switch (result.action) {
452
+ case "passthrough":
453
+ break;
454
+ case "modify":
455
+ currentResponse = result.response;
456
+ break;
457
+ }
458
+ }
459
+ return currentResponse;
419
460
  }
420
461
  /**
421
- * Build the McpTestClient and connect to the server
462
+ * Clear all interceptors and mocks
422
463
  */
423
- async buildAndConnect() {
424
- const client = this.build();
425
- await client.connect();
426
- return client;
464
+ clear() {
465
+ this.request = [];
466
+ this.response = [];
467
+ this.mocks.clear();
427
468
  }
428
469
  };
470
+ function sleep(ms) {
471
+ return new Promise((resolve) => setTimeout(resolve, ms));
472
+ }
429
473
 
430
474
  // libs/testing/src/transport/streamable-http.transport.ts
431
475
  var DEFAULT_TIMEOUT = 3e4;
476
+ var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
432
477
  var StreamableHttpTransport = class {
433
478
  config;
434
479
  state = "disconnected";
@@ -440,10 +485,15 @@ var StreamableHttpTransport = class {
440
485
  interceptors;
441
486
  publicMode;
442
487
  elicitationHandler;
488
+ notificationHandler;
489
+ notificationStream;
490
+ notificationProtocolVersion = "";
491
+ notificationStreamReopenTimer;
443
492
  constructor(config) {
444
493
  this.config = {
445
494
  baseUrl: config.baseUrl.replace(/\/$/, ""),
446
495
  // Remove trailing slash
496
+ entryPath: config.entryPath,
447
497
  timeout: config.timeout ?? DEFAULT_TIMEOUT,
448
498
  auth: config.auth ?? {},
449
499
  publicMode: config.publicMode ?? false,
@@ -455,6 +505,7 @@ var StreamableHttpTransport = class {
455
505
  this.interceptors = config.interceptors;
456
506
  this.publicMode = config.publicMode ?? false;
457
507
  this.elicitationHandler = config.elicitationHandler;
508
+ this.notificationHandler = config.notificationHandler;
458
509
  }
459
510
  async connect() {
460
511
  this.state = "connecting";
@@ -536,7 +587,7 @@ var StreamableHttpTransport = class {
536
587
  }
537
588
  const headers = this.buildHeaders();
538
589
  this.lastRequestHeaders = headers;
539
- const url = `${this.config.baseUrl}/`;
590
+ const url = this.mcpUrl();
540
591
  this.log(`POST ${url}`, message);
541
592
  const controller = new AbortController();
542
593
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -606,7 +657,7 @@ var StreamableHttpTransport = class {
606
657
  this.ensureConnected();
607
658
  const headers = this.buildHeaders();
608
659
  this.lastRequestHeaders = headers;
609
- const url = `${this.config.baseUrl}/`;
660
+ const url = this.mcpUrl();
610
661
  this.log(`POST ${url} (notification)`, message);
611
662
  const controller = new AbortController();
612
663
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -637,7 +688,7 @@ var StreamableHttpTransport = class {
637
688
  this.ensureConnected();
638
689
  const headers = this.buildHeaders();
639
690
  this.lastRequestHeaders = headers;
640
- const url = `${this.config.baseUrl}/`;
691
+ const url = this.mcpUrl();
641
692
  this.log(`POST ${url} (raw)`, data);
642
693
  const controller = new AbortController();
643
694
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -674,7 +725,12 @@ var StreamableHttpTransport = class {
674
725
  };
675
726
  }
676
727
  }
728
+ async openNotificationStream(protocolVersion) {
729
+ this.notificationProtocolVersion = protocolVersion;
730
+ await this.connectNotificationStream();
731
+ }
677
732
  async close() {
733
+ this.closeNotificationStream();
678
734
  this.state = "disconnected";
679
735
  this.sessionId = void 0;
680
736
  this.log("StreamableHTTP transport closed");
@@ -713,6 +769,7 @@ var StreamableHttpTransport = class {
713
769
  return { ...this.lastRequestHeaders };
714
770
  }
715
771
  async simulateDisconnect() {
772
+ this.closeNotificationStream();
716
773
  this.state = "disconnected";
717
774
  this.sessionId = void 0;
718
775
  }
@@ -731,6 +788,72 @@ var StreamableHttpTransport = class {
731
788
  // ═══════════════════════════════════════════════════════════════════
732
789
  // PRIVATE HELPERS
733
790
  // ═══════════════════════════════════════════════════════════════════
791
+ closeNotificationStream() {
792
+ clearTimeout(this.notificationStreamReopenTimer);
793
+ this.notificationStream?.abort();
794
+ this.notificationStream = void 0;
795
+ }
796
+ /** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
797
+ async connectNotificationStream() {
798
+ if (!this.sessionId || this.notificationStream) return;
799
+ const controller = new AbortController();
800
+ this.notificationStream = controller;
801
+ const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
802
+ const established = fetch(this.mcpUrl(), {
803
+ method: "GET",
804
+ headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
805
+ signal: controller.signal
806
+ }).then(
807
+ async (response) => {
808
+ const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
809
+ if (!response.ok || !response.body || !isEventStream) {
810
+ await response.body?.cancel();
811
+ this.log(`Server offers no notification stream (HTTP ${response.status})`);
812
+ this.releaseNotificationStream(controller);
813
+ return;
814
+ }
815
+ void this.readNotificationStream(controller, response.body.getReader());
816
+ },
817
+ (error) => {
818
+ this.log("Failed to open the notification stream:", error);
819
+ this.releaseNotificationStream(controller);
820
+ }
821
+ );
822
+ let establishTimer;
823
+ const establishDeadline = new Promise((resolve) => {
824
+ establishTimer = setTimeout(resolve, this.config.timeout);
825
+ });
826
+ await Promise.race([established, establishDeadline]);
827
+ clearTimeout(establishTimer);
828
+ }
829
+ releaseNotificationStream(controller) {
830
+ if (this.notificationStream === controller) this.notificationStream = void 0;
831
+ }
832
+ async readNotificationStream(controller, reader) {
833
+ const decoder = new TextDecoder();
834
+ let buffer = "";
835
+ try {
836
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
837
+ const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
838
+ buffer = remainder;
839
+ for (const eventText of completeEvents) {
840
+ for (const event of this.parseSSEEvents(eventText, void 0).events) {
841
+ await this.handleSSEEvent(event);
842
+ }
843
+ }
844
+ }
845
+ } catch (error) {
846
+ this.log("Notification stream ended:", error);
847
+ } finally {
848
+ reader.releaseLock();
849
+ }
850
+ if (controller.signal.aborted || this.notificationStream !== controller) return;
851
+ this.releaseNotificationStream(controller);
852
+ this.notificationStreamReopenTimer = setTimeout(
853
+ () => void this.connectNotificationStream(),
854
+ NOTIFICATION_STREAM_REOPEN_DELAY_MS
855
+ );
856
+ }
734
857
  /**
735
858
  * Handle SSE response with elicitation support.
736
859
  *
@@ -868,6 +991,13 @@ var StreamableHttpTransport = class {
868
991
  response: { jsonrpc: "2.0", id: null, result: void 0 }
869
992
  };
870
993
  }
994
+ if ("method" in parsed && parsed.id === void 0) {
995
+ this.notificationHandler?.(parsed);
996
+ return {
997
+ isFinal: false,
998
+ response: { jsonrpc: "2.0", id: null, result: void 0 }
999
+ };
1000
+ }
871
1001
  if ("result" in parsed || "error" in parsed) {
872
1002
  return { isFinal: true, response: parsed };
873
1003
  }
@@ -920,7 +1050,7 @@ var StreamableHttpTransport = class {
920
1050
  */
921
1051
  async sendElicitationResponse(requestId, response) {
922
1052
  const headers = this.buildHeaders();
923
- const url = `${this.config.baseUrl}/`;
1053
+ const url = this.mcpUrl();
924
1054
  const rpcResponse = {
925
1055
  jsonrpc: "2.0",
926
1056
  id: requestId,
@@ -940,6 +1070,18 @@ var StreamableHttpTransport = class {
940
1070
  this.log("Failed to send elicitation response:", error);
941
1071
  }
942
1072
  }
1073
+ /**
1074
+ * The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
1075
+ * trailing slash is kept because both hosts normalize it and the original
1076
+ * behaviour (`${baseUrl}/`) relied on it (issue #543).
1077
+ */
1078
+ mcpUrl() {
1079
+ const url = new URL(this.config.baseUrl);
1080
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
1081
+ const basePath = url.pathname.replace(/\/+$/, "");
1082
+ url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
1083
+ return url.toString();
1084
+ }
943
1085
  buildHeaders() {
944
1086
  const headers = {
945
1087
  "Content-Type": "application/json",
@@ -1026,286 +1168,266 @@ var StreamableHttpTransport = class {
1026
1168
  };
1027
1169
  }
1028
1170
  };
1171
+ function splitSSEEvents(buffer) {
1172
+ const endsWithCarriageReturn = buffer.endsWith("\r");
1173
+ const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
1174
+ const completeEvents = normalized.split("\n\n");
1175
+ const remainder = completeEvents.pop() ?? "";
1176
+ return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
1177
+ }
1029
1178
 
1030
- // libs/testing/src/interceptor/mock-registry.ts
1031
- var DefaultMockRegistry = class {
1032
- mocks = [];
1033
- add(mock) {
1034
- const entry = {
1035
- definition: mock,
1036
- callCount: 0,
1037
- calls: [],
1038
- remainingUses: mock.times ?? Infinity
1039
- };
1040
- this.mocks.push(entry);
1179
+ // libs/testing/src/platform/platform-client-info.ts
1180
+ var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
1181
+ function getPlatformClientInfo(platform) {
1182
+ switch (platform) {
1183
+ case "openai":
1184
+ return {
1185
+ name: "ChatGPT",
1186
+ version: "1.0"
1187
+ };
1188
+ case "ext-apps":
1189
+ return {
1190
+ name: "mcp-ext-apps",
1191
+ version: "1.0"
1192
+ };
1193
+ case "claude":
1194
+ return {
1195
+ name: "claude-desktop",
1196
+ version: "1.0"
1197
+ };
1198
+ case "cursor":
1199
+ return {
1200
+ name: "cursor",
1201
+ version: "1.0"
1202
+ };
1203
+ case "continue":
1204
+ return {
1205
+ name: "continue",
1206
+ version: "1.0"
1207
+ };
1208
+ case "cody":
1209
+ return {
1210
+ name: "cody",
1211
+ version: "1.0"
1212
+ };
1213
+ case "gemini":
1214
+ return {
1215
+ name: "gemini",
1216
+ version: "1.0"
1217
+ };
1218
+ case "generic-mcp":
1219
+ return {
1220
+ name: "generic-mcp-client",
1221
+ version: "1.0"
1222
+ };
1223
+ case "unknown":
1224
+ default:
1225
+ return {
1226
+ name: "mcp-test-client",
1227
+ version: "1.0"
1228
+ };
1229
+ }
1230
+ }
1231
+ function getPlatformCapabilities(platform) {
1232
+ const baseCapabilities = {
1233
+ sampling: {},
1234
+ // Include elicitation.form by default for testing elicitation workflows
1235
+ // Note: MCP SDK expects form to be an object, not boolean
1236
+ elicitation: {
1237
+ form: {}
1238
+ }
1239
+ };
1240
+ if (platform === "ext-apps") {
1041
1241
  return {
1042
- remove: () => {
1043
- const index = this.mocks.indexOf(entry);
1044
- if (index !== -1) {
1045
- this.mocks.splice(index, 1);
1242
+ ...baseCapabilities,
1243
+ experimental: {
1244
+ [MCP_APPS_EXTENSION_KEY]: {
1245
+ mimeTypes: ["text/html;profile=mcp-app"]
1046
1246
  }
1047
- },
1048
- callCount: () => entry.callCount,
1049
- calls: () => [...entry.calls]
1247
+ }
1050
1248
  };
1051
1249
  }
1052
- clear() {
1053
- this.mocks = [];
1054
- }
1055
- getAll() {
1056
- return this.mocks.map((e) => e.definition);
1057
- }
1058
- match(request) {
1059
- for (const entry of this.mocks) {
1060
- if (entry.remainingUses <= 0) continue;
1061
- const { definition } = entry;
1062
- if (definition.method !== request.method) continue;
1063
- if (definition.params !== void 0) {
1064
- const params = request.params ?? {};
1065
- if (typeof definition.params === "function") {
1066
- if (!definition.params(params)) continue;
1067
- } else {
1068
- if (!this.paramsMatch(definition.params, params)) continue;
1069
- }
1070
- }
1071
- entry.callCount++;
1072
- entry.calls.push(request);
1073
- entry.remainingUses--;
1074
- return definition;
1075
- }
1076
- return void 0;
1250
+ return baseCapabilities;
1251
+ }
1252
+
1253
+ // libs/testing/src/client/mcp-test-client.builder.ts
1254
+ var McpTestClientBuilder = class {
1255
+ config;
1256
+ constructor(config) {
1257
+ this.config = { ...config };
1077
1258
  }
1078
1259
  /**
1079
- * Check if request params match the mock params definition
1260
+ * Set the authentication configuration
1080
1261
  */
1081
- paramsMatch(expected, actual) {
1082
- for (const [key, value] of Object.entries(expected)) {
1083
- if (!(key in actual)) return false;
1084
- const actualValue = actual[key];
1085
- if (Array.isArray(value)) {
1086
- if (!Array.isArray(actualValue)) return false;
1087
- if (value.length !== actualValue.length) return false;
1088
- for (let i = 0; i < value.length; i++) {
1089
- const expectedItem = value[i];
1090
- const actualItem = actualValue[i];
1091
- if (typeof expectedItem === "object" && expectedItem !== null) {
1092
- if (typeof actualItem !== "object" || actualItem === null) return false;
1093
- if (!this.paramsMatch(expectedItem, actualItem)) {
1094
- return false;
1095
- }
1096
- } else if (actualItem !== expectedItem) {
1097
- return false;
1098
- }
1099
- }
1100
- } else if (typeof value === "object" && value !== null) {
1101
- if (typeof actualValue !== "object" || actualValue === null) return false;
1102
- if (!this.paramsMatch(value, actualValue)) {
1103
- return false;
1104
- }
1105
- } else if (actualValue !== value) {
1106
- return false;
1107
- }
1108
- }
1109
- return true;
1262
+ withAuth(auth) {
1263
+ this.config.auth = { ...this.config.auth, ...auth };
1264
+ return this;
1110
1265
  }
1111
- };
1112
- var mockResponse = {
1113
1266
  /**
1114
- * Create a successful JSON-RPC response
1267
+ * Set the bearer token for authentication
1115
1268
  */
1116
- success(result, id = 1) {
1117
- return {
1118
- jsonrpc: "2.0",
1119
- id,
1120
- result
1121
- };
1122
- },
1269
+ withToken(token) {
1270
+ this.config.auth = { ...this.config.auth, token };
1271
+ return this;
1272
+ }
1123
1273
  /**
1124
- * Create an error JSON-RPC response
1274
+ * Add custom headers to all requests
1125
1275
  */
1126
- error(code, message, data, id = 1) {
1127
- return {
1128
- jsonrpc: "2.0",
1129
- id,
1130
- error: { code, message, data }
1276
+ withHeaders(headers) {
1277
+ this.config.auth = {
1278
+ ...this.config.auth,
1279
+ headers: { ...this.config.auth?.headers, ...headers }
1131
1280
  };
1132
- },
1281
+ return this;
1282
+ }
1133
1283
  /**
1134
- * Create a tool result response
1284
+ * Set the transport type
1135
1285
  */
1136
- toolResult(content, id = 1) {
1137
- return {
1138
- jsonrpc: "2.0",
1139
- id,
1140
- result: { content }
1141
- };
1142
- },
1286
+ withTransport(transport) {
1287
+ this.config.transport = transport;
1288
+ return this;
1289
+ }
1143
1290
  /**
1144
- * Create a tools/list response
1291
+ * Set the request timeout in milliseconds
1145
1292
  */
1146
- toolsList(tools, id = 1) {
1147
- return {
1148
- jsonrpc: "2.0",
1149
- id,
1150
- result: { tools }
1151
- };
1152
- },
1293
+ withTimeout(timeoutMs) {
1294
+ this.config.timeout = timeoutMs;
1295
+ return this;
1296
+ }
1153
1297
  /**
1154
- * Create a resources/list response
1298
+ * Enable debug logging
1155
1299
  */
1156
- resourcesList(resources, id = 1) {
1157
- return {
1158
- jsonrpc: "2.0",
1159
- id,
1160
- result: { resources }
1161
- };
1162
- },
1300
+ withDebug(enabled = true) {
1301
+ this.config.debug = enabled;
1302
+ return this;
1303
+ }
1163
1304
  /**
1164
- * Create a resources/read response
1305
+ * Enable public mode - skip authentication entirely.
1306
+ * When true, no Authorization header is sent and anonymous token is not requested.
1307
+ * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
1165
1308
  */
1166
- resourceRead(contents, id = 1) {
1167
- return {
1168
- jsonrpc: "2.0",
1169
- id,
1170
- result: { contents }
1171
- };
1172
- },
1309
+ withPublicMode(enabled = true) {
1310
+ this.config.publicMode = enabled;
1311
+ return this;
1312
+ }
1173
1313
  /**
1174
- * Common MCP errors
1314
+ * Set the MCP protocol version to request (default '2025-06-18').
1315
+ * '2026-07-28' has no initialize handshake, so `build()` throws for it.
1175
1316
  */
1176
- errors: {
1177
- methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
1178
- invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
1179
- internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
1180
- resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
1181
- toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
1182
- unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
1183
- forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
1317
+ withProtocolVersion(version) {
1318
+ this.config.protocolVersion = version;
1319
+ return this;
1184
1320
  }
1185
- };
1186
-
1187
- // libs/testing/src/interceptor/interceptor-chain.ts
1188
- var DefaultInterceptorChain = class {
1189
- request = [];
1190
- response = [];
1191
- mocks;
1192
- constructor() {
1193
- this.mocks = new DefaultMockRegistry();
1321
+ /**
1322
+ * Set the client info sent during initialization
1323
+ */
1324
+ withClientInfo(info) {
1325
+ this.config.clientInfo = info;
1326
+ return this;
1194
1327
  }
1195
1328
  /**
1196
- * Add a request interceptor
1329
+ * Set the platform type for testing platform-specific meta keys.
1330
+ * Automatically configures clientInfo and capabilities for platform detection.
1331
+ *
1332
+ * Platform-specific behavior:
1333
+ * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
1334
+ * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
1335
+ * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
1336
+ * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
1337
+ * - Other platforms follow similar patterns
1338
+ *
1339
+ * @example
1340
+ * ```typescript
1341
+ * const client = await McpTestClient.create({ baseUrl })
1342
+ * .withPlatform('openai')
1343
+ * .buildAndConnect();
1344
+ *
1345
+ * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
1346
+ * const extAppsClient = await McpTestClient.create({ baseUrl })
1347
+ * .withPlatform('ext-apps')
1348
+ * .buildAndConnect();
1349
+ * ```
1197
1350
  */
1198
- addRequestInterceptor(interceptor) {
1199
- this.request.push(interceptor);
1200
- return () => {
1201
- const index = this.request.indexOf(interceptor);
1202
- if (index !== -1) {
1203
- this.request.splice(index, 1);
1204
- }
1205
- };
1351
+ withPlatform(platform) {
1352
+ this.config.platform = platform;
1353
+ this.config.clientInfo = getPlatformClientInfo(platform);
1354
+ this.config.capabilities = getPlatformCapabilities(platform);
1355
+ return this;
1206
1356
  }
1207
1357
  /**
1208
- * Add a response interceptor
1358
+ * Set custom client capabilities for MCP initialization.
1359
+ * Use this for fine-grained control over capabilities sent during initialization.
1360
+ *
1361
+ * @example
1362
+ * ```typescript
1363
+ * const client = await McpTestClient.create({ baseUrl })
1364
+ * .withCapabilities({
1365
+ * sampling: {},
1366
+ * experimental: {
1367
+ * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
1368
+ * }
1369
+ * })
1370
+ * .buildAndConnect();
1371
+ * ```
1209
1372
  */
1210
- addResponseInterceptor(interceptor) {
1211
- this.response.push(interceptor);
1212
- return () => {
1213
- const index = this.response.indexOf(interceptor);
1214
- if (index !== -1) {
1215
- this.response.splice(index, 1);
1216
- }
1217
- };
1373
+ withCapabilities(capabilities) {
1374
+ this.config.capabilities = capabilities;
1375
+ return this;
1218
1376
  }
1219
1377
  /**
1220
- * Process a request through the interceptor chain
1221
- * Returns either:
1222
- * - { type: 'continue', request } - continue with (possibly modified) request
1223
- * - { type: 'mock', response } - return mock response immediately
1224
- * - { type: 'error', error } - throw error
1378
+ * Set query parameters to append to the connection URL.
1379
+ * Useful for testing mode switches like `?mode=skills_only`.
1380
+ *
1381
+ * @example
1382
+ * ```typescript
1383
+ * const client = await McpTestClient.create({ baseUrl })
1384
+ * .withQueryParams({ mode: 'skills_only' })
1385
+ * .buildAndConnect();
1386
+ * ```
1225
1387
  */
1226
- async processRequest(request, meta) {
1227
- let currentRequest = request;
1228
- const mockDef = this.mocks.match(request);
1229
- if (mockDef) {
1230
- if (mockDef.delay && mockDef.delay > 0) {
1231
- await sleep(mockDef.delay);
1232
- }
1233
- let mockResponse2;
1234
- if (typeof mockDef.response === "function") {
1235
- mockResponse2 = await mockDef.response(request);
1236
- } else {
1237
- mockResponse2 = mockDef.response;
1238
- }
1239
- return {
1240
- type: "mock",
1241
- response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
1242
- };
1243
- }
1244
- for (const interceptor of this.request) {
1245
- const ctx = {
1246
- request: currentRequest,
1247
- meta
1248
- };
1249
- const result = await interceptor(ctx);
1250
- switch (result.action) {
1251
- case "passthrough":
1252
- break;
1253
- case "modify":
1254
- currentRequest = result.request;
1255
- break;
1256
- case "mock":
1257
- return {
1258
- type: "mock",
1259
- response: { ...result.response, id: request.id ?? result.response.id }
1260
- };
1261
- case "error":
1262
- return { type: "error", error: result.error };
1263
- }
1264
- }
1265
- return { type: "continue", request: currentRequest };
1388
+ withQueryParams(params) {
1389
+ this.config.queryParams = { ...this.config.queryParams, ...params };
1390
+ return this;
1266
1391
  }
1267
1392
  /**
1268
- * Process a response through the interceptor chain
1393
+ * Build the McpTestClient instance (does not connect)
1269
1394
  */
1270
- async processResponse(request, response, durationMs) {
1271
- let currentResponse = response;
1272
- for (const interceptor of this.response) {
1273
- const ctx = {
1274
- request,
1275
- response: currentResponse,
1276
- durationMs
1277
- };
1278
- const result = await interceptor(ctx);
1279
- switch (result.action) {
1280
- case "passthrough":
1281
- break;
1282
- case "modify":
1283
- currentResponse = result.response;
1284
- break;
1285
- }
1286
- }
1287
- return currentResponse;
1395
+ build() {
1396
+ return new McpTestClient(this.config);
1288
1397
  }
1289
1398
  /**
1290
- * Clear all interceptors and mocks
1399
+ * Build the McpTestClient and connect to the server
1291
1400
  */
1292
- clear() {
1293
- this.request = [];
1294
- this.response = [];
1295
- this.mocks.clear();
1401
+ async buildAndConnect() {
1402
+ const client = this.build();
1403
+ await client.connect();
1404
+ return client;
1296
1405
  }
1297
1406
  };
1298
- function sleep(ms) {
1299
- return new Promise((resolve) => setTimeout(resolve, ms));
1300
- }
1301
1407
 
1302
1408
  // libs/testing/src/client/mcp-test-client.ts
1303
1409
  var DEFAULT_TIMEOUT2 = 3e4;
1304
1410
  var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
1411
+ var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
1305
1412
  var DEFAULT_CLIENT_INFO = {
1306
1413
  name: "@frontmcp/testing",
1307
1414
  version: "0.4.0"
1308
1415
  };
1416
+ function extractEntryPaths(data) {
1417
+ let payload = data;
1418
+ if (typeof payload === "string") {
1419
+ try {
1420
+ payload = JSON.parse(payload);
1421
+ } catch {
1422
+ return void 0;
1423
+ }
1424
+ }
1425
+ if (!payload || typeof payload !== "object") return void 0;
1426
+ const paths = payload.entryPaths;
1427
+ if (!Array.isArray(paths)) return void 0;
1428
+ const strings = paths.filter((p) => typeof p === "string");
1429
+ return strings.length > 0 ? strings : void 0;
1430
+ }
1309
1431
  var McpTestClient = class {
1310
1432
  // Platform, capabilities, and queryParams are optional - only set when needed
1311
1433
  config;
@@ -1314,6 +1436,8 @@ var McpTestClient = class {
1314
1436
  requestIdCounter = 0;
1315
1437
  _lastRequestId = 0;
1316
1438
  _sessionId;
1439
+ /** #543 — discovery is attempted at most once per connect. */
1440
+ triedDiscoveredEntryPath = false;
1317
1441
  _sessionInfo = null;
1318
1442
  _authState = { isAnonymous: true, scopes: [] };
1319
1443
  // Logging and tracing
@@ -1321,6 +1445,8 @@ var McpTestClient = class {
1321
1445
  _traces = [];
1322
1446
  _notifications = [];
1323
1447
  _progressUpdates = [];
1448
+ collectingProgress = false;
1449
+ progressTokenCounter = 0;
1324
1450
  // Interceptor chain
1325
1451
  _interceptors;
1326
1452
  // Elicitation handler for server→client elicit requests
@@ -1329,8 +1455,14 @@ var McpTestClient = class {
1329
1455
  // CONSTRUCTOR & FACTORY
1330
1456
  // ═══════════════════════════════════════════════════════════════════
1331
1457
  constructor(config) {
1458
+ if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
1459
+ throw new Error(
1460
+ `McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
1461
+ );
1462
+ }
1332
1463
  this.config = {
1333
1464
  baseUrl: config.baseUrl,
1465
+ entryPath: config.entryPath,
1334
1466
  transport: config.transport ?? "streamable-http",
1335
1467
  auth: config.auth ?? {},
1336
1468
  publicMode: config.publicMode ?? false,
@@ -1366,11 +1498,13 @@ var McpTestClient = class {
1366
1498
  */
1367
1499
  async connect() {
1368
1500
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
1501
+ this.triedDiscoveredEntryPath = false;
1369
1502
  this.transport = this.createTransport();
1370
1503
  await this.transport.connect();
1371
1504
  const maxRetries = 3;
1372
1505
  const retryDelayMs = 500;
1373
1506
  let lastError;
1507
+ let reportedEntryPaths;
1374
1508
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1375
1509
  const initResponse = await this.initialize();
1376
1510
  if (initResponse.success && initResponse.data) {
@@ -1378,13 +1512,27 @@ var McpTestClient = class {
1378
1512
  break;
1379
1513
  }
1380
1514
  lastError = initResponse.error?.message ?? "Unknown error";
1515
+ reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
1516
+ if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
1517
+ this.triedDiscoveredEntryPath = true;
1518
+ const discovered = reportedEntryPaths[0];
1519
+ this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
1520
+ this.config.entryPath = discovered;
1521
+ await this.transport.close();
1522
+ this.transport = this.createTransport();
1523
+ await this.transport.connect();
1524
+ continue;
1525
+ }
1381
1526
  if (attempt < maxRetries) {
1382
1527
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
1383
1528
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
1384
1529
  }
1385
1530
  }
1386
1531
  if (!this.initResult) {
1387
- throw new Error(`Failed to initialize MCP connection after ${maxRetries} attempts: ${lastError}`);
1532
+ 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.` : "";
1533
+ throw new Error(
1534
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
1535
+ );
1388
1536
  }
1389
1537
  this._sessionId = this.transport.getSessionId();
1390
1538
  this._sessionInfo = {
@@ -1397,6 +1545,7 @@ var McpTestClient = class {
1397
1545
  jsonrpc: "2.0",
1398
1546
  method: "notifications/initialized"
1399
1547
  });
1548
+ await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
1400
1549
  this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
1401
1550
  return this.initResult;
1402
1551
  }
@@ -1507,8 +1656,8 @@ var McpTestClient = class {
1507
1656
  /**
1508
1657
  * Call a tool by name with arguments
1509
1658
  */
1510
- call: async (name, args) => {
1511
- const response = await this.callTool(name, args);
1659
+ call: async (name, args, options = {}) => {
1660
+ const response = await this.callTool(name, args, this.progressTokenFor(options));
1512
1661
  return this.wrapToolResult(response);
1513
1662
  }
1514
1663
  };
@@ -1650,6 +1799,7 @@ var McpTestClient = class {
1650
1799
  * Collect progress notifications specifically
1651
1800
  */
1652
1801
  collectProgress: () => {
1802
+ this.collectingProgress = true;
1653
1803
  return new ProgressCollector(this._progressUpdates);
1654
1804
  },
1655
1805
  /**
@@ -1955,12 +2105,29 @@ var McpTestClient = class {
1955
2105
  async listTools() {
1956
2106
  return this.request("tools/list", {});
1957
2107
  }
1958
- async callTool(name, args) {
2108
+ async callTool(name, args, progressToken) {
1959
2109
  return this.request("tools/call", {
1960
2110
  name,
1961
- arguments: args ?? {}
2111
+ arguments: args ?? {},
2112
+ ...progressToken !== void 0 && { _meta: { progressToken } }
1962
2113
  });
1963
2114
  }
2115
+ progressTokenFor(options) {
2116
+ if (options.progressToken !== void 0) return options.progressToken;
2117
+ return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
2118
+ }
2119
+ recordNotification = (notification) => {
2120
+ const timestamp = /* @__PURE__ */ new Date();
2121
+ this._notifications.push({ method: notification.method, params: notification.params, timestamp });
2122
+ if (notification.method !== "notifications/progress") return;
2123
+ const { progress, total, progressToken } = notification.params ?? {};
2124
+ this._progressUpdates.push({
2125
+ progress: Number(progress),
2126
+ total: typeof total === "number" ? total : void 0,
2127
+ progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
2128
+ timestamp
2129
+ });
2130
+ };
1964
2131
  async listResources() {
1965
2132
  return this.request("resources/list", {});
1966
2133
  }
@@ -1982,6 +2149,11 @@ var McpTestClient = class {
1982
2149
  // ═══════════════════════════════════════════════════════════════════
1983
2150
  // PRIVATE: TRANSPORT & REQUEST HELPERS
1984
2151
  // ═══════════════════════════════════════════════════════════════════
2152
+ /** The MCP path this client is currently pointed at, for log and error text. */
2153
+ mcpPathLabel() {
2154
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
2155
+ return entry ? `/${entry}` : "/";
2156
+ }
1985
2157
  createTransport() {
1986
2158
  let baseUrl = this.config.baseUrl;
1987
2159
  if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
@@ -1995,13 +2167,15 @@ var McpTestClient = class {
1995
2167
  case "streamable-http":
1996
2168
  return new StreamableHttpTransport({
1997
2169
  baseUrl,
2170
+ entryPath: this.config.entryPath,
1998
2171
  timeout: this.config.timeout,
1999
2172
  auth: this.config.auth,
2000
2173
  publicMode: this.config.publicMode,
2001
2174
  debug: this.config.debug,
2002
2175
  interceptors: this._interceptors,
2003
2176
  clientInfo: this.config.clientInfo,
2004
- elicitationHandler: this._elicitationHandler
2177
+ elicitationHandler: this._elicitationHandler,
2178
+ notificationHandler: this.recordNotification
2005
2179
  });
2006
2180
  case "sse":
2007
2181
  throw new Error("SSE transport not yet implemented");
@@ -2986,13 +3160,17 @@ var currentConfig = {};
2986
3160
  var serverInstance = null;
2987
3161
  var tokenFactory = null;
2988
3162
  var serverStartedByUs = false;
3163
+ var skipScopes = [{ skipped: false }];
3164
+ function currentSkipScope() {
3165
+ return skipScopes[skipScopes.length - 1];
3166
+ }
2989
3167
  async function initializeSharedResources() {
2990
3168
  if (!tokenFactory) {
2991
3169
  const sharedSecret = currentConfig.env?.["JWT_SECRET"];
2992
3170
  tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
2993
3171
  }
2994
3172
  if (!serverInstance) {
2995
- if (currentConfig.baseUrl) {
3173
+ if (currentConfig.baseUrl && !currentConfig.server) {
2996
3174
  serverInstance = TestServer.connect(currentConfig.baseUrl);
2997
3175
  serverStartedByUs = false;
2998
3176
  } else if (currentConfig.server) {
@@ -3042,7 +3220,8 @@ async function createTestFixtures() {
3042
3220
  throw new Error("Token factory not initialized");
3043
3221
  }
3044
3222
  const clientInstance = McpTestClient.create({
3045
- baseUrl: serverInstance.info.baseUrl,
3223
+ baseUrl: resolveClientBaseUrl(serverInstance),
3224
+ entryPath: currentConfig.entryPath,
3046
3225
  transport: currentConfig.transport ?? "streamable-http",
3047
3226
  publicMode: currentConfig.publicMode
3048
3227
  }).build();
@@ -3091,6 +3270,9 @@ async function cleanupSharedResources() {
3091
3270
  tokenFactory = null;
3092
3271
  serverStartedByUs = false;
3093
3272
  }
3273
+ function resolveClientBaseUrl(server) {
3274
+ return currentConfig.baseUrl ?? server.info.baseUrl;
3275
+ }
3094
3276
  function createAuthFixture(factory) {
3095
3277
  const users = {
3096
3278
  admin: {
@@ -3140,7 +3322,8 @@ function createServerFixture(server) {
3140
3322
  info: server.info,
3141
3323
  createClient: async (opts) => {
3142
3324
  return McpTestClient.create({
3143
- baseUrl: server.info.baseUrl,
3325
+ baseUrl: resolveClientBaseUrl(server),
3326
+ entryPath: opts?.entryPath ?? currentConfig.entryPath,
3144
3327
  transport: opts?.transport ?? "streamable-http",
3145
3328
  auth: opts?.token ? { token: opts.token } : void 0,
3146
3329
  clientInfo: opts?.clientInfo,
@@ -3149,7 +3332,8 @@ function createServerFixture(server) {
3149
3332
  },
3150
3333
  createClientBuilder: () => {
3151
3334
  const builder = new McpTestClientBuilder({
3152
- baseUrl: server.info.baseUrl,
3335
+ baseUrl: resolveClientBaseUrl(server),
3336
+ entryPath: currentConfig.entryPath,
3153
3337
  publicMode: currentConfig.publicMode
3154
3338
  });
3155
3339
  return builder;
@@ -3165,8 +3349,8 @@ function resolveServerCommand(server) {
3165
3349
  }
3166
3350
  return `npx tsx ${server}`;
3167
3351
  }
3168
- function testWithFixtures(name, fn) {
3169
- it(name, async () => {
3352
+ function runWithFixtures(fn) {
3353
+ return async () => {
3170
3354
  const fixtures = await createTestFixtures();
3171
3355
  let testFailed = false;
3172
3356
  try {
@@ -3177,7 +3361,15 @@ function testWithFixtures(name, fn) {
3177
3361
  } finally {
3178
3362
  await cleanupTestFixtures(fixtures, testFailed);
3179
3363
  }
3180
- });
3364
+ };
3365
+ }
3366
+ function testWithFixtures(name, fn) {
3367
+ const scope = currentSkipScope();
3368
+ if (scope.skipped) {
3369
+ it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
3370
+ return;
3371
+ }
3372
+ it(name, runWithFixtures(fn));
3181
3373
  }
3182
3374
  function use(config) {
3183
3375
  currentConfig = { ...currentConfig, ...config };
@@ -3185,40 +3377,60 @@ function use(config) {
3185
3377
  await cleanupSharedResources();
3186
3378
  });
3187
3379
  }
3188
- function skip(name, fn) {
3189
- it.skip(name, async () => {
3190
- const fixtures = await createTestFixtures();
3191
- let testFailed = false;
3192
- try {
3193
- await fn(fixtures);
3194
- } catch (error) {
3195
- testFailed = true;
3196
- throw error;
3197
- } finally {
3198
- await cleanupTestFixtures(fixtures, testFailed);
3380
+ function skip(nameOrCondition, fnOrReason) {
3381
+ if (typeof nameOrCondition === "boolean") {
3382
+ if (nameOrCondition) {
3383
+ const scope = currentSkipScope();
3384
+ scope.skipped = true;
3385
+ scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
3199
3386
  }
3200
- });
3387
+ return;
3388
+ }
3389
+ if (typeof fnOrReason !== "function") {
3390
+ throw new TypeError(
3391
+ `test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
3392
+ );
3393
+ }
3394
+ it.skip(nameOrCondition, runWithFixtures(fnOrReason));
3201
3395
  }
3202
3396
  function only(name, fn) {
3203
- it.only(name, async () => {
3204
- const fixtures = await createTestFixtures();
3205
- let testFailed = false;
3206
- try {
3207
- await fn(fixtures);
3208
- } catch (error) {
3209
- testFailed = true;
3210
- throw error;
3211
- } finally {
3212
- await cleanupTestFixtures(fixtures, testFailed);
3213
- }
3214
- });
3397
+ const scope = currentSkipScope();
3398
+ if (scope.skipped) {
3399
+ it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
3400
+ return;
3401
+ }
3402
+ it.only(name, runWithFixtures(fn));
3215
3403
  }
3216
3404
  function todo(name) {
3217
3405
  it.todo(name);
3218
3406
  }
3407
+ function withSkipScope(register) {
3408
+ return (name, fn) => {
3409
+ register(name, () => {
3410
+ const parent = currentSkipScope();
3411
+ skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
3412
+ try {
3413
+ fn();
3414
+ } finally {
3415
+ skipScopes.pop();
3416
+ }
3417
+ });
3418
+ };
3419
+ }
3420
+ function eachWithSkipScope(each) {
3421
+ return ((...eachArgs) => {
3422
+ const register = each(...eachArgs);
3423
+ return withSkipScope(register);
3424
+ });
3425
+ }
3426
+ var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
3427
+ only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
3428
+ skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
3429
+ each: eachWithSkipScope(describe.each)
3430
+ });
3219
3431
  var test = testWithFixtures;
3220
3432
  test.use = use;
3221
- test.describe = describe;
3433
+ test.describe = describeWithSkipScope;
3222
3434
  test.beforeAll = beforeAll;
3223
3435
  test.beforeEach = beforeEach;
3224
3436
  test.afterEach = afterEach;