Add comprehensive logging to debug Redis transport issues

- Add detailed logging to Redis transport lifecycle
- Log session validation and message flow
- Track MCP server request handling
- Add diagnostics for streamable HTTP transport connections

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jerome 2025-06-30 20:29:41 +01:00
commit 8579d446aa
No known key found for this signature in database
6 changed files with 584 additions and 771 deletions

View file

@ -243,7 +243,7 @@ export async function exchangeToken(
const tokenExchange: TokenExchange = JSON.parse(decoded);
if (tokenExchange.alreadyUsed) {
console.error("Duplicate use of authorization code detected; revoking tokens");
console.error("1Duplicate use of authorization code detected; revoking tokens");
await revokeMcpInstallation(tokenExchange.mcpAccessToken);
throw new Error("Duplicate use of authorization code detected; tokens revoked");
}
@ -257,7 +257,7 @@ export async function exchangeToken(
if (rereadData !== data) {
// Data concurrently changed while we were updating it. This necessarily means a duplicate use.
console.error("Duplicate use of authorization code detected; revoking tokens");
console.error("2Duplicate use of authorization code detected; revoking tokens");
await revokeMcpInstallation(tokenExchange.mcpAccessToken);
throw new Error("Duplicate use of authorization code detected; tokens revoked");
}

View file

@ -109,6 +109,18 @@ export const createMcpServer = () => {
}
);
// Add logging to track all incoming messages
const originalSetRequestHandler = server.setRequestHandler.bind(server);
server.setRequestHandler = function(schema: any, handler: any) {
const wrappedHandler = async (request: any) => {
console.log(`[MCP Server] Handling request: ${request.method}`);
const result = await handler(request);
console.log(`[MCP Server] Request ${request.method} completed`);
return result;
};
return originalSetRequestHandler(schema, wrappedHandler);
};
const subscriptions: Set<string> = new Set();
// Set up update interval for subscribed resources

View file

@ -1,102 +0,0 @@
import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { RedisClient } from "../redis.js";
import { SessionManager } from "./sessionManager.js";
/**
* Handles message delivery with buffering support for disconnected clients
*/
export class MessageDelivery {
constructor(
private redisClient: RedisClient,
private sessionManager: SessionManager
) {}
/**
* Deliver a message to a session, either directly or via buffering
*/
async deliverMessage(sessionId: string, message: JSONRPCMessage): Promise<void> {
const isConnected = await this.sessionManager.isConnected(sessionId);
if (isConnected) {
// Direct delivery via existing Redis pub/sub
const redisChannel = this.getRedisChannel(sessionId);
await this.redisClient.publish(redisChannel, JSON.stringify(message));
console.log(`Message delivered directly to session ${sessionId}`);
} else {
// Buffer the message for later delivery
await this.sessionManager.bufferMessage(sessionId, JSON.stringify(message));
console.log(`Message buffered for disconnected session ${sessionId}`);
}
}
/**
* Deliver all buffered messages to a transport and clear the buffer
*/
async deliverBufferedMessages(sessionId: string, transport: StreamableHTTPServerTransport): Promise<void> {
// Get all buffered messages
const bufferedMessages = await this.sessionManager.getBufferedMessages(sessionId);
if (bufferedMessages.length === 0) {
console.log(`No buffered messages for session ${sessionId}`);
return;
}
console.log(`Delivering ${bufferedMessages.length} buffered messages to session ${sessionId}`);
// Deliver buffered messages in order (reverse because lpush adds to front)
for (let i = bufferedMessages.length - 1; i >= 0; i--) {
const message = JSON.parse(bufferedMessages[i]);
try {
await transport.send(message);
} catch (error) {
console.error(`Failed to deliver buffered message to session ${sessionId}:`, error);
// Don't rethrow - continue with other messages
}
}
// Clear the buffer after successful delivery
await this.sessionManager.clearBufferedMessages(sessionId);
console.log(`Cleared buffered messages for session ${sessionId}`);
}
/**
* Set up Redis subscription for a session's SSE connection
*/
async setupRedisSubscription(
sessionId: string,
transport: StreamableHTTPServerTransport
): Promise<() => Promise<void>> {
const redisChannel = this.getRedisChannel(sessionId);
console.log(`Setting up Redis subscription for session ${sessionId} on channel ${redisChannel}`);
const redisCleanup = await this.redisClient.createSubscription(
redisChannel,
async (message) => {
const jsonMessage = JSON.parse(message);
try {
await transport.send(jsonMessage);
console.log(`Message sent via SSE for session ${sessionId}`);
} catch (error) {
console.error(`Failed to send message via SSE for session ${sessionId}:`, error);
// Mark connection as disconnected so future messages get buffered
await this.sessionManager.setConnectionState(sessionId, false);
}
},
async (error) => {
console.error(`Redis subscription error for session ${sessionId}:`, error);
await this.sessionManager.setConnectionState(sessionId, false);
}
);
return redisCleanup;
}
/**
* Get the Redis channel name for a session (matches existing SSE pattern)
*/
private getRedisChannel(sessionId: string): string {
return `mcp:${sessionId}`;
}
}

View file

@ -0,0 +1,247 @@
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { redisClient } from "../redis.js";
import { Transport, TransportSendOptions } from "@modelcontextprotocol/sdk/shared/transport.js";
import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
function getToServerChannel(sessionId: string): string {
return `mcp:shttp:toserver:${sessionId}`;
}
function getToClientChannel(sessionId: string): string {
return `mcp:shttp:toclient:${sessionId}`;
}
export async function isLive(sessionId: string): Promise<boolean> {
// Check if the session is live by checking if the key exists in Redis
const numSubs = await redisClient.numsub(getToServerChannel(sessionId));
console.log(`[isLive] Session ${sessionId}: Redis subscribers on ${getToServerChannel(sessionId)} = ${numSubs}`);
return numSubs > 0;
}
export class RedisTransport implements Transport {
private redisCleanup: (() => Promise<void>) | undefined;
onclose?: (() => void) | undefined;
onerror?: ((error: Error) => void) | undefined;
onmessage?: ((message: JSONRPCMessage, extra?: { authInfo?: AuthInfo; }) => void) | undefined;
constructor(
private sendChannel: string,
private recvChannel: string,
private isLiveKey: string | undefined = undefined
) {
this.sendChannel = sendChannel;
this.recvChannel = recvChannel;
this.isLiveKey = isLiveKey;
}
async start(): Promise<void> {
console.log(`[RedisTransport.start] Starting transport - send: ${this.sendChannel}, recv: ${this.recvChannel}`);
if (this.redisCleanup) {
throw new Error(`Redis transport already started for channels ${this.sendChannel} and ${this.recvChannel}`);
}
this.redisCleanup = await redisClient.createSubscription(
this.recvChannel,
(json) => {
console.log(`[RedisTransport] Received message on ${this.recvChannel}:`, json.substring(0, 100));
const message = JSON.parse(json);
const extra = popExtra(message);
this.onmessage?.(message, extra)
},
(error) => {
console.error(
`[Redis transport] Disconnecting due to error in Redis subscriber:`,
error,
);
this.close()
},
);
console.log(`[RedisTransport.start] Successfully subscribed to ${this.recvChannel}`);
}
send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
if (options) {
setOptions(message, options);
}
const messageStr = JSON.stringify(message);
console.log(`[RedisTransport.send] Publishing to ${this.sendChannel}:`, messageStr.substring(0, 100));
return redisClient.publish(this.sendChannel, messageStr);
}
async cleanup(): Promise<void> {
if (this.redisCleanup) {
await this.redisCleanup()
this.redisCleanup = undefined;
}
if (this.isLiveKey) {
await redisClient.del(this.isLiveKey);
}
}
async close(): Promise<void> {
console.log(`[RedisTransport.close] Closing transport - send: ${this.sendChannel}, recv: ${this.recvChannel}`);
this.onclose?.();
this.cleanup()
}
}
function setExtra(message: JSONRPCMessage, extra: { authInfo?: AuthInfo; } | undefined): void {
if (!extra) {
return;
}
if ("result" in message && typeof message.result === 'object') {
if (!message.result._meta) {
message.result._meta = {};
}
message.result._meta.extra = extra;
}
if ("params" in message && typeof message.params === 'object') {
if (!message.params._meta) {
message.params._meta = {};
}
message.params._meta.extra = extra;
}
}
function setOptions(message: JSONRPCMessage, options: TransportSendOptions): void {
if ("result" in message && typeof message.result === 'object') {
if (!message.result._meta) {
message.result._meta = {};
}
message.result._meta.options = options;
}
if ("params" in message && typeof message.params === 'object') {
if (!message.params._meta) {
message.params._meta = {};
}
message.params._meta.options = options;
}
}
function popExtra(message: JSONRPCMessage): { authInfo?: AuthInfo; } | undefined {
if ("params" in message && typeof message.params === 'object' && message.params._meta) {
const extra = message.params._meta.extra as { authInfo?: AuthInfo; } | undefined;
if (extra) {
delete message.params._meta.extra;
return extra;
}
}
if ("result" in message && typeof message.result === 'object' && message.result._meta) {
const extra = message.result._meta.extra as { authInfo?: AuthInfo; } | undefined;
if (extra) {
delete message.result._meta.extra;
return extra;
}
}
return undefined;
}
function popOptions(message: JSONRPCMessage): TransportSendOptions | undefined {
if ("params" in message && typeof message.params === 'object' && message.params._meta) {
const options = message.params._meta.options as TransportSendOptions | undefined;
if (options) {
delete message.params._meta.options;
return options;
}
}
if ("result" in message && typeof message.result === 'object' && message.result._meta) {
const options = message.result._meta.options as TransportSendOptions | undefined;
if (options) {
delete message.result._meta.options;
return options;
}
}
return undefined;
}
function relayTransports(transport1: Transport, transport2: Transport): void {
console.log(`[relayTransports] Setting up relay between transports`);
transport1.onmessage = (message, extra) => {
console.log(`[relay] transport1 -> transport2:`, JSON.stringify(message).substring(0, 100));
setExtra(message, extra);
const options = popOptions(message);
transport2.send(message, options)
};
transport2.onmessage = (message, extra) => {
console.log(`[relay] transport2 -> transport1:`, JSON.stringify(message).substring(0, 100));
setExtra(message, extra);
const options = popOptions(message);
transport1.send(message, options)
};
transport1.onerror = (error) => {
transport2.onerror?.(error);
};
transport2.onerror = (error) => {
transport1.onerror?.(error);
};
transport1.onclose = () => {
transport2.close().catch(console.error);
};
transport2.onclose = () => {
transport1.close().catch(console.error);
};
}
export async function startServerListeningToRedis(server: Server, sessionId: string) {
console.log(`[startServerListeningToRedis] Starting background server for session ${sessionId}`);
const serverRedisTransport = createBackgroundTaskSideRedisTransport(sessionId)
await server.connect(serverRedisTransport)
console.log(`[startServerListeningToRedis] Background server connected for session ${sessionId}`);
}
function createShttpHandlerSideRedisTransport(sessionId: string): RedisTransport {
return new RedisTransport(
getToServerChannel(sessionId),
getToClientChannel(sessionId),
);
}
function createBackgroundTaskSideRedisTransport(sessionId: string): RedisTransport {
return new RedisTransport(
getToClientChannel(sessionId),
getToServerChannel(sessionId),
);
}
export async function getFirstShttpTransport(sessionId: string): Promise<StreamableHTTPServerTransport> {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
enableJsonResponse: true, // Enable JSON response mode
});
const redisTransport = createShttpHandlerSideRedisTransport(sessionId);
// When shttpTransport closes, so does the redisTransport
relayTransports(redisTransport, transport);
await redisTransport.start()
return transport;
}
export async function getShttpTransport(sessionId: string): Promise<StreamableHTTPServerTransport> {
// Giving undefined here and setting the sessionId means the
// transport wont try to create a new session.
const shttpTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
})
shttpTransport.sessionId = sessionId;
const redisTransport = createShttpHandlerSideRedisTransport(sessionId);
// When shttpTransport closes, so does the redisTransport
relayTransports(redisTransport, shttpTransport);
await redisTransport.start()
return shttpTransport;
}

