Repository navigation
Releases
v0.9.2
v0.9.1
Added
create_entitytakeshas_notesandhas_activities, to enable notes and attachments or activities on the new table. Neither can be turned off once enabled (#109).
Fixed
create_entitysays what it created when it fails part way. A timeout on the table request says the table may not exist, may exist in part or may still be in progress, that no additional column was sent, and to wait up to 2 minutes before checking withget_entity_schema; a timeout on a column request says the same of that column. A failed column request says that the table was created, which columns were created, which one failed and why, and which were not sent; so does a table whose id did not come back. Before, these surfaced as the bare timeout or Dataverse error (#113).create_entityfailed on every call: Dataverse requiresHasNotesinCreateEntityand refused the request without it (Required field 'HasNotes' is missing). The tool now sendsHasNotes,falseby default;HasActivitieswas already sent asfalse(#109).
Server 0.1.0-rc.2
Added
- The MCP server for Microsoft Dataverse over Streamable HTTP (MCP 2026-07-28), for remote agents: the metadata-read, data-read, data-write, actions and functions tool groups, without the development tools (#76).
- Access with bearer tokens listed by their SHA-256 in a JSON config file (#76).
- Roles in the config file: which tool groups a token may use, which entity sets it may create, update or delete in, which actions and functions it may call, and which Dataverse user it acts on behalf of (#77).
- The image: one esbuild bundle on distroless Node 24, non-root (uid 65532), for
linux/amd64andlinux/arm64, with the licence texts of the bundled packages (#79, #96). - An audit log on stdout: one JSON line (
"type":"audit") per tool call, with the token, role,actAs, the JSON-RPC id, the proxy'sX-Request-Id, the names the call used, how it ended, the record count and the Dataverse requests, throttling and retries it caused; and a line per refused token, refusedOriginand call to a tool the role does not have. It records no content: no filters, bodies, parameter values, records or Dataverse error text (ADR-0001 §12, #78). DATAVERSE_AUDIT_KEY(optional, at least 32 characters): the key of the HMAC in the fingerprint the audit log writes for a value refused for its shape. Without it the fingerprint has only a length and flags (#78).
Changed
- The startup and shutdown messages are written to stderr; stdout carries only audit lines (#78).
Server 0.1.0-rc.1
Added
- The MCP server for Microsoft Dataverse over Streamable HTTP (MCP 2026-07-28), for remote agents: the metadata-read, data-read, data-write, actions and functions tool groups, without the development tools (#76).
- Access with bearer tokens listed by their SHA-256 in a JSON config file (#76).
- Roles in the config file: which tool groups a token may use, which entity sets it may create, update or delete in, which actions and functions it may call, and which Dataverse user it acts on behalf of (#77).
- The image: one esbuild bundle on distroless Node 24, non-root (uid 65532), for
linux/amd64andlinux/arm64, with the licence texts of the bundled packages (#79, #96).
v0.9.0 — MCP TypeScript SDK v2, Node 20
Changed
-
The server is built on MCP TypeScript SDK v2 (
@modelcontextprotocol/server2.2) instead of@modelcontextprotocol/sdk1.x (closes #75).- BREAKING — Node 20 or later is required; the SDK does not run on Node 18.
- The stdio server now speaks MCP 2026-07-28 as well as the 2025 revisions. A client that opens with
server/discoveris served on 2026-07-28; one that opens withinitializegets the revision it asks for, as before. Claude Code 2.1.287 was observed opening withserver/discover, so it now runs on the new revision instead of falling back. - Tool names, descriptions, order and input schemas are the same, with three differences on every tool, none of which changes what a tool accepts:
$schemaishttps://json-schema.org/draft/2020-12/schemainstead of draft-07;$schemais the second key of the schema instead of the first; and the tool member"execution":{"taskSupport":"forbidden"}is no longer sent (forbiddenis the default). - Input that fails a tool's schema is reported with different wording: one line, without the
MCP error -32602:prefix. - A call to a tool name the server does not have is answered with a JSON-RPC error (
-32602) instead of a tool result markedisError. - When stdin closes, the server stops at once: a request still in progress is not answered. Before, it was allowed to finish and its result was written. Clients that keep stdin open for the session, as MCP clients do, are unaffected. A one-shot pipe (
printf '…' | dataverse-mcp-server) gets no answer to a call that had not finished, and a write sent that way may still reach Dataverse. - On a 2026-07-28 session every result also carries
resultTypeand_metawith the server's name and version, andtools/listcarriesttlMsandcacheScope.
An error thrown inside a tool still reaches the client as the tool's error text with
isError: true.
v0.8.0 — Dataverse service protection limits and request timeouts
Added
-
DATAVERSE_REQUEST_TIMEOUT_MS(default 30000, maximum 120000, matching Dataverse's own 2-minute limit on a message operation): every Dataverse request and the token request to Microsoft Entra ID now time out instead of hanging a tool call indefinitely. The error says which request timed out. A value that is not a whole number from 1 to 120000 is reported throughdataverse_setuplike a missing variable, rather than silently replaced by the default (#73). -
Handling of Dataverse service protection limits (closes #74). A
429 Too Many Requestsused to fail the tool call with the raw Dataverse error. Now:- A throttled request is resent after the wait its
Retry-Afterheader asks for, up toDATAVERSE_MAX_ATTEMPTSsends in total (default 3). Without a usable header the server backs off 2 s, then 4 s, and so on. While it waits, no other request is sent, because Dataverse extends the wait for a client that keeps sending. - If the waits of one request would add up to more than
DATAVERSE_MAX_RETRY_WAIT_MS(default 15000), or the attempts run out, the tool call fails with "Dataverse is busy: a service protection limit was reached. Retry in N s." The raw Dataverse 429 text is no longer shown. - At most
DATAVERSE_MAX_CONCURRENCYrequests are in flight (default 8). Up toDATAVERSE_MAX_QUEUE_LENGTHmore (default 100) wait their turn in arrival order for at mostDATAVERSE_MAX_QUEUE_WAIT_MS(default 10000); beyond either bound the call fails with a "Dataverse is busy … Retry later" error instead of queuing indefinitely. - All five variables are optional. An out-of-range value is reported through
dataverse_setuprather than replaced by the default.
Behaviour to know about: writes are retried after a 429 like reads, as Microsoft's own clients do. A 429 holds other requests back for the wait Dataverse asked for, but no longer than
DATAVERSE_MAX_RETRY_WAIT_MS(closes #88): in a live test Dataverse answered light requests straight after a 429 withRetry-After: 300, so a longer pause would only have been downtime of our own making. With the defaults the limits add at most 25 s of waiting to a request, chosen so that one request at the default timeout still fits the 60 s a client built on the MCP TypeScript SDK waits by default; the README gives the full arithmetic. - A throttled request is resent after the wait its
Changed
- Tools are registered by purpose instead of by topic (closes #72):
metadata-read,data-read,data-write,actions,functionsanddevelopment, as named in ADR-0001 §4. Nothing a client sees changes except order: tool names, descriptions, input schemas andDATAVERSE_ALLOW_DELETEbehaviour are identical, pinned by atools/listsnapshot taken before the change.tools/listnow returns tools grouped this way rather than in the old per-file order. - Tool input schemas are built once at module load instead of on every registration, ahead of SDK v2 building a server instance per HTTP request.
- When the cached token has expired, concurrent tool calls share one token request instead of each sending their own (#73).
- The client reports failures as typed errors (
DataverseApiErrorwithstatusand the Dataverse errorcode,DataverseTimeoutError,DataverseNetworkError,DataverseAuthError), and tools map "not found" by status rather than by matching message text. API error messages read as before. A network failure now says which request failed and why, instead of a barefetch failed(#73). - HTTP execution sits behind a
RequestExecutorinterface, the seam for throttling handling (#74), acting on behalf of a user (#77) and audit logging (#78) (#73).
Fixed
- The server no longer writes a non-protocol line to stdout at startup. When a
.envfile was loaded, dotenv 17 printed a banner (◇ injected env (7) from .env // tip: …) withconsole.log, and on stdio stdout is the MCP protocol stream. Clients built on the MCP TypeScript SDK reported a parse error for that line and carried on; a stricter client could refuse the connection. Present in every release since 0.1.0. get_picklist_options,add_attributeandcreate_entityno longer report "Global OptionSet not found" for a server error whose body happens to contain "404". They matched404anywhere in the message; they now check the status (#73).
v0.7.1 — list_entities prefix filtering
Fixed
-
list_entitiesno longer fails with HTTP 501 when a prefix filter is used without a solution filter (closes #66) — theDATAVERSE_ENTITY_PREFIX-only setup that.env.exampledocuments. Metadata entities rejectstartswithoutright (0x8006088a: The "startswith" function isn't supported for Metadata Entities), not merely in combination withoras the code assumed, so the prefix is now applied client-side on every path rather than on one of the two.Cost of the fix, stated plainly: a prefixed call now fetches all table definitions and filters them here. Against a real org that is ~2.1 MB transferred to return 23 rows. Dataverse offers no server-side prefix match on metadata, and the solution-filtered path already worked this way; what is returned to the caller is unchanged.
The test that should have caught this asserted the generated
$filterstring against a mocked client, so it agreed with the code while neither agreed with Dataverse. It now asserts that nostartswithreaches the server and that the prefix is honoured on the returned rows. -
get_entity_schemareports a choice column whoseOptionSetdid not come back, instead of silently omitting itsoption_set. An omitted summary is indistinguishable from a non-choice column — an answer, and the wrong one. Such columns now appear in the same warning block as a failed cast lookup.
v0.7.0 — bind Choice columns to a Global OptionSet
Added
-
add_attributeandcreate_entityacceptglobal_option_seton aPicklistattribute, binding the column to an existing Global OptionSet by name instead of creating a private copy of the values (closes #60). Mutually exclusive withoptions; an unknown name fails asGlobal OptionSet not found: '<name>'.Global OptionSets exist so one choice list can be shared across columns and tables. Previously the only ways to get a shared list were to accept a local copy that silently drifts the first time anyone edits one of them, or to create the column by hand in the maker portal — which breaks an otherwise scripted schema workflow.
create_entityresolves names and builds every attribute body before creating the table, so nothing a client-side check can reject — an unknown set name, a Picklist with no values, an impossible DateTime pairing — can leave a half-built table behind; Dataverse offers no transaction to roll one back. A name repeated across attributes costs one lookup, not one per column.
Changed
- Reworded the tool descriptions for
get_attribute_dependencies,delete_attribute,update_attributeandget_entity_schema. These are the always-loaded prompt text a model reads to decide whether to call a tool, so they are pruned rather than expanded: return shapes and component-type enumerations that a caller gets for free by calling are gone, each meaning now lives in one tool rather than being restated across several, and the rename/type-change recipe sits inupdate_attributealone. No behavior change.
Notes
Three Web API details established live against a real org, all of which contradict the obvious reading:
- The binding goes through the
GlobalOptionSetnavigation property, not an inlineOptionSet. Sending an inline one withIsGlobal: trueis rejected:0x80048403 — Only Local option set can be created through the attribute create. IsGlobal flag must be set to 'false'. - The binding target must be the MetadataId. Microsoft's documentation states the alternate key by name —
GlobalOptionSetDefinitions(Name='...')— works there too, but a real org answersHTTP 500: Guid should contain 32 digits with 4 dashes. Hence the name is resolved to a GUID first. - Sending an inline
OptionSetand a binding together is not an error. Dataverse silently drops the binding and creates a local copy, which is why the pair is rejected client-side rather than left to the platform.
v0.6.0 — OptionSet identity in read tools
Changed
- BREAKING —
get_picklist_optionsno longer returns a bare[{ value, label }]array. It now returns{ option_set: { name, is_global, metadata_id }, options: [{ value, label }] }(closes #61). The flat array made a Local OptionSet and a Global one indistinguishable: the response shape was identical either way, and matching values do not prove a binding. Callers that consumed the array directly need to read.options. get_picklist_optionsnow resolves Choice, Status, State and MultiSelect columns, not just Choice.statecode/statuscodepreviously failed with "Picklist attribute not found"; the not-found message now names the whole choice family.
Added
get_entity_schemaattaches anoption_setsummary —{ name, is_global, metadata_id, option_count }— to every choice-style column, so a schema dump answers "what can this column contain, and is the list shared?" without a call per column. The option values themselves are deliberately excluded; read them per column withget_picklist_options.
Failure behavior
Reading OptionSet data turns get_entity_schema from one metadata request into five, so the two tools handle a failing request differently — in both cases so that an unknown answer is never presented as a definite one:
get_entity_schemadegrades. The base attribute list is the tool's contract and does not depend on OptionSets, so a failing lookup no longer costs the caller the column list. The partial coverage is reported in a second content block (the JSON stays in the first), because a silently missingoption_setwould read as "this column has no options". A failure of the base request itself still fails the call.get_picklist_optionsfails hard. There, an empty result means "not a choice column", so swallowing an error would turn a transient failure into a confidentChoice attribute not foundon a column that does exist. For the same reason a matched column whoseOptionSetdid not come back is an error rather than a default ofis_global: false.
Notes
Verified live against a real org: a column bound to a global set reports is_global: true with the global set's own metadata_id, while a locally-defined column reports is_global: false with an auto-generated name.
Two Web API details found during that verification, both encoded in src/tools/optionset-utils.ts:
OptionSetis only reachable through a type cast, and the cast must name a concrete type. Casting to the abstract base —/Attributes/Microsoft.Dynamics.CRM.EnumAttributeMetadata— is rejected with HTTP 5000x8006088a: Unexpected attribute type, despite the docs listing it as a GET-able entity type. Each concrete choice type is therefore requested separately.- The sibling
GlobalOptionSetnavigation property is not a usable binding signal: for a locally-defined column it resolves to that column's own local set rather than returning null, so it cannot tell the two cases apart.IsGlobalon the expandedOptionSetis the reliable indicator.
v0.5.0 — invoke_action / invoke_function
Added
- Two new tools for invoking Dataverse Web API actions and functions, covering operations that fall outside CRUD (closes #57):
invoke_action(name, entity_set?, id?, parameters?)— POSTs a bound or unbound action. Unbound →POST /<name>(e.g.UnpublishDuplicateRulewith{ DuplicateRuleId }); bound →POST /<entity_set>(<id>)/Microsoft.Dynamics.CRM.<name>(e.g.PublishDuplicateRulebound toduplicaterule,QualifyLeadbound tolead).parametersis sent as the JSON request body. Bound-vs-unbound is per the Web API$metadata, not the SDK shape — verified live against a real org.invoke_function(name, entity_set?, id?, parameters?)— GETs a bound or unbound function (e.g.WhoAmI).parametersare inlined as OData function arguments using parameter aliases; strings are quoted, GUIDs/numbers/booleans passed as-is.- Bare operation names are namespaced automatically for bound calls (
Microsoft.Dynamics.CRM.<name>); a fully-qualified name is passed through. Operation names are validated against a dotted-identifier pattern and boundidmust be a GUID, so a caller cannot inject extra path/query segments. Bound calls require bothentity_setandid; unbound calls require neither (the half-specified case is rejected).
Use case
Unblocks CRM operations that are only exposed as actions — notably publishing duplicate-detection rules (a rule created via create_record lands unpublished and has no effect until PublishDuplicateRule) and lead qualification (QualifyLead). Surfaced while building the fundaicapital Lead→Account qualification flow (DP-129 / DP-135).
Note
invoke_action can perform arbitrary mutating operations and is intentionally ungated for now; capability-based access control (safe-by-default gating of writes/actions) is tracked separately in #45 / #46. invoke_function is read-only.