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.
- checksums.yaml +4 -4
- data/README.md +144 -162
- data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
- data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +21 -4
- data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/mcp/adapters/base_adapter.rb +215 -0
- data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +413 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +41 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +56 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +56 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +90 -0
- data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +216 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +36 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +427 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +255 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +122 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +67 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +91 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +341 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +307 -0
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +135 -0
- data/lib/ruby_llm/mcp/auth.rb +371 -0
- data/lib/ruby_llm/mcp/client.rb +312 -35
- data/lib/ruby_llm/mcp/configuration.rb +199 -24
- data/lib/ruby_llm/mcp/elicitation.rb +261 -14
- data/lib/ruby_llm/mcp/errors.rb +29 -0
- data/lib/ruby_llm/mcp/extensions/apps/constants.rb +28 -0
- data/lib/ruby_llm/mcp/extensions/apps/resource_metadata.rb +24 -0
- data/lib/ruby_llm/mcp/extensions/apps/tool_metadata.rb +45 -0
- data/lib/ruby_llm/mcp/extensions/configuration.rb +72 -0
- data/lib/ruby_llm/mcp/extensions/constants.rb +16 -0
- data/lib/ruby_llm/mcp/extensions/registry.rb +85 -0
- data/lib/ruby_llm/mcp/handlers/approval_decision.rb +90 -0
- data/lib/ruby_llm/mcp/handlers/async_response.rb +181 -0
- data/lib/ruby_llm/mcp/handlers/concerns/approval_actions.rb +42 -0
- data/lib/ruby_llm/mcp/handlers/concerns/async_execution.rb +80 -0
- data/lib/ruby_llm/mcp/handlers/concerns/elicitation_actions.rb +42 -0
- data/lib/ruby_llm/mcp/handlers/concerns/error_handling.rb +29 -0
- data/lib/ruby_llm/mcp/handlers/concerns/guard_checks.rb +72 -0
- data/lib/ruby_llm/mcp/handlers/concerns/lifecycle.rb +84 -0
- data/lib/ruby_llm/mcp/handlers/concerns/logging.rb +19 -0
- data/lib/ruby_llm/mcp/handlers/concerns/model_filtering.rb +36 -0
- data/lib/ruby_llm/mcp/handlers/concerns/options.rb +83 -0
- data/lib/ruby_llm/mcp/handlers/concerns/registry_integration.rb +54 -0
- data/lib/ruby_llm/mcp/handlers/concerns/sampling_actions.rb +84 -0
- data/lib/ruby_llm/mcp/handlers/concerns/timeouts.rb +52 -0
- data/lib/ruby_llm/mcp/handlers/concerns/tool_filtering.rb +50 -0
- data/lib/ruby_llm/mcp/handlers/elicitation_handler.rb +58 -0
- data/lib/ruby_llm/mcp/handlers/elicitation_registry.rb +203 -0
- data/lib/ruby_llm/mcp/handlers/human_in_the_loop_handler.rb +93 -0
- data/lib/ruby_llm/mcp/handlers/human_in_the_loop_registry.rb +271 -0
- data/lib/ruby_llm/mcp/handlers/promise.rb +192 -0
- data/lib/ruby_llm/mcp/handlers/sampling_handler.rb +64 -0
- data/lib/ruby_llm/mcp/handlers.rb +14 -0
- data/lib/ruby_llm/mcp/native/cancellable_operation.rb +94 -0
- data/lib/ruby_llm/mcp/native/client.rb +551 -0
- data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
- data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
- data/lib/ruby_llm/mcp/native/messages/notifications.rb +60 -0
- data/lib/ruby_llm/mcp/native/messages/requests.rb +267 -0
- data/lib/ruby_llm/mcp/native/messages/responses.rb +114 -0
- data/lib/ruby_llm/mcp/native/messages.rb +43 -0
- data/lib/ruby_llm/mcp/native/notification.rb +16 -0
- data/lib/ruby_llm/mcp/native/protocol.rb +79 -0
- data/lib/ruby_llm/mcp/native/response_handler.rb +220 -0
- data/lib/ruby_llm/mcp/native/task_registry.rb +62 -0
- data/lib/ruby_llm/mcp/native/transport.rb +88 -0
- data/lib/ruby_llm/mcp/native/transports/sse.rb +655 -0
- data/lib/ruby_llm/mcp/native/transports/stdio.rb +367 -0
- data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +1024 -0
- data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/native/transports/support/rate_limiter.rb +49 -0
- data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
- data/lib/ruby_llm/mcp/native.rb +12 -0
- data/lib/ruby_llm/mcp/notification_handler.rb +43 -5
- data/lib/ruby_llm/mcp/prompt.rb +7 -7
- data/lib/ruby_llm/mcp/railtie.rb +7 -13
- data/lib/ruby_llm/mcp/resource.rb +17 -8
- data/lib/ruby_llm/mcp/resource_template.rb +8 -7
- data/lib/ruby_llm/mcp/result.rb +8 -4
- data/lib/ruby_llm/mcp/roots.rb +4 -4
- data/lib/ruby_llm/mcp/sample.rb +83 -13
- data/lib/ruby_llm/mcp/schema_validator.rb +33 -0
- data/lib/ruby_llm/mcp/server_capabilities.rb +41 -0
- data/lib/ruby_llm/mcp/task.rb +65 -0
- data/lib/ruby_llm/mcp/tool.rb +33 -27
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +37 -7
- data/lib/tasks/smoke.rake +66 -0
- metadata +115 -39
- data/lib/generators/ruby_llm/mcp/templates/mcps.yml +0 -9
- data/lib/ruby_llm/mcp/coordinator.rb +0 -293
- data/lib/ruby_llm/mcp/notifications/cancelled.rb +0 -32
- data/lib/ruby_llm/mcp/notifications/initialize.rb +0 -24
- data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +0 -26
- data/lib/ruby_llm/mcp/protocol.rb +0 -34
- data/lib/ruby_llm/mcp/requests/completion_prompt.rb +0 -50
- data/lib/ruby_llm/mcp/requests/completion_resource.rb +0 -50
- data/lib/ruby_llm/mcp/requests/initialization.rb +0 -34
- data/lib/ruby_llm/mcp/requests/logging_set_level.rb +0 -28
- data/lib/ruby_llm/mcp/requests/ping.rb +0 -24
- data/lib/ruby_llm/mcp/requests/prompt_call.rb +0 -32
- data/lib/ruby_llm/mcp/requests/prompt_list.rb +0 -31
- data/lib/ruby_llm/mcp/requests/resource_list.rb +0 -31
- data/lib/ruby_llm/mcp/requests/resource_read.rb +0 -30
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +0 -31
- data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +0 -30
- data/lib/ruby_llm/mcp/requests/shared/meta.rb +0 -32
- data/lib/ruby_llm/mcp/requests/shared/pagination.rb +0 -17
- data/lib/ruby_llm/mcp/requests/tool_call.rb +0 -35
- data/lib/ruby_llm/mcp/requests/tool_list.rb +0 -31
- data/lib/ruby_llm/mcp/response_handler.rb +0 -67
- data/lib/ruby_llm/mcp/responses/elicitation.rb +0 -33
- data/lib/ruby_llm/mcp/responses/error.rb +0 -33
- data/lib/ruby_llm/mcp/responses/ping.rb +0 -28
- data/lib/ruby_llm/mcp/responses/roots_list.rb +0 -31
- data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +0 -50
- data/lib/ruby_llm/mcp/transport.rb +0 -58
- data/lib/ruby_llm/mcp/transports/sse.rb +0 -341
- data/lib/ruby_llm/mcp/transports/stdio.rb +0 -230
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +0 -723
- data/lib/ruby_llm/mcp/transports/support/http_client.rb +0 -28
- data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +0 -47
- 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
|