model-context-protocol-rb 0.5.0 → 0.6.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.
@@ -15,65 +15,73 @@ module ModelContextProtocol
15
15
  end
16
16
  end
17
17
 
18
+ attr_reader :server_logger
19
+
20
+ # Initialize the HTTP transport with Redis-backed cross-server communication support
21
+ # Sets up background threads for message polling and stream monitoring in multi-server deployments
18
22
  def initialize(router:, configuration:)
19
23
  @router = router
20
24
  @configuration = configuration
25
+ @client_logger = configuration.client_logger
26
+ @server_logger = configuration.server_logger
21
27
 
22
- transport_options = @configuration.transport_options
23
28
  @redis_pool = ModelContextProtocol::Server::RedisConfig.pool
29
+ @redis = ModelContextProtocol::Server::RedisClientProxy.new(@redis_pool)
30
+
31
+ transport_options = @configuration.transport_options
24
32
  @require_sessions = transport_options.fetch(:require_sessions, false)
25
33
  @default_protocol_version = transport_options.fetch(:default_protocol_version, "2025-03-26")
26
34
  @session_protocol_versions = {}
27
35
  @validate_origin = transport_options.fetch(:validate_origin, true)
28
36
  @allowed_origins = transport_options.fetch(:allowed_origins, ["http://localhost", "https://localhost", "http://127.0.0.1", "https://127.0.0.1"])
29
- @redis = ModelContextProtocol::Server::RedisClientProxy.new(@redis_pool)
30
-
31
- @session_store = SessionStore.new(
32
- @redis,
33
- ttl: transport_options[:session_ttl] || 3600
34
- )
35
37
 
38
+ @session_store = SessionStore.new(@redis, ttl: transport_options[:session_ttl] || 3600)
36
39
  @server_instance = "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(4)}"
37
40
  @stream_registry = StreamRegistry.new(@redis, @server_instance)
38
41
  @notification_queue = NotificationQueue.new(@redis, @server_instance)
39
42
  @event_counter = EventCounter.new(@redis, @server_instance)
40
43
  @request_store = RequestStore.new(@redis, @server_instance)
41
- @stream_monitor_thread = nil
42
- @message_poller = MessagePoller.new(@redis, @stream_registry, @configuration.logger) do |stream, message|
44
+ @server_request_store = ServerRequestStore.new(@redis, @server_instance)
45
+ @ping_timeout = transport_options.fetch(:ping_timeout, 10)
46
+
47
+ @message_poller = MessagePoller.new(@redis, @stream_registry, @client_logger) do |stream, message|
43
48
  send_to_stream(stream, message)
44
49
  end
50
+ @message_poller.start
45
51
 
46
- start_message_poller
52
+ @stream_monitor_running = false
53
+ @stream_monitor_thread = nil
47
54
  start_stream_monitor
48
55
  end
49
56
 
57
+ # Gracefully shut down the transport by stopping background threads and cleaning up resources
58
+ # Closes all active streams and returns Redis connections to the pool
50
59
  def shutdown
51
- @configuration.logger.info("Shutting down StreamableHttpTransport")
60
+ @server_logger.info("Shutting down StreamableHttpTransport")
52
61
 
53
- # Stop the message poller
54
62
  @message_poller&.stop
55
63
 
56
- # Stop the stream monitor thread
64
+ @stream_monitor_running = false
57
65
  if @stream_monitor_thread&.alive?
58
66
  @stream_monitor_thread.kill
59
- @stream_monitor_thread.join(timeout: 5)
67
+ @stream_monitor_thread.join(5)
60
68
  end
61
69
 
62
- # Unregister all local streams
63
- @stream_registry.get_all_local_streams.each do |session_id, stream|
64
- @stream_registry.unregister_stream(session_id)
65
- @session_store.mark_stream_inactive(session_id)
70
+ @stream_registry.get_all_local_streams.each do |session_id, _stream|
71
+ close_stream(session_id, reason: "shutdown")
66
72
  rescue => e
67
- @configuration.logger.error("Error during stream cleanup", session_id: session_id, error: e.message)
73
+ @server_logger.error("Error during stream cleanup for session #{session_id}: #{e.message}")
68
74
  end
69
75
 
70
76
  @redis_pool.checkin(@redis) if @redis_pool && @redis
71
77
 
72
- @configuration.logger.info("StreamableHttpTransport shutdown complete")
78
+ @server_logger.info("StreamableHttpTransport shutdown complete")
73
79
  end
74
80
 
81
+ # Main entry point for handling HTTP requests (POST, GET, DELETE)
82
+ # Routes requests to appropriate handlers and manages the request/response lifecycle
75
83
  def handle
76
- @configuration.logger.connect_transport(self)
84
+ @server_logger.debug("Handling streamable HTTP transport request")
77
85
 
