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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -162
  3. data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +21 -4
  4. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +20 -0
  5. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +215 -0
  6. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +413 -0
  7. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +41 -0
  8. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +56 -0
  9. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +56 -0
  10. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +90 -0
  11. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +216 -0
  12. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +7 -1
  13. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +0 -3
  14. data/lib/ruby_llm/mcp/auth/browser/opener.rb +0 -2
  15. data/lib/ruby_llm/mcp/auth/browser/pages.rb +100 -32
  16. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +230 -57
  17. data/lib/ruby_llm/mcp/auth/discoverer.rb +157 -26
  18. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +19 -2
  19. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +3 -2
  20. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +0 -2
  21. data/lib/ruby_llm/mcp/auth/memory_storage.rb +31 -12
  22. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +124 -9
  23. data/lib/ruby_llm/mcp/auth/session_manager.rb +0 -2
  24. data/lib/ruby_llm/mcp/auth/token_manager.rb +74 -3
  25. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  26. data/lib/ruby_llm/mcp/auth/url_builder.rb +72 -15
  27. data/lib/ruby_llm/mcp/auth.rb +19 -7
  28. data/lib/ruby_llm/mcp/client.rb +267 -39
  29. data/lib/ruby_llm/mcp/configuration.rb +161 -12
  30. data/lib/ruby_llm/mcp/elicitation.rb +261 -14
  31. data/lib/ruby_llm/mcp/errors.rb +18 -0
  32. data/lib/ruby_llm/mcp/extensions/apps/constants.rb +28 -0
  33. data/lib/ruby_llm/mcp/extensions/apps/resource_metadata.rb +24 -0
  34. data/lib/ruby_llm/mcp/extensions/apps/tool_metadata.rb +45 -0
  35. data/lib/ruby_llm/mcp/extensions/configuration.rb +72 -0
  36. data/lib/ruby_llm/mcp/extensions/constants.rb +16 -0
  37. data/lib/ruby_llm/mcp/extensions/registry.rb +85 -0
  38. data/lib/ruby_llm/mcp/handlers/approval_decision.rb +90 -0
  39. data/lib/ruby_llm/mcp/handlers/async_response.rb +181 -0
  40. data/lib/ruby_llm/mcp/handlers/concerns/approval_actions.rb +42 -0
  41. data/lib/ruby_llm/mcp/handlers/concerns/async_execution.rb +80 -0
  42. data/lib/ruby_llm/mcp/handlers/concerns/elicitation_actions.rb +42 -0
  43. data/lib/ruby_llm/mcp/handlers/concerns/error_handling.rb +29 -0
  44. data/lib/ruby_llm/mcp/handlers/concerns/guard_checks.rb +72 -0
  45. data/lib/ruby_llm/mcp/handlers/concerns/lifecycle.rb +84 -0
  46. data/lib/ruby_llm/mcp/handlers/concerns/logging.rb +19 -0
  47. data/lib/ruby_llm/mcp/handlers/concerns/model_filtering.rb +36 -0
  48. data/lib/ruby_llm/mcp/handlers/concerns/options.rb +83 -0
  49. data/lib/ruby_llm/mcp/handlers/concerns/registry_integration.rb +54 -0
  50. data/lib/ruby_llm/mcp/handlers/concerns/sampling_actions.rb +84 -0
  51. data/lib/ruby_llm/mcp/handlers/concerns/timeouts.rb +52 -0
  52. data/lib/ruby_llm/mcp/handlers/concerns/tool_filtering.rb +50 -0
  53. data/lib/ruby_llm/mcp/handlers/elicitation_handler.rb +58 -0
  54. data/lib/ruby_llm/mcp/handlers/elicitation_registry.rb +203 -0
  55. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_handler.rb +93 -0
  56. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_registry.rb +271 -0
  57. data/lib/ruby_llm/mcp/handlers/promise.rb +192 -0
  58. data/lib/ruby_llm/mcp/handlers/sampling_handler.rb +64 -0
  59. data/lib/ruby_llm/mcp/handlers.rb +14 -0
  60. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +94 -0
  61. data/lib/ruby_llm/mcp/native/client.rb +551 -0
  62. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  63. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  64. data/lib/ruby_llm/mcp/native/messages/notifications.rb +60 -0
  65. data/lib/ruby_llm/mcp/native/messages/requests.rb +267 -0
  66. data/lib/ruby_llm/mcp/native/messages/responses.rb +114 -0
  67. data/lib/ruby_llm/mcp/native/messages.rb +43 -0
  68. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  69. data/lib/ruby_llm/mcp/native/protocol.rb +79 -0
  70. data/lib/ruby_llm/mcp/native/response_handler.rb +220 -0
  71. data/lib/ruby_llm/mcp/native/task_registry.rb +62 -0
  72. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  73. data/lib/ruby_llm/mcp/native/transports/sse.rb +655 -0
  74. data/lib/ruby_llm/mcp/native/transports/stdio.rb +367 -0
  75. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +1024 -0
  76. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  77. data/lib/ruby_llm/mcp/native/transports/support/rate_limiter.rb +49 -0
  78. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  79. data/lib/ruby_llm/mcp/native.rb +12 -0
  80. data/lib/ruby_llm/mcp/notification_handler.rb +43 -5
  81. data/lib/ruby_llm/mcp/prompt.rb +7 -7
  82. data/lib/ruby_llm/mcp/railtie.rb +8 -6
  83. data/lib/ruby_llm/mcp/resource.rb +17 -8
  84. data/lib/ruby_llm/mcp/resource_template.rb +8 -7
  85. data/lib/ruby_llm/mcp/result.rb +8 -4
  86. data/lib/ruby_llm/mcp/roots.rb +4 -4
  87. data/lib/ruby_llm/mcp/sample.rb +83 -13
  88. data/lib/ruby_llm/mcp/schema_validator.rb +33 -0
  89. data/lib/ruby_llm/mcp/server_capabilities.rb +41 -0
  90. data/lib/ruby_llm/mcp/task.rb +65 -0
  91. data/lib/ruby_llm/mcp/tool.rb +33 -27
  92. data/lib/ruby_llm/mcp/version.rb +1 -1
  93. data/lib/ruby_llm/mcp.rb +31 -7
  94. data/lib/tasks/smoke.rake +66 -0
  95. metadata +77 -36
  96. data/lib/ruby_llm/mcp/coordinator.rb +0 -304
  97. data/lib/ruby_llm/mcp/notifications/cancelled.rb +0 -32
  98. data/lib/ruby_llm/mcp/notifications/initialize.rb +0 -24
  99. data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +0 -26
  100. data/lib/ruby_llm/mcp/protocol.rb +0 -34
  101. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +0 -50
  102. data/lib/ruby_llm/mcp/requests/completion_resource.rb +0 -50
  103. data/lib/ruby_llm/mcp/requests/initialization.rb +0 -34
  104. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +0 -28
  105. data/lib/ruby_llm/mcp/requests/ping.rb +0 -24
  106. data/lib/ruby_llm/mcp/requests/prompt_call.rb +0 -32
  107. data/lib/ruby_llm/mcp/requests/prompt_list.rb +0 -31
  108. data/lib/ruby_llm/mcp/requests/resource_list.rb +0 -31
  109. data/lib/ruby_llm/mcp/requests/resource_read.rb +0 -30
  110. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +0 -31
  111. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +0 -30
  112. data/lib/ruby_llm/mcp/requests/shared/meta.rb +0 -32
  113. data/lib/ruby_llm/mcp/requests/shared/pagination.rb +0 -17
  114. data/lib/ruby_llm/mcp/requests/tool_call.rb +0 -35
  115. data/lib/ruby_llm/mcp/requests/tool_list.rb +0 -31
  116. data/lib/ruby_llm/mcp/response_handler.rb +0 -67
  117. data/lib/ruby_llm/mcp/responses/elicitation.rb +0 -33
  118. data/lib/ruby_llm/mcp/responses/error.rb +0 -33
  119. data/lib/ruby_llm/mcp/responses/ping.rb +0 -28
  120. data/lib/ruby_llm/mcp/responses/roots_list.rb +0 -31
  121. data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +0 -50
  122. data/lib/ruby_llm/mcp/transport.rb +0 -151
  123. data/lib/ruby_llm/mcp/transports/sse.rb +0 -435
  124. data/lib/ruby_llm/mcp/transports/stdio.rb +0 -231
  125. data/lib/ruby_llm/mcp/transports/streamable_http.rb +0 -725
  126. data/lib/ruby_llm/mcp/transports/support/http_client.rb +0 -28
  127. data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +0 -47
  128. 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
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module RubyLLM
6
4
  module MCP
7
5
  module Auth
@@ -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
- @client_creds_flow.execute(server_url, redirect_uri, scope || self.scope)
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: { total: DEFAULT_OAUTH_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
- server_metadata = @discoverer.discover(server_url)
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
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
-
5
3
  module RubyLLM
6
4
  module MCP
7
5
  module Auth
@@ -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
- return nil unless response.status == 200
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: data["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: data["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