Skip to content

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:

$ duckdb orders.duckdb < $DOSI_EXAMPLES/orders/seed.sql

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:

.mcp.json
{
  "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

  • Using Dosi in your agents


    Transports, auth, read-only deployments, the full prompt snippet, and a troubleshooting table.

  • Connect a warehouse


    Swap the sample DuckDB file for Postgres, Snowflake, StarRocks, and more.

  • MCP reference


    All ten tools, their input shapes, the protocol, and the error contract.