From aabd0cb45d9a7f5fc1699a2c0695b6f03dca12d6 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:16:26 +0000 Subject: [PATCH 1/2] Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modern streamable-HTTP entry validated every POST body as a JSON-RPC request, so any notification (no `id`) was answered 400 with -32600 "Body must be a single JSON-RPC request object". The transport spec lets a server either accept (202) or refuse a notification POST; we took the refuse branch on the grounds that 2026-07-28 defines no client-to-server notifications over HTTP. Clients in the field send them anyway (a courtesy `notifications/cancelled`, a listen teardown), the handshake-era leg and the other SDKs acknowledge the same POST, and notifications are fire-and-forget, so the 400s were pure noise that made 4xx useless as a failure signal for operators (#3324). An id-less single-object body is now acknowledged 202 with no body and dropped (never dispatched); a notification under an `MCP-Protocol-Version` this entry does not serve gets the same -32022 a request would. Posted responses, batches and malformed-id requests stay -32600, with the message reworded to name notifications as accepted. The split keys on presence of the `id` member (JSON-RPC 2.0 §4.1) so a request with a malformed id is still owed its error rather than silently 202'd. Github-Issue: #3324 --- src/mcp/server/_streamable_http_modern.py | 93 ++++++++++++++++--- tests/interaction/_requirements.py | 23 ++++- .../transports/test_hosting_http_modern.py | 35 +++++++ tests/server/test_streamable_http_modern.py | 91 ++++++++++++++++-- 4 files changed, 219 insertions(+), 23 deletions(-) diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 1db6b35f8a..d73b48a558 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -5,7 +5,10 @@ path for earlier protocol revisions. A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no -`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON +`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. A +notification POST is acknowledged `202` and dropped: the core protocol defines +no client-to-server notifications on this wire (cancellation is closing the +response stream), and a per-request entry has nothing for one to act on. JSON mode handles the request directly in the ASGI task. SSE mode runs the handler as a sibling task and defers committing to `text/event-stream` until the handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever @@ -33,6 +36,7 @@ INVALID_REQUEST, PARSE_ERROR, PROTOCOL_VERSION_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, ErrorData, JSONRPCError, JSONRPCNotification, @@ -40,8 +44,10 @@ JSONRPCResponse, ProgressToken, RequestId, + UnsupportedProtocolVersionErrorData, ) from mcp_types import methods as _methods +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import ValidationError from starlette.requests import Request from starlette.responses import Response @@ -56,6 +62,7 @@ from mcp.shared.inbound import ( ERROR_CODE_HTTP_STATUS, MCP_PARAM_HEADER_PREFIX, + MCP_PROTOCOL_VERSION_HEADER, InboundLadderRejection, InboundModernRoute, classify_inbound_request, @@ -196,6 +203,71 @@ async def _write( )(scope, receive, send) +_INVALID_BODY: Final = JSONRPCError( + jsonrpc="2.0", + id=None, + error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"), +) +"""Well-formed JSON that is not one request or notification: a batch, a posted response, a malformed envelope.""" + + +def _is_notification_shaped(decoded: Any) -> bool: + """Whether a decoded POST body is a single JSON object without an `id` member. + + JSON-RPC 2.0 §4.1: a notification is a request object without an "id" + member, so key presence — not which model happens to validate — picks the + arm. (The notification model ignores unknown keys; letting it catch a + request whose id is malformed would 202 a message that is owed an error.) + """ + return isinstance(decoded, dict) and "id" not in decoded + + +async def _acknowledge_notification( + decoded: dict[str, Any], + request: Request, + scope: Scope, + receive: Receive, + send: Send, +) -> None: + """Answer an id-less POST body: `202` for a notification at a served version, a rejection otherwise. + + Streamable-http §Sending Messages item 5 lets a server accept (202, no + body) or refuse (4xx) a notification POST; this entry accepts and drops. + The 2026-07-28 core protocol defines no client-to-server notifications over + HTTP (a client cancels by closing the response stream) and a per-request + entry holds no cross-request state for one to act on — honouring a posted + `notifications/cancelled` by client-chosen request id would let one + anonymous caller cancel another's work — but clients in the field still + POST them, and notifications are fire-and-forget, so they are acknowledged + as the handshake-era transport does rather than answered with an error + nobody reads. Header requirements for notification POSTs are undefined at + this revision; only the routing header that brought the POST here is + checked, so a version this entry does not serve is told so, as a request is. + """ + try: + notification = JSONRPCNotification.model_validate(decoded) + except ValidationError: + await _write(_INVALID_BODY, scope, receive, send) + return + requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "") + if requested not in MODERN_PROTOCOL_VERSIONS: + rej = JSONRPCError( + jsonrpc="2.0", + id=None, + error=ErrorData( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="Unsupported protocol version", + data=UnsupportedProtocolVersionErrorData( + supported=list(MODERN_PROTOCOL_VERSIONS), requested=requested + ).model_dump(mode="json"), + ), + ) + await _write(rej, scope, receive, send) + return + logger.debug("acknowledged and dropped client notification %s at %s", notification.method, requested) + await Response(status_code=202)(scope, receive, send) + + _MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower() _MCP_PARAM_LIST_PAGE_CAP: Final = 100 @@ -346,22 +418,15 @@ async def handle_modern_request( rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) await _write(rej, scope, receive, send) return + if _is_notification_shaped(decoded): + await _acknowledge_notification(decoded, request, scope, receive, send) + return try: req = JSONRPCRequest.model_validate(decoded) except ValidationError: - # Well-formed JSON that isn't a single request object. The transport - # spec permits notification POSTs and gives the server two responses - # (202 accept / 4xx cannot-accept; streamable-http §Sending Messages - # item 5). The core protocol defines no client→server notifications - # over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this - # entry takes the cannot-accept branch. TODO(L57): S4 owns the - # strict-vs-lenient choice. - rej = JSONRPCError( - jsonrpc="2.0", - id=None, - error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"), - ) - await _write(rej, scope, receive, send) + # A batch, a posted response (clients MUST NOT send those: streamable-http + # §Sending Messages item 4), or a request whose envelope is malformed. + await _write(_INVALID_BODY, scope, receive, send) return if req.method == "subscriptions/listen" and not has_sse: diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 964a1829d2..86725bcb4f 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3182,7 +3182,12 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", behavior="A POST containing only notifications or responses returns 202 with no body.", transports=("streamable-http",), - note="Only observable over HTTP: 202 is an HTTP status code.", + removed_in="2026-07-28", + superseded_by="hosting:http:modern:notification-post-202", + note=( + "Only observable over HTTP: 202 is an HTTP status code. At 2026-07-28 clients no longer post " + "responses (streamable-http §Sending Messages item 4), so only the notification half carries over." + ), ), "hosting:http:onerror": Requirement( source="sdk", @@ -3375,6 +3380,22 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.", ), + "hosting:http:modern:notification-post-202": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#sending-messages", + behavior=( + "A 2026-07-28 POST whose body is a single JSON-RPC notification is acknowledged 202 with no " + "body (the spec's accept branch) and is not dispatched; a posted JSON-RPC response is rejected " + "INVALID_REQUEST at HTTP 400." + ), + added_in="2026-07-28", + supersedes=("hosting:http:notifications-202",), + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: the HTTP status is the assertion. The revision defines no " + "client-to-server notifications on this transport (cancellation is closing the response stream), " + "so accept-and-drop is the SDK's choice between the two responses the spec permits." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Client transport: streamable HTTP # ═══════════════════════════════════════════════════════════════════════════ diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 9ebaa71460..e26c70f3bf 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -20,6 +20,7 @@ HEADER_MISMATCH, INTERNAL_ERROR, INVALID_PARAMS, + INVALID_REQUEST, METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, SERVER_INFO_META_KEY, @@ -27,6 +28,7 @@ CallToolResult, DiscoverResult, EmptyResult, + ErrorData, Implementation, JSONRPCError, JSONRPCResponse, @@ -153,6 +155,39 @@ async def test_modern_response_carries_no_session_id_header() -> None: assert "mcp-session-id" not in response.headers +@requirement("hosting:http:modern:notification-post-202") +@pytest.mark.parametrize("json_response", [True, False], ids=["json", "sse"]) +@pytest.mark.parametrize("stateless_http", [True, False], ids=["stateless-flag", "default"]) +async def test_modern_notification_post_is_acknowledged_202_and_a_posted_response_is_rejected( + json_response: bool, stateless_http: bool +) -> None: + """A 2026-07-28 notification POST is answered 202 with no body; a posted response is 400 INVALID_REQUEST. + + Spec-permitted (streamable-http §Sending Messages item 5): the server may accept (202) or refuse + (4xx) a notification POST, and the SDK accepts -- the same answer the legacy leg gives, so a + client's courtesy `notifications/cancelled` is not met with an error on one era only. + Spec-mandated (item 4): clients MUST NOT post responses, so one is refused. Driven through the + mounted app so the manager's header routing is in the path, under both response modes and both + values of the legacy-only `stateless_http` flag (neither is read before the modern entry answers). + """ + notification = {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}} + posted_response: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "result": {}} + async with mounted_app(_server(), json_response=json_response, stateless_http=stateless_http) as (http, _): + acknowledged = await http.post( + "/mcp", json=notification, headers=_modern_headers(method="notifications/cancelled") + ) + refused = await http.post("/mcp", json=posted_response, headers=_modern_headers(method="tools/list")) + + assert (acknowledged.status_code, acknowledged.content) == (202, b"") + assert "mcp-session-id" not in acknowledged.headers + assert refused.status_code == 400 + assert JSONRPCError.model_validate(refused.json()) == JSONRPCError( + jsonrpc="2.0", + id=None, + error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"), + ) + + @requirement("hosting:http:modern:initialize-removed") async def test_modern_initialize_is_method_not_found() -> None: """A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404. diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index cbf826d3f7..11e775840f 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -25,6 +25,7 @@ PARSE_ERROR, PROTOCOL_VERSION_META_KEY, SERVER_INFO_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, CallToolRequestParams, CallToolResult, ClientCapabilities, @@ -34,10 +35,11 @@ ListToolsResult, LoggingMessageNotification, LoggingMessageNotificationParams, + NotificationParams, PaginatedRequestParams, Tool, ) -from mcp_types.version import LATEST_MODERN_VERSION +from mcp_types.version import LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS from starlette.types import Message, Receive, Scope, Send from trio.testing import MockClock @@ -110,18 +112,91 @@ async def test_handle_modern_request_rejects_non_post_with_http_405_and_allow_he assert response.content == b"" -async def test_handle_modern_request_rejects_a_notification_body_with_invalid_request() -> None: - """SDK-defined: well-formed JSON that isn't a single JSON-RPC request object (e.g. a - notification, which lacks ``id``) is ``INVALID_REQUEST`` — distinct from ``PARSE_ERROR``, - which is for malformed JSON.""" +@pytest.mark.parametrize( + "body", + [ + pytest.param( + {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}, id="cancelled" + ), + pytest.param({"jsonrpc": "2.0", "method": "notifications/roots/list_changed"}, id="removed-at-2026"), + pytest.param({"jsonrpc": "2.0", "method": "acme/heartbeat", "params": {"n": 1}}, id="custom"), + pytest.param( + { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": "listen:0", "_meta": {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION}}, + }, + id="with-envelope", + ), + ], +) +async def test_handle_modern_request_acknowledges_a_notification_post_with_202_and_drops_it( + body: dict[str, Any], +) -> None: + """Spec-permitted (streamable-http §Sending Messages item 5, the accept branch): a POST whose + body is one JSON-RPC notification is answered 202 with no body, whatever its method and + whether or not it carries a `_meta` envelope. SDK-defined: it is dropped, not dispatched -- + a registered handler for the method never runs (strict-no-cover fails CI if it does).""" + + async def on_notify(ctx: Any, params: NotificationParams) -> None: + raise AssertionError("unreachable") # pragma: no cover + + server: Server[Any] = Server("test") + server.add_notification_handler(body["method"], NotificationParams, on_notify) + async with _asgi_client(server) as http: + response = await http.post("/mcp", json=body) + assert (response.status_code, response.content) == (202, b"") + + +async def test_handle_modern_request_rejects_a_notification_post_at_an_unserved_version() -> None: + """SDK-defined: the manager routes any non-handshake `MCP-Protocol-Version` here, so a + notification claiming a version this entry does not serve gets the same + `UNSUPPORTED_PROTOCOL_VERSION` answer (HTTP 400, `supported` list) a request would.""" async with _asgi_client(Server("test")) as http: response = await http.post( "/mcp", - content=b'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}', - headers={"content-type": "application/json"}, + json={"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}, + headers={MCP_PROTOCOL_VERSION_HEADER: "2099-01-01"}, ) assert response.status_code == 400 - assert response.json()["error"]["code"] == INVALID_REQUEST + assert response.json() == { + "jsonrpc": "2.0", + "id": None, + "error": { + "code": UNSUPPORTED_PROTOCOL_VERSION, + "message": "Unsupported protocol version", + "data": {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": "2099-01-01"}, + }, + } + + +@pytest.mark.parametrize( + "body", + [ + pytest.param({"jsonrpc": "2.0", "id": 1, "result": {}}, id="posted-response"), + pytest.param({"jsonrpc": "2.0", "id": 1, "error": {"code": -1, "message": "x"}}, id="posted-error"), + pytest.param([{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}], id="batch"), + pytest.param({"jsonrpc": "2.0", "id": None, "method": "tools/list"}, id="null-id-request"), + pytest.param({"jsonrpc": "2.0", "id": [1], "method": "tools/list"}, id="non-scalar-id-request"), + pytest.param({"jsonrpc": "2.0", "method": 7}, id="non-string-method-notification"), + pytest.param({"jsonrpc": "1.0", "method": "notifications/cancelled"}, id="wrong-jsonrpc-version"), + pytest.param("just a string", id="scalar"), + ], +) +async def test_handle_modern_request_rejects_a_body_that_is_neither_request_nor_notification(body: Any) -> None: + """Spec-mandated (streamable-http §Sending Messages item 4): the body MUST be a single request + or notification and clients MUST NOT post responses. SDK-defined: anything else -- a posted + response, a batch, a request whose `id` is malformed, a scalar -- is `INVALID_REQUEST` at + HTTP 400 with `id: null`, distinct from `PARSE_ERROR` (malformed JSON). A malformed-`id` + request in particular must not be mistaken for a notification and silently 202'd.""" + async with _asgi_client(Server("test")) as http: + response = await http.post("/mcp", json=body) + assert response.status_code == 400 + assert response.json() == { + "jsonrpc": "2.0", + "id": None, + "error": {"code": INVALID_REQUEST, "message": "Body must be a single JSON-RPC request or notification object"}, + } async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None: From 5aabab593dce7dc41b0edc40a9abd01d12d18d12 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:54:19 +0000 Subject: [PATCH 2/2] 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. --- docs/advanced/low-level-server.md | 2 +- docs/advanced/middleware.md | 11 ++++++--- src/mcp/server/_streamable_http_modern.py | 21 +++------------- src/mcp/shared/inbound.py | 30 +++++++++++++++++------ tests/shared/test_inbound.py | 17 +++++++++++++ 5 files changed, 51 insertions(+), 30 deletions(-) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 083e03cd61..512e2846a1 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -162,7 +162,7 @@ The constructor covers the methods MCP defines. `add_request_handler` covers eve --8<-- "docs_src/lowlevel/tutorial006.py" ``` -* The first argument is the method string. Notifications have a twin, `add_notification_handler`. +* 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. * `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. * The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result. diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 5d80927441..6865ae2112 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -48,8 +48,11 @@ That is the point. Middleware wraps **every** inbound message: * The connection setup: `server/discover`, or `initialize` and `notifications/initialized` on a legacy session. -* Every request and every notification. For a notification, `ctx.request_id is None`, - `call_next(ctx)` returns `None`, and whatever you return is discarded. +* Every request and every notification that reaches the server. For a notification, + `ctx.request_id is None`, `call_next(ctx)` returns `None`, and whatever you return is discarded. + (On the `2026-07-28` streamable-HTTP path a client's notification POST is acknowledged `202` at + the transport and never dispatched, so it does not reach middleware either; that revision + defines no client-to-server notifications over HTTP.) * Even a method the server has no handler for: `call_next` raises the `MCPError(-32601, "Method not found")` *through* your middleware on its way to the client. @@ -105,8 +108,8 @@ don't think about it. It is a no-op until you install an exporter, and it has it * A middleware is `async (ctx, call_next) -> result`, passed as `MCPServer(middleware=[...])` (or appended to `mcp.middleware`), and appended to `server.middleware` on the low-level `Server`. -* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications, - unknown methods) and runs outermost-first. +* It wraps **every** inbound message that reaches the server (`server/discover`, `initialize`, + requests, notifications, unknown methods) and runs outermost-first. * `ctx.request_id is None` is how you tell a notification from a request. * Raise instead of calling `call_next` to refuse one message; the connection survives. * The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index d73b48a558..1932a5d9d2 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -36,7 +36,6 @@ INVALID_REQUEST, PARSE_ERROR, PROTOCOL_VERSION_META_KEY, - UNSUPPORTED_PROTOCOL_VERSION, ErrorData, JSONRPCError, JSONRPCNotification, @@ -44,10 +43,8 @@ JSONRPCResponse, ProgressToken, RequestId, - UnsupportedProtocolVersionErrorData, ) from mcp_types import methods as _methods -from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import ValidationError from starlette.requests import Request from starlette.responses import Response @@ -67,6 +64,7 @@ InboundModernRoute, classify_inbound_request, find_duplicated_routing_header, + unsupported_protocol_version_rejection, validate_mcp_param_headers, ) from mcp.shared.jsonrpc_dispatcher import progress_token_from_params @@ -169,7 +167,7 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt async def _write_rejection( rejection: InboundLadderRejection, - request_id: RequestId, + request_id: RequestId | None, scope: Scope, receive: Receive, send: Send, @@ -250,19 +248,8 @@ async def _acknowledge_notification( await _write(_INVALID_BODY, scope, receive, send) return requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "") - if requested not in MODERN_PROTOCOL_VERSIONS: - rej = JSONRPCError( - jsonrpc="2.0", - id=None, - error=ErrorData( - code=UNSUPPORTED_PROTOCOL_VERSION, - message="Unsupported protocol version", - data=UnsupportedProtocolVersionErrorData( - supported=list(MODERN_PROTOCOL_VERSIONS), requested=requested - ).model_dump(mode="json"), - ), - ) - await _write(rej, scope, receive, send) + if (unsupported := unsupported_protocol_version_rejection(requested)) is not None: + await _write_rejection(unsupported, None, scope, receive, send) return logger.debug("acknowledged and dropped client notification %s at %s", notification.method, requested) await Response(status_code=202)(scope, receive, send) diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index c28aa7fb71..e33d6f502f 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -52,6 +52,7 @@ "find_duplicated_routing_header", "find_invalid_x_mcp_header", "mcp_param_headers", + "unsupported_protocol_version_rejection", "validate_mcp_param_headers", "x_mcp_header_map", ] @@ -367,6 +368,25 @@ def find_duplicated_routing_header(headers: Iterable[tuple[str, str]]) -> str | return None +def unsupported_protocol_version_rejection( + requested: str, supported_modern_versions: Sequence[str] = MODERN_PROTOCOL_VERSIONS +) -> InboundLadderRejection | None: + """The `UNSUPPORTED_PROTOCOL_VERSION` rejection for `requested`, or `None` if it is served. + + The request ladder's last rung, shared with the transport's notification arm + so both message kinds name the same `supported` list in the same words. + """ + if requested in supported_modern_versions: + return None + return InboundLadderRejection( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="Unsupported protocol version", + data=UnsupportedProtocolVersionErrorData( + supported=list(supported_modern_versions), requested=requested + ).model_dump(mode="json"), + ) + + def classify_inbound_request( body: Mapping[str, Any], *, @@ -464,14 +484,8 @@ def classify_inbound_request( message="the protocol-version envelope value must be a string", ) - if protocol_version not in supported_modern_versions: - return InboundLadderRejection( - code=UNSUPPORTED_PROTOCOL_VERSION, - message="Unsupported protocol version", - data=UnsupportedProtocolVersionErrorData( - supported=list(supported_modern_versions), requested=protocol_version - ).model_dump(mode="json"), - ) + if (unsupported := unsupported_protocol_version_rejection(protocol_version, supported_modern_versions)) is not None: + return unsupported return InboundModernRoute( protocol_version=protocol_version, diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 7712e73e5e..561eff24b0 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -43,6 +43,7 @@ find_duplicated_routing_header, find_invalid_x_mcp_header, mcp_param_headers, + unsupported_protocol_version_rejection, validate_mcp_param_headers, x_mcp_header_map, ) @@ -225,6 +226,22 @@ def test_version_rung_data_reflects_supplied_supported_list() -> None: assert rejection.data == {"supported": list(custom), "requested": LATEST_MODERN_VERSION} +def test_unsupported_protocol_version_rejection_is_the_version_rung_standalone() -> None: + """SDK-defined: the standalone helper (used by the HTTP notification arm) yields `None` for a + served version and otherwise the very rejection the request ladder's version rung produces.""" + assert unsupported_protocol_version_rejection(LATEST_MODERN_VERSION) is None + assert unsupported_protocol_version_rejection("2099-01-01") == classify_inbound_request( + envelope(version="2099-01-01") + ) + assert unsupported_protocol_version_rejection(LATEST_MODERN_VERSION, (LATEST_HANDSHAKE_VERSION,)) == ( + InboundLadderRejection( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="Unsupported protocol version", + data={"supported": [LATEST_HANDSHAKE_VERSION], "requested": LATEST_MODERN_VERSION}, + ) + ) + + # --- rung 3: header ↔ envelope agreement ---------------------------------------