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:
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¶
-
Transports, tool allowlists, and the prompt rules that keep an agent on the metrics.
-
Register the server, ask a question in English, read the tool trace — for Claude Code, Codex, or OpenCode.