Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928)

This commit is contained in:
Max 2026-06-21 19:34:17 +01:00 • committed by GitHub
commit 2397319a68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2256 additions and 1088 deletions

View file

@ -0,0 +1,151 @@
"""Direct-handler tests for the auto-derived `server/discover` handler.
These call the registered handler via the public `Server.get_request_handler`
accessor without spinning up a `ServerRunner` or any transport, so they verify
the handler's contract in isolation from the dispatch pipeline.
"""
import importlib.metadata
from typing import Any, cast
import pytest
from mcp import types
from mcp.server import Server, ServerRequestContext
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
# `Server._handle_discover` ignores its `ctx` argument entirely (it derives the
# result from server state), so a sentinel keeps the call site type-correct
# without dragging session machinery into a unit test.
_UNUSED_CTX = cast("ServerRequestContext[Any]", None)
async def _discover(server: Server[Any]) -> types.DiscoverResult:
entry = server.get_request_handler("server/discover")
assert entry is not None
result = await entry.handler(_UNUSED_CTX, types.RequestParams())
assert isinstance(result, types.DiscoverResult)
return result
def test_registered_by_default() -> None:
"""SDK-defined: a bare `Server` registers a `server/discover` handler out of
the box, typed for the base `RequestParams`."""
server = Server("test-server")
entry = server.get_request_handler("server/discover")
assert entry is not None
assert entry.params_type is types.RequestParams
@pytest.mark.anyio
async def test_supported_versions_is_modern_set() -> None:
"""`supportedVersions` is exactly the modern envelope set, not the full
legacy-compat list (D-008)."""
result = await _discover(Server("test-server"))
assert result.supported_versions == list(MODERN_PROTOCOL_VERSIONS)
@pytest.mark.anyio
async def test_server_info_reflects_constructor_fields() -> None:
"""SDK-defined: `serverInfo` is built field-for-field from the `Server`
constructor arguments."""
icons = [types.Icon(src="https://example.test/icon.png")]
server = Server(
"info-server",
version="9.9.9",
title="Info Server",
description="A server for testing discover.",
website_url="https://example.test",
icons=icons,
)
result = await _discover(server)
assert result.server_info == types.Implementation(
name="info-server",
version="9.9.9",
title="Info Server",
description="A server for testing discover.",
website_url="https://example.test",
icons=icons,
)
@pytest.mark.anyio
async def test_server_info_version_falls_back_to_package() -> None:
"""SDK-defined: when no explicit version is supplied, `serverInfo.version`
falls back to the installed `mcp` package version."""
result = await _discover(Server("unversioned"))
assert result.server_info.version == importlib.metadata.version("mcp")
@pytest.mark.anyio
async def test_instructions_threaded_through() -> None:
"""SDK-defined: the `instructions` constructor argument is passed through
verbatim, defaulting to `None` when omitted."""
server = Server("inst-server", instructions="Read the docs first.")
result = await _discover(server)
assert result.instructions == "Read the docs first."
bare = await _discover(Server("bare"))
assert bare.instructions is None
@pytest.mark.anyio
async def test_capabilities_derived_from_registered_handlers() -> None:
"""SDK-defined: capabilities are computed at handler call time from the
live registry, so post-construction `add_request_handler` calls are
reflected."""
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
raise NotImplementedError
async def list_prompts(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
raise NotImplementedError
server = Server("cap-server", on_list_tools=list_tools)
before = await _discover(server)
assert before.capabilities.tools is not None
assert before.capabilities.prompts is None
server.add_request_handler("prompts/list", types.PaginatedRequestParams, list_prompts)
after = await _discover(server)
assert after.capabilities.tools is not None
assert after.capabilities.prompts is not None
@pytest.mark.anyio
async def test_discover_result_defaults_to_immediately_stale_private_cache() -> None:
"""SDK-defined: `DiscoverResult` is cacheable; the auto-derived handler
relies on the model defaults (immediately-stale, private)."""
result = await _discover(Server("cache-server"))
assert result.ttl_ms == 0
assert result.cache_scope == "private"
@pytest.mark.anyio
async def test_overridable_via_add_request_handler() -> None:
"""SDK-defined: a custom `server/discover` handler registered via
`add_request_handler` replaces the auto-derived default wholesale."""
server = Server("custom-server", version="1.0.0")
custom = types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="custom-server", version="1.0.0"),
instructions="overridden",
ttl_ms=60_000,
cache_scope="public",
)
async def custom_discover(
ctx: ServerRequestContext[Any], params: types.RequestParams | None
) -> types.DiscoverResult:
return custom
server.add_request_handler("server/discover", types.RequestParams, custom_discover)
result = await _discover(server)
assert result is custom

View file

@ -1,5 +1,9 @@
from mcp.server.mcpserver import Context
import pytest
from mcp import Client, types
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.tools.base import Tool
from mcp.shared.exceptions import MCPError
def test_context_detected_in_union_annotation():
@ -8,3 +12,45 @@ def test_context_detected_in_union_annotation():
tool = Tool.from_function(my_tool)
assert tool.context_kwarg == "ctx"
@pytest.mark.anyio
async def test_mcperror_raised_from_a_tool_surfaces_as_a_top_level_jsonrpc_error_with_code_and_data_intact():
"""SDK-defined: ``MCPError`` carries JSON-RPC ``ErrorData(code, message, data)``
and means "respond with a protocol error". The tool wrapper re-raises it so
the kernel writes a top-level JSON-RPC error - ``code`` and ``data`` survive
the round-trip rather than being flattened into ``CallToolResult(isError=True)``."""
mcp = MCPServer(name="srv")
@mcp.tool()
async def needs_sampling() -> str:
raise MCPError(
types.MISSING_REQUIRED_CLIENT_CAPABILITY,
"sampling capability required",
data={"requiredCapabilities": ["sampling"]},
)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("needs_sampling", {})
assert exc_info.value.error.code == types.MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == {"requiredCapabilities": ["sampling"]}
@pytest.mark.anyio
async def test_non_mcperror_exception_raised_from_a_tool_is_wrapped_as_an_is_error_result():
"""SDK-defined: ordinary exceptions from a tool body are execution failures
the LLM should see, so they become ``CallToolResult(isError=True)`` rather
than a protocol-level JSON-RPC error. Pins the other arm of the same branch."""
mcp = MCPServer(name="srv")
@mcp.tool()
async def boom() -> str:
raise RuntimeError("execution failure")
async with Client(mcp) as client:
result = await client.call_tool("boom", {})
assert isinstance(result, types.CallToolResult)
assert result.is_error is True

View file

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

View file

@ -39,7 +39,7 @@ async def test_server_run_exits_cleanly_when_transport_yields_exception_then_clo
with anyio.fail_after(5):
# stateless=True so server.run doesn't wait for initialize handshake.
# Before the fix, this raised ExceptionGroup(ClosedResourceError).
await server.run(read_recv, write_send, server.create_initialization_options(), stateless=True)
await server.run(read_recv, write_send, server.create_initialization_options())
# write_send was closed inside run's `async with`; receive_nowait raises
# EndOfStream iff the buffer is empty (i.e., server wrote nothing).

View file

