Ask questions with OpenCode¶
In this walkthrough you'll register Dosi as an MCP server in OpenCode, 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 — the transcripts below were captured on DeepSeek V4 Flash, and the whole page cost a few tenths of a cent.
Prerequisites
You've installed Dosi and have a dosi-server binary;
opencode --version prints 1.18 or newer
(curl -fsSL https://opencode.ai/install | bash) with a model provider
configured; 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 OpenCode¶
OpenCode reads opencode.json from your project root, so registration is the
team config — commit this file and everyone who clones the repo gets the same
tools:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"dosi": {
"type": "remote",
"url": "http://127.0.0.1:8081/mcp",
"enabled": true
}
}
}
opencode mcp list health-checks every server, so it tells you whether the
thing is actually reachable:
For stdio, use "type": "local" and OpenCode spawns the server itself — no
port, no long-lived process, logs on stderr:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"dosi": {
"type": "local",
"command": ["dosi-server",
"--model", "/abs/path/model.yaml",
"--db", "/abs/path/orders.duckdb",
"--mcp-stdio"]
}
}
}
command is a single array, not a command / args pair. cwd,
environment, and timeout (milliseconds, default 5000, for the tool-list
fetch) are the other keys worth knowing.
If the server needs a token (--auth-token, env DOSI_SERVER_TOKEN), keep the
secret out of the committed file with {env:VAR} substitution:
{
"mcp": {
"dosi": {
"type": "remote",
"url": "http://127.0.0.1:8081/mcp",
"headers": { "Authorization": "Bearer {env:DOSI_SERVER_TOKEN}" }
}
}
}
That is the difference between this and a hand-written .mcp.json: the config
is committable as-is. opencode mcp list distinguishes a rejected token from an
unreachable server, so you can tell the two apart at a glance:
$ unset DOSI_SERVER_TOKEN && opencode mcp list
● ⚠ dosi needs authentication
│ http://127.0.0.1:8081/mcp
Configs merge rather than replace, in the order
~/.config/opencode/opencode.json → $OPENCODE_CONFIG → project
opencode.json, so a personal global config and a committed project config can
coexist — put the server in the project file and personal preferences in the
global one.
Step 4 — Ask your first question¶
Interactively you'd just type the question. Here it is headless, so the output is reproducible:
$ opencode run -m openrouter/deepseek/deepseek-v4-flash "What is revenue by order status?"
⚙ dosi_list_metrics
⚙ dosi_list_dimensions
⚙ dosi_compile_sql {"metrics":["revenue"],"group_by":[{"field":"orders.status"}]}
⚙ dosi_run_query {"metrics":["revenue"],"group_by":[{"field":"orders.status"}]}
Revenue by `orders.status`:
| status | revenue |
|-----------|--------:|
| completed | 350.00 |
| cancelled | 100.00 |
**Metric**: `revenue` — total order amount (SUM of `orders.amount`), grouped by `orders.status`.
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. Worth dwelling on: that is a small, cheap model getting a governed answer right first try, because it only had to pick a metric name.
OpenCode names MCP tools <server>_<tool> — dosi_run_query, not
mcp__dosi__run_query.
Step 5 — Read the trace¶
--format json emits the events, so you can see the whole mechanism:
$ opencode run --format json -m openrouter/deepseek/deepseek-v4-flash \
"What is our total gross margin?" \
| jq -c 'select(.type=="tool_use") | .part.tool'
"dosi_list_metrics"
"dosi_describe_metric"
"dosi_compile_sql"
"dosi_run_query"
Discover, check the definition, preview, execute — and the answer is
$360.00. That number is the whole argument for a semantic layer:
total_margin is defined as SUM(orders.amount) - SUM(products.unit_cost),
an expression over two aggregates on two different tables, and Dosi aggregates
each side before combining them. Compile the same idea by hand as a join and
you get 270, because the join repeats each product once per order and inflates
the cost side. The derivation is worked through in
Claude Code Step 6.
Errors are structured on purpose: unknown_metric comes back with a
candidates list and shape errors carry suggested_retry, so a wrong guess
costs one turn instead of a fallback to hand-written SQL. The full contract is
in the MCP reference. Note also row_limit_applied on every
run_query result: results land in an LLM context, so rows are capped at 500 by
default (5000 maximum) and the cap is always reported.
Step 6 — Tell OpenCode to prefer the metrics¶
Unlike some clients, OpenCode reaches for the metric tools on a plain data
question without being told. What the rules file buys you is the rest of the
contract — the SQL preview before execution, and an auditable answer that names
its metric. Without it, the same question runs list_metrics →
list_dimensions → run_query and prints a bare table; with it, compile_sql
appears in between and the answer carries its provenance.
Add this to your project's AGENTS.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.
OpenCode reads AGENTS.md from the project root and up the tree, then
~/.config/opencode/AGENTS.md, and falls back to CLAUDE.md — so a repo that
already followed the Claude Code walkthrough needs no second
rules file.
Step 7 — Lock the surface¶
Tools don't stop an agent that also has a shell from writing SQL by hand.
The permission key does — it removes the tool rather than asking about it:
Ask for a shell command afterwards and the model is told, precisely, what it actually has:
$ opencode run -m openrouter/deepseek/deepseek-v4-flash \
"Run 'echo hello' in the shell, then tell me revenue by order status."
✗ Invalid Tool
Model tried to call unavailable tool 'bash'. Available tools: dosi_compile_sql,
dosi_describe_metric, dosi_explain_query, dosi_get_capabilities,
dosi_list_connections, dosi_list_datasets, dosi_list_dimensions,
dosi_list_metrics, dosi_run_query, dosi_validate_model, glob, grep, read, ...
⚙ dosi_list_metrics
⚙ dosi_list_dimensions
⚙ dosi_compile_sql
⚙ dosi_run_query
I can't run shell commands in this environment (no bash tool available), but
here's the revenue data:
| status | revenue |
|-----------|--------:|
| completed | 350.0 |
| cancelled | 100.0 |
The metric path still works; the hand-written-SQL path is gone. Values are
allow, ask, or deny, and rules accept wildcards with the last match
winning. Enforce the same rule server-side with --disable-execute when the
client is not yours to trust.
Step 8 — Measure it yourself¶
Every step_finish event carries token counts and cost:
$ opencode run --format json -m openrouter/deepseek/deepseek-v4-flash \
"What is our total gross margin?" \
| jq -s '{tools: [.[] | select(.type=="tool_use") | .part.tool],
cost: ([.[] | select(.part.cost != null) | .part.cost] | add)}'
{
"tools": ["dosi_list_metrics", "dosi_describe_metric", "dosi_compile_sql", "dosi_run_query"],
"cost": 0.0019781874
}
(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. --session, --agent, and
OPENCODE_CONFIG are the knobs that make such a harness scriptable.
What you learned¶
- Registered Dosi as an MCP server over HTTP and over stdio in a single
committed
opencode.json, and health-checked it withopencode mcp list. - Kept the token out of the repo with
{env:DOSI_SERVER_TOKEN}substitution, and saw the client distinguish "needs authentication" from "unreachable". - Asked questions in English and got answers computed from governed metric definitions — verified by hand against the seed data, on a small cheap model.
- Read the trace: discover → preview → execute, with no hand-written SQL
anywhere, and saw
total_margincome back as 360 rather than 270. - Locked the surface with
permission, and learned the server-side 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.