diff --git a/fastmcp_slim/fastmcp/server/low_level.py b/fastmcp_slim/fastmcp/server/low_level.py index df3beffaf..3e020db54 100644 --- a/fastmcp_slim/fastmcp/server/low_level.py +++ b/fastmcp_slim/fastmcp/server/low_level.py @@ -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 diff --git a/tests/test_apps.py b/tests/test_apps.py index a0a65e91d..dac7276cc 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -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 # ---------------------------------------------------------------------------