Dataverse Local MCP
by BusinessNone
README.md
# Dataverse Local MCP
Connect Claude (or any MCP client) to your Microsoft Dataverse / Dynamics 365 environment and work with your data in plain English — query records, run your saved views, explore tables and columns, create and update rows.
* **Sign in like you always do** — your own Microsoft work account, in your browser, with the same trusted sign-in XrmToolBox uses. Works out of the box: no app registration, no API keys, no admin setup.
* **Fast from the second call** — your environment's schema and saved views are prefetched and cached locally, so metadata questions answer instantly.
* **Full toolbox** — OData queries, FetchXML (aggregates and joins), entity CRUD, and metadata discovery in one server.
**Guides:** [Setup](docs/setup.md) · [User guide](docs/user-guide.md)
## Getting started
**1. Install [Node.js 18 or newer](https://nodejs.org/en/download)** if you don't have it.
**2. Install the server** from the [npm install page](https://www.npmjs.com/package/dataverse-local-mcp):
```bash
npm install -g dataverse-local-mcp
```
(Or skip the install and use `npx -y dataverse-local-mcp` as the command below.)
**3. Add it to your MCP client** — for Claude Desktop, add this to `claude_desktop_config.json`, replacing the URL with your environment's:
```json
{
"mcpServers": {
"dataverse": {
"command": "dataverse-local-mcp",
"args": ["https://yourorg.crm.dynamics.com"]
}
}
}
```
**4. Sign in once.** The first time a tool runs, your browser opens a Microsoft sign-in — pick your **work account** for that environment. The token is cached at `~/.dataverse-mcp/token-cache.json`, so you won't be asked again until it expires. If the browser signs you in with the wrong account, an account picker is always shown so you can switch.
**Try it:** ask your MCP client to run the `whoami` tool — it should return your Dataverse `UserId` and `OrganizationId`. Then try "list my saved views on account" or "show me the top 5 accounts by name".
## Tools
**Data**
| Tool | What it does |
| --- | --- |
| `whoami` | Verify auth: returns `UserId`, `BusinessUnitId`, `OrganizationId` |
| `get` | Raw OData GET relative to `/api/data/v9.2/`, e.g. `accounts?$select=name&$top=5` |
| `fetch_xml` | Run a FetchXML query — aggregates, link-entity joins, complex filters; returns formatted values |
| `create` | Create a record — previews by default, payload validated against cached metadata first |
| `update` | Update one record by id or a filter matching exactly one row; optimistic concurrency by default |
| `delete` | Delete one record — preview lists its current values first; permanent |
| `associate` / `disassociate` | Relate or unrelate two records through a navigation property |
| `invoke_action` | Run a bound or unbound action (`WinOpportunity`, `SetState`, Field Service booking actions) |
| `invoke_function` | Run a bound or unbound function — side-effect free, so no confirmation needed |
| `list_saved_queries` | Browse system and personal saved views — filter by entity, scope, or name substring |
| `get_saved_query` | One saved view including its FetchXML, by id or name — run or adapt it with `fetch_xml` |
**Schema**
| Tool | What it does |
| --- | --- |
| `list_tables` | List tables from the local cache — filter by custom/out-of-the-box, name fragment, or solution |
| `describe_table` | One table in full: columns, types, required levels, option sets, lookup targets, relationships, annotations, sampled fill rates |
| `find_column` | Search cached columns by name fragment or display label, across every table cached in full |
| `lookup_reference` | Microsoft Learn documentation for an out-of-the-box table (defers to the Learn MCP when you have it) |
| `refresh_metadata` | Rebuild the cache, optionally scoped to named tables |
**Annotations**
| Tool | What it does |
| --- | --- |
| `annotate` | Record a local note on a table or column, marked `confirmed` or `inferred` |
| `remove_annotation` | Delete the local note(s) for one target |
| `export_annotations` | Write the annotation markdown to a path you name |
| `import_annotations` | Import a markdown file — refused unless its `organizationId` matches the connected environment |
| `check_drift` | Resolve every annotation against the current schema: valid, changed, or orphaned |
| `promote_annotation` | Write a confirmed annotation into the Dataverse description itself — maker mode only |
**Environment**
| Tool | What it does |
| --- | --- |
| `environment_info` | Cache state: org id, mode, last sync, table counts, sampling settings, drift |
| `set_environment_config` | Set friendly name, mode, out-of-the-box allowlist, table cap, and row sampling |
| `set_storage` | Choose where documentation and the metadata cache live — local, git, Obsidian, OneDrive, Basic Memory, Notion, or any folder |
## Writing safely
Every mutating tool **previews by default**. Called without `confirm: true` it describes exactly what would change — naming the resolved record by its primary name — and calls nothing. `delete` additionally lists the record's current field values, so you can see what is about to be lost.
- **One record at a time.** `update` and `delete` take an id or a `where` filter, and anything matching more than one record is refused with the candidates listed rather than fanning out.
- **Optimistic concurrency by default.** Updates and deletes carry the record's ETag, so a write against a record that changed after you read it fails instead of silently overwriting someone else's work. Pass `concurrency: false` to opt out.
- **Payloads are checked before they are sent.** Unknown columns, columns not valid for the operation, out-of-range option set values, and unknown `@odata.bind` navigation properties all fail locally with a message naming the problem — not an opaque platform 400.
- **Actions cascade.** Much of the real work in Dataverse happens through actions rather than table writes, and their effects reach further than the call suggests. The preview shows the call, not its consequences: those cannot be known without running it.
Errors surface the Dataverse hex code and message, with recognised shapes (privilege denied, duplicate detection, business rule or plugin rejection, concurrency, unknown column) prefixed by a plain-language line. Anything unrecognised passes through verbatim rather than being guessed at.
## Resources
The environment's full OData `$metadata` (CSDL/EDMX) schema is exposed as an MCP resource at `dataverse://metadata` (`application/xml`, often several MB).
## How the cache works
Right after the stdio handshake the server builds its caches in the background. It never opens a browser at startup: the prefetch uses **silent auth only**, so with no cached token it waits and retries after your first tool call signs in. Nothing blocks on it — a cold start still works, just slower on the first call.
Everything is keyed by **OrganizationId**, not environment URL, because URLs change and org ids don't:
```
~/.dataverse-mcp/
token-cache.json
environments/
index.json # host -> organizationId, so a warm start needs no network
<organizationId>/
config.json # url, friendly name, mode, storage, scope, sampling
schema.json # cached metadata } these two follow
schema.fingerprint # hash for drift detection } your storage choice
annotations.md # your documentation }
metadata.xml # the $metadata resource } always local:
saved-queries.json # } large, derived, cheap to refetch
```
`config.json` and `index.json` always stay local — they hold the storage settings themselves, so they can't live inside the backend they describe.
**Scope.** Every table gets a cheap name-level summary. Full column and relationship detail is cached for all custom tables plus an allowlist of out-of-the-box ones (Field Service and core sales/service by default), capped at `maxFullTables`. Anything else is fetched lazily and merged the first time a tool touches it.
**Row sampling** is off by default. Enable it per environment and the cache also records, per column, the fill rate and up to five example values from at most 20 rows — the single most useful signal for tables whose descriptions are blank. It reads real data, so it stays opt-in, and it never samples columns whose type or format suggests personal data unless you explicitly allow it.
## Annotations
Dataverse descriptions are frequently blank. The environment's own shape carries most of the meaning; the rest is human knowledge worth accumulating instead of re-deriving every session. Annotations live in plain markdown at `environments/<organizationId>/annotations.md` — human-editable, diffable, and safe to check into an engagement repo.
```markdown
## rsm_cipscenariocandidate
Candidate records for capital improvement plan scenario modelling. Populated by
the scenario engine, not by users directly.
_author: ben.vollmer_ · _added: 2026-08-20_ · _confidence: confirmed_ · _provenance: human_
### rsm_scenariotype
Picklist. 1 = replacement, 2 = rehabilitation, 3 = deferral.
_author: ben.vollmer_ · _added: 2026-08-20_ · _confidence: inferred_ · _provenance: human_
```
Each annotation carries two independent fields. **Confidence** is `confirmed` only when a human stated it; anything a model or tool worked out is `inferred` — which is exactly why annotations are never written back into Dataverse descriptions by default. **Provenance** is `human`, `preflight`, `velocity` or `model`, and governs overwrite behaviour on re-scan: a writer may replace its own earlier note freely, and a human replaces anything, but nothing else overwrites. Where a re-scan contradicts a person's note, both are kept and flagged for a human to settle rather than one silently winning.
Tool-authored notes are rendered as blockquotes so you can tell at a glance what came from a person:
```markdown
### rsm_scenariotype
> No plugins are registered on this column.
_author: preflight_ · _added: 2026-08-20_ · _confidence: confirmed_ · _provenance: preflight_
```
**Sharing.** `export_annotations` writes the file anywhere you like; `import_annotations` reads one back. Front matter carries the `organizationId`, and an import into a different org is **refused, never merged**. Where both sides annotate the same target with different text, both are kept and flagged rather than one silently winning.
**Drift.** A fingerprint over the schema is captured at cache time. When it moves, each annotation resolves to `valid`, `changed` (the type or option set moved underneath the note) or `orphaned` (the target is gone). A short summary is logged on connect, the specific warning repeats inline in `describe_table`, and nothing is ever deleted automatically.
**Modes.** Set explicitly per environment, never inferred from privileges — privileges are usually broader than intent. `consumer` (the default) keeps annotations local and never writes metadata. `maker` will additionally allow promoting a confirmed annotation into the Dataverse description itself.
## Promoting documentation into Dataverse
If you own an environment's schema, a confirmed annotation can become the real Dataverse description. This is deliberately awkward and the friction should not be reduced for convenience: it needs **maker** mode, a **confirmed** annotation (inferences are refused), no unresolved conflict on it, one target per call, and an explicit confirmation after reading the preview.
The written text is prefixed with a `[dataverse-mcp]` marker plus provenance and date. That marker is the point — without it a promoted note becomes indistinguishable from a human-authored description six months later, and something that read as a reasonable guess starts reading as fact.
Promotion is a **metadata write**: it creates an unmanaged customization in the active solution layer, which can mask later updates to a managed component, and publishing may be required before the description appears in the UI. The preview says all of this before you confirm, and the tool cannot undo it.
## Where your documentation lives
By default everything sits under `~/.dataverse-mcp`. Point it somewhere else with `set_storage` and both the annotations **and** the metadata cache follow — they always travel together, per environment.
| Kind | What it does |
| --- | --- |
| `local` | Default. Under `~/.dataverse-mcp/environments/<organizationId>/` |
| `git` | A repo on disk. Every write is committed, so the documentation carries history and diffs; set `autoPush` to push each commit |
| `obsidian` | Markdown into your vault — defaults to `~/Obsidian`, or give an explicit `path` |
| `onedrive` | Into the OneDrive sync folder — `$OneDrive` or `~/OneDrive` |
| `basic-memory` | Into the Basic Memory notes directory — defaults to `~/basic-memory` |
| `directory` | Any other folder you name |
| `notion` | The annotation document as a page under a parent page you choose |
The file-backed kinds are one implementation: an Obsidian vault, a OneDrive sync folder and a Basic Memory directory are all just folders, and git adds a commit step. Each environment gets its own subfolder (`dataverse-mcp/<friendlyName>-<orgId prefix>`) so a shared vault or repo can hold several without collision.
**Notion** needs an internal integration token in a `NOTION_TOKEN` environment variable — set it in your MCP client config, not in a file — and a `notionPageId` for the parent page, which must be shared with your integration. Each markdown line becomes one paragraph block, so the document round-trips exactly and stays readable and editable in Notion. Because Notion is a document store rather than a file store, the schema cache stays on local disk when Notion is selected; the annotations live in Notion.
Switching storage does not copy what you already have — run `export_annotations` first if you want to carry it across.
## Upgrading from 0.3.x
`list_entities` and `describe_entity` are replaced by `list_tables` and `describe_table`, which read the new cache and fold in annotations and drift warnings. The 0.3.x cache directory `~/.dataverse-mcp/cache/<host>/` is no longer read and can be deleted; the new cache rebuilds itself on first connect. Your token cache is untouched, so no new sign-in is needed.
---
# Build Spec (for contributors)
## Goal
Build a standalone MCP server that talks directly to the Dataverse Web API. Going straight to the Web API keeps the server small and dependency-light, and lets it use the sign-in flow that works most broadly across machines and tenants — including tenants with strict Conditional Access policies. TypeScript, local Node host, no new app registration required.
## Why this auth approach
This server uses the same proven auth pattern as XrmToolBox and Microsoft's own XRM Tooling samples: a Microsoft-provided, pre-consented public client with a loopback redirect, driven as a standard MSAL auth-code-plus-PKCE flow. It's the ordinary browser sign-in your tenant already trusts — it runs on every OS, satisfies Conditional Access policies that stop device-code flows, and needs no OS-level broker. Anywhere XrmToolBox connects, this connects.
```
Client ID: 51f81489-12ee-4a9e-aaae-a2591f45987d
Redirect URI: http://localhost
Authority: https://login.microsoftonline.com/common
Scope: <environmentUrl>/.default
```
This is a Microsoft multi-tenant sample app with `user_impersonation` delegated permission, no admin consent required. If XrmToolBox already connects successfully in your tenant, this same client ID is proven to already clear Conditional Access there.
## Non-goals for v1
* No custom Entra app registration (use the well-known client ID above)
* No service principal / CI auth (interactive user auth only)
## Repo layout
```
packages/
core/ @dataverse-platform/core — shared library, private
src/
index.ts public surface
auth.ts MSAL interactive + silent acquisition
cache.ts atomic read/write helpers
paths.ts ~/.dataverse-mcp layout
environment.ts per-environment config, OrganizationId resolution
dataverseClient.ts Web API calls
writes.ts preview/confirm, validation, single-record resolution
promotion.ts annotation -> Dataverse description, maker mode only
errors.ts Dataverse error translation
store.ts $metadata + saved-view warm cache
metadata/ schema cache: types, fingerprint, build, sampling
annotations/ markdown model, store, drift detection
storage/ backends: directory/git presets, Notion
mcp-server/ dataverse-local-mcp — published to npm
src/
server.ts MCP wiring
tools/ tool definitions and formatters
build.mjs esbuild bundle (inlines core)
prepack.mjs stages README/LICENSE for packing
package.json npm workspaces root
tsconfig.base.json
```
The assessment tools (`powerpreflight`, `velocity`) join as further `packages/*`, calling core directly as a library rather than going through the MCP server.
## Dependencies
```bash
npm install # installs every workspace
npm run typecheck # tsc -b across packages
npm run build # core via tsc, mcp-server bundled via esbuild
npm run clean # removes dist and tsbuildinfo
```
Runtime dependencies are `@azure/msal-node`, `@modelcontextprotocol/sdk` and `open`. HTTP calls use Node's built-in global `fetch` (hence the Node ≥ 18 requirement) — no HTTP client dependency.
## Step 1 — Auth module (`src/auth.ts`)
Acquire and cache a token using `acquireTokenInteractive`, which spins up its own loopback listener, no manual HTTP server needed.
* Client ID `51f81489-12ee-4a9e-aaae-a2591f45987d`, authority `https://login.microsoftonline.com/common`
* Scope `<environmentUrl>/.default`
* Token cache persisted to `~/.dataverse-mcp/token-cache.json`
* Silent acquisition from cache first, fall back to interactive (system browser opened via the `open` package; set `DATAVERSE_MCP_NO_OPEN=1` to print the URL instead)
* Interactive sign-in always shows the account picker (`prompt: select_account`) so browser SSO can't silently hand back the wrong account's token
* Concurrent interactive sign-ins are deduped per environment — parallel requests share one browser window
* A `silentOnly` mode backs the cache prefetch: it throws instead of opening a browser, so background work never interrupts client startup
## Step 2 — Dataverse Web API client (`src/dataverseClient.ts`)
Thin wrapper over the Dataverse Web API (`/api/data/v9.2/`) sending `Authorization: Bearer`, `OData-MaxVersion: 4.0`, `OData-Version: 4.0` headers, retrying `429`/`503` on `Retry-After` so a bulk metadata build survives service protection limits. Covers `whoAmI()`, generic `get()`, record create/update/delete (`PATCH` sends `If-Match: *` so updates never silently upsert), FetchXML queries, saved views (`savedquery` + `userquery`, following `@odata.nextLink`), the raw `$metadata` EDMX, and metadata reads over `EntityDefinitions`.
Two Dataverse constraints shape the metadata calls: `EntityDefinitions` rejects `$top` and `$orderby` (it accepts `$select` and `$filter`), and `DisplayName`/`Description`/`RequiredLevel` come back as objects rather than scalars, so labels are extracted from `UserLocalizedLabel.Label`. Option sets need a cast — the client tries the `EnumAttributeMetadata` base cast (one call for picklist, state, status and multiselect) and falls back to the concrete casts where that isn't supported.
## Step 3 — MCP server entry (`src/server.ts`)
Registers the tools listed in the Tools section above plus the `dataverse://metadata` resource, and kicks off the background cache prefetch after the transport connects. Uses the standard `@modelcontextprotocol/sdk` `Server` class with stdio transport, matching how `@microsoft/dataverse mcp` itself runs. The environment URL is passed as the first CLI argument.
## Step 4 — First test
```bash
npm run build
node dist/server.js https://yourorg.crm.dynamics.com
```
Expected: system browser opens once for interactive sign-in, token caches to `~/.dataverse-mcp/token-cache.json`, subsequent runs reuse the cached token silently. Confirm success by calling the `whoami` tool and checking the returned `UserId`/`BusinessUnitId`. After the first sign-in, the background prefetch fills `~/.dataverse-mcp/cache/<org-host>/` with `metadata.xml`, `entities.json`, and `saved-queries.json`; later launches serve metadata and saved-view tools from that cache.
## Step 5 — Claude Desktop config
```json
{
"mcpServers": {
"dataverse": {
"command": "node",
"args": ["/full/path/to/DataVerseLocalMCP/dist/server.js", "https://yourorg.crm.dynamics.com"]
}
}
}
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing