diff --git a/custom/setup/bc-mcp-bridge.js b/custom/setup/bc-mcp-bridge.js index cc995da..4382f5d 100644 --- a/custom/setup/bc-mcp-bridge.js +++ b/custom/setup/bc-mcp-bridge.js @@ -15,6 +15,14 @@ const fs = require("fs"); const path = require("path"); const ENDPOINT = "https://mcp.businesscentral.dynamics.com"; +// 2026-08-04: neither fetch() call below had a timeout, and the BC session +// (sessionId) + underlying HTTP connection were reused for the bridge +// process's entire lifetime with no proactive refresh. A long-running Claude +// Code session with gaps between calls can leave either stale -- observed as +// a 30-minute hang on one call, and separately as an "Internal_CompanyNotFound" +// that wasn't actually a company problem. REQUEST_TIMEOUT_MS + the one-retry +// wrapper below fail fast and self-heal with a forced-fresh session instead. +const REQUEST_TIMEOUT_MS = 30000; function die(m) { process.stderr.write(`[bc-mcp-bridge] ${m}\n`); process.exit(1); } @@ -56,6 +64,7 @@ async function getToken() { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!r.ok) throw new Error(`token ${r.status}: ${await r.text()}`); const j = await r.json(); @@ -76,7 +85,7 @@ function parseSSE(text) { return msgs; } -async function forward(msg) { +async function forwardOnce(msg) { const tok = await getToken(); const headers = { "Authorization": `Bearer ${tok}`, @@ -88,7 +97,12 @@ async function forward(msg) { "ConfigurationName": enc(cfg.config), }; if (sessionId) headers["Mcp-Session-Id"] = sessionId; - const r = await fetch(ENDPOINT, { method: "POST", headers, body: JSON.stringify(msg) }); + const r = await fetch(ENDPOINT, { + method: "POST", + headers, + body: JSON.stringify(msg), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); const sid = r.headers.get("mcp-session-id"); if (sid) sessionId = sid; const ct = r.headers.get("content-type") || ""; const text = await r.text(); @@ -103,6 +117,24 @@ async function forward(msg) { return ct.includes("text/event-stream") ? parseSSE(text) : (text.trim() ? [text.trim()] : []); } +async function forward(msg) { + try { + return await forwardOnce(msg); + } catch (e) { + // 2026-08-04: one retry with a forced-fresh BC session (sessionId cleared). + // A long-idle Claude Code session can leave the cached Mcp-Session-Id or + // the underlying HTTP connection stale -- observed as a request hanging + // until REQUEST_TIMEOUT_MS, or as an "Internal_CompanyNotFound" that was + // actually a dead session, not a real company problem. Quick, frequent + // calls never idle long enough to hit this; a long session with gaps + // between calls does. One retry self-heals without the developer noticing; + // if the retry also fails, this is a real error and should surface as one. + process.stderr.write(`[bc-mcp-bridge] retrying after: ${e.message || e}\n`); + sessionId = null; + return await forwardOnce(msg); + } +} + // Splits text into chunks of max maxLen chars, breaking at word boundaries. function splitTextToChunks(text, maxLen = 250) { const chunks = [];