- Add ConsoleManager to prevent console output interference with StreamableHTTPServerTransport - Implement SingleSessionHTTPServer with persistent session reuse - Create N8NMCPEngine for clean service integration - Add automatic session expiry after 30 minutes of inactivity - Update logger to be HTTP-aware during active requests - Maintain backward compatibility with existing deployments This fixes the "stream is not readable" error by implementing the Hybrid Single-Session architecture as documented in MCP_ERROR_FIX_PLAN.md 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
8.0 KiB
8.0 KiB
MCP Server Architecture Analysis: Stateful vs Stateless
Executive Summary
After deep analysis of the MCP protocol, StreamableHTTPServerTransport implementation, and our specific use case (single-player repository as an engine for a service), I recommend a Hybrid Single-Session Architecture that provides the simplicity of stateless design with the protocol compliance of stateful implementation.
Context and Requirements
Project Goals
- Single-player repository - One user at a time, not concurrent sessions
- Engine for a service - This repo will be integrated into a larger system
- Simplicity - Easy to understand, maintain, and deploy
- Separation of concerns - Multi-user features in separate repository
Protocol Reality
- MCP is inherently stateful by design
- StreamableHTTPServerTransport expects session management
- The protocol maintains context across multiple tool invocations
- Attempting pure stateless breaks protocol expectations
Architecture Options Analysis
Option A: Full Stateful Implementation
class StatefulMCPServer {
private sessions = new Map<string, SessionData>();
// Multiple concurrent sessions
// Session cleanup
// Memory management
// Complexity: HIGH
}
Pros:
- Full protocol compliance
- Supports multiple concurrent users
- Future-proof for scaling
Cons:
- Over-engineered for single-player use case
- Complex session management unnecessary
- Memory overhead for session storage
- Cleanup logic adds complexity
- Conflicts with "engine" design principle
Verdict: ❌ Too complex for our needs
Option B: Pure Stateless Implementation
class StatelessMCPServer {
// New instance per request
// No session tracking
// Complexity: LOW
}
Pros:
- Very simple implementation
- No memory overhead
- Easy to understand
Cons:
- Breaks MCP protocol expectations
- Request ID collisions
- No context between calls
- StreamableHTTPServerTransport fights this approach
- The "stream is not readable" error persists
Verdict: ❌ Incompatible with protocol
Option C: Hybrid Single-Session Architecture (Recommended)
class SingleSessionMCPServer {
private currentSession: {
transport: StreamableHTTPServerTransport;
server: N8NDocumentationMCPServer;
lastAccess: Date;
} | null = null;
async handleRequest(req: Request, res: Response) {
// Always use/reuse the single session
if (!this.currentSession || this.isExpired()) {
await this.createNewSession();
}
this.currentSession.lastAccess = new Date();
await this.currentSession.transport.handleRequest(req, res);
}
private isExpired(): boolean {
// Simple 30-minute timeout
const thirtyMinutes = 30 * 60 * 1000;
return Date.now() - this.currentSession.lastAccess.getTime() > thirtyMinutes;
}
}
Pros:
- Protocol compliant - Satisfies StreamableHTTPServerTransport expectations
- Simple - Only one session to manage
- Memory efficient - Single session overhead
- Perfect for single-player - Matches use case exactly
- Clean integration - Easy to wrap as an engine
Cons:
- Not suitable for concurrent users (but that's handled elsewhere)
Verdict: ✅ Perfect match for requirements
Detailed Implementation Strategy
1. Console Output Management
// Silence console only during transport operations
class ManagedConsole {
silence() {
this.originalLog = console.log;
console.log = () => {};
}
restore() {
console.log = this.originalLog;
}
wrapOperation<T>(fn: () => T): T {
this.silence();
try {
return fn();
} finally {
this.restore();
}
}
}
2. Single Session Manager
export class SingleSessionHTTPServer {
private session: SessionData | null = null;
private console = new ManagedConsole();
async handleRequest(req: Request, res: Response): Promise<void> {
return this.console.wrapOperation(async () => {
// Ensure we have a valid session
if (!this.session || this.shouldReset()) {
await this.resetSession();
}
// Update last access
this.session.lastAccess = new Date();
// Handle the request with existing transport
await this.session.transport.handleRequest(req, res);
});
}
private async resetSession(): Promise<void> {
// Clean up old session
if (this.session) {
await this.session.transport.close();
await this.session.server.close();
}
// Create new session
const server = new N8NDocumentationMCPServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => 'single-session', // Always same ID
});
await server.connect(transport);
this.session = {
server,
transport,
lastAccess: new Date(),
sessionId: 'single-session'
};
}
private shouldReset(): boolean {
// Reset after 30 minutes of inactivity
const inactivityLimit = 30 * 60 * 1000;
return Date.now() - this.session.lastAccess.getTime() > inactivityLimit;
}
}
3. Integration as Engine
// Easy to use in larger service
export class N8NMCPEngine {
private server: SingleSessionHTTPServer;
constructor() {
this.server = new SingleSessionHTTPServer();
}
// Simple interface for service integration
async processRequest(req: Request, res: Response): Promise<void> {
return this.server.handleRequest(req, res);
}
// Clean shutdown for service lifecycle
async shutdown(): Promise<void> {
return this.server.shutdown();
}
}
Why This Architecture Wins
1. Protocol Compliance
- StreamableHTTPServerTransport gets the session it expects
- No fighting against the SDK design
- Fixes "stream is not readable" error
2. Simplicity
- One session = one user
- No complex session management
- Clear lifecycle (create, use, expire, recreate)
3. Engine-Ready
- Clean interface for integration
- No leaked complexity
- Service wrapper handles multi-user concerns
4. Resource Efficient
- Single session in memory
- Automatic cleanup after inactivity
- No accumulating sessions
5. Maintainable
- Easy to understand code
- Clear separation of concerns
- No hidden complexity
Migration Path
Phase 1: Fix Console Output (1 day)
- Implement ManagedConsole wrapper
- Wrap all transport operations
Phase 2: Implement Single Session (2 days)
- Create SingleSessionHTTPServer
- Handle session lifecycle
- Test with Claude Desktop
Phase 3: Polish and Document (1 day)
- Add error handling
- Performance metrics
- Usage documentation
Testing Strategy
describe('Single Session MCP Server', () => {
it('should reuse session for multiple requests', async () => {
const server = new SingleSessionHTTPServer();
const req1 = createMockRequest();
const req2 = createMockRequest();
await server.handleRequest(req1, mockRes);
await server.handleRequest(req2, mockRes);
// Should use same session
expect(server.getSessionCount()).toBe(1);
});
it('should reset expired sessions', async () => {
const server = new SingleSessionHTTPServer();
// First request
await server.handleRequest(req1, res1);
// Simulate 31 minutes passing
jest.advanceTimersByTime(31 * 60 * 1000);
// Second request should create new session
await server.handleRequest(req2, res2);
expect(server.wasSessionReset()).toBe(true);
});
});
Conclusion
The Hybrid Single-Session Architecture is the optimal solution for n8n-MCP because it:
- Respects the protocol - Works with MCP's stateful design
- Matches the use case - Perfect for single-player repository
- Simplifies implementation - No unnecessary complexity
- Integrates cleanly - Ready to be an engine for larger service
- Fixes the core issue - Eliminates "stream is not readable" error
This architecture provides the best balance of simplicity, correctness, and maintainability for our specific requirements.