Add title to tools, resources, prompts (#972)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Felix Weinberger 2025-06-17 10:53:14 -04:00 • committed by GitHub
commit a2f8766a8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 481 additions and 46 deletions

View file

@ -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:

View file

@ -123,7 +123,7 @@ class Server:
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 @@ class Tool:
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."""

View file

@ -53,6 +53,7 @@ def main(port: int, transport: str) -> int:
return [
types.Prompt(
name="simple",
title="Simple Assistant Prompt",
description="A simple prompt that can take optional context and topic "
"arguments",
arguments=[

View file

@ -5,9 +5,18 @@ from mcp.server.lowlevel import Server
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 @@ def main(port: int, transport: str) -> int:
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 @@ def main(port: int, transport: str) -> int:
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

View file

@ -41,6 +41,7 @@ def main(port: int, transport: str) -> int:
return [
types.Tool(
name="fetch",
title="Website Fetcher",
description="Fetches a website and returns its content",
inputSchema={
"type": "object",

View file

@ -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 @@ class Prompt(BaseModel):
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 @@ class Prompt(BaseModel):
return cls(
name=func_name,
title=title,
description=description or fn.__doc__ or "",
arguments=arguments,
fn=fn,

View file

@ -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",

View file

@ -51,6 +51,7 @@ class ResourceManager:
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 @@ class ResourceManager:
fn,
uri_template=uri_template,
name=name,
title=title,
description=description,
mime_type=mime_type,
)

View file

@ -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 @@ class ResourceTemplate(BaseModel):
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 @@ class ResourceTemplate(BaseModel):
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 @@ class ResourceTemplate(BaseModel):
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

View file

@ -72,6 +72,7 @@ class FunctionResource(Resource):
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 @@ class FunctionResource(Resource):
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,

View file

@ -237,6 +237,7 @@ class FastMCP:
return [
MCPTool(
name=info.name,
title=info.title,
description=info.description,
inputSchema=info.parameters,
annotations=info.annotations,
@ -270,6 +271,7 @@ class FastMCP:
MCPResource(
uri=resource.uri,
name=resource.name or "",
title=resource.title,
description=resource.description,
mimeType=resource.mime_type,
)
@ -282,6 +284,7 @@ class FastMCP:
MCPResourceTemplate(
uriTemplate=template.uri_template,
name=template.name,
title=template.title,
description=template.description,
)
for template in templates
@ -305,6 +308,7 @@ class FastMCP:
self,
fn: AnyFunction,
name: str | None = None,
title: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
) -> None:
@ -316,14 +320,16 @@ class FastMCP:
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 @@ class FastMCP:
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 @@ class FastMCP:
)
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 @@ class FastMCP:
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 @@ class FastMCP:
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 @@ class FastMCP:
fn=fn,
uri_template=uri,
name=name,
title=title,
description=description,
mime_type=mime_type,
)
@ -471,6 +481,7 @@ class FastMCP:
fn=fn,
uri=uri,
name=name,
title=title,
description=description,
mime_type=mime_type,
)
@ -487,11 +498,14 @@ class FastMCP:
"""
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 @@ class FastMCP:
)
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 @@ class FastMCP:
return [
MCPPrompt(
name=prompt.name,
title=prompt.title,
description=prompt.description,
arguments=[
MCPPromptArgument(

View file

@ -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 @@ class Tool(BaseModel):
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 @@ class Tool(BaseModel):
return cls(
fn=fn,
name=func_name,
title=title,
description=func_doc,
parameters=parameters,
fn_metadata=func_arg_metadata,

View file

@ -46,11 +46,12 @@ class ToolManager:
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:

View file

@ -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

View file

@ -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]

View file

@ -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 @@ def make_everything_fastmcp() -> FastMCP:
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 @@ def make_everything_fastmcp() -> FastMCP:
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 make_everything_fastmcp() -> FastMCP:
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 @@ def make_everything_fastmcp() -> FastMCP:
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 make_everything_fastmcp() -> FastMCP:
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 make_everything_fastmcp() -> FastMCP:
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 test_elicitation_feature(server: None, server_url: str) -> None:
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]

View file

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

View file

@ -44,6 +44,7 @@ class TestAddTools:
original_tool = Tool(
name="add",
title="Add Tool",
description="Add two numbers.",
fn=add,
fn_metadata=fn_metadata,