Skip to content

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

Merged
maxisbey merged 2 commits into
mainfrom
modern-http-ack-notification-post
Aug 17, 2026
Merged

Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry#3326
maxisbey merged 2 commits into
mainfrom
modern-http-ack-notification-post

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

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 of 400 / -32600 "Body must be a single JSON-RPC request object".

Fixes #3324.

Motivation and Context

handle_modern_request validated every body as a JSONRPCRequest, so anything without an id — i.e. every notification — took the INVALID_REQUEST arm. 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/cancelled at 2026-07-28, and the result is a steady stream of 400s that makes 4xx useless as a failure signal. Looking around:

  • Both branches are conformant, but the surrounding spec text leans accept: item 4 names notifications as a valid POST body, the message-flow diagram shows 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.
  • The TypeScript server 202s any notification on the modern path (pinned for exactly this shape); C# and rmcp 202 as well. Our own legacy leg 202s the same POST.
  • Real senders exist beyond the reporter's: the TypeScript client POSTs notifications/cancelled on subscriptions/listen teardown, the Go client on context cancel and roots changes, and this SDK's deprecated send_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

  • An id-less single-object body goes to a notification arm: shape-validate → if the MCP-Protocol-Version header isn't a served modern version, -32022 with supported (the same answer a request gets) → otherwise 202, 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 posted notifications/cancelled by client-chosen id would let one anonymous caller cancel another's work.
  • The split keys on presence of the id member (JSON-RPC 2.0 §4.1), not on which model happens to validate: JSONRPCNotification ignores unknown keys, so {"id": null, "method": ...} would otherwise be mistaken for a notification and silently 202'd when it is owed an error.
  • Posted responses, batches, scalars and malformed-id requests stay 400 -32600; the message now reads "…request or notification object".
  • No _meta / Mcp-Method validation on notifications: the revision leaves header requirements for notification POSTs undefined, and requiring them would reject the TypeScript client's listen-teardown shape (no Mcp-Method).

Deliberately not done: dispatching accepted notifications to registered Server notification 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?

  • Unit: the old 400 pin is replaced with 202 across four notification shapes (bare, removed-at-2026 method, custom, enveloped) asserting a registered handler never runs; -32022 on an unserved version header; -32600 across eight non-request/non-notification shapes including id: null.
  • Interaction: driven through the mounted session manager under json/SSE × stateless_http on/off; new catalog entry hosting:http:modern:notification-post-202 supersedes the era-unbounded hosting:http:notifications-202 at 2026-07-28.
  • Manually against a uvicorn-served MCPServer in 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 -32600 now gets 202. It relaxes rather than tightens, and matches the legacy leg and the other SDKs. The -32600 message text for the remaining rejections changed wording.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

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
@maxisbey
maxisbey marked this pull request as ready for review August 17, 2026 19:19

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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, "")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

AI Disclaimer

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/mcp/server/_streamable_http_modern.py Outdated
Comment thread src/mcp/server/_streamable_http_modern.py
…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.
@github-actions

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3326.mcp-python-docs.pages.dev
Deployment https://738cc856.mcp-python-docs.pages.dev
Commit 5aabab5
Triggered by @maxisbey
Updated 2026-08-17 19:57:05 UTC

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +169 to +171
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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [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

@maxisbey
maxisbey merged commit b2025ab into main Aug 17, 2026
45 checks passed
@maxisbey
maxisbey deleted the modern-http-ack-notification-post branch August 17, 2026 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2026-07-28 transport rejects client notifications with -32600; real clients send notifications/cancelled (TODO(L57))

1 participant