Skip to content

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:

$ 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 Codex

$ codex mcp add dosi --url http://127.0.0.1:8081/mcp
Added global MCP server 'dosi'.

"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/config.toml
[mcp_servers.dosi]
url = "http://127.0.0.1:8081/mcp"

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 reachableenabled 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'.
~/.codex/config.toml
[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:

$ codex mcp add dosi --url http://127.0.0.1:8081/mcp \
    --bearer-token-env-var DOSI_SERVER_TOKEN
~/.codex/config.toml
[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:

~/.codex/config.toml
[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:

[mcp_servers.dosi.tools.run_query]
approval_mode = "prompt"

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:

~/.codex/config.toml
[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 list reports configuration, not reachability.
  • Fixed the two headless blockers: default_tools_approval_mode = "approve", without which every call returns "user cancelled", and an AGENTS.md rule, 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

  • 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.