Skip to content

Commit 82d4d14

Browse files
committed
fix: reject unsupported HTTP methods early in session manager
HEAD and other unsupported HTTP methods (PUT, PATCH, OPTIONS, etc.) sent to the StreamableHTTP endpoint now return 405 Method Not Allowed immediately in StreamableHTTPSessionManager.handle_request(), before any transport or background server task is created. Previously, in stateless mode, unsupported methods would flow through the full transport lifecycle: a new StreamableHTTPServerTransport was created, a background run_stateless_server task was spawned (starting the message router), the 405 response was sent, and then terminate() closed the streams while the message router was still running. This caused a ClosedResourceError that crashed the server. Fixes #1269
1 parent 62575ed commit 82d4d14

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

src/mcp/server/streamable_http_manager.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,26 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
141141
142142
Dispatches to the appropriate handler based on stateless mode.
143143
"""
144+
# Reject unsupported HTTP methods early, before creating any
145+
# transport or session. This avoids the race condition where a
146+
# stateless transport is created, a background server task is
147+
# spawned, the 405 response is sent, and then terminate() closes
148+
# the streams while the message-router task is still running —
149+
# resulting in a ClosedResourceError that kills the server.
150+
# See: https://github.com/modelcontextprotocol/python-sdk/issues/1269
151+
request = Request(scope, receive)
152+
if request.method not in ("GET", "POST", "DELETE"):
153+
response = Response(
154+
content='{"error": "Method Not Allowed"}',
155+
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
156+
headers={
157+
"Content-Type": "application/json",
158+
"Allow": "GET, POST, DELETE",
159+
},
160+
)
161+
await response(scope, receive, send)
162+
return
163+
144164
if self._task_group is None:
145165
raise RuntimeError("Task group is not initialized. Make sure to use run().")
146166

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Test for issue #1269 - FastMCP server death on client HEAD calls.
2+
3+
HEAD (and other unsupported HTTP methods) sent to the MCP endpoint must
4+
return 405 Method Not Allowed without creating a transport or spawning
5+
background tasks. Before the fix, such requests in stateless mode caused
6+
a ClosedResourceError in the message router because the transport was
7+
terminated while the router task was still running.
8+
9+
See: https://github.com/modelcontextprotocol/python-sdk/issues/1269
10+
"""
11+
12+
import logging
13+
from collections.abc import AsyncGenerator
14+
from contextlib import asynccontextmanager
15+
16+
import anyio
17+
import httpx
18+
import pytest
19+
from starlette.applications import Starlette
20+
from starlette.routing import Mount
21+
22+
from mcp.server import Server
23+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
24+
25+
26+
def _create_app(*, stateless: bool) -> Starlette:
27+
"""Create a minimal Starlette app backed by a StreamableHTTPSessionManager."""
28+
server = Server("test_head_crash")
29+
session_manager = StreamableHTTPSessionManager(
30+
app=server,
31+
stateless=stateless,
32+
)
33+
34+
@asynccontextmanager
35+
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
36+
async with session_manager.run():
37+
yield
38+
39+
return Starlette(
40+
routes=[Mount("/", app=session_manager.handle_request)],
41+
lifespan=lifespan,
42+
)
43+
44+
45+
@pytest.mark.anyio
46+
@pytest.mark.parametrize("stateless", [True, False])
47+
async def test_head_request_returns_405_without_error(
48+
stateless: bool,
49+
caplog: pytest.LogCaptureFixture,
50+
) -> None:
51+
"""HEAD / must return 405 and must not produce ClosedResourceError."""
52+
app = _create_app(stateless=stateless)
53+
54+
with caplog.at_level(logging.ERROR):
55+
async with httpx.AsyncClient(
56+
transport=httpx.ASGITransport(app=app),
57+
base_url="http://testserver",
58+
timeout=5.0,
59+
) as client:
60+
response = await client.head("/")
61+
assert response.status_code == 405
62+
63+
# Give any lingering background tasks a chance to log errors
64+
await anyio.sleep(0.3)
65+
66+
# Ensure no ClosedResourceError was logged
67+
for record in caplog.records:
68+
msg = record.getMessage()
69+
assert "ClosedResourceError" not in msg, f"ClosedResourceError found in logs: {msg}"
70+
assert "Error in message router" not in msg, f"Message router error found in logs: {msg}"
71+
72+
73+
@pytest.mark.anyio
74+
@pytest.mark.parametrize("method", ["PUT", "PATCH", "OPTIONS"])
75+
async def test_unsupported_methods_return_405(method: str) -> None:
76+
"""Other unsupported HTTP methods also return 405 without crashing."""
77+
app = _create_app(stateless=True)
78+
79+
async with httpx.AsyncClient(
80+
transport=httpx.ASGITransport(app=app),
81+
base_url="http://testserver",
82+
timeout=5.0,
83+
) as client:
84+
response = await client.request(method, "/")
85+
assert response.status_code == 405
86+
assert "GET" in response.headers.get("allow", "")
87+
assert "POST" in response.headers.get("allow", "")
88+
assert "DELETE" in response.headers.get("allow", "")

0 commit comments

Comments
 (0)