Windows 下自定义 MCP 踩坑:stdin 默认继承导致 CPython 启动阻塞

环境: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 文件出现了。转换其实"成功"了,只是结果回来得太晚。

第一轮排查:所有"看起来像"的原因

一开始怀疑过这些:

  1. MCP 配置的 timeoutMs 太小 ------配置是 300000ms,日志里 timedOut: false,排除。
  2. md2pdf.py 本身慢------命令行直接跑 0.66 秒,排除。
  3. MCP SDK 启动慢------日志显示 server connect 只用 3~5 秒,排除。
  4. 客户端把调用取消了------但取消只是结果,不是原因;真正的问题是调用为什么一直不返回。

这里犯了一个错误:把 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()

结果:

  • Popen 0.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 秒内完成。

复盘:三条经验

  1. MCP Server 里 spawn 子进程,三个标准流都显式指定。 至少写 stdin=subprocess.DEVNULL,不要依赖默认继承。这是本次问题的直接修复。
  2. 看到 cancelled 别急着归因。 先看日志里的 timedOut、durationMs 和具体 error,再用子进程时间线和单变量对照实验定位:到底是"执行慢",还是"根本没被调度"。
  3. 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 是跨平台更稳妥的写法,成本也只有一行。

相关推荐
小oo呆1 小时前
【学习心得】Python多进程
python·操作系统
CodeGL1 小时前
9、Python基础之IO编程
开发语言·数据库·python
HySpark1 小时前
ERes2Net声纹识别短音频匹配不稳定:从音频时长到有效音素的排查实践
python·语音识别·声纹识别·vad·eres2net
TomEval1 小时前
【测AI】第17篇:大模型评测方法论 —— LLM 怎么测?
人工智能·python·aigc·测试用例
fanruitian1 小时前
python 进程 线程
java·前端·python
m0_敲代码的彭于晏1681 小时前
OpenCode 2.0 智能编程效果全景展示
人工智能·python·语言模型
溪语流沙1 小时前
【Web全栈进阶】监控与告警:线上系统的“体检报告”
前端·vue.js·python·fastapi
蜡笔羊驼2 小时前
ModuleNotFoundError: No module named ‘torchvision.transforms.functional_tensor‘
linux·人工智能·python
李航19832 小时前
DeepDraw 网页版
人工智能·python·计算机视觉·ai·ai编程