Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry - #3326
Conversation
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
There was a problem hiding this comment.
1 issue found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/server/_streamable_http_modern.py">
<violation number="1" location="src/mcp/server/_streamable_http_modern.py:252">
P2: When a notification POST includes duplicate routing headers, this path skips duplicate-header rejection and reads one folded `mcp-protocol-version` value. Check `find_duplicated_routing_header` in `_acknowledge_notification` and return `HEADER_MISMATCH` before reading the version header.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| except ValidationError: | ||
| await _write(_INVALID_BODY, scope, receive, send) | ||
| return | ||
| requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "") |
There was a problem hiding this comment.
P2: When a notification POST includes duplicate routing headers, this path skips duplicate-header rejection and reads one folded mcp-protocol-version value. Check find_duplicated_routing_header in _acknowledge_notification and return HEADER_MISMATCH before reading the version header.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/_streamable_http_modern.py, line 252:
<comment>When a notification POST includes duplicate routing headers, this path skips duplicate-header rejection and reads one folded `mcp-protocol-version` value. Check `find_duplicated_routing_header` in `_acknowledge_notification` and return `HEADER_MISMATCH` before reading the version header.</comment>
<file context>
@@ -196,6 +203,71 @@ async def _write(
+ 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(
</file context>
| requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "") | |
| duplicated = find_duplicated_routing_header(request.headers.items()) | |
| if duplicated is not None: | |
| await _write( | |
| JSONRPCError( | |
| jsonrpc="2.0", | |
| id=None, | |
| error=ErrorData(code=HEADER_MISMATCH, message=f"{duplicated} header appears more than once"), | |
| ), | |
| scope, | |
| receive, | |
| send, | |
| ) | |
| return | |
| requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "") |
There was a problem hiding this comment.
Deliberate for now. The duplicate check guards the header↔body cross-check, which only requests get; the notification arm reads the routing header once (first value, same as the manager) and acts on nothing, so rejecting here would only turn a 202-and-drop into a 400 for a message we drop either way, on a POST whose header requirements the revision leaves undefined. If the notification arm ever grows real rungs it will get its own classifier and the check belongs there.
There was a problem hiding this comment.
Beyond the inline findings, I also examined whether accepting-and-dropping without dispatching to registered add_notification_handler handlers is a correctness bug — it is the spec's permitted accept branch, deliberately scoped in the PR, and the new tests pin the not-dispatched behaviour explicitly. Also checked the id-presence split against the malformed-id cases (id: null, non-scalar id): those correctly still take the INVALID_REQUEST arm rather than being 202'd.
Extended reasoning...
The two posted findings are quality-level (duplicated unsupported-version error construction vs. the shared inbound ladder, and a missing docs/ update for a user-visible wire change), and the hunt exited on max_rounds rather than running dry, so approval is off the table. The note records the two concrete candidate concerns that were investigated and ruled out — the intentional drop-without-dispatch design and the notification/request classification edge cases around malformed id members — which are the places a human reviewer would most likely worry about and can now skip re-deriving.
…ion 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.
📚 Documentation preview
|
There was a problem hiding this comment.
Both points from my earlier review are addressed by 5aabab5: the -32022 rejection is now built by the shared unsupported_protocol_version_rejection helper on both the request and notification arms, and the middleware / low-level-server docs now state the 202-acknowledged-not-dispatched behaviour on the 2026-07-28 HTTP path. Beyond the inline nit, I also examined the new # pragma: no cover on the never-invoked notification handler in tests/server/test_streamable_http_modern.py and ruled it out — strict-no-cover fails CI if that line ever executes, which is exactly the pin the test intends.
Extended reasoning...
Re-review after the follow-up commit. Commit 5aabab5 resolved both prior findings (duplicated unsupported-version rejection construction, missing docs update for the user-visible 202 drop) — verified in the diff: src/mcp/shared/inbound.py gains the shared helper and classify_inbound_request calls it, and docs/advanced/middleware.md plus docs/advanced/low-level-server.md now document that notification POSTs on the modern path never reach middleware or handlers. This run surfaced one remaining nit (an inaccurate parenthetical in a test docstring in tests/interaction/transports/test_hosting_http_modern.py), posted inline. The one other candidate investigated — the raise AssertionError(...) # pragma: no cover pattern in the never-invoked handler — was ruled out because strict-no-cover turns any execution of that line into a CI failure, matching the test's intent. The hunt exited on max_rounds rather than running dry, so approval is off the table regardless; the remaining inline item is a docstring-accuracy nit that does not block the change.
| 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). |
There was a problem hiding this comment.
🟡 [quality] nit: the new test's docstring claims "(neither is read before the modern entry answers)" about the json_response/stateless_http parametrization, but json_response IS read before the notification 202 — handle_modern_request's Accept gate (if not has_json or (not json_response and not has_sse), src/mcp/server/_streamable_http_modern.py:396) evaluates it ahead of _acknowledge_notification, and it genuinely changes the answer: with json_response=False a notification POST whose Accept lacks text/event-stream is 406, with json_response=True it is 202.
Extended reasoning...
Concrete cost: a documented-but-false invariant in the test that .claude/skills/test-quality/SKILL.md-style provenance docstrings are supposed to state accurately. A maintainer extending the notification arm (e.g. deciding whether a bare Accept: application/json notification POST should 202 in SSE mode, exactly the interop shape this PR is about) reads this docstring, concludes the response-mode flag cannot influence a notification POST's answer, and skips testing the 406-vs-202 divergence — the docstring is only true because base_headers() happens to send both accept types. The stateless_http half of the claim is correct; the json_response half is verifiably wrong at src/mcp/server/_streamable_http_modern.py:396.
Verification: nit — the test docstring at tests/interaction/transports/test_hosting_http_modern.py:170-171 says "under both response modes and both values of the legacy-only stateless_http flag (neither is read before the modern entry answers)", but json_response (the response-mode flag) is read before the notification arm answers: src/mcp/server/_streamable_http_modern.py:395-397 `has_json, has_sse = che
The 2026-07-28 streamable-HTTP entry now answers a POST whose body is a single JSON-RPC notification with
202 Accepted(no body) and drops it, instead of400/-32600 "Body must be a single JSON-RPC request object".Fixes #3324.
Motivation and Context
handle_modern_requestvalidated every body as aJSONRPCRequest, so anything without anid— i.e. every notification — took theINVALID_REQUESTarm. That was a deliberate choice of the "cannot accept" branch of streamable-http §Sending Messages item 5, on the grounds that the revision defines no client-to-server notifications over HTTP, with an in-code TODO recording accept-vs-refuse as an open call.#3324 supplies what that TODO was waiting for: production clients do POST
notifications/cancelledat 2026-07-28, and the result is a steady stream of 400s that makes 4xx useless as a failure signal. Looking around:Notification → 202, transports §Messages says a binding MUST deliver client-sent notifications, and cancellation §Error Handling asks servers to ignore stray cancellation notifications rather than error. Nothing forbids a client from sending one.notifications/cancelledonsubscriptions/listenteardown, the Go client on context cancel and roots changes, and this SDK's deprecatedsend_roots_list_changed()/send_progress_notification(). The Go client treats an id-less 4xx on a notification as fatal to the session, so the 400 was worse than noise there.What changes
MCP-Protocol-Versionheader isn't a served modern version,-32022withsupported(the same answer a request gets) → otherwise202, empty body, debug log, not dispatched. There is nothing for a notification to act on in a per-request exchange (cancel by closing the response stream), and honouring a postednotifications/cancelledby client-chosen id would let one anonymous caller cancel another's work.idmember (JSON-RPC 2.0 §4.1), not on which model happens to validate:JSONRPCNotificationignores unknown keys, so{"id": null, "method": ...}would otherwise be mistaken for a notification and silently 202'd when it is owed an error.400 -32600; the message now reads "…request or notification object"._meta/Mcp-Methodvalidation on notifications: the revision leaves header requirements for notification POSTs undefined, and requiring them would reject the TypeScript client's listen-teardown shape (noMcp-Method).Deliberately not done: dispatching accepted notifications to registered
Servernotification handlers (the modern stdio path does). For spec methods it's moot at 2026-07-28; for custom methods it's an additive follow-up if wanted.How Has This Been Tested?
-32022on an unserved version header;-32600across eight non-request/non-notification shapes includingid: null.stateless_httpon/off; new catalog entryhosting:http:modern:notification-post-202supersedes the era-unboundedhosting:http:notifications-202at 2026-07-28.MCPServerin all three configurations with the reporter's exact request, the enveloped shape, and the rejection cases; a 202 followed by a request on the same keep-alive connection is served normally.Breaking Changes
Observable wire change on the 2026-07-28 HTTP entry only: a notification POST that previously got
400 -32600now gets202. It relaxes rather than tightens, and matches the legacy leg and the other SDKs. The-32600message text for the remaining rejections changed wording.Types of changes
Checklist
Additional context
_streamable_http_modern.py, so this doesn't interact with it.json_response=Truemode the modern entry awaits the handler inline with nohttp.disconnectwatcher, so closing the stream — the revision's cancellation signal — doesn't cancel anything in that mode. Separate issue.