AI 技术
#MCP#JSON-RPC#幂等性#工具调用#超时重试

MCP 工具调用超时重试:客户端重发请求为何导致写操作重复执行

远程 MCP Server 执行下单类写工具时,客户端超时重发同一 JSON-RPC 请求,服务端却把它当成一次新调用,导致重复扣款或重复下单。本文从 JSON-RPC 请求 ID 的会话内语义出发,解释断线重连后 ID 重置、服务端缺少幂等键存储的机制,并对比幂等键、去重窗口与人工确认三种方案,给出可观测指标与部署边界。

一次超时重发,为什么会变成两笔订单

设想一个远程 MCP Server,它暴露一个 create_order 工具,负责在企业采购系统里创建订单并扣减预算。客户端在上午 10:00:00 发出调用,参数里包含商品、数量和采购人。10:00:30 客户端仍未收到响应,本地超时触发,于是它把同一个 JSON-RPC 请求原样重发一次。

服务端两次都成功执行了。第一次执行时写入了订单,只是响应在返回途中丢失;第二次执行时又写入一笔新订单。采购系统里出现两笔内容完全相同的订单,预算被扣了两次。

直觉上,客户端会认为“我发的是同一个请求,服务端应该能认出来”。但这个直觉依赖一个前提:服务端能跨请求识别“这是同一次业务操作”。MCP 和 JSON-RPC 本身都没有提供这个前提。

基线方案失效在哪里

JSON-RPC 的 id 只负责配对,不负责去重

JSON-RPC 2.0 规定,请求对象里的 id 由客户端建立,服务端必须在响应里返回同一个值。它的作用是让客户端把响应和请求对应起来,规范原文的措辞是“用于关联两个对象之间的上下文”。

规范没有要求服务端记住历史 id,也没有要求服务端在收到重复 id 时拒绝执行。一个合规的服务端完全可以对每个到达的请求独立执行,然后各回一个响应。也就是说,id 是关联标识,不是幂等键。

断线重连会重置 id 空间

更麻烦的是会话边界。MCP 的连接是有状态的,客户端和服务端在初始化阶段协商能力。当连接断开、客户端重新建立会话时,新的会话会重新开始分配 id。

这意味着两件事。第一,重连后客户端可能再次用 id: 1 发请求,而服务端在新会话里看到的是一个全新的 id: 1,与旧会话的 id: 1 没有任何关联。第二,如果客户端在重连后重发超时请求,服务端无法通过 id 判断这是重发还是新调用。

MCP 的 Streamable HTTP 传输规范明确写道:断连不应被解释为客户端取消请求。要取消,客户端应当显式发送取消通知。这条规定保护了长任务的执行,但也意味着断连后服务端仍在继续执行,而客户端的重发会叠加在正在执行或已完成的调用之上。

服务端没有幂等键存储

真正决定是否重复的,是服务端有没有一个跨请求的业务标识。如果 create_order 的入参只有商品、数量和采购人,服务端拿到的两次请求在结构上完全一样,但它没有任何字段能证明“这两次是同一次采购意图”。

服务端可以尝试用参数哈希去重,但参数相同的两次合法调用在业务上可能是允许的,比如同一采购人确实要下两笔相同订单。参数哈希无法区分“重发”和“真实重复”。

请求在系统里如何流动

下面用贯穿场景描述一次超时重发的完整路径。客户端持有会话 A,服务端为会话 A 维护连接状态。

flowchart TD
    A[客户端发起 create_order] --> B[会话 A 发送 JSON-RPC 请求 id=7]
    B --> C[服务端执行下单并扣预算]
    C --> D[响应在返回途中丢失]
    D --> E[客户端本地超时]
    E --> F{客户端如何处理}
    F -->|重发同一请求| G[会话 A 或新会话 B 再次发送]
    G --> H[服务端视为新调用]
    H --> I[再次下单并扣预算]
    F -->|显式取消并查询| J[发送取消通知或查询订单状态]
    J --> K[服务端返回已有订单]

关键转折点在 F。客户端在超时后有两个选择:重发,或者先确认状态再决定。重发路径下,服务端在 H 处没有任何依据判断这是重复,于是走到 I,产生第二笔副作用。

如果客户端在超时后重建了会话,G 处的请求会带着新会话的 id 到达。服务端连“同一个 id 出现过两次”这个弱信号都拿不到。

三种可选方案的实际差别

幂等键:把业务标识放进参数

幂等键的思路是让客户端为每次业务操作生成一个唯一标识,随请求一起发送。服务端在执行前先查这个键是否已处理过,已处理则直接返回上次的结果,未处理则执行并记录。

这个方案要求客户端在重发时复用同一个键,而不是每次重发都生成新键。键的生成时机应当在第一次发起调用之前,并保存在客户端本地,直到调用有确定结果。

服务端的存储需要覆盖“执行中”和“已完成”两种状态。只记录已完成结果不够,因为重发可能落在第一次执行尚未结束的窗口内,此时服务端需要返回“处理中”而不是再执行一次。

去重窗口:用时间或参数近似

去重窗口不要求客户端提供键,而是服务端根据参数、来源和时间窗口自行判断。常见做法是维护一个近期请求指纹表,窗口内出现相同指纹就拒绝或返回缓存结果。

