Add inactivity timeout to ServerRedisTransport

- Add 5-minute inactivity timeout to prevent memory leaks
- Reset timer on each message received from client
- Clear timer on transport close
- Add comprehensive tests for timeout behavior
- Fix async/await in redisRelayToMcpServer calls

Sessions will now automatically shutdown after 5 minutes of no client activity.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jerome 2025-07-09 19:42:19 +01:00
commit d779401b5f
No known key found for this signature in database
4 changed files with 3995 additions and 4802 deletions

File diff suppressed because one or more lines are too long

View file

@ -44,7 +44,7 @@ describe('Redis Transport Integration', () => {
};
// 3. Set up the Redis relay (this is what happens in the HTTP handler)
const cleanup = redisRelayToMcpServer(sessionId, mockClientTransport);
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
// Track messages received by server
const serverReceivedMessages: JSONRPCMessage[] = [];
@ -148,7 +148,7 @@ describe('Redis Transport Integration', () => {
start: jest.fn(() => Promise.resolve())
};
const cleanup = redisRelayToMcpServer(sessionId, mockClientTransport);
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
// Send tools/list request
const toolsListMessage: JSONRPCMessage = {
@ -202,7 +202,7 @@ describe('Redis Transport Integration', () => {
start: jest.fn(() => Promise.resolve())
};
const cleanup = redisRelayToMcpServer(sessionId, mockClientTransport);
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`;
@ -268,7 +268,7 @@ describe('Redis Transport Integration', () => {
start: jest.fn(() => Promise.resolve())
};
const cleanup = redisRelayToMcpServer(sessionId, mockClientTransport);
const cleanup = await redisRelayToMcpServer(sessionId, mockClientTransport);
// Send a notification (no id field)
const notification: JSONRPCMessage = {

View file

@ -172,7 +172,7 @@ describe('Redis Transport', () => {
});
it('should set up message relay from transport to server', async () => {
const cleanup = redisRelayToMcpServer(sessionId, mockTransport);
const cleanup = await redisRelayToMcpServer(sessionId, mockTransport);
// Simulate a message from the transport
const requestMessage: JSONRPCMessage = {
@ -203,7 +203,7 @@ describe('Redis Transport', () => {
});
it('should subscribe to response channel for request messages', async () => {
const cleanup = redisRelayToMcpServer(sessionId, mockTransport);
const cleanup = await redisRelayToMcpServer(sessionId, mockTransport);
const requestMessage: JSONRPCMessage = {
jsonrpc: '2.0',
@ -241,7 +241,7 @@ describe('Redis Transport', () => {
});
it('should not subscribe for notification messages (no id)', async () => {
const cleanup = redisRelayToMcpServer(sessionId, mockTransport);
const cleanup = await redisRelayToMcpServer(sessionId, mockTransport);
const notificationMessage: JSONRPCMessage = {
jsonrpc: '2.0',
@ -337,7 +337,7 @@ describe('Redis Transport', () => {
start: jest.fn(() => Promise.resolve())
};
const cleanup = redisRelayToMcpServer(sessionId, clientTransport);
const cleanup = await redisRelayToMcpServer(sessionId, clientTransport);
// Client sends a request
const listToolsRequest: JSONRPCMessage = {
@ -434,4 +434,104 @@ describe('Redis Transport', () => {
expect(onCloseMock).toHaveBeenCalled();
});
});
describe('Inactivity Timeout', () => {
const sessionId = 'test-inactivity-session';
beforeEach(() => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
});
afterEach(() => {
jest.useRealTimers();
});
it('should shutdown session after 5 minutes of inactivity', async () => {
const transport = new ServerRedisTransport(sessionId);
const shutdownSpy = jest.spyOn(mockRedis, 'publish');
await transport.start();
// Fast-forward time by 5 minutes
jest.advanceTimersByTime(5 * 60 * 1000);
// Should have published shutdown control message
expect(shutdownSpy).toHaveBeenCalledWith(
`mcp:control:${sessionId}`,
expect.stringContaining('"action":"SHUTDOWN"')
);
await transport.close();
});
it('should reset timeout when message is received', async () => {
const transport = new ServerRedisTransport(sessionId);
const onMessageMock = jest.fn();
transport.onmessage = onMessageMock;
await transport.start();
// Fast-forward 4 minutes
jest.advanceTimersByTime(4 * 60 * 1000);
// Manually publish a message to trigger the subscription handler
const testMessage = { jsonrpc: '2.0', method: 'ping' };
await mockRedis.publish(
`mcp:shttp:toserver:${sessionId}`,
JSON.stringify({
type: 'mcp',
message: testMessage
})
);
// Wait for message to be processed
await new Promise(resolve => setImmediate(resolve));
// Verify message was received
expect(onMessageMock).toHaveBeenCalledWith(testMessage, undefined);
// Clear the publish spy to check only future calls
const shutdownSpy = jest.spyOn(mockRedis, 'publish');
shutdownSpy.mockClear();
// Fast-forward 4 more minutes (total 8, but only 4 since last message)
jest.advanceTimersByTime(4 * 60 * 1000);
// Should not have shutdown yet
expect(shutdownSpy).not.toHaveBeenCalledWith(
`mcp:control:${sessionId}`,
expect.stringContaining('"action":"SHUTDOWN"')
);
// Fast-forward 2 more minutes to exceed timeout
jest.advanceTimersByTime(2 * 60 * 1000);
// Now should have shutdown
expect(shutdownSpy).toHaveBeenCalledWith(
`mcp:control:${sessionId}`,
expect.stringContaining('"action":"SHUTDOWN"')
);
await transport.close();
}, 10000);
it('should clear timeout on close', async () => {
const transport = new ServerRedisTransport(sessionId);
const shutdownSpy = jest.spyOn(mockRedis, 'publish');
await transport.start();
// Close transport before timeout
await transport.close();
// Fast-forward past timeout
jest.advanceTimersByTime(10 * 60 * 1000);
// Should not have triggered shutdown
expect(shutdownSpy).not.toHaveBeenCalledWith(
`mcp:control:${sessionId}`,
expect.stringContaining('"action":"SHUTDOWN"')
);
});
});
});

View file

@ -134,6 +134,8 @@ export class ServerRedisTransport implements Transport {
private controlCleanup?: (() => Promise<void>);
private serverCleanup?: (() => Promise<void>);
private shouldShutdown = false;
private inactivityTimeout?: NodeJS.Timeout;
private readonly INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
onclose?: (() => void) | undefined;
onerror?: ((error: Error) => void) | undefined;
@ -144,7 +146,29 @@ export class ServerRedisTransport implements Transport {
this._sessionId = sessionId;
}
private resetInactivityTimer(): void {
// Clear existing timeout if any
if (this.inactivityTimeout) {
clearTimeout(this.inactivityTimeout);
}
// Set new timeout
this.inactivityTimeout = setTimeout(() => {
console.log(`Session ${this._sessionId} timed out due to inactivity`);
void shutdownSession(this._sessionId);
}, this.INACTIVITY_TIMEOUT_MS);
}
private clearInactivityTimer(): void {
if (this.inactivityTimeout) {
clearTimeout(this.inactivityTimeout);
this.inactivityTimeout = undefined;
}
}
async start(): Promise<void> {
// Start inactivity timer
this.resetInactivityTimer();
// Subscribe to MCP messages from clients
const serverChannel = getToServerChannel(this._sessionId);
@ -153,6 +177,8 @@ export class ServerRedisTransport implements Transport {
(messageJson) => {
const redisMessage = JSON.parse(messageJson) as RedisMessage;
if (redisMessage.type === 'mcp') {
// Reset inactivity timer on each message from client
this.resetInactivityTimer();
this.onmessage?.(redisMessage.message, redisMessage.extra);
}
},
@ -191,6 +217,8 @@ export class ServerRedisTransport implements Transport {
}
async close(): Promise<void> {
// Clear inactivity timer
this.clearInactivityTimer();
// Clean up server message subscription
if (this.serverCleanup) {