From e3ce66b7e97863efa260d893fe1a8e76b88756d1 Mon Sep 17 00:00:00 2001 From: Mat Leonard Date: Thu, 9 Oct 2025 17:10:08 -0700 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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."""