From 23bfdf06809dd59c457c853f179c3c3f100a8aca Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 19 Jan 2026 15:18:35 -0500
Subject: [PATCH] Consolidate test fixtures and refactor large test files
(#2941)
---
src/fastmcp/utilities/tests.py | 16 -
tests/client/client/__init__.py | 0
tests/client/client/test_auth.py | 82 +
tests/client/client/test_client.py | 719 ++++++
tests/client/client/test_error_handling.py | 166 ++
tests/client/client/test_initialize.py | 115 +
tests/client/client/test_session.py | 137 ++
tests/client/client/test_timeout.py | 50 +
tests/client/client/test_transport.py | 137 ++
tests/client/tasks/conftest.py | 3 -
tests/client/test_client.py | 1438 ------------
tests/conftest.py | 188 ++
tests/deprecated/test_tool_serializer.py | 7 +-
tests/server/auth/oauth_proxy/__init__.py | 0
tests/server/auth/oauth_proxy/conftest.py | 309 +++
.../auth/oauth_proxy/test_authorization.py | 196 ++
.../oauth_proxy/test_client_registration.py | 43 +
tests/server/auth/oauth_proxy/test_config.py | 210 ++
tests/server/auth/oauth_proxy/test_e2e.py | 240 ++
.../auth/oauth_proxy/test_oauth_proxy.py | 69 +
tests/server/auth/oauth_proxy/test_tokens.py | 495 ++++
tests/server/auth/oauth_proxy/test_ui.py | 99 +
tests/server/auth/test_oauth_proxy.py | 1865 ---------------
.../server/middleware/test_error_handling.py | 150 +-
tests/server/middleware/test_logging.py | 96 +-
tests/server/middleware/test_timing.py | 90 +-
tests/server/mount/__init__.py | 0
tests/server/mount/test_advanced.py | 489 ++++
tests/server/mount/test_filtering.py | 97 +
tests/server/mount/test_mount.py | 542 +++++
tests/server/mount/test_prompts.py | 50 +
tests/server/mount/test_proxy.py | 306 +++
tests/server/mount/test_resources.py | 136 ++
.../local_provider_tools/__init__.py | 0
.../local_provider_tools/test_context.py | 162 ++
.../local_provider_tools/test_decorator.py | 341 +++
.../local_provider_tools/test_enabled.py | 132 ++
.../test_local_provider_tools.py | 274 +++
.../test_output_schema.py | 284 +++
.../local_provider_tools/test_parameters.py | 416 ++++
.../local_provider_tools/test_tags.py | 94 +
.../providers/test_local_provider_tools.py | 1554 -------------
tests/server/tasks/conftest.py | 3 -
tests/server/test_mount.py | 1590 -------------
tests/server/test_versioning.py | 1423 ------------
tests/server/versioning/__init__.py | 0
tests/server/versioning/test_calls.py | 361 +++
tests/server/versioning/test_filtering.py | 492 ++++
tests/server/versioning/test_mounting.py | 342 +++
tests/server/versioning/test_versioning.py | 258 +++
tests/tools/test_tool.py | 2026 -----------------
tests/tools/test_tool_transform.py | 1751 --------------
tests/tools/tool/__init__.py | 0
tests/tools/tool/test_callable.py | 102 +
tests/tools/tool/test_content.py | 550 +++++
tests/tools/tool/test_output_schema.py | 534 +++++
tests/tools/tool/test_results.py | 184 ++
tests/tools/tool/test_title.py | 95 +
tests/tools/tool/test_tool.py | 595 +++++
tests/tools/tool_transform/__init__.py | 0
tests/tools/tool_transform/test_args.py | 456 ++++
tests/tools/tool_transform/test_metadata.py | 172 ++
tests/tools/tool_transform/test_schemas.py | 534 +++++
.../tool_transform/test_tool_transform.py | 530 +++++
tests/utilities/json_schema_type/__init__.py | 0
.../test_advanced.py} | 681 +-----
.../json_schema_type/test_constraints.py | 132 ++
.../json_schema_type/test_containers.py | 201 ++
.../json_schema_type/test_formats.py | 114 +
.../json_schema_type/test_json_schema_type.py | 170 ++
.../utilities/json_schema_type/test_unions.py | 128 ++
71 files changed, 12678 insertions(+), 12543 deletions(-)
create mode 100644 tests/client/client/__init__.py
create mode 100644 tests/client/client/test_auth.py
create mode 100644 tests/client/client/test_client.py
create mode 100644 tests/client/client/test_error_handling.py
create mode 100644 tests/client/client/test_initialize.py
create mode 100644 tests/client/client/test_session.py
create mode 100644 tests/client/client/test_timeout.py
create mode 100644 tests/client/client/test_transport.py
delete mode 100644 tests/client/tasks/conftest.py
delete mode 100644 tests/client/test_client.py
create mode 100644 tests/server/auth/oauth_proxy/__init__.py
create mode 100644 tests/server/auth/oauth_proxy/conftest.py
create mode 100644 tests/server/auth/oauth_proxy/test_authorization.py
create mode 100644 tests/server/auth/oauth_proxy/test_client_registration.py
create mode 100644 tests/server/auth/oauth_proxy/test_config.py
create mode 100644 tests/server/auth/oauth_proxy/test_e2e.py
create mode 100644 tests/server/auth/oauth_proxy/test_oauth_proxy.py
create mode 100644 tests/server/auth/oauth_proxy/test_tokens.py
create mode 100644 tests/server/auth/oauth_proxy/test_ui.py
delete mode 100644 tests/server/auth/test_oauth_proxy.py
create mode 100644 tests/server/mount/__init__.py
create mode 100644 tests/server/mount/test_advanced.py
create mode 100644 tests/server/mount/test_filtering.py
create mode 100644 tests/server/mount/test_mount.py
create mode 100644 tests/server/mount/test_prompts.py
create mode 100644 tests/server/mount/test_proxy.py
create mode 100644 tests/server/mount/test_resources.py
create mode 100644 tests/server/providers/local_provider_tools/__init__.py
create mode 100644 tests/server/providers/local_provider_tools/test_context.py
create mode 100644 tests/server/providers/local_provider_tools/test_decorator.py
create mode 100644 tests/server/providers/local_provider_tools/test_enabled.py
create mode 100644 tests/server/providers/local_provider_tools/test_local_provider_tools.py
create mode 100644 tests/server/providers/local_provider_tools/test_output_schema.py
create mode 100644 tests/server/providers/local_provider_tools/test_parameters.py
create mode 100644 tests/server/providers/local_provider_tools/test_tags.py
delete mode 100644 tests/server/providers/test_local_provider_tools.py
delete mode 100644 tests/server/tasks/conftest.py
delete mode 100644 tests/server/test_mount.py
delete mode 100644 tests/server/test_versioning.py
create mode 100644 tests/server/versioning/__init__.py
create mode 100644 tests/server/versioning/test_calls.py
create mode 100644 tests/server/versioning/test_filtering.py
create mode 100644 tests/server/versioning/test_mounting.py
create mode 100644 tests/server/versioning/test_versioning.py
delete mode 100644 tests/tools/test_tool.py
delete mode 100644 tests/tools/test_tool_transform.py
create mode 100644 tests/tools/tool/__init__.py
create mode 100644 tests/tools/tool/test_callable.py
create mode 100644 tests/tools/tool/test_content.py
create mode 100644 tests/tools/tool/test_output_schema.py
create mode 100644 tests/tools/tool/test_results.py
create mode 100644 tests/tools/tool/test_title.py
create mode 100644 tests/tools/tool/test_tool.py
create mode 100644 tests/tools/tool_transform/__init__.py
create mode 100644 tests/tools/tool_transform/test_args.py
create mode 100644 tests/tools/tool_transform/test_metadata.py
create mode 100644 tests/tools/tool_transform/test_schemas.py
create mode 100644 tests/tools/tool_transform/test_tool_transform.py
create mode 100644 tests/utilities/json_schema_type/__init__.py
rename tests/utilities/{test_json_schema_type.py => json_schema_type/test_advanced.py} (57%)
create mode 100644 tests/utilities/json_schema_type/test_constraints.py
create mode 100644 tests/utilities/json_schema_type/test_containers.py
create mode 100644 tests/utilities/json_schema_type/test_formats.py
create mode 100644 tests/utilities/json_schema_type/test_json_schema_type.py
create mode 100644 tests/utilities/json_schema_type/test_unions.py
diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py
index d78ce3e62..e25bb38b5 100644
--- a/src/fastmcp/utilities/tests.py
+++ b/src/fastmcp/utilities/tests.py
@@ -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.
diff --git a/tests/client/client/__init__.py b/tests/client/client/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/client/client/test_auth.py b/tests/client/client/test_auth.py
new file mode 100644
index 000000000..f6b9dc089
--- /dev/null
+++ b/tests/client/client/test_auth.py
@@ -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"
diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py
new file mode 100644
index 000000000..ae9e39c8d
--- /dev/null
+++ b/tests/client/client/test_client.py
@@ -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
diff --git a/tests/client/client/test_error_handling.py b/tests/client/client/test_error_handling.py
new file mode 100644
index 000000000..5f5c22175
--- /dev/null
+++ b/tests/client/client/test_error_handling.py
@@ -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)
diff --git a/tests/client/client/test_initialize.py b/tests/client/client/test_initialize.py
new file mode 100644
index 000000000..2c0ccfdf4
--- /dev/null
+++ b/tests/client/client/test_initialize.py
@@ -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)
diff --git a/tests/client/client/test_session.py b/tests/client/client/test_session.py
new file mode 100644
index 000000000..f209cb113
--- /dev/null
+++ b/tests/client/client/test_session.py
@@ -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
diff --git a/tests/client/client/test_timeout.py b/tests/client/client/test_timeout.py
new file mode 100644
index 000000000..0b3b0fd3a
--- /dev/null
+++ b/tests/client/client/test_timeout.py
@@ -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)
diff --git a/tests/client/client/test_transport.py b/tests/client/client/test_transport.py
new file mode 100644
index 000000000..b7ee5330c
--- /dev/null
+++ b/tests/client/client/test_transport.py
@@ -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)
diff --git a/tests/client/tasks/conftest.py b/tests/client/tasks/conftest.py
deleted file mode 100644
index a12e0199a..000000000
--- a/tests/client/tasks/conftest.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Shared fixtures for client task tests."""
-
-# Task protocol is now always enabled - no fixture needed
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
deleted file mode 100644
index 36691e111..000000000
--- a/tests/client/test_client.py
+++ /dev/null
@@ -1,1438 +0,0 @@
-import asyncio
-import contextlib
-import sys
-from collections.abc import AsyncIterator
-from typing import Any, cast
-
-import anyio
-import pytest
-from mcp import ClientSession, McpError
-from mcp.client.auth import OAuthClientProvider
-from mcp.types import TextContent
-from pydantic import AnyUrl
-
-import fastmcp
-from fastmcp.client import Client
-from fastmcp.client.auth.bearer import BearerAuth
-from fastmcp.client.transports import (
- ClientTransport,
- FastMCPTransport,
- MCPConfigTransport,
- SSETransport,
- StdioTransport,
- StreamableHttpTransport,
- infer_transport,
-)
-from fastmcp.exceptions import ResourceError, ToolError
-from fastmcp.server.server import FastMCP
-
-
-@pytest.fixture
-def fastmcp_server():
- """Fixture that creates a FastMCP server with tools, resources, and prompts."""
- 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:
- import json
-
- 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:
- import json
-
- 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 tagged_resources_server():
- """Fixture that creates a FastMCP server with tagged resources and templates."""
- import json
-
- 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
-
-
-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."""
- from mcp import McpError
-
- 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
-
-
-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)
-
-
-@pytest.mark.skipif(
- sys.platform == "win32",
- reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.",
-)
-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)
-
-
-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)
-
-
-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"
-
-
-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)
-
-
-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
diff --git a/tests/conftest.py b/tests/conftest.py
index 043835c36..ae3061fe5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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
diff --git a/tests/deprecated/test_tool_serializer.py b/tests/deprecated/test_tool_serializer.py
index 4eba423ba..f90bf06cc 100644
--- a/tests/deprecated/test_tool_serializer.py
+++ b/tests/deprecated/test_tool_serializer.py
@@ -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}')])
diff --git a/tests/server/auth/oauth_proxy/__init__.py b/tests/server/auth/oauth_proxy/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/server/auth/oauth_proxy/conftest.py b/tests/server/auth/oauth_proxy/conftest.py
new file mode 100644
index 000000000..3acfacf7f
--- /dev/null
+++ b/tests/server/auth/oauth_proxy/conftest.py
@@ -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()
diff --git a/tests/server/auth/oauth_proxy/test_authorization.py b/tests/server/auth/oauth_proxy/test_authorization.py
new file mode 100644
index 000000000..2b5aaf4a2
--- /dev/null
+++ b/tests/server/auth/oauth_proxy/test_authorization.py
@@ -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
diff --git a/tests/server/auth/oauth_proxy/test_client_registration.py b/tests/server/auth/oauth_proxy/test_client_registration.py
new file mode 100644
index 000000000..b865b4336
--- /dev/null
+++ b/tests/server/auth/oauth_proxy/test_client_registration.py
@@ -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
diff --git a/tests/server/auth/oauth_proxy/test_config.py b/tests/server/auth/oauth_proxy/test_config.py
new file mode 100644
index 000000000..b69a1d80f
--- /dev/null
+++ b/tests/server/auth/oauth_proxy/test_config.py
@@ -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"
diff --git a/tests/server/auth/oauth_proxy/test_e2e.py b/tests/server/auth/oauth_proxy/test_e2e.py
new file mode 100644
index 000000000..8b500db61
--- /dev/null
+++ b/tests/server/auth/oauth_proxy/test_e2e.py
@@ -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
diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py
new file mode 100644
index 000000000..a8cfac54d
--- /dev/null
+++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py
@@ -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"
diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py
new file mode 100644
index 000000000..5051a8ebc
--- /dev/null
+++ b/tests/server/auth/oauth_proxy/test_tokens.py
@@ -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
diff --git a/tests/server/auth/oauth_proxy/test_ui.py b/tests/server/auth/oauth_proxy/test_ui.py
new file mode 100644
index 000000000..9967d5a31
--- /dev/null
+++ b/tests/server/auth/oauth_proxy/test_ui.py
@@ -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 "" in html
+ assert "
Test Error" 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 ",
+ error_message="Message with HTML tags",
+ error_details={"Key" not in html
+ assert "<script>" in html
+ assert "HTML" not in html
+ assert "<b>HTML</b>" in html
+
+ async def test_callback_error_returns_html_page(self):
+ """Test that OAuth callback errors return styled HTML instead of data: URLs."""
+ # Create a minimal OAuth proxy
+ provider = OAuthProxy(
+ upstream_authorization_endpoint="https://idp.example.com/authorize",
+ upstream_token_endpoint="https://idp.example.com/token",
+ upstream_client_id="test-client",
+ upstream_client_secret="test-secret",
+ token_verifier=JWTVerifier(
+ jwks_uri="https://idp.example.com/.well-known/jwks.json",
+ issuer="https://idp.example.com",
+ audience="test-client",
+ ),
+ base_url="http://localhost:8000",
+ jwt_signing_key="test-signing-key",
+ )
+
+ # Mock a request with an error from the IdP
+ mock_request = Mock(spec=Request)
+ mock_request.query_params = {
+ "error": "invalid_scope",
+ "error_description": "The application asked for scope 'read' that doesn't exist",
+ "state": "test-state",
+ }
+
+ # Call the callback handler
+ response = await provider._handle_idp_callback(mock_request)
+
+ # Verify we get an HTMLResponse, not a RedirectResponse
+ assert isinstance(response, HTMLResponse)
+ assert response.status_code == 400
+
+ # Verify the response contains the error message
+ assert b"invalid_scope" in response.body
+ assert b"doesn't exist" in response.body # HTML-escaped apostrophe
+ assert b"OAuth Error" in response.body
diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py
deleted file mode 100644
index 68b87d2e3..000000000
--- a/tests/server/auth/test_oauth_proxy.py
+++ /dev/null
@@ -1,1865 +0,0 @@
-"""Comprehensive tests for OAuth Proxy Provider functionality.
-
-This test suite covers:
-1. Initialization and configuration
-2. Client registration (DCR)
-3. Authorization flow
-4. Token management
-5. PKCE forwarding
-6. Token endpoint authentication methods
-7. E2E testing with mock OAuth provider
-"""
-
-import asyncio
-import secrets
-import time
-from unittest.mock import AsyncMock, Mock, patch
-from urllib.parse import parse_qs, urlencode, urlparse
-
-import httpx
-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,
- AuthorizationParams,
- AuthorizeError,
-)
-from mcp.shared.auth import OAuthClientInformationFull
-from pydantic import AnyUrl
-from starlette.applications import Starlette
-from starlette.requests import Request
-from starlette.responses import HTMLResponse, JSONResponse
-from starlette.routing import Route
-from starlette.testclient import TestClient
-
-from fastmcp import FastMCP
-from fastmcp.server.auth.auth import (
- AccessToken,
- 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.oauth_proxy.ui import create_error_html
-from fastmcp.server.auth.providers.jwt import JWTVerifier
-
-# =============================================================================
-# Mock OAuth Provider for E2E Testing
-# =============================================================================
-
-
-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:
- """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),
- )
-
-
-# =============================================================================
-# Test Fixtures
-# =============================================================================
-
-
-@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()
-
-
-# =============================================================================
-# Test Classes
-# =============================================================================
-
-
-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"
-
-
-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
-
-
-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):
- 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 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 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
-
-
-class TestParameterForwarding:
- """Tests for forwarding custom parameters to upstream OAuth provider."""
-
- @pytest.fixture
- def proxy_with_extra_params(self, jwt_verifier):
- """Create OAuthProxy with extra parameters configured."""
- 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",
- extra_authorize_params={"audience": "https://api.example.com"},
- extra_token_params={"audience": "https://api.example.com"},
- jwt_signing_key="test-secret",
- )
-
- @pytest.fixture
- def proxy_without_extra_params(self, jwt_verifier):
- """Create OAuthProxy without extra parameters."""
- 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",
- jwt_signing_key="test-secret",
- )
-
- async def test_resource_parameter_forwarding(self, proxy_without_extra_params):
- """Test that RFC 8707 resource parameter is forwarded from client request."""
- client = OAuthClientInformationFull(
- client_id="test-client",
- client_secret="test-secret",
- redirect_uris=[AnyUrl("http://localhost:12345/callback")],
- )
-
- # Register client first
- await proxy_without_extra_params.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"],
- resource="https://api.example.com/v1", # RFC 8707 resource indicator
- )
-
- redirect_url = await proxy_without_extra_params.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
-
- # Resource parameter should be stored in transaction for upstream forwarding
- txn_id = query_params["txn_id"][0]
- transaction = await proxy_without_extra_params._transaction_store.get(
- key=txn_id
- )
- assert transaction is not None
- assert transaction.resource == "https://api.example.com/v1"
-
- async def test_extra_authorize_params(self, proxy_with_extra_params):
- """Test that extra authorization parameters are included."""
- client = OAuthClientInformationFull(
- client_id="test-client",
- client_secret="test-secret",
- redirect_uris=[AnyUrl("http://localhost:12345/callback")],
- )
-
- # Register client first
- await proxy_with_extra_params.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_extra_params.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
-
- # Extra audience parameter is configured at proxy level (not per-transaction)
- txn_id = query_params["txn_id"][0]
- transaction = await proxy_with_extra_params._transaction_store.get(key=txn_id)
- assert transaction is not None
- # Verify proxy has extra params configured
- assert (
- proxy_with_extra_params._extra_authorize_params.get("audience")
- == "https://api.example.com"
- )
-
- async def test_resource_and_extra_params_together(self, proxy_with_extra_params):
- """Test that both resource and extra params can be used together."""
- client = OAuthClientInformationFull(
- client_id="test-client",
- client_secret="test-secret",
- redirect_uris=[AnyUrl("http://localhost:12345/callback")],
- )
-
- # Register client first
- await proxy_with_extra_params.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"],
- resource="https://resource.example.com", # Client-specified resource
- )
-
- redirect_url = await proxy_with_extra_params.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
-
- # Resource stored in transaction, extra params configured at proxy level
- txn_id = query_params["txn_id"][0]
- transaction = await proxy_with_extra_params._transaction_store.get(key=txn_id)
- assert transaction is not None
- assert transaction.resource == "https://resource.example.com"
- assert (
- proxy_with_extra_params._extra_authorize_params.get("audience")
- == "https://api.example.com"
- )
-
- async def test_no_extra_params_when_not_configured(
- self, proxy_without_extra_params
- ):
- """Test that no extra params are added when not configured."""
- client = OAuthClientInformationFull(
- client_id="test-client",
- client_secret="test-secret",
- redirect_uris=[AnyUrl("http://localhost:12345/callback")],
- )
-
- params = AuthorizationParams(
- redirect_uri=AnyUrl("http://localhost:12345/callback"),
- redirect_uri_provided_explicitly=True,
- state="client-state",
- code_challenge="client_challenge",
- scopes=["read"],
- # No resource parameter
- )
-
- redirect_url = await proxy_without_extra_params.authorize(client, params)
- query_params = parse_qs(urlparse(redirect_url).query)
-
- # No audience parameter should be present (not configured)
- assert "audience" not in query_params
- # No resource parameter should be present (not provided by client)
- assert "resource" not in query_params
-
- async def test_multiple_extra_params(self, jwt_verifier):
- """Test multiple extra parameters can be configured and forwarded."""
- 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",
- extra_authorize_params={
- "audience": "https://api.example.com",
- "prompt": "consent",
- "max_age": "3600",
- },
- 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",
- scopes=["read"],
- )
-
- redirect_url = await proxy.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
-
- # All extra parameters configured at proxy level
- txn_id = query_params["txn_id"][0]
- transaction = await proxy._transaction_store.get(key=txn_id)
- assert transaction is not None
- # Verify proxy has all extra params configured
- assert (
- proxy._extra_authorize_params.get("audience") == "https://api.example.com"
- )
- assert proxy._extra_authorize_params.get("prompt") == "consent"
- assert proxy._extra_authorize_params.get("max_age") == "3600"
-
- async def test_token_endpoint_invalid_client_error(self, jwt_verifier):
- """Test that invalid client_id returns OAuth 2.1 compliant error response.
-
- When a client ID is not found during token exchange, the proxy should:
- 1. Return HTTP 401 status code
- 2. Use 'invalid_client' error code instead of 'unauthorized_client'
-
- This aligns with OAuth 2.1 spec and enables Claude's automatic client re-registration.
- """
- 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",
- )
-
- # Create a test app with OAuth routes
- app = Starlette(routes=proxy.get_routes())
-
- # Test the token endpoint with an invalid (non-existent) client_id
- with TestClient(app) as client:
- response = client.post(
- "/token",
- data={
- "grant_type": "authorization_code",
- "code": "test-auth-code",
- "client_id": "non-existent-client-id",
- "code_verifier": "test-code-verifier",
- "redirect_uri": "http://localhost:12345/callback",
- },
- headers={
- "Content-Type": "application/x-www-form-urlencoded",
- },
- )
-
- # Verify OAuth 2.1 compliant error response
- assert response.status_code == 401, (
- f"Expected 401 but got {response.status_code}"
- )
-
- error_data = response.json()
- assert error_data["error"] == "invalid_client", (
- f"Expected 'invalid_client' but got '{error_data.get('error')}'"
- )
- assert "Invalid client_id" in error_data["error_description"]
-
- # Verify proper cache headers are set
- assert response.headers.get("Cache-Control") == "no-store"
- assert response.headers.get("Pragma") == "no-cache"
-
-
-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 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 "" in html
- assert "Test Error" 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 ",
- error_message="Message with HTML tags",
- error_details={"Key" not in html
- assert "<script>" in html
- assert "HTML" not in html
- assert "<b>HTML</b>" in html
-
- async def test_callback_error_returns_html_page(self):
- """Test that OAuth callback errors return styled HTML instead of data: URLs."""
- # Create a minimal OAuth proxy
- provider = OAuthProxy(
- upstream_authorization_endpoint="https://idp.example.com/authorize",
- upstream_token_endpoint="https://idp.example.com/token",
- upstream_client_id="test-client",
- upstream_client_secret="test-secret",
- token_verifier=JWTVerifier(
- jwks_uri="https://idp.example.com/.well-known/jwks.json",
- issuer="https://idp.example.com",
- audience="test-client",
- ),
- base_url="http://localhost:8000",
- jwt_signing_key="test-signing-key",
- )
-
- # Mock a request with an error from the IdP
- mock_request = Mock(spec=Request)
- mock_request.query_params = {
- "error": "invalid_scope",
- "error_description": "The application asked for scope 'read' that doesn't exist",
- "state": "test-state",
- }
-
- # Call the callback handler
- response = await provider._handle_idp_callback(mock_request)
-
- # Verify we get an HTMLResponse, not a RedirectResponse
- assert isinstance(response, HTMLResponse)
- assert response.status_code == 400
-
- # Verify the response contains the error message
- assert b"invalid_scope" in response.body
- assert b"doesn't exist" in response.body # HTML-escaped apostrophe
- assert b"OAuth Error" in response.body
-
-
-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 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"
-
-
-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
diff --git a/tests/server/middleware/test_error_handling.py b/tests/server/middleware/test_error_handling.py
index 7da8c3119..bf5188712 100644
--- a/tests/server/middleware/test_error_handling.py
+++ b/tests/server/middleware/test_error_handling.py
@@ -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
diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py
index db86669e9..92758dfa4 100644
--- a/tests/server/middleware/test_logging.py
+++ b/tests/server/middleware/test_logging.py
@@ -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(
[
diff --git a/tests/server/middleware/test_timing.py b/tests/server/middleware/test_timing.py
index db726b13e..f2196985b 100644
--- a/tests/server/middleware/test_timing.py
+++ b/tests/server/middleware/test_timing.py
@@ -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
diff --git a/tests/server/mount/__init__.py b/tests/server/mount/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/server/mount/test_advanced.py b/tests/server/mount/test_advanced.py
new file mode 100644
index 000000000..84619b478
--- /dev/null
+++ b/tests/server/mount/test_advanced.py
@@ -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)
diff --git a/tests/server/mount/test_filtering.py b/tests/server/mount/test_filtering.py
new file mode 100644
index 000000000..413cc0e60
--- /dev/null
+++ b/tests/server/mount/test_filtering.py
@@ -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
diff --git a/tests/server/mount/test_mount.py b/tests/server/mount/test_mount.py
new file mode 100644
index 000000000..b1b8ee0c0
--- /dev/null
+++ b/tests/server/mount/test_mount.py
@@ -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"
diff --git a/tests/server/mount/test_prompts.py b/tests/server/mount/test_prompts.py
new file mode 100644
index 000000000..9760cab93
--- /dev/null
+++ b/tests/server/mount/test_prompts.py
@@ -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
diff --git a/tests/server/mount/test_proxy.py b/tests/server/mount/test_proxy.py
new file mode 100644
index 000000000..08caa36a0
--- /dev/null
+++ b/tests/server/mount/test_proxy.py
@@ -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)
diff --git a/tests/server/mount/test_resources.py b/tests/server/mount/test_resources.py
new file mode 100644
index 000000000..d0e18b7af
--- /dev/null
+++ b/tests/server/mount/test_resources.py
@@ -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"
diff --git a/tests/server/providers/local_provider_tools/__init__.py b/tests/server/providers/local_provider_tools/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/server/providers/local_provider_tools/test_context.py b/tests/server/providers/local_provider_tools/test_context.py
new file mode 100644
index 000000000..935a9d8af
--- /dev/null
+++ b/tests/server/providers/local_provider_tools/test_context.py
@@ -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"}
diff --git a/tests/server/providers/local_provider_tools/test_decorator.py b/tests/server/providers/local_provider_tools/test_decorator.py
new file mode 100644
index 000000000..92c5df44e
--- /dev/null
+++ b/tests/server/providers/local_provider_tools/test_decorator.py
@@ -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
diff --git a/tests/server/providers/local_provider_tools/test_enabled.py b/tests/server/providers/local_provider_tools/test_enabled.py
new file mode 100644
index 000000000..aee0c4b36
--- /dev/null
+++ b/tests/server/providers/local_provider_tools/test_enabled.py
@@ -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})
diff --git a/tests/server/providers/local_provider_tools/test_local_provider_tools.py b/tests/server/providers/local_provider_tools/test_local_provider_tools.py
new file mode 100644
index 000000000..9e1803b41
--- /dev/null
+++ b/tests/server/providers/local_provider_tools/test_local_provider_tools.py
@@ -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"
diff --git a/tests/server/providers/local_provider_tools/test_output_schema.py b/tests/server/providers/local_provider_tools/test_output_schema.py
new file mode 100644
index 000000000..6f1f38d44
--- /dev/null
+++ b/tests/server/providers/local_provider_tools/test_output_schema.py
@@ -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"]}
diff --git a/tests/server/providers/local_provider_tools/test_parameters.py b/tests/server/providers/local_provider_tools/test_parameters.py
new file mode 100644
index 000000000..92e1af6de
--- /dev/null
+++ b/tests/server/providers/local_provider_tools/test_parameters.py
@@ -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"
diff --git a/tests/server/providers/local_provider_tools/test_tags.py b/tests/server/providers/local_provider_tools/test_tags.py
new file mode 100644
index 000000000..019ed4c07
--- /dev/null
+++ b/tests/server/providers/local_provider_tools/test_tags.py
@@ -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}
diff --git a/tests/server/providers/test_local_provider_tools.py b/tests/server/providers/test_local_provider_tools.py
deleted file mode 100644
index fb000519b..000000000
--- a/tests/server/providers/test_local_provider_tools.py
+++ /dev/null
@@ -1,1554 +0,0 @@
-"""Tests for tool behavior in LocalProvider.
-
-Tests cover:
-- Tool return types and serialization
-- Tool parameters and validation
-- Tool output schemas
-- Tool context injection
-- Tool decorator patterns
-"""
-
-import base64
-import datetime
-import functools
-import json
-import uuid
-from dataclasses import dataclass
-from enum import Enum
-from pathlib import Path
-from typing import Annotated, Any, Literal
-
-import pytest
-from mcp.types import (
- AudioContent,
- BlobResourceContents,
- EmbeddedResource,
- ImageContent,
- TextContent,
-)
-from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
-from typing_extensions import TypedDict
-
-from fastmcp import Context, FastMCP
-from fastmcp.exceptions import NotFoundError
-from fastmcp.tools.tool import Tool, ToolResult
-from fastmcp.utilities.json_schema import compress_schema
-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
-
-
-@pytest.fixture
-def tool_server():
- 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
-
-
-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"
-
-
-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"
-
-
-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"]}
-
-
-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"}
-
-
-class TestToolDecorator:
- async def test_no_tools_before_decorator(self):
- from fastmcp.exceptions import NotFoundError
-
- 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
-
-
-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}
-
-
-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})
diff --git a/tests/server/tasks/conftest.py b/tests/server/tasks/conftest.py
deleted file mode 100644
index 56f525eba..000000000
--- a/tests/server/tasks/conftest.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Shared fixtures for task tests."""
-
-# Task protocol is now always enabled - no fixture needed
diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py
deleted file mode 100644
index 2dae73a23..000000000
--- a/tests/server/test_mount.py
+++ /dev/null
@@ -1,1590 +0,0 @@
-import json
-import sys
-from contextlib import asynccontextmanager
-
-import pytest
-from mcp.types import TextContent
-
-from fastmcp import FastMCP
-from fastmcp.client import Client
-from fastmcp.client.transports import FastMCPTransport, SSETransport
-from fastmcp.exceptions import NotFoundError
-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
-from fastmcp.tools.tool import Tool
-from fastmcp.tools.tool_transform import TransformedTool
-from fastmcp.utilities.tests import caplog_for_fastmcp
-
-
-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."""
-
- 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
- with caplog_for_fastmcp(caplog):
- 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"
-
-
-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 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 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
-
-
-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)
-
-
-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"
-
-
-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
-
-
-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)
diff --git a/tests/server/test_versioning.py b/tests/server/test_versioning.py
deleted file mode 100644
index 2cbd7d9b5..000000000
--- a/tests/server/test_versioning.py
+++ /dev/null
@@ -1,1423 +0,0 @@
-"""Tests for component versioning functionality."""
-# 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,
- VersionSpec,
- 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"
-
-
-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
-
-
-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 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 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
-
-
-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
diff --git a/tests/server/versioning/__init__.py b/tests/server/versioning/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/server/versioning/test_calls.py b/tests/server/versioning/test_calls.py
new file mode 100644
index 000000000..67ac9d51a
--- /dev/null
+++ b/tests/server/versioning/test_calls.py
@@ -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
diff --git a/tests/server/versioning/test_filtering.py b/tests/server/versioning/test_filtering.py
new file mode 100644
index 000000000..99e3221d9
--- /dev/null
+++ b/tests/server/versioning/test_filtering.py
@@ -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
diff --git a/tests/server/versioning/test_mounting.py b/tests/server/versioning/test_mounting.py
new file mode 100644
index 000000000..3d6d10f7f
--- /dev/null
+++ b/tests/server/versioning/test_mounting.py
@@ -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
diff --git a/tests/server/versioning/test_versioning.py b/tests/server/versioning/test_versioning.py
new file mode 100644
index 000000000..cf910bc4a
--- /dev/null
+++ b/tests/server/versioning/test_versioning.py
@@ -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"
diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py
deleted file mode 100644
index 7b579076d..000000000
--- a/tests/tools/test_tool.py
+++ /dev/null
@@ -1,2026 +0,0 @@
-from dataclasses import dataclass
-from datetime import timedelta
-from typing import Annotated, Any
-
-import pytest
-from dirty_equals import HasName
-from inline_snapshot import snapshot
-from mcp.types import (
- AudioContent,
- BlobResourceContents,
- EmbeddedResource,
- ImageContent,
- ResourceLink,
- TextContent,
- TextResourceContents,
- ToolExecution,
-)
-from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
-from typing_extensions import TypedDict
-
-from fastmcp.tools.tool import Tool, ToolResult, _convert_to_content
-from fastmcp.utilities.json_schema import compress_schema
-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):
- 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 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):
- 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)
-
-
-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
-
-
-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 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 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
-
-
-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"
-
-
-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"
-
-
-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.
- """
- import asyncio
- import threading
-
- 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."""
- import asyncio
- import threading
-
- from fastmcp import Context, FastMCP
-
- 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}")]
diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py
deleted file mode 100644
index 48f7db81c..000000000
--- a/tests/tools/test_tool_transform.py
+++ /dev/null
@@ -1,1751 +0,0 @@
-import re
-from dataclasses import dataclass
-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 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 import ToolResult
-from fastmcp.tools.tool_transform import (
- ArgTransform,
- ToolTransformConfig,
- 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):
- """Test that hidden argument with default value passes constant to parent."""
- new_tool = Tool.from_tool(
- add_tool, transform_args={"old_y": ArgTransform(hide=True, default=20)}
- )
- # Only old_x should be exposed
- assert sorted(new_tool.parameters["properties"]) == ["old_x"]
- # Should pass old_x=5 and old_y=20 to parent
- result = await new_tool.run(arguments={"old_x": 5})
- assert result.structured_content == {"result": 25}
-
-
-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):
- """Test custom function with both hidden constant and hidden default parameters."""
-
- async def custom_fn(visible_x: int) -> ToolResult:
- # This custom function should receive the transformed visible parameter
- # and the hidden parameters should be automatically handled
- result = await forward(visible_x=visible_x)
- return result
-
- new_tool = Tool.from_tool(
- add_tool,
- transform_fn=custom_fn,
- transform_args={
- "old_x": ArgTransform(name="visible_x"), # Rename and expose
- "old_y": ArgTransform(hide=True, default=25), # Hidden with constant
- },
- )
-
- # Only visible_x should be exposed
- assert sorted(new_tool.parameters["properties"]) == ["visible_x"]
- # Should pass visible_x=7 as old_x=7 and old_y=25 to parent
- result = await new_tool.run(arguments={"visible_x": 7})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "32"
- assert result.structured_content == {"result": 32}
-
-
-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):
- """Test that forward() applies argument mapping correctly."""
-
- async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
- return await forward(new_x=new_x, new_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"),
- },
- )
-
- result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "5"
- assert result.structured_content == {"result": 5}
-
-
-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):
- """Test that forward_raw() calls parent directly without mapping."""
-
- async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
- # Call parent directly with original argument names
- result = await forward_raw(old_x=new_x, old_y=new_y)
- return result
-
- 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"),
- },
- )
-
- result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "5"
- assert result.structured_content == {"result": 5}
-
-
-async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
- async def custom_fn(extra: int, **kwargs) -> int:
- sum = await forward(**kwargs)
- assert isinstance(sum.content[0], TextContent)
- return int(sum.content[0].text) + extra
-
- new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
- result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "6"
- assert result.structured_content == {"result": 6}
- assert new_tool.parameters["required"] == IsList(
- "extra", "old_x", check_order=False
- )
- assert list(new_tool.parameters["properties"]) == IsList(
- "extra", "old_x", "old_y", check_order=False
- )
-
-
-async def test_fn_with_kwargs_passes_through_original_args(add_tool):
- async def custom_fn(new_y: int = 5, **kwargs) -> ToolResult:
- assert kwargs == {"old_y": 3}
- result = await forward(old_x=new_y, **kwargs)
- return result
-
- new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
- result = await new_tool.run(arguments={"new_y": 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_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):
- """Test that function can explicitly handle some transformed args while others pass through kwargs."""
-
- async def custom_fn(
- new_x: int, some_other_param: str = "default", **kwargs
- ) -> ToolResult:
- # x is explicitly handled, y should come through kwargs with transformed name
- assert kwargs == {"old_y": 7}
- 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": 3, "old_y": 7, "some_other_param": "test"}
- )
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "10"
- assert result.structured_content == {"result": 10}
-
-
-async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
- """Test **kwargs behavior with mix of mapped and unmapped arguments."""
-
- async def custom_fn(new_x: int, **kwargs) -> ToolResult:
- # new_x is explicitly handled, old_y should pass through kwargs with original name (unmapped)
- assert kwargs == {"old_y": 5}
- 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")},
- ) # only map 'a'
- result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "6"
- assert result.structured_content == {"result": 6}
-
-
-async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
- """Test that dropped arguments don't appear in **kwargs."""
-
- async def custom_fn(new_x: int, **kwargs) -> ToolResult:
- # 'b' was dropped, so kwargs should be empty
- assert kwargs == {}
- # Can't use 'old_y' since it was dropped, so just use 'old_x' mapped to 'new_x'
- result = await forward(new_x=new_x)
- return result
-
- new_tool = Tool.from_tool(
- add_tool,
- transform_fn=custom_fn,
- transform_args={
- "old_x": ArgTransform(name="new_x"),
- "old_y": ArgTransform(hide=True),
- },
- ) # drop 'old_y'
- result = await new_tool.run(arguments={"new_x": 8})
- # 8 + 10 (default value of b in parent)
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "18"
-
-
-async def test_forward_outside_context_raises_error():
- """Test that forward() raises RuntimeError when called outside a transformed tool."""
- with pytest.raises(
- RuntimeError,
- match=re.escape("forward() can only be called within a transformed tool"),
- ):
- await forward(new_x=1, old_y=2)
-
-
-async def test_forward_raw_outside_context_raises_error():
- """Test that forward_raw() raises RuntimeError when called outside a transformed tool."""
- with pytest.raises(
- RuntimeError,
- match=re.escape("forward_raw() can only be called within a transformed tool"),
- ):
- await forward_raw(new_x=1, old_y=2)
-
-
-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"]
- assert "new_y" in tool.parameters["properties"]
-
-
-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"),
- (float, "number"),
- (str, "string"),
- (bool, "boolean"),
- (list, "array"),
- (list[int], "array"),
- (dict, "object"),
- (dict[str, int], "object"),
- (MyModel, "object"),
- (MyDataclass, "object"),
- (MyTypedDict, "object"),
- ],
-)
-def test_arg_transform_type_handling(add_tool, py_type, json_type):
- """Test that ArgTransform type attribute gets applied to schema."""
- new_tool = Tool.from_tool(
- add_tool, transform_args={"old_x": ArgTransform(type=py_type)}
- )
-
- # Check that the type was changed in the schema
- x_prop = get_property(new_tool, "old_x")
- assert x_prop["type"] == json_type
-
-
-def test_arg_transform_annotated_types(add_tool):
- """Test that ArgTransform works with annotated types and complex types."""
- from typing import Annotated
-
- from pydantic import Field
-
- # Test with Annotated types
- tool = Tool.from_tool(
- add_tool,
- transform_args={
- "old_x": ArgTransform(
- type=Annotated[int, Field(description="An annotated integer")]
- )
- },
- )
-
- x_prop = get_property(tool, "old_x")
- assert x_prop["type"] == "integer"
- # The ArgTransform description should override the annotation description
- # (since we didn't set a description in ArgTransform, it should use the original)
-
- # Test with Annotated string that has constraints
- tool2 = Tool.from_tool(
- add_tool,
- transform_args={
- "old_x": ArgTransform(
- type=Annotated[str, Field(min_length=1, max_length=10)]
- )
- },
- )
-
- x_prop2 = get_property(tool2, "old_x")
- assert x_prop2["type"] == "string"
- assert x_prop2["minLength"] == 1
- assert x_prop2["maxLength"] == 10
-
-
-def test_arg_transform_precedence_over_function_without_kwargs():
- """Test that ArgTransform attributes take precedence over function signature (no **kwargs)."""
-
- @Tool.from_function
- def base(x: int, y: str = "default") -> str:
- return f"{x}: {y}"
-
- # Function signature says x: int with no default, y: str = "function_default"
- # ArgTransform should override these
- def custom_fn(x: str = "transform_default", y: int = 99) -> str:
- return f"custom: {x}, {y}"
-
- tool = Tool.from_tool(
- base,
- transform_fn=custom_fn,
- transform_args={
- "x": ArgTransform(type=str, default="transform_default"),
- "y": ArgTransform(type=int, default=99),
- },
- )
-
- # ArgTransform should take precedence
- x_prop = get_property(tool, "x")
- y_prop = get_property(tool, "y")
-
- assert x_prop["type"] == "string" # ArgTransform type wins
- assert x_prop["default"] == "transform_default" # ArgTransform default wins
- assert y_prop["type"] == "integer" # ArgTransform type wins
- assert y_prop["default"] == 99 # ArgTransform default wins
-
- # Neither parameter should be required due to ArgTransform defaults
- assert "x" not in tool.parameters["required"]
- assert "y" not in tool.parameters["required"]
-
-
-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():
- """Test that multiple ArgTransform attributes work together."""
-
- @Tool.from_function
- def base(param: int) -> str:
- return str(param)
-
- tool = Tool.from_tool(
- base,
- transform_args={
- "param": ArgTransform(
- name="renamed_param",
- type=str,
- description="New description",
- default="default_value",
- )
- },
- )
-
- # Check all attributes were applied
- assert "renamed_param" in tool.parameters["properties"]
- assert "param" not in tool.parameters["properties"]
-
- prop = get_property(tool, "renamed_param")
- assert prop["type"] == "string"
- assert prop["description"] == "New description"
- assert prop["default"] == "default_value"
- assert "renamed_param" not in tool.parameters["required"] # Has default
-
-
-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
-
-
-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"
-
-
-async def test_arg_transform_default_factory():
- """Test ArgTransform with default_factory for hidden parameters."""
-
- @Tool.from_function
- def base_tool(x: int, timestamp: float) -> str:
- return f"{x}_{timestamp}"
-
- # Create a tool with default_factory for hidden timestamp
- new_tool = Tool.from_tool(
- base_tool,
- transform_args={
- "timestamp": ArgTransform(hide=True, default_factory=lambda: 12345.0)
- },
- )
-
- # Only x should be visible since timestamp is hidden
- assert sorted(new_tool.parameters["properties"]) == ["x"]
-
- # Should work without providing timestamp (gets value from factory)
- result = await new_tool.run(arguments={"x": 42})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "42_12345.0"
-
-
-async def test_arg_transform_default_factory_called_each_time():
- """Test that default_factory is called for each execution."""
- call_count = 0
-
- def counter_factory():
- nonlocal call_count
- call_count += 1
- return call_count
-
- @Tool.from_function
- def base_tool(x: int, counter: int = 0) -> str:
- return f"{x}_{counter}"
-
- new_tool = Tool.from_tool(
- base_tool,
- transform_args={
- "counter": ArgTransform(hide=True, default_factory=counter_factory)
- },
- )
-
- # Only x should be visible since counter is hidden
- assert sorted(new_tool.parameters["properties"]) == ["x"]
-
- # First call
- result1 = await new_tool.run(arguments={"x": 1})
- assert isinstance(result1.content[0], TextContent)
- assert result1.content[0].text == "1_1"
-
- # Second call should get a different value
- result2 = await new_tool.run(arguments={"x": 2})
- assert isinstance(result2.content[0], TextContent)
- assert result2.content[0].text == "2_2"
-
-
-async def test_arg_transform_hidden_with_default_factory():
- """Test hidden parameter with default_factory."""
-
- @Tool.from_function
- def base_tool(x: int, request_id: str) -> str:
- return f"{x}_{request_id}"
-
- def make_request_id():
- return "req_123"
-
- new_tool = Tool.from_tool(
- base_tool,
- transform_args={
- "request_id": ArgTransform(hide=True, default_factory=make_request_id)
- },
- )
-
- # Only x should be visible
- assert sorted(new_tool.parameters["properties"]) == ["x"]
-
- # Should pass hidden request_id with factory value
- result = await new_tool.run(arguments={"x": 42})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "42_req_123"
-
-
-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=42, default_factory=lambda: 24)
-
-
-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: 42) # hide=False by default
-
-
-async def test_arg_transform_required_true():
- """Test that required=True makes an optional parameter required."""
-
- @Tool.from_function
- def base_tool(optional_param: int = 42) -> str:
- return f"value: {optional_param}"
-
- # Make the optional parameter required
- new_tool = Tool.from_tool(
- base_tool, transform_args={"optional_param": ArgTransform(required=True)}
- )
-
- # Parameter should now be required (no default in schema)
- assert "optional_param" in new_tool.parameters["required"]
- assert "default" not in new_tool.parameters["properties"]["optional_param"]
-
- # Should work when parameter is provided
- result = await new_tool.run(arguments={"optional_param": 100})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "value: 100"
-
- # Should fail when parameter is not provided
- with pytest.raises(TypeError, match="Missing required argument"):
- await new_tool.run(arguments={})
-
-
-async def test_arg_transform_required_false():
- """Test that required=False makes a required parameter optional with default."""
-
- @Tool.from_function
- def base_tool(required_param: int) -> str:
- return f"value: {required_param}"
-
- with pytest.raises(
- ValueError,
- match="Cannot specify 'required=False'. Set a default value instead.",
- ):
- # Intentionally passing invalid argument to test error handling
- Tool.from_tool(
- base_tool,
- transform_args={"required_param": ArgTransform(required=False, default=99)}, # type: ignore[arg-type]
- )
-
-
-async def test_arg_transform_required_with_rename():
- """Test that required works correctly with argument renaming."""
-
- @Tool.from_function
- def base_tool(optional_param: int = 42) -> str:
- return f"value: {optional_param}"
-
- # Rename and make required
- new_tool = Tool.from_tool(
- base_tool,
- transform_args={
- "optional_param": ArgTransform(name="new_param", required=True)
- },
- )
-
- # New parameter name should be required
- assert "new_param" in new_tool.parameters["required"]
- assert "optional_param" not in new_tool.parameters["properties"]
- assert "new_param" in new_tool.parameters["properties"]
- assert "default" not in new_tool.parameters["properties"]["new_param"]
-
- # Should work with new name
- result = await new_tool.run(arguments={"new_param": 200})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "value: 200"
-
-
-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 required=... (NotSet) leaves requirement status unchanged."""
-
- @Tool.from_function
- def base_tool(required_param: int, optional_param: int = 42) -> str:
- return f"values: {required_param}, {optional_param}"
-
- # Transform without changing required status
- new_tool = Tool.from_tool(
- base_tool,
- transform_args={
- "required_param": ArgTransform(name="req"),
- "optional_param": ArgTransform(name="opt"),
- },
- )
-
- # Required status should be unchanged
- assert "req" in new_tool.parameters["required"]
- assert "opt" not in new_tool.parameters["required"]
- assert new_tool.parameters["properties"]["opt"]["default"] == 42
-
- # Should work as expected
- result = await new_tool.run(arguments={"req": 1})
- assert isinstance(result.content[0], TextContent)
- assert result.content[0].text == "values: 1, 42"
-
-
-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})
-
-
-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}
-
-
-@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
-
-
-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"],
- }
- )
diff --git a/tests/tools/tool/__init__.py b/tests/tools/tool/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/tools/tool/test_callable.py b/tests/tools/tool/test_callable.py
new file mode 100644
index 000000000..02228431c
--- /dev/null
+++ b/tests/tools/tool/test_callable.py
@@ -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}")]
diff --git a/tests/tools/tool/test_content.py b/tests/tools/tool/test_content.py
new file mode 100644
index 000000000..d08720f80
--- /dev/null
+++ b/tests/tools/tool/test_content.py
@@ -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
diff --git a/tests/tools/tool/test_output_schema.py b/tests/tools/tool/test_output_schema.py
new file mode 100644
index 000000000..fc9468a04
--- /dev/null
+++ b/tests/tools/tool/test_output_schema.py
@@ -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)
diff --git a/tests/tools/tool/test_results.py b/tests/tools/tool/test_results.py
new file mode 100644
index 000000000..da5973cd8
--- /dev/null
+++ b/tests/tools/tool/test_results.py
@@ -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
diff --git a/tests/tools/tool/test_title.py b/tests/tools/tool/test_title.py
new file mode 100644
index 000000000..30fa5b3f6
--- /dev/null
+++ b/tests/tools/tool/test_title.py
@@ -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"
diff --git a/tests/tools/tool/test_tool.py b/tests/tools/tool/test_tool.py
new file mode 100644
index 000000000..37499914e
--- /dev/null
+++ b/tests/tools/tool/test_tool.py
@@ -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"
diff --git a/tests/tools/tool_transform/__init__.py b/tests/tools/tool_transform/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/tools/tool_transform/test_args.py b/tests/tools/tool_transform/test_args.py
new file mode 100644
index 000000000..7d82169e4
--- /dev/null
+++ b/tests/tools/tool_transform/test_args.py
@@ -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})
diff --git a/tests/tools/tool_transform/test_metadata.py b/tests/tools/tool_transform/test_metadata.py
new file mode 100644
index 000000000..bf611020e
--- /dev/null
+++ b/tests/tools/tool_transform/test_metadata.py
@@ -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
diff --git a/tests/tools/tool_transform/test_schemas.py b/tests/tools/tool_transform/test_schemas.py
new file mode 100644
index 000000000..8b3db954a
--- /dev/null
+++ b/tests/tools/tool_transform/test_schemas.py
@@ -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"],
+ }
+ )
diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py
new file mode 100644
index 000000000..bc3247323
--- /dev/null
+++ b/tests/tools/tool_transform/test_tool_transform.py
@@ -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"
diff --git a/tests/utilities/json_schema_type/__init__.py b/tests/utilities/json_schema_type/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/utilities/test_json_schema_type.py b/tests/utilities/json_schema_type/test_advanced.py
similarity index 57%
rename from tests/utilities/test_json_schema_type.py
rename to tests/utilities/json_schema_type/test_advanced.py
index a6c3cd011..bda541648 100644
--- a/tests/utilities/test_json_schema_type.py
+++ b/tests/utilities/json_schema_type/test_advanced.py
@@ -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."""
diff --git a/tests/utilities/json_schema_type/test_constraints.py b/tests/utilities/json_schema_type/test_constraints.py
new file mode 100644
index 000000000..53a1d9a1e
--- /dev/null
+++ b/tests/utilities/json_schema_type/test_constraints.py
@@ -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)
diff --git a/tests/utilities/json_schema_type/test_containers.py b/tests/utilities/json_schema_type/test_containers.py
new file mode 100644
index 000000000..9b10b5c37
--- /dev/null
+++ b/tests/utilities/json_schema_type/test_containers.py
@@ -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
diff --git a/tests/utilities/json_schema_type/test_formats.py b/tests/utilities/json_schema_type/test_formats.py
new file mode 100644
index 000000000..781b81a82
--- /dev/null
+++ b/tests/utilities/json_schema_type/test_formats.py
@@ -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)
diff --git a/tests/utilities/json_schema_type/test_json_schema_type.py b/tests/utilities/json_schema_type/test_json_schema_type.py
new file mode 100644
index 000000000..fc2226bdc
--- /dev/null
+++ b/tests/utilities/json_schema_type/test_json_schema_type.py
@@ -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")
diff --git a/tests/utilities/json_schema_type/test_unions.py b/tests/utilities/json_schema_type/test_unions.py
new file mode 100644
index 000000000..7a8c8944e
--- /dev/null
+++ b/tests/utilities/json_schema_type/test_unions.py
@@ -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