diff --git a/README.md b/README.md index 1bdae1d20..d8a2db2b6 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:"), @@ -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/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..cef29b851 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -5,9 +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.", + "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", + }, } @@ -28,6 +37,7 @@ async def list_resources() -> list[types.Resource]: types.Resource( uri=FileUrl(f"file:///{name}.txt"), name=name, + title=SAMPLE_RESOURCES[name]["title"], description=f"A sample text resource named {name}", mimeType="text/plain", ) @@ -43,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/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", 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..e3f49daf4 --- /dev/null +++ b/src/mcp/shared/metadata_utils.py @@ -0,0 +1,45 @@ +"""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 in a spec compliant way. +""" + +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. + + 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 + + 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/src/mcp/types.py b/src/mcp/types.py index d56a9bce8..f53813412 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -196,10 +196,26 @@ 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 + """ + 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): + """Describes the name and version of an MCP implementation.""" + version: str model_config = ConfigDict(extra="allow") @@ -382,13 +398,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.""" description: str | None = None """A description of what this resource represents.""" mimeType: str | None = None @@ -409,7 +423,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 @@ -417,8 +431,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.""" description: str | None = None """A human-readable description of what this template is for.""" mimeType: str | None = None @@ -601,11 +613,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.""" description: str | None = None """An optional description of what this prompt provides.""" arguments: list[PromptArgument] | None = None @@ -808,11 +818,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.""" description: str | None = None """A human-readable description of the tool.""" inputSchema: dict[str, Any] 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_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" 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,