Skip to content
Closed
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
8 changes: 5 additions & 3 deletions docs/advanced/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ Done. The server now advertises `io.modelcontextprotocol/ui` under
Extensions are fixed at construction. There is no `add_extension` to call later:
a server's capability map should not change while clients are connected to it.

The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy
`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see
the extension. Design for that: an extension *augments* a server, it must not be the
The capability map rides `server/discover` on **2026-07-28** connections and the
`initialize` handshake on **2025-11-25 and earlier** connections. Both client and

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This overstates the client-side guarantee: claim-bearing extensions are omitted from legacy advertisements when their claims are inactive, so their settings are not sent. Limit this statement to client extensions that remain advertised.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/advanced/extensions.md, line 29:

<comment>This overstates the client-side guarantee: claim-bearing extensions are omitted from legacy advertisements when their claims are inactive, so their settings are not sent. Limit this statement to client extensions that remain advertised.</comment>

<file context>
@@ -25,9 +25,11 @@ Done. The server now advertises `io.modelcontextprotocol/ui` under
-`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see
-the extension. Design for that: an extension *augments* a server, it must not be the
+The capability map rides `server/discover` on **2026-07-28** connections and the
+`initialize` handshake on **2025-11-25 and earlier** connections. Both client and
+server advertisements preserve `capabilities.extensions`, including extension
+settings, on every supported legacy version. Peers that do not support an extension
</file context>

server advertisements preserve `capabilities.extensions`, including extension
settings, on every supported legacy version. Peers that do not support an extension
can ignore its advertisement: an extension *augments* a server, it must not be the
only way the server is usable.

## Writing your own
Expand Down
15 changes: 13 additions & 2 deletions scripts/gen_surface_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
Runs `datamodel-code-generator` over each `schema/PINNED.json` entry and
writes the result to `src/mcp-types/mcp_types/_v<version>/__init__.py` (the
underscore marks these as internal validators, not public API) with only
the fixes the raw output needs: a small JSON pre-patch for the known
`number`-as-`integer` schema.json defect, a header, full URLs for the spec's
the fixes the raw output needs: JSON pre-patches for schema defects and legacy
extension advertisements, a header, full URLs for the spec's
site-absolute doc links, plain type aliases, and per-version epilogue aliases. Run with
`uv run --frozen --group codegen python scripts/gen_surface_types.py [--check]`.
"""
Expand Down Expand Up @@ -239,6 +239,17 @@ def build(entry: dict[str, str]) -> str:
version = entry["protocol_version"]
schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text(encoding="utf-8"))
patch_schema(schema, SCHEMA_PATCHES.get(version, []))
if version == "2025-11-25":
# Extensions predate their formal 2026 field; legacy peers negotiate them
# through initialize (modelcontextprotocol/modelcontextprotocol#3364).
for role in ("Client", "Server"):
properties = schema["$defs"][f"{role}Capabilities"]["properties"]
assert "extensions" not in properties
properties["extensions"] = {
"type": "object",
"additionalProperties": {"type": "object", "additionalProperties": True},
"description": f"MCP extensions that the {role.lower()} supports, including on legacy connections.",
}
make_server_info_opaque(schema)
if "JSONValue" in schema["$defs"]:
# A single recursive alias avoids mutually recursive alias evaluation in type checkers.
Expand Down
4 changes: 2 additions & 2 deletions src/mcp-types/mcp_types/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ class ClientCapabilities(MCPModel):
roots: RootsCapability | None = None
"""Present if the client supports listing roots."""
extensions: dict[str, dict[str, Any]] | None = None
"""MCP extensions the client supports (2026-07-28). Keys are extension
"""MCP extensions the client supports, including on legacy connections. Keys are extension
identifiers; values are per-extension settings (empty object = no settings)."""
tasks: ClientTasksCapability | None = None
"""Present if the client supports task-augmented requests (2025-11-25 only)."""
Expand Down Expand Up @@ -505,7 +505,7 @@ class ServerCapabilities(MCPModel):
"""Present if the server offers autocompletion suggestions for prompts and resources."""

