如果你在 Dify 里接 MCP 工具时见过这条红字,大概率是同一秒发生了三件事:工具被调用了、结果回来了、但平台在把它转成内部消息对象的那一步校验失败。报错原文是读取工具响应失败:Input should be a valid string [type=string_type, input_value=None,,后面被截断的部分通常是input_type=NoneType]。这半截 Pydantic 报错信息量其实很大:string_type说明某个被声明为字符串的字段收到了None,而“读取工具响应失败”这层中文包装说明错误发生在响应解析阶段,不是请求发出阶段、也不是网络握手阶段。MCP 也就是 Model Context Protocol,本质是一套基于 JSON-RPC 2.0 的工具与上下文协议,Dify 作为客户端去tools/call,拿到CallToolResult后再转译成自己内部的工具消息。这个转译环节只要遇到一个null,整条工具调用链就会断。下面我按“报错在说什么、根因分几类、怎么一步步定位、三层分别怎么改、影响面有多大”的顺序把它拆开,写给已经能跑通工具但卡在这条校验上的人,也写给刚接触 MCP 想少走弯路的人。
1. 这条报错在说什么:把 Pydantic 的抱怨翻译成人话
1.1 从报错文本里读出三层信息
先把这条报错当成一份“事故现场记录”来读。第一层是来源:Input should be a valid string是 Pydantic v2 的经典措辞,说明平台的响应模型里有类似text: str这样的字段声明,而反序列化时收到的是None。Pydantic v2 的校验比 v1 严格得多,v1 对None塞进str字段有时会做隐式转换,v2 直接拒绝。这不是 Dify 独有的问题,任何用 Pydantic v2 做响应建模的 MCP 客户端都会在同样位置炸。
第二层是时机:读取工具响应失败这个中文前缀,暴露了异常被捕获的位置。它发生在“工具已经返回结果、平台正在解析结果”之间,而不是“工具没连上”或“工具列表拉不到”。这个区分极其关键,因为排查方向完全不同:前者要去查工具返回体的形状,后者要去查传输与握手。
第三层是被截断的尾巴:input_value=None,后面大概率跟input_type=NoneType。如果你在日志里看到完整版,还能顺带看到loc字段,比如loc=('content', 0, 'text'),这个路径直接指出是content数组第 0 项的text字段为空。养成习惯:永远先去找完整的loc路径,它是这条报错里唯一直接指向病灶的信息。
顺带说一句,很多人看到string_type就以为是工具参数传错了。参数校验失败也会报同样的string_type,但那种情况下报错前缀通常是“工具调用失败”或“参数校验失败”,而不是“读取工具响应失败”。这一字之差,决定了你是该去改inputSchema,还是该去改工具的return。
1.2 MCP 的 tools/call 响应规范长什么样
要判断“什么算不合法”,先得知道“合法长什么样”。MCP 的tools/call返回的是一个CallToolResult对象,核心结构是content数组加一个可选的isError布尔标记。content里每一项是一个内容块,最常见的是文本块:
{ "jsonrpc": "2.0", "id": 12, "result": { "content": [ { "type": "text", "text": "订单 A001 状态:已发货" } ], "isError": false } }除了text,内容块还支持图片块(type: "image",带data和mimeType)和资源块(type: "resource",带resource.uri、mimeType、text或blob)。较新的规范版本还引入了structuredContent,用于承载结构化数据。
这里就藏着最容易出事的地方:文本块的text字段是必填的字符串,图片块和资源块根本没有text字段。如果客户端在转译时,对数组里每一项都无差别地执行item.get("text"),那么遇到图片块或资源块,拿到的就是None;如果它再把这个None塞进一个声明为text: str的内部模型,string_type就准时出现了。
还有一个更隐蔽的情况:content是空数组。规范允许工具返回空内容表示“执行成功但无输出”,比如发通知、写日志、触发异步任务这类没有返回值的工具。但很多客户端的转译代码默认content[0]一定存在,一旦数组为空,取出来的是None,同样炸在同一个地方。
1.3 为什么偏偏是"读取工具响应失败"这一步炸
从架构上看,Dify 接入 MCP 一般有两条路:一是远端 HTTP/SSE 形式的 MCP 服务,二是本地 stdio 形式的子进程服务。不管哪条路,客户端内部都会走“发起tools/call→ 拿到 JSON-RPC 响应 → 校验成CallToolResult→ 转译成平台内部消息 → 写入会话”这条流水线。
这条流水线上有两个校验点容易出问题。第一个在校验成CallToolResult时,如果服务端返回的text是null,规范层面的校验就会失败;第二个在转译成平台内部消息时,平台自己的模型(比如带text: str的文本内容类)会再校验一次。报错文案里“读取工具响应失败”这种偏业务化的表述,更像后者——协议层过了,业务层没过。
这也解释了一个常见疑惑:为什么用官方 Inspector 测的时候一切正常,一接进平台就报错。因为 Inspector 展示的是原始 JSON,对null很宽容;而平台要把它转成自己的消息对象,对类型很苛刻。协议合法不等于业务模型合法,这是理解这条报错的核心视角。
2. 根因归类:input_value=None 到底从哪来
2.1 工具函数忘了返回值,None 被原样塞进 text
这是出现频率最高的一类,尤其是在用 Python 写 MCP 服务的时候。Python 函数没有显式return时默认返回None,而不少 MCP 服务端实现会把函数返回值直接序列化进content。也就是说,只要你的工具函数在某个分支上忘了return,或者return的变量本身是None,就会生成一个text为null的文本块。
最常见的形态是有条件分支的查询类工具:
# 反例:命中空结果时隐式返回 None @mcp.tool() def query_order(order_id: str) -> str: row = db.get(order_id) if row: return json.dumps(row, ensure_ascii=False) # 这里没有 return,函数返回 None调用方传一个不存在的订单号,工具执行“成功”了,但返回体是{"type": "text", "text": null},客户端一校验就炸。这类的恶心之处在于:用正常数据测试永远是通的,只有边界数据才会触发,所以很容易被误判成“平台 bug”。
正确的写法是把所有分支都收敛到字符串,宁可返回一个语义明确的空结果 JSON,也不要返回None:
# 正例:所有分支都返回字符串 @mcp.tool() def query_order(order_id: str) -> str: row = db.get(order_id) if not row: return json.dumps({"found": False, "order_id": order_id}, ensure_ascii=False) return json.dumps({"found": True, **row}, ensure_ascii=False)提示:把返回类型注解写成
-> str并不能阻止运行时返回None,Python 不做强制检查。真正的保险是在函数末尾统一return,或者在装饰器层面加一层包装做兜底转换。
除了忘记return,还有一种变体:工具里写了try/except,except分支只打了日志没有返回,函数自然落到None。异常被吞掉了,工具看上去“成功”了,实际返回空。这种在联调阶段极难发现,因为日志里只有一行 warning,前端只看到一条红字。
2.2 返回了富内容,却被按纯文本去取
第二类是结构错配。工具本意是返回一张图表、一个 PDF、一份文件,走的是图片块或资源块,客户端却统一按文本块解析。前面说过,图片块只有data和mimeType,没有text,取text必然得到None。
这类问题在“文件处理”“图像分析”“报表导出”场景里特别集中。工具端觉得自己很规范——按规范返回了image类型的内容块;客户端也觉得自己很规范——按规范解析了content数组。两边都合规,但中间那层“转成平台消息”的胶水代码没做类型分支,就直接崩了。
还有一个变体是结构化数据被塞进文本块的错误位置。有些工具为了省事,把 JSON 对象直接当text的值返回:
{ "type": "text", "text": {"orderId": "A001", "status": "shipped"} }text声明是字符串,实际是对象,报错会变成Input should be a valid string [type=string_type, input_value={'orderId': ...}, input_type=dict]。注意这个和None的情况报错文本只差中间那段,但根因完全不同:一个是“没有值”,一个是“值类型错”。看报错时务必看清input_value后面到底是什么——None还是{...},直接决定你改哪一行。
2.3 stdio 通道被日志污染,响应帧解析出空壳
如果你的 MCP 服务是以 stdio 方式挂载的,也就是平台起一个子进程、通过标准输入输出通信,那要格外小心标准输出的污染。stdio 模式下,stdout就是协议通道,只能输出 JSON-RPC 帧。任何一句print("调试信息")、任何一个默认输出到stdout的日志 handler,都会往这条通道里掺沙子。
掺沙子的后果不是每次都报错,而是随机报错。客户端按行读取,读到一个不完整的 JSON 或者一个非 JSON 的行,解析失败,走了错误分支,构造出一个字段为空的响应对象,最后以text=None的形式炸在业务层。所以你会看到“同一个工具,有时候能跑,有时候报错”这种玄学现象。
判断方法很简单:把服务端的日志全部重定向到stderr,重启再测。如果报错消失,就是这个问题。标准做法是在入口处就把 logging 配置好:
import logging import sys logging.basicConfig( stream=sys.stderr, level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", )然后全项目禁用裸print,需要输出调试信息一律走logging。第三方库也要留意,有些库默认往stdout打横幅或者进度条,挂载前最好在启动脚本里把stdout临时替换掉,或者在服务入口处做一次sys.stdout = sys.stderr的重定向(把真正需要的协议输出交给 SDK 自己管理)。
2.4 超时与连接中断制造出的"伪响应"
第三类是传输层问题伪装成的解析问题。工具执行时间超过了客户端设定的超时阈值,客户端可能不会直接抛“超时”,而是先构造一个空响应或者半截响应,交给后续流程处理,结果在解析环节先炸出来,把真正的超时原因盖住了。
SSE 长连接场景下这种情况更多:服务端在流式返回中途断开,客户端只收到部分事件,拼出来的result是个空对象或者缺字段的对象,content取不到,text自然是None。还有反代层超时返回 502/504 空 body 的情况,客户端如果对空 body 做了容错解析,同样会走到“响应为空”的分支。
区分方法:看时间。如果报错总是在工具执行超过某个固定秒数后出现,基本可以锁定超时。这时不要急着改解析逻辑,先去调超时参数,或者把长任务改成“提交任务 + 轮询结果”的两段式设计。
2.5 参数校验失败在错误路径上带出的空对象
最后一类比较绕:工具本身因为参数不合法直接失败了,服务端返回的是isError: true的错误结果,而错误结果里的content在某些实现中没有填text,只有一个错误码字段。客户端在错误路径上构造内部消息时,同样会撞上text: str的校验。
举个例子,工具的inputSchema里某个参数声明为必填字符串,客户端传了null。服务端校验失败,返回{"content": [{"type": "text"}], "isError": true}——连text键都没有。这种情况的报错和“忘记 return”几乎一样,但修的地方完全不同:一个改工具返回值,一个改参数 schema 或者调用侧的传参逻辑。
下面这张表可以把五类根因的区分特征拉平了看:
| 现象特征 | 最可能根因 | 快速验证动作 | 修复位置 |
|---|---|---|---|
| 正常数据能跑,边界数据必错 | 工具函数漏 return | 传一个不存在的 ID 试一次 | 工具函数返回值 |
| 一接富内容就错,纯文本正常 | 内容块类型未分支处理 | 换成纯文本工具对比 | 客户端转译层 |
| 时好时坏,重启后短暂正常 | stdout 被日志污染 | 日志全改 stderr 再测 | 服务端日志配置 |
| 总在固定秒数后报错 | 执行超时 / 连接中断 | 调大超时看是否消失 | 超时参数 / 任务拆分 |
报错input_type=dict而非 NoneType | text 里塞了对象 | 看完整 input_value | 工具序列化方式 |
3. 动手排查:从外到内的四步定位法
3.1 第一步:脱离平台,用裸客户端直连
排查这类问题,第一原则是先把平台摘出去。很多人上来就在平台侧反复改配置、重启容器,效率极低。正确做法是写一个几十行的脚本,直接用 MCP 官方客户端连你的服务,把原始响应打出来看。
import asyncio from mcp import ClientSession from mcp.client.stdio import stdio_client, StdioServerParameters async def main(): params = StdioServerParameters(command="python", args=["server.py"]) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() print("tools:", [t.name for t in tools.tools]) result = await session.call_tool("query_order", {"order_id": "不存在的ID"}) print("isError:", result.isError) print("content:", result.content) asyncio.run(main())跑完这一步,你至少能拿到三个关键信息:工具是否列出、调用是否成功、content里到底有没有text、text是不是空。只要原始响应里text是None,问题 100% 在服务端或传输层,跟平台没关系,后面所有工作都应该在服务端做。反过来,如果原始响应完全正常,那问题就在平台的转译层,这时候你要做的是换一种内容类型测试,或者去看平台侧的日志。
这一步还有个附带好处:脚本里用不存在的ID这种边界值去试,能一把揪出“忘记 return”这类只在边界触发的缺陷。正常数据测三遍不如边界数据测一遍。
3.2 第二步:看服务端日志与原始 JSON-RPC 帧
脚本给出的响应是“结果”,日志给出的是“过程”。两边都要看,因为很多问题在结果上看是“text 为空”,在过程上看其实是“请求根本没到工具函数”。
拉日志时重点看三个位置。一是工具函数入口:有没有进来、入参是什么。如果入参里某个必填字段是null,那走的是 2.5 那条路。二是工具函数出口:有没有正常return、返回值是什么类型。打一行repr(result)比打str(result)有用得多,因为repr(None)是None,repr("")是'',一眼能分清“空字符串”和“没有值”。三是序列化之后、写回通道之前那一层,把最终要发的 JSON 打出来,这一步能直接看到text字段到底是什么。
如果是 SSE 或 HTTP 形态的服务,用浏览器的开发者工具或者任何 HTTP 抓包工具,看响应体的原始帧。重点确认两件事:响应是不是完整(有没有被截断)、Content-Type和事件流格式对不对。有时候问题出在反代的缓冲策略上,响应被攒着不发,客户端等到超时。
注意:抓包和日志里可能包含业务数据和凭证,脱敏后再贴到任何地方,包括工单和群里。这类细节不是小题大做,工具链里的数据往往比你以为的敏感得多。
3.3 第三步:最小工具二分法
当服务里有十几个工具、你不知道是哪一个在报错时,用二分法。先加一个只做一件事的工具,返回固定字符串:
@mcp.tool() def ping() -> str: """连通性测试,永远返回 ok""" return "ok"在平台里只挂这一个工具调用一次。如果ping也报同样的错,说明问题在通道层(stdio 污染、超时、反代),跟具体工具的业务逻辑无关,直接跳到 2.3、2.4 两类根因去查。如果ping正常,那就逐个把业务工具加回来,加到哪个出错就是哪个的问题。
这个方法看着笨,但它是唯一能快速把“通道问题”和“内容问题”分开的手段。我见过不止一次,团队花了半天改工具代码,最后发现是某个日志库往 stdout 打了个版本横幅。二分法三分钟就能定位。
二分的时候还有个技巧:给每个工具加一个_debug参数,默认false,传true时返回一段固定的、结构完整的 JSON。这样你可以在不改代码的前提下,用同一个工具分别验证“正常路径”和“边界路径”。
3.4 第四步:判断是内容问题还是通道问题
到这一步,应该已经能回答一个二选一的问题:同一个工具,用裸客户端直连返回正常、通过平台调用返回异常,是通道问题;两边都异常,是内容问题。
内容问题的典型特征是稳定复现、特定工具特定入参必现。通道问题的典型特征是时好时坏、重启后短暂恢复、换个网络环境结果不同。这两类的处理思路完全相反:内容问题要改代码契约,通道问题要改部署配置。
还有一个容易被忽略的判据是报错出现的时机。如果报错在点击调用的瞬间就出现,多半是参数校验或者工具根本没执行;如果等了几秒才出现,多半是执行过程中的超时或异常;如果是在流式输出过程中突然断掉,那是连接层的问题。把时间维度加进来,定位速度会快很多。
4. 修复:工具端、网关端、平台端三层怎么改
4.1 工具端:把返回值契约钉死
工具端是第一责任人,也是最容易改的地方。核心原则只有一条:任何分支都必须返回一个符合声明类型的值,绝不允许None漏出去。落地成三条规则。
规则一,所有工具的返回类型统一收敛为字符串,内部再序列化。不要一会儿返回dict、一会儿返回str、一会儿返回None,类型越统一,客户端越省心。规则二,空结果用语义化的空对象表达,而不是用空值表达。查不到就返回{"found": false},执行成功无输出就返回"ok",永远给客户端一个能解析的东西。规则三,在装饰器外层加一道兜底包装,把None转成空字符串,把不可序列化的对象转成字符串:
from functools import wraps import json def safe_result(fn): @wraps(fn) def wrapper(*args, **kwargs): try: value = fn(*args, **kwargs) except Exception as e: return json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False) if value is None: return "" if isinstance(value, str): return value return json.dumps(value, ensure_ascii=False, default=str) return wrapper这层包装的价值不只是“防 None”,更重要的是它把异常也变成了合法响应。工具内部抛异常时,如果服务端实现没有妥善处理,返回的可能是一个残缺的错误对象;有了这层包装,客户端永远能拿到一段可读的文本,用户看到的是“执行失败:xxx”,而不是一条 Pydantic 报错。
提示:兜底包装是保险,不是免死金牌。
None被静默转成空字符串后,问题从“报错”变成了“返回为空”,用户会疑惑“为什么没结果”。所以包装里最好加一条 warning 日志,把这个None记下来,定期清理。
4.2 富内容走对字段,别混进 text
返回图片、文件、结构化数据时,严格按内容块类型来。文本块只放文本,图片块放data加mimeType,资源块放uri加mimeType加text或blob。不要把二进制 base64 塞进文本块的text,也不要为了让客户端好解析就把所有内容都拍平成字符串——拍平之后信息丢了,客户端也没法做渲染。
如果确实需要同时返回“一段说明文字 + 一张图”,正确做法是在content数组里放两项,一项文本块、一项图片块。客户端的转译层要按type做分支:文本块取text并做空值保护,图片块走图片消息,资源块走文件消息,遇到未知类型降级成一段可读的描述文本。这个分支逻辑是客户端侧必须有的,缺了它,只要工具一返回富内容就会崩。
对于结构化数据,较新的规范里提供了structuredContent字段,专门承载机器可读的对象。如果你的客户端版本支持,优先用它;不支持的话,退回“文本块里放 JSON 字符串”,但要确保真的序列化成字符串了,而不是把dict对象直接赋给text。
4.3 网关/中间层做归一化兜底
在工具端和平台端之间,如果有一层自建的网关或者中间件,这里是做统一归一化最合适的位置。因为工具可能有很多个、可能是不同团队写的、可能你根本改不动别人的代码,但网关是你自己的。
归一化的逻辑不复杂:遍历content数组,逐项做类型判断,把None全部替换成有意义的默认值,把不认识的类型降级成文本描述。核心代码如下:
def normalize_content(content): """把任意 content 数组归一化成客户端一定能解析的结构""" if not content: return [{"type": "text", "text": ""}] out = [] for item in content: if not isinstance(item, dict): out.append({"type": "text", "text": str(item)}) continue ctype = item.get("type") or "text" if ctype == "text": text = item.get("text") out.append({"type": "text", "text": "" if text is None else str(text)}) elif ctype == "image": out.append({ "type": "text", "text": f"[图片内容,类型 {item.get('mimeType') or 'unknown'}]", }) elif ctype == "resource": res = item.get("resource") or {} out.append({ "type": "text", "text": str(res.get("text") or f"[资源 {res.get('uri') or 'unknown'}]"), }) else: out.append({"type": "text", "text": str(item)}) return out这段代码的关键思路是“降级而非丢弃”。图片客户端渲染不了,就转成一句可读的描述,让用户知道“这里有图但显示不出来”,而不是让整条调用链直接报错。归一化之后,再补一层日志,把每次发生降级的原始内容记下来,方便回头去修上游。
4.4 超时与重试参数怎么定
超时值的设定要根据工具的实际耗时分布来定,不能拍脑袋。一个可操作的做法是:先在测试环境跑一百次正常调用,统计 P95 耗时,超时值取 P95 的 2 到 3 倍。如果 P95 是三秒,超时设十秒就够了;如果 P95 已经是二十五秒,那说明这个工具本身就不适合同步调用,应该改成异步任务模式。
重试要极其克制。工具调用不是幂等操作,尤其是“下单”“发消息”“写库”这类副作用工具,重试可能造成重复执行。真要重试,只对“连接层失败”重试,对“业务层返回错误”不重试。而且重试次数不要超过两次,间隔用指数退避。
还有一个常被忽略的参数是单次响应的大小上限。有些工具会返回几兆的文本,客户端解析时可能触发内存或者截断问题,表现出来同样是“响应读取失败”。如果你怀疑是这个方向,让工具先返回前两千字符试试,能跑通就说明是体量问题,改成分页或者返回文件链接。
5. 影响范围与常见问题速查
5.1 这个错误会把哪些链路一起拖垮
很多人以为这只是“一个工具报错”,实际上影响面比想象中大。最直接的一层是单次工具调用失败:用户点了按钮或者 Agent 自动触发,拿不到结果。这一层影响可控,报个错刷新一下就好。
第二层是Agent 的推理链断裂。在 Agent 模式里,工具调用是推理循环的一环。工具返回异常,模型拿不到观察结果,要么停在原地反复重试,要么基于残缺信息给出错误结论。表现为“回答突然中断”“开始车轱辘话”“给出明显与事实不符的答复”。用户看不出是工具的问题,只会觉得“这个助手不好用”。
第三层是会话状态被污染。有些平台会把工具调用的中间态写进会话历史,一个None值写进去,后续每一轮对话都要带着这个脏数据走,可能引发连续报错。这时候光修工具不够,还得清会话缓存或者新建会话验证。
第四层是监控与告警的噪音。工具调用失败率一旦上来,误报会淹没真正的故障信号。所以修复之后要做的第一件事不是庆祝,是把这条错误加进监控,设一个阈值告警,防止复发时无人察觉。
从范围上看,这个问题具有“单点触发、全链路放大”的特点:一个工具的一个分支返回None,可能让整个助手在特定场景下不可用。这也是为什么值得花时间把它彻底修掉,而不是绕过。
5.2 常见问题速查表
| 问题现象 | 排查动作 | 结论与处置 |
|---|---|---|
| 报错固定出现,换工具就好 | 对比出错工具与其他工具的返回结构 | 该工具返回值有None,加兜底 |
| 所有工具都报错 | 用ping工具单测 | 通道问题,查 stdout 污染或超时 |
| 首次调用正常,第二次报错 | 看会话历史里的中间态 | 清会话重建,检查状态写入逻辑 |
| 本地跑通,部署后报错 | 对比环境变量与日志配置 | 大概率是 stdout 被日志污染 |
报错文本里是dict不是None | 看工具是否把对象塞进了 text | 改成json.dumps序列化 |
| 加日志后报错频率变高 | 检查日志输出目标 | 日志误写 stdout,全改 stderr |
| 长任务必现,短任务正常 | 记录耗时曲线 | 超时设置过小或需改异步模式 |
5.3 几条不太容易想到的经验
第一条,在工具里返回空字符串,比返回None安全得多,但空字符串同样会让用户困惑。更好的做法是返回一句人话,比如“未查询到符合条件的记录”。工具的输出最终是给人看的,别把它当成纯粹的机器接口。
第二条,别在工具里做太多事。见过不少工具,一个函数里查库、调第三方、拼报表、发通知,任何一环出问题都表现为同一个“响应读取失败”。拆成多个小工具,每个只管一件事,出问题时定位成本会低一个数量级。
第三条,联调阶段就把边界值测一遍。空字符串、超长字符串、不存在的 ID、负数、特殊字符、超大文件,这些在单元测试里各跑一次,能提前挡掉八成线上问题。等用户触发再排查,代价高得多。
第四条,把完整的错误信息落到日志里。平台展示的报错是被截断的,loc路径和input_type往往看不到。在客户端侧或者网关侧把完整的异常堆栈记下来,下次再遇到同类问题,一眼就能定位到具体字段。
第五条,升级要跟着规范走。MCP 规范在演进,structuredContent、内容块类型、错误结构都可能变。升级客户端 SDK 之前,先看一眼变更说明里有没有涉及响应结构的部分,避免升级后老工具集体失效。
我自己的习惯是,每接一个新工具,先在裸客户端里跑三个用例:正常输入、边界输入、异常输入,三个都返回合法结构了再挂到平台上。这个动作只多花五分钟,但能把“响应读取失败”这类问题挡在联调之前。工具生态越接越多的时候,你会发现真正省时间的从来不是写得多快,而是每一次返回都是可预期的。