摘要
上一篇我写的是 Docker 部署(《从零到一:基于 Docker 部署 KingbaseES V9R1C10》),结尾埋了个钩子,说想试试 KES MCP Server,让 AI 客户端直接连金仓查库。这篇就把那个钩子收掉。先说实话,我对 MCP 的认知也就刚入门,很多细节是边跑边查的。宿主机装 uv 和 Python 3.12,从 gitee 把 kingbase-mcp 仓库 clone 下来,装依赖。金仓那边建只读账号 ai_mcp,装 sys_hypo 和 sys_stat_statements 两个扩展。服务以 streamable-http 模式跑起来。我写了个几十行的客户端,9 类工具挨个调了一遍。执行计划拿到了,Top 5 慢查询拿到了,7 项健康检查也跑出来了,索引推荐也有。AI 跟金仓之间,就差这层 MCP,而这层 MCP,我自己动手搭出来了。
一、为什么换个玩法
上次我交付的是一个能跑的容器。可真要用起来,还是老流程。Navicat 里看表结构,复制到别处,看执行计划,再开个窗口问 AI。来回倒腾,烦得很。你可能也遇到过,三个工具来回切,人的精力全耗在搬运上。
KES MCP Server(kingbase-mcp 0.3.0)把金仓的能力封成了 9 类 MCP 工具:list_schemas、list_objects、get_object_details、execute_sql、explain_query、analyze_db_health、analyze_db_config、get_top_queries、analyze_workload_indexes、analyze_query_indexes。Cursor、TRAE、Claude Code 这类 IDE 直接就能调。开发者在 IDE 里问一句,AI 自己调工具,工具去碰金仓。窗口不用切了。

画了张图放上面。架构三层:上层是 AI 客户端(Cursor/TRAE/Claude Code),中层是 KES MCP Server(Python 3.12 + mcp SDK 1.29,streamable-http/SSE/stdio 三种传输),下层是金仓,用 ai_mcp 账号接住所有调用。restricted 模式只放行 SELECT/EXPLAIN/SHOW/VACUUM 这些白名单语句。AI 想绕过去,没门。
二、九件事
|--------|---------------------------------------------|-------------------------------------------------|-------|
| 场景 | 做什么 | 关键命令 | 图 |
| 一 | AI 开发环境就绪(uv + Python 3.12 + venv + CLI 可用) | uv --version + uv python list + --help | 图 2 |
| 二 | 金仓侧就绪(AI 账号 + 扩展 + 最小权限授权) | CREATE USER + GRANT + CREATE EXTENSION | 图 3 |
| 三 | 启动 KES MCP Server(streamable-http,:8000 监听) | set -a; source .env; nohup kingbase-mcp ... & | 图 4 |
| 四 | MCP 客户端接入(initialize + 工具注册表) | mcp_client.py tools | 图 5 |
| 五 | 自然语言查库(表结构 + 数据统计) | get_object_details + execute_sql | 图 6 |
| 六 | AI 驱动 SQL 调优(执行计划 + 假设索引模拟) | explain_query + 假设索引 | 图 7 |
| 七 | 慢查询洞察 + 数据库健康巡检 | get_top_queries + analyze_db_health | 图 8 |
| 八 | 参数画像 + 工作负载索引推荐 | analyze_db_config + analyze_query_indexes | 图 9 |
装环境
我先把 uv 装上,Python 3.12 就位,仓库 clone 完,venv 建好,依赖装上。uv --version 显示 0.12.1。uv python list 里 3.12.13 是已装状态。跑 .venv/bin/kingbase-mcp --help,参数选项都出来了:--access-mode {unrestricted,restricted}、--transport {stdio,sse,streamable-http}。这两组开关后面都要用。

装依赖那步 uv pip install . 我没截图。uv 的进度条一直刷,截图没法看。60 个包,主要的几个:kingbase-mcp==0.3.0、mcp==1.29.0、ksycopg2==2.9.1、pglast==7.11、starlette==1.3.1、uvicorn==0.52.1。ksycopg2 是金仓的官方驱动,PyPI 有 Linux x86_64 的 wheel,不用编译,装起来省事。
金仓侧准备
建账号、装扩展、授权,我一步步来。DROP USER IF EXISTS 先清同名。这行被跳过了,上一轮建的还在,PG 不让删正在被用的角色,后头细说。CREATE USER ai_mcp WITH PASSWORD 'Kingbase@123'。ALTER ROLE ai_mcp SET search_path = demo, public,这行后面有大用,场景六的坑就靠它解。GRANT USAGE ON SCHEMA demo TO ai_mcp、GRANT SELECT ON ALL TABLES IN SCHEMA demo TO ai_mcp,权限卡死在 demo 只读。CREATE EXTENSION IF NOT EXISTS sys_hypo 和 sys_stat_statements,已有就 NOTICEs 跳过。最后用 ai_mcp 连了一次,SELECT COUNT(*) FROM demo.t_order 返回 10000,上篇的表还在。

