Skip to content

Commit 078af46

Browse files
committed
Route unrecognised protocol-version headers to the modern entry; swap header-mismatch ahead of version-supported
The conformance harness sends header=v999.0.0 (matching the body) to trigger UnsupportedProtocolVersionError, and header=2026 with body=v999 to trigger HeaderMismatch. With the manager only routing on header in MODERN_PROTOCOL_VERSIONS, the first case fell through to the legacy stateful path and never reached the classifier; with the classifier checking version-supported before header-match, the second case returned -32022 instead of -32020. Manager now routes any non-legacy header value to the modern entry (the classifier owns rejection of unknown versions). Classifier now checks header-body agreement before version-supported, so a client that disagrees with itself is told so rather than told its body version is unsupported. Also: 3 dead test-helper lines (registered-but-never-invoked handler bodies and a never-read property on a stub) replaced per the testing-standards convention.
1 parent 4a79c32 commit 078af46

7 files changed

Lines changed: 58 additions & 39 deletions

File tree

src/mcp/server/streamable_http_manager.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from mcp.shared._compat import resync_tracer
2929
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
3030
from mcp.shared.transport_context import TransportContext
31-
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
31+
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
3232
from mcp.types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError
3333

3434
if TYPE_CHECKING:
@@ -162,11 +162,14 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
162162
if self._task_group is None:
163163
raise RuntimeError("Task group is not initialized. Make sure to use run().")
164164

165-
# TODO(L08): header-only routing for now; body-primary classification
166-
# is a follow-up. 2025 paths below remain unchanged.
165+
# TODO(L49): header-only era-routing for now; body-primary classification
166+
# is a follow-up. The legacy paths below own only the known
167+
# initialize-handshake versions; anything else (including unknown
168+
# values) goes to the modern entry so the classifier can validate it
169+
# and return a structured rejection. 2025 paths below remain unchanged.
167170
header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii")
168171
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None)
169-
if pv in MODERN_PROTOCOL_VERSIONS:
172+
if pv is not None and pv not in SUPPORTED_PROTOCOL_VERSIONS:
170173
await handle_modern_request(self.app, self.security_settings, self._lifespan_state, scope, receive, send)
171174
return
172175

