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:
22
CLAUDE.md
22
CLAUDE.md
@@ -6,9 +6,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
n8n-mcp is a comprehensive documentation and knowledge server that provides AI assistants with complete access to n8n node information through the Model Context Protocol (MCP). It serves as a bridge between n8n's workflow automation platform and AI models, enabling them to understand and work with n8n nodes effectively.
|
||||
|
||||
## ✅ Refactor Complete (v2.3)
|
||||
## ✅ Refactor Complete (v2.3.1)
|
||||
|
||||
### Latest Update (v2.3) - Universal Node.js Compatibility:
|
||||
### Latest Update (v2.3.1) - MCP Stream Error Fix:
|
||||
- ✅ Fixed "stream is not readable" error with Single-Session architecture
|
||||
- ✅ Console output isolation prevents stream corruption
|
||||
- ✅ Backward compatible with existing deployments
|
||||
- ✅ Clean engine interface for service integration
|
||||
- ✅ Automatic session management with 30-minute timeout
|
||||
|
||||
### Previous Update (v2.3) - Universal Node.js Compatibility:
|
||||
- ✅ Automatic database adapter fallback system implemented
|
||||
- ✅ Works with ANY Node.js version (no more v20.17.0 requirement)
|
||||
- ✅ Seamless fallback from better-sqlite3 to sql.js
|
||||
@@ -44,8 +51,15 @@ src/
|
||||
│ ├── rebuild.ts # Database rebuild with validation
|
||||
│ ├── validate.ts # Node validation
|
||||
│ └── test-nodes.ts # Critical node tests
|
||||
└── mcp/
|
||||
└── server.ts # MCP server with enhanced tools
|
||||
├── mcp/
|
||||
│ ├── server.ts # MCP server with enhanced tools
|
||||
│ └── index.ts # Main entry point with mode selection
|
||||
├── utils/
|
||||
│ ├── console-manager.ts # Console output isolation (NEW in v2.3.1)
|
||||
│ └── logger.ts # Logging utility with HTTP awareness
|
||||
├── http-server-single-session.ts # Single-session HTTP server (NEW in v2.3.1)
|
||||
├── mcp-engine.ts # Clean API for service integration (NEW in v2.3.1)
|
||||
└── index.ts # Library exports
|
||||
```
|
||||
|
||||
### Key Metrics:
|
||||
|
||||
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.
|
||||
456
docs/MCP_ERROR_FIX_PLAN.md
Normal file
456
docs/MCP_ERROR_FIX_PLAN.md
Normal 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.
|
||||
172
docs/SINGLE_SESSION_IMPLEMENTATION.md
Normal file
172
docs/SINGLE_SESSION_IMPLEMENTATION.md
Normal 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)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.1",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -11,9 +11,11 @@
|
||||
"test-nodes": "node dist/scripts/test-nodes.js",
|
||||
"start": "node dist/mcp/index.js",
|
||||
"start:http": "MCP_MODE=http node dist/mcp/index.js",
|
||||
"start:http:legacy": "MCP_MODE=http node dist/http-server.js",
|
||||
"http": "npm run build && npm run start:http",
|
||||
"dev": "npm run build && npm run rebuild && npm run validate",
|
||||
"dev:http": "MCP_MODE=http nodemon --watch src --ext ts --exec 'npm run build && npm run start:http'",
|
||||
"test:single-session": "./scripts/test-single-session.sh",
|
||||
"test": "jest",
|
||||
"lint": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
65
scripts/test-single-session.sh
Executable file
65
scripts/test-single-session.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# Test script for single-session HTTP server
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing Single-Session HTTP Server..."
|
||||
echo
|
||||
|
||||
# Generate test auth token if not set
|
||||
if [ -z "$AUTH_TOKEN" ]; then
|
||||
export AUTH_TOKEN="test-token-$(date +%s)"
|
||||
echo "Generated test AUTH_TOKEN: $AUTH_TOKEN"
|
||||
fi
|
||||
|
||||
# Start server in background
|
||||
echo "Starting server..."
|
||||
MCP_MODE=http npm start > server.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to start
|
||||
echo "Waiting for server to start..."
|
||||
sleep 3
|
||||
|
||||
# Check health endpoint
|
||||
echo
|
||||
echo "Testing health endpoint..."
|
||||
curl -s http://localhost:3000/health | jq .
|
||||
|
||||
# Test authentication failure
|
||||
echo
|
||||
echo "Testing authentication failure..."
|
||||
curl -s -X POST http://localhost:3000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer wrong-token" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq .
|
||||
|
||||
# Test successful request
|
||||
echo
|
||||
echo "Testing successful request..."
|
||||
curl -s -X POST http://localhost:3000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq .
|
||||
|
||||
# Test session reuse
|
||||
echo
|
||||
echo "Testing session reuse (second request)..."
|
||||
curl -s -X POST http://localhost:3000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","method":"get_database_statistics","id":2}' | jq .
|
||||
|
||||
# Check health again to see session info
|
||||
echo
|
||||
echo "Checking health to see session info..."
|
||||
curl -s http://localhost:3000/health | jq .
|
||||
|
||||
# Clean up
|
||||
echo
|
||||
echo "Stopping server..."
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "✅ Test complete! Check server.log for details."
|
||||
361
src/http-server-single-session.ts
Normal file
361
src/http-server-single-session.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Single-Session HTTP server for n8n-MCP
|
||||
* Implements Hybrid Single-Session Architecture for protocol compliance
|
||||
* while maintaining simplicity for single-player use case
|
||||
*/
|
||||
import express from 'express';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { N8NDocumentationMCPServer } from './mcp/server-update';
|
||||
import { ConsoleManager } from './utils/console-manager';
|
||||
import { logger } from './utils/logger';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
interface Session {
|
||||
server: N8NDocumentationMCPServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
lastAccess: Date;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export class SingleSessionHTTPServer {
|
||||
private session: Session | null = null;
|
||||
private consoleManager = new ConsoleManager();
|
||||
private expressServer: any;
|
||||
private sessionTimeout = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
constructor() {
|
||||
// Validate environment on construction
|
||||
this.validateEnvironment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required environment variables
|
||||
*/
|
||||
private validateEnvironment(): void {
|
||||
const required = ['AUTH_TOKEN'];
|
||||
const missing = required.filter(key => !process.env[key]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
const message = `Missing required environment variables: ${missing.join(', ')}`;
|
||||
logger.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (process.env.AUTH_TOKEN && process.env.AUTH_TOKEN.length < 32) {
|
||||
logger.warn('AUTH_TOKEN should be at least 32 characters for security');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming MCP request
|
||||
*/
|
||||
async handleRequest(req: express.Request, res: express.Response): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Wrap all operations to prevent console interference
|
||||
return this.consoleManager.wrapOperation(async () => {
|
||||
try {
|
||||
// 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);
|
||||
|
||||
// Log request duration
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info('MCP request completed', {
|
||||
duration,
|
||||
method: req.body?.method,
|
||||
sessionId: this.session!.sessionId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('MCP request error:', error);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal server error',
|
||||
data: process.env.NODE_ENV === 'development'
|
||||
? (error as Error).message
|
||||
: undefined
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the session - clean up old and create new
|
||||
*/
|
||||
private async resetSession(): Promise<void> {
|
||||
// Clean up old session if exists
|
||||
if (this.session) {
|
||||
try {
|
||||
logger.info('Closing previous session', { sessionId: this.session.sessionId });
|
||||
await this.session.transport.close();
|
||||
// Note: Don't close the server as it handles its own lifecycle
|
||||
} 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 for single-session
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
this.session = {
|
||||
server,
|
||||
transport,
|
||||
lastAccess: new Date(),
|
||||
sessionId: 'single-session'
|
||||
};
|
||||
|
||||
logger.info('Created new single session', { sessionId: this.session.sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current session is expired
|
||||
*/
|
||||
private isExpired(): boolean {
|
||||
if (!this.session) return true;
|
||||
return Date.now() - this.session.lastAccess.getTime() > this.sessionTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
const app = express();
|
||||
|
||||
// Parse JSON with strict limits
|
||||
app.use(express.json({
|
||||
limit: '1mb',
|
||||
strict: true
|
||||
}));
|
||||
|
||||
// Security headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
next();
|
||||
});
|
||||
|
||||
// CORS configuration
|
||||
app.use((req, res, next) => {
|
||||
const allowedOrigin = process.env.CORS_ORIGIN || '*';
|
||||
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(204);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
contentLength: req.get('content-length')
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
mode: 'single-session',
|
||||
version: '2.3.1',
|
||||
uptime: Math.floor(process.uptime()),
|
||||
sessionActive: !!this.session,
|
||||
sessionAge: this.session
|
||||
? Math.floor((Date.now() - this.session.lastAccess.getTime()) / 1000)
|
||||
: null,
|
||||
memory: {
|
||||
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
unit: 'MB'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Main MCP endpoint with authentication
|
||||
app.post('/mcp', async (req: express.Request, res: express.Response): Promise<void> => {
|
||||
// Simple auth check
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith('Bearer ')
|
||||
? authHeader.slice(7)
|
||||
: authHeader;
|
||||
|
||||
if (token !== process.env.AUTH_TOKEN) {
|
||||
logger.warn('Authentication failed', {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
res.status(401).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32001,
|
||||
message: 'Unauthorized'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle request with single session
|
||||
await this.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'Not found',
|
||||
message: `Cannot ${req.method} ${req.path}`
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Express error handler:', err);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal server error',
|
||||
data: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
|
||||
this.expressServer = app.listen(port, host, () => {
|
||||
logger.info(`n8n MCP Single-Session HTTP Server started`, { port, host });
|
||||
console.log(`n8n MCP Single-Session HTTP Server running on ${host}:${port}`);
|
||||
console.log(`Health check: http://localhost:${port}/health`);
|
||||
console.log(`MCP endpoint: http://localhost:${port}/mcp`);
|
||||
console.log('\nPress Ctrl+C to stop the server');
|
||||
});
|
||||
|
||||
// Handle server errors
|
||||
this.expressServer.on('error', (error: any) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
logger.error(`Port ${port} is already in use`);
|
||||
console.error(`ERROR: Port ${port} is already in use`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
logger.error('Server error:', error);
|
||||
console.error('Server error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
logger.info('Shutting down Single-Session HTTP server...');
|
||||
|
||||
// Clean up session
|
||||
if (this.session) {
|
||||
try {
|
||||
await this.session.transport.close();
|
||||
logger.info('Session closed');
|
||||
} catch (error) {
|
||||
logger.warn('Error closing session:', error);
|
||||
}
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
// Close Express server
|
||||
if (this.expressServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.expressServer.close(() => {
|
||||
logger.info('HTTP server closed');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session info (for testing/debugging)
|
||||
*/
|
||||
getSessionInfo(): { active: boolean; sessionId?: string; age?: number } {
|
||||
if (!this.session) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
return {
|
||||
active: true,
|
||||
sessionId: this.session.sessionId,
|
||||
age: Date.now() - this.session.lastAccess.getTime()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Start if called directly
|
||||
if (require.main === module) {
|
||||
const server = new SingleSessionHTTPServer();
|
||||
|
||||
// Graceful shutdown handlers
|
||||
const shutdown = async () => {
|
||||
await server.shutdown();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
// Handle uncaught errors
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught exception:', error);
|
||||
console.error('Uncaught exception:', error);
|
||||
shutdown();
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled rejection:', reason);
|
||||
console.error('Unhandled rejection at:', promise, 'reason:', reason);
|
||||
shutdown();
|
||||
});
|
||||
|
||||
// Start server
|
||||
server.start().catch(error => {
|
||||
logger.error('Failed to start Single-Session HTTP server:', error);
|
||||
console.error('Failed to start Single-Session HTTP server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
53
src/index.ts
53
src/index.ts
@@ -4,48 +4,15 @@
|
||||
* Licensed under the Sustainable Use License v1.0
|
||||
*/
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import { N8NMCPServer } from './mcp/server';
|
||||
import { MCPServerConfig, N8NConfig } from './types';
|
||||
import { logger } from './utils/logger';
|
||||
// Engine exports for service integration
|
||||
export { N8NMCPEngine, EngineHealth, EngineOptions } from './mcp-engine';
|
||||
export { SingleSessionHTTPServer } from './http-server-single-session';
|
||||
export { ConsoleManager } from './utils/console-manager';
|
||||
export { N8NDocumentationMCPServer } from './mcp/server-update';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
// Default export for convenience
|
||||
import N8NMCPEngine from './mcp-engine';
|
||||
export default N8NMCPEngine;
|
||||
|
||||
async function main() {
|
||||
const config: MCPServerConfig = {
|
||||
port: parseInt(process.env.MCP_SERVER_PORT || '3000', 10),
|
||||
host: process.env.MCP_SERVER_HOST || 'localhost',
|
||||
authToken: process.env.MCP_AUTH_TOKEN,
|
||||
};
|
||||
|
||||
const n8nConfig: N8NConfig = {
|
||||
apiUrl: process.env.N8N_API_URL || 'http://localhost:5678',
|
||||
apiKey: process.env.N8N_API_KEY || '',
|
||||
};
|
||||
|
||||
const server = new N8NMCPServer(config, n8nConfig);
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
} catch (error) {
|
||||
logger.error('Failed to start MCP server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('Received SIGINT, shutting down MCP server...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('Received SIGTERM, shutting down MCP server...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
// Legacy CLI functionality - moved to ./mcp/index.ts
|
||||
// This file now serves as the main entry point for library usage
|
||||
170
src/mcp-engine.ts
Normal file
170
src/mcp-engine.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* N8N MCP Engine - Clean interface for service integration
|
||||
*
|
||||
* This class provides a simple API for integrating the n8n-MCP server
|
||||
* into larger services. The wrapping service handles authentication,
|
||||
* multi-tenancy, rate limiting, etc.
|
||||
*/
|
||||
import { Request, Response } from 'express';
|
||||
import { SingleSessionHTTPServer } from './http-server-single-session';
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
export interface EngineHealth {
|
||||
status: 'healthy' | 'unhealthy';
|
||||
uptime: number;
|
||||
sessionActive: boolean;
|
||||
memoryUsage: {
|
||||
used: number;
|
||||
total: number;
|
||||
unit: string;
|
||||
};
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface EngineOptions {
|
||||
sessionTimeout?: number;
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
export class N8NMCPEngine {
|
||||
private server: SingleSessionHTTPServer;
|
||||
private startTime: Date;
|
||||
|
||||
constructor(options: EngineOptions = {}) {
|
||||
this.server = new SingleSessionHTTPServer();
|
||||
this.startTime = new Date();
|
||||
|
||||
if (options.logLevel) {
|
||||
process.env.LOG_LEVEL = options.logLevel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single MCP request
|
||||
* The wrapping service handles authentication, multi-tenancy, etc.
|
||||
*
|
||||
* @example
|
||||
* // In your service
|
||||
* const engine = new N8NMCPEngine();
|
||||
*
|
||||
* app.post('/api/users/:userId/mcp', authenticate, async (req, res) => {
|
||||
* // Your service handles auth, rate limiting, user context
|
||||
* await engine.processRequest(req, res);
|
||||
* });
|
||||
*/
|
||||
async processRequest(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
await this.server.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
logger.error('Engine processRequest error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for service monitoring
|
||||
*
|
||||
* @example
|
||||
* app.get('/health', async (req, res) => {
|
||||
* const health = await engine.healthCheck();
|
||||
* res.status(health.status === 'healthy' ? 200 : 503).json(health);
|
||||
* });
|
||||
*/
|
||||
async healthCheck(): Promise<EngineHealth> {
|
||||
try {
|
||||
const sessionInfo = this.server.getSessionInfo();
|
||||
const memoryUsage = process.memoryUsage();
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
uptime: Math.floor((Date.now() - this.startTime.getTime()) / 1000),
|
||||
sessionActive: sessionInfo.active,
|
||||
memoryUsage: {
|
||||
used: Math.round(memoryUsage.heapUsed / 1024 / 1024),
|
||||
total: Math.round(memoryUsage.heapTotal / 1024 / 1024),
|
||||
unit: 'MB'
|
||||
},
|
||||
version: '2.3.1'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Health check failed:', error);
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
uptime: 0,
|
||||
sessionActive: false,
|
||||
memoryUsage: { used: 0, total: 0, unit: 'MB' },
|
||||
version: '2.3.1'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session information
|
||||
* Useful for monitoring and debugging
|
||||
*/
|
||||
getSessionInfo(): { active: boolean; sessionId?: string; age?: number } {
|
||||
return this.server.getSessionInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown for service lifecycle
|
||||
*
|
||||
* @example
|
||||
* process.on('SIGTERM', async () => {
|
||||
* await engine.shutdown();
|
||||
* process.exit(0);
|
||||
* });
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
logger.info('Shutting down N8N MCP Engine...');
|
||||
await this.server.shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the engine (if using standalone mode)
|
||||
* For embedded use, this is not necessary
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
await this.server.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage in a multi-tenant service:
|
||||
*
|
||||
* ```typescript
|
||||
* import { N8NMCPEngine } from 'n8n-mcp/engine';
|
||||
* import express from 'express';
|
||||
*
|
||||
* const app = express();
|
||||
* const engine = new N8NMCPEngine();
|
||||
*
|
||||
* // Middleware for authentication
|
||||
* const authenticate = (req, res, next) => {
|
||||
* // Your auth logic
|
||||
* req.userId = 'user123';
|
||||
* next();
|
||||
* };
|
||||
*
|
||||
* // MCP endpoint with multi-tenant support
|
||||
* app.post('/api/mcp/:userId', authenticate, async (req, res) => {
|
||||
* // Log usage for billing
|
||||
* await logUsage(req.userId, 'mcp-request');
|
||||
*
|
||||
* // Rate limiting
|
||||
* if (await isRateLimited(req.userId)) {
|
||||
* return res.status(429).json({ error: 'Rate limited' });
|
||||
* }
|
||||
*
|
||||
* // Process request
|
||||
* await engine.processRequest(req, res);
|
||||
* });
|
||||
*
|
||||
* // Health endpoint
|
||||
* app.get('/health', async (req, res) => {
|
||||
* const health = await engine.healthCheck();
|
||||
* res.json(health);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export default N8NMCPEngine;
|
||||
@@ -25,9 +25,20 @@ async function main() {
|
||||
console.error('Node version:', process.version);
|
||||
|
||||
if (mode === 'http') {
|
||||
// HTTP mode - for remote deployment
|
||||
const { startHTTPServer } = await import('../http-server');
|
||||
await startHTTPServer();
|
||||
// HTTP mode - for remote deployment with single-session architecture
|
||||
const { SingleSessionHTTPServer } = await import('../http-server-single-session');
|
||||
const server = new SingleSessionHTTPServer();
|
||||
|
||||
// Graceful shutdown handlers
|
||||
const shutdown = async () => {
|
||||
await server.shutdown();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
await server.start();
|
||||
} else {
|
||||
// Stdio mode - for local Claude Desktop
|
||||
const server = new N8NDocumentationMCPServer();
|
||||
|
||||
232
src/tests/single-session.test.ts
Normal file
232
src/tests/single-session.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { SingleSessionHTTPServer } from '../http-server-single-session';
|
||||
import express from 'express';
|
||||
import { ConsoleManager } from '../utils/console-manager';
|
||||
|
||||
// Mock express Request and Response
|
||||
const createMockRequest = (body: any = {}): express.Request => {
|
||||
return {
|
||||
body,
|
||||
headers: {
|
||||
authorization: `Bearer ${process.env.AUTH_TOKEN || 'test-token'}`
|
||||
},
|
||||
method: 'POST',
|
||||
path: '/mcp',
|
||||
ip: '127.0.0.1',
|
||||
get: (header: string) => {
|
||||
if (header === 'user-agent') return 'test-agent';
|
||||
if (header === 'content-length') return '100';
|
||||
return null;
|
||||
}
|
||||
} as any;
|
||||
};
|
||||
|
||||
const createMockResponse = (): express.Response => {
|
||||
const res: any = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: null,
|
||||
headersSent: false,
|
||||
status: function(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json: function(data: any) {
|
||||
this.body = data;
|
||||
this.headersSent = true;
|
||||
return this;
|
||||
},
|
||||
setHeader: function(name: string, value: string) {
|
||||
this.headers[name] = value;
|
||||
return this;
|
||||
},
|
||||
on: function(event: string, callback: Function) {
|
||||
// Simple event emitter mock
|
||||
return this;
|
||||
}
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('SingleSessionHTTPServer', () => {
|
||||
let server: SingleSessionHTTPServer;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AUTH_TOKEN = 'test-token';
|
||||
process.env.MCP_MODE = 'http';
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
server = new SingleSessionHTTPServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
describe('Console Management', () => {
|
||||
it('should silence console during request handling', async () => {
|
||||
const consoleManager = new ConsoleManager();
|
||||
const originalLog = console.log;
|
||||
|
||||
// Create spy functions
|
||||
const logSpy = jest.fn();
|
||||
console.log = logSpy;
|
||||
|
||||
// Test console is silenced during operation
|
||||
await consoleManager.wrapOperation(() => {
|
||||
console.log('This should not appear');
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test console is restored after operation
|
||||
console.log('This should appear');
|
||||
expect(logSpy).toHaveBeenCalledWith('This should appear');
|
||||
|
||||
// Restore original
|
||||
console.log = originalLog;
|
||||
});
|
||||
|
||||
it('should handle errors and still restore console', async () => {
|
||||
const consoleManager = new ConsoleManager();
|
||||
const originalError = console.error;
|
||||
|
||||
try {
|
||||
await consoleManager.wrapOperation(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
} catch (error) {
|
||||
// Expected error
|
||||
}
|
||||
|
||||
// Verify console was restored
|
||||
expect(console.error).toBe(originalError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
it('should create a single session on first request', async () => {
|
||||
const req = createMockRequest({ method: 'tools/list' });
|
||||
const res = createMockResponse();
|
||||
|
||||
const sessionInfoBefore = server.getSessionInfo();
|
||||
expect(sessionInfoBefore.active).toBe(false);
|
||||
|
||||
await server.handleRequest(req, res);
|
||||
|
||||
const sessionInfoAfter = server.getSessionInfo();
|
||||
expect(sessionInfoAfter.active).toBe(true);
|
||||
expect(sessionInfoAfter.sessionId).toBe('single-session');
|
||||
});
|
||||
|
||||
it('should reuse the same session for multiple requests', async () => {
|
||||
const req1 = createMockRequest({ method: 'tools/list' });
|
||||
const res1 = createMockResponse();
|
||||
const req2 = createMockRequest({ method: 'get_node_info' });
|
||||
const res2 = createMockResponse();
|
||||
|
||||
// First request creates session
|
||||
await server.handleRequest(req1, res1);
|
||||
const session1 = server.getSessionInfo();
|
||||
|
||||
// Second request reuses session
|
||||
await server.handleRequest(req2, res2);
|
||||
const session2 = server.getSessionInfo();
|
||||
|
||||
expect(session1.sessionId).toBe(session2.sessionId);
|
||||
expect(session2.sessionId).toBe('single-session');
|
||||
});
|
||||
|
||||
it('should handle authentication correctly', async () => {
|
||||
const reqNoAuth = createMockRequest({ method: 'tools/list' });
|
||||
delete reqNoAuth.headers.authorization;
|
||||
const resNoAuth = createMockResponse();
|
||||
|
||||
await server.handleRequest(reqNoAuth, resNoAuth);
|
||||
|
||||
expect(resNoAuth.statusCode).toBe(401);
|
||||
expect(resNoAuth.body).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32001,
|
||||
message: 'Unauthorized'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid auth token', async () => {
|
||||
const reqBadAuth = createMockRequest({ method: 'tools/list' });
|
||||
reqBadAuth.headers.authorization = 'Bearer wrong-token';
|
||||
const resBadAuth = createMockResponse();
|
||||
|
||||
await server.handleRequest(reqBadAuth, resBadAuth);
|
||||
|
||||
expect(resBadAuth.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Expiry', () => {
|
||||
it('should detect expired sessions', () => {
|
||||
// This would require mocking timers or exposing internal state
|
||||
// For now, we'll test the concept
|
||||
const sessionInfo = server.getSessionInfo();
|
||||
expect(sessionInfo.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle server errors gracefully', async () => {
|
||||
const req = createMockRequest({ invalid: 'data' });
|
||||
const res = createMockResponse();
|
||||
|
||||
// This might not cause an error with the current implementation
|
||||
// but demonstrates error handling structure
|
||||
await server.handleRequest(req, res);
|
||||
|
||||
// Should not throw, should return error response
|
||||
if (res.statusCode === 500) {
|
||||
expect(res.body).toHaveProperty('error');
|
||||
expect(res.body.error).toHaveProperty('code', -32603);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleManager', () => {
|
||||
it('should only silence in HTTP mode', () => {
|
||||
const originalMode = process.env.MCP_MODE;
|
||||
process.env.MCP_MODE = 'stdio';
|
||||
|
||||
const consoleManager = new ConsoleManager();
|
||||
const originalLog = console.log;
|
||||
|
||||
consoleManager.silence();
|
||||
expect(console.log).toBe(originalLog); // Should not change
|
||||
|
||||
process.env.MCP_MODE = originalMode;
|
||||
});
|
||||
|
||||
it('should track silenced state', () => {
|
||||
process.env.MCP_MODE = 'http';
|
||||
const consoleManager = new ConsoleManager();
|
||||
|
||||
expect(consoleManager.isActive).toBe(false);
|
||||
consoleManager.silence();
|
||||
expect(consoleManager.isActive).toBe(true);
|
||||
consoleManager.restore();
|
||||
expect(consoleManager.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested calls correctly', () => {
|
||||
process.env.MCP_MODE = 'http';
|
||||
const consoleManager = new ConsoleManager();
|
||||
const originalLog = console.log;
|
||||
|
||||
consoleManager.silence();
|
||||
consoleManager.silence(); // Second call should be no-op
|
||||
expect(consoleManager.isActive).toBe(true);
|
||||
|
||||
consoleManager.restore();
|
||||
expect(console.log).toBe(originalLog);
|
||||
});
|
||||
});
|
||||
83
src/utils/console-manager.ts
Normal file
83
src/utils/console-manager.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Console Manager for MCP HTTP Server
|
||||
*
|
||||
* Prevents console output from interfering with StreamableHTTPServerTransport
|
||||
* by silencing console methods during MCP request handling.
|
||||
*/
|
||||
export class ConsoleManager {
|
||||
private originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
debug: console.debug,
|
||||
trace: console.trace
|
||||
};
|
||||
|
||||
private isSilenced = false;
|
||||
|
||||
/**
|
||||
* Silence all console output
|
||||
*/
|
||||
public silence(): void {
|
||||
if (this.isSilenced || process.env.MCP_MODE !== 'http') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSilenced = true;
|
||||
process.env.MCP_REQUEST_ACTIVE = 'true';
|
||||
console.log = () => {};
|
||||
console.error = () => {};
|
||||
console.warn = () => {};
|
||||
console.info = () => {};
|
||||
console.debug = () => {};
|
||||
console.trace = () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original console methods
|
||||
*/
|
||||
public restore(): void {
|
||||
if (!this.isSilenced) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSilenced = false;
|
||||
process.env.MCP_REQUEST_ACTIVE = 'false';
|
||||
console.log = this.originalConsole.log;
|
||||
console.error = this.originalConsole.error;
|
||||
console.warn = this.originalConsole.warn;
|
||||
console.info = this.originalConsole.info;
|
||||
console.debug = this.originalConsole.debug;
|
||||
console.trace = this.originalConsole.trace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an operation with console silencing
|
||||
* Automatically restores console on completion or error
|
||||
*/
|
||||
public async wrapOperation<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
this.silence();
|
||||
try {
|
||||
const result = operation();
|
||||
if (result instanceof Promise) {
|
||||
return await result.finally(() => this.restore());
|
||||
}
|
||||
this.restore();
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.restore();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if console is currently silenced
|
||||
*/
|
||||
public get isActive(): boolean {
|
||||
return this.isSilenced;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance for easy use
|
||||
export const consoleManager = new ConsoleManager();
|
||||
@@ -14,6 +14,8 @@ export interface LoggerConfig {
|
||||
export class Logger {
|
||||
private config: LoggerConfig;
|
||||
private static instance: Logger;
|
||||
private useFileLogging = false;
|
||||
private fileStream: any = null;
|
||||
|
||||
constructor(config?: Partial<LoggerConfig>) {
|
||||
this.config = {
|
||||
@@ -52,6 +54,13 @@ export class Logger {
|
||||
if (level <= this.config.level) {
|
||||
const formattedMessage = this.formatMessage(levelName, message);
|
||||
|
||||
// In HTTP mode during request handling, suppress console output
|
||||
// The ConsoleManager will handle this, but we add a safety check
|
||||
if (process.env.MCP_MODE === 'http' && process.env.MCP_REQUEST_ACTIVE === 'true') {
|
||||
// Silently drop the log during active MCP requests
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case LogLevel.ERROR:
|
||||
console.error(formattedMessage, ...args);
|
||||
|
||||
Reference in New Issue
Block a user