MCP 调不通却不报错:HTTP 200 之后的两种静默失败

0 阅读9分钟

HTTP 200,Content-Length: 0,服务端日志一行错误没有。MCP 客户端把这次 tools/call 当成功了,用户那边看到的是模型一脸认真地回了一句“我没查到”。

还有一张更常见的脸:HTTP 200,body 有内容,但错误藏在 result.isError: true 里,error 字段是空的。按 JSON-RPC 2.0 的老习惯去判 error,同样会判成调用成功。

这两种失败我在 MCP 服务端联调时都撞过。它们不是“服务没起来”那种响亮的故障,而是请求跑完了、响应也回来了,只有结果是错的。

error 为空不等于成功,result.isError 才是工具层的终态。

先分清:HTTP 200 之后还有两层

mcp-two-silent-failures.png

图 1:HTTP 200 只覆盖传输层;协议层和工具层都可能失败(2026-11-04 实测)

200 只说明 HTTP 通了,不说明调用成功

MCP 的消息走 JSON-RPC 2.0。协议层的错误放在 error 字段:缺字段、类型错、方法不存在,返回 -32602、-32601 这类错误码。工具执行期的错误不走这里,SDK 把它包成一次正常的结果,在 result.content 里放错误文本,再标一个 isError: true。

所以同一次调用有三层状态:HTTP 通没通、JSON-RPC 结构对不对、工具干成没干成。前两层过了,第三层照样可能是失败。

协议层看 error,工具层看 result.isError

判断顺序不能反:

  1. 先看 HTTP 状态码,401/403/5xx 是传输和鉴权层的问题。
  2. 200 之后看 body 长度,0 字节先按 id 类型查。
  3. body 有内容,先看 error 字段;有就是协议层错误。
  4. error 为空,再看 result.isError;true 是工具层错误,false 才是成功。

我最初只做了第 3 步,漏了第 4 步。

面孔一:body 有内容,错误在 result.isError

参数名写错,落在工具执行期这一侧

服务端注册的工具签名是 question、topK、minScore,tools/list 返回的 inputSchema.required 也是这三个:

{
  "name": "searchKnowledgeBase",
  "inputSchema": {
    "type": "object",
    "properties": {
      "question": {"type": "string"},
      "topK": {"type": "integer"},
      "minScore": {"type": "number"}
    },
    "required": ["question", "topK", "minScore"]
  }
}

客户端如果按记忆写成 query,拿到的不是 -32602,而是一条 200 的正常响应:

{
  "id": 2,
  "result": {
    "content": [{"text": "text cannot be null or blank", "type": "text"}],
    "isError": true
  },
  "jsonrpc": "2.0"
}

error 字段不存在,错误落在 result.isError 上。它没走协议层,是因为 SDK 没在协议层拦下缺字段:arguments 被直接喂给 @Tool 方法,question 是 null,工具里的 requireQuestion() 把它变成空串,最后是底层 embedding 的入参校验抛了 “text cannot be null or blank”,SDK 捕获后包成 isError。参数名写错,恰好落在“工具执行期”这一侧。

MCP 规范就是这么分的:协议层只管消息结构,工具内部发生什么,一律以结果形式返回。知道了设计意图,反而不会再跟 error 字段较劲。

参数名的唯一权威是 JSON Schema 的 required

修复分两边:

  • 客户端按 tools/list 返回的 inputSchema.required 拼参数,不按 README、不按记忆、不按模型猜。
  • 服务端在 @Tool 方法入口做非空校验,错误文本带字段名;KnowledgeBaseMcpTools.java:196 的 requireQuestion() 现在返回空串,下一步应该改成抛带字段名的业务异常,别让底层 embedding 的报错替它背锅。

边界就在 isError 这个字段上:它只表示工具层失败,不区分参数错、业务拒绝、内部异常。要区分,得在 content.text 里放结构化错误码,别让客户端靠匹配字符串。

面孔二:body 是空的,HTTP 200 也不报错

mcp-string-id-sequence.png

图 2:字符串 id 在 SDK 的 extractId() 处被丢掉,响应不生成(McpHttpController → McpJsonRpcDispatcher → McpServer.handle)

SDK 的 extractId 只认数字,规范却允许字符串

把上面的请求改成字符串 id:

curl -s -i -X POST http://localhost:8090/mcp \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer [redacted]" \
  -d '{"jsonrpc":"2.0","id":"2","method":"tools/call",
       "params":{"name":"searchKnowledgeBase",
                 "arguments":{"question":"七天无理由退货的条件是什么?",
                              "topK":3,"minScore":0.5}}}'

返回 HTTP/1.1 200,Content-Length: 0,body 是空的。

翻官方 SDK 源码,McpServer.handle() 里的 extractId() 只认 idNode.isNumber();字符串直接返回 null,后续流程判断 id 为空,不生成响应。JSON-RPC 2.0 规范里 id 允许 String、Number 或 NULL,客户端发字符串本身是合规的。不宽容的是 SDK 的实现,而窄的那一侧还静默。

同一个端点,一半宽容一半严格

更拧巴的是,同一个服务里有两套 id 口径。

自实现的 Resources / Prompts 分支用 Object 透传 JsonNode(McpHttpController.java:77、McpJsonRpcDispatcher.java:65),字符串 id 能正常返回;只有 Tools 分支交给官方 SDK,只认数字。同一个 /mcp 端点,一半宽容、一半严格,这种不一致比全面严格更难排查。

我当时盯着网关日志、连接池、超时配置查了半天,一度怀疑是 Spring 的某个 filter 把响应吞了。真凶就是 id 类型不匹配。HTTP 层 200,响应体 0 字节,没有 4xx,没有异常,没有日志。

空 body 先换数字 id,再查网关

排查动作只有一步:把 id 从字符串换成数字,重试。

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"searchKnowledgeBase","arguments":{"question":"七天无理由退货的条件是什么?","topK":3,"minScore":0.5}}}

换完能通,就别再动网关配置。不能通,再按 401/403/5xx 往下查。反着来,就是从网关一路查到 filter。

客户端一启动就断开

mcp-stdio-pollution.png

图 3:stdout 只能走 JSON-RPC;任何一行日志都会让客户端解析失败(McpServerStarter.java:138 的重定向)

stdout 是协议信道,日志必须走 stderr

stdio 模式把 Java 进程当 Claude Desktop 的子进程,协议消息走 stdin/stdout。stdout 是协议信道,不是日志信道。StdioMcpServerTransport 把 JSON-RPC 写到 System.out,任何默认日志输出到 stdout 都会污染协议流,客户端解析失败立即断开。

我在这个点上断过三次:第一次是 Logback 默认 appender;第二次以为关了 Logback 就完了,但 Spring Boot 的启动 banner 和 Bean 装配日志还在往 stdout 写;第三次最冤,换了个依赖,stdout 多出一行 Standard Commons Logging discovery in action with spring-jcl...,凶手是 pdfbox 传递依赖带进来的 commons-logging。

// McpServerStarter.java:138
private static void redirectLogbackToStderr() {
    LoggerContext loggerContext =
            (LoggerContext) LoggerFactory.getILoggerFactory();
    loggerContext.reset();               // 清掉默认 stdout appender
    ConsoleAppender<ILoggingEvent> appender = new ConsoleAppender<>();
    appender.setContext(loggerContext);
    appender.setTarget("System.err");    // 关键:日志改走 stderr
    appender.start();
    loggerContext.getLogger(Logger.ROOT_LOGGER_NAME)
            .addAppender(appender);
}

判断方法:java -jar ... 2>/dev/null 跑一次,看 stdout 是不是纯 JSON。有一行不是 JSON 的,就是它。

子进程不会自动读 application.yml,fat jar 也只能用 java -jar

用 AnnotationConfigApplicationContext 启动时,子进程不会像 SpringApplication 那样自动读 application.yml,@Value 全部落到注解默认值。我的现象是 reranker 配置变成 dashscope 却没有 key,启动即失败。启动器里得手动用 YamlPropertySourceLoader 把配置装进 Environment(McpServerStarter.java:173 的 loadClasspathYaml())。

启动命令也有一个硬约束:mvn package 出来的是 Spring Boot fat jar,类在 BOOT-INF/classes/、依赖在 BOOT-INF/lib/,-cp 根本看不见它们,写成 -cp xxx.jar com.example.assistant.mcp.McpServerStarter 直接 ClassNotFoundException。要用 -Pmcp profile 单独打一份可执行 jar,再用 java -jar 启动。

Resources 和 Prompts 得自己补

PDF 走 blob,文本走 text

官方 SDK 只覆盖 Tools,Resources / Prompts 得自己补。补的时候最容易错的是二进制资源:PDF 走 blob 字段(base64),文本走 text 字段。这是 MCP 规范的二分,混用客户端拿到的就是 base64 乱码。

