Skip to content

[v2] MCPServer reports empty experimental capabilities as {} via initialize but None via server/discover #3254

Description

@squibloads

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

from mcp.server.lowlevel import Server

server = Server("repro", version="0.0.0")
legacy = server.create_initialization_options().capabilities
modern = server.get_capabilities(protocol_version="2026-07-28")

for name, capabilities in (("legacy", legacy), ("modern", modern)):
    wire = capabilities.model_dump(by_alias=True, mode="json", exclude_none=True)
    print(name, capabilities.experimental, "experimental" in wire)

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 {}:
    def create_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.
    """
    return InitializationOptions(
    server_name=self.name,
    server_version=self.version,
    title=self.title,
    description=self.description,
    capabilities=self.get_capabilities(
    notification_options or NotificationOptions(),
    experimental_capabilities or {},
    extensions if extensions is not None else self.extensions,
  • get_capabilities preserves None:
    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
  • The modern discover handler calls it without an experimental map:
    async def _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.
    """
    return types.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:
    class ServerCapabilities(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]:
    if result is None:
    return {}
    if isinstance(result, ErrorData):
    # ErrorData is a JSON-RPC error, not a success result. Handler returns
    # already raise in `_inner`; this catches middleware returning one.
    raise MCPError.from_error_data(result)
    if isinstance(result, BaseModel):
    return result.model_dump(by_alias=True, mode="json", exclude_none=True)
    if isinstance(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.
    return dict(cast(dict[str, Any], result))
  • The client exposes the parsed discover capabilities:
    async def discover(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`.
    """
    if self._discover_result is not None:
    return self._discover_result
    try:
    raw = await self.send_discover(LATEST_MODERN_VERSION)
    except MCPError as e:
    if e.code != UNSUPPORTED_PROTOCOL_VERSION:
    raise
    try:
    data = types.UnsupportedProtocolVersionErrorData.model_validate(e.error.data)
    except ValidationError:
    raise e from None
    # ordered oldest→newest via MODERN_PROTOCOL_VERSIONS
    mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in data.supported]
    if not mutual:
    raise
    raw = await self.send_discover(mutual[-1])
    result = types.DiscoverResult.model_validate(raw)
    self.adopt(result)
    return result
    and
    @property
    def server_capabilities(self) -> types.ServerCapabilities | None:
    """Server capabilities. None until `initialize()`, `discover()`, or `adopt()`."""
    if self._discover_result is not None:
    return self._discover_result.capabilities
    if self._initialize_result is not None:
    return self._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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions