From e187a74ecea5f3de82461186c2fd34314c5a0e8c Mon Sep 17 00:00:00 2001 From: Zelys Date: Thu, 23 Apr 2026 16:55:19 -0500 Subject: [PATCH 1/2] 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 59de0ace45..96e1a349e5 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 b3471163b7..55a6e17562 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 3457ec944a..3c5276998a 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 From d1c8560aae5fb7f9b63eae3bc12573b82b962836 Mon Sep 17 00:00:00 2001 From: Zelys Date: Thu, 21 May 2026 16:09:22 -0500 Subject: [PATCH 2/2] fix(mcpserver): add lax no cover pragmas for subprocess-only coverage paths Lines 306-308 (match transport/stdio case) and 860-867 (run_stdio_async) are only covered via subprocess in test_1027_win_unreachable_cleanup.py. Coverage 7.10.7 (lowest-direct) does not capture subprocess coverage on Windows+Python 3.13, causing a spurious fail_under=100 failure. Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/server/mcpserver/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 55a6e17562..4e6e27f778 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -303,7 +303,7 @@ def run( if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover raise ValueError(f"Unknown transport: {transport}") - match transport: + match transport: # pragma: lax no cover case "stdio": anyio.run(self.run_stdio_async) case "sse": # pragma: no cover @@ -857,7 +857,7 @@ def decorator( # pragma: no cover return decorator # pragma: no cover - async def run_stdio_async(self) -> None: + async def run_stdio_async(self) -> None: # pragma: lax no cover """Run the server using stdio transport.""" async with stdio_server() as (read_stream, write_stream): await self._lowlevel_server.run(