mcpField
P
pg-readonly-mcp

pg-readonly-mcp

本地服务
les-k.github.io数据库04 周前更新

关于 pg-readonly-mcp

pg-readonly-mcp 是面向需要只读数据库访问的用户的 Postgres MCP 服务。它解析 SQL 语法树并检查连接权限,只允许单条读取查询,并限制行数和执行时间。

主要功能

  1. 01解析 SQL 语法树
  2. 02拒绝多条或非读取语句
  3. 03检查数据库角色只读权限
  4. 04限制返回行数
  5. 05设置语句超时

适用场景

  1. 01让 AI Agent 查询 Postgres 而不授予写入权限
  2. 02阻止多语句或 CTE 中隐藏的写操作
  3. 03为查询设置行数与时间限制

MCP 相关

它向 MCP 客户端提供单个 query 工具,在 SQL 触达数据库前解析并验证查询;连接角色也须通过只读权限检查。

配置

本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。

mcp.json · 通用配置
{
"mcpServers": {
"les-k-pg-readonly": {
"command": "uvx",
"args": [
"pg-readonly-mcp"
],
"env": {
"PG_READONLY_MCP_DSN": "<PG_READONLY_MCP_DSN>"
}
}
}
}
启动命令uvx pg-readonly-mcp
需要配置的环境变量
PG_READONLY_MCP_DSN密钥Postgres connection string for a role that holds only SELECT. The server refuses to start otherwise.

本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。

安装包
pypipg-readonly-mcp0.1.1

工具

还没有获取到工具列表

本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。

详细介绍

pg-readonly-mcp

CI Python License: MIT

**简单来说:**这能让 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。

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。