Consolidate test fixtures and refactor large test files (#2941)

This commit is contained in:
Jeremiah Lowin 2026-01-19 15:18:35 -05:00 committed by GitHub
commit 23bfdf0680
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 12679 additions and 12544 deletions

View file

@ -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.

View file

View 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"

View 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

View 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)

View 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)

View 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

View 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)

View 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)

View file

@ -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

View file

@ -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

View file

@ -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}')])

View 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()

View 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

View 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

View 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"

View 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

View 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"

View 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

View 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 "&lt;script&gt;" in html
assert "<b>HTML</b>" not in html
assert "&lt;b&gt;HTML&lt;/b&gt;" 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&#x27;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

View file

@ -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

View file

@ -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(
[

View file

@ -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

View file

View 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)

View 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

View 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"

View 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

View 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)

View 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"

View 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"}

View 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

View 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})

View file

@ -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"

View file

@ -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"]}

View 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"

View 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

View file

@ -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

View file

View 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

View 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

View 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

View 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

View file

View 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}")]

View 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

View 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)

View 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

View 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"

View 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"

View file

View 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})

View 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

View 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"],
}
)

View 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"

View 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."""

View 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)

View 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

View 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)

View 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")

View 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