bcquality/custom/setup/bc-mcp-bridge.js
Michael Dieringer 0563887aea 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.
2026-08-05 14:05:42 +02:00

207 lines
8.7 KiB
JavaScript

#!/usr/bin/env node
// bc-mcp-bridge.js
// Lokal stdio <-> streamable-HTTP bro mellem Claude Code og BC MCP-serveren.
// S2S-auth (client credentials) - ingen bruger-login. Broen henter + fornyer token selv,
// og injicerer routing-headers. Claude Code taler stdio til broen (intet DCR/OAuth-problem).
//
// Config: Scripts/bc-mcp.config.json (GITIGNORED) eller env-vars:
// BC_MCP_TENANT, BC_MCP_CLIENT_ID, BC_MCP_CLIENT_SECRET,
// BC_MCP_ENVIRONMENT (default Production), BC_MCP_COMPANY, BC_MCP_CONFIG (default CURABIS_DEV)
//
// .mcp.json:
// "businesscentral": { "command": "node", "args": ["Scripts/bc-mcp-bridge.js"] }
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); }
function loadConfig() {
let c = {};
// Soeger: 1) repo-lokal Scripts/bc-mcp.config.json 2) pr-maskine ~/.bc-mcp.config.json
const home = process.env.USERPROFILE || process.env.HOME || "";
for (const f of [path.join(__dirname, "bc-mcp.config.json"), path.join(home, ".bc-mcp.config.json")]) {
if (fs.existsSync(f)) {
try { c = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch (e) { die(`Kan ikke laese ${f}: ${e}`); }
}
}
const cfg = {
tenant: process.env.BC_MCP_TENANT || c.tenant,
clientId: process.env.BC_MCP_CLIENT_ID || c.clientId,
clientSecret: process.env.BC_MCP_CLIENT_SECRET || c.clientSecret,
environment: process.env.BC_MCP_ENVIRONMENT || c.environment || "Production",
company: process.env.BC_MCP_COMPANY || c.company,
config: process.env.BC_MCP_CONFIG || c.configurationName || "CURABIS_DEV",
};
for (const k of ["tenant", "clientId", "clientSecret", "company"]) {
if (!cfg[k]) die(`Mangler config '${k}' - saet i Scripts/bc-mcp.config.json eller env-var.`);
}
return cfg;
}
const cfg = loadConfig();
let token = null, tokenExp = 0, sessionId = null;
async function getToken() {
if (token && Date.now() < tokenExp - 60000) return token; // forny 1 min foer udloeb
const body = new URLSearchParams({
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
scope: `${ENDPOINT}/.default`,
grant_type: "client_credentials",
});
const r = await fetch(`https://login.microsoftonline.com/${cfg.tenant}/oauth2/v2.0/token`, {
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();
token = j.access_token;
tokenExp = Date.now() + (j.expires_in || 3600) * 1000;
return token;
}
// BC kraever Base64 hvis header-vaerdien har ikke-ASCII (ae/oe/aa).
const enc = v => /[^\x00-\x7F]/.test(v) ? `=?base64?${Buffer.from(v, "utf8").toString("base64")}?=` : v;
function parseSSE(text) {
const msgs = [];
for (const block of text.split(/\r?\n\r?\n/)) {
const data = block.split(/\r?\n/).filter(l => l.startsWith("data:")).map(l => l.slice(5).replace(/^ /, ""));
if (data.length) { const p = data.join("\n").trim(); if (p && p !== "[DONE]") msgs.push(p); }
}
return msgs;
}
async function forwardOnce(msg) {
const tok = await getToken();
const headers = {
"Authorization": `Bearer ${tok}`,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"TenantId": cfg.tenant,
"EnvironmentName": cfg.environment,
"Company": enc(cfg.company),
"ConfigurationName": enc(cfg.config),
};
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
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();
// 2026-07-31: BC has returned error bodies as plain JSON while still labelling
// content-type text/event-stream (e.g. "company not found"). parseSSE only
// extracts lines starting with "data:" - a plain JSON error body has none, so
// it silently returned []. The stdin loop then wrote nothing at all, and the
// client (Claude Code) waited out its own 30s timeout instead of seeing the
// real error immediately. Check !r.ok BEFORE any SSE parsing, unconditionally -
// never let a non-2xx response fall through to parseSSE.
if (!r.ok) throw new Error(`HTTP ${r.status}: ${text || "(empty body)"}`);
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 = [];
while (text.length > maxLen) {
let cut = text.lastIndexOf(" ", maxLen);
if (cut <= 0) cut = maxLen; // no space found — hard cut
chunks.push(text.slice(0, cut).trimEnd());
text = text.slice(cut).trimStart();
}
if (text) chunks.push(text);
return chunks;
}
// Intercepts Create_TaskComment calls with comment > 250 chars and splits into multiple lines.
// Each chunk is sent in its own fresh BC session so GetNextLineNo sees previously committed records.
async function dispatchCreateComment(msg) {
const args = (msg.params && msg.params.arguments) || {};
const comment = args.comment || "";
const chunks = splitTextToChunks(comment);
let tempId = Date.now();
const savedSessionId = sessionId; // preserve the main conversation session
for (const chunk of chunks) {
sessionId = null; // fresh session per chunk → independent BC transaction
const chunkMsg = {
...msg,
id: tempId++,
params: { ...msg.params, arguments: { ...args, comment: chunk } },
};
await forward(chunkMsg);
sessionId = null; // discard the chunk session — never bleed into next chunk
}
sessionId = savedSessionId; // restore main session for subsequent calls
return [JSON.stringify({
jsonrpc: "2.0", id: msg.id,
result: { content: [{ type: "text", text: `Kommentar gemt i ${chunks.length} linje(r).` }] },
})];
}
// stdio-loop: newline-delimited JSON-RPC (MCP stdio-transport).
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", async (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; }
try {
const isCreateComment =
msg.method === "tools/call" &&
msg.params?.name === "Create_TaskComment_PAG6102902" &&
(msg.params?.arguments?.comment || "").length > 250;
const responses = isCreateComment
? await dispatchCreateComment(msg)
: await forward(msg);
for (const out of responses) process.stdout.write(out + "\n");
} catch (e) {
process.stderr.write(`[bc-mcp-bridge] ${e.message || e}\n`);
if (msg.id !== undefined && msg.id !== null) {
process.stdout.write(JSON.stringify({
jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: String(e.message || e) },
}) + "\n");
}
}
}
});
process.stdin.on("end", () => process.exit(0));