78
86
  env = @configuration.transport_options[:env]
79
87
 
@@ -83,10 +91,13 @@ module ModelContextProtocol
83
91
 
84
92
  case env["REQUEST_METHOD"]
85
93
  when "POST"
94
+ @server_logger.debug("Handling POST request")
86
95
  handle_post_request(env)
87
96
  when "GET"
88
- handle_sse_request(env)
97
+ @server_logger.debug("Handling GET request")
98
+ handle_get_request(env)
89
99
  when "DELETE"
100
+ @server_logger.debug("Handling DELETE request")
90
101
  handle_delete_request(env)
91
102
  else
92
103
  error_response = ErrorResponse[id: nil, error: {code: -32601, message: "Method not allowed"}]
@@ -94,22 +105,68 @@ module ModelContextProtocol
94
105
  end
95
106
  end
96
107
 
97
- def send_notification(method, params)
108
+ # Send real-time notifications to active SSE streams or queue for delivery
109
+ # Used for progress updates, resource changes, and other server-initiated messages
110
+ # @param method [String] the notification method name
111
+ # @param params [Hash] the notification parameters
112
+ # @param session_id [String, nil] optional session ID for targeted delivery
113
+ def send_notification(method, params, session_id: nil)
98
114
  notification = {
99
115
  jsonrpc: "2.0",
100
116
  method: method,
101
117
  params: params
102
118
  }
103
119
 
104
- if @stream_registry.has_any_local_streams?
120
+ log_to_server_with_context do |logger|
121
+ logger.info("← #{method} [outgoing]")
122
+ logger.info(" Notification: #{notification.to_json}")
123
+ end
124
+
125
+ if session_id
126
+ # Deliver to specific session/stream
127
+ @server_logger.debug("Attempting targeted delivery to session: #{session_id}")
128
+ if deliver_to_session_stream(session_id, notification)
129
+ @server_logger.debug("Successfully delivered notification to specific stream: #{session_id}")
130
+ else
131
+ @server_logger.debug("Failed to deliver to specific stream #{session_id}, queuing notification: #{method}")
132
+ @notification_queue.push(notification)
133
+ end
134
+ elsif @stream_registry.get_local_stream(nil) # Check for persistent notification stream (no-session)
135
+ @server_logger.debug("No session_id provided, delivering notification to persistent notification stream")
136
+ if deliver_to_session_stream(nil, notification)
137
+ @server_logger.debug("Successfully delivered notification to persistent notification stream")
138
+ else
139
+ @server_logger.debug("Failed to deliver to persistent notification stream, queuing notification: #{method}")
140
+ @notification_queue.push(notification)
141
+ end
142
+ elsif @stream_registry.has_any_local_streams?
143
+ @server_logger.debug("No persistent notification stream, delivering notification to active streams")
105
144
  deliver_to_active_streams(notification)
106
145
  else
146
+ @server_logger.debug("No active streams, queuing notification: #{method}")
107
147
  @notification_queue.push(notification)
108
148
  end
109
149
  end
110
150
 
111
151
  private
112
152
 
153
+ # Provide logging context with request ID and MCP context information
154
+ # Ensures consistent logging format across all transport operations
155
+ def log_to_server_with_context(request_id: nil, &block)
156
+ original_context = Thread.current[:mcp_context]
157
+ if request_id && !Thread.current[:mcp_context]
158
+ Thread.current[:mcp_context] = {jsonrpc_request_id: request_id}
159
+ end
160
+
161
+ begin
162
+ block.call(@server_logger) if block_given?
163
+ ensure
164
+ Thread.current[:mcp_context] = original_context if request_id && original_context.nil?
165
+ end
166
+ end
167
+
168
+ # Validate HTTP headers for required content type and CORS origin
169
+ # Returns error response if headers are invalid, nil if valid
113
170
  def validate_headers(env)
114
171
  if @validate_origin
115
172
  origin = env["HTTP_ORIGIN"]
@@ -139,6 +196,8 @@ module ModelContextProtocol
139
196
  nil
140
197
  end
141
198
 
199
+ # Determine JSON-RPC message type from request body structure
200
+ # Classifies messages as request, notification, response, or unknown
142
201
  def determine_message_type(body)
143
202
  if body.key?("method") && body.key?("id")
144
203
  :request
@@ -151,56 +210,8 @@ module ModelContextProtocol
151
210
  end
152
211
  end
153
212
 
