[v2] ClientSession runs on JSONRPCDispatcher; BaseSession removed (#2838)

This commit is contained in:
Max 2026-06-15 14:46:34 +01:00 • committed by GitHub
commit 1012d60004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 2886 additions and 1734 deletions

View file

@ -6,9 +6,9 @@ import pytest
from pydantic import BaseModel, Field
from mcp import Client, types
from mcp.client.session import ClientSession, ElicitationFnT
from mcp.client import ClientRequestContext
from mcp.client.session import ElicitationFnT
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared._context import RequestContext
from mcp.types import ElicitRequestParams, ElicitResult, TextContent
@ -64,7 +64,7 @@ async def test_elicitation_accept_returns_the_users_answer_to_the_tool():
create_ask_user_tool(mcp)
# Create a custom handler for elicitation requests
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
if params.message == "Tool wants to ask: What is your name?":
return ElicitResult(action="accept", content={"answer": "Test User"})
else: # pragma: no cover
@ -81,7 +81,7 @@ async def test_elicitation_decline_reaches_the_tool_without_content():
mcp = MCPServer(name="ElicitationDeclineServer")
create_ask_user_tool(mcp)
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="decline")
await call_tool_and_assert(
@ -119,9 +119,7 @@ async def test_elicitation_schema_validation():
create_validation_tool("nested_model", InvalidNestedSchema)
# Dummy callback (won't be called due to validation failure)
async def elicitation_callback(
context: RequestContext[ClientSession], params: ElicitRequestParams
): # pragma: no cover
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:
@ -176,7 +174,7 @@ async def test_elicitation_with_optional_fields():
for content, expected in test_cases:
async def callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept", content=content)
await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected)
@ -194,9 +192,7 @@ async def test_elicitation_with_optional_fields():
except TypeError as e:
return f"Validation failed: {str(e)}"
async def elicitation_callback(
context: RequestContext[ClientSession], params: ElicitRequestParams
): # pragma: no cover
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover
return ElicitResult(action="accept", content={})
await call_tool_and_assert(
@ -219,7 +215,7 @@ async def test_elicitation_with_optional_fields():
return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}"
return f"User {result.action}" # pragma: no cover
async def multiselect_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "Please provide tags" in params.message:
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
return ElicitResult(action="decline") # pragma: no cover
@ -239,7 +235,7 @@ async def test_elicitation_with_optional_fields():
return f"Name: {result.data.name}, Tags: {tags_str}"
return f"User {result.action}" # pragma: no cover
async def optional_multiselect_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def optional_multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "Please provide optional tags" in params.message:
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
return ElicitResult(action="decline") # pragma: no cover
@ -273,7 +269,7 @@ async def test_elicitation_with_default_values():
return f"User {result.action}"
# First verify that defaults are present in the JSON schema sent to clients
async def callback_schema_verify(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def callback_schema_verify(context: ClientRequestContext, params: ElicitRequestParams):
# Verify the schema includes defaults
assert isinstance(params, types.ElicitRequestFormParams), "Expected form mode elicitation"
schema = params.requested_schema
@ -295,7 +291,7 @@ async def test_elicitation_with_default_values():
)
# Test overriding defaults
async def callback_override(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def callback_override(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(
action="accept", content={"email": "john@example.com", "name": "John", "age": 25, "subscribe": False}
)
@ -371,7 +367,7 @@ async def test_elicitation_with_enum_titles():
return f"User: {result.data.user_name}, Color: {result.data.color}"
return f"User {result.action}" # pragma: no cover
async def enum_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def enum_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "colors" in params.message and "legacy" not in params.message:
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
elif "color" in params.message:

View file

@ -26,9 +26,7 @@ from examples.snippets.servers import (
structured_output,
tool_progress,
)
from mcp.client import Client
from mcp.client.session import ClientSession
from mcp.shared._context import RequestContext
from mcp.client import Client, ClientRequestContext
from mcp.shared.session import RequestResponder
from mcp.types import (
ClientResult,
@ -80,9 +78,7 @@ class NotificationCollector:
self.tool_notifications.append(message.params)
async def sampling_callback(
context: RequestContext[ClientSession], params: CreateMessageRequestParams
) -> CreateMessageResult:
async def sampling_callback(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
"""Sampling callback for tests."""
return CreateMessageResult(
role="assistant",
@ -94,7 +90,7 @@ async def sampling_callback(
)
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
"""Elicitation callback for tests."""
# For restaurant booking test
if "No tables available" in params.message:

View file

@ -1098,10 +1098,10 @@ class TestContextInjection:
assert "Logged messages for test" in content.text
assert mock_log.call_count == 4
mock_log.assert_any_call(level="debug", data="Debug message", logger=None, related_request_id="1")
mock_log.assert_any_call(level="info", data="Info message", logger=None, related_request_id="1")
mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="1")
mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="1")
mock_log.assert_any_call(level="debug", data="Debug message", logger=None, related_request_id="2")
mock_log.assert_any_call(level="info", data="Info message", logger=None, related_request_id="2")
mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="2")
mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="2")
async def test_optional_context(self):
"""Test that context is optional."""

View file

@ -5,10 +5,9 @@ import pytest
from pydantic import BaseModel, Field
from mcp import Client, types
from mcp.client.session import ClientSession
from mcp.client import ClientRequestContext
from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation, elicit_url
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared._context import RequestContext
from mcp.types import ElicitRequestParams, ElicitResult, TextContent
@ -28,7 +27,7 @@ async def test_url_elicitation_accept():
return f"User {result.action}"
# Create elicitation callback that accepts URL mode
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
assert params.url == "https://example.com/api_key_setup"
assert params.elicitation_id == "test-elicitation-001"
@ -57,7 +56,7 @@ async def test_url_elicitation_decline():
# Test only checks decline path
return f"User {result.action} authorization"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
return ElicitResult(action="decline")
@ -83,7 +82,7 @@ async def test_url_elicitation_cancel():
# Test only checks cancel path
return f"User {result.action} payment"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
return ElicitResult(action="cancel")
@ -110,7 +109,7 @@ async def test_url_elicitation_helper_function():
# Test only checks accept path - return the type name
return type(result).__name__
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept")
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
@ -137,7 +136,7 @@ async def test_url_no_content_in_response():
assert result.content is None
return f"Action: {result.action}, Content: {result.content}"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify that this is URL mode
assert params.mode == "url"
assert isinstance(params, types.ElicitRequestURLParams)
@ -170,7 +169,7 @@ async def test_form_mode_still_works():
assert result.data is not None
return f"Hello, {result.data.name}!"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify form mode parameters
assert params.mode == "form"
assert isinstance(params, types.ElicitRequestFormParams)
@ -206,7 +205,7 @@ async def test_elicit_complete_notification():
return "Elicitation completed"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
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:
@ -263,7 +262,7 @@ async def test_elicit_url_typed_results():
return "Not cancelled" # pragma: no cover
# Test declined result
async def decline_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def decline_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="decline")
async with Client(mcp, elicitation_callback=decline_callback) as client:
@ -273,7 +272,7 @@ async def test_elicit_url_typed_results():
assert result.content[0].text == "Declined"
# Test cancelled result
async def cancel_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def cancel_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="cancel")
async with Client(mcp, elicitation_callback=cancel_callback) as client:
@ -303,7 +302,7 @@ async def test_deprecated_elicit_method():
return f"Email: {result.content.get('email', 'none')}"
return "No email provided" # pragma: no cover
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify this is form mode
assert params.mode == "form"
assert params.requested_schema is not None
@ -331,7 +330,7 @@ async def test_ctx_elicit_url_convenience_method():
)
return f"Result: {result.action}"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
assert params.elicitation_id == "ctx-test-001"
return ElicitResult(action="accept")

View file

@ -103,13 +103,13 @@ async def test_send_request_omits_call_options_when_none_given():
@pytest.mark.anyio
async def test_send_request_timeout_zero_means_no_timeout():
"""0 falls through BaseSession's `or`-fallback, so it has always meant
"no timeout"; ClientSession still reads it that way."""
async def test_send_request_timeout_zero_is_forwarded():
"""0 is a real timeout (fail at the first checkpoint, `anyio.fail_after(0)`
semantics) and must reach the dispatcher; only `None` means "no timeout"."""
dispatcher = StubDispatcher(result={})
session = _make_session(dispatcher)
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0)
assert dispatcher.requests[0][2] is None
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.0)
assert dispatcher.requests[0][2] == {"timeout": 0.0}
@pytest.mark.anyio