ruby_llm_swarm-mcp 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
- data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
- 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/chat.rb +34 -0
- data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
- data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
- data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
- data/lib/ruby_llm/mcp/attachment.rb +18 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -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 +280 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -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 +90 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -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 +236 -0
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +401 -0
- data/lib/ruby_llm/mcp/completion.rb +16 -0
- data/lib/ruby_llm/mcp/configuration.rb +310 -0
- data/lib/ruby_llm/mcp/content.rb +28 -0
- data/lib/ruby_llm/mcp/elicitation.rb +48 -0
- data/lib/ruby_llm/mcp/error.rb +34 -0
- data/lib/ruby_llm/mcp/errors.rb +91 -0
- data/lib/ruby_llm/mcp/logging.rb +16 -0
- data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
- data/lib/ruby_llm/mcp/native/client.rb +387 -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 +42 -0
- data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
- data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
- data/lib/ruby_llm/mcp/native/messages.rb +36 -0
- data/lib/ruby_llm/mcp/native/notification.rb +16 -0
- data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
- data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
- data/lib/ruby_llm/mcp/native/transport.rb +88 -0
- data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
- data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
- data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
- data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/native/transports/support/rate_limit.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 +100 -0
- data/lib/ruby_llm/mcp/progress.rb +35 -0
- data/lib/ruby_llm/mcp/prompt.rb +132 -0
- data/lib/ruby_llm/mcp/railtie.rb +14 -0
- data/lib/ruby_llm/mcp/resource.rb +112 -0
- data/lib/ruby_llm/mcp/resource_template.rb +85 -0
- data/lib/ruby_llm/mcp/result.rb +108 -0
- data/lib/ruby_llm/mcp/roots.rb +45 -0
- data/lib/ruby_llm/mcp/sample.rb +152 -0
- data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
- data/lib/ruby_llm/mcp/tool.rb +228 -0
- data/lib/ruby_llm/mcp/version.rb +7 -0
- data/lib/ruby_llm/mcp.rb +125 -0
- data/lib/tasks/release.rake +23 -0
- metadata +184 -0
|
@@ -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
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
# Service for managing OAuth token operations
|
|
7
|
+
# Handles token exchange, refresh, and client credentials flows
|
|
8
|
+
class TokenManager
|
|
9
|
+
attr_reader :http_client, :logger
|
|
10
|
+
|
|
11
|
+
def initialize(http_client, logger)
|
|
12
|
+
@http_client = http_client
|
|
13
|
+
@logger = logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Exchange authorization code for access token
|
|
17
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
18
|
+
# @param client_info [ClientInfo] client info
|
|
19
|
+
# @param code [String] authorization code
|
|
20
|
+
# @param pkce [PKCE] PKCE parameters
|
|
21
|
+
# @param server_url [String] MCP server URL
|
|
22
|
+
# @return [Token] access token
|
|
23
|
+
def exchange_authorization_code(server_metadata, client_info, code, pkce, server_url)
|
|
24
|
+
logger.debug("Exchanging authorization code for access token")
|
|
25
|
+
|
|
26
|
+
registered_redirect_uri = client_info.metadata.redirect_uris.first
|
|
27
|
+
params = build_auth_code_params(client_info, code, pkce, registered_redirect_uri, server_url)
|
|
28
|
+
|
|
29
|
+
response = post_token_exchange(server_metadata, params)
|
|
30
|
+
response = retry_if_redirect_mismatch(response, server_metadata, params, registered_redirect_uri)
|
|
31
|
+
|
|
32
|
+
validate_token_response!(response, "Token exchange")
|
|
33
|
+
parse_token_response(response)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Exchange client credentials for access token
|
|
37
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
38
|
+
# @param client_info [ClientInfo] client info with secret
|
|
39
|
+
# @param scope [String, nil] requested scope
|
|
40
|
+
# @param server_url [String] MCP server URL
|
|
41
|
+
# @return [Token] access token
|
|
42
|
+
def exchange_client_credentials(server_metadata, client_info, scope, server_url)
|
|
43
|
+
logger.debug("Exchanging client credentials for access token")
|
|
44
|
+
|
|
45
|
+
params = {
|
|
46
|
+
grant_type: "client_credentials",
|
|
47
|
+
client_id: client_info.client_id,
|
|
48
|
+
client_secret: client_info.client_secret,
|
|
49
|
+
scope: scope,
|
|
50
|
+
resource: server_url
|
|
51
|
+
}.compact
|
|
52
|
+
|
|
53
|
+
response = post_token_exchange(server_metadata, params)
|
|
54
|
+
validate_token_response!(response, "Token exchange")
|
|
55
|
+
parse_token_response(response)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Refresh access token using refresh token
|
|
59
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
60
|
+
# @param client_info [ClientInfo] client info
|
|
61
|
+
# @param token [Token] current token with refresh_token
|
|
62
|
+
# @param server_url [String] MCP server URL
|
|
63
|
+
# @return [Token, nil] new token or nil if refresh failed
|
|
64
|
+
def refresh_token(server_metadata, client_info, token, server_url)
|
|
65
|
+
return nil unless token.refresh_token
|
|
66
|
+
|
|
67
|
+
logger.debug("Refreshing access token")
|
|
68
|
+
|
|
69
|
+
params = build_refresh_params(client_info, token, server_url)
|
|
70
|
+
response = post_token_refresh(server_metadata, params)
|
|
71
|
+
|
|
72
|
+
# Return nil on error responses
|
|
73
|
+
return nil if response.is_a?(HTTPX::ErrorResponse)
|
|
74
|
+
return nil unless response.status == 200
|
|
75
|
+
|
|
76
|
+
parse_refresh_response(response, token)
|
|
77
|
+
rescue JSON::ParserError => e
|
|
78
|
+
logger.warn("Invalid token refresh response: #{e.message}")
|
|
79
|
+
nil
|
|
80
|
+
rescue HTTPX::Error => e
|
|
81
|
+
logger.warn("Network error during token refresh: #{e.message}")
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Build parameters for authorization code exchange
|
|
88
|
+
# @param client_info [ClientInfo] client info
|
|
89
|
+
# @param code [String] authorization code
|
|
90
|
+
# @param pkce [PKCE] PKCE parameters
|
|
91
|
+
# @param redirect_uri [String] redirect URI
|
|
92
|
+
# @param server_url [String] MCP server URL
|
|
93
|
+
# @return [Hash] token exchange parameters
|
|
94
|
+
def build_auth_code_params(client_info, code, pkce, redirect_uri, server_url)
|
|
95
|
+
params = {
|
|
96
|
+
grant_type: "authorization_code",
|
|
97
|
+
code: code,
|
|
98
|
+
redirect_uri: redirect_uri,
|
|
99
|
+
client_id: client_info.client_id,
|
|
100
|
+
code_verifier: pkce.code_verifier,
|
|
101
|
+
resource: server_url
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
add_client_secret_if_needed(params, client_info)
|
|
105
|
+
params
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Build parameters for token refresh
|
|
109
|
+
# @param client_info [ClientInfo] client info
|
|
110
|
+
# @param token [Token] current token
|
|
111
|
+
# @param server_url [String] MCP server URL
|
|
112
|
+
# @return [Hash] refresh parameters
|
|
113
|
+
def build_refresh_params(client_info, token, server_url)
|
|
114
|
+
params = {
|
|
115
|
+
grant_type: "refresh_token",
|
|
116
|
+
refresh_token: token.refresh_token,
|
|
117
|
+
client_id: client_info.client_id,
|
|
118
|
+
resource: server_url
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
add_client_secret_if_needed(params, client_info)
|
|
122
|
+
params
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Add client secret to params if needed
|
|
126
|
+
# @param params [Hash] token request parameters
|
|
127
|
+
# @param client_info [ClientInfo] client info
|
|
128
|
+
def add_client_secret_if_needed(params, client_info)
|
|
129
|
+
return unless client_info.client_secret
|
|
130
|
+
return unless client_info.metadata.token_endpoint_auth_method == "client_secret_post"
|
|
131
|
+
|
|
132
|
+
params[:client_secret] = client_info.client_secret
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Post token exchange request
|
|
136
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
137
|
+
# @param params [Hash] form parameters
|
|
138
|
+
# @return [HTTPX::Response] HTTP response
|
|
139
|
+
def post_token_exchange(server_metadata, params)
|
|
140
|
+
http_client.post(
|
|
141
|
+
server_metadata.token_endpoint,
|
|
142
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
143
|
+
form: params
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Post token refresh request
|
|
148
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
149
|
+
# @param params [Hash] form parameters
|
|
150
|
+
# @return [HTTPX::Response] HTTP response
|
|
151
|
+
def post_token_refresh(server_metadata, params)
|
|
152
|
+
response = http_client.post(
|
|
153
|
+
server_metadata.token_endpoint,
|
|
154
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
155
|
+
form: params
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if response.is_a?(HTTPX::ErrorResponse)
|
|
159
|
+
logger.warn("Token refresh failed: #{response.error&.message || 'Request failed'}")
|
|
160
|
+
elsif response.status != 200
|
|
161
|
+
logger.warn("Token refresh failed: HTTP #{response.status}")
|
|
162
|
+
end
|
|
163
|
+
response
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Retry token exchange if redirect URI mismatch detected
|
|
167
|
+
# @param response [HTTPX::Response] initial response
|
|
168
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
169
|
+
# @param params [Hash] exchange parameters
|
|
170
|
+
# @param registered_redirect_uri [String] registered redirect URI
|
|
171
|
+
# @return [HTTPX::Response] response (possibly retried)
|
|
172
|
+
def retry_if_redirect_mismatch(response, server_metadata, params, registered_redirect_uri)
|
|
173
|
+
# Don't retry on error responses
|
|
174
|
+
return response if response.is_a?(HTTPX::ErrorResponse)
|
|
175
|
+
return response if response.status == 200
|
|
176
|
+
|
|
177
|
+
redirect_hint = HttpResponseHandler.extract_redirect_mismatch(response.body.to_s)
|
|
178
|
+
return response unless redirect_hint
|
|
179
|
+
return response if redirect_hint[:expected] == registered_redirect_uri
|
|
180
|
+
|
|
181
|
+
logger.warn("Redirect URI mismatch, retrying with: #{redirect_hint[:expected]}")
|
|
182
|
+
params[:redirect_uri] = redirect_hint[:expected]
|
|
183
|
+
post_token_exchange(server_metadata, params)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Validate token response
|
|
187
|
+
# @param response [HTTPX::Response, HTTPX::ErrorResponse] HTTP response
|
|
188
|
+
# @param context [String] context for error messages
|
|
189
|
+
# @raise [Errors::TransportError] if response is invalid
|
|
190
|
+
def validate_token_response!(response, context)
|
|
191
|
+
# Handle HTTPX ErrorResponse
|
|
192
|
+
if response.is_a?(HTTPX::ErrorResponse)
|
|
193
|
+
error_message = response.error&.message || "Request failed"
|
|
194
|
+
raise Errors::TransportError.new(message: "#{context} failed: #{error_message}")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
return if response.status == 200
|
|
198
|
+
|
|
199
|
+
raise Errors::TransportError.new(
|
|
200
|
+
message: "#{context} failed: HTTP #{response.status}",
|
|
201
|
+
code: response.status
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Parse token response
|
|
206
|
+
# @param response [HTTPX::Response] HTTP response
|
|
207
|
+
# @return [Token] parsed token
|
|
208
|
+
def parse_token_response(response)
|
|
209
|
+
data = JSON.parse(response.body.to_s)
|
|
210
|
+
Token.new(
|
|
211
|
+
access_token: data["access_token"],
|
|
212
|
+
token_type: data["token_type"] || "Bearer",
|
|
213
|
+
expires_in: data["expires_in"],
|
|
214
|
+
scope: data["scope"],
|
|
215
|
+
refresh_token: data["refresh_token"]
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Parse refresh response, preserving old refresh token if not provided
|
|
220
|
+
# @param response [HTTPX::Response] HTTP response
|
|
221
|
+
# @param old_token [Token] previous token
|
|
222
|
+
# @return [Token] new token
|
|
223
|
+
def parse_refresh_response(response, old_token)
|
|
224
|
+
data = JSON.parse(response.body.to_s)
|
|
225
|
+
Token.new(
|
|
226
|
+
access_token: data["access_token"],
|
|
227
|
+
token_type: data["token_type"] || "Bearer",
|
|
228
|
+
expires_in: data["expires_in"],
|
|
229
|
+
scope: data["scope"],
|
|
230
|
+
refresh_token: data["refresh_token"] || old_token.refresh_token
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
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
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
# Utility class for building OAuth URLs
|
|
7
|
+
# Handles discovery URLs, authorization URLs, and URL normalization
|
|
8
|
+
class UrlBuilder
|
|
9
|
+
# Build discovery URL for OAuth server metadata
|
|
10
|
+
# @param server_url [String] MCP server URL
|
|
11
|
+
# @param discovery_type [Symbol] :authorization_server or :protected_resource
|
|
12
|
+
# @return [String] discovery URL
|
|
13
|
+
def self.build_discovery_url(server_url, discovery_type = :authorization_server)
|
|
14
|
+
uri = URI.parse(server_url)
|
|
15
|
+
|
|
16
|
+
# Extract ONLY origin (scheme + host + port)
|
|
17
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
18
|
+
origin += ":#{uri.port}" if uri.port && !default_port?(uri)
|
|
19
|
+
|
|
20
|
+
# Two discovery endpoints supported
|
|
21
|
+
endpoint = if discovery_type == :authorization_server
|
|
22
|
+
"oauth-authorization-server"
|
|
23
|
+
else
|
|
24
|
+
"oauth-protected-resource"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
"#{origin}/.well-known/#{endpoint}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Build OAuth authorization URL
|
|
31
|
+
# @param authorization_endpoint [String] auth server endpoint
|
|
32
|
+
# @param client_id [String] client ID
|
|
33
|
+
# @param redirect_uri [String] redirect URI
|
|
34
|
+
# @param scope [String, nil] requested scope
|
|
35
|
+
# @param state [String] CSRF state
|
|
36
|
+
# @param pkce [PKCE] PKCE parameters
|
|
37
|
+
# @param resource [String] resource indicator (RFC 8707)
|
|
38
|
+
# @return [String] authorization URL
|
|
39
|
+
def self.build_authorization_url(authorization_endpoint, client_id, redirect_uri, scope, state, pkce, resource) # rubocop:disable Metrics/ParameterLists
|
|
40
|
+
params = {
|
|
41
|
+
response_type: "code",
|
|
42
|
+
client_id: client_id,
|
|
43
|
+
redirect_uri: redirect_uri,
|
|
44
|
+
scope: scope,
|
|
45
|
+
state: state, # CSRF protection
|
|
46
|
+
code_challenge: pkce.code_challenge,
|
|
47
|
+
code_challenge_method: pkce.code_challenge_method, # S256
|
|
48
|
+
resource: resource # RFC 8707 - Resource Indicators
|
|
49
|
+
}.compact
|
|
50
|
+
|
|
51
|
+
uri = URI.parse(authorization_endpoint)
|
|
52
|
+
uri.query = URI.encode_www_form(params)
|
|
53
|
+
uri.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get authorization base URL from server URL
|
|
57
|
+
# @param server_url [String] MCP server URL
|
|
58
|
+
# @return [String] authorization base URL (scheme + host + port)
|
|
59
|
+
def self.get_authorization_base_url(server_url)
|
|
60
|
+
uri = URI.parse(server_url)
|
|
61
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
62
|
+
origin += ":#{uri.port}" if uri.port && !default_port?(uri)
|
|
63
|
+
origin
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if port is default for scheme
|
|
67
|
+
# @param uri [URI] parsed URI
|
|
68
|
+
# @return [Boolean] true if default port
|
|
69
|
+
def self.default_port?(uri)
|
|
70
|
+
(uri.scheme == "http" && uri.port == 80) ||
|
|
71
|
+
(uri.scheme == "https" && uri.port == 443)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|