154
- def create_initialization_sse_stream_proc(response_data)
155
- proc do |stream|
156
- event_id = next_event_id
157
- send_sse_event(stream, response_data, event_id)
158
- end
159
- end
160
-
161
- def create_request_sse_stream_proc(response_data)
162
- proc do |stream|
163
- event_id = next_event_id
164
- send_sse_event(stream, response_data, event_id)
165
- end
166
- end
167
-
168
- def create_progressive_request_sse_stream_proc(request_body, session_id)
169
- proc do |stream|
170
- temp_stream_id = session_id || "temp-#{SecureRandom.hex(8)}"
171
- @stream_registry.register_stream(temp_stream_id, stream)
172
-
173
- begin
174
- result = @router.route(request_body, request_store: @request_store, session_id: session_id, transport: self)
175
-
176
- if result
177
- response = Response[id: request_body["id"], result: result.serialized]
178
-
179
- event_id = next_event_id
180
- send_sse_event(stream, response.serialized, event_id)
181
- else
182
- event_id = next_event_id
183
- send_sse_event(stream, {}, event_id)
184
- end
185
- ensure
186
- @stream_registry.unregister_stream(temp_stream_id)
187
- end
188
- end
189
- end
190
-
191
- def next_event_id
192
- @event_counter.next_event_id
193
- end
194
-
195
- def send_sse_event(stream, data, event_id = nil)
196
- if event_id
197
- stream.write("id: #{event_id}\n")
198
- end
199
- message = data.is_a?(String) ? data : data.to_json
200
- stream.write("data: #{message}\n\n")
201
- stream.flush if stream.respond_to?(:flush)
202
- end
203
-
213
+ # Handle HTTP POST requests containing JSON-RPC messages
214
+ # Parses request body and routes to initialization or regular request handlers
204
215
  def handle_post_request(env)
205
216
  validation_error = validate_headers(env)
206
217
  return validation_error if validation_error
@@ -210,30 +221,65 @@ module ModelContextProtocol
210
221
  session_id = env["HTTP_MCP_SESSION_ID"]
211
222
  accept_header = env["HTTP_ACCEPT"] || ""
212
223
 
213
- case body["method"]
214
- when "initialize"
224
+ log_to_server_with_context(request_id: body["id"]) do |logger|
225
+ method = body["method"]
226
+ id = body["id"]
227
+
228
+ if method&.start_with?("notifications/") || id.nil?
229
+ logger.info("→ #{method} [notification]")
230
+ else
231
+ logger.info("→ #{method} (id: #{id}) [request]")
232
+ end
233
+
234
+ logger.info(" Request: #{body.to_json}")
235
+ logger.debug(" Accept: #{accept_header}") if body["method"] != "notifications/initialized"
236
+ logger.debug(" Redis Pool: #{ModelContextProtocol::Server::RedisConfig.stats}")
237
+ end
238
+
239
+ if body["method"] == "initialize"
215
240
  handle_initialization(body, accept_header)
216
241
  else
217
242
  handle_regular_request(body, session_id, accept_header)
218
243
  end
219
- rescue JSON::ParserError
244
+ rescue JSON::ParserError => e
245
+ log_to_server_with_context do |logger|
246
+ logger.error("JSON parse error in streamable HTTP transport: #{e.message}")
247
+ end
220
248
  error_response = ErrorResponse[id: "", error: {code: -32700, message: "Parse error"}]
249
+ log_to_server_with_context do |logger|
250
+ logger.info("← Error response (code: #{error_response.error[:code]})")
251
+ logger.info(" #{error_response.serialized.to_json}")
252
+ end
221
253
  {json: error_response.serialized, status: 400}
222
254
  rescue ModelContextProtocol::Server::ParameterValidationError => validation_error
223
- @configuration.logger.error("Validation error", error: validation_error.message)
255
+ log_to_server_with_context(request_id: body&.dig("id")) do |logger|
256
+ logger.error("Parameter validation failed in streamable HTTP transport: #{validation_error.message}")
257
+ end
224
258
  error_response = ErrorResponse[id: body&.dig("id"), error: {code: -32602, message: validation_error.message}]
259
+ log_to_server_with_context(request_id: error_response.id) do |logger|
260
+ logger.info("← Error response (code: #{error_response.error[:code]})")
261
+ logger.info(" #{error_response.serialized.to_json}")
262
+ end
225
263
  {json: error_response.serialized, status: 400}
226
264
  rescue => e
227
- @configuration.logger.error("Error handling POST request", error: e.message, backtrace: e.backtrace.first(5))
265
+ log_to_server_with_context(request_id: body&.dig("id")) do |logger|
266
+ logger.error("Internal error handling POST request in streamable HTTP transport: #{e.message}")
267
+ logger.debug("Backtrace: #{e.backtrace.join("\n")}")
268
+ end
228
269
  error_response = ErrorResponse[id: body&.dig("id"), error: {code: -32603, message: "Internal error"}]
270
+ log_to_server_with_context(request_id: error_response.id) do |logger|
271
+ logger.info("← Error response (code: #{error_response.error[:code]})")
272
+ logger.info(" #{error_response.serialized.to_json}")
273
+ end
229
274
  {json: error_response.serialized, status: 500}
230
275
  end
