Files
qiming/qimingclaw/crates/qiming-mcp-stdio-proxy/src/bridge.ts

596 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PersistentMcpBridge — long-lived MCP server bridge
*
* Manages persistent MCP servers (e.g. chrome-devtools-mcp) that outlive
* individual ACP sessions. Spawns child processes via CustomStdioClientTransport
* (with Windows fixes) and exposes them over HTTP for downstream consumers.
*
* Architecture:
* persistent child process (stdio)
* ↕ CustomStdioClientTransport
* MCP Client (cached tools, proxies callTool)
* ↕
* HTTP Server (single port, path routing /mcp/<serverId>)
* ↕ StreamableHTTPServerTransport (per HTTP session)
* MCP Server (tool handlers → Client.callTool)
*/
import * as http from 'http';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
import { CustomStdioClientTransport } from './customStdio.js';
import { ResilientTransportWrapper } from './resilient.js';
import type { StdioServerEntry } from './types.js';
import { MCP_SESSION_ID_HEADER } from './constants.js';
const LOG_TAG = '[McpProxy] [PersistentMcpBridge]';
const BASE_RESTART_COOLDOWN_MS = 5_000;
const MAX_RESTART_ATTEMPTS = 5;
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
const SESSION_CLEANUP_INTERVAL_MS = 60_000; // 1 minute
// ========== Types ==========
export interface BridgeLogger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
interface PersistentServerEntry {
config: StdioServerEntry;
client: Client | null;
transport: ResilientTransportWrapper | null;
tools: Tool[];
healthy: boolean;
restarting: boolean;
restartTimer: ReturnType<typeof setTimeout> | null;
restartCount: number;
}
/** session ID → { server, transport } */
interface HttpSession {
server: Server;
transport: StreamableHTTPServerTransport;
}
// ========== PersistentMcpBridge ==========
export class PersistentMcpBridge {
private log: BridgeLogger;
private httpServer: http.Server | null = null;
private port = 0;
private servers = new Map<string, PersistentServerEntry>();
/** "serverId:sessionId" → HttpSession */
private httpSessions = new Map<string, HttpSession>();
private running = false;
private sessionCleanupTimer: ReturnType<typeof setInterval> | null = null;
constructor(logger?: BridgeLogger) {
this.log = logger ?? console;
}
/**
* Start bridge: spawn child processes for each persistent server and create HTTP server
*
* @param servers - Map of server ID to stdio config
* @param options - Optional settings (e.g. port to listen on)
*/
async start(servers: Record<string, StdioServerEntry>, options?: { port?: number }): Promise<void> {
if (this.running) {
this.log.warn(`${LOG_TAG} Already running, stopping first`);
await this.stop();
}
const serverCount = Object.keys(servers).length;
this.log.info(`${LOG_TAG} Starting with ${serverCount} persistent servers (parallel)`);
// 1. 并行启动 HTTP server 和所有 MCP server 子进程。
// 两者完全独立HTTP server 在请求时才查询 this.servers
// startServer 会在异步 spawnAndConnect 前先同步写入 this.servers
// 所以 HTTP server 收到第一个请求时所有 entry 都已注册。
//
// 提前保存 serverPromises 引用:若 startHttpServer 抛出,在 catch 中先
// await Promise.allSettled(serverPromises) 等所有 spawnAndConnect 跑完
// (保证 entry.client / entry.transport 已赋值),再调用 stopServer
// 才能可靠关闭子进程,防止孤儿进程残留。
const serverPromises = Object.entries(servers).map(([id, config]) => this.startServer(id, config));
try {
await Promise.all([
this.startHttpServer(options?.port),
...serverPromises,
]);
} catch (e) {
this.log.error(`${LOG_TAG} Start failed, cleaning spawned child processes:`, e);
// 等待所有 spawnAndConnect 完成,确保 entry.client/transport 已赋值后再清理
await Promise.allSettled(serverPromises);
await Promise.all(Array.from(this.servers.keys()).map((id) => this.stopServer(id)));
this.servers.clear();
throw e;
}
this.running = true;
// 2. Start periodic session cleanup
this.sessionCleanupTimer = setInterval(() => this.cleanupStaleSessions(), SESSION_CLEANUP_INTERVAL_MS);
this.log.info(`${LOG_TAG} Bridge ready on port ${this.port} (${serverCount} servers)`);
}
/**
* Stop bridge: close HTTP, kill all child processes
*/
async stop(): Promise<void> {
this.log.info(`${LOG_TAG} Stopping...`);
this.running = false;
// Stop session cleanup timer
if (this.sessionCleanupTimer) {
clearInterval(this.sessionCleanupTimer);
this.sessionCleanupTimer = null;
}
// 并行关闭所有 HTTP sessions各 session transport 独立,无顺序依赖)。
// 内层 async 函数自行 catch 错误后不再 throwPromise.all 足够,不需要 allSettled。
await Promise.all(
Array.from(this.httpSessions.entries()).map(async ([key, session]) => {
try {
await session.transport.close();
} catch (e) {
this.log.warn(`${LOG_TAG} Error closing HTTP session ${key}:`, e);
}
}),
);
this.httpSessions.clear();
// Close HTTP server
if (this.httpServer) {
await new Promise<void>((resolve) => {
this.httpServer!.close(() => resolve());
});
this.httpServer = null;
this.port = 0;
}
// 并行关闭所有 MCP server 子进程(各自独立,无顺序依赖)
await Promise.all(
Array.from(this.servers.keys()).map((id) => this.stopServer(id)),
);
this.servers.clear();
this.log.info(`${LOG_TAG} Stopped`);
}
/**
* Get bridge URL for a server (for bridge clients to connect)
*/
getBridgeUrl(serverId: string): string | null {
if (!this.running || !this.port) return null;
const entry = this.servers.get(serverId);
if (!entry || !entry.healthy) return null;
return `http://127.0.0.1:${this.port}/mcp/${serverId}`;
}
/**
* Whether the bridge is running
*/
isRunning(): boolean {
return this.running && this.port > 0;
}
/**
* Health check for a specific server
*/
isServerHealthy(serverId: string): boolean {
return this.servers.get(serverId)?.healthy ?? false;
}
// ==================== Internal: Server Lifecycle ====================
private async startServer(id: string, config: StdioServerEntry): Promise<void> {
const entry: PersistentServerEntry = {
config,
client: null,
transport: null,
tools: [],
healthy: false,
restarting: false,
restartTimer: null,
restartCount: 0,
};
this.servers.set(id, entry);
await this.spawnAndConnect(id, entry);
}
private async spawnAndConnect(id: string, entry: PersistentServerEntry): Promise<void> {
try {
this.log.info(`${LOG_TAG} Spawning server "${id}": ${entry.config.command} ${(entry.config.args || []).join(' ')}`);
// Create MCP Client + ResilientTransportWrapper
const wrapper = new ResilientTransportWrapper({
name: id,
logger: this.log,
connectParams: async () => {
const t = new CustomStdioClientTransport({
command: entry.config.command,
args: entry.config.args || [],
env: entry.config.env,
stderr: 'pipe',
});
if (t.stderr) {
t.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text) this.log.info(`${LOG_TAG} [${id}:stderr] ${text}`);
});
}
return t;
},
pingIntervalMs: 0, // No heartbeat for stdio — child process close/error events handle detection
});
const client = new Client(
{ name: 'qiming-persistent-bridge', version: '1.0.0' },
{ capabilities: {} },
);
// Handle wrapper intentional close → mark unhealthy
wrapper.onclose = () => {
this.log.warn(`${LOG_TAG} Server "${id}" wrapper closed`);
entry.healthy = false;
};
wrapper.onerror = (err: Error) => {
this.log.error(`${LOG_TAG} Server "${id}" transport error:`, err.message);
};
// Handle reconnect → re-establish MCP session
wrapper.onreconnect = async () => {
this.log.info(`${LOG_TAG} Server "${id}" reconnecting MCP session...`);
// Track reconnect attempts for this server
entry.restartCount++;
if (entry.restartCount > MAX_RESTART_ATTEMPTS) {
this.log.warn(`${LOG_TAG} Server "${id}" failed ${entry.restartCount - 1} reconnect attempts, giving up (max ${MAX_RESTART_ATTEMPTS})`);
// Close the wrapper to stop further reconnect attempts
await wrapper.close();
return;
}
try {
// Close old client if exists
if (entry.client) {
try { await entry.client.close(); } catch { /* ignore */ }
}
// Create new client and connect
const newClient = new Client(
{ name: 'qiming-persistent-bridge', version: '1.0.0' },
{ capabilities: {} },
);
await newClient.connect(wrapper);
// Update entry with new client and refresh tools
entry.client = newClient;
const result = await newClient.listTools();
entry.tools = result.tools;
entry.healthy = true;
entry.restartCount = 0; // reset on success
this.log.info(`${LOG_TAG} Server "${id}" reconnected with ${entry.tools.length} tools`);
} catch (err) {
this.log.error(`${LOG_TAG} Server "${id}" reconnect failed (attempt ${entry.restartCount}/${MAX_RESTART_ATTEMPTS}):`, err);
throw err; // Re-throw to let ResilientTransportWrapper trigger another reconnect
}
};
// Connect client to transport (this starts the subprocess)
await wrapper.start();
await client.connect(wrapper);
entry.client = client;
entry.transport = wrapper;
// List tools (cached)
const result = await client.listTools();
entry.tools = result.tools;
entry.healthy = true;
entry.restartCount = 0; // reset on success
this.log.info(`${LOG_TAG} Server "${id}" ready with ${entry.tools.length} tools: ${entry.tools.map((t) => t.name).join(', ')}`);
} catch (e) {
this.log.error(`${LOG_TAG} Failed to start server "${id}":`, e);
entry.healthy = false;
if (this.running && !entry.restarting) {
this.scheduleRestart(id, entry);
}
}
}
private scheduleRestart(id: string, entry: PersistentServerEntry): void {
if (entry.restartTimer) return;
entry.restartCount++;
if (entry.restartCount > MAX_RESTART_ATTEMPTS) {
this.log.warn(`${LOG_TAG} Server "${id}" failed ${entry.restartCount - 1} times, giving up (max ${MAX_RESTART_ATTEMPTS})`);
entry.restarting = false;
return;
}
entry.restarting = true;
// Exponential backoff: 5s, 10s, 20s, 40s, 80s
const delay = BASE_RESTART_COOLDOWN_MS * Math.pow(2, entry.restartCount - 1);
this.log.info(`${LOG_TAG} Scheduling restart for "${id}" in ${delay}ms (attempt ${entry.restartCount}/${MAX_RESTART_ATTEMPTS})`);
entry.restartTimer = setTimeout(async () => {
entry.restartTimer = null;
entry.restarting = false;
if (!this.running) return;
// Clean up old resources
try {
if (entry.client) await entry.client.close();
} catch { /* ignore */ }
try {
if (entry.transport) await entry.transport.close();
} catch { /* ignore */ }
entry.client = null;
entry.transport = null;
this.log.info(`${LOG_TAG} Restarting server "${id}"... (attempt ${entry.restartCount}/${MAX_RESTART_ATTEMPTS})`);
await this.spawnAndConnect(id, entry);
}, delay);
}
private async stopServer(id: string): Promise<void> {
const entry = this.servers.get(id);
if (!entry) return;
if (entry.restartTimer) {
clearTimeout(entry.restartTimer);
entry.restartTimer = null;
}
entry.restarting = false;
entry.healthy = false;
try {
if (entry.client) await entry.client.close();
} catch (e) {
this.log.warn(`${LOG_TAG} Error closing client for "${id}":`, e);
}
// transport.close() handles graceful shutdown: stdin.end → SIGTERM → SIGKILL
try {
if (entry.transport) await entry.transport.close();
} catch (e) {
this.log.warn(`${LOG_TAG} Error closing transport for "${id}":`, e);
}
entry.client = null;
entry.transport = null;
}
// ==================== Internal: HTTP Server ====================
private async startHttpServer(listenPort?: number): Promise<void> {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
this.handleHttpRequest(req, res).catch((e) => {
this.log.error(`${LOG_TAG} HTTP request error:`, e);
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
});
});
// Listen on specified port or random port (0)
server.listen(listenPort ?? 0, '127.0.0.1', () => {
const addr = server.address();
if (addr && typeof addr === 'object') {
this.port = addr.port;
}
this.httpServer = server;
this.log.info(`${LOG_TAG} HTTP server listening on 127.0.0.1:${this.port}`);
resolve();
});
server.on('error', (err) => {
this.log.error(`${LOG_TAG} HTTP server error:`, err);
reject(err);
});
});
}
private async handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`);
const pathParts = url.pathname.split('/').filter(Boolean);
// Route: /mcp/<serverId>
if (pathParts.length !== 2 || pathParts[0] !== 'mcp') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found. Use /mcp/<serverId>' }));
return;
}
const serverId = pathParts[1];
const serverEntry = this.servers.get(serverId);
if (!serverEntry || !serverEntry.healthy || !serverEntry.client) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Server "${serverId}" not available` }));
return;
}
// Handle DELETE → terminate session
if (req.method === 'DELETE') {
const sessionId = req.headers[MCP_SESSION_ID_HEADER] as string | undefined;
if (sessionId) {
const key = `${serverId}:${sessionId}`;
const session = this.httpSessions.get(key);
if (session) {
try {
await session.transport.close();
} catch { /* ignore */ }
this.httpSessions.delete(key);
}
}
res.writeHead(200);
res.end();
return;
}
// POST or GET → route to session
if (req.method === 'POST' || req.method === 'GET') {
const sessionId = req.headers[MCP_SESSION_ID_HEADER] as string | undefined;
// Try to find existing session
if (sessionId) {
const key = `${serverId}:${sessionId}`;
const session = this.httpSessions.get(key);
if (session) {
if (req.method === 'POST') {
const body = await this.readBody(req);
await session.transport.handleRequest(req, res, body);
} else {
await session.transport.handleRequest(req, res);
}
return;
}
}
// New session: only for POST (initialize request)
if (req.method === 'POST') {
const body = await this.readBody(req);
const session = this.createHttpSession(serverId, serverEntry);
await session.transport.handleRequest(req, res, body);
// Register session immediately after first handleRequest (sessionId is now set)
const sid = session.transport.sessionId;
if (sid) {
const key = `${serverId}:${sid}`;
this.httpSessions.set(key, session);
this.log.info(`${LOG_TAG} New HTTP session: ${key}`);
}
return;
}
// GET without session → 400
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Missing ${MCP_SESSION_ID_HEADER} header for GET` }));
return;
}
res.writeHead(405);
res.end();
}
private createHttpSession(serverId: string, serverEntry: PersistentServerEntry): HttpSession {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => `${serverId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
});
const mcpServer = new Server(
{ name: `qiming-bridge-${serverId}`, version: '1.0.0' },
{
capabilities: {
tools: {},
},
},
);
// Register tool handlers that proxy to the persistent Client
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: serverEntry.tools };
});
mcpServer.setRequestHandler(CallToolRequestSchema, async (request: { params: { name: string; arguments?: Record<string, unknown> } }) => {
if (!serverEntry.client || !serverEntry.healthy) {
return {
content: [{ type: 'text', text: `Server "${serverId}" is not available` }],
isError: true,
};
}
try {
const result = await serverEntry.client.callTool({
name: request.params.name,
arguments: request.params.arguments,
});
return result as { content: Array<{ type: string; text?: string }>; isError?: boolean };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return {
content: [{ type: 'text', text: `Tool call failed: ${msg}` }],
isError: true,
};
}
});
// Clean up on close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) {
const key = `${serverId}:${sid}`;
this.httpSessions.delete(key);
this.log.info(`${LOG_TAG} HTTP session closed: ${key}`);
}
};
// Connect server to transport
mcpServer.connect(transport).catch((e: unknown) => {
this.log.error(`${LOG_TAG} Failed to connect HTTP session server:`, e);
});
return { server: mcpServer, transport };
}
/**
* Remove sessions whose transport has been closed but not cleaned up
*/
private cleanupStaleSessions(): void {
let cleaned = 0;
for (const [key, session] of this.httpSessions) {
// sessionId becomes undefined after transport.close()
if (!session.transport.sessionId) {
this.httpSessions.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
this.log.info(`${LOG_TAG} Cleaned up ${cleaned} stale HTTP session(s)`);
}
}
private readBody(req: http.IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalSize = 0;
req.on('data', (chunk: Buffer) => {
totalSize += chunk.length;
if (totalSize > MAX_BODY_SIZE) {
req.destroy();
reject(new Error(`Request body exceeds ${MAX_BODY_SIZE} bytes`));
return;
}
chunks.push(chunk);
});
req.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf-8');
resolve(JSON.parse(raw));
} catch (e) {
reject(e);
}
});
req.on('error', reject);
});
}
}