跳转至

用 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 服务要在真实数据库上执行,先准备一个:

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

别跳过这步

下一步不带 --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

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

这里的 global 是字面意思——Codex 把 MCP 服务记在 ~/.codex/config.toml,CLI、IDE 插件、ChatGPT 桌面端共用一份,没有项目级 .mcp.json 那样的东西。上面这条命令写入 的是:

~/.codex/config.toml
[mcp_servers.dosi]
url = "http://127.0.0.1:8081/mcp"

codex mcp listcodex 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'.
~/.codex/config.toml
[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),配置里写变量名, 而不是把密钥贴进去:

$ 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 每次请求都从自己的环境里读这个变量,正好对上 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 工具, 到这里直接判拒,而不是去问。给这个服务一个长期授权:

~/.codex/config.toml
[mcp_servers.dosi]
url = "http://127.0.0.1:8081/mcp"
default_tools_approval_mode = "approve"

可选值有四个:autopromptwritesapprove,非交互场景下只有 approve 能放行:autowrites 一样会被判拒——哪怕是 list_metrics 这种只读工具; prompt 是弹窗询问,而 codex exec 没法回答。想让发现类工具自由跑、执行类工具仍 受监管,用按工具覆盖:

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

配置键先验一下再信

当前 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——两个列名都不存在,真实的列 是 statusamount。而工具全程都连着、也可用。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 在它之后再过一遍。一个只读 分析场景的配置:

~/.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"]

直接问 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)。

下一步去哪

  • 在 Agent 里使用 Dosi


    传输方式、鉴权、只读部署、完整提示词片段,以及一张排查表。

  • 接一个数仓


    把示例 DuckDB 文件换成 Postgres、Snowflake、StarRocks 等等。

  • MCP 参考


    十个工具、各自的入参形状、协议,以及错误契约。