231
276
 
277
+ # Handle MCP initialization requests to establish protocol version and optional sessions
278
+ # Always returns JSON response regardless of Accept header to keep initialization simple
232
279
  def handle_initialization(body, accept_header)
233
280
  result = @router.route(body, transport: self)
234
281
  response = Response[id: body["id"], result: result.serialized]
235
282
  response_headers = {}
236
-
237
283
  negotiated_protocol_version = result.serialized[:protocolVersion] || result.serialized["protocolVersion"]
238
284
 
239
285
  if @require_sessions
@@ -246,32 +292,27 @@ module ModelContextProtocol
246
292
  })
247
293
  response_headers["Mcp-Session-Id"] = session_id
248
294
  @session_protocol_versions[session_id] = negotiated_protocol_version
295
+ log_to_server_with_context { |logger| logger.info("Session created: #{session_id} (protocol: #{negotiated_protocol_version})") }
249
296
  else
250
297
  @session_protocol_versions[:default] = negotiated_protocol_version
251
298
  end
252
299
 
253
- if accept_header.include?("text/event-stream") && !accept_header.include?("application/json")
254
- response_headers.merge!({
255
- "Content-Type" => "text/event-stream",
256
- "Cache-Control" => "no-cache",
257
- "Connection" => "keep-alive"
258
- })
300
+ response_headers["Content-Type"] = "application/json"
259
301
 
260
- {
261
- stream: true,
262
- headers: response_headers,
263
- stream_proc: create_initialization_sse_stream_proc(response.serialized)
264
- }
265
- else
266
- response_headers["Content-Type"] = "application/json"
267
- {
268
- json: response.serialized,
269
- status: 200,
270
- headers: response_headers
271
- }
302
+ log_to_server_with_context(request_id: response.id) do |logger|
303
+ logger.info("← #{body["method"]} Response")
304
+ logger.info(" #{response.serialized.to_json}")
272
305
  end
306
+
307
+ {
308
+ json: response.serialized,
309
+ status: 200,
310
+ headers: response_headers
311
+ }
273
312
  end
274
313
 
314
+ # Handle regular MCP requests (tools, resources, prompts) with streaming/JSON decision logic
315
+ # Defaults to SSE streaming but returns JSON when client explicitly requests JSON only
275
316
  def handle_regular_request(body, session_id, accept_header)
276
317
  if @require_sessions
277
318
  unless session_id && @session_store.session_exists?(session_id)
@@ -291,17 +332,26 @@ module ModelContextProtocol
291
332
  when :notification, :response
292
333
  if body["method"] == "notifications/cancelled"
293
334
  handle_cancellation(body, session_id)
335
+ elsif message_type == :response && handle_ping_response(body)
336
+ # Ping response handled, don't forward to streams
337
+ log_to_server_with_context do |logger|
338
+ logger.info("← Ping response [accepted]")
339
+ end
294
340
  elsif session_id && @session_store.session_has_active_stream?(session_id)
295
341
  deliver_to_session_stream(session_id, body)
342
+ elsif message_type == :response
343
+ # This might be a ping response for an expired session
344
+ log_to_server_with_context do |logger|
345
+ logger.debug("← Response for expired/unknown session: #{session_id}")
346
+ end
347
+ end
348
+ log_to_server_with_context do |logger|
349
+ logger.info("← Notification [accepted]")
296
350
  end
297
351
  {json: {}, status: 202}
298
352
 
299
353
  when :request
300
- has_progress_token = body.dig("params", "_meta", "progressToken")
301
- should_stream = (accept_header.include?("text/event-stream") && !accept_header.include?("application/json")) ||
302
- has_progress_token
303
-
304
- if should_stream
354
+ if accept_header.include?("text/event-stream")
305
355
  {
306
356
  stream: true,
307
357
  headers: {
@@ -309,32 +359,33 @@ module ModelContextProtocol
309
359
  "Cache-Control" => "no-cache",
310
360
  "Connection" => "keep-alive"
311
361
  },
312
- stream_proc: create_progressive_request_sse_stream_proc(body, session_id)
362
+ stream_proc: create_request_response_sse_stream_proc(body, session_id)
313
363
  }
314
- else
315
- result = @router.route(body, request_store: @request_store, session_id: session_id, transport: self)
316
-
317
- if result
318
- response = Response[id: body["id"], result: result.serialized]
364
+ elsif (result = @router.route(body, request_store: @request_store, session_id: session_id, transport: self))
365
+ response = Response[id: body["id"], result: result.serialized]
319
366
 
320
- if session_id && @session_store.session_has_active_stream?(session_id)
321
- deliver_to_session_stream(session_id, response.serialized)
322
- return {json: {accepted: true}, status: 200}
323
- end
367
+ log_to_server_with_context(request_id: response.id) do |logger|
368
+ logger.info("← #{body["method"]} Response")
369
+ logger.info(" #{response.serialized.to_json}")
370
+ end
324
371
 
