Add ergonomic Client class for testing MCP servers (#1870)
This commit is contained in:
parent
8adb5bdce8
commit
df039bf97c
25 changed files with 1298 additions and 526 deletions
|
|
@ -123,11 +123,13 @@ def stream_spy() -> Generator[Callable[[], StreamSpyCollection], None, None]:
|
|||
yield (client_read, spy_client_write), (server_read, spy_server_write)
|
||||
|
||||
# Apply the patch for the duration of the test
|
||||
# Patch both locations since InMemoryTransport imports it directly
|
||||
with patch("mcp.shared.memory.create_client_server_memory_streams", patched_create_streams):
|
||||
# Return a collection with helper methods
|
||||
def get_spy_collection() -> StreamSpyCollection:
|
||||
assert client_spy is not None, "client_spy was not initialized"
|
||||
assert server_spy is not None, "server_spy was not initialized"
|
||||
return StreamSpyCollection(client_spy, server_spy)
|
||||
with patch("mcp.client._memory.create_client_server_memory_streams", patched_create_streams):
|
||||
# Return a collection with helper methods
|
||||
def get_spy_collection() -> StreamSpyCollection:
|
||||
assert client_spy is not None, "client_spy was not initialized"
|
||||
assert server_spy is not None, "server_spy was not initialized"
|
||||
return StreamSpyCollection(client_spy, server_spy)
|
||||
|
||||
yield get_spy_collection
|
||||
yield get_spy_collection
|
||||
|
|
|
|||
336
tests/client/test_client.py
Normal file
336
tests/client/test_client.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
"""Tests for the unified Client class."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import mcp.types as types
|
||||
from mcp.client.client import Client
|
||||
from mcp.server import Server
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.types import EmptyResult, Resource
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_server() -> Server:
|
||||
"""Create a simple MCP server for testing."""
|
||||
server = Server(name="test_server")
|
||||
|
||||
@server.list_resources()
|
||||
async def handle_list_resources():
|
||||
return [
|
||||
Resource(
|
||||
uri="memory://test",
|
||||
name="Test Resource",
|
||||
description="A test resource",
|
||||
)
|
||||
]
|
||||
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> FastMCP:
|
||||
"""Create a FastMCP server for testing."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.tool()
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@server.tool()
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
@server.resource("test://resource")
|
||||
def test_resource() -> str:
|
||||
"""A test resource."""
|
||||
return "Test content"
|
||||
|
||||
@server.prompt()
|
||||
def greeting_prompt(name: str) -> str:
|
||||
"""A greeting prompt."""
|
||||
return f"Please greet {name} warmly."
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def test_creates_client(app: FastMCP):
|
||||
"""Test that from_server creates a connected client."""
|
||||
async with Client(app) as client:
|
||||
assert client is not None
|
||||
|
||||
|
||||
async def test_client_is_initialized(app: FastMCP):
|
||||
"""Test that the client is initialized after entering context."""
|
||||
async with Client(app) as client:
|
||||
caps = client.server_capabilities
|
||||
assert caps is not None
|
||||
assert caps.tools is not None
|
||||
|
||||
|
||||
async def test_with_simple_server(simple_server: Server):
|
||||
"""Test that from_server works with a basic Server instance."""
|
||||
async with Client(simple_server) as client:
|
||||
assert client is not None
|
||||
caps = client.server_capabilities
|
||||
assert caps is not None
|
||||
# Verify list_resources works and returns expected resource
|
||||
resources = await client.list_resources()
|
||||
assert len(resources.resources) == 1
|
||||
assert resources.resources[0].uri == "memory://test"
|
||||
|
||||
|
||||
async def test_ping_returns_empty_result(app: FastMCP):
|
||||
"""Test that ping returns an EmptyResult."""
|
||||
async with Client(app) as client:
|
||||
result = await client.send_ping()
|
||||
assert isinstance(result, EmptyResult)
|
||||
|
||||
|
||||
async def test_list_tools(app: FastMCP):
|
||||
"""Test listing tools."""
|
||||
async with Client(app) as client:
|
||||
result = await client.list_tools()
|
||||
assert result.tools is not None
|
||||
tool_names = [t.name for t in result.tools]
|
||||
assert "greet" in tool_names
|
||||
assert "add" in tool_names
|
||||
|
||||
|
||||
async def test_list_tools_with_pagination(app: FastMCP):
|
||||
"""Test listing tools with pagination params."""
|
||||
from mcp.types import PaginatedRequestParams
|
||||
|
||||
async with Client(app) as client:
|
||||
result = await client.list_tools(params=PaginatedRequestParams())
|
||||
assert result.tools is not None
|
||||
|
||||
|
||||
async def test_call_tool(app: FastMCP):
|
||||
"""Test calling a tool."""
|
||||
async with Client(app) as client:
|
||||
result = await client.call_tool("greet", {"name": "World"})
|
||||
assert result.content is not None
|
||||
assert len(result.content) > 0
|
||||
content_str = str(result.content[0])
|
||||
assert "Hello, World!" in content_str
|
||||
|
||||
|
||||
async def test_call_tool_with_multiple_args(app: FastMCP):
|
||||
"""Test calling a tool with multiple arguments."""
|
||||
async with Client(app) as client:
|
||||
result = await client.call_tool("add", {"a": 5, "b": 3})
|
||||
assert result.content is not None
|
||||
content_str = str(result.content[0])
|
||||
assert "8" in content_str
|
||||
|
||||
|
||||
async def test_list_resources(app: FastMCP):
|
||||
"""Test listing resources."""
|
||||
async with Client(app) as client:
|
||||
result = await client.list_resources()
|
||||
# FastMCP may have different resource listing behavior
|
||||
assert result is not None
|
||||
|
||||
|
||||
async def test_read_resource(app: FastMCP):
|
||||
"""Test reading a resource."""
|
||||
async with Client(app) as client:
|
||||
result = await client.read_resource("test://resource")
|
||||
assert result.contents is not None
|
||||
assert len(result.contents) > 0
|
||||
|
||||
|
||||
async def test_list_prompts(app: FastMCP):
|
||||
"""Test listing prompts."""
|
||||
async with Client(app) as client:
|
||||
result = await client.list_prompts()
|
||||
prompt_names = [p.name for p in result.prompts]
|
||||
assert "greeting_prompt" in prompt_names
|
||||
|
||||
|
||||
async def test_get_prompt(app: FastMCP):
|
||||
"""Test getting a prompt."""
|
||||
async with Client(app) as client:
|
||||
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
|
||||
assert result.messages is not None
|
||||
assert len(result.messages) > 0
|
||||
|
||||
|
||||
async def test_session_property(app: FastMCP):
|
||||
"""Test that the session property returns the ClientSession."""
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
async with Client(app) as client:
|
||||
session = client.session
|
||||
assert isinstance(session, ClientSession)
|
||||
|
||||
|
||||
async def test_session_is_same_as_internal(app: FastMCP):
|
||||
"""Test that session property returns consistent instance."""
|
||||
async with Client(app) as client:
|
||||
session1 = client.session
|
||||
session2 = client.session
|
||||
assert session1 is session2
|
||||
|
||||
|
||||
async def test_enters_and_exits_cleanly(app: FastMCP):
|
||||
"""Test that the client enters and exits cleanly."""
|
||||
async with Client(app) as client:
|
||||
# Should be able to use client
|
||||
await client.send_ping()
|
||||
# After exiting, resources should be cleaned up
|
||||
|
||||
|
||||
async def test_exception_during_use(app: FastMCP):
|
||||
"""Test that exceptions during use don't prevent cleanup."""
|
||||
with pytest.raises(Exception): # May be wrapped in ExceptionGroup by anyio
|
||||
async with Client(app) as client:
|
||||
await client.send_ping()
|
||||
raise ValueError("Test exception")
|
||||
# Should exit cleanly despite exception
|
||||
|
||||
|
||||
async def test_aexit_without_aenter(app: FastMCP):
|
||||
"""Test that calling __aexit__ without __aenter__ doesn't raise."""
|
||||
client = Client(app)
|
||||
# This should not raise even though __aenter__ was never called
|
||||
await client.__aexit__(None, None, None)
|
||||
assert client._session is None
|
||||
|
||||
|
||||
async def test_server_capabilities_after_init(app: FastMCP):
|
||||
"""Test server_capabilities property after initialization."""
|
||||
async with Client(app) as client:
|
||||
caps = client.server_capabilities
|
||||
assert caps is not None
|
||||
# FastMCP should advertise tools capability
|
||||
assert caps.tools is not None
|
||||
|
||||
|
||||
def test_session_property_before_enter(app: FastMCP):
|
||||
"""Test that accessing session before context manager raises RuntimeError."""
|
||||
client = Client(app)
|
||||
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
|
||||
_ = client.session
|
||||
|
||||
|
||||
async def test_reentry_raises_runtime_error(app: FastMCP):
|
||||
"""Test that reentering a client raises RuntimeError."""
|
||||
async with Client(app) as client:
|
||||
with pytest.raises(RuntimeError, match="Client is already entered"):
|
||||
await client.__aenter__()
|
||||
|
||||
|
||||
async def test_cleanup_on_init_failure(app: FastMCP):
|
||||
"""Test that resources are cleaned up if initialization fails."""
|
||||
with patch("mcp.client.client.ClientSession") as mock_session_class:
|
||||
# Create a mock context manager that fails on __aenter__
|
||||
mock_session = AsyncMock()
|
||||
mock_session.__aenter__.side_effect = RuntimeError("Session init failed")
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session_class.return_value = mock_session
|
||||
|
||||
client = Client(app)
|
||||
with pytest.raises(BaseException) as exc_info:
|
||||
await client.__aenter__()
|
||||
|
||||
# The error should contain our message (may be wrapped in ExceptionGroup)
|
||||
# Use repr() to see nested exceptions in ExceptionGroup
|
||||
assert "Session init failed" in repr(exc_info.value)
|
||||
|
||||
# Verify the client is in a clean state (session should be None)
|
||||
assert client._session is None
|
||||
|
||||
|
||||
async def test_send_progress_notification(app: FastMCP):
|
||||
"""Test sending progress notification."""
|
||||
async with Client(app) as client:
|
||||
# Send a progress notification - this should not raise
|
||||
await client.send_progress_notification(
|
||||
progress_token="test-token",
|
||||
progress=50.0,
|
||||
total=100.0,
|
||||
message="Half done",
|
||||
)
|
||||
|
||||
|
||||
async def test_subscribe_resource(app: FastMCP):
|
||||
"""Test subscribing to a resource."""
|
||||
async with Client(app) as client:
|
||||
# Mock the session's subscribe_resource since FastMCP doesn't support it
|
||||
with patch.object(client.session, "subscribe_resource", return_value=EmptyResult()):
|
||||
result = await client.subscribe_resource("test://resource")
|
||||
assert isinstance(result, EmptyResult)
|
||||
|
||||
|
||||
async def test_unsubscribe_resource(app: FastMCP):
|
||||
"""Test unsubscribing from a resource."""
|
||||
async with Client(app) as client:
|
||||
# Mock the session's unsubscribe_resource since FastMCP doesn't support it
|
||||
with patch.object(client.session, "unsubscribe_resource", return_value=EmptyResult()):
|
||||
result = await client.unsubscribe_resource("test://resource")
|
||||
assert isinstance(result, EmptyResult)
|
||||
|
||||
|
||||
async def test_send_roots_list_changed(app: FastMCP):
|
||||
"""Test sending roots list changed notification."""
|
||||
async with Client(app) as client:
|
||||
# Send roots list changed notification - should not raise
|
||||
await client.send_roots_list_changed()
|
||||
|
||||
|
||||
async def test_set_logging_level(app: FastMCP):
|
||||
"""Test setting logging level."""
|
||||
async with Client(app) as client:
|
||||
# Mock the session's set_logging_level since FastMCP doesn't support it
|
||||
with patch.object(client.session, "set_logging_level", return_value=EmptyResult()):
|
||||
result = await client.set_logging_level("debug")
|
||||
assert isinstance(result, EmptyResult)
|
||||
|
||||
|
||||
async def test_list_resources_with_params(app: FastMCP):
|
||||
"""Test listing resources with params parameter."""
|
||||
async with Client(app) as client:
|
||||
result = await client.list_resources(params=types.PaginatedRequestParams())
|
||||
assert result is not None
|
||||
|
||||
|
||||
async def test_list_resource_templates_with_params(app: FastMCP):
|
||||
"""Test listing resource templates with params parameter."""
|
||||
async with Client(app) as client:
|
||||
result = await client.list_resource_templates(params=types.PaginatedRequestParams())
|
||||
assert result is not None
|
||||
|
||||
|
||||
async def test_list_resource_templates_default(app: FastMCP):
|
||||
"""Test listing resource templates with no params or cursor."""
|
||||
async with Client(app) as client:
|
||||
result = await client.list_resource_templates()
|
||||
assert result is not None
|
||||
|
||||
|
||||
async def test_list_prompts_with_params(app: FastMCP):
|
||||
"""Test listing prompts with params parameter."""
|
||||
async with Client(app) as client:
|
||||
result = await client.list_prompts(params=types.PaginatedRequestParams())
|
||||
assert result is not None
|
||||
|
||||
|
||||
async def test_complete_with_prompt_reference(app: FastMCP):
|
||||
"""Test getting completions for a prompt argument."""
|
||||
async with Client(app) as client:
|
||||
ref = types.PromptReference(type="ref/prompt", name="greeting_prompt")
|
||||
# Mock the session's complete method since FastMCP may not support it
|
||||
with patch.object(
|
||||
client.session,
|
||||
"complete",
|
||||
return_value=types.CompleteResult(completion=types.Completion(values=[])),
|
||||
):
|
||||
result = await client.complete(ref=ref, argument={"name": "test"})
|
||||
assert result is not None
|
||||
|
|
@ -3,9 +3,10 @@ from collections.abc import Callable
|
|||
import pytest
|
||||
|
||||
import mcp.types as types
|
||||
from mcp.client._memory import InMemoryTransport
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server import Server
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.shared.memory import create_connected_server_and_client_session as create_session
|
||||
from mcp.types import ListToolsRequest, ListToolsResult
|
||||
|
||||
from .conftest import StreamSpyCollection
|
||||
|
|
@ -18,30 +19,28 @@ async def full_featured_server():
|
|||
"""Create a server with tools, resources, prompts, and templates."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.tool(name="test_tool_1")
|
||||
async def test_tool_1() -> str: # pragma: no cover
|
||||
"""First test tool"""
|
||||
return "Result 1"
|
||||
|
||||
@server.tool(name="test_tool_2")
|
||||
async def test_tool_2() -> str: # pragma: no cover
|
||||
"""Second test tool"""
|
||||
return "Result 2"
|
||||
|
||||
@server.resource("resource://test/data")
|
||||
async def test_resource() -> str: # pragma: no cover
|
||||
"""Test resource"""
|
||||
return "Test data"
|
||||
|
||||
@server.prompt()
|
||||
async def test_prompt(name: str) -> str: # pragma: no cover
|
||||
"""Test prompt"""
|
||||
# pragma: no cover on handlers below - these exist only to register items with the
|
||||
# server so list_* methods return results. The handlers themselves are never called
|
||||
# because these tests only verify pagination/cursor behavior, not tool/resource invocation.
|
||||
@server.tool()
|
||||
def greet(name: str) -> str: # pragma: no cover
|
||||
"""Greet someone by name."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@server.resource("resource://test/{name}")
|
||||
async def test_template(name: str) -> str: # pragma: no cover
|
||||
"""Test resource template"""
|
||||
return f"Data for {name}"
|
||||
@server.resource("test://resource")
|
||||
def test_resource() -> str: # pragma: no cover
|
||||
"""A test resource."""
|
||||
return "Test content"
|
||||
|
||||
@server.resource("test://template/{id}")
|
||||
def test_template(id: str) -> str: # pragma: no cover
|
||||
"""A test resource template."""
|
||||
return f"Template content for {id}"
|
||||
|
||||
@server.prompt()
|
||||
def greeting_prompt(name: str) -> str: # pragma: no cover
|
||||
"""A greeting prompt."""
|
||||
return f"Please greet {name}."
|
||||
|
||||
return server
|
||||
|
||||
|
|
@ -61,78 +60,82 @@ async def test_list_methods_params_parameter(
|
|||
method_name: str,
|
||||
request_method: str,
|
||||
):
|
||||
"""Test that the params parameter works correctly for list methods.
|
||||
"""Test that the params parameter is accepted and correctly passed to the server.
|
||||
|
||||
Covers: list_tools, list_resources, list_prompts, list_resource_templates
|
||||
|
||||
This tests the new params parameter API (non-deprecated) to ensure
|
||||
it correctly handles all parameter combinations.
|
||||
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
|
||||
"""
|
||||
async with create_session(full_featured_server._mcp_server) as client_session:
|
||||
spies = stream_spy()
|
||||
method = getattr(client_session, method_name)
|
||||
transport = InMemoryTransport(full_featured_server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
spies = stream_spy()
|
||||
|
||||
# Test without params parameter (omitted)
|
||||
_ = await method()
|
||||
requests = spies.get_client_requests(method=request_method)
|
||||
assert len(requests) == 1
|
||||
assert requests[0].params is None
|
||||
# Test without params (omitted)
|
||||
method = getattr(session, method_name)
|
||||
_ = await method()
|
||||
requests = spies.get_client_requests(method=request_method)
|
||||
assert len(requests) == 1
|
||||
assert requests[0].params is None
|
||||
|
||||
spies.clear()
|
||||
spies.clear()
|
||||
|
||||
# Test with params=None
|
||||
_ = await method(params=None)
|
||||
requests = spies.get_client_requests(method=request_method)
|
||||
assert len(requests) == 1
|
||||
assert requests[0].params is None
|
||||
# Test with params containing cursor
|
||||
_ = await method(params=types.PaginatedRequestParams(cursor="from_params"))
|
||||
requests = spies.get_client_requests(method=request_method)
|
||||
assert len(requests) == 1
|
||||
assert requests[0].params is not None
|
||||
assert requests[0].params["cursor"] == "from_params"
|
||||
|
||||
spies.clear()
|
||||
spies.clear()
|
||||
|
||||
# Test with empty params (for strict servers)
|
||||
_ = await method(params=types.PaginatedRequestParams())
|
||||
requests = spies.get_client_requests(method=request_method)
|
||||
assert len(requests) == 1
|
||||
assert requests[0].params is not None
|
||||
assert requests[0].params.get("cursor") is None
|
||||
|
||||
spies.clear()
|
||||
|
||||
# Test with params containing cursor
|
||||
_ = await method(params=types.PaginatedRequestParams(cursor="some_cursor_value"))
|
||||
requests = spies.get_client_requests(method=request_method)
|
||||
assert len(requests) == 1
|
||||
assert requests[0].params is not None
|
||||
assert requests[0].params["cursor"] == "some_cursor_value"
|
||||
# Test with empty params
|
||||
_ = await method(params=types.PaginatedRequestParams())
|
||||
requests = spies.get_client_requests(method=request_method)
|
||||
assert len(requests) == 1
|
||||
# Empty params means no cursor
|
||||
assert requests[0].params is None or "cursor" not in requests[0].params
|
||||
|
||||
|
||||
async def test_list_tools_with_strict_server_validation():
|
||||
"""Test that list_tools works with strict servers require a params field,
|
||||
even if it is empty.
|
||||
async def test_list_tools_with_strict_server_validation(
|
||||
full_featured_server: FastMCP,
|
||||
):
|
||||
"""Test pagination with a server that validates request format strictly."""
|
||||
transport = InMemoryTransport(full_featured_server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
result = await session.list_tools(params=types.PaginatedRequestParams())
|
||||
assert isinstance(result, ListToolsResult)
|
||||
assert len(result.tools) > 0
|
||||
|
||||
Some MCP servers may implement strict JSON-RPC validation that requires
|
||||
the params field to always be present in requests, even if empty {}.
|
||||
|
||||
This test ensures such servers are supported by the client SDK for list_resources
|
||||
requests without a cursor.
|
||||
"""
|
||||
|
||||
server = Server("strict_server")
|
||||
async def test_list_tools_with_lowlevel_server():
|
||||
"""Test that list_tools works with a lowlevel Server using params."""
|
||||
server = Server("test-lowlevel")
|
||||
|
||||
@server.list_tools()
|
||||
async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult: # pragma: no cover
|
||||
"""Strict handler that validates params field exists"""
|
||||
async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult:
|
||||
# Echo back what cursor we received in the tool description
|
||||
cursor = request.params.cursor if request.params else None
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="test_tool",
|
||||
description=f"cursor={cursor}",
|
||||
input_schema={},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Simulate strict server validation
|
||||
if request.params is None:
|
||||
raise ValueError(
|
||||
"Strict server validation failed: params field must be present. "
|
||||
"Expected params: {} for requests without cursor."
|
||||
)
|
||||
transport = InMemoryTransport(server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Return empty tools list
|
||||
return ListToolsResult(tools=[])
|
||||
result = await session.list_tools(params=types.PaginatedRequestParams())
|
||||
assert result.tools[0].description == "cursor=None"
|
||||
|
||||
async with create_session(server) as client_session:
|
||||
# Use params to explicitly send params: {} for strict server compatibility
|
||||
result = await client_session.list_tools(params=types.PaginatedRequestParams())
|
||||
assert result is not None
|
||||
result = await session.list_tools(params=types.PaginatedRequestParams(cursor="page2"))
|
||||
assert result.tools[0].description == "cursor=page2"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import pytest
|
||||
from pydantic import FileUrl
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.fastmcp.server import Context
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as create_session,
|
||||
)
|
||||
from mcp.types import ListRootsResult, Root, TextContent
|
||||
|
||||
|
||||
|
|
@ -41,17 +39,17 @@ async def test_list_roots_callback():
|
|||
return True
|
||||
|
||||
# Test with list_roots callback
|
||||
async with create_session(server._mcp_server, list_roots_callback=list_roots_callback) as client_session:
|
||||
async with Client(server, list_roots_callback=list_roots_callback) as client:
|
||||
# Make a request to trigger sampling callback
|
||||
result = await client_session.call_tool("test_list_roots", {"message": "test message"})
|
||||
result = await client.call_tool("test_list_roots", {"message": "test message"})
|
||||
assert result.is_error is False
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "true"
|
||||
|
||||
# Test without list_roots callback
|
||||
async with create_session(server._mcp_server) as client_session:
|
||||
async with Client(server) as client:
|
||||
# Make a request to trigger sampling callback
|
||||
result = await client_session.call_tool("test_list_roots", {"message": "test message"})
|
||||
result = await client.call_tool("test_list_roots", {"message": "test message"})
|
||||
assert result.is_error is True
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@ from typing import Any, Literal
|
|||
import pytest
|
||||
|
||||
import mcp.types as types
|
||||
from mcp import Client
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as create_session,
|
||||
)
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
LoggingMessageNotificationParams,
|
||||
|
|
@ -70,19 +68,19 @@ async def test_logging_callback():
|
|||
if isinstance(message, Exception): # pragma: no cover
|
||||
raise message
|
||||
|
||||
async with create_session(
|
||||
server._mcp_server,
|
||||
async with Client(
|
||||
server,
|
||||
logging_callback=logging_collector,
|
||||
message_handler=message_handler,
|
||||
) as client_session:
|
||||
) as client:
|
||||
# First verify our test tool works
|
||||
result = await client_session.call_tool("test_tool", {})
|
||||
result = await client.call_tool("test_tool", {})
|
||||
assert result.is_error is False
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "true"
|
||||
|
||||
# Now send a log message via our tool
|
||||
log_result = await client_session.call_tool(
|
||||
log_result = await client.call_tool(
|
||||
"test_tool_with_log",
|
||||
{
|
||||
"message": "Test log message",
|
||||
|
|
@ -90,7 +88,7 @@ async def test_logging_callback():
|
|||
"logger": "test_logger",
|
||||
},
|
||||
)
|
||||
log_result_with_extra = await client_session.call_tool(
|
||||
log_result_with_extra = await client.call_tool(
|
||||
"test_tool_with_log_extra",
|
||||
{
|
||||
"message": "Test log message",
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ from unittest.mock import patch
|
|||
import jsonschema
|
||||
import pytest
|
||||
|
||||
from mcp import Client
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as client_session,
|
||||
)
|
||||
from mcp.types import Tool
|
||||
|
||||
|
||||
|
|
@ -77,7 +75,7 @@ class TestClientOutputSchemaValidation:
|
|||
|
||||
# Test that client validates the structured content
|
||||
with bypass_server_output_validation():
|
||||
async with client_session(server) as client:
|
||||
async with Client(server) as client:
|
||||
# The client validates structured content and should raise an error
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await client.call_tool("get_user", {})
|
||||
|
|
@ -114,7 +112,7 @@ class TestClientOutputSchemaValidation:
|
|||
return {"result": "not_a_number"} # Invalid: should be int
|
||||
|
||||
with bypass_server_output_validation():
|
||||
async with client_session(server) as client:
|
||||
async with Client(server) as client:
|
||||
# The client validates structured content and should raise an error
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await client.call_tool("calculate", {})
|
||||
|
|
@ -145,7 +143,7 @@ class TestClientOutputSchemaValidation:
|
|||
return {"alice": "100", "bob": "85"} # Invalid: values should be int
|
||||
|
||||
with bypass_server_output_validation():
|
||||
async with client_session(server) as client:
|
||||
async with Client(server) as client:
|
||||
# The client validates structured content and should raise an error
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await client.call_tool("get_scores", {})
|
||||
|
|
@ -180,7 +178,7 @@ class TestClientOutputSchemaValidation:
|
|||
return {"name": "John", "age": 30} # Missing required 'email'
|
||||
|
||||
with bypass_server_output_validation():
|
||||
async with client_session(server) as client:
|
||||
async with Client(server) as client:
|
||||
# The client validates structured content and should raise an error
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await client.call_tool("get_person", {})
|
||||
|
|
@ -205,7 +203,7 @@ class TestClientOutputSchemaValidation:
|
|||
caplog.set_level(logging.WARNING)
|
||||
|
||||
with bypass_server_output_validation():
|
||||
async with client_session(server) as client:
|
||||
async with Client(server) as client:
|
||||
# Call a tool that wasn't listed
|
||||
result = await client.call_tool("mystery_tool", {})
|
||||
assert result.structured_content == {"result": 42}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import pytest
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as create_session,
|
||||
)
|
||||
from mcp.types import (
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
|
|
@ -43,17 +41,17 @@ async def test_sampling_callback():
|
|||
return True
|
||||
|
||||
# Test with sampling callback
|
||||
async with create_session(server._mcp_server, sampling_callback=sampling_callback) as client_session:
|
||||
async with Client(server, sampling_callback=sampling_callback) as client:
|
||||
# Make a request to trigger sampling callback
|
||||
result = await client_session.call_tool("test_sampling", {"message": "Test message for sampling"})
|
||||
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
|
||||
assert result.is_error is False
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "true"
|
||||
|
||||
# Test without sampling callback
|
||||
async with create_session(server._mcp_server) as client_session:
|
||||
async with Client(server) as client:
|
||||
# Make a request to trigger sampling callback
|
||||
result = await client_session.call_tool("test_sampling", {"message": "Test message for sampling"})
|
||||
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
|
||||
assert result.is_error is True
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported"
|
||||
|
|
@ -94,8 +92,8 @@ async def test_create_message_backwards_compat_single_content():
|
|||
assert not hasattr(result, "content_as_list") or not callable(getattr(result, "content_as_list", None))
|
||||
return True
|
||||
|
||||
async with create_session(server._mcp_server, sampling_callback=sampling_callback) as client_session:
|
||||
result = await client_session.call_tool("test_backwards_compat", {"message": "Test"})
|
||||
async with Client(server, sampling_callback=sampling_callback) as client:
|
||||
result = await client.call_tool("test_backwards_compat", {"message": "Test"})
|
||||
assert result.is_error is False
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "true"
|
||||
|
|
|
|||
0
tests/client/transports/__init__.py
Normal file
0
tests/client/transports/__init__.py
Normal file
114
tests/client/transports/test_memory.py
Normal file
114
tests/client/transports/test_memory.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Tests for InMemoryTransport."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.client._memory import InMemoryTransport
|
||||
from mcp.server import Server
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.types import Resource
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_server() -> Server:
|
||||
"""Create a simple MCP server for testing."""
|
||||
server = Server(name="test_server")
|
||||
|
||||
# pragma: no cover - handler exists only to register a resource capability.
|
||||
# Transport tests verify stream creation, not handler invocation.
|
||||
@server.list_resources()
|
||||
async def handle_list_resources(): # pragma: no cover
|
||||
return [
|
||||
Resource(
|
||||
uri="memory://test",
|
||||
name="Test Resource",
|
||||
description="A test resource",
|
||||
)
|
||||
]
|
||||
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fastmcp_server() -> FastMCP:
|
||||
"""Create a FastMCP server for testing."""
|
||||
server = FastMCP("test")
|
||||
|
||||
# pragma: no cover on handlers below - they exist only to register capabilities.
|
||||
# Transport tests verify stream creation and basic protocol, not handler invocation.
|
||||
@server.tool()
|
||||
def greet(name: str) -> str: # pragma: no cover
|
||||
"""Greet someone by name."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@server.resource("test://resource")
|
||||
def test_resource() -> str: # pragma: no cover
|
||||
"""A test resource."""
|
||||
return "Test content"
|
||||
|
||||
return server
|
||||
|
||||
|
||||
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):
|
||||
assert read_stream is not None
|
||||
assert write_stream is not None
|
||||
|
||||
|
||||
async def test_with_fastmcp(fastmcp_server: FastMCP):
|
||||
"""Test creating transport with a FastMCP instance."""
|
||||
transport = InMemoryTransport(fastmcp_server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
assert read_stream is not None
|
||||
assert write_stream is not None
|
||||
|
||||
|
||||
async def test_server_is_running(fastmcp_server: FastMCP):
|
||||
"""Test that the server is running and responding to requests."""
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
transport = InMemoryTransport(fastmcp_server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
result = await session.initialize()
|
||||
assert result is not None
|
||||
assert result.server_info.name == "test"
|
||||
|
||||
|
||||
async def test_list_tools(fastmcp_server: FastMCP):
|
||||
"""Test listing tools through the transport."""
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
transport = InMemoryTransport(fastmcp_server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
tools_result = await session.list_tools()
|
||||
assert len(tools_result.tools) > 0
|
||||
tool_names = [t.name for t in tools_result.tools]
|
||||
assert "greet" in tool_names
|
||||
|
||||
|
||||
async def test_call_tool(fastmcp_server: FastMCP):
|
||||
"""Test calling a tool through the transport."""
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
transport = InMemoryTransport(fastmcp_server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
result = await session.call_tool("greet", {"name": "World"})
|
||||
assert result is not None
|
||||
assert len(result.content) > 0
|
||||
assert "Hello, World!" in str(result.content[0])
|
||||
|
||||
|
||||
async def test_raise_exceptions(fastmcp_server: FastMCP):
|
||||
"""Test that raise_exceptions parameter is passed through."""
|
||||
transport = InMemoryTransport(fastmcp_server, raise_exceptions=True)
|
||||
async with transport.connect() as (read_stream, _write_stream):
|
||||
assert read_stream is not None
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp import Client
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as client_session,
|
||||
)
|
||||
from mcp.types import (
|
||||
ListResourceTemplatesResult,
|
||||
TextResourceContents,
|
||||
|
|
@ -78,10 +76,7 @@ async def test_resource_template_client_interaction():
|
|||
def get_user_profile(user_id: str) -> str:
|
||||
return f"Profile for user {user_id}"
|
||||
|
||||
async with client_session(mcp._mcp_server) as session:
|
||||
# Initialize the session
|
||||
await session.initialize()
|
||||
|
||||
async with Client(mcp) as session:
|
||||
# List available resources
|
||||
resources = await session.list_resource_templates()
|
||||
assert isinstance(resources, ListResourceTemplatesResult)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,10 @@ import base64
|
|||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp import types
|
||||
from mcp import Client, types
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as client_session,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
|
@ -33,7 +30,7 @@ async def test_fastmcp_resource_mime_type():
|
|||
return image_bytes
|
||||
|
||||
# Test that resources are listed with correct mime type
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
# List resources and verify mime types
|
||||
resources = await client.list_resources()
|
||||
assert resources.resources is not None
|
||||
|
|
@ -91,7 +88,7 @@ async def test_lowlevel_resource_mime_type():
|
|||
raise Exception(f"Resource not found: {uri}") # pragma: no cover
|
||||
|
||||
# Test that resources are listed with correct mime type
|
||||
async with client_session(server) as client:
|
||||
async with Client(server) as client:
|
||||
# List resources and verify mime types
|
||||
resources = await client.list_resources()
|
||||
assert resources.resources is not None
|
||||
|
|
|
|||
|
|
@ -12,12 +12,9 @@ These tests verify the fix works end-to-end through the JSON-RPC protocol.
|
|||
|
||||
import pytest
|
||||
|
||||
from mcp import types
|
||||
from mcp import Client, types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as client_session,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
|
@ -48,7 +45,7 @@ async def test_relative_uri_roundtrip():
|
|||
)
|
||||
]
|
||||
|
||||
async with client_session(server) as client:
|
||||
async with Client(server) as client:
|
||||
# List should return the exact URIs we specified
|
||||
resources = await client.list_resources()
|
||||
uri_map = {r.uri: r for r in resources.resources}
|
||||
|
|
@ -83,7 +80,7 @@ async def test_custom_scheme_uri_roundtrip():
|
|||
async def read_resource(uri: str):
|
||||
return [ReadResourceContents(content="data", mime_type="text/plain")]
|
||||
|
||||
async with client_session(server) as client:
|
||||
async with Client(server) as client:
|
||||
resources = await client.list_resources()
|
||||
uri_map = {r.uri: r for r in resources.resources}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ with parameters like 'text/html;profile=mcp-app' which are valid per RFC 2045.
|
|||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp import Client
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as client_session,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
|
@ -63,7 +61,7 @@ async def test_mime_type_preserved_in_read_resource():
|
|||
def my_widget() -> str:
|
||||
return "<html><body>Hello MCP-UI</body></html>"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
# Read the resource
|
||||
result = await client.read_resource(AnyUrl("ui://my-widget"))
|
||||
assert len(result.contents) == 1
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import anyio
|
|||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp import Client
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.shared.memory import create_connected_server_and_client_session as create_session
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -30,7 +30,7 @@ async def test_messages_are_executed_concurrently_tools():
|
|||
call_order.append("trigger_end")
|
||||
return "slow"
|
||||
|
||||
async with create_session(server._mcp_server) as client_session:
|
||||
async with Client(server) as client_session:
|
||||
# First tool will wait on event, second will set it
|
||||
async with anyio.create_task_group() as tg:
|
||||
# Start the tool first (it will wait on event)
|
||||
|
|
@ -70,7 +70,7 @@ async def test_messages_are_executed_concurrently_tools_and_resources():
|
|||
call_order.append("resource_end")
|
||||
return "slow"
|
||||
|
||||
async with create_session(server._mcp_server) as client_session:
|
||||
async with Client(server) as client_session:
|
||||
# First tool will wait on event, second will set it
|
||||
async with anyio.create_task_group() as tg:
|
||||
# Start the tool first (it will wait on event)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,11 @@ from typing import Any
|
|||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp import types
|
||||
from mcp import Client, types
|
||||
from mcp.client.session import ClientSession, ElicitationFnT
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
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.types import ElicitRequestParams, ElicitResult, TextContent
|
||||
|
||||
|
||||
|
|
@ -47,12 +46,8 @@ async def call_tool_and_assert(
|
|||
text_contains: list[str] | None = None,
|
||||
):
|
||||
"""Helper to create session, call tool, and assert result."""
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool(tool_name, args)
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool(tool_name, args)
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
|
||||
|
|
@ -134,14 +129,10 @@ async def test_elicitation_schema_validation():
|
|||
): # pragma: no cover
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
# Test both invalid schemas
|
||||
for tool_name, field_name in [("invalid_list", "numbers"), ("nested_model", "nested")]:
|
||||
result = await client_session.call_tool(tool_name, {})
|
||||
result = await client.call_tool(tool_name, {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "Validation failed as expected" in result.content[0].text
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from pydantic import BaseModel
|
|||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from mcp.client import Client
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
from mcp.server.fastmcp.prompts.base import Message, UserMessage
|
||||
|
|
@ -16,7 +17,6 @@ from mcp.server.fastmcp.utilities.types import Audio, Image
|
|||
from mcp.server.session import ServerSession
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.shared.memory import create_connected_server_and_client_session as client_session
|
||||
from mcp.types import (
|
||||
AudioContent,
|
||||
BlobResourceContents,
|
||||
|
|
@ -77,7 +77,7 @@ class TestServer:
|
|||
def hello_world(name: str = "世界") -> str:
|
||||
return f"¡Hola, {name}! 👋"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert len(tools.tools) == 1
|
||||
tool = tools.tools[0]
|
||||
|
|
@ -230,7 +230,7 @@ class TestServerTools:
|
|||
async def test_list_tools(self):
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(tool_fn)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert len(tools.tools) == 1
|
||||
|
||||
|
|
@ -238,7 +238,7 @@ class TestServerTools:
|
|||
async def test_call_tool(self):
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(tool_fn)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("my_tool", {"arg1": "value"})
|
||||
assert not hasattr(result, "error")
|
||||
assert len(result.content) > 0
|
||||
|
|
@ -247,7 +247,7 @@ class TestServerTools:
|
|||
async def test_tool_exception_handling(self):
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(error_tool_fn)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("error_tool_fn", {})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -259,7 +259,7 @@ class TestServerTools:
|
|||
async def test_tool_error_handling(self):
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(error_tool_fn)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("error_tool_fn", {})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -272,7 +272,7 @@ class TestServerTools:
|
|||
"""Test that exception details are properly formatted in the response"""
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(error_tool_fn)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("error_tool_fn", {})
|
||||
content = result.content[0]
|
||||
assert isinstance(content, TextContent)
|
||||
|
|
@ -284,7 +284,7 @@ class TestServerTools:
|
|||
async def test_tool_return_value_conversion(self):
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(tool_fn)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -302,7 +302,7 @@ class TestServerTools:
|
|||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(image_tool_fn)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -323,7 +323,7 @@ class TestServerTools:
|
|||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(audio_tool_fn)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("audio_tool_fn", {"path": str(audio_path)})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -358,7 +358,7 @@ class TestServerTools:
|
|||
audio_path = tmp_path / filename
|
||||
audio_path.write_bytes(b"fake audio data")
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("audio_tool_fn", {"path": str(audio_path)})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -373,7 +373,7 @@ class TestServerTools:
|
|||
async def test_tool_mixed_content(self):
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(mixed_content_tool_fn)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("mixed_content_tool_fn", {})
|
||||
assert len(result.content) == 3
|
||||
content1, content2, content3 = result.content
|
||||
|
|
@ -425,7 +425,7 @@ class TestServerTools:
|
|||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(mixed_list_fn) # type: ignore
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("mixed_list_fn", {})
|
||||
assert len(result.content) == 5
|
||||
# Check text conversion
|
||||
|
|
@ -469,7 +469,7 @@ class TestServerTools:
|
|||
mcp = FastMCP()
|
||||
mcp.add_tool(get_user)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
# Check that the tool has outputSchema
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools.tools if t.name == "get_user")
|
||||
|
|
@ -499,7 +499,7 @@ class TestServerTools:
|
|||
mcp = FastMCP()
|
||||
mcp.add_tool(calculate_sum)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
# Check that the tool has outputSchema
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools.tools if t.name == "calculate_sum")
|
||||
|
|
@ -526,7 +526,7 @@ class TestServerTools:
|
|||
mcp = FastMCP()
|
||||
mcp.add_tool(get_numbers)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("get_numbers", {})
|
||||
assert result.is_error is False
|
||||
assert result.structured_content is not None
|
||||
|
|
@ -542,7 +542,7 @@ class TestServerTools:
|
|||
mcp = FastMCP()
|
||||
mcp.add_tool(get_numbers)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("get_numbers", {})
|
||||
assert result.is_error is True
|
||||
assert result.structured_content is None
|
||||
|
|
@ -566,7 +566,7 @@ class TestServerTools:
|
|||
mcp = FastMCP()
|
||||
mcp.add_tool(get_metadata)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
# Check schema
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools.tools if t.name == "get_metadata")
|
||||
|
|
@ -602,7 +602,7 @@ class TestServerTools:
|
|||
mcp = FastMCP()
|
||||
mcp.add_tool(get_settings)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
# Check schema
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools.tools if t.name == "get_settings")
|
||||
|
|
@ -646,7 +646,7 @@ class TestServerTools:
|
|||
mcp.add_tool(error_tool_fn)
|
||||
|
||||
# Verify both tools exist
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert len(tools.tools) == 2
|
||||
tool_names = [t.name for t in tools.tools]
|
||||
|
|
@ -657,7 +657,7 @@ class TestServerTools:
|
|||
mcp.remove_tool("tool_fn")
|
||||
|
||||
# Verify only one tool remains
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert len(tools.tools) == 1
|
||||
assert tools.tools[0].name == "error_tool_fn"
|
||||
|
|
@ -669,7 +669,7 @@ class TestServerTools:
|
|||
mcp.add_tool(tool_fn)
|
||||
|
||||
# Verify tool works before removal
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
|
||||
assert not result.is_error
|
||||
content = result.content[0]
|
||||
|
|
@ -680,7 +680,7 @@ class TestServerTools:
|
|||
mcp.remove_tool("tool_fn")
|
||||
|
||||
# Verify calling removed tool returns an error
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
|
||||
assert result.is_error
|
||||
content = result.content[0]
|
||||
|
|
@ -699,8 +699,12 @@ class TestServerResources:
|
|||
resource = FunctionResource(uri="resource://test", name="test", fn=get_text)
|
||||
mcp.add_resource(resource)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://test")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://test")
|
||||
|
||||
assert isinstance(result.contents[0], TextResourceContents)
|
||||
assert result.contents[0].text == "Hello, world!"
|
||||
|
||||
|
|
@ -719,8 +723,12 @@ class TestServerResources:
|
|||
)
|
||||
mcp.add_resource(resource)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://binary")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://binary")
|
||||
|
||||
assert isinstance(result.contents[0], BlobResourceContents)
|
||||
assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
|
||||
|
||||
|
|
@ -735,8 +743,12 @@ class TestServerResources:
|
|||
resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file)
|
||||
mcp.add_resource(resource)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("file://test.txt")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("file://test.txt")
|
||||
|
||||
assert isinstance(result.contents[0], TextResourceContents)
|
||||
assert result.contents[0].text == "Hello from file!"
|
||||
|
||||
|
|
@ -756,8 +768,12 @@ class TestServerResources:
|
|||
)
|
||||
mcp.add_resource(resource)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("file://test.bin")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("file://test.bin")
|
||||
|
||||
assert isinstance(result.contents[0], BlobResourceContents)
|
||||
assert result.contents[0].blob == base64.b64encode(b"Binary file data").decode()
|
||||
|
||||
|
|
@ -770,7 +786,7 @@ class TestServerResources:
|
|||
"""get_data returns a string"""
|
||||
return "Hello, world!"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
resources = await client.list_resources()
|
||||
assert len(resources.resources) == 1
|
||||
resource = resources.resources[0]
|
||||
|
|
@ -822,8 +838,12 @@ class TestServerResourceTemplates:
|
|||
def get_data(name: str) -> str:
|
||||
return f"Data for {name}"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://test/data")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://test/data")
|
||||
|
||||
assert isinstance(result.contents[0], TextResourceContents)
|
||||
assert result.contents[0].text == "Data for test"
|
||||
|
||||
|
|
@ -847,8 +867,12 @@ class TestServerResourceTemplates:
|
|||
def get_data(org: str, repo: str) -> str:
|
||||
return f"Data for {org}/{repo}"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://cursor/fastmcp/data")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://cursor/fastmcp/data")
|
||||
|
||||
assert isinstance(result.contents[0], TextResourceContents)
|
||||
assert result.contents[0].text == "Data for cursor/fastmcp"
|
||||
|
||||
|
|
@ -870,8 +894,12 @@ class TestServerResourceTemplates:
|
|||
def get_static_data() -> str:
|
||||
return "Static data"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://static")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://static")
|
||||
|
||||
assert isinstance(result.contents[0], TextResourceContents)
|
||||
assert result.contents[0].text == "Static data"
|
||||
|
||||
|
|
@ -910,8 +938,12 @@ class TestServerResourceTemplates:
|
|||
assert hasattr(template, "mime_type")
|
||||
assert template.mime_type == "text/csv"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://bob/csv")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://bob/csv")
|
||||
|
||||
assert isinstance(result.contents[0], TextResourceContents)
|
||||
assert result.contents[0].text == "csv for bob"
|
||||
|
||||
|
|
@ -972,7 +1004,10 @@ class TestServerResourceMetadata:
|
|||
def get_data() -> str:
|
||||
return "test data"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://data")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://data")
|
||||
|
||||
# Verify content and metadata in protocol response
|
||||
|
|
@ -1008,7 +1043,7 @@ class TestContextInjection:
|
|||
return f"Request {ctx.request_id}: {x}"
|
||||
|
||||
mcp.add_tool(tool_with_context)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("tool_with_context", {"x": 42})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -1026,7 +1061,7 @@ class TestContextInjection:
|
|||
return f"Async request {ctx.request_id}: {x}"
|
||||
|
||||
mcp.add_tool(async_tool)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("async_tool", {"x": 42})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -1049,7 +1084,7 @@ class TestContextInjection:
|
|||
mcp.add_tool(logging_tool)
|
||||
|
||||
with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("logging_tool", {"msg": "test"})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -1091,7 +1126,7 @@ class TestContextInjection:
|
|||
return x * 2
|
||||
|
||||
mcp.add_tool(no_context)
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("no_context", {"x": 21})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -1115,7 +1150,7 @@ class TestContextInjection:
|
|||
r = r_list[0]
|
||||
return f"Read resource: {r.content} with mime type {r.mime_type}"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("tool_with_resource", {})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
|
|
@ -1141,8 +1176,13 @@ class TestContextInjection:
|
|||
assert template.context_kwarg == "ctx"
|
||||
|
||||
# Test via client
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://context/test")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://context/test")
|
||||
|
||||
assert len(result.contents) == 1
|
||||
content = result.contents[0]
|
||||
assert isinstance(content, TextResourceContents)
|
||||
|
|
@ -1166,8 +1206,13 @@ class TestContextInjection:
|
|||
assert template.context_kwarg is None
|
||||
|
||||
# Test via client
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://nocontext/test")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://nocontext/test")
|
||||
|
||||
assert len(result.contents) == 1
|
||||
content = result.contents[0]
|
||||
assert isinstance(content, TextResourceContents)
|
||||
|
|
@ -1191,8 +1236,13 @@ class TestContextInjection:
|
|||
assert template.context_kwarg == "my_ctx"
|
||||
|
||||
# Test via client
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://custom/123")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://custom/123")
|
||||
|
||||
assert len(result.contents) == 1
|
||||
content = result.contents[0]
|
||||
assert isinstance(content, TextResourceContents)
|
||||
|
|
@ -1214,7 +1264,7 @@ class TestContextInjection:
|
|||
assert len(prompts) == 1
|
||||
|
||||
# Test via client
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
# Try calling without passing ctx explicitly
|
||||
result = await client.get_prompt("prompt_with_ctx", {"text": "test"})
|
||||
# If this succeeds, check if context was injected
|
||||
|
|
@ -1234,7 +1284,7 @@ class TestContextInjection:
|
|||
return f"Prompt '{text}' works"
|
||||
|
||||
# Test via client
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt("prompt_no_ctx", {"text": "test"})
|
||||
assert len(result.messages) == 1
|
||||
message = result.messages[0]
|
||||
|
|
@ -1313,7 +1363,7 @@ class TestServerPrompts:
|
|||
def fn(name: str, optional: str = "default") -> str: # pragma: no cover
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.list_prompts()
|
||||
assert result.prompts is not None
|
||||
assert len(result.prompts) == 1
|
||||
|
|
@ -1335,7 +1385,7 @@ class TestServerPrompts:
|
|||
def fn(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt("fn", {"name": "World"})
|
||||
assert len(result.messages) == 1
|
||||
message = result.messages[0]
|
||||
|
|
@ -1353,7 +1403,7 @@ class TestServerPrompts:
|
|||
def fn(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt("fn", {"name": "World"})
|
||||
assert result.description == "Test prompt description"
|
||||
|
||||
|
|
@ -1366,7 +1416,7 @@ class TestServerPrompts:
|
|||
def fn(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt("fn", {"name": "World"})
|
||||
assert result.description == ""
|
||||
|
||||
|
|
@ -1380,7 +1430,7 @@ class TestServerPrompts:
|
|||
"""This is the function docstring."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt("fn", {"name": "World"})
|
||||
assert result.description == "This is the function docstring."
|
||||
|
||||
|
|
@ -1402,7 +1452,7 @@ class TestServerPrompts:
|
|||
)
|
||||
)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt("fn")
|
||||
assert len(result.messages) == 1
|
||||
message = result.messages[0]
|
||||
|
|
@ -1418,7 +1468,7 @@ class TestServerPrompts:
|
|||
async def test_get_unknown_prompt(self):
|
||||
"""Test error when getting unknown prompt."""
|
||||
mcp = FastMCP()
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(McpError, match="Unknown prompt"):
|
||||
await client.get_prompt("unknown")
|
||||
|
||||
|
|
@ -1431,7 +1481,7 @@ class TestServerPrompts:
|
|||
def prompt_fn(name: str) -> str: # pragma: no cover
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(McpError, match="Missing required arguments"):
|
||||
await client.get_prompt("prompt_fn")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
import pytest
|
||||
|
||||
from mcp import Client
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.fastmcp.resources import FunctionResource
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
from mcp.shared.metadata_utils import get_display_name
|
||||
from mcp.types import Prompt, Resource, ResourceTemplate, Tool, ToolAnnotations
|
||||
|
||||
|
|
@ -24,8 +24,9 @@ async def test_server_name_title_description_version():
|
|||
assert mcp.version == "1.0"
|
||||
|
||||
# Start server and connect client
|
||||
async with create_connected_server_and_client_session(mcp._mcp_server) as client:
|
||||
init_result = await client.initialize()
|
||||
async with Client(mcp) as client:
|
||||
# Access initialization result from session
|
||||
init_result = await client.session.initialize()
|
||||
assert init_result.server_info.name == "TestServer"
|
||||
assert init_result.server_info.title == "Test Server Title"
|
||||
assert init_result.server_info.description == "This is a test server description."
|
||||
|
|
@ -60,9 +61,7 @@ async def test_tool_title_precedence():
|
|||
return message
|
||||
|
||||
# Start server and connect client
|
||||
async with create_connected_server_and_client_session(mcp._mcp_server) as client:
|
||||
await client.initialize()
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# List tools
|
||||
tools_result = await client.list_tools()
|
||||
tools = {tool.name: tool for tool in tools_result.tools}
|
||||
|
|
@ -104,9 +103,7 @@ async def test_prompt_title():
|
|||
return f"Tell me about {topic}"
|
||||
|
||||
# Start server and connect client
|
||||
async with create_connected_server_and_client_session(mcp._mcp_server) as client:
|
||||
await client.initialize()
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# List prompts
|
||||
prompts_result = await client.list_prompts()
|
||||
prompts = {prompt.name: prompt for prompt in prompts_result.prompts}
|
||||
|
|
@ -164,9 +161,7 @@ async def test_resource_title():
|
|||
return f"Data for {id}"
|
||||
|
||||
# Start server and connect client
|
||||
async with create_connected_server_and_client_session(mcp._mcp_server) as client:
|
||||
await client.initialize()
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# List resources
|
||||
resources_result = await client.list_resources()
|
||||
resources = {str(res.uri): res for res in resources_result.resources}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@ import anyio
|
|||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp import types
|
||||
from mcp import Client, types
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation, elicit_url
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
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.types import ElicitRequestParams, ElicitResult, TextContent
|
||||
|
||||
|
||||
|
|
@ -37,12 +36,8 @@ async def test_url_elicitation_accept():
|
|||
assert params.message == "Please provide your API key to continue."
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("request_api_key", {})
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("request_api_key", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "User accept"
|
||||
|
|
@ -67,12 +62,8 @@ async def test_url_elicitation_decline():
|
|||
assert params.mode == "url"
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("oauth_flow", {})
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("oauth_flow", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "User decline authorization"
|
||||
|
|
@ -97,12 +88,8 @@ async def test_url_elicitation_cancel():
|
|||
assert params.mode == "url"
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("payment_flow", {})
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("payment_flow", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "User cancel payment"
|
||||
|
|
@ -127,12 +114,8 @@ async def test_url_elicitation_helper_function():
|
|||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("setup_credentials", {})
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("setup_credentials", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "AcceptedUrlElicitation"
|
||||
|
|
@ -165,12 +148,8 @@ async def test_url_no_content_in_response():
|
|||
# Return without content - this is correct for URL mode
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("check_url_response", {})
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("check_url_response", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "Content: None" in result.content[0].text
|
||||
|
|
@ -200,12 +179,8 @@ async def test_form_mode_still_works():
|
|||
assert params.requested_schema is not None
|
||||
return ElicitResult(action="accept", content={"name": "Alice"})
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("ask_name", {})
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("ask_name", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Hello, Alice!"
|
||||
|
|
@ -235,12 +210,8 @@ async def test_elicit_complete_notification():
|
|||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept") # pragma: no cover
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("trigger_elicitation", {})
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("trigger_elicitation", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Elicitation completed"
|
||||
|
|
@ -296,12 +267,8 @@ async def test_elicit_url_typed_results():
|
|||
async def decline_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=decline_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("test_decline", {})
|
||||
async with Client(mcp, elicitation_callback=decline_callback) as client:
|
||||
result = await client.call_tool("test_decline", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Declined"
|
||||
|
|
@ -310,12 +277,8 @@ async def test_elicit_url_typed_results():
|
|||
async def cancel_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=cancel_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("test_cancel", {})
|
||||
async with Client(mcp, elicitation_callback=cancel_callback) as client:
|
||||
result = await client.call_tool("test_cancel", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Cancelled"
|
||||
|
|
@ -347,12 +310,8 @@ async def test_deprecated_elicit_method():
|
|||
assert params.requested_schema is not None
|
||||
return ElicitResult(action="accept", content={"email": "test@example.com"})
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
result = await client_session.call_tool("use_deprecated_elicit", {})
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("use_deprecated_elicit", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Email: test@example.com"
|
||||
|
|
@ -378,10 +337,7 @@ async def test_ctx_elicit_url_convenience_method():
|
|||
assert params.elicitation_id == "ctx-test-001"
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||
) as client_session:
|
||||
await client_session.initialize()
|
||||
result = await client_session.call_tool("direct_elicit_url", {})
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("direct_elicit_url", {})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Result: accept"
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@
|
|||
|
||||
import pytest
|
||||
|
||||
from mcp import types
|
||||
from mcp import Client, types
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.exceptions import McpError, UrlElicitationRequiredError
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -28,12 +27,10 @@ async def test_url_elicitation_error_thrown_from_tool():
|
|||
]
|
||||
)
|
||||
|
||||
async with create_connected_server_and_client_session(mcp._mcp_server) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Call the tool - it should raise McpError with URL_ELICITATION_REQUIRED code
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await client_session.call_tool("connect_service", {"service_name": "github"})
|
||||
await client.call_tool("connect_service", {"service_name": "github"})
|
||||
|
||||
# Verify the error details
|
||||
error = exc_info.value.error
|
||||
|
|
@ -74,12 +71,10 @@ async def test_url_elicitation_error_from_error():
|
|||
]
|
||||
)
|
||||
|
||||
async with create_connected_server_and_client_session(mcp._mcp_server) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Call the tool and catch the error
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await client_session.call_tool("multi_auth", {})
|
||||
await client.call_tool("multi_auth", {})
|
||||
|
||||
# Reconstruct the typed error
|
||||
mcp_error = exc_info.value
|
||||
|
|
@ -102,11 +97,9 @@ async def test_normal_exceptions_still_return_error_result():
|
|||
async def failing_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
raise ValueError("Something went wrong")
|
||||
|
||||
async with create_connected_server_and_client_session(mcp._mcp_server) as client_session:
|
||||
await client_session.initialize()
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Normal exceptions should be returned as error results, not McpError
|
||||
result = await client_session.call_tool("failing_tool", {})
|
||||
result = await client.call_tool("failing_tool", {})
|
||||
assert result.is_error is True
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], types.TextContent)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import anyio
|
|||
import pytest
|
||||
|
||||
import mcp.types as types
|
||||
from mcp.client._memory import InMemoryTransport
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server.lowlevel.server import Server
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
from mcp.types import (
|
||||
CallToolRequest,
|
||||
CallToolRequestParams,
|
||||
|
|
@ -54,57 +55,61 @@ async def test_server_remains_functional_after_cancel():
|
|||
return [types.TextContent(type="text", text=f"Call number: {call_count}")]
|
||||
raise ValueError(f"Unknown tool: {name}") # pragma: no cover
|
||||
|
||||
async with create_connected_server_and_client_session(server) as client:
|
||||
# First request (will be cancelled)
|
||||
async def first_request():
|
||||
try:
|
||||
await client.send_request(
|
||||
ClientRequest(
|
||||
CallToolRequest(
|
||||
params=CallToolRequestParams(name="test_tool", arguments={}),
|
||||
)
|
||||
),
|
||||
CallToolResult,
|
||||
)
|
||||
pytest.fail("First request should have been cancelled") # pragma: no cover
|
||||
except McpError:
|
||||
pass # Expected
|
||||
transport = InMemoryTransport(server)
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as client:
|
||||
await client.initialize()
|
||||
|
||||
# Start first request
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(first_request)
|
||||
|
||||
# Wait for it to start
|
||||
await ev_first_call.wait()
|
||||
|
||||
# Cancel it
|
||||
assert first_request_id is not None
|
||||
await client.send_notification(
|
||||
ClientNotification(
|
||||
CancelledNotification(
|
||||
params=CancelledNotificationParams(
|
||||
request_id=first_request_id,
|
||||
reason="Testing server recovery",
|
||||
# First request (will be cancelled)
|
||||
async def first_request():
|
||||
try:
|
||||
await client.send_request(
|
||||
ClientRequest(
|
||||
CallToolRequest(
|
||||
params=CallToolRequestParams(name="test_tool", arguments={}),
|
||||
)
|
||||
),
|
||||
CallToolResult,
|
||||
)
|
||||
pytest.fail("First request should have been cancelled") # pragma: no cover
|
||||
except McpError:
|
||||
pass # Expected
|
||||
|
||||
# Start first request
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(first_request)
|
||||
|
||||
# Wait for it to start
|
||||
await ev_first_call.wait()
|
||||
|
||||
# Cancel it
|
||||
assert first_request_id is not None
|
||||
await client.send_notification(
|
||||
ClientNotification(
|
||||
CancelledNotification(
|
||||
params=CancelledNotificationParams(
|
||||
request_id=first_request_id,
|
||||
reason="Testing server recovery",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Second request (should work normally)
|
||||
result = await client.send_request(
|
||||
ClientRequest(
|
||||
CallToolRequest(
|
||||
params=CallToolRequestParams(name="test_tool", arguments={}),
|
||||
)
|
||||
),
|
||||
CallToolResult,
|
||||
)
|
||||
|
||||
# Second request (should work normally)
|
||||
result = await client.send_request(
|
||||
ClientRequest(
|
||||
CallToolRequest(
|
||||
params=CallToolRequestParams(name="test_tool", arguments={}),
|
||||
)
|
||||
),
|
||||
CallToolResult,
|
||||
)
|
||||
|
||||
# Verify second request completed successfully
|
||||
assert len(result.content) == 1
|
||||
# Type narrowing for pyright
|
||||
content = result.content[0]
|
||||
assert content.type == "text"
|
||||
assert isinstance(content, types.TextContent)
|
||||
assert content.text == "Call number: 2"
|
||||
assert call_count == 2
|
||||
# Verify second request completed successfully
|
||||
assert len(result.content) == 1
|
||||
# Type narrowing for pyright
|
||||
content = result.content[0]
|
||||
assert content.type == "text"
|
||||
assert isinstance(content, types.TextContent)
|
||||
assert content.text == "Call number: 2"
|
||||
assert call_count == 2
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue