Always emit a tool title, derived from name when unset (#4694)

* Always emit a tool title, derived from name when unset

Some MCP clients (e.g. ChatGPT) drop tools with no `title` instead of
falling back to `name` for display as the spec allows. Deriving a
default title in Tool.to_mcp_tool() fixes this for every tool built on
top of it, including the search-transform, code-mode, and session
proxy tools that never set one explicitly.

Fixes #4414

* Derive fallback title from the overridden name, document it

Addresses Codex review on #4694.

* Resolve title precedence from effective overrides

* Normalize mapping annotations before deriving the title
This commit is contained in:
Jeremiah Lowin 2026-07-28 17:30:20 -04:00 committed by GitHub
commit baced6281c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 84 additions and 11 deletions

View file

@ -74,7 +74,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l
</ParamField>
<ParamField body="title" type="str | None">
A human-readable display title for the tool. If omitted, FastMCP falls back to `annotations.title` when present.
A human-readable display title for the tool. If omitted, FastMCP falls back to `annotations.title` when present, then to a title derived from the tool's name (e.g. `find_products` becomes "Find Products") — some MCP clients drop tools that have no title at all.
</ParamField>
<ParamField body="tags" type="set[str] | None">

View file

@ -52,6 +52,16 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
def _default_title(name: str) -> str:
"""Derive a display title from a tool name.
The MCP spec says clients should fall back to `name` for display when
`title` is absent, but some clients (e.g. ChatGPT) instead drop the tool
entirely. Always emitting a title avoids depending on that fallback.
"""
return name.replace("_", " ").replace("-", " ").title()
def resolve_serialize_by_alias(value: Any) -> bool:
"""Resolve the effective ``by_alias`` setting for serializing *value*.
@ -263,21 +273,28 @@ class Tool(FastMCPComponent):
**overrides: Any,
) -> MCPTool:
"""Convert the FastMCP tool to an MCP tool."""
title = None
# Title precedence follows the effective (post-override) values, so a
# caller renaming or re-annotating a tool doesn't get a stale title.
name = overrides.get("name", self.name)
annotations = overrides.get("annotations", self.annotations)
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
if self.title:
title = self.title
elif self.annotations and self.annotations.title:
title = self.annotations.title
elif annotations and annotations.title:
title = annotations.title
else:
title = _default_title(name)
mcp_tool = MCPTool(
name=overrides.get("name", self.name),
name=name,
title=overrides.get("title", title),
description=overrides.get("description", self.description),
input_schema=overrides.get("inputSchema", self.parameters),
output_schema=overrides.get("outputSchema", self.output_schema),
icons=overrides.get("icons", self.icons),
annotations=overrides.get("annotations", self.annotations),
annotations=annotations,
execution=overrides.get("execution", self.execution),
_meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field
"_meta", self.get_meta()

View file

@ -137,6 +137,28 @@ class TestBaseTransformBehavior:
assert await mcp.get_tool("find_tools") is not None
assert await mcp.get_tool("run_tool") is not None
@pytest.mark.parametrize(
"transform_cls", [RegexSearchTransform, BM25SearchTransform]
)
async def test_synthetic_tools_have_titles(self, transform_cls):
"""Synthetic search/call tools must carry a title.
Some MCP clients (e.g. ChatGPT) drop tools with no `title` field,
which breaks tool-search discovery entirely. See #4414.
"""
mcp = _make_server_with_tools()
mcp.add_transform(
transform_cls(
search_tool_name="find_tools", call_tool_name="call_read_tool"
)
)
tools = await mcp.list_tools()
titles = {t.name: t.to_mcp_tool().title for t in tools}
assert titles == {
"find_tools": "Find Tools",
"call_read_tool": "Call Read Tool",
}
async def test_search_respects_visibility_filtering(self):
"""Tools disabled via Visibility transform should not appear in search."""
mcp = _make_server_with_tools()

View file

@ -1,3 +1,6 @@
import pytest
from mcp_types import ToolAnnotations
from fastmcp.tools.base import Tool
@ -30,7 +33,13 @@ class TestToolTitle:
)
def test_tool_without_title(self):
"""Test that tools without titles use name as display name."""
"""Test that tools without an explicit title derive one from the name.
Some MCP clients (e.g. ChatGPT) drop tools with no `title` rather
than falling back to `name` as the spec allows, so FastMCP always
emits a derived title on the wire instead of relying on that
fallback.
"""
def multiply(a: int, b: int) -> int:
return a * b
@ -40,14 +49,40 @@ class TestToolTitle:
assert tool.name == "multiply"
assert tool.title is None
# Test MCP conversion doesn't include title when None
mcp_tool = tool.to_mcp_tool()
assert mcp_tool.name == "multiply"
assert not hasattr(mcp_tool, "title") or mcp_tool.title is None
assert mcp_tool.title == "Multiply"
def test_derived_title_follows_name_override(self):
"""The derived title should reflect a `name` override, not the original name."""
def multiply(a: int, b: int) -> int:
return a * b
tool = Tool.from_function(multiply, name="multiply_tool")
mcp_tool = tool.to_mcp_tool(name="renamed_tool")
assert mcp_tool.name == "renamed_tool"
assert mcp_tool.title == "Renamed Tool"
@pytest.mark.parametrize(
"annotations",
[ToolAnnotations(title="Custom"), {"title": "Custom"}],
ids=["object", "dict"],
)
def test_annotations_override_beats_derived_title(self, annotations):
"""An `annotations` override still outranks the name-derived title."""
def multiply(a: int, b: int) -> int:
return a * b
tool = Tool.from_function(multiply)
mcp_tool = tool.to_mcp_tool(annotations=annotations)
assert mcp_tool.title == "Custom"
def test_tool_title_priority(self):
"""Test that explicit title takes priority over annotations.title."""
from mcp_types import ToolAnnotations
def divide(x: int, y: int) -> float:
"""Divide two numbers."""
@ -72,7 +107,6 @@ class TestToolTitle:
def test_tool_annotations_title_fallback(self):
"""Test that annotations.title is used when no explicit title is provided."""
from mcp_types import ToolAnnotations
def modulo(x: int, y: int) -> int:
"""Get modulo of two numbers."""