副标题:MCP 专题 · 硬核实战
本文为 vergex.cn 原创首发,转载请注明出处。
上周,我把一个内部的 PostgreSQL 查询 MCP 接到 Claude Desktop。工具列表能正常刷出,但只要点击调用,界面就卡住 30 秒然后工具直接消失。最后定位的问题根本不在 SQL,而是一条被 print 到 stdout 的 INFO 日志混进了 JSON-RPC 帧。MCP 的协议层很薄,薄的好处是简单,坏处是所有边界条件都没有现成的护栏。以下是我踩过的 7 个坑,以及一张排查流程图(完整版 PDF 见站点)。
坑一:stdio 与 SSE 是两套错误模型
MCP 目前主流的传输层有两种:stdio(子进程)和 SSE(HTTP Server-Sent Events)。很多人把它当成“部署方式”二选一,但实际上这是两套完全不同的错误模型。选错了,后面的调试方向会完全跑偏。
stdio 把 server 当作 host 的子进程拉起,协议走 stdin/stdout。这种模式下没有网络、没有 CORS、没有 TLS,但 stdout 是神圣的协议通道。任何一条普通日志、一个进度条、一次 print("debug"),都会让 host 的 JSON-RPC 解析器当场崩溃。 symptoms 通常是:工具列表能出来,调用时却无响应或工具直接消失。
SSE 则把 server 当成一个独立 HTTP 服务。stdout 可以随意打日志,但你要面对端口、防火墙、TLS 证书、CORS、连接保活、断线重连、鉴权令牌泄露路径等问题。它适合需要多 host 共享、状态常驻或部署在远程的服务。
| 维度 | stdio(子进程) | SSE(HTTP) |
|---|---|---|
| 启动方式 | host 通过 command + args 拉起 |
host 连接独立 endpoint |
| 生命周期 | 随 host 启停,单会话绑定 | 长连接,host 断开 server 仍可运行 |
| 网络依赖 | 无,依赖本地 PATH | 依赖端口、TLS、DNS、CORS |
| stdout 用途 | JSON-RPC 专用通道 | 可用于普通日志 |
| 并发模型 | 单连接顺序请求,server 内部可并发 | 多 client 可并发,SSE 为单流 |
| 调试难度 | 高:stdout 被协议占用 | 中:可用 curl/浏览器 DevTools |
| 适合场景 | 本地工具、CLI 封装、快速 PoC | 远程服务、SaaS、共享状态服务 |
核心判断标准:如果你只是想给 Claude Desktop 或 Cursor 加一个本地脚本,用 stdio;如果你要把能力暴露给多个客户端或做成 SaaS,才考虑 SSE。
坑二:配置 JSON 的 schema 比看起来严格
以 Claude Desktop 的配置为例,很多同学直接抄了一段 JSON 就开始改,结果 host 死活不识别 server。常见错误集中在 args、env 和 command 三个字段。
args 必须是字符串数组,而不是单个带空格的字符串。host 会通过 command + args 直接调用 execvp 族的接口,不会帮你走 shell 解析。也就是说 "args": ["uv run server.py"] 会被当作一个整体参数传给 uv,而 "args": ["run", "server.py"] 才是正确的。
env 如果被显式写出,强烈建议把 PATH 一并写进去。不同 host 启动子进程时的环境变量策略不一样,有的会继承 host 环境,有的会生成一个最小环境。如果你只写了 DB_URL 却忘了 PATH,很可能出现 command not found,但日志里又看不到。
command 最好写绝对路径或在 env.PATH 里保证能找到。跨平台场景下,Windows 路径带空格时要保持数组拆分,不要拼到 command 里。
{
"mcpServers": {
"sql-helper": {
"command": "uv",
"args": ["run", "--with", "mcp", "server.py"],
"env": {
"PATH": "/usr/local/bin:/usr/bin",
"DB_URL": "postgresql://localhost:5432/demo"
}
}
}
}
另外,env 的 value 必须是字符串。写 "TIMEOUT": 30 这种数字会导致部分 host 的配置校验失败。养成全部加引号的习惯。
坑三:stdout 是协议通道,别把日志/进度条往里写
这是 stdio 模式下最容易踩、也最隐蔽的坑。MCP 的 JSON-RPC 消息是换行分隔的,host 读到一行就尝试解析。如果你的 server 在 stdout 上输出了任何非 JSON 内容,host 的行为通常不是报错,而是静默丢弃、关闭连接或工具列表清空。
Python 默认的 logging.basicConfig() 如果没有指定 stream,在很多环境下会输出到 stdout。tqdm 默认输出到 stderr,但某些进度条库或机器学习框架会写到 stdout。最安全的做法是:从一开始就显式把日志定向到 stderr,并且把 stdout 当作“只写协议消息”的通道。
import sys
import logging
logging.basicConfig(
stream=sys.stderr,
level=logging.WARNING,
format="%(asctime)s [%(levelname)s] %(message)s"
)
如果你需要更详细的调试信息,不要往 stderr 狂刷,而是写到文件。同时,server 内部所有 print() 都要改成 print(..., file=sys.stderr)。TypeScript 侧对应的做法是统一使用 console.error(),禁止 console.log()。
这个坑的症状非常典型:server 单独运行看起来正常,接到 host 里就出现“工具闪一下消失”或“初始化成功但调用无响应”。遇到这种情况,先用 -- 把 server 用独立命令跑起来,把 stdout 重定向到文件检查是否有非 JSON 行。
坑四:工具注册时,description 和参数 schema 就是 prompt
LLM 决定要不要调用某个 tool,依据的是 tools/list 返回的 name、description 和 inputSchema。很多开发者把 description 写得像内部注释,或者起名叫 query、run、execute 这种通用词,结果模型在不该调用的时候乱调,该调用的时候不调。
工具名要具体,description 要写明调用时机和副作用。例如一个查股价的 tool,不要写“查询数据”,而要写“当且仅当用户询问实时股价时调用”。如果工具会修改状态,必须显式在 description 里写出“此操作会写入数据库”。
参数 schema 由 Python 类型提示或 TypeScript Zod schema 生成。要尽量避免不必要的 anyOf 和 null。比如给参数默认值 None 时,生成的 JSON Schema 会变成 {"anyOf": [{"type": "string"}, {"type": "null"}]},这对模型的理解能力是一种消耗。如果参数确实是必填的,就不要给默认值。
下面是一个可直接运行的最小示例:
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("finance")
@mcp.tool()
async def query_spot(symbol: str, market: str = "US") -> str:
"""
查询指定标的的现货最新价。当且仅当用户询问实时价格时使用。
Args:
symbol: 标的代码,例如 AAPL、BTC-USD。
market: 市场分区,默认 US。
"""
# 这里替换为真实数据源
return f"{symbol}@{market}: 173.45"
if __name__ == "__main__":
mcp.run(transport="stdio")
启动命令:
uv run --with mcp server.py
或者用 MCP Inspector 本地调试:
npx -y @modelcontextprotocol/inspector uv run --with mcp server.py
注意,mcp.run() 默认就是 stdio;上面的 transport="stdio" 只是显式声明。如果你想测试 SSE,可以改成 transport="sse",但这时要额外处理端口和 CORS。
坑五:SSE 模式下的鉴权、CORS 与重连
SSE 模式看起来像普通 HTTP,但 MCP 的 SSE 连接分为两个通道:客户端通过 /sse 建立事件流接收 server 推送,然后通过 POST 到 /messages/?session_id=xxx 发送请求。很多新手只开放了一个路径或只配置了 GET 路由,结果请求能发出去却收不到响应。
CORS 必须配置。Claude Desktop、Cursor 或浏览器宿主在连接 SSE 时都会做预检。如果你的 server 没有返回正确的 Access-Control-Allow-Origin 和 Access-Control-Allow-Methods,浏览器宿主会直接静默失败。建议用反向代理统一处理 TLS 和 CORS,而不是在 server 代码里硬编码域名。
鉴权不要放在 URL query 里。query 会被代理日志、浏览器历史、错误上报系统记录下来。更好的做法是在初始 SSE 请求的 Authorization header 里传 short-lived token。但要留意浏览器原生的 EventSource API 不支持自定义 header,所以如果宿主基于浏览器内核实现,可能需要通过 fetch 封装 SSE。通用型客户端通常支持在配置里写 headers,但实现不一致,最稳的方式还是把鉴权交给反向代理的 mTLS 或 session cookie。
SSE 长连接还要考虑保活。中间件负载均衡可能 60 秒没有数据就断开连接。server 应该定期发送 comment 或 keepalive 事件,保持 TCP 连接。客户端侧也要实现指数退避重连,并准备好处理 session_id 失效后的重新初始化。
坑六:JSON-RPC 的 id、超时与幂等
MCP 基于 JSON-RPC 2.0。Request 必须带 id,Response 必须原样返回这个 id。一个常见问题是 server 内部用异步任务处理请求,结果返回时把 id 弄丢了或搞混了,客户端会挂起直到超时。
另一个细节是:没有 id 的消息属于 notification,server 不应该回复。如果你的 server 把某些 notification 误当成 request 回复了,会导致客户端状态机异常。
超时是硬边界。大多数 host 对单次 tool call 都有默认超时,通常是几十秒。如果你的工具可能运行更久,不要硬等,而是拆成两步:先返回一个任务 ID,再提供另一个 tool 让 host 查询结果;或者在支持的情况下通过 notifications/progress 发送进度。否则用户会看到工具“无响应”,然后 LLM 开始乱试别的工具。
幂等性也要重视。LLM 或 host 在超时后可能会重试,你的 tool handler 如果带有副作用,必须能够安全重放。写入类操作最好有去重键或幂等键,查询类操作天然幂等,但也不要在 handler 里维护会污染下一次调用的全局状态。
坑七:并发、僵尸进程与优雅退出
stdio server 虽然看起来是单连接,但 host 可以并发发送多个请求。你的 handler 应该是 async 的,并且不要在 handler 里做阻塞 I/O。如果某个 handler 阻塞了事件循环,整个 server 对所有请求都会无响应。
更严重的问题是进程生命周期。如果 host 崩溃或被强制退出,stdio server 子进程可能成为僵尸。server 代码里应该处理 SIGTERM 和 SIGINT,关闭 stdin/stdout,释放资源。TypeScript SDK 通常已经封装了这部分,但 Python 里如果你自己管理 asyncio loop,要记得加 signal handler:
import signal
def _shutdown():
loop = asyncio.get_event_loop()
loop.stop()
signal.signal(signal.SIGTERM, lambda _s, _f: _shutdown())
signal.signal(signal.SIGINT, lambda _s, _f: _shutdown())
另一个高频错误是在 tool handler 里调用 sys.exit() 或 os._exit()。这会直接终结整个 server 进程,host 那边的会话也会断开。任何需要退出的错误都应该通过返回错误信息或抛出异常来处理,而不是杀进程。
SSE server 同样有连接泄露风险。如果客户端断开但 server 没有检测到 TCP 断开,session 会堆积。建议给每个 SSE 连接设置读取超时和空闲超时,并定期清理孤儿 session。
排查流程图与速查清单
完整版的“MCP 问题排查流程图”已经整理成一页 PDF,覆盖“工具不显示 / 调用无响应 / 返回乱码 / SSE 连不上”四类高频场景,可在站点下载。日常排查时可以先按下面清单逐项排除:
- [ ]
args是字符串数组,而不是单个带空格的字符串 - [ ] 显式写了
env时,同时写明了PATH - [ ] server 没有向 stdout 输出日志、进度条或
print调试信息 - [ ] 工具名和 description 足够具体,不含模糊词
- [ ] 参数 schema 尽量避免
anyOf(null, ...)的冗余结构 - [ ] SSE endpoint 同时开放了
/sse和/messages/两个路径 - [ ] SSE 服务端已配置 CORS,且鉴权令牌不在 URL query 里
- [ ] JSON-RPC 请求的
id被正确原样返回 - [ ] 长任务拆分为异步任务 + 查询,或通过进度通知避免超时
- [ ] 进程退出时清理资源,不在 tool handler 里调用
sys.exit()
核心总结:MCP 的坑几乎都不在协议语义本身,而在传输层边界、进程管理和“被当成普通日志通道的 stdout”。把 stdout 当协议通道、把环境变量当空气、把工具描述当内部注释,是三个最常见的失败模式。先把边界守好,MCP 才会稳定。

