mcp 0.10.0 → 0.15.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,136 @@ 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, parent_cancellation: nil, server_session: 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
+ cancel_hook = nil
194
+
195
+ request = { jsonrpc: "2.0", id: request_id, method: method }
196
+ request[:params] = params if params
197
+
198
+ sent = false
199
+
200
+ @mutex.synchronize do
201
+ unless (session = @sessions[session_id])
202
+ raise "Session not found: #{session_id}."
203
+ end
204
+
205
+ @pending_responses[request_id] = { queue: queue, session_id: session_id }
206
+
207
+ if (stream = active_stream(session, related_request_id: related_request_id))
208
+ begin
209
+ send_to_stream(stream, request)
210
+ sent = true
211
+ rescue *STREAM_WRITE_ERRORS
212
+ if related_request_id && session[:post_request_streams]&.key?(related_request_id)
213
+ session[:post_request_streams].delete(related_request_id)
214
+ close_stream_safely(stream)
215
+ else
216
+ cleanup_session_unsafe(session_id)
217
+ end
218
+ end
219
+ end
220
+ end
221
+
222
+ # TODO: Replace with event store + replay when resumability is implemented.
223
+ # Resumability is a separate MCP specification feature (SSE event IDs, Last-Event-ID replay,
224
+ # event store management) independent of sampling.
225
+ # See: https://modelcontextprotocol.io/specification/latest/basic/transports#resumability-and-redelivery
226
+ #
227
+ # The TypeScript and Python SDKs buffer messages and replay on reconnect.
228
+ # Until then, raise to prevent queue.pop from blocking indefinitely.
229
+ unless sent
230
+ raise "No active stream for #{method} request."
231
+ end
232
+
233
+ if parent_cancellation && server_session
234
+ cancel_hook = parent_cancellation.on_cancel do |reason|
235
+ server_session.send_peer_cancellation(
236
+ nested_request_id: request_id,
237
+ related_request_id: related_request_id,
238
+ reason: reason,
239
+ )
240
+ end
241
+ end
242
+
243
+ response = queue.pop
244
+
245
+ if response.is_a?(Hash) && response.key?(:error)
246
+ raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
247
+ end
248
+
249
+ if response == :session_closed
250
+ raise "SSE session closed while waiting for #{method} response."
251
+ end
252
+
253
+ if response == :cancelled
254
+ reason = @mutex.synchronize { @pending_responses.dig(request_id, :cancel_reason) }
255
+ raise MCP::CancelledError.new(
256
+ "#{method} request was cancelled",
257
+ request_id: request_id,
258
+ reason: reason,
259
+ )
260
+ end
261
+
262
+ response
263
+ ensure
264
+ parent_cancellation.off_cancel(cancel_hook) if cancel_hook
265
+ if request_id
266
+ @mutex.synchronize do
267
+ @pending_responses.delete(request_id)
268
+ end
269
+ end
270
+ end
271
+
272
+ # Unblocks a `send_request` awaiting a response when the peer is being cancelled.
273
+ # The waiting thread will see `:cancelled` on its queue and raise `MCP::CancelledError`.
274
+ #
275
+ # Race note: this is first-writer-wins on the pending-response queue. If a real response
276
+ # has already been pushed (client responded before the cancel hook fired), that response
277
+ # wins and `:cancelled` is enqueued behind it but never read - `send_request` returns
278
+ # the real response and deletes the pending entry in its `ensure` block. Conversely,
279
+ # if `:cancelled` arrives first, any later client response is silently dropped in `handle_response`
280
+ # because the pending entry has been removed.
281
+ def cancel_pending_request(request_id, reason: nil)
282
+ @mutex.synchronize do
283
+ if (pending = @pending_responses[request_id])
284
+ pending[:cancel_reason] = reason
285
+ pending[:queue].push(:cancelled)
286
+ end
287
+ end
121
288
  end
122
289
 
123
290
  private
@@ -136,55 +303,59 @@ module MCP
136
303
  def reap_expired_sessions
137
304
  return unless @session_idle_timeout
138
305
 
139
- expired_streams = @mutex.synchronize do
140
- @sessions.each_with_object([]) do |(session_id, session), streams|
141
- next unless session_expired?(session)
306
+ removed_sessions = @mutex.synchronize do
307
+ @sessions.each_key.filter_map do |session_id|
308
+ next unless session_expired?(@sessions[session_id])
142
309
 
143
- streams << session[:stream] if session[:stream]
144
- @sessions.delete(session_id)
310
+ cleanup_session_unsafe(session_id)
145
311
  end
146
312
  end
147
313
 
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
314
+ removed_sessions.each do |session|
315
+ close_stream_safely(session[:get_sse_stream])
316
+ close_post_request_streams(session)
155
317
  end
156
318
  end
157
319
 
158
320
  def send_to_stream(stream, data)
159
321
  message = data.is_a?(String) ? data : data.to_json
160
322
  stream.write("data: #{message}\n\n")
161
- stream.flush if stream.respond_to?(:flush)
323
+ stream.flush
162
324
  end
163
325
 
164
326
  def send_ping_to_stream(stream)
165
327
  stream.write(": ping #{Time.now.iso8601}\n\n")
166
- stream.flush if stream.respond_to?(:flush)
328
+ stream.flush
167
329
  end
168
330
 
169
331
  def handle_post(request)
170
- accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
332
+ required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
333
+ accept_error = validate_accept_header(request, required_types)
171
334
  return accept_error if accept_error
172
335
 
336
+ content_type_error = validate_content_type(request)
337
+ return content_type_error if content_type_error
338
+
173
339
  body_string = request.body.read
174
340
  session_id = extract_session_id(request)
175
341
 
176
342
  body = parse_request_body(body_string)
177
343
  return body unless body.is_a?(Hash) # Error response
178
344
 
179
- if body["method"] == "initialize"
345
+ if body[:method] == "initialize"
180
346
  handle_initialization(body_string, body)
181
347
  else
182
348
  return missing_session_id_response if !@stateless && !session_id
183
349
 
184
- if notification?(body) || response?(body)
350
+ if notification?(body)
351
+ dispatch_notification(body_string, session_id)
185
352
  handle_accepted
353
+ elsif response?(body)
354
+ return session_not_found_response if !@stateless && !session_exists?(session_id)
355
+
356
+ handle_response(body, session_id: session_id)
186
357
  else
187
- handle_regular_request(body_string, session_id)
358
+ handle_regular_request(body_string, session_id, related_request_id: body[:id])
188
359
  end
189
360
  end
190
361
  rescue StandardError => e
@@ -228,21 +399,51 @@ module MCP
228
399
  end
229
400
 
230
401
  def cleanup_session(session_id)
231
- @mutex.synchronize do
402
+ session = @mutex.synchronize do
232
403
  cleanup_session_unsafe(session_id)
233
404
  end
405
+
406
+ if session
407
+ close_stream_safely(session[:get_sse_stream])
408
+ close_post_request_streams(session)
409
+ end
234
410
  end
235
411
 
412
+ # Removes a session from `@sessions` and returns it. Does not close the stream.
413
+ # Callers must close the stream outside the mutex to avoid holding the lock during
414
+ # potentially blocking I/O.
236
415
  def cleanup_session_unsafe(session_id)
237
- session = @sessions[session_id]
238
- return unless session
416
+ session = @sessions.delete(session_id)
239
417
 
240
- begin
241
- session[:stream]&.close
242
- rescue
243
- nil
418
+ # Unblock threads waiting on pending responses for this session.
419
+ @pending_responses.each_value do |pending_response|
420
+ if pending_response[:session_id] == session_id
421
+ pending_response[:queue].push(:session_closed)
422
+ end
423
+ end
424
+
425
+ session
426
+ end
427
+
428
+ def cleanup_and_collect_stream(session_id, streams_to_close)
429
+ return unless (removed = cleanup_session_unsafe(session_id))
430
+
431
+ streams_to_close << removed[:get_sse_stream]
432
+ removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
433
+ end
434
+
435
+ def close_stream_safely(stream)
436
+ stream&.close
437
+ rescue StandardError
438
+ # Ignore close-related errors from already closed/broken streams.
439
+ end
440
+
441
+ def close_post_request_streams(session)
442
+ return unless (post_request_streams = session[:post_request_streams])
443
+
444
+ post_request_streams.each_value do |stream|
445
+ close_stream_safely(stream)
244
446
  end
245
- @sessions.delete(session_id)
246
447
  end
247
448
 
248
449
  def extract_session_id(request)