extensions: dict[str, dict[str, Any]] | None = None
"""MCP extensions the server supports (2026-07-28). Keys are extension
"""MCP extensions the server supports, including on legacy connections. Keys are extension
identifiers; values are per-extension settings (empty object = no settings)."""

tasks: ServerTasksCapability | None = None
Expand Down
8 changes: 8 additions & 0 deletions src/mcp-types/mcp_types/_v2025_11_25/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ class ClientCapabilities(WireModel):
"""
Present if the client supports task-augmented requests.
"""
extensions: dict[str, dict[str, Any]] | None = None
"""
MCP extensions that the client supports, including on legacy connections.
"""


class Argument(WireModel):
Expand Down Expand Up @@ -1132,6 +1136,10 @@ class ServerCapabilities(WireModel):
"""
Present if the server offers any tools to call.
"""
extensions: dict[str, dict[str, Any]] | None = None
"""
MCP extensions that the server supports, including on legacy connections.
"""


class SetLevelRequestParams(WireModel):
Expand Down
1 change: 1 addition & 0 deletions tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ async def test_client_is_initialized(app: MCPServer):
prompts=PromptsCapability(list_changed=False),
resources=ResourcesCapability(subscribe=False, list_changed=False),
tools=ToolsCapability(list_changed=False),
extensions={},
)
)
assert client.server_info is not None
Expand Down
3 changes: 2 additions & 1 deletion tests/interaction/lowlevel/test_initialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar
resources=ResourcesCapability(subscribe=True, list_changed=False),
tools=ToolsCapability(list_changed=False),
completions=CompletionsCapability(),
extensions={},
)
)

Expand All @@ -157,7 +158,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())
assert capabilities == snapshot(ServerCapabilities(extensions={}))


@requirement("lifecycle:initialize:client-info")
Expand Down
11 changes: 5 additions & 6 deletions tests/server/mcpserver/test_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,13 @@ async def test_extension_settings_advertised_under_server_capabilities() -> None
assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}})


async def test_extension_settings_dropped_on_legacy_handshake() -> None:
"""Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions`
field, so a legacy `initialize` handshake drops the advertised extension even
though the modern `auto` path carries it."""
server = MCPServer("test", extensions=[_SettingsExt()])
async def test_extension_settings_preserved_on_legacy_handshake() -> None:
"""SDK compatibility: a legacy initialize handshake preserves nested extension settings."""
extension = _SettingsExt()
server = MCPServer("test", extensions=[extension])

async with Client(server, mode="legacy") as client:
assert client.server_capabilities.extensions is None
assert client.server_capabilities.extensions == {extension.identifier: extension.settings()}


def test_duplicate_extension_identifier_raises() -> None:
Expand Down
8 changes: 3 additions & 5 deletions tests/server/test_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,10 @@ async def test_auto_mode_carries_apps_extension_under_server_capabilities() -> N
assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}})


async def test_legacy_handshake_drops_apps_extension_from_capabilities() -> None:
"""Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` field,
so a `mode='legacy'` handshake cannot carry the Apps capability -- only `mode='auto'`
(server/discover) does. This pins the divergence rather than fixing it."""
async def test_legacy_handshake_carries_apps_extension_in_capabilities() -> None:
"""SDK compatibility: a legacy initialize handshake advertises MCP Apps with empty settings."""
async with Client(_clock_server(), mode="legacy") as client:
assert client.server_capabilities.extensions is None
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}


async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None:
Expand Down
16 changes: 8 additions & 8 deletions tests/server/test_extensions_capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,16 @@ async def test_modern_connection_carries_the_advertised_extensions_map() -> None
assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}})


async def test_legacy_handshake_drops_the_extensions_map() -> None:
"""Pinned gap: the handshake-era `initialize` result is serialized against the
2025 wire schema, which has no `extensions` field, so a legacy handshake cannot
carry it; the client sees `None` even though the server advertised one."""
server = MCPServer("host", extensions=[_Extension()])
async def test_legacy_handshake_carries_the_extensions_map() -> None:
"""SDK compatibility: a legacy initialize handshake preserves the server's extension settings."""
extension = _Extension()
server = MCPServer("host", extensions=[extension])
async with Client(server, mode="legacy") as client:
assert client.server_capabilities.extensions is None
assert client.server_capabilities.extensions == {extension.identifier: extension.settings()}


