mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Audit mode="legacy" pins in server tests and top-level stragglers
Removes pins added while making the auto-default suite pass that weren't actually testing older-protocol-only behavior, and keeps (with a stated reason) the ones that are. Along the way, fixes two real defects the audit surfaced in the modern protocol path: PingMiddleware could leak a _active_sessions entry when a connection's exit_stack closed before its keepalive task got its first scheduler turn, and FastMCP(experimental_ capabilities=...) was silently dropped from server/discover responses (it only ever reached the legacy initialize handshake).
This commit is contained in:
parent
bf352d8a4a
commit
de14ed1b7c
17 changed files with 135 additions and 50 deletions
|
|
@ -461,13 +461,9 @@ class LowLevelServer(_Server[LifespanResultT]):
|
||||||
# ensure we use the FastMCP notification options
|
# ensure we use the FastMCP notification options
|
||||||
if notification_options is None:
|
if notification_options is None:
|
||||||
notification_options = self.notification_options
|
notification_options = self.notification_options
|
||||||
merged = {
|
|
||||||
**self.fastmcp.experimental_capabilities,
|
|
||||||
**(experimental_capabilities or {}),
|
|
||||||
}
|
|
||||||
return super().create_initialization_options(
|
return super().create_initialization_options(
|
||||||
notification_options=notification_options,
|
notification_options=notification_options,
|
||||||
experimental_capabilities=merged or None,
|
experimental_capabilities=experimental_capabilities,
|
||||||
extensions=extensions,
|
extensions=extensions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -483,13 +479,23 @@ class LowLevelServer(_Server[LifespanResultT]):
|
||||||
and advertise the MCP Apps UI extension.
|
and advertise the MCP Apps UI extension.
|
||||||
|
|
||||||
``ServerCapabilities.tasks`` and ``ServerCapabilities.extensions`` are
|
``ServerCapabilities.tasks`` and ``ServerCapabilities.extensions`` are
|
||||||
real declared fields in v2, so we update them directly.
|
real declared fields in v2, so we update them directly. The
|
||||||
|
`FastMCP(experimental_capabilities=...)` merge also lives here rather
|
||||||
|
than in `create_initialization_options`: the modern `server/discover`
|
||||||
|
handler calls this directly, without going through
|
||||||
|
`create_initialization_options` at all, so merging there only reached
|
||||||
|
the handshake-era `initialize` response and silently dropped
|
||||||
|
constructor-configured experimental capabilities from `discover`.
|
||||||
"""
|
"""
|
||||||
from fastmcp.server.tasks.capabilities import get_task_capabilities
|
from fastmcp.server.tasks.capabilities import get_task_capabilities
|
||||||
|
|
||||||
|
merged_experimental = {
|
||||||
|
**self.fastmcp.experimental_capabilities,
|
||||||
|
**(experimental_capabilities or {}),
|
||||||
|
}
|
||||||
capabilities = super().get_capabilities(
|
capabilities = super().get_capabilities(
|
||||||
notification_options,
|
notification_options,
|
||||||
experimental_capabilities,
|
merged_experimental or None,
|
||||||
extensions,
|
extensions,
|
||||||
protocol_version=protocol_version,
|
protocol_version=protocol_version,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ class PingMiddleware(Middleware):
|
||||||
ping_task.cancel()
|
ping_task.cancel()
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await ping_task
|
await ping_task
|
||||||
|
# `ping_task` may be cancelled before its first
|
||||||
|
# scheduler turn (a connection can be built and torn
|
||||||
|
# down within a single request on the modern,
|
||||||
|
# per-request `Connection` path), in which case its
|
||||||
|
# body - and the `finally` in `_ping_loop` that would
|
||||||
|
# otherwise discard this entry - never runs. Discard
|
||||||
|
# unconditionally here so a connection that closes
|
||||||
|
# before the loop starts doesn't leak its entry.
|
||||||
|
self._active_sessions.discard(connection_id)
|
||||||
|
|
||||||
connection.exit_stack.push_async_callback(_cancel_ping)
|
connection.exit_stack.push_async_callback(_cancel_ping)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ async def test_elicitation_none_response_type_warns_deprecation():
|
||||||
async def elicitation_handler(message, response_type, params, ctx):
|
async def elicitation_handler(message, response_type, params, ctx):
|
||||||
return ElicitResult(action="accept", content={})
|
return ElicitResult(action="accept", content={})
|
||||||
|
|
||||||
|
# `ctx.elicit` sends a server-initiated request down the client's
|
||||||
|
# back-channel, which only the older protocol has, so this pins that era.
|
||||||
async with Client(
|
async with Client(
|
||||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||||
) as client:
|
) as client:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
"""Tests for middleware support during initialization."""
|
"""Tests for middleware support during initialization.
|
||||||
|
|
||||||
|
`on_initialize` only fires for the `initialize` handshake, which is unique to
|
||||||
|
the older protocol version; the modern version connects without it, so a
|
||||||
|
default client never triggers this hook. Most tests below pin `mode="legacy"`
|
||||||
|
for that reason. `test_session_state_persists_across_tool_calls` pins for a
|
||||||
|
different reason: it exercises `ctx.set_state`/`get_state` persisting across
|
||||||
|
multiple tool calls in the same client session, which requires the
|
||||||
|
handshake-era's persistent session (see `test_session_visibility.py` for the
|
||||||
|
same distinction applied to a different feature).
|
||||||
|
"""
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -401,8 +411,7 @@ async def test_state_isolation_between_streamable_http_clients():
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# Client 1 stores its value
|
# Client 1 stores its value
|
||||||
# Session ids belong to the handshake era, so these pin the legacy era.
|
async with running_server.client() as client1:
|
||||||
async with running_server.client(mode="legacy") as client1:
|
|
||||||
result1 = await client1.call_tool(
|
result1 = await client1.call_tool(
|
||||||
"store_and_read", {"value": "client1-value"}
|
"store_and_read", {"value": "client1-value"}
|
||||||
)
|
)
|
||||||
|
|
@ -412,7 +421,7 @@ async def test_state_isolation_between_streamable_http_clients():
|
||||||
session_id_1 = data1["session_id"]
|
session_id_1 = data1["session_id"]
|
||||||
|
|
||||||
# Client 2 should have completely isolated state
|
# Client 2 should have completely isolated state
|
||||||
async with running_server.client(mode="legacy") as client2:
|
async with running_server.client() as client2:
|
||||||
result2 = await client2.call_tool(
|
result2 = await client2.call_tool(
|
||||||
"store_and_read", {"value": "client2-value"}
|
"store_and_read", {"value": "client2-value"}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from typing import Any
|
||||||
|
|
||||||
import mcp_types
|
import mcp_types
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp.shared.dispatcher import CallOptions
|
||||||
from mcp.shared.exceptions import MCPError
|
from mcp.shared.exceptions import MCPError
|
||||||
from mcp_types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult
|
from mcp_types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult
|
||||||
|
|
||||||
|
|
@ -65,6 +66,26 @@ def _adder() -> FastMCP:
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
async def _raw_request(
|
||||||
|
client: Client, method: str, params: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send a bare JSON-RPC request through the dispatcher, bypassing the
|
||||||
|
typed `send_request` that normally stamps the outgoing envelope.
|
||||||
|
|
||||||
|
The modern protocol version requires every request's `params._meta` to
|
||||||
|
carry the protocol version, client info, and client capabilities (there is
|
||||||
|
no handshake to establish them once, up front), so a raw request built by
|
||||||
|
hand must stamp them the same way `send_request` would or the server
|
||||||
|
rejects the envelope before dispatch ever sees it.
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = {"method": method, "params": params}
|
||||||
|
opts: CallOptions = {}
|
||||||
|
client.session._stamp(data, opts)
|
||||||
|
return await client.session._dispatcher.send_raw_request(
|
||||||
|
method, data.get("params"), opts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestNotificationVisibility:
|
class TestNotificationVisibility:
|
||||||
async def test_client_cancelled_notification_reaches_on_message(self):
|
async def test_client_cancelled_notification_reaches_on_message(self):
|
||||||
"""A ``notifications/cancelled`` from the client is observed by
|
"""A ``notifications/cancelled`` from the client is observed by
|
||||||
|
|
@ -116,11 +137,9 @@ class TestUnroutableAndMalformed:
|
||||||
recorder = HookRecorder()
|
recorder = HookRecorder()
|
||||||
server.add_middleware(recorder)
|
server.add_middleware(recorder)
|
||||||
|
|
||||||
async with Client(server, mode="legacy") as client:
|
async with Client(server) as client:
|
||||||
with pytest.raises(MCPError):
|
with pytest.raises(MCPError):
|
||||||
await client.session._dispatcher.send_raw_request(
|
await _raw_request(client, "does/not/exist", {})
|
||||||
"does/not/exist", {}, {}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert ("on_message", "does/not/exist") in recorder.records
|
assert ("on_message", "does/not/exist") in recorder.records
|
||||||
assert ("on_request", "does/not/exist") in recorder.records
|
assert ("on_request", "does/not/exist") in recorder.records
|
||||||
|
|
@ -133,11 +152,9 @@ class TestUnroutableAndMalformed:
|
||||||
recorder = HookRecorder()
|
recorder = HookRecorder()
|
||||||
server.add_middleware(recorder)
|
server.add_middleware(recorder)
|
||||||
|
|
||||||
async with Client(server, mode="legacy") as client:
|
async with Client(server) as client:
|
||||||
with pytest.raises(MCPError):
|
with pytest.raises(MCPError):
|
||||||
await client.session._dispatcher.send_raw_request(
|
await _raw_request(client, "tools/call", {"not_a_valid": "param"})
|
||||||
"tools/call", {"not_a_valid": "param"}, {}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert ("on_message", "tools/call") in recorder.records
|
assert ("on_message", "tools/call") in recorder.records
|
||||||
assert ("on_call_tool", "tools/call") not in recorder.records
|
assert ("on_call_tool", "tools/call") not in recorder.records
|
||||||
|
|
@ -216,6 +233,9 @@ class TestMessageModification:
|
||||||
server = _adder()
|
server = _adder()
|
||||||
server.add_middleware(RewriteLevel())
|
server.add_middleware(RewriteLevel())
|
||||||
|
|
||||||
|
# `logging/setLevel` was dropped from the method registry in the modern
|
||||||
|
# protocol version (logging is opt-in per-request via `_meta` there),
|
||||||
|
# so exercising it needs the older protocol.
|
||||||
async with Client(server, mode="legacy") as client:
|
async with Client(server, mode="legacy") as client:
|
||||||
await client.session._dispatcher.send_raw_request(
|
await client.session._dispatcher.send_raw_request(
|
||||||
"logging/setLevel", {"level": "not-a-valid-level"}, {}
|
"logging/setLevel", {"level": "not-a-valid-level"}, {}
|
||||||
|
|
@ -227,6 +247,8 @@ class TestMessageModification:
|
||||||
recorder = HookRecorder()
|
recorder = HookRecorder()
|
||||||
server.add_middleware(recorder)
|
server.add_middleware(recorder)
|
||||||
|
|
||||||
|
# `logging/setLevel` only exists on the older protocol; see the pin
|
||||||
|
# note in `test_modified_message_reaches_sdk_dispatch` above.
|
||||||
async with Client(server, mode="legacy") as client:
|
async with Client(server, mode="legacy") as client:
|
||||||
await client.session._dispatcher.send_raw_request(
|
await client.session._dispatcher.send_raw_request(
|
||||||
"logging/setLevel", {"level": "debug"}, {}
|
"logging/setLevel", {"level": "debug"}, {}
|
||||||
|
|
@ -254,6 +276,8 @@ class TestMessageModification:
|
||||||
server.add_middleware(recorder)
|
server.add_middleware(recorder)
|
||||||
server.add_middleware(RewriteMethod())
|
server.add_middleware(RewriteMethod())
|
||||||
|
|
||||||
|
# `ping` was removed from the modern protocol version, so this pins
|
||||||
|
# the era where it's still a real method to rewrite away from.
|
||||||
async with Client(server, mode="legacy") as client:
|
async with Client(server, mode="legacy") as client:
|
||||||
await client.session._dispatcher.send_raw_request("ping", {}, {})
|
await client.session._dispatcher.send_raw_request("ping", {}, {})
|
||||||
|
|
||||||
|
|
@ -284,11 +308,9 @@ class TestMessageModification:
|
||||||
server.add_middleware(RepairAttempt())
|
server.add_middleware(RepairAttempt())
|
||||||
server.add_middleware(recorder)
|
server.add_middleware(recorder)
|
||||||
|
|
||||||
async with Client(server, mode="legacy") as client:
|
async with Client(server) as client:
|
||||||
with pytest.raises(MCPError):
|
with pytest.raises(MCPError):
|
||||||
await client.session._dispatcher.send_raw_request(
|
await _raw_request(client, "tools/call", {"not_a_valid": "param"})
|
||||||
"tools/call", {"not_a_valid": "param"}, {}
|
|
||||||
)
|
|
||||||
|
|
||||||
calls = [r for r in recorder.records if r == ("on_message", "tools/call")]
|
calls = [r for r in recorder.records if r == ("on_message", "tools/call")]
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
|
|
|
||||||
|
|
@ -173,10 +173,13 @@ class TestMiddlewareHooks:
|
||||||
async def test_call_tool(
|
async def test_call_tool(
|
||||||
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
||||||
):
|
):
|
||||||
async with Client(mcp_server, mode="legacy") as client:
|
async with Client(mcp_server) as client:
|
||||||
await client.call_tool("add", {"a": 1, "b": 2})
|
await client.call_tool("add", {"a": 1, "b": 2})
|
||||||
|
|
||||||
assert recording_middleware.assert_called(at_least=9)
|
# The floor is lower than a legacy connection's 11: the modern
|
||||||
|
# `server/discover` negotiation fires 2 generic hooks, vs. 5 for the
|
||||||
|
# older `initialize` request plus its `notifications/initialized`.
|
||||||
|
assert recording_middleware.assert_called(at_least=8)
|
||||||
assert recording_middleware.assert_called(method="tools/call", at_least=3)
|
assert recording_middleware.assert_called(method="tools/call", at_least=3)
|
||||||
assert recording_middleware.assert_called(hook="on_message", at_least=1)
|
assert recording_middleware.assert_called(hook="on_message", at_least=1)
|
||||||
assert recording_middleware.assert_called(hook="on_request", at_least=1)
|
assert recording_middleware.assert_called(hook="on_request", at_least=1)
|
||||||
|
|
@ -299,6 +302,7 @@ class TestMiddlewareHooks:
|
||||||
async def test_initialize(
|
async def test_initialize(
|
||||||
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
||||||
):
|
):
|
||||||
|
# `ping` only exists on the older protocol, so this pins that era.
|
||||||
async with Client(mcp_server, mode="legacy") as client:
|
async with Client(mcp_server, mode="legacy") as client:
|
||||||
await client.ping()
|
await client.ping()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -477,7 +477,7 @@ class TestProxyServer:
|
||||||
# proxy server will have its tools listed as well as called in order to
|
# proxy server will have its tools listed as well as called in order to
|
||||||
# apply transforms and filters prior to the call.
|
# apply transforms and filters prior to the call.
|
||||||
proxy_server = create_proxy(mcp_server, name="Proxy Server")
|
proxy_server = create_proxy(mcp_server, name="Proxy Server")
|
||||||
async with Client(proxy_server, mode="legacy") as client:
|
async with Client(proxy_server) as client:
|
||||||
await client.call_tool("add", {"a": 1, "b": 2})
|
await client.call_tool("add", {"a": 1, "b": 2})
|
||||||
|
|
||||||
assert recording_middleware.assert_called(at_least=6)
|
assert recording_middleware.assert_called(at_least=6)
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,13 @@ class TestPingMiddlewareIntegration:
|
||||||
|
|
||||||
assert len(middleware._active_sessions) == 0
|
assert len(middleware._active_sessions) == 0
|
||||||
|
|
||||||
|
# PingMiddleware keys its keepalive loop off the connection, which
|
||||||
|
# persists for the life of a handshake-era session. On the modern
|
||||||
|
# protocol version, a connection lives only for the single request
|
||||||
|
# that built it, so `_active_sessions` never holds a mid-session
|
||||||
|
# entry an outside observer can see — the register-and-clean-up
|
||||||
|
# happens entirely within one call. That per-request lifecycle is
|
||||||
|
# itself the reason this test pins the older era.
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp, mode="legacy") as client:
|
||||||
result = await client.call_tool("hello")
|
result = await client.call_tool("hello")
|
||||||
assert result.content[0].text == "Hello!"
|
assert result.content[0].text == "Hello!"
|
||||||
|
|
@ -214,6 +221,9 @@ class TestPingMiddlewareIntegration:
|
||||||
def hello() -> str:
|
def hello() -> str:
|
||||||
return "Hello!"
|
return "Hello!"
|
||||||
|
|
||||||
|
# See the pin note in `test_ping_middleware_registers_session`: a
|
||||||
|
# mid-session `_active_sessions` entry is only observable when the
|
||||||
|
# connection persists across requests, which is handshake-era only.
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp, mode="legacy") as client:
|
||||||
await client.call_tool("hello")
|
await client.call_tool("hello")
|
||||||
# Should have one active session
|
# Should have one active session
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ Regression focus: the `sampling create_message` span is created with
|
||||||
`record_exception=False, set_status_on_exception=False` and records the
|
`record_exception=False, set_status_on_exception=False` and records the
|
||||||
exception manually in its `except` block. A failed sampling call must
|
exception manually in its `except` block. A failed sampling call must
|
||||||
therefore produce exactly ONE exception event, not two.
|
therefore produce exactly ONE exception event, not two.
|
||||||
|
|
||||||
|
`ctx.sample` requires the server to send a request down to the client, which
|
||||||
|
only the older protocol's back-channel supports, so every client below pins
|
||||||
|
`mode="legacy"`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -449,6 +449,8 @@ class TestSeamServerSpan:
|
||||||
):
|
):
|
||||||
mcp = FastMCP("test-server")
|
mcp = FastMCP("test-server")
|
||||||
|
|
||||||
|
# `logging/setLevel` was dropped from the modern protocol version
|
||||||
|
# (SEP-2577), so exercising it needs the older protocol.
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp, mode="legacy") as client:
|
||||||
await client.set_logging_level("info")
|
await client.set_logging_level("info")
|
||||||
|
|
||||||
|
|
@ -470,6 +472,8 @@ class TestSeamServerSpan:
|
||||||
"""A seam-spanned method must produce exactly one SERVER span, not two."""
|
"""A seam-spanned method must produce exactly one SERVER span, not two."""
|
||||||
mcp = FastMCP("test-server")
|
mcp = FastMCP("test-server")
|
||||||
|
|
||||||
|
# `logging/setLevel` only exists on the older protocol; see the pin
|
||||||
|
# note in `test_set_logging_level_emits_seam_span` above.
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp, mode="legacy") as client:
|
||||||
await client.set_logging_level("info")
|
await client.set_logging_level("info")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ class TestServerIcons:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify that icons and website_url are passed to the underlying server
|
# Verify that icons and website_url are passed to the underlying server
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp) as client:
|
||||||
server_info = client.initialize_result.server_info
|
server_info = client.session.server_info
|
||||||
assert server_info.website_url == "https://example.com"
|
assert server_info.website_url == "https://example.com"
|
||||||
assert server_info.icons == icons
|
assert server_info.icons == icons
|
||||||
|
|
||||||
|
|
@ -45,8 +45,8 @@ class TestServerIcons:
|
||||||
"""Test that server works without icons and websiteUrl."""
|
"""Test that server works without icons and websiteUrl."""
|
||||||
mcp = FastMCP(name="TestServer")
|
mcp = FastMCP(name="TestServer")
|
||||||
|
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp) as client:
|
||||||
server_info = client.initialize_result.server_info
|
server_info = client.session.server_info
|
||||||
assert server_info.website_url is None
|
assert server_info.website_url is None
|
||||||
assert server_info.icons is None
|
assert server_info.icons is None
|
||||||
|
|
||||||
|
|
@ -290,8 +290,8 @@ class TestIconTypes:
|
||||||
|
|
||||||
mcp = FastMCP("TestServer", icons=icons)
|
mcp = FastMCP("TestServer", icons=icons)
|
||||||
|
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp) as client:
|
||||||
server_info = client.initialize_result.server_info
|
server_info = client.session.server_info
|
||||||
assert len(server_info.icons) == 3
|
assert len(server_info.icons) == 3
|
||||||
assert server_info.icons == icons
|
assert server_info.icons == icons
|
||||||
|
|
||||||
|
|
@ -319,8 +319,8 @@ class TestIconTypes:
|
||||||
|
|
||||||
mcp = FastMCP("TestServer", icons=icons)
|
mcp = FastMCP("TestServer", icons=icons)
|
||||||
|
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp) as client:
|
||||||
server_info = client.initialize_result.server_info
|
server_info = client.session.server_info
|
||||||
assert server_info.icons[0].src == "https://example.com/icon.png"
|
assert server_info.icons[0].src == "https://example.com/icon.png"
|
||||||
assert server_info.icons[0].mime_type is None
|
assert server_info.icons[0].mime_type is None
|
||||||
assert server_info.icons[0].sizes is None
|
assert server_info.icons[0].sizes is None
|
||||||
|
|
@ -336,8 +336,8 @@ class TestIconTheme:
|
||||||
|
|
||||||
mcp = FastMCP("TestServer", icons=icons)
|
mcp = FastMCP("TestServer", icons=icons)
|
||||||
|
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp) as client:
|
||||||
server_info = client.initialize_result.server_info
|
server_info = client.session.server_info
|
||||||
assert server_info.icons[0].theme == theme
|
assert server_info.icons[0].theme == theme
|
||||||
|
|
||||||
async def test_icon_without_theme_is_none(self):
|
async def test_icon_without_theme_is_none(self):
|
||||||
|
|
@ -346,8 +346,8 @@ class TestIconTheme:
|
||||||
|
|
||||||
mcp = FastMCP("TestServer", icons=icons)
|
mcp = FastMCP("TestServer", icons=icons)
|
||||||
|
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp) as client:
|
||||||
server_info = client.initialize_result.server_info
|
server_info = client.session.server_info
|
||||||
assert server_info.icons[0].theme is None
|
assert server_info.icons[0].theme is None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,16 @@ class RecordingMessageHandler(MessageHandler):
|
||||||
|
|
||||||
|
|
||||||
class TestSessionVisibility:
|
class TestSessionVisibility:
|
||||||
"""Test session-specific visibility control via Context."""
|
"""Test session-specific visibility control via Context.
|
||||||
|
|
||||||
|
Session-scoped visibility rules are stored under `ctx.session_id`. The
|
||||||
|
modern protocol version is stateless: each request gets a fresh
|
||||||
|
connection identity, so a rule set in one request is gone by the next.
|
||||||
|
Tests that only check state within a single tool call are era-neutral
|
||||||
|
and stay unpinned; tests that activate a rule in one request and observe
|
||||||
|
its effect in a later request are pinned to the handshake era, where the
|
||||||
|
rule's persistence is the very thing under test.
|
||||||
|
"""
|
||||||
|
|
||||||
async def test_enable_components_stores_rule_dict(self):
|
async def test_enable_components_stores_rule_dict(self):
|
||||||
"""Test that enable_components stores a rule dict in session state."""
|
"""Test that enable_components stores a rule dict in session state."""
|
||||||
|
|
@ -70,7 +79,7 @@ class TestSessionVisibility:
|
||||||
assert rules[0]["tags"] == ["finance"]
|
assert rules[0]["tags"] == ["finance"]
|
||||||
return "activated"
|
return "activated"
|
||||||
|
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp) as client:
|
||||||
result = await client.call_tool("activate_finance", {})
|
result = await client.call_tool("activate_finance", {})
|
||||||
assert result.data == "activated"
|
assert result.data == "activated"
|
||||||
|
|
||||||
|
|
@ -94,7 +103,7 @@ class TestSessionVisibility:
|
||||||
assert rules[0]["tags"] == ["internal"]
|
assert rules[0]["tags"] == ["internal"]
|
||||||
return "deactivated"
|
return "deactivated"
|
||||||
|
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp) as client:
|
||||||
result = await client.call_tool("deactivate_internal", {})
|
result = await client.call_tool("deactivate_internal", {})
|
||||||
assert result.data == "deactivated"
|
assert result.data == "deactivated"
|
||||||
|
|
||||||
|
|
@ -402,7 +411,7 @@ class TestSessionVisibilityNotifications:
|
||||||
return "activated"
|
return "activated"
|
||||||
|
|
||||||
handler = RecordingMessageHandler()
|
handler = RecordingMessageHandler()
|
||||||
async with Client(mcp, mode="legacy", message_handler=handler) as client:
|
async with Client(mcp, message_handler=handler) as client:
|
||||||
handler.reset()
|
handler.reset()
|
||||||
await client.call_tool("activate", {})
|
await client.call_tool("activate", {})
|
||||||
|
|
||||||
|
|
@ -432,7 +441,7 @@ class TestSessionVisibilityNotifications:
|
||||||
return "deactivated"
|
return "deactivated"
|
||||||
|
|
||||||
handler = RecordingMessageHandler()
|
handler = RecordingMessageHandler()
|
||||||
async with Client(mcp, mode="legacy", message_handler=handler) as client:
|
async with Client(mcp, message_handler=handler) as client:
|
||||||
handler.reset()
|
handler.reset()
|
||||||
await client.call_tool("deactivate", {})
|
await client.call_tool("deactivate", {})
|
||||||
|
|
||||||
|
|
@ -461,7 +470,7 @@ class TestSessionVisibilityNotifications:
|
||||||
return "cleared"
|
return "cleared"
|
||||||
|
|
||||||
handler = RecordingMessageHandler()
|
handler = RecordingMessageHandler()
|
||||||
async with Client(mcp, mode="legacy", message_handler=handler) as client:
|
async with Client(mcp, message_handler=handler) as client:
|
||||||
handler.reset()
|
handler.reset()
|
||||||
await client.call_tool("clear", {})
|
await client.call_tool("clear", {})
|
||||||
|
|
||||||
|
|
@ -491,7 +500,7 @@ class TestSessionVisibilityNotifications:
|
||||||
return "activated"
|
return "activated"
|
||||||
|
|
||||||
handler = RecordingMessageHandler()
|
handler = RecordingMessageHandler()
|
||||||
async with Client(mcp, mode="legacy", message_handler=handler) as client:
|
async with Client(mcp, message_handler=handler) as client:
|
||||||
handler.reset()
|
handler.reset()
|
||||||
await client.call_tool("activate_tools_only", {})
|
await client.call_tool("activate_tools_only", {})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,8 @@ async def test_task_execution_auto_populated_for_task_enabled_tool():
|
||||||
"""A tool that runs in background."""
|
"""A tool that runs in background."""
|
||||||
return f"Processed: {data}"
|
return f"Processed: {data}"
|
||||||
|
|
||||||
|
# `execution.task_support` (SEP-1686) is advertised in the handshake-era
|
||||||
|
# tool listing only; the modern listing omits it.
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp, mode="legacy") as client:
|
||||||
tools_result = await client.list_tools()
|
tools_result = await client.list_tools()
|
||||||
assert len(tools_result) == 1
|
assert len(tools_result) == 1
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,9 @@ class TestBaseTransformBehavior:
|
||||||
await ctx.disable_components(names={"delete_record"})
|
await ctx.disable_components(names={"delete_record"})
|
||||||
return "disabled"
|
return "disabled"
|
||||||
|
|
||||||
|
# Session visibility rules only persist across requests on the
|
||||||
|
# handshake era (see `test_session_visibility.py`); the modern
|
||||||
|
# protocol version has no session for them to persist in.
|
||||||
async with Client(mcp, mode="legacy") as client:
|
async with Client(mcp, mode="legacy") as client:
|
||||||
# Before disabling, search should find delete_record
|
# Before disabling, search should find delete_record
|
||||||
result = await client.call_tool("search_tools", {"pattern": "delete"})
|
result = await client.call_tool("search_tools", {"pattern": "delete"})
|
||||||
|
|
|
||||||
|
|
@ -417,15 +417,15 @@ class TestExtensionAdvertisement:
|
||||||
experimental_capabilities={"file_exchange": {"version": "0.3"}},
|
experimental_capabilities={"file_exchange": {"version": "0.3"}},
|
||||||
)
|
)
|
||||||
|
|
||||||
async with Client(server, mode="legacy") as client:
|
async with Client(server) as client:
|
||||||
experimental = client.initialize_result.capabilities.experimental or {}
|
experimental = client.server_capabilities.experimental or {}
|
||||||
assert experimental.get("file_exchange") == {"version": "0.3"}
|
assert experimental.get("file_exchange") == {"version": "0.3"}
|
||||||
|
|
||||||
async def test_experimental_capabilities_default_empty(self):
|
async def test_experimental_capabilities_default_empty(self):
|
||||||
server = FastMCP("test")
|
server = FastMCP("test")
|
||||||
|
|
||||||
async with Client(server, mode="legacy") as client:
|
async with Client(server) as client:
|
||||||
experimental = client.initialize_result.capabilities.experimental
|
experimental = client.server_capabilities.experimental
|
||||||
assert not experimental
|
assert not experimental
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ class TestClientBehaviorCompat:
|
||||||
assert result.data == "hi"
|
assert result.data == "hi"
|
||||||
|
|
||||||
async def test_ping_returns_bool(self, server):
|
async def test_ping_returns_bool(self, server):
|
||||||
|
# `ping` only exists on the older protocol, so this pins that era.
|
||||||
client = Client(transport=FastMCPTransport(server), mode="legacy")
|
client = Client(transport=FastMCPTransport(server), mode="legacy")
|
||||||
async with client:
|
async with client:
|
||||||
result = await client.ping()
|
result = await client.ping()
|
||||||
|
|
|
||||||
|
|
@ -753,7 +753,7 @@ class TestProxy:
|
||||||
)
|
)
|
||||||
proxy_server.add_tool(new_add_tool)
|
proxy_server.add_tool(new_add_tool)
|
||||||
|
|
||||||
async with Client(proxy_server, mode="legacy") as client:
|
async with Client(proxy_server) as client:
|
||||||
# The tool should be registered with its transformed name
|
# The tool should be registered with its transformed name
|
||||||
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
|
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
|
||||||
assert isinstance(result.content[0], TextContent)
|
assert isinstance(result.content[0], TextContent)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue