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 = FastMCP("My App")
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource("config://app")
|
@mcp.resource("config://app", title="Application Configuration")
|
||||||
def get_config() -> str:
|
def get_config() -> str:
|
||||||
"""Static configuration data"""
|
"""Static configuration data"""
|
||||||
return "App configuration here"
|
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:
|
def get_user_profile(user_id: str) -> str:
|
||||||
"""Dynamic user data"""
|
"""Dynamic user data"""
|
||||||
return f"Profile data for user {user_id}"
|
return f"Profile data for user {user_id}"
|
||||||
|
|
@ -235,13 +235,13 @@ from mcp.server.fastmcp import FastMCP
|
||||||
mcp = FastMCP("My App")
|
mcp = FastMCP("My App")
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(title="BMI Calculator")
|
||||||
def calculate_bmi(weight_kg: float, height_m: float) -> float:
|
def calculate_bmi(weight_kg: float, height_m: float) -> float:
|
||||||
"""Calculate BMI given weight in kg and height in meters"""
|
"""Calculate BMI given weight in kg and height in meters"""
|
||||||
return weight_kg / (height_m**2)
|
return weight_kg / (height_m**2)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(title="Weather Fetcher")
|
||||||
async def fetch_weather(city: str) -> str:
|
async def fetch_weather(city: str) -> str:
|
||||||
"""Fetch current weather for a city"""
|
"""Fetch current weather for a city"""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|
@ -260,12 +260,12 @@ from mcp.server.fastmcp.prompts import base
|
||||||
mcp = FastMCP("My App")
|
mcp = FastMCP("My App")
|
||||||
|
|
||||||
|
|
||||||
@mcp.prompt()
|
@mcp.prompt(title="Code Review")
|
||||||
def review_code(code: str) -> str:
|
def review_code(code: str) -> str:
|
||||||
return f"Please review this code:\n\n{code}"
|
return f"Please review this code:\n\n{code}"
|
||||||
|
|
||||||
|
|
||||||
@mcp.prompt()
|
@mcp.prompt(title="Debug Assistant")
|
||||||
def debug_error(error: str) -> list[base.Message]:
|
def debug_error(error: str) -> list[base.Message]:
|
||||||
return [
|
return [
|
||||||
base.UserMessage("I'm seeing this error:"),
|
base.UserMessage("I'm seeing this error:"),
|
||||||
|
|
@ -918,6 +918,42 @@ async def main():
|
||||||
tool_result = await session.call_tool("echo", {"message": "hello"})
|
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
|
### OAuth Authentication for Clients
|
||||||
|
|
||||||
The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers:
|
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:
|
for item in tools_response:
|
||||||
if isinstance(item, tuple) and item[0] == "tools":
|
if isinstance(item, tuple) and item[0] == "tools":
|
||||||
tools.extend(
|
tools.extend(
|
||||||
Tool(tool.name, tool.description, tool.inputSchema)
|
Tool(tool.name, tool.description, tool.inputSchema, tool.title)
|
||||||
for tool in item[1]
|
for tool in item[1]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -189,9 +189,14 @@ class Tool:
|
||||||
"""Represents a tool with its properties and formatting."""
|
"""Represents a tool with its properties and formatting."""
|
||||||
|
|
||||||
def __init__(
|
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:
|
) -> None:
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
|
self.title: str | None = title
|
||||||
self.description: str = description
|
self.description: str = description
|
||||||
self.input_schema: dict[str, Any] = input_schema
|
self.input_schema: dict[str, Any] = input_schema
|
||||||
|
|
||||||
|
|
@ -211,13 +216,20 @@ class Tool:
|
||||||
arg_desc += " (required)"
|
arg_desc += " (required)"
|
||||||
args_desc.append(arg_desc)
|
args_desc.append(arg_desc)
|
||||||
|
|
||||||
return f"""
|
# Build the formatted output with title as a separate field
|
||||||
Tool: {self.name}
|
output = f"Tool: {self.name}\n"
|
||||||
Description: {self.description}
|
|
||||||
|
# Add human-readable title if available
|
||||||
|
if self.title:
|
||||||
|
output += f"User-readable title: {self.title}\n"
|
||||||
|
|
||||||
|
output += f"""Description: {self.description}
|
||||||
Arguments:
|
Arguments:
|
||||||
{chr(10).join(args_desc)}
|
{chr(10).join(args_desc)}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
class LLMClient:
|
class LLMClient:
|
||||||
"""Manages communication with the LLM provider."""
|
"""Manages communication with the LLM provider."""
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ def main(port: int, transport: str) -> int:
|
||||||
return [
|
return [
|
||||||
types.Prompt(
|
types.Prompt(
|
||||||
name="simple",
|
name="simple",
|
||||||
|
title="Simple Assistant Prompt",
|
||||||
description="A simple prompt that can take optional context and topic "
|
description="A simple prompt that can take optional context and topic "
|
||||||
"arguments",
|
"arguments",
|
||||||
arguments=[
|
arguments=[
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,18 @@ from mcp.server.lowlevel import Server
|
||||||
from pydantic import AnyUrl, FileUrl
|
from pydantic import AnyUrl, FileUrl
|
||||||
|
|
||||||
SAMPLE_RESOURCES = {
|
SAMPLE_RESOURCES = {
|
||||||
"greeting": "Hello! This is a sample text resource.",
|
"greeting": {
|
||||||
"help": "This server provides a few sample text resources for testing.",
|
"content": "Hello! This is a sample text resource.",
|
||||||
"about": "This is the simple-resource MCP server implementation.",
|
"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(
|
types.Resource(
|
||||||
uri=FileUrl(f"file:///{name}.txt"),
|
uri=FileUrl(f"file:///{name}.txt"),
|
||||||
name=name,
|
name=name,
|
||||||
|
title=SAMPLE_RESOURCES[name]["title"],
|
||||||
description=f"A sample text resource named {name}",
|
description=f"A sample text resource named {name}",
|
||||||
mimeType="text/plain",
|
mimeType="text/plain",
|
||||||
)
|
)
|
||||||
|
|
@ -43,7 +53,7 @@ def main(port: int, transport: str) -> int:
|
||||||
if name not in SAMPLE_RESOURCES:
|
if name not in SAMPLE_RESOURCES:
|
||||||
raise ValueError(f"Unknown resource: {uri}")
|
raise ValueError(f"Unknown resource: {uri}")
|
||||||
|
|
||||||
return SAMPLE_RESOURCES[name]
|
return SAMPLE_RESOURCES[name]["content"]
|
||||||
|
|
||||||
if transport == "sse":
|
if transport == "sse":
|
||||||
from mcp.server.sse import SseServerTransport
|
from mcp.server.sse import SseServerTransport
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ def main(port: int, transport: str) -> int:
|
||||||
return [
|
return [
|
||||||
types.Tool(
|
types.Tool(
|
||||||
name="fetch",
|
name="fetch",
|
||||||
|
title="Website Fetcher",
|
||||||
description="Fetches a website and returns its content",
|
description="Fetches a website and returns its content",
|
||||||
inputSchema={
|
inputSchema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class Prompt(BaseModel):
|
||||||
"""A prompt template that can be rendered with parameters."""
|
"""A prompt template that can be rendered with parameters."""
|
||||||
|
|
||||||
name: str = Field(description="Name of the prompt")
|
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")
|
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")
|
arguments: list[PromptArgument] | None = Field(None, description="Arguments that can be passed to the prompt")
|
||||||
fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True)
|
fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True)
|
||||||
|
|
@ -67,6 +68,7 @@ class Prompt(BaseModel):
|
||||||
cls,
|
cls,
|
||||||
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
|
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
) -> "Prompt":
|
) -> "Prompt":
|
||||||
"""Create a Prompt from a function.
|
"""Create a Prompt from a function.
|
||||||
|
|
@ -103,6 +105,7 @@ class Prompt(BaseModel):
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
name=func_name,
|
name=func_name,
|
||||||
|
title=title,
|
||||||
description=description or fn.__doc__ or "",
|
description=description or fn.__doc__ or "",
|
||||||
arguments=arguments,
|
arguments=arguments,
|
||||||
fn=fn,
|
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")
|
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)
|
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)
|
description: str | None = Field(description="Description of the resource", default=None)
|
||||||
mime_type: str = Field(
|
mime_type: str = Field(
|
||||||
default="text/plain",
|
default="text/plain",
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ class ResourceManager:
|
||||||
fn: Callable[..., Any],
|
fn: Callable[..., Any],
|
||||||
uri_template: str,
|
uri_template: str,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
mime_type: str | None = None,
|
mime_type: str | None = None,
|
||||||
) -> ResourceTemplate:
|
) -> ResourceTemplate:
|
||||||
|
|
@ -59,6 +60,7 @@ class ResourceManager:
|
||||||
fn,
|
fn,
|
||||||
uri_template=uri_template,
|
uri_template=uri_template,
|
||||||
name=name,
|
name=name,
|
||||||
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
mime_type=mime_type,
|
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)")
|
uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)")
|
||||||
name: str = Field(description="Name of the resource")
|
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")
|
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")
|
mime_type: str = Field(default="text/plain", description="MIME type of the resource content")
|
||||||
fn: Callable[..., Any] = Field(exclude=True)
|
fn: Callable[..., Any] = Field(exclude=True)
|
||||||
|
|
@ -28,6 +29,7 @@ class ResourceTemplate(BaseModel):
|
||||||
fn: Callable[..., Any],
|
fn: Callable[..., Any],
|
||||||
uri_template: str,
|
uri_template: str,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
mime_type: str | None = None,
|
mime_type: str | None = None,
|
||||||
) -> ResourceTemplate:
|
) -> ResourceTemplate:
|
||||||
|
|
@ -45,6 +47,7 @@ class ResourceTemplate(BaseModel):
|
||||||
return cls(
|
return cls(
|
||||||
uri_template=uri_template,
|
uri_template=uri_template,
|
||||||
name=func_name,
|
name=func_name,
|
||||||
|
title=title,
|
||||||
description=description or fn.__doc__ or "",
|
description=description or fn.__doc__ or "",
|
||||||
mime_type=mime_type or "text/plain",
|
mime_type=mime_type or "text/plain",
|
||||||
fn=fn,
|
fn=fn,
|
||||||
|
|
@ -71,6 +74,7 @@ class ResourceTemplate(BaseModel):
|
||||||
return FunctionResource(
|
return FunctionResource(
|
||||||
uri=uri, # type: ignore
|
uri=uri, # type: ignore
|
||||||
name=self.name,
|
name=self.name,
|
||||||
|
title=self.title,
|
||||||
description=self.description,
|
description=self.description,
|
||||||
mime_type=self.mime_type,
|
mime_type=self.mime_type,
|
||||||
fn=lambda: result, # Capture result in closure
|
fn=lambda: result, # Capture result in closure
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ class FunctionResource(Resource):
|
||||||
fn: Callable[..., Any],
|
fn: Callable[..., Any],
|
||||||
uri: str,
|
uri: str,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
mime_type: str | None = None,
|
mime_type: str | None = None,
|
||||||
) -> "FunctionResource":
|
) -> "FunctionResource":
|
||||||
|
|
@ -86,6 +87,7 @@ class FunctionResource(Resource):
|
||||||
return cls(
|
return cls(
|
||||||
uri=AnyUrl(uri),
|
uri=AnyUrl(uri),
|
||||||
name=func_name,
|
name=func_name,
|
||||||
|
title=title,
|
||||||
description=description or fn.__doc__ or "",
|
description=description or fn.__doc__ or "",
|
||||||
mime_type=mime_type or "text/plain",
|
mime_type=mime_type or "text/plain",
|
||||||
fn=fn,
|
fn=fn,
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ class FastMCP:
|
||||||
return [
|
return [
|
||||||
MCPTool(
|
MCPTool(
|
||||||
name=info.name,
|
name=info.name,
|
||||||
|
title=info.title,
|
||||||
description=info.description,
|
description=info.description,
|
||||||
inputSchema=info.parameters,
|
inputSchema=info.parameters,
|
||||||
annotations=info.annotations,
|
annotations=info.annotations,
|
||||||
|
|
@ -270,6 +271,7 @@ class FastMCP:
|
||||||
MCPResource(
|
MCPResource(
|
||||||
uri=resource.uri,
|
uri=resource.uri,
|
||||||
name=resource.name or "",
|
name=resource.name or "",
|
||||||
|
title=resource.title,
|
||||||
description=resource.description,
|
description=resource.description,
|
||||||
mimeType=resource.mime_type,
|
mimeType=resource.mime_type,
|
||||||
)
|
)
|
||||||
|
|
@ -282,6 +284,7 @@ class FastMCP:
|
||||||
MCPResourceTemplate(
|
MCPResourceTemplate(
|
||||||
uriTemplate=template.uri_template,
|
uriTemplate=template.uri_template,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
|
title=template.title,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
)
|
)
|
||||||
for template in templates
|
for template in templates
|
||||||
|
|
@ -305,6 +308,7 @@ class FastMCP:
|
||||||
self,
|
self,
|
||||||
fn: AnyFunction,
|
fn: AnyFunction,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
annotations: ToolAnnotations | None = None,
|
annotations: ToolAnnotations | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -316,14 +320,16 @@ class FastMCP:
|
||||||
Args:
|
Args:
|
||||||
fn: The function to register as a tool
|
fn: The function to register as a tool
|
||||||
name: Optional name for the tool (defaults to function name)
|
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
|
description: Optional description of what the tool does
|
||||||
annotations: Optional ToolAnnotations providing additional tool information
|
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(
|
def tool(
|
||||||
self,
|
self,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
annotations: ToolAnnotations | None = None,
|
annotations: ToolAnnotations | None = None,
|
||||||
) -> Callable[[AnyFunction], AnyFunction]:
|
) -> Callable[[AnyFunction], AnyFunction]:
|
||||||
|
|
@ -335,6 +341,7 @@ class FastMCP:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Optional name for the tool (defaults to function name)
|
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
|
description: Optional description of what the tool does
|
||||||
annotations: Optional ToolAnnotations providing additional tool information
|
annotations: Optional ToolAnnotations providing additional tool information
|
||||||
|
|
||||||
|
|
@ -360,7 +367,7 @@ class FastMCP:
|
||||||
)
|
)
|
||||||
|
|
||||||
def decorator(fn: AnyFunction) -> AnyFunction:
|
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 fn
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
@ -396,6 +403,7 @@ class FastMCP:
|
||||||
uri: str,
|
uri: str,
|
||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
mime_type: str | None = None,
|
mime_type: str | None = None,
|
||||||
) -> Callable[[AnyFunction], AnyFunction]:
|
) -> Callable[[AnyFunction], AnyFunction]:
|
||||||
|
|
@ -413,6 +421,7 @@ class FastMCP:
|
||||||
Args:
|
Args:
|
||||||
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
|
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
|
||||||
name: Optional name for the resource
|
name: Optional name for the resource
|
||||||
|
title: Optional human-readable title for the resource
|
||||||
description: Optional description of the resource
|
description: Optional description of the resource
|
||||||
mime_type: Optional MIME type for the resource
|
mime_type: Optional MIME type for the resource
|
||||||
|
|
||||||
|
|
@ -462,6 +471,7 @@ class FastMCP:
|
||||||
fn=fn,
|
fn=fn,
|
||||||
uri_template=uri,
|
uri_template=uri,
|
||||||
name=name,
|
name=name,
|
||||||
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
)
|
)
|
||||||
|
|
@ -471,6 +481,7 @@ class FastMCP:
|
||||||
fn=fn,
|
fn=fn,
|
||||||
uri=uri,
|
uri=uri,
|
||||||
name=name,
|
name=name,
|
||||||
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
)
|
)
|
||||||
|
|
@ -487,11 +498,14 @@ class FastMCP:
|
||||||
"""
|
"""
|
||||||
self._prompt_manager.add_prompt(prompt)
|
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.
|
"""Decorator to register a prompt.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Optional name for the prompt (defaults to function name)
|
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
|
description: Optional description of what the prompt does
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
@ -529,7 +543,7 @@ class FastMCP:
|
||||||
)
|
)
|
||||||
|
|
||||||
def decorator(func: AnyFunction) -> AnyFunction:
|
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)
|
self.add_prompt(prompt)
|
||||||
return func
|
return func
|
||||||
|
|
||||||
|
|
@ -831,6 +845,7 @@ class FastMCP:
|
||||||
return [
|
return [
|
||||||
MCPPrompt(
|
MCPPrompt(
|
||||||
name=prompt.name,
|
name=prompt.name,
|
||||||
|
title=prompt.title,
|
||||||
description=prompt.description,
|
description=prompt.description,
|
||||||
arguments=[
|
arguments=[
|
||||||
MCPPromptArgument(
|
MCPPromptArgument(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class Tool(BaseModel):
|
||||||
|
|
||||||
fn: Callable[..., Any] = Field(exclude=True)
|
fn: Callable[..., Any] = Field(exclude=True)
|
||||||
name: str = Field(description="Name of the tool")
|
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")
|
description: str = Field(description="Description of what the tool does")
|
||||||
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
|
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
|
||||||
fn_metadata: FuncMetadata = Field(
|
fn_metadata: FuncMetadata = Field(
|
||||||
|
|
@ -36,6 +37,7 @@ class Tool(BaseModel):
|
||||||
cls,
|
cls,
|
||||||
fn: Callable[..., Any],
|
fn: Callable[..., Any],
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
context_kwarg: str | None = None,
|
context_kwarg: str | None = None,
|
||||||
annotations: ToolAnnotations | None = None,
|
annotations: ToolAnnotations | None = None,
|
||||||
|
|
@ -69,6 +71,7 @@ class Tool(BaseModel):
|
||||||
return cls(
|
return cls(
|
||||||
fn=fn,
|
fn=fn,
|
||||||
name=func_name,
|
name=func_name,
|
||||||
|
title=title,
|
||||||
description=func_doc,
|
description=func_doc,
|
||||||
parameters=parameters,
|
parameters=parameters,
|
||||||
fn_metadata=func_arg_metadata,
|
fn_metadata=func_arg_metadata,
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,12 @@ class ToolManager:
|
||||||
self,
|
self,
|
||||||
fn: Callable[..., Any],
|
fn: Callable[..., Any],
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
annotations: ToolAnnotations | None = None,
|
annotations: ToolAnnotations | None = None,
|
||||||
) -> Tool:
|
) -> Tool:
|
||||||
"""Add a tool to the server."""
|
"""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)
|
existing = self._tools.get(tool.name)
|
||||||
if existing:
|
if existing:
|
||||||
if self.warn_on_duplicate_tools:
|
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."""
|
"""A response that indicates success but carries no data."""
|
||||||
|
|
||||||
|
|
||||||
class Implementation(BaseModel):
|
class BaseMetadata(BaseModel):
|
||||||
"""Describes the name and version of an MCP implementation."""
|
"""Base class for entities with name and optional title fields."""
|
||||||
|
|
||||||
name: str
|
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
|
version: str
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
@ -382,13 +398,11 @@ class Annotations(BaseModel):
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class Resource(BaseModel):
|
class Resource(BaseMetadata):
|
||||||
"""A known resource that the server is capable of reading."""
|
"""A known resource that the server is capable of reading."""
|
||||||
|
|
||||||
uri: Annotated[AnyUrl, UrlConstraints(host_required=False)]
|
uri: Annotated[AnyUrl, UrlConstraints(host_required=False)]
|
||||||
"""The URI of this resource."""
|
"""The URI of this resource."""
|
||||||
name: str
|
|
||||||
"""A human-readable name for this resource."""
|
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
"""A description of what this resource represents."""
|
"""A description of what this resource represents."""
|
||||||
mimeType: str | None = None
|
mimeType: str | None = None
|
||||||
|
|
@ -409,7 +423,7 @@ class Resource(BaseModel):
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class ResourceTemplate(BaseModel):
|
class ResourceTemplate(BaseMetadata):
|
||||||
"""A template description for resources available on the server."""
|
"""A template description for resources available on the server."""
|
||||||
|
|
||||||
uriTemplate: str
|
uriTemplate: str
|
||||||
|
|
@ -417,8 +431,6 @@ class ResourceTemplate(BaseModel):
|
||||||
A URI template (according to RFC 6570) that can be used to construct resource
|
A URI template (according to RFC 6570) that can be used to construct resource
|
||||||
URIs.
|
URIs.
|
||||||
"""
|
"""
|
||||||
name: str
|
|
||||||
"""A human-readable name for the type of resource this template refers to."""
|
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
"""A human-readable description of what this template is for."""
|
"""A human-readable description of what this template is for."""
|
||||||
mimeType: str | None = None
|
mimeType: str | None = None
|
||||||
|
|
@ -601,11 +613,9 @@ class PromptArgument(BaseModel):
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class Prompt(BaseModel):
|
class Prompt(BaseMetadata):
|
||||||
"""A prompt or prompt template that the server offers."""
|
"""A prompt or prompt template that the server offers."""
|
||||||
|
|
||||||
name: str
|
|
||||||
"""The name of the prompt or prompt template."""
|
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
"""An optional description of what this prompt provides."""
|
"""An optional description of what this prompt provides."""
|
||||||
arguments: list[PromptArgument] | None = None
|
arguments: list[PromptArgument] | None = None
|
||||||
|
|
@ -808,11 +818,9 @@ class ToolAnnotations(BaseModel):
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class Tool(BaseModel):
|
class Tool(BaseMetadata):
|
||||||
"""Definition for a tool the client can call."""
|
"""Definition for a tool the client can call."""
|
||||||
|
|
||||||
name: str
|
|
||||||
"""The name of the tool."""
|
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
"""A human-readable description of the tool."""
|
"""A human-readable description of the tool."""
|
||||||
inputSchema: dict[str, Any]
|
inputSchema: dict[str, Any]
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ def make_everything_fastmcp() -> FastMCP:
|
||||||
mcp = FastMCP(name="EverythingServer")
|
mcp = FastMCP(name="EverythingServer")
|
||||||
|
|
||||||
# Tool with context for logging and progress
|
# 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:
|
async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str:
|
||||||
await ctx.info(f"Starting processing of '{message}' with {steps} steps")
|
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"
|
return f"Processed '{message}' in {steps} steps"
|
||||||
|
|
||||||
# Simple tool for basic functionality
|
# 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:
|
def echo(message: str) -> str:
|
||||||
return f"Echo: {message}"
|
return f"Echo: {message}"
|
||||||
|
|
||||||
# Tool with sampling capability
|
# 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:
|
async def sampling_tool(prompt: str, ctx: Context) -> str:
|
||||||
await ctx.info(f"Requesting sampling for prompt: {prompt}")
|
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]}..."
|
return f"Sampling result: {str(result.content)[:100]}..."
|
||||||
|
|
||||||
# Tool that sends notifications and logging
|
# 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:
|
async def notification_tool(message: str, ctx: Context) -> str:
|
||||||
# Send different log levels
|
# Send different log levels
|
||||||
await ctx.debug("Debug: Starting notification tool")
|
await ctx.debug("Debug: Starting notification tool")
|
||||||
|
|
@ -188,35 +188,36 @@ def make_everything_fastmcp() -> FastMCP:
|
||||||
static_resource = FunctionResource(
|
static_resource = FunctionResource(
|
||||||
uri=AnyUrl("resource://static/info"),
|
uri=AnyUrl("resource://static/info"),
|
||||||
name="Static Info",
|
name="Static Info",
|
||||||
|
title="Static Information",
|
||||||
description="Static information resource",
|
description="Static information resource",
|
||||||
fn=get_static_info,
|
fn=get_static_info,
|
||||||
)
|
)
|
||||||
mcp.add_resource(static_resource)
|
mcp.add_resource(static_resource)
|
||||||
|
|
||||||
# Resource - dynamic function
|
# Resource - dynamic function
|
||||||
@mcp.resource("resource://dynamic/{category}")
|
@mcp.resource("resource://dynamic/{category}", title="Dynamic Resource")
|
||||||
def dynamic_resource(category: str) -> str:
|
def dynamic_resource(category: str) -> str:
|
||||||
return f"Dynamic resource content for category: {category}"
|
return f"Dynamic resource content for category: {category}"
|
||||||
|
|
||||||
# Resource template
|
# Resource template
|
||||||
@mcp.resource("resource://template/{id}/data")
|
@mcp.resource("resource://template/{id}/data", title="Template Resource")
|
||||||
def template_resource(id: str) -> str:
|
def template_resource(id: str) -> str:
|
||||||
return f"Template resource data for ID: {id}"
|
return f"Template resource data for ID: {id}"
|
||||||
|
|
||||||
# Prompt - simple
|
# Prompt - simple
|
||||||
@mcp.prompt(description="A simple prompt")
|
@mcp.prompt(description="A simple prompt", title="Simple Prompt")
|
||||||
def simple_prompt(topic: str) -> str:
|
def simple_prompt(topic: str) -> str:
|
||||||
return f"Tell me about {topic}"
|
return f"Tell me about {topic}"
|
||||||
|
|
||||||
# Prompt - complex with multiple messages
|
# 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:
|
def complex_prompt(user_query: str, context: str = "general") -> str:
|
||||||
# For simplicity, return a single string that incorporates the context
|
# For simplicity, return a single string that incorporates the context
|
||||||
# Since FastMCP doesn't support system messages in the same way
|
# Since FastMCP doesn't support system messages in the same way
|
||||||
return f"Context: {context}. Query: {user_query}"
|
return f"Context: {context}. Query: {user_query}"
|
||||||
|
|
||||||
# Resource template with completion support
|
# 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:
|
def github_repo_resource(owner: str, repo: str) -> str:
|
||||||
return f"Repository: {owner}/{repo}"
|
return f"Repository: {owner}/{repo}"
|
||||||
|
|
||||||
|
|
@ -250,7 +251,7 @@ def make_everything_fastmcp() -> FastMCP:
|
||||||
return Completion(values=[], total=0, hasMore=False)
|
return Completion(values=[], total=0, hasMore=False)
|
||||||
|
|
||||||
# Tool that echoes request headers from context
|
# 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:
|
def echo_headers(ctx: Context[Any, Any, Request]) -> str:
|
||||||
"""Returns the request headers as JSON."""
|
"""Returns the request headers as JSON."""
|
||||||
headers_info = {}
|
headers_info = {}
|
||||||
|
|
@ -260,7 +261,7 @@ def make_everything_fastmcp() -> FastMCP:
|
||||||
return json.dumps(headers_info)
|
return json.dumps(headers_info)
|
||||||
|
|
||||||
# Tool that returns full request context
|
# 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:
|
def echo_context(custom_request_id: str, ctx: Context[Any, Any, Request]) -> str:
|
||||||
"""Returns request context including headers and custom data."""
|
"""Returns request context including headers and custom data."""
|
||||||
context_data = {
|
context_data = {
|
||||||
|
|
@ -277,7 +278,7 @@ def make_everything_fastmcp() -> FastMCP:
|
||||||
return json.dumps(context_data)
|
return json.dumps(context_data)
|
||||||
|
|
||||||
# Restaurant booking tool with elicitation
|
# 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(
|
async def book_restaurant(
|
||||||
date: str,
|
date: str,
|
||||||
time: 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)
|
assert isinstance(tool_result.content[0], TextContent)
|
||||||
# # The test should only succeed with the successful elicitation response
|
# # The test should only succeed with the successful elicitation response
|
||||||
assert tool_result.content[0].text == "User answered: Test User"
|
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(
|
original_tool = Tool(
|
||||||
name="add",
|
name="add",
|
||||||
|
title="Add Tool",
|
||||||
description="Add two numbers.",
|
description="Add two numbers.",
|
||||||
fn=add,
|
fn=add,
|
||||||
fn_metadata=fn_metadata,
|
fn_metadata=fn_metadata,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue