model-context-protocol-rb 0.3.4 → 0.5.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/README.md +886 -196
  4. data/lib/model_context_protocol/server/cancellable.rb +54 -0
  5. data/lib/model_context_protocol/server/configuration.rb +80 -8
  6. data/lib/model_context_protocol/server/content.rb +321 -0
  7. data/lib/model_context_protocol/server/content_helpers.rb +84 -0
  8. data/lib/model_context_protocol/server/pagination.rb +71 -0
  9. data/lib/model_context_protocol/server/progressable.rb +72 -0
  10. data/lib/model_context_protocol/server/prompt.rb +108 -14
  11. data/lib/model_context_protocol/server/redis_client_proxy.rb +134 -0
  12. data/lib/model_context_protocol/server/redis_config.rb +108 -0
  13. data/lib/model_context_protocol/server/redis_pool_manager.rb +110 -0
  14. data/lib/model_context_protocol/server/registry.rb +94 -18
  15. data/lib/model_context_protocol/server/resource.rb +98 -25
  16. data/lib/model_context_protocol/server/resource_template.rb +26 -13
  17. data/lib/model_context_protocol/server/router.rb +36 -3
  18. data/lib/model_context_protocol/server/stdio_transport/request_store.rb +102 -0
  19. data/lib/model_context_protocol/server/stdio_transport.rb +31 -6
  20. data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +35 -0
  21. data/lib/model_context_protocol/server/streamable_http_transport/message_poller.rb +101 -0
  22. data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +80 -0
  23. data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +224 -0
  24. data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +120 -0
  25. data/lib/model_context_protocol/server/{session_store.rb → streamable_http_transport/session_store.rb} +30 -16
  26. data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +119 -0
  27. data/lib/model_context_protocol/server/streamable_http_transport.rb +352 -112
  28. data/lib/model_context_protocol/server/tool.rb +79 -53
  29. data/lib/model_context_protocol/server.rb +124 -21
  30. data/lib/model_context_protocol/version.rb +1 -1
  31. data/tasks/mcp.rake +28 -2
  32. data/tasks/templates/dev-http.erb +288 -0
  33. data/tasks/templates/dev.erb +7 -1
  34. metadata +61 -3
@@ -14,42 +14,80 @@ module ModelContextProtocol
14
14
  {jsonrpc: "2.0", id:, error:}
15
15
  end
16
16
  end
17
+
17
18
  def initialize(router:, configuration:)
18
19
  @router = router
19
20
  @configuration = configuration
20
21
 
21
22
  transport_options = @configuration.transport_options
22
- @redis = transport_options[:redis_client]
23
-
24
- @session_store = ModelContextProtocol::Server::SessionStore.new(
23
+ @redis_pool = ModelContextProtocol::Server::RedisConfig.pool
24
+ @require_sessions = transport_options.fetch(:require_sessions, false)
25
+ @default_protocol_version = transport_options.fetch(:default_protocol_version, "2025-03-26")
26
+ @session_protocol_versions = {}
27
+ @validate_origin = transport_options.fetch(:validate_origin, true)
28
+ @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(
25
32
  @redis,
26
33
  ttl: transport_options[:session_ttl] || 3600
27
34
  )
28
35
 
29
36
  @server_instance = "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(4)}"
30
- @local_streams = {}
31
- @notification_queue = []
37
+ @stream_registry = StreamRegistry.new(@redis, @server_instance)
38
+ @notification_queue = NotificationQueue.new(@redis, @server_instance)
39
+ @event_counter = EventCounter.new(@redis, @server_instance)
40
+ @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|
43
+ send_to_stream(stream, message)
44
+ end
32
45
 
33
- setup_redis_subscriber
46
+ start_message_poller
47
+ start_stream_monitor
48
+ end
49
+
50
+ def shutdown
51
+ @configuration.logger.info("Shutting down StreamableHttpTransport")
52
+
53
+ # Stop the message poller
54
+ @message_poller&.stop
55
+
56
+ # Stop the stream monitor thread
57
+ if @stream_monitor_thread&.alive?
58
+ @stream_monitor_thread.kill
59
+ @stream_monitor_thread.join(timeout: 5)
60
+ end
61
+
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)
66
+ rescue => e
67
+ @configuration.logger.error("Error during stream cleanup", session_id: session_id, error: e.message)
68
+ end
69
+
70
+ @redis_pool.checkin(@redis) if @redis_pool && @redis
71
+
72
+ @configuration.logger.info("StreamableHttpTransport shutdown complete")
34
73
  end
35
74
 
36
75
  def handle
37
76
  @configuration.logger.connect_transport(self)
38
77
 
39
- request = @configuration.transport_options[:request]
40
- response = @configuration.transport_options[:response]
78
+ env = @configuration.transport_options[:env]
41
79
 
42
- unless request && response
43
- raise ArgumentError, "StreamableHTTP transport requires request and response objects in transport_options"
80
+ unless env
81
+ raise ArgumentError, "StreamableHTTP transport requires Rack env hash in transport_options"
44
82
  end
45
83
 
46
- case request.method
84
+ case env["REQUEST_METHOD"]
47
85
  when "POST"
48
- handle_post_request(request)
86
+ handle_post_request(env)
49
87
  when "GET"
50
- handle_sse_request(request, response)
88
+ handle_sse_request(env)
51
89
  when "DELETE"
52
- handle_delete_request(request)
90
+ handle_delete_request(env)
53
91
  else
54
92
  error_response = ErrorResponse[id: nil, error: {code: -32601, message: "Method not allowed"}]
55
93
  {json: error_response.serialized, status: 405}
@@ -63,25 +101,120 @@ module ModelContextProtocol
63
101
  params: params
64
102
  }
65
103
 
66
- if has_active_streams?
104
+ if @stream_registry.has_any_local_streams?
67
105
  deliver_to_active_streams(notification)
68
106
  else
69
- @notification_queue << notification
107
+ @notification_queue.push(notification)
70
108
  end
71
109
  end
72
110
 
73
111
  private
74
112
 
75
- def handle_post_request(request)
76
- body_string = request.body.read
113
+ def validate_headers(env)
114
+ if @validate_origin
115
+ origin = env["HTTP_ORIGIN"]
116
+ if origin && !@allowed_origins.any? { |allowed| origin.start_with?(allowed) }
117
+ error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Origin not allowed"}]
118
+ return {json: error_response.serialized, status: 403}
119
+ end
120
+ end
121
+
122
+ accept_header = env["HTTP_ACCEPT"]
123
+ if accept_header
124
+ unless accept_header.include?("application/json") || accept_header.include?("text/event-stream")
125
+ error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Invalid Accept header. Must include application/json or text/event-stream"}]
126
+ return {json: error_response.serialized, status: 400}
127
+ end
128
+ end
129
+
130
+ protocol_version = env["HTTP_MCP_PROTOCOL_VERSION"]
131
+ if protocol_version
132
+ valid_versions = @session_protocol_versions.values.compact.uniq
133
+ unless valid_versions.empty? || valid_versions.include?(protocol_version)
134
+ error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Invalid MCP protocol version: #{protocol_version}. Expected one of: #{valid_versions.join(", ")}"}]
135
+ return {json: error_response.serialized, status: 400}
136
+ end
137
+ end
138
+
139
+ nil
140
+ end
141
+
142
+ def determine_message_type(body)
143
+ if body.key?("method") && body.key?("id")
144
+ :request
145
+ elsif body.key?("method") && !body.key?("id")
146
+ :notification
147
+ elsif body.key?("id") && body.key?("result") || body.key?("error")
148
+ :response
149
+ else
150
+ :unknown
151
+ end
152
+ end
153
+
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
+
204
+ def handle_post_request(env)
205
+ validation_error = validate_headers(env)
206
+ return validation_error if validation_error
207
+
208
+ body_string = env["rack.input"].read
77
209
  body = JSON.parse(body_string)
78
- session_id = request.headers["Mcp-Session-Id"]
210
+ session_id = env["HTTP_MCP_SESSION_ID"]
211
+ accept_header = env["HTTP_ACCEPT"] || ""
79
212
 
80
213
  case body["method"]
81
214
  when "initialize"
82
- handle_initialization(body)
215
+ handle_initialization(body, accept_header)
83
216
  else
84
- handle_regular_request(body, session_id)
217
+ handle_regular_request(body, session_id, accept_header)
85
218
  end
86
219
  rescue JSON::ParserError
87
220
  error_response = ErrorResponse[id: "", error: {code: -32700, message: "Parse error"}]
