dataverse-local-mcp
v1.2.1
Published
MCP server for the Dataverse Web API with MSAL interactive auth (no @microsoft/dataverse CLI)
Maintainers
Readme
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.
Getting started
1. Install Node.js 22 or newer if you don't have it.
2. Install the server from the npm install page:
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. The quickest way is to let the scanner write it: npx powerpreflight mcp <claude-code|claude-desktop|vscode> https://yourorg.crm.dynamics.com --write (it previews without --write, merges rather than replaces, and knows which top-level key each client wants). To do it by hand for Claude Desktop, add this to claude_desktop_config.json, replacing the URL with your environment's. (For VS Code the file is .vscode/mcp.json and its top-level key is servers rather than mcpServers — see docs/mcp-setup.md.)
{
"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_record | Create a record from a JSON payload (lookups via @odata.bind) |
| update_record | Update a record by GUID (update-only — never upserts) |
| delete_record | Delete a record by GUID (permanent) |
| 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 |
| 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 |
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 |
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 refetchconfig.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.
## 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_
### rsm_scenariotype
Picklist. 1 = replacement, 2 = rehabilitation, 3 = deferral.
_author: ben.vollmer_ · _added: 2026-08-20_ · _confidence: inferred_confidence is confirmed only when a human stated it. Anything the model worked out is inferred — which is exactly why annotations are never written back into Dataverse descriptions by default.
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.
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>/.defaultThis 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
src/
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
store.ts # $metadata + saved-view warm cache
server.ts # MCP wiring
metadata/
types.ts # cached schema shape
fingerprint.ts # schema + per-annotation target hashes
metadataCache.ts # build, lazy-fill, persist
sampling.ts # opt-in row sampling
annotations/
model.ts # markdown parse/serialize
store.ts # read, upsert, export, import/merge
drift.ts # valid / changed / orphaned
storage/
backend.ts # StorageBackend interface, kinds, preset paths
directory.ts # files on disk, with optional git commit/push
notion.ts # documents as Notion pages
index.ts # backend factory
tools/ # tool definitions and formatters
package.json
tsconfig.jsonDependencies
npm install @azure/msal-node @modelcontextprotocol/sdk open
npm install -D typescript @types/nodeHTTP 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, authorityhttps://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
openpackage; setDATAVERSE_MCP_NO_OPEN=1to 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
silentOnlymode 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
npm run build
node dist/server.js https://yourorg.crm.dynamics.comExpected: 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
{
"mcpServers": {
"dataverse": {
"command": "node",
"args": ["/full/path/to/DataVerseLocalMCP/dist/server.js", "https://yourorg.crm.dynamics.com"]
}
}
}