关于 PostgreSQL (hardened, read-only)
这是一个面向 AI Agent 的 Rust PostgreSQL MCP 服务,通过 SQL AST 校验、只读事务和函数拒绝列表限制写入,并提供查询成本控制、OAuth 2.1 与审计日志。
主要功能
- 01SQL AST 校验与只读事务
- 02查询超时与成本限制
- 03OAuth 2.1 身份验证
- 04防篡改审计日志
- 05表结构与键作为 MCP 资源
适用场景
- 01允许 AI Agent 在只读权限下查询 PostgreSQL
- 02在执行前拒绝多语句及写入查询
- 03为数据库访问添加认证和审计
- 04向客户端提供表结构信息
MCP 相关
它通过 stdio 和 Streamable HTTP 向 MCP 客户端提供 PostgreSQL 查询及表结构资源,并支持多种 MCP 协议修订版本。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
npx -y postgres-mcp-hardenedDATABASE_URL密钥Connection string for the role the server connects as. Use a role that cannot write — the server refuses writes twice, but a read-only role is the layer that does not depend on us being correct. `--print-setup-sql` prints the SQL that creates one.MCP_STATEMENT_TIMEOUTServer-side statement timeout, e.g. `5s`. A question that would pin the database is cancelled by PostgreSQL, not by hope.MCP_ALLOW_TABLESComma-separated allowlist. A table off the list is refused by name, and hiding it inside a CTE, a view or a join does not help.MCP_AUDIT_LOGPath to the tamper-evident audit log. Entries are chained by hash and survive a restart; `--verify-audit` checks the chain against an off-host anchor.MCP_ADDRAddress to bind, default 127.0.0.1:8080.本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
postgres-mcp-hardened
🚧 版本 0.1.10 —— 外部审查的后续发现,以及一个矫枉过正的修复
已发布:面向五个平台的二进制文件,附带校验和、Sigstore 签名和构建来源证明;
.mcpb安装包,可一键安装;ghcr.io上面向 amd64 和 arm64 的镜像;npm 上的软件包; 以及官方 MCP 注册表中的条目。0.1.8 和 0.1.9 修复了四位独立审查人员在 0.1.7 中发现的六个绕过漏洞,我们自己一个都没发现。 前一天,我们花了两天进行自我对抗性测试,结果大多一切正常。通过你以为该写的测试,并不等于真正检查过。最重要的那个漏洞完全不需要任何权限:当某列被屏蔽时,通过
USING对该列进行联接,就能判断给定值是否存在;对于本项目建议配置的最小权限读取者来说,这就构成了完整的相等性预言机。0.1.10 修复了剩余的三个问题。 设置
MCP_ALLOW_SCHEMAS=public时,SELECT * FROM secret.salaries会被拒绝,但describe_table却会泄露同一张表的所有列、类型和默认值。普通连接字符串若未携带sslmode,会采用驱动程序的prefer模式;只要服务器拒绝 TLS,所有数据就会以明文传输,而线路上的任何人都能让服务器拒绝 TLS。MCP_SSLROOTCERT还会在 242 个公共证书颁发机构之外再添加一个私有证书颁发机构,而不是用它替换这些机构;因此,自以为已将信任固定到自有颁发者的运维人员,实际上并没有做到这一点。第一个版本的 TLS 修复有误,值得一读。 它判断“是否为环回地址”,并要求其他所有连接使用 TLS,结果一次提交就让六个 PostgreSQL 版本任务、一轮一致性测试、容器检查和对抗性测试集全部失败。这些测试都像普通部署那样连接数据库:
postgres://user:pass@postgres:5432/db,即私有网络上的服务名称。Docker Compose 和 Kubernetes 不是公共互联网;如果服务器要求桥接网络中的容器使用 TLS,人们就会干脆把服务器关掉。现在要判断的是是否有不可信任的人能够接入网络链路,而不是地址是否为环回地址;测试套件中也加入了一个通过真实套接字连接 PostgreSQL 的测试,该 PostgreSQL 只能通过服务名称访问,因为有关网络的规则就应该通过网络进行测试。
THREAT_MODEL.md中记录了一项资源限制,但尚未解决:49 字节的 SQL 就能让 PostgreSQL 在规划期间将一个常量展开为 5.9 GB 的后端内存,而五秒的statement_timeout无法阻止它。常见形式会被拒绝;这个问题普遍存在,其根源超出了本服务所能处理的范围。这里的每项修复都先在运行中的服务器上复现,再予以修复;每项修复都连同查询记录在
CHANGELOG.md中。本项目目前所有内容都处于 0.1.x 阶段,因为项目外还没有人用自己的数据运行过它。
官方 Postgres MCP 服务已于 2024 年弃用,但每月仍有 467k 次下载。它的全部防护仅限于数据库级只读事务——而这本身无法阻止所有写入操作。这是一个持续维护、采用纵深防御的 Rust 替代方案。
一个可直接替换的 Model Context Protocol 服务,可让 AI Agent 查询 PostgreSQL——在数据库层面强制只读,并提供真实的 SQL 验证、超时、成本限制、OAuth 2.1 和审计跟踪。支持 Streamable HTTP 和标准输入/输出,并协商 MCP 修订版本:2026-07-28(当前版本,自上游于 2026-08-03 发布后即为默认版本)、2025-11-25 和 2025-06-18——目前大多数已发布的客户端仍在使用这些版本。客户端会请求自己了解的版本;不会协商降级。
来试着突破它——一条命令,无需数据库
只读防护有离线模式。给它一条语句,它就会告诉你它的判断:无需数据库、无需配置,也无需永久安装任何内容。
npx postgres-mcp-hardened --validate "/* comment */ DROP TABLE users"
# REJECT: non-read-only statement: Drop
npx postgres-mcp-hardened --validate "SELECT 1; DROP TABLE users"
# REJECT: multiple statements are forbidden
npx postgres-mcp-hardened --validate "WITH d AS (DELETE FROM t RETURNING *) SELECT * FROM d"
# REJECT: non-read-only statement: non-read-only query (CTE / SELECT INTO / FOR UPDATE)
npx postgres-mcp-hardened --validate "SELECT * FROM orders WHERE id = 1"
# ALLOW
如果某条会写入数据的语句返回了 ALLOW,那就是任何人都能发给我们的最有价值的内容。
不需要可用的漏洞利用代码,也不需要详细报告——一行 SQL,再加上“这不应该被允许”,就是一份完整的报告。任何绕过防护的情况都应通过 SECURITY.md 报告;其他情况都是普通问题,提交问题的标准是我觉得这不对劲,而不是我确定有问题。
模糊测试器是确定性的,并会打印种子,因此它发现的任何问题都能在一台从未见过你的机器上复现——一百万次变异大约需要一分钟:
npx postgres-mcp-hardened --fuzz 1000000
# fuzz: 1000000 iterations, seed 1592594996, slowest validation 8 ms
# RESULT: 0 invariant violations
要针对真实数据库测试完整流程,运行 docker compose -f examples/docker-compose.yml up -d,即可启动带有示例数据的 PostgreSQL,以及连接到它的服务;连接所用的角色只有 SELECT 权限,没有其他权限。
迄今发现的所有绕过方式都记录在 src/validate.rs 的 MUST_REJECT 测试集里,每次提交都会运行,并保留其代价记录,而不是被整理掉。你发现的问题也会加入其中。
为什么
@modelcontextprotocol/server-postgres 已在 npm 上弃用(最后一次发布于 2024 年 12 月),但在截至 2026 年 8 月 9 日的 30 天内仍有 475,790 次下载。该肯定的功劳还是要肯定:它的方法并不草率——它会用 BEGIN TRANSACTION READ ONLY 包裹每条查询,并始终执行 ROLLBACK。这是一种真正有效的防护,本服务现在也采用了它。
问题在于,这是唯一的防护,而且并不完善:
-
只读事务不会阻止所有写入,回滚也无法撤销它放行的所有操作。 这是两个独立的事实,第二个才是关键。
gin_clean_pending_list()可以在SET TRANSACTION READ ONLY中运行,其操作会在回滚后依然生效:一个有 25 个待处理页面的索引,在事务回滚后会变为 0。pg_backup_start()会使会话进入备份状态,即使执行DISCARD ALL也无法清除;默认情况下,fast => false会等待分散检查点,同时强制开启full_page_writes,这会给繁忙的服务器带来实际开销。pg_import_system_collations()同样可以执行,且不会引发SQLSTATE 25006,但要谨慎看待它的影响:它会被回滚撤销,因此对于始终执行回滚的服务来说,它只是个有趣的现象,而非绕过方式。可以复现,但请先阅读两个前提条件,否则你会看到结果为 0 或出现错误,然后认定我们是在编造。上述三个函数都需要超级用户权限或对象所有权。导入操作只会恢复缺失的排序规则,因此必须先移除某个排序规则:
-- as superuser, and note these are three separate transactions: a statement that errors -- inside a block aborts the whole block, so they cannot be run as one. DELETE FROM pg_collation WHERE oid IN (SELECT oid FROM pg_collation ORDER BY oid DESC LIMIT 200); BEGIN READ ONLY; DELETE FROM pg_collation WHERE collname LIKE 'zu%'; -- ERROR: cannot execute DELETE ... ROLLBACK; BEGIN READ ONLY; SELECT pg_import_system_collations('pg_catalog'); -- 200, no error COMMIT; -- and now the rows are there
这些都是写入操作,也都发生在只读事务中,但其中一个会被拒绝,另一个却不会。这种不对称性,正是本服务不把事务当作唯一防护的原因。也正因如此,任何这些因素都不如角色重要:以上示例都需要最小权限读者所不具备的权限;当被指定的角色拥有写入权限时,本服务会拒绝以网络监听模式启动。它无法控制的是,有人会把哪一个连接字符串粘贴到客户端配置中,而通常他们粘贴的就是自己原本在用的那个。
- 没有语句超时、成本限制或行数限制——单条查询可以一直运行,直到服务器放弃。
- 没有身份验证、审计记录,也无法处理通过返回的行数据进行的提示词注入。
- 一个包含 143 行的源文件,自 2024 年 12 月以来无人维护,也没有测试套件。
本服务保留回滚机制,在其前面增加 AST 验证,并补上原项目从未具备的运维保障层。
postgres-mcp-hardened 与已归档的原始版本对比
已归档的 server-postgres | postgres-mcp-hardened | |
|---|---|---|
| 只读强制机制 | BEGIN TRANSACTION READ ONLY + ROLLBACK——只有一层保护,而且 PostgreSQL 允许部分写入操作通过这一层 | AST 验证(sqlparser) 加上 相同的只读事务和回滚,再加上 针对会绕过该机制执行写入的函数的拒绝列表 |
通过 CTE 执行多语句 / DROP | 请求会到达数据库,且只有事务能阻止它 | 由解析器拒绝,不会到达数据库 |
| 语句超时 | 无 | 强制执行 statement_timeout + idle_in_transaction_session_timeout |
| 失控 / 高成本查询 | 不受限制地运行 | EXPLAIN 成本保护机制会在执行前拒绝这类查询 |
| 通过行数据进行提示词注入 | 原始输出 | 包装为 trusted="false" + 转义分隔符 |
| 错误消息 | 泄露架构信息(relation X does not exist) | 结构化、不泄露信息且可据此采取操作 |
| 身份验证 | 无 | OAuth 2.1(RS256 JWT、范围 + 受众 + 签发者) |
| 审计 | 无 | 防篡改哈希链日志 |
| 作为 MCP 资源的架构 | ✅ | ✅ — 另含注释、主键和外键 |
| 测试 / CI | 无 | 针对实时 PostgreSQL 的单元测试 + 端到端测试套件、确定性模糊测试框架、由官方 MCP SDK 驱动的一致性测试,以及每次推送时执行 clippy + cargo audit + 容器构建 |
| 传输方式 | stdio / 已弃用的 SSE | Streamable HTTP + stdio |
| 是否维护 | ❌ 自 2024 年起已弃用 | ✅ |
安装
五种接入方式,按大多数人偏好的顺序排列。
一键安装,适用于支持 .mcpb 捆绑包的客户端:从最新版本下载
postgres-mcp-hardened-<your-platform>.mcpb 并打开。捆绑包会要求提供连接字符串,并将其存储在操作系统密钥链中,而不是明文配置文件中。无需安装,也无需编辑任何内容。
通过 npm 安装——步骤最少,也是 MCP 客户端配置可以直接指向的方式。运行时不会用到 Node:这个包是一个启动器,会获取适用于你平台的原生二进制文件,并在运行前验证其校验和。
{
"mcpServers": {
"postgres": {
"command": "npx",
"args": ["-y", "postgres-mcp-hardened", "--stdio"],
"env": { "DATABASE_URL": "postgres://readonly_user:PASSWORD@localhost:5432/mydb" }
}
}
}
连接字符串应放在 env 中,而不是 args 中,这是有意为之:在共享机器上,参数会出现在 ps 输出和 shell 历史记录中,而数据库密码不应该出现在那里。
从发布页面获取二进制文件——只有一个文件,无需持续更新;如果你的机器上完全没有 Node,这是可选的方式。(它不是静态二进制文件,尽管本页面在 0.1.7 之前一直这么说:-gnu 和 macOS 目标平台会像其他原生程序一样链接系统 C 库。除此之外无需安装任何东西。)每个版本都提供适用于 Linux、macOS 和 Windows 的 x86-64 和 arm64 构建,每个构建都附带校验和与签名;下一节将介绍如何验证它们。
作为容器运行,如果你平时就是这样运行程序的。该镜像采用 distroless 设计,并以非 root 用户运行;二进制文件和镜像都由同一组签名覆盖。
docker run --rm -p 127.0.0.1:8080:8080 --memory=512m \
-e DATABASE_URL="postgres://readonly_user:PASSWORD@db-host:5432/mydb" \
-e MCP_ADDR=0.0.0.0:8080 \
-e MCP_BEARER_TOKEN="$(openssl rand -hex 32)" \
ghcr.io/eszetael/postgres-mcp-hardened:latest
--memory 并非摆设。服务空闲时占用 7.7 MB,普通请求只需个位数 MB,但调用方可以写入 SELECT repeat('x', 100000000),将峰值内存推高到 400 MB——这并非由结果导致,结果始终不超过 300 字节;问题出在成本限制器自己的 EXPLAIN 上,PostgreSQL 会在规划时将折叠后的常量填入其中。这是 THREAT_MODEL.md 中列出的残余风险,并记录了尝试过的三种修复方案及其各自造成的问题。在此问题解决之前,内存限制是有效的防护措施,因此请设置它:此处使用 --memory,systemd 下使用 MemoryMax=。
MCP_ADDR 必须绑定到 0.0.0.0,而不能绑定到 127.0.0.1,否则服务会监听一个仅存在于容器内部的接口,发布的端口将无法响应。另一个容易踩的坑是:DATABASE_URL 中的 localhost 指的是容器,而不是你的机器;因此,运行在主机上的 PostgreSQL 需要使用 host.docker.internal(Docker Desktop)或主机在网桥上的地址(Linux 上默认为 172.17.0.1)。写入本文前,我们已针对发布的镜像端到端验证了这两种情况,包括读取操作会返回行,而 DROP TABLE 会返回
-32602 non-read-only statement: Drop。
从源代码构建——在克隆的仓库中运行 cargo build --release。不要运行 cargo install:这个 crate 不在 crates.io 上;会失败的说明比缺少说明更糟糕。
检查下载的文件
每个发布的二进制文件都使用 Sigstore 无密钥签名进行签名——我们没有可能丢失的私钥,证书会注明生成该文件的工作流、仓库和标签。每个文件旁边都会附带一个 .sig 和一个 .pem 文件:
F=postgres-mcp-hardened-x86_64-unknown-linux-gnu.tar.gz
cosign verify-blob "$F" --bundle "$F.bundle" \
--certificate-identity-regexp '^https://github.com/Eszetael/postgres-mcp-hardened/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
固定身份,而不只是签名。若不指定 --certificate-identity-regexp 和
--certificate-oidc-issuer,检查结果只会回答“有人签署了这个文件”,但这并不是我们要问的问题。经过验证的证书会注明构建该文件的工作流、仓库和标签——你可以通过 base64 -d "$F.pem" | openssl x509 -noout -text 读取证书(cosign 写入的证书采用 base64 编码,这会让那些直接尝试对其运行 openssl 的人感到意外)。
较早版本的 cosign 尚不支持 --bundle;它们使用单独的 .sig 和 .pem 文件,这两个文件会一并发布,并通过 --signature "$F.sig" --certificate "$F.pem" 指定。当前版本的 cosign 已将这些参数标记为弃用,因此建议使用捆绑文件。
公开发布版本还附带 SLSA 构建来源证明,可使用
gh attestation verify <file> --repo Eszetael/postgres-mcp-hardened 进行验证。
在 Claude Desktop / Cursor 中使用(stdio)
{
"mcpServers": {
"postgres": {
"command": "postgres-mcp-hardened",
"args": ["--stdio"],
"env": { "DATABASE_URL": "postgres://readonly_user:YOUR_PASSWORD@localhost:5432/mydb" }
}
}
}
或将其作为远程服务运行(Streamable HTTP)
DATABASE_URL="postgres://readonly_user:YOUR_PASSWORD@host:5432/mydb" \
MCP_ADDR="0.0.0.0:8080" \
postgres-mcp-hardened
# POST /mcp · GET /health · GET /ready · GET /metrics
TLS:只要 PostgreSQL 服务支持,加密连接始终会启用;同时接受
sslmode=require、verify-ca和verify-full(始终验证证书链 和 主机名,因此require的行为与verify-full相同)——因此,托管的 Postgres(RDS、Supabase、Neon、Render) 开箱即用。证书和主机名始终会经过验证——已验证(接受标准:“若证书指向其他主机,则会按名称拒绝”);对于私有 CA,请将MCP_SSLROOTCERT指向 PEM 文件包。没有“信任所有内容”的开关。提示:将
DATABASE_URL指向权限最小化的只读角色。服务本身会强制执行只读限制,但限定范围的数据库角色还能提供纵深防御。
或在容器平台上运行(Apify Standby)
无需更改代码,即可将此服务作为 Apify Actor 以 Standby 模式运行。它会从 ACTOR_WEB_SERVER_PORT 读取平台分配的端口,并绑定到 0.0.0.0——该端口的优先级高于 MCP_ADDR,并会在标准错误流中明确提示,因为绑定到其他地址会导致运行始终无法标记为就绪,故障看起来就像是神秘的超时。GET / 会响应平台的就绪探测(x-apify-container-server-readiness-probe),且不会访问数据库:容器就绪不等于数据库就绪,如果探测因连接池繁忙而等待,就会让缓慢的数据库导致容器永远无法启动。
| 端点 | 方法 | 用途 |
|---|---|---|
/mcp | POST | MCP 端点(Streamable HTTP)。DELETE 会结束会话。 |
/ | GET | 就绪探测;否则显示指向真实端点的说明 |
/health | GET | 进程仍在运行 |
/ready | GET | 进程和数据库连接均可用 |
/metrics | GET | 计数器(需要 MCP_METRICS_TOKEN) |
/.well-known/mcp/server-card.json | GET | 注册表读取的信息:修订版本、传输方式、工具 |
输入是 POST 请求体中的 JSON-RPC 请求——initialize、tools/list、tools/call、resources/list、resources/read、server/discover。输出是 JSON-RPC 响应;从 2025-11-25 起,被拒绝的语句会作为工具执行错误返回(isError: true),并在内容中说明原因,以便模型重写查询。tools/list 是每个参数的权威说明。
此处的身份验证由平台负责,而非我们负责。 Apify 会在将请求路由到容器之前检查调用方的令牌,因此服务不会额外要求 MCP_BEARER_TOKEN——如果要求提供第二个密钥,找到此服务的 Agent 就无法调用它。此豁免范围有限:必须同时设置 APIFY_IS_AT_HOME 和 ACTOR_WEB_SERVER_PORT,仅设置其中一个不会产生任何影响;此时服务卡会报告 "type": "apify-platform",而不会声称存在我们并未持有的锁。其他情况下,服务仍会拒绝在没有身份验证的网络地址上启动。如果你想在同一扇门上再加一道锁,也可以设置 MCP_BEARER_TOKEN。
另一项启动门槛保持不变,而且在此场景中更为重要:具有写入权限的角色不得启动网络监听器。请将 DATABASE_URL 指向只读角色——--print-setup-sql 会写入语句。
从已弃用的服务迁移
针对 @modelcontextprotocol/server-postgres 报告最多的问题,也在此服务上进行了复现;以下是每个问题在此处的表现:
| 用户报告的问题 | 此处的表现 |
|---|---|
| 两个实例(生产环境 + 开发环境)无法区分,客户端会选中其中一个 | 资源 URI 中包含数据库名称(postgres:///mydb/public/orders/schema),而 MCP_SERVER_LABEL 会在客户端 UI 中显示实例名称 |
| 每个实例只能连接一个数据库,因为连接字符串是命令行参数 | MCP_DATABASE_URLS="prod=…;dev=…" 可让一个服务连接多个数据库;每个工具都接受可选的 database,资源也涵盖所有数据库 |
no pg_hba.conf entry … SSL off | 错误会指出服务要求 TLS,并说明修复方法(?sslmode=require) |
通过注入 COMMIT / END 绕过只读限制 | 会被拒绝——多语句检查在解析器之前基于令牌进行,单独的 COMMIT 也会被拒绝为写操作 |
spawn npx ENOENT、Node 版本问题 | 只需一个原生二进制文件;无需 Node、npx 或 node_modules |
| 连接 RDS 时无限期挂起,没有输出或错误 | 有超时限制:无法访问的主机会在约 8 s 内返回原因,不会悄无声息 |
self-signed certificate in certificate chain | 将 MCP_SSLROOTCERT 指向 CA 证书包;错误会指出该变量 |
| 连接字符串只能作为命令行参数传入 | 可使用 DATABASE_URL 或位置参数——原有调用方式仍然有效 |
密码中含特殊字符时出现 INVALID_URL | 错误会说明哪些字符需要进行百分号编码,以及如何编码 |
| 分区子表涌入表列表和资源列表 | 默认隐藏;设置 MCP_SHOW_PARTITIONS=1 即可显示 |
-32601 Method not found、Unexpected end of JSON input | 已实现 ping 和资源;多行 JSON 会先缓冲,直到内容完整;批处理请求会被拒绝并返回清晰错误,而不是毫无响应 |
| 没有行数限制——一次查询就能塞满上下文 | 自动添加 LIMIT、设置 8 MB 字节上限,并明确标记 truncated |
测试
除了单元测试外,仓库还包含两个测试工具,它们会在 CI 中针对每次变更运行:
--fuzz— 一个确定性模糊测试器,会对已知写操作语料应用不改变 SQL 含义的变换(注释、大小写、美元引用、不可见 Unicode 字符、括号),并断言这些语句绝不会变成允许执行的语句。tests/acceptance.sh— 一个端到端测试套件,会启动自己的 PostgreSQL,并检查 317 项行为:针对已弃用服务报告的每种写操作绕过方式(包括注入COMMIT/END)、结果是否真实、模式内省、协议一致性、配置错误是否明确报错、审计篡改检测、负载下的公平使用,以及多数据库部署。
对所有已报告问题逐一作答
docs/COMMUNITY_ISSUES.md 是完整的问题记录:针对已弃用服务报告的每个问题,以及针对仍在维护的替代方案提出的每个未解决问题,都列出了此处的处理方式——包括少数无法通过代码修复的问题,并如实说明。
我们从其他方案中学到了什么
这个领域中的每个服务都有问题跟踪器,而这些跟踪器记录了问题所在。 我们有意针对以下问题进行了改进:
- 发布的镜像落后于代码。 这是针对领先替代方案支持度最高的公开投诉。我们的容器使用生成二进制文件的同一标签构建并推送,因此不会出现版本脱节。
- 查询超时写死。 这也是他们最受欢迎的功能请求之一。
MCP_STATEMENT_TIMEOUT可配置,并会在启动时验证。 - 默认不限制访问。 有些服务默认允许读写,并依赖运维人员加以限制。而本服务根本没有写入路径。
- 客户端配置中包含凭据。
MCP_PASSWORD_FILE可让密码不出现在配置中。 - 非默认架构中的表会被静默地视为不存在。
MCP_SEARCH_PATH可修复查找问题,而且工具也可以显式指定schema。 - 传输方式已弃用。 HTTP+SSE 于 2025-03-26 被 Streamable HTTP 取代,至今已过去三个修订版本(本页面在 0.1.7 之前一直写着 2025-06-18,比实际晚了一个修订版本;规范自身的 2025-03-26 更新日志记录了这项替换)。我们使用的是当前传输方式。
故障排查
以下是人们在已弃用的服务上实际提出的问题及其解答,让大家不必为了查找答案而提交问题。
spawn npx ENOENT /“需要哪个 Node 版本?”——都不需要。这是一个原生二进制文件,无需另外安装运行时。从发布页面下载,并让客户端指向该文件即可。没有 node_modules,没有 npx,也没有需要保持更新的东西。
“服务已启动,但没有端口在监听。”——这是 stdio 模式,对 Claude Desktop 和 Cursor 来说是正常的:客户端通过进程的标准输入和输出进行通信,而不是通过套接字。如果需要网络端点,请在不带 --stdio 的情况下启动;此时它会打印 MCP HTTP listening on http://…,并使用 Streamable HTTP。
“另一台机器上的客户端能访问数据库吗?”——可以:在数据库旁边以 HTTP 模式运行服务,公开该服务,并启用 OAuth(JWT_PUBKEY_PEM、JWT_AUD、JWT_ISS)。这样,数据库凭据就不会离开服务运行所在的主机。
“无法连接到 MCP 服务。”——进程在握手前就已退出。请在终端中运行相同的命令:配置错误会打印原因并以状态码 2 退出,而不是悄无声息地终止;连接问题则会在首次查询时报告并说明原因。
self-signed certificate in certificate chain / unable to verify the first certificate——你的服务提供商使用私有 CA(Supabase、GCP 和 RDS 都是如此)。请下载其 CA 捆绑包,并将 MCP_SSLROOTCERT 设置为该文件。错误消息会指出对应服务提供商的处理步骤。我们不提供“信任所有内容”的开关。
托管服务提供商
此服务始终验证数据库证书,包括使用 sslmode=require 时。这是有意偏离 libpq 的做法:在 libpq 中,require 只加密而不验证,因此中间人可以读取并篡改每条查询和结果,却不会被察觉。严格验证的代价是,使用私有 CA 的服务提供商需要多做一步;放宽验证的代价则是,你永远不会知道自己是否遭到攻击。如果你不同意这种权衡,使用下面的证书包配置 verify-full,工作量相同,也能彻底消除疑虑。
| 服务提供商 | 预期情况 |
|---|---|
| Supabase | 私有 CA。在 Dashboard → Project Settings → Database → SSL configuration 中下载证书,然后将 MCP_SSLROOTCERT 指向该证书。直连主机(db.<ref>.supabase.co)仅支持 IPv6——在 IPv4 网络中使用 Supavisor 连接池字符串(端口 6543),它也适用于无服务器环境和短连接。 |
| Amazon RDS / Aurora | 私有 CA:https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem。支持 IAM 身份验证——将生成的令牌填入密码字段,并记住它会在 15 分钟后过期。 |
| Google Cloud SQL | 私有 CA:Connections → Security → server-ca.pem。通过 Cloud SQL Auth Proxy 连接时,连接到本地主机上的代理即可,TLS 由代理负责。 |
| Azure Database for PostgreSQL | 公共 CA——无需下载。Azure 在 2026 年第 1 季度将根证书轮换为 DigiCert Global Root G2;我们随附 Mozilla 根证书库,因此此次轮换无需你进行任何操作。 |
| Neon | 公共 CA(ISRG Root X1,Let's Encrypt)——无需下载。连接池端点和直连端点均可使用。 |
| DigitalOcean | 私有 CA:从集群的 Overview 页面下载证书。 |
不确定自己属于哪种情况?在进行任何配置之前,先询问服务本身:
echo | openssl s_client -starttls postgres -connect YOUR_HOST:5432 2>/dev/null \
| openssl x509 -noout -issuer
如果颁发者是知名机构(DigiCert、ISRG、Google Trust Services),说明可以直接使用;如果名称中包含你的服务提供商,则需要使用其证书包。
使用事务模式的连接池(Supavisor、PgBouncer)时,请注意,此服务会为每个会话设置 statement_timeout 和 idle_in_transaction_session_timeout,并在显式只读事务中运行每条查询。这两项都兼容事务池化;在事务之外进行会话级 SET 则不兼容,因此我们不会这么做。
no pg_hba.conf entry … no encryption——服务器只接受该主机和用户的 TLS 连接。在连接字符串中添加 ?sslmode=require。
INVALID_URL / invalid connection string——密码中包含 @、:、/、# 或 ? 时,必须进行百分号编码(@ → %40,: → %3A,/ → %2F,# → %23)。
“我的表有数百个分区,列表无法使用。”——默认会隐藏分区子表,只列出父表。如需显示分区子表,请设置 MCP_SHOW_PARTITIONS=1。
“我需要同时连接生产环境和预发布环境。”——可以为每个数据库分别运行一个服务(通过设置 MCP_SERVER_LABEL 区分),也可以使用 MCP_DATABASE_URLS 在一个服务中配置两者,并在工具参数中传入 database。
资源
每个表和视图都作为 MCP 资源(postgres:///<schema>/<table>/schema)公开,因此客户端无需执行查询即可浏览架构——这与已弃用的服务端提供的能力相同,此外负载中还包含列注释、主键和外键。
数据库尚未响应时,resources/list 会返回空列表,并在 _meta 中说明原因,而不是返回协议错误。检查此服务端的目录会在完全没有数据库的情况下启动它,并在 initialize 后立即调用 resources/list;如果对此返回错误,就像是在说服务端无法正常工作。空列表并不代表没有表——initialize 会说明数据库尚未响应,security_posture 会提供详细信息,原因也会随列表一同返回。数据库若确实已响应但拒绝了请求,仍然属于错误,因为对权限缺失却报告“没有资源”正是本服务端旨在避免的静默故障。
已验证(验收标准:“主机在没有数据库且前置 mcp-proxy 的情况下也能检查服务端”)
工具
explain_query— 执行计划;启用analyze后会运行语句并报告实际耗时和缓冲区使用情况。这里是安全的,因为语句经过验证,只读,并在始终会回滚的事务中运行。计划还附带summary:指出哪个节点耗费了时间(自身耗时,不含子节点),以及规划器的行数估算与实际情况偏差最大的地方——因为估算错误通常就是计划不佳的原因。database_health— 缓存命中率、连接数(此数据库和整个集群)、运行时间最长的语句和被遗弃时间最长的事务(分别统计)、清理积压、无效索引、接近上限的序列、复制延迟、从未分析过的表(没有规划器统计信息——这是数据库看似健康却运行缓慢的常见原因),以及计数器涵盖的时间范围。角色无权查看的内容会明确标示,而不会以确定的零值返回。analyze_indexes— 未使用的索引、重复索引,以及因频繁进行顺序扫描而值得创建索引的表。top_queries— 来自pg_stat_statements的开销最大的语句。security_posture— 通过询问 PostgreSQL,而非凭空假设,确认此部署实际上能对数据库执行哪些操作:角色是否能写入、绕过行级安全策略或访问服务端文件;传输是否经过身份验证;审计链是否使用密钥;连接是否加密。返回一个等级——取最严重的问题,绝不取平均值;若有问题,还会提供修复命令。同一摘要也会通过initialize传达给模型,因为使用 stdio 时无人能看到 stderr,而 Agent 是操作员唯一能收到消息的途径。query— 执行只读 SQL 查询(经过验证、自动添加LIMIT、受成本限制)。响应会说明执行情况:returnedRows、appliedLimit、truncated;如果较大的请求被限制为最多 10000 行,还会包含requestedLimit;分页时包含offset;配置了遮蔽时包含redactedColumns——因此 Agent 无需猜测是否收到了完整结果。list_schemas、list_tables、describe_table— 逐步发现架构(参数化,可防注入)。describe_table会返回架构注释(COMMENT ON TABLE/COLUMN)、主键、外键和默认值,让 Agent 能了解列的含义及其指向,而不是根据列名猜测;表不存在时会报错,而不会返回空列列表。
协议修订版本
如果服务端实现了客户端请求的修订版本,就会在 initialize 中返回该版本;否则返回最新版本。通过 HTTP 发送的请求会从 MCP-Protocol-Version 标头中读取修订版本,每个请求各自协商——一个客户端的协商结果不会改变另一个客户端所使用的协议。
如果请求没有携带标头,服务端不会直接退回到最旧的协议。它会读取本会话在 initialize 时协商的修订版本,这正是传输规范的要求:默认版本仅适用于“如果服务端未收到 MCP-Protocol-Version 标头,且没有其他方式识别版本——例如,依赖初始化期间协商的协议版本”。会话就是这种其他方式,因此,协商了 2025-11-25 的客户端即使随后省略标头,也会继续使用协商好的协议,而不会被悄然降级。
无法解析的标头与缺少标头是两回事,规范对此有明确规定:“如果服务端收到的请求包含无效或不受支持的 MCP-Protocol-Version,它必须以 400 Bad Request 响应。”我们未实现的版本——2025-03-26 或 not-a-date——会被拒绝,并返回我们支持的修订版本列表,而不是按客户端从未同意的协议处理。只有未提供版本时才会回退,存在分歧时不会回退。
只有请求既没有标头,也没有会话时才会回退;此时会回退到 2025-06-18——这是本服务端实现的最旧修订版本——而不是规范指定的 2025-03-26。这里并未实现那个版本,按服务端无法遵守的协议作答,比按它能遵守的最旧协议作答更糟。
拒绝请求时,关键区别在于拒绝如何传达。在 2025-06-18 下,“此语句不是只读的”会作为 JSON-RPC 错误返回:客户端看到的是一次失败调用,而模型通常根本看不到原因。从 2025-11-25(SEP-1303)开始,它会作为工具执行错误返回——isError: true,原因包含在内容中——因此模型会改写查询,而不是把失败直接交给用户。不变的是审计记录:拒绝请求的代码会记录拒绝信息,验收套件也会同时断言这两点,因此,更友好的错误永远不会悄然变成更少的日志记录。
Mcp-Method 和 Mcp-Name 的要求是内容一致,而非必须存在。草案要求提供它们;早期修订版本并未要求,强行要求会破坏所有现有客户端。但如果网关依据 Mcp-Method 进行路由或授权,而服务端却执行请求正文,那么它做出决定的请求与实际执行的请求就不一致——无论当前采用哪个修订版本,都是如此。因此,无论采用哪个修订版本,只要标头存在,就必须与请求正文相符;未发送这些标头的客户端则不受影响。
协议错误仍然是协议错误。格式错误的信封、未知方法或缺少令牌,都不是模型通过改写 SQL 就能修复的问题;客户端的错误处理也依赖这些错误一如既往地出现。
发布前的下一版
2026-07-28 是 MCP 迄今最大的一次破坏性变更:没有 initialize、没有会话标头,也没有 ping。这个标识符来自草案模式中的 LATEST_PROTOCOL_VERSION,它不是发布日期——MCP 会以最近一次引入不兼容变更的日期为版本命名,因此它描述的是草案的历史,而非发布时间表。每个请求都会在 _meta 中携带自己的协议版本,而新的 server/discover 则取代了握手。我们提前实现了这一版,并用开关控制,因为草案会不断变化,而公开宣称支持一个不断变化的目标,最终必然出错。上游于 2026-08-03 发布了 schema/2026-07-28——发布的模式与我们验证过的草案相比,只有四个文档 URL 不同,其他内容完全一致——因此我们移除了开关,这就是服务默认使用的协议版本。对于使用 2025-11-25 和 2025-06-18 的客户端,响应方式与之前相同。
无论使用哪个版本,server/discover 都会响应,因为规范希望客户端用它来探测向后兼容性——而这只有在旧服务也会响应时才行。我们的响应会列出支持的版本,并在 _meta 中提供完整的安全态势。这是刻意为之:客户端可以在发送查询之前,得知自己连接的是以超级用户身份连接的服务;这些信息以结构化数据形式呈现,而不是依赖模型从文字中自行察觉。
草案中的两条规则在这里是安全控制措施,而非形式要求。Mcp-Method 和 Mcp-Name 必须与请求正文一致;若不一致,我们会拒绝请求(-32020)——这些标头的存在,是为了让网关无需解析正文就能路由并授权;如果标头与正文可能不一致,那么负责授权的组件和负责执行的组件看到的就是两个不同的请求。而客户端声明的版本如果我们并未实现,也会收到明确告知(-32022),而不是在它从未同意的协议约定下悄悄提供服务。
安全措施的代价
测量结果,而非断言:tests/bench/,使用 pg 驱动在同一台机器上运行相同查询(Docker 中的 PostgreSQL 18.6、5 万行表、300 个顺序请求、关闭速率限制)。2026-08-17 在一台平均负载为 2.6 的共享 VPS 上重新测量——两次运行的中位数:
| 查询 | 驱动 | 本服务 | 差值 |
|---|---|---|---|
| 单点查询 | 0.43 ms | 5.9 ms | +5.5 ms |
| 小范围扫描 | 1.0 ms | 8.3 ms | +7.3 ms |
| 聚合查询 | 4.7 ms | 11.3 ms | +6.6 ms |
本页早先的一张表写的是 +3.6/+5.2/+3.7,并称「约 4 ms」。这些数据是在更空闲的机器上测得的,驱动的基准耗时也随之变化——同样的单点查询,之前是 0.28 ms,而现在是 0.43 ms——所以这是硬件的影响,不是代码造成的。更改数据前,我们检查了两件事,因为最明显的怀疑对象是我们自己的构建:在启用 lto 之前构建的 0.1.6 二进制文件,在这台机器上同一小时内测得的差值为 +5.4/+7.8/+5.9。两者完全一致。发布配置文件将二进制文件大小减半,但在这里没有带来任何性能损失。
预计每次查询会增加 5 到 8 ms,并将本页上的任何单个数值视为某台机器在某一天的一次测量结果。比数值大小更重要的是变化趋势:开销几乎恒定。如果开销来自 AST 验证,它应该会随查询复杂度增长,但事实并非如此。耗时来自多次往返——重置会话、设置超时和只读标志、开启只读事务、由成本防护机制规划语句,然后执行查询并回滚事务。驱动只需一次交互,而这里需要五到六次。
这是刻意的取舍,它带来的好处也一目了然。对于会发出几十次调用的 Agent 来说,这种开销可以忽略;如果你要将它放在对延迟极其敏感的服务路径前面,那就是用错了工具,而且它也不是为此设计的。
在并发场景下,值得关注的不是吞吐量,而是触及限制后会发生什么:有 8 个并发客户端时,它处理了每秒 373 个请求,并拒绝了另外 192 个请求,提示「in-flight 请求过多」;这说明并发请求上限正在发挥作用,而不是让队列不断增长,直到系统崩溃。
无需创建索引,也能回答:这个索引有帮助吗?
领先的替代方案真正以一项功能著称,那就是索引调优:它能在你创建索引之前,告诉你创建索引是否值得。它默认使用能够创建真实索引的连接来实现这一点——只有在你记得限制权限时才安全。
simulate_index 通过一个无法写入任何内容的连接,回答同一个问题。
hypopg 会在后端内存中注册一个假设索引:规划器能看到它,存储层看不到它;调用返回后,它就消失了。你可以获得有无该索引时的执行计划和成本;此外,还能单独得知规划器是否实际采用了它,因为成本几乎没有变化和规划器忽略了索引,是两种不同的答案。
该工具接收一个表和一个列名列表。不是一条 CREATE INDEX 语句。 索引定义由服务端根据系统目录确认存在的标识符组装,并由 PostgreSQL 自行引用,因此工具参数无法转化为任意 DDL——包含 SQL 的列名会在系统目录查询时被拒绝,而且有一项测试专门验证这一点。这些数字是规划器的估算值:大幅改善是测试该索引的理由,而非证明。
由其他人的客户端检查符合性
此处其他所有测试都是我们的测试框架与我们的服务端交互。如果我们误读了规范,那么双方都会以同样的方式误读,一切测试都会通过。因此,CI 还会通过 stdio 和 Streamable HTTP,使用官方 MCP SDK——生态系统所用的客户端库——驱动服务端:握手、工具列表和架构、读取、作为工具执行错误而非协议错误返回的写入拒绝、资源列表和读取。协议错误会表现为客户端无法与我们通信。tests/conformance/。
开发
git config core.hooksPath .githooks # once, per clone
.githooks/pre-push 会在任何内容离开你的机器之前运行格式检查、clippy、单元测试和文档声明检查。设置它是因为发生过这样一件事:一次提交带着未通过的 clippy 检查发出去了,而大家直到收到失败邮件才知道。请注意,cargo test 在这段代码上会通过——clippy 检查不是编译器错误——所以“本地能编译”并不等于“CI 会通过”。
它会有意跳过验收测试套件和 PostgreSQL 矩阵:这些需要 Docker,而且大约要花二十分钟;如果钩子耗时长到无法接受,人们就会绕过它。CI 会运行所有内容。想故意看看 CI 失败时,可以运行 git push --no-verify。
设置角色
DATABASE_URL=postgres://admin@host/mydb postgres-mcp-hardened \
--print-setup-sql --role mcp_reader --schemas public --redact ssn,email > setup.sql
# read it, then:
psql -v pw="$(openssl rand -base64 24)" -f setup.sql mydb
使用连接字符串运行时,表和列列表会从系统目录中读取;不提供连接字符串时,你会得到带占位符的同一份文档。这一区别在数据脱敏方面尤为重要:要授予回读权限的列必须从数据库中读取,因为凭记忆填写列名,正是把本该隐藏的列交还出去的原因。
输出末尾包含一些检查;操作成功时,这些检查不会返回任何行。此外还有提醒:只要将服务端连接到数据库,它就会告知你该角色可以执行哪些操作。
限制服务可访问的内容
MCP_ALLOW_SCHEMAS 和 MCP_ALLOW_TABLES 会限制查询可以访问哪些关系。设置其中任意一个即可启用允许列表;schema.* 和 schema.table 两种格式均可使用。
MCP_ALLOW_TABLES='public.customers,public.orders,analytics.*'
检查针对的是查询计划,而不是 SQL。这就是整个设计的关键:规划器已经应用了 search_path、解析了所有别名、将视图展开为基表,并且知道名为 customers 的 CTE 并不是 customers 表。因此,WITH customers AS (SELECT 1) SELECT * FROM customers 可以执行且不会访问任何内容,而 WITH x AS (SELECT * FROM salaries) SELECT * FROM x 会被拒绝。读取语句本身的做法在三轮对抗性审查中都未能过关。
启用前,有两点值得了解:
- 分区依附于父表。 允许访问
events后,由 PostgreSQL 决定读取哪些子表。 - 视图的基表也必须加入允许列表,因为计划中列出的是这些基表。将它们都加入允许列表,并通过数据库权限确保无法直接访问基表——无论如何,这才是真正的边界。
除非设置 MCP_ALLOW_CATALOG=1,否则 pg_catalog 和 information_schema 不在可访问范围内:如果 Agent 仍能读取系统目录,就可以准确枚举出允许列表本想隐藏的内容。架构工具仍可正常工作,因为它们执行的是固定查询,而不是调用方提供的 SQL。
这是通往同一事实的三条路径,因为有一段时间它只涵盖其中一条。目录视图会被规划为扫描实际关系,计划中也会列出这些关系;但 pg_settings 会被规划为对 pg_show_all_settings 的单个 Function Scan,其中没有列出任何关系;而 current_setting() 是标量调用,不会作为扫描出现。过去,即使启用了允许列表,这两者也都能返回服务配置。现在,名称以 pg_ 开头的函数,以及 current_setting、inet_server_addr 和 inet_server_port,都会与目录关系一同被拒绝;设置 MCP_ALLOW_CATALOG=1 即可同时开放所有这些内容。普通的返回集合函数——generate_series、jsonb_each、unnest、regexp_split_to_table——不带此前缀,因此不受影响。current_user、session_user、current_database 和 version 则仍可读取,这是有意为之:Agent 已经知道自己连接到了什么,以及以谁的身份连接。
成功绕过控制的案例集
审查期间绕过控制的每一种情况,都记录在 tests/adversarial/ 中,并注明了在哪一轮修复。每次构建都会运行这些测试;由于案例针对的是占位符而不是我们的测试数据,你也可以将其用于自己的数据库:
ADV_URL='postgres://…' \
ADV_TABLE=people ADV_TABLE2=orders ADV_REDACT_COL=ssn \
./tests/adversarial/run.sh
ADV_TABLE 需要包含敏感列,ADV_TABLE2 可以是任何其他可读取的表,ADV_REDACT_COL 是要脱敏的列。这三项都很重要:这个示例直到 0.1.7 才补上 ADV_TABLE2,因此它一直使用默认值 film——Pagila 示例数据库中的一张表——而严格按照说明操作会产生三个“匹配失败”,实际原因却只是缺少关系。现在,测试框架会预先检查这三项并指出错误项,因为如果安全测试集把拼写错误报告成控制失效,就会让人忽视真正重要的失败。
如果一项安全声明只能通过阅读我们的源代码来验证,那就只能选择相信它;而这个项目自身的历史恰恰说明了这种做法的问题。
安全模型
关于此服务能保证什么、不能保证什么,以及每项承诺由哪项控制来落实,完整说明见 THREAT_MODEL.md——其中也包括在审查中被绕过、因此被视为纵深防御而非安全边界的控制措施。
-
**加密传输:**通过 rustls 使用 TLS 连接 PostgreSQL(镜像中不包含 OpenSSL),始终启用证书验证,可通过
MCP_SSLROOTCERT使用私有 CA。 -
**两种方式实现只读:**使用
sqlparser解析每条语句,除非语句是SELECT/WITH/EXPLAIN/SHOW,否则予以拒绝;此外,还会将数据库会话设置为default_transaction_read_only = on。 -
**防止拒绝服务:**强制执行
statement_timeout,自动注入LIMIT,并通过基于EXPLAIN的成本防护在运行前拒绝开销过高的计划。 -
敏感列——纵深防御,并如实说明其局限:
MCP_REDACT_COLUMNS会在每一层屏蔽值,并拒绝执行引用这些列的查询,包括对抗性评审小组实际发现的绕过方式——重命名(SELECT password AS pw)、包装(md5(password))、序列化整行(row_to_json(t)、t::text、json_agg(t))、将列名作为字符串而非标识符(to_jsonb(t) ->> 'password'、#>> '{password}'、$.password)、整行通配符(ROW(t.*)::text)以及按位置重命名((SELECT * FROM staff) AS x(c1, …, c9))。这仍是基于名称的过滤;基于名称的过滤无法成为覆盖整个 SQL 语言的防护边界——四轮对抗性测试都通过某种此前未列出的形式绕过了它。在 0.1.7 中,第四轮没有找到新的名称形式,而是完全绕过了名称:
SELECT get_raw_page('people', 0)会返回表在磁盘上存储的 8192 字节,其中包含所有值,包括被屏蔽的值。这不是理论推测,而是实际演示:设置MCP_REDACT_COLUMNS=ssn后,SELECT ssn FROM people被拒绝,但返回的原始页中却以纯 ASCII 文本包含了社会安全号码。现在,pageinspect及其相关扩展会作为单独类别被拒绝,因为“返回的是存储内容,而不是列”与“执行写入”是不同的问题;明确告知操作员触发的是哪种情况,值得单独给出提示。同一轮还发现了更隐蔽的情况。PostgreSQL 的规划器会保存每列真实值的样本,并通过
pg_stats公开这些样本:在 3,000 行数据中,SELECT * FROM pg_stats WHERE tablename='people'返回了{123-45-6789,555-00-1111,987-65-4321},而SELECT ssn FROM people则被拒绝。该查询根本没有提到被屏蔽的列,因此基于名称的规则无从处理。现在,无论你配置了什么值,凡是配置了任何屏蔽项,包含值的统计信息列——most_common_vals、histogram_bounds、most_common_elems、stavalues1…stavalues5——都会一并屏蔽。视图中的其他内容不受影响:n_distinct、null_frac和correlation是下文索引建议的依据,不包含任何值;删除整个关系就会为了处理四列而破坏十列。另外还有两扇门,最终通向同一个地方。对于长到无法存进其所在行的值,PostgreSQL 会将其存入 TOAST 表,而该表可以通过名称读取:
SELECT chunk_data FROM pg_toast.pg_toast_16384返回了未加密的被屏蔽文本。pg_largeobject对大对象也做了同样的事——它存储的是lo_get底层的字节内容。现在两者都会被拒绝,并作为关系处理,而非函数;提示信息也会说明原因:它们保存的是物理存储内容,而不是列。仅用于描述数据库的目录则不受影响,包括pg_tables、pg_stat_activity、pg_largeobject_metadata,以及索引建议所需的统计信息列。 这四种情况说的都是同一件事,而且比任何补丁清单都更准确地描述了此功能的限制: 列过滤器保护的是列,因此任何绕过列读取数据的方式都不在它的承诺范围内。 原始页面、规划器采样、TOAST 块和大对象字节,是进入这个范围的四扇门;只需寻找模式而非名称,一个下午就能在同一个地方发现这四种情况。 坦诚的假设是,肯定还有更多,因此真正的边界是数据库角色和只读事务,而这仍然只是它所说的那样:纵深防御。 因此,服务不再妄加断言,而是询问数据库:启动时,它会报告所有已连接角色仍能读取经过脱敏的列的表,并给出修复所需的确切语句;设置MCP_REDACT_REQUIRE_REVOKE=1则会让它报告后拒绝运行。请注意,修复方法是先执行表级REVOKE,再对保留的列执行GRANT——如果角色对整张表持有SELECT权限,那么单独执行REVOKE SELECT (password) ON staff会悄无声息地不起作用。设置列级授权后,PostgreSQL 会拒绝对该表执行SELECT *,因此调用方需要显式指定列;describe_table会列出这些列,并标记经过脱敏的列。 -
**可识别提示词注入:**行数据会放在带有
trusted="false"的来源信息块中返回,并对分隔符进行转义,因此恶意单元格无法劫持 Agent。 -
它会生成你应该使用的角色:
--print-setup-sql会输出一个角色的 DDL:该角色不继承任何权限、不能绕过限制、不能创建对象,只能读取你指定的关系;并且在你指定了敏感列时,会按实际有效的顺序撤销这些列的权限。它只会打印,绝不执行。应用这些语句需要管理员权限,而一个全部职能都围绕“只读”的工具,绝不应该持有管理员密码。 -
**它不会暴露具有写入权限的角色:**当监听地址可从网络访问时,服务会向 PostgreSQL 查询已连接角色实际获准执行的操作——超级用户权限、
BYPASSRLS、pg_write_all_data等角色的成员身份,以及有限抽样的一些表上的写入权限——如果答案不只是“读取”,就会拒绝启动,列出每项原因并指向--print-setup-sql。出于同样的原因,它也会拒绝未认证的网络监听。回环地址和 stdio 不受影响:在这些情况下,调用方就是操作员。覆盖设置(MCP_ALLOW_EXCESSIVE_ROLE、MCP_ALLOW_ANONYMOUS_NETWORK)必须使用字面值i-accept-the-risk,以免因拼写错误而意外启用,并且启用情况会记录在审计日志中。本服务会自行强制只读,但这种强制依赖代码,而代码过去也出过错;使用无法写入的角色,才是我们任何 Bug 都无法绕过的保障。 -
**浏览器无法访问它:**除非操作员列出了某个
Origin,否则携带该Origin的请求会被拒绝并返回 403;在回环监听器上,非 localhost 的Host也会被拒绝——DNS 重绑定攻击正是以这种方式瞄准你笔记本上的数据库服务器。 -
**审计记录包含配置信息:**日志链以一条
startup记录开头,其中列出版本、传输方式和所有生效设置;连接密码会被剥除,机密会被缩减为指纹,并附带一个操作员可在重启时固定校验的config_fp。日志若只记录发生了什么,却不记录当时使用了哪些设置,就无法回答事件调查时首先会问的问题。 -
**审计会发现日志被截短:**哈希链可以证明条目未被篡改,但如果日志末尾被截掉,链内部仍然自洽——重新计算也查不出问题。因此,除了
MCP_AUDIT_LOG,服务还会维护<log>.hwm,其中用一行记录最后写入的序列号和哈希,且只会在条目持久化追加后更新。启动时会对比两者,并报告不一致的情况:末尾条目缺失、最后一条记录被改写,或日志整个消失。这并不能证明发生了篡改——非正常关机也会造成相同现象——但具有篡改迹象的记录链应该让你提出疑问,而不是直接下结论。归档或移动日志时,请将旁路文件一并保留;删除它只会失去截短检测功能,不会丢失任何条目。离线验证器保持不变,仍然需要外部锚点:--verify-audit <file> --expect-last <hash>。 已验证(验收标准:“缩短的日志会在启动时被发现,且无需任何外部锚点”) -
**错误设置会导致致命错误,而不只是产生错误结果:**无法解析的监听地址、无法写入的审计文件、连接另一台机器上的数据库时使用
sslmode=disable、与数据库凭据相同的指标令牌、拼写为yes的布尔值——这些设置过去都会被接受,随后悄悄执行与预期不同的操作。现在启动会停止,并指出具体设置。 -
**设置拼写错误会导致致命错误,但其他程序的设置不会:**过去,
MCP_REDACT_COLUMN(单数)会让服务启动时悄悄关闭脱敏,因此与真实设置相近的拼写错误现在仍会阻止启动,并指出正确拼写。看起来与我们定义的设置都不像的名称,可能是共用环境变量的其他程序所设:服务会报告并忽略它。各目录在服务器前都放置了用于检查服务器的mcp-proxy,它会导出MCP_PROXY_DEBUG——直到 0.1.6 版本,这个变量都会导致本服务在读取请求前退出。MCP_X_*仍保留供操作员自行使用。已验证(验收标准:“拼写错误仍会导致致命错误”) -
**不泄露架构信息:**数据库错误会被映射为结构化且可操作的消息,绝不会回显表名或列名。
-
**OAuth 2.1:**可选的 RS256 Bearer 令牌验证(签名、
exp、aud、iss),并强制执行作用域;未配置时会禁用,适用于本地使用或自托管。 -
**审计:**每项工具决策都会记录为具有篡改迹象、由哈希链连接的 JSON 行(不含原始 SQL)。
-
**供应链:**在 CI 中强制检查依赖项许可证、来源和安全公告(
cargo deny、cargo audit);每个发行版本都附带 CycloneDX SBOM。 -
**运行时:**以 distroless、非 root 容器形式发布——在 0.1.6 版本中,
linux/amd64的下载大小为 14.8 MB,磁盘占用为 41 MB,并在 CI 中构建和进行冒烟测试。两个数字都列出来,因为只列一个总是会显得更好看:docker images显示的是第二个数字,而消耗你带宽的是第一个数字。
资源占用
在普通 VPS 上使用包含 1.6 万行的示例数据库测得,你可以亲自核验「使用 Rust 编写」这一说法,而不必盲信:
| 空闲时的常驻内存 | 7.7 MB——分别启动 5 次的中位数,各次结果相差不超过 0.1 MB |
| 处理 200 个请求后的常驻内存 | 9.4 MB,之后保持平稳 |
| 请求延迟中位数 | 约 8 ms——包括测量时启动的 curl 进程,因此服务本身的耗时更低 |
| 启动到首条经过验证的语句 | 5 ms——5 次 --validate 运行的中位数,观察到的范围为 5 至 7 ms |
| 二进制文件 | 9.2 MB(linux x86_64,0.1.7 起)。无需额外安装任何东西——没有 Node、Python,也没有我们随附的共享库。它并非静态链接:与任何 -gnu 目标一样,它使用系统的 libc、libm 和 libgcc_s。 |
| 容器镜像 | 压缩后 12.6 MB,解压后 31.8 MB(linux/amd64,0.1.7),distroless,以非 root 用户运行 |
直到 0.1.7,这里有两行数据都是错的。空闲内存原先写的是 5.2 MB,实测为 7.7 MB——在相同条件下启动 5 次,结果相差不超过 0.1 MB,因此旧数据并非测量噪声,而是另一种未说明测量方法的结果。二进制文件一行写着「11 MB,静态链接」,而用户实际下载的文件有 18.9 MB,且采用动态链接。我们从未在发布构建中测量过大小,因为这个 crate 根本没有 [profile.release],所以每个用户下载的版本都带有超过 5 MB 的调试符号。设置 strip、lto 和 codegen-units = 1 后,文件大小降至 9.2 MB。「静态链接」这个说法对我们发布的 5 个目标都不成立,它们没有一个是 musl 构建。镜像大小也随二进制文件一同缩小,从 0.1.6 的压缩后 14.8 MB 降至 12.6 MB——这两个数字都取自已发布镜像的注册表清单,而非本地构建,因为用户拉取的并不是本地构建。
对混合流量(读取、拒绝、错误、中止的请求、会话频繁更替、未认证的请求)进行 12 分钟压力测试,共处理了 51,499 个请求,结束时打开的文件描述符仍为 15 个,与开始时相同。常驻内存从 8.1 MB 增至 11.2 MB,值得关注的是变化趋势:前 400 个请求期间从 8.1 MB 增至 10.7 MB,之后的 51,000 个请求只增加了 0.5 MB,且增长曲线逐渐趋平。这是分配器进入稳定状态,而非内存泄漏。本页面过去写内存保持「平稳」,这对最初几秒之后的情况来说属实,但与实际数据不符,因此这里给出具体数字。
与其相信这段描述,不如运行 tests/soak.sh 复现测试。
配置
| 环境变量 | 用途 |
|---|---|
DATABASE_URL | PostgreSQL 连接字符串(使用只读角色) |
MCP_ADDR | HTTP 监听地址(默认 127.0.0.1:8080) |
MCP_MAX_COST | 拒绝 EXPLAIN 成本超过此值的查询(默认 1,000,000) |
JWT_PUBKEY_PEM、JWT_AUD、JWT_ISS | 启用 OAuth 2.1 令牌验证(省略则禁用身份验证);密钥可以是 PEM 文本,也可以是 PEM 文件的路径 |
MCP_AUDIT_LOG | 仅追加审计日志的路径(采用哈希链);使用 --verify-audit <file> [--expect-last <hash>] 验证。服务还会在其旁边写入 <log>.hwm——最后一个序列号和哈希,用于在启动时检测日志是否被截短 |
MCP_AUDIT_HMAC_KEY / MCP_AUDIT_HMAC_KEY_FILE | 将审计链转换为 HMAC-SHA256 的密钥——将其保存在主机外,以防日志被重写(文件末尾的换行符会被忽略) |
MCP_AUDIT_HMAC_KEYS_OLD | 逗号分隔的旧密钥,以便在密钥轮换后仍能验证保留下来的日志。已验证(验收条件:“跨越密钥轮换的链可使用两个密钥验证”) |
MCP_REDACT_COLUMNS | 不在结果中显示的列,例如 password, ssn, card_number——会在任意深度进行遮蔽,且若查询引用这些列则会被拒绝。纵深防御,不是安全边界:应配合 REVOKE SELECT (col) 使用 |
MCP_BEARER_TOKEN | 每个请求都必须提供的共享令牌,适用于没有身份提供方的部署。配置 OAuth 时会忽略此项——将其作为替代方式接受会让持有者获得完整权限范围,并使审计日志中没有身份信息 |
MCP_STATEMENT_TIMEOUT | 查询时间限制(PostgreSQL 间隔,默认 30s) |
MCP_SEARCH_PATH | 表名未限定时要搜索的模式,例如 analytics, public |
MCP_PASSWORD_FILE | 从文件读取数据库密码,而不是将其放入连接字符串 |
MCP_DATABASE_URLS | 通过一个服务连接多个数据库:prod=postgres://…;dev=postgres://…(此时工具会接收 database 参数) |
MCP_SERVER_LABEL | 客户端界面中显示的名称,例如 production → postgres-mcp-hardened (production) |
MCP_SHOW_PARTITIONS | 设为 1 时也列出分区子表(默认隐藏) |
MCP_ALLOW_FUNCTIONS | 允许使用的目录函数(逗号分隔);我们无法确认这些函数是否只读 |
MCP_SSLROOTCERT | 用于与 PostgreSQL 建立 TLS 连接的 PEM CA 证书包路径(例如 AWS RDS 证书包);默认信任系统和 Mozilla 根证书 |
MCP_MAX_INFLIGHT_PER_CLIENT | 单个客户端的最大并发请求数(默认 4;0 表示禁用) |
MCP_RATE_RPM | 每个客户端的请求速率限制(默认每分钟 120 次;0 表示禁用) |
MCP_RATE_RPM_STDIO | stdio 的相同限制(默认每分钟 600 次):Agent 合理地探索模式时每分钟会调用几十次,但 DBA 最担心的仍是失控循环不断请求生产数据库 |
MCP_CLIENT_ID | stdio 模式下在审计日志中标识此客户端的名称,例如 claude-desktop@ada-laptop;未设置时,身份信息会回退为操作系统用户和进程 |
MCP_RATE_BURST | 该速率限制的突发请求额度(默认 MCP_RATE_RPM / 4,最小值为 5) |
MCP_METRICS_TOKEN | 访问 /metrics 所需的令牌。未设置时,/metrics 遵循服务本身的身份验证要求:服务未启用身份验证时开放,设置了 bearer 令牌时要求提供该令牌,配置 OAuth 时则关闭(JWT 不适合抓取器——请改为设置此项) |
MCP_REDACT_REQUIRE_REVOKE | 设为 1 时,如果数据库仍允许角色读取已遮蔽的列,就拒绝提供服务——将上面的设置从建议变为保证 |
MCP_STRUCTURED_CONTENT | 设为 1 时还会返回 MCP structuredContent;默认关闭,因为忽略它的客户端会为每个结果付出双倍成本。来源标记包含在对象中,但保护文本块的分隔符转义不适用——如果客户端将结构化输出直接粘贴到提示词中,就会失去这一层保护 |
MCP_RESERVED_AUTH_SLOTS | 为经过身份验证的流量保留的数据库连接槽位,防止匿名请求洪流占满连接池(默认:四分之一) |
MCP_PUBLIC_URL | 此服务的公开基础 URL,用于 OAuth 发现元数据 |
MCP_AUTH_SERVERS | 在该元数据中公布的授权服务 URL |
MCP_PROTOCOL_PREVIEW | 已弃用。 在 2026-07-28 仍为草案期间,此项用于启用该版本;上游于 2026-08-03 发布该版本,服务现在默认使用它。此名称仍会被识别,以免现有配置项被报告为拼写错误;启动时会提示一次,此项已不再生效 |
MCP_ALLOW_SCHEMAS | 查询可以访问的模式,例如 public,analytics;设置此项或下一项会启用允许列表 |
MCP_ALLOW_TABLES | 查询可以访问的关系,例如 public.orders,analytics.* |
MCP_ALLOW_CATALOG | 设为 1 时,在启用允许列表期间仍允许访问 pg_catalog |
MCP_ALLOW_PLAINTEXT_DB | 设为 i-accept-the-risk 时,允许使用 sslmode=disable 连接不在此机器上的数据库 |
MCP_ALLOW_EXCESSIVE_ROLE | 设为 i-accept-the-risk 时,允许使用具有写入权限的角色启动网络监听 |
MCP_ALLOW_ANONYMOUS_NETWORK | 设为 i-accept-the-risk 时,允许在未启用身份验证的情况下启动网络监听 |
MCP_ALLOWED_ORIGINS | 允许调用此服务的浏览器源,例如 https://my-client.example。留空表示不允许任何浏览器页面访问:用户仅仅访问的网页就能让其浏览器向 localhost 发送 POST 请求,这正是 DNS 重绑定的全部伎俩 |
MCP_ALLOWED_HOSTS | 监听环回地址时额外接受的 Host 值(始终接受 localhost 和 127.0.0.1) |
MCP_FUZZ_VERBOSE | 仅用于开发:使 --fuzz 打印尝试过的每种变更 |
MCP_TRUST_PROXY | 仅在反向代理后面时设为 1——此时速率限制器会根据 X-Forwarded-For 而不是对端地址进行限制 |
许可证
MIT — 详见 LICENSE。核心部分采用 MIT 许可证,并将一直如此。
商业和团队使用
将其连接到组织内部的生产数据库,会引发 MIT 核心部分无法解答的问题:绑定到身份而非范围的策略、发送到无法被悄悄修改的位置的审计日志、合规审查人员认可的证据,以及有人需要为其负责的部署。
目前正在规划涵盖这些需求的团队版,具体内容尚未确定。如果你的组织有这类需求,请写信至 [email protected],说明其中必须包含哪些内容。目前还没有可购买的产品——你们的回复将决定是否开发,以及开发顺序。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。