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
8 changes: 4 additions & 4 deletions docs/advanced/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ 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
only way the server is usable.
The capability map rides `server/discover` on **2026-07-28** connections and the
`initialize` result on legacy ones. A client that doesn't know an extension ignores
it. Design for that: an extension *augments* a server, it must not be the only way
the server is usable.

## Writing your own

Expand Down
16 changes: 14 additions & 2 deletions scripts/gen_surface_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
# gets its typed `$ref` stripped by `make_server_info_opaque` below.
SERVER_INFO_META_PROPERTY = "io.modelcontextprotocol/serverInfo"

LEGACY_EXTENSIONS_FIELD = {
"additionalProperties": {"additionalProperties": True, "properties": {}, "type": "object"},
"description": "Optional MCP extensions that the peer supports, keyed by extension identifier.",
"type": "object",
}

# schema.ts -> schema.json renders TypeScript `number` as JSON Schema
# `integer` at these sites; patch the JSON before codegen so floats validate.
# Patched to `["integer", "number"]` (not bare `"number"`) so codegen emits
Expand Down Expand Up @@ -68,6 +74,10 @@
{"additionalProperties": True, "properties": {}, "type": "object"},
{"anyOf": [{"additionalProperties": True, "properties": {}, "type": "object"}, {"type": "boolean"}]},
),
# Extensions are negotiated through `initialize` before 2026-07-28 too (e.g. MCP Apps);
# carry the 2026-07-28 field so the result sieve doesn't drop a server's acceptance.
("$defs/ClientCapabilities/properties/extensions", None, LEGACY_EXTENSIONS_FIELD),
("$defs/ServerCapabilities/properties/extensions", None, LEGACY_EXTENSIONS_FIELD),
],
"2026-07-28": [
("$defs/NumberSchema/properties/default/type", "number", ["integer", "number"]),
Expand Down Expand Up @@ -149,6 +159,8 @@ def load_pinned() -> list[dict[str, str]]:
def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> None:
"""Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value.

An `old` of `None` asserts the key is absent, so a patch can add a property.

Path segments use JSON-pointer escaping (`~1` for `/`, `~0` for `~`) so keys
that themselves contain a slash (the reserved `io.modelcontextprotocol/*`
`_meta` keys) are addressable.
Expand All @@ -158,8 +170,8 @@ def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) ->
node: Any = schema
for part in parts:
node = node[int(part) if part.isdigit() else part]
if node[leaf] != old:
raise SystemExit(f"schema patch {path}: expected {old!r}, found {node[leaf]!r}")
if node.get(leaf) != old:
raise SystemExit(f"schema patch {path}: expected {old!r}, found {node.get(leaf)!r}")
node[leaf] = new


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


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


class SetLevelRequestParams(WireModel):
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ def create_initialization_options(
capabilities=self.get_capabilities(
notification_options or NotificationOptions(),
experimental_capabilities,
extensions if extensions is not None else self.extensions,
extensions,

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.

🟡 (optional) Legacy clients are now told a server supports an extension whose only methods that server answers with METHOD_NOT_FOUND on the same connection. The legacy initialize result advertises every entry of self.extensions whatever the version, while _version_gated (src/mcp/server/mcpserver/server.py:1389) still refuses methods pinned with protocol_versions. Fix: on the legacy initialize path, omit identifiers whose contributions are all gated away from handshake versions, mirroring the client's _build_capabilities (src/mcp/client/session.py:621-628). The base hid this because the 2025 sieve dropped the map. [also at: scripts/gen_surface_types.py:80 - Legacy clients are now told a server supports an extension whose methods it refuses on that connection. The initialize result now carries every entry of Server.extensions, whatever version was negotiated.]

Why this was flagged

Trigger: an MCPServer whose extension binds only version-pinned methods, as docs_src/extensions/tutorial004.py:35-40 does with protocol_versions=frozenset({"2026-07-28"}), reached by a client with mode="legacy". _apply_extension writes self._lowlevel_server.extensions[extension.identifier] unconditionally (src/mcp/server/mcpserver/server.py:352), and get_capabilities copies the whole map with no version filter (src/mcp/server/lowlevel/server.py:634). After this change the legacy initialize result therefore carries extensions: {"com.example/search": {}}, yet gated raises METHOD_NOT_FOUND for that method (src/mcp/server/mcpserver/server.py:1389-1390). A client that feature-detects on server_capabilities.extensions now passes its check and gets -32601; on the base branch the map was dropped by the 2025 sieve, so the client saw None and skipped the call. No safeguard applies: the client side drops inactive identifiers on legacy, but the server has no counterpart.

Verification: Triggers when an MCPServer extension binds only methods pinned to a modern version and a client connects with a legacy initialize handshake. src/mcp/server/mcpserver/server.py:1389-1390: if ctx.protocol_version not in versions: raise MCPError(code=METHOD_NOT_FOUND, ...). On the base, the 2025 wire model had no extensions field, so the initialize sieve dropped the map.

Finding ID: f-f019fd81

),
instructions=self.instructions,
website_url=self.website_url,
Expand Down
11 changes: 6 additions & 5 deletions tests/server/mcpserver/test_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,15 @@ 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."""
async def test_extension_settings_advertised_on_legacy_handshake() -> None:
"""SDK-defined: a legacy `initialize` handshake carries the advertised extension
settings, as the modern `auto` path does."""
server = MCPServer("test", extensions=[_SettingsExt()])

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

assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}})


