Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928)
This commit is contained in:
parent
44724284b3
commit
2397319a68
1 changed files with 2256 additions and 1088 deletions
|
|
@ -1,9 +1,11 @@
|
|||
"""Tests for `Connection`.
|
||||
|
||||
`Connection` wraps an `Outbound` (the standalone stream). Its `notify` is
|
||||
best-effort (never raises); `send_raw_request` is gated on
|
||||
`has_standalone_channel`. Tested with a stub `Outbound` so we can assert wire
|
||||
shape and inject failures.
|
||||
`Connection` wraps an `Outbound` (the standalone stream). Construct it via the
|
||||
`from_envelope` / `for_loop` factories so `protocol_version` is always
|
||||
populated and `has_standalone_channel` is derived from the held outbound. Its
|
||||
`notify` is best-effort (never raises); `send_raw_request` raises
|
||||
`NoBackChannelError` structurally from the no-channel sentinel. Tested with a
|
||||
stub `Outbound` so we can assert wire shape and inject failures.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -17,6 +19,7 @@ from pydantic import BaseModel, ValidationError
|
|||
from mcp.server.connection import Connection
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
from mcp.shared.exceptions import NoBackChannelError
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ClientCapabilities,
|
||||
|
|
@ -25,7 +28,6 @@ from mcp.types import (
|
|||
ElicitationCapability,
|
||||
EmptyResult,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
ListRootsRequest,
|
||||
ListRootsResult,
|
||||
PingRequest,
|
||||
|
|
@ -37,13 +39,8 @@ from mcp.types import (
|
|||
SamplingToolsCapability,
|
||||
)
|
||||
|
||||
|
||||
def _client_params(capabilities: ClientCapabilities) -> InitializeRequestParams:
|
||||
return InitializeRequestParams(
|
||||
protocol_version=LATEST_PROTOCOL_VERSION,
|
||||
capabilities=capabilities,
|
||||
client_info=Implementation(name="t", version="0"),
|
||||
)
|
||||
_CLIENT_INFO = Implementation(name="t", version="0")
|
||||
_MODERN = MODERN_PROTOCOL_VERSIONS[0]
|
||||
|
||||
|
||||
class StubOutbound:
|
||||
|
|
@ -67,10 +64,83 @@ class StubOutbound:
|
|||
self.notifications.append((method, params))
|
||||
|
||||
|
||||
# --- factories -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_from_envelope_is_born_ready_with_no_back_channel():
|
||||
"""SDK-defined: `from_envelope` populates `protocol_version`, sets `initialized`,
|
||||
and holds the no-channel sentinel so `has_standalone_channel` derives False."""
|
||||
conn = Connection.from_envelope(_MODERN, None, None)
|
||||
assert conn.protocol_version == _MODERN
|
||||
assert conn.initialized.is_set()
|
||||
assert conn.initialize_accepted is True
|
||||
assert conn.has_standalone_channel is False
|
||||
assert conn.client_params is None
|
||||
assert conn.session_id is None
|
||||
|
||||
|
||||
def test_from_envelope_records_client_params_when_both_info_and_caps_supplied():
|
||||
"""SDK-defined: when both client info and capabilities are supplied,
|
||||
`from_envelope` synthesizes `client_params` so capability checks can run."""
|
||||
caps = ClientCapabilities(sampling=SamplingCapability())
|
||||
conn = Connection.from_envelope(_MODERN, _CLIENT_INFO, caps)
|
||||
assert conn.client_params is not None
|
||||
assert conn.client_params.client_info.name == "t"
|
||||
assert conn.client_params.capabilities.sampling is not None
|
||||
assert conn.client_params.protocol_version == _MODERN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("info", "caps"),
|
||||
[(None, ClientCapabilities()), (_CLIENT_INFO, None)],
|
||||
)
|
||||
def test_from_envelope_leaves_client_params_none_when_either_is_missing(
|
||||
info: Implementation | None, caps: ClientCapabilities | None
|
||||
):
|
||||
"""SDK-defined: `client_params` is only synthesized when both info and
|
||||
caps are present; either missing leaves it `None`."""
|
||||
conn = Connection.from_envelope(_MODERN, info, caps)
|
||||
assert conn.client_params is None
|
||||
|
||||
|
||||
def test_from_envelope_with_explicit_outbound_has_standalone_channel():
|
||||
"""SDK-defined: duplex modern transports pass an outbound; `has_standalone_channel`
|
||||
derives True since the held outbound is not the no-channel sentinel."""
|
||||
out = StubOutbound()
|
||||
conn = Connection.from_envelope(_MODERN, None, None, outbound=out)
|
||||
assert conn.has_standalone_channel is True
|
||||
assert conn.outbound is out
|
||||
assert conn.initialized.is_set()
|
||||
|
||||
|
||||
def test_for_loop_seeds_version_from_hint_or_latest_and_is_not_born_ready():
|
||||
"""SDK-defined: `for_loop` seeds `protocol_version` from the hint when given,
|
||||
else `LATEST_PROTOCOL_VERSION`; the connection awaits the initialize handshake."""
|
||||
out = StubOutbound()
|
||||
conn = Connection.for_loop(out)
|
||||
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
|
||||
assert conn.has_standalone_channel is True
|
||||
assert not conn.initialized.is_set()
|
||||
assert conn.initialize_accepted is False
|
||||
assert conn.client_params is None
|
||||
|
||||
hinted = Connection.for_loop(out, protocol_version_hint=_MODERN)
|
||||
assert hinted.protocol_version == _MODERN
|
||||
|
||||
|
||||
def test_for_loop_records_session_id_when_supplied():
|
||||
"""SDK-defined: `for_loop` stores the `session_id` kwarg verbatim."""
|
||||
conn = Connection.for_loop(StubOutbound(), session_id="sess-1")
|
||||
assert conn.session_id == "sess-1"
|
||||
|
||||
|
||||
# --- outbound channel ----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_notify_forwards_to_outbound():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.notify("notifications/message", {"level": "info", "data": "hi"})
|
||||
assert out.notifications == [("notifications/message", {"level": "info", "data": "hi"})]
|
||||
|
||||
|
|
@ -79,24 +149,24 @@ async def test_connection_notify_forwards_to_outbound():
|
|||
async def test_connection_notify_swallows_broken_stream_and_debug_logs(caplog: pytest.LogCaptureFixture):
|
||||
caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
|
||||
out = StubOutbound(raise_on_send=anyio.BrokenResourceError)
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.notify("notifications/message", {"data": "x"}) # must not raise
|
||||
assert "stream closed" in caplog.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_notify_drops_when_no_standalone_channel(caplog: pytest.LogCaptureFixture):
|
||||
"""SDK-defined: the no-channel sentinel debug-logs and drops; `notify` never raises."""
|
||||
caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=False)
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
await conn.notify("notifications/message", {"data": "x"}) # must not raise
|
||||
assert out.notifications == []
|
||||
assert "no standalone channel" in caplog.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_send_raw_request_raises_nobackchannel_when_no_standalone_channel():
|
||||
conn = Connection(StubOutbound(), has_standalone_channel=False)
|
||||
"""SDK-defined: the no-channel sentinel raises structurally; `Connection` does no pre-check."""
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
with pytest.raises(NoBackChannelError):
|
||||
await conn.send_raw_request("ping", None)
|
||||
|
||||
|
|
@ -104,7 +174,7 @@ async def test_connection_send_raw_request_raises_nobackchannel_when_no_standalo
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_send_raw_request_forwards_when_standalone_channel_present():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
result = await conn.send_raw_request("ping", None)
|
||||
assert out.requests == [("ping", None)]
|
||||
assert result == {}
|
||||
|
|
@ -113,7 +183,7 @@ async def test_connection_send_raw_request_forwards_when_standalone_channel_pres
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_send_request_with_spec_type_infers_result_type():
|
||||
out = StubOutbound(result={"roots": [{"uri": "file:///ws"}]})
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
result = await conn.send_request(ListRootsRequest())
|
||||
method, _ = out.requests[0]
|
||||
assert method == "roots/list"
|
||||
|
|
@ -126,7 +196,7 @@ async def test_connection_send_request_validates_result_alias_only():
|
|||
"""Peer results validate alias-only; a snake_case key from the wire is
|
||||
ignored as extra, not populated by Python field name."""
|
||||
snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
|
||||
conn = Connection(StubOutbound(result=snake), has_standalone_channel=True)
|
||||
conn = Connection.for_loop(StubOutbound(result=snake))
|
||||
result = await conn.send_request(CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=1)))
|
||||
assert result.stop_reason is None
|
||||
|
||||
|
|
@ -134,14 +204,14 @@ async def test_connection_send_request_validates_result_alias_only():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_send_request_with_result_type_kwarg_validates_custom_type():
|
||||
out = StubOutbound(result={})
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
result = await conn.send_request(PingRequest(), result_type=EmptyResult)
|
||||
assert isinstance(result, EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_send_request_nonconforming_result_raises_validation_error():
|
||||
conn = Connection(StubOutbound(result={"bogus": 1}), has_standalone_channel=True)
|
||||
conn = Connection.for_loop(StubOutbound(result={"bogus": 1}))
|
||||
with pytest.raises(ValidationError):
|
||||
await conn.send_request(ListRootsRequest())
|
||||
|
||||
|
|
@ -150,7 +220,7 @@ async def test_connection_send_request_nonconforming_result_raises_validation_er
|
|||
async def test_send_request_validates_the_client_result_against_the_surface_schema():
|
||||
"""A spec-method result that fails the per-version surface schema raises
|
||||
`ValidationError` even when the caller's `result_type` would accept it."""
|
||||
conn = Connection(StubOutbound(result={"roots": "nope"}), has_standalone_channel=True)
|
||||
conn = Connection.for_loop(StubOutbound(result={"roots": "nope"}))
|
||||
with pytest.raises(ValidationError):
|
||||
await conn.send_request(ListRootsRequest(), result_type=EmptyResult)
|
||||
|
||||
|
|
@ -158,8 +228,8 @@ async def test_send_request_validates_the_client_result_against_the_surface_sche
|
|||
@pytest.mark.anyio
|
||||
async def test_send_request_passes_a_spec_valid_client_result():
|
||||
"""A spec-valid client result passes the surface gate and parses to the typed model."""
|
||||
conn = Connection(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}), has_standalone_channel=True)
|
||||
conn.protocol_version = "2025-11-25"
|
||||
conn = Connection.for_loop(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
|
||||
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
|
||||
result = await conn.send_request(ListRootsRequest())
|
||||
assert isinstance(result, ListRootsResult)
|
||||
assert str(result.roots[0].uri) == "file:///ws"
|
||||
|
|
@ -178,8 +248,7 @@ class _CustomResult(BaseModel):
|
|||
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
|
||||
"""Surface row absent for the negotiated version: gate is bypassed and only
|
||||
the inferred result type validates."""
|
||||
conn = Connection(StubOutbound(result={}), has_standalone_channel=True)
|
||||
conn.protocol_version = "2026-07-28"
|
||||
conn = Connection.for_loop(StubOutbound(result={}), protocol_version_hint=_MODERN)
|
||||
result = await conn.send_request(PingRequest())
|
||||
assert isinstance(result, EmptyResult)
|
||||
|
||||
|
|
@ -187,8 +256,7 @@ async def test_send_request_skips_the_surface_gate_when_method_absent_at_version
|
|||
@pytest.mark.anyio
|
||||
async def test_send_request_with_a_custom_method_skips_the_surface_gate():
|
||||
"""Non-spec methods are not blocked by the surface gate; `result_type` validates."""
|
||||
conn = Connection(StubOutbound(result={"value": 7}), has_standalone_channel=True)
|
||||
conn.protocol_version = "2025-11-25"
|
||||
conn = Connection.for_loop(StubOutbound(result={"value": 7}))
|
||||
result = await conn.send_request(_CustomRequest(), result_type=_CustomResult)
|
||||
assert isinstance(result, _CustomResult)
|
||||
assert result.value == 7
|
||||
|
|
@ -197,7 +265,7 @@ async def test_send_request_with_a_custom_method_skips_the_surface_gate():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_ping_sends_ping_on_standalone():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.ping()
|
||||
assert out.requests == [("ping", None)]
|
||||
|
||||
|
|
@ -205,7 +273,7 @@ async def test_connection_ping_sends_ping_on_standalone():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_log_sends_logging_message_notification():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.log("info", {"k": "v"}, logger="my.logger") # pyright: ignore[reportDeprecated]
|
||||
method, params = out.notifications[0]
|
||||
assert method == "notifications/message"
|
||||
|
|
@ -218,7 +286,7 @@ async def test_connection_log_sends_logging_message_notification():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_log_with_meta_includes_meta_in_params():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.log("info", "x", meta={"traceId": "abc"}) # pyright: ignore[reportDeprecated]
|
||||
_, params = out.notifications[0]
|
||||
assert params is not None
|
||||
|
|
@ -228,7 +296,7 @@ async def test_connection_log_with_meta_includes_meta_in_params():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_list_changed_notifications_send_correct_methods():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.send_tool_list_changed()
|
||||
await conn.send_prompt_list_changed()
|
||||
await conn.send_resource_list_changed()
|
||||
|
|
@ -246,14 +314,21 @@ async def test_connection_list_changed_notifications_send_correct_methods():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_params():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.send_tool_list_changed(meta={"k": 1})
|
||||
assert out.notifications == [("notifications/tools/list_changed", {"_meta": {"k": 1}})]
|
||||
|
||||
|
||||
def test_connection_check_capability_false_before_initialized():
|
||||
conn = Connection(StubOutbound(), has_standalone_channel=True)
|
||||
# --- check_capability ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_connection_check_capability_false_when_no_client_params_recorded():
|
||||
"""SDK-defined: `check_capability` returns False when no `client_params`
|
||||
were recorded, regardless of which factory built the connection."""
|
||||
conn = Connection.for_loop(StubOutbound())
|
||||
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False
|
||||
# Same for a born-ready connection that supplied neither info nor caps.
|
||||
assert Connection.from_envelope(_MODERN, None, None).check_capability(ClientCapabilities()) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -281,6 +356,11 @@ def test_connection_check_capability_false_before_initialized():
|
|||
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
|
||||
True,
|
||||
),
|
||||
(
|
||||
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
|
||||
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
|
||||
True,
|
||||
),
|
||||
(ClientCapabilities(experimental=None), ClientCapabilities(experimental={"a": {}}), False),
|
||||
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"b": {}}), False),
|
||||
(ClientCapabilities(experimental={"a": {"x": 1}}), ClientCapabilities(experimental={"a": {"x": 2}}), False),
|
||||
|
|
@ -288,17 +368,16 @@ def test_connection_check_capability_false_before_initialized():
|
|||
],
|
||||
)
|
||||
def test_check_capability_per_field_branches(have: ClientCapabilities, want: ClientCapabilities, expected: bool):
|
||||
conn = Connection(StubOutbound(), has_standalone_channel=True)
|
||||
conn.client_params = _client_params(have)
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, _CLIENT_INFO, have)
|
||||
assert conn.check_capability(want) is expected
|
||||
|
||||
|
||||
def test_connection_check_capability_true_when_client_declares_it():
|
||||
conn = Connection(StubOutbound(), has_standalone_channel=True)
|
||||
conn.client_params = _client_params(
|
||||
ClientCapabilities(sampling=SamplingCapability(), roots=RootsCapability(list_changed=True))
|
||||
conn = Connection.from_envelope(
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
_CLIENT_INFO,
|
||||
ClientCapabilities(sampling=SamplingCapability(), roots=RootsCapability(list_changed=True)),
|
||||
)
|
||||
conn.initialized.set()
|
||||
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True
|
||||
assert conn.check_capability(ClientCapabilities(roots=RootsCapability(list_changed=True))) is True
|
||||
assert conn.check_capability(ClientCapabilities(elicitation=ElicitationCapability())) is False
|
||||
|
|
|
|||
Loading…
Reference in a new issue