From a67a2d70e0e0ca026f4eb5efd639a1ef102da818 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 16 Jun 2025 18:22:07 +0100 Subject: [PATCH 1/6] feat: add title field to MCP type schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional title field to core MCP types: - Tool: For human-readable tool display names - Resource: For human-readable resource display names - ResourceTemplate: For human-readable template display names - Prompt: For human-readable prompt display names - Implementation: For human-readable implementation display names The title field provides a way to display user-friendly names in UIs while keeping programmatic names stable. All fields are optional to maintain backwards compatibility. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/mcp/types.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/mcp/types.py b/src/mcp/types.py index d56a9bce8..536bac8ae 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -200,6 +200,8 @@ class Implementation(BaseModel): """Describes the name and version of an MCP implementation.""" name: str + title: str | None = None + """A human-readable title for the implementation.""" version: str model_config = ConfigDict(extra="allow") @@ -389,6 +391,8 @@ class Resource(BaseModel): """The URI of this resource.""" name: str """A human-readable name for this resource.""" + title: str | None = None + """A human-readable title for this resource.""" description: str | None = None """A description of what this resource represents.""" mimeType: str | None = None @@ -419,6 +423,8 @@ class ResourceTemplate(BaseModel): """ name: str """A human-readable name for the type of resource this template refers to.""" + title: str | None = None + """A human-readable title for the type of resource this template refers to.""" description: str | None = None """A human-readable description of what this template is for.""" mimeType: str | None = None @@ -606,6 +612,8 @@ class Prompt(BaseModel): name: str """The name of the prompt or prompt template.""" + title: str | None = None + """A human-readable title for the prompt.""" description: str | None = None """An optional description of what this prompt provides.""" arguments: list[PromptArgument] | None = None @@ -813,6 +821,8 @@ class Tool(BaseModel): name: str """The name of the tool.""" + title: str | None = None + """A human-readable title for the tool.""" description: str | None = None """A human-readable description of the tool.""" inputSchema: dict[str, Any] From ba45a023a525da0df9935418e93d89ac69e0b1f3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 17 Jun 2025 13:03:07 +0100 Subject: [PATCH 2/6] refactor: extract BaseMetadata base class for name/title fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add BaseMetadata base class to provide common name and title fields for consistency with TypeScript SDK implementation. This centralizes the field definitions for Implementation, Tool, Resource, ResourceTemplate, and Prompt classes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/mcp/types.py | 37 ++++++++++++++----------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/mcp/types.py b/src/mcp/types.py index 536bac8ae..d14bf6075 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -196,12 +196,19 @@ class EmptyResult(Result): """A response that indicates success but carries no data.""" -class Implementation(BaseModel): - """Describes the name and version of an MCP implementation.""" +class BaseMetadata(BaseModel): + """Base class for entities with name and optional title fields.""" name: str + """The programmatic name of the entity.""" + title: str | None = None - """A human-readable title for the implementation.""" + """An optional human-readable title for the entity.""" + + +class Implementation(BaseMetadata): + """Describes the name and version of an MCP implementation.""" + version: str model_config = ConfigDict(extra="allow") @@ -384,15 +391,11 @@ class Annotations(BaseModel): model_config = ConfigDict(extra="allow") -class Resource(BaseModel): +class Resource(BaseMetadata): """A known resource that the server is capable of reading.""" uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """The URI of this resource.""" - name: str - """A human-readable name for this resource.""" - title: str | None = None - """A human-readable title for this resource.""" description: str | None = None """A description of what this resource represents.""" mimeType: str | None = None @@ -413,7 +416,7 @@ class Resource(BaseModel): model_config = ConfigDict(extra="allow") -class ResourceTemplate(BaseModel): +class ResourceTemplate(BaseMetadata): """A template description for resources available on the server.""" uriTemplate: str @@ -421,10 +424,6 @@ class ResourceTemplate(BaseModel): A URI template (according to RFC 6570) that can be used to construct resource URIs. """ - name: str - """A human-readable name for the type of resource this template refers to.""" - title: str | None = None - """A human-readable title for the type of resource this template refers to.""" description: str | None = None """A human-readable description of what this template is for.""" mimeType: str | None = None @@ -607,13 +606,9 @@ class PromptArgument(BaseModel): model_config = ConfigDict(extra="allow") -class Prompt(BaseModel): +class Prompt(BaseMetadata): """A prompt or prompt template that the server offers.""" - name: str - """The name of the prompt or prompt template.""" - title: str | None = None - """A human-readable title for the prompt.""" description: str | None = None """An optional description of what this prompt provides.""" arguments: list[PromptArgument] | None = None @@ -816,13 +811,9 @@ class ToolAnnotations(BaseModel): model_config = ConfigDict(extra="allow") -class Tool(BaseModel): +class Tool(BaseMetadata): """Definition for a tool the client can call.""" - name: str - """The name of the tool.""" - title: str | None = None - """A human-readable title for the tool.""" description: str | None = None """A human-readable description of the tool.""" inputSchema: dict[str, Any] From f96a202672127b1c91659769e412a9a24c2b30b9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 16 Jun 2025 19:25:25 +0100 Subject: [PATCH 3/6] feat: add title field support to FastMCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional title field to tools, resources, prompts, and resource templates in FastMCP to enable human-readable display names. Also includes a utility function get_display_name() for consistent title/name precedence handling. - Add title parameter to @tool, @resource, and @prompt decorators - Add title field to Tool, Resource, Prompt, and ResourceTemplate models - Implement get_display_name() utility with proper precedence rules - Add comprehensive tests for title functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/mcp/server/fastmcp/prompts/base.py | 3 + src/mcp/server/fastmcp/resources/base.py | 1 + .../fastmcp/resources/resource_manager.py | 2 + src/mcp/server/fastmcp/resources/templates.py | 4 + src/mcp/server/fastmcp/resources/types.py | 2 + src/mcp/server/fastmcp/server.py | 23 +- src/mcp/server/fastmcp/tools/base.py | 3 + src/mcp/server/fastmcp/tools/tool_manager.py | 3 +- src/mcp/shared/metadata_utils.py | 30 +++ tests/server/fastmcp/test_title.py | 215 ++++++++++++++++++ 10 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 src/mcp/shared/metadata_utils.py create mode 100644 tests/server/fastmcp/test_title.py diff --git a/src/mcp/server/fastmcp/prompts/base.py b/src/mcp/server/fastmcp/prompts/base.py index a269d6ab4..e4751feb1 100644 --- a/src/mcp/server/fastmcp/prompts/base.py +++ b/src/mcp/server/fastmcp/prompts/base.py @@ -58,6 +58,7 @@ class Prompt(BaseModel): """A prompt template that can be rendered with parameters.""" name: str = Field(description="Name of the prompt") + title: str | None = Field(None, description="Human-readable title of the prompt") description: str | None = Field(None, description="Description of what the prompt does") arguments: list[PromptArgument] | None = Field(None, description="Arguments that can be passed to the prompt") fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True) @@ -67,6 +68,7 @@ def from_function( cls, fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, + title: str | None = None, description: str | None = None, ) -> "Prompt": """Create a Prompt from a function. @@ -103,6 +105,7 @@ def from_function( return cls( name=func_name, + title=title, description=description or fn.__doc__ or "", arguments=arguments, fn=fn, diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py index 24bf7d531..f57631cc1 100644 --- a/src/mcp/server/fastmcp/resources/base.py +++ b/src/mcp/server/fastmcp/resources/base.py @@ -21,6 +21,7 @@ class Resource(BaseModel, abc.ABC): uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(default=..., description="URI of the resource") name: str | None = Field(description="Name of the resource", default=None) + title: str | None = Field(description="Human-readable title of the resource", default=None) description: str | None = Field(description="Description of the resource", default=None) mime_type: str = Field( default="text/plain", diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/fastmcp/resources/resource_manager.py index d27e6ac12..35e4ec04d 100644 --- a/src/mcp/server/fastmcp/resources/resource_manager.py +++ b/src/mcp/server/fastmcp/resources/resource_manager.py @@ -51,6 +51,7 @@ def add_template( fn: Callable[..., Any], uri_template: str, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, ) -> ResourceTemplate: @@ -59,6 +60,7 @@ def add_template( fn, uri_template=uri_template, name=name, + title=title, description=description, mime_type=mime_type, ) diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index 19d7dedd1..b1c7b2711 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -17,6 +17,7 @@ class ResourceTemplate(BaseModel): uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)") name: str = Field(description="Name of the resource") + title: str | None = Field(description="Human-readable title of the resource", default=None) description: str | None = Field(description="Description of what the resource does") mime_type: str = Field(default="text/plain", description="MIME type of the resource content") fn: Callable[..., Any] = Field(exclude=True) @@ -28,6 +29,7 @@ def from_function( fn: Callable[..., Any], uri_template: str, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, ) -> ResourceTemplate: @@ -45,6 +47,7 @@ def from_function( return cls( uri_template=uri_template, name=func_name, + title=title, description=description or fn.__doc__ or "", mime_type=mime_type or "text/plain", fn=fn, @@ -71,6 +74,7 @@ async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: return FunctionResource( uri=uri, # type: ignore name=self.name, + title=self.title, description=self.description, mime_type=self.mime_type, fn=lambda: result, # Capture result in closure diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py index 63377f423..9c980dff1 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/fastmcp/resources/types.py @@ -72,6 +72,7 @@ def from_function( fn: Callable[..., Any], uri: str, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, ) -> "FunctionResource": @@ -86,6 +87,7 @@ def from_function( return cls( uri=AnyUrl(uri), name=func_name, + title=title, description=description or fn.__doc__ or "", mime_type=mime_type or "text/plain", fn=fn, diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index a85c7117c..3adc465b8 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -237,6 +237,7 @@ async def list_tools(self) -> list[MCPTool]: return [ MCPTool( name=info.name, + title=info.title, description=info.description, inputSchema=info.parameters, annotations=info.annotations, @@ -270,6 +271,7 @@ async def list_resources(self) -> list[MCPResource]: MCPResource( uri=resource.uri, name=resource.name or "", + title=resource.title, description=resource.description, mimeType=resource.mime_type, ) @@ -282,6 +284,7 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: MCPResourceTemplate( uriTemplate=template.uri_template, name=template.name, + title=template.title, description=template.description, ) for template in templates @@ -305,6 +308,7 @@ def add_tool( self, fn: AnyFunction, name: str | None = None, + title: str | None = None, description: str | None = None, annotations: ToolAnnotations | None = None, ) -> None: @@ -316,14 +320,16 @@ def add_tool( Args: fn: The function to register as a tool name: Optional name for the tool (defaults to function name) + title: Optional human-readable title for the tool description: Optional description of what the tool does annotations: Optional ToolAnnotations providing additional tool information """ - self._tool_manager.add_tool(fn, name=name, description=description, annotations=annotations) + self._tool_manager.add_tool(fn, name=name, title=title, description=description, annotations=annotations) def tool( self, name: str | None = None, + title: str | None = None, description: str | None = None, annotations: ToolAnnotations | None = None, ) -> Callable[[AnyFunction], AnyFunction]: @@ -335,6 +341,7 @@ def tool( Args: name: Optional name for the tool (defaults to function name) + title: Optional human-readable title for the tool description: Optional description of what the tool does annotations: Optional ToolAnnotations providing additional tool information @@ -360,7 +367,7 @@ async def async_tool(x: int, context: Context) -> str: ) def decorator(fn: AnyFunction) -> AnyFunction: - self.add_tool(fn, name=name, description=description, annotations=annotations) + self.add_tool(fn, name=name, title=title, description=description, annotations=annotations) return fn return decorator @@ -396,6 +403,7 @@ def resource( uri: str, *, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, ) -> Callable[[AnyFunction], AnyFunction]: @@ -413,6 +421,7 @@ def resource( Args: uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") name: Optional name for the resource + title: Optional human-readable title for the resource description: Optional description of the resource mime_type: Optional MIME type for the resource @@ -462,6 +471,7 @@ def decorator(fn: AnyFunction) -> AnyFunction: fn=fn, uri_template=uri, name=name, + title=title, description=description, mime_type=mime_type, ) @@ -471,6 +481,7 @@ def decorator(fn: AnyFunction) -> AnyFunction: fn=fn, uri=uri, name=name, + title=title, description=description, mime_type=mime_type, ) @@ -487,11 +498,14 @@ def add_prompt(self, prompt: Prompt) -> None: """ self._prompt_manager.add_prompt(prompt) - def prompt(self, name: str | None = None, description: str | None = None) -> Callable[[AnyFunction], AnyFunction]: + def prompt( + self, name: str | None = None, title: str | None = None, description: str | None = None + ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a prompt. Args: name: Optional name for the prompt (defaults to function name) + title: Optional human-readable title for the prompt description: Optional description of what the prompt does Example: @@ -529,7 +543,7 @@ async def analyze_file(path: str) -> list[Message]: ) def decorator(func: AnyFunction) -> AnyFunction: - prompt = Prompt.from_function(func, name=name, description=description) + prompt = Prompt.from_function(func, name=name, title=title, description=description) self.add_prompt(prompt) return func @@ -831,6 +845,7 @@ async def list_prompts(self) -> list[MCPPrompt]: return [ MCPPrompt( name=prompt.name, + title=prompt.title, description=prompt.description, arguments=[ MCPPromptArgument( diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index b25597215..2f7c48e8b 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -22,6 +22,7 @@ class Tool(BaseModel): fn: Callable[..., Any] = Field(exclude=True) name: str = Field(description="Name of the tool") + title: str | None = Field(None, description="Human-readable title of the tool") description: str = Field(description="Description of what the tool does") parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") fn_metadata: FuncMetadata = Field( @@ -36,6 +37,7 @@ def from_function( cls, fn: Callable[..., Any], name: str | None = None, + title: str | None = None, description: str | None = None, context_kwarg: str | None = None, annotations: ToolAnnotations | None = None, @@ -69,6 +71,7 @@ def from_function( return cls( fn=fn, name=func_name, + title=title, description=func_doc, parameters=parameters, fn_metadata=func_arg_metadata, diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index 1cd301299..b9ca1655d 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -46,11 +46,12 @@ def add_tool( self, fn: Callable[..., Any], name: str | None = None, + title: str | None = None, description: str | None = None, annotations: ToolAnnotations | None = None, ) -> Tool: """Add a tool to the server.""" - tool = Tool.from_function(fn, name=name, description=description, annotations=annotations) + tool = Tool.from_function(fn, name=name, title=title, description=description, annotations=annotations) existing = self._tools.get(tool.name) if existing: if self.warn_on_duplicate_tools: diff --git a/src/mcp/shared/metadata_utils.py b/src/mcp/shared/metadata_utils.py new file mode 100644 index 000000000..a682ab533 --- /dev/null +++ b/src/mcp/shared/metadata_utils.py @@ -0,0 +1,30 @@ +"""Utility functions for working with metadata in MCP types.""" + +from mcp.types import Implementation, Prompt, Resource, ResourceTemplate, Tool + + +def get_display_name(obj: Tool | Resource | Prompt | ResourceTemplate | Implementation) -> str: + """ + Get the display name for an MCP object with proper precedence. + + For tools: title > annotations.title > name + For other objects: title > name + + Args: + obj: An MCP object with name and optional title fields + + Returns: + The display name to use for UI presentation + """ + if isinstance(obj, Tool): + # Tools have special precedence: title > annotations.title > name + if hasattr(obj, "title") and obj.title is not None: + return obj.title + if obj.annotations and hasattr(obj.annotations, "title") and obj.annotations.title is not None: + return obj.annotations.title + return obj.name + else: + # All other objects: title > name + if hasattr(obj, "title") and obj.title is not None: + return obj.title + return obj.name diff --git a/tests/server/fastmcp/test_title.py b/tests/server/fastmcp/test_title.py new file mode 100644 index 000000000..a94f6671d --- /dev/null +++ b/tests/server/fastmcp/test_title.py @@ -0,0 +1,215 @@ +"""Integration tests for title field functionality.""" + +import pytest +from pydantic import AnyUrl + +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.resources import FunctionResource +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared.metadata_utils import get_display_name +from mcp.types import Prompt, Resource, ResourceTemplate, Tool, ToolAnnotations + + +@pytest.mark.anyio +async def test_tool_title_precedence(): + """Test that tool title precedence works correctly: title > annotations.title > name.""" + # Create server with various tool configurations + mcp = FastMCP(name="TitleTestServer") + + # Tool with only name + @mcp.tool(description="Basic tool") + def basic_tool(message: str) -> str: + return message + + # Tool with title + @mcp.tool(description="Tool with title", title="User-Friendly Tool") + def tool_with_title(message: str) -> str: + return message + + # Tool with annotations.title (when title is not supported on decorator) + # We'll need to add this manually after registration + @mcp.tool(description="Tool with annotations") + def tool_with_annotations(message: str) -> str: + return message + + # Tool with both title and annotations.title + @mcp.tool(description="Tool with both", title="Primary Title") + def tool_with_both(message: str) -> str: + return message + + # Start server and connect client + async with create_connected_server_and_client_session(mcp._mcp_server) as client: + await client.initialize() + + # List tools + tools_result = await client.list_tools() + tools = {tool.name: tool for tool in tools_result.tools} + + # Verify basic tool uses name + assert "basic_tool" in tools + basic = tools["basic_tool"] + # Since we haven't implemented get_display_name yet, we'll check the raw fields + assert basic.title is None + assert basic.name == "basic_tool" + + # Verify tool with title + assert "tool_with_title" in tools + titled = tools["tool_with_title"] + assert titled.title == "User-Friendly Tool" + + # For now, we'll skip the annotations.title test as it requires modifying + # the tool after registration, which we'll implement later + + # Verify tool with both uses title over annotations.title + assert "tool_with_both" in tools + both = tools["tool_with_both"] + assert both.title == "Primary Title" + + +@pytest.mark.anyio +async def test_prompt_title(): + """Test that prompt titles work correctly.""" + mcp = FastMCP(name="PromptTitleServer") + + # Prompt with only name + @mcp.prompt(description="Basic prompt") + def basic_prompt(topic: str) -> str: + return f"Tell me about {topic}" + + # Prompt with title + @mcp.prompt(description="Titled prompt", title="Ask About Topic") + def titled_prompt(topic: str) -> str: + return f"Tell me about {topic}" + + # Start server and connect client + async with create_connected_server_and_client_session(mcp._mcp_server) as client: + await client.initialize() + + # List prompts + prompts_result = await client.list_prompts() + prompts = {prompt.name: prompt for prompt in prompts_result.prompts} + + # Verify basic prompt uses name + assert "basic_prompt" in prompts + basic = prompts["basic_prompt"] + assert basic.title is None + assert basic.name == "basic_prompt" + + # Verify prompt with title + assert "titled_prompt" in prompts + titled = prompts["titled_prompt"] + assert titled.title == "Ask About Topic" + + +@pytest.mark.anyio +async def test_resource_title(): + """Test that resource titles work correctly.""" + mcp = FastMCP(name="ResourceTitleServer") + + # Static resource without title + def get_basic_data() -> str: + return "Basic data" + + basic_resource = FunctionResource( + uri=AnyUrl("resource://basic"), + name="basic_resource", + description="Basic resource", + fn=get_basic_data, + ) + mcp.add_resource(basic_resource) + + # Static resource with title + def get_titled_data() -> str: + return "Titled data" + + titled_resource = FunctionResource( + uri=AnyUrl("resource://titled"), + name="titled_resource", + title="User-Friendly Resource", + description="Resource with title", + fn=get_titled_data, + ) + mcp.add_resource(titled_resource) + + # Dynamic resource without title + @mcp.resource("resource://dynamic/{id}") + def dynamic_resource(id: str) -> str: + return f"Data for {id}" + + # Dynamic resource with title (when supported) + @mcp.resource("resource://titled-dynamic/{id}", title="Dynamic Data") + def titled_dynamic_resource(id: str) -> str: + return f"Data for {id}" + + # Start server and connect client + async with create_connected_server_and_client_session(mcp._mcp_server) as client: + await client.initialize() + + # List resources + resources_result = await client.list_resources() + resources = {str(res.uri): res for res in resources_result.resources} + + # Verify basic resource uses name + assert "resource://basic" in resources + basic = resources["resource://basic"] + assert basic.title is None + assert basic.name == "basic_resource" + + # Verify resource with title + assert "resource://titled" in resources + titled = resources["resource://titled"] + assert titled.title == "User-Friendly Resource" + + # List resource templates + templates_result = await client.list_resource_templates() + templates = {tpl.uriTemplate: tpl for tpl in templates_result.resourceTemplates} + + # Verify dynamic resource template + assert "resource://dynamic/{id}" in templates + dynamic = templates["resource://dynamic/{id}"] + assert dynamic.title is None + assert dynamic.name == "dynamic_resource" + + # Verify titled dynamic resource template (when supported) + if "resource://titled-dynamic/{id}" in templates: + titled_dynamic = templates["resource://titled-dynamic/{id}"] + assert titled_dynamic.title == "Dynamic Data" + + +@pytest.mark.anyio +async def test_get_display_name_utility(): + """Test the get_display_name utility function.""" + + # Test tool precedence: title > annotations.title > name + tool_name_only = Tool(name="test_tool", inputSchema={}) + assert get_display_name(tool_name_only) == "test_tool" + + tool_with_title = Tool(name="test_tool", title="Test Tool", inputSchema={}) + assert get_display_name(tool_with_title) == "Test Tool" + + tool_with_annotations = Tool(name="test_tool", inputSchema={}, annotations=ToolAnnotations(title="Annotated Tool")) + assert get_display_name(tool_with_annotations) == "Annotated Tool" + + tool_with_both = Tool( + name="test_tool", title="Primary Title", inputSchema={}, annotations=ToolAnnotations(title="Secondary Title") + ) + assert get_display_name(tool_with_both) == "Primary Title" + + # Test other types: title > name + resource = Resource(uri=AnyUrl("file://test"), name="test_res") + assert get_display_name(resource) == "test_res" + + resource_with_title = Resource(uri=AnyUrl("file://test"), name="test_res", title="Test Resource") + assert get_display_name(resource_with_title) == "Test Resource" + + prompt = Prompt(name="test_prompt") + assert get_display_name(prompt) == "test_prompt" + + prompt_with_title = Prompt(name="test_prompt", title="Test Prompt") + assert get_display_name(prompt_with_title) == "Test Prompt" + + template = ResourceTemplate(uriTemplate="file://{id}", name="test_template") + assert get_display_name(template) == "test_template" + + template_with_title = ResourceTemplate(uriTemplate="file://{id}", name="test_template", title="Test Template") + assert get_display_name(template_with_title) == "Test Template" From 5d54a727fc9ca95ce398d14a593913d25d38d754 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 16 Jun 2025 19:41:41 +0100 Subject: [PATCH 4/6] docs: add title field examples to documentation and example servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update README and example servers to demonstrate the new title field feature for better human-readable display names. - Add title parameter to decorator examples in README - Update simple-tool example with "Website Fetcher" title - Update simple-resource example with resource-specific titles - Update simple-prompt example with "Simple Assistant Prompt" title 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 12 ++++++------ .../simple-prompt/mcp_simple_prompt/server.py | 1 + .../simple-resource/mcp_simple_resource/server.py | 7 +++++++ .../servers/simple-tool/mcp_simple_tool/server.py | 1 + 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1bdae1d20..8a0038ae3 100644 --- a/README.md +++ b/README.md @@ -212,13 +212,13 @@ from mcp.server.fastmcp import FastMCP mcp = FastMCP("My App") -@mcp.resource("config://app") +@mcp.resource("config://app", title="Application Configuration") def get_config() -> str: """Static configuration data""" return "App configuration here" -@mcp.resource("users://{user_id}/profile") +@mcp.resource("users://{user_id}/profile", title="User Profile") def get_user_profile(user_id: str) -> str: """Dynamic user data""" return f"Profile data for user {user_id}" @@ -235,13 +235,13 @@ from mcp.server.fastmcp import FastMCP mcp = FastMCP("My App") -@mcp.tool() +@mcp.tool(title="BMI Calculator") def calculate_bmi(weight_kg: float, height_m: float) -> float: """Calculate BMI given weight in kg and height in meters""" return weight_kg / (height_m**2) -@mcp.tool() +@mcp.tool(title="Weather Fetcher") async def fetch_weather(city: str) -> str: """Fetch current weather for a city""" async with httpx.AsyncClient() as client: @@ -260,12 +260,12 @@ from mcp.server.fastmcp.prompts import base mcp = FastMCP("My App") -@mcp.prompt() +@mcp.prompt(title="Code Review") def review_code(code: str) -> str: return f"Please review this code:\n\n{code}" -@mcp.prompt() +@mcp.prompt(title="Debug Assistant") def debug_error(error: str) -> list[base.Message]: return [ base.UserMessage("I'm seeing this error:"), diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py index eca0bbcf3..b562cc932 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py @@ -53,6 +53,7 @@ async def list_prompts() -> list[types.Prompt]: return [ types.Prompt( name="simple", + title="Simple Assistant Prompt", description="A simple prompt that can take optional context and topic " "arguments", arguments=[ diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index 545b415db..184d99ec3 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -10,6 +10,12 @@ "about": "This is the simple-resource MCP server implementation.", } +RESOURCE_TITLES = { + "greeting": "Welcome Message", + "help": "Help Documentation", + "about": "About This Server", +} + @click.command() @click.option("--port", default=8000, help="Port to listen on for SSE") @@ -28,6 +34,7 @@ async def list_resources() -> list[types.Resource]: types.Resource( uri=FileUrl(f"file:///{name}.txt"), name=name, + title=RESOURCE_TITLES.get(name), description=f"A sample text resource named {name}", mimeType="text/plain", ) diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index 29c741c2c..a6a3f0bd2 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -41,6 +41,7 @@ async def list_tools() -> list[types.Tool]: return [ types.Tool( name="fetch", + title="Website Fetcher", description="Fetches a website and returns its content", inputSchema={ "type": "object", From 6fb06d79045343ad65115b02153234accd19799f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 17 Jun 2025 12:31:49 +0100 Subject: [PATCH 5/6] docs: add client-side usage examples for get_display_name() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update simple-chatbot example to show title as separate field for LLM - Add "Client Display Utilities" section to README with usage examples - Enhance get_display_name() documentation to clarify client-side usage This demonstrates best practices for displaying human-readable titles in MCP clients while maintaining programmatic names for tool execution. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 36 +++++++++++++++++++ .../simple-chatbot/mcp_simple_chatbot/main.py | 22 +++++++++--- src/mcp/shared/metadata_utils.py | 17 ++++++++- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8a0038ae3..d8a2db2b6 100644 --- a/README.md +++ b/README.md @@ -918,6 +918,42 @@ async def main(): tool_result = await session.call_tool("echo", {"message": "hello"}) ``` +### Client Display Utilities + +When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: + +```python +from mcp.shared.metadata_utils import get_display_name +from mcp.client.session import ClientSession + + +async def display_tools(session: ClientSession): + """Display available tools with human-readable names""" + tools_response = await session.list_tools() + + for tool in tools_response.tools: + # get_display_name() returns the title if available, otherwise the name + display_name = get_display_name(tool) + print(f"Tool: {display_name}") + if tool.description: + print(f" {tool.description}") + + +async def display_resources(session: ClientSession): + """Display available resources with human-readable names""" + resources_response = await session.list_resources() + + for resource in resources_response.resources: + display_name = get_display_name(resource) + print(f"Resource: {display_name} ({resource.uri})") +``` + +The `get_display_name()` function implements the proper precedence rules for displaying names: +- For tools: `title` > `annotations.title` > `name` +- For other objects: `title` > `name` + +This ensures your client UI shows the most user-friendly names that servers provide. + ### OAuth Authentication for Clients The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py index 180d709aa..b97b85080 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py @@ -123,7 +123,7 @@ async def list_tools(self) -> list[Any]: for item in tools_response: if isinstance(item, tuple) and item[0] == "tools": tools.extend( - Tool(tool.name, tool.description, tool.inputSchema) + Tool(tool.name, tool.description, tool.inputSchema, tool.title) for tool in item[1] ) @@ -189,9 +189,14 @@ class Tool: """Represents a tool with its properties and formatting.""" def __init__( - self, name: str, description: str, input_schema: dict[str, Any] + self, + name: str, + description: str, + input_schema: dict[str, Any], + title: str | None = None, ) -> None: self.name: str = name + self.title: str | None = title self.description: str = description self.input_schema: dict[str, Any] = input_schema @@ -211,13 +216,20 @@ def format_for_llm(self) -> str: arg_desc += " (required)" args_desc.append(arg_desc) - return f""" -Tool: {self.name} -Description: {self.description} + # Build the formatted output with title as a separate field + output = f"Tool: {self.name}\n" + + # Add human-readable title if available + if self.title: + output += f"User-readable title: {self.title}\n" + + output += f"""Description: {self.description} Arguments: {chr(10).join(args_desc)} """ + return output + class LLMClient: """Manages communication with the LLM provider.""" diff --git a/src/mcp/shared/metadata_utils.py b/src/mcp/shared/metadata_utils.py index a682ab533..0d7aa2a1a 100644 --- a/src/mcp/shared/metadata_utils.py +++ b/src/mcp/shared/metadata_utils.py @@ -1,4 +1,8 @@ -"""Utility functions for working with metadata in MCP types.""" +"""Utility functions for working with metadata in MCP types. + +These utilities are primarily intended for client-side usage to properly display +human-readable names in user interfaces. +""" from mcp.types import Implementation, Prompt, Resource, ResourceTemplate, Tool @@ -7,9 +11,20 @@ def get_display_name(obj: Tool | Resource | Prompt | ResourceTemplate | Implemen """ Get the display name for an MCP object with proper precedence. + This is a client-side utility function designed to help MCP clients display + human-readable names in their user interfaces. When servers provide a 'title' + field, it should be preferred over the programmatic 'name' field for display. + For tools: title > annotations.title > name For other objects: title > name + Example: + # In a client displaying available tools + tools = await session.list_tools() + for tool in tools.tools: + display_name = get_display_name(tool) + print(f"Available tool: {display_name}") + Args: obj: An MCP object with name and optional title fields From ef4ca8822a9abdd85199295a366cda773fbd9cd6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 17 Jun 2025 14:34:21 +0100 Subject: [PATCH 6/6] feat: address PR review comments for title field implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add titles to all tools, resources, and prompts in make_everything_fastmcp test fixture - Refactor simple-resource server to use single dict for resource data and titles - Add MCP specification link to metadata_utils.py documentation - Add comprehensive test_title_precedence to verify title functionality - Fix missing title field in test_tool_manager.py Tool instantiation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../mcp_simple_resource/server.py | 25 ++--- src/mcp/shared/metadata_utils.py | 2 +- src/mcp/types.py | 9 +- tests/server/fastmcp/test_integration.py | 99 ++++++++++++++++--- tests/server/fastmcp/test_tool_manager.py | 1 + 5 files changed, 111 insertions(+), 25 deletions(-) diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index 184d99ec3..cef29b851 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -5,15 +5,18 @@ from pydantic import AnyUrl, FileUrl SAMPLE_RESOURCES = { - "greeting": "Hello! This is a sample text resource.", - "help": "This server provides a few sample text resources for testing.", - "about": "This is the simple-resource MCP server implementation.", -} - -RESOURCE_TITLES = { - "greeting": "Welcome Message", - "help": "Help Documentation", - "about": "About This Server", + "greeting": { + "content": "Hello! This is a sample text resource.", + "title": "Welcome Message", + }, + "help": { + "content": "This server provides a few sample text resources for testing.", + "title": "Help Documentation", + }, + "about": { + "content": "This is the simple-resource MCP server implementation.", + "title": "About This Server", + }, } @@ -34,7 +37,7 @@ async def list_resources() -> list[types.Resource]: types.Resource( uri=FileUrl(f"file:///{name}.txt"), name=name, - title=RESOURCE_TITLES.get(name), + title=SAMPLE_RESOURCES[name]["title"], description=f"A sample text resource named {name}", mimeType="text/plain", ) @@ -50,7 +53,7 @@ async def read_resource(uri: AnyUrl) -> str | bytes: if name not in SAMPLE_RESOURCES: raise ValueError(f"Unknown resource: {uri}") - return SAMPLE_RESOURCES[name] + return SAMPLE_RESOURCES[name]["content"] if transport == "sse": from mcp.server.sse import SseServerTransport diff --git a/src/mcp/shared/metadata_utils.py b/src/mcp/shared/metadata_utils.py index 0d7aa2a1a..e3f49daf4 100644 --- a/src/mcp/shared/metadata_utils.py +++ b/src/mcp/shared/metadata_utils.py @@ -1,7 +1,7 @@ """Utility functions for working with metadata in MCP types. These utilities are primarily intended for client-side usage to properly display -human-readable names in user interfaces. +human-readable names in user interfaces in a spec compliant way. """ from mcp.types import Implementation, Prompt, Resource, ResourceTemplate, Tool diff --git a/src/mcp/types.py b/src/mcp/types.py index d14bf6075..f53813412 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -203,7 +203,14 @@ class BaseMetadata(BaseModel): """The programmatic name of the entity.""" title: str | None = None - """An optional human-readable title for the entity.""" + """ + Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ class Implementation(BaseMetadata): diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index 3eb013946..7224a8e48 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -126,7 +126,7 @@ def make_everything_fastmcp() -> FastMCP: mcp = FastMCP(name="EverythingServer") # Tool with context for logging and progress - @mcp.tool(description="A tool that demonstrates logging and progress") + @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: await ctx.info(f"Starting processing of '{message}' with {steps} steps") @@ -143,12 +143,12 @@ async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: return f"Processed '{message}' in {steps} steps" # Simple tool for basic functionality - @mcp.tool(description="A simple echo tool") + @mcp.tool(description="A simple echo tool", title="Echo Tool") def echo(message: str) -> str: return f"Echo: {message}" # Tool with sampling capability - @mcp.tool(description="A tool that uses sampling to generate content") + @mcp.tool(description="A tool that uses sampling to generate content", title="Sampling Tool") async def sampling_tool(prompt: str, ctx: Context) -> str: await ctx.info(f"Requesting sampling for prompt: {prompt}") @@ -167,7 +167,7 @@ async def sampling_tool(prompt: str, ctx: Context) -> str: return f"Sampling result: {str(result.content)[:100]}..." # Tool that sends notifications and logging - @mcp.tool(description="A tool that demonstrates notifications and logging") + @mcp.tool(description="A tool that demonstrates notifications and logging", title="Notification Tool") async def notification_tool(message: str, ctx: Context) -> str: # Send different log levels await ctx.debug("Debug: Starting notification tool") @@ -188,35 +188,36 @@ def get_static_info() -> str: static_resource = FunctionResource( uri=AnyUrl("resource://static/info"), name="Static Info", + title="Static Information", description="Static information resource", fn=get_static_info, ) mcp.add_resource(static_resource) # Resource - dynamic function - @mcp.resource("resource://dynamic/{category}") + @mcp.resource("resource://dynamic/{category}", title="Dynamic Resource") def dynamic_resource(category: str) -> str: return f"Dynamic resource content for category: {category}" # Resource template - @mcp.resource("resource://template/{id}/data") + @mcp.resource("resource://template/{id}/data", title="Template Resource") def template_resource(id: str) -> str: return f"Template resource data for ID: {id}" # Prompt - simple - @mcp.prompt(description="A simple prompt") + @mcp.prompt(description="A simple prompt", title="Simple Prompt") def simple_prompt(topic: str) -> str: return f"Tell me about {topic}" # Prompt - complex with multiple messages - @mcp.prompt(description="Complex prompt with context") + @mcp.prompt(description="Complex prompt with context", title="Complex Prompt") def complex_prompt(user_query: str, context: str = "general") -> str: # For simplicity, return a single string that incorporates the context # Since FastMCP doesn't support system messages in the same way return f"Context: {context}. Query: {user_query}" # Resource template with completion support - @mcp.resource("github://repos/{owner}/{repo}") + @mcp.resource("github://repos/{owner}/{repo}", title="GitHub Repository") def github_repo_resource(owner: str, repo: str) -> str: return f"Repository: {owner}/{repo}" @@ -250,7 +251,7 @@ async def handle_completion( return Completion(values=[], total=0, hasMore=False) # Tool that echoes request headers from context - @mcp.tool(description="Echo request headers from context") + @mcp.tool(description="Echo request headers from context", title="Echo Headers") def echo_headers(ctx: Context[Any, Any, Request]) -> str: """Returns the request headers as JSON.""" headers_info = {} @@ -260,7 +261,7 @@ def echo_headers(ctx: Context[Any, Any, Request]) -> str: return json.dumps(headers_info) # Tool that returns full request context - @mcp.tool(description="Echo request context with custom data") + @mcp.tool(description="Echo request context with custom data", title="Echo Context") def echo_context(custom_request_id: str, ctx: Context[Any, Any, Request]) -> str: """Returns request context including headers and custom data.""" context_data = { @@ -277,7 +278,7 @@ def echo_context(custom_request_id: str, ctx: Context[Any, Any, Request]) -> str return json.dumps(context_data) # Restaurant booking tool with elicitation - @mcp.tool(description="Book a table at a restaurant with elicitation") + @mcp.tool(description="Book a table at a restaurant with elicitation", title="Restaurant Booking") async def book_restaurant( date: str, time: str, @@ -1055,3 +1056,77 @@ async def elicitation_callback(context, params): assert isinstance(tool_result.content[0], TextContent) # # The test should only succeed with the successful elicitation response assert tool_result.content[0].text == "User answered: Test User" + + +@pytest.mark.anyio +async def test_title_precedence(everything_server: None, everything_server_url: str) -> None: + """Test that titles are properly returned for tools, resources, and prompts.""" + from mcp.shared.metadata_utils import get_display_name + + async with sse_client(everything_server_url + "/sse") as streams: + async with ClientSession(*streams) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + + # Test tools have titles + tools_result = await session.list_tools() + assert tools_result.tools + + # Check specific tools have titles + tool_names_to_titles = { + "tool_with_progress": "Progress Tool", + "echo": "Echo Tool", + "sampling_tool": "Sampling Tool", + "notification_tool": "Notification Tool", + "echo_headers": "Echo Headers", + "echo_context": "Echo Context", + "book_restaurant": "Restaurant Booking", + } + + for tool in tools_result.tools: + if tool.name in tool_names_to_titles: + assert tool.title == tool_names_to_titles[tool.name] + # Test get_display_name utility + assert get_display_name(tool) == tool_names_to_titles[tool.name] + + # Test resources have titles + resources_result = await session.list_resources() + assert resources_result.resources + + # Check specific resources have titles + static_resource = next((r for r in resources_result.resources if r.name == "Static Info"), None) + assert static_resource is not None + assert static_resource.title == "Static Information" + assert get_display_name(static_resource) == "Static Information" + + # Test resource templates have titles + resource_templates = await session.list_resource_templates() + assert resource_templates.resourceTemplates + + # Check specific resource templates have titles + template_uris_to_titles = { + "resource://dynamic/{category}": "Dynamic Resource", + "resource://template/{id}/data": "Template Resource", + "github://repos/{owner}/{repo}": "GitHub Repository", + } + + for template in resource_templates.resourceTemplates: + if template.uriTemplate in template_uris_to_titles: + assert template.title == template_uris_to_titles[template.uriTemplate] + assert get_display_name(template) == template_uris_to_titles[template.uriTemplate] + + # Test prompts have titles + prompts_result = await session.list_prompts() + assert prompts_result.prompts + + # Check specific prompts have titles + prompt_names_to_titles = { + "simple_prompt": "Simple Prompt", + "complex_prompt": "Complex Prompt", + } + + for prompt in prompts_result.prompts: + if prompt.name in prompt_names_to_titles: + assert prompt.title == prompt_names_to_titles[prompt.name] + assert get_display_name(prompt) == prompt_names_to_titles[prompt.name] diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index 7954e1729..206df42d7 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -44,6 +44,7 @@ class AddArguments(ArgModelBase): original_tool = Tool( name="add", + title="Add Tool", description="Add two numbers.", fn=add, fn_metadata=fn_metadata,