UNPKG

overmind-mcp

Version:

Orchestrateur universel agents IA multi-modeles via MCP. Inclut le protocole 'Custom-Nickname' pour identifier vos agents avec des surnoms originaux (The Chaos Prophet, Shadow Sniper, etc.), l'isolation mémoire (Private Memory Context) et le support pour

316 lines • 13.4 kB
/** * ╔══════════════════════════════════════════════════════════════════════╗ * ║ OVERMIND BRIDGE — BridgeProxy (Base Client SDK Proxy) ║ * ║ ║ * ║ [SCRIPT DE BASE - CLIENT SDK LOW-LEVEL TRANSPORT] ║ * ║ Couche basse : transport JSON-RPC 2.0 vers Overmind MCP Server. ║ * ║ Gère : reconnexion, health checks, circuit breaker, SSE streaming.║ * ║ ║ * ║ ARCHITECTURE ║ * ║ ───────────── ║ * ║ OverBridgeService → BridgeProxy → HTTP [::1]:3099/mcp ║ * ║ → Overmind MCP Server ║ * ╚══════════════════════════════════════════════════════════════════════╝ */ import { DEFAULT_BRIDGE_CONFIG, DEFAULT_CIRCUIT_CONFIG } from './types.js'; import { createBridgeLogger, parseMcpResponseBody, parseSseText, withRetry } from './utils.js'; // ─── Circuit Breaker ─────────────────────────────────────────────────────── class CircuitBreaker { config; state = 'closed'; failureCount = 0; successCount = 0; lastFailureAt = 0; constructor(config) { this.config = config; } get currentState() { // Auto-transition open → half-open après resetTimeout if (this.state === 'open' && Date.now() - this.lastFailureAt > this.config.resetTimeoutMs) { this.state = 'half-open'; } return this.state; } recordSuccess() { if (this.state === 'half-open') { this.successCount++; if (this.successCount >= this.config.successThreshold) { this.state = 'closed'; this.failureCount = 0; this.successCount = 0; } } else { this.failureCount = 0; } } recordFailure() { this.failureCount++; this.lastFailureAt = Date.now(); this.successCount = 0; if (this.failureCount >= this.config.failureThreshold) { this.state = 'open'; } } canExecute() { return this.currentState !== 'open'; } /** * Force-reset the circuit breaker to 'closed' state. * Used by forceReconnect() after heartbeat failures. */ reset() { this.state = 'closed'; this.failureCount = 0; this.successCount = 0; } } // ─── BridgeProxy ─────────────────────────────────────────────────────────── export class BridgeProxy { rpcId = 1; config; circuit; log; constructor(config, circuitConfig, logger) { this.config = { ...DEFAULT_BRIDGE_CONFIG, ...config }; this.circuit = new CircuitBreaker({ ...DEFAULT_CIRCUIT_CONFIG, ...circuitConfig }); this.log = logger ?? createBridgeLogger('bridge-proxy'); } get mcpUrl() { return this.config.mcpUrl; } get agentTimeout() { return this.config.agentTimeoutMs; } get defaultMcpServers() { return this.config.defaultMcpServers; } get circuitState() { return this.circuit.currentState; } /** * Force a circuit breaker reset and clear cached state. * * Fix #3: Called by OverBridgeService heartbeat after consecutive failures. * This allows the next call to bypass the 'open' circuit and attempt * a fresh connection instead of waiting for resetTimeoutMs. */ forceReconnect() { this.circuit.reset(); this.log.info('🔌 Circuit breaker force-reset (reconnect attempt)'); } // ─── Core RPC Call ─────────────────────────────────────────────────────── /** * Appelle un tool MCP via JSON-RPC 2.0 sur HTTP. * * TIMEOUT STRATEGY (3 couches) : * 1. AbortController sur le fetch initial * 2. Body reader avec deadline absolue * 3. Promise.race per-chunk (sécurité si le serveur ne produit plus) * * @param toolName Nom de l'outil MCP (ex: 'run_agent', 'memory_search') * @param args Arguments de l'outil * @param timeoutMs Timeout en ms (default: config.defaultTimeoutMs) */ async call(toolName, args, timeoutMs) { const timeout = timeoutMs ?? this.config.defaultTimeoutMs; // Circuit breaker gate if (!this.circuit.canExecute()) { throw Object.assign(new Error(`Circuit breaker OPEN — too many failures. Retry after ${this.config.retryDelayMs}ms`), { code: 'ECIRCUITOPEN' }); } const request = { jsonrpc: '2.0', id: this.rpcId++, method: 'tools/call', params: { name: toolName, arguments: args }, }; this.log.info(`→ MCP ${toolName} (timeout: ${timeout / 1000}s)`); const startTime = Date.now(); try { const response = await this._fetch(request, timeout); const elapsed = Date.now() - startTime; this.circuit.recordSuccess(); this.log.info(`← MCP ${toolName} OK (${elapsed}ms)`); return response; } catch (err) { const error = err; this.circuit.recordFailure(); // Erreurs transitoires → retry automatique const retryable = ['ETIMEDOUT', 'EBODYREAD', 'ECONNRESET', 'ECONNREFUSED']; if (retryable.includes(error?.code ?? '')) { this.log.warn(`🔁 ${error.code} on ${toolName}, retrying...`); return withRetry(() => this._fetch(request, timeout), { maxAttempts: this.config.maxRetries, delayMs: this.config.retryDelayMs, }, this.log); } throw err; } } // ─── JSON-RPC Protocol Ping ────────────────────────────────────────────── /** * Envoie un ping HTTP au serveur MCP. * Utilise un simple GET /health (plus léger que le handshake JSON-RPC complet). * Fallback: POST /mcp avec Accept SSE (si /health n'existe pas). */ async ping(timeoutMs) { const timeout = timeoutMs ?? 5_000; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { // Strategy: try GET /health first (lightweight, FastMCP exposes it) const healthUrl = this.config.mcpUrl.replace(/\/mcp\/?$/, '/health'); let response = await fetch(healthUrl, { method: 'GET', signal: controller.signal, }); if (response.ok) { clearTimeout(timer); return true; } // Fallback: POST /mcp with SSE Accept (real MCP ping) const request = { jsonrpc: '2.0', id: this.rpcId++, method: 'ping', params: {}, }; response = await fetch(this.config.mcpUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', }, body: JSON.stringify(request), signal: controller.signal, }); clearTimeout(timer); if (!response.ok) return false; const bodyText = await response.text(); // Accept both SSE and plain JSON const sse = parseSseText(bodyText); if (sse) return !sse.error; const parsed = JSON.parse(bodyText.trim()); return parsed && parsed.jsonrpc === '2.0' && !parsed.error; } catch { clearTimeout(timer); return false; } } // ─── Health Check ──────────────────────────────────────────────────────── async healthCheck() { const startTime = Date.now(); const isOnline = await this.ping(5_000); if (isOnline) { return { status: 'online', mcpUrl: this.config.mcpUrl, circuitState: this.circuit.currentState, latencyMs: Date.now() - startTime, checkedAt: Date.now(), }; } else { return { status: this.circuit.currentState === 'open' ? 'offline' : 'degraded', mcpUrl: this.config.mcpUrl, circuitState: this.circuit.currentState, checkedAt: Date.now(), }; } } // ─── Internal Fetch ────────────────────────────────────────────────────── async _fetch(request, timeoutMs) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); let response; try { response = await fetch(this.config.mcpUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', }, body: JSON.stringify(request), signal: controller.signal, }); } catch (err) { clearTimeout(timer); const error = err; if (error.name === 'TimeoutError' || error.type === 'TimeoutError' || error.name === 'AbortError') { throw Object.assign(new Error(`TIMEOUT: ${request.params.name} no response after ${timeoutMs / 1000}s`), { code: 'ETIMEDOUT', toolName: request.params.name, timeoutMs }); } throw err; } finally { clearTimeout(timer); } if (!response.ok) { throw Object.assign(new Error(`MCP HTTP ${response.status}: ${response.statusText}`), { code: 'EHTTPSTATUS', status: response.status, }); } // Lecture du body avec timeout effectif (3 couches) const text = await this._readBody(response, timeoutMs); return parseMcpResponseBody(text); } /** * Lecture du body response avec 3 couches de timeout : * 1. Deadline absolue (startRead + timeoutMs) * 2. Promise.race per-chunk * 3. reader.cancel() sur timeout */ async _readBody(response, timeoutMs) { if (!response.body) { throw Object.assign(new Error('Response body is null'), { code: 'EBODYREAD' }); } const reader = response.body.getReader(); const decoder = new TextDecoder(); const chunks = []; const startRead = Date.now(); try { while (true) { // Couche 1 — deadline absolue if (Date.now() - startRead > timeoutMs) { reader.cancel(); throw Object.assign(new Error(`Body read timeout after ${timeoutMs / 1000}s`), { code: 'EBODYREAD', }); } // Couche 2+3 — Promise.race per-chunk (avec cleanup du timeout) let raceTimer; const { done, value } = await Promise.race([ reader.read(), new Promise((_, reject) => { raceTimer = setTimeout(() => reject(Object.assign(new Error(`Chunk read timeout after ${timeoutMs / 1000}s`), { code: 'EBODYREAD', })), timeoutMs); }), ]); if (raceTimer) { clearTimeout(raceTimer); } if (done) break; chunks.push(decoder.decode(value, { stream: true })); } chunks.push(decoder.decode()); return chunks.join(''); } catch (err) { const error = err; if (error?.code === 'EBODYREAD') throw err; throw Object.assign(new Error(`Body read failed: ${error.message}`), { code: 'EBODYREAD' }); } } } //# sourceMappingURL=BridgeProxy.js.map