关于 pg-readonly-mcp
pg-readonly-mcp 是面向需要只读数据库访问的用户的 Postgres MCP 服务。它解析 SQL 语法树并检查连接权限,只允许单条读取查询,并限制行数和执行时间。
主要功能
- 01解析 SQL 语法树
- 02拒绝多条或非读取语句
- 03检查数据库角色只读权限
- 04限制返回行数
- 05设置语句超时
适用场景
- 01让 AI Agent 查询 Postgres 而不授予写入权限
- 02阻止多语句或 CTE 中隐藏的写操作
- 03为查询设置行数与时间限制
MCP 相关
它向 MCP 客户端提供单个 query 工具,在 SQL 触达数据库前解析并验证查询;连接角色也须通过只读权限检查。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
pg-readonly-mcp
**简单来说:**这能让 AI 查看你的数据库,但不能做其他任何事。 不是因为有人告诉它不要写入,而是因为它使用的数据库登录凭据在物理上 就不具备写入能力。这个工具的官方版本曾公开存在一个漏洞,允许“删除所有 内容”的命令绕过安全检查;本版本已针对这一确切攻击进行测试。
一个只读的 Postgres MCP 服务,它会解析 SQL,而不是进行模式匹配—— 因为另一种做法已经在公开场合失败过一次,而且失败方式值得具体说明。
本项目旨在修复的绕过漏洞
Anthropic 的 Postgres MCP 服务参考实现通过将每个查询放入只读的 事务中来强制执行“只读”。它还接受以分号分隔的多语句输入。这种组合 可以被利用:
SELECT 1; COMMIT; DROP SCHEMA public CASCADE;
COMMIT 会提前结束只读事务。其后的所有内容都会以完整的会话权限运行。
Datadog Security Labs 于 2026 年披露了这一问题;
该服务已弃用并归档——但此后,该漏洞包每周仍有 21,000 次下载。
只读事务是查询的执行方式所具备的属性。它并不说明查询本身是什么, 这正是为什么可以通过操作绕过它。本服务检查的是后者。
两层独立防护
任何一层单独存在都足以阻止已披露的绕过攻击。两层防护并存,是因为 其中一层存在漏洞时,不应只有这一道防线来阻止 Agent 写入。
1. 解析 SQL,而非扫描文本。 guard.py
使用 sqlglot 构建真正的语法树,
并执行三项检查:
- 必须恰好只有一条语句。
sqlglot.parse会像驱动程序一样根据结束语句的 分号拆分语句,因此上面的载荷会变成三条语句,并在任何语句到达连接之前 被拒绝。 - 最外层语句必须是读取类型——
SELECT、UNION、INTERSECT、EXCEPT,或由这些语句构成的 CTE。DROP TABLE users会在此处被拒绝。 - 整棵语法树中任何位置都不能有写入操作,并会遍历完整棵树。这项检查
无法被前两项取代:Postgres 允许 CTE 携带数据修改语句,因此
WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x在最外层是SELECT。只检查最外层形式的检查会完全漏掉这一点。遍历每个节点都能 找到DELETE,无论它位于多深的层级。
对于无法识别的语句形式——任何 sqlglot 没有专门规则的语句——一律按
其他语句相同的规则拒绝。陌生并不等于安全。
2. 无论被要求执行什么,连接本身都无法写入。
check_connection_is_readonly 会在连接的角色
是超级用户、可以创建数据库或角色、可以绕过行级安全策略,或对其能看到的
任何表持有 SELECT 以外的任何授权时拒绝启动。检查并不全面——Postgres
权限也可能通过所有权、授予 PUBLIC 的权限,或此检查未枚举的 RLS 策略
获得——但它能捕获错误配置最常见的两种表现形式。这里明确说明这一点,而非
仅作暗示。
它无法防护的情况
- 仍然向它提供了高权限连接字符串。 启动检查能捕捉常见的权限过高情况;它并非全面的权限审计,上文也已明确说明这一点,而不是暗示它能做到这一点。
- 在限制范围内耗尽资源。 一个合法返回 1,000 行超宽数据的查询,或一个规划开销确实很大的查询,仍然会产生相应的成本。行数上限和语句超时能限制损害,但无法让昂贵的读取变得免费。
- Agent 获取数据后如何处理。 这是查询闸门,不是数据防泄漏工具。对某个表拥有读取权限,就意味着可以读取其中的所有内容。
- 解析器意见不一致。
sqlglot和 Postgres 自带的解析器是同一语法的两个独立实现。尚未证明它们对 Postgres 接受的每一种边缘情况都能达成一致——对于一个核心前提是不信任单一层的项目来说,这是一个真实存在、尽管范围有限的缺口。第二层的部分作用正是应对这一点:即使解析器差异让某些非预期内容通过了检查,下层连接仍然无法写入。
扫描器结果
2026 年 8 月 18 日,使用 agent-audit 0.19.2 运行:共发现 15 项,其中 11 项被自动抑制,4 项需要处理——1 项 BLOCK,3 项 WARN。
BLOCK 项最值得关注,值得完整阅读。 server.py:156,置信度 1.0:cur.execute(sql)——因未参数化执行而被标记为 SQL 注入。这一行确实存在,也确实是整个代码库中防护最严密的一行:在 sql 到达这里之前,validate_readonly() 已经对其进行了解析,确认它恰好是一条语句,确认该语句属于读取类型,并遍历其中的每个节点来检查是否有写入操作。扫描器无法看到这些——它只是单文件模式匹配,而验证发生在另一个函数、另一个模块中,且早在数行之前。它正确识别出通常具有危险性的模式,却看不到这个模式已经经过检查。
它也不可能通过照它的建议去做而消除这一告警。 参数化查询保护的是代入固定查询结构中的值——例如 WHERE id = %s。这里不适用,因为该工具要接受的输入正是查询的结构。对于“运行调用方要求的任意只读 SQL”,不存在只对值进行参数化的方案。这类工具的解决办法是验证结构,而这正是本文件其余部分所做的事。
针对 query() 定义的 WARN(AGENT-034,“函数体内没有输入验证”)是同一个盲点的另一种表现:该函数的第一行就在 try/except 中调用了 validate_readonly(sql)。扫描器不会将对导入函数的调用识别为验证模式。
关于硬编码凭据的两项 WARN 位于 tests/conftest.py:默认管理员 DSN(postgres:postgres@localhost:5432/postgres,这是本地/CI Postgres 的标准默认值),以及测试套件在同一测试中创建并删除角色时使用的字面密码。两者都被正确识别为凭据形式的字符串;但它们都不是用于保护任何东西的凭据——一个指向一次性本地数据库,另一个只在单次测试期间存在。
剩余的 11 项发现——全部是 AGENT-041,都位于使用 uuid.uuid4() 派生的名称构造 CREATE SCHEMA / GRANT / DROP ROLE 语句的测试夹具中——已由扫描器自行自动抑制。
模式扫描器是烟雾探测器,不是裁判。公开它发现了什么以及原因,比单独追求一个干净的数字更有价值。
安装
pip install pg-readonly-mcp
从源码检出目录安装:pip install -e .
配置
需要连接字符串,可通过 --dsn 提供,也可通过
PG_READONLY_MCP_DSN 提供——设置此环境变量是为了避免密码出现在命令行中,或出现在可能被意外提交的客户端配置文件中:
{
"mcpServers": {
"pg-readonly-mcp": {
"command": "pg-readonly-mcp",
"env": { "PG_READONLY_MCP_DSN": "postgresql://readonly_role:...@host:5432/db" }
}
}
}
该连接字符串中的角色只能拥有 SELECT 权限。
服务会自行检查这一点,否则将拒绝启动——参见上文的
check_connection_is_readonly。
工具
只有一个工具,这是有意为之。一个全部价值都在于“除读取外一概拒绝”的服务,无需再增加第二个接口来确保这一点。
| 工具 | 功能 |
|---|---|
query | 执行一条类 SELECT 语句。执行前会先解析并遍历该语句。行数上限为 --max-rows(默认值为 1000),超时上限为 --timeout-ms(默认值为 5000) |
测试
**共 37 项测试。25 项无需数据库,可在任何环境中运行——它们构成了 guard.py 的完整测试套件,纯粹是输入字符串、输出判断。**另外 12 项需要连接运行中的 Postgres,按设计属于集成测试:check_connection_is_readonly 的意义在于检查真实角色属性和真实授权后的行为,而无论服务针对真实数据库实际做什么,模拟连接都能让测试通过。
pytest -q --cov=pg_readonly_mcp
CI 使用真实的 postgres:16 服务容器运行测试;如果数据库测试在该环境中被标记为跳过,构建就会失败——这与 sweep-mcp 对符号链接测试采用的规则相同,原因也相同:悄无声息地什么都不做的测试,比没有测试更糟。
这 12 项测试包括:通过实际 MCP 工具调用,而非直接调用 guard.py,现场重现精确的 Datadog 载荷;并断言目标架构之后仍然存在,而不仅仅是断言抛出了异常。还覆盖了:通过相同调用路径在 CTE 中隐藏写入操作、行数上限、语句超时、查询被取消后连接仍可用于执行下一条查询,以及仅凭角色属性就拒绝拥有 CREATEDB 权限但没有任何表授权的角色。
CI 上针对真实 Postgres 的覆盖率:78%,guard.py 达到 100%。server.py 中未覆盖的部分是通用的 psycopg.Error 捕获逻辑——测试套件不会刻意触发非取消操作导致的数据库错误;另一处是 main() 的 argparse 和传输配置,测试套件改为直接通过 build_server 对其进行测试。传输层最不值得模拟,也最不可能藏有实际错误。
**这次推送的第一个版本没有通过。**真实 Postgres 服务容器首次运行测试套件时,才发现了两个仅凭代码无法察觉的错误:SET statement_timeout = %s 到达 Postgres 时变成了 SET statement_timeout = $1,导致解析失败,因为 SET 是实用语句,不能像 SELECT 那样接受绑定参数——工具的每一次实际调用都会同样失败。另一个问题是测试夹具试图对仍持有有效授权的角色执行 DROP ROLE,而 Postgres 会拒绝这一操作;必须先运行 DROP OWNED BY。两处问题都已修复,后续运行结果就是本文所述数据。保留这段说明,是因为一个永远只报告成功的测试套件,没人见过它失败。
目录结构
src/pg_readonly_mcp/
guard.py parses and walks the tree. Opens no connection. 135 lines.
server.py the MCP tool, the connection check, the timeout and row cap.
tests/
test_guard.py 25 tests - no database, run anywhere
test_server.py 12 tests - live Postgres required, CI-enforced
guard.py 不了解 MCP 或 psycopg。如果查询被拒绝的原因实际上存在于 server.py 中,那就是一个错误——判断逻辑应属于下一层,以便只用一个字符串就能测试,不需要其他东西。
许可证
MIT。
作者
由 Leslie Kadenge 构建。我独立开展 MCP 服务的安全审查;我对 13 个生产环境服务的公开调查见 les-k.github.io/field-notes.html。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。