@@ -96,51 +229,133 @@ module ModelContextProtocol
96
229
  {json: error_response.serialized, status: 500}
97
230
  end
98
231
 
99
- def handle_initialization(body)
100
- session_id = SecureRandom.uuid
101
-
102
- @session_store.create_session(session_id, {
103
- server_instance: @server_instance,
104
- context: @configuration.context || {},
105
- created_at: Time.now.to_f
106
- })
107
-
108
- result = @router.route(body)
232
+ def handle_initialization(body, accept_header)
233
+ result = @router.route(body, transport: self)
109
234
  response = Response[id: body["id"], result: result.serialized]
235
+ response_headers = {}
236
+
237
+ negotiated_protocol_version = result.serialized[:protocolVersion] || result.serialized["protocolVersion"]
238
+
239
+ if @require_sessions
240
+ session_id = SecureRandom.uuid
241
+ @session_store.create_session(session_id, {
242
+ server_instance: @server_instance,
243
+ context: @configuration.context || {},
244
+ created_at: Time.now.to_f,
245
+ negotiated_protocol_version: negotiated_protocol_version
246
+ })
247
+ response_headers["Mcp-Session-Id"] = session_id
248
+ @session_protocol_versions[session_id] = negotiated_protocol_version
249
+ else
250
+ @session_protocol_versions[:default] = negotiated_protocol_version
251
+ end
110
252
 
111
- {
112
- json: response.serialized,
113
- status: 200,
114
- headers: {"Mcp-Session-Id" => session_id}
115
- }
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
+ })
259
+
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
+ }
272
+ end
116
273
  end
117
274
 
118
- def handle_regular_request(body, session_id)
119
- unless session_id && @session_store.session_exists?(session_id)
120
- error_response = ErrorResponse[id: body["id"], error: {code: -32600, message: "Invalid or missing session ID"}]
121
- return {json: error_response.serialized, status: 400}
275
+ def handle_regular_request(body, session_id, accept_header)
276
+ if @require_sessions
277
+ unless session_id && @session_store.session_exists?(session_id)
278
+ if session_id && !@session_store.session_exists?(session_id)
279
+ error_response = ErrorResponse[id: body["id"], error: {code: -32600, message: "Session terminated"}]
280
+ return {json: error_response.serialized, status: 404}
281
+ else
282
+ error_response = ErrorResponse[id: body["id"], error: {code: -32600, message: "Invalid or missing session ID"}]
283
+ return {json: error_response.serialized, status: 400}
284
+ end
285
+ end
122
286
  end
123
287
 
124
- result = @router.route(body)
125
- response = Response[id: body["id"], result: result.serialized]
288
+ message_type = determine_message_type(body)
126
289
 
127
- if @session_store.session_has_active_stream?(session_id)
128
- deliver_to_session_stream(session_id, response.serialized)
129
- {json: {accepted: true}, status: 200}
130
- else
131
- {json: response.serialized, status: 200}
290
+ case message_type
291
+ when :notification, :response
292
+ if body["method"] == "notifications/cancelled"
293
+ handle_cancellation(body, session_id)
294
+ elsif session_id && @session_store.session_has_active_stream?(session_id)
295
+ deliver_to_session_stream(session_id, body)
296
+ end
297
+ {json: {}, status: 202}
298
+
299
+ 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
305
+ {
306
+ stream: true,
307
+ headers: {
308
+ "Content-Type" => "text/event-stream",
309
+ "Cache-Control" => "no-cache",
310
+ "Connection" => "keep-alive"
311
+ },
312
+ stream_proc: create_progressive_request_sse_stream_proc(body, session_id)
313
+ }
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]
319
+
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
324
+
325
+ {
326
+ json: response.serialized,
327
+ status: 200,
328
+ headers: {"Content-Type" => "application/json"}
329
+ }
330
+ else
331
+ {json: {}, status: 204}
332
+ end
333
+ end
132
334
  end
133
335
  end
134
336
 
135
- def handle_sse_request(request, response)
136
- session_id = request.headers["Mcp-Session-Id"]
137
-
138
- unless session_id && @session_store.session_exists?(session_id)
139
- error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Invalid or missing session ID"}]
337
+ def handle_sse_request(env)
338
+ accept_header = env["HTTP_ACCEPT"] || ""
339
+ unless accept_header.include?("text/event-stream")
340
+ error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Accept header must include text/event-stream"}]
140
341
  return {json: error_response.serialized, status: 400}
