资讯动态

Go实现MCP只读服务:让Claude Code安全查询GaussDB生产库

发布时间:2026/9/28 13:11:18 来源:尧图企业网站定制
1. 为什么我要给 Claude Code 配一个只读的 GaussDB 通道先说结论我写了一个用 Go 实现的 MCP 服务把 GaussDB 的查询能力以只读的方式暴露给 Claude Code。它解决的核心问题很具体——我想让 AI 帮我查生产库的数据、分析表结构、定位慢查询但绝对不能让它在生产库上执行任何写操作。这个需求不是凭空冒出来的。日常开发里我们经常遇到这样的场景线上某个业务表的数据对不上需要拉几条样本看看某张表的字段类型和文档不一致想确认一下真实结构一个慢查询卡了很久想看看执行计划到底走了哪个索引。这些事以前要么自己连上去手动查要么写个临时脚本跑一遍。现在有了 Claude Code 这类工具理论上可以直接对话式地完成但问题也随之而来——你不可能把生产库的写权限交给一个 AI。MCPModel Context Protocol是这里的关键。它本质上是一套让 AI 客户端和外部工具/数据源通信的协议Claude Code 作为客户端可以连接各种 MCP Server把 Server 暴露出来的能力当成自己的工具来用。我做的事情就是写一个 MCP Server它对外只暴露查询这一类工具内部连接 GaussDB并且在多个层面把写操作堵死。为什么选 Go因为 MCP Server 本质上是一个常驻的、需要处理并发请求的小服务Go 的编译产物是单个二进制、部署简单、并发模型清晰、标准库对数据库和 JSON 的支持足够好。你不需要为了这么一个小服务去搭一套 Python 虚拟环境或者 Node 运行时扔一个二进制到服务器上就能跑这对运维友好度是决定性的。这篇文章我会把整个实现过程拆开讲MCP 协议里到底要处理什么、GaussDB 的连接有什么坑、只读是怎么做到多层设防的、以及我在实测中踩到的那些文档里不会写的问题。如果你也在琢磨怎么让 AI 安全地碰你的数据库这篇应该能帮你少走不少弯路。2. 先把 MCP 协议这层搞明白别急着写数据库代码2.1 MCP Server 到底要响应哪些东西很多人一上来就去研究数据库驱动结果卡在协议层。我的建议是先把 MCP 的通信模型理清楚因为它决定了你的代码骨架长什么样。MCP 目前主流的传输方式是 stdio 和 HTTP/SSE 两类。对于本地工具型服务stdio 是最省事的Claude Code 启动你的进程通过标准输入输出交换 JSON-RPC 消息。你不需要开端口、不需要处理鉴权、不需要考虑网络暴露面。我的选择就是 stdio理由很直接——这个服务只给本机的 Claude Code 用没有任何理由把它暴露成网络服务。协议交互的核心是几个方法initialize握手双方交换能力声明。客户端告诉你它支持什么你告诉客户端你提供什么。tools/list返回你暴露的工具清单每个工具包含名称、描述、输入参数的 JSON Schema。tools/call客户端调用某个工具传入参数你返回结果。这里有个容易被忽略的点工具的 description 字段是给模型看的。它不是写给人看的注释而是模型决定要不要调用、怎么填参数的主要依据。所以描述要写得精确比如执行一条只读 SQL 查询并返回结果仅支持 SELECT 语句禁止任何写操作这种约束写进描述里模型在生成调用时会更谨慎。2.2 JSON-RPC 消息的边界处理stdio 模式下最容易翻车的地方是消息分帧。JSON-RPC over stdio 通常用换行分隔每条消息但如果你读的时候用bufio.Scanner默认的 64KB 上限遇到大结果集直接就被截断了。我一开始就踩了这个坑——查一张宽表返回几百行结果 JSON 被切断客户端报解析错误排查了半天才发现是 Scanner 的缓冲区限制。解决办法是显式设置更大的缓冲区scanner : bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 1024*1024), 16*1024*1024)第一参数是初始缓冲第二参数是最大容量。我给了 16MB足够应付绝大多数查询结果。但更稳妥的做法是在工具层面就限制返回行数不要让单次响应无限膨胀这个后面讲只读设计时会再展开。另一个坑是标准输出的纯净性。stdio 模式下 stdout 是协议通道你任何一句fmt.Println调试输出都会污染协议流导致客户端解析失败。所有日志必须走 stderr。我在代码里把日志统一封装成一个写 stderr 的 logger杜绝了随手打印的习惯。2.3 工具清单怎么设计才合理我没有把所有能力塞进一个万能查询工具而是拆成了几个职责清晰的工具工具名作用关键约束query执行只读 SQL仅 SELECT强制 LIMITlist_tables列出 schema 下的表只读元数据describe_table查看表结构只读元数据explain_query获取执行计划仅 EXPLAIN不实际执行拆开的好处是每个工具的输入 Schema 都很简单模型不容易填错参数。而且像explain_query这种我可以单独控制它只允许EXPLAIN前缀比在一个大工具里做字符串判断要清晰得多。提示工具数量不要贪多。每多一个工具模型在选择时的决策成本就高一分。我最初还加了list_schemas、show_indexes之类的后来发现describe_table返回的信息已经够用就砍掉了实际体验反而更顺。3. GaussDB 连接层驱动选择与那些文档没写的细节3.1 驱动选型为什么用 pgx 而不是 database/sql 原生GaussDB 在协议层面高度兼容 PostgreSQL所以 Go 生态里成熟的 PG 驱动基本都能用。我选的是jackc/pgx而不是标准库database/sql配lib/pq。原因有三点。第一pgx 有原生接口pgx.Conn、pgxpool.Pool性能比走database/sql抽象层要好尤其是批量查询和类型映射。第二pgx 对 PostgreSQL 协议的支持更完整GaussDB 的一些扩展类型在 pgx 下解析更顺。第三pgx 的类型系统更智能查询结果直接映射成 Go 类型时遇到numeric、timestamp with time zone这类类型不容易出幺蛾子。连接串的写法大致是这样postgres://user:passwordhost:port/dbname?sslmodedisablestatement_cache_capacity0这里有个 GaussDB 特有的坑statement cache 有时候会出问题。pgx 默认会缓存 prepared statement但某些 GaussDB 版本在特定配置下对 prepared statement 的支持不完整表现为偶发的 prepared statement already exists 或者参数绑定异常。我一开始遇到间歇性查询失败查了很久才定位到这里。把statement_cache_capacity设为 0 关掉缓存后问题消失。代价是每次查询都要重新解析 SQL但对于一个查询频率不高的辅助工具来说这点开销完全可以接受。3.2 连接池参数怎么定即使是只读服务也建议用连接池而不是单连接。因为 Claude Code 可能连续发起多个查询单连接会串行化体验很差。我用的是pgxpoolconfig, err : pgxpool.ParseConfig(connString) if err ! nil { return nil, err } config.MaxConns 4 config.MinConns 1 config.MaxConnLifetime time.Hour config.MaxConnIdleTime 10 * time.MinuteMaxConns给 4 就够了。这个服务不是高并发场景一个开发者本机用4 个连接绰绰有余。给太多反而会占用数据库的连接数配额生产库的连接数是宝贵资源别浪费。MaxConnLifetime设一小时是为了避免长连接被数据库端或中间网络设备悄悄掐断后客户端还拿着死连接用。设一个合理的生命周期让连接定期重建能规避很多连接突然失效的玄学问题。3.3 超时控制别让一个慢查询拖死整个服务这是我认为最容易被忽视但最重要的一环。生产库上一条没走索引的查询可能跑几分钟甚至更久如果不设超时你的 MCP 服务就会一直挂着Claude Code 那边也会一直等整个交互就卡死了。我的做法是给每个查询都套一个 context 超时ctx, cancel : context.WithTimeout(context.Background(), 15*time.Second) defer cancel() rows, err : pool.Query(ctx, sqlText)15 秒是我权衡后的值。太短了正常的分析查询跑不完太长了卡住体验差。超过 15 秒的查询说明它本身就不适合在这个场景下跑应该去专门的慢查询分析工具里处理。同时GaussDB 侧也可以设statement_timeout双保险。可以在连接初始化时执行SET statement_timeout 15s;这样即使客户端 context 因为某些原因没生效数据库端也会主动掐断。两层超时一层在客户端一层在服务端互为兜底。4. 只读是怎么做到多层设防的这部分是整个项目的核心也是标题里放心连生产库的底气所在。我的原则是不依赖任何单一防线每一层都假设其他层可能失效。4.1 第一层数据库账号权限最根本的一层是数据库账号本身就只有只读权限。这是不可绕过的基础。创建一个专用账号只授予CONNECT和USAGE以及对目标 schema 下表的SELECTCREATE USER mcp_readonly WITH PASSWORD xxx; GRANT CONNECT ON DATABASE yourdb TO mcp_readonly; GRANT USAGE ON SCHEMA public TO mcp_readonly; GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_readonly;最后那句ALTER DEFAULT PRIVILEGES很关键它保证以后新建的表也自动带上 SELECT 权限省得每次加表都要手动授权。注意千万不要图省事用超级用户或者有写权限的账号。哪怕你的代码里做了 SQL 校验账号权限这一层也必须卡死。代码可能有 bug但数据库的权限系统不会骗你。4.2 第二层SQL 语句白名单校验在服务端我对传入的 SQL 做严格校验。核心逻辑是只允许以 SELECT 或 WITH 开头的语句且不允许出现任何写操作关键字。func isReadOnly(sqlText string) bool { normalized : strings.TrimSpace(strings.ToLower(sqlText)) // 去掉开头的注释 normalized stripLeadingComments(normalized) if !strings.HasPrefix(normalized, select) !strings.HasPrefix(normalized, with) { return false } forbidden : []string{ insert, update, delete, drop, truncate, alter, create, grant, revoke, merge, call, execute, copy, } for _, kw : range forbidden { if containsKeyword(normalized, kw) { return false } } return true }这里有个细节要小心不能简单地用strings.Contains判断关键字。因为update_time这样的列名里就包含 updatecreated_at里包含 create。如果粗暴匹配正常的查询会被误杀。我的做法是用词边界匹配把 SQL 按非字母数字字符切分然后逐个 token 比对。这样update_time会被切成update和time两个 token……等等下划线算不算分隔符如果算那update_time还是会被误判。更稳妥的方案是先做词法切分识别出 SQL 里的关键字位置只对语句级关键字做判断而不是对任意 token。但完整实现一个 SQL 词法分析器成本太高。我的折中方案是用正则匹配关键字前后必须是空白或标点的情况同时把常见的关键字下划线组合排除掉。实测下来配合数据库账号权限这层兜底这个精度已经够用了。4.3 第三层强制 LIMIT 与结果集截断即使查询是只读的一条SELECT * FROM huge_table也能把内存打爆。所以我在执行前会检查 SQL 是否带 LIMIT如果没有自动追加一个if !hasLimitClause(normalized) { sqlText sqlText LIMIT 1000 }同时在读取结果时也做行数上限控制读到 1000 行就停止并在返回结果里明确标注结果已截断。这样模型和用户都知道数据不完整需要更精确的查询条件。这里有个坑给带ORDER BY的查询追加 LIMIT 是安全的但给某些聚合查询追加可能改变语义。不过对于探索性查询来说LIMIT 的存在利大于弊而且我会在工具描述里说明这个行为让模型知道结果可能被截断。4.4 第四层事务只读模式在连接层面我还会把事务设为只读SET TRANSACTION READ ONLY;或者在 pgx 里用BeginTx指定AccessMode: pgx.ReadOnly。这样即使前面几层都被绕过数据库层面也会拒绝写操作。这是最后一道保险。四层防线叠加下来实际防护效果是账号权限挡住绝大部分SQL 校验挡住明显的写语句LIMIT 防止资源耗尽事务只读兜底。任何一层单独看都不完美但叠在一起误操作的可能性被压到了极低。5. 实测中那些让人抓头的具体问题5.1 大字段返回导致的 JSON 序列化问题GaussDB 里如果有bytea、text这类大字段查询结果序列化成 JSON 时可能出问题。bytea直接序列化会变成一长串 base64几 MB 的二进制字段能把响应撑爆。我的处理方式是在结果转换阶段做类型判断遇到bytea类型不返回实际内容而是返回一个占位符加长度信息比如binary data, 2048 bytes。这样既保留了这里有数据的信息又不会把响应撑大。switch field.DataTypeOID { case pgtype.ByteaOID: result fmt.Sprintf(binary data, %d bytes, len(rawBytes)) default: result string(rawBytes) }5.2 NULL 值的处理Go 的pgx在扫描 NULL 值时如果目标类型是string会报错。必须用pgtype.Text或者*string来接。我一开始用[]string接所有列遇到 NULL 直接 panic。后来改成统一用[]any接然后逐个判断类型再转换。虽然麻烦一点但通用性最好不用为每种列类型写不同的扫描逻辑。5.3 中文与特殊字符的编码GaussDB 的字符集配置如果和客户端不一致中文可能变乱码。连接串里可以指定client_encodingpostgres://...?client_encodingUTF8另外返回 JSON 时 Go 默认会把非 ASCII 字符转义成\uXXXX虽然合法但可读性差。如果希望保留原始中文需要在编码时关闭 HTML 转义encoder : json.NewEncoder(w) encoder.SetEscapeHTML(false)5.4 Claude Code 侧的配置服务写好了还要让 Claude Code 知道怎么启动它。配置里指定命令和参数即可大致是这样{ mcpServers: { gaussdb-readonly: { command: /path/to/your/mcp-server, args: [--conn, postgres://...] } } }连接串这种敏感信息我建议通过环境变量传而不是写在配置文件的 args 里。配置文件可能被同步、被分享环境变量相对安全一些。提示第一次配置完先在 Claude Code 里让它调用list_tables这种最简单的工具确认链路通了再去试复杂查询。一上来就查复杂 SQL出问题不好定位是协议层还是数据库层。6. 关于只读这件事我的一些额外思考写这个服务的过程中我对只读的理解比一开始深了不少。只读不只是不让写它包含好几个维度不写数据、不写结构、不消耗过多资源、不泄露敏感信息。资源消耗这点前面提了 LIMIT 和超时但还有一个容易被忽略的——并发控制。如果模型一次发起十个查询每个都跑满连接池数据库那边压力就上来了。我在服务层加了一个简单的信号量限制同时执行的查询数量超出的排队等待。这样即使模型行为激进也不会把生产库打垮。敏感信息方面如果表里有手机号、身份证号这类字段直接返回给 AI 是有风险的。理想情况下应该做脱敏但脱敏规则因业务而异很难做成通用的。我的做法是在工具描述里明确提示返回结果可能包含敏感数据请谨慎处理把判断权交给使用者。更严格的方案是配置一个字段黑名单命中就替换成掩码这个可以根据自己团队的情况加。最后说一个心态上的建议不要因为做了只读就完全放松警惕。只读查询一样可能触发全表扫描、一样可能锁住某些资源取决于隔离级别、一样可能因为返回海量数据拖垮客户端。只读降低的是数据被篡改的风险不是系统被影响的风险。把超时、限流、结果截断这些做扎实才是真正让人放心的关键。我在实际使用中最大的体会是这套东西的价值不在于技术多复杂而在于它把让 AI 碰生产库这件原本让人心里发毛的事变成了一件可以放心做的事。四层防线里没有哪一层是黑科技都是很朴素的工程手段但组合起来就形成了足够可靠的保障。如果你也在做类似的事希望这些经验能帮你把坑填平。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