Skip to content

Commit aabd0cb

Browse files
committed
Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry
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
1 parent e473cca commit aabd0cb

4 files changed

Lines changed: 219 additions & 23 deletions

File tree

src/mcp/server/_streamable_http_modern.py

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
path for earlier protocol revisions.
66
77
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
8-
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON
8+
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. A
9+
notification POST is acknowledged `202` and dropped: the core protocol defines
10+
no client-to-server notifications on this wire (cancellation is closing the
11+
response stream), and a per-request entry has nothing for one to act on. JSON
912
mode handles the request directly in the ASGI task. SSE mode runs the handler
1013
as a sibling task and defers committing to `text/event-stream` until the
1114
handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever
@@ -33,15 +36,18 @@
3336
INVALID_REQUEST,
3437
PARSE_ERROR,
3538
PROTOCOL_VERSION_META_KEY,
39+
UNSUPPORTED_PROTOCOL_VERSION,
3640
ErrorData,
3741
JSONRPCError,
3842
JSONRPCNotification,
3943
JSONRPCRequest,
4044
JSONRPCResponse,
4145
ProgressToken,
4246
RequestId,
47+
UnsupportedProtocolVersionErrorData,
4348
)
4449
from mcp_types import methods as _methods
50+
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
4551
from pydantic import ValidationError
4652
from starlette.requests import Request
4753
from starlette.responses import Response
@@ -56,6 +62,7 @@
5662
from mcp.shared.inbound import (
5763
ERROR_CODE_HTTP_STATUS,
5864
MCP_PARAM_HEADER_PREFIX,
65+
MCP_PROTOCOL_VERSION_HEADER,
5966
InboundLadderRejection,
6067
InboundModernRoute,
6168
classify_inbound_request,
@@ -196,6 +203,71 @@ async def _write(
196203
)(scope, receive, send)
197204

198205

206+
_INVALID_BODY: Final = JSONRPCError(
207+
jsonrpc="2.0",
208+
id=None,
209+
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"),
210+
)
211+
"""Well-formed JSON that is not one request or notification: a batch, a posted response, a malformed envelope."""
212+
213+
214+
def _is_notification_shaped(decoded: Any) -> bool:
215+
"""Whether a decoded POST body is a single JSON object without an `id` member.
216+
217+
JSON-RPC 2.0 §4.1: a notification is a request object without an "id"
218+
member, so key presence — not which model happens to validate — picks the
219+
arm. (The notification model ignores unknown keys; letting it catch a
220+
request whose id is malformed would 202 a message that is owed an error.)
221+
"""
222+
return isinstance(decoded, dict) and "id" not in decoded
223+
224+
225+
async def _acknowledge_notification(
226+
decoded: dict[str, Any],
227+
request: Request,
228+
scope: Scope,
229+
receive: Receive,
230+
send: Send,
231+
) -> None:
232+
"""Answer an id-less POST body: `202` for a notification at a served version, a rejection otherwise.
233+
234+
Streamable-http §Sending Messages item 5 lets a server accept (202, no
235+
body) or refuse (4xx) a notification POST; this entry accepts and drops.
236+
The 2026-07-28 core protocol defines no client-to-server notifications over
237+
HTTP (a client cancels by closing the response stream) and a per-request
238+
entry holds no cross-request state for one to act on — honouring a posted
239+
`notifications/cancelled` by client-chosen request id would let one
240+
anonymous caller cancel another's work — but clients in the field still
241+
POST them, and notifications are fire-and-forget, so they are acknowledged
242+
as the handshake-era transport does rather than answered with an error
243+
nobody reads. Header requirements for notification POSTs are undefined at
244+
this revision; only the routing header that brought the POST here is
245+
checked, so a version this entry does not serve is told so, as a request is.
246+
"""
247+
try:
248+
notification = JSONRPCNotification.model_validate(decoded)
249+
except ValidationError:
250+
await _write(_INVALID_BODY, scope, receive, send)
251+
return
252+
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)
266+
return
267+
logger.debug("acknowledged and dropped client notification %s at %s", notification.method, requested)
268+
await Response(status_code=202)(scope, receive, send)
269+
270+
199271
_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower()
200272

