ruby_llm-mcp 0.7.1 → 1.0.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -162
  3. data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
  4. data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +21 -4
  5. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
  6. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  16. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  17. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  18. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  19. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +215 -0
  20. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +413 -0
  21. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +41 -0
  22. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +56 -0
  23. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +56 -0
  24. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +90 -0
  25. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +216 -0
  26. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  27. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +36 -0
  28. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
  29. data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
  30. data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
  31. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +427 -0
  32. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  33. data/lib/ruby_llm/mcp/auth/discoverer.rb +255 -0
  34. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +122 -0
  35. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +67 -0
  36. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  37. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  38. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  39. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
  40. data/lib/ruby_llm/mcp/auth/memory_storage.rb +91 -0
  41. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +341 -0
  42. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  43. data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
  44. data/lib/ruby_llm/mcp/auth/token_manager.rb +307 -0
  45. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  46. data/lib/ruby_llm/mcp/auth/url_builder.rb +135 -0
  47. data/lib/ruby_llm/mcp/auth.rb +371 -0
  48. data/lib/ruby_llm/mcp/client.rb +312 -35
  49. data/lib/ruby_llm/mcp/configuration.rb +199 -24
  50. data/lib/ruby_llm/mcp/elicitation.rb +261 -14
  51. data/lib/ruby_llm/mcp/errors.rb +29 -0
  52. data/lib/ruby_llm/mcp/extensions/apps/constants.rb +28 -0
  53. data/lib/ruby_llm/mcp/extensions/apps/resource_metadata.rb +24 -0
  54. data/lib/ruby_llm/mcp/extensions/apps/tool_metadata.rb +45 -0
  55. data/lib/ruby_llm/mcp/extensions/configuration.rb +72 -0
  56. data/lib/ruby_llm/mcp/extensions/constants.rb +16 -0
  57. data/lib/ruby_llm/mcp/extensions/registry.rb +85 -0
  58. data/lib/ruby_llm/mcp/handlers/approval_decision.rb +90 -0
  59. data/lib/ruby_llm/mcp/handlers/async_response.rb +181 -0
  60. data/lib/ruby_llm/mcp/handlers/concerns/approval_actions.rb +42 -0
  61. data/lib/ruby_llm/mcp/handlers/concerns/async_execution.rb +80 -0
  62. data/lib/ruby_llm/mcp/handlers/concerns/elicitation_actions.rb +42 -0
  63. data/lib/ruby_llm/mcp/handlers/concerns/error_handling.rb +29 -0
  64. data/lib/ruby_llm/mcp/handlers/concerns/guard_checks.rb +72 -0
  65. data/lib/ruby_llm/mcp/handlers/concerns/lifecycle.rb +84 -0
  66. data/lib/ruby_llm/mcp/handlers/concerns/logging.rb +19 -0
  67. data/lib/ruby_llm/mcp/handlers/concerns/model_filtering.rb +36 -0
  68. data/lib/ruby_llm/mcp/handlers/concerns/options.rb +83 -0
  69. data/lib/ruby_llm/mcp/handlers/concerns/registry_integration.rb +54 -0
  70. data/lib/ruby_llm/mcp/handlers/concerns/sampling_actions.rb +84 -0
  71. data/lib/ruby_llm/mcp/handlers/concerns/timeouts.rb +52 -0
  72. data/lib/ruby_llm/mcp/handlers/concerns/tool_filtering.rb +50 -0
  73. data/lib/ruby_llm/mcp/handlers/elicitation_handler.rb +58 -0
  74. data/lib/ruby_llm/mcp/handlers/elicitation_registry.rb +203 -0
  75. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_handler.rb +93 -0
  76. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_registry.rb +271 -0
  77. data/lib/ruby_llm/mcp/handlers/promise.rb +192 -0
  78. data/lib/ruby_llm/mcp/handlers/sampling_handler.rb +64 -0
  79. data/lib/ruby_llm/mcp/handlers.rb +14 -0
  80. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +94 -0
  81. data/lib/ruby_llm/mcp/native/client.rb +551 -0
  82. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  83. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  84. data/lib/ruby_llm/mcp/native/messages/notifications.rb +60 -0
  85. data/lib/ruby_llm/mcp/native/messages/requests.rb +267 -0
  86. data/lib/ruby_llm/mcp/native/messages/responses.rb +114 -0
  87. data/lib/ruby_llm/mcp/native/messages.rb +43 -0
  88. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  89. data/lib/ruby_llm/mcp/native/protocol.rb +79 -0
  90. data/lib/ruby_llm/mcp/native/response_handler.rb +220 -0
  91. data/lib/ruby_llm/mcp/native/task_registry.rb +62 -0
  92. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  93. data/lib/ruby_llm/mcp/native/transports/sse.rb +655 -0
  94. data/lib/ruby_llm/mcp/native/transports/stdio.rb +367 -0
  95. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +1024 -0
  96. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  97. data/lib/ruby_llm/mcp/native/transports/support/rate_limiter.rb +49 -0
  98. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  99. data/lib/ruby_llm/mcp/native.rb +12 -0
  100. data/lib/ruby_llm/mcp/notification_handler.rb +43 -5
  101. data/lib/ruby_llm/mcp/prompt.rb +7 -7
  102. data/lib/ruby_llm/mcp/railtie.rb +7 -13
  103. data/lib/ruby_llm/mcp/resource.rb +17 -8
  104. data/lib/ruby_llm/mcp/resource_template.rb +8 -7
  105. data/lib/ruby_llm/mcp/result.rb +8 -4
  106. data/lib/ruby_llm/mcp/roots.rb +4 -4
  107. data/lib/ruby_llm/mcp/sample.rb +83 -13
  108. data/lib/ruby_llm/mcp/schema_validator.rb +33 -0
  109. data/lib/ruby_llm/mcp/server_capabilities.rb +41 -0
  110. data/lib/ruby_llm/mcp/task.rb +65 -0
  111. data/lib/ruby_llm/mcp/tool.rb +33 -27
  112. data/lib/ruby_llm/mcp/version.rb +1 -1
  113. data/lib/ruby_llm/mcp.rb +37 -7
  114. data/lib/tasks/smoke.rake +66 -0
  115. metadata +115 -39
  116. data/lib/generators/ruby_llm/mcp/templates/mcps.yml +0 -9
  117. data/lib/ruby_llm/mcp/coordinator.rb +0 -293
  118. data/lib/ruby_llm/mcp/notifications/cancelled.rb +0 -32
  119. data/lib/ruby_llm/mcp/notifications/initialize.rb +0 -24
  120. data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +0 -26
  121. data/lib/ruby_llm/mcp/protocol.rb +0 -34
  122. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +0 -50
  123. data/lib/ruby_llm/mcp/requests/completion_resource.rb +0 -50
  124. data/lib/ruby_llm/mcp/requests/initialization.rb +0 -34
  125. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +0 -28
  126. data/lib/ruby_llm/mcp/requests/ping.rb +0 -24
  127. data/lib/ruby_llm/mcp/requests/prompt_call.rb +0 -32
  128. data/lib/ruby_llm/mcp/requests/prompt_list.rb +0 -31
  129. data/lib/ruby_llm/mcp/requests/resource_list.rb +0 -31
  130. data/lib/ruby_llm/mcp/requests/resource_read.rb +0 -30
  131. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +0 -31
  132. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +0 -30
  133. data/lib/ruby_llm/mcp/requests/shared/meta.rb +0 -32
  134. data/lib/ruby_llm/mcp/requests/shared/pagination.rb +0 -17
  135. data/lib/ruby_llm/mcp/requests/tool_call.rb +0 -35
  136. data/lib/ruby_llm/mcp/requests/tool_list.rb +0 -31
  137. data/lib/ruby_llm/mcp/response_handler.rb +0 -67
  138. data/lib/ruby_llm/mcp/responses/elicitation.rb +0 -33
  139. data/lib/ruby_llm/mcp/responses/error.rb +0 -33
  140. data/lib/ruby_llm/mcp/responses/ping.rb +0 -28
  141. data/lib/ruby_llm/mcp/responses/roots_list.rb +0 -31
  142. data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +0 -50
  143. data/lib/ruby_llm/mcp/transport.rb +0 -58
  144. data/lib/ruby_llm/mcp/transports/sse.rb +0 -341
  145. data/lib/ruby_llm/mcp/transports/stdio.rb +0 -230
  146. data/lib/ruby_llm/mcp/transports/streamable_http.rb +0 -723
  147. data/lib/ruby_llm/mcp/transports/support/http_client.rb +0 -28
  148. data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +0 -47
  149. data/lib/ruby_llm/mcp/transports/support/timeout.rb +0 -34
