跳转至

用 Claude Code 提问

本页把 Dosi 注册成 Claude Code 的 MCP 服务,然 后用自然语言提问,看着 Agent 通过指标作答,而不是自己猜 SQL。所有数字都小到 可以手算核对。

大约 15 分钟,会消耗少量模型额度。

前置条件

已经安装好 Dosi,有 dosi-server 可执行文件; claude --version 输出 2.1 或更新;duckdb CLI 在 PATH 上。 $DOSI_EXAMPLES 指自带示例模型所在的位置——用安装脚本的话是 ~/.local/share/dosi/examples

用的是自带的 orders 模型:三张表、五个指标、六行数据,和 第一个指标查询那篇是同一个。

第 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 步 —— 注册到 Claude Code

$ claude mcp add --transport http dosi http://127.0.0.1:8081/mcp
Added HTTP MCP server dosi with URL: http://127.0.0.1:8081/mcp to local config

确认客户端真的连得上——claude mcp list 会对每个服务做健康检查:

$ claude mcp list
dosi: http://127.0.0.1:8081/mcp (HTTP) - ✔ Connected

$ claude mcp get dosi
dosi:
  Scope: Local config (private to you in this project)
  Status: ✔ Connected
  Type: http
  URL: http://127.0.0.1:8081/mcp

服务需要 token 时(--auth-token,环境变量 DOSI_SERVER_TOKEN),注册时一并传 入——每个请求都会校验:

claude mcp add --transport http dosi http://127.0.0.1:8081/mcp \
  --header "Authorization: Bearer $DOSI_SERVER_TOKEN"

第 4 步 —— 问第一个问题

交互式的话直接把问题打出来即可。这里用 headless 形式,输出可复现:

$ claude -p "What is revenue by order status?" \
    --allowedTools "mcp__dosi__list_metrics,mcp__dosi__list_dimensions,mcp__dosi__compile_sql,mcp__dosi__run_query"
Revenue by order status (`revenue` = SUM of `orders.amount`, from the server's
local DuckDB):

| status    | revenue |
|-----------|--------:|
| completed |  350.00 |
| cancelled |  100.00 |

Total 450.00 across 2 statuses. Note this includes cancelled orders — if you
want completed-only, I can re-run with a `status = 'completed'` filter.

对着种子数据核一遍:completed 是 100 + 50 + 80 + 120 = 350,cancelled 是 30 + 70 = 100。对得上——注意 Agent 还说明了它用的口径,因为口径来自模型, 不是它自己猜的。

--allowedTools 列出的就是 Dosi 的工具面,命名为 mcp__<server>__<tool>。明确 写出来,意味着 Agent 没有 shell、也无法手写 SQL:它答出来的任何东西,都是走你的 指标算的。

第 5 步 —— 读懂轨迹

那一个回答背后是四次工具调用。整套机制就在这里:

1. list_metrics —— 能问什么(只保留关键字段):

[
  {"name": "revenue",          "kind": "aggregate",  "datasets": ["orders"],
   "description": "Total order amount"},
  {"name": "order_count",      "kind": "aggregate",  "datasets": ["orders"],
   "description": "Number of orders"},
  {"name": "unique_customers", "kind": "aggregate",  "datasets": ["orders"],
   "description": "Distinct purchasing customers"},
  {"name": "avg_order_value",  "kind": "ratio",      "datasets": ["orders"],
   "description": "Revenue per order (ratio)"},
  {"name": "total_margin",     "kind": "expression", "datasets": ["orders", "products"],
   "description": "Revenue minus cost (expression over two aggregates)"}
]

2. list_dimensions —— 能按什么拆。字段写成 dataset.field,时间维度带标记 (十二行里取三行):

[{"name": "orders.status",     "is_time": false, "time_granularity": null},
 {"name": "orders.order_date", "is_time": true,  "time_granularity": null},
 {"name": "customers.region",  "is_time": false, "time_granularity": null}]

3. compile_sql —— 执行之前,先看它将要跑的 SQL:

{"dialect": "duckdb",
 "sql": "SELECT orders.status AS status, SUM(orders.amount) AS revenue FROM main.orders AS orders GROUP BY orders.status"}

4. run_query —— 拿到行:

{"dialect": "duckdb",
 "sql": "SELECT orders.status AS status, SUM(orders.amount) AS revenue FROM main.orders AS orders GROUP BY orders.status LIMIT 500",
 "columns": ["status", "revenue"],
 "rows": [{"status": "completed", "revenue": 350.0},
          {"status": "cancelled", "revenue": 100.0}],
 "row_count": 2, "row_limit_applied": 500}

Agent 只说了一个指标名和一个维度。它没写 SUM、没选表、没挑连接方式、也没编过 滤条件。注意 row_limit_applied:结果要进 LLM 上下文,所以 run_query 默认最多 返回 500 行(上限 5000),并且总会报出实际生效的上限——Agent 据此能分辨截断页和 完整结果。

第 6 步 —— 一个跨两张表的指标

问一下总毛利:

$ claude -p "What is our total gross margin?" \
    --allowedTools "mcp__dosi__list_metrics,mcp__dosi__run_query"
**Total margin: 360.0** (on revenue of 450.0 — an 80% margin ratio).
[…]

total_margin 的定义是 SUM(orders.amount) - SUM(products.unit_cost):两个聚 合,分别落在两张表上。Dosi 先各自聚合,再合并:

WITH m0 AS (SELECT SUM(orders.amount)      AS orders_amount_sum      FROM main.orders   AS orders),
     m1 AS (SELECT SUM(products.unit_cost) AS products_unit_cost_sum FROM main.products AS products)