@ -1,49 +1,63 @@
"""Tests for `ServerRunner`.
"""Tests for `ServerRunner` and the free-function drivers.
End-to-end over `JSONRPCDispatcher` with a real lowlevel `Server` as the
registry. The `connected_runner` helper starts both sides and (by default)
performs the initialize handshake, so each test exercises only the behaviour
under test.
The kernel tests run end-to-end over `JSONRPCDispatcher` with a real lowlevel
`Server` as the registry. The `connected_runner` helper starts both sides and
(by default) performs the initialize handshake, so each test exercises only the
behaviour under test. Driver tests (`serve_connection`, `serve_one`,
`to_jsonrpc_response`, `aclose_shielded`) follow at the bottom.
"""
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from functools import partial
from typing import Any, cast
import anyio
import anyio.abc
import pytest
from opentelemetry.trace import SpanKind, StatusCode
import mcp.server.runner
from mcp.server.connection import Connection
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.server.runner import ServerRunner, _extract_meta, _resolve_protocol_version, otel_middleware
from mcp.server.runner import (
ServerRunner,
_extract_meta,
aclose_shielded,
otel_middleware,
serve_connection,
serve_one,
to_jsonrpc_response,
)
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
from mcp.shared.dispatcher import CallOptions, DispatchContext, DispatchMiddleware, OnRequest
from mcp.shared.exceptions import MCPError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata
from mcp.shared.message import MessageMetadata
from mcp.shared.peer import dump_params
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
CallToolRequestParams,
ClientCapabilities,
ErrorData,
Implementation,
InitializeRequestParams,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
ListToolsResult,
NotificationParams,
PaginatedRequestParams,
ProgressNotificationParams,
RequestParams,
RequestParamsMeta,
SetLevelRequestParams,
Tool,
)
@ -84,36 +98,46 @@ async def connected_runner(
server: SrvT,
*,
initialized: bool = True,
stateless: bool = False,
has_standalone_channel: bool = True,
init_options: InitializationOptions | None = None,
session_id: str | None = None,
dispatch_middleware: list[DispatchMiddleware] | None = None,
connection: Connection | None = None,
) -> AsyncIterator[tuple[JSONRPCDispatcher[TransportContext], ServerRunner[dict[str, Any]]]]:
"""Yield `(client, runner)` running over an in-memory JSON-RPC dispatcher pair.
Starts the client (echo handlers) and `runner.run()` in a task group, wraps
the body in `anyio.fail_after(5)`, and cancels on exit. When
`initialized` is true the helper performs the real `initialize` request
before yielding, so tests start past the init-gate via the public path.
Starts the client (echo handlers) and the server-side dispatcher loop
(kernel `on_request`/`on_notify` + `aclose_shielded` teardown - the
`serve_connection` shape) in a task group, wraps the body in
`anyio.fail_after(5)`, and cancels on exit. When `initialized` is true the
helper performs the real `initialize` request before yielding, so tests
start past the init-gate via the public path.
`connection` defaults to `Connection.for_loop(server_dispatcher)`. Pass a
factory-built connection (e.g. `Connection.from_envelope(...)`) to exercise
the born-ready path; the kernel reads it as a fact and is mode-agnostic.
"""
client, server_d, close = jsonrpc_pair()
assert isinstance(client, JSONRPCDispatcher) and isinstance(server_d, JSONRPCDispatcher)
if connection is None:
connection = Connection.for_loop(server_d)
runner = ServerRunner(
server=server,
dispatcher=server_d,
connection=connection,
lifespan_state={},
has_standalone_channel=has_standalone_channel,
init_options=init_options,
session_id=session_id,
stateless=stateless,
dispatch_middleware=dispatch_middleware or [],
)
c_req, c_notify = echo_handlers(Recorder())
body_exc: BaseException | None = None
async def _drive(*, task_status: anyio.abc.TaskStatus[None]) -> None:
try:
await server_d.run(runner.on_request, runner.on_notify, task_status=task_status)
finally:
await aclose_shielded(connection)
async with anyio.create_task_group() as tg:
await tg.start(client.run, c_req, c_notify)
await tg.start(runner.run)
await tg.start(_drive)
try:
with anyio.fail_after(5):
if initialized:
@ -219,11 +243,21 @@ async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
assert isinstance(ctx, ServerRequestContext)
assert ctx.lifespan_context == {}
assert isinstance(ctx.session, ServerSession)
assert ctx.session is runner.session
assert ctx.session.protocol_version == runner.connection.protocol_version
assert ctx.request_id is not None
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
@pytest.mark.anyio
async def test_runner_builds_a_fresh_session_per_request(server: SrvT):
"""`ctx.session` is built per-request from the per-request `DispatchContext`
and the connection's standalone outbound; it is not connection-scoped."""
async with connected_runner(server) as (client, _):
await client.send_raw_request("tools/list", None)
await client.send_raw_request("tools/list", None)
assert _seen_ctx[0].session is not _seen_ctx[1].session
@pytest.mark.anyio
async def test_runner_spec_method_with_no_handler_raises_method_not_found(server: SrvT):
async with connected_runner(server) as (client, _):
@ -605,7 +639,6 @@ async def test_runner_middleware_raise_after_call_next_on_initialize_leaves_conn
assert await client.send_raw_request("ping", None) == {}
assert runner.connection.initialize_accepted is False
assert runner.connection.client_params is None
assert runner.connection.protocol_version is None
assert not runner.connection.initialized.is_set()
@ -656,65 +689,6 @@ async def test_runner_server_middleware_wraps_notifications(server: SrvT):
]
def test_resolve_protocol_version_handshake_committed_value_wins():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-03-26"}
assert _resolve_protocol_version("2025-06-18", meta, md) == "2025-06-18"
def test_resolve_protocol_version_reads_per_request_meta_when_no_handshake():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-06-18"}
assert _resolve_protocol_version(None, meta, md) == "2025-06-18"
def test_resolve_protocol_version_skips_unsupported_meta_value():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "1900-01-01"}
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
def test_resolve_protocol_version_skips_non_string_meta_value():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: 42}
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
def test_resolve_protocol_version_reads_transport_hint_when_no_handshake_or_meta():
md = ServerMessageMetadata(protocol_version="2025-06-18")
assert _resolve_protocol_version(None, None, md) == "2025-06-18"
assert _resolve_protocol_version(None, {}, md) == "2025-06-18"
def test_resolve_protocol_version_skips_unsupported_transport_hint():
"""The `initialize` params version reaches the metadata unvalidated; surface validation must never see it."""
md = ServerMessageMetadata(protocol_version="1900-01-01")
assert _resolve_protocol_version(None, None, md) == "2025-11-25"
def test_resolve_protocol_version_terminal_default_with_no_signals():
assert _resolve_protocol_version(None, None, None) == "2025-11-25"
assert _resolve_protocol_version(None, None, ServerMessageMetadata()) == "2025-11-25"
assert _resolve_protocol_version(None, None, ClientMessageMetadata()) == "2025-11-25"
@pytest.mark.anyio
async def test_runner_ctx_protocol_version_is_terminal_default_on_stateless_in_memory(server: SrvT):
async with connected_runner(server, initialized=False, stateless=True) as (client, runner):
await client.send_raw_request("tools/list", None)
ctx = _seen_ctx[0]
assert ctx.protocol_version == "2025-11-25"
assert ctx.session.protocol_version is None
assert runner.connection.protocol_version is None
@pytest.mark.anyio
async def test_runner_ctx_protocol_version_tracks_per_request_meta_on_stateless(server: SrvT):
async with connected_runner(server, initialized=False, stateless=True) as (client, _):
await client.send_raw_request("tools/list", {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-06-18"}})
assert _seen_ctx[0].protocol_version == "2025-06-18"
def test_extract_meta_returns_none_for_absent_or_malformed():
"""Context construction is independent of `_meta` validity; the params
validation inside `call_next()` is what surfaces the error."""
@ -837,21 +811,16 @@ async def test_runner_handler_returning_unsupported_type_surfaces_as_error(serve
@pytest.mark.anyio
async def test_runner_stateless_skips_init_gate(server: SrvT):
async with connected_runner(server, initialized=False, stateless=True, has_standalone_channel=False) as (client, _):
result = await client.send_raw_request("tools/list", None)
assert result["tools"][0]["name"] == "t"
@pytest.mark.anyio
async def test_runner_stateless_connection_initialized_event_set_on_construction(server: SrvT):
"""`connection.initialized` mirrors the gate flag in stateless mode so
`await connection.initialized.wait()` does not hang when no handshake
arrives."""
async with connected_runner(server, initialized=False, stateless=True, has_standalone_channel=False) as (_, runner):
async def test_runner_with_born_ready_connection_skips_init_gate(server: SrvT):
"""A `Connection.from_envelope` connection is born ready: the kernel's
init-gate is open without any handshake. The kernel is mode-agnostic - the
same `on_request` reads `connection.initialize_accepted` as a fact."""
born_ready = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner):
assert runner.connection.initialize_accepted is True
assert runner.connection.initialized.is_set()
await runner.connection.initialized.wait()
result = await client.send_raw_request("tools/list", None)
assert result["tools"][0]["name"] == "t"
@pytest.mark.anyio
@ -954,6 +923,33 @@ async def test_runner_spec_method_absent_at_the_negotiated_version_is_method_not
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="server/discover")
@pytest.mark.anyio
async def test_on_request_rejects_initialize_at_modern_version_with_method_not_found(server: SrvT):
"""Spec-mandated: `initialize` has no `CLIENT_REQUESTS` row at the modern
version; kernel dispatch (not the inbound classifier) rejects it."""
born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner):
assert runner.connection.protocol_version == MODERN_PROTOCOL_VERSIONS[0]
with pytest.raises(MCPError) as exc:
await client.send_raw_request("initialize", _initialize_params())
assert exc.value.error.code == METHOD_NOT_FOUND
@pytest.mark.anyio
async def test_on_request_dispatches_custom_method_registered_via_add_request_handler(server: SrvT):
"""SDK-defined: a method outside `SPEC_CLIENT_METHODS` skips the version
gate and reaches its registered handler at any negotiated version."""
async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return {"echoed": True}
server.add_request_handler("myorg/echo", RequestParams, echo)
born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
result = await client.send_raw_request("myorg/echo", None)
assert result == {"echoed": True}
@pytest.mark.anyio
async def test_runner_middleware_short_circuit_on_a_wrong_version_spec_method_skips_the_sieve(server: SrvT):
"""A server-tier middleware that returns without calling `call_next` for a
@ -1205,3 +1201,194 @@ async def test_runner_exit_stack_fast_cleanup_completes_within_grace(
await client.send_raw_request("tools/list", None)
assert cleaned == [2, 1]
assert "abandoning remaining callbacks" not in caplog.text
# --- to_jsonrpc_response -------------------------------------------------------
@pytest.mark.anyio
async def test_to_jsonrpc_response_wraps_success_as_jsonrpc_response():
"""SDK-defined: a handler coroutine resolving to a result dict is wrapped as a
`JSONRPCResponse` carrying the supplied id and the dict verbatim as `result`."""
async def ok() -> dict[str, Any]:
return {"k": "v"}
reply = await to_jsonrpc_response(7, ok())
assert isinstance(reply, JSONRPCResponse)
assert reply.id == 7
assert reply.result == {"k": "v"}
@pytest.mark.anyio
async def test_to_jsonrpc_response_maps_mcp_error_to_jsonrpc_error():
"""SDK-defined: an `MCPError` raised by the handler coroutine is wrapped as a
`JSONRPCError` whose `error` carries the same code, message, and data."""
async def fail() -> dict[str, Any]:
raise MCPError(code=METHOD_NOT_FOUND, message="nope", data="x")
reply = await to_jsonrpc_response("rid", fail())
assert isinstance(reply, JSONRPCError)
assert reply.id == "rid"
assert reply.error == ErrorData(code=METHOD_NOT_FOUND, message="nope", data="x")
@pytest.mark.anyio
async def test_to_jsonrpc_response_maps_validation_error_to_invalid_params():
"""SDK-defined: a pydantic `ValidationError` escaping the handler coroutine is
mapped to `INVALID_PARAMS` with a generic message (validator detail does not
reach the wire)."""
async def fail() -> dict[str, Any]:
Tool.model_validate({"name": 123}) # raises ValidationError
raise NotImplementedError
reply = await to_jsonrpc_response(1, fail())
assert isinstance(reply, JSONRPCError)
assert reply.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
@pytest.mark.anyio
async def test_to_jsonrpc_response_maps_unmapped_exception_to_internal_error_and_logs(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: an unmapped exception is logged server-side and surfaced as
`INTERNAL_ERROR` with a generic message; the exception text never reaches the
wire."""
async def fail() -> dict[str, Any]:
raise RuntimeError("boom")
reply = await to_jsonrpc_response(1, fail())
assert isinstance(reply, JSONRPCError)
assert reply.error.code == INTERNAL_ERROR
# Handler internals never reach the wire.
assert "boom" not in reply.error.message
assert "request handler raised" in caplog.text
# --- aclose_shielded -----------------------------------------------------------
@pytest.mark.anyio
async def test_aclose_shielded_runs_callbacks_under_outer_cancellation():
"""The shield lets per-connection cleanup run even when the enclosing scope
is being cancelled."""
cleaned: list[int] = []
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
async def _append() -> None:
await anyio.sleep(0)
cleaned.append(1)
conn.exit_stack.push_async_callback(_append)
with anyio.CancelScope() as scope:
scope.cancel()
await aclose_shielded(conn)
assert cleaned == [1]
# --- serve_one / serve_connection ---------------------------------------------
@dataclass
class _StubDispatchContext:
"""Minimal `DispatchContext` for `serve_one` driver tests.
The modern entry hands a per-request context to `serve_one`; this stub
satisfies the protocol structurally with no real back-channel.
"""
request_id: int | str | None
transport: TransportContext = field(default_factory=lambda: TransportContext(kind="direct", can_send_request=False))
message_metadata: MessageMetadata = None
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
can_send_request: bool = False
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
raise NotImplementedError
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
raise NotImplementedError
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
raise NotImplementedError
async def _append_async(dst: list[int], v: int) -> None:
dst.append(v)
_LIFESPAN: dict[str, Any] = {}
@pytest.mark.anyio
async def test_serve_one_runs_handler_and_returns_jsonrpc_response(server: SrvT):
"""The single-exchange driver: builds the kernel, runs `on_request` once,
wraps via `to_jsonrpc_response`, and tears down `connection.exit_stack`."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
cleaned: list[int] = []
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
request = JSONRPCRequest(jsonrpc="2.0", id=9, method="tools/list", params=None)
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(9), lifespan_state=_LIFESPAN)
assert isinstance(reply, JSONRPCResponse)
assert reply.id == 9
assert reply.result["tools"][0]["name"] == "t"
assert cleaned == [1]
ctx = _seen_ctx[0]
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
@pytest.mark.anyio
async def test_serve_one_maps_error_to_jsonrpc_error_and_still_closes_exit_stack(server: SrvT):
"""SDK-defined: a kernel-produced error (here `METHOD_NOT_FOUND` for an
unregistered method) is wrapped as a `JSONRPCError`, and the per-request
exit stack is closed on the error path too."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
cleaned: list[int] = []
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
request = JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/list", params=None)
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(2), lifespan_state=_LIFESPAN)
assert isinstance(reply, JSONRPCError)
assert reply.error.code == METHOD_NOT_FOUND
assert cleaned == [1]
@pytest.mark.anyio
async def test_serve_one_reads_connection_protocol_version_as_a_fact(server: SrvT):
"""`serve_one` builds the kernel over the entry's `Connection`; the kernel
reads `connection.protocol_version` for the version gate. A `from_envelope`
connection at a modern version rejects a method absent there."""
conn = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="logging/setLevel", params={"level": "info"})
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(1), lifespan_state=_LIFESPAN)
assert isinstance(reply, JSONRPCError)
assert reply.error.code == METHOD_NOT_FOUND
@pytest.mark.anyio
async def test_serve_connection_drives_dispatcher_loop_and_tears_down(server: SrvT):
"""The loop-mode driver: `serve_connection` builds the kernel, hands
`on_request`/`on_notify` to `dispatcher.run()`, and `aclose_shielded`s the
connection on the way out."""
client, server_d, close = jsonrpc_pair()
assert isinstance(client, JSONRPCDispatcher) and isinstance(server_d, JSONRPCDispatcher)
conn = Connection.for_loop(server_d)
cleaned: list[int] = []
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
c_req, c_notify = echo_handlers(Recorder())
async with anyio.create_task_group() as tg:
await tg.start(client.run, c_req, c_notify)
await tg.start(partial(serve_connection, server, server_d, connection=conn, lifespan_state=_LIFESPAN))
with anyio.fail_after(5):
await client.send_raw_request("initialize", _initialize_params())
result = await client.send_raw_request("tools/list", None)
assert result["tools"][0]["name"] == "t"
assert cleaned == []
close()
assert cleaned == [1]
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
assert conn.client_params is not None

