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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +277 -0
  4. data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
  5. data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
  6. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  16. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  17. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  18. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  19. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  20. data/lib/ruby_llm/chat.rb +34 -0
  21. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
  22. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
  23. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
  24. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
  25. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
  26. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
  27. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
  28. data/lib/ruby_llm/mcp/attachment.rb +18 -0
  29. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  30. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  31. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
  32. data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
  33. data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
  34. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
  35. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  36. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  37. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  38. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  39. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  40. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  41. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  42. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
  43. data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
  44. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
  45. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  46. data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
  47. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  48. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  49. data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
  50. data/lib/ruby_llm/mcp/auth.rb +359 -0
  51. data/lib/ruby_llm/mcp/client.rb +401 -0
  52. data/lib/ruby_llm/mcp/completion.rb +16 -0
  53. data/lib/ruby_llm/mcp/configuration.rb +310 -0
  54. data/lib/ruby_llm/mcp/content.rb +28 -0
  55. data/lib/ruby_llm/mcp/elicitation.rb +48 -0
  56. data/lib/ruby_llm/mcp/error.rb +34 -0
  57. data/lib/ruby_llm/mcp/errors.rb +91 -0
  58. data/lib/ruby_llm/mcp/logging.rb +16 -0
  59. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
  60. data/lib/ruby_llm/mcp/native/client.rb +387 -0
  61. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  62. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  63. data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
  64. data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
  65. data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
  66. data/lib/ruby_llm/mcp/native/messages.rb +36 -0
  67. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  68. data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
  69. data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
  70. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  71. data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
  72. data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
  73. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
  74. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  75. data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
  76. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  77. data/lib/ruby_llm/mcp/native.rb +12 -0
  78. data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
  79. data/lib/ruby_llm/mcp/progress.rb +35 -0
  80. data/lib/ruby_llm/mcp/prompt.rb +132 -0
  81. data/lib/ruby_llm/mcp/railtie.rb +14 -0
  82. data/lib/ruby_llm/mcp/resource.rb +112 -0
  83. data/lib/ruby_llm/mcp/resource_template.rb +85 -0
  84. data/lib/ruby_llm/mcp/result.rb +108 -0
  85. data/lib/ruby_llm/mcp/roots.rb +45 -0
  86. data/lib/ruby_llm/mcp/sample.rb +152 -0
  87. data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
  88. data/lib/ruby_llm/mcp/tool.rb +228 -0
  89. data/lib/ruby_llm/mcp/version.rb +7 -0
  90. data/lib/ruby_llm/mcp.rb +125 -0
  91. data/lib/tasks/release.rake +23 -0
  92. 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