def test_duplicate_extension_identifier_raises() -> None:
Expand Down
9 changes: 4 additions & 5 deletions tests/server/test_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,11 @@ 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_under_server_capabilities() -> None:
"""MCP Apps is negotiated through `initialize` on handshake-era versions, so a
`mode='legacy'` client also sees `EXTENSION_ID` under `server_capabilities.extensions`."""
async with Client(_clock_server(), mode="legacy") as client:
assert client.server_capabilities.extensions is None
assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}})


async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None:
Expand Down
9 changes: 4 additions & 5 deletions tests/server/test_extensions_capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,12 @@ 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."""
async def test_legacy_handshake_carries_the_advertised_extensions_map() -> None:
"""SDK-defined: over a legacy (`initialize`) connection the client also reads
the server's advertised extension map from `server_capabilities`."""
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 == snapshot({"com.example/x": {"k": 1}})


async def test_server_accepts_capability_for_client_advertised_extension() -> None:
Expand Down
17 changes: 17 additions & 0 deletions tests/types/test_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,23 @@ def test_serialize_server_result_drops_2026_only_keys_at_pre_2026_versions(metho
assert "cacheScope" not in sieved


@pytest.mark.parametrize("version", PRE_2026)
def test_initialize_round_trips_capability_extensions_at_pre_2026_versions(version: str):

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): the new test test_initialize_round_trips_capability_extensions_at_pre_2026_versions has no return type hint, which AGENTS.md requires for all code. Fix: add -> None to the signature at tests/types/test_methods.py:938, as the three other tests this PR touches already have. [also at: tests/types/test_methods.py:938 - nit: AGENTS.md requires type hints for all code: the new test_initialize_round_trips_capability_extensions_at_pre_2026_versions(version: str) in tests/types/test_methods.py annotates its parameter but has no return annotation.]

Why this was flagged

No runtime input triggers this; it is a convention check on the added test at tests/types/test_methods.py:938. The signature is def test_initialize_round_trips_capability_extensions_at_pre_2026_versions(version: str): with a typed parameter but no return annotation. AGENTS.md line 50 says type hints are required for all code, and the tests this PR renames in tests/server/test_apps.py:86, tests/server/test_extensions_capability.py:59 and tests/server/mcpserver/test_extension.py:179 all carry -> None. Nothing fails at runtime and the base branch has no such test; neighbouring older tests in the same file (lines 912, 924, 954) also omit the annotation, so no tool enforces it.

Verification: AGENTS.md line 50 reads - Type hints required for all code. The diff adds, at tests/types/test_methods.py:938, def test_initialize_round_trips_capability_extensions_at_pre_2026_versions(version: str): — the parameter is annotated but there is no return annotation. No tooling enforces it, so CI will not fail.

Finding ID: f-a8ecf1a0

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): The new parametrized test has no docstring, so a later failure gives no hint whether it is a spec regression or an SDK choice. test_initialize_round_trips_capability_extensions_at_pre_2026_versions at tests/types/test_methods.py:938 goes straight into its body. Fix: add a one or two sentence docstring that states the behaviour and its provenance, for example SDK-defined: as the two flipped legacy-handshake tests in this change do.

