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