diff --git a/README.md b/README.md index e2ef5a7ca..3d563f72c 100644 --- a/README.md +++ b/README.md @@ -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)_ +### 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: @@ -896,6 +931,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 diff --git a/examples/fastmcp/icons_demo.py b/examples/fastmcp/icons_demo.py new file mode 100644 index 000000000..7467e448a --- /dev/null +++ b/examples/fastmcp/icons_demo.py @@ -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() diff --git a/examples/fastmcp/mcp.png b/examples/fastmcp/mcp.png new file mode 100644 index 000000000..8e08571d3 Binary files /dev/null and b/examples/fastmcp/mcp.png differ diff --git a/src/mcp/server/fastmcp/__init__.py b/src/mcp/server/fastmcp/__init__.py index f8f9c1c4c..a89902cfd 100644 --- a/src/mcp/server/fastmcp/__init__.py +++ b/src/mcp/server/fastmcp/__init__.py @@ -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"] diff --git a/src/mcp/server/fastmcp/prompts/base.py b/src/mcp/server/fastmcp/prompts/base.py index 79519a3bf..4bf4389c1 100644 --- a/src/mcp/server/fastmcp/prompts/base.py +++ b/src/mcp/server/fastmcp/prompts/base.py @@ -11,7 +11,7 @@ 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 @@ def from_function( 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 @@ def from_function( description=description or fn.__doc__ or "", arguments=arguments, fn=fn, + icons=icons, context_kwarg=context_kwarg, ) diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py index f57631cc1..0bef1a266 100644 --- a/src/mcp/server/fastmcp/resources/base.py +++ b/src/mcp/server/fastmcp/resources/base.py @@ -13,6 +13,8 @@ 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 diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index 91b2ad391..922364cd8 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -103,6 +103,7 @@ async def create_resource( 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: diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py index f2a330706..c578e23de 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/fastmcp/resources/types.py @@ -14,6 +14,7 @@ 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 @@ def from_function( 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 @@ def from_function( description=description or fn.__doc__ or "", mime_type=mime_type or "text/plain", fn=fn, + icons=icons, ) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index d86fa85e3..03aa6c888 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -43,7 +43,7 @@ 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 @@ async def wrap(_: MCPServer[LifespanResultT, Request]) -> AsyncIterator[Lifespan 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 @@ def __init__( 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 @@ def name(self) -> str: 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 @@ async def list_tools(self) -> list[MCPTool]: inputSchema=info.parameters, outputSchema=info.output_schema, annotations=info.annotations, + icons=info.icons, ) for info in tools ] @@ -308,6 +321,7 @@ async def list_resources(self) -> list[MCPResource]: title=resource.title, description=resource.description, mimeType=resource.mime_type, + icons=resource.icons, ) for resource in resources ] @@ -347,6 +361,7 @@ def add_tool( 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 @@ def add_tool( title=title, description=description, annotations=annotations, + icons=icons, structured_output=structured_output, ) @@ -380,6 +396,7 @@ def tool( 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 @@ def decorator(fn: AnyFunction) -> AnyFunction: title=title, description=description, annotations=annotations, + icons=icons, structured_output=structured_output, ) return fn @@ -466,6 +484,7 @@ def resource( 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 @@ def decorator(fn: AnyFunction) -> AnyFunction: title=title, description=description, mime_type=mime_type, + # Note: Resource templates don't support icons ) else: # Register as regular resource @@ -550,6 +570,7 @@ def decorator(fn: AnyFunction) -> AnyFunction: title=title, description=description, mime_type=mime_type, + icons=icons, ) self.add_resource(resource) return fn @@ -565,7 +586,11 @@ def add_prompt(self, prompt: Prompt) -> None: 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 @@ async def analyze_file(path: str) -> list[Message]: ) 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 @@ async def list_prompts(self) -> list[MCPPrompt]: ) for arg in (prompt.arguments or []) ], + icons=prompt.icons, ) for prompt in prompts ] diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index bb5003de3..3f26ddcea 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -11,7 +11,7 @@ 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 @@ def from_function( 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 @@ def from_function( is_async=is_async, context_kwarg=context_kwarg, annotations=annotations, + icons=icons, ) async def run( diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index bfa8b2382..443196d0d 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -7,7 +7,7 @@ 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 @@ def add_tool( 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 @@ def add_tool( title=title, description=description, annotations=annotations, + icons=icons, structured_output=structured_output, ) existing = self._tools.get(tool.name) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 3448424bc..2fec3381b 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -136,6 +136,8 @@ def __init__( 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 @@ def __init__( 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 @@ def pkg_version(package: str) -> str: experimental_capabilities or {}, ), instructions=self.instructions, + website_url=self.website_url, + icons=self.icons, ) def get_capabilities( diff --git a/src/mcp/server/models.py b/src/mcp/server/models.py index 3b5abba78..ddf716cb9 100644 --- a/src/mcp/server/models.py +++ b/src/mcp/server/models.py @@ -6,6 +6,7 @@ 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 diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 7b3680f7c..d00277f11 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -156,6 +156,8 @@ async def _received_request(self, responder: RequestResponder[types.ClientReques 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, ) diff --git a/src/mcp/types.py b/src/mcp/types.py index 62feda87a..fa99f748b 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -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) diff --git a/tests/issues/test_1338_icons_and_metadata.py b/tests/issues/test_1338_icons_and_metadata.py new file mode 100644 index 000000000..ad39d7f01 --- /dev/null +++ b/tests/issues/test_1338_icons_and_metadata.py @@ -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