Implementation of SEP 973 - Additional metadata + icons support (#1357)
Some checks failed
Main branch checks / checks (push) Has been cancelled

This commit is contained in:
Peter Alexander 2025-09-24 20:29:04 +01:00 • committed by GitHub
commit 71889d7387
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 308 additions and 8 deletions

View file

@ -516,6 +516,41 @@ def debug_error(error: str) -> list[base.Message]:
_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_
<!-- /snippet-source -->
### Icons
MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts:
```python
from mcp.server.fastmcp import FastMCP, Icon
# Create an icon from a file path or URL
icon = Icon(
src="icon.png",
mimeType="image/png",
sizes="64x64"
)
# Add icons to server
mcp = FastMCP(
"My Server",
website_url="https://example.com",
icons=[icon]
)
# Add icons to tools, resources, and prompts
@mcp.tool(icons=[icon])
def my_tool():
"""Tool with an icon."""
return "result"
@mcp.resource("demo://resource", icons=[icon])
def my_resource():
"""Resource with an icon."""
return "content"
```
_Full example: [examples/fastmcp/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/fastmcp/icons_demo.py)_
### Images
FastMCP provides an `Image` class that automatically handles image data:
@ -898,6 +933,8 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv
- `ctx.fastmcp.name` - The server's name as defined during initialization
- `ctx.fastmcp.instructions` - Server instructions/description provided to clients
- `ctx.fastmcp.website_url` - Optional website URL for the server
- `ctx.fastmcp.icons` - Optional list of icons for UI display
- `ctx.fastmcp.settings` - Complete server configuration object containing:
- `debug` - Debug mode flag
- `log_level` - Current logging level

View file

@ -0,0 +1,55 @@
"""
FastMCP Icons Demo Server
Demonstrates using icons with tools, resources, prompts, and implementation.
"""
import base64
from pathlib import Path
from mcp.server.fastmcp import FastMCP, Icon
# Load the icon file and convert to data URI
icon_path = Path(__file__).parent / "mcp.png"
icon_data = base64.standard_b64encode(icon_path.read_bytes()).decode()
icon_data_uri = f"data:image/png;base64,{icon_data}"
icon_data = Icon(src=icon_data_uri, mimeType="image/png", sizes="64x64")
# Create server with icons in implementation
mcp = FastMCP("Icons Demo Server", website_url="https://github.com/modelcontextprotocol/python-sdk", icons=[icon_data])
@mcp.tool(icons=[icon_data])
def demo_tool(message: str) -> str:
"""A demo tool with an icon."""
return message
@mcp.resource("demo://readme", icons=[icon_data])
def readme_resource() -> str:
"""A demo resource with an icon"""
return "This resource has an icon"
@mcp.prompt("prompt_with_icon", icons=[icon_data])
def prompt_with_icon(text: str) -> str:
"""A demo prompt with an icon"""
return text
@mcp.tool(
icons=[
Icon(src=icon_data_uri, mimeType="image/png", sizes="16x16"),
Icon(src=icon_data_uri, mimeType="image/png", sizes="32x32"),
Icon(src=icon_data_uri, mimeType="image/png", sizes="64x64"),
]
)
def multi_icon_tool(action: str) -> str:
"""A tool demonstrating multiple icons."""
return "multi_icon_tool"
if __name__ == "__main__":
# Run the server
mcp.run()

BIN
examples/fastmcp/mcp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -2,8 +2,10 @@
from importlib.metadata import version
from mcp.types import Icon
from .server import Context, FastMCP
from .utilities.types import Audio, Image
__version__ = version("mcp")
__all__ = ["FastMCP", "Context", "Image", "Audio"]
__all__ = ["FastMCP", "Context", "Image", "Audio", "Icon"]

View file

@ -11,7 +11,7 @@ from pydantic import BaseModel, Field, TypeAdapter, validate_call
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
from mcp.types import ContentBlock, TextContent
from mcp.types import ContentBlock, Icon, TextContent
if TYPE_CHECKING:
from mcp.server.fastmcp.server import Context
@ -71,6 +71,7 @@ class Prompt(BaseModel):
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)
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this prompt")
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context", exclude=True)
@classmethod
@ -80,6 +81,7 @@ class Prompt(BaseModel):
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
context_kwarg: str | None = None,
) -> Prompt:
"""Create a Prompt from a function.
@ -128,6 +130,7 @@ class Prompt(BaseModel):
description=description or fn.__doc__ or "",
arguments=arguments,
fn=fn,
icons=icons,
context_kwarg=context_kwarg,
)

View file

@ -13,6 +13,8 @@ from pydantic import (
field_validator,
)
from mcp.types import Icon
class Resource(BaseModel, abc.ABC):
"""Base class for all resources."""
@ -28,6 +30,7 @@ class Resource(BaseModel, abc.ABC):
description="MIME type of the resource content",
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
)
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource")
@field_validator("name", mode="before")
@classmethod

View file

@ -103,6 +103,7 @@ class ResourceTemplate(BaseModel):
title=self.title,
description=self.description,
mime_type=self.mime_type,
icons=None, # Resource templates don't support icons
fn=lambda: result, # Capture result in closure
)
except Exception as e:

View file

@ -14,6 +14,7 @@ import pydantic_core
from pydantic import AnyUrl, Field, ValidationInfo, validate_call
from mcp.server.fastmcp.resources.base import Resource
from mcp.types import Icon
class TextResource(Resource):
@ -80,6 +81,7 @@ class FunctionResource(Resource):
title: str | None = None,
description: str | None = None,
mime_type: str | None = None,
icons: list[Icon] | None = None,
) -> "FunctionResource":
"""Create a FunctionResource from a function."""
func_name = name or fn.__name__
@ -96,6 +98,7 @@ class FunctionResource(Resource):
description=description or fn.__doc__ or "",
mime_type=mime_type or "text/plain",
fn=fn,
icons=icons,
)

View file

@ -43,7 +43,7 @@ from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
from mcp.types import AnyFunction, ContentBlock, GetPromptResult, ToolAnnotations
from mcp.types import AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations
from mcp.types import Prompt as MCPPrompt
from mcp.types import PromptArgument as MCPPromptArgument
from mcp.types import Resource as MCPResource
@ -120,10 +120,12 @@ def lifespan_wrapper(
class FastMCP(Generic[LifespanResultT]):
def __init__(
def __init__( # noqa: PLR0913
self,
name: str | None = None,
instructions: str | None = None,
website_url: str | None = None,
icons: list[Icon] | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
token_verifier: TokenVerifier | None = None,
event_store: EventStore | None = None,
@ -170,6 +172,8 @@ class FastMCP(Generic[LifespanResultT]):
self._mcp_server = MCPServer(
name=name or "FastMCP",
instructions=instructions,
website_url=website_url,
icons=icons,
# TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an FastMCP and Server.
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
@ -211,6 +215,14 @@ class FastMCP(Generic[LifespanResultT]):
def instructions(self) -> str | None:
return self._mcp_server.instructions
@property
def website_url(self) -> str | None:
return self._mcp_server.website_url
@property
def icons(self) -> list[Icon] | None:
return self._mcp_server.icons
@property
def session_manager(self) -> StreamableHTTPSessionManager:
"""Get the StreamableHTTP session manager.
@ -277,6 +289,7 @@ class FastMCP(Generic[LifespanResultT]):
inputSchema=info.parameters,
outputSchema=info.output_schema,
annotations=info.annotations,
icons=info.icons,
)
for info in tools
]
@ -308,6 +321,7 @@ class FastMCP(Generic[LifespanResultT]):
title=resource.title,
description=resource.description,
mimeType=resource.mime_type,
icons=resource.icons,
)
for resource in resources
]
@ -347,6 +361,7 @@ class FastMCP(Generic[LifespanResultT]):
title: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
icons: list[Icon] | None = None,
structured_output: bool | None = None,
) -> None:
"""Add a tool to the server.
@ -371,6 +386,7 @@ class FastMCP(Generic[LifespanResultT]):
title=title,
description=description,
annotations=annotations,
icons=icons,
structured_output=structured_output,
)
@ -380,6 +396,7 @@ class FastMCP(Generic[LifespanResultT]):
title: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
icons: list[Icon] | None = None,
structured_output: bool | None = None,
) -> Callable[[AnyFunction], AnyFunction]:
"""Decorator to register a tool.
@ -426,6 +443,7 @@ class FastMCP(Generic[LifespanResultT]):
title=title,
description=description,
annotations=annotations,
icons=icons,
structured_output=structured_output,
)
return fn
@ -466,6 +484,7 @@ class FastMCP(Generic[LifespanResultT]):
title: str | None = None,
description: str | None = None,
mime_type: str | None = None,
icons: list[Icon] | None = None,
) -> Callable[[AnyFunction], AnyFunction]:
"""Decorator to register a function as a resource.
@ -540,6 +559,7 @@ class FastMCP(Generic[LifespanResultT]):
title=title,
description=description,
mime_type=mime_type,
# Note: Resource templates don't support icons
)
else:
# Register as regular resource
@ -550,6 +570,7 @@ class FastMCP(Generic[LifespanResultT]):
title=title,
description=description,
mime_type=mime_type,
icons=icons,
)
self.add_resource(resource)
return fn
@ -565,7 +586,11 @@ class FastMCP(Generic[LifespanResultT]):
self._prompt_manager.add_prompt(prompt)
def prompt(
self, name: str | None = None, title: str | None = None, description: str | None = None
self,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
) -> Callable[[AnyFunction], AnyFunction]:
"""Decorator to register a prompt.
@ -609,7 +634,7 @@ class FastMCP(Generic[LifespanResultT]):
)
def decorator(func: AnyFunction) -> AnyFunction:
prompt = Prompt.from_function(func, name=name, title=title, description=description)
prompt = Prompt.from_function(func, name=name, title=title, description=description, icons=icons)
self.add_prompt(prompt)
return func
@ -980,6 +1005,7 @@ class FastMCP(Generic[LifespanResultT]):
)
for arg in (prompt.arguments or [])
],
icons=prompt.icons,
)
for prompt in prompts
]

View file

@ -11,7 +11,7 @@ from pydantic import BaseModel, Field
from mcp.server.fastmcp.exceptions import ToolError
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
from mcp.types import ToolAnnotations
from mcp.types import Icon, ToolAnnotations
if TYPE_CHECKING:
from mcp.server.fastmcp.server import Context
@ -33,6 +33,7 @@ class Tool(BaseModel):
is_async: bool = Field(description="Whether the tool is async")
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool")
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool")
@cached_property
def output_schema(self) -> dict[str, Any] | None:
@ -47,6 +48,7 @@ class Tool(BaseModel):
description: str | None = None,
context_kwarg: str | None = None,
annotations: ToolAnnotations | None = None,
icons: list[Icon] | None = None,
structured_output: bool | None = None,
) -> Tool:
"""Create a Tool from a function."""
@ -78,6 +80,7 @@ class Tool(BaseModel):
is_async=is_async,
context_kwarg=context_kwarg,
annotations=annotations,
icons=icons,
)
async def run(

View file

@ -7,7 +7,7 @@ from mcp.server.fastmcp.exceptions import ToolError
from mcp.server.fastmcp.tools.base import Tool
from mcp.server.fastmcp.utilities.logging import get_logger
from mcp.shared.context import LifespanContextT, RequestT
from mcp.types import ToolAnnotations
from mcp.types import Icon, ToolAnnotations
if TYPE_CHECKING:
from mcp.server.fastmcp.server import Context
@ -49,6 +49,7 @@ class ToolManager:
title: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
icons: list[Icon] | None = None,
structured_output: bool | None = None,
) -> Tool:
"""Add a tool to the server."""
@ -58,6 +59,7 @@ class ToolManager:
title=title,
description=description,
annotations=annotations,
icons=icons,
structured_output=structured_output,
)
existing = self._tools.get(tool.name)

