mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Live incident i aften: businesscentral MCP fejlede med et generisk 30-sekunders "connection timed out", ingen brugbar fejl. To reelle, adskilte fejl fundet ved at teste direkte mod BC's endpoint: 1. ~/.bc-mcp.config.json havde "company": "CURABIS ApS" (Vist navn), men BC's faktiske Navn-felt er "Curabis ApS". BC svarede korrekt og hurtigt (400, under 200ms) - problemet var aldrig BC. 2. bc-mcp-bridge.js svaelgede det svar stille: en fejl-krop formateret som almindelig JSON, men markeret content-type text/event-stream, blev sendt til parseSSE() som kun leder efter "data:"-linjer - fandt ingen, returnerede en tom liste. Broen skrev derfor INGENTING, hverken stdout eller stderr, og Claude Code ventede blot sin egen 30-sekunders timeout ud. Rettet: - forward() tjekker nu !r.ok FOER content-type-forgrening, ubetinget - en fejlrespons naar aldrig parseSSE, uanset hvad serveren paastaar om sin egen content-type. Testet direkte mod det reproducerede scenarie: fejlen vises nu med det samme (5s test-vindue, ikke 30s timeout), med det fulde BC-fejlsvar synligt i baade stdout (JSON-RPC error) og stderr. - bc-mcp.config.template.json matchede slet ikke broens faktiske felter (tenantId/baseUrl vs. broens tenant/company/configurationName) - enhver ny udvikler der udfyldte skabelonen efter dens egne feltnavne ville faa en config der intet virkede med. Rettet til de rigtige feltnavne, plus en eksplicit advarsel om Navn vs. Vist navn i company-feltet. - Mode A's opsaetningsbesked (Step 3b) opdateret til at naevne alle placeholder-felter, ikke kun secret'en. - To nye BCQuality-videnfiler dokumenterer begge fejl til fremtidig fejlsoegning. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
175 lines
7.2 KiB
JavaScript
175 lines
7.2 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";
|
|
|
|
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,
|
|
});
|
|
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 forward(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) });
|
|
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()] : []);
|
|
}
|
|
|
|
// 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));
|