Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry (#3326)

This commit is contained in:
Max 2026-08-17 21:15:35 +01:00 • committed by GitHub
commit b2025ab815
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 254 additions and 37 deletions

View file

@ -173,7 +173,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.

View file

@ -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

View file

@ -5,7 +5,10 @@ The legacy streamable-HTTP transport is untouched and remains the supported
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
@ -56,10 +59,12 @@ from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.inbound import (
ERROR_CODE_HTTP_STATUS,
MCP_PARAM_HEADER_PREFIX,
MCP_PROTOCOL_VERSION_HEADER,
InboundLadderRejection,
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
@ -162,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,
@ -196,6 +201,60 @@ 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 (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)
_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower()
_MCP_PARAM_LIST_PAGE_CAP: Final = 100
@ -346,22 +405,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:

View file

@ -52,6 +52,7 @@ __all__ = [
"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,

View file

@ -3182,7 +3182,12 @@ REQUIREMENTS: dict[str, Requirement] = {
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 @@ REQUIREMENTS: dict[str, Requirement] = {
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
# ═══════════════════════════════════════════════════════════════════════════

View file

@ -20,6 +20,7 @@ from mcp_types import (
HEADER_MISMATCH,
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
SERVER_INFO_META_KEY,
@ -27,6 +28,7 @@ from mcp_types import (
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.

View file

@ -25,6 +25,7 @@ from mcp_types import (
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
CallToolRequestParams,
CallToolResult,
ClientCapabilities,
@ -34,10 +35,11 @@ from mcp_types import (
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:

View file

@ -43,6 +43,7 @@ from mcp.shared.inbound import (
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 ---------------------------------------