Skip to content

How It Works

Ryan James edited this page Aug 17, 2026 · 1 revision

The request path

sequenceDiagram
    participant C as MCP client
    participant S as dataverse-mcp
    participant E as Entra ID
    participant D as Dataverse Web API
    C->>S: tool call over stdio (JSON args)
    S->>S: Pydantic validation + URL normalise + whitelist
    S->>S: token cache lookup (per scope)
    S-->>E: acquire token (cache miss only)
    S->>D: HTTPS request, Bearer token
    D-->>S: OData JSON (retry on 429 / gateway 5xx)
    S-->>C: JSON string on stdout
Loading

The server is a subprocess of your client. stdout carries the MCP protocol and nothing else; all logging goes to stderr, which is why the code never calls print().

Validation happens before anything leaves the machine. Each tool's arguments are parsed into a Pydantic model with extra='forbid', and dataverse_url is normalised and whitelist-checked inside that model — so a bad URL or an unapproved host is rejected without a token ever being minted, and surfaces as an MCP tool error rather than the JSON error contract below.

Tokens are cached per scope in the server process and reused until 5 minutes before expiry. A cache miss runs the blocking credential call on a worker thread under a per-scope lock, so ten concurrent tool calls on a cold cache produce one sign-in, not ten. That wait is capped by DATAVERSE_AUTH_TIMEOUT_SECONDS.

All HTTP goes through one shared httpx.AsyncClient (max 20 connections, 10 keep-alive; 10 s connect, 60 s read/write, 120 s for $batch).

What tools return

Every tool returns a string containing JSON — never Markdown, never a raised exception.

List shape

{ "records": [ ... ], "count": 25, "has_more": false }

The collection key names the thing being listed (records, tables, solutions, environments, …). count is how many are in this response.

Single shape — a flat object of the fields that tool documents, e.g. dataverse_whoami returning UserId / BusinessUnitId / OrganizationId.

Error shape

{ "error": true, "message": "Dataverse returned HTTP 403: [<odata-code>] <Dataverse's own message>" }

is_transient: true is added when the request timed out before the server answered — the operation may still have completed, so verify before retrying.

Condition Message
Dataverse returned an error status Dataverse returned HTTP <code>: [<odata-code>] <message>
Request timed out The request timed out before the server responded. The operation may still have completed on the server; verify before retrying. + is_transient
Host unreachable Could not reach <host>: <detail>
Credential failed Authentication failed. Run az login to refresh your Azure CLI session, or check DATAVERSE_AUTH_TYPE and your credential configuration.
Anything unhandled Unexpected error in <tool_name>. See the server logs for details.

The last one is deliberate: exception text can carry internal paths and hostnames, so the detail stays in the server log. Dataverse error messages are passed through but capped at 2,000 characters, suffixed … (truncated).

Pagination

List tools take top, defaulting to 50 on most of them. The server asks Dataverse for pages of min(top, 500) via Prefer: odata.maxpagesize and follows @odata.nextLink until it has top records — so top is a total, not a page size, and one tool call may be several HTTP round-trips.

has_more is true when the response came back full (count >= top). That means it can be true when the next page would in fact be empty: treat it as "ask for more if you need more", not as proof more exists. dataverse_execute_fetchxml is the exception — it pages with a Dataverse paging cookie, so its has_more is exact and the cookie is returned alongside it.

A few tools that wrap Dataverse functions with no server-side paging fetch the whole set and trim it locally — those report both count (returned) and total_count (the real size). dataverse_get_role_privileges is the one to remember: a System Administrator role carries roughly 4,100 privileges and about 1 MB, so it trims to top and tells you the true magnitude in total_count.

Response size

Size Behaviour
Over 1 MB Logged as a warning; response returned normally
Over 5 MB Response replaced with an error
{ "error": true, "message": "Response too large (7.3 MB). Narrow the query with select/top/filter." }

The fix is always the same: name the columns in select, lower top, add a filter. Some tools have their own lever — include_content, include_solutions, max_chars — which is cheaper than narrowing the query.

Retry and throttling

Up to 3 attempts per request.

Condition Retried for Wait
HTTP 429 (service protection) All methods Retry-After seconds, capped at 30 s; 2 s if the header is missing or unparseable
HTTP 502 / 503 / 504 GET, PUT, DELETE only 1 s, 2 s, 4 s
Connection failure GET only 1 s, 2 s, 4 s — then DataverseConnectionError
Timeout GET only 1 s, 2 s, 4 s — then re-raised

Any other status is returned to the caller immediately.

POST and PATCH are not retried on 5xx. A gateway error can arrive after Dataverse committed the write, so a retry would create a second record or re-apply an update. The 5xx is returned instead and the agent is told to verify. 429 is different — it is a rejection before processing, so nothing was committed and retrying every method is safe.

Quirks worth knowing

Behaviour Why it bites
Forms, views and apps read back the published version A read after an edit looks unchanged. Use dataverse_retrieve_unpublished (savedqueries, systemforms, appmodules, webresourceset — not sitemap), or run dataverse_publish_customizations first.
dataverse_get_total_record_counts is snapshot-based Counts come from a snapshot taken at most once every 24 hours, and are all-or-nothing — one unrecognised logical name fails the whole batch with HTTP 400. All zeros usually means the snapshot job has not run. Use dataverse_count_records for an exact, live count.
dataverse_get_setting returns HTTP 200 for a name that does not exist Dataverse answers with a null detail rather than an error. The tool reports setting_found: false, which never collapses with a setting genuinely holding "", "false" or 0.
dataverse_validate_fetchxml returns HTTP 200 for an invalid query An unknown table or attribute comes back as an error-severity message, not an error status. Read has_errors / error_count, not the status.
dataverse_retrieve_access_origin returns HTTP 200 when there is no access No access and a nonexistent record are both successful calls; the answer is in the prose of access_origin.

Clone this wiki locally