From a98bfd435d2454b1c8d0fdaea8c76235d763e7e0 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Wed, 19 Aug 2026 02:37:26 +0000 Subject: [PATCH] fix(docker): restrict MCP SSE route to GET, fixing client connect hangs 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 #2120 --- deploy/docker/mcp_bridge.py | 8 ++- .../tests/test_security_mcp_sse_methods.py | 60 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 deploy/docker/tests/test_security_mcp_sse_methods.py diff --git a/deploy/docker/mcp_bridge.py b/deploy/docker/mcp_bridge.py index 653bc368b..fedd2e29b 100644 --- a/deploy/docker/mcp_bridge.py +++ b/deploy/docker/mcp_bridge.py @@ -249,7 +249,13 @@ async def __call__(self, scope, receive, send): async with sse.connect_sse(scope, receive, send) as (read_stream, write_stream): await mcp.run(read_stream, write_stream, init_opts) - app.routes.append(Route(f"{base}/sse", endpoint=_MCPSseApp())) + # `methods` must be explicit: for a class-based ASGI endpoint, Starlette's + # Route otherwise leaves `self.methods = None`, which matches *any* HTTP + # method. A client probing this URL with POST (e.g. an MCP client trying + # the Streamable HTTP transport before falling back to SSE) would then be + # routed into the SSE handshake instead of getting a fast 405, and hang + # until its own client-side timeout. + app.routes.append(Route(f"{base}/sse", endpoint=_MCPSseApp(), methods=["GET"])) app.routes.append(Mount(f"{base}/messages", app=sse.handle_post_message)) # ── schema endpoint ─────────────────────────────────────── diff --git a/deploy/docker/tests/test_security_mcp_sse_methods.py b/deploy/docker/tests/test_security_mcp_sse_methods.py new file mode 100644 index 000000000..220367beb --- /dev/null +++ b/deploy/docker/tests/test_security_mcp_sse_methods.py @@ -0,0 +1,60 @@ +""" +Regression test for issue #2120: a POST to the MCP SSE endpoint must be +rejected fast (405), not silently upgraded into an SSE connection. + +`/mcp/sse` is mounted as a raw ASGI Route with a class-based endpoint +(`_MCPSseApp`). Starlette only defaults `methods` to `["GET"]` for +function/method endpoints; a class-based endpoint with no explicit +`methods=` matches *every* HTTP verb. Clients that probe an MCP endpoint +with a POST (e.g. attempting the Streamable HTTP transport before falling +back to legacy SSE) were routed straight into the SSE handshake instead of +getting a 405, and the request just hung until the client's own timeout — +matching the "connect via MCP from LM Studio" timeout report. + +The client call runs in a daemon thread with a hard wall-clock bound: on +the buggy code the request never returns, so awaiting it inline (or via a +non-daemon executor, whose shutdown() joins the thread) would hang the test +suite forever instead of failing. +""" + +import threading + +import pytest + +pytestmark = pytest.mark.cve + +from auth import create_access_token + + +def test_post_to_mcp_sse_is_rejected_fast(stock_client): + # Authenticate past the AuthGateMiddleware gate so the request actually + # reaches routing — an unauthenticated POST would get a fast 401 from the + # gate regardless of the route's `methods`, which would not exercise the + # bug this test guards against. + token = create_access_token({"sub": "user@x.com"}, scope="data") + + result = {} + + def _post(): + result["response"] = stock_client.post( + "/mcp/sse", + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + headers={"Authorization": f"Bearer {token}"}, + ) + + thread = threading.Thread(target=_post, daemon=True) + thread.start() + thread.join(timeout=5) + + if thread.is_alive(): + pytest.fail( + "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." + ) + + response = result["response"] + assert response.status_code == 405, ( + f"POST /mcp/sse returned {response.status_code}; expected 405 " + "(only GET should reach the SSE handshake)." + )