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
5 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"