mcp 0.4.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +216 -0
  3. data/README.md +550 -63
  4. data/lib/json_rpc_handler.rb +171 -0
  5. data/lib/mcp/annotations.rb +21 -0
  6. data/lib/mcp/client/http.rb +23 -7
  7. data/lib/mcp/client/stdio.rb +222 -0
  8. data/lib/mcp/client.rb +109 -34
  9. data/lib/mcp/configuration.rb +11 -9
  10. data/lib/mcp/content.rb +29 -2
  11. data/lib/mcp/icon.rb +22 -0
  12. data/lib/mcp/instrumentation.rb +1 -1
  13. data/lib/mcp/logging_message_notification.rb +30 -0
  14. data/lib/mcp/methods.rb +3 -0
  15. data/lib/mcp/progress.rb +24 -0
  16. data/lib/mcp/prompt/message.rb +1 -1
  17. data/lib/mcp/prompt/result.rb +1 -1
  18. data/lib/mcp/prompt.rb +22 -5
  19. data/lib/mcp/resource/contents.rb +2 -2
  20. data/lib/mcp/resource/embedded.rb +2 -1
  21. data/lib/mcp/resource.rb +7 -2
  22. data/lib/mcp/resource_template.rb +4 -2
  23. data/lib/mcp/server/transports/stdio_transport.rb +41 -4
  24. data/lib/mcp/server/transports/streamable_http_transport.rb +456 -85
  25. data/lib/mcp/server/transports.rb +10 -0
  26. data/lib/mcp/server.rb +403 -67
  27. data/lib/mcp/server_context.rb +58 -0
  28. data/lib/mcp/server_session.rb +107 -0
  29. data/lib/mcp/string_utils.rb +3 -3
  30. data/lib/mcp/tool/annotations.rb +1 -1
  31. data/lib/mcp/tool/input_schema.rb +6 -55
  32. data/lib/mcp/tool/output_schema.rb +3 -54
  33. data/lib/mcp/tool/response.rb +1 -1
  34. data/lib/mcp/tool/schema.rb +48 -0
  35. data/lib/mcp/tool.rb +39 -5
  36. data/lib/mcp/transport.rb +15 -2
  37. data/lib/mcp/version.rb +1 -1
  38. data/lib/mcp.rb +12 -31
  39. metadata +21 -42
  40. data/.gitattributes +0 -4
  41. data/.github/dependabot.yml +0 -6
  42. data/.github/workflows/ci.yml +0 -33
  43. data/.github/workflows/release.yml +0 -25
  44. data/.gitignore +0 -10
  45. data/.rubocop.yml +0 -12
  46. data/AGENTS.md +0 -119
  47. data/CHANGELOG.md +0 -87
  48. data/CODE_OF_CONDUCT.md +0 -74
  49. data/Gemfile +0 -27
  50. data/LICENSE.txt +0 -21
  51. data/Rakefile +0 -17
  52. data/bin/console +0 -15
  53. data/bin/rake +0 -31
  54. data/bin/setup +0 -8
  55. data/dev.yml +0 -31
  56. data/examples/README.md +0 -197
  57. data/examples/http_client.rb +0 -184
  58. data/examples/http_server.rb +0 -170
  59. data/examples/stdio_server.rb +0 -94
  60. data/examples/streamable_http_client.rb +0 -203
  61. data/examples/streamable_http_server.rb +0 -172
  62. data/mcp.gemspec +0 -32
@@ -1,20 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../transport"
4
3
  require "json"
5
- require "securerandom"
4
+ require_relative "../../transport"
6
5
 
7
6
  module MCP
8
7
  class Server
9
8
  module Transports
10
9
  class StreamableHTTPTransport < Transport
11
- def initialize(server)
12
- super
13
- # { session_id => { stream: stream_object }
10
+ SSE_HEADERS = {
11
+ "Content-Type" => "text/event-stream",
12
+ "Cache-Control" => "no-cache",
13
+ "Connection" => "keep-alive",
14
+ }.freeze
15
+
16
+ def initialize(server, stateless: false, session_idle_timeout: nil)
17
+ super(server)
18
+ # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
14
19
  @sessions = {}