325
- {
326
- json: response.serialized,
327
- status: 200,
328
- headers: {"Content-Type" => "application/json"}
329
- }
330
- else
331
- {json: {}, status: 204}
372
+ {
373
+ json: response.serialized,
374
+ status: 200,
375
+ headers: {"Content-Type" => "application/json"}
376
+ }
377
+ else
378
+ log_to_server_with_context do |logger|
379
+ logger.info("← Response (status: 204)")
332
380
  end
381
+ {json: {}, status: 204}
333
382
  end
334
383
  end
335
384
  end
336
385
 
337
- def handle_sse_request(env)
386
+ # Handle HTTP GET requests to establish persistent SSE connections for notifications
387
+ # Validates session requirements and Accept headers before opening long-lived streams
388
+ def handle_get_request(env)
338
389
  accept_header = env["HTTP_ACCEPT"] || ""
339
390
  unless accept_header.include?("text/event-stream")
340
391
  error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Accept header must include text/event-stream"}]
@@ -364,24 +415,110 @@ module ModelContextProtocol
364
415
  "Cache-Control" => "no-cache",
365
416
  "Connection" => "keep-alive"
366
417
  },
367
- stream_proc: create_sse_stream_proc(session_id, last_event_id)
418
+ stream_proc: create_persistent_notification_sse_stream_proc(session_id, last_event_id)
368
419
  }
369
420
  end
370
421
 
422
+ # Handle HTTP DELETE requests to clean up sessions and associated resources
423
+ # Removes session data, closes streams, and cleans up request store entries
371
424
  def handle_delete_request(env)
372
425
  session_id = env["HTTP_MCP_SESSION_ID"]
373
426
 
427
+ @server_logger.info("→ DELETE /mcp [Session cleanup: #{session_id || "unknown"}]")
428
+
374
429
  if session_id
375
430
  cleanup_session(session_id)
431
+ log_to_server_with_context { |logger| logger.info("Session cleanup: #{session_id}") }
376
432
  end
377
433
 
434
+ log_to_server_with_context do |logger|
435
+ logger.info("← DELETE Response")
436
+ logger.info(" #{{"success" => true}.to_json}")
437
+ end
378
438
  {json: {success: true}, status: 200}
379
439
  end
380
440
 
381
- def create_sse_stream_proc(session_id, last_event_id = nil)
441
+ # Create SSE stream processor for request-response pattern with real-time progress support
442
+ # Opens stream → Executes request → Sends response → Closes stream
443
+ # Enables progress notifications during long-running operations like tool calls
444
+ def create_request_response_sse_stream_proc(request_body, session_id)
445
+ proc do |stream|
446
+ temp_stream_id = "temp-#{SecureRandom.hex(8)}"
447
+ @stream_registry.register_stream(temp_stream_id, stream)
448
+
449
+ log_to_server_with_context(request_id: request_body["id"]) do |logger|
450
+ logger.info("← SSE stream [opened] (#{temp_stream_id})")
451
+ logger.info(" Connection will remain open for real-time notifications")
452
+ end
453
+
454
+ begin
455
+ if (result = @router.route(request_body, request_store: @request_store, session_id: session_id, transport: self, stream_id: temp_stream_id))
456
+ response = Response[id: request_body["id"], result: result.serialized]
457
+ event_id = next_event_id
458
+ send_sse_event(stream, response.serialized, event_id)
459
+ @server_logger.debug("Sent response via SSE stream (id: #{request_body["id"]})")
460
+ else
461
+ event_id = next_event_id
462
+ send_sse_event(stream, {}, event_id)
463
+ @server_logger.debug("Sent empty response via SSE stream (id: #{request_body["id"]})")
464
+ end
465
+
466
+ close_stream(temp_stream_id, reason: "request_completed")
467
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
468
+ @server_logger.debug("Client disconnected during progressive request processing: #{e.class.name}")
469
+ log_to_server_with_context { |logger| logger.info("← SSE stream [closed] (#{temp_stream_id}) [client_disconnected]") }
470
+ ensure
471
+ @stream_registry.unregister_stream(temp_stream_id)
472
+ end
473
+ end
474
+ end
475
+
476
+ # Generate unique sequential event IDs for SSE streams
477
+ # Enables client-side event replay and ordering guarantees
478
+ def next_event_id
479
+ @event_counter.next_event_id
480
+ end
481
+
482
+ # Send formatted SSE event to stream with optional event ID
483
+ # Handles JSON serialization and proper SSE formatting with data/id fields
484
+ def send_sse_event(stream, data, event_id = nil)
485
+ if event_id
486
+ stream.write("id: #{event_id}\n")
487
+ end
488
+ message = data.is_a?(String) ? data : data.to_json
489
+ stream.write("data: #{message}\n\n")
490
+ stream.flush if stream.respond_to?(:flush)
491
+ end
492
+
493
+ # Close an active SSE stream and clean up associated resources
494
+ # Unregisters from stream registry and marks session inactive
495
+ def close_stream(session_id, reason: "completed")
496
+ if (stream = @stream_registry.get_local_stream(session_id))
497
+ begin
498
+ stream.close
499
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN, Errno::EBADF
500
+ nil
501
+ end
502
+
503
+ reason_text = reason ? " [#{reason}]" : ""
504
+ log_to_server_with_context { |logger| logger.info("← SSE stream [closed] (#{session_id})#{reason_text}") }
505
+ @stream_registry.unregister_stream(session_id)
506
+ @session_store.mark_stream_inactive(session_id) if @require_sessions
507
+ end
508
+ end
509
+
510
+ # Create SSE stream processor for long-lived notification streams
511
+ # Opens stream → Keeps connection alive → Receives notifications over time
512
+ # Supports event replay from last_event_id for client reconnection scenarios
513
+ def create_persistent_notification_sse_stream_proc(session_id, last_event_id = nil)
382
514
  proc do |stream|
