MCP 写操作不能靠模型自行确认
作者复盘库存和订单 MCP 工具:写入开关与事务回滚预览虽已存在,最终确认仍由模型填写布尔参数。文章指出提示模型先征求同意无法证明用户已授权,并介绍将审批交还用户的改造。
AI 深度解读
给 MCP 写工具加上 confirm: true,不等于用户真的确认过。作者检查自己的库存服务时发现:这个参数由模型填写,服务端既不知道用户有没有看过预览,也无法保证预览和最终提交用的是同一组参数。
他的改法是把确认交给 MCP 的 elicitation:服务端先生成真实预览,再通过客户端向用户提问。这篇最有用的地方,是把「模型答应遵守流程」和「程序强制执行流程」之间的差别,落到了代码、测试和兼容性取舍上。
为什么有了试运行,模型仍然能直接写入
作者的个人项目 mcp-rest-bridge 代理一家商店的 REST API,提供六个读工具和两个写工具:adjust_stock、create_order。
原先的防线看起来并不少。操作人员必须设置 STORE_ALLOW_WRITES=true 才能开启写入;调用默认是试运行(dry run),API 会在事务里执行真实写入,再回滚,因此库存不能为负、订单各行库存必须充足等规则都会经过检查。
试运行结果还会要求模型先给用户展示预览,得到同意后再用 confirm: true 调用。
问题就在最后一步。
confirm 用 Zod 定义为布尔值,默认 false,描述里要求用户同意后才能设为 true。但描述只是给模型看的文字。服务端收到这个值时,没有任何证据证明用户参与过。
作者已有的测试意外验证了这一点:直接对 TLS-002 提交 delta: -2、reason: "damaged in transit" 和 confirm: true,没有提前试运行,也能保存,返回 dryRun: false、newStock: 1。
模型填写的确认参数,只能表达模型选择了提交,不能证明用户批准了提交。
还有一个独立漏洞:模型可以先预览 delta: 20,再提交 delta: 200。两个请求之间没有绑定关系,用户看过前一个数字,并不能替后一个数字背书。
客户端自带的工具审批仍然有用。adjust_stock 的 destructiveHint: true 可以帮助客户端决定如何提示,但工具注解只是提示,规范要求客户端将其视为不可信信息。服务端也无法知道,用户是否早已点过「始终允许」。
怎样让用户批准的就是这次提交
作者采用的是 elicitation/create。服务端发送一段说明和一个简单、扁平的 JSON schema,客户端展示表单,返回 accept、decline 或 cancel。
按照原文描述,这个回答由客户端收集,模型不负责填写。在 TypeScript SDK 1.32 中,对应调用是 server.server.elicitInput(...),客户端需要声明 elicitation 能力。
下面这个辅助函数负责把预览、询问和提交串起来:
async function withUserApproval(
server: McpServer,
preview: () => Promise<CallToolResult>,
describe: (preview: CallToolResult) => string,
apply: () => Promise<CallToolResult>,
): Promise<CallToolResult> {
if (!server.server.getClientCapabilities()?.elicitation?.form) return apply();
const dryRun = await preview();
if (dryRun.isError) return dryRun;
let answer: ElicitResult;
try {
answer = await server.server.elicitInput({
mode: "form",
message: describe(dryRun),
requestedSchema: {
type: "object",
properties: { approve: { type: "boolean", title: "Apply this change?" } },
required: ["approve"],
},
});
} catch (err) {
console.error("Approval request failed:", err);
return fail("Could not get the user's approval in the client, so nothing was saved.");
}
if (answer.action !== "accept" || answer.content?.approve !== true) return fail(DECLINED);
return apply();
}
重点看最后的判断:必须同时满足 accept 和 approve === true 才提交。用户关闭弹窗、取消、接受表单却没有勾选,以及请求抛错,都不能落入写入分支。只判断 decline 会漏掉 cancel。
第一行也必须看清:客户端不支持表单 elicitation 时,代码直接执行 apply()。这项兼容策略保留了原来的风险,后面还要单独评估。
adjust_stock 的调用方式是:confirm 为假就执行 run(true);为真则进入辅助函数,用 run(true) 生成预览,再用 run(false) 提交。预览和提交使用同一次工具调用捕获的参数,弹窗里的 previousStock、newStock 来自 API 返回值,还会展示 sku、delta 和 reason。
这样消除了「预览 20、提交 200」的参数替换空间。若试运行已经失败,例如库存会变成负数,就直接返回错误,不再让用户批准一个无法执行的操作。
订单也采用具体信息提问:给客户 1 创建待处理订单,购买 1 x TLS-002,总额 $109.99。相比泛泛的确认按钮,这些信息才足以让人判断操作是否符合意图。
拒绝消息还要求模型「除非用户提出,否则不要重试」。这是对模型后续行为的提示,不能和服务端的写入拦截混为一谈。
测试为什么要查数据库,而不只看报错
作者没有依赖真实界面测试这段逻辑,而是使用 SDK 的 InMemoryTransport,配合带 elicitation handler 的 Client 模拟用户回答。
测试客户端名为 test-client、版本为 1.0.0,通过 capabilities: { elicitation: { form: {} } } 声明能力,并用 setRequestHandler(ElicitRequestSchema, ...) 接收请求。
最关键的测试,是模型已经要求提交,但用户明确拒绝:
it("saves nothing when the user declines, even though the model sent confirm: true", async () => {
const seen: string[] = [];
const mcp = await connectWith({ action: "decline" }, seen);
const result = await mcp.client.callTool({
name: "adjust_stock",
arguments: { sku: "TLS-002", delta: 200, reason: "stock count", confirm: true },
});
expect(seen).toHaveLength(1);
expect(result.isError).toBe(true);
expect(textOf(result)).toContain("did not approve");
expect(stockOf("TLS-002")).toBe(3);
expect(api.store.stockMovements("TLS-002")).toEqual([]);
await mcp.close();
});
要看的是最后两项业务断言:库存仍为 3,库存流水为空。工具返回错误只是表象,数据库没有发生变化,才证明拒绝路径守住了写入边界。
作者展示了七项审批测试,覆盖批准、拒绝、取消与未勾选、业务校验失败、普通试运行、订单金额展示和审批请求异常,结果为 7 passed (7)。完整测试套件共 39 项,typecheck、lint 也通过。作者说明,代码改动和文章由 AI agent 协助完成,引用的测试与输出均在发布前重新运行。
这些是作者报告的测试结果,能支持所列分支的行为,不能直接证明真实客户端的弹窗足够清晰,或者用户确实会阅读。
我的判断
这次改动最成立的地方,是把提交条件从自然语言要求变成了服务端分支。对已有 Node 或 TypeScript MCP 服务,这比继续强化参数描述更有约束力;客户端工具审批则可以继续作为外层关卡。
但「约 40 行加测试」成立有前提:现有 API 已经支持执行真实业务规则再回滚,客户端也支持表单 elicitation。普通团队若缺少试运行接口,不能只复制辅助函数就获得同样效果。
最大的取舍是能力降级。不支持 elicitation 的客户端仍可通过 confirm: true 写入,用户得到的保护会因客户端而异。作者为了兼容性保留了这个行为;对于库存、订单这类写入,我更倾向于明确支持范围,再决定是否拒绝不具备确认能力的客户端。
另外,同一次工具调用绑定的是参数,不代表预览与提交之间的数据库状态被冻结。其他请求可能修改库存,最终写入仍应重新校验;原文没有交代并发情况下如何处理。
elicitation 也不是身份授权。API key 使用谁的权限、谁能开启写入,仍由服务部署者决定。表单不能索取密码或 API key,原文提到这类流程另有 URL mode。
至于具体数字能否减少误点、拒绝提示能否减少模型重试,作者没有给出对照数据,应当视为经验判断。
能拿来做什么
- 检查自己的 MCP 写工具,沿着
confirm等参数追到实际提交分支,确认服务端是否掌握独立于模型输出的用户同意证据。 - 在 Node 或 TypeScript 服务中,把业务预览、用户确认和最终提交放进同一次工具调用,并明确决定客户端缺少
elicitation.form时是否允许写入。 - 用
InMemoryTransport模拟拒绝、取消、未勾选和审批异常,检查数据库及审计记录,避免只断言工具返回了错误。 - 为库存和订单确认展示实际变化、操作原因及金额;业务校验不通过时直接返回错误,减少无效弹窗。