Add title to tools, resources, prompts (#972)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
029f434109
commit
a2f8766a8a
3 changed files with 481 additions and 46 deletions
|
|
@ -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]
|
||||
|
|
|
|||
215
tests/server/fastmcp/test_title.py
Normal file
215
tests/server/fastmcp/test_title.py
Normal 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"
|
||||
|
|
@ -44,6 +44,7 @@ class TestAddTools:
|
|||
|
||||
original_tool = Tool(
|
||||
name="add",
|
||||
title="Add Tool",
|
||||
description="Add two numbers.",
|
||||
fn=add,
|
||||
fn_metadata=fn_metadata,
|
||||
|
|
|
|||
Loading…
Reference in a new issue