MCP 工具跑得慢怎么办:进度通知、客户端超时和取消,附 3 个实测踩坑(Python SDK v2)
给 Agent 写 MCP 工具,一开始都是查个天气、算个数,几百毫秒就返回。真正接到业务里,很快会遇到跑几秒到几分钟的工具:批量处理一批文件、转码一段视频、跑一轮数据检查。这时候三个问题一起冒出来:
- 客户端干等,不知道跑到哪了;
- 不设超时,卡死了也一直等;设了超时,服务端那边到底停没停?
- 超时之后这条连接还能不能接着用?
这篇用一个最小的批处理工具把这三件事跑一遍:服务端用 ctx.report_progress 报进度,客户端用 progress_callback 收进度、用 read_timeout_seconds 设超时,再实测超时后服务端的取消行为。最后是 3 个我实际跑出来的坑。
环境:Linux,Python 3.13.5,mcp 2.3.0(MCP Python SDK v2)。下文输出都是真实运行结果。
一、准备环境
python3 -m venv .venv && source .venv/bin/activate
pip install "mcp>=2,<3"
二、服务端:一个会报进度的批处理工具
用 asyncio.sleep 模拟每条耗时 0.5 秒的 IO 操作。工具函数多声明一个 ctx: Context 参数,SDK 会自动注入,它不会出现在工具的入参 schema 里。
"""server.py:会跑几秒的批处理工具,边跑边报进度(MCP Python SDK v2)"""
import asyncio, sys, time # time 给坑 1 的两个工具用
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("batch-demo", version="0.1.0")
def log(msg: str):
# stdio 模式下 stdout 是协议通道,日志只能写 stderr
print(f"[server] {msg}", file=sys.stderr, flush=True)
class ItemResult(BaseModel):
item: str
chars: int
class BatchResult(BaseModel):
total: int
results: list[ItemResult]
@mcp.tool()
async def batch_check(items: list[str], ctx: Context, delay: float = 0.5) -> BatchResult:
"""逐条处理 items(示意:每条 sleep delay 秒模拟耗时),返回处理结果。"""
if not items:
raise ToolError("items 不能为空")
done: list[ItemResult] = []
total = len(items)
try:
for i, it in enumerate(items, 1):
await asyncio.sleep(delay) # 模拟耗时的 IO 操作
done.append(ItemResult(item=it, chars=len(it)))
await ctx.report_progress(i, total, f"已处理 {it}")
except asyncio.CancelledError:
log(f"被取消,已完成 {len(done)}/{total},在这里做清理")
raise # 一定要继续往上抛
log(f"全部完成 {total}/{total}")
return BatchResult(total=total, results=done)
if __name__ == "__main__":
mcp.run() # 默认 stdio
report_progress(progress, total, message) 三个参数:当前值、总量(可选)、一句说明(可选)。总量不知道的时候可以只传 progress,客户端自己决定怎么显示。
三、客户端:收进度
"""client_progress.py"""
import asyncio, sys, time
from mcp import Client, StdioServerParameters
params = StdioServerParameters(command=sys.executable, args=["server.py"])
t0 = time.monotonic()
async def on_progress(progress: float, total: float | None, message: str | None):
print(f"[{time.monotonic()-t0:4.1f}s] {progress:.0f}/{total:.0f} {message}")
async def main():
async with Client(params) as client:
r = await client.call_tool("batch_check", {"items": ["a1", "b22", "c333", "d4444"]},
progress_callback=on_progress)
print("isError:", r.is_error)
print("result:", r.structured_content)
asyncio.run(main())
运行结果(计时从启动子进程前开始,所以第一条含服务端启动时间):
[ 1.0s] 1/4 已处理 a1
[ 1.5s] 2/4 已处理 b22
[ 2.0s] 3/4 已处理 c333
[ 2.5s] 4/4 已处理 d4444
isError: False
result: {'total': 4, 'results': [{'item': 'a1', 'chars': 2}, {'item': 'b22', 'chars': 3}, {'item': 'c333', 'chars': 4}, {'item': 'd4444', 'chars': 5}]}
只要传了 progress_callback,SDK 会自动在请求里带上 progressToken,服务端的 report_progress 就有地方发了;不传的话,服务端调 report_progress 也不会报错,只是没人收。
如果你在做 Agent 的前端,这个回调就是进度条的数据源;如果是纯后台任务,至少把它打进日志,排查「卡住了还是在跑」会省很多事。
四、客户端超时:服务端会不会停?
call_tool 有个 read_timeout_seconds 参数(Client(...) 构造时也能设全局默认值)。我让工具处理 10 条(约 5 秒),超时设 2 秒,然后在超时后立刻再发一个很短的请求,看连接还能不能用。
"""client_compare.py:同样 2 秒超时,对比三种写法"""
import asyncio, sys, time
from mcp import Client, StdioServerParameters
params = StdioServerParameters(command=sys.executable, args=["server.py"])
ITEMS = [f"x{i}" for i in range(10)] # 10 条 × 0.5 秒 = 5 秒,必然超时
async def run(client, tool):
t0 = time.monotonic()
try:
await client.call_tool(tool, {"items": ITEMS}, read_timeout_seconds=2)
except Exception as e:
print(f"{tool}: {time.monotonic()-t0:.1f}s 后 {type(e).__name__}: {e}")
# 紧接着发一个很短的请求,看连接是不是被堵住
t1 = time.monotonic()
await client.call_tool("batch_check", {"items": ["ping"], "delay": 0})
print(f"{tool}: 下一个请求等了 {time.monotonic()-t1:.1f}s")
async def main():
async with Client(params) as client:
for tool in ["batch_check", "batch_check_blocking", "batch_check_thread"]:
await run(client, tool)
await asyncio.sleep(0.5)
print()
asyncio.run(main())
先只看第一种(正常写法 batch_check)的结果:
[server] 被取消,已完成 4/10,在这里做清理
batch_check: 2.0s 后 MCPError: Request 'tools/call' timed out
batch_check: 下一个请求等了 0.0s
结论有三条:
- 客户端 2 秒准时抛
MCPError,提示Request 'tools/call' timed out; - 服务端确实停了:工具里的
asyncio.CancelledError被触发,10 条只做了 4 条。在我这版 SDK 上,客户端超时后会通知服务端取消这次请求,服务端把对应的协程 cancel 掉; - 连接没断,下一个请求立刻返回。
所以「超时之后要不要重建连接」这个问题,在这种写法下答案是不用。但另外两种写法的结果就不一样了,见下面的踩坑。
五、3 个实测踩坑
坑 1:async 工具里用了阻塞调用,取消和超时都「失灵」
很多现成的库只有同步接口(老的 SDK、图像处理、某些数据库驱动)。直接在 async def 工具里调用,会把整个事件循环堵住。反面教材:
@mcp.tool()
async def batch_check_blocking(items: list[str], ctx: Context, delay: float = 0.5) -> BatchResult:
"""反面教材:在 async 工具里用 time.sleep(阻塞事件循环)。"""
done: list[ItemResult] = []
for i, it in enumerate(items, 1):
time.sleep(delay) # ❌ 阻塞调用
done.append(ItemResult(item=it, chars=len(it)))
await ctx.report_progress(i, len(items), f"已处理 {it}")
log(f"blocking 版跑完 {len(done)}/{len(items)}")
return BatchResult(total=len(items), results=done)
同一个对比脚本的输出:
[server] blocking 版跑完 10/10
batch_check_blocking: 2.0s 后 MCPError: Request 'tools/call' timed out
batch_check_blocking: 下一个请求等了 3.0s
客户端那边照样 2 秒超时,看起来一切正常,但:
- 服务端没停,10 条全跑完了(取消信号根本没机会被处理);
- 后面那个本该 0 秒返回的请求,等了 3 秒,因为服务端事件循环一直被占着。
在 Agent 场景里这很要命:模型以为工具失败了,可能马上重试,结果服务端同时跑着两份;如果工具有副作用(写文件、发请求),就会重复执行。
解法:把同步调用丢进线程,事件循环保持畅通。
@mcp.tool()
async def batch_check_thread(items: list[str], ctx: Context, delay: float = 0.5) -> BatchResult:
"""正解:阻塞的同步函数丢进线程跑,事件循环保持畅通。"""
def work(it: str) -> ItemResult:
time.sleep(delay) # 假装是一个只能同步调用的库
return ItemResult(item=it, chars=len(it))
done: list[ItemResult] = []
try:
for i, it in enumerate(items, 1):
done.append(await asyncio.to_thread(work, it))
await ctx.report_progress(i, len(items), f"已处理 {it}")
except asyncio.CancelledError:
log(f"thread 版被取消,已完成 {len(done)}/{len(items)}")
raise
return BatchResult(total=len(items), results=done)
[server] thread 版被取消,已完成 3/10
batch_check_thread: 2.0s 后 MCPError: Request 'tools/call' timed out
batch_check_thread: 下一个请求等了 0.0s
取消生效、下一个请求不受影响。注意一个细节:asyncio.to_thread 取消的是「等待」,已经在线程里跑的那一条不会被强行杀掉,它会跑完这一条再停。所以每个同步步骤尽量切小,副作用操作要能重复执行(幂等)。
坑 2:返回 dict,structured_content 是 None
我第一版工具的返回值类型写的是 -> dict,客户端拿到:
isError: False
result: None
数据其实在 r.content[0].text 里,是一段 JSON 字符串,但 structured_content 是空的。在我这版 SDK 上,裸 dict 返回类型没有生成结构化输出,只给了文本。改成上面的 Pydantic 模型 -> BatchResult 之后,structured_content 就是正常的字典了,客户端和模型都能按字段取,不用自己再 json.loads。
建议:凡是给程序消费的工具结果,都用 Pydantic 模型(或 TypedDict)声明返回类型。
坑 3:捕获了 CancelledError 却没有往上抛
服务端想在取消时做清理(删临时文件、记录做到第几条),很自然会写 except asyncio.CancelledError。关键是最后那个 raise 不能省。这是 asyncio 的通用规矩:吞掉 CancelledError,上层就收不到取消信号,任务可能被当成正常结束继续往下走(这一条是通用写法建议,文中没有单独做对照实验)。清理逻辑如果比较多,用 try/finally 更稳,正常结束和被取消都会执行。
另外,工具主动报错用 ToolError,客户端拿到的是 isError=True 的正常结果,而不是异常。实测:
True [TextContent(type='text', text='Error executing tool batch_check: items 不能为空', ...)]
这和超时不一样:超时在客户端是抛 MCPError 异常,业务错误是 isError 结果。Agent 循环里两种都要处理,前者通常可以重试,后者一般应该把错误文本回给模型,让它改参数。
六、小结
- 长任务工具:服务端
ctx.report_progress,客户端progress_callback,进度条和日志都有了; - 每个工具调用都设
read_timeout_seconds,别让 Agent 无限等待; - 在 Python SDK v2 上,超时会触发服务端取消,前提是工具代码里没有阻塞事件循环的调用;同步库一律
asyncio.to_thread; - 取消时的清理放在
except CancelledError: ... raise或finally里; - 返回值用 Pydantic 模型,拿到的是结构化结果。
完整代码就是文中的 server.py(把坑 1 里的两个工具也加进去)、client_progress.py、client_compare.py 三个文件,复制下来就能跑。
作者在做短视频内容自动化相关的产品,日常和 MCP 工具打交道,这里记录的是实际开发中踩过的坑。
更多推荐

所有评论(0)