Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ def create_initialization_options(
description=self.description,
capabilities=self.get_capabilities(
notification_options or NotificationOptions(),
experimental_capabilities or {},
experimental_capabilities,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): Readers migrating from v1 are told the v2 initialize result differs only in server_version, which is no longer true after this change. docs/migration.md:1287 says of create_initialization_options() that "the only value that differs is server_version", but server.py:548 now also drops the v1 "experimental": {} from the legacy initialize result when nothing is configured. Fix: update that migration.md sentence (correcting an existing entry is allowed) to also note that an unconfigured server omits experimental instead of sending {}, so caps.experimental can be None on a legacy connection.

Why this was flagged

The PR removes experimental_capabilities or {} at src/mcp/server/lowlevel/server.py:548, so a lowlevel Server or MCPServer with no experimental capabilities now returns ServerCapabilities(experimental=None) from the initialize handshake; on the base branch and in v1 it sent "experimental": {}. docs/migration.md:1287 still states that create_initialization_options() builds the same InitializationOptions as v1 and "the only value that differs is server_version". A v1 user following that page and keeping code like caps.experimental.get(...) on a legacy connection now gets AttributeError on None, and the migration doc does not warn them. AGENTS.md requires docs/ to be updated in the same PR when user-visible behaviour changes, and permits correcting existing migration.md entries; no docs file is touched in this diff.

Verification: nit. Triggering condition: any v1 user reading the migration page for an unconfigured lowlevel Server. Mechanism: the diff changes src/mcp/server/lowlevel/server.py:548 so an unconfigured server now builds experimental=None where the base built experimental={}. docs/migration.md:1287 still reads "the only value that differs is server_version", so that sentence is now inaccurate. The diff touches no file under docs/.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): AGENTS.md says a change to a released v2 API's observable behaviour is a maintainer design decision that should generally be avoided: dropping or {} here makes create_initialization_options() (and so every legacy initialize result from Server/MCPServer) omit experimental instead of sending {}, which v1 and 2.x have always emitted. Fix: either keep the {} coercion on the legacy path (and let server/discover stay as-is), or have a maintainer explicitly sign off on the wire change and record it where 2.x behaviour changes are noted.

Why this was flagged

Nothing fails in the SDK itself; the guard is the 2.x compatibility contract. A client on a legacy (<=2025-11-25) connection that reads caps.experimental.get(...) or caps.experimental[...] without a None check will now raise AttributeError/TypeError where it previously got an empty dict. Mitigating facts the maintainer may weigh: the field has always been typed dict | None; server/discover already omitted it, so modern connections returned None already; docs/client/index.md already states an absent capability is None; the PR description lists this as a deliberate legacy-path change and cites other SDKs omitting it; the author is a listed project author in pyproject.toml. An explicit experimental_capabilities={} passed by a caller is still sent as {}.

Verification: AGENTS.md (base 17aaf25, "Branching Model") states: "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour ... is a design decision a maintainer makes explicitly, and should generally be avoided."

extensions if extensions is not None else self.extensions,
),
instructions=self.instructions,
Expand Down
1 change: 0 additions & 1 deletion tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ async def test_client_is_initialized(app: MCPServer):
async with Client(app, mode="legacy") as client:
assert client.server_capabilities == snapshot(
ServerCapabilities(
experimental={},
prompts=PromptsCapability(list_changed=False),
resources=ResourcesCapability(subscribe=False, list_changed=False),
tools=ToolsCapability(list_changed=False),
Expand Down
3 changes: 1 addition & 2 deletions tests/interaction/lowlevel/test_initialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,6 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar

assert capabilities == snapshot(
ServerCapabilities(
experimental={},
logging=LoggingCapability(),
prompts=PromptsCapability(list_changed=False),
resources=ResourcesCapability(subscribe=True, list_changed=False),
Expand All @@ -158,7 +157,7 @@ async def test_initialize_minimal_server_advertises_no_capabilities(connect: Con
async with connect(Server("bare")) as client:
capabilities = client.server_capabilities

assert capabilities == snapshot(ServerCapabilities(experimental={}))
assert capabilities == snapshot(ServerCapabilities())


@requirement("lifecycle:initialize:client-info")
Expand Down
32 changes: 27 additions & 5 deletions tests/server/lowlevel/test_server_discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

These call the registered handler via the public `Server.get_request_handler`
accessor without spinning up a `ServerRunner` or any transport, so they verify
the handler's contract in isolation from the dispatch pipeline. The exception
is the server-identity pair: the serverInfo `_meta` stamp is applied by the
runner (spec 2026-07-28, #3002), not the handler, so those two drive one
request through `serve_one` to observe it.
the handler's contract in isolation from the dispatch pipeline. The exceptions
are the server-identity pair and the handshake comparison. The serverInfo
`_meta` stamp is applied by the runner (spec 2026-07-28, #3002), not the
handler, so that pair drives one request through `serve_one` to observe it.
The handshake comparison needs a real `initialize` result to set beside the
`server/discover` one, so it connects a `Client` in each mode.
"""

from collections.abc import Mapping
Expand All @@ -15,8 +17,9 @@
import anyio
import mcp_types as types
import pytest
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS

from mcp import Client
from mcp.server import NotificationOptions, Server, ServerRequestContext
from mcp.server.connection import Connection
from mcp.server.runner import serve_one
Expand Down Expand Up @@ -271,3 +274,22 @@ async def list_tools(

opted_in = server.get_capabilities(NotificationOptions(tools_changed=True))
assert opted_in.tools is not None and opted_in.tools.list_changed is True


@pytest.mark.anyio
async def test_unconfigured_experimental_is_omitted_from_both_initialize_and_discover() -> None:
"""SDK-defined: a server with no experimental capabilities configured leaves
`experimental` out of the `initialize` result as well as the `server/discover`
result, so a client reads the same thing whichever way it connects."""
server = Server("bare")

async with Client(server, mode="legacy") as legacy:
assert legacy.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS
initialize_capabilities = legacy.server_capabilities

async with Client(server, mode="auto") as modern:
assert modern.protocol_version in MODERN_PROTOCOL_VERSIONS
discover_capabilities = modern.server_capabilities

assert initialize_capabilities.experimental is None
assert discover_capabilities.experimental is None
Loading