Merge branch 'audit/pins-server' into feature/client-auto-default

This commit is contained in:
Jeremiah Lowin 2026-07-20 16:27:59 -04:00
commit d048c7e690
No known key found for this signature in database
17 changed files with 135 additions and 50 deletions

View file

@ -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:

View file

@ -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"}
)

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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", {})

View file

@ -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

View file

@ -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"})

View file

@ -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

View file

@ -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()

View file

@ -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)