不能。 超时只说明客户端本地定时器到点、不再等了,它对"服务端做了什么"不携带任何信息。MCP 规范里 tools/call 的响应只有两种形态:带 resultType、content、isError 的结果,或多轮输入的 input_required,协议层面根本不存在"超时"这种应答 S1。服务端可能已收到请求、正在执行、甚至已经执行完毕并留下副作用。所以带副作用的调用超时后,正确动作不是当作没发生,而是先读回副作用状态,再决定重试还是补偿。
先看一个假设场景
假设你的 Agent 调用 create_invoice 生成一张发票,客户端 60 秒超时触发,调用以异常结束。此时最省事的路是直接重试一次。但服务端那边可能正在跑:第一次调用在 61 秒时写入成功,第二次又开了一张发票。反过来,也可能请求压根没送达,重试是完全安全的。这两种情况在你这一侧的异常长得一模一样------这正是问题所在:超时信号里没有区分它们的信息。

超时为什么没有结论:证据在哪一层
三层证据可以钉死这个判断。
第一,协议层。规范定义的 tools/call 应答里,成功的工具执行失败也走结果通道:isError: true 是"一个成功的 JSON-RPC 结果,模型能读到并据此恢复";协议错误才是 JSON-RPC error 响应,模型看不到 S2。翻遍 TypeScript SDK 的 ProtocolErrorCode 全部成员------ParseError、InvalidParams、InternalError 等------没有任何一个是超时码 S2。超时不是协议事件,它是你的客户端代码在本地抛出的。
第二,实现层。超时是各客户端/网关自己配的定时器。以 gh-aw-mcpg 为例,它的 toolTimeout 是"发往某个 server 的 tools/call 请求的调用超时",可按 server 覆盖全局值,最小 10 秒 S3------注意这是这一个网关实现的字段,不是所有 MCP 网关的通用配置。Super-MCP 的默认值是 30 分钟,而且它开了一个口子:resetTimeoutOnProgress: true,服务端每发一条进度通知,客户端定时器就重置一次 S4。
第三,这两条合起来推出关键结论:客户端放弃等待和服务端停止执行是两件独立的事。进度通知重置定时器的机制之所以存在,恰恰因为服务端操作可以在客户端"没耐心"之后继续跑 S4。定时器到点只是你这边不再收结果了,不是那边停手了。
进入读回检查前,先分清你拿到的是哪种信号
超时后第一件事不是读回,而是确认这个异常真的是超时。三种信号对应三种结论:
| 你看到的 | 协议层是什么 | 能下的结论 |
|---|---|---|
isError: true 的正常结果 |
服务端已处理并返回的失败 S2 | 已执行(执行到失败那步),模型可读文案恢复 |
| JSON-RPC error 响应 | 协议错误,模型看不到 S2 | 只有当错误来自传输层或网关在进入工具处理器之前对请求的拒绝时,才可判定未执行;仅凭错误码本身不能下这个结论 |
| 本地超时异常 | 协议之外,客户端定时器 S1S3 | 无法下任何结论,进入读回 |
第二行需要限定清楚:对 tools/call 来说,工具内部抛的异常会被 SDK 转成 isError: true 的结果,不会变成协议错误 S2------所以工具执行失败不会以 JSON-RPC error 的形态出现。 tools/call 的参数在进入处理器之前是否存在一道产生协议错误的校验,这一层行为要查你所用中间件或网关的文档,不能拿相邻规范条款类推。
三步读回检查:步骤、预期时间线、三分类
思路:让带副作用的工具在开始执行时就写入一条可查询的状态记录,key 由调用方提供(兼作幂等键)。超时发生后用只读工具查这个 key。
前提:TypeScript SDK v2,参照其文档中内存 Client 直连 server 的接线方式。示例,未在本文环境实测:
ts
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const records = new Map<string, { status: 'pending' | 'done'; value?: string }>();
const server = new McpServer({ name: 'probe', version: '1.0.0' });
server.registerTool('write_record',
{
description: '写入一条记录,key 由调用方提供,用于幂等与读回',
inputSchema: z.object({ key: z.string(), value: z.string() }),
},
async ({ key, value }) => {
records.set(key, { status: 'pending' }); // 执行开始即落状态
await new Promise(r => setTimeout(r, 5000)); // 模拟慢执行
records.set(key, { status: 'done', value });
return { content: [{ type: 'text', text: `written ${key}` }] };
}
);
server.registerTool('get_record',
{
description: '按 key 查询记录状态',
inputSchema: z.object({ key: z.string() }),
},
async ({ key }) => {
const rec = records.get(key);
return {
content: [{ type: 'text', text: rec ? rec.status : 'not_found' }],
};
}
);
检查步骤:
- 触发超时。 把客户端单次
tools/call的超时调到远小于工具执行时长(上例中执行 5 秒,超时设 1 秒;具体参数名以你所用 SDK 文档为准),带着key: "probe-001"调write_record,等本地超时异常抛出。 - 立刻读回。 异常抛出后不要重试,马上调
get_record查probe-001。 - 等待后再读一次。 等待时长超过工具的最大执行时间(上例约 5 秒以上),再查一次。
预期时间线(对照上例):t=1s 超时触发;此刻读回应得 pending------证明服务端已收到请求且正在执行;t≥5s 再读应得 done------证明它最终执行完成。只有从头到尾都返回 not_found,且服务端日志里没有这条请求,才指向"未送达"。
由此得到三分类和对应动作:
- 已执行(done):重试会重复副作用。要么带同一个 key 走幂等路径,要么走补偿。
- 执行中(pending):等它结束后再读一次,落到上一种或下一种。
- 未送达(not_found):可以安全重试------但最好有服务端日志佐证,排除"写了还没可见"的延迟。
失败边界:这套检查什么时候不管用
三条边界要心里有数。
其一,中间层超时点不可见 。真实部署里请求要过传输层甚至网关,gh-aw-mcpg 这类网关实现自己就持有 toolTimeout,且"超时"可能由中间层触发而非你的客户端 S3;请求死在送达前还是执行中,读回本身区分不了,需要服务端日志配合。排查时先确认是哪一层的定时器到点。
其二,没有可查询副作用时读回失效。纯外部副作用(发邮件、调无回查接口的第三方 API)查不到状态,此时只能按"可能已执行"处理,把安全寄托在幂等键或外部系统的去重上,而不是读回。
其三,读回有可见性延迟 。如果服务端是异步落库,not_found 可能是假阴性------这就是为什么第 3 步要等一个超过最大执行时间的窗口再查,而不是查一次定生死。另外,若服务端支持进度通知,客户端的定时器会被重置 S4,实际"放弃等待"的时点会比你配置的值更晚,等待窗口也要相应放宽。
可以存下来的检查清单
- 异常抛出后,先按信号三分类确认这真的是本地超时,不是
isError: true也不是 JSON-RPC error。 - 不重试,带原 key 读回副作用状态,得 done / pending / not_found 之一。
- pending 就等,not_found 要等够窗口再确认,done 就走幂等或补偿。
- 没有可查副作用的工具,在设计阶段就补幂等键------超时后的安全性靠的是设计,不是排查。
以上预期时间线来自示例逻辑的推演,未在本文环境实测。如果你在自己的 SDK 和传输方式上跑这条检查,得到了不同的读回时序(尤其是异步落库或网关在中间的情况),欢迎带着运行条件(SDK 版本、传输方式、超时配置在哪一层)来交流,我会据此修正边界描述。