Skip to content

Commit 88bc72d

Browse files
committed
add output_schema override to support ChatGPT App SDK
1 parent 02985db commit 88bc72d

File tree

4 files changed

+105
-3
lines changed

4 files changed

+105
-3
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ def add_tool(
385385
annotations: ToolAnnotations | None = None,
386386
icons: list[Icon] | None = None,
387387
meta: dict[str, Any] | None = None,
388+
output_schema: dict[str, Any] | None = None,
388389
structured_output: bool | None = None,
389390
) -> None:
390391
"""Add a tool to the server.
@@ -398,6 +399,8 @@ def add_tool(
398399
title: Optional human-readable title for the tool
399400
description: Optional description of what the tool does
400401
annotations: Optional ToolAnnotations providing additional tool information
402+
meta: Optional metadata dictionary
403+
output_schema: Optional Pydantic model defining the output schema separate from the tool's return type
401404
structured_output: Controls whether the tool's output is structured or unstructured
402405
- If None, auto-detects based on the function's return type annotation
403406
- If True, creates a structured tool (return type annotation permitting)
@@ -411,6 +414,7 @@ def add_tool(
411414
annotations=annotations,
412415
icons=icons,
413416
meta=meta,
417+
output_schema=output_schema,
414418
structured_output=structured_output,
415419
)
416420

@@ -433,6 +437,7 @@ def tool(
433437
annotations: ToolAnnotations | None = None,
434438
icons: list[Icon] | None = None,
435439
meta: dict[str, Any] | None = None,
440+
output_schema: dict[str, Any] | None = None,
436441
structured_output: bool | None = None,
437442
) -> Callable[[AnyFunction], AnyFunction]:
438443
"""Decorator to register a tool.
@@ -446,6 +451,8 @@ def tool(
446451
title: Optional human-readable title for the tool
447452
description: Optional description of what the tool does
448453
annotations: Optional ToolAnnotations providing additional tool information
454+
meta: Optional metadata dictionary
455+
output_schema: Optional Pydantic model defining the output schema separate from the tool's return type
449456
structured_output: Controls whether the tool's output is structured or unstructured
450457
- If None, auto-detects based on the function's return type annotation
451458
- If True, creates a structured tool (return type annotation permitting)
@@ -481,6 +488,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
481488
annotations=annotations,
482489
icons=icons,
483490
meta=meta,
491+
output_schema=output_schema,
484492
structured_output=structured_output,
485493
)
486494
return fn

src/mcp/server/fastmcp/tools/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,15 @@ class Tool(BaseModel):
3535
annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool")
3636
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool")
3737
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool")
38+
output_schema_override: dict[str, Any] | None = Field(
39+
default=None,
40+
description="Optional Pydantic model defining the output schema separate from the tool's return type",
41+
)
3842

3943
@cached_property
4044
def output_schema(self) -> dict[str, Any] | None:
45+
if self.output_schema_override is not None:
46+
return self.output_schema_override
4147
return self.fn_metadata.output_schema
4248

4349
@classmethod
@@ -51,6 +57,7 @@ def from_function(
5157
annotations: ToolAnnotations | None = None,
5258
icons: list[Icon] | None = None,
5359
meta: dict[str, Any] | None = None,
60+
output_schema: dict[str, Any] | None = None,
5461
structured_output: bool | None = None,
5562
) -> Tool:
5663
"""Create a Tool from a function."""
@@ -84,6 +91,7 @@ def from_function(
8491
annotations=annotations,
8592
icons=icons,
8693
meta=meta,
94+
output_schema_override=output_schema,
8795
)
8896

8997
async def run(
@@ -98,7 +106,7 @@ async def run(
98106
self.fn,
99107
self.is_async,
100108
arguments,
101-
{self.context_kwarg: context} if self.context_kwarg is not None else None,
109+
({self.context_kwarg: context} if self.context_kwarg is not None else None),
102110
)
103111

104112
if convert_result:

src/mcp/server/fastmcp/tools/tool_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def add_tool(
5151
annotations: ToolAnnotations | None = None,
5252
icons: list[Icon] | None = None,
5353
meta: dict[str, Any] | None = None,
54+
output_schema: dict[str, Any] | None = None,
5455
structured_output: bool | None = None,
5556
) -> Tool:
5657
"""Add a tool to the server."""
@@ -62,6 +63,7 @@ def add_tool(
6263
annotations=annotations,
6364
icons=icons,
6465
meta=meta,
66+
output_schema=output_schema,
6567
structured_output=structured_output,
6668
)
6769
existing = self._tools.get(tool.name)

tests/server/fastmcp/test_tool_manager.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Any, TypedDict
55

66
import pytest
7-
from pydantic import BaseModel
7+
from pydantic import BaseModel, Field
88

99
from mcp.server.fastmcp import Context, FastMCP
1010
from mcp.server.fastmcp.exceptions import ToolError
@@ -577,13 +577,97 @@ def get_user() -> UserOutput:
577577

578578
# Test that output_schema is populated
579579
expected_schema = {
580-
"properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}},
580+
"properties": {
581+
"name": {"type": "string", "title": "Name"},
582+
"age": {"type": "integer", "title": "Age"},
583+
},
581584
"required": ["name", "age"],
582585
"title": "UserOutput",
583586
"type": "object",
584587
}
585588
assert tool.output_schema == expected_schema
586589

590+
def test_add_output_schema_override(self):
591+
"""Test registering a tool with an explicit output schema."""
592+
593+
# For the ChatGPT App SDK, the tool output should be structured like:
594+
# {
595+
# "structuredOutput": { ... },
596+
# "content": [ { "type": "text", "text": "..." }, ... ],
597+
# "_meta": { ... }
598+
# }
599+
# and the tool output schema should reflect the structure of "structuredOutput"
600+
class UserOutput(BaseModel):
601+
name: str
602+
age: int
603+
604+
# Output structure expected by ChatGPT App SDK
605+
class ToolOutput(BaseModel):
606+
structuredOutput: UserOutput
607+
content: list[dict[str, str]]
608+
meta: dict[str, Any] = Field(alias="_meta")
609+
610+
def get_user(user_id: int) -> ToolOutput:
611+
"""Get user by ID."""
612+
return ToolOutput(
613+
structuredOutput=UserOutput(name="John", age=30),
614+
content=[{"type": "text", "text": "User found"}],
615+
_meta={"request_id": "12345"},
616+
)
617+
618+
manager = ToolManager()
619+
tool = manager.add_tool(get_user, output_schema=UserOutput.model_json_schema())
620+
621+
expected_schema = {
622+
"properties": {
623+
"name": {"type": "string", "title": "Name"},
624+
"age": {"type": "integer", "title": "Age"},
625+
},
626+
"required": ["name", "age"],
627+
"title": "UserOutput",
628+
"type": "object",
629+
}
630+
assert tool.output_schema == expected_schema
631+
assert tool.fn_metadata.output_model == ToolOutput
632+
633+
@pytest.mark.anyio
634+
async def test_call_tool_with_output_schema_override(self):
635+
# For the ChatGPT App SDK, the tool output should be structured like:
636+
# {
637+
# "structuredOutput": { ... },
638+
# "content": [ { "type": "text", "text": "..." }, ... ],
639+
# "_meta": { ... }
640+
# }
641+
# and the tool output schema should reflect the structure of "structuredOutput"
642+
class UserOutput(BaseModel):
643+
name: str
644+
age: int
645+
646+
# Output structure expected by ChatGPT App SDK
647+
class ToolOutput(BaseModel):
648+
structuredOutput: UserOutput
649+
content: list[dict[str, str]]
650+
meta: dict[str, Any] = Field(alias="_meta")
651+
652+
def get_user(user_id: int) -> ToolOutput:
653+
"""Get user by ID."""
654+
return ToolOutput(
655+
structuredOutput=UserOutput(name="John", age=30),
656+
content=[{"type": "some more information about the output data"}],
657+
_meta={"request_id": "12345"},
658+
)
659+
660+
manager = ToolManager()
661+
manager.add_tool(get_user, output_schema=UserOutput.model_json_schema())
662+
result = await manager.call_tool("get_user", {"user_id": 1}, convert_result=True)
663+
664+
expected_result = {
665+
"structuredOutput": {"name": "John", "age": 30},
666+
"content": [{"type": "some more information about the output data"}],
667+
"_meta": {"request_id": "12345"},
668+
}
669+
assert len(result) == 2 and result[1] == expected_result
670+
587671
@pytest.mark.anyio
588672
async def test_tool_with_dict_str_any_output(self):
589673
"""Test tool with dict[str, Any] return type."""

0 commit comments

Comments
 (0)