@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/esm/index.mjs CHANGED
@@ -5,255 +5,345 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
5
5
  throw Error('Dynamic require of "' + x + '" is not supported');
6
6
  });
7
7
 
8
- // libs/testing/src/platform/platform-client-info.ts
9
- var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
10
- function getPlatformClientInfo(platform) {
11
- switch (platform) {
12
- case "openai":
13
- return {
14
- name: "ChatGPT",
15
- version: "1.0"
16
- };
17
- case "ext-apps":
18
- return {
19
- name: "mcp-ext-apps",
20
- version: "1.0"
21
- };
22
- case "claude":
23
- return {
24
- name: "claude-desktop",
25
- version: "1.0"
26
- };
27
- case "cursor":
28
- return {
29
- name: "cursor",
30
- version: "1.0"
31
- };
32
- case "continue":
33
- return {
34
- name: "continue",
35
- version: "1.0"
36
- };
37
- case "cody":
38
- return {
39
- name: "cody",
40
- version: "1.0"
41
- };
42
- case "gemini":
43
- return {
44
- name: "gemini",
45
- version: "1.0"
46
- };
47
- case "generic-mcp":
48
- return {
49
- name: "generic-mcp-client",
50
- version: "1.0"
51
- };
52
- case "unknown":
53
- default:
54
- return {
55
- name: "mcp-test-client",
56
- version: "1.0"
57
- };
58
- }
59
- }
60
- function buildUserAgent(clientInfo) {
61
- return `${clientInfo.name}/${clientInfo.version}`;
62
- }
63
- function getPlatformUserAgent(platform) {
64
- return buildUserAgent(getPlatformClientInfo(platform));
65
- }
66
- var PLATFORM_DETECTION_PATTERNS = {
67
- openai: /chatgpt/i,
68
- "ext-apps": /mcp-ext-apps/i,
69
- // Note: Actual detection uses capabilities
70
- claude: /claude|claude-desktop/i,
71
- cursor: /cursor/i,
72
- continue: /continue/i,
73
- cody: /cody/i,
74
- gemini: /gemini/i,
75
- "generic-mcp": /generic-mcp/i,
76
- unknown: /.*/
77
- // Matches anything (fallback)
78
- };
79
- function getPlatformCapabilities(platform) {
80
- const baseCapabilities = {
81
- sampling: {},
82
- // Include elicitation.form by default for testing elicitation workflows
83
- // Note: MCP SDK expects form to be an object, not boolean
84
- elicitation: {
85
- form: {}
86
- }
87
- };
88
- if (platform === "ext-apps") {
8
+ // libs/testing/src/interceptor/mock-registry.ts
9
+ var DefaultMockRegistry = class {
10
+ mocks = [];
11
+ add(mock) {
12
+ const entry = {
13
+ definition: mock,
14
+ callCount: 0,
15
+ calls: [],
16
+ remainingUses: mock.times ?? Infinity
17
+ };
18
+ this.mocks.push(entry);
89
19
  return {
90
- ...baseCapabilities,
91
- experimental: {
92
- [MCP_APPS_EXTENSION_KEY]: {
93
- mimeTypes: ["text/html;profile=mcp-app"]
20
+ remove: () => {
21
+ const index = this.mocks.indexOf(entry);
22
+ if (index !== -1) {
23
+ this.mocks.splice(index, 1);
94
24
  }
95
- }
25
+ },
26
+ callCount: () => entry.callCount,
27
+ calls: () => [...entry.calls]
96
28
  };
97
29
  }
98
- return baseCapabilities;
99
- }
100
-
101
- // libs/testing/src/client/mcp-test-client.builder.ts
102
- var McpTestClientBuilder = class {
103
- config;
104
- constructor(config) {
105
- this.config = { ...config };
30
+ clear() {
31
+ this.mocks = [];
32
+ }
33
+ getAll() {
34
+ return this.mocks.map((e) => e.definition);
35
+ }
36
+ match(request) {
37
+ for (const entry of this.mocks) {
38
+ if (entry.remainingUses <= 0) continue;
39
+ const { definition } = entry;
40
+ if (definition.method !== request.method) continue;
41
+ if (definition.params !== void 0) {
42
+ const params = request.params ?? {};
43
+ if (typeof definition.params === "function") {
44
+ if (!definition.params(params)) continue;
45
+ } else {
46
+ if (!this.paramsMatch(definition.params, params)) continue;
47
+ }
48
+ }
49
+ entry.callCount++;
50
+ entry.calls.push(request);
51
+ entry.remainingUses--;
52
+ return definition;
53
+ }
54
+ return void 0;
106
55
  }
107
56
  /**
108
- * Set the authentication configuration
57
+ * Check if request params match the mock params definition
109
58
  */
110
- withAuth(auth) {
111
- this.config.auth = { ...this.config.auth, ...auth };
112
- return this;
59
+ paramsMatch(expected, actual) {
60
+ for (const [key, value] of Object.entries(expected)) {
61
+ if (!(key in actual)) return false;
62
+ const actualValue = actual[key];
63
+ if (Array.isArray(value)) {
64
+ if (!Array.isArray(actualValue)) return false;
65
+ if (value.length !== actualValue.length) return false;
66
+ for (let i = 0; i < value.length; i++) {
67
+ const expectedItem = value[i];
68
+ const actualItem = actualValue[i];
69
+ if (typeof expectedItem === "object" && expectedItem !== null) {
70
+ if (typeof actualItem !== "object" || actualItem === null) return false;
71
+ if (!this.paramsMatch(expectedItem, actualItem)) {
72
+ return false;
73
+ }
74
+ } else if (actualItem !== expectedItem) {
75
+ return false;
76
+ }
77
+ }
78
+ } else if (typeof value === "object" && value !== null) {
79
+ if (typeof actualValue !== "object" || actualValue === null) return false;
80
+ if (!this.paramsMatch(value, actualValue)) {
81
+ return false;
82
+ }
83
+ } else if (actualValue !== value) {
84
+ return false;
85
+ }
86
+ }
87
+ return true;
113
88
  }
89
+ };
90
+ var mockResponse = {
114
91
  /**
115
- * Set the bearer token for authentication
92
+ * Create a successful JSON-RPC response
116
93
  */
117
- withToken(token) {
118
- this.config.auth = { ...this.config.auth, token };
119
- return this;
120
- }
94
+ success(result, id = 1) {
95
+ return {
96
+ jsonrpc: "2.0",
97
+ id,
98
+ result
99
+ };
100
+ },
121
101
  /**
122
- * Add custom headers to all requests
102
+ * Create an error JSON-RPC response
123
103
  */
124
- withHeaders(headers) {
125
- this.config.auth = {
126
- ...this.config.auth,
127
- headers: { ...this.config.auth?.headers, ...headers }
104
+ error(code, message, data, id = 1) {
105
+ return {
106
+ jsonrpc: "2.0",
107
+ id,
108
+ error: { code, message, data }
128
109
  };
129
- return this;
130
- }
110
+ },
131
111
  /**
132
- * Set the transport type
112
+ * Create a tool result response
133
113
  */
134
- withTransport(transport) {
135
- this.config.transport = transport;
136
- return this;
137
- }
114
+ toolResult(content, id = 1) {
115
+ return {
116
+ jsonrpc: "2.0",
117
+ id,
118
+ result: { content }
119
+ };
120
+ },
138
121
  /**
139
- * Set the request timeout in milliseconds
122
+ * Create a tools/list response
140
123
  */
141
- withTimeout(timeoutMs) {
142
- this.config.timeout = timeoutMs;
143
- return this;
144
- }
124
+ toolsList(tools, id = 1) {
125
+ return {
126
+ jsonrpc: "2.0",
127
+ id,
128
+ result: { tools }
129
+ };
130
+ },
145
131
  /**
146
- * Enable debug logging
132
+ * Create a resources/list response
147
133
  */
148
- withDebug(enabled = true) {
149
- this.config.debug = enabled;
150
- return this;
151
- }
134
+ resourcesList(resources, id = 1) {
135
+ return {
136
+ jsonrpc: "2.0",
137
+ id,
138
+ result: { resources }
139
+ };
140
+ },
152
141
  /**
153
- * Enable public mode - skip authentication entirely.
154
- * When true, no Authorization header is sent and anonymous token is not requested.
155
- * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
142
+ * Create a resources/read response
156
143
  */
157
- withPublicMode(enabled = true) {
158
- this.config.publicMode = enabled;
159
- return this;
160
- }
144
+ resourceRead(contents, id = 1) {
145
+ return {
146
+ jsonrpc: "2.0",
147
+ id,
148
+ result: { contents }
149
+ };
150
+ },
161
151
  /**
162
- * Set the MCP protocol version to request
152
+ * Common MCP errors
163
153
  */
164
- withProtocolVersion(version) {
165
- this.config.protocolVersion = version;
166
- return this;
154
+ errors: {
155
+ methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
156
+ invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
157
+ internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
158
+ resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
159
+ toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
160
+ unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
161
+ forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
162
+ }
163
+ };
164
+
165
+ // libs/testing/src/interceptor/interceptor-chain.ts
166
+ var DefaultInterceptorChain = class {
167
+ request = [];
168
+ response = [];
169
+ mocks;
170
+ constructor() {
171
+ this.mocks = new DefaultMockRegistry();
167
172
  }
168
173
  /**
169
- * Set the client info sent during initialization
174
+ * Add a request interceptor
170
175
  */
171
- withClientInfo(info) {
172
- this.config.clientInfo = info;
173
- return this;
176
+ addRequestInterceptor(interceptor) {
177
+ this.request.push(interceptor);
178
+ return () => {
179
+ const index = this.request.indexOf(interceptor);
180
+ if (index !== -1) {
181
+ this.request.splice(index, 1);
182
+ }
183
+ };
174
184
  }
175
185
  /**
176
- * Set the platform type for testing platform-specific meta keys.
177
- * Automatically configures clientInfo and capabilities for platform detection.
178
- *
179
- * Platform-specific behavior:
180
- * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
181
- * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
182
- * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
183
- * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
184
- * - Other platforms follow similar patterns
185
- *
186
- * @example
187
- * ```typescript
188
- * const client = await McpTestClient.create({ baseUrl })
189
- * .withPlatform('openai')
190
- * .buildAndConnect();
191
- *
192
- * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
193
- * const extAppsClient = await McpTestClient.create({ baseUrl })
194
- * .withPlatform('ext-apps')
195
- * .buildAndConnect();
196
- * ```
186
+ * Add a response interceptor
197
187
  */
198
- withPlatform(platform) {
199
- this.config.platform = platform;
200
- this.config.clientInfo = getPlatformClientInfo(platform);
201
- this.config.capabilities = getPlatformCapabilities(platform);
202
- return this;
188
+ addResponseInterceptor(interceptor) {
189
+ this.response.push(interceptor);
190
+ return () => {
191
+ const index = this.response.indexOf(interceptor);
192
+ if (index !== -1) {
193
+ this.response.splice(index, 1);
194
+ }
195
+ };
203
196
  }
204
197
  /**
205
- * Set custom client capabilities for MCP initialization.
206
- * Use this for fine-grained control over capabilities sent during initialization.
207
- *
208
- * @example
209
- * ```typescript
210
- * const client = await McpTestClient.create({ baseUrl })
211
- * .withCapabilities({
212
- * sampling: {},
213
- * experimental: {
214
- * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
215
- * }
216
- * })
217
- * .buildAndConnect();
218
- * ```
198
+ * Process a request through the interceptor chain
199
+ * Returns either:
200
+ * - { type: 'continue', request } - continue with (possibly modified) request
201
+ * - { type: 'mock', response } - return mock response immediately
202
+ * - { type: 'error', error } - throw error
219
203
  */
220
- withCapabilities(capabilities) {
221
- this.config.capabilities = capabilities;
222
- return this;
204
+ async processRequest(request, meta) {
205
+ let currentRequest = request;
206
+ const mockDef = this.mocks.match(request);
207
+ if (mockDef) {
208
+ if (mockDef.delay && mockDef.delay > 0) {
209
+ await sleep(mockDef.delay);
210
+ }
211
+ let mockResponse2;
212
+ if (typeof mockDef.response === "function") {
213
+ mockResponse2 = await mockDef.response(request);
214
+ } else {
215
+ mockResponse2 = mockDef.response;
216
+ }
217
+ return {
218
+ type: "mock",
219
+ response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
220
+ };
221
+ }
222
+ for (const interceptor of this.request) {
223
+ const ctx = {
224
+ request: currentRequest,
225
+ meta
226
+ };
227
+ const result = await interceptor(ctx);
228
+ switch (result.action) {
229
+ case "passthrough":
230
+ break;
231
+ case "modify":
232
+ currentRequest = result.request;
233
+ break;
234
+ case "mock":
235
+ return {
236
+ type: "mock",
237
+ response: { ...result.response, id: request.id ?? result.response.id }
238
+ };
239
+ case "error":
240
+ return { type: "error", error: result.error };
241
+ }
242
+ }
243
+ return { type: "continue", request: currentRequest };
223
244
  }
224
245
  /**
225
- * Set query parameters to append to the connection URL.
226
- * Useful for testing mode switches like `?mode=skills_only`.
227
- *
228
- * @example
229
- * ```typescript
230
- * const client = await McpTestClient.create({ baseUrl })
231
- * .withQueryParams({ mode: 'skills_only' })
232
- * .buildAndConnect();
233
- * ```
246
+ * Process a response through the interceptor chain
234
247
  */
235
- withQueryParams(params) {
236
- this.config.queryParams = { ...this.config.queryParams, ...params };
237
- return this;
248
+ async processResponse(request, response, durationMs) {
249
+ let currentResponse = response;
250
+ for (const interceptor of this.response) {
251
+ const ctx = {
252
+ request,
253
+ response: currentResponse,
254
+ durationMs
255
+ };
256
+ const result = await interceptor(ctx);
257
+ switch (result.action) {
258
+ case "passthrough":
259
+ break;
260
+ case "modify":
261
+ currentResponse = result.response;
262
+ break;
263
+ }
264
+ }
265
+ return currentResponse;
238
266
  }
239
267
  /**
240
- * Build the McpTestClient instance (does not connect)
268
+ * Clear all interceptors and mocks
241
269
  */
242
- build() {
243
- return new McpTestClient(this.config);
270
+ clear() {
271
+ this.request = [];
272
+ this.response = [];
273
+ this.mocks.clear();
244
274
  }
275
+ };
276
+ function sleep(ms) {
277
+ return new Promise((resolve) => setTimeout(resolve, ms));
278
+ }
279
+ var interceptors = {
245
280
  /**
246
- * Build the McpTestClient and connect to the server
281
+ * Create an interceptor that logs all requests
247
282
  */
248
- async buildAndConnect() {
249
- const client = this.build();
250
- await client.connect();
251
- return client;
283
+ logger(logFn = console.log) {
284
+ return (ctx) => {
285
+ logFn(`[MCP Request] ${ctx.request.method}`, ctx.request.params);
286
+ return { action: "passthrough" };
287
+ };
288
+ },
289
+ /**
290
+ * Create an interceptor that adds latency to all requests
291
+ */
292
+ delay(ms) {
293
+ return async () => {
294
+ await sleep(ms);
295
+ return { action: "passthrough" };
296
+ };
297
+ },
298
+ /**
299
+ * Create an interceptor that fails requests matching a condition
300
+ */
301
+ failWhen(condition, error) {
302
+ return (ctx) => {
303
+ if (condition(ctx)) {
304
+ const err = typeof error === "string" ? new Error(error) : error;
305
+ return { action: "error", error: err };
306
+ }
307
+ return { action: "passthrough" };
308
+ };
309
+ },
310
+ /**
311
+ * Create an interceptor that modifies specific methods
312
+ */
313
+ modifyMethod(method, modifier) {
314
+ return (ctx) => {
315
+ if (ctx.request.method === method) {
316
+ return { action: "modify", request: modifier(ctx.request) };
317
+ }
318
+ return { action: "passthrough" };
319
+ };
320
+ },
321
+ /**
322
+ * Create a response interceptor that logs responses
323
+ */
324
+ responseLogger(logFn = console.log) {
325
+ return (ctx) => {
326
+ const status = ctx.response.error ? "ERROR" : "OK";
327
+ logFn(`[MCP Response] ${ctx.request.method} ${status} (${ctx.durationMs}ms)`, ctx.response);
328
+ return { action: "passthrough" };
329
+ };
330
+ },
331
+ /**
332
+ * Create a response interceptor that modifies specific responses
333
+ */
334
+ modifyResponse(method, modifier) {
335
+ return (ctx) => {
336
+ if (ctx.request.method === method) {
337
+ return { action: "modify", response: modifier(ctx.response) };
338
+ }
339
+ return { action: "passthrough" };
340
+ };
252
341
  }
253
342
  };
254
343
 
255
344
  // libs/testing/src/transport/streamable-http.transport.ts
256
345
  var DEFAULT_TIMEOUT = 3e4;
346
+ var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
257
347
  var StreamableHttpTransport = class {
258
348
  config;
259
349
  state = "disconnected";
@@ -265,10 +355,15 @@ var StreamableHttpTransport = class {
265
355
  interceptors;
266
356
  publicMode;
267
357
  elicitationHandler;
358
+ notificationHandler;
359
+ notificationStream;
360
+ notificationProtocolVersion = "";
361
+ notificationStreamReopenTimer;
268
362
  constructor(config) {
269
363
  this.config = {
270
364
  baseUrl: config.baseUrl.replace(/\/$/, ""),
271
365
  // Remove trailing slash
366
+ entryPath: config.entryPath,
272
367
  timeout: config.timeout ?? DEFAULT_TIMEOUT,
273
368
  auth: config.auth ?? {},
274
369
  publicMode: config.publicMode ?? false,
@@ -280,6 +375,7 @@ var StreamableHttpTransport = class {
280
375
  this.interceptors = config.interceptors;
281
376
  this.publicMode = config.publicMode ?? false;
282
377
  this.elicitationHandler = config.elicitationHandler;
378
+ this.notificationHandler = config.notificationHandler;
283
379
  }
284
380
  async connect() {
285
381
  this.state = "connecting";
@@ -361,7 +457,7 @@ var StreamableHttpTransport = class {
361
457
  }
362
458
  const headers = this.buildHeaders();
363
459
  this.lastRequestHeaders = headers;
364
- const url = `${this.config.baseUrl}/`;
460
+ const url = this.mcpUrl();
365
461
  this.log(`POST ${url}`, message);
366
462
  const controller = new AbortController();
367
463
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -431,7 +527,7 @@ var StreamableHttpTransport = class {
431
527
  this.ensureConnected();
432
528
  const headers = this.buildHeaders();
433
529
  this.lastRequestHeaders = headers;
434
- const url = `${this.config.baseUrl}/`;
530
+ const url = this.mcpUrl();
435
531
  this.log(`POST ${url} (notification)`, message);
436
532
  const controller = new AbortController();
437
533
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -462,7 +558,7 @@ var StreamableHttpTransport = class {
462
558
  this.ensureConnected();
463
559
  const headers = this.buildHeaders();
464
560
  this.lastRequestHeaders = headers;
465
- const url = `${this.config.baseUrl}/`;
561
+ const url = this.mcpUrl();
466
562
  this.log(`POST ${url} (raw)`, data);
467
563
  const controller = new AbortController();
468
564
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
@@ -499,7 +595,12 @@ var StreamableHttpTransport = class {
499
595
  };
500
596
  }
501
597
  }
598
+ async openNotificationStream(protocolVersion) {
599
+ this.notificationProtocolVersion = protocolVersion;
600
+ await this.connectNotificationStream();
601
+ }
502
602
  async close() {
603
+ this.closeNotificationStream();
503
604
  this.state = "disconnected";
504
605
  this.sessionId = void 0;
505
606
  this.log("StreamableHTTP transport closed");
@@ -538,6 +639,7 @@ var StreamableHttpTransport = class {
538
639
  return { ...this.lastRequestHeaders };
539
640
  }
540
641
  async simulateDisconnect() {
642
+ this.closeNotificationStream();
541
643
  this.state = "disconnected";
542
644
  this.sessionId = void 0;
543
645
  }
@@ -556,23 +658,89 @@ var StreamableHttpTransport = class {
556
658
  // ═══════════════════════════════════════════════════════════════════
557
659
  // PRIVATE HELPERS
558
660
  // ═══════════════════════════════════════════════════════════════════
559
- /**
560
- * Handle SSE response with elicitation support.
561
- *
562
- * Streams the SSE response, detects elicitation/create requests, and handles them
563
- * by calling the registered handler and sending the response back to the server.
564
- */
565
- async handleSSEResponseWithElicitation(response, originalRequest) {
566
- this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
567
- const reader = response.body?.getReader();
568
- if (!reader) {
569
- this.log("handleSSEResponseWithElicitation: no response body");
570
- return {
571
- jsonrpc: "2.0",
572
- id: originalRequest.id ?? null,
573
- error: { code: -32e3, message: "No response body" }
574
- };
575
- }
661
+ closeNotificationStream() {
662
+ clearTimeout(this.notificationStreamReopenTimer);
663
+ this.notificationStream?.abort();
664
+ this.notificationStream = void 0;
665
+ }
666
+ /** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
667
+ async connectNotificationStream() {
668
+ if (!this.sessionId || this.notificationStream) return;
669
+ const controller = new AbortController();
670
+ this.notificationStream = controller;
671
+ const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
672
+ const established = fetch(this.mcpUrl(), {
673
+ method: "GET",
674
+ headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
675
+ signal: controller.signal
676
+ }).then(
677
+ async (response) => {
678
+ const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
679
+ if (!response.ok || !response.body || !isEventStream) {
680
+ await response.body?.cancel();
681
+ this.log(`Server offers no notification stream (HTTP ${response.status})`);
682
+ this.releaseNotificationStream(controller);
683
+ return;
684
+ }
685
+ void this.readNotificationStream(controller, response.body.getReader());
686
+ },
687
+ (error) => {
688
+ this.log("Failed to open the notification stream:", error);
689
+ this.releaseNotificationStream(controller);
690
+ }
691
+ );
692
+ let establishTimer;
693
+ const establishDeadline = new Promise((resolve) => {
694
+ establishTimer = setTimeout(resolve, this.config.timeout);
695
+ });
696
+ await Promise.race([established, establishDeadline]);
697
+ clearTimeout(establishTimer);
698
+ }
699
+ releaseNotificationStream(controller) {
700
+ if (this.notificationStream === controller) this.notificationStream = void 0;
701
+ }
702
+ async readNotificationStream(controller, reader) {
703
+ const decoder = new TextDecoder();
704
+ let buffer = "";
705
+ try {
706
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
707
+ const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
708
+ buffer = remainder;
709
+ for (const eventText of completeEvents) {
710
+ for (const event of this.parseSSEEvents(eventText, void 0).events) {
711
+ await this.handleSSEEvent(event);
712
+ }
713
+ }
714
+ }
715
+ } catch (error) {
716
+ this.log("Notification stream ended:", error);
717
+ } finally {
718
+ reader.releaseLock();
719
+ }
720
+ if (controller.signal.aborted || this.notificationStream !== controller) return;
721
+ this.releaseNotificationStream(controller);
722
+ this.notificationStreamReopenTimer = setTimeout(
723
+ () => void this.connectNotificationStream(),
724
+ NOTIFICATION_STREAM_REOPEN_DELAY_MS
725
+ );
726
+ }
727
+ /**
728
+ * Handle SSE response with elicitation support.
729
+ *
730
+ * Streams the SSE response, detects elicitation/create requests, and handles them
731
+ * by calling the registered handler and sending the response back to the server.
732
+ */
733
+ async handleSSEResponseWithElicitation(response, originalRequest) {
734
+ this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
735
+ const reader = response.body?.getReader();
736
+ if (!reader) {
737
+ this.log("handleSSEResponseWithElicitation: no response body");
738
+ return {
739
+ jsonrpc: "2.0",
740
+ id: originalRequest.id ?? null,
741
+ error: { code: -32e3, message: "No response body" }
742
+ };
743
+ }
576
744
  const decoder = new TextDecoder();
577
745
  let buffer = "";
578
746
  let finalResponse = null;
@@ -693,6 +861,13 @@ var StreamableHttpTransport = class {
693
861
  response: { jsonrpc: "2.0", id: null, result: void 0 }
694
862
  };
695
863
  }
864
+ if ("method" in parsed && parsed.id === void 0) {
865
+ this.notificationHandler?.(parsed);
866
+ return {
867
+ isFinal: false,
868
+ response: { jsonrpc: "2.0", id: null, result: void 0 }
869
+ };
870
+ }
696
871
  if ("result" in parsed || "error" in parsed) {
697
872
  return { isFinal: true, response: parsed };
698
873
  }
@@ -745,7 +920,7 @@ var StreamableHttpTransport = class {
745
920
  */
746
921
  async sendElicitationResponse(requestId, response) {
747
922
  const headers = this.buildHeaders();
748
- const url = `${this.config.baseUrl}/`;
923
+ const url = this.mcpUrl();
749
924
  const rpcResponse = {
750
925
  jsonrpc: "2.0",
751
926
  id: requestId,
@@ -765,6 +940,18 @@ var StreamableHttpTransport = class {
765
940
  this.log("Failed to send elicitation response:", error);
766
941
  }
767
942
  }
943
+ /**
944
+ * The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
945
+ * trailing slash is kept because both hosts normalize it and the original
946
+ * behaviour (`${baseUrl}/`) relied on it (issue #543).
947
+ */
948
+ mcpUrl() {
949
+ const url = new URL(this.config.baseUrl);
950
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
951
+ const basePath = url.pathname.replace(/\/+$/, "");
952
+ url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
953
+ return url.toString();
954
+ }
768
955
  buildHeaders() {
769
956
  const headers = {
770
957
  "Content-Type": "application/json",
@@ -851,350 +1038,285 @@ var StreamableHttpTransport = class {
851
1038
  };
852
1039
  }
853
1040
  };
1041
+ function splitSSEEvents(buffer) {
1042
+ const endsWithCarriageReturn = buffer.endsWith("\r");
1043
+ const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
1044
+ const completeEvents = normalized.split("\n\n");
1045
+ const remainder = completeEvents.pop() ?? "";
1046
+ return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
1047
+ }
854
1048
 
855
- // libs/testing/src/interceptor/mock-registry.ts
856
- var DefaultMockRegistry = class {
857
- mocks = [];
858
- add(mock) {
859
- const entry = {
860
- definition: mock,
861
- callCount: 0,
862
- calls: [],
863
- remainingUses: mock.times ?? Infinity
864
- };
865
- this.mocks.push(entry);
866
- return {
867
- remove: () => {
868
- const index = this.mocks.indexOf(entry);
869
- if (index !== -1) {
870
- this.mocks.splice(index, 1);
871
- }
872
- },
873
- callCount: () => entry.callCount,
874
- calls: () => [...entry.calls]
875
- };
876
- }
877
- clear() {
878
- this.mocks = [];
879
- }
880
- getAll() {
881
- return this.mocks.map((e) => e.definition);
1049
+ // libs/testing/src/platform/platform-client-info.ts
1050
+ var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
1051
+ function getPlatformClientInfo(platform) {
1052
+ switch (platform) {
1053
+ case "openai":
1054
+ return {
1055
+ name: "ChatGPT",
1056
+ version: "1.0"
1057
+ };
1058
+ case "ext-apps":
1059
+ return {
1060
+ name: "mcp-ext-apps",
1061
+ version: "1.0"
1062
+ };
1063
+ case "claude":
1064
+ return {
1065
+ name: "claude-desktop",
1066
+ version: "1.0"
1067
+ };
1068
+ case "cursor":
1069
+ return {
1070
+ name: "cursor",
1071
+ version: "1.0"
1072
+ };
1073
+ case "continue":
1074
+ return {
1075
+ name: "continue",
1076
+ version: "1.0"
1077
+ };
1078
+ case "cody":
1079
+ return {
1080
+ name: "cody",
1081
+ version: "1.0"
1082
+ };
1083
+ case "gemini":
1084
+ return {
1085
+ name: "gemini",
1086
+ version: "1.0"
1087
+ };
1088
+ case "generic-mcp":
1089
+ return {
1090
+ name: "generic-mcp-client",
1091
+ version: "1.0"
1092
+ };
1093
+ case "unknown":
1094
+ default:
1095
+ return {
1096
+ name: "mcp-test-client",
1097
+ version: "1.0"
1098
+ };
882
1099
  }
883
- match(request) {
884
- for (const entry of this.mocks) {
885
- if (entry.remainingUses <= 0) continue;
886
- const { definition } = entry;
887
- if (definition.method !== request.method) continue;
888
- if (definition.params !== void 0) {
889
- const params = request.params ?? {};
890
- if (typeof definition.params === "function") {
891
- if (!definition.params(params)) continue;
892
- } else {
893
- if (!this.paramsMatch(definition.params, params)) continue;
894
- }
895
- }
896
- entry.callCount++;
897
- entry.calls.push(request);
898
- entry.remainingUses--;
899
- return definition;
1100
+ }
1101
+ function buildUserAgent(clientInfo) {
1102
+ return `${clientInfo.name}/${clientInfo.version}`;
1103
+ }
1104
+ function getPlatformUserAgent(platform) {
1105
+ return buildUserAgent(getPlatformClientInfo(platform));
1106
+ }
1107
+ var PLATFORM_DETECTION_PATTERNS = {
1108
+ openai: /chatgpt/i,
1109
+ "ext-apps": /mcp-ext-apps/i,
1110
+ // Note: Actual detection uses capabilities
1111
+ claude: /claude|claude-desktop/i,
1112
+ cursor: /cursor/i,
1113
+ continue: /continue/i,
1114
+ cody: /cody/i,
1115
+ gemini: /gemini/i,
1116
+ "generic-mcp": /generic-mcp/i,
1117
+ unknown: /.*/
1118
+ // Matches anything (fallback)
1119
+ };
1120
+ function getPlatformCapabilities(platform) {
1121
+ const baseCapabilities = {
1122
+ sampling: {},
1123
+ // Include elicitation.form by default for testing elicitation workflows
1124
+ // Note: MCP SDK expects form to be an object, not boolean
1125
+ elicitation: {
1126
+ form: {}
900
1127
  }
901
- return void 0;
902
- }
903
- /**
904
- * Check if request params match the mock params definition
905
- */
906
- paramsMatch(expected, actual) {
907
- for (const [key, value] of Object.entries(expected)) {
908
- if (!(key in actual)) return false;
909
- const actualValue = actual[key];
910
- if (Array.isArray(value)) {
911
- if (!Array.isArray(actualValue)) return false;
912
- if (value.length !== actualValue.length) return false;
913
- for (let i = 0; i < value.length; i++) {
914
- const expectedItem = value[i];
915
- const actualItem = actualValue[i];
916
- if (typeof expectedItem === "object" && expectedItem !== null) {
917
- if (typeof actualItem !== "object" || actualItem === null) return false;
918
- if (!this.paramsMatch(expectedItem, actualItem)) {
919
- return false;
920
- }
921
- } else if (actualItem !== expectedItem) {
922
- return false;
923
- }
924
- }
925
- } else if (typeof value === "object" && value !== null) {
926
- if (typeof actualValue !== "object" || actualValue === null) return false;
927
- if (!this.paramsMatch(value, actualValue)) {
928
- return false;
1128
+ };
1129
+ if (platform === "ext-apps") {
1130
+ return {
1131
+ ...baseCapabilities,
1132
+ experimental: {
1133
+ [MCP_APPS_EXTENSION_KEY]: {
1134
+ mimeTypes: ["text/html;profile=mcp-app"]
929
1135
  }
930
- } else if (actualValue !== value) {
931
- return false;
932
1136
  }
933
- }
934
- return true;
935
- }
936
- };
937
- var mockResponse = {
938
- /**
939
- * Create a successful JSON-RPC response
940
- */
941
- success(result, id = 1) {
942
- return {
943
- jsonrpc: "2.0",
944
- id,
945
- result
946
- };
947
- },
948
- /**
949
- * Create an error JSON-RPC response
950
- */
951
- error(code, message, data, id = 1) {
952
- return {
953
- jsonrpc: "2.0",
954
- id,
955
- error: { code, message, data }
956
- };
957
- },
958
- /**
959
- * Create a tool result response
960
- */
961
- toolResult(content, id = 1) {
962
- return {
963
- jsonrpc: "2.0",
964
- id,
965
- result: { content }
966
- };
967
- },
968
- /**
969
- * Create a tools/list response
970
- */
971
- toolsList(tools, id = 1) {
972
- return {
973
- jsonrpc: "2.0",
974
- id,
975
- result: { tools }
976
- };
977
- },
978
- /**
979
- * Create a resources/list response
980
- */
981
- resourcesList(resources, id = 1) {
982
- return {
983
- jsonrpc: "2.0",
984
- id,
985
- result: { resources }
986
1137
  };
987
- },
988
- /**
989
- * Create a resources/read response
990
- */
991
- resourceRead(contents, id = 1) {
992
- return {
993
- jsonrpc: "2.0",
994
- id,
995
- result: { contents }
996
- };
997
- },
998
- /**
999
- * Common MCP errors
1000
- */
1001
- errors: {
1002
- methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
1003
- invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
1004
- internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
1005
- resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
1006
- toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
1007
- unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
1008
- forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
1009
1138
  }
1010
- };
1139
+ return baseCapabilities;
1140
+ }
1011
1141
 
1012
- // libs/testing/src/interceptor/interceptor-chain.ts
1013
- var DefaultInterceptorChain = class {
1014
- request = [];
1015
- response = [];
1016
- mocks;
1017
- constructor() {
1018
- this.mocks = new DefaultMockRegistry();
1019
- }
1020
- /**
1021
- * Add a request interceptor
1022
- */
1023
- addRequestInterceptor(interceptor) {
1024
- this.request.push(interceptor);
1025
- return () => {
1026
- const index = this.request.indexOf(interceptor);
1027
- if (index !== -1) {
1028
- this.request.splice(index, 1);
1029
- }
1030
- };
1142
+ // libs/testing/src/client/mcp-test-client.builder.ts
1143
+ var McpTestClientBuilder = class {
1144
+ config;
1145
+ constructor(config) {
1146
+ this.config = { ...config };
1031
1147
  }
1032
1148
  /**
1033
- * Add a response interceptor
1149
+ * Set the authentication configuration
1034
1150
  */
1035
- addResponseInterceptor(interceptor) {
1036
- this.response.push(interceptor);
1037
- return () => {
1038
- const index = this.response.indexOf(interceptor);
1039
- if (index !== -1) {
1040
- this.response.splice(index, 1);
1041
- }
1042
- };
1151
+ withAuth(auth) {
1152
+ this.config.auth = { ...this.config.auth, ...auth };
1153
+ return this;
1043
1154
  }
1044
1155
  /**
1045
- * Process a request through the interceptor chain
1046
- * Returns either:
1047
- * - { type: 'continue', request } - continue with (possibly modified) request
1048
- * - { type: 'mock', response } - return mock response immediately
1049
- * - { type: 'error', error } - throw error
1156
+ * Set the bearer token for authentication
1050
1157
  */
1051
- async processRequest(request, meta) {
1052
- let currentRequest = request;
1053
- const mockDef = this.mocks.match(request);
1054
- if (mockDef) {
1055
- if (mockDef.delay && mockDef.delay > 0) {
1056
- await sleep(mockDef.delay);
1057
- }
1058
- let mockResponse2;
1059
- if (typeof mockDef.response === "function") {
1060
- mockResponse2 = await mockDef.response(request);
1061
- } else {
1062
- mockResponse2 = mockDef.response;
1063
- }
1064
- return {
1065
- type: "mock",
1066
- response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
1067
- };
1068
- }
1069
- for (const interceptor of this.request) {
1070
- const ctx = {
1071
- request: currentRequest,
1072
- meta
1073
- };
1074
- const result = await interceptor(ctx);
1075
- switch (result.action) {
1076
- case "passthrough":
1077
- break;
1078
- case "modify":
1079
- currentRequest = result.request;
1080
- break;
1081
- case "mock":
1082
- return {
1083
- type: "mock",
1084
- response: { ...result.response, id: request.id ?? result.response.id }
1085
- };
1086
- case "error":
1087
- return { type: "error", error: result.error };
1088
- }
1089
- }
1090
- return { type: "continue", request: currentRequest };
1158
+ withToken(token) {
1159
+ this.config.auth = { ...this.config.auth, token };
1160
+ return this;
1091
1161
  }
1092
1162
  /**
1093
- * Process a response through the interceptor chain
1163
+ * Add custom headers to all requests
1094
1164
  */
1095
- async processResponse(request, response, durationMs) {
1096
- let currentResponse = response;
1097
- for (const interceptor of this.response) {
1098
- const ctx = {
1099
- request,
1100
- response: currentResponse,
1101
- durationMs
1102
- };
1103
- const result = await interceptor(ctx);
1104
- switch (result.action) {
1105
- case "passthrough":
1106
- break;
1107
- case "modify":
1108
- currentResponse = result.response;
1109
- break;
1110
- }
1111
- }
1112
- return currentResponse;
1165
+ withHeaders(headers) {
1166
+ this.config.auth = {
1167
+ ...this.config.auth,
1168
+ headers: { ...this.config.auth?.headers, ...headers }
1169
+ };
1170
+ return this;
1113
1171
  }
1114
1172
  /**
1115
- * Clear all interceptors and mocks
1173
+ * Set the transport type
1116
1174
  */
1117
- clear() {
1118
- this.request = [];
1119
- this.response = [];
1120
- this.mocks.clear();
1175
+ withTransport(transport) {
1176
+ this.config.transport = transport;
1177
+ return this;
1121
1178
  }
1122
- };
1123
- function sleep(ms) {
1124
- return new Promise((resolve) => setTimeout(resolve, ms));
1125
- }
1126
- var interceptors = {
1127
1179
  /**
1128
- * Create an interceptor that logs all requests
1180
+ * Set the request timeout in milliseconds
1129
1181
  */
1130
- logger(logFn = console.log) {
1131
- return (ctx) => {
1132
- logFn(`[MCP Request] ${ctx.request.method}`, ctx.request.params);
1133
- return { action: "passthrough" };
1134
- };
1135
- },
1182
+ withTimeout(timeoutMs) {
1183
+ this.config.timeout = timeoutMs;
1184
+ return this;
1185
+ }
1136
1186
  /**
1137
- * Create an interceptor that adds latency to all requests
1187
+ * Enable debug logging
1138
1188
  */
1139
- delay(ms) {
1140
- return async () => {
1141
- await sleep(ms);
1142
- return { action: "passthrough" };
1143
- };
1144
- },
1189
+ withDebug(enabled = true) {
1190
+ this.config.debug = enabled;
1191
+ return this;
1192
+ }
1145
1193
  /**
1146
- * Create an interceptor that fails requests matching a condition
1194
+ * Enable public mode - skip authentication entirely.
1195
+ * When true, no Authorization header is sent and anonymous token is not requested.
1196
+ * Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
1147
1197
  */
1148
- failWhen(condition, error) {
1149
- return (ctx) => {
1150
- if (condition(ctx)) {
1151
- const err = typeof error === "string" ? new Error(error) : error;
1152
- return { action: "error", error: err };
1153
- }
1154
- return { action: "passthrough" };
1155
- };
1156
- },
1198
+ withPublicMode(enabled = true) {
1199
+ this.config.publicMode = enabled;
1200
+ return this;
1201
+ }
1157
1202
  /**
1158
- * Create an interceptor that modifies specific methods
1203
+ * Set the MCP protocol version to request (default '2025-06-18').
1204
+ * '2026-07-28' has no initialize handshake, so `build()` throws for it.
1159
1205
  */
1160
- modifyMethod(method, modifier) {
1161
- return (ctx) => {
1162
- if (ctx.request.method === method) {
1163
- return { action: "modify", request: modifier(ctx.request) };
1164
- }
1165
- return { action: "passthrough" };
1166
- };
1167
- },
1206
+ withProtocolVersion(version) {
1207
+ this.config.protocolVersion = version;
1208
+ return this;
1209
+ }
1168
1210
  /**
1169
- * Create a response interceptor that logs responses
1211
+ * Set the client info sent during initialization
1170
1212
  */
1171
- responseLogger(logFn = console.log) {
1172
- return (ctx) => {
1173
- const status = ctx.response.error ? "ERROR" : "OK";
1174
- logFn(`[MCP Response] ${ctx.request.method} ${status} (${ctx.durationMs}ms)`, ctx.response);
1175
- return { action: "passthrough" };
1176
- };
1177
- },
1213
+ withClientInfo(info) {
1214
+ this.config.clientInfo = info;
1215
+ return this;
1216
+ }
1178
1217
  /**
1179
- * Create a response interceptor that modifies specific responses
1218
+ * Set the platform type for testing platform-specific meta keys.
1219
+ * Automatically configures clientInfo and capabilities for platform detection.
1220
+ *
1221
+ * Platform-specific behavior:
1222
+ * - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
1223
+ * - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
1224
+ * - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
1225
+ * - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
1226
+ * - Other platforms follow similar patterns
1227
+ *
1228
+ * @example
1229
+ * ```typescript
1230
+ * const client = await McpTestClient.create({ baseUrl })
1231
+ * .withPlatform('openai')
1232
+ * .buildAndConnect();
1233
+ *
1234
+ * // ext-apps automatically sets the io.modelcontextprotocol/ui capability
1235
+ * const extAppsClient = await McpTestClient.create({ baseUrl })
1236
+ * .withPlatform('ext-apps')
1237
+ * .buildAndConnect();
1238
+ * ```
1180
1239
  */
1181
- modifyResponse(method, modifier) {
1182
- return (ctx) => {
1183
- if (ctx.request.method === method) {
1184
- return { action: "modify", response: modifier(ctx.response) };
1185
- }
1186
- return { action: "passthrough" };
1187
- };
1240
+ withPlatform(platform) {
1241
+ this.config.platform = platform;
1242
+ this.config.clientInfo = getPlatformClientInfo(platform);
1243
+ this.config.capabilities = getPlatformCapabilities(platform);
1244
+ return this;
1245
+ }
1246
+ /**
1247
+ * Set custom client capabilities for MCP initialization.
1248
+ * Use this for fine-grained control over capabilities sent during initialization.
1249
+ *
1250
+ * @example
1251
+ * ```typescript
1252
+ * const client = await McpTestClient.create({ baseUrl })
1253
+ * .withCapabilities({
1254
+ * sampling: {},
1255
+ * experimental: {
1256
+ * 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
1257
+ * }
1258
+ * })
1259
+ * .buildAndConnect();
1260
+ * ```
1261
+ */
1262
+ withCapabilities(capabilities) {
1263
+ this.config.capabilities = capabilities;
1264
+ return this;
1265
+ }
1266
+ /**
1267
+ * Set query parameters to append to the connection URL.
1268
+ * Useful for testing mode switches like `?mode=skills_only`.
1269
+ *
1270
+ * @example
1271
+ * ```typescript
1272
+ * const client = await McpTestClient.create({ baseUrl })
1273
+ * .withQueryParams({ mode: 'skills_only' })
1274
+ * .buildAndConnect();
1275
+ * ```
1276
+ */
1277
+ withQueryParams(params) {
1278
+ this.config.queryParams = { ...this.config.queryParams, ...params };
1279
+ return this;
1280
+ }
1281
+ /**
1282
+ * Build the McpTestClient instance (does not connect)
1283
+ */
1284
+ build() {
1285
+ return new McpTestClient(this.config);
1286
+ }
1287
+ /**
1288
+ * Build the McpTestClient and connect to the server
1289
+ */
1290
+ async buildAndConnect() {
1291
+ const client = this.build();
1292
+ await client.connect();
1293
+ return client;
1188
1294
  }
1189
1295
  };
1190
1296
 
1191
1297
  // libs/testing/src/client/mcp-test-client.ts
1192
1298
  var DEFAULT_TIMEOUT2 = 3e4;
1193
1299
  var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
1300
+ var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
1194
1301
  var DEFAULT_CLIENT_INFO = {
1195
1302
  name: "@frontmcp/testing",
1196
1303
  version: "0.4.0"
1197
1304
  };
1305
+ function extractEntryPaths(data) {
1306
+ let payload = data;
1307
+ if (typeof payload === "string") {
1308
+ try {
1309
+ payload = JSON.parse(payload);
1310
+ } catch {
1311
+ return void 0;
1312
+ }
1313
+ }
1314
+ if (!payload || typeof payload !== "object") return void 0;
1315
+ const paths = payload.entryPaths;
1316
+ if (!Array.isArray(paths)) return void 0;
1317
+ const strings = paths.filter((p) => typeof p === "string");
1318
+ return strings.length > 0 ? strings : void 0;
1319
+ }
1198
1320
  var McpTestClient = class {
1199
1321
  // Platform, capabilities, and queryParams are optional - only set when needed
1200
1322
  config;
@@ -1203,6 +1325,8 @@ var McpTestClient = class {
1203
1325
  requestIdCounter = 0;
1204
1326
  _lastRequestId = 0;
1205
1327
  _sessionId;
1328
+ /** #543 — discovery is attempted at most once per connect. */
1329
+ triedDiscoveredEntryPath = false;
1206
1330
  _sessionInfo = null;
1207
1331
  _authState = { isAnonymous: true, scopes: [] };
1208
1332
  // Logging and tracing
@@ -1210,6 +1334,8 @@ var McpTestClient = class {
1210
1334
  _traces = [];
1211
1335
  _notifications = [];
1212
1336
  _progressUpdates = [];
1337
+ collectingProgress = false;
1338
+ progressTokenCounter = 0;
1213
1339
  // Interceptor chain
1214
1340
  _interceptors;
1215
1341
  // Elicitation handler for server→client elicit requests
@@ -1218,8 +1344,14 @@ var McpTestClient = class {
1218
1344
  // CONSTRUCTOR & FACTORY
1219
1345
  // ═══════════════════════════════════════════════════════════════════
1220
1346
  constructor(config) {
1347
+ if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
1348
+ throw new Error(
1349
+ `McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
1350
+ );
1351
+ }
1221
1352
  this.config = {
1222
1353
  baseUrl: config.baseUrl,
1354
+ entryPath: config.entryPath,
1223
1355
  transport: config.transport ?? "streamable-http",
1224
1356
  auth: config.auth ?? {},
1225
1357
  publicMode: config.publicMode ?? false,
@@ -1255,11 +1387,13 @@ var McpTestClient = class {
1255
1387
  */
1256
1388
  async connect() {
1257
1389
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
1390
+ this.triedDiscoveredEntryPath = false;
1258
1391
  this.transport = this.createTransport();
1259
1392
  await this.transport.connect();
1260
1393
  const maxRetries = 3;
1261
1394
  const retryDelayMs = 500;
1262
1395
  let lastError;
1396
+ let reportedEntryPaths;
1263
1397
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1264
1398
  const initResponse = await this.initialize();
1265
1399
  if (initResponse.success && initResponse.data) {
@@ -1267,13 +1401,27 @@ var McpTestClient = class {
1267
1401
  break;
1268
1402
  }
1269
1403
  lastError = initResponse.error?.message ?? "Unknown error";
1404
+ reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
1405
+ if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
1406
+ this.triedDiscoveredEntryPath = true;
1407
+ const discovered = reportedEntryPaths[0];
1408
+ this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
1409
+ this.config.entryPath = discovered;
1410
+ await this.transport.close();
1411
+ this.transport = this.createTransport();
1412
+ await this.transport.connect();
1413
+ continue;
1414
+ }
1270
1415
  if (attempt < maxRetries) {
1271
1416
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
1272
1417
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
1273
1418
  }
1274
1419
  }
1275
1420
  if (!this.initResult) {
1276
- throw new Error(`Failed to initialize MCP connection after ${maxRetries} attempts: ${lastError}`);
1421
+ 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.` : "";
1422
+ throw new Error(
1423
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
1424
+ );
1277
1425
  }
1278
1426
  this._sessionId = this.transport.getSessionId();
1279
1427
  this._sessionInfo = {
@@ -1286,6 +1434,7 @@ var McpTestClient = class {
1286
1434
  jsonrpc: "2.0",
1287
1435
  method: "notifications/initialized"
1288
1436
  });
1437
+ await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
1289
1438
  this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
1290
1439
  return this.initResult;
1291
1440
  }
@@ -1396,8 +1545,8 @@ var McpTestClient = class {
1396
1545
  /**
1397
1546
  * Call a tool by name with arguments
1398
1547
  */
1399
- call: async (name, args) => {
1400
- const response = await this.callTool(name, args);
1548
+ call: async (name, args, options = {}) => {
1549
+ const response = await this.callTool(name, args, this.progressTokenFor(options));
1401
1550
  return this.wrapToolResult(response);
1402
1551
  }
1403
1552
  };
@@ -1539,6 +1688,7 @@ var McpTestClient = class {
1539
1688
  * Collect progress notifications specifically
1540
1689
  */
1541
1690
  collectProgress: () => {
1691
+ this.collectingProgress = true;
1542
1692
  return new ProgressCollector(this._progressUpdates);
1543
1693
  },
1544
1694
  /**
@@ -1844,12 +1994,29 @@ var McpTestClient = class {
1844
1994
  async listTools() {
1845
1995
  return this.request("tools/list", {});
1846
1996
  }
1847
- async callTool(name, args) {
1997
+ async callTool(name, args, progressToken) {
1848
1998
  return this.request("tools/call", {
1849
1999
  name,
1850
- arguments: args ?? {}
2000
+ arguments: args ?? {},
2001
+ ...progressToken !== void 0 && { _meta: { progressToken } }
1851
2002
  });
1852
2003
  }
2004
+ progressTokenFor(options) {
2005
+ if (options.progressToken !== void 0) return options.progressToken;
2006
+ return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
2007
+ }
2008
+ recordNotification = (notification) => {
2009
+ const timestamp = /* @__PURE__ */ new Date();
2010
+ this._notifications.push({ method: notification.method, params: notification.params, timestamp });
2011
+ if (notification.method !== "notifications/progress") return;
2012
+ const { progress, total, progressToken } = notification.params ?? {};
2013
+ this._progressUpdates.push({
2014
+ progress: Number(progress),
2015
+ total: typeof total === "number" ? total : void 0,
2016
+ progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
2017
+ timestamp
2018
+ });
2019
+ };
1853
2020
  async listResources() {
1854
2021
  return this.request("resources/list", {});
1855
2022
  }
@@ -1871,6 +2038,11 @@ var McpTestClient = class {
1871
2038
  // ═══════════════════════════════════════════════════════════════════
1872
2039
  // PRIVATE: TRANSPORT & REQUEST HELPERS
1873
2040
  // ═══════════════════════════════════════════════════════════════════
2041
+ /** The MCP path this client is currently pointed at, for log and error text. */
2042
+ mcpPathLabel() {
2043
+ const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
2044
+ return entry ? `/${entry}` : "/";
2045
+ }
1874
2046
  createTransport() {
1875
2047
  let baseUrl = this.config.baseUrl;
1876
2048
  if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
@@ -1884,13 +2056,15 @@ var McpTestClient = class {
1884
2056
  case "streamable-http":
1885
2057
  return new StreamableHttpTransport({
1886
2058
  baseUrl,
2059
+ entryPath: this.config.entryPath,
1887
2060
  timeout: this.config.timeout,
1888
2061
  auth: this.config.auth,
1889
2062
  publicMode: this.config.publicMode,
1890
2063
  debug: this.config.debug,
1891
2064
  interceptors: this._interceptors,
1892
2065
  clientInfo: this.config.clientInfo,
1893
- elicitationHandler: this._elicitationHandler
2066
+ elicitationHandler: this._elicitationHandler,
2067
+ notificationHandler: this.recordNotification
1894
2068
  });
1895
2069
  case "sse":
1896
2070
  throw new Error("SSE transport not yet implemented");
@@ -4770,13 +4944,17 @@ var currentConfig = {};
4770
4944
  var serverInstance = null;
4771
4945
  var tokenFactory = null;
4772
4946
  var serverStartedByUs = false;
4947
+ var skipScopes = [{ skipped: false }];
4948
+ function currentSkipScope() {
4949
+ return skipScopes[skipScopes.length - 1];
4950
+ }
4773
4951
  async function initializeSharedResources() {
4774
4952
  if (!tokenFactory) {
4775
4953
  const sharedSecret = currentConfig.env?.["JWT_SECRET"];
4776
4954
  tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
4777
4955
  }
4778
4956
  if (!serverInstance) {
4779
- if (currentConfig.baseUrl) {
4957
+ if (currentConfig.baseUrl && !currentConfig.server) {
4780
4958
  serverInstance = TestServer.connect(currentConfig.baseUrl);
4781
4959
  serverStartedByUs = false;
4782
4960
  } else if (currentConfig.server) {
@@ -4826,7 +5004,8 @@ async function createTestFixtures() {
4826
5004
  throw new Error("Token factory not initialized");
4827
5005
  }
4828
5006
  const clientInstance = McpTestClient.create({
4829
- baseUrl: serverInstance.info.baseUrl,
5007
+ baseUrl: resolveClientBaseUrl(serverInstance),
5008
+ entryPath: currentConfig.entryPath,
4830
5009
  transport: currentConfig.transport ?? "streamable-http",
4831
5010
  publicMode: currentConfig.publicMode
4832
5011
  }).build();
@@ -4875,6 +5054,9 @@ async function cleanupSharedResources() {
4875
5054
  tokenFactory = null;
4876
5055
  serverStartedByUs = false;
4877
5056
  }
5057
+ function resolveClientBaseUrl(server) {
5058
+ return currentConfig.baseUrl ?? server.info.baseUrl;
5059
+ }
4878
5060
  function createAuthFixture(factory) {
4879
5061
  const users = {
4880
5062
  admin: {
@@ -4924,7 +5106,8 @@ function createServerFixture(server) {
4924
5106
  info: server.info,
4925
5107
  createClient: async (opts) => {
4926
5108
  return McpTestClient.create({
4927
- baseUrl: server.info.baseUrl,
5109
+ baseUrl: resolveClientBaseUrl(server),
5110
+ entryPath: opts?.entryPath ?? currentConfig.entryPath,
4928
5111
  transport: opts?.transport ?? "streamable-http",
4929
5112
  auth: opts?.token ? { token: opts.token } : void 0,
4930
5113
  clientInfo: opts?.clientInfo,
@@ -4933,7 +5116,8 @@ function createServerFixture(server) {
4933
5116
  },
4934
5117
  createClientBuilder: () => {
4935
5118
  const builder = new McpTestClientBuilder({
4936
- baseUrl: server.info.baseUrl,
5119
+ baseUrl: resolveClientBaseUrl(server),
5120
+ entryPath: currentConfig.entryPath,
4937
5121
  publicMode: currentConfig.publicMode
4938
5122
  });
4939
5123
  return builder;
@@ -4949,8 +5133,8 @@ function resolveServerCommand(server) {
4949
5133
  }
4950
5134
  return `npx tsx ${server}`;
4951
5135
  }
4952
- function testWithFixtures(name, fn) {
4953
- it2(name, async () => {
5136
+ function runWithFixtures(fn) {
5137
+ return async () => {
4954
5138
  const fixtures = await createTestFixtures();
4955
5139
  let testFailed = false;
4956
5140
  try {
@@ -4961,7 +5145,15 @@ function testWithFixtures(name, fn) {
4961
5145
  } finally {
4962
5146
  await cleanupTestFixtures(fixtures, testFailed);
4963
5147
  }
4964
- });
5148
+ };
5149
+ }
5150
+ function testWithFixtures(name, fn) {
5151
+ const scope = currentSkipScope();
5152
+ if (scope.skipped) {
5153
+ it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
5154
+ return;
5155
+ }
5156
+ it2(name, runWithFixtures(fn));
4965
5157
  }
4966
5158
  function use(config) {
4967
5159
  currentConfig = { ...currentConfig, ...config };
@@ -4969,40 +5161,60 @@ function use(config) {
4969
5161
  await cleanupSharedResources();
4970
5162
  });
4971
5163
  }
4972
- function skip(name, fn) {
4973
- it2.skip(name, async () => {
4974
- const fixtures = await createTestFixtures();
4975
- let testFailed = false;
4976
- try {
4977
- await fn(fixtures);
4978
- } catch (error) {
4979
- testFailed = true;
4980
- throw error;
4981
- } finally {
4982
- await cleanupTestFixtures(fixtures, testFailed);
5164
+ function skip(nameOrCondition, fnOrReason) {
5165
+ if (typeof nameOrCondition === "boolean") {
5166
+ if (nameOrCondition) {
5167
+ const scope = currentSkipScope();
5168
+ scope.skipped = true;
5169
+ scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
4983
5170
  }
4984
- });
5171
+ return;
5172
+ }
5173
+ if (typeof fnOrReason !== "function") {
5174
+ throw new TypeError(
5175
+ `test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
5176
+ );
5177
+ }
5178
+ it2.skip(nameOrCondition, runWithFixtures(fnOrReason));
4985
5179
  }
4986
5180
  function only(name, fn) {
4987
- it2.only(name, async () => {
4988
- const fixtures = await createTestFixtures();
4989
- let testFailed = false;
4990
- try {
4991
- await fn(fixtures);
4992
- } catch (error) {
4993
- testFailed = true;
4994
- throw error;
4995
- } finally {
4996
- await cleanupTestFixtures(fixtures, testFailed);
4997
- }
4998
- });
5181
+ const scope = currentSkipScope();
5182
+ if (scope.skipped) {
5183
+ it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
5184
+ return;
5185
+ }
5186
+ it2.only(name, runWithFixtures(fn));
4999
5187
  }
5000
5188
  function todo(name) {
5001
5189
  it2.todo(name);
5002
5190
  }
5191
+ function withSkipScope(register) {
5192
+ return (name, fn) => {
5193
+ register(name, () => {
5194
+ const parent = currentSkipScope();
5195
+ skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
5196
+ try {
5197
+ fn();
5198
+ } finally {
5199
+ skipScopes.pop();
5200
+ }
5201
+ });
5202
+ };
5203
+ }
5204
+ function eachWithSkipScope(each) {
5205
+ return ((...eachArgs) => {
5206
+ const register = each(...eachArgs);
5207
+ return withSkipScope(register);
5208
+ });
5209
+ }
5210
+ var describeWithSkipScope = Object.assign(withSkipScope(describe2), describe2, {
5211
+ only: Object.assign(withSkipScope(describe2.only), { each: eachWithSkipScope(describe2.only.each) }),
5212
+ skip: Object.assign(withSkipScope(describe2.skip), { each: eachWithSkipScope(describe2.skip.each) }),
5213
+ each: eachWithSkipScope(describe2.each)
5214
+ });
5003
5215
  var test = testWithFixtures;
5004
5216
  test.use = use;
5005
- test.describe = describe2;
5217
+ test.describe = describeWithSkipScope;
5006
5218
  test.beforeAll = beforeAll2;
5007
5219
  test.beforeEach = beforeEach2;
5008
5220
  test.afterEach = afterEach2;
@@ -5395,18 +5607,17 @@ var toBeError = function(received, expectedCode) {
5395
5607
  message: () => `Expected a result wrapper object with isError property`
5396
5608
  };
5397
5609
  }
5398
- let pass = result.isError;
5399
- if (pass && expectedCode !== void 0) {
5400
- pass = result.error?.code === expectedCode;
5401
- }
5610
+ const receivedCode = typeof expectedCode === "string" ? result.raw?._meta?.["code"] : result.error?.code;
5611
+ const codeMatches = expectedCode === void 0 || receivedCode === expectedCode;
5612
+ const pass = result.isError && codeMatches;
5402
5613
  return {
5403
5614
  pass,
5404
5615
  message: () => {
5405
5616
  if (!result.isError) {
5406
5617
  return "Expected result to be an error, but it was successful";
5407
5618
  }
5408
- if (expectedCode !== void 0 && result.error?.code !== expectedCode) {
5409
- return `Expected error code ${expectedCode}, but got ${result.error?.code}`;
5619
+ if (!codeMatches) {
5620
+ return `Expected error code ${expectedCode}, but got ${String(receivedCode)}`;
5410
5621
  }
5411
5622
  return "Expected result not to be an error";
5412
5623
  }