From fc45dbad4ee831255dc7af97131aab98f3fbde18 Mon Sep 17 00:00:00 2001 From: Hamjaster <87142882+Hamjaster@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:53:11 +0500 Subject: [PATCH] fix(server): normalize experimental capability to {} in get_capabilities An unconfigured server reported experimental differently depending on which discovery path answered: {} via create_initialization_options() (the legacy initialize path), None via a direct get_capabilities() call with no experimental_capabilities argument (what server/discover does internally). create_initialization_options() was the only caller normalizing None to {} before passing it down, so get_capabilities() itself fell back to its own parameter default whenever a caller didn't normalize first. Move the normalization into get_capabilities(), next to the existing notification_options fallback, so every caller gets the same value regardless of what it passes. Verified with the repro from the issue: legacy and modern now both report experimental={} for the same server, and "experimental" is present in both wire dumps instead of only the legacy one. --- src/mcp/server/lowlevel/server.py | 5 +++++ tests/server/lowlevel/test_server_discover.py | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index efdf4b216e..02e08a22b3 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -575,6 +575,11 @@ def get_capabilities( handshake-era derivation applies unchanged. """ notification_options = notification_options or NotificationOptions() + # Normalized here rather than only at the create_initialization_options() + # call site, so server/discover (which calls this directly with no + # experimental_capabilities argument) reports {} instead of None for the + # same unconfigured server. + experimental_capabilities = experimental_capabilities or {} prompts_capability = None resources_capability = None tools_capability = None diff --git a/tests/server/lowlevel/test_server_discover.py b/tests/server/lowlevel/test_server_discover.py index 23a29327ee..8298274bd8 100644 --- a/tests/server/lowlevel/test_server_discover.py +++ b/tests/server/lowlevel/test_server_discover.py @@ -180,6 +180,27 @@ async def list_prompts( assert after.capabilities.prompts is not None +@pytest.mark.anyio +async def test_experimental_capability_is_empty_dict_not_none_when_unset() -> None: + """SDK-defined: an unconfigured server's `experimental` capability is `{}` + on both discovery paths, matching `create_initialization_options()`. + + `get_capabilities()` used to leave `experimental` at its own parameter + default (`None`) unless the caller normalized it first; + `create_initialization_options()` did that normalization, but + `server/discover` calls `get_capabilities()` directly and did not - so the + same unconfigured server reported `{}` via `initialize` and `None` via + `server/discover`. See https://github.com/modelcontextprotocol/python-sdk/issues/3254 + """ + server = Server("cap-server") + + legacy_capabilities = server.create_initialization_options().capabilities + discovered = await _discover(server) + + assert legacy_capabilities.experimental == {} + assert discovered.capabilities.experimental == {} + + @pytest.mark.anyio async def test_discover_result_defaults_to_immediately_stale_private_cache() -> None: """SDK-defined: `DiscoverResult` is cacheable; the auto-derived handler