用 Codex 提问¶
本页把 Dosi 注册成 Codex 的 MCP 服务,然 后用自然语言提问,看着 Agent 通过指标作答,而不是自己手写 SQL。
大约 15 分钟,会消耗少量模型额度。
前置条件
已经安装好 Dosi,有 dosi-server 可执行文件;
codex --version 输出 0.144 或更新;duckdb CLI 在 PATH 上。
$DOSI_EXAMPLES 指自带示例模型所在的位置——用安装脚本的话是
~/.local/share/dosi/examples。
用的是自带的 orders 模型:三张表、五个指标、六行数据,和
第一个指标查询那篇是同一个。
Codex 和 Claude Code 有两点不同,直接决定这一套能不能跑起来,而且都发生在提问之
前:codex exec 里 MCP 工具调用默认被拒;面对一个普通的数据问题,Codex 不会
主动去用指标工具,除非明确要求。第 4、5 步就是这两处设置。少一处,拿到的就是一个
语气笃定、但由臆造 SQL 拼出来的答案。
第 1 步 —— 灌一个数据库¶
MCP 服务要在真实数据库上执行,先准备一个:
别跳过这步
下一步不带 --db 的话,服务用的本地 DuckDB 是内存库,而且没灌数据。
compile_sql 照常能跑,run_query 会报表不存在——首次上手最常见的困惑。
第 2 步 —— 起 MCP 服务¶
$ 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 挂在 POST /mcp——顶层路径,不在 /v1 下面。先不带 Agent 验一下:
$ 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
用预编译好的二进制,别用 cargo run:在 MCP 客户端下面,一次编译会让首个工具调用
看起来像卡死。
第 3 步 —— 注册到 Codex¶
这里的 global 是字面意思——Codex 把 MCP 服务记在 ~/.codex/config.toml,CLI、IDE
插件、ChatGPT 桌面端共用一份,没有项目级 .mcp.json 那样的东西。上面这条命令写入
的是:
codex mcp list 和 codex mcp get 读的就是这个文件:
$ 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 不做健康检查
和 claude mcp list 不一样,它报的是配置状态,不是连通状态——这里的
enabled 只表示没在配置里禁用。服务停了,显示完全一样。连通性靠第 2 步那条
curl 来验。(Auth: Unsupported 说的是 OAuth 发现流程,与 bearer token 无关,
后者可以正常用。)
要走 stdio,把服务命令放在 -- 后面,由 Codex 自己拉起:不占端口、没有常驻进程、
日志走 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"]
路径写绝对的:Codex 从当前所在目录拉起服务,那个目录是哪儿并不确定。
--env KEY=VALUE 只对 stdio 服务有效;模型较大时,startup_timeout_sec(默认 10
秒)值得调大。
服务需要 token 时(--auth-token,环境变量 DOSI_SERVER_TOKEN),配置里写变量名,
而不是把密钥贴进去:
[mcp_servers.dosi]
url = "http://127.0.0.1:8081/mcp"
bearer_token_env_var = "DOSI_SERVER_TOKEN"
Codex 每次请求都从自己的环境里读这个变量,正好对上 Dosi 逐请求校验的鉴权方式。
服务前面还挂了代理、需要别的头,用 http_headers。
第 4 步 —— 先让工具调用真的能跑¶
服务注册好,headless 提一个问题,每次调用都是这样:
$ 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"}}
没有人取消过任何东西。codex exec 的审批策略是 never,本该弹窗确认的 MCP 工具,
到这里直接判拒,而不是去问。给这个服务一个长期授权:
[mcp_servers.dosi]
url = "http://127.0.0.1:8081/mcp"
default_tools_approval_mode = "approve"
可选值有四个:auto、prompt、writes、approve,非交互场景下只有 approve
能放行:auto 和 writes 一样会被判拒——哪怕是 list_metrics 这种只读工具;
prompt 是弹窗询问,而 codex exec 没法回答。想让发现类工具自由跑、执行类工具仍
受监管,用按工具覆盖:
配置键先验一下再信
当前 Codex 版本不认识的键,codex exec --strict-config 会直接报错;值非法时
还会把可选集合打出来:
unknown variant 'bogus', expected one of 'auto', 'prompt', 'writes', 'approve'。
第 5 步 —— 让 Codex 优先走指标¶
服务注册好了、审批也放行了,问一个普通的数据问题,Codex 依然不碰它:
$ 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;
它翻了一遍文件系统,什么也没找到,然后给出一段 SQL——两个列名都不存在,真实的列
是 status 和 amount。而工具全程都连着、也可用。Codex 的默认反应是先上 shell,
所以这里的规则文件不是锦上添花,是接线的一部分。把这段放进项目的 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.
完整版本连同每条规则的理由,在在 Agent 里使用 Dosi。
Codex 从项目根目录开始、沿目录树向上读 AGENTS.md。
第 6 步 —— 问第一个问题¶
同一条命令、同一个问题,这次规则文件已经就位:
$ 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.
对着种子数据核一遍:completed 是 100 + 50 + 80 + 120 = 350,cancelled 是 30 + 70 = 100。对得上——而且 Agent 点明了自己用的口径,也说明没加任何过滤, 因为口径来自模型,不是它自己猜的。
-s read-only 是这类活儿合适的沙箱级别:数据库的事由 Dosi 在进程外做,Agent 不需要
对任何东西有写权限。
第 7 步 —— 读懂轨迹¶
--json 会把调用按 JSONL 打出来。一个回答背后是:
$ 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}]}}
发现、发现、预览、执行。Agent 只报了一个指标名和一个维度名,没写过 SUM,没挑过表,
没选过 JOIN,也没编过过滤条件。
错误是刻意做成结构化的,所以猜错只花一轮,而不是退回去手写 SQL:unknown_metric
会带 candidates 列表,形状类错误带 suggested_retry。下面这段轨迹是真实抓到的,
当时两个服务同时占着同一个 DuckDB 文件:
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.
它读了错误,去找有没有合规的替代路径,没找到,然后拒绝编一个数出来。完整的错误契约 见 MCP 参考;跨两张表的指标是怎么回事、为什么语义层给 360 而手写 JOIN 给 270,见 Claude Code 第 6 步。
第 8 步 —— 收窄工具面¶
enabled_tools 决定模型能看到哪些工具,disabled_tools 在它之后再过一遍。一个只读
分析场景的配置:
[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"]
直接问 Agent 手上有什么,收窄的效果是看得见的——顺带能看到 Codex 内部用的
mcp__<server>__<tool> 命名:
$ 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`
去掉 run_query,得到的就是一个只能出 SQL、不能执行的 Agent。客户端不在自己手里时,
用 --disable-execute 在服务端强制同样的规则。
第 9 步 —— 自己量一遍¶
turn.completed 事件带着这次回答的成本:
$ 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}
(你的数字会不一样——模型、机器、问法都会影响。)
这就是和"裸表结构 NL2SQL"做 A/B 的原始材料:同一批问题、同一个库,一组给 MCP 工具,
另一组只给 SQL shell,然后比正确率、轮数和 token。我们自己的那一份——连基线提示词
一起公开,方便核对这个对比公不公平——在基准测试。预期要摆正:在
这个 fixture 上,指标路线赢在正确率,输在上下文体积,因为十个工具定义比三张表
的 schema dump 更贵,见代价在哪里。-o last-message.txt、
--output-schema 和 -c 覆盖,是让这类 harness 可脚本化的几个开关。
学到了什么¶
- 注册:HTTP 和 stdio 两种方式都接过一遍,也知道了
codex mcp list报的是配置 状态,不是连通状态。 - 补齐 headless 的两处卡点:
default_tools_approval_mode = "approve",没有它每次 调用都返回 "user cancelled";以及一条AGENTS.md规则,没有它 Codex 会拿臆造的 SQL 作答。 - 提问:用自然语言提问,拿到按治理口径算出来的答案,并对着种子数据手算核对过。
- 读轨迹:发现 → 预览 → 执行,全程没有手写 SQL;也看到结构化错误换来的是一次拒答, 而不是一个编出来的数。
- 收窄面:用
enabled_tools限定工具,也知道了服务端的对应做法(--disable-execute)。
下一步去哪¶
-
传输方式、鉴权、只读部署、完整提示词片段,以及一张排查表。
-
把示例 DuckDB 文件换成 Postgres、Snowflake、StarRocks 等等。
-
十个工具、各自的入参形状、协议,以及错误契约。