Tool 返回值报错?TaoToken 这样改 Codex 的配置再对照 Java/Python 📅 发布时间:2026/9/17 19:01:38 👁 浏览次数: MCP 的 Tool 返回值报错Java 侧尤其难查。我先在 TaoTokenhttps://taotoken.net/?utm_sourcetaotoken_aicg_blog_end拿了一把 Key把 Codex 的 Base URL 填成 https://taotoken.net/api再让它读本地的 MCP Server 代码把 Python 与 Java 的 Content 类型写法并排摆出来对照。根因不复杂Python 的 FastMCP 会把函数返回的字符串自动包装成合规的 content 数组而 Java SDK 走强类型路线必须显式返回 McpSchema.CallToolResult里面再塞一个 McpSchema.TextContent 列表。少这一层Server 进程活得好好的客户端却在解析 tool result 时直接失败日志里只留一句语焉不详的类型错误。这篇按排障顺序走先把报错现场钉死再把 Codex 的通道配好然后把两版 Tool 代码交给它做对照改写最后处理返回值修好之后才会冒出来的连带问题。整条链路上 Codex 只干三件事——解释代码、生成 SQL 或包装代码、比对差异它不会去连你的库、也不会替你执行任何东西执行永远在你本地。1. 钉死报错现场Java 的 CallToolResult 和 Python 的裸字符串1.1 同一个工具两边吐出来的结构不是一个形状先看 Python 侧最常见的写法。用 FastMCP 装饰器注册一个get_current_time函数签名写- strbody 里 return 一个 ISO 字符串mcp.run(transportstdio)就能跑。这个过程里有一个隐形动作装饰器在注册时把返回值类型识别成文本序列化时自动包成content: [{type: text, text: ...}]。你写的是字符串协议里跑的是数组。Java 侧没有这层自动包装。SDK 的addTool第二个参数是一个返回McpSchema.CallToolResult的函数构造它需要两个东西content 列表和 isError 标志。文本内容得自己new McpSchema.TextContent(...)包一层再List.of(...)装进结果对象。因为编译期类型检查在你很难写出「返回裸字符串」这种代码——它压根不让你过。真正会出问题的是另外两条路一是用Map.of(text, now)之类的原始结构糊弄过去二是从别的语言或老版本 SDK 抄了一段返回体字段名看着像但结构不对。1.2 客户端侧的报错文本和它对应的真实结构排障先看客户端说了什么。这类问题的报错通常有三种面孔一种是 JSON 解析失败大意是 content 期望数组却收到字符串一种是协议层校验失败工具调用被判定为非法 result还有一种最阴——不报错但模型回复里说「没有拿到工具结果」你去翻日志发现 content 是空数组而 isError 还是 false。定位办法只有一条别猜结构直接把 Server 往 stdout 上写的那段 JSON 抓出来看。stdio 传输下协议消息走标准输出你在本地起 Server 时重定向一下就能看到原始报文python server.py server_out.log 2 server_err.log然后重点看三个字段result.content是不是数组、数组元素里有没有type和text、isError是不是 false。Python 版和 Java 版各抓一次两份报文并排贴出来差异一目了然。这一步做完再去找 Codex 问你问的是「这两份报文结构差在哪、Java 怎么补」而不是让它对着一段模糊描述盲猜效率差好几倍。2. 让 Codex 读代码之前先把 Key 和 config.toml 填对2.1 在 TaoToken 建一把 Key模型 ID 去模型广场当场确认打开 TaoToken 注册并进入控制台创建 API Key得到的字符串下文统一写作YOUR_API_KEY。同时把要用的模型 ID 一起抄下来——别从任何博客里照搬一个带日期后缀的名字模型 ID 以 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 上的模型广场当时列表为准列表刷新过就以刷新后的为准。这一步看起来和 MCP 返回值毫无关系但它决定了后面能不能让 Codex 读完你本地那两个文件。Key 有了、模型 ID 确认了才轮到配置文件。2.2 ~/.codex/config.toml 里把 base_url 指向 https://taotoken.net/apiCodex 的供应商配置写在~/.codex/config.toml。下面这份是可复制的最小结构把两处占位符换掉即可model YOUR_MODEL_ID model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY wire_api chat配套把 Key 放进环境变量名字要和env_key完全一致export TAOTOKEN_API_KEYYOUR_API_KEY有两个坑要提前说清楚。第一base_url填的是接口地址https://taotoken.net/api末尾不要加/v1也不要把官网落地页地址填进这个字段两个地址用途不同官网地址用来注册、建 Key、看模型广场和用量接口地址只填进工具。第二model_providers下的字段名在不同 Codex 版本里略有出入如果你本地版本不认wire_api删掉它再试其余三个字段是通用的。2.3 先发一条消息确认通道通了再动代码配完别急着做对照。先让 Codex 回一条简单的解释性问题比如「MCP 协议里 tool result 的 content 字段为什么必须是数组」。这条能正常回说明 Key、模型 ID、Base URL 三者都对上了后面如果对照代码时它答得离谱问题就在你给的上下文而不是通道。如果这条回不通先按顺序查三件事环境变量有没有在当前 shell 里生效换个终端窗口就没了、模型 ID 是不是抄错、base_url有没有被顺手加上/v1。这三条覆盖了绝大多数第一次配置失败的情况排查时间比反复改文件短得多。3. 把两版 Tool 代码交给 Codex让它产出合规的返回包装3.1 上下文怎么给只给关键文件不要整个仓库Codex 读代码的效果和你给的范围强相关。把整个项目丢过去它大概率会先花力气理解构建脚本和目录结构真正的 Tool 返回逻辑反而被埋掉。正确做法是把范围收窄到三样东西Java 侧的 Tool 注册类、Python 侧对应的 Server 文件、以及你刚抓出来的两份原始报文。给的时候顺手把问题说清楚「这是同一个工具在两套生态里的实现Python 版客户端能正常解析Java 版客户端报 content 类型错误请指出 Java 侧返回体缺少哪一层包装并给出符合 MCP 规范的写法。」问题里带上现象和期望比只贴代码效果好很多。至于它是否需要查规范细节让它基于你贴的代码和报文推理即可你负责把结论拿回本地验证。3.2 Java 侧的修正从原始结构到 TextContent 列表Java SDK 里正确的返回形态是把文本内容包成McpSchema.TextContent再组成列表交给McpSchema.CallToolResult。把注册和实现拆开写会更清楚public static McpSchema.Tool getCurrentTimeTool() { return new McpSchema.Tool( get_current_time, 获取当前时间, Map.of(type, object, properties, Map.of()) ); } public static McpSchema.CallToolResult getCurrentTime() { String now LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME); return new McpSchema.CallToolResult( List.of(new McpSchema.TextContent(now)), false ); }注册时把两者接上server.addTool(getCurrentTimeTool(), request - getCurrentTime());对照点在于CallToolResult的第一个参数是 content 列表而不是字符串第二个参数 isError 表示这次调用本身是否失败——「工具没找到」「参数不合法」这类业务失败应该走异常或把 isError 置为 true而不是硬塞一个描述性字符串当正常结果返回。这两件事混在一起客户端和模型都会误判。3.3 Python 侧别大意FastMCP 帮你包了底层 Server 不会Python 版之所以看着「返回字符串就行」是因为 FastMCP 在装饰器里做了包装。但如果你没用 FastMCP而是直接用底层的Server注册call_tool回调约定就变了——回调需要自己返回 content 列表import datetime import mcp.types as types from mcp.server import Server server Server(time-server) server.call_tool() async def call_tool(name: str, arguments: dict): if name get_current_time: return [types.TextContent(typetext, textdatetime.datetime.now().isoformat())] raise ValueError(funknown tool: {name})这也是一部分迁移动作出问题的原因从 FastMCP 换成底层 Server或者反过来返回契约同时变了代码没跟着改客户端表现就和 Java 侧那个「content 不是数组」一模一样。所以对照时不要只比语言要比「我这一侧用的是哪一层封装」把对比维度写进给 Codex 的提示里。3.4 工具里带 SQL 时让 Codex 出 SQL你在本地执行如果这个 MCP 工具内部是查数据库注意边界Codex 可以帮你写查询语句、解释执行计划、改写返回包装但它不负责连上你的库去跑。正确的桥是这样——让它生成诊断用的 SQL你在本地或 SQL*Plus 里执行把报错原文或结果集贴回对话它再据此调整。-- 由 Codex 生成读者在本地或 SQL*Plus 中执行后再把结果贴回 select tool_name, status, last_error, created_at from ai_tool_call_log where created_at sysdate - 1 order by created_at desc;值得强调这条边界不是保守是省时间。工具返回值类型错了你把库连上去也看不出来只会多绕一圈。真正需要的是把本地执行结果当成新的上下文喂回去让它在信息完整的情况下改代码。4. 返回值改完之后才会冒出来的两个连带问题4.1 Spring Boot 侧取 content 的空指针与 isError 判断把 Server 端返回体修对了Java 客户端侧还有一次可以踩。很多示例代码直接写result.content().get(0).text()前提是 content 一定非空且第一个元素一定是文本。真实环境里这两个前提随时可能破工具返回空列表、返回的是图片等非文本内容、或者调用本身 isError 为 true。更稳的取法是把判断摊开McpSchema.CallToolResult result mcpClient.callTool(get_current_time, Map.of()); if (result.isError()) { throw new IllegalStateException(tool call failed); } String text result.content().stream() .filter(c - c instanceof McpSchema.TextContent) .map(c - ((McpSchema.TextContent) c).text()) .findFirst() .orElseThrow(() - new IllegalStateException(empty content));先判 error 再判内容类型最后兜一个明确的异常信息。这样下次再出问题时日志会直接告诉你「是调用失败」还是「返回体里没文本」不用再像第一次那样从头猜起。具体方法签名以你本地 SDK 版本为准思路不变。4.2 stdio 与 SSE 的选择以及 mcp 1.5.0 之后的 API 变化传输层是另一处容易把返回值问题搅浑的地方。本地进程间通信走 stdio跨网络走 SSE两条路径的调试手段完全不同stdio 下你能直接看子进程的标准输出SSE 下要抓的是网络请求。Java SDK 的 SSE 支持成熟度不如 stdio如果是第一次把链路跑通先用 stdio 把返回值类型对齐更省事。版本差异同样值得记一笔。Python 的mcp包在 1.5.0 之后有一次比较明显的 API 调整网上大量教程停留在旧写法你照抄一份「返回字符串即可」的示例很可能抄的就是那个时代的产物。安装时直接锁新版本遇到回调签名对不上先查版本再改代码pip install mcp1.6.05. 修完之后怎么验客户端跑一遍再回控制台对账5.1 客户端侧三个检查点验证不用搞复杂跑一遍调用看三件事就够。第一工具列表能列出来说明 Server 注册没坏第二调用返回的报文里 content 是数组、元素带 type 和 text第三客户端不吞结果模型回复里确实引用到了返回值。三个都过这次改动才算收口。如果你是把工具接在带有 MCP 能力的编码工具里用最好再让它描述一次返回值结构看它复述的和实际报文是否一致。模型对结构的复述准确说明内容被正确解析复述成套话或者含糊其辞多半还是包装层没对。5.2 看用量然后把下一步接上排障告一段落剩下的动作是回控制台对账这次调试烧了多少 Token、走的是哪个模型账目对不对得上心里要有数。Key 的管理和新建都在 控制台 API Keys想先用同一把 Key 单独发一条消息验证通道可以打开 TaoToken 模型对话如果要长期写代码、每天都要让模型读一堆文件去 Coding Plan 看看套餐够不够用Codex 这类命令行工具的接入细节环境变量和配置文件字段的对照说明在 接入文档。回到这次的坑本身Java 与 Python 在 MCP 上的差异很多时候不是能力差异而是「谁替你做了包装」。Python 用装饰器把复杂度藏起来写起来顺手Java 把每一步都摊在类型系统里第一次配要花更多时间但一旦配对返回值结构不会莫名其妙变形状。把这个差异记在排障清单里下次换个工具、换个语言你会先去看契约而不是先去看日志。