MCP 工具报错,到底要往哪查?这是我这段时间被问得最多的一句话。某天下午在调试一个支持 MCP 的客户端,界面上只弹出一行“Tool execution failed”,点开详情什么都没有;服务端日志里堆栈是有的,但我把异常改成更详细的错误信息重新调用,客户端还是只显示同样的一句话。我用三条探针做了一次最小复现检查,把“不同环节都会怎么改写错误”这件事彻底搞清楚。这篇文章就记录整个过程,适合在本地或远程环境里维护 MCP 服务、经常与协议层报错打交道的开发者。
先说结论:MCP 工具报错从来不是一条直线走到客户端。错误可能在工具执行时产生,也可能在 SDK 封装时被改写,还可能到了客户端之后被 UI 故意简化。你要做的第一件事,不是猜“到底哪里出了问题”,而是确认“这段错误现在正在哪一层、是以什么形态存在的”。三条探针就是为了回答这个问题。
1. MCP 工具报错的通道:四段链路与三个常见错位
1.1 一次工具调用最少经过哪几段
很多刚接触 MCP 的开发者,会把“工具调用”理解成一个函数调用:客户端喊一声,服务端跑一下,结果回来。但在做最小复现检查之前,建议先把这条链路拆成四段,因为每一段都是一次可能的“错误改写现场”。
第一段是客户端侧。客户端拿到你的输入,根据协议协议组装出一个 JSON-RPC 2.0 的tools/call请求,然后交给传输层。第二段是传输层本身。本地最常用的是 stdio,也就是把客户端进程和服务端进程用标准输入输出管道连起来;远程则多数是 HTTP 或 SSE。第三段是服务端内部。它收到请求后要完成协议握手、方法路由,根据工具名找到注册表里的处理器,最终执行你写的那段真实业务代码。第四段是返回路径,结果或者异常要从服务端按 JSON-RPC 格式封装,再穿过传输层回到客户端 UI 层展示。
注意第四段并不是简单原路返回。工具代码执行后返回的“东西”和服务端真正写进 stdout 的“东西”,往往已经不一样了。我之前排查过一个案例,工具函数明明返回了一个结构化对象,客户端收到的却是被 SDK 包装成字符串的错误消息,这就是在第四段发生的改写。
1.2 报错不是从哪发生就从哪原样暴露
我在这里要先纠正一个直觉:很多人以为“错误在哪里发生,就会以什么形态到达客户端”,其实完全不是。JSON-RPC 的 error 对象结构本身只有三个最常用的字段:code、message、data。其中code是数字错误码,message是给人类看的字符串,data是可选的附加详情。
麻烦出在两层。第一层是服务端 SDK 的“兜底捕获”。大多数 MCP 服务端框架为了不让工具异常直接炸穿协议,都会在外面包一层try/catch,把任何异常转成 error 对象返回。问题是这个兜底逻辑通常只做一件事:catch (e) { return { code: -32603, message: e.message || String(e) }; }。如果你的异常对象里面带了stack、cause、自定义字段,这些信息大概率都会在这一步被丢掉。前几天我还在一个服务的返回报文里看到message: "Internal error",真正的原始错误被塞进了data.original_error,这在协议层面其实是合法的,但展示层未必会去读。
第二层是客户端 UI 的“统一化处理”。很多客户端并不会把服务端返回的 error.message 一字不改地摆出来,而是映射成自己的一套文案,比如所有 tool 调用失败都显示“Tool execution failed”。这就是为什么服务端说“我已经返回了”,客户端却像什么都不知道一样。
1.3 先按“错误走向”给通道分个类
既然要做“报错走哪条通道”的最小复现检查,我们得先把通道本身定义清楚。我自己习惯把错误分四类:
- 启动与握手期失败:服务端进程没起来、初始化失败、端口被占用、协议版本不匹配。这类错误通常发生在任何工具调用之前,客户端表现多半是“连接失败”或者“会话无法建立”。
- 传输层失败:stdio 管道断开、HTTP 超时、服务端 stdout 混入了非协议内容导致 JSON 解析失败。这类错误的特点往往是客户端显示“无响应”或“请求超时”。
- 工具执行层异常:你写的工具函数真的
throw了,堆栈也在服务端日志里,但经过 SDK 封装后 message 可能变成通用文案。 - 业务层“静默失败”:工具没有抛异常,而是返回了一个格式正常但内容表示失败的结果,比如
{ success: false, reason: "磁盘已满" }。
前两类相对好定位,第三类是最常见的“错误被吞”场景,而第四类最坑——因为从协议层看,这次调用是成功的,UI 根本不会把这段失败当报错处理。
我经常提醒同事:不要只看客户端有没有弹错误框。把同样的逻辑前移到服务端,在每一个可能被截断的环节插一个观察点,才能判断错误到底是在哪一层被变成了现在这个样子。这就是三条探针要做的事。
2. 三条探针的最小复现设计:先缩环境,再定观察点
2.1 最小复现的两个关键前提
“最小复现检查”这个词听起来有点学院派,但我在实际排错里发现它非常实用。核心原则只有两条:第一,把环境缩到不能再缩;第二,在固定情况下复现错误。
环境缩到最小,指的是你只保留一个 MCP 服务端、一个能发起调用的客户端入口、一个测试工具、一个固定的入参。很多人一上来就在自己开发的项目里排查,结果同时加载了十几个工具,还有自动补全、权限检查、埋点日志在跑,出错时根本无法判断责任方。我自己的做法是单独建一个目录,用一个最简单的服务端脚本和最小测试工具,跑通一次正常调用后再把错误触发出来。
固定版本也很重要。MCP 协议的客户端和服务端 SDK 不同版本,对错误字段的处理策略差别非常大。有些旧版本 SDK 会把整个异常对象直接String()拍成[object Object],这个现象很容易让人误判成业务代码问题,实际上升级 SDK 就好了。
2.2 为什么不是一条探针而是三条
你可能会想:改服务端日志不行吗?或者直接在客户端界面看不行吗?我试过,都不够。服务端日志只能证明“异常在这里发生过”,看不到协议层最终返回了什么;客户端 UI 只能看到被加工后的展示层结果,看不到原始报文;直接抓配置文件改调试开关,又会改变被测服务的行为。
三探针的设计初衷,是三个互不干扰的信号采集点,各自捕捉不同阶段的“错误形态”。用医学做类比,探针一相当于在血管里放一个摄像头看血液流动,探针二相当于在手术位置旁边放一个录音笔记录手术过程,探针三相当于最后把病理切片送去做第三方化验。位置不同、采集角度不同、回答的问题也不同。
2.3 三条探针的分工表
我在排错时的判断框架是这样的:
| 探针 | 采集位置 | 能看到什么 | 回答什么问题 |
|---|---|---|---|
| 探针一:stdio 日志隧道 | 客户端进程与服务端进程之间的传输管道 | 原始字节流、JSON-RPC 报文、stdout 是否被污染 | 协议报文长什么样?错误字段完整吗? |
| 探针二:服务端执行点旁路 | 服务端注册工具或call_tool的入口 | 入参、出参、异常类型、未被 SDK 捕获前的堆栈 | 真异常到底是什么?堆栈在哪里? |
| 探针三:最小 JSON-RPC 模拟器 | 完全绕开 GUI 客户端,直接与服务端对话 | 服务端对外暴露的“标准独立响应体” | 错误是服务端侧的问题,还是客户端展示的问题? |
三条探针在最小复现环境里互不干扰,原因在于它们各自独立消费信息,不会在被测系统内部互相影响。下面讲具体实现。
3. 探针搭建实录:隧道、旁路、模拟器
3.1 探针一:stdio 双向日志隧道
本地 MCP 服务最常用的传输方式是 stdio。所谓 stdio,就是客户端像启动一个子进程一样把你服务端的启动命令跑起来,然后通过 stdin 写请求、从 stdout 读响应。探针一的基本思路,是在客户端和服务端之间插入一个“中间人”,把所有流经的字节原样记录下来。
实现方式很简单。把你原本在 MCP 客户端配置里填的服务启动命令,比如node server.js,替换成python tunnel.py --log /tmp/mcp_trace.log node server.js。这个 tunnel 脚本负责启动真正的服务端进程,然后把客户端的标准输入转给服务端、把服务端的标准输出转回客户端,同时把所有内容按方向记录到日志文件。
#!/usr/bin/env python3 """tunnel.py: stdio 双向转发 + 原始字节日志""" import argparse import os import subprocess import sys import threading import time def pump(src, dst, log_path, label): while True: try: data = os.read(src.fileno(), 4096) except (OSError, ValueError): break if not data: break ts = time.strftime("%H:%M:%S", time.localtime()) with open(log_path, "a", encoding="utf-8") as f: f.write(f"[{label} {ts}] {data.decode('utf-8', 'replace')}\n") try: dst.write(data) dst.flush() except (OSError, ValueError): break def main(): ap = argparse.ArgumentParser() ap.add_argument("--log", default="/tmp/mcp_tunnel.log") ap.add_argument("cmd", nargs=argparse.REMAINDER) args = ap.parse_args() server = subprocess.Popen( args.cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=open(args.log + ".stderr", "ab"), ) threading.Thread( target=pump, args=(sys.stdin.buffer, server.stdin, args.log, "C->S"), daemon=True, ).start() threading.Thread( target=pump, args=(server.stdout, sys.stdout.buffer, args.log, "S->C"), daemon=True, ).start() server.wait() if __name__ == "__main__": main()这里面有几个容易踩的细节。第一,读数据不要用readline(),要用read()或os.read(),因为服务端返回的消息可能分片,按行读容易卡住或截断。第二,日志文件记录的是字节流,不是解析后的 JSON,这样哪怕 UUID、时间戳、乱码混进去也能看到。第三,服务端的 stderr 必须单独重定向,否则服务端日志会直接污染协议通道。
拿到日志之后,我一般会按三个问题去翻:请求和响应是不是合法的单行 JSON?请求的id和响应的id是否对应?error 对象里的code、message、data分别是什么。如果发现 stdout 里混了[INFO] xxx这类日志,基本可以确定是有人把调试输出写到了标准输出上,协议解析失败就是从这里开始的。
3.2 探针二:服务端工具执行点旁路
探针一看到的是协议报文,但它回答不了“工具函数真实抛出的异常是什么”。这个要靠探针二。我不主张去改每个工具函数的内部代码,而是建议在服务端入口处给call_tool加一个旁路包装。
不同 MCP SDK 暴露的内部函数名不一样,但思路一致:找到那个“接收工具名和参数、执行真实工具函数”的方法,在它外面套一层,在调用前后各记录一次,遇到异常时先打印完整堆栈再重新抛出。
import sys import traceback def wrap_tool_call(original_call): def instrumented(name, arguments): print(f"[PROBE2] call tool={name} args={arguments!r}", file=sys.stderr, flush=True) try: result = original_call(name, arguments) print(f"[PROBE2] ok tool={name} result={result!r}", file=sys.stderr, flush=True) return result except Exception as e: print(f"[PROBE2] exception type={type(e).__name__} value={e!r}", file=sys.stderr, flush=True) traceback.print_exc(file=sys.stderr) raise return instrumented server.call_tool = wrap_tool_call(server.call_tool)这里有个细节可能和直觉相反:探针二打印完堆栈之后,没有任何兜底,而是直接raise。我要的就是让异常继续按原本的路径走,让 SDK 自己去做封装,这样我们才能对比“封装前”和“封装后”的差异。打印必须走 stderr,如果走 stdout 会破坏协议通道,这和我们探针一要抓的现象就重合了。
我特别建议用{e!r}而不是{e}来打印异常,因为repr会保留异常对象的完整字符串形态,有时候能直接看出 SDK 把什么对象塞进了异常链。曾经有一个案例,异常对象本身是一个自定义类型,其__str__方法没有实现,导致str(e)变成了[object Object],用repr打印后才发现原始信息其实还在某个属性里。
3.3 探针三:最小 JSON-RPC 模拟器
探针一负责看传输层,探针二负责看服务端内部,探针三则回答一个看似多余但关键的问题:如果绕开所有客户端框架,直接向服务端发一条标准请求,返回的响应体到底是什么?我自己经常用这个探针来做“第三方裁决”。
最小模拟器的核心是一个手工构造的 JSON-RPC 客户端,用最简单的文本流方式发送请求,再读回响应。关键是要按协议顺序完成握手:先initialize,再发一个notifications/initialized通知,之后才能正常发tools/call。
import json import subprocess server = subprocess.Popen( ["node", "server.js"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=open("/tmp/probe3.stderr", "ab"), text=True, bufsize=1, ) def rpc(req): server.stdin.write(json.dumps(req, ensure_ascii=False) + "\n") server.stdin.flush() line = server.stdout.readline() if not line: raise RuntimeError("server exited before response") return json.loads(line) # 1. 初始化握手 rpc( { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "0.0.1", "capabilities": {}, "clientInfo": {"name": "probe3", "version": "0.1"}, }, } ) # 2. initialized 通知(不需要 id,服务端也可能不返回内容) server.stdin.write( json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n" ) server.stdin.flush() # 3. 调用工具 resp = rpc( { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "read_meta", "arguments": {"path": "demo.bin"}, }, } ) print(json.dumps(resp, indent=2, ensure_ascii=False))写这个探针时最容易掉进两个坑。第一个坑是initialize握手之后直接发tools/call,忘了发notifications/initialized。有些服务端对顺序要求不严格,但严格的服务端会直接拒绝调用,导致一个本来很靠后的错误被提前触发。第二个坑是读取响应时用了readline()却不知道服务端消息可能跨行。为了省事,我这里假设一行一条消息,但实际调试时建议改成一个基于长度前缀或持续读取的解析函数。
探针三最大的价值,在于它能帮你在不引入任何客户端的缓存、拦截、UI 映射之后,看到一个“纯粹暴露在协议里的服务端响应体”。如果这份响应和客户端显示不一致,那问题就出在客户端侧;如果一致,那问题从服务端就已经开始了。
3.4 搭建探针时容易掉的三个坑
第一条探针的记录日志文件不要放临时目录,放项目目录下,并且每次运行前先清空,否则几次调用混在一起很难追。我自己吃过亏:日志文件太大,用编辑器打开卡了十几秒。
第二条探针的包裹不能只包一个工具函数。如果你只给单个工具做旁路,其他工具的报错仍然看不到。建议统一包在入口方法上,保证所有工具的调用都会经过。
探针三的代码最好不要把它写复杂。它是一次性的诊断工具,你不需要把错误码枚举、重试逻辑、分页全都塞进去。保持最小,这样每次运行都能确认“是的,我的最小复现请求就是刚才那三行”。
4. 把三条探针用在一起:一次报错的完整追查过程
4.1 现象:客户端只有一句 Tool execution failed
理论说完了,结合一次真实排查来演示。某次我在一个模拟项目 X 里接了一个图像元数据读取工具read_meta,从某个支持 MCP 的客户端里调用时,界面弹出一句“Tool execution failed”,点了详情也看不到任何额外信息。服务端那边倒是也打了日志,日志里确实有异常,但对方坚持说自己已经把错误返回了。
两边都觉得是对方的锅。为了不吵架,我搭了三条探针,把最小复现环境搭在本地:一个只注册了read_meta的服务端、一个固定的输入文件、一条固定的调用命令。搭建过程大概半小时,其中大部分时间花在写探针代码上。
4.2 探针一揭开的第一个事实
第一轮排查中,探针一先抓到了一条原始响应,结构大概是这样的:
{ "jsonrpc": "2.0", "id": 3, "error": { "code": -32603, "message": "Internal error", "data": { "original": "unsupported image format, ext=hex" } } }注意这里的关键信息:协议层其实是有 error 的,error.code 也正常,error.data 里甚至保留了原始出错信息。但客户端 UI 只展示了message字段的“Internal error”,大概率映射成了自己统一的“Tool execution failed”。如果你只看客户端,确实什么都看不到;只看服务端日志,又会觉得“我已经报错了”。两边都没说谎,只是看的字段不一样。
4.3 探针二找到真正抛出的异常
光有探针一的报文还不够,我继续看探针二,在服务端工具执行点旁路里抓到了这样的记录:
[PROBE2] exception type=ValueError value=ValueError('unsupported image format, ext=hex') Traceback (most recent call last): File "meta_parser.py", line 42, in parse raise ValueError(f"unsupported image format, ext={ext}")到这里真相已经浮出水面:工具代码本身是没问题的,它抛出的 ValueError 信息还挺完整。但 SDK 的异常封装逻辑没有把e.message直接放进去,而是把它替换成了统一的 “Internal error”,把原始的e.message塞进了data.original。用户在 UI 里只看得到message,看不到data,于是信息就这样被折断了。
4.4 探针三做最终裁决
为了确认这确实是服务端侧行为,而不是某个客户端做了什么额外处理,我再用探针三手工调一遍。结果返回的 JSON 与探针一抓到的几乎完全一致。这样问题归属就清晰了:服务端 SDK 在异常封装时选择了“message 统一化、细节进 data”的策略,而客户端 UI 只展示 message。两个环节叠在一起,产生了“好像谁都没错,但错误就是看不到”的结果。
修复思路就很明确了。要么把服务端 SDK 升级或配置成在异常时保留原始 message,比如让 error 对象变成message: "unsupported image format, ext=hex";要么让客户端在存在error.data时展示 data 内容;再或者,让工具把错误设计成返回值而不是抛出异常。这个案例也说明了为什么三条探针要同时开:探针一告诉你协议层有错,探针二告诉你错误源头是什么,探针三告诉你这不是客户端单方面的问题。
4.5 另一条分支:错误没走 error 通道的静默返回
另一类值得专门提起的场景是“静默失败”。我们项目里另一个工具,返回的是{ success: false, reason: "disk full" },但协议层没有任何 error。探针一和探针三都显示这是一次成功的调用,code是 0,没有 error 结构,客户端自然也就不会把这个结果呈现成报错。
这是因为工具函数没有抛异常,而是把业务失败包装成了“正常返回的 result”。这时候报错并没有走 JSON-RPC 的 error 通道,而是藏在 result 内容里。很多刚接触 MCP 的开发者不会去检查这种通道的差异,导致数据明明不对,但界面上一切正常。
我处理这类的做法,是把工具的返回值规范成带明确成功标志的结构,比如{ "isError": false, "data": ... }或{ "isError": true, "reason": ... },并且约定:凡是业务失败必须自己抛异常,凡是业务成功但数据可疑则返回失败标志。两条通道分开走,才不会被吞。
5. 排查沉淀与快捷判断法
5.1 几条我反复验证过的规律
这套排查做完后,我沉淀了几个判断通道的经验性规律。
第一,绝大多数“错误看不全”都不是一个人造成的,而是服务端 SDK 的规范化处理和客户端 UI 的简化展示叠加的结果。你很难在单个环节里找到“全责方”。第二,错误信息丢失最严重的环节是服务端 SDK 的兜底 catch。工具异常原本带着堆栈和文本,一旦进了 catch,往往只剩一个 message 字符串,可能还是通用文案。第三,data 字段是保存原始错误最好的地方,但很多人不知道客户端 UI 会不会读它,所以不要假设它会被展示。第四,最隐蔽的错误往往不报错,而是以正常 result 形态返回的失败。协议层越“干净”,这类问题越容易被日常排错路径漏掉。
这些规律不是教科书里写的,而是我在翻了很多个探针日志之后总结出来的。现在排查任何 MCP 报错,我会先问三个问题:请求出去了没有?错是哪个环节产生的?最终客户端到底收到并展示了哪个字段?三个问题分别对应三根探针。
5.2 根据现象快速定位通道
和同事协作时,我给他们整理了一张快查表,现在基本贴在调试区的显示器上:
| 客户端现象 | 优先怀疑通道 | 先用哪个探针 |
|---|---|---|
| 显示 Tool execution failed 且详情为空 | 协议 error 存在,但 message 被规范化或稀释 | 探针一 |
| 超时、无响应、连接断开 | 传输层或服务端启动失败,stdout 被污染 | 探针一、服务端 stderr |
| 详情里出现大段堆栈但 UI 渲染不全 | SDK 把堆栈塞进 data,展示层没读 | 探针二、探针三 |
| 没有报错,但结果明显不对 | 业务层静默失败,走了 result 通道 | 探针二、探针三 |
| 客户端显示服务端错误码如 -32000/-32603 | 协议层错误已生成,问题往往在服务端封装 | 探针一、探针三 |
这张表的用途不是替代排查,而是帮你决定第一动作。拿到一个报错时先想清楚它最有可能属于哪类通道,再去开对应的探针,能省掉很多反复试客户端的时间。
5.3 我现在的排查习惯
我现在调试 MCP 服务时,已经把探针一和探针二做成了调试配置里的常备项。探针一那个隧道脚本永远留着,需要用的时候直接把客户端配置里的命令替换掉就能开抓;探针二用一个环境变量控制开启,默认关闭,因为大部分时间不需要打印调用细节。探针三写好之后放在项目的 scripts 目录里,作为回归脚本,每次改动服务端协议处理逻辑之后跑一遍,确认对外响应体仍然稳定。
这三条探针不是每次都全部打开。多数场景里,探针一加探针二已经覆盖了八成的问题:先看协议报文长什么样,再看服务端内部真异常是什么。只有当两边结论对不上,或者需要给团队一个事后的截屏证据时,我才会把探针三也跑一遍。
回到开头那个问题,“MCP 工具报错走哪条通道”其实没有标准答案,因为每次报错走过的路径可能完全不同。但只要你掌握了在关键节点采集信号的方法,通道就一定会现形。这是我这段时间最想分享的东西。