Support different transports in Client (#1972)
This commit is contained in:
parent
2137c8fbdb
commit
21822053df
7 changed files with 544 additions and 631 deletions
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
import mcp.types as types
|
||||
from mcp.client._memory import InMemoryTransport
|
||||
from mcp.client.client import Client
|
||||
from mcp.server import Server
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
|
@ -285,3 +288,21 @@ async def test_complete_with_prompt_reference(simple_server: Server):
|
|||
ref = types.PromptReference(type="ref/prompt", name="test_prompt")
|
||||
result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"})
|
||||
assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[])))
|
||||
|
||||
|
||||
def test_client_with_url_initializes_streamable_http_transport():
|
||||
with patch("mcp.client.client.streamable_http_client") as mock:
|
||||
_ = Client("http://localhost:8000/mcp")
|
||||
mock.assert_called_once_with("http://localhost:8000/mcp")
|
||||
|
||||
|
||||
async def test_client_uses_transport_directly(app: MCPServer):
|
||||
transport = InMemoryTransport(app)
|
||||
async with Client(transport) as client:
|
||||
result = await client.call_tool("greet", {"name": "Transport"})
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(text="Hello, Transport!")],
|
||||
structured_content={"result": "Hello, Transport!"},
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ async def test_streamable_http_client_unicode_tool_call(running_unicode_server:
|
|||
base_url = running_unicode_server
|
||||
endpoint_url = f"{base_url}/mcp"
|
||||
|
||||
async with streamable_http_client(endpoint_url) as (read_stream, write_stream, _get_session_id):
|
||||
async with streamable_http_client(endpoint_url) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
|
|
@ -205,7 +205,7 @@ async def test_streamable_http_client_unicode_prompts(running_unicode_server: st
|
|||
base_url = running_unicode_server
|
||||
endpoint_url = f"{base_url}/mcp"
|
||||
|
||||
async with streamable_http_client(endpoint_url) as (read_stream, write_stream, _get_session_id):
|
||||
async with streamable_http_client(endpoint_url) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
|
|
|
|||
|
|
@ -125,12 +125,8 @@ async def test_non_compliant_notification_response(non_sdk_server: None, non_sdk
|
|||
if isinstance(message, Exception):
|
||||
returned_exception = message
|
||||
|
||||
async with streamable_http_client(server_url) as (read_stream, write_stream, _):
|
||||
async with ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
message_handler=message_handler,
|
||||
) as session:
|
||||
async with streamable_http_client(server_url) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
# Initialize should work normally
|
||||
await session.initialize()
|
||||
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ async def test_client_session_group_disconnect_non_existent_server():
|
|||
await group.disconnect_from_server(session)
|
||||
|
||||
|
||||
# TODO(Marcelo): This is horrible. We should drop this test.
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_params_instance, client_type_name, patch_target_for_client_func",
|
||||
|
|
@ -310,19 +311,8 @@ async def test_client_session_group_establish_session_parameterized(
|
|||
mock_read_stream = mock.AsyncMock(name=f"{client_type_name}Read")
|
||||
mock_write_stream = mock.AsyncMock(name=f"{client_type_name}Write")
|
||||
|
||||
# streamable_http_client's __aenter__ returns three values
|
||||
if client_type_name == "streamablehttp":
|
||||
mock_extra_stream_val = mock.AsyncMock(name="StreamableExtra")
|
||||
mock_client_cm_instance.__aenter__.return_value = (
|
||||
mock_read_stream,
|
||||
mock_write_stream,
|
||||
mock_extra_stream_val,
|
||||
)
|
||||
else:
|
||||
mock_client_cm_instance.__aenter__.return_value = (
|
||||
mock_read_stream,
|
||||
mock_write_stream,
|
||||
)
|
||||
# All client context managers return (read_stream, write_stream)
|
||||
mock_client_cm_instance.__aenter__.return_value = (mock_read_stream, mock_write_stream)
|
||||
|
||||
mock_client_cm_instance.__aexit__ = mock.AsyncMock(return_value=None)
|
||||
mock_specific_client_func.return_value = mock_client_cm_instance
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ pytestmark = pytest.mark.anyio
|
|||
async def test_with_server(simple_server: Server):
|
||||
"""Test creating transport with a Server instance."""
|
||||
transport = InMemoryTransport(simple_server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
async with transport as (read_stream, write_stream):
|
||||
assert read_stream is not None
|
||||
assert write_stream is not None
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ async def test_with_server(simple_server: Server):
|
|||
async def test_with_mcpserver(mcpserver_server: MCPServer):
|
||||
"""Test creating transport with an MCPServer instance."""
|
||||
transport = InMemoryTransport(mcpserver_server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
async with transport as (read_stream, write_stream):
|
||||
assert read_stream is not None
|
||||
assert write_stream is not None
|
||||
|
||||
|
|
@ -93,5 +93,5 @@ async def test_call_tool(mcpserver_server: MCPServer):
|
|||
async def test_raise_exceptions(mcpserver_server: MCPServer):
|
||||
"""Test that raise_exceptions parameter is passed through."""
|
||||
transport = InMemoryTransport(mcpserver_server, raise_exceptions=True)
|
||||
async with transport.connect() as (read_stream, _write_stream):
|
||||
async with transport as (read_stream, _write_stream):
|
||||
assert read_stream is not None
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ from collections.abc import Generator
|
|||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from examples.snippets.servers import (
|
||||
|
|
@ -33,9 +32,8 @@ from examples.snippets.servers import (
|
|||
)
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import GetSessionIdCallback, streamable_http_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
ClientResult,
|
||||
|
|
@ -185,32 +183,6 @@ def create_client_for_transport(transport: str, server_url: str):
|
|||
raise ValueError(f"Invalid transport: {transport}")
|
||||
|
||||
|
||||
def unpack_streams(
|
||||
client_streams: tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
|
||||
| tuple[
|
||||
MemoryObjectReceiveStream[SessionMessage | Exception],
|
||||
MemoryObjectSendStream[SessionMessage],
|
||||
GetSessionIdCallback,
|
||||
],
|
||||
):
|
||||
"""Unpack client streams handling different return values from SSE vs StreamableHTTP.
|
||||
|
||||
SSE client returns (read_stream, write_stream)
|
||||
StreamableHTTP client returns (read_stream, write_stream, session_id_callback)
|
||||
|
||||
Args:
|
||||
client_streams: Tuple from client context manager
|
||||
|
||||
Returns:
|
||||
Tuple of (read_stream, write_stream)
|
||||
"""
|
||||
if len(client_streams) == 2:
|
||||
return client_streams
|
||||
else:
|
||||
read_stream, write_stream, _ = client_streams
|
||||
return read_stream, write_stream
|
||||
|
||||
|
||||
# Callback functions for testing
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession, None], params: CreateMessageRequestParams
|
||||
|
|
@ -253,8 +225,7 @@ async def test_basic_tools(server_transport: str, server_url: str) -> None:
|
|||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
@ -290,8 +261,7 @@ async def test_basic_resources(server_transport: str, server_url: str) -> None:
|
|||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
@ -331,8 +301,7 @@ async def test_basic_prompts(server_transport: str, server_url: str) -> None:
|
|||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
@ -391,8 +360,7 @@ async def test_tool_progress(server_transport: str, server_url: str) -> None:
|
|||
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
@ -441,8 +409,7 @@ async def test_sampling(server_transport: str, server_url: str) -> None:
|
|||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, sampling_callback=sampling_callback) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
@ -472,8 +439,7 @@ async def test_elicitation(server_transport: str, server_url: str) -> None:
|
|||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, elicitation_callback=elicitation_callback) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
@ -529,8 +495,7 @@ async def test_notifications(server_transport: str, server_url: str) -> None:
|
|||
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
@ -570,8 +535,7 @@ async def test_completion(server_transport: str, server_url: str) -> None:
|
|||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
@ -623,8 +587,7 @@ async def test_mcpserver_quickstart(server_transport: str, server_url: str) -> N
|
|||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
@ -659,8 +622,7 @@ async def test_structured_output(server_transport: str, server_url: str) -> None
|
|||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with client_cm as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ from mcp.server.streamable_http import (
|
|||
)
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
from mcp.shared._httpx_utils import (
|
||||
MCP_DEFAULT_SSE_READ_TIMEOUT,
|
||||
MCP_DEFAULT_TIMEOUT,
|
||||
create_mcp_http_client,
|
||||
)
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
|
|
@ -965,15 +969,8 @@ async def http_client(basic_server: None, basic_server_url: str): # pragma: no
|
|||
@pytest.fixture
|
||||
async def initialized_client_session(basic_server: None, basic_server_url: str):
|
||||
"""Create initialized StreamableHTTP client session."""
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
) as session:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
|
|
@ -981,11 +978,8 @@ async def initialized_client_session(basic_server: None, basic_server_url: str):
|
|||
@pytest.mark.anyio
|
||||
async def test_streamable_http_client_basic_connection(basic_server: None, basic_server_url: str):
|
||||
"""Test basic client connection with initialization."""
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, _):
|
||||
async with ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
) as session:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
|
|
@ -1029,15 +1023,8 @@ async def test_streamable_http_client_error_handling(initialized_client_session:
|
|||
@pytest.mark.anyio
|
||||
async def test_streamable_http_client_session_persistence(basic_server: None, basic_server_url: str):
|
||||
"""Test that session ID persists across requests."""
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
) as session:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize the session
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
|
|
@ -1057,7 +1044,7 @@ async def test_streamable_http_client_session_persistence(basic_server: None, ba
|
|||
@pytest.mark.anyio
|
||||
async def test_streamable_http_client_json_response(json_response_server: None, json_server_url: str):
|
||||
"""Test client with JSON response mode."""
|
||||
async with streamable_http_client(f"{json_server_url}/mcp") as (read_stream, write_stream, _):
|
||||
async with streamable_http_client(f"{json_server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize the session
|
||||
result = await session.initialize()
|
||||
|
|
@ -1087,7 +1074,7 @@ async def test_streamable_http_client_get_stream(basic_server: None, basic_serve
|
|||
if isinstance(message, types.ServerNotification): # pragma: no branch
|
||||
notifications_received.append(message)
|
||||
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, _):
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
# Initialize the session - this triggers the GET stream setup
|
||||
result = await session.initialize()
|
||||
|
|
@ -1109,34 +1096,54 @@ async def test_streamable_http_client_get_stream(basic_server: None, basic_serve
|
|||
assert resource_update_found, "ResourceUpdatedNotification not received via GET stream"
|
||||
|
||||
|
||||
def create_session_id_capturing_client() -> tuple[httpx.AsyncClient, list[str]]:
|
||||
"""Create an httpx client that captures the session ID from responses."""
|
||||
captured_ids: list[str] = []
|
||||
|
||||
async def capture_session_id(response: httpx.Response) -> None:
|
||||
session_id = response.headers.get(MCP_SESSION_ID_HEADER)
|
||||
if session_id:
|
||||
captured_ids.append(session_id)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT),
|
||||
event_hooks={"response": [capture_session_id]},
|
||||
)
|
||||
return client, captured_ids
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_streamable_http_client_session_termination(basic_server: None, basic_server_url: str):
|
||||
"""Test client session termination functionality."""
|
||||
# Use httpx client with event hooks to capture session ID
|
||||
httpx_client, captured_ids = create_session_id_capturing_client()
|
||||
|
||||
captured_session_id = None
|
||||
async with httpx_client:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize the session
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert len(captured_ids) > 0
|
||||
captured_session_id = captured_ids[0]
|
||||
assert captured_session_id is not None
|
||||
|
||||
# Create the streamable_http_client with a custom httpx client to capture headers
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, get_session_id):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize the session
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
captured_session_id = get_session_id()
|
||||
assert captured_session_id is not None
|
||||
|
||||
# Make a request to confirm session is working
|
||||
tools = await session.list_tools()
|
||||
assert len(tools.tools) == 10
|
||||
# Make a request to confirm session is working
|
||||
tools = await session.list_tools()
|
||||
assert len(tools.tools) == 10
|
||||
|
||||
headers: dict[str, str] = {} # pragma: lax no cover
|
||||
if captured_session_id: # pragma: lax no cover
|
||||
headers[MCP_SESSION_ID_HEADER] = captured_session_id
|
||||
|
||||
async with create_mcp_http_client(headers=headers) as httpx_client:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as (
|
||||
async with create_mcp_http_client(headers=headers) as httpx_client2:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client2) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
# Attempt to make a request after termination
|
||||
|
|
@ -1173,30 +1180,34 @@ async def test_streamable_http_client_session_termination_204(
|
|||
# Apply the patch to the httpx client
|
||||
monkeypatch.setattr(httpx.AsyncClient, "delete", mock_delete)
|
||||
|
||||
captured_session_id = None
|
||||
# Use httpx client with event hooks to capture session ID
|
||||
httpx_client, captured_ids = create_session_id_capturing_client()
|
||||
|
||||
# Create the streamable_http_client with a custom httpx client to capture headers
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, get_session_id):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize the session
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
captured_session_id = get_session_id()
|
||||
assert captured_session_id is not None
|
||||
async with httpx_client:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize the session
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert len(captured_ids) > 0
|
||||
captured_session_id = captured_ids[0]
|
||||
assert captured_session_id is not None
|
||||
|
||||
# Make a request to confirm session is working
|
||||
tools = await session.list_tools()
|
||||
assert len(tools.tools) == 10
|
||||
# Make a request to confirm session is working
|
||||
tools = await session.list_tools()
|
||||
assert len(tools.tools) == 10
|
||||
|
||||
headers: dict[str, str] = {} # pragma: lax no cover
|
||||
if captured_session_id: # pragma: lax no cover
|
||||
headers[MCP_SESSION_ID_HEADER] = captured_session_id
|
||||
|
||||
async with create_mcp_http_client(headers=headers) as httpx_client:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as (
|
||||
async with create_mcp_http_client(headers=headers) as httpx_client2:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client2) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
# Attempt to make a request after termination
|
||||
|
|
@ -1210,10 +1221,9 @@ async def test_streamable_http_client_resumption(event_server: tuple[SimpleEvent
|
|||
_, server_url = event_server
|
||||
|
||||
# Variables to track the state
|
||||
captured_session_id = None
|
||||
captured_resumption_token = None
|
||||
captured_resumption_token: str | None = None
|
||||
captured_notifications: list[types.ServerNotification] = []
|
||||
captured_protocol_version = None
|
||||
captured_protocol_version: str | int | None = None
|
||||
first_notification_received = False
|
||||
|
||||
async def message_handler( # pragma: no branch
|
||||
|
|
@ -1231,50 +1241,56 @@ async def test_streamable_http_client_resumption(event_server: tuple[SimpleEvent
|
|||
nonlocal captured_resumption_token
|
||||
captured_resumption_token = token
|
||||
|
||||
# Use httpx client with event hooks to capture session ID
|
||||
httpx_client, captured_ids = create_session_id_capturing_client()
|
||||
|
||||
# First, start the client session and begin the tool that waits on lock
|
||||
async with streamable_http_client(f"{server_url}/mcp", terminate_on_close=False) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
get_session_id,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
# Initialize the session
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
captured_session_id = get_session_id()
|
||||
assert captured_session_id is not None
|
||||
# Capture the negotiated protocol version
|
||||
captured_protocol_version = result.protocol_version
|
||||
async with httpx_client:
|
||||
async with streamable_http_client(f"{server_url}/mcp", terminate_on_close=False, http_client=httpx_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
# Initialize the session
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert len(captured_ids) > 0
|
||||
captured_session_id = captured_ids[0]
|
||||
assert captured_session_id is not None
|
||||
# Capture the negotiated protocol version
|
||||
captured_protocol_version = result.protocol_version
|
||||
|
||||
# Start the tool that will wait on lock in a task
|
||||
async with anyio.create_task_group() as tg: # pragma: no branch
|
||||
# Start the tool that will wait on lock in a task
|
||||
async with anyio.create_task_group() as tg: # pragma: no branch
|
||||
|
||||
async def run_tool():
|
||||
metadata = ClientMessageMetadata(
|
||||
on_resumption_token_update=on_resumption_token_update,
|
||||
)
|
||||
await session.send_request(
|
||||
types.CallToolRequest(
|
||||
params=types.CallToolRequestParams(name="wait_for_lock_with_notification", arguments={}),
|
||||
),
|
||||
types.CallToolResult,
|
||||
metadata=metadata,
|
||||
)
|
||||
async def run_tool():
|
||||
metadata = ClientMessageMetadata(
|
||||
on_resumption_token_update=on_resumption_token_update,
|
||||
)
|
||||
await session.send_request(
|
||||
types.CallToolRequest(
|
||||
params=types.CallToolRequestParams(
|
||||
name="wait_for_lock_with_notification", arguments={}
|
||||
),
|
||||
),
|
||||
types.CallToolResult,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
tg.start_soon(run_tool)
|
||||
tg.start_soon(run_tool)
|
||||
|
||||
# Wait for the first notification and resumption token
|
||||
while not first_notification_received or not captured_resumption_token:
|
||||
await anyio.sleep(0.1)
|
||||
# Wait for the first notification and resumption token
|
||||
while not first_notification_received or not captured_resumption_token:
|
||||
await anyio.sleep(0.1)
|
||||
|
||||
# Kill the client session while tool is waiting on lock
|
||||
tg.cancel_scope.cancel()
|
||||
# Kill the client session while tool is waiting on lock
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
# Verify we received exactly one notification (inside ClientSession
|
||||
# so coverage tracks these on Python 3.11, see PR #1897 for details)
|
||||
assert len(captured_notifications) == 1 # pragma: lax no cover
|
||||
assert isinstance(captured_notifications[0], types.LoggingMessageNotification) # pragma: lax no cover
|
||||
assert captured_notifications[0].params.data == "First notification before lock" # pragma: lax no cover
|
||||
# Verify we received exactly one notification (inside ClientSession
|
||||
# so coverage tracks these on Python 3.11, see PR #1897 for details)
|
||||
assert len(captured_notifications) == 1 # pragma: lax no cover
|
||||
assert isinstance(captured_notifications[0], types.LoggingMessageNotification) # pragma: lax no cover
|
||||
assert captured_notifications[0].params.data == "First notification before lock" # pragma: lax no cover
|
||||
|
||||
# Clear notifications and set up headers for phase 2 (between connections,
|
||||
# not tracked by coverage on Python 3.11 due to cancel scope + sys.settrace bug)
|
||||
|
|
@ -1286,11 +1302,10 @@ async def test_streamable_http_client_resumption(event_server: tuple[SimpleEvent
|
|||
MCP_PROTOCOL_VERSION_HEADER: captured_protocol_version,
|
||||
}
|
||||
|
||||
async with create_mcp_http_client(headers=headers) as httpx_client:
|
||||
async with streamable_http_client(f"{server_url}/mcp", http_client=httpx_client) as (
|
||||
async with create_mcp_http_client(headers=headers) as httpx_client2:
|
||||
async with streamable_http_client(f"{server_url}/mcp", http_client=httpx_client2) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, message_handler=message_handler
|
||||
|
|
@ -1349,16 +1364,8 @@ async def test_streamablehttp_server_sampling(basic_server: None, basic_server_u
|
|||
)
|
||||
|
||||
# Create client with sampling callback
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
sampling_callback=sampling_callback,
|
||||
) as session:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, sampling_callback=sampling_callback) as session:
|
||||
# Initialize the session
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
|
|
@ -1498,7 +1505,6 @@ async def test_streamablehttp_request_context_propagation(context_aware_server:
|
|||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
result = await session.initialize()
|
||||
|
|
@ -1536,7 +1542,6 @@ async def test_streamablehttp_request_context_isolation(context_aware_server: No
|
|||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
await session.initialize()
|
||||
|
|
@ -1561,11 +1566,7 @@ async def test_streamablehttp_request_context_isolation(context_aware_server: No
|
|||
@pytest.mark.anyio
|
||||
async def test_client_includes_protocol_version_header_after_init(context_aware_server: None, basic_server_url: str):
|
||||
"""Test that client includes mcp-protocol-version header after initialization."""
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize and get the negotiated version
|
||||
init_result = await session.initialize()
|
||||
|
|
@ -1677,7 +1678,7 @@ async def test_client_crash_handled(basic_server: None, basic_server_url: str):
|
|||
# Simulate bad client that crashes after init
|
||||
async def bad_client():
|
||||
"""Client that triggers ClosedResourceError"""
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, _):
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
raise Exception("client crash")
|
||||
|
|
@ -1691,7 +1692,7 @@ async def test_client_crash_handled(basic_server: None, basic_server_url: str):
|
|||
await anyio.sleep(0.1)
|
||||
|
||||
# Try a good client, it should still be able to connect and list tools
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, _):
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
|
|
@ -1847,11 +1848,7 @@ async def test_streamable_http_client_receives_priming_event(
|
|||
async def on_resumption_token_update(token: str) -> None:
|
||||
captured_resumption_tokens.append(token)
|
||||
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
|
|
@ -1884,11 +1881,7 @@ async def test_server_close_sse_stream_via_context(
|
|||
"""Server tool can call ctx.close_sse_stream() to close connection."""
|
||||
_, server_url = event_server
|
||||
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
|
|
@ -1921,16 +1914,8 @@ async def test_streamable_http_client_auto_reconnects(
|
|||
if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
|
||||
captured_notifications.append(str(message.params.data))
|
||||
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
message_handler=message_handler,
|
||||
) as session:
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Call tool that:
|
||||
|
|
@ -1956,11 +1941,7 @@ async def test_streamable_http_client_respects_retry_interval(
|
|||
"""Client MUST respect retry field, waiting specified ms before reconnecting."""
|
||||
_, server_url = event_server
|
||||
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
|
|
@ -1997,16 +1978,8 @@ async def test_streamable_http_sse_polling_full_cycle(
|
|||
if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
|
||||
all_notifications.append(str(message.params.data))
|
||||
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
message_handler=message_handler,
|
||||
) as session:
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Call tool that simulates polling pattern:
|
||||
|
|
@ -2045,16 +2018,8 @@ async def test_streamable_http_events_replayed_after_disconnect(
|
|||
if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
|
||||
notification_data.append(str(message.params.data))
|
||||
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
message_handler=message_handler,
|
||||
) as session:
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Tool sends: notification1, close_stream, notification2, notification3, response
|
||||
|
|
@ -2097,7 +2062,7 @@ async def test_streamable_http_multiple_reconnections(
|
|||
async def on_resumption_token(token: str) -> None:
|
||||
resumption_tokens.append(token)
|
||||
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream, _):
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
|
|
@ -2128,9 +2093,7 @@ async def test_streamable_http_multiple_reconnections(
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_standalone_get_stream_reconnection(
|
||||
event_server: tuple[SimpleEventStore, str],
|
||||
) -> None:
|
||||
async def test_standalone_get_stream_reconnection(event_server: tuple[SimpleEventStore, str]) -> None:
|
||||
"""Test that standalone GET stream automatically reconnects after server closes it.
|
||||
|
||||
Verifies:
|
||||
|
|
@ -2154,16 +2117,8 @@ async def test_standalone_get_stream_reconnection(
|
|||
if isinstance(message, types.ResourceUpdatedNotification): # pragma: no branch
|
||||
received_notifications.append(str(message.params.uri))
|
||||
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
message_handler=message_handler,
|
||||
) as session:
|
||||
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Call tool that:
|
||||
|
|
@ -2203,7 +2158,6 @@ async def test_streamable_http_client_does_not_mutate_provided_client(
|
|||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=custom_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
result = await session.initialize()
|
||||
|
|
@ -2233,11 +2187,7 @@ async def test_streamable_http_client_mcp_headers_override_defaults(
|
|||
# Verify client has default accept header
|
||||
assert client.headers.get("accept") == "*/*"
|
||||
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
await session.initialize()
|
||||
|
||||
|
|
@ -2268,11 +2218,7 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(
|
|||
}
|
||||
|
||||
async with httpx.AsyncClient(headers=custom_headers, follow_redirects=True) as client:
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
await session.initialize()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue