Skip to content

Commit fc45dba

Browse files
committed
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.
1 parent a4f4ccd commit fc45dba

2 files changed

Lines changed: 26 additions & 0 deletions

File tree

src/mcp/server/lowlevel/server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,11 @@ def get_capabilities(
575575
handshake-era derivation applies unchanged.
576576
"""
577577
notification_options = notification_options or NotificationOptions()
578+
# Normalized here rather than only at the create_initialization_options()
579+
# call site, so server/discover (which calls this directly with no
580+
# experimental_capabilities argument) reports {} instead of None for the
581+
# same unconfigured server.
582+
experimental_capabilities = experimental_capabilities or {}
578583
prompts_capability = None
579584
resources_capability = None
580585
tools_capability = None

tests/server/lowlevel/test_server_discover.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,27 @@ async def list_prompts(
180180
assert after.capabilities.prompts is not None
181181

182182

183+
@pytest.mark.anyio
184+
async def test_experimental_capability_is_empty_dict_not_none_when_unset() -> None:
185+
"""SDK-defined: an unconfigured server's `experimental` capability is `{}`
186+
on both discovery paths, matching `create_initialization_options()`.
187+
188+
`get_capabilities()` used to leave `experimental` at its own parameter
189+
default (`None`) unless the caller normalized it first;
190+
`create_initialization_options()` did that normalization, but
191+
`server/discover` calls `get_capabilities()` directly and did not - so the
192+
same unconfigured server reported `{}` via `initialize` and `None` via
193+
`server/discover`. See https://github.com/modelcontextprotocol/python-sdk/issues/3254
194+
"""
195+
server = Server("cap-server")
196+
197+
legacy_capabilities = server.create_initialization_options().capabilities
198+
discovered = await _discover(server)
199+
200+
assert legacy_capabilities.experimental == {}
201+
assert discovered.capabilities.experimental == {}
202+
203+
183204
@pytest.mark.anyio
184205
async def test_discover_result_defaults_to_immediately_stale_private_cache() -> None:
185206
"""SDK-defined: `DiscoverResult` is cacheable; the auto-derived handler

0 commit comments

Comments
 (0)