Skip to content

Commit fe8a1c7

Browse files
committed
Carry capabilities.extensions on legacy initialize handshakes
1 parent a525a3d commit fe8a1c7

8 files changed

Lines changed: 58 additions & 22 deletions

File tree

‎docs/advanced/extensions.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ 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
31-
only way the server is usable.
28+
The capability map rides `server/discover` on **2026-07-28** connections and the
29+
`initialize` result on legacy ones. A client that doesn't know an extension ignores
30+
it. Design for that: an extension *augments* a server, it must not be the only way
31+
the server is usable.
3232

3333
## Writing your own
3434

‎scripts/gen_surface_types.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
# gets its typed `$ref` stripped by `make_server_info_opaque` below.
3232
SERVER_INFO_META_PROPERTY = "io.modelcontextprotocol/serverInfo"
3333

34+
LEGACY_EXTENSIONS_FIELD = {
35+
"additionalProperties": {"additionalProperties": True, "properties": {}, "type": "object"},
36+
"description": "Optional MCP extensions that the peer supports, keyed by extension identifier.",
37+
"type": "object",
38+
}
39+
3440
# schema.ts -> schema.json renders TypeScript `number` as JSON Schema
3541
# `integer` at these sites; patch the JSON before codegen so floats validate.
3642
# Patched to `["integer", "number"]` (not bare `"number"`) so codegen emits
@@ -68,6 +74,10 @@
6874
{"additionalProperties": True, "properties": {}, "type": "object"},
6975
{"anyOf": [{"additionalProperties": True, "properties": {}, "type": "object"}, {"type": "boolean"}]},
7076
),
77+
# Extensions are negotiated through `initialize` before 2026-07-28 too (e.g. MCP Apps);
78+
# carry the 2026-07-28 field so the result sieve doesn't drop a server's acceptance.
79+
("$defs/ClientCapabilities/properties/extensions", None, LEGACY_EXTENSIONS_FIELD),
80+
("$defs/ServerCapabilities/properties/extensions", None, LEGACY_EXTENSIONS_FIELD),
7181
],
7282
"2026-07-28": [
7383
("$defs/NumberSchema/properties/default/type", "number", ["integer", "number"]),
@@ -149,6 +159,8 @@ def load_pinned() -> list[dict[str, str]]:
149159
def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> None:
150160
"""Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value.
151161
162+
An `old` of `None` asserts the key is absent, so a patch can add a property.
163+
152164
Path segments use JSON-pointer escaping (`~1` for `/`, `~0` for `~`) so keys
153165
that themselves contain a slash (the reserved `io.modelcontextprotocol/*`
154166
`_meta` keys) are addressable.
@@ -158,8 +170,8 @@ def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) ->
158170
node: Any = schema
159171
for part in parts:
160172
node = node[int(part) if part.isdigit() else part]
161-
if node[leaf] != old:
162-
raise SystemExit(f"schema patch {path}: expected {old!r}, found {node[leaf]!r}")
173+
if node.get(leaf) != old:
174+
raise SystemExit(f"schema patch {path}: expected {old!r}, found {node.get(leaf)!r}")
163175
node[leaf] = new
164176

165177

‎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+
Optional MCP extensions that the peer supports, keyed by extension identifier.
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+
Optional MCP extensions that the peer supports, keyed by extension identifier.
1142+
"""
11351143

11361144

11371145
class SetLevelRequestParams(WireModel):

‎src/mcp/server/lowlevel/server.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ def create_initialization_options(
557557
capabilities=self.get_capabilities(
558558
notification_options or NotificationOptions(),
559559
experimental_capabilities,
560-
extensions if extensions is not None else self.extensions,
560+
extensions,
561561
),
562562
instructions=self.instructions,
563563
website_url=self.website_url,

‎tests/server/mcpserver/test_extension.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,15 @@ 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."""
179+
async def test_extension_settings_advertised_on_legacy_handshake() -> None:
180+
"""SDK-defined: a legacy `initialize` handshake carries the advertised extension
181+
settings, as the modern `auto` path does."""
183182
server = MCPServer("test", extensions=[_SettingsExt()])
184183

185184
async with Client(server, mode="legacy") as client:
186-
assert client.server_capabilities.extensions is None
185+
extensions = client.server_capabilities.extensions
186+
187+
assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}})
187188

188189

189190
def test_duplicate_extension_identifier_raises() -> None:

‎tests/server/test_apps.py‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,11 @@ 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_under_server_capabilities() -> None:
87+
"""MCP Apps is negotiated through `initialize` on handshake-era versions, so a
88+
`mode='legacy'` client also sees `EXTENSION_ID` under `server_capabilities.extensions`."""
9089
async with Client(_clock_server(), mode="legacy") as client:
91-
assert client.server_capabilities.extensions is None
90+
assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}})
9291

9392

9493
async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None:

‎tests/server/test_extensions_capability.py‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,12 @@ 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."""
59+
async def test_legacy_handshake_carries_the_advertised_extensions_map() -> None:
60+
"""SDK-defined: over a legacy (`initialize`) connection the client also reads
61+
the server's advertised extension map from `server_capabilities`."""
6362
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 == snapshot({"com.example/x": {"k": 1}})
6665

6766

6867
async def test_server_accepts_capability_for_client_advertised_extension() -> None:

‎tests/types/test_methods.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,23 @@ def test_serialize_server_result_drops_2026_only_keys_at_pre_2026_versions(metho
934934
assert "cacheScope" not in sieved
935935

936936

937+
@pytest.mark.parametrize("version", PRE_2026)
938+
def test_initialize_round_trips_capability_extensions_at_pre_2026_versions(version: str):
939+
extensions = {"io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]}}
940+
capabilities = {"tools": {}, "extensions": extensions}
941+
result = {"protocolVersion": version, "capabilities": capabilities, "serverInfo": {"name": "S", "version": "1"}}
942+
sieved = methods.serialize_server_result("initialize", version, result)
943+
assert sieved["capabilities"] == capabilities
944+
parsed = methods.parse_server_result("initialize", version, sieved)
945+
assert isinstance(parsed, types.InitializeResult)
946+
assert parsed.capabilities.extensions == extensions
947+
client_info = {"name": "C", "version": "1"}
948+
params = {"protocolVersion": version, "capabilities": {"extensions": extensions}, "clientInfo": client_info}
949+
request = methods.parse_client_request("initialize", version, params)
950+
assert isinstance(request, types.InitializeRequest)
951+
assert request.params.capabilities.extensions == extensions
952+
953+
937954
def test_serialize_server_result_keeps_2026_only_keys_at_2026_07_28():
938955
dumped = MONOLITH_RESULT_FIXTURES["tools/list"].model_dump(by_alias=True, mode="json", exclude_none=True)
939956
sieved = methods.serialize_server_result("tools/list", "2026-07-28", dumped)

0 commit comments

Comments
 (0)