Skip to content

MCP server

dosi-server speaks the Model Context Protocol natively, so AI agents (Claude Code, Codex, OpenCode, or any MCP client) can discover metrics and dimensions, compile metric queries to dialect SQL, and execute them — without an HTTP shim in between. The MCP tools call the same engine seam as the REST API and return the same JSON shapes and structured errors as dosi --format json.

The first-party pairing is datus-agent: Datus' own data agents, built to take end-to-end data engineering and analytics work — modelling, pipelines, and the analysis on top. Dosi gives them the metric layer, so the numbers an agent produces come from the same definitions everything else in the stack uses.

Looking for the how-to?

This page is the reference. To wire Dosi into an agent, start with Using Dosi in your agents, or follow a client walkthrough end to end: Claude Code, Codex, or OpenCode.

Protocol: targets MCP spec 2026-07-28 and serves it statelessly end to end — no initialize handshake, no Mcp-Session-Id header, client metadata rides per-request _meta. Legacy (pre-2026-07-28) clients are also served without sessions, and simple request/response tool calls get plain application/json replies. Any replica behind a load balancer can serve any request; nothing about a conversation lives on the server.

Transports

Streamable HTTP (primary)

Every running dosi-server mounts MCP at POST /mcp (top level — not under /v1), beside the REST API:

export DOSI_EXAMPLES=~/.local/share/dosi/examples
dosi-server --model $DOSI_EXAMPLES/orders/model.yaml --db orders.duckdb
curl -s -X POST localhost:8081/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

That is where the install script puts the bundled example models; any Apache Ossie model file works just as well.

