From 3bc585788f329e958f9708fe086cfe17e0b3cc0d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:54:07 -0400 Subject: [PATCH 01/25] Add output schema to tools --- src/fastmcp/tools/tool.py | 72 ++++++++++--- src/fastmcp/tools/tool_transform.py | 25 ++--- src/fastmcp/utilities/types.py | 70 +++++++++---- tests/server/test_server_interactions.py | 18 +++- tests/tools/test_tool.py | 128 ++++++++++++++++++++++- tests/utilities/test_types.py | 27 +++++ 6 files changed, 289 insertions(+), 51 deletions(-) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 08518db97..ad53590e6 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -4,12 +4,13 @@ import inspect import json from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Annotated, Any +import mcp.types import pydantic_core from mcp.types import ContentBlock, TextContent, ToolAnnotations from mcp.types import Tool as MCPTool -from pydantic import Field +from pydantic import Field, PydanticSchemaGenerationError import fastmcp from fastmcp.server.dependencies import get_context @@ -20,8 +21,11 @@ from fastmcp.utilities.types import ( Audio, File, Image, + NotSet, + NotSetT, find_kwarg_by_type, get_cached_typeadapter, + replace_type, ) if TYPE_CHECKING: @@ -37,19 +41,27 @@ def default_serializer(data: Any) -> str: class Tool(FastMCPComponent): """Internal tool registration info.""" - parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") - annotations: ToolAnnotations | None = Field( - default=None, description="Additional annotations about the tool" - ) - serializer: Callable[[Any], str] | None = Field( - default=None, description="Optional custom serializer for tool results" - ) + parameters: Annotated[ + dict[str, Any], Field(description="JSON schema for tool parameters") + ] + output_schema: Annotated[ + dict[str, Any] | None, Field(description="JSON schema for tool output") + ] = None + annotations: Annotated[ + ToolAnnotations | None, + Field(description="Additional annotations about the tool"), + ] = None + serializer: Annotated[ + Callable[[Any], str] | None, + Field(description="Optional custom serializer for tool results"), + ] = None def to_mcp_tool(self, **overrides: Any) -> MCPTool: kwargs = { "name": self.name, "description": self.description, "inputSchema": self.parameters, + "outputSchema": self.output_schema, "annotations": self.annotations, } return MCPTool(**kwargs | overrides) @@ -62,6 +74,7 @@ class Tool(FastMCPComponent): tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, + output_schema: dict[str, Any] | None | NotSetT = NotSet, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None, ) -> FunctionTool: @@ -73,6 +86,7 @@ class Tool(FastMCPComponent): tags=tags, annotations=annotations, exclude_args=exclude_args, + output_schema=output_schema, serializer=serializer, enabled=enabled, ) @@ -121,6 +135,7 @@ class FunctionTool(Tool): tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, + output_schema: dict[str, Any] | None | NotSetT = NotSet, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None, ) -> FunctionTool: @@ -131,13 +146,17 @@ class FunctionTool(Tool): if name is None and parsed_fn.name == "": raise ValueError("You must provide a name for lambda functions") + if isinstance(output_schema, NotSetT): + output_schema = parsed_fn.output_schema + return cls( fn=parsed_fn.fn, name=name or parsed_fn.name, description=description or parsed_fn.description, - parameters=parsed_fn.parameters, - tags=tags or set(), + parameters=parsed_fn.input_schema, + output_schema=output_schema, annotations=annotations, + tags=tags or set(), serializer=serializer, enabled=enabled if enabled is not None else True, ) @@ -194,7 +213,8 @@ class ParsedFunction: fn: Callable[..., Any] name: str description: str | None - parameters: dict[str, Any] + input_schema: dict[str, Any] + output_schema: dict[str, Any] | None @classmethod def from_function( @@ -240,9 +260,6 @@ class ParsedFunction: if isinstance(fn, staticmethod): fn = fn.__func__ - type_adapter = get_cached_typeadapter(fn) - schema = type_adapter.json_schema() - prune_params: list[str] = [] context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context) if context_kwarg: @@ -250,12 +267,33 @@ class ParsedFunction: if exclude_args: prune_params.extend(exclude_args) - schema = compress_schema(schema, prune_params=prune_params) + input_type_adapter = get_cached_typeadapter(fn) + input_schema = input_type_adapter.json_schema() + input_schema = compress_schema(input_schema, prune_params=prune_params) + + output_schema = None + output_type = inspect.signature(fn).return_annotation + if output_type is not inspect._empty: + try: + replaced_output_type = replace_type( + output_type, + { + Image: mcp.types.ImageContent, + Audio: mcp.types.AudioContent, + File: mcp.types.EmbeddedResource, + }, + ) + output_type_adapter = get_cached_typeadapter(replaced_output_type) + output_schema = output_type_adapter.json_schema() + except PydanticSchemaGenerationError: + logger.debug(f"Unable to generate schema for type {output_type!r}") + return cls( fn=fn, name=fn_name, description=fn_doc, - parameters=schema, + input_schema=input_schema, + output_schema=output_schema, ) diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 43fa369d7..4104fcfe1 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -4,7 +4,6 @@ import inspect from collections.abc import Callable from contextvars import ContextVar from dataclasses import dataclass -from types import EllipsisType from typing import Any, Literal from mcp.types import ContentBlock, ToolAnnotations @@ -12,12 +11,10 @@ from pydantic import ConfigDict from fastmcp.tools.tool import ParsedFunction, Tool from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter +from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter logger = get_logger(__name__) -NotSet = ... - # Context variable to store current transformed tool _current_tool: ContextVar[TransformedTool | None] = ContextVar( @@ -131,14 +128,14 @@ class ArgTransform: ArgTransform(name="new_name", description="New desc", default=None, type=int) """ - name: str | EllipsisType = NotSet - description: str | EllipsisType = NotSet - default: Any | EllipsisType = NotSet - default_factory: Callable[[], Any] | EllipsisType = NotSet - type: Any | EllipsisType = NotSet + name: str | NotSetT = NotSet + description: str | NotSetT = NotSet + default: Any | NotSetT = NotSet + default_factory: Callable[[], Any] | NotSetT = NotSet + type: Any | NotSetT = NotSet hide: bool = False - required: Literal[True] | EllipsisType = NotSet - examples: Any | EllipsisType = NotSet + required: Literal[True] | NotSetT = NotSet + examples: Any | NotSetT = NotSet def __post_init__(self): """Validate that only one of default or default_factory is provided.""" @@ -334,7 +331,7 @@ class TransformedTool(Tool): has_kwargs = cls._function_has_kwargs(transform_fn) # Validate function parameters against transformed schema - fn_params = set(parsed_fn.parameters.get("properties", {}).keys()) + fn_params = set(parsed_fn.input_schema.get("properties", {}).keys()) transformed_params = set(schema.get("properties", {}).keys()) if not has_kwargs: @@ -351,7 +348,7 @@ class TransformedTool(Tool): # ArgTransform takes precedence over function signature # Start with function schema as base, then override with transformed schema final_schema = cls._merge_schema_with_precedence( - parsed_fn.parameters, schema + parsed_fn.input_schema, schema ) else: # With **kwargs, function can access all transformed params @@ -360,7 +357,7 @@ class TransformedTool(Tool): # Start with function schema as base, then override with transformed schema final_schema = cls._merge_schema_with_precedence( - parsed_fn.parameters, schema + parsed_fn.input_schema, schema ) # Additional validation: check for naming conflicts after transformation diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 8c65bd82c..919f03abd 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -6,21 +6,19 @@ import mimetypes from collections.abc import Callable from functools import lru_cache from pathlib import Path -from types import UnionType -from typing import Annotated, TypeVar, Union, get_args, get_origin +from types import EllipsisType, UnionType +from typing import Annotated, TypeAlias, TypeVar, Union, get_args, get_origin -from mcp.types import ( - Annotations, - AudioContent, - BlobResourceContents, - EmbeddedResource, - ImageContent, - TextResourceContents, -) +import mcp.types +from mcp.types import Annotations from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints T = TypeVar("T") +# sentinel values for optional arguments +NotSet = ... +NotSetT: TypeAlias = EllipsisType + class FastMCPBaseModel(BaseModel): """Base model for FastMCP models.""" @@ -129,7 +127,7 @@ class Image: self, mime_type: str | None = None, annotations: Annotations | None = None, - ) -> ImageContent: + ) -> mcp.types.ImageContent: """Convert to MCP ImageContent.""" if self.path: with open(self.path, "rb") as f: @@ -139,7 +137,7 @@ class Image: else: raise ValueError("No image data available") - return ImageContent( + return mcp.types.ImageContent( type="image", data=data, mimeType=mime_type or self._mime_type, @@ -188,7 +186,7 @@ class Audio: self, mime_type: str | None = None, annotations: Annotations | None = None, - ) -> AudioContent: + ) -> mcp.types.AudioContent: if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() @@ -197,7 +195,7 @@ class Audio: else: raise ValueError("No audio data available") - return AudioContent( + return mcp.types.AudioContent( type="audio", data=data, mimeType=mime_type or self._mime_type, @@ -248,7 +246,7 @@ class File: self, mime_type: str | None = None, annotations: Annotations | None = None, - ) -> EmbeddedResource: + ) -> mcp.types.EmbeddedResource: if self.path: with open(self.path, "rb") as f: raw_data = f.read() @@ -271,21 +269,57 @@ class File: text = raw_data.decode("utf-8") except UnicodeDecodeError: text = raw_data.decode("latin-1") - resource = TextResourceContents( + resource = mcp.types.TextResourceContents( text=text, mimeType=mime, uri=uri, ) else: data = base64.b64encode(raw_data).decode() - resource = BlobResourceContents( + resource = mcp.types.BlobResourceContents( blob=data, mimeType=mime, uri=uri, ) - return EmbeddedResource( + return mcp.types.EmbeddedResource( type="resource", resource=resource, annotations=annotations or self.annotations, ) + + +def replace_type(type_, type_map: dict[type, type]): + """ + Given a (possibly generic, nested, or otherwise complex) type, replaces all + instances of old_type with new_type. + + This is useful for transforming types when creating tools. + + Args: + type_: The type to replace instances of old_type with new_type. + old_type: The type to replace. + new_type: The type to replace old_type with. + + Examples: + >>> replace_type(list[int | bool], {int: str}) + list[str | bool] + + >>> replace_type(list[list[int]], {int: str}) + list[list[str]] + + """ + if type_ in type_map: + return type_map[type_] + + origin = get_origin(type_) + if not origin: + return type_ + + args = get_args(type_) + new_args = tuple(replace_type(arg, type_map) for arg in args) + + if origin is UnionType: + return Union[new_args] # type: ignore # noqa: UP007 + else: + return origin[new_args] diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 6198a9f70..78e25dd55 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -17,7 +17,7 @@ from mcp.types import ( TextContent, TextResourceContents, ) -from pydantic import AnyUrl, Field +from pydantic import AnyUrl, Field, TypeAdapter from fastmcp import Client, Context, FastMCP from fastmcp.client.transports import FastMCPTransport @@ -826,6 +826,22 @@ class TestToolParameters: assert result[0].text == "0:16:40" # type: ignore[attr-defined] +class TestToolOutputSchema: + @pytest.mark.parametrize("annotation", [str, int, float, bool, list, dict, AnyUrl]) + async def test_output_schema(self, annotation): + mcp = FastMCP() + + @mcp.tool + def f() -> annotation: # type: ignore + return "hello" + + async with Client(mcp) as client: + tools = await client.list_tools() + assert len(tools) == 1 + # this line will fail until MCP adds output schemas!! + assert tools[0].outputSchema == TypeAdapter(annotation).json_schema() # type: ignore + + class TestToolContextInjection: """Test context injection in tools.""" diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 89a72a482..b1ec8b45c 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -1,4 +1,6 @@ import json +from dataclasses import dataclass +from typing import Annotated, Any, TypedDict import pytest from mcp.types import ( @@ -8,7 +10,7 @@ from mcp.types import ( TextContent, TextResourceContents, ) -from pydantic import AnyUrl, BaseModel +from pydantic import AnyUrl, BaseModel, Field, TypeAdapter from fastmcp import FastMCP from fastmcp.client import Client @@ -33,6 +35,7 @@ class TestToolFromFunction: assert len(tool.parameters["properties"]) == 2 assert tool.parameters["properties"]["a"]["type"] == "integer" assert tool.parameters["properties"]["b"]["type"] == "integer" + assert tool.output_schema == {"type": "integer"} async def test_async_function(self): """Test registering and running an async function.""" @@ -244,6 +247,129 @@ class TestToolFromFunction: assert result[0].text == "Custom serializer: 15" +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", + [ + None, + int, + float, + bool, + str, + int | float, + list[int], + list[int | float], + dict[str, int | None], + tuple[int, str], + set[int], + list[tuple[int, str]], + ], + ) + async def test_simple_return_annotation(self, annotation): + def func() -> annotation: # type: ignore + return 1 + + tool = Tool.from_function(func) + assert tool.output_schema == TypeAdapter(annotation).json_schema() + + @pytest.mark.parametrize( + "annotation", + [ + Any, + AnyUrl, + Annotated[int, Field(ge=1)], + Annotated[int, Field(ge=1)], + ], + ) + async def test_complex_return_annotation(self, annotation): + def func() -> annotation: # type: ignore + return 1 + + tool = Tool.from_function(func) + assert tool.output_schema == TypeAdapter(annotation).json_schema() + + @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: # type: ignore + return 1 + + tool = Tool.from_function(func) + assert tool.output_schema == TypeAdapter(expected).json_schema() + + 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) + assert tool.output_schema == TypeAdapter(Person).json_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 == TypeAdapter(Person).json_schema() + + 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 == TypeAdapter(Person).json_schema() + + 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 + + class TestLegacyToolJsonParsing: """Tests for Tool's JSON pre-parsing functionality.""" diff --git a/tests/utilities/test_types.py b/tests/utilities/test_types.py index 1f8338318..4ac9109c0 100644 --- a/tests/utilities/test_types.py +++ b/tests/utilities/test_types.py @@ -12,6 +12,7 @@ from fastmcp.utilities.types import ( find_kwarg_by_type, is_class_member_of_type, issubclass_safe, + replace_type, ) @@ -536,3 +537,29 @@ class TestFindKwargByType: pass assert find_kwarg_by_type(func, str) == "c" + + +class TestReplaceType: + @pytest.mark.parametrize( + "input,type_map,expected", + [ + (int, {}, int), + (int, {int: str}, str), + (int, {int: int}, int), + (int, {int: float, bool: str}, float), + (bool, {int: float, bool: str}, str), + (int, {int: list[int]}, list[int]), + (list[int], {int: str}, list[str]), + (list[int], {int: list[str]}, list[list[str]]), + ( + list[int], + {int: float, list[int]: bool}, + bool, + ), # list[int] will match before int + (list[int | bool], {int: str}, list[str | bool]), + (list[list[int]], {int: str}, list[list[str]]), + ], + ) + def test_replace_type(self, input, type_map, expected): + """Test replacing a type with another type.""" + assert replace_type(input, type_map) == expected From 4b77c3a4056f2e9d82446989f1e4c925f1ebef4e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 20 Jun 2025 18:23:47 -0400 Subject: [PATCH 02/25] Add docs --- docs/servers/tools.mdx | 69 ++++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 300a6c7dd..63bb447c4 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -251,6 +251,8 @@ Use `async def` when your tool needs to perform operations that might wait for e ### Return Values +#### Output Conversion + FastMCP automatically converts the value returned by your function into the appropriate MCP content format for the client: - **`str`**: Sent as `TextContent`. @@ -264,43 +266,52 @@ FastMCP automatically converts the value returned by your function into the appr FastMCP will attempt to serialize other types to a string if possible. - -At this time, FastMCP responds only to your tool's return *value*, not its return *annotation*. - +#### Output Schemas -```python + + +FastMCP will automatically generate MCP [output schemas](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) for your tools based on their return type annotations. This helps MCP clients understand what type of data to expect from your tool, enabling better validation and type safety. + +When you add a return type annotation to your tool function, FastMCP will generate a JSON schema describing the expected output format and include it in the tool definition sent to MCP clients. + + +```python Tool Definition +from dataclasses import dataclass from fastmcp import FastMCP -from fastmcp.utilities.types import Image -import io -try: - from PIL import Image as PILImage -except ImportError: - raise ImportError("Please install the `pillow` library to run this example.") +mcp = FastMCP() -mcp = FastMCP("Image Demo") +@dataclass +class Person: + name: str + age: int + email: str @mcp.tool -def generate_image(width: int, height: int, color: str) -> Image: - """Generates a solid color image.""" - # Create image using Pillow - img = PILImage.new("RGB", (width, height), color=color) - - # Save to a bytes buffer - buffer = io.BytesIO() - img.save(buffer, format="PNG") - img_bytes = buffer.getvalue() - - # Return using FastMCP's Image helper - return Image(data=img_bytes, format="png") - -@mcp.tool -def do_nothing() -> None: - """This tool performs an action but returns no data.""" - print("Performing a side effect...") - return None +def get_user_profile(user_id: str) -> Person: + """Get a user's profile information.""" + return Person(name="Alice", age=30, email="alice@example.com") ``` +```json Generated Output Schema +{ + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + "email": {"title": "Email", "type": "string"} + }, + "required": ["name", "age", "email"], + "title": "Person", + "type": "object" +} + ``` + +The output schema is automatically generated for most common types including basic types, collections, union types, Pydantic models, TypedDict structures, and dataclasses. For FastMCP's special types (`Image`, `Audio`, `File`), the output schema reflects their MCP equivalents rather than the FastMCP wrapper types. + + +If your return type annotation cannot be converted to a JSON schema (e.g., complex custom classes without Pydantic support), the output schema will be omitted from the tool definition. The tool will still function normally, but clients won't receive type information about the expected output. + + ### Error Handling From cc69e56758411ddedfeb338eac5810d2a6be60ef Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 20 Jun 2025 18:58:23 -0400 Subject: [PATCH 03/25] Add output schema to tool decorator --- src/fastmcp/server/server.py | 6 ++ tests/server/test_server.py | 9 +++ tests/tools/test_tool.py | 105 +++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 6f52a7c8a..6f14af60c 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -768,6 +768,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, + output_schema: dict[str, Any] | None = None, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -781,6 +782,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, + output_schema: dict[str, Any] | None = None, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -793,6 +795,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, + output_schema: dict[str, Any] | None = None, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -815,6 +818,7 @@ class FastMCP(Generic[LifespanResultT]): name: Optional name for the tool (keyword-only, alternative to name_or_fn) description: Optional description of what the tool does tags: Optional set of tags for categorizing the tool + output_schema: Optional JSON schema for the tool's output annotations: Optional annotations about the tool's behavior (e.g. {"is_async": True}) exclude_args: Optional list of argument names to exclude from the tool schema enabled: Optional boolean to enable or disable the tool @@ -867,6 +871,7 @@ class FastMCP(Generic[LifespanResultT]): name=tool_name, description=description, tags=tags, + output_schema=output_schema, annotations=annotations, exclude_args=exclude_args, serializer=self._tool_serializer, @@ -897,6 +902,7 @@ class FastMCP(Generic[LifespanResultT]): name=tool_name, description=description, tags=tags, + output_schema=output_schema, annotations=annotations, exclude_args=exclude_args, enabled=enabled, diff --git a/tests/server/test_server.py b/tests/server/test_server.py index d255ad76c..022f910c0 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -390,6 +390,15 @@ class TestToolDecorator: def my_function(x: int) -> str: return f"Result: {x}" + async def test_tool_decorator_with_output_schema(self): + mcp = FastMCP() + + @mcp.tool(output_schema={"type": "integer"}) + def my_function(x: int) -> str: + return f"Result: {x}" + + assert my_function.output_schema == {"type": "integer"} + class TestResourceDecorator: async def test_no_resources_before_decorator(self): diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index b1ec8b45c..d60ec0a7d 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -369,6 +369,111 @@ class TestToolFromFunctionOutputSchema: 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": "string", "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": "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": "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": "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": "array", "items": {"type": "number"}} + + tool = Tool.from_function(func, output_schema=custom_schema) + assert tool.output_schema == custom_schema + class TestLegacyToolJsonParsing: """Tests for Tool's JSON pre-parsing functionality.""" From 7f0622b7076adea207b1b8d86f9a8601dff3a865 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:17:02 -0400 Subject: [PATCH 04/25] Fix contrib tests --- tests/contrib/test_bulk_tool_caller.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py index 58aebb762..38dc16872 100644 --- a/tests/contrib/test_bulk_tool_caller.py +++ b/tests/contrib/test_bulk_tool_caller.py @@ -45,8 +45,13 @@ async def echo_tool(arg1: str) -> str: def echo_tool_result_factory(arg1: str) -> CallToolRequestResult: """A tool that returns a result based on the input arguments.""" return CallToolRequestResult( - isError=False, - content=[TextContent(text=f"{arg1}", type="text")], + isError=True, + content=[ + TextContent( + text="Output validation error: outputSchema defined but no structured output returned", + type="text", + ) + ], tool="echo_tool", arguments={"arg1": arg1}, ) @@ -59,7 +64,15 @@ async def no_return_tool(arg1: str) -> None: def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult: """A tool that returns a result based on the input arguments.""" return CallToolRequestResult( - isError=False, content=[], tool="no_return_tool", arguments={"arg1": arg1} + isError=True, + content=[ + TextContent( + text="Output validation error: outputSchema defined but no structured output returned", + type="text", + ) + ], + tool="no_return_tool", + arguments={"arg1": arg1}, ) From 21d4ce092c6b01723c1432b56051e16f1be2ff9d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Jun 2025 21:15:25 -0400 Subject: [PATCH 05/25] Add structured output support - Tools can return StructuredOutput() for explicit structured data - Tools with output_schema automatically return structured data - Client hydrates structured responses into typed objects - Maintains backward compatibility with unstructured-only tools --- src/fastmcp/client/client.py | 25 +- src/fastmcp/server/server.py | 7 +- src/fastmcp/tools/tool.py | 34 +- src/fastmcp/utilities/json_schema_type.py | 526 ++++++++++++++++++++++ src/fastmcp/utilities/types.py | 12 +- 5 files changed, 592 insertions(+), 12 deletions(-) create mode 100644 src/fastmcp/utilities/json_schema_type.py diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index dbf088606..89599906d 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -30,6 +30,7 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback from fastmcp.exceptions import ToolError from fastmcp.server import FastMCP from fastmcp.utilities.exceptions import get_catch_handlers +from fastmcp.utilities.json_schema_type import json_schema_to_type from fastmcp.utilities.mcp_config import MCPConfig from .transports import ( @@ -675,7 +676,7 @@ class Client(Generic[ClientTransportT]): arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, - ) -> list[ContentBlock]: + ) -> list[ContentBlock] | dict[str, Any] | type: """Call a tool on the server. Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error. @@ -687,8 +688,12 @@ class Client(Generic[ClientTransportT]): progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. Returns: - list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.AudioContent | mcp.types.EmbeddedResource]: - The content returned by the tool. + list[ContentBlock] | dict[str, Any]: + The content returned by the tool. If the tool returns structured + outputs, they are returned as a dictionary; otherwise, a list of + content blocks is returned. Note: to receive both structured and + unstructured outputs, use call_tool_mcp instead and access the + raw result object. Raises: ToolError: If the tool call results in an error. @@ -703,4 +708,16 @@ class Client(Generic[ClientTransportT]): if result.isError: msg = cast(mcp.types.TextContent, result.content[0]).text raise ToolError(msg) - return result.content + elif result.structuredContent: + if name not in self.session._tool_output_schemas: + # refresh output schema cache + await self.session.list_tools() + if name in self.session._tool_output_schemas: + output_schema = self.session._tool_output_schemas.get(name) + if output_schema: + output_type = json_schema_to_type(output_schema) + return output_type(**result.structuredContent) + + return result.structuredContent + else: + return result.content diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index ac3076b71..b33ec1f4c 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -63,6 +63,7 @@ from fastmcp.utilities.cache import TimedCache from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_config import MCPConfig +from fastmcp.utilities.types import NotSet, NotSetT if TYPE_CHECKING: from fastmcp.client import Client @@ -792,7 +793,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, - output_schema: dict[str, Any] | None = None, + output_schema: dict[str, Any] | NotSetT = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -806,7 +807,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, - output_schema: dict[str, Any] | None = None, + output_schema: dict[str, Any] | NotSetT = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -819,7 +820,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, - output_schema: dict[str, Any] | None = None, + output_schema: dict[str, Any] | NotSetT = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 6579c3672..8510714e7 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -21,6 +21,7 @@ from fastmcp.utilities.types import ( Image, NotSet, NotSetT, + StructuredOutput, find_kwarg_by_type, get_cached_typeadapter, replace_type, @@ -105,8 +106,18 @@ class Tool(FastMCPComponent): enabled=enabled, ) - async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]: - """Run the tool with arguments.""" + async def run( + self, arguments: dict[str, Any] + ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]: + """ + Run the tool with arguments. + + This method is not implemented in the base Tool class and must be + implemented by subclasses. + + `run()` can EITHER return a list of ContentBlocks, or a tuple of + (list of ContentBlocks, dict of structured output). + """ raise NotImplementedError("Subclasses must implement run()") @classmethod @@ -175,7 +186,9 @@ class FunctionTool(Tool): enabled=enabled if enabled is not None else True, ) - async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]: + async def run( + self, arguments: dict[str, Any] + ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]: """Run the tool with arguments.""" from fastmcp.server.context import Context @@ -190,7 +203,20 @@ class FunctionTool(Tool): if inspect.isawaitable(result): result = await result - return _convert_to_content(result, serializer=self.serializer) + unstructured_result = _convert_to_content(result, serializer=self.serializer) + + structured_result = None + if isinstance(result, StructuredOutput): + structured_result = result.to_structured_output() + elif self.output_schema is not None: + structured_result = pydantic_core.to_jsonable_python(result, fallback=str) + + # return only the unstructured result if there is no structured output + if structured_result is None: + return unstructured_result + + # return both the unstructured and structured results if there is structured output + return (unstructured_result, structured_result) @dataclass diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py new file mode 100644 index 000000000..c76a3f2cf --- /dev/null +++ b/src/fastmcp/utilities/json_schema_type.py @@ -0,0 +1,526 @@ +"""Convert JSON Schema to Python types with validation. + +The json_schema_to_type function converts a JSON Schema into a Python type that can be used +for validation with Pydantic. It supports: + +- Basic types (string, number, integer, boolean, null) +- Complex types (arrays, objects) +- Format constraints (date-time, email, uri) +- Numeric constraints (minimum, maximum, multipleOf) +- String constraints (minLength, maxLength, pattern) +- Array constraints (minItems, maxItems, uniqueItems) +- Object properties with defaults +- References and recursive schemas +- Enums and constants +- Union types + +Example: + ```python + schema = { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1}, + "age": {"type": "integer", "minimum": 0}, + "email": {"type": "string", "format": "email"} + }, + "required": ["name", "age"] + } + + # Name is optional and will be inferred from schema's "title" property if not provided + Person = json_schema_to_type(schema) + # Creates a validated dataclass with name, age, and optional email fields + ``` +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Callable, Mapping +from copy import deepcopy +from dataclasses import MISSING, field, make_dataclass +from datetime import datetime +from enum import Enum +from typing import ( + Annotated, + Any, + ForwardRef, + Literal, + Optional, + Union, +) + +from pydantic import ( + AnyUrl, + EmailStr, + Field, + Json, + StringConstraints, + model_validator, +) +from typing_extensions import NotRequired, TypedDict + +__all__ = ["json_schema_to_type", "JSONSchema"] + + +FORMAT_TYPES: dict[str, Any] = { + "date-time": datetime, + "email": EmailStr, + "uri": AnyUrl, + "json": Json, +} + +_classes: dict[tuple[str, Any], type | None] = {} + + +class JSONSchema(TypedDict): + type: NotRequired[str | list[str]] + properties: NotRequired[dict[str, JSONSchema]] + required: NotRequired[list[str]] + additionalProperties: NotRequired[bool | JSONSchema] + items: NotRequired[JSONSchema | list[JSONSchema]] + enum: NotRequired[list[Any]] + const: NotRequired[Any] + default: NotRequired[Any] + description: NotRequired[str] + title: NotRequired[str] + examples: NotRequired[list[Any]] + format: NotRequired[str] + allOf: NotRequired[list[JSONSchema]] + anyOf: NotRequired[list[JSONSchema]] + oneOf: NotRequired[list[JSONSchema]] + not_: NotRequired[JSONSchema] + definitions: NotRequired[dict[str, JSONSchema]] + dependencies: NotRequired[dict[str, JSONSchema | list[str]]] + pattern: NotRequired[str] + minLength: NotRequired[int] + maxLength: NotRequired[int] + minimum: NotRequired[int | float] + maximum: NotRequired[int | float] + exclusiveMinimum: NotRequired[int | float] + exclusiveMaximum: NotRequired[int | float] + multipleOf: NotRequired[int | float] + uniqueItems: NotRequired[bool] + minItems: NotRequired[int] + maxItems: NotRequired[int] + additionalItems: NotRequired[bool | JSONSchema] + + +def json_schema_to_type( + schema: Mapping[str, Any], + name: str | None = None, +) -> type: + """Convert JSON schema to appropriate Python type with validation. + + Args: + schema: A JSON Schema dictionary defining the type structure and validation rules + name: Optional name for object schemas. Only allowed when schema type is "object". + If not provided for objects, name will be inferred from schema's "title" + property or default to "Root". + + Returns: + A Python type (typically a dataclass for objects) with Pydantic validation + + Raises: + ValueError: If a name is provided for a non-object schema + + Examples: + Create a dataclass from an object schema: + ```python + schema = { + "type": "object", + "title": "Person", + "properties": { + "name": {"type": "string", "minLength": 1}, + "age": {"type": "integer", "minimum": 0}, + "email": {"type": "string", "format": "email"} + }, + "required": ["name", "age"] + } + + Person = json_schema_to_type(schema) + # Creates a dataclass with name, age, and optional email fields: + # @dataclass + # class Person: + # name: str + # age: int + # email: str | None = None + ``` + Person(name="John", age=30) + + Create a scalar type with constraints: + ```python + schema = { + "type": "string", + "minLength": 3, + "pattern": "^[A-Z][a-z]+$" + } + + NameType = json_schema_to_type(schema) + # Creates Annotated[str, StringConstraints(min_length=3, pattern="^[A-Z][a-z]+$")] + + @dataclass + class Name: + name: NameType + ``` + """ + # Always use the top-level schema for references + if schema.get("type") == "object": + return _create_dataclass(schema, name, schemas=schema) + elif name: + raise ValueError(f"Can not apply name to non-object schema: {name}") + return _schema_to_type(schema, schemas=schema) + + +def _hash_schema(schema: Mapping[str, Any]) -> str: + """Generate a deterministic hash for schema caching.""" + return hashlib.sha256(json.dumps(schema, sort_keys=True).encode()).hexdigest() + + +def _resolve_ref(ref: str, schemas: Mapping[str, Any]) -> Mapping[str, Any]: + """Resolve JSON Schema reference to target schema.""" + path = ref.replace("#/", "").split("/") + current = schemas + for part in path: + current = current.get(part, {}) + return current + + +def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]: + """Create string type with optional constraints.""" + if "const" in schema: + return Literal[schema["const"]] # type: ignore + + if fmt := schema.get("format"): + if fmt == "uri": + return AnyUrl + elif fmt == "uri-reference": + return str + return FORMAT_TYPES.get(fmt, str) + + constraints = { + k: v + for k, v in { + "min_length": schema.get("minLength"), + "max_length": schema.get("maxLength"), + "pattern": schema.get("pattern"), + }.items() + if v is not None + } + + return Annotated[str, StringConstraints(**constraints)] if constraints else str + + +def _create_numeric_type( + base: type[int | float], schema: Mapping[str, Any] +) -> type | Annotated[Any, ...]: + """Create numeric type with optional constraints.""" + if "const" in schema: + return Literal[schema["const"]] # type: ignore + + constraints = { + k: v + for k, v in { + "gt": schema.get("exclusiveMinimum"), + "ge": schema.get("minimum"), + "lt": schema.get("exclusiveMaximum"), + "le": schema.get("maximum"), + "multiple_of": schema.get("multipleOf"), + }.items() + if v is not None + } + + return Annotated[base, Field(**constraints)] if constraints else base + + +def _create_enum(name: str, values: list[Any]) -> type | Enum: + """Create enum type from list of values.""" + if all(isinstance(v, str) for v in values): + return Enum(name, {v.upper(): v for v in values}) + return Literal[tuple(values)] # type: ignore + + +def _create_array_type( + schema: Mapping[str, Any], schemas: Mapping[str, Any] +) -> type | Annotated[Any, ...]: + """Create list/set type with optional constraints.""" + items = schema.get("items", {}) + if isinstance(items, list): + # Handle positional item schemas + item_types = [_schema_to_type(s, schemas) for s in items] + combined = Union[tuple(item_types)] # type: ignore # noqa: UP007 + base = list[combined] + else: + # Handle single item schema + item_type = _schema_to_type(items, schemas) + base = set if schema.get("uniqueItems") else list + base = base[item_type] + + constraints = { + k: v + for k, v in { + "min_length": schema.get("minItems"), + "max_length": schema.get("maxItems"), + }.items() + if v is not None + } + + return Annotated[base, Field(**constraints)] if constraints else base + + +def _return_Any() -> Any: + return Any + + +def _get_from_type_handler( + schema: Mapping[str, Any], schemas: Mapping[str, Any] +) -> Callable[..., Any]: + """Get the appropriate type handler for the schema.""" + + type_handlers: dict[str, Callable[..., Any]] = { # TODO + "string": lambda s: _create_string_type(s), # type: ignore + "integer": lambda s: _create_numeric_type(int, s), # type: ignore + "number": lambda s: _create_numeric_type(float, s), # type: ignore + "boolean": lambda _: bool, # type: ignore + "null": lambda _: type(None), # type: ignore + "array": lambda s: _create_array_type(s, schemas), # type: ignore + "object": lambda s: _create_dataclass(s, s.get("title"), schemas), # type: ignore + } + return type_handlers.get(schema.get("type", None), _return_Any) + + +def _schema_to_type( + schema: Mapping[str, Any], + schemas: Mapping[str, Any], +) -> type: + """Convert schema to appropriate Python type.""" + if not schema: + return object + + if "type" not in schema and "properties" in schema: + return _create_dataclass(schema, schema.get("title", ""), schemas) + + # Handle references first + if "$ref" in schema: + ref = schema["$ref"] + # Handle self-reference + if ref == "#": + return ForwardRef(schema.get("title", "Root")) + return _schema_to_type(_resolve_ref(ref, schemas), schemas) + + if "const" in schema: + return Literal[schema["const"]] # type: ignore + + if "enum" in schema: + return _create_enum(f"Enum_{len(_classes)}", schema["enum"]) + + schema_type = schema.get("type") + if not schema_type: + return Any + + if isinstance(schema_type, list): + # Create a copy of the schema for each type, but keep all constraints + types: list[type | Any] = [] + for t in schema_type: + type_schema = schema.copy() + type_schema["type"] = t + types.append(_schema_to_type(type_schema, schemas)) + has_null = type(None) in types + types = [t for t in types if t is not type(None)] + if has_null: + return Optional[tuple(types) if len(types) > 1 else types[0]] # type: ignore # noqa: UP007 + return Union[tuple(types)] # type: ignore # noqa: UP007 + + return _get_from_type_handler(schema, schemas)(schema) + + +def _sanitize_name(name: str) -> str: + """Convert string to valid Python identifier.""" + # Step 1: replace everything except [0-9a-zA-Z_] with underscores + cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name) + # Step 2: deduplicate underscores + cleaned = re.sub(r"__+", "_", cleaned) + # Step 3: if the first char of original name isn't a letter, prepend field_ + if not name or not re.match(r"[a-zA-Z]", name[0]): + cleaned = f"field_{cleaned}" + # Step 4: deduplicate again and strip trailing underscores + cleaned = re.sub(r"__+", "_", cleaned).strip("_") + return cleaned + + +def _get_default_value( + schema: dict[str, Any], + prop_name: str, + parent_default: dict[str, Any] | None = None, +) -> Any: + """Get default value with proper priority ordering. + 1. Value from parent's default if it exists + 2. Property's own default if it exists + 3. None + """ + if parent_default is not None and prop_name in parent_default: + return parent_default[prop_name] + return schema.get("default") + + +def _create_field_with_default( + field_type: type, + default_value: Any, + schema: dict[str, Any], +) -> Any: + """Create a field with simplified default handling.""" + # Always use None as default for complex types + if isinstance(default_value, dict | list) or default_value is None: + return field(default=None) + + # For simple types, use the value directly + return field(default=default_value) + + +def _create_dataclass( + schema: Mapping[str, Any], + name: str | None = None, + schemas: Mapping[str, Any] | None = None, +) -> type: + """Create dataclass from object schema.""" + name = name or schema.get("title", "Root") + # Sanitize name for class creation + sanitized_name = _sanitize_name(name) + schema_hash = _hash_schema(schema) + cache_key = (schema_hash, sanitized_name) + original_schema = dict(schema) # Store copy for validator + + # Return existing class if already built + if cache_key in _classes: + existing = _classes[cache_key] + if existing is None: + return ForwardRef(sanitized_name) + return existing + + # Place placeholder for recursive references + _classes[cache_key] = None + + if "$ref" in schema: + ref = schema["$ref"] + if ref == "#": + return ForwardRef(sanitized_name) + schema = _resolve_ref(ref, schemas or {}) + + properties = schema.get("properties", {}) + required = schema.get("required", []) + + fields: list[tuple[Any, ...]] = [] + for prop_name, prop_schema in properties.items(): + field_name = _sanitize_name(prop_name) + + # Check for self-reference in property + if prop_schema.get("$ref") == "#": + field_type = ForwardRef(sanitized_name) + else: + field_type = _schema_to_type(prop_schema, schemas) + + default_val = prop_schema.get("default", MISSING) + is_required = prop_name in required + + # Include alias in field metadata + meta = {"alias": prop_name} + + if default_val is not MISSING: + if isinstance(default_val, dict | list): + field_def = field( + default_factory=lambda d=default_val: deepcopy(d), metadata=meta + ) + else: + field_def = field(default=default_val, metadata=meta) + else: + if is_required: + field_def = field(metadata=meta) + else: + field_def = field(default=None, metadata=meta) + + if is_required and default_val is not MISSING: + fields.append((field_name, field_type, field_def)) + elif is_required: + fields.append((field_name, field_type, field_def)) + else: + fields.append((field_name, Optional[field_type], field_def)) + + cls = make_dataclass(sanitized_name, fields, kw_only=True) + + # Add model validator for defaults + @model_validator(mode="before") + @classmethod + def _apply_defaults(cls, data: Mapping[str, Any]): + if isinstance(data, dict): + return _merge_defaults(data, original_schema) + return data + + setattr(cls, "_apply_defaults", _apply_defaults) + + # Store completed class + _classes[cache_key] = cls + return cls + + +def _merge_defaults( + data: Mapping[str, Any], + schema: Mapping[str, Any], + parent_default: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Merge defaults with provided data at all levels.""" + # If we have no data + if not data: + # Start with parent default if available + if parent_default: + result = dict(parent_default) + # Otherwise use schema default if available + elif "default" in schema: + result = dict(schema["default"]) + # Otherwise start empty + else: + result = {} + # If we have data and a parent default, merge them + elif parent_default: + result = dict(parent_default) + for key, value in data.items(): + if ( + isinstance(value, dict) + and key in result + and isinstance(result[key], dict) + ): + # recursively merge nested dicts + result[key] = _merge_defaults(value, {"properties": {}}, result[key]) + else: + result[key] = value + # Otherwise just use the data + else: + result = dict(data) + + # For each property in the schema + for prop_name, prop_schema in schema.get("properties", {}).items(): + # If property is missing, apply defaults in priority order + if prop_name not in result: + if parent_default and prop_name in parent_default: + result[prop_name] = parent_default[prop_name] + elif "default" in prop_schema: + result[prop_name] = prop_schema["default"] + + # If property exists and is an object, recursively merge + if ( + prop_name in result + and isinstance(result[prop_name], dict) + and prop_schema.get("type") == "object" + ): + # Get the appropriate default for this nested object + nested_default = None + if parent_default and prop_name in parent_default: + nested_default = parent_default[prop_name] + elif "default" in prop_schema: + nested_default = prop_schema["default"] + + result[prop_name] = _merge_defaults( + result[prop_name], prop_schema, nested_default + ) + + return result diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 919f03abd..e8a4740f4 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -7,7 +7,7 @@ from collections.abc import Callable from functools import lru_cache from pathlib import Path from types import EllipsisType, UnionType -from typing import Annotated, TypeAlias, TypeVar, Union, get_args, get_origin +from typing import Annotated, Any, TypeAlias, TypeVar, Union, get_args, get_origin import mcp.types from mcp.types import Annotations @@ -289,6 +289,16 @@ class File: ) +class StructuredOutput: + """Helper class for returning structured output from tools.""" + + def __init__(self, data: dict[str, Any]): + self.data = data + + def to_structured_output(self) -> dict[str, Any]: + return self.data + + def replace_type(type_, type_map: dict[type, type]): """ Given a (possibly generic, nested, or otherwise complex) type, replaces all From 2b313bc3ff1984cd2cfa7159f21a6af2b633d4eb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:02:04 -0400 Subject: [PATCH 06/25] Update tool transformation for ToolResult compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TransformedTool.run() now returns ToolResult instead of list[ContentBlock] - forward() and forward_raw() return ToolResult from parent tools - Transform functions can return ToolResult for full control or any value for auto-wrapping - Maintains backward compatibility with existing transform functions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/fastmcp/client/client.py | 9 ++-- src/fastmcp/server/proxy.py | 9 ++-- src/fastmcp/server/server.py | 13 +++-- src/fastmcp/tools/tool.py | 78 +++++++++++++++++++---------- src/fastmcp/tools/tool_manager.py | 8 ++- src/fastmcp/tools/tool_transform.py | 23 ++++++--- 6 files changed, 86 insertions(+), 54 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 89599906d..4512f3d5e 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -676,7 +676,7 @@ class Client(Generic[ClientTransportT]): arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, - ) -> list[ContentBlock] | dict[str, Any] | type: + ) -> list[ContentBlock] | dict[str, Any] | Any: """Call a tool on the server. Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error. @@ -688,10 +688,11 @@ class Client(Generic[ClientTransportT]): progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. Returns: - list[ContentBlock] | dict[str, Any]: + list[ContentBlock] | dict[str, Any] | Any: The content returned by the tool. If the tool returns structured - outputs, they are returned as a dictionary; otherwise, a list of - content blocks is returned. Note: to receive both structured and + outputs, they are returned as a dataclass (if an output schema + is available) or a dictionary; otherwise, a list of content + blocks is returned. Note: to receive both structured and unstructured outputs, use call_tool_mcp instead and access the raw result object. diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index f80c9c38e..d196c2b7c 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -23,7 +23,7 @@ from fastmcp.resources import Resource, ResourceTemplate from fastmcp.resources.resource_manager import ResourceManager from fastmcp.server.context import Context from fastmcp.server.server import FastMCP -from fastmcp.tools.tool import Tool +from fastmcp.tools.tool import Tool, ToolResult from fastmcp.tools.tool_manager import ToolManager from fastmcp.utilities.logging import get_logger @@ -232,7 +232,7 @@ class ProxyTool(Tool): self, arguments: dict[str, Any], context: Context | None = None, - ) -> list[ContentBlock]: + ) -> ToolResult: """Executes the tool by making a call through the client.""" # This is where the remote execution logic lives. async with self._client: @@ -242,7 +242,10 @@ class ProxyTool(Tool): ) if result.isError: raise ToolError(cast(mcp.types.TextContent, result.content[0]).text) - return result.content + return ToolResult( + content=result.content, + structured_output=result.structuredContent, + ) class ProxyResource(Resource): diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index b33ec1f4c..bfbd50678 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -58,7 +58,7 @@ from fastmcp.server.low_level import LowLevelServer from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.settings import Settings from fastmcp.tools import ToolManager -from fastmcp.tools.tool import FunctionTool, Tool +from fastmcp.tools.tool import FunctionTool, Tool, ToolResult from fastmcp.utilities.cache import TimedCache from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger @@ -593,7 +593,7 @@ class FastMCP(Generic[LifespanResultT]): async def _mcp_call_tool( self, key: str, arguments: dict[str, Any] - ) -> list[ContentBlock]: + ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]: """ Handle MCP 'callTool' requests. @@ -610,22 +610,21 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): try: - return await self._call_tool(key, arguments) + result = await self._call_tool(key, arguments) + return result.to_mcp_result() except DisabledError: raise NotFoundError(f"Unknown tool: {key}") except NotFoundError: raise NotFoundError(f"Unknown tool: {key}") - async def _call_tool( - self, key: str, arguments: dict[str, Any] - ) -> list[ContentBlock]: + async def _call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult: """ Applies this server's middleware and delegates the filtered call to the manager. """ async def _handler( context: MiddlewareContext[mcp.types.CallToolRequestParams], - ) -> list[ContentBlock]: + ) -> ToolResult: tool = await self._tool_manager.get_tool(context.message.name) if not self._should_enable_component(tool): raise NotFoundError(f"Unknown tool: {context.message.name!r}") diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 8510714e7..560b4df00 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -5,7 +5,6 @@ from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Annotated, Any -import mcp.types import pydantic_core from mcp.types import ContentBlock, TextContent, ToolAnnotations from mcp.types import Tool as MCPTool @@ -24,7 +23,6 @@ from fastmcp.utilities.types import ( StructuredOutput, find_kwarg_by_type, get_cached_typeadapter, - replace_type, ) if TYPE_CHECKING: @@ -37,6 +35,19 @@ def default_serializer(data: Any) -> str: return pydantic_core.to_json(data, fallback=str, indent=2).decode() +@dataclass +class ToolResult: + content: list[ContentBlock] + structured_output: dict[str, Any] | None = None + + def to_mcp_result( + self, + ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]: + if self.structured_output is None: + return self.content + return self.content, self.structured_output + + class Tool(FastMCPComponent): """Internal tool registration info.""" @@ -106,9 +117,7 @@ class Tool(FastMCPComponent): enabled=enabled, ) - async def run( - self, arguments: dict[str, Any] - ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]: + async def run(self, arguments: dict[str, Any]) -> ToolResult: """ Run the tool with arguments. @@ -150,6 +159,18 @@ class Tool(FastMCPComponent): class FunctionTool(Tool): fn: Callable[..., Any] + wrap_primitive_output: bool = Field( + default=False, + description="""Whether to wrap the function's return value in a {"value": result} object. + + This is automatically set to True when a function has a primitive return type + annotation (int, str, bool, etc.) and FastMCP auto-generates an object schema + with a single "value" property to enable structured output support. + + When True, the function's raw return value gets wrapped as {"value": raw_result} + in the structured output, allowing clients to receive properly typed objects + even for primitive return types.""", + ) @classmethod def from_function( @@ -171,8 +192,18 @@ class FunctionTool(Tool): if name is None and parsed_fn.name == "": raise ValueError("You must provide a name for lambda functions") + wrap_primitive_output = False if isinstance(output_schema, NotSetT): output_schema = parsed_fn.output_schema + # convert primitive types to object with a single "value" property + if output_schema and output_schema.get("type") != "object": + wrap_primitive_output = True + output_schema = { + "type": "object", + "properties": {"value": output_schema | {"title": "Value"}}, + "required": ["value"], + "title": "Result", + } return cls( fn=parsed_fn.fn, @@ -184,11 +215,10 @@ class FunctionTool(Tool): tags=tags or set(), serializer=serializer, enabled=enabled if enabled is not None else True, + wrap_primitive_output=wrap_primitive_output, ) - async def run( - self, arguments: dict[str, Any] - ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]: + async def run(self, arguments: dict[str, Any]) -> ToolResult: """Run the tool with arguments.""" from fastmcp.server.context import Context @@ -205,18 +235,20 @@ class FunctionTool(Tool): unstructured_result = _convert_to_content(result, serializer=self.serializer) - structured_result = None + structured_output = None if isinstance(result, StructuredOutput): - structured_result = result.to_structured_output() + structured_output = result.to_structured_output() elif self.output_schema is not None: - structured_result = pydantic_core.to_jsonable_python(result, fallback=str) + raw_result = pydantic_core.to_jsonable_python(result, fallback=str) + if self.wrap_primitive_output: + structured_output = {"value": raw_result} + else: + structured_output = raw_result - # return only the unstructured result if there is no structured output - if structured_result is None: - return unstructured_result - - # return both the unstructured and structured results if there is structured output - return (unstructured_result, structured_result) + return ToolResult( + content=unstructured_result, + structured_output=structured_output, + ) @dataclass @@ -284,17 +316,9 @@ class ParsedFunction: output_schema = None output_type = inspect.signature(fn).return_annotation - if output_type is not inspect._empty: + if output_type not in (inspect._empty, Image, Audio, File, StructuredOutput): try: - replaced_output_type = replace_type( - output_type, - { - Image: mcp.types.ImageContent, - Audio: mcp.types.AudioContent, - File: mcp.types.EmbeddedResource, - }, - ) - output_type_adapter = get_cached_typeadapter(replaced_output_type) + output_type_adapter = get_cached_typeadapter(output_type) output_schema = output_type_adapter.json_schema() except PydanticSchemaGenerationError: logger.debug(f"Unable to generate schema for type {output_type!r}") diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index ee577a450..29bb956c3 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -4,12 +4,12 @@ import warnings from collections.abc import Callable from typing import TYPE_CHECKING, Any -from mcp.types import ContentBlock, ToolAnnotations +from mcp.types import ToolAnnotations from fastmcp import settings from fastmcp.exceptions import NotFoundError, ToolError from fastmcp.settings import DuplicateBehavior -from fastmcp.tools.tool import Tool +from fastmcp.tools.tool import Tool, ToolResult from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: @@ -169,9 +169,7 @@ class ToolManager: else: raise NotFoundError(f"Tool {key!r} not found") - async def call_tool( - self, key: str, arguments: dict[str, Any] - ) -> list[ContentBlock]: + async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult: """ Internal API for servers: Finds and calls a tool, respecting the filtered protocol path. diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 75fa2baaa..38d074430 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -6,10 +6,10 @@ from contextvars import ContextVar from dataclasses import dataclass from typing import Any, Literal -from mcp.types import ContentBlock, ToolAnnotations +from mcp.types import ToolAnnotations from pydantic import ConfigDict -from fastmcp.tools.tool import ParsedFunction, Tool +from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter @@ -22,7 +22,7 @@ _current_tool: ContextVar[TransformedTool | None] = ContextVar( ) -async def forward(**kwargs) -> Any: +async def forward(**kwargs) -> ToolResult: """Forward to parent tool with argument transformation applied. This function can only be called from within a transformed tool's custom @@ -38,7 +38,7 @@ async def forward(**kwargs) -> Any: **kwargs: Arguments to forward to the parent tool (using transformed names). Returns: - The result from the parent tool execution. + The ToolResult from the parent tool execution. Raises: RuntimeError: If called outside a transformed tool context. @@ -219,7 +219,7 @@ class TransformedTool(Tool): forwarding_fn: Callable[..., Any] # Always present, handles arg transformation transform_args: dict[str, ArgTransform] - async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]: + async def run(self, arguments: dict[str, Any]) -> ToolResult: """Run the tool with context set for forward() functions. This method executes the tool's function while setting up the context @@ -230,8 +230,7 @@ class TransformedTool(Tool): arguments: Dictionary of arguments to pass to the tool's function. Returns: - List of content objects (text, image, or embedded resources) representing - the tool's output. + ToolResult object containing content and optional structured output. """ from fastmcp.tools.tool import _convert_to_content @@ -269,7 +268,15 @@ class TransformedTool(Tool): token = _current_tool.set(self) try: result = await self.fn(**arguments) - return _convert_to_content(result, serializer=self.serializer) + + # If transform function returns ToolResult, use it directly + if isinstance(result, ToolResult): + return result + + # Otherwise convert to content and create basic ToolResult + from fastmcp.tools.tool import _convert_to_content + unstructured_result = _convert_to_content(result, serializer=self.serializer) + return ToolResult(content=unstructured_result) finally: _current_tool.reset(token) From 6b89695949a15af16e68bffb8f6b081a08641856 Mon Sep 17 00:00:00 2001 From: Goro Date: Fri, 27 Jun 2025 19:27:15 +0200 Subject: [PATCH 07/25] Refactor component manager into contrib module --- .../component_manager/component_manager.py | 115 +++++++++ .../component_manager/component_service.py | 221 ++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 src/fastmcp/contrib/component_manager/component_manager.py create mode 100644 src/fastmcp/contrib/component_manager/component_service.py diff --git a/src/fastmcp/contrib/component_manager/component_manager.py b/src/fastmcp/contrib/component_manager/component_manager.py new file mode 100644 index 000000000..e2fbcdca1 --- /dev/null +++ b/src/fastmcp/contrib/component_manager/component_manager.py @@ -0,0 +1,115 @@ +from starlette.applications import Starlette +from starlette.exceptions import HTTPException as StarletteHTTPException + +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Mount, Route + +from fastmcp.contrib.component_manager.component_service import ComponentService +from fastmcp.exceptions import NotFoundError + +from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware +from typing import TYPE_CHECKING, Any + +from fastmcp.server.server import FastMCP + +def set_up_component_manager( + server: FastMCP, root_path: str = "/", required_scopes: list[str] | None = None +): + """Set up routes for enabling/disabling tools, resources, and prompts. + Args: + server: The FastMCP server instance + required_scopes: Optional list of scopes required for these routes + Returns: + A list of routes or mounts for component management + """ + + service = ComponentService(server) + routes: list[Route] | list[Mount] = [] + route_configs = { + "tool": { + "param": "tool_name", + "enable": service._enable_tool, + "disable": service._disable_tool, + }, + "resource": { + "param": "uri:path", + "enable": service._enable_resource, + "disable": service._disable_resource, + }, + "prompt": { + "param": "prompt_name", + "enable": service._enable_prompt, + "disable": service._disable_prompt, + }, + } + + if required_scopes is None: + routes.extend( + build_component_manager_enpoints(route_configs, root_path) + ) + else: + if root_path != "/": + routes.append( + build_component_manager_enpoints( + route_configs, root_path, required_scopes + )) + else: + routes.append( + build_component_manager_enpoints( + {"tool": route_configs["tool"]}, "/tools", required_scopes + )) + routes.append( + build_component_manager_enpoints( + {"resource": route_configs["resource"]}, "/resources", required_scopes + )) + routes.append( + build_component_manager_enpoints( + {"prompt": route_configs["prompt"]}, "/prompts", required_scopes + )) + + server._additional_http_routes.extend(routes) + + +def build_component_manager_enpoints(route_configs, root_path, required_scopes=None) -> list[Route] | Mount: + component_management_routes: list[Route] = [] + + for component in route_configs: + config: dict[str, Any] = route_configs[component] + for action in ["enable", "disable"]: + + async def endpoint( + request: Request, + action: str = action, + component: str = component, + config: dict[str, Any] = config, + ): + name = request.path_params[config["param"].split(":")[0]] + + try: + await config[action](name) + return JSONResponse( + {"message": f"{action.capitalize()}d {component}: {name}"} + ) + except NotFoundError: + raise StarletteHTTPException( + status_code=404, + detail=f"Unknown {component}: {name}", + ) + + if required_scopes is not None and root_path in ["/tools", "/resources", "/prompts"]: + path = f"/{{{config['param']}}}/{action}" + else: + path = f"/{component}s/{{{config['param']}}}/{action}" + + route = Route(path, endpoint=endpoint, methods=["POST"]) + component_management_routes.append(route) + + if required_scopes is None: + return component_management_routes + else: + return Mount( + f"{root_path}", + app=RequireAuthMiddleware(Starlette(routes=component_management_routes), + required_scopes) + ) \ No newline at end of file diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py new file mode 100644 index 000000000..aa5358e1b --- /dev/null +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -0,0 +1,221 @@ +from fastmcp.exceptions import NotFoundError +from fastmcp.prompts.prompt import Prompt +from fastmcp.resources.resource import Resource +from fastmcp.resources.template import ResourceTemplate +from fastmcp.tools.tool import Tool + +from fastmcp.utilities.logging import get_logger +from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix + +logger = get_logger(__name__) + +class ComponentService: + """Service for managing components like tools, resources, and prompts.""" + def __init__(self, server: FastMCP): + self._server = server + self._tool_manager = server._tool_manager + self._resource_manager = server._resource_manager + self._prompt_manager = server._prompt_manager + + async def _enable_tool(self, key: str) -> Tool: + """Handle 'enableTool' requests. + + Args: + key: The key of the tool to enable + + Returns: + The tool that was enabled + """ + logger.debug("Enabling tool: %s", key) + + # 1. Check local tools first. The server will have already applied its filter. + if key in self._server._tool_manager._tools: + tool: Tool = await self._server.get_tool(key) + tool.enable() + return tool + + # 2. Check mounted servers using the filtered protocol path. + for mounted in reversed(self._tool_manager._mounted_servers): + if mounted.prefix: + if key.startswith(f"{mounted.prefix}_"): + tool_key = key.removeprefix(f"{mounted.prefix}_") + mounted_service = ComponentService(mounted.server) + tool = await mounted_service._enable_tool(tool_key) + tool.disable() + return tool + else: + continue + raise NotFoundError(f"Unknown tool: {key}") + + async def _disable_tool(self, key: str) -> Tool: + """Handle 'disableTool' requests. + + Args: + key: The key of the tool to disable + + Returns: + The tool that was disabled + """ + logger.debug("Disable tool: %s", key) + + # 1. Check local tools first. The server will have already applied its filter. + if key in self._server._tool_manager._tools: + tool: Tool = await self._server.get_tool(key) + tool.disable() + return tool + + # 2. Check mounted servers using the filtered protocol path. + for mounted in reversed(self._tool_manager._mounted_servers): + if mounted.prefix: + if key.startswith(f"{mounted.prefix}_"): + tool_key = key.removeprefix(f"{mounted.prefix}_") + mounted_service = ComponentService(mounted.server) + tool = await mounted_service._disable_tool(tool_key) + tool.disable() + return tool + else: + continue + raise NotFoundError(f"Unknown tool: {key}") + + async def _enable_resource(self, key: str) -> Resource | ResourceTemplate: + """Handle 'enableResource' requests. + + Args: + key: The key of the resource to enable + + Returns: + The resource that was enabled + """ + logger.debug("Enabling resource: %s", key) + + # 1. Check local resources first. The server will have already applied its filter. + if key in self._resource_manager._resources: + resource: Resource = await self._server.get_resource(key) + resource.enable() + return resource + if key in self._resource_manager._templates: + template: ResourceTemplate = await self._server.get_resource_template(key) + template.enable() + return template + + # 2. Check mounted servers using the filtered protocol path. + for mounted in reversed(self._resource_manager._mounted_servers): + if mounted.prefix: + if has_resource_prefix( + key, + mounted.prefix, + mounted.resource_prefix_format, + ): + key = remove_resource_prefix( + key, + mounted.prefix, + mounted.resource_prefix_format, + ) + mounted_service = ComponentService(mounted.server) + resource = await mounted_service._enable_resource(key) + resource.enable() + return resource + else: + continue + raise NotFoundError(f"Unknown resource: {key}") + + async def _disable_resource(self, key: str) -> Resource | ResourceTemplate: + """Handle 'disableResource' requests. + + Args: + key: The key of the resource to disable + + Returns: + The resource that was disabled + """ + logger.debug("Disable resource: %s", key) + + # 1. Check local resources first. The server will have already applied its filter. + if key in self._resource_manager._resources: + resource: Resource = await self._server.get_resource(key) + resource.disable() + return resource + if key in self._resource_manager._templates: + template: ResourceTemplate = await self._server.get_resource_template(key) + template.disable() + return template + + # 2. Check mounted servers using the filtered protocol path. + for mounted in reversed(self._resource_manager._mounted_servers): + if mounted.prefix: + if has_resource_prefix( + key, + mounted.prefix, + mounted.resource_prefix_format, + ): + key = remove_resource_prefix( + key, + mounted.prefix, + mounted.resource_prefix_format, + ) + mounted_service = ComponentService(mounted.server) + resource = await mounted_service._disable_resource(key) + resource.disable() + return resource + else: + continue + raise NotFoundError(f"Unknown resource: {key}") + + async def _enable_prompt(self, key: str) -> Prompt: + """Handle 'enablePrompt' requests. + + Args: + key: The key of the prompt to enable + + Returns: + The prompt that was enable + """ + logger.debug("Enabling prompt: %s", key) + + # 1. Check local prompts first. The server will have already applied its filter. + if key in self._server._prompt_manager._prompts: + prompt: Prompt = await self._server.get_prompt(key) + prompt.enable() + return prompt + + # 2. Check mounted servers using the filtered protocol path. + for mounted in reversed(self._prompt_manager._mounted_servers): + if mounted.prefix: + if key.startswith(f"{mounted.prefix}_"): + prompt_key = key.removeprefix(f"{mounted.prefix}_") + mounted_service = ComponentService(mounted.server) + prompt = await mounted_service._enable_prompt(prompt_key) + prompt.enable() + return prompt + else: + continue + raise NotFoundError(f"Unknown prompt: {key}") + + async def _disable_prompt(self, key: str) -> Prompt: + """Handle 'disablePrompt' requests. + + Args: + key: The key of the prompt to disable + + Returns: + The prompt that was disabled + """ + + # 1. Check local prompts first. The server will have already applied its filter. + if key in self._server._prompt_manager._prompts: + prompt: Prompt = await self._server.get_prompt(key) + prompt.disable() + return prompt + + # 2. Check mounted servers using the filtered protocol path. + for mounted in reversed(self._prompt_manager._mounted_servers): + if mounted.prefix: + if key.startswith(f"{mounted.prefix}_"): + prompt_key = key.removeprefix(f"{mounted.prefix}_") + mounted_service = ComponentService(mounted.server) + prompt = await mounted_service._disable_prompt(prompt_key) + prompt.disable() + return prompt + else: + continue + raise NotFoundError(f"Unknown prompt: {key}") From 02a905e8b95871367cdb2a6e903f2c5b3798867d Mon Sep 17 00:00:00 2001 From: Goro Date: Fri, 27 Jun 2025 20:04:25 +0200 Subject: [PATCH 08/25] Add component manager tests --- tests/contrib/test_component_manager.py | 537 ++++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 tests/contrib/test_component_manager.py diff --git a/tests/contrib/test_component_manager.py b/tests/contrib/test_component_manager.py new file mode 100644 index 000000000..1c36d42f9 --- /dev/null +++ b/tests/contrib/test_component_manager.py @@ -0,0 +1,537 @@ +import pytest +from starlette import status +from starlette.testclient import TestClient + +from fastmcp import FastMCP +from fastmcp.contrib.component_manager.component_manager import set_up_component_manager +from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair + + +class TestComponentManagementRoutes: + """Test the component management routes for tools, resources, and prompts.""" + + @pytest.fixture + def mounted_mcp(self): + """Create a FastMCP server with a mounted sub-server and a tool, resource, and prompt on the sub-server.""" + mounted_mcp = FastMCP("SubServer") + + @mounted_mcp.tool() + def mounted_tool() -> str: + """Test tool for tool management routes.""" + return "mounted_tool_result" + + @mounted_mcp.resource("data://mounted_resource") + def mounted_resource() -> str: + """Test resource for tool management routes.""" + return "mounted_resource_result" + + # Add a test resource + @mounted_mcp.resource("data://mounted_resource/{id}") + def test_template(id: str) -> dict: + """Test template for tool management routes.""" + return {"id": id, "value": "data"} + + @mounted_mcp.prompt() + def mounted_prompt() -> str: + """Test prompt for tool management routes.""" + return "mounted_prompt_result" + + return mounted_mcp + + @pytest.fixture + def mcp(self, mounted_mcp): + """Create a FastMCP server with test tools, resources, and prompts.""" + mcp = FastMCP("TestServer") + mcp.mount(mounted_mcp, prefix="sub") + set_up_component_manager(server=mcp) + # Add a test tool + @mcp.tool + def test_tool() -> str: + """Test tool for tool management routes.""" + return "test_tool_result" + + # Add a test resource + @mcp.resource("data://test_resource") + def test_resource() -> str: + """Test resource for tool management routes.""" + return "test_resource_result" + + # Add a test resource + @mcp.resource("data://test_resource/{id}") + def test_template(id: str) -> dict: + """Test template for tool management routes.""" + return {"id": id, "value": "data"} + + # Add a test prompt + @mcp.prompt + def test_prompt() -> str: + """Test prompt for tool management routes.""" + return "test_prompt_result" + + return mcp + + @pytest.fixture + def client(self, mcp): + """Create a test client for the FastMCP server.""" + return TestClient(mcp.http_app()) + + async def test_enable_tool_route(self, client, mcp): + """Test enabling a tool via the HTTP route.""" + # First disable the tool + tool = await mcp._tool_manager.get_tool("test_tool") + tool.enabled = False + + # Enable the tool via the HTTP route + response = client.post("/tools/test_tool/enable") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Enabled tool: test_tool"} + + # Verify the tool is enabled + tool = await mcp._tool_manager.get_tool("test_tool") + assert tool.enabled is True + + async def test_disable_tool_route(self, client, mcp): + """Test disabling a tool via the HTTP route.""" + # First ensure the tool is enabled + tool = await mcp._tool_manager.get_tool("test_tool") + tool.enabled = True + + # Disable the tool via the HTTP route + response = client.post("/tools/test_tool/disable") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Disabled tool: test_tool"} + + # Verify the tool is disabled + tool = await mcp._tool_manager.get_tool("test_tool") + assert tool.enabled is False + + async def test_enable_resource_route(self, client, mcp): + """Test enabling a resource via the HTTP route.""" + # First disable the resource + resource = await mcp._resource_manager.get_resource("data://test_resource") + resource.enabled = False + + # Enable the resource via the HTTP route + response = client.post("/resources/data://test_resource/enable") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Enabled resource: data://test_resource"} + + # Verify the resource is enabled + resource = await mcp._resource_manager.get_resource("data://test_resource") + assert resource.enabled is True + + async def test_disable_resource_route(self, client, mcp): + """Test disabling a resource via the HTTP route.""" + # First ensure the resource is enabled + resource = await mcp._resource_manager.get_resource("data://test_resource") + resource.enabled = True + + # Disable the resource via the HTTP route + response = client.post("/resources/data://test_resource/disable") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Disabled resource: data://test_resource"} + + # Verify the resource is disabled + resource = await mcp._resource_manager.get_resource("data://test_resource") + assert resource.enabled is False + + async def test_enable_template_route(self, client, mcp): + """Test enabling a resource on a mounted server via the parent server's HTTP route.""" + key = "data://test_resource/{id}" + resource = mcp._resource_manager._templates[key] + resource.enabled = False + response = client.post("/resources/data://test_resource/{id}/enable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "message": "Enabled resource: data://test_resource/{id}" + } + assert resource.enabled is True + + async def test_disable_template_route(self, client, mcp): + """Test disabling a resource on a mounted server via the parent server's HTTP route.""" + key = "data://test_resource/{id}" + resource = mcp._resource_manager._templates[key] + resource.enabled = True + response = client.post("/resources/data://test_resource/{id}/disable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "message": "Disabled resource: data://test_resource/{id}" + } + assert resource.enabled is False + + async def test_enable_prompt_route(self, client, mcp): + """Test enabling a prompt via the HTTP route.""" + # First disable the prompt + prompt = await mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = False + + # Enable the prompt via the HTTP route + response = client.post("/prompts/test_prompt/enable") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Enabled prompt: test_prompt"} + + # Verify the prompt is enabled + prompt = await mcp._prompt_manager.get_prompt("test_prompt") + assert prompt.enabled is True + + async def test_disable_prompt_route(self, client, mcp): + """Test disabling a prompt via the HTTP route.""" + # First ensure the prompt is enabled + prompt = await mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = True + + # Disable the prompt via the HTTP route + response = client.post("/prompts/test_prompt/disable") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Disabled prompt: test_prompt"} + + # Verify the prompt is disabled + prompt = await mcp._prompt_manager.get_prompt("test_prompt") + assert prompt.enabled is False + + async def test_enable_tool_route_on_mounted_server(self, client, mounted_mcp): + """Test enabling a tool on a mounted server via the parent server's HTTP route.""" + # Disable the tool on the sub-server + sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool") + sub_tool.enabled = False + # Enable via parent + response = client.post("/tools/sub_mounted_tool/enable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Enabled tool: sub_mounted_tool"} + # Confirm disabled on sub-server + assert sub_tool.enabled is True + + async def test_disable_tool_route_on_mounted_server(self, client, mounted_mcp): + """Test disabling a tool on a mounted server via the parent server's HTTP route.""" + # Enable the tool on the sub-server + sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool") + sub_tool.enabled = True + # Disable via parent + response = client.post("/tools/sub_mounted_tool/disable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Disabled tool: sub_mounted_tool"} + # Confirm disabled on sub-server + assert sub_tool.enabled is False + + async def test_enable_resource_route_on_mounted_server(self, client, mounted_mcp): + """Test enabling a resource on a mounted server via the parent server's HTTP route.""" + resource = await mounted_mcp._resource_manager.get_resource( + "data://mounted_resource" + ) + resource.enabled = False + response = client.post("/resources/data://sub/mounted_resource/enable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "message": "Enabled resource: data://sub/mounted_resource" + } + resource = await mounted_mcp._resource_manager.get_resource( + "data://mounted_resource" + ) + assert resource.enabled is True + + async def test_disable_resource_route_on_mounted_server(self, client, mounted_mcp): + """Test disabling a resource on a mounted server via the parent server's HTTP route.""" + resource = await mounted_mcp._resource_manager.get_resource( + "data://mounted_resource" + ) + resource.enabled = True + response = client.post("/resources/data://sub/mounted_resource/disable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "message": "Disabled resource: data://sub/mounted_resource" + } + resource = await mounted_mcp._resource_manager.get_resource( + "data://mounted_resource" + ) + assert resource.enabled is False + + async def test_enable_template_route_on_mounted_server(self, client, mounted_mcp): + """Test enabling a resource on a mounted server via the parent server's HTTP route.""" + key = "data://mounted_resource/{id}" + resource = mounted_mcp._resource_manager._templates[key] + resource.enabled = False + response = client.post("/resources/data://sub/mounted_resource/{id}/enable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "message": "Enabled resource: data://sub/mounted_resource/{id}" + } + assert resource.enabled is True + + async def test_disable_template_route_on_mounted_server(self, client, mounted_mcp): + """Test disabling a resource on a mounted server via the parent server's HTTP route.""" + key = "data://mounted_resource/{id}" + resource = mounted_mcp._resource_manager._templates[key] + resource.enabled = True + response = client.post("/resources/data://sub/mounted_resource/{id}/disable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "message": "Disabled resource: data://sub/mounted_resource/{id}" + } + assert resource.enabled is False + + async def test_enable_prompt_route_on_mounted_server(self, client, mounted_mcp): + """Test enabling a prompt on a mounted server via the parent server's HTTP route.""" + prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt") + prompt.enabled = False + response = client.post("/prompts/sub_mounted_prompt/enable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Enabled prompt: sub_mounted_prompt"} + prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt") + assert prompt.enabled is True + + async def test_disable_prompt_route_on_mounted_server(self, client, mounted_mcp): + """Test disabling a prompt on a mounted server via the parent server's HTTP route.""" + prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt") + prompt.enabled = True + response = client.post("/prompts/sub_mounted_prompt/disable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Disabled prompt: sub_mounted_prompt"} + prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt") + assert prompt.enabled is False + + def test_enable_nonexistent_tool(self, client): + """Test enabling a non-existent tool returns 404.""" + response = client.post("/tools/nonexistent_tool/enable") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.text == "Unknown tool: nonexistent_tool" + + def test_disable_nonexistent_tool(self, client): + """Test disabling a non-existent tool returns 404.""" + response = client.post("/tools/nonexistent_tool/disable") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.text == "Unknown tool: nonexistent_tool" + + def test_enable_nonexistent_resource(self, client): + """Test enabling a non-existent resource returns 404.""" + response = client.post("/resources/nonexistent://resource/enable") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.text == "Unknown resource: nonexistent://resource" + + def test_disable_nonexistent_resource(self, client): + """Test disabling a non-existent resource returns 404.""" + response = client.post("/resources/nonexistent://resource/disable") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.text == "Unknown resource: nonexistent://resource" + + def test_enable_nonexistent_prompt(self, client): + """Test enabling a non-existent prompt returns 404.""" + response = client.post("/prompts/nonexistent_prompt/enable") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.text == "Unknown prompt: nonexistent_prompt" + + def test_disable_nonexistent_prompt(self, client): + """Test disabling a non-existent prompt returns 404.""" + response = client.post("/prompts/nonexistent_prompt/disable") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.text == "Unknown prompt: nonexistent_prompt" + + +class TestAuthComponentManagementRoutes: + """Test the component management routes with authentication for tools, resources, and prompts.""" + + def setup_method(self): + """Set up test fixtures.""" + # Generate a key pair and create an auth provider + key_pair = RSAKeyPair.generate() + self.auth = BearerAuthProvider( + public_key=key_pair.public_key, + issuer="https://dev.example.com", + audience="my-dev-server", + ) + self.mcp = FastMCP("TestServerWithAuth", auth=self.auth) + set_up_component_manager(server=self.mcp, required_scopes=["tool:write", "tool:read"]) + self.token = key_pair.create_token( + subject="dev-user", + issuer="https://dev.example.com", + audience="my-dev-server", + scopes=["tool:write", "tool:read"], + ) + self.token_without_scopes = key_pair.create_token( + subject="dev-user", + issuer="https://dev.example.com", + audience="my-dev-server", + scopes=["tool:read"], + ) + + # Add test components + @self.mcp.tool + def test_tool() -> str: + """Test tool for auth testing.""" + return "test_tool_result" + + @self.mcp.resource("data://test_resource") + def test_resource() -> str: + """Test resource for auth testing.""" + return "test_resource_result" + + @self.mcp.prompt + def test_prompt() -> str: + """Test prompt for auth testing.""" + return "test_prompt_result" + + # Create test client + self.client = TestClient(self.mcp.http_app()) + + async def test_unauthorized_enable_tool(self): + """Test that unauthenticated requests to enable a tool are rejected.""" + tool = await self.mcp._tool_manager.get_tool("test_tool") + tool.enabled = False + + response = self.client.post("/tools/test_tool/enable") + assert response.status_code == 401 + assert tool.enabled is False + + async def test_authorized_enable_tool(self): + """Test that authenticated requests to enable a tool are allowed.""" + tool = await self.mcp._tool_manager.get_tool("test_tool") + tool.enabled = False + + response = self.client.post( + "/tools/test_tool/enable", headers={"Authorization": "Bearer " + self.token} + ) + assert response.status_code == 200 + assert response.json() == {"message": "Enabled tool: test_tool"} + assert tool.enabled is True + + async def test_unauthorized_disable_tool(self): + """Test that unauthenticated requests to disable a tool are rejected.""" + tool = await self.mcp._tool_manager.get_tool("test_tool") + tool.enabled = True + + response = self.client.post("/tools/test_tool/disable") + assert response.status_code == 401 + assert tool.enabled is True + + async def test_authorized_disable_tool(self): + """Test that authenticated requests to disable a tool are allowed.""" + tool = await self.mcp._tool_manager.get_tool("test_tool") + tool.enabled = True + + response = self.client.post( + "/tools/test_tool/disable", + headers={"Authorization": "Bearer " + self.token}, + ) + assert response.status_code == 200 + assert response.json() == {"message": "Disabled tool: test_tool"} + assert tool.enabled is False + + async def test_forbidden_enable_tool(self): + """Test that unauthenticated requests to enable a resource are rejected.""" + tool = await self.mcp._tool_manager.get_tool("test_tool") + tool.enabled = False + + response = self.client.post( + "/tools/test_tool/enable", + headers={"Authorization": "Bearer " + self.token_without_scopes}, + ) + assert response.status_code == 403 + assert tool.enabled is False + + async def test_authorized_enable_resource(self): + """Test that authenticated requests to enable a resource are allowed.""" + resource = await self.mcp._resource_manager.get_resource("data://test_resource") + resource.enabled = False + + response = self.client.post( + "/resources/data://test_resource/enable", + headers={"Authorization": "Bearer " + self.token}, + ) + assert response.status_code == 200 + assert response.json() == {"message": "Enabled resource: data://test_resource"} + assert resource.enabled is True + + async def test_unauthorized_disable_resource(self): + """Test that unauthenticated requests to disable a resource are rejected.""" + resource = await self.mcp._resource_manager.get_resource("data://test_resource") + resource.enabled = True + + response = self.client.post("/resources/data://test_resource/disable") + assert response.status_code == 401 + assert resource.enabled is True + + async def test_forbidden_enable_resource(self): + """Test that unauthenticated requests to enable a resource are rejected.""" + resource = await self.mcp._resource_manager.get_resource("data://test_resource") + resource.enabled = False + + response = self.client.post( + "/resources/data://test_resource/disable", + headers={"Authorization": "Bearer " + self.token_without_scopes}, + ) + assert response.status_code == 403 + assert resource.enabled is False + + async def test_authorized_disable_resource(self): + """Test that authenticated requests to disable a resource are allowed.""" + resource = await self.mcp._resource_manager.get_resource("data://test_resource") + resource.enabled = True + + response = self.client.post( + "/resources/data://test_resource/disable", + headers={"Authorization": "Bearer " + self.token}, + ) + assert response.status_code == 200 + assert response.json() == {"message": "Disabled resource: data://test_resource"} + assert resource.enabled is False + + async def test_unauthorized_enable_prompt(self): + """Test that unauthenticated requests to enable a prompt are rejected.""" + prompt = await self.mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = False + + response = self.client.post("/prompts/test_prompt/enable") + assert response.status_code == 401 + assert prompt.enabled is False + + async def test_authorized_enable_prompt(self): + """Test that authenticated requests to enable a prompt are allowed.""" + prompt = await self.mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = False + + response = self.client.post( + "/prompts/test_prompt/enable", + headers={"Authorization": "Bearer " + self.token}, + ) + assert response.status_code == 200 + assert response.json() == {"message": "Enabled prompt: test_prompt"} + assert prompt.enabled is True + + async def test_unauthorized_disable_prompt(self): + """Test that unauthenticated requests to disable a prompt are rejected.""" + prompt = await self.mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = True + + response = self.client.post("/prompts/test_prompt/disable") + assert response.status_code == 401 + assert prompt.enabled is True + + async def test_forbidden_disable_prompt(self): + """Test that unauthenticated requests to enable a resource are rejected.""" + prompt = await self.mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = True + + response = self.client.post( + "/prompts/test_prompt/disable", + headers={"Authorization": "Bearer " + self.token_without_scopes}, + ) + assert response.status_code == 403 + assert prompt.enabled is True + + async def test_authorized_disable_prompt(self): + """Test that authenticated requests to disable a prompt are allowed.""" + prompt = await self.mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = True + + response = self.client.post( + "/prompts/test_prompt/disable", + headers={"Authorization": "Bearer " + self.token}, + ) + assert response.status_code == 200 + assert response.json() == {"message": "Disabled prompt: test_prompt"} + assert prompt.enabled is False \ No newline at end of file From fc26023f5d7a2aabe45d37beb6b1fe2167fc7fb7 Mon Sep 17 00:00:00 2001 From: Goro Date: Fri, 27 Jun 2025 20:12:52 +0200 Subject: [PATCH 09/25] Fix typing warnings --- .../component_manager/component_manager.py | 73 ++++++++++++++----- .../component_manager/component_service.py | 14 ++-- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/fastmcp/contrib/component_manager/component_manager.py b/src/fastmcp/contrib/component_manager/component_manager.py index e2fbcdca1..659b9a5a5 100644 --- a/src/fastmcp/contrib/component_manager/component_manager.py +++ b/src/fastmcp/contrib/component_manager/component_manager.py @@ -9,7 +9,7 @@ from fastmcp.contrib.component_manager.component_service import ComponentService from fastmcp.exceptions import NotFoundError from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware -from typing import TYPE_CHECKING, Any +from typing import Any from fastmcp.server.server import FastMCP @@ -25,7 +25,8 @@ def set_up_component_manager( """ service = ComponentService(server) - routes: list[Route] | list[Mount] = [] + routes: list[Route] = [] + mounts: list[Mount] = [] route_configs = { "tool": { "param": "tool_name", @@ -50,28 +51,29 @@ def set_up_component_manager( ) else: if root_path != "/": - routes.append( - build_component_manager_enpoints( + mounts.append( + build_component_manager_mount( route_configs, root_path, required_scopes )) else: - routes.append( - build_component_manager_enpoints( + mounts.append( + build_component_manager_mount( {"tool": route_configs["tool"]}, "/tools", required_scopes )) - routes.append( - build_component_manager_enpoints( + mounts.append( + build_component_manager_mount( {"resource": route_configs["resource"]}, "/resources", required_scopes )) - routes.append( - build_component_manager_enpoints( + mounts.append( + build_component_manager_mount( {"prompt": route_configs["prompt"]}, "/prompts", required_scopes )) server._additional_http_routes.extend(routes) + server._additional_http_routes.extend(mounts) -def build_component_manager_enpoints(route_configs, root_path, required_scopes=None) -> list[Route] | Mount: +def build_component_manager_enpoints(route_configs, root_path, required_scopes=None) -> list[Route]: component_management_routes: list[Route] = [] for component in route_configs: @@ -105,11 +107,44 @@ def build_component_manager_enpoints(route_configs, root_path, required_scopes=N route = Route(path, endpoint=endpoint, methods=["POST"]) component_management_routes.append(route) - if required_scopes is None: - return component_management_routes - else: - return Mount( - f"{root_path}", - app=RequireAuthMiddleware(Starlette(routes=component_management_routes), - required_scopes) - ) \ No newline at end of file + return component_management_routes + +def build_component_manager_mount(route_configs, root_path, required_scopes) -> Mount: + component_management_routes: list[Route] = [] + + for component in route_configs: + config: dict[str, Any] = route_configs[component] + for action in ["enable", "disable"]: + + async def endpoint( + request: Request, + action: str = action, + component: str = component, + config: dict[str, Any] = config, + ): + name = request.path_params[config["param"].split(":")[0]] + + try: + await config[action](name) + return JSONResponse( + {"message": f"{action.capitalize()}d {component}: {name}"} + ) + except NotFoundError: + raise StarletteHTTPException( + status_code=404, + detail=f"Unknown {component}: {name}", + ) + + if required_scopes is not None and root_path in ["/tools", "/resources", "/prompts"]: + path = f"/{{{config['param']}}}/{action}" + else: + path = f"/{component}s/{{{config['param']}}}/{action}" + + route = Route(path, endpoint=endpoint, methods=["POST"]) + component_management_routes.append(route) + + return Mount( + f"{root_path}", + app=RequireAuthMiddleware(Starlette(routes=component_management_routes), + required_scopes) + ) \ No newline at end of file diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index aa5358e1b..7077d8d2a 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -41,7 +41,7 @@ class ComponentService: tool_key = key.removeprefix(f"{mounted.prefix}_") mounted_service = ComponentService(mounted.server) tool = await mounted_service._enable_tool(tool_key) - tool.disable() + tool.enable() return tool else: continue @@ -112,9 +112,9 @@ class ComponentService: mounted.resource_prefix_format, ) mounted_service = ComponentService(mounted.server) - resource = await mounted_service._enable_resource(key) - resource.enable() - return resource + mounted_resource: Resource | ResourceTemplate = await mounted_service._enable_resource(key) + mounted_resource.enable() + return mounted_resource else: continue raise NotFoundError(f"Unknown resource: {key}") @@ -154,9 +154,9 @@ class ComponentService: mounted.resource_prefix_format, ) mounted_service = ComponentService(mounted.server) - resource = await mounted_service._disable_resource(key) - resource.disable() - return resource + mounted_resource: Resource | ResourceTemplate = await mounted_service._disable_resource(key) + mounted_resource.disable() + return mounted_resource else: continue raise NotFoundError(f"Unknown resource: {key}") From f2fe4f94b0fc062d5a5cb4fc4e71f1115740675f Mon Sep 17 00:00:00 2001 From: Goro Date: Fri, 27 Jun 2025 21:12:17 +0200 Subject: [PATCH 10/25] Refactor duplicated logic --- .../contrib/component_manager/README.md | 125 ++++++++++++++++++ .../contrib/component_manager/__init__.py | 7 + .../component_manager/component_manager.py | 99 ++++++-------- tests/contrib/test_component_manager.py | 2 +- 4 files changed, 170 insertions(+), 63 deletions(-) create mode 100644 src/fastmcp/contrib/component_manager/README.md create mode 100644 src/fastmcp/contrib/component_manager/__init__.py diff --git a/src/fastmcp/contrib/component_manager/README.md b/src/fastmcp/contrib/component_manager/README.md new file mode 100644 index 000000000..b7b1dccec --- /dev/null +++ b/src/fastmcp/contrib/component_manager/README.md @@ -0,0 +1,125 @@ +# Component Manager – Contrib Module for FastMCP + +The **Component Manager** provides a unified API for enabling and disabling tools, resources, and prompts at runtime in a FastMCP server. This module is useful for dynamic control over which components are active, enabling advanced features like feature toggling, admin interfaces, or automation workflows. + +--- + +## 🔧 Features + +- Enable/disable **tools**, **resources**, and **prompts** via HTTP endpoints. +- Supports **local** and **mounted (server)** components. +- Customizable **API root path**. +- Optional **Auth scopes** for secured access. +- Fully integrates with FastMCP with minimal configuration. + +--- + +## 📦 Installation + +This module is part of the `fastmcp.contrib` package. No separate installation is required if you're already using **FastMCP**. + +--- + +## 🚀 Usage + +### Basic Setup + +```python +from fastmcp import FastMCP +from fastmcp.contrib.component_manager.component_manager import set_up_component_manager + +mcp = FastMCP("Component Manager", instructions="This is a test server with component manager.") +set_up_component_manager(server=mcp) +``` + +--- + +## 🔗 API Endpoints + +By default, all endpoints are registered at `/` by default, or under the custom path if one is provided. + +### Tools + +```http +POST /tools/{tool_name}/enable +POST /tools/{tool_name}/disable +``` + +### Resources + +```http +POST /resources/{uri:path}/enable +POST /resources/{uri:path}/disable +``` + + * Works with template URIs too +```http +POST /resources/example://test/{id}/enable +POST /resources/example://test/{id}/disable +``` + +### Prompts + +```http +POST /prompts/{prompt_name}/enable +POST /prompts/{prompt_name}/disable +``` + +--- + +## ⚙️ Configuration Options + +### Custom Root Path + +To mount the API under a different path: + +```python +set_up_component_manager(server=mcp, path="/admin") +``` + +### Securing Endpoints with Auth Scopes + +If your server uses authentication: + +```python +mcp = FastMCP("Component Manager", instructions="This is a test server with component manager.", auth=auth) +set_up_component_manager(server=mcp, required_scopes=["tools:write", "tools:read"]) +``` + +--- + +## 🧪 Example: Enabling a Tool with Curl + +```bash +curl -X POST \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + http://localhost:8001/tools/example_tool/enable +``` + +--- + +## ⚙️ How It Works + +- `set_up_component_manager()` registers API routes for tools, resources, and prompts. +- The `ComponentService` class exposes async methods to enable/disable components. +- Each endpoint returns a success message in JSON or a 404 error if the component isn't found. + +--- + +## 🧩 Extending + +You can subclass `ComponentService` for custom behavior or mount its routes elsewhere as needed. + +--- + +## Maintenance Notice + +This module is not officially maintained by the core FastMCP team. It is an independent extension developed by [gorocode](https://github.com/gorocode). + +If you encounter any issues or wish to contribute, please feel free to open an issue or submit a pull request, and kindly notify me. I'd love to stay up to date. + + +## 📄 License + +This module follows the license of the main [FastMCP](https://github.com/jlowin/fastmcp) project. \ No newline at end of file diff --git a/src/fastmcp/contrib/component_manager/__init__.py b/src/fastmcp/contrib/component_manager/__init__.py new file mode 100644 index 000000000..edc85e475 --- /dev/null +++ b/src/fastmcp/contrib/component_manager/__init__.py @@ -0,0 +1,7 @@ +from .component_manager import set_up_component_manager +from .component_service import ComponentService + +__all__ = [ + "set_up_component_manager", + "ComponentService" +] diff --git a/src/fastmcp/contrib/component_manager/component_manager.py b/src/fastmcp/contrib/component_manager/component_manager.py index 659b9a5a5..6fc20eace 100644 --- a/src/fastmcp/contrib/component_manager/component_manager.py +++ b/src/fastmcp/contrib/component_manager/component_manager.py @@ -14,11 +14,12 @@ from typing import Any from fastmcp.server.server import FastMCP def set_up_component_manager( - server: FastMCP, root_path: str = "/", required_scopes: list[str] | None = None + server: FastMCP, path: str = "/", required_scopes: list[str] | None = None ): """Set up routes for enabling/disabling tools, resources, and prompts. Args: server: The FastMCP server instance + root_path: Path used to mount all component-related routes on the server required_scopes: Optional list of scopes required for these routes Returns: A list of routes or mounts for component management @@ -47,13 +48,13 @@ def set_up_component_manager( if required_scopes is None: routes.extend( - build_component_manager_enpoints(route_configs, root_path) + build_component_manager_endpoints(route_configs, path) ) else: - if root_path != "/": + if path != "/": mounts.append( build_component_manager_mount( - route_configs, root_path, required_scopes + route_configs, path, required_scopes )) else: mounts.append( @@ -73,78 +74,52 @@ def set_up_component_manager( server._additional_http_routes.extend(mounts) -def build_component_manager_enpoints(route_configs, root_path, required_scopes=None) -> list[Route]: +def make_endpoint(action, component, config): + async def endpoint(request: Request): + name = request.path_params[config["param"].split(":")[0]] + + try: + await config[action](name) + return JSONResponse( + {"message": f"{action.capitalize()}d {component}: {name}"} + ) + except NotFoundError: + raise StarletteHTTPException( + status_code=404, + detail=f"Unknown {component}: {name}", + ) + return endpoint + +def make_route(action, component, config, required_scopes, root_path) -> Route: + endpoint = make_endpoint(action, component, config) + + if required_scopes is not None and root_path in ["/tools", "/resources", "/prompts"]: + path = f"/{{{config['param']}}}/{action}" + else: + path = f"/{component}s/{{{config['param']}}}/{action}" + + return Route(path, endpoint=endpoint, methods=["POST"]) + +def build_component_manager_endpoints(route_configs, root_path, required_scopes=None) -> list[Route]: component_management_routes: list[Route] = [] for component in route_configs: config: dict[str, Any] = route_configs[component] for action in ["enable", "disable"]: - - async def endpoint( - request: Request, - action: str = action, - component: str = component, - config: dict[str, Any] = config, - ): - name = request.path_params[config["param"].split(":")[0]] - - try: - await config[action](name) - return JSONResponse( - {"message": f"{action.capitalize()}d {component}: {name}"} - ) - except NotFoundError: - raise StarletteHTTPException( - status_code=404, - detail=f"Unknown {component}: {name}", - ) - - if required_scopes is not None and root_path in ["/tools", "/resources", "/prompts"]: - path = f"/{{{config['param']}}}/{action}" - else: - path = f"/{component}s/{{{config['param']}}}/{action}" - - route = Route(path, endpoint=endpoint, methods=["POST"]) - component_management_routes.append(route) + component_management_routes.append(make_route(action, component, config, required_scopes, root_path)) return component_management_routes + def build_component_manager_mount(route_configs, root_path, required_scopes) -> Mount: component_management_routes: list[Route] = [] for component in route_configs: config: dict[str, Any] = route_configs[component] for action in ["enable", "disable"]: - - async def endpoint( - request: Request, - action: str = action, - component: str = component, - config: dict[str, Any] = config, - ): - name = request.path_params[config["param"].split(":")[0]] - - try: - await config[action](name) - return JSONResponse( - {"message": f"{action.capitalize()}d {component}: {name}"} - ) - except NotFoundError: - raise StarletteHTTPException( - status_code=404, - detail=f"Unknown {component}: {name}", - ) - - if required_scopes is not None and root_path in ["/tools", "/resources", "/prompts"]: - path = f"/{{{config['param']}}}/{action}" - else: - path = f"/{component}s/{{{config['param']}}}/{action}" - - route = Route(path, endpoint=endpoint, methods=["POST"]) - component_management_routes.append(route) + component_management_routes.append(make_route(action, component, config, required_scopes, root_path)) return Mount( f"{root_path}", - app=RequireAuthMiddleware(Starlette(routes=component_management_routes), - required_scopes) - ) \ No newline at end of file + app=RequireAuthMiddleware(Starlette(routes=component_management_routes), required_scopes) + ) diff --git a/tests/contrib/test_component_manager.py b/tests/contrib/test_component_manager.py index 1c36d42f9..644890ea0 100644 --- a/tests/contrib/test_component_manager.py +++ b/tests/contrib/test_component_manager.py @@ -3,7 +3,7 @@ from starlette import status from starlette.testclient import TestClient from fastmcp import FastMCP -from fastmcp.contrib.component_manager.component_manager import set_up_component_manager +from fastmcp.contrib.component_manager import set_up_component_manager from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair From d8dc28b9b204c9ae4762ffe28973b389a62bec9f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:40:52 -0400 Subject: [PATCH 11/25] Add structured content & update almost all tests --- pyproject.toml | 1 + src/fastmcp/client/client.py | 80 +- src/fastmcp/server/low_level.py | 6 +- src/fastmcp/server/openapi.py | 11 +- src/fastmcp/server/proxy.py | 14 +- src/fastmcp/server/server.py | 6 +- src/fastmcp/tools/tool.py | 114 +- src/fastmcp/utilities/json_schema_type.py | 76 +- src/fastmcp/utilities/types.py | 12 +- tests/auth/test_oauth_client.py | 4 +- tests/client/test_client.py | 7 +- tests/client/test_notifications.py | 12 +- tests/client/test_openapi.py | 4 +- tests/client/test_roots.py | 4 +- tests/client/test_sampling.py | 9 +- tests/client/test_stdio.py | 18 +- tests/client/test_streamable_http.py | 6 +- .../deprecated/test_mount_import_arg_order.py | 4 +- tests/server/openapi/test_openapi.py | 44 +- .../openapi/test_openapi_path_parameters.py | 13 +- tests/server/test_import_server.py | 12 +- tests/server/test_mount.py | 85 +- tests/server/test_proxy.py | 14 +- tests/server/test_server.py | 4 +- tests/server/test_server_interactions.py | 218 ++- tests/server/test_tool_annotations.py | 6 +- tests/server/test_tool_exclude_args.py | 8 +- tests/test_examples.py | 24 +- tests/tools/test_tool.py | 65 +- tests/tools/test_tool_manager.py | 59 +- tests/tools/test_tool_transform.py | 39 +- tests/utilities/test_json_schema_type.py | 1418 +++++++++++++++++ tests/utilities/test_mcp_config.py | 4 +- uv.lock | 29 + 34 files changed, 2082 insertions(+), 348 deletions(-) create mode 100644 tests/utilities/test_json_schema_type.py diff --git a/pyproject.toml b/pyproject.toml index 86108e1d2..d4fbbed4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "rich>=13.9.4", "typer>=0.15.2", "authlib>=1.5.2", + "pydantic[email]>=2.11.7", ] requires-python = ">=3.10" readme = "README.md" diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 4512f3d5e..f160e978a 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import asyncio import datetime from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass from pathlib import Path from typing import Any, Generic, Literal, cast, overload @@ -10,7 +13,6 @@ import mcp.types import pydantic_core from exceptiongroup import catch from mcp import ClientSession -from mcp.types import ContentBlock from pydantic import AnyUrl import fastmcp @@ -31,7 +33,9 @@ from fastmcp.exceptions import ToolError from fastmcp.server import FastMCP from fastmcp.utilities.exceptions import get_catch_handlers from fastmcp.utilities.json_schema_type import json_schema_to_type +from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_config import MCPConfig +from fastmcp.utilities.types import get_cached_typeadapter from .transports import ( ClientTransportT, @@ -57,6 +61,8 @@ __all__ = [ "ProgressHandler", ] +logger = get_logger(__name__) + class Client(Generic[ClientTransportT]): """ @@ -100,34 +106,39 @@ class Client(Generic[ClientTransportT]): cls, transport: ClientTransportT, **kwargs: Any, - ) -> "Client[ClientTransportT]": ... + ) -> Client[ClientTransportT]: ... @overload def __new__( cls, transport: AnyUrl, **kwargs - ) -> "Client[SSETransport|StreamableHttpTransport]": ... + ) -> Client[SSETransport | StreamableHttpTransport]: ... @overload def __new__( cls, transport: FastMCP | FastMCP1Server, **kwargs - ) -> "Client[FastMCPTransport]": ... + ) -> Client[FastMCPTransport]: ... @overload def __new__( cls, transport: Path, **kwargs - ) -> "Client[PythonStdioTransport|NodeStdioTransport]": ... + ) -> Client[PythonStdioTransport | NodeStdioTransport]: ... @overload def __new__( cls, transport: MCPConfig | dict[str, Any], **kwargs - ) -> "Client[MCPConfigTransport]": ... + ) -> Client[MCPConfigTransport]: ... @overload def __new__( cls, transport: str, **kwargs - ) -> "Client[PythonStdioTransport|NodeStdioTransport|SSETransport|StreamableHttpTransport]": ... + ) -> Client[ + PythonStdioTransport + | NodeStdioTransport + | SSETransport + | StreamableHttpTransport + ]: ... - def __new__(cls, transport, **kwargs) -> "Client": + def __new__(cls, transport, **kwargs) -> Client: instance = super().__new__(cls) return instance @@ -676,7 +687,8 @@ class Client(Generic[ClientTransportT]): arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, - ) -> list[ContentBlock] | dict[str, Any] | Any: + raise_on_error: bool = True, + ) -> CallToolResult: """Call a tool on the server. Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error. @@ -688,7 +700,7 @@ class Client(Generic[ClientTransportT]): progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. Returns: - list[ContentBlock] | dict[str, Any] | Any: + CallToolResult: The content returned by the tool. If the tool returns structured outputs, they are returned as a dataclass (if an output schema is available) or a dictionary; otherwise, a list of content @@ -706,19 +718,43 @@ class Client(Generic[ClientTransportT]): timeout=timeout, progress_handler=progress_handler, ) - if result.isError: + data = None + if result.isError and raise_on_error: msg = cast(mcp.types.TextContent, result.content[0]).text raise ToolError(msg) elif result.structuredContent: - if name not in self.session._tool_output_schemas: - # refresh output schema cache - await self.session.list_tools() - if name in self.session._tool_output_schemas: - output_schema = self.session._tool_output_schemas.get(name) - if output_schema: - output_type = json_schema_to_type(output_schema) - return output_type(**result.structuredContent) + try: + if name not in self.session._tool_output_schemas: + await self.session.list_tools() + if name in self.session._tool_output_schemas: + output_schema = self.session._tool_output_schemas.get(name) + if output_schema: + if output_schema.get("x-fastmcp-wrap-result"): + output_schema = output_schema.get("properties", {}).get( + "result" + ) + structured_content = result.structuredContent.get("result") + else: + structured_content = result.structuredContent + output_type = json_schema_to_type(output_schema) + type_adapter = get_cached_typeadapter(output_type) + data = type_adapter.validate_python(structured_content) + else: + data = result.structuredContent + except Exception as e: + logger.error(f"Error parsing structured content: {e}") - return result.structuredContent - else: - return result.content + return CallToolResult( + content=result.content, + structured_content=result.structuredContent, + data=data, + is_error=result.isError, + ) + + +@dataclass +class CallToolResult: + content: list[mcp.types.ContentBlock] + structured_content: dict[str, Any] | None + data: Any = None + is_error: bool = False diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py index 620abe713..7dd3e9d4b 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -4,12 +4,14 @@ from mcp.server.lowlevel.server import ( LifespanResultT, NotificationOptions, RequestT, - Server, +) +from mcp.server.lowlevel.server import ( + Server as _Server, ) from mcp.server.models import InitializationOptions -class LowLevelServer(Server[LifespanResultT, RequestT]): +class LowLevelServer(_Server[LifespanResultT, RequestT]): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # FastMCP servers support notifications for all components diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 02a68de5d..69a815261 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -13,7 +13,7 @@ from re import Pattern from typing import TYPE_CHECKING, Any, Literal import httpx -from mcp.types import ContentBlock, ToolAnnotations +from mcp.types import ToolAnnotations from pydantic.networks import AnyUrl import fastmcp @@ -21,7 +21,7 @@ from fastmcp.exceptions import ToolError from fastmcp.resources import Resource, ResourceTemplate from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP -from fastmcp.tools.tool import Tool, _convert_to_content +from fastmcp.tools.tool import Tool, ToolResult from fastmcp.utilities import openapi from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import ( @@ -254,7 +254,7 @@ class OpenAPITool(Tool): """Custom representation to prevent recursion errors when printing.""" return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})" - async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]: + async def run(self, arguments: dict[str, Any]) -> ToolResult: """Execute the HTTP request based on the route configuration.""" # Prepare URL @@ -450,10 +450,9 @@ class OpenAPITool(Tool): # Try to parse as JSON first try: result = response.json() + return ToolResult(structured_content=result) except (json.JSONDecodeError, ValueError): - # Return text content if not JSON - result = response.text - return _convert_to_content(result) + return ToolResult(content=response.text) except httpx.HTTPStatusError as e: # Handle HTTP errors (4xx, 5xx) diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index d196c2b7c..07650fc27 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -8,7 +8,6 @@ from mcp.shared.exceptions import McpError from mcp.types import ( METHOD_NOT_FOUND, BlobResourceContents, - ContentBlock, GetPromptResult, TextResourceContents, ) @@ -67,9 +66,7 @@ class ProxyToolManager(ToolManager): tools_dict = await self.get_tools() return list(tools_dict.values()) - async def call_tool( - self, key: str, arguments: dict[str, Any] - ) -> list[ContentBlock]: + async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult: """Calls a tool, trying local/mounted first, then proxy if not found.""" try: # First try local and mounted tools @@ -77,7 +74,11 @@ class ProxyToolManager(ToolManager): except NotFoundError: # If not found locally, try proxy async with self.client: - return await self.client.call_tool(key, arguments) + result = await self.client.call_tool(key, arguments) + return ToolResult( + content=result.content, + structured_content=result.structured_content, + ) class ProxyResourceManager(ResourceManager): @@ -226,6 +227,7 @@ class ProxyTool(Tool): description=mcp_tool.description, parameters=mcp_tool.inputSchema, annotations=mcp_tool.annotations, + output_schema=mcp_tool.outputSchema, ) async def run( @@ -244,7 +246,7 @@ class ProxyTool(Tool): raise ToolError(cast(mcp.types.TextContent, result.content[0]).text) return ToolResult( content=result.content, - structured_output=result.structuredContent, + structured_content=result.structuredContent, ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index bfbd50678..d3252b771 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -792,7 +792,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT = NotSet, + output_schema: dict[str, Any] | None | NotSetT = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -806,7 +806,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT = NotSet, + output_schema: dict[str, Any] | None | NotSetT = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -819,7 +819,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT = NotSet, + output_schema: dict[str, Any] | None | NotSetT = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 560b4df00..3fd4f2004 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -5,6 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Annotated, Any +import mcp.types import pydantic_core from mcp.types import ContentBlock, TextContent, ToolAnnotations from mcp.types import Tool as MCPTool @@ -20,9 +21,9 @@ from fastmcp.utilities.types import ( Image, NotSet, NotSetT, - StructuredOutput, find_kwarg_by_type, get_cached_typeadapter, + replace_type, ) if TYPE_CHECKING: @@ -31,21 +32,47 @@ if TYPE_CHECKING: logger = get_logger(__name__) +class _UnserializableType: + pass + + def default_serializer(data: Any) -> str: return pydantic_core.to_json(data, fallback=str, indent=2).decode() -@dataclass class ToolResult: - content: list[ContentBlock] - structured_output: dict[str, Any] | None = None + def __init__( + self, + content: list[ContentBlock] | Any | None = None, + structured_content: dict[str, Any] | Any | None = None, + ): + if content is None and structured_content is None: + raise ValueError("Either content or structured_content must be provided") + elif content is None: + content = structured_content + + self.content = _convert_to_content(content) + + if structured_content is not None: + try: + structured_content = pydantic_core.to_jsonable_python( + structured_content + ) + except pydantic_core.PydanticSerializationError: + logger.error( + "Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization:" + ) + raise + if not isinstance(structured_content, dict): + structured_content = {"result": structured_content} + self.structured_content: dict[str, Any] | None = structured_content def to_mcp_result( self, ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]: - if self.structured_output is None: + if self.structured_content is None: return self.content - return self.content, self.structured_output + return self.content, self.structured_content class Tool(FastMCPComponent): @@ -159,18 +186,6 @@ class Tool(FastMCPComponent): class FunctionTool(Tool): fn: Callable[..., Any] - wrap_primitive_output: bool = Field( - default=False, - description="""Whether to wrap the function's return value in a {"value": result} object. - - This is automatically set to True when a function has a primitive return type - annotation (int, str, bool, etc.) and FastMCP auto-generates an object schema - with a single "value" property to enable structured output support. - - When True, the function's raw return value gets wrapped as {"value": raw_result} - in the structured output, allowing clients to receive properly typed objects - even for primitive return types.""", - ) @classmethod def from_function( @@ -192,17 +207,14 @@ class FunctionTool(Tool): if name is None and parsed_fn.name == "": raise ValueError("You must provide a name for lambda functions") - wrap_primitive_output = False if isinstance(output_schema, NotSetT): output_schema = parsed_fn.output_schema - # convert primitive types to object with a single "value" property + if output_schema and output_schema.get("type") != "object": - wrap_primitive_output = True output_schema = { "type": "object", - "properties": {"value": output_schema | {"title": "Value"}}, - "required": ["value"], - "title": "Result", + "properties": {"result": output_schema}, + "x-fastmcp-wrap-result": True, } return cls( @@ -215,7 +227,6 @@ class FunctionTool(Tool): tags=tags or set(), serializer=serializer, enabled=enabled if enabled is not None else True, - wrap_primitive_output=wrap_primitive_output, ) async def run(self, arguments: dict[str, Any]) -> ToolResult: @@ -233,21 +244,22 @@ class FunctionTool(Tool): if inspect.isawaitable(result): result = await result + if isinstance(result, ToolResult): + return result + unstructured_result = _convert_to_content(result, serializer=self.serializer) - structured_output = None - if isinstance(result, StructuredOutput): - structured_output = result.to_structured_output() - elif self.output_schema is not None: - raw_result = pydantic_core.to_jsonable_python(result, fallback=str) - if self.wrap_primitive_output: - structured_output = {"value": raw_result} + if self.output_schema is not None: + if self.output_schema.get("x-fastmcp-wrap-result"): + structured_output = {"result": result} else: - structured_output = raw_result + structured_output = result + else: + structured_output = None return ToolResult( content=unstructured_result, - structured_output=structured_output, + structured_content=structured_output, ) @@ -264,6 +276,7 @@ class ParsedFunction: cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, + ignore_response_types: list[type] | None = None, validate: bool = True, ) -> ParsedFunction: from fastmcp.server.context import Context @@ -316,11 +329,34 @@ class ParsedFunction: output_schema = None output_type = inspect.signature(fn).return_annotation - if output_type not in (inspect._empty, Image, Audio, File, StructuredOutput): - try: - output_type_adapter = get_cached_typeadapter(output_type) - output_schema = output_type_adapter.json_schema() - except PydanticSchemaGenerationError: + + # there are a variety of types that we don't want to attempt to + # serialize because they are either used by FastMCP internally, + # or are MCP content types that explicitly don't form structured + # content. By replacing them with an explicitly unserializable type, + # we ensure that no output schema is automatically generated. + + output_type = replace_type( + output_type, + { + inspect._empty: _UnserializableType, + Image: _UnserializableType, + Audio: _UnserializableType, + File: _UnserializableType, + ToolResult: _UnserializableType, + mcp.types.TextContent: _UnserializableType, + mcp.types.ImageContent: _UnserializableType, + mcp.types.AudioContent: _UnserializableType, + mcp.types.ResourceLink: _UnserializableType, + mcp.types.EmbeddedResource: _UnserializableType, + }, + ) + + try: + output_type_adapter = get_cached_typeadapter(output_type) + output_schema = output_type_adapter.json_schema() + except PydanticSchemaGenerationError as e: + if "_UnserializableType" not in str(e): logger.debug(f"Unable to generate schema for type {output_type!r}") return cls( diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index c76a3f2cf..2e48cd8a0 100644 --- a/src/fastmcp/utilities/json_schema_type.py +++ b/src/fastmcp/utilities/json_schema_type.py @@ -53,6 +53,8 @@ from typing import ( from pydantic import ( AnyUrl, + BaseModel, + ConfigDict, EmailStr, Field, Json, @@ -167,6 +169,13 @@ def json_schema_to_type( """ # Always use the top-level schema for references if schema.get("type") == "object": + # If no properties defined but additionalProperties is True, return dict[str, Any] + if not schema.get("properties") and schema.get("additionalProperties") is True: + return dict[str, Any] # type: ignore + # If has properties AND additionalProperties is True, use Pydantic BaseModel + elif schema.get("properties") and schema.get("additionalProperties") is True: + return _create_pydantic_model(schema, name, schemas=schema) + # Otherwise use fast dataclass return _create_dataclass(schema, name, schemas=schema) elif name: raise ValueError(f"Can not apply name to non-object schema: {name}") @@ -285,7 +294,11 @@ def _get_from_type_handler( "boolean": lambda _: bool, # type: ignore "null": lambda _: type(None), # type: ignore "array": lambda s: _create_array_type(s, schemas), # type: ignore - "object": lambda s: _create_dataclass(s, s.get("title"), schemas), # type: ignore + "object": lambda s: ( + _create_pydantic_model(s, s.get("title"), schemas) + if s.get("properties") and s.get("additionalProperties") is True + else _create_dataclass(s, s.get("title"), schemas) + ), # type: ignore } return type_handlers.get(schema.get("type", None), _return_Any) @@ -329,7 +342,10 @@ def _schema_to_type( has_null = type(None) in types types = [t for t in types if t is not type(None)] if has_null: - return Optional[tuple(types) if len(types) > 1 else types[0]] # type: ignore # noqa: UP007 + if len(types) == 1: + return Optional[types[0]] # type: ignore # noqa: UP007 + else: + return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007 return Union[tuple(types)] # type: ignore # noqa: UP007 return _get_from_type_handler(schema, schemas)(schema) @@ -378,6 +394,62 @@ def _create_field_with_default( return field(default=default_value) +def _create_pydantic_model( + schema: Mapping[str, Any], + name: str | None = None, + schemas: Mapping[str, Any] | None = None, +) -> type: + """Create Pydantic BaseModel from object schema with additionalProperties.""" + name = name or schema.get("title", "Root") + sanitized_name = _sanitize_name(name) + schema_hash = _hash_schema(schema) + cache_key = (schema_hash, sanitized_name) + + # Return existing class if already built + if cache_key in _classes: + existing = _classes[cache_key] + if existing is None: + return ForwardRef(sanitized_name) + return existing + + # Place placeholder for recursive references + _classes[cache_key] = None + + properties = schema.get("properties", {}) + required = schema.get("required", []) + + # Build field annotations and defaults + annotations = {} + defaults = {} + + for prop_name, prop_schema in properties.items(): + field_type = _schema_to_type(prop_schema, schemas or {}) + + # Handle defaults + default_value = prop_schema.get("default", MISSING) + if default_value is not MISSING: + defaults[prop_name] = default_value + annotations[prop_name] = field_type + elif prop_name in required: + annotations[prop_name] = field_type + else: + annotations[prop_name] = Optional[field_type] + defaults[prop_name] = None + + # Create Pydantic model class + cls_dict = { + "__annotations__": annotations, + "model_config": ConfigDict(extra="allow"), + **defaults, + } + + cls = type(sanitized_name, (BaseModel,), cls_dict) + + # Store completed class + _classes[cache_key] = cls + return cls + + def _create_dataclass( schema: Mapping[str, Any], name: str | None = None, diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index e8a4740f4..919f03abd 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -7,7 +7,7 @@ from collections.abc import Callable from functools import lru_cache from pathlib import Path from types import EllipsisType, UnionType -from typing import Annotated, Any, TypeAlias, TypeVar, Union, get_args, get_origin +from typing import Annotated, TypeAlias, TypeVar, Union, get_args, get_origin import mcp.types from mcp.types import Annotations @@ -289,16 +289,6 @@ class File: ) -class StructuredOutput: - """Helper class for returning structured output from tools.""" - - def __init__(self, data: dict[str, Any]): - self.data = data - - def to_structured_output(self) -> dict[str, Any]: - return self.data - - def replace_type(type_, type_map: dict[type, type]): """ Given a (possibly generic, nested, or otherwise complex) type, replaces all diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index cb7204818..c12f80a1d 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -226,7 +226,9 @@ async def test_call_tool(client_with_headless_oauth: Client): """Test that we can call a tool.""" async with client_with_headless_oauth: result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3}) - assert result[0].text == "8" # type: ignore[attr-defined] + # The add tool returns int which gets wrapped as structured output + # Client unwraps it and puts the actual int in the data field + assert result.data == 8 async def test_list_resources(client_with_headless_oauth: Client): diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 55210c432..499a3f256 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -121,9 +121,10 @@ async def test_call_tool(fastmcp_server): async with client: result = await client.call_tool("greet", {"name": "World"}) - # The result content should contain our greeting - content_str = str(result[0]) - assert "Hello, World!" in content_str + assert result.content[0].text == "Hello, World!" # type: ignore[attr-defined] + 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): diff --git a/tests/client/test_notifications.py b/tests/client/test_notifications.py index 0c833adf2..62a5ea283 100644 --- a/tests/client/test_notifications.py +++ b/tests/client/test_notifications.py @@ -126,7 +126,7 @@ class TestToolNotifications: # Enable the target tool result = await client.call_tool("enable_target_tool", {}) - assert result[0].text == "Target tool enabled" # type: ignore[attr-defined] + assert result.data == "Target tool enabled" # Check that notification was sent recording_message_handler.assert_notification_sent( @@ -147,7 +147,7 @@ class TestToolNotifications: # Disable the target tool result = await client.call_tool("disable_target_tool", {}) - assert result[0].text == "Target tool disabled" # type: ignore[attr-defined] + assert result.data == "Target tool disabled" # Check that notification was sent recording_message_handler.assert_notification_sent( @@ -231,7 +231,7 @@ class TestResourceNotifications: # Enable the target resource result = await client.call_tool("enable_target_resource", {}) - assert result[0].text == "Target resource enabled" # type: ignore[attr-defined] + assert result.data == "Target resource enabled" # Check that notification was sent recording_message_handler.assert_notification_sent( @@ -252,7 +252,7 @@ class TestResourceNotifications: # Disable the target resource result = await client.call_tool("disable_target_resource", {}) - assert result[0].text == "Target resource disabled" # type: ignore[attr-defined] + assert result.data == "Target resource disabled" # Check that notification was sent recording_message_handler.assert_notification_sent( @@ -313,7 +313,7 @@ class TestPromptNotifications: # Enable the target prompt result = await client.call_tool("enable_target_prompt", {}) - assert result[0].text == "Target prompt enabled" # type: ignore[attr-defined] + assert result.data == "Target prompt enabled" # Check that notification was sent recording_message_handler.assert_notification_sent( @@ -334,7 +334,7 @@ class TestPromptNotifications: # Disable the target prompt result = await client.call_tool("disable_target_prompt", {}) - assert result[0].text == "Target prompt disabled" # type: ignore[attr-defined] + assert result.data == "Target prompt disabled" # Check that notification was sent recording_message_handler.assert_notification_sent( diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index 6f662e927..04ba6c123 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -118,7 +118,7 @@ class TestClientHeaders: transport=SSETransport(sse_server, headers={"X-TEST": "test-123"}) ) as client: result = await client.call_tool("post_headers_headers_post") - headers = json.loads(result[0].text) # type: ignore[attr-defined] + headers: dict[str, str] = result.data assert headers["x-test"] == "test-123" async def test_client_headers_shttp_tool(self, shttp_server: str): @@ -128,7 +128,7 @@ class TestClientHeaders: ) ) as client: result = await client.call_tool("post_headers_headers_post") - headers = json.loads(result[0].text) # type: ignore[attr-defined] + headers: dict[str, str] = result.data assert headers["x-test"] == "test-123" async def test_client_overrides_server_headers(self, shttp_server: str): diff --git a/tests/client/test_roots.py b/tests/client/test_roots.py index f4df827de..d3bc7d5ca 100644 --- a/tests/client/test_roots.py +++ b/tests/client/test_roots.py @@ -1,5 +1,3 @@ -import json - import pytest from fastmcp import Client, Context, FastMCP @@ -40,7 +38,7 @@ class TestClientRoots: async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]): async with Client(fastmcp_server, roots=roots) as client: result = await client.call_tool("list_roots", {}) - assert json.loads(result[0].text) == [ # type: ignore[attr-defined] + assert result.data == [ "file://x/y/z", "file://x/y/z", ] diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py index 497aa8513..5b11b0885 100644 --- a/tests/client/test_sampling.py +++ b/tests/client/test_sampling.py @@ -47,8 +47,7 @@ async def test_simple_sampling(fastmcp_server: FastMCP): async with Client(fastmcp_server, sampling_handler=sampling_handler) as client: result = await client.call_tool("simple_sample", {"message": "Hello, world!"}) - reply = cast(TextContent, result[0]) - assert reply.text == "This is the sample message!" + assert result.data == "This is the sample message!" async def test_sampling_with_system_prompt(fastmcp_server: FastMCP): @@ -62,8 +61,7 @@ async def test_sampling_with_system_prompt(fastmcp_server: FastMCP): result = await client.call_tool( "sample_with_system_prompt", {"message": "Hello, world!"} ) - reply = cast(TextContent, result[0]) - assert reply.text == "You love FastMCP" + assert result.data == "You love FastMCP" async def test_sampling_with_messages(fastmcp_server: FastMCP): @@ -81,5 +79,4 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP): result = await client.call_tool( "sample_with_messages", {"message": "Hello, world!"} ) - reply = cast(TextContent, result[0]) - assert reply.text == "I need to think." + assert result.data == "I need to think." diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index d9f9247d8..0ccac8f5d 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -48,11 +48,11 @@ class TestKeepAlive: async with client: result1 = await client.call_tool("pid") - pid1 = int(result1[0].text) # type: ignore[attr-defined] + pid1: int = result1.data async with client: result2 = await client.call_tool("pid") - pid2 = int(result2[0].text) # type: ignore[attr-defined] + pid2: int = result2.data assert pid1 == pid2 @@ -66,11 +66,11 @@ class TestKeepAlive: async with client: result1 = await client.call_tool("pid") - pid1 = int(result1[0].text) # type: ignore[attr-defined] + pid1: int = result1.data async with client: result2 = await client.call_tool("pid") - pid2 = int(result2[0].text) # type: ignore[attr-defined] + pid2: int = result2.data assert pid1 != pid2 @@ -80,13 +80,13 @@ class TestKeepAlive: async with client: result1 = await client.call_tool("pid") - pid1 = int(result1[0].text) # type: ignore[attr-defined] + pid1: int = result1.data await client.close() async with client: result2 = await client.call_tool("pid") - pid2 = int(result2[0].text) # type: ignore[attr-defined] + pid2: int = result2.data assert pid1 != pid2 @@ -96,14 +96,14 @@ class TestKeepAlive: async with client: result1 = await client.call_tool("pid") - pid1 = int(result1[0].text) # type: ignore[attr-defined] + pid1: int = result1.data async with client: result2 = await client.call_tool("pid") - pid2 = int(result2[0].text) # type: ignore[attr-defined] + pid2: int = result2.data result3 = await client.call_tool("pid") - pid3 = int(result3[0].text) # type: ignore[attr-defined] + pid3: int = result3.data assert pid1 == pid2 == pid3 diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index efcb79f16..7bc7b4e7d 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock import pytest import uvicorn from mcp import McpError -from mcp.types import TextContent from starlette.applications import Starlette from starlette.routing import Mount @@ -166,10 +165,7 @@ async def test_greet_with_progress_tool(streamable_http_server: str): progress_handler=progress_handler, ) as client: result = await client.call_tool("greet_with_progress", {"name": "Alice"}) - - assert isinstance(result, list) - assert isinstance(result[0], TextContent) - assert result[0].text == "Hello, Alice!" + assert result.data == "Hello, Alice!" progress_handler.assert_called_once_with(0.5, 1.0, "Greeting in progress") diff --git a/tests/deprecated/test_mount_import_arg_order.py b/tests/deprecated/test_mount_import_arg_order.py index 7fc273b36..b65b75c30 100644 --- a/tests/deprecated/test_mount_import_arg_order.py +++ b/tests/deprecated/test_mount_import_arg_order.py @@ -36,7 +36,7 @@ class TestDeprecatedMountArgOrder: # Test functionality async with Client(main_app) as client: result = await client.call_tool("sub_sub_tool", {}) - assert result[0].text == "Sub tool result" # type: ignore[attr-defined] + assert result.data == "Sub tool result" async def test_mount_new_arg_order_no_warning(self): """Test that mount(server, prefix) works without deprecation warning.""" @@ -122,7 +122,7 @@ class TestDeprecatedImportArgOrder: # Test functionality async with Client(main_app) as client: result = await client.call_tool("sub_sub_tool", {}) - assert result[0].text == "Sub tool result" # type: ignore[attr-defined] + assert result.data == "Sub tool result" async def test_import_new_arg_order_no_warning(self): """Test that import_server(server, prefix) works without deprecation warning.""" diff --git a/tests/server/openapi/test_openapi.py b/tests/server/openapi/test_openapi.py index 5f1583fcf..f8d5013c6 100644 --- a/tests/server/openapi/test_openapi.py +++ b/tests/server/openapi/test_openapi.py @@ -269,9 +269,8 @@ class TestTools: "create_user_users_post", {"name": "David", "active": False} ) - response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined] expected_user = User(id=4, name="David", active=False).model_dump() - assert response_data == expected_user + assert tool_response.data == expected_user # Check that the user was created via API response = await api_client.get("/users") @@ -298,9 +297,8 @@ class TestTools: {"user_id": 1, "name": "XYZ"}, ) - response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined] expected_data = dict(id=1, name="XYZ", active=True) - assert response_data == expected_data + assert tool_response.data == expected_data # Check that the user was updated via API response = await api_client.get("/users") @@ -332,10 +330,12 @@ class TestTools: ) async with Client(mcp_server) as client: tool_response = await client.call_tool("get_users_users_get", {}) - assert json.loads(tool_response[0].text) == [ # type: ignore[attr-defined] - user.model_dump() - for user in sorted(users_db.values(), key=lambda x: x.id) - ] + assert tool_response.data == { + "result": [ + user.model_dump() + for user in sorted(users_db.values(), key=lambda x: x.id) + ] + } class TestResources: @@ -729,12 +729,22 @@ class TestOpenAPI30Compatibility: "createProduct", {"name": "New Product", "price": 39.99} ) # Result should be a text content - assert len(result) == 1 - product = json.loads(result[0].text) # type: ignore[attr-defined] + assert len(result.content) == 1 + product = json.loads(result.content[0].text) # type: ignore[attr-defined] assert product["id"] == "p3" assert product["name"] == "New Product" assert product["price"] == 39.99 + assert result.structured_content is not None + assert result.structured_content["id"] == "p3" + assert result.structured_content["name"] == "New Product" + assert result.structured_content["price"] == 39.99 + + assert result.data is not None + assert result.data["id"] == "p3" + assert result.data["name"] == "New Product" + assert result.data["price"] == 39.99 + class TestOpenAPI31Compatibility: """Tests for compatibility with OpenAPI 3.1 specifications.""" @@ -905,12 +915,22 @@ class TestOpenAPI31Compatibility: "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]} ) # Result should be a text content - assert len(result) == 1 - order = json.loads(result[0].text) # type: ignore[attr-dict] + assert len(result.content) == 1 + order = json.loads(result.content[0].text) # type: ignore[attr-dict] assert order["id"] == "o3" assert order["customer"] == "Charlie" assert order["items"] == ["item4", "item5"] + assert result.structured_content is not None + assert result.structured_content["id"] == "o3" + assert result.structured_content["customer"] == "Charlie" + assert result.structured_content["items"] == ["item4", "item5"] + + assert result.data is not None + assert result.data["id"] == "o3" + assert result.data["customer"] == "Charlie" + assert result.data["items"] == ["item4", "item5"] + async def test_empty_query_parameters_not_sent( fastapi_app: FastAPI, api_client: httpx.AsyncClient diff --git a/tests/server/openapi/test_openapi_path_parameters.py b/tests/server/openapi/test_openapi_path_parameters.py index 7f977ea50..075ad7045 100644 --- a/tests/server/openapi/test_openapi_path_parameters.py +++ b/tests/server/openapi/test_openapi_path_parameters.py @@ -301,20 +301,11 @@ async def test_array_query_param_with_fastapi(): # Single day result = await client.call_tool(tool_name, {"days": ["monday"]}) - # Client returns TextContent objects, so parse the JSON - assert len(result) == 1 - assert result[0].type == "text" - import json - - result_data = json.loads(result[0].text) - assert result_data == {"selected": ["monday"]} + assert result.data == {"selected": ["monday"]} # Multiple days result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]}) - assert len(result) == 1 - assert result[0].type == "text" - result_data = json.loads(result[0].text) - assert result_data == {"selected": ["monday", "tuesday"]} + assert result.data == {"selected": ["monday", "tuesday"]} async def test_array_query_parameter_format(mock_client): diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index 958fbdc83..fcabb1ac9 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -224,7 +224,7 @@ async def test_call_imported_custom_named_tool(): async with Client(main_app) as client: result = await client.call_tool("api_get_data", {"query": "test"}) - assert result[0].text == "Data for query: test" # type: ignore[attr-defined] + assert result.data == "Data for query: test" async def test_first_level_importing_with_custom_name(): @@ -278,7 +278,7 @@ async def test_call_nested_imported_tool(): async with Client(main_app) as client: result = await client.call_tool("service_provider_compute", {"input": 21}) - assert result[0].text == "42" # type: ignore[attr-defined] + assert result.data == "42" async def test_import_with_proxy_tools(): @@ -302,7 +302,7 @@ async def test_import_with_proxy_tools(): async with Client(main_app) as client: result = await client.call_tool("api_get_data", {"query": "test"}) - assert result[0].text == "Data for query: test" # type: ignore[attr-defined] + assert result.data == "Data for query: test" async def test_import_with_proxy_prompts(): @@ -443,7 +443,7 @@ async def test_import_with_no_prefix(): async with Client(main_app) as client: # Test tool tool_result = await client.call_tool("sub_tool", {}) - assert tool_result[0].text == "Sub tool result" # type: ignore[attr-defined] + assert tool_result.data == "Sub tool result" # Test resource resource_result = await client.read_resource("data://config") @@ -485,7 +485,7 @@ async def test_import_conflict_resolution_tools(): assert tool_names.count("shared_tool") == 1 # Should only appear once result = await client.call_tool("shared_tool", {}) - assert result[0].text == "Second app tool" # type: ignore[attr-defined] + assert result.data == "Second app tool" async def test_import_conflict_resolution_resources(): @@ -604,4 +604,4 @@ async def test_import_conflict_resolution_with_prefix(): assert tool_names.count("api_shared_tool") == 1 # Should only appear once result = await client.call_tool("api_shared_tool", {}) - assert result[0].text == "Second app tool" # type: ignore[attr-defined] + assert result.data == "Second app tool" diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index bb1d65d8f..942809c4e 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -33,7 +33,7 @@ class TestBasicMount: async with Client(main_app) as client: result = await client.call_tool("sub_sub_tool", {}) - assert result[0].text == "This is from the sub app" # type: ignore[attr-defined] + assert result.data == "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).""" @@ -52,8 +52,9 @@ class TestBasicMount: assert "sub_greet" in tools # Call the tool - result = await main_app._mcp_call_tool("sub_greet", {"name": "World"}) - assert result[0].text == "Hello, World!" # type: ignore[attr-defined] + async with Client(main_app) as client: + result = await client.call_tool("sub_greet", {"name": "World"}) + assert result.data == "Hello, World!" async def test_mount_invalid_resource_prefix(self): main_app = FastMCP("MainApp") @@ -104,8 +105,9 @@ class TestBasicMount: assert "sub_tool" in tools # Call the tool to verify it works - result = await main_app._mcp_call_tool("sub_tool", {}) - assert result[0].text == "This is from the sub app" # type: ignore[attr-defined] + async with Client(main_app) as client: + result = await client.call_tool("sub_tool", {}) + assert result.data == "This is from the sub app" async def test_mount_tools_no_prefix(self): """Test mounting a server with tools without prefix.""" @@ -124,8 +126,9 @@ class TestBasicMount: assert "sub_tool" in tools # Test actual functionality - tool_result = await main_app._mcp_call_tool("sub_tool", {}) - assert tool_result[0].text == "Sub tool result" # type: ignore[attr-defined] + async with Client(main_app) as client: + tool_result = await client.call_tool("sub_tool", {}) + assert tool_result.data == "Sub tool result" async def test_mount_resources_no_prefix(self): """Test mounting a server with resources without prefix.""" @@ -144,8 +147,9 @@ class TestBasicMount: assert "data://config" in resources # Test actual functionality - resource_result = await main_app._mcp_read_resource("data://config") - assert resource_result[0].content == "Sub resource data" # type: ignore[attr-defined] + async with Client(main_app) as client: + resource_result = await client.read_resource("data://config") + assert resource_result[0].text == "Sub resource data" # type: ignore[attr-defined] async def test_mount_resource_templates_no_prefix(self): """Test mounting a server with resource templates without prefix.""" @@ -164,8 +168,9 @@ class TestBasicMount: assert "users://{user_id}/info" in templates # Test actual functionality - template_result = await main_app._mcp_read_resource("users://123/info") - assert template_result[0].content == "Sub template for user 123" # type: ignore[attr-defined] + async with Client(main_app) as client: + template_result = await client.read_resource("users://123/info") + assert template_result[0].text == "Sub template for user 123" # type: ignore[attr-defined] async def test_mount_prompts_no_prefix(self): """Test mounting a server with prompts without prefix.""" @@ -184,8 +189,9 @@ class TestBasicMount: assert "sub_prompt" in prompts # Test actual functionality - prompt_result = await main_app._mcp_get_prompt("sub_prompt", {}) - assert prompt_result.messages is not None + async with Client(main_app) as client: + prompt_result = await client.get_prompt("sub_prompt", {}) + assert prompt_result.messages is not None class TestMultipleServerMount: @@ -215,11 +221,11 @@ class TestMultipleServerMount: assert "news_get_headlines" in tools # Call tools from both mounted servers - result1 = await main_app._mcp_call_tool("weather_get_forecast", {}) - assert result1[0].text == "Weather forecast" # type: ignore[attr-defined] - - result2 = await main_app._mcp_call_tool("news_get_headlines", {}) - assert result2[0].text == "News headlines" # type: ignore[attr-defined] + async with Client(main_app) as client: + result1 = await client.call_tool("weather_get_forecast", {}) + assert result1.data == "Weather forecast" + result2 = await client.call_tool("news_get_headlines", {}) + assert result2.data == "News headlines" async def test_mount_same_prefix(self): """Test that mounting with the same prefix replaces the previous mount.""" @@ -292,7 +298,7 @@ class TestMultipleServerMount: # Test calling a tool result = await client.call_tool("working_working_tool", {}) - assert result[0].text == "Working tool" # type: ignore[attr-defined] + assert result.data == "Working tool" # Test resources resources = await client.list_resources() @@ -352,7 +358,7 @@ class TestPrefixConflictResolution: # Test that calling the tool uses the later server's implementation result = await client.call_tool("shared_tool", {}) - assert result[0].text == "Second app tool" # type: ignore[attr-defined] + assert result.data == "Second app tool" async def test_later_server_wins_tools_same_prefix(self): """Test that later mounted server wins for tools when same prefix is used.""" @@ -381,7 +387,7 @@ class TestPrefixConflictResolution: # Test that calling the tool uses the later server's implementation result = await client.call_tool("api_shared_tool", {}) - assert result[0].text == "Second app tool" # type: ignore[attr-defined] + assert result.data == "Second app tool" async def test_later_server_wins_resources_no_prefix(self): """Test that later mounted server wins for resources when no prefix is used.""" @@ -593,8 +599,9 @@ class TestDynamicChanges: assert "sub_dynamic_tool" in tools # Call the dynamically added tool - result = await main_app._mcp_call_tool("sub_dynamic_tool", {}) - assert result[0].text == "Added after mounting" # type: ignore[attr-defined] + async with Client(main_app) as client: + result = await client.call_tool("sub_dynamic_tool", {}) + assert result.data == "Added after mounting" async def test_removing_tool_after_mounting(self): """Test that tools removed from mounted servers are no longer accessible.""" @@ -726,8 +733,9 @@ class TestPrompts: assert "assistant_greeting" in prompts # Render the prompt - result = await main_app._mcp_get_prompt("assistant_greeting", {"name": "World"}) - assert result.messages is not None + async with Client(main_app) as client: + result = await client.get_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): @@ -748,8 +756,9 @@ class TestPrompts: assert "assistant_farewell" in prompts # Render the prompt - result = await main_app._mcp_get_prompt("assistant_farewell", {"name": "World"}) - assert result.messages is not None + async with Client(main_app) as client: + result = await client.get_prompt("assistant_farewell", {"name": "World"}) + assert result.messages is not None # The message should contain our farewell text @@ -779,8 +788,9 @@ class TestProxyServer: assert "proxy_get_data" in tools # Call the tool - result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"}) - assert result[0].text == "Data for test" # type: ignore[attr-defined] + async with Client(main_app) as client: + result = await client.call_tool("proxy_get_data", {"query": "test"}) + assert result.data == "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.""" @@ -806,8 +816,9 @@ class TestProxyServer: assert "proxy_dynamic_data" in tools # Call the tool - result = await main_app._mcp_call_tool("proxy_dynamic_data", {}) - assert result[0].text == "Dynamic data" # type: ignore[attr-defined] + async with Client(main_app) as client: + result = await client.call_tool("proxy_dynamic_data", {}) + assert result.data == "Dynamic data" async def test_proxy_server_with_resources(self): """Test mounting a proxy server with resources.""" @@ -828,9 +839,10 @@ class TestProxyServer: main_app.mount(proxy_server, "proxy") # Resource should be accessible through main app - result = await main_app._mcp_read_resource("config://proxy/settings") - config = json.loads(result[0].content) # type: ignore[attr-defined] - assert config["api_key"] == "12345" + async with Client(main_app) as client: + result = await client.read_resource("config://proxy/settings") + config = json.loads(result[0].text) # type: ignore[attr-defined] + assert config["api_key"] == "12345" async def test_proxy_server_with_prompts(self): """Test mounting a proxy server with prompts.""" @@ -851,8 +863,9 @@ class TestProxyServer: main_app.mount(proxy_server, "proxy") # Prompt should be accessible through main app - result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"}) - assert result.messages is not None + async with Client(main_app) as client: + result = await client.get_prompt("proxy_welcome", {"name": "World"}) + assert result.messages is not None # The message should contain our welcome text diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 612f8bfc8..e63c18551 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -89,15 +89,17 @@ async def test_create_proxy(fastmcp_server): async def test_as_proxy_with_server(fastmcp_server): """FastMCP.as_proxy should accept a FastMCP instance.""" proxy = FastMCP.as_proxy(fastmcp_server) - result = await proxy._mcp_call_tool("greet", {"name": "Test"}) - assert result[0].text == "Hello, Test!" # type: ignore[attr-defined] + async with Client(proxy) as client: + result = await client.call_tool("greet", {"name": "Test"}) + assert result.data == "Hello, Test!" async def test_as_proxy_with_transport(fastmcp_server): """FastMCP.as_proxy should accept a ClientTransport.""" proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server)) - result = await proxy._mcp_call_tool("greet", {"name": "Test"}) - assert result[0].text == "Hello, Test!" # type: ignore[attr-defined] + async with Client(proxy) as client: + result = await client.call_tool("greet", {"name": "Test"}) + assert result.data == "Hello, Test!" def test_as_proxy_with_url(): @@ -137,7 +139,7 @@ class TestTools: async def test_call_tool_calls_tool(self, proxy_server): async with Client(proxy_server) as client: proxy_result = await client.call_tool("add", {"a": 1, "b": 2}) - assert proxy_result[0].text == "3" # type: ignore[attr-defined] + assert proxy_result.data == 3 async def test_error_tool_raises_error(self, proxy_server): with pytest.raises(ToolError, match="This is a test error"): @@ -155,7 +157,7 @@ class TestTools: async with Client(proxy_server) as client: result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"}) - assert result[0].text == "Overwritten, Marvin! abc" # type: ignore[attr-defined] + assert result.data == "Overwritten, Marvin! abc" async def test_proxy_errors_if_overwritten_tool_is_disabled(self, proxy_server): """ diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 022f910c0..59e4bdc07 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -45,9 +45,7 @@ class TestCreateServer: assert "🎉" in tool.description result = await client.call_tool("hello_world", {}) - assert len(result) == 1 - content = result[0] - assert content.text == "¡Hola, 世界! 👋" # type: ignore[attr-defined] + assert result.data == "¡Hola, 世界! 👋" class TestTools: diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 042067bcb..1e810d891 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -2,11 +2,11 @@ import base64 import datetime import json import uuid +from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Annotated, Literal -import pydantic_core import pytest from mcp import McpError from mcp.types import ( @@ -17,7 +17,8 @@ from mcp.types import ( TextContent, TextResourceContents, ) -from pydantic import AnyUrl, Field, TypeAdapter +from pydantic import AnyUrl, BaseModel, Field, TypeAdapter +from typing_extensions import TypedDict from fastmcp import Client, Context, FastMCP from fastmcp.client.transports import FastMCPTransport @@ -25,10 +26,26 @@ from fastmcp.exceptions import ToolError from fastmcp.prompts.prompt import Prompt, PromptMessage from fastmcp.resources import FileResource, ResourceTemplate from fastmcp.resources.resource import FunctionResource -from fastmcp.tools.tool import Tool +from fastmcp.tools.tool import Tool, ToolResult from fastmcp.utilities.types import Audio, File, Image +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() @@ -72,7 +89,7 @@ def tool_server(): ), ] - @mcp.tool + @mcp.tool(output_schema=None) def mixed_list_fn(image_path: str) -> list: return [ "text message", @@ -81,7 +98,7 @@ def tool_server(): TextContent(type="text", text="direct content"), ] - @mcp.tool + @mcp.tool(output_schema=None) def mixed_audio_list_fn(audio_path: str) -> list: return [ "text message", @@ -90,7 +107,7 @@ def tool_server(): TextContent(type="text", text="direct content"), ] - @mcp.tool + @mcp.tool(output_schema=None) def mixed_file_list_fn(file_path: str) -> list: return [ "text message", @@ -117,26 +134,24 @@ class TestTools: async with Client(tool_server) as client: assert len(await client.list_tools()) == 11 + async def test_call_tool_mcp(self, tool_server: FastMCP): + async with Client(tool_server) as client: + result = await client.call_tool_mcp("add", {"x": 1, "y": 2}) + assert result.content[0].text == "3" # type: ignore[attr-defined] + assert result.structuredContent == {"result": 3} + async def test_call_tool(self, tool_server: FastMCP): async with Client(tool_server) as client: result = await client.call_tool("add", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] - - async def test_call_tool_as_client(self, tool_server: FastMCP): - async with Client(tool_server) as client: - result = await client.call_tool("add", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + assert result.content[0].text == "3" # type: ignore[attr-defined] + assert result.structured_content == {"result": 3} + assert result.data == 3 async def test_call_tool_error(self, tool_server: FastMCP): async with Client(tool_server) as client: with pytest.raises(Exception): await client.call_tool("error_tool", {}) - async def test_call_tool_error_as_client(self, tool_server: FastMCP): - async with Client(tool_server) as client: - with pytest.raises(Exception): - await client.call_tool("error_tool", {}) - async def test_call_tool_error_as_client_raw(self): """Test raising and catching errors from a tool.""" mcp = FastMCP() @@ -154,13 +169,14 @@ class TestTools: async def test_tool_returns_list(self, tool_server: FastMCP): async with Client(tool_server) as client: result = await client.call_tool("list_tool", {}) - assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined] + assert result.content[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined] + assert result.data == ["x", 2] async def test_file_text_tool(self, tool_server: FastMCP): async with Client(tool_server) as client: result = await client.call_tool("file_text_tool", {}) - assert len(result) == 1 - embedded = result[0] + assert len(result.content) == 1 + embedded = result.content[0] assert isinstance(embedded, EmbeddedResource) resource = embedded.resource assert isinstance(resource, TextResourceContents) @@ -222,7 +238,7 @@ class TestToolTags: async with Client(mcp) as client: result_1 = await client.call_tool("tool_1", {}) - assert result_1[0].text == "1" # type: ignore[attr-defined] + assert result_1.data == 1 with pytest.raises(ToolError, match="Unknown tool"): await client.call_tool("tool_2", {}) @@ -235,7 +251,7 @@ class TestToolTags: await client.call_tool("tool_1", {}) result_2 = await client.call_tool("tool_2", {}) - assert result_2[0].text == "2" # type: ignore[attr-defined] + assert result_2.data == 2 class TestToolReturnTypes: @@ -248,7 +264,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("string_tool", {}) - assert result[0].text == "Hello, world!" # type: ignore[attr-defined] + assert result.data == "Hello, world!" async def test_bytes(self, tmp_path: Path): mcp = FastMCP() @@ -259,7 +275,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("bytes_tool", {}) - assert result[0].text == '"Hello, world!"' # type: ignore[attr-defined] + assert result.data == "Hello, world!" async def test_uuid(self): mcp = FastMCP() @@ -272,7 +288,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("uuid_tool", {}) - assert result[0].text == pydantic_core.to_json(test_uuid).decode() # type: ignore[attr-defined] + assert result.data == str(test_uuid) async def test_path(self): mcp = FastMCP() @@ -285,7 +301,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("path_tool", {}) - assert result[0].text == pydantic_core.to_json(test_path).decode() # type: ignore[attr-defined] + assert result.data == str(test_path) async def test_datetime(self): mcp = FastMCP() @@ -298,7 +314,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("datetime_tool", {}) - assert result[0].text == pydantic_core.to_json(dt).decode() # type: ignore[attr-defined] + assert result.data == dt async def test_image(self, tmp_path: Path): mcp = FastMCP() @@ -313,7 +329,8 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("image_tool", {"path": str(image_path)}) - content = result[0] + assert result.structured_content is None + content = result.content[0] assert isinstance(content, ImageContent) assert content.type == "image" assert content.mimeType == "image/png" @@ -334,7 +351,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("audio_tool", {"path": str(audio_path)}) - content = result[0] + content = result.content[0] assert isinstance(content, AudioContent) assert content.type == "audio" assert content.mimeType == "audio/wav" @@ -355,7 +372,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("file_tool", {"path": str(file_path)}) - content = result[0] + content = result.content[0] assert isinstance(content, EmbeddedResource) assert content.type == "resource" resource = content.resource @@ -371,10 +388,10 @@ class TestToolReturnTypes: async def test_tool_mixed_content(self, tool_server: FastMCP): async with Client(tool_server) as client: result = await client.call_tool("mixed_content_tool", {}) - assert len(result) == 3 - content1 = result[0] - content2 = result[1] - content3 = result[2] + 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) @@ -402,18 +419,18 @@ class TestToolReturnTypes: result = await client.call_tool( "mixed_list_fn", {"image_path": str(image_path)} ) - assert len(result) == 3 + assert len(result.content) == 3 # Check text conversion - content1 = result[0] + content1 = result.content[0] assert isinstance(content1, TextContent) assert json.loads(content1.text) == ["text message", {"key": "value"}] # Check image conversion - content2 = result[1] + content2 = result.content[1] assert isinstance(content2, ImageContent) assert content2.mimeType == "image/png" assert base64.b64decode(content2.data) == b"test image data" # Check direct TextContent - content3 = result[2] + content3 = result.content[2] assert isinstance(content3, TextContent) assert content3.text == "direct content" @@ -430,18 +447,18 @@ class TestToolReturnTypes: result = await client.call_tool( "mixed_audio_list_fn", {"audio_path": str(audio_path)} ) - assert len(result) == 3 + assert len(result.content) == 3 # Check text conversion - content1 = result[0] + content1 = result.content[0] assert isinstance(content1, TextContent) assert json.loads(content1.text) == ["text message", {"key": "value"}] # Check audio conversion - content2 = result[1] + content2 = result.content[1] assert isinstance(content2, AudioContent) assert content2.mimeType == "audio/wav" assert base64.b64decode(content2.data) == b"test audio data" # Check direct TextContent - content3 = result[2] + content3 = result.content[2] assert isinstance(content3, TextContent) assert content3.text == "direct content" @@ -458,13 +475,13 @@ class TestToolReturnTypes: result = await client.call_tool( "mixed_file_list_fn", {"file_path": str(file_path)} ) - assert len(result) == 3 + assert len(result.content) == 3 # Check text conversion - content1 = result[0] + content1 = result.content[0] assert isinstance(content1, TextContent) assert json.loads(content1.text) == ["text message", {"key": "value"}] # Check file conversion - content2 = result[1] + content2 = result.content[1] assert isinstance(content2, EmbeddedResource) assert content2.type == "resource" resource = content2.resource @@ -473,7 +490,7 @@ class TestToolReturnTypes: blob_data = getattr(resource, "blob") assert base64.b64decode(blob_data) == b"test file data" # Check direct TextContent - content3 = result[2] + content3 = result.content[2] assert isinstance(content3, TextContent) assert content3.text == "direct content" @@ -540,9 +557,10 @@ class TestToolParameters: result = await client.call_tool( "process_image", {"image": b"fake png data"} ) - assert isinstance(result[0], ImageContent) - assert result[0].mimeType == "image/png" - assert result[0].data == base64.b64encode(b"fake png data").decode() + assert result.structured_content is None + 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): mcp = FastMCP() @@ -660,7 +678,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("analyze", {"x": "a"}) - assert result[0].text == "a" # type: ignore[attr-defined] + assert result.data == "a" async def test_enum_type_validation_error(self): mcp = FastMCP() @@ -695,7 +713,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("analyze", {"x": "red"}) - assert result[0].text == "red" # type: ignore[attr-defined] + assert result.data == "red" async def test_union_type_validation(self): mcp = FastMCP() @@ -706,10 +724,10 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("analyze", {"x": 1}) - assert result[0].text == "1" # type: ignore[attr-defined] + assert result.data == "1" result = await client.call_tool("analyze", {"x": 1.0}) - assert result[0].text == "1.0" # type: ignore[attr-defined] + assert result.data == "1.0" with pytest.raises( ToolError, @@ -730,7 +748,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_path", {"path": str(test_path)}) - assert result[0].text == str(test_path) # type: ignore[attr-defined] + assert result.data == str(test_path) async def test_path_type_error(self): mcp = FastMCP() @@ -757,7 +775,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_uuid", {"x": test_uuid}) - assert result[0].text == str(test_uuid) # type: ignore[attr-defined] + assert result.data == str(test_uuid) async def test_uuid_type_error(self): mcp = FastMCP() @@ -781,7 +799,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_datetime", {"x": dt}) - assert result[0].text == dt.isoformat() # type: ignore[attr-defined] + assert result.data == dt.isoformat() async def test_datetime_type_parse_string(self): mcp = FastMCP() @@ -794,7 +812,7 @@ class TestToolParameters: result = await client.call_tool( "send_datetime", {"x": "2021-01-01T00:00:00"} ) - assert result[0].text == "2021-01-01T00:00:00" # type: ignore[attr-defined] + assert result.data == "2021-01-01T00:00:00" async def test_datetime_type_error(self): mcp = FastMCP() @@ -816,7 +834,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_date", {"x": datetime.date.today()}) - assert result[0].text == datetime.date.today().isoformat() # type: ignore[attr-defined] + assert result.data == datetime.date.today().isoformat() async def test_date_type_parse_string(self): mcp = FastMCP() @@ -827,7 +845,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_date", {"x": "2021-01-01"}) - assert result[0].text == "2021-01-01" # type: ignore[attr-defined] + assert result.data == "2021-01-01" async def test_timedelta_type(self): mcp = FastMCP() @@ -840,7 +858,7 @@ class TestToolParameters: result = await client.call_tool( "send_timedelta", {"x": datetime.timedelta(days=1)} ) - assert result[0].text == "1 day, 0:00:00" # type: ignore[attr-defined] + assert result.data == "1 day, 0:00:00" async def test_timedelta_type_parse_int(self): """Test that invalid timedelta input raises validation error.""" @@ -860,8 +878,8 @@ class TestToolParameters: class TestToolOutputSchema: - @pytest.mark.parametrize("annotation", [str, int, float, bool, list, dict, AnyUrl]) - async def test_output_schema(self, annotation): + @pytest.mark.parametrize("annotation", [str, int, float, bool, list, AnyUrl]) + async def test_simple_output_schema(self, annotation): mcp = FastMCP() @mcp.tool @@ -871,8 +889,62 @@ class TestToolOutputSchema: async with Client(mcp) as client: tools = await client.list_tools() assert len(tools) == 1 + + type_schema = TypeAdapter(annotation).json_schema() # this line will fail until MCP adds output schemas!! - assert tools[0].outputSchema == TypeAdapter(annotation).json_schema() # type: ignore + assert tools[0].outputSchema == { + "type": "object", + "properties": {"result": type_schema}, + "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} + + async with Client(mcp) as client: + tools = await client.list_tools() + + type_schema = TypeAdapter(annotation).json_schema() + assert len(tools) == 1 + assert tools[0].outputSchema == type_schema + + async def test_disabled_output_schema_no_structured_content(self): + mcp = FastMCP() + + @mcp.tool(output_schema=None) + def f() -> dict[str, str]: + return {"message": "Hello, world!"} + + async with Client(mcp) as client: + result = await client.call_tool("f", {}) + assert json.loads(result.content[0].text) == {"message": "Hello, world!"} # type: ignore[attr-defined] + assert result.structured_content is None + assert result.data is None + + async def test_manual_structured_content(self): + mcp = FastMCP() + + @mcp.tool + def f() -> ToolResult: + return ToolResult( + content="Hello, world!", structured_content={"message": "Hello, world!"} + ) + + assert f.output_schema is None + + async with Client(mcp) as client: + result = await client.call_tool("f", {}) + assert result.content[0].text == "Hello, world!" # type: ignore[attr-defined] + assert result.structured_content == {"message": "Hello, world!"} + assert result.data == {"message": "Hello, world!"} class TestToolContextInjection: @@ -903,9 +975,7 @@ class TestToolContextInjection: async with Client(mcp) as client: result = await client.call_tool("tool_with_context", {"x": 42}) - assert len(result) == 1 - content = result[0] - assert content.text == "1" # type: ignore[attr-defined] + assert result.data == "1" async def test_async_context(self): """Test that context works in async functions.""" @@ -918,9 +988,7 @@ class TestToolContextInjection: async with Client(mcp) as client: result = await client.call_tool("async_tool", {"x": 42}) - assert len(result) == 1 - content = result[0] - assert content.text == "Async request 1: 42" # type: ignore[attr-defined] + assert result.data == "Async request 1: 42" async def test_optional_context(self): """Test that context is optional.""" @@ -932,9 +1000,7 @@ class TestToolContextInjection: async with Client(mcp) as client: result = await client.call_tool("no_context", {"x": 21}) - assert len(result) == 1 - content = result[0] - assert content.text == "42" # type: ignore[attr-defined] + assert result.data == 42 async def test_context_resource_access(self): """Test that context can access resources.""" @@ -954,9 +1020,9 @@ class TestToolContextInjection: async with Client(mcp) as client: result = await client.call_tool("tool_with_resource", {}) - assert len(result) == 1 - content = result[0] - assert "Read resource: resource data" in content.text # type: ignore[attr-defined] + assert ( + result.data == "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.""" @@ -984,7 +1050,7 @@ class TestToolContextInjection: async with Client(mcp) as client: result = await client.call_tool("MyTool", {"x": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + assert result.data == 3 class TestToolEnabled: diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index 07a21077d..d0f03876c 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -218,8 +218,4 @@ async def test_tool_functionality_with_annotations(): result = await client.call_tool( "create_item", {"name": "test_item", "value": 42} ) - assert len(result) == 1 - - # The result should contain the expected JSON - assert '"name": "test_item"' in result[0].text # type: ignore[attr-defined] - assert '"value": 42' in result[0].text # type: ignore[attr-defined] + assert result.data == {"name": "test_item", "value": 42} diff --git a/tests/server/test_tool_exclude_args.py b/tests/server/test_tool_exclude_args.py index 025555ebc..fec695f88 100644 --- a/tests/server/test_tool_exclude_args.py +++ b/tests/server/test_tool_exclude_args.py @@ -1,7 +1,6 @@ from typing import Any import pytest -from mcp.types import TextContent from fastmcp import Client, FastMCP from fastmcp.tools.tool import Tool @@ -92,9 +91,4 @@ async def test_tool_functionality_with_exclude_args(): result = await client.call_tool( "create_item", {"name": "test_item", "value": 42} ) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - - # The result should contain the expected JSON - assert '"name": "test_item"' in result[0].text - assert '"value": 42' in result[0].text + assert result.data == {"name": "test_item", "value": 42} diff --git a/tests/test_examples.py b/tests/test_examples.py index 0fa1da3a4..0edeee96e 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -10,9 +10,9 @@ async def test_simple_echo(): from examples.simple_echo import mcp async with Client(mcp) as client: - result = await client.call_tool("echo", {"text": "hello"}) - assert len(result) == 1 - assert result[0].text == "hello" # type: ignore[attr-defined] + result = await client.call_tool_mcp("echo", {"text": "hello"}) + assert len(result.content) == 1 + assert result.content[0].text == "hello" # type: ignore[attr-defined] async def test_complex_inputs(): @@ -21,11 +21,11 @@ async def test_complex_inputs(): async with Client(mcp) as client: tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]} - result = await client.call_tool( + result = await client.call_tool_mcp( "name_shrimp", {"tank": tank, "extra_names": ["charlie"]} ) - assert len(result) == 1 - assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined] + assert len(result.content) == 1 + assert result.content[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined] async def test_desktop(monkeypatch): @@ -34,9 +34,9 @@ async def test_desktop(monkeypatch): async with Client(mcp) as client: # Test the add function - result = await client.call_tool("add", {"a": 1, "b": 2}) - assert len(result) == 1 - assert result[0].text == "3" # type: ignore[attr-defined] + result = await client.call_tool_mcp("add", {"a": 1, "b": 2}) + assert len(result.content) == 1 + assert result.content[0].text == "3" # type: ignore[attr-defined] async with Client(mcp) as client: result = await client.read_resource(AnyUrl("greeting://rooter12")) @@ -49,9 +49,9 @@ async def test_echo(): from examples.echo import mcp async with Client(mcp) as client: - result = await client.call_tool("echo_tool", {"text": "hello"}) - assert len(result) == 1 - assert result[0].text == "hello" # type: ignore[attr-defined] + result = await client.call_tool_mcp("echo_tool", {"text": "hello"}) + assert len(result.content) == 1 + assert result.content[0].text == "hello" # type: ignore[attr-defined] async with Client(mcp) as client: result = await client.read_resource(AnyUrl("echo://static")) diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 1561323e2..eb5b045a0 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -31,7 +31,15 @@ class TestToolFromFunction: assert len(tool.parameters["properties"]) == 2 assert tool.parameters["properties"]["a"]["type"] == "integer" assert tool.parameters["properties"]["b"]["type"] == "integer" - assert tool.output_schema == {"type": "integer"} + # With primitive wrapping, int return type becomes object with value property + expected_schema = { + "type": "object", + "properties": {"value": {"title": "Value", "type": "integer"}}, + "required": ["value"], + "title": "Result", + "x-fastmcp-wrap-result": True, + } + assert tool.output_schema == expected_schema async def test_async_function(self): """Test registering and running an async function.""" @@ -103,7 +111,7 @@ class TestToolFromFunction: result = await tool.run({"data": "test.png"}) assert tool.parameters["properties"]["data"]["type"] == "string" - assert isinstance(result[0], ImageContent) + assert isinstance(result.content[0], ImageContent) async def test_tool_with_audio_return(self): def audio_tool(data: bytes) -> Audio: @@ -113,7 +121,7 @@ class TestToolFromFunction: result = await tool.run({"data": "test.wav"}) assert tool.parameters["properties"]["data"]["type"] == "string" - assert isinstance(result[0], AudioContent) + assert isinstance(result.content[0], AudioContent) async def test_tool_with_file_return(self): def file_tool(data: bytes) -> File: @@ -123,11 +131,11 @@ class TestToolFromFunction: result = await tool.run({"data": "test.bin"}) assert tool.parameters["properties"]["data"]["type"] == "string" - assert len(result) == 1 - assert isinstance(result[0], EmbeddedResource) - assert result[0].type == "resource" - assert hasattr(result[0], "resource") - resource = result[0].resource + assert len(result.content) == 1 + assert isinstance(result.content[0], EmbeddedResource) + assert result.content[0].type == "resource" + assert hasattr(result.content[0], "resource") + resource = result.content[0].resource assert resource.mimeType == "application/octet-stream" def test_non_callable_fn(self): @@ -239,8 +247,11 @@ class TestToolFromFunction: tool = Tool.from_function(process_list, serializer=custom_serializer) result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]}) - assert isinstance(result[0], TextContent) - assert result[0].text == "Custom serializer: 15" + # Custom serializer affects unstructured content + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Custom serializer: 15" + # Structured output should have the raw value + assert result.structured_content == {"value": 15} class TestToolFromFunctionOutputSchema: @@ -273,7 +284,31 @@ class TestToolFromFunctionOutputSchema: return 1 tool = Tool.from_function(func) - assert tool.output_schema == TypeAdapter(annotation).json_schema() + + base_schema = TypeAdapter(annotation).json_schema() + + # Only pure primitives (just type + optional title) get wrapped + primitive_types = {"string", "number", "integer", "boolean", "null"} + schema_type = base_schema.get("type") + is_pure_primitive = ( + schema_type in primitive_types + and len(base_schema) <= 2 # Only 'type' and optionally 'title' + and all(key in {"type", "title"} for key in base_schema.keys()) + ) + + if is_pure_primitive: + # Pure primitives get wrapped + expected_schema = { + "type": "object", + "properties": {"value": base_schema | {"title": "Value"}}, + "required": ["value"], + "title": "Result", + "x-fastmcp-wrap-result": True, + } + assert tool.output_schema == expected_schema + else: + # Complex types (objects, unions, constrained types) remain unwrapped + assert tool.output_schema == base_schema @pytest.mark.parametrize( "annotation", @@ -289,7 +324,10 @@ class TestToolFromFunctionOutputSchema: return 1 tool = Tool.from_function(func) - assert tool.output_schema == TypeAdapter(annotation).json_schema() + base_schema = TypeAdapter(annotation).json_schema() + + # Complex types with constraints are not wrapped - they remain as-is + assert tool.output_schema == base_schema @pytest.mark.parametrize( "annotation, expected", @@ -307,7 +345,8 @@ class TestToolFromFunctionOutputSchema: return 1 tool = Tool.from_function(func) - assert tool.output_schema == TypeAdapter(expected).json_schema() + # 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 diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index de6ab041e..152b305a4 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -125,7 +125,8 @@ class TestAddTools: tool = await manager.get_tool("image_tool") result = await tool.run({"data": "test.png"}) assert tool.parameters["properties"]["data"]["type"] == "string" - assert isinstance(result[0], ImageContent) + assert isinstance(result.content[0], ImageContent) + assert result.structured_content is None def test_add_noncallable_tool(self): manager = ToolManager() @@ -353,7 +354,8 @@ class TestCallTools: manager.add_tool(tool) result = await manager.call_tool("add", {"a": 1, "b": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + assert result.content[0].text == "3" # type: ignore[attr-defined] + assert result.structured_content == {"value": 3} async def test_call_async_tool(self): async def double(n: int) -> int: @@ -364,7 +366,8 @@ class TestCallTools: tool = Tool.from_function(double) manager.add_tool(tool) result = await manager.call_tool("double", {"n": 5}) - assert result[0].text == "10" # type: ignore[attr-defined] + assert result.content[0].text == "10" # type: ignore[attr-defined] + assert result.structured_content == {"value": 10} async def test_call_tool_callable_object(self): class Adder: @@ -378,7 +381,8 @@ class TestCallTools: tool = Tool.from_function(Adder()) manager.add_tool(tool) result = await manager.call_tool("Adder", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + assert result.content[0].text == "3" # type: ignore[attr-defined] + assert result.structured_content == {"value": 3} async def test_call_tool_callable_object_async(self): class Adder: @@ -392,7 +396,8 @@ class TestCallTools: tool = Tool.from_function(Adder()) manager.add_tool(tool) result = await manager.call_tool("Adder", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + assert result.content[0].text == "3" # type: ignore[attr-defined] + assert result.structured_content == {"value": 3} async def test_call_tool_with_default_args(self): def add(a: int, b: int = 1) -> int: @@ -404,7 +409,8 @@ class TestCallTools: manager.add_tool(tool) result = await manager.call_tool("add", {"a": 1}) - assert result[0].text == "2" # type: ignore[attr-defined] + assert result.content[0].text == "2" # type: ignore[attr-defined] + assert result.structured_content == {"value": 2} async def test_call_tool_with_missing_args(self): def add(a: int, b: int) -> int: @@ -431,7 +437,8 @@ class TestCallTools: manager.add_tool(tool) result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}) - assert result[0].text == "6" # type: ignore[attr-defined] + assert result.content[0].text == "6" # type: ignore[attr-defined] + assert result.structured_content == {"value": 6} async def test_call_tool_with_list_str_or_str_input(self): def concat_strs(vals: list[str] | str) -> str: @@ -443,10 +450,12 @@ class TestCallTools: # Try both with plain python object and with JSON list result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]}) - assert result[0].text == "abc" # type: ignore[attr-defined] + assert result.content[0].text == "abc" # type: ignore[attr-defined] + assert result.structured_content == {"value": "abc"} result = await manager.call_tool("concat_strs", {"vals": "a"}) - assert result[0].text == "a" # type: ignore[attr-defined] + assert result.content[0].text == "a" # type: ignore[attr-defined] + assert result.structured_content == {"value": "a"} async def test_call_tool_with_complex_model(self): class MyShrimpTank(BaseModel): @@ -477,7 +486,8 @@ class TestCallTools: }, ) - assert result[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined] + assert result.content[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined] + assert result.structured_content == {"value": ["rex", "gertrude"]} async def test_call_tool_with_custom_serializer(self): """Test that a custom serializer provided to FastMCP is used by tools.""" @@ -496,7 +506,8 @@ class TestCallTools: return {"key": "value", "number": 123} result = await manager.call_tool("get_data", {}) - assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined] + assert result.content[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined] + assert result.structured_content == {"key": "value", "number": 123} async def test_call_tool_with_list_result_custom_serializer(self): """Test that a custom serializer provided to FastMCP is used by tools that return lists.""" @@ -518,9 +529,15 @@ class TestCallTools: result = await manager.call_tool("get_data", {}) assert ( - result[0].text # type: ignore[attr-defined] + result.content[0].text # type: ignore[attr-defined] == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined] ) + assert result.structured_content == { + "value": [ + {"key": "value", "number": 123}, + {"key": "value2", "number": 456}, + ] + } async def test_custom_serializer_fallback_on_error(self): """Test that a broken custom serializer gracefully falls back.""" @@ -538,7 +555,11 @@ class TestCallTools: return uuid_result result = await manager.call_tool("get_data", {}) - assert result[0].text == pydantic_core.to_json(uuid_result).decode() # type: ignore[attr-defined] + assert ( + result.content[0].text # type: ignore[attr-defined] + == pydantic_core.to_json(uuid_result).decode() + ) + assert result.structured_content == {"value": str(uuid_result)} class TestToolSchema: @@ -608,7 +629,8 @@ class TestContextHandling: async with context: result = await manager.call_tool("tool_with_context", {"x": 42}) - assert result[0].text == "42" # type: ignore[attr-defined] + assert result.content[0].text == "42" # type: ignore[attr-defined] + assert result.structured_content == {"value": "42"} async def test_context_injection_async(self): """Test that context is properly injected in async tools.""" @@ -626,7 +648,8 @@ class TestContextHandling: async with context: result = await manager.call_tool("async_tool", {"x": 42}) - assert result[0].text == "42" # type: ignore[attr-defined] + assert result.content[0].text == "42" # type: ignore[attr-defined] + assert result.structured_content == {"value": "42"} async def test_context_optional(self): """Test that context is optional when calling tools.""" @@ -644,7 +667,8 @@ class TestContextHandling: async with context: result = await manager.call_tool("tool_with_context", {"x": 42}) - assert result[0].text == "42" # type: ignore[attr-defined] + assert result.content[0].text == "42" # type: ignore[attr-defined] + assert result.structured_content == {"value": 42} def test_parameterized_context_parameter_detection(self): """Test that context parameters are properly detected in @@ -752,7 +776,8 @@ class TestCustomToolNames: # Tool should be callable by its custom name result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3}) - assert result[0].text == "15" # type: ignore[attr-defined] + assert result.content[0].text == "15" # type: ignore[attr-defined] + assert result.structured_content == {"value": 15} # Original name should not be registered with pytest.raises(NotFoundError, match="Tool 'multiply' not found"): diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index 0498c6c5c..ab72352d0 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -52,7 +52,8 @@ async def test_tool_defaults_are_maintained_on_unmapped_args(add_tool): add_tool, transform_args={"old_x": ArgTransform(name="new_x")} ) result = await new_tool.run(arguments={"new_x": 1}) - assert result[0].text == "11" # type: ignore[attr-defined] + # The parent tool returns int which gets wrapped as structured output + assert result.structured_content == {"value": 11} async def test_tool_defaults_are_maintained_on_mapped_args(add_tool): @@ -60,7 +61,8 @@ async def test_tool_defaults_are_maintained_on_mapped_args(add_tool): add_tool, transform_args={"old_y": ArgTransform(name="new_y")} ) result = await new_tool.run(arguments={"old_x": 1}) - assert result[0].text == "11" # type: ignore[attr-defined] + # The parent tool returns int which gets wrapped as structured output + assert result.structured_content == {"value": 11} def test_tool_change_arg_name(add_tool): @@ -87,7 +89,7 @@ async def test_tool_drop_arg(add_tool): ) assert sorted(new_tool.parameters["properties"]) == ["old_x"] result = await new_tool.run(arguments={"old_x": 1}) - assert result[0].text == "11" # type: ignore[attr-defined] + assert result.structured_content == {"value": 11} async def test_dropped_args_error_if_provided(add_tool): @@ -109,7 +111,7 @@ async def test_hidden_arg_with_constant_default(add_tool): 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[0].text == "25" # type: ignore[attr-defined] + assert result.structured_content == {"value": 25} async def test_hidden_arg_without_default_uses_parent_default(add_tool): @@ -121,7 +123,8 @@ async def test_hidden_arg_without_default_uses_parent_default(add_tool): 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 result[0].text == "13" # type: ignore[attr-defined] + assert result.content[0].text == "13" # type: ignore[attr-defined] + assert result.structured_content == {"value": 13} async def test_mixed_hidden_args_with_custom_function(add_tool): @@ -146,7 +149,8 @@ async def test_mixed_hidden_args_with_custom_function(add_tool): 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 result[0].text == "32" # type: ignore[attr-defined] + assert result.content[0].text == "32" # type: ignore[attr-defined] + assert result.structured_content == {"value": 32} async def test_hide_required_param_without_default_raises_error(): @@ -184,7 +188,7 @@ async def test_hide_required_param_with_user_default_works(): 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[0].text == "25" # type: ignore[attr-defined] + assert result.structured_content == {"value": 25} async def test_forward_with_argument_mapping(add_tool): @@ -203,7 +207,8 @@ async def test_forward_with_argument_mapping(add_tool): ) result = await new_tool.run(arguments={"new_x": 2, "new_y": 3}) - assert result[0].text == "5" # type: ignore[attr-defined] + assert result.content[0].text == "5" # type: ignore[attr-defined] + assert result.structured_content == {"value": 5} async def test_forward_with_incorrect_args_raises_error(add_tool): @@ -243,7 +248,8 @@ async def test_forward_raw_without_argument_mapping(add_tool): ) result = await new_tool.run(arguments={"new_x": 2, "new_y": 3}) - assert result[0].text == "5" # type: ignore[attr-defined] + assert result.content[0].text == "5" # type: ignore[attr-defined] + assert result.structured_content == {"value": 5} async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool): @@ -253,7 +259,8 @@ async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool): 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 result[0].text == "6" # type: ignore[attr-defined] + assert result.content[0].text == "6" # type: ignore[attr-defined] + assert result.structured_content == {"value": 6} assert new_tool.parameters["required"] == IsList( "extra", "old_x", check_order=False ) @@ -270,7 +277,8 @@ async def test_fn_with_kwargs_passes_through_original_args(add_tool): new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn) result = await new_tool.run(arguments={"new_y": 2, "old_y": 3}) - assert result[0].text == "5" # type: ignore[attr-defined] + assert result.content[0].text == "5" # type: ignore[attr-defined] + assert result.structured_content == {"value": 5} async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool): @@ -288,7 +296,8 @@ async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool): transform_args={"old_x": ArgTransform(name="new_x")}, ) result = await new_tool.run(arguments={"new_x": 2, "old_y": 3}) - assert result[0].text == "5" # type: ignore[attr-defined] + assert result.content[0].text == "5" # type: ignore[attr-defined] + assert result.structured_content == {"value": 5} async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool): @@ -308,7 +317,8 @@ async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool): result = await new_tool.run( arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"} ) - assert result[0].text == "10" # type: ignore[attr-defined] + assert result.content[0].text == "10" # type: ignore[attr-defined] + assert result.structured_content == {"value": 10} async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool): @@ -326,7 +336,8 @@ async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool): transform_args={"old_x": ArgTransform(name="new_x")}, ) # only map 'a' result = await new_tool.run(arguments={"new_x": 1, "old_y": 5}) - assert result[0].text == "6" # type: ignore[attr-defined] + assert result.content[0].text == "6" # type: ignore[attr-defined] + assert result.structured_content == {"value": 6} async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool): diff --git a/tests/utilities/test_json_schema_type.py b/tests/utilities/test_json_schema_type.py new file mode 100644 index 000000000..14bf949fe --- /dev/null +++ b/tests/utilities/test_json_schema_type.py @@ -0,0 +1,1418 @@ +from datetime import datetime +from typing import Union + +import pytest +from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError + +from fastmcp.utilities.json_schema_type import ( + _hash_schema, + _merge_defaults, + json_schema_to_type, +) + + +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 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"], + } + }, + } + ) + + 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}}) + + +class TestDefaultValues: + """Test suite for default value handling.""" + + @pytest.fixture + def simple_defaults(self): + return json_schema_to_type( + { + "type": "object", + "properties": { + "name": {"type": "string", "default": "anonymous"}, + "age": {"type": "integer", "default": 0}, + }, + } + ) + + @pytest.fixture + def nested_defaults(self): + return json_schema_to_type( + { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string", "default": "anonymous"}, + "settings": { + "type": "object", + "properties": { + "theme": {"type": "string", "default": "light"} + }, + "default": {"theme": "dark"}, + }, + }, + "default": {"name": "guest", "settings": {"theme": "system"}}, + } + }, + } + ) + + def test_simple_defaults_empty_object(self, simple_defaults): + validator = TypeAdapter(simple_defaults) + result = validator.validate_python({}) + assert result.name == "anonymous" + assert result.age == 0 + + def test_simple_defaults_partial_override(self, simple_defaults): + validator = TypeAdapter(simple_defaults) + result = validator.validate_python({"name": "test"}) + assert result.name == "test" + assert result.age == 0 + + def test_nested_defaults_empty_object(self, nested_defaults): + validator = TypeAdapter(nested_defaults) + result = validator.validate_python({}) + assert result.user.name == "guest" + assert result.user.settings.theme == "system" + + def test_nested_defaults_partial_override(self, nested_defaults): + validator = TypeAdapter(nested_defaults) + result = validator.validate_python({"user": {"name": "test"}}) + assert result.user.name == "test" + 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.""" + + @pytest.fixture + def self_referential(self): + return json_schema_to_type( + { + "type": "object", + "properties": {"name": {"type": "string"}, "child": {"$ref": "#"}}, + } + ) + + @pytest.fixture + def mutually_recursive(self): + return json_schema_to_type( + { + "type": "object", + "definitions": { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "friend": {"$ref": "#/definitions/Pet"}, + }, + }, + "Pet": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "owner": {"$ref": "#/definitions/Person"}, + }, + }, + }, + "properties": {"person": {"$ref": "#/definitions/Person"}}, + } + ) + + def test_self_ref_single_level(self, self_referential): + validator = TypeAdapter(self_referential) + result = validator.validate_python( + {"name": "parent", "child": {"name": "child"}} + ) + assert result.name == "parent" + assert result.child.name == "child" + assert result.child.child is None + + def test_self_ref_multiple_levels(self, self_referential): + validator = TypeAdapter(self_referential) + result = validator.validate_python( + { + "name": "grandparent", + "child": {"name": "parent", "child": {"name": "child"}}, + } + ) + assert result.name == "grandparent" + assert result.child.name == "parent" + assert result.child.child.name == "child" + + def test_mutual_recursion_single_level(self, mutually_recursive): + validator = TypeAdapter(mutually_recursive) + result = validator.validate_python( + {"person": {"name": "Alice", "friend": {"name": "Spot"}}} + ) + assert result.person.name == "Alice" + assert result.person.friend.name == "Spot" + assert result.person.friend.owner is None + + def test_mutual_recursion_multiple_levels(self, mutually_recursive): + validator = TypeAdapter(mutually_recursive) + result = validator.validate_python( + { + "person": { + "name": "Alice", + "friend": {"name": "Spot", "owner": {"name": "Bob"}}, + } + } + ) + assert result.person.name == "Alice" + assert result.person.friend.name == "Spot" + assert result.person.friend.owner.name == "Bob" + + +class TestIdentifierNormalization: + """Test suite for handling non-standard property names.""" + + @pytest.fixture + def special_chars(self): + return json_schema_to_type( + { + "type": "object", + "properties": { + "@type": {"type": "string"}, + "first-name": {"type": "string"}, + "last.name": {"type": "string"}, + "2nd_address": {"type": "string"}, + "$ref": {"type": "string"}, + }, + } + ) + + def test_normalizes_special_chars(self, special_chars): + validator = TypeAdapter(special_chars) + result = validator.validate_python( + { + "@type": "person", + "first-name": "Alice", + "last.name": "Smith", + "2nd_address": "456 Oak St", + "$ref": "12345", + } + ) + assert result.field_type == "person" # @type -> field_type + assert result.first_name == "Alice" # first-name -> first_name + assert result.last_name == "Smith" # last.name -> last_name + assert ( + result.field_2nd_address == "456 Oak St" + ) # 2nd_address -> field_2nd_address + assert result.field_ref == "12345" # $ref -> field_ref + + +class TestConstantValues: + """Test suite for constant value validation.""" + + @pytest.fixture + def string_const(self): + return json_schema_to_type({"type": "string", "const": "production"}) + + @pytest.fixture + def number_const(self): + return json_schema_to_type({"type": "number", "const": 42.5}) + + @pytest.fixture + def boolean_const(self): + return json_schema_to_type({"type": "boolean", "const": True}) + + @pytest.fixture + def null_const(self): + return json_schema_to_type({"type": "null", "const": None}) + + @pytest.fixture + def object_with_consts(self): + return json_schema_to_type( + { + "type": "object", + "properties": { + "env": {"const": "production"}, + "version": {"const": 1}, + "enabled": {"const": True}, + }, + } + ) + + def test_string_const_valid(self, string_const): + validator = TypeAdapter(string_const) + assert validator.validate_python("production") == "production" + + def test_string_const_invalid(self, string_const): + validator = TypeAdapter(string_const) + with pytest.raises(ValidationError): + validator.validate_python("development") + + def test_number_const_valid(self, number_const): + validator = TypeAdapter(number_const) + assert validator.validate_python(42.5) == 42.5 + + def test_number_const_invalid(self, number_const): + validator = TypeAdapter(number_const) + with pytest.raises(ValidationError): + validator.validate_python(42) + + def test_boolean_const_valid(self, boolean_const): + validator = TypeAdapter(boolean_const) + assert validator.validate_python(True) is True + + def test_boolean_const_invalid(self, boolean_const): + validator = TypeAdapter(boolean_const) + with pytest.raises(ValidationError): + validator.validate_python(False) + + def test_null_const_valid(self, null_const): + validator = TypeAdapter(null_const) + assert validator.validate_python(None) is None + + def test_null_const_invalid(self, null_const): + validator = TypeAdapter(null_const) + with pytest.raises(ValidationError): + validator.validate_python(False) + + def test_object_consts_valid(self, object_with_consts): + validator = TypeAdapter(object_with_consts) + result = validator.validate_python( + {"env": "production", "version": 1, "enabled": True} + ) + assert result.env == "production" + assert result.version == 1 + assert result.enabled is True + + def test_object_consts_invalid(self, object_with_consts): + validator = TypeAdapter(object_with_consts) + with pytest.raises(ValidationError): + validator.validate_python( + { + "env": "production", + "version": 2, # Wrong constant + "enabled": True, + } + ) + + +class TestSchemaCaching: + """Test suite for schema caching behavior.""" + + def test_identical_schemas_reuse_class(self): + schema = {"type": "object", "properties": {"name": {"type": "string"}}} + + class1 = json_schema_to_type(schema) + class2 = json_schema_to_type(schema) + assert class1 is class2 + + def test_different_names_different_classes(self): + schema = {"type": "object", "properties": {"name": {"type": "string"}}} + + class1 = json_schema_to_type(schema, name="Class1") + class2 = json_schema_to_type(schema, name="Class2") + assert class1 is not class2 + assert class1.__name__ == "Class1" + assert class2.__name__ == "Class2" + + def test_nested_schema_caching(self): + schema = { + "type": "object", + "properties": { + "nested": {"type": "object", "properties": {"name": {"type": "string"}}} + }, + } + + class1 = json_schema_to_type(schema) + class2 = json_schema_to_type(schema) + + # Both main classes and their nested classes should be identical + assert class1 is class2 + assert ( + class1.__dataclass_fields__["nested"].type + is class2.__dataclass_fields__["nested"].type + ) + + +class TestSchemaHashing: + """Test suite for schema hashing utility.""" + + def test_deterministic_hash(self): + schema = {"type": "object", "properties": {"name": {"type": "string"}}} + hash1 = _hash_schema(schema) + hash2 = _hash_schema(schema) + assert hash1 == hash2 + assert isinstance(hash1, str) + assert len(hash1) == 64 # SHA-256 hash length + + def test_different_schemas_different_hashes(self): + schema1 = {"type": "object", "properties": {"name": {"type": "string"}}} + schema2 = {"type": "object", "properties": {"age": {"type": "integer"}}} + assert _hash_schema(schema1) != _hash_schema(schema2) + + def test_order_independent_hash(self): + schema1 = {"properties": {"name": {"type": "string"}}, "type": "object"} + schema2 = {"type": "object", "properties": {"name": {"type": "string"}}} + assert _hash_schema(schema1) == _hash_schema(schema2) + + def test_nested_schema_hash(self): + schema = { + "type": "object", + "properties": { + "nested": {"type": "object", "properties": {"name": {"type": "string"}}} + }, + } + hash1 = _hash_schema(schema) + assert isinstance(hash1, str) + assert len(hash1) == 64 + + +class TestDefaultMerging: + """Test suite for default value merging behavior.""" + + def test_simple_merge(self): + defaults = {"name": "anonymous", "age": 0} + data = {"name": "test"} + result = _merge_defaults(data, {"properties": {}}, defaults) + assert result["name"] == "test" + assert result["age"] == 0 + + def test_nested_merge(self): + defaults = {"user": {"name": "anonymous", "settings": {"theme": "light"}}} + data = {"user": {"name": "test"}} + result = _merge_defaults(data, {"properties": {}}, defaults) + assert result["user"]["name"] == "test" + assert result["user"]["settings"]["theme"] == "light" + + def test_array_merge(self): + defaults = { + "items": [ + {"name": "item1", "done": False}, + {"name": "item2", "done": False}, + ] + } + data = {"items": [{"name": "custom", "done": True}]} + result = _merge_defaults(data, {"properties": {}}, defaults) + assert len(result["items"]) == 1 + assert result["items"][0]["name"] == "custom" + assert result["items"][0]["done"] is True + + def test_empty_data_uses_defaults(self): + schema = { + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string", "default": "anonymous"}, + "settings": {"type": "object", "default": {"theme": "light"}}, + }, + "default": {"name": "guest", "settings": {"theme": "dark"}}, + } + } + } + result = _merge_defaults({}, schema) + assert result["user"]["name"] == "guest" + assert result["user"]["settings"]["theme"] == "dark" + + def test_property_level_defaults(self): + schema = { + "properties": { + "name": {"type": "string", "default": "anonymous"}, + "age": {"type": "integer", "default": 0}, + } + } + result = _merge_defaults({}, schema) + assert result["name"] == "anonymous" + assert result["age"] == 0 + + def test_nested_property_defaults(self): + schema = { + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string", "default": "anonymous"}, + "settings": { + "type": "object", + "properties": { + "theme": {"type": "string", "default": "light"} + }, + }, + }, + } + } + } + result = _merge_defaults({"user": {"settings": {}}}, schema) + assert result["user"]["name"] == "anonymous" + assert result["user"]["settings"]["theme"] == "light" + + def test_default_priority(self): + schema = { + "properties": { + "settings": { + "type": "object", + "properties": {"theme": {"type": "string", "default": "light"}}, + "default": {"theme": "dark"}, + } + }, + "default": {"settings": {"theme": "system"}}, + } + + # Test priority: data > parent default > object default > property default + result1 = _merge_defaults({}, schema) # Uses schema default + assert result1["settings"]["theme"] == "system" + + result2 = _merge_defaults({"settings": {}}, schema) # Uses object default + assert result2["settings"]["theme"] == "dark" + + result3 = _merge_defaults( + {"settings": {"theme": "custom"}}, schema + ) # Uses provided data + assert result3["settings"]["theme"] == "custom" + + +class TestEdgeCases: + """Test suite for edge cases and corner scenarios.""" + + def test_empty_schema(self): + schema = {} + result = json_schema_to_type(schema) + assert result is object + + def test_schema_without_type(self): + schema = {"properties": {"name": {"type": "string"}}} + Type = json_schema_to_type(schema) + validator = TypeAdapter(Type) + result = validator.validate_python({"name": "test"}) + assert result.name == "test" + + def test_recursive_defaults(self): + schema = { + "type": "object", + "properties": { + "node": { + "type": "object", + "properties": {"value": {"type": "string"}, "next": {"$ref": "#"}}, + "default": {"value": "default", "next": None}, + } + }, + } + Type = json_schema_to_type(schema) + validator = TypeAdapter(Type) + result = validator.validate_python({}) + assert result.node.value == "default" + assert result.node.next is None + + def test_mixed_type_array(self): + schema = { + "type": "array", + "items": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}], + } + Type = json_schema_to_type(schema) + validator = TypeAdapter(Type) + result = validator.validate_python(["test", 123, True]) + assert result == ["test", 123, True] + + +class TestNameHandling: + """Test suite for schema name handling.""" + + def test_name_from_title(self): + schema = { + "type": "object", + "title": "Person", + "properties": {"name": {"type": "string"}}, + } + Type = json_schema_to_type(schema) + assert Type.__name__ == "Person" + + def test_explicit_name_overrides_title(self): + schema = { + "type": "object", + "title": "Person", + "properties": {"name": {"type": "string"}}, + } + Type = json_schema_to_type(schema, name="CustomPerson") + assert Type.__name__ == "CustomPerson" + + def test_default_name_without_title(self): + schema = {"type": "object", "properties": {"name": {"type": "string"}}} + Type = json_schema_to_type(schema) + assert Type.__name__ == "Root" + + def test_name_only_allowed_for_objects(self): + schema = {"type": "string"} + with pytest.raises(ValueError, match="Can not apply name to non-object schema"): + json_schema_to_type(schema, name="StringType") + + def test_nested_object_names(self): + schema = { + "type": "object", + "title": "Parent", + "properties": { + "child": { + "type": "object", + "title": "Child", + "properties": {"name": {"type": "string"}}, + } + }, + } + Type = json_schema_to_type(schema) + assert Type.__name__ == "Parent" + assert Type.__dataclass_fields__["child"].type.__origin__ is Union + assert Type.__dataclass_fields__["child"].type.__args__[0].__name__ == "Child" + assert Type.__dataclass_fields__["child"].type.__args__[1] is type(None) + + def test_recursive_schema_naming(self): + schema = { + "type": "object", + "title": "Node", + "properties": {"next": {"$ref": "#"}}, + } + Type = json_schema_to_type(schema) + assert Type.__name__ == "Node" + assert Type.__dataclass_fields__["next"].type.__origin__ is Union + assert ( + Type.__dataclass_fields__["next"].type.__args__[0].__forward_arg__ == "Node" + ) + assert Type.__dataclass_fields__["next"].type.__args__[1] is type(None) + + def test_name_caching_with_different_titles(self): + """Ensure schemas with different titles create different cached classes""" + schema1 = { + "type": "object", + "title": "Type1", + "properties": {"name": {"type": "string"}}, + } + schema2 = { + "type": "object", + "title": "Type2", + "properties": {"name": {"type": "string"}}, + } + Type1 = json_schema_to_type(schema1) + Type2 = json_schema_to_type(schema2) + assert Type1 is not Type2 + assert Type1.__name__ == "Type1" + assert Type2.__name__ == "Type2" + + def test_recursive_schema_with_invalid_python_name(self): + """Test that recursive schemas work with titles that aren't valid Python identifiers""" + schema = { + "type": "object", + "title": "My Complex Type!", + "properties": {"name": {"type": "string"}, "child": {"$ref": "#"}}, + } + Type = json_schema_to_type(schema) + # The class should get a sanitized name + assert Type.__name__ == "My_Complex_Type" + # Create an instance to verify the recursive reference works + validator = TypeAdapter(Type) + result = validator.validate_python( + {"name": "parent", "child": {"name": "child", "child": None}} + ) + assert result.name == "parent" + assert result.child.name == "child" + assert result.child.child is None + + +class TestAdditionalProperties: + """Test suite for additionalProperties handling.""" + + @pytest.fixture + def dict_only_schema(self): + """Schema with no properties but additionalProperties=True -> dict[str, Any]""" + return json_schema_to_type({"type": "object", "additionalProperties": True}) + + @pytest.fixture + def properties_with_additional(self): + """Schema with properties AND additionalProperties=True -> BaseModel""" + return json_schema_to_type( + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "additionalProperties": True, + } + ) + + @pytest.fixture + def properties_without_additional(self): + """Schema with properties but no additionalProperties -> dataclass""" + return json_schema_to_type( + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + } + ) + + @pytest.fixture + def required_properties_with_additional(self): + """Schema with required properties AND additionalProperties=True -> BaseModel""" + return json_schema_to_type( + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name"], + "additionalProperties": True, + } + ) + + def test_dict_only_returns_dict_type(self, dict_only_schema): + """Test that schema with no properties + additionalProperties=True returns dict[str, Any]""" + import typing + + assert dict_only_schema == dict[str, typing.Any] + + def test_dict_only_accepts_any_data(self, dict_only_schema): + """Test that pure dict accepts arbitrary key-value pairs""" + validator = TypeAdapter(dict_only_schema) + data = {"anything": "works", "numbers": 123, "nested": {"key": "value"}} + result = validator.validate_python(data) + assert result == data + assert isinstance(result, dict) + + def test_properties_with_additional_returns_basemodel( + self, properties_with_additional + ): + """Test that schema with properties + additionalProperties=True returns BaseModel""" + assert issubclass(properties_with_additional, BaseModel) + + def test_properties_with_additional_accepts_extra_fields( + self, properties_with_additional + ): + """Test that BaseModel with extra='allow' accepts additional properties""" + validator = TypeAdapter(properties_with_additional) + data = { + "name": "Alice", + "age": 30, + "extra": "field", + "another": {"nested": "data"}, + } + result = validator.validate_python(data) + + # Check standard properties + assert result.name == "Alice" + assert result.age == 30 + + # Check extra properties are preserved with dot access + assert hasattr(result, "extra") + assert result.extra == "field" + assert hasattr(result, "another") + assert result.another == {"nested": "data"} + + def test_properties_with_additional_validates_known_fields( + self, properties_with_additional + ): + """Test that BaseModel still validates known fields""" + validator = TypeAdapter(properties_with_additional) + + # Should accept valid data + result = validator.validate_python({"name": "Alice", "age": 30, "extra": "ok"}) + assert result.name == "Alice" + assert result.age == 30 + assert result.extra == "ok" + + # Should reject invalid types for known fields + with pytest.raises(ValidationError): + validator.validate_python({"name": "Alice", "age": "not_a_number"}) + + def test_properties_without_additional_is_dataclass( + self, properties_without_additional + ): + """Test that schema with properties but no additionalProperties returns dataclass""" + assert not issubclass(properties_without_additional, BaseModel) + assert hasattr(properties_without_additional, "__dataclass_fields__") + + def test_properties_without_additional_ignores_extra_fields( + self, properties_without_additional + ): + """Test that dataclass ignores extra properties (current behavior)""" + validator = TypeAdapter(properties_without_additional) + data = {"name": "Alice", "age": 30, "extra": "ignored"} + result = validator.validate_python(data) + + # Check standard properties + assert result.name == "Alice" + assert result.age == 30 + + # Check extra property is ignored + assert not hasattr(result, "extra") + + def test_required_properties_with_additional( + self, required_properties_with_additional + ): + """Test BaseModel with required fields and additional properties""" + validator = TypeAdapter(required_properties_with_additional) + + # Should accept valid data with required field + result = validator.validate_python({"name": "Alice", "extra": "field"}) + assert result.name == "Alice" + assert result.age is None # Optional field + assert result.extra == "field" + + # Should reject missing required field + with pytest.raises(ValidationError): + validator.validate_python({"age": 30, "extra": "field"}) + + def test_nested_additional_properties(self): + """Test nested objects with additionalProperties""" + schema = { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": True, + }, + "settings": { + "type": "object", + "properties": {"theme": {"type": "string"}}, + }, + }, + "additionalProperties": True, + } + + Type = json_schema_to_type(schema) + validator = TypeAdapter(Type) + + data = { + "user": {"name": "Alice", "extra_user_field": "value"}, + "settings": {"theme": "dark", "extra_settings_field": "ignored"}, + "top_level_extra": "preserved", + } + + result = validator.validate_python(data) + + # Check top-level extra field (BaseModel) + assert result.top_level_extra == "preserved" + + # Check nested user extra field (BaseModel) + assert result.user.name == "Alice" + assert result.user.extra_user_field == "value" + + # Check nested settings - should be dataclass + assert result.settings.theme == "dark" + # Note: When nested in BaseModel with extra='allow', Pydantic may preserve extra fields + # even on dataclass children. The important thing is that settings is still a dataclass. + assert not issubclass(type(result.settings), BaseModel) + + def test_additional_properties_false_vs_missing(self): + """Test difference between additionalProperties: false and missing additionalProperties""" + # Schema with explicit additionalProperties: false + schema_false = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": False, + } + + # Schema with no additionalProperties key + schema_missing = { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + + Type_false = json_schema_to_type(schema_false) + Type_missing = json_schema_to_type(schema_missing) + + # Both should create dataclasses (not BaseModel) + assert not issubclass(Type_false, BaseModel) + assert not issubclass(Type_missing, BaseModel) + assert hasattr(Type_false, "__dataclass_fields__") + assert hasattr(Type_missing, "__dataclass_fields__") + + def test_additional_properties_with_defaults(self): + """Test additionalProperties with default values""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string", "default": "anonymous"}, + "age": {"type": "integer", "default": 0}, + }, + "additionalProperties": True, + } + + Type = json_schema_to_type(schema) + validator = TypeAdapter(Type) + + # Test with extra fields and defaults + result = validator.validate_python({"extra": "field"}) + assert result.name == "anonymous" + assert result.age == 0 + assert result.extra == "field" + + def test_additional_properties_type_consistency(self): + """Test that the same schema always returns the same type""" + schema = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": True, + } + + Type1 = json_schema_to_type(schema) + Type2 = json_schema_to_type(schema) + + # Should be the same cached class + assert Type1 is Type2 + assert issubclass(Type1, BaseModel) diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py index 7775e12bc..f6136e793 100644 --- a/tests/utilities/test_mcp_config.py +++ b/tests/utilities/test_mcp_config.py @@ -136,8 +136,8 @@ async def test_multi_client(tmp_path: Path): result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2}) result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2}) - assert result_1[0].text == "3" # type: ignore[attr-dict] - assert result_2[0].text == "3" # type: ignore[attr-dict] + assert result_1.data == 3 + assert result_2.data == 3 async def test_remote_config_default_no_auth(): diff --git a/uv.lock b/uv.lock index 87a47f5ee..ebfad50b7 100644 --- a/uv.lock +++ b/uv.lock @@ -378,6 +378,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, ] +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -444,6 +466,7 @@ dependencies = [ { name = "httpx" }, { name = "mcp" }, { name = "openapi-pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "python-dotenv" }, { name = "rich" }, { name = "typer" }, @@ -484,6 +507,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.10.0" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, { name = "typer", specifier = ">=0.15.2" }, @@ -935,6 +959,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.33.2" From ddb557ba30c718caee57b15ac5a22ef1663cbed5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:44:46 -0400 Subject: [PATCH 12/25] Update for 'result' kwarg instead of 'value' --- tests/tools/test_tool_manager.py | 30 +++++++++++++++--------------- tests/tools/test_tool_transform.py | 28 ++++++++++++++-------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 152b305a4..5ebf50c95 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -355,7 +355,7 @@ class TestCallTools: result = await manager.call_tool("add", {"a": 1, "b": 2}) assert result.content[0].text == "3" # type: ignore[attr-defined] - assert result.structured_content == {"value": 3} + assert result.structured_content == {"result": 3} async def test_call_async_tool(self): async def double(n: int) -> int: @@ -367,7 +367,7 @@ class TestCallTools: manager.add_tool(tool) result = await manager.call_tool("double", {"n": 5}) assert result.content[0].text == "10" # type: ignore[attr-defined] - assert result.structured_content == {"value": 10} + assert result.structured_content == {"result": 10} async def test_call_tool_callable_object(self): class Adder: @@ -382,7 +382,7 @@ class TestCallTools: manager.add_tool(tool) result = await manager.call_tool("Adder", {"x": 1, "y": 2}) assert result.content[0].text == "3" # type: ignore[attr-defined] - assert result.structured_content == {"value": 3} + assert result.structured_content == {"result": 3} async def test_call_tool_callable_object_async(self): class Adder: @@ -397,7 +397,7 @@ class TestCallTools: manager.add_tool(tool) result = await manager.call_tool("Adder", {"x": 1, "y": 2}) assert result.content[0].text == "3" # type: ignore[attr-defined] - assert result.structured_content == {"value": 3} + assert result.structured_content == {"result": 3} async def test_call_tool_with_default_args(self): def add(a: int, b: int = 1) -> int: @@ -410,7 +410,7 @@ class TestCallTools: result = await manager.call_tool("add", {"a": 1}) assert result.content[0].text == "2" # type: ignore[attr-defined] - assert result.structured_content == {"value": 2} + assert result.structured_content == {"result": 2} async def test_call_tool_with_missing_args(self): def add(a: int, b: int) -> int: @@ -438,7 +438,7 @@ class TestCallTools: result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}) assert result.content[0].text == "6" # type: ignore[attr-defined] - assert result.structured_content == {"value": 6} + assert result.structured_content == {"result": 6} async def test_call_tool_with_list_str_or_str_input(self): def concat_strs(vals: list[str] | str) -> str: @@ -451,11 +451,11 @@ class TestCallTools: # Try both with plain python object and with JSON list result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]}) assert result.content[0].text == "abc" # type: ignore[attr-defined] - assert result.structured_content == {"value": "abc"} + assert result.structured_content == {"result": "abc"} result = await manager.call_tool("concat_strs", {"vals": "a"}) assert result.content[0].text == "a" # type: ignore[attr-defined] - assert result.structured_content == {"value": "a"} + assert result.structured_content == {"result": "a"} async def test_call_tool_with_complex_model(self): class MyShrimpTank(BaseModel): @@ -487,7 +487,7 @@ class TestCallTools: ) assert result.content[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined] - assert result.structured_content == {"value": ["rex", "gertrude"]} + assert result.structured_content == {"result": ["rex", "gertrude"]} async def test_call_tool_with_custom_serializer(self): """Test that a custom serializer provided to FastMCP is used by tools.""" @@ -533,7 +533,7 @@ class TestCallTools: == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined] ) assert result.structured_content == { - "value": [ + "result": [ {"key": "value", "number": 123}, {"key": "value2", "number": 456}, ] @@ -559,7 +559,7 @@ class TestCallTools: result.content[0].text # type: ignore[attr-defined] == pydantic_core.to_json(uuid_result).decode() ) - assert result.structured_content == {"value": str(uuid_result)} + assert result.structured_content == {"result": str(uuid_result)} class TestToolSchema: @@ -630,7 +630,7 @@ class TestContextHandling: async with context: result = await manager.call_tool("tool_with_context", {"x": 42}) assert result.content[0].text == "42" # type: ignore[attr-defined] - assert result.structured_content == {"value": "42"} + assert result.structured_content == {"result": "42"} async def test_context_injection_async(self): """Test that context is properly injected in async tools.""" @@ -649,7 +649,7 @@ class TestContextHandling: async with context: result = await manager.call_tool("async_tool", {"x": 42}) assert result.content[0].text == "42" # type: ignore[attr-defined] - assert result.structured_content == {"value": "42"} + assert result.structured_content == {"result": "42"} async def test_context_optional(self): """Test that context is optional when calling tools.""" @@ -668,7 +668,7 @@ class TestContextHandling: async with context: result = await manager.call_tool("tool_with_context", {"x": 42}) assert result.content[0].text == "42" # type: ignore[attr-defined] - assert result.structured_content == {"value": 42} + assert result.structured_content == {"result": 42} def test_parameterized_context_parameter_detection(self): """Test that context parameters are properly detected in @@ -777,7 +777,7 @@ class TestCustomToolNames: # Tool should be callable by its custom name result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3}) assert result.content[0].text == "15" # type: ignore[attr-defined] - assert result.structured_content == {"value": 15} + assert result.structured_content == {"result": 15} # Original name should not be registered with pytest.raises(NotFoundError, match="Tool 'multiply' not found"): diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index ab72352d0..a616f556c 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -53,7 +53,7 @@ async def test_tool_defaults_are_maintained_on_unmapped_args(add_tool): ) result = await new_tool.run(arguments={"new_x": 1}) # The parent tool returns int which gets wrapped as structured output - assert result.structured_content == {"value": 11} + assert result.structured_content == {"result": 11} async def test_tool_defaults_are_maintained_on_mapped_args(add_tool): @@ -62,7 +62,7 @@ async def test_tool_defaults_are_maintained_on_mapped_args(add_tool): ) result = await new_tool.run(arguments={"old_x": 1}) # The parent tool returns int which gets wrapped as structured output - assert result.structured_content == {"value": 11} + assert result.structured_content == {"result": 11} def test_tool_change_arg_name(add_tool): @@ -89,7 +89,7 @@ async def test_tool_drop_arg(add_tool): ) assert sorted(new_tool.parameters["properties"]) == ["old_x"] result = await new_tool.run(arguments={"old_x": 1}) - assert result.structured_content == {"value": 11} + assert result.structured_content == {"result": 11} async def test_dropped_args_error_if_provided(add_tool): @@ -111,7 +111,7 @@ async def test_hidden_arg_with_constant_default(add_tool): 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 == {"value": 25} + assert result.structured_content == {"result": 25} async def test_hidden_arg_without_default_uses_parent_default(add_tool): @@ -124,7 +124,7 @@ async def test_hidden_arg_without_default_uses_parent_default(add_tool): # Should pass old_x=3 and let parent use its default old_y=10 result = await new_tool.run(arguments={"old_x": 3}) assert result.content[0].text == "13" # type: ignore[attr-defined] - assert result.structured_content == {"value": 13} + assert result.structured_content == {"result": 13} async def test_mixed_hidden_args_with_custom_function(add_tool): @@ -150,7 +150,7 @@ async def test_mixed_hidden_args_with_custom_function(add_tool): # 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 result.content[0].text == "32" # type: ignore[attr-defined] - assert result.structured_content == {"value": 32} + assert result.structured_content == {"result": 32} async def test_hide_required_param_without_default_raises_error(): @@ -188,7 +188,7 @@ async def test_hide_required_param_with_user_default_works(): 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 == {"value": 25} + assert result.structured_content == {"result": 25} async def test_forward_with_argument_mapping(add_tool): @@ -208,7 +208,7 @@ async def test_forward_with_argument_mapping(add_tool): result = await new_tool.run(arguments={"new_x": 2, "new_y": 3}) assert result.content[0].text == "5" # type: ignore[attr-defined] - assert result.structured_content == {"value": 5} + assert result.structured_content == {"result": 5} async def test_forward_with_incorrect_args_raises_error(add_tool): @@ -249,7 +249,7 @@ async def test_forward_raw_without_argument_mapping(add_tool): result = await new_tool.run(arguments={"new_x": 2, "new_y": 3}) assert result.content[0].text == "5" # type: ignore[attr-defined] - assert result.structured_content == {"value": 5} + assert result.structured_content == {"result": 5} async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool): @@ -260,7 +260,7 @@ async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool): 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 result.content[0].text == "6" # type: ignore[attr-defined] - assert result.structured_content == {"value": 6} + assert result.structured_content == {"result": 6} assert new_tool.parameters["required"] == IsList( "extra", "old_x", check_order=False ) @@ -278,7 +278,7 @@ async def test_fn_with_kwargs_passes_through_original_args(add_tool): new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn) result = await new_tool.run(arguments={"new_y": 2, "old_y": 3}) assert result.content[0].text == "5" # type: ignore[attr-defined] - assert result.structured_content == {"value": 5} + assert result.structured_content == {"result": 5} async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool): @@ -297,7 +297,7 @@ async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool): ) result = await new_tool.run(arguments={"new_x": 2, "old_y": 3}) assert result.content[0].text == "5" # type: ignore[attr-defined] - assert result.structured_content == {"value": 5} + assert result.structured_content == {"result": 5} async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool): @@ -318,7 +318,7 @@ async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool): arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"} ) assert result.content[0].text == "10" # type: ignore[attr-defined] - assert result.structured_content == {"value": 10} + assert result.structured_content == {"result": 10} async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool): @@ -337,7 +337,7 @@ async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool): ) # only map 'a' result = await new_tool.run(arguments={"new_x": 1, "old_y": 5}) assert result.content[0].text == "6" # type: ignore[attr-defined] - assert result.structured_content == {"value": 6} + assert result.structured_content == {"result": 6} async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool): From 7d252e6da48de9498f9cc740c3525cc897c2a3ca Mon Sep 17 00:00:00 2001 From: Goro Date: Fri, 27 Jun 2025 22:17:44 +0200 Subject: [PATCH 13/25] Add missing documentation --- .../contrib/component_manager/README.md | 53 ++++++++- .../contrib/component_manager/__init__.py | 5 +- .../component_manager/component_manager.py | 108 ++++++++++++++---- .../component_manager/component_service.py | 38 +++--- .../contrib/component_manager/example.py | 59 ++++++++++ tests/contrib/test_component_manager.py | 9 +- 6 files changed, 222 insertions(+), 50 deletions(-) create mode 100644 src/fastmcp/contrib/component_manager/example.py diff --git a/src/fastmcp/contrib/component_manager/README.md b/src/fastmcp/contrib/component_manager/README.md index b7b1dccec..32cd4121d 100644 --- a/src/fastmcp/contrib/component_manager/README.md +++ b/src/fastmcp/contrib/component_manager/README.md @@ -26,7 +26,7 @@ This module is part of the `fastmcp.contrib` package. No separate installation i ```python from fastmcp import FastMCP -from fastmcp.contrib.component_manager.component_manager import set_up_component_manager +from fastmcp.contrib.component_manager import set_up_component_manager mcp = FastMCP("Component Manager", instructions="This is a test server with component manager.") set_up_component_manager(server=mcp) @@ -36,7 +36,7 @@ set_up_component_manager(server=mcp) ## 🔗 API Endpoints -By default, all endpoints are registered at `/` by default, or under the custom path if one is provided. +All endpoints are registered at `/` by default, or under the custom path if one is provided. ### Tools @@ -52,7 +52,7 @@ POST /resources/{uri:path}/enable POST /resources/{uri:path}/disable ``` - * Works with template URIs too + * Supports template URIs as well ```http POST /resources/example://test/{id}/enable POST /resources/example://test/{id}/disable @@ -63,6 +63,19 @@ POST /resources/example://test/{id}/disable ```http POST /prompts/{prompt_name}/enable POST /prompts/{prompt_name}/disable +``` +--- + +#### 🧪 Example Response + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "message": "Disabled tool: example_tool" +} + ``` --- @@ -83,7 +96,7 @@ If your server uses authentication: ```python mcp = FastMCP("Component Manager", instructions="This is a test server with component manager.", auth=auth) -set_up_component_manager(server=mcp, required_scopes=["tools:write", "tools:read"]) +set_up_component_manager(server=mcp, required_scopes=["write", "read"]) ``` --- @@ -99,6 +112,38 @@ curl -X POST \ --- +## 🧱 Working with Mounted Servers + +You can also combine different configurations when working with mounted servers — for example, using different scopes: + +```python +mcp = FastMCP("Component Manager", instructions="This is a test server with component manager.", auth=auth) +set_up_component_manager(server=mcp, required_scopes=["mcp:write"]) + +mounted = FastMCP("Component Manager", instructions="This is a test server with component manager.", auth=auth) +set_up_component_manager(server=mounted, required_scopes=["mounted:write"]) + +mcp.mount(server=mounted, prefix="mo") +``` + +This allows you to grant different levels of access: + +```bash +# Accessing the main server gives you control over both local and mounted components +curl -X POST \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + http://localhost:8001/tools/mo_example_tool/enable + +# Accessing the mounted server gives you control only over its own components +curl -X POST \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + http://localhost:8002/tools/example_tool/enable +``` + +--- + ## ⚙️ How It Works - `set_up_component_manager()` registers API routes for tools, resources, and prompts. diff --git a/src/fastmcp/contrib/component_manager/__init__.py b/src/fastmcp/contrib/component_manager/__init__.py index edc85e475..6bb6c89ba 100644 --- a/src/fastmcp/contrib/component_manager/__init__.py +++ b/src/fastmcp/contrib/component_manager/__init__.py @@ -1,7 +1,4 @@ from .component_manager import set_up_component_manager from .component_service import ComponentService -__all__ = [ - "set_up_component_manager", - "ComponentService" -] +__all__ = ["set_up_component_manager", "ComponentService"] diff --git a/src/fastmcp/contrib/component_manager/component_manager.py b/src/fastmcp/contrib/component_manager/component_manager.py index 6fc20eace..a5d0259fd 100644 --- a/src/fastmcp/contrib/component_manager/component_manager.py +++ b/src/fastmcp/contrib/component_manager/component_manager.py @@ -1,28 +1,30 @@ +""" +Routes and helpers for managing tools, resources, and prompts in FastMCP. +Provides endpoints for enabling/disabling components via HTTP, with optional authentication scopes. +""" + +from typing import Any + +from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware from starlette.applications import Starlette from starlette.exceptions import HTTPException as StarletteHTTPException - from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Mount, Route from fastmcp.contrib.component_manager.component_service import ComponentService from fastmcp.exceptions import NotFoundError - -from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware -from typing import Any - from fastmcp.server.server import FastMCP + def set_up_component_manager( server: FastMCP, path: str = "/", required_scopes: list[str] | None = None ): """Set up routes for enabling/disabling tools, resources, and prompts. Args: server: The FastMCP server instance - root_path: Path used to mount all component-related routes on the server - required_scopes: Optional list of scopes required for these routes - Returns: - A list of routes or mounts for component management + path: Path used to mount all component-related routes on the server + required_scopes: Optional list of scopes required for these routes. Applies only if authentication is enabled. """ service = ComponentService(server) @@ -47,34 +49,46 @@ def set_up_component_manager( } if required_scopes is None: - routes.extend( - build_component_manager_endpoints(route_configs, path) - ) + routes.extend(build_component_manager_endpoints(route_configs, path)) else: if path != "/": mounts.append( - build_component_manager_mount( - route_configs, path, required_scopes - )) + build_component_manager_mount(route_configs, path, required_scopes) + ) else: mounts.append( build_component_manager_mount( {"tool": route_configs["tool"]}, "/tools", required_scopes - )) + ) + ) mounts.append( build_component_manager_mount( - {"resource": route_configs["resource"]}, "/resources", required_scopes - )) + {"resource": route_configs["resource"]}, + "/resources", + required_scopes, + ) + ) mounts.append( build_component_manager_mount( {"prompt": route_configs["prompt"]}, "/prompts", required_scopes - )) + ) + ) server._additional_http_routes.extend(routes) server._additional_http_routes.extend(mounts) def make_endpoint(action, component, config): + """ + Factory for creating Starlette endpoint functions for enabling/disabling a component. + Args: + action: 'enable' or 'disable' + component: The component type (e.g., 'tool', 'resource', or 'prompt') + config: Dict with param and handler functions for the component + Returns: + An async endpoint function for Starlette. + """ + async def endpoint(request: Request): name = request.path_params[config["param"].split(":")[0]] @@ -88,38 +102,82 @@ def make_endpoint(action, component, config): status_code=404, detail=f"Unknown {component}: {name}", ) + return endpoint + def make_route(action, component, config, required_scopes, root_path) -> Route: + """ + Creates a Starlette Route for enabling/disabling a component. + Args: + action: 'enable' or 'disable' + component: The component type + config: Dict with param and handler functions + required_scopes: Optional list of required auth scopes + root_path: The base path for the route + Returns: + A Starlette Route object. + """ endpoint = make_endpoint(action, component, config) - if required_scopes is not None and root_path in ["/tools", "/resources", "/prompts"]: + if required_scopes is not None and root_path in [ + "/tools", + "/resources", + "/prompts", + ]: path = f"/{{{config['param']}}}/{action}" else: path = f"/{component}s/{{{config['param']}}}/{action}" return Route(path, endpoint=endpoint, methods=["POST"]) - -def build_component_manager_endpoints(route_configs, root_path, required_scopes=None) -> list[Route]: + + +def build_component_manager_endpoints( + route_configs, root_path, required_scopes=None +) -> list[Route]: + """ + Build a list of Starlette Route objects for all components/actions. + Args: + route_configs: Dict describing component types and their handlers + root_path: The base path for the routes + required_scopes: Optional list of required auth scopes + Returns: + List of Starlette Route objects for component management. + """ component_management_routes: list[Route] = [] for component in route_configs: config: dict[str, Any] = route_configs[component] for action in ["enable", "disable"]: - component_management_routes.append(make_route(action, component, config, required_scopes, root_path)) + component_management_routes.append( + make_route(action, component, config, required_scopes, root_path) + ) return component_management_routes def build_component_manager_mount(route_configs, root_path, required_scopes) -> Mount: + """ + Build a Starlette Mount with authentication for component management routes. + Args: + route_configs: Dict describing component types and their handlers + root_path: The base path for the mount + required_scopes: List of required auth scopes + Returns: + A Starlette Mount object with authentication middleware. + """ component_management_routes: list[Route] = [] for component in route_configs: config: dict[str, Any] = route_configs[component] for action in ["enable", "disable"]: - component_management_routes.append(make_route(action, component, config, required_scopes, root_path)) + component_management_routes.append( + make_route(action, component, config, required_scopes, root_path) + ) return Mount( f"{root_path}", - app=RequireAuthMiddleware(Starlette(routes=component_management_routes), required_scopes) + app=RequireAuthMiddleware( + Starlette(routes=component_management_routes), required_scopes + ), ) diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index 7077d8d2a..777834f52 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -1,16 +1,22 @@ +""" +ComponentService: Provides async management of tools, resources, and prompts for FastMCP servers. +Handles enabling/disabling components both locally and across mounted servers. +""" + from fastmcp.exceptions import NotFoundError from fastmcp.prompts.prompt import Prompt from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate -from fastmcp.tools.tool import Tool - -from fastmcp.utilities.logging import get_logger from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix +from fastmcp.tools.tool import Tool +from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) - -class ComponentService: + + +class ComponentService: """Service for managing components like tools, resources, and prompts.""" + def __init__(self, server: FastMCP): self._server = server self._tool_manager = server._tool_manager @@ -33,7 +39,7 @@ class ComponentService: tool: Tool = await self._server.get_tool(key) tool.enable() return tool - + # 2. Check mounted servers using the filtered protocol path. for mounted in reversed(self._tool_manager._mounted_servers): if mounted.prefix: @@ -63,7 +69,7 @@ class ComponentService: tool: Tool = await self._server.get_tool(key) tool.disable() return tool - + # 2. Check mounted servers using the filtered protocol path. for mounted in reversed(self._tool_manager._mounted_servers): if mounted.prefix: @@ -112,11 +118,13 @@ class ComponentService: mounted.resource_prefix_format, ) mounted_service = ComponentService(mounted.server) - mounted_resource: Resource | ResourceTemplate = await mounted_service._enable_resource(key) + mounted_resource: ( + Resource | ResourceTemplate + ) = await mounted_service._enable_resource(key) mounted_resource.enable() return mounted_resource else: - continue + continue raise NotFoundError(f"Unknown resource: {key}") async def _disable_resource(self, key: str) -> Resource | ResourceTemplate: @@ -154,11 +162,13 @@ class ComponentService: mounted.resource_prefix_format, ) mounted_service = ComponentService(mounted.server) - mounted_resource: Resource | ResourceTemplate = await mounted_service._disable_resource(key) + mounted_resource: ( + Resource | ResourceTemplate + ) = await mounted_service._disable_resource(key) mounted_resource.disable() return mounted_resource else: - continue + continue raise NotFoundError(f"Unknown resource: {key}") async def _enable_prompt(self, key: str) -> Prompt: @@ -177,7 +187,7 @@ class ComponentService: prompt: Prompt = await self._server.get_prompt(key) prompt.enable() return prompt - + # 2. Check mounted servers using the filtered protocol path. for mounted in reversed(self._prompt_manager._mounted_servers): if mounted.prefix: @@ -190,7 +200,7 @@ class ComponentService: else: continue raise NotFoundError(f"Unknown prompt: {key}") - + async def _disable_prompt(self, key: str) -> Prompt: """Handle 'disablePrompt' requests. @@ -206,7 +216,7 @@ class ComponentService: prompt: Prompt = await self._server.get_prompt(key) prompt.disable() return prompt - + # 2. Check mounted servers using the filtered protocol path. for mounted in reversed(self._prompt_manager._mounted_servers): if mounted.prefix: diff --git a/src/fastmcp/contrib/component_manager/example.py b/src/fastmcp/contrib/component_manager/example.py new file mode 100644 index 000000000..e05455446 --- /dev/null +++ b/src/fastmcp/contrib/component_manager/example.py @@ -0,0 +1,59 @@ +from fastmcp import FastMCP +from fastmcp.contrib.component_manager import set_up_component_manager +from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair + +key_pair = RSAKeyPair.generate() + +auth = BearerAuthProvider( + public_key=key_pair.public_key, + issuer="https://dev.example.com", + audience="my-dev-server", + required_scopes=["mcp:read"], +) + +# Build main server +mcp_token = key_pair.create_token( + subject="dev-user", + issuer="https://dev.example.com", + audience="my-dev-server", + scopes=["mcp:write", "mcp:read"], +) +mcp = FastMCP( + "Component Manager", + instructions="This is a test server with component manager.", + auth=auth, +) + +# Set up main server component manager +set_up_component_manager(server=mcp, required_scopes=["mcp:write"]) + +# Build mounted server +mounted_token = key_pair.create_token( + subject="dev-user", + issuer="https://dev.example.com", + audience="my-dev-server", + scopes=["mounted:write", "mcp:read"], +) +mounted = FastMCP( + "Component Manager", + instructions="This is a test server with component manager.", + auth=auth, +) + +# Set up mounted server component manager +set_up_component_manager(server=mounted, required_scopes=["mounted:write"]) + +# Mount +mcp.mount(server=mounted, prefix="mo") + + +@mcp.resource("resource://greeting") +def get_greeting() -> str: + """Provides a simple greeting message.""" + return "Hello from FastMCP Resources!" + + +@mounted.tool("greeting") +def get_info() -> str: + """Provides a simple info.""" + return "You are using component manager contrib module!" diff --git a/tests/contrib/test_component_manager.py b/tests/contrib/test_component_manager.py index 644890ea0..e2b43c36d 100644 --- a/tests/contrib/test_component_manager.py +++ b/tests/contrib/test_component_manager.py @@ -44,6 +44,7 @@ class TestComponentManagementRoutes: mcp = FastMCP("TestServer") mcp.mount(mounted_mcp, prefix="sub") set_up_component_manager(server=mcp) + # Add a test tool @mcp.tool def test_tool() -> str: @@ -345,7 +346,9 @@ class TestAuthComponentManagementRoutes: audience="my-dev-server", ) self.mcp = FastMCP("TestServerWithAuth", auth=self.auth) - set_up_component_manager(server=self.mcp, required_scopes=["tool:write", "tool:read"]) + set_up_component_manager( + server=self.mcp, required_scopes=["tool:write", "tool:read"] + ) self.token = key_pair.create_token( subject="dev-user", issuer="https://dev.example.com", @@ -445,7 +448,7 @@ class TestAuthComponentManagementRoutes: assert response.status_code == 200 assert response.json() == {"message": "Enabled resource: data://test_resource"} assert resource.enabled is True - + async def test_unauthorized_disable_resource(self): """Test that unauthenticated requests to disable a resource are rejected.""" resource = await self.mcp._resource_manager.get_resource("data://test_resource") @@ -534,4 +537,4 @@ class TestAuthComponentManagementRoutes: ) assert response.status_code == 200 assert response.json() == {"message": "Disabled prompt: test_prompt"} - assert prompt.enabled is False \ No newline at end of file + assert prompt.enabled is False From 852952dd6df401808b4e68ea3d93a40a9d54be86 Mon Sep 17 00:00:00 2001 From: Goro Date: Fri, 27 Jun 2025 23:30:41 +0200 Subject: [PATCH 14/25] Add tests when custom path is provided --- .../component_manager/component_manager.py | 5 +- tests/contrib/test_component_manager.py | 203 ++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/contrib/component_manager/component_manager.py b/src/fastmcp/contrib/component_manager/component_manager.py index a5d0259fd..01a24eff0 100644 --- a/src/fastmcp/contrib/component_manager/component_manager.py +++ b/src/fastmcp/contrib/component_manager/component_manager.py @@ -127,7 +127,10 @@ def make_route(action, component, config, required_scopes, root_path) -> Route: ]: path = f"/{{{config['param']}}}/{action}" else: - path = f"/{component}s/{{{config['param']}}}/{action}" + if root_path != "/" and required_scopes is None: + path = f"{root_path}/{component}s/{{{config['param']}}}/{action}" + else: + path = f"/{component}s/{{{config['param']}}}/{action}" return Route(path, endpoint=endpoint, methods=["POST"]) diff --git a/tests/contrib/test_component_manager.py b/tests/contrib/test_component_manager.py index e2b43c36d..8fea3c8bc 100644 --- a/tests/contrib/test_component_manager.py +++ b/tests/contrib/test_component_manager.py @@ -538,3 +538,206 @@ class TestAuthComponentManagementRoutes: assert response.status_code == 200 assert response.json() == {"message": "Disabled prompt: test_prompt"} assert prompt.enabled is False + + +class TestComponentManagerWithPath: + """Test component manager routes when mounted at a custom path.""" + + @pytest.fixture + def mcp_with_path(self): + mcp = FastMCP("TestServerWithPath") + set_up_component_manager(server=mcp, path="/test") + + @mcp.tool + def test_tool() -> str: + return "test_tool_result" + + @mcp.resource("data://test_resource") + def test_resource() -> str: + return "test_resource_result" + + @mcp.prompt + def test_prompt() -> str: + return "test_prompt_result" + + return mcp + + @pytest.fixture + def client_with_path(self, mcp_with_path): + return TestClient(mcp_with_path.http_app()) + + @pytest.mark.asyncio + async def test_enable_tool_route_with_path(self, client_with_path, mcp_with_path): + tool = await mcp_with_path._tool_manager.get_tool("test_tool") + tool.enabled = False + response = client_with_path.post("/test/tools/test_tool/enable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Enabled tool: test_tool"} + tool = await mcp_with_path._tool_manager.get_tool("test_tool") + assert tool.enabled is True + + @pytest.mark.asyncio + async def test_disable_resource_route_with_path( + self, client_with_path, mcp_with_path + ): + resource = await mcp_with_path._resource_manager.get_resource( + "data://test_resource" + ) + resource.enabled = True + response = client_with_path.post("/test/resources/data://test_resource/disable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Disabled resource: data://test_resource"} + resource = await mcp_with_path._resource_manager.get_resource( + "data://test_resource" + ) + assert resource.enabled is False + + @pytest.mark.asyncio + async def test_enable_prompt_route_with_path(self, client_with_path, mcp_with_path): + prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt") + prompt.enabled = False + response = client_with_path.post("/test/prompts/test_prompt/enable") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Enabled prompt: test_prompt"} + prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt") + assert prompt.enabled is True + + +class TestComponentManagerWithPathAuth: + """Test component manager routes with auth when mounted at a custom path.""" + + def setup_method(self): + # Generate a key pair and create an auth provider + key_pair = RSAKeyPair.generate() + self.auth = BearerAuthProvider( + public_key=key_pair.public_key, + issuer="https://dev.example.com", + audience="my-dev-server", + required_scopes=["tool:write", "tool:read"], + ) + self.mcp = FastMCP("TestServerWithPathAuth", auth=self.auth) + set_up_component_manager( + server=self.mcp, path="/test", required_scopes=["tool:write", "tool:read"] + ) + self.token = key_pair.create_token( + subject="dev-user", + issuer="https://dev.example.com", + audience="my-dev-server", + scopes=["tool:read", "tool:write"], + ) + self.token_without_scopes = key_pair.create_token( + subject="dev-user", + issuer="https://dev.example.com", + audience="my-dev-server", + scopes=[], + ) + + @self.mcp.tool + def test_tool() -> str: + return "test_tool_result" + + @self.mcp.resource("data://test_resource") + def test_resource() -> str: + return "test_resource_result" + + @self.mcp.prompt + def test_prompt() -> str: + return "test_prompt_result" + + self.client = TestClient(self.mcp.http_app()) + + @pytest.mark.asyncio + async def test_unauthorized_enable_tool(self): + tool = await self.mcp._tool_manager.get_tool("test_tool") + tool.enabled = False + response = self.client.post("/test/tools/test_tool/enable") + assert response.status_code == 401 + assert tool.enabled is False + + @pytest.mark.asyncio + async def test_forbidden_enable_tool(self): + tool = await self.mcp._tool_manager.get_tool("test_tool") + tool.enabled = False + response = self.client.post( + "/test/tools/test_tool/enable", + headers={"Authorization": "Bearer " + self.token_without_scopes}, + ) + assert response.status_code == 403 + assert tool.enabled is False + + @pytest.mark.asyncio + async def test_authorized_enable_tool(self): + tool = await self.mcp._tool_manager.get_tool("test_tool") + tool.enabled = False + response = self.client.post( + "/test/tools/test_tool/enable", + headers={"Authorization": "Bearer " + self.token}, + ) + assert response.status_code == 200 + assert response.json() == {"message": "Enabled tool: test_tool"} + tool = await self.mcp._tool_manager.get_tool("test_tool") + assert tool.enabled is True + + @pytest.mark.asyncio + async def test_unauthorized_disable_resource(self): + resource = await self.mcp._resource_manager.get_resource("data://test_resource") + resource.enabled = True + response = self.client.post("/test/resources/data://test_resource/disable") + assert response.status_code == 401 + assert resource.enabled is True + + @pytest.mark.asyncio + async def test_forbidden_disable_resource(self): + resource = await self.mcp._resource_manager.get_resource("data://test_resource") + resource.enabled = True + response = self.client.post( + "/test/resources/data://test_resource/disable", + headers={"Authorization": "Bearer " + self.token_without_scopes}, + ) + assert response.status_code == 403 + assert resource.enabled is True + + @pytest.mark.asyncio + async def test_authorized_disable_resource(self): + resource = await self.mcp._resource_manager.get_resource("data://test_resource") + resource.enabled = True + response = self.client.post( + "/test/resources/data://test_resource/disable", + headers={"Authorization": "Bearer " + self.token}, + ) + assert response.status_code == 200 + assert response.json() == {"message": "Disabled resource: data://test_resource"} + resource = await self.mcp._resource_manager.get_resource("data://test_resource") + assert resource.enabled is False + + @pytest.mark.asyncio + async def test_unauthorized_enable_prompt(self): + prompt = await self.mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = False + response = self.client.post("/test/prompts/test_prompt/enable") + assert response.status_code == 401 + assert prompt.enabled is False + + @pytest.mark.asyncio + async def test_forbidden_enable_prompt(self): + prompt = await self.mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = False + response = self.client.post( + "/test/prompts/test_prompt/enable", + headers={"Authorization": "Bearer " + self.token_without_scopes}, + ) + assert response.status_code == 403 + assert prompt.enabled is False + + @pytest.mark.asyncio + async def test_authorized_enable_prompt(self): + prompt = await self.mcp._prompt_manager.get_prompt("test_prompt") + prompt.enabled = False + response = self.client.post( + "/test/prompts/test_prompt/enable", + headers={"Authorization": "Bearer " + self.token}, + ) + assert response.status_code == 200 + assert response.json() == {"message": "Enabled prompt: test_prompt"} + prompt = await self.mcp._prompt_manager.get_prompt("test_prompt") + assert prompt.enabled is True From 3598943d067bfeb126b72740ab12588fb0c42194 Mon Sep 17 00:00:00 2001 From: Goro Date: Fri, 27 Jun 2025 23:54:18 +0200 Subject: [PATCH 15/25] Fix doc and example --- src/fastmcp/contrib/component_manager/README.md | 8 ++++---- src/fastmcp/contrib/component_manager/example.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/contrib/component_manager/README.md b/src/fastmcp/contrib/component_manager/README.md index 32cd4121d..efdeb3b7b 100644 --- a/src/fastmcp/contrib/component_manager/README.md +++ b/src/fastmcp/contrib/component_manager/README.md @@ -28,7 +28,7 @@ This module is part of the `fastmcp.contrib` package. No separate installation i from fastmcp import FastMCP from fastmcp.contrib.component_manager import set_up_component_manager -mcp = FastMCP("Component Manager", instructions="This is a test server with component manager.") +mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.") set_up_component_manager(server=mcp) ``` @@ -95,7 +95,7 @@ set_up_component_manager(server=mcp, path="/admin") If your server uses authentication: ```python -mcp = FastMCP("Component Manager", instructions="This is a test server with component manager.", auth=auth) +mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) set_up_component_manager(server=mcp, required_scopes=["write", "read"]) ``` @@ -117,10 +117,10 @@ curl -X POST \ You can also combine different configurations when working with mounted servers — for example, using different scopes: ```python -mcp = FastMCP("Component Manager", instructions="This is a test server with component manager.", auth=auth) +mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) set_up_component_manager(server=mcp, required_scopes=["mcp:write"]) -mounted = FastMCP("Component Manager", instructions="This is a test server with component manager.", auth=auth) +mounted = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) set_up_component_manager(server=mounted, required_scopes=["mounted:write"]) mcp.mount(server=mounted, prefix="mo") diff --git a/src/fastmcp/contrib/component_manager/example.py b/src/fastmcp/contrib/component_manager/example.py index e05455446..845c374ff 100644 --- a/src/fastmcp/contrib/component_manager/example.py +++ b/src/fastmcp/contrib/component_manager/example.py @@ -19,7 +19,7 @@ mcp_token = key_pair.create_token( scopes=["mcp:write", "mcp:read"], ) mcp = FastMCP( - "Component Manager", + name="Component Manager", instructions="This is a test server with component manager.", auth=auth, ) @@ -35,7 +35,7 @@ mounted_token = key_pair.create_token( scopes=["mounted:write", "mcp:read"], ) mounted = FastMCP( - "Component Manager", + name="Component Manager", instructions="This is a test server with component manager.", auth=auth, ) From 18e522b320675dc809d05ad97089433e26ba6fc1 Mon Sep 17 00:00:00 2001 From: Goro Date: Fri, 27 Jun 2025 23:58:59 +0200 Subject: [PATCH 16/25] Remove redundant testing calls --- src/fastmcp/contrib/component_manager/component_service.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index 777834f52..d8b1736f3 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -47,7 +47,6 @@ class ComponentService: tool_key = key.removeprefix(f"{mounted.prefix}_") mounted_service = ComponentService(mounted.server) tool = await mounted_service._enable_tool(tool_key) - tool.enable() return tool else: continue @@ -77,7 +76,6 @@ class ComponentService: tool_key = key.removeprefix(f"{mounted.prefix}_") mounted_service = ComponentService(mounted.server) tool = await mounted_service._disable_tool(tool_key) - tool.disable() return tool else: continue @@ -121,7 +119,6 @@ class ComponentService: mounted_resource: ( Resource | ResourceTemplate ) = await mounted_service._enable_resource(key) - mounted_resource.enable() return mounted_resource else: continue @@ -165,7 +162,6 @@ class ComponentService: mounted_resource: ( Resource | ResourceTemplate ) = await mounted_service._disable_resource(key) - mounted_resource.disable() return mounted_resource else: continue @@ -195,7 +191,6 @@ class ComponentService: prompt_key = key.removeprefix(f"{mounted.prefix}_") mounted_service = ComponentService(mounted.server) prompt = await mounted_service._enable_prompt(prompt_key) - prompt.enable() return prompt else: continue @@ -224,7 +219,6 @@ class ComponentService: prompt_key = key.removeprefix(f"{mounted.prefix}_") mounted_service = ComponentService(mounted.server) prompt = await mounted_service._disable_prompt(prompt_key) - prompt.disable() return prompt else: continue From f524652f2522195b4cab35b098a941a987622f90 Mon Sep 17 00:00:00 2001 From: Aditya Bansal Date: Fri, 27 Jun 2025 15:26:55 -0700 Subject: [PATCH 17/25] Add OpenAPI extensions support to HTTPRoute - Add extensions field to HTTPRoute class to store x-* fields - Extract extensions from operation's model_extra in parser - Add test to verify extensions are properly parsed --- src/fastmcp/utilities/openapi.py | 10 ++++++++++ tests/utilities/openapi/test_openapi.py | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py index c29b20661..fd0cbed5b 100644 --- a/src/fastmcp/utilities/openapi.py +++ b/src/fastmcp/utilities/openapi.py @@ -84,6 +84,7 @@ class HTTPRoute(FastMCPBaseModel): schema_definitions: dict[str, JsonSchema] = Field( default_factory=dict ) # Store component schemas + extensions: dict[str, Any] = Field(default_factory=dict) # Export public symbols @@ -591,6 +592,14 @@ class OpenAPIParser( getattr(operation, "responses", None) ) + extensions = {} + if hasattr(operation, "model_extra") and operation.model_extra: + extensions = { + k: v + for k, v in operation.model_extra.items() + if k.startswith("x-") + } + route = HTTPRoute( path=path_str, method=method_upper, # type: ignore[arg-type] # Known valid HTTP method @@ -602,6 +611,7 @@ class OpenAPIParser( request_body=request_body_info, responses=responses, schema_definitions=schema_definitions, + extensions=extensions, ) routes.append(route) logger.info( diff --git a/tests/utilities/openapi/test_openapi.py b/tests/utilities/openapi/test_openapi.py index a1f3bdca1..6cbad7cad 100644 --- a/tests/utilities/openapi/test_openapi.py +++ b/tests/utilities/openapi/test_openapi.py @@ -687,6 +687,29 @@ def test_multiple_tags_preserved(bookstore_schema): assert len(get_books.tags) == 3 +def test_openapi_extensions(petstore_schema): + """Test that OpenAPI extensions (x-*) are correctly parsed from operations.""" + # Add extensions to a route + petstore_schema["paths"]["/pets"]["get"]["x-rate-limit"] = 100 + petstore_schema["paths"]["/pets"]["get"]["x-custom-auth"] = "bearer" + petstore_schema["paths"]["/pets"]["get"]["x-internal"] = True + + # Parse the modified schema + routes = parse_openapi_to_http_routes(petstore_schema) + + # Find the GET /pets route + get_pets = next( + (r for r in routes if r.method == "GET" and r.path == "/pets"), None + ) + assert get_pets is not None + + # Should have extensions + assert get_pets.extensions["x-rate-limit"] == 100 + assert get_pets.extensions["x-custom-auth"] == "bearer" + assert get_pets.extensions["x-internal"] is True + assert len(get_pets.extensions) == 3 + + # --- Tests for BookStore schema --- # From a7eb2da273d7bc607a6a5b268d206bea71c919b2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Jun 2025 20:17:10 -0400 Subject: [PATCH 18/25] Update output schema control --- src/fastmcp/tools/tool.py | 48 +++++++++++++++++++------- src/fastmcp/tools/tool_transform.py | 53 ++++++++++++++++++++++++----- tests/tools/test_tool.py | 40 +++++++++++----------- tests/tools/test_tool_transform.py | 38 ++++++++++----------- 4 files changed, 119 insertions(+), 60 deletions(-) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 3fd4f2004..9a53acb69 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -3,7 +3,7 @@ from __future__ import annotations import inspect from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Annotated, Any +from typing import TYPE_CHECKING, Annotated, Any, Literal import mcp.types import pydantic_core @@ -40,6 +40,27 @@ def default_serializer(data: Any) -> str: return pydantic_core.to_json(data, fallback=str, indent=2).decode() +def _wrap_schema_if_needed(schema: dict[str, Any] | None) -> dict[str, Any] | None: + """Wrap non-object schemas with result property for structured output. + + This wrapping allows primitive types (int, str, etc.) to be returned as + structured content by placing them under a "result" key. + + Args: + schema: The JSON schema to potentially wrap + + Returns: + Wrapped schema if needed, or original schema if already an object type + """ + if schema and schema.get("type") != "object": + return { + "type": "object", + "properties": {"result": schema}, + "x-fastmcp-wrap-result": True, + } + return schema + + class ToolResult: def __init__( self, @@ -64,7 +85,11 @@ class ToolResult: ) raise if not isinstance(structured_content, dict): - structured_content = {"result": structured_content} + raise ValueError( + "structured_content must be a dict or None. " + f"Got {type(structured_content).__name__}: {structured_content!r}. " + "Tools should wrap non-dict values based on their output_schema." + ) self.structured_content: dict[str, Any] | None = structured_content def to_mcp_result( @@ -127,7 +152,7 @@ class Tool(FastMCPComponent): tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, - output_schema: dict[str, Any] | None | NotSetT = NotSet, + output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None, ) -> FunctionTool: @@ -166,6 +191,7 @@ class Tool(FastMCPComponent): description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, + output_schema: dict[str, Any] | None | Literal[False] = None, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None, ) -> TransformedTool: @@ -179,6 +205,7 @@ class Tool(FastMCPComponent): description=description, tags=tags, annotations=annotations, + output_schema=output_schema, serializer=serializer, enabled=enabled, ) @@ -196,7 +223,7 @@ class FunctionTool(Tool): tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, - output_schema: dict[str, Any] | None | NotSetT = NotSet, + output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None, ) -> FunctionTool: @@ -208,14 +235,10 @@ class FunctionTool(Tool): raise ValueError("You must provide a name for lambda functions") if isinstance(output_schema, NotSetT): - output_schema = parsed_fn.output_schema - - if output_schema and output_schema.get("type") != "object": - output_schema = { - "type": "object", - "properties": {"result": output_schema}, - "x-fastmcp-wrap-result": True, - } + output_schema = _wrap_schema_if_needed(parsed_fn.output_schema) + elif output_schema is False: + output_schema = None + # Note: explicit schemas (dict) are used as-is without auto-wrapping return cls( fn=parsed_fn.fn, @@ -249,6 +272,7 @@ class FunctionTool(Tool): unstructured_result = _convert_to_content(result, serializer=self.serializer) + # Handle structured content based on output schema if self.output_schema is not None: if self.output_schema.get("x-fastmcp-wrap-result"): structured_output = {"result": result} diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 38d074430..f52a54da3 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -9,7 +9,7 @@ from typing import Any, Literal from mcp.types import ToolAnnotations from pydantic import ConfigDict -from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult +from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _wrap_schema_if_needed from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter @@ -52,7 +52,7 @@ async def forward(**kwargs) -> ToolResult: return await tool.forwarding_fn(**kwargs) -async def forward_raw(**kwargs) -> Any: +async def forward_raw(**kwargs) -> ToolResult: """Forward directly to parent tool without transformation. This function bypasses all argument transformation and validation, calling the parent @@ -66,7 +66,7 @@ async def forward_raw(**kwargs) -> Any: **kwargs: Arguments to pass directly to the parent tool (using original names). Returns: - The result from the parent tool execution. + The ToolResult from the parent tool execution. Raises: RuntimeError: If called outside a transformed tool context. @@ -268,15 +268,31 @@ class TransformedTool(Tool): token = _current_tool.set(self) try: result = await self.fn(**arguments) - + # If transform function returns ToolResult, use it directly if isinstance(result, ToolResult): return result - - # Otherwise convert to content and create basic ToolResult + + # Otherwise convert to content and create ToolResult with proper structured content from fastmcp.tools.tool import _convert_to_content - unstructured_result = _convert_to_content(result, serializer=self.serializer) - return ToolResult(content=unstructured_result) + + unstructured_result = _convert_to_content( + result, serializer=self.serializer + ) + + # Handle structured content based on output schema + if self.output_schema is not None: + if self.output_schema.get("x-fastmcp-wrap-result"): + structured_output = {"result": result} + else: + structured_output = result + else: + structured_output = None + + return ToolResult( + content=unstructured_result, + structured_content=structured_output, + ) finally: _current_tool.reset(token) @@ -290,6 +306,7 @@ class TransformedTool(Tool): transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | None = None, + output_schema: dict[str, Any] | None | Literal[False] = None, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None, ) -> TransformedTool: @@ -352,13 +369,30 @@ class TransformedTool(Tool): # Always create the forwarding transform schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args) + # Handle output schema with smart fallback + if output_schema is False: + final_output_schema = None + elif output_schema is not None: + # Explicit schema provided - use as-is + final_output_schema = output_schema + else: + # Smart fallback: try custom function, then parent, then None + if transform_fn is not None: + parsed_fn = ParsedFunction.from_function(transform_fn, validate=False) + final_output_schema = _wrap_schema_if_needed(parsed_fn.output_schema) + if final_output_schema is None: + final_output_schema = tool.output_schema + else: + final_output_schema = tool.output_schema + if transform_fn is None: # User wants pure transformation - use forwarding_fn as the main function final_fn = forwarding_fn final_schema = schema else: # User provided custom function - merge schemas - parsed_fn = ParsedFunction.from_function(transform_fn, validate=False) + if "parsed_fn" not in locals(): + parsed_fn = ParsedFunction.from_function(transform_fn, validate=False) final_fn = transform_fn has_kwargs = cls._function_has_kwargs(transform_fn) @@ -426,6 +460,7 @@ class TransformedTool(Tool): name=name or tool.name, description=final_description, parameters=final_schema, + output_schema=final_output_schema, tags=tags or tool.tags, annotations=annotations or tool.annotations, serializer=serializer or tool.serializer, diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index eb5b045a0..b03559cdf 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -31,12 +31,10 @@ class TestToolFromFunction: assert len(tool.parameters["properties"]) == 2 assert tool.parameters["properties"]["a"]["type"] == "integer" assert tool.parameters["properties"]["b"]["type"] == "integer" - # With primitive wrapping, int return type becomes object with value property + # With primitive wrapping, int return type becomes object with result property expected_schema = { "type": "object", - "properties": {"value": {"title": "Value", "type": "integer"}}, - "required": ["value"], - "title": "Result", + "properties": {"result": {"type": "integer"}}, "x-fastmcp-wrap-result": True, } assert tool.output_schema == expected_schema @@ -251,7 +249,7 @@ class TestToolFromFunction: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Custom serializer: 15" # Structured output should have the raw value - assert result.structured_content == {"value": 15} + assert result.structured_content == {"result": 15} class TestToolFromFunctionOutputSchema: @@ -287,27 +285,20 @@ class TestToolFromFunctionOutputSchema: base_schema = TypeAdapter(annotation).json_schema() - # Only pure primitives (just type + optional title) get wrapped - primitive_types = {"string", "number", "integer", "boolean", "null"} + # Non-object types get wrapped schema_type = base_schema.get("type") - is_pure_primitive = ( - schema_type in primitive_types - and len(base_schema) <= 2 # Only 'type' and optionally 'title' - and all(key in {"type", "title"} for key in base_schema.keys()) - ) + is_object_type = schema_type == "object" - if is_pure_primitive: - # Pure primitives get wrapped + if not is_object_type: + # Non-object types get wrapped expected_schema = { "type": "object", - "properties": {"value": base_schema | {"title": "Value"}}, - "required": ["value"], - "title": "Result", + "properties": {"result": base_schema}, "x-fastmcp-wrap-result": True, } assert tool.output_schema == expected_schema else: - # Complex types (objects, unions, constrained types) remain unwrapped + # Object types remain unwrapped assert tool.output_schema == base_schema @pytest.mark.parametrize( @@ -326,8 +317,17 @@ class TestToolFromFunctionOutputSchema: tool = Tool.from_function(func) base_schema = TypeAdapter(annotation).json_schema() - # Complex types with constraints are not wrapped - they remain as-is - assert tool.output_schema == base_schema + # Special case for Any type - it generates an empty schema and doesn't get wrapped + if annotation is Any: + assert tool.output_schema == base_schema # Should be {} + else: + # All other non-object types get wrapped, including complex constrained types + expected_schema = { + "type": "object", + "properties": {"result": base_schema}, + "x-fastmcp-wrap-result": True, + } + assert tool.output_schema == expected_schema @pytest.mark.parametrize( "annotation, expected", diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index a616f556c..014422452 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -255,7 +255,7 @@ async def test_forward_raw_without_argument_mapping(add_tool): 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) - return int(sum[0].text) + extra # type: ignore[attr-defined] + return int(sum.content[0].text) + extra # type: ignore[attr-defined] 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}) @@ -360,7 +360,7 @@ async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool): ) # drop 'old_y' result = await new_tool.run(arguments={"new_x": 8}) # 8 + 10 (default value of b in parent) - assert result[0].text == "18" # type: ignore[attr-defined] + assert result.content[0].text == "18" # type: ignore[attr-defined] async def test_forward_outside_context_raises_error(): @@ -480,18 +480,18 @@ async def test_tool_transform_chaining(add_tool): tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")}) result = await tool2.run(arguments={"final_x": 5}) - assert result[0].text == "15" # type: ignore[attr-defined] + assert result.content[0].text == "15" # type: ignore[attr-defined] # 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) - return f"custom {result[0].text}" # Extract text from content + 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 result[0].text == "custom 8" # type: ignore[attr-defined] + assert result.content[0].text == "custom 8" # type: ignore[attr-defined] class MyModel(BaseModel): @@ -619,7 +619,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs(): # Function signature has different types/defaults than ArgTransform async def custom_fn(x: str = "function_default", **kwargs) -> str: result = await forward(x=x, **kwargs) - return f"custom: {result}" + return f"custom: {result.content[0].text}" tool = Tool.from_tool( base, @@ -646,7 +646,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs(): # Test it works at runtime result = await tool.run(arguments={"y": "test"}) # Should use ArgTransform default of 42 - assert "42: test" in result[0].text # type: ignore[attr-defined] + assert "42: test" in result.content[0].text # type: ignore[attr-defined] def test_arg_transform_combined_attributes(): @@ -691,7 +691,7 @@ async def test_arg_transform_type_precedence_runtime(): # Convert string back to int for the original function result = await forward_raw(x=int(x), y=y) # Extract the text from the result - result_text = result[0].text + result_text = result.content[0].text return f"String input '{x}' converted to result: {result_text}" tool = Tool.from_tool( @@ -703,8 +703,8 @@ async def test_arg_transform_type_precedence_runtime(): # Test it works with string input result = await tool.run(arguments={"x": "5", "y": 3}) - assert "String input '5'" in result[0].text # type: ignore[attr-defined] - assert "result: 8" in result[0].text # type: ignore[attr-defined] + assert "String input '5'" in result.content[0].text # type: ignore[attr-defined] + assert "result: 8" in result.content[0].text # type: ignore[attr-defined] class TestProxy: @@ -739,7 +739,7 @@ class TestProxy: 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 result[0].text == "3" # type: ignore[attr-defined] + assert result.content[0].text == "3" # type: ignore[attr-defined] async def test_arg_transform_default_factory(): @@ -762,7 +762,7 @@ async def test_arg_transform_default_factory(): # Should work without providing timestamp (gets value from factory) result = await new_tool.run(arguments={"x": 42}) - assert result[0].text == "42_12345.0" # type: ignore[attr-defined] + assert result.content[0].text == "42_12345.0" # type: ignore[attr-defined] async def test_arg_transform_default_factory_called_each_time(): @@ -790,11 +790,11 @@ async def test_arg_transform_default_factory_called_each_time(): # First call result1 = await new_tool.run(arguments={"x": 1}) - assert result1[0].text == "1_1" # type: ignore[attr-defined] + assert result1.content[0].text == "1_1" # type: ignore[attr-defined] # Second call should get a different value result2 = await new_tool.run(arguments={"x": 2}) - assert result2[0].text == "2_2" # type: ignore[attr-defined] + assert result2.content[0].text == "2_2" # type: ignore[attr-defined] async def test_arg_transform_hidden_with_default_factory(): @@ -819,7 +819,7 @@ async def test_arg_transform_hidden_with_default_factory(): # Should pass hidden request_id with factory value result = await new_tool.run(arguments={"x": 42}) - assert result[0].text == "42_req_123" # type: ignore[attr-defined] + assert result.content[0].text == "42_req_123" # type: ignore[attr-defined] async def test_arg_transform_default_and_factory_raises_error(): @@ -856,7 +856,7 @@ async def test_arg_transform_required_true(): # Should work when parameter is provided result = await new_tool.run(arguments={"optional_param": 100}) - assert result[0].text == "value: 100" # type: ignore + assert result.content[0].text == "value: 100" # type: ignore # Should fail when parameter is not provided with pytest.raises(TypeError, match="Missing required argument"): @@ -903,7 +903,7 @@ async def test_arg_transform_required_with_rename(): # Should work with new name result = await new_tool.run(arguments={"new_param": 200}) - assert result[0].text == "value: 200" # type: ignore + assert result.content[0].text == "value: 200" # type: ignore async def test_arg_transform_required_true_with_default_raises_error(): @@ -945,7 +945,7 @@ async def test_arg_transform_required_no_change(): # Should work as expected result = await new_tool.run(arguments={"req": 1}) - assert result[0].text == "values: 1, 42" # type: ignore + assert result.content[0].text == "values: 1, 42" # type: ignore async def test_arg_transform_hide_and_required_raises_error(): @@ -977,7 +977,7 @@ class TestEnableDisable: assert {tool.name for tool in tools} == {"new_add"} result = await client.call_tool("new_add", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + assert result.content[0].text == "3" # type: ignore[attr-defined] with pytest.raises(ToolError): await client.call_tool("add", {"x": 1, "y": 2}) From db24c85359cef1a7c6bb78f192c475ddc5b289b3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:41:37 -0400 Subject: [PATCH 19/25] Update all tests --- src/fastmcp/server/openapi.py | 4 +- src/fastmcp/tools/tool.py | 66 +++-- src/fastmcp/tools/tool_transform.py | 41 ++- src/fastmcp/utilities/json_schema_type.py | 50 +++- tests/contrib/test_bulk_tool_caller.py | 18 +- tests/server/http/test_http_dependencies.py | 10 +- tests/server/test_import_server.py | 2 +- tests/server/test_server.py | 75 +++--- tests/server/test_server_interactions.py | 194 +++++++++++++- tests/tools/test_tool.py | 256 +++++++++++++++++-- tests/tools/test_tool_transform.py | 270 +++++++++++++++++++- tests/utilities/test_json_schema_type.py | 25 +- 12 files changed, 911 insertions(+), 100 deletions(-) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 69a815261..1e8051ace 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -450,8 +450,10 @@ class OpenAPITool(Tool): # Try to parse as JSON first try: result = response.json() + if not isinstance(result, dict): + result = {"result": result} return ToolResult(structured_content=result) - except (json.JSONDecodeError, ValueError): + except json.JSONDecodeError: return ToolResult(content=response.text) except httpx.HTTPStatusError as e: diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 9a53acb69..bd8d8a497 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -240,6 +240,13 @@ class FunctionTool(Tool): output_schema = None # Note: explicit schemas (dict) are used as-is without auto-wrapping + # Validate that explicit schemas are object type for structured content + if output_schema is not None and isinstance(output_schema, dict): + if output_schema.get("type") != "object": + raise ValueError( + f'Output schemas must have "type" set to "object" due to MCP spec limitations. Received: {output_schema!r}' + ) + return cls( fn=parsed_fn.fn, name=name or parsed_fn.name, @@ -264,6 +271,7 @@ class FunctionTool(Tool): type_adapter = get_cached_typeadapter(self.fn) result = type_adapter.validate_python(arguments) + if inspect.isawaitable(result): result = await result @@ -275,6 +283,7 @@ class FunctionTool(Tool): # Handle structured content based on output schema if self.output_schema is not None: if self.output_schema.get("x-fastmcp-wrap-result"): + # Schema says wrap - always wrap in result key structured_output = {"result": result} else: structured_output = result @@ -354,26 +363,43 @@ class ParsedFunction: output_schema = None output_type = inspect.signature(fn).return_annotation - # there are a variety of types that we don't want to attempt to - # serialize because they are either used by FastMCP internally, - # or are MCP content types that explicitly don't form structured - # content. By replacing them with an explicitly unserializable type, - # we ensure that no output schema is automatically generated. + if output_type not in (inspect._empty, None, Any, ...): + # there are a variety of types that we don't want to attempt to + # serialize because they are either used by FastMCP internally, + # or are MCP content types that explicitly don't form structured + # content. By replacing them with an explicitly unserializable type, + # we ensure that no output schema is automatically generated. + output_type = replace_type( + output_type, + { + t: _UnserializableType + for t in ( + Image, + Audio, + File, + ToolResult, + mcp.types.TextContent, + mcp.types.ImageContent, + mcp.types.AudioContent, + mcp.types.ResourceLink, + mcp.types.EmbeddedResource, + ) + }, + ) - output_type = replace_type( - output_type, - { - inspect._empty: _UnserializableType, - Image: _UnserializableType, - Audio: _UnserializableType, - File: _UnserializableType, - ToolResult: _UnserializableType, - mcp.types.TextContent: _UnserializableType, - mcp.types.ImageContent: _UnserializableType, - mcp.types.AudioContent: _UnserializableType, - mcp.types.ResourceLink: _UnserializableType, - mcp.types.EmbeddedResource: _UnserializableType, - }, + try: + output_type_adapter = get_cached_typeadapter(output_type) + output_schema = output_type_adapter.json_schema() + except PydanticSchemaGenerationError as e: + if "_UnserializableType" not in str(e): + logger.debug(f"Unable to generate schema for type {output_type!r}") + + return cls( + fn=fn, + name=fn_name, + description=fn_doc, + input_schema=input_schema, + output_schema=output_schema or None, ) try: @@ -388,7 +414,7 @@ class ParsedFunction: name=fn_name, description=fn_doc, input_schema=input_schema, - output_schema=output_schema, + output_schema=output_schema or None, ) diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index f52a54da3..db842c519 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -269,9 +269,32 @@ class TransformedTool(Tool): try: result = await self.fn(**arguments) - # If transform function returns ToolResult, use it directly + # If transform function returns ToolResult, respect our output_schema setting if isinstance(result, ToolResult): - return result + if self.output_schema is None: + # Check if this is from a custom function that returns ToolResult + import inspect + + return_annotation = inspect.signature(self.fn).return_annotation + if return_annotation is ToolResult: + # Custom function returns ToolResult - preserve its content + return result + else: + # Forwarded call with disabled schema - strip structured content + return ToolResult( + content=result.content, + structured_content=None, + ) + elif self.output_schema.get( + "type" + ) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"): + # Non-object explicit schemas disable structured content + return ToolResult( + content=result.content, + structured_content=None, + ) + else: + return result # Otherwise convert to content and create ToolResult with proper structured content from fastmcp.tools.tool import _convert_to_content @@ -283,8 +306,11 @@ class TransformedTool(Tool): # Handle structured content based on output schema if self.output_schema is not None: if self.output_schema.get("x-fastmcp-wrap-result"): + # Schema says wrap - always wrap in result key structured_output = {"result": result} else: + # Object schemas - use result directly + # User is responsible for returning dict-compatible data structured_output = result else: structured_output = None @@ -381,7 +407,16 @@ class TransformedTool(Tool): parsed_fn = ParsedFunction.from_function(transform_fn, validate=False) final_output_schema = _wrap_schema_if_needed(parsed_fn.output_schema) if final_output_schema is None: - final_output_schema = tool.output_schema + # Check if function returns ToolResult - if so, don't fall back to parent + import inspect + + return_annotation = inspect.signature( + transform_fn + ).return_annotation + if return_annotation is ToolResult: + final_output_schema = None + else: + final_output_schema = tool.output_schema else: final_output_schema = tool.output_schema diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index 2e48cd8a0..6be3c49ad 100644 --- a/src/fastmcp/utilities/json_schema_type.py +++ b/src/fastmcp/utilities/json_schema_type.py @@ -169,8 +169,17 @@ def json_schema_to_type( """ # Always use the top-level schema for references if schema.get("type") == "object": - # If no properties defined but additionalProperties is True, return dict[str, Any] - if not schema.get("properties") and schema.get("additionalProperties") is True: + # If no properties defined but has additionalProperties, return typed dict + if not schema.get("properties") and schema.get("additionalProperties"): + additional_props = schema["additionalProperties"] + if additional_props is True: + return dict[str, Any] # type: ignore - additionalProperties: true means dict[str, Any] + else: + # Handle typed dictionaries like dict[str, str] + value_type = _schema_to_type(additional_props, schemas=schema) + return dict[str, value_type] # type: ignore + # If no properties and no additionalProperties, default to dict[str, Any] for safety + elif not schema.get("properties") and not schema.get("additionalProperties"): return dict[str, Any] # type: ignore # If has properties AND additionalProperties is True, use Pydantic BaseModel elif schema.get("properties") and schema.get("additionalProperties") is True: @@ -328,6 +337,43 @@ def _schema_to_type( if "enum" in schema: return _create_enum(f"Enum_{len(_classes)}", schema["enum"]) + # Handle anyOf unions + if "anyOf" in schema: + types: list[type | Any] = [] + for subschema in schema["anyOf"]: + # Special handling for dict-like objects in unions + if ( + subschema.get("type") == "object" + and not subschema.get("properties") + and subschema.get("additionalProperties") + ): + # This is a dict type, handle it directly + additional_props = subschema["additionalProperties"] + if additional_props is True: + types.append(dict[str, Any]) # type: ignore + else: + value_type = _schema_to_type(additional_props, schemas) + types.append(dict[str, value_type]) # type: ignore + else: + types.append(_schema_to_type(subschema, schemas)) + + # Check if one of the types is None (null) + has_null = type(None) in types + types = [t for t in types if t is not type(None)] + + if len(types) == 0: + return type(None) + elif len(types) == 1: + if has_null: + return Optional[types[0]] # type: ignore # noqa: UP007 + else: + return types[0] + else: + if has_null: + return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007 + else: + return Union[tuple(types)] # type: ignore # noqa: UP007 + schema_type = schema.get("type") if not schema_type: return Any diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py index 38dc16872..578dcba96 100644 --- a/tests/contrib/test_bulk_tool_caller.py +++ b/tests/contrib/test_bulk_tool_caller.py @@ -45,13 +45,8 @@ async def echo_tool(arg1: str) -> str: def echo_tool_result_factory(arg1: str) -> CallToolRequestResult: """A tool that returns a result based on the input arguments.""" return CallToolRequestResult( - isError=True, - content=[ - TextContent( - text="Output validation error: outputSchema defined but no structured output returned", - type="text", - ) - ], + isError=False, + content=[TextContent(text=f"{arg1}", type="text")], tool="echo_tool", arguments={"arg1": arg1}, ) @@ -64,13 +59,8 @@ async def no_return_tool(arg1: str) -> None: def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult: """A tool that returns a result based on the input arguments.""" return CallToolRequestResult( - isError=True, - content=[ - TextContent( - text="Output validation error: outputSchema defined but no structured output returned", - type="text", - ) - ], + isError=False, + content=[], tool="no_return_tool", arguments={"arg1": arg1}, ) diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index ff88e1e7a..32aa87588 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -86,9 +86,8 @@ async def test_http_headers_tool_shttp(shttp_server: str): ) ) as client: result = await client.call_tool("get_headers_tool") - json_result = json.loads(result[0].text) # type: ignore[attr-defined] - assert "x-demo-header" in json_result - assert json_result["x-demo-header"] == "ABC" + assert "x-demo-header" in result.data + assert result.data["x-demo-header"] == "ABC" async def test_http_headers_tool_sse(sse_server: str): @@ -96,9 +95,8 @@ async def test_http_headers_tool_sse(sse_server: str): transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) ) as client: result = await client.call_tool("get_headers_tool") - json_result = json.loads(result[0].text) # type: ignore[attr-defined] - assert "x-demo-header" in json_result - assert json_result["x-demo-header"] == "ABC" + assert "x-demo-header" in result.data + assert result.data["x-demo-header"] == "ABC" async def test_http_headers_prompt_shttp(shttp_server: str): diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index fcabb1ac9..1f8f6611c 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -278,7 +278,7 @@ async def test_call_nested_imported_tool(): async with Client(main_app) as client: result = await client.call_tool("service_provider_compute", {"input": 21}) - assert result.data == "42" + assert result.data == 42 async def test_import_with_proxy_tools(): diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 59e4bdc07..9c08955c2 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -127,8 +127,9 @@ class TestToolDecorator: def add(x: int, y: int) -> int: return x + y - result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("add", {"x": 1, "y": 2}) + assert result.data == 3 async def test_tool_decorator_without_parentheses(self): """Test that @tool decorator works without parentheses.""" @@ -144,8 +145,9 @@ class TestToolDecorator: assert "add" in tools # Verify it can be called - result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("add", {"x": 1, "y": 2}) + assert result.data == 3 async def test_tool_decorator_with_name(self): mcp = FastMCP() @@ -154,8 +156,9 @@ class TestToolDecorator: def add(x: int, y: int) -> int: return x + y - result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("custom-add", {"x": 1, "y": 2}) + assert result.data == 3 async def test_tool_decorator_with_description(self): mcp = FastMCP() @@ -181,8 +184,9 @@ class TestToolDecorator: obj = MyClass(10) mcp.add_tool(Tool.from_function(obj.add)) - result = await mcp._mcp_call_tool("add", {"y": 2}) - assert result[0].text == "12" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("add", {"y": 2}) + assert result.data == 12 async def test_tool_decorator_classmethod(self): mcp = FastMCP() @@ -195,8 +199,9 @@ class TestToolDecorator: return cls.x + y mcp.add_tool(Tool.from_function(MyClass.add)) - result = await mcp._mcp_call_tool("add", {"y": 2}) - assert result[0].text == "12" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("add", {"y": 2}) + assert result.data == 12 async def test_tool_decorator_staticmethod(self): mcp = FastMCP() @@ -207,8 +212,9 @@ class TestToolDecorator: def add(x: int, y: int) -> int: return x + y - result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("add", {"x": 1, "y": 2}) + assert result.data == 3 async def test_tool_decorator_async_function(self): mcp = FastMCP() @@ -217,8 +223,9 @@ class TestToolDecorator: async def add(x: int, y: int) -> int: return x + y - result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("add", {"x": 1, "y": 2}) + assert result.data == 3 async def test_tool_decorator_classmethod_error(self): mcp = FastMCP() @@ -242,8 +249,9 @@ class TestToolDecorator: return cls.x + y mcp.add_tool(Tool.from_function(MyClass.add)) - result = await mcp._mcp_call_tool("add", {"y": 2}) - assert result[0].text == "12" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("add", {"y": 2}) + assert result.data == 12 async def test_tool_decorator_staticmethod_async_function(self): mcp = FastMCP() @@ -254,8 +262,9 @@ class TestToolDecorator: return x + y mcp.add_tool(Tool.from_function(MyClass.add)) - result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("add", {"x": 1, "y": 2}) + assert result.data == 3 async def test_tool_decorator_staticmethod_order(self): """Test that the recommended decorator order works for static methods""" @@ -268,8 +277,9 @@ class TestToolDecorator: return x + y # Test that the recommended order works - result = await mcp._mcp_call_tool("add_v1", {"x": 1, "y": 2}) - assert result[0].text == "3" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("add_v1", {"x": 1, "y": 2}) + assert result.data == 3 async def test_tool_decorator_with_tags(self): """Test that the tool decorator properly sets tags.""" @@ -299,8 +309,9 @@ class TestToolDecorator: assert "custom_multiply" in tools # Call the tool by its custom name - result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3}) - assert result[0].text == "15" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("custom_multiply", {"a": 5, "b": 3}) + assert result.data == 15 # Original name should not be registered assert "multiply" not in tools @@ -354,8 +365,9 @@ class TestToolDecorator: assert tools["direct_call_tool"] is result_fn # Verify it can be called - result = await mcp._mcp_call_tool("direct_call_tool", {"x": 5, "y": 3}) - assert result[0].text == "8" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("direct_call_tool", {"x": 5, "y": 3}) + assert result.data == 8 async def test_tool_decorator_with_string_name(self): """Test that @tool("custom_name") syntax works correctly.""" @@ -372,8 +384,9 @@ class TestToolDecorator: assert "my_function" not in tools # Original name should not be registered # Verify it can be called - result = await mcp._mcp_call_tool("string_named_tool", {"x": 42}) - assert result[0].text == "Result: 42" # type: ignore[attr-defined] + async with Client(mcp) as client: + result = await client.call_tool("string_named_tool", {"x": 42}) + assert result.data == "Result: 42" async def test_tool_decorator_conflicting_names_error(self): """Test that providing both positional and keyword name raises an error.""" @@ -391,11 +404,13 @@ class TestToolDecorator: async def test_tool_decorator_with_output_schema(self): mcp = FastMCP() - @mcp.tool(output_schema={"type": "integer"}) - def my_function(x: int) -> str: - return f"Result: {x}" + with pytest.raises( + ValueError, match='Output schemas must have "type" set to "object"' + ): - assert my_function.output_schema == {"type": "integer"} + @mcp.tool(output_schema={"type": "integer"}) + def my_function(x: int) -> str: + return f"Result: {x}" class TestResourceDecorator: diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 1e810d891..b91b83b44 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -5,7 +5,7 @@ import uuid from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import Annotated, Literal +from typing import Annotated, Any, Literal import pytest from mcp import McpError @@ -946,6 +946,198 @@ class TestToolOutputSchema: assert result.structured_content == {"message": "Hello, world!"} assert result.data == {"message": "Hello, world!"} + async def test_output_schema_false_full_handshake(self): + """Test that output_schema=False works through full client/server handshake.""" + mcp = FastMCP() + + @mcp.tool(output_schema=False) + def simple_tool() -> dict[str, str]: + return {"message": "Hello from disabled schema"} + + async with Client(mcp) as client: + # List tools and verify output schema is None + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "simple_tool") + assert tool.outputSchema is None + + # Call tool and verify no structured content + result = await client.call_tool("simple_tool", {}) + assert result.structured_content is None + assert result.data is None + assert json.loads(result.content[0].text) == { + "message": "Hello from disabled schema" + } # type: ignore[attr-defined] + + async def test_output_schema_explicit_object_full_handshake(self): + """Test explicit object output schema through full client/server handshake.""" + 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} + + async with Client(mcp) as client: + # List tools and verify exact schema is preserved + tools = await client.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.outputSchema == expected_schema + + # Call tool and verify structured content matches return value directly + result = await client.call_tool("explicit_tool", {}) + assert result.structured_content == {"greeting": "Hello", "count": 42} + # Client deserializes according to schema, so check fields + assert result.data.greeting == "Hello" # type: ignore[attr-defined] + assert result.data.count == 42 # type: ignore[attr-defined] + + async def test_output_schema_wrapped_primitive_full_handshake(self): + """Test wrapped primitive output schema through full client/server handshake.""" + mcp = FastMCP() + + @mcp.tool + def primitive_tool() -> str: + return "Hello, primitives!" + + async with Client(mcp) as client: + # List tools and verify schema shows wrapped structure + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "primitive_tool") + expected_schema = { + "type": "object", + "properties": {"result": {"type": "string"}}, + "x-fastmcp-wrap-result": True, + } + assert tool.outputSchema == expected_schema + + # Call tool and verify structured content is wrapped + result = await client.call_tool("primitive_tool", {}) + assert result.structured_content == {"result": "Hello, primitives!"} + assert result.data == "Hello, primitives!" # Client unwraps for convenience + + async def test_output_schema_complex_type_full_handshake(self): + """Test complex type output schema through full client/server handshake.""" + mcp = FastMCP() + + @mcp.tool + def complex_tool() -> list[dict[str, int]]: + return [{"a": 1, "b": 2}, {"c": 3, "d": 4}] + + async with Client(mcp) as client: + # List tools and verify schema shows wrapped array + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "complex_tool") + expected_inner_schema = TypeAdapter(list[dict[str, int]]).json_schema() + expected_schema = { + "type": "object", + "properties": {"result": expected_inner_schema}, + "x-fastmcp-wrap-result": True, + } + assert tool.outputSchema == expected_schema + + # Call tool and verify structured content is wrapped + result = await client.call_tool("complex_tool", {}) + expected_data = [{"a": 1, "b": 2}, {"c": 3, "d": 4}] + assert result.structured_content == {"result": expected_data} + # Client deserializes - just verify we got data back + assert result.data is not None + + async def test_output_schema_dataclass_full_handshake(self): + """Test dataclass output schema through full client/server handshake.""" + mcp = FastMCP() + + @dataclass + class User: + name: str + age: int + + @mcp.tool + def dataclass_tool() -> User: + return User(name="Alice", age=30) + + async with Client(mcp) as client: + # List tools and verify schema is object type (not wrapped) + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "dataclass_tool") + expected_schema = TypeAdapter(User).json_schema() + assert tool.outputSchema == expected_schema + assert "x-fastmcp-wrap-result" not in tool.outputSchema + + # Call tool and verify structured content is direct + result = await client.call_tool("dataclass_tool", {}) + assert result.structured_content == {"name": "Alice", "age": 30} + # Client deserializes according to schema + assert result.data.name == "Alice" # type: ignore[attr-defined] + assert result.data.age == 30 # type: ignore[attr-defined] + + 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 mixed content that includes MCP types and regular data + return [ + "text message", + {"structured": "data"}, + TextContent(type="text", text="direct MCP content"), + ] + + async with Client(mcp) as client: + result = await client.call_tool("mixed_output", {}) + + # Should have multiple content blocks + assert len(result.content) >= 2 + + # Should have structured output with wrapped result + expected_data = [ + "text message", + {"structured": "data"}, + { + "type": "text", + "text": "direct MCP content", + "annotations": None, + "_meta": None, + }, + ] + assert result.structured_content == {"result": expected_data} + + 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") + + async with Client(mcp) as client: + # Verify tuple gets proper schema + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "edge_case_tool") + + # Tuples should be wrapped since they're not object type + assert "x-fastmcp-wrap-result" in tool.outputSchema + + result = await client.call_tool("edge_case_tool", {}) + # Should be wrapped with result key + assert result.structured_content == {"result": [42, "hello"]} + assert result.data == [42, "hello"] + class TestToolContextInjection: """Test context injection in tools.""" diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index b03559cdf..6a4b5ac24 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -263,14 +263,16 @@ class TestToolFromFunctionOutputSchema: @pytest.mark.parametrize( "annotation", [ - None, 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], @@ -304,7 +306,6 @@ class TestToolFromFunctionOutputSchema: @pytest.mark.parametrize( "annotation", [ - Any, AnyUrl, Annotated[int, Field(ge=1)], Annotated[int, Field(ge=1)], @@ -317,17 +318,26 @@ class TestToolFromFunctionOutputSchema: tool = Tool.from_function(func) base_schema = TypeAdapter(annotation).json_schema() - # Special case for Any type - it generates an empty schema and doesn't get wrapped - if annotation is Any: - assert tool.output_schema == base_schema # Should be {} - else: - # All other non-object types get wrapped, including complex constrained types - expected_schema = { - "type": "object", - "properties": {"result": base_schema}, - "x-fastmcp-wrap-result": True, - } - assert tool.output_schema == expected_schema + expected_schema = { + "type": "object", + "properties": {"result": base_schema}, + "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", @@ -413,7 +423,7 @@ class TestToolFromFunctionOutputSchema: return {"a": 1, "b": 2} # Provide a custom output schema that differs from the inferred one - custom_schema = {"type": "string", "description": "Custom schema"} + custom_schema = {"type": "object", "description": "Custom schema"} tool = Tool.from_function(func, output_schema=custom_schema) assert tool.output_schema == custom_schema @@ -445,7 +455,10 @@ class TestToolFromFunctionOutputSchema: return Unserializable(data="test") # Provide a custom output schema even though the annotation is unserializable - custom_schema = {"type": "array", "items": {"type": "string"}} + 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 @@ -457,7 +470,10 @@ class TestToolFromFunctionOutputSchema: return "hello" # Provide a custom output schema even though there's no return annotation - custom_schema = {"type": "number", "minimum": 0} + 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 @@ -486,7 +502,7 @@ class TestToolFromFunctionOutputSchema: return "hello" # Provide a custom output schema that differs from the inferred union schema - custom_schema = {"type": "boolean"} + custom_schema = {"type": "object", "properties": {"flag": {"type": "boolean"}}} tool = Tool.from_function(func, output_schema=custom_schema) assert tool.output_schema == custom_schema @@ -504,11 +520,215 @@ class TestToolFromFunctionOutputSchema: return Person(name="John", age=30) # Provide a custom output schema that differs from the inferred Person schema - custom_schema = {"type": "array", "items": {"type": "number"}} + 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_disables_structured_content(self): + """Test that output_schema=False disables structured content generation.""" + + def func() -> dict[str, str]: + return {"message": "Hello, world!"} + + tool = Tool.from_function(func, output_schema=False) + assert tool.output_schema is None + + result = await tool.run({}) + assert result.structured_content is None + assert len(result.content) == 1 + assert result.content[0].text == '{\n "message": "Hello, world!"\n}' + + 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 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) + expected_schema = { + "type": "object", + "properties": {"result": {"type": "integer"}}, + "x-fastmcp-wrap-result": True, + } + assert tool.output_schema == expected_schema + + 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 "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 result.content[0].text == '{\n "value": 42\n}' + + 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 "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) + expected_schema = { + "type": "object", + "properties": {"result": {"type": "string"}}, + "x-fastmcp-wrap-result": True, + } + assert tool.output_schema == expected_schema + + result = await tool.run({}) + # Unstructured content + assert len(result.content) == 1 + 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 + + # False should also disable + tool_false = Tool.from_function(func, output_schema=False) + assert tool_false.output_schema is None + + # Both should have same behavior + result_none = await tool_none.run({}) + result_false = await tool_false.run({}) + + assert result_none.structured_content is None + assert result_false.structured_content is None + assert result_none.content[0].text == result_false.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 have "type" set to "object"' + ): + Tool.from_function(func, output_schema=schema) + class TestConvertResultToContent: """Tests for the _convert_to_content helper function.""" diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index 014422452..ba25d19a4 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -4,14 +4,15 @@ from typing import Annotated, Any import pytest from dirty_equals import IsList -from pydantic import BaseModel, Field +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.tool import FunctionTool +from fastmcp.tools.tool import FunctionTool, ToolResult from fastmcp.tools.tool_transform import ArgTransform, TransformedTool @@ -691,7 +692,7 @@ async def test_arg_transform_type_precedence_runtime(): # Convert string back to int for the original function result = await forward_raw(x=int(x), y=y) # Extract the text from the result - result_text = result.content[0].text + result_text = result.content[0].text # type: ignore[attr-defined] return f"String input '{x}' converted to result: {result_text}" tool = Tool.from_tool( @@ -1030,3 +1031,266 @@ def test_arg_transform_examples_in_schema(add_tool): ) prop3 = get_property(new_tool3, "old_x") assert "examples" not in prop3 + + +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"}}, + "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_false(self, base_string_tool): + """Test that output_schema=False disables structured output.""" + new_tool = Tool.from_tool(base_string_tool, output_schema=False) + + assert new_tool.output_schema is None + + async def test_transform_output_schema_false_runtime(self, base_string_tool): + """Test runtime behavior with output_schema=False.""" + new_tool = Tool.from_tool(base_string_tool, output_schema=False) + + # 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}) + assert result.structured_content is None + assert result.content[0].text == "Result: 5" # type: ignore[attr-defined] + + 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 result.content[0].text == "Result: 10" # type: ignore[attr-defined] + + 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) + return f"Custom: {result.content[0].text}" # type: ignore[attr-defined] + + 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"}}, + "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) + return f"Custom: {result.content[0].text}" # type: ignore[attr-defined] + + 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: {\n "value": 3\n}'} + + 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]: + result = 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 "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 "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) -> list: + # 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 result2.content[0].text == "Custom: 2" # type: ignore[attr-defined] + + 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) + return {"transformed": result.content[0].text} # type: ignore[attr-defined] + + 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_none_vs_false(self, base_string_tool): + """Test None vs False behavior for output_schema in transforms.""" + # None (default) should use smart fallback (inherit from parent) + tool_none = Tool.from_tool(base_string_tool) # default output_schema=None + assert tool_none.output_schema == base_string_tool.output_schema # Inherits + + # False should explicitly disable + tool_false = Tool.from_tool(base_string_tool, output_schema=False) + assert tool_false.output_schema is None + + # Different behavior at runtime + result_none = await tool_none.run({"x": 5}) + result_false = await tool_false.run({"x": 5}) + + assert result_none.structured_content == { + "result": "Result: 5" + } # Inherits wrapping + assert result_false.structured_content is None # Disabled + assert result_none.content[0].text == result_false.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 result.content[0].text == "Direct: 6" # type: ignore[attr-defined] + assert result.structured_content == {"direct_value": 6, "doubled": 12} diff --git a/tests/utilities/test_json_schema_type.py b/tests/utilities/test_json_schema_type.py index 14bf949fe..866facbce 100644 --- a/tests/utilities/test_json_schema_type.py +++ b/tests/utilities/test_json_schema_type.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Union +from typing import Any, Union import pytest from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError @@ -326,6 +326,29 @@ class TestObjectTypes: } ) + @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}) From 5e38048b44ea425575fc08bceadb98044971a4ed Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:45:28 -0400 Subject: [PATCH 20/25] Fix typing issues in json_schema_type --- src/fastmcp/utilities/json_schema_type.py | 40 ++++++++++++----------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index 6be3c49ad..1160c6334 100644 --- a/src/fastmcp/utilities/json_schema_type.py +++ b/src/fastmcp/utilities/json_schema_type.py @@ -47,7 +47,6 @@ from typing import ( Any, ForwardRef, Literal, - Optional, Union, ) @@ -188,7 +187,8 @@ def json_schema_to_type( return _create_dataclass(schema, name, schemas=schema) elif name: raise ValueError(f"Can not apply name to non-object schema: {name}") - return _schema_to_type(schema, schemas=schema) + result = _schema_to_type(schema, schemas=schema) + return result # type: ignore[return-value] def _hash_schema(schema: Mapping[str, Any]) -> str: @@ -252,11 +252,11 @@ def _create_numeric_type( return Annotated[base, Field(**constraints)] if constraints else base -def _create_enum(name: str, values: list[Any]) -> type | Enum: +def _create_enum(name: str, values: list[Any]) -> type: """Create enum type from list of values.""" if all(isinstance(v, str) for v in values): - return Enum(name, {v.upper(): v for v in values}) - return Literal[tuple(values)] # type: ignore + return Enum(name, {v.upper(): v for v in values}) # type: ignore[return-value] + return Literal[tuple(values)] # type: ignore[return-value] def _create_array_type( @@ -272,8 +272,8 @@ def _create_array_type( else: # Handle single item schema item_type = _schema_to_type(items, schemas) - base = set if schema.get("uniqueItems") else list - base = base[item_type] + base_class = set if schema.get("uniqueItems") else list + base = base_class[item_type] # type: ignore[misc] constraints = { k: v @@ -315,7 +315,7 @@ def _get_from_type_handler( def _schema_to_type( schema: Mapping[str, Any], schemas: Mapping[str, Any], -) -> type: +) -> type | ForwardRef: """Convert schema to appropriate Python type.""" if not schema: return object @@ -328,7 +328,7 @@ def _schema_to_type( ref = schema["$ref"] # Handle self-reference if ref == "#": - return ForwardRef(schema.get("title", "Root")) + return ForwardRef(schema.get("title", "Root")) # type: ignore[return-value] return _schema_to_type(_resolve_ref(ref, schemas), schemas) if "const" in schema: @@ -365,7 +365,7 @@ def _schema_to_type( return type(None) elif len(types) == 1: if has_null: - return Optional[types[0]] # type: ignore # noqa: UP007 + return types[0] | None # type: ignore else: return types[0] else: @@ -376,20 +376,20 @@ def _schema_to_type( schema_type = schema.get("type") if not schema_type: - return Any + return Any # type: ignore[return-value] if isinstance(schema_type, list): # Create a copy of the schema for each type, but keep all constraints types: list[type | Any] = [] for t in schema_type: - type_schema = schema.copy() + type_schema = dict(schema) type_schema["type"] = t types.append(_schema_to_type(type_schema, schemas)) has_null = type(None) in types types = [t for t in types if t is not type(None)] if has_null: if len(types) == 1: - return Optional[types[0]] # type: ignore # noqa: UP007 + return types[0] | None # type: ignore else: return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007 return Union[tuple(types)] # type: ignore # noqa: UP007 @@ -447,6 +447,7 @@ def _create_pydantic_model( ) -> type: """Create Pydantic BaseModel from object schema with additionalProperties.""" name = name or schema.get("title", "Root") + assert name is not None # Should not be None after the or operation sanitized_name = _sanitize_name(name) schema_hash = _hash_schema(schema) cache_key = (schema_hash, sanitized_name) @@ -455,7 +456,7 @@ def _create_pydantic_model( if cache_key in _classes: existing = _classes[cache_key] if existing is None: - return ForwardRef(sanitized_name) + return ForwardRef(sanitized_name) # type: ignore[return-value] return existing # Place placeholder for recursive references @@ -479,7 +480,7 @@ def _create_pydantic_model( elif prop_name in required: annotations[prop_name] = field_type else: - annotations[prop_name] = Optional[field_type] + annotations[prop_name] = Union[field_type, type(None)] # type: ignore[misc] # noqa: UP007 defaults[prop_name] = None # Create Pydantic model class @@ -504,6 +505,7 @@ def _create_dataclass( """Create dataclass from object schema.""" name = name or schema.get("title", "Root") # Sanitize name for class creation + assert name is not None # Should not be None after the or operation sanitized_name = _sanitize_name(name) schema_hash = _hash_schema(schema) cache_key = (schema_hash, sanitized_name) @@ -513,7 +515,7 @@ def _create_dataclass( if cache_key in _classes: existing = _classes[cache_key] if existing is None: - return ForwardRef(sanitized_name) + return ForwardRef(sanitized_name) # type: ignore[return-value] return existing # Place placeholder for recursive references @@ -522,7 +524,7 @@ def _create_dataclass( if "$ref" in schema: ref = schema["$ref"] if ref == "#": - return ForwardRef(sanitized_name) + return ForwardRef(sanitized_name) # type: ignore[return-value] schema = _resolve_ref(ref, schemas or {}) properties = schema.get("properties", {}) @@ -536,7 +538,7 @@ def _create_dataclass( if prop_schema.get("$ref") == "#": field_type = ForwardRef(sanitized_name) else: - field_type = _schema_to_type(prop_schema, schemas) + field_type = _schema_to_type(prop_schema, schemas or {}) default_val = prop_schema.get("default", MISSING) is_required = prop_name in required @@ -562,7 +564,7 @@ def _create_dataclass( elif is_required: fields.append((field_name, field_type, field_def)) else: - fields.append((field_name, Optional[field_type], field_def)) + fields.append((field_name, Union[field_type, type(None)], field_def)) # type: ignore[misc] # noqa: UP007 cls = make_dataclass(sanitized_name, fields, kw_only=True) From b3799e7a7329f7ddb87059f68f7b25418b911dd6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:54:17 -0400 Subject: [PATCH 21/25] Update typing --- tests/server/test_server_interactions.py | 14 +++++---- tests/tools/test_tool.py | 14 ++++----- tests/tools/test_tool_transform.py | 38 +++++++++++++----------- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index b91b83b44..c48e5800a 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -906,7 +906,7 @@ class TestToolOutputSchema: mcp = FastMCP() @mcp.tool - def f() -> annotation: + def f() -> annotation: # type: ignore[valid-type] return {"name": "John", "age": 30} async with Client(mcp) as client: @@ -950,7 +950,7 @@ class TestToolOutputSchema: """Test that output_schema=False works through full client/server handshake.""" mcp = FastMCP() - @mcp.tool(output_schema=False) + @mcp.tool(output_schema=False) # type: ignore[arg-type] def simple_tool() -> dict[str, str]: return {"message": "Hello from disabled schema"} @@ -964,9 +964,9 @@ class TestToolOutputSchema: result = await client.call_tool("simple_tool", {}) assert result.structured_content is None assert result.data is None - assert json.loads(result.content[0].text) == { + assert json.loads(result.content[0].text) == { # type: ignore[attr-defined] "message": "Hello from disabled schema" - } # type: ignore[attr-defined] + } async def test_output_schema_explicit_object_full_handshake(self): """Test explicit object output schema through full client/server handshake.""" @@ -1076,7 +1076,9 @@ class TestToolOutputSchema: tool = next(t for t in tools if t.name == "dataclass_tool") expected_schema = TypeAdapter(User).json_schema() assert tool.outputSchema == expected_schema - assert "x-fastmcp-wrap-result" not in tool.outputSchema + assert ( + tool.outputSchema and "x-fastmcp-wrap-result" not in tool.outputSchema + ) # Call tool and verify structured content is direct result = await client.call_tool("dataclass_tool", {}) @@ -1131,7 +1133,7 @@ class TestToolOutputSchema: tool = next(t for t in tools if t.name == "edge_case_tool") # Tuples should be wrapped since they're not object type - assert "x-fastmcp-wrap-result" in tool.outputSchema + assert tool.outputSchema and "x-fastmcp-wrap-result" in tool.outputSchema result = await client.call_tool("edge_case_tool", {}) # Should be wrapped with result key diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 6a4b5ac24..24b10ce1f 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -540,7 +540,7 @@ class TestToolFromFunctionOutputSchema: result = await tool.run({}) assert result.structured_content is None assert len(result.content) == 1 - assert result.content[0].text == '{\n "message": "Hello, world!"\n}' + assert result.content[0].text == '{\n "message": "Hello, world!"\n}' # type: ignore[attr-defined] async def test_output_schema_none_disables_structured_content(self): """Test that output_schema=None explicitly disables structured content.""" @@ -554,7 +554,7 @@ class TestToolFromFunctionOutputSchema: result = await tool.run({}) assert result.structured_content is None assert len(result.content) == 1 - assert result.content[0].text == "42" + assert result.content[0].text == "42" # type: ignore[attr-defined] async def test_output_schema_inferred_when_not_specified(self): """Test that output schema is inferred when not explicitly specified.""" @@ -587,12 +587,12 @@ class TestToolFromFunctionOutputSchema: } tool = Tool.from_function(func, output_schema=explicit_schema) assert tool.output_schema == explicit_schema # Schema not wrapped - assert "x-fastmcp-wrap-result" not in tool.output_schema + 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 result.content[0].text == '{\n "value": 42\n}' + assert result.content[0].text == '{\n "value": 42\n}' # type: ignore[attr-defined] async def test_explicit_object_schema_with_non_dict_return_fails(self): """Test that explicit object schemas fail when function returns non-dict.""" @@ -621,7 +621,7 @@ class TestToolFromFunctionOutputSchema: tool = Tool.from_function(func) expected_schema = TypeAdapter(dict[str, int]).json_schema() assert tool.output_schema == expected_schema # Not wrapped - assert "x-fastmcp-wrap-result" not in tool.output_schema + 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 @@ -644,7 +644,7 @@ class TestToolFromFunctionOutputSchema: result = await tool.run({}) # Unstructured content assert len(result.content) == 1 - assert result.content[0].text == "hello" + assert result.content[0].text == "hello" # type: ignore[attr-defined] # Structured content should be wrapped assert result.structured_content == {"result": "hello"} @@ -706,7 +706,7 @@ class TestToolFromFunctionOutputSchema: assert result_none.structured_content is None assert result_false.structured_content is None - assert result_none.content[0].text == result_false.content[0].text == "123" + assert result_none.content[0].text == result_false.content[0].text == "123" # type: ignore[attr-defined] async def test_non_object_output_schema_raises_error(self): """Test that providing a non-object output schema raises a ValueError.""" diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index ba25d19a4..9a31db81d 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -131,7 +131,7 @@ async def test_hidden_arg_without_default_uses_parent_default(add_tool): 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) -> int: + 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) @@ -195,7 +195,7 @@ async def test_hide_required_param_with_user_default_works(): 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) -> int: + 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( @@ -213,7 +213,7 @@ async def test_forward_with_argument_mapping(add_tool): async def test_forward_with_incorrect_args_raises_error(add_tool): - async def custom_fn(new_x: int, new_y: int = 5) -> int: + 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) @@ -234,7 +234,7 @@ async def test_forward_with_incorrect_args_raises_error(add_tool): 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) -> int: + 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 @@ -254,7 +254,7 @@ async def test_forward_raw_without_argument_mapping(add_tool): async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool): - async def custom_fn(extra: int, **kwargs) -> int: + async def custom_fn(extra: int, **kwargs) -> ToolResult: sum = await forward(**kwargs) return int(sum.content[0].text) + extra # type: ignore[attr-defined] @@ -271,7 +271,7 @@ async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool): async def test_fn_with_kwargs_passes_through_original_args(add_tool): - async def custom_fn(new_y: int = 5, **kwargs) -> int: + 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 @@ -285,7 +285,7 @@ async def test_fn_with_kwargs_passes_through_original_args(add_tool): 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) -> int: + 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) @@ -304,7 +304,9 @@ async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool): 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) -> int: + 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) @@ -325,7 +327,7 @@ async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool): 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) -> int: + 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) @@ -344,7 +346,7 @@ async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool): 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) -> int: + 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' @@ -486,7 +488,7 @@ async def test_tool_transform_chaining(add_tool): # 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) - return f"custom {result.content[0].text}" # Extract text from content + return f"custom {result.content[0].text}" # Extract text from content # type: ignore[attr-defined] tool3 = Tool.from_tool( tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")} @@ -620,7 +622,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs(): # Function signature has different types/defaults than ArgTransform async def custom_fn(x: str = "function_default", **kwargs) -> str: result = await forward(x=x, **kwargs) - return f"custom: {result.content[0].text}" + return f"custom: {result.content[0].text}" # type: ignore[attr-defined] tool = Tool.from_tool( base, @@ -1168,7 +1170,7 @@ class TestTransformToolOutputSchema: """Test custom function returning object type.""" async def custom_fn(x: int) -> dict[str, int]: - result = await forward(x=x) + await forward(x=x) return {"original": x, "transformed": x * 2} new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn) @@ -1176,7 +1178,7 @@ class TestTransformToolOutputSchema: # Object types should not be wrapped expected_schema = TypeAdapter(dict[str, int]).json_schema() assert new_tool.output_schema == expected_schema - assert "x-fastmcp-wrap-result" not in new_tool.output_schema + assert "x-fastmcp-wrap-result" not in new_tool.output_schema # type: ignore[attr-defined] result = await new_tool.run({"x": 4}) # Direct value, not wrapped @@ -1189,7 +1191,7 @@ class TestTransformToolOutputSchema: result = await new_tool.run({"x": 7}) # Should wrap because parent schema has wrap marker assert result.structured_content == {"result": "Result: 7"} - assert "x-fastmcp-wrap-result" in new_tool.output_schema + assert "x-fastmcp-wrap-result" in new_tool.output_schema # type: ignore[attr-defined] def test_transform_chained_output_schema_inheritance(self, base_string_tool): """Test output schema inheritance through multiple transformations.""" @@ -1214,10 +1216,10 @@ class TestTransformToolOutputSchema: ): """Test transformation handling of mixed content types.""" - async def custom_fn(x: int) -> list: + async def custom_fn(x: int) -> ToolResult: # Return mixed content including ToolResult if x == 1: - return ["text", {"data": x}] + return ["text", {"data": x}] # type: ignore[return-value] else: # Return ToolResult directly return ToolResult( @@ -1271,7 +1273,7 @@ class TestTransformToolOutputSchema: "result": "Result: 5" } # Inherits wrapping assert result_false.structured_content is None # Disabled - assert result_none.content[0].text == result_false.content[0].text + assert result_none.content[0].text == result_false.content[0].text # type: ignore[attr-defined] async def test_transform_output_schema_with_tool_result_return( self, base_string_tool From 5520102821bcff6741f4da9f23d01a2c10348495 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Jun 2025 22:06:35 -0400 Subject: [PATCH 22/25] Update failing tests --- tests/tools/test_tool.py | 3 ++- tests/tools/test_tool_transform.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 24b10ce1f..67ebe9da7 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -1,6 +1,6 @@ import json from dataclasses import dataclass -from typing import Annotated, Any, TypedDict +from typing import Annotated, Any import pytest from mcp.types import ( @@ -11,6 +11,7 @@ from mcp.types import ( TextResourceContents, ) from pydantic import AnyUrl, BaseModel, Field, TypeAdapter +from typing_extensions import TypedDict from fastmcp.tools.tool import Tool, _convert_to_content from fastmcp.utilities.types import Audio, File, Image diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index 9a31db81d..5e976b381 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -254,7 +254,7 @@ async def test_forward_raw_without_argument_mapping(add_tool): async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool): - async def custom_fn(extra: int, **kwargs) -> ToolResult: + async def custom_fn(extra: int, **kwargs) -> int: sum = await forward(**kwargs) return int(sum.content[0].text) + extra # type: ignore[attr-defined] @@ -1216,10 +1216,10 @@ class TestTransformToolOutputSchema: ): """Test transformation handling of mixed content types.""" - async def custom_fn(x: int) -> ToolResult: + async def custom_fn(x: int): # Return mixed content including ToolResult if x == 1: - return ["text", {"data": x}] # type: ignore[return-value] + return ["text", {"data": x}] else: # Return ToolResult directly return ToolResult( From c412a63d4e8dbc51f50ecedbf8cfbbc0692eb5c6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Jun 2025 22:38:55 -0400 Subject: [PATCH 23/25] Update docs --- docs/clients/tools.mdx | 126 ++++++++++++-- docs/patterns/tool-transformation.mdx | 40 ++++- docs/servers/tools.mdx | 199 ++++++++++++++++++++-- src/fastmcp/tools/tool.py | 18 +- src/fastmcp/tools/tool_transform.py | 29 +++- tests/server/test_server_interactions.py | 6 +- tests/tools/test_tool.py | 203 ++++++++++++++++++++++- 7 files changed, 575 insertions(+), 46 deletions(-) diff --git a/docs/clients/tools.mdx b/docs/clients/tools.mdx index 3821725cb..68d4424bc 100644 --- a/docs/clients/tools.mdx +++ b/docs/clients/tools.mdx @@ -37,10 +37,13 @@ Execute a tool using `call_tool()` with the tool name and arguments: async with client: # Simple tool call result = await client.call_tool("add", {"a": 5, "b": 3}) - # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...] + # result -> CallToolResult with structured and unstructured data - # Access the result content - print(result[0].text) # Assuming TextContent, e.g., '8' + # Access structured data (automatically deserialized) + print(result.data) # 8 (int) or {"result": 8} for primitive types + + # Access traditional content blocks + print(result.content[0].text) # "8" (TextContent) ``` ### Advanced Execution Options @@ -72,21 +75,97 @@ async with client: ## Handling Results -Tool execution returns a list of content objects. The most common types are: + -- **`TextContent`**: Text-based results with a `.text` attribute -- **`ImageContent`**: Image data with image-specific attributes -- **`BlobContent`**: Binary data content +Tool execution returns a `CallToolResult` object with both structured and traditional content. FastMCP's standout feature is the `.data` property, which doesn't just provide raw JSON but actually hydrates complete Python objects including complex types like datetimes, UUIDs, and custom classes. + +### CallToolResult Properties + + + + **FastMCP exclusive**: Fully hydrated Python objects with complex type support (datetimes, UUIDs, custom classes). Goes beyond JSON to provide complete object reconstruction from output schemas. + + + + Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.) available from all MCP servers. + + + + Standard MCP structured JSON data as sent by the server, available from all MCP servers that support structured outputs. + + + + Boolean indicating if the tool execution failed. + + + +### Structured Data Access + +FastMCP's `.data` property provides fully hydrated Python objects, not just JSON dictionaries. This includes complex type reconstruction: ```python +from datetime import datetime +from uuid import UUID + async with client: result = await client.call_tool("get_weather", {"city": "London"}) - for content in result: - if hasattr(content, 'text'): - print(f"Text result: {content.text}") - elif hasattr(content, 'data'): - print(f"Binary data: {len(content.data)} bytes") + # FastMCP reconstructs complete Python objects from the server's output schema + weather = result.data # Server-defined WeatherReport object + print(f"Temperature: {weather.temperature}°C at {weather.timestamp}") + print(f"Station: {weather.station_id}") + print(f"Humidity: {weather.humidity}%") + + # The timestamp is a real datetime object, not a string! + assert isinstance(weather.timestamp, datetime) + assert isinstance(weather.station_id, UUID) + + # Compare with raw structured JSON (standard MCP) + print(f"Raw JSON: {result.structured_content}") + # {"temperature": 20, "timestamp": "2024-01-15T14:30:00Z", "station_id": "123e4567-..."} + + # Traditional content blocks (standard MCP) + print(f"Text content: {result.content[0].text}") +``` + +### Fallback Behavior + +For tools without output schemas or when deserialization fails, `.data` will be `None`: + +```python +async with client: + result = await client.call_tool("legacy_tool", {"param": "value"}) + + if result.data is not None: + # Structured output available and successfully deserialized + print(f"Structured: {result.data}") + else: + # No structured output or deserialization failed - use content blocks + for content in result.content: + if hasattr(content, 'text'): + print(f"Text result: {content.text}") + elif hasattr(content, 'data'): + print(f"Binary data: {len(content.data)} bytes") +``` + +### Primitive Type Unwrapping + + +FastMCP servers automatically wrap non-object results (like `int`, `str`, `bool`) in a `{"result": value}` structure to create valid structured outputs. FastMCP clients understand this convention and automatically unwrap the value in `.data` for convenience, so you get the original primitive value instead of a wrapper object. + + +```python +async with client: + result = await client.call_tool("calculate_sum", {"a": 5, "b": 3}) + + # FastMCP client automatically unwraps for convenience + print(result.data) # 8 (int) - the original value + + # Raw structured content shows the server-side wrapping + print(result.structured_content) # {"result": 8} + + # Other MCP clients would need to manually access ["result"] + # value = result.structured_content["result"] # Not needed with FastMCP! ``` ## Error Handling @@ -101,14 +180,32 @@ from fastmcp.exceptions import ToolError async with client: try: result = await client.call_tool("potentially_failing_tool", {"param": "value"}) - print("Tool succeeded:", result) + print("Tool succeeded:", result.data) except ToolError as e: print(f"Tool failed: {e}") ``` ### Manual Error Checking -For more granular control, use `call_tool_mcp()` which returns the raw MCP protocol object with an `isError` flag: +You can disable automatic error raising and manually check the result: + +```python +async with client: + result = await client.call_tool( + "potentially_failing_tool", + {"param": "value"}, + raise_on_error=False + ) + + if result.is_error: + print(f"Tool failed: {result.content[0].text}") + else: + print(f"Tool succeeded: {result.data}") +``` + +### Raw MCP Protocol Access + +For complete control, use `call_tool_mcp()` which returns the raw MCP protocol object: ```python async with client: @@ -119,6 +216,7 @@ async with client: print(f"Tool failed: {result.content}") else: print(f"Tool succeeded: {result.content}") + # Note: No automatic deserialization with call_tool_mcp() ``` ## Argument Handling diff --git a/docs/patterns/tool-transformation.mdx b/docs/patterns/tool-transformation.mdx index f736f791c..c9528282e 100644 --- a/docs/patterns/tool-transformation.mdx +++ b/docs/patterns/tool-transformation.mdx @@ -89,6 +89,7 @@ The `Tool.from_tool()` class method is the primary way to create a transformed t - `description`: An optional description for the new tool. - `transform_args`: A dictionary of `ArgTransform` objects, one for each argument you want to modify. - `transform_fn`: An optional function that will be called instead of the parent tool's logic. +- `output_schema`: Control output schema and structured outputs (see [Output Schema Control](#output-schema-control)). - `tags`: An optional set of tags for the new tool. - `annotations`: An optional set of `ToolAnnotations` for the new tool. - `serializer`: An optional function that will be called to serialize the result of the new tool. @@ -439,7 +440,44 @@ mcp.add_tool(new_tool) In the above example, `**kwargs` receives the renamed argument `b`, not the original argument `y`. It is therefore recommended to use with `forward()`, not `forward_raw()`. - + + +## Output Schema Control + + + +Transformed tools inherit output schemas from their parent by default, but you can control this behavior: + +**Inherit from Parent (Default)** +```python +Tool.from_tool(parent_tool, name="renamed_tool") +``` +The transformed tool automatically uses the parent tool's output schema and structured output behavior. + +**Custom Output Schema** +```python +Tool.from_tool(parent_tool, output_schema={ + "type": "object", + "properties": {"status": {"type": "string"}} +}) +``` +Provide your own schema that differs from the parent. The tool must return data matching this schema. + +**Remove Output Schema** +```python +Tool.from_tool(parent_tool, output_schema=False) +``` +Removes the output schema declaration. Automatic structured content still works for object-like returns (dict, dataclass, Pydantic models) but primitive types won't be structured. + +**Full Control with Transform Functions** +```python +async def custom_output(**kwargs) -> ToolResult: + result = await forward(**kwargs) + return ToolResult(content=[...], structured_content={...}) + +Tool.from_tool(parent_tool, transform_fn=custom_output) +``` +Use a transform function returning `ToolResult` for complete control over both content blocks and structured outputs. ## Common Patterns diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 5c5e9cb94..7ba5535a4 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -288,28 +288,100 @@ Use `async def` when your tool needs to perform operations that might wait for e ### Return Values -#### Output Conversion -FastMCP automatically converts the value returned by your function into the appropriate MCP content format for the client: +FastMCP tools can return data in two complementary formats: **traditional content blocks** (like text and images) and **structured outputs** (machine-readable JSON). When you add return type annotations, FastMCP automatically generates **output schemas** to validate the structured data and enables clients to deserialize results back to Python objects. -- **`str`**: Sent as `TextContent`. -- **`dict`, `list`, Pydantic `BaseModel`**: Serialized to a JSON string and sent as `TextContent`. -- **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (often within an `EmbeddedResource`). -- **`fastmcp.utilities.types.Image`**: A helper class for easily returning image data. Sent as `ImageContent`. -- **`fastmcp.utilities.types.Audio`**: A helper class for easily returning audio data. Sent as `AudioContent`. -- **`fastmcp.utilities.types.File`**: A helper class for easily returning binary data as base64-encoded content. Sent as `EmbeddedResource`. -- **A list of any of the above**: Automatically converts each item appropriately. -- **`None`**: Results in an empty response (no content is sent back to the client). +Understanding how these three concepts work together: -FastMCP will attempt to serialize other types to a string if possible. +- **Return Values**: What your Python function returns (determines both content blocks and structured data) +- **Structured Outputs**: JSON data sent alongside traditional content for machine processing +- **Output Schemas**: JSON Schema declarations that describe and validate the structured output format -#### Output Schemas +The following sections explain each concept in detail. + +#### Content Blocks + +FastMCP automatically converts tool return values into appropriate MCP content blocks: + +- **`str`**: Sent as `TextContent` +- **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (within an `EmbeddedResource`) +- **`fastmcp.utilities.types.Image`**: Sent as `ImageContent` +- **`fastmcp.utilities.types.Audio`**: Sent as `AudioContent` +- **`fastmcp.utilities.types.File`**: Sent as base64-encoded `EmbeddedResource` +- **A list of any of the above**: Converts each item appropriately +- **`None`**: Results in an empty response + +#### Structured Output -FastMCP will automatically generate MCP [output schemas](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) for your tools based on their return type annotations. This helps MCP clients understand what type of data to expect from your tool, enabling better validation and type safety. +The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content) structured content, which is a new way to return data from tools. Structured content is a JSON object that is sent alongside traditional content. FastMCP automatically creates structured outputs alongside traditional content when your tool returns data that has a JSON object representation. This provides machine-readable JSON data that clients can deserialize back to Python objects. -When you add a return type annotation to your tool function, FastMCP will generate a JSON schema describing the expected output format and include it in the tool definition sent to MCP clients. +**Automatic Structured Content Rules:** +- **Object-like results** (`dict`, Pydantic models, dataclasses) → Always become structured content (even without output schema) +- **Non-object results** (`int`, `str`, `list`) → Only become structured content if there's an output schema to validate/serialize them +- **All results** → Always become traditional content blocks for backward compatibility + + +This automatic behavior enables clients to receive machine-readable data alongside human-readable content without requiring explicit output schemas for object-like returns. + + +##### Object-like Results (Automatic Structured Content) + + +```python Dict Return (No Schema Needed) +@mcp.tool +def get_user_data(user_id: str) -> dict: + """Get user data without type annotation.""" + return {"name": "Alice", "age": 30, "active": True} +``` + +```json Traditional Content +"{\n \"name\": \"Alice\",\n \"age\": 30,\n \"active\": true\n}" +``` + +```json Structured Content (Automatic) +{ + "name": "Alice", + "age": 30, + "active": true +} +``` + + +##### Non-object Results (Schema Required) + + +```python Integer Return (No Schema) +@mcp.tool +def calculate_sum(a: int, b: int): + """Calculate sum without return annotation.""" + return a + b # Returns 8 +``` + +```json Traditional Content Only +"8" +``` + +```python Integer Return (With Schema) +@mcp.tool +def calculate_sum(a: int, b: int) -> int: + """Calculate sum with return annotation.""" + return a + b # Returns 8 +``` + +```json Traditional Content +"8" +``` + +```json Structured Content (From Schema) +{ + "result": 8 +} +``` + + +##### Complex Type Example ```python Tool Definition @@ -334,19 +406,110 @@ def get_user_profile(user_id: str) -> Person: { "properties": { "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, + "age": {"title": "Age", "type": "integer"}, "email": {"title": "Email", "type": "string"} }, "required": ["name", "age", "email"], "title": "Person", "type": "object" } - ``` +``` + +```json Structured Output +{ + "name": "Alice", + "age": 30, + "email": "alice@example.com" +} +``` -The output schema is automatically generated for most common types including basic types, collections, union types, Pydantic models, TypedDict structures, and dataclasses. For FastMCP's special types (`Image`, `Audio`, `File`), the output schema reflects their MCP equivalents rather than the FastMCP wrapper types. + +#### Output Schemas + + + +The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) output schemas, which are a new way to describe the expected output format of a tool. When an output schema is provided, the tool *must* return structured output that matches the schema. + +When you add return type annotations to your functions, FastMCP automatically generates JSON schemas that describe the expected output format. These schemas help MCP clients understand and validate the structured data they receive. + +##### Primitive Type Wrapping + +For primitive return types (like `int`, `str`, `bool`), FastMCP automatically wraps the result under a `"result"` key to create valid structured output: + + +```python Primitive Return Type +@mcp.tool +def calculate_sum(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b +``` + +```json Generated Schema (Wrapped) +{ + "type": "object", + "properties": { + "result": {"type": "integer"} + }, + "x-fastmcp-wrap-result": true +} +``` + +```json Structured Output +{ + "result": 8 +} +``` + + +##### Manual Schema Control + +You can override the automatically generated schema by providing a custom `output_schema`: + +```python +@mcp.tool(output_schema={ + "type": "object", + "properties": { + "data": {"type": "string"}, + "metadata": {"type": "object"} + } +}) +def custom_schema_tool() -> dict: + """Tool with custom output schema.""" + return {"data": "Hello", "metadata": {"version": "1.0"}} +``` + +Schema generation works for most common types including basic types, collections, union types, Pydantic models, TypedDict structures, and dataclasses. + + +**Important Constraints**: +- Output schemas must be object types (`"type": "object"`) +- If you provide an output schema, your tool **must** return structured output that matches it +- However, you can provide structured output without an output schema (using `ToolResult`) + + +#### Full Control with ToolResult + +For complete control over both traditional content and structured output, return a `ToolResult` object: + +```python +from fastmcp.tools.tool import ToolResult + +@mcp.tool +def advanced_tool() -> ToolResult: + """Tool with full control over output.""" + return ToolResult( + content=[TextContent(text="Human-readable summary")], + structured_content={"data": "value", "count": 42} + ) +``` + +When returning `ToolResult`: +- You control exactly what content and structured data is sent +- Output schemas are optional - structured content can be provided without a schema +- Clients receive both traditional content blocks and structured data -If your return type annotation cannot be converted to a JSON schema (e.g., complex custom classes without Pydantic support), the output schema will be omitted from the tool definition. The tool will still function normally, but clients won't receive type information about the expected output. +If your return type annotation cannot be converted to a JSON schema (e.g., complex custom classes without Pydantic support), the output schema will be omitted but the tool will still function normally with traditional content. ### Error Handling diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index bd8d8a497..35dec76f6 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -79,9 +79,9 @@ class ToolResult: structured_content = pydantic_core.to_jsonable_python( structured_content ) - except pydantic_core.PydanticSerializationError: + except pydantic_core.PydanticSerializationError as e: logger.error( - "Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization:" + f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}" ) raise if not isinstance(structured_content, dict): @@ -280,15 +280,23 @@ class FunctionTool(Tool): unstructured_result = _convert_to_content(result, serializer=self.serializer) - # Handle structured content based on output schema + structured_output = None + # First handle structured content based on output schema, if any if self.output_schema is not None: if self.output_schema.get("x-fastmcp-wrap-result"): # Schema says wrap - always wrap in result key structured_output = {"result": result} else: structured_output = result - else: - structured_output = None + # If no output schema, try to serialize the result. If it is a dict, use + # it as structured content. If it is not a dict, ignore it. + if structured_output is None: + try: + structured_output = pydantic_core.to_jsonable_python(result) + if not isinstance(structured_output, dict): + structured_output = None + except Exception: + pass return ToolResult( content=unstructured_result, diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index db842c519..61d9ecc9e 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -198,11 +198,12 @@ class TransformedTool(Tool): This class represents a tool that has been created by transforming another tool. It supports argument renaming, schema modification, custom function injection, - and provides context for the forward() and forward_raw() functions. + structured output control, and provides context for the forward() and forward_raw() functions. The transformation can be purely schema-based (argument renaming, dropping, etc.) or can include a custom function that uses forward() to call the parent tool - with transformed arguments. + with transformed arguments. Output schemas and structured outputs are automatically + inherited from the parent tool but can be overridden or disabled. Attributes: parent_tool: The original tool that this tool was transformed from. @@ -352,6 +353,10 @@ class TransformedTool(Tool): description: New description. Defaults to parent's description. tags: New tags. Defaults to parent's tags. annotations: New annotations. Defaults to parent's annotations. + output_schema: Control output schema for structured outputs: + - None (default): Inherit from transform_fn if available, then parent tool + - dict: Use custom output schema + - False: Disable output schema and structured outputs serializer: New serializer. Defaults to parent's serializer. Returns: @@ -380,6 +385,26 @@ class TransformedTool(Tool): Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"}) ``` + + # Control structured outputs and schemas + ```python + # Custom output schema + Tool.from_tool(parent, output_schema={ + "type": "object", + "properties": {"status": {"type": "string"}} + }) + + # Disable structured outputs + Tool.from_tool(parent, output_schema=False) + + # Return ToolResult for full control + async def custom_output(**kwargs) -> ToolResult: + result = await forward(**kwargs) + return ToolResult( + content=[TextContent(text="Summary")], + structured_content={"processed": True} + ) + ``` """ transform_args = transform_args or {} diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index c48e5800a..d953cd910 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -920,12 +920,12 @@ class TestToolOutputSchema: mcp = FastMCP() @mcp.tool(output_schema=None) - def f() -> dict[str, str]: - return {"message": "Hello, world!"} + def f() -> int: + return 42 async with Client(mcp) as client: result = await client.call_tool("f", {}) - assert json.loads(result.content[0].text) == {"message": "Hello, world!"} # type: ignore[attr-defined] + assert result.content[0].text == "42" # type: ignore[attr-defined] assert result.structured_content is None assert result.data is None diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 67ebe9da7..f94e73fd3 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -529,8 +529,8 @@ class TestToolFromFunctionOutputSchema: tool = Tool.from_function(func, output_schema=custom_schema) assert tool.output_schema == custom_schema - async def test_output_schema_false_disables_structured_content(self): - """Test that output_schema=False disables structured content generation.""" + 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!"} @@ -539,7 +539,8 @@ class TestToolFromFunctionOutputSchema: assert tool.output_schema is None result = await tool.run({}) - assert result.structured_content is None + # Dict objects automatically become structured content even without schema + assert result.structured_content == {"message": "Hello, world!"} assert len(result.content) == 1 assert result.content[0].text == '{\n "message": "Hello, world!"\n}' # type: ignore[attr-defined] @@ -1028,3 +1029,199 @@ class TestConvertResultToContent: 1, {"type": "text", "text": "hello", "annotations": None, "_meta": None}, ] + + +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=False) + + 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 disable output schema to test automatic structured content + tool = Tool.from_function(get_user_stats, output_schema=False) + + 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_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({}) + + # Should only have content, no structured content + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + 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 (type name will match) + assert result.data.__class__.__name__ == "UserProfile" + assert result.data.name == "Bob" + assert result.data.age == 25 + assert result.data.verified is True From 2a302082457ac7126a9f99a90ce12ebd73d28bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Sat, 28 Jun 2025 15:55:16 +0800 Subject: [PATCH 24/25] Add a comma --- docs/clients/client.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index ea5971a99..f9ed5ca1b 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -109,7 +109,7 @@ config = { }, "local_server": { # Local stdio server - "transport": "stdio" + "transport": "stdio", "command": "python", "args": ["./server.py", "--verbose"], "env": {"DEBUG": "true"}, From b10bab94232d3596155b9540822951882fee5dde Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 28 Jun 2025 08:14:31 -0400 Subject: [PATCH 25/25] Fix output schema test --- tests/server/test_server_interactions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index d953cd910..f37434194 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -947,12 +947,14 @@ class TestToolOutputSchema: assert result.data == {"message": "Hello, world!"} async def test_output_schema_false_full_handshake(self): - """Test that output_schema=False works through full client/server handshake.""" + """Test that output_schema=False works through full client/server + handshake. We test this by returning a scalar, which requires an output + schema to serialize.""" mcp = FastMCP() @mcp.tool(output_schema=False) # type: ignore[arg-type] - def simple_tool() -> dict[str, str]: - return {"message": "Hello from disabled schema"} + def simple_tool() -> int: + return 42 async with Client(mcp) as client: # List tools and verify output schema is None @@ -964,9 +966,7 @@ class TestToolOutputSchema: result = await client.call_tool("simple_tool", {}) assert result.structured_content is None assert result.data is None - assert json.loads(result.content[0].text) == { # type: ignore[attr-defined] - "message": "Hello from disabled schema" - } + assert result.content[0].text == "42" # type: ignore[attr-defined] async def test_output_schema_explicit_object_full_handshake(self): """Test explicit object output schema through full client/server handshake."""