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,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module Flows
7
+ # Orchestrates OAuth 2.1 Client Credentials flow
8
+ # Used for application authentication without user interaction
9
+ class ClientCredentialsFlow
10
+ attr_reader :discoverer, :client_registrar, :token_manager, :storage, :logger
11
+
12
+ def initialize(discoverer:, client_registrar:, token_manager:, storage:, logger:)
13
+ @discoverer = discoverer
14
+ @client_registrar = client_registrar
15
+ @token_manager = token_manager
16
+ @storage = storage
17
+ @logger = logger
18
+ end
19
+
20
+ # Perform client credentials flow
21
+ # @param server_url [String] MCP server URL
22
+ # @param redirect_uri [String] redirect URI (used for registration only)
23
+ # @param scope [String, nil] requested scope
24
+ # @return [Token] access token
25
+ def execute(server_url, redirect_uri, scope)
26
+ logger.debug("Starting OAuth client credentials flow")
27
+
28
+ # 1. Discover authorization server
29
+ server_metadata = discoverer.discover(server_url)
30
+ raise Errors::TransportError.new(message: "OAuth server discovery failed") unless server_metadata
31
+
32
+ # 2. Register client (or get cached client) with client credentials grant
33
+ client_info = client_registrar.get_or_register(
34
+ server_url,
35
+ server_metadata,
36
+ :client_credentials,
37
+ redirect_uri,
38
+ scope
39
+ )
40
+
41
+ # 3. Validate that we have a client secret
42
+ unless client_info.client_secret
43
+ raise Errors::TransportError.new(
44
+ message: "Client credentials flow requires client_secret"
45
+ )
46
+ end
47
+
48
+ # 4. Exchange client credentials for token
49
+ token = token_manager.exchange_client_credentials(
50
+ server_metadata,
51
+ client_info,
52
+ scope,
53
+ server_url
54
+ )
55
+
56
+ # 5. Store token
57
+ storage.set_token(server_url, token)
58
+
59
+ logger.info("Client credentials authentication completed successfully")
60
+ token
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module GrantStrategies
7
+ # Authorization Code grant strategy
8
+ # Used for user authorization with PKCE (OAuth 2.1)
9
+ class AuthorizationCode < Base
10
+ # Public clients don't use client_secret
11
+ # @return [String] "none"
12
+ def auth_method
13
+ "none"
14
+ end
15
+
16
+ # Authorization code and refresh token grants
17
+ # @return [Array<String>] grant types
18
+ def grant_types_list
19
+ %w[authorization_code refresh_token]
20
+ end
21
+
22
+ # Only "code" response type for authorization code flow
23
+ # @return [Array<String>] response types
24
+ def response_types_list
25
+ ["code"]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module GrantStrategies
7
+ # Base strategy for OAuth grant types
8
+ # Defines interface for grant-specific configuration
9
+ class Base
10
+ # Get token endpoint authentication method
11
+ # @return [String] auth method (e.g., "none", "client_secret_post")
12
+ def auth_method
13
+ raise NotImplementedError, "#{self.class} must implement #auth_method"
14
+ end
15
+
16
+ # Get list of grant types to request during registration
17
+ # @return [Array<String>] grant types
18
+ def grant_types_list
19
+ raise NotImplementedError, "#{self.class} must implement #grant_types_list"
20
+ end
21
+
22
+ # Get list of response types to request during registration
23
+ # @return [Array<String>] response types
24
+ def response_types_list
25
+ raise NotImplementedError, "#{self.class} must implement #response_types_list"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module GrantStrategies
7
+ # Client Credentials grant strategy
8
+ # Used for application authentication without user interaction
9
+ class ClientCredentials < Base
10
+ # Client credentials require client_secret
11
+ # @return [String] "client_secret_post"
12
+ def auth_method
13
+ "client_secret_post"
14
+ end
15
+
16
+ # Client credentials and refresh token grants
17
+ # @return [Array<String>] grant types
18
+ def grant_types_list
19
+ %w[client_credentials refresh_token]
20
+ end
21
+
22
+ # No response types for client credentials flow (no redirect)
23
+ # @return [Array<String>] response types (empty)
24
+ def response_types_list
25
+ []
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Utility class for handling HTTP responses in OAuth flows
7
+ # Consolidates error handling and response parsing
8
+ class HttpResponseHandler
9
+ # Handle and parse a successful HTTP response
10
+ # @param response [HTTPX::Response, HTTPX::ErrorResponse] HTTP response
11
+ # @param context [String] description for error messages (e.g., "Token exchange")
12
+ # @param expected_status [Integer, Array<Integer>] expected status code(s)
13
+ # @return [Hash] parsed JSON response
14
+ # @raise [Errors::TransportError] if response is an error or unexpected status
15
+ def self.handle_response(response, context:, expected_status: 200)
16
+ expected_statuses = Array(expected_status)
17
+
18
+ # Handle HTTPX ErrorResponse (connection failures, timeouts, etc.)
19
+ if response.is_a?(HTTPX::ErrorResponse)
20
+ error_message = response.error&.message || "Request failed"
21
+ raise Errors::TransportError.new(
22
+ message: "#{context} failed: #{error_message}"
23
+ )
24
+ end
25
+
26
+ unless expected_statuses.include?(response.status)
27
+ raise Errors::TransportError.new(
28
+ message: "#{context} failed: HTTP #{response.status}",
29
+ code: response.status
30
+ )
31
+ end
32
+
33
+ JSON.parse(response.body.to_s)
34
+ end
35
+
36
+ # Extract redirect URI mismatch details from error response
37
+ # @param body [String] error response body
38
+ # @return [Hash, nil] mismatch details or nil
39
+ def self.extract_redirect_mismatch(body)
40
+ data = JSON.parse(body)
41
+ error = data["error"] || data[:error]
42
+ return nil unless error == "unauthorized_client"
43
+
44
+ description = data["error_description"] || data[:error_description]
45
+ return nil unless description.is_a?(String)
46
+
47
+ # Parse common OAuth error message format
48
+ # Matches: "You sent <url> and we expected <url>"
49
+ match = description.match(%r{You sent\s+(https?://[^\s,]+)[\s,]+and we expected\s+(https?://\S+?)\.?\s*$}i)
50
+ return nil unless match
51
+
52
+ {
53
+ sent: match[1],
54
+ expected: match[2],
55
+ description: description
56
+ }
57
+ rescue JSON::ParserError
58
+ nil
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # In-memory storage for OAuth data
7
+ # Stores tokens, client registrations, server metadata, and temporary session data
8
+ class MemoryStorage
9
+ def initialize
10
+ @tokens = {}
11
+ @client_infos = {}
12
+ @server_metadata = {}
13
+ @pkce_data = {}
14
+ @state_data = {}
15
+ @resource_metadata = {}
16
+ end
17
+
18
+ # Token storage
19
+ def get_token(server_url)
20
+ @tokens[server_url]
21
+ end
22
+
23
+ def set_token(server_url, token)
24
+ @tokens[server_url] = token
25
+ end
26
+
27
+ def delete_token(server_url)
28
+ @tokens.delete(server_url)
29
+ end
30
+
31
+ # Client registration storage
32
+ def get_client_info(server_url)
33
+ @client_infos[server_url]
34
+ end
35
+
36
+ def set_client_info(server_url, client_info)
37
+ @client_infos[server_url] = client_info
38
+ end
39
+
40
+ # Server metadata caching
41
+ def get_server_metadata(server_url)
42
+ @server_metadata[server_url]
43
+ end
44
+
45
+ def set_server_metadata(server_url, metadata)
46
+ @server_metadata[server_url] = metadata
47
+ end
48
+
49
+ # PKCE state management (temporary)
50
+ def get_pkce(server_url)
51
+ @pkce_data[server_url]
52
+ end
53
+
54
+ def set_pkce(server_url, pkce)
55
+ @pkce_data[server_url] = pkce
56
+ end
57
+
58
+ def delete_pkce(server_url)
59
+ @pkce_data.delete(server_url)
60
+ end
61
+
62
+ # State parameter management (temporary)
63
+ def get_state(server_url)
64
+ @state_data[server_url]
65
+ end
66
+
67
+ def set_state(server_url, state)
68
+ @state_data[server_url] = state
69
+ end
70
+
71
+ def delete_state(server_url)
72
+ @state_data.delete(server_url)
73
+ end
74
+
75
+ # Resource metadata management
76
+ def get_resource_metadata(server_url)
77
+ @resource_metadata[server_url]
78
+ end
79
+
80
+ def set_resource_metadata(server_url, metadata)
81
+ @resource_metadata[server_url] = metadata
82
+ end
83
+
84
+ def delete_resource_metadata(server_url)
85
+ @resource_metadata.delete(server_url)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Core OAuth 2.1 provider implementing complete authorization flow
7
+ # Supports RFC 7636 (PKCE), RFC 7591 (Dynamic Registration),
8
+ # RFC 8414 (Server Metadata), RFC 8707 (Resource Indicators), RFC 9728 (Protected Resource Metadata)
9
+ #
10
+ # @note This class is not thread-safe. Each thread should use its own instance.
11
+ class OAuthProvider
12
+ attr_reader :server_url
13
+ attr_accessor :redirect_uri, :scope, :logger, :storage, :grant_type
14
+
15
+ # Normalize server URL for consistent comparison
16
+ # @param url [String] raw URL
17
+ # @return [String] normalized URL
18
+ def self.normalize_url(url)
19
+ uri = URI.parse(url)
20
+
21
+ uri.scheme = uri.scheme&.downcase
22
+ uri.host = uri.host&.downcase
23
+
24
+ if (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443)
25
+ uri.port = nil
26
+ end
27
+
28
+ if uri.path.nil? || uri.path.empty? || uri.path == "/"
29
+ uri.path = ""
30
+ elsif uri.path.end_with?("/")
31
+ uri.path = uri.path.chomp("/")
32
+ end
33
+
34
+ uri.fragment = nil
35
+ uri.to_s
36
+ end
37
+
38
+ def initialize(server_url:, redirect_uri: "http://localhost:8080/callback", scope: nil, logger: nil, # rubocop:disable Metrics/ParameterLists
39
+ storage: nil, grant_type: :authorization_code)
40
+ self.server_url = server_url
41
+ self.redirect_uri = redirect_uri
42
+ self.scope = scope
43
+ self.logger = logger || MCP.logger
44
+ self.storage = storage || MemoryStorage.new
45
+ self.grant_type = grant_type.to_sym
46
+ validate_redirect_uri!(redirect_uri)
47
+
48
+ # Initialize HTTP client
49
+ @http_client = create_http_client
50
+
51
+ # Initialize service objects
52
+ @discoverer = Discoverer.new(@http_client, self.storage, self.logger)
53
+ @client_registrar = ClientRegistrar.new(@http_client, self.storage, self.logger, MCP.config)
54
+ @token_manager = TokenManager.new(@http_client, self.logger)
55
+ @session_manager = SessionManager.new(self.storage)
56
+
57
+ # Initialize flow orchestrators
58
+ @auth_code_flow = Flows::AuthorizationCodeFlow.new(
59
+ discoverer: @discoverer,
60
+ client_registrar: @client_registrar,
61
+ session_manager: @session_manager,
62
+ token_manager: @token_manager,
63
+ storage: self.storage,
64
+ logger: self.logger
65
+ )
66
+ @client_creds_flow = Flows::ClientCredentialsFlow.new(
67
+ discoverer: @discoverer,
68
+ client_registrar: @client_registrar,
69
+ token_manager: @token_manager,
70
+ storage: self.storage,
71
+ logger: self.logger
72
+ )
73
+ end
74
+
75
+ # Get current access token, refreshing if needed
76
+ # @return [Token, nil] valid access token or nil
77
+ def access_token
78
+ logger.debug("OAuth access_token: Looking up token for server_url='#{server_url}'")
79
+ token = storage.get_token(server_url)
80
+ logger.debug("OAuth access_token: Storage returned token=#{token ? 'present' : 'nil'}")
81
+
82
+ if token
83
+ logger.debug(" Token expires_at: #{token.expires_at}")
84
+ logger.debug(" Token expired?: #{token.expired?}")
85
+ logger.debug(" Token expires_soon?: #{token.expires_soon?}")
86
+ else
87
+ logger.warn("✗ No token found in storage for server_url='#{server_url}'")
88
+ logger.warn(" Check that authentication completed and stored the token")
89
+ return nil
90
+ end
91
+
92
+ # Return token if still valid
93
+ return token unless token.expired? || token.expires_soon?
94
+
95
+ # Try to refresh if we have a refresh token
96
+ logger.debug("Token expired or expiring soon, attempting refresh...")
97
+ refresh_token(token) if token.refresh_token
98
+ end
99
+
100
+ # Authenticate and return current access token
101
+ # This is a convenience method for consistency with BrowserOAuthProvider
102
+ # For standard OAuth flow, external authorization is required before calling this
103
+ # @return [Token] current valid access token
104
+ # @raise [Errors::TransportError] if not authenticated or token unavailable
105
+ def authenticate
106
+ token = access_token
107
+ unless token
108
+ raise Errors::TransportError.new(
109
+ message: "Not authenticated. Please complete OAuth authorization flow first. " \
110
+ "For standard OAuth, you must authorize externally and exchange the code."
111
+ )
112
+ end
113
+ token
114
+ end
115
+
116
+ # Start OAuth authorization flow (authorization code grant)
117
+ # @return [String] authorization URL for user to visit
118
+ def start_authorization_flow
119
+ @auth_code_flow.start(
120
+ server_url,
121
+ redirect_uri,
122
+ scope,
123
+ https_validator: method(:validate_https_endpoint)
124
+ )
125
+ end
126
+
127
+ # Perform client credentials flow (application authentication without user)
128
+ # @param scope [String] optional scope override
129
+ # @return [Token] access token
130
+ def client_credentials_flow(scope: nil)
131
+ @client_creds_flow.execute(server_url, redirect_uri, scope || self.scope)
132
+ end
133
+
134
+ # Complete OAuth authorization flow after callback
135
+ # @param code [String] authorization code from callback
136
+ # @param state [String] state parameter from callback
137
+ # @return [Token] access token
138
+ def complete_authorization_flow(code, state)
139
+ @auth_code_flow.complete(server_url, code, state)
140
+ end
141
+
142
+ # Apply authorization header to HTTP request
143
+ # @param request [HTTPX::Request] HTTP request object
144
+ def apply_authorization(request)
145
+ token = access_token
146
+ logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
147
+ return unless token
148
+
149
+ logger.debug("OAuth applying authorization header: #{token.to_header[0..20]}...")
150
+ request.headers["Authorization"] = token.to_header
151
+ end
152
+
153
+ # Handle authentication challenge from server (401 response)
154
+ # Attempts to refresh token or raises error if interactive auth required
155
+ # @param www_authenticate [String, nil] WWW-Authenticate header value
156
+ # @param resource_metadata_url [String, nil] Resource metadata URL from response
157
+ # @param requested_scope [String, nil] Scope from WWW-Authenticate challenge
158
+ # @return [Boolean] true if authentication was refreshed successfully
159
+ # @raise [Errors::AuthenticationRequiredError] if interactive auth is required
160
+ def handle_authentication_challenge(www_authenticate: nil, resource_metadata_url: nil, requested_scope: nil)
161
+ logger.debug("Handling authentication challenge")
162
+ logger.debug(" WWW-Authenticate: #{www_authenticate}") if www_authenticate
163
+ logger.debug(" Resource metadata URL: #{resource_metadata_url}") if resource_metadata_url
164
+ logger.debug(" Requested scope: #{requested_scope}") if requested_scope
165
+
166
+ # Parse WWW-Authenticate header if provided
167
+ final_requested_scope = requested_scope
168
+ if www_authenticate
169
+ challenge_info = parse_www_authenticate(www_authenticate)
170
+ final_requested_scope ||= challenge_info[:scope]
171
+ # NOTE: resource_metadata_url from challenge_info could be used for future discovery
172
+ end
173
+
174
+ # Update scope if server requested different scope
175
+ if final_requested_scope && final_requested_scope != scope
176
+ logger.debug("Updating scope from '#{scope}' to '#{final_requested_scope}'")
177
+ self.scope = final_requested_scope
178
+ end
179
+
180
+ # Try to refresh existing token
181
+ token = storage.get_token(server_url)
182
+ if token&.refresh_token
183
+ logger.debug("Attempting token refresh with existing refresh token")
184
+ refreshed_token = refresh_token(token)
185
+ return true if refreshed_token
186
+ end
187
+
188
+ # If we have client credentials, try that flow
189
+ if grant_type == :client_credentials
190
+ logger.debug("Attempting client credentials flow")
191
+ begin
192
+ new_token = client_credentials_flow(scope: requested_scope)
193
+ return true if new_token
194
+ rescue StandardError => e
195
+ logger.warn("Client credentials flow failed: #{e.message}")
196
+ end
197
+ end
198
+
199
+ # Cannot automatically authenticate - interactive auth required
200
+ logger.warn("Cannot automatically authenticate - interactive authorization required")
201
+ raise Errors::AuthenticationRequiredError.new(
202
+ message: "OAuth authentication required. Token refresh failed and interactive authorization is needed."
203
+ )
204
+ end
205
+
206
+ # Parse WWW-Authenticate header to extract challenge parameters
207
+ # @param header [String] WWW-Authenticate header value
208
+ # @return [Hash] parsed challenge information
209
+ def parse_www_authenticate(header)
210
+ result = {}
211
+
212
+ # Example: Bearer realm="example", scope="mcp:read mcp:write", resource_metadata_url="https://..."
213
+ if header =~ /Bearer\s+(.+)/i
214
+ params = ::Regexp.last_match(1)
215
+
216
+ # Extract scope
217
+ if params =~ /scope="([^"]+)"/
218
+ result[:scope] = ::Regexp.last_match(1)
219
+ end
220
+
221
+ # Extract resource metadata URL
222
+ if params =~ /resource_metadata_url="([^"]+)"/
223
+ result[:resource_metadata_url] = ::Regexp.last_match(1)
224
+ end
225
+
226
+ # Extract realm
227
+ if params =~ /realm="([^"]+)"/
228
+ result[:realm] = ::Regexp.last_match(1)
229
+ end
230
+ end
231
+
232
+ result
233
+ end
234
+
235
+ private
236
+
237
+ # Create HTTP client for OAuth requests
238
+ # @return [HTTPX::Session] HTTP client
239
+ def create_http_client
240
+ headers = {
241
+ "Accept" => "application/json",
242
+ "User-Agent" => "RubyLLM-MCP/#{RubyLLM::MCP::VERSION}"
243
+ }
244
+ headers["MCP-Protocol-Version"] = RubyLLM::MCP.config.protocol_version
245
+
246
+ HTTPX.plugin(:follow_redirects).with(
247
+ timeout: { total: DEFAULT_OAUTH_TIMEOUT },
248
+ headers: headers
249
+ )
250
+ end
251
+
252
+ # Normalize and set server URL
253
+ # Ensures consistent URL format for storage keys
254
+ def server_url=(url)
255
+ @server_url = self.class.normalize_url(url)
256
+ end
257
+
258
+ # Validate redirect URI per OAuth 2.1 security requirements
259
+ # @param uri [String] redirect URI
260
+ # @raise [ArgumentError] if URI is invalid or not localhost/HTTPS
261
+ def validate_redirect_uri!(uri)
262
+ parsed = URI.parse(uri)
263
+ is_localhost = ["localhost", "127.0.0.1", "::1"].include?(parsed.host)
264
+ is_https = parsed.scheme == "https"
265
+
266
+ unless is_localhost || is_https
267
+ raise ArgumentError,
268
+ "Redirect URI must be localhost or HTTPS per OAuth 2.1 security requirements: #{uri}"
269
+ end
270
+ rescue URI::InvalidURIError => e
271
+ raise ArgumentError, "Invalid redirect URI: #{uri} - #{e.message}"
272
+ end
273
+
274
+ # Validate HTTPS usage for OAuth endpoint (warning only)
275
+ # @param url [String] endpoint URL
276
+ # @param endpoint_name [String] descriptive name for logging
277
+ def validate_https_endpoint(url, endpoint_name)
278
+ uri = URI.parse(url)
279
+ is_localhost = ["localhost", "127.0.0.1", "::1"].include?(uri.host)
280
+
281
+ if uri.scheme != "https" && !is_localhost
282
+ logger.warn("WARNING: #{endpoint_name} is not using HTTPS: #{url}")
283
+ logger.warn("OAuth endpoints SHOULD use HTTPS in production environments")
284
+ end
285
+ end
286
+
287
+ # Refresh access token using refresh token
288
+ # @param token [Token] current token with refresh_token
289
+ # @return [Token, nil] new token or nil if refresh failed
290
+ def refresh_token(token)
291
+ return nil unless token.refresh_token
292
+
293
+ server_metadata = @discoverer.discover(server_url)
294
+ client_info = storage.get_client_info(server_url)
295
+ return nil unless server_metadata && client_info
296
+
297
+ new_token = @token_manager.refresh_token(server_metadata, client_info, token, server_url)
298
+ storage.set_token(server_url, new_token) if new_token
299
+ logger.debug("Token refreshed successfully") if new_token
300
+ new_token
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Security utilities for OAuth implementation
7
+ module Security
8
+ module_function
9
+
10
+ # Constant-time string comparison to prevent timing attacks
11
+ # @param a [String] first string
12
+ # @param b [String] second string
13
+ # @return [Boolean] true if strings are equal
14
+ def secure_compare(first, second)
15
+ # Handle nil values
16
+ return false if first.nil? || second.nil?
17
+
18
+ # Use Rails/ActiveSupport's secure_compare if available (more battle-tested)
19
+ if defined?(ActiveSupport::SecurityUtils) && ActiveSupport::SecurityUtils.respond_to?(:secure_compare)
20
+ return ActiveSupport::SecurityUtils.secure_compare(first, second)
21
+ end
22
+
23
+ # Fallback to our own implementation
24
+ constant_time_compare?(first, second)
25
+ end
26
+
27
+ # Constant-time comparison implementation
28
+ # @param a [String] first string
29
+ # @param b [String] second string
30
+ # @return [Boolean] true if strings are equal
31
+ def constant_time_compare?(first, second)
32
+ return false unless first.bytesize == second.bytesize
33
+
34
+ l = first.unpack("C*")
35
+ r = 0
36
+ i = -1
37
+
38
+ second.each_byte { |v| r |= v ^ l[i += 1] }
39
+ r.zero?
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end