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