#!/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(); if (!r.ok && !text) throw new Error(`HTTP ${r.status}`); 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));