副标题:一个真实排障日志。当你确认"工具已经列出来、模型也选了它、参数也对",但调用就是失败——问题往往不在你的代码逻辑,而在协议栈的某一层。
一、故障现场
上周四晚上,你的桌面应用的 MCP server 上线前夜。7 个工具里 6 个在 Claude Desktop 里调得顺风顺水:get_account_quota、search_contacts、list_recent_chats……唯独send_message怎么都调不通。
现象很诡异:
tools/list能列出来,模型也知道该调它;- 模型生成的
arguments完全正确(account_id、contact_id、text 都在); - 但一调用就报
Method not found或者干脆超时,Claude Desktop 里显示"工具调用失败,请重试"。
其他 6 个工具同一个 Server、同一套装饰器,凭什么就它不行?我赌了半包烟在"肯定是 send 的异步实现写错了",结果打脸。
二、排查树:从表象往根因走
我把排障分成四层,从外到内逐层排除:
- Host 层:是 Claude Desktop 的问题,还是所有 Host 都调不通?(换 Cursor 试)
- 协议层:JSON-RPC 的
method名、id、传输是否对? - Server 层:
@mcp.tool()注册成功了吗?tools/list返回里有没有它? - 业务层:工具函数内部是不是抛异常了?
第一斧:换 Host,定位是不是客户端问题
我先在 Cursor 里试了同一个send_message,结果一样失败。说明不是 Claude Desktop 的锅,是 Server 侧。
第二斧:抓 JSON-RPC 报文
在 MCP endpoint(127.0.0.1:53716)前面挂了一层 middleware 打印所有进出报文。发现一个关键线索:
{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"send_message","arguments":{"account_id":"acct_main_01","contact_id":"c_8842","text":"Hi"}}}请求长得没问题。但 Server 回的却是:
{"jsonrpc":"2.0","id":42,"error":{"code":-32601,"message":"Method not found"}}-32601 Method not found——意思是 Server 根本不认识tools/call这个 method。可其他 6 个工具明明能调,说明tools/call这个入口是存在的。矛盾点就在这里:同一个tools/call,换个别的工具名就行,换成send_message就报 method not found?
第三斧:看 tools/list 返回
我直接curl了tools/list,把返回的 tools 数组打印出来。真相浮出水面——
列表里压根没有send_message这个工具。
模型之所以"知道"要调它,是因为我的instructions里写了"可用工具涵盖消息发送",但tools/list实际注册的工具名是send_message(早期命名)。模型从 instructions 推测出名字,实际注册名对不上,于是tools/call带着错误的name进来,Server 当然回Method not found。
三、为什么只有它出问题
其他 6 个工具名都和 instructions 描述一致,模型不会搞错;唯独send_message在重构时改过名,instructions 没同步。模型不是"瞎调",是被我过时的 instructions 误导了。
这正好印证了上一篇说的:instructions字段的权重极高,但它的内容必须和tools/list真实注册名严格一致。一份过期的 instructions,比没有 instructions 更危险——它制造了"模型以为能调、实际调不到"的幻觉。
四、顺手修掉的另外两个暗雷
排查过程中还顺手发现两个隐患,记下来:
暗雷 1:streamable HTTP 的 idle 超时。我用的传输是 streamable HTTP,Server 侧给 SSE 连接设了 30s idle 超时。长文本发送(200 字产品介绍)偶尔耗时超过 30s,连接被服务端掐断,表现为"调用超时"。修复:把 idle 超时调到 120s,并在客户端加指数退避重试。
暗雷 2:async 事件循环嵌套。send_message内部调了 你的桌面应用业务后端的异步 SDK,而我最初图省事在同步工具函数里asyncio.run()包了一层。FastMCP 本身就是 async 跑的,嵌套事件循环直接抛RuntimeError: This event loop is already running。改成@mcp.tool()装饰 async 函数 +await后解决。
五、踩坑清单(这一篇的精华)
按"我真踩过"的顺序:
tools/list返回的工具名,是唯一真相来源。模型调工具用的是params.name,它必须和tools/list里注册的name一字不差。instructions/ 文档 / 你脑子里的名字都不算数。改了工具名,三处(注册、instructions、调用方)必须一起改。-32601 Method not found不等于"函数没写"。它只代表"带着这个 name 的 tools/call 进来了,但我这没有"。先curl tools/list看真实注册名,比翻代码快十倍。instructions过期比缺失更坑。它会被注入每个 Host 的上下文,模型高度信任。写完后每次改工具名/参数,把 instructions 当代码一样走 review。streamable HTTP 的 idle 超时要按最长工具耗时设。不要用默认 30s。长耗时工具(发消息、跑批)单独评估,必要时拆成"提交→轮询"两步。
MCP 工具函数一律
async def。只要内部有 IO(HTTP、DB、消息队列),就await,别在同步函数里asyncio.run()。FastMCP 已经在事件循环里了。排障要从协议层抓报文,不要靠猜。在 endpoint 前挂一层 middleware 打印 JSON-RPC 进出,90% 的"调不通"在报文里一目了然。这是性价比最高的调试习惯。
换 Host 复现,先排除客户端。一个 Host 调不通,立刻换另一个(Cursor / WorkBuddy / Cline)。如果都失败,问题 100% 在 Server;如果只有某一个失败,是 Host 的 MCP 实现差异。
六、下一篇预告
第 4 篇讲多租户路由设计:一个 你的桌面应用ID 怎么管多个 应用账号,account_id怎么在 MCP 工具层做路由隔离,避免"调 A 账号却发了 B 账号消息"的事故。
系列目录:
- 第 1 篇:为什么选 MCP、怎么和 HTTP API/CLI/WS 对比
- 第 2 篇:从 0 暴露 7 个工具的完整代码
- 第 3 篇(本篇):MCP 调用失败的排查实录
- 第 4 篇:多租户路由设计
- …… 共 10 篇,每日更新
如果你也在接 MCP,建议现在就给 endpoint 加一层报文日志——等"调不通"的那天,你会感谢今天的自己。