Why this was flagged

The trigger is a future failure of test_initialize_round_trips_capability_extensions_at_pre_2026_versions (tests/types/test_methods.py:938-951). The test asserts that an extensions map survives serialize_server_result, parse_server_result and parse_client_request for initialize at every PRE_2026 version, but carries no docstring. .claude/skills/test-quality/SKILL.md:16-19, which AGENTS.md:68 requires tests to conform to, asks for "Docstrings: 1–2 sentences of behaviour, honest about provenance — spec-mandated, SDK-defined, or pinning a known gap?" because "provenance is the triage key when the test later fails". The extensions field on the 2025-11-25 capability models is an SDK-added schema patch (scripts/gen_surface_types.py:79-80), not part of the vendored schema, so the provenance is not obvious from the test. Nothing breaks at runtime and the base branch has no such test. No safeguard applies: no lint enforces test docstrings, and the undocumented neighbour at line 924 is older code the skill says not to copy.

Verification: nit — triggered whenever this test later fails and someone has to triage it; nothing breaks at runtime. The skill rule is at .claude/skills/test-quality/SKILL.md:16-19. The added test violates it: tests/types/test_methods.py:937-951 is new in this diff, and line 938 is followed directly at line 939 by extensions = {...} with no docstring.

Finding ID: f-0b6238ad

extensions = {"io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]}}
capabilities = {"tools": {}, "extensions": extensions}
result = {"protocolVersion": version, "capabilities": capabilities, "serverInfo": {"name": "S", "version": "1"}}
sieved = methods.serialize_server_result("initialize", version, result)
assert sieved["capabilities"] == capabilities
parsed = methods.parse_server_result("initialize", version, sieved)
assert isinstance(parsed, types.InitializeResult)
assert parsed.capabilities.extensions == extensions
client_info = {"name": "C", "version": "1"}
params = {"protocolVersion": version, "capabilities": {"extensions": extensions}, "clientInfo": client_info}
request = methods.parse_client_request("initialize", version, params)
assert isinstance(request, types.InitializeRequest)
assert request.params.capabilities.extensions == extensions
Comment on lines +947 to +951

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): The client-request half of the new test passes without this PR, so nothing proves the ClientCapabilities schema patch. parse_client_request returns the monolith model parsed from the raw params (src/mcp-types/mcp_types/methods.py:528). The old 2025 surface simply ignored the unknown extensions key. Fix: assert something only the patched surface model can show, e.g. validate the params with the 2025 surface InitializeRequest and check extensions survives its dump, or drop that half of the test.

Why this was flagged

The condition is always present: test_initialize_round_trips_capability_extensions_at_pre_2026_versions asserts request.params.capabilities.extensions == extensions at tests/types/test_methods.py:951. parse_client_request first calls validate_client_request, whose surface model has extra="ignore", then builds the returned object from the untouched body via _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False) (methods.py:527-528). The monolith ClientCapabilities already had extensions (src/mcp-types/mcp_types/_types.py:407), so this assertion holds on the base branch too. Removing the $defs/ClientCapabilities/properties/extensions patch (scripts/gen_surface_types.py:79) would leave the test green, so the patch is unguarded against regression. The repository's test-quality skill asks for the hollow-proof check ("which assertion proves this?") and a provenance docstring, which this test also lacks. No safeguard applies; the server-result half (sieved["capabilities"] == capabilities, line 943) is sound.

Verification: The request half of the new test (tests/types/test_methods.py:947-951) would pass on the base commit, so it does not exercise the ClientCapabilities schema patch. parse_client_request (src/mcp-types/mcp_types/methods.py:527-528) builds the returned object from the raw params, never from the surface model. On the base an unknown extensions key was ignored, not rejected.

Finding ID: f-11055b97



def test_serialize_server_result_keeps_2026_only_keys_at_2026_07_28():
dumped = MONOLITH_RESULT_FIXTURES["tools/list"].model_dump(by_alias=True, mode="json", exclude_none=True)
sieved = methods.serialize_server_result("tools/list", "2026-07-28", dumped)
Expand Down
Loading