MCP server¶
dosi-server speaks the Model Context Protocol
natively, so AI agents (Claude Code, datus-agent, 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.
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, beside the REST
API:
cargo run -p dosi-server -- --model fixtures/orders/model.yaml
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"}'
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://localhost: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.
All the usual flags apply (--connections, --db, --disable-execute,
--osi-basic, ...); both transports serve the identical tool set.
Tools¶
| Tool | Input | Returns |
|---|---|---|
list_datasets |
— | datasets: name, source, primary key, fields, time dimensions |
list_metrics |
— | metrics: name, kind, datasets, measures, time dimension, description |
list_dimensions |
— | dataset.field names with time flags and granularities |
describe_metric |
name |
one metric's row; 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} without executing |
explain_query |
metric query | the logical plan as text |
run_query |
metric query + dialect, connection, row_limit |
{dialect, sql, columns, rows, row_count, row_limit_applied} |
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,
time_range, order_by, limit) — the tool schemas are generated from the
same types, so they cannot drift.
Execution safeguards¶
run_query shares the server's execute machinery: the concurrency semaphore
(--max-concurrent-executions), the execute timeout, and --disable-execute
(which turns run_query into a tool-level forbidden error). Because
results land in an LLM context, rows are capped: a query with no limit gets
LIMIT 500, and row_limit clamps at 5000; the applied cap is reported as
row_limit_applied. 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.
Errors¶
Engine failures come back as tool-level errors carrying the engine's
structured JSON — the same stable code, candidates, and
suggested_retry fields as the REST API and CLI:
{"error": {"code": "unknown_metric", "message": "unknown metric \"revenu\"",
"candidates": ["revenue"], "suggested_retry": {"metrics": ["revenue"]}}}
Agents should read the error and correct the query rather than retrying unchanged; the server's MCP instructions say so too.