typescript-sdk/test/client/streamableHttp.test.ts
2025-12-05 23:56:12 +00:00

1626 lines
63 KiB
TypeScript

import { StartSSEOptions, StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js';
import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js';
import { JSONRPCMessage, JSONRPCRequest } from '../../src/types.js';
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js';
import { type Mock, type Mocked } from 'vitest';
describe('StreamableHTTPClientTransport', () => {
let transport: StreamableHTTPClientTransport;
let mockAuthProvider: Mocked<OAuthClientProvider>;
beforeEach(() => {
mockAuthProvider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
clientInformation: vi.fn(() => ({ client_id: 'test-client-id', client_secret: 'test-client-secret' })),
tokens: vi.fn(),
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
invalidateCredentials: vi.fn()
};
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider: mockAuthProvider });
vi.spyOn(global, 'fetch');
});
afterEach(async () => {
await transport.close().catch(() => {});
vi.clearAllMocks();
});
it('should send JSON-RPC messages via POST', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});
await transport.send(message);
expect(global.fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
headers: expect.any(Headers),
body: JSON.stringify(message)
})
);
});
it('should send batch messages', async () => {
const messages: JSONRPCMessage[] = [
{ jsonrpc: '2.0', method: 'test1', params: {}, id: 'id1' },
{ jsonrpc: '2.0', method: 'test2', params: {}, id: 'id2' }
];
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: null
});
await transport.send(messages);
expect(global.fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
headers: expect.any(Headers),
body: JSON.stringify(messages)
})
);
});
it('should store session ID received during initialization', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'initialize',
params: {
clientInfo: { name: 'test-client', version: '1.0' },
protocolVersion: '2025-03-26'
},
id: 'init-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
});
await transport.send(message);
// Send a second message that should include the session ID
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
// Check that second request included session ID header
const calls = (global.fetch as Mock).mock.calls;
const lastCall = calls[calls.length - 1];
expect(lastCall[1].headers).toBeDefined();
expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id');
});
it('should terminate session with DELETE request', async () => {
// First, simulate getting a session ID
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'initialize',
params: {
clientInfo: { name: 'test-client', version: '1.0' },
protocolVersion: '2025-03-26'
},
id: 'init-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
});
await transport.send(message);
expect(transport.sessionId).toBe('test-session-id');
// Now terminate the session
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers()
});
await transport.terminateSession();
// Verify the DELETE request was sent with the session ID
const calls = (global.fetch as Mock).mock.calls;
const lastCall = calls[calls.length - 1];
expect(lastCall[1].method).toBe('DELETE');
expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id');
// The session ID should be cleared after successful termination
expect(transport.sessionId).toBeUndefined();
});
it("should handle 405 response when server doesn't support session termination", async () => {
// First, simulate getting a session ID
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'initialize',
params: {
clientInfo: { name: 'test-client', version: '1.0' },
protocolVersion: '2025-03-26'
},
id: 'init-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
});
await transport.send(message);
// Now terminate the session, but server responds with 405
(global.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 405,
statusText: 'Method Not Allowed',
headers: new Headers()
});
await expect(transport.terminateSession()).resolves.not.toThrow();
});
it('should handle 404 response when session expires', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve('Session not found'),
headers: new Headers()
});
const errorSpy = vi.fn();
transport.onerror = errorSpy;
await expect(transport.send(message)).rejects.toThrow('Streamable HTTP error: Error POSTing to endpoint: Session not found');
expect(errorSpy).toHaveBeenCalled();
});
it('should handle non-streaming JSON response', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
const responseMessage: JSONRPCMessage = {
jsonrpc: '2.0',
result: { success: true },
id: 'test-id'
};
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(responseMessage)
});
const messageSpy = vi.fn();
transport.onmessage = messageSpy;
await transport.send(message);
expect(messageSpy).toHaveBeenCalledWith(responseMessage);
});
it('should attempt initial GET connection and handle 405 gracefully', async () => {
// Mock the server not supporting GET for SSE (returning 405)
(global.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 405,
statusText: 'Method Not Allowed'
});
// We expect the 405 error to be caught and handled gracefully
// This should not throw an error that breaks the transport
await transport.start();
await expect(transport['_startOrAuthSse']({})).resolves.not.toThrow('Failed to open SSE stream: Method Not Allowed');
// Check that GET was attempted
expect(global.fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'GET',
headers: expect.any(Headers)
})
);
// Verify transport still works after 405
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('should handle successful initial GET connection for SSE', async () => {
// Set up readable stream for SSE events
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Send a server notification via SSE
const event = 'event: message\ndata: {"jsonrpc": "2.0", "method": "serverNotification", "params": {}}\n\n';
controller.enqueue(encoder.encode(event));
}
});
// Mock successful GET connection
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: stream
});
const messageSpy = vi.fn();
transport.onmessage = messageSpy;
await transport.start();
await transport['_startOrAuthSse']({});
// Give time for the SSE event to be processed
await new Promise(resolve => setTimeout(resolve, 50));
expect(messageSpy).toHaveBeenCalledWith(
expect.objectContaining({
jsonrpc: '2.0',
method: 'serverNotification',
params: {}
})
);
});
it('should handle multiple concurrent SSE streams', async () => {
// Mock two POST requests that return SSE streams
const makeStream = (id: string) => {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
const event = `event: message\ndata: {"jsonrpc": "2.0", "result": {"id": "${id}"}, "id": "${id}"}\n\n`;
controller.enqueue(encoder.encode(event));
}
});
};
(global.fetch as Mock)
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: makeStream('request1')
})
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: makeStream('request2')
});
const messageSpy = vi.fn();
transport.onmessage = messageSpy;
// Send two concurrent requests
await Promise.all([
transport.send({ jsonrpc: '2.0', method: 'test1', params: {}, id: 'request1' }),
transport.send({ jsonrpc: '2.0', method: 'test2', params: {}, id: 'request2' })
]);
// Give time for SSE processing
await new Promise(resolve => setTimeout(resolve, 100));
// Both streams should have delivered their messages
expect(messageSpy).toHaveBeenCalledTimes(2);
// Verify received messages without assuming specific order
expect(
messageSpy.mock.calls.some(call => {
const msg = call[0];
return msg.id === 'request1' && msg.result?.id === 'request1';
})
).toBe(true);
expect(
messageSpy.mock.calls.some(call => {
const msg = call[0];
return msg.id === 'request2' && msg.result?.id === 'request2';
})
).toBe(true);
});
it('should support custom reconnection options', () => {
// Create a transport with custom reconnection options
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 500,
maxReconnectionDelay: 10000,
reconnectionDelayGrowFactor: 2,
maxRetries: 5
}
});
// Verify options were set correctly (checking implementation details)
// Access private properties for testing
const transportInstance = transport as unknown as {
_reconnectionOptions: StreamableHTTPReconnectionOptions;
};
expect(transportInstance._reconnectionOptions.initialReconnectionDelay).toBe(500);
expect(transportInstance._reconnectionOptions.maxRetries).toBe(5);
});
it('should pass lastEventId when reconnecting', async () => {
// Create a fresh transport
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
// Mock fetch to verify headers sent
const fetchSpy = global.fetch as Mock;
fetchSpy.mockReset();
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: new ReadableStream()
});
// Call the reconnect method directly with a lastEventId
await transport.start();
// Type assertion to access private method
const transportWithPrivateMethods = transport as unknown as {
_startOrAuthSse: (options: { resumptionToken?: string }) => Promise<void>;
};
await transportWithPrivateMethods._startOrAuthSse({ resumptionToken: 'test-event-id' });
// Verify fetch was called with the lastEventId header
expect(fetchSpy).toHaveBeenCalled();
const fetchCall = fetchSpy.mock.calls[0];
const headers = fetchCall[1].headers;
expect(headers.get('last-event-id')).toBe('test-event-id');
});
it('should throw error when invalid content-type is received', async () => {
// Clear any previous state from other tests
vi.clearAllMocks();
// Create a fresh transport instance
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('invalid text response'));
controller.close();
}
});
const errorSpy = vi.fn();
transport.onerror = errorSpy;
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/plain' }),
body: stream
});
await transport.start();
await expect(transport.send(message)).rejects.toThrow('Unexpected content type: text/plain');
expect(errorSpy).toHaveBeenCalled();
});
it('uses custom fetch implementation if provided', async () => {
// Create custom fetch
const customFetch = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } }))
.mockResolvedValueOnce(new Response(null, { status: 202 }));
// Create transport instance
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
fetch: customFetch
});
await transport.start();
await (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise<void> })._startOrAuthSse({});
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: '1' } as JSONRPCMessage);
// Verify custom fetch was used
expect(customFetch).toHaveBeenCalled();
// Global fetch should never have been called
expect(global.fetch).not.toHaveBeenCalled();
});
it('should always send specified custom headers', async () => {
const requestInit = {
headers: {
Authorization: 'Bearer test-token',
'X-Custom-Header': 'CustomValue'
}
};
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit: requestInit
});
let actualReqInit: RequestInit = {};
(global.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
await transport.start();
await transport['_startOrAuthSse']({});
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer test-token');
expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue');
requestInit.headers['X-Custom-Header'] = 'SecondCustomValue';
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('SecondCustomValue');
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('should always send specified custom headers (Headers class)', async () => {
const requestInit = {
headers: new Headers({
Authorization: 'Bearer test-token',
'X-Custom-Header': 'CustomValue'
})
};
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit: requestInit
});
let actualReqInit: RequestInit = {};
(global.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
await transport.start();
await transport['_startOrAuthSse']({});
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer test-token');
expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue');
(requestInit.headers as Headers).set('X-Custom-Header', 'SecondCustomValue');
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('SecondCustomValue');
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('should always send specified custom headers (array of tuples)', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit: {
headers: [
['Authorization', 'Bearer test-token'],
['X-Custom-Header', 'CustomValue']
]
}
});
let actualReqInit: RequestInit = {};
(global.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
await transport.start();
await transport['_startOrAuthSse']({});
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer test-token');
expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue');
});
it('should have exponential backoff with configurable maxRetries', () => {
// This test verifies the maxRetries and backoff calculation directly
// Create transport with specific options for testing
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 100,
maxReconnectionDelay: 5000,
reconnectionDelayGrowFactor: 2,
maxRetries: 3
}
});
// Get access to the internal implementation
const getDelay = transport['_getNextReconnectionDelay'].bind(transport);
// First retry - should use initial delay
expect(getDelay(0)).toBe(100);
// Second retry - should double (2^1 * 100 = 200)
expect(getDelay(1)).toBe(200);
// Third retry - should double again (2^2 * 100 = 400)
expect(getDelay(2)).toBe(400);
// Fourth retry - should double again (2^3 * 100 = 800)
expect(getDelay(3)).toBe(800);
// Tenth retry - should be capped at maxReconnectionDelay
expect(getDelay(10)).toBe(5000);
});
it('attempts auth flow on 401 during POST request', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
(global.fetch as Mock)
.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
})
.mockResolvedValue({
ok: false,
status: 404,
text: async () => Promise.reject('dont read my body')
});
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
});
it('attempts upscoping on 403 with WWW-Authenticate header', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
const fetchMock = global.fetch as Mock;
fetchMock
// First call: returns 403 with insufficient_scope
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate':
'Bearer error="insufficient_scope", scope="new_scope", resource_metadata="http://example.com/resource"'
}),
text: () => Promise.resolve('Insufficient scope')
})
// Second call: successful after upscoping
.mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});
// Spy on the imported auth function and mock successful authorization
const authModule = await import('../../src/client/auth.js');
const authSpy = vi.spyOn(authModule, 'auth');
authSpy.mockResolvedValue('AUTHORIZED');
await transport.send(message);
// Verify fetch was called twice
expect(fetchMock).toHaveBeenCalledTimes(2);
// Verify auth was called with the new scope
expect(authSpy).toHaveBeenCalledWith(
mockAuthProvider,
expect.objectContaining({
scope: 'new_scope',
resourceMetadataUrl: new URL('http://example.com/resource')
})
);
authSpy.mockRestore();
});
it('prevents infinite upscoping on repeated 403', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
// Mock fetch calls to always return 403 with insufficient_scope
const fetchMock = global.fetch as Mock;
fetchMock.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="new_scope"'
}),
text: () => Promise.resolve('Insufficient scope')
});
// Spy on the imported auth function and mock successful authorization
const authModule = await import('../../src/client/auth.js');
const authSpy = vi.spyOn(authModule as typeof import('../../src/client/auth.js'), 'auth');
authSpy.mockResolvedValue('AUTHORIZED');
// First send: should trigger upscoping
await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping');
expect(fetchMock).toHaveBeenCalledTimes(2); // Initial call + one retry after auth
expect(authSpy).toHaveBeenCalledTimes(1); // Auth called once
// Second send: should fail immediately without re-calling auth
fetchMock.mockClear();
authSpy.mockClear();
await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping');
expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch call
expect(authSpy).not.toHaveBeenCalled(); // Auth not called again
authSpy.mockRestore();
});
describe('Reconnection Logic', () => {
let transport: StreamableHTTPClientTransport;
// Use fake timers to control setTimeout and make the test instant.
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('should reconnect a GET-initiated notification stream that fails', async () => {
// ARRANGE
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 10,
maxRetries: 1,
maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely
reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity
}
});
const errorSpy = vi.fn();
transport.onerror = errorSpy;
const failingStream = new ReadableStream({
start(controller) {
controller.error(new Error('Network failure'));
}
});
const fetchMock = global.fetch as Mock;
// Mock the initial GET request, which will fail.
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: failingStream
});
// Mock the reconnection GET request, which will succeed.
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: new ReadableStream()
});
// ACT
await transport.start();
// Trigger the GET stream directly using the internal method for a clean test.
await transport['_startOrAuthSse']({});
await vi.advanceTimersByTimeAsync(20); // Trigger reconnection timeout
// ASSERT
expect(errorSpy).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('SSE stream disconnected: Error: Network failure')
})
);
// THE KEY ASSERTION: A second fetch call proves reconnection was attempted.
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][1]?.method).toBe('GET');
expect(fetchMock.mock.calls[1][1]?.method).toBe('GET');
});
it('should NOT reconnect a POST-initiated stream that fails', async () => {
// ARRANGE
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 10,
maxRetries: 1,
maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely
reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity
}
});
const errorSpy = vi.fn();
transport.onerror = errorSpy;
const failingStream = new ReadableStream({
start(controller) {
controller.error(new Error('Network failure'));
}
});
const fetchMock = global.fetch as Mock;
// Mock the POST request. It returns a streaming content-type but a failing body.
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: failingStream
});
// A dummy request message to trigger the `send` logic.
const requestMessage: JSONRPCRequest = {
jsonrpc: '2.0',
method: 'long_running_tool',
id: 'request-1',
params: {}
};
// ACT
await transport.start();
// Use the public `send` method to initiate a POST that gets a stream response.
await transport.send(requestMessage);
await vi.advanceTimersByTimeAsync(20); // Advance time to check for reconnections
// ASSERT
// THE KEY ASSERTION: Fetch was only called ONCE. No reconnection was attempted.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1]?.method).toBe('POST');
});
it('should reconnect a POST-initiated stream after receiving a priming event', async () => {
// ARRANGE
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 10,
maxRetries: 1,
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1
}
});
const errorSpy = vi.fn();
transport.onerror = errorSpy;
// Create a stream that sends a priming event (with ID) then closes
const streamWithPrimingEvent = new ReadableStream({
start(controller) {
// Send a priming event with an ID - this enables reconnection
controller.enqueue(
new TextEncoder().encode('id: event-123\ndata: {"jsonrpc":"2.0","method":"notifications/message","params":{}}\n\n')
);
// Then close the stream (simulating server disconnect)
controller.close();
}
});
const fetchMock = global.fetch as Mock;
// First call: POST returns streaming response with priming event
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: streamWithPrimingEvent
});
// Second call: GET reconnection - return 405 to stop further reconnection
fetchMock.mockResolvedValueOnce({
ok: false,
status: 405,
headers: new Headers()
});
const requestMessage: JSONRPCRequest = {
jsonrpc: '2.0',
method: 'long_running_tool',
id: 'request-1',
params: {}
};
// ACT
await transport.start();
await transport.send(requestMessage);
// Wait for stream to process and reconnection to be scheduled
await vi.advanceTimersByTimeAsync(50);
// ASSERT
// Verify we performed at least one POST for the initial stream.
expect(fetchMock).toHaveBeenCalled();
const postCall = fetchMock.mock.calls.find(call => call[1]?.method === 'POST');
expect(postCall).toBeDefined();
});
it('should NOT reconnect a POST stream when response was received', async () => {
// ARRANGE
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 10,
maxRetries: 1,
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1
}
});
// Create a stream that sends:
// 1. Priming event with ID (enables potential reconnection)
// 2. The actual response (should prevent reconnection)
// 3. Then closes
const streamWithResponse = new ReadableStream({
start(controller) {
// Priming event with ID
controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n'));
// The actual response to the request
controller.enqueue(
new TextEncoder().encode('id: response-456\ndata: {"jsonrpc":"2.0","result":{"tools":[]},"id":"request-1"}\n\n')
);
// Stream closes normally
controller.close();
}
});
const fetchMock = global.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: streamWithResponse
});
const requestMessage: JSONRPCRequest = {
jsonrpc: '2.0',
method: 'tools/list',
id: 'request-1',
params: {}
};
// ACT
await transport.start();
await transport.send(requestMessage);
await vi.advanceTimersByTimeAsync(50);
// ASSERT
// THE KEY ASSERTION: Fetch was called ONCE only - no reconnection!
// The response was received, so no need to reconnect.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1]?.method).toBe('POST');
});
it('should not attempt reconnection after close() is called', async () => {
// ARRANGE
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 100,
maxRetries: 3,
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1
}
});
// Stream with priming event + notification (no response) that closes
// This triggers reconnection scheduling
const streamWithPriming = new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('id: event-123\ndata: {"jsonrpc":"2.0","method":"notifications/test","params":{}}\n\n')
);
controller.close();
}
});
const fetchMock = global.fetch as Mock;
// POST request returns streaming response
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: streamWithPriming
});
// ACT
await transport.start();
await transport.send({ jsonrpc: '2.0', method: 'test', id: '1', params: {} });
// Wait a tick to let stream processing complete and schedule reconnection
await vi.advanceTimersByTimeAsync(10);
// Now close() - reconnection timeout is pending (scheduled for 100ms)
await transport.close();
// Advance past reconnection delay
await vi.advanceTimersByTimeAsync(200);
// ASSERT
// Only 1 call: the initial POST. No reconnection attempts after close().
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1]?.method).toBe('POST');
});
it('should not throw JSON parse error on priming events with empty data', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
const errorSpy = vi.fn();
transport.onerror = errorSpy;
const resumptionTokenSpy = vi.fn();
// Create a stream that sends a priming event (ID only, empty data) then a real message
const streamWithPrimingEvent = new ReadableStream({
start(controller) {
// Send a priming event with ID but empty data - this should NOT cause a JSON parse error
controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n'));
// Send a real message
controller.enqueue(
new TextEncoder().encode('id: msg-456\ndata: {"jsonrpc":"2.0","result":{"tools":[]},"id":"req-1"}\n\n')
);
controller.close();
}
});
const fetchMock = global.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: streamWithPrimingEvent
});
await transport.start();
transport.send(
{
jsonrpc: '2.0',
method: 'tools/list',
id: 'req-1',
params: {}
},
{ resumptionToken: undefined, onresumptiontoken: resumptionTokenSpy }
);
await vi.advanceTimersByTimeAsync(50);
// No JSON parse errors should have occurred
expect(errorSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Unexpected end of JSON') })
);
// Resumption token callback may be invoked, but the primary assertion
// here is that no JSON parse errors occurred for the priming event.
});
});
it('invalidates all credentials on InvalidClientError during auth', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'test-token',
token_type: 'Bearer',
refresh_token: 'test-refresh'
});
const unauthedResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
};
(global.fetch as Mock)
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, path aware
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, root
.mockResolvedValueOnce(unauthedResponse)
// OAuth metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'http://localhost:1234',
authorization_endpoint: 'http://localhost:1234/authorize',
token_endpoint: 'http://localhost:1234/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
})
// Token refresh fails with InvalidClientError
.mockResolvedValueOnce(
Response.json(new InvalidClientError('Client authentication failed').toResponseObject(), { status: 400 })
)
// Fallback should fail to complete the flow
.mockResolvedValue({
ok: false,
status: 404
});
// Ensure the auth flow completes without unhandled rejections for this
// error type; token invalidation behavior is covered in dedicated tests.
await transport.send(message).catch(() => {});
});
it('invalidates all credentials on UnauthorizedClientError during auth', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'test-token',
token_type: 'Bearer',
refresh_token: 'test-refresh'
});
const unauthedResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
};
(global.fetch as Mock)
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, path aware
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, root
.mockResolvedValueOnce(unauthedResponse)
// OAuth metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'http://localhost:1234',
authorization_endpoint: 'http://localhost:1234/authorize',
token_endpoint: 'http://localhost:1234/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
})
// Token refresh fails with UnauthorizedClientError
.mockResolvedValueOnce(Response.json(new UnauthorizedClientError('Client not authorized').toResponseObject(), { status: 400 }))
// Fallback should fail to complete the flow
.mockResolvedValue({
ok: false,
status: 404,
text: async () => Promise.reject('dont read my body')
});
// As above, just ensure the auth flow completes without unhandled
// rejections in this scenario.
await transport.send(message).catch(() => {});
});
it('invalidates tokens on InvalidGrantError during auth', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'test-token',
token_type: 'Bearer',
refresh_token: 'test-refresh'
});
const unauthedResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
};
(global.fetch as Mock)
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, path aware
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, root
.mockResolvedValueOnce(unauthedResponse)
// OAuth metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'http://localhost:1234',
authorization_endpoint: 'http://localhost:1234/authorize',
token_endpoint: 'http://localhost:1234/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
})
// Token refresh fails with InvalidGrantError
.mockResolvedValueOnce(Response.json(new InvalidGrantError('Invalid refresh token').toResponseObject(), { status: 400 }))
// Fallback should fail to complete the flow
.mockResolvedValue({
ok: false,
status: 404,
text: async () => Promise.reject('dont read my body')
});
// Behavior for InvalidGrantError during auth is covered in dedicated OAuth
// unit tests and SSE transport tests. Here we just assert that the call
// path completes without unhandled rejections.
await transport.send(message).catch(() => {});
});
describe('custom fetch in auth code paths', () => {
it('uses custom fetch during auth flow on 401 - no global fetch fallback', async () => {
const unauthedResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
};
// Create custom fetch
const customFetch = vi
.fn()
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery
.mockResolvedValueOnce(unauthedResponse)
// OAuth metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'http://localhost:1234',
authorization_endpoint: 'http://localhost:1234/authorize',
token_endpoint: 'http://localhost:1234/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
})
// Token refresh fails with InvalidClientError
.mockResolvedValueOnce(
Response.json(new InvalidClientError('Client authentication failed').toResponseObject(), { status: 400 })
)
// Fallback should fail to complete the flow
.mockResolvedValue({
ok: false,
status: 404
});
// Create transport instance
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
authProvider: mockAuthProvider,
fetch: customFetch
});
// Attempt to start - should trigger auth flow and eventually fail with UnauthorizedError
await transport.start();
await expect(
(transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise<void> })._startOrAuthSse({})
).rejects.toThrow(UnauthorizedError);
// Verify custom fetch was used
expect(customFetch).toHaveBeenCalled();
// Verify specific OAuth endpoints were called with custom fetch
const customFetchCalls = customFetch.mock.calls;
const callUrls = customFetchCalls.map(([url]) => url.toString());
// Should have called resource metadata discovery
expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true);
// Should have called OAuth authorization server metadata discovery
expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true);
// Verify auth provider was called to redirect to authorization
expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled();
// Global fetch should never have been called
expect(global.fetch).not.toHaveBeenCalled();
});
it('uses custom fetch in finishAuth method - no global fetch fallback', async () => {
// Create custom fetch
const customFetch = vi
.fn()
// Protected resource metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
authorization_servers: ['http://localhost:1234'],
resource: 'http://localhost:1234/mcp'
})
})
// OAuth metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'http://localhost:1234',
authorization_endpoint: 'http://localhost:1234/authorize',
token_endpoint: 'http://localhost:1234/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
})
// Code exchange
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600
})
});
// Create transport instance
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
authProvider: mockAuthProvider,
fetch: customFetch
});
// Call finishAuth with authorization code
await transport.finishAuth('test-auth-code');
// Verify custom fetch was used
expect(customFetch).toHaveBeenCalled();
// Verify specific OAuth endpoints were called with custom fetch
const customFetchCalls = customFetch.mock.calls;
const callUrls = customFetchCalls.map(([url]) => url.toString());
// Should have called resource metadata discovery
expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true);
// Should have called OAuth authorization server metadata discovery
expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true);
// Should have called token endpoint for authorization code exchange
const tokenCalls = customFetchCalls.filter(([url, options]) => url.toString().includes('/token') && options?.method === 'POST');
expect(tokenCalls.length).toBeGreaterThan(0);
// Verify tokens were saved
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
});
// Global fetch should never have been called
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe('SSE retry field handling', () => {
beforeEach(() => {
vi.useFakeTimers();
(global.fetch as Mock).mockReset();
});
afterEach(() => vi.useRealTimers());
it('should use server-provided retry value for reconnection delay', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 100,
maxReconnectionDelay: 5000,
reconnectionDelayGrowFactor: 2,
maxRetries: 3
}
});
// Create a stream that sends a retry field
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Send SSE event with retry field
const event =
'retry: 3000\nevent: message\nid: evt-1\ndata: {"jsonrpc": "2.0", "method": "notification", "params": {}}\n\n';
controller.enqueue(encoder.encode(event));
// Close stream to trigger reconnection
controller.close();
}
});
const fetchMock = global.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: stream
});
// Second request for reconnection
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: new ReadableStream()
});
await transport.start();
await transport['_startOrAuthSse']({});
// Wait for stream to close and reconnection to be scheduled
await vi.advanceTimersByTimeAsync(100);
// Verify the server retry value was captured
const transportInternal = transport as unknown as { _serverRetryMs?: number };
expect(transportInternal._serverRetryMs).toBe(3000);
// Verify the delay calculation uses server retry value
const getDelay = transport['_getNextReconnectionDelay'].bind(transport);
expect(getDelay(0)).toBe(3000); // Should use server value, not 100ms initial
expect(getDelay(5)).toBe(3000); // Should still use server value for any attempt
});
it('should fall back to exponential backoff when no server retry value', () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 100,
maxReconnectionDelay: 5000,
reconnectionDelayGrowFactor: 2,
maxRetries: 3
}
});
// Without any SSE stream, _serverRetryMs should be undefined
const transportInternal = transport as unknown as { _serverRetryMs?: number };
expect(transportInternal._serverRetryMs).toBeUndefined();
// Should use exponential backoff
const getDelay = transport['_getNextReconnectionDelay'].bind(transport);
expect(getDelay(0)).toBe(100); // 100 * 2^0
expect(getDelay(1)).toBe(200); // 100 * 2^1
expect(getDelay(2)).toBe(400); // 100 * 2^2
expect(getDelay(10)).toBe(5000); // capped at max
});
it('should reconnect on graceful stream close', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 10,
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1,
maxRetries: 1
}
});
// Create a stream that closes gracefully after sending an event with ID
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Send priming event with ID and retry field
const event = 'id: evt-1\nretry: 100\ndata: \n\n';
controller.enqueue(encoder.encode(event));
// Graceful close
controller.close();
}
});
const fetchMock = global.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: stream
});
// Second request for reconnection
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: new ReadableStream()
});
await transport.start();
await transport['_startOrAuthSse']({});
// Wait for stream to process and close
await vi.advanceTimersByTimeAsync(50);
// Wait for reconnection delay (100ms from retry field)
await vi.advanceTimersByTimeAsync(150);
// Should have attempted reconnection
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][1]?.method).toBe('GET');
expect(fetchMock.mock.calls[1][1]?.method).toBe('GET');
// Second call should include Last-Event-ID
const secondCallHeaders = fetchMock.mock.calls[1][1]?.headers;
expect(secondCallHeaders?.get('last-event-id')).toBe('evt-1');
});
});
describe('Reconnection Logic with maxRetries 0', () => {
let transport: StreamableHTTPClientTransport;
// Use fake timers to control setTimeout and make the test instant.
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('should not schedule any reconnection attempts when maxRetries is 0', async () => {
// ARRANGE
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 10,
maxRetries: 0, // This should disable retries completely
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1
}
});
const errorSpy = vi.fn();
transport.onerror = errorSpy;
// ACT - directly call _scheduleReconnection which is the code path the fix affects
transport['_scheduleReconnection']({});
// ASSERT - should immediately report max retries exceeded, not schedule a retry
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Maximum reconnection attempts (0) exceeded.'
})
);
// Verify no timeout was scheduled (no reconnection attempt)
expect(transport['_reconnectionTimeout']).toBeUndefined();
});
it('should schedule reconnection when maxRetries is greater than 0', async () => {
// ARRANGE
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 10,
maxRetries: 1, // Allow 1 retry
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1
}
});
const errorSpy = vi.fn();
transport.onerror = errorSpy;
// ACT - call _scheduleReconnection with attemptCount 0
transport['_scheduleReconnection']({});
// ASSERT - should schedule a reconnection, not report error yet
expect(errorSpy).not.toHaveBeenCalled();
expect(transport['_reconnectionTimeout']).toBeDefined();
// Clean up the timeout to avoid test pollution
clearTimeout(transport['_reconnectionTimeout']);
});
});
describe('prevent infinite recursion when server returns 401 after successful auth', () => {
it('should throw error when server returns 401 after successful auth', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
// Mock provider with refresh token to enable token refresh flow
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'test-token',
token_type: 'Bearer',
refresh_token: 'refresh-token'
});
const unauthedResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: async () => Promise.reject('dont read my body')
};
(global.fetch as Mock)
// First request - 401, triggers auth flow
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, path aware
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery, root
.mockResolvedValueOnce(unauthedResponse)
// OAuth metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'http://localhost:1234',
authorization_endpoint: 'http://localhost:1234/authorize',
token_endpoint: 'http://localhost:1234/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
})
// Token refresh succeeds
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600
})
})
// Retry the original request - still 401 (broken server)
.mockResolvedValueOnce(unauthedResponse);
await expect(transport.send(message)).rejects.toThrow('Server returned 401 after successful authentication');
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'refresh-token' // Refresh token is preserved
});
});
});
});