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."""