From 24deabf568dc5066f324151c2a76fa4b9e2b69bf Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 28 Jun 2025 18:11:29 -0400 Subject: [PATCH 1/3] Add title field support to FastMCP components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional title field to FastMCPComponent base class - Support title parameter in all component decorators (@tool, @resource, @prompt) - Include title in MCP protocol serialization when provided - Add get_display_name() method to prefer title over name for display - Add comprehensive tests for title functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/fastmcp/prompts/prompt.py | 13 +++++++- src/fastmcp/resources/resource.py | 7 +++++ src/fastmcp/resources/template.py | 7 +++++ src/fastmcp/server/server.py | 13 ++++++++ src/fastmcp/tools/tool.py | 7 +++++ src/fastmcp/utilities/components.py | 10 +++++- tests/tools/test_tool.py | 47 +++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index d27892513..5022d0711 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -100,12 +100,16 @@ class Prompt(FastMCPComponent, ABC): "description": self.description, "arguments": arguments, } + # Add title field if provided + if self.title is not None: + kwargs["title"] = self.title return MCPPrompt(**kwargs | overrides) @staticmethod def from_function( fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, @@ -119,7 +123,12 @@ class Prompt(FastMCPComponent, ABC): - A sequence of any of the above """ return FunctionPrompt.from_function( - fn=fn, name=name, description=description, tags=tags, enabled=enabled + fn=fn, + name=name, + title=title, + description=description, + tags=tags, + enabled=enabled, ) @abstractmethod @@ -141,6 +150,7 @@ class FunctionPrompt(Prompt): cls, fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, @@ -232,6 +242,7 @@ class FunctionPrompt(Prompt): return cls( name=func_name, + title=title, description=description, arguments=arguments, tags=tags or set(), diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index dd04f8a3f..113960630 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -65,6 +65,7 @@ class Resource(FastMCPComponent, abc.ABC): fn: Callable[[], Any], uri: str | AnyUrl, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, @@ -74,6 +75,7 @@ class Resource(FastMCPComponent, abc.ABC): fn=fn, uri=uri, name=name, + title=title, description=description, mime_type=mime_type, tags=tags, @@ -112,6 +114,9 @@ class Resource(FastMCPComponent, abc.ABC): "description": self.description, "mimeType": self.mime_type, } + # Add title field if provided + if self.title is not None: + kwargs["title"] = self.title return MCPResource(**kwargs | overrides) def __repr__(self) -> str: @@ -149,6 +154,7 @@ class FunctionResource(Resource): fn: Callable[[], Any], uri: str | AnyUrl, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, @@ -161,6 +167,7 @@ class FunctionResource(Resource): fn=fn, uri=uri, name=name or fn.__name__, + title=title, description=description or inspect.getdoc(fn), mime_type=mime_type or "text/plain", tags=tags or set(), diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 00fc29863..d8558e746 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -86,6 +86,7 @@ class ResourceTemplate(FastMCPComponent): fn: Callable[..., Any], uri_template: str, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, @@ -95,6 +96,7 @@ class ResourceTemplate(FastMCPComponent): fn=fn, uri_template=uri_template, name=name, + title=title, description=description, mime_type=mime_type, tags=tags, @@ -145,6 +147,9 @@ class ResourceTemplate(FastMCPComponent): "description": self.description, "mimeType": self.mime_type, } + # Add title field if provided + if self.title is not None: + kwargs["title"] = self.title return MCPResourceTemplate(**kwargs | overrides) @classmethod @@ -197,6 +202,7 @@ class FunctionResourceTemplate(ResourceTemplate): fn: Callable[..., Any], uri_template: str, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, @@ -278,6 +284,7 @@ class FunctionResourceTemplate(ResourceTemplate): return cls( uri_template=uri_template, name=func_name, + title=title, description=description, mime_type=mime_type or "text/plain", fn=fn, diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index d3252b771..c424fa6e1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -790,6 +790,7 @@ class FastMCP(Generic[LifespanResultT]): name_or_fn: AnyFunction, *, name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | None | NotSetT = NotSet, @@ -804,6 +805,7 @@ class FastMCP(Generic[LifespanResultT]): name_or_fn: str | None = None, *, name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | None | NotSetT = NotSet, @@ -817,6 +819,7 @@ class FastMCP(Generic[LifespanResultT]): name_or_fn: str | AnyFunction | None = None, *, name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | None | NotSetT = NotSet, @@ -897,6 +900,7 @@ class FastMCP(Generic[LifespanResultT]): tool = Tool.from_function( fn, name=tool_name, + title=title, description=description, tags=tags, output_schema=output_schema, @@ -928,6 +932,7 @@ class FastMCP(Generic[LifespanResultT]): return partial( self.tool, name=tool_name, + title=title, description=description, tags=tags, output_schema=output_schema, @@ -1016,6 +1021,7 @@ class FastMCP(Generic[LifespanResultT]): uri: str, *, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, @@ -1107,6 +1113,7 @@ class FastMCP(Generic[LifespanResultT]): fn=fn, uri_template=uri, name=name, + title=title, description=description, mime_type=mime_type, tags=tags, @@ -1119,6 +1126,7 @@ class FastMCP(Generic[LifespanResultT]): fn=fn, uri=uri, name=name, + title=title, description=description, mime_type=mime_type, tags=tags, @@ -1158,6 +1166,7 @@ class FastMCP(Generic[LifespanResultT]): name_or_fn: AnyFunction, *, name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, @@ -1169,6 +1178,7 @@ class FastMCP(Generic[LifespanResultT]): name_or_fn: str | None = None, *, name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, @@ -1179,6 +1189,7 @@ class FastMCP(Generic[LifespanResultT]): name_or_fn: str | AnyFunction | None = None, *, name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, @@ -1275,6 +1286,7 @@ class FastMCP(Generic[LifespanResultT]): prompt = Prompt.from_function( fn=fn, name=prompt_name, + title=title, description=description, tags=tags, enabled=enabled, @@ -1303,6 +1315,7 @@ class FastMCP(Generic[LifespanResultT]): return partial( self.prompt, name=prompt_name, + title=title, description=description, tags=tags, enabled=enabled, diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 35dec76f6..c9787adc6 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -142,12 +142,16 @@ class Tool(FastMCPComponent): "outputSchema": self.output_schema, "annotations": self.annotations, } + # Add title field if provided + if self.title is not None: + kwargs["title"] = self.title return MCPTool(**kwargs | overrides) @staticmethod def from_function( fn: Callable[..., Any], name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, @@ -160,6 +164,7 @@ class Tool(FastMCPComponent): return FunctionTool.from_function( fn=fn, name=name, + title=title, description=description, tags=tags, annotations=annotations, @@ -219,6 +224,7 @@ class FunctionTool(Tool): cls, fn: Callable[..., Any], name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, @@ -250,6 +256,7 @@ class FunctionTool(Tool): return cls( fn=parsed_fn.fn, name=name or parsed_fn.name, + title=title, description=description or parsed_fn.description, parameters=parsed_fn.input_schema, output_schema=output_schema, diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py index 2c3bbb2e9..6e7a36418 100644 --- a/src/fastmcp/utilities/components.py +++ b/src/fastmcp/utilities/components.py @@ -24,6 +24,10 @@ class FastMCPComponent(FastMCPBaseModel): name: str = Field( description="The name of the component.", ) + title: str | None = Field( + default=None, + description="The title of the component for display purposes.", + ) description: str | None = Field( default=None, description="The description of the component.", @@ -64,7 +68,7 @@ class FastMCPComponent(FastMCPBaseModel): return self.model_dump() == other.model_dump() def __repr__(self) -> str: - return f"{self.__class__.__name__}(name={self.name!r}, description={self.description!r}, tags={self.tags}, enabled={self.enabled})" + return f"{self.__class__.__name__}(name={self.name!r}, title={self.title!r}, description={self.description!r}, tags={self.tags}, enabled={self.enabled})" def enable(self) -> None: """Enable the component.""" @@ -73,3 +77,7 @@ class FastMCPComponent(FastMCPBaseModel): def disable(self) -> None: """Disable the component.""" self.enabled = False + + def get_display_name(self) -> str: + """Get the display name for this component, preferring title over name.""" + return self.title if self.title is not None else self.name diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index f94e73fd3..9350147c1 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -1225,3 +1225,50 @@ class TestAutomaticStructuredContent: assert result.data.name == "Bob" assert result.data.age == 25 assert result.data.verified is True + + +class TestToolTitle: + """Tests for tool title functionality.""" + + def test_tool_with_title(self): + """Test that tools can have titles and they appear in MCP conversion.""" + + def calculate(x: int, y: int) -> int: + """Calculate the sum of two numbers.""" + return x + y + + tool = Tool.from_function( + calculate, + name="calc", + title="Advanced Calculator Tool", + description="Custom description", + ) + + assert tool.name == "calc" + assert tool.title == "Advanced Calculator Tool" + assert tool.description == "Custom description" + assert tool.get_display_name() == "Advanced Calculator Tool" + + # Test MCP conversion includes title + mcp_tool = tool.to_mcp_tool() + assert mcp_tool.name == "calc" + assert ( + hasattr(mcp_tool, "title") and mcp_tool.title == "Advanced Calculator Tool" + ) + + def test_tool_without_title(self): + """Test that tools without titles use name as display name.""" + + def multiply(a: int, b: int) -> int: + return a * b + + tool = Tool.from_function(multiply) + + assert tool.name == "multiply" + assert tool.title is None + assert tool.get_display_name() == "multiply" + + # 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 From 7d3dcdca036f281c874eec4e708a221cf54427c6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 28 Jun 2025 19:58:38 -0400 Subject: [PATCH 2/3] Add tests for title priority logic in tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test that explicit title takes priority over annotations.title - Test that annotations.title is used as fallback when no explicit title - Ensures the improved to_mcp_tool logic works correctly - Add proper type guards for annotations None checks 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/tools/test_tool.py | 51 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 9350147c1..a2278d6d1 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -1247,7 +1247,6 @@ class TestToolTitle: assert tool.name == "calc" assert tool.title == "Advanced Calculator Tool" assert tool.description == "Custom description" - assert tool.get_display_name() == "Advanced Calculator Tool" # Test MCP conversion includes title mcp_tool = tool.to_mcp_tool() @@ -1266,9 +1265,57 @@ class TestToolTitle: assert tool.name == "multiply" assert tool.title is None - assert tool.get_display_name() == "multiply" # 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 + + 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.""" + return x / y + + # Test with both explicit title and annotations.title + annotations = ToolAnnotations(title="Annotation Title") + tool = Tool.from_function( + divide, + name="div", + title="Explicit Title", + annotations=annotations, + ) + + assert tool.title == "Explicit Title" + assert tool.annotations is not None + assert tool.annotations.title == "Annotation Title" + + # Explicit title should take priority + mcp_tool = tool.to_mcp_tool() + assert mcp_tool.title == "Explicit Title" + + 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.""" + return x % y + + # Test with only annotations.title (no explicit title) + annotations = ToolAnnotations(title="Annotation Title") + tool = Tool.from_function( + modulo, + name="mod", + annotations=annotations, + ) + + assert tool.title is None + assert tool.annotations is not None + assert tool.annotations.title == "Annotation Title" + + # Should fall back to annotations.title + mcp_tool = tool.to_mcp_tool() + assert mcp_tool.title == "Annotation Title" From e0b6c321d29dbf48cecee749b368298bed417e8c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 28 Jun 2025 20:01:21 -0400 Subject: [PATCH 3/3] Update title precedence --- src/fastmcp/prompts/prompt.py | 4 +--- src/fastmcp/resources/resource.py | 4 +--- src/fastmcp/resources/template.py | 4 +--- src/fastmcp/tools/tool.py | 11 ++++++++--- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 5022d0711..98518a24b 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -99,10 +99,8 @@ class Prompt(FastMCPComponent, ABC): "name": self.name, "description": self.description, "arguments": arguments, + "title": self.title, } - # Add title field if provided - if self.title is not None: - kwargs["title"] = self.title return MCPPrompt(**kwargs | overrides) @staticmethod diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 113960630..77941e73d 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -113,10 +113,8 @@ class Resource(FastMCPComponent, abc.ABC): "name": self.name, "description": self.description, "mimeType": self.mime_type, + "title": self.title, } - # Add title field if provided - if self.title is not None: - kwargs["title"] = self.title return MCPResource(**kwargs | overrides) def __repr__(self) -> str: diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index d8558e746..6680de9c9 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -146,10 +146,8 @@ class ResourceTemplate(FastMCPComponent): "name": self.name, "description": self.description, "mimeType": self.mime_type, + "title": self.title, } - # Add title field if provided - if self.title is not None: - kwargs["title"] = self.title return MCPResourceTemplate(**kwargs | overrides) @classmethod diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index c9787adc6..03cab7d3c 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -135,16 +135,21 @@ class Tool(FastMCPComponent): pass # No context available def to_mcp_tool(self, **overrides: Any) -> MCPTool: + if self.title: + title = self.title + elif self.annotations and self.annotations.title: + title = self.annotations.title + else: + title = None + kwargs = { "name": self.name, "description": self.description, "inputSchema": self.parameters, "outputSchema": self.output_schema, "annotations": self.annotations, + "title": title, } - # Add title field if provided - if self.title is not None: - kwargs["title"] = self.title return MCPTool(**kwargs | overrides) @staticmethod