Ask questions with Claude Code¶
In this walkthrough you'll register Dosi as an MCP server in Claude Code, ask questions in plain English, and watch the agent answer them through your metrics instead of guessing SQL. Every number is small enough to check by hand.
It takes about 15 minutes and spends a small amount of model quota.
Prerequisites
You've installed Dosi and have a dosi-server binary;
claude --version prints 2.1 or newer; the duckdb CLI is on your PATH.
$DOSI_EXAMPLES stands for wherever the bundled example models landed —
~/.local/share/dosi/examples if you used the install script.
We'll use the bundled orders model — three tables, five metrics, six rows
of data — the same model as the first metric query
tutorial.
Step 1 — Seed a database¶
The MCP server executes against a real database, so give it one:
Don't skip this
Without --db in the next step, the server's local DuckDB is in-memory
and unseeded. compile_sql will work and run_query will fail with a
missing-table error — the single most common first-run confusion.
Step 2 — Start the MCP server¶
$ dosi-server --model $DOSI_EXAMPLES/orders/model.yaml --db orders.duckdb
INFO dosi_server::bootstrap: model compiled model=.../orders/model.yaml mode="datus" datasets=3 metrics=5
INFO dosi_server: listening on http://127.0.0.1:8081
MCP is mounted at POST /mcp — at the top level, not under /v1. Sanity
check it without an agent in the loop:
$ 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"}' | jq '.result.tools | length'
10
Use a prebuilt binary, not cargo run: under an MCP client, a compile makes
the first tool call look like a hang.
Step 3 — Register it with Claude Code¶
$ claude mcp add --transport http dosi http://127.0.0.1:8081/mcp
Added HTTP MCP server dosi with URL: http://127.0.0.1:8081/mcp to local config
Confirm the client can actually reach it — claude mcp list health-checks
every server:
$ claude mcp list
dosi: http://127.0.0.1:8081/mcp (HTTP) - ✔ Connected
$ claude mcp get dosi
dosi:
Scope: Local config (private to you in this project)
Status: ✔ Connected
Type: http
URL: http://127.0.0.1:8081/mcp
If the server needs a token (--auth-token, env DOSI_SERVER_TOKEN), pass it
on registration — it's verified on every request:
claude mcp add --transport http dosi http://127.0.0.1:8081/mcp \
--header "Authorization: Bearer $DOSI_SERVER_TOKEN"
Step 4 — Ask your first question¶
Interactively you'd just type the question. Here it is headless, so the output is reproducible:
$ claude -p "What is revenue by order status?" \
--allowedTools "mcp__dosi__list_metrics,mcp__dosi__list_dimensions,mcp__dosi__compile_sql,mcp__dosi__run_query"
Revenue by order status (`revenue` = SUM of `orders.amount`, from the server's
local DuckDB):
| status | revenue |
|-----------|--------:|
| completed | 350.00 |
| cancelled | 100.00 |
Total 450.00 across 2 statuses. Note this includes cancelled orders — if you
want completed-only, I can re-run with a `status = 'completed'` filter.
Check it against the seed: completed orders are 100 + 50 + 80 + 120 = 350, cancelled are 30 + 70 = 100. Correct — and notice the agent explained the definition it used, because the definition came from the model rather than from its own guess.
The --allowedTools list is the Dosi tool surface, named
mcp__<server>__<tool>. Naming it explicitly means the agent has no shell and
no ability to write SQL by hand; whatever it answers, it answered through your
metrics.
Step 5 — Read the trace¶
Behind that one answer are four tool calls. This is the whole mechanism:
1. list_metrics — what can be asked (trimmed to the interesting fields):
[
{"name": "revenue", "kind": "aggregate", "datasets": ["orders"],
"description": "Total order amount"},
{"name": "order_count", "kind": "aggregate", "datasets": ["orders"],
"description": "Number of orders"},
{"name": "unique_customers", "kind": "aggregate", "datasets": ["orders"],
"description": "Distinct purchasing customers"},
{"name": "avg_order_value", "kind": "ratio", "datasets": ["orders"],
"description": "Revenue per order (ratio)"},
{"name": "total_margin", "kind": "expression", "datasets": ["orders", "products"],
"description": "Revenue minus cost (expression over two aggregates)"}
]
2. list_dimensions — what it can be broken down by. Fields are
dataset.field, with time dimensions flagged (three of the twelve rows shown):
[{"name": "orders.status", "is_time": false, "time_granularity": null},
{"name": "orders.order_date", "is_time": true, "time_granularity": null},
{"name": "customers.region", "is_time": false, "time_granularity": null}]
3. compile_sql — the SQL it would run, before running anything:
{"dialect": "duckdb",
"sql": "SELECT orders.status AS status, SUM(orders.amount) AS revenue FROM main.orders AS orders GROUP BY orders.status"}
4. run_query — the rows:
{"dialect": "duckdb",
"sql": "SELECT orders.status AS status, SUM(orders.amount) AS revenue FROM main.orders AS orders GROUP BY orders.status LIMIT 500",
"columns": ["status", "revenue"],
"rows": [{"status": "completed", "revenue": 350.0},
{"status": "cancelled", "revenue": 100.0}],
"row_count": 2, "row_limit_applied": 500}
The agent named a metric and a dimension. It never wrote a SUM, never picked
a table, never chose a join, and never invented a filter. Note
row_limit_applied: results land in an LLM context, so run_query caps rows
at 500 by default (5000 maximum) and always reports the cap — an agent can
tell a truncated page from a complete result.
Step 6 — A metric that spans two tables¶
Ask for total margin:
$ claude -p "What is our total gross margin?" \
--allowedTools "mcp__dosi__list_metrics,mcp__dosi__run_query"
**Total margin: 360.0** (on revenue of 450.0 — an 80% margin ratio).
[…]
total_margin is defined as SUM(orders.amount) - SUM(products.unit_cost) —
an expression over two aggregates living on two different tables. Dosi
aggregates each side before combining them:
WITH m0 AS (SELECT SUM(orders.amount) AS orders_amount_sum FROM main.orders AS orders),
m1 AS (SELECT SUM(products.unit_cost) AS products_unit_cost_sum FROM main.products AS products)
SELECT m0.orders_amount_sum - m1.products_unit_cost_sum AS total_margin
FROM m0 CROSS JOIN m1
450 − (20 + 10 + 60) = 360, exactly what the definition says. Compile the
same idea by hand as a join — SUM(o.amount - p.unit_cost) over
orders JOIN products — and you get 270, because the join repeats each
product once per order and inflates the cost side from 60 to 180. That drift is
what the engine's plan prevents: whatever a metric is defined to mean, adding a
dimension or a second dataset doesn't silently change its value.
The engine guarantees the definition, not the business logic
Dosi guarantees you get this model's total_margin, reproducibly, on any
warehouse. Whether that definition is the right business definition is the
model author's call — and this fixture's is deliberately simplistic (it
subtracts the catalog's unit costs, unweighted by quantity sold, so it
isn't COGS). Claude will often tell you so unprompted, which is the point:
the SQL is visible, so the definition is reviewable instead of buried in
someone's ad-hoc query. See
what a semantic layer does not fix.
Step 7 — Watch it correct itself¶
Errors are structured on purpose. Ask for a metric that doesn't exist:
$ claude -p "Show me revenu by month" \
--allowedTools "mcp__dosi__list_metrics,mcp__dosi__list_dimensions,mcp__dosi__run_query"
**Revenue by month** (metric `revenue` = `SUM(orders.amount)`, grouped on
`orders.order_date` at month grain): 2024-01 150.00, 2024-02 230.00,
2024-03 70.00. Total 450.00 across 3 months.
January = 100 + 50 = 150, February = 80 + 30 + 120 = 230, March = 70 — right, despite the typo. Usually the agent never even trips the error, because it lists the metrics first and reads the correct spelling there. When it does guess wrong, the tool call fails loudly and usefully:
{"error": {"code": "unknown_metric", "message": "unknown metric \"revenu\"",
"metrics": ["revenu"],
"candidates": ["revenue", "order_count", "unique_customers",
"avg_order_value", "total_margin"]}}
candidates is the whole recovery mechanism: the agent doesn't retry blindly or
fall back to inventing SQL, it picks a name that exists. Errors that need a
change of shape rather than a name carry prose guidance instead:
{"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"}}
Step 8 — Commit the config for your team¶
Registering by hand is fine for you; a committed .mcp.json gives everyone who
clones your repo the same tools. Drop this at your project root:
{
"mcpServers": {
"dosi": {
"command": "dosi-server",
"args": ["--model", "examples/orders/model.yaml",
"--db", "orders.duckdb", "--mcp-stdio"]
}
}
}
--mcp-stdio makes Claude Code spawn the server itself — no port, no
long-lived process, logs on stderr. Relative paths resolve from the project
root, so copy the example model in beside it (or use absolute paths), and seed
orders.duckdb first (Step 1). Project-scoped servers need approval the first
time:
$ claude mcp list
dosi: dosi-server --model examples/orders/model.yaml --db orders.duckdb --mcp-stdio - ⏸ Pending approval (run `claude` to approve)
Start claude once and approve it, and both transports behave identically from
then on.
Step 9 — Tell Claude to prefer the metrics¶
Tools don't stop an agent that also has Bash from writing SQL by hand.
Prompt rules do. Add this to your project's CLAUDE.md:
## Data questions
Answer data questions through the `dosi` MCP tools, never by writing SQL
against raw tables.
1. Discover: `list_metrics`, then `list_dimensions` for the breakdown fields.
2. Preview: `compile_sql` before executing, and show me the SQL.
3. Execute: `run_query`.
Never invent a metric name — read the error's `candidates` and retry. Never
re-derive a metric by hand. If no metric covers the question, say so.
The full version, with the reasoning behind each rule, is in Using Dosi in your agents.
Step 10 — Measure it yourself¶
--output-format json wraps the answer in an envelope that carries the cost of
getting it:
$ claude -p "What is revenue by order status?" \
--mcp-config .mcp.json \
--allowedTools "mcp__dosi__list_metrics,mcp__dosi__run_query" \
--max-turns 8 --output-format json | jq '{num_turns, duration_ms, total_cost_usd}'
{
"num_turns": 5,
"duration_ms": 12206,
"total_cost_usd": 0.1271
}
(Your numbers will differ — model, machine, and phrasing all move them.)
That's the raw material for an A/B against raw-schema NL2SQL: same questions,
same database, one arm with the MCP tools and one with only a SQL shell, then
compare correctness, turns, and tokens. Ours — including the baseline prompt,
so you can audit whether the comparison is fair — is in
Benchmarks. Worth setting expectations: the metric path wins
the correctness comparison on this fixture and loses the context-size one,
because ten tool definitions cost more than a three-table schema dump. See
what it costs. --mcp-config (instead of the registered
server), --max-turns, and --output-format stream-json are the flags that
make such a harness scriptable.
What you learned¶
- Registered Dosi as an MCP server over HTTP and over stdio, and
health-checked it with
claude mcp list. - Asked questions in English and got answers computed from governed metric definitions — verified by hand against the seed data.
- Read the trace: discover → preview → execute, with no hand-written SQL anywhere.
- Saw the difference a semantic layer makes on
total_margin(360, not 270) and saw the agent recover from a bad name in one turn. - Locked the surface with
--allowedTools, and learned the read-only alternative (--disable-execute).
Where to next¶
-
Transports, auth, read-only deployments, the full prompt snippet, and a troubleshooting table.
-
Swap the sample DuckDB file for Postgres, Snowflake, StarRocks, and more.
-
All ten tools, their input shapes, the protocol, and the error contract.