Summary
On the 2026-07-28 per-request-envelope path, the streamable-HTTP transport rejects any client notification with HTTP 400 and -32600 "Body must be a single JSON-RPC request object". The same notification is accepted with 202 on the handshake era.
mcp/server/_streamable_http_modern.py:349-362 documents this as a deliberate choice, and notes the decision is still open:
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.
I'm not reporting a spec violation — the transport spec permits either branch, and the reasoning above is sound. I'm supplying the empirical input that TODO(L57) is missing: real clients do send notifications on 2026-07-28, so the strict branch produces a steady stream of 400s in production.
Observed in production
A server running mcp==2.0.0 behind an OAuth-protected streamable-HTTP endpoint, serving live Claude-User connector traffic. Within one client session, on 2026-07-28:
| Method |
Result |
server/discover |
200 |
notifications/cancelled |
400 -32600 |
tools/list (234 ms later) |
200 |
The client was unaffected — notifications are fire-and-forget, so it carried on and the next request succeeded. The practical cost is observability rather than function: a monitoring rule of the obvious shape ("no 400s on the new revision") fires permanently, and the 400s are indistinguishable by status code from genuine protocol failures.
Reproduction
Depends only on mcp and httpx; no network.
import anyio, httpx
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
PV = "io.modelcontextprotocol/protocolVersion"
CC = "io.modelcontextprotocol/clientCapabilities"
srv = MCPServer(name="repro", version="0.0.1")
@srv.tool(name="ping", description="noop")
def ping() -> str:
return "pong"
def app():
return srv.streamable_http_app(
stateless_http=True, json_response=True,
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
async def post(label, body, version):
a = app()
async with a.router.lifespan_context(a):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=a), base_url="http://localhost") as c:
r = await c.post("/mcp", json=body, headers={
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": version,
"Mcp-Method": body["method"],
})
print(f"{label:<48} HTTP {r.status_code} {r.text[:100]}")
async def main():
meta = {PV: "2026-07-28", CC: {}}
await post("notification, WITH _meta envelope",
{"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": {"requestId": 1, "_meta": meta}}, "2026-07-28")
await post("notification, without _meta envelope",
{"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": {"requestId": 1}}, "2026-07-28")
await post("control: request (has id), WITH envelope",
{"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {"_meta": meta}}, "2026-07-28")
await post("same notification on 2025-11-25",
{"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": {"requestId": 1}}, "2025-11-25")
anyio.run(main)
Output:
notification, WITH _meta envelope HTTP 400 {"jsonrpc":"2.0","error":{"code":-32600,"message":"Body must be a single JSON-RPC request object"},"id":null}
notification, without _meta envelope HTTP 400 {"jsonrpc":"2.0","error":{"code":-32600,"message":"Body must be a single JSON-RPC request object"},"id":null}
control: request (has id), WITH envelope HTTP 200 {"jsonrpc":"2.0","id":1,"result":{...}}
same notification on 2025-11-25 HTTP 202
Note the discriminator is the absent id, not the envelope: adding a well-formed params._meta changes nothing, because JSONRPCRequest.model_validate fails before the envelope is ever examined.
Questions
- Is the cannot-accept branch intended to stay, now that clients are observably sending these? The lenient branch (
202 and drop) would match handshake-era behaviour for the same message and keep 4xx meaningful as a failure signal.
- If it stays, would you consider a distinguishable response — a dedicated error message or code for "notifications are not accepted on this revision" — so operators can separate it from genuine protocol errors without parsing bodies?
- Is it worth a note in the migration guide? A dual-era server inherits this the moment a client starts using
2026-07-28, with no change on the server's part.
Environment
python 3.13.7
mcp 2.0.0
mcp-types 2.0.0
starlette 1.0.0
pydantic 2.13.4
Summary
On the
2026-07-28per-request-envelope path, the streamable-HTTP transport rejects any client notification with HTTP 400 and-32600 "Body must be a single JSON-RPC request object". The same notification is accepted with202on the handshake era.mcp/server/_streamable_http_modern.py:349-362documents this as a deliberate choice, and notes the decision is still open:I'm not reporting a spec violation — the transport spec permits either branch, and the reasoning above is sound. I'm supplying the empirical input that
TODO(L57)is missing: real clients do send notifications on2026-07-28, so the strict branch produces a steady stream of 400s in production.Observed in production
A server running
mcp==2.0.0behind an OAuth-protected streamable-HTTP endpoint, serving liveClaude-Userconnector traffic. Within one client session, on2026-07-28:server/discovernotifications/cancelled-32600tools/list(234 ms later)The client was unaffected — notifications are fire-and-forget, so it carried on and the next request succeeded. The practical cost is observability rather than function: a monitoring rule of the obvious shape ("no 400s on the new revision") fires permanently, and the 400s are indistinguishable by status code from genuine protocol failures.
Reproduction
Depends only on
mcpandhttpx; no network.Output:
Note the discriminator is the absent
id, not the envelope: adding a well-formedparams._metachanges nothing, becauseJSONRPCRequest.model_validatefails before the envelope is ever examined.Questions
202and drop) would match handshake-era behaviour for the same message and keep 4xx meaningful as a failure signal.2026-07-28, with no change on the server's part.Environment