MCP 实战(四):做一个能查数据库的 MCP,以及工具描述到底有多重要

目录


一、从玩具到真实业务

前两篇的工具是加法和一个假的天气查询。它们能验证链路,但说明不了这套东西有什么用。

这一篇换一个真场景:

让 AI 能用自然语言查数据库。

你问:「9 月份领用数量最多的 5 个备件是什么?分别用在哪个部门?」

它自己完成:看有哪些表 → 看表结构 → 写 SQL → 执行 → 组织答案。

暴露 4 个工具:

工具输入输出作用
list_tables无表名 + 行数 + 中文说明「有什么数据可查」
describe_table表名字段、类型、可空、主键、中文注释「这张表什么结构」
run_querySELECT 语句Markdown 结果表格主角
explain_querySELECT 语句执行计划防止写出全表扫描

跑起来长这样:

用户: 9 月份领用数量最多的 5 个备件是什么?分别用在哪个部门?

  [调用] list_tables {}
  [调用] describe_table {table_name: spare_usage}
  [调用] describe_table {table_name: spare_part}
  [调用] run_query {sql: SELECT p.code, p.name, SUM(u.quantity) AS total ...}
  [结果] | CODE | NAME | TOTAL |
         |---|---|---|
         | MF-0002 | O型密封圈 | 200 |
         ...

Claude: 9 月份领用最多的是 O型密封圈,共 200 个,主要用于装配车间……

注意中间那三行。 模型先看有哪些表,再看表结构,最后才写 SQL。

这个顺序不是我们教的,是工具描述引导出来的。 这是这一篇最重要的内容。


二、为什么 describe_table 比 run_query 更重要

先说一个容易被忽略的事实:

模型看不见你的数据库。

你问它「上个月领用最多的备件」,它不知道:

  • 有 spare_usage 这张表
  • 字段叫 used_at 而不是 create_time
  • part_id 要 JOIN 到 spare_part
  • quantity 是「本次领用数量」还是「剩余数量」

它唯一的信息来源就是你通过工具给它的描述。

所以:

run_query 能不能查对,
九成取决于模型有没有看懂表结构。

所以 describe_table 的质量,比 run_query 本身还重要。

这个结论一开始让我有点意外。我以为难点在「生成正确的 SQL」,实际上难点在「让模型知道该生成什么样的 SQL」。


三、把数据库注释变成模型上下文

那怎么让模型「看懂」表结构?靠数据库自己的注释。

CREATE TABLE spare_usage (...);

COMMENT ON TABLE  spare_usage         IS '备件领用记录,一行代表一次领用';
COMMENT ON COLUMN spare_usage.used_at IS '领用日期';
COMMENT ON COLUMN spare_usage.dept    IS '领用部门。可能为空,表示未登记';

这些注释不是装饰,是给模型看的。

describe_table 通过 JDBC 标准接口把它们读出来:

DatabaseMetaData meta = conn.getMetaData();

try (ResultSet rs = meta.getColumns(null, conn.getSchema(), actualTable, "%")) {
    while (rs.next()) {
        String name   = rs.getString("COLUMN_NAME");
        String type   = rs.getString("TYPE_NAME");
        String remark = rs.getString("REMARKS");   // ← 就是 COMMENT ON 的内容
        ...
    }
}

REMARKS 字段就是 COMMENT ON COLUMN 写进去的内容。

用 DatabaseMetaData 而不是查 information_schema,好处是完全跨数据库——PostgreSQL / MySQL / Oracle 都不用改代码。

3.1 输出给模型长这样

表 SPARE_USAGE —— 备件领用记录,一行代表一次领用

| 字段名 | 类型 | 可空 | 主键 | 说明 |
|---|---|---|---|---|
| ID | INTEGER | 否 | ✓ | — |
| PART_ID | INTEGER | 否 |  | 关联 spare_part.id |
| QUANTITY | INTEGER | 否 |  | 本次领用数量 |
| DEPT | CHARACTER VARYING(32) | 是 |  | 领用部门。可能为空,表示未登记 |
| OPERATOR | CHARACTER VARYING(32) | 是 |  | 领用人 |
| USED_AT | DATE | 否 |  | 领用日期 |

对比一下没有注释的情况:

| DEPT | CHARACTER VARYING(32) | 是 |  | — |

模型只能靠字段名猜 DEPT 是什么。

