环境:Windows 11 · Python 3.14.3 · mcp 1.28.1
背景
最近在 ZCode 里挂了一个自定义 MCP Server,把本地一个 Markdown → PDF 的脚本 md2pdf.py 暴露成 md2pdf.convert 工具。服务端用 Python 的 mcp SDK(FastMCP),走 stdio 传输。我是在 ZCode 里配的,如图:

对应 JSON 配置如下:
json
{
"mcp": {
"servers": {
"md2pdf": {
"type": "stdio",
"command": "C:\\Program Files\\Python314\\python.exe",
"args": ["C:\\zht\\code\\md2pdf_mcp.py"],
"timeoutMs": 300000
}
}
}
}
脚本本身很普通:读取 Markdown,用 ReportLab 排版生成 PDF。命令行直接跑一个几百字的文档,只要 0.6 秒。
但通过 MCP 调用时,问题来了。
现象:一次"诡异"的 100 秒
配置好之后,我重新开了一个会话进行测试。结果 MCP 工具执行很慢,一开始 ZCode 还把失败归因为我中断了本轮对话,如图:

但把日志翻出来之后,事情并没有这么简单。第一次调用 md2pdf.convert,界面一直转圈,大约 95 秒后调用失败:
MCP tool call failed
durationMs: 95583
timedOut: false
error: "v4 sendQueuedNow preempts active turn"
第二次 81 秒,第三次 108 秒,错误变成 v4 session stopped。三次全部失败,但 timedOut 明确是 false------也就是说,根本不是 MCP 配置的 300 秒超时。
更诡异的是:第三次调用失败后大约 1 秒,目标 PDF 文件出现了。转换其实"成功"了,只是结果回来得太晚。
第一轮排查:所有"看起来像"的原因
一开始怀疑过这些:
- MCP 配置的 timeoutMs 太小 ------配置是 300000ms,日志里
timedOut: false,排除。 - md2pdf.py 本身慢------命令行直接跑 0.66 秒,排除。
- MCP SDK 启动慢------日志显示 server connect 只用 3~5 秒,排除。
- 客户端把调用取消了------但取消只是结果,不是原因;真正的问题是调用为什么一直不返回。
这里犯了一个错误:把 cancelled 当成了根因,而没有继续追问"为什么会一直挂住"。
转折点:把子进程的时间线打出来
先写了一个最小 MCP Server 做对照:只提供 ping 和 sleep 两个同步工具。结果都正常,ping 0.01 秒返回。
然后给 convert 加上分段计时:
python
t0 = time.time()
p = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
t1 = time.time()
out, err = p.communicate()
t2 = time.time()
结果:
Popen0.006 秒返回,子进程 PID 已经拿到;communicate()一直阻塞,直到客户端放弃;- 子进程自己写的时间戳文件显示:它在客户端超时的那一刻才开始执行。
这不是"转换慢",而是子进程根本没有被调度起来。
根因:继承的 stdin 管道
把子进程的 stdin 分别设为三种模式,做了一次单变量对照:
| 子进程 stdin | 结果 |
|---|---|
| 默认(继承父进程) | 一直阻塞到客户端断开 |
subprocess.DEVNULL |
0.54 秒完成 |
subprocess.PIPE |
0.54 秒完成 |
差异非常明确:当子进程继承了 MCP Server 的 stdin 时,CPython 启动阶段会阻塞在这个管道句柄上。
用 py-spy 抓到的原生栈也印证了这一点:
NtQueryInformationFile (ntdll.dll)
SetFilePointerEx (KERNELBASE.dll)
fflush_nolock (ucrtbase.dll)
lseeki64 (ucrtbase.dll)
...
Py_InitializeFromConfig (python314.dll)
Py_Main (python314.dll)
阻塞发生在 Py_InitializeFromConfig 期间:CPython 初始化标准流时,会对继承来的 stdin 句柄执行 seek/flush,在这个管道上一直不返回;只有当管道另一端关闭(客户端超时、断开,或者本轮被抢占)时,子进程才继续启动。
这也解释了几个"为什么":
- 为什么直接跑脚本没问题? 从命令行启动时,子进程继承的 stdin 是控制台或已关闭的管道,不会触发同样的阻塞。
- 为什么 MCP Server 里的
ping没问题? 因为它不创建子进程。 - 为什么原生
cmd.exe子进程没问题? 因为原生程序启动时不会做 CPython 那套 stdio 初始化。
修复
在 MCP Server 里给 subprocess.run 显式指定 stdin:
python
# stdin=DEVNULL 必须显式指定:否则子进程会继承 MCP 的 stdin 管道,
# CPython 启动时会在该管道句柄上阻塞,直到客户端断开。
proc = subprocess.run(
cmd,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=300,
)
md2pdf.py 本身不读 stdin,所以这是安全的最小改动。如果子进程确实需要输入,用 stdin=subprocess.PIPE 并通过 communicate(input=...) 传入数据,同样可以避开继承。
验证
- 用真实脚本走 MCP 协议:调用从 100 秒级降到 0.87 秒;
- 重启 MCP Server 后,在 ZCode 里直接调用
md2pdf.convert:立即返回,PDF 正常生成; - 不带
out参数走默认输出路径再测一次,同样在 1 秒内完成。
复盘:三条经验
- MCP Server 里 spawn 子进程,三个标准流都显式指定。 至少写
stdin=subprocess.DEVNULL,不要依赖默认继承。这是本次问题的直接修复。 - 看到
cancelled别急着归因。 先看日志里的timedOut、durationMs和具体error,再用子进程时间线和单变量对照实验定位:到底是"执行慢",还是"根本没被调度"。 - FastMCP 的同步工具会阻塞事件循环。 我们观察到
ping在convert挂住期间也无法响应;长耗时工具最好用 async,或者用anyio.to_thread.run_sync放到线程里执行。
如果你也在 Windows 上写 stdio MCP Server,并且工具里会调用外部命令,建议先检查一下 subprocess 的 stdin 参数。这个坑很隐蔽,日志里通常只会表现为"调用莫名很慢"或者"一直不返回"。日志里通常只会表现为"调用莫名很慢"或者"一直不返回"。
补充:其他操作系统的行为
本文的复现和验证都是在 Windows 11 上完成的。Linux/macOS 等 POSIX 系统上,子进程继承的 stdin 是文件描述符,CPython 启动时对管道执行 seek/flush 会立即返回 ESPIPE,预计不会出现本文这种"卡到客户端断开才恢复"的阻塞;WSL 下一般也按 Linux 的行为处理。
ESPIPE 是 POSIX 的 "Illegal seek" 错误码:对管道、FIFO、socket 调用 lseek 时,系统会立刻返回这个错误 ,而不是等待。CPython 启动时需要判断标准流能否定位/刷新,管道场景下拿到 ESPIPE 属于预期结果------它相当于告诉解释器"这个流不可 seek",初始化随即继续。关键不在"报错",而在"立刻报错、不阻塞"。Windows 上对应的 lseeki64 → SetFilePointerEx → NtQueryInformationFile 在本文的管道句柄上没有及时返回错误,而是一直等到管道另一端关闭,启动流程才继续,这就是阻塞的来源。
不过这不代表可以省略显式指定 stdin:不同 MCP 客户端、管道实现和 Python 版本仍可能有差异,stdin=subprocess.DEVNULL 是跨平台更稳妥的写法,成本也只有一行。