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