实操建议:对接真实库时,如果表字段没有注释, 先去补注释,效果比调 prompt 明显得多。 这是投入产出比最高的一步。

3.2 两个实现细节

① 输出用 Markdown 表格而不是 JSON

因为模型读表格比读 JSON 更准,而且 token 更省(不用重复字段名)。

② 表名要做大小写归一

H2 / PostgreSQL 里未加引号的标识符会被转成大写,而模型不知道这个细节(它会传 spare_usage)。所以要先去元数据里找出真实表名,找不到时返回友好错误 + 引导,而不是抛异常:

String actualTable = resolveTableName(conn, table);
if (actualTable == null) {
    return "错误:找不到表 " + table + "。可用 list_tables 查看有哪些表。";
}

四、工具描述工程

这是这一篇最「反直觉」的部分。

看这两句描述:

// list_tables
.description("列出数据库中所有的表,包含表名、行数和中文说明。"
        + "在写任何查询之前,如果不确定有哪些表可用,先调用这个工具。")

// describe_table
.description("查看某张表的完整字段结构,包含字段名、类型、是否可空、主键标记和中文注释。"
        + "写查询前如果不确定字段名或字段含义,必须先调用这个工具——"
        + "不要凭猜测写字段名。")

正是这两句话让模型形成了「先看表 → 再看结构 → 最后写 SQL」的习惯。

如果没有这两句,模型会直接凭直觉写 SQL,然后因为表名或字段名不对而失败。

4.1 写描述的公式

description = 「做什么」 + 「什么时候用」

对比一下:

写法效果
"数据库工具"❌ 模型只能瞎猜
"查询数据库"⚠️ 知道做什么,不知道什么时候用
"执行一条只读 SELECT 查询。只允许 SELECT,任何写操作都会被拒绝。结果最多返回 200 行。建议:先用 list_tables 和 describe_table 确认表结构,再写查询。"✅ 完整

4.2 几个常用句式

场景句式
有前置依赖在描述里指明顺序:「如果……请先调用 X」
有硬约束明确写出来:「只允许 SELECT」「最多返回 200 行」
参数有歧义在参数 description 里解释:「表名,可先用 list_tables 查看有哪些表」
想防某种错误直接说不要做什么:「不要凭猜测写字段名」

最后一条特别有效。模型对「不要……」这类明确禁令的遵从度很高。

4.3 一句值得记住的话

description 是模型判断「要不要用这个工具」的唯一依据。 它看不到你的代码,只有这一句话。

我在搭这个 Demo 时,这几句描述改了好几版才稳定下来。


五、分层架构:换库只改一个文件

┌──────────────────────────────────────────────────────┐
│  DbMcpServer.java      工具定义 + handler             │  ← 跟具体是什么库无关
├──────────────────────────────────────────────────────┤
│  SqlGuard.java          SQL 白名单 / 强制 LIMIT        │  ← 跟具体是什么库无关
│  AuditLog.java          审计日志                       │
│  SchemaIntrospector     表结构自省                     │
├──────────────────────────────────────────────────────┤
│  Db.java               数据源 + 演示数据               │  ← ★只有这层要换
└──────────────────────────────────────────────────────┘

换库只改 Db.java,而且如果之前配了环境变量,连这一层都不用改:

DB_URL=jdbc:postgresql://test-db:5432/sparedb
DB_USER=mcp_readonly
DB_PASSWORD=******

设了 DB_URL 就自动切到外部库模式,代码一行不用动。

为什么能这样? 因为上面两层只依赖两个跟数据库无关的东西:

  • JDBC 标准接口(DatabaseMetaData)
  • 一份 SQL 白名单

5.1 一个真实的踩坑记录

Db.java 里有个看起来多余的常量,值得说一下:

// 管理连接:带 DB_CLOSE_DELAY=-1,让内存库在整个 JVM 生命周期内存活
private static final String ADMIN_URL = "jdbc:h2:mem:sparedb;DB_CLOSE_DELAY=-1";

// ★ 只读连接:注意这里【没有】DB_CLOSE_DELAY
private static final String READONLY_URL = isDemoMode() ? "jdbc:h2:mem:sparedb" : ENV_URL;

我第一版是把 URL 抽成一个常量复用的,结果所有只读连接全部失败:

Admin rights are required for this operation;
SQL statement: SET DB_CLOSE_DELAY -1