View file

@ -136,6 +136,8 @@ class Server(Generic[LifespanResultT, RequestT]):
name: str,
version: str | None = None,
instructions: str | None = None,
website_url: str | None = None,
icons: list[types.Icon] | None = None,
lifespan: Callable[
[Server[LifespanResultT, RequestT]],
AbstractAsyncContextManager[LifespanResultT],
@ -144,6 +146,8 @@ class Server(Generic[LifespanResultT, RequestT]):
self.name = name
self.version = version
self.instructions = instructions
self.website_url = website_url
self.icons = icons
self.lifespan = lifespan
self.request_handlers: dict[type, Callable[..., Awaitable[types.ServerResult]]] = {
types.PingRequest: _ping_handler,
@ -177,6 +181,8 @@ class Server(Generic[LifespanResultT, RequestT]):
experimental_capabilities or {},
),
instructions=self.instructions,
website_url=self.website_url,
icons=self.icons,
)
def get_capabilities(

View file

@ -6,6 +6,7 @@ and tools.
from pydantic import BaseModel
from mcp.types import (
Icon,
ServerCapabilities,
)
@ -15,3 +16,5 @@ class InitializationOptions(BaseModel):
server_version: str
capabilities: ServerCapabilities
instructions: str | None = None
website_url: str | None = None
icons: list[Icon] | None = None

View file

@ -156,6 +156,8 @@ class ServerSession(
serverInfo=types.Implementation(
name=self._init_options.server_name,
version=self._init_options.server_version,
websiteUrl=self._init_options.website_url,
icons=self._init_options.icons,
),
instructions=self._init_options.instructions,
)

View file

@ -213,10 +213,32 @@ class BaseMetadata(BaseModel):
"""
class Icon(BaseModel):
"""An icon for display in user interfaces."""
src: str
"""URL or data URI for the icon."""
mimeType: str | None = None
"""Optional MIME type for the icon."""
sizes: str | None = None
"""Optional string specifying icon dimensions (e.g., "48x48 96x96")."""
model_config = ConfigDict(extra="allow")
class Implementation(BaseMetadata):
"""Describes the name and version of an MCP implementation."""
version: str
websiteUrl: str | None = None
"""An optional URL of the website for this implementation."""
icons: list[Icon] | None = None
"""An optional list of icons for this implementation."""
model_config = ConfigDict(extra="allow")
@ -422,6 +444,8 @@ class Resource(BaseMetadata):
This can be used by Hosts to display file sizes and estimate context window usage.
"""
icons: list[Icon] | None = None
"""An optional list of icons for this resource."""
annotations: Annotations | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
"""
@ -628,6 +652,8 @@ class Prompt(BaseMetadata):
"""An optional description of what this prompt provides."""
arguments: list[PromptArgument] | None = None
"""A list of arguments to use for templating the prompt."""
icons: list[Icon] | None = None
"""An optional list of icons for this prompt."""
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
"""
See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
@ -852,6 +878,8 @@ class Tool(BaseMetadata):
An optional JSON Schema object defining the structure of the tool's output
returned in the structuredContent field of a CallToolResult.
"""
icons: list[Icon] | None = None
"""An optional list of icons for this tool."""
annotations: ToolAnnotations | None = None
"""Optional additional tool information."""
meta: dict[str, Any] | None = Field(alias="_meta", default=None)

View file

@ -0,0 +1,126 @@
"""Test icon and metadata support (SEP-973)."""
import pytest
from mcp.server.fastmcp import FastMCP
from mcp.types import Icon
pytestmark = pytest.mark.anyio
async def test_icons_and_website_url():
"""Test that icons and websiteUrl are properly returned in API calls."""
# Create test icon
test_icon = Icon(
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
mimeType="image/png",
sizes="1x1",
)
# Create server with website URL and icon
mcp = FastMCP("TestServer", website_url="https://example.com", icons=[test_icon])
# Create tool with icon
@mcp.tool(icons=[test_icon])
def test_tool(message: str) -> str:
"""A test tool with an icon."""
return message
# Create resource with icon
@mcp.resource("test://resource", icons=[test_icon])
def test_resource() -> str:
"""A test resource with an icon."""
return "test content"
# Create prompt with icon
@mcp.prompt("test_prompt", icons=[test_icon])
def test_prompt(text: str) -> str:
"""A test prompt with an icon."""
return text
# Test server metadata includes websiteUrl and icons
assert mcp.name == "TestServer"
assert mcp.website_url == "https://example.com"
assert mcp.icons is not None
assert len(mcp.icons) == 1
assert mcp.icons[0].src == test_icon.src
assert mcp.icons[0].mimeType == test_icon.mimeType
assert mcp.icons[0].sizes == test_icon.sizes
# Test tool includes icon
tools = await mcp.list_tools()
assert len(tools) == 1
tool = tools[0]
assert tool.name == "test_tool"
assert tool.icons is not None
assert len(tool.icons) == 1
assert tool.icons[0].src == test_icon.src
# Test resource includes icon
resources = await mcp.list_resources()
assert len(resources) == 1
resource = resources[0]
assert str(resource.uri) == "test://resource"
assert resource.icons is not None
assert len(resource.icons) == 1
assert resource.icons[0].src == test_icon.src
# Test prompt includes icon
prompts = await mcp.list_prompts()
assert len(prompts) == 1
prompt = prompts[0]
assert prompt.name == "test_prompt"
assert prompt.icons is not None
assert len(prompt.icons) == 1
assert prompt.icons[0].src == test_icon.src
async def test_multiple_icons():
"""Test that multiple icons can be added to tools, resources, and prompts."""
# Create multiple test icons
icon1 = Icon(src="data:image/png;base64,icon1", mimeType="image/png", sizes="16x16")
icon2 = Icon(src="data:image/png;base64,icon2", mimeType="image/png", sizes="32x32")
icon3 = Icon(src="data:image/png;base64,icon3", mimeType="image/png", sizes="64x64")
mcp = FastMCP("MultiIconServer")
# Create tool with multiple icons
@mcp.tool(icons=[icon1, icon2, icon3])
def multi_icon_tool() -> str:
"""A tool with multiple icons."""
return "success"
# Test tool has all icons
tools = await mcp.list_tools()
assert len(tools) == 1
tool = tools[0]
assert tool.icons is not None
assert len(tool.icons) == 3
assert tool.icons[0].sizes == "16x16"
assert tool.icons[1].sizes == "32x32"
assert tool.icons[2].sizes == "64x64"
async def test_no_icons_or_website():
"""Test that server works without icons or websiteUrl."""
mcp = FastMCP("BasicServer")
@mcp.tool()
def basic_tool() -> str:
"""A basic tool without icons."""
return "success"
# Test server metadata has no websiteUrl or icons
assert mcp.name == "BasicServer"
assert mcp.website_url is None
assert mcp.icons is None
# Test tool has no icons
tools = await mcp.list_tools()
assert len(tools) == 1
tool = tools[0]
assert tool.name == "basic_tool"
assert tool.icons is None