mcp 0.10.0 → 0.12.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,21 +1,27 @@
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
- # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
18
+ # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
14
19
  @sessions = {}
15
20
  @mutex = Mutex.new
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[:get_sse_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[:get_sse_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[:get_sse_stream])
251
+ close_post_request_streams(session)
155
252
  end
156
253
  end
157
254
 
@@ -170,21 +267,28 @@ module MCP
170
267
  accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
171
268
  return accept_error if accept_error
172
269
 
270
+ content_type_error = validate_content_type(request)
271
+ return content_type_error if content_type_error
272
+
173
273
  body_string = request.body.read
174
274
  session_id = extract_session_id(request)
175
275
 
176
276
  body = parse_request_body(body_string)
177
277
  return body unless body.is_a?(Hash) # Error response
178
278
 
179
- if body["method"] == "initialize"
279
+ if body[:method] == "initialize"
180
280
  handle_initialization(body_string, body)
181
281
  else
182
282
  return missing_session_id_response if !@stateless && !session_id
183
283
 
184
- if notification?(body) || response?(body)
284
+ if notification?(body)
185
285
  handle_accepted
286
+ elsif response?(body)
287
+ return session_not_found_response if !@stateless && !session_exists?(session_id)
288
+
289
+ handle_response(body, session_id: session_id)
186
290
  else
187
- handle_regular_request(body_string, session_id)
291
+ handle_regular_request(body_string, session_id, related_request_id: body[:id])
188
292
  end
189
293
  end
190
294
  rescue StandardError => e
@@ -228,21 +332,51 @@ module MCP
228
332
  end
229
333
 
230
334
  def cleanup_session(session_id)
231
- @mutex.synchronize do
335
+ session = @mutex.synchronize do
232
336
  cleanup_session_unsafe(session_id)
233
337
  end
338
+
339
+ if session
340
+ close_stream_safely(session[:get_sse_stream])
341
+ close_post_request_streams(session)
342
+ end
234
343
  end
235
344
 
345
+ # Removes a session from `@sessions` and returns it. Does not close the stream.
346
+ # Callers must close the stream outside the mutex to avoid holding the lock during
347
+ # potentially blocking I/O.
236
348
  def cleanup_session_unsafe(session_id)
237
- session = @sessions[session_id]
238
- return unless session
349
+ session = @sessions.delete(session_id)
239
350
 
240
- begin
241
- session[:stream]&.close
242
- rescue
243
- nil
351
+ # Unblock threads waiting on pending responses for this session.
352
+ @pending_responses.each_value do |pending_response|
353
+ if pending_response[:session_id] == session_id
354
+ pending_response[:queue].push(:session_closed)
355
+ end
356
+ end
357
+
358
+ session
359
+ end
360
+
361
+ def cleanup_and_collect_stream(session_id, streams_to_close)
362
+ return unless (removed = cleanup_session_unsafe(session_id))
363
+
364
+ streams_to_close << removed[:get_sse_stream]
365
+ removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
366
+ end
367
+
368
+ def close_stream_safely(stream)
369
+ stream&.close
370
+ rescue StandardError
371
+ # Ignore close-related errors from already closed/broken streams.
372
+ end
373
+
374
+ def close_post_request_streams(session)
375
+ return unless (post_request_streams = session[:post_request_streams])
376
+
377
+ post_request_streams.each_value do |stream|
378
+ close_stream_safely(stream)
244
379
  end
245
- @sessions.delete(session_id)
246
380
  end
247
381
 
248
382
  def extract_session_id(request)
@@ -268,6 +402,18 @@ module MCP
268
402
  end
269
403
  end
270
404
 
405
+ def validate_content_type(request)
406
+ content_type = request.env["CONTENT_TYPE"]
407
+ media_type = content_type&.split(";")&.first&.strip&.downcase
408
+ return if media_type == "application/json"
409
+
410
+ [
411
+ 415,
412
+ { "Content-Type" => "application/json" },
413
+ [{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
414
+ ]
415
+ end
416
+
271
417
  def not_acceptable_response(required_types)
272
418
  [
273
419
  406,
@@ -277,17 +423,35 @@ module MCP
277
423
  end
278
424
 
279
425
  def parse_request_body(body_string)
280
- JSON.parse(body_string)
426
+ JSON.parse(body_string, symbolize_names: true)
281
427
  rescue JSON::ParserError, TypeError
282
428
  [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
283
429
  end
284
430
 
285
431
  def notification?(body)
286
- !body["id"] && !!body["method"]
432
+ !body[:id] && !!body[:method]
287
433
  end
288
434
 
289
435
  def response?(body)
290
- !!body["id"] && !body["method"]
436
+ !!body[:id] && !body[:method]
437
+ end
438
+
439
+ # Verifies that the response came from the expected session to prevent
440
+ # cross-session response injection if request IDs are ever leaked.
441
+ def handle_response(body, session_id:)
442
+ request_id = body[:id]
443
+ @mutex.synchronize do
444
+ if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id
445
+ if body.key?(:error)
446
+ error = body[:error]
447
+ pending_response[:queue].push(error: { code: error[:code], message: error[:message] })
448
+ else
449
+ pending_response[:queue].push(body[:result])
450
+ end
451
+ end
452
+ end
453
+
454
+ handle_accepted
291
455
  end
292
456
 
293
457
  def handle_initialization(body_string, body)
@@ -300,7 +464,7 @@ module MCP
300
464
 
301
465
  @mutex.synchronize do
302
466
  @sessions[session_id] = {
303
- stream: nil,
467
+ get_sse_stream: nil,
304
468
  server_session: server_session,
305
469
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
306
470
  }
@@ -326,9 +490,8 @@ module MCP
326
490
  [202, {}, []]
327
491
  end
328
492
 
329
- def handle_regular_request(body_string, session_id)
493
+ def handle_regular_request(body_string, session_id, related_request_id: nil)
330
494
  server_session = nil
331
- stream = nil
332
495
 
333
496
  unless @stateless
334
497
  if session_id
@@ -338,55 +501,104 @@ module MCP
338
501
  @mutex.synchronize do
339
502
  session = @sessions[session_id]
340
503
  server_session = session[:server_session] if session
341
- stream = session[:stream] if session
342
504
  end
343
505
  end
344
506
  end
345
507
 
346
- response = if server_session
347
- server_session.handle_json(body_string)
508
+ if session_id && !@stateless
509
+ handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
348
510
  else
349
- @server.handle_json(body_string)
511
+ response = dispatch_handle_json(body_string, server_session)
512
+ [200, { "Content-Type" => "application/json" }, [response]]
350
513
  end
514
+ end
515
+
516
+ # Returns the POST response as an SSE stream so the server can send
517
+ # JSON-RPC requests and notifications during request processing.
518
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
519
+ def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
520
+ body = proc do |stream|
521
+ @mutex.synchronize do
522
+ session = @sessions[session_id]
523
+ if session && related_request_id
524
+ session[:post_request_streams] ||= {}
525
+ session[:post_request_streams][related_request_id] = stream
526
+ end
527
+ end
528
+
529
+ begin
530
+ response = dispatch_handle_json(body_string, server_session)
351
531
 
352
- if stream
353
- send_response_to_stream(stream, response, session_id)
532
+ send_to_stream(stream, response) if response
533
+ ensure
534
+ if related_request_id
535
+ @mutex.synchronize do
536
+ session = @sessions[session_id]
537
+ session[:post_request_streams]&.delete(related_request_id) if session
538
+ end
539
+ end
540
+
541
+ begin
542
+ stream.close
543
+ rescue StandardError
544
+ # Ignore close-related errors from already closed/broken streams.
545
+ end
546
+ end
547
+ end
548
+
549
+ [200, SSE_HEADERS, body]
550
+ end
551
+
552
+ # Returns the SSE stream available for server-to-client messages.
553
+ # When `related_request_id` is given, returns only the POST response
554
+ # stream for that request (no fallback to GET SSE). This prevents
555
+ # request-scoped messages from leaking to the wrong stream.
556
+ # When `related_request_id` is nil, returns the GET SSE stream.
557
+ def active_stream(session, related_request_id: nil)
558
+ if related_request_id
559
+ session.dig(:post_request_streams, related_request_id)
354
560
  else
355
- [200, { "Content-Type" => "application/json" }, [response]]
561
+ session[:get_sse_stream]
562
+ end
563
+ end
564
+
565
+ def dispatch_handle_json(body_string, server_session)
566
+ if server_session
567
+ server_session.handle_json(body_string)
568
+ else
569
+ @server.handle_json(body_string)
356
570
  end
357
571
  end
358
572
 
359
573
  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
574
+ removed = nil
575
+
576
+ response = @mutex.synchronize do
577
+ next session_not_found_response unless (session = @sessions[session_id])
578
+ next unless @session_idle_timeout
363
579
 
364
580
  if session_expired?(session)
365
- cleanup_session_unsafe(session_id)
366
- return session_not_found_response
581
+ removed = cleanup_session_unsafe(session_id)
582
+ next session_not_found_response
367
583
  end
368
584
 
369
585
  session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
586
+ nil
370
587
  end
371
588
 
372
- nil
373
- end
589
+ if removed
590
+ close_stream_safely(removed[:get_sse_stream])
374
591
 
375
- def get_session_stream(session_id)
376
- @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
592
+ removed[:post_request_streams]&.each_value do |stream|
593
+ close_stream_safely(stream)
594
+ end
595
+ end
596
+
597
+ response
377
598
  end
378
599
 
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]]
600
+ def get_session_stream(session_id)
601
+ @mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
390
602
  end
391
603
 
392
604
  def session_exists?(session_id)
@@ -416,13 +628,7 @@ module MCP
416
628
  def setup_sse_stream(session_id)
417
629
  body = create_sse_body(session_id)
418
630
 
419
- headers = {
420
- "Content-Type" => "text/event-stream",
421
- "Cache-Control" => "no-cache",
422
- "Connection" => "keep-alive",
423
- }
424
-
425
- [200, headers, body]
631
+ [200, SSE_HEADERS, body]
426
632
  end
427
633
 
428
634
  def create_sse_body(session_id)
@@ -435,8 +641,8 @@ module MCP
435
641
  def store_stream_for_session(session_id, stream)
436
642
  @mutex.synchronize do
437
643
  session = @sessions[session_id]
438
- if session && !session[:stream]
439
- session[:stream] = stream
644
+ if session && !session[:get_sse_stream]
645
+ session[:get_sse_stream] = stream
440
646
  else
441
647
  # Either session was removed, or another request already established a stream.
442
648
  stream.close
@@ -461,13 +667,13 @@ module MCP
461
667
  end
462
668
 
463
669
  def session_active_with_stream?(session_id)
464
- @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] }
670
+ @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
465
671
  end
466
672
 
467
673
  def send_keepalive_ping(session_id)
468
674
  @mutex.synchronize do
469
- if @sessions[session_id] && @sessions[session_id][:stream]
470
- send_ping_to_stream(@sessions[session_id][:stream])
675
+ if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
676
+ send_ping_to_stream(@sessions[session_id][:get_sse_stream])
471
677
  end
472
678
  end
473
679
  rescue *STREAM_WRITE_ERRORS => e