原因是 H2 里 SET DB_CLOSE_DELAY 需要管理员权限。它是数据库级设置,由管理员连接设好之后就一直有效,后续连接不需要(也不能)再设一遍。

这个坑的教训是:看似可以复用的配置,有时候恰恰不能复用。


六、测试数据为什么要故意造一张大表

演示库里有 5 张表,其中一张是 5000 行的 device_log。

这不是为了凑数。 它存在的唯一理由是:

只有数据量够大,才能验证这些机制到底有没有生效:
  · LIMIT 有没有被强制追加
  · 结果长度有没有被截断
  · 模型面对 200 行返回时会不会自己加聚合条件

小数据量下这些机制全都是隐形的。

比如你测 SELECT * FROM spare_usage(15 行),返回 15 行——你没法判断是因为「表里就 15 行」还是因为「LIMIT 生效了」。

测 SELECT * FROM device_log(5000 行)返回 200 行 + 一行截断警告,这才是有效验证。

这个思路可以推广:要验证一个防御机制,你得先造出能触发它的场景。 第五篇的攻击用例也是同一个道理。


七、跑起来

7.1 先自检(不需要 API Key)

mvn -q compile exec:exec -DmainClass=com.example.mcp.db.DbAgentMain -DappArgs=check
目标数据源: 内置演示库 jdbc:h2:mem:sparedb(测试数据,可随意折腾)
已连接 spare-db,加载 4 个工具:[list_tables, describe_table, run_query, explain_query]

✅ 自检通过:MCP Server 连接正常,工具已全部加载。

建议第一次先用这个。 它验证的是 MCP 侧,不花钱。免得把一个配置问题误判成「模型不好用」。

7.2 实际提问

set ANTHROPIC_API_KEY=sk-ant-...
mvn -q compile exec:exec -DmainClass=com.example.mcp.db.DbAgentMain

7.3 交互模式

mvn -q compile exec:exec -DmainClass=com.example.mcp.db.DbAgentMain -DappArgs=chat

一次连接,连续提问。

⚠️ 注意每次 ask() 都是从空的对话历史开始,所以没有跨轮记忆。要做多轮对话,需要把 messages 提升为 agent 的字段——这个留给你自己改。


八、常见误区

误区一:以为难点在「生成正确的 SQL」。

真正的难点是让模型知道该生成什么样的 SQL。模型看不见你的库,你不告诉它有哪些表、字段叫什么、业务含义是什么,它只能猜。

所以真正要投入的地方是:表注释、工具描述、以及把数据库报错原样回传给模型让它自我纠正。

误区二:觉得「表注释」是可有可无的文档工作。

在传统开发里,注释是给人看的,写得随意一点问题不大。

在 AI 场景里,注释是模型理解你数据结构的唯一途径。它直接决定了查询能不能写对。这个投入产出比比调 prompt 高得多。

误区三:工具描述写得越简短越好。

恰好相反。描述是模型判断「什么时候用这个工具」的唯一依据。写得含糊,模型要么不用,要么乱用。

写得具体、写明使用时机、写明约束,才是正确方向。

误区四:用一个通用账号连数据库。

代码能跑,但你在安全上什么都没做。数据库账号应该是只读的——这一条在下篇会展开,它是整个安全模型里唯一不可替代的一环。

误区五:测试数据用小表就够。

小表测不出限流、截断这些机制。要验证防御,先造出能触发它的场景。


九、自检清单

  • 为什么 describe_table 的质量比 run_query 更影响最终效果?
  • DatabaseMetaData.getColumns() 里哪个字段是列注释?
  • 为什么输出用 Markdown 表格而不是 JSON?
  • 工具描述的写法公式是什么?
  • 这个架构里,换成公司真实的库要改几个文件?
  • 为什么演示库要造一张 5000 行的大表?小表不行吗?
  • 为什么演示库的只读连接不能复用管理员连接的 URL?

附:延伸阅读

相关资料

资源说明
JDBC DatabaseMetaData 文档本篇章结构自省用的全是这里的标准接口
PostgreSQL COMMENT 语法接真实库时补注释用,H2 / MySQL 语法类似
MCP Inspector调工具描述时用它反复试,比改代码重启快得多
pg_class / information_schema 统计行数生产环境取代 COUNT(*) 拿近似行数的做法

关联笔记


最后更新:2026-10-08