news 2026/9/28 13:11:13

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Go实现MCP只读服务:让Claude Code安全查询GaussDB生产库

1. 为什么我要给 Claude Code 配一个只读的 GaussDB 通道

先说结论:我写了一个用 Go 实现的 MCP 服务,把 GaussDB 的查询能力以只读的方式暴露给 Claude Code。它解决的核心问题很具体——我想让 AI 帮我查生产库的数据、分析表结构、定位慢查询,但绝对不能让它在生产库上执行任何写操作。

这个需求不是凭空冒出来的。日常开发里,我们经常遇到这样的场景:线上某个业务表的数据对不上,需要拉几条样本看看;某张表的字段类型和文档不一致,想确认一下真实结构;一个慢查询卡了很久,想看看执行计划到底走了哪个索引。这些事以前要么自己连上去手动查,要么写个临时脚本跑一遍。现在有了 Claude Code 这类工具,理论上可以直接对话式地完成,但问题也随之而来——你不可能把生产库的写权限交给一个 AI。

MCP(Model 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,强制 LIMIT
list_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:password@host:port/dbname?sslmode=disable&statement_cache_capacity=0

这里有个 GaussDB 特有的坑:statement cache 有时候会出问题。pgx 默认会缓存 prepared statement,但某些 GaussDB 版本在特定配置下对 prepared statement 的支持不完整,表现为偶发的 "prepared statement already exists" 或者参数绑定异常。我一开始遇到间歇性查询失败,查了很久才定位到这里。把statement_cache_capacity设为 0 关掉缓存后,问题消失。代价是每次查询都要重新解析 SQL,但对于一个查询频率不高的辅助工具来说,这点开销完全可以接受。

3.2 连接池参数怎么定

即使是只读服务,也建议用连接池而不是单连接。因为 Claude Code 可能连续发起多个查询,单连接会串行化,体验很差。我用的是pgxpool:

config, err := pgxpool.ParseConfig(connString) if err != nil { return nil, err } config.MaxConns = 4 config.MinConns = 1 config.MaxConnLifetime = time.Hour config.MaxConnIdleTime = 10 * time.Minute

MaxConns给 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 下表的SELECT:

CREATE 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这样的列名里就包含 "update",created_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_encoding:

postgres://...?client_encoding=UTF8

另外,返回 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 碰生产库"这件原本让人心里发毛的事,变成了一件可以放心做的事。四层防线里没有哪一层是黑科技,都是很朴素的工程手段,但组合起来就形成了足够可靠的保障。如果你也在做类似的事,希望这些经验能帮你把坑填平。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 13:08:05

RK3562J上稳定运行MCP2518FD CAN-FD实战指南

1. 项目概述&#xff1a;为什么CAN-FD在RK3562J上跑不稳&#xff1f;先从MCP2518FD的“脾气”说起你手头有一块RK3562J开发板&#xff0c;刚焊好MCP2518FD CAN-FD控制器&#xff0c;接上示波器和CAN分析仪&#xff0c;一通电——收不到帧&#xff0c;发出去的帧被节点拒收&…

作者头像 李华
网站建设 2026/9/28 13:07:35

S905L3SB盒子刷安卓9.0+当贝桌面重获新生

1. 项目概述&#xff1a;一台被遗忘的盒子&#xff0c;如何用当贝桌面晶晨S905L3SB重获新生&#xff1f;你手边是不是也躺着一台开机要等两分钟、点个应用卡三秒、遥控器按十下才响应一次的电视盒子&#xff1f;它可能贴着“中国移动魔百和M301H”“联通沃家TV E900V22E”或者“…

作者头像 李华
网站建设 2026/9/28 13:06:23

PDF解析如何变成API?陌讯Skills实现Office-AI融合实战

上周整理知识库&#xff0c;客户丢过来三千多份PDF合同&#xff0c;要求AI能直接回答“哪些供应商的付款周期超过60天”这种问题。我第一反应不是去写脚本硬扛&#xff0c;而是把PDF解析能力封装成API&#xff0c;挂到陌讯Skills上&#xff0c;让大模型自己按需取数。这个思路跑…

作者头像 李华
网站建设 2026/9/28 13:05:46

源荷不确定性下的低碳调度:场景建模与求解实现

1. 这个课题到底在解决什么问题&#xff1a;源荷不确定性下的低碳调度难题这几年双碳目标带火了电力系统的低碳调度研究&#xff0c;但真正动手写过代码的人都知道&#xff0c;难点不在"低碳"二字上&#xff0c;而在"不确定性"这三个字。拿我自己的经历来说…

作者头像 李华
网站建设 2026/9/28 13:04:14

风光氢多主体合作运行:纳什谈判与ADMM分布式优化实现

1. 为什么风、光、氢三个主体需要“坐下来谈判”国内做新能源系统仿真的研究者&#xff0c;对“风–光–氢”这个组合一定不陌生。风电和光伏出力随机波动、氢能系统负责消纳和储能&#xff0c;看起来是天然互补的一对搭档。但真正把三个主体放在同一个系统里做联合运行优化时&…

作者头像 李华
网站建设 2026/9/28 13:03:45

SQLAlchemy 2.x实战:从裸SQL到ORM的进阶与避坑

如果你用Python写过一阵子业务代码&#xff0c;一定和我一样碰到过这种场景&#xff1a;数据库操作从最开始的手写SQL&#xff0c;慢慢变成字符串拼接&#xff0c;再变成参数化查询&#xff0c;最后发现不同数据库的方言差异搞得人头疼。MySQL里一行INSERT IGNORE&#xff0c;到…

作者头像 李华