From 0563887aeaf91cf1aeb9c857f0ea89f38ed1b8e5 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:05:42 +0200 Subject: [PATCH] bc-mcp-bridge: timeout + one-retry-with-fresh-session self-heal Neither fetch() call had a timeout, and the BC session (Mcp-Session-Id) plus the 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 today: a 30-minute hang on one call (no timeout meant it never failed on its own), and separately an "Internal_CompanyNotFound" on two machines that likely wasn't a real company problem but a dead session -- Michael noticed the pattern independently: Summatim's frequent quick calls never have this issue, longer sessions with gaps do. Added REQUEST_TIMEOUT_MS (30s) via AbortSignal.timeout on both fetch calls, and wrapped forward() to retry once with sessionId cleared on any failure -- self-heals a stale session/connection without the developer noticing. Tested end to end: full initialize + tools/call succeeds normally (no retry needed), and a forced-failure path correctly retries once then surfaces the real error if it persists. --- custom/setup/bc-mcp-bridge.js | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) 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 = [];