[v2] ClientSession runs on JSONRPCDispatcher; BaseSession removed (#2838)
This commit is contained in:
parent
cf110e3214
commit
1012d60004
6 changed files with 2886 additions and 1734 deletions
20
tests/shared/test_context_streams.py
Normal file
20
tests/shared/test_context_streams.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Tests for the contextvars-carrying memory-stream wrappers."""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp.shared._context_streams import create_context_streams
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
async def test_sync_close_closes_the_underlying_streams() -> None:
|
||||
"""The wrappers mirror anyio's memory streams: close() is the sync form of aclose()."""
|
||||
send, receive = create_context_streams[str](1)
|
||||
await send.send("queued")
|
||||
send.close()
|
||||
receive.close()
|
||||
with pytest.raises(anyio.ClosedResourceError):
|
||||
await send.send("after close")
|
||||
with pytest.raises(anyio.ClosedResourceError):
|
||||
await receive.receive()
|
||||
|
|
@ -13,11 +13,20 @@ from typing import TYPE_CHECKING, Any
|
|||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared.direct_dispatcher import DirectDispatcher, create_direct_dispatcher_pair
|
||||
from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnRequest, Outbound
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.types import INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, REQUEST_TIMEOUT, ErrorData, Tool
|
||||
from mcp.types import (
|
||||
CONNECTION_CLOSED,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
REQUEST_TIMEOUT,
|
||||
ErrorData,
|
||||
Tool,
|
||||
)
|
||||
|
||||
from .conftest import PairFactory, direct_pair
|
||||
|
||||
|
|
@ -72,6 +81,7 @@ async def running_pair(
|
|||
finally:
|
||||
tg.cancel_scope.cancel()
|
||||
finally:
|
||||
await resync_tracer()
|
||||
close()
|
||||
|
||||
|
||||
|
|
@ -228,13 +238,15 @@ async def test_ctx_message_metadata_is_none_when_transport_attaches_nothing(pair
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_request_id_exposes_inbound_id(pair_factory: PairFactory):
|
||||
"""JSON-RPC carries the wire id through; direct dispatch has none."""
|
||||
"""Every dispatcher assigns each inbound request a distinct int id; JSON-RPC carries
|
||||
the wire id through, DirectDispatcher synthesizes one (SDK-defined)."""
|
||||
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("tools/call", None)
|
||||
await client.send_raw_request("tools/call", None)
|
||||
a, b = (ctx.request_id for ctx in srec.contexts)
|
||||
assert (a is None and b is None) or (isinstance(a, int) and isinstance(b, int) and a != b)
|
||||
assert isinstance(a, int) and isinstance(b, int)
|
||||
assert a != b
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -259,13 +271,10 @@ async def test_direct_send_raw_request_issued_before_peer_run_blocks_until_peer_
|
|||
s_req, s_notify = echo_handlers(Recorder())
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
|
||||
async def late_start():
|
||||
await anyio.sleep(0)
|
||||
await server.run(s_req, s_notify)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(client.run, c_req, c_notify)
|
||||
tg.start_soon(late_start)
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
# start_soon: the server side only becomes ready once the request below has parked.
|
||||
tg.start_soon(server.run, s_req, s_notify)
|
||||
with anyio.fail_after(5):
|
||||
result = await client.send_raw_request("ping", None)
|
||||
assert result == {"echoed": "ping", "params": {}}
|
||||
|
|
@ -273,6 +282,99 @@ async def test_direct_send_raw_request_issued_before_peer_run_blocks_until_peer_
|
|||
server.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_before_run_raises_runtimeerror():
|
||||
"""The not-running guard fires immediately - before any waiting on the peer - matching JSONRPCDispatcher."""
|
||||
client, _server = create_direct_dispatcher_pair()
|
||||
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert str(exc.value) == "DirectDispatcher.send_raw_request called before run()"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_to_never_run_peer_honors_timeout():
|
||||
"""A configured timeout bounds the wait for a peer whose run() has not started."""
|
||||
client, _server = create_direct_dispatcher_pair()
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None, {"timeout": 0})
|
||||
assert exc.value.error.code == REQUEST_TIMEOUT
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_request_parked_waiting_for_peer_run_is_woken_by_peer_close():
|
||||
"""A request waiting on a never-run peer fails with CONNECTION_CLOSED when that peer closes."""
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
|
||||
async def send() -> None:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
client.close()
|
||||
|
||||
tg.start_soon(send)
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
server.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_after_local_close_raises_and_notify_is_dropped():
|
||||
"""After this side has closed, send_raw_request raises CONNECTION_CLOSED and notify
|
||||
drops fire-and-forget, matching JSONRPCDispatcher (SDK-defined)."""
|
||||
async with running_pair(direct_pair) as (client, _server, _crec, srec):
|
||||
pass # exiting cancels both run() loops, closing both sides
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
await client.notify("notifications/roots/list_changed", None)
|
||||
assert srec.requests == []
|
||||
assert srec.notifications == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_inbound_after_peer_close_refuses_requests_and_drops_notifications():
|
||||
"""Dispatch to a closed side fails the peer's request with CONNECTION_CLOSED and silently
|
||||
drops the peer's notify; the closed side's handlers are never invoked (SDK-defined)."""
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
crec, srec = Recorder(), Recorder()
|
||||
c_req, c_notify = echo_handlers(crec)
|
||||
s_req, s_notify = echo_handlers(srec)
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
await tg.start(server.run, s_req, s_notify)
|
||||
client.close()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await server.send_raw_request("roots/list", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
await server.notify("notifications/message", None)
|
||||
server.close()
|
||||
assert crec.requests == []
|
||||
assert crec.notifications == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_inbound_to_closed_never_run_peer_fails_with_connection_closed():
|
||||
"""A peer that closed without ever running refuses dispatch instead of parking the caller."""
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
server.close()
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_and_notify_raise_runtimeerror_when_no_peer_connected():
|
||||
d = DirectDispatcher(TransportContext(kind="direct", can_send_request=True))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import pytest
|
||||
|
||||
from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
|
||||
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData
|
||||
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_create_with_single_elicitation() -> None:
|
||||
|
|
@ -162,3 +162,14 @@ def test_url_elicitation_required_error_exception_message() -> None:
|
|||
|
||||
# The exception's string representation should match the message
|
||||
assert str(error) == "URL elicitation required"
|
||||
|
||||
|
||||
def test_from_jsonrpc_error_preserves_code_message_and_data() -> None:
|
||||
"""Building an MCPError from a wire JSONRPCError keeps every error field."""
|
||||
wire = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=3,
|
||||
error=ErrorData(code=URL_ELICITATION_REQUIRED, message="go elsewhere", data={"hint": "y"}),
|
||||
)
|
||||
error = MCPError.from_jsonrpc_error(wire)
|
||||
assert error.error == ErrorData(code=URL_ELICITATION_REQUIRED, message="go elsewhere", data={"hint": "y"})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,447 +0,0 @@
|
|||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp import Client, types
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.memory import create_client_server_memory_streams
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
METHOD_NOT_FOUND,
|
||||
PARSE_ERROR,
|
||||
CancelledNotification,
|
||||
CancelledNotificationParams,
|
||||
ClientResult,
|
||||
EmptyResult,
|
||||
ErrorData,
|
||||
JSONRPCError,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_request_cancellation():
|
||||
"""Test that requests can be cancelled while in-flight."""
|
||||
ev_tool_called = anyio.Event()
|
||||
ev_cancelled = anyio.Event()
|
||||
request_id = None
|
||||
|
||||
# Create a server with a slow tool
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
nonlocal request_id, ev_tool_called
|
||||
if params.name == "slow_tool":
|
||||
request_id = ctx.request_id
|
||||
ev_tool_called.set()
|
||||
await anyio.sleep(10) # Long enough to ensure we can cancel
|
||||
return types.CallToolResult(content=[]) # pragma: no cover
|
||||
raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover
|
||||
|
||||
async def handle_list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
server = Server(
|
||||
name="TestSessionServer",
|
||||
on_call_tool=handle_call_tool,
|
||||
on_list_tools=handle_list_tools,
|
||||
)
|
||||
|
||||
async def make_request(client: Client):
|
||||
nonlocal ev_cancelled
|
||||
try:
|
||||
await client.session.send_request(
|
||||
types.CallToolRequest(
|
||||
params=types.CallToolRequestParams(name="slow_tool", arguments={}),
|
||||
),
|
||||
types.CallToolResult,
|
||||
)
|
||||
pytest.fail("Request should have been cancelled") # pragma: no cover
|
||||
except MCPError as e:
|
||||
# Expected - request was cancelled
|
||||
assert "Request cancelled" in str(e)
|
||||
ev_cancelled.set()
|
||||
|
||||
async with Client(server) as client:
|
||||
async with anyio.create_task_group() as tg: # pragma: no branch
|
||||
tg.start_soon(make_request, client)
|
||||
|
||||
# Wait for the request to be in-flight
|
||||
with anyio.fail_after(1): # Timeout after 1 second
|
||||
await ev_tool_called.wait()
|
||||
|
||||
# Send cancellation notification
|
||||
assert request_id is not None
|
||||
await client.session.send_notification(
|
||||
CancelledNotification(params=CancelledNotificationParams(request_id=request_id))
|
||||
)
|
||||
|
||||
# Give cancellation time to process
|
||||
with anyio.fail_after(1): # pragma: no branch
|
||||
await ev_cancelled.wait()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_response_id_type_mismatch_string_to_int():
|
||||
"""Test that responses with string IDs are correctly matched to requests sent with
|
||||
integer IDs.
|
||||
|
||||
This handles the case where a server returns "id": "0" (string) but the client
|
||||
sent "id": 0 (integer). Without ID type normalization, this would cause a timeout.
|
||||
"""
|
||||
ev_response_received = anyio.Event()
|
||||
result_holder: list[types.EmptyResult] = []
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Receive a request and respond with a string ID instead of integer."""
|
||||
message = await server_read.receive()
|
||||
assert isinstance(message, SessionMessage)
|
||||
root = message.message
|
||||
assert isinstance(root, JSONRPCRequest)
|
||||
# Get the original request ID (which is an integer)
|
||||
request_id = root.id
|
||||
assert isinstance(request_id, int), f"Expected int, got {type(request_id)}"
|
||||
|
||||
# Respond with the ID as a string (simulating a buggy server)
|
||||
response = JSONRPCResponse(
|
||||
jsonrpc="2.0",
|
||||
id=str(request_id), # Convert to string to simulate mismatch
|
||||
result={},
|
||||
)
|
||||
await server_write.send(SessionMessage(message=response))
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
nonlocal result_holder
|
||||
# Send a ping request (uses integer ID internally)
|
||||
result = await client_session.send_ping()
|
||||
result_holder.append(result)
|
||||
ev_response_received.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(read_stream=client_read, write_stream=client_write) as client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
tg.start_soon(make_request, client_session)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_response_received.wait()
|
||||
|
||||
assert len(result_holder) == 1
|
||||
assert isinstance(result_holder[0], EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_error_response_id_type_mismatch_string_to_int():
|
||||
"""Test that error responses with string IDs are correctly matched to requests
|
||||
sent with integer IDs.
|
||||
|
||||
This handles the case where a server returns an error with "id": "0" (string)
|
||||
but the client sent "id": 0 (integer).
|
||||
"""
|
||||
ev_error_received = anyio.Event()
|
||||
error_holder: list[MCPError | Exception] = []
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Receive a request and respond with an error using a string ID."""
|
||||
message = await server_read.receive()
|
||||
assert isinstance(message, SessionMessage)
|
||||
root = message.message
|
||||
assert isinstance(root, JSONRPCRequest)
|
||||
request_id = root.id
|
||||
assert isinstance(request_id, int)
|
||||
|
||||
# Respond with an error, using the ID as a string
|
||||
error_response = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=str(request_id), # Convert to string to simulate mismatch
|
||||
error=ErrorData(code=-32600, message="Test error"),
|
||||
)
|
||||
await server_write.send(SessionMessage(message=error_response))
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
nonlocal error_holder
|
||||
try:
|
||||
await client_session.send_ping()
|
||||
pytest.fail("Expected MCPError to be raised") # pragma: no cover
|
||||
except MCPError as e:
|
||||
error_holder.append(e)
|
||||
ev_error_received.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(read_stream=client_read, write_stream=client_write) as client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
tg.start_soon(make_request, client_session)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_error_received.wait()
|
||||
|
||||
assert len(error_holder) == 1
|
||||
assert "Test error" in str(error_holder[0])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_response_id_non_numeric_string_no_match():
|
||||
"""Test that responses with non-numeric string IDs don't incorrectly match
|
||||
integer request IDs.
|
||||
|
||||
If a server returns "id": "abc" (non-numeric string), it should not match
|
||||
a request sent with "id": 0 (integer).
|
||||
"""
|
||||
ev_timeout = anyio.Event()
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Receive a request and respond with a non-numeric string ID."""
|
||||
message = await server_read.receive()
|
||||
assert isinstance(message, SessionMessage)
|
||||
|
||||
# Respond with a non-numeric string ID (should not match)
|
||||
response = JSONRPCResponse(
|
||||
jsonrpc="2.0",
|
||||
id="not_a_number", # Non-numeric string
|
||||
result={},
|
||||
)
|
||||
await server_write.send(SessionMessage(message=response))
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
try:
|
||||
# Use a short timeout since we expect this to fail
|
||||
await client_session.send_request(
|
||||
types.PingRequest(),
|
||||
types.EmptyResult,
|
||||
request_read_timeout_seconds=0.5,
|
||||
)
|
||||
pytest.fail("Expected timeout") # pragma: no cover
|
||||
except MCPError as e:
|
||||
assert "Timed out" in str(e)
|
||||
ev_timeout.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(read_stream=client_read, write_stream=client_write) as client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
tg.start_soon(make_request, client_session)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_timeout.wait()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_closed():
|
||||
"""Test that pending requests are cancelled when the connection is closed remotely."""
|
||||
|
||||
ev_closed = anyio.Event()
|
||||
ev_response = anyio.Event()
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
"""Send a request in a separate task"""
|
||||
nonlocal ev_response
|
||||
try:
|
||||
# any request will do
|
||||
await client_session.initialize()
|
||||
pytest.fail("Request should have errored") # pragma: no cover
|
||||
except MCPError as e:
|
||||
# Expected - request errored
|
||||
assert "Connection closed" in str(e)
|
||||
ev_response.set()
|
||||
|
||||
async def mock_server():
|
||||
"""Wait for a request, then close the connection"""
|
||||
nonlocal ev_closed
|
||||
# Wait for a request
|
||||
await server_read.receive()
|
||||
# Close the connection, as if the server exited
|
||||
server_write.close()
|
||||
server_read.close()
|
||||
ev_closed.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(read_stream=client_read, write_stream=client_write) as client_session,
|
||||
):
|
||||
tg.start_soon(make_request, client_session)
|
||||
tg.start_soon(mock_server)
|
||||
|
||||
with anyio.fail_after(1):
|
||||
await ev_closed.wait()
|
||||
with anyio.fail_after(1): # pragma: no branch
|
||||
await ev_response.wait()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_null_id_error_surfaced_via_message_handler():
|
||||
"""Test that a JSONRPCError with id=None is surfaced to the message handler.
|
||||
|
||||
Per JSON-RPC 2.0, error responses use id=null when the request id could not
|
||||
be determined (e.g., parse errors). These cannot be correlated to any pending
|
||||
request, so they are forwarded to the message handler as MCPError.
|
||||
"""
|
||||
ev_error_received = anyio.Event()
|
||||
error_holder: list[MCPError] = []
|
||||
|
||||
async def capture_errors(
|
||||
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
|
||||
) -> None:
|
||||
assert isinstance(message, MCPError)
|
||||
error_holder.append(message)
|
||||
ev_error_received.set()
|
||||
|
||||
sent_error = ErrorData(code=PARSE_ERROR, message="Parse error")
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
_server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Send a null-id error (simulating a parse error)."""
|
||||
error_response = JSONRPCError(jsonrpc="2.0", id=None, error=sent_error)
|
||||
await server_write.send(SessionMessage(message=error_response))
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(
|
||||
read_stream=client_read,
|
||||
write_stream=client_write,
|
||||
message_handler=capture_errors,
|
||||
) as _client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_error_received.wait()
|
||||
|
||||
assert len(error_holder) == 1
|
||||
assert error_holder[0].error == sent_error
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_null_id_error_does_not_affect_pending_request():
|
||||
"""Test that a null-id error doesn't interfere with an in-flight request.
|
||||
|
||||
When a null-id error arrives while a request is pending, the error should
|
||||
go to the message handler and the pending request should still complete
|
||||
normally with its own response.
|
||||
"""
|
||||
ev_error_received = anyio.Event()
|
||||
ev_response_received = anyio.Event()
|
||||
error_holder: list[MCPError] = []
|
||||
result_holder: list[EmptyResult] = []
|
||||
|
||||
async def capture_errors(
|
||||
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
|
||||
) -> None:
|
||||
assert isinstance(message, MCPError)
|
||||
error_holder.append(message)
|
||||
ev_error_received.set()
|
||||
|
||||
sent_error = ErrorData(code=PARSE_ERROR, message="Parse error")
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Read a request, inject a null-id error, then respond normally."""
|
||||
message = await server_read.receive()
|
||||
assert isinstance(message, SessionMessage)
|
||||
assert isinstance(message.message, JSONRPCRequest)
|
||||
request_id = message.message.id
|
||||
|
||||
# First, send a null-id error (should go to message handler)
|
||||
await server_write.send(SessionMessage(message=JSONRPCError(jsonrpc="2.0", id=None, error=sent_error)))
|
||||
|
||||
# Then, respond normally to the pending request
|
||||
await server_write.send(SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=request_id, result={})))
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
result = await client_session.send_ping()
|
||||
result_holder.append(result)
|
||||
ev_response_received.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(
|
||||
read_stream=client_read,
|
||||
write_stream=client_write,
|
||||
message_handler=capture_errors,
|
||||
) as client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
tg.start_soon(make_request, client_session)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_error_received.wait()
|
||||
await ev_response_received.wait()
|
||||
|
||||
# Null-id error reached the message handler
|
||||
assert len(error_holder) == 1
|
||||
assert error_holder[0].error == sent_error
|
||||
|
||||
# Pending request completed successfully
|
||||
assert len(result_holder) == 1
|
||||
assert isinstance(result_holder[0], EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_receive_loop_answers_unknown_request_method_with_method_not_found():
|
||||
"""A peer request whose method is not in the receive union gets -32601
|
||||
(METHOD_NOT_FOUND) on the wire, not a validation failure (-32602)."""
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async with ClientSession(read_stream=client_read, write_stream=client_write):
|
||||
await server_write.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=7, method="x/unknown")))
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
out = await server_read.receive()
|
||||
|
||||
assert isinstance(out, SessionMessage)
|
||||
assert isinstance(out.message, JSONRPCError)
|
||||
assert out.message.id == 7
|
||||
assert out.message.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="x/unknown")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_receive_loop_drops_unknown_notification_method_without_response():
|
||||
"""An unknown notification method is dropped silently: JSON-RPC forbids
|
||||
responses to notifications, and the receive loop keeps serving."""
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async with ClientSession(read_stream=client_read, write_stream=client_write):
|
||||
await server_write.send(SessionMessage(message=JSONRPCNotification(jsonrpc="2.0", method="x/unknown")))
|
||||
# The next wire output must be the answer to this follow-up ping,
|
||||
# proving the notification produced no response and the loop survived.
|
||||
await server_write.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")))
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
out = await server_read.receive()
|
||||
|
||||
assert isinstance(out, SessionMessage)
|
||||
assert isinstance(out.message, JSONRPCResponse)
|
||||
assert out.message.id == 1
|
||||
|
|
@ -18,16 +18,20 @@ from urllib.parse import urlparse
|
|||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from httpx_sse import ServerSentEvent
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.routing import Mount
|
||||
from starlette.types import Message, Scope
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.streamable_http import (
|
||||
GET_STREAM_KEY,
|
||||
MCP_PROTOCOL_VERSION_HEADER,
|
||||
MCP_SESSION_ID_HEADER,
|
||||
SESSION_ID_PATTERN,
|
||||
|
|
@ -41,7 +45,6 @@ from mcp.server.streamable_http import (
|
|||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.shared._context_streams import create_context_streams
|
||||
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
|
|
@ -1232,7 +1235,7 @@ async def test_streamablehttp_server_sampling(basic_app: Starlette) -> None:
|
|||
|
||||
# Define sampling callback that returns a mock response
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult:
|
||||
nonlocal sampling_callback_invoked, captured_message_params
|
||||
|
|
@ -2224,3 +2227,115 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(context_
|
|||
|
||||
assert "content-type" in headers_data
|
||||
assert headers_data["content-type"] == "application/json"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_standalone_stream_teardown_mid_listen_is_not_an_error(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Standalone-stream teardown while the writer is parked in receive() logs no error (SDK-defined)."""
|
||||
session_manager = StreamableHTTPSessionManager(
|
||||
app=_create_server(),
|
||||
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||
)
|
||||
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
|
||||
notified = anyio.Event()
|
||||
|
||||
async def message_handler(
|
||||
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
||||
) -> None:
|
||||
# Only the standalone-stream notification is teed to the handler here.
|
||||
assert isinstance(message, types.ResourceUpdatedNotification)
|
||||
notified.set()
|
||||
|
||||
async with session_manager.run():
|
||||
async with (
|
||||
make_client(app) as http_client,
|
||||
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream),
|
||||
ClientSession(read_stream, write_stream, message_handler=message_handler) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
# A notification with no related request rides the GET stream, proving the writer is live.
|
||||
await session.call_tool("test_tool_with_standalone_notification", {})
|
||||
with anyio.fail_after(5):
|
||||
await notified.wait()
|
||||
# Tear the standalone stream down while the writer is parked on it.
|
||||
(transport,) = session_manager._server_instances.values() # pyright: ignore[reportPrivateUsage]
|
||||
await transport._clean_up_memory_streams(GET_STREAM_KEY) # pyright: ignore[reportPrivateUsage]
|
||||
assert "Error in standalone SSE writer" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_standalone_stream_teardown_between_dequeues_is_not_an_error(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Teardown landing while the standalone writer is between dequeues logs no error.
|
||||
|
||||
SDK-defined: after teardown the writer's next dequeue hits its own closed stream — expected
|
||||
disconnect noise. The public surface cannot force this window (the in-process client consumes
|
||||
SSE without backpressure), so the test drives the transport's ASGI entry point with a gated `send`.
|
||||
"""
|
||||
transport = StreamableHTTPServerTransport(
|
||||
mcp_session_id=None,
|
||||
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||
)
|
||||
# The GET handler only checks that a read-stream writer exists; it is never written to.
|
||||
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
|
||||
transport._read_stream_writer = read_stream_writer # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
stream_registered = anyio.Event()
|
||||
|
||||
class SignalingStreams(
|
||||
dict[types.RequestId, tuple[MemoryObjectSendStream[EventMessage], MemoryObjectReceiveStream[EventMessage]]]
|
||||
):
|
||||
# Only the GET handler inserts here, so any insert is the standalone stream registration.
|
||||
def __setitem__(
|
||||
self,
|
||||
key: types.RequestId,
|
||||
value: tuple[MemoryObjectSendStream[EventMessage], MemoryObjectReceiveStream[EventMessage]],
|
||||
) -> None:
|
||||
super().__setitem__(key, value)
|
||||
stream_registered.set()
|
||||
|
||||
transport._request_streams = SignalingStreams() # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
gate = anyio.Event()
|
||||
sent: list[Message] = []
|
||||
|
||||
async def asgi_send(message: Message) -> None:
|
||||
sent.append(message)
|
||||
await gate.wait()
|
||||
|
||||
# Never delivers anything, parking the response's disconnect listener.
|
||||
disconnect_send, disconnect_receive = anyio.create_memory_object_stream[Message](0)
|
||||
|
||||
async def asgi_receive() -> Message:
|
||||
return await disconnect_receive.receive()
|
||||
|
||||
scope: Scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/mcp",
|
||||
"query_string": b"",
|
||||
"headers": [(b"accept", b"text/event-stream")],
|
||||
}
|
||||
notification = types.JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")
|
||||
|
||||
async with read_stream_writer, read_stream, disconnect_send, disconnect_receive:
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg: # pragma: no branch
|
||||
tg.start_soon(transport.handle_request, scope, asgi_receive, asgi_send)
|
||||
await stream_registered.wait()
|
||||
standalone_send = transport._request_streams[GET_STREAM_KEY][0] # pyright: ignore[reportPrivateUsage]
|
||||
# Zero-buffer rendezvous: once send() returns, the writer has dequeued the event
|
||||
# and is blocked forwarding it past the closed gate — the between-dequeues window.
|
||||
await standalone_send.send(EventMessage(notification))
|
||||
await transport._clean_up_memory_streams(GET_STREAM_KEY) # pyright: ignore[reportPrivateUsage]
|
||||
# Unblock the response; the writer's next dequeue hits its closed stream.
|
||||
gate.set()
|
||||
|
||||
assert sent[0]["type"] == "http.response.start"
|
||||
assert sent[0]["status"] == 200
|
||||
body_chunks = [message for message in sent if message["type"] == "http.response.body"]
|
||||
assert b"notifications/initialized" in body_chunks[0]["body"]
|
||||
assert body_chunks[-1] == {"type": "http.response.body", "body": b"", "more_body": False}
|
||||
assert "Error in standalone SSE writer" not in caplog.text
|
||||
assert "Error in standalone SSE response" not in caplog.text
|
||||
|
|
|
|||
Loading…
Reference in a new issue