1375 lines
52 KiB
TypeScript
1375 lines
52 KiB
TypeScript
import { StartSSEOptions, StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from './streamableHttp.js';
|
|
import { OAuthClientProvider, UnauthorizedError } from './auth.js';
|
|
import { JSONRPCMessage, JSONRPCRequest } from '../types.js';
|
|
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../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('Error POSTing to endpoint (HTTP 404)');
|
|
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: {
|
|
'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('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({
|
|
'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('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 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()
|
|
})
|
|
.mockResolvedValue({
|
|
ok: false,
|
|
status: 404
|
|
});
|
|
|
|
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('./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('./auth.js');
|
|
const authSpy = vi.spyOn(authModule, '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
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining('SSE stream disconnected: Error: Network failure')
|
|
})
|
|
);
|
|
// 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
|
|
// THE KEY ASSERTION: Fetch was called TWICE - POST then GET reconnection
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(fetchMock.mock.calls[0][1]?.method).toBe('POST');
|
|
expect(fetchMock.mock.calls[1][1]?.method).toBe('GET');
|
|
// Verify Last-Event-ID header was sent for reconnection
|
|
const reconnectHeaders = fetchMock.mock.calls[1][1]?.headers as Headers;
|
|
expect(reconnectHeaders.get('last-event-id')).toBe('event-123');
|
|
});
|
|
});
|
|
|
|
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()
|
|
};
|
|
(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
|
|
});
|
|
|
|
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
|
|
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
|
|
});
|
|
|
|
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()
|
|
};
|
|
(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
|
|
});
|
|
|
|
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
|
|
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
|
|
});
|
|
|
|
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()
|
|
};
|
|
(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
|
|
});
|
|
|
|
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
|
|
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
|
|
});
|
|
|
|
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()
|
|
};
|
|
|
|
// 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('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()
|
|
};
|
|
|
|
(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
|
|
});
|
|
});
|
|
});
|
|
});
|