@@ -0,0 +1,91 @@
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
+ @mutex = Mutex.new
11
+ @tokens = {}
12
+ @client_infos = {}
13
+ @server_metadata = {}
14
+ @pkce_data = {}
15
+ @state_data = {}
16
+ @resource_metadata = {}
17
+ end
18
+
19
+ # Token storage
20
+ def get_token(server_url)
21
+ @mutex.synchronize { @tokens[server_url] }
22
+ end
23
+
24
+ def set_token(server_url, token)
25
+ @mutex.synchronize { @tokens[server_url] = token }
26
+ end
27
+
28
+ def delete_token(server_url)
29
+ @mutex.synchronize { @tokens.delete(server_url) }
30
+ end
31
+
32
+ # Client registration storage
33
+ def get_client_info(server_url)
34
+ @mutex.synchronize { @client_infos[server_url] }
35
+ end
36
+
37
+ def set_client_info(server_url, client_info)
38
+ @mutex.synchronize { @client_infos[server_url] = client_info }
39
+ end
40
+
41
+ # Server metadata caching
42
+ def get_server_metadata(server_url)
43
+ @mutex.synchronize { @server_metadata[server_url] }
44
+ end
45
+
46
+ def set_server_metadata(server_url, metadata)
47
+ @mutex.synchronize { @server_metadata[server_url] = metadata }
48
+ end
49
+
50
+ # PKCE state management (temporary)
51
+ def get_pkce(server_url)
52
+ @mutex.synchronize { @pkce_data[server_url] }
53
+ end
54
+
55
+ def set_pkce(server_url, pkce)
56
+ @mutex.synchronize { @pkce_data[server_url] = pkce }
57
+ end
58
+
59
+ def delete_pkce(server_url)
60
+ @mutex.synchronize { @pkce_data.delete(server_url) }
61
+ end
62
+
63
+ # State parameter management (temporary)
64
+ def get_state(server_url)
65
+ @mutex.synchronize { @state_data[server_url] }
66
+ end
67
+
68
+ def set_state(server_url, state)
69
+ @mutex.synchronize { @state_data[server_url] = state }
70
+ end
71
+
72
+ def delete_state(server_url)
73
+ @mutex.synchronize { @state_data.delete(server_url) }
74
+ end
75
+
76
+ # Resource metadata management
77
+ def get_resource_metadata(server_url)
78
+ @mutex.synchronize { @resource_metadata[server_url] }
79
+ end
80
+
81
+ def set_resource_metadata(server_url, metadata)
82
+ @mutex.synchronize { @resource_metadata[server_url] = metadata }
83
+ end
84
+
85
+ def delete_resource_metadata(server_url)
86
+ @mutex.synchronize { @resource_metadata.delete(server_url) }
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,341 @@
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
+ # @param resource_metadata [String, nil] explicit resource metadata URL hint
118
+ # @return [String] authorization URL for user to visit
119
+ def start_authorization_flow(resource_metadata: nil)
120
+ hint = resource_metadata || @resource_metadata_hint
121
+ @auth_code_flow.start(
122
+ server_url,
123
+ redirect_uri,
124
+ scope,
125
+ resource_metadata: hint,
126
+ https_validator: method(:validate_https_endpoint)
127
+ )
128
+ end
129
+
130
+ # Perform client credentials flow (application authentication without user)
131
+ # @param scope [String] optional scope override
132
+ # @param resource_metadata [String, nil] explicit resource metadata URL hint
133
+ # @return [Token] access token
134
+ def client_credentials_flow(scope: nil, resource_metadata: nil)
135
+ hint = resource_metadata || @resource_metadata_hint
136
+ @client_creds_flow.execute(
137
+ server_url,
138
+ redirect_uri,
139
+ scope || self.scope,
140
+ resource_metadata: hint
141
+ )
142
+ end
143
+
144
+ # Complete OAuth authorization flow after callback
145
+ # @param code [String] authorization code from callback
146
+ # @param state [String] state parameter from callback
147
+ # @return [Token] access token
148
+ def complete_authorization_flow(code, state)
149
+ @auth_code_flow.complete(server_url, code, state)
150
+ end
151
+
152
+ # Apply authorization header to HTTP request
153
+ # @param request [HTTPX::Request] HTTP request object
154
+ def apply_authorization(request)
155
+ token = access_token
156
+ logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
157
+ return unless token
158
+
159
+ logger.debug("OAuth applying authorization header: #{token.to_header[0..20]}...")
160
+ request.headers["Authorization"] = token.to_header
161
+ end
162
+
163
+ # Handle authentication challenge from server (401 response)
164
+ # Attempts to refresh token or raises error if interactive auth required
165
+ # @param www_authenticate [String, nil] WWW-Authenticate header value
166
+ # @param resource_metadata [String, nil] Resource metadata URL from response/challenge
167
+ # @param resource_metadata_url [String, nil] Legacy alias for resource_metadata
168
+ # @param requested_scope [String, nil] Scope from WWW-Authenticate challenge
169
+ # @return [Boolean] true if authentication was refreshed successfully
170
+ # @raise [Errors::AuthenticationRequiredError] if interactive auth is required
171
+ def handle_authentication_challenge(www_authenticate: nil, resource_metadata: nil, resource_metadata_url: nil,
172
+ requested_scope: nil)
173
+ resolved_resource_metadata = resource_metadata || resource_metadata_url
174
+ logger.debug("Handling authentication challenge")
175
+ logger.debug(" WWW-Authenticate: #{www_authenticate}") if www_authenticate
176
+ logger.debug(" Resource metadata URL: #{resolved_resource_metadata}") if resolved_resource_metadata
177
+ logger.debug(" Requested scope: #{requested_scope}") if requested_scope
178
+
179
+ final_requested_scope, final_resource_metadata = resolve_challenge_context(
180
+ www_authenticate,
181
+ resource_metadata,
182
+ resource_metadata_url,
183
+ requested_scope
184
+ )
185
+
186
+ # Persist discovery hint for browser-based fallback flows.
187
+ @resource_metadata_hint = final_resource_metadata if final_resource_metadata
188
+
189
+ update_scope_if_needed(final_requested_scope)
190
+
191
+ # Try to refresh existing token
192
+ token = storage.get_token(server_url)
193
+ if token&.refresh_token
194
+ logger.debug("Attempting token refresh with existing refresh token")
195
+ refreshed_token = refresh_token(token, resource_metadata: final_resource_metadata)
196
+ return true if refreshed_token
197
+ end
198
+
199
+ # If we have client credentials, try that flow
200
+ if grant_type == :client_credentials
201
+ logger.debug("Attempting client credentials flow")
202
+ begin
203
+ new_token = client_credentials_flow(
204
+ scope: final_requested_scope,
205
+ resource_metadata: final_resource_metadata
206
+ )
207
+ return true if new_token
208
+ rescue StandardError => e
209
+ logger.warn("Client credentials flow failed: #{e.message}")
210
+ end
211
+ end
212
+
213
+ # Cannot automatically authenticate - interactive auth required
214
+ logger.warn("Cannot automatically authenticate - interactive authorization required")
215
+ raise Errors::AuthenticationRequiredError.new(
216
+ message: "OAuth authentication required. Token refresh failed and interactive authorization is needed."
217
+ )
218
+ end
219
+
220
+ # Parse WWW-Authenticate header to extract challenge parameters
221
+ # @param header [String] WWW-Authenticate header value
222
+ # @return [Hash] parsed challenge information
223
+ def parse_www_authenticate(header)
224
+ result = {}
225
+
226
+ # Example: Bearer realm="example", scope="mcp:read mcp:write", resource_metadata="https://..."
227
+ if header =~ /Bearer\s+(.+)/i
228
+ params = ::Regexp.last_match(1)
229
+ parsed_params = {}
230
+ params.scan(/([a-zA-Z_][a-zA-Z0-9_-]*)="([^"]*)"/) do |key, value|
231
+ parsed_params[key.downcase] = value
232
+ end
233
+
234
+ # Extract scope
235
+ result[:scope] = parsed_params["scope"] if parsed_params["scope"]
236
+
237
+ # Extract resource metadata URL (spec + legacy alias)
238
+ result[:resource_metadata] = parsed_params["resource_metadata"] || parsed_params["resource_metadata_url"]
239
+ result[:resource_metadata_url] = result[:resource_metadata] if result[:resource_metadata]
240
+
241
+ # Extract realm
242
+ result[:realm] = parsed_params["realm"] if parsed_params["realm"]
243
+ end
244
+
245
+ result
246
+ end
247
+
248
+ private
249
+
250
+ # Create HTTP client for OAuth requests
251
+ # @return [HTTPX::Session] HTTP client
252
+ def create_http_client
253
+ headers = {
254
+ "Accept" => "application/json",
255
+ "User-Agent" => "RubyLLM-MCP/#{RubyLLM::MCP::VERSION}"
256
+ }
257
+ headers["MCP-Protocol-Version"] = RubyLLM::MCP.config.protocol_version
258
+
259
+ HTTPX.plugin(:follow_redirects).with(
260
+ timeout: { request_timeout: DEFAULT_OAUTH_TIMEOUT },
261
+ headers: headers
262
+ )
263
+ end
264
+
265
+ # Normalize and set server URL
266
+ # Ensures consistent URL format for storage keys
267
+ def server_url=(url)
268
+ @server_url = self.class.normalize_url(url)
269
+ end
270
+
271
+ # Validate redirect URI per OAuth 2.1 security requirements
272
+ # @param uri [String] redirect URI
273
+ # @raise [ArgumentError] if URI is invalid or not localhost/HTTPS
274
+ def validate_redirect_uri!(uri)
275
+ parsed = URI.parse(uri)
276
+ is_localhost = ["localhost", "127.0.0.1", "::1"].include?(parsed.host)
277
+ is_https = parsed.scheme == "https"
278
+
279
+ unless is_localhost || is_https
280
+ raise ArgumentError,
281
+ "Redirect URI must be localhost or HTTPS per OAuth 2.1 security requirements: #{uri}"
282
+ end
283
+ rescue URI::InvalidURIError => e
284
+ raise ArgumentError, "Invalid redirect URI: #{uri} - #{e.message}"
285
+ end
286
+
287
+ # Validate HTTPS usage for OAuth endpoint (warning only)
288
+ # @param url [String] endpoint URL
289
+ # @param endpoint_name [String] descriptive name for logging
290
+ def validate_https_endpoint(url, endpoint_name)
291
+ uri = URI.parse(url)
292
+ is_localhost = ["localhost", "127.0.0.1", "::1"].include?(uri.host)
293
+
294
+ if uri.scheme != "https" && !is_localhost
295
+ logger.warn("WARNING: #{endpoint_name} is not using HTTPS: #{url}")
296
+ logger.warn("OAuth endpoints SHOULD use HTTPS in production environments")
297
+ end
298
+ end
299
+
300
+ # Refresh access token using refresh token
301
+ # @param token [Token] current token with refresh_token
302
+ # @param resource_metadata [String, nil] explicit resource metadata URL hint
303
+ # @return [Token, nil] new token or nil if refresh failed
304
+ def refresh_token(token, resource_metadata: nil)
305
+ return nil unless token.refresh_token
306
+
307
+ hint = resource_metadata || @resource_metadata_hint
308
+ server_metadata = @discoverer.discover(server_url, resource_metadata_url: hint)
309
+ client_info = storage.get_client_info(server_url)
310
+ return nil unless server_metadata && client_info
311
+
312
+ new_token = @token_manager.refresh_token(server_metadata, client_info, token, server_url)
313
+ storage.set_token(server_url, new_token) if new_token
314
+ logger.debug("Token refreshed successfully") if new_token
315
+ new_token
316
+ end
317
+
318
+ # Resolve requested scope and resource metadata from inputs and WWW-Authenticate header.
319
+ # @return [Array(String, String)] [requested_scope, resource_metadata]
320
+ def resolve_challenge_context(www_authenticate, resource_metadata, resource_metadata_url, requested_scope)
321
+ final_resource_metadata = resource_metadata || resource_metadata_url
322
+ final_requested_scope = requested_scope
323
+ return [final_requested_scope, final_resource_metadata] unless www_authenticate
324
+
325
+ challenge_info = parse_www_authenticate(www_authenticate)
326
+ final_requested_scope ||= challenge_info[:scope]
327
+ final_resource_metadata ||= challenge_info[:resource_metadata]
328
+ [final_requested_scope, final_resource_metadata]
329
+ end
330
+
331
+ # Update provider scope only when a different challenge scope is provided.
332
+ def update_scope_if_needed(new_scope)
333
+ return unless new_scope && new_scope != scope
334
+
335
+ logger.debug("Updating scope from '#{scope}' to '#{new_scope}'")
336
+ self.scope = new_scope
337
+ end
338
+ end
339
+ end
340
+ end
341
+ 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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Service for managing OAuth session state (PKCE and CSRF state)
7
+ # Handles creation, validation, and cleanup of temporary session data
8
+ class SessionManager
9
+ attr_reader :storage
10
+
11
+ def initialize(storage)
12
+ @storage = storage
13
+ end
14
+
15
+ # Create a new OAuth session with PKCE and CSRF state
16
+ # @param server_url [String] MCP server URL
17
+ # @return [Hash] session data with :pkce and :state
18
+ def create_session(server_url)
19
+ pkce = PKCE.new
20
+ state = SecureRandom.urlsafe_base64(CSRF_STATE_SIZE)
21
+
22
+ storage.set_pkce(server_url, pkce)
23
+ storage.set_state(server_url, state)
24
+
25
+ { pkce: pkce, state: state }
26
+ end
27
+
28
+ # Validate state parameter and retrieve session data
29
+ # @param server_url [String] MCP server URL
30
+ # @param state [String] state parameter from callback
31
+ # @return [Hash] session data with :pkce and :client_info
32
+ # @raise [ArgumentError] if state is invalid
33
+ def validate_and_retrieve_session(server_url, state)
34
+ stored_state = storage.get_state(server_url)
35
+ unless stored_state && Security.secure_compare(stored_state, state)
36
+ raise ArgumentError, "Invalid state parameter"
37
+ end
38
+
39
+ {
40
+ pkce: storage.get_pkce(server_url),
41
+ client_info: storage.get_client_info(server_url)
42
+ }
43
+ end
44
+
45
+ # Clean up temporary session data
46
+ # @param server_url [String] MCP server URL
47
+ def cleanup_session(server_url)
48
+ storage.delete_pkce(server_url)
49
+ storage.delete_state(server_url)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end