From 5bd1ab4b74df45206854f188e5ae1bdf73fe7272 Mon Sep 17 00:00:00 2001 From: Zelys Date: Thu, 23 Apr 2026 16:55:19 -0500 Subject: [PATCH] fix(mcpserver): advertise capabilities only for registered primitives MCPServer unconditionally passed non-None list handlers to the lowlevel Server, which caused it to advertise tools/resources/prompts capabilities even when none of those primitives had been registered. Per the MCP schema spec, a capability entry should only appear when the server actually offers that primitive. Adds a `capability_filter` hook to the lowlevel Server that, if set, post-processes the computed ServerCapabilities before they are returned. MCPServer uses this to suppress tools/resources/prompts entries when the corresponding manager is empty at capability-computation time (i.e. when create_initialization_options() is called, after all decorators have been applied). Fixes #2473. Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/server/lowlevel/server.py | 4 ++ src/mcp/server/mcpserver/server.py | 12 ++++ tests/server/mcpserver/test_server.py | 99 +++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 59de0ace4..96e1a349e 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -173,6 +173,7 @@ def __init__( [ServerRequestContext[LifespanResultT], types.RequestParams | None], Awaitable[types.EmptyResult], ] = _ping_handler, + capability_filter: Callable[[types.ServerCapabilities], types.ServerCapabilities] | None = None, # Notification handlers on_roots_list_changed: Callable[ [ServerRequestContext[LifespanResultT], types.NotificationParams | None], @@ -198,6 +199,7 @@ def __init__( str, Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[None]] ] = {} self._experimental_handlers: ExperimentalHandlers[LifespanResultT] | None = None + self._capability_filter = capability_filter self._session_manager: StreamableHTTPSessionManager | None = None logger.debug("Initializing server %r", name) @@ -325,6 +327,8 @@ def get_capabilities( ) if self._experimental_handlers: self._experimental_handlers.update_capabilities(capabilities) + if self._capability_filter is not None: + capabilities = self._capability_filter(capabilities) return capabilities @property diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index be77705da..863492ccf 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -62,6 +62,7 @@ PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, + ServerCapabilities, TextContent, TextResourceContents, ToolAnnotations, @@ -167,6 +168,16 @@ def __init__( resources=resources, warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources ) self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts) + + def _filter_capabilities(caps: ServerCapabilities) -> ServerCapabilities: + if not self._tool_manager.list_tools(): + caps.tools = None + if not (self._resource_manager.list_resources() or self._resource_manager.list_templates()): + caps.resources = None + if not self._prompt_manager.list_prompts(): + caps.prompts = None + return caps + self._lowlevel_server = Server( name=name or "mcp-server", title=title, @@ -182,6 +193,7 @@ def __init__( on_list_resource_templates=self._handle_list_resource_templates, on_list_prompts=self._handle_list_prompts, on_get_prompt=self._handle_get_prompt, + capability_filter=_filter_capabilities, # TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an MCPServer 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 diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 3457ec944..3c5276998 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1516,3 +1516,102 @@ async def test_report_progress_passes_related_request_id(): message="halfway", related_request_id="req-abc-123", ) + + +# --------------------------------------------------------------------------- +# Capability filtering: MCPServer only advertises capabilities for primitives +# that are actually registered, per the MCP schema spec. +# --------------------------------------------------------------------------- + + +def _get_caps(mcp: MCPServer) -> Any: + """Return the ServerCapabilities advertised by this MCPServer at run time.""" + return mcp._lowlevel_server.create_initialization_options().capabilities # type: ignore[reportPrivateUsage] + + +def test_capabilities_empty_server(): + mcp = MCPServer("test") + caps = _get_caps(mcp) + assert caps.tools is None + assert caps.resources is None + assert caps.prompts is None + + +def test_capabilities_tool_only(): + mcp = MCPServer("test") + + @mcp.tool() + def echo(text: str) -> str: + return text + + assert echo("hi") == "hi" + caps = _get_caps(mcp) + assert caps.tools is not None + assert caps.resources is None + assert caps.prompts is None + + +def test_capabilities_resource_only(): + mcp = MCPServer("test") + + @mcp.resource("resource://data") + def get_data() -> str: + return "hello" + + assert get_data() == "hello" + caps = _get_caps(mcp) + assert caps.tools is None + assert caps.resources is not None + assert caps.prompts is None + + +def test_capabilities_resource_template_only(): + mcp = MCPServer("test") + + @mcp.resource("resource://{city}/weather") + def get_weather(city: str) -> str: + return f"weather for {city}" + + assert get_weather("london") == "weather for london" + caps = _get_caps(mcp) + assert caps.tools is None + assert caps.resources is not None + assert caps.prompts is None + + +def test_capabilities_prompt_only(): + mcp = MCPServer("test") + + @mcp.prompt() + def greet(name: str) -> str: + return f"Hello, {name}!" + + assert greet("world") == "Hello, world!" + caps = _get_caps(mcp) + assert caps.tools is None + assert caps.resources is None + assert caps.prompts is not None + + +def test_capabilities_all_registered(): + mcp = MCPServer("test") + + @mcp.tool() + def echo(text: str) -> str: + return text + + @mcp.resource("resource://data") + def get_data() -> str: + return "hello" + + @mcp.prompt() + def greet(name: str) -> str: + return f"Hello, {name}!" + + assert echo("x") == "x" + assert get_data() == "hello" + assert greet("y") == "Hello, y!" + caps = _get_caps(mcp) + assert caps.tools is not None + assert caps.resources is not None + assert caps.prompts is not None