mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
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:
parent
5a98ceb5ca
commit
baced6281c
4 changed files with 84 additions and 11 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue