fix(mcp): read a JSON-RPC error on the modern probe as a legacy-handshake refusal

A server that does not speak the 2026-07-28 revision can answer the
`server/discover` probe in two shapes: a bare transport status, or a
well-formed JSON-RPC error. Codewhale only recognised the first. Its modern
refusal gate accepted 404/405/406/415 and treated everything else as a hard
failure, so a server replying `400` with `{"error":{"code":-32600,
"message":"Unsupported protocol version"}}` stopped the connection instead of
negotiating the legacy `initialize` handshake it was plainly offering. The
same narrow test sat on the legacy side and on the extension host's two
allow-lists, so the refusal never reached the transport either.

`mcp::wire::is_json_rpc_error_body` parses the excerpt for an `error.code` and
both the modern and legacy refusal gates now accept `400` carrying one. The
vendored builtin module's two status lists gain `400` to match, since Rust is
the authority that issues the grant.

Verified on 0e3677b50d + this change: `mcp.deepwiki.com`, which answers the
probe with exactly that 400, now connects and lists its three tools, where
before it failed with `MCP Streamable HTTP rejected ... status=400`. Linear is
unchanged (`mcp tools linear` lists 77). `mcp_http`, `mcp::tests::` and
`table_matches_the_host_build` run 300 cases, 300 pass. The two
`sse_turn_recovery` cases in the same filter selection fail with and without
this change on clean HEAD, so they are not part of this slice.

Not established: no server that returns 400 for a reason other than a probe
refusal was exercised; the excerpt bound and the parse keep a non-JSON or
error-free body on the hard-failure path.
This commit is contained in:
CodeWhale Bot committed 2026-10-29 23:04:22 -07:00
1 parent 0e3677b50d
commit 0d8b664a7a
9 files changed
+20 -9

No files matched your search