15
20
  @mutex = Mutex.new
21
+
22
+ @stateless = stateless
23
+ @session_idle_timeout = session_idle_timeout
24
+ @pending_responses = {}
25
+
26
+ if @session_idle_timeout
27
+ if @stateless
28
+ raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
29
+ elsif @session_idle_timeout <= 0
30
+ raise ArgumentError, "session_idle_timeout must be a positive number."
31
+ end
32
+ end
33
+
34
+ start_reaper_thread if @session_idle_timeout
16
35
  end
17
36
 
37
+ REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
38
+ REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
39
+ STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
40
+ SESSION_REAP_INTERVAL = 60
41
+
18
42
  def handle_request(request)
19
43
  case request.env["REQUEST_METHOD"]
20
44
  when "POST"
@@ -24,38 +48,63 @@ module MCP
24
48
  when "DELETE"
25
49
  handle_delete(request)
26
50
  else
27
- [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
51
+ method_not_allowed_response
28
52
  end
29
53
  end
30
54
 
31
55
  def close
32
- @mutex.synchronize do
33
- @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
56
+ @reaper_thread&.kill
57
+ @reaper_thread = nil
58
+
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)
34
66
  end
35
67
  end
36
68
 
37
- def send_notification(method, params = nil, session_id: nil)
69
+ def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
70
+ # Stateless mode doesn't support notifications
71
+ raise "Stateless mode does not support notifications" if @stateless
72
+
38
73
  notification = {
39
74
  jsonrpc: "2.0",
40
- method:,
75
+ method: method,
41
76
  }
42
77
  notification[:params] = params if params
43
78
 
44
- @mutex.synchronize do
79
+ streams_to_close = []
80
+
81
+ result = @mutex.synchronize do
45
82
  if session_id
46
83
  # Send to specific session
47
- session = @sessions[session_id]
48
- 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
88
+
89
+ if session_expired?(session)
90
+ cleanup_and_collect_stream(session_id, streams_to_close)
91
+ next false
92
+ end
49
93
 
50
94
  begin
51
- send_to_stream(session[:stream], notification)
95
+ send_to_stream(stream, notification)
52
96
  true
53
- rescue IOError, Errno::EPIPE => e
97
+ rescue *STREAM_WRITE_ERRORS => e
54
98
  MCP.configuration.exception_reporter.call(
55
99
  e,
56
100
  { session_id: session_id, error: "Failed to send notification" },
57
101
  )
58
- 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
59
108
  false
60
109
  end
61
110
  else
@@ -64,12 +113,17 @@ module MCP
64
113
  failed_sessions = []
65
114
 
66
115
  @sessions.each do |sid, session|
67
- next unless session[:stream]
116
+ next unless (stream = session[:stream])
117
+
118
+ if session_expired?(session)
119
+ failed_sessions << sid
120
+ next
121
+ end
68
122
 
69
123
  begin
70
- send_to_stream(session[:stream], notification)
124
+ send_to_stream(stream, notification)
71
125
  sent_count += 1