141
342
  end
142
343
 
143
- @session_store.mark_stream_active(session_id, @server_instance)
344
+ session_id = env["HTTP_MCP_SESSION_ID"]
345
+ last_event_id = env["HTTP_LAST_EVENT_ID"]
346
+
347
+ if @require_sessions
348
+ unless session_id && @session_store.session_exists?(session_id)
349
+ if session_id && !@session_store.session_exists?(session_id)
350
+ error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Session terminated"}]
351
+ return {json: error_response.serialized, status: 404}
352
+ else
353
+ error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Invalid or missing session ID"}]
354
+ return {json: error_response.serialized, status: 400}
355
+ end
356
+ end
357
+ @session_store.mark_stream_active(session_id, @server_instance)
358
+ end
144
359
 
145
360
  {
146
361
  stream: true,
@@ -149,12 +364,12 @@ module ModelContextProtocol
149
364
  "Cache-Control" => "no-cache",
150
365
  "Connection" => "keep-alive"
151
366
  },
152
- stream_proc: create_sse_stream_proc(session_id)
367
+ stream_proc: create_sse_stream_proc(session_id, last_event_id)
153
368
  }
154
369
  end
155
370
 
156
- def handle_delete_request(request)
157
- session_id = request.headers["Mcp-Session-Id"]
371
+ def handle_delete_request(env)
372
+ session_id = env["HTTP_MCP_SESSION_ID"]
158
373
 
159
374
  if session_id
160
375
  cleanup_session(session_id)
@@ -163,32 +378,25 @@ module ModelContextProtocol
163
378
  {json: {success: true}, status: 200}
164
379
  end
165
380
 
166
- def create_sse_stream_proc(session_id)
381
+ def create_sse_stream_proc(session_id, last_event_id = nil)
167
382
  proc do |stream|
168
- register_local_stream(session_id, stream)
169
-
170
- flush_notifications_to_stream(stream)
383
+ @stream_registry.register_stream(session_id, stream) if session_id
171
384
 
172
- start_keepalive_thread(session_id, stream)
385
+ if last_event_id
386
+ replay_messages_after_event_id(stream, session_id, last_event_id)
387
+ else
388
+ flush_notifications_to_stream(stream)
389
+ end
173
390
 
174
391
  loop do
175
392
  break unless stream_connected?(stream)
176
393
  sleep 0.1
177
394
  end
178
395
  ensure
179
- cleanup_local_stream(session_id)
396
+ @stream_registry.unregister_stream(session_id) if session_id
180
397
  end
181
398
  end
182
399
 
183
- def register_local_stream(session_id, stream)
184
- @local_streams[session_id] = stream
185
- end
186
-
187
- def cleanup_local_stream(session_id)
188
- @local_streams.delete(session_id)
189
- @session_store.mark_stream_inactive(session_id)
190
- end
191
-
192
400
  def stream_connected?(stream)
193
401
  return false unless stream
194
402
 
@@ -201,22 +409,41 @@ module ModelContextProtocol
201
409
  end
202
410
  end
203
411
 
204
- def start_keepalive_thread(session_id, stream)
205
- Thread.new do
412
+ def start_stream_monitor
413
+ @stream_monitor_thread = Thread.new do
206
414
  loop do
207
- sleep 30
208
- break unless stream_connected?(stream)
415
+ sleep 30 # Check every 30 seconds
209
416
 
210
417
  begin
211
- send_ping_to_stream(stream)
212
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET
213
- break
418
+ monitor_streams
419
+ rescue => e
420
+ @configuration.logger.error("Stream monitor error", error: e.message)
214
421
  end
215
422
  end
216
423
  rescue => e
217
- @configuration.logger.error("Keepalive thread error", error: e.message)
218
- ensure
219
- cleanup_local_stream(session_id)
424
+ @configuration.logger.error("Stream monitor thread error", error: e.message)
425
+ sleep 5
426
+ retry
427
+ end
428
+ end
429
+
430
+ def monitor_streams
431
+ expired_sessions = @stream_registry.cleanup_expired_streams
432
+ expired_sessions.each do |session_id|
433
+ @session_store.mark_stream_inactive(session_id)
434
+ end
435
+
436
+ @stream_registry.get_all_local_streams.each do |session_id, stream|
437
+ if stream_connected?(stream)
438
+ send_ping_to_stream(stream)
439
+ @stream_registry.refresh_heartbeat(session_id)
440
+ else
441
+ @stream_registry.unregister_stream(session_id)
442
+ @session_store.mark_stream_inactive(session_id)
443
+ end
444
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
445
+ @stream_registry.unregister_stream(session_id)
446
+ @session_store.mark_stream_inactive(session_id)
220
447
  end
