@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/index.js CHANGED
@@ -124,255 +124,345 @@ __export(index_exports, {
124
124
  });
125
125
  module.exports = __toCommonJS(index_exports);
126
126
 
127
- // libs/testing/src/platform/platform-client-info.ts
128
- var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
129
- function getPlatformClientInfo(platform) {
130
- switch (platform) {
131
- case "openai":
132
- return {
133
- name: "ChatGPT",
134
- version: "1.0"
135
- };
136
- case "ext-apps":
137
- return {
138
- name: "mcp-ext-apps",
139
- version: "1.0"
140
- };
141
- case "claude":
142
- return {
143
- name: "claude-desktop",
144
- version: "1.0"
145
- };
146
- case "cursor":
147
- return {
148
- name: "cursor",
149
- version: "1.0"
150
- };
151
- case "continue":
152
- return {
153
- name: "continue",
154
- version: "1.0"
155
- };
156
- case "cody":
157
- return {
158
- name: "cody",
159
- version: "1.0"
160
- };
161
- case "gemini":
162
- return {
163
- name: "gemini",
164
- version: "1.0"
165
- };
166
- case "generic-mcp":
167
- return {
168
- name: "generic-mcp-client",
169
- version: "1.0"
170
- };
171
- case "unknown":
172
- default:
173
- return {
174
- name: "mcp-test-client",
175
- version: "1.0"
176
- };
177
- }
178
- }
179
- function buildUserAgent(clientInfo) {
180
- return `${clientInfo.name}/${clientInfo.version}`;
181
- }
182
- function getPlatformUserAgent(platform) {
183
- return buildUserAgent(getPlatformClientInfo(platform));
184
- }
185
- var PLATFORM_DETECTION_PATTERNS = {
186
- openai: /chatgpt/i,
187
- "ext-apps": /mcp-ext-apps/i,
188
- // Note: Actual detection uses capabilities
189
- claude: /claude|claude-desktop/i,
190
- cursor: /cursor/i,
191
- continue: /continue/i,
192
- cody: /cody/i,
193
- gemini: /gemini/i,
194
- "generic-mcp": /generic-mcp/i,
195
- unknown: /.*/
196
- // Matches anything (fallback)
197
- };
198
- function getPlatformCapabilities(platform) {
199
- const baseCapabilities = {
200
- sampling: {},
201
- // Include elicitation.form by default for testing elicitation workflows
202
- // Note: MCP SDK expects form to be an object, not boolean
203
- elicitation: {
204
- form: {}
205
- }
206
- };
207
- if (platform === "ext-apps") {
127
+ // libs/testing/src/interceptor/mock-registry.ts
128
+ var DefaultMockRegistry = class {
129
+ mocks = [];
130
+ add(mock) {
131
+ const entry = {
132
+ definition: mock,
133
+ callCount: 0,
134
+ calls: [],
135
+ remainingUses: mock.times ?? Infinity
136
+ };
137
+ this.mocks.push(entry);
208
138
  return {
209
- ...baseCapabilities,
210
- experimental: {
211
- [MCP_APPS_EXTENSION_KEY]: {
212
- mimeTypes: ["text/html;profile=mcp-app"]
139
+ remove: () => {
140
+ const index = this.mocks.indexOf(entry);
141
+ if (index !== -1) {
142
+ this.mocks.splice(index, 1);
213
143
  }
214
- }
144
+ },
145
+ callCount: () => entry.callCount,
146
+ calls: () => [...entry.calls]
215
147
  };
216
148
  }
217
- return baseCapabilities;
218
- }
219
-
220
- // libs/testing/src/client/mcp-test-client.builder.ts
221
- var McpTestClientBuilder = class {
222
- config;
223
- constructor(config) {
224
- this.config = { ...config };
149
+ clear() {
150
+ this.mocks = [];
151
+ }
152
+ getAll() {
153
+ return this.mocks.map((e) => e.definition);
154
+ }
155
+ match(request) {
156
+ for (const entry of this.mocks) {
157
+ if (entry.remainingUses <= 0) continue;
158
+ const { definition } = entry;
159
+ if (definition.method !== request.method) continue;
160
+ if (definition.params !== void 0) {
161
+ const params = request.params ?? {};
162
+ if (typeof definition.params === "function") {
163
+ if (!definition.params(params)) continue;
164
+ } else {
165
+ if (!this.paramsMatch(definition.params, params)) continue;
166
+ }
167
+ }
168
+ entry.callCount++;
169
+ entry.calls.push(request);
170
+ entry.remainingUses--;
171
+ return definition;
172
+ }
173
+ return void 0;
225
174
  }
226
175
  /**
227
- * Set the authentication configuration
176
+ * Check if request params match the mock params definition
228
177
  */
229
- withAuth(auth) {
230
- this.config.auth = { ...this.config.auth, ...auth };
231
- return this;
178
+ paramsMatch(expected, actual) {
179
+ for (const [key, value] of Object.entries(expected)) {
180
+ if (!(key in actual)) return false;
181
+ const actualValue = actual[key];
182
+ if (Array.isArray(value)) {
183
+ if (!Array.isArray(actualValue)) return false;
184
+ if (value.length !== actualValue.length) return false;
185
+ for (let i = 0; i < value.length; i++) {
186
+ const expectedItem = value[i];
187
+ const actualItem = actualValue[i];
188
+ if (typeof expectedItem === "object" && expectedItem !== null) {
189
+ if (typeof actualItem !== "object" || actualItem === null) return false;
190
+ if (!this.paramsMatch(expectedItem, actualItem)) {
191
+ return false;
192
+ }
193
+ } else if (actualItem !== expectedItem) {
194
+ return false;
195
+ }
196
+ }
197
+ } else if (typeof value === "object" && value !== null) {
198
+ if (typeof actualValue !== "object" || actualValue === null) return false;
199
+ if (!this.paramsMatch(value, actualValue)) {
200
+ return false;
201
+ }
202
+ } else if (actualValue !== value) {
203
+ return false;
204
+ }
205
+ }
206
+ return true;
232
207
  }
208
+ };
209
+ var mockResponse = {
233
210
  /**
234
- * Set the bearer token for authentication
211
+ * Create a successful JSON-RPC response
235
212
  */
236
- withToken(token) {
237
- this.config.auth = { ...this.config.auth, token };
238
- return this;
239
- }
213
+ success(result, id = 1) {
214
+ return {
215
+ jsonrpc: "2.0",
216
+ id,
217
+ result
218
+ };
219
+ },
240
220
  /**
241
- * Add custom headers to all requests
221
+ * Create an error JSON-RPC response
242
222
  */
243
- withHeaders(headers) {
244
- this.config.auth = {
245
- ...this.config.auth,
246
- headers: { ...this.config.auth?.headers, ...headers }
223
+ error(code, message, data, id = 1) {
224
+ return {
225
+ jsonrpc: "2.0",
226
+ id,
227
+ error: { code, message, data }
247
228
  };
248
- return this;
249
- }
229
+ },
250
230
  /**
251
- * Set the transport type
231
+ * Create a tool result response
252
232
  */
253
- withTransport(transport) {
254
- this.config.transport = transport;
255
- return this;
256
- }
233
+ toolResult(content, id = 1) {
234
+ return {
235
+ jsonrpc: "2.0",
236
+ id,
237
+ result: { content }
238
+ };
239
+ },
257
240
  /**
258
- * Set the request timeout in milliseconds
241
+ * Create a tools/list response
259
242
  */
260
- withTimeout(timeoutMs) {
261
- this.config.timeout = timeoutMs;
262
- return this;
263
- }
243
+ toolsList(tools, id = 1) {
244
+ return {
245
+ jsonrpc: "2.0",
246
+ id,
247
+ result: { tools }
248
+ };
249
+ },
264
250
  /**
265
- * Enable debug logging
251
+ * Create a resources/list response
266
252
  */
267
- withDebug(enabled = true) {
268
- this.config.debug = enabled;
269
- return this;
270
- }
253
+ resourcesList(resources, id = 1) {
254
+ return {
255
+ jsonrpc: "2.0",
256
+ id,
257
+ result: { resources }
258
+ };
259
+ },
271
260
  /**
272
- * Enable public mode - skip authentication entirely.
273
- * When true, no Authorization header is sent and anonymous token is not requested.
274
- * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
261
+ * Create a resources/read response
275
262
  */
276
- withPublicMode(enabled = true) {
277
- this.config.publicMode = enabled;
278
- return this;
279
- }
263
+ resourceRead(contents, id = 1) {
264
+ return {
265
+ jsonrpc: "2.0",
266
+ id,
267
+ result: { contents }
268
+ };
269
+ },
280
270
  /**
281
- * Set the MCP protocol version to request
271
+ * Common MCP errors
282
272
  */
283
- withProtocolVersion(version) {
284
- this.config.protocolVersion = version;
285
- return this;
273
+ errors: {
274
+ methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
275
+ invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
276
+ internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
277
+ resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
278
+ toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
279
+ unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
280
+ forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
281
+ }
282
+ };
283
+
284
+ // libs/testing/src/interceptor/interceptor-chain.ts
285
+ var DefaultInterceptorChain = class {
286
+ request = [];
287
+ response = [];
288
+ mocks;
289
+ constructor() {
290
+ this.mocks = new DefaultMockRegistry();
286
291
  }
287
292
  /**
288
- * Set the client info sent during initialization
293
+ * Add a request interceptor
289
294
  */
290
- withClientInfo(info) {
291
- this.config.clientInfo = info;
292
- return this;
295
+ addRequestInterceptor(interceptor) {
296
+ this.request.push(interceptor);
297
+ return () => {
298
+ const index = this.request.indexOf(interceptor);
299
+ if (index !== -1) {
300
+ this.request.splice(index, 1);
301
+ }
302
+ };
293
303
  }
294
304
  /**
295
- * Set the platform type for testing platform-specific meta keys.
296
- * Automatically configures clientInfo and capabilities for platform detection.
297
- *
298
- * Platform-specific behavior:
299
- * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
300
- * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
301
- * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
302
- * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
303
- * - Other platforms follow similar patterns
304
- *
305
- * @example
306
- * ```typescript
307
- * const client = await McpTestClient.create({ baseUrl })
308
- * .withPlatform('openai')
309
- * .buildAndConnect();
310
- *
311
- * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
312
- * const extAppsClient = await McpTestClient.create({ baseUrl })
313
- * .withPlatform('ext-apps')
314
- * .buildAndConnect();
315
- * ```
305
+ * Add a response interceptor
316
306
  */
317
- withPlatform(platform) {
318
- this.config.platform = platform;
319
- this.config.clientInfo = getPlatformClientInfo(platform);
320
- this.config.capabilities = getPlatformCapabilities(platform);
321
- return this;
307
+ addResponseInterceptor(interceptor) {
308
+ this.response.push(interceptor);
309
+ return () => {
310
+ const index = this.response.indexOf(interceptor);
311
+ if (index !== -1) {
312
+ this.response.splice(index, 1);
313
+ }
314
+ };
322
315
  }
323
316
  /**
324
- * Set custom client capabilities for MCP initialization.
325
- * Use this for fine-grained control over capabilities sent during initialization.
326
- *
327
- * @example
328
- * ```typescript
329
- * const client = await McpTestClient.create({ baseUrl })
330
- * .withCapabilities({
331
- * sampling: {},
332
- * experimental: {
333
- * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
334
- * }
335
- * })
336
- * .buildAndConnect();
337
- * ```
317
+ * Process a request through the interceptor chain
318
+ * Returns either:
319
+ * - { type: 'continue', request } - continue with (possibly modified) request
320
+ * - { type: 'mock', response } - return mock response immediately
321
+ * - { type: 'error', error } - throw error
338
322
  */
339
- withCapabilities(capabilities) {
340
- this.config.capabilities = capabilities;
341
- return this;
323
+ async processRequest(request, meta) {
324
+ let currentRequest = request;
325
+ const mockDef = this.mocks.match(request);
326
+ if (mockDef) {
327
+ if (mockDef.delay && mockDef.delay > 0) {
328
+ await sleep(mockDef.delay);
329
+ }
330
+ let mockResponse2;
331
+ if (typeof mockDef.response === "function") {
332
+ mockResponse2 = await mockDef.response(request);
333
+ } else {
334
+ mockResponse2 = mockDef.response;
335
+ }
336
+ return {
337
+ type: "mock",
338
+ response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
339
+ };
340
+ }
341
+ for (const interceptor of this.request) {
342
+ const ctx = {
343
+ request: currentRequest,
344
+ meta
345
+ };
346
+ const result = await interceptor(ctx);
347
+ switch (result.action) {
348
+ case "passthrough":
349
+ break;
350
+ case "modify":
351
+ currentRequest = result.request;
352
+ break;
353
+ case "mock":
354
+ return {
355
+ type: "mock",
356
+ response: { ...result.response, id: request.id ?? result.response.id }
357
+ };
358
+ case "error":
359
+ return { type: "error", error: result.error };
360
+ }
361
+ }
362
+ return { type: "continue", request: currentRequest };
342
363
  }
343
364
  /**
344
- * Set query parameters to append to the connection URL.
345
- * Useful for testing mode switches like `?mode=skills_only`.
346
- *
347
- * @example
348
- * ```typescript
349
- * const client = await McpTestClient.create({ baseUrl })
350
- * .withQueryParams({ mode: 'skills_only' })
351
- * .buildAndConnect();
352
- * ```
365
+ * Process a response through the interceptor chain
353
366
  */
354
- withQueryParams(params) {
355
- this.config.queryParams = { ...this.config.queryParams, ...params };
356
- return this;
367
+ async processResponse(request, response, durationMs) {
368
+ let currentResponse = response;
369
+ for (const interceptor of this.response) {
370
+ const ctx = {
371
+ request,
372
+ response: currentResponse,
373
+ durationMs
374
+ };
375
+ const result = await interceptor(ctx);
376
+ switch (result.action) {
377
+ case "passthrough":
378
+ break;
379
+ case "modify":
380
+ currentResponse = result.response;
381
+ break;
382
+ }
383
+ }
384
+ return currentResponse;
357
385
  }
358
386
  /**
359
- * Build the McpTestClient instance (does not connect)
387
+ * Clear all interceptors and mocks
360
388
  */
361
- build() {
362
- return new McpTestClient(this.config);
389
+ clear() {
390
+ this.request = [];
391
+ this.response = [];
392
+ this.mocks.clear();
363
393
  }
394
+ };
395
+ function sleep(ms) {
396
+ return new Promise((resolve) => setTimeout(resolve, ms));
397
+ }
398
+ var interceptors = {
364
399
  /**
365
- * Build the McpTestClient and connect to the server
400
+ * Create an interceptor that logs all requests
366
401
  */
367
- async buildAndConnect() {
368
- const client = this.build();
369
- await client.connect();
370
- return client;
402
+ logger(logFn = console.log) {
403
+ return (ctx) => {
404
+ logFn(`[MCP Request] ${ctx.request.method}`, ctx.request.params);
405
+ return { action: "passthrough" };
406
+ };
407
+ },
408
+ /**
409
+ * Create an interceptor that adds latency to all requests
410
+ */
411
+ delay(ms) {
412
+ return async () => {
413
+ await sleep(ms);
414
+ return { action: "passthrough" };
415
+ };
416
+ },
417
+ /**
418
+ * Create an interceptor that fails requests matching a condition
419
+ */
420
+ failWhen(condition, error) {
421
+ return (ctx) => {
422
+ if (condition(ctx)) {
423
+ const err = typeof error === "string" ? new Error(error) : error;
424
+ return { action: "error", error: err };
425
+ }
426
+ return { action: "passthrough" };
427
+ };
428
+ },
429
+ /**
430
+ * Create an interceptor that modifies specific methods
431
+ */
432
+ modifyMethod(method, modifier) {
433
+ return (ctx) => {
434
+ if (ctx.request.method === method) {
435
+ return { action: "modify", request: modifier(ctx.request) };
436
+ }
437
+ return { action: "passthrough" };
438
+ };
439
+ },
440
+ /**
441
+ * Create a response interceptor that logs responses
442
+ */
443
+ responseLogger(logFn = console.log) {
444
+ return (ctx) => {
445
+ const status = ctx.response.error ? "ERROR" : "OK";
446
+ logFn(`[MCP Response] ${ctx.request.method} ${status} (${ctx.durationMs}ms)`, ctx.response);
447
+ return { action: "passthrough" };
448
+ };
449
+ },
450
+ /**
451
+ * Create a response interceptor that modifies specific responses
452
+ */
453
+ modifyResponse(method, modifier) {
454
+ return (ctx) => {
455
+ if (ctx.request.method === method) {
456
+ return { action: "modify", response: modifier(ctx.response) };
457
+ }
458
+ return { action: "passthrough" };
459
+ };
371
460
  }
372
461
  };
373
462
 
374
463
  // libs/testing/src/transport/streamable-http.transport.ts
375
464
  var DEFAULT_TIMEOUT = 3e4;
465
+ var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
376
466
  var StreamableHttpTransport = class {
377
467
  config;
378
468
  state = "disconnected";
@@ -384,10 +474,15 @@ var StreamableHttpTransport = class {
384
474
  interceptors;
385
475
  publicMode;
386
476
  elicitationHandler;
477
+ notificationHandler;
478
+ notificationStream;
479
+ notificationProtocolVersion = "";
480
+ notificationStreamReopenTimer;
387
481
  constructor(config) {
388
482
  this.config = {
389
483
  baseUrl: config.baseUrl.replace(/\/$/, ""),
390
484
  // Remove trailing slash
485
+ entryPath: config.entryPath,
391
486
  timeout: config.timeout ?? DEFAULT_TIMEOUT,
392
487
  auth: config.auth ?? {},
393
488
  publicMode: config.publicMode ?? false,
@@ -399,6 +494,7 @@ var StreamableHttpTransport = class {
399
494
  this.interceptors = config.interceptors;
400
495
  this.publicMode = config.publicMode ?? false;
401
496
  this.elicitationHandler = config.elicitationHandler;
497
+ this.notificationHandler = config.notificationHandler;
402
498
  }
403
499
  async connect() {
404
500
  this.state = "connecting";
@@ -480,7 +576,7 @@ var StreamableHttpTransport = class {
480
576
  }
481
577
  const headers = this.buildHeaders();
482
578
  this.lastRequestHeaders = headers;
483
- const url = `${this.config.baseUrl}/`;
579
+ const url = this.mcpUrl();
484
580
  this.log(`POST ${url}`, message);
485
581
  const controller = new AbortController();
486
582
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -550,7 +646,7 @@ var StreamableHttpTransport = class {
550
646
  this.ensureConnected();
551
647
  const headers = this.buildHeaders();
552
648
  this.lastRequestHeaders = headers;
553
- const url = `${this.config.baseUrl}/`;
649
+ const url = this.mcpUrl();
554
650
  this.log(`POST ${url} (notification)`, message);
555
651
  const controller = new AbortController();
556
652
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -581,7 +677,7 @@ var StreamableHttpTransport = class {
581
677
  this.ensureConnected();
582
678
  const headers = this.buildHeaders();
583
679
  this.lastRequestHeaders = headers;
584
- const url = `${this.config.baseUrl}/`;
680
+ const url = this.mcpUrl();
585
681
  this.log(`POST ${url} (raw)`, data);
586
682
  const controller = new AbortController();
587
683
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -618,7 +714,12 @@ var StreamableHttpTransport = class {
618
714
  };
619
715
  }
620
716
  }
717
+ async openNotificationStream(protocolVersion) {
718
+ this.notificationProtocolVersion = protocolVersion;
719
+ await this.connectNotificationStream();
720
+ }
621
721
  async close() {
722
+ this.closeNotificationStream();
622
723
  this.state = "disconnected";
623
724
  this.sessionId = void 0;
624
725
  this.log("StreamableHTTP transport closed");
@@ -657,6 +758,7 @@ var StreamableHttpTransport = class {
657
758
  return { ...this.lastRequestHeaders };
658
759
  }
659
760
  async simulateDisconnect() {
761
+ this.closeNotificationStream();
660
762
  this.state = "disconnected";
661
763
  this.sessionId = void 0;
662
764
  }
@@ -675,23 +777,89 @@ var StreamableHttpTransport = class {
675
777
  // ═══════════════════════════════════════════════════════════════════
676
778
  // PRIVATE HELPERS
677
779
  // ═══════════════════════════════════════════════════════════════════
678
- /**
679
- * Handle SSE response with elicitation support.
680
- *
681
- * Streams the SSE response, detects elicitation/create requests, and handles them
682
- * by calling the registered handler and sending the response back to the server.
683
- */
684
- async handleSSEResponseWithElicitation(response, originalRequest) {
685
- this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
686
- const reader = response.body?.getReader();
687
- if (!reader) {
688
- this.log("handleSSEResponseWithElicitation: no response body");
689
- return {
690
- jsonrpc: "2.0",
691
- id: originalRequest.id ?? null,
692
- error: { code: -32e3, message: "No response body" }
693
- };
694
- }
780
+ closeNotificationStream() {
781
+ clearTimeout(this.notificationStreamReopenTimer);
782
+ this.notificationStream?.abort();
783
+ this.notificationStream = void 0;
784
+ }
785
+ /** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
786
+ async connectNotificationStream() {
787
+ if (!this.sessionId || this.notificationStream) return;
788
+ const controller = new AbortController();
789
+ this.notificationStream = controller;
790
+ const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
791
+ const established = fetch(this.mcpUrl(), {
792
+ method: "GET",
793
+ headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
794
+ signal: controller.signal
795
+ }).then(
796
+ async (response) => {
797
+ const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
798
+ if (!response.ok || !response.body || !isEventStream) {
799
+ await response.body?.cancel();
800
+ this.log(`Server offers no notification stream (HTTP ${response.status})`);
801
+ this.releaseNotificationStream(controller);
802
+ return;
803
+ }
804
+ void this.readNotificationStream(controller, response.body.getReader());
805
+ },
806
+ (error) => {
807
+ this.log("Failed to open the notification stream:", error);
808
+ this.releaseNotificationStream(controller);
809
+ }
810
+ );
811
+ let establishTimer;
812
+ const establishDeadline = new Promise((resolve) => {
813
+ establishTimer = setTimeout(resolve, this.config.timeout);
814
+ });
815
+ await Promise.race([established, establishDeadline]);
816
+ clearTimeout(establishTimer);
817
+ }
818
+ releaseNotificationStream(controller) {
819
+ if (this.notificationStream === controller) this.notificationStream = void 0;
820
+ }
821
+ async readNotificationStream(controller, reader) {
822
+ const decoder = new TextDecoder();
823
+ let buffer = "";
824
+ try {
825
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
826
+ const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
827
+ buffer = remainder;
828
+ for (const eventText of completeEvents) {
829
+ for (const event of this.parseSSEEvents(eventText, void 0).events) {
830
+ await this.handleSSEEvent(event);
831
+ }
832
+ }
833
+ }
834
+ } catch (error) {
835
+ this.log("Notification stream ended:", error);
836
+ } finally {
837
+ reader.releaseLock();
838
+ }
839
+ if (controller.signal.aborted || this.notificationStream !== controller) return;
840
+ this.releaseNotificationStream(controller);
841
+ this.notificationStreamReopenTimer = setTimeout(
842
+ () => void this.connectNotificationStream(),
843
+ NOTIFICATION_STREAM_REOPEN_DELAY_MS
844
+ );
845
+ }
846
+ /**
847
+ * Handle SSE response with elicitation support.
848
+ *
849
+ * Streams the SSE response, detects elicitation/create requests, and handles them
850
+ * by calling the registered handler and sending the response back to the server.
851
+ */
852
+ async handleSSEResponseWithElicitation(response, originalRequest) {
853
+ this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
854
+ const reader = response.body?.getReader();
855
+ if (!reader) {
856
+ this.log("handleSSEResponseWithElicitation: no response body");
857
+ return {
858
+ jsonrpc: "2.0",
859
+ id: originalRequest.id ?? null,
860
+ error: { code: -32e3, message: "No response body" }
861
+ };
862
+ }
695
863
  const decoder = new TextDecoder();
696
864
  let buffer = "";
697
865
  let finalResponse = null;
@@ -812,6 +980,13 @@ var StreamableHttpTransport = class {
812
980
  response: { jsonrpc: "2.0", id: null, result: void 0 }
813
981
  };
814
982
  }
983
+ if ("method" in parsed && parsed.id === void 0) {
984
+ this.notificationHandler?.(parsed);
985
+ return {
986
+ isFinal: false,
987
+ response: { jsonrpc: "2.0", id: null, result: void 0 }
988
+ };
989
+ }
815
990
  if ("result" in parsed || "error" in parsed) {
816
991
  return { isFinal: true, response: parsed };
817
992
  }
@@ -864,7 +1039,7 @@ var StreamableHttpTransport = class {
864
1039
  */
865
1040
  async sendElicitationResponse(requestId, response) {
866
1041
  const headers = this.buildHeaders();
867
- const url = `${this.config.baseUrl}/`;
1042
+ const url = this.mcpUrl();
868
1043
  const rpcResponse = {
869
1044
  jsonrpc: "2.0",
870
1045
  id: requestId,
@@ -884,6 +1059,18 @@ var StreamableHttpTransport = class {
884
1059
  this.log("Failed to send elicitation response:", error);
885
1060
  }
886
1061
  }
1062
+ /**
1063
+ * The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
1064
+ * trailing slash is kept because both hosts normalize it and the original
1065
+ * behaviour (`${baseUrl}/`) relied on it (issue #543).
1066
+ */
1067
+ mcpUrl() {
1068
+ const url = new URL(this.config.baseUrl);
1069
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
1070
+ const basePath = url.pathname.replace(/\/+$/, "");
1071
+ url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
1072
+ return url.toString();
1073
+ }
887
1074
  buildHeaders() {
888
1075
  const headers = {
889
1076
  "Content-Type": "application/json",
@@ -970,350 +1157,285 @@ var StreamableHttpTransport = class {
970
1157
  };
971
1158
  }
972
1159
  };
1160
+ function splitSSEEvents(buffer) {
1161
+ const endsWithCarriageReturn = buffer.endsWith("\r");
1162
+ const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
1163
+ const completeEvents = normalized.split("\n\n");
1164
+ const remainder = completeEvents.pop() ?? "";
1165
+ return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
1166
+ }
973
1167
 
974
- // libs/testing/src/interceptor/mock-registry.ts
975
- var DefaultMockRegistry = class {
976
- mocks = [];
977
- add(mock) {
978
- const entry = {
979
- definition: mock,
980
- callCount: 0,
981
- calls: [],
982
- remainingUses: mock.times ?? Infinity
983
- };
984
- this.mocks.push(entry);
985
- return {
986
- remove: () => {
987
- const index = this.mocks.indexOf(entry);
988
- if (index !== -1) {
989
- this.mocks.splice(index, 1);
990
- }
991
- },
992
- callCount: () => entry.callCount,
993
- calls: () => [...entry.calls]
994
- };
995
- }
996
- clear() {
997
- this.mocks = [];
998
- }
999
- getAll() {
1000
- return this.mocks.map((e) => e.definition);
1168
+ // libs/testing/src/platform/platform-client-info.ts
1169
+ var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
1170
+ function getPlatformClientInfo(platform) {
1171
+ switch (platform) {
1172
+ case "openai":
1173
+ return {
1174
+ name: "ChatGPT",
1175
+ version: "1.0"
1176
+ };
1177
+ case "ext-apps":
1178
+ return {
1179
+ name: "mcp-ext-apps",
1180
+ version: "1.0"
1181
+ };
1182
+ case "claude":
1183
+ return {
1184
+ name: "claude-desktop",
1185
+ version: "1.0"
1186
+ };
1187
+ case "cursor":
1188
+ return {
1189
+ name: "cursor",
1190
+ version: "1.0"
1191
+ };
1192
+ case "continue":
1193
+ return {
1194
+ name: "continue",
1195
+ version: "1.0"
1196
+ };
1197
+ case "cody":
1198
+ return {
1199
+ name: "cody",
1200
+ version: "1.0"
1201
+ };
1202
+ case "gemini":
1203
+ return {
1204
+ name: "gemini",
1205
+ version: "1.0"
1206
+ };
1207
+ case "generic-mcp":
1208
+ return {
1209
+ name: "generic-mcp-client",
1210
+ version: "1.0"
1211
+ };
1212
+ case "unknown":
1213
+ default:
1214
+ return {
1215
+ name: "mcp-test-client",
1216
+ version: "1.0"
1217
+ };
1001
1218
  }
1002
- match(request) {
1003
- for (const entry of this.mocks) {
1004
- if (entry.remainingUses <= 0) continue;
1005
- const { definition } = entry;
1006
- if (definition.method !== request.method) continue;
1007
- if (definition.params !== void 0) {
1008
- const params = request.params ?? {};
1009
- if (typeof definition.params === "function") {
1010
- if (!definition.params(params)) continue;
1011
- } else {
1012
- if (!this.paramsMatch(definition.params, params)) continue;
1013
- }
1014
- }
1015
- entry.callCount++;
1016
- entry.calls.push(request);
1017
- entry.remainingUses--;
1018
- return definition;
1219
+ }
1220
+ function buildUserAgent(clientInfo) {
1221
+ return `${clientInfo.name}/${clientInfo.version}`;
1222
+ }
1223
+ function getPlatformUserAgent(platform) {
1224
+ return buildUserAgent(getPlatformClientInfo(platform));
1225
+ }
1226
+ var PLATFORM_DETECTION_PATTERNS = {
1227
+ openai: /chatgpt/i,
1228
+ "ext-apps": /mcp-ext-apps/i,
1229
+ // Note: Actual detection uses capabilities
1230
+ claude: /claude|claude-desktop/i,
1231
+ cursor: /cursor/i,
1232
+ continue: /continue/i,
1233
+ cody: /cody/i,
1234
+ gemini: /gemini/i,
1235
+ "generic-mcp": /generic-mcp/i,
1236
+ unknown: /.*/
1237
+ // Matches anything (fallback)
1238
+ };
1239
+ function getPlatformCapabilities(platform) {
1240
+ const baseCapabilities = {
1241
+ sampling: {},
1242
+ // Include elicitation.form by default for testing elicitation workflows
1243
+ // Note: MCP SDK expects form to be an object, not boolean
1244
+ elicitation: {
1245
+ form: {}
1019
1246
  }
1020
- return void 0;
1021
- }
1022
- /**
1023
- * Check if request params match the mock params definition
1024
- */
1025
- paramsMatch(expected, actual) {
1026
- for (const [key, value] of Object.entries(expected)) {
1027
- if (!(key in actual)) return false;
1028
- const actualValue = actual[key];
1029
- if (Array.isArray(value)) {
1030
- if (!Array.isArray(actualValue)) return false;
1031
- if (value.length !== actualValue.length) return false;
1032
- for (let i = 0; i < value.length; i++) {
1033
- const expectedItem = value[i];
1034
- const actualItem = actualValue[i];
1035
- if (typeof expectedItem === "object" && expectedItem !== null) {
1036
- if (typeof actualItem !== "object" || actualItem === null) return false;
1037
- if (!this.paramsMatch(expectedItem, actualItem)) {
1038
- return false;
1039
- }
1040
- } else if (actualItem !== expectedItem) {
1041
- return false;
1042
- }
1043
- }
1044
- } else if (typeof value === "object" && value !== null) {
1045
- if (typeof actualValue !== "object" || actualValue === null) return false;
1046
- if (!this.paramsMatch(value, actualValue)) {
1047
- return false;
1247
+ };
1248
+ if (platform === "ext-apps") {
1249
+ return {
1250
+ ...baseCapabilities,
1251
+ experimental: {
1252
+ [MCP_APPS_EXTENSION_KEY]: {
1253
+ mimeTypes: ["text/html;profile=mcp-app"]
1048
1254
  }
1049
- } else if (actualValue !== value) {
1050
- return false;
1051
1255
  }
1052
- }
1053
- return true;
1054
- }
1055
- };
1056
- var mockResponse = {
1057
- /**
1058
- * Create a successful JSON-RPC response
1059
- */
1060
- success(result, id = 1) {
1061
- return {
1062
- jsonrpc: "2.0",
1063
- id,
1064
- result
1065
- };
1066
- },
1067
- /**
1068
- * Create an error JSON-RPC response
1069
- */
1070
- error(code, message, data, id = 1) {
1071
- return {
1072
- jsonrpc: "2.0",
1073
- id,
1074
- error: { code, message, data }
1075
- };
1076
- },
1077
- /**
1078
- * Create a tool result response
1079
- */
1080
- toolResult(content, id = 1) {
1081
- return {
1082
- jsonrpc: "2.0",
1083
- id,
1084
- result: { content }
1085
- };
1086
- },
1087
- /**
1088
- * Create a tools/list response
1089
- */
1090
- toolsList(tools, id = 1) {
1091
- return {
1092
- jsonrpc: "2.0",
1093
- id,
1094
- result: { tools }
1095
- };
1096
- },
1097
- /**
1098
- * Create a resources/list response
1099
- */
1100
- resourcesList(resources, id = 1) {
1101
- return {
1102
- jsonrpc: "2.0",
1103
- id,
1104
- result: { resources }
1105
1256
  };
1106
- },
1107
- /**
1108
- * Create a resources/read response
1109
- */
1110
- resourceRead(contents, id = 1) {
1111
- return {
1112
- jsonrpc: "2.0",
1113
- id,
1114
- result: { contents }
1115
- };
1116
- },
1117
- /**
1118
- * Common MCP errors
1119
- */
1120
- errors: {
1121
- methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
1122
- invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
1123
- internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
1124
- resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
1125
- toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
1126
- unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
1127
- forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
1128
1257
  }
1129
- };
1258
+ return baseCapabilities;
1259
+ }
1130
1260
 
1131
- // libs/testing/src/interceptor/interceptor-chain.ts
1132
- var DefaultInterceptorChain = class {
1133
- request = [];
1134
- response = [];
1135
- mocks;
1136
- constructor() {
1137
- this.mocks = new DefaultMockRegistry();
1138
- }
1139
- /**
1140
- * Add a request interceptor
1141
- */
1142
- addRequestInterceptor(interceptor) {
1143
- this.request.push(interceptor);
1144
- return () => {
1145
- const index = this.request.indexOf(interceptor);
1146
- if (index !== -1) {
1147
- this.request.splice(index, 1);
1148
- }
1149
- };
1261
+ // libs/testing/src/client/mcp-test-client.builder.ts
1262
+ var McpTestClientBuilder = class {
1263
+ config;
1264
+ constructor(config) {
1265
+ this.config = { ...config };
1150
1266
  }
1151
1267
  /**
1152
- * Add a response interceptor
1268
+ * Set the authentication configuration
1153
1269
  */
1154
- addResponseInterceptor(interceptor) {
1155
- this.response.push(interceptor);
1156
- return () => {
1157
- const index = this.response.indexOf(interceptor);
1158
- if (index !== -1) {
1159
- this.response.splice(index, 1);
1160
- }
1161
- };
1270
+ withAuth(auth) {
1271
+ this.config.auth = { ...this.config.auth, ...auth };
1272
+ return this;
1162
1273
  }
1163
1274
  /**
1164
- * Process a request through the interceptor chain
1165
- * Returns either:
1166
- * - { type: 'continue', request } - continue with (possibly modified) request
1167
- * - { type: 'mock', response } - return mock response immediately
1168
- * - { type: 'error', error } - throw error
1275
+ * Set the bearer token for authentication
1169
1276
  */
1170
- async processRequest(request, meta) {
1171
- let currentRequest = request;
1172
- const mockDef = this.mocks.match(request);
1173
- if (mockDef) {
1174
- if (mockDef.delay && mockDef.delay > 0) {
1175
- await sleep(mockDef.delay);
1176
- }
1177
- let mockResponse2;
1178
- if (typeof mockDef.response === "function") {
1179
- mockResponse2 = await mockDef.response(request);
1180
- } else {
1181
- mockResponse2 = mockDef.response;
1182
- }
1183
- return {
1184
- type: "mock",
1185
- response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
1186
- };
1187
- }
1188
- for (const interceptor of this.request) {
1189
- const ctx = {
1190
- request: currentRequest,
1191
- meta
1192
- };
1193
- const result = await interceptor(ctx);
1194
- switch (result.action) {
1195
- case "passthrough":
1196
- break;
1197
- case "modify":
1198
- currentRequest = result.request;
1199
- break;
1200
- case "mock":
1201
- return {
1202
- type: "mock",
1203
- response: { ...result.response, id: request.id ?? result.response.id }
1204
- };
1205
- case "error":
1206
- return { type: "error", error: result.error };
1207
- }
1208
- }
1209
- return { type: "continue", request: currentRequest };
1277
+ withToken(token) {
1278
+ this.config.auth = { ...this.config.auth, token };
1279
+ return this;
1210
1280
  }
1211
1281
  /**
1212
- * Process a response through the interceptor chain
1282
+ * Add custom headers to all requests
1213
1283
  */
1214
- async processResponse(request, response, durationMs) {
1215
- let currentResponse = response;
1216
- for (const interceptor of this.response) {
1217
- const ctx = {
1218
- request,
1219
- response: currentResponse,
1220
- durationMs
1221
- };
1222
- const result = await interceptor(ctx);
1223
- switch (result.action) {
1224
- case "passthrough":
1225
- break;
1226
- case "modify":
1227
- currentResponse = result.response;
1228
- break;
1229
- }
1230
- }
1231
- return currentResponse;
1284
+ withHeaders(headers) {
1285
+ this.config.auth = {
1286
+ ...this.config.auth,
1287
+ headers: { ...this.config.auth?.headers, ...headers }
1288
+ };
1289
+ return this;
1232
1290
  }
1233
1291
  /**
1234
- * Clear all interceptors and mocks
1292
+ * Set the transport type
1235
1293
  */
1236
- clear() {
1237
- this.request = [];
1238
- this.response = [];
1239
- this.mocks.clear();
1294
+ withTransport(transport) {
1295
+ this.config.transport = transport;
1296
+ return this;
1240
1297
  }
1241
- };
1242
- function sleep(ms) {
1243
- return new Promise((resolve) => setTimeout(resolve, ms));
1244
- }
1245
- var interceptors = {
1246
1298
  /**
1247
- * Create an interceptor that logs all requests
1299
+ * Set the request timeout in milliseconds
1248
1300
  */
1249
- logger(logFn = console.log) {
1250
- return (ctx) => {
1251
- logFn(`[MCP Request] ${ctx.request.method}`, ctx.request.params);
1252
- return { action: "passthrough" };
1253
- };
1254
- },
1301
+ withTimeout(timeoutMs) {
1302
+ this.config.timeout = timeoutMs;
1303
+ return this;
1304
+ }
1255
1305
  /**
1256
- * Create an interceptor that adds latency to all requests
1306
+ * Enable debug logging
1257
1307
  */
1258
- delay(ms) {
1259
- return async () => {
1260
- await sleep(ms);
1261
- return { action: "passthrough" };
1262
- };
1263
- },
1308
+ withDebug(enabled = true) {
1309
+ this.config.debug = enabled;
1310
+ return this;
1311
+ }
1264
1312
  /**
1265
- * Create an interceptor that fails requests matching a condition
1313
+ * Enable public mode - skip authentication entirely.
1314
+ * When true, no Authorization header is sent and anonymous token is not requested.
1315
+ * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
1266
1316
  */
1267
- failWhen(condition, error) {
1268
- return (ctx) => {
1269
- if (condition(ctx)) {
1270
- const err = typeof error === "string" ? new Error(error) : error;
1271
- return { action: "error", error: err };
1272
- }
1273
- return { action: "passthrough" };
1274
- };
1275
- },
1317
+ withPublicMode(enabled = true) {
1318
+ this.config.publicMode = enabled;
1319
+ return this;
1320
+ }
1276
1321
  /**
1277
- * Create an interceptor that modifies specific methods
1322
+ * Set the MCP protocol version to request (default '2025-06-18').
1323
+ * '2026-07-28' has no initialize handshake, so `build()` throws for it.
1278
1324
  */
1279
- modifyMethod(method, modifier) {
1280
- return (ctx) => {
1281
- if (ctx.request.method === method) {
1282
- return { action: "modify", request: modifier(ctx.request) };
1283
- }
1284
- return { action: "passthrough" };
1285
- };
1286
- },
1325
+ withProtocolVersion(version) {
1326
+ this.config.protocolVersion = version;
1327
+ return this;
1328
+ }
1287
1329
  /**
1288
- * Create a response interceptor that logs responses
1330
+ * Set the client info sent during initialization
1289
1331
  */
1290
- responseLogger(logFn = console.log) {
1291
- return (ctx) => {
1292
- const status = ctx.response.error ? "ERROR" : "OK";
1293
- logFn(`[MCP Response] ${ctx.request.method} ${status} (${ctx.durationMs}ms)`, ctx.response);
1294
- return { action: "passthrough" };
1295
- };
1296
- },
1332
+ withClientInfo(info) {
1333
+ this.config.clientInfo = info;
1334
+ return this;
1335
+ }
1297
1336
  /**
1298
- * Create a response interceptor that modifies specific responses
1337
+ * Set the platform type for testing platform-specific meta keys.
1338
+ * Automatically configures clientInfo and capabilities for platform detection.
1339
+ *
1340
+ * Platform-specific behavior:
1341
+ * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
1342
+ * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
1343
+ * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
1344
+ * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
1345
+ * - Other platforms follow similar patterns
1346
+ *
1347
+ * @example
1348
+ * ```typescript
1349
+ * const client = await McpTestClient.create({ baseUrl })
1350
+ * .withPlatform('openai')
1351
+ * .buildAndConnect();
1352
+ *
1353
+ * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
1354
+ * const extAppsClient = await McpTestClient.create({ baseUrl })
1355
+ * .withPlatform('ext-apps')
1356
+ * .buildAndConnect();
1357
+ * ```
1299
1358
  */
1300
- modifyResponse(method, modifier) {
1301
- return (ctx) => {
1302
- if (ctx.request.method === method) {
1303
- return { action: "modify", response: modifier(ctx.response) };
1304
- }
1305
- return { action: "passthrough" };
1306
- };
1359
+ withPlatform(platform) {
1360
+ this.config.platform = platform;
1361
+ this.config.clientInfo = getPlatformClientInfo(platform);
1362
+ this.config.capabilities = getPlatformCapabilities(platform);
1363
+ return this;
1364
+ }
1365
+ /**
1366
+ * Set custom client capabilities for MCP initialization.
1367
+ * Use this for fine-grained control over capabilities sent during initialization.
1368
+ *
1369
+ * @example
1370
+ * ```typescript
1371
+ * const client = await McpTestClient.create({ baseUrl })
1372
+ * .withCapabilities({
1373
+ * sampling: {},
1374
+ * experimental: {
1375
+ * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
1376
+ * }
1377
+ * })
1378
+ * .buildAndConnect();
1379
+ * ```
1380
+ */
1381
+ withCapabilities(capabilities) {
1382
+ this.config.capabilities = capabilities;
1383
+ return this;
1384
+ }
1385
+ /**
1386
+ * Set query parameters to append to the connection URL.
1387
+ * Useful for testing mode switches like `?mode=skills_only`.
1388
+ *
1389
+ * @example
1390
+ * ```typescript
1391
+ * const client = await McpTestClient.create({ baseUrl })
1392
+ * .withQueryParams({ mode: 'skills_only' })
1393
+ * .buildAndConnect();
1394
+ * ```
1395
+ */
1396
+ withQueryParams(params) {
1397
+ this.config.queryParams = { ...this.config.queryParams, ...params };
1398
+ return this;
1399
+ }
1400
+ /**
1401
+ * Build the McpTestClient instance (does not connect)
1402
+ */
1403
+ build() {
1404
+ return new McpTestClient(this.config);
1405
+ }
1406
+ /**
1407
+ * Build the McpTestClient and connect to the server
1408
+ */
1409
+ async buildAndConnect() {
1410
+ const client = this.build();
1411
+ await client.connect();
1412
+ return client;
1307
1413
  }
1308
1414
  };
1309
1415
 
1310
1416
  // libs/testing/src/client/mcp-test-client.ts
1311
1417
  var DEFAULT_TIMEOUT2 = 3e4;
1312
1418
  var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
1419
+ var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
1313
1420
  var DEFAULT_CLIENT_INFO = {
1314
1421
  name: "@frontmcp/testing",
1315
1422
  version: "0.4.0"
1316
1423
  };
1424
+ function extractEntryPaths(data) {
1425
+ let payload = data;
1426
+ if (typeof payload === "string") {
1427
+ try {
1428
+ payload = JSON.parse(payload);
1429
+ } catch {
1430
+ return void 0;
1431
+ }
1432
+ }
1433
+ if (!payload || typeof payload !== "object") return void 0;
1434
+ const paths = payload.entryPaths;
1435
+ if (!Array.isArray(paths)) return void 0;
1436
+ const strings = paths.filter((p) => typeof p === "string");
1437
+ return strings.length > 0 ? strings : void 0;
1438
+ }
1317
1439
  var McpTestClient = class {
1318
1440
  // Platform, capabilities, and queryParams are optional - only set when needed
1319
1441
  config;
@@ -1322,6 +1444,8 @@ var McpTestClient = class {
1322
1444
  requestIdCounter = 0;
1323
1445
  _lastRequestId = 0;
1324
1446
  _sessionId;
1447
+ /** #543 — discovery is attempted at most once per connect. */
1448
+ triedDiscoveredEntryPath = false;
1325
1449
  _sessionInfo = null;
1326
1450
  _authState = { isAnonymous: true, scopes: [] };
1327
1451
  // Logging and tracing
@@ -1329,6 +1453,8 @@ var McpTestClient = class {
1329
1453
  _traces = [];
1330
1454
  _notifications = [];
1331
1455
  _progressUpdates = [];
1456
+ collectingProgress = false;
1457
+ progressTokenCounter = 0;
1332
1458
  // Interceptor chain
1333
1459
  _interceptors;
1334
1460
  // Elicitation handler for server→client elicit requests
@@ -1337,8 +1463,14 @@ var McpTestClient = class {
1337
1463
  // CONSTRUCTOR & FACTORY
1338
1464
  // ═══════════════════════════════════════════════════════════════════
1339
1465
  constructor(config) {
1466
+ if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
1467
+ throw new Error(
1468
+ `McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
1469
+ );
1470
+ }
1340
1471
  this.config = {
1341
1472
  baseUrl: config.baseUrl,
1473
+ entryPath: config.entryPath,
1342
1474
  transport: config.transport ?? "streamable-http",
1343
1475
  auth: config.auth ?? {},
1344
1476
  publicMode: config.publicMode ?? false,
@@ -1374,11 +1506,13 @@ var McpTestClient = class {
1374
1506
  */
1375
1507
  async connect() {
1376
1508
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
1509
+ this.triedDiscoveredEntryPath = false;
1377
1510
  this.transport = this.createTransport();
1378
1511
  await this.transport.connect();
1379
1512
  const maxRetries = 3;
1380
1513
  const retryDelayMs = 500;
1381
1514
  let lastError;
1515
+ let reportedEntryPaths;
1382
1516
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1383
1517
  const initResponse = await this.initialize();
1384
1518
  if (initResponse.success && initResponse.data) {
@@ -1386,13 +1520,27 @@ var McpTestClient = class {
1386
1520
  break;
1387
1521
  }
1388
1522
  lastError = initResponse.error?.message ?? "Unknown error";
1523
+ reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
1524
+ if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
1525
+ this.triedDiscoveredEntryPath = true;
1526
+ const discovered = reportedEntryPaths[0];
1527
+ this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
1528
+ this.config.entryPath = discovered;
1529
+ await this.transport.close();
1530
+ this.transport = this.createTransport();
1531
+ await this.transport.connect();
1532
+ continue;
1533
+ }
1389
1534
  if (attempt < maxRetries) {
1390
1535
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
1391
1536
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
1392
1537
  }
1393
1538
  }
1394
1539
  if (!this.initResult) {
1395
- throw new Error(`Failed to initialize MCP connection after ${maxRetries} attempts: ${lastError}`);
1540
+ 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.` : "";
1541
+ throw new Error(
1542
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
1543
+ );
1396
1544
  }
1397
1545
  this._sessionId = this.transport.getSessionId();
1398
1546
  this._sessionInfo = {
@@ -1405,6 +1553,7 @@ var McpTestClient = class {
1405
1553
  jsonrpc: "2.0",
1406
1554
  method: "notifications/initialized"
1407
1555
  });
1556
+ await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
1408
1557
  this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
1409
1558
  return this.initResult;
1410
1559
  }
@@ -1515,8 +1664,8 @@ var McpTestClient = class {
1515
1664
  /**
1516
1665
  * Call a tool by name with arguments
1517
1666
  */
1518
- call: async (name, args) => {
1519
- const response = await this.callTool(name, args);
1667
+ call: async (name, args, options = {}) => {
1668
+ const response = await this.callTool(name, args, this.progressTokenFor(options));
1520
1669
  return this.wrapToolResult(response);
1521
1670
  }
1522
1671
  };
@@ -1658,6 +1807,7 @@ var McpTestClient = class {
1658
1807
  * Collect progress notifications specifically
1659
1808
  */
1660
1809
  collectProgress: () => {
1810
+ this.collectingProgress = true;
1661
1811
  return new ProgressCollector(this._progressUpdates);
1662
1812
  },
1663
1813
  /**
@@ -1963,12 +2113,29 @@ var McpTestClient = class {
1963
2113
  async listTools() {
1964
2114
  return this.request("tools/list", {});
1965
2115
  }
1966
- async callTool(name, args) {
2116
+ async callTool(name, args, progressToken) {
1967
2117
  return this.request("tools/call", {
1968
2118
  name,
1969
- arguments: args ?? {}
2119
+ arguments: args ?? {},
2120
+ ...progressToken !== void 0 && { _meta: { progressToken } }
1970
2121
  });
1971
2122
  }
2123
+ progressTokenFor(options) {
2124
+ if (options.progressToken !== void 0) return options.progressToken;
2125
+ return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
2126
+ }
2127
+ recordNotification = (notification) => {
2128
+ const timestamp = /* @__PURE__ */ new Date();
2129
+ this._notifications.push({ method: notification.method, params: notification.params, timestamp });
2130
+ if (notification.method !== "notifications/progress") return;
2131
+ const { progress, total, progressToken } = notification.params ?? {};
2132
+ this._progressUpdates.push({
2133
+ progress: Number(progress),
2134
+ total: typeof total === "number" ? total : void 0,
2135
+ progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
2136
+ timestamp
2137
+ });
2138
+ };
1972
2139
  async listResources() {
1973
2140
  return this.request("resources/list", {});
1974
2141
  }
@@ -1990,6 +2157,11 @@ var McpTestClient = class {
1990
2157
  // ═══════════════════════════════════════════════════════════════════
1991
2158
  // PRIVATE: TRANSPORT & REQUEST HELPERS
1992
2159
  // ═══════════════════════════════════════════════════════════════════
2160
+ /** The MCP path this client is currently pointed at, for log and error text. */
2161
+ mcpPathLabel() {
2162
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
2163
+ return entry ? `/${entry}` : "/";
2164
+ }
1993
2165
  createTransport() {
1994
2166
  let baseUrl = this.config.baseUrl;
1995
2167
  if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
@@ -2003,13 +2175,15 @@ var McpTestClient = class {
2003
2175
  case "streamable-http":
2004
2176
  return new StreamableHttpTransport({
2005
2177
  baseUrl,
2178
+ entryPath: this.config.entryPath,
2006
2179
  timeout: this.config.timeout,
2007
2180
  auth: this.config.auth,
2008
2181
  publicMode: this.config.publicMode,
2009
2182
  debug: this.config.debug,
2010
2183
  interceptors: this._interceptors,
2011
2184
  clientInfo: this.config.clientInfo,
2012
- elicitationHandler: this._elicitationHandler
2185
+ elicitationHandler: this._elicitationHandler,
2186
+ notificationHandler: this.recordNotification
2013
2187
  });
2014
2188
  case "sse":
2015
2189
  throw new Error("SSE transport not yet implemented");
@@ -4882,13 +5056,17 @@ var currentConfig = {};
4882
5056
  var serverInstance = null;
4883
5057
  var tokenFactory = null;
4884
5058
  var serverStartedByUs = false;
5059
+ var skipScopes = [{ skipped: false }];
5060
+ function currentSkipScope() {
5061
+ return skipScopes[skipScopes.length - 1];
5062
+ }
4885
5063
  async function initializeSharedResources() {
4886
5064
  if (!tokenFactory) {
4887
5065
  const sharedSecret = currentConfig.env?.["JWT_SECRET"];
4888
5066
  tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
4889
5067
  }
4890
5068
  if (!serverInstance) {
4891
- if (currentConfig.baseUrl) {
5069
+ if (currentConfig.baseUrl && !currentConfig.server) {
4892
5070
  serverInstance = TestServer.connect(currentConfig.baseUrl);
4893
5071
  serverStartedByUs = false;
4894
5072
  } else if (currentConfig.server) {
@@ -4938,7 +5116,8 @@ async function createTestFixtures() {
4938
5116
  throw new Error("Token factory not initialized");
4939
5117
  }
4940
5118
  const clientInstance = McpTestClient.create({
4941
- baseUrl: serverInstance.info.baseUrl,
5119
+ baseUrl: resolveClientBaseUrl(serverInstance),
5120
+ entryPath: currentConfig.entryPath,
4942
5121
  transport: currentConfig.transport ?? "streamable-http",
4943
5122
  publicMode: currentConfig.publicMode
4944
5123
  }).build();
@@ -4987,6 +5166,9 @@ async function cleanupSharedResources() {
4987
5166
  tokenFactory = null;
4988
5167
  serverStartedByUs = false;
4989
5168
  }
5169
+ function resolveClientBaseUrl(server) {
5170
+ return currentConfig.baseUrl ?? server.info.baseUrl;
5171
+ }
4990
5172
  function createAuthFixture(factory) {
4991
5173
  const users = {
4992
5174
  admin: {
@@ -5036,7 +5218,8 @@ function createServerFixture(server) {
5036
5218
  info: server.info,
5037
5219
  createClient: async (opts) => {
5038
5220
  return McpTestClient.create({
5039
- baseUrl: server.info.baseUrl,
5221
+ baseUrl: resolveClientBaseUrl(server),
5222
+ entryPath: opts?.entryPath ?? currentConfig.entryPath,
5040
5223
  transport: opts?.transport ?? "streamable-http",
5041
5224
  auth: opts?.token ? { token: opts.token } : void 0,
5042
5225
  clientInfo: opts?.clientInfo,
@@ -5045,7 +5228,8 @@ function createServerFixture(server) {
5045
5228
  },
5046
5229
  createClientBuilder: () => {
5047
5230
  const builder = new McpTestClientBuilder({
5048
- baseUrl: server.info.baseUrl,
5231
+ baseUrl: resolveClientBaseUrl(server),
5232
+ entryPath: currentConfig.entryPath,
5049
5233
  publicMode: currentConfig.publicMode
5050
5234
  });
5051
5235
  return builder;
@@ -5061,8 +5245,8 @@ function resolveServerCommand(server) {
5061
5245
  }
5062
5246
  return `npx tsx ${server}`;
5063
5247
  }
5064
- function testWithFixtures(name, fn) {
5065
- it2(name, async () => {
5248
+ function runWithFixtures(fn) {
5249
+ return async () => {
5066
5250
  const fixtures = await createTestFixtures();
5067
5251
  let testFailed = false;
5068
5252
  try {
@@ -5073,7 +5257,15 @@ function testWithFixtures(name, fn) {
5073
5257
  } finally {
5074
5258
  await cleanupTestFixtures(fixtures, testFailed);
5075
5259
  }
5076
- });
5260
+ };
5261
+ }
5262
+ function testWithFixtures(name, fn) {
5263
+ const scope = currentSkipScope();
5264
+ if (scope.skipped) {
5265
+ it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
5266
+ return;
5267
+ }
5268
+ it2(name, runWithFixtures(fn));
5077
5269
  }
5078
5270
  function use(config) {
5079
5271
  currentConfig = { ...currentConfig, ...config };
@@ -5081,40 +5273,60 @@ function use(config) {
5081
5273
  await cleanupSharedResources();
5082
5274
  });
5083
5275
  }
5084
- function skip(name, fn) {
5085
- it2.skip(name, async () => {
5086
- const fixtures = await createTestFixtures();
5087
- let testFailed = false;
5088
- try {
5089
- await fn(fixtures);
5090
- } catch (error) {
5091
- testFailed = true;
5092
- throw error;
5093
- } finally {
5094
- await cleanupTestFixtures(fixtures, testFailed);
5276
+ function skip(nameOrCondition, fnOrReason) {
5277
+ if (typeof nameOrCondition === "boolean") {
5278
+ if (nameOrCondition) {
5279
+ const scope = currentSkipScope();
5280
+ scope.skipped = true;
5281
+ scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
5095
5282
  }
5096
- });
5283
+ return;
5284
+ }
5285
+ if (typeof fnOrReason !== "function") {
5286
+ throw new TypeError(
5287
+ `test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
5288
+ );
5289
+ }
5290
+ it2.skip(nameOrCondition, runWithFixtures(fnOrReason));
5097
5291
  }
5098
5292
  function only(name, fn) {
5099
- it2.only(name, async () => {
5100
- const fixtures = await createTestFixtures();
5101
- let testFailed = false;
5102
- try {
5103
- await fn(fixtures);
5104
- } catch (error) {
5105
- testFailed = true;
5106
- throw error;
5107
- } finally {
5108
- await cleanupTestFixtures(fixtures, testFailed);
5109
- }
5110
- });
5293
+ const scope = currentSkipScope();
5294
+ if (scope.skipped) {
5295
+ it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
5296
+ return;
5297
+ }
5298
+ it2.only(name, runWithFixtures(fn));
5111
5299
  }
5112
5300
  function todo(name) {
5113
5301
  it2.todo(name);
5114
5302
  }
5303
+ function withSkipScope(register) {
5304
+ return (name, fn) => {
5305
+ register(name, () => {
5306
+ const parent = currentSkipScope();
5307
+ skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
5308
+ try {
5309
+ fn();
5310
+ } finally {
5311
+ skipScopes.pop();
5312
+ }
5313
+ });
5314
+ };
5315
+ }
5316
+ function eachWithSkipScope(each) {
5317
+ return ((...eachArgs) => {
5318
+ const register = each(...eachArgs);
5319
+ return withSkipScope(register);
5320
+ });
5321
+ }
5322
+ var describeWithSkipScope = Object.assign(withSkipScope(describe2), describe2, {
5323
+ only: Object.assign(withSkipScope(describe2.only), { each: eachWithSkipScope(describe2.only.each) }),
5324
+ skip: Object.assign(withSkipScope(describe2.skip), { each: eachWithSkipScope(describe2.skip.each) }),
5325
+ each: eachWithSkipScope(describe2.each)
5326
+ });
5115
5327
  var test = testWithFixtures;
5116
5328
  test.use = use;
5117
- test.describe = describe2;
5329
+ test.describe = describeWithSkipScope;
5118
5330
  test.beforeAll = beforeAll2;
5119
5331
  test.beforeEach = beforeEach2;
5120
5332
  test.afterEach = afterEach2;
@@ -5507,18 +5719,17 @@ var toBeError = function(received, expectedCode) {
5507
5719
  message: () => `Expected a result wrapper object with isError property`
5508
5720
  };
5509
5721
  }
5510
- let pass = result.isError;
5511
- if (pass && expectedCode !== void 0) {
5512
- pass = result.error?.code === expectedCode;
5513
- }
5722
+ const receivedCode = typeof expectedCode === "string" ? result.raw?._meta?.["code"] : result.error?.code;
5723
+ const codeMatches = expectedCode === void 0 || receivedCode === expectedCode;
5724
+ const pass = result.isError && codeMatches;
5514
5725
  return {
5515
5726
  pass,
5516
5727
  message: () => {
5517
5728
  if (!result.isError) {
5518
5729
  return "Expected result to be an error, but it was successful";
5519
5730
  }
5520
- if (expectedCode !== void 0 && result.error?.code !== expectedCode) {
5521
- return `Expected error code ${expectedCode}, but got ${result.error?.code}`;
5731
+ if (!codeMatches) {
5732
+ return `Expected error code ${expectedCode}, but got ${String(receivedCode)}`;
5522
5733
  }
5523
5734
  return "Expected result not to be an error";
5524
5735
  }