383
515
  @stream_registry.register_stream(session_id, stream) if session_id
384
516
 
517
+ log_to_server_with_context do |logger|
518
+ logger.info("← SSE stream [opened] (#{session_id || "no-session"})")
519
+ logger.info(" Connection will remain open for real-time notifications")
520
+ end
521
+
385
522
  if last_event_id
386
523
  replay_messages_after_event_id(stream, session_id, last_event_id)
387
524
  else
@@ -393,139 +530,258 @@ module ModelContextProtocol
393
530
  sleep 0.1
394
531
  end
395
532
  ensure
396
- @stream_registry.unregister_stream(session_id) if session_id
533
+ if session_id
534
+ log_to_server_with_context { |logger| logger.info("← SSE stream [closed] (#{session_id}) [loop_ended]") }
535
+ @stream_registry.unregister_stream(session_id)
536
+ end
397
537
  end
398
538
  end
399
539
 
540
+ # Test if an SSE stream is still connected by checking its status
541
+ # Returns false if stream has been disconnected due to network issues
542
+ # Actual connectivity testing is done via MCP ping requests in monitor_streams
400
543
  def stream_connected?(stream)
401
544
  return false unless stream
402
545
 
403
546
  begin
404
- stream.write(": ping\n\n")
405
- stream.flush if stream.respond_to?(:flush)
547
+ # Check if stream reports as closed first (quick check)
548
+ if stream.respond_to?(:closed?) && stream.closed?
549
+ return false
550
+ end
551
+
406
552
  true
407
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET
553
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN, Errno::EBADF
408
554
  false
409
555
  end
410
556
  end
411
557
 
558
+ # Start background thread to monitor stream health and clean up disconnected streams
559
+ # Runs every 30 seconds to detect client disconnections and prevent resource leaks
412
560
  def start_stream_monitor
561
+ @stream_monitor_running = true
413
562
  @stream_monitor_thread = Thread.new do
414
- loop do
415
- sleep 30 # Check every 30 seconds
563
+ while @stream_monitor_running
564
+ # Sleep in 1-second intervals to allow quick shutdown response
565
+ 30.times do
566
+ break unless @stream_monitor_running
567
+ sleep 1
568
+ end
569
+
570
+ next unless @stream_monitor_running
416
571
 
417
572
  begin
418
573
  monitor_streams
419
574
  rescue => e
420
- @configuration.logger.error("Stream monitor error", error: e.message)
575
+ @server_logger.error("Stream monitor error: #{e.message}")
421
576
  end
422
577
  end
423
- rescue => e
424
- @configuration.logger.error("Stream monitor thread error", error: e.message)
425
- sleep 5
426
- retry
427
578
  end
428
579
  end
429
580
 
581
+ # Monitor all active streams for connectivity and clean up expired/disconnected ones
582
+ # Sends ping messages and removes streams that fail to respond
430
583
  def monitor_streams
431
584
  expired_sessions = @stream_registry.cleanup_expired_streams
585
+ unless expired_sessions.empty?
586
+ @server_logger.debug("Cleaned up #{expired_sessions.size} expired streams: #{expired_sessions.join(", ")}")
587
+ end
588
+
432
589
  expired_sessions.each do |session_id|
433
590
  @session_store.mark_stream_inactive(session_id)
434
591
  end
435
592
 
