Description
With mcp==2.0.0, the same unconfigured server exposes empty experimental capabilities differently through its two public discovery paths:
- initialize:
capabilities.experimental == {} and the field is present on the wire server/discover: capabilities.experimental is None; the field is omitted on the wire, while the parsed SDK model materializes None
In a sanitized capture this is visible at both $.handshake.capabilities.experimental and $.handshake.result.capabilities.experimental as {} to null. The null is 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 as is not None also change meaning.
Minimal reproduction
frommcp.server.lowlevelimportServerserver=Server("repro", version="0.0.0")
legacy=server.create_initialization_options().capabilitiesmodern=server.get_capabilities(protocol_version="2026-07-28")
forname, capabilitiesin (("legacy", legacy), ("modern", modern)):
wire=capabilities.model_dump(by_alias=True, mode="json", exclude_none=True)
print(name, capabilities.experimental, "experimental"inwire)Observed with Python 3.14.3, mcp==2.0.0, mcp-types==2.0.0, and Pydantic 2.13.4:
legacy {} True
modern None False
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:
- The initialize path converts a missing experimental map to
{}: | defcreate_initialization_options( |
| self, |
| notification_options: NotificationOptions|None=None, |
| experimental_capabilities: dict[str, dict[str, Any]] |None=None, |
| extensions: dict[str, dict[str, Any]] |None=None, |
| ) ->InitializationOptions: |
| """Create initialization options from this server instance. |
| |
| `extensions` advertises SEP-2133 extension support under |
| `ServerCapabilities.extensions`; keys are extension identifiers (e.g. |
| `io.modelcontextprotocol/ui`), values are per-extension settings. |
| Defaults to `self.extensions`, which higher layers populate. |
| """ |
| returnInitializationOptions( |
| server_name=self.name, |
| server_version=self.version, |
| title=self.title, |
| description=self.description, |
| capabilities=self.get_capabilities( |
| notification_optionsorNotificationOptions(), |
| experimental_capabilitiesor {}, |
| extensionsifextensionsisnotNoneelseself.extensions, |
get_capabilities preserves None: | defget_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_optionsorNotificationOptions() |
| prompts_capability=None |
| resources_capability=None |
| tools_capability=None |
| logging_capability=None |
| completions_capability=None |
| |
| ifprotocol_versioninMODERN_PROTOCOL_VERSIONS: |
| listen_served="subscriptions/listen"inself._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"inself._request_handlers |
| |
| # Set prompt capabilities if handler exists |
| if"prompts/list"inself._request_handlers: |
| prompts_capability=types.PromptsCapability(list_changed=prompts_changed) |
| |
| # Set resource capabilities if handler exists |
| if"resources/list"inself._request_handlers: |
| resources_capability=types.ResourcesCapability( |
| subscribe=subscribe, |
| list_changed=resources_changed, |
| ) |
| |
| # Set tool capabilities if handler exists |
| if"tools/list"inself._request_handlers: |
| tools_capability=types.ToolsCapability(list_changed=tools_changed) |
| |
| # Set logging capabilities if handler exists |
| if"logging/setLevel"inself._request_handlers: |
| logging_capability=types.LoggingCapability() |
| |
| # Set completions capabilities if handler exists |
| if"completion/complete"inself._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=extensionsifextensionsisnotNoneelse (self.extensionsorNone), |
| completions=completions_capability, |
| ) |
| returncapabilities |
- The modern discover handler calls it without an experimental map:
| asyncdef_handle_discover( |
| self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams|None |
| ) ->types.DiscoverResult: |
| """Default `server/discover` handler. |
| |
| Auto-derived from server state at call time, so capabilities reflect |
| whatever has been registered (constructor `on_*` kwargs and later |
| `add_request_handler` calls). Operators can replace it wholesale via |
| `add_request_handler("server/discover", ...)`. Reachability for legacy |
| peers is decided at the boundary (`types.methods`), not here. |
| """ |
| returntypes.DiscoverResult( |
| supported_versions=list(MODERN_PROTOCOL_VERSIONS), |
| capabilities=self.get_capabilities(protocol_version=ctx.protocol_version), |
| instructions=self.instructions, |
| ) |
- The type defaults
experimental to None: | classServerCapabilities(MCPModel): |
| """Capabilities that a server may support. Not a closed set.""" |
| |
| experimental: dict[str, dict[str, Any]] |None=None |
| """Experimental, non-standard capabilities that the server supports.""" |
- The runner omits
None from the modern wire response: | def_dump_result(result: Any) ->dict[str, Any]: |
| ifresultisNone: |
| return {} |
| ifisinstance(result, ErrorData): |
| # ErrorData is a JSON-RPC error, not a success result. Handler returns |
| # already raise in `_inner`; this catches middleware returning one. |
| raiseMCPError.from_error_data(result) |
| ifisinstance(result, BaseModel): |
| returnresult.model_dump(by_alias=True, mode="json", exclude_none=True) |
| ifisinstance(result, dict): |
| # Copied so callers own the returned dict: handlers and middleware may |
| # retain the object they returned, and the outbound pipeline shapes the |
| # wire form without reaching into anything the handler still holds. |
| returndict(cast(dict[str, Any], result)) |
- The client exposes the parsed discover capabilities:
| asyncdefdiscover(self) ->types.DiscoverResult: |
| """Probe `server/discover` and adopt the result. |
| |
| Sends a single `server/discover` proposing the newest modern protocol |
| version. On `UNSUPPORTED_PROTOCOL_VERSION` (-32022) the server's |
| `supported` list is intersected with `MODERN_PROTOCOL_VERSIONS` and the |
| probe is retried once at the highest mutual version. Any other error — |
| including `METHOD_NOT_FOUND` (-32601) and `REQUEST_TIMEOUT` (-32001) — |
| propagates; the legacy `initialize()` fallback is the caller's policy. |
| |
| Raises: |
| MCPError: The server rejected `server/discover`, the probe timed |
| out, or the -32022 retry found no mutual version / failed again. |
| RuntimeError: `adopt()` found no mutual version in the returned |
| `supported_versions`. |
| """ |
| ifself._discover_resultisnotNone: |
| returnself._discover_result |
| |
| try: |
| raw=awaitself.send_discover(LATEST_MODERN_VERSION) |
| exceptMCPErrorase: |
| ife.code!=UNSUPPORTED_PROTOCOL_VERSION: |
| raise |
| try: |
| data=types.UnsupportedProtocolVersionErrorData.model_validate(e.error.data) |
| exceptValidationError: |
| raiseefromNone |
| # ordered oldest→newest via MODERN_PROTOCOL_VERSIONS |
| mutual= [vforvinMODERN_PROTOCOL_VERSIONSifvindata.supported] |
| ifnotmutual: |
| raise |
| raw=awaitself.send_discover(mutual[-1]) |
| |
| result=types.DiscoverResult.model_validate(raw) |
| self.adopt(result) |
| returnresult |
and | @property |
| defserver_capabilities(self) ->types.ServerCapabilities|None: |
| """Server capabilities. None until `initialize()`, `discover()`, or `adopt()`.""" |
| ifself._discover_resultisnotNone: |
| returnself._discover_result.capabilities |
| ifself._initialize_resultisnotNone: |
| returnself._initialize_result.capabilities |
- The protocol schema makes the field optional and object-valued:
| "ServerCapabilities": { |
| "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", |
| "properties": { |
| "completions": { |
| "$ref": "#/$defs/JSONObject", |
| "description": "Present if the server supports argument autocompletion suggestions." |
| }, |
| "experimental": { |
| "additionalProperties": { |
| "$ref": "#/$defs/JSONObject" |
| }, |
| "description": "Experimental, non-standard capabilities that the server supports.", |
| "type": "object" |
| }, |
| "extensions": { |
| "additionalProperties": { |
| "$ref": "#/$defs/JSONObject" |
| }, |
| "description": "Optional MCP extensions that the server supports. Keys are extension identifiers\n(e.g., \"io.modelcontextprotocol/tasks\"), and values are per-extension settings\nobjects. An empty object indicates support with no settings.\n\nKeys MUST follow the {@link MetaObject`_meta` key naming rules}, with a\nmandatory prefix.", |
| "type": "object" |
| }, |
| "logging": { |
| "$ref": "#/$defs/JSONObject", |
| "description": "Present if the server supports sending log messages to the client." |
| }, |
| "prompts": { |
| "description": "Present if the server offers any prompt templates.", |
| "properties": { |
| "listChanged": { |
| "description": "Whether this server supports notifications for changes to the prompt list.", |
| "type": "boolean" |
| } |
| }, |
| "type": "object" |
| }, |
| "resources": { |
| "description": "Present if the server offers any resources to read.", |
| "properties": { |
| "listChanged": { |
| "description": "Whether this server supports notifications for changes to the resource list.", |
| "type": "boolean" |
| }, |
| "subscribe": { |
| "description": "Whether this server supports subscribing to resource updates.", |
| "type": "boolean" |
| } |
| }, |
| "type": "object" |
| }, |
| "tools": { |
| "description": "Present if the server offers any tools to call.", |
| "properties": { |
| "listChanged": { |
| "description": "Whether this server supports notifications for changes to the tool list.", |
| "type": "boolean" |
| } |
| }, |
| "type": "object" |
| } |
| }, |
| "type": "object" |
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
- Python: 3.14.3
- MCP Python SDK: 2.0.0
- mcp-types: 2.0.0
- Pydantic: 2.13.4
- OS: Windows
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