Skip to content

Answer HEAD on the SSE endpoint with GET's headers instead of opening a stream - #3759

Open
Kludex wants to merge 5 commits into
mainfrom
sse-head-requests
Open

Kludex wants to merge 5 commits 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 at once with 200 and the headers a GET gets (Content-Type: text/event-stream, Cache-Control: no-store, X-Accel-Buffering: no), without a body and 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.

SseServerTransport gains a public head_response(request), which runs the transport's own Host/Origin validation (so DNS-rebinding protection still refuses a disallowed HEAD with 421/403) and otherwise returns the headers a GET gets, with no body. Both routes in sse_app() go through one sse_endpoint that returns it before connect_sse(). With auth configured the route is RequireAuthMiddleware(request_response(sse_endpoint)), so authentication runs first and an unauthenticated HEAD keeps getting its 401 (what OAuth-detection probes look for). The module docstring example, which downstream servers copy, calls the same method, so servers built directly on the transport get the fix and the validation without duplicating either.

This follows RFC 9110: servers must support HEAD wherever they support GET (§9.1), and should send the same header fields without content (§9.3.2). Starlette's automatic Content-Length: 0 is dropped, since a HEAD response must not carry one that differs from what the GET would send (§8.6), and an SSE stream has no length.

How Has This Been Tested?

  • test_head_on_the_sse_endpoint_is_answered_with_headers_only drives the real MCPServer.sse_app() in process and snapshots the status, full header set and empty body. Without the fix it times out, reproducing the hang.
  • test_head_on_the_sse_endpoint_is_subject_to_dns_rebinding_protection sends HEAD with a disallowed Host and expects 421.
  • 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 its headers instead of hanging. GET /sse is unchanged. SseServerTransport.head_response() is a new public method.

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 GET path of sse_app() stays excluded from coverage, as before, and the auth branch is not exercised by a test. Going through request_response means the auth route now also sends the trailing Response() after a GET stream completes, as the no-auth route always has; it only shows when the stream ends while the client is still connected.

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 thread src/mcp/server/mcpserver/server.py
Comment thread tests/interaction/transports/test_sse.py Outdated
Comment thread src/mcp/server/mcpserver/server.py Outdated
Comment thread src/mcp/server/mcpserver/server.py
Comment thread tests/interaction/transports/test_sse.py Outdated
Comment thread src/mcp/server/mcpserver/server.py
Comment thread src/mcp/server/sse.py Outdated
@Kludex Kludex changed the title Answer HEAD on the SSE endpoint with 405 instead of opening a stream Answer HEAD on the SSE endpoint with GET's headers instead of opening a stream Oct 25, 2026

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

Still open from earlier reviews (5):

  • Unresolved: 5 minor or pre-existing.

Comment thread src/mcp/server/mcpserver/server.py Outdated

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

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread src/mcp/server/mcpserver/server.py Outdated
Comment thread tests/interaction/transports/test_sse.py Outdated
Comment thread src/mcp/server/sse.py Outdated
Comment thread src/mcp/server/mcpserver/server.py Outdated

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

No new blocking issues. 1 optional suggestion (a nit or a note on pre-existing code) was found and not posted. Nothing in this review needs a push before merging.

Still open from earlier reviews (3):

  • Unresolved: 3 minor or pre-existing.

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

All reported issues were addressed across 3 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Turn on auto-fix | Re-trigger cubic

Comment thread tests/interaction/_requirements.py

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/server/mcpserver/server.py">

<violation number="1" location="src/mcp/server/mcpserver/server.py:1198">
P2: `connect_sse()` already sends the SSE response, so returning `Response()` makes `request_response` send a second response when a GET stream closes. Keep the GET handler as a raw ASGI endpoint and return a `Response` only for HEAD.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

streams[0], streams[1], self._lowlevel_server.create_initialization_options()
)
return Response()
return Response() # pragma: no cover

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: connect_sse() already sends the SSE response, so returning Response() makes request_response send a second response when a GET stream closes. Keep the GET handler as a raw ASGI endpoint and return a Response only for HEAD.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/mcp/server/mcpserver/server.py, line 1198:

<comment>`connect_sse()` already sends the SSE response, so returning `Response()` makes `request_response` send a second response when a GET stream closes. Keep the GET handler as a raw ASGI endpoint and return a `Response` only for HEAD.</comment>

<file context>
@@ -1178,21 +1177,25 @@ def sse_app(
                 await self._lowlevel_server.run(
                     streams[0], streams[1], self._lowlevel_server.create_initialization_options()
                 )
+            return Response()  # pragma: no cover
 
         # Create routes
</file context>

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

No new blocking issues. 1 optional suggestion (a nit or a note on pre-existing code) was found and not posted. Nothing in this review needs a push before merging.

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

Code review found no issues

No high-confidence issues detected in this change.

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