393 lines
15 KiB
Python
393 lines
15 KiB
Python
from typing import Any, cast
|
|
from unittest.mock import patch
|
|
|
|
import anyio
|
|
import pytest
|
|
|
|
import mcp.types as types
|
|
from mcp.client.session import ClientSession
|
|
from mcp.server import Server
|
|
from mcp.server.lowlevel import NotificationOptions
|
|
from mcp.server.models import InitializationOptions
|
|
from mcp.server.session import ServerSession
|
|
from mcp.shared.context import RequestContext
|
|
from mcp.shared.memory import create_connected_server_and_client_session
|
|
from mcp.shared.progress import progress
|
|
from mcp.shared.session import BaseSession, RequestResponder, SessionMessage
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_bidirectional_progress_notifications():
|
|
"""Test that both client and server can send progress notifications."""
|
|
# Create memory streams for client/server
|
|
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5)
|
|
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5)
|
|
|
|
# Run a server session so we can send progress updates in tool
|
|
async def run_server():
|
|
# Create a server session
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(
|
|
server_name="ProgressTestServer",
|
|
server_version="0.1.0",
|
|
capabilities=server.get_capabilities(NotificationOptions(), {}),
|
|
),
|
|
) as server_session:
|
|
global serv_sesh
|
|
|
|
serv_sesh = server_session
|
|
async for message in server_session.incoming_messages:
|
|
try:
|
|
await server._handle_message(message, server_session, {})
|
|
except Exception as e: # pragma: no cover
|
|
raise e
|
|
|
|
# Track progress updates
|
|
server_progress_updates: list[dict[str, Any]] = []
|
|
client_progress_updates: list[dict[str, Any]] = []
|
|
|
|
# Progress tokens
|
|
server_progress_token = "server_token_123"
|
|
client_progress_token = "client_token_456"
|
|
|
|
# Create a server with progress capability
|
|
server = Server(name="ProgressTestServer")
|
|
|
|
# Register progress handler
|
|
@server.progress_notification()
|
|
async def handle_progress(
|
|
progress_token: str | int,
|
|
progress: float,
|
|
total: float | None,
|
|
message: str | None,
|
|
):
|
|
server_progress_updates.append(
|
|
{
|
|
"token": progress_token,
|
|
"progress": progress,
|
|
"total": total,
|
|
"message": message,
|
|
}
|
|
)
|
|
|
|
# Register list tool handler
|
|
@server.list_tools()
|
|
async def handle_list_tools() -> list[types.Tool]:
|
|
return [
|
|
types.Tool(
|
|
name="test_tool",
|
|
description="A tool that sends progress notifications <o/",
|
|
inputSchema={},
|
|
)
|
|
]
|
|
|
|
# Register tool handler
|
|
@server.call_tool()
|
|
async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]:
|
|
# Make sure we received a progress token
|
|
if name == "test_tool":
|
|
if arguments and "_meta" in arguments:
|
|
progressToken = arguments["_meta"]["progressToken"]
|
|
|
|
if not progressToken: # pragma: no cover
|
|
raise ValueError("Empty progress token received")
|
|
|
|
if progressToken != client_progress_token: # pragma: no cover
|
|
raise ValueError("Server sending back incorrect progressToken")
|
|
|
|
# Send progress notifications
|
|
await serv_sesh.send_progress_notification(
|
|
progress_token=progressToken,
|
|
progress=0.25,
|
|
total=1.0,
|
|
message="Server progress 25%",
|
|
)
|
|
|
|
await serv_sesh.send_progress_notification(
|
|
progress_token=progressToken,
|
|
progress=0.5,
|
|
total=1.0,
|
|
message="Server progress 50%",
|
|
)
|
|
|
|
await serv_sesh.send_progress_notification(
|
|
progress_token=progressToken,
|
|
progress=1.0,
|
|
total=1.0,
|
|
message="Server progress 100%",
|
|
)
|
|
|
|
else: # pragma: no cover
|
|
raise ValueError("Progress token not sent.")
|
|
|
|
return [types.TextContent(type="text", text="Tool executed successfully")]
|
|
|
|
raise ValueError(f"Unknown tool: {name}") # pragma: no cover
|
|
|
|
# Client message handler to store progress notifications
|
|
async def handle_client_message(
|
|
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
|
) -> None:
|
|
if isinstance(message, Exception): # pragma: no cover
|
|
raise message
|
|
|
|
if isinstance(message, types.ServerNotification): # pragma: no branch
|
|
if isinstance(message.root, types.ProgressNotification): # pragma: no branch
|
|
params = message.root.params
|
|
client_progress_updates.append(
|
|
{
|
|
"token": params.progressToken,
|
|
"progress": params.progress,
|
|
"total": params.total,
|
|
"message": params.message,
|
|
}
|
|
)
|
|
|
|
# Test using client
|
|
async with (
|
|
ClientSession(
|
|
server_to_client_receive,
|
|
client_to_server_send,
|
|
message_handler=handle_client_message,
|
|
) as client_session,
|
|
anyio.create_task_group() as tg,
|
|
):
|
|
# Start the server in a background task
|
|
tg.start_soon(run_server)
|
|
|
|
# Initialize the client connection
|
|
await client_session.initialize()
|
|
|
|
# Call list_tools with progress token
|
|
await client_session.list_tools()
|
|
|
|
# Call test_tool with progress token
|
|
await client_session.call_tool("test_tool", {"_meta": {"progressToken": client_progress_token}})
|
|
|
|
# Send progress notifications from client to server
|
|
await client_session.send_progress_notification(
|
|
progress_token=server_progress_token,
|
|
progress=0.33,
|
|
total=1.0,
|
|
message="Client progress 33%",
|
|
)
|
|
|
|
await client_session.send_progress_notification(
|
|
progress_token=server_progress_token,
|
|
progress=0.66,
|
|
total=1.0,
|
|
message="Client progress 66%",
|
|
)
|
|
|
|
await client_session.send_progress_notification(
|
|
progress_token=server_progress_token,
|
|
progress=1.0,
|
|
total=1.0,
|
|
message="Client progress 100%",
|
|
)
|
|
|
|
# Wait and exit
|
|
await anyio.sleep(0.5)
|
|
tg.cancel_scope.cancel()
|
|
|
|
# Verify client received progress updates from server
|
|
assert len(client_progress_updates) == 3
|
|
assert client_progress_updates[0]["token"] == client_progress_token
|
|
assert client_progress_updates[0]["progress"] == 0.25
|
|
assert client_progress_updates[0]["message"] == "Server progress 25%"
|
|
assert client_progress_updates[2]["progress"] == 1.0
|
|
|
|
# Verify server received progress updates from client
|
|
assert len(server_progress_updates) == 3
|
|
assert server_progress_updates[0]["token"] == server_progress_token
|
|
assert server_progress_updates[0]["progress"] == 0.33
|
|
assert server_progress_updates[0]["message"] == "Client progress 33%"
|
|
assert server_progress_updates[2]["progress"] == 1.0
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_progress_context_manager():
|
|
"""Test client using progress context manager for sending progress notifications."""
|
|
# Create memory streams for client/server
|
|
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5)
|
|
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5)
|
|
|
|
# Track progress updates
|
|
server_progress_updates: list[dict[str, Any]] = []
|
|
|
|
server = Server(name="ProgressContextTestServer")
|
|
|
|
progress_token = None
|
|
|
|
# Register progress handler
|
|
@server.progress_notification()
|
|
async def handle_progress(
|
|
progress_token: str | int,
|
|
progress: float,
|
|
total: float | None,
|
|
message: str | None,
|
|
):
|
|
server_progress_updates.append(
|
|
{"token": progress_token, "progress": progress, "total": total, "message": message}
|
|
)
|
|
|
|
# Run server session to receive progress updates
|
|
async def run_server():
|
|
# Create a server session
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(
|
|
server_name="ProgressContextTestServer",
|
|
server_version="0.1.0",
|
|
capabilities=server.get_capabilities(NotificationOptions(), {}),
|
|
),
|
|
) as server_session:
|
|
async for message in server_session.incoming_messages:
|
|
try:
|
|
await server._handle_message(message, server_session, {})
|
|
except Exception as e: # pragma: no cover
|
|
raise e
|
|
|
|
# Client message handler
|
|
async def handle_client_message(
|
|
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
|
) -> None:
|
|
if isinstance(message, Exception): # pragma: no cover
|
|
raise message
|
|
|
|
# run client session
|
|
async with (
|
|
ClientSession(
|
|
server_to_client_receive,
|
|
client_to_server_send,
|
|
message_handler=handle_client_message,
|
|
) as client_session,
|
|
anyio.create_task_group() as tg,
|
|
):
|
|
tg.start_soon(run_server)
|
|
|
|
await client_session.initialize()
|
|
|
|
progress_token = "client_token_456"
|
|
|
|
# Create request context
|
|
meta = types.RequestParams.Meta(progressToken=progress_token)
|
|
request_context = RequestContext(
|
|
request_id="test-request",
|
|
session=client_session,
|
|
meta=meta,
|
|
lifespan_context=None,
|
|
)
|
|
|
|
# cast for type checker
|
|
typed_context = cast(RequestContext[BaseSession[Any, Any, Any, Any, Any], Any], request_context)
|
|
|
|
# Utilize progress context manager
|
|
with progress(typed_context, total=100) as p:
|
|
await p.progress(10, message="Loading configuration...")
|
|
await p.progress(30, message="Connecting to database...")
|
|
await p.progress(40, message="Fetching data...")
|
|
await p.progress(20, message="Processing results...")
|
|
|
|
# Wait for all messages to be processed
|
|
await anyio.sleep(0.5)
|
|
tg.cancel_scope.cancel()
|
|
|
|
# Verify progress updates were received by server
|
|
assert len(server_progress_updates) == 4
|
|
|
|
# first update
|
|
assert server_progress_updates[0]["token"] == progress_token
|
|
assert server_progress_updates[0]["progress"] == 10
|
|
assert server_progress_updates[0]["total"] == 100
|
|
assert server_progress_updates[0]["message"] == "Loading configuration..."
|
|
|
|
# second update
|
|
assert server_progress_updates[1]["token"] == progress_token
|
|
assert server_progress_updates[1]["progress"] == 40
|
|
assert server_progress_updates[1]["total"] == 100
|
|
assert server_progress_updates[1]["message"] == "Connecting to database..."
|
|
|
|
# third update
|
|
assert server_progress_updates[2]["token"] == progress_token
|
|
assert server_progress_updates[2]["progress"] == 80
|
|
assert server_progress_updates[2]["total"] == 100
|
|
assert server_progress_updates[2]["message"] == "Fetching data..."
|
|
|
|
# final update
|
|
assert server_progress_updates[3]["token"] == progress_token
|
|
assert server_progress_updates[3]["progress"] == 100
|
|
assert server_progress_updates[3]["total"] == 100
|
|
assert server_progress_updates[3]["message"] == "Processing results..."
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_progress_callback_exception_logging():
|
|
"""Test that exceptions in progress callbacks are logged and \
|
|
don't crash the session."""
|
|
# Track logged warnings
|
|
logged_errors: list[str] = []
|
|
|
|
def mock_log_error(msg: str, *args: Any) -> None:
|
|
logged_errors.append(msg % args if args else msg)
|
|
|
|
# Create a progress callback that raises an exception
|
|
async def failing_progress_callback(
|
|
progress: float, total: float | None, message: str | None
|
|
) -> None: # pragma: no cover
|
|
raise ValueError("Progress callback failed!")
|
|
|
|
# Create a server with a tool that sends progress notifications
|
|
server = Server(name="TestProgressServer")
|
|
|
|
@server.call_tool()
|
|
async def handle_call_tool(name: str, arguments: Any) -> list[types.TextContent]:
|
|
if name == "progress_tool":
|
|
# Send a progress notification
|
|
await server.request_context.session.send_progress_notification(
|
|
progress_token=server.request_context.request_id,
|
|
progress=50.0,
|
|
total=100.0,
|
|
message="Halfway done",
|
|
)
|
|
return [types.TextContent(type="text", text="progress_result")]
|
|
raise ValueError(f"Unknown tool: {name}") # pragma: no cover
|
|
|
|
@server.list_tools()
|
|
async def handle_list_tools() -> list[types.Tool]:
|
|
return [
|
|
types.Tool(
|
|
name="progress_tool",
|
|
description="A tool that sends progress notifications",
|
|
inputSchema={},
|
|
)
|
|
]
|
|
|
|
# Test with mocked logging
|
|
with patch("mcp.shared.session.logging.error", side_effect=mock_log_error):
|
|
async with create_connected_server_and_client_session(server) as client_session:
|
|
# Send a request with a failing progress callback
|
|
result = await client_session.send_request(
|
|
types.ClientRequest(
|
|
types.CallToolRequest(
|
|
method="tools/call",
|
|
params=types.CallToolRequestParams(name="progress_tool", arguments={}),
|
|
)
|
|
),
|
|
types.CallToolResult,
|
|
progress_callback=failing_progress_callback,
|
|
)
|
|
|
|
# Verify the request completed successfully despite the callback failure
|
|
assert len(result.content) == 1
|
|
content = result.content[0]
|
|
assert isinstance(content, types.TextContent)
|
|
assert content.text == "progress_result"
|
|
|
|
# Check that a warning was logged for the progress callback exception
|
|
assert len(logged_errors) > 0
|
|
assert any("Progress callback raised an exception" in warning for warning in logged_errors)
|