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,427 @@
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
+ # Serializes logger calls so test doubles and non-thread-safe loggers remain safe
11
+ # when callback and main threads log at the same time.
12
+ class SynchronizedLogger
13
+ def initialize(logger)
14
+ @logger = logger
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def debug(...)
19
+ synchronized_log(:debug, ...)
20
+ end
21
+
22
+ def info(...)
23
+ synchronized_log(:info, ...)
24
+ end
25
+
26
+ def warn(...)
27
+ synchronized_log(:warn, ...)
28
+ end
29
+
30
+ def error(...)
31
+ synchronized_log(:error, ...)
32
+ end
33
+
34
+ private
35
+
36
+ def synchronized_log(level, ...)
37
+ @mutex.synchronize do
38
+ return unless @logger.respond_to?(level)
39
+
40
+ @logger.public_send(level, ...)
41
+ end
42
+ end
43
+ end
44
+
45
+ # Callback worker thread logs are intentionally isolated from the caller logger.
46
+ # JRuby + rspec-mocks logger doubles are not safe to share across threads.
47
+ class NullLogger
48
+ def debug(*) = nil
49
+ def info(*) = nil
50
+ def warn(*) = nil
51
+ def error(*) = nil
52
+ end
53
+
54
+ attr_reader :oauth_provider, :callback_port, :callback_path, :logger
55
+ attr_accessor :server_url, :redirect_uri, :scope, :storage
56
+
57
+ # Expose custom pages for testing/inspection
58
+ def custom_success_page
59
+ @pages.instance_variable_get(:@custom_success_page)
60
+ end
61
+
62
+ def custom_error_page
63
+ @pages.instance_variable_get(:@custom_error_page)
64
+ end
65
+
66
+ # @param server_url [String] OAuth server URL (alternative to oauth_provider)
67
+ # @param oauth_provider [OAuthProvider] OAuth provider instance (alternative to server_url)
68
+ # @param callback_port [Integer] port for local callback server
69
+ # @param callback_path [String] path for callback URL
70
+ # @param logger [Logger] logger instance
71
+ # @param storage [Object] token storage instance
72
+ # @param redirect_uri [String] OAuth redirect URI
73
+ # @param scope [String] OAuth scopes
74
+ def initialize(server_url: nil, oauth_provider: nil, callback_port: 8080, callback_path: "/callback", # rubocop:disable Metrics/ParameterLists
75
+ logger: nil, storage: nil, redirect_uri: nil, scope: nil)
76
+ @logger = logger || MCP.logger
77
+ @synchronized_logger = SynchronizedLogger.new(@logger)
78
+ @callback_logger = NullLogger.new
79
+ @callback_port = callback_port
80
+ @callback_path = callback_path
81
+
82
+ # Set redirect_uri before creating oauth_provider
83
+ redirect_uri ||= "http://localhost:#{callback_port}#{callback_path}"
84
+
85
+ # Either accept an existing oauth_provider or create one
86
+ if oauth_provider
87
+ @oauth_provider = oauth_provider
88
+ # Sync attributes from the provided oauth_provider
89
+ @server_url = oauth_provider.server_url
90
+ @redirect_uri = oauth_provider.redirect_uri
91
+ @scope = oauth_provider.scope
92
+ @storage = oauth_provider.storage
93
+ elsif server_url
94
+ @server_url = server_url
95
+ @redirect_uri = redirect_uri
96
+ @scope = scope
97
+ @storage = storage || MemoryStorage.new
98
+ # Create a new oauth_provider
99
+ @oauth_provider = OAuthProvider.new(
100
+ server_url: server_url,
101
+ redirect_uri: redirect_uri,
102
+ scope: scope,
103
+ logger: @synchronized_logger,
104
+ storage: @storage
105
+ )
106
+ else
107
+ raise ArgumentError, "Either server_url or oauth_provider must be provided"
108
+ end
109
+
110
+ # Ensure OAuth provider redirect_uri matches our callback server
111
+ validate_and_sync_redirect_uri!
112
+
113
+ # Initialize browser helpers
114
+ @http_server = Browser::HttpServer.new(port: @callback_port, logger: @callback_logger)
115
+ @callback_handler = Browser::CallbackHandler.new(callback_path: @callback_path, logger: @callback_logger)
116
+ @pages = Browser::Pages.new(
117
+ custom_success_page: MCP.config.oauth.browser_success_page,
118
+ custom_error_page: MCP.config.oauth.browser_error_page
119
+ )
120
+ @opener = Browser::Opener.new(logger: @synchronized_logger)
121
+ end
122
+
123
+ # Perform complete OAuth authentication flow with browser
124
+ # Compatible with OAuthProvider's authentication pattern
125
+ # @param timeout [Integer] seconds to wait for authorization
126
+ # @param auto_open_browser [Boolean] automatically open browser
127
+ # @return [Token] access token
128
+ def authenticate(timeout: 300, auto_open_browser: true)
129
+ # 1. Start authorization flow and get URL
130
+ auth_url = @oauth_provider.start_authorization_flow
131
+ @synchronized_logger.debug("Authorization URL: #{auth_url}")
132
+
133
+ # 2. Create result container for thread coordination
134
+ result = { code: nil, state: nil, error: nil, completed: false }
135
+ mutex = Mutex.new
136
+ condition = ConditionVariable.new
137
+
138
+ # 3. Start local callback server
139
+ server = start_callback_server(result, mutex, condition)
140
+
141
+ begin
142
+ announce_authorization_flow(auth_url, auto_open_browser)
143
+
144
+ # Allow callback worker to begin processing only after setup logging/browser open
145
+ # to reduce cross-thread test-double races under JRuby.
146
+ server.start
147
+
148
+ # 5. Wait for callback with timeout
149
+ mutex.synchronize do
150
+ condition.wait(mutex, timeout) unless result[:completed]
151
+ end
152
+
153
+ snapshot = mutex.synchronize { result.dup }
154
+
155
+ unless snapshot[:completed]
156
+ raise Errors::TimeoutError.new(message: "OAuth authorization timed out after #{timeout} seconds")
157
+ end
158
+
159
+ if snapshot[:error]
160
+ raise Errors::TransportError.new(message: "OAuth authorization failed: #{snapshot[:error]}")
161
+ end
162
+
163
+ # Stop callback server before token exchange to avoid cross-thread races
164
+ # (observed under JRuby when background callback logging overlaps main-thread mocks).
165
+ server&.shutdown
166
+ server = nil
167
+
168
+ # 6. Complete OAuth flow
169
+ @synchronized_logger.debug("Completing OAuth authorization flow")
170
+ token = @oauth_provider.complete_authorization_flow(snapshot[:code], snapshot[:state])
171
+
172
+ @synchronized_logger.info("\nAuthentication successful!")
173
+ token
174
+ ensure
175
+ # Always shutdown the server
176
+ server&.shutdown
177
+ end
178
+ end
179
+
180
+ # Get current access token (for compatibility with OAuthProvider)
181
+ # @return [Token, nil] valid access token or nil
182
+ def access_token
183
+ @oauth_provider.access_token
184
+ end
185
+
186
+ # Apply authorization header to HTTP request (for compatibility with OAuthProvider)
187
+ # @param request [HTTPX::Request] HTTP request object
188
+ def apply_authorization(request)
189
+ @oauth_provider.apply_authorization(request)
190
+ end
191
+
192
+ # Start authorization flow (for compatibility with OAuthProvider)
193
+ # @return [String] authorization URL
194
+ def start_authorization_flow
195
+ @oauth_provider.start_authorization_flow
196
+ end
197
+
198
+ # Complete authorization flow (for compatibility with OAuthProvider)
199
+ # @param code [String] authorization code
200
+ # @param state [String] state parameter
201
+ # @return [Token] access token
202
+ def complete_authorization_flow(code, state)
203
+ @oauth_provider.complete_authorization_flow(code, state)
204
+ end
205
+
206
+ # Handle authentication challenge with browser-based auth
207
+ # @param www_authenticate [String, nil] WWW-Authenticate header value
208
+ # @param resource_metadata [String, nil] Resource metadata URL from response/challenge
209
+ # @param resource_metadata_url [String, nil] Legacy alias for resource_metadata
210
+ # @param requested_scope [String, nil] Scope from WWW-Authenticate challenge
211
+ # @return [Boolean] true if authentication was completed successfully
212
+ def handle_authentication_challenge(www_authenticate: nil, resource_metadata: nil, resource_metadata_url: nil,
213
+ requested_scope: nil)
214
+ @synchronized_logger.debug("BrowserOAuthProvider handling authentication challenge")
215
+
216
+ # Try standard provider's automatic handling first (token refresh, client credentials)
217
+ begin
218
+ return @oauth_provider.handle_authentication_challenge(
219
+ www_authenticate: www_authenticate,
220
+ resource_metadata: resource_metadata,
221
+ resource_metadata_url: resource_metadata_url,
222
+ requested_scope: requested_scope
223
+ )
224
+ rescue Errors::AuthenticationRequiredError
225
+ # Standard provider couldn't handle it - need interactive auth
226
+ @synchronized_logger.info("Automatic authentication failed, starting browser-based OAuth flow")
227
+ end
228
+
229
+ # Perform full browser-based authentication
230
+ authenticate(auto_open_browser: true)
231
+ true
232
+ end
233
+
234
+ # Parse WWW-Authenticate header (delegate to oauth_provider)
235
+ # @param header [String] WWW-Authenticate header value
236
+ # @return [Hash] parsed challenge information
237
+ def parse_www_authenticate(header)
238
+ @oauth_provider.parse_www_authenticate(header)
239
+ end
240
+
241
+ private
242
+
243
+ # Validate and synchronize redirect_uri between this provider and oauth_provider
244
+ def validate_and_sync_redirect_uri!
245
+ expected_redirect_uri = "http://localhost:#{@callback_port}#{@callback_path}"
246
+
247
+ if @oauth_provider.redirect_uri != expected_redirect_uri
248
+ @synchronized_logger.warn("OAuth provider redirect_uri (#{@oauth_provider.redirect_uri}) " \
249
+ "doesn't match callback server (#{expected_redirect_uri}). " \
250
+ "Updating redirect_uri.")
251
+ @oauth_provider.redirect_uri = expected_redirect_uri
252
+ @redirect_uri = expected_redirect_uri
253
+ end
254
+ end
255
+
256
+ # Start local HTTP callback server
257
+ # @param result [Hash] result container for callback data
258
+ # @param mutex [Mutex] synchronization mutex
259
+ # @param condition [ConditionVariable] wait condition
260
+ # @return [Browser::CallbackServer] server wrapper
261
+ def start_callback_server(result, mutex, condition)
262
+ server = @http_server.start_server
263
+ @synchronized_logger.debug("Started callback server on http://127.0.0.1:#{@callback_port}#{@callback_path}")
264
+
265
+ control = build_callback_thread_control
266
+ thread = build_callback_worker_thread(server, result, mutex, condition, control)
267
+ stop_proc = -> { stop_callback_worker(control) }
268
+ start_proc = -> { start_callback_worker(control) }
269
+
270
+ # Return wrapper with shutdown method
271
+ Browser::CallbackServer.new(server, thread, stop_proc, start_proc)
272
+ end
273
+
274
+ # Handle incoming HTTP request on callback server
275
+ # @param client [TCPSocket] client socket
276
+ # @param result [Hash] result container
277
+ # @param mutex [Mutex] synchronization mutex
278
+ # @param condition [ConditionVariable] wait condition
279
+ def handle_http_request(client, result, mutex, condition)
280
+ callback_result = nil
281
+
282
+ @http_server.configure_client_socket(client)
283
+
284
+ request_line = @http_server.read_request_line(client)
285
+ return unless request_line
286
+
287
+ method_name, path = @http_server.extract_request_parts(request_line)
288
+ return unless method_name && path
289
+
290
+ @http_server.read_http_headers(client)
291
+
292
+ # Validate callback path
293
+ unless @callback_handler.valid_callback_path?(path)
294
+ @http_server.send_http_response(client, 404, "text/plain", "Not Found")
295
+ return
296
+ end
297
+
298
+ # Parse and extract OAuth parameters
299
+ params = @callback_handler.parse_callback_params(path, @http_server)
300
+ oauth_params = @callback_handler.extract_oauth_params(params)
301
+ callback_result = build_callback_result(oauth_params)
302
+
303
+ # Send response
304
+ if callback_result[:error]
305
+ @http_server.send_http_response(client, 400, "text/html", @pages.error_page(callback_result[:error]))
306
+ else
307
+ @http_server.send_http_response(client, 200, "text/html", @pages.success_page)
308
+ end
309
+ ensure
310
+ apply_callback_result(callback_result, result, mutex, condition) if callback_result
311
+ close_callback_client(client)
312
+ end
313
+
314
+ # Wake the waiting authentication flow with a deterministic error when callback
315
+ # processing fails in the worker thread.
316
+ def mark_callback_failure(result, mutex, condition, error)
317
+ mutex.synchronize do
318
+ return if result[:completed]
319
+
320
+ result[:error] = "OAuth callback processing failed: #{error.message}"
321
+ result[:completed] = true
322
+ condition.signal
323
+ end
324
+
325
+ @synchronized_logger.warn("OAuth callback worker failed: #{error.class}: #{error.message}")
326
+ end
327
+
328
+ def build_callback_result(oauth_params)
329
+ if oauth_params[:error]
330
+ { code: nil, state: nil, error: oauth_params[:error_description] || oauth_params[:error] }
331
+ elsif oauth_params[:code] && oauth_params[:state]
332
+ { code: oauth_params[:code], state: oauth_params[:state], error: nil }
333
+ else
334
+ { code: nil, state: nil, error: "Invalid callback: missing code or state parameter" }
335
+ end
336
+ end
337
+
338
+ def apply_callback_result(callback_result, result, mutex, condition)
339
+ mutex.synchronize do
340
+ return if result[:completed]
341
+
342
+ result[:code] = callback_result[:code]
343
+ result[:state] = callback_result[:state]
344
+ result[:error] = callback_result[:error]
345
+ result[:completed] = true
346
+ condition.signal
347
+ end
348
+ end
349
+
350
+ def close_callback_client(client)
351
+ client&.close
352
+ rescue IOError, SystemCallError => e
353
+ @synchronized_logger.debug("Error closing OAuth callback client socket: #{e.class}: #{e.message}")
354
+ end
355
+
356
+ def announce_authorization_flow(auth_url, auto_open_browser)
357
+ if auto_open_browser
358
+ @opener.open_browser(auth_url)
359
+ @synchronized_logger.info("\nOpening browser for authorization...")
360
+ @synchronized_logger.info("If browser doesn't open automatically, visit this URL:")
361
+ else
362
+ @synchronized_logger.info("\nPlease visit this URL to authorize:")
363
+ end
364
+ @synchronized_logger.info(auth_url)
365
+ @synchronized_logger.info("\nWaiting for authorization...")
366
+ end
367
+
368
+ def build_callback_thread_control
369
+ {
370
+ mutex: Mutex.new,
371
+ condition: ConditionVariable.new,
372
+ running: true,
373
+ accepting: false
374
+ }
375
+ end
376
+
377
+ def build_callback_worker_thread(server, result, result_mutex, condition, control)
378
+ Thread.new do
379
+ wait_for_callback_worker_start(control)
380
+
381
+ while callback_worker_running?(control)
382
+ begin
383
+ # Use wait_readable with timeout to allow checking stop signal
384
+ next unless server.wait_readable(0.5)
385
+
386
+ client = server.accept
387
+ handle_http_request(client, result, result_mutex, condition)
388
+ break if result_mutex.synchronize { result[:completed] }
389
+ rescue IOError, Errno::EBADF
390
+ # Server was closed, exit loop
391
+ break
392
+ rescue StandardError => e
393
+ mark_callback_failure(result, result_mutex, condition, e)
394
+ break
395
+ end
396
+ end
397
+ end
398
+ end
399
+
400
+ def wait_for_callback_worker_start(control)
401
+ control[:mutex].synchronize do
402
+ control[:condition].wait(control[:mutex]) until control[:accepting] || !control[:running]
403
+ end
404
+ end
405
+
406
+ def callback_worker_running?(control)
407
+ control[:mutex].synchronize { control[:running] }
408
+ end
409
+
410
+ def start_callback_worker(control)
411
+ control[:mutex].synchronize do
412
+ control[:accepting] = true
413
+ control[:condition].signal
414
+ end
415
+ end
416
+
417
+ def stop_callback_worker(control)
418
+ control[:mutex].synchronize do
419
+ control[:running] = false
420
+ control[:accepting] = true
421
+ control[:condition].broadcast
422
+ end
423
+ end
424
+ end
425
+ end
426
+ end
427
+ 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