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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ module Adapters
8
+ # RubyLLM Adapter - wraps the Native protocol implementation
9
+ # This is a thin bridge between the public API and Native::Client
10
+ class RubyLLMAdapter < BaseAdapter
11
+ extend Forwardable
12
+
13
+ supports :tools, :prompts, :resources, :resource_templates,
14
+ :completions, :logging, :sampling, :roots,
15
+ :notifications, :progress_tracking, :human_in_the_loop,
16
+ :elicitation, :subscriptions, :list_changed_notifications
17
+
18
+ supports_transport :stdio, :sse, :streamable, :streamable_http
19
+
20
+ attr_reader :native_client
21
+
22
+ def initialize(client, transport_type:, config: {})
23
+ validate_transport!(transport_type)
24
+ super
25
+
26
+ request_timeout = config.delete(:request_timeout)
27
+ transport_config = prepare_transport_config(config, transport_type)
28
+
29
+ @native_client = Native::Client.new(
30
+ name: client.name,
31
+ transport_type: transport_type,
32
+ transport_config: transport_config,
33
+ request_timeout: request_timeout,
34
+ human_in_the_loop_callback: build_human_in_the_loop_callback(client),
35
+ roots_callback: -> { client.roots.paths },
36
+ logging_enabled: client.logging_handler_enabled?,
37
+ logging_level: client.on_logging_level,
38
+ elicitation_enabled: client.elicitation_enabled?,
39
+ progress_tracking_enabled: client.tracking_progress?,
40
+ elicitation_callback: ->(elicitation) { client.on[:elicitation]&.call(elicitation) },
41
+ sampling_callback: ->(sample) { client.on[:sampling]&.call(sample) },
42
+ notification_callback: ->(notification) { NotificationHandler.new(client).execute(notification) }
43
+ )
44
+ end
45
+
46
+ def_delegators :@native_client,
47
+ :start, :stop, :restart!, :alive?, :ping,
48
+ :capabilities, :client_capabilities, :protocol_version,
49
+ :tool_list, :execute_tool,
50
+ :resource_list, :resource_read, :resource_template_list,
51
+ :resources_subscribe,
52
+ :prompt_list, :execute_prompt,
53
+ :completion_resource, :completion_prompt,
54
+ :set_logging, :set_progress_tracking,
55
+ :initialize_notification, :cancelled_notification,
56
+ :roots_list_change_notification,
57
+ :ping_response, :roots_list_response,
58
+ :sampling_create_message_response,
59
+ :error_response, :elicitation_response,
60
+ :register_in_flight_request, :unregister_in_flight_request,
61
+ :cancel_in_flight_request
62
+
63
+ def register_resource(resource)
64
+ client.linked_resources << resource
65
+ client.resources[resource.name] = resource
66
+ end
67
+
68
+ private
69
+
70
+ def prepare_transport_config(config, transport_type)
71
+ transport_config = config.dup
72
+
73
+ if %i[sse streamable streamable_http].include?(transport_type)
74
+ oauth_provider = Auth::TransportOauthHelper.create_oauth_provider(transport_config) if Auth::TransportOauthHelper.oauth_config_present?(transport_config)
75
+
76
+ Auth::TransportOauthHelper.prepare_http_transport_config(transport_config, oauth_provider)
77
+ elsif transport_type == :stdio
78
+ Auth::TransportOauthHelper.prepare_stdio_transport_config(transport_config)
79
+ else
80
+ transport_config
81
+ end
82
+ end
83
+
84
+ def build_human_in_the_loop_callback(client)
85
+ lambda do |name, params|
86
+ !client.human_in_the_loop? || client.on[:human_in_the_loop].call(name, params)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Attachment < RubyLLM::Attachment
6
+ attr_reader :content, :mime_type
7
+
8
+ def initialize(content, mime_type) # rubocop:disable Lint/MissingSuper
9
+ @content = content
10
+ @mime_type = mime_type
11
+ end
12
+
13
+ def encoded
14
+ @content
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module Browser
7
+ # Handles OAuth callback request processing
8
+ # Extracts and validates OAuth parameters from callback requests
9
+ class CallbackHandler
10
+ attr_reader :callback_path, :logger
11
+
12
+ def initialize(callback_path:, logger: nil)
13
+ @callback_path = callback_path
14
+ @logger = logger || MCP.logger
15
+ end
16
+
17
+ # Validate that the request path matches the expected callback path
18
+ # @param path [String] request path
19
+ # @return [Boolean] true if path is valid
20
+ def valid_callback_path?(path)
21
+ uri_path, = path.split("?", 2)
22
+ uri_path == @callback_path
23
+ end
24
+
25
+ # Parse callback parameters from path
26
+ # @param path [String] full request path with query string
27
+ # @param http_server [HttpServer] HTTP server instance for parsing
28
+ # @return [Hash] parsed parameters
29
+ def parse_callback_params(path, http_server)
30
+ _, query_string = path.split("?", 2)
31
+ params = http_server.parse_query_params(query_string || "")
32
+ @logger.debug("Callback params: #{params.keys.join(', ')}")
33
+ params
34
+ end
35
+
36
+ # Extract OAuth parameters from parsed params
37
+ # @param params [Hash] parsed query parameters
38
+ # @return [Hash] OAuth parameters (code, state, error, error_description)
39
+ def extract_oauth_params(params)
40
+ {
41
+ code: params["code"],
42
+ state: params["state"],
43
+ error: params["error"],
44
+ error_description: params["error_description"]
45
+ }
46
+ end
47
+
48
+ # Update result hash with OAuth parameters (thread-safe)
49
+ # @param oauth_params [Hash] OAuth parameters
50
+ # @param result [Hash] result container
51
+ # @param mutex [Mutex] synchronization mutex
52
+ # @param condition [ConditionVariable] wait condition
53
+ def update_result_with_oauth_params(oauth_params, result, mutex, condition)
54
+ mutex.synchronize do
55
+ if oauth_params[:error]
56
+ result[:error] = oauth_params[:error_description] || oauth_params[:error]
57
+ elsif oauth_params[:code] && oauth_params[:state]
58
+ result[:code] = oauth_params[:code]
59
+ result[:state] = oauth_params[:state]
60
+ else
61
+ result[:error] = "Invalid callback: missing code or state parameter"
62
+ end
63
+ result[:completed] = true
64
+ condition.signal
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module Browser
7
+ # Callback server wrapper for clean shutdown
8
+ # Manages server lifecycle and thread coordination
9
+ class CallbackServer
10
+ def initialize(server, thread, stop_proc)
11
+ @server = server
12
+ @thread = thread
13
+ @stop_proc = stop_proc
14
+ end
15
+
16
+ # Shutdown server and cleanup resources
17
+ # @return [nil] always returns nil
18
+ def shutdown
19
+ @stop_proc.call
20
+ @server.close unless @server.closed?
21
+ @thread.join(5) # Wait max 5 seconds for thread to finish
22
+ rescue StandardError
23
+ # Ignore shutdown errors
24
+ nil
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module Browser
7
+ # HTTP server utilities for OAuth callback handling
8
+ # Provides lightweight HTTP server functionality without external dependencies
9
+ class HttpServer
10
+ attr_reader :port, :logger
11
+
12
+ def initialize(port:, logger: nil)
13
+ @port = port
14
+ @logger = logger || MCP.logger
15
+ end
16
+
17
+ # Start TCP server for OAuth callbacks
18
+ # @return [TCPServer] TCP server instance
19
+ # @raise [Errors::TransportError] if server cannot start
20
+ def start_server
21
+ TCPServer.new("127.0.0.1", @port)
22
+ rescue Errno::EADDRINUSE
23
+ raise Errors::TransportError.new(
24
+ message: "Cannot start OAuth callback server: port #{@port} is already in use. " \
25
+ "Please close the application using this port or choose a different callback_port."
26
+ )
27
+ rescue StandardError => e
28
+ raise Errors::TransportError.new(
29
+ message: "Failed to start OAuth callback server on port #{@port}: #{e.message}"
30
+ )
31
+ end
32
+
33
+ # Configure client socket with timeout
34
+ # @param client [TCPSocket] client socket
35
+ def configure_client_socket(client)
36
+ client.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
37
+ end
38
+
39
+ # Read HTTP request line from client
40
+ # @param client [TCPSocket] client socket
41
+ # @return [String, nil] request line
42
+ def read_request_line(client)
43
+ client.gets
44
+ end
45
+
46
+ # Extract method and path from request line
47
+ # @param request_line [String] HTTP request line
48
+ # @return [Array<String, String>, nil] method and path, or nil if invalid
49
+ def extract_request_parts(request_line)
50
+ parts = request_line.split
51
+ return nil unless parts.length >= 2
52
+
53
+ parts[0..1]
54
+ end
55
+
56
+ # Read HTTP headers from client
57
+ # @param client [TCPSocket] client socket
58
+ def read_http_headers(client)
59
+ header_count = 0
60
+ loop do
61
+ break if header_count >= 100
62
+
63
+ line = client.gets
64
+ break if line.nil? || line.strip.empty?
65
+
66
+ header_count += 1
67
+ end
68
+ end
69
+
70
+ # Parse URL query parameters
71
+ # @param query_string [String] query string
72
+ # @return [Hash] parsed parameters
73
+ def parse_query_params(query_string)
74
+ params = {}
75
+ query_string.split("&").each do |param|
76
+ next if param.empty?
77
+
78
+ key, value = param.split("=", 2)
79
+ params[CGI.unescape(key)] = CGI.unescape(value || "")
80
+ end
81
+ params
82
+ end
83
+
84
+ # Send HTTP response to client
85
+ # @param client [TCPSocket] client socket
86
+ # @param status [Integer] HTTP status code
87
+ # @param content_type [String] content type
88
+ # @param body [String] response body
89
+ def send_http_response(client, status, content_type, body)
90
+ status_text = case status
91
+ when 200 then "OK"
92
+ when 400 then "Bad Request"
93
+ when 404 then "Not Found"
94
+ else "Unknown"
95
+ end
96
+
97
+ response = "HTTP/1.1 #{status} #{status_text}\r\n"
98
+ response += "Content-Type: #{content_type}\r\n"
99
+ response += "Content-Length: #{body.bytesize}\r\n"
100
+ response += "Connection: close\r\n"
101
+ response += "\r\n"
102
+ response += body
103
+
104
+ client.write(response)
105
+ rescue IOError, Errno::EPIPE => e
106
+ @logger.debug("Error sending response: #{e.message}")
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module Browser
7
+ # Browser opening utilities for different operating systems
8
+ # Handles cross-platform browser launching
9
+ class Opener
10
+ attr_reader :logger
11
+
12
+ def initialize(logger: nil)
13
+ @logger = logger || MCP.logger
14
+ end
15
+
16
+ # Open browser to URL
17
+ # @param url [String] URL to open
18
+ # @return [Boolean] true if successful
19
+ def open_browser(url)
20
+ case RbConfig::CONFIG["host_os"]
21
+ when /darwin/
22
+ system("open", url)
23
+ when /linux|bsd/
24
+ system("xdg-open", url)
25
+ when /mswin|mingw|cygwin/
26
+ system("start", url)
27
+ else
28
+ @logger.warn("Unknown operating system, cannot open browser automatically")
29
+ false
30
+ end
31
+ rescue StandardError => e
32
+ @logger.warn("Failed to open browser: #{e.message}")
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end