593
+ # Check for expired ping requests and close unresponsive streams
594
+ expired_pings = @server_request_store.get_expired_requests(@ping_timeout)
595
+ unless expired_pings.empty?
596
+ @server_logger.debug("Found #{expired_pings.size} expired ping requests")
597
+ expired_pings.each do |ping_info|
598
+ session_id = ping_info[:session_id]
599
+ request_id = ping_info[:request_id]
600
+ age = ping_info[:age]
601
+
602
+ @server_logger.warn("Ping timeout for session #{session_id} (request: #{request_id}, age: #{age.round(2)}s)")
603
+ close_stream(session_id, reason: "ping_timeout")
604
+ @server_request_store.unregister_request(request_id)
605
+ end
606
+ end
607
+
436
608
  @stream_registry.get_all_local_streams.each do |session_id, stream|
437
609
  if stream_connected?(stream)
438
- send_ping_to_stream(stream)
610
+ send_ping_to_stream(stream, session_id)
439
611
  @stream_registry.refresh_heartbeat(session_id)
440
612
  else
441
- @stream_registry.unregister_stream(session_id)
442
- @session_store.mark_stream_inactive(session_id)
613
+ @server_logger.debug("Stream disconnected during monitoring: #{session_id}")
614
+ close_stream(session_id, reason: "client_disconnected")
443
615
  end
444
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET
445
- @stream_registry.unregister_stream(session_id)
446
- @session_store.mark_stream_inactive(session_id)
616
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN, Errno::EBADF => e
617
+ @server_logger.debug("Network error during stream monitoring for #{session_id}: #{e.class.name}")
618
+ close_stream(session_id, reason: "network_error")
447
619
  end
448
620
  end
449
621
 
450
- def send_ping_to_stream(stream)
451
- stream.write(": ping #{Time.now.iso8601}\n\n")
452
- stream.flush if stream.respond_to?(:flush)
622
+ # Send MCP-compliant ping request to test connectivity and expect response
623
+ # Tracks the ping in server request store for timeout detection
624
+ def send_ping_to_stream(stream, session_id)
625
+ ping_id = "ping-#{SecureRandom.hex(8)}"
626
+ ping_request = {
627
+ jsonrpc: "2.0",
628
+ id: ping_id,
629
+ method: "ping"
630
+ }
631
+
632
+ @server_request_store.register_request(ping_id, session_id, type: :ping)
633
+ send_to_stream(stream, ping_request)
634
+
635
+ @server_logger.debug("Sent MCP ping request (id: #{ping_id}) to stream: #{session_id}")
453
636
  end
454
637
 
638
+ # Send data to an SSE stream with proper event formatting and error handling
639
+ # Automatically closes stream on connection errors to prevent resource leaks
455
640
  def send_to_stream(stream, data)
456
641
  event_id = next_event_id
457
642
  send_sse_event(stream, data, event_id)
458
643
  end
459
644
 
645
+ # Replay missed messages from Redis after client reconnection
646
+ # Enables clients to catch up on messages they missed during disconnection
460
647
  def replay_messages_after_event_id(stream, session_id, last_event_id)
461
648
  flush_notifications_to_stream(stream)
462
649
  end
463
650
 
651
+ # Deliver data to a specific session's stream or queue for cross-server delivery
652
+ # Handles both local stream delivery and cross-server message queuing
653
+ # @return [Boolean] true if delivered to active stream, false if queued
464
654
  def deliver_to_session_stream(session_id, data)
465
655
  if @stream_registry.has_local_stream?(session_id)
466
656
  stream = @stream_registry.get_local_stream(session_id)
467
657
  begin
658
+ # MANDATORY connection validation before every delivery
659
+ @server_logger.debug("Validating stream connection for #{session_id}")
660
+ unless stream_connected?(stream)
661
+ @server_logger.warn("Stream #{session_id} failed connection validation - cleaning up")
662
+ close_stream(session_id, reason: "connection_validation_failed")
663
+ return false
664
+ end
665
+
666
+ @server_logger.debug("Stream #{session_id} passed connection validation")
468
667
  send_to_stream(stream, data)
668
+ @server_logger.debug("Successfully delivered message to active stream: #{session_id}")
469
669
  return true
470
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET
471
- @stream_registry.unregister_stream(session_id)
670
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN, Errno::EBADF => e
671
+ @server_logger.warn("Failed to deliver to stream #{session_id}, network error: #{e.class.name}")
672
+ close_stream(session_id, reason: "network_error")
673
+ return false
472
674
  end
473
675
  end
474
676
 
677
+ @server_logger.debug("No local stream found for session #{session_id}, queuing message")
475
678
  @session_store.queue_message_for_session(session_id, data)
679
+ false
476
680
  end
477
681
 
682
+ # Clean up all resources associated with a session
683
+ # Removes from stream registry, session store, request store, and server request store
478
684
  def cleanup_session(session_id)
479
685
  @stream_registry.unregister_stream(session_id)
480
686
  @session_store.cleanup_session(session_id)
481
687
  @request_store.cleanup_session_requests(session_id)
