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, encodingutf-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, nargsargparse.REMAINDER) args ap.parse_args() server subprocess.Popen( args.cmd, stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderropen(args.log .stderr, ab), ) threading.Thread( targetpump, args(sys.stdin.buffer, server.stdin, args.log, C-S), daemonTrue, ).start() threading.Thread( targetpump, args(server.stdout, sys.stdout.buffer, args.log, S-C), daemonTrue, ).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}, filesys.stderr, flushTrue) try: result original_call(name, arguments) print(f[PROBE2] ok tool{name} result{result!r}, filesys.stderr, flushTrue) return result except Exception as e: print(f[PROBE2] exception type{type(e).__name__} value{e!r}, filesys.stderr, flushTrue) traceback.print_exc(filesys.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], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderropen(/tmp/probe3.stderr, ab), textTrue, bufsize1, ) def rpc(req): server.stdin.write(json.dumps(req, ensure_asciiFalse) \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, indent2, ensure_asciiFalse))写这个探针时最容易掉进两个坑。第一个坑是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, exthex } } }注意这里的关键信息协议层其实是有 error 的error.code 也正常error.data 里甚至保留了原始出错信息。但客户端 UI 只展示了message字段的“Internal error”大概率映射成了自己统一的“Tool execution failed”。如果你只看客户端确实什么都看不到只看服务端日志又会觉得“我已经报错了”。两边都没说谎只是看的字段不一样。4.3 探针二找到真正抛出的异常光有探针一的报文还不够我继续看探针二在服务端工具执行点旁路里抓到了这样的记录[PROBE2] exception typeValueError valueValueError(unsupported image format, exthex) Traceback (most recent call last): File meta_parser.py, line 42, in parse raise ValueError(funsupported 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, exthex要么让客户端在存在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 工具报错走哪条通道”其实没有标准答案因为每次报错走过的路径可能完全不同。但只要你掌握了在关键节点采集信号的方法通道就一定会现形。这是我这段时间最想分享的东西。