Add title to tools, resources, prompts (#972)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
029f434109
commit
a2f8766a8a
18 changed files with 481 additions and 46 deletions
48
README.md
48
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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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=[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
45
src/mcp/shared/metadata_utils.py
Normal file
45
src/mcp/shared/metadata_utils.py
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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