async def test_server_accepts_capability_for_client_advertised_extension() -> None:
@pytest.mark.parametrize("mode", ["auto", "legacy"])
async def test_server_accepts_capability_for_client_advertised_extension(mode: str) -> None:
"""SDK-defined: a client advertising `extensions={id: ...}` makes the
server-side `check_client_capability` return True when queried for that id.
Observed inside a tool handler."""
Expand All @@ -83,7 +83,7 @@ async def list_tools(
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])

server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
async with Client(server, mode=mode, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
await client.call_tool("probe", {})

assert supported == [True]
Expand Down
70 changes: 69 additions & 1 deletion tests/types/test_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import pydantic
import pytest
from mcp_types import methods
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, KNOWN_PROTOCOL_VERSIONS
from pydantic import BaseModel

# Transcribed from each schema's ClientRequest/ServerRequest/ClientNotification/
Expand Down Expand Up @@ -470,6 +470,74 @@ def test_discovery_capabilities_preserve_nested_json_values() -> None:
assert methods.serialize_server_result("server/discover", "2026-07-28", raw) == raw


@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS)
@pytest.mark.parametrize(
"extensions",
[
{},
{"io.modelcontextprotocol/ui": {}},
{"com.example/settings": {"nested": [None, True, 1, 1.5, "text", {"child": [False]}]}},
],
)
def test_initialize_round_trip_preserves_server_extensions(version: str, extensions: dict[str, dict[str, Any]]) -> None:
"""SDK compatibility: legacy initialize responses carry extension advertisements, including opaque settings."""
raw = {
"protocolVersion": version,
"capabilities": {"tools": {}, "extensions": extensions},
"serverInfo": {"name": "server", "version": "1"},
}
serialized = methods.serialize_server_result("initialize", version, raw)
assert serialized == raw
parsed = methods.parse_server_result("initialize", version, serialized)
assert isinstance(parsed, types.InitializeResult)
assert parsed.capabilities.extensions == extensions


@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS)
@pytest.mark.parametrize(
"extensions",
[
{},
{"io.modelcontextprotocol/ui": {}},
{"com.example/settings": {"nested": [None, True, 1, 1.5, "text", {"child": [False]}]}},
],
)
def test_initialize_round_trip_preserves_client_extensions(version: str, extensions: dict[str, dict[str, Any]]) -> None:
"""SDK compatibility: the legacy request wire model preserves the same extension settings as the public parser."""
raw = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": version,
"capabilities": {"extensions": extensions},
"clientInfo": {"name": "client", "version": "1"},
},
}
surface = methods.CLIENT_REQUESTS[("initialize", version)]
serialized = surface.model_validate(raw).model_dump(by_alias=True, mode="json", exclude_none=True)
assert serialized == raw
parsed = methods.parse_client_request("initialize", version, serialized["params"])
assert isinstance(parsed, types.InitializeRequest)
assert parsed.params.capabilities.extensions == extensions


@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS)
@pytest.mark.parametrize("capabilities", [{}, {"extensions": None}])
def test_initialize_omits_unset_server_extensions(version: str, capabilities: dict[str, Any]) -> None:
"""SDK-defined: absent or null extension advertisements remain omitted on the legacy wire."""
raw = {
"protocolVersion": version,
"capabilities": capabilities,
"serverInfo": {"name": "server", "version": "1"},
}
serialized = methods.serialize_server_result("initialize", version, raw)
assert serialized == {**raw, "capabilities": {}}
parsed = methods.parse_server_result("initialize", version, serialized)
assert isinstance(parsed, types.InitializeResult)
assert parsed.capabilities.extensions is None


def test_elicit_request_surface_accepts_loose_property_schemas():
"""Older python-sdk emits `anyOf` for `Optional` form fields; the surface gate must let it through."""
params = {
Expand Down
Loading