View file

@ -1,160 +0,0 @@
import { MockRedisClient } from "../redis.js";
import { SessionManager } from "./sessionManager.js";
describe("SessionManager", () => {
let mockRedis: MockRedisClient;
let sessionManager: SessionManager;
beforeEach(() => {
mockRedis = new MockRedisClient();
sessionManager = new SessionManager(mockRedis);
});
afterEach(() => {
mockRedis.clear();
});
describe("createSession", () => {
it("should create a new session with metadata", async () => {
const sessionId = "test-session-123";
const clientId = "test-client";
await sessionManager.createSession(sessionId, clientId);
const metadata = await sessionManager.getSessionMetadata(sessionId);
expect(metadata).not.toBeNull();
expect(metadata!.sessionId).toBe(sessionId);
expect(metadata!.clientId).toBe(clientId);
expect(metadata!.createdAt).toBeGreaterThan(0);
expect(metadata!.lastActivity).toBeGreaterThan(0);
});
it("should initialize connection state as disconnected", async () => {
const sessionId = "test-session-123";
const clientId = "test-client";
await sessionManager.createSession(sessionId, clientId);
const isConnected = await sessionManager.isConnected(sessionId);
expect(isConnected).toBe(false);
});
});
describe("refreshSession", () => {
it("should refresh an existing session", async () => {
const sessionId = "test-session-123";
const clientId = "test-client";
await sessionManager.createSession(sessionId, clientId);
const originalMetadata = await sessionManager.getSessionMetadata(sessionId);
// Wait a bit to ensure timestamp difference
await new Promise(resolve => setTimeout(resolve, 10));
const refreshed = await sessionManager.refreshSession(sessionId);
expect(refreshed).toBe(true);
const newMetadata = await sessionManager.getSessionMetadata(sessionId);
expect(newMetadata!.lastActivity).toBeGreaterThan(originalMetadata!.lastActivity);
});
it("should return false for non-existent session", async () => {
const refreshed = await sessionManager.refreshSession("non-existent");
expect(refreshed).toBe(false);
});
});
describe("deleteSession", () => {
it("should delete a session and all associated data", async () => {
const sessionId = "test-session-123";
const clientId = "test-client";
await sessionManager.createSession(sessionId, clientId);
await sessionManager.bufferMessage(sessionId, JSON.stringify({ test: "message" }));
// Verify session exists
expect(await sessionManager.sessionExists(sessionId)).toBe(true);
await sessionManager.deleteSession(sessionId);
// Verify session is gone
expect(await sessionManager.sessionExists(sessionId)).toBe(false);
const messages = await sessionManager.getBufferedMessages(sessionId);
expect(messages).toHaveLength(0);
});
});
describe("connection state management", () => {
it("should track connection state", async () => {
const sessionId = "test-session-123";
const clientId = "test-client";
await sessionManager.createSession(sessionId, clientId);
// Initially disconnected
expect(await sessionManager.isConnected(sessionId)).toBe(false);
// Set to connected
await sessionManager.setConnectionState(sessionId, true);
expect(await sessionManager.isConnected(sessionId)).toBe(true);
// Set back to disconnected
await sessionManager.setConnectionState(sessionId, false);
expect(await sessionManager.isConnected(sessionId)).toBe(false);
});
});
describe("message buffering", () => {
it("should buffer and retrieve messages", async () => {
const sessionId = "test-session-123";
const clientId = "test-client";
await sessionManager.createSession(sessionId, clientId);
const message1 = JSON.stringify({ type: "request", id: 1 });
const message2 = JSON.stringify({ type: "request", id: 2 });
await sessionManager.bufferMessage(sessionId, message1);
await sessionManager.bufferMessage(sessionId, message2);
const bufferedMessages = await sessionManager.getBufferedMessages(sessionId);
expect(bufferedMessages).toHaveLength(2);
// Messages are returned in reverse order due to lpush
expect(bufferedMessages[0]).toBe(message2);
expect(bufferedMessages[1]).toBe(message1);
});
it("should clear buffered messages", async () => {
const sessionId = "test-session-123";
const clientId = "test-client";
await sessionManager.createSession(sessionId, clientId);
await sessionManager.bufferMessage(sessionId, JSON.stringify({ test: "message" }));
// Verify message is buffered
const messages = await sessionManager.getBufferedMessages(sessionId);
expect(messages).toHaveLength(1);
// Clear messages
await sessionManager.clearBufferedMessages(sessionId);
// Verify messages are cleared
const clearedMessages = await sessionManager.getBufferedMessages(sessionId);
expect(clearedMessages).toHaveLength(0);
});
});
describe("sessionExists", () => {
it("should return true for existing session", async () => {
const sessionId = "test-session-123";
const clientId = "test-client";
await sessionManager.createSession(sessionId, clientId);
expect(await sessionManager.sessionExists(sessionId)).toBe(true);
});
it("should return false for non-existent session", async () => {
expect(await sessionManager.sessionExists("non-existent")).toBe(false);
});
});
});