截图里有一行 ERROR: current logged-in user cannot be dropped。一开始我以为是脚本写错了,排查半天。后来反应过来,MCP Server 进程还握着 ai_mcp 的连接,PG 不许删一个正在被用的角色。想改角色得先把服务停了:先停 MCP、改账号、再启服务。这个顺序绕不开。
把服务拉起来
凭据我没直接 export,写进了 /opt/kes-mcp/kingbase-mcp/.env,权限 600。启动时 set -a; source .env; set +a 拉进环境,再 nohup .venv/bin/kingbase-mcp ... & 后台跑。ps 里看不到密码,history 里也没有,踏实。

ss -tlnp | grep 8000 看到 LISTEN 0 2048 0.0.0.0:8000,端口就绪。curl POST /mcp 返回 400。第一眼还愣了一下,查了下才发现 streamable-http 要 Accept 协商头,不带就 400。算预期。tail 日志看到 INFO 127.0.0.1:51032 - "POST /mcp HTTP/1.1" 400 Bad Request,服务在响应。
截图开头有行 pkill -f kingbase-mcp 2>/dev/null; sleep 1; echo OK,清残留进程用的,防端口被占。跑第二次能直接用。生产上这步得换成 systemd。
工具清单
我写了个客户端 mcp_client.py,60 行左右,放 /tmp。用 mcp SDK 1.29 的 streamablehttp_client 连 http://127.0.0.1:8000/mcp,子命令:tools/details/query/explain/hypo/slow/health/config/qindexes。真实场景里 Cursor/TRAE 的 AI 也是走这套协议,这里只是把 AI 那层换成终端输出,看得见摸得着。后面几个场景全是它跑出来的。

tools 一列,10 项:list_schemas、list_objects、get_object_details、explain_query、analyze_workload_indexes、analyze_query_indexes、analyze_db_health、get_top_queries、analyze_db_config、execute_sql。README 写 9 项,我实测 10 项,多了个 analyze_db_config。每一项都带 description 和参数 schema。AI 拿到就知道怎么选、怎么传参。
查表结构 + 统计
我先调 get_object_details,问它 demo.t_order 这张表长什么样。它把 5 个列、2 个索引全给我列出来了,连字段类型都标得清清楚楚。表结构拿到手,我心里就有数了,后面写 SQL 不用再猜字段名。

再问一句,两种状态各有多少单、总金额多少。execute_sql 直接跑分组统计,返回 N 状态 7500 单、18967315.21 元,S 状态 2500 单、6417424.10 元。我特意看了眼金额,Decimal 类型,一分不差。这玩意儿要是用 float,早晚丢钱。
假设索引
explain_query 跑一条 status='S' 的查询,返回 Bitmap Heap Scan,Cost 51.66..167.91。表上本来就有 status 的索引,走 Bitmap 不意外。

我加了个假设索引再跑,Cost 掉到 47.41..163.66。收益不大,表上已有 status 索引。可这套玩法搬到没索引的查询上是通的。先模拟,后决定,不用真建真删。
这中间栽了个坑。sys_hypo 的索引名里不能带 schema 点号,传 demo.t_order 进去直接报 syntax error at or near "."。折腾了一会儿,改成传 t_order 就好。场景二那句 ALTER ROLE 已经把 search_path 指到 demo 了。这坑,客户端默认参数里已经绕开。
慢查询 + 健康
get_top_queries 出了 Top 5 慢查询。排第一的是一条 198ms 的 CTE,几百行的 bloat 诊断 SQL,跑起来确实重。列表里还混着 MCP 自己触发的查询,带 /* kingbase-mcp */ 前缀,一眼能认出来。有行显示 insufficient privilege,权限不够,SQL 被藏了,看不全。

analyze_db_health 一口气跑 7 项检查。无效索引没有,重复索引没有,索引膨胀没有。倒是揪出一个未用索引,idx_order_status 只被扫了 2 次。连接健康,8 total 0 idle。vacuum 那边有个表接近 wraparound,10M 内要处理。Buffer 命中率 index 90.6%、table 96.3%,表命中贴着 95% 阈值,2 GiB 机器就这样。以前这套得一条条敲,现在一条调用全出。
这工具也有坑。all 模式 7 项并发,连接池偶发 connection pool is closed。我改成单项分几次调,health_type="index,vacuum,constraint",稳了。
参数 + 索引推荐
analyze_db_config 查 shared_buffers,返回 128 MB,提示偏低,建议提到 2 GB。2 GiB 机器 25% 是 512 MB,128 MB 确实低。演示机凑合,生产得按建议提。

