- 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>
13 KiB
MCP "Stream is not readable" Error Fix Implementation Plan
Executive Summary
This document outlines a comprehensive plan to fix the "InternalServerError: stream is not readable" error in the n8n-MCP HTTP server implementation. The error stems from multiple architectural and implementation issues that need systematic resolution.
Chosen Solution: After thorough analysis, we will implement a Hybrid Single-Session Architecture that provides protocol compliance while optimizing for the single-player use case. This approach balances simplicity with correctness, making it ideal for use as an engine in larger services.
Problem Analysis
Root Causes
-
Stream Contamination
- Console output during server initialization interferes with StreamableHTTPServerTransport
- The transport expects clean stdin/stdout/stderr streams
- Any console.log/error before or during request handling corrupts the stream
-
Architectural Mismatch
- Current implementation: Stateless (new server instance per request)
- StreamableHTTPServerTransport design: Stateful (expects session persistence)
- Passing
sessionIdGenerator: undefineddoesn't make it truly stateless
-
Protocol Implementation Gap
- Missing proper SSE (Server-Sent Events) support
- Not handling the dual-mode nature of Streamable HTTP (JSON-RPC + SSE)
- Accept header validation but no actual SSE implementation
-
Version Inconsistency
- Multiple MCP SDK versions in dependency tree (1.12.1, 1.11.0)
- Potential API incompatibilities between versions
Implementation Strategy
Phase 1: Dependency Consolidation (Priority: Critical)
1.1 Update MCP SDK
{
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1"
},
"overrides": {
"@modelcontextprotocol/sdk": "^1.12.1"
}
}
1.2 Remove Conflicting Dependencies
- Audit n8n packages that bundle older MCP versions
- Consider isolating MCP server from n8n dependencies
Phase 2: Console Output Isolation (Priority: Critical)
2.1 Create Environment-Aware Logging
// src/utils/console-manager.ts
export class ConsoleManager {
private originalConsole = {
log: console.log,
error: console.error,
warn: console.warn
};
public silence() {
if (process.env.MCP_MODE === 'http') {
console.log = () => {};
console.error = () => {};
console.warn = () => {};
}
}
public restore() {
console.log = this.originalConsole.log;
console.error = this.originalConsole.error;
console.warn = this.originalConsole.warn;
}
}
2.2 Refactor All Console Usage
- Replace console.* with logger.* throughout codebase
- Add initialization flag to prevent startup logs in HTTP mode
- Ensure no third-party libraries write to console
Phase 3: Transport Architecture - Hybrid Single-Session (Priority: High)
3.1 Chosen Architecture: Single-Session Implementation
Based on architectural analysis, we will implement a hybrid single-session approach that:
- Maintains protocol compliance with StreamableHTTPServerTransport
- Optimizes for single-player use case (one user at a time)
- Simplifies implementation while fixing the core issues
- Provides clean interface for future service integration
// src/http-server-single-session.ts
export class SingleSessionHTTPServer {
private session: {
server: N8NDocumentationMCPServer;
transport: StreamableHTTPServerTransport;
lastAccess: Date;
} | null = null;
private consoleManager = new ConsoleManager();
async handleRequest(req: Request, res: Response): Promise<void> {
// Wrap all operations to prevent console interference
return this.consoleManager.wrapOperation(async () => {
// Ensure we have a valid session
if (!this.session || this.isExpired()) {
await this.resetSession();
}
// Update last access time
this.session.lastAccess = new Date();
// Handle request with existing transport
await this.session.transport.handleRequest(req, res);
});
}
private async resetSession(): Promise<void> {
// Clean up old session if exists
if (this.session) {
try {
await this.session.transport.close();
await this.session.server.close();
} catch (error) {
logger.warn('Error closing previous session:', error);
}
}
// 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()
};
logger.info('Created new single session');
}
private isExpired(): boolean {
const thirtyMinutes = 30 * 60 * 1000;
return Date.now() - this.session.lastAccess.getTime() > thirtyMinutes;
}
async shutdown(): Promise<void> {
if (this.session) {
await this.session.transport.close();
await this.session.server.close();
this.session = null;
}
}
}
3.2 Console Wrapper Implementation
// src/utils/console-manager.ts
export class ConsoleManager {
private originalConsole = {
log: console.log,
error: console.error,
warn: console.warn
};
public wrapOperation<T>(operation: () => T | Promise<T>): T | Promise<T> {
this.silence();
try {
const result = operation();
if (result instanceof Promise) {
return result.finally(() => this.restore());
}
this.restore();
return result;
} catch (error) {
this.restore();
throw error;
}
}
private silence() {
if (process.env.MCP_MODE === 'http') {
console.log = () => {};
console.error = () => {};
console.warn = () => {};
}
}
private restore() {
console.log = this.originalConsole.log;
console.error = this.originalConsole.error;
console.warn = this.originalConsole.warn;
}
}
Phase 4: Engine Integration Interface (Priority: Medium)
4.1 Clean API for Service Integration
// src/mcp-engine.ts
export class N8NMCPEngine {
private server: SingleSessionHTTPServer;
constructor() {
this.server = new SingleSessionHTTPServer();
}
/**
* Process a single MCP request
* The wrapping service handles authentication, multi-tenancy, etc.
*/
async processRequest(req: Request, res: Response): Promise<void> {
return this.server.handleRequest(req, res);
}
/**
* Health check for service monitoring
*/
async healthCheck(): Promise<{ status: string; uptime: number }> {
return {
status: 'healthy',
uptime: process.uptime()
};
}
/**
* Graceful shutdown for service lifecycle
*/
async shutdown(): Promise<void> {
return this.server.shutdown();
}
}
// Usage in multi-tenant service:
// const engine = new N8NMCPEngine();
// app.post('/api/users/:userId/mcp', authenticate, (req, res) => {
// engine.processRequest(req, res);
// });
Phase 5: SSE Support Implementation (Priority: Low)
Note: Basic SSE support may be added later if needed, but the single-session architecture handles most use cases through standard request-response.
4.1 Dual-Mode Response Handler
class DualModeHandler {
async handleRequest(req: Request, res: Response) {
const acceptsSSE = req.headers.accept?.includes('text/event-stream');
if (acceptsSSE && this.isStreamableMethod(req.body.method)) {
// Handle as SSE stream
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
await this.handleSSEStream(req, res);
} else {
// Handle as single JSON-RPC response
await this.handleJSONRPC(req, res);
}
}
}
Phase 5: Testing Strategy (Priority: High)
5.1 Unit Tests
- Test console output isolation
- Test session management
- Test SSE vs JSON-RPC response handling
5.2 Integration Tests
describe('Single Session MCP Server', () => {
it('should handle JSON-RPC requests without console interference', async () => {
const server = new SingleSessionHTTPServer();
const mockReq = createMockRequest({ method: 'tools/list' });
const mockRes = createMockResponse();
await server.handleRequest(mockReq, mockRes);
expect(mockRes.statusCode).toBe(200);
expect(console.log).not.toHaveBeenCalled();
});
it('should reuse single session for multiple requests', async () => {
const server = new SingleSessionHTTPServer();
// First request creates session
await server.handleRequest(req1, res1);
const firstSessionId = server.getSessionId();
// Second request reuses session
await server.handleRequest(req2, res2);
const secondSessionId = server.getSessionId();
expect(firstSessionId).toBe(secondSessionId);
expect(firstSessionId).toBe('single-session');
});
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 trigger reset
const resetSpy = jest.spyOn(server, 'resetSession');
await server.handleRequest(req2, res2);
expect(resetSpy).toHaveBeenCalled();
});
it('should handle errors gracefully', async () => {
const server = new SingleSessionHTTPServer();
const badReq = createMockRequest({ invalid: 'data' });
await expect(server.handleRequest(badReq, mockRes))
.resolves.not.toThrow();
});
});
5.3 Docker Testing
- Test in isolated Docker environment
- Verify no stream corruption
- Test with actual Claude Desktop client
Implementation Order
Phase 1: Foundation (2 days)
-
Day 1:
- Update dependencies, consolidate MCP SDK version
- Create ConsoleManager utility class
- Replace console.* calls with logger in HTTP paths
-
Day 2:
- Implement and test console output isolation
- Verify no third-party console writes
Phase 2: Core Fix (3 days)
-
Day 3-4:
- Implement SingleSessionHTTPServer class
- Integrate console wrapping
- Handle session lifecycle (create, expire, reset)
-
Day 5:
- Update HTTP server to use new architecture
- Test with actual MCP requests
- Verify "stream is not readable" error is resolved
Phase 3: Polish & Testing (2 days)
-
Day 6:
- Comprehensive testing suite
- Error handling improvements
- Performance metrics
-
Day 7:
- Docker integration testing
- Documentation updates
- Release preparation
Total Timeline: 7 days (vs original 15 days)
Risk Mitigation
Backward Compatibility
- Keep existing stdio mode unchanged
- Add feature flag for new HTTP implementation
- Gradual rollout with fallback option
Performance Considerations
- Single session = minimal memory overhead
- Automatic expiry after 30 minutes of inactivity
- No session accumulation or cleanup complexity
- Connection pooling for database access
Security Implications
- Session timeout configuration
- Rate limiting per session
- Secure session ID generation
Success Metrics
- Zero "stream is not readable" errors in production
- Successful Claude Desktop integration via mcp-remote
- Response time < 100ms for standard queries
- Memory usage stable over extended periods
- Clean logs without stream corruption
Alternative Approaches
Alternative 1: Different Transport
- Use WebSocket instead of HTTP
- Implement custom transport that avoids StreamableHTTP issues
- Direct JSON-RPC without MCP SDK transport layer
Alternative 2: Process Isolation
- Spawn separate process for each request
- Complete isolation of streams
- Higher overhead but guaranteed clean state
Alternative 3: Proxy Layer
- Add nginx or similar proxy
- Handle SSE at proxy level
- Simplify Node.js implementation
Rollback Plan
If issues persist after implementation:
- Revert to previous version
- Disable HTTP mode temporarily
- Focus on stdio mode for Claude Desktop
- Investigate alternative MCP implementations
Long-term Considerations
-
Monitor MCP SDK Development
- StreamableHTTP is evolving
- May need updates as SDK matures
-
Consider Official Examples
- Align with official MCP server implementations
- Contribute fixes back to SDK if needed
-
Performance Optimization
- Cache frequently accessed data
- Optimize session management
- Consider clustering for scale
Conclusion
The "stream is not readable" error is solvable through systematic addressing of console output and implementing the Hybrid Single-Session architecture. This approach provides:
- Protocol Compliance: Works with StreamableHTTPServerTransport's expectations
- Simplicity: Single session eliminates complex state management
- Performance: Minimal overhead, automatic cleanup
- Integration Ready: Clean interface for service wrapper
- Reduced Timeline: 7 days vs original 15 days
The single-session approach is ideal for a single-player repository that will serve as an engine for larger services, maintaining simplicity while ensuring correctness.