Skip to content

Commit 3048a6f

Browse files
authored
Merge branch 'main' into patch-1
2 parents f0fbfc7 + 12d8ac9 commit 3048a6f

21 files changed

+3522
-1674
lines changed

src/client/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,11 @@ export class Client<
126126

127127
override async connect(transport: Transport, options?: RequestOptions): Promise<void> {
128128
await super.connect(transport);
129-
129+
// When transport sessionId is already set this means we are trying to reconnect.
130+
// In this case we don't need to initialize again.
131+
if (transport.sessionId !== undefined) {
132+
return;
133+
}
130134
try {
131135
const result = await this.request(
132136
{

src/client/streamableHttp.test.ts

Lines changed: 152 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StreamableHTTPClientTransport } from "./streamableHttp.js";
1+
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js";
22
import { JSONRPCMessage } from "../types.js";
33

44

@@ -101,6 +101,77 @@ describe("StreamableHTTPClientTransport", () => {
101101
expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id");
102102
});
103103

104+
it("should terminate session with DELETE request", async () => {
105+
// First, simulate getting a session ID
106+
const message: JSONRPCMessage = {
107+
jsonrpc: "2.0",
108+
method: "initialize",
109+
params: {
110+
clientInfo: { name: "test-client", version: "1.0" },
111+
protocolVersion: "2025-03-26"
112+
},
113+
id: "init-id"
114+
};
115+
116+
(global.fetch as jest.Mock).mockResolvedValueOnce({
117+
ok: true,
118+
status: 200,
119+
headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }),
120+
});
121+
122+
await transport.send(message);
123+
expect(transport.sessionId).toBe("test-session-id");
124+
125+
// Now terminate the session
126+
(global.fetch as jest.Mock).mockResolvedValueOnce({
127+
ok: true,
128+
status: 200,
129+
headers: new Headers()
130+
});
131+
132+
await transport.terminateSession();
133+
134+
// Verify the DELETE request was sent with the session ID
135+
const calls = (global.fetch as jest.Mock).mock.calls;
136+
const lastCall = calls[calls.length - 1];
137+
expect(lastCall[1].method).toBe("DELETE");
138+
expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id");
139+
140+
// The session ID should be cleared after successful termination
141+
expect(transport.sessionId).toBeUndefined();
142+
});
143+
144+
it("should handle 405 response when server doesn't support session termination", async () => {
145+
// First, simulate getting a session ID
146+
const message: JSONRPCMessage = {
147+
jsonrpc: "2.0",
148+
method: "initialize",
149+
params: {
150+
clientInfo: { name: "test-client", version: "1.0" },
151+
protocolVersion: "2025-03-26"
152+
},
153+
id: "init-id"
154+
};
155+
156+
(global.fetch as jest.Mock).mockResolvedValueOnce({
157+
ok: true,
158+
status: 200,
159+
headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }),
160+
});
161+
162+
await transport.send(message);
163+
164+
// Now terminate the session, but server responds with 405
165+
(global.fetch as jest.Mock).mockResolvedValueOnce({
166+
ok: false,
167+
status: 405,
168+
statusText: "Method Not Allowed",
169+
headers: new Headers()
170+
});
171+
172+
await expect(transport.terminateSession()).resolves.not.toThrow();
173+
});
174+
104175
it("should handle 404 response when session expires", async () => {
105176
const message: JSONRPCMessage = {
106177
jsonrpc: "2.0",
@@ -164,7 +235,7 @@ describe("StreamableHTTPClientTransport", () => {
164235
// We expect the 405 error to be caught and handled gracefully
165236
// This should not throw an error that breaks the transport
166237
await transport.start();
167-
await expect(transport["_startOrAuthStandaloneSSE"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed");
238+
await expect(transport["_startOrAuthSse"]({})).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed");
168239
// Check that GET was attempted
169240
expect(global.fetch).toHaveBeenCalledWith(
170241
expect.anything(),
@@ -208,7 +279,7 @@ describe("StreamableHTTPClientTransport", () => {
208279
transport.onmessage = messageSpy;
209280

210281
await transport.start();
211-
await transport["_startOrAuthStandaloneSSE"]();
282+
await transport["_startOrAuthSse"]({});
212283

213284
// Give time for the SSE event to be processed
214285
await new Promise(resolve => setTimeout(resolve, 50));
@@ -275,45 +346,62 @@ describe("StreamableHTTPClientTransport", () => {
275346
})).toBe(true);
276347
});
277348

278-
it("should include last-event-id header when resuming a broken connection", async () => {
279-
// First make a successful connection that provides an event ID
280-
const encoder = new TextEncoder();
281-
const stream = new ReadableStream({
282-
start(controller) {
283-
const event = "id: event-123\nevent: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n";
284-
controller.enqueue(encoder.encode(event));
285-
controller.close();
349+
it("should support custom reconnection options", () => {
350+
// Create a transport with custom reconnection options
351+
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), {
352+
reconnectionOptions: {
353+
initialReconnectionDelay: 500,
354+
maxReconnectionDelay: 10000,
355+
reconnectionDelayGrowFactor: 2,
356+
maxRetries: 5,
286357
}
287358
});
288359

289-
(global.fetch as jest.Mock).mockResolvedValueOnce({
290-
ok: true,
291-
status: 200,
292-
headers: new Headers({ "content-type": "text/event-stream" }),
293-
body: stream
294-
});
360+
// Verify options were set correctly (checking implementation details)
361+
// Access private properties for testing
362+
const transportInstance = transport as unknown as {
363+
_reconnectionOptions: StreamableHTTPReconnectionOptions;
364+
};
365+
expect(transportInstance._reconnectionOptions.initialReconnectionDelay).toBe(500);
366+
expect(transportInstance._reconnectionOptions.maxRetries).toBe(5);
367+
});
295368

296-
await transport.start();
297-
await transport["_startOrAuthStandaloneSSE"]();
298-
await new Promise(resolve => setTimeout(resolve, 50));
369+
it("should pass lastEventId when reconnecting", async () => {
370+
// Create a fresh transport
371+
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"));
299372

300-
// Now simulate attempting to reconnect
301-
(global.fetch as jest.Mock).mockResolvedValueOnce({
373+
// Mock fetch to verify headers sent
374+
const fetchSpy = global.fetch as jest.Mock;
375+
fetchSpy.mockReset();
376+
fetchSpy.mockResolvedValue({
302377
ok: true,
303378
status: 200,
304379
headers: new Headers({ "content-type": "text/event-stream" }),
305-
body: null
380+
body: new ReadableStream()
306381
});
307382

308-
await transport["_startOrAuthStandaloneSSE"]();
383+
// Call the reconnect method directly with a lastEventId
384+
await transport.start();
385+
// Type assertion to access private method
386+
const transportWithPrivateMethods = transport as unknown as {
387+
_startOrAuthSse: (options: { resumptionToken?: string }) => Promise<void>
388+
};
389+
await transportWithPrivateMethods._startOrAuthSse({ resumptionToken: "test-event-id" });
309390

310-
// Check that Last-Event-ID was included
311-
const calls = (global.fetch as jest.Mock).mock.calls;
312-
const lastCall = calls[calls.length - 1];
313-
expect(lastCall[1].headers.get("last-event-id")).toBe("event-123");
391+
// Verify fetch was called with the lastEventId header
392+
expect(fetchSpy).toHaveBeenCalled();
393+
const fetchCall = fetchSpy.mock.calls[0];
394+
const headers = fetchCall[1].headers;
395+
expect(headers.get("last-event-id")).toBe("test-event-id");
314396
});
315397

316398
it("should throw error when invalid content-type is received", async () => {
399+
// Clear any previous state from other tests
400+
jest.clearAllMocks();
401+
402+
// Create a fresh transport instance
403+
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"));
404+
317405
const message: JSONRPCMessage = {
318406
jsonrpc: "2.0",
319407
method: "test",
@@ -323,7 +411,7 @@ describe("StreamableHTTPClientTransport", () => {
323411

324412
const stream = new ReadableStream({
325413
start(controller) {
326-
controller.enqueue("invalid text response");
414+
controller.enqueue(new TextEncoder().encode("invalid text response"));
327415
controller.close();
328416
}
329417
});
@@ -365,7 +453,7 @@ describe("StreamableHTTPClientTransport", () => {
365453

366454
await transport.start();
367455

368-
await transport["_startOrAuthStandaloneSSE"]();
456+
await transport["_startOrAuthSse"]({});
369457
expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue");
370458

371459
requestInit.headers["X-Custom-Header"] = "SecondCustomValue";
@@ -375,4 +463,38 @@ describe("StreamableHTTPClientTransport", () => {
375463

376464
expect(global.fetch).toHaveBeenCalledTimes(2);
377465
});
466+
467+
468+
it("should have exponential backoff with configurable maxRetries", () => {
469+
// This test verifies the maxRetries and backoff calculation directly
470+
471+
// Create transport with specific options for testing
472+
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), {
473+
reconnectionOptions: {
474+
initialReconnectionDelay: 100,
475+
maxReconnectionDelay: 5000,
476+
reconnectionDelayGrowFactor: 2,
477+
maxRetries: 3,
478+
}
479+
});
480+
481+
// Get access to the internal implementation
482+
const getDelay = transport["_getNextReconnectionDelay"].bind(transport);
483+
484+
// First retry - should use initial delay
485+
expect(getDelay(0)).toBe(100);
486+
487+
// Second retry - should double (2^1 * 100 = 200)
488+
expect(getDelay(1)).toBe(200);
489+
490+
// Third retry - should double again (2^2 * 100 = 400)
491+
expect(getDelay(2)).toBe(400);
492+
493+
// Fourth retry - should double again (2^3 * 100 = 800)
494+
expect(getDelay(3)).toBe(800);
495+
496+
// Tenth retry - should be capped at maxReconnectionDelay
497+
expect(getDelay(10)).toBe(5000);
498+
});
499+
378500
});

0 commit comments

Comments
 (0)