mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
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.
This commit is contained in:
parent
ea5ca2c3fd
commit
0563887aea
1 changed files with 34 additions and 2 deletions
|
|
@ -15,6 +15,14 @@ const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const ENDPOINT = "https://mcp.businesscentral.dynamics.com";
|
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 die(m) { process.stderr.write(`[bc-mcp-bridge] ${m}\n`); process.exit(1); }
|
||||||
|
|
||||||
|
|
@ -56,6 +64,7 @@ async function getToken() {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body,
|
body,
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!r.ok) throw new Error(`token ${r.status}: ${await r.text()}`);
|
if (!r.ok) throw new Error(`token ${r.status}: ${await r.text()}`);
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
|
|
@ -76,7 +85,7 @@ function parseSSE(text) {
|
||||||
return msgs;
|
return msgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forward(msg) {
|
async function forwardOnce(msg) {
|
||||||
const tok = await getToken();
|
const tok = await getToken();
|
||||||
const headers = {
|
const headers = {
|
||||||
"Authorization": `Bearer ${tok}`,
|
"Authorization": `Bearer ${tok}`,
|
||||||
|
|
@ -88,7 +97,12 @@ async function forward(msg) {
|
||||||
"ConfigurationName": enc(cfg.config),
|
"ConfigurationName": enc(cfg.config),
|
||||||
};
|
};
|
||||||
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
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 sid = r.headers.get("mcp-session-id"); if (sid) sessionId = sid;
|
||||||
const ct = r.headers.get("content-type") || "";
|
const ct = r.headers.get("content-type") || "";
|
||||||
const text = await r.text();
|
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()] : []);
|
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.
|
// Splits text into chunks of max maxLen chars, breaking at word boundaries.
|
||||||
function splitTextToChunks(text, maxLen = 250) {
|
function splitTextToChunks(text, maxLen = 250) {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue