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
3 changed files with 481 additions and 46 deletions

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,