688
+ @server_request_store.cleanup_session_requests(session_id)
482
689
  end
483
690
 
484
- def start_message_poller
485
- @message_poller.start
486
- end
487
-
691
+ # Check if this transport instance has any active local streams
692
+ # Used to determine if notifications should be queued or delivered immediately
488
693
  def has_active_streams?
489
694
  @stream_registry.has_any_local_streams?
490
695
  end
491
696
 
697
+ # Broadcast notification to all active streams on this transport instance
698
+ # Handles connection errors gracefully and removes disconnected streams
492
699
  def deliver_to_active_streams(notification)
700
+ delivered_count = 0
701
+ disconnected_streams = []
702
+
493
703
  @stream_registry.get_all_local_streams.each do |session_id, stream|
704
+ # Verify stream is still connected before attempting delivery
705
+ unless stream_connected?(stream)
706
+ disconnected_streams << session_id
707
+ next
708
+ end
709
+
494
710
  send_to_stream(stream, notification)
495
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET
496
- @stream_registry.unregister_stream(session_id)
711
+ delivered_count += 1
712
+ @server_logger.debug("Delivered notification to stream: #{session_id}")
713
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN, Errno::EBADF => e
714
+ @server_logger.debug("Failed to deliver notification to stream #{session_id}, client disconnected: #{e.class.name}")
715
+ disconnected_streams << session_id
497
716
  end
717
+
718
+ # Clean up disconnected streams
719
+ disconnected_streams.each do |session_id|
720
+ close_stream(session_id, reason: "client_disconnected")
721
+ end
722
+
723
+ @server_logger.debug("Delivered notifications to #{delivered_count} streams, cleaned up #{disconnected_streams.size} disconnected streams")
498
724
  end
499
725
 
726
+ # Flush any queued notifications to a newly connected stream
727
+ # Ensures clients receive notifications that were queued while disconnected
500
728
  def flush_notifications_to_stream(stream)
501
729
  notifications = @notification_queue.pop_all
502
- notifications.each do |notification|
503
- send_to_stream(stream, notification)
730
+ @server_logger.debug("Checking notification queue: #{notifications.size} notifications queued")
731
+ if notifications.empty?
732
+ @server_logger.debug("No queued notifications to flush")
733
+ else
734
+ @server_logger.debug("Flushing #{notifications.size} queued notifications to new stream")
735
+ notifications.each do |notification|
736
+ send_to_stream(stream, notification)
737
+ @server_logger.debug("Flushed queued notification: #{notification[:method]}")
738
+ end
739
+ end
740
+ end
741
+
742
+ # Handle ping responses from clients to mark server-initiated ping requests as completed
743
+ # Returns true if this was a ping response, false otherwise
744
+ def handle_ping_response(message)
745
+ response_id = message["id"]
746
+ return false unless response_id
747
+
748
+ # Check if this response ID corresponds to a pending ping request
749
+ if @server_request_store.pending?(response_id)
750
+ request_info = @server_request_store.get_request(response_id)
751
+ if request_info && request_info["type"] == "ping"
752
+ @server_request_store.mark_completed(response_id)
753
+ @server_logger.debug("Received ping response for request: #{response_id}")
754
+ return true
755
+ end
504
756
  end
757
+
758
+ false
759
+ rescue => e
760
+ @server_logger.error("Error processing ping response: #{e.message}")
761
+ false
505
762
  end
506
763
 
507
- # Handle a cancellation notification from the client
508
- #
509
- # @param message [Hash] the cancellation notification message
510
- # @param session_id [String, nil] the session ID if available
764
+ # Handle client cancellation requests to abort in-progress operations
765
+ # Marks requests as cancelled in the request store to stop ongoing work
511
766
  def handle_cancellation(message, session_id = nil)
512
767
  params = message["params"]
513
768
  return unless params
514
769
 
515
- request_id = params["requestId"]
770
+ jsonrpc_request_id = params["requestId"]
516
771
  reason = params["reason"]
517
772
 
518
- return unless request_id
773
+ return unless jsonrpc_request_id
519
774
 
520
- @request_store.mark_cancelled(request_id, reason)
521
- rescue
522
- nil
523
- end
775
+ log_to_server_with_context(request_id: jsonrpc_request_id) do |logger|
776
+ logger.info("Processing cancellation (reason: #{reason || "unknown"})")
777
+ end
524
778
 
525
- def cleanup
526
- @message_poller&.stop
527
- @stream_monitor_thread&.kill
528
- @redis = nil
779
+ @request_store.mark_cancelled(jsonrpc_request_id, reason)
780
+ rescue => e
781
+ log_to_server_with_context(request_id: jsonrpc_request_id) do |logger|
782
+ logger.error("Error processing cancellation: #{e.message}")
783
+ end
784
+ nil
529
785
  end
530
786
  end
531
787
  end