src/mcp/shared/inbound.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,14 @@ def classify_inbound_request(
9393
1. ``params._meta`` is a mapping carrying every reserved envelope key
9494
(protocol version, client info, client capabilities) → else
9595
:data:`~mcp.types.jsonrpc.INVALID_PARAMS`.
96-
2. The envelope's protocol version is in ``supported_modern_versions`` →
96+
2. When ``headers`` is given, its ``MCP-Protocol-Version`` entry equals
97+
the envelope's protocol version → else
98+
:data:`~mcp.types.jsonrpc.HEADER_MISMATCH`. Runs before the
99+
supported-version rung so a client that disagrees with itself is told
100+
so, rather than told the body's version is unsupported.
101+
3. The envelope's protocol version is in ``supported_modern_versions`` →
97102
else :data:`~mcp.types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with
98103
``data = {"supported": [...], "requested": <value>}``.
99-
3. When ``headers`` is given, its ``MCP-Protocol-Version`` entry equals
100-
the envelope's protocol version → else
101-
:data:`~mcp.types.jsonrpc.HEADER_MISMATCH`.
102104
103105
Method existence is *not* a rung: kernel dispatch owns that decision so
104106
custom-registered methods route and the answer lives in one place.
@@ -123,19 +125,19 @@ def classify_inbound_request(
123125
"client-capabilities envelope keys",
124126
)
125127

128+
if headers is not None and headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version:
129+
return InboundLadderRejection(
130+
code=HEADER_MISMATCH,
131+
message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version",
132+
)
133+
126134
if protocol_version not in supported_modern_versions:
127135
return InboundLadderRejection(
128136
code=UNSUPPORTED_PROTOCOL_VERSION,
129137
message="Unsupported protocol version",
130138
data={"supported": list(supported_modern_versions), "requested": protocol_version},
131139
)
132140

133-
if headers is not None and headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version:
134-
return InboundLadderRejection(
135-
code=HEADER_MISMATCH,
136-
message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version",
137-
)
138-
139141
return InboundModernRoute(
140142
protocol_version=protocol_version,
141143
client_info=client_info,

tests/interaction/transports/test_hosting_http.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@
1414

1515
from mcp.server import Server, ServerRequestContext
1616
from mcp.server.transport_security import TransportSecuritySettings
17+
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
1718
from mcp.types import (
19+
CLIENT_CAPABILITIES_META_KEY,
20+
CLIENT_INFO_META_KEY,
1821
INVALID_PARAMS,
1922
PARSE_ERROR,
23+
PROTOCOL_VERSION_META_KEY,
24+
UNSUPPORTED_PROTOCOL_VERSION,
2025
CallToolRequestParams,
2126
CallToolResult,
2227
EmptyResult,
@@ -155,7 +160,12 @@ async def test_malformed_and_batched_bodies_return_400() -> None:
155160
@requirement("hosting:http:protocol-version-400")
156161
@requirement("hosting:http:protocol-version-default")
157162
async def test_protocol_version_header_is_validated() -> None:
158-
"""An unsupported MCP-Protocol-Version header returns 400; an absent header is accepted as the default."""
163+
"""An unsupported MCP-Protocol-Version header returns 400; an absent header is accepted as the default.
164+
165+
An unrecognised header value routes to the modern entry (which owns rejection of unknown
166+
versions), and a request without the per-request envelope is rejected at the first ladder
167+
rung. Only known initialize-handshake versions and an absent header reach the legacy path.
168+
"""
159169
async with mounted_app(_server()) as (http, _):
160170
session_id = await initialize_via_http(http)
161171

@@ -172,9 +182,7 @@ async def test_protocol_version_header_is_validated() -> None:
172182
)
173183

174184
assert bad.status_code == 400
175-
assert JSONRPCError.model_validate_json(bad.text).error.message.startswith(
176-
"Bad Request: Unsupported protocol version: 1991-01-01."
177-
)
185+
assert JSONRPCError.model_validate_json(bad.text).error.code == INVALID_PARAMS
178186
# 202 proves the request was accepted under the assumed default version (2025-03-26).
179187
assert defaulted.status_code == 202
180188

@@ -185,18 +193,27 @@ async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_
185193
186194
SDK-defined: other SDKs detect this rejection by substring-matching ``Unsupported protocol
187195
version`` in the response body, so the literal must survive any rewording of the surrounding
188-
message. Asserted at the wire because the SDK client never surfaces the rejection body.
196+
message. The unsupported value must appear in both the header and the envelope so the
197+
classifier reaches its version-supported rung rather than reporting a header mismatch first.
189198
"""
199+
bad = "1991-01-01"
200+
meta = {
201+
PROTOCOL_VERSION_META_KEY: bad,
202+
CLIENT_INFO_META_KEY: {"name": "t", "version": "0"},
203+
CLIENT_CAPABILITIES_META_KEY: {},
204+
}
190205
async with mounted_app(_server()) as (http, _):
191-
session_id = await initialize_via_http(http)
192206
response = await http.post(
193207
"/mcp",
194-
json={"jsonrpc": "2.0", "id": 2, "method": "ping"},
195-
headers=base_headers(session_id=session_id) | {"mcp-protocol-version": "1991-01-01"},
208+
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": meta}},
209+
headers=base_headers() | {"mcp-protocol-version": bad},
196210
)
197211

198212
assert response.status_code == 400
213+
error = JSONRPCError.model_validate_json(response.text).error
214+
assert error.code == UNSUPPORTED_PROTOCOL_VERSION
199215
assert "Unsupported protocol version" in response.text
216+
assert error.data == {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": bad}
200217

201218

202219
@requirement("hosting:http:json-response-mode")

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,12 @@ async def test_modern_initialize_is_method_not_found() -> None:
157157

158158

159159
@requirement("hosting:http:modern:legacy-fallthrough")
160-
async def test_non_modern_version_header_falls_through_to_legacy_transport_unchanged() -> None:
161-
"""The 2026-07-28 routing branch fires only on its exact header; everything else reaches legacy.
162-
163-
SDK-defined under the draft versioning rules: the modern entry must not change any 2025-era
164-
byte. A 2025-era initialize on the same endpoint still completes (legacy serves it), and an
165-
unrecognised ``MCP-Protocol-Version`` still falls through to the legacy gate and produces the
166-
``Unsupported protocol version`` literal that peer SDKs substring-sniff. Asserted at the wire
167-
because the literal is only observable in the raw response body.
160+
async def test_legacy_version_header_falls_through_and_unrecognised_header_routes_to_modern() -> None:
161+
"""SDK-defined under the draft versioning rules: only the known initialize-handshake protocol
162+
versions reach the legacy transport, so a 2025-era ``initialize`` on the same endpoint still
163+
completes unchanged. Any other ``MCP-Protocol-Version`` value routes to the modern entry,
164+
where the validation ladder rejects it (a request without the per-request envelope fails the
165+
first rung). The modern entry is therefore the single owner of unknown-version rejection.
168166
"""
169167
async with mounted_app(_server()) as (http, _):
170168
# 2025-era initialize through the same endpoint: the modern branch must not intercept it.
@@ -176,7 +174,7 @@ async def test_non_modern_version_header_falls_through_to_legacy_transport_uncha
176174
)
177175

178176
assert unrecognised.status_code == 400
179-
assert "Unsupported protocol version" in unrecognised.text
177+
assert JSONRPCError.model_validate_json(unrecognised.text).error.code == INVALID_PARAMS
180178

181179

182180
@requirement("hosting:http:modern:handler-exception-internal-error")

tests/server/lowlevel/test_server_discover.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,12 @@ async def test_capabilities_derived_from_registered_handlers() -> None:
9898
async def list_tools(
9999
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
100100
) -> types.ListToolsResult:
101-
return types.ListToolsResult(tools=[])
101+
raise NotImplementedError
102102

103103
async def list_prompts(
104104
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
105105
) -> types.ListPromptsResult:
106-
return types.ListPromptsResult(prompts=[])
106+
raise NotImplementedError
107107

108108
server = Server("cap-server", on_list_tools=list_tools)
109109

tests/server/test_runner.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,10 +1304,7 @@ class _StubDispatchContext:
13041304
transport: TransportContext = field(default_factory=lambda: TransportContext(kind="direct", can_send_request=False))
13051305
message_metadata: MessageMetadata = None
13061306
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
1307-
1308-
@property
1309-
def can_send_request(self) -> bool:
1310-
return self.transport.can_send_request
1307+
can_send_request: bool = False
13111308

13121309
async def send_raw_request(
13131310
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None

tests/shared/test_inbound.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,12 @@ def test_classifier_passes_unknown_method_through_to_route(method: str) -> None:
172172

173173

174174
def test_ladder_first_failure_wins() -> None:
175-
"""Spec-mandated: rungs evaluate in order — version and header would both fail; the version rung fires."""
175+
"""Spec-mandated: rungs evaluate in order — header-mismatch and version-unsupported
176+
would both fail; the header rung fires first so an inconsistent client is told it
177+
disagrees with itself rather than that its body version is unsupported."""
176178
body = envelope(version=LATEST_PROTOCOL_VERSION)
177179
result = classify_inbound_request(body, headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
178-
assert_rejected(result, UNSUPPORTED_PROTOCOL_VERSION)
180+
assert_rejected(result, HEADER_MISMATCH)
179181

180182

181183
# --- ERROR_CODE_HTTP_STATUS ----------------------------------------------------

0 commit comments

Comments
 (0)