tests: increase coverage to 84% (#4)
This commit is contained in:
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -92,7 +92,7 @@ jobs:
|
|||||||
path: htmlcov
|
path: htmlcov
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
|
|
||||||
- run: uv run --frozen coverage report --fail-under 70
|
- run: uv run --frozen coverage report --fail-under 83
|
||||||
# https://github.com/marketplace/actions/alls-green#why used for branch protection checks
|
# https://github.com/marketplace/actions/alls-green#why used for branch protection checks
|
||||||
check:
|
check:
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Unit Test",
|
||||||
|
"type": "python",
|
||||||
|
"request": "test",
|
||||||
|
"justMyCode": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"python.testing.pytestArgs": ["."],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ Tests are running in two modes:
|
|||||||
The same test code is run on both to ensure parity.
|
The same test code is run on both to ensure parity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from mcp import types
|
from mcp import types
|
||||||
@@ -18,6 +19,7 @@ from mcp.client.session import ClientSession
|
|||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.shared.memory import create_connected_server_and_client_session
|
from mcp.shared.memory import create_connected_server_and_client_session
|
||||||
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from mcp_proxy import create_proxy_server
|
from mcp_proxy import create_proxy_server
|
||||||
|
|
||||||
@@ -46,17 +48,138 @@ def session_generator(request: pytest.FixtureRequest) -> SessionContextManager:
|
|||||||
return proxy
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
async def test_list_prompts(session_generator: SessionContextManager) -> None:
|
@pytest.fixture
|
||||||
"""Test list_prompts."""
|
def server() -> Server:
|
||||||
server = Server("prompt-server")
|
"""Return a server instance."""
|
||||||
|
return Server("test-server")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_list_prompts(server: Server, prompt: types.Prompt) -> Server:
|
||||||
|
"""Return a server instance with prompts."""
|
||||||
|
|
||||||
@server.list_prompts()
|
@server.list_prompts()
|
||||||
async def list_prompts() -> list[types.Prompt]:
|
async def _() -> list[types.Prompt]:
|
||||||
return [types.Prompt(name="prompt1")]
|
return [prompt]
|
||||||
|
|
||||||
async with session_generator(server) as session:
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_get_prompt(
|
||||||
|
server_can_list_prompts: Server,
|
||||||
|
prompt_callback: Callable[[str, dict[str, str] | None], Awaitable[types.GetPromptResult]],
|
||||||
|
) -> Server:
|
||||||
|
"""Return a server instance with prompts."""
|
||||||
|
server_can_list_prompts.get_prompt()(prompt_callback)
|
||||||
|
|
||||||
|
return server_can_list_prompts
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_list_tools(server: Server, tool: types.Tool) -> Server:
|
||||||
|
"""Return a server instance with tools."""
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def _() -> list[types.Tool]:
|
||||||
|
return [tool]
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_call_tool(server_can_list_tools: Server, tool: Callable[..., ...]) -> Server:
|
||||||
|
"""Return a server instance with tools."""
|
||||||
|
server_can_list_tools.call_tool()(tool)
|
||||||
|
|
||||||
|
return server_can_list_tools
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_list_resources(server: Server, resource: types.Resource) -> Server:
|
||||||
|
"""Return a server instance with resources."""
|
||||||
|
|
||||||
|
@server.list_resources()
|
||||||
|
async def _() -> list[types.Resource]:
|
||||||
|
return [resource]
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_subscribe_resource(
|
||||||
|
server_can_list_resources: Server,
|
||||||
|
subscribe_callback: Callable[[AnyUrl], Awaitable[None]],
|
||||||
|
) -> Server:
|
||||||
|
"""Return a server instance with resource templates."""
|
||||||
|
server_can_list_resources.subscribe_resource()(subscribe_callback)
|
||||||
|
|
||||||
|
return server_can_list_resources
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_unsubscribe_resource(
|
||||||
|
server_can_list_resources: Server,
|
||||||
|
unsubscribe_callback: Callable[[AnyUrl], Awaitable[None]],
|
||||||
|
) -> Server:
|
||||||
|
"""Return a server instance with resource templates."""
|
||||||
|
server_can_list_resources.unsubscribe_resource()(unsubscribe_callback)
|
||||||
|
|
||||||
|
return server_can_list_resources
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_read_resource(
|
||||||
|
server_can_list_resources: Server,
|
||||||
|
resource_callback: Callable[[AnyUrl], Awaitable[str | bytes]],
|
||||||
|
) -> Server:
|
||||||
|
"""Return a server instance with resources."""
|
||||||
|
server_can_list_resources.read_resource()(resource_callback)
|
||||||
|
|
||||||
|
return server_can_list_resources
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_set_logging_level(
|
||||||
|
server: Server,
|
||||||
|
logging_level_callback: Callable[[types.LoggingLevel], Awaitable[None]],
|
||||||
|
) -> Server:
|
||||||
|
"""Return a server instance with logging capabilities."""
|
||||||
|
server.set_logging_level()(logging_level_callback)
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_send_progress_notification(
|
||||||
|
server: Server,
|
||||||
|
) -> Server:
|
||||||
|
"""Return a server instance with logging capabilities."""
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_can_complete(
|
||||||
|
server: Server,
|
||||||
|
complete_callback: Callable[
|
||||||
|
[types.PromptReference | types.ResourceReference, types.CompletionArgument],
|
||||||
|
Awaitable[types.Completion | None],
|
||||||
|
],
|
||||||
|
) -> Server:
|
||||||
|
"""Return a server instance with logging capabilities."""
|
||||||
|
server.completion()(complete_callback)
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt", [types.Prompt(name="prompt1")])
|
||||||
|
async def test_list_prompts(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_list_prompts: Server,
|
||||||
|
prompt: types.Prompt,
|
||||||
|
) -> None:
|
||||||
|
"""Test list_prompts."""
|
||||||
|
async with session_generator(server_can_list_prompts) as session:
|
||||||
result = await session.initialize()
|
result = await session.initialize()
|
||||||
assert result.serverInfo.name == "prompt-server"
|
|
||||||
assert result.capabilities
|
assert result.capabilities
|
||||||
assert result.capabilities.prompts
|
assert result.capabilities.prompts
|
||||||
assert not result.capabilities.tools
|
assert not result.capabilities.tools
|
||||||
@@ -64,29 +187,30 @@ async def test_list_prompts(session_generator: SessionContextManager) -> None:
|
|||||||
assert not result.capabilities.logging
|
assert not result.capabilities.logging
|
||||||
|
|
||||||
list_prompts_result = await session.list_prompts()
|
list_prompts_result = await session.list_prompts()
|
||||||
assert list_prompts_result.prompts == [types.Prompt(name="prompt1")]
|
assert list_prompts_result.prompts == [prompt]
|
||||||
|
|
||||||
with pytest.raises(McpError, match="Method not found"):
|
with pytest.raises(McpError, match="Method not found"):
|
||||||
await session.list_tools()
|
await session.list_tools()
|
||||||
|
|
||||||
|
|
||||||
async def test_list_tools(session_generator: SessionContextManager) -> None:
|
@pytest.mark.parametrize(
|
||||||
|
"tool",
|
||||||
|
[
|
||||||
|
types.Tool(
|
||||||
|
name="tool-name",
|
||||||
|
description="tool-description",
|
||||||
|
inputSchema=TOOL_INPUT_SCHEMA,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_list_tools(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_list_tools: Server,
|
||||||
|
tool: types.Tool,
|
||||||
|
) -> None:
|
||||||
"""Test list_tools."""
|
"""Test list_tools."""
|
||||||
server = Server("tools-server")
|
async with session_generator(server_can_list_tools) as session:
|
||||||
|
|
||||||
@server.list_tools()
|
|
||||||
async def list_tools() -> list[types.Tool]:
|
|
||||||
return [
|
|
||||||
types.Tool(
|
|
||||||
name="tool-name",
|
|
||||||
description="tool-description",
|
|
||||||
inputSchema=TOOL_INPUT_SCHEMA,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
async with session_generator(server) as session:
|
|
||||||
result = await session.initialize()
|
result = await session.initialize()
|
||||||
assert result.serverInfo.name == "tools-server"
|
|
||||||
assert result.capabilities
|
assert result.capabilities
|
||||||
assert result.capabilities.tools
|
assert result.capabilities.tools
|
||||||
assert not result.capabilities.prompts
|
assert not result.capabilities.prompts
|
||||||
@@ -94,10 +218,247 @@ async def test_list_tools(session_generator: SessionContextManager) -> None:
|
|||||||
assert not result.capabilities.logging
|
assert not result.capabilities.logging
|
||||||
|
|
||||||
list_tools_result = await session.list_tools()
|
list_tools_result = await session.list_tools()
|
||||||
assert len(list_tools_result.tools) == 1
|
assert list_tools_result.tools == [tool]
|
||||||
assert list_tools_result.tools[0].name == "tool-name"
|
|
||||||
assert list_tools_result.tools[0].description == "tool-description"
|
|
||||||
assert list_tools_result.tools[0].inputSchema == TOOL_INPUT_SCHEMA
|
|
||||||
|
|
||||||
with pytest.raises(McpError, match="Method not found"):
|
with pytest.raises(McpError, match="Method not found"):
|
||||||
await session.list_prompts()
|
await session.list_prompts()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("logging_level_callback", [AsyncMock()])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"log_level",
|
||||||
|
["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"],
|
||||||
|
)
|
||||||
|
async def test_set_logging_error(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_set_logging_level: Server,
|
||||||
|
logging_level_callback: AsyncMock,
|
||||||
|
log_level: types.LoggingLevel,
|
||||||
|
) -> None:
|
||||||
|
"""Test set_logging_level."""
|
||||||
|
async with session_generator(server_can_set_logging_level) as session:
|
||||||
|
result = await session.initialize()
|
||||||
|
assert result.capabilities
|
||||||
|
assert result.capabilities.logging
|
||||||
|
assert not result.capabilities.prompts
|
||||||
|
assert not result.capabilities.resources
|
||||||
|
assert not result.capabilities.tools
|
||||||
|
|
||||||
|
logging_level_callback.return_value = None
|
||||||
|
await session.set_logging_level(log_level)
|
||||||
|
logging_level_callback.assert_called_once_with(log_level)
|
||||||
|
logging_level_callback.reset_mock() # Reset the mock for the next test case
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tool", [AsyncMock()])
|
||||||
|
async def test_call_tool(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_call_tool: Server,
|
||||||
|
tool: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test call_tool."""
|
||||||
|
async with session_generator(server_can_call_tool) as session:
|
||||||
|
result = await session.initialize()
|
||||||
|
assert result.capabilities
|
||||||
|
assert result.capabilities
|
||||||
|
assert result.capabilities.tools
|
||||||
|
assert not result.capabilities.prompts
|
||||||
|
assert not result.capabilities.resources
|
||||||
|
assert not result.capabilities.logging
|
||||||
|
|
||||||
|
tool.return_value = []
|
||||||
|
call_tool_result = await session.call_tool("tool", {})
|
||||||
|
assert call_tool_result.content == []
|
||||||
|
assert not call_tool_result.isError
|
||||||
|
|
||||||
|
tool.assert_called_once_with("tool", {})
|
||||||
|
tool.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resource",
|
||||||
|
[
|
||||||
|
types.Resource(
|
||||||
|
uri=AnyUrl("scheme://resource-uri"),
|
||||||
|
name="resource-name",
|
||||||
|
description="resource-description",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_list_resources(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_list_resources: Server,
|
||||||
|
resource: types.Resource,
|
||||||
|
) -> None:
|
||||||
|
"""Test get_resource."""
|
||||||
|
async with session_generator(server_can_list_resources) as session:
|
||||||
|
result = await session.initialize()
|
||||||
|
assert result.capabilities
|
||||||
|
assert result.capabilities.resources
|
||||||
|
assert not result.capabilities.prompts
|
||||||
|
assert not result.capabilities.tools
|
||||||
|
assert not result.capabilities.logging
|
||||||
|
|
||||||
|
list_resources_result = await session.list_resources()
|
||||||
|
assert list_resources_result.resources == [resource]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt_callback", [AsyncMock()])
|
||||||
|
@pytest.mark.parametrize("prompt", [types.Prompt(name="prompt1")])
|
||||||
|
async def test_get_prompt(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_get_prompt: Server,
|
||||||
|
prompt_callback: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test get_prompt."""
|
||||||
|
async with session_generator(server_can_get_prompt) as session:
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
|
prompt_callback.return_value = types.GetPromptResult(messages=[])
|
||||||
|
|
||||||
|
await session.get_prompt("prompt", {})
|
||||||
|
prompt_callback.assert_called_once_with("prompt", {})
|
||||||
|
prompt_callback.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("resource_callback", [AsyncMock()])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resource",
|
||||||
|
[
|
||||||
|
types.Resource(
|
||||||
|
uri=AnyUrl("scheme://resource-uri"),
|
||||||
|
name="resource-name",
|
||||||
|
description="resource-description",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_read_resource(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_read_resource: Server,
|
||||||
|
resource_callback: AsyncMock,
|
||||||
|
resource: types.Resource,
|
||||||
|
) -> None:
|
||||||
|
"""Test read_resource."""
|
||||||
|
async with session_generator(server_can_read_resource) as session:
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
|
resource_callback.return_value = "resource-content"
|
||||||
|
await session.read_resource(resource.uri)
|
||||||
|
resource_callback.assert_called_once_with(resource.uri)
|
||||||
|
resource_callback.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("subscribe_callback", [AsyncMock()])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resource",
|
||||||
|
[
|
||||||
|
types.Resource(
|
||||||
|
uri=AnyUrl("scheme://resource-uri"),
|
||||||
|
name="resource-name",
|
||||||
|
description="resource-description",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_subscribe_resource(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_subscribe_resource: Server,
|
||||||
|
subscribe_callback: AsyncMock,
|
||||||
|
resource: types.Resource,
|
||||||
|
) -> None:
|
||||||
|
"""Test subscribe_resource."""
|
||||||
|
async with session_generator(server_can_subscribe_resource) as session:
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
|
subscribe_callback.return_value = None
|
||||||
|
await session.subscribe_resource(resource.uri)
|
||||||
|
subscribe_callback.assert_called_once_with(resource.uri)
|
||||||
|
subscribe_callback.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("unsubscribe_callback", [AsyncMock()])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resource",
|
||||||
|
[
|
||||||
|
types.Resource(
|
||||||
|
uri=AnyUrl("scheme://resource-uri"),
|
||||||
|
name="resource-name",
|
||||||
|
description="resource-description",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_unsubscribe_resource(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_unsubscribe_resource: Server,
|
||||||
|
unsubscribe_callback: AsyncMock,
|
||||||
|
resource: types.Resource,
|
||||||
|
) -> None:
|
||||||
|
"""Test subscribe_resource."""
|
||||||
|
async with session_generator(server_can_unsubscribe_resource) as session:
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
|
unsubscribe_callback.return_value = None
|
||||||
|
await session.unsubscribe_resource(resource.uri)
|
||||||
|
unsubscribe_callback.assert_called_once_with(resource.uri)
|
||||||
|
unsubscribe_callback.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_progress_notification(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_send_progress_notification: Server,
|
||||||
|
) -> None:
|
||||||
|
"""Test send_progress_notification."""
|
||||||
|
async with session_generator(server_can_send_progress_notification) as session:
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
|
await session.send_progress_notification(
|
||||||
|
progress_token=1,
|
||||||
|
progress=0.5,
|
||||||
|
total=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("complete_callback", [AsyncMock()])
|
||||||
|
async def test_complete(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_complete: Server,
|
||||||
|
complete_callback: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test complete."""
|
||||||
|
async with session_generator(server_can_complete) as session:
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
|
complete_callback.return_value = None
|
||||||
|
result = await session.complete(
|
||||||
|
types.PromptReference(type="ref/prompt", name="name"),
|
||||||
|
argument={"name": "name", "value": "value"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.completion.values == []
|
||||||
|
|
||||||
|
complete_callback.assert_called_with(
|
||||||
|
types.PromptReference(type="ref/prompt", name="name"),
|
||||||
|
types.CompletionArgument(name="name", value="value"),
|
||||||
|
)
|
||||||
|
complete_callback.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tool", [AsyncMock()])
|
||||||
|
async def test_call_tool_with_error(
|
||||||
|
session_generator: SessionContextManager,
|
||||||
|
server_can_call_tool: Server,
|
||||||
|
tool: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test call_tool."""
|
||||||
|
async with session_generator(server_can_call_tool) as session:
|
||||||
|
result = await session.initialize()
|
||||||
|
assert result.capabilities
|
||||||
|
assert result.capabilities
|
||||||
|
assert result.capabilities.tools
|
||||||
|
assert not result.capabilities.prompts
|
||||||
|
assert not result.capabilities.resources
|
||||||
|
assert not result.capabilities.logging
|
||||||
|
|
||||||
|
tool.side_effect = Exception("Error")
|
||||||
|
|
||||||
|
call_tool_result = await session.call_tool("tool", {})
|
||||||
|
assert call_tool_result.isError
|
||||||
|
|||||||
Reference in New Issue
Block a user