Skip to main content
Glama
SShadowS

business-central-mcp

by SShadowS
README.md
<p align="center">
  <h1 align="center">business-central-mcp</h1>
  <p align="center">
    Give AI assistants direct access to Microsoft Dynamics 365 Business Central.<br/>
    Native WebSocket protocol -- no OData, no APIs, no browser automation.
  </p>
</p>

<p align="center">
  <a href="https://www.npmjs.com/package/business-central-mcp"><img src="https://img.shields.io/npm/v/business-central-mcp" alt="npm version"></a>
  <a href="https://www.npmjs.com/package/business-central-mcp"><img src="https://img.shields.io/npm/dm/business-central-mcp" alt="npm downloads"></a>
  <a href="https://github.com/SShadowS/business-central-mcp/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/business-central-mcp" alt="license"></a>
  <a href="vscode:mcp/install?%7B%22name%22%3A%22business-central%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22business-central-mcp%22%5D%2C%22inputs%22%3A%5B%7B%22id%22%3A%22bc_base_url%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22BC%20base%20URL%20%28e.g.%20http%3A%2F%2Fyour-bc-server%2FBC%29%22%7D%2C%7B%22id%22%3A%22bc_username%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22BC%20username%22%7D%2C%7B%22id%22%3A%22bc_password%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22BC%20password%22%2C%22password%22%3Atrue%7D%5D%2C%22env%22%3A%7B%22BC_BASE_URL%22%3A%22%24%7Binput%3Abc_base_url%7D%22%2C%22BC_USERNAME%22%3A%22%24%7Binput%3Abc_username%7D%22%2C%22BC_PASSWORD%22%3A%22%24%7Binput%3Abc_password%7D%22%7D%7D"><img src="https://img.shields.io/badge/VSCode-Install-007ACC?logo=visualstudiocode" alt="Install in VSCode"></a>
  <a href="https://github.com/SShadowS/business-central-mcp/releases/latest"><img src="https://img.shields.io/badge/Claude%20Desktop-Download%20.dxt-d97757" alt="Download .dxt for Claude Desktop"></a>
</p>

---

## Overview

| Property | Value |
|----------|-------|
| Language | TypeScript / Node 20+ |
| npm package | [`business-central-mcp`](https://www.npmjs.com/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 | 16 |
| Tests | 1117 unit/protocol + 164 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](#saas-sandbox-setup). The snippets below are for on-prem NavUserPassword.

### VSCode

[![Install in VSCode](https://img.shields.io/badge/VSCode-Install-007ACC?logo=visualstudiocode)](vscode:mcp/install?%7B%22name%22%3A%22business-central%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22business-central-mcp%22%5D%2C%22inputs%22%3A%5B%7B%22id%22%3A%22bc_base_url%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22BC%20base%20URL%20%28e.g.%20http%3A%2F%2Fyour-bc-server%2FBC%29%22%7D%2C%7B%22id%22%3A%22bc_username%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22BC%20username%22%7D%2C%7B%22id%22%3A%22bc_password%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22BC%20password%22%2C%22password%22%3Atrue%7D%5D%2C%22env%22%3A%7B%22BC_BASE_URL%22%3A%22%24%7Binput%3Abc_base_url%7D%22%2C%22BC_USERNAME%22%3A%22%24%7Binput%3Abc_username%7D%22%2C%22BC_PASSWORD%22%3A%22%24%7Binput%3Abc_password%7D%22%7D%7D)

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](#saas-sandbox-setup) env (URL + optional email only).

<details>
<summary><strong>Manual install</strong></summary>

Workspace: create `.vscode/mcp.json`:

```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:

```json
{
  "servers": {
    "business-central": {
      "command": "npx",
      "args": ["-y", "business-central-mcp"],
      "env": {
        "BC_BASE_URL": "https://businesscentral.dynamics.com/<aad-tenant-id>/DEV",
        "BC_USERNAME": "you@tenant.com"
      }
    }
  }
}
```

</details>

### Claude Code

```bash
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-mcp
```

Scope it to the current project with `--scope project`. See `claude mcp --help` for scoping options.

BC Online (no password):

```bash
claude mcp add business-central \
  -e BC_BASE_URL=https://businesscentral.dynamics.com/<aad-tenant-id>/DEV \
  -e BC_USERNAME=you@tenant.com \
  --scope project \
  -- npx -y business-central-mcp
```

### Claude Desktop

1. Download the latest `.dxt` from [Releases](https://github.com/SShadowS/business-central-mcp/releases/latest).
2. Double-click. Claude Desktop opens Settings → Extensions and prompts for BC URL, username, and password (on-prem). For BC Online, use the [manual snippet](#saas-sandbox-setup) instead — do not store a SaaS password.
3. Restart Claude Desktop.

<details>
<summary><strong>Manual install</strong></summary>

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`

```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.

</details>

## 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](#which-client-id-signs-in-bc_client_id)). 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 | detected | `navigationContext.applicationId` sent at session open. Unset: read from the server's web client config (`FIN` on SaaS and cronus images, `NAV` on on-prem containers), falling back to `FIN` (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), and the `operations/` replay ledger. 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_OPERATION_TTL` | No | `86400000` (24h) | How long (ms) a completed `operationId` stays replayable. After that the entry is deleted and the same id runs the call again. Must be > 0. |
| `BC_OPERATION_INDOUBT_TTL` | No | `2592000000` (30 days) | How long (ms) `sent`/`unknown` ledger entries (in-flight or in-doubt calls) are kept before they are deleted. Must be at least 24h and at least 2x `BC_INVOKE_MAX_DURATION`, so an in-flight call's entry is never pruned. |
| `BC_INVOKE_TIMEOUT` | No | `30000` | RPC timeout in ms for OpenSession and modal-recovery calls. |
| `BC_ISEXECUTING_POLL_INTERVAL` | No | `10000` | While a call runs, ask BC every N ms whether it is still processing. A long post or report stays alive as long as BC says yes; a hung call is killed once BC says no. |
| `BC_INVOKE_MAX_DURATION` | No | `600000` | Hard cap in ms for one call, even while BC is still processing. |
| `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.

1. Register the server globally:

   ```bash
   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.ts
   ```

2. Create `~/.bc-mcp/config.jsonc` (see `config.jsonc.example`): a set of named
   `connections`, an optional `default`, and an optional `map[]` from repo path
   to connection.

3. 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 whose `path` matches the session's working directory;
   - the `default` connection.

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: `applicationId` (`FIN` vs `NAV`)

`OpenSession` must carry the `applicationId` the server expects. SaaS and cronus images expect
`FIN`; on-prem BcContainerHelper containers (the `onprem` artifact type) generally expect `NAV`.
With `BC_APPLICATION_ID` unset, bc-mcp reads the value the web client would use from
`{BC_BASE_URL}/boot/browser/desktop?tenant=…` (served without sign-in) before the first session,
and falls back to `FIN` if that fails (logged as a warning).

If the session still dies at `OpenSession` with `NavCancelCredentialPromptException` although
sign-in and the WebSocket upgrade both succeeded, set it explicitly:

```
BC_APPLICATION_ID=NAV
```

The 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. 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.

1. Copy that portal URL into `BC_BASE_URL` (no extra path, no query string).
2. Optionally set `BC_USERNAME` to your work email — that only prefills the sign-in form.
3. Point the MCP at this project (stdio, Grok `.grok/config.toml`, Claude `--scope project`, or a workspace `mcp.json`). Leave `STATE_DIR` unset so cookies land in `{project}/.state/`.
4. Start the agent on a machine **with a display** (Linux needs `DISPLAY` or `WAYLAND_DISPLAY`). Headless CI cannot complete MFA.
5. 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.
6. 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):

```bash
npx business-central-mcp login
# from a source checkout:
npx tsx src/stdio-server.ts login
```

**Grok** (project-scoped, no password):

```toml
# .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 = "you@tenant.com"
```

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`):

```json
{
  "mcpServers": {
    "business-central": {
      "command": "npx",
      "args": ["-y", "business-central-mcp"],
      "env": {
        "BC_BASE_URL": "https://businesscentral.dynamics.com/<aad-tenant-id>/DEV",
        "BC_USERNAME": "you@tenant.com"
      }
    }
  }
}
```

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):

```bash
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 and drill down into records |
| `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) |
| `bc_lookup` | List the valid values for a lookup field (e.g. Salesperson Code) |
| `bc_query` | Bulk read-only OData queries against the BC Standard API v2.0 |
| `bc_upload_file` | Upload a local file into a BC file-upload dialog (e.g. Import Layout) |
| `bc_get_operation` | Look up an earlier `operationId` and, when its outcome is in doubt, read the record back |

Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`), so clients can auto-approve the read-only ones and ask before writes, actions, dialogs, reports and uploads.

The eight tools that change state accept an optional `operationId` (an idempotency key you generate). A retry with the same id and arguments returns the first call's stored result instead of running the change again, so retrying after a timeout or dropped connection cannot double-post. The same id with different arguments returns `OPERATION_ID_REUSED`. If the first call's outcome is unknown (timeout, session loss), the retry returns `OPERATION_IN_DOUBT`: check the record before acting under a new id. That response carries a `target` snapshot taken before the first call ran (company, page id and caption, section, the targeted rows' cell values, dialog), so you can find the record again even though the original `pageContextId` died with the session. Calls are recorded in `STATE_DIR/operations/`, so this survives a server restart.

Replay is time-limited. The server prunes the ledger at start and hourly: a completed entry is deleted after `BC_OPERATION_TTL` (default 24 hours), after which a retry with the same id runs the call again, so check with `bc_get_operation` before a late retry. `sent`/`unknown` entries are kept for `BC_OPERATION_INDOUBT_TTL` (default 30 days). Download bytes are not stored: a replayed result lists each download's `fileName`, `contentType`, `sizeBytes` and `savedPath` (set when `BC_DOWNLOAD_DIR` is configured), but not its `bytes`; each such entry carries `error.code: "NOT_REPLAYED"` so it is never mistaken for a successful capture.

Every result from those eight tools (and its ledger entry) carries `replaySafe`. `true` means BC's response proved that re-running the same call, even under a new `operationId` after an unknown outcome, cannot apply the change twice. `false` means a re-run may double-apply, or nothing in the response proved otherwise: read the record back first. The server only says `true` when it can prove it:

| Tool | `replaySafe: true` when |
|------|-------------------------|
| `bc_write_data` | BC echoed every written value (it echoes only a value that changed, and a write to a read-only field is silently dropped), and no dialog is pending |
| `bc_execute_action` | the action was Refresh, or BC closed the form the action ran on (a re-run on a closed form is rejected with `FormNotFoundException`) |
| `bc_respond_dialog` | BC closed the dialog form |
| `bc_wizard_navigate` | BC closed the wizard form (finish/cancel); next/back never |
| `bc_close_page` | no save/discard dialog blocked the close (closing a closed form is a no-op) |
| `bc_switch_company` | the session ends on the requested company (a switch to the current company is a no-op that keeps open pages) |
| `bc_run_report`, `bc_upload_file` | never: a re-run reruns the report or the import |

An `OPERATION_IN_DOUBT` response is always `replaySafe: false`, and so is the first error response to an ambiguous failure (`TIMEOUT_ERROR`, `SESSION_LOST`, `CONNECTION_ERROR`, `PROTOCOL_ERROR`, `ABORTED_ERROR`, or an unclassified error), with or without an `operationId`: it is in `structuredContent` next to the error code. Definite failures (validation, business errors) carry no verdict, because nothing was applied.

`bc_get_operation { operationId }` (read-only) returns the ledger entry: status, tool, timestamps, `replaySafe`, the stored result and the `target` snapshot. When the status is not `completed` or `replaySafe` is false, it also re-reads the target in the current session: it re-opens the page by page id on the recorded record bookmark, reads the record's primary-key fields (by table field number, from the page's own key metadata; a key field with no control on the page, such as a Sales Order's "Document Type", is decoded from the record bookmark and marked `source: "bookmark"`, with an option as its ordinal; a key that no longer matches returns `readBackError: KEY_MISMATCH` without an `evidenceHash`) plus any written fields (or the targeted list row, `null` if it is gone; a document header also gets its loaded `lines`, since a posting changes line state such as "Quantity Shipped" but not the header key fields), closes the page, and returns that as `readBack` with `evidenceHash`, the SHA-256 of its sorted-key JSON. It does not switch company for you (`readBackError: COMPANY_MISMATCH`), does not open a page while a dialog is open (`MODAL_OPEN`: opening would cancel it; answer it first), and never re-opens a page without a recorded record bookmark (`NO_TARGET`). A card in edit mode keeps a written value until the page is closed or leaves the record, so a read-back taken while the original page is still open can show the old value.

The HTTP transport's REST API (`POST /api/v1/...`) uses the same ledger. Its three mutating routes take the key in an `Idempotency-Key` header and record it under their MCP tool name (`pages/write` = `bc_write_data`, `pages/action` = `bc_execute_action`, `pages/close` = `bc_close_page`), so a key means the same operation on both transports and `bc_get_operation` can look up a REST call. Responses:

| Case | HTTP status |
|------|-------------|
| success | `200` |
| replay of a completed call | `200` with the stored body and `Idempotent-Replayed: true` |
| `OPERATION_ID_REUSED` (key used for a different route or body) | `422` |
| `OPERATION_IN_DOUBT` | `409`, body `{ code, operationId, status, replaySafe: false, target, ... }` |
| definite failure (validation, business error) | `400` |
| sign-in required | `401` |
| `TIMEOUT_ERROR` | `504` |
| other ambiguous failure (`SESSION_LOST`, `CONNECTION_ERROR`, `PROTOCOL_ERROR`, `ABORTED_ERROR`) | `503` |
| unclassified error | `500` |

Ambiguous failures (`503`/`504`/`500`) of a mutating route carry `replaySafe: false`, with or without a key. Read-only routes ignore the header.

## 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 Server
```

<details>
<summary><strong>Page output shape</strong></summary>

`bc_open_page` returns the page as a flat list of sections:

```json
{
  "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** (`header` on Card pages, `factbox`, `requestPage`): `fields[]` and (for `header`) `actions[]`
- **List-style** (`lines` on Documents, `header` on List pages, repeater subpages): `rows[]` and `totalRowCount`
- **Cue tiles** (Role Center hosted CardParts): `cues[]` with each tile's `name`, `value`, `groupCaption`, `synopsis`, `hasAction`. Drill down with `bc_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.

</details>

<details>
<summary><strong>Session resilience</strong></summary>

- 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 `LogicalModalityViolationException` mid-session: closes stale confirms, messages and report request pages (`CloseForm`, which answers a confirm with No and cancels a request page) and retries transparently; any other stale modal (a `Page.RunModal` page, a lookup, a menu), or a dialog BC does not close, resets the session instead, because closing it could accept it

</details>

## 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

```bash
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](ROADMAP.md) for the full list and priorities.

---

**Author:** Torben Leth (sshadows@sshadows.dk)
**License:** MIT (see [LICENSE](LICENSE))

TDQS

A4.7/5.0

Scored across 16 tools

Disambiguation4/5

Most tools have distinct roles (bc_query for bulk reads, bc_read_data for page refresh, bc_write_data for field edits, bc_execute_action for actions, etc.). Potential confusion exists between bc_read_data and bc_query for reading data, and between bc_execute_action and bc_navigate for drill-down, but the extensive descriptions clarify when to use each.

Naming Consistency5/5

All 16 tools follow the exact same bc_verb_noun pattern (bc_read_data, bc_write_data, bc_execute_action, bc_navigate, etc.), providing a highly predictable and consistent naming convention throughout.

Tool Count5/5

With 16 tools, the count is well-suited for the complex domain of Business Central automation, covering page interaction, data querying, session management, and file operations without excessive redundancy.

Completeness5/5

The toolset provides comprehensive coverage for interacting with Business Central via MCP: page lifecycle (open/close), read/write, action execution, navigation, dialogs, reports, file upload, bulk queries, company switching, and operation tracking. It addresses both UI-driven and server-side workflows, leaving no obvious gaps for the stated purpose.

Maintenance

ActivityNo data
ResponsivenessWithin a week