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 之后还有两层

图 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
判断顺序不能反:
- 先看 HTTP 状态码,401/403/5xx 是传输和鉴权层的问题。
- 200 之后看 body 长度,0 字节先按 id 类型查。
- body 有内容,先看
error字段;有就是协议层错误。 error为空,再看result.isError;true是工具层错误,false才是成功。
我最初只做了第 3 步,漏了第 4 步。
面孔一:body 有内容,错误在 result.isError
参数名写错,落在工具执行期这一侧
服务端注册的工具签名是 question、topK、minScore,tools/list 返回的 inputSchema.required 也是这三个:
json
{
"name": "searchKnowledgeBase",
"inputSchema": {
"type": "object",
"properties": {
"question": {"type": "string"},
"topK": {"type": "integer"},
"minScore": {"type": "number"}
},
"required": ["question", "topK", "minScore"]
}
}
客户端如果按记忆写成 query,拿到的不是 -32602,而是一条 200 的正常响应:
json
{
"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 也不报错

图 2:字符串 id 在 SDK 的 extractId() 处被丢掉,响应不生成(McpHttpController → McpJsonRpcDispatcher → McpServer.handle)
SDK 的 extractId 只认数字,规范却允许字符串
把上面的请求改成字符串 id:
bash
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 从字符串换成数字,重试。
json
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"searchKnowledgeBase","arguments":{"question":"七天无理由退货的条件是什么?","topK":3,"minScore":0.5}}}
换完能通,就别再动网关配置。不能通,再按 401/403/5xx 往下查。反着来,就是从网关一路查到 filter。
客户端一启动就断开

图 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。
java
// 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 乱码。
java
// 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,被当成指令的一部分。
java
// 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:
json
{
"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"不能原样落库。
我现在的排查顺序

图 4:先看 body,再看 error 落在哪一层,最后才查网关
- body 长度 0:先换数字 id 重试。还空,再查 401/403/5xx。
- body 有内容,
error非空:协议层错误,查请求结构、方法名、参数类型。 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}} 原样进 LLM |
renderTemplate() 缺值换空串 |
| 安全 | 默认占位 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