@@ -268,6 +469,18 @@ module MCP
268
469
  end
269
470
  end
270
471
 
472
+ def validate_content_type(request)
473
+ content_type = request.env["CONTENT_TYPE"]
474
+ media_type = content_type&.split(";")&.first&.strip&.downcase
475
+ return if media_type == "application/json"
476
+
477
+ [
478
+ 415,
479
+ { "Content-Type" => "application/json" },
480
+ [{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
481
+ ]
482
+ end
483
+
271
484
  def not_acceptable_response(required_types)
272
485
  [
273
486
  406,
@@ -277,17 +490,51 @@ module MCP
277
490
  end
278
491
 
279
492
  def parse_request_body(body_string)
280
- JSON.parse(body_string)
493
+ JSON.parse(body_string, symbolize_names: true)
281
494
  rescue JSON::ParserError, TypeError
282
495
  [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
283
496
  end
284
497
 
285
498
  def notification?(body)
286
- !body["id"] && !!body["method"]
499
+ !body[:id] && !!body[:method]
500
+ end
501
+
502
+ # Dispatches a client-originated notification (e.g. `notifications/cancelled`,
503
+ # `notifications/initialized`) through the server so it can update session state.
504
+ def dispatch_notification(body_string, session_id)
505
+ server_session = nil
506
+ if session_id && !@stateless
507
+ @mutex.synchronize do
508
+ session = @sessions[session_id]
509
+ server_session = session[:server_session] if session
510
+ end
511
+ end
512
+
513
+ dispatch_handle_json(body_string, server_session)
514
+ rescue => e
515
+ MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
287
516
  end
288
517
 
289
518
  def response?(body)
290
- !!body["id"] && !body["method"]
519
+ !!body[:id] && !body[:method]
520
+ end
521
+
522
+ # Verifies that the response came from the expected session to prevent
523
+ # cross-session response injection if request IDs are ever leaked.
524
+ def handle_response(body, session_id:)
525
+ request_id = body[:id]
526
+ @mutex.synchronize do
527
+ if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id
528
+ if body.key?(:error)
529
+ error = body[:error]
530
+ pending_response[:queue].push(error: { code: error[:code], message: error[:message] })
531
+ else
532
+ pending_response[:queue].push(body[:result])
533
+ end
534
+ end
535
+ end
536
+
537
+ handle_accepted
291
538
  end
292
539
 
293
540
  def handle_initialization(body_string, body)
@@ -300,7 +547,7 @@ module MCP
300
547
 
301
548
  @mutex.synchronize do
302
549
  @sessions[session_id] = {
303
- stream: nil,
550
+ get_sse_stream: nil,
304
551
  server_session: server_session,
305
552
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
306
553
  }
@@ -326,9 +573,8 @@ module MCP
326
573
  [202, {}, []]
327
574
  end
328
575
 
329
- def handle_regular_request(body_string, session_id)
576
+ def handle_regular_request(body_string, session_id, related_request_id: nil)
330
577
  server_session = nil
331
- stream = nil
332
578
 
333
579
  unless @stateless
334
580
  if session_id
@@ -338,55 +584,110 @@ module MCP
338
584
  @mutex.synchronize do
339
585
  session = @sessions[session_id]
340
586
  server_session = session[:server_session] if session
341
- stream = session[:stream] if session
342
587
  end
343
588
  end
344
589
  end
345
590
 
346
- response = if server_session
347
- server_session.handle_json(body_string)
591
+ if session_id && !@stateless && !@enable_json_response
592
+ handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
348
593
  else
349
- @server.handle_json(body_string)
594
+ response = dispatch_handle_json(body_string, server_session)
595
+
596
+ # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
597
+ # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
598
+ # which would produce an empty body the client cannot parse as JSON.
599
+ return handle_accepted if response.nil?
600
+
601
+ [200, { "Content-Type" => "application/json" }, [response]]
350
602
  end
603
+ end
604
+
605
+ # Returns the POST response as an SSE stream so the server can send
606
+ # JSON-RPC requests and notifications during request processing.
607
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
608
+ def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
609
+ body = proc do |stream|
610
+ @mutex.synchronize do
611
+ session = @sessions[session_id]
612
+ if session && related_request_id
613
+ session[:post_request_streams] ||= {}
614
+ session[:post_request_streams][related_request_id] = stream
615
+ end
616
+ end
617
+
618
+ begin
619
+ response = dispatch_handle_json(body_string, server_session)
351
620
 
352
- if stream
353
- send_response_to_stream(stream, response, session_id)
621
+ send_to_stream(stream, response) if response
622
+ ensure
623
+ if related_request_id
624
+ @mutex.synchronize do
625
+ session = @sessions[session_id]
626
+ session[:post_request_streams]&.delete(related_request_id) if session
627
+ end
628
+ end
629
+
630
+ begin
631
+ stream.close
632
+ rescue StandardError
633
+ # Ignore close-related errors from already closed/broken streams.
634
+ end
635
+ end
636
+ end
637
+
638
+ [200, SSE_HEADERS.dup, body]
639
+ end
640
+
641
+ # Returns the SSE stream available for server-to-client messages.
642
+ # When `related_request_id` is given, returns only the POST response
643
+ # stream for that request (no fallback to GET SSE). This prevents
644
+ # request-scoped messages from leaking to the wrong stream.
645
+ # When `related_request_id` is nil, returns the GET SSE stream.
646
+ def active_stream(session, related_request_id: nil)
647
+ if related_request_id
648
+ session.dig(:post_request_streams, related_request_id)
354
649
  else
355
- [200, { "Content-Type" => "application/json" }, [response]]
650
+ session[:get_sse_stream]
651
+ end
652
+ end
653
+
654
+ def dispatch_handle_json(body_string, server_session)
655
+ if server_session
656
+ server_session.handle_json(body_string)
657
+ else
658
+ @server.handle_json(body_string)
356
659
  end
357
660
  end
358
661
 
359
662
  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
663
+ removed = nil
664
+
665
+ response = @mutex.synchronize do
666
+ next session_not_found_response unless (session = @sessions[session_id])
667
+ next unless @session_idle_timeout
363
668
 
364
669
  if session_expired?(session)
365
- cleanup_session_unsafe(session_id)
366
- return session_not_found_response
670
+ removed = cleanup_session_unsafe(session_id)
671
+ next session_not_found_response
367
672
  end
368
673
 
369
674
  session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
675
+ nil
370
676
  end
371
677
 
372
- nil
373
- end
678
+ if removed
679
+ close_stream_safely(removed[:get_sse_stream])
374
680
 
375
- def get_session_stream(session_id)
376
- @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
681
+ removed[:post_request_streams]&.each_value do |stream|
682
+ close_stream_safely(stream)
683
+ end
684
+ end
685
+
686
+ response
377
687
  end
378
688
 
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]]
689
+ def get_session_stream(session_id)
690
+ @mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
390
691
  end
391
692
 
392
693
  def session_exists?(session_id)
@@ -416,13 +717,7 @@ module MCP
416
717
  def setup_sse_stream(session_id)
417
718
  body = create_sse_body(session_id)
418
719
 
419
- headers = {
420
- "Content-Type" => "text/event-stream",
421
- "Cache-Control" => "no-cache",
422
- "Connection" => "keep-alive",
423
- }
424
-
425
- [200, headers, body]
720
+ [200, SSE_HEADERS.dup, body]
426
721
  end
427
722
 
428
723
  def create_sse_body(session_id)
@@ -435,8 +730,8 @@ module MCP
435
730
  def store_stream_for_session(session_id, stream)
436
731
  @mutex.synchronize do
437
732
  session = @sessions[session_id]
438
- if session && !session[:stream]
439
- session[:stream] = stream
733
+ if session && !session[:get_sse_stream]
734
+ session[:get_sse_stream] = stream
440
735
  else
441
736
  # Either session was removed, or another request already established a stream.
442
737
  stream.close
@@ -461,13 +756,13 @@ module MCP
461
756
  end
462
757
 
463
758
  def session_active_with_stream?(session_id)
464
- @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] }
759
+ @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
465
760
  end
466
761
 
467
762
  def send_keepalive_ping(session_id)
468
763
  @mutex.synchronize do
469
- if @sessions[session_id] && @sessions[session_id][:stream]
470
- send_ping_to_stream(@sessions[session_id][:stream])
764
+ if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
765
+ send_ping_to_stream(@sessions[session_id][:get_sse_stream])
471
766
  end
472
767
  end
473
768
  rescue *STREAM_WRITE_ERRORS => e