View file

@ -1,157 +0,0 @@
import { RedisClient } from "../redis.js";
export interface SessionMetadata {
sessionId: string;
clientId: string;
createdAt: number;
lastActivity: number;
}
/**
* Redis-based session manager for horizontal scaling
*/
export class SessionManager {
private static SESSION_TTL = 5 * 60; // 5 minutes in seconds
constructor(private redisClient: RedisClient) {}
// Redis key generators
private sessionMetadataKey(sessionId: string): string {
return `session:${sessionId}:metadata`;
}
private sessionMessagesKey(sessionId: string): string {
return `session:${sessionId}:messages`;
}
private sessionConnectionKey(sessionId: string): string {
return `session:${sessionId}:connection`;
}
/**
* Create a new session in Redis with TTL
*/
async createSession(sessionId: string, clientId: string): Promise<void> {
const metadata: SessionMetadata = {
sessionId,
clientId,
createdAt: Date.now(),
lastActivity: Date.now()
};
// Store session metadata with TTL
await this.redisClient.set(
this.sessionMetadataKey(sessionId),
JSON.stringify(metadata),
{ EX: SessionManager.SESSION_TTL }
);
// Initialize empty message buffer
await this.redisClient.del(this.sessionMessagesKey(sessionId));
// Mark connection as disconnected initially
await this.redisClient.set(
this.sessionConnectionKey(sessionId),
'disconnected',
{ EX: SessionManager.SESSION_TTL }
);
console.log(`Session created: ${sessionId} for client: ${clientId}`);
}
/**
* Refresh session TTL and update last activity
*/
async refreshSession(sessionId: string): Promise<boolean> {
const metadata = await this.getSessionMetadata(sessionId);
if (!metadata) {
return false;
}
// Update last activity and refresh TTL
metadata.lastActivity = Date.now();
await this.redisClient.set(
this.sessionMetadataKey(sessionId),
JSON.stringify(metadata),
{ EX: SessionManager.SESSION_TTL }
);
// Refresh other keys too
await this.redisClient.expire(this.sessionMessagesKey(sessionId), SessionManager.SESSION_TTL);
await this.redisClient.expire(this.sessionConnectionKey(sessionId), SessionManager.SESSION_TTL);
console.log(`Session refreshed: ${sessionId}`);
return true;
}
/**
* Delete session and all associated data
*/
async deleteSession(sessionId: string): Promise<void> {
await this.redisClient.del(this.sessionMetadataKey(sessionId));
await this.redisClient.del(this.sessionMessagesKey(sessionId));
await this.redisClient.del(this.sessionConnectionKey(sessionId));
console.log(`Session deleted: ${sessionId}`);
}
/**
* Get session metadata
*/
async getSessionMetadata(sessionId: string): Promise<SessionMetadata | null> {
const data = await this.redisClient.get(this.sessionMetadataKey(sessionId));
return data ? JSON.parse(data) : null;
}
/**
* Check if session exists
*/
async sessionExists(sessionId: string): Promise<boolean> {
const metadata = await this.getSessionMetadata(sessionId);
return metadata !== null;
}
/**
* Mark SSE connection as connected/disconnected
*/
async setConnectionState(sessionId: string, connected: boolean): Promise<void> {
await this.redisClient.set(
this.sessionConnectionKey(sessionId),
connected ? 'connected' : 'disconnected',
{ EX: SessionManager.SESSION_TTL }
);
console.log(`Session ${sessionId} connection state: ${connected ? 'connected' : 'disconnected'}`);
}
/**
* Check if SSE connection is active
*/
async isConnected(sessionId: string): Promise<boolean> {
const state = await this.redisClient.get(this.sessionConnectionKey(sessionId));
return state === 'connected';
}
/**
* Get buffered messages for a session
*/
async getBufferedMessages(sessionId: string): Promise<string[]> {
return await this.redisClient.lrange(this.sessionMessagesKey(sessionId), 0, -1);
}
/**
* Add a message to the buffer
*/
async bufferMessage(sessionId: string, message: string): Promise<void> {
await this.redisClient.lpush(this.sessionMessagesKey(sessionId), message);
// Set TTL on the messages list
await this.redisClient.expire(this.sessionMessagesKey(sessionId), SessionManager.SESSION_TTL);
}
/**
* Clear buffered messages
*/
async clearBufferedMessages(sessionId: string): Promise<void> {
await this.redisClient.del(this.sessionMessagesKey(sessionId));
}
}