|
1 | 1 | import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions, StartSSEOptions } from "./streamableHttp.js";
|
2 | 2 | import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
3 |
| -import { JSONRPCMessage } from "../types.js"; |
| 3 | +import { JSONRPCMessage, JSONRPCRequest } from "../types.js"; |
4 | 4 | import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js";
|
5 | 5 |
|
6 | 6 |
|
@@ -594,6 +594,111 @@ describe("StreamableHTTPClientTransport", () => {
|
594 | 594 | await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
|
595 | 595 | expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
|
596 | 596 | });
|
| 597 | + |
| 598 | + describe('Reconnection Logic', () => { |
| 599 | + let transport: StreamableHTTPClientTransport; |
| 600 | + |
| 601 | + // Use fake timers to control setTimeout and make the test instant. |
| 602 | + beforeEach(() => jest.useFakeTimers()); |
| 603 | + afterEach(() => jest.useRealTimers()); |
| 604 | + |
| 605 | + it('should reconnect a GET-initiated notification stream that fails', async () => { |
| 606 | + // ARRANGE |
| 607 | + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { |
| 608 | + reconnectionOptions: { |
| 609 | + initialReconnectionDelay: 10, |
| 610 | + maxRetries: 1, |
| 611 | + maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely |
| 612 | + reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity |
| 613 | + } |
| 614 | + }); |
| 615 | + |
| 616 | + const errorSpy = jest.fn(); |
| 617 | + transport.onerror = errorSpy; |
| 618 | + |
| 619 | + const failingStream = new ReadableStream({ |
| 620 | + start(controller) { controller.error(new Error("Network failure")); } |
| 621 | + }); |
| 622 | + |
| 623 | + const fetchMock = global.fetch as jest.Mock; |
| 624 | + // Mock the initial GET request, which will fail. |
| 625 | + fetchMock.mockResolvedValueOnce({ |
| 626 | + ok: true, status: 200, |
| 627 | + headers: new Headers({ "content-type": "text/event-stream" }), |
| 628 | + body: failingStream, |
| 629 | + }); |
| 630 | + // Mock the reconnection GET request, which will succeed. |
| 631 | + fetchMock.mockResolvedValueOnce({ |
| 632 | + ok: true, status: 200, |
| 633 | + headers: new Headers({ "content-type": "text/event-stream" }), |
| 634 | + body: new ReadableStream(), |
| 635 | + }); |
| 636 | + |
| 637 | + // ACT |
| 638 | + await transport.start(); |
| 639 | + // Trigger the GET stream directly using the internal method for a clean test. |
| 640 | + await transport["_startOrAuthSse"]({}); |
| 641 | + await jest.advanceTimersByTimeAsync(20); // Trigger reconnection timeout |
| 642 | + |
| 643 | + // ASSERT |
| 644 | + expect(errorSpy).toHaveBeenCalledWith(expect.objectContaining({ |
| 645 | + message: expect.stringContaining('SSE stream disconnected: Error: Network failure'), |
| 646 | + })); |
| 647 | + // THE KEY ASSERTION: A second fetch call proves reconnection was attempted. |
| 648 | + expect(fetchMock).toHaveBeenCalledTimes(2); |
| 649 | + expect(fetchMock.mock.calls[0][1]?.method).toBe('GET'); |
| 650 | + expect(fetchMock.mock.calls[1][1]?.method).toBe('GET'); |
| 651 | + }); |
| 652 | + |
| 653 | + it('should NOT reconnect a POST-initiated stream that fails', async () => { |
| 654 | + // ARRANGE |
| 655 | + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { |
| 656 | + reconnectionOptions: { |
| 657 | + initialReconnectionDelay: 10, |
| 658 | + maxRetries: 1, |
| 659 | + maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely |
| 660 | + reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity |
| 661 | + } |
| 662 | + }); |
| 663 | + |
| 664 | + const errorSpy = jest.fn(); |
| 665 | + transport.onerror = errorSpy; |
| 666 | + |
| 667 | + const failingStream = new ReadableStream({ |
| 668 | + start(controller) { controller.error(new Error("Network failure")); } |
| 669 | + }); |
| 670 | + |
| 671 | + const fetchMock = global.fetch as jest.Mock; |
| 672 | + // Mock the POST request. It returns a streaming content-type but a failing body. |
| 673 | + fetchMock.mockResolvedValueOnce({ |
| 674 | + ok: true, status: 200, |
| 675 | + headers: new Headers({ "content-type": "text/event-stream" }), |
| 676 | + body: failingStream, |
| 677 | + }); |
| 678 | + |
| 679 | + // A dummy request message to trigger the `send` logic. |
| 680 | + const requestMessage: JSONRPCRequest = { |
| 681 | + jsonrpc: '2.0', |
| 682 | + method: 'long_running_tool', |
| 683 | + id: 'request-1', |
| 684 | + params: {}, |
| 685 | + }; |
| 686 | + |
| 687 | + // ACT |
| 688 | + await transport.start(); |
| 689 | + // Use the public `send` method to initiate a POST that gets a stream response. |
| 690 | + await transport.send(requestMessage); |
| 691 | + await jest.advanceTimersByTimeAsync(20); // Advance time to check for reconnections |
| 692 | + |
| 693 | + // ASSERT |
| 694 | + expect(errorSpy).toHaveBeenCalledWith(expect.objectContaining({ |
| 695 | + message: expect.stringContaining('SSE stream disconnected: Error: Network failure'), |
| 696 | + })); |
| 697 | + // THE KEY ASSERTION: Fetch was only called ONCE. No reconnection was attempted. |
| 698 | + expect(fetchMock).toHaveBeenCalledTimes(1); |
| 699 | + expect(fetchMock.mock.calls[0][1]?.method).toBe('POST'); |
| 700 | + }); |
| 701 | + }); |
597 | 702 |
|
598 | 703 | it("invalidates all credentials on InvalidClientError during auth", async () => {
|
599 | 704 | const message: JSONRPCMessage = {
|
|
0 commit comments