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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
- data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
- data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/chat.rb +34 -0
- data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
- data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
- data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
- data/lib/ruby_llm/mcp/attachment.rb +18 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +401 -0
- data/lib/ruby_llm/mcp/completion.rb +16 -0
- data/lib/ruby_llm/mcp/configuration.rb +310 -0
- data/lib/ruby_llm/mcp/content.rb +28 -0
- data/lib/ruby_llm/mcp/elicitation.rb +48 -0
- data/lib/ruby_llm/mcp/error.rb +34 -0
- data/lib/ruby_llm/mcp/errors.rb +91 -0
- data/lib/ruby_llm/mcp/logging.rb +16 -0
- data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
- data/lib/ruby_llm/mcp/native/client.rb +387 -0
- data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
- data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
- data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
- data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
- data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
- data/lib/ruby_llm/mcp/native/messages.rb +36 -0
- data/lib/ruby_llm/mcp/native/notification.rb +16 -0
- data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
- data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
- data/lib/ruby_llm/mcp/native/transport.rb +88 -0
- data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
- data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
- data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
- data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
- data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
- data/lib/ruby_llm/mcp/native.rb +12 -0
- data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
- data/lib/ruby_llm/mcp/progress.rb +35 -0
- data/lib/ruby_llm/mcp/prompt.rb +132 -0
- data/lib/ruby_llm/mcp/railtie.rb +14 -0
- data/lib/ruby_llm/mcp/resource.rb +112 -0
- data/lib/ruby_llm/mcp/resource_template.rb +85 -0
- data/lib/ruby_llm/mcp/result.rb +108 -0
- data/lib/ruby_llm/mcp/roots.rb +45 -0
- data/lib/ruby_llm/mcp/sample.rb +152 -0
- data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
- data/lib/ruby_llm/mcp/tool.rb +228 -0
- data/lib/ruby_llm/mcp/version.rb +7 -0
- data/lib/ruby_llm/mcp.rb +125 -0
- data/lib/tasks/release.rake +23 -0
- 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
|