Skip to content

Commit 70035d5

Browse files
committed
Return 405 for GET in stateless mode
1 parent 3d7b311 commit 70035d5

File tree

2 files changed

+60
-1
lines changed

2 files changed

+60
-1
lines changed

src/mcp/server/streamable_http.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,18 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
658658
if writer is None: # pragma: no cover
659659
raise ValueError("No read stream writer available. Ensure connect() is called first.")
660660

661+
if not self.mcp_session_id:
662+
response = self._create_error_response(
663+
"Method Not Allowed: SSE stream not supported",
664+
HTTPStatus.METHOD_NOT_ALLOWED,
665+
headers={
666+
"Content-Type": CONTENT_TYPE_JSON,
667+
"Allow": "POST",
668+
},
669+
)
670+
await response(request.scope, request.receive, send)
671+
return
672+
661673
# Validate Accept header - must include text/event-stream
662674
_, has_sse = self._check_accept_headers(request)
663675

tests/shared/test_streamable_http.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,13 +397,15 @@ def create_app(
397397
is_json_response_enabled: bool = False,
398398
event_store: EventStore | None = None,
399399
retry_interval: int | None = None,
400+
stateless: bool = False,
400401
) -> Starlette: # pragma: no cover
401402
"""Create a Starlette application for testing using the session manager.
402403
403404
Args:
404405
is_json_response_enabled: If True, use JSON responses instead of SSE streams.
405406
event_store: Optional event store for testing resumability.
406407
retry_interval: Retry interval in milliseconds for SSE polling.
408+
stateless: If True, create a stateless Streamable HTTP server.
407409
"""
408410
# Create server instance
409411
server = _create_server()
@@ -416,6 +418,7 @@ def create_app(
416418
app=server,
417419
event_store=event_store,
418420
json_response=is_json_response_enabled,
421+
stateless=stateless,
419422
security_settings=security_settings,
420423
retry_interval=retry_interval,
421424
)
@@ -437,6 +440,7 @@ def run_server(
437440
is_json_response_enabled: bool = False,
438441
event_store: EventStore | None = None,
439442
retry_interval: int | None = None,
443+
stateless: bool = False,
440444
) -> None: # pragma: no cover
441445
"""Run the test server.
442446
@@ -445,9 +449,10 @@ def run_server(
445449
is_json_response_enabled: If True, use JSON responses instead of SSE streams.
446450
event_store: Optional event store for testing resumability.
447451
retry_interval: Retry interval in milliseconds for SSE polling.
452+
stateless: If True, run the server in stateless mode.
448453
"""
449454

450-
app = create_app(is_json_response_enabled, event_store, retry_interval)
455+
app = create_app(is_json_response_enabled, event_store, retry_interval, stateless)
451456
# Configure server
452457
config = uvicorn.Config(
453458
app=app,
@@ -570,6 +575,34 @@ def json_server_url(json_server_port: int) -> str:
570575
return f"http://127.0.0.1:{json_server_port}"
571576

572577

578+
@pytest.fixture
579+
def stateless_server_port() -> int:
580+
"""Find an available port for the stateless server."""
581+
with socket.socket() as s:
582+
s.bind(("127.0.0.1", 0))
583+
return s.getsockname()[1]
584+
585+
586+
@pytest.fixture
587+
def stateless_server(stateless_server_port: int) -> Generator[None, None, None]:
588+
"""Start a server in stateless mode."""
589+
proc = multiprocessing.Process(target=run_server, kwargs={"port": stateless_server_port, "stateless": True}, daemon=True)
590+
proc.start()
591+
592+
wait_for_server(stateless_server_port)
593+
594+
yield
595+
596+
proc.kill()
597+
proc.join(timeout=2)
598+
599+
600+
@pytest.fixture
601+
def stateless_server_url(stateless_server_port: int) -> str:
602+
"""Get the URL for the stateless test server."""
603+
return f"http://127.0.0.1:{stateless_server_port}"
604+
605+
573606
# Basic request validation tests
574607
def test_accept_header_validation(basic_server: None, basic_server_url: str):
575608
"""Test that Accept header is properly validated."""
@@ -1043,6 +1076,20 @@ def test_get_validation(basic_server: None, basic_server_url: str):
10431076
assert "Not Acceptable" in response.text
10441077

10451078

1079+
def test_get_method_not_allowed_in_stateless_mode(stateless_server: None, stateless_server_url: str):
1080+
"""Test that stateless servers reject standalone GET SSE requests."""
1081+
response = requests.get(
1082+
f"{stateless_server_url}/mcp",
1083+
headers={
1084+
"Accept": "text/event-stream",
1085+
},
1086+
)
1087+
1088+
assert response.status_code == 405
1089+
assert response.headers.get("Allow") == "POST"
1090+
assert "Method Not Allowed" in response.text
1091+
1092+
10461093
# Client-specific fixtures
10471094
@pytest.fixture
10481095
async def http_client(basic_server: None, basic_server_url: str): # pragma: no cover

0 commit comments

Comments
 (0)