@@ -169,8 +169,10 @@ def initialize(
169169 @allowed_origins = Array ( allowed_origins ) . map ( &:downcase ) . freeze
170170 @pending_responses = { }
171171
172- # Maps a `subscriptions/listen` request id to
173- # `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575).
172+ # Maps a key the transport mints for each `subscriptions/listen` stream to
173+ # `{ request_id: listen_request_id, stream: stream_object, filter: honored_subscription_filter, active: boolean,
174+ # write_mutex: Mutex }` (SEP-2575). The request id is the client's, unique only among that client's own
175+ # in-flight requests, so it stamps `subscriptionId` but cannot serve as the key: two clients may pick the same one.
174176 # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
175177 # which is a follow-up.
176178 @listen_subscriptions = { }
@@ -925,19 +927,25 @@ def first
925927 # the legacy GET stream (`create_sse_body`).
926928 #
927929 # Registration and activation are split on purpose: the entry is inserted inactive
928- # (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock,
930+ # (reserving the cap slot atomically), the acknowledgement is written outside the lock,
929931 # and only then does the entry become eligible for delivery. A concurrent notification between
930932 # the insert and the acknowledgement write skips the inactive entry,
931933 # enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
934+ #
935+ # The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
936+ # the requesting client's own in-flight requests, and two clients that pick the same one must each get
937+ # their stream, stamped with the id they sent.
932938 def listen_sse_body ( request_id , honored )
933939 ListenStreamBody . new do |stream |
940+ subscription_key = SecureRandom . uuid
934941 rejected = false
935942 @mutex . synchronize do
936- if @listen_subscriptions . key? ( request_id ) ||
937- ( @max_listen_subscriptions && @listen_subscriptions . size >= @max_listen_subscriptions )
943+ if @max_listen_subscriptions && @listen_subscriptions . size >= @max_listen_subscriptions
938944 rejected = true
939945 else
940- @listen_subscriptions [ request_id ] = { stream : stream , filter : honored , active : false , write_mutex : Mutex . new }
946+ @listen_subscriptions [ subscription_key ] = {
947+ request_id : request_id , stream : stream , filter : honored , active : false , write_mutex : Mutex . new ,
948+ }
941949 end
942950 end
943951
@@ -955,10 +963,10 @@ def listen_sse_body(request_id, honored)
955963
956964 begin
957965 send_to_stream ( stream , acknowledgement )
958- activate_listen_subscription ( request_id )
959- start_listen_keepalive_thread ( request_id )
966+ activate_listen_subscription ( subscription_key )
967+ start_listen_keepalive_thread ( subscription_key , request_id )
960968 rescue *STREAM_WRITE_ERRORS
961- remove_listen_subscription ( request_id )
969+ remove_listen_subscription ( subscription_key )
962970 close_stream_safely ( stream )
963971 end
964972 end
@@ -967,9 +975,9 @@ def listen_sse_body(request_id, honored)
967975
968976 # Marks a listen subscription eligible for delivery once its acknowledgement write has completed.
969977 # The entry may already be gone when the transport closed concurrently.
970- def activate_listen_subscription ( request_id )
978+ def activate_listen_subscription ( subscription_key )
971979 @mutex . synchronize do
972- subscription = @listen_subscriptions [ request_id ]
980+ subscription = @listen_subscriptions [ subscription_key ]
973981 subscription [ :active ] = true if subscription
974982 end
975983 end
@@ -978,37 +986,39 @@ def activate_listen_subscription(request_id)
978986 # connection is detected and its slot freed, rather than held until the next fan-out write.
979987 # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
980988 # cannot corrupt an interleaved notification's JSON.
981- def start_listen_keepalive_thread ( request_id )
989+ def start_listen_keepalive_thread ( subscription_key , request_id )
982990 return unless @listen_keepalive_interval
983991
984992 Thread . new do
985- while listen_subscription_active? ( request_id )
993+ while listen_subscription_active? ( subscription_key )
986994 sleep ( @listen_keepalive_interval )
987- send_listen_keepalive_ping ( request_id )
995+ send_listen_keepalive_ping ( subscription_key )
988996 end
989997 rescue *STREAM_WRITE_ERRORS
990998 # The peer went away; the ensure frees the slot. A dropped listen stream is the normal
991999 # way this loop ends, so it is not reported.
9921000 rescue StandardError => e
1001+ # The request id is taken from the caller rather than the registry: a delivery failure may have
1002+ # removed the entry already, and the report should still name the stream.
9931003 MCP . configuration . exception_reporter . call ( e , { subscription_id : request_id } )
9941004 ensure
9951005 stream = @mutex . synchronize do
996- subscription = @listen_subscriptions . delete ( request_id )
1006+ subscription = @listen_subscriptions . delete ( subscription_key )
9971007 subscription && subscription [ :stream ]
9981008 end
9991009 close_stream_safely ( stream ) if stream
10001010 end
10011011 end
10021012
1003- def listen_subscription_active? ( request_id )
1004- @mutex . synchronize { @listen_subscriptions . key? ( request_id ) }
1013+ def listen_subscription_active? ( subscription_key )
1014+ @mutex . synchronize { @listen_subscriptions . key? ( subscription_key ) }
10051015 end
10061016
10071017 # Resolves the stream under the lock, then writes outside it so a stalled reader cannot block
10081018 # every other subscription on `@mutex`. A write error propagates to end the keepalive loop.
1009- def send_listen_keepalive_ping ( request_id )
1019+ def send_listen_keepalive_ping ( subscription_key )
10101020 stream = @mutex . synchronize do
1011- subscription = @listen_subscriptions [ request_id ]
1021+ subscription = @listen_subscriptions [ subscription_key ]
10121022 subscription && subscription [ :stream ]
10131023 end
10141024 return unless stream
@@ -1054,7 +1064,7 @@ def deliver_to_listen_subscriptions(method, params)
10541064 # The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
10551065 # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
10561066 matched = @mutex . synchronize do
1057- @listen_subscriptions . filter_map do |request_id , subscription |
1067+ @listen_subscriptions . filter_map do |subscription_key , subscription |
10581068 # An inactive entry has not finished writing its acknowledgement yet;
10591069 # delivering to it would put a notification ahead of the acknowledgement.
10601070 next unless subscription [ :active ]
@@ -1067,12 +1077,12 @@ def deliver_to_listen_subscriptions(method, params)
10671077 uris . is_a? ( Array ) && uris . include? ( uri )
10681078 end
10691079
1070- [ request_id , subscription ] if hit
1080+ [ subscription_key , subscription ] if hit
10711081 end
10721082 end
10731083
1074- matched . each do |request_id , subscription |
1075- meta = { RequestEnvelope ::SUBSCRIPTION_ID_META_KEY . to_sym => request_id }
1084+ matched . each do |subscription_key , subscription |
1085+ meta = { RequestEnvelope ::SUBSCRIPTION_ID_META_KEY . to_sym => subscription [ : request_id] }
10761086 notification_params = ( params || { } ) . merge ( _meta : meta )
10771087 notification = { jsonrpc : "2.0" , method : method , params : notification_params }
10781088
@@ -1089,16 +1099,16 @@ def deliver_to_listen_subscriptions(method, params)
10891099 rescue *STREAM_WRITE_ERRORS => e
10901100 MCP . configuration . exception_reporter . call (
10911101 e ,
1092- { subscription_id : request_id , error : "Failed to send notification" } ,
1102+ { subscription_id : subscription [ : request_id] , error : "Failed to send notification" } ,
10931103 )
1094- remove_listen_subscription ( request_id )
1104+ remove_listen_subscription ( subscription_key )
10951105 close_stream_safely ( subscription [ :stream ] )
10961106 end
10971107 end
10981108 end
10991109
1100- def remove_listen_subscription ( request_id )
1101- @mutex . synchronize { @listen_subscriptions . delete ( request_id ) }
1110+ def remove_listen_subscription ( subscription_key )
1111+ @mutex . synchronize { @listen_subscriptions . delete ( subscription_key ) }
11021112 end
11031113
11041114 # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
@@ -1110,7 +1120,7 @@ def teardown_listen_subscriptions
11101120 subscriptions
11111121 end
11121122
1113- removed . each do |request_id , subscription |
1123+ removed . each_value do |subscription |
11141124 # Marking the entry closed and writing the result under the stream's write mutex orders
11151125 # this against in-flight deliveries: each one either lands before the result or observes
11161126 # `closed` and skips, keeping the graceful result the stream's final message.
@@ -1120,13 +1130,13 @@ def teardown_listen_subscriptions
11201130 begin
11211131 send_to_stream ( subscription [ :stream ] , {
11221132 jsonrpc : "2.0" ,
1123- id : request_id ,
1133+ id : subscription [ : request_id] ,
11241134 result : {
11251135 # `SubscriptionsListenResult` is served at the transport layer and never
11261136 # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
11271137 # stamped at its construction site.
11281138 resultType : ResultType ::COMPLETE ,
1129- _meta : { RequestEnvelope ::SUBSCRIPTION_ID_META_KEY . to_sym => request_id } ,
1139+ _meta : { RequestEnvelope ::SUBSCRIPTION_ID_META_KEY . to_sym => subscription [ : request_id] } ,
11301140 } ,
11311141 } )
11321142 rescue *STREAM_WRITE_ERRORS
0 commit comments