SELECT m0.orders_amount_sum - m1.products_unit_cost_sum AS total_margin
FROM m0 CROSS JOIN m1

450 − (20 + 10 + 60) = 360,与定义完全一致。同样的想法手写成连接—— orders JOIN products 之后 SUM(o.amount - p.unit_cost)——得到 270:连接让 每个商品行按订单数重复,成本侧从 60 被放大到 180。引擎的计划要挡的就是这种漂 移:无论指标定义成什么,加一个维度、加第二张表,都不该悄悄改变它的值。

引擎保证的是定义,不是业务逻辑

Dosi 保证你在任何数仓上都能可复现地拿到这个模型的 total_margin。至于这 个定义在业务上对不对,那是建模者的判断——而本示例的定义是刻意简化的(它把整 个商品目录的单位成本相加,不按销量加权,因此并不是 COGS)。Claude 常常会主 动指出这一点,而这正说明问题:SQL 是可见的,所以定义可评审,不会埋在某个人 的临时查询里。参见 语义层解决不了什么

第 7 步 —— 看它自我纠正

错误是刻意做成结构化的。问一个不存在的指标:

$ claude -p "Show me revenu by month" \
    --allowedTools "mcp__dosi__list_metrics,mcp__dosi__list_dimensions,mcp__dosi__run_query"
**Revenue by month** (metric `revenue` = `SUM(orders.amount)`, grouped on
`orders.order_date` at month grain): 2024-01 150.00, 2024-02 230.00,
2024-03 70.00. Total 450.00 across 3 months.

1 月 = 100 + 50 = 150,2 月 = 80 + 30 + 120 = 230,3 月 = 70——拼错了 也答对了。多数时候 Agent 根本碰不到这个错误:它会先列指标,从那里读到正确拼写。 真猜错的时候,工具调用会失败得很响亮、也很有用:

{"error": {"code": "unknown_metric", "message": "unknown metric \"revenu\"",
           "metrics": ["revenu"],
           "candidates": ["revenue", "order_count", "unique_customers",
                          "avg_order_value", "total_margin"]}}

candidates 就是整个纠正机制:Agent 不会盲目重试,也不会退回去自己编 SQL,而是 挑一个确实存在的名字。要改的不是名字而是查询形状时,错误里给的是散文式指引:

{"error": {"code": "grain_on_non_time_dimension",
           "message": "orders.status is not a time dimension; a grain cannot be applied",
           "suggested_retry": "drop the :grain suffix or mark the field with dimension.is_time: true"}}

第 8 步 —— 把配置提交给团队

自己手动注册没问题;把 .mcp.json 提交进仓库,则是让所有 clone 的人都拿到同一套 工具。放在项目根目录:

.mcp.json
{
  "mcpServers": {
    "dosi": {
      "command": "dosi-server",
      "args": ["--model", "examples/orders/model.yaml",
               "--db", "orders.duckdb", "--mcp-stdio"]
    }
  }
}

--mcp-stdio 让 Claude Code 自己拉起服务——不占端口、没有长驻进程、日志走 stderr。相对路径按项目根目录解析,所以把示例模型复制到旁边(或者用绝对路径),并 先按第 1 步灌好 orders.duckdb。项目级的服务首次使用需要批准:

$ claude mcp list
dosi: dosi-server --model examples/orders/model.yaml --db orders.duckdb --mcp-stdio - ⏸ Pending approval (run `claude` to approve)

起一次 claude 批准掉,之后两种传输方式的表现完全一致。

第 9 步 —— 让 Claude 优先走指标

对一个同时Bash 的 Agent,光有工具拦不住它手写 SQL,提示词规则可以。把这 段加进项目的 CLAUDE.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

第 10 步 —— 自己量一量

--output-format json 会把回答包在一个信封里,里面带着这次回答的成本:

$ claude -p "What is revenue by order status?" \
    --mcp-config .mcp.json \
    --allowedTools "mcp__dosi__list_metrics,mcp__dosi__run_query" \
    --max-turns 8 --output-format json | jq '{num_turns, duration_ms, total_cost_usd}'
{
  "num_turns": 5,
  "duration_ms": 12206,
  "total_cost_usd": 0.1271
}

(你的数字会不一样——模型、机器、措辞都会影响。)

这就是跟裸 schema NL2SQL 做 A/B 的原材料:同一批问题、同一个数据库,一臂给 MCP 工具,一臂只给 SQL shell,然后比正确率、轮次和 token。我们自己的那一版——连同入 库可评审的基线提示词——见基准测试。有个预期要先摆正:在这个 fixture 上,走指标的一臂赢在正确率输在上下文体积,因为十个工具的定义比 三张表的 schema dump 还大,见代价是什么--mcp-config(替代已注册的服务)、--max-turns--output-format stream-json 是把这类 harness 脚本化时会用到的参数。

你学到了什么

  • 用 HTTP 和 stdio 两种方式注册了 Dosi,并用 claude mcp list 做了健康检查。
  • 用自然语言提问,拿到由受治理的指标定义算出来的答案,并逐个对着种子数据核过。
  • 读懂了轨迹:发现 → 预览 → 执行,全程没有手写 SQL。
  • total_margin看到了差别(360,不是 270),也看到 Agent 一轮就从错名 字里纠正回来。
  • --allowedTools 收紧了权限,并知道了只读部署的另一种做法 (--disable-execute)。

下一步

  • 在 Agent 里使用 Dosi


    传输方式、鉴权、只读部署、完整的提示词 snippet,以及排错清单。

  • 连接数仓


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

  • MCP 参考


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