ruby_llm_swarm-mcp 0.8.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 (92) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +277 -0
  4. data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
  5. data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
  6. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  16. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  17. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  18. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  19. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  20. data/lib/ruby_llm/chat.rb +34 -0
  21. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
  22. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
  23. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
  24. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
  25. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
  26. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
  27. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
  28. data/lib/ruby_llm/mcp/attachment.rb +18 -0
  29. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  30. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  31. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
  32. data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
  33. data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
  34. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
  35. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  36. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  37. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  38. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  39. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  40. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  41. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  42. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
  43. data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
  44. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
  45. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  46. data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
  47. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  48. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  49. data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
  50. data/lib/ruby_llm/mcp/auth.rb +359 -0
  51. data/lib/ruby_llm/mcp/client.rb +401 -0
  52. data/lib/ruby_llm/mcp/completion.rb +16 -0
  53. data/lib/ruby_llm/mcp/configuration.rb +310 -0
  54. data/lib/ruby_llm/mcp/content.rb +28 -0
  55. data/lib/ruby_llm/mcp/elicitation.rb +48 -0
  56. data/lib/ruby_llm/mcp/error.rb +34 -0
  57. data/lib/ruby_llm/mcp/errors.rb +91 -0
  58. data/lib/ruby_llm/mcp/logging.rb +16 -0
  59. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
  60. data/lib/ruby_llm/mcp/native/client.rb +387 -0
  61. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  62. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  63. data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
  64. data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
  65. data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
  66. data/lib/ruby_llm/mcp/native/messages.rb +36 -0
  67. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  68. data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
  69. data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
  70. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  71. data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
  72. data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
  73. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
  74. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  75. data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
  76. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  77. data/lib/ruby_llm/mcp/native.rb +12 -0
  78. data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
  79. data/lib/ruby_llm/mcp/progress.rb +35 -0
  80. data/lib/ruby_llm/mcp/prompt.rb +132 -0
  81. data/lib/ruby_llm/mcp/railtie.rb +14 -0
  82. data/lib/ruby_llm/mcp/resource.rb +112 -0
  83. data/lib/ruby_llm/mcp/resource_template.rb +85 -0
  84. data/lib/ruby_llm/mcp/result.rb +108 -0
  85. data/lib/ruby_llm/mcp/roots.rb +45 -0
  86. data/lib/ruby_llm/mcp/sample.rb +152 -0
  87. data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
  88. data/lib/ruby_llm/mcp/tool.rb +228 -0
  89. data/lib/ruby_llm/mcp/version.rb +7 -0
  90. data/lib/ruby_llm/mcp.rb +125 -0
  91. data/lib/tasks/release.rake +23 -0
  92. metadata +184 -0
@@ -0,0 +1,926 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Native
6
+ module Transports
7
+ # Configuration options for reconnection behavior
8
+ class ReconnectionOptions
9
+ attr_reader :max_reconnection_delay, :initial_reconnection_delay,
10
+ :reconnection_delay_grow_factor, :max_retries
11
+
12
+ def initialize(
13
+ max_reconnection_delay: 30_000,
14
+ initial_reconnection_delay: 1_000,
15
+ reconnection_delay_grow_factor: 1.5,
16
+ max_retries: 2
17
+ )
18
+ @max_reconnection_delay = max_reconnection_delay
19
+ @initial_reconnection_delay = initial_reconnection_delay
20
+ @reconnection_delay_grow_factor = reconnection_delay_grow_factor
21
+ @max_retries = max_retries
22
+ end
23
+ end
24
+
25
+ # Options for starting SSE connections
26
+ class StartSSEOptions
27
+ attr_accessor :resumption_token
28
+ attr_reader :on_resumption_token, :replay_message_id
29
+
30
+ def initialize(resumption_token: nil, on_resumption_token: nil, replay_message_id: nil)
31
+ @resumption_token = resumption_token
32
+ @on_resumption_token = on_resumption_token
33
+ @replay_message_id = replay_message_id
34
+ end
35
+ end
36
+
37
+ # Main StreamableHTTP transport class
38
+ class StreamableHTTP
39
+ include Support::Timeout
40
+
41
+ attr_reader :session_id, :protocol_version, :coordinator, :oauth_provider
42
+
43
+ def initialize( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
44
+ url:,
45
+ request_timeout:,
46
+ coordinator:,
47
+ headers: {},
48
+ reconnection: {},
49
+ version: :http2,
50
+ oauth_provider: nil,
51
+ rate_limit: nil,
52
+ reconnection_options: nil,
53
+ session_id: nil,
54
+ sse_timeout: nil,
55
+ options: {}
56
+ )
57
+ # Extract options if provided (for backward compatibility)
58
+ extracted_options = options.dup
59
+ headers = extracted_options.delete(:headers) || headers
60
+ version = extracted_options.delete(:version) || version
61
+ oauth_provider = extracted_options.delete(:oauth_provider) || oauth_provider
62
+ reconnection = extracted_options.delete(:reconnection) || reconnection
63
+ reconnection_options = extracted_options.delete(:reconnection_options) || reconnection_options
64
+ rate_limit = extracted_options.delete(:rate_limit) || rate_limit
65
+ session_id = extracted_options.delete(:session_id) || session_id
66
+ sse_timeout = extracted_options.delete(:sse_timeout) || sse_timeout
67
+
68
+ @url = URI(url)
69
+ @coordinator = coordinator
70
+ @request_timeout = request_timeout
71
+ @sse_timeout = sse_timeout
72
+ @headers = headers || {}
73
+ @session_id = session_id
74
+
75
+ @version = version
76
+ @protocol_version = nil
77
+
78
+ @client_id = SecureRandom.uuid
79
+
80
+ # Reconnection options precedence: explicit > hash > defaults
81
+ @reconnection_options = if reconnection_options
82
+ reconnection_options
83
+ elsif reconnection && !reconnection.empty?
84
+ ReconnectionOptions.new(**reconnection)
85
+ else
86
+ ReconnectionOptions.new
87
+ end
88
+
89
+ @oauth_provider = oauth_provider
90
+ @rate_limiter = Support::RateLimiter.new(**rate_limit) if rate_limit
91
+
92
+ @id_counter = 0
93
+ @id_mutex = Mutex.new
94
+ @pending_requests = {}
95
+ @pending_mutex = Mutex.new
96
+ @running = true
97
+ @sse_stopped = false
98
+ @state_mutex = Mutex.new
99
+ @sse_thread = nil
100
+ @sse_mutex = Mutex.new
101
+ @last_sse_event_id = nil
102
+
103
+ # Track if we've attempted auth flow to prevent infinite loops
104
+ @auth_retry_attempted = false
105
+
106
+ # Thread-safe collection of all HTTPX clients
107
+ @clients = []
108
+ @clients_mutex = Mutex.new
109
+
110
+ @connection = create_connection
111
+ end
112
+
113
+ def request(body, wait_for_response: true)
114
+ if @rate_limiter&.exceeded?
115
+ sleep(1) while @rate_limiter&.exceeded?
116
+ end
117
+ @rate_limiter&.add
118
+
119
+ # Extract the request ID from the body (if present)
120
+ request_id = body.is_a?(Hash) ? (body["id"] || body[:id]) : nil
121
+ is_initialization = body.is_a?(Hash) && (body["method"] == "initialize" || body[:method] == :initialize)
122
+
123
+ response_queue = setup_response_queue(request_id, wait_for_response)
124
+ result = send_http_request(body, request_id, is_initialization: is_initialization)
125
+ return result if result.is_a?(RubyLLM::MCP::Result)
126
+
127
+ if wait_for_response && request_id
128
+ wait_for_response_with_timeout(request_id.to_s, response_queue)
129
+ end
130
+ end
131
+
132
+ def alive?
133
+ running?
134
+ end
135
+
136
+ def close
137
+ terminate_session
138
+ cleanup_sse_resources
139
+ cleanup_connection
140
+ end
141
+
142
+ def start
143
+ @state_mutex.synchronize do
144
+ @sse_stopped = false
145
+ end
146
+ end
147
+
148
+ def set_protocol_version(version)
149
+ @protocol_version = version
150
+ end
151
+
152
+ def on_message(&block)
153
+ @on_message_callback = block
154
+ end
155
+
156
+ def on_error(&block)
157
+ @on_error_callback = block
158
+ end
159
+
160
+ def on_close(&block)
161
+ @on_close_callback = block
162
+ end
163
+
164
+ private
165
+
166
+ def running?
167
+ @state_mutex.synchronize { @running && !@sse_stopped }
168
+ end
169
+
170
+ def abort!
171
+ @state_mutex.synchronize do
172
+ @running = false
173
+ @sse_stopped = true
174
+ end
175
+ end
176
+
177
+ def terminate_session
178
+ return unless @session_id
179
+
180
+ begin
181
+ headers = build_common_headers
182
+ response = @connection.delete(@url, headers: headers)
183
+
184
+ handle_httpx_error_response!(response, context: { location: "terminating session" })
185
+
186
+ unless [200, 405].include?(response.status)
187
+ reason_phrase = response.respond_to?(:reason_phrase) ? response.reason_phrase : nil
188
+ raise Errors::TransportError.new(
189
+ code: response.status,
190
+ message: "Failed to terminate session: #{reason_phrase || response.status}"
191
+ )
192
+ end
193
+
194
+ @session_id = nil
195
+ rescue StandardError => e
196
+ raise Errors::TransportError.new(
197
+ message: "Failed to terminate session: #{e.message}",
198
+ code: nil,
199
+ error: e
200
+ )
201
+ end
202
+ end
203
+
204
+ def handle_httpx_error_response!(response, context:, allow_eof_for_sse: false)
205
+ return false unless response.is_a?(HTTPX::ErrorResponse)
206
+
207
+ error = response.error
208
+
209
+ if allow_eof_for_sse && error.is_a?(EOFError)
210
+ RubyLLM::MCP.logger.info "SSE stream closed: #{response.error.message}"
211
+ return :eof_handled
212
+ end
213
+
214
+ if error.is_a?(HTTPX::ReadTimeoutError)
215
+ raise Errors::TimeoutError.new(
216
+ message: "Request timed out after #{@request_timeout / 1000} seconds",
217
+ request_id: context[:request_id]
218
+ )
219
+ end
220
+
221
+ error_message = response.error&.message || "Request failed"
222
+ RubyLLM::MCP.logger.error "HTTPX error in #{context[:location]}: #{error_message}"
223
+
224
+ raise Errors::TransportError.new(
225
+ code: nil,
226
+ message: "HTTPX Error #{context}: #{error_message}"
227
+ )
228
+ end
229
+
230
+ def register_client(client)
231
+ @clients_mutex.synchronize do
232
+ @clients << client
233
+ end
234
+ client
235
+ end
236
+
237
+ def unregister_client(client)
238
+ @clients_mutex.synchronize do
239
+ @clients.delete(client)
240
+ end
241
+ end
242
+
243
+ def close_client(client)
244
+ client.close if client.respond_to?(:close)
245
+ rescue StandardError => e
246
+ RubyLLM::MCP.logger.debug "Error closing HTTPX client: #{e.message}"
247
+ ensure
248
+ unregister_client(client)
249
+ end
250
+
251
+ def active_clients_count
252
+ @clients_mutex.synchronize do
253
+ @clients.size
254
+ end
255
+ end
256
+
257
+ def create_connection
258
+ timeout_seconds = @request_timeout / 1000.0
259
+ client = Support::HTTPClient.connection.with(
260
+ timeout: {
261
+ connect_timeout: 10,
262
+ read_timeout: timeout_seconds,
263
+ write_timeout: timeout_seconds,
264
+ operation_timeout: timeout_seconds
265
+ }
266
+ )
267
+
268
+ register_client(client)
269
+ end
270
+
271
+ def build_common_headers
272
+ headers = @headers.dup
273
+
274
+ headers["mcp-session-id"] = @session_id if @session_id
275
+ headers["mcp-protocol-version"] = @protocol_version if @protocol_version
276
+ headers["X-CLIENT-ID"] = @client_id
277
+ headers["Origin"] = @url.to_s
278
+
279
+ if @oauth_provider
280
+ RubyLLM::MCP.logger.debug "OAuth provider present, attempting to get token..."
281
+ RubyLLM::MCP.logger.debug " Server URL: #{@oauth_provider.server_url}"
282
+
283
+ token = @oauth_provider.access_token
284
+ if token
285
+ headers["Authorization"] = token.to_header
286
+ RubyLLM::MCP.logger.debug "Applied OAuth authorization header: #{token.to_header}"
287
+ else
288
+ RubyLLM::MCP.logger.warn "OAuth provider present but no valid token available!"
289
+ RubyLLM::MCP.logger.warn " This means the token is not in storage or has expired"
290
+ RubyLLM::MCP.logger.warn " Check that authentication completed successfully"
291
+ end
292
+ else
293
+ RubyLLM::MCP.logger.debug "No OAuth provider configured for this transport"
294
+ end
295
+
296
+ headers
297
+ end
298
+
299
+ def setup_response_queue(request_id, wait_for_response)
300
+ response_queue = Queue.new
301
+ if wait_for_response && request_id
302
+ @pending_mutex.synchronize do
303
+ @pending_requests[request_id.to_s] = response_queue
304
+ end
305
+ end
306
+ response_queue
307
+ end
308
+
309
+ def send_http_request(body, request_id, is_initialization: false)
310
+ headers = build_common_headers
311
+ headers["Content-Type"] = "application/json"
312
+ headers["Accept"] = "application/json, text/event-stream"
313
+
314
+ json_body = JSON.generate(body)
315
+ RubyLLM::MCP.logger.debug "Sending Request: #{json_body}"
316
+
317
+ request_client = nil
318
+ begin
319
+ connection = if is_initialization
320
+ @connection
321
+ else
322
+ request_client = create_connection_with_streaming_callbacks(request_id)
323
+ request_client
324
+ end
325
+
326
+ response = connection.post(@url, json: body, headers: headers)
327
+ handle_response(response, request_id, body)
328
+ ensure
329
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
330
+ close_client(request_client) if request_client && !is_initialization
331
+ end
332
+ end
333
+
334
+ def create_connection_with_streaming_callbacks(request_id)
335
+ buffer = +""
336
+
337
+ client = Support::HTTPClient.connection.plugin(:callbacks)
338
+ .on_response_body_chunk do |request, _response, chunk|
339
+ next unless running?
340
+
341
+ RubyLLM::MCP.logger.debug "Received chunk: #{chunk.bytesize} bytes for #{request.uri}"
342
+ buffer << chunk
343
+ process_sse_buffer_events(buffer, request_id&.to_s)
344
+ end
345
+ .with(
346
+ timeout: {
347
+ connect_timeout: @request_timeout / 1000,
348
+ read_timeout: @request_timeout / 1000,
349
+ write_timeout: @request_timeout / 1000,
350
+ operation_timeout: @request_timeout / 1000
351
+ }
352
+ )
353
+
354
+ register_client(client)
355
+ end
356
+
357
+ def handle_response(response, request_id, original_message)
358
+ handle_httpx_error_response!(response, context: { location: "handling response", request_id: request_id })
359
+
360
+ session_id = response.headers["mcp-session-id"]
361
+ @session_id = session_id if session_id
362
+
363
+ case response.status
364
+ when 200
365
+ handle_success_response(response, request_id, original_message)
366
+ when 202
367
+ handle_accepted_response(original_message)
368
+ when 404
369
+ handle_session_expired
370
+ when 401
371
+ handle_authentication_challenge(response, request_id, original_message)
372
+ when 405
373
+ # Method not allowed - acceptable for some endpoints
374
+ nil
375
+ when 400...500
376
+ handle_client_error(response)
377
+ else
378
+ response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
379
+ raise Errors::TransportError.new(
380
+ code: response.status,
381
+ message: "HTTP request failed: #{response.status} - #{response_body}"
382
+ )
383
+ end
384
+ end
385
+
386
+ def handle_success_response(response, request_id, _original_message)
387
+ content_type = response.respond_to?(:headers) ? response.headers["content-type"] : nil
388
+
389
+ if content_type&.include?("text/event-stream")
390
+ start_sse_stream
391
+ nil
392
+ elsif content_type&.include?("application/json")
393
+ response_body = response.respond_to?(:body) ? response.body.to_s : "{}"
394
+ if response_body == "null" # Fix related to official MCP Ruby SDK implementation
395
+ response_body = "{}"
396
+ end
397
+
398
+ json_response = parse_and_validate_http_response(response_body)
399
+ result = RubyLLM::MCP::Result.new(json_response, session_id: @session_id)
400
+
401
+ if request_id
402
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
403
+ end
404
+
405
+ result
406
+ else
407
+ raise Errors::TransportError.new(
408
+ code: -1,
409
+ message: "Unexpected content type: #{content_type}"
410
+ )
411
+ end
412
+ rescue StandardError => e
413
+ raise Errors::TransportError.new(
414
+ message: "Invalid JSON response: #{e.message}",
415
+ error: e
416
+ )
417
+ end
418
+
419
+ def handle_accepted_response(original_message)
420
+ # 202 Accepted - start SSE stream if this was an initialization
421
+ if original_message.is_a?(Hash) && original_message["method"] == "initialize"
422
+ start_sse_stream
423
+ end
424
+ nil
425
+ end
426
+
427
+ def handle_client_error(response)
428
+ status_code = response.respond_to?(:status) ? response.status : "Unknown"
429
+
430
+ handle_oauth_authorization_error(response, status_code) if status_code == 403 && @oauth_provider
431
+
432
+ handle_json_error_response(response, status_code)
433
+
434
+ response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
435
+ raise Errors::TransportError.new(
436
+ code: status_code,
437
+ message: "HTTP client error: #{status_code} - #{response_body}"
438
+ )
439
+ end
440
+
441
+ def handle_oauth_authorization_error(response, status_code)
442
+ response_body = response.respond_to?(:body) ? response.body.to_s : ""
443
+ error_body = JSON.parse(response_body)
444
+ error_message = error_body.dig("error", "message") || "Authorization failed"
445
+
446
+ raise Errors::TransportError.new(
447
+ code: status_code,
448
+ message: "Authorization failed (403 Forbidden). #{error_message}. Check token scope and permissions."
449
+ )
450
+ rescue JSON::ParserError
451
+ raise Errors::TransportError.new(
452
+ code: status_code,
453
+ message: "Authorization failed (403 Forbidden). Check token scope and permissions."
454
+ )
455
+ end
456
+
457
+ def handle_json_error_response(response, status_code)
458
+ response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
459
+ error_body = JSON.parse(response_body)
460
+
461
+ return unless error_body.is_a?(Hash) && error_body["error"]
462
+
463
+ error_message = error_body["error"]["message"] || error_body["error"]["code"]
464
+
465
+ if error_message.to_s.empty?
466
+ raise Errors::TransportError.new(
467
+ code: status_code,
468
+ message: "Empty error (full response: #{response_body})"
469
+ )
470
+ end
471
+
472
+ if error_message.to_s.downcase.include?("session")
473
+ raise Errors::TransportError.new(
474
+ code: response.status,
475
+ message: "Server error: #{error_message} (Current session ID: #{@session_id || 'none'})"
476
+ )
477
+ end
478
+
479
+ raise Errors::TransportError.new(
480
+ code: response.status,
481
+ message: "Server error: #{error_message}"
482
+ )
483
+ rescue JSON::ParserError
484
+ nil
485
+ end
486
+
487
+ def handle_session_expired
488
+ @session_id = nil
489
+ raise Errors::SessionExpiredError.new(
490
+ message: "Session expired, re-initialization required"
491
+ )
492
+ end
493
+
494
+ def extract_resource_metadata_url(response)
495
+ return nil unless response.respond_to?(:headers)
496
+
497
+ metadata_url = response.headers["mcp-resource-metadata-url"]
498
+ if metadata_url
499
+ @resource_metadata_url = metadata_url
500
+ RubyLLM::MCP.logger.debug("Extracted resource metadata URL: #{metadata_url}")
501
+ end
502
+ metadata_url ? URI(metadata_url) : nil
503
+ end
504
+
505
+ def handle_authentication_challenge(response, request_id, original_message)
506
+ check_retry_guard!
507
+ check_oauth_provider_configured!
508
+
509
+ RubyLLM::MCP.logger.info("Received 401 Unauthorized, attempting automatic authentication")
510
+
511
+ www_authenticate = response.headers["www-authenticate"]
512
+ resource_metadata_url = extract_resource_metadata_url(response)
513
+
514
+ attempt_authentication_retry(www_authenticate, resource_metadata_url, request_id, original_message)
515
+ end
516
+
517
+ def check_retry_guard!
518
+ return unless @auth_retry_attempted
519
+
520
+ RubyLLM::MCP.logger.warn("Authentication retry already attempted, raising error")
521
+ @auth_retry_attempted = false
522
+ raise Errors::AuthenticationRequiredError.new(
523
+ message: "OAuth authentication required (401 Unauthorized) - retry failed"
524
+ )
525
+ end
526
+
527
+ def check_oauth_provider_configured!
528
+ return if @oauth_provider
529
+
530
+ raise Errors::AuthenticationRequiredError.new(
531
+ message: "OAuth authentication required (401 Unauthorized) but no OAuth provider configured"
532
+ )
533
+ end
534
+
535
+ def attempt_authentication_retry(www_authenticate, resource_metadata_url, request_id, original_message)
536
+ @auth_retry_attempted = true
537
+
538
+ success = @oauth_provider.handle_authentication_challenge(
539
+ www_authenticate: www_authenticate,
540
+ resource_metadata_url: resource_metadata_url&.to_s,
541
+ requested_scope: nil
542
+ )
543
+
544
+ if success
545
+ RubyLLM::MCP.logger.info("Authentication challenge handled successfully, retrying request")
546
+ result = send_http_request(original_message, request_id, is_initialization: false)
547
+ @auth_retry_attempted = false
548
+ return result
549
+ end
550
+
551
+ @auth_retry_attempted = false
552
+ raise Errors::AuthenticationRequiredError.new(
553
+ message: "OAuth authentication required (401 Unauthorized)"
554
+ )
555
+ rescue Errors::AuthenticationRequiredError => e
556
+ @auth_retry_attempted = false
557
+ raise e
558
+ rescue StandardError => e
559
+ @auth_retry_attempted = false
560
+ RubyLLM::MCP.logger.error("Authentication challenge handling failed: #{e.message}")
561
+ raise Errors::AuthenticationRequiredError.new(
562
+ message: "OAuth authentication failed: #{e.message}"
563
+ )
564
+ end
565
+
566
+ def start_sse_stream(options = StartSSEOptions.new)
567
+ return unless running?
568
+
569
+ @sse_mutex.synchronize do
570
+ return if @sse_thread&.alive?
571
+
572
+ @sse_thread = Thread.new do
573
+ start_sse(options)
574
+ end
575
+ end
576
+ end
577
+
578
+ def start_sse(options) # rubocop:disable Metrics/MethodLength
579
+ attempt_count = 0
580
+
581
+ begin
582
+ headers = build_common_headers
583
+ headers["Accept"] = "text/event-stream"
584
+
585
+ if options.resumption_token
586
+ headers["Last-Event-ID"] = options.resumption_token
587
+ end
588
+
589
+ connection = create_connection_with_sse_callbacks(options, headers)
590
+ response = connection.get(@url)
591
+
592
+ error_result = handle_httpx_error_response!(response, context: { location: "SSE connection" },
593
+ allow_eof_for_sse: true)
594
+ return if error_result == :eof_handled
595
+
596
+ case response.status
597
+ when 200
598
+ RubyLLM::MCP.logger.debug "SSE stream established"
599
+ when 405, 401
600
+ RubyLLM::MCP.logger.info "Server does not support SSE streaming"
601
+ nil
602
+ when 409
603
+ RubyLLM::MCP.logger.debug "SSE stream already exists for this session"
604
+ nil
605
+ else
606
+ reason_phrase = response.respond_to?(:reason_phrase) ? response.reason_phrase : nil
607
+ raise Errors::TransportError.new(
608
+ code: response.status,
609
+ message: "Failed to open SSE stream: #{reason_phrase || response.status}"
610
+ )
611
+ end
612
+ rescue StandardError => e
613
+ RubyLLM::MCP.logger.error "SSE stream error: #{e.message}"
614
+ if running? && attempt_count < @reconnection_options.max_retries
615
+ delay = calculate_reconnection_delay(attempt_count)
616
+ RubyLLM::MCP.logger.info "Reconnecting SSE stream in #{delay}ms..."
617
+
618
+ sleep(delay / 1000.0)
619
+ attempt_count += 1
620
+
621
+ # Create new options with the last event ID for resumption
622
+ options = StartSSEOptions.new(
623
+ resumption_token: @last_sse_event_id,
624
+ on_resumption_token: options.on_resumption_token,
625
+ replay_message_id: options.replay_message_id
626
+ )
627
+
628
+ retry
629
+ end
630
+
631
+ raise e
632
+ end
633
+ end
634
+
635
+ def create_connection_with_sse_callbacks(options, headers)
636
+ client = HTTPX.plugin(:callbacks)
637
+ client = add_on_response_body_chunk_callback(client, options)
638
+
639
+ sse_timeout_seconds = if @sse_timeout
640
+ @sse_timeout / 1000.0
641
+ else
642
+ # Default to 1 hour for SSE if not specified
643
+ 3600
644
+ end
645
+
646
+ client = client.with(
647
+ timeout: {
648
+ connect_timeout: 10,
649
+ read_timeout: sse_timeout_seconds,
650
+ write_timeout: sse_timeout_seconds,
651
+ operation_timeout: sse_timeout_seconds
652
+ },
653
+ headers: headers
654
+ )
655
+
656
+ if @version == :http1
657
+ client = client.with(
658
+ ssl: { alpn_protocols: ["http/1.1"] }
659
+ )
660
+ end
661
+
662
+ register_client(client)
663
+ end
664
+
665
+ def add_on_response_body_chunk_callback(client, options)
666
+ buffer = +""
667
+ client.on_response_body_chunk do |request, response, chunk|
668
+ # Only process chunks for text/event-stream and if still running
669
+ next unless running?
670
+
671
+ if chunk.include?("event: stop")
672
+ RubyLLM::MCP.logger.debug "Closing SSE stream"
673
+ request.close
674
+ end
675
+
676
+ content_type = response.headers["content-type"]
677
+ if content_type&.include?("text/event-stream")
678
+ buffer << chunk.to_s
679
+
680
+ while (event_data = extract_sse_event(buffer))
681
+ raw_event, remaining_buffer = event_data
682
+ buffer.replace(remaining_buffer)
683
+
684
+ next unless raw_event && raw_event[:data]
685
+
686
+ if raw_event[:id]
687
+ @last_sse_event_id = raw_event[:id]
688
+ options.on_resumption_token&.call(raw_event[:id])
689
+ end
690
+
691
+ process_sse_event(raw_event, options.replay_message_id)
692
+ end
693
+ end
694
+ end
695
+ end
696
+
697
+ def calculate_reconnection_delay(attempt)
698
+ initial = @reconnection_options.initial_reconnection_delay
699
+ factor = @reconnection_options.reconnection_delay_grow_factor
700
+ max_delay = @reconnection_options.max_reconnection_delay
701
+
702
+ [initial * (factor**attempt), max_delay].min
703
+ end
704
+
705
+ def process_sse_buffer_events(buffer, request_id)
706
+ return unless running?
707
+
708
+ while (event_data = extract_sse_event(buffer))
709
+ raw_event, remaining_buffer = event_data
710
+ buffer.replace(remaining_buffer)
711
+
712
+ if raw_event && raw_event[:data]
713
+ RubyLLM::MCP.logger.debug "Processing SSE buffer event for request #{request_id}" if request_id
714
+ process_sse_event(raw_event, nil)
715
+ end
716
+ end
717
+ end
718
+
719
+ def extract_sse_event(buffer)
720
+ # Support both Unix (\n\n) and Windows (\r\n\r\n) line endings
721
+ separator = if buffer.include?("\r\n\r\n")
722
+ "\r\n\r\n"
723
+ elsif buffer.include?("\n\n")
724
+ "\n\n"
725
+ else
726
+ return nil
727
+ end
728
+
729
+ raw, rest = buffer.split(separator, 2)
730
+ [parse_sse_event(raw), rest || ""]
731
+ end
732
+
733
+ def parse_sse_event(raw)
734
+ event = {}
735
+ raw.each_line do |line|
736
+ line = line.strip
737
+ case line
738
+ when /^data:\s*(.*)/
739
+ (event[:data] ||= []) << ::Regexp.last_match(1)
740
+ when /^event:\s*(.*)/
741
+ event[:event] = ::Regexp.last_match(1)
742
+ when /^id:\s*(.*)/
743
+ event[:id] = ::Regexp.last_match(1)
744
+ end
745
+ end
746
+ event[:data] = event[:data]&.join("\n")
747
+ event
748
+ end
749
+
750
+ def process_sse_event(raw_event, replay_message_id) # rubocop:disable Metrics/MethodLength
751
+ return unless raw_event[:data]
752
+ return unless running?
753
+
754
+ begin
755
+ event_data = parse_and_validate_sse_event(raw_event[:data])
756
+ return unless event_data
757
+
758
+ event_type = raw_event[:event] || "message"
759
+ event_id = raw_event[:id]
760
+ RubyLLM::MCP.logger.debug "Processing SSE event: type=#{event_type}, id=#{event_id || 'none'}"
761
+
762
+ if replay_message_id && event_data.is_a?(Hash) && event_data["id"]
763
+ event_data["id"] = replay_message_id
764
+ end
765
+
766
+ result = RubyLLM::MCP::Result.new(event_data, session_id: @session_id)
767
+ RubyLLM::MCP.logger.debug "SSE Result Received: #{result.inspect}"
768
+
769
+ @on_message_callback&.call(result)
770
+
771
+ result = @coordinator.process_result(result)
772
+ return if result.nil?
773
+
774
+ request_id = result.id&.to_s
775
+ if request_id
776
+ @pending_mutex.synchronize do
777
+ response_queue = @pending_requests.delete(request_id)
778
+ if response_queue
779
+ RubyLLM::MCP.logger.debug "Matched SSE event to pending request: #{request_id}"
780
+ response_queue.push(result)
781
+ else
782
+ RubyLLM::MCP.logger.debug "No pending request found for SSE event: #{request_id}"
783
+ end
784
+ end
785
+ end
786
+ rescue JSON::ParserError => e
787
+ RubyLLM::MCP.logger.warn "Failed to parse SSE event data: #{raw_event[:data]} - #{e.message}"
788
+ @on_error_callback&.call(e)
789
+ rescue Errors::UnknownRequest => e
790
+ RubyLLM::MCP.logger.warn "Unknown request from MCP server: #{e.message}"
791
+ @on_error_callback&.call(e)
792
+ rescue StandardError => e
793
+ RubyLLM::MCP.logger.error "Error processing SSE event: #{e.message}"
794
+ @on_error_callback&.call(e)
795
+ raise Errors::TransportError.new(
796
+ message: "Error processing SSE event: #{e.message}",
797
+ error: e
798
+ )
799
+ end
800
+ end
801
+
802
+ def parse_and_validate_sse_event(data)
803
+ event_data = JSON.parse(data)
804
+
805
+ # Validate JSON-RPC envelope
806
+ validator = Native::JsonRpc::EnvelopeValidator.new(event_data)
807
+ unless validator.valid?
808
+ RubyLLM::MCP.logger.error(
809
+ "Invalid JSON-RPC envelope in SSE event: #{validator.error_message}\nRaw: #{data}"
810
+ )
811
+ return nil
812
+ end
813
+
814
+ event_data
815
+ end
816
+
817
+ def parse_and_validate_http_response(response_body)
818
+ json_response = JSON.parse(response_body)
819
+
820
+ # Validate JSON-RPC envelope
821
+ validator = Native::JsonRpc::EnvelopeValidator.new(json_response)
822
+ unless validator.valid?
823
+ error_msg = "Invalid JSON-RPC envelope: #{validator.error_message}"
824
+ RubyLLM::MCP.logger.error("#{error_msg}\nRaw: #{response_body}")
825
+ raise Errors::TransportError.new(
826
+ message: error_msg,
827
+ code: Native::JsonRpc::ErrorCodes::INVALID_REQUEST
828
+ )
829
+ end
830
+
831
+ json_response
832
+ rescue JSON::ParserError => e
833
+ error_msg = "JSON parse error: #{e.message}"
834
+ RubyLLM::MCP.logger.error("#{error_msg}\nRaw: #{response_body}")
835
+ raise Errors::TransportError.new(
836
+ message: error_msg,
837
+ code: Native::JsonRpc::ErrorCodes::PARSE_ERROR,
838
+ error: e
839
+ )
840
+ end
841
+
842
+ def wait_for_response_with_timeout(request_id, response_queue)
843
+ result = with_timeout(@request_timeout / 1000, request_id: request_id) do
844
+ response_queue.pop
845
+ end
846
+
847
+ # Check if we received a shutdown error sentinel
848
+ if result.is_a?(Errors::TransportError)
849
+ raise result
850
+ end
851
+
852
+ result
853
+ rescue RubyLLM::MCP::Errors::TimeoutError => e
854
+ log_message = "StreamableHTTP request timeout (ID: #{request_id}) after #{@request_timeout / 1000} seconds"
855
+ RubyLLM::MCP.logger.error(log_message)
856
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
857
+ raise e
858
+ end
859
+
860
+ def cleanup_sse_resources
861
+ abort!
862
+
863
+ # Call on_close hook if registered
864
+ @on_close_callback&.call
865
+
866
+ # Close all HTTPX clients to signal SSE thread to exit
867
+ close_all_clients
868
+
869
+ @sse_mutex.synchronize do
870
+ if @sse_thread&.alive?
871
+ unless @sse_thread.join(5)
872
+ RubyLLM::MCP.logger.warn "SSE thread did not exit cleanly, forcing termination"
873
+ @sse_thread.kill
874
+ @sse_thread.join(1)
875
+ end
876
+ @sse_thread = nil
877
+ end
878
+ end
879
+
880
+ drain_pending_requests_with_error
881
+ end
882
+
883
+ def close_all_clients
884
+ clients_to_close = []
885
+
886
+ @clients_mutex.synchronize do
887
+ clients_to_close = @clients.dup
888
+ end
889
+
890
+ clients_to_close.each do |client|
891
+ client.close if client.respond_to?(:close)
892
+ rescue StandardError => e
893
+ RubyLLM::MCP.logger.debug "Error closing HTTPX client: #{e.message}"
894
+ end
895
+ end
896
+
897
+ def cleanup_connection
898
+ close_all_clients
899
+
900
+ @clients_mutex.synchronize do
901
+ @clients.clear
902
+ end
903
+
904
+ @connection = nil
905
+ end
906
+
907
+ def drain_pending_requests_with_error
908
+ shutdown_error = Errors::TransportError.new(
909
+ message: "Transport is shutting down",
910
+ code: nil
911
+ )
912
+
913
+ @pending_mutex.synchronize do
914
+ @pending_requests.each_value do |queue|
915
+ queue.push(shutdown_error)
916
+ rescue StandardError => e
917
+ RubyLLM::MCP.logger.debug "Error pushing shutdown error to queue: #{e.message}"
918
+ end
919
+ @pending_requests.clear
920
+ end
921
+ end
922
+ end
923
+ end
924
+ end
925
+ end
926
+ end