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,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
|