View file

@ -31,21 +31,20 @@ class _Lifespan:
@pytest.mark.anyio
async def test_context_exposes_lifespan_and_connection_and_forwards_base_context():
captured: list[Context[_Lifespan]] = []
conn = Connection.__new__(Connection) # placeholder until running_pair gives us the dispatcher
conn_holder: list[Connection] = []
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=conn)
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=conn_holder[0])
captured.append(ctx)
return {}
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, server, *_):
# Now we have the server dispatcher; build the real Connection bound to it.
conn.__init__(server, has_standalone_channel=True, session_id="sess-1")
conn_holder.append(Connection.for_loop(server, session_id="sess-1"))
with anyio.fail_after(5):
await client.send_raw_request("t", None)
ctx = captured[0]
assert ctx.lifespan.name == "app"
assert ctx.connection is conn
assert ctx.connection is conn_holder[0]
assert ctx.transport.kind == "direct"
assert ctx.can_send_request is True
assert ctx.session_id == "sess-1"
@ -58,9 +57,7 @@ async def test_context_log_sends_request_scoped_message_notification():
_, c_notify = echo_handlers(crec)
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
ctx: Context[_Lifespan] = Context(
dctx, lifespan=_Lifespan("app"), connection=Connection(dctx, has_standalone_channel=True)
)
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
await ctx.log("debug", "hello") # pyright: ignore[reportDeprecated]
return {}
@ -82,9 +79,7 @@ async def test_context_log_includes_logger_and_meta_when_supplied():
_, c_notify = echo_handlers(crec)
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
ctx: Context[_Lifespan] = Context(
dctx, lifespan=_Lifespan("app"), connection=Connection(dctx, has_standalone_channel=True)
)
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
await ctx.log("info", "x", logger="my.log", meta={"traceId": "t"}) # pyright: ignore[reportDeprecated]
return {}

