[v2] ClientSession runs on JSONRPCDispatcher; BaseSession removed (#2838)

This commit is contained in:
Max 2026-06-15 14:46:34 +01:00 • committed by GitHub
commit 1012d60004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2886 additions and 1734 deletions

View 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()

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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