它的优点是客户端无需改造,缺点是判断依据弱。参数相同但业务上合法的两次调用会被误判为重复;窗口设置过长会误伤,过短则覆盖不了慢重试。窗口大小还依赖服务端对客户端重试节奏的假设,而客户端行为往往不可控。

人工确认:把不确定性交给用户

人工确认不试图自动区分重复,而是在超时后暂停自动重发,向用户展示“这笔操作状态未知”,由用户决定是查询、重试还是放弃。

它适合低频、高金额的写操作,代价是引入人工延迟,并且需要客户端能准确表达“未知”状态,而不是简单报错。如果客户端把超时统一处理成失败并提示重试,用户很可能直接点重试,效果等同于自动重发。

三种方案的对比

维度幂等键去重窗口人工确认
客户端改造需要生成并保存键无需改造需要展示未知状态
服务端改造需要键存储与状态机需要指纹表与窗口需要查询接口
区分重发与合法重复能准确区分依赖参数,可能误判由用户判断
覆盖执行中窗口需要处理中状态依赖窗口长度不自动处理
适用频率高频写操作中低频、参数区分度高低频高金额
主要风险键生成或保存失败误伤合法重复用户误点重试

这张表的核心信息是:幂等键把判断责任放在客户端和服务端的协作上,去重窗口把责任放在服务端的启发式上,人工确认把责任放在用户身上。三者不是互斥的,生产环境常见组合是幂等键为主、人工确认为兜底。

实现中最关键的数据结构

幂等键方案落地时,服务端需要一张以幂等键为主键的记录表。每条记录至少包含四个字段:键本身、状态、结果和过期时间。

状态机通常有三个值:处理中、已完成、已失败。收到请求时,服务端尝试以键插入一条“处理中”记录。插入成功说明这是首次调用,继续执行;插入冲突说明键已存在,读取状态后决定返回处理中、返回上次结果还是允许重试。

这里有一个容易忽略的边界:执行失败后,键记录该不该删除。如果删除,客户端重发会重新执行,可能产生部分副作用;如果不删除,客户端永远拿不到重试机会。常见做法是把失败记录保留一段时间,并允许客户端用同一个键显式请求重试,由服务端决定是否真正重新执行。

过期时间需要覆盖客户端可能的最长重试周期。如果记录在客户端重试之前过期,去重就失效了。

生产环境需要观察哪些信号

重复写操作的排查难点在于,它往往不报错。两笔订单都创建成功,日志里两条都是 200。可观测性需要专门针对这个场景设计。

服务端应当记录每次写工具调用的幂等键、会话标识、请求到达时间和执行结果。当同一个幂等键在短时间内出现多次到达时,记录一条去重命中事件。这条事件的计数直接反映客户端重试频率。

客户端侧应当区分“超时”和“失败”。超时意味着结果未知,失败意味着服务端明确拒绝。把两者混在一起上报,会让重复执行的根因被掩盖。

业务侧可以对比同一时间窗口内的调用次数和实际业务单据数量。如果调用次数明显多于单据数量,说明去重生效;如果两者相等但用户投诉重复,说明去重没有覆盖到实际路径。

还需要观察会话重建频率。频繁重连会放大 id 重置的问题,也会让基于会话的去重逻辑失效。

部署边界与失效条件

幂等键方案在以下条件下会退化。客户端在重发时重新生成键,去重完全失效。客户端把键存在内存里,进程重启后丢失,重发变成新调用。服务端存储不可用,插入冲突判断失败,可能放行重复执行。

去重窗口方案在客户端重试间隔超过窗口长度时失效。在参数区分度低、合法重复常见的业务里,它会误伤正常调用。

人工确认方案在客户端无法表达未知状态时失效,用户会把超时当成失败直接重试。

还有一个跨会话的边界:如果服务端的去重记录与会话绑定,断线重连后记录不可见,去重失效。幂等键必须独立于会话生命周期存储,才能覆盖重连场景。

MCP 规范把工具描述和注解视为不可信,要求主机在调用工具前获得用户明确同意。这个安全原则同样适用于重试:客户端不应在用户不知情的情况下自动重发写操作。读操作重试通常无害,写操作重试需要显式的策略,而不是复用读操作的默认重试逻辑。

仍未解决的问题

幂等键的生成责任放在客户端,但客户端可能由不同厂商实现,键的格式和生命周期没有协议级约束。跨客户端、跨主机的去重无法保证。

服务端返回“处理中”之后,客户端应该等待多久、以什么频率查询,目前没有统一约定。等待过短会放大查询压力,过长会拖慢用户感知。

对于执行时间本身超过客户端超时阈值的工具,超时是常态而非异常。这类工具需要的是进度通知和显式取消语义,而不是重试。MCP 提供了进度跟踪和取消通知,但客户端是否正确使用,取决于具体实现。在这些机制被普遍正确实现之前,写工具的重复执行仍会是一个需要服务端自行兜底的问题。

资料来源

  1. Model Context Protocol Specification (2025-06-18)
  2. JSON-RPC 2.0 Specification
  3. Model Context Protocol Transports
  4. 工良出品 | 长文讲解 MCP 和案例实战 - 痴者工良 - 博客园