ruby_llm-mcp 0.7.1 → 1.0.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -162
  3. data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
  4. data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +21 -4
  5. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
  6. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  16. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  17. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  18. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  19. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +215 -0
  20. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +413 -0
  21. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +41 -0
  22. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +56 -0
  23. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +56 -0
  24. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +90 -0
  25. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +216 -0
  26. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  27. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +36 -0
  28. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
  29. data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
  30. data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
  31. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +427 -0
  32. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  33. data/lib/ruby_llm/mcp/auth/discoverer.rb +255 -0
  34. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +122 -0
  35. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +67 -0
  36. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  37. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  38. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  39. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
  40. data/lib/ruby_llm/mcp/auth/memory_storage.rb +91 -0
  41. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +341 -0
  42. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  43. data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
  44. data/lib/ruby_llm/mcp/auth/token_manager.rb +307 -0
  45. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  46. data/lib/ruby_llm/mcp/auth/url_builder.rb +135 -0
  47. data/lib/ruby_llm/mcp/auth.rb +371 -0
  48. data/lib/ruby_llm/mcp/client.rb +312 -35
  49. data/lib/ruby_llm/mcp/configuration.rb +199 -24
  50. data/lib/ruby_llm/mcp/elicitation.rb +261 -14
  51. data/lib/ruby_llm/mcp/errors.rb +29 -0
  52. data/lib/ruby_llm/mcp/extensions/apps/constants.rb +28 -0
  53. data/lib/ruby_llm/mcp/extensions/apps/resource_metadata.rb +24 -0
  54. data/lib/ruby_llm/mcp/extensions/apps/tool_metadata.rb +45 -0
  55. data/lib/ruby_llm/mcp/extensions/configuration.rb +72 -0
  56. data/lib/ruby_llm/mcp/extensions/constants.rb +16 -0
  57. data/lib/ruby_llm/mcp/extensions/registry.rb +85 -0
  58. data/lib/ruby_llm/mcp/handlers/approval_decision.rb +90 -0
  59. data/lib/ruby_llm/mcp/handlers/async_response.rb +181 -0
  60. data/lib/ruby_llm/mcp/handlers/concerns/approval_actions.rb +42 -0
  61. data/lib/ruby_llm/mcp/handlers/concerns/async_execution.rb +80 -0
  62. data/lib/ruby_llm/mcp/handlers/concerns/elicitation_actions.rb +42 -0
  63. data/lib/ruby_llm/mcp/handlers/concerns/error_handling.rb +29 -0
  64. data/lib/ruby_llm/mcp/handlers/concerns/guard_checks.rb +72 -0
  65. data/lib/ruby_llm/mcp/handlers/concerns/lifecycle.rb +84 -0
  66. data/lib/ruby_llm/mcp/handlers/concerns/logging.rb +19 -0
  67. data/lib/ruby_llm/mcp/handlers/concerns/model_filtering.rb +36 -0
  68. data/lib/ruby_llm/mcp/handlers/concerns/options.rb +83 -0
  69. data/lib/ruby_llm/mcp/handlers/concerns/registry_integration.rb +54 -0
  70. data/lib/ruby_llm/mcp/handlers/concerns/sampling_actions.rb +84 -0
  71. data/lib/ruby_llm/mcp/handlers/concerns/timeouts.rb +52 -0
  72. data/lib/ruby_llm/mcp/handlers/concerns/tool_filtering.rb +50 -0
  73. data/lib/ruby_llm/mcp/handlers/elicitation_handler.rb +58 -0
  74. data/lib/ruby_llm/mcp/handlers/elicitation_registry.rb +203 -0
  75. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_handler.rb +93 -0
  76. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_registry.rb +271 -0
  77. data/lib/ruby_llm/mcp/handlers/promise.rb +192 -0
  78. data/lib/ruby_llm/mcp/handlers/sampling_handler.rb +64 -0
  79. data/lib/ruby_llm/mcp/handlers.rb +14 -0
  80. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +94 -0
  81. data/lib/ruby_llm/mcp/native/client.rb +551 -0
  82. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  83. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  84. data/lib/ruby_llm/mcp/native/messages/notifications.rb +60 -0
  85. data/lib/ruby_llm/mcp/native/messages/requests.rb +267 -0
  86. data/lib/ruby_llm/mcp/native/messages/responses.rb +114 -0
  87. data/lib/ruby_llm/mcp/native/messages.rb +43 -0
  88. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  89. data/lib/ruby_llm/mcp/native/protocol.rb +79 -0
  90. data/lib/ruby_llm/mcp/native/response_handler.rb +220 -0
  91. data/lib/ruby_llm/mcp/native/task_registry.rb +62 -0
  92. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  93. data/lib/ruby_llm/mcp/native/transports/sse.rb +655 -0
  94. data/lib/ruby_llm/mcp/native/transports/stdio.rb +367 -0
  95. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +1024 -0
  96. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  97. data/lib/ruby_llm/mcp/native/transports/support/rate_limiter.rb +49 -0
  98. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  99. data/lib/ruby_llm/mcp/native.rb +12 -0
  100. data/lib/ruby_llm/mcp/notification_handler.rb +43 -5
  101. data/lib/ruby_llm/mcp/prompt.rb +7 -7
  102. data/lib/ruby_llm/mcp/railtie.rb +7 -13
  103. data/lib/ruby_llm/mcp/resource.rb +17 -8
  104. data/lib/ruby_llm/mcp/resource_template.rb +8 -7
  105. data/lib/ruby_llm/mcp/result.rb +8 -4
  106. data/lib/ruby_llm/mcp/roots.rb +4 -4
  107. data/lib/ruby_llm/mcp/sample.rb +83 -13
  108. data/lib/ruby_llm/mcp/schema_validator.rb +33 -0
  109. data/lib/ruby_llm/mcp/server_capabilities.rb +41 -0
  110. data/lib/ruby_llm/mcp/task.rb +65 -0
  111. data/lib/ruby_llm/mcp/tool.rb +33 -27
  112. data/lib/ruby_llm/mcp/version.rb +1 -1
  113. data/lib/ruby_llm/mcp.rb +37 -7
  114. data/lib/tasks/smoke.rake +66 -0
  115. metadata +115 -39
  116. data/lib/generators/ruby_llm/mcp/templates/mcps.yml +0 -9
  117. data/lib/ruby_llm/mcp/coordinator.rb +0 -293
  118. data/lib/ruby_llm/mcp/notifications/cancelled.rb +0 -32
  119. data/lib/ruby_llm/mcp/notifications/initialize.rb +0 -24
  120. data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +0 -26
  121. data/lib/ruby_llm/mcp/protocol.rb +0 -34
  122. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +0 -50
  123. data/lib/ruby_llm/mcp/requests/completion_resource.rb +0 -50
  124. data/lib/ruby_llm/mcp/requests/initialization.rb +0 -34
  125. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +0 -28
  126. data/lib/ruby_llm/mcp/requests/ping.rb +0 -24
  127. data/lib/ruby_llm/mcp/requests/prompt_call.rb +0 -32
  128. data/lib/ruby_llm/mcp/requests/prompt_list.rb +0 -31
  129. data/lib/ruby_llm/mcp/requests/resource_list.rb +0 -31
  130. data/lib/ruby_llm/mcp/requests/resource_read.rb +0 -30
  131. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +0 -31
  132. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +0 -30
  133. data/lib/ruby_llm/mcp/requests/shared/meta.rb +0 -32
  134. data/lib/ruby_llm/mcp/requests/shared/pagination.rb +0 -17
  135. data/lib/ruby_llm/mcp/requests/tool_call.rb +0 -35
  136. data/lib/ruby_llm/mcp/requests/tool_list.rb +0 -31
  137. data/lib/ruby_llm/mcp/response_handler.rb +0 -67
  138. data/lib/ruby_llm/mcp/responses/elicitation.rb +0 -33
  139. data/lib/ruby_llm/mcp/responses/error.rb +0 -33
  140. data/lib/ruby_llm/mcp/responses/ping.rb +0 -28
  141. data/lib/ruby_llm/mcp/responses/roots_list.rb +0 -31
  142. data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +0 -50
  143. data/lib/ruby_llm/mcp/transport.rb +0 -58
  144. data/lib/ruby_llm/mcp/transports/sse.rb +0 -341
  145. data/lib/ruby_llm/mcp/transports/stdio.rb +0 -230
  146. data/lib/ruby_llm/mcp/transports/streamable_http.rb +0 -723
  147. data/lib/ruby_llm/mcp/transports/support/http_client.rb +0 -28
  148. data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +0 -47
  149. data/lib/ruby_llm/mcp/transports/support/timeout.rb +0 -34
