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:
301
docs/MCP_ARCHITECTURE_ANALYSIS.md
Normal file
301
docs/MCP_ARCHITECTURE_ANALYSIS.md
Normal 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.
|
||||
Reference in New Issue
Block a user