analyze_query_indexes 拿两条查询去分析,推荐给 t_order 建 amount 单列索引。amount>4000 那条查询代价从 210.0 降到 158.9,1.3x。status='S' 那条已经命中索引,没变化。1 万行的表全表扫本来就快,1.3 倍不稀奇。这套东西,得拿到百万、亿行的表上才有看头。
三、数据汇总
|--------|---------------------------------------------------|--------|
| 维度 | 实测结果 | 出处 |
| 环境搭建 | uv 0.12.1 + Python 3.12.13 + venv 60 包就绪,CLI 可用 | 图 2 |
| 凭据安全 | DATABASE_URI 写入 .env(600 权限),命令行不出密码 | 图 4 |
| 账号权限 | ai_mcp 最小权限(demo schema 只读),扩展已装 | 图 3 |
| MCP 工具 | 实测注册 10 项(含 README 未列出的 analyze_db_config) | 图 5 |
| 自然语言查库 | get_object_details + execute_sql 毫秒级返回 | 图 6 |
| 假设索引收益 | status 假设索引:代价 167.91 → 163.66(≈8% 提升) | 图 7 |
| 慢查询 | MCP 内部 CTE 198ms 居首,sys_stat_statements 已采集 18+ 条 | 图 8 |
| 健康巡检 | 7 项中 1 项提示(idx_order_status 利用率低),其余健康 | 图 8 |
| 参数画像 | shared_buffers=128MB < 25% 内存建议,2 GiB 演示机可接受 | 图 9 |
| 索引推荐 | amount 单列索引预测 1.3x 提升(210.0 → 158.9 cost) | 图 9 |
四、写在最后
开头那个钩子,到这里收住了。上一篇我交付一个能跑的容器,这一篇交付"AI 看得见它"。两篇都在 2 vCPU / 1.9 GiB 的小机器上跑完:uv 隔离环境,streamable-http 多团队共用,restricted 不给写权限。这几个细节凑一块,"AI 直连国产数据库"才算有了着落。
说真的,跑完这趟我心里踏实了不少。国产库这些年性能、兼容性一直在追,工具链还是差口气。KES MCP Server 把"开发者怎么跟数据库打交道"这一层补上了。你手里要是有台能跑 Docker 的小机器,照着附录 A 走一遍,一个下午就能复现。试试看,不亏。
后面想把这套全链路打包成 IDE 提示词模板,给 Cursor/TRAE/Claude Code 的用户直接用。再找百万、亿行的表跑一次完整的 EXPLAIN 和假设索引,看看工具在大数据量下到底有几分成色。到那天,可能又得写一篇。
附录 A:MCP 客户端核心脚本(用于 IDE 集成可按需精简)
#!/usr/bin/env python3
import asyncio
import json
import sys
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
URL = "http://127.0.0.1:8000/mcp"
TOOLS = {
"details": ("get_object_details", {"schema_name": "demo", "object_name": "t_order", "object_type": "table"}),
"query": ("execute_sql", {"sql": "SELECT status, COUNT(*) AS cnt, ROUND(SUM(amount),2) AS total FROM demo.t_order GROUP BY status;"}),
"explain": ("explain_query", {"sql": "SELECT * FROM demo.t_order WHERE status='S';", "analyze": False}),
"hypo": ("explain_query", {"sql": "SELECT * FROM demo.t_order WHERE status='S';", "analyze": False, "hypothetical_indexes": [{"table": "t_order", "columns": ["status"]}]}),
"slow": ("get_top_queries", {"sort_by": "mean_time", "limit": 5}),
"health": ("analyze_db_health", {"health_type": "all"}),
"config": ("analyze_db_config", {"parameter": "shared_buffers"}),
"qindexes":("analyze_query_indexes", {"queries": ["SELECT * FROM t_order WHERE status='S'", "SELECT * FROM t_order WHERE amount>4000"], "max_index_size_mb": 100, "method": "dta"}),
}
def fmt(content):
if content is None:
return ""
if isinstance(content, str):
return content
out = []
for item in content:
t = getattr(item, "text", None)
if isinstance(t, str) and t:
out.append(t)
else:
s = getattr(item, "resource", None)
if s is not None:
out.append(json.dumps(s, ensure_ascii=False, indent=2))
else:
out.append(str(item))
return "\n".join(out)
async def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "tools"
async with streamablehttp_client(URL) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
if mode == "tools":
tools = await session.list_tools()
print("MCP server connected: KingbaseES MCP Server (streamable-http)")
print("Total tools: %d\n" % len(tools.tools))
for t in tools.tools:
print("- %s" % t.name)
print(" desc: %s" % (t.description or "").split("\n")[0])
print(" args: %s" % ", ".join(t.inputSchema.get("properties", {}).keys()))
return
name, args = TOOLS[mode]
print(">>> call_tool: %s" % name)
print(">>> arguments: %s" % json.dumps(args, ensure_ascii=False) if args else ">>> arguments: (none)")
res = await session.call_tool(name, args or {})
print("<<< result:")
print(fmt(res.content) if res.content else "(no content)")
if res.isError:
print("<<< isError: True")
if __name__ == "__main__":
asyncio.run(main())