Read the real extensions field in client_supports_extension

SDK v2 declares extensions as a real field on ClientCapabilities, so a
client sending ClientCapabilities(extensions={...}) populates the field,
not model_extra. Read caps.extensions first and fall back to model_extra
only for legacy-serialized clients.
This commit is contained in:
Jeremiah Lowin 2026-07-06 11:44:38 -04:00
commit 96ca009220
No known key found for this signature in database
2 changed files with 83 additions and 5 deletions

View file

@ -38,9 +38,14 @@ logger = get_logger(__name__)
def client_supports_extension(session: ServerSession, extension_id: str) -> bool:
"""Check whether the connected client supports a given MCP extension.
Inspects the ``extensions`` extra field on ``ClientCapabilities`` sent by
the client during initialization. In v2 the client's initialize params are
Inspects the ``extensions`` capability on ``ClientCapabilities`` sent by the
client during initialization. In v2 the client's initialize params are
reachable via ``session.client_params``.
SDK v2 declares ``extensions`` as a real field on ``ClientCapabilities``, so
a client sending ``ClientCapabilities(extensions={...})`` populates the field
directly. We read that field first and fall back to ``model_extra`` only for
legacy-serialized clients that carried ``extensions`` as an extra key.
"""
client_params = session.client_params
if client_params is None:
@ -48,9 +53,12 @@ def client_supports_extension(session: ServerSession, extension_id: str) -> bool
caps = client_params.capabilities
if caps is None:
return False
# ClientCapabilities uses extra="allow" — extensions is an extra field
extras = caps.model_extra or {}
extensions: dict[str, Any] | None = extras.get("extensions")
extensions: dict[str, Any] | None = caps.extensions
if extensions is None:
# Legacy fallback: clients that serialized `extensions` as an extra key
# (ClientCapabilities uses extra="allow") rather than the real field.
extras = caps.model_extra or {}
extensions = extras.get("extensions")
if not extensions:
return False
return extension_id in extensions

View file

@ -6,9 +6,15 @@ extension negotiation, and the ``Context.client_supports_extension`` method.
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from mcp_types import (
ClientCapabilities,
Implementation,
InitializeRequestParams,
)
from fastmcp import Client, FastMCP
from fastmcp.apps import (
@ -20,6 +26,7 @@ from fastmcp.apps import (
app_config_to_meta_dict,
)
from fastmcp.server.context import Context
from fastmcp.server.low_level import client_supports_extension
# ---------------------------------------------------------------------------
# Model serialization
@ -434,6 +441,69 @@ class TestContextClientSupportsExtension:
assert ctx.client_supports_extension(UI_EXTENSION_ID) is False
class TestClientSupportsExtension:
"""Tests for the low-level ``client_supports_extension`` helper.
SDK v2 declares ``extensions`` as a real field on ``ClientCapabilities``, so
a client sending ``ClientCapabilities(extensions={...})`` populates the field
directly (``model_extra`` stays ``None``). The helper must read the real
field, not only ``model_extra``.
"""
@staticmethod
def _session_with_capabilities(
capabilities: ClientCapabilities | None,
) -> Any:
params: InitializeRequestParams | None = None
if capabilities is not None:
params = InitializeRequestParams(
protocol_version="2026-07-28",
capabilities=capabilities,
client_info=Implementation(name="test-client", version="1.0"),
)
return SimpleNamespace(client_params=params)
def test_real_extensions_field(self):
"""A client that sets the real `extensions` field is detected."""
caps = ClientCapabilities(extensions={UI_EXTENSION_ID: {}})
# Guard: the regression this covers is the field being populated while
# model_extra stays empty.
assert caps.model_extra in (None, {})
session = self._session_with_capabilities(caps)
assert client_supports_extension(session, UI_EXTENSION_ID) is True
def test_real_extensions_field_without_target_extension(self):
caps = ClientCapabilities(extensions={"other/extension": {}})
session = self._session_with_capabilities(caps)
assert client_supports_extension(session, UI_EXTENSION_ID) is False
def test_legacy_model_extra_fallback(self):
"""Defensive fallback: capabilities whose real `extensions` field is None
but which carry `extensions` in `model_extra` are still detected.
SDK v2 always routes `extensions` to the real field, so this branch is
only reachable by a capabilities object serialized under an older schema;
we exercise it with a stand-in that mimics that shape.
"""
fake_caps = SimpleNamespace(
extensions=None,
model_extra={"extensions": {UI_EXTENSION_ID: {}}},
)
session: Any = SimpleNamespace(
client_params=SimpleNamespace(capabilities=fake_caps)
)
assert client_supports_extension(session, UI_EXTENSION_ID) is True
def test_no_extensions(self):
caps = ClientCapabilities()
session = self._session_with_capabilities(caps)
assert client_supports_extension(session, UI_EXTENSION_ID) is False
def test_no_capabilities(self):
session = self._session_with_capabilities(None)
assert client_supports_extension(session, UI_EXTENSION_ID) is False
# ---------------------------------------------------------------------------
# Integration — full client↔server round-trip
# ---------------------------------------------------------------------------