@@ -1011,6 +1011,155 @@ async def test_request_id_reuse_after_completion_allowed(basic_app: Starlette) -
10111011 assert body ["result" ]["content" ][0 ]["text" ] == "Called test_tool"
10121012
10131013
1014+ @pytest .mark .anyio
1015+ async def test_numeric_and_string_request_ids_do_not_collide_while_in_flight (basic_app : Starlette ) -> None :
1016+ """Integer id 1 and string id "1" are distinct JSON-RPC ids and may run concurrently.
1017+
1018+ Routing used to key streams with str(id), so an in-flight numeric 1 falsely
1019+ 409'd a string "1" (or cross-wired responses). Raw HTTP is required to control
1020+ the JSON-RPC id types on the wire.
1021+ """
1022+ async with make_client (basic_app ) as client :
1023+ init_response = await client .post (
1024+ "/mcp" ,
1025+ headers = {
1026+ "Accept" : "application/json, text/event-stream" ,
1027+ "Content-Type" : "application/json" ,
1028+ },
1029+ json = INIT_REQUEST ,
1030+ )
1031+ assert init_response .status_code == 200
1032+ headers = {
1033+ "Accept" : "application/json, text/event-stream" ,
1034+ "Content-Type" : "application/json" ,
1035+ MCP_SESSION_ID_HEADER : init_response .headers [MCP_SESSION_ID_HEADER ],
1036+ MCP_PROTOCOL_VERSION_HEADER : extract_protocol_version_from_sse (init_response ),
1037+ }
1038+
1039+ async with client .stream (
1040+ "POST" ,
1041+ "/mcp" ,
1042+ headers = headers ,
1043+ json = {
1044+ "jsonrpc" : "2.0" ,
1045+ "method" : "tools/call" ,
1046+ "params" : {"name" : "wait_for_lock_with_notification" , "arguments" : {}},
1047+ "id" : 1 ,
1048+ },
1049+ ) as first_response :
1050+ assert first_response .status_code == 200
1051+ lines = first_response .aiter_lines ()
1052+ with anyio .fail_after (5 ):
1053+ notification = await next_sse_data (lines )
1054+ assert notification ["params" ]["data" ] == "First notification before lock"
1055+
1056+ string_id_response = await client .post (
1057+ "/mcp" ,
1058+ headers = headers ,
1059+ json = {
1060+ "jsonrpc" : "2.0" ,
1061+ "method" : "tools/call" ,
1062+ "params" : {"name" : "test_tool" , "arguments" : {}},
1063+ "id" : "1" ,
1064+ },
1065+ )
1066+ assert string_id_response .status_code == 200
1067+ string_body = first_sse_data (string_id_response )
1068+ assert string_body ["id" ] == "1"
1069+ assert string_body ["result" ]["content" ][0 ]["text" ] == "Called test_tool"
1070+
1071+ release_response = await client .post (
1072+ "/mcp" ,
1073+ headers = headers ,
1074+ json = {
1075+ "jsonrpc" : "2.0" ,
1076+ "method" : "tools/call" ,
1077+ "params" : {"name" : "release_lock" , "arguments" : {}},
1078+ "id" : "release-lock-type-key" ,
1079+ },
1080+ )
1081+ assert release_response .status_code == 200
1082+
1083+ with anyio .fail_after (5 ):
1084+ second_notification = await next_sse_data (lines )
1085+ final = await next_sse_data (lines )
1086+ assert second_notification ["params" ]["data" ] == "Second notification after lock"
1087+ assert final ["id" ] == 1
1088+ assert final ["result" ]["content" ][0 ]["text" ] == "Completed"
1089+
1090+
1091+ @pytest .mark .anyio
1092+ async def test_request_id_matching_get_stream_key_does_not_block_standalone_get (basic_app : Starlette ) -> None :
1093+ """A POST with string id \" _GET_stream\" must not occupy the standalone GET slot.
1094+
1095+ Pre-fix, both used the bare string as the routing key, so a client could lock
1096+ itself out of GET (or have GET overwrite its POST slot).
1097+ """
1098+ async with make_client (basic_app ) as client :
1099+ init_response = await client .post (
1100+ "/mcp" ,
1101+ headers = {
1102+ "Accept" : "application/json, text/event-stream" ,
1103+ "Content-Type" : "application/json" ,
1104+ },
1105+ json = INIT_REQUEST ,
1106+ )
1107+ assert init_response .status_code == 200
1108+ session_id = init_response .headers [MCP_SESSION_ID_HEADER ]
1109+ negotiated_version = extract_protocol_version_from_sse (init_response )
1110+ headers = {
1111+ "Accept" : "application/json, text/event-stream" ,
1112+ "Content-Type" : "application/json" ,
1113+ MCP_SESSION_ID_HEADER : session_id ,
1114+ MCP_PROTOCOL_VERSION_HEADER : negotiated_version ,
1115+ }
1116+
1117+ async with client .stream (
1118+ "POST" ,
1119+ "/mcp" ,
1120+ headers = headers ,
1121+ json = {
1122+ "jsonrpc" : "2.0" ,
1123+ "method" : "tools/call" ,
1124+ "params" : {"name" : "wait_for_lock_with_notification" , "arguments" : {}},
1125+ "id" : "_GET_stream" ,
1126+ },
1127+ ) as post_response :
1128+ assert post_response .status_code == 200
1129+ lines = post_response .aiter_lines ()
1130+ with anyio .fail_after (5 ):
1131+ notification = await next_sse_data (lines )
1132+ assert notification ["params" ]["data" ] == "First notification before lock"
1133+
1134+ get_headers = {
1135+ "Accept" : "text/event-stream" ,
1136+ MCP_SESSION_ID_HEADER : session_id ,
1137+ MCP_PROTOCOL_VERSION_HEADER : negotiated_version ,
1138+ }
1139+ async with client .stream ("GET" , "/mcp" , headers = get_headers ) as get_response :
1140+ assert get_response .status_code == 200
1141+ assert get_response .headers .get ("Content-Type" ) == "text/event-stream"
1142+
1143+ release_response = await client .post (
1144+ "/mcp" ,
1145+ headers = headers ,
1146+ json = {
1147+ "jsonrpc" : "2.0" ,
1148+ "method" : "tools/call" ,
1149+ "params" : {"name" : "release_lock" , "arguments" : {}},
1150+ "id" : "release-after-get-key" ,
1151+ },
1152+ )
1153+ assert release_response .status_code == 200
1154+
1155+ with anyio .fail_after (5 ):
1156+ second_notification = await next_sse_data (lines )
1157+ final = await next_sse_data (lines )
1158+ assert second_notification ["params" ]["data" ] == "Second notification after lock"
1159+ assert final ["id" ] == "_GET_stream"
1160+ assert final ["result" ]["content" ][0 ]["text" ] == "Completed"
1161+
1162+
10141163@pytest .mark .anyio
10151164async def test_duplicate_request_id_rejected_during_priming_event_store () -> None :
10161165 """The duplicate-id guard holds while the event store persists the priming event.
@@ -1029,7 +1178,8 @@ def __init__(self) -> None:
10291178 self .release = anyio .Event ()
10301179
10311180 async def store_event (self , stream_id : StreamId , message : types .JSONRPCMessage | None ) -> EventId :
1032- if stream_id == "gated-1" and message is None :
1181+ # Routing keys are type-tagged (`s:` for string JSON-RPC ids).
1182+ if stream_id == "s:gated-1" and message is None :
10331183 self .entered .set ()
10341184 await self .release .wait ()
10351185 return await super ().store_event (stream_id , message )
0 commit comments