Skip to content

Commit 9c6fd15

Browse files
SDK Parity: Avoid Parsing Server Response for non-JsonRPCMessage Requests (#1290)
1 parent eaf7cf4 commit 9c6fd15

File tree

2 files changed

+163
-11
lines changed

2 files changed

+163
-11
lines changed

src/mcp/client/streamable_http.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -279,17 +279,19 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
279279
if is_initialization:
280280
self._maybe_extract_session_id_from_response(response)
281281

282-
content_type = response.headers.get(CONTENT_TYPE, "").lower()
283-
284-
if content_type.startswith(JSON):
285-
await self._handle_json_response(response, ctx.read_stream_writer, is_initialization)
286-
elif content_type.startswith(SSE):
287-
await self._handle_sse_response(response, ctx, is_initialization)
288-
else:
289-
await self._handle_unexpected_content_type(
290-
content_type,
291-
ctx.read_stream_writer,
292-
)
282+
# Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications:
283+
# The server MUST NOT send a response to notifications.
284+
if isinstance(message.root, JSONRPCRequest):
285+
content_type = response.headers.get(CONTENT_TYPE, "").lower()
286+
if content_type.startswith(JSON):
287+
await self._handle_json_response(response, ctx.read_stream_writer, is_initialization)
288+
elif content_type.startswith(SSE):
289+
await self._handle_sse_response(response, ctx, is_initialization)
290+
else:
291+
await self._handle_unexpected_content_type(
292+
content_type,
293+
ctx.read_stream_writer,
294+
)
293295

294296
async def _handle_json_response(
295297
self,
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""
2+
Tests for StreamableHTTP client transport with non-SDK servers.
3+
4+
These tests verify client behavior when interacting with servers
5+
that don't follow SDK conventions.
6+
"""
7+
8+
import json
9+
import multiprocessing
10+
import socket
11+
import time
12+
from collections.abc import Generator
13+
14+
import pytest
15+
import uvicorn
16+
from starlette.applications import Starlette
17+
from starlette.requests import Request
18+
from starlette.responses import JSONResponse, Response
19+
from starlette.routing import Route
20+
21+
from mcp import ClientSession, types
22+
from mcp.client.streamable_http import streamablehttp_client
23+
from mcp.shared.session import RequestResponder
24+
from mcp.types import ClientNotification, RootsListChangedNotification
25+
26+
27+
def create_non_sdk_server_app() -> Starlette:
28+
"""Create a minimal server that doesn't follow SDK conventions."""
29+
30+
async def handle_mcp_request(request: Request) -> Response:
31+
"""Handle MCP requests with non-standard responses."""
32+
try:
33+
body = await request.body()
34+
data = json.loads(body)
35+
36+
# Handle initialize request normally
37+
if data.get("method") == "initialize":
38+
response_data = {
39+
"jsonrpc": "2.0",
40+
"id": data["id"],
41+
"result": {
42+
"serverInfo": {"name": "test-non-sdk-server", "version": "1.0.0"},
43+
"protocolVersion": "2024-11-05",
44+
"capabilities": {},
45+
},
46+
}
47+
return JSONResponse(response_data)
48+
49+
# For notifications, return 204 No Content (non-SDK behavior)
50+
if "id" not in data:
51+
return Response(status_code=204, headers={"Content-Type": "application/json"})
52+
53+
# Default response for other requests
54+
return JSONResponse(
55+
{"jsonrpc": "2.0", "id": data.get("id"), "error": {"code": -32601, "message": "Method not found"}}
56+
)
57+
58+
except Exception as e:
59+
return JSONResponse({"error": f"Server error: {str(e)}"}, status_code=500)
60+
61+
app = Starlette(
62+
debug=True,
63+
routes=[
64+
Route("/mcp", handle_mcp_request, methods=["POST"]),
65+
],
66+
)
67+
return app
68+
69+
70+
def run_non_sdk_server(port: int) -> None:
71+
"""Run the non-SDK server in a separate process."""
72+
app = create_non_sdk_server_app()
73+
config = uvicorn.Config(
74+
app=app,
75+
host="127.0.0.1",
76+
port=port,
77+
log_level="error", # Reduce noise in tests
78+
)
79+
server = uvicorn.Server(config=config)
80+
server.run()
81+
82+
83+
@pytest.fixture
84+
def non_sdk_server_port() -> int:
85+
"""Get an available port for the test server."""
86+
with socket.socket() as s:
87+
s.bind(("127.0.0.1", 0))
88+
return s.getsockname()[1]
89+
90+
91+
@pytest.fixture
92+
def non_sdk_server(non_sdk_server_port: int) -> Generator[None, None, None]:
93+
"""Start a non-SDK server for testing."""
94+
proc = multiprocessing.Process(target=run_non_sdk_server, kwargs={"port": non_sdk_server_port}, daemon=True)
95+
proc.start()
96+
97+
# Wait for server to be ready
98+
start_time = time.time()
99+
while time.time() - start_time < 10:
100+
try:
101+
with socket.create_connection(("127.0.0.1", non_sdk_server_port), timeout=0.1):
102+
break
103+
except (TimeoutError, ConnectionRefusedError):
104+
time.sleep(0.1)
105+
else:
106+
proc.kill()
107+
proc.join(timeout=2)
108+
pytest.fail("Server failed to start within 10 seconds")
109+
110+
yield
111+
112+
proc.kill()
113+
proc.join(timeout=2)
114+
115+
116+
@pytest.mark.anyio
117+
async def test_non_compliant_notification_response(non_sdk_server: None, non_sdk_server_port: int) -> None:
118+
"""
119+
This test verifies that the client ignores unexpected responses to notifications: the spec states they should
120+
either be 202 + no response body, or 4xx + optional error body
121+
(https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server),
122+
but some servers wrongly return other 2xx codes (e.g. 204). For now we simply ignore unexpected responses
123+
(aligning behaviour w/ the TS SDK).
124+
"""
125+
server_url = f"http://127.0.0.1:{non_sdk_server_port}/mcp"
126+
returned_exception = None
127+
128+
async def message_handler(
129+
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
130+
):
131+
nonlocal returned_exception
132+
if isinstance(message, Exception):
133+
returned_exception = message
134+
135+
async with streamablehttp_client(server_url) as (read_stream, write_stream, _):
136+
async with ClientSession(
137+
read_stream,
138+
write_stream,
139+
message_handler=message_handler,
140+
) as session:
141+
# Initialize should work normally
142+
await session.initialize()
143+
144+
# The test server returns a 204 instead of the expected 202
145+
await session.send_notification(
146+
ClientNotification(RootsListChangedNotification(method="notifications/roots/list_changed"))
147+
)
148+
149+
if returned_exception:
150+
pytest.fail(f"Server encountered an exception: {returned_exception}")

0 commit comments

Comments
 (0)