@frontmcp/testing 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/auth/index.d.ts +2 -0
  2. package/auth/index.d.ts.map +1 -1
  3. package/auth/mock-cimd-server.d.ts +174 -0
  4. package/auth/mock-cimd-server.d.ts.map +1 -0
  5. package/auth/mock-oauth-server.d.ts +136 -6
  6. package/auth/mock-oauth-server.d.ts.map +1 -1
  7. package/auth/token-factory.d.ts.map +1 -1
  8. package/client/index.d.ts +1 -1
  9. package/client/index.d.ts.map +1 -1
  10. package/client/mcp-test-client.builder.d.ts +12 -0
  11. package/client/mcp-test-client.builder.d.ts.map +1 -1
  12. package/client/mcp-test-client.d.ts +48 -2
  13. package/client/mcp-test-client.d.ts.map +1 -1
  14. package/client/mcp-test-client.types.d.ts +60 -0
  15. package/client/mcp-test-client.types.d.ts.map +1 -1
  16. package/esm/fixtures/index.mjs +661 -83
  17. package/esm/index.mjs +3245 -219
  18. package/esm/package.json +5 -4
  19. package/esm/perf/index.mjs +4334 -0
  20. package/esm/perf/perf-setup.mjs +31 -0
  21. package/fixtures/fixture-types.d.ts +10 -1
  22. package/fixtures/fixture-types.d.ts.map +1 -1
  23. package/fixtures/index.js +661 -93
  24. package/fixtures/test-fixture.d.ts +1 -1
  25. package/fixtures/test-fixture.d.ts.map +1 -1
  26. package/index.d.ts +5 -1
  27. package/index.d.ts.map +1 -1
  28. package/index.js +3271 -219
  29. package/interceptor/interceptor-chain.d.ts +1 -0
  30. package/interceptor/interceptor-chain.d.ts.map +1 -1
  31. package/package.json +5 -4
  32. package/perf/baseline-store.d.ts +67 -0
  33. package/perf/baseline-store.d.ts.map +1 -0
  34. package/perf/index.d.ts +44 -0
  35. package/perf/index.d.ts.map +1 -0
  36. package/perf/index.js +4404 -0
  37. package/perf/jest-perf-reporter.d.ts +6 -0
  38. package/perf/jest-perf-reporter.d.ts.map +1 -0
  39. package/perf/leak-detector.d.ts +81 -0
  40. package/perf/leak-detector.d.ts.map +1 -0
  41. package/perf/metrics-collector.d.ts +83 -0
  42. package/perf/metrics-collector.d.ts.map +1 -0
  43. package/perf/perf-fixtures.d.ts +107 -0
  44. package/perf/perf-fixtures.d.ts.map +1 -0
  45. package/perf/perf-setup.d.ts +9 -0
  46. package/perf/perf-setup.d.ts.map +1 -0
  47. package/perf/perf-setup.js +50 -0
  48. package/perf/perf-test.d.ts +69 -0
  49. package/perf/perf-test.d.ts.map +1 -0
  50. package/perf/regression-detector.d.ts +55 -0
  51. package/perf/regression-detector.d.ts.map +1 -0
  52. package/perf/report-generator.d.ts +66 -0
  53. package/perf/report-generator.d.ts.map +1 -0
  54. package/perf/types.d.ts +439 -0
  55. package/perf/types.d.ts.map +1 -0
  56. package/platform/platform-client-info.d.ts +18 -0
  57. package/platform/platform-client-info.d.ts.map +1 -1
  58. package/server/index.d.ts +2 -0
  59. package/server/index.d.ts.map +1 -1
  60. package/server/port-registry.d.ts +179 -0
  61. package/server/port-registry.d.ts.map +1 -0
  62. package/server/test-server.d.ts +9 -5
  63. package/server/test-server.d.ts.map +1 -1
  64. package/transport/streamable-http.transport.d.ts +26 -0
  65. package/transport/streamable-http.transport.d.ts.map +1 -1
  66. package/transport/transport.interface.d.ts +9 -1
  67. package/transport/transport.interface.d.ts.map +1 -1
@@ -52,7 +52,12 @@ function getPlatformClientInfo(platform) {
52
52
  }
53
53
  function getPlatformCapabilities(platform) {
54
54
  const baseCapabilities = {
55
- sampling: {}
55
+ sampling: {},
56
+ // Include elicitation.form by default for testing elicitation workflows
57
+ // Note: MCP SDK expects form to be an object, not boolean
58
+ elicitation: {
59
+ form: {}
60
+ }
56
61
  };
57
62
  if (platform === "ext-apps") {
58
63
  return {
@@ -190,6 +195,21 @@ var McpTestClientBuilder = class {
190
195
  this.config.capabilities = capabilities;
191
196
  return this;
192
197
  }
198
+ /**
199
+ * Set query parameters to append to the connection URL.
200
+ * Useful for testing mode switches like `?mode=skills_only`.
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * const client = await McpTestClient.create({ baseUrl })
205
+ * .withQueryParams({ mode: 'skills_only' })
206
+ * .buildAndConnect();
207
+ * ```
208
+ */
209
+ withQueryParams(params) {
210
+ this.config.queryParams = { ...this.config.queryParams, ...params };
211
+ return this;
212
+ }
193
213
  /**
194
214
  * Build the McpTestClient instance (does not connect)
195
215
  */
@@ -218,6 +238,7 @@ var StreamableHttpTransport = class {
218
238
  lastRequestHeaders = {};
219
239
  interceptors;
220
240
  publicMode;
241
+ elicitationHandler;
221
242
  constructor(config) {
222
243
  this.config = {
223
244
  baseUrl: config.baseUrl.replace(/\/$/, ""),
@@ -232,6 +253,7 @@ var StreamableHttpTransport = class {
232
253
  this.authToken = config.auth?.token;
233
254
  this.interceptors = config.interceptors;
234
255
  this.publicMode = config.publicMode ?? false;
256
+ this.elicitationHandler = config.elicitationHandler;
235
257
  }
236
258
  async connect() {
237
259
  this.state = "connecting";
@@ -324,7 +346,6 @@ var StreamableHttpTransport = class {
324
346
  body: JSON.stringify(message),
325
347
  signal: controller.signal
326
348
  });
327
- clearTimeout(timeoutId);
328
349
  const newSessionId = response.headers.get("mcp-session-id");
329
350
  if (newSessionId) {
330
351
  this.sessionId = newSessionId;
@@ -344,28 +365,26 @@ var StreamableHttpTransport = class {
344
365
  };
345
366
  } else {
346
367
  const contentType = response.headers.get("content-type") ?? "";
347
- const text = await response.text();
348
- this.log("Response:", text);
349
- if (!text.trim()) {
350
- jsonResponse = {
351
- jsonrpc: "2.0",
352
- id: message.id ?? null,
353
- result: void 0
354
- };
355
- } else if (contentType.includes("text/event-stream")) {
356
- const { response: sseResponse, sseSessionId } = this.parseSSEResponseWithSession(text, message.id);
357
- jsonResponse = sseResponse;
358
- if (sseSessionId && !this.sessionId) {
359
- this.sessionId = sseSessionId;
360
- this.log("Session ID from SSE:", this.sessionId);
361
- }
368
+ if (contentType.includes("text/event-stream")) {
369
+ jsonResponse = await this.handleSSEResponseWithElicitation(response, message);
362
370
  } else {
363
- jsonResponse = JSON.parse(text);
371
+ const text = await response.text();
372
+ this.log("Response:", text);
373
+ if (!text.trim()) {
374
+ jsonResponse = {
375
+ jsonrpc: "2.0",
376
+ id: message.id ?? null,
377
+ result: void 0
378
+ };
379
+ } else {
380
+ jsonResponse = JSON.parse(text);
381
+ }
364
382
  }
365
383
  }
366
384
  if (this.interceptors) {
367
385
  jsonResponse = await this.interceptors.processResponse(message, jsonResponse, Date.now() - startTime);
368
386
  }
387
+ clearTimeout(timeoutId);
369
388
  return jsonResponse;
370
389
  } catch (error) {
371
390
  clearTimeout(timeoutId);
@@ -480,6 +499,9 @@ var StreamableHttpTransport = class {
480
499
  getInterceptors() {
481
500
  return this.interceptors;
482
501
  }
502
+ setElicitationHandler(handler) {
503
+ this.elicitationHandler = handler;
504
+ }
483
505
  getConnectionCount() {
484
506
  return this.connectionCount;
485
507
  }
@@ -508,6 +530,215 @@ var StreamableHttpTransport = class {
508
530
  // ═══════════════════════════════════════════════════════════════════
509
531
  // PRIVATE HELPERS
510
532
  // ═══════════════════════════════════════════════════════════════════
533
+ /**
534
+ * Handle SSE response with elicitation support.
535
+ *
536
+ * Streams the SSE response, detects elicitation/create requests, and handles them
537
+ * by calling the registered handler and sending the response back to the server.
538
+ */
539
+ async handleSSEResponseWithElicitation(response, originalRequest) {
540
+ this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
541
+ const reader = response.body?.getReader();
542
+ if (!reader) {
543
+ this.log("handleSSEResponseWithElicitation: no response body");
544
+ return {
545
+ jsonrpc: "2.0",
546
+ id: originalRequest.id ?? null,
547
+ error: { code: -32e3, message: "No response body" }
548
+ };
549
+ }
550
+ const decoder = new TextDecoder();
551
+ let buffer = "";
552
+ let finalResponse = null;
553
+ let sseSessionId;
554
+ try {
555
+ let readCount = 0;
556
+ while (true) {
557
+ readCount++;
558
+ this.log(`handleSSEResponseWithElicitation: reading chunk ${readCount}`);
559
+ const { done, value } = await reader.read();
560
+ this.log(`handleSSEResponseWithElicitation: read result`, { done, valueLength: value?.length });
561
+ if (done) {
562
+ if (buffer.trim()) {
563
+ const parsed = this.parseSSEEvents(buffer, originalRequest.id);
564
+ for (const event of parsed.events) {
565
+ const handled = await this.handleSSEEvent(event);
566
+ if (handled.isFinal) {
567
+ finalResponse = handled.response;
568
+ }
569
+ }
570
+ if (parsed.sessionId && !sseSessionId) {
571
+ sseSessionId = parsed.sessionId;
572
+ }
573
+ }
574
+ break;
575
+ }
576
+ buffer += decoder.decode(value, { stream: true });
577
+ const eventEndPattern = /\n\n/g;
578
+ let lastEventEnd = 0;
579
+ let match;
580
+ while ((match = eventEndPattern.exec(buffer)) !== null) {
581
+ const eventText = buffer.slice(lastEventEnd, match.index);
582
+ lastEventEnd = match.index + 2;
583
+ if (eventText.trim()) {
584
+ const parsed = this.parseSSEEvents(eventText, originalRequest.id);
585
+ for (const event of parsed.events) {
586
+ const handled = await this.handleSSEEvent(event);
587
+ if (handled.isFinal) {
588
+ finalResponse = handled.response;
589
+ }
590
+ }
591
+ if (parsed.sessionId && !sseSessionId) {
592
+ sseSessionId = parsed.sessionId;
593
+ }
594
+ }
595
+ }
596
+ buffer = buffer.slice(lastEventEnd);
597
+ }
598
+ } finally {
599
+ reader.releaseLock();
600
+ }
601
+ if (sseSessionId && !this.sessionId) {
602
+ this.sessionId = sseSessionId;
603
+ this.log("Session ID from SSE:", this.sessionId);
604
+ }
605
+ if (finalResponse) {
606
+ return finalResponse;
607
+ }
608
+ return {
609
+ jsonrpc: "2.0",
610
+ id: originalRequest.id ?? null,
611
+ error: { code: -32e3, message: "No final response received in SSE stream" }
612
+ };
613
+ }
614
+ /**
615
+ * Parse SSE event text into structured events
616
+ */
617
+ parseSSEEvents(text, _requestId) {
618
+ const lines = text.split("\n");
619
+ const events = [];
620
+ let currentEvent = { type: "message", data: [] };
621
+ let sessionId;
622
+ for (const line of lines) {
623
+ if (line.startsWith("event: ")) {
624
+ currentEvent.type = line.slice(7);
625
+ } else if (line.startsWith("data: ")) {
626
+ currentEvent.data.push(line.slice(6));
627
+ } else if (line === "data:") {
628
+ currentEvent.data.push("");
629
+ } else if (line.startsWith("id: ")) {
630
+ const idValue = line.slice(4);
631
+ currentEvent.id = idValue;
632
+ const colonIndex = idValue.lastIndexOf(":");
633
+ if (colonIndex > 0) {
634
+ sessionId = idValue.substring(0, colonIndex);
635
+ } else {
636
+ sessionId = idValue;
637
+ }
638
+ } else if (line === "" && currentEvent.data.length > 0) {
639
+ events.push({
640
+ type: currentEvent.type,
641
+ data: currentEvent.data.join("\n"),
642
+ id: currentEvent.id
643
+ });
644
+ currentEvent = { type: "message", data: [] };
645
+ }
646
+ }
647
+ if (currentEvent.data.length > 0) {
648
+ events.push({
649
+ type: currentEvent.type,
650
+ data: currentEvent.data.join("\n"),
651
+ id: currentEvent.id
652
+ });
653
+ }
654
+ return { events, sessionId };
655
+ }
656
+ /**
657
+ * Handle a single SSE event, including elicitation requests
658
+ */
659
+ async handleSSEEvent(event) {
660
+ this.log("SSE Event:", { type: event.type, data: event.data.slice(0, 200) });
661
+ try {
662
+ const parsed = JSON.parse(event.data);
663
+ if ("method" in parsed && parsed.method === "elicitation/create") {
664
+ await this.handleElicitationRequest(parsed);
665
+ return {
666
+ isFinal: false,
667
+ response: { jsonrpc: "2.0", id: null, result: void 0 }
668
+ };
669
+ }
670
+ if ("result" in parsed || "error" in parsed) {
671
+ return { isFinal: true, response: parsed };
672
+ }
673
+ return {
674
+ isFinal: false,
675
+ response: { jsonrpc: "2.0", id: null, result: void 0 }
676
+ };
677
+ } catch {
678
+ this.log("Failed to parse SSE event data:", event.data);
679
+ return {
680
+ isFinal: false,
681
+ response: { jsonrpc: "2.0", id: null, result: void 0 }
682
+ };
683
+ }
684
+ }
685
+ /**
686
+ * Handle an elicitation/create request from the server
687
+ */
688
+ async handleElicitationRequest(request) {
689
+ const params = request.params;
690
+ this.log("Elicitation request received:", {
691
+ mode: params?.mode,
692
+ message: params?.message?.slice(0, 100)
693
+ });
694
+ const requestId = request.id;
695
+ if (requestId === void 0 || requestId === null) {
696
+ this.log("Elicitation request has no ID, cannot respond");
697
+ return;
698
+ }
699
+ if (!this.elicitationHandler) {
700
+ this.log("No elicitation handler registered, sending error");
701
+ await this.sendElicitationResponse(requestId, {
702
+ action: "decline"
703
+ });
704
+ return;
705
+ }
706
+ try {
707
+ const response = await this.elicitationHandler(params);
708
+ this.log("Elicitation handler response:", response);
709
+ await this.sendElicitationResponse(requestId, response);
710
+ } catch (error) {
711
+ this.log("Elicitation handler error:", error);
712
+ await this.sendElicitationResponse(requestId, {
713
+ action: "cancel"
714
+ });
715
+ }
716
+ }
717
+ /**
718
+ * Send an elicitation response back to the server
719
+ */
720
+ async sendElicitationResponse(requestId, response) {
721
+ const headers = this.buildHeaders();
722
+ const url = `${this.config.baseUrl}/`;
723
+ const rpcResponse = {
724
+ jsonrpc: "2.0",
725
+ id: requestId,
726
+ result: response
727
+ };
728
+ this.log("Sending elicitation response:", rpcResponse);
729
+ try {
730
+ const fetchResponse = await fetch(url, {
731
+ method: "POST",
732
+ headers,
733
+ body: JSON.stringify(rpcResponse)
734
+ });
735
+ if (!fetchResponse.ok) {
736
+ this.log(`Elicitation response HTTP error: ${fetchResponse.status}`);
737
+ }
738
+ } catch (error) {
739
+ this.log("Failed to send elicitation response:", error);
740
+ }
741
+ }
511
742
  buildHeaders() {
512
743
  const headers = {
513
744
  "Content-Type": "application/json",
@@ -875,7 +1106,7 @@ var DEFAULT_CLIENT_INFO = {
875
1106
  version: "0.4.0"
876
1107
  };
877
1108
  var McpTestClient = class {
878
- // Platform and capabilities are optional - only set when testing platform-specific behavior
1109
+ // Platform, capabilities, and queryParams are optional - only set when needed
879
1110
  config;
880
1111
  transport = null;
881
1112
  initResult = null;
@@ -891,6 +1122,8 @@ var McpTestClient = class {
891
1122
  _progressUpdates = [];
892
1123
  // Interceptor chain
893
1124
  _interceptors;
1125
+ // Elicitation handler for server→client elicit requests
1126
+ _elicitationHandler;
894
1127
  // ═══════════════════════════════════════════════════════════════════
895
1128
  // CONSTRUCTOR & FACTORY
896
1129
  // ═══════════════════════════════════════════════════════════════════
@@ -905,7 +1138,8 @@ var McpTestClient = class {
905
1138
  protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
906
1139
  clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
907
1140
  platform: config.platform,
908
- capabilities: config.capabilities
1141
+ capabilities: config.capabilities,
1142
+ queryParams: config.queryParams
909
1143
  };
910
1144
  if (config.auth?.token) {
911
1145
  this._authState = {
@@ -1138,9 +1372,9 @@ var McpTestClient = class {
1138
1372
  * Send any JSON-RPC request
1139
1373
  */
1140
1374
  request: async (message) => {
1141
- this.ensureConnected();
1375
+ const transport = this.getConnectedTransport();
1142
1376
  const start = Date.now();
1143
- const response = await this.transport.request(message);
1377
+ const response = await transport.request(message);
1144
1378
  this.traceRequest(message.method, message.params, message.id, response, Date.now() - start);
1145
1379
  return response;
1146
1380
  },
@@ -1148,15 +1382,15 @@ var McpTestClient = class {
1148
1382
  * Send a notification (no response expected)
1149
1383
  */
1150
1384
  notify: async (message) => {
1151
- this.ensureConnected();
1152
- await this.transport.notify(message);
1385
+ const transport = this.getConnectedTransport();
1386
+ await transport.notify(message);
1153
1387
  },
1154
1388
  /**
1155
1389
  * Send raw string data (for error testing)
1156
1390
  */
1157
1391
  sendRaw: async (data) => {
1158
- this.ensureConnected();
1159
- return this.transport.sendRaw(data);
1392
+ const transport = this.getConnectedTransport();
1393
+ return transport.sendRaw(data);
1160
1394
  }
1161
1395
  };
1162
1396
  get lastRequestId() {
@@ -1212,6 +1446,63 @@ var McpTestClient = class {
1212
1446
  }
1213
1447
  };
1214
1448
  // ═══════════════════════════════════════════════════════════════════
1449
+ // ELICITATION
1450
+ // ═══════════════════════════════════════════════════════════════════
1451
+ /**
1452
+ * Register a handler for elicitation requests from the server.
1453
+ *
1454
+ * When a tool calls `this.elicit()` during execution, the server sends an
1455
+ * `elicitation/create` request to the client. This handler is called to
1456
+ * provide the response that would normally come from user interaction.
1457
+ *
1458
+ * @param handler - Function that receives the elicitation request and returns a response
1459
+ *
1460
+ * @example
1461
+ * ```typescript
1462
+ * // Simple acceptance
1463
+ * mcp.onElicitation(async () => ({
1464
+ * action: 'accept',
1465
+ * content: { confirmed: true }
1466
+ * }));
1467
+ *
1468
+ * // Conditional response based on request
1469
+ * mcp.onElicitation(async (request) => {
1470
+ * if (request.message.includes('delete')) {
1471
+ * return { action: 'decline' };
1472
+ * }
1473
+ * return { action: 'accept', content: { approved: true } };
1474
+ * });
1475
+ *
1476
+ * // Multi-step wizard
1477
+ * let step = 0;
1478
+ * mcp.onElicitation(async () => {
1479
+ * step++;
1480
+ * if (step === 1) return { action: 'accept', content: { name: 'Alice' } };
1481
+ * return { action: 'accept', content: { color: 'blue' } };
1482
+ * });
1483
+ * ```
1484
+ */
1485
+ onElicitation(handler) {
1486
+ this._elicitationHandler = handler;
1487
+ if (this.transport?.setElicitationHandler) {
1488
+ this.transport.setElicitationHandler(handler);
1489
+ }
1490
+ this.log("debug", "Elicitation handler registered");
1491
+ }
1492
+ /**
1493
+ * Clear the elicitation handler.
1494
+ *
1495
+ * After calling this, elicitation requests from the server will not be
1496
+ * handled automatically. This can be used to test timeout scenarios.
1497
+ */
1498
+ clearElicitationHandler() {
1499
+ this._elicitationHandler = void 0;
1500
+ if (this.transport?.setElicitationHandler) {
1501
+ this.transport.setElicitationHandler(void 0);
1502
+ }
1503
+ this.log("debug", "Elicitation handler cleared");
1504
+ }
1505
+ // ═══════════════════════════════════════════════════════════════════
1215
1506
  // LOGGING & DEBUGGING
1216
1507
  // ═══════════════════════════════════════════════════════════════════
1217
1508
  logs = {
@@ -1436,7 +1727,10 @@ var McpTestClient = class {
1436
1727
  // ═══════════════════════════════════════════════════════════════════
1437
1728
  async initialize() {
1438
1729
  const capabilities = this.config.capabilities ?? {
1439
- sampling: {}
1730
+ sampling: {},
1731
+ elicitation: {
1732
+ form: {}
1733
+ }
1440
1734
  };
1441
1735
  return this.request("initialize", {
1442
1736
  protocolVersion: this.config.protocolVersion,
@@ -1475,16 +1769,25 @@ var McpTestClient = class {
1475
1769
  // PRIVATE: TRANSPORT & REQUEST HELPERS
1476
1770
  // ═══════════════════════════════════════════════════════════════════
1477
1771
  createTransport() {
1772
+ let baseUrl = this.config.baseUrl;
1773
+ if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
1774
+ const url = new URL(baseUrl);
1775
+ Object.entries(this.config.queryParams).forEach(([key, value]) => {
1776
+ url.searchParams.set(key, String(value));
1777
+ });
1778
+ baseUrl = url.toString();
1779
+ }
1478
1780
  switch (this.config.transport) {
1479
1781
  case "streamable-http":
1480
1782
  return new StreamableHttpTransport({
1481
- baseUrl: this.config.baseUrl,
1783
+ baseUrl,
1482
1784
  timeout: this.config.timeout,
1483
1785
  auth: this.config.auth,
1484
1786
  publicMode: this.config.publicMode,
1485
1787
  debug: this.config.debug,
1486
1788
  interceptors: this._interceptors,
1487
- clientInfo: this.config.clientInfo
1789
+ clientInfo: this.config.clientInfo,
1790
+ elicitationHandler: this._elicitationHandler
1488
1791
  });
1489
1792
  case "sse":
1490
1793
  throw new Error("SSE transport not yet implemented");
@@ -1493,12 +1796,12 @@ var McpTestClient = class {
1493
1796
  }
1494
1797
  }
1495
1798
  async request(method, params) {
1496
- this.ensureConnected();
1799
+ const transport = this.getConnectedTransport();
1497
1800
  const id = ++this.requestIdCounter;
1498
1801
  this._lastRequestId = id;
1499
1802
  const start = Date.now();
1500
1803
  try {
1501
- const response = await this.transport.request({
1804
+ const response = await transport.request({
1502
1805
  jsonrpc: "2.0",
1503
1806
  id,
1504
1807
  method,
@@ -1537,10 +1840,14 @@ var McpTestClient = class {
1537
1840
  };
1538
1841
  }
1539
1842
  }
1540
- ensureConnected() {
1541
- if (!this.transport?.isConnected()) {
1843
+ /**
1844
+ * Get the transport, throwing if not connected.
1845
+ */
1846
+ getConnectedTransport() {
1847
+ if (!this.transport || !this.transport.isConnected()) {
1542
1848
  throw new Error("Not connected to MCP server. Call connect() first.");
1543
1849
  }
1850
+ return this.transport;
1544
1851
  }
1545
1852
  updateSessionActivity() {
1546
1853
  if (this._sessionInfo) {
@@ -1793,6 +2100,9 @@ var TestTokenFactory = class {
1793
2100
  scope: options.scopes?.join(" "),
1794
2101
  ...options.claims
1795
2102
  };
2103
+ if (!this.privateKey) {
2104
+ throw new Error("Private key not initialized");
2105
+ }
1796
2106
  const token = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
1797
2107
  return token;
1798
2108
  }
@@ -1852,6 +2162,9 @@ var TestTokenFactory = class {
1852
2162
  exp: now - 3600
1853
2163
  // Expired 1 hour ago
1854
2164
  };
2165
+ if (!this.privateKey) {
2166
+ throw new Error("Private key not initialized");
2167
+ }
1855
2168
  const token = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
1856
2169
  return token;
1857
2170
  }
@@ -1878,6 +2191,9 @@ var TestTokenFactory = class {
1878
2191
  */
1879
2192
  async getPublicJwks() {
1880
2193
  await this.ensureKeys();
2194
+ if (!this.jwk) {
2195
+ throw new Error("JWK not initialized");
2196
+ }
1881
2197
  return {
1882
2198
  keys: [this.jwk]
1883
2199
  };
@@ -1898,21 +2214,195 @@ var TestTokenFactory = class {
1898
2214
 
1899
2215
  // libs/testing/src/server/test-server.ts
1900
2216
  import { spawn } from "child_process";
2217
+
2218
+ // libs/testing/src/errors/index.ts
2219
+ var TestClientError = class extends Error {
2220
+ constructor(message) {
2221
+ super(message);
2222
+ this.name = "TestClientError";
2223
+ Object.setPrototypeOf(this, new.target.prototype);
2224
+ }
2225
+ };
2226
+ var ServerStartError = class extends TestClientError {
2227
+ constructor(message, cause) {
2228
+ super(message);
2229
+ this.cause = cause;
2230
+ this.name = "ServerStartError";
2231
+ Object.setPrototypeOf(this, new.target.prototype);
2232
+ }
2233
+ };
2234
+
2235
+ // libs/testing/src/server/port-registry.ts
2236
+ import { createServer } from "net";
2237
+ var E2E_PORT_RANGES = {
2238
+ // Core E2E tests (50000-50099)
2239
+ "demo-e2e-public": { start: 5e4, size: 10 },
2240
+ "demo-e2e-cache": { start: 50010, size: 10 },
2241
+ "demo-e2e-config": { start: 50020, size: 10 },
2242
+ "demo-e2e-direct": { start: 50030, size: 10 },
2243
+ "demo-e2e-errors": { start: 50040, size: 10 },
2244
+ "demo-e2e-hooks": { start: 50050, size: 10 },
2245
+ "demo-e2e-multiapp": { start: 50060, size: 10 },
2246
+ "demo-e2e-notifications": { start: 50070, size: 10 },
2247
+ "demo-e2e-providers": { start: 50080, size: 10 },
2248
+ "demo-e2e-standalone": { start: 50090, size: 10 },
2249
+ // Auth E2E tests (50100-50199)
2250
+ "demo-e2e-orchestrated": { start: 50100, size: 10 },
2251
+ "demo-e2e-transparent": { start: 50110, size: 10 },
2252
+ "demo-e2e-cimd": { start: 50120, size: 10 },
2253
+ // Feature E2E tests (50200-50299)
2254
+ "demo-e2e-skills": { start: 50200, size: 10 },
2255
+ "demo-e2e-remote": { start: 50210, size: 10 },
2256
+ "demo-e2e-openapi": { start: 50220, size: 10 },
2257
+ "demo-e2e-ui": { start: 50230, size: 10 },
2258
+ "demo-e2e-codecall": { start: 50240, size: 10 },
2259
+ "demo-e2e-remember": { start: 50250, size: 10 },
2260
+ "demo-e2e-elicitation": { start: 50260, size: 10 },
2261
+ "demo-e2e-agents": { start: 50270, size: 10 },
2262
+ "demo-e2e-transport-recreation": { start: 50280, size: 10 },
2263
+ // Infrastructure E2E tests (50300-50399)
2264
+ "demo-e2e-redis": { start: 50300, size: 10 },
2265
+ "demo-e2e-serverless": { start: 50310, size: 10 },
2266
+ // Mock servers and utilities (50900-50999)
2267
+ "mock-oauth": { start: 50900, size: 10 },
2268
+ "mock-api": { start: 50910, size: 10 },
2269
+ "mock-cimd": { start: 50920, size: 10 },
2270
+ // Dynamic/unknown projects (51000+)
2271
+ default: { start: 51e3, size: 100 }
2272
+ };
2273
+ var reservedPorts = /* @__PURE__ */ new Map();
2274
+ var projectPortIndex = /* @__PURE__ */ new Map();
2275
+ function getPortRange(project) {
2276
+ const key = project;
2277
+ if (key in E2E_PORT_RANGES) {
2278
+ return E2E_PORT_RANGES[key];
2279
+ }
2280
+ return E2E_PORT_RANGES.default;
2281
+ }
2282
+ async function reservePort(project, preferredPort) {
2283
+ const range = getPortRange(project);
2284
+ if (preferredPort !== void 0) {
2285
+ const reservation = await tryReservePort(preferredPort, project);
2286
+ if (reservation) {
2287
+ return {
2288
+ port: preferredPort,
2289
+ release: async () => {
2290
+ await releasePort(preferredPort);
2291
+ }
2292
+ };
2293
+ }
2294
+ console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
2295
+ }
2296
+ let index = projectPortIndex.get(project) ?? 0;
2297
+ const maxAttempts = range.size;
2298
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2299
+ const port = range.start + index % range.size;
2300
+ index = (index + 1) % range.size;
2301
+ if (reservedPorts.has(port)) {
2302
+ continue;
2303
+ }
2304
+ const reservation = await tryReservePort(port, project);
2305
+ if (reservation) {
2306
+ projectPortIndex.set(project, index);
2307
+ return {
2308
+ port,
2309
+ release: async () => {
2310
+ await releasePort(port);
2311
+ }
2312
+ };
2313
+ }
2314
+ }
2315
+ const dynamicPort = await findAvailablePortInRange(51e3, 52e3);
2316
+ if (dynamicPort) {
2317
+ const reservation = await tryReservePort(dynamicPort, project);
2318
+ if (reservation) {
2319
+ return {
2320
+ port: dynamicPort,
2321
+ release: async () => {
2322
+ await releasePort(dynamicPort);
2323
+ }
2324
+ };
2325
+ }
2326
+ }
2327
+ throw new Error(
2328
+ `[PortRegistry] Could not reserve a port for ${project}. Range: ${range.start}-${range.start + range.size - 1}. Currently reserved: ${Array.from(reservedPorts.keys()).join(", ")}`
2329
+ );
2330
+ }
2331
+ async function tryReservePort(port, project) {
2332
+ return new Promise((resolve) => {
2333
+ const server = createServer();
2334
+ server.once("error", () => {
2335
+ resolve(false);
2336
+ });
2337
+ server.listen(port, "::", () => {
2338
+ reservedPorts.set(port, {
2339
+ port,
2340
+ project,
2341
+ holder: server,
2342
+ reservedAt: Date.now()
2343
+ });
2344
+ resolve(true);
2345
+ });
2346
+ });
2347
+ }
2348
+ async function releasePort(port) {
2349
+ const reservation = reservedPorts.get(port);
2350
+ if (!reservation) {
2351
+ return;
2352
+ }
2353
+ return new Promise((resolve) => {
2354
+ reservation.holder.close(() => {
2355
+ reservedPorts.delete(port);
2356
+ resolve();
2357
+ });
2358
+ });
2359
+ }
2360
+ async function findAvailablePortInRange(start, end) {
2361
+ for (let port = start; port < end; port++) {
2362
+ if (reservedPorts.has(port)) {
2363
+ continue;
2364
+ }
2365
+ const available = await isPortAvailable(port);
2366
+ if (available) {
2367
+ return port;
2368
+ }
2369
+ }
2370
+ return null;
2371
+ }
2372
+ async function isPortAvailable(port) {
2373
+ return new Promise((resolve) => {
2374
+ const server = createServer();
2375
+ server.once("error", () => {
2376
+ resolve(false);
2377
+ });
2378
+ server.listen(port, "::", () => {
2379
+ server.close(() => {
2380
+ resolve(true);
2381
+ });
2382
+ });
2383
+ });
2384
+ }
2385
+
2386
+ // libs/testing/src/server/test-server.ts
2387
+ var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
1901
2388
  var TestServer = class _TestServer {
1902
2389
  process = null;
1903
2390
  options;
1904
2391
  _info;
1905
2392
  logs = [];
1906
- constructor(options, port) {
2393
+ portRelease = null;
2394
+ constructor(options, port, portRelease) {
1907
2395
  this.options = {
1908
2396
  port,
2397
+ project: options.project,
1909
2398
  command: options.command ?? "",
1910
2399
  cwd: options.cwd ?? process.cwd(),
1911
2400
  env: options.env ?? {},
1912
2401
  startupTimeout: options.startupTimeout ?? 3e4,
1913
2402
  healthCheckPath: options.healthCheckPath ?? "/health",
1914
- debug: options.debug ?? false
2403
+ debug: options.debug ?? DEBUG_SERVER
1915
2404
  };
2405
+ this.portRelease = portRelease ?? null;
1916
2406
  this._info = {
1917
2407
  baseUrl: `http://localhost:${port}`,
1918
2408
  port
@@ -1922,7 +2412,9 @@ var TestServer = class _TestServer {
1922
2412
  * Start a test server with custom command
1923
2413
  */
1924
2414
  static async start(options) {
1925
- const port = options.port ?? await findAvailablePort();
2415
+ const project = options.project ?? "default";
2416
+ const { port, release } = await reservePort(project, options.port);
2417
+ await release();
1926
2418
  const server = new _TestServer(options, port);
1927
2419
  try {
1928
2420
  await server.startProcess();
@@ -1941,10 +2433,12 @@ var TestServer = class _TestServer {
1941
2433
  `Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`
1942
2434
  );
1943
2435
  }
1944
- const port = options.port ?? await findAvailablePort();
2436
+ const { port, release } = await reservePort(project, options.port);
2437
+ await release();
1945
2438
  const serverOptions = {
1946
2439
  ...options,
1947
2440
  port,
2441
+ project,
1948
2442
  command: `npx nx serve ${project} --port ${port}`,
1949
2443
  cwd: options.cwd ?? process.cwd()
1950
2444
  };
@@ -2102,22 +2596,54 @@ var TestServer = class _TestServer {
2102
2596
  exitCode = code;
2103
2597
  this.log(`Server process exited with code ${code}`);
2104
2598
  });
2105
- await this.waitForReadyWithExitDetection(() => {
2106
- if (exitError) {
2107
- return { exited: true, error: exitError };
2108
- }
2109
- if (processExited) {
2110
- const recentLogs = this.logs.slice(-10).join("\n");
2111
- return {
2112
- exited: true,
2113
- error: new Error(`Server process exited unexpectedly with code ${exitCode}.
2599
+ try {
2600
+ await this.waitForReadyWithExitDetection(() => {
2601
+ if (exitError) {
2602
+ return { exited: true, error: exitError };
2603
+ }
2604
+ if (processExited) {
2605
+ const allLogs = this.logs.join("\n");
2606
+ const errorLogs = this.logs.filter((l) => l.includes("[ERROR]") || l.toLowerCase().includes("error")).join("\n");
2607
+ return {
2608
+ exited: true,
2609
+ error: new ServerStartError(
2610
+ `Server process exited unexpectedly with code ${exitCode}.
2114
2611
 
2115
- Recent logs:
2116
- ${recentLogs}`)
2117
- };
2118
- }
2119
- return { exited: false };
2120
- });
2612
+ Command: ${this.options.command}
2613
+ CWD: ${this.options.cwd}
2614
+ Port: ${this.options.port}
2615
+
2616
+ === Error Logs ===
2617
+ ${errorLogs || "No error logs captured"}
2618
+
2619
+ === Full Logs ===
2620
+ ${allLogs || "No logs captured"}`
2621
+ )
2622
+ };
2623
+ }
2624
+ return { exited: false };
2625
+ });
2626
+ } catch (error) {
2627
+ this.printLogsOnFailure("Server startup failed");
2628
+ throw error;
2629
+ }
2630
+ }
2631
+ /**
2632
+ * Print server logs on failure for debugging
2633
+ */
2634
+ printLogsOnFailure(context) {
2635
+ const allLogs = this.logs.join("\n");
2636
+ if (allLogs) {
2637
+ console.error(`
2638
+ [TestServer] ${context}`);
2639
+ console.error(`[TestServer] Command: ${this.options.command}`);
2640
+ console.error(`[TestServer] Port: ${this.options.port}`);
2641
+ console.error(`[TestServer] CWD: ${this.options.cwd}`);
2642
+ console.error(`[TestServer] === Server Logs ===
2643
+ ${allLogs}`);
2644
+ console.error(`[TestServer] === End Logs ===
2645
+ `);
2646
+ }
2121
2647
  }
2122
2648
  /**
2123
2649
  * Wait for server to be ready, but also detect early process exit
@@ -2126,29 +2652,57 @@ ${recentLogs}`)
2126
2652
  const timeoutMs = this.options.startupTimeout;
2127
2653
  const deadline = Date.now() + timeoutMs;
2128
2654
  const checkInterval = 100;
2655
+ let lastHealthCheckError = null;
2656
+ let healthCheckAttempts = 0;
2657
+ this.log(`Waiting for server to be ready (timeout: ${timeoutMs}ms)...`);
2129
2658
  while (Date.now() < deadline) {
2130
2659
  const exitStatus = checkExit();
2131
2660
  if (exitStatus.exited) {
2132
- throw exitStatus.error ?? new Error("Server process exited unexpectedly");
2661
+ throw exitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
2133
2662
  }
2663
+ healthCheckAttempts++;
2134
2664
  try {
2135
- const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {
2665
+ const healthUrl = `${this._info.baseUrl}${this.options.healthCheckPath}`;
2666
+ const response = await fetch(healthUrl, {
2136
2667
  method: "GET",
2137
2668
  signal: AbortSignal.timeout(1e3)
2138
2669
  });
2139
2670
  if (response.ok || response.status === 404) {
2140
- this.log("Server is ready");
2671
+ this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
2141
2672
  return;
2142
2673
  }
2143
- } catch {
2674
+ lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
2675
+ } catch (err) {
2676
+ lastHealthCheckError = err instanceof Error ? err.message : String(err);
2677
+ }
2678
+ const elapsed = Date.now() - (deadline - timeoutMs);
2679
+ if (elapsed > 0 && elapsed % 5e3 < checkInterval) {
2680
+ this.log(
2681
+ `Still waiting for server... (${Math.round(elapsed / 1e3)}s elapsed, last error: ${lastHealthCheckError})`
2682
+ );
2144
2683
  }
2145
2684
  await sleep2(checkInterval);
2146
2685
  }
2147
2686
  const finalExitStatus = checkExit();
2148
2687
  if (finalExitStatus.exited) {
2149
- throw finalExitStatus.error ?? new Error("Server process exited unexpectedly");
2688
+ throw finalExitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
2150
2689
  }
2151
- throw new Error(`Server did not become ready within ${timeoutMs}ms`);
2690
+ const allLogs = this.logs.join("\n");
2691
+ throw new ServerStartError(
2692
+ `Server did not become ready within ${timeoutMs}ms.
2693
+
2694
+ Command: ${this.options.command}
2695
+ CWD: ${this.options.cwd}
2696
+ Port: ${this.options.port}
2697
+ Health check URL: ${this._info.baseUrl}${this.options.healthCheckPath}
2698
+ Health check attempts: ${healthCheckAttempts}
2699
+ Last health check error: ${lastHealthCheckError ?? "none"}
2700
+
2701
+ === Server Logs ===
2702
+ ${allLogs || "No logs captured"}
2703
+
2704
+ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
2705
+ );
2152
2706
  }
2153
2707
  log(message) {
2154
2708
  if (this.options.debug) {
@@ -2156,22 +2710,6 @@ ${recentLogs}`)
2156
2710
  }
2157
2711
  }
2158
2712
  };
2159
- async function findAvailablePort() {
2160
- const { createServer } = await import("net");
2161
- return new Promise((resolve, reject) => {
2162
- const server = createServer();
2163
- server.listen(0, () => {
2164
- const address = server.address();
2165
- if (address && typeof address !== "string") {
2166
- const port = address.port;
2167
- server.close(() => resolve(port));
2168
- } else {
2169
- reject(new Error("Could not get port"));
2170
- }
2171
- });
2172
- server.on("error", reject);
2173
- });
2174
- }
2175
2713
  function sleep2(ms) {
2176
2714
  return new Promise((resolve) => setTimeout(resolve, ms));
2177
2715
  }
@@ -2190,14 +2728,36 @@ async function initializeSharedResources() {
2190
2728
  serverInstance = TestServer.connect(currentConfig.baseUrl);
2191
2729
  serverStartedByUs = false;
2192
2730
  } else if (currentConfig.server) {
2193
- serverInstance = await TestServer.start({
2194
- port: currentConfig.port,
2195
- command: resolveServerCommand(currentConfig.server),
2196
- env: currentConfig.env,
2197
- startupTimeout: currentConfig.startupTimeout ?? 3e4,
2198
- debug: currentConfig.logLevel === "debug"
2199
- });
2200
- serverStartedByUs = true;
2731
+ const serverCommand = resolveServerCommand(currentConfig.server);
2732
+ const isDebug = currentConfig.logLevel === "debug" || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
2733
+ if (isDebug) {
2734
+ console.log(`[TestFixture] Starting server: ${serverCommand}`);
2735
+ }
2736
+ try {
2737
+ serverInstance = await TestServer.start({
2738
+ project: currentConfig.project,
2739
+ port: currentConfig.port,
2740
+ command: serverCommand,
2741
+ env: currentConfig.env,
2742
+ startupTimeout: currentConfig.startupTimeout ?? 3e4,
2743
+ debug: isDebug
2744
+ });
2745
+ serverStartedByUs = true;
2746
+ if (isDebug) {
2747
+ console.log(`[TestFixture] Server started at ${serverInstance.info.baseUrl}`);
2748
+ }
2749
+ } catch (error) {
2750
+ const errMsg = error instanceof Error ? error.message : String(error);
2751
+ throw new Error(
2752
+ `Failed to start test server.
2753
+
2754
+ Server entry: ${currentConfig.server}
2755
+ Project: ${currentConfig.project ?? "default"}
2756
+ Command: ${serverCommand}
2757
+
2758
+ Error: ${errMsg}`
2759
+ );
2760
+ }
2201
2761
  } else {
2202
2762
  throw new Error(
2203
2763
  'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
@@ -2207,6 +2767,12 @@ async function initializeSharedResources() {
2207
2767
  }
2208
2768
  async function createTestFixtures() {
2209
2769
  await initializeSharedResources();
2770
+ if (!serverInstance) {
2771
+ throw new Error("Server instance not initialized");
2772
+ }
2773
+ if (!tokenFactory) {
2774
+ throw new Error("Token factory not initialized");
2775
+ }
2210
2776
  const clientInstance = await McpTestClient.create({
2211
2777
  baseUrl: serverInstance.info.baseUrl,
2212
2778
  transport: currentConfig.transport ?? "streamable-http",
@@ -2220,7 +2786,19 @@ async function createTestFixtures() {
2220
2786
  server
2221
2787
  };
2222
2788
  }
2223
- async function cleanupTestFixtures(fixtures, _testFailed = false) {
2789
+ async function cleanupTestFixtures(fixtures, testFailed = false) {
2790
+ if (testFailed && serverInstance) {
2791
+ const logs = serverInstance.getLogs();
2792
+ if (logs.length > 0) {
2793
+ console.error("\n[TestFixture] === Server Logs (test failed) ===");
2794
+ const recentLogs = logs.slice(-50);
2795
+ if (logs.length > 50) {
2796
+ console.error(`[TestFixture] (showing last 50 of ${logs.length} log entries)`);
2797
+ }
2798
+ console.error(recentLogs.join("\n"));
2799
+ console.error("[TestFixture] === End Server Logs ===\n");
2800
+ }
2801
+ }
2224
2802
  if (fixtures.mcp.isConnected()) {
2225
2803
  await fixtures.mcp.disconnect();
2226
2804
  }