Skip to content

fix(docker): restrict MCP SSE route to GET, fixing client connect hangs (#2120) - #2153

Open
chelsealong wants to merge 1 commit into
unclecode:mainfrom
chelsealong:fix/mcp-sse-post-hang-clean
Open

fix(docker): restrict MCP SSE route to GET, fixing client connect hangs (#2120)#2153
chelsealong wants to merge 1 commit into
unclecode:mainfrom
chelsealong:fix/mcp-sse-post-hang-clean

Conversation

@chelsealong

Copy link
Copy Markdown

Summary

Fixes #2120 — "A timeout occurs when attempting to connect via MCP from LM Studio."

deploy/docker/mcp_bridge.py mounts the MCP SSE endpoint like this:

app.routes.append(Route(f"{base}/sse", endpoint=_MCPSseApp()))

_MCPSseApp is a callable class (used deliberately, per the comment above it,
to get a raw ASGI app instead of Starlette's request_response() wrapper —
see #1594/#1850). But Starlette's Route.__init__ only defaults methods to
["GET"] when the endpoint is a plain function/method:

if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
    self.app = request_response(endpoint)
    if methods is None:
        methods = ["GET"]
else:
    self.app = endpoint   # <-- our case: methods stays None
...
if methods is None:
    self.methods = None   # None means "match any method"

With self.methods = None, Route.matches() accepts every HTTP verb on
/mcp/sse, not just GET. A client that POSTs to that URL — which is exactly
what happens when an MCP client probes for the newer Streamable HTTP
transport before falling back to the legacy two-endpoint SSE transport the
URL name implies — gets routed straight into sse.connect_sse(). That opens
an SSE stream and waits for a POST to the (different) /mcp/messages/
endpoint that will never arrive from a client that already gave up on this
URL, so the original POST just hangs. There is no error, no fast 404/405 —
the connection sits open until the client's own timeout fires, which matches
the reported symptom exactly ("no response ... times out after several tens
of seconds").

The fix is a one-line addition of the methods=["GET"] argument, so the
route only ever matches GET/HEAD, and any other verb gets a normal fast 405
Allow: GET, HEAD response instead of being silently absorbed.

I ruled out the currently-open lookalike PRs before writing this — none of
them touch mcp_bridge.py, the MCP transport, or Docker auth (#2113 bounds
Playwright evaluate()/page.content() calls, #2142 is an egress-proxy
feature, #1923 is a dispatcher-level arun_many watchdog, #2005 is a docs-only
example) — and confirmed no commit on develop since the issue was filed
touches this file either.

Root cause verification

Reproduced against the actual mcp_bridge.attach_mcp() + AuthGateMiddleware

  • security-headers middleware stack (installed the pinned mcp, fastapi,
    sse-starlette versions from deploy/docker/requirements.txt and booted a
    minimal app):
POST /mcp/sse  (before fix)  -> httpx.ConnectTimeout / never returns
POST /mcp/sse  (after fix)   -> 405 Method Not Allowed, Allow: GET, HEAD, in ~70ms
GET  /mcp/sse  (before/after)-> unchanged: SSE handshake still completes normally
                                 (endpoint event -> POST initialize -> 202 -> SSE
                                 "message" event with the initialize result)

List of files changed and why

  • deploy/docker/mcp_bridge.py — pass methods=["GET"] explicitly to the
    /mcp/sse Route() call.
  • deploy/docker/tests/test_security_mcp_sse_methods.py — new regression
    test (named test_security_* to match the existing
    pytest deploy/docker/tests/test_security_*.py CI glob in
    .github/workflows/security.yml). Boots the real app via the stock_client
    fixture from conftest.py, authenticates past AuthGateMiddleware with a
    real JWT (an unauthenticated request would get a fast 401 regardless of
    methods, which wouldn't exercise this bug), and POSTs to /mcp/sse from a
    daemon thread with a 5s wall-clock bound — so on the buggy code the test
    fails after 5s instead of hanging the whole suite forever.

How Has This Been Tested?

Confirmed the test fails without the fix and passes with it (reverted only
the source change locally, restored afterward):

$ pytest deploy/docker/tests/test_security_mcp_sse_methods.py -v   # fix reverted
FAILED ... - Failed: POST /mcp/sse did not return within 5s — it was routed
into the SSE handshake instead of being rejected with 405, so the connection
hangs until the client's own timeout.
1 failed in 6.76s

$ pytest deploy/docker/tests/test_security_mcp_sse_methods.py -v   # fix restored
PASSED
1 passed in 1.63s

Full existing security suite, unaffected:

$ pytest deploy/docker/tests/test_security_*.py -q
317 passed, 1 xfailed in 2.97s

(Installed via pip install -e . + pip install -r deploy/docker/requirements.txt -r deploy/docker/tests/requirements.txt pytest pytest-asyncio, matching
.github/workflows/security.yml.)

ruff check and black --check are clean on the new test file. (Pre-existing
mcp_bridge.py has long-standing, unrelated ruff findings — noted in #2113 as
well — that this PR does not touch beyond the one added line.)

AI assistance disclosure

This PR was prepared with AI assistance (root-cause investigation, fix, and
test authored by an AI coding agent), then verified by running the actual
pinned dependency versions locally as shown above.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • New and existing unit tests pass locally with my changes
  • I have added/updated unit tests that prove my fix is effective
  • Documentation updated (no user-facing behavior/docs change needed — this
    only fixes a routing gap that made bad requests hang instead of erroring)

Route(f"{base}/sse", endpoint=_MCPSseApp()) left `methods=None` because
Starlette only defaults to ["GET"] for function/method endpoints, not
class-based ASGI ones. That let *any* verb match the route: a client
that POSTs to probe for the newer Streamable HTTP transport before
falling back to legacy SSE (as several MCP clients do) got silently
routed into the SSE handshake instead of a fast 405, and the request
just sat open until the client's own timeout fired.

Fixes unclecode#2120
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.

[Bug]: A timeout occurs when attempting to connect via MCP from LM Studio.

1 participant