Skip to content

2026-07-28 SSE responses share one header list, so in-place middleware appends leak across responses #3701

Open

Description

@mgoldsborough

Initial Checks

Release line

2.x (current stable)

Description

On the 2026-07-28 HTTP entry, handle_modern_request starts every SSE response with the module-level _SSE_HEADERS list itself:

await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS})

ASGI middleware that adds a header with message["headers"].append(...) therefore appends to the SDK's shared list. The header stays there for the life of the process, so every later SSE response carries one more copy, along with the values set for earlier requests. JSON responses aren't affected, because they build their own header list.

We hit this in production with an observability middleware that appended an x-correlation-id. After about 250 SSE responses in one process, our edge proxy's HTTP client rejected the responses as malformed. Only calls that commit to SSE (a notification, or more than 15 s without one) take this path, so fast calls and most test suites never touch it, and a restart hid it for a while.

The middleware shouldn't mutate a list it doesn't own; Starlette's MutableHeaders(scope=message) copies for exactly this reason. Passing list(_SSE_HEADERS) would keep one middleware's mistake from turning into process-wide header growth and cross-request header leakage.

Expected: each SSE response gets its own header list.

Example Code

import asyncio
import httpx
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.transport_security import TransportSecuritySettings

mcp = MCPServer("repro")

@mcp.tool()
async def slow(ctx: Context) -> str:
    await ctx.report_progress(1, 2)  # any notification commits the response to SSE
    return "done"

inner = mcp.streamable_http_app(
    transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
)

async def app(scope, receive, send):  # middleware that appends in place
    async def send2(msg):
        if msg["type"] == "http.response.start":
            msg["headers"].append((b"x-correlation-id", b"abc"))
        await send(msg)
    await inner(scope, receive, send2)

def body(i):
    return {"jsonrpc": "2.0", "id": i, "method": "tools/call",
            "params": {"name": "slow", "arguments": {}, "_meta": {
                "progressToken": i,
                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
                "io.modelcontextprotocol/clientInfo": {"name": "r", "version": "0"},
                "io.modelcontextprotocol/clientCapabilities": {}}}}

async def main():
    async with inner.router.lifespan_context(inner):
        async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://localhost") as c:
            for i in range(1, 6):
                r = await c.post("/mcp", json=body(i), headers={
                    "accept": "application/json, text/event-stream",
                    "mcp-protocol-version": "2026-07-28",
                    "mcp-method": "tools/call", "mcp-name": "slow"})
                print(i, len(r.headers.get_list("x-correlation-id")))

asyncio.run(main())

Output: 1 1, 2 2, 3 3, 4 4, 5 5.

I have a fix ready (list(_SSE_HEADERS) plus a regression test in tests/server/test_streamable_http_modern.py that fails before and passes after) and would be glad to open the PR if you assign this to me. Open PR #3192 carries the same line, in case that's the better place for it.

AI disclosure: I used Claude Code to find the line, write the repro, and draft this issue. I hit the bug in production and have checked the repro and the fix myself.

Python & MCP Python SDK

Python 3.13, mcp 2.3.0 (also present on main at 754254c)

Activity

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    on Oct 13, 2026
  2. er-s-an commented on Oct 14, 2026

    @er-s-an

    Confirmed on current main in src/mcp/server/_streamable_http_modern.py: handle_modern_request passes the module-level _SSE_HEADERS list into http.response.start. ASGI middleware that does message["headers"].append(...) therefore mutates the shared list, so later SSE responses accumulate earlier middleware headers.

    A copy at send time (list(_SSE_HEADERS) / [*_SSE_HEADERS]) would isolate each response without changing the header values themselves. Happy to help with a regression that appends a unique header via middleware and asserts it does not appear on a subsequent SSE response, if that would be useful once this is queued.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingspec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions