fix: implement Single-Session architecture to resolve MCP stream errors

- 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>
This commit is contained in:
czlonkowski
2025-06-14 15:02:49 +02:00
parent 88dd66bb7a
commit 2cb264fd56
13 changed files with 1894 additions and 51 deletions

View File

@@ -0,0 +1,301 @@
# 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
1. **Single-player repository** - One user at a time, not concurrent sessions
2. **Engine for a service** - This repo will be integrated into a larger system
3. **Simplicity** - Easy to understand, maintain, and deploy
4. **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
```typescript
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
```typescript
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)
```typescript
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
```typescript
// 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
```typescript
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
```typescript
// 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
```typescript
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:
1. **Respects the protocol** - Works with MCP's stateful design
2. **Matches the use case** - Perfect for single-player repository
3. **Simplifies implementation** - No unnecessary complexity
4. **Integrates cleanly** - Ready to be an engine for larger service
5. **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.

456
docs/MCP_ERROR_FIX_PLAN.md Normal file
View File

@@ -0,0 +1,456 @@
# 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
1. **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
2. **Architectural Mismatch**
- Current implementation: Stateless (new server instance per request)
- StreamableHTTPServerTransport design: Stateful (expects session persistence)
- Passing `sessionIdGenerator: undefined` doesn't make it truly stateless
3. **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
4. **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
```json
{
"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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
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
```typescript
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)
1. **Day 1**:
- Update dependencies, consolidate MCP SDK version
- Create ConsoleManager utility class
- Replace console.* calls with logger in HTTP paths
2. **Day 2**:
- Implement and test console output isolation
- Verify no third-party console writes
### Phase 2: Core Fix (3 days)
1. **Day 3-4**:
- Implement SingleSessionHTTPServer class
- Integrate console wrapping
- Handle session lifecycle (create, expire, reset)
2. **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)
1. **Day 6**:
- Comprehensive testing suite
- Error handling improvements
- Performance metrics
2. **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
1. **Zero "stream is not readable" errors** in production
2. **Successful Claude Desktop integration** via mcp-remote
3. **Response time < 100ms** for standard queries
4. **Memory usage stable** over extended periods
5. **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:
1. Revert to previous version
2. Disable HTTP mode temporarily
3. Focus on stdio mode for Claude Desktop
4. Investigate alternative MCP implementations
## Long-term Considerations
1. **Monitor MCP SDK Development**
- StreamableHTTP is evolving
- May need updates as SDK matures
2. **Consider Official Examples**
- Align with official MCP server implementations
- Contribute fixes back to SDK if needed
3. **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:
1. **Protocol Compliance**: Works with StreamableHTTPServerTransport's expectations
2. **Simplicity**: Single session eliminates complex state management
3. **Performance**: Minimal overhead, automatic cleanup
4. **Integration Ready**: Clean interface for service wrapper
5. **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.

View File

@@ -0,0 +1,172 @@
# Single-Session HTTP Server Implementation
## Overview
This document describes the implementation of the Hybrid Single-Session architecture that fixes the "stream is not readable" error in the n8n-MCP HTTP server.
## Architecture
The Single-Session architecture maintains one persistent MCP session that is reused across all requests, providing:
- Protocol compliance with StreamableHTTPServerTransport
- Simple state management (one session only)
- Automatic session expiry after 30 minutes of inactivity
- Clean console output management
## Key Components
### 1. ConsoleManager (`src/utils/console-manager.ts`)
Prevents console output from interfering with the StreamableHTTPServerTransport:
- Silences all console methods during MCP request handling
- Automatically restores console after request completion
- Only active in HTTP mode
### 2. SingleSessionHTTPServer (`src/http-server-single-session.ts`)
Core implementation of the single-session architecture:
- Maintains one persistent session with StreamableHTTPServerTransport
- Automatically creates/resets session as needed
- Wraps all operations with ConsoleManager
- Handles authentication and request routing
### 3. N8NMCPEngine (`src/mcp-engine.ts`)
Clean interface for service integration:
- Simple API for processing MCP requests
- Health check capabilities
- Graceful shutdown support
- Ready for multi-tenant wrapper services
## Usage
### Standalone Mode
```bash
# Start the single-session HTTP server
MCP_MODE=http npm start
# Or use the legacy stateless server
npm run start:http:legacy
```
### As a Library
```typescript
import { N8NMCPEngine } from 'n8n-mcp';
const engine = new N8NMCPEngine();
// In your Express app
app.post('/api/mcp', authenticate, async (req, res) => {
await engine.processRequest(req, res);
});
// Health check
app.get('/health', async (req, res) => {
const health = await engine.healthCheck();
res.json(health);
});
```
### Docker Deployment
```yaml
services:
n8n-mcp:
image: ghcr.io/czlonkowski/n8n-mcp:latest
environment:
- MCP_MODE=http
- AUTH_TOKEN=${AUTH_TOKEN}
ports:
- "3000:3000"
```
## Testing
### Manual Testing
```bash
# Run the test script
npm run test:single-session
```
### Unit Tests
```bash
# Run Jest tests
npm test -- single-session.test.ts
```
### Health Check
```bash
curl http://localhost:3000/health
```
Response includes session information:
```json
{
"status": "ok",
"mode": "single-session",
"version": "2.3.1",
"sessionActive": true,
"sessionAge": 45,
"uptime": 120,
"memory": {
"used": 45,
"total": 128,
"unit": "MB"
}
}
```
## Configuration
### Environment Variables
- `AUTH_TOKEN` - Required authentication token (min 32 chars recommended)
- `MCP_MODE` - Set to "http" for HTTP mode
- `PORT` - Server port (default: 3000)
- `HOST` - Server host (default: 0.0.0.0)
- `CORS_ORIGIN` - CORS allowed origin (default: *)
### Session Timeout
The session automatically expires after 30 minutes of inactivity. This is configurable in the SingleSessionHTTPServer constructor.
## Migration from Stateless
The single-session implementation is backward compatible:
1. Same API endpoints
2. Same authentication mechanism
3. Same request/response format
4. Only internal architecture changed
To migrate:
1. Update to latest version
2. No configuration changes needed
3. Monitor logs for any issues
4. Session management is automatic
## Performance
The single-session architecture provides:
- Lower memory usage (one session vs many)
- Faster response times (no session creation overhead)
- Automatic cleanup (session expiry)
- No session accumulation issues
## Troubleshooting
### "Stream is not readable" error
This error should no longer occur with the single-session implementation. If it does:
1. Check console output isn't being written during requests
2. Verify ConsoleManager is properly wrapping operations
3. Check for third-party libraries writing to console
### Session expiry issues
If sessions are expiring too quickly:
1. Increase the timeout in SingleSessionHTTPServer
2. Monitor session age in health endpoint
3. Check for long gaps between requests
### Authentication failures
1. Verify AUTH_TOKEN is set correctly
2. Check authorization header format: `Bearer <token>`
3. Monitor logs for auth failures
## Future Enhancements
1. **Configurable session timeout** - Allow timeout configuration via environment variable
2. **Session metrics** - Track session lifetime, request count, etc.
3. **Graceful session migration** - Handle session updates without dropping requests
4. **Multi-session support** - For future scaling needs (separate repository)