72
- rescue IOError, Errno::EPIPE => e
126
+ rescue *STREAM_WRITE_ERRORS => e
73
127
  MCP.configuration.exception_reporter.call(
74
128
  e,
75
129
  { session_id: sid, error: "Failed to send notification" },
@@ -79,15 +133,125 @@ module MCP
79
133
  end
80
134
 
81
135
  # Clean up failed sessions
82
- failed_sessions.each { |sid| cleanup_session_unsafe(sid) }
136
+ failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
83
137
 
84
138
  sent_count
85
139
  end
86
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
87
223
  end
88
224
 
89
225
  private
90
226
 
227
+ def start_reaper_thread
228
+ @reaper_thread = Thread.new do
229
+ loop do
230
+ sleep(SESSION_REAP_INTERVAL)
231
+ reap_expired_sessions
232
+ rescue StandardError => e
233
+ MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
234
+ end
235
+ end
236
+ end
237
+
238
+ def reap_expired_sessions
239
+ return unless @session_idle_timeout
240
+
241
+ removed_sessions = @mutex.synchronize do
242
+ @sessions.each_key.filter_map do |session_id|
243
+ next unless session_expired?(@sessions[session_id])
244
+
245
+ cleanup_session_unsafe(session_id)
246
+ end
247
+ end
248
+
249
+ removed_sessions.each do |session|
250
+ close_stream_safely(session[:stream])
251
+ close_post_request_streams(session)
252
+ end
253
+ end
254
+
91
255
  def send_to_stream(stream, data)
92
256
  message = data.is_a?(String) ? data : data.to_json
93
257
  stream.write("data: #{message}\n\n")
@@ -100,18 +264,29 @@ module MCP
100
264
  end
101
265
 
102
266
  def handle_post(request)
267
+ accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
268
+ return accept_error if accept_error
269
+
103
270
  body_string = request.body.read
104
271
  session_id = extract_session_id(request)
105
272
 
106
273
  body = parse_request_body(body_string)
107
274
  return body unless body.is_a?(Hash) # Error response
108
275
 
109
- if body["method"] == "initialize"
276
+ if body[:method] == "initialize"
110
277
  handle_initialization(body_string, body)
111
- elsif notification?(body) || response?(body)
112
- handle_accepted
113
278
  else
114
- handle_regular_request(body_string, session_id)
279
+ return missing_session_id_response if !@stateless && !session_id
280
+
281
+ if notification?(body)
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)
287
+ else
288
+ handle_regular_request(body_string, session_id, related_request_id: body[:id])
289
+ end
115
290
  end
116
291
  rescue StandardError => e
117
292
  MCP.configuration.exception_reporter.call(e, { request: body_string })
@@ -119,79 +294,180 @@ module MCP
119
294
  end
120
295
 
121
296
  def handle_get(request)
297
+ if @stateless
298
+ return method_not_allowed_response
299
+ end
300
+
301
+ accept_error = validate_accept_header(request, REQUIRED_GET_ACCEPT_TYPES)
302
+ return accept_error if accept_error
303
+
122
304
  session_id = extract_session_id(request)
123
305
 
124
306
  return missing_session_id_response unless session_id
125
- return session_not_found_response unless session_exists?(session_id)
307
+
308
+ error_response = validate_and_touch_session(session_id)
309
+ return error_response if error_response
310
+ return session_already_connected_response if get_session_stream(session_id)
126
311
 
127
312
  setup_sse_stream(session_id)
128
313
  end
129
314
 
130
315
  def handle_delete(request)
131
- session_id = request.env["HTTP_MCP_SESSION_ID"]
316
+ success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
132
317
 
133
- return [
134
- 400,
135
- { "Content-Type" => "application/json" },
136
- [{ error: "Missing session ID" }.to_json],
137
- ] unless session_id
318
+ if @stateless
319
+ # Stateless mode doesn't support sessions, so we can just return a success response
320
+ return success_response
321
+ end
322
+
323
+ return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"])
324
+ return session_not_found_response unless session_exists?(session_id)
138
325
 
139
326
  cleanup_session(session_id)
140
- [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
327
+
328
+ success_response
141
329
  end
142
330
 
143
331
  def cleanup_session(session_id)
144
- @mutex.synchronize do
332
+ session = @mutex.synchronize do
145
333
  cleanup_session_unsafe(session_id)
146
334
  end
335
+
336
+ if session
337
+ close_stream_safely(session[:stream])
338
+ close_post_request_streams(session)
339
+ end
147
340
  end
148
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.
149
345
  def cleanup_session_unsafe(session_id)
150
- session = @sessions[session_id]
151
- return unless session
346
+ session = @sessions.delete(session_id)
152
347
 
153
- begin
154
- session[:stream]&.close
155
- rescue
156
- 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)
157
376
  end
