From e3ce66b7e97863efa260d893fe1a8e76b88756d1 Mon Sep 17 00:00:00 2001 From: Mat Leonard Date: Thu, 9 Oct 2025 17:10:08 -0700 Subject: [PATCH 1/5] feat: add tool metadata in decorator --- src/mcp/server/fastmcp/server.py | 5 +++++ src/mcp/server/fastmcp/tools/base.py | 3 +++ src/mcp/server/fastmcp/tools/tool_manager.py | 2 ++ 3 files changed, 10 insertions(+) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 485ef1519..18247caea 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -290,6 +290,7 @@ async def list_tools(self) -> list[MCPTool]: outputSchema=info.output_schema, annotations=info.annotations, icons=info.icons, + _meta=info.meta ) for info in tools ] @@ -363,6 +364,7 @@ def add_tool( description: str | None = None, annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> None: """Add a tool to the server. @@ -388,6 +390,7 @@ def add_tool( description=description, annotations=annotations, icons=icons, + meta=meta, structured_output=structured_output, ) @@ -409,6 +412,7 @@ def tool( description: str | None = None, annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a tool. @@ -456,6 +460,7 @@ def decorator(fn: AnyFunction) -> AnyFunction: description=description, annotations=annotations, icons=icons, + meta=meta, structured_output=structured_output, ) return fn diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index 3f26ddcea..f8da98d95 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -34,6 +34,7 @@ class Tool(BaseModel): context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool") icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool") + meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool") @cached_property def output_schema(self) -> dict[str, Any] | None: @@ -49,6 +50,7 @@ def from_function( context_kwarg: str | None = None, annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Tool: """Create a Tool from a function.""" @@ -81,6 +83,7 @@ def from_function( context_kwarg=context_kwarg, annotations=annotations, icons=icons, + meta=meta ) async def run( diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index d6c0054af..095753de6 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -50,6 +50,7 @@ def add_tool( description: str | None = None, annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Tool: """Add a tool to the server.""" @@ -60,6 +61,7 @@ def add_tool( description=description, annotations=annotations, icons=icons, + meta=meta, structured_output=structured_output, ) existing = self._tools.get(tool.name) From 02985db130894d184017bf17335a7890bead9564 Mon Sep 17 00:00:00 2001 From: Mat Leonard Date: Fri, 10 Oct 2025 06:27:21 -0700 Subject: [PATCH 2/5] fix linting --- src/mcp/server/fastmcp/server.py | 41 ++++++++++++++++++++++------ src/mcp/server/fastmcp/tools/base.py | 2 +- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 18247caea..4ed052efd 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -4,7 +4,14 @@ import inspect import re -from collections.abc import AsyncIterator, Awaitable, Callable, Collection, Iterable, Sequence +from collections.abc import ( + AsyncIterator, + Awaitable, + Callable, + Collection, + Iterable, + Sequence, +) from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Generic, Literal @@ -22,10 +29,21 @@ from starlette.types import Receive, Scope, Send from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware -from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier +from mcp.server.auth.middleware.bearer_auth import ( + BearerAuthBackend, + RequireAuthMiddleware, +) +from mcp.server.auth.provider import ( + OAuthAuthorizationServerProvider, + ProviderTokenVerifier, + TokenVerifier, +) from mcp.server.auth.settings import AuthSettings -from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, elicit_with_validation +from mcp.server.elicitation import ( + ElicitationResult, + ElicitSchemaModelT, + elicit_with_validation, +) from mcp.server.fastmcp.exceptions import ResourceError from mcp.server.fastmcp.prompts import Prompt, PromptManager from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager @@ -112,7 +130,9 @@ def lifespan_wrapper( lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]], ) -> Callable[[MCPServer[LifespanResultT, Request]], AbstractAsyncContextManager[LifespanResultT]]: @asynccontextmanager - async def wrap(_: MCPServer[LifespanResultT, Request]) -> AsyncIterator[LifespanResultT]: + async def wrap( + _: MCPServer[LifespanResultT, Request], + ) -> AsyncIterator[LifespanResultT]: async with lifespan(app) as context: yield context @@ -126,7 +146,7 @@ def __init__( # noqa: PLR0913 instructions: str | None = None, website_url: str | None = None, icons: list[Icon] | None = None, - auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, + auth_server_provider: (OAuthAuthorizationServerProvider[Any, Any, Any] | None) = None, token_verifier: TokenVerifier | None = None, event_store: EventStore | None = None, *, @@ -145,7 +165,7 @@ def __init__( # noqa: PLR0913 warn_on_duplicate_tools: bool = True, warn_on_duplicate_prompts: bool = True, dependencies: Collection[str] = (), - lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None, + lifespan: (Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None) = None, auth: AuthSettings | None = None, transport_security: TransportSecuritySettings | None = None, ): @@ -290,7 +310,7 @@ async def list_tools(self) -> list[MCPTool]: outputSchema=info.output_schema, annotations=info.annotations, icons=info.icons, - _meta=info.meta + _meta=info.meta, ) for info in tools ] @@ -1169,7 +1189,10 @@ async def elicit( """ return await elicit_with_validation( - session=self.request_context.session, message=message, schema=schema, related_request_id=self.request_id + session=self.request_context.session, + message=message, + schema=schema, + related_request_id=self.request_id, ) async def log( diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index f8da98d95..7002af493 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -83,7 +83,7 @@ def from_function( context_kwarg=context_kwarg, annotations=annotations, icons=icons, - meta=meta + meta=meta, ) async def run( From 88bc72d0ff1f9574c71fe9d4858dd38e907fd15a Mon Sep 17 00:00:00 2001 From: Mat Leonard Date: Tue, 14 Oct 2025 11:47:27 -0700 Subject: [PATCH 3/5] add output_schema override to support ChatGPT App SDK --- src/mcp/server/fastmcp/server.py | 8 ++ src/mcp/server/fastmcp/tools/base.py | 10 ++- src/mcp/server/fastmcp/tools/tool_manager.py | 2 + tests/server/fastmcp/test_tool_manager.py | 88 +++++++++++++++++++- 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 4ed052efd..414471912 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -385,6 +385,7 @@ def add_tool( annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, + output_schema: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> None: """Add a tool to the server. @@ -398,6 +399,8 @@ def add_tool( title: Optional human-readable title for the tool description: Optional description of what the tool does annotations: Optional ToolAnnotations providing additional tool information + meta: Optional metadata dictionary + output_schema: Optional Pydantic model defining the output schema separate from the tool's return type structured_output: Controls whether the tool's output is structured or unstructured - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) @@ -411,6 +414,7 @@ def add_tool( annotations=annotations, icons=icons, meta=meta, + output_schema=output_schema, structured_output=structured_output, ) @@ -433,6 +437,7 @@ def tool( annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, + output_schema: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a tool. @@ -446,6 +451,8 @@ def tool( title: Optional human-readable title for the tool description: Optional description of what the tool does annotations: Optional ToolAnnotations providing additional tool information + meta: Optional metadata dictionary + output_schema: Optional Pydantic model defining the output schema separate from the tool's return type structured_output: Controls whether the tool's output is structured or unstructured - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) @@ -481,6 +488,7 @@ def decorator(fn: AnyFunction) -> AnyFunction: annotations=annotations, icons=icons, meta=meta, + output_schema=output_schema, structured_output=structured_output, ) return fn diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index 7002af493..c8b70e911 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -35,9 +35,15 @@ class Tool(BaseModel): annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool") icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool") meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool") + output_schema_override: dict[str, Any] | None = Field( + default=None, + description="Optional Pydantic model defining the output schema separate from the tool's return type", + ) @cached_property def output_schema(self) -> dict[str, Any] | None: + if self.output_schema_override is not None: + return self.output_schema_override return self.fn_metadata.output_schema @classmethod @@ -51,6 +57,7 @@ def from_function( annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, + output_schema: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Tool: """Create a Tool from a function.""" @@ -84,6 +91,7 @@ def from_function( annotations=annotations, icons=icons, meta=meta, + output_schema_override=output_schema, ) async def run( @@ -98,7 +106,7 @@ async def run( self.fn, self.is_async, arguments, - {self.context_kwarg: context} if self.context_kwarg is not None else None, + ({self.context_kwarg: context} if self.context_kwarg is not None else None), ) if convert_result: diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index 095753de6..01e75d83c 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -51,6 +51,7 @@ def add_tool( annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, + output_schema: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Tool: """Add a tool to the server.""" @@ -62,6 +63,7 @@ def add_tool( annotations=annotations, icons=icons, meta=meta, + output_schema=output_schema, structured_output=structured_output, ) existing = self._tools.get(tool.name) diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index 71884fba2..ceb4c31ac 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -4,7 +4,7 @@ from typing import Any, TypedDict import pytest -from pydantic import BaseModel +from pydantic import BaseModel, Field from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp.exceptions import ToolError @@ -577,13 +577,97 @@ def get_user() -> UserOutput: # Test that output_schema is populated expected_schema = { - "properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}}, + "properties": { + "name": {"type": "string", "title": "Name"}, + "age": {"type": "integer", "title": "Age"}, + }, "required": ["name", "age"], "title": "UserOutput", "type": "object", } assert tool.output_schema == expected_schema + def test_add_output_schema_override(self): + """Test registering a tool with an explicit output schema.""" + + # For the ChatGPT App SDK, the tool output should be structured like: + # { + # "structuredOutput": { ... }, + # "content": [ { "type": "text", "text": "..." }, ... ], + # "_meta": { ... } + # } + # and the tool output schema should reflect the structure of "structuredOutput" + class UserOutput(BaseModel): + name: str + age: int + + # Output structure expected by ChatGPT App SDK + class ToolOutput(BaseModel): + structuredOutput: UserOutput + content: list[dict[str, str]] + meta: dict[str, Any] = Field(alias="_meta") + + def get_user(user_id: int) -> ToolOutput: + """Get user by ID.""" + return ToolOutput( + structuredOutput=UserOutput(name="John", age=30), + content=[{"type": "text", "text": "User found"}], + _meta={"request_id": "12345"}, + ) + + manager = ToolManager() + tool = manager.add_tool(get_user, output_schema=UserOutput.model_json_schema()) + + expected_schema = { + "properties": { + "name": {"type": "string", "title": "Name"}, + "age": {"type": "integer", "title": "Age"}, + }, + "required": ["name", "age"], + "title": "UserOutput", + "type": "object", + } + assert tool.output_schema == expected_schema + assert tool.fn_metadata.output_model == ToolOutput + + @pytest.mark.anyio + async def test_call_tool_with_output_schema_override(self): + # For the ChatGPT App SDK, the tool output should be structured like: + # { + # "structuredOutput": { ... }, + # "content": [ { "type": "text", "text": "..." }, ... ], + # "_meta": { ... } + # } + # and the tool output schema should reflect the structure of "structuredOutput" + class UserOutput(BaseModel): + name: str + age: int + + # Output structure expected by ChatGPT App SDK + class ToolOutput(BaseModel): + structuredOutput: UserOutput + content: list[dict[str, str]] + meta: dict[str, Any] = Field(alias="_meta") + + def get_user(user_id: int) -> ToolOutput: + """Get user by ID.""" + return ToolOutput( + structuredOutput=UserOutput(name="John", age=30), + content=[{"type": "some more information about the output data"}], + _meta={"request_id": "12345"}, + ) + + manager = ToolManager() + manager.add_tool(get_user, output_schema=UserOutput.model_json_schema()) + result = await manager.call_tool("get_user", {"user_id": 1}, convert_result=True) + + expected_result = { + "structuredOutput": {"name": "John", "age": 30}, + "content": [{"type": "some more information about the output data"}], + "_meta": {"request_id": "12345"}, + } + assert len(result) == 2 and result[1] == expected_result + @pytest.mark.anyio async def test_tool_with_dict_str_any_output(self): """Test tool with dict[str, Any] return type.""" From 2c2cd0e37d35cf66dd57200620a5b8657c9c9234 Mon Sep 17 00:00:00 2001 From: Mat Leonard Date: Tue, 14 Oct 2025 15:48:33 -0700 Subject: [PATCH 4/5] Revert "add output_schema override to support ChatGPT App SDK" This reverts commit 88bc72d0ff1f9574c71fe9d4858dd38e907fd15a. --- src/mcp/server/fastmcp/server.py | 8 -- src/mcp/server/fastmcp/tools/base.py | 10 +-- src/mcp/server/fastmcp/tools/tool_manager.py | 2 - tests/server/fastmcp/test_tool_manager.py | 88 +------------------- 4 files changed, 3 insertions(+), 105 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 414471912..4ed052efd 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -385,7 +385,6 @@ def add_tool( annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, - output_schema: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> None: """Add a tool to the server. @@ -399,8 +398,6 @@ def add_tool( title: Optional human-readable title for the tool description: Optional description of what the tool does annotations: Optional ToolAnnotations providing additional tool information - meta: Optional metadata dictionary - output_schema: Optional Pydantic model defining the output schema separate from the tool's return type structured_output: Controls whether the tool's output is structured or unstructured - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) @@ -414,7 +411,6 @@ def add_tool( annotations=annotations, icons=icons, meta=meta, - output_schema=output_schema, structured_output=structured_output, ) @@ -437,7 +433,6 @@ def tool( annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, - output_schema: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a tool. @@ -451,8 +446,6 @@ def tool( title: Optional human-readable title for the tool description: Optional description of what the tool does annotations: Optional ToolAnnotations providing additional tool information - meta: Optional metadata dictionary - output_schema: Optional Pydantic model defining the output schema separate from the tool's return type structured_output: Controls whether the tool's output is structured or unstructured - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) @@ -488,7 +481,6 @@ def decorator(fn: AnyFunction) -> AnyFunction: annotations=annotations, icons=icons, meta=meta, - output_schema=output_schema, structured_output=structured_output, ) return fn diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index c8b70e911..7002af493 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -35,15 +35,9 @@ class Tool(BaseModel): annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool") icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool") meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool") - output_schema_override: dict[str, Any] | None = Field( - default=None, - description="Optional Pydantic model defining the output schema separate from the tool's return type", - ) @cached_property def output_schema(self) -> dict[str, Any] | None: - if self.output_schema_override is not None: - return self.output_schema_override return self.fn_metadata.output_schema @classmethod @@ -57,7 +51,6 @@ def from_function( annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, - output_schema: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Tool: """Create a Tool from a function.""" @@ -91,7 +84,6 @@ def from_function( annotations=annotations, icons=icons, meta=meta, - output_schema_override=output_schema, ) async def run( @@ -106,7 +98,7 @@ async def run( self.fn, self.is_async, arguments, - ({self.context_kwarg: context} if self.context_kwarg is not None else None), + {self.context_kwarg: context} if self.context_kwarg is not None else None, ) if convert_result: diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index 01e75d83c..095753de6 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -51,7 +51,6 @@ def add_tool( annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, - output_schema: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Tool: """Add a tool to the server.""" @@ -63,7 +62,6 @@ def add_tool( annotations=annotations, icons=icons, meta=meta, - output_schema=output_schema, structured_output=structured_output, ) existing = self._tools.get(tool.name) diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index ceb4c31ac..71884fba2 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -4,7 +4,7 @@ from typing import Any, TypedDict import pytest -from pydantic import BaseModel, Field +from pydantic import BaseModel from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp.exceptions import ToolError @@ -577,97 +577,13 @@ def get_user() -> UserOutput: # Test that output_schema is populated expected_schema = { - "properties": { - "name": {"type": "string", "title": "Name"}, - "age": {"type": "integer", "title": "Age"}, - }, + "properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}}, "required": ["name", "age"], "title": "UserOutput", "type": "object", } assert tool.output_schema == expected_schema - def test_add_output_schema_override(self): - """Test registering a tool with an explicit output schema.""" - - # For the ChatGPT App SDK, the tool output should be structured like: - # { - # "structuredOutput": { ... }, - # "content": [ { "type": "text", "text": "..." }, ... ], - # "_meta": { ... } - # } - # and the tool output schema should reflect the structure of "structuredOutput" - class UserOutput(BaseModel): - name: str - age: int - - # Output structure expected by ChatGPT App SDK - class ToolOutput(BaseModel): - structuredOutput: UserOutput - content: list[dict[str, str]] - meta: dict[str, Any] = Field(alias="_meta") - - def get_user(user_id: int) -> ToolOutput: - """Get user by ID.""" - return ToolOutput( - structuredOutput=UserOutput(name="John", age=30), - content=[{"type": "text", "text": "User found"}], - _meta={"request_id": "12345"}, - ) - - manager = ToolManager() - tool = manager.add_tool(get_user, output_schema=UserOutput.model_json_schema()) - - expected_schema = { - "properties": { - "name": {"type": "string", "title": "Name"}, - "age": {"type": "integer", "title": "Age"}, - }, - "required": ["name", "age"], - "title": "UserOutput", - "type": "object", - } - assert tool.output_schema == expected_schema - assert tool.fn_metadata.output_model == ToolOutput - - @pytest.mark.anyio - async def test_call_tool_with_output_schema_override(self): - # For the ChatGPT App SDK, the tool output should be structured like: - # { - # "structuredOutput": { ... }, - # "content": [ { "type": "text", "text": "..." }, ... ], - # "_meta": { ... } - # } - # and the tool output schema should reflect the structure of "structuredOutput" - class UserOutput(BaseModel): - name: str - age: int - - # Output structure expected by ChatGPT App SDK - class ToolOutput(BaseModel): - structuredOutput: UserOutput - content: list[dict[str, str]] - meta: dict[str, Any] = Field(alias="_meta") - - def get_user(user_id: int) -> ToolOutput: - """Get user by ID.""" - return ToolOutput( - structuredOutput=UserOutput(name="John", age=30), - content=[{"type": "some more information about the output data"}], - _meta={"request_id": "12345"}, - ) - - manager = ToolManager() - manager.add_tool(get_user, output_schema=UserOutput.model_json_schema()) - result = await manager.call_tool("get_user", {"user_id": 1}, convert_result=True) - - expected_result = { - "structuredOutput": {"name": "John", "age": 30}, - "content": [{"type": "some more information about the output data"}], - "_meta": {"request_id": "12345"}, - } - assert len(result) == 2 and result[1] == expected_result - @pytest.mark.anyio async def test_tool_with_dict_str_any_output(self): """Test tool with dict[str, Any] return type.""" From cda1cf47ba6e27f4c5b91854418bb4e79efc68c9 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:14:07 +0100 Subject: [PATCH 5/5] test: add comprehensive unit tests for tool metadata feature Add tests covering: - Adding metadata via ToolManager.add_tool() - Adding metadata via FastMCP.tool() decorator - Metadata preservation in Tool objects - Metadata inclusion in MCPTool when listing tools - Edge cases (None, empty dict, complex nested structures) - Integration with tool annotations All tests verify that the meta parameter correctly flows through the tool registration and listing pipeline. --- tests/server/fastmcp/test_tool_manager.py | 172 ++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index 71884fba2..7a53ec37a 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -635,6 +635,178 @@ def get_scores() -> dict[str, int]: assert result == expected +class TestToolMetadata: + """Test tool metadata functionality.""" + + def test_add_tool_with_metadata(self): + """Test adding a tool with metadata via ToolManager.""" + + def process_data(input_data: str) -> str: + """Process some data.""" + return f"Processed: {input_data}" + + metadata = {"ui": {"type": "form", "fields": ["input"]}, "version": "1.0"} + + manager = ToolManager() + tool = manager.add_tool(process_data, meta=metadata) + + assert tool.meta is not None + assert tool.meta == metadata + assert tool.meta["ui"]["type"] == "form" + assert tool.meta["version"] == "1.0" + + def test_add_tool_without_metadata(self): + """Test that tools without metadata have None as meta value.""" + + def simple_tool(x: int) -> int: + """Simple tool.""" + return x * 2 + + manager = ToolManager() + tool = manager.add_tool(simple_tool) + + assert tool.meta is None + + @pytest.mark.anyio + async def test_metadata_in_fastmcp_decorator(self): + """Test that metadata is correctly added via FastMCP.tool decorator.""" + + app = FastMCP() + + metadata = {"client": {"ui_component": "file_picker"}, "priority": "high"} + + @app.tool(meta=metadata) + def upload_file(filename: str) -> str: + """Upload a file.""" + return f"Uploaded: {filename}" + + # Get the tool from the tool manager + tool = app._tool_manager.get_tool("upload_file") + assert tool is not None + assert tool.meta is not None + assert tool.meta == metadata + assert tool.meta["client"]["ui_component"] == "file_picker" + assert tool.meta["priority"] == "high" + + @pytest.mark.anyio + async def test_metadata_in_list_tools(self): + """Test that metadata is included in MCPTool when listing tools.""" + + app = FastMCP() + + metadata = { + "ui": {"input_type": "textarea", "rows": 5}, + "tags": ["text", "processing"], + } + + @app.tool(meta=metadata) + def analyze_text(text: str) -> dict[str, Any]: + """Analyze text content.""" + return {"length": len(text), "words": len(text.split())} + + tools = await app.list_tools() + assert len(tools) == 1 + assert tools[0].meta is not None + assert tools[0].meta == metadata + + @pytest.mark.anyio + async def test_multiple_tools_with_different_metadata(self): + """Test multiple tools with different metadata values.""" + + app = FastMCP() + + metadata1 = {"ui": "form", "version": 1} + metadata2 = {"ui": "picker", "experimental": True} + + @app.tool(meta=metadata1) + def tool1(x: int) -> int: + """First tool.""" + return x + + @app.tool(meta=metadata2) + def tool2(y: str) -> str: + """Second tool.""" + return y + + @app.tool() + def tool3(z: bool) -> bool: + """Third tool without metadata.""" + return z + + tools = await app.list_tools() + assert len(tools) == 3 + + # Find tools by name and check metadata + tools_by_name = {t.name: t for t in tools} + + assert tools_by_name["tool1"].meta == metadata1 + assert tools_by_name["tool2"].meta == metadata2 + assert tools_by_name["tool3"].meta is None + + def test_metadata_with_complex_structure(self): + """Test metadata with complex nested structures.""" + + def complex_tool(data: str) -> str: + """Tool with complex metadata.""" + return data + + metadata = { + "ui": { + "components": [ + {"type": "input", "name": "field1", "validation": {"required": True, "minLength": 5}}, + {"type": "select", "name": "field2", "options": ["a", "b", "c"]}, + ], + "layout": {"columns": 2, "responsive": True}, + }, + "permissions": ["read", "write"], + "tags": ["data-processing", "user-input"], + "version": 2, + } + + manager = ToolManager() + tool = manager.add_tool(complex_tool, meta=metadata) + + assert tool.meta is not None + assert tool.meta["ui"]["components"][0]["validation"]["minLength"] == 5 + assert tool.meta["ui"]["layout"]["columns"] == 2 + assert "read" in tool.meta["permissions"] + assert "data-processing" in tool.meta["tags"] + + def test_metadata_empty_dict(self): + """Test that empty dict metadata is preserved.""" + + def tool_with_empty_meta(x: int) -> int: + """Tool with empty metadata.""" + return x + + manager = ToolManager() + tool = manager.add_tool(tool_with_empty_meta, meta={}) + + assert tool.meta is not None + assert tool.meta == {} + + @pytest.mark.anyio + async def test_metadata_with_annotations(self): + """Test that metadata and annotations can coexist.""" + + app = FastMCP() + + metadata = {"custom": "value"} + annotations = ToolAnnotations(title="Combined Tool", readOnlyHint=True) + + @app.tool(meta=metadata, annotations=annotations) + def combined_tool(data: str) -> str: + """Tool with both metadata and annotations.""" + return data + + tools = await app.list_tools() + assert len(tools) == 1 + assert tools[0].meta == metadata + assert tools[0].annotations is not None + assert tools[0].annotations.title == "Combined Tool" + assert tools[0].annotations.readOnlyHint is True + + class TestRemoveTools: """Test tool removal functionality in the tool manager."""