Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)
This commit is contained in:
parent
ae13ede143
commit
f226d00d0a
13 changed files with 2533 additions and 993 deletions
|
|
@ -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, {})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue