2222 ClientCapabilities ,
2323 CreateMessageRequest ,
2424 CreateMessageRequestParams ,
25- DiscoverResult ,
2625 ElicitRequest ,
2726 ElicitRequestFormParams ,
2827 ElicitResult ,
5049from mcp .client import ClientRequestContext , ClientSession
5150from mcp .client .client import Client
5251from mcp .client .streamable_http import streamable_http_client
53- from mcp .server import Server , ServerRequestContext
52+ from mcp .server import MCPServer , Server , ServerRequestContext
53+ from mcp .server .context import CallNext , HandlerResult
54+ from mcp .server .extension import Extension
5455from mcp .shared .exceptions import NoBackChannelError
5556from mcp .shared .memory import MessageStream , create_client_server_memory_streams
5657from mcp .shared .message import SessionMessage
@@ -754,8 +755,10 @@ async def test_parallel_mrtr_calls_keep_request_state_and_responses_isolated() -
754755 """Parallel MRTR calls keep requestState and inputResponses scoped to their originating request.
755756
756757 A symmetric rendezvous in the elicitation callback forces both loops mid-flight before either
757- retry leaves; the exhaustive scan over every recorded tools/call frame proves no leak (spec MUST NOT).
758+ retry leaves (spec MUST NOT). Handler capture suffices: every tools/call the client sends is
759+ delivered to the handler, so the captured rounds are 1:1 with the sent frames.
758760 """
761+ rounds : list [tuple [str , str | None , set [str ] | None ]] = []
759762
760763 async def list_tools (
761764 ctx : ServerRequestContext , params : types .PaginatedRequestParams | None
@@ -772,12 +775,14 @@ async def call_tool(
772775 ) -> CallToolResult | InputRequiredResult :
773776 assert params .name in ("alpha" , "beta" )
774777 name = params .name
778+ rounds .append (
779+ (name , params .request_state , None if params .input_responses is None else set (params .input_responses ))
780+ )
775781 if params .input_responses is None :
776782 return InputRequiredResult (
777783 input_requests = {f"q-{ name } " : _form_request (f"for { name } " )},
778784 request_state = f"state-{ name } " ,
779785 )
780- assert params .request_state == f"state-{ name } "
781786 return CallToolResult (content = [TextContent (text = name )])
782787
783788 server = Server ("parallel" , on_list_tools = list_tools , on_call_tool = call_tool )
@@ -799,12 +804,7 @@ async def answer(context: ClientRequestContext, params: types.ElicitRequestParam
799804
800805 with anyio .fail_after (5 ):
801806 async with (
802- mounted_app (server ) as (http , _ ),
803- Client (
804- recording := RecordingTransport (streamable_http_client (f"{ BASE_URL } /mcp" , http_client = http )),
805- mode = LATEST_MODERN_VERSION ,
806- elicitation_callback = answer ,
807- ) as client ,
807+ Client (server , mode = LATEST_MODERN_VERSION , elicitation_callback = answer ) as client ,
808808 # Last item so it exits first: both calls complete while the client is still open.
809809 anyio .create_task_group () as task_group ,
810810 ):
@@ -815,28 +815,10 @@ async def call(name: str) -> None:
815815 task_group .start_soon (call , "alpha" )
816816 task_group .start_soon (call , "beta" )
817817
818- frames = [
819- message .message
820- for message in recording .sent
821- if isinstance (message .message , JSONRPCRequest ) and message .message .method == "tools/call"
822- ]
823- by_name : dict [str , list [dict [str , Any ]]] = {"alpha" : [], "beta" : []}
824- for frame in frames :
825- assert frame .params is not None
826- by_name [frame .params ["name" ]].append (frame .params )
827- for name , sent_params in by_name .items ():
828- assert len (sent_params ) == 2
829- initial , retry = sent_params
830- assert "requestState" not in initial
831- assert "inputResponses" not in initial
832- assert retry ["requestState" ] == f"state-{ name } "
833- assert set (retry ["inputResponses" ]) == {f"q-{ name } " }
834- # The exhaustive negative: no frame anywhere carries the other call's state or responses.
835- for params in (frame .params for frame in frames ):
836- assert params is not None
837- other = "beta" if params ["name" ] == "alpha" else "alpha"
838- assert params .get ("requestState" ) in (None , f"state-{ params ['name' ]} " )
839- assert f"q-{ other } " not in params .get ("inputResponses" , {})
818+ # The rendezvous guarantees both initial rounds land before either retry; order within a phase is free.
819+ assert sorted (rounds [:2 ]) == [("alpha" , None , None ), ("beta" , None , None )]
820+ # Each retry carries exactly its own call's state and response key -- nothing crossed over.
821+ assert sorted (rounds [2 :]) == [("alpha" , "state-alpha" , {"q-alpha" }), ("beta" , "state-beta" , {"q-beta" })]
840822 assert results == {
841823 "alpha" : CallToolResult (content = [TextContent (text = "alpha" )]),
842824 "beta" : CallToolResult (content = [TextContent (text = "beta" )]),
@@ -966,7 +948,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
966948 assert error .error == snapshot (ErrorData (code = INVALID_PARAMS , message = "Invalid request parameters" , data = "" ))
967949
968950
969- # --- scripted server peer: result bodies a real Server cannot emit ---
951+ # --- scripted server peer: byte-controlled absence of the resultType key ---
970952
971953
972954@requirement ("protocol:result-type:absent-is-complete" )
@@ -1034,63 +1016,40 @@ def respond(request_id: types.RequestId, result: dict[str, object]) -> SessionMe
10341016 assert result == snapshot (CallToolResult (content = [TextContent (text = "plain" )]))
10351017
10361018
1019+ # --- unrecognized resultType: a server extension puts an arbitrary tag on the wire ---
1020+
1021+
10371022@requirement ("protocol:result-type:unrecognized-invalid" )
1038- async def test_an_unrecognized_result_type_value_is_surfaced_unchanged_instead_of_treated_as_invalid () -> None :
1023+ async def test_an_unrecognized_result_type_value_is_surfaced_unchanged_instead_of_treated_as_invalid (
1024+ connect : Connect ,
1025+ ) -> None :
10391026 """PINS A KNOWN GAP: an unrecognized resultType round-trips instead of being treated as invalid (spec MUST).
10401027
1041- The client's open ResultType union accepts any string. Scripted peer over memory streams
1042- because the typed Server cannot author an arbitrary resultType . When the client starts
1043- rejecting unrecognized resultType values: re-pin to the typed rejection and delete the Divergence.
1028+ The leniency is narrow: the unknown tag survives only because the body also parses as a
1029+ complete core result . When the client starts rejecting unrecognized resultType values:
1030+ re-pin to the typed rejection and delete the Divergence.
10441031 """
10451032
1046- async def scripted_server ( streams : MessageStream ) -> None :
1047- server_read , server_write = streams
1033+ class BogusIssuer ( Extension ) :
1034+ identifier = "com.example/bogus"
10481035
1049- def respond (request_id : types .RequestId , result : dict [str , object ]) -> SessionMessage :
1050- return SessionMessage (JSONRPCResponse (jsonrpc = "2.0" , id = request_id , result = result ))
1036+ async def intercept_tool_call (
1037+ self , params : types .CallToolRequestParams , ctx : ServerRequestContext [Any , Any ], call_next : CallNext
1038+ ) -> HandlerResult :
1039+ assert params .name == "probe"
1040+ # "bogus" is in no core or extension vocabulary -- exactly the value the MUST addresses.
1041+ return {"resultType" : "bogus" , "content" : [{"type" : "text" , "text" : "still here" }]}
10511042
1052- call = await server_read .receive ()
1053- assert isinstance (call , SessionMessage )
1054- assert isinstance (call .message , JSONRPCRequest )
1055- assert call .message .method == "tools/call"
1056- # "bogus" is in no core or extension vocabulary -- exactly the value the MUST addresses.
1057- await server_write .send (
1058- respond (call .message .id , {"resultType" : "bogus" , "content" : [{"type" : "text" , "text" : "still here" }]})
1059- )
1043+ server = MCPServer ("bogus-issuer" , extensions = [BogusIssuer ()])
10601044
1061- # The client's output-schema cache refresh follows the call result; stopping here hangs the test.
1062- refresh = await server_read .receive ()
1063- assert isinstance (refresh , SessionMessage )
1064- assert isinstance (refresh .message , JSONRPCRequest )
1065- assert refresh .message .method == "tools/list"
1066- await server_write .send (
1067- respond (
1068- refresh .message .id ,
1069- {
1070- "tools" : [{"name" : "x" , "inputSchema" : {"type" : "object" }}],
1071- "resultType" : "complete" ,
1072- "ttlMs" : 0 ,
1073- "cacheScope" : "private" ,
1074- },
1075- )
1076- )
1045+ @server .tool ()
1046+ def probe () -> CallToolResult :
1047+ """Probe the unrecognized-tag path."""
1048+ raise NotImplementedError # the server extension answers before the tool runs
10771049
1078- async with (
1079- create_client_server_memory_streams () as ((client_read , client_write ), server_streams ),
1080- anyio .create_task_group () as task_group ,
1081- ClientSession (client_read , client_write , client_info = Implementation (name = "cli" , version = "0" )) as session ,
1082- ):
1083- task_group .start_soon (scripted_server , server_streams )
1084- session .adopt (
1085- DiscoverResult (
1086- supported_versions = [LATEST_MODERN_VERSION ],
1087- capabilities = ServerCapabilities (),
1088- server_info = Implementation (name = "srv" , version = "0" ),
1089- )
1090- )
1091- with anyio .fail_after (5 ):
1092- result = await session .call_tool ("x" , {})
1050+ async with connect (server ) as client :
1051+ result = await client .call_tool ("probe" , {})
10931052
1094- # The divergent observable: the unrecognized discriminator survives unchanged, never a rejection.
1095- assert result .result_type == "bogus"
1096- assert result == snapshot (CallToolResult (content = [TextContent (text = "still here" )], result_type = "bogus" ))
1053+ # The divergent observable: the unrecognized discriminator survives unchanged, never a rejection.
1054+ assert result .result_type == "bogus"
1055+ assert result == snapshot (CallToolResult (content = [TextContent (text = "still here" )], result_type = "bogus" ))
0 commit comments