business-central-mcp
v1.7.0
Published
MCP server for Microsoft Dynamics 365 Business Central -- speaks BC's native WebSocket protocol directly
Maintainers
Readme
Overview
| Property | Value |
|----------|-------|
| Language | TypeScript / Node 20+ |
| npm package | business-central-mcp |
| BC versions | BC27, BC28 (wire-compatible) |
| Auth | On-prem NavUserPassword. BC Online: ESTS cookie session for /csh (no password in env) + device-code for bc_query. |
| Tools | 12 |
| Tests | 901 unit/protocol + 111 integration |
| License | MIT |
Install
BC Online (sandbox / production): do not put a password in env. Copy the portal URL from your browser and follow SaaS sandbox setup. The snippets below are for on-prem NavUserPassword.
VSCode
Click the badge. VSCode opens and prompts for your BC URL, username, and password (on-prem), then writes the configured entry to your user mcp.json. For BC Online, skip the badge and use the SaaS sandbox env (URL + optional email only).
Workspace: create .vscode/mcp.json:
{
"servers": {
"business-central": {
"command": "npx",
"args": ["-y", "business-central-mcp"],
"env": {
"BC_BASE_URL": "http://your-bc-server/BC",
"BC_USERNAME": "your-user",
"BC_PASSWORD": "your-password"
}
}
}
}BC Online — same file, no password:
{
"servers": {
"business-central": {
"command": "npx",
"args": ["-y", "business-central-mcp"],
"env": {
"BC_BASE_URL": "https://businesscentral.dynamics.com/<aad-tenant-id>/DEV",
"BC_USERNAME": "[email protected]"
}
}
}
}Claude Code
claude mcp add business-central \
-e BC_BASE_URL=http://your-bc-server/BC \
-e BC_USERNAME=you \
-e BC_PASSWORD=secret \
-- npx -y business-central-mcpScope it to the current project with --scope project. See claude mcp --help for scoping options.
BC Online (no password):
claude mcp add business-central \
-e BC_BASE_URL=https://businesscentral.dynamics.com/<aad-tenant-id>/DEV \
-e [email protected] \
--scope project \
-- npx -y business-central-mcpClaude Desktop
- Download the latest
.dxtfrom Releases. - Double-click. Claude Desktop opens Settings → Extensions and prompts for BC URL, username, and password (on-prem). For BC Online, use the manual snippet instead — do not store a SaaS password.
- Restart Claude Desktop.
Edit claude_desktop_config.json:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"business-central": {
"command": "npx",
"args": ["-y", "business-central-mcp"],
"env": {
"BC_BASE_URL": "http://your-bc-server/BC",
"BC_USERNAME": "your-user",
"BC_PASSWORD": "your-password"
}
}
}
}Restart Claude Desktop.
Configuration
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| BC_BASE_URL | Yes | — | BC server base URL, e.g. http://your-bc-server/BC, or a SaaS portal URL https://businesscentral.dynamics.com/{aadTenant}/{environment} |
| BC_USERNAME | NavUserPassword | — | On-prem username. On SaaS this is only an email prefill for the local sign-in window. |
| BC_PASSWORD | NavUserPassword | — | On-prem password. Ignored on SaaS (never put a SaaS password in env). |
| BC_AUTH | No | auto | auto (SaaS URL → SaasWeb, otherwise NavUserPassword), OAuth, SaasWeb, or NavUserPassword |
| BC_AAD_TENANT_ID | OAuth (if not in URL) | — | Entra tenant GUID. Taken from a SaaS BC_BASE_URL when present |
| BC_ENVIRONMENT | No | from URL | SaaS environment name (DEV, sandbox, production) |
| BC_CLIENT_ID | bc_query on SaaS | — | Multi-tenant public Entra app for device-code sign-in (see bc_query on SaaS). UI tools do not need it |
| BC_OAUTH_SCOPE | No | user_impersonation + offline_access | Override the Entra scope for bc_query device-code |
| BC_PROFILE | No | server default | Profile id, e.g. BUSINESS MANAGER. Affects which Role Center loads and which pages Tell Me indexes. |
| BC_TENANT_ID | No | default | On-prem multi-tenant id. SaaS uses the Entra tenant from the URL. |
| BC_CLIENT_VERSION | No | 27.0.0.0 | Version reported to BC during session open. |
| BC_APPLICATION_ID | No | FIN | navigationContext.applicationId sent at session open. SaaS and cronus images expect FIN; some on-prem containers expect NAV (see below). |
| PORT | No | 3000 | HTTP transport port (stdio transport ignores this). |
| LOG_LEVEL | No | info | debug / info / warn / error. |
| LOG_DIR | No | ./logs | Directory for log files. |
| STATE_DIR | No | {cwd}/.state | Per-repo directory for saas-web-cookies.json and oauth-tokens.json (mode 0600). Relative paths resolve against the MCP process working directory (the project you started the agent in). Sessions in the same repo share the file; different repos never share a login. |
| BC_INVOKE_TIMEOUT | No | 30000 | Per-invoke timeout in ms. Kills hung sessions. |
| BC_RECONNECT_MAX_RETRIES | No | 4 | Reconnect attempts after session death. |
| BC_RECONNECT_BASE_DELAY | No | 1000 | Base delay (ms) for exponential reconnect backoff. |
Central connection config
Running several Claude Code sessions against several BC instances no longer
requires a full BC_* env block in every repo's .mcp.json. Register the
server once at user scope and define the connections in one file.
Register the server globally:
claude mcp add business-central -s user -- node U:/git/bc-mcp/node_modules/tsx/dist/cli.mjs U:/git/bc-mcp/src/stdio-server.tsCreate
~/.bc-mcp/config.jsonc(seeconfig.jsonc.example): a set of namedconnections, an optionaldefault, and an optionalmap[]from repo path to connection.Each session picks its connection, highest priority first:
- an explicit
BC_*env var (e.g.BC_BASE_URL) always wins for that field; BC_CONNECTION=<name>selects a named connection;- a
map[]entry whosepathmatches the session's working directory; - the
defaultconnection.
- an explicit
Keep secrets out of the file with ${ENV} references (expanded from the
process environment); on macOS/Linux the file should be mode 0600. SaaS
connections carry no password — sign in via the local window or
npx business-central-mcp login. With no config file present, the server runs
exactly as before from plain BC_* environment variables.
A <cwd>/.env (or the file at BC_ENV_FILE) is also auto-loaded at startup,
before connection resolution — real environment variables set outside the
file still win (override:false).
On-prem containers: set BC_APPLICATION_ID=NAV
If sign-in and the WebSocket upgrade both succeed but the session dies at OpenSession with
NavCancelCredentialPromptException, the server is rejecting the default applicationId (FIN).
On-prem BcContainerHelper containers (the onprem artifact type) generally expect NAV:
BC_APPLICATION_ID=NAVThe failure is misleading because authentication and the /csh upgrade complete first (you get a
101); BC only rejects the applicationId inside the OpenSession RPC body. SaaS and cronus images
keep the FIN default. Verified against BC 27.1 onprem (see issue #10).
SaaS sandbox setup
You only need the URL from the browser address bar — the same one you use to open Business Central Online:
https://businesscentral.dynamics.com/<aad-tenant-id>/<environment><environment> is usually DEV, sandbox, or production. Do not set BC_PASSWORD. Company policy and this server both treat a SaaS password in env as wrong.
- Copy that portal URL into
BC_BASE_URL(no extra path, no query string). - Optionally set
BC_USERNAMEto your work email — that only prefills the sign-in form. - Point the MCP at this project (stdio, Grok
.grok/config.toml, Claude--scope project, or a workspacemcp.json). LeaveSTATE_DIRunset so cookies land in{project}/.state/. - Start the agent on a machine with a display (Linux needs
DISPLAYorWAYLAND_DISPLAY). Headless CI cannot complete MFA. - Ask the agent to open a page (
bc_open_page, e.g. Customer List = 22). A local window (127.0.0.1) opens. Sign in with Microsoft and complete Authenticator there. Do not paste the password into chat or tool arguments. - Retry the tool. Cookies are saved as
{project}/.state/saas-web-cookies.json(mode 0600). Later sessions in the same repo reuse them; another repo needs its own sign-in.
Human shortcut (same working directory as the MCP):
npx business-central-mcp login
# from a source checkout:
npx tsx src/stdio-server.ts loginGrok (project-scoped, no password):
# .grok/config.toml — not committed if it holds a tenant URL you do not want shared
[mcp_servers.business-central]
command = "npx"
args = ["-y", "business-central-mcp"]
[mcp_servers.business-central.env]
BC_BASE_URL = "https://businesscentral.dynamics.com/<aad-tenant-id>/DEV"
BC_USERNAME = "[email protected]"From a source checkout, point command / args at node + node_modules/tsx/dist/cli.mjs + src/stdio-server.ts instead of npx.
Claude Desktop / VS Code (no BC_PASSWORD):
{
"mcpServers": {
"business-central": {
"command": "npx",
"args": ["-y", "business-central-mcp"],
"env": {
"BC_BASE_URL": "https://businesscentral.dynamics.com/<aad-tenant-id>/DEV",
"BC_USERNAME": "[email protected]"
}
}
}
}The WebSocket is not on the portal host. After sign-in the server discovers the cluster and uses Origin: https://businesscentral.dynamics.com. You never put a cluster URL in config.
bc_query (OData) on SaaS
bc_query does not use the /csh cookie session. When sign-in is needed the first call returns DEVICE_LOGIN_REQUIRED with a https://microsoft.com/devicelogin URL and user code — complete it in a browser and retry; the retry picks up the pending sign-in and runs the query. The refresh token is stored in STATE_DIR/oauth-tokens.json (mode 0600).
bc_query talks to https://api.businesscentral.dynamics.com/v2.0/{tenant}/{environment}/api/v2.0 with the Bearer token. If BC_CLIENT_ID is not configured it returns OAUTH_NOT_CONFIGURED (device-code that has not been completed returns DEVICE_LOGIN_REQUIRED, above); it never sends Basic.
Which client id signs in (BC_CLIENT_ID)
BC_CLIENT_ID is required for bc_query on BC Online: a multi-tenant public Entra app with delegated Dynamics 365 Business Central / user_impersonation. The publisher registers it ONE time in their own tenant; customer tenants register nothing — each user consents at first sign-in (user_impersonation is user-consentable), and tenants that disable user consent need a one-time admin-consent click.
Do not borrow a Microsoft first-party client (Azure PowerShell 1950a258-… as New-BcAuthContext does, Azure CLI, …): on tenants with Entra first-party hardening the sign-in fails in the browser with AADSTS65002 ("consent between first party application and first party resource must be configured via preauthorization"), which no tenant admin can consent around. Verified live 2026-08-16 — the same sign-in succeeds on one tenant and fails with 65002 on another. A third-party multi-tenant app is structurally immune (65002 only gates Microsoft-owned client/resource pairs).
Create the app (once, in the publisher tenant):
az ad app create --display-name "business-central-mcp" \
--is-fallback-public-client true \
--sign-in-audience AzureADMultipleOrgs \
--required-resource-accesses '[{"resourceAppId":"996def3d-b36c-4153-8607-a6fd3c01b89f","resourceAccess":[{"id":"bce0976a-cb0b-473b-8800-84eda9f8e447","type":"Scope"}]}]' \
--query appId -o tsv(996def3d… is the Dynamics 365 Business Central resource; bce0976a… is its delegated user_impersonation scope.) Put the printed appId in BC_CLIENT_ID.
Known wart: when the browser sign-in fails (65002, blocked consent), Entra keeps the device code authorization_pending, so retries re-serve the same doomed code until it expires (~15 min). Fix the client id / consent, wait out or ignore the old code, and retry for a fresh one.
What can it do?
| Tool | What it does |
|---|---|
| bc_open_page | Open any page by ID -- lists, cards, documents, role centers. Returns the page as sections[] with header, lines, factboxes, and Role Center cuegroup tiles. |
| bc_read_data | Refresh a single section: filter, paginate, slice, project tab/columns. Returns the same Section shape as bc_open_page. |
| bc_write_data | Write field values; BC validates and echoes confirmed values. Section-aware (lines, factboxes, header). |
| bc_execute_action | Run header / row / wizard actions, OR drill down on Role Center cue tiles via cue input. |
| bc_respond_dialog | Handle confirmation prompts and request pages |
| bc_navigate | Select rows, drill down into records, field lookups |
| bc_search_pages | Tell Me search. Returns { name, objectType, runTarget, departmentPath, category, score } per result. |
| bc_close_page | Close a page and free server resources |
| bc_switch_company | Switch to a different company mid-session |
| bc_list_companies | Discover available companies |
| bc_run_report | Execute reports and fill request page parameters |
| bc_wizard_navigate | Drive NavigatePage / wizard flows (back / next / finish / cancel) |
How it works
This server speaks BC's internal WebSocket protocol directly -- the same protocol the browser-based web client uses. It was reverse-engineered from decompiled BC server assemblies. No OData endpoints, no SOAP services, no Selenium.
One WebSocket connection per session. All operations serialized through a promise queue. BC27 and BC28 are wire-compatible.
LLM (Claude / Copilot / etc.)
|
v MCP (stdio or HTTP)
business-central-mcp
|
v WebSocket + JSON-RPC
BC Web Service Tier (BC27 / BC28)
|
v internal calls
BC Serverbc_open_page returns the page as a flat list of sections:
{
"pageContextId": "session:page:21:abc",
"pageType": "Card",
"caption": "Customer Card",
"isModal": false,
"sections": [
{ "sectionId": "header", "kind": "header", "fields": [...], "actions": [...] },
{ "sectionId": "factbox:Customer Statistics", "kind": "factbox", "fields": [...] }
]
}Each section carries its own content shape:
- Card-style (
headeron Card pages,factbox,requestPage):fields[]and (forheader)actions[] - List-style (
lineson Documents,headeron List pages, repeater subpages):rows[]andtotalRowCount - Cue tiles (Role Center hosted CardParts):
cues[]with each tile'sname,value,groupCaption,synopsis,hasAction. Drill down withbc_execute_action { section, cue }.
bc_read_data returns a single Section for the requested sectionId (defaults to "header"). The section ID for a FactBox or subpage comes from the bc_open_page response.
- Automatic reconnect with exponential backoff after session death
- Handles BC's ~15s NTLM auth slot hold after crashes
- Auto-dismisses license popups on fresh databases
- Invoke timeout kills hung sessions and triggers recovery
- Auto-recovery from
LogicalModalityViolationExceptionmid-session: reconciles the modal stack and retries transparently; falls back to session reset when BC keeps a confirm dialog sticky
Key files
| File | Purpose |
|------|---------|
| src/stdio-server.ts | npm bin entry -- stdio MCP transport |
| src/server.ts | HTTP MCP transport entry |
| src/mcp/ | MCP tool registry, schemas, request handler |
| src/operations/ | One handler per tool (bc_open_page, bc_read_data, etc.) |
| src/services/ | Page, data, action, navigation, search business logic |
| src/protocol/ | WebSocket transport, wire types, captures |
| src/session/ | Session lifecycle, modal stack, reconnect |
| manifest.json | Claude Desktop Extension manifest |
| scripts/build-dxt.ts | Builds .dxt artifact for Claude Desktop |
| .github/workflows/release.yml | Builds + attaches .dxt on v* tag pushes |
| ROADMAP.md | Deferred work (Cursor, init wizard) |
Development
git clone https://github.com/SShadowS/business-central-mcp
cd business-central-mcp
npm install
npm run start:stdio-direct # Run from source
npm test # unit + protocol tests
npm run test:integration # Cronus28 integration tests (requires running BC server)
npm run test:saas # BC Online smoke (needs a signed-in STATE_DIR cookie file)Roadmap
Cursor support, an interactive init wizard, and a few protocol gaps.
See ROADMAP.md for the full list and priorities.
Author: Torben Leth ([email protected]) License: MIT (see LICENSE)
