Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ assigned to their author are closed automatically.
- Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test
- For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()`
- Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts)
Comment thread
Kludex marked this conversation as resolved.
- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs
- Pytest is configured with `filterwarnings = ["error"]`, so warnings fail
tests. Don't silence warnings from your own code; fix the underlying cause.
Scoped `ignore::` entries for upstream libraries are acceptable in
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/_streamable_http_modern.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
# First notification arrived, or the deferral window elapsed: commit
# `text/event-stream` and start pinging so a proxy idle-read timeout
# cannot close the stream (which on this path cancels the handler).
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS})
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": list(_SSE_HEADERS)})
while not done:
await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True})
event = None
Expand Down
32 changes: 32 additions & 0 deletions tests/server/test_streamable_http_modern.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,38 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
assert events[2]["result"]["tools"] == []


async def test_sse_mode_headers_are_not_shared_across_responses() -> None:
"""SSE mode: each response gets its own header list, so middleware appending in place
cannot leak headers into later responses."""

async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
await ctx.session.report_progress(1.0)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")

server = Server("test", on_list_tools=list_tools)

async def app(scope: Scope, receive: Receive, send: Send) -> None:
async def append_header(message: Message) -> None:
if message["type"] == "http.response.start":
message["headers"].append((b"x-id", b"abc"))
await send(message)

async with server.lifespan(server) as lifespan_state:
await handle_modern_request(server, None, False, lifespan_state, scope, receive, append_header)

async with httpx2.AsyncClient(
transport=StreamingASGITransport(app),
base_url="http://testserver",
headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, "accept": "application/json, text/event-stream"},
) as http:
for _ in range(2):
response = await http.post(
"/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"}
)
Comment thread
Kludex marked this conversation as resolved.
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
assert response.headers.get_list("x-id") == ["abc"]


@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
Expand Down
Loading