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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -44
  3. data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +4 -21
  4. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +0 -20
  5. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +3 -0
  6. data/lib/ruby_llm/mcp/auth/browser/opener.rb +2 -0
  7. data/lib/ruby_llm/mcp/auth/browser/pages.rb +32 -100
  8. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +6 -32
  9. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +2 -0
  10. data/lib/ruby_llm/mcp/auth/memory_storage.rb +0 -18
  11. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +3 -82
  12. data/lib/ruby_llm/mcp/auth/session_manager.rb +2 -0
  13. data/lib/ruby_llm/mcp/auth/url_builder.rb +2 -0
  14. data/lib/ruby_llm/mcp/client.rb +32 -119
  15. data/lib/ruby_llm/mcp/configuration.rb +6 -74
  16. data/lib/ruby_llm/mcp/coordinator.rb +304 -0
  17. data/lib/ruby_llm/mcp/elicitation.rb +6 -8
  18. data/lib/ruby_llm/mcp/errors.rb +0 -15
  19. data/lib/ruby_llm/mcp/notification_handler.rb +5 -21
  20. data/lib/ruby_llm/mcp/notifications/cancelled.rb +32 -0
  21. data/lib/ruby_llm/mcp/notifications/initialize.rb +24 -0
  22. data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +26 -0
  23. data/lib/ruby_llm/mcp/prompt.rb +7 -7
  24. data/lib/ruby_llm/mcp/protocol.rb +34 -0
  25. data/lib/ruby_llm/mcp/railtie.rb +6 -8
  26. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +50 -0
  27. data/lib/ruby_llm/mcp/requests/completion_resource.rb +50 -0
  28. data/lib/ruby_llm/mcp/requests/initialization.rb +34 -0
  29. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +28 -0
  30. data/lib/ruby_llm/mcp/requests/ping.rb +24 -0
  31. data/lib/ruby_llm/mcp/requests/prompt_call.rb +32 -0
  32. data/lib/ruby_llm/mcp/requests/prompt_list.rb +31 -0
  33. data/lib/ruby_llm/mcp/requests/resource_list.rb +31 -0
  34. data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
  35. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +31 -0
  36. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +30 -0
  37. data/lib/ruby_llm/mcp/requests/shared/meta.rb +32 -0
  38. data/lib/ruby_llm/mcp/requests/shared/pagination.rb +17 -0
  39. data/lib/ruby_llm/mcp/requests/tool_call.rb +35 -0
  40. data/lib/ruby_llm/mcp/requests/tool_list.rb +31 -0
  41. data/lib/ruby_llm/mcp/resource.rb +8 -6
  42. data/lib/ruby_llm/mcp/resource_template.rb +7 -7
  43. data/lib/ruby_llm/mcp/response_handler.rb +67 -0
  44. data/lib/ruby_llm/mcp/responses/elicitation.rb +33 -0
  45. data/lib/ruby_llm/mcp/responses/error.rb +33 -0
  46. data/lib/ruby_llm/mcp/responses/ping.rb +28 -0
  47. data/lib/ruby_llm/mcp/responses/roots_list.rb +31 -0
  48. data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +50 -0
  49. data/lib/ruby_llm/mcp/result.rb +4 -8
  50. data/lib/ruby_llm/mcp/roots.rb +4 -4
  51. data/lib/ruby_llm/mcp/sample.rb +2 -6
  52. data/lib/ruby_llm/mcp/tool.rb +9 -9
  53. data/lib/ruby_llm/mcp/transport.rb +151 -0
  54. data/lib/ruby_llm/mcp/transports/sse.rb +435 -0
  55. data/lib/ruby_llm/mcp/transports/stdio.rb +231 -0
  56. data/lib/ruby_llm/mcp/transports/streamable_http.rb +725 -0
  57. data/lib/ruby_llm/mcp/transports/support/http_client.rb +28 -0
  58. data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +47 -0
  59. data/lib/ruby_llm/mcp/transports/support/timeout.rb +34 -0
  60. data/lib/ruby_llm/mcp/version.rb +1 -1
  61. data/lib/ruby_llm/mcp.rb +7 -30
  62. metadata +38 -33
  63. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +0 -179
  64. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +0 -292
  65. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +0 -33
  66. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +0 -52
  67. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +0 -52
  68. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +0 -86
  69. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +0 -92
  70. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +0 -107
  71. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +0 -57
  72. data/lib/ruby_llm/mcp/native/client.rb +0 -387
  73. data/lib/ruby_llm/mcp/native/json_rpc.rb +0 -170
  74. data/lib/ruby_llm/mcp/native/messages/helpers.rb +0 -39
  75. data/lib/ruby_llm/mcp/native/messages/notifications.rb +0 -42
  76. data/lib/ruby_llm/mcp/native/messages/requests.rb +0 -206
  77. data/lib/ruby_llm/mcp/native/messages/responses.rb +0 -106
  78. data/lib/ruby_llm/mcp/native/messages.rb +0 -36
  79. data/lib/ruby_llm/mcp/native/notification.rb +0 -16
  80. data/lib/ruby_llm/mcp/native/protocol.rb +0 -36
  81. data/lib/ruby_llm/mcp/native/response_handler.rb +0 -110
  82. data/lib/ruby_llm/mcp/native/transport.rb +0 -88
  83. data/lib/ruby_llm/mcp/native/transports/sse.rb +0 -607
  84. data/lib/ruby_llm/mcp/native/transports/stdio.rb +0 -356
  85. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +0 -926
  86. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +0 -28
  87. data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +0 -49
  88. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +0 -36
  89. 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