Skip to content

Answer HEAD on the SSE endpoint with 405 instead of opening a stream - #3759

Open
Kludex wants to merge 1 commit into
mainfrom
sse-head-requests
Open

Kludex wants to merge 1 commit into
mainfrom
sse-head-requests

Conversation

@Kludex

@Kludex Kludex commented Oct 24, 2026

Copy link
Copy Markdown
Member

Fixes #3736

MCPServer.sse_app() now answers HEAD on the SSE endpoint with 405 and Allow: GET, without opening a session or a stream.

Motivation and Context

Starlette adds HEAD to every GET route, so HEAD /sse ran the full SSE handler: connect_sse() opened a session and EventSourceResponse started a stream that never ends. The server sent the headers, discarded the body, and never completed the response. A keep-alive client or reverse proxy (nginx returns a HEAD's upstream connection to its pool once the headers arrive) then hung on its next request, and the handler, its session and the socket stayed alive. LibreChat sends HEAD to every MCP server at startup, which is how this showed up in production.

Both routes in sse_app() now go through one sse_endpoint, which returns the 405 before connect_sse(). With auth configured the route is RequireAuthMiddleware(request_response(sse_endpoint)), so authentication still runs first and an unauthenticated HEAD keeps getting its 401 (what OAuth-detection probes look for). The SseServerTransport module docstring example, which downstream servers copy, gets the same guard.

405 rather than a headers-only 200 matches how the Streamable HTTP transport and the SSE message endpoint already reject other methods.

How Has This Been Tested?

  • test_head_on_the_sse_endpoint_is_answered_without_opening_a_stream drives the real MCPServer.sse_app() in process and expects (405, "GET"). Without the fix it times out, reproducing the hang.
  • Registered under a new transport:sse:head requirement (issue:#3736).
  • Full suite passes; coverage 100%, strict-no-cover and pyright clean.

Breaking Changes

HEAD /sse returns 405 instead of hanging. GET /sse is unchanged.

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 am assigned to the linked issue (or it is labeled help wanted, or I'm a maintainer)
  • I have disclosed any AI assistance and can explain the change in my own words
  • 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 auth branch of sse_app() is excluded from coverage, as before, so the request_response wrapping there is not exercised by a test. It also means the trailing Response() after a GET stream ends is now sent on that path too, as the no-auth path already did; by then the client has disconnected.

The generic part (EventSourceResponse streaming forever on HEAD) is tracked upstream in sysid/sse-starlette#218.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-24T20:33:16.547436Z 4b82769 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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.

No issues found across 4 files

Turn on auto-fix | Re-trigger cubic

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment on lines +1244 to +1246
endpoint=RequireAuthMiddleware(
request_response(sse_endpoint), required_scopes, resource_metadata_url
),

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.

🟡 (optional) Auth-enabled SSE servers now send a second response after each GET stream ends, which the base branch never did on that route. request_response(sse_endpoint) at server.py:1245 sends the trailing Response() from server.py:1186 on the same send the SSE stream used. An ASGI server that still accepts messages then raises on the extra http.response.start, for example when shutdown ends the stream. Fix: once a stream has run, send nothing more on either route (for example return a response whose call is a no-op), while HEAD still gets its 405. The no-auth route already does this (pre-existing there).

Why this was flagged

The trigger is a GET on the SSE path of MCPServer.sse_app() with a token verifier configured, whose stream ends while the ASGI server still processes send (server-side stream end such as shutdown, or a server that does not ignore sends after disconnect). sse.connect_sse runs EventSourceResponse(...)(scope, receive, send) at src/mcp/server/sse.py:205, so the response is already started on that send. sse_endpoint then returns Response() at server.py:1186 and request_response (server.py:1245) calls it with the same send, emitting a second http.response.start. On the base, RequireAuthMiddleware(handle_sse, ...) awaited a raw ASGI function whose Response() return value was discarded, so nothing was sent after the stream. Operators get an exception and traceback per affected connection instead of a quiet return. RequireAuthMiddleware.__call__ (bearer_auth.py:133) only forwards and does not guard this.

Verification: On the base, RequireAuthMiddleware.__call__ ends with await self.app(scope, receive, send) (src/mcp/server/auth/middleware/bearer_auth.py:133) and discards the return value, so the trailing return Response() was never sent on that route. So return Response() at server.py:1186 now emits a second http.response.start on the auth route after every GET stream.

Finding ID: f-8ee46866

Comment on lines +95 to +104
@requirement("transport:sse:head")
async def test_head_on_the_sse_endpoint_is_answered_without_opening_a_stream() -> None:
"""`MCPServer.sse_app()` answers HEAD at once instead of streaming forever."""
app = MCPServer("legacy").sse_app(
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
)
async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
with anyio.fail_after(5):
response = await http.head("/sse")
assert (response.status_code, response.headers["allow"]) == snapshot((405, "GET"))

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.

🟡 nit (optional): The new requirement claims more than its test proves, so a regression that opens a session before answering HEAD would still pass CI. transport:sse:head says HEAD is answered "without opening a session or a stream, so the connection stays usable" (tests/interaction/_requirements.py:2711). The test at test_sse.py:104 asserts only (405, "GET"). Fix: make claim and assertions match, either by narrowing the requirement text and test name to the 405/Allow outcome, or by asserting the rest, e.g. a second request on the same client inside anyio.fail_after(5). [also at: tests/interaction/transports/test_sse.py:97 - nit: The new test's docstring does not say where the asserted behaviour comes from, so a later failure cannot be triaged from the test alone.]

Why this was flagged

The trigger is any later change that keeps the 405 but answers it after sse.connect_sse(...) has been entered, reached through MCPServer.sse_app() at src/mcp/server/mcpserver/server.py:1174-1186. The test at tests/interaction/transports/test_sse.py:98-104 builds the app through MCPServer("legacy").sse_app(...), so it holds no reference to the SseServerTransport and cannot inspect _read_stream_writers the way the first test in the file does at test_sse.py:58-61. It makes one http.head("/sse") call and snapshots status and allow only; nothing checks that no session was registered or that a following request succeeds. The requirement text at tests/interaction/_requirements.py:2711-2712 and the test name ..._is_answered_without_opening_a_stream therefore record coverage the suite does not have. The base branch has no such requirement entry, so this is new with the PR. anyio.fail_after(5) only bounds a hang; it does not prove the session or connection claims. This is what the hollow-proof check in .claude/skills/test-quality/SKILL.md guards against.

Verification: nit — triggering condition: a later change that registers a session before answering HEAD with 405 (no such code exists today, so nothing breaks at merge). tests/interaction/transports/test_sse.py:95-104 links the test to that requirement, and its only assertion is (response.status_code, response.headers["allow"]) == snapshot((405, "GET")) under anyio.fail_after(5). "Without opening a session" has no assertion.

Finding ID: f-53e8b984

Comment on lines +1175 to +1177
if request.method == "HEAD":
# The SSE stream never ends, so a HEAD answered with it would never complete.
return Response(status_code=405, headers={"Allow": "GET"})

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.

🟡 nit (optional): Users now get 405 with Allow: GET from HEAD on the SSE endpoint, but no page under docs/ says so. The behaviour change is at server.py:1177, and the only prose updated is the module docstring example in src/mcp/server/sse.py:18-20. Fix: add a sentence to the page that covers sse_app() (docs/run/asgi.md:36 is its only mention outside docs/migration.md) stating that HEAD is answered 405, and that a hand-rolled connect_sse route needs the same guard. [also at: src/mcp/server/mcpserver/server.py:1177 - nit: AGENTS.md (Documentation) asks that a change to user-visible behaviour update the relevant page under docs/ in the same PR.; src/mcp/server/mcpserver/server.py:1175 - nit: The new 405 answer to HEAD on the SSE endpoint is user-visible, but no page under docs/ is updated. Fix: add a sentence where the SSE server transport is covered (docs/run/index.md:15-18, or the health-check bullet at docs/run/deploy.md:178) saying HEAD on the SSE path is answered 405 with Allow: GET, so probes belong on a custom route.]

Why this was flagged

The trigger is the diff itself: sse_endpoint at src/mcp/server/mcpserver/server.py:1175-1177 changes what HEAD /sse returns from MCPServer.sse_app() on both the auth route (server.py:1244-1246) and the no-auth route (server.py:1261). The diff touches four files and none is under docs/. A search of docs/ for HEAD or 405 finds nothing about the SSE endpoint, and sse_app() is mentioned once, at docs/run/asgi.md:36. On the base branch the same request hung, so this is a user-visible behaviour change. Servers that copied the old handle_sse example from src/mcp/server/sse.py still hang on HEAD, and the only place that tells them to add the guard is that module docstring. No safeguard applies; the repository instruction asks for the docs page to be updated in the same PR.

Verification: nit — triggered by the diff itself: it changes what HEAD on the SSE endpoint returns and touches no page under docs/. src/mcp/server/mcpserver/server.py:1175-1177 adds if request.method == "HEAD": return Response(status_code=405, headers={"Allow": "GET"}) in the shared sse_endpoint. A grep of docs/ for HEAD and 405 finds nothing about the SSE endpoint.

Finding ID: f-728c631d

Comment on lines +1178 to +1186
async with sse.connect_sse( # pragma: no cover
request.scope,
request.receive,
request._send, # type: ignore[reportPrivateUsage]
) as streams:
await self._lowlevel_server.run(
streams[0], streams[1], self._lowlevel_server.create_initialization_options()
)
return Response()
return Response() # pragma: no cover

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.

🟡 nit (optional): The GET path of MCPServer.sse_app() stays excluded from coverage, so a regression in the rewritten shared handler would pass CI unnoticed. The rewrite re-adds # pragma: no cover at server.py:1178 and server.py:1186, plus a # type: ignore at server.py:1181. Fix: cover the GET path with an in-process test that connects sse_client to MCPServer.sse_app() through StreamingASGITransport, as the new HEAD test already does for HEAD, then delete both pragmas. [also at: src/mcp/server/mcpserver/server.py:1178 - nit: AGENTS.md (Coverage) says to avoid adding # pragma: no cover / # type: ignore, and that in src/ a # pragma: no cover needs very good reasoning because it usually means a test is missing.]

Why this was flagged

The trigger is any GET to the SSE endpoint built by MCPServer.sse_app(), which now runs through the merged sse_endpoint on both the auth and no-auth routes. The handler body is marked # pragma: no cover on the async with sse.connect_sse( header at src/mcp/server/mcpserver/server.py:1178 and on return Response() at :1186, so no test executes connect_sse, the _lowlevel_server.run call, or the trailing response. The base carried the same two exclusions on the handle_sse and inner sse_endpoint definitions, so the net count is unchanged, but the diff writes them onto new lines while restructuring the code they cover. AGENTS.md says a # pragma: no cover in src/ usually means a test is missing, and the new test at tests/interaction/transports/test_sse.py:96 shows the real app can be driven in process. No safeguard applies: strict-no-cover only fails when a pragma line is executed, and no test sends GET to this app.

Verification: nit — triggered whenever the rewritten GET path of MCPServer.sse_app() regresses, since no test executes it. The pragma on the async with header at src/mcp/server/mcpserver/server.py:1178 excludes the whole block. tests/interaction/transports/test_sse.py:98 (the new test) sends only HEAD. These are relocations, not net-new suppressions.

Finding ID: f-ae798a8e



@requirement("transport:sse:head")
async def test_head_on_the_sse_endpoint_is_answered_without_opening_a_stream() -> None:

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.

🟡 nit (optional): AGENTS.md (Testing) says test files mirror the source tree and tests go in the existing file for that module. The new test exercises MCPServer.sse_app() from src/mcp/server/mcpserver/server.py, but is added to tests/interaction/transports/test_sse.py, whose other tests drive build_sse_app(Server(...)) rather than sse_app(). Fix: put the HEAD test in tests/server/mcpserver/test_server.py, next to test_sse_app_applies_the_configured_request_body_limit, keeping an interaction-suite entry only if the requirements manifest needs one.

Why this was flagged

Nothing fails at runtime; this is a layout/discoverability point only. The instruction guards being able to find a module's tests at the mirrored path. The behaviour under test lives in src/mcp/server/mcpserver/server.py (the only non-docstring source change), whose mirrored file tests/server/mcpserver/test_server.py exists and already holds an in-process HTTP test of sse_app() (line 1971). The new test instead lands in the interaction suite's SSE file, whose module docstring scopes it to the SSE wiring driven through the suite's own build_sse_app helper; it is the only test there that constructs MCPServer(...).sse_app() directly. Counterweight for the maintainer: tests/interaction/ is an established cross-cutting suite with a requirements manifest (the PR registers transport:sse:head there), it does not mirror the source tree by design, and the test was added to an existing file rather than a new one, so the author may reasonably consider this the right home.

Verification: Instruction: root AGENTS.md (base a525a3d), Testing section: "Test files mirror the source tree: src/mcp/client/stdio.py → tests/client/test_stdio.py. Add tests to the existing file for that module." It is a placement convention with no carve-out for any suite, and it binds the whole repo. Code: the diff adds test_head_on_the_sse_endpoint_is_answered_without_opening_a_stream at tests/interaction/transports/test_sse.py:95-104, which builds MCPServer("legacy").sse_app(...) and asserts (405, "GET").

Finding ID: f-e12e5ad9


async with sse.connect_sse(scope, receive, send) as streams:
async def sse_endpoint(request: Request) -> Response:
if request.method == "HEAD":

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.

🟡 nit (optional): AGENTS.md (Branching Model) says any change to an existing API's observable behaviour on the v2 line is a design decision a maintainer makes explicitly and should generally be avoided. MCPServer.sse_app() now answers HEAD on the SSE path with 405 + Allow: GET instead of running the SSE handler, and the auth route now goes through request_response(sse_endpoint), so the trailing Response() is sent on that path too. Fix: get an explicit maintainer sign-off on the new HEAD contract (405 vs a headers-only 200) recorded in the PR, and mark it as a behaviour change rather than non-breaking.

Why this was flagged

Nothing fails at runtime. The instruction guards the 2.x compatibility contract: observable behaviour of a released public API should not change without an explicit maintainer decision. Here the changed behaviour is HEAD on the sse_app() SSE endpoint: before, the request ran the SSE handler (headers sent, response never completed); after, it gets 405 with Allow: GET. A client or probe that treated a 200 header response to HEAD /sse as 'server is up' would now see 405. The PR description itself lists this under 'Breaking Changes' ('HEAD /sse returns 405 instead of hanging') while ticking only 'Bug fix (non-breaking change)'. Sizing: this is a bug fix replacing a hang that reportedly broke keep-alive clients and proxies (LibreChat, nginx), so the old behaviour is unlikely to be relied on; the PR author is shown as Kludex and the checklist line mentions being a maintainer, which I cannot verify from the checkout.

Verification: Instruction: root AGENTS.md at base a525a3d, "Branching Model", second bullet: "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour (including ones softened by a @ deprecated shim) is a design decision a maintainer makes explicitly, and should generally be avoided." It is present essentially as quoted and binds src/.

Finding ID: f-dfcf6cdc

Comment thread src/mcp/server/sse.py
Comment on lines +18 to +20
# Starlette also routes HEAD here; the stream never ends, so a HEAD would never complete
if request.method == "HEAD":
return Response(status_code=405, headers={"Allow": "GET"})

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.

🟣 pre-existing, not blocking: pre-existing: Servers built directly on SseServerTransport still open a never-ending stream on HEAD after this merges; only MCPServer.sse_app() is fixed. connect_sse at sse.py:141 checks scope["type"] but never the method, and the documented Route("/sse", ..., methods=["GET"]) also routes HEAD. The new guard at sse.py:19-20 is docstring text, so handlers already copied from it keep hanging. Fix: reject HEAD inside connect_sse before a session is created, as it already does for a failed Host check (send the 405, then raise), so every caller is covered without editing its handler.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

The trigger is a HEAD request to a low-level server whose handler follows the module example: Route("/sse", endpoint=handle_sse, methods=["GET"]) at src/mcp/server/sse.py:12 with a handler that calls sse.connect_sse(request.scope, request.receive, request._send). Starlette adds HEAD to a GET route, so the request reaches connect_sse at src/mcp/server/sse.py:141. That function only checks scope["type"] != "http" at sse.py:142 and the Host/Origin rules at sse.py:148, then registers a session at sse.py:162 and starts EventSourceResponse at sse.py:205, which never completes for HEAD. The diff only edits the docstring at sse.py:18-20, so no existing downstream handler changes behaviour: a keep-alive client or reverse proxy still hangs on its next request, exactly as on the base branch. The sibling entry point already enforces its method itself: handle_post_message at sse.py:228 answers non-POST with 405.

Verification: pre-existing — triggers when a HEAD request reaches a server that calls SseServerTransport.connect_sse from its own handler without a method check. src/mcp/server/sse.py:141-151: connect_sse rejects only scope["type"] != "http" (line 142) and a failed Host/Origin validation (lines 148-151). The only change in sse.py is docstring text at lines 18-20, so it has no runtime effect.

Finding ID: f-6f2cce2e

This branch has not been deployed

No deployments
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.

SSE transport: HEAD on the SSE endpoint never completes, hanging keep-alive connections and leaking the handler

1 participant