ruby_llm_swarm-mcp 0.8.0 → 0.8.1
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 +4 -4
- data/README.md +3 -44
- data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +4 -21
- data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +0 -20
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +3 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +2 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +32 -100
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +6 -32
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +2 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +0 -18
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +3 -82
- data/lib/ruby_llm/mcp/auth/session_manager.rb +2 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +2 -0
- data/lib/ruby_llm/mcp/client.rb +32 -119
- data/lib/ruby_llm/mcp/configuration.rb +6 -74
- data/lib/ruby_llm/mcp/coordinator.rb +304 -0
- data/lib/ruby_llm/mcp/elicitation.rb +6 -8
- data/lib/ruby_llm/mcp/errors.rb +0 -15
- data/lib/ruby_llm/mcp/notification_handler.rb +5 -21
- data/lib/ruby_llm/mcp/notifications/cancelled.rb +32 -0
- data/lib/ruby_llm/mcp/notifications/initialize.rb +24 -0
- data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +26 -0
- data/lib/ruby_llm/mcp/prompt.rb +7 -7
- data/lib/ruby_llm/mcp/protocol.rb +34 -0
- data/lib/ruby_llm/mcp/railtie.rb +6 -8
- data/lib/ruby_llm/mcp/requests/completion_prompt.rb +50 -0
- data/lib/ruby_llm/mcp/requests/completion_resource.rb +50 -0
- data/lib/ruby_llm/mcp/requests/initialization.rb +34 -0
- data/lib/ruby_llm/mcp/requests/logging_set_level.rb +28 -0
- data/lib/ruby_llm/mcp/requests/ping.rb +24 -0
- data/lib/ruby_llm/mcp/requests/prompt_call.rb +32 -0
- data/lib/ruby_llm/mcp/requests/prompt_list.rb +31 -0
- data/lib/ruby_llm/mcp/requests/resource_list.rb +31 -0
- data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +31 -0
- data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +30 -0
- data/lib/ruby_llm/mcp/requests/shared/meta.rb +32 -0
- data/lib/ruby_llm/mcp/requests/shared/pagination.rb +17 -0
- data/lib/ruby_llm/mcp/requests/tool_call.rb +35 -0
- data/lib/ruby_llm/mcp/requests/tool_list.rb +31 -0
- data/lib/ruby_llm/mcp/resource.rb +8 -6
- data/lib/ruby_llm/mcp/resource_template.rb +7 -7
- data/lib/ruby_llm/mcp/response_handler.rb +67 -0
- data/lib/ruby_llm/mcp/responses/elicitation.rb +33 -0
- data/lib/ruby_llm/mcp/responses/error.rb +33 -0
- data/lib/ruby_llm/mcp/responses/ping.rb +28 -0
- data/lib/ruby_llm/mcp/responses/roots_list.rb +31 -0
- data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +50 -0
- data/lib/ruby_llm/mcp/result.rb +4 -8
- data/lib/ruby_llm/mcp/roots.rb +4 -4
- data/lib/ruby_llm/mcp/sample.rb +2 -6
- data/lib/ruby_llm/mcp/tool.rb +9 -9
- data/lib/ruby_llm/mcp/transport.rb +151 -0
- data/lib/ruby_llm/mcp/transports/sse.rb +435 -0
- data/lib/ruby_llm/mcp/transports/stdio.rb +231 -0
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +725 -0
- data/lib/ruby_llm/mcp/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +47 -0
- data/lib/ruby_llm/mcp/transports/support/timeout.rb +34 -0
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +7 -30
- metadata +38 -33
- data/lib/ruby_llm/mcp/adapters/base_adapter.rb +0 -179
- data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +0 -292
- data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +0 -33
- data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +0 -52
- data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +0 -52
- data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +0 -86
- data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +0 -92
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +0 -107
- data/lib/ruby_llm/mcp/native/cancellable_operation.rb +0 -57
- data/lib/ruby_llm/mcp/native/client.rb +0 -387
- data/lib/ruby_llm/mcp/native/json_rpc.rb +0 -170
- data/lib/ruby_llm/mcp/native/messages/helpers.rb +0 -39
- data/lib/ruby_llm/mcp/native/messages/notifications.rb +0 -42
- data/lib/ruby_llm/mcp/native/messages/requests.rb +0 -206
- data/lib/ruby_llm/mcp/native/messages/responses.rb +0 -106
- data/lib/ruby_llm/mcp/native/messages.rb +0 -36
- data/lib/ruby_llm/mcp/native/notification.rb +0 -16
- data/lib/ruby_llm/mcp/native/protocol.rb +0 -36
- data/lib/ruby_llm/mcp/native/response_handler.rb +0 -110
- data/lib/ruby_llm/mcp/native/transport.rb +0 -88
- data/lib/ruby_llm/mcp/native/transports/sse.rb +0 -607
- data/lib/ruby_llm/mcp/native/transports/stdio.rb +0 -356
- data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +0 -926
- data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +0 -28
- data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +0 -49
- data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +0 -36
- data/lib/ruby_llm/mcp/native.rb +0 -12
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "httpx"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
|
|
9
|
+
module RubyLLM
|
|
10
|
+
module MCP
|
|
11
|
+
module Transports
|
|
12
|
+
# Configuration options for reconnection behavior
|
|
13
|
+
class ReconnectionOptions
|
|
14
|
+
attr_reader :max_reconnection_delay, :initial_reconnection_delay,
|
|
15
|
+
:reconnection_delay_grow_factor, :max_retries
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
max_reconnection_delay: 30_000,
|
|
19
|
+
initial_reconnection_delay: 1_000,
|
|
20
|
+
reconnection_delay_grow_factor: 1.5,
|
|
21
|
+
max_retries: 2
|
|
22
|
+
)
|
|
23
|
+
@max_reconnection_delay = max_reconnection_delay
|
|
24
|
+
@initial_reconnection_delay = initial_reconnection_delay
|
|
25
|
+
@reconnection_delay_grow_factor = reconnection_delay_grow_factor
|
|
26
|
+
@max_retries = max_retries
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Options for starting SSE connections
|
|
31
|
+
class StartSSEOptions
|
|
32
|
+
attr_reader :resumption_token, :on_resumption_token, :replay_message_id
|
|
33
|
+
|
|
34
|
+
def initialize(resumption_token: nil, on_resumption_token: nil, replay_message_id: nil)
|
|
35
|
+
@resumption_token = resumption_token
|
|
36
|
+
@on_resumption_token = on_resumption_token
|
|
37
|
+
@replay_message_id = replay_message_id
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Main StreamableHTTP transport class
|
|
42
|
+
class StreamableHTTP
|
|
43
|
+
include Support::Timeout
|
|
44
|
+
|
|
45
|
+
attr_reader :session_id, :protocol_version, :coordinator, :oauth_provider
|
|
46
|
+
|
|
47
|
+
def initialize(url:, request_timeout:, coordinator:, options: {})
|
|
48
|
+
@url = URI(url)
|
|
49
|
+
@coordinator = coordinator
|
|
50
|
+
@request_timeout = request_timeout
|
|
51
|
+
|
|
52
|
+
extract_options(options)
|
|
53
|
+
initialize_state_variables
|
|
54
|
+
initialize_mutexes
|
|
55
|
+
|
|
56
|
+
@connection = create_connection
|
|
57
|
+
|
|
58
|
+
RubyLLM::MCP.logger.debug "OAuth provider: #{@oauth_provider ? 'present' : 'none'}" if @oauth_provider
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_options(options)
|
|
62
|
+
@headers = options[:headers] || options["headers"] || {}
|
|
63
|
+
@session_id = options[:session_id] || options["session_id"]
|
|
64
|
+
@oauth_provider = options[:oauth_provider] || options["oauth_provider"]
|
|
65
|
+
@version = options[:version] || options["version"] || :http2
|
|
66
|
+
@protocol_version = nil
|
|
67
|
+
|
|
68
|
+
reconnection = options[:reconnection] || options["reconnection"] || {}
|
|
69
|
+
@reconnection_options = options[:reconnection_options] || ReconnectionOptions.new(**reconnection)
|
|
70
|
+
|
|
71
|
+
rate_limit = options[:rate_limit] || options["rate_limit"]
|
|
72
|
+
@rate_limiter = Support::RateLimiter.new(**rate_limit) if rate_limit
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def initialize_state_variables
|
|
76
|
+
@resource_metadata_url = nil
|
|
77
|
+
@client_id = SecureRandom.uuid
|
|
78
|
+
@id_counter = 0
|
|
79
|
+
@pending_requests = {}
|
|
80
|
+
@running = true
|
|
81
|
+
@abort_controller = nil
|
|
82
|
+
@sse_thread = nil
|
|
83
|
+
@clients = []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def initialize_mutexes
|
|
87
|
+
@id_mutex = Mutex.new
|
|
88
|
+
@pending_mutex = Mutex.new
|
|
89
|
+
@sse_mutex = Mutex.new
|
|
90
|
+
@clients_mutex = Mutex.new
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def request(body, add_id: true, wait_for_response: true)
|
|
94
|
+
if @rate_limiter&.exceeded?
|
|
95
|
+
sleep(1) while @rate_limiter&.exceeded?
|
|
96
|
+
end
|
|
97
|
+
@rate_limiter&.add
|
|
98
|
+
|
|
99
|
+
# Generate a unique request ID for requests
|
|
100
|
+
if add_id && body.is_a?(Hash) && !body.key?("id")
|
|
101
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
|
102
|
+
body["id"] = @id_counter
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
request_id = body.is_a?(Hash) ? body["id"] : nil
|
|
106
|
+
is_initialization = body.is_a?(Hash) && body["method"] == "initialize"
|
|
107
|
+
|
|
108
|
+
response_queue = setup_response_queue(request_id, wait_for_response)
|
|
109
|
+
result = send_http_request(body, request_id, is_initialization: is_initialization)
|
|
110
|
+
return result if result.is_a?(RubyLLM::MCP::Result)
|
|
111
|
+
|
|
112
|
+
if wait_for_response && request_id
|
|
113
|
+
wait_for_response_with_timeout(request_id.to_s, response_queue)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def alive?
|
|
118
|
+
@running
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def close
|
|
122
|
+
terminate_session
|
|
123
|
+
cleanup_sse_resources
|
|
124
|
+
cleanup_connection
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def start
|
|
128
|
+
@abort_controller = false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def set_protocol_version(version)
|
|
132
|
+
@protocol_version = version
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def terminate_session
|
|
138
|
+
return unless @session_id
|
|
139
|
+
|
|
140
|
+
begin
|
|
141
|
+
headers = build_common_headers
|
|
142
|
+
response = @connection.delete(@url, headers: headers)
|
|
143
|
+
|
|
144
|
+
# Handle HTTPX error responses first
|
|
145
|
+
handle_httpx_error_response!(response, context: { location: "terminating session" })
|
|
146
|
+
|
|
147
|
+
# 405 Method Not Allowed is acceptable per spec
|
|
148
|
+
unless [200, 405].include?(response.status)
|
|
149
|
+
reason_phrase = response.respond_to?(:reason_phrase) ? response.reason_phrase : nil
|
|
150
|
+
raise Errors::TransportError.new(
|
|
151
|
+
code: response.status,
|
|
152
|
+
message: "Failed to terminate session: #{reason_phrase || response.status}"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
@session_id = nil
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
raise Errors::TransportError.new(
|
|
159
|
+
message: "Failed to terminate session: #{e.message}",
|
|
160
|
+
code: nil,
|
|
161
|
+
error: e
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def handle_httpx_error_response!(response, context:, allow_eof_for_sse: false)
|
|
167
|
+
return false unless response.is_a?(HTTPX::ErrorResponse)
|
|
168
|
+
|
|
169
|
+
error = response.error
|
|
170
|
+
|
|
171
|
+
# Special handling for EOFError in SSE contexts
|
|
172
|
+
if allow_eof_for_sse && error.is_a?(EOFError)
|
|
173
|
+
RubyLLM::MCP.logger.info "SSE stream closed: #{response.error.message}"
|
|
174
|
+
return :eof_handled
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if error.is_a?(HTTPX::ReadTimeoutError)
|
|
178
|
+
raise Errors::TimeoutError.new(
|
|
179
|
+
message: "Request timed out after #{@request_timeout / 1000} seconds",
|
|
180
|
+
request_id: context[:request_id]
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
error_message = response.error&.message || "Request failed"
|
|
185
|
+
RubyLLM::MCP.logger.error "HTTPX error in #{context[:location]}: #{error_message}"
|
|
186
|
+
|
|
187
|
+
raise Errors::TransportError.new(
|
|
188
|
+
code: nil,
|
|
189
|
+
message: "HTTPX Error #{context}: #{error_message}"
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def register_client(client)
|
|
194
|
+
@clients_mutex.synchronize do
|
|
195
|
+
@clients << client
|
|
196
|
+
end
|
|
197
|
+
client
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def unregister_client(client)
|
|
201
|
+
@clients_mutex.synchronize do
|
|
202
|
+
@clients.delete(client)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def close_client(client)
|
|
207
|
+
client.close if client.respond_to?(:close)
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
RubyLLM::MCP.logger.debug "Error closing HTTPX client: #{e.message}"
|
|
210
|
+
ensure
|
|
211
|
+
unregister_client(client)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def active_clients_count
|
|
215
|
+
@clients_mutex.synchronize do
|
|
216
|
+
@clients.size
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def create_connection
|
|
221
|
+
client = Support::HTTPClient.connection.with(
|
|
222
|
+
timeout: {
|
|
223
|
+
connect_timeout: 10,
|
|
224
|
+
read_timeout: @request_timeout / 1000,
|
|
225
|
+
write_timeout: @request_timeout / 1000,
|
|
226
|
+
operation_timeout: @request_timeout / 1000
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
register_client(client)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def build_common_headers
|
|
234
|
+
headers = @headers.dup
|
|
235
|
+
|
|
236
|
+
headers["mcp-session-id"] = @session_id if @session_id
|
|
237
|
+
headers["mcp-protocol-version"] = @protocol_version if @protocol_version
|
|
238
|
+
headers["X-CLIENT-ID"] = @client_id
|
|
239
|
+
headers["Origin"] = @url.to_s
|
|
240
|
+
|
|
241
|
+
# Apply OAuth authorization if available
|
|
242
|
+
if @oauth_provider
|
|
243
|
+
RubyLLM::MCP.logger.debug "OAuth provider present, attempting to get token..."
|
|
244
|
+
RubyLLM::MCP.logger.debug " Server URL: #{@oauth_provider.server_url}"
|
|
245
|
+
|
|
246
|
+
token = @oauth_provider.access_token
|
|
247
|
+
if token
|
|
248
|
+
headers["Authorization"] = token.to_header
|
|
249
|
+
RubyLLM::MCP.logger.debug "✓ Applied OAuth authorization header: #{token.to_header[0..30]}..."
|
|
250
|
+
else
|
|
251
|
+
RubyLLM::MCP.logger.warn "✗ OAuth provider present but no valid token available!"
|
|
252
|
+
RubyLLM::MCP.logger.warn " This means the token is not in storage or has expired"
|
|
253
|
+
RubyLLM::MCP.logger.warn " Check that authentication completed successfully"
|
|
254
|
+
end
|
|
255
|
+
else
|
|
256
|
+
RubyLLM::MCP.logger.debug "No OAuth provider configured for this transport"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
headers
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def setup_response_queue(request_id, wait_for_response)
|
|
263
|
+
response_queue = Queue.new
|
|
264
|
+
if wait_for_response && request_id
|
|
265
|
+
@pending_mutex.synchronize do
|
|
266
|
+
@pending_requests[request_id.to_s] = response_queue
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
response_queue
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def send_http_request(body, request_id, is_initialization: false)
|
|
273
|
+
headers = build_common_headers
|
|
274
|
+
headers["Content-Type"] = "application/json"
|
|
275
|
+
headers["Accept"] = "application/json, text/event-stream"
|
|
276
|
+
|
|
277
|
+
json_body = JSON.generate(body)
|
|
278
|
+
RubyLLM::MCP.logger.debug "Sending Request: #{json_body}"
|
|
279
|
+
|
|
280
|
+
begin
|
|
281
|
+
# Set up connection with streaming callbacks if not initialization
|
|
282
|
+
connection = if is_initialization
|
|
283
|
+
@connection
|
|
284
|
+
else
|
|
285
|
+
create_connection_with_streaming_callbacks(request_id)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
response = connection.post(@url, json: body, headers: headers)
|
|
289
|
+
handle_response(response, request_id, body)
|
|
290
|
+
ensure
|
|
291
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def create_connection_with_streaming_callbacks(request_id)
|
|
296
|
+
buffer = +""
|
|
297
|
+
|
|
298
|
+
client = Support::HTTPClient.connection.plugin(:callbacks)
|
|
299
|
+
.on_response_body_chunk do |request, _response, chunk|
|
|
300
|
+
next unless @running && !@abort_controller
|
|
301
|
+
|
|
302
|
+
RubyLLM::MCP.logger.debug "Received chunk: #{chunk.bytesize} bytes for #{request.uri}"
|
|
303
|
+
buffer << chunk
|
|
304
|
+
process_sse_buffer_events(buffer, request_id&.to_s)
|
|
305
|
+
end
|
|
306
|
+
.with(
|
|
307
|
+
timeout: {
|
|
308
|
+
connect_timeout: 10,
|
|
309
|
+
read_timeout: @request_timeout / 1000,
|
|
310
|
+
write_timeout: @request_timeout / 1000,
|
|
311
|
+
operation_timeout: @request_timeout / 1000
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
register_client(client)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def handle_response(response, request_id, original_message)
|
|
319
|
+
# Handle HTTPX error responses first
|
|
320
|
+
handle_httpx_error_response!(response, context: { location: "handling response", request_id: request_id })
|
|
321
|
+
|
|
322
|
+
# Extract session ID if present (only for successful responses)
|
|
323
|
+
session_id = response.headers["mcp-session-id"]
|
|
324
|
+
@session_id = session_id if session_id
|
|
325
|
+
|
|
326
|
+
case response.status
|
|
327
|
+
when 200
|
|
328
|
+
handle_success_response(response, request_id, original_message)
|
|
329
|
+
when 202
|
|
330
|
+
handle_accepted_response(original_message)
|
|
331
|
+
when 404
|
|
332
|
+
handle_session_expired
|
|
333
|
+
when 401
|
|
334
|
+
raise Errors::AuthenticationRequiredError.new(
|
|
335
|
+
message: "OAuth authentication required. Server returned 401 Unauthorized.",
|
|
336
|
+
code: 401
|
|
337
|
+
)
|
|
338
|
+
when 405
|
|
339
|
+
# Method not allowed - acceptable for some endpoints
|
|
340
|
+
nil
|
|
341
|
+
when 400...500
|
|
342
|
+
handle_client_error(response)
|
|
343
|
+
else
|
|
344
|
+
response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
|
|
345
|
+
raise Errors::TransportError.new(
|
|
346
|
+
code: response.status,
|
|
347
|
+
message: "HTTP request failed: #{response.status} - #{response_body}"
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def handle_success_response(response, request_id, _original_message)
|
|
353
|
+
content_type = response.respond_to?(:headers) ? response.headers["content-type"] : nil
|
|
354
|
+
|
|
355
|
+
if content_type&.include?("text/event-stream")
|
|
356
|
+
start_sse_stream
|
|
357
|
+
nil
|
|
358
|
+
elsif content_type&.include?("application/json")
|
|
359
|
+
response_body = response.respond_to?(:body) ? response.body.to_s : "{}"
|
|
360
|
+
if response_body == "null" # Fix related to official MCP Ruby SDK implementation
|
|
361
|
+
response_body = "{}"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
json_response = JSON.parse(response_body)
|
|
365
|
+
result = RubyLLM::MCP::Result.new(json_response, session_id: @session_id)
|
|
366
|
+
|
|
367
|
+
if request_id
|
|
368
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
result
|
|
372
|
+
else
|
|
373
|
+
raise Errors::TransportError.new(
|
|
374
|
+
code: -1,
|
|
375
|
+
message: "Unexpected content type: #{content_type}"
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
rescue StandardError => e
|
|
379
|
+
raise Errors::TransportError.new(
|
|
380
|
+
message: "Invalid JSON response: #{e.message}",
|
|
381
|
+
error: e
|
|
382
|
+
)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def handle_accepted_response(original_message)
|
|
386
|
+
# 202 Accepted - start SSE stream if this was an initialization
|
|
387
|
+
if original_message.is_a?(Hash) && original_message["method"] == "initialize"
|
|
388
|
+
start_sse_stream
|
|
389
|
+
end
|
|
390
|
+
nil
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def handle_client_error(response)
|
|
394
|
+
response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
|
|
395
|
+
status_code = response.respond_to?(:status) ? response.status : "Unknown"
|
|
396
|
+
|
|
397
|
+
begin
|
|
398
|
+
error_body = JSON.parse(response_body)
|
|
399
|
+
|
|
400
|
+
if error_body.is_a?(Hash) && error_body["error"]
|
|
401
|
+
error_message = error_body["error"]["message"] || error_body["error"]["code"] || error_body["error"].to_s
|
|
402
|
+
|
|
403
|
+
# If we still don't have a message, include the full error object
|
|
404
|
+
if error_message.to_s.strip.empty?
|
|
405
|
+
error_message = "Empty error (full response: #{response_body})"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
if error_message.to_s.downcase.include?("session")
|
|
409
|
+
raise Errors::TransportError.new(
|
|
410
|
+
code: status_code,
|
|
411
|
+
message: "Server error: #{error_message} (Current session ID: #{@session_id || 'none'})"
|
|
412
|
+
)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Special handling for 403 Forbidden with OAuth
|
|
416
|
+
if status_code == 403 && @oauth_provider
|
|
417
|
+
raise Errors::TransportError.new(
|
|
418
|
+
code: status_code,
|
|
419
|
+
message: "Authorization failed (403 Forbidden): #{error_message}. \
|
|
420
|
+
Check token scope and resource permissions at #{@oauth_provider.server_url}."
|
|
421
|
+
)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
raise Errors::TransportError.new(
|
|
425
|
+
code: status_code,
|
|
426
|
+
message: "Server error: #{error_message}"
|
|
427
|
+
)
|
|
428
|
+
end
|
|
429
|
+
rescue JSON::ParserError
|
|
430
|
+
# Fall through to generic error
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
raise Errors::TransportError.new(
|
|
434
|
+
code: status_code,
|
|
435
|
+
message: "HTTP client error: #{status_code} - #{response_body}"
|
|
436
|
+
)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def handle_session_expired
|
|
440
|
+
@session_id = nil
|
|
441
|
+
raise Errors::SessionExpiredError.new(
|
|
442
|
+
message: "Session expired, re-initialization required"
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def extract_resource_metadata_url(response)
|
|
447
|
+
# Extract resource metadata URL from response headers if present
|
|
448
|
+
# Guard against error responses that don't have headers
|
|
449
|
+
return nil unless response.respond_to?(:headers)
|
|
450
|
+
|
|
451
|
+
metadata_url = response.headers["mcp-resource-metadata-url"]
|
|
452
|
+
metadata_url ? URI(metadata_url) : nil
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def start_sse_stream(options = StartSSEOptions.new)
|
|
456
|
+
return unless @running && !@abort_controller
|
|
457
|
+
|
|
458
|
+
@sse_mutex.synchronize do
|
|
459
|
+
return if @sse_thread&.alive?
|
|
460
|
+
|
|
461
|
+
@sse_thread = Thread.new do
|
|
462
|
+
start_sse(options)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def start_sse(options) # rubocop:disable Metrics/MethodLength
|
|
468
|
+
attempt_count = 0
|
|
469
|
+
|
|
470
|
+
begin
|
|
471
|
+
headers = build_common_headers
|
|
472
|
+
headers["Accept"] = "text/event-stream"
|
|
473
|
+
|
|
474
|
+
if options.resumption_token
|
|
475
|
+
headers["Last-Event-ID"] = options.resumption_token
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Set up SSE streaming connection with callbacks
|
|
479
|
+
connection = create_connection_with_sse_callbacks(options, headers)
|
|
480
|
+
response = connection.get(@url)
|
|
481
|
+
|
|
482
|
+
# Handle HTTPX error responses first
|
|
483
|
+
error_result = handle_httpx_error_response!(response, context: { location: "SSE connection" },
|
|
484
|
+
allow_eof_for_sse: true)
|
|
485
|
+
return if error_result == :eof_handled
|
|
486
|
+
|
|
487
|
+
case response.status
|
|
488
|
+
when 200
|
|
489
|
+
# SSE stream established successfully
|
|
490
|
+
RubyLLM::MCP.logger.debug "SSE stream established"
|
|
491
|
+
# Response will be processed through callbacks
|
|
492
|
+
when 401
|
|
493
|
+
raise Errors::AuthenticationRequiredError.new(
|
|
494
|
+
message: "OAuth authentication required. Server returned 401 Unauthorized.",
|
|
495
|
+
code: 401
|
|
496
|
+
)
|
|
497
|
+
when 405
|
|
498
|
+
# Server doesn't support SSE - this is acceptable
|
|
499
|
+
RubyLLM::MCP.logger.info "Server does not support SSE streaming"
|
|
500
|
+
nil
|
|
501
|
+
when 409
|
|
502
|
+
# Conflict - SSE connection already exists for this session
|
|
503
|
+
# This is expected when reusing sessions and is acceptable
|
|
504
|
+
RubyLLM::MCP.logger.debug "SSE stream already exists for this session"
|
|
505
|
+
nil
|
|
506
|
+
else
|
|
507
|
+
reason_phrase = response.respond_to?(:reason_phrase) ? response.reason_phrase : nil
|
|
508
|
+
raise Errors::TransportError.new(
|
|
509
|
+
code: response.status,
|
|
510
|
+
message: "Failed to open SSE stream: #{reason_phrase || response.status}"
|
|
511
|
+
)
|
|
512
|
+
end
|
|
513
|
+
rescue StandardError => e
|
|
514
|
+
RubyLLM::MCP.logger.error "SSE stream error: #{e.message}"
|
|
515
|
+
# Attempt reconnection with exponential backoff
|
|
516
|
+
|
|
517
|
+
if @running && !@abort_controller && attempt_count < @reconnection_options.max_retries
|
|
518
|
+
delay = calculate_reconnection_delay(attempt_count)
|
|
519
|
+
RubyLLM::MCP.logger.info "Reconnecting SSE stream in #{delay}ms..."
|
|
520
|
+
|
|
521
|
+
sleep(delay / 1000.0)
|
|
522
|
+
attempt_count += 1
|
|
523
|
+
retry
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
raise e
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def create_connection_with_sse_callbacks(options, headers)
|
|
531
|
+
client = HTTPX.plugin(:callbacks)
|
|
532
|
+
client = add_on_response_body_chunk_callback(client, options)
|
|
533
|
+
|
|
534
|
+
client = client.with(
|
|
535
|
+
timeout: {
|
|
536
|
+
connect_timeout: 10,
|
|
537
|
+
read_timeout: @request_timeout / 1000,
|
|
538
|
+
write_timeout: @request_timeout / 1000,
|
|
539
|
+
operation_timeout: @request_timeout / 1000
|
|
540
|
+
},
|
|
541
|
+
headers: headers
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
if @version == :http1
|
|
545
|
+
client = client.with(
|
|
546
|
+
ssl: { alpn_protocols: ["http/1.1"] }
|
|
547
|
+
)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
register_client(client)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def add_on_response_body_chunk_callback(client, options)
|
|
554
|
+
buffer = +""
|
|
555
|
+
client.on_response_body_chunk do |request, response, chunk|
|
|
556
|
+
# Only process chunks for text/event-stream and if still running
|
|
557
|
+
next unless @running && !@abort_controller
|
|
558
|
+
|
|
559
|
+
if chunk.include?("event: stop")
|
|
560
|
+
RubyLLM::MCP.logger.debug "Closing SSE stream"
|
|
561
|
+
request.close
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
content_type = response.headers["content-type"]
|
|
565
|
+
if content_type&.include?("text/event-stream")
|
|
566
|
+
buffer << chunk.to_s
|
|
567
|
+
|
|
568
|
+
while (event_data = extract_sse_event(buffer))
|
|
569
|
+
raw_event, remaining_buffer = event_data
|
|
570
|
+
buffer.replace(remaining_buffer)
|
|
571
|
+
|
|
572
|
+
next unless raw_event && raw_event[:data]
|
|
573
|
+
|
|
574
|
+
if raw_event[:id]
|
|
575
|
+
options.on_resumption_token&.call(raw_event[:id])
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
process_sse_event(raw_event, options.replay_message_id)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def calculate_reconnection_delay(attempt)
|
|
585
|
+
initial = @reconnection_options.initial_reconnection_delay
|
|
586
|
+
factor = @reconnection_options.reconnection_delay_grow_factor
|
|
587
|
+
max_delay = @reconnection_options.max_reconnection_delay
|
|
588
|
+
|
|
589
|
+
[initial * (factor**attempt), max_delay].min
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def process_sse_buffer_events(buffer, _request_id)
|
|
593
|
+
return unless @running && !@abort_controller
|
|
594
|
+
|
|
595
|
+
while (event_data = extract_sse_event(buffer))
|
|
596
|
+
raw_event, remaining_buffer = event_data
|
|
597
|
+
buffer.replace(remaining_buffer)
|
|
598
|
+
|
|
599
|
+
process_sse_event(raw_event, nil) if raw_event && raw_event[:data]
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def extract_sse_event(buffer)
|
|
604
|
+
# Support both Unix (\n\n) and Windows (\r\n\r\n) line endings
|
|
605
|
+
separator = if buffer.include?("\r\n\r\n")
|
|
606
|
+
"\r\n\r\n"
|
|
607
|
+
elsif buffer.include?("\n\n")
|
|
608
|
+
"\n\n"
|
|
609
|
+
else
|
|
610
|
+
return nil
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
raw, rest = buffer.split(separator, 2)
|
|
614
|
+
[parse_sse_event(raw), rest || ""]
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def parse_sse_event(raw)
|
|
618
|
+
event = {}
|
|
619
|
+
raw.each_line do |line|
|
|
620
|
+
line = line.strip
|
|
621
|
+
case line
|
|
622
|
+
when /^data:\s*(.*)/
|
|
623
|
+
(event[:data] ||= []) << ::Regexp.last_match(1)
|
|
624
|
+
when /^event:\s*(.*)/
|
|
625
|
+
event[:event] = ::Regexp.last_match(1)
|
|
626
|
+
when /^id:\s*(.*)/
|
|
627
|
+
event[:id] = ::Regexp.last_match(1)
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
event[:data] = event[:data]&.join("\n")
|
|
631
|
+
event
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def process_sse_event(raw_event, replay_message_id)
|
|
635
|
+
return unless raw_event[:data]
|
|
636
|
+
return unless @running && !@abort_controller
|
|
637
|
+
|
|
638
|
+
begin
|
|
639
|
+
event_data = JSON.parse(raw_event[:data])
|
|
640
|
+
|
|
641
|
+
# Handle replay message ID if specified
|
|
642
|
+
if replay_message_id && event_data.is_a?(Hash) && event_data["id"]
|
|
643
|
+
event_data["id"] = replay_message_id
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
result = RubyLLM::MCP::Result.new(event_data, session_id: @session_id)
|
|
647
|
+
RubyLLM::MCP.logger.debug "SSE Result Received: #{result.inspect}"
|
|
648
|
+
|
|
649
|
+
result = @coordinator.process_result(result)
|
|
650
|
+
return if result.nil?
|
|
651
|
+
|
|
652
|
+
request_id = result.id&.to_s
|
|
653
|
+
if request_id
|
|
654
|
+
@pending_mutex.synchronize do
|
|
655
|
+
response_queue = @pending_requests.delete(request_id)
|
|
656
|
+
response_queue&.push(result)
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
rescue JSON::ParserError => e
|
|
660
|
+
RubyLLM::MCP.logger.warn "Failed to parse SSE event data: #{raw_event[:data]} - #{e.message}"
|
|
661
|
+
rescue Errors::UnknownRequest => e
|
|
662
|
+
RubyLLM::MCP.logger.warn "Unknown request from MCP server: #{e.message}"
|
|
663
|
+
rescue StandardError => e
|
|
664
|
+
RubyLLM::MCP.logger.error "Error processing SSE event: #{e.message}"
|
|
665
|
+
raise Errors::TransportError.new(
|
|
666
|
+
message: "Error processing SSE event: #{e.message}",
|
|
667
|
+
error: e
|
|
668
|
+
)
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def wait_for_response_with_timeout(request_id, response_queue)
|
|
673
|
+
with_timeout(@request_timeout / 1000, request_id: request_id) do
|
|
674
|
+
response_queue.pop
|
|
675
|
+
end
|
|
676
|
+
rescue RubyLLM::MCP::Errors::TimeoutError => e
|
|
677
|
+
log_message = "StreamableHTTP request timeout (ID: #{request_id}) after #{@request_timeout / 1000} seconds"
|
|
678
|
+
RubyLLM::MCP.logger.error(log_message)
|
|
679
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
|
680
|
+
raise e
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def cleanup_sse_resources
|
|
684
|
+
@running = false
|
|
685
|
+
@abort_controller = true
|
|
686
|
+
|
|
687
|
+
@sse_mutex.synchronize do
|
|
688
|
+
if @sse_thread&.alive?
|
|
689
|
+
@sse_thread.kill
|
|
690
|
+
@sse_thread.join(5) # Wait up to 5 seconds for thread to finish
|
|
691
|
+
@sse_thread = nil
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Clear any pending requests
|
|
696
|
+
@pending_mutex.synchronize do
|
|
697
|
+
@pending_requests.each_value do |queue|
|
|
698
|
+
queue.close if queue.respond_to?(:close)
|
|
699
|
+
rescue StandardError
|
|
700
|
+
# Ignore errors when closing queues
|
|
701
|
+
end
|
|
702
|
+
@pending_requests.clear
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def cleanup_connection
|
|
707
|
+
clients_to_close = []
|
|
708
|
+
|
|
709
|
+
@clients_mutex.synchronize do
|
|
710
|
+
clients_to_close = @clients.dup
|
|
711
|
+
@clients.clear
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
clients_to_close.each do |client|
|
|
715
|
+
client.close if client.respond_to?(:close)
|
|
716
|
+
rescue StandardError => e
|
|
717
|
+
RubyLLM::MCP.logger.debug "Error closing HTTPX client: #{e.message}"
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
@connection = nil
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
end
|