@@ -0,0 +1,655 @@
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
+ when 403
173
+ handle_authorization_challenge(response, body, request_id)
174
+ else
175
+ message = "Failed to have a successful request to #{@messages_url}: #{response.status} - #{response.body}"
176
+ RubyLLM::MCP.logger.error(message)
177
+ raise Errors::TransportError.new(
178
+ message: message,
179
+ code: response.status
180
+ )
181
+ end
182
+ end
183
+
184
+ def build_request_headers
185
+ headers = @headers.dup
186
+
187
+ # Apply OAuth authorization if available
188
+ if @oauth_provider
189
+ RubyLLM::MCP.logger.debug "OAuth provider present, attempting to get token..."
190
+ token = @oauth_provider.access_token
191
+ if token
192
+ headers["Authorization"] = token.to_header
193
+ RubyLLM::MCP.logger.debug "Applied OAuth authorization header"
194
+ else
195
+ RubyLLM::MCP.logger.warn "OAuth provider present but no valid token available!"
196
+ end
197
+ end
198
+
199
+ headers
200
+ end
201
+
202
+ def handle_authentication_challenge(response, original_body, request_id)
203
+ check_retry_guard!
204
+ check_oauth_provider_configured!
205
+
206
+ RubyLLM::MCP.logger.info("Received 401 Unauthorized, attempting automatic authentication")
207
+
208
+ www_authenticate = response.headers["www-authenticate"]
209
+ resource_metadata_url = response.headers["mcp-resource-metadata-url"]
210
+ @resource_metadata_url = resource_metadata_url if resource_metadata_url
211
+
212
+ attempt_authentication_retry(www_authenticate, resource_metadata_url, original_body, request_id)
213
+ end
214
+
215
+ def handle_authorization_challenge(response, original_body, request_id)
216
+ unless @oauth_provider && response.headers["www-authenticate"]
217
+ message = "Failed to have a successful request to #{@messages_url}: #{response.status} - #{response.body}"
218
+ RubyLLM::MCP.logger.error(message)
219
+ raise Errors::TransportError.new(
220
+ message: message,
221
+ code: response.status
222
+ )
223
+ end
224
+
225
+ check_retry_guard!(status_context: "403 Forbidden")
226
+ check_oauth_provider_configured!(status_context: "403 Forbidden")
227
+
228
+ RubyLLM::MCP.logger.info("Received 403 Forbidden with OAuth challenge, attempting automatic authentication")
229
+
230
+ www_authenticate = response.headers["www-authenticate"]
231
+ resource_metadata_url = response.headers["mcp-resource-metadata-url"]
232
+ @resource_metadata_url = resource_metadata_url if resource_metadata_url
233
+
234
+ attempt_authentication_retry(
235
+ www_authenticate,
236
+ resource_metadata_url,
237
+ original_body,
238
+ request_id,
239
+ status_context: "403 Forbidden"
240
+ )
241
+ end
242
+
243
+ def check_retry_guard!(status_context: "401 Unauthorized")
244
+ return unless @auth_retry_attempted
245
+
246
+ RubyLLM::MCP.logger.warn("Authentication retry already attempted, raising error")
247
+ @auth_retry_attempted = false
248
+ raise Errors::AuthenticationRequiredError.new(
249
+ message: "OAuth authentication required (#{status_context}) - retry failed"
250
+ )
251
+ end
252
+
253
+ def check_oauth_provider_configured!(status_context: "401 Unauthorized")
254
+ return if @oauth_provider
255
+
256
+ raise Errors::AuthenticationRequiredError.new(
257
+ message: "OAuth authentication required (#{status_context}) but no OAuth provider configured"
258
+ )
259
+ end
260
+
261
+ def attempt_authentication_retry(www_authenticate, resource_metadata_url, original_body, request_id,
262
+ status_context: "401 Unauthorized")
263
+ @auth_retry_attempted = true
264
+
265
+ success = @oauth_provider.handle_authentication_challenge(
266
+ www_authenticate: www_authenticate,
267
+ resource_metadata: resource_metadata_url,
268
+ resource_metadata_url: resource_metadata_url,
269
+ requested_scope: nil
270
+ )
271
+
272
+ if success
273
+ RubyLLM::MCP.logger.info("Authentication challenge handled successfully, retrying request")
274
+ send_request(original_body, request_id)
275
+ @auth_retry_attempted = false
276
+ return
277
+ end
278
+
279
+ @auth_retry_attempted = false
280
+ raise Errors::AuthenticationRequiredError.new(
281
+ message: "OAuth authentication required (#{status_context})"
282
+ )
283
+ rescue Errors::AuthenticationRequiredError => e
284
+ @auth_retry_attempted = false
285
+ raise e
286
+ rescue StandardError => e
287
+ @auth_retry_attempted = false
288
+ RubyLLM::MCP.logger.error("Authentication challenge handling failed: #{e.message}")
289
+ raise Errors::AuthenticationRequiredError.new(
290
+ message: "OAuth authentication failed: #{e.message}"
291
+ )
292
+ end
293
+
294
+ def start_sse_listener
295
+ @connection_mutex.synchronize do # rubocop:disable Metrics/BlockLength
296
+ return if sse_thread_running?
297
+
298
+ RubyLLM::MCP.logger.info "Starting SSE listener thread"
299
+
300
+ response_queue = Queue.new
301
+ @pending_mutex.synchronize do
302
+ @pending_requests["endpoint"] = response_queue
303
+ end
304
+
305
+ @sse_thread = Thread.new do
306
+ listen_for_events
307
+ end
308
+ @sse_thread.abort_on_exception = true
309
+
310
+ begin
311
+ with_timeout(@request_timeout / 1000) do
312
+ endpoint = response_queue.pop
313
+ raise endpoint if endpoint.is_a?(StandardError)
314
+
315
+ set_message_endpoint(endpoint)
316
+ end
317
+ rescue Errors::TimeoutError => e
318
+ @pending_mutex.synchronize do
319
+ @pending_requests.delete("endpoint")
320
+ end
321
+ RubyLLM::MCP.logger.error "Timeout waiting for endpoint event: #{e.message}"
322
+ raise e
323
+ rescue StandardError => e
324
+ @pending_mutex.synchronize do
325
+ @pending_requests.delete("endpoint")
326
+ end
327
+ raise e
328
+ end
329
+ end
330
+ end
331
+
332
+ def set_message_endpoint(endpoint)
333
+ endpoint_url = if endpoint.is_a?(String)
334
+ endpoint
335
+ elsif endpoint.is_a?(Hash)
336
+ # Support richer endpoint metadata (e.g., { "url": "...", "last_event_id": "..." })
337
+ endpoint["url"] || endpoint[:url]
338
+ else
339
+ endpoint.to_s
340
+ end
341
+
342
+ unless endpoint_url && !endpoint_url.empty?
343
+ raise Errors::TransportError.new(
344
+ message: "Invalid endpoint event: missing URL",
345
+ code: nil
346
+ )
347
+ end
348
+
349
+ uri = URI.parse(endpoint_url)
350
+
351
+ @messages_url = if uri.host.nil?
352
+ "#{@root_url}#{endpoint_url}"
353
+ else
354
+ endpoint_url
355
+ end
356
+
357
+ RubyLLM::MCP.logger.info "SSE message endpoint set to: #{@messages_url}"
358
+ rescue URI::InvalidURIError => e
359
+ raise Errors::TransportError.new(
360
+ message: "Invalid endpoint URL: #{e.message}",
361
+ code: nil
362
+ )
363
+ end
364
+
365
+ def sse_thread_running?
366
+ @sse_thread&.alive?
367
+ end
368
+
369
+ def listen_for_events
370
+ stream_events_from_server while running?
371
+ rescue StandardError => e
372
+ handle_connection_error("SSE connection error", e)
373
+ end
374
+
375
+ def stream_events_from_server
376
+ sse_client = create_sse_client
377
+ @sse_response = sse_client.get(@event_url, stream: true)
378
+ validate_sse_response!(@sse_response)
379
+ process_event_stream(@sse_response)
380
+ end
381
+
382
+ def create_sse_client
383
+ headers = @headers.dup
384
+ if @oauth_provider
385
+ token = @oauth_provider.access_token
386
+ if token
387
+ headers["Authorization"] = token.to_header
388
+ RubyLLM::MCP.logger.debug("Applied OAuth authorization header to SSE stream request")
389
+ else
390
+ RubyLLM::MCP.logger.warn("OAuth provider present but no valid token available for SSE stream request")
391
+ end
392
+ end
393
+
394
+ sse_client = HTTPX.plugin(:stream).with(headers: headers)
395
+ return sse_client unless @version == :http1
396
+
397
+ sse_client.with(ssl: { alpn_protocols: ["http/1.1"] })
398
+ end
399
+
400
+ def validate_sse_response!(response)
401
+ return unless response.status >= 400
402
+
403
+ # Handle 401 specially for OAuth
404
+ if response.status == 401 || (response.status == 403 && response.headers["www-authenticate"])
405
+ handle_sse_authentication_challenge(response)
406
+ return
407
+ end
408
+
409
+ error_body = read_error_body(response)
410
+ error_message = "HTTP #{response.status} error from SSE endpoint: #{error_body}"
411
+ RubyLLM::MCP.logger.error error_message
412
+
413
+ handle_client_error!(error_message, response.status) if response.status < 500
414
+
415
+ raise StandardError, error_message
416
+ end
417
+
418
+ def handle_sse_authentication_challenge(response)
419
+ unless @oauth_provider
420
+ raise Errors::AuthenticationRequiredError.new(
421
+ message: "OAuth authentication required for SSE stream but no OAuth provider configured"
422
+ )
423
+ end
424
+
425
+ RubyLLM::MCP.logger.info("SSE stream received #{response.status}, attempting authentication")
426
+
427
+ www_authenticate = response.headers["www-authenticate"]
428
+ resource_metadata_url = response.headers["mcp-resource-metadata-url"]
429
+
430
+ begin
431
+ success = @oauth_provider.handle_authentication_challenge(
432
+ www_authenticate: www_authenticate,
433
+ resource_metadata: resource_metadata_url,
434
+ resource_metadata_url: resource_metadata_url,
435
+ requested_scope: nil
436
+ )
437
+
438
+ if success
439
+ RubyLLM::MCP.logger.info("Authentication successful, SSE stream will reconnect")
440
+ # The caller will retry the SSE connection
441
+ return
442
+ end
443
+ rescue Errors::AuthenticationRequiredError
444
+ raise
445
+ rescue StandardError => e
446
+ RubyLLM::MCP.logger.error("SSE authentication failed: #{e.message}")
447
+ end
448
+
449
+ raise Errors::AuthenticationRequiredError.new(
450
+ message: "OAuth authentication required for SSE stream"
451
+ )
452
+ end
453
+
454
+ def handle_client_error!(error_message, status_code)
455
+ transport_error = Errors::TransportError.new(
456
+ message: error_message,
457
+ code: status_code
458
+ )
459
+ close
460
+
461
+ raise transport_error
462
+ end
463
+
464
+ def fail_pending_requests!(error)
465
+ @pending_mutex.synchronize do
466
+ @pending_requests.each_value do |queue|
467
+ queue.push(error)
468
+ end
469
+ @pending_requests.clear
470
+ end
471
+ end
472
+
473
+ def process_event_stream(response)
474
+ event_buffer = []
475
+ response.each_line do |event_line|
476
+ break unless handle_event_line?(event_line, event_buffer, response)
477
+ end
478
+ end
479
+
480
+ def handle_event_line?(event_line, event_buffer, response)
481
+ unless running?
482
+ response.body.close
483
+ return false
484
+ end
485
+
486
+ line = event_line.strip
487
+
488
+ if line.empty?
489
+ process_buffered_event(event_buffer)
490
+ else
491
+ event_buffer << line
492
+ end
493
+
494
+ true
495
+ end
496
+
497
+ def process_buffered_event(event_buffer)
498
+ return unless event_buffer.any?
499
+
500
+ events = parse_event(event_buffer.join("\n"))
501
+ events.each { |event| process_event(event) }
502
+ event_buffer.clear
503
+ end
504
+
505
+ def read_error_body(response)
506
+ body = ""
507
+ begin
508
+ response.each do |chunk|
509
+ body << chunk
510
+ end
511
+ rescue StandardError
512
+ # If we can't read the body, just use what we have
513
+ end
514
+ body.strip.empty? ? "No error details provided" : body.strip
515
+ end
516
+
517
+ def handle_connection_error(message, error)
518
+ return unless running?
519
+ # Ignore errors from a previous listener thread after restart.
520
+ return if Thread.current != @sse_thread
521
+
522
+ error_message = "#{message}: #{error.message}"
523
+ RubyLLM::MCP.logger.error "#{error_message}. Closing SSE transport."
524
+
525
+ close
526
+ end
527
+
528
+ def handle_httpx_error_response!(response, context:)
529
+ return false unless response.is_a?(HTTPX::ErrorResponse)
530
+
531
+ error = response.error
532
+
533
+ if error.is_a?(HTTPX::ReadTimeoutError)
534
+ raise Errors::TimeoutError.new(
535
+ message: "Request timed out after #{@request_timeout / 1000} seconds"
536
+ )
537
+ end
538
+
539
+ error_message = response.error&.message || "Request failed"
540
+
541
+ raise Errors::TransportError.new(
542
+ code: nil,
543
+ message: "Request Error #{context}: #{error_message}"
544
+ )
545
+ end
546
+
547
+ def process_event(raw_event)
548
+ return if raw_event[:data].nil?
549
+
550
+ if raw_event[:event] == "endpoint"
551
+ process_endpoint_event(raw_event)
552
+ else
553
+ process_message_event(raw_event)
554
+ end
555
+ end
556
+
557
+ def process_endpoint_event(raw_event)
558
+ request_id = "endpoint"
559
+ event_data = raw_event[:data]
560
+ return if event_data.nil?
561
+
562
+ endpoint = begin
563
+ JSON.parse(event_data)
564
+ rescue JSON::ParserError
565
+ event_data
566
+ end
567
+
568
+ RubyLLM::MCP.logger.debug "Received endpoint event: #{endpoint.inspect}"
569
+
570
+ @pending_mutex.synchronize do
571
+ response_queue = @pending_requests.delete(request_id)
572
+ response_queue&.push(endpoint)
573
+ end
574
+ end
575
+
576
+ def process_message_event(raw_event)
577
+ event = parse_and_validate_event(raw_event[:data])
578
+ return if event.nil?
579
+
580
+ request_id = event["id"]&.to_s
581
+ result = RubyLLM::MCP::Result.new(event)
582
+
583
+ result = @coordinator.process_result(result)
584
+ return if result.nil?
585
+
586
+ return if request_id.nil?
587
+
588
+ response_queue = nil
589
+ matching_result = false
590
+
591
+ @pending_mutex.synchronize do
592
+ if @pending_requests.key?(request_id)
593
+ matching_result = if result.is_a?(RubyLLM::MCP::Result)
594
+ result.matching_id?(request_id)
595
+ else
596
+ true
597
+ end
598
+
599
+ response_queue = @pending_requests.delete(request_id) if matching_result
600
+ else
601
+ matching_result = false
602
+ end
603
+ end
604
+
605
+ response_queue&.push(result) if matching_result
606
+ end
607
+
608
+ def parse_and_validate_event(data)
609
+ event = JSON.parse(data)
610
+
611
+ # Validate JSON-RPC envelope
612
+ validator = Native::JsonRpc::EnvelopeValidator.new(event)
613
+ unless validator.valid?
614
+ RubyLLM::MCP.logger.error(
615
+ "Invalid JSON-RPC envelope in SSE event: #{validator.error_message}\nRaw: #{data}"
616
+ )
617
+ # SSE is unidirectional from server to client, so we can't send error responses back
618
+ return nil
619
+ end
620
+
621
+ event
622
+ rescue JSON::ParserError => e
623
+ # Partial endpoint events can arrive while establishing the stream; log once we know the URL.
624
+ if @messages_url
625
+ RubyLLM::MCP.logger.debug "Failed to parse SSE event data: #{data} - #{e.message}"
626
+ end
627
+ nil
628
+ end
629
+
630
+ def parse_event(raw)
631
+ event_blocks = raw.split(/\n\s*\n/)
632
+
633
+ events = event_blocks.map do |event_block|
634
+ event = {}
635
+ event_block.each_line do |line|
636
+ case line
637
+ when /^data:\s*(.*)/
638
+ (event[:data] ||= []) << ::Regexp.last_match(1)
639
+ when /^event:\s*(.*)/
640
+ event[:event] = ::Regexp.last_match(1)
641
+ when /^id:\s*(.*)/
642
+ event[:id] = ::Regexp.last_match(1)
643
+ end
644
+ end
645
+ event[:data] = event[:data]&.join("\n")
646
+ event
647
+ end
648
+
649
+ events.reject { |event| event.empty? || event[:data].nil? }
650
+ end
651
+ end
652
+ end
653
+ end
654
+ end
655
+ end