From 71889d7387f070cd872cab7c9aa3d1ff1fa5a5d2 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Wed, 24 Sep 2025 20:29:04 +0100 Subject: [PATCH] Implementation of SEP 973 - Additional metadata + icons support (#1357) --- README.md | 37 +++++ examples/fastmcp/icons_demo.py | 55 ++++++++ examples/fastmcp/mcp.png | Bin 0 -> 2586 bytes src/mcp/server/fastmcp/__init__.py | 4 +- src/mcp/server/fastmcp/prompts/base.py | 5 +- src/mcp/server/fastmcp/resources/base.py | 3 + src/mcp/server/fastmcp/resources/templates.py | 1 + src/mcp/server/fastmcp/resources/types.py | 3 + src/mcp/server/fastmcp/server.py | 34 ++++- src/mcp/server/fastmcp/tools/base.py | 5 +- src/mcp/server/fastmcp/tools/tool_manager.py | 4 +- src/mcp/server/lowlevel/server.py | 6 + src/mcp/server/models.py | 3 + src/mcp/server/session.py | 2 + src/mcp/types.py | 28 ++++ tests/issues/test_1338_icons_and_metadata.py | 126 ++++++++++++++++++ 16 files changed, 308 insertions(+), 8 deletions(-) create mode 100644 examples/fastmcp/icons_demo.py create mode 100644 examples/fastmcp/mcp.png create mode 100644 tests/issues/test_1338_icons_and_metadata.py diff --git a/README.md b/README.md index 45ff7ec0..c4bfaa95 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: @@ -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 diff --git a/examples/fastmcp/icons_demo.py b/examples/fastmcp/icons_demo.py new file mode 100644 index 00000000..7467e448 --- /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 0000000000000000000000000000000000000000..8e08571d326bba9110ea3ced50e6769d0e100b21 GIT binary patch literal 2586 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 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, ) diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py index f57631cc..0bef1a26 100644 --- a/src/mcp/server/fastmcp/resources/base.py +++ b/src/mcp/server/fastmcp/resources/base.py @@ -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 diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index 91b2ad39..922364cd 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -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: diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py index f2a33070..c578e23d 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/fastmcp/resources/types.py @@ -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, ) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index cacf41dc..83963293 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -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 ] diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index bb5003de..3f26ddce 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -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( diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index bfa8b238..443196d0 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.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) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 3448424b..2fec3381 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -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( diff --git a/src/mcp/server/models.py b/src/mcp/server/models.py index 3b5abba7..ddf716cb 100644 --- a/src/mcp/server/models.py +++ b/src/mcp/server/models.py @@ -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 diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 7b3680f7..d00277f1 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -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, ) diff --git a/src/mcp/types.py b/src/mcp/types.py index 62feda87..fa99f748 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 00000000..ad39d7f0 --- /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