mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
Consolidate test fixtures and refactor large test files (#2941)
This commit is contained in:
parent
e376bfcfb2
commit
23bfdf0680
71 changed files with 12679 additions and 12544 deletions
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import multiprocessing
|
||||
import socket
|
||||
import time
|
||||
|
|
@ -12,7 +11,6 @@ from urllib.parse import parse_qs, urlparse
|
|||
|
||||
import httpx
|
||||
import uvicorn
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
from fastmcp import settings
|
||||
from fastmcp.client.auth.oauth import OAuth
|
||||
|
|
@ -224,20 +222,6 @@ async def run_server_async(
|
|||
await asyncio.wait_for(server_task, timeout=2.0)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def caplog_for_fastmcp(
|
||||
caplog: LogCaptureFixture,
|
||||
) -> Generator[LogCaptureFixture, None, None]:
|
||||
"""Context manager to capture logs from FastMCP loggers even when propagation is disabled."""
|
||||
caplog.clear()
|
||||
logger = logging.getLogger("fastmcp")
|
||||
logger.addHandler(caplog.handler)
|
||||
try:
|
||||
yield caplog
|
||||
finally:
|
||||
logger.removeHandler(caplog.handler)
|
||||
|
||||
|
||||
class HeadlessOAuth(OAuth):
|
||||
"""
|
||||
OAuth provider that bypasses browser interaction for testing.
|
||||
|
|
|
|||
0
tests/client/client/__init__.py
Normal file
0
tests/client/client/__init__.py
Normal file
82
tests/client/client/test_auth.py
Normal file
82
tests/client/client/test_auth.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Client authentication tests."""
|
||||
|
||||
import pytest
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.auth.bearer import BearerAuth
|
||||
from fastmcp.client.transports import (
|
||||
SSETransport,
|
||||
StdioTransport,
|
||||
StreamableHttpTransport,
|
||||
)
|
||||
|
||||
|
||||
class TestAuth:
|
||||
def test_default_auth_is_none(self):
|
||||
client = Client(transport=StreamableHttpTransport("http://localhost:8000"))
|
||||
assert client.transport.auth is None
|
||||
|
||||
def test_stdio_doesnt_support_auth(self):
|
||||
with pytest.raises(ValueError, match="This transport does not support auth"):
|
||||
Client(transport=StdioTransport("echo", ["hello"]), auth="oauth")
|
||||
|
||||
def test_oauth_literal_sets_up_oauth_shttp(self):
|
||||
client = Client(
|
||||
transport=StreamableHttpTransport("http://localhost:8000"), auth="oauth"
|
||||
)
|
||||
assert isinstance(client.transport, StreamableHttpTransport)
|
||||
assert isinstance(client.transport.auth, OAuthClientProvider)
|
||||
|
||||
def test_oauth_literal_pass_direct_to_transport(self):
|
||||
client = Client(
|
||||
transport=StreamableHttpTransport("http://localhost:8000", auth="oauth"),
|
||||
)
|
||||
assert isinstance(client.transport, StreamableHttpTransport)
|
||||
assert isinstance(client.transport.auth, OAuthClientProvider)
|
||||
|
||||
def test_oauth_literal_sets_up_oauth_sse(self):
|
||||
client = Client(transport=SSETransport("http://localhost:8000"), auth="oauth")
|
||||
assert isinstance(client.transport, SSETransport)
|
||||
assert isinstance(client.transport.auth, OAuthClientProvider)
|
||||
|
||||
def test_oauth_literal_pass_direct_to_transport_sse(self):
|
||||
client = Client(transport=SSETransport("http://localhost:8000", auth="oauth"))
|
||||
assert isinstance(client.transport, SSETransport)
|
||||
assert isinstance(client.transport.auth, OAuthClientProvider)
|
||||
|
||||
def test_auth_string_sets_up_bearer_auth_shttp(self):
|
||||
client = Client(
|
||||
transport=StreamableHttpTransport("http://localhost:8000"),
|
||||
auth="test_token",
|
||||
)
|
||||
assert isinstance(client.transport, StreamableHttpTransport)
|
||||
assert isinstance(client.transport.auth, BearerAuth)
|
||||
assert client.transport.auth.token.get_secret_value() == "test_token"
|
||||
|
||||
def test_auth_string_pass_direct_to_transport_shttp(self):
|
||||
client = Client(
|
||||
transport=StreamableHttpTransport(
|
||||
"http://localhost:8000", auth="test_token"
|
||||
),
|
||||
)
|
||||
assert isinstance(client.transport, StreamableHttpTransport)
|
||||
assert isinstance(client.transport.auth, BearerAuth)
|
||||
assert client.transport.auth.token.get_secret_value() == "test_token"
|
||||
|
||||
def test_auth_string_sets_up_bearer_auth_sse(self):
|
||||
client = Client(
|
||||
transport=SSETransport("http://localhost:8000"),
|
||||
auth="test_token",
|
||||
)
|
||||
assert isinstance(client.transport, SSETransport)
|
||||
assert isinstance(client.transport.auth, BearerAuth)
|
||||
assert client.transport.auth.token.get_secret_value() == "test_token"
|
||||
|
||||
def test_auth_string_pass_direct_to_transport_sse(self):
|
||||
client = Client(
|
||||
transport=SSETransport("http://localhost:8000", auth="test_token"),
|
||||
)
|
||||
assert isinstance(client.transport, SSETransport)
|
||||
assert isinstance(client.transport.auth, BearerAuth)
|
||||
assert client.transport.auth.token.get_secret_value() == "test_token"
|
||||
719
tests/client/client/test_client.py
Normal file
719
tests/client/client/test_client.py
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
"""Core client functionality: tools, resources, prompts."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp import ClientSession, McpError
|
||||
from mcp.types import TextContent
|
||||
from pydantic import AnyUrl
|
||||
|
||||
import fastmcp
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports import (
|
||||
ClientTransport,
|
||||
FastMCPTransport,
|
||||
)
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
async def test_list_tools(fastmcp_server):
|
||||
"""Test listing tools with InMemoryClient."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.list_tools()
|
||||
|
||||
# Check that our tools are available
|
||||
assert len(result) == 3
|
||||
assert set(tool.name for tool in result) == {"greet", "add", "sleep"}
|
||||
|
||||
|
||||
async def test_list_tools_mcp(fastmcp_server):
|
||||
"""Test the list_tools_mcp method that returns raw MCP protocol objects."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.list_tools_mcp()
|
||||
|
||||
# Check that we got the raw MCP ListToolsResult object
|
||||
assert hasattr(result, "tools")
|
||||
assert len(result.tools) == 3
|
||||
assert set(tool.name for tool in result.tools) == {"greet", "add", "sleep"}
|
||||
|
||||
|
||||
async def test_call_tool(fastmcp_server):
|
||||
"""Test calling a tool with InMemoryClient."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.call_tool("greet", {"name": "World"})
|
||||
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Hello, World!"
|
||||
assert result.structured_content == {"result": "Hello, World!"}
|
||||
assert result.data == "Hello, World!"
|
||||
assert result.is_error is False
|
||||
|
||||
|
||||
async def test_call_tool_mcp(fastmcp_server):
|
||||
"""Test the call_tool_mcp method that returns raw MCP protocol objects."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.call_tool_mcp("greet", {"name": "World"})
|
||||
|
||||
# Check that we got the raw MCP CallToolResult object
|
||||
assert hasattr(result, "content")
|
||||
assert hasattr(result, "isError")
|
||||
assert result.isError is False
|
||||
# The content is a list, so we'll check the first element
|
||||
# by properly accessing it
|
||||
content = result.content
|
||||
assert len(content) > 0
|
||||
first_content = content[0]
|
||||
content_str = str(first_content)
|
||||
assert "Hello, World!" in content_str
|
||||
|
||||
|
||||
async def test_call_tool_with_meta():
|
||||
"""Test that meta parameter is properly passed from client to server."""
|
||||
server = FastMCP("MetaTestServer")
|
||||
|
||||
# Create a tool that accesses the meta from the request context
|
||||
@server.tool
|
||||
def check_meta() -> dict[str, Any]:
|
||||
"""A tool that returns the meta from the request context."""
|
||||
from fastmcp.server.dependencies import get_context
|
||||
|
||||
context = get_context()
|
||||
assert context.request_context is not None
|
||||
meta = context.request_context.meta
|
||||
|
||||
# Return the meta data as a dict
|
||||
if meta is not None:
|
||||
return {
|
||||
"has_meta": True,
|
||||
"user_id": getattr(meta, "user_id", None),
|
||||
"trace_id": getattr(meta, "trace_id", None),
|
||||
}
|
||||
return {"has_meta": False}
|
||||
|
||||
client = Client(transport=FastMCPTransport(server))
|
||||
|
||||
async with client:
|
||||
# Test with meta parameter - verify the server receives it
|
||||
test_meta = {"user_id": "test-123", "trace_id": "abc-def"}
|
||||
result = await client.call_tool("check_meta", {}, meta=test_meta)
|
||||
|
||||
assert result.data["has_meta"] is True
|
||||
assert result.data["user_id"] == "test-123"
|
||||
assert result.data["trace_id"] == "abc-def"
|
||||
|
||||
# Test without meta parameter - verify fields are not present
|
||||
result_no_meta = await client.call_tool("check_meta", {})
|
||||
# When meta is not provided, custom fields should not be present
|
||||
assert result_no_meta.data.get("user_id") is None
|
||||
assert result_no_meta.data.get("trace_id") is None
|
||||
|
||||
|
||||
async def test_list_resources(fastmcp_server):
|
||||
"""Test listing resources with InMemoryClient."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.list_resources()
|
||||
|
||||
# Check that our resource is available
|
||||
assert len(result) == 1
|
||||
assert str(result[0].uri) == "data://users"
|
||||
|
||||
|
||||
async def test_list_resources_mcp(fastmcp_server):
|
||||
"""Test the list_resources_mcp method that returns raw MCP protocol objects."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.list_resources_mcp()
|
||||
|
||||
# Check that we got the raw MCP ListResourcesResult object
|
||||
assert hasattr(result, "resources")
|
||||
assert len(result.resources) == 1
|
||||
assert str(result.resources[0].uri) == "data://users"
|
||||
|
||||
|
||||
async def test_list_prompts(fastmcp_server):
|
||||
"""Test listing prompts with InMemoryClient."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.list_prompts()
|
||||
|
||||
# Check that our prompt is available
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "welcome"
|
||||
|
||||
|
||||
async def test_list_prompts_mcp(fastmcp_server):
|
||||
"""Test the list_prompts_mcp method that returns raw MCP protocol objects."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.list_prompts_mcp()
|
||||
|
||||
# Check that we got the raw MCP ListPromptsResult object
|
||||
assert hasattr(result, "prompts")
|
||||
assert len(result.prompts) == 1
|
||||
assert result.prompts[0].name == "welcome"
|
||||
|
||||
|
||||
async def test_get_prompt(fastmcp_server):
|
||||
"""Test getting a prompt with InMemoryClient."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.get_prompt("welcome", {"name": "Developer"})
|
||||
|
||||
# The result should contain our welcome message
|
||||
assert isinstance(result.messages[0].content, TextContent)
|
||||
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
|
||||
assert result.description == "Example greeting prompt."
|
||||
|
||||
|
||||
async def test_get_prompt_mcp(fastmcp_server):
|
||||
"""Test the get_prompt_mcp method that returns raw MCP protocol objects."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.get_prompt_mcp("welcome", {"name": "Developer"})
|
||||
|
||||
# The result should contain our welcome message
|
||||
assert isinstance(result.messages[0].content, TextContent)
|
||||
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
|
||||
assert result.description == "Example greeting prompt."
|
||||
|
||||
|
||||
async def test_client_serializes_all_non_string_arguments():
|
||||
"""Test that client always serializes non-string arguments to JSON, regardless of server types."""
|
||||
server = FastMCP("TestServer")
|
||||
|
||||
@server.prompt
|
||||
def echo_args(arg1: str, arg2: str, arg3: str) -> str:
|
||||
"""Server accepts all string args but client sends mixed types."""
|
||||
return f"arg1: {arg1}, arg2: {arg2}, arg3: {arg3}"
|
||||
|
||||
client = Client(transport=FastMCPTransport(server))
|
||||
|
||||
async with client:
|
||||
result = await client.get_prompt(
|
||||
"echo_args",
|
||||
{
|
||||
"arg1": "hello", # string - should pass through
|
||||
"arg2": [1, 2, 3], # list - should be JSON serialized
|
||||
"arg3": {"key": "value"}, # dict - should be JSON serialized
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(result.messages[0].content, TextContent)
|
||||
content = result.messages[0].content.text
|
||||
assert "arg1: hello" in content
|
||||
assert "arg2: [1,2,3]" in content # JSON serialized list
|
||||
assert 'arg3: {"key":"value"}' in content # JSON serialized dict
|
||||
|
||||
|
||||
async def test_client_server_type_conversion_integration():
|
||||
"""Test that client serialization works with server-side type conversion."""
|
||||
server = FastMCP("TestServer")
|
||||
|
||||
@server.prompt
|
||||
def typed_prompt(numbers: list[int], config: dict[str, str]) -> str:
|
||||
"""Server expects typed args - will convert from JSON strings."""
|
||||
return f"Got {len(numbers)} numbers and {len(config)} config items"
|
||||
|
||||
client = Client(transport=FastMCPTransport(server))
|
||||
|
||||
async with client:
|
||||
result = await client.get_prompt(
|
||||
"typed_prompt",
|
||||
{"numbers": [1, 2, 3, 4], "config": {"theme": "dark", "lang": "en"}},
|
||||
)
|
||||
|
||||
assert isinstance(result.messages[0].content, TextContent)
|
||||
content = result.messages[0].content.text
|
||||
assert "Got 4 numbers and 2 config items" in content
|
||||
|
||||
|
||||
async def test_client_serialization_error():
|
||||
"""Test client error when object cannot be serialized."""
|
||||
import pydantic_core
|
||||
|
||||
server = FastMCP("TestServer")
|
||||
|
||||
@server.prompt
|
||||
def any_prompt(data: str) -> str:
|
||||
return f"Got: {data}"
|
||||
|
||||
# Create an unserializable object
|
||||
class UnserializableClass:
|
||||
def __init__(self):
|
||||
self.func = lambda x: x # functions can't be JSON serialized
|
||||
|
||||
client = Client(transport=FastMCPTransport(server))
|
||||
|
||||
async with client:
|
||||
with pytest.raises(
|
||||
pydantic_core.PydanticSerializationError, match="Unable to serialize"
|
||||
):
|
||||
await client.get_prompt("any_prompt", {"data": UnserializableClass()})
|
||||
|
||||
|
||||
async def test_server_deserialization_error():
|
||||
"""Test server error when JSON string cannot be converted to expected type."""
|
||||
|
||||
server = FastMCP("TestServer")
|
||||
|
||||
@server.prompt
|
||||
def strict_typed_prompt(numbers: list[int]) -> str:
|
||||
"""Expects list of integers but will receive invalid JSON."""
|
||||
return f"Got {len(numbers)} numbers"
|
||||
|
||||
client = Client(transport=FastMCPTransport(server))
|
||||
|
||||
async with client:
|
||||
with pytest.raises(McpError, match="Error rendering prompt"):
|
||||
await client.get_prompt(
|
||||
"strict_typed_prompt",
|
||||
{
|
||||
"numbers": "not valid json" # This will fail server-side conversion
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_read_resource_invalid_uri(fastmcp_server):
|
||||
"""Test reading a resource with an invalid URI."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
with pytest.raises(ValueError, match="Provided resource URI is invalid"):
|
||||
await client.read_resource("invalid_uri")
|
||||
|
||||
|
||||
async def test_read_resource(fastmcp_server):
|
||||
"""Test reading a resource with InMemoryClient."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
# Use the URI from the resource we know exists in our server
|
||||
uri = cast(
|
||||
AnyUrl, "data://users"
|
||||
) # Use cast for type hint only, the URI is valid
|
||||
result = await client.read_resource(uri)
|
||||
|
||||
# The contents should include our user list
|
||||
contents_str = str(result[0])
|
||||
assert "Alice" in contents_str
|
||||
assert "Bob" in contents_str
|
||||
assert "Charlie" in contents_str
|
||||
|
||||
|
||||
async def test_read_resource_mcp(fastmcp_server):
|
||||
"""Test the read_resource_mcp method that returns raw MCP protocol objects."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
# Use the URI from the resource we know exists in our server
|
||||
uri = cast(
|
||||
AnyUrl, "data://users"
|
||||
) # Use cast for type hint only, the URI is valid
|
||||
result = await client.read_resource_mcp(uri)
|
||||
|
||||
# Check that we got the raw MCP ReadResourceResult object
|
||||
assert hasattr(result, "contents")
|
||||
assert len(result.contents) > 0
|
||||
contents_str = str(result.contents[0])
|
||||
assert "Alice" in contents_str
|
||||
assert "Bob" in contents_str
|
||||
assert "Charlie" in contents_str
|
||||
|
||||
|
||||
async def test_client_connection(fastmcp_server):
|
||||
"""Test that connect is idempotent."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
# Connect idempotently
|
||||
async with client:
|
||||
assert client.is_connected()
|
||||
# Make a request to ensure connection is working
|
||||
await client.ping()
|
||||
assert not client.is_connected()
|
||||
|
||||
|
||||
async def test_initialize_called_once(fastmcp_server):
|
||||
"""Test that initialization is called once and sets initialize_result."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
async with client:
|
||||
# Verify that initialization succeeded by checking initialize_result
|
||||
assert client.initialize_result is not None
|
||||
assert client.initialize_result.serverInfo is not None
|
||||
|
||||
|
||||
async def test_initialize_result_connected(fastmcp_server):
|
||||
"""Test that initialize_result returns the correct result when connected."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
# Initialize result should be None before connection
|
||||
assert client.initialize_result is None
|
||||
|
||||
async with client:
|
||||
# Once connected, initialize_result should be available
|
||||
result = client.initialize_result
|
||||
|
||||
# Verify the initialize result has expected properties
|
||||
assert hasattr(result, "serverInfo")
|
||||
assert result.serverInfo.name == "TestServer"
|
||||
assert result.serverInfo.version is not None
|
||||
|
||||
|
||||
async def test_initialize_result_disconnected(fastmcp_server):
|
||||
"""Test that initialize_result is None when not connected."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
# Initialize result should be None before connection
|
||||
assert client.initialize_result is None
|
||||
|
||||
# Connect and then disconnect
|
||||
async with client:
|
||||
assert client.is_connected()
|
||||
|
||||
# After disconnection, initialize_result should be None again
|
||||
assert not client.is_connected()
|
||||
assert client.initialize_result is None
|
||||
|
||||
|
||||
async def test_server_info_custom_version():
|
||||
"""Test that custom version is properly set in serverInfo."""
|
||||
# Test with custom version
|
||||
server_with_version = FastMCP("CustomVersionServer", version="1.2.3")
|
||||
client = Client(transport=FastMCPTransport(server_with_version))
|
||||
|
||||
async with client:
|
||||
result = client.initialize_result
|
||||
assert result is not None
|
||||
assert result.serverInfo.name == "CustomVersionServer"
|
||||
assert result.serverInfo.version == "1.2.3"
|
||||
|
||||
# Test without version (backward compatibility)
|
||||
server_without_version = FastMCP("DefaultVersionServer")
|
||||
client = Client(transport=FastMCPTransport(server_without_version))
|
||||
|
||||
async with client:
|
||||
result = client.initialize_result
|
||||
assert result is not None
|
||||
assert result.serverInfo.name == "DefaultVersionServer"
|
||||
# Should fall back to FastMCP version
|
||||
assert result.serverInfo.version == fastmcp.__version__
|
||||
|
||||
|
||||
class _DelayedConnectTransport(ClientTransport):
|
||||
def __init__(
|
||||
self,
|
||||
inner: ClientTransport,
|
||||
connect_started: anyio.Event,
|
||||
allow_connect: anyio.Event,
|
||||
) -> None:
|
||||
self._inner = inner
|
||||
self._connect_started = connect_started
|
||||
self._allow_connect = allow_connect
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(
|
||||
self, **session_kwargs: Any
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
self._connect_started.set()
|
||||
await self._allow_connect.wait()
|
||||
async with self._inner.connect_session(**session_kwargs) as session:
|
||||
yield session
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._inner.close()
|
||||
|
||||
|
||||
async def test_client_nested_context_manager(fastmcp_server):
|
||||
"""Test that the client connects and disconnects once in nested context manager."""
|
||||
|
||||
client = Client(fastmcp_server)
|
||||
|
||||
# Before connection
|
||||
assert not client.is_connected()
|
||||
assert client._session_state.session is None
|
||||
|
||||
# During connection
|
||||
async with client:
|
||||
assert client.is_connected()
|
||||
assert client._session_state.session is not None
|
||||
session = client._session_state.session
|
||||
|
||||
# Reuse the same session
|
||||
async with client:
|
||||
assert client.is_connected()
|
||||
assert client._session_state.session is session
|
||||
|
||||
# Reuse the same session
|
||||
async with client:
|
||||
assert client.is_connected()
|
||||
assert client._session_state.session is session
|
||||
|
||||
# After connection
|
||||
assert not client.is_connected()
|
||||
assert client._session_state.session is None
|
||||
|
||||
|
||||
async def test_client_context_entry_cancelled_starter_cleans_up(fastmcp_server):
|
||||
connect_started = anyio.Event()
|
||||
allow_connect = anyio.Event()
|
||||
|
||||
client = Client(
|
||||
transport=_DelayedConnectTransport(
|
||||
FastMCPTransport(fastmcp_server),
|
||||
connect_started=connect_started,
|
||||
allow_connect=allow_connect,
|
||||
)
|
||||
)
|
||||
|
||||
async def enter_and_never_reach_body() -> None:
|
||||
async with client:
|
||||
pytest.fail(
|
||||
"Context body should not be reached when __aenter__ is cancelled"
|
||||
)
|
||||
|
||||
task = asyncio.create_task(enter_and_never_reach_body())
|
||||
await connect_started.wait()
|
||||
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
# Connection startup was cancelled; session state should be fully reset.
|
||||
assert client._session_state.session_task is None
|
||||
assert client._session_state.session is None
|
||||
assert client._session_state.nesting_counter == 0
|
||||
|
||||
# A future connection attempt should work normally.
|
||||
allow_connect.set()
|
||||
async with client:
|
||||
tools = await client.list_tools()
|
||||
assert len(tools) == 3
|
||||
|
||||
|
||||
async def test_cancelled_context_entry_waiter_does_not_close_active_session(
|
||||
fastmcp_server,
|
||||
):
|
||||
connect_started = anyio.Event()
|
||||
allow_connect = anyio.Event()
|
||||
|
||||
client = Client(
|
||||
transport=_DelayedConnectTransport(
|
||||
FastMCPTransport(fastmcp_server),
|
||||
connect_started=connect_started,
|
||||
allow_connect=allow_connect,
|
||||
)
|
||||
)
|
||||
|
||||
b_done = asyncio.Event()
|
||||
b_started = asyncio.Event()
|
||||
|
||||
async def task_a() -> int:
|
||||
async with client:
|
||||
await b_done.wait()
|
||||
tools = await client.list_tools()
|
||||
return len(tools)
|
||||
|
||||
async def task_b() -> None:
|
||||
b_started.set()
|
||||
async with client:
|
||||
pytest.fail("This context should never be entered due to cancellation")
|
||||
|
||||
a = asyncio.create_task(task_a())
|
||||
await connect_started.wait()
|
||||
|
||||
b = asyncio.create_task(task_b())
|
||||
await b_started.wait()
|
||||
await asyncio.sleep(0) # let task_b attempt to acquire the client lock
|
||||
|
||||
b.cancel()
|
||||
allow_connect.set()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await b
|
||||
|
||||
# task_b is fully cancelled; allow task_a to exercise the connected session.
|
||||
b_done.set()
|
||||
assert await a == 3
|
||||
|
||||
|
||||
async def test_concurrent_client_context_managers():
|
||||
"""
|
||||
Test that concurrent client usage doesn't cause cross-task cancel scope issues.
|
||||
https://github.com/jlowin/fastmcp/pull/643
|
||||
"""
|
||||
# Create a simple server
|
||||
server = FastMCP("Test Server")
|
||||
|
||||
@server.tool
|
||||
def echo(text: str) -> str:
|
||||
"""Echo tool"""
|
||||
return text
|
||||
|
||||
# Create client
|
||||
client = Client(server)
|
||||
|
||||
# Track results
|
||||
results = {}
|
||||
errors = []
|
||||
|
||||
async def use_client(task_id: str, delay: float = 0):
|
||||
"""Use the client with a small delay to ensure overlap"""
|
||||
try:
|
||||
async with client:
|
||||
# Add a small delay to ensure contexts overlap
|
||||
await asyncio.sleep(delay)
|
||||
# Make an actual call to exercise the session
|
||||
tools = await client.list_tools()
|
||||
results[task_id] = len(tools)
|
||||
except Exception as e:
|
||||
errors.append((task_id, str(e)))
|
||||
|
||||
# Run multiple tasks concurrently
|
||||
# The key is having them enter and exit the context at different times
|
||||
await asyncio.gather(
|
||||
use_client("task1", 0.0),
|
||||
use_client("task2", 0.01), # Slight delay to ensure overlap
|
||||
use_client("task3", 0.02),
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
assert len(errors) == 0, f"Errors occurred: {errors}"
|
||||
assert len(results) == 3
|
||||
assert all(count == 1 for count in results.values()) # All should see 1 tool
|
||||
|
||||
|
||||
async def test_resource_template(fastmcp_server):
|
||||
"""Test using a resource template with InMemoryClient."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
# First, list templates
|
||||
result = await client.list_resource_templates()
|
||||
|
||||
# Check that our template is available
|
||||
assert len(result) == 1
|
||||
assert "data://user/{user_id}" in result[0].uriTemplate
|
||||
|
||||
# Now use the template with a specific user_id
|
||||
uri = cast(AnyUrl, "data://user/123")
|
||||
result = await client.read_resource(uri)
|
||||
|
||||
# Check the content matches what we expect for the provided user_id
|
||||
content_str = str(result[0])
|
||||
assert '"id":"123"' in content_str
|
||||
assert '"name":"User 123"' in content_str
|
||||
assert '"active":true' in content_str
|
||||
|
||||
|
||||
async def test_list_resource_templates_mcp(fastmcp_server):
|
||||
"""Test the list_resource_templates_mcp method that returns raw MCP protocol objects."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
result = await client.list_resource_templates_mcp()
|
||||
|
||||
# Check that we got the raw MCP ListResourceTemplatesResult object
|
||||
assert hasattr(result, "resourceTemplates")
|
||||
assert len(result.resourceTemplates) == 1
|
||||
assert "data://user/{user_id}" in result.resourceTemplates[0].uriTemplate
|
||||
|
||||
|
||||
async def test_mcp_resource_generation(fastmcp_server):
|
||||
"""Test that resources are properly generated in MCP format."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
resources = await client.list_resources()
|
||||
assert len(resources) == 1
|
||||
resource = resources[0]
|
||||
|
||||
# Verify resource has correct MCP format
|
||||
assert hasattr(resource, "uri")
|
||||
assert hasattr(resource, "name")
|
||||
assert hasattr(resource, "description")
|
||||
assert str(resource.uri) == "data://users"
|
||||
|
||||
|
||||
async def test_mcp_template_generation(fastmcp_server):
|
||||
"""Test that templates are properly generated in MCP format."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
templates = await client.list_resource_templates()
|
||||
assert len(templates) == 1
|
||||
template = templates[0]
|
||||
|
||||
# Verify template has correct MCP format
|
||||
assert hasattr(template, "uriTemplate")
|
||||
assert hasattr(template, "name")
|
||||
assert hasattr(template, "description")
|
||||
assert "data://user/{user_id}" in template.uriTemplate
|
||||
|
||||
|
||||
async def test_template_access_via_client(fastmcp_server):
|
||||
"""Test that templates can be accessed through a client."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
|
||||
async with client:
|
||||
# Verify template works correctly when accessed
|
||||
uri = cast(AnyUrl, "data://user/456")
|
||||
result = await client.read_resource(uri)
|
||||
content_str = str(result[0])
|
||||
assert '"id":"456"' in content_str
|
||||
|
||||
|
||||
async def test_tagged_resource_metadata(tagged_resources_server):
|
||||
"""Test that resource metadata is preserved in MCP format."""
|
||||
client = Client(transport=FastMCPTransport(tagged_resources_server))
|
||||
|
||||
async with client:
|
||||
resources = await client.list_resources()
|
||||
assert len(resources) == 1
|
||||
resource = resources[0]
|
||||
|
||||
# Verify resource metadata is preserved
|
||||
assert str(resource.uri) == "data://tagged"
|
||||
assert resource.description == "A tagged resource"
|
||||
|
||||
|
||||
async def test_tagged_template_metadata(tagged_resources_server):
|
||||
"""Test that template metadata is preserved in MCP format."""
|
||||
client = Client(transport=FastMCPTransport(tagged_resources_server))
|
||||
|
||||
async with client:
|
||||
templates = await client.list_resource_templates()
|
||||
assert len(templates) == 1
|
||||
template = templates[0]
|
||||
|
||||
# Verify template metadata is preserved
|
||||
assert "template://{id}" in template.uriTemplate
|
||||
assert template.description == "A tagged template"
|
||||
|
||||
|
||||
async def test_tagged_template_functionality(tagged_resources_server):
|
||||
"""Test that tagged templates function correctly when accessed."""
|
||||
client = Client(transport=FastMCPTransport(tagged_resources_server))
|
||||
|
||||
async with client:
|
||||
# Verify template functionality
|
||||
uri = cast(AnyUrl, "template://123")
|
||||
result = await client.read_resource(uri)
|
||||
content_str = str(result[0])
|
||||
assert '"id":"123"' in content_str
|
||||
assert '"type":"template_data"' in content_str
|
||||
166
tests/client/client/test_error_handling.py
Normal file
166
tests/client/client/test_error_handling.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Client error handling tests."""
|
||||
|
||||
import pytest
|
||||
from mcp.types import TextContent
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports import FastMCPTransport
|
||||
from fastmcp.exceptions import ResourceError, ToolError
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
async def test_general_tool_exceptions_are_not_masked_by_default(self):
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.tool
|
||||
def error_tool():
|
||||
raise ValueError("This is a test error (abc)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
|
||||
async with client:
|
||||
result = await client.call_tool_mcp("error_tool", {})
|
||||
assert result.isError
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "test error" in result.content[0].text
|
||||
assert "abc" in result.content[0].text
|
||||
|
||||
async def test_general_tool_exceptions_are_masked_when_enabled(self):
|
||||
mcp = FastMCP("TestServer", mask_error_details=True)
|
||||
|
||||
@mcp.tool
|
||||
def error_tool():
|
||||
raise ValueError("This is a test error (abc)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
|
||||
async with client:
|
||||
result = await client.call_tool_mcp("error_tool", {})
|
||||
assert result.isError
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "test error" not in result.content[0].text
|
||||
assert "abc" not in result.content[0].text
|
||||
|
||||
async def test_validation_errors_are_not_masked_when_enabled(self):
|
||||
mcp = FastMCP("TestServer", mask_error_details=True)
|
||||
|
||||
@mcp.tool
|
||||
def validated_tool(x: int) -> int:
|
||||
return x
|
||||
|
||||
async with Client(transport=FastMCPTransport(mcp)) as client:
|
||||
result = await client.call_tool_mcp("validated_tool", {"x": "abc"})
|
||||
assert result.isError
|
||||
# Pydantic validation error message should NOT be masked
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "Input should be a valid integer" in result.content[0].text
|
||||
|
||||
async def test_specific_tool_errors_are_sent_to_client(self):
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.tool
|
||||
def custom_error_tool():
|
||||
raise ToolError("This is a test error (abc)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
|
||||
async with client:
|
||||
result = await client.call_tool_mcp("custom_error_tool", {})
|
||||
assert result.isError
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "test error" in result.content[0].text
|
||||
assert "abc" in result.content[0].text
|
||||
|
||||
async def test_general_resource_exceptions_are_not_masked_by_default(self):
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.resource(uri="exception://resource")
|
||||
async def exception_resource():
|
||||
raise ValueError("This is an internal error (sensitive)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await client.read_resource(AnyUrl("exception://resource"))
|
||||
assert "Error reading resource" in str(excinfo.value)
|
||||
assert "sensitive" in str(excinfo.value)
|
||||
assert "internal error" in str(excinfo.value)
|
||||
|
||||
async def test_general_resource_exceptions_are_masked_when_enabled(self):
|
||||
mcp = FastMCP("TestServer", mask_error_details=True)
|
||||
|
||||
@mcp.resource(uri="exception://resource")
|
||||
async def exception_resource():
|
||||
raise ValueError("This is an internal error (sensitive)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await client.read_resource(AnyUrl("exception://resource"))
|
||||
assert "Error reading resource" in str(excinfo.value)
|
||||
assert "sensitive" not in str(excinfo.value)
|
||||
assert "internal error" not in str(excinfo.value)
|
||||
|
||||
async def test_resource_errors_are_sent_to_client(self):
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.resource(uri="error://resource")
|
||||
async def error_resource():
|
||||
raise ResourceError("This is a resource error (xyz)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await client.read_resource(AnyUrl("error://resource"))
|
||||
assert "This is a resource error (xyz)" in str(excinfo.value)
|
||||
|
||||
async def test_general_template_exceptions_are_not_masked_by_default(self):
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.resource(uri="exception://resource/{id}")
|
||||
async def exception_resource(id: str):
|
||||
raise ValueError("This is an internal error (sensitive)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await client.read_resource(AnyUrl("exception://resource/123"))
|
||||
assert "Error reading resource" in str(excinfo.value)
|
||||
assert "sensitive" in str(excinfo.value)
|
||||
assert "internal error" in str(excinfo.value)
|
||||
|
||||
async def test_general_template_exceptions_are_masked_when_enabled(self):
|
||||
mcp = FastMCP("TestServer", mask_error_details=True)
|
||||
|
||||
@mcp.resource(uri="exception://resource/{id}")
|
||||
async def exception_resource(id: str):
|
||||
raise ValueError("This is an internal error (sensitive)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await client.read_resource(AnyUrl("exception://resource/123"))
|
||||
assert "Error reading resource" in str(excinfo.value)
|
||||
assert "sensitive" not in str(excinfo.value)
|
||||
assert "internal error" not in str(excinfo.value)
|
||||
|
||||
async def test_template_errors_are_sent_to_client(self):
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.resource(uri="error://resource/{id}")
|
||||
async def error_resource(id: str):
|
||||
raise ResourceError("This is a resource error (xyz)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await client.read_resource(AnyUrl("error://resource/123"))
|
||||
assert "This is a resource error (xyz)" in str(excinfo.value)
|
||||
115
tests/client/client/test_initialize.py
Normal file
115
tests/client/client/test_initialize.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Client initialization tests."""
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
class TestInitialize:
|
||||
"""Tests for client initialization behavior."""
|
||||
|
||||
async def test_auto_initialize_default(self, fastmcp_server):
|
||||
"""Test that auto_initialize=True is the default and works automatically."""
|
||||
client = Client(fastmcp_server)
|
||||
|
||||
async with client:
|
||||
# Should be automatically initialized
|
||||
assert client.initialize_result is not None
|
||||
assert client.initialize_result.serverInfo.name == "TestServer"
|
||||
assert client.initialize_result.instructions is None
|
||||
|
||||
async def test_auto_initialize_explicit_true(self, fastmcp_server):
|
||||
"""Test explicit auto_initialize=True."""
|
||||
client = Client(fastmcp_server, auto_initialize=True)
|
||||
|
||||
async with client:
|
||||
assert client.initialize_result is not None
|
||||
assert client.initialize_result.serverInfo.name == "TestServer"
|
||||
|
||||
async def test_auto_initialize_false(self, fastmcp_server):
|
||||
"""Test that auto_initialize=False prevents automatic initialization."""
|
||||
client = Client(fastmcp_server, auto_initialize=False)
|
||||
|
||||
async with client:
|
||||
# Should not be automatically initialized
|
||||
assert client.initialize_result is None
|
||||
|
||||
async def test_manual_initialize(self, fastmcp_server):
|
||||
"""Test manual initialization when auto_initialize=False."""
|
||||
client = Client(fastmcp_server, auto_initialize=False)
|
||||
|
||||
async with client:
|
||||
# Manually initialize
|
||||
result = await client.initialize()
|
||||
|
||||
assert result is not None
|
||||
assert result.serverInfo.name == "TestServer"
|
||||
assert client.initialize_result is result
|
||||
|
||||
async def test_initialize_idempotent(self, fastmcp_server):
|
||||
"""Test that calling initialize() multiple times returns cached result."""
|
||||
client = Client(fastmcp_server, auto_initialize=False)
|
||||
|
||||
async with client:
|
||||
result1 = await client.initialize()
|
||||
result2 = await client.initialize()
|
||||
result3 = await client.initialize()
|
||||
|
||||
# All should return the same cached result
|
||||
assert result1 is result2
|
||||
assert result2 is result3
|
||||
|
||||
async def test_initialize_with_instructions(self):
|
||||
"""Test that server instructions are available via initialize_result."""
|
||||
server = FastMCP("InstructionsServer", instructions="Use the greet tool!")
|
||||
|
||||
@server.tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
client = Client(server)
|
||||
|
||||
async with client:
|
||||
result = client.initialize_result
|
||||
assert result is not None
|
||||
assert result.instructions == "Use the greet tool!"
|
||||
|
||||
async def test_initialize_timeout_custom(self, fastmcp_server):
|
||||
"""Test custom timeout for initialize()."""
|
||||
client = Client(fastmcp_server, auto_initialize=False)
|
||||
|
||||
async with client:
|
||||
# Should succeed with reasonable timeout
|
||||
result = await client.initialize(timeout=5.0)
|
||||
assert result is not None
|
||||
|
||||
async def test_initialize_property_after_auto_init(self, fastmcp_server):
|
||||
"""Test accessing initialize_result property after auto-initialization."""
|
||||
client = Client(fastmcp_server, auto_initialize=True)
|
||||
|
||||
async with client:
|
||||
# Access via property
|
||||
result = client.initialize_result
|
||||
assert result is not None
|
||||
assert result.serverInfo.name == "TestServer"
|
||||
|
||||
# Call method - should return cached
|
||||
result2 = await client.initialize()
|
||||
assert result is result2
|
||||
|
||||
async def test_initialize_property_before_connect(self, fastmcp_server):
|
||||
"""Test that initialize_result property is None before connection."""
|
||||
client = Client(fastmcp_server)
|
||||
|
||||
# Not yet connected
|
||||
assert client.initialize_result is None
|
||||
|
||||
async def test_manual_initialize_can_call_tools(self, fastmcp_server):
|
||||
"""Test that manually initialized client can call tools."""
|
||||
client = Client(fastmcp_server, auto_initialize=False)
|
||||
|
||||
async with client:
|
||||
await client.initialize()
|
||||
|
||||
# Should be able to call tools after manual initialization
|
||||
result = await client.call_tool("greet", {"name": "World"})
|
||||
assert "Hello, World!" in str(result.content)
|
||||
137
tests/client/client/test_session.py
Normal file
137
tests/client/client/test_session.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Client session and task error propagation tests."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp.client import Client
|
||||
|
||||
|
||||
class TestSessionTaskErrorPropagation:
|
||||
"""Tests for ensuring session task errors propagate to client calls.
|
||||
|
||||
Regression tests for https://github.com/jlowin/fastmcp/issues/2595
|
||||
where the client would hang indefinitely when the session task failed
|
||||
(e.g., due to HTTP 4xx/5xx errors) instead of raising an exception.
|
||||
"""
|
||||
|
||||
async def test_session_task_error_propagates_to_call(self, fastmcp_server):
|
||||
"""Test that errors in session task propagate to pending client calls.
|
||||
|
||||
When the session task fails (e.g., due to HTTP errors), pending
|
||||
client operations should immediately receive the exception rather
|
||||
than hanging indefinitely.
|
||||
"""
|
||||
client = Client(fastmcp_server)
|
||||
|
||||
async with client:
|
||||
original_task = client._session_state.session_task
|
||||
assert original_task is not None
|
||||
|
||||
async def never_complete():
|
||||
"""A coroutine that will never complete normally."""
|
||||
await asyncio.sleep(1000)
|
||||
|
||||
async def failing_session():
|
||||
"""Simulates a session task that raises an error."""
|
||||
raise ValueError("Simulated HTTP error")
|
||||
|
||||
# Replace session_task with one that will fail
|
||||
client._session_state.session_task = asyncio.create_task(failing_session())
|
||||
|
||||
# The monitoring should detect the session task failure
|
||||
with pytest.raises(ValueError, match="Simulated HTTP error"):
|
||||
await client._await_with_session_monitoring(never_complete())
|
||||
|
||||
# Restore original task for cleanup
|
||||
client._session_state.session_task = original_task
|
||||
|
||||
async def test_session_task_already_done_with_error(self, fastmcp_server):
|
||||
"""Test that if session task is already done with error, calls fail immediately."""
|
||||
client = Client(fastmcp_server)
|
||||
|
||||
async with client:
|
||||
original_task = client._session_state.session_task
|
||||
|
||||
async def raise_error():
|
||||
raise ValueError("Session failed")
|
||||
|
||||
# Replace session_task with one that has already failed
|
||||
failed_task = asyncio.create_task(raise_error())
|
||||
try:
|
||||
await failed_task
|
||||
except ValueError:
|
||||
pass # Expected
|
||||
client._session_state.session_task = failed_task
|
||||
|
||||
# New calls should fail immediately with the original error
|
||||
async def simple_coro():
|
||||
return "should not reach"
|
||||
|
||||
with pytest.raises(ValueError, match="Session failed"):
|
||||
await client._await_with_session_monitoring(simple_coro())
|
||||
|
||||
# Restore original task for cleanup
|
||||
client._session_state.session_task = original_task
|
||||
|
||||
async def test_session_task_already_done_no_error_raises_runtime_error(
|
||||
self, fastmcp_server
|
||||
):
|
||||
"""Test that if session task completes without error, raises RuntimeError."""
|
||||
client = Client(fastmcp_server)
|
||||
|
||||
async with client:
|
||||
original_task = client._session_state.session_task
|
||||
|
||||
# Create a task that completes normally (unexpected for session task)
|
||||
completed_task = asyncio.create_task(asyncio.sleep(0))
|
||||
await completed_task
|
||||
client._session_state.session_task = completed_task
|
||||
|
||||
async def simple_coro():
|
||||
return "should not reach"
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="Session task completed unexpectedly"
|
||||
):
|
||||
await client._await_with_session_monitoring(simple_coro())
|
||||
|
||||
# Restore original task for cleanup
|
||||
client._session_state.session_task = original_task
|
||||
|
||||
async def test_normal_operation_unaffected(self, fastmcp_server):
|
||||
"""Test that normal operation is unaffected by the monitoring."""
|
||||
client = Client(fastmcp_server)
|
||||
|
||||
async with client:
|
||||
# These should all work normally
|
||||
tools = await client.list_tools()
|
||||
assert len(tools) > 0
|
||||
|
||||
result = await client.call_tool("greet", {"name": "Test"})
|
||||
assert "Hello, Test!" in str(result.content)
|
||||
|
||||
resources = await client.list_resources()
|
||||
assert len(resources) > 0
|
||||
|
||||
prompts = await client.list_prompts()
|
||||
assert len(prompts) > 0
|
||||
|
||||
async def test_no_session_task_falls_back_to_direct_await(self, fastmcp_server):
|
||||
"""Test that when no session task exists, it falls back to direct await."""
|
||||
client = Client(fastmcp_server)
|
||||
|
||||
async with client:
|
||||
# Temporarily remove session_task to test fallback
|
||||
original_task = client._session_state.session_task
|
||||
client._session_state.session_task = None
|
||||
|
||||
# Should work via direct await
|
||||
async def simple_coro():
|
||||
return "success"
|
||||
|
||||
result = await client._await_with_session_monitoring(simple_coro())
|
||||
assert result == "success"
|
||||
|
||||
# Restore for cleanup
|
||||
client._session_state.session_task = original_task
|
||||
50
tests/client/client/test_timeout.py
Normal file
50
tests/client/client/test_timeout.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Client timeout tests."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from mcp import McpError
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports import FastMCPTransport
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
class TestTimeout:
|
||||
async def test_timeout(self, fastmcp_server: FastMCP):
|
||||
async with Client(
|
||||
transport=FastMCPTransport(fastmcp_server), timeout=0.05
|
||||
) as client:
|
||||
with pytest.raises(
|
||||
McpError,
|
||||
match="Timed out while waiting for response to ClientRequest. Waited 0.05 seconds",
|
||||
):
|
||||
await client.call_tool("sleep", {"seconds": 0.1})
|
||||
|
||||
async def test_timeout_tool_call(self, fastmcp_server: FastMCP):
|
||||
async with Client(transport=FastMCPTransport(fastmcp_server)) as client:
|
||||
with pytest.raises(McpError):
|
||||
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
|
||||
|
||||
async def test_timeout_tool_call_overrides_client_timeout(
|
||||
self, fastmcp_server: FastMCP
|
||||
):
|
||||
async with Client(
|
||||
transport=FastMCPTransport(fastmcp_server),
|
||||
timeout=2,
|
||||
) as client:
|
||||
with pytest.raises(McpError):
|
||||
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.",
|
||||
)
|
||||
async def test_timeout_tool_call_overrides_client_timeout_even_if_lower(
|
||||
self, fastmcp_server: FastMCP
|
||||
):
|
||||
async with Client(
|
||||
transport=FastMCPTransport(fastmcp_server),
|
||||
timeout=0.01,
|
||||
) as client:
|
||||
await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
|
||||
137
tests/client/client/test_transport.py
Normal file
137
tests/client/client/test_transport.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Client transport inference tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp.client.transports import (
|
||||
FastMCPTransport,
|
||||
MCPConfigTransport,
|
||||
SSETransport,
|
||||
StdioTransport,
|
||||
StreamableHttpTransport,
|
||||
infer_transport,
|
||||
)
|
||||
|
||||
|
||||
class TestInferTransport:
|
||||
"""Tests for the infer_transport function."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://example.com/api/sse/stream",
|
||||
"https://localhost:8080/mcp/sse/endpoint",
|
||||
"http://example.com/api/sse",
|
||||
"http://example.com/api/sse/",
|
||||
"https://localhost:8080/mcp/sse/",
|
||||
"http://example.com/api/sse?param=value",
|
||||
"https://localhost:8080/mcp/sse/?param=value",
|
||||
"https://localhost:8000/mcp/sse?x=1&y=2",
|
||||
],
|
||||
ids=[
|
||||
"path_with_sse_directory",
|
||||
"path_with_sse_subdirectory",
|
||||
"path_ending_with_sse",
|
||||
"path_ending_with_sse_slash",
|
||||
"path_ending_with_sse_https",
|
||||
"path_with_sse_and_query_params",
|
||||
"path_with_sse_slash_and_query_params",
|
||||
"path_with_sse_and_ampersand_param",
|
||||
],
|
||||
)
|
||||
def test_url_returns_sse_transport(self, url):
|
||||
"""Test that URLs with /sse/ pattern return SSETransport."""
|
||||
assert isinstance(infer_transport(url), SSETransport)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://example.com/api",
|
||||
"https://localhost:8080/mcp/",
|
||||
"http://example.com/asset/image.jpg",
|
||||
"https://localhost:8080/sservice/endpoint",
|
||||
"https://example.com/assets/file",
|
||||
],
|
||||
ids=[
|
||||
"regular_http_url",
|
||||
"regular_https_url",
|
||||
"url_with_unrelated_path",
|
||||
"url_with_sservice_in_path",
|
||||
"url_with_assets_in_path",
|
||||
],
|
||||
)
|
||||
def test_url_returns_streamable_http_transport(self, url):
|
||||
"""Test that URLs without /sse/ pattern return StreamableHttpTransport."""
|
||||
assert isinstance(infer_transport(url), StreamableHttpTransport)
|
||||
|
||||
def test_infer_remote_transport_from_config(self):
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"test_server": {
|
||||
"url": "http://localhost:8000/sse/",
|
||||
"headers": {"Authorization": "Bearer 123"},
|
||||
},
|
||||
}
|
||||
}
|
||||
transport = infer_transport(config)
|
||||
assert isinstance(transport, MCPConfigTransport)
|
||||
assert isinstance(transport.transport, SSETransport)
|
||||
assert transport.transport.url == "http://localhost:8000/sse/"
|
||||
assert transport.transport.headers == {"Authorization": "Bearer 123"}
|
||||
|
||||
def test_infer_local_transport_from_config(self):
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"test_server": {
|
||||
"command": "echo",
|
||||
"args": ["hello"],
|
||||
},
|
||||
}
|
||||
}
|
||||
transport = infer_transport(config)
|
||||
assert isinstance(transport, MCPConfigTransport)
|
||||
assert isinstance(transport.transport, StdioTransport)
|
||||
assert transport.transport.command == "echo"
|
||||
assert transport.transport.args == ["hello"]
|
||||
|
||||
def test_config_with_no_servers(self):
|
||||
"""Test that an empty MCPConfig raises a ValueError."""
|
||||
config = {"mcpServers": {}}
|
||||
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
|
||||
infer_transport(config)
|
||||
|
||||
def test_mcpconfigtransport_with_no_servers(self):
|
||||
"""Test that MCPConfigTransport raises a ValueError when initialized with an empty config."""
|
||||
config = {"mcpServers": {}}
|
||||
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
|
||||
MCPConfigTransport(config=config)
|
||||
|
||||
def test_infer_composite_client(self):
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"local": {
|
||||
"command": "echo",
|
||||
"args": ["hello"],
|
||||
},
|
||||
"remote": {
|
||||
"url": "http://localhost:8000/sse/",
|
||||
"headers": {"Authorization": "Bearer 123"},
|
||||
},
|
||||
}
|
||||
}
|
||||
transport = infer_transport(config)
|
||||
assert isinstance(transport, MCPConfigTransport)
|
||||
# Multi-server configs create composite server at connect time
|
||||
assert len(transport.config.mcpServers) == 2
|
||||
|
||||
def test_infer_fastmcp_server(self, fastmcp_server):
|
||||
"""FastMCP server instances should infer to FastMCPTransport."""
|
||||
transport = infer_transport(fastmcp_server)
|
||||
assert isinstance(transport, FastMCPTransport)
|
||||
|
||||
def test_infer_fastmcp_v1_server(self):
|
||||
"""FastMCP 1.0 server instances should infer to FastMCPTransport."""
|
||||
from mcp.server.fastmcp import FastMCP as FastMCP1
|
||||
|
||||
server = FastMCP1()
|
||||
transport = infer_transport(server)
|
||||
assert isinstance(transport, FastMCPTransport)
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""Shared fixtures for client task tests."""
|
||||
|
||||
# Task protocol is now always enabled - no fixture needed
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
from collections.abc import Callable, Generator
|
||||
|
|
@ -35,6 +36,24 @@ def import_rich_rule():
|
|||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_fastmcp_logger_propagation(caplog):
|
||||
"""Enable propagation on FastMCP root logger so caplog captures FastMCP log messages.
|
||||
|
||||
FastMCP loggers have propagate=False by default, which prevents messages from
|
||||
reaching pytest's caplog handler (attached to root logger). This fixture
|
||||
temporarily enables propagation on the FastMCP root logger so FastMCP logs
|
||||
are captured in tests.
|
||||
"""
|
||||
root_logger = logging.getLogger("fastmcp")
|
||||
original_propagate = root_logger.propagate
|
||||
root_logger.propagate = True
|
||||
|
||||
yield
|
||||
|
||||
root_logger.propagate = original_propagate
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_settings_home(tmp_path: Path):
|
||||
"""Ensure each test uses an isolated settings.home directory.
|
||||
|
|
@ -111,3 +130,172 @@ def trace_exporter(
|
|||
exporter.clear()
|
||||
yield exporter
|
||||
exporter.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fastmcp_server():
|
||||
"""Fixture that creates a FastMCP server with tools, resources, and prompts."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
server = FastMCP("TestServer")
|
||||
|
||||
# Add a tool
|
||||
@server.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# Add a second tool
|
||||
@server.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers together."""
|
||||
return a + b
|
||||
|
||||
@server.tool
|
||||
async def sleep(seconds: float) -> str:
|
||||
"""Sleep for a given number of seconds."""
|
||||
await asyncio.sleep(seconds)
|
||||
return f"Slept for {seconds} seconds"
|
||||
|
||||
# Add a resource (return JSON string for proper typing)
|
||||
@server.resource(uri="data://users")
|
||||
async def get_users() -> str:
|
||||
return json.dumps(["Alice", "Bob", "Charlie"], separators=(",", ":"))
|
||||
|
||||
# Add a resource template (return JSON string for proper typing)
|
||||
@server.resource(uri="data://user/{user_id}")
|
||||
async def get_user(user_id: str) -> str:
|
||||
return json.dumps(
|
||||
{"id": user_id, "name": f"User {user_id}", "active": True},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
# Add a prompt
|
||||
@server.prompt
|
||||
def welcome(name: str) -> str:
|
||||
"""Example greeting prompt."""
|
||||
return f"Welcome to FastMCP, {name}!"
|
||||
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_server():
|
||||
"""Fixture that creates a FastMCP server with comprehensive tool set for provider tests."""
|
||||
import base64
|
||||
|
||||
from mcp.types import (
|
||||
BlobResourceContents,
|
||||
EmbeddedResource,
|
||||
ImageContent,
|
||||
TextContent,
|
||||
)
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.types import Audio, File, Image
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
@mcp.tool
|
||||
def list_tool() -> list[str | int]:
|
||||
return ["x", 2]
|
||||
|
||||
@mcp.tool
|
||||
def error_tool() -> None:
|
||||
raise ValueError("Test error")
|
||||
|
||||
@mcp.tool
|
||||
def image_tool(path: str) -> Image:
|
||||
return Image(path)
|
||||
|
||||
@mcp.tool
|
||||
def audio_tool(path: str) -> Audio:
|
||||
return Audio(path)
|
||||
|
||||
@mcp.tool
|
||||
def file_tool(path: str) -> File:
|
||||
return File(path)
|
||||
|
||||
@mcp.tool
|
||||
def mixed_content_tool() -> list[TextContent | ImageContent | EmbeddedResource]:
|
||||
return [
|
||||
TextContent(type="text", text="Hello"),
|
||||
ImageContent(type="image", data="abc", mimeType="application/octet-stream"),
|
||||
EmbeddedResource(
|
||||
type="resource",
|
||||
resource=BlobResourceContents(
|
||||
blob=base64.b64encode(b"abc").decode(),
|
||||
mimeType="application/octet-stream",
|
||||
uri=AnyUrl("file:///test.bin"),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.tool(output_schema=None)
|
||||
def mixed_list_fn(image_path: str) -> list:
|
||||
return [
|
||||
"text message",
|
||||
Image(image_path),
|
||||
{"key": "value"},
|
||||
TextContent(type="text", text="direct content"),
|
||||
]
|
||||
|
||||
@mcp.tool(output_schema=None)
|
||||
def mixed_audio_list_fn(audio_path: str) -> list:
|
||||
return [
|
||||
"text message",
|
||||
Audio(audio_path),
|
||||
{"key": "value"},
|
||||
TextContent(type="text", text="direct content"),
|
||||
]
|
||||
|
||||
@mcp.tool(output_schema=None)
|
||||
def mixed_file_list_fn(file_path: str) -> list:
|
||||
return [
|
||||
"text message",
|
||||
File(file_path),
|
||||
{"key": "value"},
|
||||
TextContent(type="text", text="direct content"),
|
||||
]
|
||||
|
||||
@mcp.tool
|
||||
def file_text_tool() -> File:
|
||||
return File(data=b"hello world", format="plain")
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tagged_resources_server():
|
||||
"""Fixture that creates a FastMCP server with tagged resources and templates."""
|
||||
import json
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
server = FastMCP("TaggedResourcesServer")
|
||||
|
||||
# Add a resource with tags
|
||||
@server.resource(
|
||||
uri="data://tagged", tags={"test", "metadata"}, description="A tagged resource"
|
||||
)
|
||||
async def get_tagged_data() -> str:
|
||||
return json.dumps({"type": "tagged_data"}, separators=(",", ":"))
|
||||
|
||||
# Add a resource template with tags
|
||||
@server.resource(
|
||||
uri="template://{id}",
|
||||
tags={"template", "parameterized"},
|
||||
description="A tagged template",
|
||||
)
|
||||
async def get_template_data(id: str) -> str:
|
||||
return json.dumps({"id": id, "type": "template_data"}, separators=(",", ":"))
|
||||
|
||||
return server
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from fastmcp.contrib.mcp_mixin import mcp_tool
|
|||
from fastmcp.server.providers import LocalProvider
|
||||
from fastmcp.tools.tool import Tool, _convert_to_content
|
||||
from fastmcp.tools.tool_transform import TransformedTool
|
||||
from fastmcp.utilities.tests import caplog_for_fastmcp, temporary_settings
|
||||
from fastmcp.utilities.tests import temporary_settings
|
||||
|
||||
# Reset deprecation warnings for this module
|
||||
pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
|
||||
|
|
@ -60,10 +60,7 @@ class TestToolSerializerDeprecated:
|
|||
def custom_serializer_that_fails(data):
|
||||
raise ValueError("Serialization failed")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = _convert_to_content(
|
||||
{"a": 1}, serializer=custom_serializer_that_fails
|
||||
)
|
||||
result = _convert_to_content({"a": 1}, serializer=custom_serializer_that_fails)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert result == snapshot([TextContent(type="text", text='{"a":1}')])
|
||||
|
|
|
|||
0
tests/server/auth/oauth_proxy/__init__.py
Normal file
0
tests/server/auth/oauth_proxy/__init__.py
Normal file
309
tests/server/auth/oauth_proxy/conftest.py
Normal file
309
tests/server/auth/oauth_proxy/conftest.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"""Shared fixtures and helpers for OAuth proxy tests."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import time
|
||||
from unittest.mock import Mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from fastmcp.server.auth.auth import TokenVerifier
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
|
||||
|
||||
class MockOAuthProvider:
|
||||
"""Mock OAuth provider for testing OAuth proxy E2E flows.
|
||||
|
||||
This provider simulates a complete OAuth server without requiring:
|
||||
- Real authentication credentials
|
||||
- Browser automation
|
||||
- Network calls to external services
|
||||
"""
|
||||
|
||||
def __init__(self, port: int = 0):
|
||||
self.port = port
|
||||
self.base_url = f"http://localhost:{port}"
|
||||
self.app = None
|
||||
self.server = None
|
||||
|
||||
# Storage for OAuth state
|
||||
self.authorization_codes = {}
|
||||
self.access_tokens = {}
|
||||
self.refresh_tokens = {}
|
||||
self.revoked_tokens = set()
|
||||
|
||||
# Tracking for assertions
|
||||
self.authorize_called = False
|
||||
self.token_called = False
|
||||
self.refresh_called = False
|
||||
self.revoke_called = False
|
||||
|
||||
# Configuration
|
||||
self.require_pkce = False
|
||||
self.token_endpoint_auth_method = "client_secret_basic"
|
||||
|
||||
@property
|
||||
def authorize_endpoint(self) -> str:
|
||||
return f"{self.base_url}/authorize"
|
||||
|
||||
@property
|
||||
def token_endpoint(self) -> str:
|
||||
return f"{self.base_url}/token"
|
||||
|
||||
@property
|
||||
def revocation_endpoint(self) -> str:
|
||||
return f"{self.base_url}/revoke"
|
||||
|
||||
def create_app(self) -> Starlette:
|
||||
"""Create the mock OAuth server application."""
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/authorize", self.handle_authorize),
|
||||
Route("/token", self.handle_token, methods=["POST"]),
|
||||
Route("/revoke", self.handle_revoke, methods=["POST"]),
|
||||
]
|
||||
)
|
||||
|
||||
async def handle_authorize(self, request):
|
||||
"""Handle authorization requests."""
|
||||
self.authorize_called = True
|
||||
query = dict(request.query_params)
|
||||
|
||||
# Validate PKCE if required
|
||||
if self.require_pkce and "code_challenge" not in query:
|
||||
return JSONResponse(
|
||||
{"error": "invalid_request", "error_description": "PKCE required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Generate authorization code
|
||||
code = secrets.token_urlsafe(32)
|
||||
self.authorization_codes[code] = {
|
||||
"client_id": query.get("client_id"),
|
||||
"redirect_uri": query.get("redirect_uri"),
|
||||
"state": query.get("state"),
|
||||
"code_challenge": query.get("code_challenge"),
|
||||
"code_challenge_method": query.get("code_challenge_method", "S256"),
|
||||
"scope": query.get("scope"),
|
||||
"created_at": time.time(),
|
||||
}
|
||||
|
||||
# Redirect back to callback
|
||||
redirect_uri = query["redirect_uri"]
|
||||
params = {"code": code}
|
||||
if query.get("state"):
|
||||
params["state"] = query["state"]
|
||||
|
||||
redirect_url = f"{redirect_uri}?{urlencode(params)}"
|
||||
return JSONResponse(
|
||||
content={}, status_code=302, headers={"Location": redirect_url}
|
||||
)
|
||||
|
||||
async def handle_token(self, request):
|
||||
"""Handle token requests."""
|
||||
self.token_called = True
|
||||
form = await request.form()
|
||||
grant_type = form.get("grant_type")
|
||||
|
||||
if grant_type == "authorization_code":
|
||||
code = form.get("code")
|
||||
if code not in self.authorization_codes:
|
||||
return JSONResponse(
|
||||
{"error": "invalid_grant", "error_description": "Invalid code"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate PKCE if it was used
|
||||
auth_data = self.authorization_codes[code]
|
||||
if auth_data.get("code_challenge"):
|
||||
verifier = form.get("code_verifier")
|
||||
if not verifier:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing code_verifier",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
# In a real implementation, we'd validate the verifier
|
||||
|
||||
# Generate tokens
|
||||
access_token = f"mock_access_{secrets.token_hex(16)}"
|
||||
refresh_token = f"mock_refresh_{secrets.token_hex(16)}"
|
||||
|
||||
self.access_tokens[access_token] = {
|
||||
"client_id": auth_data["client_id"],
|
||||
"scope": auth_data.get("scope"),
|
||||
"expires_at": time.time() + 3600,
|
||||
}
|
||||
self.refresh_tokens[refresh_token] = {
|
||||
"client_id": auth_data["client_id"],
|
||||
"scope": auth_data.get("scope"),
|
||||
}
|
||||
|
||||
# Clean up used code
|
||||
del self.authorization_codes[code]
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": refresh_token,
|
||||
"scope": auth_data.get("scope"),
|
||||
}
|
||||
)
|
||||
|
||||
elif grant_type == "refresh_token":
|
||||
self.refresh_called = True
|
||||
refresh_token = form.get("refresh_token")
|
||||
|
||||
if refresh_token not in self.refresh_tokens:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Invalid refresh token",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Generate new access token
|
||||
new_access = f"mock_access_{secrets.token_hex(16)}"
|
||||
token_data = self.refresh_tokens[refresh_token]
|
||||
|
||||
self.access_tokens[new_access] = {
|
||||
"client_id": token_data["client_id"],
|
||||
"scope": token_data.get("scope"),
|
||||
"expires_at": time.time() + 3600,
|
||||
}
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"access_token": new_access,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": refresh_token, # Same refresh token
|
||||
"scope": token_data.get("scope"),
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse({"error": "unsupported_grant_type"}, status_code=400)
|
||||
|
||||
async def handle_revoke(self, request):
|
||||
"""Handle token revocation."""
|
||||
self.revoke_called = True
|
||||
form = await request.form()
|
||||
token = form.get("token")
|
||||
|
||||
if token:
|
||||
self.revoked_tokens.add(token)
|
||||
# Remove from active tokens
|
||||
self.access_tokens.pop(token, None)
|
||||
self.refresh_tokens.pop(token, None)
|
||||
|
||||
return JSONResponse({})
|
||||
|
||||
async def start(self):
|
||||
"""Start the mock OAuth server."""
|
||||
import socket
|
||||
|
||||
from uvicorn import Config, Server
|
||||
|
||||
self.app = self.create_app()
|
||||
|
||||
# If port is 0, find an available port
|
||||
if self.port == 0:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
s.listen(1)
|
||||
self.port = s.getsockname()[1]
|
||||
|
||||
self.base_url = f"http://localhost:{self.port}"
|
||||
config = Config(
|
||||
self.app,
|
||||
host="localhost",
|
||||
port=self.port,
|
||||
log_level="error",
|
||||
ws="websockets-sansio",
|
||||
)
|
||||
self.server = Server(config)
|
||||
|
||||
# Start server in background
|
||||
asyncio.create_task(self.server.serve())
|
||||
|
||||
# Wait for server to be ready
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the mock OAuth server."""
|
||||
if self.server:
|
||||
self.server.should_exit = True
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
def reset(self):
|
||||
"""Reset all state for next test."""
|
||||
self.authorization_codes.clear()
|
||||
self.access_tokens.clear()
|
||||
self.refresh_tokens.clear()
|
||||
self.revoked_tokens.clear()
|
||||
self.authorize_called = False
|
||||
self.token_called = False
|
||||
self.refresh_called = False
|
||||
self.revoke_called = False
|
||||
|
||||
|
||||
class MockTokenVerifier(TokenVerifier):
|
||||
"""Mock token verifier for testing."""
|
||||
|
||||
def __init__(self, required_scopes=None):
|
||||
self.required_scopes = required_scopes or ["read", "write"]
|
||||
self.verify_called = False
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override]
|
||||
"""Mock token verification."""
|
||||
self.verify_called = True
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="mock-client",
|
||||
scopes=self.required_scopes,
|
||||
expires_at=int(time.time() + 3600),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_verifier():
|
||||
"""Create a mock JWT verifier for testing."""
|
||||
verifier = Mock(spec=JWTVerifier)
|
||||
verifier.required_scopes = ["read", "write"]
|
||||
verifier.verify_token = Mock(return_value=None)
|
||||
return verifier
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_proxy(jwt_verifier):
|
||||
"""Create a standard OAuthProxy instance for testing."""
|
||||
return OAuthProxy(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id="test-client-id",
|
||||
upstream_client_secret="test-client-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://myserver.com",
|
||||
redirect_path="/auth/callback",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_oauth_provider():
|
||||
"""Create and start a mock OAuth provider."""
|
||||
provider = MockOAuthProvider()
|
||||
await provider.start()
|
||||
yield provider
|
||||
await provider.stop()
|
||||
196
tests/server/auth/oauth_proxy/test_authorization.py
Normal file
196
tests/server/auth/oauth_proxy/test_authorization.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Tests for OAuth proxy authorization flow."""
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from mcp.server.auth.provider import AuthorizationParams
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
|
||||
|
||||
class TestOAuthProxyAuthorization:
|
||||
"""Tests for OAuth proxy authorization flow."""
|
||||
|
||||
async def test_authorize_creates_transaction(self, oauth_proxy):
|
||||
"""Test that authorize creates transaction and redirects to consent."""
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:54321/callback")],
|
||||
jwt_signing_key="test-secret", # type: ignore[call-arg] # Optional field in MCP SDK
|
||||
)
|
||||
|
||||
# Register client first (required for consent flow)
|
||||
await oauth_proxy.register_client(client)
|
||||
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:54321/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state-123",
|
||||
code_challenge="challenge-abc",
|
||||
scopes=["read", "write"],
|
||||
)
|
||||
|
||||
redirect_url = await oauth_proxy.authorize(client, params)
|
||||
|
||||
# Parse the redirect URL
|
||||
parsed = urlparse(redirect_url)
|
||||
query_params = parse_qs(parsed.query)
|
||||
|
||||
# Should redirect to consent page
|
||||
assert "/consent" in redirect_url
|
||||
assert "txn_id" in query_params
|
||||
|
||||
# Verify transaction was stored with correct data
|
||||
txn_id = query_params["txn_id"][0]
|
||||
transaction = await oauth_proxy._transaction_store.get(key=txn_id)
|
||||
assert transaction is not None
|
||||
assert transaction.client_id == "test-client"
|
||||
assert transaction.code_challenge == "challenge-abc"
|
||||
assert transaction.client_state == "client-state-123"
|
||||
assert transaction.scopes == ["read", "write"]
|
||||
|
||||
|
||||
class TestOAuthProxyPKCE:
|
||||
"""Tests for OAuth proxy PKCE forwarding."""
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_with_pkce(self, jwt_verifier):
|
||||
return OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="upstream-client",
|
||||
upstream_client_secret="upstream-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
forward_pkce=True,
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_without_pkce(self, jwt_verifier):
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
|
||||
return OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="upstream-client",
|
||||
upstream_client_secret="upstream-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
forward_pkce=False,
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
async def test_pkce_forwarding_enabled(self, proxy_with_pkce):
|
||||
"""Test that proxy generates and forwards its own PKCE."""
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
# Register client first
|
||||
await proxy_with_pkce.register_client(client)
|
||||
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="client_challenge",
|
||||
scopes=["read"],
|
||||
)
|
||||
|
||||
redirect_url = await proxy_with_pkce.authorize(client, params)
|
||||
query_params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
# Should redirect to consent page
|
||||
assert "/consent" in redirect_url
|
||||
assert "txn_id" in query_params
|
||||
|
||||
# Transaction should store both challenges
|
||||
txn_id = query_params["txn_id"][0]
|
||||
transaction = await proxy_with_pkce._transaction_store.get(key=txn_id)
|
||||
assert transaction is not None
|
||||
assert transaction.code_challenge == "client_challenge" # Client's
|
||||
assert transaction.proxy_code_verifier is not None # Proxy's verifier
|
||||
# Proxy code challenge is computed from verifier when building upstream URL
|
||||
# Just verify the verifier exists and is different from client's challenge
|
||||
assert len(transaction.proxy_code_verifier) > 0
|
||||
|
||||
async def test_pkce_forwarding_disabled(self, proxy_without_pkce):
|
||||
"""Test that PKCE is not forwarded when disabled."""
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
# Register client first
|
||||
await proxy_without_pkce.register_client(client)
|
||||
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="client_challenge",
|
||||
scopes=["read"],
|
||||
)
|
||||
|
||||
redirect_url = await proxy_without_pkce.authorize(client, params)
|
||||
query_params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
# Should redirect to consent page
|
||||
assert "/consent" in redirect_url
|
||||
assert "txn_id" in query_params
|
||||
|
||||
# Client's challenge still stored, but no proxy PKCE
|
||||
txn_id = query_params["txn_id"][0]
|
||||
transaction = await proxy_without_pkce._transaction_store.get(key=txn_id)
|
||||
assert transaction is not None
|
||||
assert transaction.code_challenge == "client_challenge"
|
||||
assert transaction.proxy_code_verifier is None # No proxy PKCE when disabled
|
||||
|
||||
|
||||
class TestParameterForwarding:
|
||||
"""Tests for parameter forwarding in OAuth proxy."""
|
||||
|
||||
async def test_extra_authorize_params_forwarded(self, jwt_verifier):
|
||||
"""Test that extra authorize parameters are forwarded to upstream."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="upstream-client",
|
||||
upstream_client_secret="upstream-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret",
|
||||
extra_authorize_params={
|
||||
"audience": "https://api.example.com",
|
||||
"prompt": "consent",
|
||||
"max_age": "3600",
|
||||
},
|
||||
)
|
||||
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
await proxy.register_client(client)
|
||||
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="challenge",
|
||||
scopes=["read"],
|
||||
# No resource parameter
|
||||
)
|
||||
|
||||
# Should succeed (no resource check needed)
|
||||
redirect_url = await proxy.authorize(client, params)
|
||||
assert "/consent" in redirect_url
|
||||
43
tests/server/auth/oauth_proxy/test_client_registration.py
Normal file
43
tests/server/auth/oauth_proxy/test_client_registration.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Tests for OAuth proxy client registration (DCR)."""
|
||||
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
from pydantic import AnyUrl
|
||||
|
||||
|
||||
class TestOAuthProxyClientRegistration:
|
||||
"""Tests for OAuth proxy client registration (DCR)."""
|
||||
|
||||
async def test_register_client(self, oauth_proxy):
|
||||
"""Test client registration creates ProxyDCRClient."""
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id="original-client",
|
||||
client_secret="original-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
await oauth_proxy.register_client(client_info)
|
||||
|
||||
# Client should be retrievable with original credentials
|
||||
stored = await oauth_proxy.get_client("original-client")
|
||||
assert stored is not None
|
||||
assert stored.client_id == "original-client"
|
||||
# Proxy uses token_endpoint_auth_method="none", so client_secret is not stored
|
||||
assert stored.client_secret is None
|
||||
|
||||
async def test_get_registered_client(self, oauth_proxy):
|
||||
"""Test retrieving a registered client."""
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:8080/callback")],
|
||||
)
|
||||
await oauth_proxy.register_client(client_info)
|
||||
|
||||
retrieved = await oauth_proxy.get_client("test-client")
|
||||
assert retrieved is not None
|
||||
assert retrieved.client_id == "test-client"
|
||||
|
||||
async def test_get_unregistered_client_returns_none(self, oauth_proxy):
|
||||
"""Test that unregistered clients return None."""
|
||||
client = await oauth_proxy.get_client("unknown-client")
|
||||
assert client is None
|
||||
210
tests/server/auth/oauth_proxy/test_config.py
Normal file
210
tests/server/auth/oauth_proxy/test_config.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""Tests for OAuth proxy configuration and validation."""
|
||||
|
||||
import pytest
|
||||
from mcp.server.auth.provider import AuthorizationParams, AuthorizeError
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
|
||||
|
||||
class TestResourceURLValidation:
|
||||
"""Tests for OAuth Proxy resource URL validation (GHSA-5h2m-4q8j-pqpj fix)."""
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_with_resource_url(self, jwt_verifier):
|
||||
"""Create an OAuthProxy with set_mcp_path called."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="upstream-client",
|
||||
upstream_client_secret="upstream-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
# Use non-default path to prove fix isn't relying on old hardcoded /mcp
|
||||
proxy.set_mcp_path("/api/v2/mcp")
|
||||
return proxy
|
||||
|
||||
async def test_authorize_rejects_mismatched_resource(self, proxy_with_resource_url):
|
||||
"""Test that authorization rejects requests with mismatched resource."""
|
||||
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
await proxy_with_resource_url.register_client(client)
|
||||
|
||||
# Client requests a different resource than the server's
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="challenge",
|
||||
scopes=["read"],
|
||||
resource="https://malicious-server.com/mcp", # Wrong resource
|
||||
)
|
||||
|
||||
with pytest.raises(AuthorizeError) as exc_info:
|
||||
await proxy_with_resource_url.authorize(client, params)
|
||||
|
||||
assert exc_info.value.error == "invalid_target"
|
||||
assert "Resource does not match" in exc_info.value.error_description
|
||||
|
||||
async def test_authorize_accepts_matching_resource(self, proxy_with_resource_url):
|
||||
"""Test that authorization accepts requests with matching resource."""
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
await proxy_with_resource_url.register_client(client)
|
||||
|
||||
# Client requests the correct resource (must match /api/v2/mcp path)
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="challenge",
|
||||
scopes=["read"],
|
||||
resource="https://proxy.example.com/api/v2/mcp", # Correct resource
|
||||
)
|
||||
|
||||
# Should succeed (redirect to consent page)
|
||||
redirect_url = await proxy_with_resource_url.authorize(client, params)
|
||||
assert "/consent" in redirect_url
|
||||
|
||||
async def test_authorize_rejects_old_hardcoded_mcp_path(
|
||||
self, proxy_with_resource_url
|
||||
):
|
||||
"""Test that old hardcoded /mcp path is rejected when server uses different path."""
|
||||
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
await proxy_with_resource_url.register_client(client)
|
||||
|
||||
# Client requests the old hardcoded /mcp path (would have worked before fix)
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="challenge",
|
||||
scopes=["read"],
|
||||
resource="https://proxy.example.com/mcp", # Old hardcoded path
|
||||
)
|
||||
|
||||
# Should fail because server is at /api/v2/mcp, not /mcp
|
||||
with pytest.raises(AuthorizeError) as exc_info:
|
||||
await proxy_with_resource_url.authorize(client, params)
|
||||
|
||||
assert exc_info.value.error == "invalid_target"
|
||||
|
||||
async def test_authorize_accepts_no_resource(self, proxy_with_resource_url):
|
||||
"""Test that authorization accepts requests without resource parameter."""
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
await proxy_with_resource_url.register_client(client)
|
||||
|
||||
# Client doesn't specify resource
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="challenge",
|
||||
scopes=["read"],
|
||||
# No resource parameter
|
||||
)
|
||||
|
||||
# Should succeed (no resource check needed)
|
||||
redirect_url = await proxy_with_resource_url.authorize(client, params)
|
||||
assert "/consent" in redirect_url
|
||||
|
||||
def test_set_mcp_path_creates_jwt_issuer_with_correct_audience(self, jwt_verifier):
|
||||
"""Test that set_mcp_path creates JWTIssuer with correct audience."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="upstream-client",
|
||||
upstream_client_secret="upstream-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
# Before set_mcp_path, _jwt_issuer is None
|
||||
assert proxy._jwt_issuer is None
|
||||
|
||||
# Call set_mcp_path with custom path
|
||||
proxy.set_mcp_path("/custom/mcp")
|
||||
|
||||
# After set_mcp_path, _jwt_issuer should be created
|
||||
assert proxy._jwt_issuer is not None
|
||||
assert proxy.jwt_issuer.audience == "https://proxy.example.com/custom/mcp"
|
||||
assert proxy.jwt_issuer.issuer == "https://proxy.example.com/"
|
||||
|
||||
def test_set_mcp_path_uses_base_url_if_no_path(self, jwt_verifier):
|
||||
"""Test that set_mcp_path uses base_url as audience if no path provided."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="upstream-client",
|
||||
upstream_client_secret="upstream-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
proxy.set_mcp_path(None)
|
||||
|
||||
assert proxy.jwt_issuer.audience == "https://proxy.example.com/"
|
||||
|
||||
def test_jwt_issuer_property_raises_if_not_initialized(self, jwt_verifier):
|
||||
"""Test that jwt_issuer property raises if set_mcp_path not called."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="upstream-client",
|
||||
upstream_client_secret="upstream-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
_ = proxy.jwt_issuer
|
||||
|
||||
assert "JWT issuer not initialized" in str(exc_info.value)
|
||||
|
||||
def test_get_routes_calls_set_mcp_path(self, jwt_verifier):
|
||||
"""Test that get_routes() calls set_mcp_path() to initialize JWT issuer."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="upstream-client",
|
||||
upstream_client_secret="upstream-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
# Before get_routes, _jwt_issuer is None
|
||||
assert proxy._jwt_issuer is None
|
||||
|
||||
# get_routes should call set_mcp_path internally
|
||||
proxy.get_routes("/api/mcp")
|
||||
|
||||
# After get_routes, _jwt_issuer should be created with correct audience
|
||||
assert proxy._jwt_issuer is not None
|
||||
assert proxy.jwt_issuer.audience == "https://proxy.example.com/api/mcp"
|
||||
240
tests/server/auth/oauth_proxy/test_e2e.py
Normal file
240
tests/server/auth/oauth_proxy/test_e2e.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""End-to-end tests for OAuth proxy using mock provider."""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from mcp.server.auth.provider import AuthorizationCode, AuthorizationParams
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.auth import RefreshToken
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.server.auth.oauth_proxy.models import ClientCode
|
||||
from tests.server.auth.oauth_proxy.conftest import MockTokenVerifier
|
||||
|
||||
|
||||
class TestOAuthProxyE2E:
|
||||
"""End-to-end tests using mock OAuth provider."""
|
||||
|
||||
async def test_full_oauth_flow_with_mock_provider(self, mock_oauth_provider):
|
||||
"""Test complete OAuth flow with mock provider."""
|
||||
# Create proxy pointing to mock provider
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint=mock_oauth_provider.authorize_endpoint,
|
||||
upstream_token_endpoint=mock_oauth_provider.token_endpoint,
|
||||
upstream_client_id="mock-client",
|
||||
upstream_client_secret="mock-secret",
|
||||
token_verifier=MockTokenVerifier(),
|
||||
base_url="http://localhost:8000",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
# Create FastMCP server with proxy
|
||||
server = FastMCP("Test Server", auth=proxy)
|
||||
|
||||
@server.tool
|
||||
def protected_tool() -> str:
|
||||
return "Protected data"
|
||||
|
||||
# Start authorization flow
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
# Register client first
|
||||
await proxy.register_client(client_info)
|
||||
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="", # Empty string for no PKCE
|
||||
scopes=["read"],
|
||||
)
|
||||
|
||||
# Get authorization URL (now returns consent redirect)
|
||||
auth_url = await proxy.authorize(client_info, params)
|
||||
|
||||
# Should redirect to consent page
|
||||
assert "/consent" in auth_url
|
||||
query_params = parse_qs(urlparse(auth_url).query)
|
||||
assert "txn_id" in query_params
|
||||
|
||||
# Verify transaction was created with correct configuration
|
||||
txn_id = query_params["txn_id"][0]
|
||||
transaction = await proxy._transaction_store.get(key=txn_id)
|
||||
assert transaction is not None
|
||||
assert transaction.client_id == "test-client"
|
||||
assert transaction.scopes == ["read"]
|
||||
# Transaction ID itself is used as upstream state parameter
|
||||
assert transaction.txn_id == txn_id
|
||||
|
||||
async def test_token_refresh_with_mock_provider(self, mock_oauth_provider):
|
||||
"""Test token refresh flow with mock provider."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint=mock_oauth_provider.authorize_endpoint,
|
||||
upstream_token_endpoint=mock_oauth_provider.token_endpoint,
|
||||
upstream_client_id="mock-client",
|
||||
upstream_client_secret="mock-secret",
|
||||
token_verifier=MockTokenVerifier(),
|
||||
base_url="http://localhost:8000",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
# Initialize JWT issuer before token operations
|
||||
proxy.set_mcp_path("/mcp")
|
||||
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
# Register client first
|
||||
await proxy.register_client(client)
|
||||
|
||||
# Set up initial upstream tokens in mock provider
|
||||
upstream_refresh_token = "mock_refresh_initial"
|
||||
mock_oauth_provider.refresh_tokens[upstream_refresh_token] = {
|
||||
"client_id": "mock-client",
|
||||
"scope": "read write",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"fastmcp.server.auth.oauth_proxy.proxy.AsyncOAuth2Client"
|
||||
) as MockClient:
|
||||
mock_client = AsyncMock()
|
||||
|
||||
# Mock initial token exchange to get FastMCP tokens
|
||||
mock_client.fetch_token = AsyncMock(
|
||||
return_value={
|
||||
"access_token": "upstream-access-initial",
|
||||
"refresh_token": upstream_refresh_token,
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
)
|
||||
|
||||
# Configure mock to call real provider for refresh
|
||||
async def mock_refresh(*args, **kwargs):
|
||||
async with httpx.AsyncClient() as http:
|
||||
response = await http.post(
|
||||
mock_oauth_provider.token_endpoint,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": upstream_refresh_token,
|
||||
},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
mock_client.refresh_token = mock_refresh
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
# Store client code that would be created during OAuth callback
|
||||
client_code = ClientCode(
|
||||
code="test-auth-code",
|
||||
client_id="test-client",
|
||||
redirect_uri="http://localhost:12345/callback",
|
||||
code_challenge="",
|
||||
code_challenge_method="S256",
|
||||
scopes=["read", "write"],
|
||||
idp_tokens={
|
||||
"access_token": "upstream-access-initial",
|
||||
"refresh_token": upstream_refresh_token,
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
expires_at=time.time() + 300,
|
||||
created_at=time.time(),
|
||||
)
|
||||
await proxy._code_store.put(key=client_code.code, value=client_code)
|
||||
|
||||
# Exchange authorization code to get FastMCP tokens
|
||||
auth_code = AuthorizationCode(
|
||||
code="test-auth-code",
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 300,
|
||||
client_id="test-client",
|
||||
code_challenge="",
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
initial_result = await proxy.exchange_authorization_code(
|
||||
client=client,
|
||||
authorization_code=auth_code,
|
||||
)
|
||||
|
||||
# Now test refresh with the valid FastMCP refresh token
|
||||
assert initial_result.refresh_token is not None
|
||||
fastmcp_refresh = RefreshToken(
|
||||
token=initial_result.refresh_token,
|
||||
client_id="test-client",
|
||||
scopes=["read"],
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
result = await proxy.exchange_refresh_token(
|
||||
client, fastmcp_refresh, ["read"]
|
||||
)
|
||||
|
||||
# Should return new FastMCP tokens (not upstream tokens)
|
||||
assert result.access_token != "upstream-access-initial"
|
||||
# FastMCP tokens are JWTs (have 3 segments)
|
||||
assert len(result.access_token.split(".")) == 3
|
||||
assert mock_oauth_provider.refresh_called
|
||||
|
||||
async def test_pkce_validation_with_mock_provider(self, mock_oauth_provider):
|
||||
"""Test PKCE validation with mock provider."""
|
||||
mock_oauth_provider.require_pkce = True
|
||||
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint=mock_oauth_provider.authorize_endpoint,
|
||||
upstream_token_endpoint=mock_oauth_provider.token_endpoint,
|
||||
upstream_client_id="mock-client",
|
||||
upstream_client_secret="mock-secret",
|
||||
token_verifier=MockTokenVerifier(),
|
||||
base_url="http://localhost:8000",
|
||||
forward_pkce=True, # Enable PKCE forwarding
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
# Register client first
|
||||
await proxy.register_client(client)
|
||||
|
||||
params = AuthorizationParams(
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="client_challenge_value",
|
||||
scopes=["read"],
|
||||
)
|
||||
|
||||
# Start authorization with PKCE
|
||||
auth_url = await proxy.authorize(client, params)
|
||||
query_params = parse_qs(urlparse(auth_url).query)
|
||||
|
||||
# Should redirect to consent page
|
||||
assert "/consent" in auth_url
|
||||
assert "txn_id" in query_params
|
||||
|
||||
# Transaction should have proxy's PKCE verifier (different from client's)
|
||||
txn_id = query_params["txn_id"][0]
|
||||
transaction = await proxy._transaction_store.get(key=txn_id)
|
||||
assert transaction is not None
|
||||
assert (
|
||||
transaction.code_challenge == "client_challenge_value"
|
||||
) # Client's challenge
|
||||
assert transaction.proxy_code_verifier is not None # Proxy generated its own
|
||||
# Proxy code challenge is computed from verifier when needed
|
||||
assert len(transaction.proxy_code_verifier) > 0
|
||||
69
tests/server/auth/oauth_proxy/test_oauth_proxy.py
Normal file
69
tests/server/auth/oauth_proxy/test_oauth_proxy.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Tests for OAuth proxy initialization and configuration."""
|
||||
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
|
||||
|
||||
class TestOAuthProxyInitialization:
|
||||
"""Tests for OAuth proxy initialization and configuration."""
|
||||
|
||||
def test_basic_initialization(self, jwt_verifier):
|
||||
"""Test basic proxy initialization with required parameters."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://auth.example.com/authorize",
|
||||
upstream_token_endpoint="https://auth.example.com/token",
|
||||
upstream_client_id="client-123",
|
||||
upstream_client_secret="secret-456",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://api.example.com",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
assert (
|
||||
proxy._upstream_authorization_endpoint
|
||||
== "https://auth.example.com/authorize"
|
||||
)
|
||||
assert proxy._upstream_token_endpoint == "https://auth.example.com/token"
|
||||
assert proxy._upstream_client_id == "client-123"
|
||||
assert proxy._upstream_client_secret.get_secret_value() == "secret-456"
|
||||
assert str(proxy.base_url) == "https://api.example.com/"
|
||||
|
||||
def test_all_optional_parameters(self, jwt_verifier):
|
||||
"""Test initialization with all optional parameters."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://auth.example.com/authorize",
|
||||
upstream_token_endpoint="https://auth.example.com/token",
|
||||
upstream_client_id="client-123",
|
||||
upstream_client_secret="secret-456",
|
||||
upstream_revocation_endpoint="https://auth.example.com/revoke",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://api.example.com",
|
||||
redirect_path="/custom/callback",
|
||||
issuer_url="https://issuer.example.com",
|
||||
service_documentation_url="https://docs.example.com",
|
||||
allowed_client_redirect_uris=["http://localhost:*"],
|
||||
valid_scopes=["custom", "scopes"],
|
||||
forward_pkce=False,
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
assert proxy._upstream_revocation_endpoint == "https://auth.example.com/revoke"
|
||||
assert proxy._redirect_path == "/custom/callback"
|
||||
assert proxy._forward_pkce is False
|
||||
assert proxy._token_endpoint_auth_method == "client_secret_post"
|
||||
assert proxy.client_registration_options is not None
|
||||
assert proxy.client_registration_options.valid_scopes == ["custom", "scopes"]
|
||||
|
||||
def test_redirect_path_normalization(self, jwt_verifier):
|
||||
"""Test that redirect_path is normalized with leading slash."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://auth.com/authorize",
|
||||
upstream_token_endpoint="https://auth.com/token",
|
||||
upstream_client_id="client",
|
||||
upstream_client_secret="secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://api.com",
|
||||
redirect_path="auth/callback", # No leading slash
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
assert proxy._redirect_path == "/auth/callback"
|
||||
495
tests/server/auth/oauth_proxy/test_tokens.py
Normal file
495
tests/server/auth/oauth_proxy/test_tokens.py
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
"""Tests for OAuth proxy token endpoint and handling."""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from mcp.server.auth.handlers.token import TokenErrorResponse
|
||||
from mcp.server.auth.handlers.token import TokenHandler as SDKTokenHandler
|
||||
from mcp.server.auth.provider import AuthorizationCode
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from fastmcp.server.auth.auth import RefreshToken, TokenHandler, TokenVerifier
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.server.auth.oauth_proxy.models import (
|
||||
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS,
|
||||
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
ClientCode,
|
||||
)
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
|
||||
|
||||
class TestOAuthProxyTokenEndpointAuth:
|
||||
"""Tests for token endpoint authentication methods."""
|
||||
|
||||
def test_token_auth_method_initialization(self, jwt_verifier):
|
||||
"""Test different token endpoint auth methods."""
|
||||
# client_secret_post
|
||||
proxy_post = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="client",
|
||||
upstream_client_secret="secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
assert proxy_post._token_endpoint_auth_method == "client_secret_post"
|
||||
|
||||
# client_secret_basic (default)
|
||||
proxy_basic = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="client",
|
||||
upstream_client_secret="secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
token_endpoint_auth_method="client_secret_basic",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
assert proxy_basic._token_endpoint_auth_method == "client_secret_basic"
|
||||
|
||||
# None (use authlib default)
|
||||
proxy_default = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="client",
|
||||
upstream_client_secret="secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
assert proxy_default._token_endpoint_auth_method is None
|
||||
|
||||
async def test_token_auth_method_passed_to_client(self, jwt_verifier):
|
||||
"""Test that auth method is passed to AsyncOAuth2Client."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
||||
upstream_token_endpoint="https://oauth.example.com/token",
|
||||
upstream_client_id="client-id",
|
||||
upstream_client_secret="client-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
# Initialize JWT issuer before token operations
|
||||
proxy.set_mcp_path("/mcp")
|
||||
|
||||
# First, create a valid FastMCP token via full OAuth flow
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
|
||||
# Mock the upstream OAuth provider response
|
||||
with patch(
|
||||
"fastmcp.server.auth.oauth_proxy.proxy.AsyncOAuth2Client"
|
||||
) as MockClient:
|
||||
mock_client = AsyncMock()
|
||||
|
||||
# Mock initial token exchange (authorization code flow)
|
||||
mock_client.fetch_token = AsyncMock(
|
||||
return_value={
|
||||
"access_token": "upstream-access-token",
|
||||
"refresh_token": "upstream-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
)
|
||||
|
||||
# Mock token refresh
|
||||
mock_client.refresh_token = AsyncMock(
|
||||
return_value={
|
||||
"access_token": "new-upstream-token",
|
||||
"refresh_token": "new-upstream-refresh",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
)
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
# Register client and do initial OAuth flow to get valid FastMCP tokens
|
||||
await proxy.register_client(client)
|
||||
|
||||
# Store client code that would be created during OAuth callback
|
||||
client_code = ClientCode(
|
||||
code="test-auth-code",
|
||||
client_id="test-client",
|
||||
redirect_uri="http://localhost:12345/callback",
|
||||
code_challenge="",
|
||||
code_challenge_method="S256",
|
||||
scopes=["read"],
|
||||
idp_tokens={
|
||||
"access_token": "upstream-access-token",
|
||||
"refresh_token": "upstream-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
expires_at=time.time() + 300,
|
||||
created_at=time.time(),
|
||||
)
|
||||
await proxy._code_store.put(key=client_code.code, value=client_code)
|
||||
|
||||
# Exchange authorization code to get FastMCP tokens
|
||||
auth_code = AuthorizationCode(
|
||||
code="test-auth-code",
|
||||
scopes=["read"],
|
||||
expires_at=time.time() + 300,
|
||||
client_id="test-client",
|
||||
code_challenge="",
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
result = await proxy.exchange_authorization_code(
|
||||
client=client,
|
||||
authorization_code=auth_code,
|
||||
)
|
||||
|
||||
# Now test refresh with the valid FastMCP refresh token
|
||||
assert result.refresh_token is not None
|
||||
fastmcp_refresh = RefreshToken(
|
||||
token=result.refresh_token,
|
||||
client_id="test-client",
|
||||
scopes=["read"],
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
# Reset mock to check refresh call
|
||||
MockClient.reset_mock()
|
||||
mock_client.refresh_token = AsyncMock(
|
||||
return_value={
|
||||
"access_token": "new-upstream-token-2",
|
||||
"refresh_token": "new-upstream-refresh-2",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
)
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
await proxy.exchange_refresh_token(client, fastmcp_refresh, ["read"])
|
||||
|
||||
# Verify auth method was passed to OAuth client
|
||||
MockClient.assert_called_with(
|
||||
client_id="client-id",
|
||||
client_secret="client-secret",
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
|
||||
class TestTokenHandlerErrorTransformation:
|
||||
"""Tests for TokenHandler's OAuth 2.1 compliant error transformation."""
|
||||
|
||||
async def test_transforms_client_auth_failure_to_invalid_client_401(self):
|
||||
"""Test that client authentication failures return invalid_client with 401."""
|
||||
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())
|
||||
|
||||
# Create a mock 401 response like the SDK returns for auth failures
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 401
|
||||
mock_response.body = (
|
||||
b'{"error":"unauthorized_client","error_description":"Invalid client_id"}'
|
||||
)
|
||||
|
||||
# Patch the parent class's handle() to return our mock response
|
||||
with patch.object(
|
||||
SDKTokenHandler,
|
||||
"handle",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
):
|
||||
response = await handler.handle(Mock())
|
||||
|
||||
# Should transform to OAuth 2.1 compliant response
|
||||
assert response.status_code == 401
|
||||
assert b'"error":"invalid_client"' in response.body
|
||||
assert b'"error_description":"Invalid client_id"' in response.body
|
||||
|
||||
def test_does_not_transform_grant_type_unauthorized_to_invalid_client(self):
|
||||
"""Test that grant type authorization errors stay as unauthorized_client with 400."""
|
||||
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())
|
||||
|
||||
# Simulate error from grant_type not in client_info.grant_types
|
||||
error_response = TokenErrorResponse(
|
||||
error="unauthorized_client",
|
||||
error_description="Client not authorized for this grant type",
|
||||
)
|
||||
|
||||
response = handler.response(error_response)
|
||||
|
||||
# Should NOT transform - keep as 400 unauthorized_client
|
||||
assert response.status_code == 400
|
||||
assert b'"error":"unauthorized_client"' in response.body
|
||||
|
||||
async def test_transforms_invalid_grant_to_401(self):
|
||||
"""Test that invalid_grant errors return 401 per MCP spec.
|
||||
|
||||
Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response."
|
||||
The SDK incorrectly returns 400 for all TokenErrorResponse including invalid_grant.
|
||||
"""
|
||||
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())
|
||||
|
||||
# Create a mock 400 response like the SDK returns for invalid_grant
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.body = (
|
||||
b'{"error":"invalid_grant","error_description":"refresh token has expired"}'
|
||||
)
|
||||
|
||||
# Patch the parent class's handle() to return our mock response
|
||||
with patch.object(
|
||||
SDKTokenHandler,
|
||||
"handle",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
):
|
||||
response = await handler.handle(Mock())
|
||||
|
||||
# Should transform to MCP-compliant 401 response
|
||||
assert response.status_code == 401
|
||||
assert b'"error":"invalid_grant"' in response.body
|
||||
assert b'"error_description":"refresh token has expired"' in response.body
|
||||
|
||||
def test_does_not_transform_other_400_errors(self):
|
||||
"""Test that non-invalid_grant 400 errors pass through unchanged."""
|
||||
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())
|
||||
|
||||
# Test with invalid_request error (should stay 400)
|
||||
error_response = TokenErrorResponse(
|
||||
error="invalid_request",
|
||||
error_description="Missing required parameter",
|
||||
)
|
||||
|
||||
response = handler.response(error_response)
|
||||
|
||||
# Should pass through unchanged as 400
|
||||
assert response.status_code == 400
|
||||
assert b'"error":"invalid_request"' in response.body
|
||||
|
||||
|
||||
class TestFallbackAccessTokenExpiry:
|
||||
"""Test fallback access token expiry constants and configuration."""
|
||||
|
||||
def test_default_constants(self):
|
||||
"""Verify the default expiry constants are set correctly."""
|
||||
assert DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS == 60 * 60 # 1 hour
|
||||
assert (
|
||||
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS == 60 * 60 * 24 * 365
|
||||
) # 1 year
|
||||
|
||||
def test_fallback_parameter_stored(self):
|
||||
"""Verify fallback_access_token_expiry_seconds is stored on provider."""
|
||||
provider = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://idp.example.com/authorize",
|
||||
upstream_token_endpoint="https://idp.example.com/token",
|
||||
upstream_client_id="test-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=JWTVerifier(
|
||||
jwks_uri="https://idp.example.com/.well-known/jwks.json",
|
||||
issuer="https://idp.example.com",
|
||||
),
|
||||
base_url="http://localhost:8000",
|
||||
jwt_signing_key="test-signing-key",
|
||||
fallback_access_token_expiry_seconds=86400,
|
||||
)
|
||||
|
||||
assert provider._fallback_access_token_expiry_seconds == 86400
|
||||
|
||||
def test_fallback_parameter_defaults_to_none(self):
|
||||
"""Verify fallback defaults to None (enabling smart defaults)."""
|
||||
provider = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://idp.example.com/authorize",
|
||||
upstream_token_endpoint="https://idp.example.com/token",
|
||||
upstream_client_id="test-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=JWTVerifier(
|
||||
jwks_uri="https://idp.example.com/.well-known/jwks.json",
|
||||
issuer="https://idp.example.com",
|
||||
),
|
||||
base_url="http://localhost:8000",
|
||||
jwt_signing_key="test-signing-key",
|
||||
)
|
||||
|
||||
assert provider._fallback_access_token_expiry_seconds is None
|
||||
|
||||
|
||||
class TestUpstreamTokenStorageTTL:
|
||||
"""Tests for upstream token storage TTL calculation (issue #2670).
|
||||
|
||||
The TTL should use max(refresh_expires_in, expires_in) to handle cases where
|
||||
the refresh token has a shorter lifetime than the access token (e.g., Keycloak
|
||||
with sliding session windows).
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_verifier(self):
|
||||
"""Create a mock JWT verifier."""
|
||||
verifier = Mock(spec=TokenVerifier)
|
||||
verifier.required_scopes = ["read", "write"]
|
||||
verifier.verify_token = AsyncMock(return_value=None)
|
||||
return verifier
|
||||
|
||||
@pytest.fixture
|
||||
def proxy(self, jwt_verifier):
|
||||
"""Create an OAuth proxy for testing."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://idp.example.com/authorize",
|
||||
upstream_token_endpoint="https://idp.example.com/token",
|
||||
upstream_client_id="test-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret-key",
|
||||
)
|
||||
proxy.set_mcp_path("/mcp")
|
||||
return proxy
|
||||
|
||||
async def test_ttl_uses_max_when_refresh_shorter_than_access(self, proxy):
|
||||
"""TTL should use access token expiry when refresh is shorter.
|
||||
|
||||
This is the xsreality case: Keycloak returns refresh_expires_in=120 (2 min)
|
||||
but expires_in=28800 (8 hours). The upstream tokens should persist for
|
||||
8 hours (the access token lifetime), not 2 minutes.
|
||||
"""
|
||||
# Register client
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
await proxy.register_client(client)
|
||||
|
||||
# Simulate xsreality's Keycloak setup: short refresh, long access
|
||||
client_code = ClientCode(
|
||||
code="test-auth-code",
|
||||
client_id="test-client",
|
||||
redirect_uri="http://localhost:12345/callback",
|
||||
code_challenge="test-challenge",
|
||||
code_challenge_method="S256",
|
||||
scopes=["read", "write"],
|
||||
idp_tokens={
|
||||
"access_token": "upstream-access-token",
|
||||
"refresh_token": "upstream-refresh-token",
|
||||
"expires_in": 28800, # 8 hours (access token)
|
||||
"refresh_expires_in": 120, # 2 minutes (refresh token) - SHORTER!
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
expires_at=time.time() + 300,
|
||||
created_at=time.time(),
|
||||
)
|
||||
await proxy._code_store.put(key=client_code.code, value=client_code)
|
||||
|
||||
# Exchange the code
|
||||
auth_code = AuthorizationCode(
|
||||
code="test-auth-code",
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 300,
|
||||
client_id="test-client",
|
||||
code_challenge="test-challenge",
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
|
||||
result = await proxy.exchange_authorization_code(
|
||||
client=client,
|
||||
authorization_code=auth_code,
|
||||
)
|
||||
|
||||
# Verify tokens were issued
|
||||
assert result.access_token is not None
|
||||
assert result.refresh_token is not None
|
||||
|
||||
# The key test: verify upstream tokens are stored with TTL=max(120, 28800)=28800
|
||||
# We can verify this by checking the tokens are still accessible after 2 minutes
|
||||
# would have passed (if TTL was incorrectly set to 120)
|
||||
#
|
||||
# Since we can't easily time-travel in tests, we verify the storage directly
|
||||
# by checking that we can still look up the tokens for refresh purposes.
|
||||
#
|
||||
# Extract the JTI from the refresh token to look up the mapping
|
||||
refresh_payload = proxy.jwt_issuer.verify_token(result.refresh_token)
|
||||
refresh_jti = refresh_payload["jti"]
|
||||
|
||||
# The JTI mapping should exist
|
||||
jti_mapping = await proxy._jti_mapping_store.get(key=refresh_jti)
|
||||
assert jti_mapping is not None
|
||||
|
||||
# The upstream tokens should exist
|
||||
upstream_tokens = await proxy._upstream_token_store.get(
|
||||
key=jti_mapping.upstream_token_id
|
||||
)
|
||||
assert upstream_tokens is not None
|
||||
assert upstream_tokens.access_token == "upstream-access-token"
|
||||
assert upstream_tokens.refresh_token == "upstream-refresh-token"
|
||||
|
||||
async def test_ttl_uses_refresh_when_refresh_longer_than_access(self, proxy):
|
||||
"""TTL should use refresh token expiry when refresh is longer.
|
||||
|
||||
This is the ianw case: IdP returns expires_in=300 (5 min) but
|
||||
refresh_expires_in=32318 (9 hours). The upstream tokens should persist
|
||||
for 9 hours (the refresh token lifetime).
|
||||
"""
|
||||
# Register client
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
||||
)
|
||||
await proxy.register_client(client)
|
||||
|
||||
# Simulate ianw's setup: short access, long refresh (typical)
|
||||
client_code = ClientCode(
|
||||
code="test-auth-code-2",
|
||||
client_id="test-client",
|
||||
redirect_uri="http://localhost:12345/callback",
|
||||
code_challenge="test-challenge",
|
||||
code_challenge_method="S256",
|
||||
scopes=["read", "write"],
|
||||
idp_tokens={
|
||||
"access_token": "upstream-access-token-2",
|
||||
"refresh_token": "upstream-refresh-token-2",
|
||||
"expires_in": 300, # 5 minutes (access token)
|
||||
"refresh_expires_in": 32318, # 9 hours (refresh token) - LONGER
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
expires_at=time.time() + 300,
|
||||
created_at=time.time(),
|
||||
)
|
||||
await proxy._code_store.put(key=client_code.code, value=client_code)
|
||||
|
||||
# Exchange the code
|
||||
auth_code = AuthorizationCode(
|
||||
code="test-auth-code-2",
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 300,
|
||||
client_id="test-client",
|
||||
code_challenge="test-challenge",
|
||||
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
|
||||
result = await proxy.exchange_authorization_code(
|
||||
client=client,
|
||||
authorization_code=auth_code,
|
||||
)
|
||||
|
||||
# Verify tokens were issued
|
||||
assert result.access_token is not None
|
||||
assert result.refresh_token is not None
|
||||
|
||||
# Verify upstream tokens are accessible
|
||||
refresh_payload = proxy.jwt_issuer.verify_token(result.refresh_token)
|
||||
refresh_jti = refresh_payload["jti"]
|
||||
|
||||
jti_mapping = await proxy._jti_mapping_store.get(key=refresh_jti)
|
||||
assert jti_mapping is not None
|
||||
|
||||
upstream_tokens = await proxy._upstream_token_store.get(
|
||||
key=jti_mapping.upstream_token_id
|
||||
)
|
||||
assert upstream_tokens is not None
|
||||
99
tests/server/auth/oauth_proxy/test_ui.py
Normal file
99
tests/server/auth/oauth_proxy/test_ui.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Tests for OAuth proxy UI and error page rendering."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.server.auth.oauth_proxy.ui import create_error_html
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
|
||||
|
||||
class TestErrorPageRendering:
|
||||
"""Test error page rendering for OAuth callback errors."""
|
||||
|
||||
def test_create_error_html_basic(self):
|
||||
"""Test basic error page generation."""
|
||||
|
||||
html = create_error_html(
|
||||
error_title="Test Error",
|
||||
error_message="This is a test error message",
|
||||
)
|
||||
|
||||
# Verify it's valid HTML
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "<title>Test Error</title>" in html
|
||||
assert "This is a test error message" in html
|
||||
assert 'class="info-box error"' in html
|
||||
|
||||
def test_create_error_html_with_details(self):
|
||||
"""Test error page with error details."""
|
||||
|
||||
html = create_error_html(
|
||||
error_title="OAuth Error",
|
||||
error_message="Authentication failed",
|
||||
error_details={
|
||||
"Error Code": "invalid_scope",
|
||||
"Description": "Requested scope does not exist",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify error details are included
|
||||
assert "Error Details" in html
|
||||
assert "Error Code" in html
|
||||
assert "invalid_scope" in html
|
||||
assert "Description" in html
|
||||
assert "Requested scope does not exist" in html
|
||||
|
||||
def test_create_error_html_escapes_user_input(self):
|
||||
"""Test that error page properly escapes HTML in user input."""
|
||||
|
||||
html = create_error_html(
|
||||
error_title="Error <script>alert('xss')</script>",
|
||||
error_message="Message with <b>HTML</b> tags",
|
||||
error_details={"Key<script>": "Value<img>"},
|
||||
)
|
||||
|
||||
# Verify HTML is escaped
|
||||
assert "<script>alert('xss')</script>" not in html
|
||||
assert "<script>" in html
|
||||
assert "<b>HTML</b>" not in html
|
||||
assert "<b>HTML</b>" in html
|
||||
|
||||
async def test_callback_error_returns_html_page(self):
|
||||
"""Test that OAuth callback errors return styled HTML instead of data: URLs."""
|
||||
# Create a minimal OAuth proxy
|
||||
provider = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://idp.example.com/authorize",
|
||||
upstream_token_endpoint="https://idp.example.com/token",
|
||||
upstream_client_id="test-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=JWTVerifier(
|
||||
jwks_uri="https://idp.example.com/.well-known/jwks.json",
|
||||
issuer="https://idp.example.com",
|
||||
audience="test-client",
|
||||
),
|
||||
base_url="http://localhost:8000",
|
||||
jwt_signing_key="test-signing-key",
|
||||
)
|
||||
|
||||
# Mock a request with an error from the IdP
|
||||
mock_request = Mock(spec=Request)
|
||||
mock_request.query_params = {
|
||||
"error": "invalid_scope",
|
||||
"error_description": "The application asked for scope 'read' that doesn't exist",
|
||||
"state": "test-state",
|
||||
}
|
||||
|
||||
# Call the callback handler
|
||||
response = await provider._handle_idp_callback(mock_request)
|
||||
|
||||
# Verify we get an HTMLResponse, not a RedirectResponse
|
||||
assert isinstance(response, HTMLResponse)
|
||||
assert response.status_code == 400
|
||||
|
||||
# Verify the response contains the error message
|
||||
assert b"invalid_scope" in response.body
|
||||
assert b"doesn't exist" in response.body # HTML-escaped apostrophe
|
||||
assert b"OAuth Error" in response.body
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,6 @@ from fastmcp.server.middleware.error_handling import (
|
|||
RetryMiddleware,
|
||||
)
|
||||
from fastmcp.server.middleware.middleware import MiddlewareContext
|
||||
from fastmcp.utilities.tests import caplog_for_fastmcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -62,9 +61,8 @@ class TestErrorHandlingMiddleware:
|
|||
middleware = ErrorHandlingMiddleware()
|
||||
error = ValueError("test error")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
middleware._log_error(error, mock_context)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
middleware._log_error(error, mock_context)
|
||||
|
||||
assert "Error in test_method: ValueError: test error" in caplog.text
|
||||
assert "ValueError:test_method" in middleware.error_counts
|
||||
|
|
@ -75,9 +73,8 @@ class TestErrorHandlingMiddleware:
|
|||
middleware = ErrorHandlingMiddleware(include_traceback=True)
|
||||
error = ValueError("test error")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
middleware._log_error(error, mock_context)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
middleware._log_error(error, mock_context)
|
||||
|
||||
assert "Error in test_method: ValueError: test error" in caplog.text
|
||||
# The traceback is added to the log message
|
||||
|
|
@ -99,9 +96,8 @@ class TestErrorHandlingMiddleware:
|
|||
middleware = ErrorHandlingMiddleware(error_callback=callback)
|
||||
error = ValueError("test error")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
middleware._log_error(error, mock_context)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
middleware._log_error(error, mock_context)
|
||||
|
||||
assert "Error in error callback: callback error" in caplog.text
|
||||
|
||||
|
|
@ -205,10 +201,9 @@ class TestErrorHandlingMiddleware:
|
|||
middleware = ErrorHandlingMiddleware()
|
||||
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await middleware.on_message(mock_context, mock_call_next)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await middleware.on_message(mock_context, mock_call_next)
|
||||
|
||||
assert isinstance(exc_info.value, McpError)
|
||||
assert exc_info.value.error.code == -32602
|
||||
|
|
@ -222,10 +217,9 @@ class TestErrorHandlingMiddleware:
|
|||
tool_error.__cause__ = ValueError()
|
||||
mock_call_next = AsyncMock(side_effect=tool_error)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await middleware.on_message(mock_context, mock_call_next)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await middleware.on_message(mock_context, mock_call_next)
|
||||
|
||||
assert isinstance(exc_info.value, McpError)
|
||||
assert exc_info.value.error.code == -32602
|
||||
|
|
@ -327,9 +321,8 @@ class TestRetryMiddleware:
|
|||
]
|
||||
)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = await middleware.on_request(mock_context, mock_call_next)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = await middleware.on_request(mock_context, mock_call_next)
|
||||
|
||||
assert result == "test_result"
|
||||
assert mock_call_next.call_count == 3
|
||||
|
|
@ -342,10 +335,9 @@ class TestRetryMiddleware:
|
|||
# Fail all attempts
|
||||
mock_call_next = AsyncMock(side_effect=ConnectionError("connection failed"))
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with pytest.raises(ConnectionError):
|
||||
await middleware.on_request(mock_context, mock_call_next)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with pytest.raises(ConnectionError):
|
||||
await middleware.on_request(mock_context, mock_call_next)
|
||||
|
||||
assert mock_call_next.call_count == 3 # initial + 2 retries
|
||||
assert "Retrying in" in caplog.text
|
||||
|
|
@ -421,19 +413,14 @@ class TestErrorHandlingMiddlewareIntegration:
|
|||
|
||||
error_handling_server.add_middleware(ErrorHandlingMiddleware())
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
async with Client(error_handling_server) as client:
|
||||
# Test different types of errors
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool(
|
||||
"failing_operation", {"error_type": "value"}
|
||||
)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
async with Client(error_handling_server) as client:
|
||||
# Test different types of errors
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("failing_operation", {"error_type": "value"})
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool(
|
||||
"failing_operation", {"error_type": "file"}
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("failing_operation", {"error_type": "file"})
|
||||
|
||||
log_text = caplog.text
|
||||
|
||||
|
|
@ -462,12 +449,12 @@ class TestErrorHandlingMiddlewareIntegration:
|
|||
with pytest.raises(Exception):
|
||||
await client.call_tool("failing_operation", {"error_type": "file"})
|
||||
|
||||
# Try some intermittent operations (some may succeed)
|
||||
for _ in range(5):
|
||||
try:
|
||||
await client.call_tool("intermittent_operation", {"fail_rate": 0.8})
|
||||
except Exception:
|
||||
pass # Expected failures
|
||||
# Try some intermittent operations (some may succeed)
|
||||
for _ in range(5):
|
||||
try:
|
||||
await client.call_tool("intermittent_operation", {"fail_rate": 0.8})
|
||||
except Exception:
|
||||
pass # Expected failures
|
||||
|
||||
# Check error statistics
|
||||
stats = error_middleware.get_error_stats()
|
||||
|
|
@ -484,20 +471,17 @@ class TestErrorHandlingMiddlewareIntegration:
|
|||
|
||||
error_handling_server.add_middleware(ErrorHandlingMiddleware())
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
async with Client(error_handling_server) as client:
|
||||
# Successful operation (should not generate error logs)
|
||||
await client.call_tool("reliable_operation", {"data": "test"})
|
||||
with caplog.at_level(logging.ERROR):
|
||||
async with Client(error_handling_server) as client:
|
||||
# Successful operation (should not generate error logs)
|
||||
await client.call_tool("reliable_operation", {"data": "test"})
|
||||
|
||||
# Failed operation (should generate error log)
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool(
|
||||
"failing_operation", {"error_type": "value"}
|
||||
)
|
||||
# Failed operation (should generate error log)
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("failing_operation", {"error_type": "value"})
|
||||
|
||||
# Another successful operation
|
||||
await client.call_tool("reliable_operation", {"data": "test2"})
|
||||
# Another successful operation
|
||||
await client.call_tool("reliable_operation", {"data": "test2"})
|
||||
|
||||
log_text = caplog.text
|
||||
|
||||
|
|
@ -555,8 +539,8 @@ class TestErrorHandlingMiddlewareIntegration:
|
|||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool("failing_operation", {"error_type": "value"})
|
||||
|
||||
# Error should still exist (may be wrapped by FastMCP)
|
||||
assert exc_info.value is not None
|
||||
# Error should still exist (may be wrapped by FastMCP)
|
||||
assert exc_info.value is not None
|
||||
|
||||
|
||||
class TestRetryMiddlewareIntegration:
|
||||
|
|
@ -577,19 +561,18 @@ class TestRetryMiddlewareIntegration:
|
|||
)
|
||||
)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
async with Client(error_handling_server) as client:
|
||||
# This operation fails intermittently - try several times
|
||||
success_count = 0
|
||||
for _ in range(5):
|
||||
try:
|
||||
await client.call_tool(
|
||||
"intermittent_operation", {"fail_rate": 0.7}
|
||||
)
|
||||
success_count += 1
|
||||
except Exception:
|
||||
pass # Some failures expected even with retries
|
||||
with caplog.at_level(logging.WARNING):
|
||||
async with Client(error_handling_server) as client:
|
||||
# This operation fails intermittently - try several times
|
||||
success_count = 0
|
||||
for _ in range(5):
|
||||
try:
|
||||
await client.call_tool(
|
||||
"intermittent_operation", {"fail_rate": 0.7}
|
||||
)
|
||||
success_count += 1
|
||||
except Exception:
|
||||
pass # Some failures expected even with retries
|
||||
|
||||
# Should have some retry log messages
|
||||
# Note: Retry logs might not appear if the underlying errors are wrapped by FastMCP
|
||||
|
|
@ -613,7 +596,7 @@ class TestRetryMiddlewareIntegration:
|
|||
with pytest.raises(Exception):
|
||||
await client.call_tool("failing_operation", {"error_type": "value"})
|
||||
|
||||
# Should fail immediately without retries
|
||||
# Should fail immediately without retries
|
||||
|
||||
async def test_combined_error_handling_and_retry_middleware(
|
||||
self, error_handling_server, caplog
|
||||
|
|
@ -629,22 +612,17 @@ class TestRetryMiddlewareIntegration:
|
|||
)
|
||||
)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
async with Client(error_handling_server) as client:
|
||||
# Try intermittent operation
|
||||
try:
|
||||
await client.call_tool(
|
||||
"intermittent_operation", {"fail_rate": 0.9}
|
||||
)
|
||||
except Exception:
|
||||
pass # May still fail even with retries
|
||||
with caplog.at_level(logging.ERROR):
|
||||
async with Client(error_handling_server) as client:
|
||||
# Try intermittent operation
|
||||
try:
|
||||
await client.call_tool("intermittent_operation", {"fail_rate": 0.9})
|
||||
except Exception:
|
||||
pass # May still fail even with retries
|
||||
|
||||
# Try permanent failure
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool(
|
||||
"failing_operation", {"error_type": "value"}
|
||||
)
|
||||
# Try permanent failure
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("failing_operation", {"error_type": "value"})
|
||||
|
||||
log_text = caplog.text
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ from fastmcp.server.middleware.logging import (
|
|||
StructuredLoggingMiddleware,
|
||||
)
|
||||
from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext
|
||||
from fastmcp.utilities.tests import caplog_for_fastmcp
|
||||
|
||||
FIXED_DATE = datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
|
@ -191,8 +190,7 @@ class TestStructuredLoggingMiddleware:
|
|||
middleware = StructuredLoggingMiddleware()
|
||||
mock_call_next = AsyncMock(return_value="test_result")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
|
||||
assert result == "test_result"
|
||||
assert mock_call_next.called
|
||||
|
|
@ -211,9 +209,8 @@ class TestStructuredLoggingMiddleware:
|
|||
middleware = StructuredLoggingMiddleware()
|
||||
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with pytest.raises(ValueError):
|
||||
await middleware.on_message(mock_context, mock_call_next)
|
||||
with pytest.raises(ValueError):
|
||||
await middleware.on_message(mock_context, mock_call_next)
|
||||
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
[
|
||||
|
|
@ -266,9 +263,8 @@ class TestLoggingMiddleware:
|
|||
middleware = StructuredLoggingMiddleware()
|
||||
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with pytest.raises(ValueError):
|
||||
await middleware.on_message(mock_context, mock_call_next)
|
||||
with pytest.raises(ValueError):
|
||||
await middleware.on_message(mock_context, mock_call_next)
|
||||
|
||||
# Check that we have structured JSON logs
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
|
|
@ -296,8 +292,7 @@ class TestLoggingMiddleware:
|
|||
|
||||
middleware = StructuredLoggingMiddleware(include_payloads=True)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
|
||||
assert result == "test_result"
|
||||
|
||||
|
|
@ -325,8 +320,7 @@ class TestLoggingMiddleware:
|
|||
|
||||
middleware = StructuredLoggingMiddleware(include_payloads=True)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
|
||||
assert result == "test_result"
|
||||
|
||||
|
|
@ -358,8 +352,7 @@ class TestLoggingMiddleware:
|
|||
|
||||
middleware = StructuredLoggingMiddleware(include_payloads=True)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
|
||||
assert result == "test_result"
|
||||
|
||||
|
|
@ -393,8 +386,7 @@ class TestLoggingMiddleware:
|
|||
include_payloads=True, payload_serializer=custom_serializer
|
||||
)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
result = await middleware.on_message(mock_context, mock_call_next)
|
||||
|
||||
assert result == "test_result"
|
||||
|
||||
|
|
@ -488,16 +480,15 @@ class TestLoggingMiddlewareIntegration:
|
|||
|
||||
logging_server.add_middleware(logging_middleware)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.INFO):
|
||||
async with Client(logging_server) as client:
|
||||
await client.call_tool(
|
||||
name="simple_operation", arguments={"data": "test_data"}
|
||||
)
|
||||
await client.call_tool(
|
||||
name="complex_operation",
|
||||
arguments={"items": ["a", "b", "c"], "mode": "batch"},
|
||||
)
|
||||
with caplog.at_level(logging.INFO):
|
||||
async with Client(logging_server) as client:
|
||||
await client.call_tool(
|
||||
name="simple_operation", arguments={"data": "test_data"}
|
||||
)
|
||||
await client.call_tool(
|
||||
name="complex_operation",
|
||||
arguments={"items": ["a", "b", "c"], "mode": "batch"},
|
||||
)
|
||||
|
||||
# Should have processing and completion logs for both operations
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
|
|
@ -515,13 +506,10 @@ class TestLoggingMiddlewareIntegration:
|
|||
"""Test that logging middleware captures failed operations."""
|
||||
logging_server.add_middleware(LoggingMiddleware(methods=["tools/call"]))
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
async with Client(logging_server) as client:
|
||||
# This should fail and be logged
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool(
|
||||
"operation_with_error", {"should_fail": True}
|
||||
)
|
||||
async with Client(logging_server) as client:
|
||||
# This should fail and be logged
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("operation_with_error", {"should_fail": True})
|
||||
|
||||
log_text = caplog.text
|
||||
|
||||
|
|
@ -540,9 +528,8 @@ class TestLoggingMiddlewareIntegration:
|
|||
)
|
||||
logging_server.add_middleware(middleware)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
async with Client(logging_server) as client:
|
||||
await client.call_tool("simple_operation", {"data": "payload_test"})
|
||||
async with Client(logging_server) as client:
|
||||
await client.call_tool("simple_operation", {"data": "payload_test"})
|
||||
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
[
|
||||
|
|
@ -562,11 +549,10 @@ class TestLoggingMiddlewareIntegration:
|
|||
|
||||
logging_server.add_middleware(logging_middleware)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
async with Client(logging_server) as client:
|
||||
await client.call_tool(
|
||||
name="simple_operation", arguments={"data": "json_test"}
|
||||
)
|
||||
async with Client(logging_server) as client:
|
||||
await client.call_tool(
|
||||
name="simple_operation", arguments={"data": "json_test"}
|
||||
)
|
||||
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
[
|
||||
|
|
@ -584,13 +570,12 @@ class TestLoggingMiddlewareIntegration:
|
|||
|
||||
logging_server.add_middleware(logging_middleware)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with caplog.at_level(logging.INFO):
|
||||
async with Client(logging_server) as client:
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool(
|
||||
"operation_with_error", {"should_fail": True}
|
||||
)
|
||||
with caplog.at_level(logging.INFO):
|
||||
async with Client(logging_server) as client:
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool(
|
||||
"operation_with_error", {"should_fail": True}
|
||||
)
|
||||
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
[
|
||||
|
|
@ -615,13 +600,12 @@ class TestLoggingMiddlewareIntegration:
|
|||
)
|
||||
)
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
async with Client(logging_server) as client:
|
||||
# Test different operation types
|
||||
await client.call_tool("simple_operation", {"data": "test"})
|
||||
await client.read_resource("log://test")
|
||||
await client.get_prompt("test_prompt")
|
||||
await client.list_resources()
|
||||
async with Client(logging_server) as client:
|
||||
# Test different operation types
|
||||
await client.call_tool("simple_operation", {"data": "test"})
|
||||
await client.read_resource("log://test")
|
||||
await client.get_prompt("test_prompt")
|
||||
await client.list_resources()
|
||||
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from fastmcp import FastMCP
|
|||
from fastmcp.client import Client
|
||||
from fastmcp.server.middleware.middleware import MiddlewareContext
|
||||
from fastmcp.server.middleware.timing import DetailedTimingMiddleware, TimingMiddleware
|
||||
from fastmcp.utilities.tests import caplog_for_fastmcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -48,8 +47,7 @@ class TestTimingMiddleware:
|
|||
"""Test timing successful requests."""
|
||||
middleware = TimingMiddleware()
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_request(mock_context, mock_call_next)
|
||||
result = await middleware.on_request(mock_context, mock_call_next)
|
||||
|
||||
assert result == "test_result"
|
||||
assert mock_call_next.called
|
||||
|
|
@ -61,9 +59,8 @@ class TestTimingMiddleware:
|
|||
middleware = TimingMiddleware()
|
||||
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with pytest.raises(ValueError):
|
||||
await middleware.on_request(mock_context, mock_call_next)
|
||||
with pytest.raises(ValueError):
|
||||
await middleware.on_request(mock_context, mock_call_next)
|
||||
|
||||
assert "Request test_method failed after" in caplog.text
|
||||
assert "ms: test error" in caplog.text
|
||||
|
|
@ -85,8 +82,7 @@ class TestDetailedTimingMiddleware:
|
|||
context.message.name = "test_tool"
|
||||
mock_call_next = AsyncMock(return_value="tool_result")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_call_tool(context, mock_call_next)
|
||||
result = await middleware.on_call_tool(context, mock_call_next)
|
||||
|
||||
assert result == "tool_result"
|
||||
assert "Tool 'test_tool' completed in" in caplog.text
|
||||
|
|
@ -98,8 +94,7 @@ class TestDetailedTimingMiddleware:
|
|||
context.message.uri = "test://resource"
|
||||
mock_call_next = AsyncMock(return_value="resource_result")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_read_resource(context, mock_call_next)
|
||||
result = await middleware.on_read_resource(context, mock_call_next)
|
||||
|
||||
assert result == "resource_result"
|
||||
assert "Resource 'test://resource' completed in" in caplog.text
|
||||
|
|
@ -111,8 +106,7 @@ class TestDetailedTimingMiddleware:
|
|||
context.message.name = "test_prompt"
|
||||
mock_call_next = AsyncMock(return_value="prompt_result")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_get_prompt(context, mock_call_next)
|
||||
result = await middleware.on_get_prompt(context, mock_call_next)
|
||||
|
||||
assert result == "prompt_result"
|
||||
assert "Prompt 'test_prompt' completed in" in caplog.text
|
||||
|
|
@ -123,8 +117,7 @@ class TestDetailedTimingMiddleware:
|
|||
context = MagicMock()
|
||||
mock_call_next = AsyncMock(return_value="tools_result")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
result = await middleware.on_list_tools(context, mock_call_next)
|
||||
result = await middleware.on_list_tools(context, mock_call_next)
|
||||
|
||||
assert result == "tools_result"
|
||||
assert "List tools completed in" in caplog.text
|
||||
|
|
@ -136,9 +129,8 @@ class TestDetailedTimingMiddleware:
|
|||
context.message.name = "failing_tool"
|
||||
mock_call_next = AsyncMock(side_effect=RuntimeError("operation failed"))
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
with pytest.raises(RuntimeError):
|
||||
await middleware.on_call_tool(context, mock_call_next)
|
||||
with pytest.raises(RuntimeError):
|
||||
await middleware.on_call_tool(context, mock_call_next)
|
||||
|
||||
assert "Tool 'failing_tool' failed after" in caplog.text
|
||||
assert "ms: operation failed" in caplog.text
|
||||
|
|
@ -195,16 +187,15 @@ class TestTimingMiddlewareIntegration:
|
|||
"""Test that timing middleware accurately measures tool execution times."""
|
||||
timing_server.add_middleware(TimingMiddleware())
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
async with Client(timing_server) as client:
|
||||
# Test instant task
|
||||
await client.call_tool("instant_task")
|
||||
async with Client(timing_server) as client:
|
||||
# Test instant task
|
||||
await client.call_tool("instant_task")
|
||||
|
||||
# Test short task (0.1s)
|
||||
await client.call_tool("short_task")
|
||||
# Test short task (0.1s)
|
||||
await client.call_tool("short_task")
|
||||
|
||||
# Test medium task (0.15s)
|
||||
await client.call_tool("medium_task")
|
||||
# Test medium task (0.15s)
|
||||
await client.call_tool("medium_task")
|
||||
|
||||
log_text = caplog.text
|
||||
|
||||
|
|
@ -226,11 +217,10 @@ class TestTimingMiddlewareIntegration:
|
|||
"""Test that timing middleware measures time even for failed operations."""
|
||||
timing_server.add_middleware(TimingMiddleware())
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
async with Client(timing_server) as client:
|
||||
# This should fail but still be timed
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("failing_task")
|
||||
async with Client(timing_server) as client:
|
||||
# This should fail but still be timed
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("failing_task")
|
||||
|
||||
# Should log the failure with timing
|
||||
assert "tools/call failed after" in caplog.text
|
||||
|
|
@ -242,21 +232,20 @@ class TestTimingMiddlewareIntegration:
|
|||
"""Test that detailed timing middleware provides operation-specific timing."""
|
||||
timing_server.add_middleware(DetailedTimingMiddleware())
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
async with Client(timing_server) as client:
|
||||
# Test tool call
|
||||
await client.call_tool("short_task")
|
||||
async with Client(timing_server) as client:
|
||||
# Test tool call
|
||||
await client.call_tool("short_task")
|
||||
|
||||
# Test resource read
|
||||
await client.read_resource("timer://test")
|
||||
# Test resource read
|
||||
await client.read_resource("timer://test")
|
||||
|
||||
# Test prompt
|
||||
await client.get_prompt("test_prompt")
|
||||
# Test prompt
|
||||
await client.get_prompt("test_prompt")
|
||||
|
||||
# Test listing operations
|
||||
await client.list_tools()
|
||||
await client.list_resources()
|
||||
await client.list_prompts()
|
||||
# Test listing operations
|
||||
await client.list_tools()
|
||||
await client.list_resources()
|
||||
await client.list_prompts()
|
||||
|
||||
log_text = caplog.text
|
||||
|
||||
|
|
@ -272,16 +261,15 @@ class TestTimingMiddlewareIntegration:
|
|||
"""Test timing middleware with concurrent operations."""
|
||||
timing_server.add_middleware(TimingMiddleware())
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
async with Client(timing_server) as client:
|
||||
# Run multiple operations concurrently
|
||||
tasks = [
|
||||
client.call_tool("instant_task"),
|
||||
client.call_tool("short_task"),
|
||||
client.call_tool("instant_task"),
|
||||
]
|
||||
async with Client(timing_server) as client:
|
||||
# Run multiple operations concurrently
|
||||
tasks = [
|
||||
client.call_tool("instant_task"),
|
||||
client.call_tool("short_task"),
|
||||
client.call_tool("instant_task"),
|
||||
]
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
log_text = caplog.text
|
||||
|
||||
|
|
|
|||
0
tests/server/mount/__init__.py
Normal file
0
tests/server/mount/__init__.py
Normal file
489
tests/server/mount/test_advanced.py
Normal file
489
tests/server/mount/test_advanced.py
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
"""Advanced mounting scenarios."""
|
||||
|
||||
import pytest
|
||||
from mcp.types import TextContent
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.server.providers import FastMCPProvider
|
||||
from fastmcp.server.providers.wrapped_provider import _WrappedProvider
|
||||
|
||||
|
||||
class TestDynamicChanges:
|
||||
"""Test that changes to mounted servers are reflected dynamically."""
|
||||
|
||||
async def test_adding_tool_after_mounting(self):
|
||||
"""Test that tools added after mounting are accessible."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
# Mount the sub-app before adding any tools
|
||||
main_app.mount(sub_app, "sub")
|
||||
|
||||
# Initially, there should be no tools from sub_app
|
||||
tools = await main_app.list_tools()
|
||||
assert not any(t.name.startswith("sub_") for t in tools)
|
||||
|
||||
# Add a tool to the sub-app after mounting
|
||||
@sub_app.tool
|
||||
def dynamic_tool() -> str:
|
||||
return "Added after mounting"
|
||||
|
||||
# The tool should be accessible through the main app
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "sub_dynamic_tool" for t in tools)
|
||||
|
||||
# Call the dynamically added tool
|
||||
result = await main_app.call_tool("sub_dynamic_tool", {})
|
||||
assert result.structured_content == {"result": "Added after mounting"}
|
||||
|
||||
async def test_removing_tool_after_mounting(self):
|
||||
"""Test that tools removed from mounted servers are no longer accessible."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.tool
|
||||
def temp_tool() -> str:
|
||||
return "Temporary tool"
|
||||
|
||||
# Mount the sub-app
|
||||
main_app.mount(sub_app, "sub")
|
||||
|
||||
# Initially, the tool should be accessible
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "sub_temp_tool" for t in tools)
|
||||
|
||||
# Remove the tool from sub_app using public API
|
||||
sub_app.remove_tool("temp_tool")
|
||||
|
||||
# The tool should no longer be accessible
|
||||
tools = await main_app.list_tools()
|
||||
assert not any(t.name == "sub_temp_tool" for t in tools)
|
||||
|
||||
|
||||
class TestCustomRouteForwarding:
|
||||
"""Test that custom HTTP routes from mounted servers are forwarded."""
|
||||
|
||||
async def test_get_additional_http_routes_empty(self):
|
||||
"""Test _get_additional_http_routes returns empty list for server with no routes."""
|
||||
server = FastMCP("TestServer")
|
||||
routes = server._get_additional_http_routes()
|
||||
assert routes == []
|
||||
|
||||
async def test_get_additional_http_routes_with_custom_route(self):
|
||||
"""Test _get_additional_http_routes returns server's own routes."""
|
||||
server = FastMCP("TestServer")
|
||||
|
||||
@server.custom_route("/test", methods=["GET"])
|
||||
async def test_route(request):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
return JSONResponse({"message": "test"})
|
||||
|
||||
routes = server._get_additional_http_routes()
|
||||
assert len(routes) == 1
|
||||
assert hasattr(routes[0], "path")
|
||||
assert routes[0].path == "/test"
|
||||
|
||||
async def test_mounted_servers_tracking(self):
|
||||
"""Test that providers list tracks mounted servers correctly."""
|
||||
from fastmcp.server.providers.local_provider import LocalProvider
|
||||
|
||||
main_server = FastMCP("MainServer")
|
||||
sub_server1 = FastMCP("SubServer1")
|
||||
sub_server2 = FastMCP("SubServer2")
|
||||
|
||||
@sub_server1.tool
|
||||
def tool1() -> str:
|
||||
return "1"
|
||||
|
||||
@sub_server2.tool
|
||||
def tool2() -> str:
|
||||
return "2"
|
||||
|
||||
# Initially only LocalProvider
|
||||
assert len(main_server.providers) == 1
|
||||
assert isinstance(main_server.providers[0], LocalProvider)
|
||||
|
||||
# Mount first server
|
||||
main_server.mount(sub_server1, "sub1")
|
||||
assert len(main_server.providers) == 2
|
||||
# LocalProvider is at index 0, mounted provider (wrapped) at index 1
|
||||
provider1 = main_server.providers[1]
|
||||
assert isinstance(provider1, _WrappedProvider)
|
||||
assert isinstance(provider1._inner, FastMCPProvider)
|
||||
assert provider1._inner.server == sub_server1
|
||||
|
||||
# Mount second server
|
||||
main_server.mount(sub_server2, "sub2")
|
||||
assert len(main_server.providers) == 3
|
||||
provider2 = main_server.providers[2]
|
||||
assert isinstance(provider2, _WrappedProvider)
|
||||
assert isinstance(provider2._inner, FastMCPProvider)
|
||||
assert provider2._inner.server == sub_server2
|
||||
|
||||
# Verify namespacing is applied by checking tool names
|
||||
tools = await main_server.list_tools()
|
||||
tool_names = {t.name for t in tools}
|
||||
assert tool_names == {"sub1_tool1", "sub2_tool2"}
|
||||
|
||||
async def test_multiple_routes_same_server(self):
|
||||
"""Test that multiple custom routes from same server are all included."""
|
||||
server = FastMCP("TestServer")
|
||||
|
||||
@server.custom_route("/route1", methods=["GET"])
|
||||
async def route1(request):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
return JSONResponse({"message": "route1"})
|
||||
|
||||
@server.custom_route("/route2", methods=["POST"])
|
||||
async def route2(request):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
return JSONResponse({"message": "route2"})
|
||||
|
||||
routes = server._get_additional_http_routes()
|
||||
assert len(routes) == 2
|
||||
route_paths = [route.path for route in routes if hasattr(route, "path")]
|
||||
assert "/route1" in route_paths
|
||||
assert "/route2" in route_paths
|
||||
|
||||
|
||||
class TestDeeplyNestedMount:
|
||||
"""Test deeply nested mount scenarios (3+ levels deep).
|
||||
|
||||
This tests the fix for https://github.com/jlowin/fastmcp/issues/2583
|
||||
where tools/resources/prompts mounted more than 2 levels deep would fail
|
||||
to invoke even though they were correctly listed.
|
||||
"""
|
||||
|
||||
async def test_three_level_nested_tool_invocation(self):
|
||||
"""Test invoking tools from servers mounted 3 levels deep."""
|
||||
root = FastMCP("root")
|
||||
middle = FastMCP("middle")
|
||||
leaf = FastMCP("leaf")
|
||||
|
||||
@leaf.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
@middle.tool
|
||||
def multiply(a: int, b: int) -> int:
|
||||
return a * b
|
||||
|
||||
middle.mount(leaf, namespace="leaf")
|
||||
root.mount(middle, namespace="middle")
|
||||
|
||||
# Tool at level 2 should work
|
||||
result = await root.call_tool("middle_multiply", {"a": 3, "b": 4})
|
||||
assert result.structured_content == {"result": 12}
|
||||
|
||||
# Tool at level 3 should also work (this was the bug)
|
||||
result = await root.call_tool("middle_leaf_add", {"a": 5, "b": 7})
|
||||
assert result.structured_content == {"result": 12}
|
||||
|
||||
async def test_three_level_nested_resource_invocation(self):
|
||||
"""Test reading resources from servers mounted 3 levels deep."""
|
||||
root = FastMCP("root")
|
||||
middle = FastMCP("middle")
|
||||
leaf = FastMCP("leaf")
|
||||
|
||||
@leaf.resource("leaf://data")
|
||||
def leaf_data() -> str:
|
||||
return "leaf data"
|
||||
|
||||
@middle.resource("middle://data")
|
||||
def middle_data() -> str:
|
||||
return "middle data"
|
||||
|
||||
middle.mount(leaf, namespace="leaf")
|
||||
root.mount(middle, namespace="middle")
|
||||
|
||||
# Resource at level 2 should work
|
||||
result = await root.read_resource("middle://middle/data")
|
||||
assert result.contents[0].content == "middle data"
|
||||
|
||||
# Resource at level 3 should also work
|
||||
result = await root.read_resource("leaf://middle/leaf/data")
|
||||
assert result.contents[0].content == "leaf data"
|
||||
|
||||
async def test_three_level_nested_resource_template_invocation(self):
|
||||
"""Test reading resource templates from servers mounted 3 levels deep."""
|
||||
root = FastMCP("root")
|
||||
middle = FastMCP("middle")
|
||||
leaf = FastMCP("leaf")
|
||||
|
||||
@leaf.resource("leaf://item/{id}")
|
||||
def leaf_item(id: str) -> str:
|
||||
return f"leaf item {id}"
|
||||
|
||||
@middle.resource("middle://item/{id}")
|
||||
def middle_item(id: str) -> str:
|
||||
return f"middle item {id}"
|
||||
|
||||
middle.mount(leaf, namespace="leaf")
|
||||
root.mount(middle, namespace="middle")
|
||||
|
||||
# Resource template at level 2 should work
|
||||
result = await root.read_resource("middle://middle/item/42")
|
||||
assert result.contents[0].content == "middle item 42"
|
||||
|
||||
# Resource template at level 3 should also work
|
||||
result = await root.read_resource("leaf://middle/leaf/item/99")
|
||||
assert result.contents[0].content == "leaf item 99"
|
||||
|
||||
async def test_three_level_nested_prompt_invocation(self):
|
||||
"""Test getting prompts from servers mounted 3 levels deep."""
|
||||
root = FastMCP("root")
|
||||
middle = FastMCP("middle")
|
||||
leaf = FastMCP("leaf")
|
||||
|
||||
@leaf.prompt
|
||||
def leaf_prompt(name: str) -> str:
|
||||
return f"Hello from leaf: {name}"
|
||||
|
||||
@middle.prompt
|
||||
def middle_prompt(name: str) -> str:
|
||||
return f"Hello from middle: {name}"
|
||||
|
||||
middle.mount(leaf, namespace="leaf")
|
||||
root.mount(middle, namespace="middle")
|
||||
|
||||
# Prompt at level 2 should work
|
||||
result = await root.render_prompt("middle_middle_prompt", {"name": "World"})
|
||||
assert isinstance(result.messages[0].content, TextContent)
|
||||
assert "Hello from middle: World" in result.messages[0].content.text
|
||||
|
||||
# Prompt at level 3 should also work
|
||||
result = await root.render_prompt("middle_leaf_leaf_prompt", {"name": "Test"})
|
||||
assert isinstance(result.messages[0].content, TextContent)
|
||||
assert "Hello from leaf: Test" in result.messages[0].content.text
|
||||
|
||||
async def test_four_level_nested_tool_invocation(self):
|
||||
"""Test invoking tools from servers mounted 4 levels deep."""
|
||||
root = FastMCP("root")
|
||||
level1 = FastMCP("level1")
|
||||
level2 = FastMCP("level2")
|
||||
level3 = FastMCP("level3")
|
||||
|
||||
@level3.tool
|
||||
def deep_tool() -> str:
|
||||
return "very deep"
|
||||
|
||||
level2.mount(level3, namespace="l3")
|
||||
level1.mount(level2, namespace="l2")
|
||||
root.mount(level1, namespace="l1")
|
||||
|
||||
# Verify tool is listed
|
||||
tools = await root.list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "l1_l2_l3_deep_tool" in tool_names
|
||||
|
||||
# Tool at level 4 should work
|
||||
result = await root.call_tool("l1_l2_l3_deep_tool", {})
|
||||
assert result.structured_content == {"result": "very deep"}
|
||||
|
||||
|
||||
class TestToolNameOverrides:
|
||||
"""Test tool and prompt name overrides in mount() (issue #2596)."""
|
||||
|
||||
async def test_tool_names_override_via_transforms(self):
|
||||
"""Test that tool_names renames tools via ToolTransform layer.
|
||||
|
||||
Tool renames are applied first, then namespace prefixing.
|
||||
So original_tool → custom_name → prefix_custom_name.
|
||||
"""
|
||||
sub = FastMCP("Sub")
|
||||
|
||||
@sub.tool
|
||||
def original_tool() -> str:
|
||||
return "test"
|
||||
|
||||
main = FastMCP("Main")
|
||||
# tool_names renames first, then namespace is applied
|
||||
main.mount(
|
||||
sub,
|
||||
namespace="prefix",
|
||||
tool_names={"original_tool": "custom_name"},
|
||||
)
|
||||
|
||||
# Server introspection shows renamed + namespaced names
|
||||
tools = await main.list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "prefix_custom_name" in tool_names
|
||||
assert "original_tool" not in tool_names
|
||||
assert "prefix_original_tool" not in tool_names
|
||||
assert "custom_name" not in tool_names
|
||||
|
||||
async def test_tool_names_override_applied_in_list_tools(self):
|
||||
"""Test that tool_names override is reflected in list_tools()."""
|
||||
sub = FastMCP("Sub")
|
||||
|
||||
@sub.tool
|
||||
def original_tool() -> str:
|
||||
return "test"
|
||||
|
||||
main = FastMCP("Main")
|
||||
main.mount(
|
||||
sub,
|
||||
namespace="prefix",
|
||||
tool_names={"original_tool": "custom_name"},
|
||||
)
|
||||
|
||||
tools = await main.list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "prefix_custom_name" in tool_names
|
||||
assert "prefix_original_tool" not in tool_names
|
||||
|
||||
async def test_tool_call_with_overridden_name(self):
|
||||
"""Test that overridden tool can be called by its new name."""
|
||||
sub = FastMCP("Sub")
|
||||
|
||||
@sub.tool
|
||||
def original_tool() -> str:
|
||||
return "success"
|
||||
|
||||
main = FastMCP("Main")
|
||||
main.mount(
|
||||
sub,
|
||||
namespace="prefix",
|
||||
tool_names={"original_tool": "renamed"},
|
||||
)
|
||||
|
||||
# Tool is renamed then namespaced: original_tool → renamed → prefix_renamed
|
||||
result = await main.call_tool("prefix_renamed", {})
|
||||
assert result.structured_content == {"result": "success"}
|
||||
|
||||
def test_duplicate_tool_rename_targets_raises_error(self):
|
||||
"""Test that duplicate target names in tool_renames raises ValueError."""
|
||||
sub = FastMCP("Sub")
|
||||
main = FastMCP("Main")
|
||||
|
||||
with pytest.raises(ValueError, match="duplicate target name"):
|
||||
main.mount(
|
||||
sub,
|
||||
tool_names={"tool_a": "same_name", "tool_b": "same_name"},
|
||||
)
|
||||
|
||||
|
||||
class TestMountedServerDocketBehavior:
|
||||
"""Regression tests for mounted server lifecycle behavior.
|
||||
|
||||
These tests guard against architectural changes that could accidentally
|
||||
start Docket instances for mounted servers. Mounted servers should only
|
||||
run their user-defined lifespan, not the full _lifespan_manager which
|
||||
includes Docket creation.
|
||||
"""
|
||||
|
||||
async def test_mounted_server_does_not_have_docket(self):
|
||||
"""Test that a mounted server doesn't create its own Docket.
|
||||
|
||||
MountedProvider.lifespan() should call only the server's _lifespan
|
||||
(user-defined lifespan), not _lifespan_manager (which includes Docket).
|
||||
"""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@main_app.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@sub_app.tool
|
||||
def my_tool() -> str:
|
||||
return "test"
|
||||
|
||||
main_app.mount(sub_app, "sub")
|
||||
|
||||
# After running the main app's lifespan, the sub app should not have
|
||||
# its own Docket instance
|
||||
async with Client(main_app) as client:
|
||||
# The main app should have a docket (created by _lifespan_manager)
|
||||
# because it has a task-enabled component
|
||||
assert main_app.docket is not None
|
||||
|
||||
# The mounted sub app should NOT have its own docket
|
||||
# It uses the parent's docket for background tasks
|
||||
assert sub_app.docket is None
|
||||
|
||||
# But the tool should still work (prefixed as sub_my_tool)
|
||||
result = await client.call_tool("sub_my_tool", {})
|
||||
assert result.data == "test"
|
||||
|
||||
|
||||
class TestComponentServicePrefixLess:
|
||||
"""Test that enable/disable works with prefix-less mounted servers."""
|
||||
|
||||
async def test_enable_tool_prefixless_mount(self):
|
||||
"""Test enabling a tool on a prefix-less mounted server."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.tool
|
||||
def my_tool() -> str:
|
||||
return "test"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Initially the tool is enabled
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "my_tool" for t in tools)
|
||||
|
||||
# Disable and re-enable
|
||||
main_app.disable(names={"my_tool"}, components={"tool"})
|
||||
# Verify tool is now disabled
|
||||
tools = await main_app.list_tools()
|
||||
assert not any(t.name == "my_tool" for t in tools)
|
||||
|
||||
main_app.enable(names={"my_tool"}, components={"tool"})
|
||||
# Verify tool is now enabled
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "my_tool" for t in tools)
|
||||
|
||||
async def test_enable_resource_prefixless_mount(self):
|
||||
"""Test enabling a resource on a prefix-less mounted server."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.resource(uri="data://test")
|
||||
def my_resource() -> str:
|
||||
return "test data"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Disable and re-enable
|
||||
main_app.disable(names={"data://test"}, components={"resource"})
|
||||
# Verify resource is now disabled
|
||||
resources = await main_app.list_resources()
|
||||
assert not any(str(r.uri) == "data://test" for r in resources)
|
||||
|
||||
main_app.enable(names={"data://test"}, components={"resource"})
|
||||
# Verify resource is now enabled
|
||||
resources = await main_app.list_resources()
|
||||
assert any(str(r.uri) == "data://test" for r in resources)
|
||||
|
||||
async def test_enable_prompt_prefixless_mount(self):
|
||||
"""Test enabling a prompt on a prefix-less mounted server."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.prompt
|
||||
def my_prompt() -> str:
|
||||
return "test prompt"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Disable and re-enable
|
||||
main_app.disable(names={"my_prompt"}, components={"prompt"})
|
||||
# Verify prompt is now disabled
|
||||
prompts = await main_app.list_prompts()
|
||||
assert not any(p.name == "my_prompt" for p in prompts)
|
||||
|
||||
main_app.enable(names={"my_prompt"}, components={"prompt"})
|
||||
# Verify prompt is now enabled
|
||||
prompts = await main_app.list_prompts()
|
||||
assert any(p.name == "my_prompt" for p in prompts)
|
||||
97
tests/server/mount/test_filtering.py
Normal file
97
tests/server/mount/test_filtering.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Tests for tag filtering in mounted servers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
|
||||
|
||||
class TestParentTagFiltering:
|
||||
"""Test that parent server tag filters apply recursively to mounted servers."""
|
||||
|
||||
async def test_parent_include_tags_filters_mounted_tools(self):
|
||||
"""Test that parent include_tags filters out non-matching mounted tools."""
|
||||
parent = FastMCP("Parent", include_tags={"allowed"})
|
||||
mounted = FastMCP("Mounted")
|
||||
|
||||
@mounted.tool(tags={"allowed"})
|
||||
def allowed_tool() -> str:
|
||||
return "allowed"
|
||||
|
||||
@mounted.tool(tags={"blocked"})
|
||||
def blocked_tool() -> str:
|
||||
return "blocked"
|
||||
|
||||
parent.mount(mounted)
|
||||
|
||||
tools = await parent.list_tools()
|
||||
tool_names = {t.name for t in tools}
|
||||
assert "allowed_tool" in tool_names
|
||||
assert "blocked_tool" not in tool_names
|
||||
|
||||
# Verify execution also respects filters
|
||||
result = await parent.call_tool("allowed_tool", {})
|
||||
assert result.structured_content == {"result": "allowed"}
|
||||
|
||||
with pytest.raises(NotFoundError, match="Unknown tool"):
|
||||
await parent.call_tool("blocked_tool", {})
|
||||
|
||||
async def test_parent_exclude_tags_filters_mounted_tools(self):
|
||||
"""Test that parent exclude_tags filters out matching mounted tools."""
|
||||
parent = FastMCP("Parent", exclude_tags={"blocked"})
|
||||
mounted = FastMCP("Mounted")
|
||||
|
||||
@mounted.tool(tags={"production"})
|
||||
def production_tool() -> str:
|
||||
return "production"
|
||||
|
||||
@mounted.tool(tags={"blocked"})
|
||||
def blocked_tool() -> str:
|
||||
return "blocked"
|
||||
|
||||
parent.mount(mounted)
|
||||
|
||||
tools = await parent.list_tools()
|
||||
tool_names = {t.name for t in tools}
|
||||
assert "production_tool" in tool_names
|
||||
assert "blocked_tool" not in tool_names
|
||||
|
||||
async def test_parent_filters_apply_to_mounted_resources(self):
|
||||
"""Test that parent tag filters apply to mounted resources."""
|
||||
parent = FastMCP("Parent", include_tags={"allowed"})
|
||||
mounted = FastMCP("Mounted")
|
||||
|
||||
@mounted.resource("resource://allowed", tags={"allowed"})
|
||||
def allowed_resource() -> str:
|
||||
return "allowed"
|
||||
|
||||
@mounted.resource("resource://blocked", tags={"blocked"})
|
||||
def blocked_resource() -> str:
|
||||
return "blocked"
|
||||
|
||||
parent.mount(mounted)
|
||||
|
||||
resources = await parent.list_resources()
|
||||
resource_uris = {str(r.uri) for r in resources}
|
||||
assert "resource://allowed" in resource_uris
|
||||
assert "resource://blocked" not in resource_uris
|
||||
|
||||
async def test_parent_filters_apply_to_mounted_prompts(self):
|
||||
"""Test that parent tag filters apply to mounted prompts."""
|
||||
parent = FastMCP("Parent", exclude_tags={"blocked"})
|
||||
mounted = FastMCP("Mounted")
|
||||
|
||||
@mounted.prompt(tags={"allowed"})
|
||||
def allowed_prompt() -> str:
|
||||
return "allowed"
|
||||
|
||||
@mounted.prompt(tags={"blocked"})
|
||||
def blocked_prompt() -> str:
|
||||
return "blocked"
|
||||
|
||||
parent.mount(mounted)
|
||||
|
||||
prompts = await parent.list_prompts()
|
||||
prompt_names = {p.name for p in prompts}
|
||||
assert "allowed_prompt" in prompt_names
|
||||
assert "blocked_prompt" not in prompt_names
|
||||
542
tests/server/mount/test_mount.py
Normal file
542
tests/server/mount/test_mount.py
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
"""Basic mounting functionality tests."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from mcp.types import TextContent
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports import SSETransport
|
||||
from fastmcp.tools.tool import Tool
|
||||
from fastmcp.tools.tool_transform import TransformedTool
|
||||
|
||||
|
||||
class TestBasicMount:
|
||||
"""Test basic mounting functionality."""
|
||||
|
||||
async def test_mount_simple_server(self):
|
||||
"""Test mounting a simple server and accessing its tool."""
|
||||
# Create main app and sub-app
|
||||
main_app = FastMCP("MainApp")
|
||||
|
||||
# Add a tool to the sub-app
|
||||
def tool() -> str:
|
||||
return "This is from the sub app"
|
||||
|
||||
sub_tool = Tool.from_function(tool)
|
||||
|
||||
transformed_tool = TransformedTool.from_tool(
|
||||
name="transformed_tool", tool=sub_tool
|
||||
)
|
||||
|
||||
sub_app = FastMCP("SubApp", tools=[transformed_tool, sub_tool])
|
||||
|
||||
# Mount the sub-app to the main app
|
||||
main_app.mount(sub_app, "sub")
|
||||
|
||||
# Get tools from main app, should include sub_app's tools
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "sub_tool" for t in tools)
|
||||
assert any(t.name == "sub_transformed_tool" for t in tools)
|
||||
|
||||
result = await main_app.call_tool("sub_tool", {})
|
||||
assert result.structured_content == {"result": "This is from the sub app"}
|
||||
|
||||
async def test_mount_with_custom_separator(self):
|
||||
"""Test mounting with a custom tool separator (deprecated but still supported)."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# Mount without custom separator - custom separators are deprecated
|
||||
main_app.mount(sub_app, "sub")
|
||||
|
||||
# Tool should be accessible with the default separator
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "sub_greet" for t in tools)
|
||||
|
||||
# Call the tool
|
||||
result = await main_app.call_tool("sub_greet", {"name": "World"})
|
||||
assert result.structured_content == {"result": "Hello, World!"}
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["", None])
|
||||
async def test_mount_with_no_prefix(self, prefix):
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.tool
|
||||
def sub_tool() -> str:
|
||||
return "This is from the sub app"
|
||||
|
||||
# Mount with empty prefix but without deprecated separators
|
||||
main_app.mount(sub_app, namespace=prefix)
|
||||
|
||||
tools = await main_app.list_tools()
|
||||
# With empty prefix, the tool should keep its original name
|
||||
assert any(t.name == "sub_tool" for t in tools)
|
||||
|
||||
async def test_mount_with_no_prefix_provided(self):
|
||||
"""Test mounting without providing a prefix at all."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.tool
|
||||
def sub_tool() -> str:
|
||||
return "This is from the sub app"
|
||||
|
||||
# Mount without providing a prefix (should be None)
|
||||
main_app.mount(sub_app)
|
||||
|
||||
tools = await main_app.list_tools()
|
||||
# Without prefix, the tool should keep its original name
|
||||
assert any(t.name == "sub_tool" for t in tools)
|
||||
|
||||
# Call the tool to verify it works
|
||||
result = await main_app.call_tool("sub_tool", {})
|
||||
assert result.structured_content == {"result": "This is from the sub app"}
|
||||
|
||||
async def test_mount_tools_no_prefix(self):
|
||||
"""Test mounting a server with tools without prefix."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.tool
|
||||
def sub_tool() -> str:
|
||||
return "Sub tool result"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Verify tool is accessible with original name
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "sub_tool" for t in tools)
|
||||
|
||||
# Test actual functionality
|
||||
tool_result = await main_app.call_tool("sub_tool", {})
|
||||
assert tool_result.structured_content == {"result": "Sub tool result"}
|
||||
|
||||
async def test_mount_resources_no_prefix(self):
|
||||
"""Test mounting a server with resources without prefix."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.resource(uri="data://config")
|
||||
def sub_resource():
|
||||
return "Sub resource data"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Verify resource is accessible with original URI
|
||||
resources = await main_app.list_resources()
|
||||
assert any(str(r.uri) == "data://config" for r in resources)
|
||||
|
||||
# Test actual functionality
|
||||
resource_result = await main_app.read_resource("data://config")
|
||||
assert resource_result.contents[0].content == "Sub resource data"
|
||||
|
||||
async def test_mount_resource_templates_no_prefix(self):
|
||||
"""Test mounting a server with resource templates without prefix."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.resource(uri="users://{user_id}/info")
|
||||
def sub_template(user_id: str):
|
||||
return f"Sub template for user {user_id}"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Verify template is accessible with original URI template
|
||||
templates = await main_app.list_resource_templates()
|
||||
assert any(t.uri_template == "users://{user_id}/info" for t in templates)
|
||||
|
||||
# Test actual functionality
|
||||
template_result = await main_app.read_resource("users://123/info")
|
||||
assert template_result.contents[0].content == "Sub template for user 123"
|
||||
|
||||
async def test_mount_prompts_no_prefix(self):
|
||||
"""Test mounting a server with prompts without prefix."""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.prompt
|
||||
def sub_prompt() -> str:
|
||||
return "Sub prompt content"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Verify prompt is accessible with original name
|
||||
prompts = await main_app.list_prompts()
|
||||
assert any(p.name == "sub_prompt" for p in prompts)
|
||||
|
||||
# Test actual functionality
|
||||
prompt_result = await main_app.render_prompt("sub_prompt")
|
||||
assert prompt_result.messages is not None
|
||||
|
||||
|
||||
class TestMultipleServerMount:
|
||||
"""Test mounting multiple servers simultaneously."""
|
||||
|
||||
async def test_mount_multiple_servers(self):
|
||||
"""Test mounting multiple servers with different prefixes."""
|
||||
main_app = FastMCP("MainApp")
|
||||
weather_app = FastMCP("WeatherApp")
|
||||
news_app = FastMCP("NewsApp")
|
||||
|
||||
@weather_app.tool
|
||||
def get_forecast() -> str:
|
||||
return "Weather forecast"
|
||||
|
||||
@news_app.tool
|
||||
def get_headlines() -> str:
|
||||
return "News headlines"
|
||||
|
||||
# Mount both apps
|
||||
main_app.mount(weather_app, "weather")
|
||||
main_app.mount(news_app, "news")
|
||||
|
||||
# Check both are accessible
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "weather_get_forecast" for t in tools)
|
||||
assert any(t.name == "news_get_headlines" for t in tools)
|
||||
|
||||
# Call tools from both mounted servers
|
||||
result1 = await main_app.call_tool("weather_get_forecast", {})
|
||||
assert result1.structured_content == {"result": "Weather forecast"}
|
||||
result2 = await main_app.call_tool("news_get_headlines", {})
|
||||
assert result2.structured_content == {"result": "News headlines"}
|
||||
|
||||
async def test_mount_same_prefix(self):
|
||||
"""Test that mounting with the same prefix replaces the previous mount."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
||||
@first_app.tool
|
||||
def first_tool() -> str:
|
||||
return "First app tool"
|
||||
|
||||
@second_app.tool
|
||||
def second_tool() -> str:
|
||||
return "Second app tool"
|
||||
|
||||
# Mount first app
|
||||
main_app.mount(first_app, "api")
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "api_first_tool" for t in tools)
|
||||
|
||||
# Mount second app with same prefix
|
||||
main_app.mount(second_app, "api")
|
||||
tools = await main_app.list_tools()
|
||||
|
||||
# Both apps' tools should be accessible (new behavior)
|
||||
assert any(t.name == "api_first_tool" for t in tools)
|
||||
assert any(t.name == "api_second_tool" for t in tools)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="Windows asyncio networking timeouts."
|
||||
)
|
||||
async def test_mount_with_unreachable_proxy_servers(self, caplog):
|
||||
"""Test graceful handling when multiple mounted servers fail to connect."""
|
||||
caplog.set_level(logging.DEBUG, logger="fastmcp")
|
||||
|
||||
main_app = FastMCP("MainApp")
|
||||
working_app = FastMCP("WorkingApp")
|
||||
|
||||
@working_app.tool
|
||||
def working_tool() -> str:
|
||||
return "Working tool"
|
||||
|
||||
@working_app.resource(uri="working://data")
|
||||
def working_resource():
|
||||
return "Working resource"
|
||||
|
||||
@working_app.prompt
|
||||
def working_prompt() -> str:
|
||||
return "Working prompt"
|
||||
|
||||
# Mount the working server
|
||||
main_app.mount(working_app, "working")
|
||||
|
||||
# Use an unreachable port
|
||||
unreachable_client = Client(
|
||||
transport=SSETransport("http://127.0.0.1:9999/sse/"),
|
||||
name="unreachable_client",
|
||||
)
|
||||
|
||||
# Create a proxy server that will fail to connect
|
||||
unreachable_proxy = FastMCP.as_proxy(
|
||||
unreachable_client, name="unreachable_proxy"
|
||||
)
|
||||
|
||||
# Mount the unreachable proxy
|
||||
main_app.mount(unreachable_proxy, "unreachable")
|
||||
|
||||
# All object types should work from working server despite unreachable proxy
|
||||
async with Client(main_app, name="main_app_client") as client:
|
||||
# Test tools
|
||||
tools = await client.list_tools()
|
||||
tool_names = [tool.name for tool in tools]
|
||||
assert "working_working_tool" in tool_names
|
||||
|
||||
# Test calling a tool
|
||||
result = await client.call_tool("working_working_tool", {})
|
||||
assert result.data == "Working tool"
|
||||
|
||||
# Test resources
|
||||
resources = await client.list_resources()
|
||||
resource_uris = [str(resource.uri) for resource in resources]
|
||||
assert "working://working/data" in resource_uris
|
||||
|
||||
# Test prompts
|
||||
prompts = await client.list_prompts()
|
||||
prompt_names = [prompt.name for prompt in prompts]
|
||||
assert "working_working_prompt" in prompt_names
|
||||
|
||||
# Verify that errors were logged for the unreachable provider (at DEBUG level)
|
||||
debug_messages = [
|
||||
record.message for record in caplog.records if record.levelname == "DEBUG"
|
||||
]
|
||||
assert any(
|
||||
"Error during list_tools from provider" in msg for msg in debug_messages
|
||||
)
|
||||
assert any(
|
||||
"Error during list_resources from provider" in msg for msg in debug_messages
|
||||
)
|
||||
assert any(
|
||||
"Error during list_prompts from provider" in msg for msg in debug_messages
|
||||
)
|
||||
|
||||
|
||||
class TestPrefixConflictResolution:
|
||||
"""Test that first registered provider wins when there are conflicts.
|
||||
|
||||
Provider semantics: 'Providers are queried in registration order; first non-None wins'
|
||||
"""
|
||||
|
||||
async def test_first_server_wins_tools_no_prefix(self):
|
||||
"""Test that first mounted server wins for tools when no prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
||||
@first_app.tool(name="shared_tool")
|
||||
def first_shared_tool() -> str:
|
||||
return "First app tool"
|
||||
|
||||
@second_app.tool(name="shared_tool")
|
||||
def second_shared_tool() -> str:
|
||||
return "Second app tool"
|
||||
|
||||
# Mount both apps without prefix
|
||||
main_app.mount(first_app)
|
||||
main_app.mount(second_app)
|
||||
|
||||
# list_tools returns all components; execution uses first match
|
||||
tools = await main_app.list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "shared_tool" in tool_names
|
||||
|
||||
# Test that calling the tool uses the first server's implementation
|
||||
result = await main_app.call_tool("shared_tool", {})
|
||||
assert result.structured_content == {"result": "First app tool"}
|
||||
|
||||
async def test_first_server_wins_tools_same_prefix(self):
|
||||
"""Test that first mounted server wins for tools when same prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
||||
@first_app.tool(name="shared_tool")
|
||||
def first_shared_tool() -> str:
|
||||
return "First app tool"
|
||||
|
||||
@second_app.tool(name="shared_tool")
|
||||
def second_shared_tool() -> str:
|
||||
return "Second app tool"
|
||||
|
||||
# Mount both apps with same prefix
|
||||
main_app.mount(first_app, "api")
|
||||
main_app.mount(second_app, "api")
|
||||
|
||||
# list_tools returns all components; execution uses first match
|
||||
tools = await main_app.list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "api_shared_tool" in tool_names
|
||||
|
||||
# Test that calling the tool uses the first server's implementation
|
||||
result = await main_app.call_tool("api_shared_tool", {})
|
||||
assert result.structured_content == {"result": "First app tool"}
|
||||
|
||||
async def test_first_server_wins_resources_no_prefix(self):
|
||||
"""Test that first mounted server wins for resources when no prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
||||
@first_app.resource(uri="shared://data")
|
||||
def first_resource():
|
||||
return "First app data"
|
||||
|
||||
@second_app.resource(uri="shared://data")
|
||||
def second_resource():
|
||||
return "Second app data"
|
||||
|
||||
# Mount both apps without prefix
|
||||
main_app.mount(first_app)
|
||||
main_app.mount(second_app)
|
||||
|
||||
# list_resources returns all components; execution uses first match
|
||||
resources = await main_app.list_resources()
|
||||
resource_uris = [str(r.uri) for r in resources]
|
||||
assert "shared://data" in resource_uris
|
||||
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await main_app.read_resource("shared://data")
|
||||
assert result.contents[0].content == "First app data"
|
||||
|
||||
async def test_first_server_wins_resources_same_prefix(self):
|
||||
"""Test that first mounted server wins for resources when same prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
||||
@first_app.resource(uri="shared://data")
|
||||
def first_resource():
|
||||
return "First app data"
|
||||
|
||||
@second_app.resource(uri="shared://data")
|
||||
def second_resource():
|
||||
return "Second app data"
|
||||
|
||||
# Mount both apps with same prefix
|
||||
main_app.mount(first_app, "api")
|
||||
main_app.mount(second_app, "api")
|
||||
|
||||
# list_resources returns all components; execution uses first match
|
||||
resources = await main_app.list_resources()
|
||||
resource_uris = [str(r.uri) for r in resources]
|
||||
assert "shared://api/data" in resource_uris
|
||||
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await main_app.read_resource("shared://api/data")
|
||||
assert result.contents[0].content == "First app data"
|
||||
|
||||
async def test_first_server_wins_resource_templates_no_prefix(self):
|
||||
"""Test that first mounted server wins for resource templates when no prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
||||
@first_app.resource(uri="users://{user_id}/profile")
|
||||
def first_template(user_id: str):
|
||||
return f"First app user {user_id}"
|
||||
|
||||
@second_app.resource(uri="users://{user_id}/profile")
|
||||
def second_template(user_id: str):
|
||||
return f"Second app user {user_id}"
|
||||
|
||||
# Mount both apps without prefix
|
||||
main_app.mount(first_app)
|
||||
main_app.mount(second_app)
|
||||
|
||||
# list_resource_templates returns all components; execution uses first match
|
||||
templates = await main_app.list_resource_templates()
|
||||
template_uris = [t.uri_template for t in templates]
|
||||
assert "users://{user_id}/profile" in template_uris
|
||||
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await main_app.read_resource("users://123/profile")
|
||||
assert result.contents[0].content == "First app user 123"
|
||||
|
||||
async def test_first_server_wins_resource_templates_same_prefix(self):
|
||||
"""Test that first mounted server wins for resource templates when same prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
||||
@first_app.resource(uri="users://{user_id}/profile")
|
||||
def first_template(user_id: str):
|
||||
return f"First app user {user_id}"
|
||||
|
||||
@second_app.resource(uri="users://{user_id}/profile")
|
||||
def second_template(user_id: str):
|
||||
return f"Second app user {user_id}"
|
||||
|
||||
# Mount both apps with same prefix
|
||||
main_app.mount(first_app, "api")
|
||||
main_app.mount(second_app, "api")
|
||||
|
||||
# list_resource_templates returns all components; execution uses first match
|
||||
templates = await main_app.list_resource_templates()
|
||||
template_uris = [t.uri_template for t in templates]
|
||||
assert "users://api/{user_id}/profile" in template_uris
|
||||
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await main_app.read_resource("users://api/123/profile")
|
||||
assert result.contents[0].content == "First app user 123"
|
||||
|
||||
async def test_first_server_wins_prompts_no_prefix(self):
|
||||
"""Test that first mounted server wins for prompts when no prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
||||
@first_app.prompt(name="shared_prompt")
|
||||
def first_shared_prompt() -> str:
|
||||
return "First app prompt"
|
||||
|
||||
@second_app.prompt(name="shared_prompt")
|
||||
def second_shared_prompt() -> str:
|
||||
return "Second app prompt"
|
||||
|
||||
# Mount both apps without prefix
|
||||
main_app.mount(first_app)
|
||||
main_app.mount(second_app)
|
||||
|
||||
# list_prompts returns all components; execution uses first match
|
||||
prompts = await main_app.list_prompts()
|
||||
prompt_names = [p.name for p in prompts]
|
||||
assert "shared_prompt" in prompt_names
|
||||
|
||||
# Test that getting the prompt uses the first server's implementation
|
||||
result = await main_app.render_prompt("shared_prompt")
|
||||
assert result.messages is not None
|
||||
assert isinstance(result.messages[0].content, TextContent)
|
||||
assert result.messages[0].content.text == "First app prompt"
|
||||
|
||||
async def test_first_server_wins_prompts_same_prefix(self):
|
||||
"""Test that first mounted server wins for prompts when same prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
||||
@first_app.prompt(name="shared_prompt")
|
||||
def first_shared_prompt() -> str:
|
||||
return "First app prompt"
|
||||
|
||||
@second_app.prompt(name="shared_prompt")
|
||||
def second_shared_prompt() -> str:
|
||||
return "Second app prompt"
|
||||
|
||||
# Mount both apps with same prefix
|
||||
main_app.mount(first_app, "api")
|
||||
main_app.mount(second_app, "api")
|
||||
|
||||
# list_prompts returns all components; execution uses first match
|
||||
prompts = await main_app.list_prompts()
|
||||
prompt_names = [p.name for p in prompts]
|
||||
assert "api_shared_prompt" in prompt_names
|
||||
|
||||
# Test that getting the prompt uses the first server's implementation
|
||||
result = await main_app.render_prompt("api_shared_prompt")
|
||||
assert result.messages is not None
|
||||
assert isinstance(result.messages[0].content, TextContent)
|
||||
assert result.messages[0].content.text == "First app prompt"
|
||||
50
tests/server/mount/test_prompts.py
Normal file
50
tests/server/mount/test_prompts.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Tests for prompt mounting."""
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
class TestPrompts:
|
||||
"""Test mounting with prompts."""
|
||||
|
||||
async def test_mount_with_prompts(self):
|
||||
"""Test mounting a server with prompts."""
|
||||
main_app = FastMCP("MainApp")
|
||||
assistant_app = FastMCP("AssistantApp")
|
||||
|
||||
@assistant_app.prompt
|
||||
def greeting(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# Mount the assistant app
|
||||
main_app.mount(assistant_app, "assistant")
|
||||
|
||||
# Prompt should be accessible through main app
|
||||
prompts = await main_app.list_prompts()
|
||||
assert any(p.name == "assistant_greeting" for p in prompts)
|
||||
|
||||
# Render the prompt
|
||||
result = await main_app.render_prompt("assistant_greeting", {"name": "World"})
|
||||
assert result.messages is not None
|
||||
# The message should contain our greeting text
|
||||
|
||||
async def test_adding_prompt_after_mounting(self):
|
||||
"""Test adding a prompt after mounting."""
|
||||
main_app = FastMCP("MainApp")
|
||||
assistant_app = FastMCP("AssistantApp")
|
||||
|
||||
# Mount the assistant app before adding prompts
|
||||
main_app.mount(assistant_app, "assistant")
|
||||
|
||||
# Add a prompt after mounting
|
||||
@assistant_app.prompt
|
||||
def farewell(name: str) -> str:
|
||||
return f"Goodbye, {name}!"
|
||||
|
||||
# Prompt should be accessible through main app
|
||||
prompts = await main_app.list_prompts()
|
||||
assert any(p.name == "assistant_farewell" for p in prompts)
|
||||
|
||||
# Render the prompt
|
||||
result = await main_app.render_prompt("assistant_farewell", {"name": "World"})
|
||||
assert result.messages is not None
|
||||
# The message should contain our farewell text
|
||||
306
tests/server/mount/test_proxy.py
Normal file
306
tests/server/mount/test_proxy.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"""Tests for proxy server mounting."""
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports import FastMCPTransport
|
||||
from fastmcp.server.providers import FastMCPProvider
|
||||
from fastmcp.server.providers.proxy import FastMCPProxy
|
||||
from fastmcp.server.providers.wrapped_provider import _WrappedProvider
|
||||
from fastmcp.server.transforms import Namespace
|
||||
|
||||
|
||||
class TestProxyServer:
|
||||
"""Test mounting a proxy server."""
|
||||
|
||||
async def test_mount_proxy_server(self):
|
||||
"""Test mounting a proxy server."""
|
||||
# Create original server
|
||||
original_server = FastMCP("OriginalServer")
|
||||
|
||||
@original_server.tool
|
||||
def get_data(query: str) -> str:
|
||||
return f"Data for {query}"
|
||||
|
||||
# Create proxy server
|
||||
proxy_server = FastMCP.as_proxy(FastMCPTransport(original_server))
|
||||
|
||||
# Mount proxy server
|
||||
main_app = FastMCP("MainApp")
|
||||
main_app.mount(proxy_server, "proxy")
|
||||
|
||||
# Tool should be accessible through main app
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "proxy_get_data" for t in tools)
|
||||
|
||||
# Call the tool
|
||||
result = await main_app.call_tool("proxy_get_data", {"query": "test"})
|
||||
assert result.structured_content == {"result": "Data for test"}
|
||||
|
||||
async def test_dynamically_adding_to_proxied_server(self):
|
||||
"""Test that changes to the original server are reflected in the mounted proxy."""
|
||||
# Create original server
|
||||
original_server = FastMCP("OriginalServer")
|
||||
|
||||
# Create proxy server
|
||||
proxy_server = FastMCP.as_proxy(FastMCPTransport(original_server))
|
||||
|
||||
# Mount proxy server
|
||||
main_app = FastMCP("MainApp")
|
||||
main_app.mount(proxy_server, "proxy")
|
||||
|
||||
# Add a tool to the original server
|
||||
@original_server.tool
|
||||
def dynamic_data() -> str:
|
||||
return "Dynamic data"
|
||||
|
||||
# Tool should be accessible through main app via proxy
|
||||
tools = await main_app.list_tools()
|
||||
assert any(t.name == "proxy_dynamic_data" for t in tools)
|
||||
|
||||
# Call the tool
|
||||
result = await main_app.call_tool("proxy_dynamic_data", {})
|
||||
assert result.structured_content == {"result": "Dynamic data"}
|
||||
|
||||
async def test_proxy_server_with_resources(self):
|
||||
"""Test mounting a proxy server with resources."""
|
||||
# Create original server
|
||||
original_server = FastMCP("OriginalServer")
|
||||
|
||||
@original_server.resource(uri="config://settings")
|
||||
def get_config() -> str:
|
||||
return json.dumps({"api_key": "12345"})
|
||||
|
||||
# Create proxy server
|
||||
proxy_server = FastMCP.as_proxy(FastMCPTransport(original_server))
|
||||
|
||||
# Mount proxy server
|
||||
main_app = FastMCP("MainApp")
|
||||
main_app.mount(proxy_server, "proxy")
|
||||
|
||||
# Resource should be accessible through main app
|
||||
result = await main_app.read_resource("config://proxy/settings")
|
||||
assert len(result.contents) == 1
|
||||
config = json.loads(result.contents[0].content)
|
||||
assert config["api_key"] == "12345"
|
||||
|
||||
async def test_proxy_server_with_prompts(self):
|
||||
"""Test mounting a proxy server with prompts."""
|
||||
# Create original server
|
||||
original_server = FastMCP("OriginalServer")
|
||||
|
||||
@original_server.prompt
|
||||
def welcome(name: str) -> str:
|
||||
return f"Welcome, {name}!"
|
||||
|
||||
# Create proxy server
|
||||
proxy_server = FastMCP.as_proxy(FastMCPTransport(original_server))
|
||||
|
||||
# Mount proxy server
|
||||
main_app = FastMCP("MainApp")
|
||||
main_app.mount(proxy_server, "proxy")
|
||||
|
||||
# Prompt should be accessible through main app
|
||||
result = await main_app.render_prompt("proxy_welcome", {"name": "World"})
|
||||
assert result.messages is not None
|
||||
# The message should contain our welcome text
|
||||
|
||||
|
||||
class TestAsProxyKwarg:
|
||||
"""Test the as_proxy kwarg."""
|
||||
|
||||
async def test_as_proxy_defaults_false(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
|
||||
@sub.tool
|
||||
def sub_tool() -> str:
|
||||
return "test"
|
||||
|
||||
mcp.mount(sub, "sub")
|
||||
# Index 1 because LocalProvider is at index 0
|
||||
provider = mcp.providers[1]
|
||||
# Provider is wrapped with Namespace transform
|
||||
assert isinstance(provider, _WrappedProvider)
|
||||
assert len(provider._transforms) == 1
|
||||
assert isinstance(provider._transforms[0], Namespace)
|
||||
# Inner provider is FastMCPProvider
|
||||
assert isinstance(provider._inner, FastMCPProvider)
|
||||
assert provider._inner.server is sub
|
||||
# Verify namespace is applied
|
||||
tools = await mcp.list_tools()
|
||||
assert {t.name for t in tools} == {"sub_sub_tool"}
|
||||
|
||||
async def test_as_proxy_false(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
|
||||
@sub.tool
|
||||
def sub_tool() -> str:
|
||||
return "test"
|
||||
|
||||
mcp.mount(sub, "sub", as_proxy=False)
|
||||
|
||||
# Index 1 because LocalProvider is at index 0
|
||||
provider = mcp.providers[1]
|
||||
# Provider is wrapped with Namespace transform
|
||||
assert isinstance(provider, _WrappedProvider)
|
||||
assert len(provider._transforms) == 1
|
||||
assert isinstance(provider._transforms[0], Namespace)
|
||||
# Inner provider is FastMCPProvider
|
||||
assert isinstance(provider._inner, FastMCPProvider)
|
||||
assert provider._inner.server is sub
|
||||
# Verify namespace is applied
|
||||
tools = await mcp.list_tools()
|
||||
assert {t.name for t in tools} == {"sub_sub_tool"}
|
||||
|
||||
async def test_as_proxy_true(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
|
||||
@sub.tool
|
||||
def sub_tool() -> str:
|
||||
return "test"
|
||||
|
||||
mcp.mount(sub, "sub", as_proxy=True)
|
||||
|
||||
# Index 1 because LocalProvider is at index 0
|
||||
provider = mcp.providers[1]
|
||||
# Provider is wrapped with Namespace transform
|
||||
assert isinstance(provider, _WrappedProvider)
|
||||
assert len(provider._transforms) == 1
|
||||
assert isinstance(provider._transforms[0], Namespace)
|
||||
# Inner provider is FastMCPProvider wrapping a proxy
|
||||
assert isinstance(provider._inner, FastMCPProvider)
|
||||
assert provider._inner.server is not sub
|
||||
assert isinstance(provider._inner.server, FastMCPProxy)
|
||||
# Verify namespace is applied
|
||||
tools = await mcp.list_tools()
|
||||
assert {t.name for t in tools} == {"sub_sub_tool"}
|
||||
|
||||
async def test_lifespan_server_mounted_directly(self):
|
||||
"""Test that servers with lifespan are mounted directly (not auto-proxied).
|
||||
|
||||
Since FastMCPProvider now handles lifespan via the provider lifespan interface,
|
||||
there's no need to auto-convert to a proxy. The server is mounted directly.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def server_lifespan(mcp: FastMCP):
|
||||
yield
|
||||
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub", lifespan=server_lifespan)
|
||||
|
||||
@sub.tool
|
||||
def sub_tool() -> str:
|
||||
return "test"
|
||||
|
||||
mcp.mount(sub, "sub")
|
||||
|
||||
# Server should be mounted directly without auto-proxying
|
||||
# Index 1 because LocalProvider is at index 0
|
||||
provider = mcp.providers[1]
|
||||
# Provider is wrapped with Namespace transform
|
||||
assert isinstance(provider, _WrappedProvider)
|
||||
assert len(provider._transforms) == 1
|
||||
assert isinstance(provider._transforms[0], Namespace)
|
||||
# Inner provider is FastMCPProvider
|
||||
assert isinstance(provider._inner, FastMCPProvider)
|
||||
assert provider._inner.server is sub
|
||||
# Verify namespace is applied
|
||||
tools = await mcp.list_tools()
|
||||
assert {t.name for t in tools} == {"sub_sub_tool"}
|
||||
|
||||
async def test_as_proxy_ignored_for_proxy_mounts_default(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
sub_proxy = FastMCP.as_proxy(FastMCPTransport(sub))
|
||||
|
||||
mcp.mount(sub_proxy, "sub")
|
||||
|
||||
# Index 1 because LocalProvider is at index 0
|
||||
provider = mcp.providers[1]
|
||||
# Provider is wrapped with Namespace transform
|
||||
assert isinstance(provider, _WrappedProvider)
|
||||
assert len(provider._transforms) == 1
|
||||
assert isinstance(provider._transforms[0], Namespace)
|
||||
# Inner provider is FastMCPProvider
|
||||
assert isinstance(provider._inner, FastMCPProvider)
|
||||
assert provider._inner.server is sub_proxy
|
||||
|
||||
async def test_as_proxy_ignored_for_proxy_mounts_false(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
sub_proxy = FastMCP.as_proxy(FastMCPTransport(sub))
|
||||
|
||||
mcp.mount(sub_proxy, "sub", as_proxy=False)
|
||||
|
||||
# Index 1 because LocalProvider is at index 0
|
||||
provider = mcp.providers[1]
|
||||
# Provider is wrapped with Namespace transform
|
||||
assert isinstance(provider, _WrappedProvider)
|
||||
assert len(provider._transforms) == 1
|
||||
assert isinstance(provider._transforms[0], Namespace)
|
||||
# Inner provider is FastMCPProvider
|
||||
assert isinstance(provider._inner, FastMCPProvider)
|
||||
assert provider._inner.server is sub_proxy
|
||||
|
||||
async def test_as_proxy_ignored_for_proxy_mounts_true(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
sub_proxy = FastMCP.as_proxy(FastMCPTransport(sub))
|
||||
|
||||
mcp.mount(sub_proxy, "sub", as_proxy=True)
|
||||
|
||||
# Index 1 because LocalProvider is at index 0
|
||||
provider = mcp.providers[1]
|
||||
# Provider is wrapped with Namespace transform
|
||||
assert isinstance(provider, _WrappedProvider)
|
||||
assert len(provider._transforms) == 1
|
||||
assert isinstance(provider._transforms[0], Namespace)
|
||||
# Inner provider is FastMCPProvider
|
||||
assert isinstance(provider._inner, FastMCPProvider)
|
||||
assert provider._inner.server is sub_proxy
|
||||
|
||||
async def test_as_proxy_mounts_still_have_live_link(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
|
||||
mcp.mount(sub, "sub", as_proxy=True)
|
||||
|
||||
assert len(await mcp.list_tools()) == 0
|
||||
|
||||
@sub.tool
|
||||
def hello():
|
||||
return "hi"
|
||||
|
||||
assert len(await mcp.list_tools()) == 1
|
||||
|
||||
async def test_sub_lifespan_is_executed(self):
|
||||
lifespan_check = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(mcp: FastMCP):
|
||||
lifespan_check.append("start")
|
||||
yield
|
||||
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub", lifespan=lifespan)
|
||||
|
||||
@sub.tool
|
||||
def hello():
|
||||
return "hi"
|
||||
|
||||
mcp.mount(sub, as_proxy=True)
|
||||
|
||||
assert lifespan_check == []
|
||||
|
||||
async with Client(mcp) as client:
|
||||
await client.call_tool("hello", {})
|
||||
|
||||
# Lifespan is executed at least once (may be multiple times for proxy connections)
|
||||
assert len(lifespan_check) >= 1
|
||||
assert all(x == "start" for x in lifespan_check)
|
||||
136
tests/server/mount/test_resources.py
Normal file
136
tests/server/mount/test_resources.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""Tests for resource and template mounting."""
|
||||
|
||||
import json
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
class TestResourcesAndTemplates:
|
||||
"""Test mounting with resources and resource templates."""
|
||||
|
||||
async def test_mount_with_resources(self):
|
||||
"""Test mounting a server with resources."""
|
||||
main_app = FastMCP("MainApp")
|
||||
data_app = FastMCP("DataApp")
|
||||
|
||||
@data_app.resource(uri="data://users")
|
||||
async def get_users() -> str:
|
||||
return "user1, user2"
|
||||
|
||||
# Mount the data app
|
||||
main_app.mount(data_app, "data")
|
||||
|
||||
# Resource should be accessible through main app
|
||||
resources = await main_app.list_resources()
|
||||
assert any(str(r.uri) == "data://data/users" for r in resources)
|
||||
|
||||
# Check that resource can be accessed
|
||||
result = await main_app.read_resource("data://data/users")
|
||||
assert len(result.contents) == 1
|
||||
# Note: The function returns "user1, user2" which is not valid JSON
|
||||
# This test should be updated to return proper JSON or check the string directly
|
||||
assert result.contents[0].content == "user1, user2"
|
||||
|
||||
async def test_mount_with_resource_templates(self):
|
||||
"""Test mounting a server with resource templates."""
|
||||
main_app = FastMCP("MainApp")
|
||||
user_app = FastMCP("UserApp")
|
||||
|
||||
@user_app.resource(uri="users://{user_id}/profile")
|
||||
def get_user_profile(user_id: str) -> str:
|
||||
return json.dumps({"id": user_id, "name": f"User {user_id}"})
|
||||
|
||||
# Mount the user app
|
||||
main_app.mount(user_app, "api")
|
||||
|
||||
# Template should be accessible through main app
|
||||
templates = await main_app.list_resource_templates()
|
||||
assert any(t.uri_template == "users://api/{user_id}/profile" for t in templates)
|
||||
|
||||
# Check template instantiation
|
||||
result = await main_app.read_resource("users://api/123/profile")
|
||||
assert len(result.contents) == 1
|
||||
profile = json.loads(result.contents[0].content)
|
||||
assert profile["id"] == "123"
|
||||
assert profile["name"] == "User 123"
|
||||
|
||||
async def test_adding_resource_after_mounting(self):
|
||||
"""Test adding a resource after mounting."""
|
||||
main_app = FastMCP("MainApp")
|
||||
data_app = FastMCP("DataApp")
|
||||
|
||||
# Mount the data app before adding resources
|
||||
main_app.mount(data_app, "data")
|
||||
|
||||
# Add a resource after mounting
|
||||
@data_app.resource(uri="data://config")
|
||||
def get_config() -> str:
|
||||
return json.dumps({"version": "1.0"})
|
||||
|
||||
# Resource should be accessible through main app
|
||||
resources = await main_app.list_resources()
|
||||
assert any(str(r.uri) == "data://data/config" for r in resources)
|
||||
|
||||
# Check access to the resource
|
||||
result = await main_app.read_resource("data://data/config")
|
||||
assert len(result.contents) == 1
|
||||
config = json.loads(result.contents[0].content)
|
||||
assert config["version"] == "1.0"
|
||||
|
||||
|
||||
class TestResourceUriPrefixing:
|
||||
"""Test that resource and resource template URIs get prefixed when mounted (names are NOT prefixed)."""
|
||||
|
||||
async def test_resource_uri_prefixing(self):
|
||||
"""Test that resource URIs are prefixed when mounted (names are NOT prefixed)."""
|
||||
|
||||
# Create a sub-app with a resource
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.resource("resource://my_resource")
|
||||
def my_resource() -> str:
|
||||
return "Resource content"
|
||||
|
||||
# Create main app and mount sub-app with prefix
|
||||
main_app = FastMCP("MainApp")
|
||||
main_app.mount(sub_app, "prefix")
|
||||
|
||||
# Get resources from main app
|
||||
resources = await main_app.list_resources()
|
||||
|
||||
# Should have prefixed key (using path format: resource://prefix/resource_name)
|
||||
assert any(str(r.uri) == "resource://prefix/my_resource" for r in resources)
|
||||
|
||||
# The resource name should NOT be prefixed (only URI is prefixed)
|
||||
resource = next(
|
||||
r for r in resources if str(r.uri) == "resource://prefix/my_resource"
|
||||
)
|
||||
assert resource.name == "my_resource"
|
||||
|
||||
async def test_resource_template_uri_prefixing(self):
|
||||
"""Test that resource template URIs are prefixed when mounted (names are NOT prefixed)."""
|
||||
|
||||
# Create a sub-app with a resource template
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.resource("resource://user/{user_id}")
|
||||
def user_template(user_id: str) -> str:
|
||||
return f"User {user_id} data"
|
||||
|
||||
# Create main app and mount sub-app with prefix
|
||||
main_app = FastMCP("MainApp")
|
||||
main_app.mount(sub_app, "prefix")
|
||||
|
||||
# Get resource templates from main app
|
||||
templates = await main_app.list_resource_templates()
|
||||
|
||||
# Should have prefixed key (using path format: resource://prefix/template_uri)
|
||||
assert any(
|
||||
t.uri_template == "resource://prefix/user/{user_id}" for t in templates
|
||||
)
|
||||
|
||||
# The template name should NOT be prefixed (only URI template is prefixed)
|
||||
template = next(
|
||||
t for t in templates if t.uri_template == "resource://prefix/user/{user_id}"
|
||||
)
|
||||
assert template.name == "user_template"
|
||||
0
tests/server/providers/local_provider_tools/__init__.py
Normal file
0
tests/server/providers/local_provider_tools/__init__.py
Normal file
162
tests/server/providers/local_provider_tools/test_context.py
Normal file
162
tests/server/providers/local_provider_tools/test_context.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Tests for tool context injection."""
|
||||
|
||||
import functools
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
|
||||
def _normalize_anyof_order(schema):
|
||||
"""Normalize the order of items in anyOf arrays for consistent comparison."""
|
||||
if isinstance(schema, dict):
|
||||
if "anyOf" in schema:
|
||||
schema = schema.copy()
|
||||
schema["anyOf"] = sorted(schema["anyOf"], key=str)
|
||||
return {k: _normalize_anyof_order(v) for k, v in schema.items()}
|
||||
elif isinstance(schema, list):
|
||||
return [_normalize_anyof_order(item) for item in schema]
|
||||
return schema
|
||||
|
||||
|
||||
class PersonTypedDict(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class PersonModel(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonDataclass:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class TestToolContextInjection:
|
||||
"""Test context injection in tools."""
|
||||
|
||||
async def test_context_detection(self):
|
||||
"""Test that context parameters are properly detected and excluded from schema."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def tool_with_context(x: int, ctx: Context) -> str:
|
||||
return f"Request: {x}"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "tool_with_context"
|
||||
# Context param should not appear in schema
|
||||
assert "ctx" not in tools[0].parameters.get("properties", {})
|
||||
|
||||
async def test_context_injection_basic(self):
|
||||
"""Test that context is properly injected into tool calls."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def tool_with_context(x: int, ctx: Context) -> str:
|
||||
assert isinstance(ctx, Context)
|
||||
return f"Got context with x={x}"
|
||||
|
||||
result = await mcp.call_tool("tool_with_context", {"x": 42})
|
||||
assert result.structured_content == {"result": "Got context with x=42"}
|
||||
|
||||
async def test_async_context(self):
|
||||
"""Test that context works in async functions."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def async_tool(x: int, ctx: Context) -> str:
|
||||
assert isinstance(ctx, Context)
|
||||
return f"Async with x={x}"
|
||||
|
||||
result = await mcp.call_tool("async_tool", {"x": 42})
|
||||
assert result.structured_content == {"result": "Async with x=42"}
|
||||
|
||||
async def test_optional_context(self):
|
||||
"""Test that context is optional."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def no_context(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
result = await mcp.call_tool("no_context", {"x": 21})
|
||||
assert result.structured_content == {"result": 42}
|
||||
|
||||
async def test_context_resource_access(self):
|
||||
"""Test that context can access resources."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("test://data")
|
||||
def test_resource() -> str:
|
||||
return "resource data"
|
||||
|
||||
@mcp.tool
|
||||
async def tool_with_resource(ctx: Context) -> str:
|
||||
result = await ctx.read_resource("test://data")
|
||||
assert len(result.contents) == 1
|
||||
r = result.contents[0]
|
||||
return f"Read resource: {r.content} with mime type {r.mime_type}"
|
||||
|
||||
result = await mcp.call_tool("tool_with_resource", {})
|
||||
assert result.structured_content == {
|
||||
"result": "Read resource: resource data with mime type text/plain"
|
||||
}
|
||||
|
||||
async def test_tool_decorator_with_tags(self):
|
||||
"""Test that the tool decorator properly sets tags."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(tags={"example", "test-tag"})
|
||||
def sample_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].tags == {"example", "test-tag"}
|
||||
|
||||
async def test_callable_object_with_context(self):
|
||||
"""Test that a callable object can be used as a tool with context."""
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyTool:
|
||||
async def __call__(self, x: int, ctx: Context) -> int:
|
||||
assert isinstance(ctx, Context)
|
||||
return x + 1
|
||||
|
||||
mcp.add_tool(Tool.from_function(MyTool(), name="MyTool"))
|
||||
|
||||
result = await mcp.call_tool("MyTool", {"x": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
async def test_decorated_tool_with_functools_wraps(self):
|
||||
"""Regression test for #2524: @mcp.tool with functools.wraps decorator."""
|
||||
|
||||
def custom_decorator(func):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
@custom_decorator
|
||||
async def decorated_tool(ctx: Context, query: str) -> str:
|
||||
assert isinstance(ctx, Context)
|
||||
return f"query: {query}"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "decorated_tool")
|
||||
assert "ctx" not in tool.parameters.get("properties", {})
|
||||
|
||||
result = await mcp.call_tool("decorated_tool", {"query": "test"})
|
||||
assert result.structured_content == {"result": "query: test"}
|
||||
341
tests/server/providers/local_provider_tools/test_decorator.py
Normal file
341
tests/server/providers/local_provider_tools/test_decorator.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"""Tests for tool decorator patterns."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
|
||||
def _normalize_anyof_order(schema):
|
||||
"""Normalize the order of items in anyOf arrays for consistent comparison."""
|
||||
if isinstance(schema, dict):
|
||||
if "anyOf" in schema:
|
||||
schema = schema.copy()
|
||||
schema["anyOf"] = sorted(schema["anyOf"], key=str)
|
||||
return {k: _normalize_anyof_order(v) for k, v in schema.items()}
|
||||
elif isinstance(schema, list):
|
||||
return [_normalize_anyof_order(item) for item in schema]
|
||||
return schema
|
||||
|
||||
|
||||
class PersonTypedDict(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class PersonModel(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonDataclass:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class TestToolDecorator:
|
||||
async def test_no_tools_before_decorator(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(NotFoundError, match="Unknown tool: 'add'"):
|
||||
await mcp.call_tool("add", {"x": 1, "y": 2})
|
||||
|
||||
async def test_tool_decorator(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
async def test_tool_decorator_without_parentheses(self):
|
||||
"""Test that @tool decorator works without parentheses."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert any(t.name == "add" for t in tools)
|
||||
|
||||
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
async def test_tool_decorator_with_name(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(name="custom-add")
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
result = await mcp.call_tool("custom-add", {"x": 1, "y": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
async def test_tool_decorator_with_description(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(description="Add two numbers")
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
tool = tools[0]
|
||||
assert tool.description == "Add two numbers"
|
||||
|
||||
async def test_tool_decorator_instance_method(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
def __init__(self, x: int):
|
||||
self.x = x
|
||||
|
||||
def add(self, y: int) -> int:
|
||||
return self.x + y
|
||||
|
||||
obj = MyClass(10)
|
||||
mcp.add_tool(Tool.from_function(obj.add))
|
||||
result = await mcp.call_tool("add", {"y": 2})
|
||||
assert result.structured_content == {"result": 12}
|
||||
|
||||
async def test_tool_decorator_classmethod(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
x: int = 10
|
||||
|
||||
@classmethod
|
||||
def add(cls, y: int) -> int:
|
||||
return cls.x + y
|
||||
|
||||
mcp.add_tool(Tool.from_function(MyClass.add))
|
||||
result = await mcp.call_tool("add", {"y": 2})
|
||||
assert result.structured_content == {"result": 12}
|
||||
|
||||
async def test_tool_decorator_staticmethod(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@mcp.tool
|
||||
@staticmethod
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
async def test_tool_decorator_async_function(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
async def test_tool_decorator_classmethod_error(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(TypeError, match="classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@mcp.tool
|
||||
@classmethod
|
||||
def add(cls, y: int) -> None:
|
||||
pass
|
||||
|
||||
async def test_tool_decorator_classmethod_async_function(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
x = 10
|
||||
|
||||
@classmethod
|
||||
async def add(cls, y: int) -> int:
|
||||
return cls.x + y
|
||||
|
||||
mcp.add_tool(Tool.from_function(MyClass.add))
|
||||
result = await mcp.call_tool("add", {"y": 2})
|
||||
assert result.structured_content == {"result": 12}
|
||||
|
||||
async def test_tool_decorator_staticmethod_async_function(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@staticmethod
|
||||
async def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
mcp.add_tool(Tool.from_function(MyClass.add))
|
||||
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
async def test_tool_decorator_staticmethod_order(self):
|
||||
"""Test that the recommended decorator order works for static methods"""
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@mcp.tool
|
||||
@staticmethod
|
||||
def add_v1(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
result = await mcp.call_tool("add_v1", {"x": 1, "y": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
async def test_tool_decorator_with_tags(self):
|
||||
"""Test that the tool decorator properly sets tags."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(tags={"example", "test-tag"})
|
||||
def sample_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].tags == {"example", "test-tag"}
|
||||
|
||||
async def test_add_tool_with_custom_name(self):
|
||||
"""Test adding a tool with a custom name using server.add_tool()."""
|
||||
mcp = FastMCP()
|
||||
|
||||
def multiply(a: int, b: int) -> int:
|
||||
"""Multiply two numbers."""
|
||||
return a * b
|
||||
|
||||
mcp.add_tool(Tool.from_function(multiply, name="custom_multiply"))
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert any(t.name == "custom_multiply" for t in tools)
|
||||
|
||||
result = await mcp.call_tool("custom_multiply", {"a": 5, "b": 3})
|
||||
assert result.structured_content == {"result": 15}
|
||||
|
||||
assert not any(t.name == "multiply" for t in tools)
|
||||
|
||||
async def test_tool_with_annotated_arguments(self):
|
||||
"""Test that tools with annotated arguments work correctly."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def add(
|
||||
x: Annotated[int, Field(description="x is an int")],
|
||||
y: Annotated[str, Field(description="y is not an int")],
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "add")
|
||||
assert tool.parameters["properties"]["x"]["description"] == "x is an int"
|
||||
assert tool.parameters["properties"]["y"]["description"] == "y is not an int"
|
||||
|
||||
async def test_tool_with_field_defaults(self):
|
||||
"""Test that tools with annotated arguments work correctly."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def add(
|
||||
x: int = Field(description="x is an int"),
|
||||
y: str = Field(description="y is not an int"),
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "add")
|
||||
assert tool.parameters["properties"]["x"]["description"] == "x is an int"
|
||||
assert tool.parameters["properties"]["y"]["description"] == "y is not an int"
|
||||
|
||||
async def test_tool_direct_function_call(self):
|
||||
"""Test that tools can be registered via direct function call."""
|
||||
from typing import cast
|
||||
|
||||
from fastmcp.tools.function_tool import DecoratedTool
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
def standalone_function(x: int, y: int) -> int:
|
||||
"""A standalone function to be registered."""
|
||||
return x + y
|
||||
|
||||
result_fn = mcp.tool(standalone_function, name="direct_call_tool")
|
||||
|
||||
# In new decorator mode, returns the function with metadata
|
||||
decorated = cast(DecoratedTool, result_fn)
|
||||
assert hasattr(result_fn, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.name == "direct_call_tool"
|
||||
assert result_fn is standalone_function
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "direct_call_tool")
|
||||
# Tool is registered separately, not same object as decorated function
|
||||
assert tool.name == "direct_call_tool"
|
||||
|
||||
result = await mcp.call_tool("direct_call_tool", {"x": 5, "y": 3})
|
||||
assert result.structured_content == {"result": 8}
|
||||
|
||||
async def test_tool_decorator_with_string_name(self):
|
||||
"""Test that @tool("custom_name") syntax works correctly."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool("string_named_tool")
|
||||
def my_function(x: int) -> str:
|
||||
"""A function with a string name."""
|
||||
return f"Result: {x}"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert any(t.name == "string_named_tool" for t in tools)
|
||||
assert not any(t.name == "my_function" for t in tools)
|
||||
|
||||
result = await mcp.call_tool("string_named_tool", {"x": 42})
|
||||
assert result.structured_content == {"result": "Result: 42"}
|
||||
|
||||
async def test_tool_decorator_conflicting_names_error(self):
|
||||
"""Test that providing both positional and keyword name raises an error."""
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match="Cannot specify both a name as first argument and as keyword argument",
|
||||
):
|
||||
|
||||
@mcp.tool("positional_name", name="keyword_name")
|
||||
def my_function(x: int) -> str:
|
||||
return f"Result: {x}"
|
||||
|
||||
async def test_tool_decorator_with_output_schema(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Output schemas must represent object types"
|
||||
):
|
||||
|
||||
@mcp.tool(output_schema={"type": "integer"})
|
||||
def my_function(x: int) -> str:
|
||||
return f"Result: {x}"
|
||||
|
||||
async def test_tool_decorator_with_meta(self):
|
||||
"""Test that meta parameter is passed through the tool decorator."""
|
||||
mcp = FastMCP()
|
||||
|
||||
meta_data = {"version": "1.0", "author": "test"}
|
||||
|
||||
@mcp.tool(meta=meta_data)
|
||||
def multiply(a: int, b: int) -> int:
|
||||
"""Multiply two numbers."""
|
||||
return a * b
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "multiply")
|
||||
|
||||
assert tool.meta == meta_data
|
||||
132
tests/server/providers/local_provider_tools/test_enabled.py
Normal file
132
tests/server/providers/local_provider_tools/test_enabled.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Tests for tool enabled/disabled state."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
|
||||
|
||||
def _normalize_anyof_order(schema):
|
||||
"""Normalize the order of items in anyOf arrays for consistent comparison."""
|
||||
if isinstance(schema, dict):
|
||||
if "anyOf" in schema:
|
||||
schema = schema.copy()
|
||||
schema["anyOf"] = sorted(schema["anyOf"], key=str)
|
||||
return {k: _normalize_anyof_order(v) for k, v in schema.items()}
|
||||
elif isinstance(schema, list):
|
||||
return [_normalize_anyof_order(item) for item in schema]
|
||||
return schema
|
||||
|
||||
|
||||
class PersonTypedDict(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class PersonModel(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonDataclass:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class TestToolEnabled:
|
||||
async def test_toggle_enabled(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def sample_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
# Tool is enabled by default
|
||||
tools = await mcp.list_tools()
|
||||
assert any(t.name == "sample_tool" for t in tools)
|
||||
|
||||
# Disable via server
|
||||
mcp.disable(names={"sample_tool"}, components={"tool"})
|
||||
|
||||
# Tool should not be in list when disabled
|
||||
tools = await mcp.list_tools()
|
||||
assert not any(t.name == "sample_tool" for t in tools)
|
||||
|
||||
# Re-enable via server
|
||||
mcp.enable(names={"sample_tool"}, components={"tool"})
|
||||
tools = await mcp.list_tools()
|
||||
assert any(t.name == "sample_tool" for t in tools)
|
||||
|
||||
async def test_tool_disabled_via_server(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def sample_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
mcp.disable(names={"sample_tool"}, components={"tool"})
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 0
|
||||
|
||||
with pytest.raises(NotFoundError, match="Unknown tool"):
|
||||
await mcp.call_tool("sample_tool", {"x": 5})
|
||||
|
||||
async def test_tool_toggle_enabled(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def sample_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
mcp.disable(names={"sample_tool"}, components={"tool"})
|
||||
mcp.enable(names={"sample_tool"}, components={"tool"})
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
|
||||
async def test_tool_toggle_disabled(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def sample_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
mcp.disable(names={"sample_tool"}, components={"tool"})
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 0
|
||||
|
||||
with pytest.raises(NotFoundError, match="Unknown tool"):
|
||||
await mcp.call_tool("sample_tool", {"x": 5})
|
||||
|
||||
async def test_get_tool_and_disable(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def sample_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
tool = await mcp.get_tool("sample_tool")
|
||||
assert tool is not None
|
||||
|
||||
mcp.disable(names={"sample_tool"}, components={"tool"})
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 0
|
||||
|
||||
with pytest.raises(NotFoundError, match="Unknown tool"):
|
||||
await mcp.call_tool("sample_tool", {"x": 5})
|
||||
|
||||
async def test_cant_call_disabled_tool(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def sample_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
mcp.disable(names={"sample_tool"}, components={"tool"})
|
||||
|
||||
with pytest.raises(NotFoundError, match="Unknown tool"):
|
||||
await mcp.call_tool("sample_tool", {"x": 5})
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
"""Core tool return types and serialization tests."""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.types import (
|
||||
AudioContent,
|
||||
EmbeddedResource,
|
||||
ImageContent,
|
||||
TextContent,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.types import Audio, File, Image
|
||||
|
||||
|
||||
def _normalize_anyof_order(schema):
|
||||
"""Normalize the order of items in anyOf arrays for consistent comparison."""
|
||||
if isinstance(schema, dict):
|
||||
if "anyOf" in schema:
|
||||
schema = schema.copy()
|
||||
schema["anyOf"] = sorted(schema["anyOf"], key=str)
|
||||
return {k: _normalize_anyof_order(v) for k, v in schema.items()}
|
||||
elif isinstance(schema, list):
|
||||
return [_normalize_anyof_order(item) for item in schema]
|
||||
return schema
|
||||
|
||||
|
||||
class PersonTypedDict(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class PersonModel(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonDataclass:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class TestToolReturnTypes:
|
||||
async def test_string(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def string_tool() -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
result = await mcp.call_tool("string_tool", {})
|
||||
assert result.structured_content == {"result": "Hello, world!"}
|
||||
|
||||
async def test_bytes(self, tmp_path: Path):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def bytes_tool() -> bytes:
|
||||
return b"Hello, world!"
|
||||
|
||||
result = await mcp.call_tool("bytes_tool", {})
|
||||
assert result.structured_content == {"result": "Hello, world!"}
|
||||
|
||||
async def test_uuid(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
test_uuid = uuid.uuid4()
|
||||
|
||||
@mcp.tool
|
||||
def uuid_tool() -> uuid.UUID:
|
||||
return test_uuid
|
||||
|
||||
result = await mcp.call_tool("uuid_tool", {})
|
||||
assert result.structured_content == {"result": str(test_uuid)}
|
||||
|
||||
async def test_path(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
test_path = Path("/tmp/test.txt")
|
||||
|
||||
@mcp.tool
|
||||
def path_tool() -> Path:
|
||||
return test_path
|
||||
|
||||
result = await mcp.call_tool("path_tool", {})
|
||||
assert result.structured_content == {"result": str(test_path)}
|
||||
|
||||
async def test_datetime(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
dt = datetime.datetime(2025, 4, 25, 1, 2, 3)
|
||||
|
||||
@mcp.tool
|
||||
def datetime_tool() -> datetime.datetime:
|
||||
return dt
|
||||
|
||||
result = await mcp.call_tool("datetime_tool", {})
|
||||
assert result.structured_content == {"result": dt.isoformat()}
|
||||
|
||||
async def test_image(self, tmp_path: Path):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def image_tool(path: str) -> Image:
|
||||
return Image(path)
|
||||
|
||||
image_path = tmp_path / "test.png"
|
||||
image_path.write_bytes(b"fake png data")
|
||||
|
||||
result = await mcp.call_tool("image_tool", {"path": str(image_path)})
|
||||
assert result.structured_content is None
|
||||
assert isinstance(result.content, list)
|
||||
content = result.content[0]
|
||||
assert isinstance(content, ImageContent)
|
||||
assert content.type == "image"
|
||||
assert content.mimeType == "image/png"
|
||||
decoded = base64.b64decode(content.data)
|
||||
assert decoded == b"fake png data"
|
||||
|
||||
async def test_audio(self, tmp_path: Path):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def audio_tool(path: str) -> Audio:
|
||||
return Audio(path)
|
||||
|
||||
audio_path = tmp_path / "test.wav"
|
||||
audio_path.write_bytes(b"fake wav data")
|
||||
|
||||
result = await mcp.call_tool("audio_tool", {"path": str(audio_path)})
|
||||
assert isinstance(result.content, list)
|
||||
content = result.content[0]
|
||||
assert isinstance(content, AudioContent)
|
||||
assert content.type == "audio"
|
||||
assert content.mimeType == "audio/wav"
|
||||
decoded = base64.b64decode(content.data)
|
||||
assert decoded == b"fake wav data"
|
||||
|
||||
async def test_file(self, tmp_path: Path):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def file_tool(path: str) -> File:
|
||||
return File(path)
|
||||
|
||||
file_path = tmp_path / "test.bin"
|
||||
file_path.write_bytes(b"test file data")
|
||||
|
||||
result = await mcp.call_tool("file_tool", {"path": str(file_path)})
|
||||
assert isinstance(result.content, list)
|
||||
content = result.content[0]
|
||||
assert isinstance(content, EmbeddedResource)
|
||||
assert content.type == "resource"
|
||||
resource = content.resource
|
||||
assert resource.mimeType == "application/octet-stream"
|
||||
assert hasattr(resource, "blob")
|
||||
blob_data = getattr(resource, "blob")
|
||||
decoded = base64.b64decode(blob_data)
|
||||
assert decoded == b"test file data"
|
||||
assert str(resource.uri) == file_path.resolve().as_uri()
|
||||
|
||||
async def test_tool_mixed_content(self, tool_server: FastMCP):
|
||||
result = await tool_server.call_tool("mixed_content_tool", {})
|
||||
assert isinstance(result.content, list)
|
||||
assert len(result.content) == 3
|
||||
content1 = result.content[0]
|
||||
content2 = result.content[1]
|
||||
content3 = result.content[2]
|
||||
assert isinstance(content1, TextContent)
|
||||
assert content1.text == "Hello"
|
||||
assert isinstance(content2, ImageContent)
|
||||
assert content2.mimeType == "application/octet-stream"
|
||||
assert content2.data == "abc"
|
||||
assert isinstance(content3, EmbeddedResource)
|
||||
assert content3.type == "resource"
|
||||
resource = content3.resource
|
||||
assert resource.mimeType == "application/octet-stream"
|
||||
assert hasattr(resource, "blob")
|
||||
blob_data = getattr(resource, "blob")
|
||||
decoded = base64.b64decode(blob_data)
|
||||
assert decoded == b"abc"
|
||||
|
||||
async def test_tool_mixed_list_with_image(
|
||||
self, tool_server: FastMCP, tmp_path: Path
|
||||
):
|
||||
"""Test that lists containing Image objects and other types are handled
|
||||
correctly. Items now preserve their original order."""
|
||||
image_path = tmp_path / "test.png"
|
||||
image_path.write_bytes(b"test image data")
|
||||
|
||||
result = await tool_server.call_tool(
|
||||
"mixed_list_fn", {"image_path": str(image_path)}
|
||||
)
|
||||
assert isinstance(result.content, list)
|
||||
assert len(result.content) == 4
|
||||
content1 = result.content[0]
|
||||
assert isinstance(content1, TextContent)
|
||||
assert content1.text == "text message"
|
||||
content2 = result.content[1]
|
||||
assert isinstance(content2, ImageContent)
|
||||
assert content2.mimeType == "image/png"
|
||||
assert base64.b64decode(content2.data) == b"test image data"
|
||||
content3 = result.content[2]
|
||||
assert isinstance(content3, TextContent)
|
||||
assert json.loads(content3.text) == {"key": "value"}
|
||||
content4 = result.content[3]
|
||||
assert isinstance(content4, TextContent)
|
||||
assert content4.text == "direct content"
|
||||
|
||||
async def test_tool_mixed_list_with_audio(
|
||||
self, tool_server: FastMCP, tmp_path: Path
|
||||
):
|
||||
"""Test that lists containing Audio objects and other types are handled
|
||||
correctly. Items now preserve their original order."""
|
||||
audio_path = tmp_path / "test.wav"
|
||||
audio_path.write_bytes(b"test audio data")
|
||||
|
||||
result = await tool_server.call_tool(
|
||||
"mixed_audio_list_fn", {"audio_path": str(audio_path)}
|
||||
)
|
||||
assert isinstance(result.content, list)
|
||||
assert len(result.content) == 4
|
||||
content1 = result.content[0]
|
||||
assert isinstance(content1, TextContent)
|
||||
assert content1.text == "text message"
|
||||
content2 = result.content[1]
|
||||
assert isinstance(content2, AudioContent)
|
||||
assert content2.mimeType == "audio/wav"
|
||||
assert base64.b64decode(content2.data) == b"test audio data"
|
||||
content3 = result.content[2]
|
||||
assert isinstance(content3, TextContent)
|
||||
assert json.loads(content3.text) == {"key": "value"}
|
||||
content4 = result.content[3]
|
||||
assert isinstance(content4, TextContent)
|
||||
assert content4.text == "direct content"
|
||||
|
||||
async def test_tool_mixed_list_with_file(
|
||||
self, tool_server: FastMCP, tmp_path: Path
|
||||
):
|
||||
"""Test that lists containing File objects and other types are handled
|
||||
correctly. Items now preserve their original order."""
|
||||
file_path = tmp_path / "test.bin"
|
||||
file_path.write_bytes(b"test file data")
|
||||
|
||||
result = await tool_server.call_tool(
|
||||
"mixed_file_list_fn", {"file_path": str(file_path)}
|
||||
)
|
||||
assert isinstance(result.content, list)
|
||||
assert len(result.content) == 4
|
||||
content1 = result.content[0]
|
||||
assert isinstance(content1, TextContent)
|
||||
assert content1.text == "text message"
|
||||
content2 = result.content[1]
|
||||
assert isinstance(content2, EmbeddedResource)
|
||||
assert content2.type == "resource"
|
||||
resource = content2.resource
|
||||
assert resource.mimeType == "application/octet-stream"
|
||||
assert hasattr(resource, "blob")
|
||||
blob_data = getattr(resource, "blob")
|
||||
assert base64.b64decode(blob_data) == b"test file data"
|
||||
content3 = result.content[2]
|
||||
assert isinstance(content3, TextContent)
|
||||
assert json.loads(content3.text) == {"key": "value"}
|
||||
content4 = result.content[3]
|
||||
assert isinstance(content4, TextContent)
|
||||
assert content4.text == "direct content"
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
"""Tests for tool output schemas."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp.types import (
|
||||
TextContent,
|
||||
)
|
||||
from pydantic import AnyUrl, BaseModel, TypeAdapter
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from fastmcp.utilities.json_schema import compress_schema
|
||||
|
||||
|
||||
def _normalize_anyof_order(schema):
|
||||
"""Normalize the order of items in anyOf arrays for consistent comparison."""
|
||||
if isinstance(schema, dict):
|
||||
if "anyOf" in schema:
|
||||
schema = schema.copy()
|
||||
schema["anyOf"] = sorted(schema["anyOf"], key=str)
|
||||
return {k: _normalize_anyof_order(v) for k, v in schema.items()}
|
||||
elif isinstance(schema, list):
|
||||
return [_normalize_anyof_order(item) for item in schema]
|
||||
return schema
|
||||
|
||||
|
||||
class PersonTypedDict(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class PersonModel(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonDataclass:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class TestToolOutputSchema:
|
||||
@pytest.mark.parametrize("annotation", [str, int, float, bool, list, AnyUrl])
|
||||
async def test_simple_output_schema(self, annotation):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def f() -> annotation:
|
||||
return "hello"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
|
||||
type_schema = TypeAdapter(annotation).json_schema()
|
||||
type_schema = compress_schema(type_schema, prune_titles=True)
|
||||
assert tools[0].output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": type_schema},
|
||||
"required": ["result"],
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"annotation",
|
||||
[dict[str, int | str], PersonTypedDict, PersonModel, PersonDataclass],
|
||||
)
|
||||
async def test_structured_output_schema(self, annotation):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def f() -> annotation:
|
||||
return {"name": "John", "age": 30}
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
|
||||
type_schema = compress_schema(
|
||||
TypeAdapter(annotation).json_schema(), prune_titles=True
|
||||
)
|
||||
assert len(tools) == 1
|
||||
|
||||
actual_schema = _normalize_anyof_order(tools[0].output_schema)
|
||||
expected_schema = _normalize_anyof_order(type_schema)
|
||||
assert actual_schema == expected_schema
|
||||
|
||||
async def test_disabled_output_schema_no_structured_content(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(output_schema=None)
|
||||
def f() -> int:
|
||||
return 42
|
||||
|
||||
result = await mcp.call_tool("f", {})
|
||||
assert isinstance(result.content, list)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "42"
|
||||
assert result.structured_content is None
|
||||
|
||||
async def test_manual_structured_content(self):
|
||||
from typing import cast
|
||||
|
||||
from fastmcp.tools.function_tool import DecoratedTool
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def f() -> ToolResult:
|
||||
return ToolResult(
|
||||
content="Hello, world!", structured_content={"message": "Hello, world!"}
|
||||
)
|
||||
|
||||
# In new decorator mode, check metadata instead of attributes
|
||||
from fastmcp.utilities.types import NotSet
|
||||
|
||||
decorated = cast(DecoratedTool, f)
|
||||
assert hasattr(f, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.output_schema is NotSet
|
||||
|
||||
result = await mcp.call_tool("f", {})
|
||||
assert isinstance(result.content, list)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Hello, world!"
|
||||
assert result.structured_content == {"message": "Hello, world!"}
|
||||
|
||||
async def test_output_schema_none(self):
|
||||
"""Test that output_schema=None works correctly."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(output_schema=None)
|
||||
def simple_tool() -> int:
|
||||
return 42
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "simple_tool")
|
||||
assert tool.output_schema is None
|
||||
|
||||
result = await mcp.call_tool("simple_tool", {})
|
||||
assert result.structured_content is None
|
||||
assert isinstance(result.content, list)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "42"
|
||||
|
||||
async def test_output_schema_explicit_object(self):
|
||||
"""Test explicit object output schema."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"greeting": {"type": "string"},
|
||||
"count": {"type": "integer"},
|
||||
},
|
||||
"required": ["greeting"],
|
||||
}
|
||||
)
|
||||
def explicit_tool() -> dict[str, Any]:
|
||||
return {"greeting": "Hello", "count": 42}
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "explicit_tool")
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"greeting": {"type": "string"},
|
||||
"count": {"type": "integer"},
|
||||
},
|
||||
"required": ["greeting"],
|
||||
}
|
||||
assert tool.output_schema == expected_schema
|
||||
|
||||
result = await mcp.call_tool("explicit_tool", {})
|
||||
assert result.structured_content == {"greeting": "Hello", "count": 42}
|
||||
|
||||
async def test_output_schema_wrapped_primitive(self):
|
||||
"""Test wrapped primitive output schema."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def primitive_tool() -> str:
|
||||
return "Hello, primitives!"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "primitive_tool")
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert tool.output_schema == expected_schema
|
||||
|
||||
result = await mcp.call_tool("primitive_tool", {})
|
||||
assert result.structured_content == {"result": "Hello, primitives!"}
|
||||
|
||||
async def test_output_schema_complex_type(self):
|
||||
"""Test complex type output schema."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def complex_tool() -> list[dict[str, int]]:
|
||||
return [{"a": 1, "b": 2}, {"c": 3, "d": 4}]
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "complex_tool")
|
||||
expected_inner_schema = compress_schema(
|
||||
TypeAdapter(list[dict[str, int]]).json_schema(), prune_titles=True
|
||||
)
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": expected_inner_schema},
|
||||
"required": ["result"],
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert tool.output_schema == expected_schema
|
||||
|
||||
result = await mcp.call_tool("complex_tool", {})
|
||||
expected_data = [{"a": 1, "b": 2}, {"c": 3, "d": 4}]
|
||||
assert result.structured_content == {"result": expected_data}
|
||||
|
||||
async def test_output_schema_dataclass(self):
|
||||
"""Test dataclass output schema."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
@mcp.tool
|
||||
def dataclass_tool() -> User:
|
||||
return User(name="Alice", age=30)
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "dataclass_tool")
|
||||
expected_schema = compress_schema(
|
||||
TypeAdapter(User).json_schema(), prune_titles=True
|
||||
)
|
||||
assert tool.output_schema == expected_schema
|
||||
assert tool.output_schema and "x-fastmcp-wrap-result" not in tool.output_schema
|
||||
|
||||
result = await mcp.call_tool("dataclass_tool", {})
|
||||
assert result.structured_content == {"name": "Alice", "age": 30}
|
||||
|
||||
async def test_output_schema_mixed_content_types(self):
|
||||
"""Test tools with mixed content and output schemas."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def mixed_output() -> list[Any]:
|
||||
return [
|
||||
"text message",
|
||||
{"structured": "data"},
|
||||
TextContent(type="text", text="direct MCP content"),
|
||||
]
|
||||
|
||||
result = await mcp.call_tool("mixed_output", {})
|
||||
assert isinstance(result.content, list)
|
||||
assert len(result.content) == 3
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "text message"
|
||||
assert isinstance(result.content[1], TextContent)
|
||||
assert result.content[1].text == '{"structured":"data"}'
|
||||
assert isinstance(result.content[2], TextContent)
|
||||
assert result.content[2].text == "direct MCP content"
|
||||
|
||||
async def test_output_schema_serialization_edge_cases(self):
|
||||
"""Test edge cases in output schema serialization."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def edge_case_tool() -> tuple[int, str]:
|
||||
return (42, "hello")
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool = next(t for t in tools if t.name == "edge_case_tool")
|
||||
|
||||
assert tool.output_schema and "x-fastmcp-wrap-result" in tool.output_schema
|
||||
|
||||
result = await mcp.call_tool("edge_case_tool", {})
|
||||
assert result.structured_content == {"result": [42, "hello"]}
|
||||
416
tests/server/providers/local_provider_tools/test_parameters.py
Normal file
416
tests/server/providers/local_provider_tools/test_parameters.py
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
"""Tests for tool parameters and validation."""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import pytest
|
||||
from mcp.types import (
|
||||
ImageContent,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.types import Image
|
||||
|
||||
|
||||
def _normalize_anyof_order(schema):
|
||||
"""Normalize the order of items in anyOf arrays for consistent comparison."""
|
||||
if isinstance(schema, dict):
|
||||
if "anyOf" in schema:
|
||||
schema = schema.copy()
|
||||
schema["anyOf"] = sorted(schema["anyOf"], key=str)
|
||||
return {k: _normalize_anyof_order(v) for k, v in schema.items()}
|
||||
elif isinstance(schema, list):
|
||||
return [_normalize_anyof_order(item) for item in schema]
|
||||
return schema
|
||||
|
||||
|
||||
class PersonTypedDict(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class PersonModel(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonDataclass:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class TestToolParameters:
|
||||
async def test_parameter_descriptions_with_field_annotations(self):
|
||||
mcp = FastMCP("Test Server")
|
||||
|
||||
@mcp.tool
|
||||
def greet(
|
||||
name: Annotated[str, Field(description="The name to greet")],
|
||||
title: Annotated[str, Field(description="Optional title", default="")],
|
||||
) -> str:
|
||||
"""A greeting tool"""
|
||||
return f"Hello {title} {name}"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
tool = tools[0]
|
||||
|
||||
properties = tool.parameters["properties"]
|
||||
assert "name" in properties
|
||||
assert properties["name"]["description"] == "The name to greet"
|
||||
assert "title" in properties
|
||||
assert properties["title"]["description"] == "Optional title"
|
||||
assert properties["title"]["default"] == ""
|
||||
assert tool.parameters["required"] == ["name"]
|
||||
|
||||
async def test_parameter_descriptions_with_field_defaults(self):
|
||||
mcp = FastMCP("Test Server")
|
||||
|
||||
@mcp.tool
|
||||
def greet(
|
||||
name: str = Field(description="The name to greet"),
|
||||
title: str = Field(description="Optional title", default=""),
|
||||
) -> str:
|
||||
"""A greeting tool"""
|
||||
return f"Hello {title} {name}"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
tool = tools[0]
|
||||
|
||||
properties = tool.parameters["properties"]
|
||||
assert "name" in properties
|
||||
assert properties["name"]["description"] == "The name to greet"
|
||||
assert "title" in properties
|
||||
assert properties["title"]["description"] == "Optional title"
|
||||
assert properties["title"]["default"] == ""
|
||||
assert tool.parameters["required"] == ["name"]
|
||||
|
||||
async def test_tool_with_bytes_input(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def process_image(image: bytes) -> Image:
|
||||
return Image(data=image)
|
||||
|
||||
result = await mcp.call_tool("process_image", {"image": b"fake png data"})
|
||||
assert result.structured_content is None
|
||||
assert isinstance(result.content, list)
|
||||
assert isinstance(result.content[0], ImageContent)
|
||||
assert result.content[0].mimeType == "image/png"
|
||||
assert result.content[0].data == base64.b64encode(b"fake png data").decode()
|
||||
|
||||
async def test_tool_with_invalid_input(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def my_tool(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Input should be a valid integer",
|
||||
):
|
||||
await mcp.call_tool("my_tool", {"x": "not an int"})
|
||||
|
||||
async def test_tool_int_coercion(self):
|
||||
"""Test that string ints are coerced by default."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def add_one(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
result = await mcp.call_tool("add_one", {"x": "42"})
|
||||
assert result.structured_content == {"result": 43}
|
||||
|
||||
async def test_tool_bool_coercion(self):
|
||||
"""Test that string bools are coerced by default."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def toggle(flag: bool) -> bool:
|
||||
return not flag
|
||||
|
||||
result = await mcp.call_tool("toggle", {"flag": "true"})
|
||||
assert result.structured_content == {"result": False}
|
||||
|
||||
result = await mcp.call_tool("toggle", {"flag": "false"})
|
||||
assert result.structured_content == {"result": True}
|
||||
|
||||
async def test_annotated_field_validation(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def analyze(x: Annotated[int, Field(ge=1)]) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Input should be greater than or equal to 1",
|
||||
):
|
||||
await mcp.call_tool("analyze", {"x": 0})
|
||||
|
||||
async def test_default_field_validation(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def analyze(x: int = Field(ge=1)) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Input should be greater than or equal to 1",
|
||||
):
|
||||
await mcp.call_tool("analyze", {"x": 0})
|
||||
|
||||
async def test_default_field_is_still_required_if_no_default_specified(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def analyze(x: int = Field()) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(ValidationError, match="missing"):
|
||||
await mcp.call_tool("analyze", {})
|
||||
|
||||
async def test_literal_type_validation_error(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def analyze(x: Literal["a", "b"]) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Input should be 'a' or 'b'",
|
||||
):
|
||||
await mcp.call_tool("analyze", {"x": "c"})
|
||||
|
||||
async def test_literal_type_validation_success(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def analyze(x: Literal["a", "b"]) -> str:
|
||||
return x
|
||||
|
||||
result = await mcp.call_tool("analyze", {"x": "a"})
|
||||
assert result.structured_content == {"result": "a"}
|
||||
|
||||
async def test_enum_type_validation_error(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyEnum(Enum):
|
||||
RED = "red"
|
||||
GREEN = "green"
|
||||
BLUE = "blue"
|
||||
|
||||
@mcp.tool
|
||||
def analyze(x: MyEnum) -> str:
|
||||
return x.value
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Input should be 'red', 'green' or 'blue'",
|
||||
):
|
||||
await mcp.call_tool("analyze", {"x": "some-color"})
|
||||
|
||||
async def test_enum_type_validation_success(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyEnum(Enum):
|
||||
RED = "red"
|
||||
GREEN = "green"
|
||||
BLUE = "blue"
|
||||
|
||||
@mcp.tool
|
||||
def analyze(x: MyEnum) -> str:
|
||||
return x.value
|
||||
|
||||
result = await mcp.call_tool("analyze", {"x": "red"})
|
||||
assert result.structured_content == {"result": "red"}
|
||||
|
||||
async def test_union_type_validation(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def analyze(x: int | float) -> str:
|
||||
return str(x)
|
||||
|
||||
result = await mcp.call_tool("analyze", {"x": 1})
|
||||
assert result.structured_content == {"result": "1"}
|
||||
|
||||
result = await mcp.call_tool("analyze", {"x": 1.0})
|
||||
assert result.structured_content == {"result": "1.0"}
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Input should be a valid",
|
||||
):
|
||||
await mcp.call_tool("analyze", {"x": "not a number"})
|
||||
|
||||
async def test_path_type(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_path(path: Path) -> str:
|
||||
assert isinstance(path, Path)
|
||||
return str(path)
|
||||
|
||||
test_path = Path("tmp") / "test.txt"
|
||||
|
||||
result = await mcp.call_tool("send_path", {"path": str(test_path)})
|
||||
assert result.structured_content == {"result": str(test_path)}
|
||||
|
||||
async def test_path_type_error(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_path(path: Path) -> str:
|
||||
return str(path)
|
||||
|
||||
with pytest.raises(ValidationError, match="Input is not a valid path"):
|
||||
await mcp.call_tool("send_path", {"path": 1})
|
||||
|
||||
async def test_uuid_type(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_uuid(x: uuid.UUID) -> str:
|
||||
assert isinstance(x, uuid.UUID)
|
||||
return str(x)
|
||||
|
||||
test_uuid = uuid.uuid4()
|
||||
|
||||
result = await mcp.call_tool("send_uuid", {"x": test_uuid})
|
||||
assert result.structured_content == {"result": str(test_uuid)}
|
||||
|
||||
async def test_uuid_type_error(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_uuid(x: uuid.UUID) -> str:
|
||||
return str(x)
|
||||
|
||||
with pytest.raises(ValidationError, match="Input should be a valid UUID"):
|
||||
await mcp.call_tool("send_uuid", {"x": "not a uuid"})
|
||||
|
||||
async def test_datetime_type(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_datetime(x: datetime.datetime) -> str:
|
||||
return x.isoformat()
|
||||
|
||||
dt = datetime.datetime(2025, 4, 25, 1, 2, 3)
|
||||
|
||||
result = await mcp.call_tool("send_datetime", {"x": dt})
|
||||
assert result.structured_content == {"result": dt.isoformat()}
|
||||
|
||||
async def test_datetime_type_parse_string(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_datetime(x: datetime.datetime) -> str:
|
||||
return x.isoformat()
|
||||
|
||||
result = await mcp.call_tool("send_datetime", {"x": "2021-01-01T00:00:00"})
|
||||
assert result.structured_content == {"result": "2021-01-01T00:00:00"}
|
||||
|
||||
async def test_datetime_type_error(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_datetime(x: datetime.datetime) -> str:
|
||||
return x.isoformat()
|
||||
|
||||
with pytest.raises(ValidationError, match="Input should be a valid datetime"):
|
||||
await mcp.call_tool("send_datetime", {"x": "not a datetime"})
|
||||
|
||||
async def test_date_type(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_date(x: datetime.date) -> str:
|
||||
return x.isoformat()
|
||||
|
||||
result = await mcp.call_tool("send_date", {"x": datetime.date.today()})
|
||||
assert result.structured_content == {
|
||||
"result": datetime.date.today().isoformat()
|
||||
}
|
||||
|
||||
async def test_date_type_parse_string(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_date(x: datetime.date) -> str:
|
||||
return x.isoformat()
|
||||
|
||||
result = await mcp.call_tool("send_date", {"x": "2021-01-01"})
|
||||
assert result.structured_content == {"result": "2021-01-01"}
|
||||
|
||||
async def test_timedelta_type(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_timedelta(x: datetime.timedelta) -> str:
|
||||
return str(x)
|
||||
|
||||
result = await mcp.call_tool(
|
||||
"send_timedelta", {"x": datetime.timedelta(days=1)}
|
||||
)
|
||||
assert result.structured_content == {"result": "1 day, 0:00:00"}
|
||||
|
||||
async def test_timedelta_type_parse_int(self):
|
||||
"""Test that int input is coerced to timedelta (seconds)."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def send_timedelta(x: datetime.timedelta) -> str:
|
||||
return str(x)
|
||||
|
||||
result = await mcp.call_tool("send_timedelta", {"x": 1000})
|
||||
assert result.structured_content is not None
|
||||
result_str = result.structured_content["result"]
|
||||
assert (
|
||||
"0:16:40" in result_str or "16:40" in result_str
|
||||
) # 1000 seconds = 16 minutes 40 seconds
|
||||
|
||||
async def test_annotated_string_description(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def f(x: Annotated[int, "A number"]):
|
||||
return x
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].parameters["properties"]["x"]["description"] == "A number"
|
||||
94
tests/server/providers/local_provider_tools/test_tags.py
Normal file
94
tests/server/providers/local_provider_tools/test_tags.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Tests for tool tags."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
|
||||
|
||||
def _normalize_anyof_order(schema):
|
||||
"""Normalize the order of items in anyOf arrays for consistent comparison."""
|
||||
if isinstance(schema, dict):
|
||||
if "anyOf" in schema:
|
||||
schema = schema.copy()
|
||||
schema["anyOf"] = sorted(schema["anyOf"], key=str)
|
||||
return {k: _normalize_anyof_order(v) for k, v in schema.items()}
|
||||
elif isinstance(schema, list):
|
||||
return [_normalize_anyof_order(item) for item in schema]
|
||||
return schema
|
||||
|
||||
|
||||
class PersonTypedDict(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class PersonModel(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonDataclass:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class TestToolTags:
|
||||
def create_server(self, include_tags=None, exclude_tags=None):
|
||||
mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
|
||||
|
||||
@mcp.tool(tags={"a", "b"})
|
||||
def tool_1() -> int:
|
||||
return 1
|
||||
|
||||
@mcp.tool(tags={"b", "c"})
|
||||
def tool_2() -> int:
|
||||
return 2
|
||||
|
||||
return mcp
|
||||
|
||||
async def test_include_tags_all_tools(self):
|
||||
mcp = self.create_server(include_tags={"a", "b"})
|
||||
tools = await mcp.list_tools()
|
||||
assert {t.name for t in tools} == {"tool_1", "tool_2"}
|
||||
|
||||
async def test_include_tags_some_tools(self):
|
||||
mcp = self.create_server(include_tags={"a", "z"})
|
||||
tools = await mcp.list_tools()
|
||||
assert {t.name for t in tools} == {"tool_1"}
|
||||
|
||||
async def test_exclude_tags_all_tools(self):
|
||||
mcp = self.create_server(exclude_tags={"a", "b"})
|
||||
tools = await mcp.list_tools()
|
||||
assert {t.name for t in tools} == set()
|
||||
|
||||
async def test_exclude_tags_some_tools(self):
|
||||
mcp = self.create_server(exclude_tags={"a", "z"})
|
||||
tools = await mcp.list_tools()
|
||||
assert {t.name for t in tools} == {"tool_2"}
|
||||
|
||||
async def test_exclude_precedence(self):
|
||||
mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})
|
||||
tools = await mcp.list_tools()
|
||||
assert {t.name for t in tools} == {"tool_2"}
|
||||
|
||||
async def test_call_included_tool(self):
|
||||
mcp = self.create_server(include_tags={"a"})
|
||||
result_1 = await mcp.call_tool("tool_1", {})
|
||||
assert result_1.structured_content == {"result": 1}
|
||||
|
||||
with pytest.raises(NotFoundError, match="Unknown tool"):
|
||||
await mcp.call_tool("tool_2", {})
|
||||
|
||||
async def test_call_excluded_tool(self):
|
||||
mcp = self.create_server(exclude_tags={"a"})
|
||||
with pytest.raises(NotFoundError, match="Unknown tool"):
|
||||
await mcp.call_tool("tool_1", {})
|
||||
|
||||
result_2 = await mcp.call_tool("tool_2", {})
|
||||
assert result_2.structured_content == {"result": 2}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +0,0 @@
|
|||
"""Shared fixtures for task tests."""
|
||||
|
||||
# Task protocol is now always enabled - no fixture needed
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
0
tests/server/versioning/__init__.py
Normal file
0
tests/server/versioning/__init__.py
Normal file
361
tests/server/versioning/test_calls.py
Normal file
361
tests/server/versioning/test_calls.py
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"""Tests for versioned calls and client version selection."""
|
||||
# ruff: noqa: F811 # Intentional function redefinition for version testing
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.types import TextContent
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.versions import (
|
||||
VersionSpec,
|
||||
)
|
||||
|
||||
|
||||
class TestVersionMixingValidation:
|
||||
"""Tests for versioned/unversioned mixing prevention."""
|
||||
|
||||
async def test_resource_mixing_rejected(self):
|
||||
"""Cannot mix versioned and unversioned resources with the same URI."""
|
||||
import pytest
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("file:///config", version="1.0")
|
||||
def config_v1() -> str:
|
||||
return "v1"
|
||||
|
||||
with pytest.raises(ValueError, match="unversioned.*versioned"):
|
||||
|
||||
@mcp.resource("file:///config")
|
||||
def config_unversioned() -> str:
|
||||
return "unversioned"
|
||||
|
||||
async def test_prompt_mixing_rejected(self):
|
||||
"""Cannot mix versioned and unversioned prompts with the same name."""
|
||||
import pytest
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.prompt
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
with pytest.raises(ValueError, match="versioned.*unversioned"):
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hi, {name}!"
|
||||
|
||||
async def test_multiple_versions_allowed(self):
|
||||
"""Multiple versioned components with same name are allowed."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def calc() -> int:
|
||||
return 1
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def calc() -> int:
|
||||
return 2
|
||||
|
||||
@mcp.tool(version="3.0")
|
||||
def calc() -> int:
|
||||
return 3
|
||||
|
||||
# All versioned - list_tools returns all
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 3
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"1.0", "2.0", "3.0"}
|
||||
|
||||
# get_tool returns highest
|
||||
tool = await mcp.get_tool("calc")
|
||||
assert tool is not None
|
||||
assert tool.version == "3.0"
|
||||
|
||||
|
||||
class TestVersionValidation:
|
||||
"""Tests for version string validation."""
|
||||
|
||||
async def test_version_with_at_symbol_rejected(self):
|
||||
"""Version strings containing '@' should be rejected."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValidationError, match="cannot contain '@'"):
|
||||
|
||||
@mcp.tool(version="1.0@beta")
|
||||
def my_tool() -> str:
|
||||
return "test"
|
||||
|
||||
|
||||
class TestVersionMetadata:
|
||||
"""Tests for version metadata exposure in list operations."""
|
||||
|
||||
async def test_tool_versions_in_meta(self):
|
||||
"""Each version has its own version in metadata."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def add(x: int, y: int) -> int: # noqa: F811
|
||||
return x + y
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def add(x: int, y: int) -> int: # noqa: F811
|
||||
return x + y
|
||||
|
||||
# list_tools returns all versions
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 2
|
||||
|
||||
# Each version has its own version in metadata
|
||||
by_version = {t.version: t for t in tools}
|
||||
assert by_version["1.0"].get_meta()["fastmcp"]["version"] == "1.0"
|
||||
assert by_version["2.0"].get_meta()["fastmcp"]["version"] == "2.0"
|
||||
|
||||
async def test_resource_versions_in_meta(self):
|
||||
"""Each version has its own version in metadata."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("data://config", version="1.0")
|
||||
def config_v1() -> str: # noqa: F811
|
||||
return "v1"
|
||||
|
||||
@mcp.resource("data://config", version="2.0")
|
||||
def config_v2() -> str: # noqa: F811
|
||||
return "v2"
|
||||
|
||||
# list_resources returns all versions
|
||||
resources = await mcp.list_resources()
|
||||
assert len(resources) == 2
|
||||
|
||||
# Each version has its own version in metadata
|
||||
by_version = {r.version: r for r in resources}
|
||||
assert by_version["1.0"].get_meta()["fastmcp"]["version"] == "1.0"
|
||||
assert by_version["2.0"].get_meta()["fastmcp"]["version"] == "2.0"
|
||||
|
||||
async def test_prompt_versions_in_meta(self):
|
||||
"""Each version has its own version in metadata."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def greet() -> str: # noqa: F811
|
||||
return "Hello v1"
|
||||
|
||||
@mcp.prompt(version="2.0")
|
||||
def greet() -> str: # noqa: F811
|
||||
return "Hello v2"
|
||||
|
||||
# list_prompts returns all versions
|
||||
prompts = await mcp.list_prompts()
|
||||
assert len(prompts) == 2
|
||||
|
||||
# Each version has its own version in metadata
|
||||
by_version = {p.version: p for p in prompts}
|
||||
assert by_version["1.0"].get_meta()["fastmcp"]["version"] == "1.0"
|
||||
assert by_version["2.0"].get_meta()["fastmcp"]["version"] == "2.0"
|
||||
|
||||
async def test_unversioned_no_versions_list(self):
|
||||
"""Unversioned components should not have versions list in meta."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def simple() -> str:
|
||||
return "simple"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
|
||||
tool = tools[0]
|
||||
meta = tool.get_meta()
|
||||
assert "versions" not in meta.get("fastmcp", {})
|
||||
|
||||
|
||||
class TestVersionedCalls:
|
||||
"""Tests for calling specific component versions."""
|
||||
|
||||
async def test_call_tool_with_version(self):
|
||||
"""call_tool should use specified version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def calculate(x: int, y: int) -> int: # noqa: F811
|
||||
return x + y
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def calculate(x: int, y: int) -> int: # noqa: F811
|
||||
return x * y
|
||||
|
||||
# Default: highest version (2.0, multiplication)
|
||||
result = await mcp.call_tool("calculate", {"x": 3, "y": 4})
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["result"] == 12
|
||||
|
||||
# Explicit v1.0 (addition)
|
||||
result = await mcp.call_tool(
|
||||
"calculate", {"x": 3, "y": 4}, version=VersionSpec(eq="1.0")
|
||||
)
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["result"] == 7
|
||||
|
||||
# Explicit v2.0 (multiplication)
|
||||
result = await mcp.call_tool(
|
||||
"calculate", {"x": 3, "y": 4}, version=VersionSpec(eq="2.0")
|
||||
)
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["result"] == 12
|
||||
|
||||
async def test_read_resource_with_version(self):
|
||||
"""read_resource should use specified version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("data://config", version="1.0")
|
||||
def config() -> str: # noqa: F811
|
||||
return "config v1"
|
||||
|
||||
@mcp.resource("data://config", version="2.0")
|
||||
def config() -> str: # noqa: F811
|
||||
return "config v2"
|
||||
|
||||
# Default: highest version
|
||||
result = await mcp.read_resource("data://config")
|
||||
assert result.contents[0].content == "config v2"
|
||||
|
||||
# Explicit v1.0
|
||||
result = await mcp.read_resource("data://config", version=VersionSpec(eq="1.0"))
|
||||
assert result.contents[0].content == "config v1"
|
||||
|
||||
async def test_render_prompt_with_version(self):
|
||||
"""render_prompt should use specified version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def greet() -> str: # noqa: F811
|
||||
return "Hello from v1"
|
||||
|
||||
@mcp.prompt(version="2.0")
|
||||
def greet() -> str: # noqa: F811
|
||||
return "Hello from v2"
|
||||
|
||||
# Default: highest version
|
||||
result = await mcp.render_prompt("greet")
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent) and content.text == "Hello from v2"
|
||||
|
||||
# Explicit v1.0
|
||||
result = await mcp.render_prompt("greet", version=VersionSpec(eq="1.0"))
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent) and content.text == "Hello from v1"
|
||||
|
||||
async def test_call_tool_invalid_version_not_found(self):
|
||||
"""Calling with non-existent version should raise NotFoundError."""
|
||||
import pytest
|
||||
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def mytool() -> str:
|
||||
return "v1"
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await mcp.call_tool("mytool", {}, version=VersionSpec(eq="999.0"))
|
||||
|
||||
|
||||
class TestClientVersionSelection:
|
||||
"""Tests for client-side version selection via the version parameter.
|
||||
|
||||
Version selection flows through request-level _meta, not arguments.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
(None, 10), # Default: highest version (2.0) -> 5 * 2
|
||||
("1.0", 6), # v1.0 -> 5 + 1
|
||||
("2.0", 10), # v2.0 -> 5 * 2
|
||||
],
|
||||
)
|
||||
async def test_call_tool_version_selection(
|
||||
self, version: str | None, expected: int
|
||||
):
|
||||
"""Client.call_tool routes to correct version via request meta."""
|
||||
from fastmcp import Client
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def calc(x: int) -> int: # noqa: F811
|
||||
return x + 1
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def calc(x: int) -> int: # noqa: F811
|
||||
return x * 2
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("calc", {"x": 5}, version=version)
|
||||
assert result.data == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
(None, "Hello world from v2"), # Default: highest version
|
||||
("1.0", "Hello world from v1"),
|
||||
("2.0", "Hello world from v2"),
|
||||
],
|
||||
)
|
||||
async def test_get_prompt_version_selection(
|
||||
self, version: str | None, expected: str
|
||||
):
|
||||
"""Client.get_prompt routes to correct version via request meta."""
|
||||
from fastmcp import Client
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def greet(name: str) -> str: # noqa: F811
|
||||
return f"Hello {name} from v1"
|
||||
|
||||
@mcp.prompt(version="2.0")
|
||||
def greet(name: str) -> str: # noqa: F811
|
||||
return f"Hello {name} from v2"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt(
|
||||
"greet", {"name": "world"}, version=version
|
||||
)
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent) and content.text == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
(None, "v2 data"), # Default: highest version
|
||||
("1.0", "v1 data"),
|
||||
("2.0", "v2 data"),
|
||||
],
|
||||
)
|
||||
async def test_read_resource_version_selection(
|
||||
self, version: str | None, expected: str
|
||||
):
|
||||
"""Client.read_resource routes to correct version via request meta."""
|
||||
from fastmcp import Client
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("data://info", version="1.0")
|
||||
def info_v1() -> str: # noqa: F811
|
||||
return "v1 data"
|
||||
|
||||
@mcp.resource("data://info", version="2.0")
|
||||
def info_v2() -> str: # noqa: F811
|
||||
return "v2 data"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("data://info", version=version)
|
||||
assert result[0].text == expected
|
||||
492
tests/server/versioning/test_filtering.py
Normal file
492
tests/server/versioning/test_filtering.py
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
"""Tests for version filtering functionality."""
|
||||
# ruff: noqa: F811 # Intentional function redefinition for version testing
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.versions import (
|
||||
VersionSpec,
|
||||
)
|
||||
|
||||
|
||||
class TestVersionFilter:
|
||||
"""Tests for VersionFilter transform."""
|
||||
|
||||
async def test_version_lt_filters_high_versions(self):
|
||||
"""VersionFilter(version_lt='3.0') hides v3+, shows v1 and v2."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def calc() -> int:
|
||||
return 1
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def calc() -> int:
|
||||
return 2
|
||||
|
||||
@mcp.tool(version="3.0")
|
||||
def calc() -> int:
|
||||
return 3
|
||||
|
||||
# Without filter, list_tools returns all versions
|
||||
tools = await mcp.list_tools()
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"1.0", "2.0", "3.0"}
|
||||
|
||||
# With filter, only v1 and v2 are visible
|
||||
mcp.add_transform(VersionFilter(version_lt="3.0"))
|
||||
tools = await mcp.list_tools()
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"1.0", "2.0"}
|
||||
|
||||
# get_tool returns highest matching version
|
||||
tool = await mcp.get_tool("calc")
|
||||
assert tool is not None
|
||||
assert tool.version == "2.0"
|
||||
|
||||
async def test_version_gte_filters_low_versions(self):
|
||||
"""VersionFilter(version_gte='2.0') hides v1, shows v2 and v3."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def add(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def add(x: int) -> int:
|
||||
return x + 2
|
||||
|
||||
@mcp.tool(version="3.0")
|
||||
def add(x: int) -> int:
|
||||
return x + 3
|
||||
|
||||
mcp.add_transform(VersionFilter(version_gte="2.0"))
|
||||
|
||||
# list_tools shows all matching versions (v2 and v3)
|
||||
tools = await mcp.list_tools()
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"2.0", "3.0"}
|
||||
|
||||
# get_tool returns highest matching version
|
||||
tool = await mcp.get_tool("add")
|
||||
assert tool is not None
|
||||
assert tool.version == "3.0"
|
||||
|
||||
# Can request specific versions in range
|
||||
tool_v2 = await mcp.get_tool("add", VersionSpec(eq="2.0"))
|
||||
assert tool_v2 is not None
|
||||
assert tool_v2.version == "2.0"
|
||||
|
||||
# Cannot request version outside range - returns None
|
||||
assert await mcp.get_tool("add", VersionSpec(eq="1.0")) is None
|
||||
|
||||
async def test_version_range(self):
|
||||
"""VersionFilter(version_gte='2.0', version_lt='3.0') shows only v2.x."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def calc() -> int:
|
||||
return 1
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def calc() -> int:
|
||||
return 2
|
||||
|
||||
@mcp.tool(version="2.5")
|
||||
def calc() -> int:
|
||||
return 25
|
||||
|
||||
@mcp.tool(version="3.0")
|
||||
def calc() -> int:
|
||||
return 3
|
||||
|
||||
mcp.add_transform(VersionFilter(version_gte="2.0", version_lt="3.0"))
|
||||
|
||||
# list_tools shows all versions in range
|
||||
tools = await mcp.list_tools()
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"2.0", "2.5"}
|
||||
|
||||
# get_tool returns highest in range
|
||||
tool = await mcp.get_tool("calc")
|
||||
assert tool is not None
|
||||
assert tool.version == "2.5"
|
||||
|
||||
# Can request specific versions in range
|
||||
tool_v2 = await mcp.get_tool("calc", VersionSpec(eq="2.0"))
|
||||
assert tool_v2 is not None
|
||||
assert tool_v2.version == "2.0"
|
||||
|
||||
# Versions outside range are not accessible - return None
|
||||
assert await mcp.get_tool("calc", VersionSpec(eq="1.0")) is None
|
||||
assert await mcp.get_tool("calc", VersionSpec(eq="3.0")) is None
|
||||
|
||||
async def test_unversioned_always_passes(self):
|
||||
"""Unversioned components pass through any filter."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def unversioned_tool() -> str:
|
||||
return "unversioned"
|
||||
|
||||
@mcp.tool(version="5.0")
|
||||
def versioned_tool() -> str:
|
||||
return "v5"
|
||||
|
||||
# Filter that would exclude v5.0
|
||||
mcp.add_transform(VersionFilter(version_lt="3.0"))
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
names = [t.name for t in tools]
|
||||
assert "unversioned_tool" in names
|
||||
assert "versioned_tool" not in names
|
||||
|
||||
async def test_date_versions(self):
|
||||
"""Works with date-based versions like '2025-01-15'."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="2025-01-01")
|
||||
def report() -> str:
|
||||
return "jan"
|
||||
|
||||
@mcp.tool(version="2025-06-01")
|
||||
def report() -> str:
|
||||
return "jun"
|
||||
|
||||
@mcp.tool(version="2025-12-01")
|
||||
def report() -> str:
|
||||
return "dec"
|
||||
|
||||
# Q1 API: before April
|
||||
mcp.add_transform(VersionFilter(version_lt="2025-04-01"))
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].version == "2025-01-01"
|
||||
|
||||
async def test_get_tool_respects_filter(self):
|
||||
"""get_tool() returns None if highest version is filtered out."""
|
||||
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="5.0")
|
||||
def only_v5() -> str:
|
||||
return "v5"
|
||||
|
||||
mcp.add_transform(VersionFilter(version_lt="3.0"))
|
||||
|
||||
# Tool exists but is filtered out - returns None (use get_tool to apply transforms)
|
||||
assert await mcp.get_tool("only_v5") is None
|
||||
|
||||
async def test_must_specify_at_least_one(self):
|
||||
"""VersionFilter() with no args raises ValueError."""
|
||||
import pytest
|
||||
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
with pytest.raises(ValueError, match="At least one of"):
|
||||
VersionFilter()
|
||||
|
||||
async def test_resources_filtered(self):
|
||||
"""Resources are filtered by version."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("file:///config", version="1.0")
|
||||
def config_v1() -> str:
|
||||
return "v1"
|
||||
|
||||
@mcp.resource("file:///config", version="2.0")
|
||||
def config_v2() -> str:
|
||||
return "v2"
|
||||
|
||||
mcp.add_transform(VersionFilter(version_lt="2.0"))
|
||||
|
||||
resources = await mcp.list_resources()
|
||||
assert len(resources) == 1
|
||||
assert resources[0].version == "1.0"
|
||||
|
||||
async def test_prompts_filtered(self):
|
||||
"""Prompts are filtered by version."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hi {name}"
|
||||
|
||||
@mcp.prompt(version="2.0")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello {name}"
|
||||
|
||||
mcp.add_transform(VersionFilter(version_lt="2.0"))
|
||||
|
||||
prompts = await mcp.list_prompts()
|
||||
assert len(prompts) == 1
|
||||
assert prompts[0].version == "1.0"
|
||||
|
||||
async def test_repr(self):
|
||||
"""Test VersionFilter string representation."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
f1 = VersionFilter(version_lt="3.0")
|
||||
assert repr(f1) == "VersionFilter(version_lt='3.0')"
|
||||
|
||||
f2 = VersionFilter(version_gte="2.0", version_lt="3.0")
|
||||
assert repr(f2) == "VersionFilter(version_gte='2.0', version_lt='3.0')"
|
||||
|
||||
f3 = VersionFilter(version_gte="1.0")
|
||||
assert repr(f3) == "VersionFilter(version_gte='1.0')"
|
||||
|
||||
|
||||
class TestMountedVersionFiltering:
|
||||
"""Tests for version filtering with mounted servers (FastMCPProvider).
|
||||
|
||||
Note: For mounted servers, list_* methods show what the child exposes (already
|
||||
deduplicated to highest version). get_* methods support range filtering via
|
||||
VersionSpec propagation to FastMCPProvider.
|
||||
"""
|
||||
|
||||
async def test_mounted_get_tool_with_range_filter(self):
|
||||
"""FastMCPProvider.get_tool applies range filtering from VersionSpec."""
|
||||
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(version="2.0")
|
||||
def calc() -> int:
|
||||
return 2
|
||||
|
||||
provider = FastMCPProvider(child)
|
||||
|
||||
# Without range spec, should return the tool
|
||||
tool = await provider.get_tool("calc")
|
||||
assert tool is not None
|
||||
assert tool.version == "2.0"
|
||||
|
||||
# With range spec that excludes v2.0, should return None
|
||||
tool = await provider.get_tool("calc", version=VersionSpec(lt="2.0"))
|
||||
assert tool is None
|
||||
|
||||
# With range spec that includes v2.0, should return the tool
|
||||
tool = await provider.get_tool("calc", version=VersionSpec(gte="2.0"))
|
||||
assert tool is not None
|
||||
assert tool.version == "2.0"
|
||||
|
||||
async def test_mounted_get_resource_with_range_filter(self):
|
||||
"""FastMCPProvider.get_resource applies range filtering from VersionSpec."""
|
||||
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.resource("file://data/", version="2.0")
|
||||
def data() -> str:
|
||||
return "data"
|
||||
|
||||
provider = FastMCPProvider(child)
|
||||
|
||||
# Without range spec, should return the resource
|
||||
resource = await provider.get_resource("file://data/")
|
||||
assert resource is not None
|
||||
assert resource.version == "2.0"
|
||||
|
||||
# With range spec that excludes v2.0, should return None
|
||||
resource = await provider.get_resource(
|
||||
"file://data/", version=VersionSpec(lt="2.0")
|
||||
)
|
||||
assert resource is None
|
||||
|
||||
async def test_mounted_get_prompt_with_range_filter(self):
|
||||
"""FastMCPProvider.get_prompt applies range filtering from VersionSpec."""
|
||||
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.prompt(version="2.0")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello {name}"
|
||||
|
||||
provider = FastMCPProvider(child)
|
||||
|
||||
# Without range spec, should return the prompt
|
||||
prompt = await provider.get_prompt("greet")
|
||||
assert prompt is not None
|
||||
assert prompt.version == "2.0"
|
||||
|
||||
# With range spec that excludes v2.0, should return None
|
||||
prompt = await provider.get_prompt("greet", version=VersionSpec(lt="2.0"))
|
||||
assert prompt is None
|
||||
|
||||
async def test_mounted_unversioned_passes_version_filter(self):
|
||||
"""Unversioned components in mounted servers pass through version filters."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool
|
||||
def unversioned_tool() -> str:
|
||||
return "unversioned"
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
parent.add_transform(VersionFilter(version_lt="3.0"))
|
||||
|
||||
# Unversioned should pass through
|
||||
tools = await parent.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "child_unversioned_tool"
|
||||
assert tools[0].version is None
|
||||
|
||||
async def test_version_filter_filters_out_high_mounted_version(self):
|
||||
"""VersionFilter hides mounted components outside the range."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(version="5.0")
|
||||
def high_version_tool() -> int:
|
||||
return 5
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
parent.add_transform(VersionFilter(version_lt="3.0"))
|
||||
|
||||
# v5.0 is outside the filter range, so it should be hidden
|
||||
tools = await parent.list_tools()
|
||||
assert len(tools) == 0
|
||||
|
||||
# get_tool should also return None (respects filter, applies transforms)
|
||||
assert await parent.get_tool("child_high_version_tool") is None
|
||||
|
||||
|
||||
class TestMountedRangeFiltering:
|
||||
"""Tests for version range filtering with mounted servers."""
|
||||
|
||||
async def test_mounted_lower_version_selected_by_filter(self):
|
||||
"""When parent has filter <2.0 and child has v1.0+v3.0, should get v1.0."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(version="1.0")
|
||||
def calc() -> int:
|
||||
return 1
|
||||
|
||||
@child.tool(version="3.0")
|
||||
def calc() -> int:
|
||||
return 3
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
parent.add_transform(VersionFilter(version_lt="2.0"))
|
||||
|
||||
# Should return v1.0 (the highest version that matches <2.0)
|
||||
# Use get_tool to apply transforms
|
||||
tool = await parent.get_tool("child_calc")
|
||||
assert tool is not None
|
||||
assert tool.version == "1.0"
|
||||
|
||||
async def test_explicit_version_honored_within_filter_range(self):
|
||||
"""Explicit version="1.0" request should work within filter range."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(version="1.0")
|
||||
def calc() -> int:
|
||||
return 1
|
||||
|
||||
@child.tool(version="2.0")
|
||||
def calc() -> int:
|
||||
return 2
|
||||
|
||||
@child.tool(version="3.0")
|
||||
def calc() -> int:
|
||||
return 3
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
parent.add_transform(VersionFilter(version_gte="1.0", version_lt="3.0"))
|
||||
|
||||
# Request specific version within range (use get_tool to apply transforms)
|
||||
tool = await parent.get_tool("child_calc", VersionSpec(eq="1.0"))
|
||||
assert tool is not None
|
||||
assert tool.version == "1.0"
|
||||
|
||||
# Request version outside range should return None
|
||||
result = await parent.get_tool("child_calc", VersionSpec(eq="3.0"))
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestUnversionedExemption:
|
||||
"""Tests confirming unversioned components bypass version filters."""
|
||||
|
||||
async def test_unversioned_bypasses_version_filter(self):
|
||||
"""Unversioned components pass through any VersionFilter - by design."""
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def unversioned_tool() -> str:
|
||||
return "unversioned"
|
||||
|
||||
@mcp.tool(version="5.0")
|
||||
def versioned_tool() -> str:
|
||||
return "v5"
|
||||
|
||||
# Filter that would exclude v5.0
|
||||
mcp.add_transform(VersionFilter(version_lt="3.0"))
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
names = [t.name for t in tools]
|
||||
|
||||
# Unversioned passes through (exempt from filtering)
|
||||
assert "unversioned_tool" in names
|
||||
# Versioned is filtered out
|
||||
assert "versioned_tool" not in names
|
||||
|
||||
async def test_unversioned_returned_for_exact_version_request(self):
|
||||
"""Requesting exact version of unversioned tool returns the tool."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def my_tool() -> str:
|
||||
return "unversioned"
|
||||
|
||||
# Even with explicit version request, unversioned tool is returned
|
||||
# (it's the only version that exists, and unversioned matches any spec)
|
||||
tool = await mcp.get_tool("my_tool", VersionSpec(eq="1.0"))
|
||||
assert tool is not None
|
||||
assert tool.version is None
|
||||
|
||||
async def test_unversioned_matches_any_version_spec(self):
|
||||
"""VersionSpec.matches(None) returns True for any spec."""
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
# Unversioned matches exact version specs
|
||||
assert VersionSpec(eq="1.0").matches(None) is True
|
||||
|
||||
# Unversioned matches range specs
|
||||
assert VersionSpec(gte="1.0", lt="3.0").matches(None) is True
|
||||
|
||||
# Unversioned matches open specs
|
||||
assert VersionSpec(lt="5.0").matches(None) is True
|
||||
assert VersionSpec(gte="1.0").matches(None) is True
|
||||
342
tests/server/versioning/test_mounting.py
Normal file
342
tests/server/versioning/test_mounting.py
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
"""Tests for versioning in mounted servers."""
|
||||
# ruff: noqa: F811 # Intentional function redefinition for version testing
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.types import TextContent
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.versions import (
|
||||
VersionSpec,
|
||||
)
|
||||
|
||||
|
||||
class TestVersionSorting:
|
||||
"""Tests for version sorting behavior."""
|
||||
|
||||
async def test_semantic_version_sorting(self):
|
||||
"""Versions should sort semantically, not lexicographically."""
|
||||
mcp = FastMCP()
|
||||
|
||||
# Add versions out of order
|
||||
@mcp.tool(version="1")
|
||||
def count() -> int:
|
||||
return 1
|
||||
|
||||
@mcp.tool(version="10")
|
||||
def count() -> int:
|
||||
return 10
|
||||
|
||||
@mcp.tool(version="2")
|
||||
def count() -> int:
|
||||
return 2
|
||||
|
||||
# list_tools returns all versions
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 3
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"1", "2", "10"}
|
||||
|
||||
# get_tool returns highest (semantic: 10 > 2 > 1)
|
||||
tool = await mcp.get_tool("count")
|
||||
assert tool is not None
|
||||
assert tool.version == "10"
|
||||
|
||||
# call_tool uses highest version
|
||||
result = await mcp.call_tool("count", {})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "10"
|
||||
|
||||
async def test_semver_sorting(self):
|
||||
"""Full semver versions should sort correctly."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.2.3")
|
||||
def info() -> str:
|
||||
return "1.2.3"
|
||||
|
||||
@mcp.tool(version="1.2.10")
|
||||
def info() -> str:
|
||||
return "1.2.10"
|
||||
|
||||
@mcp.tool(version="1.10.1")
|
||||
def info() -> str:
|
||||
return "1.10.1"
|
||||
|
||||
# list_tools returns all versions
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 3
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"1.2.3", "1.2.10", "1.10.1"}
|
||||
|
||||
# get_tool returns highest: 1.10.1 > 1.2.10 > 1.2.3 (semantic)
|
||||
tool = await mcp.get_tool("info")
|
||||
assert tool is not None
|
||||
assert tool.version == "1.10.1"
|
||||
|
||||
async def test_v_prefix_normalized(self):
|
||||
"""Versions with 'v' prefix should compare correctly."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="v1.0")
|
||||
def calc() -> int:
|
||||
return 1
|
||||
|
||||
@mcp.tool(version="v2.0")
|
||||
def calc() -> int:
|
||||
return 2
|
||||
|
||||
# list_tools returns all versions
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 2
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"v1.0", "v2.0"}
|
||||
|
||||
# get_tool returns highest
|
||||
tool = await mcp.get_tool("calc")
|
||||
assert tool is not None
|
||||
assert tool.version == "v2.0"
|
||||
|
||||
|
||||
class TestMountedServerVersioning:
|
||||
"""Tests for versioning in mounted servers (FastMCPProvider)."""
|
||||
|
||||
async def test_mounted_tool_preserves_version(self):
|
||||
"""Mounted tools should preserve their version info."""
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(version="2.0")
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
|
||||
tools = await parent.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "child_add"
|
||||
assert tools[0].version == "2.0"
|
||||
|
||||
async def test_mounted_resource_preserves_version(self):
|
||||
"""Mounted resources should preserve their version info."""
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.resource("file:///config", version="1.5")
|
||||
def config() -> str:
|
||||
return "config data"
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
|
||||
resources = await parent.list_resources()
|
||||
assert len(resources) == 1
|
||||
assert resources[0].version == "1.5"
|
||||
|
||||
async def test_mounted_prompt_preserves_version(self):
|
||||
"""Mounted prompts should preserve their version info."""
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.prompt(version="3.0")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
|
||||
prompts = await parent.list_prompts()
|
||||
assert len(prompts) == 1
|
||||
assert prompts[0].name == "child_greet"
|
||||
assert prompts[0].version == "3.0"
|
||||
|
||||
async def test_mounted_get_tool_with_version(self):
|
||||
"""Should be able to get specific version from mounted server."""
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(version="1.0")
|
||||
def calc() -> int:
|
||||
return 1
|
||||
|
||||
@child.tool(version="2.0")
|
||||
def calc() -> int:
|
||||
return 2
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
|
||||
# Get highest version (default)
|
||||
tool = await parent.get_tool("child_calc")
|
||||
assert tool is not None
|
||||
assert tool.version == "2.0"
|
||||
|
||||
# Get specific version
|
||||
tool_v1 = await parent.get_tool("child_calc", VersionSpec(eq="1.0"))
|
||||
assert tool_v1 is not None
|
||||
assert tool_v1.version == "1.0"
|
||||
|
||||
async def test_mounted_multiple_versions_all_returned(self):
|
||||
"""Mounted server with multiple versions should show all versions."""
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(version="1.0")
|
||||
def my_tool() -> str:
|
||||
return "v1"
|
||||
|
||||
@child.tool(version="3.0")
|
||||
def my_tool() -> str:
|
||||
return "v3"
|
||||
|
||||
@child.tool(version="2.0")
|
||||
def my_tool() -> str:
|
||||
return "v2"
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
|
||||
# list_tools returns all versions
|
||||
tools = await parent.list_tools()
|
||||
assert len(tools) == 3
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"1.0", "2.0", "3.0"}
|
||||
|
||||
# get_tool returns highest
|
||||
tool = await parent.get_tool("child_my_tool")
|
||||
assert tool is not None
|
||||
assert tool.version == "3.0"
|
||||
|
||||
async def test_mounted_call_tool_uses_highest_version(self):
|
||||
"""Calling mounted tool should use highest version."""
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(version="1.0")
|
||||
def double(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
@child.tool(version="2.0")
|
||||
def double(x: int) -> int:
|
||||
return x * 2 + 100 # Different behavior
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
|
||||
result = await parent.call_tool("child_double", {"x": 5})
|
||||
# Should use v2.0 which adds 100
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "110"
|
||||
|
||||
async def test_mounted_tool_wrapper_executes_correct_version(self):
|
||||
"""Calling a specific versioned tool wrapper should execute that version."""
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(version="1.0")
|
||||
def calc(x: int) -> int:
|
||||
return x * 10 # v1.0 multiplies by 10
|
||||
|
||||
@child.tool(version="2.0")
|
||||
def calc(x: int) -> int:
|
||||
return x * 100 # v2.0 multiplies by 100
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
|
||||
# Get the v1.0 wrapper specifically
|
||||
tools = await parent.list_tools()
|
||||
v1_tool = next(
|
||||
t for t in tools if t.name == "child_calc" and t.version == "1.0"
|
||||
)
|
||||
|
||||
# Calling the v1.0 wrapper should execute v1.0's logic
|
||||
result = await v1_tool.run({"x": 5})
|
||||
assert result.content[0].text == "50" # 5 * 10, not 5 * 100
|
||||
|
||||
async def test_mounted_resource_wrapper_reads_correct_version(self):
|
||||
"""Reading a specific versioned resource should read that version."""
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.resource("data:///config", version="1.0")
|
||||
def config_v1() -> str:
|
||||
return "config-v1-content"
|
||||
|
||||
@child.resource("data:///config", version="2.0")
|
||||
def config_v2() -> str:
|
||||
return "config-v2-content"
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
|
||||
# Reading with version=1.0 should read v1.0's content
|
||||
result = await parent.read_resource(
|
||||
"data://child//config", version=VersionSpec(eq="1.0")
|
||||
)
|
||||
assert result.contents[0].content == "config-v1-content"
|
||||
|
||||
# Reading with version=2.0 should read v2.0's content
|
||||
result = await parent.read_resource(
|
||||
"data://child//config", version=VersionSpec(eq="2.0")
|
||||
)
|
||||
assert result.contents[0].content == "config-v2-content"
|
||||
|
||||
async def test_mounted_prompt_wrapper_renders_correct_version(self):
|
||||
"""Rendering a specific versioned prompt should render that version."""
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.prompt(version="1.0")
|
||||
def greeting(name: str) -> str:
|
||||
return f"Hello, {name}!" # v1.0 says Hello
|
||||
|
||||
@child.prompt(version="2.0")
|
||||
def greeting(name: str) -> str:
|
||||
return f"Greetings, {name}!" # v2.0 says Greetings
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, "child")
|
||||
|
||||
# Rendering with version=1.0 should render v1.0's content
|
||||
result = await parent.render_prompt(
|
||||
"child_greeting", {"name": "World"}, version=VersionSpec(eq="1.0")
|
||||
)
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent) and "Hello, World!" in content.text
|
||||
|
||||
# Rendering with version=2.0 should render v2.0's content
|
||||
result = await parent.render_prompt(
|
||||
"child_greeting", {"name": "World"}, version=VersionSpec(eq="2.0")
|
||||
)
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent) and "Greetings, World!" in content.text
|
||||
|
||||
async def test_deeply_nested_version_forwarding(self):
|
||||
"""Verify version is correctly forwarded through multiple mount levels."""
|
||||
level3 = FastMCP("Level3")
|
||||
|
||||
@level3.tool(version="1.0")
|
||||
def calc(x: int) -> int:
|
||||
return x * 10 # v1.0 multiplies by 10
|
||||
|
||||
@level3.tool(version="2.0")
|
||||
def calc(x: int) -> int:
|
||||
return x * 100 # v2.0 multiplies by 100
|
||||
|
||||
level2 = FastMCP("Level2")
|
||||
level2.mount(level3, "l3")
|
||||
|
||||
level1 = FastMCP("Level1")
|
||||
level1.mount(level2, "l2")
|
||||
|
||||
# All versions should be visible through two levels of mounting
|
||||
tools = await level1.list_tools()
|
||||
calc_tools = [t for t in tools if "calc" in t.name]
|
||||
assert len(calc_tools) == 2
|
||||
versions = {t.version for t in calc_tools}
|
||||
assert versions == {"1.0", "2.0"}
|
||||
|
||||
# Get v1.0 wrapper through two levels of mounting
|
||||
v1_tool = next(t for t in tools if "calc" in t.name and t.version == "1.0")
|
||||
|
||||
# Should execute v1.0 logic, not v2.0
|
||||
result = await v1_tool.run({"x": 5})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "50" # 5 * 10, not 5 * 100
|
||||
258
tests/server/versioning/test_versioning.py
Normal file
258
tests/server/versioning/test_versioning.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""Core versioning functionality: VersionKey, utilities, and components."""
|
||||
# ruff: noqa: F811 # Intentional function redefinition for version testing
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.types import TextContent
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.versions import (
|
||||
VersionKey,
|
||||
compare_versions,
|
||||
is_version_greater,
|
||||
)
|
||||
|
||||
|
||||
class TestVersionKey:
|
||||
"""Tests for VersionKey comparison class."""
|
||||
|
||||
def test_none_sorts_lowest(self):
|
||||
"""None (unversioned) should sort lower than any version."""
|
||||
assert VersionKey(None) < VersionKey("1.0")
|
||||
assert VersionKey(None) < VersionKey("0.1")
|
||||
assert VersionKey(None) < VersionKey("anything")
|
||||
|
||||
def test_none_equals_none(self):
|
||||
"""Two None versions should be equal."""
|
||||
assert VersionKey(None) == VersionKey(None)
|
||||
assert not (VersionKey(None) < VersionKey(None))
|
||||
assert not (VersionKey(None) > VersionKey(None))
|
||||
|
||||
def test_pep440_versions_compared_semantically(self):
|
||||
"""Valid PEP 440 versions should compare semantically."""
|
||||
assert VersionKey("1.0") < VersionKey("2.0")
|
||||
assert VersionKey("1.0") < VersionKey("1.1")
|
||||
assert VersionKey("1.9") < VersionKey("1.10") # Semantic, not string
|
||||
assert VersionKey("2") < VersionKey("10") # Semantic, not string
|
||||
|
||||
def test_v_prefix_stripped(self):
|
||||
"""Versions with 'v' prefix should be handled correctly."""
|
||||
assert VersionKey("v1.0") == VersionKey("1.0")
|
||||
assert VersionKey("v2.0") > VersionKey("v1.0")
|
||||
|
||||
def test_string_fallback_for_invalid_versions(self):
|
||||
"""Invalid PEP 440 versions should fall back to string comparison."""
|
||||
# Dates are not valid PEP 440
|
||||
assert VersionKey("2024-01-01") < VersionKey("2025-01-01")
|
||||
# String comparison (lexicographic)
|
||||
assert VersionKey("alpha") < VersionKey("beta")
|
||||
|
||||
def test_pep440_sorts_before_strings(self):
|
||||
"""PEP 440 versions sort before invalid string versions."""
|
||||
# "1.0" is valid PEP 440, "not-semver" is not
|
||||
assert VersionKey("1.0") < VersionKey("not-semver")
|
||||
assert VersionKey("999.0") < VersionKey("aaa") # PEP 440 < string
|
||||
|
||||
def test_repr(self):
|
||||
"""Test string representation."""
|
||||
assert repr(VersionKey("1.0")) == "VersionKey('1.0')"
|
||||
assert repr(VersionKey(None)) == "VersionKey(None)"
|
||||
|
||||
|
||||
class TestVersionFunctions:
|
||||
"""Tests for version comparison functions."""
|
||||
|
||||
def test_compare_versions(self):
|
||||
"""Test compare_versions function."""
|
||||
assert compare_versions("1.0", "2.0") == -1
|
||||
assert compare_versions("2.0", "1.0") == 1
|
||||
assert compare_versions("1.0", "1.0") == 0
|
||||
assert compare_versions(None, "1.0") == -1
|
||||
assert compare_versions("1.0", None) == 1
|
||||
assert compare_versions(None, None) == 0
|
||||
|
||||
def test_is_version_greater(self):
|
||||
"""Test is_version_greater function."""
|
||||
assert is_version_greater("2.0", "1.0")
|
||||
assert not is_version_greater("1.0", "2.0")
|
||||
assert not is_version_greater("1.0", "1.0")
|
||||
assert is_version_greater("1.0", None)
|
||||
assert not is_version_greater(None, "1.0")
|
||||
|
||||
|
||||
class TestComponentVersioning:
|
||||
"""Tests for versioning in FastMCP components."""
|
||||
|
||||
async def test_tool_with_version(self):
|
||||
"""Tool version should be reflected in key."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def my_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "my_tool"
|
||||
assert tools[0].version == "2.0"
|
||||
assert tools[0].key == "tool:my_tool@2.0"
|
||||
|
||||
async def test_tool_without_version(self):
|
||||
"""Tool without version should have @ sentinel in key but empty version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def my_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].version is None
|
||||
# Keys always have @ sentinel for unambiguous parsing
|
||||
assert tools[0].key == "tool:my_tool@"
|
||||
|
||||
async def test_tool_version_as_int(self):
|
||||
"""Tool version as int should be coerced to string."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version=2)
|
||||
def my_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].version == "2"
|
||||
assert tools[0].key == "tool:my_tool@2"
|
||||
|
||||
async def test_tool_version_zero_is_truthy(self):
|
||||
"""Version 0 should become "0" (truthy string), not empty."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version=0)
|
||||
def my_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].version == "0"
|
||||
assert tools[0].key == "tool:my_tool@0" # Not "tool:my_tool@"
|
||||
|
||||
async def test_multiple_tool_versions_all_returned(self):
|
||||
"""list_tools returns all versions; get_tool returns highest."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def add(x: int, y: int, z: int = 0) -> int:
|
||||
return x + y + z
|
||||
|
||||
# list_tools returns all versions
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 2
|
||||
versions = {t.version for t in tools}
|
||||
assert versions == {"1.0", "2.0"}
|
||||
|
||||
# get_tool returns highest version
|
||||
tool = await mcp.get_tool("add")
|
||||
assert tool is not None
|
||||
assert tool.version == "2.0"
|
||||
|
||||
async def test_call_tool_invokes_highest_version(self):
|
||||
"""Calling a tool by name should invoke the highest version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def add(x: int, y: int) -> int:
|
||||
return (x + y) * 10 # Different behavior to distinguish
|
||||
|
||||
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
||||
# Should invoke v2.0 which multiplies by 10
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "30"
|
||||
|
||||
async def test_mixing_versioned_and_unversioned_rejected(self):
|
||||
"""Cannot mix versioned and unversioned tools with the same name."""
|
||||
import pytest
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def my_tool() -> str:
|
||||
return "unversioned"
|
||||
|
||||
# Adding versioned tool when unversioned exists should fail
|
||||
with pytest.raises(ValueError, match="versioned.*unversioned"):
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def my_tool() -> str:
|
||||
return "v1.0"
|
||||
|
||||
async def test_mixing_unversioned_after_versioned_rejected(self):
|
||||
"""Cannot add unversioned tool when versioned exists."""
|
||||
import pytest
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def my_tool() -> str:
|
||||
return "v1.0"
|
||||
|
||||
# Adding unversioned tool when versioned exists should fail
|
||||
with pytest.raises(ValueError, match="unversioned.*versioned"):
|
||||
|
||||
@mcp.tool
|
||||
def my_tool() -> str:
|
||||
return "unversioned"
|
||||
|
||||
async def test_resource_with_version(self):
|
||||
"""Resource version should work like tool version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("file:///config", version="1.0")
|
||||
def config_v1() -> str:
|
||||
return "config v1"
|
||||
|
||||
@mcp.resource("file:///config", version="2.0")
|
||||
def config_v2() -> str:
|
||||
return "config v2"
|
||||
|
||||
# list_resources returns all versions
|
||||
resources = await mcp.list_resources()
|
||||
assert len(resources) == 2
|
||||
versions = {r.version for r in resources}
|
||||
assert versions == {"1.0", "2.0"}
|
||||
|
||||
# get_resource returns highest version
|
||||
resource = await mcp.get_resource("file:///config")
|
||||
assert resource is not None
|
||||
assert resource.version == "2.0"
|
||||
|
||||
async def test_prompt_with_version(self):
|
||||
"""Prompt version should work like tool version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@mcp.prompt(version="2.0")
|
||||
def greet(name: str) -> str:
|
||||
return f"Greetings, {name}!"
|
||||
|
||||
# list_prompts returns all versions
|
||||
prompts = await mcp.list_prompts()
|
||||
assert len(prompts) == 2
|
||||
versions = {p.version for p in prompts}
|
||||
assert versions == {"1.0", "2.0"}
|
||||
|
||||
# get_prompt returns highest version
|
||||
prompt = await mcp.get_prompt("greet")
|
||||
assert prompt is not None
|
||||
assert prompt.version == "2.0"
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
0
tests/tools/tool/__init__.py
Normal file
0
tests/tools/tool/__init__.py
Normal file
102
tests/tools/tool/test_callable.py
Normal file
102
tests/tools/tool/test_callable.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import asyncio
|
||||
import threading
|
||||
|
||||
from mcp.types import TextContent
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
|
||||
class TestToolCallable:
|
||||
"""Test tools with callable objects."""
|
||||
|
||||
async def test_callable_object_sync(self):
|
||||
"""Test that callable objects with sync __call__ work."""
|
||||
|
||||
class MyTool:
|
||||
def __init__(self, multiplier: int):
|
||||
self.multiplier = multiplier
|
||||
|
||||
def __call__(self, x: int) -> int:
|
||||
return x * self.multiplier
|
||||
|
||||
tool = Tool.from_function(MyTool(3))
|
||||
result = await tool.run({"x": 5})
|
||||
assert result.content == [TextContent(type="text", text="15")]
|
||||
|
||||
async def test_callable_object_async(self):
|
||||
"""Test that callable objects with async __call__ work."""
|
||||
|
||||
class AsyncTool:
|
||||
def __init__(self, multiplier: int):
|
||||
self.multiplier = multiplier
|
||||
|
||||
async def __call__(self, x: int) -> int:
|
||||
return x * self.multiplier
|
||||
|
||||
tool = Tool.from_function(AsyncTool(4))
|
||||
result = await tool.run({"x": 5})
|
||||
assert result.content == [TextContent(type="text", text="20")]
|
||||
|
||||
|
||||
class TestSyncToolConcurrency:
|
||||
"""Tests for concurrent execution of sync tools without blocking the event loop."""
|
||||
|
||||
async def test_sync_tools_run_concurrently(self):
|
||||
"""Test that sync tools run in threadpool and don't block each other.
|
||||
|
||||
Uses a threading barrier to prove concurrent execution: all calls must
|
||||
reach the barrier simultaneously for any to proceed. If they ran
|
||||
sequentially, only one would reach the barrier and it would timeout.
|
||||
"""
|
||||
num_calls = 3
|
||||
# Barrier requires all threads to arrive before any proceed
|
||||
# Short timeout since concurrent threads should arrive within milliseconds
|
||||
barrier = threading.Barrier(num_calls, timeout=0.5)
|
||||
|
||||
def concurrent_tool(x: int) -> int:
|
||||
"""Tool that proves concurrency via barrier synchronization."""
|
||||
# If calls run sequentially, only 1 thread reaches barrier and times out
|
||||
# If calls run concurrently, all 3 reach barrier and proceed
|
||||
barrier.wait()
|
||||
return x * 2
|
||||
|
||||
tool = Tool.from_function(concurrent_tool)
|
||||
|
||||
# Run concurrent calls - will raise BrokenBarrierError if not concurrent
|
||||
results = await asyncio.gather(
|
||||
tool.run({"x": 1}),
|
||||
tool.run({"x": 2}),
|
||||
tool.run({"x": 3}),
|
||||
)
|
||||
|
||||
# Verify results
|
||||
assert [r.content for r in results] == [
|
||||
[TextContent(type="text", text="2")],
|
||||
[TextContent(type="text", text="4")],
|
||||
[TextContent(type="text", text="6")],
|
||||
]
|
||||
|
||||
async def test_sync_tool_with_context_runs_concurrently(self):
|
||||
"""Test that sync tools with Context dependency also run concurrently."""
|
||||
num_calls = 3
|
||||
barrier = threading.Barrier(num_calls, timeout=0.5)
|
||||
|
||||
mcp = FastMCP("test")
|
||||
|
||||
@mcp.tool
|
||||
def ctx_tool(x: int, ctx: Context) -> str:
|
||||
"""A sync tool with context that uses barrier to prove concurrency."""
|
||||
barrier.wait()
|
||||
return f"{ctx.fastmcp.name}:{x}"
|
||||
|
||||
# Run concurrent calls through the server interface (which sets up Context)
|
||||
results = await asyncio.gather(
|
||||
mcp.call_tool("ctx_tool", {"x": 1}),
|
||||
mcp.call_tool("ctx_tool", {"x": 2}),
|
||||
mcp.call_tool("ctx_tool", {"x": 3}),
|
||||
)
|
||||
|
||||
# Verify results
|
||||
for i, result in enumerate(results, 1):
|
||||
assert result.content == [TextContent(type="text", text=f"test:{i}")]
|
||||
550
tests/tools/tool/test_content.py
Normal file
550
tests/tools/tool/test_content.py
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp.types import (
|
||||
AudioContent,
|
||||
BlobResourceContents,
|
||||
EmbeddedResource,
|
||||
ImageContent,
|
||||
ResourceLink,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
|
||||
from fastmcp.tools.tool import Tool, _convert_to_content
|
||||
from fastmcp.utilities.types import Audio, File, Image
|
||||
|
||||
|
||||
class SampleModel(BaseModel):
|
||||
x: int
|
||||
y: str
|
||||
|
||||
|
||||
class TestConvertResultToContent:
|
||||
"""Tests for the _convert_to_content helper function."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
argnames=("result", "expected"),
|
||||
argvalues=[
|
||||
(True, "true"),
|
||||
("hello", "hello"),
|
||||
(123, "123"),
|
||||
(123.45, "123.45"),
|
||||
({"key": "value"}, '{"key":"value"}'),
|
||||
(
|
||||
SampleModel(x=1, y="hello"),
|
||||
'{"x":1,"y":"hello"}',
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"boolean",
|
||||
"string",
|
||||
"integer",
|
||||
"float",
|
||||
"object",
|
||||
"basemodel",
|
||||
],
|
||||
)
|
||||
def test_convert_singular(self, result, expected):
|
||||
"""Test that a single item is converted to a TextContent."""
|
||||
converted = _convert_to_content(result)
|
||||
assert converted == [TextContent(type="text", text=expected)]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
argnames=("result", "expected_text"),
|
||||
argvalues=[
|
||||
([None], "[null]"),
|
||||
([None, None], "[null,null]"),
|
||||
([True], "[true]"),
|
||||
([True, False], "[true,false]"),
|
||||
(["hello"], '["hello"]'),
|
||||
(["hello", "world"], '["hello","world"]'),
|
||||
([123], "[123]"),
|
||||
([123, 456], "[123,456]"),
|
||||
([123.45], "[123.45]"),
|
||||
([123.45, 456.78], "[123.45,456.78]"),
|
||||
([{"key": "value"}], '[{"key":"value"}]'),
|
||||
(
|
||||
[{"key": "value"}, {"key2": "value2"}],
|
||||
'[{"key":"value"},{"key2":"value2"}]',
|
||||
),
|
||||
([SampleModel(x=1, y="hello")], '[{"x":1,"y":"hello"}]'),
|
||||
(
|
||||
[SampleModel(x=1, y="hello"), SampleModel(x=2, y="world")],
|
||||
'[{"x":1,"y":"hello"},{"x":2,"y":"world"}]',
|
||||
),
|
||||
([1, "two", None, {"c": 3}, False], '[1,"two",null,{"c":3},false]'),
|
||||
],
|
||||
ids=[
|
||||
"none",
|
||||
"none_many",
|
||||
"boolean",
|
||||
"boolean_many",
|
||||
"string",
|
||||
"string_many",
|
||||
"integer",
|
||||
"integer_many",
|
||||
"float",
|
||||
"float_many",
|
||||
"object",
|
||||
"object_many",
|
||||
"basemodel",
|
||||
"basemodel_many",
|
||||
"mixed",
|
||||
],
|
||||
)
|
||||
def test_convert_list(self, result, expected_text):
|
||||
"""Test that a list is converted to a TextContent."""
|
||||
converted = _convert_to_content(result)
|
||||
assert converted == [TextContent(type="text", text=expected_text)]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
argnames="content_block",
|
||||
argvalues=[
|
||||
(TextContent(type="text", text="hello")),
|
||||
(ImageContent(type="image", data="fakeimagedata", mimeType="image/png")),
|
||||
(AudioContent(type="audio", data="fakeaudiodata", mimeType="audio/mpeg")),
|
||||
(
|
||||
ResourceLink(
|
||||
type="resource_link",
|
||||
name="test resource",
|
||||
uri=AnyUrl("resource://test"),
|
||||
)
|
||||
),
|
||||
(
|
||||
EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri=AnyUrl("resource://test"),
|
||||
mimeType="text/plain",
|
||||
text="resource content",
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
ids=["text", "image", "audio", "resource link", "embedded resource"],
|
||||
)
|
||||
def test_convert_content_block(self, content_block):
|
||||
converted = _convert_to_content(content_block)
|
||||
assert converted == [content_block]
|
||||
|
||||
converted = _convert_to_content([content_block, content_block])
|
||||
assert converted == [content_block, content_block]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
argnames=("result", "expected"),
|
||||
argvalues=[
|
||||
(
|
||||
Image(data=b"fakeimagedata"),
|
||||
[
|
||||
ImageContent(
|
||||
type="image", data="ZmFrZWltYWdlZGF0YQ==", mimeType="image/png"
|
||||
)
|
||||
],
|
||||
),
|
||||
(
|
||||
Audio(data=b"fakeaudiodata"),
|
||||
[
|
||||
AudioContent(
|
||||
type="audio", data="ZmFrZWF1ZGlvZGF0YQ==", mimeType="audio/wav"
|
||||
)
|
||||
],
|
||||
),
|
||||
(
|
||||
File(data=b"filedata", format="octet-stream"),
|
||||
[
|
||||
EmbeddedResource(
|
||||
type="resource",
|
||||
resource=BlobResourceContents(
|
||||
uri=AnyUrl("file:///resource.octet-stream"),
|
||||
blob="ZmlsZWRhdGE=",
|
||||
mimeType="application/octet-stream",
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
ids=["image", "audio", "file"],
|
||||
)
|
||||
def test_convert_helpers(self, result, expected):
|
||||
converted = _convert_to_content(result)
|
||||
assert converted == expected
|
||||
|
||||
converted = _convert_to_content([result, result])
|
||||
assert converted == expected * 2
|
||||
|
||||
def test_convert_mixed_content(self):
|
||||
result = [
|
||||
"hello",
|
||||
123,
|
||||
123.45,
|
||||
{"key": "value"},
|
||||
SampleModel(x=1, y="hello"),
|
||||
Image(data=b"fakeimagedata"),
|
||||
Audio(data=b"fakeaudiodata"),
|
||||
ResourceLink(
|
||||
type="resource_link",
|
||||
name="test resource",
|
||||
uri=AnyUrl("resource://test"),
|
||||
),
|
||||
EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri=AnyUrl("resource://test"),
|
||||
mimeType="text/plain",
|
||||
text="resource content",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
converted = _convert_to_content(result)
|
||||
|
||||
assert converted == snapshot(
|
||||
[
|
||||
TextContent(type="text", text="hello"),
|
||||
TextContent(type="text", text="123"),
|
||||
TextContent(type="text", text="123.45"),
|
||||
TextContent(type="text", text='{"key":"value"}'),
|
||||
TextContent(type="text", text='{"x":1,"y":"hello"}'),
|
||||
ImageContent(
|
||||
type="image", data="ZmFrZWltYWdlZGF0YQ==", mimeType="image/png"
|
||||
),
|
||||
AudioContent(
|
||||
type="audio", data="ZmFrZWF1ZGlvZGF0YQ==", mimeType="audio/wav"
|
||||
),
|
||||
ResourceLink(
|
||||
name="test resource",
|
||||
uri=AnyUrl("resource://test"),
|
||||
type="resource_link",
|
||||
),
|
||||
EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri=AnyUrl("resource://test"),
|
||||
mimeType="text/plain",
|
||||
text="resource content",
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def test_empty_list(self):
|
||||
"""Test that an empty list results in an empty list."""
|
||||
result = _convert_to_content([])
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_empty_dict(self):
|
||||
"""Test that an empty dictionary is converted to TextContent."""
|
||||
result = _convert_to_content({})
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], TextContent)
|
||||
assert result[0].text == "{}"
|
||||
|
||||
|
||||
class TestAutomaticStructuredContent:
|
||||
"""Tests for automatic structured content generation based on return types."""
|
||||
|
||||
async def test_dict_return_creates_structured_content_without_schema(self):
|
||||
"""Test that dict returns automatically create structured content even without output schema."""
|
||||
|
||||
def get_user_data(user_id: str) -> dict:
|
||||
return {"name": "Alice", "age": 30, "active": True}
|
||||
|
||||
# No explicit output schema provided
|
||||
tool = Tool.from_function(get_user_data)
|
||||
|
||||
result = await tool.run({"user_id": "123"})
|
||||
|
||||
# Should have both content and structured content
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.structured_content == {"name": "Alice", "age": 30, "active": True}
|
||||
|
||||
async def test_dataclass_return_creates_structured_content_without_schema(self):
|
||||
"""Test that dataclass returns automatically create structured content even without output schema."""
|
||||
|
||||
@dataclass
|
||||
class UserProfile:
|
||||
name: str
|
||||
age: int
|
||||
email: str
|
||||
|
||||
def get_profile(user_id: str) -> UserProfile:
|
||||
return UserProfile(name="Bob", age=25, email="bob@example.com")
|
||||
|
||||
# No explicit output schema, but dataclass should still create structured content
|
||||
tool = Tool.from_function(get_profile, output_schema=None)
|
||||
|
||||
result = await tool.run({"user_id": "456"})
|
||||
|
||||
# Should have both content and structured content
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
# Dataclass should serialize to dict
|
||||
assert result.structured_content == {
|
||||
"name": "Bob",
|
||||
"age": 25,
|
||||
"email": "bob@example.com",
|
||||
}
|
||||
|
||||
async def test_pydantic_model_return_creates_structured_content_without_schema(
|
||||
self,
|
||||
):
|
||||
"""Test that Pydantic model returns automatically create structured content even without output schema."""
|
||||
|
||||
class UserData(BaseModel):
|
||||
username: str
|
||||
score: int
|
||||
verified: bool
|
||||
|
||||
def get_user_stats(user_id: str) -> UserData:
|
||||
return UserData(username="charlie", score=100, verified=True)
|
||||
|
||||
# Explicitly set output schema to None to test automatic structured content
|
||||
tool = Tool.from_function(get_user_stats, output_schema=None)
|
||||
|
||||
result = await tool.run({"user_id": "789"})
|
||||
|
||||
# Should have both content and structured content
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
# Pydantic model should serialize to dict
|
||||
assert result.structured_content == {
|
||||
"username": "charlie",
|
||||
"score": 100,
|
||||
"verified": True,
|
||||
}
|
||||
|
||||
async def test_self_referencing_dataclass_not_wrapped(self):
|
||||
"""Test that self-referencing dataclasses are not wrapped in result field."""
|
||||
|
||||
@dataclass
|
||||
class ReturnThing:
|
||||
value: int
|
||||
stuff: list["ReturnThing"]
|
||||
|
||||
def return_things() -> ReturnThing:
|
||||
return ReturnThing(value=123, stuff=[ReturnThing(value=456, stuff=[])])
|
||||
|
||||
tool = Tool.from_function(return_things)
|
||||
|
||||
result = await tool.run({})
|
||||
|
||||
# Should have structured content without wrapping
|
||||
assert result.structured_content is not None
|
||||
# Should NOT be wrapped in "result" field
|
||||
assert "result" not in result.structured_content
|
||||
# Should have the actual data directly
|
||||
assert result.structured_content == {
|
||||
"value": 123,
|
||||
"stuff": [{"value": 456, "stuff": []}],
|
||||
}
|
||||
|
||||
async def test_self_referencing_pydantic_model_has_type_object_at_root(self):
|
||||
"""Test that self-referencing Pydantic models have type: object at root.
|
||||
|
||||
MCP spec requires outputSchema to have "type": "object" at the root level.
|
||||
Pydantic generates schemas with $ref at root for self-referential models,
|
||||
which violates this requirement. FastMCP should resolve the $ref.
|
||||
|
||||
Regression test for issue #2455.
|
||||
"""
|
||||
|
||||
class Issue(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
dependencies: list["Issue"] = []
|
||||
dependents: list["Issue"] = []
|
||||
|
||||
def get_issue(issue_id: str) -> Issue:
|
||||
return Issue(id=issue_id, title="Test")
|
||||
|
||||
tool = Tool.from_function(get_issue)
|
||||
|
||||
# The output schema should have "type": "object" at root, not $ref
|
||||
assert tool.output_schema is not None
|
||||
assert tool.output_schema.get("type") == "object"
|
||||
assert "properties" in tool.output_schema
|
||||
# Should still have $defs for nested references
|
||||
assert "$defs" in tool.output_schema
|
||||
# Should NOT have $ref at root level
|
||||
assert "$ref" not in tool.output_schema
|
||||
|
||||
async def test_self_referencing_model_outputschema_mcp_compliant(self):
|
||||
"""Test that self-referencing model schemas are MCP spec compliant.
|
||||
|
||||
The MCP spec requires:
|
||||
- type: "object" at root level
|
||||
- properties field
|
||||
- required field (optional)
|
||||
|
||||
This ensures clients can properly validate the schema.
|
||||
|
||||
Regression test for issue #2455.
|
||||
"""
|
||||
|
||||
class Node(BaseModel):
|
||||
id: str
|
||||
children: list["Node"] = []
|
||||
|
||||
def get_node() -> Node:
|
||||
return Node(id="1")
|
||||
|
||||
tool = Tool.from_function(get_node)
|
||||
|
||||
# Schema should be MCP-compliant
|
||||
assert tool.output_schema is not None
|
||||
assert tool.output_schema.get("type") == "object", (
|
||||
"MCP spec requires 'type': 'object' at root"
|
||||
)
|
||||
assert "properties" in tool.output_schema
|
||||
assert "id" in tool.output_schema["properties"]
|
||||
assert "children" in tool.output_schema["properties"]
|
||||
# Required should include 'id'
|
||||
assert "id" in tool.output_schema.get("required", [])
|
||||
|
||||
async def test_int_return_no_structured_content_without_schema(self):
|
||||
"""Test that int returns don't create structured content without output schema."""
|
||||
|
||||
def calculate_sum(a: int, b: int):
|
||||
"""No return annotation."""
|
||||
return a + b
|
||||
|
||||
# No output schema
|
||||
tool = Tool.from_function(calculate_sum)
|
||||
|
||||
result = await tool.run({"a": 5, "b": 3})
|
||||
|
||||
# Should only have content, no structured content
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "8"
|
||||
assert result.structured_content is None
|
||||
|
||||
async def test_str_return_no_structured_content_without_schema(self):
|
||||
"""Test that str returns don't create structured content without output schema."""
|
||||
|
||||
def get_greeting(name: str):
|
||||
"""No return annotation."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# No output schema
|
||||
tool = Tool.from_function(get_greeting)
|
||||
|
||||
result = await tool.run({"name": "World"})
|
||||
|
||||
# Should only have content, no structured content
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Hello, World!"
|
||||
assert result.structured_content is None
|
||||
|
||||
async def test_list_return_no_structured_content_without_schema(self):
|
||||
"""Test that list returns don't create structured content without output schema."""
|
||||
|
||||
def get_numbers():
|
||||
"""No return annotation."""
|
||||
return [1, 2, 3, 4, 5]
|
||||
|
||||
# No output schema
|
||||
tool = Tool.from_function(get_numbers)
|
||||
|
||||
result = await tool.run({})
|
||||
|
||||
assert result.structured_content is None
|
||||
assert result.content == snapshot(
|
||||
[TextContent(type="text", text="[1,2,3,4,5]")]
|
||||
)
|
||||
|
||||
async def test_audio_return_creates_no_structured_content(self):
|
||||
"""Test that audio returns don't create structured content."""
|
||||
|
||||
def get_audio() -> AudioContent:
|
||||
"""No return annotation."""
|
||||
return Audio(data=b"fakeaudiodata").to_audio_content()
|
||||
|
||||
# No output schema
|
||||
tool = Tool.from_function(get_audio)
|
||||
|
||||
result = await tool.run({})
|
||||
|
||||
assert result.content == snapshot(
|
||||
[
|
||||
AudioContent(
|
||||
type="audio", data="ZmFrZWF1ZGlvZGF0YQ==", mimeType="audio/wav"
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result.structured_content is None
|
||||
|
||||
async def test_int_return_with_schema_creates_structured_content(self):
|
||||
"""Test that int returns DO create structured content when there's an output schema."""
|
||||
|
||||
def calculate_sum(a: int, b: int) -> int:
|
||||
"""With return annotation."""
|
||||
return a + b
|
||||
|
||||
# Output schema should be auto-generated from annotation
|
||||
tool = Tool.from_function(calculate_sum)
|
||||
assert tool.output_schema is not None
|
||||
|
||||
result = await tool.run({"a": 5, "b": 3})
|
||||
|
||||
# Should have both content and structured content
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "8"
|
||||
assert result.structured_content == {"result": 8}
|
||||
|
||||
async def test_client_automatic_deserialization_with_dict_result(self):
|
||||
"""Test that clients automatically deserialize dict results from structured content."""
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def get_user_info(user_id: str) -> dict:
|
||||
return {"name": "Alice", "age": 30, "active": True}
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("get_user_info", {"user_id": "123"})
|
||||
|
||||
# Client should provide the deserialized data
|
||||
assert result.data == {"name": "Alice", "age": 30, "active": True}
|
||||
assert result.structured_content == {
|
||||
"name": "Alice",
|
||||
"age": 30,
|
||||
"active": True,
|
||||
}
|
||||
assert len(result.content) == 1
|
||||
|
||||
async def test_client_automatic_deserialization_with_dataclass_result(self):
|
||||
"""Test that clients automatically deserialize dataclass results from structured content."""
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@dataclass
|
||||
class UserProfile:
|
||||
name: str
|
||||
age: int
|
||||
verified: bool
|
||||
|
||||
@mcp.tool
|
||||
def get_profile(user_id: str) -> UserProfile:
|
||||
return UserProfile(name="Bob", age=25, verified=True)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("get_profile", {"user_id": "456"})
|
||||
|
||||
# Client should deserialize back to a dataclass (but type name is lost with title pruning)
|
||||
assert result.data.__class__.__name__ == "Root"
|
||||
assert result.data.name == "Bob"
|
||||
assert result.data.age == 25
|
||||
assert result.data.verified is True
|
||||
534
tests/tools/tool/test_output_schema.py
Normal file
534
tests/tools/tool/test_output_schema.py
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp.types import AudioContent, EmbeddedResource, ImageContent, TextContent
|
||||
from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from fastmcp.tools.tool import Tool
|
||||
from fastmcp.utilities.json_schema import compress_schema
|
||||
from fastmcp.utilities.types import Audio, File, Image
|
||||
|
||||
|
||||
class TestToolFromFunctionOutputSchema:
|
||||
async def test_no_return_annotation(self):
|
||||
def func():
|
||||
pass
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"annotation",
|
||||
[
|
||||
int,
|
||||
float,
|
||||
bool,
|
||||
str,
|
||||
int | float,
|
||||
list,
|
||||
list[int],
|
||||
list[int | float],
|
||||
dict,
|
||||
dict[str, Any],
|
||||
dict[str, int | None],
|
||||
tuple[int, str],
|
||||
set[int],
|
||||
list[tuple[int, str]],
|
||||
],
|
||||
)
|
||||
async def test_simple_return_annotation(self, annotation):
|
||||
def func() -> annotation:
|
||||
return 1
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
|
||||
base_schema = TypeAdapter(annotation).json_schema()
|
||||
|
||||
# Non-object types get wrapped
|
||||
schema_type = base_schema.get("type")
|
||||
is_object_type = schema_type == "object"
|
||||
|
||||
if not is_object_type:
|
||||
# Non-object types get wrapped
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": base_schema},
|
||||
"required": ["result"],
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert tool.output_schema == expected_schema
|
||||
# # Note: Parameterized test - keeping original assertion for multiple parameter values
|
||||
else:
|
||||
# Object types remain unwrapped
|
||||
assert tool.output_schema == base_schema
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"annotation",
|
||||
[
|
||||
AnyUrl,
|
||||
Annotated[int, Field(ge=1)],
|
||||
Annotated[int, Field(ge=1)],
|
||||
],
|
||||
)
|
||||
async def test_complex_return_annotation(self, annotation):
|
||||
def func() -> annotation:
|
||||
return 1
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
|
||||
base_schema = TypeAdapter(annotation).json_schema()
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": base_schema},
|
||||
"required": ["result"],
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert tool.output_schema == expected_schema
|
||||
|
||||
async def test_none_return_annotation(self):
|
||||
def func() -> None:
|
||||
pass
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema is None
|
||||
|
||||
async def test_any_return_annotation(self):
|
||||
from typing import Any
|
||||
|
||||
def func() -> Any:
|
||||
return 1
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"annotation, expected",
|
||||
[
|
||||
(Image, ImageContent),
|
||||
(Audio, AudioContent),
|
||||
(File, EmbeddedResource),
|
||||
(Image | int, ImageContent | int),
|
||||
(Image | Audio, ImageContent | AudioContent),
|
||||
(list[Image | Audio], list[ImageContent | AudioContent]),
|
||||
],
|
||||
)
|
||||
async def test_converted_return_annotation(self, annotation, expected):
|
||||
def func() -> annotation:
|
||||
return 1
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
# Image, Audio, File types don't generate output schemas since they're converted to content directly
|
||||
assert tool.output_schema is None
|
||||
|
||||
async def test_dataclass_return_annotation(self):
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def func() -> Person:
|
||||
return Person(name="John", age=30)
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
expected_schema = compress_schema(
|
||||
TypeAdapter(Person).json_schema(), prune_titles=True
|
||||
)
|
||||
assert tool.output_schema == expected_schema
|
||||
|
||||
async def test_base_model_return_annotation(self):
|
||||
class Person(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def func() -> Person:
|
||||
return Person(name="John", age=30)
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name", "age"],
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
|
||||
async def test_typeddict_return_annotation(self):
|
||||
class Person(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def func() -> Person:
|
||||
return Person(name="John", age=30)
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name", "age"],
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
|
||||
async def test_unserializable_return_annotation(self):
|
||||
class Unserializable:
|
||||
def __init__(self, data: Any):
|
||||
self.data = data
|
||||
|
||||
def func() -> Unserializable:
|
||||
return Unserializable(data="test")
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema is None
|
||||
|
||||
async def test_mixed_unserializable_return_annotation(self):
|
||||
class Unserializable:
|
||||
def __init__(self, data: Any):
|
||||
self.data = data
|
||||
|
||||
def func() -> Unserializable | int:
|
||||
return Unserializable(data="test")
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema is None
|
||||
|
||||
async def test_provided_output_schema_takes_precedence_over_json_compatible_annotation(
|
||||
self,
|
||||
):
|
||||
"""Test that provided output_schema takes precedence over inferred schema from JSON-compatible annotation."""
|
||||
|
||||
def func() -> dict[str, int]:
|
||||
return {"a": 1, "b": 2}
|
||||
|
||||
# Provide a custom output schema that differs from the inferred one
|
||||
custom_schema = {"type": "object", "description": "Custom schema"}
|
||||
|
||||
tool = Tool.from_function(func, output_schema=custom_schema)
|
||||
assert tool.output_schema == custom_schema
|
||||
|
||||
async def test_provided_output_schema_takes_precedence_over_complex_annotation(
|
||||
self,
|
||||
):
|
||||
"""Test that provided output_schema takes precedence over inferred schema from complex annotation."""
|
||||
|
||||
def func() -> list[dict[str, int | float]]:
|
||||
return [{"a": 1, "b": 2.5}]
|
||||
|
||||
# Provide a custom output schema that differs from the inferred one
|
||||
custom_schema = {"type": "object", "properties": {"custom": {"type": "string"}}}
|
||||
|
||||
tool = Tool.from_function(func, output_schema=custom_schema)
|
||||
assert tool.output_schema == custom_schema
|
||||
|
||||
async def test_provided_output_schema_takes_precedence_over_unserializable_annotation(
|
||||
self,
|
||||
):
|
||||
"""Test that provided output_schema takes precedence over None schema from unserializable annotation."""
|
||||
|
||||
class Unserializable:
|
||||
def __init__(self, data: Any):
|
||||
self.data = data
|
||||
|
||||
def func() -> Unserializable:
|
||||
return Unserializable(data="test")
|
||||
|
||||
# Provide a custom output schema even though the annotation is unserializable
|
||||
custom_schema = {
|
||||
"type": "object",
|
||||
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
|
||||
}
|
||||
|
||||
tool = Tool.from_function(func, output_schema=custom_schema)
|
||||
assert tool.output_schema == custom_schema
|
||||
|
||||
async def test_provided_output_schema_takes_precedence_over_no_annotation(self):
|
||||
"""Test that provided output_schema takes precedence over None schema from no annotation."""
|
||||
|
||||
def func():
|
||||
return "hello"
|
||||
|
||||
# Provide a custom output schema even though there's no return annotation
|
||||
custom_schema = {
|
||||
"type": "object",
|
||||
"properties": {"value": {"type": "number", "minimum": 0}},
|
||||
}
|
||||
|
||||
tool = Tool.from_function(func, output_schema=custom_schema)
|
||||
assert tool.output_schema == custom_schema
|
||||
|
||||
async def test_provided_output_schema_takes_precedence_over_converted_annotation(
|
||||
self,
|
||||
):
|
||||
"""Test that provided output_schema takes precedence over converted schema from Image/Audio/File annotations."""
|
||||
|
||||
def func() -> Image:
|
||||
return Image(data=b"test")
|
||||
|
||||
# Provide a custom output schema that differs from the converted ImageContent schema
|
||||
custom_schema = {
|
||||
"type": "object",
|
||||
"properties": {"custom_image": {"type": "string"}},
|
||||
}
|
||||
|
||||
tool = Tool.from_function(func, output_schema=custom_schema)
|
||||
assert tool.output_schema == custom_schema
|
||||
|
||||
async def test_provided_output_schema_takes_precedence_over_union_annotation(self):
|
||||
"""Test that provided output_schema takes precedence over inferred schema from union annotation."""
|
||||
|
||||
def func() -> str | int | None:
|
||||
return "hello"
|
||||
|
||||
# Provide a custom output schema that differs from the inferred union schema
|
||||
custom_schema = {"type": "object", "properties": {"flag": {"type": "boolean"}}}
|
||||
|
||||
tool = Tool.from_function(func, output_schema=custom_schema)
|
||||
assert tool.output_schema == custom_schema
|
||||
|
||||
async def test_provided_output_schema_takes_precedence_over_pydantic_annotation(
|
||||
self,
|
||||
):
|
||||
"""Test that provided output_schema takes precedence over inferred schema from Pydantic model annotation."""
|
||||
|
||||
class Person(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def func() -> Person:
|
||||
return Person(name="John", age=30)
|
||||
|
||||
# Provide a custom output schema that differs from the inferred Person schema
|
||||
custom_schema = {
|
||||
"type": "object",
|
||||
"properties": {"numbers": {"type": "array", "items": {"type": "number"}}},
|
||||
}
|
||||
|
||||
tool = Tool.from_function(func, output_schema=custom_schema)
|
||||
assert tool.output_schema == custom_schema
|
||||
|
||||
async def test_output_schema_false_allows_automatic_structured_content(self):
|
||||
"""Test that output_schema=False still allows automatic structured content for dict-like objects."""
|
||||
|
||||
def func() -> dict[str, str]:
|
||||
return {"message": "Hello, world!"}
|
||||
|
||||
tool = Tool.from_function(func, output_schema=None)
|
||||
assert tool.output_schema is None
|
||||
|
||||
result = await tool.run({})
|
||||
# Dict objects automatically become structured content even without schema
|
||||
assert result.structured_content == {"message": "Hello, world!"}
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == '{"message":"Hello, world!"}'
|
||||
|
||||
async def test_output_schema_none_disables_structured_content(self):
|
||||
"""Test that output_schema=None explicitly disables structured content."""
|
||||
|
||||
def func() -> int:
|
||||
return 42
|
||||
|
||||
tool = Tool.from_function(func, output_schema=None)
|
||||
assert tool.output_schema is None
|
||||
|
||||
result = await tool.run({})
|
||||
assert result.structured_content is None
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "42"
|
||||
|
||||
async def test_output_schema_inferred_when_not_specified(self):
|
||||
"""Test that output schema is inferred when not explicitly specified."""
|
||||
|
||||
def func() -> int:
|
||||
return 42
|
||||
|
||||
# Don't specify output_schema - should infer and wrap
|
||||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
)
|
||||
|
||||
result = await tool.run({})
|
||||
assert result.structured_content == {"result": 42}
|
||||
|
||||
async def test_explicit_object_schema_with_dict_return(self):
|
||||
"""Test that explicit object schemas work when function returns a dict."""
|
||||
|
||||
def func() -> dict[str, int]:
|
||||
return {"value": 42}
|
||||
|
||||
# Provide explicit object schema
|
||||
explicit_schema = {
|
||||
"type": "object",
|
||||
"properties": {"value": {"type": "integer", "minimum": 0}},
|
||||
}
|
||||
tool = Tool.from_function(func, output_schema=explicit_schema)
|
||||
assert tool.output_schema == explicit_schema # Schema not wrapped
|
||||
assert tool.output_schema and "x-fastmcp-wrap-result" not in tool.output_schema
|
||||
|
||||
result = await tool.run({})
|
||||
# Dict result with object schema is used directly
|
||||
assert result.structured_content == {"value": 42}
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == '{"value":42}'
|
||||
|
||||
async def test_explicit_object_schema_with_non_dict_return_fails(self):
|
||||
"""Test that explicit object schemas fail when function returns non-dict."""
|
||||
|
||||
def func() -> int:
|
||||
return 42
|
||||
|
||||
# Provide explicit object schema but return non-dict
|
||||
explicit_schema = {
|
||||
"type": "object",
|
||||
"properties": {"value": {"type": "integer"}},
|
||||
}
|
||||
tool = Tool.from_function(func, output_schema=explicit_schema)
|
||||
|
||||
# Should fail because int is not dict-compatible with object schema
|
||||
with pytest.raises(ValueError, match="structured_content must be a dict"):
|
||||
await tool.run({})
|
||||
|
||||
async def test_object_output_schema_not_wrapped(self):
|
||||
"""Test that object-type output schemas are never wrapped."""
|
||||
|
||||
def func() -> dict[str, int]:
|
||||
return {"value": 42}
|
||||
|
||||
# Object schemas should never be wrapped, even when inferred
|
||||
tool = Tool.from_function(func)
|
||||
expected_schema = TypeAdapter(dict[str, int]).json_schema()
|
||||
assert tool.output_schema == expected_schema # Not wrapped
|
||||
assert tool.output_schema and "x-fastmcp-wrap-result" not in tool.output_schema
|
||||
|
||||
result = await tool.run({})
|
||||
assert result.structured_content == {"value": 42} # Direct value
|
||||
|
||||
async def test_structured_content_interaction_with_wrapping(self):
|
||||
"""Test that structured content works correctly with schema wrapping."""
|
||||
|
||||
def func() -> str:
|
||||
return "hello"
|
||||
|
||||
# Inferred schema should wrap string type
|
||||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
)
|
||||
|
||||
result = await tool.run({})
|
||||
# Unstructured content
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "hello"
|
||||
# Structured content should be wrapped
|
||||
assert result.structured_content == {"result": "hello"}
|
||||
|
||||
async def test_structured_content_with_explicit_object_schema(self):
|
||||
"""Test structured content with explicit object schema."""
|
||||
|
||||
def func() -> dict[str, str]:
|
||||
return {"greeting": "hello"}
|
||||
|
||||
# Provide explicit object schema
|
||||
explicit_schema = {
|
||||
"type": "object",
|
||||
"properties": {"greeting": {"type": "string"}},
|
||||
"required": ["greeting"],
|
||||
}
|
||||
tool = Tool.from_function(func, output_schema=explicit_schema)
|
||||
assert tool.output_schema == explicit_schema
|
||||
|
||||
result = await tool.run({})
|
||||
# Should use direct value since explicit schema doesn't have wrap marker
|
||||
assert result.structured_content == {"greeting": "hello"}
|
||||
|
||||
async def test_structured_content_with_custom_wrapper_schema(self):
|
||||
"""Test structured content with custom schema that includes wrap marker."""
|
||||
|
||||
def func() -> str:
|
||||
return "world"
|
||||
|
||||
# Custom schema with wrap marker
|
||||
custom_schema = {
|
||||
"type": "object",
|
||||
"properties": {"message": {"type": "string"}},
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
tool = Tool.from_function(func, output_schema=custom_schema)
|
||||
assert tool.output_schema == custom_schema
|
||||
|
||||
result = await tool.run({})
|
||||
# Should wrap with "result" key due to wrap marker
|
||||
assert result.structured_content == {"result": "world"}
|
||||
|
||||
async def test_none_vs_false_output_schema_behavior(self):
|
||||
"""Test the difference between None and False for output_schema."""
|
||||
|
||||
def func() -> int:
|
||||
return 123
|
||||
|
||||
# None should disable
|
||||
tool_none = Tool.from_function(func, output_schema=None)
|
||||
assert tool_none.output_schema is None
|
||||
|
||||
# Default (NotSet) should infer from return type
|
||||
tool_default = Tool.from_function(func)
|
||||
assert (
|
||||
tool_default.output_schema is not None
|
||||
) # Should infer schema from dict return type
|
||||
|
||||
# Different behavior: None vs inferred
|
||||
result_none = await tool_none.run({})
|
||||
result_default = await tool_default.run({})
|
||||
|
||||
# None should still try fallback generation but fail for non-dict
|
||||
assert result_none.structured_content is None # Fallback fails for int
|
||||
# Default should use proper schema and wrap the result
|
||||
assert result_default.structured_content == {
|
||||
"result": 123
|
||||
} # Schema-based generation with wrapping
|
||||
assert isinstance(result_none.content[0], TextContent)
|
||||
assert isinstance(result_default.content[0], TextContent)
|
||||
assert result_none.content[0].text == result_default.content[0].text == "123"
|
||||
|
||||
async def test_non_object_output_schema_raises_error(self):
|
||||
"""Test that providing a non-object output schema raises a ValueError."""
|
||||
|
||||
def func() -> int:
|
||||
return 42
|
||||
|
||||
# Test various non-object schemas that should raise errors
|
||||
non_object_schemas = [
|
||||
{"type": "string"},
|
||||
{"type": "integer", "minimum": 0},
|
||||
{"type": "number"},
|
||||
{"type": "boolean"},
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
]
|
||||
|
||||
for schema in non_object_schemas:
|
||||
with pytest.raises(
|
||||
ValueError, match="Output schemas must represent object types"
|
||||
):
|
||||
Tool.from_function(func, output_schema=schema)
|
||||
184
tests/tools/tool/test_results.py
Normal file
184
tests/tools/tool/test_results.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
|
||||
|
||||
class TestToolResultCasting:
|
||||
@pytest.fixture
|
||||
async def client(self):
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def test_tool(
|
||||
unstructured: str | None = None,
|
||||
structured: dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
):
|
||||
return ToolResult(
|
||||
content=unstructured,
|
||||
structured_content=structured,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
yield client
|
||||
|
||||
async def test_only_unstructured_content(self, client):
|
||||
result = await client.call_tool("test_tool", {"unstructured": "test data"})
|
||||
|
||||
assert result.content[0].type == "text"
|
||||
assert result.content[0].text == "test data"
|
||||
assert result.structured_content is None
|
||||
assert result.meta is None
|
||||
|
||||
async def test_neither_unstructured_or_structured_content(self, client):
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool("test_tool", {})
|
||||
|
||||
async def test_structured_and_unstructured_content(self, client):
|
||||
result = await client.call_tool(
|
||||
"test_tool",
|
||||
{"unstructured": "test data", "structured": {"data_type": "test"}},
|
||||
)
|
||||
|
||||
assert result.content[0].type == "text"
|
||||
assert result.content[0].text == "test data"
|
||||
assert result.structured_content == {"data_type": "test"}
|
||||
assert result.meta is None
|
||||
|
||||
async def test_structured_unstructured_and_meta_content(self, client):
|
||||
result = await client.call_tool(
|
||||
"test_tool",
|
||||
{
|
||||
"unstructured": "test data",
|
||||
"structured": {"data_type": "test"},
|
||||
"meta": {"some": "metadata"},
|
||||
},
|
||||
)
|
||||
|
||||
assert result.content[0].type == "text"
|
||||
assert result.content[0].text == "test data"
|
||||
assert result.structured_content == {"data_type": "test"}
|
||||
assert result.meta == {"some": "metadata"}
|
||||
|
||||
|
||||
class TestUnionReturnTypes:
|
||||
"""Tests for tools with union return types."""
|
||||
|
||||
async def test_dataclass_union_string_works(self):
|
||||
"""Test that union of dataclass and string works correctly."""
|
||||
|
||||
@dataclass
|
||||
class Data:
|
||||
value: int
|
||||
|
||||
def get_data(return_error: bool) -> Data | str:
|
||||
if return_error:
|
||||
return "error occurred"
|
||||
return Data(value=42)
|
||||
|
||||
tool = Tool.from_function(get_data)
|
||||
|
||||
# Test returning dataclass
|
||||
result1 = await tool.run({"return_error": False})
|
||||
assert result1.structured_content == {"result": {"value": 42}}
|
||||
|
||||
# Test returning string
|
||||
result2 = await tool.run({"return_error": True})
|
||||
assert result2.structured_content == {"result": "error occurred"}
|
||||
|
||||
|
||||
class TestSerializationAlias:
|
||||
"""Tests for Pydantic field serialization alias support in tool output schemas."""
|
||||
|
||||
def test_output_schema_respects_serialization_alias(self):
|
||||
"""Test that Tool.from_function generates output schema using serialization alias."""
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
class Component(BaseModel):
|
||||
"""Model with multiple validation aliases but specific serialization alias."""
|
||||
|
||||
component_id: str = Field(
|
||||
validation_alias=AliasChoices("id", "componentId"),
|
||||
serialization_alias="componentId",
|
||||
description="The ID of the component",
|
||||
)
|
||||
|
||||
async def get_component(
|
||||
component_id: str,
|
||||
) -> Annotated[Component, Field(description="The component.")]:
|
||||
# API returns data with 'id' field
|
||||
api_data = {"id": component_id}
|
||||
return Component.model_validate(api_data)
|
||||
|
||||
tool = Tool.from_function(get_component, name="get-component")
|
||||
|
||||
# The output schema should use the serialization alias 'componentId'
|
||||
# not the first validation alias 'id'
|
||||
assert tool.output_schema is not None
|
||||
|
||||
# Object schemas have properties directly at root (MCP spec compliance)
|
||||
# Root-level $refs are resolved to ensure type: object at root
|
||||
assert "properties" in tool.output_schema
|
||||
assert tool.output_schema.get("type") == "object"
|
||||
|
||||
# Should have 'componentId' not 'id' in properties
|
||||
assert "componentId" in tool.output_schema["properties"]
|
||||
assert "id" not in tool.output_schema["properties"]
|
||||
|
||||
# Should require 'componentId' not 'id'
|
||||
assert "componentId" in tool.output_schema.get("required", [])
|
||||
assert "id" not in tool.output_schema.get("required", [])
|
||||
|
||||
async def test_tool_execution_with_serialization_alias(self):
|
||||
"""Test that tool execution works correctly with serialization aliases."""
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
|
||||
class Component(BaseModel):
|
||||
"""Model with multiple validation aliases but specific serialization alias."""
|
||||
|
||||
component_id: str = Field(
|
||||
validation_alias=AliasChoices("id", "componentId"),
|
||||
serialization_alias="componentId",
|
||||
description="The ID of the component",
|
||||
)
|
||||
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.tool
|
||||
async def get_component(
|
||||
component_id: str,
|
||||
) -> Annotated[Component, Field(description="The component.")]:
|
||||
# API returns data with 'id' field
|
||||
api_data = {"id": component_id}
|
||||
return Component.model_validate(api_data)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Execute the tool - this should work without validation errors
|
||||
result = await client.call_tool(
|
||||
"get_component", {"component_id": "test123"}
|
||||
)
|
||||
|
||||
# The result should contain the serialized form with 'componentId'
|
||||
assert result.structured_content is not None
|
||||
# Object types may be wrapped in "result" or not, depending on schema structure
|
||||
if "result" in result.structured_content:
|
||||
component_data = result.structured_content["result"]
|
||||
else:
|
||||
component_data = result.structured_content
|
||||
assert component_data["componentId"] == "test123"
|
||||
assert "id" not in component_data
|
||||
95
tests/tools/tool/test_title.py
Normal file
95
tests/tools/tool/test_title.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from fastmcp.tools.tool import Tool
|
||||
|
||||
|
||||
class TestToolTitle:
|
||||
"""Tests for tool title functionality."""
|
||||
|
||||
def test_tool_with_title(self):
|
||||
"""Test that tools can have titles and they appear in MCP conversion."""
|
||||
|
||||
def calculate(x: int, y: int) -> int:
|
||||
"""Calculate the sum of two numbers."""
|
||||
return x + y
|
||||
|
||||
tool = Tool.from_function(
|
||||
calculate,
|
||||
name="calc",
|
||||
title="Advanced Calculator Tool",
|
||||
description="Custom description",
|
||||
)
|
||||
|
||||
assert tool.name == "calc"
|
||||
assert tool.title == "Advanced Calculator Tool"
|
||||
assert tool.description == "Custom description"
|
||||
|
||||
# Test MCP conversion includes title
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
assert mcp_tool.name == "calc"
|
||||
assert (
|
||||
hasattr(mcp_tool, "title") and mcp_tool.title == "Advanced Calculator Tool"
|
||||
)
|
||||
|
||||
def test_tool_without_title(self):
|
||||
"""Test that tools without titles use name as display name."""
|
||||
|
||||
def multiply(a: int, b: int) -> int:
|
||||
return a * b
|
||||
|
||||
tool = Tool.from_function(multiply)
|
||||
|
||||
assert tool.name == "multiply"
|
||||
assert tool.title is None
|
||||
|
||||
# Test MCP conversion doesn't include title when None
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
assert mcp_tool.name == "multiply"
|
||||
assert not hasattr(mcp_tool, "title") or mcp_tool.title is None
|
||||
|
||||
def test_tool_title_priority(self):
|
||||
"""Test that explicit title takes priority over annotations.title."""
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
def divide(x: int, y: int) -> float:
|
||||
"""Divide two numbers."""
|
||||
return x / y
|
||||
|
||||
# Test with both explicit title and annotations.title
|
||||
annotations = ToolAnnotations(title="Annotation Title")
|
||||
tool = Tool.from_function(
|
||||
divide,
|
||||
name="div",
|
||||
title="Explicit Title",
|
||||
annotations=annotations,
|
||||
)
|
||||
|
||||
assert tool.title == "Explicit Title"
|
||||
assert tool.annotations is not None
|
||||
assert tool.annotations.title == "Annotation Title"
|
||||
|
||||
# Explicit title should take priority
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
assert mcp_tool.title == "Explicit Title"
|
||||
|
||||
def test_tool_annotations_title_fallback(self):
|
||||
"""Test that annotations.title is used when no explicit title is provided."""
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
def modulo(x: int, y: int) -> int:
|
||||
"""Get modulo of two numbers."""
|
||||
return x % y
|
||||
|
||||
# Test with only annotations.title (no explicit title)
|
||||
annotations = ToolAnnotations(title="Annotation Title")
|
||||
tool = Tool.from_function(
|
||||
modulo,
|
||||
name="mod",
|
||||
annotations=annotations,
|
||||
)
|
||||
|
||||
assert tool.title is None
|
||||
assert tool.annotations is not None
|
||||
assert tool.annotations.title == "Annotation Title"
|
||||
|
||||
# Should fall back to annotations.title
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
assert mcp_tool.title == "Annotation Title"
|
||||
595
tests/tools/tool/test_tool.py
Normal file
595
tests/tools/tool/test_tool.py
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from dirty_equals import HasName
|
||||
from inline_snapshot import snapshot
|
||||
from mcp.types import (
|
||||
AudioContent,
|
||||
ImageContent,
|
||||
ToolExecution,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
from fastmcp.utilities.types import Audio, File, Image
|
||||
|
||||
|
||||
class TestToolFromFunction:
|
||||
def test_basic_function(self):
|
||||
"""Test registering and running a basic function."""
|
||||
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
tool = Tool.from_function(add)
|
||||
|
||||
assert tool.model_dump(exclude_none=True) == snapshot(
|
||||
{
|
||||
"name": "add",
|
||||
"description": "Add two numbers.",
|
||||
"tags": set(),
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"a": {"type": "integer"},
|
||||
"b": {"type": "integer"},
|
||||
},
|
||||
"required": ["a", "b"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"fn": HasName("add"),
|
||||
"task_config": {
|
||||
"mode": "forbidden",
|
||||
"poll_interval": timedelta(seconds=5),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_meta_parameter(self):
|
||||
"""Test that meta parameter is properly handled."""
|
||||
|
||||
def multiply(a: int, b: int) -> int:
|
||||
"""Multiply two numbers."""
|
||||
return a * b
|
||||
|
||||
meta_data = {"version": "1.0", "author": "test"}
|
||||
tool = Tool.from_function(multiply, meta=meta_data)
|
||||
|
||||
assert tool.meta == meta_data
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
|
||||
# MCP tool includes fastmcp meta, so check that our meta is included
|
||||
assert mcp_tool.meta is not None
|
||||
assert meta_data.items() <= mcp_tool.meta.items()
|
||||
|
||||
async def test_async_function(self):
|
||||
"""Test registering and running an async function."""
|
||||
|
||||
async def fetch_data(url: str) -> str:
|
||||
"""Fetch data from URL."""
|
||||
return f"Data from {url}"
|
||||
|
||||
tool = Tool.from_function(fetch_data)
|
||||
|
||||
assert tool.model_dump(exclude_none=True) == snapshot(
|
||||
{
|
||||
"name": "fetch_data",
|
||||
"description": "Fetch data from URL.",
|
||||
"tags": set(),
|
||||
"parameters": {
|
||||
"properties": {"url": {"type": "string"}},
|
||||
"required": ["url"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"fn": HasName("fetch_data"),
|
||||
"task_config": {
|
||||
"mode": "forbidden",
|
||||
"poll_interval": timedelta(seconds=5),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_callable_object(self):
|
||||
class Adder:
|
||||
"""Adds two numbers."""
|
||||
|
||||
def __call__(self, x: int, y: int) -> int:
|
||||
"""ignore this"""
|
||||
return x + y
|
||||
|
||||
tool = Tool.from_function(Adder())
|
||||
|
||||
assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot(
|
||||
{
|
||||
"name": "Adder",
|
||||
"description": "Adds two numbers.",
|
||||
"tags": set(),
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"x": {"type": "integer"},
|
||||
"y": {"type": "integer"},
|
||||
},
|
||||
"required": ["x", "y"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"task_config": {
|
||||
"mode": "forbidden",
|
||||
"poll_interval": timedelta(seconds=5),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_async_callable_object(self):
|
||||
class Adder:
|
||||
"""Adds two numbers."""
|
||||
|
||||
async def __call__(self, x: int, y: int) -> int:
|
||||
"""ignore this"""
|
||||
return x + y
|
||||
|
||||
tool = Tool.from_function(Adder())
|
||||
|
||||
assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot(
|
||||
{
|
||||
"name": "Adder",
|
||||
"description": "Adds two numbers.",
|
||||
"tags": set(),
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"x": {"type": "integer"},
|
||||
"y": {"type": "integer"},
|
||||
},
|
||||
"required": ["x", "y"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"task_config": {
|
||||
"mode": "forbidden",
|
||||
"poll_interval": timedelta(seconds=5),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_pydantic_model_function(self):
|
||||
"""Test registering a function that takes a Pydantic model."""
|
||||
|
||||
class UserInput(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def create_user(user: UserInput, flag: bool) -> dict:
|
||||
"""Create a new user."""
|
||||
return {"id": 1, **user.model_dump()}
|
||||
|
||||
tool = Tool.from_function(create_user)
|
||||
|
||||
assert tool.model_dump(exclude_none=True) == snapshot(
|
||||
{
|
||||
"name": "create_user",
|
||||
"description": "Create a new user.",
|
||||
"tags": set(),
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"user": {
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name", "age"],
|
||||
"type": "object",
|
||||
},
|
||||
"flag": {"type": "boolean"},
|
||||
},
|
||||
"required": ["user", "flag"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {"additionalProperties": True, "type": "object"},
|
||||
"fn": HasName("create_user"),
|
||||
"task_config": {
|
||||
"mode": "forbidden",
|
||||
"poll_interval": timedelta(seconds=5),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async def test_tool_with_image_return(self):
|
||||
def image_tool(data: bytes) -> Image:
|
||||
return Image(data=data)
|
||||
|
||||
tool = Tool.from_function(image_tool)
|
||||
assert tool.parameters["properties"]["data"]["type"] == "string"
|
||||
assert tool.output_schema is None
|
||||
|
||||
result = await tool.run({"data": "test.png"})
|
||||
assert isinstance(result.content[0], ImageContent)
|
||||
|
||||
async def test_tool_with_audio_return(self):
|
||||
def audio_tool(data: bytes) -> Audio:
|
||||
return Audio(data=data)
|
||||
|
||||
tool = Tool.from_function(audio_tool)
|
||||
assert tool.parameters["properties"]["data"]["type"] == "string"
|
||||
assert tool.output_schema is None
|
||||
|
||||
result = await tool.run({"data": "test.wav"})
|
||||
assert isinstance(result.content[0], AudioContent)
|
||||
|
||||
async def test_tool_with_file_return(self):
|
||||
from pydantic import AnyUrl
|
||||
|
||||
def file_tool(data: bytes) -> File:
|
||||
return File(data=data, format="octet-stream")
|
||||
|
||||
tool = Tool.from_function(file_tool)
|
||||
assert tool.parameters["properties"]["data"]["type"] == "string"
|
||||
assert tool.output_schema is None
|
||||
|
||||
result: ToolResult = await tool.run({"data": "test.bin"})
|
||||
assert result.content[0].model_dump(exclude_none=True) == snapshot(
|
||||
{
|
||||
"type": "resource",
|
||||
"resource": {
|
||||
"uri": AnyUrl("file:///resource.octet-stream"),
|
||||
"mimeType": "application/octet-stream",
|
||||
"blob": "dGVzdC5iaW4=",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_non_callable_fn(self):
|
||||
with pytest.raises(TypeError, match="not a callable object"):
|
||||
Tool.from_function(1) # type: ignore
|
||||
|
||||
def test_lambda(self):
|
||||
tool = Tool.from_function(lambda x: x, name="my_tool")
|
||||
assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot(
|
||||
{
|
||||
"name": "my_tool",
|
||||
"tags": set(),
|
||||
"parameters": {
|
||||
"properties": {"x": {"title": "X"}},
|
||||
"required": ["x"],
|
||||
"type": "object",
|
||||
},
|
||||
"task_config": {
|
||||
"mode": "forbidden",
|
||||
"poll_interval": timedelta(seconds=5),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_lambda_with_no_name(self):
|
||||
with pytest.raises(
|
||||
ValueError, match="You must provide a name for lambda functions"
|
||||
):
|
||||
Tool.from_function(lambda x: x)
|
||||
|
||||
def test_private_arguments(self):
|
||||
def add(_a: int, _b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return _a + _b
|
||||
|
||||
tool = Tool.from_function(add)
|
||||
|
||||
assert tool.model_dump(
|
||||
exclude_none=True, exclude={"output_schema", "fn"}
|
||||
) == snapshot(
|
||||
{
|
||||
"name": "add",
|
||||
"description": "Add two numbers.",
|
||||
"tags": set(),
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"_a": {"type": "integer"},
|
||||
"_b": {"type": "integer"},
|
||||
},
|
||||
"required": ["_a", "_b"],
|
||||
"type": "object",
|
||||
},
|
||||
"task_config": {
|
||||
"mode": "forbidden",
|
||||
"poll_interval": timedelta(seconds=5),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_tool_with_varargs_not_allowed(self):
|
||||
def func(a: int, b: int, *args: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*args are not supported as tools"
|
||||
):
|
||||
Tool.from_function(func)
|
||||
|
||||
def test_tool_with_varkwargs_not_allowed(self):
|
||||
def func(a: int, b: int, **kwargs: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
|
||||
):
|
||||
Tool.from_function(func)
|
||||
|
||||
async def test_instance_method(self):
|
||||
class MyClass:
|
||||
def add(self, x: int, y: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return x + y
|
||||
|
||||
obj = MyClass()
|
||||
|
||||
tool = Tool.from_function(obj.add)
|
||||
assert "self" not in tool.parameters["properties"]
|
||||
|
||||
assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot(
|
||||
{
|
||||
"name": "add",
|
||||
"description": "Add two numbers.",
|
||||
"tags": set(),
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"x": {"type": "integer"},
|
||||
"y": {"type": "integer"},
|
||||
},
|
||||
"required": ["x", "y"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"task_config": {
|
||||
"mode": "forbidden",
|
||||
"poll_interval": timedelta(seconds=5),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async def test_instance_method_with_varargs_not_allowed(self):
|
||||
class MyClass:
|
||||
def add(self, x: int, y: int, *args: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return x + y
|
||||
|
||||
obj = MyClass()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*args are not supported as tools"
|
||||
):
|
||||
Tool.from_function(obj.add)
|
||||
|
||||
async def test_instance_method_with_varkwargs_not_allowed(self):
|
||||
class MyClass:
|
||||
def add(self, x: int, y: int, **kwargs: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return x + y
|
||||
|
||||
obj = MyClass()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
|
||||
):
|
||||
Tool.from_function(obj.add)
|
||||
|
||||
async def test_classmethod(self):
|
||||
class MyClass:
|
||||
x: int = 10
|
||||
|
||||
@classmethod
|
||||
def call(cls, x: int, y: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return x + y
|
||||
|
||||
tool = Tool.from_function(MyClass.call)
|
||||
assert tool.name == "call"
|
||||
assert tool.description == "Add two numbers."
|
||||
assert "x" in tool.parameters["properties"]
|
||||
assert "y" in tool.parameters["properties"]
|
||||
|
||||
|
||||
class TestToolNameValidation:
|
||||
"""Tests for tool name validation per MCP specification (SEP-986)."""
|
||||
|
||||
@pytest.fixture
|
||||
def caplog_for_mcp_validation(self, caplog):
|
||||
"""Capture logs from the MCP SDK's tool name validation logger."""
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING)
|
||||
logger = logging.getLogger("mcp.shared.tool_name_validation")
|
||||
original_level = logger.level
|
||||
logger.setLevel(logging.WARNING)
|
||||
logger.addHandler(caplog.handler)
|
||||
try:
|
||||
yield caplog
|
||||
finally:
|
||||
logger.removeHandler(caplog.handler)
|
||||
logger.setLevel(original_level)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"valid_tool",
|
||||
"valid-tool",
|
||||
"valid.tool",
|
||||
"ValidTool",
|
||||
"tool123",
|
||||
"a",
|
||||
"a" * 128,
|
||||
],
|
||||
)
|
||||
def test_valid_tool_names_no_warnings(self, name, caplog_for_mcp_validation):
|
||||
"""Valid tool names should not produce warnings."""
|
||||
|
||||
def fn() -> str:
|
||||
return "test"
|
||||
|
||||
tool = Tool.from_function(fn, name=name)
|
||||
assert tool.name == name
|
||||
assert "Tool name validation warning" not in caplog_for_mcp_validation.text
|
||||
|
||||
def test_tool_name_with_spaces_warns(self, caplog_for_mcp_validation):
|
||||
"""Tool names with spaces should produce a warning."""
|
||||
|
||||
def fn() -> str:
|
||||
return "test"
|
||||
|
||||
tool = Tool.from_function(fn, name="my tool")
|
||||
assert tool.name == "my tool"
|
||||
assert "Tool name validation warning" in caplog_for_mcp_validation.text
|
||||
assert "contains spaces" in caplog_for_mcp_validation.text
|
||||
|
||||
def test_tool_name_with_invalid_chars_warns(self, caplog_for_mcp_validation):
|
||||
"""Tool names with invalid characters should produce a warning."""
|
||||
|
||||
def fn() -> str:
|
||||
return "test"
|
||||
|
||||
tool = Tool.from_function(fn, name="tool@name!")
|
||||
assert tool.name == "tool@name!"
|
||||
assert "Tool name validation warning" in caplog_for_mcp_validation.text
|
||||
assert "invalid characters" in caplog_for_mcp_validation.text
|
||||
|
||||
def test_tool_name_too_long_warns(self, caplog_for_mcp_validation):
|
||||
"""Tool names exceeding 128 characters should produce a warning."""
|
||||
|
||||
def fn() -> str:
|
||||
return "test"
|
||||
|
||||
long_name = "a" * 129
|
||||
tool = Tool.from_function(fn, name=long_name)
|
||||
assert tool.name == long_name
|
||||
assert "Tool name validation warning" in caplog_for_mcp_validation.text
|
||||
assert "exceeds maximum length" in caplog_for_mcp_validation.text
|
||||
|
||||
def test_tool_name_with_leading_dash_warns(self, caplog_for_mcp_validation):
|
||||
"""Tool names starting with dash should produce a warning."""
|
||||
|
||||
def fn() -> str:
|
||||
return "test"
|
||||
|
||||
tool = Tool.from_function(fn, name="-tool")
|
||||
assert tool.name == "-tool"
|
||||
assert "Tool name validation warning" in caplog_for_mcp_validation.text
|
||||
assert "starts or ends with a dash" in caplog_for_mcp_validation.text
|
||||
|
||||
def test_tool_still_created_despite_warnings(self, caplog_for_mcp_validation):
|
||||
"""Tools with invalid names should still be created (SHOULD not MUST)."""
|
||||
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
tool = Tool.from_function(add, name="invalid tool name!")
|
||||
assert tool.name == "invalid tool name!"
|
||||
assert tool.parameters is not None
|
||||
assert "a" in tool.parameters["properties"]
|
||||
assert "b" in tool.parameters["properties"]
|
||||
|
||||
|
||||
class TestToolExecutionField:
|
||||
"""Tests for the execution field on the base Tool class."""
|
||||
|
||||
def test_tool_with_execution_field(self):
|
||||
"""Test that Tool can store and return execution metadata."""
|
||||
tool = Tool(
|
||||
name="my_tool",
|
||||
description="A tool with execution",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
execution=ToolExecution(taskSupport="optional"),
|
||||
)
|
||||
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
assert mcp_tool.execution is not None
|
||||
assert mcp_tool.execution.taskSupport == "optional"
|
||||
|
||||
def test_tool_without_execution_field(self):
|
||||
"""Test that Tool without execution returns None."""
|
||||
tool = Tool(
|
||||
name="my_tool",
|
||||
description="A tool without execution",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
assert mcp_tool.execution is None
|
||||
|
||||
def test_execution_override_takes_precedence(self):
|
||||
"""Test that explicit override takes precedence over field value."""
|
||||
tool = Tool(
|
||||
name="my_tool",
|
||||
description="A tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
execution=ToolExecution(taskSupport="optional"),
|
||||
)
|
||||
|
||||
override_execution = ToolExecution(taskSupport="required")
|
||||
mcp_tool = tool.to_mcp_tool(execution=override_execution)
|
||||
assert mcp_tool.execution is not None
|
||||
assert mcp_tool.execution.taskSupport == "required"
|
||||
|
||||
async def test_function_tool_task_config_still_works(self):
|
||||
"""FunctionTool should still derive execution from task_config."""
|
||||
|
||||
async def my_fn() -> str:
|
||||
return "hello"
|
||||
|
||||
tool = Tool.from_function(my_fn, task=True)
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
|
||||
# FunctionTool sets execution from task_config
|
||||
assert mcp_tool.execution is not None
|
||||
assert mcp_tool.execution.taskSupport == "optional"
|
||||
|
||||
def test_tool_execution_required_mode(self):
|
||||
"""Test that Tool can store required execution mode."""
|
||||
tool = Tool(
|
||||
name="my_tool",
|
||||
description="A tool with required execution",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
execution=ToolExecution(taskSupport="required"),
|
||||
)
|
||||
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
assert mcp_tool.execution is not None
|
||||
assert mcp_tool.execution.taskSupport == "required"
|
||||
|
||||
def test_tool_execution_forbidden_mode(self):
|
||||
"""Test that Tool can store forbidden execution mode."""
|
||||
tool = Tool(
|
||||
name="my_tool",
|
||||
description="A tool with forbidden execution",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
execution=ToolExecution(taskSupport="forbidden"),
|
||||
)
|
||||
|
||||
mcp_tool = tool.to_mcp_tool()
|
||||
assert mcp_tool.execution is not None
|
||||
assert mcp_tool.execution.taskSupport == "forbidden"
|
||||
0
tests/tools/tool_transform/__init__.py
Normal file
0
tests/tools/tool_transform/__init__.py
Normal file
456
tests/tools/tool_transform/test_args.py
Normal file
456
tests/tools/tool_transform/test_args.py
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
"""Tests for argument transformation in tool transforms."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from mcp.types import TextContent
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client.client import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.tools import Tool, forward, forward_raw
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool_transform import (
|
||||
ArgTransform,
|
||||
)
|
||||
|
||||
|
||||
def get_property(tool: Tool, name: str) -> dict[str, Any]:
|
||||
return tool.parameters["properties"][name]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def add_tool() -> FunctionTool:
|
||||
def add(
|
||||
old_x: Annotated[int, Field(description="old_x description")], old_y: int = 10
|
||||
) -> int:
|
||||
print("running!")
|
||||
return old_x + old_y
|
||||
|
||||
return Tool.from_function(add)
|
||||
|
||||
|
||||
async def test_tool_transform_chaining(add_tool):
|
||||
"""Test that transformed tools can be transformed again."""
|
||||
# First transformation: a -> x
|
||||
tool1 = Tool.from_tool(add_tool, transform_args={"old_x": ArgTransform(name="x")})
|
||||
|
||||
# Second transformation: x -> final_x, using tool1
|
||||
tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
|
||||
|
||||
result = await tool2.run(arguments={"final_x": 5})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "15"
|
||||
|
||||
# Transform tool1 with custom function that handles all parameters
|
||||
async def custom(final_x: int, **kwargs) -> str:
|
||||
result = await forward(final_x=final_x, **kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return f"custom {result.content[0].text}" # Extract text from content
|
||||
|
||||
tool3 = Tool.from_tool(
|
||||
tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
|
||||
)
|
||||
result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "custom 8"
|
||||
|
||||
|
||||
class MyModel(BaseModel):
|
||||
x: int
|
||||
y: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyDataclass:
|
||||
x: int
|
||||
y: str
|
||||
|
||||
|
||||
class MyTypedDict(TypedDict):
|
||||
x: int
|
||||
y: str
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"py_type, json_type",
|
||||
[
|
||||
(int, "integer"),
|
||||
(str, "string"),
|
||||
(float, "number"),
|
||||
(bool, "boolean"),
|
||||
(MyModel, "object"),
|
||||
(MyDataclass, "object"),
|
||||
(MyTypedDict, "object"),
|
||||
],
|
||||
)
|
||||
def test_arg_transform_type_handling(add_tool, py_type, json_type):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_x": ArgTransform(type=py_type)}
|
||||
)
|
||||
prop = get_property(new_tool, "old_x")
|
||||
assert prop["type"] == json_type
|
||||
|
||||
|
||||
def test_arg_transform_annotated_types(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(
|
||||
type=Annotated[int, Field(ge=0, le=100)], description="A number 0-100"
|
||||
)
|
||||
},
|
||||
)
|
||||
prop = get_property(new_tool, "old_x")
|
||||
assert prop["type"] == "integer"
|
||||
assert prop["description"] == "A number 0-100"
|
||||
assert prop["minimum"] == 0
|
||||
assert prop["maximum"] == 100
|
||||
|
||||
|
||||
def test_arg_transform_precedence_over_function_without_kwargs():
|
||||
def base(x: int) -> int:
|
||||
return x
|
||||
|
||||
tool = Tool.from_function(base)
|
||||
new_tool = Tool.from_tool(
|
||||
tool, transform_args={"x": ArgTransform(type=str, description="String input")}
|
||||
)
|
||||
|
||||
prop = get_property(new_tool, "x")
|
||||
assert prop["type"] == "string"
|
||||
assert prop["description"] == "String input"
|
||||
|
||||
|
||||
async def test_arg_transform_precedence_over_function_with_kwargs():
|
||||
"""Test that ArgTransform attributes take precedence over function signature (with **kwargs)."""
|
||||
|
||||
@Tool.from_function
|
||||
def base(x: int, y: str = "base_default") -> str:
|
||||
return f"{x}: {y}"
|
||||
|
||||
# Function signature has different types/defaults than ArgTransform
|
||||
async def custom_fn(x: str = "function_default", **kwargs) -> str:
|
||||
result = await forward(x=x, **kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return f"custom: {result.content[0].text}"
|
||||
|
||||
tool = Tool.from_tool(
|
||||
base,
|
||||
transform_fn=custom_fn,
|
||||
transform_args={
|
||||
"x": ArgTransform(type=int, default=42), # Different type and default
|
||||
"y": ArgTransform(description="ArgTransform description"),
|
||||
},
|
||||
)
|
||||
|
||||
# ArgTransform should take precedence
|
||||
x_prop = get_property(tool, "x")
|
||||
y_prop = get_property(tool, "y")
|
||||
|
||||
assert x_prop["type"] == "integer" # ArgTransform type wins over function's str
|
||||
assert x_prop["default"] == 42 # ArgTransform default wins over function's default
|
||||
assert (
|
||||
y_prop["description"] == "ArgTransform description"
|
||||
) # ArgTransform description
|
||||
|
||||
# x should not be required due to ArgTransform default
|
||||
assert "x" not in tool.parameters["required"]
|
||||
|
||||
# Test it works at runtime
|
||||
result = await tool.run(arguments={"y": "test"})
|
||||
# Should use ArgTransform default of 42
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "42: test" in result.content[0].text
|
||||
|
||||
|
||||
def test_arg_transform_combined_attributes(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(
|
||||
name="new_x",
|
||||
description="New description",
|
||||
type=str,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
prop = get_property(new_tool, "new_x")
|
||||
assert prop["type"] == "string"
|
||||
assert prop["description"] == "New description"
|
||||
assert "old_x" not in new_tool.parameters["properties"]
|
||||
|
||||
|
||||
async def test_arg_transform_type_precedence_runtime():
|
||||
"""Test that ArgTransform type changes work correctly at runtime."""
|
||||
|
||||
@Tool.from_function
|
||||
def base(x: int, y: int = 10) -> int:
|
||||
return x + y
|
||||
|
||||
# Transform x to string type but keep same logic
|
||||
async def custom_fn(x: str, y: int = 10) -> str:
|
||||
# Convert string back to int for the original function
|
||||
result = await forward_raw(x=int(x), y=y)
|
||||
# Extract the text from the result
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
result_text = result.content[0].text
|
||||
return f"String input '{x}' converted to result: {result_text}"
|
||||
|
||||
tool = Tool.from_tool(
|
||||
base, transform_fn=custom_fn, transform_args={"x": ArgTransform(type=str)}
|
||||
)
|
||||
|
||||
# Verify schema shows string type
|
||||
assert get_property(tool, "x")["type"] == "string"
|
||||
|
||||
# Test it works with string input
|
||||
result = await tool.run(arguments={"x": "5", "y": 3})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "String input '5'" in result.content[0].text
|
||||
assert "result: 8" in result.content[0].text
|
||||
|
||||
|
||||
async def test_arg_transform_default_factory():
|
||||
"""Test ArgTransform with default_factory for hidden parameters."""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
@Tool.from_function
|
||||
def base_tool(x: int, timestamp: float) -> str:
|
||||
return f"{x}_{timestamp}"
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
base_tool,
|
||||
transform_args={
|
||||
"timestamp": ArgTransform(hide=True, default_factory=time.time)
|
||||
},
|
||||
)
|
||||
|
||||
result1 = await new_tool.run(arguments={"x": 1})
|
||||
await asyncio.sleep(0.01)
|
||||
result2 = await new_tool.run(arguments={"x": 2})
|
||||
|
||||
# Each call should get a different timestamp
|
||||
assert isinstance(result1.content[0], TextContent)
|
||||
assert isinstance(result2.content[0], TextContent)
|
||||
assert result1.content[0].text != result2.content[0].text
|
||||
assert "1_" in result1.content[0].text
|
||||
assert "2_" in result2.content[0].text
|
||||
|
||||
|
||||
async def test_arg_transform_default_factory_called_each_time():
|
||||
"""Test that default_factory is called for each tool execution."""
|
||||
|
||||
call_count = {"count": 0}
|
||||
|
||||
def get_counter():
|
||||
call_count["count"] += 1
|
||||
return call_count["count"]
|
||||
|
||||
@Tool.from_function
|
||||
def base_tool(x: int, counter: int) -> str:
|
||||
return f"{x}_{counter}"
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
base_tool,
|
||||
transform_args={
|
||||
"counter": ArgTransform(hide=True, default_factory=get_counter)
|
||||
},
|
||||
)
|
||||
|
||||
result1 = await new_tool.run(arguments={"x": 1})
|
||||
result2 = await new_tool.run(arguments={"x": 2})
|
||||
result3 = await new_tool.run(arguments={"x": 3})
|
||||
|
||||
# Each call should increment the counter
|
||||
assert isinstance(result1.content[0], TextContent)
|
||||
assert isinstance(result2.content[0], TextContent)
|
||||
assert isinstance(result3.content[0], TextContent)
|
||||
assert "1_1" in result1.content[0].text
|
||||
assert "2_2" in result2.content[0].text
|
||||
assert "3_3" in result3.content[0].text
|
||||
|
||||
|
||||
async def test_arg_transform_hidden_with_default_factory():
|
||||
"""Test that hidden parameters with default_factory work correctly."""
|
||||
|
||||
@Tool.from_function
|
||||
def base_tool(x: int, session_id: str) -> str:
|
||||
return f"{x}_{session_id}"
|
||||
|
||||
import uuid
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
base_tool,
|
||||
transform_args={
|
||||
"session_id": ArgTransform(
|
||||
hide=True, default_factory=lambda: str(uuid.uuid4())
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
result = await new_tool.run(arguments={"x": 1})
|
||||
# Should have a UUID in the result
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "1_" in result.content[0].text
|
||||
assert len(result.content[0].text.split("_")[1]) > 10
|
||||
|
||||
|
||||
async def test_arg_transform_default_and_factory_raises_error():
|
||||
"""Test that providing both default and default_factory raises an error."""
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot specify both 'default' and 'default_factory'"
|
||||
):
|
||||
ArgTransform(default=10, default_factory=lambda: 20)
|
||||
|
||||
|
||||
async def test_arg_transform_default_factory_requires_hide():
|
||||
"""Test that default_factory requires hide=True."""
|
||||
with pytest.raises(
|
||||
ValueError, match="default_factory can only be used with hide=True"
|
||||
):
|
||||
ArgTransform(default_factory=lambda: 10)
|
||||
|
||||
|
||||
async def test_arg_transform_required_true(add_tool):
|
||||
"""Test ArgTransform with required=True."""
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_args={"old_y": ArgTransform(required=True)},
|
||||
)
|
||||
|
||||
# old_y should now be required (even though it had a default)
|
||||
assert "old_y" in new_tool.parameters["required"]
|
||||
|
||||
|
||||
async def test_arg_transform_required_false():
|
||||
"""Test ArgTransform with required=False by setting a default."""
|
||||
|
||||
def func(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
# Setting a default makes it not required
|
||||
new_tool = Tool.from_tool(tool, transform_args={"y": ArgTransform(default=0)})
|
||||
|
||||
# y should not be required since it has a default
|
||||
assert "y" not in new_tool.parameters.get("required", [])
|
||||
|
||||
|
||||
async def test_arg_transform_required_with_rename(add_tool):
|
||||
"""Test ArgTransform with required and rename."""
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_args={"old_y": ArgTransform(name="new_y", required=True)},
|
||||
)
|
||||
|
||||
# new_y should be required
|
||||
assert "new_y" in new_tool.parameters["required"]
|
||||
assert "old_y" not in new_tool.parameters["properties"]
|
||||
|
||||
|
||||
async def test_arg_transform_required_true_with_default_raises_error():
|
||||
"""Test that required=True with default raises an error."""
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot specify 'required=True' with 'default'"
|
||||
):
|
||||
ArgTransform(required=True, default=42)
|
||||
|
||||
|
||||
async def test_arg_transform_required_true_with_factory_raises_error():
|
||||
"""Test that required=True with default_factory raises an error."""
|
||||
with pytest.raises(
|
||||
ValueError, match="default_factory can only be used with hide=True"
|
||||
):
|
||||
ArgTransform(required=True, default_factory=lambda: 42)
|
||||
|
||||
|
||||
async def test_arg_transform_required_no_change():
|
||||
"""Test that not specifying required doesn't change existing required status."""
|
||||
|
||||
def func(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
# Both x and y are required in original
|
||||
assert "x" in tool.parameters["required"]
|
||||
assert "y" in tool.parameters["required"]
|
||||
|
||||
# Not specifying required should keep x required
|
||||
new_tool = Tool.from_tool(
|
||||
tool, transform_args={"x": ArgTransform(description="Updated x")}
|
||||
)
|
||||
|
||||
# x should still be required, and y should still be
|
||||
assert "x" in new_tool.parameters.get("required", [])
|
||||
assert "y" in new_tool.parameters["required"]
|
||||
|
||||
|
||||
async def test_arg_transform_hide_and_required_raises_error():
|
||||
"""Test that hide=True and required=True together raises an error."""
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot specify both 'hide=True' and 'required=True'"
|
||||
):
|
||||
ArgTransform(hide=True, required=True)
|
||||
|
||||
|
||||
class TestEnableDisable:
|
||||
async def test_transform_disabled_tool(self):
|
||||
"""
|
||||
Tests that a transformed tool can run even if the parent tool is disabled via server.
|
||||
"""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def add(x: int, y: int = 10) -> int:
|
||||
return x + y
|
||||
|
||||
# Get the registered Tool object from the server
|
||||
add_tool = await mcp._local_provider.get_tool("add")
|
||||
assert isinstance(add_tool, Tool)
|
||||
new_add = Tool.from_tool(add_tool, name="new_add")
|
||||
mcp.add_tool(new_add)
|
||||
|
||||
# Disable original tool, but new_add should still work
|
||||
mcp.disable(names={"add"}, components={"tool"})
|
||||
|
||||
async with Client(mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert {tool.name for tool in tools} == {"new_add"}
|
||||
|
||||
result = await client.call_tool("new_add", {"x": 1, "y": 2})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "3"
|
||||
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool("add", {"x": 1, "y": 2})
|
||||
|
||||
async def test_disable_transformed_tool(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def add(x: int, y: int = 10) -> int:
|
||||
return x + y
|
||||
|
||||
# Get the registered Tool object from the server
|
||||
add_tool = await mcp._local_provider.get_tool("add")
|
||||
assert isinstance(add_tool, Tool)
|
||||
new_add = Tool.from_tool(add_tool, name="new_add")
|
||||
mcp.add_tool(new_add)
|
||||
|
||||
# Disable both tools via server
|
||||
mcp.disable(names={"add"}, components={"tool"}).disable(
|
||||
names={"new_add"}, components={"tool"}
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert len(tools) == 0
|
||||
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool("new_add", {"x": 1, "y": 2})
|
||||
172
tests/tools/tool_transform/test_metadata.py
Normal file
172
tests/tools/tool_transform/test_metadata.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from pydantic import Field
|
||||
|
||||
from fastmcp.tools import Tool
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool_transform import (
|
||||
ToolTransformConfig,
|
||||
)
|
||||
|
||||
|
||||
def get_property(tool: Tool, name: str) -> dict[str, Any]:
|
||||
return tool.parameters["properties"][name]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def add_tool() -> FunctionTool:
|
||||
def add(
|
||||
old_x: Annotated[int, Field(description="old_x description")], old_y: int = 10
|
||||
) -> int:
|
||||
print("running!")
|
||||
return old_x + old_y
|
||||
|
||||
return Tool.from_function(add)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tool():
|
||||
"""Sample tool for testing transformations."""
|
||||
|
||||
def sample_func(x: int) -> str:
|
||||
return f"Result: {x}"
|
||||
|
||||
return Tool.from_function(
|
||||
sample_func,
|
||||
name="sample_tool",
|
||||
title="Original Tool Title",
|
||||
description="Original description",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tool_no_title():
|
||||
"""Sample tool without title for testing."""
|
||||
|
||||
def sample_func(x: int) -> str:
|
||||
return f"Result: {x}"
|
||||
|
||||
return Tool.from_function(sample_func, name="no_title_tool")
|
||||
|
||||
|
||||
def test_transform_inherits_title(sample_tool):
|
||||
"""Test that transformed tools inherit title when none specified."""
|
||||
transformed = Tool.from_tool(sample_tool)
|
||||
assert transformed.title == "Original Tool Title"
|
||||
|
||||
|
||||
def test_transform_overrides_title(sample_tool):
|
||||
"""Test that transformed tools can override title."""
|
||||
transformed = Tool.from_tool(sample_tool, title="New Tool Title")
|
||||
assert transformed.title == "New Tool Title"
|
||||
|
||||
|
||||
def test_transform_sets_title_to_none(sample_tool):
|
||||
"""Test that transformed tools can explicitly set title to None."""
|
||||
transformed = Tool.from_tool(sample_tool, title=None)
|
||||
assert transformed.title is None
|
||||
|
||||
|
||||
def test_transform_inherits_none_title(sample_tool_no_title):
|
||||
"""Test that transformed tools inherit None title."""
|
||||
transformed = Tool.from_tool(sample_tool_no_title)
|
||||
assert transformed.title is None
|
||||
|
||||
|
||||
def test_transform_adds_title_to_none(sample_tool_no_title):
|
||||
"""Test that transformed tools can add title when parent has None."""
|
||||
transformed = Tool.from_tool(sample_tool_no_title, title="Added Title")
|
||||
assert transformed.title == "Added Title"
|
||||
|
||||
|
||||
def test_transform_inherits_description(sample_tool):
|
||||
"""Test that transformed tools inherit description when none specified."""
|
||||
transformed = Tool.from_tool(sample_tool)
|
||||
assert transformed.description == "Original description"
|
||||
|
||||
|
||||
def test_transform_overrides_description(sample_tool):
|
||||
"""Test that transformed tools can override description."""
|
||||
transformed = Tool.from_tool(sample_tool, description="New description")
|
||||
assert transformed.description == "New description"
|
||||
|
||||
|
||||
def test_transform_sets_description_to_none(sample_tool):
|
||||
"""Test that transformed tools can explicitly set description to None."""
|
||||
transformed = Tool.from_tool(sample_tool, description=None)
|
||||
assert transformed.description is None
|
||||
|
||||
|
||||
def test_transform_inherits_none_description(sample_tool_no_title):
|
||||
"""Test that transformed tools inherit None description."""
|
||||
transformed = Tool.from_tool(sample_tool_no_title)
|
||||
assert transformed.description is None
|
||||
|
||||
|
||||
def test_transform_adds_description_to_none(sample_tool_no_title):
|
||||
"""Test that transformed tools can add description when parent has None."""
|
||||
transformed = Tool.from_tool(sample_tool_no_title, description="Added description")
|
||||
assert transformed.description == "Added description"
|
||||
|
||||
|
||||
# Meta transformation tests
|
||||
def test_transform_inherits_meta(sample_tool):
|
||||
"""Test that transformed tools inherit meta when none specified."""
|
||||
sample_tool.meta = {"original": True, "version": "1.0"}
|
||||
transformed = Tool.from_tool(sample_tool)
|
||||
assert transformed.meta == {"original": True, "version": "1.0"}
|
||||
|
||||
|
||||
def test_transform_overrides_meta(sample_tool):
|
||||
"""Test that transformed tools can override meta."""
|
||||
sample_tool.meta = {"original": True, "version": "1.0"}
|
||||
transformed = Tool.from_tool(sample_tool, meta={"custom": True, "priority": "high"})
|
||||
assert transformed.meta == {"custom": True, "priority": "high"}
|
||||
|
||||
|
||||
def test_transform_sets_meta_to_none(sample_tool):
|
||||
"""Test that transformed tools can explicitly set meta to None."""
|
||||
sample_tool.meta = {"original": True, "version": "1.0"}
|
||||
transformed = Tool.from_tool(sample_tool, meta=None)
|
||||
assert transformed.meta is None
|
||||
|
||||
|
||||
def test_transform_inherits_none_meta(sample_tool_no_title):
|
||||
"""Test that transformed tools inherit None meta."""
|
||||
sample_tool_no_title.meta = None
|
||||
transformed = Tool.from_tool(sample_tool_no_title)
|
||||
assert transformed.meta is None
|
||||
|
||||
|
||||
def test_transform_adds_meta_to_none(sample_tool_no_title):
|
||||
"""Test that transformed tools can add meta when parent has None."""
|
||||
sample_tool_no_title.meta = None
|
||||
transformed = Tool.from_tool(sample_tool_no_title, meta={"added": True})
|
||||
assert transformed.meta == {"added": True}
|
||||
|
||||
|
||||
def test_tool_transform_config_inherits_meta(sample_tool):
|
||||
"""Test that ToolTransformConfig inherits meta when unset."""
|
||||
sample_tool.meta = {"original": True, "version": "1.0"}
|
||||
config = ToolTransformConfig(name="config_tool")
|
||||
transformed = config.apply(sample_tool)
|
||||
assert transformed.meta == {"original": True, "version": "1.0"}
|
||||
|
||||
|
||||
def test_tool_transform_config_overrides_meta(sample_tool):
|
||||
"""Test that ToolTransformConfig can override meta."""
|
||||
sample_tool.meta = {"original": True, "version": "1.0"}
|
||||
config = ToolTransformConfig(
|
||||
name="config_tool", meta={"config": True, "priority": "high"}
|
||||
)
|
||||
transformed = config.apply(sample_tool)
|
||||
assert transformed.meta == {"config": True, "priority": "high"}
|
||||
|
||||
|
||||
def test_tool_transform_config_removes_meta(sample_tool):
|
||||
"""Test that ToolTransformConfig can remove meta with None."""
|
||||
sample_tool.meta = {"original": True, "version": "1.0"}
|
||||
config = ToolTransformConfig(name="config_tool", meta=None)
|
||||
transformed = config.apply(sample_tool)
|
||||
assert transformed.meta is None
|
||||
534
tests/tools/tool_transform/test_schemas.py
Normal file
534
tests/tools/tool_transform/test_schemas.py
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from dirty_equals import IsList
|
||||
from inline_snapshot import snapshot
|
||||
from mcp.types import TextContent
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
|
||||
from fastmcp.tools import Tool, forward
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from fastmcp.tools.tool_transform import (
|
||||
ArgTransform,
|
||||
TransformedTool,
|
||||
)
|
||||
|
||||
|
||||
def get_property(tool: Tool, name: str) -> dict[str, Any]:
|
||||
return tool.parameters["properties"][name]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def add_tool() -> FunctionTool:
|
||||
def add(
|
||||
old_x: Annotated[int, Field(description="old_x description")], old_y: int = 10
|
||||
) -> int:
|
||||
print("running!")
|
||||
return old_x + old_y
|
||||
|
||||
return Tool.from_function(add)
|
||||
|
||||
|
||||
class TestTransformToolOutputSchema:
|
||||
"""Test output schema handling in transformed tools."""
|
||||
|
||||
@pytest.fixture
|
||||
def base_string_tool(self) -> FunctionTool:
|
||||
"""Tool that returns a string (gets wrapped)."""
|
||||
|
||||
def string_tool(x: int) -> str:
|
||||
return f"Result: {x}"
|
||||
|
||||
return Tool.from_function(string_tool)
|
||||
|
||||
@pytest.fixture
|
||||
def base_dict_tool(self) -> FunctionTool:
|
||||
"""Tool that returns a dict (object type, not wrapped)."""
|
||||
|
||||
def dict_tool(x: int) -> dict[str, int]:
|
||||
return {"value": x}
|
||||
|
||||
return Tool.from_function(dict_tool)
|
||||
|
||||
def test_transform_inherits_parent_output_schema(self, base_string_tool):
|
||||
"""Test that transformed tool inherits parent's output schema by default."""
|
||||
new_tool = Tool.from_tool(base_string_tool)
|
||||
|
||||
# Should inherit parent's wrapped string schema
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert new_tool.output_schema == expected_schema
|
||||
assert new_tool.output_schema == base_string_tool.output_schema
|
||||
|
||||
def test_transform_with_explicit_output_schema_none(self, base_string_tool):
|
||||
"""Test that output_schema=None sets output schema to None."""
|
||||
new_tool = Tool.from_tool(base_string_tool, output_schema=None)
|
||||
|
||||
assert new_tool.output_schema is None
|
||||
|
||||
async def test_transform_output_schema_none_runtime(self, base_string_tool):
|
||||
"""Test runtime behavior with output_schema=None."""
|
||||
new_tool = Tool.from_tool(base_string_tool, output_schema=None)
|
||||
|
||||
# Debug: check that output_schema is actually None
|
||||
assert new_tool.output_schema is None, (
|
||||
f"Expected None, got {new_tool.output_schema}"
|
||||
)
|
||||
|
||||
result = await new_tool.run({"x": 5})
|
||||
# Even with output_schema=None, structured content should be generated via fallback logic
|
||||
assert result.structured_content == {"result": "Result: 5"}
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Result: 5"
|
||||
|
||||
def test_transform_with_explicit_output_schema_dict(self, base_string_tool):
|
||||
"""Test that explicit output schema overrides parent."""
|
||||
custom_schema = {
|
||||
"type": "object",
|
||||
"properties": {"message": {"type": "string"}},
|
||||
}
|
||||
new_tool = Tool.from_tool(base_string_tool, output_schema=custom_schema)
|
||||
|
||||
assert new_tool.output_schema == custom_schema
|
||||
assert new_tool.output_schema != base_string_tool.output_schema
|
||||
|
||||
async def test_transform_explicit_schema_runtime(self, base_string_tool):
|
||||
"""Test runtime behavior with explicit output schema."""
|
||||
custom_schema = {"type": "string", "minLength": 1}
|
||||
new_tool = Tool.from_tool(base_string_tool, output_schema=custom_schema)
|
||||
|
||||
result = await new_tool.run({"x": 10})
|
||||
# Non-object explicit schemas disable structured content
|
||||
assert result.structured_content is None
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Result: 10"
|
||||
|
||||
def test_transform_with_custom_function_inferred_schema(self, base_dict_tool):
|
||||
"""Test that custom function's output schema is inferred."""
|
||||
|
||||
async def custom_fn(x: int) -> str:
|
||||
result = await forward(x=x)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return f"Custom: {result.content[0].text}"
|
||||
|
||||
new_tool = Tool.from_tool(base_dict_tool, transform_fn=custom_fn)
|
||||
|
||||
# Should infer string schema from custom function and wrap it
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert new_tool.output_schema == expected_schema
|
||||
|
||||
async def test_transform_custom_function_runtime(self, base_dict_tool):
|
||||
"""Test runtime behavior with custom function that has inferred schema."""
|
||||
|
||||
async def custom_fn(x: int) -> str:
|
||||
result = await forward(x=x)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return f"Custom: {result.content[0].text}"
|
||||
|
||||
new_tool = Tool.from_tool(base_dict_tool, transform_fn=custom_fn)
|
||||
|
||||
result = await new_tool.run({"x": 3})
|
||||
# Should wrap string result
|
||||
assert result.structured_content == {"result": 'Custom: {"value":3}'}
|
||||
|
||||
def test_transform_custom_function_fallback_to_parent(self, base_string_tool):
|
||||
"""Test that custom function without output annotation falls back to parent."""
|
||||
|
||||
async def custom_fn(x: int):
|
||||
# No return annotation - should fallback to parent schema
|
||||
result = await forward(x=x)
|
||||
return result
|
||||
|
||||
new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
|
||||
|
||||
# Should use parent's schema since custom function has no annotation
|
||||
assert new_tool.output_schema == base_string_tool.output_schema
|
||||
|
||||
def test_transform_custom_function_explicit_overrides(self, base_string_tool):
|
||||
"""Test that explicit output_schema overrides both custom function and parent."""
|
||||
|
||||
async def custom_fn(x: int) -> dict[str, str]:
|
||||
return {"custom": "value"}
|
||||
|
||||
explicit_schema = {"type": "array", "items": {"type": "number"}}
|
||||
new_tool = Tool.from_tool(
|
||||
base_string_tool, transform_fn=custom_fn, output_schema=explicit_schema
|
||||
)
|
||||
|
||||
# Explicit schema should win
|
||||
assert new_tool.output_schema == explicit_schema
|
||||
|
||||
async def test_transform_custom_function_object_return(self, base_string_tool):
|
||||
"""Test custom function returning object type."""
|
||||
|
||||
async def custom_fn(x: int) -> dict[str, int]:
|
||||
await forward(x=x)
|
||||
return {"original": x, "transformed": x * 2}
|
||||
|
||||
new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
|
||||
|
||||
# Object types should not be wrapped
|
||||
expected_schema = TypeAdapter(dict[str, int]).json_schema()
|
||||
assert new_tool.output_schema == expected_schema
|
||||
assert isinstance(new_tool.output_schema, dict)
|
||||
assert "x-fastmcp-wrap-result" not in new_tool.output_schema
|
||||
|
||||
result = await new_tool.run({"x": 4})
|
||||
# Direct value, not wrapped
|
||||
assert result.structured_content == {"original": 4, "transformed": 8}
|
||||
|
||||
async def test_transform_preserves_wrap_marker_behavior(self, base_string_tool):
|
||||
"""Test that wrap marker behavior is preserved through transformation."""
|
||||
new_tool = Tool.from_tool(base_string_tool)
|
||||
|
||||
result = await new_tool.run({"x": 7})
|
||||
# Should wrap because parent schema has wrap marker
|
||||
assert result.structured_content == {"result": "Result: 7"}
|
||||
assert isinstance(new_tool.output_schema, dict)
|
||||
assert "x-fastmcp-wrap-result" in new_tool.output_schema
|
||||
|
||||
def test_transform_chained_output_schema_inheritance(self, base_string_tool):
|
||||
"""Test output schema inheritance through multiple transformations."""
|
||||
# First transformation keeps parent schema
|
||||
tool1 = Tool.from_tool(base_string_tool)
|
||||
assert tool1.output_schema == base_string_tool.output_schema
|
||||
|
||||
# Second transformation also inherits
|
||||
tool2 = Tool.from_tool(tool1)
|
||||
assert (
|
||||
tool2.output_schema == tool1.output_schema == base_string_tool.output_schema
|
||||
)
|
||||
|
||||
# Third transformation with explicit override
|
||||
custom_schema = {"type": "number"}
|
||||
tool3 = Tool.from_tool(tool2, output_schema=custom_schema)
|
||||
assert tool3.output_schema == custom_schema
|
||||
assert tool3.output_schema != tool2.output_schema
|
||||
|
||||
async def test_transform_mixed_structured_unstructured_content(
|
||||
self, base_string_tool
|
||||
):
|
||||
"""Test transformation handling of mixed content types."""
|
||||
|
||||
async def custom_fn(x: int):
|
||||
# Return mixed content including ToolResult
|
||||
if x == 1:
|
||||
return ["text", {"data": x}]
|
||||
else:
|
||||
# Return ToolResult directly
|
||||
return ToolResult(
|
||||
content=[TextContent(type="text", text=f"Custom: {x}")],
|
||||
structured_content={"custom_value": x},
|
||||
)
|
||||
|
||||
new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
|
||||
|
||||
# Test mixed content return
|
||||
result1 = await new_tool.run({"x": 1})
|
||||
assert result1.structured_content == {"result": ["text", {"data": 1}]}
|
||||
|
||||
# Test ToolResult return
|
||||
result2 = await new_tool.run({"x": 2})
|
||||
assert result2.structured_content == {"custom_value": 2}
|
||||
assert isinstance(result2.content[0], TextContent)
|
||||
assert result2.content[0].text == "Custom: 2"
|
||||
|
||||
def test_transform_output_schema_with_arg_transforms(self, base_string_tool):
|
||||
"""Test that output schema works correctly with argument transformations."""
|
||||
|
||||
async def custom_fn(new_x: int) -> dict[str, str]:
|
||||
result = await forward(new_x=new_x)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return {"transformed": result.content[0].text}
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
base_string_tool,
|
||||
transform_fn=custom_fn,
|
||||
transform_args={"x": ArgTransform(name="new_x")},
|
||||
)
|
||||
|
||||
# Should infer object schema from custom function
|
||||
expected_schema = TypeAdapter(dict[str, str]).json_schema()
|
||||
assert new_tool.output_schema == expected_schema
|
||||
|
||||
async def test_transform_output_schema_default_vs_none(self, base_string_tool):
|
||||
"""Test default (NotSet) vs explicit None behavior for output_schema in transforms."""
|
||||
# Default (NotSet) should use smart fallback (inherit from parent)
|
||||
tool_default = Tool.from_tool(base_string_tool) # default output_schema=NotSet
|
||||
assert tool_default.output_schema == base_string_tool.output_schema # Inherits
|
||||
|
||||
# None should explicitly set output_schema to None but still generate structured content via fallback
|
||||
tool_explicit_none = Tool.from_tool(base_string_tool, output_schema=None)
|
||||
assert tool_explicit_none.output_schema is None
|
||||
|
||||
# Both should generate structured content now (via different paths)
|
||||
result_default = await tool_default.run({"x": 5})
|
||||
result_explicit_none = await tool_explicit_none.run({"x": 5})
|
||||
|
||||
assert result_default.structured_content == {
|
||||
"result": "Result: 5"
|
||||
} # Inherits wrapping
|
||||
assert result_explicit_none.structured_content == {
|
||||
"result": "Result: 5"
|
||||
} # Generated via fallback logic
|
||||
assert isinstance(result_default.content[0], TextContent)
|
||||
assert isinstance(result_explicit_none.content[0], TextContent)
|
||||
assert result_default.content[0].text == result_explicit_none.content[0].text
|
||||
|
||||
async def test_transform_output_schema_with_tool_result_return(
|
||||
self, base_string_tool
|
||||
):
|
||||
"""Test transform when custom function returns ToolResult directly."""
|
||||
|
||||
async def custom_fn(x: int) -> ToolResult:
|
||||
# Custom function returns ToolResult - should bypass schema handling
|
||||
return ToolResult(
|
||||
content=[TextContent(type="text", text=f"Direct: {x}")],
|
||||
structured_content={"direct_value": x, "doubled": x * 2},
|
||||
)
|
||||
|
||||
new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
|
||||
|
||||
# ToolResult return type should result in None output schema
|
||||
assert new_tool.output_schema is None
|
||||
|
||||
result = await new_tool.run({"x": 6})
|
||||
# Should use ToolResult content directly
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Direct: 6"
|
||||
assert result.structured_content == {"direct_value": 6, "doubled": 12}
|
||||
|
||||
|
||||
class TestInputSchema:
|
||||
"""Test schema definition handling and reference finding."""
|
||||
|
||||
def test_arg_transform_examples_in_schema(self, add_tool: Tool):
|
||||
# Simple example
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(examples=[1, 2, 3]),
|
||||
},
|
||||
)
|
||||
prop = get_property(new_tool, "old_x")
|
||||
assert prop["examples"] == [1, 2, 3]
|
||||
|
||||
# Nested example (e.g., for array type)
|
||||
new_tool2 = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(examples=[["a", "b"], ["c", "d"]]),
|
||||
},
|
||||
)
|
||||
prop2 = get_property(new_tool2, "old_x")
|
||||
assert prop2["examples"] == [["a", "b"], ["c", "d"]]
|
||||
|
||||
# If not set, should not be present
|
||||
new_tool3 = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(),
|
||||
},
|
||||
)
|
||||
prop3 = get_property(new_tool3, "old_x")
|
||||
assert "examples" not in prop3
|
||||
|
||||
def test_merge_schema_with_defs_precedence(self):
|
||||
"""Test _merge_schema_with_precedence merges $defs correctly.
|
||||
|
||||
Note: This tests the raw merge behavior before dereferencing.
|
||||
The final schema output will be dereferenced by compress_schema.
|
||||
"""
|
||||
base_schema = {
|
||||
"type": "object",
|
||||
"properties": {"field1": {"$ref": "#/$defs/BaseType"}},
|
||||
"$defs": {
|
||||
"BaseType": {"type": "string", "description": "base"},
|
||||
"SharedType": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
}
|
||||
|
||||
override_schema = {
|
||||
"type": "object",
|
||||
"properties": {"field2": {"$ref": "#/$defs/OverrideType"}},
|
||||
"$defs": {
|
||||
"OverrideType": {"type": "boolean"},
|
||||
"SharedType": {"type": "integer", "minimum": 10}, # Override
|
||||
},
|
||||
}
|
||||
|
||||
transformed_tool_schema = TransformedTool._merge_schema_with_precedence(
|
||||
base_schema, override_schema
|
||||
)
|
||||
|
||||
# SharedType should no longer be present on the schema (unused)
|
||||
assert "SharedType" not in transformed_tool_schema.get("$defs", {})
|
||||
|
||||
# Schema is dereferenced so no $defs in final output
|
||||
assert transformed_tool_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field1": {"type": "string", "description": "base"},
|
||||
"field2": {"type": "boolean"},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
|
||||
def test_transform_tool_with_complex_defs_pruning(self):
|
||||
"""Test that tool transformation properly handles hidden params.
|
||||
|
||||
With schema dereferencing, unused types are automatically removed
|
||||
since $defs is eliminated entirely.
|
||||
"""
|
||||
|
||||
class UsedType(BaseModel):
|
||||
value: str
|
||||
|
||||
class UnusedType(BaseModel):
|
||||
other: int
|
||||
|
||||
@Tool.from_function
|
||||
def complex_tool(
|
||||
used_param: UsedType, unused_param: UnusedType | None = None
|
||||
) -> str:
|
||||
return used_param.value
|
||||
|
||||
# Transform to hide unused_param
|
||||
transformed_tool: TransformedTool = Tool.from_tool(
|
||||
complex_tool, transform_args={"unused_param": ArgTransform(hide=True)}
|
||||
)
|
||||
|
||||
# Schema is dereferenced - no $defs
|
||||
assert "$defs" not in transformed_tool.parameters
|
||||
|
||||
assert transformed_tool.parameters == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"used_param": {
|
||||
"properties": {"value": {"type": "string"}},
|
||||
"required": ["value"],
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
"required": ["used_param"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_transform_with_custom_function_preserves_needed_types(self):
|
||||
"""Test that custom transform functions preserve necessary types inline."""
|
||||
|
||||
class InputType(BaseModel):
|
||||
data: str
|
||||
|
||||
class OutputType(BaseModel):
|
||||
result: str
|
||||
|
||||
@Tool.from_function
|
||||
def base_tool(input_data: InputType) -> OutputType:
|
||||
return OutputType(result=input_data.data.upper())
|
||||
|
||||
async def transform_function(renamed_input: InputType):
|
||||
return await forward(renamed_input=renamed_input)
|
||||
|
||||
# Transform with custom function and argument rename
|
||||
transformed = Tool.from_tool(
|
||||
base_tool,
|
||||
transform_fn=transform_function,
|
||||
transform_args={"input_data": ArgTransform(name="renamed_input")},
|
||||
)
|
||||
|
||||
# Schema is dereferenced - types are inlined
|
||||
assert "$defs" not in transformed.parameters
|
||||
|
||||
assert transformed.parameters == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"renamed_input": {
|
||||
"properties": {"data": {"type": "string"}},
|
||||
"required": ["data"],
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
"required": ["renamed_input"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_chained_transforms_inline_types(self):
|
||||
"""Test that chained transformations produce correct inlined schemas."""
|
||||
|
||||
class TypeA(BaseModel):
|
||||
a: str
|
||||
|
||||
class TypeB(BaseModel):
|
||||
b: int
|
||||
|
||||
class TypeC(BaseModel):
|
||||
c: bool
|
||||
|
||||
@Tool.from_function
|
||||
def base_tool(param_a: TypeA, param_b: TypeB, param_c: TypeC) -> str:
|
||||
return f"{param_a.a}-{param_b.b}-{param_c.c}"
|
||||
|
||||
# First transform: hide param_c
|
||||
transform1 = Tool.from_tool(
|
||||
base_tool,
|
||||
transform_args={"param_c": ArgTransform(hide=True, default=TypeC(c=True))},
|
||||
)
|
||||
|
||||
# Schema is dereferenced - types are inlined
|
||||
assert "$defs" not in transform1.parameters
|
||||
|
||||
assert transform1.parameters == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param_a": {
|
||||
"properties": {"a": {"type": "string"}},
|
||||
"required": ["a"],
|
||||
"type": "object",
|
||||
},
|
||||
"param_b": {
|
||||
"properties": {"b": {"type": "integer"}},
|
||||
"required": ["b"],
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"required": IsList("param_b", "param_a", check_order=False),
|
||||
}
|
||||
)
|
||||
|
||||
# Second transform: hide param_b
|
||||
transform2 = Tool.from_tool(
|
||||
transform1,
|
||||
transform_args={"param_b": ArgTransform(hide=True, default=TypeB(b=42))},
|
||||
)
|
||||
|
||||
assert "$defs" not in transform2.parameters
|
||||
|
||||
assert transform2.parameters == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param_a": {
|
||||
"properties": {"a": {"type": "string"}},
|
||||
"required": ["a"],
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
"required": ["param_a"],
|
||||
}
|
||||
)
|
||||
530
tests/tools/tool_transform/test_tool_transform.py
Normal file
530
tests/tools/tool_transform/test_tool_transform.py
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
"""Core tool transform functionality."""
|
||||
|
||||
import re
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from mcp.types import TextContent
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client.client import Client
|
||||
from fastmcp.tools import Tool, forward, forward_raw
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from fastmcp.tools.tool_transform import (
|
||||
ArgTransform,
|
||||
TransformedTool,
|
||||
)
|
||||
|
||||
|
||||
def get_property(tool: Tool, name: str) -> dict[str, Any]:
|
||||
return tool.parameters["properties"][name]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def add_tool() -> FunctionTool:
|
||||
def add(
|
||||
old_x: Annotated[int, Field(description="old_x description")], old_y: int = 10
|
||||
) -> int:
|
||||
print("running!")
|
||||
return old_x + old_y
|
||||
|
||||
return Tool.from_function(add)
|
||||
|
||||
|
||||
def test_tool_from_tool_no_change(add_tool):
|
||||
new_tool = Tool.from_tool(add_tool)
|
||||
assert isinstance(new_tool, TransformedTool)
|
||||
assert new_tool.parameters == add_tool.parameters
|
||||
assert new_tool.name == add_tool.name
|
||||
assert new_tool.description == add_tool.description
|
||||
|
||||
|
||||
async def test_renamed_arg_description_is_maintained(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
|
||||
)
|
||||
assert (
|
||||
new_tool.parameters["properties"]["new_x"]["description"] == "old_x description"
|
||||
)
|
||||
|
||||
|
||||
async def test_tool_defaults_are_maintained_on_unmapped_args(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
|
||||
)
|
||||
result = await new_tool.run(arguments={"new_x": 1})
|
||||
# The parent tool returns int which gets wrapped as structured output
|
||||
assert result.structured_content == {"result": 11}
|
||||
|
||||
|
||||
async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
|
||||
)
|
||||
result = await new_tool.run(arguments={"old_x": 1})
|
||||
# The parent tool returns int which gets wrapped as structured output
|
||||
assert result.structured_content == {"result": 11}
|
||||
|
||||
|
||||
def test_tool_change_arg_name(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
|
||||
)
|
||||
|
||||
assert sorted(new_tool.parameters["properties"]) == ["new_x", "old_y"]
|
||||
assert get_property(new_tool, "new_x") == get_property(add_tool, "old_x")
|
||||
assert get_property(new_tool, "old_y") == get_property(add_tool, "old_y")
|
||||
assert new_tool.parameters["required"] == ["new_x"]
|
||||
|
||||
|
||||
def test_tool_change_arg_description(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_x": ArgTransform(description="new description")}
|
||||
)
|
||||
assert get_property(new_tool, "old_x")["description"] == "new description"
|
||||
|
||||
|
||||
async def test_tool_drop_arg(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_y": ArgTransform(hide=True)}
|
||||
)
|
||||
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
||||
result = await new_tool.run(arguments={"old_x": 1})
|
||||
assert result.structured_content == {"result": 11}
|
||||
|
||||
|
||||
async def test_dropped_args_error_if_provided(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_y": ArgTransform(hide=True)}
|
||||
)
|
||||
with pytest.raises(
|
||||
TypeError, match="Got unexpected keyword argument\\(s\\): old_y"
|
||||
):
|
||||
await new_tool.run(arguments={"old_x": 1, "old_y": 2})
|
||||
|
||||
|
||||
async def test_hidden_arg_with_constant_default(add_tool):
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_y": ArgTransform(hide=True)}
|
||||
)
|
||||
result = await new_tool.run(arguments={"old_x": 1})
|
||||
# old_y should use its default value of 10
|
||||
assert result.structured_content == {"result": 11}
|
||||
|
||||
|
||||
async def test_hidden_arg_without_default_uses_parent_default(add_tool):
|
||||
"""Test that hidden argument without default uses parent's default."""
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool, transform_args={"old_y": ArgTransform(hide=True)}
|
||||
)
|
||||
# Only old_x should be exposed
|
||||
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
||||
# Should pass old_x=3 and let parent use its default old_y=10
|
||||
result = await new_tool.run(arguments={"old_x": 3})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "13"
|
||||
assert result.structured_content == {"result": 13}
|
||||
|
||||
|
||||
async def test_mixed_hidden_args_with_custom_function(add_tool):
|
||||
async def custom_fn(new_x: int, **kwargs) -> str:
|
||||
result = await forward(new_x=new_x, **kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return f"Custom: {result.content[0].text}"
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=custom_fn,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(name="new_x"),
|
||||
"old_y": ArgTransform(hide=True),
|
||||
},
|
||||
)
|
||||
|
||||
result = await new_tool.run(arguments={"new_x": 5})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Custom: 15"
|
||||
|
||||
|
||||
async def test_hide_required_param_without_default_raises_error():
|
||||
"""Test that hiding a required parameter without providing default raises error."""
|
||||
|
||||
@Tool.from_function
|
||||
def tool_with_required_param(required_param: int, optional_param: int = 10) -> int:
|
||||
return required_param + optional_param
|
||||
|
||||
# This should raise an error because required_param has no default and we're not providing one
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"Hidden parameter 'required_param' has no default value in parent tool",
|
||||
):
|
||||
Tool.from_tool(
|
||||
tool_with_required_param,
|
||||
transform_args={"required_param": ArgTransform(hide=True)},
|
||||
)
|
||||
|
||||
|
||||
async def test_hide_required_param_with_user_default_works():
|
||||
"""Test that hiding a required parameter works when user provides a default."""
|
||||
|
||||
@Tool.from_function
|
||||
def tool_with_required_param(required_param: int, optional_param: int = 10) -> int:
|
||||
return required_param + optional_param
|
||||
|
||||
# This should work because we're providing a default for the hidden required param
|
||||
new_tool = Tool.from_tool(
|
||||
tool_with_required_param,
|
||||
transform_args={"required_param": ArgTransform(hide=True, default=5)},
|
||||
)
|
||||
|
||||
# Only optional_param should be exposed
|
||||
assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
|
||||
# Should pass required_param=5 and optional_param=20 to parent
|
||||
result = await new_tool.run(arguments={"optional_param": 20})
|
||||
assert result.structured_content == {"result": 25}
|
||||
|
||||
|
||||
async def test_hidden_param_prunes_defs():
|
||||
class VisibleType(BaseModel):
|
||||
x: int
|
||||
|
||||
class HiddenType(BaseModel):
|
||||
y: int
|
||||
|
||||
@Tool.from_function
|
||||
def tool_with_refs(a: VisibleType, b: HiddenType | None = None) -> int:
|
||||
return a.x + (b.y if b else 0)
|
||||
|
||||
# Hide parameter 'b'
|
||||
new_tool = Tool.from_tool(
|
||||
tool_with_refs, transform_args={"b": ArgTransform(hide=True)}
|
||||
)
|
||||
|
||||
schema = new_tool.parameters
|
||||
# Only 'a' should be visible
|
||||
assert list(schema["properties"].keys()) == ["a"]
|
||||
# Schema should be fully dereferenced (no $defs)
|
||||
assert "$defs" not in schema
|
||||
# VisibleType should be inlined in the property
|
||||
assert schema["properties"]["a"] == {
|
||||
"properties": {"x": {"type": "integer"}},
|
||||
"required": ["x"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
|
||||
async def test_forward_with_argument_mapping(add_tool):
|
||||
async def custom_fn(new_x: int, **kwargs) -> str:
|
||||
result = await forward(new_x=new_x, **kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return f"Mapped: {result.content[0].text}"
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=custom_fn,
|
||||
transform_args={"old_x": ArgTransform(name="new_x")},
|
||||
)
|
||||
|
||||
result = await new_tool.run(arguments={"new_x": 3, "old_y": 7})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Mapped: 10"
|
||||
|
||||
|
||||
async def test_forward_with_incorrect_args_raises_error(add_tool):
|
||||
async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
|
||||
# the forward should use the new args, not the old ones
|
||||
return await forward(old_x=new_x, old_y=new_y)
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=custom_fn,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(name="new_x"),
|
||||
"old_y": ArgTransform(name="new_y"),
|
||||
},
|
||||
)
|
||||
with pytest.raises(
|
||||
TypeError, match=re.escape("Got unexpected keyword argument(s): old_x, old_y")
|
||||
):
|
||||
await new_tool.run(arguments={"new_x": 2, "new_y": 3})
|
||||
|
||||
|
||||
async def test_forward_raw_without_argument_mapping(add_tool):
|
||||
async def custom_fn(**kwargs) -> str:
|
||||
# forward_raw passes through kwargs as-is
|
||||
result = await forward_raw(**kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return f"Raw: {result.content[0].text}"
|
||||
|
||||
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
||||
|
||||
result = await new_tool.run(arguments={"old_x": 2, "old_y": 8})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Raw: 10"
|
||||
|
||||
|
||||
async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
|
||||
async def custom_fn(**kwargs) -> str:
|
||||
result = await forward(**kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return f"Custom: {result.content[0].text}"
|
||||
|
||||
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
||||
|
||||
result = await new_tool.run(arguments={"old_x": 4, "old_y": 6})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Custom: 10"
|
||||
|
||||
|
||||
async def test_fn_with_kwargs_passes_through_original_args(add_tool):
|
||||
async def custom_fn(**kwargs) -> str:
|
||||
# Should receive original arg names
|
||||
assert "old_x" in kwargs
|
||||
assert "old_y" in kwargs
|
||||
result = await forward(**kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return result.content[0].text
|
||||
|
||||
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
||||
|
||||
result = await new_tool.run(arguments={"old_x": 1, "old_y": 2})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "3"
|
||||
|
||||
|
||||
async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
|
||||
"""Test that **kwargs receives arguments with their transformed names from transform_args."""
|
||||
|
||||
async def custom_fn(new_x: int, **kwargs) -> ToolResult:
|
||||
# kwargs should contain 'old_y': 3 (transformed name), not 'old_y': 3 (original name)
|
||||
assert kwargs == {"old_y": 3}
|
||||
result = await forward(new_x=new_x, **kwargs)
|
||||
return result
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=custom_fn,
|
||||
transform_args={"old_x": ArgTransform(name="new_x")},
|
||||
)
|
||||
result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "5"
|
||||
assert result.structured_content == {"result": 5}
|
||||
|
||||
|
||||
async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
|
||||
async def custom_fn(new_x: int, **kwargs) -> str:
|
||||
result = await forward(new_x=new_x, **kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return result.content[0].text
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=custom_fn,
|
||||
transform_args={"old_x": ArgTransform(name="new_x")},
|
||||
)
|
||||
|
||||
# Only provide new_x, old_y should use default
|
||||
result = await new_tool.run(arguments={"new_x": 7})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "17" # 7 + 10 (default)
|
||||
|
||||
|
||||
async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
|
||||
async def custom_fn(new_x: int, old_y: int, **kwargs) -> str:
|
||||
result = await forward(new_x=new_x, old_y=old_y, **kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return result.content[0].text
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=custom_fn,
|
||||
transform_args={"old_x": ArgTransform(name="new_x")},
|
||||
)
|
||||
|
||||
result = await new_tool.run(arguments={"new_x": 2, "old_y": 8})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "10"
|
||||
|
||||
|
||||
async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
|
||||
async def custom_fn(new_x: int, **kwargs) -> str:
|
||||
# old_y is dropped, so it shouldn't be in kwargs
|
||||
assert "old_y" not in kwargs
|
||||
result = await forward(new_x=new_x, **kwargs)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return result.content[0].text
|
||||
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=custom_fn,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(name="new_x"),
|
||||
"old_y": ArgTransform(hide=True),
|
||||
},
|
||||
)
|
||||
|
||||
result = await new_tool.run(arguments={"new_x": 3})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "13" # 3 + 10 (default for hidden old_y)
|
||||
|
||||
|
||||
async def test_forward_outside_context_raises_error():
|
||||
"""Test that forward() raises error when called outside transform context."""
|
||||
with pytest.raises(RuntimeError, match="forward\(\) can only be called"):
|
||||
await forward(x=1)
|
||||
|
||||
|
||||
async def test_forward_raw_outside_context_raises_error():
|
||||
"""Test that forward_raw() raises error when called outside transform context."""
|
||||
with pytest.raises(RuntimeError, match="forward_raw\(\) can only be called"):
|
||||
await forward_raw(x=1)
|
||||
|
||||
|
||||
def test_transform_args_with_parent_defaults():
|
||||
"""Test that transform_args with parent defaults works."""
|
||||
|
||||
class CoolModel(BaseModel):
|
||||
x: int = 10
|
||||
|
||||
def parent_tool(cool_model: CoolModel) -> int:
|
||||
return cool_model.x
|
||||
|
||||
tool = Tool.from_function(parent_tool)
|
||||
|
||||
new_tool = Tool.from_tool(tool)
|
||||
|
||||
# Both tools should have the same dereferenced schema
|
||||
assert new_tool.parameters == tool.parameters
|
||||
# Schema should be fully dereferenced (no $defs)
|
||||
assert "$defs" not in new_tool.parameters
|
||||
|
||||
|
||||
def test_transform_args_validation_unknown_arg(add_tool):
|
||||
"""Test that transform_args with unknown arguments raises ValueError."""
|
||||
with pytest.raises(
|
||||
ValueError, match="Unknown arguments in transform_args: unknown_param"
|
||||
) as exc_info:
|
||||
Tool.from_tool(
|
||||
add_tool, transform_args={"unknown_param": ArgTransform(name="new_name")}
|
||||
)
|
||||
|
||||
assert "`add`" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_transform_args_creates_duplicate_names(add_tool):
|
||||
"""Test that transform_args creating duplicate parameter names raises ValueError."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Multiple arguments would be mapped to the same names: same_name",
|
||||
):
|
||||
Tool.from_tool(
|
||||
add_tool,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(name="same_name"),
|
||||
"old_y": ArgTransform(name="same_name"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_function_without_kwargs_missing_params(add_tool):
|
||||
"""Test that function missing required transformed parameters raises ValueError."""
|
||||
|
||||
def invalid_fn(new_x: int, non_existent: str) -> str:
|
||||
return f"{new_x}_{non_existent}"
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Function missing parameters required after transformation: new_y",
|
||||
):
|
||||
Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=invalid_fn,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(name="new_x"),
|
||||
"old_y": ArgTransform(name="new_y"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_function_without_kwargs_can_have_extra_params(add_tool):
|
||||
"""Test that function can have extra parameters not in parent tool."""
|
||||
|
||||
def valid_fn(new_x: int, new_y: int, extra_param: str = "default") -> str:
|
||||
return f"{new_x}_{new_y}_{extra_param}"
|
||||
|
||||
# Should work - extra_param is fine as long as it has a default
|
||||
new_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=valid_fn,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(name="new_x"),
|
||||
"old_y": ArgTransform(name="new_y"),
|
||||
},
|
||||
)
|
||||
|
||||
# The final schema should include all function parameters
|
||||
assert "new_x" in new_tool.parameters["properties"]
|
||||
assert "new_y" in new_tool.parameters["properties"]
|
||||
assert "extra_param" in new_tool.parameters["properties"]
|
||||
|
||||
|
||||
def test_function_with_kwargs_can_add_params(add_tool):
|
||||
"""Test that function with **kwargs can add new parameters."""
|
||||
|
||||
async def valid_fn(extra_param: str, **kwargs) -> str:
|
||||
result = await forward(**kwargs)
|
||||
return f"{extra_param}: {result}"
|
||||
|
||||
# This should work fine - kwargs allows access to all transformed params
|
||||
tool = Tool.from_tool(
|
||||
add_tool,
|
||||
transform_fn=valid_fn,
|
||||
transform_args={
|
||||
"old_x": ArgTransform(name="new_x"),
|
||||
"old_y": ArgTransform(name="new_y"),
|
||||
},
|
||||
)
|
||||
|
||||
# extra_param is added, new_x and new_y are available
|
||||
assert "extra_param" in tool.parameters["properties"]
|
||||
assert "new_x" in tool.parameters["properties"]
|
||||
|
||||
|
||||
class TestProxy:
|
||||
@pytest.fixture
|
||||
def mcp_server(self) -> FastMCP:
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def add(old_x: int, old_y: int = 10) -> int:
|
||||
return old_x + old_y
|
||||
|
||||
return mcp
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_server(self, mcp_server: FastMCP) -> FastMCP:
|
||||
from fastmcp.client.transports import FastMCPTransport
|
||||
|
||||
proxy = FastMCP.as_proxy(FastMCPTransport(mcp_server))
|
||||
return proxy
|
||||
|
||||
async def test_transform_proxy(self, proxy_server: FastMCP):
|
||||
# when adding transformed tools to proxy servers. Needs separate investigation.
|
||||
|
||||
add_tool = await proxy_server.get_tool("add")
|
||||
assert add_tool is not None
|
||||
new_add_tool = Tool.from_tool(
|
||||
add_tool,
|
||||
name="add_transformed",
|
||||
transform_args={"old_x": ArgTransform(name="new_x")},
|
||||
)
|
||||
proxy_server.add_tool(new_add_tool)
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
# The tool should be registered with its transformed name
|
||||
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "3"
|
||||
0
tests/utilities/json_schema_type/__init__.py
Normal file
0
tests/utilities/json_schema_type/__init__.py
Normal file
|
|
@ -1,10 +1,10 @@
|
|||
from dataclasses import Field, dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Literal, Union
|
||||
"""Advanced JSON schema type conversion features."""
|
||||
|
||||
from dataclasses import Field
|
||||
from typing import Union
|
||||
|
||||
import pytest
|
||||
from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
from fastmcp.utilities.json_schema_type import (
|
||||
_hash_schema,
|
||||
|
|
@ -17,464 +17,6 @@ def get_dataclass_field(type: type, field_name: str) -> Field:
|
|||
return type.__dataclass_fields__[field_name] # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
class TestSimpleTypes:
|
||||
"""Test suite for basic type validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def simple_string(self):
|
||||
return json_schema_to_type({"type": "string"})
|
||||
|
||||
@pytest.fixture
|
||||
def simple_number(self):
|
||||
return json_schema_to_type({"type": "number"})
|
||||
|
||||
@pytest.fixture
|
||||
def simple_integer(self):
|
||||
return json_schema_to_type({"type": "integer"})
|
||||
|
||||
@pytest.fixture
|
||||
def simple_boolean(self):
|
||||
return json_schema_to_type({"type": "boolean"})
|
||||
|
||||
@pytest.fixture
|
||||
def simple_null(self):
|
||||
return json_schema_to_type({"type": "null"})
|
||||
|
||||
def test_string_accepts_string(self, simple_string):
|
||||
validator = TypeAdapter(simple_string)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_string_rejects_number(self, simple_string):
|
||||
validator = TypeAdapter(simple_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(123)
|
||||
|
||||
def test_number_accepts_float(self, simple_number):
|
||||
validator = TypeAdapter(simple_number)
|
||||
assert validator.validate_python(123.45) == 123.45
|
||||
|
||||
def test_number_accepts_integer(self, simple_number):
|
||||
validator = TypeAdapter(simple_number)
|
||||
assert validator.validate_python(123) == 123
|
||||
|
||||
def test_number_accepts_numeric_string(self, simple_number):
|
||||
validator = TypeAdapter(simple_number)
|
||||
assert validator.validate_python("123.45") == 123.45
|
||||
assert validator.validate_python("123") == 123
|
||||
|
||||
def test_number_rejects_invalid_string(self, simple_number):
|
||||
validator = TypeAdapter(simple_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not a number")
|
||||
|
||||
def test_integer_accepts_integer(self, simple_integer):
|
||||
validator = TypeAdapter(simple_integer)
|
||||
assert validator.validate_python(123) == 123
|
||||
|
||||
def test_integer_accepts_integer_string(self, simple_integer):
|
||||
validator = TypeAdapter(simple_integer)
|
||||
assert validator.validate_python("123") == 123
|
||||
|
||||
def test_integer_rejects_float(self, simple_integer):
|
||||
validator = TypeAdapter(simple_integer)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(123.45)
|
||||
|
||||
def test_integer_rejects_float_string(self, simple_integer):
|
||||
validator = TypeAdapter(simple_integer)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("123.45")
|
||||
|
||||
def test_boolean_accepts_boolean(self, simple_boolean):
|
||||
validator = TypeAdapter(simple_boolean)
|
||||
assert validator.validate_python(True) is True
|
||||
assert validator.validate_python(False) is False
|
||||
|
||||
def test_boolean_accepts_boolean_strings(self, simple_boolean):
|
||||
validator = TypeAdapter(simple_boolean)
|
||||
assert validator.validate_python("true") is True
|
||||
assert validator.validate_python("True") is True
|
||||
assert validator.validate_python("false") is False
|
||||
assert validator.validate_python("False") is False
|
||||
|
||||
def test_boolean_rejects_invalid_string(self, simple_boolean):
|
||||
validator = TypeAdapter(simple_boolean)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not a boolean")
|
||||
|
||||
def test_null_accepts_none(self, simple_null):
|
||||
validator = TypeAdapter(simple_null)
|
||||
assert validator.validate_python(None) is None
|
||||
|
||||
def test_null_rejects_false(self, simple_null):
|
||||
validator = TypeAdapter(simple_null)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(False)
|
||||
|
||||
|
||||
class TestConstrainedTypes:
|
||||
def test_constant(self):
|
||||
validator = TypeAdapter(Literal["x"])
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal["x"]
|
||||
assert TypeAdapter(type_).validate_python("x") == "x"
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python("y")
|
||||
|
||||
def test_union_constants(self):
|
||||
validator = TypeAdapter(Literal["x"] | Literal["y"])
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal["x"] | Literal["y"]
|
||||
assert TypeAdapter(type_).validate_python("x") == "x"
|
||||
assert TypeAdapter(type_).validate_python("y") == "y"
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python("z")
|
||||
|
||||
def test_enum_str(self):
|
||||
class MyEnum(Enum):
|
||||
X = "x"
|
||||
Y = "y"
|
||||
|
||||
validator = TypeAdapter(MyEnum)
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal["x", "y"]
|
||||
assert TypeAdapter(type_).validate_python("x") == "x"
|
||||
assert TypeAdapter(type_).validate_python("y") == "y"
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python("z")
|
||||
|
||||
def test_enum_int(self):
|
||||
class MyEnum(Enum):
|
||||
X = 1
|
||||
Y = 2
|
||||
|
||||
validator = TypeAdapter(MyEnum)
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal[1, 2]
|
||||
assert TypeAdapter(type_).validate_python(1) == 1
|
||||
assert TypeAdapter(type_).validate_python(2) == 2
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python(3)
|
||||
|
||||
def test_choice(self):
|
||||
validator = TypeAdapter(Literal["x", "y"])
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal["x", "y"]
|
||||
assert TypeAdapter(type_).validate_python("x") == "x"
|
||||
assert TypeAdapter(type_).validate_python("y") == "y"
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python("z")
|
||||
|
||||
|
||||
class TestStringConstraints:
|
||||
"""Test suite for string constraint validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def min_length_string(self):
|
||||
return json_schema_to_type({"type": "string", "minLength": 3})
|
||||
|
||||
@pytest.fixture
|
||||
def max_length_string(self):
|
||||
return json_schema_to_type({"type": "string", "maxLength": 5})
|
||||
|
||||
@pytest.fixture
|
||||
def pattern_string(self):
|
||||
return json_schema_to_type({"type": "string", "pattern": "^[A-Z][a-z]+$"})
|
||||
|
||||
@pytest.fixture
|
||||
def email_string(self):
|
||||
return json_schema_to_type({"type": "string", "format": "email"})
|
||||
|
||||
def test_min_length_accepts_valid(self, min_length_string):
|
||||
validator = TypeAdapter(min_length_string)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_min_length_rejects_short(self, min_length_string):
|
||||
validator = TypeAdapter(min_length_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("ab")
|
||||
|
||||
def test_max_length_accepts_valid(self, max_length_string):
|
||||
validator = TypeAdapter(max_length_string)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_max_length_rejects_long(self, max_length_string):
|
||||
validator = TypeAdapter(max_length_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("toolong")
|
||||
|
||||
def test_pattern_accepts_valid(self, pattern_string):
|
||||
validator = TypeAdapter(pattern_string)
|
||||
assert validator.validate_python("Hello") == "Hello"
|
||||
|
||||
def test_pattern_rejects_invalid(self, pattern_string):
|
||||
validator = TypeAdapter(pattern_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("hello")
|
||||
|
||||
def test_email_accepts_valid(self, email_string):
|
||||
validator = TypeAdapter(email_string)
|
||||
result = validator.validate_python("test@example.com")
|
||||
assert result == "test@example.com"
|
||||
|
||||
def test_email_rejects_invalid(self, email_string):
|
||||
validator = TypeAdapter(email_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-an-email")
|
||||
|
||||
|
||||
class TestNumberConstraints:
|
||||
"""Test suite for numeric constraint validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def multiple_of_number(self):
|
||||
return json_schema_to_type({"type": "number", "multipleOf": 0.5})
|
||||
|
||||
@pytest.fixture
|
||||
def min_number(self):
|
||||
return json_schema_to_type({"type": "number", "minimum": 0})
|
||||
|
||||
@pytest.fixture
|
||||
def exclusive_min_number(self):
|
||||
return json_schema_to_type({"type": "number", "exclusiveMinimum": 0})
|
||||
|
||||
@pytest.fixture
|
||||
def max_number(self):
|
||||
return json_schema_to_type({"type": "number", "maximum": 100})
|
||||
|
||||
@pytest.fixture
|
||||
def exclusive_max_number(self):
|
||||
return json_schema_to_type({"type": "number", "exclusiveMaximum": 100})
|
||||
|
||||
def test_multiple_of_accepts_valid(self, multiple_of_number):
|
||||
validator = TypeAdapter(multiple_of_number)
|
||||
assert validator.validate_python(2.5) == 2.5
|
||||
|
||||
def test_multiple_of_rejects_invalid(self, multiple_of_number):
|
||||
validator = TypeAdapter(multiple_of_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(2.7)
|
||||
|
||||
def test_minimum_accepts_equal(self, min_number):
|
||||
validator = TypeAdapter(min_number)
|
||||
assert validator.validate_python(0) == 0
|
||||
|
||||
def test_minimum_rejects_less(self, min_number):
|
||||
validator = TypeAdapter(min_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(-1)
|
||||
|
||||
def test_exclusive_minimum_rejects_equal(self, exclusive_min_number):
|
||||
validator = TypeAdapter(exclusive_min_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(0)
|
||||
|
||||
def test_maximum_accepts_equal(self, max_number):
|
||||
validator = TypeAdapter(max_number)
|
||||
assert validator.validate_python(100) == 100
|
||||
|
||||
def test_maximum_rejects_greater(self, max_number):
|
||||
validator = TypeAdapter(max_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(101)
|
||||
|
||||
def test_exclusive_maximum_rejects_equal(self, exclusive_max_number):
|
||||
validator = TypeAdapter(exclusive_max_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(100)
|
||||
|
||||
|
||||
class TestArrayTypes:
|
||||
"""Test suite for array validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def string_array(self):
|
||||
return json_schema_to_type({"type": "array", "items": {"type": "string"}})
|
||||
|
||||
@pytest.fixture
|
||||
def min_items_array(self):
|
||||
return json_schema_to_type(
|
||||
{"type": "array", "items": {"type": "string"}, "minItems": 2}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def max_items_array(self):
|
||||
return json_schema_to_type(
|
||||
{"type": "array", "items": {"type": "string"}, "maxItems": 3}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def unique_items_array(self):
|
||||
return json_schema_to_type(
|
||||
{"type": "array", "items": {"type": "string"}, "uniqueItems": True}
|
||||
)
|
||||
|
||||
def test_array_accepts_valid_items(self, string_array):
|
||||
validator = TypeAdapter(string_array)
|
||||
assert validator.validate_python(["a", "b"]) == ["a", "b"]
|
||||
|
||||
def test_array_rejects_invalid_items(self, string_array):
|
||||
validator = TypeAdapter(string_array)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python([1, "b"])
|
||||
|
||||
def test_min_items_accepts_valid(self, min_items_array):
|
||||
validator = TypeAdapter(min_items_array)
|
||||
assert validator.validate_python(["a", "b"]) == ["a", "b"]
|
||||
|
||||
def test_min_items_rejects_too_few(self, min_items_array):
|
||||
validator = TypeAdapter(min_items_array)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(["a"])
|
||||
|
||||
def test_max_items_accepts_valid(self, max_items_array):
|
||||
validator = TypeAdapter(max_items_array)
|
||||
assert validator.validate_python(["a", "b", "c"]) == ["a", "b", "c"]
|
||||
|
||||
def test_max_items_rejects_too_many(self, max_items_array):
|
||||
validator = TypeAdapter(max_items_array)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(["a", "b", "c", "d"])
|
||||
|
||||
def test_unique_items_accepts_unique(self, unique_items_array):
|
||||
validator = TypeAdapter(unique_items_array)
|
||||
assert isinstance(validator.validate_python(["a", "b"]), set)
|
||||
|
||||
def test_unique_items_converts_duplicates(self, unique_items_array):
|
||||
validator = TypeAdapter(unique_items_array)
|
||||
result = validator.validate_python(["a", "a", "b"])
|
||||
assert result == {"a", "b"}
|
||||
|
||||
|
||||
class TestObjectTypes:
|
||||
"""Test suite for object validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def simple_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def required_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
|
||||
"required": ["name"],
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def nested_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name"],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_type, expected_type",
|
||||
[
|
||||
# Plain dict becomes dict[str, Any] (JSON Schema accurate)
|
||||
(dict, dict[str, Any]),
|
||||
# dict[str, Any] stays the same
|
||||
(dict[str, Any], dict[str, Any]),
|
||||
# Simple typed dicts work correctly
|
||||
(dict[str, str], dict[str, str]),
|
||||
(dict[str, int], dict[str, int]),
|
||||
# Union value types work
|
||||
(dict[str, str | int], dict[str, str | int]),
|
||||
# Key types are constrained to str in JSON Schema
|
||||
(dict[int, list[str]], dict[str, list[str]]),
|
||||
# Union key types become str (JSON Schema limitation)
|
||||
(dict[str | int, str | None], dict[str, str | None]),
|
||||
],
|
||||
)
|
||||
def test_dict_types_are_generated_correctly(self, input_type, expected_type):
|
||||
schema = TypeAdapter(input_type).json_schema()
|
||||
generated_type = json_schema_to_type(schema)
|
||||
assert generated_type == expected_type
|
||||
|
||||
def test_object_accepts_valid(self, simple_object):
|
||||
validator = TypeAdapter(simple_object)
|
||||
result = validator.validate_python({"name": "test", "age": 30})
|
||||
assert result.name == "test"
|
||||
assert result.age == 30
|
||||
|
||||
def test_object_accepts_extra_properties(self, simple_object):
|
||||
validator = TypeAdapter(simple_object)
|
||||
result = validator.validate_python(
|
||||
{"name": "test", "age": 30, "extra": "field"}
|
||||
)
|
||||
assert result.name == "test"
|
||||
assert result.age == 30
|
||||
assert not hasattr(result, "extra")
|
||||
|
||||
def test_required_accepts_valid(self, required_object):
|
||||
validator = TypeAdapter(required_object)
|
||||
result = validator.validate_python({"name": "test"})
|
||||
assert result.name == "test"
|
||||
assert result.age is None
|
||||
|
||||
def test_required_rejects_missing(self, required_object):
|
||||
validator = TypeAdapter(required_object)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python({})
|
||||
|
||||
def test_nested_accepts_valid(self, nested_object):
|
||||
validator = TypeAdapter(nested_object)
|
||||
result = validator.validate_python({"user": {"name": "test", "age": 30}})
|
||||
assert result.user.name == "test"
|
||||
assert result.user.age == 30
|
||||
|
||||
def test_nested_rejects_invalid(self, nested_object):
|
||||
validator = TypeAdapter(nested_object)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python({"user": {"age": 30}})
|
||||
|
||||
def test_object_with_underscore_names(self):
|
||||
@dataclass
|
||||
class Data:
|
||||
x: int
|
||||
x_: int
|
||||
_x: int
|
||||
|
||||
schema = TypeAdapter(Data).json_schema()
|
||||
assert schema == {
|
||||
"title": "Data",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"type": "integer", "title": "X"},
|
||||
"x_": {"type": "integer", "title": "X"},
|
||||
"_x": {"type": "integer", "title": "X"},
|
||||
},
|
||||
"required": ["x", "x_", "_x"],
|
||||
}
|
||||
|
||||
object = json_schema_to_type(schema)
|
||||
object_schema = TypeAdapter(object).json_schema()
|
||||
assert object_schema == schema
|
||||
|
||||
|
||||
class TestDefaultValues:
|
||||
"""Test suite for default value handling."""
|
||||
|
||||
|
|
@ -539,219 +81,6 @@ class TestDefaultValues:
|
|||
assert result.user.settings.theme == "system"
|
||||
|
||||
|
||||
class TestUnionTypes:
|
||||
"""Test suite for testing union type behaviors."""
|
||||
|
||||
@pytest.fixture
|
||||
def heterogeneous_union(self):
|
||||
return json_schema_to_type({"type": ["string", "number", "boolean", "null"]})
|
||||
|
||||
@pytest.fixture
|
||||
def union_with_constraints(self):
|
||||
return json_schema_to_type(
|
||||
{"type": ["string", "number"], "minLength": 3, "minimum": 0}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def union_with_formats(self):
|
||||
return json_schema_to_type({"type": ["string", "null"], "format": "email"})
|
||||
|
||||
@pytest.fixture
|
||||
def nested_union_array(self):
|
||||
return json_schema_to_type(
|
||||
{"type": "array", "items": {"type": ["string", "number"]}}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def nested_union_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": ["string", "integer"]},
|
||||
"data": {
|
||||
"type": ["object", "null"],
|
||||
"properties": {"value": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_heterogeneous_accepts_string(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_heterogeneous_accepts_number(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
assert validator.validate_python(123.45) == 123.45
|
||||
|
||||
def test_heterogeneous_accepts_boolean(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
assert validator.validate_python(True) is True
|
||||
|
||||
def test_heterogeneous_accepts_null(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
assert validator.validate_python(None) is None
|
||||
|
||||
def test_heterogeneous_rejects_array(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python([])
|
||||
|
||||
def test_constrained_string_valid(self, union_with_constraints):
|
||||
validator = TypeAdapter(union_with_constraints)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_constrained_string_invalid(self, union_with_constraints):
|
||||
validator = TypeAdapter(union_with_constraints)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("ab")
|
||||
|
||||
def test_constrained_number_valid(self, union_with_constraints):
|
||||
validator = TypeAdapter(union_with_constraints)
|
||||
assert validator.validate_python(10) == 10
|
||||
|
||||
def test_constrained_number_invalid(self, union_with_constraints):
|
||||
validator = TypeAdapter(union_with_constraints)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(-1)
|
||||
|
||||
def test_format_valid_email(self, union_with_formats):
|
||||
validator = TypeAdapter(union_with_formats)
|
||||
result = validator.validate_python("test@example.com")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_format_valid_null(self, union_with_formats):
|
||||
validator = TypeAdapter(union_with_formats)
|
||||
assert validator.validate_python(None) is None
|
||||
|
||||
def test_format_invalid_email(self, union_with_formats):
|
||||
validator = TypeAdapter(union_with_formats)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-an-email")
|
||||
|
||||
def test_nested_array_mixed_types(self, nested_union_array):
|
||||
validator = TypeAdapter(nested_union_array)
|
||||
result = validator.validate_python(["test", 123, "abc"])
|
||||
assert result == ["test", 123, "abc"]
|
||||
|
||||
def test_nested_array_rejects_invalid(self, nested_union_array):
|
||||
validator = TypeAdapter(nested_union_array)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(["test", ["not", "allowed"], "abc"])
|
||||
|
||||
def test_nested_object_string_id(self, nested_union_object):
|
||||
validator = TypeAdapter(nested_union_object)
|
||||
result = validator.validate_python({"id": "abc123", "data": {"value": "test"}})
|
||||
assert result.id == "abc123"
|
||||
assert result.data.value == "test"
|
||||
|
||||
def test_nested_object_integer_id(self, nested_union_object):
|
||||
validator = TypeAdapter(nested_union_object)
|
||||
result = validator.validate_python({"id": 123, "data": None})
|
||||
assert result.id == 123
|
||||
assert result.data is None
|
||||
|
||||
|
||||
class TestFormatTypes:
|
||||
"""Test suite for format type validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def datetime_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "date-time"})
|
||||
|
||||
@pytest.fixture
|
||||
def email_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "email"})
|
||||
|
||||
@pytest.fixture
|
||||
def uri_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "uri"})
|
||||
|
||||
@pytest.fixture
|
||||
def uri_reference_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "uri-reference"})
|
||||
|
||||
@pytest.fixture
|
||||
def json_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "json"})
|
||||
|
||||
@pytest.fixture
|
||||
def mixed_formats_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"full_uri": {"type": "string", "format": "uri"},
|
||||
"ref_uri": {"type": "string", "format": "uri-reference"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_datetime_valid(self, datetime_format):
|
||||
validator = TypeAdapter(datetime_format)
|
||||
result = validator.validate_python("2024-01-17T12:34:56Z")
|
||||
assert isinstance(result, datetime)
|
||||
|
||||
def test_datetime_invalid(self, datetime_format):
|
||||
validator = TypeAdapter(datetime_format)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-a-date")
|
||||
|
||||
def test_email_valid(self, email_format):
|
||||
validator = TypeAdapter(email_format)
|
||||
result = validator.validate_python("test@example.com")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_email_invalid(self, email_format):
|
||||
validator = TypeAdapter(email_format)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-an-email")
|
||||
|
||||
def test_uri_valid(self, uri_format):
|
||||
validator = TypeAdapter(uri_format)
|
||||
result = validator.validate_python("https://example.com")
|
||||
assert isinstance(result, AnyUrl)
|
||||
|
||||
def test_uri_invalid(self, uri_format):
|
||||
validator = TypeAdapter(uri_format)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-a-uri")
|
||||
|
||||
def test_uri_reference_valid(self, uri_reference_format):
|
||||
validator = TypeAdapter(uri_reference_format)
|
||||
result = validator.validate_python("https://example.com")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_uri_reference_relative_valid(self, uri_reference_format):
|
||||
validator = TypeAdapter(uri_reference_format)
|
||||
result = validator.validate_python("/path/to/resource")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_uri_reference_invalid(self, uri_reference_format):
|
||||
validator = TypeAdapter(uri_reference_format)
|
||||
result = validator.validate_python("not a uri")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_json_valid(self, json_format):
|
||||
validator = TypeAdapter(json_format)
|
||||
result = validator.validate_python('{"key": "value"}')
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_json_invalid(self, json_format):
|
||||
validator = TypeAdapter(json_format)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("{invalid json}")
|
||||
|
||||
def test_mixed_formats_object(self, mixed_formats_object):
|
||||
validator = TypeAdapter(mixed_formats_object)
|
||||
result = validator.validate_python(
|
||||
{"full_uri": "https://example.com", "ref_uri": "/path/to/resource"}
|
||||
)
|
||||
assert isinstance(result.full_uri, AnyUrl)
|
||||
assert isinstance(result.ref_uri, str)
|
||||
|
||||
|
||||
class TestCircularReferences:
|
||||
"""Test suite for circular reference handling."""
|
||||
|
||||
132
tests/utilities/json_schema_type/test_constraints.py
Normal file
132
tests/utilities/json_schema_type/test_constraints.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Tests for type constraints in JSON schema conversion."""
|
||||
|
||||
from dataclasses import Field
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from fastmcp.utilities.json_schema_type import (
|
||||
json_schema_to_type,
|
||||
)
|
||||
|
||||
|
||||
def get_dataclass_field(type: type, field_name: str) -> Field:
|
||||
return type.__dataclass_fields__[field_name] # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
class TestStringConstraints:
|
||||
"""Test suite for string constraint validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def min_length_string(self):
|
||||
return json_schema_to_type({"type": "string", "minLength": 3})
|
||||
|
||||
@pytest.fixture
|
||||
def max_length_string(self):
|
||||
return json_schema_to_type({"type": "string", "maxLength": 5})
|
||||
|
||||
@pytest.fixture
|
||||
def pattern_string(self):
|
||||
return json_schema_to_type({"type": "string", "pattern": "^[A-Z][a-z]+$"})
|
||||
|
||||
@pytest.fixture
|
||||
def email_string(self):
|
||||
return json_schema_to_type({"type": "string", "format": "email"})
|
||||
|
||||
def test_min_length_accepts_valid(self, min_length_string):
|
||||
validator = TypeAdapter(min_length_string)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_min_length_rejects_short(self, min_length_string):
|
||||
validator = TypeAdapter(min_length_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("ab")
|
||||
|
||||
def test_max_length_accepts_valid(self, max_length_string):
|
||||
validator = TypeAdapter(max_length_string)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_max_length_rejects_long(self, max_length_string):
|
||||
validator = TypeAdapter(max_length_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("toolong")
|
||||
|
||||
def test_pattern_accepts_valid(self, pattern_string):
|
||||
validator = TypeAdapter(pattern_string)
|
||||
assert validator.validate_python("Hello") == "Hello"
|
||||
|
||||
def test_pattern_rejects_invalid(self, pattern_string):
|
||||
validator = TypeAdapter(pattern_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("hello")
|
||||
|
||||
def test_email_accepts_valid(self, email_string):
|
||||
validator = TypeAdapter(email_string)
|
||||
result = validator.validate_python("test@example.com")
|
||||
assert result == "test@example.com"
|
||||
|
||||
def test_email_rejects_invalid(self, email_string):
|
||||
validator = TypeAdapter(email_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-an-email")
|
||||
|
||||
|
||||
class TestNumberConstraints:
|
||||
"""Test suite for numeric constraint validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def multiple_of_number(self):
|
||||
return json_schema_to_type({"type": "number", "multipleOf": 0.5})
|
||||
|
||||
@pytest.fixture
|
||||
def min_number(self):
|
||||
return json_schema_to_type({"type": "number", "minimum": 0})
|
||||
|
||||
@pytest.fixture
|
||||
def exclusive_min_number(self):
|
||||
return json_schema_to_type({"type": "number", "exclusiveMinimum": 0})
|
||||
|
||||
@pytest.fixture
|
||||
def max_number(self):
|
||||
return json_schema_to_type({"type": "number", "maximum": 100})
|
||||
|
||||
@pytest.fixture
|
||||
def exclusive_max_number(self):
|
||||
return json_schema_to_type({"type": "number", "exclusiveMaximum": 100})
|
||||
|
||||
def test_multiple_of_accepts_valid(self, multiple_of_number):
|
||||
validator = TypeAdapter(multiple_of_number)
|
||||
assert validator.validate_python(2.5) == 2.5
|
||||
|
||||
def test_multiple_of_rejects_invalid(self, multiple_of_number):
|
||||
validator = TypeAdapter(multiple_of_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(2.7)
|
||||
|
||||
def test_minimum_accepts_equal(self, min_number):
|
||||
validator = TypeAdapter(min_number)
|
||||
assert validator.validate_python(0) == 0
|
||||
|
||||
def test_minimum_rejects_less(self, min_number):
|
||||
validator = TypeAdapter(min_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(-1)
|
||||
|
||||
def test_exclusive_minimum_rejects_equal(self, exclusive_min_number):
|
||||
validator = TypeAdapter(exclusive_min_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(0)
|
||||
|
||||
def test_maximum_accepts_equal(self, max_number):
|
||||
validator = TypeAdapter(max_number)
|
||||
assert validator.validate_python(100) == 100
|
||||
|
||||
def test_maximum_rejects_greater(self, max_number):
|
||||
validator = TypeAdapter(max_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(101)
|
||||
|
||||
def test_exclusive_maximum_rejects_equal(self, exclusive_max_number):
|
||||
validator = TypeAdapter(exclusive_max_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(100)
|
||||
201
tests/utilities/json_schema_type/test_containers.py
Normal file
201
tests/utilities/json_schema_type/test_containers.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""Tests for container types in JSON schema conversion."""
|
||||
|
||||
from dataclasses import Field, dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from fastmcp.utilities.json_schema_type import (
|
||||
json_schema_to_type,
|
||||
)
|
||||
|
||||
|
||||
def get_dataclass_field(type: type, field_name: str) -> Field:
|
||||
return type.__dataclass_fields__[field_name] # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
class TestArrayTypes:
|
||||
"""Test suite for array validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def string_array(self):
|
||||
return json_schema_to_type({"type": "array", "items": {"type": "string"}})
|
||||
|
||||
@pytest.fixture
|
||||
def min_items_array(self):
|
||||
return json_schema_to_type(
|
||||
{"type": "array", "items": {"type": "string"}, "minItems": 2}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def max_items_array(self):
|
||||
return json_schema_to_type(
|
||||
{"type": "array", "items": {"type": "string"}, "maxItems": 3}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def unique_items_array(self):
|
||||
return json_schema_to_type(
|
||||
{"type": "array", "items": {"type": "string"}, "uniqueItems": True}
|
||||
)
|
||||
|
||||
def test_array_accepts_valid_items(self, string_array):
|
||||
validator = TypeAdapter(string_array)
|
||||
assert validator.validate_python(["a", "b"]) == ["a", "b"]
|
||||
|
||||
def test_array_rejects_invalid_items(self, string_array):
|
||||
validator = TypeAdapter(string_array)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python([1, "b"])
|
||||
|
||||
def test_min_items_accepts_valid(self, min_items_array):
|
||||
validator = TypeAdapter(min_items_array)
|
||||
assert validator.validate_python(["a", "b"]) == ["a", "b"]
|
||||
|
||||
def test_min_items_rejects_too_few(self, min_items_array):
|
||||
validator = TypeAdapter(min_items_array)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(["a"])
|
||||
|
||||
def test_max_items_accepts_valid(self, max_items_array):
|
||||
validator = TypeAdapter(max_items_array)
|
||||
assert validator.validate_python(["a", "b", "c"]) == ["a", "b", "c"]
|
||||
|
||||
def test_max_items_rejects_too_many(self, max_items_array):
|
||||
validator = TypeAdapter(max_items_array)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(["a", "b", "c", "d"])
|
||||
|
||||
def test_unique_items_accepts_unique(self, unique_items_array):
|
||||
validator = TypeAdapter(unique_items_array)
|
||||
assert isinstance(validator.validate_python(["a", "b"]), set)
|
||||
|
||||
def test_unique_items_converts_duplicates(self, unique_items_array):
|
||||
validator = TypeAdapter(unique_items_array)
|
||||
result = validator.validate_python(["a", "a", "b"])
|
||||
assert result == {"a", "b"}
|
||||
|
||||
|
||||
class TestObjectTypes:
|
||||
"""Test suite for object validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def simple_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def required_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
|
||||
"required": ["name"],
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def nested_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name"],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_type, expected_type",
|
||||
[
|
||||
# Plain dict becomes dict[str, Any] (JSON Schema accurate)
|
||||
(dict, dict[str, Any]),
|
||||
# dict[str, Any] stays the same
|
||||
(dict[str, Any], dict[str, Any]),
|
||||
# Simple typed dicts work correctly
|
||||
(dict[str, str], dict[str, str]),
|
||||
(dict[str, int], dict[str, int]),
|
||||
# Union value types work
|
||||
(dict[str, str | int], dict[str, str | int]),
|
||||
# Key types are constrained to str in JSON Schema
|
||||
(dict[int, list[str]], dict[str, list[str]]),
|
||||
# Union key types become str (JSON Schema limitation)
|
||||
(dict[str | int, str | None], dict[str, str | None]),
|
||||
],
|
||||
)
|
||||
def test_dict_types_are_generated_correctly(self, input_type, expected_type):
|
||||
schema = TypeAdapter(input_type).json_schema()
|
||||
generated_type = json_schema_to_type(schema)
|
||||
assert generated_type == expected_type
|
||||
|
||||
def test_object_accepts_valid(self, simple_object):
|
||||
validator = TypeAdapter(simple_object)
|
||||
result = validator.validate_python({"name": "test", "age": 30})
|
||||
assert result.name == "test"
|
||||
assert result.age == 30
|
||||
|
||||
def test_object_accepts_extra_properties(self, simple_object):
|
||||
validator = TypeAdapter(simple_object)
|
||||
result = validator.validate_python(
|
||||
{"name": "test", "age": 30, "extra": "field"}
|
||||
)
|
||||
assert result.name == "test"
|
||||
assert result.age == 30
|
||||
assert not hasattr(result, "extra")
|
||||
|
||||
def test_required_accepts_valid(self, required_object):
|
||||
validator = TypeAdapter(required_object)
|
||||
result = validator.validate_python({"name": "test"})
|
||||
assert result.name == "test"
|
||||
assert result.age is None
|
||||
|
||||
def test_required_rejects_missing(self, required_object):
|
||||
validator = TypeAdapter(required_object)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python({})
|
||||
|
||||
def test_nested_accepts_valid(self, nested_object):
|
||||
validator = TypeAdapter(nested_object)
|
||||
result = validator.validate_python({"user": {"name": "test", "age": 30}})
|
||||
assert result.user.name == "test"
|
||||
assert result.user.age == 30
|
||||
|
||||
def test_nested_rejects_invalid(self, nested_object):
|
||||
validator = TypeAdapter(nested_object)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python({"user": {"age": 30}})
|
||||
|
||||
def test_object_with_underscore_names(self):
|
||||
@dataclass
|
||||
class Data:
|
||||
x: int
|
||||
x_: int
|
||||
_x: int
|
||||
|
||||
schema = TypeAdapter(Data).json_schema()
|
||||
assert schema == {
|
||||
"title": "Data",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"type": "integer", "title": "X"},
|
||||
"x_": {"type": "integer", "title": "X"},
|
||||
"_x": {"type": "integer", "title": "X"},
|
||||
},
|
||||
"required": ["x", "x_", "_x"],
|
||||
}
|
||||
|
||||
object = json_schema_to_type(schema)
|
||||
object_schema = TypeAdapter(object).json_schema()
|
||||
assert object_schema == schema
|
||||
114
tests/utilities/json_schema_type/test_formats.py
Normal file
114
tests/utilities/json_schema_type/test_formats.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Tests for format handling in JSON schema conversion."""
|
||||
|
||||
from dataclasses import Field
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from pydantic import AnyUrl, TypeAdapter, ValidationError
|
||||
|
||||
from fastmcp.utilities.json_schema_type import (
|
||||
json_schema_to_type,
|
||||
)
|
||||
|
||||
|
||||
def get_dataclass_field(type: type, field_name: str) -> Field:
|
||||
return type.__dataclass_fields__[field_name] # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
class TestFormatTypes:
|
||||
"""Test suite for format type validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def datetime_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "date-time"})
|
||||
|
||||
@pytest.fixture
|
||||
def email_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "email"})
|
||||
|
||||
@pytest.fixture
|
||||
def uri_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "uri"})
|
||||
|
||||
@pytest.fixture
|
||||
def uri_reference_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "uri-reference"})
|
||||
|
||||
@pytest.fixture
|
||||
def json_format(self):
|
||||
return json_schema_to_type({"type": "string", "format": "json"})
|
||||
|
||||
@pytest.fixture
|
||||
def mixed_formats_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"full_uri": {"type": "string", "format": "uri"},
|
||||
"ref_uri": {"type": "string", "format": "uri-reference"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_datetime_valid(self, datetime_format):
|
||||
validator = TypeAdapter(datetime_format)
|
||||
result = validator.validate_python("2024-01-17T12:34:56Z")
|
||||
assert isinstance(result, datetime)
|
||||
|
||||
def test_datetime_invalid(self, datetime_format):
|
||||
validator = TypeAdapter(datetime_format)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-a-date")
|
||||
|
||||
def test_email_valid(self, email_format):
|
||||
validator = TypeAdapter(email_format)
|
||||
result = validator.validate_python("test@example.com")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_email_invalid(self, email_format):
|
||||
validator = TypeAdapter(email_format)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-an-email")
|
||||
|
||||
def test_uri_valid(self, uri_format):
|
||||
validator = TypeAdapter(uri_format)
|
||||
result = validator.validate_python("https://example.com")
|
||||
assert isinstance(result, AnyUrl)
|
||||
|
||||
def test_uri_invalid(self, uri_format):
|
||||
validator = TypeAdapter(uri_format)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-a-uri")
|
||||
|
||||
def test_uri_reference_valid(self, uri_reference_format):
|
||||
validator = TypeAdapter(uri_reference_format)
|
||||
result = validator.validate_python("https://example.com")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_uri_reference_relative_valid(self, uri_reference_format):
|
||||
validator = TypeAdapter(uri_reference_format)
|
||||
result = validator.validate_python("/path/to/resource")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_uri_reference_invalid(self, uri_reference_format):
|
||||
validator = TypeAdapter(uri_reference_format)
|
||||
result = validator.validate_python("not a uri")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_json_valid(self, json_format):
|
||||
validator = TypeAdapter(json_format)
|
||||
result = validator.validate_python('{"key": "value"}')
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_json_invalid(self, json_format):
|
||||
validator = TypeAdapter(json_format)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("{invalid json}")
|
||||
|
||||
def test_mixed_formats_object(self, mixed_formats_object):
|
||||
validator = TypeAdapter(mixed_formats_object)
|
||||
result = validator.validate_python(
|
||||
{"full_uri": "https://example.com", "ref_uri": "/path/to/resource"}
|
||||
)
|
||||
assert isinstance(result.full_uri, AnyUrl)
|
||||
assert isinstance(result.ref_uri, str)
|
||||
170
tests/utilities/json_schema_type/test_json_schema_type.py
Normal file
170
tests/utilities/json_schema_type/test_json_schema_type.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Core JSON schema type conversion tests."""
|
||||
|
||||
from dataclasses import Field
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from fastmcp.utilities.json_schema_type import (
|
||||
json_schema_to_type,
|
||||
)
|
||||
|
||||
|
||||
def get_dataclass_field(type: type, field_name: str) -> Field:
|
||||
return type.__dataclass_fields__[field_name] # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
class TestSimpleTypes:
|
||||
"""Test suite for basic type validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def simple_string(self):
|
||||
return json_schema_to_type({"type": "string"})
|
||||
|
||||
@pytest.fixture
|
||||
def simple_number(self):
|
||||
return json_schema_to_type({"type": "number"})
|
||||
|
||||
@pytest.fixture
|
||||
def simple_integer(self):
|
||||
return json_schema_to_type({"type": "integer"})
|
||||
|
||||
@pytest.fixture
|
||||
def simple_boolean(self):
|
||||
return json_schema_to_type({"type": "boolean"})
|
||||
|
||||
@pytest.fixture
|
||||
def simple_null(self):
|
||||
return json_schema_to_type({"type": "null"})
|
||||
|
||||
def test_string_accepts_string(self, simple_string):
|
||||
validator = TypeAdapter(simple_string)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_string_rejects_number(self, simple_string):
|
||||
validator = TypeAdapter(simple_string)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(123)
|
||||
|
||||
def test_number_accepts_float(self, simple_number):
|
||||
validator = TypeAdapter(simple_number)
|
||||
assert validator.validate_python(123.45) == 123.45
|
||||
|
||||
def test_number_accepts_integer(self, simple_number):
|
||||
validator = TypeAdapter(simple_number)
|
||||
assert validator.validate_python(123) == 123
|
||||
|
||||
def test_number_accepts_numeric_string(self, simple_number):
|
||||
validator = TypeAdapter(simple_number)
|
||||
assert validator.validate_python("123.45") == 123.45
|
||||
assert validator.validate_python("123") == 123
|
||||
|
||||
def test_number_rejects_invalid_string(self, simple_number):
|
||||
validator = TypeAdapter(simple_number)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not a number")
|
||||
|
||||
def test_integer_accepts_integer(self, simple_integer):
|
||||
validator = TypeAdapter(simple_integer)
|
||||
assert validator.validate_python(123) == 123
|
||||
|
||||
def test_integer_accepts_integer_string(self, simple_integer):
|
||||
validator = TypeAdapter(simple_integer)
|
||||
assert validator.validate_python("123") == 123
|
||||
|
||||
def test_integer_rejects_float(self, simple_integer):
|
||||
validator = TypeAdapter(simple_integer)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(123.45)
|
||||
|
||||
def test_integer_rejects_float_string(self, simple_integer):
|
||||
validator = TypeAdapter(simple_integer)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("123.45")
|
||||
|
||||
def test_boolean_accepts_boolean(self, simple_boolean):
|
||||
validator = TypeAdapter(simple_boolean)
|
||||
assert validator.validate_python(True) is True
|
||||
assert validator.validate_python(False) is False
|
||||
|
||||
def test_boolean_accepts_boolean_strings(self, simple_boolean):
|
||||
validator = TypeAdapter(simple_boolean)
|
||||
assert validator.validate_python("true") is True
|
||||
assert validator.validate_python("True") is True
|
||||
assert validator.validate_python("false") is False
|
||||
assert validator.validate_python("False") is False
|
||||
|
||||
def test_boolean_rejects_invalid_string(self, simple_boolean):
|
||||
validator = TypeAdapter(simple_boolean)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not a boolean")
|
||||
|
||||
def test_null_accepts_none(self, simple_null):
|
||||
validator = TypeAdapter(simple_null)
|
||||
assert validator.validate_python(None) is None
|
||||
|
||||
def test_null_rejects_false(self, simple_null):
|
||||
validator = TypeAdapter(simple_null)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(False)
|
||||
|
||||
|
||||
class TestConstrainedTypes:
|
||||
def test_constant(self):
|
||||
validator = TypeAdapter(Literal["x"])
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal["x"]
|
||||
assert TypeAdapter(type_).validate_python("x") == "x"
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python("y")
|
||||
|
||||
def test_union_constants(self):
|
||||
validator = TypeAdapter(Literal["x"] | Literal["y"])
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal["x"] | Literal["y"]
|
||||
assert TypeAdapter(type_).validate_python("x") == "x"
|
||||
assert TypeAdapter(type_).validate_python("y") == "y"
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python("z")
|
||||
|
||||
def test_enum_str(self):
|
||||
class MyEnum(Enum):
|
||||
X = "x"
|
||||
Y = "y"
|
||||
|
||||
validator = TypeAdapter(MyEnum)
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal["x", "y"]
|
||||
assert TypeAdapter(type_).validate_python("x") == "x"
|
||||
assert TypeAdapter(type_).validate_python("y") == "y"
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python("z")
|
||||
|
||||
def test_enum_int(self):
|
||||
class MyEnum(Enum):
|
||||
X = 1
|
||||
Y = 2
|
||||
|
||||
validator = TypeAdapter(MyEnum)
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal[1, 2]
|
||||
assert TypeAdapter(type_).validate_python(1) == 1
|
||||
assert TypeAdapter(type_).validate_python(2) == 2
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python(3)
|
||||
|
||||
def test_choice(self):
|
||||
validator = TypeAdapter(Literal["x", "y"])
|
||||
schema = validator.json_schema()
|
||||
type_ = json_schema_to_type(schema)
|
||||
assert type_ == Literal["x", "y"]
|
||||
assert TypeAdapter(type_).validate_python("x") == "x"
|
||||
assert TypeAdapter(type_).validate_python("y") == "y"
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(type_).validate_python("z")
|
||||
128
tests/utilities/json_schema_type/test_unions.py
Normal file
128
tests/utilities/json_schema_type/test_unions.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Tests for union types in JSON schema conversion."""
|
||||
|
||||
from dataclasses import Field
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from fastmcp.utilities.json_schema_type import (
|
||||
json_schema_to_type,
|
||||
)
|
||||
|
||||
|
||||
def get_dataclass_field(type: type, field_name: str) -> Field:
|
||||
return type.__dataclass_fields__[field_name] # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
class TestUnionTypes:
|
||||
"""Test suite for testing union type behaviors."""
|
||||
|
||||
@pytest.fixture
|
||||
def heterogeneous_union(self):
|
||||
return json_schema_to_type({"type": ["string", "number", "boolean", "null"]})
|
||||
|
||||
@pytest.fixture
|
||||
def union_with_constraints(self):
|
||||
return json_schema_to_type(
|
||||
{"type": ["string", "number"], "minLength": 3, "minimum": 0}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def union_with_formats(self):
|
||||
return json_schema_to_type({"type": ["string", "null"], "format": "email"})
|
||||
|
||||
@pytest.fixture
|
||||
def nested_union_array(self):
|
||||
return json_schema_to_type(
|
||||
{"type": "array", "items": {"type": ["string", "number"]}}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def nested_union_object(self):
|
||||
return json_schema_to_type(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": ["string", "integer"]},
|
||||
"data": {
|
||||
"type": ["object", "null"],
|
||||
"properties": {"value": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_heterogeneous_accepts_string(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_heterogeneous_accepts_number(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
assert validator.validate_python(123.45) == 123.45
|
||||
|
||||
def test_heterogeneous_accepts_boolean(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
assert validator.validate_python(True) is True
|
||||
|
||||
def test_heterogeneous_accepts_null(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
assert validator.validate_python(None) is None
|
||||
|
||||
def test_heterogeneous_rejects_array(self, heterogeneous_union):
|
||||
validator = TypeAdapter(heterogeneous_union)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python([])
|
||||
|
||||
def test_constrained_string_valid(self, union_with_constraints):
|
||||
validator = TypeAdapter(union_with_constraints)
|
||||
assert validator.validate_python("test") == "test"
|
||||
|
||||
def test_constrained_string_invalid(self, union_with_constraints):
|
||||
validator = TypeAdapter(union_with_constraints)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("ab")
|
||||
|
||||
def test_constrained_number_valid(self, union_with_constraints):
|
||||
validator = TypeAdapter(union_with_constraints)
|
||||
assert validator.validate_python(10) == 10
|
||||
|
||||
def test_constrained_number_invalid(self, union_with_constraints):
|
||||
validator = TypeAdapter(union_with_constraints)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(-1)
|
||||
|
||||
def test_format_valid_email(self, union_with_formats):
|
||||
validator = TypeAdapter(union_with_formats)
|
||||
result = validator.validate_python("test@example.com")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_format_valid_null(self, union_with_formats):
|
||||
validator = TypeAdapter(union_with_formats)
|
||||
assert validator.validate_python(None) is None
|
||||
|
||||
def test_format_invalid_email(self, union_with_formats):
|
||||
validator = TypeAdapter(union_with_formats)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python("not-an-email")
|
||||
|
||||
def test_nested_array_mixed_types(self, nested_union_array):
|
||||
validator = TypeAdapter(nested_union_array)
|
||||
result = validator.validate_python(["test", 123, "abc"])
|
||||
assert result == ["test", 123, "abc"]
|
||||
|
||||
def test_nested_array_rejects_invalid(self, nested_union_array):
|
||||
validator = TypeAdapter(nested_union_array)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate_python(["test", ["not", "allowed"], "abc"])
|
||||
|
||||
def test_nested_object_string_id(self, nested_union_object):
|
||||
validator = TypeAdapter(nested_union_object)
|
||||
result = validator.validate_python({"id": "abc123", "data": {"value": "test"}})
|
||||
assert result.id == "abc123"
|
||||
assert result.data.value == "test"
|
||||
|
||||
def test_nested_object_integer_id(self, nested_union_object):
|
||||
validator = TypeAdapter(nested_union_object)
|
||||
result = validator.validate_python({"id": 123, "data": None})
|
||||
assert result.id == 123
|
||||
assert result.data is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue