ruby_llm_swarm-mcp 0.8.0 → 0.8.1

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -44
  3. data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +4 -21
  4. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +0 -20
  5. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +3 -0
  6. data/lib/ruby_llm/mcp/auth/browser/opener.rb +2 -0
  7. data/lib/ruby_llm/mcp/auth/browser/pages.rb +32 -100
  8. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +6 -32
  9. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +2 -0
  10. data/lib/ruby_llm/mcp/auth/memory_storage.rb +0 -18
  11. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +3 -82
  12. data/lib/ruby_llm/mcp/auth/session_manager.rb +2 -0
  13. data/lib/ruby_llm/mcp/auth/url_builder.rb +2 -0
  14. data/lib/ruby_llm/mcp/client.rb +32 -119
  15. data/lib/ruby_llm/mcp/configuration.rb +6 -74
  16. data/lib/ruby_llm/mcp/coordinator.rb +304 -0
  17. data/lib/ruby_llm/mcp/elicitation.rb +6 -8
  18. data/lib/ruby_llm/mcp/errors.rb +0 -15
  19. data/lib/ruby_llm/mcp/notification_handler.rb +5 -21
  20. data/lib/ruby_llm/mcp/notifications/cancelled.rb +32 -0
  21. data/lib/ruby_llm/mcp/notifications/initialize.rb +24 -0
  22. data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +26 -0
  23. data/lib/ruby_llm/mcp/prompt.rb +7 -7
  24. data/lib/ruby_llm/mcp/protocol.rb +34 -0
  25. data/lib/ruby_llm/mcp/railtie.rb +6 -8
  26. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +50 -0
  27. data/lib/ruby_llm/mcp/requests/completion_resource.rb +50 -0
  28. data/lib/ruby_llm/mcp/requests/initialization.rb +34 -0
  29. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +28 -0
  30. data/lib/ruby_llm/mcp/requests/ping.rb +24 -0
  31. data/lib/ruby_llm/mcp/requests/prompt_call.rb +32 -0
  32. data/lib/ruby_llm/mcp/requests/prompt_list.rb +31 -0
  33. data/lib/ruby_llm/mcp/requests/resource_list.rb +31 -0
  34. data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
  35. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +31 -0
  36. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +30 -0
  37. data/lib/ruby_llm/mcp/requests/shared/meta.rb +32 -0
  38. data/lib/ruby_llm/mcp/requests/shared/pagination.rb +17 -0
  39. data/lib/ruby_llm/mcp/requests/tool_call.rb +35 -0
  40. data/lib/ruby_llm/mcp/requests/tool_list.rb +31 -0
  41. data/lib/ruby_llm/mcp/resource.rb +8 -6
  42. data/lib/ruby_llm/mcp/resource_template.rb +7 -7
  43. data/lib/ruby_llm/mcp/response_handler.rb +67 -0
  44. data/lib/ruby_llm/mcp/responses/elicitation.rb +33 -0
  45. data/lib/ruby_llm/mcp/responses/error.rb +33 -0
  46. data/lib/ruby_llm/mcp/responses/ping.rb +28 -0
  47. data/lib/ruby_llm/mcp/responses/roots_list.rb +31 -0
  48. data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +50 -0
  49. data/lib/ruby_llm/mcp/result.rb +4 -8
  50. data/lib/ruby_llm/mcp/roots.rb +4 -4
  51. data/lib/ruby_llm/mcp/sample.rb +2 -6
  52. data/lib/ruby_llm/mcp/tool.rb +9 -9
  53. data/lib/ruby_llm/mcp/transport.rb +151 -0
  54. data/lib/ruby_llm/mcp/transports/sse.rb +435 -0
  55. data/lib/ruby_llm/mcp/transports/stdio.rb +231 -0
  56. data/lib/ruby_llm/mcp/transports/streamable_http.rb +725 -0
  57. data/lib/ruby_llm/mcp/transports/support/http_client.rb +28 -0
  58. data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +47 -0
  59. data/lib/ruby_llm/mcp/transports/support/timeout.rb +34 -0
  60. data/lib/ruby_llm/mcp/version.rb +1 -1
  61. data/lib/ruby_llm/mcp.rb +7 -30
  62. metadata +38 -33
  63. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +0 -179
  64. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +0 -292
  65. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +0 -33
  66. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +0 -52
  67. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +0 -52
  68. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +0 -86
  69. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +0 -92
  70. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +0 -107
  71. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +0 -57
  72. data/lib/ruby_llm/mcp/native/client.rb +0 -387
  73. data/lib/ruby_llm/mcp/native/json_rpc.rb +0 -170
  74. data/lib/ruby_llm/mcp/native/messages/helpers.rb +0 -39
  75. data/lib/ruby_llm/mcp/native/messages/notifications.rb +0 -42
  76. data/lib/ruby_llm/mcp/native/messages/requests.rb +0 -206
  77. data/lib/ruby_llm/mcp/native/messages/responses.rb +0 -106
  78. data/lib/ruby_llm/mcp/native/messages.rb +0 -36
  79. data/lib/ruby_llm/mcp/native/notification.rb +0 -16
  80. data/lib/ruby_llm/mcp/native/protocol.rb +0 -36
  81. data/lib/ruby_llm/mcp/native/response_handler.rb +0 -110
  82. data/lib/ruby_llm/mcp/native/transport.rb +0 -88
  83. data/lib/ruby_llm/mcp/native/transports/sse.rb +0 -607
  84. data/lib/ruby_llm/mcp/native/transports/stdio.rb +0 -356
  85. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +0 -926
  86. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +0 -28
  87. data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +0 -49
  88. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +0 -36
  89. data/lib/ruby_llm/mcp/native.rb +0 -12
@@ -0,0 +1,725 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "httpx"
6
+ require "timeout"
7
+ require "securerandom"
8
+
9
+ module RubyLLM
10
+ module MCP
11
+ module Transports
12
+ # Configuration options for reconnection behavior
13
+ class ReconnectionOptions
14
+ attr_reader :max_reconnection_delay, :initial_reconnection_delay,
15
+ :reconnection_delay_grow_factor, :max_retries
16
+
17
+ def initialize(
18
+ max_reconnection_delay: 30_000,
19
+ initial_reconnection_delay: 1_000,
20
+ reconnection_delay_grow_factor: 1.5,
21
+ max_retries: 2
22
+ )
23
+ @max_reconnection_delay = max_reconnection_delay
24
+ @initial_reconnection_delay = initial_reconnection_delay
25
+ @reconnection_delay_grow_factor = reconnection_delay_grow_factor
26
+ @max_retries = max_retries
27
+ end
28
+ end
29
+
30
+ # Options for starting SSE connections
31
+ class StartSSEOptions
32
+ attr_reader :resumption_token, :on_resumption_token, :replay_message_id
33
+
34
+ def initialize(resumption_token: nil, on_resumption_token: nil, replay_message_id: nil)
35
+ @resumption_token = resumption_token
36
+ @on_resumption_token = on_resumption_token
37
+ @replay_message_id = replay_message_id
38
+ end
39
+ end
40
+
41
+ # Main StreamableHTTP transport class
42
+ class StreamableHTTP
43
+ include Support::Timeout
44
+
45
+ attr_reader :session_id, :protocol_version, :coordinator, :oauth_provider
46
+
47
+ def initialize(url:, request_timeout:, coordinator:, options: {})
48
+ @url = URI(url)
49
+ @coordinator = coordinator
50
+ @request_timeout = request_timeout
51
+
52
+ extract_options(options)
53
+ initialize_state_variables
54
+ initialize_mutexes
55
+
56
+ @connection = create_connection
57
+
58
+ RubyLLM::MCP.logger.debug "OAuth provider: #{@oauth_provider ? 'present' : 'none'}" if @oauth_provider
59
+ end
60
+
61
+ def extract_options(options)
62
+ @headers = options[:headers] || options["headers"] || {}
63
+ @session_id = options[:session_id] || options["session_id"]
64
+ @oauth_provider = options[:oauth_provider] || options["oauth_provider"]
65
+ @version = options[:version] || options["version"] || :http2
66
+ @protocol_version = nil
67
+
68
+ reconnection = options[:reconnection] || options["reconnection"] || {}
69
+ @reconnection_options = options[:reconnection_options] || ReconnectionOptions.new(**reconnection)
70
+
71
+ rate_limit = options[:rate_limit] || options["rate_limit"]
72
+ @rate_limiter = Support::RateLimiter.new(**rate_limit) if rate_limit
73
+ end
74
+
75
+ def initialize_state_variables
76
+ @resource_metadata_url = nil
77
+ @client_id = SecureRandom.uuid
78
+ @id_counter = 0
79
+ @pending_requests = {}
80
+ @running = true
81
+ @abort_controller = nil
82
+ @sse_thread = nil
83
+ @clients = []
84
+ end
85
+
86
+ def initialize_mutexes
87
+ @id_mutex = Mutex.new
88
+ @pending_mutex = Mutex.new
89
+ @sse_mutex = Mutex.new
90
+ @clients_mutex = Mutex.new
91
+ end
92
+
93
+ def request(body, add_id: true, wait_for_response: true)
94
+ if @rate_limiter&.exceeded?
95
+ sleep(1) while @rate_limiter&.exceeded?
96
+ end
97
+ @rate_limiter&.add
98
+
99
+ # Generate a unique request ID for requests
100
+ if add_id && body.is_a?(Hash) && !body.key?("id")
101
+ @id_mutex.synchronize { @id_counter += 1 }
102
+ body["id"] = @id_counter
103
+ end
104
+
105
+ request_id = body.is_a?(Hash) ? body["id"] : nil
106
+ is_initialization = body.is_a?(Hash) && body["method"] == "initialize"
107
+
108
+ response_queue = setup_response_queue(request_id, wait_for_response)
109
+ result = send_http_request(body, request_id, is_initialization: is_initialization)
110
+ return result if result.is_a?(RubyLLM::MCP::Result)
111
+
112
+ if wait_for_response && request_id
113
+ wait_for_response_with_timeout(request_id.to_s, response_queue)
114
+ end
115
+ end
116
+
117
+ def alive?
118
+ @running
119
+ end
120
+
121
+ def close
122
+ terminate_session
123
+ cleanup_sse_resources
124
+ cleanup_connection
125
+ end
126
+
127
+ def start
128
+ @abort_controller = false
129
+ end
130
+
131
+ def set_protocol_version(version)
132
+ @protocol_version = version
133
+ end
134
+
135
+ private
136
+
137
+ def terminate_session
138
+ return unless @session_id
139
+
140
+ begin
141
+ headers = build_common_headers
142
+ response = @connection.delete(@url, headers: headers)
143
+
144
+ # Handle HTTPX error responses first
145
+ handle_httpx_error_response!(response, context: { location: "terminating session" })
146
+
147
+ # 405 Method Not Allowed is acceptable per spec
148
+ unless [200, 405].include?(response.status)
149
+ reason_phrase = response.respond_to?(:reason_phrase) ? response.reason_phrase : nil
150
+ raise Errors::TransportError.new(
151
+ code: response.status,
152
+ message: "Failed to terminate session: #{reason_phrase || response.status}"
153
+ )
154
+ end
155
+
156
+ @session_id = nil
157
+ rescue StandardError => e
158
+ raise Errors::TransportError.new(
159
+ message: "Failed to terminate session: #{e.message}",
160
+ code: nil,
161
+ error: e
162
+ )
163
+ end
164
+ end
165
+
166
+ def handle_httpx_error_response!(response, context:, allow_eof_for_sse: false)
167
+ return false unless response.is_a?(HTTPX::ErrorResponse)
168
+
169
+ error = response.error
170
+
171
+ # Special handling for EOFError in SSE contexts
172
+ if allow_eof_for_sse && error.is_a?(EOFError)
173
+ RubyLLM::MCP.logger.info "SSE stream closed: #{response.error.message}"
174
+ return :eof_handled
175
+ end
176
+
177
+ if error.is_a?(HTTPX::ReadTimeoutError)
178
+ raise Errors::TimeoutError.new(
179
+ message: "Request timed out after #{@request_timeout / 1000} seconds",
180
+ request_id: context[:request_id]
181
+ )
182
+ end
183
+
184
+ error_message = response.error&.message || "Request failed"
185
+ RubyLLM::MCP.logger.error "HTTPX error in #{context[:location]}: #{error_message}"
186
+
187
+ raise Errors::TransportError.new(
188
+ code: nil,
189
+ message: "HTTPX Error #{context}: #{error_message}"
190
+ )
191
+ end
192
+
193
+ def register_client(client)
194
+ @clients_mutex.synchronize do
195
+ @clients << client
196
+ end
197
+ client
198
+ end
199
+
200
+ def unregister_client(client)
201
+ @clients_mutex.synchronize do
202
+ @clients.delete(client)
203
+ end
204
+ end
205
+
206
+ def close_client(client)
207
+ client.close if client.respond_to?(:close)
208
+ rescue StandardError => e
209
+ RubyLLM::MCP.logger.debug "Error closing HTTPX client: #{e.message}"
210
+ ensure
211
+ unregister_client(client)
212
+ end
213
+
214
+ def active_clients_count
215
+ @clients_mutex.synchronize do
216
+ @clients.size
217
+ end
218
+ end
219
+
220
+ def create_connection
221
+ client = Support::HTTPClient.connection.with(
222
+ timeout: {
223
+ connect_timeout: 10,
224
+ read_timeout: @request_timeout / 1000,
225
+ write_timeout: @request_timeout / 1000,
226
+ operation_timeout: @request_timeout / 1000
227
+ }
228
+ )
229
+
230
+ register_client(client)
231
+ end
232
+
233
+ def build_common_headers
234
+ headers = @headers.dup
235
+
236
+ headers["mcp-session-id"] = @session_id if @session_id
237
+ headers["mcp-protocol-version"] = @protocol_version if @protocol_version
238
+ headers["X-CLIENT-ID"] = @client_id
239
+ headers["Origin"] = @url.to_s
240
+
241
+ # Apply OAuth authorization if available
242
+ if @oauth_provider
243
+ RubyLLM::MCP.logger.debug "OAuth provider present, attempting to get token..."
244
+ RubyLLM::MCP.logger.debug " Server URL: #{@oauth_provider.server_url}"
245
+
246
+ token = @oauth_provider.access_token
247
+ if token
248
+ headers["Authorization"] = token.to_header
249
+ RubyLLM::MCP.logger.debug "✓ Applied OAuth authorization header: #{token.to_header[0..30]}..."
250
+ else
251
+ RubyLLM::MCP.logger.warn "✗ OAuth provider present but no valid token available!"
252
+ RubyLLM::MCP.logger.warn " This means the token is not in storage or has expired"
253
+ RubyLLM::MCP.logger.warn " Check that authentication completed successfully"
254
+ end
255
+ else
256
+ RubyLLM::MCP.logger.debug "No OAuth provider configured for this transport"
257
+ end
258
+
259
+ headers
260
+ end
261
+
262
+ def setup_response_queue(request_id, wait_for_response)
263
+ response_queue = Queue.new
264
+ if wait_for_response && request_id
265
+ @pending_mutex.synchronize do
266
+ @pending_requests[request_id.to_s] = response_queue
267
+ end
268
+ end
269
+ response_queue
270
+ end
271
+
272
+ def send_http_request(body, request_id, is_initialization: false)
273
+ headers = build_common_headers
274
+ headers["Content-Type"] = "application/json"
275
+ headers["Accept"] = "application/json, text/event-stream"
276
+
277
+ json_body = JSON.generate(body)
278
+ RubyLLM::MCP.logger.debug "Sending Request: #{json_body}"
279
+
280
+ begin
281
+ # Set up connection with streaming callbacks if not initialization
282
+ connection = if is_initialization
283
+ @connection
284
+ else
285
+ create_connection_with_streaming_callbacks(request_id)
286
+ end
287
+
288
+ response = connection.post(@url, json: body, headers: headers)
289
+ handle_response(response, request_id, body)
290
+ ensure
291
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
292
+ end
293
+ end
294
+
295
+ def create_connection_with_streaming_callbacks(request_id)
296
+ buffer = +""
297
+
298
+ client = Support::HTTPClient.connection.plugin(:callbacks)
299
+ .on_response_body_chunk do |request, _response, chunk|
300
+ next unless @running && !@abort_controller
301
+
302
+ RubyLLM::MCP.logger.debug "Received chunk: #{chunk.bytesize} bytes for #{request.uri}"
303
+ buffer << chunk
304
+ process_sse_buffer_events(buffer, request_id&.to_s)
305
+ end
306
+ .with(
307
+ timeout: {
308
+ connect_timeout: 10,
309
+ read_timeout: @request_timeout / 1000,
310
+ write_timeout: @request_timeout / 1000,
311
+ operation_timeout: @request_timeout / 1000
312
+ }
313
+ )
314
+
315
+ register_client(client)
316
+ end
317
+
318
+ def handle_response(response, request_id, original_message)
319
+ # Handle HTTPX error responses first
320
+ handle_httpx_error_response!(response, context: { location: "handling response", request_id: request_id })
321
+
322
+ # Extract session ID if present (only for successful responses)
323
+ session_id = response.headers["mcp-session-id"]
324
+ @session_id = session_id if session_id
325
+
326
+ case response.status
327
+ when 200
328
+ handle_success_response(response, request_id, original_message)
329
+ when 202
330
+ handle_accepted_response(original_message)
331
+ when 404
332
+ handle_session_expired
333
+ when 401
334
+ raise Errors::AuthenticationRequiredError.new(
335
+ message: "OAuth authentication required. Server returned 401 Unauthorized.",
336
+ code: 401
337
+ )
338
+ when 405
339
+ # Method not allowed - acceptable for some endpoints
340
+ nil
341
+ when 400...500
342
+ handle_client_error(response)
343
+ else
344
+ response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
345
+ raise Errors::TransportError.new(
346
+ code: response.status,
347
+ message: "HTTP request failed: #{response.status} - #{response_body}"
348
+ )
349
+ end
350
+ end
351
+
352
+ def handle_success_response(response, request_id, _original_message)
353
+ content_type = response.respond_to?(:headers) ? response.headers["content-type"] : nil
354
+
355
+ if content_type&.include?("text/event-stream")
356
+ start_sse_stream
357
+ nil
358
+ elsif content_type&.include?("application/json")
359
+ response_body = response.respond_to?(:body) ? response.body.to_s : "{}"
360
+ if response_body == "null" # Fix related to official MCP Ruby SDK implementation
361
+ response_body = "{}"
362
+ end
363
+
364
+ json_response = JSON.parse(response_body)
365
+ result = RubyLLM::MCP::Result.new(json_response, session_id: @session_id)
366
+
367
+ if request_id
368
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
369
+ end
370
+
371
+ result
372
+ else
373
+ raise Errors::TransportError.new(
374
+ code: -1,
375
+ message: "Unexpected content type: #{content_type}"
376
+ )
377
+ end
378
+ rescue StandardError => e
379
+ raise Errors::TransportError.new(
380
+ message: "Invalid JSON response: #{e.message}",
381
+ error: e
382
+ )
383
+ end
384
+
385
+ def handle_accepted_response(original_message)
386
+ # 202 Accepted - start SSE stream if this was an initialization
387
+ if original_message.is_a?(Hash) && original_message["method"] == "initialize"
388
+ start_sse_stream
389
+ end
390
+ nil
391
+ end
392
+
393
+ def handle_client_error(response)
394
+ response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
395
+ status_code = response.respond_to?(:status) ? response.status : "Unknown"
396
+
397
+ begin
398
+ error_body = JSON.parse(response_body)
399
+
400
+ if error_body.is_a?(Hash) && error_body["error"]
401
+ error_message = error_body["error"]["message"] || error_body["error"]["code"] || error_body["error"].to_s
402
+
403
+ # If we still don't have a message, include the full error object
404
+ if error_message.to_s.strip.empty?
405
+ error_message = "Empty error (full response: #{response_body})"
406
+ end
407
+
408
+ if error_message.to_s.downcase.include?("session")
409
+ raise Errors::TransportError.new(
410
+ code: status_code,
411
+ message: "Server error: #{error_message} (Current session ID: #{@session_id || 'none'})"
412
+ )
413
+ end
414
+
415
+ # Special handling for 403 Forbidden with OAuth
416
+ if status_code == 403 && @oauth_provider
417
+ raise Errors::TransportError.new(
418
+ code: status_code,
419
+ message: "Authorization failed (403 Forbidden): #{error_message}. \
420
+ Check token scope and resource permissions at #{@oauth_provider.server_url}."
421
+ )
422
+ end
423
+
424
+ raise Errors::TransportError.new(
425
+ code: status_code,
426
+ message: "Server error: #{error_message}"
427
+ )
428
+ end
429
+ rescue JSON::ParserError
430
+ # Fall through to generic error
431
+ end
432
+
433
+ raise Errors::TransportError.new(
434
+ code: status_code,
435
+ message: "HTTP client error: #{status_code} - #{response_body}"
436
+ )
437
+ end
438
+
439
+ def handle_session_expired
440
+ @session_id = nil
441
+ raise Errors::SessionExpiredError.new(
442
+ message: "Session expired, re-initialization required"
443
+ )
444
+ end
445
+
446
+ def extract_resource_metadata_url(response)
447
+ # Extract resource metadata URL from response headers if present
448
+ # Guard against error responses that don't have headers
449
+ return nil unless response.respond_to?(:headers)
450
+
451
+ metadata_url = response.headers["mcp-resource-metadata-url"]
452
+ metadata_url ? URI(metadata_url) : nil
453
+ end
454
+
455
+ def start_sse_stream(options = StartSSEOptions.new)
456
+ return unless @running && !@abort_controller
457
+
458
+ @sse_mutex.synchronize do
459
+ return if @sse_thread&.alive?
460
+
461
+ @sse_thread = Thread.new do
462
+ start_sse(options)
463
+ end
464
+ end
465
+ end
466
+
467
+ def start_sse(options) # rubocop:disable Metrics/MethodLength
468
+ attempt_count = 0
469
+
470
+ begin
471
+ headers = build_common_headers
472
+ headers["Accept"] = "text/event-stream"
473
+
474
+ if options.resumption_token
475
+ headers["Last-Event-ID"] = options.resumption_token
476
+ end
477
+
478
+ # Set up SSE streaming connection with callbacks
479
+ connection = create_connection_with_sse_callbacks(options, headers)
480
+ response = connection.get(@url)
481
+
482
+ # Handle HTTPX error responses first
483
+ error_result = handle_httpx_error_response!(response, context: { location: "SSE connection" },
484
+ allow_eof_for_sse: true)
485
+ return if error_result == :eof_handled
486
+
487
+ case response.status
488
+ when 200
489
+ # SSE stream established successfully
490
+ RubyLLM::MCP.logger.debug "SSE stream established"
491
+ # Response will be processed through callbacks
492
+ when 401
493
+ raise Errors::AuthenticationRequiredError.new(
494
+ message: "OAuth authentication required. Server returned 401 Unauthorized.",
495
+ code: 401
496
+ )
497
+ when 405
498
+ # Server doesn't support SSE - this is acceptable
499
+ RubyLLM::MCP.logger.info "Server does not support SSE streaming"
500
+ nil
501
+ when 409
502
+ # Conflict - SSE connection already exists for this session
503
+ # This is expected when reusing sessions and is acceptable
504
+ RubyLLM::MCP.logger.debug "SSE stream already exists for this session"
505
+ nil
506
+ else
507
+ reason_phrase = response.respond_to?(:reason_phrase) ? response.reason_phrase : nil
508
+ raise Errors::TransportError.new(
509
+ code: response.status,
510
+ message: "Failed to open SSE stream: #{reason_phrase || response.status}"
511
+ )
512
+ end
513
+ rescue StandardError => e
514
+ RubyLLM::MCP.logger.error "SSE stream error: #{e.message}"
515
+ # Attempt reconnection with exponential backoff
516
+
517
+ if @running && !@abort_controller && attempt_count < @reconnection_options.max_retries
518
+ delay = calculate_reconnection_delay(attempt_count)
519
+ RubyLLM::MCP.logger.info "Reconnecting SSE stream in #{delay}ms..."
520
+
521
+ sleep(delay / 1000.0)
522
+ attempt_count += 1
523
+ retry
524
+ end
525
+
526
+ raise e
527
+ end
528
+ end
529
+
530
+ def create_connection_with_sse_callbacks(options, headers)
531
+ client = HTTPX.plugin(:callbacks)
532
+ client = add_on_response_body_chunk_callback(client, options)
533
+
534
+ client = client.with(
535
+ timeout: {
536
+ connect_timeout: 10,
537
+ read_timeout: @request_timeout / 1000,
538
+ write_timeout: @request_timeout / 1000,
539
+ operation_timeout: @request_timeout / 1000
540
+ },
541
+ headers: headers
542
+ )
543
+
544
+ if @version == :http1
545
+ client = client.with(
546
+ ssl: { alpn_protocols: ["http/1.1"] }
547
+ )
548
+ end
549
+
550
+ register_client(client)
551
+ end
552
+
553
+ def add_on_response_body_chunk_callback(client, options)
554
+ buffer = +""
555
+ client.on_response_body_chunk do |request, response, chunk|
556
+ # Only process chunks for text/event-stream and if still running
557
+ next unless @running && !@abort_controller
558
+
559
+ if chunk.include?("event: stop")
560
+ RubyLLM::MCP.logger.debug "Closing SSE stream"
561
+ request.close
562
+ end
563
+
564
+ content_type = response.headers["content-type"]
565
+ if content_type&.include?("text/event-stream")
566
+ buffer << chunk.to_s
567
+
568
+ while (event_data = extract_sse_event(buffer))
569
+ raw_event, remaining_buffer = event_data
570
+ buffer.replace(remaining_buffer)
571
+
572
+ next unless raw_event && raw_event[:data]
573
+
574
+ if raw_event[:id]
575
+ options.on_resumption_token&.call(raw_event[:id])
576
+ end
577
+
578
+ process_sse_event(raw_event, options.replay_message_id)
579
+ end
580
+ end
581
+ end
582
+ end
583
+
584
+ def calculate_reconnection_delay(attempt)
585
+ initial = @reconnection_options.initial_reconnection_delay
586
+ factor = @reconnection_options.reconnection_delay_grow_factor
587
+ max_delay = @reconnection_options.max_reconnection_delay
588
+
589
+ [initial * (factor**attempt), max_delay].min
590
+ end
591
+
592
+ def process_sse_buffer_events(buffer, _request_id)
593
+ return unless @running && !@abort_controller
594
+
595
+ while (event_data = extract_sse_event(buffer))
596
+ raw_event, remaining_buffer = event_data
597
+ buffer.replace(remaining_buffer)
598
+
599
+ process_sse_event(raw_event, nil) if raw_event && raw_event[:data]
600
+ end
601
+ end
602
+
603
+ def extract_sse_event(buffer)
604
+ # Support both Unix (\n\n) and Windows (\r\n\r\n) line endings
605
+ separator = if buffer.include?("\r\n\r\n")
606
+ "\r\n\r\n"
607
+ elsif buffer.include?("\n\n")
608
+ "\n\n"
609
+ else
610
+ return nil
611
+ end
612
+
613
+ raw, rest = buffer.split(separator, 2)
614
+ [parse_sse_event(raw), rest || ""]
615
+ end
616
+
617
+ def parse_sse_event(raw)
618
+ event = {}
619
+ raw.each_line do |line|
620
+ line = line.strip
621
+ case line
622
+ when /^data:\s*(.*)/
623
+ (event[:data] ||= []) << ::Regexp.last_match(1)
624
+ when /^event:\s*(.*)/
625
+ event[:event] = ::Regexp.last_match(1)
626
+ when /^id:\s*(.*)/
627
+ event[:id] = ::Regexp.last_match(1)
628
+ end
629
+ end
630
+ event[:data] = event[:data]&.join("\n")
631
+ event
632
+ end
633
+
634
+ def process_sse_event(raw_event, replay_message_id)
635
+ return unless raw_event[:data]
636
+ return unless @running && !@abort_controller
637
+
638
+ begin
639
+ event_data = JSON.parse(raw_event[:data])
640
+
641
+ # Handle replay message ID if specified
642
+ if replay_message_id && event_data.is_a?(Hash) && event_data["id"]
643
+ event_data["id"] = replay_message_id
644
+ end
645
+
646
+ result = RubyLLM::MCP::Result.new(event_data, session_id: @session_id)
647
+ RubyLLM::MCP.logger.debug "SSE Result Received: #{result.inspect}"
648
+
649
+ result = @coordinator.process_result(result)
650
+ return if result.nil?
651
+
652
+ request_id = result.id&.to_s
653
+ if request_id
654
+ @pending_mutex.synchronize do
655
+ response_queue = @pending_requests.delete(request_id)
656
+ response_queue&.push(result)
657
+ end
658
+ end
659
+ rescue JSON::ParserError => e
660
+ RubyLLM::MCP.logger.warn "Failed to parse SSE event data: #{raw_event[:data]} - #{e.message}"
661
+ rescue Errors::UnknownRequest => e
662
+ RubyLLM::MCP.logger.warn "Unknown request from MCP server: #{e.message}"
663
+ rescue StandardError => e
664
+ RubyLLM::MCP.logger.error "Error processing SSE event: #{e.message}"
665
+ raise Errors::TransportError.new(
666
+ message: "Error processing SSE event: #{e.message}",
667
+ error: e
668
+ )
669
+ end
670
+ end
671
+
672
+ def wait_for_response_with_timeout(request_id, response_queue)
673
+ with_timeout(@request_timeout / 1000, request_id: request_id) do
674
+ response_queue.pop
675
+ end
676
+ rescue RubyLLM::MCP::Errors::TimeoutError => e
677
+ log_message = "StreamableHTTP request timeout (ID: #{request_id}) after #{@request_timeout / 1000} seconds"
678
+ RubyLLM::MCP.logger.error(log_message)
679
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
680
+ raise e
681
+ end
682
+
683
+ def cleanup_sse_resources
684
+ @running = false
685
+ @abort_controller = true
686
+
687
+ @sse_mutex.synchronize do
688
+ if @sse_thread&.alive?
689
+ @sse_thread.kill
690
+ @sse_thread.join(5) # Wait up to 5 seconds for thread to finish
691
+ @sse_thread = nil
692
+ end
693
+ end
694
+
695
+ # Clear any pending requests
696
+ @pending_mutex.synchronize do
697
+ @pending_requests.each_value do |queue|
698
+ queue.close if queue.respond_to?(:close)
699
+ rescue StandardError
700
+ # Ignore errors when closing queues
701
+ end
702
+ @pending_requests.clear
703
+ end
704
+ end
705
+
706
+ def cleanup_connection
707
+ clients_to_close = []
708
+
709
+ @clients_mutex.synchronize do
710
+ clients_to_close = @clients.dup
711
+ @clients.clear
712
+ end
713
+
714
+ clients_to_close.each do |client|
715
+ client.close if client.respond_to?(:close)
716
+ rescue StandardError => e
717
+ RubyLLM::MCP.logger.debug "Error closing HTTPX client: #{e.message}"
718
+ end
719
+
720
+ @connection = nil
721
+ end
722
+ end
723
+ end
724
+ end
725
+ end