201273
_MCP_PARAM_LIST_PAGE_CAP: Final = 100
@@ -346,22 +418,15 @@ async def handle_modern_request(
346418
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
347419
await _write(rej, scope, receive, send)
348420
return
421+
if _is_notification_shaped(decoded):
422+
await _acknowledge_notification(decoded, request, scope, receive, send)
423+
return
349424
try:
350425
req = JSONRPCRequest.model_validate(decoded)
351426
except ValidationError:
352-
# Well-formed JSON that isn't a single request object. The transport
353-
# spec permits notification POSTs and gives the server two responses
354-
# (202 accept / 4xx cannot-accept; streamable-http §Sending Messages
355-
# item 5). The core protocol defines no client→server notifications
356-
# over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this
357-
# entry takes the cannot-accept branch. TODO(L57): S4 owns the
358-
# strict-vs-lenient choice.
359-
rej = JSONRPCError(
360-
jsonrpc="2.0",
361-
id=None,
362-
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"),
363-
)
364-
await _write(rej, scope, receive, send)
427+
# A batch, a posted response (clients MUST NOT send those: streamable-http
428+
# §Sending Messages item 4), or a request whose envelope is malformed.
429+
await _write(_INVALID_BODY, scope, receive, send)
365430
return
366431

367432
if req.method == "subscriptions/listen" and not has_sse:

tests/interaction/_requirements.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3182,7 +3182,12 @@ def __post_init__(self) -> None:
31823182
source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
31833183
behavior="A POST containing only notifications or responses returns 202 with no body.",
31843184
transports=("streamable-http",),
3185-
note="Only observable over HTTP: 202 is an HTTP status code.",
3185+
removed_in="2026-07-28",
3186+
superseded_by="hosting:http:modern:notification-post-202",
3187+
note=(
3188+
"Only observable over HTTP: 202 is an HTTP status code. At 2026-07-28 clients no longer post "
3189+
"responses (streamable-http §Sending Messages item 4), so only the notification half carries over."
3190+
),
31863191
),
31873192
"hosting:http:onerror": Requirement(
31883193
source="sdk",
@@ -3375,6 +3380,22 @@ def __post_init__(self) -> None:
33753380
transports=("streamable-http",),
33763381
note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.",
33773382
),
3383+
"hosting:http:modern:notification-post-202": Requirement(
3384+
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#sending-messages",
3385+
behavior=(
3386+
"A 2026-07-28 POST whose body is a single JSON-RPC notification is acknowledged 202 with no "
3387+
"body (the spec's accept branch) and is not dispatched; a posted JSON-RPC response is rejected "
3388+
"INVALID_REQUEST at HTTP 400."
3389+
),
3390+
added_in="2026-07-28",
3391+
supersedes=("hosting:http:notifications-202",),
3392+
transports=("streamable-http",),
3393+
note=(
3394+
"Only observable over streamable HTTP: the HTTP status is the assertion. The revision defines no "
3395+
"client-to-server notifications on this transport (cancellation is closing the response stream), "
3396+
"so accept-and-drop is the SDK's choice between the two responses the spec permits."
3397+
),
3398+
),
33783399
# ═══════════════════════════════════════════════════════════════════════════
33793400
# Client transport: streamable HTTP
33803401
# ═══════════════════════════════════════════════════════════════════════════

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
HEADER_MISMATCH,
2121
INTERNAL_ERROR,
2222
INVALID_PARAMS,
23+
INVALID_REQUEST,
2324
METHOD_NOT_FOUND,
2425
MISSING_REQUIRED_CLIENT_CAPABILITY,
2526
SERVER_INFO_META_KEY,
2627
CallToolRequestParams,
2728
CallToolResult,
2829
DiscoverResult,
2930
EmptyResult,
31+
ErrorData,
3032
Implementation,
3133
JSONRPCError,
3234
JSONRPCResponse,
@@ -153,6 +155,39 @@ async def test_modern_response_carries_no_session_id_header() -> None:
153155
assert "mcp-session-id" not in response.headers
154156

155157

