@@ -196,80 +196,107 @@ def send_notification(method, params = nil, session_id: nil, related_request_id:
196196 }
197197 notification [ :params ] = params if params
198198
199+ if session_id
200+ deliver_targeted_notification ( notification , session_id , related_request_id )
201+ else
202+ deliver_broadcast_notification ( notification )
203+ end
204+ end
205+
206+ def deliver_targeted_notification ( notification , session_id , related_request_id )
207+ # JSON response mode returns a single JSON object as the POST response,
208+ # so request-scoped notifications (e.g. progress, log) cannot be delivered
209+ # alongside it. Session-scoped standalone notifications
210+ # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
211+ return false if @enable_json_response && related_request_id
212+
199213 streams_to_close = [ ]
200214
201- result = @mutex . synchronize do
202- if session_id
203- # JSON response mode returns a single JSON object as the POST response,
204- # so request-scoped notifications (e.g. progress, log) cannot be delivered
205- # alongside it. Session-scoped standalone notifications
206- # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
207- next false if @enable_json_response && related_request_id
208-
209- # Send to specific session
210- if ( session = @sessions [ session_id ] )
211- stream = active_stream ( session , related_request_id : related_request_id )
212- end
213- next false unless stream
215+ # Resolve the target stream under the lock, then write outside it: a stalled SSE reader
216+ # must not block every other session that needs `@mutex`.
217+ stream = @mutex . synchronize do
218+ next unless ( session = @sessions [ session_id ] )
219+
220+ if session_expired? ( session )
221+ cleanup_and_collect_stream ( session_id , streams_to_close )
222+ next
223+ end
224+
225+ active_stream ( session , related_request_id : related_request_id )
226+ end
227+
228+ close_streams ( streams_to_close )
229+ return false unless stream
230+
231+ write_notification ( stream , notification , session_id , related_request_id )
232+ end
233+
234+ def deliver_broadcast_notification ( notification )
235+ streams_to_close = [ ]
236+
237+ # Snapshot the connected streams under the lock, then write to each outside it.
238+ targets = @mutex . synchronize do
239+ expired_session_ids = [ ]
240+
241+ collected = @sessions . filter_map do |session_id , session |
242+ next unless ( stream = session [ :get_sse_stream ] )
214243
215244 if session_expired? ( session )
216- cleanup_and_collect_stream ( session_id , streams_to_close )
217- next false
245+ expired_session_ids << session_id
246+ next
218247 end
219248
220- begin
221- send_to_stream ( stream , notification )
222- true
223- rescue *STREAM_WRITE_ERRORS => e
224- MCP . configuration . exception_reporter . call (
225- e ,
226- { session_id : session_id , error : "Failed to send notification" } ,
227- )
228- if related_request_id && session [ :post_request_streams ] &.key? ( related_request_id )
229- session [ :post_request_streams ] . delete ( related_request_id )
230- streams_to_close << stream
231- else
232- cleanup_and_collect_stream ( session_id , streams_to_close )
233- end
234- false
235- end
236- else
237- # Broadcast to all connected SSE sessions
238- sent_count = 0
239- failed_sessions = [ ]
249+ [ session_id , stream ]
250+ end
240251
241- @sessions . each do |sid , session |
242- next unless ( stream = session [ :get_sse_stream ] )
252+ expired_session_ids . each do |session_id |
253+ cleanup_and_collect_stream ( session_id , streams_to_close )
254+ end
243255
244- if session_expired? ( session )
245- failed_sessions << sid
246- next
247- end
256+ collected
257+ end
248258
249- begin
250- send_to_stream ( stream , notification )
251- sent_count += 1
252- rescue *STREAM_WRITE_ERRORS => e
253- MCP . configuration . exception_reporter . call (
254- e ,
255- { session_id : sid , error : "Failed to send notification" } ,
256- )
257- failed_sessions << sid
258- end
259- end
259+ close_streams ( streams_to_close )
260260
261- # Clean up failed sessions
262- failed_sessions . each { |sid | cleanup_and_collect_stream ( sid , streams_to_close ) }
261+ targets . count do |session_id , stream |
262+ write_notification ( stream , notification , session_id , nil )
263+ end
264+ end
265+
266+ # Writes a notification to an SSE stream without holding `@mutex`. On a write error,
267+ # drops the broken stream and returns false; on success returns true.
268+ def write_notification ( stream , notification , session_id , related_request_id )
269+ send_to_stream ( stream , notification )
270+ true
271+ rescue *STREAM_WRITE_ERRORS => e
272+ MCP . configuration . exception_reporter . call ( e , { session_id : session_id , error : "Failed to send notification" } )
273+ drop_broken_stream ( session_id , stream , related_request_id )
274+ false
275+ end
276+
277+ # Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
278+ # a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
279+ # under `@mutex`, and the affected streams are closed outside it.
280+ def drop_broken_stream ( session_id , stream , related_request_id )
281+ streams_to_close = [ ]
263282
264- sent_count
283+ @mutex . synchronize do
284+ session = @sessions [ session_id ]
285+ if related_request_id && session &.dig ( :post_request_streams , related_request_id )
286+ session [ :post_request_streams ] . delete ( related_request_id )
287+ streams_to_close << stream
288+ else
289+ cleanup_and_collect_stream ( session_id , streams_to_close )
265290 end
266291 end
267292
268- streams_to_close . each do |stream |
293+ close_streams ( streams_to_close )
294+ end
295+
296+ def close_streams ( streams )
297+ streams . each do |stream |
269298 close_stream_safely ( stream )
270299 end
271-
272- result
273300 end
274301
275302 # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
@@ -299,27 +326,25 @@ def send_request(method, params = nil, session_id: nil, related_request_id: nil,
299326 request = { jsonrpc : "2.0" , id : request_id , method : method }
300327 request [ :params ] = params if params
301328
302- sent = false
303-
304- @mutex . synchronize do
329+ # Register the pending response and resolve the stream under the lock, but perform
330+ # the write outside it so a stalled reader cannot block every other session on `@mutex`.
331+ stream = @mutex . synchronize do
305332 unless ( session = @sessions [ session_id ] )
306333 raise "Session not found: #{ session_id } ."
307334 end
308335
309336 @pending_responses [ request_id ] = { queue : queue , session_id : session_id }
310337
311- if ( stream = active_stream ( session , related_request_id : related_request_id ) )
312- begin
313- send_to_stream ( stream , request )
314- sent = true
315- rescue *STREAM_WRITE_ERRORS
316- if related_request_id && session [ :post_request_streams ] &.key? ( related_request_id )
317- session [ :post_request_streams ] . delete ( related_request_id )
318- close_stream_safely ( stream )
319- else
320- cleanup_session_unsafe ( session_id )
321- end
322- end
338+ active_stream ( session , related_request_id : related_request_id )
339+ end
340+
341+ sent = false
342+ if stream
343+ begin
344+ send_to_stream ( stream , request )
345+ sent = true
346+ rescue *STREAM_WRITE_ERRORS
347+ drop_broken_stream ( session_id , stream , related_request_id )
323348 end
324349 end
325350
@@ -1164,11 +1189,15 @@ def session_active_with_stream?(session_id)
11641189 end
11651190
11661191 def send_keepalive_ping ( session_id )
1167- @mutex . synchronize do
1168- if @sessions [ session_id ] && @sessions [ session_id ] [ :get_sse_stream ]
1169- send_ping_to_stream ( @sessions [ session_id ] [ :get_sse_stream ] )
1170- end
1192+ # Resolve the stream under the lock, then write outside it so a stalled reader
1193+ # cannot block every other session on `@mutex`.
1194+ stream = @mutex . synchronize do
1195+ session = @sessions [ session_id ]
1196+ session && session [ :get_sse_stream ]
11711197 end
1198+ return unless stream
1199+
1200+ send_ping_to_stream ( stream )
11721201 rescue *STREAM_WRITE_ERRORS => e
11731202 MCP . configuration . exception_reporter . call (
11741203 e ,
0 commit comments