Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions src/fastmcp/tools/function_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,16 @@ class _UnserializableType:
pass


def _is_object_schema(schema: dict[str, Any]) -> bool:
def _is_object_schema(
schema: dict[str, Any],
*,
_root_schema: dict[str, Any] | None = None,
_seen_refs: set[str] | None = None,
) -> bool:
"""Check if a JSON schema represents an object type."""
root_schema = _root_schema or schema
seen_refs = _seen_refs or set()

# Direct object type
if schema.get("type") == "object":
return True
Expand All @@ -73,9 +81,28 @@ def _is_object_schema(schema: dict[str, Any]) -> bool:
if "properties" in schema:
return True

# Self-referencing types use $ref pointing to $defs
# The referenced type is always an object in our use case
return "$ref" in schema and "$defs" in schema
# Resolve local $ref definitions and recurse into the target schema.
ref = schema.get("$ref")
if not isinstance(ref, str) or not ref.startswith("#/$defs/"):
return False

if ref in seen_refs:
return False

definition_name = ref.removeprefix("#/$defs/")
definitions = root_schema.get("$defs")
if not isinstance(definitions, dict):
return False

target_schema = definitions.get(definition_name)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve escaped JSON-pointer tokens in $defs refs

The new $ref resolver assumes the definition key is the raw suffix after #/$defs/, but JSON Pointer escapes / and ~ as ~1/~0 (and can include deeper paths), so refs like #/$defs/A~1B or #/$defs/Outer/$defs/Inner won’t resolve even when they point to object schemas. In those cases _is_object_schema returns False, which causes FastMCP to wrap an already-object output and change structured_content shape unexpectedly for valid schemas that previously passed object checks.

Useful? React with 👍 / 👎.

if not isinstance(target_schema, dict):
return False

return _is_object_schema(
target_schema,
_root_schema=root_schema,
_seen_refs=seen_refs | {ref},
)


@dataclass
Expand Down
30 changes: 28 additions & 2 deletions tests/server/providers/local_provider_tools/test_output_schema.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""Tests for tool output schemas."""

from dataclasses import dataclass
from typing import Any
from typing import Any, Literal

import pytest
from mcp.types import (
TextContent,
)
from pydantic import AnyUrl, BaseModel, TypeAdapter
from typing_extensions import TypedDict
from typing_extensions import TypeAliasType, TypedDict

from fastmcp import FastMCP
from fastmcp.tools.tool import ToolResult
Expand Down Expand Up @@ -282,3 +282,29 @@ def edge_case_tool() -> tuple[int, str]:

result = await mcp.call_tool("edge_case_tool", {})
assert result.structured_content == {"result": [42, "hello"]}

async def test_output_schema_wraps_non_object_ref_schema(self):
"""Root $ref schemas should only skip wrapping when they resolve to objects."""
mcp = FastMCP()
AliasType = TypeAliasType("AliasType", Literal["foo", "bar"])

@mcp.tool
def alias_tool() -> AliasType:
return "foo"

tools = await mcp.list_tools()
tool = next(t for t in tools if t.name == "alias_tool")

expected_inner_schema = compress_schema(
TypeAdapter(AliasType).json_schema(mode="serialization"),
prune_titles=True,
)
assert tool.output_schema == {
"type": "object",
"properties": {"result": expected_inner_schema},
"required": ["result"],
"x-fastmcp-wrap-result": True,
}

result = await mcp.call_tool("alias_tool", {})
assert result.structured_content == {"result": "foo"}
Loading