Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928)
This commit is contained in:
parent
44724284b3
commit
2397319a68
3 changed files with 2256 additions and 1088 deletions
|
|
@ -4,7 +4,8 @@ from pydantic import FileUrl
|
|||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.types import ListRootsResult, Root, TextContent
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.types import INVALID_REQUEST, ListRootsResult, Root, TextContent
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -37,10 +38,10 @@ async def test_list_roots_callback():
|
|||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "true"
|
||||
|
||||
# Test without list_roots callback
|
||||
# Without a list_roots callback the client responds with an MCPError, which the
|
||||
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
|
||||
# error rather than wrapping it as an isError result.
|
||||
async with Client(server) as client:
|
||||
# Make a request to trigger sampling callback
|
||||
result = await client.call_tool("test_list_roots", {"message": "test message"})
|
||||
assert result.is_error is True
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported"
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("test_list_roots", {"message": "test message"})
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
|
|
|||
|
|
@ -201,3 +201,62 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
|
|||
|
||||
with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch
|
||||
await session.list_tools()
|
||||
|
||||
|
||||
def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette:
|
||||
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests."""
|
||||
|
||||
async def handle_mcp_request(request: Request) -> Response:
|
||||
data = json.loads(await request.body())
|
||||
if data.get("method") == "initialize":
|
||||
return _init_json_response(data)
|
||||
if "id" not in data:
|
||||
return Response(status_code=202)
|
||||
return Response(content=body, status_code=status, media_type="application/json")
|
||||
|
||||
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
|
||||
|
||||
|
||||
async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id() -> None:
|
||||
"""SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
|
||||
server set ``id: null`` — the client rewraps it under the pending request's id, so
|
||||
the awaiting call resolves with the server's error code instead of the generic fallback."""
|
||||
body = json.dumps(
|
||||
{"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}}
|
||||
).encode()
|
||||
app = _create_non_2xx_json_body_app(400, body)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
|
||||
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
await session.initialize()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await session.list_tools()
|
||||
assert exc.value.error.code == types.METHOD_NOT_FOUND
|
||||
|
||||
|
||||
async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result() -> None:
|
||||
"""SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
|
||||
error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
|
||||
treated as the request's reply."""
|
||||
app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}')
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
|
||||
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
await session.initialize()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await session.list_tools()
|
||||
assert exc.value.error.code == types.INTERNAL_ERROR
|
||||
|
||||
|
||||
async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json() -> None:
|
||||
"""SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
|
||||
and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
|
||||
pending request — the parse failure never propagates."""
|
||||
app = _create_non_2xx_json_body_app(404, b"not valid json{{{")
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
|
||||
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
await session.initialize()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await session.list_tools()
|
||||
assert exc.value.error.code == types.INVALID_REQUEST
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import pytest
|
|||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.types import (
|
||||
INVALID_REQUEST,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
CreateMessageResultWithTools,
|
||||
|
|
@ -47,13 +49,13 @@ async def test_sampling_callback():
|
|||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "true"
|
||||
|
||||
# Test without sampling callback
|
||||
# Without a sampling callback the client responds with an MCPError, which the
|
||||
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
|
||||
# error rather than wrapping it as an isError result.
|
||||
async with Client(server) as client:
|
||||
# Make a request to trigger sampling callback
|
||||
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
|
||||
assert result.is_error is True
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported"
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("test_sampling", {"message": "Test message for sampling"})
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
Loading…
Reference in a new issue