Skip to content

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

Description

@EsntlSolutions

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

  1. 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.
  2. 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?
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Significant bug affecting many users, highly requested featurebugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions