- Merge auth-server and mcp-server into unified architecture under src/modules/ - Add configurable auth modes: internal (single process) and external (separate processes) - Update example scripts to support OAuth discovery and both auth modes - Consolidate OAuth documentation into comprehensive implementation guide - Add configuration options for development vs production deployments - Support both Redis-backed and in-memory session storage
291 lines
No EOL
9.3 KiB
TypeScript
291 lines
No EOL
9.3 KiB
TypeScript
import { jest } from '@jest/globals';
|
|
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
import { MockRedisClient, setRedisClient } from '../../shared/redis.js';
|
|
import {
|
|
ServerRedisTransport,
|
|
redisRelayToMcpServer,
|
|
shutdownSession
|
|
} from './redisTransport.js';
|
|
import { createMcpServer } from './mcp.js';
|
|
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
|
|
describe('Redis Transport Integration', () => {
|
|
let mockRedis: MockRedisClient;
|
|
|
|
beforeEach(() => {
|
|
mockRedis = new MockRedisClient();
|
|
setRedisClient(mockRedis);
|
|
jest.resetAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockRedis.clear();
|
|
});
|
|
|
|
describe('MCP Initialization Flow', () => {
|
|
const sessionId = 'test-init-session';
|
|
|
|
it('should relay initialization request from client to server through Redis', async () => {
|
|
// 1. Start the server listening to Redis
|
|
const { server, cleanup: serverCleanup } = createMcpServer();
|
|
const serverTransport = new ServerRedisTransport(sessionId);
|
|
serverTransport.onclose = serverCleanup;
|
|
await server.connect(serverTransport);
|
|
|
|
// 2. Create a mock client transport (simulating the streamable HTTP client side)
|
|
const mockClientTransport: Transport = {
|
|
onmessage: undefined,
|
|
onclose: undefined,
|
|
onerror: undefined,
|
|
send: jest.fn(() => Promise.resolve()),
|
|
close: jest.fn(() => Promise.resolve()),
|
|
start: jest.fn(() => Promise.resolve())
|
|
};
|
|
|
|
// 3. Set up the Redis relay (this is what happens in the HTTP handler)
|
|
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
|
|
|
|
// Track messages received by server
|
|
const serverReceivedMessages: JSONRPCMessage[] = [];
|
|
const originalServerOnMessage = serverTransport.onmessage;
|
|
serverTransport.onmessage = (message, extra) => {
|
|
serverReceivedMessages.push(message);
|
|
originalServerOnMessage?.(message, extra);
|
|
};
|
|
|
|
// 4. Simulate client sending initialization request
|
|
const initMessage: JSONRPCMessage = {
|
|
jsonrpc: '2.0',
|
|
id: 'init-1',
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: {
|
|
name: 'test-client',
|
|
version: '1.0.0'
|
|
}
|
|
}
|
|
};
|
|
|
|
// Trigger the client transport onmessage (simulates HTTP request)
|
|
mockClientTransport.onmessage?.(initMessage);
|
|
|
|
// Wait for message to be relayed through Redis
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// 5. Verify server received the init message
|
|
expect(serverReceivedMessages).toHaveLength(1);
|
|
expect(serverReceivedMessages[0]).toMatchObject({
|
|
jsonrpc: '2.0',
|
|
id: 'init-1',
|
|
method: 'initialize'
|
|
});
|
|
|
|
// 6. Simulate server responding (this should get relayed back to client)
|
|
const initResponse: JSONRPCMessage = {
|
|
jsonrpc: '2.0',
|
|
id: 'init-1',
|
|
result: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {
|
|
tools: {},
|
|
prompts: {},
|
|
resources: {}
|
|
},
|
|
serverInfo: {
|
|
name: 'example-server',
|
|
version: '1.0.0'
|
|
}
|
|
}
|
|
};
|
|
|
|
await serverTransport.send(initResponse, { relatedRequestId: 'init-1' });
|
|
|
|
// Wait for response to be relayed back
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// 7. Verify client transport received the response
|
|
expect(mockClientTransport.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
jsonrpc: '2.0',
|
|
id: 'init-1',
|
|
result: expect.objectContaining({
|
|
protocolVersion: '2024-11-05',
|
|
serverInfo: expect.objectContaining({
|
|
name: 'example-server'
|
|
})
|
|
})
|
|
}),
|
|
{ relatedRequestId: 'init-1' }
|
|
);
|
|
|
|
// Cleanup
|
|
await cleanup();
|
|
await shutdownSession(sessionId);
|
|
serverCleanup(); // Clean up MCP server intervals
|
|
|
|
// Ensure server transport is closed
|
|
await serverTransport.close();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
});
|
|
|
|
it('should handle tools/list request through Redis relay', async () => {
|
|
// Set up server and mock client
|
|
const { server, cleanup: serverCleanup } = createMcpServer();
|
|
const serverTransport = new ServerRedisTransport(sessionId);
|
|
serverTransport.onclose = serverCleanup;
|
|
await server.connect(serverTransport);
|
|
|
|
const mockClientTransport: Transport = {
|
|
onmessage: undefined,
|
|
onclose: undefined,
|
|
onerror: undefined,
|
|
send: jest.fn(() => Promise.resolve()),
|
|
close: jest.fn(() => Promise.resolve()),
|
|
start: jest.fn(() => Promise.resolve())
|
|
};
|
|
|
|
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
|
|
|
|
// Send tools/list request
|
|
const toolsListMessage: JSONRPCMessage = {
|
|
jsonrpc: '2.0',
|
|
id: 'tools-1',
|
|
method: 'tools/list',
|
|
params: {}
|
|
};
|
|
|
|
mockClientTransport.onmessage?.(toolsListMessage);
|
|
|
|
// Wait for processing and response
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Verify client received a response with tools
|
|
expect(mockClientTransport.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
jsonrpc: '2.0',
|
|
id: 'tools-1',
|
|
result: expect.objectContaining({
|
|
tools: expect.any(Array)
|
|
})
|
|
}),
|
|
undefined
|
|
);
|
|
|
|
// Cleanup
|
|
await cleanup();
|
|
await shutdownSession(sessionId);
|
|
serverCleanup(); // Clean up MCP server intervals
|
|
|
|
// Ensure server transport is closed
|
|
await serverTransport.close();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
});
|
|
|
|
it('should handle notifications through Redis relay', async () => {
|
|
// Set up server and mock client
|
|
const { server, cleanup: serverCleanup } = createMcpServer();
|
|
const serverTransport = new ServerRedisTransport(sessionId);
|
|
serverTransport.onclose = serverCleanup;
|
|
await server.connect(serverTransport);
|
|
|
|
const mockClientTransport: Transport = {
|
|
onmessage: undefined,
|
|
onclose: undefined,
|
|
onerror: undefined,
|
|
send: jest.fn(() => Promise.resolve()),
|
|
close: jest.fn(() => Promise.resolve()),
|
|
start: jest.fn(() => Promise.resolve())
|
|
};
|
|
|
|
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
|
|
|
|
// Set up notification subscription manually since notifications don't have an id
|
|
const notificationChannel = `mcp:shttp:toclient:${sessionId}:__GET_stream`;
|
|
const notificationCleanup = await mockRedis.createSubscription(notificationChannel, async (redisMessageJson) => {
|
|
const redisMessage = JSON.parse(redisMessageJson);
|
|
if (redisMessage.type === 'mcp') {
|
|
await mockClientTransport.send(redisMessage.message, redisMessage.options);
|
|
}
|
|
}, (error) => {
|
|
mockClientTransport.onerror?.(error);
|
|
});
|
|
|
|
// Send a notification from server (notifications don't have an id)
|
|
const notification: JSONRPCMessage = {
|
|
jsonrpc: '2.0',
|
|
method: 'notifications/message',
|
|
params: {
|
|
level: 'info',
|
|
logger: 'test',
|
|
data: 'Test notification'
|
|
}
|
|
};
|
|
|
|
await serverTransport.send(notification);
|
|
|
|
// Wait for notification to be delivered
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// Verify client received the notification
|
|
expect(mockClientTransport.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
jsonrpc: '2.0',
|
|
method: 'notifications/message',
|
|
params: expect.objectContaining({
|
|
level: 'info',
|
|
data: 'Test notification'
|
|
})
|
|
}),
|
|
undefined
|
|
);
|
|
|
|
// Cleanup notification subscription
|
|
await notificationCleanup();
|
|
|
|
// Cleanup
|
|
await cleanup();
|
|
await shutdownSession(sessionId);
|
|
serverCleanup(); // Clean up MCP server intervals
|
|
|
|
// Ensure server transport is closed
|
|
await serverTransport.close();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
});
|
|
|
|
it('should not create response subscriptions for notifications', async () => {
|
|
const mockClientTransport: Transport = {
|
|
onmessage: undefined,
|
|
onclose: undefined,
|
|
onerror: undefined,
|
|
send: jest.fn(() => Promise.resolve()),
|
|
close: jest.fn(() => Promise.resolve()),
|
|
start: jest.fn(() => Promise.resolve())
|
|
};
|
|
|
|
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
|
|
|
|
// Send a notification (no id field)
|
|
const notification: JSONRPCMessage = {
|
|
jsonrpc: '2.0',
|
|
method: 'notifications/initialized',
|
|
params: {}
|
|
};
|
|
|
|
mockClientTransport.onmessage?.(notification);
|
|
|
|
// Wait for processing
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// Should not create any response channel subscriptions for notifications
|
|
// (we can't easily test this directly, but we can ensure no errors occur)
|
|
expect(mockClientTransport.send).not.toHaveBeenCalled();
|
|
|
|
await cleanup();
|
|
});
|
|
});
|
|
}); |