Two request-level rules apply to /mcp: the Host header is required (rmcp's DNS-rebinding protection answers 400 without it, with no body), and request bodies are capped at 1 MiB — which in practice only bounds validate_model; validate a larger model with dosi validate instead.

When --auth-token (env DOSI_SERVER_TOKEN) is set, /mcp requires Authorization: Bearer <token> like the rest of the API — evaluated on every request, which is exactly what a stateless deployment wants. Claude Code remote config:

claude mcp add --transport http dosi http://127.0.0.1:8081/mcp \
  --header "Authorization: Bearer $DOSI_SERVER_TOKEN"

stdio (local single-client)

--mcp-stdio serves MCP over stdin/stdout instead of binding HTTP — the standard way for a local agent to spawn the server as a child process. Logs go to stderr; stdout carries only the protocol.

claude mcp add dosi -- \
  dosi-server --model /path/to/model.yaml \
    --db /path/to/warehouse.duckdb --mcp-stdio

Point the client at the installed binary (an absolute path if dosi-server is not on the client's PATH), never at cargo run — under an MCP client a compile makes the first tool call look like a hang.

All the usual flags apply (--connections, --db, --disable-execute, --osi-basic, ...); both transports serve the identical tool set.

The server exposes tools only — no MCP resources and no prompts — plus an instructions string that states the discover → preview → execute workflow and tells clients to read structured errors instead of retrying unchanged.

Tools

Tool Input Returns
list_datasets — datasets: name, source, primary key, fields, time dimensions
list_metrics — metrics: name, kind, datasets, measures, time dimension, description, params (D-PARAM declarations)
list_dimensions metric (optional) dataset.field names with time flags and granularities, each carrying is_dimension (worth grouping by) and source (declared, or which rule inferred it). With metric, only what that metric accepts, best first
describe_metric name one metric's row, plus param_schema (JSON Schema for params) on a parameterized metric; unknown names return candidates
list_connections — named warehouse profiles: name, dialect, availability
get_capabilities — engine mode, supported dialects, execute enabled, model stats
compile_sql metric query + dialect, pretty {dialect, sql, outputs} without executing
explain_query metric query the logical plan as text
run_query metric query + dialect, connection, row_limit {dialect, sql, outputs, columns, rows, row_count, row_limit_applied}
attribute_metric metric, dimensions, baseline/current windows + where_sql, dialect, connection per-dimension decomposition of the metric's change: strategy, total_change, dimension_ranking, value contributions with drill_down.where_sql (see Attribution)
compile_select detail query + dialect, pretty {dialect, sql} without executing (detail queries)
explain_select detail query the detail plan as text — which joins were chosen
select detail query + dialect, connection, row_limit {dialect, sql, columns, rows, row_count, row_limit_applied} — row-level records
validate_model model_yaml {valid, issues, compile_errors, warnings}

The metric-query inputs are the exact MetricQuery wire shape the REST API and CLI use (metrics, group_by as [{field, grain}], where_sql, context_filter, time_range, order_by, limit, params) — the tool schemas are generated from the same types, so they cannot drift. To see one market's rank among all markets, filter it in where_sql; to rank within that market alone, use context_filter (filters). For a parameterized metric (params non-null in list_metrics), call describe_metric first and bind within its param_schema; outputs in every response says which column is which binding (D-PARAM).

The detail-query inputs are likewise the exact DetailQuery wire shape (from, fields, where_sql, time_range, order_by, limit). Reach for select when the answer is records rather than an aggregate — "list the 100 largest orders", "which contracts expire this month". Its two distinctive refusals are worth reading rather than retrying blindly: detail_fanout means the root would be multiplied by a one-to-many hop and names the root to use instead, while ambiguous_join_path lists the relationship prefixes that name each candidate path. Both hand back a suggested_retry you can paste into the next call (detail queries).

Execution safeguards

run_query shares the server's execute machinery: the concurrency semaphore (--max-concurrent-executions, which yields busy when saturated), the execute timeout (--execute-timeout-secs → timeout), and --disable-execute (which turns run_query into a tool-level forbidden error). Note that --request-timeout-secs does not cover /mcp — only those two bound a run_query. select runs through the same machinery and obeys the same three switches.

Because results land in an LLM context, rows are capped: a metric query with no limit gets LIMIT 500 and row_limit clamps at 5000; a detail query defaults to LIMIT 100 and clamps at 10000 — the clamp also applies to a caller's own larger limit. The applied cap comes back as row_limit_applied, so an agent can tell a truncated page from a complete result.

Without a connection, queries run on the server's local DuckDB (--db file or in-memory); name a profile from list_connections to run on a warehouse.

run_query needs data

Without --db, the local DuckDB is in-memory and unseeded, so run_query fails with a missing-table error while compile_sql keeps working. Point --db at a database that actually has the model's tables:

$ duckdb orders.duckdb < $DOSI_EXAMPLES/orders/seed.sql
$ dosi-server --model $DOSI_EXAMPLES/orders/model.yaml --db orders.duckdb

Errors

Engine failures come back as tool-level errors (isError: true) carrying the engine's structured JSON — the same stable code, candidates, and suggested_retry fields as the REST API and CLI. A near-miss name comes back with the valid spellings:

{"error": {"code": "unknown_metric", "message": "unknown metric \"revenu\"",
           "metrics": ["revenu"],
           "candidates": ["revenue", "order_count", "unique_customers",
                          "avg_order_value", "total_margin"]}}

and where the fix is a change in shape rather than a name, suggested_retry spells it out in prose (it is a string, not a query object):

{"error": {"code": "grain_on_non_time_dimension",
           "message": "orders.status is not a time dimension; a grain cannot be applied",
           "suggested_retry": "drop the :grain suffix or mark the field with dimension.is_time: true"}}

Malformed tool arguments are rejected one level earlier, by the tool schema itself — {"grain": "fortnight"} returns a plain-text deserialization error naming the valid variants (day, week, month, quarter, year) rather than an {"error": …} body.

Agents should read the error and correct the query rather than retrying unchanged; the server's MCP instructions say so too.

Trying it interactively

npx @modelcontextprotocol/inspector \
  dosi-server --model $DOSI_EXAMPLES/orders/model.yaml \
    --db orders.duckdb --mcp-stdio

Where to next