你是不是也遇到过这种情况MCP 工具在自己的测试脚本里跑得好好的一接进宿主端就“失灵”页面既不显示错误也不返回数据看起来就像工具从来没执行过。更气人的是你把同样的入参丢回本地脚本又能复现出异常。这种“报错不知道走到哪儿去了”的问题根源其实不在业务逻辑而在 MCP 工具的错误传输通道没有对齐。我早期接 MCP 工具时踩过这个坑后来养成了一个习惯任何工具正式接入之前先做一组“三条探针”的最小复现检查专门回答一个问题——这个工具真正报错时错误到底走的是哪条通道。这篇文章把这套检查方法完整拆出来包含探针怎么设计、最小复现环境怎么搭、结果怎么解读以及定位之后修复往哪个方向走适合所有正在开发 MCP 工具的人参考。1. 先回答“报错有哪些通道”MCP 工具错误的三层出口排查任何问题之前得先把“通道”这个概念搞清楚。MCP 工具的一次调用从客户端发起tools/call到服务端执行工具函数再返回结果中间要经过多个层次。每一层都有自己处理错误的方式错误最终落在不同出口宿主端看到的表现完全不同。1.1 通道一工具返回内容里的“业务错误”这是最容易混淆的一条通道。工具函数内部自己 catch 住异常然后返回一个包含错误信息的字符串或对象例如{code: E_BAD_INPUT}。对于 MCP 协议来说这不是错误这是一次成功的工具调用。SDK 会把这个对象当作正常结果包装成CallToolResult塞进content字段返回给客户端。这条通道的特点是协议层面完全正常所有“错误”都藏在返回值的内容里。宿主端如果只是简单渲染textContent你会看到一串 JSON 或者一段普通文本如果宿主端对结构化内容支持得不好那个{code: E_BAD_INPUT}就会被序列化成一段很难看的字符串。用户看到的结果是“工具执行成功但输出了一堆看不懂的东西”几乎不会把它和“工具报错了”联系起来。1.2 通道二JSON-RPC error 与 SDK 自带的异常转换第二条通道在协议层。服务端工具函数抛出了未被捕获的异常MCP SDK 会拦截它并尝试把它转换成 JSON-RPC error 响应。这条通道对应response.error字段里面有标准的错误码框架层也经常用isError标记位来告知客户端这次调用是失败的。问题在于不是所有异常都能被 SDK 转成 JSON-RPC error。不同语言、不同版本的 SDK内部策略差异很大。有的 SDK 只把特定类型的异常映射为协议错误其余异常直接吞掉并返回空内容有的 SDK 在序列化异常信息时失败反而导致连接中断。更隐蔽的是某些宿主端在收到 JSON-RPC error 后会把它转换成自己的内部错误再把错误信息注入到会话上下文里日志里看不到原始堆栈。1.3 通道三stderr 日志与宿主端可见性差异第三条通道是服务端进程的 stderr。工具函数抛出的异常、SDK 打印的警告、第三方库打出的堆栈通常都会往 stderr 上写。这本来是诊断信息最全的一条通道却也最容易引起误判。在 stdio 传输模式下stdout 是协议通道所有 MCP 报文都走 stdout。你如果在工具函数里用print打印调试信息会直接污染协议报文导致客户端解析失败。反过来stderr 上的内容不会影响协议但能不能被看到完全取决于宿主端有没有捕获子进程的 stderr。有些宿主端会把这些日志转发到自己的日志面板有些则直接丢弃。这导致同一个错误在某些环境里你一眼就能看到堆栈换个环境却什么都搜不到。三条通道的差异总结起来是这样的通道表现形式宿主端典型表现排查难度业务错误工具返回内容正常返回内容里带错误码显示一串输出不像报错低但容易被忽略JSON-RPC error响应里带 error 字段红色错误提示或文案中取决于 SDK 转换策略stderr 日志服务端进程日志日志面板里可能有堆栈高取决于宿主端是否捕获明白了这三层出口再去设计最小复现检查思路就清晰多了。2. 三条探针的最小复现设计从工具内到客户端一次看全我设计的三条探针对应三个检测位置工具函数内部、SDK 出口、客户端接收端。每条探针刻意只暴露一种错误形态这样一旦结果里出现不符合预期的表现就能立刻锁定是哪一层出了问题。2.1 探针A捕获后返回结构化错误标记探针A的工具函数不抛出异常而是捕获异常之后返回一个明确的错误对象。这个对象包含一个标准字段probe_tag值定为A_CHANNEL方便后续在任何出口识别它。from mcp.server.fastmcp import FastMCP mcp FastMCP(probe-server) mcp.tool() def probe_a_handled_error(): try: # 模拟一个必然失败的业务操作 raise ValueError(stock data missing) except Exception as exc: return { code: E_STOCK_MISSING, message: str(exc), probe_tag: A_CHANNEL, }这条探针想验证的核心是工具函数返回的错误对象经过 SDK 包装后在客户端手里是什么样子。你不需要关心 SDK 怎么处理异常只需要观察“正常返回的错误数据”是否能完整到达客户端。若数据在半路被截断、被序列化扭曲、或者根本没出现在结果里那说明输出阶段就有问题。2.2 探针B直接抛出原始异常探针B是探针A的反面。它不在函数内部做任何捕获直接抛出一个带独特信息的异常。这是用来测试“意外错误”路径的因为真实生产环境里不是你所有代码都能写进 try-catch。mcp.tool() def probe_b_raw_exception(): raise RuntimeError(boom from probe B, tagB_CHANNEL)这条探针的核心观察点是SDK 接到这个异常后是转换成 JSON-RPC error 返回给客户端还是吞掉异常返回空结果或者直接在连接层断掉。我的经验是这里不同 SDK 版本差别很大所以它也是最能暴露环境差异的一条探针。2.3 探针C客户端三通道同时采样前两条探针都是在服务端动手第三条探针放在客户端。它不额外制造错误而是调用前两条探针并把响应结果里的几个关键字段一次性都打印出来包括response.error、result.content、result.structuredContent同时抓取服务端进程的 stderr 摘要。import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def check(channel: str): server StdioServerParameters( commandpython, args[probe_server.py], cwd., env{MCP_LOG_LEVEL: DEBUG}, ) async with stdio_client(server) as (read, write): async with ClientSession(read, write) as session: await session.initialize() result await session.call_tool( probe_a_handled_error if channel A else probe_b_raw_exception, {}, ) print( channel, channel, ) print(jsonrpc_error:, getattr(result, error, None)) if result.content: print(text_content:, result.content[0].text[:500]) print(structured_content:, result.structuredContent) asyncio.run(check(A)) asyncio.run(check(B))这里有个细节call_tool的返回值在多数 SDK 里已经不再是原始 JSON-RPC 响应而是被解析过的CallToolResult对象所以探针C顺手把原始error字段一并打印。这样三条通道的信息就齐了协议错误、文本内容、结构化内容。2.4 三种探针对应的预期结果矩阵设计探针时不妨先写一份“预期结果表”知道每条探针在健康环境下应该呈现什么状态再拿实际输出做对比。下面是我常用的判断参考探针健康环境预期表现异常预警信号探针Atext_content或structured_content里能看到probe_tagA_CHANNEL内容为空、字段丢失、序列化后缺 field探针B客户端能看到明确error信息或服务端 stderr 有完整堆栈无 error、无堆栈、连接没有任何反应探针C三个采样字段至少一个能确认错误来源三个字段全空且 stderr 无日志有了这张表实际跑完探针之后只需要对着填结果问题出在哪一层就一目了然。3. 搭建最小复现环境依赖版本、stdio 传输和日志缓冲的细节探针代码本身没什么复杂逻辑真正容易翻车的是最小复现环境。同一个探针脚本在不同环境里跑出来的结果可能完全是两回事多数时候不是业务代码的问题而是环境细节没有对齐。3.1 环境准备与依赖版本对齐我用 Python SDK 做探针时固定用一套虚拟环境不混装系统级依赖。这里用uv管理虚拟环境比较省心虚拟环境隔离干净避免其他项目改动了pydantic或httpx版本导致行为变化。MCP SDK 的版本对异常处理策略影响很大。如果服务端用的 SDK 和客户端用的 SDK 大版本不一致可能遇到字段命名差异、错误对象解析失败等问题。我这里建议做探针测试时两边统一到同一个版本用uv lock锁定依赖。项目里的pyproject.toml长这样[project] name mcp-probe version 0.1.0 requires-python 3.11 dependencies [ mcp1.3.0, ] [dependency-groups] dev [ pytest8.0, ]锁版本不只是为了复现性更重要的是让你踩过的坑有据可查。我遇到过一种情况工具抛异常后旧版 SDK 会把原始错误字符串反射回textContent升级小版本后就改成只返回通用错误码之前写好的前端错误提示完全失效。没有版本锁定这类问题几乎没法定位。3.2 stdio 模式下日志输出的纪律问题做探针检查时服务端脚本里绝对不能往 stdout 写任何非协议内容。一个最简单的print(server starting)就可能让 client 端解析报文失败报一个莫名其妙的协议错误。正确做法是所有调试信息都走logging模块并且配置输出到 stderr。探针脚本里的日志配置我一般这样写import logging logging.basicConfig( levellogging.DEBUG, format%(asctime)s [%(levelname)s] %(name)s: %(message)s, )在 stdio 模式下这是唯一安全的调试输出通道。如果你发现自己写了个print也能跑通那纯属运气好因为客户端解析器可能正好容忍了多余的行但不代表生产环境也能容忍。3.3 client 侧收集 stderr 的方法探针C要在客户端同时查看服务端 stderr这也是一个容易被忽略的点。用 Python SDK 的stdio_client时你启动的是一个子进程默认情况下这个子进程的 stderr 会直接继承当前进程的 stderr也就是会打印在你的终端上。但很多代码里为了不让服务端日志干扰正常输出会故意把stderr重定向到DEVNULL这样一旦出问题你什么都看不到。探针检查时不要偷懒应该保留 stderr 或把它写入日志文件。改法很简单import subprocess server StdioServerParameters( commandpython, args[probe_server.py], stderrsubprocess.PIPE, # 让 stderr 可被捕获 )如果你通过stdio_client接入SDK 内部暴露的stderr处理能力有限我一般会直接在外面用subprocess.Popen包一层进程同时读 stdout 和 stderr再手动喂给 MCP 客户端。这样虽然多写几行代码但排查时可以同时看到协议报文和日志输出信息量完全不同。考虑到最小复现的目标多这一步非常值。4. 探针实测三条探针跑出来的结果分部拆解探针和环境的准备工作都到位后实际跑一遍。下面我按自己实际跑完的结果来描述你可以拿着这个结果和自己的输出对比。需要说明的是以下结果基于我当时的 SDK 版本和宿主端配置换成不同版本可能细节有差异但通道的结构是一样的。4.1 探针A的结果错误数据活着到达客户端但不好看探针A调用后客户端收到的text_content是一个序列化后的 JSON 字符串probe_tagA_CHANNEL原样保留code和message也都还在。structuredContent能拿到解析后的对象但宿主端并不会把它当作错误处理因为协议层面这次调用是成功的。这意味着业务错误可以通但用户体验部分基本靠运气。宿主端如果只是渲染纯文本用户会因为一段 JSON 而感到困惑宿主端如果支持结构化内容渲染能把code字段用红色展示用户才能看出是报错。你在服务端控制不了这点只能靠宿主端的能力。4.2 探针B的结果SDK 吞掉了异常错误残留在 stderr探针B的结果比较意外。客户端拿到的返回值不是 JSON-RPC error而是一个空的CallToolResultisError标记位也没有被置为True。换句话说SDK 没有把这个异常转成协议层错误而是把它吞掉了只在服务端 stderr 留下了完整的RuntimeError堆栈。这个现象说明你如果直接把原生异常抛给 SDK错误并不会自动成为一条客户端可见的报错。在宿主端界面里这次调用看起来就是“执行失败但没有返回任何数据”用户连错误摘要都看不到。如果你以为 SDK 会帮你把异常转成 JSON-RPC error那这个探针结果就会推翻你的假设。4.3 探针C的结果三通道采样定位问题的关键探针C把三个字段同时拉齐定位就变得很直观jsonrpc_error字段一直是None说明协议层没有错误探针A的text_content里有错误码探针B的text_content是空structuredContent只有探针A有内容两个探针的 stderr 都能看到日志但探针A的日志只是警告级别探针B的日志包含完整异常堆栈。对比预期表之后问题集中在两点一是 SDK 对未捕获异常的处理策略是“吞掉后置空”而不是返回协议错误二是宿主端只把 JSON-RPC error 当作真正错误所以探针B 的异常完全没被展示。后续修复只要盯着这两点做就行。5. 定位之后的修复方向让错误真正到达该到的地方探针跑通了通道也定位了剩下的问题是怎么修。修不是让工具不报错而是让错误在正确的场景到达正确的通道。5.1 在工具层统一错误规约业务逻辑里的预期错误全部采用探针A的“捕获后返回结构化错误对象”方案但要形成统一规约。错误对象至少包含三个字段错误码、可读消息、恢复提示。错误码供程序判断消息供用户阅读恢复提示告诉用户下一步怎么做。def ok(dataNone): return {ok: True, data: data} def fail(code, message, hint): return { ok: False, error: {code: code, message: message, hint: hint}, } mcp.tool() def fetch_stock(symbol: str): if not symbol.strip(): return fail(E_BAD_INPUT, 股票代码不能为空, 请检查输入后重试) try: data real_business_logic(symbol) return ok(data) except Exception as exc: return fail(E_INTERNAL, str(exc), 请稍后重试或联系维护人员)这套规约的价值在于宿主端无论怎么渲染至少用户能看到“操作失败”和原因而不会面对一个空结果。5.2 在 SDK 层把意外异常映射为 isError 标记对于探针B暴露的“异常被吞”的问题不能简单依赖 SDK 默认策略。应该在服务端入口统一做异常包装把意外异常也转成CallToolResult的isErrorTrue状态。服务端工具注册时可以用装饰器包一层from mcp.server.fastmcp import FastMCP from functools import wraps mcp FastMCP(probe-server) def mcp_tool_guard(func): wraps(func) async def wrapper(*args, **kwargs): try: return await func(*args, **kwargs) except Exception as exc: return { isError: True, content: [{ type: text, text: f工具执行异常: {exc}, }], } return wrapper mcp.tool() mcp_tool_guard def probe_b_raw_exception(): raise RuntimeError(boom from probe B, tagB_CHANNEL)这样处理后客户端拿到的结果是明确标记了isError的宿主端至少会把它当错误态展示而不是静默失败。5.3 在 client 侧同时检查多个字段并落日志如果你的工具是给别人接入用的能控制的部分很有限但至少要保证自己的 client 同时检查error、isError、content、structuredContent和 stderr并把它们一并写入结构化日志。我写过一个小工具函数来做这个聚合检查def summarize_tool_result(result: CallToolResult) - dict: return { error: getattr(result, error, None), is_error: getattr(result, isError, False), text: result.content[0].text if result.content else None, structured: result.structuredContent, }这样每次工具调用日志里都会有一条“错误通道摘要”。它能帮你快速复盘任何一次看起来奇怪的失败都可以回溯到具体是哪一层丢的信息。探针C在其中就是把这个摘要逻辑自动化了。5.4 团队协作层面的错误契约文档工具如果多人协作最好在项目里维护一份错误契约文档。文档只需要一张表错误码、错误含义、触发场景、推荐恢复动作、预期通道。比如E_STOCK_MISSING预期通道是工具返回isErrorFalse的结构化错误对象恢复动作是提示用户检查数据库连接E_INTERNAL则标记为意外错误必须在 stderr 留完整堆栈同时返回isErrorTrue。这张表的用途不是规范文档本身而是让所有接入方有一个统一的判断基准。当有人看到某个行为不符合预期时第一反应应该是查契约而不是凭感觉改代码。和探针矩阵配合使用排查效率会高很多。三条探针的检查方法整体跑一遍通常只需要半小时。但这半小时能帮你省掉后面几天“为什么工具没有报错”的无效排查时间。我的体会是MCP 工具的难不在于写工具函数而在于错误信息能不能在协议、内容、日志三层通道里找到正确归宿。把这套探针检查当作常规交付流程的一部分就像上线前跑一遍测试一样后面你会感谢现在的自己。最后再分享一个实际操作的小习惯每次升级 SDK 版本后把探针脚本重新跑一遍看看通道行为有没有变化。版本升级带来的行为漂移往往就藏在这些细节里。
阅读完成 · 觉得有帮助?