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

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    P1Significant bug affecting many users, highly requested feature
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    and removed
    P1Significant bug affecting many users, highly requested feature
    on Aug 11, 2026
  2. pttydou commented on Aug 22, 2026

    @pttydou

    I reproduced this on current main (57394b0) through the actual default discover handler and runner. Omitted experimental and "experimental": {} both validate for the 2025-11-25 and 2026-07-28 schemas and contain zero named experimental capabilities, but the spec does not require one canonical JSON or SDK representation; parsing preserves None versus {}.

    There are two compatibility boundaries: normalizing get_capabilities() globally to {} (the approach in #3255) also changes the observable default of that public method, while passing {} only from _handle_discover aligns the two default wire discovery paths without changing direct callers. The narrower handler-only change appears safer under the v2 compatibility contract.

    A regression test should exercise the runner's full discover result, mark this as SDK-defined rather than a spec MUST, and stay scoped to the unconfigured case. Non-empty experimental maps passed to initialization are not stored on Server, so making those automatically appear in discover would be a broader API design. Would maintainers prefer the handler-only boundary, or the broader global normalization already in #3255?

    AI disclosure: I used Codex to inspect the current SDK and specification sources and to run the local reproduction and schema checks.

  3. maxisbey commented on Oct 2, 2026

    @maxisbey
    Contributor

    Fixed in #3614, thanks for the detailed write-up.

    A server with no experimental capabilities configured now leaves experimental out of its initialize result too, so both paths give you None.

    I went with omitting it rather than sending {} everywhere because that's what server/discover and the other SDKs I checked already do. So caps.experimental or {} is the safe way to read it on either path.

    If that doesn't work for your migration gate, please open a new issue.

    AI Disclaimer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurespec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions