mcp 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "securerandom"
5
4
  require_relative "../../transport"
6
5
 
7
6
  module MCP
8
7
  class Server
9
8
  module Transports
10
9
  class StreamableHTTPTransport < Transport
10
+ SSE_HEADERS = {
11
+ "Content-Type" => "text/event-stream",
12
+ "Cache-Control" => "no-cache",
13
+ "Connection" => "keep-alive",
14
+ }.freeze
15
+
11
16
  def initialize(server, stateless: false, session_idle_timeout: nil)
12
17
  super(server)
13
18
  # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
@@ -16,6 +21,7 @@ module MCP
16
21
 
17
22
  @stateless = stateless
18
23
  @session_idle_timeout = session_idle_timeout
24
+ @pending_responses = {}
19
25
 
20
26
  if @session_idle_timeout
21
27
  if @stateless
@@ -50,12 +56,17 @@ module MCP
50
56
  @reaper_thread&.kill
51
57
  @reaper_thread = nil
52
58
 
53
- @mutex.synchronize do
54
- @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
59
+ removed_sessions = @mutex.synchronize do
60
+ @sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) }
61
+ end
62
+
63
+ removed_sessions.each do |session|
64
+ close_stream_safely(session[:stream])
65
+ close_post_request_streams(session)
55
66
  end
56
67
  end
57
68
 
58
- def send_notification(method, params = nil, session_id: nil)
69
+ def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
59
70
  # Stateless mode doesn't support notifications
60
71
  raise "Stateless mode does not support notifications" if @stateless
61
72
 
@@ -65,26 +76,35 @@ module MCP
65
76
  }
66
77
  notification[:params] = params if params
67
78
 
68
- @mutex.synchronize do
79
+ streams_to_close = []
80
+
81
+ result = @mutex.synchronize do
69
82
  if session_id
70
83
  # Send to specific session
71
- session = @sessions[session_id]
72
- return false unless session && session[:stream]
84
+ if (session = @sessions[session_id])
85
+ stream = active_stream(session, related_request_id: related_request_id)
86
+ end
87
+ next false unless stream
73
88
 
74
89
  if session_expired?(session)
75
- cleanup_session_unsafe(session_id)
76
- return false
90
+ cleanup_and_collect_stream(session_id, streams_to_close)
91
+ next false
77
92
  end
78
93
 
79
94
  begin
80
- send_to_stream(session[:stream], notification)
95
+ send_to_stream(stream, notification)
81
96
  true
82
97
  rescue *STREAM_WRITE_ERRORS => e
83
98
  MCP.configuration.exception_reporter.call(
84
99
  e,
85
100
  { session_id: session_id, error: "Failed to send notification" },
86
101
  )
87
- cleanup_session_unsafe(session_id)
102
+ if related_request_id && session[:post_request_streams]&.key?(related_request_id)
103
+ session[:post_request_streams].delete(related_request_id)
104
+ streams_to_close << stream
105
+ else
106
+ cleanup_and_collect_stream(session_id, streams_to_close)
107
+ end
88
108
  false
89
109
  end
90
110
  else
@@ -93,7 +113,7 @@ module MCP
93
113
  failed_sessions = []
94
114
 
95
115
  @sessions.each do |sid, session|
96
- next unless session[:stream]
116
+ next unless (stream = session[:stream])
97
117
 
98
118
  if session_expired?(session)
99
119
  failed_sessions << sid
@@ -101,7 +121,7 @@ module MCP
101
121
  end
102
122
 
103
123
  begin
104
- send_to_stream(session[:stream], notification)
124
+ send_to_stream(stream, notification)
105
125
  sent_count += 1
106
126
  rescue *STREAM_WRITE_ERRORS => e
107
127
  MCP.configuration.exception_reporter.call(
@@ -113,11 +133,93 @@ module MCP
113
133
  end
114
134
 
115
135
  # Clean up failed sessions
116
- failed_sessions.each { |sid| cleanup_session_unsafe(sid) }
136
+ failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
117
137
 
118
138
  sent_count
119
139
  end
120
140
  end
141
+
142
+ streams_to_close.each do |stream|
143
+ close_stream_safely(stream)
144
+ end
145
+
146
+ result
147
+ end
148
+
149
+ # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
150
+ # blocks until the client responds.
151
+ #
152
+ # Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`,
153
+ # sends the request via SSE stream, then blocks on `queue.pop`.
154
+ # When the client POSTs a response, `handle_response` matches it by `request_id`
155
+ # and pushes the result onto the queue, unblocking this thread.
156
+ def send_request(method, params = nil, session_id: nil, related_request_id: nil)
157
+ if @stateless
158
+ raise "Stateless mode does not support server-to-client requests."
159
+ end
160
+
161
+ unless session_id
162
+ raise "session_id is required for server-to-client requests."
163
+ end
164
+
165
+ request_id = generate_request_id
166
+ queue = Queue.new
167
+
168
+ request = { jsonrpc: "2.0", id: request_id, method: method }
169
+ request[:params] = params if params
170
+
171
+ sent = false
172
+
173
+ @mutex.synchronize do
174
+ unless (session = @sessions[session_id])
175
+ raise "Session not found: #{session_id}."
176
+ end
177
+
178
+ @pending_responses[request_id] = { queue: queue, session_id: session_id }
179
+
180
+ if (stream = active_stream(session, related_request_id: related_request_id))
181
+ begin
182
+ send_to_stream(stream, request)
183
+ sent = true
184
+ rescue *STREAM_WRITE_ERRORS
185
+ if related_request_id && session[:post_request_streams]&.key?(related_request_id)
186
+ session[:post_request_streams].delete(related_request_id)
187
+ close_stream_safely(stream)
188
+ else
189
+ cleanup_session_unsafe(session_id)
190
+ end
191
+ end
192
+ end
193
+ end
194
+
195
+ # TODO: Replace with event store + replay when resumability is implemented.
196
+ # Resumability is a separate MCP specification feature (SSE event IDs, Last-Event-ID replay,
197
+ # event store management) independent of sampling.
198
+ # See: https://modelcontextprotocol.io/specification/latest/basic/transports#resumability-and-redelivery
199
+ #
200
+ # The TypeScript and Python SDKs buffer messages and replay on reconnect.
201
+ # Until then, raise to prevent queue.pop from blocking indefinitely.
202
+ unless sent
203
+ raise "No active stream for #{method} request."
204
+ end
205
+
206
+ response = queue.pop
207
+
208
+ if response.is_a?(Hash) && response.key?(:error)
209
+ raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
210
+ end
211
+
212
+ if response == :session_closed
213
+ raise "SSE session closed while waiting for #{method} response."
214
+ end
215
+
216
+ response
217
+ ensure
218
+ if request_id
219
+ @mutex.synchronize do
220
+ @pending_responses.delete(request_id)
221
+ end
222
+ end
121
223
  end
122
224
 
123
225
  private
@@ -136,22 +238,17 @@ module MCP
136
238
  def reap_expired_sessions
137
239
  return unless @session_idle_timeout
138
240
 
139
- expired_streams = @mutex.synchronize do
140
- @sessions.each_with_object([]) do |(session_id, session), streams|
141
- next unless session_expired?(session)
241
+ removed_sessions = @mutex.synchronize do
242
+ @sessions.each_key.filter_map do |session_id|
243
+ next unless session_expired?(@sessions[session_id])
142
244
 
143
- streams << session[:stream] if session[:stream]
144
- @sessions.delete(session_id)
245
+ cleanup_session_unsafe(session_id)
145
246
  end
146
247
  end
147
248
 
148
- expired_streams.each do |stream|
149
- # Closing outside the mutex is safe because expired sessions are already
150
- # removed from `@sessions` above, so other threads will not find them
151
- # and will not attempt to close the same stream.
152
- stream.close
153
- rescue
154
- nil
249
+ removed_sessions.each do |session|
250
+ close_stream_safely(session[:stream])
251
+ close_post_request_streams(session)
155
252
  end
156
253
  end
157
254
 
@@ -176,15 +273,19 @@ module MCP
176
273
  body = parse_request_body(body_string)
177
274
  return body unless body.is_a?(Hash) # Error response
178
275
 
179
- if body["method"] == "initialize"
276
+ if body[:method] == "initialize"
180
277
  handle_initialization(body_string, body)
181
278
  else
182
279
  return missing_session_id_response if !@stateless && !session_id
183
280
 
184
- if notification?(body) || response?(body)
281
+ if notification?(body)
185
282
  handle_accepted
283
+ elsif response?(body)
284
+ return session_not_found_response if !@stateless && !session_exists?(session_id)
285
+
286
+ handle_response(body, session_id: session_id)
186
287
  else
187
- handle_regular_request(body_string, session_id)
288
+ handle_regular_request(body_string, session_id, related_request_id: body[:id])
188
289
  end
189
290
  end
190
291
  rescue StandardError => e
@@ -228,21 +329,51 @@ module MCP
228
329
  end
229
330
 
230
331
  def cleanup_session(session_id)
231
- @mutex.synchronize do
332
+ session = @mutex.synchronize do
232
333
  cleanup_session_unsafe(session_id)
233
334
  end
335
+
336
+ if session
337
+ close_stream_safely(session[:stream])
338
+ close_post_request_streams(session)
339
+ end
234
340
  end
235
341
 
342
+ # Removes a session from `@sessions` and returns it. Does not close the stream.
343
+ # Callers must close the stream outside the mutex to avoid holding the lock during
344
+ # potentially blocking I/O.
236
345
  def cleanup_session_unsafe(session_id)
237
- session = @sessions[session_id]
238
- return unless session
346
+ session = @sessions.delete(session_id)
239
347
 
240
- begin
241
- session[:stream]&.close
242
- rescue
243
- nil
348
+ # Unblock threads waiting on pending responses for this session.
349
+ @pending_responses.each_value do |pending_response|
350
+ if pending_response[:session_id] == session_id
351
+ pending_response[:queue].push(:session_closed)
352
+ end
353
+ end
354
+
355
+ session
356
+ end
357
+
358
+ def cleanup_and_collect_stream(session_id, streams_to_close)
359
+ return unless (removed = cleanup_session_unsafe(session_id))
360
+
361
+ streams_to_close << removed[:stream]
362
+ removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
363
+ end
364
+
365
+ def close_stream_safely(stream)
366
+ stream&.close
367
+ rescue StandardError
368
+ # Ignore close-related errors from already closed/broken streams.
369
+ end
370
+
371
+ def close_post_request_streams(session)
372
+ return unless (post_request_streams = session[:post_request_streams])
373
+
374
+ post_request_streams.each_value do |stream|
375
+ close_stream_safely(stream)
244
376
  end
245
- @sessions.delete(session_id)
246
377
  end
247
378
 
248
379
  def extract_session_id(request)
@@ -277,17 +408,35 @@ module MCP
277
408
  end
278
409
 
279
410
  def parse_request_body(body_string)
280
- JSON.parse(body_string)
411
+ JSON.parse(body_string, symbolize_names: true)
281
412
  rescue JSON::ParserError, TypeError
282
413
  [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
283
414
  end
284
415
 
285
416
  def notification?(body)
286
- !body["id"] && !!body["method"]
417
+ !body[:id] && !!body[:method]
287
418
  end
288
419
 
289
420
  def response?(body)
290
- !!body["id"] && !body["method"]
421
+ !!body[:id] && !body[:method]
422
+ end
423
+
424
+ # Verifies that the response came from the expected session to prevent
425
+ # cross-session response injection if request IDs are ever leaked.
426
+ def handle_response(body, session_id:)
427
+ request_id = body[:id]
428
+ @mutex.synchronize do
429
+ if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id
430
+ if body.key?(:error)
431
+ error = body[:error]
432
+ pending_response[:queue].push(error: { code: error[:code], message: error[:message] })
433
+ else
434
+ pending_response[:queue].push(body[:result])
435
+ end
436
+ end
437
+ end
438
+
439
+ handle_accepted
291
440
  end
292
441
 
293
442
  def handle_initialization(body_string, body)
@@ -326,9 +475,8 @@ module MCP
326
475
  [202, {}, []]
327
476
  end
328
477
 
329
- def handle_regular_request(body_string, session_id)
478
+ def handle_regular_request(body_string, session_id, related_request_id: nil)
330
479
  server_session = nil
331
- stream = nil
332
480
 
333
481
  unless @stateless
334
482
  if session_id
@@ -338,57 +486,106 @@ module MCP
338
486
  @mutex.synchronize do
339
487
  session = @sessions[session_id]
340
488
  server_session = session[:server_session] if session
341
- stream = session[:stream] if session
342
489
  end
343
490
  end
344
491
  end
345
492
 
346
- response = if server_session
347
- server_session.handle_json(body_string)
493
+ if session_id && !@stateless
494
+ handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
348
495
  else
349
- @server.handle_json(body_string)
496
+ response = dispatch_handle_json(body_string, server_session)
497
+ [200, { "Content-Type" => "application/json" }, [response]]
498
+ end
499
+ end
500
+
501
+ # Returns the POST response as an SSE stream so the server can send
502
+ # JSON-RPC requests and notifications during request processing.
503
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
504
+ def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
505
+ body = proc do |stream|
506
+ @mutex.synchronize do
507
+ session = @sessions[session_id]
508
+ if session && related_request_id
509
+ session[:post_request_streams] ||= {}
510
+ session[:post_request_streams][related_request_id] = stream
511
+ end
512
+ end
513
+
514
+ begin
515
+ response = dispatch_handle_json(body_string, server_session)
516
+
517
+ send_to_stream(stream, response) if response
518
+ ensure
519
+ if related_request_id
520
+ @mutex.synchronize do
521
+ session = @sessions[session_id]
522
+ session[:post_request_streams]&.delete(related_request_id) if session
523
+ end
524
+ end
525
+
526
+ begin
527
+ stream.close
528
+ rescue StandardError
529
+ # Ignore close-related errors from already closed/broken streams.
530
+ end
531
+ end
350
532
  end
351
533
 
352
- if stream
353
- send_response_to_stream(stream, response, session_id)
534
+ [200, SSE_HEADERS, body]
535
+ end
536
+
537
+ # Returns the SSE stream available for server-to-client messages.
538
+ # When `related_request_id` is given, returns only the POST response
539
+ # stream for that request (no fallback to GET SSE). This prevents
540
+ # request-scoped messages from leaking to the wrong stream.
541
+ # When `related_request_id` is nil, returns the GET SSE stream.
542
+ def active_stream(session, related_request_id: nil)
543
+ if related_request_id
544
+ session.dig(:post_request_streams, related_request_id)
354
545
  else
355
- [200, { "Content-Type" => "application/json" }, [response]]
546
+ session[:stream]
547
+ end
548
+ end
549
+
550
+ def dispatch_handle_json(body_string, server_session)
551
+ if server_session
552
+ server_session.handle_json(body_string)
553
+ else
554
+ @server.handle_json(body_string)
356
555
  end
357
556
  end
358
557
 
359
558
  def validate_and_touch_session(session_id)
360
- @mutex.synchronize do
361
- return session_not_found_response unless (session = @sessions[session_id])
362
- return unless @session_idle_timeout
559
+ removed = nil
560
+
561
+ response = @mutex.synchronize do
562
+ next session_not_found_response unless (session = @sessions[session_id])
563
+ next unless @session_idle_timeout
363
564
 
364
565
  if session_expired?(session)
365
- cleanup_session_unsafe(session_id)
366
- return session_not_found_response
566
+ removed = cleanup_session_unsafe(session_id)
567
+ next session_not_found_response
367
568
  end
368
569
 
369
570
  session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
571
+ nil
370
572
  end
371
573
 
372
- nil
574
+ if removed
575
+ close_stream_safely(removed[:stream])
576
+
577
+ removed[:post_request_streams]&.each_value do |stream|
578
+ close_stream_safely(stream)
579
+ end
580
+ end
581
+
582
+ response
373
583
  end
374
584
 
375
585
  def get_session_stream(session_id)
376
586
  @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
377
587
  end
378
588
 
379
- def send_response_to_stream(stream, response, session_id)
380
- message = JSON.parse(response)
381
- send_to_stream(stream, message)
382
- handle_accepted
383
- rescue *STREAM_WRITE_ERRORS => e
384
- MCP.configuration.exception_reporter.call(
385
- e,
386
- { session_id: session_id, error: "Stream closed during response" },
387
- )
388
- cleanup_session(session_id)
389
- [200, { "Content-Type" => "application/json" }, [response]]
390
- end
391
-
392
589
  def session_exists?(session_id)
393
590
  @mutex.synchronize { @sessions.key?(session_id) }
394
591
  end
@@ -416,13 +613,7 @@ module MCP
416
613
  def setup_sse_stream(session_id)
417
614
  body = create_sse_body(session_id)
418
615
 
419
- headers = {
420
- "Content-Type" => "text/event-stream",
421
- "Cache-Control" => "no-cache",
422
- "Connection" => "keep-alive",
423
- }
424
-
425
- [200, headers, body]
616
+ [200, SSE_HEADERS, body]
426
617
  end
427
618
 
428
619
  def create_sse_body(session_id)