Repository navigation
fix(core): preserve MCP status for optional -32601 responses - #12500
Conversation
…onal-method-not-found-12496
|
@qwen-code /review |
…ignore-optional-method-not-found-12496 # Conflicts: # packages/core/src/tools/mcp-client.test.ts
|
@qwen-code /review |
|
@qwen-code /triage |
Real-environment verification: PR #12500 @
|
| Arms | base 99fd765 (merge-base), head 8a0d3bf, and for comparison #12562 head 1e17509. Each is a separate worktree built with pnpm install --frozen-lockfile. Every run uses the real bundle (dist/cli.js). |
| Host | macOS (Darwin 25.6), Node 24.18.1. Each run gets its own isolated QWEN_HOME / QWEN_RUNTIME_DIR. |
| Server 1 | GitLab 19.4 MCP replica built from lib/api/mcp/base.rb (v19.4.0-ee), the #12496 reporter's server. It is tools-only, answers any other method with HTTP 404 + {"jsonrpc":"2.0","error":{"code":-32601,…}}, answers GET with 405, and has a /__control outage switch. |
| Server 2 | Fault server with both a Streamable HTTP (httpUrl) and a legacy SSE (url) endpoint. initialize / tools/list / tools/call succeed. Every other method gets a chosen status and body, set per mcpServers entry through its query string, so one headless run measures a whole matrix. |
| Model | Scripted OpenAI-compatible model (repo fake-openai-server). It makes a real MCP tool call and records which tools were declared. |
Rig scripts: pr12500/rig/
1. The #12496 case in the real TUI
- base:
/mcpshows✗ disconnectedright after startup and again 210 s later. The replica's ledger records a full re-handshake at +0.0 s, +95.4 s and +190.5 s. The health monitor keeps reconnecting because the status was poisoned, and each reconnect re-sendsprompts/listandresources/listand gets 404 again. - head:
✓ connectedat both points, with one handshake and no further requests over the 210 s. - The model's
get_mcp_server_versioncall returns19.4.0-replicain both arms. In headless-pmode base still declares and calls the tools, and the only visible symptom there is a falseWarning: MCP server(s) failed to start. The user-facing damage is the TUI status plus the reconnect loop.
2. Status matrix: 29 optional-method rows plus 8 GET-stream rows, real CLI
Section A: optional-method errors
Each row is a real SDK error raised through the real client transports:
- Streamable HTTP raises
SdkHttpError{status,text}. - Legacy SSE raises a plain
Error("Error POSTing to endpoint (HTTP nnn): …").
| Rows | base | head |
|---|---|---|
11 positive -32601 rows: HTTP 400/404/405/422/501, alternate "Unknown method" wording, SSE 400/404/405/422/501 |
all disconnect | all stay connected |
16 negative rows: 401, 403, 500, 502, 503 with a -32601 body; 404 + -32001 session-not-found; 400 + -32603; body missing "jsonrpc":"2.0"; HTML page; text quoting a -32601 payload; text/plain "Method not found" (both transports where applicable) |
all disconnect | all still disconnect |
| 2 in-band controls (HTTP 200) | connected | connected |
Head matches the PR's stated contract on 29/29 rows.
Section B: optional GET SSE stream
The PR also adds 422 and 501 to STREAMABLE_HTTP_GET_SSE_FALLBACK_STATUSES.
| GET status | base | head |
|---|---|---|
| 422, 501 | disconnects (Failed to open SSE stream) |
continues POST-only, stays connected |
| 400, 404, 405 | connected | connected |
| 403, 500, 503 | disconnects | disconnects |
Head matches on 8/8 rows.
3. Negative control mid-session: real outages still disconnect
The replica starts healthy. It then switches to (a) an nginx 502 HTML page and (b) a 502 text page that quotes a -32601 payload. The model calls a GitLab tool and then /mcp is reopened.
- Head goes from
✓ connectedto✗ disconnectedin both cases (left panel of Fig. 3 below). - Base was already showing a poisoned
✗ disconnectedbefore the outage.
4. Tests and mutation
| Check | Result |
|---|---|
| PR test file on head (macOS) | 161/161 pass |
| PR test file on base source | 16 fail: the 10-row status matrix, standalone discovery, 2 wording cases, the 401-body case, GET 422 and GET 501. Red-before confirmed. |
All 12 src/tools/mcp*.test.ts files on head (macOS) |
609/609 pass |
| 12 production mutants against the PR's test file | 9 killed, 3 survived (below) |
Killed mutants:
- drop the pooled guard (10 fail)
- drop the standalone guard (1)
- accept any status (5)
- accept a missing status (1)
- drop the
jsonrpc === '2.0'check (1) - accept any error code (1)
- substring match instead of
JSON.parse(1) - drop the transport short-circuit in
isMethodNotFound(1) - revert the GET fallback to
[400, 404](2)
Survivors:
- M5: add 503 to
LEGACY_MCP_METHOD_NOT_FOUND_STATUSES. The whole suite stays green because the negative table only has 401/403 status rows and no 5xx row. This is the same gap as R4-5 in the round-5 deferred list. The runtime behaviour is already right (h500, h502, h503 and s503 disconnect in §2); only a test would stop a regression. - M9 (drop the
^anchor) and M10 (read(HTTP nnn)from anywhere in the message). These are equivalent on every message the SDK actually emits. The SSE client always starts the message withError POSTing to endpoint (HTTP nnn):, and Streamable HTTP carries a structureddata.status. The peer body therefore has no way to supply the status, which the h404quote and s404quote rows confirm end to end. No action needed.
5. Comparison with #12562 (same issue, conflicting)
On the real-world shapes (GitLab 404 + -32601, SDK-style 400) both PRs fix the bug and keep tools usable. #12562 looks at the error code only. It ignores the HTTP status, parses from the first { to the last }, and does not require "jsonrpc":"2.0". On this rig that leads to three differences:
- 11 of the 16 negative rows stay connected under fix(core): keep MCP server connected on a -32601 onerror (#12496) #12562: 401/403/500/502/503 with a
-32601body, the body without a JSON-RPC version, and the text page quoting-32601(both transports). - In the TUI outage it stays
✓ connectedthrough the quoting 502 page while the tool call itself fails (Fig. 3, right). With a plain nginx 502 both PRs disconnect. - It does not include the GET 422/501 widening, which is not in its scope.
These divergent rows are synthetic. None of the real server implementations surveyed in my #12562 report (comment) produced them. So this is a difference in strictness, not a field bug reproduced against #12562. On this evidence #12500 is the stricter and slightly broader of the two. I did not re-measure #12562's separate mcp-retry.ts change here.
6. Merge readiness
git merge-treeagainst current mainc662f78is clean. In the merge result,mcp-client.tsandmcp-client.test.tsare byte-identical to the tested head.- CI: Test (ubuntu), Lint & Static and Integration (no-AK) are green.
- Review state: the bot's round-3
CHANGES_REQUESTEDhad its only Critical (R1-2) ruled fixed. Round 5 on8a0d3bfposted no findings, and the bot then approved8a0d3bf.reviewDecisionis nowREVIEW_REQUIRED, which means it is waiting on a human review. - The triage sandbox verification (run 36256587959) was still in progress when I posted this. This report is independent of it.
Non-blocking notes for the author
- Add a 5xx negative row, e.g.
legacyOptionalMethodTransportError(503)plus an SSE 503 row. That kills M5 (R4-5). - The PR body doesn't mention the GET-stream widening. Adding 422/501 to
STREAMABLE_HTTP_GET_SSE_FALLBACK_STATUSESis a separate behaviour change (§2-B). One line under What this PR does / Risk & Scope would help reviewers. The Tested on table can also now show macOS as tested by this run. - The standalone
connectAndDiscoverguard has no in-repo production caller. It is only reachable through core's public export; the CLI goes throughMcpClient/ the manager. It is fine as a consistency guard, and its only coverage is the unit test.
中文版
真实环境验证:PR #12500 @ 8a0d3bf
结论:端到端行为与 PR 声明的契约一致,建议合入。 在真实 CLI 中,只提供 tools 的服务器对可选的 prompts/list / resources/list 回 JSON-RPC -32601 时保持连接;我试过的所有负向对照仍然断开。下面的备注都不阻断合入。
合入背景: #12562 修的是同一个 issue(#12496),与本 PR 在 packages/core/src/tools/mcp-client.ts 上冲突,两者只能合一个。我在同一装置上把它作为第三臂实测(第 5 节)。
装置
| 三条臂 | base 99fd765(merge-base)、head 8a0d3bf,以及用于对比的 #12562 head 1e17509。各自独立 worktree,用 pnpm install --frozen-lockfile 构建;每次运行都用真实 bundle(dist/cli.js)。 |
| 主机 | macOS(Darwin 25.6),Node 24.18.1;每次运行使用独立隔离的 QWEN_HOME / QWEN_RUNTIME_DIR。 |
| 服务器 1 | 依据 lib/api/mcp/base.rb(v19.4.0-ee)源码复刻的 GitLab 19.4 MCP,即 #12496 报告者使用的服务器。只提供 tools,其它方法一律回 HTTP 404 + {"jsonrpc":"2.0","error":{"code":-32601,…}},GET 回 405,并带 /__control 故障开关。 |
| 服务器 2 | 可配置故障服务器,同时提供 Streamable HTTP(httpUrl)和 旧版 SSE(url)端点。initialize / tools/list / tools/call 正常返回;其它方法按指定状态码和响应体回复,由每个 mcpServers 条目的 query 参数决定,所以一次无头运行就能测完整张矩阵。 |
| 模型 | 脚本化的 OpenAI 兼容模型(仓库自带的 fake-openai-server),会发起真实的 MCP 工具调用,并记录声明了哪些工具。 |
装置脚本:pr12500/rig/
1. 真 TUI 复现 #12496(图 1)
- base: 启动后
/mcp显示✗ disconnected,210 秒后仍是如此。复刻服务器的账本在 +0.0、+95.4、+190.5 秒各记录到一次完整的重新握手:状态被误判后,健康检查不停重连,每次重连又重新请求prompts/list、resources/list并再次拿到 404。 - head: 两个时间点都是
✓ connected,只握手一次,210 秒内没有任何后续请求。 - 两臂的模型调用
get_mcp_server_version都返回19.4.0-replica。无头-p模式下 base 仍会声明并调用这些工具,唯一可见的症状是一条误报的Warning: MCP server(s) failed to start;真正影响用户的是 TUI 里的状态显示和重连循环。
2. 状态矩阵:29 行可选方法 + 8 行 GET 流,真实 CLI(图 2)
A 段:可选方法错误
每一行都是经由真实客户端传输层产生的真实 SDK 错误:
- Streamable HTTP 抛
SdkHttpError{status,text}; - 旧版 SSE 抛纯
Error("Error POSTing to endpoint (HTTP nnn): …")。
| 行 | base | head |
|---|---|---|
11 个正向 -32601 行:HTTP 400/404/405/422/501、“Unknown method” 措辞变体、SSE 400/404/405/422/501 |
全部断开 | 全部保持连接 |
16 个负向行:401/403/500/502/503 带 -32601 体;404 + -32001 会话不存在;400 + -32603;缺 "jsonrpc":"2.0";HTML 页;引用 -32601 载荷的文本;text/plain "Method not found"(适用处两种传输都测) |
全部断开 | 仍全部断开 |
| 2 个 in-band 对照(HTTP 200) | 连接 | 连接 |
head 在 29/29 行与 PR 声明一致。
B 段:可选的 GET SSE 流
PR 还把 422、501 加进了 STREAMABLE_HTTP_GET_SSE_FALLBACK_STATUSES。
| GET 状态 | base | head |
|---|---|---|
| 422、501 | 断开(Failed to open SSE stream) |
改为仅 POST 模式,保持连接 |
| 400、404、405 | 连接 | 连接 |
| 403、500、503 | 断开 | 断开 |
head 在 8/8 行一致。
3. 会话中途的负向对照:真实故障仍会断开(图 3)
复刻服务器先正常运行,随后切换到 (a) nginx 502 HTML 页、(b) 引用了 -32601 载荷的 502 文本页。模型调用 GitLab 工具,然后重新打开 /mcp。
- head 两种情况都从
✓ connected变为✗ disconnected(图 3 左)。 - base 在故障发生前就已经显示被误判的
✗ disconnected。
4. 测试与变异
| 检查 | 结果 |
|---|---|
| head 上跑 PR 的测试文件(macOS) | 161/161 通过 |
| 同一测试文件跑在 base 源码上 | 16 条失败:10 行状态矩阵、standalone 发现路径、2 条措辞用例、401 响应体用例、GET 422、GET 501。确认修复前为红。 |
head 上全部 12 个 src/tools/mcp*.test.ts(macOS) |
609/609 通过 |
| 12 个生产代码变异体对 PR 测试文件 | 杀死 9 个,存活 3 个(见下) |
被杀死的变异:
- 去掉连接池路径的守卫(10 条失败)
- 去掉 standalone 守卫(1)
- 接受任意状态码(5)
- 接受缺失的状态码(1)
- 去掉
jsonrpc === '2.0'检查(1) - 接受任意错误码(1)
- 用子串匹配代替
JSON.parse(1) - 去掉
isMethodNotFound里的传输层短路(1) - GET 回退集合还原为
[400, 404](2)
存活的变异:
- M5:
LEGACY_MCP_METHOD_NOT_FOUND_STATUSES加入 503。 整个测试文件仍全绿,因为负向表的状态行只有 401/403,没有任何 5xx 行。这与第 5 轮延后列表里的 R4-5 是同一个缺口。运行时行为本身是对的(第 2 节里 h500、h502、h503、s503 都会断开),只是缺一条测试来防止回退。 - M9(去掉
^锚点)和 M10(从消息任意位置读取(HTTP nnn))。 对 SDK 实际产出的所有错误消息而言,这两个变异是等价的:SSE 客户端的消息总是以Error POSTing to endpoint (HTTP nnn):开头,Streamable HTTP 则带结构化的data.status。因此对端返回的响应体无法提供状态码,h404quote、s404quote 两行的端到端结果也印证了这一点。无需处理。
5. 与 #12562 的对比(同一 issue,相互冲突)
对真实世界的形态(GitLab 404 + -32601、SDK 风格的 400),两个 PR 都修好了问题,工具也都可用。#12562 只看错误码:不看 HTTP 状态,从第一个 { 到最后一个 } 做解析,也不要求 "jsonrpc":"2.0"。在本装置上由此带来三点差异:
- 16 个负向行中有 11 行在 fix(core): keep MCP server connected on a -32601 onerror (#12496) #12562 下保持连接:带
-32601体的 401/403/500/502/503、缺 JSON-RPC 版本的响应体、引用-32601的文本页(两种传输)。 - TUI 故障场景中,遇到引用型 502 页时,工具调用本身失败了,它却仍显示
✓ connected(图 3 右);普通 nginx 502 下两者都会断开。 - 它不包含 GET 422/501 的扩展(不在其范围内)。
这些出现分歧的行都是合成的:我在 #12562 报告(评论)中调研过的真实服务器实现,没有一个会产生这类响应。所以这是严格程度上的差异,不是在 #12562 上复现出的现场缺陷。按这些证据,#12500 是两者中更严格、覆盖面也稍广的一个。#12562 另外对 mcp-retry.ts 的改动本轮没有重新实测。
6. 合入就绪情况
- 与当前 main
c662f78做git merge-tree无冲突;合并结果中的mcp-client.ts和mcp-client.test.ts与实测的 head 逐字节一致。 - CI:Test(ubuntu)、Lint & Static、Integration(no-AK)均为绿色。
- 审查状态:bot 第 3 轮的
CHANGES_REQUESTED中唯一的 Critical(R1-2)已被判定修复;第 5 轮(8a0d3bf)没有发布任何发现,随后 bot 批准了8a0d3bf。reviewDecision现在是REVIEW_REQUIRED,即等待人工评审。 - 发布本报告时,triage 沙箱验证(run 36256587959)仍在运行;本报告与它相互独立。
给作者的非阻断建议
- 补一条 5xx 负向用例,例如
legacyOptionalMethodTransportError(503),再加一条 SSE 503。这样能杀死 M5(R4-5)。 - PR 描述没有提到 GET 流回退的扩展。 把 422/501 加进
STREAMABLE_HTTP_GET_SSE_FALLBACK_STATUSES是一项独立的行为变化(第 2 节 B 段),建议在 What this PR does / Risk & Scope 里补一句,方便评审。Tested on 表也可以据本次运行把 macOS 标为已测。 - standalone
connectAndDiscover守卫在仓库内没有生产调用方。 它只能通过 core 的公共导出触达,CLI 实际走的是McpClient/ manager。作为一致性守卫没有问题,只是它唯一的覆盖来自单测。
Merging main brings QwenLM#12500, which hardened the exact tolerance this branch added for QwenLM#12496: a session now stays up only when a transport-owned HTTP status (400/404/405/422/501) carries a valid JSON-RPC 2.0 -32601 body. Both onerror handlers use that gate, so peer-controlled shapes — a bare numeric `code` on a plain Error, a statusless SSE wrapper, a 401/403 body — still retire the connection, as main's tests require. Retained from this branch, since neither weakens that gate: the shared getJsonRpcErrorCode extraction (isMethodNotFound and isTransientNetworkError now read the code by parsing the JSON-RPC body instead of regex-scanning it), and debug-level logging on the tolerance path. The loose isJsonRpcMethodNotFound detector is dropped — it is strictly broader than main's gate and would have re-admitted every shape main excludes.



What this PR does
Keeps a healthy MCP connection connected when a legacy Streamable HTTP server answers an optional
prompts/listorresources/listrequest with a valid JSON-RPC 2.0-32601body and HTTP 400, 404, 405, 422, or 501 status. This includes legacy SSE errors that embed the status in a plain error message. The same filtering is applied to pooled and standalone discovery status paths, while unrelated transport errors still mark the server disconnected.Why it's needed
The discovery layer already treats
Method not foundas an absent optional capability and continues, but the legacy Streamable HTTP transport can surface the same JSON-RPC response through the client error callback first. The status callback then marks the server disconnected even thoughtools/listsucceeded and the connection is still usable. This makes tools-only servers appear failed and removes their tools from normal use.The fix recognizes the exact JSON-RPC method-not-found response at the status boundary instead of gating requests on advertised capabilities. That preserves compatibility with servers that implement prompts or resources but under-declare those capabilities, while keeping real transport failures on the existing disconnect path.
Reviewer Test Plan
How to verify
Configure a legacy Streamable HTTP MCP server that advertises tools but omits prompts and resources. Make
prompts/listandresources/listreturn HTTP 400, 404, 405, 422, or 501 with a JSON-RPC 2.0 error body whose code is-32601; also exercise a legacy SSEErrorwhose message embeds(HTTP 400)and the JSON body but exposes no status or text fields. Confirm optional discovery returns empty results while the server remains connected and its tools remain usable. Then try HTTP 401/403, a method-not-found message with no HTTP status, a-32001session error, andECONNRESET; each must mark the server disconnected.The focused automated check is:
The regression suite covers pooled status handling, standalone discovery status handling, and the negative case where an unrelated transport error still disconnects the server.
Evidence (Before & After)
Before: the regression reproduced a false
DISCONNECTEDstate after an optional-method response. After: all 161 focusedmcp-client.test.tstests pass, including real SDK HTTP errors, legacy SSE errors, malformed bodies, forged status markers, and disconnecting controls.N/A — this is not a TUI or visual change; the evidence is the focused unit-test result and the red-before comparison above.
Tested on
Environment (optional)
Node.js 24.15.0 on Windows; focused Vitest, ESLint, and Prettier checks in
packages/core. No sandbox or external MCP service was required for the automated verification.Risk & Scope
-32601body paired with HTTP 400, 404, 405, 422, or 501, whether status comes from structured metadata or a legacy error message. Missing or unsupported status, malformed bodies, authentication failures, and unrelated transport errors still trigger disconnect.npm run preflightand an interactive runtime check with a globally installed Qwen CLI were not run. The fullnpm run buildandnpm run typecheck, the focused test file (161/161), ESLint, and Prettier passed locally.Linked Issues
Fixes #12496
中文说明
这个 PR 做了什么
当 legacy Streamable HTTP MCP server 对可选的
prompts/list或resources/list请求返回有效的 JSON-RPC 2.0-32601错误体及 HTTP 400、404、405、422 或 501 状态时,让仍然健康的 MCP 连接保持 connected。也覆盖旧版 SSE transport 把状态码嵌入普通错误消息的形式。连接池和 standalone discovery 两条状态路径都使用同一过滤逻辑;其他传输错误仍会把 server 标记为 disconnected。为什么需要
发现层已经把
Method not found当作可选能力不存在并继续执行,但 legacy Streamable HTTP transport 可能会先通过 client error callback 抛出同一个 JSON-RPC 响应。状态 callback 随后会把 server 标记为 disconnected,尽管tools/list已经成功且连接仍然可用。这会让只有 tools 的 server 被错误显示为失败,并从正常使用路径中移除其工具。修复在状态边界识别精确的 JSON-RPC method-not-found 响应,而不是根据 server 声明的 capability 阻止请求。这样保留了对“实际实现了 prompts 或 resources、但在 capability 中漏声明”的 server 的兼容性,同时让真正的传输失败继续走现有的断开路径。
评审验证计划
如何验证
配置一个声明了 tools、但省略 prompts 和 resources 的 legacy Streamable HTTP MCP server。让
prompts/list和resources/list返回 HTTP 400、404、405、422 或 501,并携带-32601JSON-RPC 2.0 错误体;同时覆盖状态码和 JSON 体只出现在普通错误消息中的 legacy SSE 形式。确认可选能力发现返回空结果,但 server 仍保持 connected 且工具仍然可用。再测试 HTTP 401/403、没有 HTTP 状态的 method-not-found 消息、-32001session 错误与ECONNRESET;这些情况都应标记为 disconnected。聚焦自动化检查命令为:
回归套件覆盖连接池状态处理、standalone discovery 状态处理,以及其他传输错误仍然断开 server 的负向场景。
证据(修复前与修复后)
修复前:回归会在可选方法响应后错误地把 server 状态变成
DISCONNECTED。修复后:mcp-client.test.ts的 161 项聚焦测试全部通过,覆盖 SDK HTTP 错误、legacy SSE 错误、错误响应体、伪造状态标记及断开连接的负向控制。不适用——这不是 TUI 或视觉改动;证据是聚焦单测结果以及上面的修复前红灯对照。
测试平台
环境(可选)
Windows 上的 Node.js 24.15.0;在
packages/core中执行了聚焦 Vitest、ESLint 和 Prettier 检查。自动化验证不需要 sandbox 或外部 MCP 服务。风险与范围
-32601错误体配合 HTTP 400、404、405、422 或 501 时抑制断开;状态码可以来自结构化元数据或 legacy 错误消息。状态码缺失或不支持、错误体格式无效、认证失败及其他传输错误仍会触发断开。npm run preflight,也未使用全局 Qwen CLI 做交互式运行时检查。本地已通过完整npm run build、npm run typecheck、聚焦测试文件(161/161)、ESLint 和 Prettier。关联 Issue
Fixes #12496