Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)

This commit is contained in:
Max 2026-06-25 16:09:23 +02:00 • committed by GitHub
commit f226d00d0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2533 additions and 993 deletions

View file

@ -43,7 +43,7 @@ async def call_tool_and_assert(
text_contains: list[str] | None = None,
):
"""Helper to create session, call tool, and assert result."""
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool(tool_name, args)
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -122,7 +122,7 @@ async def test_elicitation_schema_validation():
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover
return ElicitResult(action="accept", content={})
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
# Test both invalid schemas
for tool_name, field_name in [("invalid_list", "numbers"), ("nested_model", "nested")]:
result = await client.call_tool(tool_name, {})

View file

@ -105,7 +105,7 @@ async def elicitation_callback(context: ClientRequestContext, params: ElicitRequ
async def test_basic_tools() -> None:
"""Test basic tool functionality."""
async with Client(basic_tool.mcp) as client:
assert client.initialize_result.capabilities.tools is not None
assert client.server_capabilities.tools is not None
# Test sum tool
tool_result = await client.call_tool("sum", {"a": 5, "b": 3})
@ -123,7 +123,7 @@ async def test_basic_tools() -> None:
async def test_basic_resources() -> None:
"""Test basic resource functionality."""
async with Client(basic_resource.mcp) as client:
assert client.initialize_result.capabilities.resources is not None
assert client.server_capabilities.resources is not None
# Test document resource
doc_content = await client.read_resource("file://documents/readme")
@ -145,7 +145,7 @@ async def test_basic_resources() -> None:
async def test_basic_prompts() -> None:
"""Test basic prompt functionality."""
async with Client(basic_prompt.mcp) as client:
assert client.initialize_result.capabilities.prompts is not None
assert client.server_capabilities.prompts is not None
# Test review_code prompt
prompts = await client.list_prompts()
@ -185,7 +185,7 @@ async def test_tool_progress() -> None:
if isinstance(message, Exception): # pragma: no cover
raise message
async with Client(tool_progress.mcp, message_handler=message_handler) as client:
async with Client(tool_progress.mcp, message_handler=message_handler, mode="legacy") as client:
# Test progress callback
progress_updates = []
@ -215,8 +215,8 @@ async def test_tool_progress() -> None:
async def test_sampling() -> None:
"""Test sampling (LLM interaction) functionality."""
async with Client(sampling.mcp, sampling_callback=sampling_callback) as client:
assert client.initialize_result.capabilities.tools is not None
async with Client(sampling.mcp, sampling_callback=sampling_callback, mode="legacy") as client:
assert client.server_capabilities.tools is not None
# Test sampling tool
sampling_result = await client.call_tool("generate_poem", {"topic": "nature"})
@ -227,7 +227,7 @@ async def test_sampling() -> None:
async def test_elicitation() -> None:
"""Test elicitation (user interaction) functionality."""
async with Client(elicitation.mcp, elicitation_callback=elicitation_callback) as client:
async with Client(elicitation.mcp, elicitation_callback=elicitation_callback, mode="legacy") as client:
# Test booking with unavailable date (triggers elicitation)
booking_result = await client.call_tool(
"book_table",
@ -264,7 +264,7 @@ async def test_notifications() -> None:
if isinstance(message, Exception): # pragma: no cover
raise message
async with Client(notifications.mcp, message_handler=message_handler) as client:
async with Client(notifications.mcp, message_handler=message_handler, mode="legacy") as client:
# Call tool that generates notifications
tool_result = await client.call_tool("process_data", {"data": "test_data"})
assert len(tool_result.content) == 1
@ -286,8 +286,8 @@ async def test_notifications() -> None:
async def test_completion() -> None:
"""Test completion (autocomplete) functionality."""
async with Client(completion.mcp) as client:
assert client.initialize_result.capabilities.resources is not None
assert client.initialize_result.capabilities.prompts is not None
assert client.server_capabilities.resources is not None
assert client.server_capabilities.prompts is not None
# Test resource completion
completion_result = await client.complete(

View file

@ -720,7 +720,7 @@ class TestServerResources:
mcp = MCPServer(resources=[resource])
async with Client(mcp) as client:
assert client.initialize_result.capabilities.resources is not None
assert client.server_capabilities.resources is not None
resources = await client.list_resources()
assert len(resources.resources) == 1
@ -1119,7 +1119,7 @@ class TestContextInjection:
mcp.add_tool(logging_tool)
with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
async with Client(mcp) as client:
async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("logging_tool", {"msg": "test"})
assert len(result.content) == 1
content = result.content[0]
@ -1466,7 +1466,7 @@ class TestServerPrompts:
"""Test error when getting unknown prompt."""
mcp = MCPServer()
async with Client(mcp) as client:
async with Client(mcp, mode="legacy") as client:
with pytest.raises(MCPError, match="Unknown prompt"):
await client.get_prompt("unknown")
@ -1477,7 +1477,7 @@ class TestServerPrompts:
@mcp.prompt()
def prompt_fn(name: str) -> str: ... # pragma: no branch
async with Client(mcp) as client:
async with Client(mcp, mode="legacy") as client:
with pytest.raises(MCPError, match="Missing required arguments"):
await client.get_prompt("prompt_fn")
@ -1515,21 +1515,21 @@ def test_streamable_http_no_redirect() -> None:
assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp"
async def test_report_progress_passes_related_request_id():
"""Test that report_progress passes the request_id as related_request_id.
async def test_report_progress_delegates_to_session_report_progress():
"""Context.report_progress delegates to ServerSession.report_progress unconditionally.
Without related_request_id, the streamable HTTP transport cannot route
progress notifications to the correct SSE stream, causing them to be
silently dropped. See #953 and #2001.
Stream routing (related_request_id, progress-token gating) is encapsulated in the
per-request DispatchContext that ServerSession holds, so Context never inspects
request metadata itself. See #953 and #2001 for the original streamable-HTTP routing bug.
"""
mock_session = AsyncMock()
mock_session.send_progress_notification = AsyncMock()
mock_session.report_progress = AsyncMock()
request_context = ServerRequestContext(
request_id="req-abc-123",
session=mock_session,
method="tools/call",
meta={"progress_token": "tok-1"},
meta=None,
lifespan_context=None,
protocol_version="2025-11-25",
)
@ -1538,13 +1538,7 @@ async def test_report_progress_passes_related_request_id():
await ctx.report_progress(50, 100, message="halfway")
mock_session.send_progress_notification.assert_awaited_once_with(
progress_token="tok-1",
progress=50,
total=100,
message="halfway",
related_request_id="req-abc-123",
)
mock_session.report_progress.assert_awaited_once_with(50, 100, "halfway")
async def test_read_resource_template_error():

View file

@ -25,12 +25,10 @@ async def test_server_name_title_description_version():
# Start server and connect client
async with Client(mcp) as client:
# Access initialization result from session
init_result = await client.session.initialize()
assert init_result.server_info.name == "TestServer"
assert init_result.server_info.title == "Test Server Title"
assert init_result.server_info.description == "This is a test server description."
assert init_result.server_info.version == "1.0"
assert client.server_info.name == "TestServer"
assert client.server_info.title == "Test Server Title"
assert client.server_info.description == "This is a test server description."
assert client.server_info.version == "1.0"
@pytest.mark.anyio

View file

@ -34,7 +34,7 @@ async def test_url_elicitation_accept():
assert params.message == "Please provide your API key to continue."
return ElicitResult(action="accept")
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("request_api_key", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -60,7 +60,7 @@ async def test_url_elicitation_decline():
assert params.mode == "url"
return ElicitResult(action="decline")
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("oauth_flow", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -86,7 +86,7 @@ async def test_url_elicitation_cancel():
assert params.mode == "url"
return ElicitResult(action="cancel")
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("payment_flow", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -112,7 +112,7 @@ async def test_url_elicitation_helper_function():
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept")
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("setup_credentials", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -146,7 +146,7 @@ async def test_url_no_content_in_response():
# Return without content - this is correct for URL mode
return ElicitResult(action="accept")
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("check_url_response", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -177,7 +177,7 @@ async def test_form_mode_still_works():
assert params.requested_schema is not None
return ElicitResult(action="accept", content={"name": "Alice"})
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("ask_name", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -208,7 +208,7 @@ async def test_elicit_complete_notification():
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept") # pragma: no cover
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("trigger_elicitation", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -265,7 +265,7 @@ async def test_elicit_url_typed_results():
async def decline_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="decline")
async with Client(mcp, elicitation_callback=decline_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=decline_callback) as client:
result = await client.call_tool("test_decline", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -275,7 +275,7 @@ async def test_elicit_url_typed_results():
async def cancel_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="cancel")
async with Client(mcp, elicitation_callback=cancel_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=cancel_callback) as client:
result = await client.call_tool("test_cancel", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -308,7 +308,7 @@ async def test_deprecated_elicit_method():
assert params.requested_schema is not None
return ElicitResult(action="accept", content={"email": "test@example.com"})
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("use_deprecated_elicit", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
@ -335,7 +335,7 @@ async def test_ctx_elicit_url_convenience_method():
assert params.elicitation_id == "ctx-test-001"
return ElicitResult(action="accept")
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("direct_elicit_url", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Result: accept"

View file

@ -7,8 +7,8 @@ from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp.shared.message import SessionMessage
from mcp.shared.version import LATEST_HANDSHAKE_VERSION
from mcp.types import (
LATEST_PROTOCOL_VERSION,
CallToolRequest,
CallToolRequestParams,
CallToolResult,
@ -59,7 +59,7 @@ async def test_server_remains_functional_after_cancel():
server = Server("test-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
async with Client(server) as client:
async with Client(server, mode="legacy") as client:
# First request (will be cancelled)
async def first_request():
try:
@ -138,7 +138,7 @@ async def test_server_cancels_in_flight_handlers_on_transport_close():
id=1,
method="initialize",
params=InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test", version="1.0"),
).model_dump(by_alias=True, mode="json", exclude_none=True),
@ -212,7 +212,7 @@ async def test_server_handles_transport_close_with_pending_server_to_client_requ
id=1,
method="initialize",
params=InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test", version="1.0"),
).model_dump(by_alias=True, mode="json", exclude_none=True),

View file

@ -137,7 +137,7 @@ async def test_completion_error_on_missing_context():
server = Server("test-server", on_completion=handle_completion)
async with Client(server) as client:
async with Client(server, mode="legacy") as client:
# Try to complete table without database context - should raise error
with pytest.raises(Exception) as exc_info:
await client.complete(

View file

@ -19,7 +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.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
@ -40,7 +40,6 @@ from mcp.types import (
)
_CLIENT_INFO = Implementation(name="t", version="0")
_MODERN = MODERN_PROTOCOL_VERSIONS[0]
class StubOutbound:
@ -58,7 +57,7 @@ class StubOutbound:
self.requests.append((method, params))
return self._result
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
if self._raise_on_send is not None:
raise self._raise_on_send()
self.notifications.append((method, params))
@ -70,8 +69,8 @@ class StubOutbound:
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
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
assert conn.protocol_version == LATEST_MODERN_VERSION
assert conn.initialized.is_set()
assert conn.initialize_accepted is True
assert conn.has_standalone_channel is False
@ -83,11 +82,11 @@ 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)
conn = Connection.from_envelope(LATEST_MODERN_VERSION, _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
assert conn.client_params.protocol_version == LATEST_MODERN_VERSION
@pytest.mark.parametrize(
@ -99,7 +98,7 @@ def test_from_envelope_leaves_client_params_none_when_either_is_missing(
):
"""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)
conn = Connection.from_envelope(LATEST_MODERN_VERSION, info, caps)
assert conn.client_params is None
@ -107,7 +106,7 @@ 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)
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=out)
assert conn.has_standalone_channel is True
assert conn.outbound is out
assert conn.initialized.is_set()
@ -115,17 +114,17 @@ def test_from_envelope_with_explicit_outbound_has_standalone_channel():
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."""
else `LATEST_HANDSHAKE_VERSION`; the connection awaits the initialize handshake."""
out = StubOutbound()
conn = Connection.for_loop(out)
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
assert conn.protocol_version == LATEST_HANDSHAKE_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
hinted = Connection.for_loop(out, protocol_version_hint=LATEST_MODERN_VERSION)
assert hinted.protocol_version == LATEST_MODERN_VERSION
def test_for_loop_records_session_id_when_supplied():
@ -229,7 +228,7 @@ async def test_send_request_validates_the_client_result_against_the_surface_sche
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.for_loop(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
assert conn.protocol_version == LATEST_HANDSHAKE_VERSION
result = await conn.send_request(ListRootsRequest())
assert isinstance(result, ListRootsResult)
assert str(result.roots[0].uri) == "file:///ws"
@ -248,7 +247,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.for_loop(StubOutbound(result={}), protocol_version_hint=_MODERN)
conn = Connection.for_loop(StubOutbound(result={}), protocol_version_hint=LATEST_MODERN_VERSION)
result = await conn.send_request(PingRequest())
assert isinstance(result, EmptyResult)
@ -328,7 +327,7 @@ def test_connection_check_capability_false_when_no_client_params_recorded():
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
assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False
@pytest.mark.parametrize(

View file

@ -4,7 +4,7 @@ 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.
`aclose_shielded`) follow at the bottom.
"""
from collections.abc import AsyncIterator, Mapping
@ -30,7 +30,6 @@ from mcp.server.runner import (
otel_middleware,
serve_connection,
serve_one,
to_jsonrpc_response,
)
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import CallOptions, DispatchContext, DispatchMiddleware, OnRequest
@ -39,7 +38,7 @@ from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
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 MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
@ -50,9 +49,6 @@ from mcp.types import (
ErrorData,
Implementation,
InitializeRequestParams,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
ListToolsResult,
NotificationParams,
PaginatedRequestParams,
@ -71,7 +67,7 @@ Ctx = ServerRequestContext[dict[str, Any], Any]
def _initialize_params() -> dict[str, Any]:
return InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test-client", version="1.0"),
).model_dump(by_alias=True, exclude_none=True)
@ -168,7 +164,7 @@ async def test_runner_handles_initialize_and_populates_connection(server: SrvT):
assert "tools" in result["capabilities"]
assert runner.connection.client_params is not None
assert runner.connection.client_params.client_info.name == "test-client"
assert runner.connection.protocol_version == LATEST_PROTOCOL_VERSION
assert runner.connection.protocol_version == LATEST_HANDSHAKE_VERSION
assert runner.connection.initialize_accepted is True
@ -245,7 +241,7 @@ async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
assert isinstance(ctx.session, ServerSession)
assert ctx.session.protocol_version == runner.connection.protocol_version
assert ctx.request_id is not None
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
assert ctx.protocol_version == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
@ -290,7 +286,7 @@ async def test_runner_rejects_snake_case_initialize_params(server: SrvT):
"""Inbound wire payloads validate alias-only; Python field names are not
accepted (`protocol_version` must arrive as `protocolVersion`)."""
snake = {
"protocol_version": LATEST_PROTOCOL_VERSION,
"protocol_version": LATEST_HANDSHAKE_VERSION,
"capabilities": {},
"client_info": {"name": "c", "version": "0"},
}
@ -815,7 +811,7 @@ 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)
born_ready = Connection.from_envelope(LATEST_HANDSHAKE_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()
@ -848,7 +844,7 @@ async def test_server_add_request_handler_routes_custom_method_with_validated_pa
@pytest.mark.anyio
async def test_runner_spec_method_with_invalid_params_is_invalid_params_at_the_negotiated_version(server: SrvT):
async with connected_runner(server) as (client, runner):
assert runner.connection.protocol_version == LATEST_PROTOCOL_VERSION
assert runner.connection.protocol_version == LATEST_HANDSHAKE_VERSION
with pytest.raises(MCPError) as exc:
await client.send_raw_request("tools/call", {"name": 42})
assert exc.value.error.code == INVALID_PARAMS
@ -927,9 +923,9 @@ async def test_runner_spec_method_absent_at_the_negotiated_version_is_method_not
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)
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner):
assert runner.connection.protocol_version == MODERN_PROTOCOL_VERSIONS[0]
assert runner.connection.protocol_version == LATEST_MODERN_VERSION
with pytest.raises(MCPError) as exc:
await client.send_raw_request("initialize", _initialize_params())
assert exc.value.error.code == METHOD_NOT_FOUND
@ -944,7 +940,7 @@ async def test_on_request_dispatches_custom_method_registered_via_add_request_ha
return {"echoed": True}
server.add_request_handler("myorg/echo", RequestParams, echo)
born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, 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}
@ -998,7 +994,7 @@ async def test_runner_initialize_result_reflects_init_options():
@pytest.mark.anyio
async def test_runner_initialize_echoes_supported_version_and_falls_back_to_latest(server: SrvT):
oldest = SUPPORTED_PROTOCOL_VERSIONS[0]
oldest = OLDEST_SUPPORTED_VERSION
async with connected_runner(server, initialized=False) as (client, _):
params = {**_initialize_params(), "protocolVersion": oldest}
result = await client.send_raw_request("initialize", params)
@ -1006,7 +1002,7 @@ async def test_runner_initialize_echoes_supported_version_and_falls_back_to_late
async with connected_runner(server, initialized=False) as (client, _):
params = {**_initialize_params(), "protocolVersion": "1999-01-01"}
result = await client.send_raw_request("initialize", params)
assert result["protocolVersion"] == LATEST_PROTOCOL_VERSION
assert result["protocolVersion"] == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
@ -1202,71 +1198,6 @@ async def test_runner_exit_stack_fast_cleanup_completes_within_grace(
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 -----------------------------------------------------------
@ -1310,7 +1241,7 @@ class _StubDispatchContext:
) -> dict[str, Any]:
raise NotImplementedError
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
raise NotImplementedError
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
@ -1325,34 +1256,34 @@ _LIFESPAN: dict[str, Any] = {}
@pytest.mark.anyio
async def test_serve_one_runs_handler_and_returns_jsonrpc_response(server: SrvT):
async def test_serve_one_runs_handler_and_returns_result_dict(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)
returns the agnostic result dict, and tears down `connection.exit_stack`."""
conn = Connection.from_envelope(LATEST_HANDSHAKE_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"
result = await serve_one(
server, _StubDispatchContext(9), "tools/list", None, connection=conn, lifespan_state=_LIFESPAN
)
assert result["tools"][0]["name"] == "t"
assert cleaned == [1]
ctx = _seen_ctx[0]
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
assert ctx.protocol_version == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
async def test_serve_one_maps_error_to_jsonrpc_error_and_still_closes_exit_stack(server: SrvT):
async def test_serve_one_propagates_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)
unregistered method) propagates as `MCPError`, and the per-request exit
stack is closed on the error path too."""
conn = Connection.from_envelope(LATEST_HANDSHAKE_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
with pytest.raises(MCPError) as exc_info:
await serve_one(
server, _StubDispatchContext(2), "resources/list", None, connection=conn, lifespan_state=_LIFESPAN
)
assert exc_info.value.error.code == METHOD_NOT_FOUND
assert cleaned == [1]
@ -1361,11 +1292,17 @@ async def test_serve_one_reads_connection_protocol_version_as_a_fact(server: Srv
"""`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
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
with pytest.raises(MCPError) as exc_info:
await serve_one(
server,
_StubDispatchContext(1),
"logging/setLevel",
{"level": "info"},
connection=conn,
lifespan_state=_LIFESPAN,
)
assert exc_info.value.error.code == METHOD_NOT_FOUND
@pytest.mark.anyio
@ -1389,5 +1326,5 @@ async def test_serve_connection_drives_dispatcher_loop_and_tears_down(server: Sr
assert cleaned == []
close()
assert cleaned == [1]
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
assert conn.protocol_version == LATEST_HANDSHAKE_VERSION
assert conn.client_params is not None

View file

@ -15,11 +15,10 @@ from pydantic import ValidationError
from mcp import types
from mcp.server.connection import Connection
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import CallOptions, Outbound
from mcp.shared.dispatcher import CallOptions
from mcp.shared.message import ServerMessageMetadata
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
Implementation,
SamplingCapability,
@ -28,11 +27,21 @@ from mcp.types import (
class StubOutbound:
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
"""Records `send_raw_request` / `notify` / `progress` calls and returns a canned result.
Structurally a `DispatchContext[Any]` so it can stand in for the per-request channel.
"""
transport: Any = None
can_send_request: bool = True
request_id: Any = None
message_metadata: Any = None
cancel_requested: Any = None
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.progress_calls: list[tuple[float, float | None, str | None]] = []
self.result = result if result is not None else {}
async def send_raw_request(
@ -44,15 +53,18 @@ class StubOutbound:
self.requests.append((method, params, opts))
return self.result
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
self.notifications.append((method, params))
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
self.progress_calls.append((progress, total, message))
def _make_session(
outbound: StubOutbound,
*,
capabilities: ClientCapabilities | None = None,
protocol_version: str = LATEST_PROTOCOL_VERSION,
protocol_version: str = LATEST_HANDSHAKE_VERSION,
) -> ServerSession:
"""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
@ -60,9 +72,9 @@ def _make_session(
return ServerSession(outbound, conn)
def _two_channel_session(request_ch: Outbound, standalone_ch: Outbound) -> ServerSession:
def _two_channel_session(request_ch: StubOutbound, standalone_ch: StubOutbound) -> 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)
conn = Connection.from_envelope(LATEST_HANDSHAKE_VERSION, None, None, outbound=standalone_ch)
return ServerSession(request_ch, conn)
@ -145,6 +157,19 @@ async def test_send_notification_routes_by_related_request_id():
assert [m for m, _ in request_ch.notifications] == ["notifications/progress"]
@pytest.mark.anyio
async def test_report_progress_delegates_to_the_request_dispatch_context():
"""`report_progress` calls the per-request `DispatchContext.progress` seam, never the
standalone channel: token gating and routing live in the dispatcher, not here."""
request_ch = StubOutbound()
standalone_ch = StubOutbound()
session = _two_channel_session(request_ch, standalone_ch)
await session.report_progress(0.5, total=1.0, message="halfway")
assert request_ch.progress_calls == [(0.5, 1.0, "halfway")]
assert standalone_ch.progress_calls == []
assert request_ch.notifications == []
@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
@ -167,7 +192,7 @@ async def test_send_request_passes_a_spec_valid_client_result():
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
"""Surface row absent for the connection's version: gate is bypassed and only
`result_type` validates."""
session = _make_session(StubOutbound(result={}), protocol_version=MODERN_PROTOCOL_VERSIONS[0])
session = _make_session(StubOutbound(result={}), protocol_version=LATEST_MODERN_VERSION)
result = await session.send_request(types.PingRequest(), types.EmptyResult)
assert isinstance(result, types.EmptyResult)

View file

@ -21,7 +21,16 @@ from mcp.types import LATEST_PROTOCOL_VERSION
class StubOutbound:
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
"""Records `send_raw_request` / `notify` calls and returns a canned result.
Structurally a `DispatchContext[Any]` so it can stand in for the per-request channel.
"""
transport: Any = None
can_send_request: bool = True
request_id: Any = None
message_metadata: Any = None
cancel_requested: Any = None
def __init__(self, result: dict[str, Any] | None = None) -> None:
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = []
@ -37,9 +46,12 @@ class StubOutbound:
self.requests.append((method, params, opts))
return self.result
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
self.notifications.append((method, params))
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
raise NotImplementedError # pragma: no cover
def _no_channel_session(request_ch: StubOutbound | None = None) -> tuple[ServerSession, StubOutbound]:
"""A session whose standalone channel is the connection's no-channel

View file

@ -333,7 +333,7 @@ async def test_e2e_streamable_http_server_cleanup():
mcp_app.router.lifespan_context(mcp_app),
httpx.ASGITransport(mcp_app) as transport,
httpx.AsyncClient(transport=transport) as http_client,
Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client)) as client,
Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client), mode="legacy") as client,
):
await client.list_tools()

View file

@ -15,20 +15,31 @@ import pytest
from starlette.types import Receive, Scope, Send
from mcp.server import Server, ServerRequestContext, runner
from mcp.server._streamable_http_modern import _SingleExchangeDispatchContext, handle_modern_request
from mcp.server._streamable_http_modern import (
_SingleExchangeDispatchContext,
_to_jsonrpc_response,
handle_modern_request,
)
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.shared.version import LATEST_MODERN_VERSION
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
ErrorData,
JSONRPCError,
JSONRPCResponse,
ListToolsResult,
PaginatedRequestParams,
Tool,
)
pytestmark = pytest.mark.anyio
@ -56,7 +67,7 @@ def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettin
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
headers={MCP_PROTOCOL_VERSION_HEADER: MODERN_PROTOCOL_VERSIONS[0]},
headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION},
)
@ -114,7 +125,7 @@ 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 = {
PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0],
PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION,
CLIENT_INFO_META_KEY: {"name": "raw", "version": "0.0.0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
@ -198,3 +209,64 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hang
assert response.status_code == 200 # pragma: lax no cover
assert response.json()["result"]["tools"] == [] # pragma: lax no cover
assert "abandoning remaining callbacks" in caplog.text # pragma: lax no cover
# --- _to_jsonrpc_response ------------------------------------------------------
async def test_to_jsonrpc_response_wraps_success_as_jsonrpc_response() -> None:
"""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"}
async def test_to_jsonrpc_response_maps_mcp_error_to_jsonrpc_error() -> None:
"""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")
async def test_to_jsonrpc_response_maps_validation_error_to_invalid_params() -> None:
"""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="")
async def test_to_jsonrpc_response_maps_unmapped_exception_to_internal_error_and_logs(
caplog: pytest.LogCaptureFixture,
) -> None:
"""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