feat: Initial commit
This commit is contained in:
33
tests/conftest.py
Normal file
33
tests/conftest.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Shared pytest fixtures."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from mcp_api import MCPToolbox
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server_config() -> dict:
|
||||
"""Config dict for the two test MCP servers, using the current Python interpreter."""
|
||||
python = sys.executable
|
||||
return {
|
||||
"echo": {
|
||||
"command": python,
|
||||
"args": [str(FIXTURES / "echo_server.py")],
|
||||
},
|
||||
"math": {
|
||||
"command": python,
|
||||
"args": [str(FIXTURES / "math_server.py")],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def toolbox(server_config: dict) -> MCPToolbox:
|
||||
async with MCPToolbox.from_config(server_config) as tb:
|
||||
yield tb
|
||||
21
tests/fixtures/echo_server.py
vendored
Normal file
21
tests/fixtures/echo_server.py
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Simple MCP server used for testing: string tools."""
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("echo")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def echo(message: str) -> str:
|
||||
"""Return the message unchanged."""
|
||||
return message
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reverse(text: str) -> str:
|
||||
"""Return the text reversed."""
|
||||
return text[::-1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run("stdio")
|
||||
21
tests/fixtures/math_server.py
vendored
Normal file
21
tests/fixtures/math_server.py
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Simple MCP server used for testing: numeric tools."""
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("math")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Return the sum of a and b."""
|
||||
return a + b
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def multiply(a: int, b: int) -> int:
|
||||
"""Return the product of a and b."""
|
||||
return a * b
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run("stdio")
|
||||
16
tests/fixtures/test_claude.json
vendored
Normal file
16
tests/fixtures/test_claude.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"echo": {
|
||||
"command": "/home/matteo/Gitted/Thesis/python-mcp-api/.venv/bin/python",
|
||||
"args": [
|
||||
"/home/matteo/Gitted/Thesis/python-mcp-api/tests/fixtures/echo_server.py"
|
||||
]
|
||||
},
|
||||
"math": {
|
||||
"command": "/home/matteo/Gitted/Thesis/python-mcp-api/.venv/bin/python",
|
||||
"args": [
|
||||
"/home/matteo/Gitted/Thesis/python-mcp-api/tests/fixtures/math_server.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
141
tests/test_toolbox.py
Normal file
141
tests/test_toolbox.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Tests for MCPToolbox."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_api import MCPTool, MCPToolbox
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovers_all_tools(toolbox: MCPToolbox) -> None:
|
||||
names = {t.name for t in toolbox.tools}
|
||||
assert names == {"echo", "reverse", "add", "multiply"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_has_description(toolbox: MCPToolbox) -> None:
|
||||
for tool in toolbox.tools:
|
||||
assert tool.description, f"{tool.name} has no description"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_has_input_schema(toolbox: MCPToolbox) -> None:
|
||||
for tool in toolbox.tools:
|
||||
assert isinstance(tool.input_schema, dict)
|
||||
assert tool.input_schema.get("type") == "object"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_getitem(toolbox: MCPToolbox) -> None:
|
||||
assert isinstance(toolbox["echo"], MCPTool)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flat_attribute_access(toolbox: MCPToolbox) -> None:
|
||||
# "echo" is also a server name → _ServerNamespace takes priority
|
||||
# Use a tool name that doesn't collide with a server name for flat access
|
||||
assert isinstance(toolbox.reverse, MCPTool)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_namespace_attribute_access(toolbox: MCPToolbox) -> None:
|
||||
assert isinstance(toolbox.echo.echo, MCPTool)
|
||||
assert isinstance(toolbox.math.add, MCPTool)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_attribute_raises(toolbox: MCPToolbox) -> None:
|
||||
with pytest.raises(AttributeError):
|
||||
_ = toolbox.nonexistent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool calls — direct
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_echo(toolbox: MCPToolbox) -> None:
|
||||
result = await toolbox.call_tool("echo", {"message": "hello"})
|
||||
assert result == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_reverse(toolbox: MCPToolbox) -> None:
|
||||
result = await toolbox.call_tool("reverse", {"text": "abc"})
|
||||
assert result == "cba"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_add(toolbox: MCPToolbox) -> None:
|
||||
result = await toolbox.call_tool("add", {"a": 3, "b": 4})
|
||||
assert result == "7"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_multiply(toolbox: MCPToolbox) -> None:
|
||||
result = await toolbox.call_tool("multiply", {"a": 6, "b": 7})
|
||||
assert result == "42"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool calls — via callable / attribute access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callable_tool(toolbox: MCPToolbox) -> None:
|
||||
result = await toolbox["echo"](message="world")
|
||||
assert result == "world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_namespace_call(toolbox: MCPToolbox) -> None:
|
||||
result = await toolbox.math.add(a=10, b=5)
|
||||
assert result == "15"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_as_anthropic_tools_structure(toolbox: MCPToolbox) -> None:
|
||||
defs = toolbox.as_anthropic_tools()
|
||||
assert len(defs) == 4
|
||||
for d in defs:
|
||||
assert {"name", "description", "input_schema"} == d.keys()
|
||||
assert isinstance(d["input_schema"], dict)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_anthropic_tool_use_success(toolbox: MCPToolbox) -> None:
|
||||
class FakeBlock:
|
||||
type = "tool_use"
|
||||
id = "tu_123"
|
||||
name = "echo"
|
||||
input = {"message": "ping"}
|
||||
|
||||
result = await toolbox.handle_anthropic_tool_use(FakeBlock())
|
||||
assert result["type"] == "tool_result"
|
||||
assert result["tool_use_id"] == "tu_123"
|
||||
assert result["content"] == "ping"
|
||||
assert result["is_error"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_anthropic_tool_use_error(toolbox: MCPToolbox) -> None:
|
||||
class FakeBlock:
|
||||
type = "tool_use"
|
||||
id = "tu_456"
|
||||
name = "nonexistent_tool"
|
||||
input = {}
|
||||
|
||||
result = await toolbox.handle_anthropic_tool_use(FakeBlock())
|
||||
assert result["is_error"] is True
|
||||
assert result["tool_use_id"] == "tu_456"
|
||||
Reference in New Issue
Block a user