Skip to content

Commit b5ddbf4

Browse files
Update httpx/mcp.types references to match renamed httpx2/mcp_types on main
1 parent 34dc68a commit b5ddbf4

1 file changed

Lines changed: 77 additions & 10 deletions

File tree

tests/client/test_notification_response.py

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,18 @@
66

77
import json
88

9-
import httpx
9+
import httpx2
10+
import mcp_types as types
1011
import pytest
12+
from mcp_types import RootsListChangedNotification
1113
from starlette.applications import Starlette
1214
from starlette.requests import Request
1315
from starlette.responses import JSONResponse, Response
1416
from starlette.routing import Route
1517

16-
from mcp import ClientSession, MCPError, types
18+
from mcp import ClientSession, MCPError
1719
from mcp.client.streamable_http import streamable_http_client
1820
from mcp.shared.session import RequestResponder
19-
from mcp.types import RootsListChangedNotification
2021

2122
pytestmark = pytest.mark.anyio
2223

@@ -103,7 +104,7 @@ async def message_handler( # pragma: no cover
103104
if isinstance(message, Exception):
104105
returned_exception = message
105106

106-
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_non_sdk_server_app())) as client:
107+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_non_sdk_server_app())) as client:
107108
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
108109
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
109110
await session.initialize()
@@ -122,7 +123,7 @@ async def test_unexpected_content_type_sends_jsonrpc_error() -> None:
122123
the client should send a JSONRPCError so the pending request resolves immediately
123124
instead of hanging until timeout.
124125
"""
125-
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_unexpected_content_type_app())) as client:
126+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_unexpected_content_type_app())) as client:
126127
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
127128
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
128129
await session.initialize()
@@ -138,7 +139,7 @@ async def test_initialize_does_not_hang_on_unexpected_content_type() -> None:
138139
other than application/json or text/event-stream in response to the initialize request,
139140
the client must raise MCPError right away instead of hanging forever.
140141
"""
141-
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_plain_text_server_app())) as client:
142+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_plain_text_server_app())) as client:
142143
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
143144
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
144145
with pytest.raises(MCPError, match="Unexpected content type: text/plain"): # pragma: no branch
@@ -170,9 +171,9 @@ async def test_http_error_status_sends_jsonrpc_error() -> None:
170171
171172
When a server returns a non-2xx status code (e.g. 500), the client should
172173
send a JSONRPCError so the pending request resolves immediately instead of
173-
raising an unhandled httpx.HTTPStatusError that causes the caller to hang.
174+
raising an unhandled httpx2.HTTPStatusError that causes the caller to hang.
174175
"""
175-
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_http_error_app(500))) as client:
176+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_http_error_app(500))) as client:
176177
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
177178
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
178179
await session.initialize()
@@ -188,7 +189,7 @@ async def test_http_error_on_notification_does_not_hang() -> None:
188189
unblock, so the client should just return without sending a JSONRPCError.
189190
"""
190191
app = _create_http_error_app(500, error_on_notifications=True)
191-
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
192+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client:
192193
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
193194
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
194195
await session.initialize()
@@ -223,10 +224,76 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
223224
should send a JSONRPCError so the pending request resolves immediately
224225
instead of hanging until timeout.
225226
"""
226-
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_invalid_json_response_app())) as client:
227+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_invalid_json_response_app())) as client:
227228
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
228229
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
229230
await session.initialize()
230231

231232
with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch
232233
await session.list_tools()
234+
235+
236+
def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette:
237+
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests.
238+
239+
The initialize response carries an ``mcp-session-id`` so the client treats subsequent
240+
requests as part of an established session (needed for the 404 → session-terminated mapping).
241+
"""
242+
243+
async def handle_mcp_request(request: Request) -> Response:
244+
data = json.loads(await request.body())
245+
if data.get("method") == "initialize":
246+
return JSONResponse(
247+
{"jsonrpc": "2.0", "id": data["id"], "result": INIT_RESPONSE},
248+
headers={"mcp-session-id": "test-session"},
249+
)
250+
if "id" not in data:
251+
return Response(status_code=202)
252+
return Response(content=body, status_code=status, media_type="application/json")
253+
254+
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
255+
256+
257+
async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id() -> None:
258+
"""SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
259+
server set ``id: null`` — the client rewraps it under the pending request's id, so
260+
the awaiting call resolves with the server's error code instead of the generic fallback."""
261+
body = json.dumps(
262+
{"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}}
263+
).encode()
264+
app = _create_non_2xx_json_body_app(400, body)
265+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client:
266+
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
267+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
268+
await session.initialize()
269+
with pytest.raises(MCPError) as exc:
270+
await session.list_tools()
271+
assert exc.value.error.code == types.METHOD_NOT_FOUND
272+
273+
274+
async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result() -> None:
275+
"""SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
276+
error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
277+
treated as the request's reply."""
278+
app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}')
279+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client:
280+
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
281+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
282+
await session.initialize()
283+
with pytest.raises(MCPError) as exc:
284+
await session.list_tools()
285+
assert exc.value.error.code == types.INTERNAL_ERROR
286+
287+
288+
async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json() -> None:
289+
"""SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
290+
and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
291+
pending request — the parse failure never propagates."""
292+
app = _create_non_2xx_json_body_app(404, b"not valid json{{{")
293+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client:
294+
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
295+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
296+
await session.initialize()
297+
with pytest.raises(MCPError) as exc:
298+
await session.list_tools()
299+
assert exc.value.error.code == types.INVALID_REQUEST

0 commit comments

Comments
 (0)