Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)
This commit is contained in:
parent
ae13ede143
commit
f226d00d0a
5 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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue