From 777141ef85ebe5046c2a16a7f66122ba50369880 Mon Sep 17 00:00:00 2001 From: XInke YI Date: Wed, 17 Dec 2025 15:31:01 -0800 Subject: [PATCH 1/3] feat: support setting title and description for server --- src/mcp/server/fastmcp/server.py | 12 +++ src/mcp/server/lowlevel/server.py | 6 ++ src/mcp/server/models.py | 2 + src/mcp/server/session.py | 2 + src/mcp/types.py | 9 +++ tests/server/fastmcp/test_server.py | 34 ++++++++ tests/server/test_session.py | 120 +++++++++++++++++++++------- 7 files changed, 154 insertions(+), 31 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index f74b65557f..bf8965bb21 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -173,6 +173,8 @@ def __init__( # noqa: PLR0913 lifespan: (Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None) = None, auth: AuthSettings | None = None, transport_security: TransportSecuritySettings | None = None, + title: str | None = None, + description: str | None = None, ): # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): @@ -207,6 +209,8 @@ def __init__( # noqa: PLR0913 instructions=instructions, website_url=website_url, icons=icons, + title=title, + description=description, # 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 @@ -249,6 +253,14 @@ def name(self) -> str: def instructions(self) -> str | None: return self._mcp_server.instructions + @property + def title(self) -> str | None: + return self._mcp_server.title + + @property + def description(self) -> str | None: + return self._mcp_server.description + @property def website_url(self) -> str | None: return self._mcp_server.website_url diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 3fc2d497d1..363c920b43 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -146,12 +146,16 @@ def __init__( [Server[LifespanResultT, RequestT]], AbstractAsyncContextManager[LifespanResultT], ] = lifespan, + title: str | None = None, + description: str | None = None, ): self.name = name self.version = version self.instructions = instructions self.website_url = website_url self.icons = icons + self.title = title + self.description = description self.lifespan = lifespan self.request_handlers: dict[type, Callable[..., Awaitable[types.ServerResult]]] = { types.PingRequest: _ping_handler, @@ -186,6 +190,8 @@ def pkg_version(package: str) -> str: experimental_capabilities or {}, ), instructions=self.instructions, + title=self.title, + description=self.description, website_url=self.website_url, icons=self.icons, ) diff --git a/src/mcp/server/models.py b/src/mcp/server/models.py index ddf716cb95..fca64e3c9c 100644 --- a/src/mcp/server/models.py +++ b/src/mcp/server/models.py @@ -16,5 +16,7 @@ class InitializationOptions(BaseModel): server_version: str capabilities: ServerCapabilities instructions: str | None = None + title: str | None = None + description: 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 8f0baa3e9c..bea9c20201 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -177,6 +177,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, + title=self._init_options.title, + description=self._init_options.description, websiteUrl=self._init_options.website_url, icons=self._init_options.icons, ), diff --git a/src/mcp/types.py b/src/mcp/types.py index 654c00660b..6fc4df93ff 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -244,6 +244,15 @@ class BaseMetadata(BaseModel): if present). """ + description: str | None = None + """ + An optional human-readable description of what this implementation does. + + This can be used by clients or servers to provide context about their purpose + and capabilities. For example, a server might describe the types of resources + or tools it provides, while a client might describe its intended use case. + """ + class Icon(BaseModel): """An icon for display in user interfaces.""" diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 3935f3bd13..726ea97265 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -23,6 +23,7 @@ ContentBlock, EmbeddedResource, ImageContent, + InitializeResult, TextContent, TextResourceContents, ) @@ -38,6 +39,39 @@ async def test_create_server(self): assert mcp.name == "FastMCP" assert mcp.instructions == "Server instructions" + @pytest.mark.anyio + async def test_server_with_title_and_description(self): + """Test that FastMCP server title and description are passed through to serverInfo.""" + mcp = FastMCP( + name="test-fastmcp-server", + title="Test FastMCP Server Title", + description="A test server that demonstrates title and description support.", + ) + + assert mcp.title == "Test FastMCP Server Title" + assert mcp.description == "A test server that demonstrates title and description support." + + async with client_session(mcp._mcp_server) as client_session_instance: + result = await client_session_instance.initialize() + + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "test-fastmcp-server" + assert result.serverInfo.title == "Test FastMCP Server Title" + assert result.serverInfo.description == "A test server that demonstrates title and description support." + + @pytest.mark.anyio + async def test_server_without_title_and_description(self): + """Test that FastMCP server works correctly when title and description are not provided.""" + mcp = FastMCP(name="test-fastmcp-server") + + async with client_session(mcp._mcp_server) as client_session_instance: + result = await client_session_instance.initialize() + + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "test-fastmcp-server" + assert result.serverInfo.title is None + assert result.serverInfo.description is None + @pytest.mark.anyio async def test_normalize_path(self): """Test path normalization for mount paths.""" diff --git a/tests/server/test_session.py b/tests/server/test_session.py index 34f9c6e28e..f3f8ea5183 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -35,7 +35,7 @@ async def test_server_session_initialize(): client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) # Create a message handler to catch exceptions - async def message_handler( # pragma: no cover + async def message_handler( message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: if isinstance(message, Exception): @@ -55,15 +55,15 @@ async def run_server(): capabilities=ServerCapabilities(), ), ) as server_session: - async for message in server_session.incoming_messages: # pragma: no branch - if isinstance(message, Exception): # pragma: no cover + async for message in server_session.incoming_messages: + assert message is not None, "Expected to receive messages" + if isinstance(message, Exception): raise message - if isinstance(message, ClientNotification) and isinstance( - message.root, InitializedNotification - ): # pragma: no branch - received_initialized = True - return + assert isinstance(message, ClientNotification), "Expected ClientNotification" + assert isinstance(message.root, InitializedNotification), "Expected InitializedNotification" + received_initialized = True + return try: async with ( @@ -77,12 +77,70 @@ async def run_server(): tg.start_soon(run_server) await client_session.initialize() - except anyio.ClosedResourceError: # pragma: no cover + except anyio.ClosedResourceError: + # This can happen if the server closes before the client finishes pass assert received_initialized +@pytest.mark.anyio +async def test_server_session_initialize_with_title_and_description(): + """Test that server_title and server_description are passed through to serverInfo.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + + async def message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): + raise message + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + title="Test Server Title", + description="A description of what this server does.", + capabilities=ServerCapabilities(), + ), + ) as server_session: + async for message in server_session.incoming_messages: + assert message is not None, "Expected to receive messages" + if isinstance(message, Exception): + raise message + + assert isinstance(message, ClientNotification), "Expected ClientNotification" + assert isinstance(message.root, InitializedNotification), "Expected InitializedNotification" + return + + result: types.InitializeResult | None = None + try: + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session, + anyio.create_task_group() as tg, + ): + tg.start_soon(run_server) + + result = await client_session.initialize() + except anyio.ClosedResourceError: + # This can happen if the server closes before the client finishes + pass + + assert result is not None + assert result.serverInfo.name == "test-server" + assert result.serverInfo.title == "Test Server Title" + assert result.serverInfo.version == "1.0.0" + assert result.serverInfo.description == "A description of what this server does." + + @pytest.mark.anyio async def test_server_capabilities(): server = Server("test") @@ -97,7 +155,7 @@ async def test_server_capabilities(): # Add a prompts handler @server.list_prompts() - async def list_prompts() -> list[Prompt]: # pragma: no cover + async def list_prompts() -> list[Prompt]: return [] caps = server.get_capabilities(notification_options, experimental_capabilities) @@ -107,7 +165,7 @@ async def list_prompts() -> list[Prompt]: # pragma: no cover # Add a resources handler @server.list_resources() - async def list_resources() -> list[Resource]: # pragma: no cover + async def list_resources() -> list[Resource]: return [] caps = server.get_capabilities(notification_options, experimental_capabilities) @@ -117,7 +175,7 @@ async def list_resources() -> list[Resource]: # pragma: no cover # Add a complete handler @server.completion() - async def complete( # pragma: no cover + async def complete( ref: PromptReference | ResourceTemplateReference, argument: CompletionArgument, context: CompletionContext | None, @@ -153,15 +211,15 @@ async def run_server(): capabilities=ServerCapabilities(), ), ) as server_session: - async for message in server_session.incoming_messages: # pragma: no branch - if isinstance(message, Exception): # pragma: no cover + async for message in server_session.incoming_messages: + assert message is not None, "Expected to receive messages" + if isinstance(message, Exception): raise message - if isinstance(message, types.ClientNotification) and isinstance( - message.root, InitializedNotification - ): # pragma: no branch - received_initialized = True - return + assert isinstance(message, types.ClientNotification), "Expected ClientNotification" + assert isinstance(message.root, InitializedNotification), "Expected InitializedNotification" + received_initialized = True + return async def mock_client(): nonlocal received_protocol_version @@ -239,18 +297,18 @@ async def run_server(): capabilities=ServerCapabilities(), ), ) as server_session: - async for message in server_session.incoming_messages: # pragma: no branch - if isinstance(message, Exception): # pragma: no cover + async for message in server_session.incoming_messages: + assert message is not None, "Expected to receive messages" + if isinstance(message, Exception): raise message # We should receive a ping request before initialization - if isinstance(message, RequestResponder) and isinstance( - message.request.root, types.PingRequest - ): # pragma: no branch - # Respond to the ping - with message: - await message.respond(types.ServerResult(types.EmptyResult())) - return + assert isinstance(message, RequestResponder), "Expected RequestResponder" + assert isinstance(message.request.root, types.PingRequest), "Expected PingRequest" + # Respond to the ping + with message: + await message.respond(types.ServerResult(types.EmptyResult())) + return async def mock_client(): nonlocal ping_response_received, ping_response_id @@ -508,9 +566,9 @@ async def mock_client(): # Wait for the error response error_message = await server_to_client_receive.receive() - if isinstance(error_message.message.root, types.JSONRPCError): # pragma: no branch - error_response_received = True - error_code = error_message.message.root.error.code + assert isinstance(error_message.message.root, types.JSONRPCError), "Expected JSONRPCError response" + error_response_received = True + error_code = error_message.message.root.error.code async with ( client_to_server_send, From e05c684d0e260713843521ced6837e5479cb380d Mon Sep 17 00:00:00 2001 From: Andy Yi Date: Thu, 18 Dec 2025 16:56:50 -0800 Subject: [PATCH 2/3] Remove pragma in the new test --- tests/server/test_session.py | 105 +++++++++++++++-------------------- 1 file changed, 44 insertions(+), 61 deletions(-) diff --git a/tests/server/test_session.py b/tests/server/test_session.py index f3f8ea5183..0330fb6362 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -35,7 +35,7 @@ async def test_server_session_initialize(): client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) # Create a message handler to catch exceptions - async def message_handler( + async def message_handler( # pragma: no cover message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: if isinstance(message, Exception): @@ -55,15 +55,15 @@ async def run_server(): capabilities=ServerCapabilities(), ), ) as server_session: - async for message in server_session.incoming_messages: - assert message is not None, "Expected to receive messages" - if isinstance(message, Exception): + async for message in server_session.incoming_messages: # pragma: no branch + if isinstance(message, Exception): # pragma: no cover raise message - assert isinstance(message, ClientNotification), "Expected ClientNotification" - assert isinstance(message.root, InitializedNotification), "Expected InitializedNotification" - received_initialized = True - return + if isinstance(message, ClientNotification) and isinstance( + message.root, InitializedNotification + ): # pragma: no branch + received_initialized = True + return try: async with ( @@ -77,8 +77,7 @@ async def run_server(): tg.start_soon(run_server) await client_session.initialize() - except anyio.ClosedResourceError: - # This can happen if the server closes before the client finishes + except anyio.ClosedResourceError: # pragma: no cover pass assert received_initialized @@ -90,12 +89,6 @@ async def test_server_session_initialize_with_title_and_description(): server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) - async def message_handler( - message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, - ) -> None: - if isinstance(message, Exception): - raise message - async def run_server(): async with ServerSession( client_to_server_receive, @@ -107,32 +100,22 @@ async def run_server(): description="A description of what this server does.", capabilities=ServerCapabilities(), ), - ) as server_session: - async for message in server_session.incoming_messages: - assert message is not None, "Expected to receive messages" - if isinstance(message, Exception): - raise message - - assert isinstance(message, ClientNotification), "Expected ClientNotification" - assert isinstance(message.root, InitializedNotification), "Expected InitializedNotification" - return + ) as _: + # Just run the server without handling incoming messages + # The server will process messages internally + await anyio.sleep(0.1) # Give time for initialization to complete result: types.InitializeResult | None = None - try: - async with ( - ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session, - anyio.create_task_group() as tg, - ): - tg.start_soon(run_server) + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + ) as client_session, + anyio.create_task_group() as tg, + ): + tg.start_soon(run_server) - result = await client_session.initialize() - except anyio.ClosedResourceError: - # This can happen if the server closes before the client finishes - pass + result = await client_session.initialize() assert result is not None assert result.serverInfo.name == "test-server" @@ -155,7 +138,7 @@ async def test_server_capabilities(): # Add a prompts handler @server.list_prompts() - async def list_prompts() -> list[Prompt]: + async def list_prompts() -> list[Prompt]: # pragma: no cover return [] caps = server.get_capabilities(notification_options, experimental_capabilities) @@ -165,7 +148,7 @@ async def list_prompts() -> list[Prompt]: # Add a resources handler @server.list_resources() - async def list_resources() -> list[Resource]: + async def list_resources() -> list[Resource]: # pragma: no cover return [] caps = server.get_capabilities(notification_options, experimental_capabilities) @@ -175,7 +158,7 @@ async def list_resources() -> list[Resource]: # Add a complete handler @server.completion() - async def complete( + async def complete( # pragma: no cover ref: PromptReference | ResourceTemplateReference, argument: CompletionArgument, context: CompletionContext | None, @@ -211,15 +194,15 @@ async def run_server(): capabilities=ServerCapabilities(), ), ) as server_session: - async for message in server_session.incoming_messages: - assert message is not None, "Expected to receive messages" - if isinstance(message, Exception): + async for message in server_session.incoming_messages: # pragma: no branch + if isinstance(message, Exception): # pragma: no cover raise message - assert isinstance(message, types.ClientNotification), "Expected ClientNotification" - assert isinstance(message.root, InitializedNotification), "Expected InitializedNotification" - received_initialized = True - return + if isinstance(message, types.ClientNotification) and isinstance( + message.root, InitializedNotification + ): # pragma: no branch + received_initialized = True + return async def mock_client(): nonlocal received_protocol_version @@ -297,18 +280,18 @@ async def run_server(): capabilities=ServerCapabilities(), ), ) as server_session: - async for message in server_session.incoming_messages: - assert message is not None, "Expected to receive messages" - if isinstance(message, Exception): + async for message in server_session.incoming_messages: # pragma: no branch + if isinstance(message, Exception): # pragma: no cover raise message # We should receive a ping request before initialization - assert isinstance(message, RequestResponder), "Expected RequestResponder" - assert isinstance(message.request.root, types.PingRequest), "Expected PingRequest" - # Respond to the ping - with message: - await message.respond(types.ServerResult(types.EmptyResult())) - return + if isinstance(message, RequestResponder) and isinstance( + message.request.root, types.PingRequest + ): # pragma: no branch + # Respond to the ping + with message: + await message.respond(types.ServerResult(types.EmptyResult())) + return async def mock_client(): nonlocal ping_response_received, ping_response_id @@ -566,9 +549,9 @@ async def mock_client(): # Wait for the error response error_message = await server_to_client_receive.receive() - assert isinstance(error_message.message.root, types.JSONRPCError), "Expected JSONRPCError response" - error_response_received = True - error_code = error_message.message.root.error.code + if isinstance(error_message.message.root, types.JSONRPCError): # pragma: no branch + error_response_received = True + error_code = error_message.message.root.error.code async with ( client_to_server_send, From 5c4341859d0a8930d636f7edb59f8a1034606ff9 Mon Sep 17 00:00:00 2001 From: Andy Yi Date: Fri, 19 Dec 2025 14:51:47 -0800 Subject: [PATCH 3/3] Move description to Implementation class --- src/mcp/types.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/mcp/types.py b/src/mcp/types.py index 6fc4df93ff..186c583785 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -244,15 +244,6 @@ class BaseMetadata(BaseModel): if present). """ - description: str | None = None - """ - An optional human-readable description of what this implementation does. - - This can be used by clients or servers to provide context about their purpose - and capabilities. For example, a server might describe the types of resources - or tools it provides, while a client might describe its intended use case. - """ - class Icon(BaseModel): """An icon for display in user interfaces.""" @@ -282,6 +273,15 @@ class Implementation(BaseMetadata): model_config = ConfigDict(extra="allow") + description: str | None = None + """ + An optional human-readable description of what this implementation does. + + This can be used by clients or servers to provide context about their purpose + and capabilities. For example, a server might describe the types of resources + or tools it provides, while a client might describe its intended use case. + """ + class RootsCapability(BaseModel): """Capability for root operations."""