ruby_llm-mcp 0.8.0 → 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/templates/initializer.rb +21 -4
- data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +20 -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_server.rb +7 -1
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +0 -3
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +0 -2
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +100 -32
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +230 -57
- data/lib/ruby_llm/mcp/auth/discoverer.rb +157 -26
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +19 -2
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +3 -2
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +0 -2
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +31 -12
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +124 -9
- data/lib/ruby_llm/mcp/auth/session_manager.rb +0 -2
- data/lib/ruby_llm/mcp/auth/token_manager.rb +74 -3
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +72 -15
- data/lib/ruby_llm/mcp/auth.rb +19 -7
- data/lib/ruby_llm/mcp/client.rb +267 -39
- data/lib/ruby_llm/mcp/configuration.rb +161 -12
- data/lib/ruby_llm/mcp/elicitation.rb +261 -14
- data/lib/ruby_llm/mcp/errors.rb +18 -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 +8 -6
- 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 +31 -7
- data/lib/tasks/smoke.rake +66 -0
- metadata +77 -36
- data/lib/ruby_llm/mcp/coordinator.rb +0 -304
- 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 -151
- data/lib/ruby_llm/mcp/transports/sse.rb +0 -435
- data/lib/ruby_llm/mcp/transports/stdio.rb +0 -231
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +0 -725
- 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
|
@@ -22,15 +22,18 @@ module RubyLLM
|
|
|
22
22
|
# @param server_url [String] MCP server URL
|
|
23
23
|
# @param redirect_uri [String] redirect URI for callback
|
|
24
24
|
# @param scope [String, nil] requested scope
|
|
25
|
+
# @param resource_metadata [String, nil] explicit resource metadata URL hint
|
|
25
26
|
# @param https_validator [Proc] callback to validate HTTPS usage
|
|
26
27
|
# @return [String] authorization URL for user to visit
|
|
27
|
-
def start(server_url, redirect_uri, scope, https_validator: nil)
|
|
28
|
+
def start(server_url, redirect_uri, scope, resource_metadata: nil, https_validator: nil)
|
|
28
29
|
logger.debug("Starting OAuth authorization flow for #{server_url}")
|
|
29
30
|
|
|
30
31
|
# 1. Discover authorization server
|
|
31
|
-
server_metadata = discoverer.discover(server_url)
|
|
32
|
+
server_metadata = discoverer.discover(server_url, resource_metadata_url: resource_metadata)
|
|
32
33
|
raise Errors::TransportError.new(message: "OAuth server discovery failed") unless server_metadata
|
|
33
34
|
|
|
35
|
+
validate_pkce_support!(server_metadata)
|
|
36
|
+
|
|
34
37
|
# 2. Register client (or get cached client)
|
|
35
38
|
client_info = client_registrar.get_or_register(
|
|
36
39
|
server_url,
|
|
@@ -98,6 +101,20 @@ module RubyLLM
|
|
|
98
101
|
logger.info("OAuth authorization completed successfully")
|
|
99
102
|
token
|
|
100
103
|
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# If the server advertises PKCE methods, S256 must be supported per MCP authorization requirements.
|
|
108
|
+
def validate_pkce_support!(server_metadata)
|
|
109
|
+
methods = server_metadata.code_challenge_methods_supported
|
|
110
|
+
normalized_methods = Array(methods)
|
|
111
|
+
return if normalized_methods.empty? || normalized_methods.include?("S256")
|
|
112
|
+
|
|
113
|
+
raise Errors::TransportError.new(
|
|
114
|
+
message: "Authorization server does not support required PKCE method S256 " \
|
|
115
|
+
"(advertised: #{normalized_methods.join(', ')})"
|
|
116
|
+
)
|
|
117
|
+
end
|
|
101
118
|
end
|
|
102
119
|
end
|
|
103
120
|
end
|
|
@@ -21,12 +21,13 @@ module RubyLLM
|
|
|
21
21
|
# @param server_url [String] MCP server URL
|
|
22
22
|
# @param redirect_uri [String] redirect URI (used for registration only)
|
|
23
23
|
# @param scope [String, nil] requested scope
|
|
24
|
+
# @param resource_metadata [String, nil] explicit resource metadata URL hint
|
|
24
25
|
# @return [Token] access token
|
|
25
|
-
def execute(server_url, redirect_uri, scope)
|
|
26
|
+
def execute(server_url, redirect_uri, scope, resource_metadata: nil)
|
|
26
27
|
logger.debug("Starting OAuth client credentials flow")
|
|
27
28
|
|
|
28
29
|
# 1. Discover authorization server
|
|
29
|
-
server_metadata = discoverer.discover(server_url)
|
|
30
|
+
server_metadata = discoverer.discover(server_url, resource_metadata_url: resource_metadata)
|
|
30
31
|
raise Errors::TransportError.new(message: "OAuth server discovery failed") unless server_metadata
|
|
31
32
|
|
|
32
33
|
# 2. Register client (or get cached client) with client credentials grant
|
|
@@ -7,64 +7,83 @@ module RubyLLM
|
|
|
7
7
|
# Stores tokens, client registrations, server metadata, and temporary session data
|
|
8
8
|
class MemoryStorage
|
|
9
9
|
def initialize
|
|
10
|
+
@mutex = Mutex.new
|
|
10
11
|
@tokens = {}
|
|
11
12
|
@client_infos = {}
|
|
12
13
|
@server_metadata = {}
|
|
13
14
|
@pkce_data = {}
|
|
14
15
|
@state_data = {}
|
|
16
|
+
@resource_metadata = {}
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
# Token storage
|
|
18
20
|
def get_token(server_url)
|
|
19
|
-
@tokens[server_url]
|
|
21
|
+
@mutex.synchronize { @tokens[server_url] }
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def set_token(server_url, token)
|
|
23
|
-
@tokens[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) }
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
# Client registration storage
|
|
27
33
|
def get_client_info(server_url)
|
|
28
|
-
@client_infos[server_url]
|
|
34
|
+
@mutex.synchronize { @client_infos[server_url] }
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
def set_client_info(server_url, client_info)
|
|
32
|
-
@client_infos[server_url] = client_info
|
|
38
|
+
@mutex.synchronize { @client_infos[server_url] = client_info }
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
# Server metadata caching
|
|
36
42
|
def get_server_metadata(server_url)
|
|
37
|
-
@server_metadata[server_url]
|
|
43
|
+
@mutex.synchronize { @server_metadata[server_url] }
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
def set_server_metadata(server_url, metadata)
|
|
41
|
-
@server_metadata[server_url] = metadata
|
|
47
|
+
@mutex.synchronize { @server_metadata[server_url] = metadata }
|
|
42
48
|
end
|
|
43
49
|
|
|
44
50
|
# PKCE state management (temporary)
|
|
45
51
|
def get_pkce(server_url)
|
|
46
|
-
@pkce_data[server_url]
|
|
52
|
+
@mutex.synchronize { @pkce_data[server_url] }
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
def set_pkce(server_url, pkce)
|
|
50
|
-
@pkce_data[server_url] = pkce
|
|
56
|
+
@mutex.synchronize { @pkce_data[server_url] = pkce }
|
|
51
57
|
end
|
|
52
58
|
|
|
53
59
|
def delete_pkce(server_url)
|
|
54
|
-
@pkce_data.delete(server_url)
|
|
60
|
+
@mutex.synchronize { @pkce_data.delete(server_url) }
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
# State parameter management (temporary)
|
|
58
64
|
def get_state(server_url)
|
|
59
|
-
@state_data[server_url]
|
|
65
|
+
@mutex.synchronize { @state_data[server_url] }
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
def set_state(server_url, state)
|
|
63
|
-
@state_data[server_url] = state
|
|
69
|
+
@mutex.synchronize { @state_data[server_url] = state }
|
|
64
70
|
end
|
|
65
71
|
|
|
66
72
|
def delete_state(server_url)
|
|
67
|
-
@state_data.delete(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) }
|
|
68
87
|
end
|
|
69
88
|
end
|
|
70
89
|
end
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "httpx"
|
|
4
|
-
require "uri"
|
|
5
|
-
|
|
6
3
|
module RubyLLM
|
|
7
4
|
module MCP
|
|
8
5
|
module Auth
|
|
@@ -117,21 +114,31 @@ module RubyLLM
|
|
|
117
114
|
end
|
|
118
115
|
|
|
119
116
|
# Start OAuth authorization flow (authorization code grant)
|
|
117
|
+
# @param resource_metadata [String, nil] explicit resource metadata URL hint
|
|
120
118
|
# @return [String] authorization URL for user to visit
|
|
121
|
-
def start_authorization_flow
|
|
119
|
+
def start_authorization_flow(resource_metadata: nil)
|
|
120
|
+
hint = resource_metadata || @resource_metadata_hint
|
|
122
121
|
@auth_code_flow.start(
|
|
123
122
|
server_url,
|
|
124
123
|
redirect_uri,
|
|
125
124
|
scope,
|
|
125
|
+
resource_metadata: hint,
|
|
126
126
|
https_validator: method(:validate_https_endpoint)
|
|
127
127
|
)
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
# Perform client credentials flow (application authentication without user)
|
|
131
131
|
# @param scope [String] optional scope override
|
|
132
|
+
# @param resource_metadata [String, nil] explicit resource metadata URL hint
|
|
132
133
|
# @return [Token] access token
|
|
133
|
-
def client_credentials_flow(scope: nil)
|
|
134
|
-
|
|
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
|
+
)
|
|
135
142
|
end
|
|
136
143
|
|
|
137
144
|
# Complete OAuth authorization flow after callback
|
|
@@ -153,6 +160,91 @@ module RubyLLM
|
|
|
153
160
|
request.headers["Authorization"] = token.to_header
|
|
154
161
|
end
|
|
155
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
|
+
|
|
156
248
|
private
|
|
157
249
|
|
|
158
250
|
# Create HTTP client for OAuth requests
|
|
@@ -165,7 +257,7 @@ module RubyLLM
|
|
|
165
257
|
headers["MCP-Protocol-Version"] = RubyLLM::MCP.config.protocol_version
|
|
166
258
|
|
|
167
259
|
HTTPX.plugin(:follow_redirects).with(
|
|
168
|
-
timeout: {
|
|
260
|
+
timeout: { request_timeout: DEFAULT_OAUTH_TIMEOUT },
|
|
169
261
|
headers: headers
|
|
170
262
|
)
|
|
171
263
|
end
|
|
@@ -207,11 +299,13 @@ module RubyLLM
|
|
|
207
299
|
|
|
208
300
|
# Refresh access token using refresh token
|
|
209
301
|
# @param token [Token] current token with refresh_token
|
|
302
|
+
# @param resource_metadata [String, nil] explicit resource metadata URL hint
|
|
210
303
|
# @return [Token, nil] new token or nil if refresh failed
|
|
211
|
-
def refresh_token(token)
|
|
304
|
+
def refresh_token(token, resource_metadata: nil)
|
|
212
305
|
return nil unless token.refresh_token
|
|
213
306
|
|
|
214
|
-
|
|
307
|
+
hint = resource_metadata || @resource_metadata_hint
|
|
308
|
+
server_metadata = @discoverer.discover(server_url, resource_metadata_url: hint)
|
|
215
309
|
client_info = storage.get_client_info(server_url)
|
|
216
310
|
return nil unless server_metadata && client_info
|
|
217
311
|
|
|
@@ -220,6 +314,27 @@ module RubyLLM
|
|
|
220
314
|
logger.debug("Token refreshed successfully") if new_token
|
|
221
315
|
new_token
|
|
222
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
|
|
223
338
|
end
|
|
224
339
|
end
|
|
225
340
|
end
|
|
@@ -71,9 +71,17 @@ module RubyLLM
|
|
|
71
71
|
|
|
72
72
|
# Return nil on error responses
|
|
73
73
|
return nil if response.is_a?(HTTPX::ErrorResponse)
|
|
74
|
-
|
|
74
|
+
|
|
75
|
+
if response.status != 200
|
|
76
|
+
oauth_error = extract_oauth_error(response.body.to_s)
|
|
77
|
+
raise_oauth_error!("Token refresh", oauth_error, response.status) if oauth_error
|
|
78
|
+
return nil
|
|
79
|
+
end
|
|
75
80
|
|
|
76
81
|
parse_refresh_response(response, token)
|
|
82
|
+
rescue Errors::TransportError => e
|
|
83
|
+
logger.warn(e.message)
|
|
84
|
+
nil
|
|
77
85
|
rescue JSON::ParserError => e
|
|
78
86
|
logger.warn("Invalid token refresh response: #{e.message}")
|
|
79
87
|
nil
|
|
@@ -194,6 +202,9 @@ module RubyLLM
|
|
|
194
202
|
raise Errors::TransportError.new(message: "#{context} failed: #{error_message}")
|
|
195
203
|
end
|
|
196
204
|
|
|
205
|
+
oauth_error = extract_oauth_error(response.body.to_s)
|
|
206
|
+
raise_oauth_error!(context, oauth_error, response.status) if oauth_error
|
|
207
|
+
|
|
197
208
|
return if response.status == 200
|
|
198
209
|
|
|
199
210
|
raise Errors::TransportError.new(
|
|
@@ -207,8 +218,18 @@ module RubyLLM
|
|
|
207
218
|
# @return [Token] parsed token
|
|
208
219
|
def parse_token_response(response)
|
|
209
220
|
data = JSON.parse(response.body.to_s)
|
|
221
|
+
raise_oauth_error!("Token exchange", extract_oauth_error(data), response.status)
|
|
222
|
+
|
|
223
|
+
access_token = data["access_token"]
|
|
224
|
+
if access_token.nil? || access_token.empty?
|
|
225
|
+
raise Errors::TransportError.new(
|
|
226
|
+
message: "Token exchange failed: invalid token response (missing access_token)",
|
|
227
|
+
code: response.status
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
210
231
|
Token.new(
|
|
211
|
-
access_token:
|
|
232
|
+
access_token: access_token,
|
|
212
233
|
token_type: data["token_type"] || "Bearer",
|
|
213
234
|
expires_in: data["expires_in"],
|
|
214
235
|
scope: data["scope"],
|
|
@@ -222,14 +243,64 @@ module RubyLLM
|
|
|
222
243
|
# @return [Token] new token
|
|
223
244
|
def parse_refresh_response(response, old_token)
|
|
224
245
|
data = JSON.parse(response.body.to_s)
|
|
246
|
+
raise_oauth_error!("Token refresh", extract_oauth_error(data), response.status)
|
|
247
|
+
|
|
248
|
+
access_token = data["access_token"]
|
|
249
|
+
if access_token.nil? || access_token.empty?
|
|
250
|
+
raise Errors::TransportError.new(
|
|
251
|
+
message: "Token refresh failed: invalid token response (missing access_token)",
|
|
252
|
+
code: response.status
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
225
256
|
Token.new(
|
|
226
|
-
access_token:
|
|
257
|
+
access_token: access_token,
|
|
227
258
|
token_type: data["token_type"] || "Bearer",
|
|
228
259
|
expires_in: data["expires_in"],
|
|
229
260
|
scope: data["scope"],
|
|
230
261
|
refresh_token: data["refresh_token"] || old_token.refresh_token
|
|
231
262
|
)
|
|
232
263
|
end
|
|
264
|
+
|
|
265
|
+
# Extract OAuth error fields from JSON response data
|
|
266
|
+
# @param source [String, Hash] response body string or parsed JSON hash
|
|
267
|
+
# @return [Hash, nil] OAuth error fields or nil
|
|
268
|
+
def extract_oauth_error(source)
|
|
269
|
+
data = source.is_a?(Hash) ? source : JSON.parse(source)
|
|
270
|
+
error = data["error"] || data[:error]
|
|
271
|
+
return nil unless error
|
|
272
|
+
|
|
273
|
+
{
|
|
274
|
+
error: error,
|
|
275
|
+
error_description: data["error_description"] || data[:error_description],
|
|
276
|
+
error_uri: data["error_uri"] || data[:error_uri]
|
|
277
|
+
}
|
|
278
|
+
rescue JSON::ParserError
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Raise TransportError for OAuth error responses
|
|
283
|
+
# @param context [String] context for the error
|
|
284
|
+
# @param oauth_error [Hash, nil] OAuth error fields
|
|
285
|
+
# @param status_code [Integer, nil] HTTP response status code
|
|
286
|
+
# @raise [Errors::TransportError] when oauth_error is present
|
|
287
|
+
def raise_oauth_error!(context, oauth_error, status_code)
|
|
288
|
+
return unless oauth_error
|
|
289
|
+
|
|
290
|
+
error = oauth_error[:error]
|
|
291
|
+
description = oauth_error[:error_description]
|
|
292
|
+
error_uri = oauth_error[:error_uri]
|
|
293
|
+
|
|
294
|
+
message = "#{context} failed: OAuth error '#{error}'"
|
|
295
|
+
message += ": #{description}" if description
|
|
296
|
+
message += " (#{error_uri})" if error_uri
|
|
297
|
+
|
|
298
|
+
raise Errors::TransportError.new(
|
|
299
|
+
message: message,
|
|
300
|
+
code: status_code,
|
|
301
|
+
error: error
|
|
302
|
+
)
|
|
303
|
+
end
|
|
233
304
|
end
|
|
234
305
|
end
|
|
235
306
|
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
# Helper module for preparing OAuth providers for transports
|
|
7
|
+
# This keeps OAuth logic out of the Native module while making it reusable
|
|
8
|
+
module TransportOauthHelper
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Check if OAuth configuration is present
|
|
12
|
+
# @param config [Hash] transport configuration hash
|
|
13
|
+
# @return [Boolean] true if OAuth config is present
|
|
14
|
+
def oauth_config_present?(config)
|
|
15
|
+
oauth_config = config[:oauth] || config["oauth"]
|
|
16
|
+
return false if oauth_config.nil?
|
|
17
|
+
|
|
18
|
+
# If it's an OAuth provider instance, it's present
|
|
19
|
+
return true if oauth_config.respond_to?(:access_token)
|
|
20
|
+
|
|
21
|
+
# If it's a hash, check if it's not empty
|
|
22
|
+
!oauth_config.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Create OAuth provider from configuration
|
|
26
|
+
# Accepts either a provider instance or a configuration hash
|
|
27
|
+
# @param config [Hash] transport configuration hash (will be modified)
|
|
28
|
+
# @return [OAuthProvider, BrowserOAuthProvider, nil] OAuth provider or nil
|
|
29
|
+
def create_oauth_provider(config)
|
|
30
|
+
oauth_config = config.delete(:oauth) || config.delete("oauth")
|
|
31
|
+
return nil unless oauth_config
|
|
32
|
+
|
|
33
|
+
# If provider key exists with an instance, use it
|
|
34
|
+
if oauth_config.is_a?(Hash) && (oauth_config[:provider] || oauth_config["provider"])
|
|
35
|
+
return oauth_config[:provider] || oauth_config["provider"]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# If oauth_config itself is a provider instance, use it directly
|
|
39
|
+
if oauth_config.respond_to?(:access_token) && oauth_config.respond_to?(:start_authorization_flow)
|
|
40
|
+
return oauth_config
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Otherwise create new provider from config hash
|
|
44
|
+
server_url = determine_server_url(config)
|
|
45
|
+
return nil unless server_url
|
|
46
|
+
|
|
47
|
+
redirect_uri = oauth_config[:redirect_uri] || oauth_config["redirect_uri"] || "http://localhost:8080/callback"
|
|
48
|
+
scope = oauth_config[:scope] || oauth_config["scope"]
|
|
49
|
+
storage = oauth_config[:storage] || oauth_config["storage"]
|
|
50
|
+
grant_type = oauth_config[:grant_type] || oauth_config["grant_type"] || :authorization_code
|
|
51
|
+
|
|
52
|
+
RubyLLM::MCP::Auth::OAuthProvider.new(
|
|
53
|
+
server_url: server_url,
|
|
54
|
+
redirect_uri: redirect_uri,
|
|
55
|
+
scope: scope,
|
|
56
|
+
logger: MCP.logger,
|
|
57
|
+
storage: storage,
|
|
58
|
+
grant_type: grant_type
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Determine server URL from transport config
|
|
63
|
+
# @param config [Hash] transport configuration hash
|
|
64
|
+
# @return [String, nil] server URL or nil
|
|
65
|
+
def determine_server_url(config)
|
|
66
|
+
config[:url] || config["url"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Prepare HTTP transport configuration with OAuth provider
|
|
70
|
+
# @param config [Hash] transport configuration hash (will be modified)
|
|
71
|
+
# @param oauth_provider [OAuthProvider, nil] OAuth provider instance
|
|
72
|
+
# @return [Hash] prepared configuration
|
|
73
|
+
def prepare_http_transport_config(config, oauth_provider)
|
|
74
|
+
options = {
|
|
75
|
+
version: config.delete(:version) || config.delete("version"),
|
|
76
|
+
headers: config.delete(:headers) || config.delete("headers"),
|
|
77
|
+
oauth_provider: oauth_provider,
|
|
78
|
+
reconnection: config.delete(:reconnection) || config.delete("reconnection"),
|
|
79
|
+
reconnection_options: config.delete(:reconnection_options) || config.delete("reconnection_options"),
|
|
80
|
+
rate_limit: config.delete(:rate_limit) || config.delete("rate_limit"),
|
|
81
|
+
session_id: config.delete(:session_id) || config.delete("session_id")
|
|
82
|
+
}.compact
|
|
83
|
+
|
|
84
|
+
config[:options] = options
|
|
85
|
+
config
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Prepare stdio transport configuration
|
|
89
|
+
# @param config [Hash] transport configuration hash (will be modified)
|
|
90
|
+
# @return [Hash] prepared configuration
|
|
91
|
+
def prepare_stdio_transport_config(config)
|
|
92
|
+
# Remove OAuth config from stdio transport (not supported)
|
|
93
|
+
config.delete(:oauth)
|
|
94
|
+
config.delete("oauth")
|
|
95
|
+
|
|
96
|
+
options = {
|
|
97
|
+
args: config.delete(:args) || config.delete("args"),
|
|
98
|
+
env: config.delete(:env) || config.delete("env")
|
|
99
|
+
}.compact
|
|
100
|
+
|
|
101
|
+
config[:options] = options unless options.empty?
|
|
102
|
+
config
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|