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,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Browser-based OAuth authentication provider
7
+ # Provides complete OAuth 2.1 flow with automatic browser opening and local callback server
8
+ # Compatible API with OAuthProvider for seamless interchange
9
+ class BrowserOAuthProvider
10
+ attr_reader :oauth_provider, :callback_port, :callback_path, :logger
11
+ attr_accessor :server_url, :redirect_uri, :scope, :storage
12
+
13
+ # Expose custom pages for testing/inspection
14
+ def custom_success_page
15
+ @pages.instance_variable_get(:@custom_success_page)
16
+ end
17
+
18
+ def custom_error_page
19
+ @pages.instance_variable_get(:@custom_error_page)
20
+ end
21
+
22
+ # @param server_url [String] OAuth server URL (alternative to oauth_provider)
23
+ # @param oauth_provider [OAuthProvider] OAuth provider instance (alternative to server_url)
24
+ # @param callback_port [Integer] port for local callback server
25
+ # @param callback_path [String] path for callback URL
26
+ # @param logger [Logger] logger instance
27
+ # @param storage [Object] token storage instance
28
+ # @param redirect_uri [String] OAuth redirect URI
29
+ # @param scope [String] OAuth scopes
30
+ def initialize(server_url: nil, oauth_provider: nil, callback_port: 8080, callback_path: "/callback", # rubocop:disable Metrics/ParameterLists
31
+ logger: nil, storage: nil, redirect_uri: nil, scope: nil)
32
+ @logger = logger || MCP.logger
33
+ @callback_port = callback_port
34
+ @callback_path = callback_path
35
+
36
+ # Set redirect_uri before creating oauth_provider
37
+ redirect_uri ||= "http://localhost:#{callback_port}#{callback_path}"
38
+
39
+ # Either accept an existing oauth_provider or create one
40
+ if oauth_provider
41
+ @oauth_provider = oauth_provider
42
+ # Sync attributes from the provided oauth_provider
43
+ @server_url = oauth_provider.server_url
44
+ @redirect_uri = oauth_provider.redirect_uri
45
+ @scope = oauth_provider.scope
46
+ @storage = oauth_provider.storage
47
+ elsif server_url
48
+ @server_url = server_url
49
+ @redirect_uri = redirect_uri
50
+ @scope = scope
51
+ @storage = storage || MemoryStorage.new
52
+ # Create a new oauth_provider
53
+ @oauth_provider = OAuthProvider.new(
54
+ server_url: server_url,
55
+ redirect_uri: redirect_uri,
56
+ scope: scope,
57
+ logger: @logger,
58
+ storage: @storage
59
+ )
60
+ else
61
+ raise ArgumentError, "Either server_url or oauth_provider must be provided"
62
+ end
63
+
64
+ # Ensure OAuth provider redirect_uri matches our callback server
65
+ validate_and_sync_redirect_uri!
66
+
67
+ # Initialize browser helpers
68
+ @http_server = Browser::HttpServer.new(port: @callback_port, logger: @logger)
69
+ @callback_handler = Browser::CallbackHandler.new(callback_path: @callback_path, logger: @logger)
70
+ @pages = Browser::Pages.new(
71
+ custom_success_page: MCP.config.oauth.browser_success_page,
72
+ custom_error_page: MCP.config.oauth.browser_error_page
73
+ )
74
+ @opener = Browser::Opener.new(logger: @logger)
75
+ end
76
+
77
+ # Perform complete OAuth authentication flow with browser
78
+ # Compatible with OAuthProvider's authentication pattern
79
+ # @param timeout [Integer] seconds to wait for authorization
80
+ # @param auto_open_browser [Boolean] automatically open browser
81
+ # @return [Token] access token
82
+ def authenticate(timeout: 300, auto_open_browser: true)
83
+ # 1. Start authorization flow and get URL
84
+ auth_url = @oauth_provider.start_authorization_flow
85
+ @logger.debug("Authorization URL: #{auth_url}")
86
+
87
+ # 2. Create result container for thread coordination
88
+ result = { code: nil, state: nil, error: nil, completed: false }
89
+ mutex = Mutex.new
90
+ condition = ConditionVariable.new
91
+
92
+ # 3. Start local callback server
93
+ server = start_callback_server(result, mutex, condition)
94
+
95
+ begin
96
+ # 4. Open browser to authorization URL
97
+ if auto_open_browser
98
+ @opener.open_browser(auth_url)
99
+ @logger.info("\nOpening browser for authorization...")
100
+ @logger.info("If browser doesn't open automatically, visit this URL:")
101
+ else
102
+ @logger.info("\nPlease visit this URL to authorize:")
103
+ end
104
+ @logger.info(auth_url)
105
+ @logger.info("\nWaiting for authorization...")
106
+
107
+ # 5. Wait for callback with timeout
108
+ mutex.synchronize do
109
+ condition.wait(mutex, timeout) unless result[:completed]
110
+ end
111
+
112
+ unless result[:completed]
113
+ raise Errors::TimeoutError.new(message: "OAuth authorization timed out after #{timeout} seconds")
114
+ end
115
+
116
+ if result[:error]
117
+ raise Errors::TransportError.new(message: "OAuth authorization failed: #{result[:error]}")
118
+ end
119
+
120
+ # 6. Complete OAuth flow
121
+ @logger.debug("Completing OAuth authorization flow")
122
+ token = @oauth_provider.complete_authorization_flow(result[:code], result[:state])
123
+
124
+ @logger.info("\nAuthentication successful!")
125
+ token
126
+ ensure
127
+ # Always shutdown the server
128
+ server&.shutdown
129
+ end
130
+ end
131
+
132
+ # Get current access token (for compatibility with OAuthProvider)
133
+ # @return [Token, nil] valid access token or nil
134
+ def access_token
135
+ @oauth_provider.access_token
136
+ end
137
+
138
+ # Apply authorization header to HTTP request (for compatibility with OAuthProvider)
139
+ # @param request [HTTPX::Request] HTTP request object
140
+ def apply_authorization(request)
141
+ @oauth_provider.apply_authorization(request)
142
+ end
143
+
144
+ # Start authorization flow (for compatibility with OAuthProvider)
145
+ # @return [String] authorization URL
146
+ def start_authorization_flow
147
+ @oauth_provider.start_authorization_flow
148
+ end
149
+
150
+ # Complete authorization flow (for compatibility with OAuthProvider)
151
+ # @param code [String] authorization code
152
+ # @param state [String] state parameter
153
+ # @return [Token] access token
154
+ def complete_authorization_flow(code, state)
155
+ @oauth_provider.complete_authorization_flow(code, state)
156
+ end
157
+
158
+ # Handle authentication challenge with browser-based auth
159
+ # @param www_authenticate [String, nil] WWW-Authenticate header value
160
+ # @param resource_metadata_url [String, nil] Resource metadata URL from response
161
+ # @param requested_scope [String, nil] Scope from WWW-Authenticate challenge
162
+ # @return [Boolean] true if authentication was completed successfully
163
+ def handle_authentication_challenge(www_authenticate: nil, resource_metadata_url: nil, requested_scope: nil)
164
+ @logger.debug("BrowserOAuthProvider handling authentication challenge")
165
+
166
+ # Try standard provider's automatic handling first (token refresh, client credentials)
167
+ begin
168
+ return @oauth_provider.handle_authentication_challenge(
169
+ www_authenticate: www_authenticate,
170
+ resource_metadata_url: resource_metadata_url,
171
+ requested_scope: requested_scope
172
+ )
173
+ rescue Errors::AuthenticationRequiredError
174
+ # Standard provider couldn't handle it - need interactive auth
175
+ @logger.info("Automatic authentication failed, starting browser-based OAuth flow")
176
+ end
177
+
178
+ # Perform full browser-based authentication
179
+ authenticate(auto_open_browser: true)
180
+ true
181
+ end
182
+
183
+ # Parse WWW-Authenticate header (delegate to oauth_provider)
184
+ # @param header [String] WWW-Authenticate header value
185
+ # @return [Hash] parsed challenge information
186
+ def parse_www_authenticate(header)
187
+ @oauth_provider.parse_www_authenticate(header)
188
+ end
189
+
190
+ private
191
+
192
+ # Validate and synchronize redirect_uri between this provider and oauth_provider
193
+ def validate_and_sync_redirect_uri!
194
+ expected_redirect_uri = "http://localhost:#{@callback_port}#{@callback_path}"
195
+
196
+ if @oauth_provider.redirect_uri != expected_redirect_uri
197
+ @logger.warn("OAuth provider redirect_uri (#{@oauth_provider.redirect_uri}) " \
198
+ "doesn't match callback server (#{expected_redirect_uri}). " \
199
+ "Updating redirect_uri.")
200
+ @oauth_provider.redirect_uri = expected_redirect_uri
201
+ @redirect_uri = expected_redirect_uri
202
+ end
203
+ end
204
+
205
+ # Start local HTTP callback server
206
+ # @param result [Hash] result container for callback data
207
+ # @param mutex [Mutex] synchronization mutex
208
+ # @param condition [ConditionVariable] wait condition
209
+ # @return [Browser::CallbackServer] server wrapper
210
+ def start_callback_server(result, mutex, condition)
211
+ server = @http_server.start_server
212
+ @logger.debug("Started callback server on http://127.0.0.1:#{@callback_port}#{@callback_path}")
213
+
214
+ running = true
215
+
216
+ # Start server in background thread
217
+ thread = Thread.new do
218
+ while running
219
+ begin
220
+ # Use wait_readable with timeout to allow checking running flag
221
+ next unless server.wait_readable(0.5)
222
+
223
+ client = server.accept
224
+ handle_http_request(client, result, mutex, condition)
225
+ rescue IOError, Errno::EBADF
226
+ # Server was closed, exit loop
227
+ break
228
+ rescue StandardError => e
229
+ @logger.error("Error handling callback request: #{e.message}")
230
+ end
231
+ end
232
+ end
233
+
234
+ # Return wrapper with shutdown method
235
+ Browser::CallbackServer.new(server, thread, -> { running = false })
236
+ end
237
+
238
+ # Handle incoming HTTP request on callback server
239
+ # @param client [TCPSocket] client socket
240
+ # @param result [Hash] result container
241
+ # @param mutex [Mutex] synchronization mutex
242
+ # @param condition [ConditionVariable] wait condition
243
+ def handle_http_request(client, result, mutex, condition)
244
+ @http_server.configure_client_socket(client)
245
+
246
+ request_line = @http_server.read_request_line(client)
247
+ return unless request_line
248
+
249
+ method_name, path = @http_server.extract_request_parts(request_line)
250
+ return unless method_name && path
251
+
252
+ @logger.debug("Received #{method_name} request: #{path}")
253
+ @http_server.read_http_headers(client)
254
+
255
+ # Validate callback path
256
+ unless @callback_handler.valid_callback_path?(path)
257
+ @http_server.send_http_response(client, 404, "text/plain", "Not Found")
258
+ return
259
+ end
260
+
261
+ # Parse and extract OAuth parameters
262
+ params = @callback_handler.parse_callback_params(path, @http_server)
263
+ oauth_params = @callback_handler.extract_oauth_params(params)
264
+
265
+ # Update result with OAuth parameters
266
+ @callback_handler.update_result_with_oauth_params(oauth_params, result, mutex, condition)
267
+
268
+ # Send response
269
+ if result[:error]
270
+ @http_server.send_http_response(client, 400, "text/html", @pages.error_page(result[:error]))
271
+ else
272
+ @http_server.send_http_response(client, 200, "text/html", @pages.success_page)
273
+ end
274
+ ensure
275
+ client&.close
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Service for registering OAuth clients
7
+ # Implements RFC 7591 (Dynamic Client Registration)
8
+ class ClientRegistrar
9
+ attr_reader :http_client, :storage, :logger, :config
10
+
11
+ def initialize(http_client, storage, logger, config)
12
+ @http_client = http_client
13
+ @storage = storage
14
+ @logger = logger
15
+ @config = config
16
+ end
17
+
18
+ # Get cached client info or register new client
19
+ # @param server_url [String] MCP server URL
20
+ # @param server_metadata [ServerMetadata] server metadata
21
+ # @param grant_type [Symbol] :authorization_code or :client_credentials
22
+ # @param redirect_uri [String] redirect URI for authorization code flow
23
+ # @param scope [String, nil] requested scope
24
+ # @return [ClientInfo] client information
25
+ def get_or_register(server_url, server_metadata, grant_type, redirect_uri, scope)
26
+ # Check cache first
27
+ client_info = storage.get_client_info(server_url)
28
+ return client_info if client_info && !client_info.client_secret_expired?
29
+
30
+ # Register new client if no cached info or secret expired
31
+ if server_metadata.supports_registration?
32
+ register(server_url, server_metadata, grant_type, redirect_uri, scope)
33
+ else
34
+ raise Errors::TransportError.new(
35
+ message: "OAuth server does not support dynamic client registration"
36
+ )
37
+ end
38
+ end
39
+
40
+ # Register OAuth client dynamically (RFC 7591)
41
+ # @param server_url [String] MCP server URL
42
+ # @param server_metadata [ServerMetadata] server metadata
43
+ # @param grant_type [Symbol] :authorization_code or :client_credentials
44
+ # @param redirect_uri [String] redirect URI for authorization code flow
45
+ # @param scope [String, nil] requested scope
46
+ # @return [ClientInfo] registered client info
47
+ def register(server_url, server_metadata, grant_type, redirect_uri, scope)
48
+ logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
49
+
50
+ metadata = build_client_metadata(grant_type, redirect_uri, scope)
51
+ response = post_registration(server_metadata, metadata)
52
+ data = HttpResponseHandler.handle_response(response, context: "Client registration",
53
+ expected_status: [200, 201])
54
+
55
+ registered_metadata = parse_registered_metadata(data, redirect_uri)
56
+ warn_redirect_uri_mismatch(registered_metadata, redirect_uri)
57
+
58
+ client_info = create_client_info(data, registered_metadata)
59
+ storage.set_client_info(server_url, client_info)
60
+ logger.debug("Client registered successfully: #{client_info.client_id}")
61
+ client_info
62
+ end
63
+
64
+ private
65
+
66
+ # Build client metadata for registration request
67
+ # @param grant_type [Symbol] :authorization_code or :client_credentials
68
+ # @param redirect_uri [String] redirect URI
69
+ # @param scope [String, nil] requested scope
70
+ # @return [ClientMetadata] client metadata
71
+ def build_client_metadata(grant_type, redirect_uri, scope)
72
+ strategy = grant_strategy_for(grant_type)
73
+
74
+ metadata = {
75
+ redirect_uris: [redirect_uri],
76
+ token_endpoint_auth_method: strategy.auth_method,
77
+ grant_types: strategy.grant_types_list,
78
+ response_types: strategy.response_types_list,
79
+ scope: scope,
80
+ client_name: config.oauth.client_name,
81
+ client_uri: config.oauth.client_uri,
82
+ logo_uri: config.oauth.logo_uri,
83
+ contacts: config.oauth.contacts,
84
+ tos_uri: config.oauth.tos_uri,
85
+ policy_uri: config.oauth.policy_uri,
86
+ jwks_uri: config.oauth.jwks_uri,
87
+ jwks: config.oauth.jwks,
88
+ software_id: config.oauth.software_id,
89
+ software_version: config.oauth.software_version
90
+ }.compact
91
+
92
+ ClientMetadata.new(**metadata)
93
+ end
94
+
95
+ # Get grant strategy for grant type
96
+ # @param grant_type [Symbol] :authorization_code or :client_credentials
97
+ # @return [GrantStrategies::Base] grant strategy
98
+ def grant_strategy_for(grant_type)
99
+ case grant_type
100
+ when :client_credentials
101
+ GrantStrategies::ClientCredentials.new
102
+ else
103
+ GrantStrategies::AuthorizationCode.new
104
+ end
105
+ end
106
+
107
+ # Post client registration request
108
+ # @param server_metadata [ServerMetadata] server metadata
109
+ # @param metadata [ClientMetadata] client metadata
110
+ # @return [HTTPX::Response] HTTP response
111
+ def post_registration(server_metadata, metadata)
112
+ http_client.post(
113
+ server_metadata.registration_endpoint,
114
+ headers: { "Content-Type" => "application/json" },
115
+ json: metadata.to_h
116
+ )
117
+ end
118
+
119
+ # Parse registered client metadata from response
120
+ # @param data [Hash] registration response data
121
+ # @param redirect_uri [String] requested redirect URI
122
+ # @return [ClientMetadata] registered metadata
123
+ def parse_registered_metadata(data, redirect_uri)
124
+ ClientMetadata.new(
125
+ redirect_uris: data["redirect_uris"] || [redirect_uri],
126
+ token_endpoint_auth_method: data["token_endpoint_auth_method"] || "none",
127
+ grant_types: data["grant_types"] || %w[authorization_code refresh_token],
128
+ response_types: data["response_types"] || ["code"],
129
+ scope: data["scope"],
130
+ client_name: data["client_name"],
131
+ client_uri: data["client_uri"],
132
+ logo_uri: data["logo_uri"],
133
+ contacts: data["contacts"],
134
+ tos_uri: data["tos_uri"],
135
+ policy_uri: data["policy_uri"],
136
+ jwks_uri: data["jwks_uri"],
137
+ jwks: data["jwks"],
138
+ software_id: data["software_id"],
139
+ software_version: data["software_version"]
140
+ )
141
+ end
142
+
143
+ # Warn if server changed redirect URI
144
+ # @param registered_metadata [ClientMetadata] registered metadata
145
+ # @param redirect_uri [String] requested redirect URI
146
+ def warn_redirect_uri_mismatch(registered_metadata, redirect_uri)
147
+ return if registered_metadata.redirect_uris.first == redirect_uri
148
+
149
+ logger.warn("OAuth server changed redirect_uri:")
150
+ logger.warn(" Requested: #{redirect_uri}")
151
+ logger.warn(" Registered: #{registered_metadata.redirect_uris.first}")
152
+ end
153
+
154
+ # Create client info from registration response
155
+ # @param data [Hash] registration response data
156
+ # @param registered_metadata [ClientMetadata] registered metadata
157
+ # @return [ClientInfo] client info
158
+ def create_client_info(data, registered_metadata)
159
+ ClientInfo.new(
160
+ client_id: data["client_id"],
161
+ client_secret: data["client_secret"],
162
+ client_id_issued_at: data["client_id_issued_at"],
163
+ client_secret_expires_at: data["client_secret_expires_at"],
164
+ metadata: registered_metadata
165
+ )
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Service for discovering OAuth authorization servers
7
+ # Implements RFC 8414 (Server Metadata) and RFC 9728 (Protected Resource Metadata)
8
+ class Discoverer
9
+ attr_reader :http_client, :storage, :logger
10
+
11
+ def initialize(http_client, storage, logger)
12
+ @http_client = http_client
13
+ @storage = storage
14
+ @logger = logger
15
+ end
16
+
17
+ # Discover OAuth authorization server
18
+ # Tries two patterns: server as own auth server, or delegated auth server
19
+ # @param server_url [String] MCP server URL
20
+ # @return [ServerMetadata, nil] server metadata or nil
21
+ def discover(server_url)
22
+ logger.debug("Discovering OAuth authorization server for #{server_url}")
23
+
24
+ # Check cache first
25
+ cached = storage.get_server_metadata(server_url)
26
+ return cached if cached
27
+
28
+ server_metadata = try_authorization_server_discovery(server_url) ||
29
+ try_protected_resource_discovery(server_url) ||
30
+ create_default_metadata(server_url)
31
+
32
+ # Cache and return
33
+ storage.set_server_metadata(server_url, server_metadata) if server_metadata
34
+ server_metadata
35
+ end
36
+
37
+ private
38
+
39
+ # Try oauth-authorization-server discovery (server is own auth server)
40
+ # @param server_url [String] MCP server URL
41
+ # @return [ServerMetadata, nil] server metadata or nil
42
+ def try_authorization_server_discovery(server_url)
43
+ discovery_url = UrlBuilder.build_discovery_url(server_url, :authorization_server)
44
+ logger.debug("Trying discovery URL: #{discovery_url}")
45
+ fetch_server_metadata(discovery_url)
46
+ rescue StandardError => e
47
+ logger.debug("oauth-authorization-server discovery failed: #{e.message}")
48
+ nil
49
+ end
50
+
51
+ # Try oauth-protected-resource discovery (delegation pattern)
52
+ # @param server_url [String] MCP server URL
53
+ # @return [ServerMetadata, nil] server metadata or nil
54
+ def try_protected_resource_discovery(server_url)
55
+ discovery_url = UrlBuilder.build_discovery_url(server_url, :protected_resource)
56
+ logger.debug("Trying protected resource discovery: #{discovery_url}")
57
+ resource_metadata = fetch_resource_metadata(discovery_url)
58
+ auth_server_url = resource_metadata.authorization_servers.first
59
+
60
+ if auth_server_url
61
+ logger.debug("Found delegated auth server: #{auth_server_url}")
62
+ fetch_server_metadata("#{auth_server_url}/.well-known/oauth-authorization-server")
63
+ end
64
+ rescue StandardError => e
65
+ logger.debug("oauth-protected-resource discovery failed: #{e.message}")
66
+ nil
67
+ end
68
+
69
+ # Create default server metadata when discovery fails
70
+ # @param server_url [String] MCP server URL
71
+ # @return [ServerMetadata] server metadata with default endpoints
72
+ def create_default_metadata(server_url)
73
+ base_url = UrlBuilder.get_authorization_base_url(server_url)
74
+ logger.warn("OAuth discovery failed, falling back to default endpoints")
75
+ logger.info("Using default OAuth endpoints for #{base_url}")
76
+
77
+ ServerMetadata.new(
78
+ issuer: base_url,
79
+ authorization_endpoint: "#{base_url}/authorize",
80
+ token_endpoint: "#{base_url}/token",
81
+ options: { registration_endpoint: "#{base_url}/register" }
82
+ )
83
+ end
84
+
85
+ # Fetch OAuth server metadata
86
+ # @param url [String] discovery URL
87
+ # @return [ServerMetadata] server metadata
88
+ def fetch_server_metadata(url)
89
+ logger.debug("Fetching server metadata from #{url}")
90
+ response = http_client.get(url)
91
+
92
+ data = HttpResponseHandler.handle_response(response, context: "Server metadata fetch")
93
+
94
+ ServerMetadata.new(
95
+ issuer: data["issuer"],
96
+ authorization_endpoint: data["authorization_endpoint"],
97
+ token_endpoint: data["token_endpoint"],
98
+ options: {
99
+ registration_endpoint: data["registration_endpoint"],
100
+ scopes_supported: data["scopes_supported"],
101
+ response_types_supported: data["response_types_supported"],
102
+ grant_types_supported: data["grant_types_supported"]
103
+ }
104
+ )
105
+ end
106
+
107
+ # Fetch OAuth protected resource metadata
108
+ # @param url [String] discovery URL
109
+ # @return [ResourceMetadata] resource metadata
110
+ def fetch_resource_metadata(url)
111
+ logger.debug("Fetching resource metadata from #{url}")
112
+ response = http_client.get(url)
113
+
114
+ data = HttpResponseHandler.handle_response(response, context: "Resource metadata fetch")
115
+
116
+ ResourceMetadata.new(
117
+ resource: data["resource"],
118
+ authorization_servers: data["authorization_servers"]
119
+ )
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module Flows
7
+ # Orchestrates OAuth 2.1 Authorization Code flow with PKCE
8
+ # Coordinates session management, discovery, registration, and token exchange
9
+ class AuthorizationCodeFlow
10
+ attr_reader :discoverer, :client_registrar, :session_manager, :token_manager, :storage, :logger
11
+
12
+ def initialize(discoverer:, client_registrar:, session_manager:, token_manager:, storage:, logger:) # rubocop:disable Metrics/ParameterLists
13
+ @discoverer = discoverer
14
+ @client_registrar = client_registrar
15
+ @session_manager = session_manager
16
+ @token_manager = token_manager
17
+ @storage = storage
18
+ @logger = logger
19
+ end
20
+
21
+ # Start OAuth authorization flow
22
+ # @param server_url [String] MCP server URL
23
+ # @param redirect_uri [String] redirect URI for callback
24
+ # @param scope [String, nil] requested scope
25
+ # @param https_validator [Proc] callback to validate HTTPS usage
26
+ # @return [String] authorization URL for user to visit
27
+ def start(server_url, redirect_uri, scope, https_validator: nil)
28
+ logger.debug("Starting OAuth authorization flow for #{server_url}")
29
+
30
+ # 1. Discover authorization server
31
+ server_metadata = discoverer.discover(server_url)
32
+ raise Errors::TransportError.new(message: "OAuth server discovery failed") unless server_metadata
33
+
34
+ # 2. Register client (or get cached client)
35
+ client_info = client_registrar.get_or_register(
36
+ server_url,
37
+ server_metadata,
38
+ :authorization_code,
39
+ redirect_uri,
40
+ scope
41
+ )
42
+
43
+ # 3. Create session with PKCE and CSRF state
44
+ session = session_manager.create_session(server_url)
45
+
46
+ # 4. Validate HTTPS usage (optional warning)
47
+ https_validator&.call(server_metadata.authorization_endpoint, "Authorization endpoint")
48
+
49
+ # 5. Build and return authorization URL
50
+ auth_url = UrlBuilder.build_authorization_url(
51
+ server_metadata.authorization_endpoint,
52
+ client_info.client_id,
53
+ client_info.metadata.redirect_uris.first,
54
+ scope,
55
+ session[:state],
56
+ session[:pkce],
57
+ server_url
58
+ )
59
+
60
+ logger.debug("Authorization URL: #{auth_url}")
61
+ auth_url
62
+ end
63
+
64
+ # Complete OAuth authorization flow after callback
65
+ # @param server_url [String] MCP server URL
66
+ # @param code [String] authorization code from callback
67
+ # @param state [String] state parameter from callback
68
+ # @return [Token] access token
69
+ def complete(server_url, code, state)
70
+ logger.debug("Completing OAuth authorization flow")
71
+
72
+ # 1. Validate state and retrieve session data
73
+ session_data = session_manager.validate_and_retrieve_session(server_url, state)
74
+
75
+ pkce = session_data[:pkce]
76
+ client_info = session_data[:client_info]
77
+ server_metadata = discoverer.discover(server_url)
78
+
79
+ unless pkce && client_info
80
+ raise Errors::TransportError.new(message: "Missing PKCE or client info")
81
+ end
82
+
83
+ # 2. Exchange authorization code for tokens
84
+ token = token_manager.exchange_authorization_code(
85
+ server_metadata,
86
+ client_info,
87
+ code,
88
+ pkce,
89
+ server_url
90
+ )
91
+
92
+ # 3. Store token
93
+ storage.set_token(server_url, token)
94
+
95
+ # 4. Clean up temporary session data
96
+ session_manager.cleanup_session(server_url)
97
+
98
+ logger.info("OAuth authorization completed successfully")
99
+ token
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end