49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
166 lines
5.7 KiB
Python
166 lines
5.7 KiB
Python
import logging
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from mcp_types import (
|
|
CallToolRequestParams,
|
|
CallToolResult,
|
|
ListToolsResult,
|
|
PaginatedRequestParams,
|
|
TextContent,
|
|
Tool,
|
|
)
|
|
|
|
from mcp import Client
|
|
from mcp.server import Server, ServerRequestContext
|
|
|
|
|
|
def _make_server(
|
|
tools: list[Tool],
|
|
structured_content: dict[str, Any],
|
|
) -> Server:
|
|
"""Create a low-level server that returns the given structured_content for any tool call."""
|
|
|
|
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
return ListToolsResult(tools=tools)
|
|
|
|
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
return CallToolResult(
|
|
content=[TextContent(type="text", text="result")],
|
|
structured_content=structured_content,
|
|
)
|
|
|
|
return Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_structured_output_client_side_validation_basemodel():
|
|
"""Test that client validates structured content against schema for BaseModel outputs"""
|
|
output_schema = {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}},
|
|
"required": ["name", "age"],
|
|
"title": "UserOutput",
|
|
}
|
|
|
|
server = _make_server(
|
|
tools=[
|
|
Tool(
|
|
name="get_user",
|
|
description="Get user data",
|
|
input_schema={"type": "object"},
|
|
output_schema=output_schema,
|
|
)
|
|
],
|
|
structured_content={"name": "John", "age": "invalid"}, # Invalid: age should be int
|
|
)
|
|
|
|
async with Client(server) as client:
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await client.call_tool("get_user", {})
|
|
assert "Invalid structured content returned by tool get_user" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_structured_output_client_side_validation_primitive():
|
|
"""Test that client validates structured content for primitive outputs"""
|
|
output_schema = {
|
|
"type": "object",
|
|
"properties": {"result": {"type": "integer", "title": "Result"}},
|
|
"required": ["result"],
|
|
"title": "calculate_Output",
|
|
}
|
|
|
|
server = _make_server(
|
|
tools=[
|
|
Tool(
|
|
name="calculate",
|
|
description="Calculate something",
|
|
input_schema={"type": "object"},
|
|
output_schema=output_schema,
|
|
)
|
|
],
|
|
structured_content={"result": "not_a_number"}, # Invalid: should be int
|
|
)
|
|
|
|
async with Client(server) as client:
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await client.call_tool("calculate", {})
|
|
assert "Invalid structured content returned by tool calculate" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_structured_output_client_side_validation_dict_typed():
|
|
"""Test that client validates dict[str, T] structured content"""
|
|
output_schema = {"type": "object", "additionalProperties": {"type": "integer"}, "title": "get_scores_Output"}
|
|
|
|
server = _make_server(
|
|
tools=[
|
|
Tool(
|
|
name="get_scores",
|
|
description="Get scores",
|
|
input_schema={"type": "object"},
|
|
output_schema=output_schema,
|
|
)
|
|
],
|
|
structured_content={"alice": "100", "bob": "85"}, # Invalid: values should be int
|
|
)
|
|
|
|
async with Client(server) as client:
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await client.call_tool("get_scores", {})
|
|
assert "Invalid structured content returned by tool get_scores" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_structured_output_client_side_validation_missing_required():
|
|
"""Test that client validates missing required fields"""
|
|
output_schema = {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}, "email": {"type": "string"}},
|
|
"required": ["name", "age", "email"],
|
|
"title": "PersonOutput",
|
|
}
|
|
|
|
server = _make_server(
|
|
tools=[
|
|
Tool(
|
|
name="get_person",
|
|
description="Get person data",
|
|
input_schema={"type": "object"},
|
|
output_schema=output_schema,
|
|
)
|
|
],
|
|
structured_content={"name": "John", "age": 30}, # Missing required 'email'
|
|
)
|
|
|
|
async with Client(server) as client:
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await client.call_tool("get_person", {})
|
|
assert "Invalid structured content returned by tool get_person" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_not_listed_warning(caplog: pytest.LogCaptureFixture):
|
|
"""Test that client logs warning when tool is not in list_tools but has output_schema"""
|
|
|
|
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
return ListToolsResult(tools=[])
|
|
|
|
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
return CallToolResult(
|
|
content=[TextContent(type="text", text="result")],
|
|
structured_content={"result": 42},
|
|
)
|
|
|
|
server = Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
|
|
|
|
caplog.set_level(logging.WARNING)
|
|
|
|
async with Client(server) as client:
|
|
result = await client.call_tool("mystery_tool", {})
|
|
assert result.structured_content == {"result": 42}
|
|
assert result.is_error is False
|
|
|
|
assert "Tool mystery_tool not listed" in caplog.text
|