@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.
- package/auth/index.d.ts +2 -0
- package/auth/index.d.ts.map +1 -1
- package/auth/mock-cimd-server.d.ts +174 -0
- package/auth/mock-cimd-server.d.ts.map +1 -0
- package/auth/mock-oauth-server.d.ts +136 -6
- package/auth/mock-oauth-server.d.ts.map +1 -1
- package/auth/token-factory.d.ts.map +1 -1
- package/client/index.d.ts +1 -1
- package/client/index.d.ts.map +1 -1
- package/client/mcp-test-client.builder.d.ts +12 -0
- package/client/mcp-test-client.builder.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +48 -2
- package/client/mcp-test-client.d.ts.map +1 -1
- package/client/mcp-test-client.types.d.ts +60 -0
- package/client/mcp-test-client.types.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +661 -83
- package/esm/index.mjs +3245 -219
- package/esm/package.json +5 -4
- package/esm/perf/index.mjs +4334 -0
- package/esm/perf/perf-setup.mjs +31 -0
- package/fixtures/fixture-types.d.ts +10 -1
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +661 -93
- package/fixtures/test-fixture.d.ts +1 -1
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/index.d.ts +5 -1
- package/index.d.ts.map +1 -1
- package/index.js +3271 -219
- package/interceptor/interceptor-chain.d.ts +1 -0
- package/interceptor/interceptor-chain.d.ts.map +1 -1
- package/package.json +5 -4
- package/perf/baseline-store.d.ts +67 -0
- package/perf/baseline-store.d.ts.map +1 -0
- package/perf/index.d.ts +44 -0
- package/perf/index.d.ts.map +1 -0
- package/perf/index.js +4404 -0
- package/perf/jest-perf-reporter.d.ts +6 -0
- package/perf/jest-perf-reporter.d.ts.map +1 -0
- package/perf/leak-detector.d.ts +81 -0
- package/perf/leak-detector.d.ts.map +1 -0
- package/perf/metrics-collector.d.ts +83 -0
- package/perf/metrics-collector.d.ts.map +1 -0
- package/perf/perf-fixtures.d.ts +107 -0
- package/perf/perf-fixtures.d.ts.map +1 -0
- package/perf/perf-setup.d.ts +9 -0
- package/perf/perf-setup.d.ts.map +1 -0
- package/perf/perf-setup.js +50 -0
- package/perf/perf-test.d.ts +69 -0
- package/perf/perf-test.d.ts.map +1 -0
- package/perf/regression-detector.d.ts +55 -0
- package/perf/regression-detector.d.ts.map +1 -0
- package/perf/report-generator.d.ts +66 -0
- package/perf/report-generator.d.ts.map +1 -0
- package/perf/types.d.ts +439 -0
- package/perf/types.d.ts.map +1 -0
- package/platform/platform-client-info.d.ts +18 -0
- package/platform/platform-client-info.d.ts.map +1 -1
- package/server/index.d.ts +2 -0
- package/server/index.d.ts.map +1 -1
- package/server/port-registry.d.ts +179 -0
- package/server/port-registry.d.ts.map +1 -0
- package/server/test-server.d.ts +9 -5
- package/server/test-server.d.ts.map +1 -1
- package/transport/streamable-http.transport.d.ts +26 -0
- package/transport/streamable-http.transport.d.ts.map +1 -1
- package/transport/transport.interface.d.ts +9 -1
- package/transport/transport.interface.d.ts.map +1 -1
package/esm/fixtures/index.mjs
CHANGED
|
@@ -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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1375
|
+
const transport = this.getConnectedTransport();
|
|
1142
1376
|
const start = Date.now();
|
|
1143
|
-
const response = await
|
|
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.
|
|
1152
|
-
await
|
|
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.
|
|
1159
|
-
return
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
1541
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
|
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
|
|
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
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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
|
-
|
|
2116
|
-
${
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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
|
|
2661
|
+
throw exitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
2133
2662
|
}
|
|
2663
|
+
healthCheckAttempts++;
|
|
2134
2664
|
try {
|
|
2135
|
-
const
|
|
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(
|
|
2671
|
+
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
2141
2672
|
return;
|
|
2142
2673
|
}
|
|
2143
|
-
|
|
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
|
|
2688
|
+
throw finalExitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
2150
2689
|
}
|
|
2151
|
-
|
|
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
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
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,
|
|
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
|
}
|