221
448
  end
222
449
 
@@ -226,66 +453,79 @@ module ModelContextProtocol
226
453
  end
227
454
 
228
455
  def send_to_stream(stream, data)
229
- message = data.is_a?(String) ? data : data.to_json
230
- stream.write("data: #{message}\n\n")
231
- stream.flush if stream.respond_to?(:flush)
456
+ event_id = next_event_id
457
+ send_sse_event(stream, data, event_id)
458
+ end
459
+
460
+ def replay_messages_after_event_id(stream, session_id, last_event_id)
461
+ flush_notifications_to_stream(stream)
232
462
  end
233
463
 
234
464
  def deliver_to_session_stream(session_id, data)
235
- if @local_streams[session_id]
465
+ if @stream_registry.has_local_stream?(session_id)
466
+ stream = @stream_registry.get_local_stream(session_id)
236
467
  begin
237
- send_to_stream(@local_streams[session_id], data)
468
+ send_to_stream(stream, data)
238
469
  return true
239
470
  rescue IOError, Errno::EPIPE, Errno::ECONNRESET
240
- cleanup_local_stream(session_id)
471
+ @stream_registry.unregister_stream(session_id)
241
472
  end
242
473
  end
243
474
 
244
- @session_store.route_message_to_session(session_id, data)
475
+ @session_store.queue_message_for_session(session_id, data)
245
476
  end
246
477
 
247
478
  def cleanup_session(session_id)
248
- cleanup_local_stream(session_id)
479
+ @stream_registry.unregister_stream(session_id)
249
480
  @session_store.cleanup_session(session_id)
481
+ @request_store.cleanup_session_requests(session_id)
250
482
  end
251
483
 
252
- def setup_redis_subscriber
253
- Thread.new do
254
- @session_store.subscribe_to_server(@server_instance) do |data|
255
- session_id = data["session_id"]
256
- message = data["message"]
257
-
258
- if @local_streams[session_id]
259
- begin
260
- send_to_stream(@local_streams[session_id], message)
261
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET
262
- cleanup_local_stream(session_id)
263
- end
264
- end
265
- end
266
- rescue => e
267
- @configuration.logger.error("Redis subscriber error", error: e.message, backtrace: e.backtrace.first(5))
268
- sleep 5
269
- retry
270
- end
484
+ def start_message_poller
485
+ @message_poller.start
271
486
  end
272
487
 
273
488
  def has_active_streams?
274
- @local_streams.any?
489
+ @stream_registry.has_any_local_streams?
275
490
  end
276
491
 
277
492
  def deliver_to_active_streams(notification)
278
- @local_streams.each do |session_id, stream|
493
+ @stream_registry.get_all_local_streams.each do |session_id, stream|
279
494
  send_to_stream(stream, notification)
280
495
  rescue IOError, Errno::EPIPE, Errno::ECONNRESET
281
- cleanup_local_stream(session_id)
496
+ @stream_registry.unregister_stream(session_id)
282
497
  end
283
498
  end
284
499
 
285
500
  def flush_notifications_to_stream(stream)
286
- while (notification = @notification_queue.shift)
501
+ notifications = @notification_queue.pop_all
502
+ notifications.each do |notification|
287
503
  send_to_stream(stream, notification)
288
504
  end
289
505
  end
506
+
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
511
+ def handle_cancellation(message, session_id = nil)
512
+ params = message["params"]
513
+ return unless params
514
+
515
+ request_id = params["requestId"]
516
+ reason = params["reason"]
517
+
518
+ return unless request_id
519
+
520
+ @request_store.mark_cancelled(request_id, reason)
521
+ rescue
522
+ nil
523
+ end
524
+
525
+ def cleanup
526
+ @message_poller&.stop
527
+ @stream_monitor_thread&.kill
528
+ @redis = nil
529
+ end
290
530
  end
291
531
  end