src/mcp-stdio-bridge.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
// stdio↔HTTP bridge for MCP (Streamable HTTP transport, maintains session ID)
// Env: SHRAGA_URL + SHRAGA_API_KEY (preferred), or generic MCP_URL + MCP_API_KEY, or legacy UNCLAW_*
// MCP_EXTRA_HEADERS — optional JSON object of extra headers sent with every request,
// for servers that need a second credential beyond the bearer token (e.g. an
// upstream app behind a proxy that gates its own admin ops on its own header).
// MCP_BRIDGE_TIMEOUT_MS — per-request bound for protocol calls (initialize, tools/list…), default 60s.
// MCP_BRIDGE_TOOL_TIMEOUT_MS — bound for tools/call, default 30min (a sync post_chat turn has no
// server-side wall clock). Without a bound, a frozen server hung the client forever on "connecting…".
const envMs = (v: string | undefined, fallback: number) => (Number(v) > 0 ? Number(v) : fallback);
const REQUEST_TIMEOUT_MS = envMs(process.env.MCP_BRIDGE_TIMEOUT_MS, 60_000);
const TOOL_TIMEOUT_MS = envMs(process.env.MCP_BRIDGE_TOOL_TIMEOUT_MS, 30 * 60_000);
const baseUrl = (process.env.SHRAGA_URL || process.env.MCP_URL || process.env.UNCLAW_URL || 'http://localhost:3033').replace(/\/$/, '');
const mcpPath = process.env.SHRAGA_MCP_PATH || process.env.MCP_PATH || '/mcp';
const apiKey = process.env.SHRAGA_API_KEY || process.env.MCP_API_KEY || process.env.UNCLAW_API_KEY;
if (!apiKey) { console.error('[mcp-bridge] SHRAGA_API_KEY (or MCP_API_KEY/UNCLAW_API_KEY) is required'); process.exit(1); }
let extraHeaders: Record<string, string> = {};
if (process.env.MCP_EXTRA_HEADERS) {
try {
const parsed = JSON.parse(process.env.MCP_EXTRA_HEADERS);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('must be a JSON object');
extraHeaders = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
} catch (e: any) {
// Fail loudly: silently dropping a credential header would surface far away as a 403.
console.error(`[mcp-bridge] MCP_EXTRA_HEADERS is not a valid JSON object: ${e.message}`);
process.exit(1);
}
}
let sessionId: string | null = null;
async function sendMessage(message: any): Promise<void> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'Accept': 'application/json, text/event-stream',
};
if (sessionId) headers['mcp-session-id'] = sessionId;
Object.assign(headers, extraHeaders);
const limit = message?.method === 'tools/call' ? TOOL_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
// The signal also bounds the body read below, not just the headers.
const signal = AbortSignal.timeout(limit);
let res: Response;
let text: string;
try {
res = await fetch(`${baseUrl}${mcpPath}`, {
method: 'POST',
headers,
body: JSON.stringify(message),
signal,
});
text = res.status === 204 ? '' : await res.text();
} catch (e: any) {
if (signal.aborted) throw new Error(`no response from ${baseUrl} for ${message?.method ?? 'message'} within ${limit}ms`);
throw e;
}
// Capture session ID from server on initialize
const newSession = res.headers.get('mcp-session-id');
if (newSession) sessionId = newSession;
if (res.status === 204) return;
// A non-2xx (401 on a stale key, 404 on a wrong MCP_PATH, 502 from a proxy) has no JSON-RPC
// body, so forwarding nothing left the client waiting on a reply that never came — it surfaced
// as "connection timed out after 30000ms", which reads as an unreachable server rather than a
// refused credential. Raising the HTTP status as a JSON-RPC error names the real cause. This
// cost a full investigation on 2026-09-18 after a key rotation missed one config file.
if (!res.ok && message?.id !== undefined && message?.id !== null) {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: message.id,
error: { code: -32603, message: `Bridge: ${baseUrl}${mcpPath} returned HTTP ${res.status} for ${message?.method ?? 'message'}${res.status === 401 ? ' — check MCP_API_KEY' : ''}` },
}) + '\n');
return;
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('text/event-stream')) {
// SSE: parse and forward each data line
for (const line of text.split('\n')) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data && data !== '[DONE]') {
process.stdout.write(data + '\n');
}
}
}
} else if (text) {
process.stdout.write(text + '\n');
}
}
import { createInterface } from 'node:readline';
const rl = createInterface({ input: process.stdin, terminal: false });
let buffer = '';
rl.on('line', async (line) => {
buffer += line;
let message: any;
try { message = JSON.parse(buffer); } catch { return; }
buffer = '';
try {
await sendMessage(message);
} catch (e: any) {
const err = {
jsonrpc: '2.0',
id: message?.id ?? null,
error: { code: -32603, message: `Bridge error: ${e.message}` },
};
process.stdout.write(JSON.stringify(err) + '\n');
}
});