mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat: Implement keepalive functionality for SSE and StreamableHTTP connections (#442)
This commit is contained in:
@@ -12,7 +12,9 @@ import { RequestContextService } from './requestContextService.js';
|
|||||||
import { IUser } from '../types/index.js';
|
import { IUser } from '../types/index.js';
|
||||||
import { resolveOAuthUserFromToken } from '../utils/oauthBearer.js';
|
import { resolveOAuthUserFromToken } from '../utils/oauthBearer.js';
|
||||||
|
|
||||||
export const transports: { [sessionId: string]: { transport: Transport; group: string; needsInitialization?: boolean } } = {};
|
export const transports: {
|
||||||
|
[sessionId: string]: { transport: Transport; group: string; needsInitialization?: boolean };
|
||||||
|
} = {};
|
||||||
|
|
||||||
// Session creation locks to prevent concurrent session creation conflicts
|
// Session creation locks to prevent concurrent session creation conflicts
|
||||||
const sessionCreationLocks: { [sessionId: string]: Promise<StreamableHTTPServerTransport> } = {};
|
const sessionCreationLocks: { [sessionId: string]: Promise<StreamableHTTPServerTransport> } = {};
|
||||||
@@ -211,7 +213,25 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
|||||||
const transport = new SSEServerTransport(messagesPath, res);
|
const transport = new SSEServerTransport(messagesPath, res);
|
||||||
transports[transport.sessionId] = { transport, group: group };
|
transports[transport.sessionId] = { transport, group: group };
|
||||||
|
|
||||||
|
// Send keepalive ping every 30 seconds to prevent client from closing connection
|
||||||
|
const keepAlive = setInterval(() => {
|
||||||
|
try {
|
||||||
|
// Send a ping notification to keep the connection alive
|
||||||
|
transport.send({ jsonrpc: '2.0', method: 'ping' });
|
||||||
|
console.log(`Sent keepalive ping for SSE session: ${transport.sessionId}`);
|
||||||
|
} catch (e) {
|
||||||
|
// If sending a ping fails, the connection is likely broken.
|
||||||
|
// Log the error and clear the interval to prevent further attempts.
|
||||||
|
console.warn(
|
||||||
|
`Failed to send keepalive ping for SSE session ${transport.sessionId}, cleaning up interval:`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
}
|
||||||
|
}, 30000); // Send ping every 30 seconds
|
||||||
|
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
|
clearInterval(keepAlive);
|
||||||
delete transports[transport.sessionId];
|
delete transports[transport.sessionId];
|
||||||
deleteMcpServer(transport.sessionId);
|
deleteMcpServer(transport.sessionId);
|
||||||
console.log(`SSE connection closed: ${transport.sessionId}`);
|
console.log(`SSE connection closed: ${transport.sessionId}`);
|
||||||
@@ -276,8 +296,14 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to create a session with a specific sessionId
|
// Helper function to create a session with a specific sessionId
|
||||||
async function createSessionWithId(sessionId: string, group: string, username?: string): Promise<StreamableHTTPServerTransport> {
|
async function createSessionWithId(
|
||||||
console.log(`[SESSION REBUILD] Starting session rebuild for ID: ${sessionId}${username ? ` for user: ${username}` : ''}`);
|
sessionId: string,
|
||||||
|
group: string,
|
||||||
|
username?: string,
|
||||||
|
): Promise<StreamableHTTPServerTransport> {
|
||||||
|
console.log(
|
||||||
|
`[SESSION REBUILD] Starting session rebuild for ID: ${sessionId}${username ? ` for user: ${username}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Create a new server instance to ensure clean state
|
// Create a new server instance to ensure clean state
|
||||||
const server = getMcpServer(sessionId, group);
|
const server = getMcpServer(sessionId, group);
|
||||||
@@ -285,18 +311,42 @@ async function createSessionWithId(sessionId: string, group: string, username?:
|
|||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => sessionId, // Use the specified sessionId
|
sessionIdGenerator: () => sessionId, // Use the specified sessionId
|
||||||
onsessioninitialized: (initializedSessionId) => {
|
onsessioninitialized: (initializedSessionId) => {
|
||||||
console.log(`[SESSION REBUILD] onsessioninitialized triggered for ID: ${initializedSessionId}`); // New log
|
console.log(
|
||||||
|
`[SESSION REBUILD] onsessioninitialized triggered for ID: ${initializedSessionId}`,
|
||||||
|
); // New log
|
||||||
if (initializedSessionId === sessionId) {
|
if (initializedSessionId === sessionId) {
|
||||||
transports[sessionId] = { transport, group };
|
transports[sessionId] = { transport, group };
|
||||||
console.log(`[SESSION REBUILD] Session ${sessionId} initialized successfully${username ? ` for user: ${username}` : ''}`);
|
console.log(
|
||||||
|
`[SESSION REBUILD] Session ${sessionId} initialized successfully${username ? ` for user: ${username}` : ''}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[SESSION REBUILD] Session ID mismatch: expected ${sessionId}, got ${initializedSessionId}`);
|
console.warn(
|
||||||
|
`[SESSION REBUILD] Session ID mismatch: expected ${sessionId}, got ${initializedSessionId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send keepalive ping every 30 seconds to prevent client from closing connection
|
||||||
|
const keepAlive = setInterval(() => {
|
||||||
|
try {
|
||||||
|
// Send a ping notification to keep the connection alive
|
||||||
|
transport.send({ jsonrpc: '2.0', method: 'ping' });
|
||||||
|
console.log(`Sent keepalive ping for StreamableHTTP session: ${sessionId}`);
|
||||||
|
} catch (e) {
|
||||||
|
// If sending a ping fails, the connection is likely broken.
|
||||||
|
// Log the error and clear the interval to prevent further attempts.
|
||||||
|
console.warn(
|
||||||
|
`Failed to send keepalive ping for StreamableHTTP session ${sessionId}, cleaning up interval:`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
}
|
||||||
|
}, 30000); // Send ping every 30 seconds
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
console.log(`[SESSION REBUILD] Transport closed: ${sessionId}`);
|
console.log(`[SESSION REBUILD] Transport closed: ${sessionId}`);
|
||||||
|
clearInterval(keepAlive);
|
||||||
delete transports[sessionId];
|
delete transports[sessionId];
|
||||||
deleteMcpServer(sessionId);
|
deleteMcpServer(sessionId);
|
||||||
};
|
};
|
||||||
@@ -305,37 +355,66 @@ async function createSessionWithId(sessionId: string, group: string, username?:
|
|||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|
||||||
// Wait for the server to fully initialize
|
// Wait for the server to fully initialize
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
// Ensure the transport is properly initialized
|
// Ensure the transport is properly initialized
|
||||||
if (!transports[sessionId]) {
|
if (!transports[sessionId]) {
|
||||||
console.warn(`[SESSION REBUILD] Transport not found in transports after initialization, forcing registration`);
|
console.warn(
|
||||||
|
`[SESSION REBUILD] Transport not found in transports after initialization, forcing registration`,
|
||||||
|
);
|
||||||
transports[sessionId] = { transport, group, needsInitialization: true };
|
transports[sessionId] = { transport, group, needsInitialization: true };
|
||||||
} else {
|
} else {
|
||||||
// Mark the session as needing initialization
|
// Mark the session as needing initialization
|
||||||
transports[sessionId].needsInitialization = true;
|
transports[sessionId].needsInitialization = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[SESSION REBUILD] Session ${sessionId} created but not yet initialized. It will be initialized on first use.`);
|
console.log(
|
||||||
|
`[SESSION REBUILD] Session ${sessionId} created but not yet initialized. It will be initialized on first use.`,
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[SESSION REBUILD] Successfully rebuilt session ${sessionId} in group: ${group}`);
|
console.log(`[SESSION REBUILD] Successfully rebuilt session ${sessionId} in group: ${group}`);
|
||||||
return transport;
|
return transport;
|
||||||
}
|
}
|
||||||
// Helper function to create a completely new session
|
// Helper function to create a completely new session
|
||||||
async function createNewSession(group: string, username?: string): Promise<StreamableHTTPServerTransport> {
|
async function createNewSession(
|
||||||
|
group: string,
|
||||||
|
username?: string,
|
||||||
|
): Promise<StreamableHTTPServerTransport> {
|
||||||
const newSessionId = randomUUID();
|
const newSessionId = randomUUID();
|
||||||
console.log(`[SESSION NEW] Creating new session with ID: ${newSessionId}${username ? ` for user: ${username}` : ''}`);
|
console.log(
|
||||||
|
`[SESSION NEW] Creating new session with ID: ${newSessionId}${username ? ` for user: ${username}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => newSessionId,
|
sessionIdGenerator: () => newSessionId,
|
||||||
onsessioninitialized: (sessionId) => {
|
onsessioninitialized: (sessionId) => {
|
||||||
transports[sessionId] = { transport, group };
|
transports[sessionId] = { transport, group };
|
||||||
console.log(`[SESSION NEW] New session ${sessionId} initialized successfully${username ? ` for user: ${username}` : ''}`);
|
console.log(
|
||||||
|
`[SESSION NEW] New session ${sessionId} initialized successfully${username ? ` for user: ${username}` : ''}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send keepalive ping every 30 seconds to prevent client from closing connection
|
||||||
|
const keepAlive = setInterval(() => {
|
||||||
|
try {
|
||||||
|
// Send a ping notification to keep the connection alive
|
||||||
|
transport.send({ jsonrpc: '2.0', method: 'ping' });
|
||||||
|
console.log(`Sent keepalive ping for StreamableHTTP session: ${newSessionId}`);
|
||||||
|
} catch (e) {
|
||||||
|
// If sending a ping fails, the connection is likely broken.
|
||||||
|
// Log the error and clear the interval to prevent further attempts.
|
||||||
|
console.warn(
|
||||||
|
`Failed to send keepalive ping for StreamableHTTP session ${newSessionId}, cleaning up interval:`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
}
|
||||||
|
}, 30000); // Send ping every 30 seconds
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
console.log(`[SESSION NEW] Transport closed: ${newSessionId}`);
|
console.log(`[SESSION NEW] Transport closed: ${newSessionId}`);
|
||||||
|
clearInterval(keepAlive);
|
||||||
delete transports[newSessionId];
|
delete transports[newSessionId];
|
||||||
deleteMcpServer(newSessionId);
|
deleteMcpServer(newSessionId);
|
||||||
};
|
};
|
||||||
@@ -380,7 +459,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
let transport: StreamableHTTPServerTransport;
|
let transport: StreamableHTTPServerTransport;
|
||||||
let transportInfo: typeof transports[string] | undefined;
|
let transportInfo: (typeof transports)[string] | undefined;
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
transportInfo = transports[sessionId];
|
transportInfo = transports[sessionId];
|
||||||
@@ -388,7 +467,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
|
|
||||||
if (sessionId && transportInfo) {
|
if (sessionId && transportInfo) {
|
||||||
// Case 1: Session exists and is valid, reuse it
|
// Case 1: Session exists and is valid, reuse it
|
||||||
console.log(`[SESSION REUSE] Reusing existing session: ${sessionId}${username ? ` for user: ${username}` : ''}`);
|
console.log(
|
||||||
|
`[SESSION REUSE] Reusing existing session: ${sessionId}${username ? ` for user: ${username}` : ''}`,
|
||||||
|
);
|
||||||
transport = transportInfo.transport as StreamableHTTPServerTransport;
|
transport = transportInfo.transport as StreamableHTTPServerTransport;
|
||||||
} else if (sessionId) {
|
} else if (sessionId) {
|
||||||
// Case 2: SessionId exists but transport is missing (server restart), check if session rebuild is enabled
|
// Case 2: SessionId exists but transport is missing (server restart), check if session rebuild is enabled
|
||||||
@@ -396,16 +477,22 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
|
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
|
||||||
|
|
||||||
if (enableSessionRebuild) {
|
if (enableSessionRebuild) {
|
||||||
console.log(`[SESSION AUTO-REBUILD] Session ${sessionId} not found, initiating transparent rebuild${username ? ` for user: ${username}` : ''}`);
|
console.log(
|
||||||
|
`[SESSION AUTO-REBUILD] Session ${sessionId} not found, initiating transparent rebuild${username ? ` for user: ${username}` : ''}`,
|
||||||
|
);
|
||||||
// Prevent concurrent session creation
|
// Prevent concurrent session creation
|
||||||
if (sessionCreationLocks[sessionId] !== undefined) {
|
if (sessionCreationLocks[sessionId] !== undefined) {
|
||||||
console.log(`[SESSION AUTO-REBUILD] Session creation in progress for ${sessionId}, waiting...`);
|
console.log(
|
||||||
|
`[SESSION AUTO-REBUILD] Session creation in progress for ${sessionId}, waiting...`,
|
||||||
|
);
|
||||||
transport = await sessionCreationLocks[sessionId];
|
transport = await sessionCreationLocks[sessionId];
|
||||||
} else {
|
} else {
|
||||||
sessionCreationLocks[sessionId] = createSessionWithId(sessionId, group, username);
|
sessionCreationLocks[sessionId] = createSessionWithId(sessionId, group, username);
|
||||||
try {
|
try {
|
||||||
transport = await sessionCreationLocks[sessionId];
|
transport = await sessionCreationLocks[sessionId];
|
||||||
console.log(`[SESSION AUTO-REBUILD] Successfully transparently rebuilt session: ${sessionId}`);
|
console.log(
|
||||||
|
`[SESSION AUTO-REBUILD] Successfully transparently rebuilt session: ${sessionId}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SESSION AUTO-REBUILD] Failed to rebuild session ${sessionId}:`, error);
|
console.error(`[SESSION AUTO-REBUILD] Failed to rebuild session ${sessionId}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -419,7 +506,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Session rebuild is disabled, return error
|
// Session rebuild is disabled, return error
|
||||||
console.warn(`[SESSION ERROR] Session ${sessionId} not found and session rebuild is disabled${username ? ` for user: ${username}` : ''}`);
|
console.warn(
|
||||||
|
`[SESSION ERROR] Session ${sessionId} not found and session rebuild is disabled${username ? ` for user: ${username}` : ''}`,
|
||||||
|
);
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
error: {
|
error: {
|
||||||
@@ -432,11 +521,15 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
} else if (isInitializeRequest(req.body)) {
|
} else if (isInitializeRequest(req.body)) {
|
||||||
// Case 3: No sessionId and this is an initialize request, create new session
|
// Case 3: No sessionId and this is an initialize request, create new session
|
||||||
console.log(`[SESSION CREATE] No session ID provided for initialize request, creating new session${username ? ` for user: ${username}` : ''}`);
|
console.log(
|
||||||
|
`[SESSION CREATE] No session ID provided for initialize request, creating new session${username ? ` for user: ${username}` : ''}`,
|
||||||
|
);
|
||||||
transport = await createNewSession(group, username);
|
transport = await createNewSession(group, username);
|
||||||
} else {
|
} else {
|
||||||
// Case 4: No sessionId and not an initialize request, return error
|
// Case 4: No sessionId and not an initialize request, return error
|
||||||
console.warn(`[SESSION ERROR] No session ID provided for non-initialize request (method: ${req.body?.method})${username ? ` for user: ${username}` : ''}`);
|
console.warn(
|
||||||
|
`[SESSION ERROR] No session ID provided for non-initialize request (method: ${req.body?.method})${username ? ` for user: ${username}` : ''}`,
|
||||||
|
);
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
error: {
|
error: {
|
||||||
@@ -456,7 +549,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
|
|
||||||
// Check if the session needs initialization (for rebuilt sessions)
|
// Check if the session needs initialization (for rebuilt sessions)
|
||||||
if (transportInfo && transportInfo.needsInitialization) {
|
if (transportInfo && transportInfo.needsInitialization) {
|
||||||
console.log(`[MCP] Session ${sessionId} needs initialization, performing proactive initialization`);
|
console.log(
|
||||||
|
`[MCP] Session ${sessionId} needs initialization, performing proactive initialization`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a mock response object that doesn't actually send headers
|
// Create a mock response object that doesn't actually send headers
|
||||||
@@ -466,7 +561,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
json: () => {},
|
json: () => {},
|
||||||
status: () => mockRes,
|
status: () => mockRes,
|
||||||
send: () => {},
|
send: () => {},
|
||||||
headersSent: false
|
headersSent: false,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
// First, send the initialize request
|
// First, send the initialize request
|
||||||
@@ -477,11 +572,11 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: 'MCPHub-Client',
|
name: 'MCPHub-Client',
|
||||||
version: '1.0.0'
|
version: '1.0.0',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: `init-${sessionId}-${Date.now()}`
|
id: `init-${sessionId}-${Date.now()}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[MCP] Sending initialize request for session ${sessionId}`);
|
console.log(`[MCP] Sending initialize request for session ${sessionId}`);
|
||||||
@@ -491,7 +586,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
// Then send the initialized notification
|
// Then send the initialized notification
|
||||||
const initializedNotification = {
|
const initializedNotification = {
|
||||||
method: 'notifications/initialized',
|
method: 'notifications/initialized',
|
||||||
jsonrpc: '2.0'
|
jsonrpc: '2.0',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[MCP] Sending initialized notification for session ${sessionId}`);
|
console.log(`[MCP] Sending initialized notification for session ${sessionId}`);
|
||||||
@@ -512,7 +607,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Check if this is a "Server not initialized" error for a newly rebuilt session
|
// Check if this is a "Server not initialized" error for a newly rebuilt session
|
||||||
if (sessionId && error.message && error.message.includes('Server not initialized')) {
|
if (sessionId && error.message && error.message.includes('Server not initialized')) {
|
||||||
console.log(`[SESSION AUTO-REBUILD] Server not initialized for ${sessionId}. Attempting to initialize with the current request.`);
|
console.log(
|
||||||
|
`[SESSION AUTO-REBUILD] Server not initialized for ${sessionId}. Attempting to initialize with the current request.`,
|
||||||
|
);
|
||||||
|
|
||||||
// Check if the current request is an 'initialize' request
|
// Check if the current request is an 'initialize' request
|
||||||
if (isInitializeRequest(req.body)) {
|
if (isInitializeRequest(req.body)) {
|
||||||
@@ -529,14 +626,16 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: 'MCPHub-Client',
|
name: 'MCPHub-Client',
|
||||||
version: '1.0.0'
|
version: '1.0.0',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: `init-${sessionId}-${Date.now()}`
|
id: `init-${sessionId}-${Date.now()}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[SESSION AUTO-REBUILD] Sending initialize request for ${sessionId} before handling the actual request.`);
|
console.log(
|
||||||
|
`[SESSION AUTO-REBUILD] Sending initialize request for ${sessionId} before handling the actual request.`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// Temporarily replace the body to send the initialize request
|
// Temporarily replace the body to send the initialize request
|
||||||
const originalBody = req.body;
|
const originalBody = req.body;
|
||||||
@@ -546,18 +645,22 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
// Now, send the notifications/initialized
|
// Now, send the notifications/initialized
|
||||||
const initializedNotification = {
|
const initializedNotification = {
|
||||||
method: 'notifications/initialized',
|
method: 'notifications/initialized',
|
||||||
jsonrpc: '2.0'
|
jsonrpc: '2.0',
|
||||||
};
|
};
|
||||||
req.body = initializedNotification;
|
req.body = initializedNotification;
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
|
||||||
// Restore the original body and retry the original request
|
// Restore the original body and retry the original request
|
||||||
req.body = originalBody;
|
req.body = originalBody;
|
||||||
console.log(`[SESSION AUTO-REBUILD] Initialization complete for ${sessionId}. Retrying original request.`);
|
console.log(
|
||||||
|
`[SESSION AUTO-REBUILD] Initialization complete for ${sessionId}. Retrying original request.`,
|
||||||
|
);
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
|
||||||
} catch (initError) {
|
} catch (initError) {
|
||||||
console.error(`[SESSION AUTO-REBUILD] Failed to initialize session ${sessionId} on-the-fly:`, initError);
|
console.error(
|
||||||
|
`[SESSION AUTO-REBUILD] Failed to initialize session ${sessionId} on-the-fly:`,
|
||||||
|
initError,
|
||||||
|
);
|
||||||
// Re-throw the original error if initialization fails
|
// Re-throw the original error if initialization fails
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -604,7 +707,9 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
|||||||
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
|
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
|
||||||
|
|
||||||
if (enableSessionRebuild) {
|
if (enableSessionRebuild) {
|
||||||
console.log(`[SESSION AUTO-REBUILD] Session ${sessionId} not found in handleMcpOtherRequest, initiating transparent rebuild`);
|
console.log(
|
||||||
|
`[SESSION AUTO-REBUILD] Session ${sessionId} not found in handleMcpOtherRequest, initiating transparent rebuild`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if user context exists
|
// Check if user context exists
|
||||||
@@ -617,14 +722,18 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
|||||||
const group = req.params.group;
|
const group = req.params.group;
|
||||||
const rebuiltSession = await createSessionWithId(sessionId, group, currentUser.username);
|
const rebuiltSession = await createSessionWithId(sessionId, group, currentUser.username);
|
||||||
if (rebuiltSession) {
|
if (rebuiltSession) {
|
||||||
console.log(`[SESSION AUTO-REBUILD] Successfully transparently rebuilt session: ${sessionId}`);
|
console.log(
|
||||||
|
`[SESSION AUTO-REBUILD] Successfully transparently rebuilt session: ${sessionId}`,
|
||||||
|
);
|
||||||
transportEntry = transports[sessionId];
|
transportEntry = transports[sessionId];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SESSION AUTO-REBUILD] Failed to rebuild session ${sessionId}:`, error);
|
console.error(`[SESSION AUTO-REBUILD] Failed to rebuild session ${sessionId}:`, error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[SESSION ERROR] Session ${sessionId} not found and session rebuild is disabled in handleMcpOtherRequest`);
|
console.warn(
|
||||||
|
`[SESSION ERROR] Session ${sessionId} not found and session rebuild is disabled in handleMcpOtherRequest`,
|
||||||
|
);
|
||||||
res.status(400).send('Invalid or missing session ID');
|
res.status(400).send('Invalid or missing session ID');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
303
tests/services/keepalive.test.ts
Normal file
303
tests/services/keepalive.test.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
// Mock openid-client before anything else
|
||||||
|
jest.mock('openid-client', () => ({
|
||||||
|
discovery: jest.fn(),
|
||||||
|
dynamicClientRegistration: jest.fn(),
|
||||||
|
ClientSecretPost: jest.fn(() => jest.fn()),
|
||||||
|
ClientSecretBasic: jest.fn(() => jest.fn()),
|
||||||
|
None: jest.fn(() => jest.fn()),
|
||||||
|
calculatePKCECodeChallenge: jest.fn(),
|
||||||
|
randomPKCECodeVerifier: jest.fn(),
|
||||||
|
buildAuthorizationUrl: jest.fn(),
|
||||||
|
authorizationCodeGrant: jest.fn(),
|
||||||
|
refreshTokenGrant: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock dependencies BEFORE any imports that use them
|
||||||
|
jest.mock('../../src/models/OAuth.js', () => ({
|
||||||
|
OAuthModel: {
|
||||||
|
getOAuthToken: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../src/db/connection.js', () => ({
|
||||||
|
getDatabase: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../src/services/vectorSearchService.js', () => ({
|
||||||
|
VectorSearchService: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../src/utils/oauthBearer.js', () => ({
|
||||||
|
resolveOAuthUserFromToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { handleSseConnection, transports } from '../../src/services/sseService.js';
|
||||||
|
import * as mcpService from '../../src/services/mcpService.js';
|
||||||
|
import * as configModule from '../../src/config/index.js';
|
||||||
|
|
||||||
|
// Mock remaining dependencies
|
||||||
|
jest.mock('../../src/services/mcpService.js');
|
||||||
|
jest.mock('../../src/config/index.js');
|
||||||
|
|
||||||
|
// Mock UserContextService with getInstance pattern
|
||||||
|
const mockUserContextService = {
|
||||||
|
getCurrentUser: jest.fn().mockReturnValue(null),
|
||||||
|
setCurrentUser: jest.fn(),
|
||||||
|
clearCurrentUser: jest.fn(),
|
||||||
|
hasUser: jest.fn().mockReturnValue(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('../../src/services/userContextService.js', () => ({
|
||||||
|
UserContextService: {
|
||||||
|
getInstance: jest.fn(() => mockUserContextService),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock RequestContextService with getInstance pattern
|
||||||
|
const mockRequestContextService = {
|
||||||
|
setRequestContext: jest.fn(),
|
||||||
|
clearRequestContext: jest.fn(),
|
||||||
|
getRequestContext: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('../../src/services/requestContextService.js', () => ({
|
||||||
|
RequestContextService: {
|
||||||
|
getInstance: jest.fn(() => mockRequestContextService),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock SSEServerTransport
|
||||||
|
const mockTransportInstance = {
|
||||||
|
sessionId: 'test-session-id',
|
||||||
|
send: jest.fn(),
|
||||||
|
onclose: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
|
||||||
|
SSEServerTransport: jest.fn().mockImplementation(() => mockTransportInstance),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Keepalive Functionality', () => {
|
||||||
|
let mockReq: Partial<Request>;
|
||||||
|
let mockRes: Partial<Response>;
|
||||||
|
let eventListeners: { [event: string]: (...args: any[]) => void };
|
||||||
|
let originalSetInterval: typeof setInterval;
|
||||||
|
let originalClearInterval: typeof clearInterval;
|
||||||
|
let intervals: NodeJS.Timeout[];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Save original timer functions
|
||||||
|
originalSetInterval = global.setInterval;
|
||||||
|
originalClearInterval = global.clearInterval;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Track all intervals created during the test
|
||||||
|
intervals = [];
|
||||||
|
|
||||||
|
// Mock setInterval to track created intervals
|
||||||
|
global.setInterval = jest.fn((callback: any, ms: number) => {
|
||||||
|
const interval = originalSetInterval(callback, ms);
|
||||||
|
intervals.push(interval);
|
||||||
|
return interval;
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
// Mock clearInterval to track cleanup
|
||||||
|
global.clearInterval = jest.fn((interval: NodeJS.Timeout) => {
|
||||||
|
const index = intervals.indexOf(interval);
|
||||||
|
if (index > -1) {
|
||||||
|
intervals.splice(index, 1);
|
||||||
|
}
|
||||||
|
originalClearInterval(interval);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
eventListeners = {};
|
||||||
|
|
||||||
|
mockReq = {
|
||||||
|
params: { group: 'test-group' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRes = {
|
||||||
|
on: jest.fn((event: string, callback: (...args: any[]) => void) => {
|
||||||
|
eventListeners[event] = callback;
|
||||||
|
return mockRes as Response;
|
||||||
|
}),
|
||||||
|
setHeader: jest.fn(),
|
||||||
|
writeHead: jest.fn(),
|
||||||
|
write: jest.fn(),
|
||||||
|
end: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the mock instance for each test
|
||||||
|
mockTransportInstance.sessionId = 'test-session-id';
|
||||||
|
mockTransportInstance.send = jest.fn();
|
||||||
|
mockTransportInstance.onclose = null;
|
||||||
|
|
||||||
|
// Mock getMcpServer
|
||||||
|
const mockMcpServer = {
|
||||||
|
connect: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
(mcpService.getMcpServer as jest.Mock).mockReturnValue(mockMcpServer);
|
||||||
|
|
||||||
|
// Mock loadSettings
|
||||||
|
(configModule.loadSettings as jest.Mock).mockReturnValue({
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: false,
|
||||||
|
bearerAuthKey: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mcpServers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear transports
|
||||||
|
Object.keys(transports).forEach((key) => delete transports[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up all intervals
|
||||||
|
intervals.forEach((interval) => originalClearInterval(interval));
|
||||||
|
intervals = [];
|
||||||
|
|
||||||
|
// Restore original timer functions
|
||||||
|
global.setInterval = originalSetInterval;
|
||||||
|
global.clearInterval = originalClearInterval;
|
||||||
|
|
||||||
|
// Clear all mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SSE Connection Keepalive', () => {
|
||||||
|
it('should create a keepalive interval when establishing SSE connection', async () => {
|
||||||
|
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Verify setInterval was called with 30000ms (30 seconds)
|
||||||
|
expect(global.setInterval).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send ping messages via transport', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Fast-forward time by 30 seconds
|
||||||
|
jest.advanceTimersByTime(30000);
|
||||||
|
|
||||||
|
// Verify ping was sent using mockTransportInstance
|
||||||
|
expect(mockTransportInstance.send).toHaveBeenCalledWith({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'ping',
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send multiple pings at 30-second intervals', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Fast-forward time by 90 seconds (3 intervals)
|
||||||
|
jest.advanceTimersByTime(90000);
|
||||||
|
|
||||||
|
// Verify ping was sent 3 times using mockTransportInstance
|
||||||
|
expect(mockTransportInstance.send).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockTransportInstance.send).toHaveBeenCalledWith({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'ping',
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear keepalive interval when connection closes', async () => {
|
||||||
|
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Verify interval was created
|
||||||
|
expect(global.setInterval).toHaveBeenCalled();
|
||||||
|
const intervalsBefore = intervals.length;
|
||||||
|
expect(intervalsBefore).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Simulate connection close
|
||||||
|
if (eventListeners['close']) {
|
||||||
|
eventListeners['close']();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify clearInterval was called
|
||||||
|
expect(global.clearInterval).toHaveBeenCalled();
|
||||||
|
expect(intervals.length).toBeLessThan(intervalsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ping send errors gracefully', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||||
|
|
||||||
|
// Make transport.send throw an error on the first call
|
||||||
|
let callCount = 0;
|
||||||
|
mockTransportInstance.send.mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
throw new Error('Connection broken');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fast-forward time by 30 seconds (first ping)
|
||||||
|
jest.advanceTimersByTime(30000);
|
||||||
|
|
||||||
|
// Verify error was logged for the first ping
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Failed to send keepalive ping'),
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstCallCount = callCount;
|
||||||
|
|
||||||
|
// Fast-forward time by another 30 seconds
|
||||||
|
jest.advanceTimersByTime(30000);
|
||||||
|
|
||||||
|
// Verify no additional attempts were made after the error (interval was cleared)
|
||||||
|
expect(callCount).toBe(firstCallCount);
|
||||||
|
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not send pings after connection is closed', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||||
|
|
||||||
|
// Close the connection
|
||||||
|
if (eventListeners['close']) {
|
||||||
|
eventListeners['close']();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset mock to count pings after close
|
||||||
|
mockTransportInstance.send.mockClear();
|
||||||
|
|
||||||
|
// Fast-forward time by 60 seconds
|
||||||
|
jest.advanceTimersByTime(60000);
|
||||||
|
|
||||||
|
// Verify no pings were sent after close
|
||||||
|
expect(mockTransportInstance.send).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StreamableHTTP Connection Keepalive', () => {
|
||||||
|
// Note: StreamableHTTP keepalive is tested indirectly through the session creation functions
|
||||||
|
// These are tested in the integration tests as they require more complex setup
|
||||||
|
|
||||||
|
it('should track keepalive intervals for multiple sessions', () => {
|
||||||
|
// This test verifies the pattern is set up correctly
|
||||||
|
const intervalCount = intervals.length;
|
||||||
|
expect(intervalCount).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user