fix: Reconnect transport layer during session restoration (v2.19.3) (#317)

Fixes critical bug where session restoration successfully restored InstanceContext
but failed to reconnect the transport layer, causing all requests on restored
sessions to hang indefinitely.

Root Cause:
The handleRequest() method's session restoration flow (lines 1119-1197) called
createSession() which creates a NEW transport separate from the current HTTP request.
This separate transport is not linked to the current req/res pair, so responses
cannot be sent back through the active HTTP connection.

Fix Applied:
Replace createSession() call with inline transport creation that mirrors the
initialize flow. Create StreamableHTTPServerTransport directly for the current
HTTP req/res context and ensure transport is connected to server BEFORE handling
request. This makes restored sessions work identically to fresh sessions.

Impact:
- Zero-downtime deployments now work correctly
- Users can continue work after container restart without restarting MCP client
- Session persistence is now fully functional for production use

Technical Details:
The StreamableHTTPServerTransport class from MCP SDK links a specific HTTP
req/res pair to the MCP server. Creating transport in createSession() binds
it to the wrong req/res (or no req/res at all). The initialize flow got this
right, but restoration flow did not.

Files Changed:
- src/http-server-single-session.ts: Fixed session restoration (lines 1163-1244)
- package.json, package.runtime.json, src/mcp-engine.ts: Version bump to 2.19.3
- CHANGELOG.md: Documented fix with technical details

Testing:
All 13 session persistence integration tests pass, verifying restoration works
correctly.

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

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2025-10-13 13:11:35 +02:00
committed by GitHub
parent 318986f546
commit 112b40119c
6 changed files with 147 additions and 25 deletions

View File

@@ -1160,29 +1160,77 @@ export class SingleSessionHTTPServer {
return;
}
// REQ-2: Create session (idempotent) and wait for connection
logger.info('Session restoration successful, creating session', {
// REQ-2: Create transport and server inline for THIS REQUEST (like initialize flow)
// CRITICAL FIX: Don't use createSession() as it creates a separate transport
// not linked to the current HTTP req/res pair. We MUST create the transport
// for the current request context, just like the initialize flow does.
logger.info('Session restoration successful, creating transport inline', {
sessionId,
instanceId: restoredContext.instanceId
});
// CRITICAL: Wait for server.connect() to complete before proceeding
// This ensures the transport is fully ready to handle requests
await this.createSession(restoredContext, sessionId, true);
// Create server and transport for THIS REQUEST
const server = new N8NDocumentationMCPServer(restoredContext);
// Verify session was created
if (!this.transports[sessionId]) {
logger.error('Session creation failed after restoration', { sessionId });
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Session creation failed'
},
id: req.body?.id || null
});
return;
}
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
onsessioninitialized: (initializedSessionId: string) => {
// Store both transport and server by session ID when session is initialized
logger.info('Session initialized after restoration', {
sessionId: initializedSessionId
});
this.transports[initializedSessionId] = transport;
this.servers[initializedSessionId] = server;
// Store session metadata and context
this.sessionMetadata[initializedSessionId] = {
lastAccess: new Date(),
createdAt: new Date()
};
this.sessionContexts[initializedSessionId] = restoredContext;
}
});
// Set up cleanup handlers (same as initialize flow)
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) {
// Prevent recursive cleanup during shutdown
if (this.isShuttingDown) {
logger.debug('Ignoring transport close event during shutdown', { sessionId: sid });
return;
}
logger.info('Restored transport closed, cleaning up', { sessionId: sid });
this.removeSession(sid, 'transport_closed').catch(err => {
logger.error('Error during transport close cleanup', {
sessionId: sid,
error: err instanceof Error ? err.message : String(err)
});
});
}
};
// Handle transport errors to prevent connection drops
transport.onerror = (error: Error) => {
const sid = transport.sessionId;
if (sid) {
// Prevent recursive cleanup during shutdown
if (this.isShuttingDown) {
logger.debug('Ignoring transport error event during shutdown', { sessionId: sid });
return;
}
logger.error('Restored transport error', { sessionId: sid, error: error.message });
this.removeSession(sid, 'transport_error').catch(err => {
logger.error('Error during transport error cleanup', { error: err });
});
}
};
// Connect the server to the transport BEFORE handling the request
logger.info('Connecting server to restored session transport');
await server.connect(transport);
// Phase 3: Emit onSessionRestored event (REQ-4)
// Fire-and-forget: don't await or block request processing
@@ -1193,9 +1241,7 @@ export class SingleSessionHTTPServer {
});
});
// Use the restored session
transport = this.transports[sessionId];
logger.info('Using restored session transport', { sessionId });
logger.info('Restored session transport ready', { sessionId });
} catch (error) {
// Handle timeout