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

📅 发布时间:2026/9/28 6:39:30
Go 实现 MCP 只读服务:让 Claude Code 安全查询 GaussDB
1. 为什么我要给 Claude Code 配一个只读的 GaussDB 通道先说结论我写了一个用 Go 实现的 MCP 服务把 GaussDB 的查询能力以只读方式暴露给 Claude Code。它解决的核心问题很具体——我想让 AI 帮我查生产库的数据结构、排查数据问题、写 SQL 草稿但绝对不能让它在生产库上执行任何写操作。这个服务适合所有正在用 Claude Code 做开发、手上有 GaussDB或兼容 PostgreSQL 协议的国产数据库并且对生产数据安全有硬性要求的同学。事情的起因很朴素。前段时间我在做一个数据核对的需求表结构复杂、字段命名又不太规范我一边翻文档一边写 SQL效率很低。那时候我已经在用 Claude Code 帮我处理一些代码和脚本就想着能不能让它直接连上数据库我描述需求、它帮我查表结构和样本数据。想法很美好但真动手的时候我犹豫了Claude Code 通过 MCP 连数据库意味着它拿到了一个可以执行 SQL 的通道。如果这个通道是可写的那它某一次手滑执行了 UPDATE 或者 DELETE生产库上就是事故。哪怕概率极低这个风险我也不想承担。所以我给自己定了一条硬规则给 AI 的数据库通道必须是只读的而且是在数据库层面只读不是靠提示词约束。提示词约束是靠不住的模型可能被诱导、可能理解偏差、可能在多轮对话里忘记约束。真正可靠的做法是让数据库账号本身就没有写权限再在服务层做一层 SQL 白名单校验双保险。MCP 是 Anthropic 推出的模型上下文协议简单理解就是给 AI 客户端比如 Claude Code挂载外部工具的标准接口。你可以把它类比成给 AI 装插件AI 通过 MCP 服务暴露的工具列表知道有哪些能力可用调用时把参数传过来服务执行完把结果返回。我选 Go 来写这个服务原因有三个一是 Go 编译出来是单文件二进制扔到服务器上就能跑不用装运行时二是 Go 的数据库驱动生态成熟database/sql配合lib/pq或pgx处理 PostgreSQL 协议很稳而 GaussDB 兼容 PostgreSQL 协议直接复用三是 Go 写并发和超时控制很顺手查询超时、连接池这些都能精细控制。下面我把整个设计思路、关键实现、踩过的坑都摊开讲你可以直接照着复现。2. 整体设计只读这件事要分三层来做2.1 三层防护的设计逻辑很多人做只读控制第一反应是我在代码里判断一下 SQL 开头是不是 SELECT 就行了。这个思路方向对但太粗糙。我把它拆成三层每一层解决不同的问题防护层位置解决的问题失效场景数据库账号权限GaussDB 侧从根上禁止写操作账号配置错误SQL 语句校验MCP 服务层拦截非查询语句、多语句注入正则绕过执行超时与行数限制MCP 服务层防止慢查询拖垮生产库无第一层是根本。我给 MCP 服务单独建了一个数据库账号这个账号只授予CONNECT和USAGE权限以及对目标 schema 下表的SELECT权限。注意不要图省事直接用超级用户或者业务账号那样前面两层防护就形同虚设了。GaussDB 的权限体系和 PostgreSQL 基本一致建账号和授权的语句我后面会给。第二层是服务层的 SQL 校验。它的作用不是替代数据库权限而是提前拦截。因为如果一条写语句发到数据库才被拒绝虽然数据安全但会产生一次无效连接和错误日志而且如果账号权限配置有疏漏这层就是最后一道闸。我的做法是只允许以SELECT、WITH、EXPLAIN、SHOW开头的语句并且禁止分号后跟其他语句防多语句注入。第三层是资源保护。生产库最怕的不是写操作而是慢查询。一条没走索引的SELECT * FROM 大表就能把数据库连接占满。所以我给每个查询都设了超时我设的是 10 秒并且限制返回行数默认 200 行可配置。这两条能挡住绝大多数AI 生成了一条看起来很合理但实际全表扫描的情况。2.2 为什么用 MCP 而不是直接给 Claude Code 一个脚本有人会问我直接写个 Python 脚本查库把结果贴给 Claude Code 不就行了为什么要搞 MCP区别在于交互效率。脚本模式下每次查询都要我手动执行、手动粘贴AI 是被动的。而 MCP 模式下Claude Code 可以自己决定我需要先看看这张表有哪些字段然后主动调用工具拿到结果后继续推理。整个排查过程是连贯的我只需要描述目标中间的表结构探查、样本数据查看它自己就完成了。这个体验差异很大尤其是排查复杂数据问题时。另外 MCP 的工具描述是结构化的我可以把这个库是生产库只读查询要加 LIMIT这些约束写进工具描述里Claude Code 在调用时会参考这些描述减少无效调用。2.3 技术选型Go pgx MCP SDK具体技术栈语言Go 1.21用到了context超时控制和database/sql连接池数据库驱动github.com/jackc/pgx/v5它比lib/pq性能更好对 PostgreSQL 协议支持更完整GaussDB 兼容性实测没问题MCP 实现我用的是社区维护的 Go MCP SDKgithub.com/mark3labs/mcp-go这类它封装了 stdio 和 SSE 两种传输方式配置管理环境变量 一个可选的配置文件避免把连接串硬编码选 pgx 而不是 lib/pq还有一个原因是 pgx 对context取消的支持更干净。当查询超时触发时pgx 能真正把查询取消掉而不是只让客户端断开、数据库那边还在跑。这一点对生产库保护很关键。3. 核心实现细节从建账号到 SQL 校验3.1 GaussDB 侧建一个什么都不能写的账号这一步是整个方案的地基我建议你亲手做一遍不要用现成的账号。假设我的目标库叫prod_db要暴露的 schema 是public账号叫mcp_reader-- 创建只读账号 CREATE USER mcp_reader WITH PASSWORD 一个足够复杂的密码; -- 允许连接 GRANT CONNECT ON DATABASE prod_db TO mcp_reader; -- 允许使用 schema GRANT USAGE ON SCHEMA public TO mcp_reader; -- 授予现有表的查询权限 GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_reader; -- 关键让未来新建的表也自动授予查询权限 ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_reader;这里有几个坑我要重点说。第一个坑GRANT SELECT ON ALL TABLES只对当前已存在的表生效。如果之后有人建了新表mcp_reader是查不到的。所以ALTER DEFAULT PRIVILEGES这行必须加它保证未来新建的表自动带上查询权限。我一开始漏了这行结果新加的一张表怎么都查不到排查了半天。第二个坑GaussDB 里如果开了行级访问控制或者有视图权限模型会更复杂。如果你的表是通过视图暴露的记得视图的权限也要单独授。另外如果目标 schema 不是public把上面语句里的 schema 名换掉。第三个坑不要给这个账号任何CREATE、TEMPORARY权限。有些数据库默认会给新用户TEMPORARY权限允许建临时表虽然临时表不影响生产数据但会占用资源。稳妥起见显式收回REVOKE CREATE ON SCHEMA public FROM mcp_reader; REVOKE TEMPORARY ON DATABASE prod_db FROM mcp_reader;授权完成后一定要用这个账号实际登录测试一遍确认SELECT能跑、INSERT/UPDATE/DELETE被拒绝。测试语句-- 应该成功 SELECT 1; -- 应该报权限错误 CREATE TABLE test_perm (id int);如果CREATE TABLE没报错说明权限没收干净回去检查。3.2 服务层SQL 校验函数怎么写才靠谱SQL 校验是第二层防护核心是一个函数给定一条 SQL判断它是不是安全的只读查询。我最初的版本很简单就是判断开头是不是SELECT后来发现漏洞太多逐步加固成了下面这样。先说要拦截哪些情况任何以INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、CREATE、GRANT、REVOKE开头的语句多语句SELECT 1; DROP TABLE users;这种分号后面还有内容注释绕过SELECT 1 /* */ ; DELETE ...这种注释里藏东西大小写和空白绕过select、SeLeCt、换行开头的select我的校验逻辑分三步走。第一步去掉首尾空白和 SQL 注释把语句规范化。第二步检查是否包含分号排除字符串字面量里的分号这个用简单状态机处理。第三步检查规范化后的语句是否以允许的关键字开头。package validator import ( regexp strings ) var ( // 允许的语句前缀 allowedPrefixes []string{select, with, explain, show} // 用于去除 SQL 注释的正则 blockComment regexp.MustCompile(/\*[\s\S]*?\*/) lineComment regexp.MustCompile(--[^\n]*) ) // ValidateReadOnly 校验 SQL 是否为只读查询 func ValidateReadOnly(sql string) (bool, string) { // 1. 去除注释 cleaned : blockComment.ReplaceAllString(sql, ) cleaned lineComment.ReplaceAllString(cleaned, ) cleaned strings.TrimSpace(cleaned) if cleaned { return false, 空语句 } // 2. 检查多语句简单状态机跳过字符串字面量 if hasMultipleStatements(cleaned) { return false, 检测到多语句禁止执行 } // 3. 检查前缀 lower : strings.ToLower(cleaned) for _, prefix : range allowedPrefixes { if strings.HasPrefix(lower, prefix) { // 额外检查WITH 语句后面不能跟写操作 if prefix with containsWriteKeyword(lower) { return false, WITH 语句中包含写操作 } return true, } } return false, 只允许 SELECT/WITH/EXPLAIN/SHOW 查询 } // hasMultipleStatements 检测分号分隔的多语句 func hasMultipleStatements(sql string) bool { inString : false var quoteChar rune runes : []rune(sql) for i : 0; i len(runes); i { ch : runes[i] if inString { if ch quoteChar { // 处理转义的单引号 if i1 len(runes) runes[i1] quoteChar { i continue } inString false } continue } if ch \ || ch { inString true quoteChar ch continue } if ch ; { // 分号后如果还有非空白内容视为多语句 rest : strings.TrimSpace(string(runes[i1:])) if rest ! { return true } } } return false } // containsWriteKeyword 检查是否包含写操作关键字 func containsWriteKeyword(sql string) bool { writeKeywords : []string{ insert , update , delete , drop , alter , truncate , create , grant , revoke , } for _, kw : range writeKeywords { if strings.Contains(sql, kw) { return true } } return false }这段代码有几个设计取舍我要解释。为什么用前缀匹配而不是完整 SQL 解析完整解析 SQL 需要引入一个 SQL parser比如vitess的 sqlparser 或者pingcap/parser。这些库很重而且对 GaussDB 的方言支持不一定完整。前缀匹配虽然笨但配合数据库层的权限控制已经足够安全。我的原则是服务层做粗筛数据库层做硬拦不指望服务层做到 100% 精确。为什么WITH语句要额外检查WITH开头的是 CTE公共表表达式它本身是查询但 PostgreSQL 支持WITH ... AS (INSERT ...)这种数据修改 CTE。虽然 GaussDB 上这种写法用得少但为了保险我加了一层关键字检查。字符串字面量里的分号处理SELECT a;b这种语句分号在字符串里不应该被判定为多语句。所以hasMultipleStatements里用了一个简单的状态机遇到引号就进入字符串模式跳过里面的内容。这个状态机不处理 PostgreSQL 的 dollar-quoting$$...$$如果你的场景会用到需要额外处理。注意SQL 校验永远只是辅助手段。真正兜底的是数据库账号权限。如果你的账号权限配置错了再好的校验函数也可能被绕过。两层都要做不要偷懒。3.3 连接池与超时保护生产库的关键参数生产库最怕的是连接被占满和慢查询。我的配置是这样的package db import ( context time github.com/jackc/pgx/v5/pgxpool ) func NewPool(ctx context.Context, connString string) (*pgxpool.Pool, error) { config, err : pgxpool.ParseConfig(connString) if err ! nil { return nil, err } // 连接池大小MCP 服务是单用户场景不需要大池子 config.MaxConns 5 config.MinConns 1 // 连接最大存活时间避免长时间占用 config.MaxConnLifetime 30 * time.Minute config.MaxConnIdleTime 5 * time.Minute // 连接建立超时 config.ConnConfig.ConnectTimeout 5 * time.Second // 关键设置 statement timeout让数据库侧也超时 config.ConnConfig.RuntimeParams[statement_timeout] 10000 // 10秒 return pgxpool.NewWithConfig(ctx, config) }这里最值得说的是statement_timeout。这个参数是 PostgreSQL 协议里的运行时参数设置后数据库会在查询超过指定毫秒数时主动终止查询。这比客户端超时更彻底——客户端超时只是客户端不等了数据库那边可能还在跑而statement_timeout是数据库自己掐断。我把MaxConns设成 5是因为 MCP 服务的使用者通常只有一个人我自己并发查询很少。设太大反而可能让 AI 在短时间内发起大量查询把生产库的连接占掉。宁可让 AI 等一等也不要影响生产业务。查询执行时我还会再包一层context.WithTimeout双保险func ExecuteQuery(ctx context.Context, pool *pgxpool.Pool, sql string, maxRows int) ([]map[string]interface{}, error) { queryCtx, cancel : context.WithTimeout(ctx, 10*time.Second) defer cancel() rows, err : pool.Query(queryCtx, sql) if err ! nil { return nil, err } defer rows.Close() // 限制返回行数 var results []map[string]interface{} fieldDescs : rows.FieldDescriptions() count : 0 for rows.Next() { if count maxRows { break } values, err : rows.Values() if err ! nil { return nil, err } row : make(map[string]interface{}) for i, fd : range fieldDescs { row[string(fd.Name)] values[i] } results append(results, row) count } return results, rows.Err() }maxRows我默认设 200。为什么是 200因为 AI 看样本数据不需要太多行200 行足够它理解数据分布和字段含义。行数太多反而会撑爆上下文窗口让对话变得又慢又贵。这个值可以通过环境变量调整但我不建议超过 1000。4. 把服务接进 Claude Code配置与联调实录4.1 MCP 服务的工具定义MCP 服务的核心是暴露工具tool。我定义了两个工具一个查表结构一个执行查询。为什么拆成两个而不是一个因为查表结构是高频、低风险操作可以放宽限制执行查询是低频、需要谨慎的操作要严格校验。分开定义能让 Claude Code 更清楚地知道什么时候用哪个。工具定义大致长这样以 mcp-go 的 API 为例package main import ( context encoding/json fmt github.com/mark3labs/mcp-go/mcp github.com/mark3labs/mcp-go/server ) func main() { s : server.NewMCPServer(gaussdb-readonly, 1.0.0) // 工具1查询表结构 listTablesTool : mcp.NewTool(list_tables, mcp.WithDescription(列出数据库中的所有表名和注释。这是只读操作用于了解数据库结构。), mcp.WithString(schema, mcp.Description(schema 名称默认 public), ), ) s.AddTool(listTablesTool, handleListTables) // 工具2执行只读查询 queryTool : mcp.NewTool(run_query, mcp.WithDescription(执行只读 SQL 查询。只允许 SELECT/WITH/EXPLAIN/SHOW 语句。生产库请务必加 LIMIT避免全表扫描。), mcp.WithString(sql, mcp.Required(), mcp.Description(要执行的 SQL 语句必须是只读查询), ), mcp.WithNumber(max_rows, mcp.Description(最大返回行数默认 200上限 1000), ), ) s.AddTool(queryTool, handleRunQuery) // 使用 stdio 传输 if err : server.ServeStdio(s); err ! nil { fmt.Printf(server error: %v\n, err) } }工具描述WithDescription这块我写得很用心。因为 Claude Code 在决定调用哪个工具时会读这些描述。我在描述里明确写了生产库只读请加 LIMIT这些提示能显著减少 AI 生成危险查询的概率。这不是安全依赖但能提升体验。4.2 在 Claude Code 里注册 MCP 服务服务编译成二进制后在 Claude Code 的配置文件里注册。Claude Code 的 MCP 配置通常放在项目的.mcp.json或者用户级的配置目录里。我用的配置格式是这样的{ mcpServers: { gaussdb-readonly: { command: /usr/local/bin/gaussdb-mcp, args: [], env: { GAUSSDB_DSN: postgres://mcp_reader:密码10.0.0.1:5432/prod_db?sslmodedisable, MAX_ROWS: 200, QUERY_TIMEOUT_SECONDS: 10 } } } }这里有几个实操细节。连接串格式GaussDB 兼容 PostgreSQL 协议所以连接串用postgres://开头是可行的。如果你的 GaussDB 集群开了 SSL把sslmode改成require或verify-full。生产环境我强烈建议开 SSL哪怕在内网。密码不要写在配置里上面为了演示写在了env里但实际部署时我建议用环境变量注入或者用系统的密钥管理。如果一定要写在配置里确保这个文件的权限是600只有自己能读。路径用绝对路径command一定要用绝对路径。Claude Code 启动 MCP 服务时的工作目录可能和你想象的不一样用相对路径会找不到二进制。配置完成后重启 Claude Code它会在启动时拉起 MCP 服务。你可以在对话里让它列出数据库的表如果它能正确返回表名说明链路通了。4.3 联调时我踩过的坑坑一stdio 模式下日志不能随便打印。MCP 的 stdio 传输是用标准输入输出通信的如果你在代码里用fmt.Println打印调试日志会污染协议数据导致 Claude Code 解析失败。我一开始加了几行fmt.Println调试结果服务一直连不上排查了很久。正确做法是把日志写到标准错误fmt.Fprintln(os.Stderr, ...)或者文件。坑二GaussDB 的SHOW语句行为和 PostgreSQL 有差异。PostgreSQL 的SHOW能查很多运行时参数GaussDB 上部分参数不支持会报错。我在校验里允许了SHOW但实际用的时候发现有些SHOW语句会失败。这不影响安全但会让 AI 困惑。后来我在工具描述里补了一句SHOW 语句支持有限。坑三中文表注释的编码问题。GaussDB 里表的注释如果是中文通过 pgx 读出来可能是乱码取决于数据库的字符集配置。我遇到过一次后来确认是数据库端字符集和客户端不一致。解决办法是在连接串里显式指定client_encodingUTF8。坑四Claude Code 有时会生成带EXPLAIN ANALYZE的语句。EXPLAIN ANALYZE会实际执行查询虽然不写数据但会消耗资源。我的校验允许EXPLAIN开头所以EXPLAIN ANALYZE也能过。这个我认为可以接受因为EXPLAIN ANALYZE对排查慢查询很有用而且有statement_timeout兜底。如果你更保守可以在校验里禁止ANALYZE关键字。5. 常见问题排查与安全加固清单5.1 问题速查表现象可能原因排查方法解决Claude Code 连不上 MCP 服务二进制路径错误 / 权限不足手动执行二进制看是否报错用绝对路径chmod x服务启动但工具列表为空stdio 被日志污染检查是否有 Println 输出日志改到 stderr查询报权限错误账号没授 SELECT用账号手动登录测试补授权限查询超时全表扫描 / 缺索引看EXPLAIN结果加 LIMIT 或让 AI 优化 SQL中文注释乱码字符集不一致查数据库字符集连接串加client_encoding新表查不到默认权限没设检查ALTER DEFAULT PRIVILEGES补设默认权限返回结果被截断超过 max_rows看返回行数调大 max_rows 或加过滤条件5.2 安全加固清单上线前我建议你逐条核对数据库账号只有CONNECT、USAGE、SELECT权限没有CREATE、TEMPORARYALTER DEFAULT PRIVILEGES已设置未来新表自动可读服务层 SQL 校验已启用拦截非查询语句和多语句statement_timeout已设置建议 10 秒以内连接池MaxConns不超过 10避免占用生产连接返回行数有上限默认 200连接串使用 SSL生产环境必须配置文件权限600密码不硬编码日志输出到 stderr 或文件不污染 stdio服务以低权限系统用户运行不用 root5.3 几个我个人的实操心得心得一先在小库上跑通再连生产。我一开始就想直接连生产库结果各种配置问题排查起来很痛苦因为不敢随便试。后来我在本地搭了个测试库把所有流程跑通再切到生产顺畅很多。GaussDB 社区版可以本地装或者用 Docker 起一个兼容 PostgreSQL 的实例做测试。心得二给 AI 的查询加引导性描述。我在工具描述里写了生产库请务必加 LIMIT实测下来 Claude Code 生成查询时确实更倾向于加 LIMIT。这不是安全机制但能减少无效查询。你还可以在描述里写清楚常用表名和字段含义让 AI 少走弯路。心得三定期审计 MCP 服务的查询日志。我会把每次查询的 SQL 和时间记到日志文件里每周扫一眼。有一次发现 AI 反复查同一张大表我一看是它没理解字段含义在反复试探。后来我在工具描述里补充了字段说明问题就没了。日志不仅能审计还能帮你优化 AI 的使用体验。心得四不要给 AI 开EXPLAIN ANALYZE的绿灯太久。EXPLAIN ANALYZE会实际执行查询在数据量大的表上可能跑很久。我现在的做法是允许但配合严格的statement_timeout。如果你的生产库负载很高建议直接在校验里禁掉ANALYZE。心得五MCP 服务的版本要跟着 Claude Code 更新。MCP 协议还在演进Claude Code 的版本更新可能会调整对 MCP 服务的调用方式。我遇到过升级 Claude Code 后工具调用参数格式变化的情况服务端要跟着改。建议把 MCP 服务的代码纳入版本管理升级客户端时同步测试。6. 后续可以怎么扩展这套东西跑通之后我又做了几个扩展顺手提一下给有需要的同学参考。扩展一加一个describe_table工具。专门查某张表的字段、类型、注释、索引信息。这个比让 AI 自己拼information_schema查询更稳定因为information_schema的字段名在不同数据库上略有差异封装成工具后 AI 不用关心这些细节。扩展二查询结果脱敏。生产数据里可能有手机号、身份证号这类敏感字段。我在返回结果前加了一层脱敏对特定字段名比如phone、id_card做掩码处理。这样即使 AI 看到了数据也不会泄露真实敏感信息。扩展三查询缓存。表结构这类信息变化不频繁我加了一个内存缓存同一个表结构在 5 分钟内只查一次数据库。这能减少对生产库的压力尤其是 AI 在排查问题时反复查同一张表的情况。扩展四多数据源支持。现在服务只连一个库我把它改成了可以配置多个数据源每个数据源有独立的只读账号和权限。这样我可以同时让 AI 查生产库和测试库对比数据差异。这套方案的核心思路其实很简单把信任从提示词转移到架构上。不指望 AI 永远不犯错而是让它在架构上就没有犯错的能力。数据库账号没写权限、服务层拦截写语句、超时和行数限制保护资源三层叠加我才能放心地让 Claude Code 连生产库。如果你也在做类似的事希望这些经验能帮你少踩几个坑。