claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
732 lines • 27.8 kB
JavaScript
/**
* V3 CLI MCP Server Management
*
* Provides server lifecycle management for MCP integration:
* - Start/stop/status methods with process management
* - Health check endpoint integration
* - Graceful shutdown handling
* - PID file management for daemon detection
* - Event-based status monitoring
*
* Performance Targets:
* - Server startup: <400ms
* - Health check: <10ms
* - Graceful shutdown: <5s
*
* @module @claude-flow/cli/mcp-server
* @version 3.0.0
*/
import { EventEmitter } from 'events';
import { execFileSync } from 'child_process';
import { request as httpRequestFn } from 'http';
import { randomUUID } from 'crypto';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { trackRequest } from './mcp-tools/request-tracker.js';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Default configuration
*/
const DEFAULT_OPTIONS = {
transport: 'stdio',
host: 'localhost',
port: 3000,
pidFile: path.join(os.tmpdir(), 'claude-flow-mcp.pid'),
logFile: path.join(os.tmpdir(), 'claude-flow-mcp.log'),
tools: 'all',
daemonize: false,
timeout: 30000,
};
/**
* MCP Server Manager
*
* Manages the lifecycle of the MCP server process
*/
export class MCPServerManager extends EventEmitter {
options;
process;
server;
startTime;
healthCheckInterval;
constructor(options = {}) {
super();
this.options = { ...DEFAULT_OPTIONS, ...options };
}
/**
* Start the MCP server
*/
async start() {
// Check if already running (skip if status reports our own PID —
// getStatus() returns running=true for the current process in stdio mode
// even before the server is actually started)
const status = await this.getStatus();
if (status.running && status.pid !== process.pid) {
throw new Error(`MCP Server already running (PID: ${status.pid})`);
}
const startTime = performance.now();
this.startTime = new Date();
this.emit('starting', { options: this.options });
try {
if (this.options.transport === 'stdio') {
// For stdio transport, spawn the server process
await this.startStdioServer();
}
else {
// For HTTP/WebSocket, start in-process server
await this.startHttpServer();
}
const duration = performance.now() - startTime;
// Write PID file
await this.writePidFile();
// Start health check monitoring
this.startHealthMonitoring();
const finalStatus = await this.getStatus();
this.emit('started', {
...finalStatus,
startupTime: duration,
});
return finalStatus;
}
catch (error) {
this.emit('error', error);
throw error;
}
}
/**
* Stop the MCP server
*/
async stop(force = false) {
const status = await this.getStatus();
if (!status.running) {
return;
}
this.emit('stopping', { force });
try {
// Stop health monitoring
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = undefined;
}
if (this.process) {
// Graceful shutdown
if (!force) {
this.process.kill('SIGTERM');
await this.waitForExit(5000);
}
// Force kill if still running
if (this.process && !this.process.killed) {
this.process.kill('SIGKILL');
}
this.process = undefined;
}
if (this.server) {
await new Promise((resolve) => {
this.server.close(() => resolve());
});
this.server = undefined;
}
// Remove PID file
await this.removePidFile();
this.startTime = undefined;
this.emit('stopped');
}
catch (error) {
this.emit('error', error);
throw error;
}
}
/**
* Get server status
*/
async getStatus() {
// Check PID file
const pid = await this.readPidFile();
if (!pid) {
// No PID file found. Detect if we are running in stdio mode
// (e.g., launched by Claude Code via `claude mcp add`).
const isStdio = !process.stdin.isTTY;
const envTransport = process.env.CLAUDE_FLOW_MCP_TRANSPORT;
if (isStdio || envTransport === 'stdio' || this.options.transport === 'stdio') {
return {
running: true,
pid: process.pid,
transport: 'stdio',
startedAt: this.startTime?.toISOString(),
uptime: this.startTime
? Math.floor((Date.now() - this.startTime.getTime()) / 1000)
: undefined,
};
}
return { running: false };
}
// Check if process is running
const isRunning = this.isProcessRunning(pid);
if (!isRunning) {
// Clean up stale PID file
await this.removePidFile();
return { running: false };
}
// Build status
const status = {
running: true,
pid,
transport: this.options.transport,
host: this.options.host,
port: this.options.port,
startedAt: this.startTime?.toISOString(),
uptime: this.startTime
? Math.floor((Date.now() - this.startTime.getTime()) / 1000)
: undefined,
};
// Get health status for HTTP transport
if (this.options.transport !== 'stdio') {
status.health = await this.checkHealth();
}
return status;
}
/**
* Check server health
*/
async checkHealth() {
if (this.options.transport === 'stdio') {
// For stdio, check if process is running
const pid = await this.readPidFile();
if (pid === null) {
return { healthy: false, error: 'No PID file found' };
}
if (!this.isProcessRunning(pid)) {
// Clean up stale PID file
await this.removePidFile();
return { healthy: false, error: 'Process not running (cleaned up stale PID)' };
}
return { healthy: true };
}
// For HTTP/WebSocket, make health check request
try {
const response = await this.httpRequest(`http://${this.options.host}:${this.options.port}/health`, 'GET', this.options.timeout);
return {
healthy: response.status === 'ok',
metrics: {
connections: response.connections || 0,
},
};
}
catch (error) {
return {
healthy: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Restart the server
*/
async restart() {
await this.stop();
return await this.start();
}
/**
* Start stdio server in-process
* Handles stdin/stdout directly like V2 implementation
*/
async startStdioServer() {
// ruflo#1910 — protect the JSON-RPC stdout from any stray
// console.log/info/debug emitted by lazily-loaded modules
// (@ruvector/router, @claude-flow/neural, transformers.js, ONNX,
// semantic-router init, etc.). Codex closes the MCP transport
// the moment it sees a non-JSON line on stdout, and one such
// line during a tool batch bricked the whole session.
//
// Strategy: replace console.log/info/debug with stderr writers
// for the rest of the process. JSON-RPC frames go out via the
// dedicated `writeFrame()` helper below (process.stdout.write
// with the original native binding, NOT console.log), so the
// hijack can't accidentally redirect protocol frames too.
process.env.MCP_STDIO_MODE = '1';
const originalLog = console.log; // eslint-disable-line no-console
console.log = (...args) => process.stderr.write('[stdout→stderr] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') + '\n');
console.info = (...args) => process.stderr.write('[stdout→stderr] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') + '\n');
console.debug = (...args) => process.stderr.write('[stdout→stderr] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') + '\n');
/** Send a single JSON-RPC frame to the real stdout. Use this instead
* of `console.log` so the hijack above can't redirect protocol frames. */
const writeFrame = (msg) => {
process.stdout.write(JSON.stringify(msg) + '\n');
};
// Reference originalLog to keep the eslint-disable meaningful — also
// gives us an escape hatch if a test wants to verify it was replaced.
void originalLog;
// Catch fatal errors that would otherwise close the transport
// mid-batch with no JSON-RPC error returned to the client.
process.on('uncaughtException', (err) => {
process.stderr.write(`[mcp-stdio] uncaughtException: ${err.stack || err.message}\n`);
});
process.on('unhandledRejection', (reason) => {
process.stderr.write(`[mcp-stdio] unhandledRejection: ${reason instanceof Error ? reason.stack || reason.message : String(reason)}\n`);
});
// Import the tool registry
const { listMCPTools, callMCPTool, hasTool } = await import('./mcp-client.js');
const VERSION = '3.0.0';
const sessionId = `mcp-${Date.now()}-${randomUUID().slice(0, 8)}`;
// Log to stderr to not corrupt stdout
console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] (${sessionId}) Starting in stdio mode`);
// Auto-initialize memory database before tools are registered (#1524)
// This ensures memory_store and other memory tools work immediately
// without waiting for the first tool call to trigger lazy init.
try {
const { initializeMemoryDatabase, checkMemoryInitialization } = await import('./memory/memory-initializer.js');
const status = await checkMemoryInitialization();
if (!status.initialized) {
console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] (${sessionId}) Auto-initializing memory database...`);
const result = await initializeMemoryDatabase({ force: false, verbose: false });
if (result.success) {
console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] (${sessionId}) Memory database initialized at ${result.dbPath}`);
}
else if (result.error && !result.error.includes('already exists')) {
console.error(`[${new Date().toISOString()}] WARN [claude-flow-mcp] (${sessionId}) Memory database init returned: ${result.error}`);
}
}
else {
console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] (${sessionId}) Memory database already initialized (v${status.version || 'unknown'})`);
}
}
catch (memInitError) {
// Graceful degradation: server continues even if memory init fails.
// Memory tools will attempt lazy init on first call via ensureInitialized().
console.error(`[${new Date().toISOString()}] WARN [claude-flow-mcp] (${sessionId}) Memory auto-init failed (tools will retry on first call): ${memInitError instanceof Error ? memInitError.message : String(memInitError)}`);
}
console.error(JSON.stringify({
arch: process.arch,
mode: 'mcp-stdio',
nodeVersion: process.version,
pid: process.pid,
platform: process.platform,
protocol: 'stdio',
sessionId,
version: VERSION,
}));
// Send server initialization notification
writeFrame({
jsonrpc: '2.0',
method: 'server.initialized',
params: {
serverInfo: {
name: 'ruflo',
version: VERSION,
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true, listChanged: true },
},
},
},
});
// Handle stdin messages (S-5: bounded buffer to prevent OOM)
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB
let buffer = '';
process.stdin.on('data', async (chunk) => {
buffer += chunk.toString();
if (buffer.length > MAX_BUFFER_SIZE) {
console.error(`[${new Date().toISOString()}] ERROR [claude-flow-mcp] Buffer exceeded ${MAX_BUFFER_SIZE} bytes, rejecting`);
buffer = '';
writeFrame({
jsonrpc: '2.0',
error: { code: -32600, message: 'Request too large' },
});
return;
}
// Process complete JSON messages
let lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim()) {
try {
const message = JSON.parse(line);
const response = await this.handleMCPMessage(message, sessionId);
if (response) {
writeFrame(response);
}
}
catch (error) {
console.error(`[${new Date().toISOString()}] ERROR [claude-flow-mcp] Failed to parse message:`, error instanceof Error ? error.message : String(error));
}
}
}
});
process.stdin.on('end', () => {
console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] (${sessionId}) stdin closed, shutting down...`);
process.exit(0);
});
// Handle process termination
process.on('SIGINT', () => {
console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] (${sessionId}) Received SIGINT, shutting down...`);
process.exit(0);
});
process.on('SIGTERM', () => {
console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] (${sessionId}) Received SIGTERM, shutting down...`);
process.exit(0);
});
// Mark as ready immediately for stdio
this.emit('ready');
}
/**
* Handle incoming MCP message
*/
async handleMCPMessage(message, sessionId) {
const { listMCPTools, callMCPTool, hasTool } = await import('./mcp-client.js');
if (!message.method) {
return {
jsonrpc: '2.0',
id: message.id,
error: { code: -32600, message: 'Invalid Request: missing method' },
};
}
const params = (message.params || {});
try {
switch (message.method) {
case 'initialize':
return {
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: '2024-11-05',
serverInfo: { name: 'ruflo', version: '3.0.0' },
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true, listChanged: true },
},
},
};
case 'tools/list':
const tools = listMCPTools();
return {
jsonrpc: '2.0',
id: message.id,
result: {
tools: tools.map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
},
};
case 'tools/call':
const toolName = params.name;
const toolParams = (params.arguments || {});
if (!hasTool(toolName)) {
return {
jsonrpc: '2.0',
id: message.id,
error: { code: -32601, message: `Tool not found: ${toolName}` },
};
}
try {
const result = await callMCPTool(toolName, toolParams, { sessionId });
trackRequest(toolName, true);
return {
jsonrpc: '2.0',
id: message.id,
result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] },
};
}
catch (error) {
trackRequest(toolName, false);
return {
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: error instanceof Error ? error.message : 'Tool execution failed',
},
};
}
case 'notifications/initialized':
// Client notification - no response needed
console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] (${sessionId}) Client initialized`);
return null;
case 'ping':
return {
jsonrpc: '2.0',
id: message.id,
result: {},
};
default:
return {
jsonrpc: '2.0',
id: message.id,
error: { code: -32601, message: `Method not found: ${message.method}` },
};
}
}
catch (error) {
console.error(`[${new Date().toISOString()}] ERROR [claude-flow-mcp] Error handling ${message.method}:`, error);
return {
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: error instanceof Error ? error.message : 'Internal error',
},
};
}
}
/**
* Start HTTP server in-process
*/
async startHttpServer() {
// Dynamically import the MCP server package
// FIX for issue #942: Use proper package import instead of broken relative path
const { createMCPServer } = await import('@claude-flow/mcp');
const logger = {
debug: (msg, data) => this.emit('log', { level: 'debug', msg, data }),
info: (msg, data) => this.emit('log', { level: 'info', msg, data }),
warn: (msg, data) => this.emit('log', { level: 'warn', msg, data }),
error: (msg, data) => this.emit('log', { level: 'error', msg, data }),
};
const mcpServer = createMCPServer({
name: 'Claude-Flow MCP Server V3',
version: '3.0.0',
transport: this.options.transport,
host: this.options.host,
port: this.options.port,
enableMetrics: true,
enableCaching: true,
}, logger);
await mcpServer.start();
// Store reference for stopping
this._mcpServer = mcpServer;
}
/**
* Wait for server to be ready
*/
async waitForReady(timeout = 10000) {
// For stdio transport, we're ready immediately (in-process)
if (this.options.transport === 'stdio') {
return;
}
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const health = await this.checkHealth();
if (health.healthy) {
return;
}
await this.sleep(100);
}
throw new Error('Server failed to start within timeout');
}
/**
* Wait for process to exit
*/
async waitForExit(timeout) {
if (!this.process)
return;
return new Promise((resolve) => {
const timer = setTimeout(() => {
resolve();
}, timeout);
this.process.once('exit', () => {
clearTimeout(timer);
resolve();
});
});
}
/**
* Start health monitoring
*/
startHealthMonitoring() {
this.healthCheckInterval = setInterval(async () => {
try {
const health = await this.checkHealth();
this.emit('health', health);
if (!health.healthy) {
this.emit('unhealthy', health);
}
}
catch (error) {
this.emit('health-error', error);
}
}, 30000);
this.healthCheckInterval.unref();
}
/**
* Write PID file
*/
async writePidFile() {
const pid = this.process?.pid || process.pid;
await fs.promises.writeFile(this.options.pidFile, String(pid), 'utf8');
}
/**
* Read PID file
*/
async readPidFile() {
try {
const content = await fs.promises.readFile(this.options.pidFile, 'utf8');
const pid = parseInt(content.trim(), 10);
return isNaN(pid) ? null : pid;
}
catch {
return null;
}
}
/**
* Remove PID file
*/
async removePidFile() {
try {
await fs.promises.unlink(this.options.pidFile);
}
catch {
// Ignore errors
}
// Also clean up legacy PID file location from older versions
try {
const legacyPath = path.join(process.env.CLAUDE_FLOW_CWD || process.cwd(), '.claude-flow', 'mcp-server.pid');
if (legacyPath !== this.options.pidFile) {
await fs.promises.unlink(legacyPath);
}
}
catch {
// Ignore — file may not exist
}
}
/**
* Check if process is running AND is a node/claude-flow process.
* Plain `kill -0` returns true for any process with the same owner,
* which causes false positives when the OS recycles the PID.
*/
isProcessRunning(pid) {
try {
process.kill(pid, 0);
}
catch {
return false;
}
// Verify it's actually a node process (guards against PID reuse)
// DA-CRIT-3: Use execFileSync to prevent command injection via PID values
try {
const safePid = String(Math.floor(Math.abs(pid)));
let cmdline = '';
try {
// Try /proc on Linux
cmdline = fs.readFileSync(`/proc/${safePid}/cmdline`, 'utf8');
}
catch {
// Fall back to ps on macOS/other
try {
cmdline = execFileSync('ps', ['-p', safePid, '-o', 'comm='], {
encoding: 'utf8',
timeout: 1000,
}).trim();
}
catch {
// ps failed — fall through
}
}
if (!cmdline)
return true; // Can't inspect, fall back to kill check
// Must be a node process to be our MCP server
return cmdline.includes('node') || cmdline.includes('claude-flow') || cmdline.includes('npx');
}
catch {
// If we can't inspect the process (macOS, Windows, permissions), fall back to kill check
return true;
}
}
/**
* Make HTTP request
*/
async httpRequest(url, method, timeout) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const req = httpRequestFn({
hostname: urlObj.hostname,
port: urlObj.port,
path: urlObj.pathname,
method,
timeout,
}, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
resolve(JSON.parse(data));
}
catch {
resolve({ status: res.statusCode === 200 ? 'ok' : 'error' });
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
}
/**
* Sleep utility
*/
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
/**
* Create MCP server manager
*/
export function createMCPServerManager(options) {
return new MCPServerManager(options);
}
/**
* Singleton server manager instance
*/
let serverManager = null;
let currentTransport = undefined;
/**
* Get or create server manager singleton
*
* FIX for issue #942: Recreate singleton if transport type changes
* Previously, once created with stdio (default), HTTP options were ignored
*/
export function getServerManager(options) {
const requestedTransport = options?.transport;
// Recreate if transport type changes (fixes HTTP transport not working)
if (serverManager && requestedTransport && requestedTransport !== currentTransport) {
serverManager = new MCPServerManager(options);
currentTransport = requestedTransport;
}
if (!serverManager) {
serverManager = new MCPServerManager(options);
currentTransport = options?.transport;
}
return serverManager;
}
/**
* Quick start MCP server
*/
export async function startMCPServer(options) {
const manager = getServerManager(options);
return await manager.start();
}
/**
* Quick stop MCP server
*/
export async function stopMCPServer(force = false) {
if (serverManager) {
await serverManager.stop(force);
}
}
/**
* Get MCP server status
*/
export async function getMCPServerStatus() {
const manager = getServerManager();
return await manager.getStatus();
}
export default MCPServerManager;
//# sourceMappingURL=mcp-server.js.map