diff --git a/fastmcp_slim/fastmcp/server/low_level.py b/fastmcp_slim/fastmcp/server/low_level.py index 927ccf9de..27d9dce27 100644 --- a/fastmcp_slim/fastmcp/server/low_level.py +++ b/fastmcp_slim/fastmcp/server/low_level.py @@ -461,13 +461,9 @@ class LowLevelServer(_Server[LifespanResultT]): # ensure we use the FastMCP notification options if notification_options is None: notification_options = self.notification_options - merged = { - **self.fastmcp.experimental_capabilities, - **(experimental_capabilities or {}), - } return super().create_initialization_options( notification_options=notification_options, - experimental_capabilities=merged or None, + experimental_capabilities=experimental_capabilities, extensions=extensions, ) @@ -483,13 +479,23 @@ class LowLevelServer(_Server[LifespanResultT]): and advertise the MCP Apps UI extension. ``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 + merged_experimental = { + **self.fastmcp.experimental_capabilities, + **(experimental_capabilities or {}), + } capabilities = super().get_capabilities( notification_options, - experimental_capabilities, + merged_experimental or None, extensions, protocol_version=protocol_version, ) diff --git a/fastmcp_slim/fastmcp/server/middleware/ping.py b/fastmcp_slim/fastmcp/server/middleware/ping.py index e81ccc377..02329ca60 100644 --- a/fastmcp_slim/fastmcp/server/middleware/ping.py +++ b/fastmcp_slim/fastmcp/server/middleware/ping.py @@ -71,6 +71,15 @@ class PingMiddleware(Middleware): ping_task.cancel() with contextlib.suppress(asyncio.CancelledError): 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) diff --git a/tests/deprecated/test_elicitation.py b/tests/deprecated/test_elicitation.py index cbb35541c..187c5404d 100644 --- a/tests/deprecated/test_elicitation.py +++ b/tests/deprecated/test_elicitation.py @@ -25,6 +25,8 @@ async def test_elicitation_none_response_type_warns_deprecation(): async def elicitation_handler(message, response_type, params, ctx): 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( mcp, mode="legacy", elicitation_handler=elicitation_handler ) as client: diff --git a/tests/server/middleware/test_initialization_middleware.py b/tests/server/middleware/test_initialization_middleware.py index fb3d4fcf0..a42ee79af 100644 --- a/tests/server/middleware/test_initialization_middleware.py +++ b/tests/server/middleware/test_initialization_middleware.py @@ -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 typing import Any @@ -401,8 +411,7 @@ async def test_state_isolation_between_streamable_http_clients(): import json # Client 1 stores its value - # Session ids belong to the handshake era, so these pin the legacy era. - async with running_server.client(mode="legacy") as client1: + async with running_server.client() as client1: result1 = await client1.call_tool( "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"] # 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( "store_and_read", {"value": "client2-value"} ) diff --git a/tests/server/middleware/test_message_visibility.py b/tests/server/middleware/test_message_visibility.py index 63dcac8f5..8145c2b06 100644 --- a/tests/server/middleware/test_message_visibility.py +++ b/tests/server/middleware/test_message_visibility.py @@ -12,6 +12,7 @@ from typing import Any import mcp_types import pytest +from mcp.shared.dispatcher import CallOptions from mcp.shared.exceptions import MCPError from mcp_types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult @@ -65,6 +66,26 @@ def _adder() -> FastMCP: 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: async def test_client_cancelled_notification_reaches_on_message(self): """A ``notifications/cancelled`` from the client is observed by @@ -116,11 +137,9 @@ class TestUnroutableAndMalformed: recorder = HookRecorder() server.add_middleware(recorder) - async with Client(server, mode="legacy") as client: + async with Client(server) as client: with pytest.raises(MCPError): - await client.session._dispatcher.send_raw_request( - "does/not/exist", {}, {} - ) + await _raw_request(client, "does/not/exist", {}) assert ("on_message", "does/not/exist") in recorder.records assert ("on_request", "does/not/exist") in recorder.records @@ -133,11 +152,9 @@ class TestUnroutableAndMalformed: recorder = HookRecorder() server.add_middleware(recorder) - async with Client(server, mode="legacy") as client: + async with Client(server) as client: with pytest.raises(MCPError): - await client.session._dispatcher.send_raw_request( - "tools/call", {"not_a_valid": "param"}, {} - ) + await _raw_request(client, "tools/call", {"not_a_valid": "param"}) assert ("on_message", "tools/call") in recorder.records assert ("on_call_tool", "tools/call") not in recorder.records @@ -216,6 +233,9 @@ class TestMessageModification: server = _adder() 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: await client.session._dispatcher.send_raw_request( "logging/setLevel", {"level": "not-a-valid-level"}, {} @@ -227,6 +247,8 @@ class TestMessageModification: recorder = HookRecorder() 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: await client.session._dispatcher.send_raw_request( "logging/setLevel", {"level": "debug"}, {} @@ -254,6 +276,8 @@ class TestMessageModification: server.add_middleware(recorder) 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: await client.session._dispatcher.send_raw_request("ping", {}, {}) @@ -284,11 +308,9 @@ class TestMessageModification: server.add_middleware(RepairAttempt()) server.add_middleware(recorder) - async with Client(server, mode="legacy") as client: + async with Client(server) as client: with pytest.raises(MCPError): - await client.session._dispatcher.send_raw_request( - "tools/call", {"not_a_valid": "param"}, {} - ) + await _raw_request(client, "tools/call", {"not_a_valid": "param"}) calls = [r for r in recorder.records if r == ("on_message", "tools/call")] assert len(calls) == 1 diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index 8518845b7..c625a7d55 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -173,10 +173,13 @@ class TestMiddlewareHooks: async def test_call_tool( 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}) - 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(hook="on_message", at_least=1) assert recording_middleware.assert_called(hook="on_request", at_least=1) @@ -299,6 +302,7 @@ class TestMiddlewareHooks: async def test_initialize( 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: await client.ping() diff --git a/tests/server/middleware/test_middleware_nested.py b/tests/server/middleware/test_middleware_nested.py index a67b78830..cc2eea6c0 100644 --- a/tests/server/middleware/test_middleware_nested.py +++ b/tests/server/middleware/test_middleware_nested.py @@ -477,7 +477,7 @@ class TestProxyServer: # proxy server will have its tools listed as well as called in order to # apply transforms and filters prior to the call. 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}) assert recording_middleware.assert_called(at_least=6) diff --git a/tests/server/middleware/test_ping.py b/tests/server/middleware/test_ping.py index 4713ca7a5..fdf3a7591 100644 --- a/tests/server/middleware/test_ping.py +++ b/tests/server/middleware/test_ping.py @@ -193,6 +193,13 @@ class TestPingMiddlewareIntegration: 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: result = await client.call_tool("hello") assert result.content[0].text == "Hello!" @@ -214,6 +221,9 @@ class TestPingMiddlewareIntegration: def hello() -> str: 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: await client.call_tool("hello") # Should have one active session diff --git a/tests/server/telemetry/test_sampling_tracing.py b/tests/server/telemetry/test_sampling_tracing.py index 59cb7afb7..ffd3f19f8 100644 --- a/tests/server/telemetry/test_sampling_tracing.py +++ b/tests/server/telemetry/test_sampling_tracing.py @@ -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 exception manually in its `except` block. A failed sampling call must 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 diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py index 1b90f7e1c..3454d93ab 100644 --- a/tests/server/telemetry/test_server_tracing.py +++ b/tests/server/telemetry/test_server_tracing.py @@ -449,6 +449,8 @@ class TestSeamServerSpan: ): 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: await client.set_logging_level("info") @@ -470,6 +472,8 @@ class TestSeamServerSpan: """A seam-spanned method must produce exactly one SERVER span, not two.""" 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: await client.set_logging_level("info") diff --git a/tests/server/test_icons.py b/tests/server/test_icons.py index b156d24fb..37ecf5e10 100644 --- a/tests/server/test_icons.py +++ b/tests/server/test_icons.py @@ -36,8 +36,8 @@ class TestServerIcons: ) # Verify that icons and website_url are passed to the underlying server - async with Client(mcp, mode="legacy") as client: - server_info = client.initialize_result.server_info + async with Client(mcp) as client: + server_info = client.session.server_info assert server_info.website_url == "https://example.com" assert server_info.icons == icons @@ -45,8 +45,8 @@ class TestServerIcons: """Test that server works without icons and websiteUrl.""" mcp = FastMCP(name="TestServer") - async with Client(mcp, mode="legacy") as client: - server_info = client.initialize_result.server_info + async with Client(mcp) as client: + server_info = client.session.server_info assert server_info.website_url is None assert server_info.icons is None @@ -290,8 +290,8 @@ class TestIconTypes: mcp = FastMCP("TestServer", icons=icons) - async with Client(mcp, mode="legacy") as client: - server_info = client.initialize_result.server_info + async with Client(mcp) as client: + server_info = client.session.server_info assert len(server_info.icons) == 3 assert server_info.icons == icons @@ -319,8 +319,8 @@ class TestIconTypes: mcp = FastMCP("TestServer", icons=icons) - async with Client(mcp, mode="legacy") as client: - server_info = client.initialize_result.server_info + async with Client(mcp) as client: + server_info = client.session.server_info 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].sizes is None @@ -336,8 +336,8 @@ class TestIconTheme: mcp = FastMCP("TestServer", icons=icons) - async with Client(mcp, mode="legacy") as client: - server_info = client.initialize_result.server_info + async with Client(mcp) as client: + server_info = client.session.server_info assert server_info.icons[0].theme == theme async def test_icon_without_theme_is_none(self): @@ -346,8 +346,8 @@ class TestIconTheme: mcp = FastMCP("TestServer", icons=icons) - async with Client(mcp, mode="legacy") as client: - server_info = client.initialize_result.server_info + async with Client(mcp) as client: + server_info = client.session.server_info assert server_info.icons[0].theme is None diff --git a/tests/server/test_session_visibility.py b/tests/server/test_session_visibility.py index 6a6f9b105..2e0d5ddc7 100644 --- a/tests/server/test_session_visibility.py +++ b/tests/server/test_session_visibility.py @@ -48,7 +48,16 @@ class RecordingMessageHandler(MessageHandler): 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): """Test that enable_components stores a rule dict in session state.""" @@ -70,7 +79,7 @@ class TestSessionVisibility: assert rules[0]["tags"] == ["finance"] return "activated" - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: result = await client.call_tool("activate_finance", {}) assert result.data == "activated" @@ -94,7 +103,7 @@ class TestSessionVisibility: assert rules[0]["tags"] == ["internal"] return "deactivated" - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: result = await client.call_tool("deactivate_internal", {}) assert result.data == "deactivated" @@ -402,7 +411,7 @@ class TestSessionVisibilityNotifications: return "activated" handler = RecordingMessageHandler() - async with Client(mcp, mode="legacy", message_handler=handler) as client: + async with Client(mcp, message_handler=handler) as client: handler.reset() await client.call_tool("activate", {}) @@ -432,7 +441,7 @@ class TestSessionVisibilityNotifications: return "deactivated" handler = RecordingMessageHandler() - async with Client(mcp, mode="legacy", message_handler=handler) as client: + async with Client(mcp, message_handler=handler) as client: handler.reset() await client.call_tool("deactivate", {}) @@ -461,7 +470,7 @@ class TestSessionVisibilityNotifications: return "cleared" handler = RecordingMessageHandler() - async with Client(mcp, mode="legacy", message_handler=handler) as client: + async with Client(mcp, message_handler=handler) as client: handler.reset() await client.call_tool("clear", {}) @@ -491,7 +500,7 @@ class TestSessionVisibilityNotifications: return "activated" handler = RecordingMessageHandler() - async with Client(mcp, mode="legacy", message_handler=handler) as client: + async with Client(mcp, message_handler=handler) as client: handler.reset() await client.call_tool("activate_tools_only", {}) diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index 46bb44e5e..e839d5689 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -229,6 +229,8 @@ async def test_task_execution_auto_populated_for_task_enabled_tool(): """A tool that runs in background.""" 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: tools_result = await client.list_tools() assert len(tools_result) == 1 diff --git a/tests/server/transforms/test_search.py b/tests/server/transforms/test_search.py index 241743ce2..9c508f893 100644 --- a/tests/server/transforms/test_search.py +++ b/tests/server/transforms/test_search.py @@ -188,6 +188,9 @@ class TestBaseTransformBehavior: await ctx.disable_components(names={"delete_record"}) 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: # Before disabling, search should find delete_record result = await client.call_tool("search_tools", {"pattern": "delete"}) diff --git a/tests/test_apps.py b/tests/test_apps.py index 6aa65afed..fe1491ed3 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -417,15 +417,15 @@ class TestExtensionAdvertisement: experimental_capabilities={"file_exchange": {"version": "0.3"}}, ) - async with Client(server, mode="legacy") as client: - experimental = client.initialize_result.capabilities.experimental or {} + async with Client(server) as client: + experimental = client.server_capabilities.experimental or {} assert experimental.get("file_exchange") == {"version": "0.3"} async def test_experimental_capabilities_default_empty(self): server = FastMCP("test") - async with Client(server, mode="legacy") as client: - experimental = client.initialize_result.capabilities.experimental + async with Client(server) as client: + experimental = client.server_capabilities.experimental assert not experimental diff --git a/tests/test_compat.py b/tests/test_compat.py index ec38cd6f6..4e2d0f6af 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -231,6 +231,7 @@ class TestClientBehaviorCompat: assert result.data == "hi" 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") async with client: result = await client.ping() diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index 8011d506f..2cde40b7f 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -753,7 +753,7 @@ class TestProxy: ) 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 result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2}) assert isinstance(result.content[0], TextContent)