pg-readonly-mcp
pg-readonly-mcp
一个只读的 Postgres MCP 服务器,通过解析 SQL 而非模式匹配来工作——因为另一种方案已经在公开场合失败过一次,而且失败得值得具体说明。
本工具旨在堵住的绕过方式
Anthropic 的参考 Postgres MCP 服务器通过将每个查询包装在只读事务中来强制执行"只读"。它还接受分号分隔的多语句输入。这种组合是可以被利用的:
SELECT 1; COMMIT; DROP SCHEMA public CASCADE;COMMIT 提前结束了只读事务。之后的所有内容都以完整的会话权限运行。Datadog 安全实验室于 2026 年披露了此漏洞;该服务器已被弃用并归档——而该有漏洞的包在此之后仍然每周被下载 21,000 次。
只读事务描述的是查询如何运行。它完全没有说明查询是什么,而这正是你可以通过对话绕开它的原因。本服务器检查的是第二件事。
Related MCP server: postgres-mcp-readonly
两个独立的防护层
其中任何一个单独存在都足以阻止已披露的绕过方式。两者并存是因为一个层的缺陷不应成为阻止代理执行写入操作的唯一屏障。
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 行非常宽数据的查询,或者一个合法代价高昂的计划查询,仍然会消耗其应有的资源。行上限和语句超时限制了损害;它们不会让昂贵的读取变得免费。
代理获取数据后如何处理。 这是一个查询门控,而非数据防泄漏工具。对表的读取访问就是对表中任何内容的读取访问。
解析器差异。
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。不存在仅限值的参数化方案来"运行调用者要求的任何只读 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 -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。
工具
只有一个工具,这是有意为之。一个其全部价值主张是"我们拒绝除读取之外的所有操作"的服务器,不需要第二个表面来同样正确地做到这一点。
工具 | 功能 |
| 运行一条 SELECT 形态的语句。在触及连接之前进行解析和遍历。受 |
测试
37 个测试。25 个不需要数据库,可在任何地方运行——它们是 guard.py 的完整测试套件,纯字符串输入,输出决策。 其他 12 个需要真实的 Postgres,并且是有意设计的集成测试:check_connection_is_readonly 的意义在于它针对真实角色属性和真实授权所做的事情,而模拟的连接无论服务器针对真实连接实际做什么都会通过。
pytest -q --cov=pg_readonly_mcpCI 针对真实的 postgres:16 服务容器运行,如果数据库支持的测试在那里被跳过,则构建失败——与 sweep-mcp 对其符号链接测试应用的规则相同,原因也相同:一个静默地什么都不做的测试比没有测试更糟糕。
在这 12 个测试中:一个对确切 Datadog 载荷的实时复现,通过实际的 MCP 工具调用而非直接通过 guard.py 驱动——以及一个断言,即目标模式之后仍然存在,而不仅仅是抛出了异常。还涵盖了:通过相同调用路径隐藏在 CTE 中的写入操作、行上限、语句超时、取消的查询使连接可用于下一个查询,以及具有 CREATEDB 和零表授权的角色仅凭角色属性就被拒绝。
在 CI 上,针对真实 Postgres 的覆盖率:78%,guard.py 为 100%。 server.py 中的差距是通用的 psycopg.Error 捕获所有异常——套件中没有故意触发非取消的数据库错误——以及 main() 的 argparse 和传输连接,套件通过直接使用 build_server 来测试这些;传输是最不值得模拟的部分,也是最不可能隐藏真正错误的地方。
推送的第一个版本没有通过。 两个错误只有在真实 Postgres 服务容器首次运行套件时才暴露出来,并且两者都无法仅从代码中看出:SET statement_timeout = %s 以 SET statement_timeout = $1 的形式到达 Postgres 并解析失败,因为 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-enforcedguard.py 对 MCP 或 psycopg 一无所知。如果查询因 server.py 中的原因而被拒绝,那是一个错误——判断应属于下一层,在那里可以仅用字符串进行测试,无需其他任何东西。
许可证
MIT。
This server cannot be deployed
Maintenance
Related MCP Connectors
- WoWSQLOAuthcom.wowsql
Managed Postgres MCP: projects, SQL, docs search, and storage buckets via OAuth.
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Guard AI agents' PostgreSQL/MySQL access via MCP: SQL audit, auth, masking, write approval
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceRead-only PostgreSQL MCP server that enables running SELECT queries, listing tables and schemas, and describing columns, with built-in protection against writes and malicious SQL attacks.590 npmMIT
- AlicenseAqualityDmaintenanceA secure, read-only PostgreSQL MCP server that provides safe database introspection and querying capabilities.1419 npmMIT
- FlicenseNot gradedqualityBmaintenanceRead-only MCP server for PostgreSQL enabling schema introspection and SELECT queries via MCP clients like Claude, with multi-layered write protection.-
- AlicenseNot gradedqualityCmaintenanceProvides a read-only PostgreSQL MCP server with schema introspection. Enforces least-privilege database roles to prevent any writes, even from malicious SQL.MIT