mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
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:
parent
1ac166bd49
commit
96ca009220
2 changed files with 83 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue