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,607 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Native
6
+ module Transports
7
+ class SSE
8
+ include Support::Timeout
9
+
10
+ attr_reader :headers, :id, :coordinator
11
+
12
+ def initialize(url:, coordinator:, request_timeout:, options: {})
13
+ @event_url = url
14
+ @messages_url = nil
15
+ @coordinator = coordinator
16
+ @request_timeout = request_timeout
17
+
18
+ # Extract options
19
+ extracted_options = options.dup
20
+ @version = extracted_options.delete(:version) || :http2
21
+ headers = extracted_options.delete(:headers) || {}
22
+ oauth_provider = extracted_options.delete(:oauth_provider)
23
+
24
+ uri = URI.parse(url)
25
+ @root_url = "#{uri.scheme}://#{uri.host}"
26
+ @root_url += ":#{uri.port}" if uri.port != uri.default_port
27
+
28
+ @client_id = SecureRandom.uuid
29
+ @headers = headers.merge({
30
+ "Accept" => "text/event-stream",
31
+ "Content-Type" => "application/json",
32
+ "Cache-Control" => "no-cache",
33
+ "X-CLIENT-ID" => @client_id
34
+ })
35
+
36
+ @oauth_provider = oauth_provider
37
+ @resource_metadata_url = nil
38
+ @auth_retry_attempted = false
39
+
40
+ @id_counter = 0
41
+ @id_mutex = Mutex.new
42
+ @pending_requests = {}
43
+ @pending_mutex = Mutex.new
44
+ @connection_mutex = Mutex.new
45
+ @state_mutex = Mutex.new
46
+ @running = false
47
+ @sse_thread = nil
48
+ @sse_response = nil
49
+
50
+ RubyLLM::MCP.logger.info "Initializing SSE transport to #{@event_url} with client ID #{@client_id}"
51
+ end
52
+
53
+ def request(body, wait_for_response: true) # rubocop:disable Metrics/MethodLength
54
+ request_id = body.is_a?(Hash) ? (body["id"] || body[:id]) : nil
55
+
56
+ if wait_for_response && request_id.nil?
57
+ raise ArgumentError, "Request ID must be provided in message body when wait_for_response is true"
58
+ end
59
+
60
+ response_queue = nil
61
+ if wait_for_response
62
+ response_queue = Queue.new
63
+ @pending_mutex.synchronize do
64
+ @pending_requests[request_id.to_s] = response_queue
65
+ end
66
+ end
67
+
68
+ begin
69
+ send_request(body, request_id)
70
+ rescue Errors::TransportError, Errors::TimeoutError => e
71
+ if wait_for_response && request_id
72
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
73
+ end
74
+ RubyLLM::MCP.logger.error "Request error (ID: #{request_id}): #{e.message}"
75
+ raise e
76
+ end
77
+
78
+ return unless wait_for_response
79
+
80
+ result = nil
81
+ begin
82
+ result = with_timeout(@request_timeout / 1000, request_id: request_id) do
83
+ response_queue.pop
84
+ end
85
+ rescue Errors::TimeoutError => e
86
+ if request_id
87
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
88
+ end
89
+ RubyLLM::MCP.logger.error "SSE request timeout (ID: #{request_id}) \
90
+ after #{@request_timeout / 1000} seconds."
91
+ raise e
92
+ end
93
+
94
+ raise result if result.is_a?(Errors::TransportError)
95
+
96
+ result
97
+ end
98
+
99
+ def alive?
100
+ running?
101
+ end
102
+
103
+ def running?
104
+ @state_mutex.synchronize { @running }
105
+ end
106
+
107
+ def start
108
+ @state_mutex.synchronize do
109
+ return if @running
110
+
111
+ @running = true
112
+ end
113
+
114
+ start_sse_listener
115
+ end
116
+
117
+ def close
118
+ should_close = @state_mutex.synchronize do
119
+ return unless @running
120
+
121
+ @running = false
122
+ true
123
+ end
124
+
125
+ return unless should_close
126
+
127
+ RubyLLM::MCP.logger.info "Closing SSE transport connection"
128
+
129
+ # Close the SSE response stream if it exists
130
+ begin
131
+ @sse_response&.body&.close
132
+ rescue StandardError => e
133
+ RubyLLM::MCP.logger.debug "Error closing SSE response: #{e.message}"
134
+ end
135
+
136
+ # Wait for the thread to finish (but don't join from within itself)
137
+ if @sse_thread && Thread.current != @sse_thread
138
+ @sse_thread.join(1)
139
+ end
140
+ @sse_thread = nil
141
+
142
+ fail_pending_requests!(
143
+ Errors::TransportError.new(
144
+ message: "SSE transport closed",
145
+ code: nil
146
+ )
147
+ )
148
+
149
+ @messages_url = nil
150
+ end
151
+
152
+ def set_protocol_version(version)
153
+ @protocol_version = version
154
+ end
155
+
156
+ private
157
+
158
+ def send_request(body, request_id)
159
+ headers = build_request_headers
160
+ http_client = Support::HTTPClient.connection.with(timeout: { request_timeout: @request_timeout / 1000 },
161
+ headers: headers)
162
+ response = http_client.post(@messages_url, body: JSON.generate(body))
163
+ handle_httpx_error_response!(response,
164
+ context: { location: "message endpoint request", request_id: request_id })
165
+
166
+ case response.status
167
+ when 200, 202
168
+ # Success
169
+ nil
170
+ when 401
171
+ handle_authentication_challenge(response, body, request_id)
172
+ else
173
+ message = "Failed to have a successful request to #{@messages_url}: #{response.status} - #{response.body}"
174
+ RubyLLM::MCP.logger.error(message)
175
+ raise Errors::TransportError.new(
176
+ message: message,
177
+ code: response.status
178
+ )
179
+ end
180
+ end
181
+
182
+ def build_request_headers
183
+ headers = @headers.dup
184
+
185
+ # Apply OAuth authorization if available
186
+ if @oauth_provider
187
+ RubyLLM::MCP.logger.debug "OAuth provider present, attempting to get token..."
188
+ token = @oauth_provider.access_token
189
+ if token
190
+ headers["Authorization"] = token.to_header
191
+ RubyLLM::MCP.logger.debug "Applied OAuth authorization header"
192
+ else
193
+ RubyLLM::MCP.logger.warn "OAuth provider present but no valid token available!"
194
+ end
195
+ end
196
+
197
+ headers
198
+ end
199
+
200
+ def handle_authentication_challenge(response, original_body, request_id)
201
+ check_retry_guard!
202
+ check_oauth_provider_configured!
203
+
204
+ RubyLLM::MCP.logger.info("Received 401 Unauthorized, attempting automatic authentication")
205
+
206
+ www_authenticate = response.headers["www-authenticate"]
207
+ resource_metadata_url = response.headers["mcp-resource-metadata-url"]
208
+ @resource_metadata_url = resource_metadata_url if resource_metadata_url
209
+
210
+ attempt_authentication_retry(www_authenticate, resource_metadata_url, original_body, request_id)
211
+ end
212
+
213
+ def check_retry_guard!
214
+ return unless @auth_retry_attempted
215
+
216
+ RubyLLM::MCP.logger.warn("Authentication retry already attempted, raising error")
217
+ @auth_retry_attempted = false
218
+ raise Errors::AuthenticationRequiredError.new(
219
+ message: "OAuth authentication required (401 Unauthorized) - retry failed"
220
+ )
221
+ end
222
+
223
+ def check_oauth_provider_configured!
224
+ return if @oauth_provider
225
+
226
+ raise Errors::AuthenticationRequiredError.new(
227
+ message: "OAuth authentication required (401 Unauthorized) but no OAuth provider configured"
228
+ )
229
+ end
230
+
231
+ def attempt_authentication_retry(www_authenticate, resource_metadata_url, original_body, request_id)
232
+ @auth_retry_attempted = true
233
+
234
+ success = @oauth_provider.handle_authentication_challenge(
235
+ www_authenticate: www_authenticate,
236
+ resource_metadata_url: resource_metadata_url,
237
+ requested_scope: nil
238
+ )
239
+
240
+ if success
241
+ RubyLLM::MCP.logger.info("Authentication challenge handled successfully, retrying request")
242
+ send_request(original_body, request_id)
243
+ @auth_retry_attempted = false
244
+ return
245
+ end
246
+
247
+ @auth_retry_attempted = false
248
+ raise Errors::AuthenticationRequiredError.new(
249
+ message: "OAuth authentication required (401 Unauthorized)"
250
+ )
251
+ rescue Errors::AuthenticationRequiredError => e
252
+ @auth_retry_attempted = false
253
+ raise e
254
+ rescue StandardError => e
255
+ @auth_retry_attempted = false
256
+ RubyLLM::MCP.logger.error("Authentication challenge handling failed: #{e.message}")
257
+ raise Errors::AuthenticationRequiredError.new(
258
+ message: "OAuth authentication failed: #{e.message}"
259
+ )
260
+ end
261
+
262
+ def start_sse_listener
263
+ @connection_mutex.synchronize do # rubocop:disable Metrics/BlockLength
264
+ return if sse_thread_running?
265
+
266
+ RubyLLM::MCP.logger.info "Starting SSE listener thread"
267
+
268
+ response_queue = Queue.new
269
+ @pending_mutex.synchronize do
270
+ @pending_requests["endpoint"] = response_queue
271
+ end
272
+
273
+ @sse_thread = Thread.new do
274
+ listen_for_events
275
+ end
276
+ @sse_thread.abort_on_exception = true
277
+
278
+ begin
279
+ with_timeout(@request_timeout / 1000) do
280
+ endpoint = response_queue.pop
281
+ set_message_endpoint(endpoint)
282
+ end
283
+ rescue Errors::TimeoutError => e
284
+ @pending_mutex.synchronize do
285
+ @pending_requests.delete("endpoint")
286
+ end
287
+ RubyLLM::MCP.logger.error "Timeout waiting for endpoint event: #{e.message}"
288
+ raise e
289
+ rescue StandardError => e
290
+ @pending_mutex.synchronize do
291
+ @pending_requests.delete("endpoint")
292
+ end
293
+ raise e
294
+ end
295
+ end
296
+ end
297
+
298
+ def set_message_endpoint(endpoint)
299
+ endpoint_url = if endpoint.is_a?(String)
300
+ endpoint
301
+ elsif endpoint.is_a?(Hash)
302
+ # Support richer endpoint metadata (e.g., { "url": "...", "last_event_id": "..." })
303
+ endpoint["url"] || endpoint[:url]
304
+ else
305
+ endpoint.to_s
306
+ end
307
+
308
+ unless endpoint_url && !endpoint_url.empty?
309
+ raise Errors::TransportError.new(
310
+ message: "Invalid endpoint event: missing URL",
311
+ code: nil
312
+ )
313
+ end
314
+
315
+ uri = URI.parse(endpoint_url)
316
+
317
+ @messages_url = if uri.host.nil?
318
+ "#{@root_url}#{endpoint_url}"
319
+ else
320
+ endpoint_url
321
+ end
322
+
323
+ RubyLLM::MCP.logger.info "SSE message endpoint set to: #{@messages_url}"
324
+ rescue URI::InvalidURIError => e
325
+ raise Errors::TransportError.new(
326
+ message: "Invalid endpoint URL: #{e.message}",
327
+ code: nil
328
+ )
329
+ end
330
+
331
+ def sse_thread_running?
332
+ @sse_thread&.alive?
333
+ end
334
+
335
+ def listen_for_events
336
+ stream_events_from_server while running?
337
+ rescue StandardError => e
338
+ handle_connection_error("SSE connection error", e)
339
+ end
340
+
341
+ def stream_events_from_server
342
+ sse_client = create_sse_client
343
+ @sse_response = sse_client.get(@event_url, stream: true)
344
+ validate_sse_response!(@sse_response)
345
+ process_event_stream(@sse_response)
346
+ end
347
+
348
+ def create_sse_client
349
+ sse_client = HTTPX.plugin(:stream).with(headers: @headers)
350
+ return sse_client unless @version == :http1
351
+
352
+ sse_client.with(ssl: { alpn_protocols: ["http/1.1"] })
353
+ end
354
+
355
+ def validate_sse_response!(response)
356
+ return unless response.status >= 400
357
+
358
+ # Handle 401 specially for OAuth
359
+ if response.status == 401
360
+ handle_sse_authentication_challenge(response)
361
+ return
362
+ end
363
+
364
+ error_body = read_error_body(response)
365
+ error_message = "HTTP #{response.status} error from SSE endpoint: #{error_body}"
366
+ RubyLLM::MCP.logger.error error_message
367
+
368
+ handle_client_error!(error_message, response.status) if response.status < 500
369
+
370
+ raise StandardError, error_message
371
+ end
372
+
373
+ def handle_sse_authentication_challenge(response)
374
+ unless @oauth_provider
375
+ raise Errors::AuthenticationRequiredError.new(
376
+ message: "OAuth authentication required for SSE stream but no OAuth provider configured"
377
+ )
378
+ end
379
+
380
+ RubyLLM::MCP.logger.info("SSE stream received 401, attempting authentication")
381
+
382
+ www_authenticate = response.headers["www-authenticate"]
383
+ resource_metadata_url = response.headers["mcp-resource-metadata-url"]
384
+
385
+ begin
386
+ success = @oauth_provider.handle_authentication_challenge(
387
+ www_authenticate: www_authenticate,
388
+ resource_metadata_url: resource_metadata_url,
389
+ requested_scope: nil
390
+ )
391
+
392
+ if success
393
+ RubyLLM::MCP.logger.info("Authentication successful, SSE stream will reconnect")
394
+ # The caller will retry the SSE connection
395
+ return
396
+ end
397
+ rescue Errors::AuthenticationRequiredError
398
+ raise
399
+ rescue StandardError => e
400
+ RubyLLM::MCP.logger.error("SSE authentication failed: #{e.message}")
401
+ end
402
+
403
+ raise Errors::AuthenticationRequiredError.new(
404
+ message: "OAuth authentication required for SSE stream"
405
+ )
406
+ end
407
+
408
+ def handle_client_error!(error_message, status_code)
409
+ transport_error = Errors::TransportError.new(
410
+ message: error_message,
411
+ code: status_code
412
+ )
413
+ close
414
+
415
+ raise transport_error
416
+ end
417
+
418
+ def fail_pending_requests!(error)
419
+ @pending_mutex.synchronize do
420
+ @pending_requests.each_value do |queue|
421
+ queue.push(error)
422
+ end
423
+ @pending_requests.clear
424
+ end
425
+ end
426
+
427
+ def process_event_stream(response)
428
+ event_buffer = []
429
+ response.each_line do |event_line|
430
+ break unless handle_event_line?(event_line, event_buffer, response)
431
+ end
432
+ end
433
+
434
+ def handle_event_line?(event_line, event_buffer, response)
435
+ unless running?
436
+ response.body.close
437
+ return false
438
+ end
439
+
440
+ line = event_line.strip
441
+
442
+ if line.empty?
443
+ process_buffered_event(event_buffer)
444
+ else
445
+ event_buffer << line
446
+ end
447
+
448
+ true
449
+ end
450
+
451
+ def process_buffered_event(event_buffer)
452
+ return unless event_buffer.any?
453
+
454
+ events = parse_event(event_buffer.join("\n"))
455
+ events.each { |event| process_event(event) }
456
+ event_buffer.clear
457
+ end
458
+
459
+ def read_error_body(response)
460
+ body = ""
461
+ begin
462
+ response.each do |chunk|
463
+ body << chunk
464
+ end
465
+ rescue StandardError
466
+ # If we can't read the body, just use what we have
467
+ end
468
+ body.strip.empty? ? "No error details provided" : body.strip
469
+ end
470
+
471
+ def handle_connection_error(message, error)
472
+ return unless running?
473
+
474
+ error_message = "#{message}: #{error.message}"
475
+ RubyLLM::MCP.logger.error "#{error_message}. Closing SSE transport."
476
+
477
+ close
478
+ end
479
+
480
+ def handle_httpx_error_response!(response, context:)
481
+ return false unless response.is_a?(HTTPX::ErrorResponse)
482
+
483
+ error = response.error
484
+
485
+ if error.is_a?(HTTPX::ReadTimeoutError)
486
+ raise Errors::TimeoutError.new(
487
+ message: "Request timed out after #{@request_timeout / 1000} seconds"
488
+ )
489
+ end
490
+
491
+ error_message = response.error&.message || "Request failed"
492
+
493
+ raise Errors::TransportError.new(
494
+ code: nil,
495
+ message: "Request Error #{context}: #{error_message}"
496
+ )
497
+ end
498
+
499
+ def process_event(raw_event)
500
+ return if raw_event[:data].nil?
501
+
502
+ if raw_event[:event] == "endpoint"
503
+ process_endpoint_event(raw_event)
504
+ else
505
+ process_message_event(raw_event)
506
+ end
507
+ end
508
+
509
+ def process_endpoint_event(raw_event)
510
+ request_id = "endpoint"
511
+ event_data = raw_event[:data]
512
+ return if event_data.nil?
513
+
514
+ endpoint = begin
515
+ JSON.parse(event_data)
516
+ rescue JSON::ParserError
517
+ event_data
518
+ end
519
+
520
+ RubyLLM::MCP.logger.debug "Received endpoint event: #{endpoint.inspect}"
521
+
522
+ @pending_mutex.synchronize do
523
+ response_queue = @pending_requests.delete(request_id)
524
+ response_queue&.push(endpoint)
525
+ end
526
+ end
527
+
528
+ def process_message_event(raw_event)
529
+ event = parse_and_validate_event(raw_event[:data])
530
+ return if event.nil?
531
+
532
+ request_id = event["id"]&.to_s
533
+ result = RubyLLM::MCP::Result.new(event)
534
+
535
+ result = @coordinator.process_result(result)
536
+ return if result.nil?
537
+
538
+ return if request_id.nil?
539
+
540
+ response_queue = nil
541
+ matching_result = false
542
+
543
+ @pending_mutex.synchronize do
544
+ if @pending_requests.key?(request_id)
545
+ matching_result = if result.is_a?(RubyLLM::MCP::Result)
546
+ result.matching_id?(request_id)
547
+ else
548
+ true
549
+ end
550
+
551
+ response_queue = @pending_requests.delete(request_id) if matching_result
552
+ else
553
+ matching_result = false
554
+ end
555
+ end
556
+
557
+ response_queue&.push(result) if matching_result
558
+ end
559
+
560
+ def parse_and_validate_event(data)
561
+ event = JSON.parse(data)
562
+
563
+ # Validate JSON-RPC envelope
564
+ validator = Native::JsonRpc::EnvelopeValidator.new(event)
565
+ unless validator.valid?
566
+ RubyLLM::MCP.logger.error(
567
+ "Invalid JSON-RPC envelope in SSE event: #{validator.error_message}\nRaw: #{data}"
568
+ )
569
+ # SSE is unidirectional from server to client, so we can't send error responses back
570
+ return nil
571
+ end
572
+
573
+ event
574
+ rescue JSON::ParserError => e
575
+ # Partial endpoint events can arrive while establishing the stream; log once we know the URL.
576
+ if @messages_url
577
+ RubyLLM::MCP.logger.debug "Failed to parse SSE event data: #{data} - #{e.message}"
578
+ end
579
+ nil
580
+ end
581
+
582
+ def parse_event(raw)
583
+ event_blocks = raw.split(/\n\s*\n/)
584
+
585
+ events = event_blocks.map do |event_block|
586
+ event = {}
587
+ event_block.each_line do |line|
588
+ case line
589
+ when /^data:\s*(.*)/
590
+ (event[:data] ||= []) << ::Regexp.last_match(1)
591
+ when /^event:\s*(.*)/
592
+ event[:event] = ::Regexp.last_match(1)
593
+ when /^id:\s*(.*)/
594
+ event[:id] = ::Regexp.last_match(1)
595
+ end
596
+ end
597
+ event[:data] = event[:data]&.join("\n")
598
+ event
599
+ end
600
+
601
+ events.reject { |event| event.empty? || event[:data].nil? }
602
+ end
603
+ end
604
+ end
605
+ end
606
+ end
607
+ end