关于 sw-postgres-mcp
sw-postgres-mcp 是供 Agent 使用的 Postgres MCP 服务,将读写权限分开。写入先在回滚事务中预览,只有单独批准且与预览匹配的计划才能提交。
主要功能
- 01只读架构、查询和计划工具
- 02基于回滚的写入预览
- 03单独执行计划步骤
- 04数据库角色隔离
- 05读写允许列表
- 06审批阈值与行数硬上限
适用场景
- 01检查架构并查询获准的表
- 02提交前预览数据库变更
- 03对较大变更要求人工审批
MCP 相关
向 MCP 客户端提供 8 个工具:3 个只读工具、4 个写入预览工具,以及唯一负责提交预览变更的 `execute_plan`。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
安全写入 Postgres MCP 服务
Agent 可以读取和修改数据库,同时不会造成无法挽回的事故。其差异化优势在于安全层,而非工具覆盖范围:共有 8 个 MCP 工具,其中 3 个只读,4 个写入工具只会预览更改,还有一个 execute_plan,用于提交已预览的更改,仅此而已。
架构
Claude (agent)
│ MCP stdio (tools/call)
▼
sw-postgres-mcp
│
├─ describe_schema / query / explain_plan ──► readonly pool ──► Postgres "readonly" role (SELECT only)
│
├─ delete_rows / insert_rows / update_rows / run_migration
│ │
│ ▼
│ TwoPhaseWrite.preview()
│ BEGIN → run the real statement → ROLLBACK
│ DML (delete_rows/insert_rows/update_rows): capture exact RETURNING count + sample
│ DDL (run_migration): no RETURNING to capture — reports 0 affected rows, a `target`
│ table/schema extracted from the statement text, and always
│ goes to "awaiting_approval" regardless of that row count
│ └─► plan_token = sha256(statement + params) ("statementFingerprint")
│ ├─ affected_rows ≤ approvalRequiredAboveRows → status: "previewed"
│ ├─ affected_rows > approvalRequiredAboveRows → status: "awaiting_approval"
│ └─ affected_rows > hardMaxRows → refused outright, no token issued
│ (run_migration ignores both thresholds — every DDL preview is "awaiting_approval")
│
├─ execute_plan(plan_token, statement, params) ──► writer pool ──► Postgres "writer" role (DML + DDL)
│ re-derives the fingerprint from what was passed back and refuses on any mismatch
│ (STATEMENT_MISMATCH) or an affected-row-set that changed since preview (ROWSET_CHANGED)
│
└─ every preview / approval / execution / rejection / refusal ──► mcp_audit.log
(INSERT-only grant;
UPDATE/DELETE/TRUNCATE revoked
— see "Audit log" below)
an "awaiting_approval" plan surfaces at:
localhost approval UI — http://127.0.0.1:4319/?token=<per-session-token>
(bound to 127.0.0.1, human-only; the token is printed once on stderr at
startup and every route requires it as `Authorization: Bearer <token>`
or `?token=` — see "Localhost approval UI" below)
approve() / reject() are called directly on the shared TwoPhaseWrite instance —
never exposed as an MCP tool the agent itself can reach
两个 Postgres 连接池分别使用不同的角色进行身份验证(见下文的威胁模型):readonly 用于 describe_schema/query/explain_plan,writer 用于四个写入预览工具和 execute_plan。全部 4 个写入工具——delete_rows、insert_rows、update_rows、run_migration——共用一个核心 TwoPhaseWrite(src/writeCore.ts):每个工具都会在事务中预览,并始终回滚;随后必须单独调用 execute_plan 并提供完全匹配的计划令牌,才能真正提交。没有第 5 个写入工具,也没有任何工具可以跳过预览步骤——execute_plan 是此服务中唯一会提交任何内容的工具,而且它只会重新执行已预览过的语句。各工具的参数和返回值请参见下文的工具,具体机制请参见两阶段写入。
威胁模型
这里的风险不是 SQL 注入。delete_rows、insert_rows 和 update_rows 接收结构化参数——table、where + 参数化的 params、set 对象——这些结构化输入中的每个值都会通过 $n 占位符传入,而不会进行字符串拼接(相关说明见工具中 update_rows 的注释)。run_migration 则有所不同:DDL 无法像 DML 值那样进行参数化,因此它会将 Agent 提供的原始语句直接发送给 Postgres,就像 query/explain_plan 处理原始 SQL 一样——它的安全性并非来自参数化,而是无论受影响的行数是多少,都始终要求人工审批(见工具);它从来不具备 $n 占位符保证。Agent 是其发送的 SQL 的编写者,也是一个值得信任但难免出错的编写者:它不会试图转义引号,但完全可能生成语法完美、格式规范,却因作用范围有问题的语句——例如恰好有 40,000 行匹配时执行 DELETE FROM users WHERE active = false,或因 Agent 忘记添加 WHERE 子句而意外执行了 UPDATE。本项目正是为应对这种故障模式而构建的,主要依靠三种机制:
1. 预览并回滚,而不是 EXPLAIN。 EXPLAIN 只能提供 Postgres 规划器对语句将影响多少行的估算值。这个估算基于表统计信息,这些信息可能已经过时(尤其是在批量加载后、ANALYZE 尚未运行时),也可能因为规划器无法很好地处理相关谓词而单纯出错。基于估算值构建的审批门槛,可能被 Agent(或普通的数据分布偏斜)意外绕过,而不只是被恶意绕过——即使语句实际影响的行数为 40,000,只要规划器估算为 80,它仍可能低于阈值。因此,这里的每个写入工具都会改为在 BEGIN … ROLLBACK 中执行真实语句:预览中的行数就是刚刚实际执行得出的精确数量,而不是预测值。EXPLAIN 仍有其用途——独立的 explain_plan 工具提供了一种开销低、无副作用的预检查,Agent 可以在尝试两阶段写入前调用它——但审批阈值绝不会以它为比较依据。
2. 角色隔离,而非解析。 readonly 和 writer 是两个独立的 Postgres 角色,各自拥有不同的授权(docker/init/01-roles.sql):readonly 仅拥有 SELECT 权限(并且其架构上的 CREATE 权限已明确撤销);writer 拥有 SELECT, INSERT, UPDATE, DELETE 权限,但还受到本项目自身写入允许列表的进一步限制。通过 readonly 连接池提交的修改语句会被 Postgres 本身拒绝,并返回 permission denied——这一点已在 tests/roles.test.ts 中通过连接实时数据库验证,而不只是代码中的断言。我们有意不把另一种做法——解析或用正则匹配 SQL 文本来判断“这是不是写操作?”——设为安全边界:解析器总可能被它未曾处理过的写法绕过(例如用 CTE 包裹的 WITH x AS (DELETE FROM ... RETURNING *) SELECT * FROM x、修改数据的函数调用、引号边界情况);而判断错误是安全漏洞,不是表面问题。query/explain_plan 仍会拒绝非 SELECT 语句,并通过从语句文本中提取表引用来执行读取允许列表检查(src/tools/sqlGuard.ts)——但这明确只是基于角色授权的第二层纵深防御,而非其本身。完整说明请参阅 DECISIONS.md,其中还解释了为什么这种基于文本的允许列表解析中存在的漏洞(我们曾多次加固对带引号/Unicode 转义标识符的处理)属于范围有限的允许列表绕过风险,而不是“读取工具执行了写操作”的风险。
3. 计划令牌绑定语句哈希指纹。 计划令牌本身只是一个随机的不透明 ID,它只能证明“某个预览曾在某个时候发生过”。它并未说明预览的是哪条语句,因此单靠令牌无法阻止偷梁换柱:替换成范围更宽的 WHERE 子句、不同的表、额外的行,然后将看起来相同的令牌交给 execute_plan。因此,每个令牌都会绑定到 statementFingerprint(statement, params)——即对经过修剪的语句文本与 JSON 序列化参数列表计算出的 SHA-256 哈希;该哈希在预览时计算,并根据实际调用 execute_plan 时传入的内容重新计算;如果两者不匹配,则会在执行任何操作之前以 STATEMENT_MISMATCH 拒绝。这就是为什么人在 localhost 审批界面中的批准有实际意义:他们批准的是自己看到的确切语句和参数,而不是一个之后可能被用于不同 SQL 的令牌。(第二项独立检查——受影响行摘要——则单独捕获这样一种情况:由于并发活动,相同语句现在匹配到了不同的行集;请参阅下文的两阶段写入。)
有关此模型有意不涵盖的内容,请参阅限制;有关这三项决策以及审批机制技术验证(#1)的完整说明,请参阅 DECISIONS.md。
快速开始
docker compose up -d
npm install
npm test
npm run build
将 Claude Desktop 指向此服务(请参阅 config.example.json 和下文的 Claude Desktop 部分)。node dist/index.js 还会同时在 http://127.0.0.1:4319/ 启动一个 localhost 审批界面——请打开服务在 stderr 上打印一次的完整 URL(带有 ?token=...),因为每个路由都需要每会话专属的 bearer token。
配置
将 config.example.json 复制为 config.json(或将 SW_POSTGRES_CONFIG 设置为自定义路径):
{
"database": {
"readonlyConnectionString": "postgres://readonly:readonly_password@localhost:5432/mcp_test",
"writerConnectionString": "postgres://writer:writer_password@localhost:5432/mcp_test"
},
"allowlist": {
"read": { "schemas": ["public"], "tables": [] },
"write": { "schemas": [], "tables": [] }
}
}
allowlist.read— Agent 可通过describe_schema/query查看架构/表。如果为空,则所有表均可读取。如果tables非空,则仅列出这些完全限定的表。allowlist.write— Agent 可修改的架构/表。默认拒绝:如果schemas和tables均为空,则不可写入任何内容。请显式添加条目。write.planTtlMs—plan_token的有效时长(默认值为60000)。可通过SW_PLAN_TTL_MS覆盖。write.statementTimeoutMs— 写入执行时每个连接的statement_timeout(默认值为10000)。可通过SW_STATEMENT_TIMEOUT_MS覆盖。write.approvalRequiredAboveRows— 如果预览中精确的回滚预览受影响行数不超过此值,则会返回一个execute_plan可立即执行的令牌,与当前行为相同。超过此值时,预览会改为返回status: "awaiting_approval",且在人工通过带外方式批准之前,execute_plan会拒绝该令牌(详见下文审批阈值与硬性行数上限——审批有意不作为面向 Agent 的 MCP 工具提供)。默认值为100。可通过SW_APPROVAL_REQUIRED_ABOVE_ROWS覆盖。write.hardMaxRows— 第二个更高的独立阈值。如果预览中精确的受影响行数超过此值,则会直接拒绝:不会签发令牌,也没有审批途径——响应是一个简单的结构化错误(HARD_MAX_ROWS_EXCEEDED),不能通过升级审批绕过。默认值为10000。可通过SW_HARD_MAX_ROWS覆盖。必须满足>= write.approvalRequiredAboveRows;否则loadConfig会抛出异常。approvalServer.enabled— 是否在 MCP 服务启动时一并启动本地主机审批 UI(默认值为true)。可通过SW_APPROVAL_SERVER_ENABLED("true"/"false")覆盖。禁用后,任何计划都无法获批,因此任何需要审批的预览(超过write.approvalRequiredAboveRows,或任何run_migration)都会直接返回结构化的APPROVAL_UNAVAILABLE错误——不会签发plan_token——而不是签发一个只能让execute_plan一直阻塞到过期的计划。approvalServer.port— 本地主机审批 UI 监听的端口,仅绑定到127.0.0.1(默认值为4319)。可通过SW_APPROVAL_SERVER_PORT覆盖。approvalServer.requireAuth— 本地主机审批 UI 的每个路由(包括只读 GET 路由)是否都要求提供每会话 bearer token(默认值为true)。回环绑定以及 Host/Origin/Sec-Fetch-Site 来源检查可阻止恶意浏览器页面;但无法阻止仅发送预期请求头的其他本地进程,因为之前只凭计划令牌就足以批准或拒绝计划——bearer token 是用于限制此操作的凭据(safe-write-mcp-core#20)。仅在需要恢复到 0.4.0 之前的行为时才设为false(不推荐)。可通过SW_APPROVAL_SERVER_REQUIRE_AUTH("true"/"false")覆盖。approvalServer.authToken— 本地主机审批 UI 使用的显式 bearer token,通过Authorization: Bearer <token>发送,或使用?token=查询字符串作为备用方式。省略时(默认情况),服务每次启动都会生成一个随机令牌,并在 stderr 上连同审批 URL 一并打印一次——页面自身的批准/拒绝按钮已包含该令牌。需要固定令牌时(例如用于脚本化访问)可设置此项。可通过SW_APPROVAL_SERVER_AUTH_TOKEN覆盖。callerId— 记录在每条审计日志行的caller_id字段中的身份标识(默认值为"unknown")。可通过SW_CALLER_ID覆盖。详见审计日志。- 环境变量
DATABASE_URL_READONLY/DATABASE_URL_WRITER可覆盖文件中的配置。 配置文件仅在服务器启动时读取一次——运行中的服务器不会重新读取config.json。编辑后,请重启 MCP 服务(退出并重新打开 Claude Desktop / MCP 客户端),否则旧配置会继续生效。启动时,服务器会将已加载的配置文件和实际生效的写入允许列表记录到 stderr;如果未找到配置文件,则会发出醒目警告,因为缺少文件意味着写入允许列表为空(默认拒绝——拒绝所有写入操作)。
服务器会创建两个使用不同 Postgres 角色(readonly 和 writer)的连接池。只读权限由数据库授权强制执行,而不是通过解析 SQL 实现——我们代码中的错误无法将读取工具变成写入工具。
Claude Desktop
添加到 claude_desktop_config.json:
{
"mcpServers": {
"sw-postgres-mcp": {
"command": "node",
"args": ["/absolute/path/to/sw-postgres-mcp/dist/index.js"],
"env": {
"SW_POSTGRES_CONFIG": "/absolute/path/to/sw-postgres-mcp/config.json",
"DATABASE_URL_READONLY": "postgres://readonly:readonly_password@localhost:5432/mcp_test",
"DATABASE_URL_WRITER": "postgres://writer:writer_password@localhost:5432/mcp_test"
}
}
}
}
显式设置 SW_POSTGRES_CONFIG,因为 MCP 客户端不会以项目目录作为工作目录来启动服务器,因此默认查找 ./config.json 时会悄然找不到该文件。
重启 Claude Desktop。询问“这个数据库里有什么?”——describe_schema 会返回恰好位于允许列表中的架构和表的表、带类型的列、外键以及行数估算值。
Docker
docker compose up 会启动一个临时 Postgres(postgres:16-alpine),通过 docker/init/01-roles.sql 配置两个角色,通过 docker/init/02-audit-log.sql 创建审计架构,并通过 docker/init/03-approval-workflow.sql 为审批工作流扩展其 status 枚举。测试或本地开发无需手动设置。
演示数据库
docker compose up -d --wait
npm run seed:demo
将一个合成的电商数据集(customers、products、orders、order_items,总计约 208k 行)写入空数据库,这样就有真实的数据可供 describe_schema / query / 写入工具使用,无需准备真实的生产数据集。临时 Docker Postgres 会自动应用架构(docker/init/03-demo-schema.sql);对于普通的本地 Postgres,npm run seed:demo 会自行应用该架构,因此无论哪种情况都无需手动迁移。数据生成是确定性的——使用带种子的 PRNG(mulberry32,而非 Math.random()),因此相同的 --seed 总会生成完全相同的行:
npm run seed:demo -- --seed=7
npm run seed:demo -- --connection="postgres://user:pass@host:5432/db"
连接解析遵循本项目其他位置使用的相同优先级:--connection 标志 > DATABASE_URL_WRITER > POSTGRES_WRITER_URL > DATABASE_URL > docker-compose writer 默认值。每次运行都会清空并重新生成四张演示表,因此可以安全地对非空数据库重复运行。
行数分布(默认种子):
| 分组 | 行数 | 说明 |
|---|---|---|
customers | 50,000 | |
products | 2,000 | |
orders | 60,000 | |
order_items | ~96,000 | 每个订单 1-4 件商品 |
非活跃客户(last_login < 2025-01-01) | 40,000 | 约占客户总数的 80% |
测试租户(customers.segment = 'test_tenant') | 8 个客户 / 320 个订单 | 一个规模较小、可精确查询的租户,完全属于非活跃客户群体 |
orders.status = 'cancelled' | 13,200 | 超过 10,000 行的硬性上限,用于测试硬性上限拒绝行为 |
测试
docker compose up -d --wait
npm test
集成测试会针对正在运行的 Postgres 验证:角色隔离、只读角色无法写入、describe_schema 字段、允许列表过滤,以及演示数据库播种器的行数结构(tests/seedDemo.test.ts 实际运行 npm run seed:demo,并查询结果)。tests/approvalUi.test.ts 会在真实环境中启动本地审批 HTTP 服务器(使用操作系统分配的端口),并通过携带会话专属 bearer token 的普通 fetch() 调用它——不使用浏览器自动化——涵盖待处理计划列表、端到端的批准/拒绝 HTTP 端点(包括解锁和永久终止 execute_plan)、仅限环回地址的绑定、bearer token 验证(401、?token= 回退方式、requireAuth: false 退出选项、403 优先于 401 的顺序)、审计记录、过期计划过滤,以及在没有任何 MCP 客户端连接时该界面仍可正常工作。tests/insertRows.test.ts 和 tests/updateRows.test.ts 针对两个较新的写入工具,涵盖相同的预览→令牌→执行流程、审批阈值/硬上限、写入允许列表、审计日志、类似 SQL 注入的输入,以及(对于插入)序列间隙行为;tests/writeStatements.test.ts 则通过单元测试检验两个 DELETE/UPDATE 工具共用的无 WHERE 防护。tests/runMigration.test.ts 通过同一个真实的本地审批服务器调用 run_migration(从不直接调用 TwoPhaseWrite),以证明不会检查行数阈值、拒绝会以 PLAN_REJECTED 传递给 execute_plan、多语句输入和超出允许列表的目标会在访问数据库前被拒绝;此外,它还针对正在运行的 Postgres 验证 CREATE TABLE、ALTER TABLE、DROP TABLE 和 CREATE INDEX 都能正确地先预览再回滚、先执行再提交;它还会直接对 src/tools/ddlTarget.ts 的语句目标提取逻辑进行单元测试。
安全性案例
上述每个测试文件都会验证其所对应任务中引入的防护在相应的单个工具上有效。tests/safetyCase.test.ts 则针对每项适用的写入工具,运行一组固定的安全属性测试——阈值触发、硬上限拒绝、过期/重用/篡改/拒绝的令牌、允许列表执行、只读角色、审计轨迹完整性、审计记录不可变,以及预览不留痕迹——并使用共享的 it.each 驱动辅助函数,而非为每个工具分别编写,以专门捕获防护已接入某个工具、却被悄然遗漏在另一个工具上的情况。其中一组测试会针对由 npm run seed:demo 使用的同一确定性生成器生成的数据集运行阈值/硬上限测试(issue #10),从而证明这些防护在真实的播种数据量下也有效,而不只是对较小的合成计数有效。
工具
describe_schema— 返回表、列及其类型、外键和行数估算(遵循读取白名单)。query— 执行只读的SELECT,并返回{ columns, rows, row_count }。在只读角色下运行,因此无论 SQL 中写了什么,数据库都会拒绝会修改数据的语句。每次调用只允许一条语句,并遵循读取白名单。可选参数:limit和params。explain_plan— 对候选只读语句运行EXPLAIN (FORMAT JSON),返回规划器估算的cost和rows,而不执行该语句。在运行可能开销较大的操作前,可用它进行快速预检。delete_rows— 两阶段删除。在事务中运行语句,返回确切的受影响行数和受影响行的样本,然后回滚。响应包含plan_token、确切的statement和params,以及状态previewed或awaiting_approval(见下文审批阈值和硬性行数上限)。除非传入confirm_full_table: true,否则拒绝不含WHERE子句的语句。insert_rows— 两阶段插入。接收table、columns(列名数组)、rows(值数组的数组,每行一个,按columns的顺序对应),以及reason。在事务中运行INSERT ... VALUES (...), (...) RETURNING *,返回确切的行数和拟插入行的样本,然后回滚。有关值得了解的一项副作用,请参见下文序列值与已回滚的插入。示例:{ "table": "customers", "columns": ["email", "active"], "rows": [ ["[email protected]", true], ["[email protected]", false] ], "reason": "seeding two test accounts" }update_rows— 两阶段更新。接收table、set(用于指定更改内容的{ "column": value, ... }对象)、where和params(参数化的 WHERE 条件,约定与delete_rows相同)、confirm_full_table,以及reason。在事务中运行UPDATE ... SET ... WHERE ... RETURNING *,返回确切的受影响行数和更新后的样本,然后回滚。除非传入confirm_full_table: true,否则拒绝不含WHERE子句的语句;使用的保护措施与delete_rows完全相同(src/tools/writeStatements.ts),而非重新实现。示例:{ "table": "customers", "set": { "active": false }, "where": "last_login < $1", "params": ["2025-01-01"], "reason": "deactivating accounts inactive since before 2025" }set中的列名始终是带引号的标识符,值始终是$n参数——绝不会通过字符串拼接写入语句——因此,精心构造的列名或值无法注入 SQL;格式错误的列名只会因列名未知而失败。run_migration— 两阶段 DDL。接收statement(一条CREATE TABLE、ALTER TABLE、DROP TABLE或CREATE [UNIQUE] INDEX ... ON <table>语句)和reason。在事务中运行该语句——Postgres 的 DDL 支持事务,因此可以正常回滚——然后回滚。始终需要人工审批:每次run_migration预览都会无条件返回状态status: "awaiting_approval";无论迁移看起来多么简单或不涉及行数,都不会查询write.approvalRequiredAboveRows/hardMaxRows(见下文迁移始终需要审批)。由于 DDL 没有基于RETURNING的行数可供显示,响应中的affected_rows/sample_rows始终为0/[]——不会伪造行数——而是会包含target字段(即从语句中提取的带架构限定的表或索引,例如"public.customers"),以便人工审批时有具体信息可供判断。多语句输入(例如以分号分隔的两条CREATE TABLE语句)会在到达数据库前被拒绝。示例:{ "statement": "ALTER TABLE customers ADD COLUMN loyalty_tier text", "reason": "adding a column the new loyalty feature needs" }execute_plan— 提交先前预览的写入操作。传回预览响应(delete_rows、insert_rows、update_rows或run_migration)中的plan_token、statement和params。如果计划仍在等待审批,此调用会阻塞,直到人工通过本地主机审批界面批准该计划(随后执行)、拒绝该计划(返回带有人工填写的理由的结构化PLAN_REJECTED错误),或计划过期(EXPIRED_TOKEN)。 此列表中刻意不提供approve_plan、reject_plan(或任何其他审批)工具。Agent 无法审批或拒绝awaiting_approval计划——请参阅下文的本地主机审批界面,了解原因以及预期的替代用法。
每个工具都接受一个 reason 字符串(记录在审计日志中——见下文),并以结构化的 { code, message, hint } 格式返回错误——绝不会返回原始 Postgres 异常或多语句批处理。
两阶段写入
delete_rows、insert_rows、update_rows 和 run_migration 都经过完全相同的核心逻辑(src/writeCore.ts 中的 TwoPhaseWrite)——Agent 必须先确认预览,才能执行:
- 工具在事务中运行语句,通过
RETURNING捕获精确的受影响/插入行数和受影响行的样本(对于run_migration,见下文——DDL 没有RETURNING可供捕获),然后回滚。数据库不会发生任何更改。 execute_plan重放完全相同的语句并提交——但前提是令牌有效、未过期、未使用,且绑定到预览中的确切语句和参数。对于delete_rows/update_rows,如果自预览以来匹配的行集发生变化,它也会拒绝提交(ROWSET_CHANGED)。如果计划仍处于等待审批状态,execute_plan会阻塞并等待令牌,直到人工通过带外方式批准(随后执行)、拒绝(PLAN_REJECTED)或令牌过期(EXPIRED_TOKEN)——见下文。
除非传入 confirm_full_table: true,否则不带 WHERE 子句的 DELETE/UPDATE 会被拒绝——这是一项由两个工具共享的保护措施(src/tools/writeStatements.ts),而不是两份可能逐渐出现差异的副本。INSERT 没有 WHERE 子句,因此这项保护不适用于 insert_rows。所有写入都通过 writer 连接池执行;readonly 连接池绝不会用于数据变更。
insert_rows 会刻意跳过 ROWSET_CHANGED 检查:该检查用于捕获“WHERE 子句匹配的行在预览和执行之间发生变化”的情况,而 INSERT 没有与之对应的情形——它没有可供匹配的现有行集。insert_rows 仍享有核心逻辑提供的其他所有保护(精确的 statementFingerprint 绑定、一次性/限时令牌、审批阈值和硬性上限,以及完整的审计日志);关于为何比较 INSERT 预览和执行之间的 RETURNING 摘要,否则会导致针对具有服务器生成列的表进行的每次写入都被拒绝,请参阅 DECISIONS.md。
run_migration 的 DDL 语句也因相同的根本原因跳过这项检查(不存在预先匹配的行集),但还有一个更有力的原因:DDL 完全不支持 RETURNING 子句,因此根本无法计算摘要、受影响行数或样本行。run_migration 会在预览的 BEGIN/ROLLBACK 中运行原始语句(Postgres DDL 支持事务,因此 CREATE TABLE/ALTER TABLE 预览会像 DELETE/UPDATE 预览一样准确回滚),并报告 affected_rows: 0、sample_rows: []——这不是实际计数的替代值,而是对没有返回行的语句给出的准确结果;此外还会提供一个 target 字段,指出从语句中提取的带架构限定的表/索引名称,让审批者有具体内容可供判断。请参阅下文的迁移始终需要审批以及 DECISIONS.md。
序列值与已回滚的插入
由于 insert_rows 的预览是真实执行 INSERT ... RETURNING *,随后再进行回滚,因此目标表列上任何 serial/identity/其他由序列提供默认值的字段都会递增——Postgres 序列不受事务控制,因此回滚不会返还已消耗的序列值。这意味着:
- 预览插入(即使之后从未执行)也会永久消耗该列序列中的一个或多个值。
- 预览中
sample_rows显示的id(或类似字段)仅供参考,并非保证值——execute_plan实际提交的行很可能会获得不同的序列生成值,因为预览自身的回滚已经消耗了显示的那个值。 - 随着时间推移,这会导致序列列出现间隙(例如,
1, 2, 5, 6,而不是1, 2, 3, 4),完全是由预览造成的,无论这些预览后来是否执行。这并无大碍——即使不使用此工具,Postgres 序列也从未保证值连续无间隙——但在把序列列当作连续计数器之前,值得了解这一点。
审批阈值与行数硬上限
预览中精确的回滚预览受影响行数(绝非 EXPLAIN 估算值——write.approvalRequiredAboveRows 和 write.hardMaxRows 只会与真实的回滚计数进行比较)决定同一次工具调用接下来会执行什么操作:
| 精确受影响行数 | 结果 |
|---|---|
<= approvalRequiredAboveRows(默认值为 100) | 不变:status: "previewed",可立即通过 execute_plan 使用令牌 |
> approvalRequiredAboveRows,且 <= hardMaxRows | status: "awaiting_approval"——返回令牌和示例行,execute_plan 会依据该令牌暂停,直到人工通过带外方式批准(随后执行)、拒绝该计划(PLAN_REJECTED),或计划过期(EXPIRED_TOKEN)(见下文) |
> hardMaxRows(默认值为 10,000) | 直接拒绝——不会发放 plan_token,delete_rows 本身会返回结构化的 HARD_MAX_ROWS_EXCEEDED 错误。这是一道硬墙,而非关卡:没有审批途径,预期 Agent 应重写语句以减少受影响行数,而不是再次请求 |
审批机制(#6) 预览发放的计划令牌会在内存中的计划存储(safe-write-mcp-core 的 PlanStore,由 src/writeCore.ts 中的 TwoPhaseWrite 持有)中,连同现有的指纹、过期时间和单次使用状态,一起携带内部的 requiresApproval / approved / rejected 标记。批准会将 approved 设为 true,并写入一条 approved 审计记录;拒绝会永久设置 rejected,并写入一条 rejected 审计记录(TwoPhaseWrite.approvePlan()/rejectPlan() 是程序化入口)。两者都不会作为 MCP 工具公开——面向 Agent 的工具界面不包含 approve_plan/reject_plan(或任何其他审批)入口,因为请求受控写入的 Agent 不得批准自己的计划;详见 DECISIONS.md。特意没有单独的审批数据库表——计划令牌正好是 execute_plan 所针对的适当粒度单元,而且它与其他两阶段写入状态(指纹、过期时间、单次使用)完全存放在一起。localhost 审批界面(#7)是唯一的审批界面:它直接操作同一个存储,并通过自身(非 Agent)的 HTTP 界面完成操作。
迁移始终需要审批
run_migration (#9) 不使用上表。它完全不会参考行数阈值——每次 run_migration 预览都会无条件返回 status: "awaiting_approval",即使某次迁移的 affected_rows 为 0,远低于 approvalRequiredAboveRows 也是如此。这是有意为之:即使迁移不触及任何行(添加列、删除空表、创建索引),也可能是此服务执行的破坏性最大的操作。DELETE/UPDATE/INSERT 使用的行数只是“风险有多大”的代理指标,对架构变更毫无意义,因此迁移绝不会被视为普通写入。
具体来说,src/tools/runMigration.ts 每次调用 TwoPhaseWrite.preview() 时都会传入 alwaysRequireApproval: true——WriteMeta 中的字段会由 preview() 与其他工具依据行数阈值作出的 requiresApproval 决策进行 OR 运算(src/writeCore.ts)。这在工具模块自身的代码中硬编码,而非从 run_migration 的参数中读取:它的 MCP inputSchema(src/server.ts)没有任何字段能影响此设置,因此调用 Agent 没有任何参数可以削弱或绕过它;write.approvalRequiredAboveRows/hardMaxRows 配置错误也不会意外放行迁移——此标志根本不会参考这两项设置。除此之外,run_migration 会自动获得其他审批机制提供的所有保障,与 delete_rows/insert_rows/update_rows 相同:审批或拒绝只能通过本地主机审批界面进行;一旦拒绝,令牌就会永久失效,下一次尝试 execute_plan 时会返回结构化的 PLAN_REJECTED 错误;每次预览、审批、执行和拒绝都会连同 Agent 的 reason 一并记录到审计日志中。
run_migration 还会在调用 preview() 之前执行写入允许列表检查:它会从语句文本中提取目标表(对于 DROP TABLE,则提取语句中命名的每张表),并使用与数据写入工具完全相同的 isTableWritable 逻辑进行检查。关于支持哪些 DDL 形式,以及为什么目前不支持 DROP INDEX,请参阅 src/tools/ddlTarget.ts 和 DECISIONS.md。
本地主机审批 UI
一个小型纯 HTML 页面,无需 React,也无需构建步骤,供人查看 Agent 想要执行且超过审批阈值的操作,并决定批准或拒绝。它作为独立的仅限本地访问的 HTTP 服务运行(src/approvalServer.ts),与 MCP stdio 传输分开,由 src/index.ts 随 MCP 服务一同启动,且仅绑定到 127.0.0.1(绝不绑定到 0.0.0.0——参见 DECISIONS.md)。它与 MCP 服务共享同一个内存中的 TwoPhaseWrite 实例,因此在此处批准或拒绝后,MCP 连接上的 execute_plan 会立即获知结果。
- **访问方式:**在运行服务的机器上的浏览器中,打开服务启动时在 stderr 上打印一次的完整 URL:
http://127.0.0.1:4319/?token=<per-session-token>(或配置的approvalServer.port值)。其他机器无法访问此页面——这是有意设计的,没有主机/绑定地址配置选项。每个路由都需要 bearer token(0.4.0,safe-write-mcp-core#20):通过Authorization: Bearer <token>发送,或使用?token=查询字符串作为备用方式;粘贴的 URL 已包含该参数。页面自身的“Approve/Reject”按钮会自动通过标头发送令牌。未通过 Host/Origin/Sec-Fetch-Site 来源检查的请求会在检查令牌之前收到403;通过来源检查但没有令牌(或令牌错误)的请求会收到401 UNAUTHORIZED。对GET /api/plans执行curl/fetch()时也需要提供标头,例如:curl -H "Authorization: Bearer <token>" http://127.0.0.1:4319/api/plans。设置approvalServer.requireAuth: false可选择禁用认证(不推荐);也可设置approvalServer.authToken,使用固定令牌而非自动生成的令牌。 - **显示内容:**所有当前处于
awaiting_approval状态的计划,包括工具、确切语句、Agent 给出的reason、回滚预览中受影响行的确切数量、受影响行的示例(预览中的sample_rows),以及——当预览提取出目标时(目前仅适用于run_migration)——标明语句操作的、带架构限定的表/索引的target。对于 DDL 迁移,人类会据此审核,而不是查看行数。过期计划会从列表中消失,不会继续显示为可审批状态。机器可读的对应接口是GET /api/plans(JSON),可直接通过fetch()/curl访问——无需浏览器或 MCP 客户端;tests/approvalUi.test.ts和tests/runMigration.test.ts中的验收测试也采用这种方式访问。 - 批准(
POST /api/plans/:token/approve,可选的{ approvedBy }请求体)会直接在进程内批准内存计划存储中的计划,不会通过任何 MCP 工具执行。之后,使用该令牌调用execute_plan会立即成功。 - 拒绝(
POST /api/plans/:token/reject,可选的{ rejectedBy, reason }请求体)会在存储中永久使该令牌失效。此后永远无法再批准或执行该令牌,即使之后再次点击“approve”或第二次点击“reject”也不行(两者均安全幂等,除重新记录审计外不会产生其他操作)。由于计划等待审批时execute_plan会阻塞,拒绝结果会立即反馈到该次正在等待的调用中——被阻塞的execute_plan会返回结构化且可区分的PLAN_REJECTED错误(而非AWAITING_APPROVAL、EXPIRED_TOKEN或通用失败);如果提供了人工拒绝原因,错误消息也会包含该原因,让 Agent 能据此采取具体行动——缩小语句范围并重新预览,而不是盲目重试。 - **审计:**每次批准和拒绝都会向
mcp_audit.log写入一行(status: "approved"/"rejected",approved_by取自approvedBy/rejectedBy,默认值为"unknown");该表及列也用于记录 MCP 驱动的预览/执行操作。 - **安全边界:**只能通过此 HTTP 接口批准或拒绝,连接的 Agent 无法通过 MCP 工具调用这些操作。与工单 #6 修复
approve_plan自我审批漏洞的原因相同,reject_plan也存在同样的问题,因此两者都不在src/server.ts的 MCP 工具列表中。
审计日志
双阶段写入核心处理的每次预览、批准、执行和拒绝,都会在 mcp_audit 架构的 mcp_audit.log 中写入一行:
| 列 | 含义 |
|---|---|
id, ts | 行 ID 和时间戳 |
tool | 发起写入的 MCP 工具(例如 delete_rows) |
reason | 调用方提供的 reason 字符串 |
statement | 完整的 SQL 语句(架构/表已根据允许列表完成验证);approve_plan/reject_plan 行为空(这些行的 tool 设为对应的字面字符串,而非原始写入工具),并通过 plan_token 引用计划,而不会再次写入其语句 |
params_redacted | 参数的结构,而不是字面值——每个参数记录为 { type, length },这样操作员可以查看传入了多少个参数及其大致类型,但绝不会看到属于语句一部分的客户邮箱、令牌或其他任何字面值 |
preview_rows | 预览时记录的受影响行数(精确的回滚预览计数,绝非 EXPLAIN 估算值) |
actual_rows | 执行时实际提交的受影响行数(执行成功前为 null) |
plan_token, approved_by | 将 previewed/awaiting_approval 行与后续的 approved/rejected/executed/failed 行关联起来;approved_by 记录在 approved 行中(取自传给 TwoPhaseWrite.approvePlan() 的 approvedBy,或审批 UI 的批准端点),并出于同一“谁处理了此令牌”的用途,记录在 rejected 行中(取自传给 rejectPlan() 的 rejectedBy,或拒绝端点)——未提供身份时,两者均默认为 "unknown" |
status | previewed | awaiting_approval | approved | executed | rejected | hard_cap_refused | failed——关于 awaiting_approval 和 hard_cap_refused,请参阅审批阈值和硬性行数上限;关于 approved/rejected,请参阅本地主机审批 UI |
duration_ms | 数据库往返所用的实际时间 |
caller_id | 标识服务实例/部署(config.callerId、环境变量 SW_CALLER_ID,默认为 "unknown")——v1 中没有针对每个请求的终端用户身份验证,因此此字段归属部署,而非个人 |
写入审计行绝不会阻塞或掩盖其所记录写入的结果:若审计插入失败(例如连接短暂中断),错误会记录到 stderr 并被吞掉,不会抛出,因此审计行丢失不会与实际失败的数据库写入混淆。
只追加保证由 Postgres 强制执行,而非应用代码。 docker/init/02-audit-log.sql(已提交的迁移,同时应用于一次性 Docker 测试数据库和任何其他目标 Postgres)授予 writer 角色对 mcp_audit.log 的 INSERT 权限——且仅有 INSERT 权限——然后明确从该角色撤销 UPDATE、DELETE 和 TRUNCATE 权限:
GRANT INSERT ON mcp_audit.log TO writer;
REVOKE UPDATE, DELETE, TRUNCATE ON mcp_audit.log FROM writer;
此服务中的任何 bug,以及 Agent 通过 writer 角色能够构造的任何 SQL,都无法改写或删除已写入的行——Postgres 会直接以 permission denied 拒绝 UPDATE/DELETE。这一点由针对 Postgres 的实际测试(tests/auditLog.test.ts)验证,而非仅停留在文档说明。readonly 仅拥有 SELECT 权限,因此操作员可以查看记录,而无法写入。
限制
如实说明,不作隐瞒:
- 回滚会撤销表写入,但不会撤销外部副作用。
delete_rows/insert_rows/update_rows/run_migration预览确实会回滚——但如果受影响表上的BEFORE/AFTER触发器触发了NOTIFY、调用了外部服务,或通过外部数据包装器(FDW)写入另一个数据库,该副作用就已经发生,预览的ROLLBACK无法撤销它。Postgres 事务只能保证事务性表状态恢复原样;它们无法撤回已经发送到数据库外部的消息。如果 Agent 可以写入的表上有此类触发器,就应将预览步骤本身视为有副作用,而非无影响操作。 - 已回滚的插入(以及预览后回滚的 DDL)仍会消耗序列值。 Postgres 序列不具备事务性,因此,已回滚的
insert_rows预览——或已回滚的CREATE TABLE ... GENERATED ... AS IDENTITY风格 DDL 预览——会永久推进受影响列默认使用的任何serial/identity序列。即使预览从未真正执行,serial列也会因此出现间隙。这无害(Postgres 序列本来就从不保证值连续),但值得了解;参见上文的序列值与已回滚的插入。 - 审计日志的只追加保证取决于
writer角色的授权,而非本服务的代码,并且仅适用于通过该角色建立的连接。docker/init/02-audit-log.sql仅向writer授予对mcp_audit.log的INSERT权限,并撤销UPDATE/DELETE/TRUNCATE权限——参见上文的审计日志——但超级用户连接完全不受此模型约束:Postgres 超级用户、该表的所有者,或任何拥有(或可被授予)mcp_audit.log的UPDATE/DELETE/TRUNCATE权限的角色——无论是通过表所有权、直接授权,还是对writer角色本身拥有ALTER/GRANT权限——都能重写或删除审计行,无论writer自身的授权如何。此保证的含义是“本服务使用为其配置的凭据时,无法篡改自己的审计记录”,而不是“无论谁管理数据库,审计记录都不可篡改”。 - 不支持多租户。 一个服务进程连接一个 Postgres 数据库,使用一对
readonly/writer角色和一个允许列表。没有按租户划分的凭据范围或审计分区;要为多个租户运行此服务,意味着使用独立配置运行多个服务实例。 - 除本地配置外不提供身份验证。 不进行按请求的最终用户身份验证——
caller_id(参见审计日志)标识的是部署/配置,而非 MCP 会话背后的个人。任何能与此服务的 stdio 传输通信的人(或者,对于审批而言,任何能访问其运行机器上的127.0.0.1的人),都可以使用该服务配置的角色。 - v1 不支持云端部署。 本地主机审批 UI 有意绑定到
127.0.0.1(参见本地主机审批 UI),其他机器无法访问——不支持远程审批,也不提供 TLS 终止或多用户审批路由。此服务旨在与 Agent 一起运行在同一台机器上,而非作为托管服务。 - 计划令牌和审批状态仅驻留内存且限于进程范围。
TwoPhaseWrite的令牌存储不会持久化到审计日志之外——服务重启后,所有待处理的awaiting_approval计划(必须从头重新预览)及所有计划的审批状态都会丢失。重启后仅会在mcp_audit.log中保留预览、审批和执行记录的历史;而“execute_plan现在能否接受此令牌”的实时状态不会保留。参见DECISIONS.md(#6),了解为何这是有意选择,而非疏漏。 run_migration的允许列表强制执行并未覆盖所有 DDL 形式。 支持CREATE TABLE、ALTER TABLE、DROP TABLE和CREATE [UNIQUE] INDEX ... ON <table>;裸写的DROP INDEX <name>会被直接拒绝(UNSUPPORTED),而非进行允许列表检查,因为仅凭该语句文本无法确定索引所属的表。参见DECISIONS.md(#9)。- 单语句、单数据库。 所有工具都会拒绝多语句输入,也没有跨语句/跨表且跨越多次工具调用的事务——每次
delete_rows/insert_rows/update_rows/run_migration调用都是各自独立的预览/执行对。无法预览两项相关写入(例如一条UPDATE和一条依赖它的INSERT),并将它们作为一个原子单元批准。
发布到 MCP Registry
server.json 是提交到官方 MCP Registry 的清单。tests/serverJson.test.ts 会检查其中是否包含 Registry 架构 URL 和关键清单属性(反向 DNS 名称、版本、npm 软件包条目、stdio 传输方式、必需的环境变量),并确保其与 package.json 保持同步——其 name 必须与 package.json 中的 mcpName 字段相同,这是 Registry 在发布时检查的 npm 所有权验证标记。
发布通过 CLI 流程完成(而非 GitHub PR),且要求 npm 软件包已存在:
- 发布到 npm:
npm run build && npm publish(参见 issue #24)。 - 安装发布工具:
brew install mcp-publisher,或从modelcontextprotocol/registry下载发行二进制文件。 - 进行身份验证:
mcp-publisher login github(设备授权流程)。 - 发布:
mcp-publisher publish——读取server.json并注册服务。
清单中的 name(io.github.jpka/sw-postgres-mcp)以 GitHub 账号为命名空间,因此使用 GitHub 身份验证即可,无需 DNS 验证。它声明的 DATABASE_URL_READONLY / DATABASE_URL_WRITER 环境变量,与配置部分介绍的连接字符串相同。
许可证
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。