bc-mcp-bridge: reactive + proactive session self-heal

Two fixes bundled together, both diagnosed from a live BC MCP failure in
a Jernpladsen (Project Management 365) session:

- 2026-08-05 (previously drafted, never committed): BC rejects a
  non-initialize request with a stale/absent Mcp-Session-Id with "A new
  session can only be created by an initialize request." The prior retry
  just cleared sessionId and resent the same message with no header,
  reproducing the identical error. Now replays the client's cached
  initialize message first to obtain a fresh session, then retries.
  This also fixes dispatchCreateComment's per-chunk fresh-session reset,
  which was hitting exactly this failure on every chunk of a >250-char
  Task Comment.

- 2026-08-07: the reactive fix above only self-heals after BC has
  already rejected a request. A client that opens a fresh session per
  call (e.g. a Power Automate flow) never hits staleness at all, which
  is why that pattern reads as more reliable than our long-lived
  interactive bridge process. Added a proactive refresh: if the cached
  session has been idle past a heuristic threshold, re-initialize
  before attempting the call instead of waiting for the rejection.
This commit is contained in:
Michael Dieringer 2026-08-07 08:34:20 +02:00
parent b79b90c4ec
commit 386729f2ef

View file

@ -50,7 +50,18 @@ function loadConfig() {
}
const cfg = loadConfig();
let token = null, tokenExp = 0, sessionId = null;
let token = null, tokenExp = 0, sessionId = null, lastInitializeMsg = null, lastActivityAt = 0;
// 2026-08-07: the reactive re-initialize below only fires after BC has already
// rejected a request. It self-heals invisibly to the caller, but still burns
// two round trips (reject, initialize, retry) on the first call after any idle
// gap. A client that opens a fresh session per call (e.g. a Power Automate
// flow) never hits this at all, which is why it reads as more stable than a
// long-lived interactive session that caches sessionId for its whole
// lifetime. Proactively refreshing before a call that has been idle a while
// gets the same effective robustness without waiting for BC to reject first.
// No API exposes the session's actual TTL, so this threshold is a heuristic,
// not a documented guarantee.
const SESSION_IDLE_THRESHOLD_MS = 3 * 60 * 1000; // 3 min
async function getToken() {
if (token && Date.now() < tokenExp - 60000) return token; // forny 1 min foer udloeb
@ -118,8 +129,23 @@ async function forwardOnce(msg) {
}
async function forward(msg) {
if (msg.method === "initialize") lastInitializeMsg = msg;
if (sessionId && msg.method !== "initialize" && lastActivityAt &&
Date.now() - lastActivityAt > SESSION_IDLE_THRESHOLD_MS) {
process.stderr.write(`[bc-mcp-bridge] session idle ${Math.round((Date.now() - lastActivityAt) / 1000)}s, refreshing proactively\n`);
sessionId = null;
if (lastInitializeMsg) {
try {
await forwardOnce(lastInitializeMsg);
} catch (initErr) {
process.stderr.write(`[bc-mcp-bridge] proactive re-initialize failed: ${initErr.message || initErr}\n`);
}
}
}
try {
return await forwardOnce(msg);
const result = await forwardOnce(msg);
lastActivityAt = Date.now();
return result;
} 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
@ -127,11 +153,30 @@ async function forward(msg) {
// 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.
// between calls does.
process.stderr.write(`[bc-mcp-bridge] retrying after: ${e.message || e}\n`);
sessionId = null;
return await forwardOnce(msg);
// 2026-08-05: clearing sessionId is not enough on its own. BC's own error
// is explicit -- "A new session can only be created by an initialize
// request" -- so simply resending the original non-initialize message
// with no session header reproduces the exact same error, not a fresh
// session. Replay the client's actual initialize message first (cached
// above, the only thing BC will accept to hand out a new session), THEN
// retry the original call with the session that establishes. If there's
// no cached initialize yet (this failure IS the very first call, or is
// itself the initialize), skip straight to the plain retry below.
if (msg.method !== "initialize" && lastInitializeMsg) {
try {
await forwardOnce(lastInitializeMsg);
} catch (initErr) {
process.stderr.write(`[bc-mcp-bridge] re-initialize failed: ${initErr.message || initErr}\n`);
// Fall through anyway -- retrying the original message will still
// surface a clear, real error if the session truly can't be restored.
}
}
const result = await forwardOnce(msg);
lastActivityAt = Date.now();
return result;
}
}