View file

@ -1,41 +1,38 @@
"""Tests for `ServerSession`.
`ServerSession` is a thin proxy over a dispatcher and a `Connection`. Tested
with a stub dispatcher so we can assert what reaches the wire (method, params,
`CallOptions`, related-request-id) without standing up a full transport.
`ServerSession` is a thin per-request proxy over two `Outbound` channels and a
`Connection`. Tested with stub outbounds so we can assert what reaches the wire
(method, params, `CallOptions`) and which channel it routed to, without standing
up a transport.
"""
from collections.abc import Mapping
from typing import Any, cast
from typing import Any
import pytest
from pydantic import ValidationError
from mcp import types
from mcp.server import Server, ServerRequestContext
from mcp.server.connection import Connection
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.dispatcher import CallOptions, Outbound
from mcp.shared.message import ServerMessageMetadata
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
Implementation,
InitializeRequestParams,
SamplingCapability,
SamplingToolsCapability,
)
from .test_runner import connected_runner
class StubDispatcher:
class StubOutbound:
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
def __init__(self, result: dict[str, Any] | None = None) -> None:
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None, Any]] = []
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = []
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
self.result = result if result is not None else {}
async def send_raw_request(
@ -43,40 +40,36 @@ class StubDispatcher:
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
*,
_related_request_id: Any = None,
) -> dict[str, Any]:
self.requests.append((method, params, opts, _related_request_id))
self.requests.append((method, params, opts))
return self.result
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
raise NotImplementedError
self.notifications.append((method, params))
def _make_session(
dispatcher: StubDispatcher,
outbound: StubOutbound,
*,
capabilities: ClientCapabilities | None = None,
has_standalone_channel: bool = True,
protocol_version: str | None = None,
protocol_version: str = LATEST_PROTOCOL_VERSION,
) -> ServerSession:
conn = Connection(dispatcher, has_standalone_channel=has_standalone_channel)
conn.protocol_version = protocol_version
if capabilities is not None:
conn.client_params = InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
capabilities=capabilities,
client_info=Implementation(name="c", version="0"),
)
# cast: `ServerSession` is typed to take `JSONRPCDispatcher` but only ever
# calls `send_raw_request` / `notify`, so the stub is structurally sufficient.
return ServerSession(cast("JSONRPCDispatcher[Any]", dispatcher), conn)
"""Single-channel session: the stub is both request and standalone outbound."""
client_info = Implementation(name="c", version="0") if capabilities is not None else None
conn = Connection.from_envelope(protocol_version, client_info, capabilities, outbound=outbound)
return ServerSession(outbound, conn)
def _two_channel_session(request_ch: Outbound, standalone_ch: Outbound) -> ServerSession:
"""Distinct request/standalone outbounds so routing assertions can tell the channels apart."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None, outbound=standalone_ch)
return ServerSession(request_ch, conn)
@pytest.mark.anyio
async def test_send_request_forwards_timeout_and_progress_callback_as_call_options():
dispatcher = StubDispatcher(result={"roots": []})
session = _make_session(dispatcher)
outbound = StubOutbound(result={"roots": []})
session = _make_session(outbound)
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
raise NotImplementedError
@ -85,57 +78,78 @@ async def test_send_request_forwards_timeout_and_progress_callback_as_call_optio
types.ListRootsRequest(),
types.ListRootsResult,
request_read_timeout_seconds=2.5,
metadata=ServerMessageMetadata(related_request_id=7),
progress_callback=on_progress,
)
assert isinstance(result, types.ListRootsResult)
method, _params, opts, related = dispatcher.requests[0]
method, _params, opts = outbound.requests[0]
assert method == "roots/list"
assert opts == {"timeout": 2.5, "on_progress": on_progress}
assert related == 7
@pytest.mark.anyio
async def test_send_request_omits_call_options_when_none_given():
dispatcher = StubDispatcher(result={"roots": []})
session = _make_session(dispatcher)
outbound = StubOutbound(result={"roots": []})
session = _make_session(outbound)
await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
_method, _params, opts, related = dispatcher.requests[0]
_method, _params, opts = outbound.requests[0]
assert opts is None
assert related is None
@pytest.mark.anyio
async def test_send_request_timeout_zero_is_forwarded():
"""0 is a real timeout (fail at the first checkpoint, `anyio.fail_after(0)`
semantics) and must reach the dispatcher; only `None` means "no timeout"."""
dispatcher = StubDispatcher(result={})
session = _make_session(dispatcher)
semantics) and must reach the channel; only `None` means "no timeout"."""
outbound = StubOutbound(result={})
session = _make_session(outbound)
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.0)
assert dispatcher.requests[0][2] == {"timeout": 0.0}
assert outbound.requests[0][2] == {"timeout": 0.0}
@pytest.mark.anyio
async def test_send_request_without_back_channel_or_related_id_fails_fast():
"""No standalone channel and no related request to ride on: raise instead
of parking forever on a response that cannot arrive."""
dispatcher = StubDispatcher(result={})
session = _make_session(dispatcher, has_standalone_channel=False)
with pytest.raises(NoBackChannelError):
await session.send_request(types.PingRequest(), types.EmptyResult)
assert dispatcher.requests == []
# With a related request id the message rides that request's stream.
await session.send_request(
types.PingRequest(), types.EmptyResult, metadata=ServerMessageMetadata(related_request_id=3)
async def test_send_request_without_related_id_routes_to_standalone_channel():
"""SDK-defined: no `related_request_id` routes the request onto the connection's standalone channel."""
request_ch = StubOutbound()
standalone_ch = StubOutbound(result={"roots": []})
session = _two_channel_session(request_ch, standalone_ch)
await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert request_ch.requests == []
assert standalone_ch.requests[0][0] == "roots/list"
@pytest.mark.anyio
async def test_send_request_with_related_id_routes_to_request_channel():
"""SDK-defined: with `related_request_id` the request rides the per-request channel
(the originating POST's response stream over streamable HTTP)."""
request_ch = StubOutbound(result={"action": "cancel"})
standalone_ch = StubOutbound()
session = _two_channel_session(request_ch, standalone_ch)
result = await session.send_request(
types.ElicitRequest(params=types.ElicitRequestFormParams(message="q", requested_schema={})),
types.ElicitResult,
metadata=ServerMessageMetadata(related_request_id=7),
)
assert dispatcher.requests[0][3] == 3
assert isinstance(result, types.ElicitResult)
assert standalone_ch.requests == []
assert request_ch.requests[0][0] == "elicitation/create"
@pytest.mark.anyio
async def test_send_notification_routes_by_related_request_id():
"""SDK-defined: notifications select channel by `related_request_id` exactly like requests."""
request_ch = StubOutbound()
standalone_ch = StubOutbound()
session = _two_channel_session(request_ch, standalone_ch)
await session.send_tool_list_changed()
await session.send_progress_notification("tok", 0.5, related_request_id="req-1")
assert [m for m, _ in standalone_ch.notifications] == ["notifications/tools/list_changed"]
assert [m for m, _ in request_ch.notifications] == ["notifications/progress"]
@pytest.mark.anyio
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."""
session = _make_session(StubDispatcher(result={"roots": "nope"}))
session = _make_session(StubOutbound(result={"roots": "nope"}))
with pytest.raises(ValidationError):
await session.send_request(types.ListRootsRequest(), types.EmptyResult)
@ -143,7 +157,7 @@ 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."""
session = _make_session(StubDispatcher(result={"roots": [{"uri": "file:///ws"}]}))
session = _make_session(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
result = await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert isinstance(result, types.ListRootsResult)
assert str(result.roots[0].uri) == "file:///ws"
@ -151,9 +165,9 @@ async def test_send_request_passes_a_spec_valid_client_result():
@pytest.mark.anyio
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
"""Surface row absent for the connection's version: gate is bypassed and only
`result_type` validates."""
session = _make_session(StubDispatcher(result={}), protocol_version="2026-07-28")
session = _make_session(StubOutbound(result={}), protocol_version=MODERN_PROTOCOL_VERSIONS[0])
result = await session.send_request(types.PingRequest(), types.EmptyResult)
assert isinstance(result, types.EmptyResult)
@ -163,7 +177,7 @@ async def test_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"}
session = _make_session(StubDispatcher(result=snake))
session = _make_session(StubOutbound(result=snake))
request = types.CreateMessageRequest(params=types.CreateMessageRequestParams(messages=[], max_tokens=1))
result = await session.send_request(request, types.CreateMessageResult)
assert result.stop_reason is None
@ -171,9 +185,9 @@ async def test_send_request_validates_result_alias_only():
@pytest.mark.anyio
async def test_create_message_with_tools_returns_with_tools_result():
dispatcher = StubDispatcher(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
outbound = StubOutbound(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
session = _make_session(
dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
outbound, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
)
result = await session.create_message( # pyright: ignore[reportDeprecated]
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
@ -181,74 +195,22 @@ async def test_create_message_with_tools_returns_with_tools_result():
tools=[types.Tool(name="t", input_schema={"type": "object"})],
)
assert isinstance(result, types.CreateMessageResultWithTools)
method, params, _opts, _related = dispatcher.requests[0]
method, params, _opts = outbound.requests[0]
assert method == "sampling/createMessage"
assert params is not None and params["tools"][0]["name"] == "t"
def test_check_client_capability_delegates_to_connection():
dispatcher = StubDispatcher()
session = _make_session(dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability()))
outbound = StubOutbound()
session = _make_session(outbound, capabilities=ClientCapabilities(sampling=SamplingCapability()))
assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True
assert session.check_client_capability(ClientCapabilities(experimental={"x": {}})) is False
def _runner_server(seen_versions: list[str | None]) -> Server[dict[str, Any]]:
"""A lowlevel Server whose tools/list handler records `ctx.session.protocol_version`."""
async def list_tools(
ctx: ServerRequestContext[dict[str, Any], Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
seen_versions.append(ctx.session.protocol_version)
return types.ListToolsResult(tools=[])
return Server(name="test-server", version="0.0.1", on_list_tools=list_tools)
def _init_params(protocol_version: str) -> dict[str, Any]:
return InitializeRequestParams(
protocol_version=protocol_version,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test-client", version="1.0"),
).model_dump(by_alias=True, exclude_none=True)
@pytest.mark.anyio
async def test_protocol_version_is_none_before_initialize():
"""No negotiated version is readable before the initialize handshake."""
async with connected_runner(_runner_server([]), initialized=False) as (_client, runner):
assert runner.session.protocol_version is None
@pytest.mark.anyio
async def test_protocol_version_is_negotiated_version_after_initialize():
"""A supported requested version is echoed back and readable on the session,
both directly and from inside a handler via `ctx.session`."""
seen: list[str | None] = []
async with connected_runner(_runner_server(seen), initialized=False) as (client, runner):
result = await client.send_raw_request("initialize", _init_params("2025-03-26"))
assert result["protocolVersion"] == "2025-03-26"
assert runner.session.protocol_version == "2025-03-26"
await client.send_raw_request("tools/list", None)
assert seen == ["2025-03-26"]
@pytest.mark.anyio
async def test_protocol_version_reads_latest_when_requested_version_unsupported():
"""An unsupported requested version negotiates down to LATEST_PROTOCOL_VERSION."""
async with connected_runner(_runner_server([]), initialized=False) as (client, runner):
result = await client.send_raw_request("initialize", _init_params("1999-01-01"))
assert result["protocolVersion"] == LATEST_PROTOCOL_VERSION
assert runner.session.protocol_version == LATEST_PROTOCOL_VERSION
@pytest.mark.anyio
async def test_protocol_version_is_none_on_stateless_connection():
"""Stateless connections never see a handshake: requests flow, but the
negotiated version legitimately stays None."""
seen: list[str | None] = []
async with connected_runner(_runner_server(seen), initialized=False, stateless=True) as (client, runner):
result = await client.send_raw_request("tools/list", None)
assert result == {"tools": []}
assert seen == [None]
assert runner.session.protocol_version is None
def test_protocol_version_proxies_connection():
"""SDK-defined: `session.protocol_version` reads through to the held `Connection`."""
_ARBITRARY_VERSION = "sentinel-version" # identity-only: any string the connection holds
conn = Connection.from_envelope(_ARBITRARY_VERSION, None, None)
session = ServerSession(StubOutbound(), conn)
assert session.protocol_version == _ARBITRARY_VERSION
assert session.client_params is None

View file

@ -1,187 +1,170 @@
"""Tests for stateless HTTP mode limitations.
"""Tests for the no-back-channel path (stateless HTTP).
Stateless HTTP mode does not support server-to-client requests because there
is no persistent connection for bidirectional communication. These tests verify
that appropriate errors are raised when attempting to use unsupported features.
A `Connection.from_envelope(...)` connection installs the no-channel sentinel
as its standalone outbound, so server-to-client requests with no related
request to ride on raise `NoBackChannelError` from the channel itself.
See: https://github.com/modelcontextprotocol/python-sdk/issues/1097
"""
from collections.abc import Mapping
from typing import Any
from unittest.mock import Mock
import anyio
import pytest
from mcp import types
from mcp.server.connection import Connection
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel.server import Server
from mcp.server.session import ServerSession
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCRequest, JSONRPCResponse, ListToolsResult, PaginatedRequestParams
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError
from mcp.types import LATEST_PROTOCOL_VERSION
def _make_session(*, stateless: bool) -> ServerSession:
"""A `ServerSession` with a mock dispatcher; the stateless guard fires before any send."""
return ServerSession(
Mock(spec=JSONRPCDispatcher),
Connection(Mock(), has_standalone_channel=False),
stateless=stateless,
)
class StubOutbound:
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
def __init__(self, result: dict[str, Any] | None = None) -> None:
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = []
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
self.result = result if result is not None else {}
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
self.requests.append((method, params, opts))
return self.result
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
self.notifications.append((method, params))
def _no_channel_session(request_ch: StubOutbound | None = None) -> tuple[ServerSession, StubOutbound]:
"""A session whose standalone channel is the connection's no-channel
sentinel; the request channel is a working stub."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
assert conn.has_standalone_channel is False
request = request_ch if request_ch is not None else StubOutbound()
return ServerSession(request, conn), request
@pytest.fixture
def stateless_session() -> ServerSession:
return _make_session(stateless=True)
def no_channel_session() -> ServerSession:
session, _ = _no_channel_session()
return session
@pytest.mark.anyio
async def test_list_roots_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that list_roots raises StatelessModeNotSupported in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="list_roots"):
await stateless_session.list_roots() # pyright: ignore[reportDeprecated]
async def test_list_roots_raises_no_back_channel(no_channel_session: ServerSession):
"""SDK-defined: `list_roots` has no `related_request_id` so it always rides
the standalone channel, which raises here."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.list_roots() # pyright: ignore[reportDeprecated]
assert exc.value.method == "roots/list"
@pytest.mark.anyio
async def test_create_message_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that create_message raises StatelessModeNotSupported in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="sampling"):
await stateless_session.create_message( # pyright: ignore[reportDeprecated]
messages=[
types.SamplingMessage(
role="user",
content=types.TextContent(type="text", text="hello"),
)
],
async def test_send_ping_raises_no_back_channel(no_channel_session: ServerSession):
"""SDK-defined: `send_ping` rides the standalone channel and raises when there is none."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.send_ping()
assert exc.value.method == "ping"
@pytest.mark.anyio
async def test_create_message_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: `create_message` without a related id rides the standalone channel and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.create_message( # pyright: ignore[reportDeprecated]
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
max_tokens=100,
)
assert exc.value.method == "sampling/createMessage"
@pytest.mark.anyio
async def test_elicit_form_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that elicit_form raises StatelessModeNotSupported in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="elicitation"):
await stateless_session.elicit_form(
message="Please provide input",
requested_schema={"type": "object", "properties": {}},
)
async def test_elicit_form_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: `elicit_form` without a related id rides the standalone channel and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.elicit_form(message="m", requested_schema={"type": "object", "properties": {}})
assert exc.value.method == "elicitation/create"
@pytest.mark.anyio
async def test_elicit_url_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that elicit_url raises StatelessModeNotSupported in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="elicitation"):
await stateless_session.elicit_url(
message="Please authenticate",
url="https://example.com/auth",
elicitation_id="test-123",
)
async def test_elicit_url_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: `elicit_url` without a related id rides the standalone channel and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.elicit_url(message="m", url="https://example.com/auth", elicitation_id="e-1")
assert exc.value.method == "elicitation/create"
@pytest.mark.anyio
async def test_elicit_deprecated_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that the deprecated elicit method also fails in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="elicitation"):
await stateless_session.elicit(
message="Please provide input",
requested_schema={"type": "object", "properties": {}},
)
async def test_elicit_deprecated_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: the deprecated `elicit` alias routes the same as `elicit_form` and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.elicit(message="m", requested_schema={"type": "object", "properties": {}})
assert exc.value.method == "elicitation/create"
@pytest.mark.anyio
async def test_stateless_error_message_is_actionable(stateless_session: ServerSession):
"""Test that the error message provides actionable guidance."""
with pytest.raises(StatelessModeNotSupported) as exc_info:
await stateless_session.list_roots() # pyright: ignore[reportDeprecated]
error_message = str(exc_info.value)
# Should mention it's stateless mode
assert "stateless HTTP mode" in error_message
# Should explain why it doesn't work
assert "server-to-client requests" in error_message
# Should tell user how to fix it
assert "stateless_http=False" in error_message
async def test_send_request_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: the generic `send_request` path with no metadata routes standalone and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert exc.value.method == "roots/list"
@pytest.mark.anyio
async def test_exception_has_method_attribute(stateless_session: ServerSession):
"""Test that the exception has a method attribute for programmatic access."""
with pytest.raises(StatelessModeNotSupported) as exc_info:
await stateless_session.list_roots() # pyright: ignore[reportDeprecated]
assert exc_info.value.method == "list_roots"
@pytest.fixture
def stateful_session() -> ServerSession:
return _make_session(stateless=False)
async def test_elicit_form_with_related_id_rides_the_request_channel():
"""SDK-defined: with a related request the message rides the per-request
channel, so the no-channel standalone is never touched and the call succeeds."""
session, request_ch = _no_channel_session(StubOutbound(result={"action": "cancel"}))
result = await session.elicit_form(
message="m", requested_schema={"type": "object", "properties": {}}, related_request_id=3
)
assert isinstance(result, types.ElicitResult)
assert request_ch.requests[0][0] == "elicitation/create"
@pytest.mark.anyio
async def test_stateful_mode_does_not_raise_stateless_error(
stateful_session: ServerSession, monkeypatch: pytest.MonkeyPatch
):
"""Test that StatelessModeNotSupported is not raised in stateful mode.
async def test_send_log_message_with_related_id_rides_the_request_channel():
"""SDK-defined: the deprecated ``send_log_message`` notification with a related id
rides the per-request channel, so it is delivered even with no standalone back-channel."""
session, request_ch = _no_channel_session()
await session.send_log_message( # pyright: ignore[reportDeprecated]
level="info", data="hello", logger="test", related_request_id=3
)
assert request_ch.notifications == [("notifications/message", {"level": "info", "data": "hello", "logger": "test"})]
We mock send_request to avoid blocking on I/O while still verifying
that the stateless check passes.
"""
send_request_called = False
async def mock_send_request(*_: Any, **__: Any) -> types.ListRootsResult:
nonlocal send_request_called
send_request_called = True
return types.ListRootsResult(roots=[])
@pytest.mark.anyio
async def test_unrelated_notification_is_dropped_silently():
"""SDK-defined: notifications on the no-channel standalone are best-effort — dropped, never raised."""
session, request_ch = _no_channel_session()
await session.send_tool_list_changed()
assert request_ch.notifications == []
monkeypatch.setattr(stateful_session, "send_request", mock_send_request)
# This should NOT raise StatelessModeNotSupported
result = await stateful_session.list_roots() # pyright: ignore[reportDeprecated]
assert send_request_called
@pytest.mark.anyio
async def test_loop_connection_outbound_does_not_raise_no_back_channel():
"""SDK-defined: a `for_loop` connection holds a real outbound, so the
standalone path reaches the channel rather than raising."""
standalone = StubOutbound(result={"roots": []})
conn = Connection.for_loop(standalone)
assert conn.has_standalone_channel is True
session = ServerSession(StubOutbound(), conn)
result = await session.list_roots() # pyright: ignore[reportDeprecated]
assert isinstance(result, types.ListRootsResult)
assert standalone.requests[0][0] == "roots/list"
@pytest.mark.anyio
async def test_server_run_stateless_wires_no_standalone_channel():
"""`Server.run(stateless=True)` must wire `Connection.has_standalone_channel=False`.
Stateless HTTP has no standalone GET stream, so server-initiated requests on
the connection must fail fast with `NoBackChannelError` rather than write to
a channel that will never deliver a response. The `ServerSession` typed
helpers carry their own stateless guard (tested above); this pins the
`Connection` wiring that `Server.run` produces.
"""
captured: list[Connection] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
# `ServerRequestContext` doesn't expose `connection` directly yet (it
# will after the Context rework); reach it via the session for now.
captured.append(ctx.session._connection) # pyright: ignore[reportPrivateUsage]
return ListToolsResult(tools=[])
server: Server[Any] = Server("test", on_list_tools=list_tools)
to_server, server_read = anyio.create_memory_object_stream[SessionMessage | Exception](10)
server_write, from_server = anyio.create_memory_object_stream[SessionMessage](10)
async def run_server() -> None:
await server.run(server_read, server_write, server.create_initialization_options(), stateless=True)
async with anyio.create_task_group() as tg, to_server, server_read, server_write, from_server:
tg.start_soon(run_server)
# stateless=True skips the init gate, so tools/list routes immediately.
await to_server.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list")))
with anyio.fail_after(5):
response = (await from_server.receive()).message
assert isinstance(response, JSONRPCResponse)
tg.cancel_scope.cancel()
assert len(captured) == 1
conn = captured[0]
assert conn.has_standalone_channel is False
with pytest.raises(NoBackChannelError):
async def test_from_envelope_connection_ping_raises_no_back_channel():
"""SDK-defined: `Connection`'s own helpers route through the same sentinel,
so `ping` on a `from_envelope` connection raises."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
with pytest.raises(NoBackChannelError) as exc:
await conn.ping()
assert exc.value.method == "ping"

View file

@ -103,11 +103,12 @@ async def running_manager():
@pytest.mark.anyio
async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[StreamableHTTPSessionManager, Server]):
manager, app = running_manager
manager, _app = running_manager
mock_mcp_run = AsyncMock(return_value=None)
# This will be called by StreamableHTTPSessionManager's run_server -> self.app.run
app.run = mock_mcp_run
# The manager's `run_server` task drives `serve_loop` directly (the manager
# owns lifespan); patch that seam so the loop returns immediately and we
# can observe the cleanup that follows.
mock_serve = AsyncMock(return_value=None)
sent_messages: list[Message] = []
@ -125,7 +126,8 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
return {"type": "http.request", "body": b"", "more_body": False}
# Trigger session creation
await manager.handle_request(scope, mock_receive, mock_send)
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
await manager.handle_request(scope, mock_receive, mock_send)
# Extract session ID from response headers
session_id = None
@ -140,10 +142,9 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
assert session_id is not None, "Session ID not found in response headers"
# Ensure MCPServer.run was called
mock_mcp_run.assert_called_once()
mock_serve.assert_called_once()
# At this point, mock_mcp_run has completed, and the finally block in
# At this point, mock_serve has completed, and the finally block in
# StreamableHTTPSessionManager's run_server should have executed.
# To ensure the task spawned by handle_request finishes and cleanup occurs:
@ -158,10 +159,9 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
@pytest.mark.anyio
async def test_stateful_session_cleanup_on_exception(running_manager: tuple[StreamableHTTPSessionManager, Server]):
manager, app = running_manager
manager, _app = running_manager
mock_mcp_run = AsyncMock(side_effect=TestException("Simulated crash"))
app.run = mock_mcp_run
mock_serve = AsyncMock(side_effect=TestException("Simulated crash"))
sent_messages: list[Message] = []
@ -184,7 +184,8 @@ async def test_stateful_session_cleanup_on_exception(running_manager: tuple[Stre
return {"type": "http.request", "body": b"", "more_body": False}
# Trigger session creation
await manager.handle_request(scope, mock_receive, mock_send)
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
await manager.handle_request(scope, mock_receive, mock_send)
session_id = None
for msg in sent_messages: # pragma: no branch
@ -198,7 +199,7 @@ async def test_stateful_session_cleanup_on_exception(running_manager: tuple[Stre
assert session_id is not None, "Session ID not found in response headers"
mock_mcp_run.assert_called_once()
mock_serve.assert_called_once()
# Give other tasks a chance to run to ensure the finally block executes
await anyio.sleep(0.01)
@ -229,9 +230,6 @@ async def test_stateless_requests_memory_cleanup():
with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=track_transport):
async with manager.run():
# Mock app.run to complete immediately
app.run = AsyncMock(return_value=None)
# Send a simple request
sent_messages: list[Message] = []

View file

@ -2,61 +2,38 @@
The interaction suite under ``tests/interaction/transports/test_hosting_http_modern.py`` pins
the wire contract end to end; these tests cover the module's internal seams directly --
the closed back-channel on the dispatcher and dispatch context, the exception-to-error
mapping in ``handle()``, and the request-validation ladder in ``handle_modern_request``.
the closed back-channel on the dispatch context, and the request-validation ladder in
``handle_modern_request``.
"""
import logging
from collections.abc import Mapping
from typing import Any
import anyio
import httpx
import pytest
from starlette.requests import Request
from starlette.types import Receive, Scope, Send
import mcp.server._streamable_http_modern as modern
from mcp.server import Server, ServerRequestContext
from mcp.server._streamable_http_modern import (
SingleExchangeDispatcher,
_SingleExchangeDispatchContext,
handle_modern_request,
)
from mcp.server import Server, ServerRequestContext, runner
from mcp.server._streamable_http_modern import _SingleExchangeDispatchContext, handle_modern_request
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.dispatcher import DispatchContext
from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.transport_context import TransportContext
from mcp.types import INVALID_PARAMS, PARSE_ERROR, JSONRPCError, JSONRPCRequest, ListToolsResult, PaginatedRequestParams
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INVALID_REQUEST,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
ListToolsResult,
PaginatedRequestParams,
)
pytestmark = pytest.mark.anyio
def _request() -> Request:
return Request({"type": "http", "method": "POST", "headers": []})
async def test_single_exchange_dispatcher_has_no_back_channel_and_is_never_driven() -> None:
"""The dispatcher refuses server-initiated requests, drops notifications, and is not run-driven.
A 2026-07-28 POST has no channel for the server to push to the client, and ``ServerRunner``
never calls ``run()`` on this dispatcher -- ``handle()`` is invoked directly per request.
"""
dispatcher = SingleExchangeDispatcher(_request())
with pytest.raises(NoBackChannelError):
await dispatcher.send_raw_request("sampling/createMessage", None)
assert await dispatcher.notify("notifications/message", None) is None
async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
raise AssertionError("unreachable") # pragma: no cover
async def on_notify(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> None:
raise AssertionError("unreachable") # pragma: no cover
with pytest.raises(RuntimeError, match="never driven"):
await dispatcher.run(on_request, on_notify)
async def test_single_exchange_dispatch_context_has_no_back_channel() -> None:
"""The per-request dispatch context refuses server-initiated requests and drops notify/progress."""
dctx = _SingleExchangeDispatchContext(
@ -71,41 +48,49 @@ async def test_single_exchange_dispatch_context_has_no_back_channel() -> None:
assert await dctx.progress(0.5, total=1.0, message="half") is None
async def test_handle_maps_validation_error_to_invalid_params() -> None:
"""A handler raising ``ValidationError`` is mapped to a ``-32602`` JSON-RPC error.
Mirrors ``JSONRPCDispatcher``'s exception-to-wire boundary: a Pydantic validation failure
inside the handler becomes ``INVALID_PARAMS`` rather than the generic internal error.
"""
async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
JSONRPCRequest.model_validate({}) # raises ValidationError
raise AssertionError("unreachable") # pragma: no cover
dispatcher = SingleExchangeDispatcher(_request())
msg = await dispatcher.handle(JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/call", params={}), on_request)
assert isinstance(msg, JSONRPCError)
assert msg.id == 7
assert msg.error.code == INVALID_PARAMS
def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient:
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await handle_modern_request(server, security_settings, "2026-07-28", scope, receive, send)
async with server.lifespan(server) as lifespan_state:
await handle_modern_request(server, security_settings, lifespan_state, scope, receive, send)
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
headers={MCP_PROTOCOL_VERSION_HEADER: MODERN_PROTOCOL_VERSIONS[0]},
)
async def test_handle_modern_request_rejects_non_post_with_405() -> None:
"""A GET on the 2026-07-28 entry is answered with 405 before any body is read."""
async def test_handle_modern_request_rejects_non_post_with_http_405_and_allow_header() -> None:
"""SDK-defined: a GET on the modern entry is an HTTP-verb mismatch — 405 Method Not
Allowed with ``Allow: POST`` per RFC 9110. This is HTTP-layer (before JSON-RPC parsing)
so there is no JSON-RPC body."""
async with _asgi_client(Server("test")) as http:
response = await http.get("/mcp")
assert response.status_code == 405
assert response.headers["allow"] == "POST"
assert response.content == b""
async def test_handle_modern_request_rejects_a_notification_body_with_invalid_request() -> None:
"""SDK-defined: well-formed JSON that isn't a single JSON-RPC request object (e.g. a
notification, which lacks ``id``) is ``INVALID_REQUEST`` — distinct from ``PARSE_ERROR``,
which is for malformed JSON."""
async with _asgi_client(Server("test")) as http:
response = await http.post(
"/mcp",
content=b'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}',
headers={"content-type": "application/json"},
)
assert response.status_code == 400
assert response.json()["error"]["code"] == INVALID_REQUEST
async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None:
"""A POST whose body is not a valid ``JSONRPCRequest`` returns 400 with ``-32700``."""
"""An unparseable POST body yields HTTP 400 with a ``PARSE_ERROR`` JSON-RPC error envelope.
SDK-defined: the 400 status comes from the SDK's error-code→HTTP-status table; spec-mandated: the
body is a full JSON-RPC error object with ``id: null`` and code ``-32700``.
"""
async with _asgi_client(Server("test")) as http:
response = await http.post("/mcp", content=b"not json", headers={"content-type": "application/json"})
assert response.status_code == 400
@ -113,7 +98,7 @@ async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -
assert response.json() == {
"jsonrpc": "2.0",
"id": None,
"error": {"code": PARSE_ERROR, "message": "Parse error", "data": None},
"error": {"code": PARSE_ERROR, "message": "Parse error"},
}
@ -129,20 +114,45 @@ async def test_handle_modern_request_returns_transport_security_error_response()
def _list_tools_body() -> dict[str, Any]:
"""A minimal valid 2026-07-28 ``tools/list`` request body, including the required ``_meta`` envelope."""
meta = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "0.0.0"},
"io.modelcontextprotocol/clientCapabilities": {},
PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0],
CLIENT_INFO_META_KEY: {"name": "raw", "version": "0.0.0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}}
async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info() -> None:
"""SDK-defined: a mis-shaped ``clientInfo`` envelope value is treated as not supplied —
the request still routes (200 + result) and the handler observes ``client_params is None``
rather than the request being rejected at the validation ladder. A non-spec method is
used so the kernel's per-method params validation does not re-reject the envelope."""
seen: list[object] = []
async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> dict[str, Any]:
seen.append(ctx.session.client_params)
return {"ok": True}
server: Server[Any] = Server("test")
server.add_request_handler("custom/greet", PaginatedRequestParams, greet)
body = _list_tools_body()
body["method"] = "custom/greet"
body["params"]["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
async with _asgi_client(server) as http:
response = await http.post("/mcp", json=body, headers={"content-type": "application/json"})
assert response.status_code == 200
assert response.json()["result"] == {"ok": True}
assert seen == [None]
async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_raises(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A raising ``connection.exit_stack`` callback is logged and swallowed; the computed result still ships.
The exit-stack guard mirrors ``ServerRunner.run``: cleanup runs in a ``finally`` after the
handler, and an exception there must not displace the JSON-RPC response that was already built.
The exit-stack guard is `aclose_shielded`: cleanup runs in `serve_one`'s ``finally`` after
the handler, and an exception there must not displace the JSON-RPC response that was already
built.
"""
async def boom() -> None:
@ -152,7 +162,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_rais
ctx.session._connection.exit_stack.push_async_callback(boom)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
with caplog.at_level(logging.ERROR, logger=modern.__name__):
with caplog.at_level(logging.ERROR, logger=runner.__name__):
async with _asgi_client(Server("test", on_list_tools=list_tools)) as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"})
@ -170,7 +180,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hang
blocker at its first checkpoint, the abandonment warning is logged, and the JSON-RPC response
that was built before cleanup is sent unchanged.
"""
monkeypatch.setattr(modern, "_EXIT_STACK_CLOSE_TIMEOUT", 0)
monkeypatch.setattr(runner, "_EXIT_STACK_CLOSE_TIMEOUT", 0)
async def block() -> None:
await anyio.Event().wait()
@ -180,7 +190,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hang
ctx.session._connection.exit_stack.push_async_callback(block)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=modern.__name__):
with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=runner.__name__):
async with _asgi_client(Server("test", on_list_tools=list_tools)) as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"})
# coverage.py on Python 3.11 misreports the lines below as unhit (the test passes there);