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,435 @@
|
|
|
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
|
+
class SSE
|
|
13
|
+
include Support::Timeout
|
|
14
|
+
|
|
15
|
+
attr_reader :headers, :id, :coordinator, :oauth_provider
|
|
16
|
+
|
|
17
|
+
def initialize(url:, coordinator:, request_timeout:, options: {})
|
|
18
|
+
@event_url = url
|
|
19
|
+
@messages_url = nil
|
|
20
|
+
@coordinator = coordinator
|
|
21
|
+
@request_timeout = request_timeout
|
|
22
|
+
@version = options[:version] || options["version"] || :http2
|
|
23
|
+
@oauth_provider = options[:oauth_provider] || options["oauth_provider"]
|
|
24
|
+
|
|
25
|
+
uri = URI.parse(url)
|
|
26
|
+
@root_url = "#{uri.scheme}://#{uri.host}"
|
|
27
|
+
@root_url += ":#{uri.port}" if uri.port != uri.default_port
|
|
28
|
+
|
|
29
|
+
@client_id = SecureRandom.uuid
|
|
30
|
+
custom_headers = options[:headers] || options["headers"] || {}
|
|
31
|
+
@headers = custom_headers.merge({
|
|
32
|
+
"Accept" => "text/event-stream",
|
|
33
|
+
"Content-Type" => "application/json",
|
|
34
|
+
"Cache-Control" => "no-cache",
|
|
35
|
+
"X-CLIENT-ID" => @client_id
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
@id_counter = 0
|
|
39
|
+
@id_mutex = Mutex.new
|
|
40
|
+
@pending_requests = {}
|
|
41
|
+
@pending_mutex = Mutex.new
|
|
42
|
+
@connection_mutex = Mutex.new
|
|
43
|
+
@running = false
|
|
44
|
+
@sse_thread = nil
|
|
45
|
+
|
|
46
|
+
RubyLLM::MCP.logger.info "Initializing SSE transport to #{@event_url} with client ID #{@client_id}"
|
|
47
|
+
RubyLLM::MCP.logger.debug "OAuth provider: #{@oauth_provider ? 'present' : 'none'}" if @oauth_provider
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def request(body, add_id: true, wait_for_response: true)
|
|
51
|
+
if add_id
|
|
52
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
|
53
|
+
request_id = @id_counter
|
|
54
|
+
body["id"] = request_id
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
response_queue = Queue.new
|
|
58
|
+
if wait_for_response
|
|
59
|
+
@pending_mutex.synchronize do
|
|
60
|
+
@pending_requests[request_id.to_s] = response_queue
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
send_request(body, request_id)
|
|
66
|
+
rescue Errors::TransportError, Errors::TimeoutError => e
|
|
67
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
|
68
|
+
RubyLLM::MCP.logger.error "Request error (ID: #{request_id}): #{e.message}"
|
|
69
|
+
raise e
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return unless wait_for_response
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
with_timeout(@request_timeout / 1000, request_id: request_id) do
|
|
76
|
+
response_queue.pop
|
|
77
|
+
end
|
|
78
|
+
rescue Errors::TimeoutError => e
|
|
79
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
|
80
|
+
RubyLLM::MCP.logger.error "SSE request timeout (ID: #{request_id}) after #{@request_timeout / 1000} seconds"
|
|
81
|
+
raise e
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def alive?
|
|
86
|
+
@running
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def start
|
|
90
|
+
return if @running
|
|
91
|
+
|
|
92
|
+
@running = true
|
|
93
|
+
start_sse_listener
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def close
|
|
97
|
+
RubyLLM::MCP.logger.info "Closing SSE transport connection"
|
|
98
|
+
@running = false
|
|
99
|
+
@sse_thread&.join(1) # Give the thread a second to clean up
|
|
100
|
+
@sse_thread = nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def set_protocol_version(version)
|
|
104
|
+
@protocol_version = version
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def send_request(body, request_id)
|
|
110
|
+
request_headers = build_request_headers
|
|
111
|
+
http_client = Support::HTTPClient.connection.with(
|
|
112
|
+
timeout: { request_timeout: @request_timeout / 1000 },
|
|
113
|
+
headers: request_headers
|
|
114
|
+
)
|
|
115
|
+
response = http_client.post(@messages_url, body: JSON.generate(body))
|
|
116
|
+
handle_httpx_error_response!(response,
|
|
117
|
+
context: { location: "message endpoint request", request_id: request_id })
|
|
118
|
+
|
|
119
|
+
unless [200, 202].include?(response.status)
|
|
120
|
+
handle_send_request_error(response)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def handle_send_request_error(response)
|
|
125
|
+
response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
|
|
126
|
+
status_code = response.respond_to?(:status) ? response.status : "Unknown"
|
|
127
|
+
|
|
128
|
+
# Try to parse JSON error
|
|
129
|
+
error_message = begin
|
|
130
|
+
error_body = JSON.parse(response_body)
|
|
131
|
+
if error_body.is_a?(Hash) && error_body["error"]
|
|
132
|
+
msg = error_body["error"]["message"] || error_body["error"]["code"] || error_body["error"].to_s
|
|
133
|
+
msg.to_s.strip.empty? ? "Empty error (full response: #{response_body})" : msg
|
|
134
|
+
else
|
|
135
|
+
response_body
|
|
136
|
+
end
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
response_body
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
full_message = "Failed to have a successful request to #{@messages_url}: #{status_code} - #{error_message}"
|
|
142
|
+
RubyLLM::MCP.logger.error(full_message)
|
|
143
|
+
|
|
144
|
+
# Special handling for 403 with OAuth
|
|
145
|
+
if status_code == 403 && @oauth_provider
|
|
146
|
+
raise Errors::TransportError.new(
|
|
147
|
+
message: "Authorization failed (403 Forbidden): #{error_message}. Check token scope and resource \
|
|
148
|
+
permissions at #{@oauth_provider.server_url}.",
|
|
149
|
+
code: status_code
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
raise Errors::TransportError.new(
|
|
154
|
+
message: full_message,
|
|
155
|
+
code: status_code
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def start_sse_listener
|
|
160
|
+
@connection_mutex.synchronize do
|
|
161
|
+
return if sse_thread_running?
|
|
162
|
+
|
|
163
|
+
RubyLLM::MCP.logger.info "Starting SSE listener thread"
|
|
164
|
+
|
|
165
|
+
response_queue = Queue.new
|
|
166
|
+
@pending_mutex.synchronize do
|
|
167
|
+
@pending_requests["endpoint"] = response_queue
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
@sse_thread = Thread.new do
|
|
171
|
+
listen_for_events while @running
|
|
172
|
+
end
|
|
173
|
+
@sse_thread.abort_on_exception = true
|
|
174
|
+
|
|
175
|
+
with_timeout(@request_timeout / 1000) do
|
|
176
|
+
endpoint = response_queue.pop
|
|
177
|
+
set_message_endpoint(endpoint)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def set_message_endpoint(endpoint)
|
|
183
|
+
uri = URI.parse(endpoint)
|
|
184
|
+
|
|
185
|
+
@messages_url = if uri.host.nil?
|
|
186
|
+
"#{@root_url}#{endpoint}"
|
|
187
|
+
else
|
|
188
|
+
endpoint
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
RubyLLM::MCP.logger.info "SSE message endpoint set to: #{@messages_url}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def sse_thread_running?
|
|
195
|
+
@sse_thread&.alive?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def listen_for_events
|
|
199
|
+
stream_events_from_server
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
handle_connection_error("SSE connection error", e)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def stream_events_from_server
|
|
205
|
+
sse_client = create_sse_client
|
|
206
|
+
response = sse_client.get(@event_url, stream: true)
|
|
207
|
+
validate_sse_response!(response)
|
|
208
|
+
process_event_stream(response)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def create_sse_client
|
|
212
|
+
stream_headers = build_request_headers
|
|
213
|
+
sse_client = HTTPX.plugin(:stream).with(headers: stream_headers)
|
|
214
|
+
return sse_client unless @version == :http1
|
|
215
|
+
|
|
216
|
+
sse_client.with(ssl: { alpn_protocols: ["http/1.1"] })
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Build request headers with OAuth authorization if available
|
|
220
|
+
def build_request_headers
|
|
221
|
+
headers = @headers.dup
|
|
222
|
+
|
|
223
|
+
if @oauth_provider
|
|
224
|
+
RubyLLM::MCP.logger.debug "OAuth provider present, attempting to get token..."
|
|
225
|
+
RubyLLM::MCP.logger.debug " Server URL: #{@oauth_provider.server_url}"
|
|
226
|
+
|
|
227
|
+
token = @oauth_provider.access_token
|
|
228
|
+
if token
|
|
229
|
+
headers["Authorization"] = token.to_header
|
|
230
|
+
RubyLLM::MCP.logger.debug "✓ Applied OAuth authorization header: #{token.to_header[0..30]}..."
|
|
231
|
+
else
|
|
232
|
+
RubyLLM::MCP.logger.warn "✗ OAuth provider present but no valid token available!"
|
|
233
|
+
RubyLLM::MCP.logger.warn " This means the token is not in storage or has expired"
|
|
234
|
+
RubyLLM::MCP.logger.warn " Check that authentication completed successfully"
|
|
235
|
+
end
|
|
236
|
+
else
|
|
237
|
+
RubyLLM::MCP.logger.debug "No OAuth provider configured for this transport"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
headers
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def validate_sse_response!(response)
|
|
244
|
+
return unless response.status >= 400
|
|
245
|
+
|
|
246
|
+
error_body = read_error_body(response)
|
|
247
|
+
|
|
248
|
+
# Try to parse as JSON to get better error details
|
|
249
|
+
error_message = begin
|
|
250
|
+
error_data = JSON.parse(error_body)
|
|
251
|
+
if error_data.is_a?(Hash) && error_data["error"]
|
|
252
|
+
msg = error_data["error"]["message"] || error_data["error"]["code"] || error_data["error"].to_s
|
|
253
|
+
# If we still don't have a message, include the full error object
|
|
254
|
+
msg.to_s.strip.empty? ? "Empty error (full response: #{error_body})" : msg
|
|
255
|
+
else
|
|
256
|
+
error_body
|
|
257
|
+
end
|
|
258
|
+
rescue JSON::ParserError
|
|
259
|
+
error_body
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
full_error_message = "HTTP #{response.status} error from SSE endpoint: #{error_message}"
|
|
263
|
+
RubyLLM::MCP.logger.error full_error_message
|
|
264
|
+
|
|
265
|
+
handle_client_error!(full_error_message, response.status, error_message) if response.status < 500
|
|
266
|
+
|
|
267
|
+
raise StandardError, full_error_message
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def handle_client_error!(full_error_message, status_code, error_message)
|
|
271
|
+
@running = false
|
|
272
|
+
|
|
273
|
+
# Special handling for 401 Unauthorized - OAuth authentication required
|
|
274
|
+
if status_code == 401
|
|
275
|
+
raise Errors::AuthenticationRequiredError.new(
|
|
276
|
+
message: "OAuth authentication required. Server returned 401 Unauthorized.",
|
|
277
|
+
code: 401
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Special handling for 403 Forbidden with OAuth
|
|
282
|
+
if status_code == 403 && @oauth_provider
|
|
283
|
+
raise Errors::TransportError.new(
|
|
284
|
+
message: "Authorization failed (403 Forbidden): #{error_message}. \
|
|
285
|
+
Check token scope and resource permissions at #{@oauth_provider.server_url}.",
|
|
286
|
+
code: status_code
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
raise Errors::TransportError.new(
|
|
291
|
+
message: full_error_message,
|
|
292
|
+
code: status_code
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def process_event_stream(response)
|
|
297
|
+
event_buffer = []
|
|
298
|
+
response.each_line do |event_line|
|
|
299
|
+
break unless handle_event_line?(event_line, event_buffer, response)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def handle_event_line?(event_line, event_buffer, response)
|
|
304
|
+
unless @running
|
|
305
|
+
response.body.close
|
|
306
|
+
return false
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
line = event_line.strip
|
|
310
|
+
|
|
311
|
+
if line.empty?
|
|
312
|
+
process_buffered_event(event_buffer)
|
|
313
|
+
else
|
|
314
|
+
event_buffer << line
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
true
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def process_buffered_event(event_buffer)
|
|
321
|
+
return unless event_buffer.any?
|
|
322
|
+
|
|
323
|
+
events = parse_event(event_buffer.join("\n"))
|
|
324
|
+
events.each { |event| process_event(event) }
|
|
325
|
+
event_buffer.clear
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def read_error_body(response)
|
|
329
|
+
# Try to read the error body from the response
|
|
330
|
+
body = ""
|
|
331
|
+
begin
|
|
332
|
+
response.each do |chunk|
|
|
333
|
+
body << chunk
|
|
334
|
+
end
|
|
335
|
+
rescue StandardError
|
|
336
|
+
# If we can't read the body, just use what we have
|
|
337
|
+
end
|
|
338
|
+
body.strip.empty? ? "No error details provided" : body.strip
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def handle_connection_error(message, error)
|
|
342
|
+
return unless @running
|
|
343
|
+
|
|
344
|
+
error_message = "#{message}: #{error.message}"
|
|
345
|
+
RubyLLM::MCP.logger.error "#{error_message}. Reconnecting in 1 seconds..."
|
|
346
|
+
sleep 1
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def handle_httpx_error_response!(response, context:)
|
|
350
|
+
return false unless response.is_a?(HTTPX::ErrorResponse)
|
|
351
|
+
|
|
352
|
+
error = response.error
|
|
353
|
+
|
|
354
|
+
if error.is_a?(HTTPX::ReadTimeoutError)
|
|
355
|
+
raise Errors::TimeoutError.new(
|
|
356
|
+
message: "Request timed out after #{@request_timeout / 1000} seconds"
|
|
357
|
+
)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
error_message = response.error&.message || "Request failed"
|
|
361
|
+
|
|
362
|
+
raise Errors::TransportError.new(
|
|
363
|
+
code: nil,
|
|
364
|
+
message: "Request Error #{context}: #{error_message}"
|
|
365
|
+
)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def process_event(raw_event)
|
|
369
|
+
# Return if we believe that are getting a partial event
|
|
370
|
+
return if raw_event[:data].nil?
|
|
371
|
+
|
|
372
|
+
if raw_event[:event] == "endpoint"
|
|
373
|
+
request_id = "endpoint"
|
|
374
|
+
event = raw_event[:data]
|
|
375
|
+
return if event.nil?
|
|
376
|
+
|
|
377
|
+
RubyLLM::MCP.logger.debug "Received endpoint event: #{event}"
|
|
378
|
+
@pending_mutex.synchronize do
|
|
379
|
+
response_queue = @pending_requests.delete(request_id)
|
|
380
|
+
response_queue&.push(event)
|
|
381
|
+
end
|
|
382
|
+
else
|
|
383
|
+
event = begin
|
|
384
|
+
JSON.parse(raw_event[:data])
|
|
385
|
+
rescue JSON::ParserError => e
|
|
386
|
+
# We can sometimes get partial endpoint events, so we will ignore them
|
|
387
|
+
unless @endpoint.nil?
|
|
388
|
+
RubyLLM::MCP.logger.info "Failed to parse SSE event data: #{raw_event[:data]} - #{e.message}"
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
return if event.nil?
|
|
394
|
+
|
|
395
|
+
request_id = event["id"]&.to_s
|
|
396
|
+
result = RubyLLM::MCP::Result.new(event)
|
|
397
|
+
|
|
398
|
+
result = @coordinator.process_result(result)
|
|
399
|
+
return if result.nil?
|
|
400
|
+
|
|
401
|
+
@pending_mutex.synchronize do
|
|
402
|
+
# You can receieve duplicate events for the same request id, and we will ignore thoses
|
|
403
|
+
if result.matching_id?(request_id) && @pending_requests.key?(request_id)
|
|
404
|
+
response_queue = @pending_requests.delete(request_id)
|
|
405
|
+
response_queue&.push(result)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def parse_event(raw)
|
|
412
|
+
event_blocks = raw.split(/\n\s*\n/)
|
|
413
|
+
|
|
414
|
+
events = event_blocks.map do |event_block|
|
|
415
|
+
event = {}
|
|
416
|
+
event_block.each_line do |line|
|
|
417
|
+
case line
|
|
418
|
+
when /^data:\s*(.*)/
|
|
419
|
+
(event[:data] ||= []) << ::Regexp.last_match(1)
|
|
420
|
+
when /^event:\s*(.*)/
|
|
421
|
+
event[:event] = ::Regexp.last_match(1)
|
|
422
|
+
when /^id:\s*(.*)/
|
|
423
|
+
event[:id] = ::Regexp.last_match(1)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
event[:data] = event[:data]&.join("\n")
|
|
427
|
+
event
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
events.reject { |event| event.empty? || event[:data].nil? }
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module RubyLLM
|
|
9
|
+
module MCP
|
|
10
|
+
module Transports
|
|
11
|
+
class Stdio
|
|
12
|
+
include Support::Timeout
|
|
13
|
+
|
|
14
|
+
attr_reader :command, :stdin, :stdout, :stderr, :id, :coordinator
|
|
15
|
+
|
|
16
|
+
def initialize(command:, coordinator:, request_timeout:, options: {})
|
|
17
|
+
@request_timeout = request_timeout
|
|
18
|
+
@command = command
|
|
19
|
+
@coordinator = coordinator
|
|
20
|
+
@args = options[:args] || options["args"] || []
|
|
21
|
+
@env = options[:env] || options["env"] || {}
|
|
22
|
+
@client_id = SecureRandom.uuid
|
|
23
|
+
# NOTE: Stdio transport doesn't use OAuth (local process communication)
|
|
24
|
+
|
|
25
|
+
@id_counter = 0
|
|
26
|
+
@id_mutex = Mutex.new
|
|
27
|
+
@pending_requests = {}
|
|
28
|
+
@pending_mutex = Mutex.new
|
|
29
|
+
@running = false
|
|
30
|
+
@reader_thread = nil
|
|
31
|
+
@stderr_thread = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def request(body, add_id: true, wait_for_response: true)
|
|
35
|
+
if add_id
|
|
36
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
|
37
|
+
request_id = @id_counter
|
|
38
|
+
body["id"] = request_id
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
response_queue = Queue.new
|
|
42
|
+
if wait_for_response
|
|
43
|
+
@pending_mutex.synchronize do
|
|
44
|
+
@pending_requests[request_id.to_s] = response_queue
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
body = JSON.generate(body)
|
|
50
|
+
RubyLLM::MCP.logger.debug "Sending Request: #{body}"
|
|
51
|
+
@stdin.puts(body)
|
|
52
|
+
@stdin.flush
|
|
53
|
+
rescue IOError, Errno::EPIPE => e
|
|
54
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
|
55
|
+
restart_process
|
|
56
|
+
raise RubyLLM::MCP::Errors::TransportError.new(message: e.message, error: e)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
return unless wait_for_response
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
with_timeout(@request_timeout / 1000, request_id: request_id) do
|
|
63
|
+
response_queue.pop
|
|
64
|
+
end
|
|
65
|
+
rescue RubyLLM::MCP::Errors::TimeoutError => e
|
|
66
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
|
67
|
+
log_message = "Stdio request timeout (ID: #{request_id}) after #{@request_timeout / 1000} seconds"
|
|
68
|
+
RubyLLM::MCP.logger.error(log_message)
|
|
69
|
+
raise e
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def alive?
|
|
74
|
+
@running
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def start
|
|
78
|
+
start_process unless @running
|
|
79
|
+
@running = true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def close
|
|
83
|
+
@running = false
|
|
84
|
+
|
|
85
|
+
[@stdin, @stdout, @stderr].each do |stream|
|
|
86
|
+
stream&.close
|
|
87
|
+
rescue IOError, Errno::EBADF
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
[@wait_thread, @reader_thread, @stderr_thread].each do |thread|
|
|
92
|
+
thread&.join(1)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@stdin = @stdout = @stderr = nil
|
|
98
|
+
@wait_thread = @reader_thread = @stderr_thread = nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def set_protocol_version(version)
|
|
102
|
+
@protocol_version = version
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def start_process
|
|
108
|
+
close if @stdin || @stdout || @stderr || @wait_thread
|
|
109
|
+
|
|
110
|
+
@stdin, @stdout, @stderr, @wait_thread = if @env.empty?
|
|
111
|
+
Open3.popen3(@command, *@args)
|
|
112
|
+
else
|
|
113
|
+
Open3.popen3(@env, @command, *@args)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
start_reader_thread
|
|
117
|
+
start_stderr_thread
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def restart_process
|
|
121
|
+
RubyLLM::MCP.logger.error "Process connection lost. Restarting..."
|
|
122
|
+
start_process
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def start_reader_thread
|
|
126
|
+
@reader_thread = Thread.new do
|
|
127
|
+
read_stdout_loop
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@reader_thread.abort_on_exception = true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def read_stdout_loop
|
|
134
|
+
while @running
|
|
135
|
+
begin
|
|
136
|
+
handle_stdout_read
|
|
137
|
+
rescue IOError, Errno::EPIPE => e
|
|
138
|
+
handle_stream_error(e, "Reader")
|
|
139
|
+
break unless @running
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
RubyLLM::MCP.logger.error "Error in reader thread: #{e.message}, #{e.backtrace.join("\n")}"
|
|
142
|
+
sleep 1
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def handle_stdout_read
|
|
148
|
+
if @stdout.closed? || @wait_thread.nil? || !@wait_thread.alive?
|
|
149
|
+
if @running
|
|
150
|
+
sleep 1
|
|
151
|
+
restart_process
|
|
152
|
+
end
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
line = @stdout.gets
|
|
157
|
+
return unless line && !line.strip.empty?
|
|
158
|
+
|
|
159
|
+
process_response(line.strip)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def handle_stream_error(error, stream_name)
|
|
163
|
+
# Check @running to distinguish graceful shutdown from unexpected errors.
|
|
164
|
+
# During shutdown, streams are closed intentionally and shouldn't trigger restarts.
|
|
165
|
+
if @running
|
|
166
|
+
RubyLLM::MCP.logger.error "#{stream_name} error: #{error.message}. Restarting in 1 second..."
|
|
167
|
+
sleep 1
|
|
168
|
+
restart_process
|
|
169
|
+
else
|
|
170
|
+
# Graceful shutdown in progress
|
|
171
|
+
RubyLLM::MCP.logger.debug "#{stream_name} thread exiting during shutdown"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def start_stderr_thread
|
|
176
|
+
@stderr_thread = Thread.new do
|
|
177
|
+
read_stderr_loop
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
@stderr_thread.abort_on_exception = true
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def read_stderr_loop
|
|
184
|
+
while @running
|
|
185
|
+
begin
|
|
186
|
+
handle_stderr_read
|
|
187
|
+
rescue IOError, Errno::EPIPE => e
|
|
188
|
+
handle_stream_error(e, "Stderr reader")
|
|
189
|
+
break unless @running
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
RubyLLM::MCP.logger.error "Error in stderr thread: #{e.message}"
|
|
192
|
+
sleep 1
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def handle_stderr_read
|
|
198
|
+
if @stderr.closed? || @wait_thread.nil? || !@wait_thread.alive?
|
|
199
|
+
sleep 1
|
|
200
|
+
return
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
line = @stderr.gets
|
|
204
|
+
return unless line && !line.strip.empty?
|
|
205
|
+
|
|
206
|
+
RubyLLM::MCP.logger.info(line.strip)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def process_response(line)
|
|
210
|
+
response = JSON.parse(line)
|
|
211
|
+
request_id = response["id"]&.to_s
|
|
212
|
+
result = RubyLLM::MCP::Result.new(response)
|
|
213
|
+
RubyLLM::MCP.logger.debug "Result Received: #{result.inspect}"
|
|
214
|
+
|
|
215
|
+
result = @coordinator.process_result(result)
|
|
216
|
+
return if result.nil?
|
|
217
|
+
|
|
218
|
+
# Handle regular responses (tool calls, etc.)
|
|
219
|
+
@pending_mutex.synchronize do
|
|
220
|
+
if result.matching_id?(request_id) && @pending_requests.key?(request_id)
|
|
221
|
+
response_queue = @pending_requests.delete(request_id)
|
|
222
|
+
response_queue&.push(result)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
rescue JSON::ParserError => e
|
|
226
|
+
RubyLLM::MCP.logger.error("Error parsing response as JSON: #{e.message}\nRaw response: #{line}")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|