158+
@requirement("hosting:http:modern:notification-post-202")
159+
@pytest.mark.parametrize("json_response", [True, False], ids=["json", "sse"])
160+
@pytest.mark.parametrize("stateless_http", [True, False], ids=["stateless-flag", "default"])
161+
async def test_modern_notification_post_is_acknowledged_202_and_a_posted_response_is_rejected(
162+
json_response: bool, stateless_http: bool
163+
) -> None:
164+
"""A 2026-07-28 notification POST is answered 202 with no body; a posted response is 400 INVALID_REQUEST.
165+
166+
Spec-permitted (streamable-http §Sending Messages item 5): the server may accept (202) or refuse
167+
(4xx) a notification POST, and the SDK accepts -- the same answer the legacy leg gives, so a
168+
client's courtesy `notifications/cancelled` is not met with an error on one era only.
169+
Spec-mandated (item 4): clients MUST NOT post responses, so one is refused. Driven through the
170+
mounted app so the manager's header routing is in the path, under both response modes and both
171+
values of the legacy-only `stateless_http` flag (neither is read before the modern entry answers).
172+
"""
173+
notification = {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}
174+
posted_response: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "result": {}}
175+
async with mounted_app(_server(), json_response=json_response, stateless_http=stateless_http) as (http, _):
176+
acknowledged = await http.post(
177+
"/mcp", json=notification, headers=_modern_headers(method="notifications/cancelled")
178+
)
179+
refused = await http.post("/mcp", json=posted_response, headers=_modern_headers(method="tools/list"))
180+
181+
assert (acknowledged.status_code, acknowledged.content) == (202, b"")
182+
assert "mcp-session-id" not in acknowledged.headers
183+
assert refused.status_code == 400
184+
assert JSONRPCError.model_validate(refused.json()) == JSONRPCError(
185+
jsonrpc="2.0",
186+
id=None,
187+
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"),
188+
)
189+
190+
156191
@requirement("hosting:http:modern:initialize-removed")
157192
async def test_modern_initialize_is_method_not_found() -> None:
158193
"""A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404.

tests/server/test_streamable_http_modern.py

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
PARSE_ERROR,
2626
PROTOCOL_VERSION_META_KEY,
2727
SERVER_INFO_META_KEY,
28+
UNSUPPORTED_PROTOCOL_VERSION,
2829
CallToolRequestParams,
2930
CallToolResult,
3031
ClientCapabilities,
@@ -34,10 +35,11 @@
3435
ListToolsResult,
3536
LoggingMessageNotification,
3637
LoggingMessageNotificationParams,
38+
NotificationParams,
3739
PaginatedRequestParams,
3840
Tool,
3941
)
40-
from mcp_types.version import LATEST_MODERN_VERSION
42+
from mcp_types.version import LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS
4143
from starlette.types import Message, Receive, Scope, Send
4244
from trio.testing import MockClock
4345

@@ -110,18 +112,91 @@ async def test_handle_modern_request_rejects_non_post_with_http_405_and_allow_he
110112
assert response.content == b""
111113

112114

113-
async def test_handle_modern_request_rejects_a_notification_body_with_invalid_request() -> None:
114-
"""SDK-defined: well-formed JSON that isn't a single JSON-RPC request object (e.g. a
115-
notification, which lacks ``id``) is ``INVALID_REQUEST`` — distinct from ``PARSE_ERROR``,
116-
which is for malformed JSON."""
115+
@pytest.mark.parametrize(
116+
"body",
117+
[
118+
pytest.param(
119+
{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}, id="cancelled"
120+
),
121+
pytest.param({"jsonrpc": "2.0", "method": "notifications/roots/list_changed"}, id="removed-at-2026"),
122+
pytest.param({"jsonrpc": "2.0", "method": "acme/heartbeat", "params": {"n": 1}}, id="custom"),
123+
pytest.param(
124+
{
125+
"jsonrpc": "2.0",
126+
"method": "notifications/cancelled",
127+
"params": {"requestId": "listen:0", "_meta": {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION}},
128+
},
129+
id="with-envelope",
130+
),
131+
],
132+
)
133+
async def test_handle_modern_request_acknowledges_a_notification_post_with_202_and_drops_it(
134+
body: dict[str, Any],
135+
) -> None:
136+
"""Spec-permitted (streamable-http §Sending Messages item 5, the accept branch): a POST whose
137+
body is one JSON-RPC notification is answered 202 with no body, whatever its method and
138+
whether or not it carries a `_meta` envelope. SDK-defined: it is dropped, not dispatched --
139+
a registered handler for the method never runs (strict-no-cover fails CI if it does)."""
140+
141+
async def on_notify(ctx: Any, params: NotificationParams) -> None:
142+
raise AssertionError("unreachable") # pragma: no cover
143+
144+
server: Server[Any] = Server("test")
145+
server.add_notification_handler(body["method"], NotificationParams, on_notify)
146+
async with _asgi_client(server) as http:
147+
response = await http.post("/mcp", json=body)
148+
assert (response.status_code, response.content) == (202, b"")
149+
150+
151+
async def test_handle_modern_request_rejects_a_notification_post_at_an_unserved_version() -> None:
152+
"""SDK-defined: the manager routes any non-handshake `MCP-Protocol-Version` here, so a
153+
notification claiming a version this entry does not serve gets the same
154+
`UNSUPPORTED_PROTOCOL_VERSION` answer (HTTP 400, `supported` list) a request would."""
117155
async with _asgi_client(Server("test")) as http:
118156
response = await http.post(
119157
"/mcp",
120-
content=b'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}',
121-
headers={"content-type": "application/json"},
158+
json={"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}},
159+
headers={MCP_PROTOCOL_VERSION_HEADER: "2099-01-01"},
122160
)
123161
assert response.status_code == 400
124-
assert response.json()["error"]["code"] == INVALID_REQUEST
162+
assert response.json() == {
163+
"jsonrpc": "2.0",
164+
"id": None,
165+
"error": {
166+
"code": UNSUPPORTED_PROTOCOL_VERSION,
167+
"message": "Unsupported protocol version",
168+
"data": {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": "2099-01-01"},
169+
},
170+
}
171+
172+
173+
@pytest.mark.parametrize(
174+
"body",
175+
[
176+
pytest.param({"jsonrpc": "2.0", "id": 1, "result": {}}, id="posted-response"),
177+
pytest.param({"jsonrpc": "2.0", "id": 1, "error": {"code": -1, "message": "x"}}, id="posted-error"),
178+
pytest.param([{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}], id="batch"),
179+
pytest.param({"jsonrpc": "2.0", "id": None, "method": "tools/list"}, id="null-id-request"),
180+
pytest.param({"jsonrpc": "2.0", "id": [1], "method": "tools/list"}, id="non-scalar-id-request"),
181+
pytest.param({"jsonrpc": "2.0", "method": 7}, id="non-string-method-notification"),
182+
pytest.param({"jsonrpc": "1.0", "method": "notifications/cancelled"}, id="wrong-jsonrpc-version"),
183+
pytest.param("just a string", id="scalar"),
184+
],
185+
)
186+
async def test_handle_modern_request_rejects_a_body_that_is_neither_request_nor_notification(body: Any) -> None:
187+
"""Spec-mandated (streamable-http §Sending Messages item 4): the body MUST be a single request
188+
or notification and clients MUST NOT post responses. SDK-defined: anything else -- a posted
189+
response, a batch, a request whose `id` is malformed, a scalar -- is `INVALID_REQUEST` at
190+
HTTP 400 with `id: null`, distinct from `PARSE_ERROR` (malformed JSON). A malformed-`id`
191+
request in particular must not be mistaken for a notification and silently 202'd."""
192+
async with _asgi_client(Server("test")) as http:
193+
response = await http.post("/mcp", json=body)
194+
assert response.status_code == 400
195+
assert response.json() == {
196+
"jsonrpc": "2.0",
197+
"id": None,
198+
"error": {"code": INVALID_REQUEST, "message": "Body must be a single JSON-RPC request or notification object"},
199+
}
125200

126201

127202
async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None:

0 commit comments

Comments
 (0)