refactor: flatten the methods in Client (#1914)
This commit is contained in:
parent
3dc8b72041
commit
bcb07c2b25
9 changed files with 213 additions and 315 deletions
2
.github/workflows/shared.yml
vendored
2
.github/workflows/shared.yml
vendored
|
|
@ -58,7 +58,7 @@ jobs:
|
||||||
- name: Run pytest with coverage
|
- name: Run pytest with coverage
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen --no-sync coverage run -m pytest
|
uv run --frozen --no-sync coverage run -m pytest -n auto
|
||||||
uv run --frozen --no-sync coverage combine
|
uv run --frozen --no-sync coverage combine
|
||||||
uv run --frozen --no-sync coverage report
|
uv run --frozen --no-sync coverage report
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ This document contains critical information about working with this codebase. Fo
|
||||||
- Functions must be focused and small
|
- Functions must be focused and small
|
||||||
- Follow existing patterns exactly
|
- Follow existing patterns exactly
|
||||||
- Line length: 120 chars maximum
|
- Line length: 120 chars maximum
|
||||||
|
- FORBIDDEN: imports inside functions
|
||||||
|
|
||||||
3. Testing Requirements
|
3. Testing Requirements
|
||||||
- Framework: `uv run --frozen pytest`
|
- Framework: `uv run --frozen pytest`
|
||||||
|
|
@ -25,6 +26,8 @@ This document contains critical information about working with this codebase. Fo
|
||||||
- Coverage: test edge cases and errors
|
- Coverage: test edge cases and errors
|
||||||
- New features require tests
|
- New features require tests
|
||||||
- Bug fixes require regression tests
|
- Bug fixes require regression tests
|
||||||
|
- IMPORTANT: The `tests/client/test_client.py` is the most well designed test file. Follow its patterns.
|
||||||
|
- IMPORTANT: Be minimal, and focus on E2E tests: Use the `mcp.client.Client` whenever possible.
|
||||||
|
|
||||||
- For commits fixing bugs or adding features based on user reports add:
|
- For commits fixing bugs or adding features based on user reports add:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,6 @@ xfail_strict = true
|
||||||
addopts = """
|
addopts = """
|
||||||
--color=yes
|
--color=yes
|
||||||
--capture=fd
|
--capture=fd
|
||||||
--numprocesses auto
|
|
||||||
"""
|
"""
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
"error",
|
"error",
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,7 @@ class InMemoryTransport:
|
||||||
result = await client.call_tool("my_tool", {...})
|
result = await client.call_tool("my_tool", {...})
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, server: Server[Any] | FastMCP, *, raise_exceptions: bool = False) -> None:
|
||||||
self,
|
|
||||||
server: Server[Any] | FastMCP,
|
|
||||||
*,
|
|
||||||
raise_exceptions: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the in-memory transport.
|
"""Initialize the in-memory transport.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class Client:
|
||||||
self,
|
self,
|
||||||
server: Server[Any] | FastMCP,
|
server: Server[Any] | FastMCP,
|
||||||
*,
|
*,
|
||||||
|
# TODO(Marcelo): When do `raise_exceptions=True` actually raises?
|
||||||
raise_exceptions: bool = False,
|
raise_exceptions: bool = False,
|
||||||
read_timeout_seconds: float | None = None,
|
read_timeout_seconds: float | None = None,
|
||||||
sampling_callback: SamplingFnT | None = None,
|
sampling_callback: SamplingFnT | None = None,
|
||||||
|
|
@ -125,14 +126,9 @@ class Client:
|
||||||
self._exit_stack = exit_stack.pop_all()
|
self._exit_stack = exit_stack.pop_all()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(
|
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc_val: BaseException | None,
|
|
||||||
exc_tb: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Exit the async context manager."""
|
"""Exit the async context manager."""
|
||||||
if self._exit_stack:
|
if self._exit_stack: # pragma: no branch
|
||||||
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
|
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
|
||||||
self._session = None
|
self._session = None
|
||||||
|
|
||||||
|
|
@ -177,28 +173,22 @@ class Client:
|
||||||
"""Set the logging level on the server."""
|
"""Set the logging level on the server."""
|
||||||
return await self.session.set_logging_level(level)
|
return await self.session.set_logging_level(level)
|
||||||
|
|
||||||
async def list_resources(
|
async def list_resources(self, *, cursor: str | None = None) -> types.ListResourcesResult:
|
||||||
self,
|
|
||||||
params: types.PaginatedRequestParams | None = None,
|
|
||||||
) -> types.ListResourcesResult:
|
|
||||||
"""List available resources from the server."""
|
"""List available resources from the server."""
|
||||||
return await self.session.list_resources(params=params)
|
return await self.session.list_resources(params=types.PaginatedRequestParams(cursor=cursor))
|
||||||
|
|
||||||
async def list_resource_templates(
|
async def list_resource_templates(self, *, cursor: str | None = None) -> types.ListResourceTemplatesResult:
|
||||||
self,
|
|
||||||
params: types.PaginatedRequestParams | None = None,
|
|
||||||
) -> types.ListResourceTemplatesResult:
|
|
||||||
"""List available resource templates from the server."""
|
"""List available resource templates from the server."""
|
||||||
return await self.session.list_resource_templates(params=params)
|
return await self.session.list_resource_templates(params=types.PaginatedRequestParams(cursor=cursor))
|
||||||
|
|
||||||
async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult:
|
async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult:
|
||||||
"""Read a resource from the server.
|
"""Read a resource from the server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
uri: The URI of the resource to read
|
uri: The URI of the resource to read.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The resource content
|
The resource content.
|
||||||
"""
|
"""
|
||||||
return await self.session.read_resource(uri)
|
return await self.session.read_resource(uri)
|
||||||
|
|
||||||
|
|
@ -239,18 +229,11 @@ class Client:
|
||||||
meta=meta,
|
meta=meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def list_prompts(
|
async def list_prompts(self, *, cursor: str | None = None) -> types.ListPromptsResult:
|
||||||
self,
|
|
||||||
params: types.PaginatedRequestParams | None = None,
|
|
||||||
) -> types.ListPromptsResult:
|
|
||||||
"""List available prompts from the server."""
|
"""List available prompts from the server."""
|
||||||
return await self.session.list_prompts(params=params)
|
return await self.session.list_prompts(params=types.PaginatedRequestParams(cursor=cursor))
|
||||||
|
|
||||||
async def get_prompt(
|
async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult:
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
arguments: dict[str, str] | None = None,
|
|
||||||
) -> types.GetPromptResult:
|
|
||||||
"""Get a prompt from the server.
|
"""Get a prompt from the server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -258,7 +241,7 @@ class Client:
|
||||||
arguments: Arguments to pass to the prompt
|
arguments: Arguments to pass to the prompt
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The prompt content
|
The prompt content.
|
||||||
"""
|
"""
|
||||||
return await self.session.get_prompt(name=name, arguments=arguments)
|
return await self.session.get_prompt(name=name, arguments=arguments)
|
||||||
|
|
||||||
|
|
@ -276,21 +259,15 @@ class Client:
|
||||||
context_arguments: Additional context arguments
|
context_arguments: Additional context arguments
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Completion suggestions
|
Completion suggestions.
|
||||||
"""
|
"""
|
||||||
return await self.session.complete(
|
return await self.session.complete(ref=ref, argument=argument, context_arguments=context_arguments)
|
||||||
ref=ref,
|
|
||||||
argument=argument,
|
|
||||||
context_arguments=context_arguments,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def list_tools(
|
async def list_tools(self, *, cursor: str | None = None) -> types.ListToolsResult:
|
||||||
self,
|
|
||||||
params: types.PaginatedRequestParams | None = None,
|
|
||||||
) -> types.ListToolsResult:
|
|
||||||
"""List available tools from the server."""
|
"""List available tools from the server."""
|
||||||
return await self.session.list_tools(params=params)
|
return await self.session.list_tools(params=types.PaginatedRequestParams(cursor=cursor))
|
||||||
|
|
||||||
async def send_roots_list_changed(self) -> None:
|
async def send_roots_list_changed(self) -> None:
|
||||||
"""Send a notification that the roots list has changed."""
|
"""Send a notification that the roots list has changed."""
|
||||||
await self.session.send_roots_list_changed()
|
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
|
||||||
|
await self.session.send_roots_list_changed() # pragma: no cover
|
||||||
|
|
|
||||||
|
|
@ -349,10 +349,7 @@ class ClientSession(
|
||||||
Args:
|
Args:
|
||||||
params: Full pagination parameters including cursor and any future fields
|
params: Full pagination parameters including cursor and any future fields
|
||||||
"""
|
"""
|
||||||
return await self.send_request(
|
return await self.send_request(types.ListPromptsRequest(params=params), types.ListPromptsResult)
|
||||||
types.ListPromptsRequest(params=params),
|
|
||||||
types.ListPromptsResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult:
|
async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult:
|
||||||
"""Send a prompts/get request."""
|
"""Send a prompts/get request."""
|
||||||
|
|
|
||||||
|
|
@ -234,12 +234,12 @@ class BaseSession(
|
||||||
metadata: MessageMetadata = None,
|
metadata: MessageMetadata = None,
|
||||||
progress_callback: ProgressFnT | None = None,
|
progress_callback: ProgressFnT | None = None,
|
||||||
) -> ReceiveResultT:
|
) -> ReceiveResultT:
|
||||||
"""Sends a request and wait for a response. Raises an McpError if the
|
"""Sends a request and wait for a response.
|
||||||
response contains an error. If a request read timeout is provided, it
|
|
||||||
will take precedence over the session read timeout.
|
|
||||||
|
|
||||||
Do not use this method to emit notifications! Use send_notification()
|
Raises an McpError if the response contains an error. If a request read timeout is provided, it will take
|
||||||
instead.
|
precedence over the session read timeout.
|
||||||
|
|
||||||
|
Do not use this method to emit notifications! Use send_notification() instead.
|
||||||
"""
|
"""
|
||||||
request_id = self._request_id
|
request_id = self._request_id
|
||||||
self._request_id = request_id + 1
|
self._request_id = request_id + 1
|
||||||
|
|
@ -261,15 +261,10 @@ class BaseSession(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data)
|
jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data)
|
||||||
|
|
||||||
await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata))
|
await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata))
|
||||||
|
|
||||||
# request read timeout takes precedence over session read timeout
|
# request read timeout takes precedence over session read timeout
|
||||||
timeout = None
|
timeout = request_read_timeout_seconds or self._session_read_timeout_seconds
|
||||||
if request_read_timeout_seconds is not None: # pragma: no cover
|
|
||||||
timeout = request_read_timeout_seconds
|
|
||||||
elif self._session_read_timeout_seconds is not None: # pragma: no cover
|
|
||||||
timeout = self._session_read_timeout_seconds
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with anyio.fail_after(timeout):
|
with anyio.fail_after(timeout):
|
||||||
|
|
@ -279,9 +274,8 @@ class BaseSession(
|
||||||
ErrorData(
|
ErrorData(
|
||||||
code=httpx.codes.REQUEST_TIMEOUT,
|
code=httpx.codes.REQUEST_TIMEOUT,
|
||||||
message=(
|
message=(
|
||||||
f"Timed out while waiting for response to "
|
f"Timed out while waiting for response to {request.__class__.__name__}. "
|
||||||
f"{request.__class__.__name__}. Waited "
|
f"Waited {timeout} seconds."
|
||||||
f"{timeout} seconds."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -302,9 +296,7 @@ class BaseSession(
|
||||||
notification: SendNotificationT,
|
notification: SendNotificationT,
|
||||||
related_request_id: RequestId | None = None,
|
related_request_id: RequestId | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Emits a notification, which is a one-way message that does not expect
|
"""Emits a notification, which is a one-way message that does not expect a response."""
|
||||||
a response.
|
|
||||||
"""
|
|
||||||
# Some transport implementations may need to set the related_request_id
|
# Some transport implementations may need to set the related_request_id
|
||||||
# to attribute to the notifications to the request that triggered them.
|
# to attribute to the notifications to the request that triggered them.
|
||||||
jsonrpc_notification = JSONRPCNotification(
|
jsonrpc_notification = JSONRPCNotification(
|
||||||
|
|
@ -373,11 +365,7 @@ class BaseSession(
|
||||||
error_response = JSONRPCError(
|
error_response = JSONRPCError(
|
||||||
jsonrpc="2.0",
|
jsonrpc="2.0",
|
||||||
id=message.message.id,
|
id=message.message.id,
|
||||||
error=ErrorData(
|
error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""),
|
||||||
code=INVALID_PARAMS,
|
|
||||||
message="Invalid request parameters",
|
|
||||||
data="",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
session_message = SessionMessage(message=error_response)
|
session_message = SessionMessage(message=error_response)
|
||||||
await self._write_stream.send(session_message)
|
await self._write_stream.send(session_message)
|
||||||
|
|
@ -518,13 +506,9 @@ class BaseSession(
|
||||||
total: float | None = None,
|
total: float | None = None,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Sends a progress notification for a request that is currently being
|
"""Sends a progress notification for a request that is currently being processed."""
|
||||||
processed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def _handle_incoming(
|
async def _handle_incoming(
|
||||||
self,
|
self, req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception
|
||||||
req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A generic handler for incoming messages. Overwritten by subclasses."""
|
"""A generic handler for incoming messages. Overwritten by subclasses."""
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,36 @@
|
||||||
"""Tests for the unified Client class."""
|
"""Tests for the unified Client class."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
|
from inline_snapshot import snapshot
|
||||||
|
|
||||||
import mcp.types as types
|
import mcp.types as types
|
||||||
from mcp.client.client import Client
|
from mcp.client.client import Client
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from mcp.types import EmptyResult, Resource
|
from mcp.types import (
|
||||||
|
CallToolResult,
|
||||||
|
EmptyResult,
|
||||||
|
GetPromptResult,
|
||||||
|
ListPromptsResult,
|
||||||
|
ListResourcesResult,
|
||||||
|
ListResourceTemplatesResult,
|
||||||
|
ListToolsResult,
|
||||||
|
Prompt,
|
||||||
|
PromptArgument,
|
||||||
|
PromptMessage,
|
||||||
|
PromptsCapability,
|
||||||
|
ReadResourceResult,
|
||||||
|
Resource,
|
||||||
|
ResourcesCapability,
|
||||||
|
ServerCapabilities,
|
||||||
|
TextContent,
|
||||||
|
TextResourceContents,
|
||||||
|
Tool,
|
||||||
|
ToolsCapability,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.anyio
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
@ -20,13 +42,27 @@ def simple_server() -> Server:
|
||||||
|
|
||||||
@server.list_resources()
|
@server.list_resources()
|
||||||
async def handle_list_resources():
|
async def handle_list_resources():
|
||||||
return [
|
return [Resource(uri="memory://test", name="Test Resource", description="A test resource")]
|
||||||
Resource(
|
|
||||||
uri="memory://test",
|
@server.subscribe_resource()
|
||||||
name="Test Resource",
|
async def handle_subscribe_resource(uri: str):
|
||||||
description="A test resource",
|
pass
|
||||||
)
|
|
||||||
]
|
@server.unsubscribe_resource()
|
||||||
|
async def handle_unsubscribe_resource(uri: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@server.set_logging_level()
|
||||||
|
async def handle_set_logging_level(level: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@server.completion()
|
||||||
|
async def handle_completion(
|
||||||
|
ref: types.PromptReference | types.ResourceTemplateReference,
|
||||||
|
argument: types.CompletionArgument,
|
||||||
|
context: types.CompletionContext | None,
|
||||||
|
) -> types.Completion | None:
|
||||||
|
return types.Completion(values=[])
|
||||||
|
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
@ -41,11 +77,6 @@ def app() -> FastMCP:
|
||||||
"""Greet someone by name."""
|
"""Greet someone by name."""
|
||||||
return f"Hello, {name}!"
|
return f"Hello, {name}!"
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def add(a: int, b: int) -> int:
|
|
||||||
"""Add two numbers."""
|
|
||||||
return a + b
|
|
||||||
|
|
||||||
@server.resource("test://resource")
|
@server.resource("test://resource")
|
||||||
def test_resource() -> str:
|
def test_resource() -> str:
|
||||||
"""A test resource."""
|
"""A test resource."""
|
||||||
|
|
@ -59,278 +90,198 @@ def app() -> FastMCP:
|
||||||
return server
|
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):
|
async def test_client_is_initialized(app: FastMCP):
|
||||||
"""Test that the client is initialized after entering context."""
|
"""Test that the client is initialized after entering context."""
|
||||||
async with Client(app) as client:
|
async with Client(app) as client:
|
||||||
caps = client.server_capabilities
|
assert client.server_capabilities == snapshot(
|
||||||
assert caps is not None
|
ServerCapabilities(
|
||||||
assert caps.tools is not None
|
experimental={},
|
||||||
|
prompts=PromptsCapability(list_changed=False),
|
||||||
|
resources=ResourcesCapability(subscribe=False, list_changed=False),
|
||||||
|
tools=ToolsCapability(list_changed=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_with_simple_server(simple_server: Server):
|
async def test_client_with_simple_server(simple_server: Server):
|
||||||
"""Test that from_server works with a basic Server instance."""
|
"""Test that from_server works with a basic Server instance."""
|
||||||
async with Client(simple_server) as client:
|
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()
|
resources = await client.list_resources()
|
||||||
assert len(resources.resources) == 1
|
assert resources == snapshot(
|
||||||
assert resources.resources[0].uri == "memory://test"
|
ListResourcesResult(
|
||||||
|
resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_ping_returns_empty_result(app: FastMCP):
|
async def test_client_send_ping(app: FastMCP):
|
||||||
"""Test that ping returns an EmptyResult."""
|
|
||||||
async with Client(app) as client:
|
async with Client(app) as client:
|
||||||
result = await client.send_ping()
|
result = await client.send_ping()
|
||||||
assert isinstance(result, EmptyResult)
|
assert result == snapshot(EmptyResult())
|
||||||
|
|
||||||
|
|
||||||
async def test_list_tools(app: FastMCP):
|
async def test_client_list_tools(app: FastMCP):
|
||||||
"""Test listing tools."""
|
|
||||||
async with Client(app) as client:
|
async with Client(app) as client:
|
||||||
result = await client.list_tools()
|
result = await client.list_tools()
|
||||||
assert result.tools is not None
|
assert result == snapshot(
|
||||||
tool_names = [t.name for t in result.tools]
|
ListToolsResult(
|
||||||
assert "greet" in tool_names
|
tools=[
|
||||||
assert "add" in tool_names
|
Tool(
|
||||||
|
name="greet",
|
||||||
|
description="Greet someone by name.",
|
||||||
|
input_schema={
|
||||||
|
"properties": {"name": {"title": "Name", "type": "string"}},
|
||||||
|
"required": ["name"],
|
||||||
|
"title": "greetArguments",
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
output_schema={
|
||||||
|
"properties": {"result": {"title": "Result", "type": "string"}},
|
||||||
|
"required": ["result"],
|
||||||
|
"title": "greetOutput",
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_list_tools_with_pagination(app: FastMCP):
|
async def test_client_call_tool(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:
|
async with Client(app) as client:
|
||||||
result = await client.call_tool("greet", {"name": "World"})
|
result = await client.call_tool("greet", {"name": "World"})
|
||||||
assert result.content is not None
|
assert result == snapshot(
|
||||||
assert len(result.content) > 0
|
CallToolResult(
|
||||||
content_str = str(result.content[0])
|
content=[TextContent(text="Hello, World!")],
|
||||||
assert "Hello, World!" in content_str
|
structured_content={"result": "Hello, World!"},
|
||||||
|
)
|
||||||
|
)
|
||||||
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):
|
async def test_read_resource(app: FastMCP):
|
||||||
"""Test reading a resource."""
|
"""Test reading a resource."""
|
||||||
async with Client(app) as client:
|
async with Client(app) as client:
|
||||||
result = await client.read_resource("test://resource")
|
result = await client.read_resource("test://resource")
|
||||||
assert result.contents is not None
|
assert result == snapshot(
|
||||||
assert len(result.contents) > 0
|
ReadResourceResult(
|
||||||
|
contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")]
|
||||||
|
)
|
||||||
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):
|
async def test_get_prompt(app: FastMCP):
|
||||||
"""Test getting a prompt."""
|
"""Test getting a prompt."""
|
||||||
async with Client(app) as client:
|
async with Client(app) as client:
|
||||||
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
|
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
|
||||||
assert result.messages is not None
|
assert result == snapshot(
|
||||||
assert len(result.messages) > 0
|
GetPromptResult(
|
||||||
|
description="A greeting prompt.",
|
||||||
|
messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_session_property(app: FastMCP):
|
def test_client_session_property_before_enter(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."""
|
"""Test that accessing session before context manager raises RuntimeError."""
|
||||||
client = Client(app)
|
client = Client(app)
|
||||||
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
|
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
|
||||||
_ = client.session
|
client.session
|
||||||
|
|
||||||
|
|
||||||
async def test_reentry_raises_runtime_error(app: FastMCP):
|
async def test_client_reentry_raises_runtime_error(app: FastMCP):
|
||||||
"""Test that reentering a client raises RuntimeError."""
|
"""Test that reentering a client raises RuntimeError."""
|
||||||
async with Client(app) as client:
|
async with Client(app) as client:
|
||||||
with pytest.raises(RuntimeError, match="Client is already entered"):
|
with pytest.raises(RuntimeError, match="Client is already entered"):
|
||||||
await client.__aenter__()
|
await client.__aenter__()
|
||||||
|
|
||||||
|
|
||||||
async def test_cleanup_on_init_failure(app: FastMCP):
|
async def test_client_send_progress_notification():
|
||||||
"""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."""
|
"""Test sending progress notification."""
|
||||||
|
received_from_client = None
|
||||||
|
event = anyio.Event()
|
||||||
|
server = Server(name="test_server")
|
||||||
|
|
||||||
|
@server.progress_notification()
|
||||||
|
async def handle_progress_notification(
|
||||||
|
progress_token: str | int,
|
||||||
|
progress: float = 0.0,
|
||||||
|
total: float | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
nonlocal received_from_client
|
||||||
|
received_from_client = {"progress_token": progress_token, "progress": progress}
|
||||||
|
event.set()
|
||||||
|
|
||||||
|
async with Client(server) as client:
|
||||||
|
await client.send_progress_notification(progress_token="token123", progress=50.0)
|
||||||
|
await event.wait()
|
||||||
|
assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_subscribe_resource(simple_server: Server):
|
||||||
|
async with Client(simple_server) as client:
|
||||||
|
result = await client.subscribe_resource("memory://test")
|
||||||
|
assert result == snapshot(EmptyResult())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_unsubscribe_resource(simple_server: Server):
|
||||||
|
async with Client(simple_server) as client:
|
||||||
|
result = await client.unsubscribe_resource("memory://test")
|
||||||
|
assert result == snapshot(EmptyResult())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_set_logging_level(simple_server: Server):
|
||||||
|
"""Test setting logging level."""
|
||||||
|
async with Client(simple_server) as client:
|
||||||
|
result = await client.set_logging_level("debug")
|
||||||
|
assert result == snapshot(EmptyResult())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_list_resources_with_params(app: FastMCP):
|
||||||
|
"""Test listing resources with params parameter."""
|
||||||
async with Client(app) as client:
|
async with Client(app) as client:
|
||||||
# Send a progress notification - this should not raise
|
result = await client.list_resources()
|
||||||
await client.send_progress_notification(
|
assert result == snapshot(
|
||||||
progress_token="test-token",
|
ListResourcesResult(
|
||||||
progress=50.0,
|
resources=[
|
||||||
total=100.0,
|
Resource(
|
||||||
message="Half done",
|
name="test_resource",
|
||||||
|
uri="test://resource",
|
||||||
|
description="A test resource.",
|
||||||
|
mime_type="text/plain",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_subscribe_resource(app: FastMCP):
|
async def test_client_list_resource_templates(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."""
|
"""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:
|
async with Client(app) as client:
|
||||||
result = await client.list_resource_templates()
|
result = await client.list_resource_templates()
|
||||||
assert result is not None
|
assert result == snapshot(ListResourceTemplatesResult(resource_templates=[]))
|
||||||
|
|
||||||
|
|
||||||
async def test_list_prompts_with_params(app: FastMCP):
|
async def test_list_prompts(app: FastMCP):
|
||||||
"""Test listing prompts with params parameter."""
|
"""Test listing prompts with params parameter."""
|
||||||
async with Client(app) as client:
|
async with Client(app) as client:
|
||||||
result = await client.list_prompts(params=types.PaginatedRequestParams())
|
result = await client.list_prompts()
|
||||||
assert result is not None
|
assert result == snapshot(
|
||||||
|
ListPromptsResult(
|
||||||
|
prompts=[
|
||||||
|
Prompt(
|
||||||
|
name="greeting_prompt",
|
||||||
|
description="A greeting prompt.",
|
||||||
|
arguments=[PromptArgument(name="name", required=True)],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_complete_with_prompt_reference(app: FastMCP):
|
async def test_complete_with_prompt_reference(simple_server: Server):
|
||||||
"""Test getting completions for a prompt argument."""
|
"""Test getting completions for a prompt argument."""
|
||||||
async with Client(app) as client:
|
async with Client(simple_server) as client:
|
||||||
ref = types.PromptReference(type="ref/prompt", name="greeting_prompt")
|
ref = types.PromptReference(type="ref/prompt", name="test_prompt")
|
||||||
# Mock the session's complete method since FastMCP may not support it
|
result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"})
|
||||||
with patch.object(
|
assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[])))
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,12 @@ async def test_list_methods_params_parameter(
|
||||||
_ = await method()
|
_ = await method()
|
||||||
requests = spies.get_client_requests(method=request_method)
|
requests = spies.get_client_requests(method=request_method)
|
||||||
assert len(requests) == 1
|
assert len(requests) == 1
|
||||||
assert requests[0].params is None
|
assert requests[0].params is None or "cursor" not in requests[0].params
|
||||||
|
|
||||||
spies.clear()
|
spies.clear()
|
||||||
|
|
||||||
# Test with params containing cursor
|
# Test with params containing cursor
|
||||||
_ = await method(params=types.PaginatedRequestParams(cursor="from_params"))
|
_ = await method(cursor="from_params")
|
||||||
requests = spies.get_client_requests(method=request_method)
|
requests = spies.get_client_requests(method=request_method)
|
||||||
assert len(requests) == 1
|
assert len(requests) == 1
|
||||||
assert requests[0].params is not None
|
assert requests[0].params is not None
|
||||||
|
|
@ -87,7 +87,7 @@ async def test_list_methods_params_parameter(
|
||||||
spies.clear()
|
spies.clear()
|
||||||
|
|
||||||
# Test with empty params
|
# Test with empty params
|
||||||
_ = await method(params=types.PaginatedRequestParams())
|
_ = await method()
|
||||||
requests = spies.get_client_requests(method=request_method)
|
requests = spies.get_client_requests(method=request_method)
|
||||||
assert len(requests) == 1
|
assert len(requests) == 1
|
||||||
# Empty params means no cursor
|
# Empty params means no cursor
|
||||||
|
|
@ -99,7 +99,7 @@ async def test_list_tools_with_strict_server_validation(
|
||||||
):
|
):
|
||||||
"""Test pagination with a server that validates request format strictly."""
|
"""Test pagination with a server that validates request format strictly."""
|
||||||
async with Client(full_featured_server) as client:
|
async with Client(full_featured_server) as client:
|
||||||
result = await client.list_tools(params=types.PaginatedRequestParams())
|
result = await client.list_tools()
|
||||||
assert isinstance(result, ListToolsResult)
|
assert isinstance(result, ListToolsResult)
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
|
|
@ -112,19 +112,11 @@ async def test_list_tools_with_lowlevel_server():
|
||||||
async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult:
|
async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult:
|
||||||
# Echo back what cursor we received in the tool description
|
# Echo back what cursor we received in the tool description
|
||||||
cursor = request.params.cursor if request.params else None
|
cursor = request.params.cursor if request.params else None
|
||||||
return ListToolsResult(
|
return ListToolsResult(tools=[types.Tool(name="test_tool", description=f"cursor={cursor}", input_schema={})])
|
||||||
tools=[
|
|
||||||
types.Tool(
|
|
||||||
name="test_tool",
|
|
||||||
description=f"cursor={cursor}",
|
|
||||||
input_schema={},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
async with Client(server) as client:
|
async with Client(server) as client:
|
||||||
result = await client.list_tools(params=types.PaginatedRequestParams())
|
result = await client.list_tools()
|
||||||
assert result.tools[0].description == "cursor=None"
|
assert result.tools[0].description == "cursor=None"
|
||||||
|
|
||||||
result = await client.list_tools(params=types.PaginatedRequestParams(cursor="page2"))
|
result = await client.list_tools(cursor="page2")
|
||||||
assert result.tools[0].description == "cursor=page2"
|
assert result.tools[0].description == "cursor=page2"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue