From baced6281c64b3fefb049beeccef3de919ffb62d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:30:20 -0400 Subject: [PATCH] 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 --- docs/servers/tools.mdx | 2 +- fastmcp_slim/fastmcp/tools/base.py | 27 +++++++++++++--- tests/server/transforms/test_search.py | 22 +++++++++++++ tests/tools/tool/test_title.py | 44 +++++++++++++++++++++++--- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 527b16b9b..f7fe31ec0 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -74,7 +74,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l - 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. diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py index ad21f1253..5e02246dd 100644 --- a/fastmcp_slim/fastmcp/tools/base.py +++ b/fastmcp_slim/fastmcp/tools/base.py @@ -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() diff --git a/tests/server/transforms/test_search.py b/tests/server/transforms/test_search.py index 9c508f893..810a05bdd 100644 --- a/tests/server/transforms/test_search.py +++ b/tests/server/transforms/test_search.py @@ -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() diff --git a/tests/tools/tool/test_title.py b/tests/tools/tool/test_title.py index 29c0c1a73..fde193f70 100644 --- a/tests/tools/tool/test_title.py +++ b/tests/tools/tool/test_title.py @@ -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."""