158
- @sessions.delete(session_id)
159
377
  end
160
378
 
161
379
  def extract_session_id(request)
162
380
  request.env["HTTP_MCP_SESSION_ID"]
163
381
  end
164
382
 
383
+ def validate_accept_header(request, required_types)
384
+ accept_header = request.env["HTTP_ACCEPT"]
385
+ return not_acceptable_response(required_types) unless accept_header
386
+
387
+ accepted_types = parse_accept_header(accept_header)
388
+ return if accepted_types.include?("*/*")
389
+
390
+ missing_types = required_types - accepted_types
391
+ return not_acceptable_response(required_types) unless missing_types.empty?
392
+
393
+ nil
394
+ end
395
+
396
+ def parse_accept_header(header)
397
+ header.split(",").map do |part|
398
+ part.split(";").first.strip
399
+ end
400
+ end
401
+
402
+ def not_acceptable_response(required_types)
403
+ [
404
+ 406,
405
+ { "Content-Type" => "application/json" },
406
+ [{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json],
407
+ ]
408
+ end
409
+
165
410
  def parse_request_body(body_string)
166
- JSON.parse(body_string)
411
+ JSON.parse(body_string, symbolize_names: true)
167
412
  rescue JSON::ParserError, TypeError
168
413
  [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
169
414
  end
170
415
 
171
416
  def notification?(body)
172
- !body["id"] && !!body["method"]
417
+ !body[:id] && !!body[:method]
173
418
  end
174
419
 
175
420
  def response?(body)
176
- !!body["id"] && !body["method"]
421
+ !!body[:id] && !body[:method]
177
422
  end
178
423
 
179
- def handle_initialization(body_string, body)
180
- session_id = SecureRandom.uuid
181
-
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]
182
428
  @mutex.synchronize do
183
- @sessions[session_id] = {
184
- stream: nil,
185
- }
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
186
437
  end
187
438
 
188
- response = @server.handle_json(body_string)
439
+ handle_accepted
440
+ end
441
+
442
+ def handle_initialization(body_string, body)
443
+ session_id = nil
444
+ server_session = nil
445
+
446
+ unless @stateless
447
+ session_id = SecureRandom.uuid
448
+ server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
449
+
450
+ @mutex.synchronize do
451
+ @sessions[session_id] = {
452
+ stream: nil,
453
+ server_session: server_session,
454
+ last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
455
+ }
456
+ end
457
+ end
458
+
459
+ response = if server_session
460
+ server_session.handle_json(body_string)
461
+ else
462
+ @server.handle_json(body_string)
463
+ end
189
464
 
190
465
  headers = {
191
466
  "Content-Type" => "application/json",
192
- "Mcp-Session-Id" => session_id,
193
467
  }
194
468
 
469
+ headers["Mcp-Session-Id"] = session_id if session_id
470
+
195
471
  [200, headers, [response]]
196
472
  end
197
473
 
@@ -199,43 +475,125 @@ module MCP
199
475
  [202, {}, []]
200
476
  end
201
477
 
202
- def handle_regular_request(body_string, session_id)
203
- # If session ID is provided, but not in the sessions hash, return an error
204
- if session_id && !@sessions.key?(session_id)
205
- return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
206
- end
478
+ def handle_regular_request(body_string, session_id, related_request_id: nil)
479
+ server_session = nil
207
480
 
208
- response = @server.handle_json(body_string)
209
- stream = get_session_stream(session_id) if session_id
481
+ unless @stateless
482
+ if session_id
483
+ error_response = validate_and_touch_session(session_id)
484
+ return error_response if error_response
485
+
486
+ @mutex.synchronize do
487
+ session = @sessions[session_id]
488
+ server_session = session[:server_session] if session
489
+ end
490
+ end
491
+ end
210
492
 
211
- if stream
212
- send_response_to_stream(stream, response, session_id)
493
+ if session_id && !@stateless
494
+ handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
213
495
  else
496
+ response = dispatch_handle_json(body_string, server_session)
214
497
  [200, { "Content-Type" => "application/json" }, [response]]
215
498
  end
216
499
  end
217
500
 
218
- def get_session_stream(session_id)
219
- @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
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
532
+ end
533
+
534
+ [200, SSE_HEADERS, body]
220
535
  end
221
536
 
222
- def send_response_to_stream(stream, response, session_id)
223
- message = JSON.parse(response)
224
- send_to_stream(stream, message)
225
- [200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]]
226
- rescue IOError, Errno::EPIPE => e
227
- MCP.configuration.exception_reporter.call(
228
- e,
229
- { session_id: session_id, error: "Stream closed during response" },
230
- )
231
- cleanup_session(session_id)
232
- [200, { "Content-Type" => "application/json" }, [response]]
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)
545
+ else
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)
555
+ end
556
+ end
557
+
558
+ def validate_and_touch_session(session_id)
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
564
+
565
+ if session_expired?(session)
566
+ removed = cleanup_session_unsafe(session_id)
567
+ next session_not_found_response
568
+ end
569
+
570
+ session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
571
+ nil
572
+ end
573
+
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
583
+ end
584
+
585
+ def get_session_stream(session_id)
586
+ @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
233
587
  end
