|
def get_capabilities( |
|
self, |
|
notification_options: NotificationOptions | None = None, |
|
experimental_capabilities: dict[str, dict[str, Any]] | None = None, |
|
extensions: dict[str, dict[str, Any]] | None = None, |
|
*, |
|
protocol_version: str | None = None, |
|
) -> types.ServerCapabilities: |
|
"""Convert existing handlers to a ServerCapabilities object. |
|
|
|
`extensions` is the SEP-2133 extension map (identifier -> settings) |
|
advertised under `ServerCapabilities.extensions`; it defaults to |
|
`self.extensions`. |
|
|
|
`protocol_version` makes the subscription-delivered bits era-honest: |
|
at 2026-07-28+ versions, change notifications are delivered only on |
|
`subscriptions/listen` streams, so the `listChanged` flags and |
|
`resources.subscribe` derive from whether that method is served - |
|
`notification_options` and the legacy `resources/subscribe` handler |
|
(which the modern wire cannot dispatch) are ignored. When omitted, the |
|
handshake-era derivation applies unchanged. |
|
""" |
|
notification_options = notification_options or NotificationOptions() |
|
prompts_capability = None |
|
resources_capability = None |
|
tools_capability = None |
|
logging_capability = None |
|
completions_capability = None |
|
|
|
if protocol_version in MODERN_PROTOCOL_VERSIONS: |
|
listen_served = "subscriptions/listen" in self._request_handlers |
|
prompts_changed = tools_changed = resources_changed = subscribe = listen_served |
|
else: |
|
prompts_changed = notification_options.prompts_changed |
|
tools_changed = notification_options.tools_changed |
|
resources_changed = notification_options.resources_changed |
|
subscribe = "resources/subscribe" in self._request_handlers |
|
|
|
# Set prompt capabilities if handler exists |
|
if "prompts/list" in self._request_handlers: |
|
prompts_capability = types.PromptsCapability(list_changed=prompts_changed) |
|
|
|
# Set resource capabilities if handler exists |
|
if "resources/list" in self._request_handlers: |
|
resources_capability = types.ResourcesCapability( |
|
subscribe=subscribe, |
|
list_changed=resources_changed, |
|
) |
|
|
|
# Set tool capabilities if handler exists |
|
if "tools/list" in self._request_handlers: |
|
tools_capability = types.ToolsCapability(list_changed=tools_changed) |
|
|
|
# Set logging capabilities if handler exists |
|
if "logging/setLevel" in self._request_handlers: |
|
logging_capability = types.LoggingCapability() |
|
|
|
# Set completions capabilities if handler exists |
|
if "completion/complete" in self._request_handlers: |
|
completions_capability = types.CompletionsCapability() |
|
|
|
capabilities = types.ServerCapabilities( |
|
prompts=prompts_capability, |
|
resources=resources_capability, |
|
tools=tools_capability, |
|
logging=logging_capability, |
|
experimental=experimental_capabilities, |
|
extensions=extensions if extensions is not None else (self.extensions or None), |
|
completions=completions_capability, |
|
) |
|
return capabilities |
Description
With
mcp==2.0.0, the same unconfigured server exposes empty experimental capabilities differently through its two public discovery paths:capabilities.experimental == {}and the field is present on the wireserver/discover:capabilities.experimental is None; the field is omitted on the wire, while the parsed SDK model materializesNoneIn a sanitized capture this is visible at both
$.handshake.capabilities.experimentaland$.handshake.result.capabilities.experimentalas{}tonull. Thenullis a diagnostic model dump, not a literal modern wire value.This distinction is client-visible. Code using
.get(...)on the legacy value works but raises on the modern value, while checks such asis not Nonealso change meaning.Minimal reproduction
Observed with Python 3.14.3,
mcp==2.0.0,mcp-types==2.0.0, and Pydantic 2.13.4:Expected behavior
The two supported discovery paths should expose consistent public SDK semantics for an unconfigured experimental capability map, or the intentional difference should be documented with migration guidance.
Source diagnosis
The tagged v2.0.0 source appears to explain the mismatch:
{}:python-sdk/src/mcp/server/lowlevel/server.py
Lines 527 to 548 in 6f69a37
get_capabilitiespreservesNone:python-sdk/src/mcp/server/lowlevel/server.py
Lines 555 to 625 in 6f69a37
python-sdk/src/mcp/server/lowlevel/server.py
Lines 660 to 675 in 6f69a37
experimentaltoNone:python-sdk/src/mcp-types/mcp_types/_types.py
Lines 485 to 489 in 6f69a37
Nonefrom the modern wire response:python-sdk/src/mcp/server/runner.py
Lines 110 to 123 in 6f69a37
python-sdk/src/mcp/client/session.py
Lines 719 to 755 in 6f69a37
python-sdk/src/mcp/client/session.py
Lines 791 to 797 in 6f69a37
python-sdk/schema/2026-07-28.json
Lines 3117 to 3177 in 6f69a37
Downstream impact and revisit condition
A migration gate currently needs a provisional expected delta for this client-visible transition. We will retest the first 2.x release that fixes or documents this behavior and remove or revise that delta when the two representations converge or the intended contract is clarified.
Version