mcp 0.10.0 → 0.14.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,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "securerandom"
5
4
  require_relative "../../transport"
6
5
 
6
+ # This file is autoloaded only when `StreamableHTTPTransport` is referenced,
7
+ # so the `rack` dependency does not affect `StdioTransport` users.
8
+ begin
9
+ require "rack"
10
+ rescue LoadError
11
+ raise LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport. " \
12
+ "Add it to your Gemfile: gem 'rack'"
13
+ end
14
+
7
15
  module MCP
8
16
  class Server
9
17
  module Transports
10
18
  class StreamableHTTPTransport < Transport
11
- def initialize(server, stateless: false, session_idle_timeout: nil)
19
+ SSE_HEADERS = {
20
+ "Content-Type" => "text/event-stream",
21
+ "Cache-Control" => "no-cache",
22
+ "Connection" => "keep-alive",
23
+ }.freeze
24
+
25
+ def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
12
26
  super(server)
13
- # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
27
+ # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
14
28
  @sessions = {}
15
29
  @mutex = Mutex.new
16
30
 
17
31
  @stateless = stateless
32
+ @enable_json_response = enable_json_response
18
33
  @session_idle_timeout = session_idle_timeout
34
+ @pending_responses = {}
19
35
 
20
36
  if @session_idle_timeout
21
37
  if @stateless
@@ -28,11 +44,17 @@ module MCP
28
44
  start_reaper_thread if @session_idle_timeout
29
45
  end
30
46
 
31
- REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
47
+ REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze
48
+ REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
32
49
  REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
33
50
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
34
51
  SESSION_REAP_INTERVAL = 60
35
52
 
53
+ # Rack app interface. This transport can be mounted as a Rack app.
54
+ def call(env)
55
+ handle_request(Rack::Request.new(env))
56
+ end
57
+
36
58
  def handle_request(request)
37
59
  case request.env["REQUEST_METHOD"]
38
60
  when "POST"
@@ -50,12 +72,17 @@ module MCP
50
72
  @reaper_thread&.kill
51
73
  @reaper_thread = nil
52
74
 
53
- @mutex.synchronize do
54
- @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
75
+ removed_sessions = @mutex.synchronize do
76
+ @sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) }
77
+ end
78
+
79
+ removed_sessions.each do |session|
80
+ close_stream_safely(session[:get_sse_stream])
81
+ close_post_request_streams(session)
55
82
  end
56
83
  end
57
84
 
58
- def send_notification(method, params = nil, session_id: nil)
85
+ def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
59
86
  # Stateless mode doesn't support notifications
60
87
  raise "Stateless mode does not support notifications" if @stateless
61
88
 
@@ -65,26 +92,41 @@ module MCP
65
92
  }
66
93
  notification[:params] = params if params
67
94
 
68
- @mutex.synchronize do
95
+ streams_to_close = []
96
+
97
+ result = @mutex.synchronize do
69
98
  if session_id
99
+ # JSON response mode returns a single JSON object as the POST response,
100
+ # so request-scoped notifications (e.g. progress, log) cannot be delivered
101
+ # alongside it. Session-scoped standalone notifications
102
+ # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
103
+ next false if @enable_json_response && related_request_id
104
+
70
105
  # Send to specific session
71
- session = @sessions[session_id]
72
- return false unless session && session[:stream]
106
+ if (session = @sessions[session_id])
107
+ stream = active_stream(session, related_request_id: related_request_id)
108
+ end
109
+ next false unless stream
73
110
 
74
111
  if session_expired?(session)
75
- cleanup_session_unsafe(session_id)
76
- return false
112
+ cleanup_and_collect_stream(session_id, streams_to_close)
113
+ next false
77
114
  end
78
115
 
79
116
  begin
80
- send_to_stream(session[:stream], notification)
117
+ send_to_stream(stream, notification)
81
118
  true
82
119
  rescue *STREAM_WRITE_ERRORS => e
83
120
  MCP.configuration.exception_reporter.call(
84
121
  e,
85
122
  { session_id: session_id, error: "Failed to send notification" },
86
123
  )