234
588
 
235
589
  def session_exists?(session_id)
236
590
  @mutex.synchronize { @sessions.key?(session_id) }
237
591
  end
238
592
 
593
+ def method_not_allowed_response
594
+ [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
595
+ end
596
+
239
597
  def missing_session_id_response
240
598
  [400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
241
599
  end
@@ -244,31 +602,38 @@ module MCP
244
602
  [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
245
603
  end
246
604
 
605
+ def session_already_connected_response
606
+ [
607
+ 409,
608
+ { "Content-Type" => "application/json" },
609
+ [{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json],
610
+ ]
611
+ end
612
+
247
613
  def setup_sse_stream(session_id)
248
614
  body = create_sse_body(session_id)
249
615
 
250
- headers = {
251
- "Content-Type" => "text/event-stream",
252
- "Cache-Control" => "no-cache",
253
- "Connection" => "keep-alive",
254
- }
255
-
256
- [200, headers, body]
616
+ [200, SSE_HEADERS, body]
257
617
  end
258
618
 
259
619
  def create_sse_body(session_id)
260
620
  proc do |stream|
261
- store_stream_for_session(session_id, stream)
262
- start_keepalive_thread(session_id)
621
+ stored = store_stream_for_session(session_id, stream)
622
+ start_keepalive_thread(session_id) if stored
263
623
  end
264
624
  end
265
625
 
266
626
  def store_stream_for_session(session_id, stream)
267
627
  @mutex.synchronize do
268
- if @sessions[session_id]
269
- @sessions[session_id][:stream] = stream
628
+ session = @sessions[session_id]
629
+ if session && !session[:stream]
630
+ session[:stream] = stream
270
631
  else
632
+ # Either session was removed, or another request already established a stream.
271
633
  stream.close
634
+ # `stream.close` may return a truthy value depending on the stream class.
635
+ # Explicitly return nil to guarantee a falsy return for callers.
636
+ nil
272
637
  end
273
638
  end
274
639
  end
@@ -296,13 +661,19 @@ module MCP
296
661
  send_ping_to_stream(@sessions[session_id][:stream])
297
662
  end
298
663
  end
299
- rescue IOError, Errno::EPIPE => e
664
+ rescue *STREAM_WRITE_ERRORS => e
300
665
  MCP.configuration.exception_reporter.call(
301
666
  e,
302
667
  { session_id: session_id, error: "Stream closed" },
303
668
  )
304
669
  raise # Re-raise to exit the keepalive loop
305
670
  end
671
+
672
+ def session_expired?(session)
673
+ return false unless @session_idle_timeout
674
+
675
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout
676
+ end
306
677
  end
307
678
  end
308
679
  end