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