Skip to content

Commit 5aabab5

Browse files
committed
Share the unsupported-version rejection between request and notification arms
The notification arm hand-built the -32022 error (message text plus the supported/requested payload) that the request ladder's last rung already produces, so the two could drift. Lift that rung into `unsupported_protocol_version_rejection()` in `mcp.shared.inbound`, use it from both, and let `_write_rejection` take a null id so the notification arm writes through the same path as every other rejection. Also note in the low-level server and middleware docs that on the 2026-07-28 streamable-HTTP path a client notification POST is acknowledged 202 at the transport and not dispatched, so notification handlers and middleware do not see it there.
1 parent aabd0cb commit 5aabab5

5 files changed

Lines changed: 51 additions & 30 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The constructor covers the methods MCP defines. `add_request_handler` covers eve
162162
--8<-- "docs_src/lowlevel/tutorial006.py"
163163
```
164164

165-
* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
165+
* The first argument is the method string. Notifications have a twin, `add_notification_handler`. Its handlers fire on stdio and on handshake-era HTTP connections; on the `2026-07-28` streamable-HTTP path a client's notification POST is acknowledged `202` and not dispatched, because that revision defines no client-to-server notifications over HTTP.
166166
* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's.
167167
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.
168168

docs/advanced/middleware.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ That is the point. Middleware wraps **every** inbound message:
4848

4949
* The connection setup: `server/discover`, or `initialize` and `notifications/initialized`
5050
on a legacy session.
51-
* Every request and every notification. For a notification, `ctx.request_id is None`,
52-
`call_next(ctx)` returns `None`, and whatever you return is discarded.
51+
* Every request and every notification that reaches the server. For a notification,
52+
`ctx.request_id is None`, `call_next(ctx)` returns `None`, and whatever you return is discarded.
53+
(On the `2026-07-28` streamable-HTTP path a client's notification POST is acknowledged `202` at
54+
the transport and never dispatched, so it does not reach middleware either; that revision
55+
defines no client-to-server notifications over HTTP.)
5356
* Even a method the server has no handler for: `call_next` raises the
5457
`MCPError(-32601, "Method not found")` *through* your middleware on its way to the client.
5558

@@ -105,8 +108,8 @@ don't think about it. It is a no-op until you install an exporter, and it has it
105108

106109
* A middleware is `async (ctx, call_next) -> result`, passed as `MCPServer(middleware=[...])` (or
107110
appended to `mcp.middleware`), and appended to `server.middleware` on the low-level `Server`.
108-
* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications,
109-
unknown methods) and runs outermost-first.
111+
* It wraps **every** inbound message that reaches the server (`server/discover`, `initialize`,
112+
requests, notifications, unknown methods) and runs outermost-first.
110113
* `ctx.request_id is None` is how you tell a notification from a request.
111114
* Raise instead of calling `call_next` to refuse one message; the connection survives.
112115
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See

src/mcp/server/_streamable_http_modern.py

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,15 @@
3636
INVALID_REQUEST,
3737
PARSE_ERROR,
3838
PROTOCOL_VERSION_META_KEY,
39-
UNSUPPORTED_PROTOCOL_VERSION,
4039
ErrorData,
4140
JSONRPCError,
4241
JSONRPCNotification,
4342
JSONRPCRequest,
4443
JSONRPCResponse,
4544
ProgressToken,
4645
RequestId,
47-
UnsupportedProtocolVersionErrorData,
4846
)
4947
from mcp_types import methods as _methods
50-
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
5148
from pydantic import ValidationError
5249
from starlette.requests import Request
5350
from starlette.responses import Response
@@ -67,6 +64,7 @@
6764
InboundModernRoute,
6865
classify_inbound_request,
6966
find_duplicated_routing_header,
67+
unsupported_protocol_version_rejection,
7068
validate_mcp_param_headers,
7169
)
7270
from mcp.shared.jsonrpc_dispatcher import progress_token_from_params
@@ -169,7 +167,7 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt
169167

170168
async def _write_rejection(
171169
rejection: InboundLadderRejection,
172-
request_id: RequestId,
170+
request_id: RequestId | None,
173171
scope: Scope,
174172
receive: Receive,
175173
send: Send,
@@ -250,19 +248,8 @@ async def _acknowledge_notification(
250248
await _write(_INVALID_BODY, scope, receive, send)
251249
return
252250
requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "")
253-
if requested not in MODERN_PROTOCOL_VERSIONS:
254-
rej = JSONRPCError(
255-
jsonrpc="2.0",
256-
id=None,
257-
error=ErrorData(
258-
code=UNSUPPORTED_PROTOCOL_VERSION,
259-
message="Unsupported protocol version",
260-
data=UnsupportedProtocolVersionErrorData(
261-
supported=list(MODERN_PROTOCOL_VERSIONS), requested=requested
262-
).model_dump(mode="json"),
263-
),
264-
)
265-
await _write(rej, scope, receive, send)
251+
if (unsupported := unsupported_protocol_version_rejection(requested)) is not None:
252+
await _write_rejection(unsupported, None, scope, receive, send)
266253
return
267254
logger.debug("acknowledged and dropped client notification %s at %s", notification.method, requested)
268255
await Response(status_code=202)(scope, receive, send)

src/mcp/shared/inbound.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"find_duplicated_routing_header",
5353
"find_invalid_x_mcp_header",
5454
"mcp_param_headers",
55+
"unsupported_protocol_version_rejection",
5556
"validate_mcp_param_headers",
5657
"x_mcp_header_map",
5758
]
@@ -367,6 +368,25 @@ def find_duplicated_routing_header(headers: Iterable[tuple[str, str]]) -> str |
367368
return None
368369

369370

371+
def unsupported_protocol_version_rejection(
372+
requested: str, supported_modern_versions: Sequence[str] = MODERN_PROTOCOL_VERSIONS
373+
) -> InboundLadderRejection | None:
374+
"""The `UNSUPPORTED_PROTOCOL_VERSION` rejection for `requested`, or `None` if it is served.
375+
376+
The request ladder's last rung, shared with the transport's notification arm
377+
so both message kinds name the same `supported` list in the same words.
378+
"""
379+
if requested in supported_modern_versions:
380+
return None
381+
return InboundLadderRejection(
382+
code=UNSUPPORTED_PROTOCOL_VERSION,
383+
message="Unsupported protocol version",
384+
data=UnsupportedProtocolVersionErrorData(
385+
supported=list(supported_modern_versions), requested=requested
386+
).model_dump(mode="json"),
387+
)
388+
389+
370390
def classify_inbound_request(
371391
body: Mapping[str, Any],
372392
*,
@@ -464,14 +484,8 @@ def classify_inbound_request(
464484
message="the protocol-version envelope value must be a string",
465485
)
466486

467-
if protocol_version not in supported_modern_versions:
468-
return InboundLadderRejection(
469-
code=UNSUPPORTED_PROTOCOL_VERSION,
470-
message="Unsupported protocol version",
471-
data=UnsupportedProtocolVersionErrorData(
472-
supported=list(supported_modern_versions), requested=protocol_version
473-
).model_dump(mode="json"),
474-
)
487+
if (unsupported := unsupported_protocol_version_rejection(protocol_version, supported_modern_versions)) is not None:
488+
return unsupported
475489

476490
return InboundModernRoute(
477491
protocol_version=protocol_version,

tests/shared/test_inbound.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
find_duplicated_routing_header,
4444
find_invalid_x_mcp_header,
4545
mcp_param_headers,
46+
unsupported_protocol_version_rejection,
4647
validate_mcp_param_headers,
4748
x_mcp_header_map,
4849
)
@@ -225,6 +226,22 @@ def test_version_rung_data_reflects_supplied_supported_list() -> None:
225226
assert rejection.data == {"supported": list(custom), "requested": LATEST_MODERN_VERSION}
226227

227228

229+
def test_unsupported_protocol_version_rejection_is_the_version_rung_standalone() -> None:
230+
"""SDK-defined: the standalone helper (used by the HTTP notification arm) yields `None` for a
231+
served version and otherwise the very rejection the request ladder's version rung produces."""
232+
assert unsupported_protocol_version_rejection(LATEST_MODERN_VERSION) is None
233+
assert unsupported_protocol_version_rejection("2099-01-01") == classify_inbound_request(
234+
envelope(version="2099-01-01")
235+
)
236+
assert unsupported_protocol_version_rejection(LATEST_MODERN_VERSION, (LATEST_HANDSHAKE_VERSION,)) == (
237+
InboundLadderRejection(
238+
code=UNSUPPORTED_PROTOCOL_VERSION,
239+
message="Unsupported protocol version",
240+
data={"supported": [LATEST_HANDSHAKE_VERSION], "requested": LATEST_MODERN_VERSION},
241+
)
242+
)
243+
244+
228245
# --- rung 3: header ↔ envelope agreement ---------------------------------------
229246

230247

0 commit comments

Comments
 (0)