87
- cleanup_session_unsafe(session_id)
124
+ if related_request_id && session[:post_request_streams]&.key?(related_request_id)
125
+ session[:post_request_streams].delete(related_request_id)
126
+ streams_to_close << stream
127
+ else
128
+ cleanup_and_collect_stream(session_id, streams_to_close)
129
+ end
88
130
  false
89
131
  end
90
132
  else
@@ -93,7 +135,7 @@ module MCP
93
135
  failed_sessions = []
94
136
 
95
137
  @sessions.each do |sid, session|
96
- next unless session[:stream]
138
+ next unless (stream = session[:get_sse_stream])
97
139
 
98
140
  if session_expired?(session)
99
141
  failed_sessions << sid
@@ -101,7 +143,7 @@ module MCP
101
143
  end
102
144
 
103
145
  begin
104
- send_to_stream(session[:stream], notification)
146
+ send_to_stream(stream, notification)
105
147
  sent_count += 1
106
148
  rescue *STREAM_WRITE_ERRORS => e
107
149
  MCP.configuration.exception_reporter.call(
@@ -113,11 +155,97 @@ module MCP
113
155
  end
114
156
 
115
157
  # Clean up failed sessions
116
- failed_sessions.each { |sid| cleanup_session_unsafe(sid) }
158
+ failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
117
159
 
118
160
  sent_count
119
161
  end
120
162
  end
163
+
164
+ streams_to_close.each do |stream|
165
+ close_stream_safely(stream)
166
+ end
167
+
168
+ result
169
+ end
170
+
171
+ # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
172
+ # blocks until the client responds.
173
+ #
174
+ # Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`,
175
+ # sends the request via SSE stream, then blocks on `queue.pop`.
176
+ # When the client POSTs a response, `handle_response` matches it by `request_id`
177
+ # and pushes the result onto the queue, unblocking this thread.
178
+ def send_request(method, params = nil, session_id: nil, related_request_id: nil)
179
+ if @stateless
180
+ raise "Stateless mode does not support server-to-client requests."
181
+ end
182
+
183
+ if @enable_json_response
184
+ raise "JSON response mode does not support server-to-client requests."
185
+ end
186
+
187
+ unless session_id
188
+ raise "session_id is required for server-to-client requests."
189
+ end
190
+
191
+ request_id = generate_request_id
192
+ queue = Queue.new
193
+
194
+ request = { jsonrpc: "2.0", id: request_id, method: method }
195
+ request[:params] = params if params
196
+
197
+ sent = false
198
+
199
+ @mutex.synchronize do
200
+ unless (session = @sessions[session_id])
201
+ raise "Session not found: #{session_id}."
202
+ end
203
+
204
+ @pending_responses[request_id] = { queue: queue, session_id: session_id }
205
+
206
+ if (stream = active_stream(session, related_request_id: related_request_id))
207
+ begin
208
+ send_to_stream(stream, request)
209
+ sent = true
210
+ rescue *STREAM_WRITE_ERRORS
211
+ if related_request_id && session[:post_request_streams]&.key?(related_request_id)
212
+ session[:post_request_streams].delete(related_request_id)
213
+ close_stream_safely(stream)
214
+ else
215
+ cleanup_session_unsafe(session_id)
216
+ end
217
+ end
218
+ end
219
+ end
220
+
221
+ # TODO: Replace with event store + replay when resumability is implemented.
222
+ # Resumability is a separate MCP specification feature (SSE event IDs, Last-Event-ID replay,
223
+ # event store management) independent of sampling.
224
+ # See: https://modelcontextprotocol.io/specification/latest/basic/transports#resumability-and-redelivery
225
+ #
226
+ # The TypeScript and Python SDKs buffer messages and replay on reconnect.
227
+ # Until then, raise to prevent queue.pop from blocking indefinitely.
228
+ unless sent
229
+ raise "No active stream for #{method} request."
230
+ end
231
+
232
+ response = queue.pop
233
+
234
+ if response.is_a?(Hash) && response.key?(:error)
235
+ raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
236
+ end
237
+
238
+ if response == :session_closed
239
+ raise "SSE session closed while waiting for #{method} response."
240
+ end
241
+
242
+ response
243
+ ensure
244
+ if request_id
245
+ @mutex.synchronize do
246
+ @pending_responses.delete(request_id)
247
+ end
248
+ end
121
249
  end
122
250
 
123
251
  private
@@ -136,55 +264,58 @@ module MCP
136
264
  def reap_expired_sessions
137
265
  return unless @session_idle_timeout
138
266
 
139
- expired_streams = @mutex.synchronize do
140
- @sessions.each_with_object([]) do |(session_id, session), streams|
141
- next unless session_expired?(session)
267
+ removed_sessions = @mutex.synchronize do
268
+ @sessions.each_key.filter_map do |session_id|
269
+ next unless session_expired?(@sessions[session_id])
142
270
 
143
- streams << session[:stream] if session[:stream]
144
- @sessions.delete(session_id)
271
+ cleanup_session_unsafe(session_id)
145
272
  end
146
273
  end
147
274
 
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
275
+ removed_sessions.each do |session|
276
+ close_stream_safely(session[:get_sse_stream])
277
+ close_post_request_streams(session)
155
278
  end
156
279
  end
157
280
 
158
281
  def send_to_stream(stream, data)
159
282
  message = data.is_a?(String) ? data : data.to_json
160
283
  stream.write("data: #{message}\n\n")
161
- stream.flush if stream.respond_to?(:flush)
284
+ stream.flush
162
285
  end
163
286
 
164
287
  def send_ping_to_stream(stream)
165
288
  stream.write(": ping #{Time.now.iso8601}\n\n")
166
- stream.flush if stream.respond_to?(:flush)
289
+ stream.flush
167
290
  end
168
291
 
169
292
  def handle_post(request)
170
- accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
293
+ required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
294
+ accept_error = validate_accept_header(request, required_types)
171
295
  return accept_error if accept_error
172
296
 
297
+ content_type_error = validate_content_type(request)
298
+ return content_type_error if content_type_error
299
+
173
300
  body_string = request.body.read
174
301
  session_id = extract_session_id(request)
175
302
 
176
303
  body = parse_request_body(body_string)
177
304
  return body unless body.is_a?(Hash) # Error response
178
305
 
179
- if body["method"] == "initialize"
306
+ if body[:method] == "initialize"
180
307
  handle_initialization(body_string, body)
181
308
  else
182
309
  return missing_session_id_response if !@stateless && !session_id
183
310
 
184
- if notification?(body) || response?(body)
311
+ if notification?(body)
185
312
  handle_accepted
313
+ elsif response?(body)
314
+ return session_not_found_response if !@stateless && !session_exists?(session_id)
315
+
316
+ handle_response(body, session_id: session_id)
186
317
  else
187
- handle_regular_request(body_string, session_id)
318
+ handle_regular_request(body_string, session_id, related_request_id: body[:id])
188
319
  end
189
320
  end
190
321
  rescue StandardError => e
@@ -228,21 +359,51 @@ module MCP
228
359
  end
229
360
 
230
361
  def cleanup_session(session_id)
231
- @mutex.synchronize do
362
+ session = @mutex.synchronize do
232
363
  cleanup_session_unsafe(session_id)
233
364
  end
365
+
366
+ if session
367
+ close_stream_safely(session[:get_sse_stream])
368
+ close_post_request_streams(session)
369
+ end
234
370
  end
235
371
 
372
+ # Removes a session from `@sessions` and returns it. Does not close the stream.
373
+ # Callers must close the stream outside the mutex to avoid holding the lock during
374
+ # potentially blocking I/O.
236
375
  def cleanup_session_unsafe(session_id)
237
- session = @sessions[session_id]
238
- return unless session
376
+ session = @sessions.delete(session_id)
239
377
 
240
- begin
241
- session[:stream]&.close
242
- rescue
243
- nil
378
+ # Unblock threads waiting on pending responses for this session.
379
+ @pending_responses.each_value do |pending_response|
380
+ if pending_response[:session_id] == session_id
381
+ pending_response[:queue].push(:session_closed)
382
+ end
383
+ end
384
+
385
+ session
386
+ end
387
+
388
+ def cleanup_and_collect_stream(session_id, streams_to_close)
389
+ return unless (removed = cleanup_session_unsafe(session_id))
390
+
391
+ streams_to_close << removed[:get_sse_stream]
392
+ removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
393
+ end
394
+
395
+ def close_stream_safely(stream)
396
+ stream&.close
397
+ rescue StandardError
398
+ # Ignore close-related errors from already closed/broken streams.
399
+ end
400
+
401
+ def close_post_request_streams(session)
402
+ return unless (post_request_streams = session[:post_request_streams])
403
+
404
+ post_request_streams.each_value do |stream|
405
+ close_stream_safely(stream)
244
406
  end
245
- @sessions.delete(session_id)
246
407
  end
247
408
 
248
409
  def extract_session_id(request)
@@ -268,6 +429,18 @@ module MCP
268
429
  end
269
430
  end
270
431
 
432
+ def validate_content_type(request)
433
+ content_type = request.env["CONTENT_TYPE"]
434
+ media_type = content_type&.split(";")&.first&.strip&.downcase
435
+ return if media_type == "application/json"
436
+
437
+ [
438
+ 415,
439
+ { "Content-Type" => "application/json" },
440
+ [{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
441
+ ]
442
+ end
443
+
271
444
  def not_acceptable_response(required_types)
272
445
  [
273
446
  406,
@@ -277,17 +450,35 @@ module MCP
277
450
  end
278
451
 
279
452
  def parse_request_body(body_string)
280
- JSON.parse(body_string)
453
+ JSON.parse(body_string, symbolize_names: true)
281
454
  rescue JSON::ParserError, TypeError
282
455
  [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
283
456
  end
284
457
 
285
458
  def notification?(body)
286
- !body["id"] && !!body["method"]
459
+ !body[:id] && !!body[:method]
287
460
  end
288
461
 
289
462
  def response?(body)
290
- !!body["id"] && !body["method"]
463
+ !!body[:id] && !body[:method]
464
+ end
465
+
466
+ # Verifies that the response came from the expected session to prevent
467
+ # cross-session response injection if request IDs are ever leaked.
468
+ def handle_response(body, session_id:)
469
+ request_id = body[:id]
470
+ @mutex.synchronize do
471
+ if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id
472
+ if body.key?(:error)
473
+ error = body[:error]
474
+ pending_response[:queue].push(error: { code: error[:code], message: error[:message] })
475
+ else
476
+ pending_response[:queue].push(body[:result])
477
+ end
478
+ end
479
+ end
480
+
481
+ handle_accepted
291
482
  end
292
483
 
293
484
  def handle_initialization(body_string, body)
@@ -300,7 +491,7 @@ module MCP
300
491
 
301
492
  @mutex.synchronize do
302
493
  @sessions[session_id] = {
303
- stream: nil,
494
+ get_sse_stream: nil,
304
495
  server_session: server_session,
305
496
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
306
497
  }
@@ -326,9 +517,8 @@ module MCP
326
517
  [202, {}, []]
327
518
  end
328
519
 
329
- def handle_regular_request(body_string, session_id)
520
+ def handle_regular_request(body_string, session_id, related_request_id: nil)
330
521
  server_session = nil
331
- stream = nil
332
522
 
333
523
  unless @stateless
334
524
  if session_id
@@ -338,55 +528,104 @@ module MCP
338
528
  @mutex.synchronize do
339
529
  session = @sessions[session_id]
340
530
  server_session = session[:server_session] if session
341
- stream = session[:stream] if session
342
531
  end
343
532
  end
344
533
  end
345
534
 
346
- response = if server_session
347
- server_session.handle_json(body_string)
535
+ if session_id && !@stateless && !@enable_json_response
536
+ handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
348
537
  else
349
- @server.handle_json(body_string)
538
+ response = dispatch_handle_json(body_string, server_session)
539
+ [200, { "Content-Type" => "application/json" }, [response]]
350
540
  end
541
+ end
542
+
543
+ # Returns the POST response as an SSE stream so the server can send
544
+ # JSON-RPC requests and notifications during request processing.
545
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
546
+ def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
547
+ body = proc do |stream|
548
+ @mutex.synchronize do
549
+ session = @sessions[session_id]
550
+ if session && related_request_id
551
+ session[:post_request_streams] ||= {}
552
+ session[:post_request_streams][related_request_id] = stream
553
+ end
554
+ end
555
+
556
+ begin
557
+ response = dispatch_handle_json(body_string, server_session)
558
+
559
+ send_to_stream(stream, response) if response
560
+ ensure
561
+ if related_request_id
562
+ @mutex.synchronize do
563
+ session = @sessions[session_id]
564
+ session[:post_request_streams]&.delete(related_request_id) if session
565
+ end
566
+ end
351
567
 
352
- if stream
353
- send_response_to_stream(stream, response, session_id)
568
+ begin
569
+ stream.close
570
+ rescue StandardError
571
+ # Ignore close-related errors from already closed/broken streams.
572
+ end
573
+ end
574
+ end
575
+
576
+ [200, SSE_HEADERS.dup, body]
577
+ end
578
+
579
+ # Returns the SSE stream available for server-to-client messages.
580
+ # When `related_request_id` is given, returns only the POST response
581
+ # stream for that request (no fallback to GET SSE). This prevents
582
+ # request-scoped messages from leaking to the wrong stream.
583
+ # When `related_request_id` is nil, returns the GET SSE stream.
584
+ def active_stream(session, related_request_id: nil)
585
+ if related_request_id
586
+ session.dig(:post_request_streams, related_request_id)
354
587
  else
355
- [200, { "Content-Type" => "application/json" }, [response]]
588
+ session[:get_sse_stream]
589
+ end
590
+ end
591
+
592
+ def dispatch_handle_json(body_string, server_session)
593
+ if server_session
594
+ server_session.handle_json(body_string)
595
+ else
596
+ @server.handle_json(body_string)
356
597
  end
357
598
  end
358
599
 
359
600
  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
601
+ removed = nil
602
+
603
+ response = @mutex.synchronize do
604
+ next session_not_found_response unless (session = @sessions[session_id])
605
+ next unless @session_idle_timeout
363
606
 
364
607
  if session_expired?(session)
365
- cleanup_session_unsafe(session_id)
366
- return session_not_found_response
608
+ removed = cleanup_session_unsafe(session_id)
609
+ next session_not_found_response
367
610
  end
368
611
 
369
612
  session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
613
+ nil
370
614
  end
371
615
 
372
- nil
373
- end
616
+ if removed
617
+ close_stream_safely(removed[:get_sse_stream])
374
618
 
375
- def get_session_stream(session_id)
376
- @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
619
+ removed[:post_request_streams]&.each_value do |stream|
620
+ close_stream_safely(stream)
621
+ end
622
+ end
623
+
624
+ response
377
625
  end
378
626
 
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]]
627
+ def get_session_stream(session_id)
628
+ @mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
390
629
  end
391
630
 
392
631
  def session_exists?(session_id)
@@ -416,13 +655,7 @@ module MCP
416
655
  def setup_sse_stream(session_id)
417
656
  body = create_sse_body(session_id)
418
657
 
419
- headers = {
420
- "Content-Type" => "text/event-stream",
421
- "Cache-Control" => "no-cache",
422
- "Connection" => "keep-alive",
423
- }
424
-
425
- [200, headers, body]
658
+ [200, SSE_HEADERS.dup, body]
426
659
  end
427
660
 
428
661
  def create_sse_body(session_id)
@@ -435,8 +668,8 @@ module MCP
435
668
  def store_stream_for_session(session_id, stream)
436
669
  @mutex.synchronize do
437
670
  session = @sessions[session_id]
438
- if session && !session[:stream]
439
- session[:stream] = stream
671
+ if session && !session[:get_sse_stream]
672
+ session[:get_sse_stream] = stream
440
673
  else
441
674
  # Either session was removed, or another request already established a stream.
442
675
  stream.close
@@ -461,13 +694,13 @@ module MCP
461
694
  end
462
695
 
463
696
  def session_active_with_stream?(session_id)
464
- @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] }
697
+ @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
465
698
  end
466
699
 
467
700
  def send_keepalive_ping(session_id)
468
701
  @mutex.synchronize do
469
- if @sessions[session_id] && @sessions[session_id][:stream]
470
- send_ping_to_stream(@sessions[session_id][:stream])
702
+ if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
703
+ send_ping_to_stream(@sessions[session_id][:get_sse_stream])
471
704
  end
472
705
  end
473
706
  rescue *STREAM_WRITE_ERRORS => e