+1
View File
@@ -124,6 +124,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **MCP servers that reject the modern probe now fall back to the legacy handshake:** a server answering `server/discover` with HTTP 400 and a JSON-RPC error (for example `-32600 Unsupported protocol version`) was treated as a hard failure instead of evidence that it speaks the older handshake, so such a server could not connect at all. Codewhale now reads a parsed JSON-RPC error on the probe as a transport refusal and negotiates the legacy `initialize` handshake, which is what a server that clearly answered in JSON-RPC means. Reproduced against `mcp.deepwiki.com` (three tools after the fix); Linear is unaffected.
- **MCP servers reject requests that carried two framing headers:** Codewhale sent every Streamable HTTP request with a duplicated `Accept` and `Content-Type`, because the extension host forwarded the SDK's headers and the Rust send path added them again. A server that validates those headers answered `415`, which Codewhale read as "this endpoint is not Streamable HTTP" and answered by switching to the deprecated SSE transport; servers that refuse `GET` then returned `405` and the connection failed with only `SSE error: Non-200 status code (405)`. Linear was unreachable this way. The send path now collapses the framing headers to one value each. Verified against Linear: discovery, tool listing (77 tools) and connection succeed.
- **One context window from planning to compaction (#7133):** the window a route resolves (from provider facts, your configuration or a fallback) and where it came from now travel together. Turn planning, automatic compaction, tool-output sizing, output caps, child agents, the context inspector and the model picker all use the same window. Previously a custom model with no context metadata was sent with a 128K fallback while compaction planned against 1M. A model switch or re-sync that keeps the same model and limits keeps that window. A restored Auto session uses the fallback window until its next turn picks a route. The original Windows report has not been reproduced.
- **Session saves no longer fail silently (#7095):** if the session store has stopped, a checkpoint or reset save now reports the failure in the status line instead of being dropped with only a log line.
+1
View File
@@ -124,6 +124,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **MCP servers that reject the modern probe now fall back to the legacy handshake:** a server answering `server/discover` with HTTP 400 and a JSON-RPC error (for example `-32600 Unsupported protocol version`) was treated as a hard failure instead of evidence that it speaks the older handshake, so such a server could not connect at all. Codewhale now reads a parsed JSON-RPC error on the probe as a transport refusal and negotiates the legacy `initialize` handshake, which is what a server that clearly answered in JSON-RPC means. Reproduced against `mcp.deepwiki.com` (three tools after the fix); Linear is unaffected.
- **MCP servers reject requests that carried two framing headers:** Codewhale sent every Streamable HTTP request with a duplicated `Accept` and `Content-Type`, because the extension host forwarded the SDK's headers and the Rust send path added them again. A server that validates those headers answered `415`, which Codewhale read as "this endpoint is not Streamable HTTP" and answered by switching to the deprecated SSE transport; servers that refuse `GET` then returned `405` and the connection failed with only `SSE error: Non-200 status code (405)`. Linear was unreachable this way. The send path now collapses the framing headers to one value each. Verified against Linear: discovery, tool listing (77 tools) and connection succeed.
- **One context window from planning to compaction (#7133):** the window a route resolves (from provider facts, your configuration or a fallback) and where it came from now travel together. Turn planning, automatic compaction, tool-output sizing, output caps, child agents, the context inspector and the model picker all use the same window. Previously a custom model with no context metadata was sent with a 128K fallback while compaction planned against 1M. A model switch or re-sync that keeps the same model and limits keeps that window. A restored Auto session uses the fallback window until its next turn picks a route. The original Windows report has not been reproduced.
- **Session saves no longer fail silently (#7095):** if the session store has stopped, a checkpoint or reset save now reports the failure in the status line instead of being dropped with only a log line.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"modules": {
"harness": "ad24207f93d963dcffe1967f6863f416117d9acd67cdfccbcef59c12ccb39f92",
"mcp": "50516f554ba8786b3282c8f576a11e86df189ac7fd226176a5d1b618042246ea"
"mcp": "a01be482b32e24d50267b466b206b793e3d64b8692fb0961f9be85862a639892"
}
}
+2 -2
View File
@@ -24928,11 +24928,11 @@ var HttpBrokerTransport = class {
}
const reply = await this.rpc.request("net/fetch", { owner: this.owner, session_id: this.session, url: url2, method, headers, ...admitted ?? {} }, signal);
if (reply.legacy_handshake === true) {
if (!admitted || !("method" in admitted.frame) || admitted.frame.method !== "server/discover" || reply.response_id !== void 0 || ![404, 405, 406, 415].includes(reply.status)) throw new Error("MCP handshake refusal unavailable");
if (!admitted || !("method" in admitted.frame) || admitted.frame.method !== "server/discover" || reply.response_id !== void 0 || ![400, 404, 405, 406, 415].includes(reply.status)) throw new Error("MCP handshake refusal unavailable");
this.legacyHandshake = true;
}
if (reply.legacy_grant !== void 0) {
if (!admitted || this.legacy || this.negotiation || reply.response_id !== void 0 || ![404, 405, 406, 415, 501].includes(reply.status) || typeof reply.legacy_grant.ticket !== "string" || !reply.legacy_grant.ticket) throw new Error("MCP negotiation unavailable");
if (!admitted || this.legacy || this.negotiation || reply.response_id !== void 0 || ![400, 404, 405, 406, 415, 501].includes(reply.status) || typeof reply.legacy_grant.ticket !== "string" || !reply.legacy_grant.ticket) throw new Error("MCP negotiation unavailable");
this.negotiation = reply.legacy_grant;
throw new Error("Rust admitted legacy MCP negotiation");
}
@@ -19,7 +19,7 @@ var __export = (target, all) => {
var define_BUILTIN_MODULE_DIGESTS_default;
var init_define_BUILTIN_MODULE_DIGESTS = __esm({
"<define:__BUILTIN_MODULE_DIGESTS__>"() {
define_BUILTIN_MODULE_DIGESTS_default = { harness: "ad24207f93d963dcffe1967f6863f416117d9acd67cdfccbcef59c12ccb39f92", mcp: "50516f554ba8786b3282c8f576a11e86df189ac7fd226176a5d1b618042246ea" };
define_BUILTIN_MODULE_DIGESTS_default = { harness: "ad24207f93d963dcffe1967f6863f416117d9acd67cdfccbcef59c12ccb39f92", mcp: "a01be482b32e24d50267b466b206b793e3d64b8692fb0961f9be85862a639892" };
}
});
+2 -2
View File
@@ -243,12 +243,12 @@ class HttpBrokerTransport implements GrantedTransport {
const reply = await this.rpc.request<{ response_id?: string; status: number; headers: Record<string, string>; legacy_grant?: OperationGrant; legacy_handshake?: boolean }>('net/fetch', { owner: this.owner, session_id: this.session, url, method, headers, ...(admitted ?? {}) }, signal)
if (reply.legacy_handshake === true) {
if (!admitted || !('method' in admitted.frame) || admitted.frame.method !== 'server/discover'
|| reply.response_id !== undefined || ![404, 405, 406, 415].includes(reply.status)) throw new Error('MCP handshake refusal unavailable')
|| reply.response_id !== undefined || ![400, 404, 405, 406, 415].includes(reply.status)) throw new Error('MCP handshake refusal unavailable')
this.legacyHandshake = true
}
if (reply.legacy_grant !== undefined) {
if (!admitted || this.legacy || this.negotiation || reply.response_id !== undefined
|| ![404, 405, 406, 415, 501].includes(reply.status)
|| ![400, 404, 405, 406, 415, 501].includes(reply.status)
|| typeof reply.legacy_grant.ticket !== 'string' || !reply.legacy_grant.ticket) throw new Error('MCP negotiation unavailable')
this.negotiation = reply.legacy_grant
throw new Error('Rust admitted legacy MCP negotiation')
+4 -2
View File
@@ -543,7 +543,8 @@ async fn fetch(
.frame
.as_ref()
.is_some_and(|frame| frame["method"] == "server/discover")
&& matches!(status, 404 | 405 | 406 | 415)
&& (matches!(status, 404 | 405 | 406 | 415)
|| (status == 400 && crate::mcp::wire::is_json_rpc_error_body(&excerpt)))
{
session.legacy_allowed.store(true, Ordering::SeqCst);
guard.armed = false;
@@ -567,7 +568,8 @@ async fn fetch(
&& original
.as_ref()
.is_some_and(|operation| operation.target["method"] == "initialize")
&& is_streamable_http_incompatible_status(reqwest::StatusCode::from_u16(status)?)
&& (is_streamable_http_incompatible_status(reqwest::StatusCode::from_u16(status)?)
|| (status == 400 && crate::mcp::wire::is_json_rpc_error_body(&excerpt)))
{
let operation = original.context("transport refusal has no semantic operation")?;
let grant = renegotiate(
+1 -1
View File
@@ -152,7 +152,7 @@ pub(crate) const BUILTIN_MODULES: &[BuiltinModule] = &[
},
BuiltinModule {
id: "mcp",
source_sha256: "50516f554ba8786b3282c8f576a11e86df189ac7fd226176a5d1b618042246ea",
source_sha256: "a01be482b32e24d50267b466b206b793e3d64b8692fb0961f9be85862a639892",
tools: &[],
},
];
+7
View File
@@ -138,6 +138,13 @@ pub(crate) fn sse_field_value<'a>(line: &'a str, field: &str) -> Option<&'a str>
Some(value.strip_prefix(' ').unwrap_or(value))
}
pub(crate) fn is_json_rpc_error_body(body_excerpt: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body_excerpt.trim())
.ok()
.and_then(|value| value.get("error")?.get("code")?.as_i64())
.is_some()
}
pub(crate) fn is_streamable_http_incompatible_status(status: reqwest::StatusCode) -> bool {
matches!(
status,