// McpResourcesProvider.java:117
content.put("blob", Base64.getEncoder().encodeToString(entry.bytes));

实测 resources/list 返回 4 个 classpath 文档,resources/read 读 PDF 时 mimeType 是 application/pdf,blob 是 base64;读 txt 时走 text。

缺值的 {{var}} 必须替换成空串

Prompts 模板的 {{var}} 替换,验证点是“响应里没有残留占位符”。不替换的话,{{language}} 会原样进 LLM,被当成指令的一部分。

// McpPromptsProvider.java:173:缺值替换为空串,不保留占位符
private static String renderTemplate(String template, Map<String, Object> args) {
    // 用正则匹配 {{var}},逐项替换;args 里没有的键替换成 ""
}

实测 3 个模板:qa-policy-check、review-code、summarize-doc。prompts/get 传入 question 和 context,返回的 user message 里两个占位符都已替换成实参。

安全配置也会静默失守

空 key 死在启动,默认占位 key 只 warning

McpSecurityService.java:56 的 @PostConstruct 会校验 API Key:null 或短于 16 字符直接抛异常,进程起不来。这条比运行时拒绝更稳,运维事故没法“带病上线”。

但默认占位 key dev-mcp-key-replace-in-production 有 33 个字符,能过长度校验,只打一条 warning。也就是说,默认配置能带着一个公开的 key 上线,鉴权形同虚设。这块是欠账,别把“启动期校验”当成“默认值安全”,生产环境必须显式覆盖 chat.mcp.api-key。

工具白名单只能有一个真相源

白名单两个真相源:注册处一份、白名单一份,加新工具忘了同步,要么新工具被拦,要么旧工具裸奔。把名单收成单一常量 KnowledgeBaseMcpTools.TOOL_NAMES(KnowledgeBaseMcpTools.java:56),McpSecurityService 直接引用它(McpSecurityService.java:49),两处就不会漂。

白名单外的工具直接 403:

{
  "id": 8,
  "error": {"code": -32601, "message": "Tool not allowed: rm_rf (allowed: 3 tools)"},
  "jsonrpc": "2.0"
}

Bearer 的 scheme 比较必须忽略大小写

HTTP 规范里 scheme 大小写不敏感,小写的 bearer xxx 也该通过。McpSecurityService.java:81 用 equalsIgnoreCase("Bearer ") 比较前 7 个字符,再用常量时间比较校验 key,避免时序攻击。

审计管道也要过 redact() 正则(password / token / apiKey 一族,McpAuditService.java:91),用户问“密码是 123456”不能原样落库。

我现在的排查顺序

mcp-triage-tree.png

图 4:先看 body,再看 error 落在哪一层,最后才查网关

  1. body 长度 0:先换数字 id 重试。还空,再查 401/403/5xx。
  2. body 有内容,error 非空:协议层错误,查请求结构、方法名、参数类型。
  3. error 为空:看 result.isError。true 查参数名和 schema.required;false 才是成功,再去查业务结果。

MCP 出问题时,状态码是最不能信的信号。

附录:按层速查

同一条链路上顺手记下的,按层排,下次直接对号。

层症状处置
传输stdout 多一行日志,客户端秒断日志重定向 stderr;java -jar ... 2>/dev/null 验 stdout
传输子进程 @Value 全落默认值手动 loadClasspathYaml();fat jar 用 java -jar
原语PDF 当文本读,客户端拿 base64 乱码二进制走 blob,文本走 text
原语{{var}} 原样进 LLMrenderTemplate() 缺值换空串
安全默认占位 API Key 能上线生产显式覆盖 chat.mcp.api-key
安全工具白名单两个真相源TOOL_NAMES 单一来源
安全审计日志把“密码是 123456”原样落库redact() 过滤 password / token / apiKey
安全bearer xxx 鉴权失败equalsIgnoreCase("Bearer ")

想自己跑一遍

端到端验证在 ch19 §19.9:stdio 3 原语 + Streamable HTTP 9 步端点当日实测,resources 4 个、prompts 3 个模板、403 白名单、审计全链路。想动手的话,labs/lab4-meeting-mcp 是带写操作的 MCP 服务端场景,零 LLM 成本。

规范见 modelcontextprotocol.io/specificati… 仓库 github.com/hengdboy/la… 114 个测试不依赖 key