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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +277 -0
  4. data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
  5. data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
  6. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  16. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  17. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  18. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  19. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  20. data/lib/ruby_llm/chat.rb +34 -0
  21. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
  22. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
  23. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
  24. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
  25. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
  26. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
  27. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
  28. data/lib/ruby_llm/mcp/attachment.rb +18 -0
  29. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  30. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  31. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
  32. data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
  33. data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
  34. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
  35. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  36. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  37. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  38. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  39. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  40. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  41. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  42. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
  43. data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
  44. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
  45. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  46. data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
  47. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  48. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  49. data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
  50. data/lib/ruby_llm/mcp/auth.rb +359 -0
  51. data/lib/ruby_llm/mcp/client.rb +401 -0
  52. data/lib/ruby_llm/mcp/completion.rb +16 -0
  53. data/lib/ruby_llm/mcp/configuration.rb +310 -0
  54. data/lib/ruby_llm/mcp/content.rb +28 -0
  55. data/lib/ruby_llm/mcp/elicitation.rb +48 -0
  56. data/lib/ruby_llm/mcp/error.rb +34 -0
  57. data/lib/ruby_llm/mcp/errors.rb +91 -0
  58. data/lib/ruby_llm/mcp/logging.rb +16 -0
  59. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
  60. data/lib/ruby_llm/mcp/native/client.rb +387 -0
  61. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  62. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  63. data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
  64. data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
  65. data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
  66. data/lib/ruby_llm/mcp/native/messages.rb +36 -0
  67. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  68. data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
  69. data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
  70. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  71. data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
  72. data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
  73. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
  74. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  75. data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
  76. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  77. data/lib/ruby_llm/mcp/native.rb +12 -0
  78. data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
  79. data/lib/ruby_llm/mcp/progress.rb +35 -0
  80. data/lib/ruby_llm/mcp/prompt.rb +132 -0
  81. data/lib/ruby_llm/mcp/railtie.rb +14 -0
  82. data/lib/ruby_llm/mcp/resource.rb +112 -0
  83. data/lib/ruby_llm/mcp/resource_template.rb +85 -0
  84. data/lib/ruby_llm/mcp/result.rb +108 -0
  85. data/lib/ruby_llm/mcp/roots.rb +45 -0
  86. data/lib/ruby_llm/mcp/sample.rb +152 -0
  87. data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
  88. data/lib/ruby_llm/mcp/tool.rb +228 -0
  89. data/lib/ruby_llm/mcp/version.rb +7 -0
  90. data/lib/ruby_llm/mcp.rb +125 -0
  91. data/lib/tasks/release.rake +23 -0
  92. metadata +184 -0
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Native
6
+ module Transports
7
+ class Stdio
8
+ include Support::Timeout
9
+
10
+ attr_reader :command, :stdin, :stdout, :stderr, :id, :coordinator
11
+
12
+ # Default environment that merges with user-provided env
13
+ # This ensures PATH and other critical env vars are preserved
14
+ DEFAULT_ENV = ENV.to_h.freeze
15
+
16
+ def initialize(command:, coordinator:, request_timeout:, args: [], env: {})
17
+ @request_timeout = request_timeout
18
+ @command = command
19
+ @coordinator = coordinator
20
+ @args = args
21
+ # Merge provided env with default environment (user env takes precedence)
22
+ @env = DEFAULT_ENV.merge(env || {})
23
+ @client_id = SecureRandom.uuid
24
+
25
+ @id_counter = 0
26
+ @id_mutex = Mutex.new
27
+ @pending_requests = {}
28
+ @pending_mutex = Mutex.new
29
+ @state_mutex = Mutex.new
30
+ @running = false
31
+ @reader_thread = nil
32
+ @stderr_thread = nil
33
+ end
34
+
35
+ def request(body, wait_for_response: true)
36
+ request_id = prepare_request_id(body, wait_for_response)
37
+ response_queue = register_pending_request(request_id, wait_for_response)
38
+
39
+ send_request(body, request_id)
40
+
41
+ return unless wait_for_response
42
+
43
+ wait_for_request_response(request_id, response_queue)
44
+ end
45
+
46
+ def alive?
47
+ running?
48
+ end
49
+
50
+ def running?
51
+ @state_mutex.synchronize { @running }
52
+ end
53
+
54
+ def start
55
+ @state_mutex.synchronize do
56
+ return if @running
57
+
58
+ @running = true
59
+ end
60
+ start_process
61
+ end
62
+
63
+ def close
64
+ @state_mutex.synchronize do
65
+ return unless @running
66
+
67
+ @running = false
68
+ end
69
+ shutdown_process
70
+ fail_pending_requests!(RubyLLM::MCP::Errors::TransportError.new(message: "Transport closed"))
71
+ end
72
+
73
+ def set_protocol_version(version)
74
+ @protocol_version = version
75
+ end
76
+
77
+ private
78
+
79
+ def prepare_request_id(body, wait_for_response)
80
+ request_id = body["id"] || body[:id]
81
+
82
+ if wait_for_response && request_id.nil?
83
+ raise ArgumentError, "Request ID must be provided in message body when wait_for_response is true"
84
+ end
85
+
86
+ request_id
87
+ end
88
+
89
+ def register_pending_request(request_id, wait_for_response)
90
+ return nil unless wait_for_response
91
+
92
+ response_queue = Queue.new
93
+ @pending_mutex.synchronize do
94
+ @pending_requests[request_id.to_s] = response_queue
95
+ end
96
+ response_queue
97
+ end
98
+
99
+ def send_request(body, request_id)
100
+ body = JSON.generate(body)
101
+ RubyLLM::MCP.logger.debug "Sending Request: #{body}"
102
+ @stdin.puts(body)
103
+ @stdin.flush
104
+ rescue IOError, Errno::EPIPE => e
105
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
106
+ raise RubyLLM::MCP::Errors::TransportError.new(message: e.message, error: e)
107
+ end
108
+
109
+ def wait_for_request_response(request_id, response_queue)
110
+ with_timeout(@request_timeout / 1000, request_id: request_id) do
111
+ response_queue.pop
112
+ end
113
+ rescue RubyLLM::MCP::Errors::TimeoutError => e
114
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
115
+ log_message = "Stdio request timeout (ID: #{request_id}) after #{@request_timeout / 1000} seconds"
116
+ RubyLLM::MCP.logger.error(log_message)
117
+ raise e
118
+ end
119
+
120
+ def start_process
121
+ shutdown_process if @stdin || @stdout || @stderr || @wait_thread
122
+
123
+ # Always pass env - it now includes defaults merged with user overrides
124
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
125
+
126
+ start_reader_thread
127
+ start_stderr_thread
128
+ end
129
+
130
+ def shutdown_process
131
+ close_stdin
132
+ terminate_child_process
133
+ close_output_streams
134
+ join_reader_threads
135
+ clear_process_handles
136
+ end
137
+
138
+ def close_stdin
139
+ @stdin&.close
140
+ rescue IOError, Errno::EBADF
141
+ # Already closed
142
+ end
143
+
144
+ def terminate_child_process
145
+ return unless @wait_thread
146
+
147
+ @wait_thread.join(1) if @wait_thread.alive? # 1s grace period
148
+ send_signal_to_process("TERM", 2) if @wait_thread.alive?
149
+ send_signal_to_process("KILL", 0) if @wait_thread.alive?
150
+ end
151
+
152
+ def send_signal_to_process(signal, wait_time)
153
+ Process.kill(signal, @wait_thread.pid)
154
+ @wait_thread.join(wait_time) if wait_time.positive?
155
+ rescue StandardError => e
156
+ RubyLLM::MCP.logger.debug "Error sending #{signal}: #{e.message}"
157
+ end
158
+
159
+ def close_output_streams
160
+ [@stdout, @stderr].each do |stream|
161
+ stream&.close
162
+ rescue IOError, Errno::EBADF
163
+ # Already closed
164
+ end
165
+ end
166
+
167
+ def join_reader_threads
168
+ [@reader_thread, @stderr_thread].each do |thread|
169
+ next unless thread&.alive?
170
+ next if Thread.current == thread # Avoid self-join deadlock
171
+
172
+ thread.join(1)
173
+ rescue StandardError => e
174
+ RubyLLM::MCP.logger.debug "Error joining thread: #{e.message}"
175
+ end
176
+ end
177
+
178
+ def clear_process_handles
179
+ @stdin = @stdout = @stderr = nil
180
+ @wait_thread = @reader_thread = @stderr_thread = nil
181
+ end
182
+
183
+ def fail_pending_requests!(error)
184
+ @pending_mutex.synchronize do
185
+ @pending_requests.each_value do |queue|
186
+ queue.push(error)
187
+ end
188
+ @pending_requests.clear
189
+ end
190
+ end
191
+
192
+ def safe_close_with_error(error)
193
+ fail_pending_requests!(error)
194
+ close
195
+ end
196
+
197
+ def start_reader_thread
198
+ @reader_thread = Thread.new do
199
+ read_stdout_loop
200
+ end
201
+ end
202
+
203
+ def read_stdout_loop
204
+ while running?
205
+ begin
206
+ handle_stdout_read
207
+ rescue IOError, Errno::EPIPE => e
208
+ handle_stream_error(e, "Reader")
209
+ break unless running?
210
+ rescue StandardError => e
211
+ RubyLLM::MCP.logger.error "Error in reader thread: #{e.message}, #{e.backtrace.join("\n")}"
212
+ sleep 1
213
+ end
214
+ end
215
+ end
216
+
217
+ def handle_stdout_read
218
+ if @stdout.closed? || @wait_thread.nil? || !@wait_thread.alive?
219
+ # Process is dead - if we're still running, this is an error
220
+ if running?
221
+ error = RubyLLM::MCP::Errors::TransportError.new(
222
+ message: "Process terminated unexpectedly"
223
+ )
224
+ safe_close_with_error(error)
225
+ end
226
+ return
227
+ end
228
+
229
+ line = @stdout.gets
230
+ return unless line && !line.strip.empty?
231
+
232
+ process_response(line.strip)
233
+ end
234
+
235
+ def handle_stream_error(error, stream_name)
236
+ if running?
237
+ RubyLLM::MCP.logger.error "#{stream_name} error: #{error.message}. Closing transport."
238
+ safe_close_with_error(error)
239
+ else
240
+ RubyLLM::MCP.logger.debug "#{stream_name} thread exiting during shutdown"
241
+ end
242
+ end
243
+
244
+ def start_stderr_thread
245
+ @stderr_thread = Thread.new do
246
+ read_stderr_loop
247
+ end
248
+ end
249
+
250
+ def read_stderr_loop
251
+ while running?
252
+ begin
253
+ handle_stderr_read
254
+ rescue IOError, Errno::EPIPE => e
255
+ handle_stream_error(e, "Stderr reader")
256
+ break unless running?
257
+ rescue StandardError => e
258
+ RubyLLM::MCP.logger.error "Error in stderr thread: #{e.message}"
259
+ sleep 1
260
+ end
261
+ end
262
+ end
263
+
264
+ def handle_stderr_read
265
+ if @stderr.closed? || @wait_thread.nil? || !@wait_thread.alive?
266
+ return
267
+ end
268
+
269
+ line = @stderr.gets
270
+ return unless line && !line.strip.empty?
271
+
272
+ RubyLLM::MCP.logger.info(line.strip)
273
+ end
274
+
275
+ def process_response(line)
276
+ response = parse_and_validate_envelope(line)
277
+ return unless response
278
+
279
+ request_id = response["id"]&.to_s
280
+ result = RubyLLM::MCP::Result.new(response)
281
+ RubyLLM::MCP.logger.debug "Result Received: #{result.inspect}"
282
+
283
+ result = @coordinator.process_result(result)
284
+ return if result.nil?
285
+
286
+ @pending_mutex.synchronize do
287
+ if result.matching_id?(request_id) && @pending_requests.key?(request_id)
288
+ response_queue = @pending_requests.delete(request_id)
289
+ response_queue&.push(result)
290
+ end
291
+ end
292
+ end
293
+
294
+ def parse_and_validate_envelope(line)
295
+ response = JSON.parse(line)
296
+
297
+ # Validate JSON-RPC envelope
298
+ validator = Native::JsonRpc::EnvelopeValidator.new(response)
299
+ unless validator.valid?
300
+ RubyLLM::MCP.logger.error("Invalid JSON-RPC envelope: #{validator.error_message}\nRaw: #{line}")
301
+
302
+ # If this is a request with an id, send an error response
303
+ if response.is_a?(Hash) && response["id"]
304
+ send_invalid_request_error(response["id"], validator.error_message)
305
+ end
306
+
307
+ return nil
308
+ end
309
+
310
+ response
311
+ rescue JSON::ParserError => e
312
+ RubyLLM::MCP.logger.error("JSON parse error: #{e.message}\nRaw response: #{line}")
313
+
314
+ # JSON-RPC 2.0 §5.1: Parse error should return error with id: null
315
+ send_parse_error(e.message)
316
+ nil
317
+ end
318
+
319
+ def send_invalid_request_error(id, detail)
320
+ error_body = Native::Messages::Responses.error(
321
+ id: id,
322
+ message: "Invalid Request",
323
+ code: Native::JsonRpc::ErrorCodes::INVALID_REQUEST,
324
+ data: { detail: detail }
325
+ )
326
+
327
+ begin
328
+ body_json = JSON.generate(error_body)
329
+ @stdin.puts(body_json)
330
+ @stdin.flush
331
+ rescue IOError, Errno::EPIPE => e
332
+ RubyLLM::MCP.logger.error("Failed to send invalid request error: #{e.message}")
333
+ end
334
+ end
335
+
336
+ def send_parse_error(detail)
337
+ error_body = Native::Messages::Responses.error(
338
+ id: nil,
339
+ message: "Parse error",
340
+ code: Native::JsonRpc::ErrorCodes::PARSE_ERROR,
341
+ data: { detail: detail }
342
+ )
343
+
344
+ begin
345
+ body_json = JSON.generate(error_body)
346
+ @stdin.puts(body_json)
347
+ @stdin.flush
348
+ rescue IOError, Errno::EPIPE => e
349
+ RubyLLM::MCP.logger.error("Failed to send parse error: #{e.message}")
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end