Ask questions with Codex¶
In this walkthrough you'll register Dosi as an MCP server in Codex, ask questions in plain English, and watch the agent answer them through your metrics instead of writing SQL 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;
codex --version prints 0.144 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.
Codex differs from Claude Code in two ways that decide whether any of this
works, and both come before your first question: MCP tool calls are denied by
default in codex exec, and Codex will not reach for a metric tool on a
plain data question unless you tell it to. Steps 4 and 5 are those two settings.
Skip either and you get a confident answer built from invented SQL.
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 Codex¶
"Global" is literal — Codex keeps MCP servers in ~/.codex/config.toml, shared
by the CLI, the IDE extension, and the ChatGPT desktop app. There is no
project-scoped .mcp.json equivalent. The command above writes:
codex mcp list and codex mcp get read that file back:
$ codex mcp list
Name Url Bearer Token Env Var Status Auth
dosi http://127.0.0.1:8081/mcp - enabled Unsupported
$ codex mcp get dosi
dosi
enabled: true
transport: streamable_http
url: http://127.0.0.1:8081/mcp
bearer_token_env_var: -
http_headers: -
env_http_headers: -
remove: codex mcp remove dosi
codex mcp list does not health-check
Unlike claude mcp list, it reports what is configured, not what is
reachable — enabled here means "not disabled in config". A stopped
server looks identical. The curl in Step 2 is your connectivity test.
(Auth: Unsupported refers to OAuth discovery, not to bearer tokens, which
work fine — see below.)
For stdio instead, put the server command after -- and Codex spawns it
itself — no port, no long-lived process, logs on stderr:
$ codex mcp add dosi-stdio -- \
dosi-server --model $DOSI_EXAMPLES/orders/model.yaml \
--db /abs/path/orders.duckdb --mcp-stdio
Added global MCP server 'dosi-stdio'.
[mcp_servers.dosi-stdio]
command = "dosi-server"
args = ["--model", "/abs/path/model.yaml", "--db", "/abs/path/orders.duckdb", "--mcp-stdio"]
Use absolute paths: Codex spawns the server from whatever directory you happen
to be in. --env KEY=VALUE is accepted on stdio servers only, and
startup_timeout_sec (default 10) is worth raising if your model is large.
If the server needs a token (--auth-token, env DOSI_SERVER_TOKEN), name the
variable rather than pasting the secret into the config file:
[mcp_servers.dosi]
url = "http://127.0.0.1:8081/mcp"
bearer_token_env_var = "DOSI_SERVER_TOKEN"
Codex reads the variable from its own environment on every request, which is
exactly what Dosi's per-request auth wants. http_headers covers anything else
a proxy in front of the server needs.
Step 4 — Let the tool calls actually run¶
Register the server, ask a question headlessly, and every call comes back like this:
$ codex exec --json "Call the dosi list_metrics tool and print the result."
{"type":"item.completed","item":{"type":"mcp_tool_call","server":"dosi","tool":"list_metrics",
"result":null,"error":{"message":"user cancelled MCP tool call"},"status":"failed"}}
Nobody cancelled anything. codex exec runs with approval policy never, and
an MCP tool that would otherwise prompt for approval gets denied instead of
asked. Grant the server standing approval:
[mcp_servers.dosi]
url = "http://127.0.0.1:8081/mcp"
default_tools_approval_mode = "approve"
The four accepted values are auto, prompt, writes, and approve, and only
approve clears the gate in a non-interactive run: auto and writes both
still come back cancelled — even for a read-only tool like list_metrics — and
prompt asks, which codex exec has no way to answer. Per-tool overrides let
you keep the executing tool supervised while discovery runs freely:
Check a config key before you trust it
codex exec --strict-config fails on keys this Codex version doesn't know,
and an invalid value prints the accepted set:
unknown variant 'bogus', expected one of 'auto', 'prompt', 'writes', 'approve'.
Step 5 — Tell Codex to prefer the metrics¶
With the server registered and approvals granted, ask a plain data question and Codex still does not touch it:
$ codex exec "What is revenue by order status?" -s read-only
I don't have access to the orders dataset in this workspace. Please connect or
upload the data and identify the orders table; I'll calculate revenue by status.
Typical query:
SELECT order_status, SUM(order_total) AS revenue
FROM orders GROUP BY order_status ORDER BY revenue DESC;
It searched the filesystem, found nothing, and offered SQL over two columns
that do not exist — the real ones are status and amount. The tools were
connected and available the whole time. Codex leads with its shell, so the
rules file is not a refinement here, it is the wiring. Put this in 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. Codex
reads AGENTS.md from the project root and up the directory tree.
Step 6 — Ask your first question¶
Same command, same question, with the rules file in place:
$ codex exec "What is revenue by order status?" -s read-only
mcp: dosi/list_metrics (completed)
mcp: dosi/list_dimensions (completed)
mcp: dosi/compile_sql (completed)
The governed query compiles to:
SELECT orders.status AS status, SUM(orders.amount) AS revenue
FROM main.orders AS orders
GROUP BY orders.status
ORDER BY revenue DESC
I'm running that exact metric query now.
mcp: dosi/run_query (completed)
Revenue by order status:
| Order status | Revenue |
|---|---:|
| Completed | $350.00 |
| Cancelled | $100.00 |
Metric: `revenue` ("Total order amount"), grouped by `orders.status`. No filters applied.
Check it against the seed: completed orders are 100 + 50 + 80 + 120 = 350, cancelled are 30 + 70 = 100. Correct — and notice the agent named the definition it used and stated that no filter was applied, because the definition came from the model rather than from its own guess.
-s read-only is the right sandbox for this work: Dosi does the database work
out of process, so the agent needs no write access to anything.
Step 7 — Read the trace¶
--json prints the calls as JSONL. Behind that one answer:
$ codex exec --json "What is revenue by order status?" -s read-only | jq -c 'select(.item.type=="mcp_tool_call") | {tool: .item.tool, args: .item.arguments}'
{"tool":"list_metrics","args":{}}
{"tool":"list_dimensions","args":{}}
{"tool":"compile_sql","args":{"metrics":["revenue"],"group_by":[{"field":"orders.status"}],"order_by":[{"key":"revenue","desc":true}],"pretty":true}}
{"tool":"run_query","args":{"metrics":["revenue"],"group_by":[{"field":"orders.status"}],"order_by":[{"key":"revenue","desc":true}]}}
Discover, discover, preview, execute. 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.
Errors are structured so a wrong guess costs one turn, not a fallback to
hand-written SQL: unknown_metric carries a candidates list, and shape errors
carry suggested_retry. This trace is a real one, captured when two servers
held the same DuckDB file:
mcp: dosi/run_query (failed)
The local database is temporarily locked by another Dosi server process. I'm
checking whether an available governed connection can execute the same metric.
mcp: dosi/list_connections (completed)
mcp: dosi/run_query (failed)
I couldn't retrieve the total because the governed database is locked.
Metric: `order_count`. No alternate connection is available, so I can't report
an auditable number yet.
It read the error, looked for a legitimate alternative, found none, and refused to make a number up. The full error contract is in the MCP reference; a worked example of a metric spanning two tables — and why the semantic layer answers 360 where a hand-written join answers 270 — is in Claude Code Step 6.
Step 8 — Lock the surface¶
enabled_tools narrows what the model can see, and disabled_tools is applied
after it. A read-only analyst deployment:
[mcp_servers.dosi]
url = "http://127.0.0.1:8081/mcp"
default_tools_approval_mode = "approve"
enabled_tools = ["list_metrics", "list_dimensions", "compile_sql", "run_query"]
Ask the agent what it has and the narrowing is visible — note the
mcp__<server>__<tool> naming Codex uses internally:
$ codex exec -c 'mcp_servers.dosi.enabled_tools=["list_metrics","list_dimensions","compile_sql"]' \
"Run the dosi run_query tool. If it is not available, list the dosi tools you do have."
The `dosi run_query` tool is not available. The exact `dosi` tools I have are:
- `mcp__dosi__compile_sql`
- `mcp__dosi__list_dimensions`
- `mcp__dosi__list_metrics`
Dropping run_query gives you a compile-only agent that can show SQL but never
execute it. Enforce the same rule server-side with --disable-execute when the
client is not yours to trust.
Step 9 — Measure it yourself¶
The turn.completed event carries the cost of the answer:
$ codex exec --json "What is revenue by order status?" -s read-only \
| jq -c 'select(.type=="turn.completed") | .usage'
{"input_tokens":107962,"cached_input_tokens":92928,"output_tokens":421,"reasoning_output_tokens":44}
(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. -o last-message.txt,
--output-schema, and -c overrides are the flags that make such a harness
scriptable.
What you learned¶
- Registered Dosi as an MCP server over HTTP and over stdio, and learned
that
codex mcp listreports configuration, not reachability. - Fixed the two headless blockers:
default_tools_approval_mode = "approve", without which every call returns "user cancelled", and anAGENTS.mdrule, without which Codex answers from invented SQL. - 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, and watched a structured error produce a refusal instead of a fabricated number.
- Locked the surface with
enabled_tools, 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.