Skip to content

Commit 75f60cc

Browse files
committed
Fix legacy initialize extension capability advertisements
Preserve capabilities.extensions in both legacy wire models and generated schemas, with round-trip coverage for every supported handshake version. Update extension documentation and affected initialization snapshots. Fixes #3773 AI-assisted implementation and validation with Codex.
1 parent a525a3d commit 75f60cc

10 files changed

Lines changed: 116 additions & 28 deletions

File tree

‎docs/advanced/extensions.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ Done. The server now advertises `io.modelcontextprotocol/ui` under
2525
Extensions are fixed at construction. There is no `add_extension` to call later:
2626
a server's capability map should not change while clients are connected to it.
2727

28-
The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy
29-
`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see
30-
the extension. Design for that: an extension *augments* a server, it must not be the
28+
The capability map rides `server/discover` on **2026-07-28** connections and the
29+
`initialize` handshake on **2025-11-25 and earlier** connections. Both client and
30+
server advertisements preserve `capabilities.extensions`, including extension
31+
settings, on every supported legacy version. Peers that do not support an extension
32+
can ignore its advertisement: an extension *augments* a server, it must not be the
3133
only way the server is usable.
3234

3335
## Writing your own

‎scripts/gen_surface_types.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
Runs `datamodel-code-generator` over each `schema/PINNED.json` entry and
44
writes the result to `src/mcp-types/mcp_types/_v<version>/__init__.py` (the
55
underscore marks these as internal validators, not public API) with only
6-
the fixes the raw output needs: a small JSON pre-patch for the known
7-
`number`-as-`integer` schema.json defect, a header, full URLs for the spec's
6+
the fixes the raw output needs: JSON pre-patches for schema defects and legacy
7+
extension advertisements, a header, full URLs for the spec's
88
site-absolute doc links, plain type aliases, and per-version epilogue aliases. Run with
99
`uv run --frozen --group codegen python scripts/gen_surface_types.py [--check]`.
1010
"""
@@ -239,6 +239,17 @@ def build(entry: dict[str, str]) -> str:
239239
version = entry["protocol_version"]
240240
schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text(encoding="utf-8"))
241241
patch_schema(schema, SCHEMA_PATCHES.get(version, []))
242+
if version == "2025-11-25":
243+
# Extensions predate their formal 2026 field; legacy peers negotiate them
244+
# through initialize (modelcontextprotocol/modelcontextprotocol#3364).
245+
for role in ("Client", "Server"):
246+
properties = schema["$defs"][f"{role}Capabilities"]["properties"]
247+
assert "extensions" not in properties
248+
properties["extensions"] = {
249+
"type": "object",
250+
"additionalProperties": {"type": "object", "additionalProperties": True},
251+
"description": f"MCP extensions that the {role.lower()} supports, including on legacy connections.",
252+
}
242253
make_server_info_opaque(schema)
243254
if "JSONValue" in schema["$defs"]:
244255
# A single recursive alias avoids mutually recursive alias evaluation in type checkers.

‎src/mcp-types/mcp_types/_types.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ class ClientCapabilities(MCPModel):
405405
roots: RootsCapability | None = None
406406
"""Present if the client supports listing roots."""
407407
extensions: dict[str, dict[str, Any]] | None = None
408-
"""MCP extensions the client supports (2026-07-28). Keys are extension
408+
"""MCP extensions the client supports, including on legacy connections. Keys are extension
409409
identifiers; values are per-extension settings (empty object = no settings)."""
410410
tasks: ClientTasksCapability | None = None
411411
"""Present if the client supports task-augmented requests (2025-11-25 only)."""
@@ -505,7 +505,7 @@ class ServerCapabilities(MCPModel):
505505
"""Present if the server offers autocompletion suggestions for prompts and resources."""
506506

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

511511
tasks: ServerTasksCapability | None = None

‎src/mcp-types/mcp_types/_v2025_11_25/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ class ClientCapabilities(WireModel):
218218
"""
219219
Present if the client supports task-augmented requests.
220220
"""
221+
extensions: dict[str, dict[str, Any]] | None = None
222+
"""
223+
MCP extensions that the client supports, including on legacy connections.
224+
"""
221225

222226

223227
class Argument(WireModel):
@@ -1132,6 +1136,10 @@ class ServerCapabilities(WireModel):
11321136
"""
11331137
Present if the server offers any tools to call.
11341138
"""
1139+
extensions: dict[str, dict[str, Any]] | None = None
1140+
"""
1141+
MCP extensions that the server supports, including on legacy connections.
1142+
"""
11351143

11361144

11371145
class SetLevelRequestParams(WireModel):

‎tests/client/test_client.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ async def test_client_is_initialized(app: MCPServer):
117117
prompts=PromptsCapability(list_changed=False),
118118
resources=ResourcesCapability(subscribe=False, list_changed=False),
119119
tools=ToolsCapability(list_changed=False),
120+
extensions={},
120121
)
121122
)
122123
assert client.server_info is not None

‎tests/interaction/lowlevel/test_initialize.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar
147147
resources=ResourcesCapability(subscribe=True, list_changed=False),
148148
tools=ToolsCapability(list_changed=False),
149149
completions=CompletionsCapability(),
150+
extensions={},
150151
)
151152
)
152153

@@ -157,7 +158,7 @@ async def test_initialize_minimal_server_advertises_no_capabilities(connect: Con
157158
async with connect(Server("bare")) as client:
158159
capabilities = client.server_capabilities
159160

160-
assert capabilities == snapshot(ServerCapabilities())
161+
assert capabilities == snapshot(ServerCapabilities(extensions={}))
161162

162163

163164
@requirement("lifecycle:initialize:client-info")

‎tests/server/mcpserver/test_extension.py‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,13 @@ async def test_extension_settings_advertised_under_server_capabilities() -> None
176176
assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}})
177177

178178

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

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

188187

189188
def test_duplicate_extension_identifier_raises() -> None:

‎tests/server/test_apps.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,10 @@ async def test_auto_mode_carries_apps_extension_under_server_capabilities() -> N
8383
assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}})
8484

8585

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

9391

9492
async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None:

‎tests/server/test_extensions_capability.py‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,16 @@ async def test_modern_connection_carries_the_advertised_extensions_map() -> None
5656
assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}})
5757

5858

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

6766

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

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

8989
assert supported == [True]

‎tests/types/test_methods.py‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import pydantic
1212
import pytest
1313
from mcp_types import methods
14-
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS
14+
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, KNOWN_PROTOCOL_VERSIONS
1515
from pydantic import BaseModel
1616

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

472472

473+
@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS)
474+
@pytest.mark.parametrize(
475+
"extensions",
476+
[
477+
{},
478+
{"io.modelcontextprotocol/ui": {}},
479+
{"com.example/settings": {"nested": [None, True, 1, 1.5, "text", {"child": [False]}]}},
480+
],
481+
)
482+
def test_initialize_round_trip_preserves_server_extensions(version: str, extensions: dict[str, dict[str, Any]]) -> None:
483+
"""SDK compatibility: legacy initialize responses carry extension advertisements, including opaque settings."""
484+
raw = {
485+
"protocolVersion": version,
486+
"capabilities": {"tools": {}, "extensions": extensions},
487+
"serverInfo": {"name": "server", "version": "1"},
488+
}
489+
serialized = methods.serialize_server_result("initialize", version, raw)
490+
assert serialized == raw
491+
parsed = methods.parse_server_result("initialize", version, serialized)
492+
assert isinstance(parsed, types.InitializeResult)
493+
assert parsed.capabilities.extensions == extensions
494+
495+
496+
@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS)
497+
@pytest.mark.parametrize(
498+
"extensions",
499+
[
500+
{},
501+
{"io.modelcontextprotocol/ui": {}},
502+
{"com.example/settings": {"nested": [None, True, 1, 1.5, "text", {"child": [False]}]}},
503+
],
504+
)
505+
def test_initialize_round_trip_preserves_client_extensions(version: str, extensions: dict[str, dict[str, Any]]) -> None:
506+
"""SDK compatibility: the legacy request wire model preserves the same extension settings as the public parser."""
507+
raw = {
508+
"jsonrpc": "2.0",
509+
"id": 1,
510+
"method": "initialize",
511+
"params": {
512+
"protocolVersion": version,
513+
"capabilities": {"extensions": extensions},
514+
"clientInfo": {"name": "client", "version": "1"},
515+
},
516+
}
517+
surface = methods.CLIENT_REQUESTS[("initialize", version)]
518+
serialized = surface.model_validate(raw).model_dump(by_alias=True, mode="json", exclude_none=True)
519+
assert serialized == raw
520+
parsed = methods.parse_client_request("initialize", version, serialized["params"])
521+
assert isinstance(parsed, types.InitializeRequest)
522+
assert parsed.params.capabilities.extensions == extensions
523+
524+
525+
@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS)
526+
@pytest.mark.parametrize("capabilities", [{}, {"extensions": None}])
527+
def test_initialize_omits_unset_server_extensions(version: str, capabilities: dict[str, Any]) -> None:
528+
"""SDK-defined: absent or null extension advertisements remain omitted on the legacy wire."""
529+
raw = {
530+
"protocolVersion": version,
531+
"capabilities": capabilities,
532+
"serverInfo": {"name": "server", "version": "1"},
533+
}
534+
serialized = methods.serialize_server_result("initialize", version, raw)
535+
assert serialized == {**raw, "capabilities": {}}
536+
parsed = methods.parse_server_result("initialize", version, serialized)
537+
assert isinstance(parsed, types.InitializeResult)
538+
assert parsed.capabilities.extensions is None
539+
540+
473541
def test_elicit_request_surface_accepts_loose_property_schemas():
474542
"""Older python-sdk emits `anyOf` for `Optional` form fields; the surface gate must let it through."""
475543
params = {

0 commit comments

Comments
 (0)