Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion deploy/docker/mcp_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────
Expand Down
60 changes: 60 additions & 0 deletions deploy/docker/tests/test_security_mcp_sse_methods.py
Original file line number Diff line number Diff line change
@@ -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)."
)