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
JavaScript
/**
* ╔══════════════════════════════════════════════════════════════════════╗
* ║ 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