actionmcp 0.71.1 → 0.72.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -15
  3. data/app/controllers/action_mcp/application_controller.rb +45 -38
  4. data/app/controllers/action_mcp/oauth/endpoints_controller.rb +11 -10
  5. data/app/controllers/action_mcp/oauth/metadata_controller.rb +6 -10
  6. data/app/controllers/action_mcp/oauth/registration_controller.rb +15 -20
  7. data/app/models/action_mcp/oauth_client.rb +7 -5
  8. data/app/models/action_mcp/oauth_token.rb +2 -1
  9. data/app/models/action_mcp/session.rb +40 -5
  10. data/config/routes.rb +4 -2
  11. data/db/migrate/20250512154359_consolidated_migration.rb +3 -3
  12. data/db/migrate/20250608112101_add_oauth_to_sessions.rb +17 -8
  13. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +7 -5
  14. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +3 -1
  15. data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +7 -0
  16. data/lib/action_mcp/base_response.rb +1 -1
  17. data/lib/action_mcp/client/base.rb +9 -11
  18. data/lib/action_mcp/client/elicitation.rb +4 -4
  19. data/lib/action_mcp/client/json_rpc_handler.rb +11 -13
  20. data/lib/action_mcp/client/jwt_client_provider.rb +6 -5
  21. data/lib/action_mcp/client/oauth_client_provider.rb +8 -8
  22. data/lib/action_mcp/client/streamable_http_transport.rb +29 -39
  23. data/lib/action_mcp/client.rb +6 -3
  24. data/lib/action_mcp/configuration.rb +28 -53
  25. data/lib/action_mcp/engine.rb +1 -3
  26. data/lib/action_mcp/filtered_logger.rb +1 -1
  27. data/lib/action_mcp/gateway.rb +7 -11
  28. data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
  29. data/lib/action_mcp/jwt_decoder.rb +4 -2
  30. data/lib/action_mcp/oauth/active_record_storage.rb +1 -1
  31. data/lib/action_mcp/oauth/memory_storage.rb +1 -3
  32. data/lib/action_mcp/oauth/middleware.rb +13 -18
  33. data/lib/action_mcp/oauth/provider.rb +45 -65
  34. data/lib/action_mcp/omniauth/mcp_strategy.rb +23 -37
  35. data/lib/action_mcp/prompt.rb +2 -0
  36. data/lib/action_mcp/renderable.rb +1 -1
  37. data/lib/action_mcp/resource_template.rb +6 -2
  38. data/lib/action_mcp/server/{memory_session.rb → base_session.rb} +39 -26
  39. data/lib/action_mcp/server/base_session_store.rb +86 -0
  40. data/lib/action_mcp/server/capabilities.rb +2 -1
  41. data/lib/action_mcp/server/elicitation.rb +3 -9
  42. data/lib/action_mcp/server/error_handling.rb +14 -1
  43. data/lib/action_mcp/server/handlers/router.rb +31 -0
  44. data/lib/action_mcp/server/json_rpc_handler.rb +2 -5
  45. data/lib/action_mcp/server/{messaging.rb → messaging_service.rb} +38 -14
  46. data/lib/action_mcp/server/prompts.rb +4 -4
  47. data/lib/action_mcp/server/resources.rb +23 -4
  48. data/lib/action_mcp/server/session_store_factory.rb +1 -1
  49. data/lib/action_mcp/server/solid_mcp_adapter.rb +9 -10
  50. data/lib/action_mcp/server/tools.rb +62 -43
  51. data/lib/action_mcp/server/transport_handler.rb +2 -4
  52. data/lib/action_mcp/server/volatile_session_store.rb +1 -93
  53. data/lib/action_mcp/tagged_stream_logging.rb +2 -2
  54. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +4 -4
  55. data/lib/action_mcp/test_helper/session_store_assertions.rb +5 -1
  56. data/lib/action_mcp/tool.rb +48 -37
  57. data/lib/action_mcp/types/float_array_type.rb +5 -3
  58. data/lib/action_mcp/version.rb +1 -1
  59. data/lib/action_mcp.rb +1 -1
  60. data/lib/generators/action_mcp/install/templates/application_gateway.rb +1 -0
  61. data/lib/tasks/action_mcp_tasks.rake +7 -5
  62. metadata +20 -18
  63. data/lib/action_mcp/server/notifications.rb +0 -58
@@ -53,7 +53,7 @@ module ActionMCP
53
53
 
54
54
  def initialize
55
55
  @logging_enabled = true
56
- @list_changed = false
56
+ @list_changed = true
57
57
  @logging_level = :info
58
58
  @resources_subscribe = false
59
59
  @elicitation_enabled = false
@@ -67,7 +67,7 @@ module ActionMCP
67
67
 
68
68
  @sse_heartbeat_interval = 30
69
69
  @post_response_preference = :json
70
- @protocol_version = "2025-03-26" # Default to legacy for backwards compatibility
70
+ @protocol_version = "2025-03-26" # Default to legacy for backwards compatibility
71
71
 
72
72
  # Resumability defaults
73
73
  @sse_event_retention_period = 15.minutes
@@ -93,8 +93,8 @@ module ActionMCP
93
93
 
94
94
  def gateway_class
95
95
  if @gateway_class_name
96
- klass = @gateway_class_name.constantize
97
- klass
96
+ @gateway_class_name.constantize
97
+
98
98
  else
99
99
  @gateway_class
100
100
  end
@@ -117,22 +117,16 @@ module ActionMCP
117
117
  raise "Invalid MCP config file" unless app_config.is_a?(Hash)
118
118
 
119
119
  # Extract authentication configuration if present
120
- if app_config["authentication"]
121
- @authentication_methods = Array(app_config["authentication"])
122
- end
120
+ @authentication_methods = Array(app_config["authentication"]) if app_config["authentication"]
123
121
 
124
122
  # Extract OAuth configuration if present
125
- if app_config["oauth"]
126
- @oauth_config = HashWithIndifferentAccess.new(app_config["oauth"])
127
- end
123
+ @oauth_config = HashWithIndifferentAccess.new(app_config["oauth"]) if app_config["oauth"]
128
124
 
129
125
  # Extract other top-level configuration settings
130
126
  extract_top_level_settings(app_config)
131
127
 
132
128
  # Extract profiles configuration
133
- if app_config["profiles"]
134
- @profiles = app_config["profiles"]
135
- end
129
+ @profiles = app_config["profiles"] if app_config["profiles"]
136
130
  rescue StandardError => e
137
131
  # If the config file doesn't exist in the Rails app, just use the defaults
138
132
  Rails.logger.warn "[Configuration] Failed to load MCP config: #{e.class} - #{e.message}"
@@ -192,20 +186,16 @@ module ActionMCP
192
186
 
193
187
  # Check profile configuration instead of registry contents
194
188
  # If profile includes tools (either "all" or specific tools), advertise tools capability
195
- if profile && profile[:tools] && profile[:tools].any?
196
- capabilities[:tools] = { listChanged: @list_changed }
197
- end
189
+ capabilities[:tools] = { listChanged: @list_changed } if profile && profile[:tools]&.any?
198
190
 
199
191
  # If profile includes prompts, advertise prompts capability
200
- if profile && profile[:prompts] && profile[:prompts].any?
201
- capabilities[:prompts] = { listChanged: @list_changed }
202
- end
192
+ capabilities[:prompts] = { listChanged: @list_changed } if profile && profile[:prompts]&.any?
203
193
 
204
194
  capabilities[:logging] = {} if @logging_enabled
205
195
 
206
196
  # If profile includes resources, advertise resources capability
207
- if profile && profile[:resources] && profile[:resources].any?
208
- capabilities[:resources] = { subscribe: @resources_subscribe }
197
+ if profile && profile[:resources]&.any?
198
+ capabilities[:resources] = { subscribe: @resources_subscribe, listChanged: @list_changed }
209
199
  end
210
200
 
211
201
  capabilities[:elicitation] = {} if @elicitation_enabled
@@ -240,12 +230,12 @@ module ActionMCP
240
230
 
241
231
  # Check if any component type includes "all"
242
232
  needs_eager_load = profile[:tools]&.include?("all") ||
243
- profile[:prompts]&.include?("all") ||
244
- profile[:resources]&.include?("all")
233
+ profile[:prompts]&.include?("all") ||
234
+ profile[:resources]&.include?("all")
245
235
 
246
- if needs_eager_load
247
- ensure_mcp_components_loaded
248
- end
236
+ return unless needs_eager_load
237
+
238
+ ensure_mcp_components_loaded
249
239
  end
250
240
 
251
241
  private
@@ -285,51 +275,35 @@ module ActionMCP
285
275
  end
286
276
 
287
277
  # Extract thread pool settings
288
- if app_config["min_threads"]
289
- @min_threads = app_config["min_threads"]
290
- end
278
+ @min_threads = app_config["min_threads"] if app_config["min_threads"]
291
279
 
292
- if app_config["max_threads"]
293
- @max_threads = app_config["max_threads"]
294
- end
280
+ @max_threads = app_config["max_threads"] if app_config["max_threads"]
295
281
 
296
- if app_config["max_queue"]
297
- @max_queue = app_config["max_queue"]
298
- end
282
+ @max_queue = app_config["max_queue"] if app_config["max_queue"]
299
283
 
300
284
  # Extract polling interval for solid_cable
301
- if app_config["polling_interval"]
302
- @polling_interval = app_config["polling_interval"]
303
- end
285
+ @polling_interval = app_config["polling_interval"] if app_config["polling_interval"]
304
286
 
305
287
  # Extract connects_to setting
306
- if app_config["connects_to"]
307
- @connects_to = app_config["connects_to"]
308
- end
288
+ @connects_to = app_config["connects_to"] if app_config["connects_to"]
309
289
 
310
290
  # Extract verbose logging setting
311
- if app_config.key?("verbose_logging")
312
- @verbose_logging = app_config["verbose_logging"]
313
- end
291
+ @verbose_logging = app_config["verbose_logging"] if app_config.key?("verbose_logging")
314
292
 
315
293
  # Extract gateway class configuration
316
- if app_config["gateway_class"]
317
- @gateway_class_name = app_config["gateway_class"]
318
- end
294
+ @gateway_class_name = app_config["gateway_class"] if app_config["gateway_class"]
319
295
 
320
296
  # Extract session store configuration
321
- if app_config["session_store_type"]
322
- @session_store_type = app_config["session_store_type"].to_sym
323
- end
297
+ @session_store_type = app_config["session_store_type"].to_sym if app_config["session_store_type"]
324
298
 
325
299
  # Extract client and server session store types
326
300
  if app_config["client_session_store_type"]
327
301
  @client_session_store_type = app_config["client_session_store_type"].to_sym
328
302
  end
329
303
 
330
- if app_config["server_session_store_type"]
331
- @server_session_store_type = app_config["server_session_store_type"].to_sym
332
- end
304
+ return unless app_config["server_session_store_type"]
305
+
306
+ @server_session_store_type = app_config["server_session_store_type"].to_sym
333
307
  end
334
308
 
335
309
  def should_include_all?(type)
@@ -377,6 +351,7 @@ module ActionMCP
377
351
  Dir.glob(mcp_path.join("**/*.rb")).sort.each do |file|
378
352
  # Skip base classes we already loaded
379
353
  next if base_files.any? { |base| file == base.to_s }
354
+
380
355
  require_dependency file
381
356
  end
382
357
  end
@@ -77,9 +77,7 @@ module ActionMCP
77
77
  end
78
78
 
79
79
  # Add identifiers directory for gateway identifiers
80
- if identifiers_path.exist?
81
- app.autoloaders.main.push_dir(identifiers_path, namespace: Object)
82
- end
80
+ app.autoloaders.main.push_dir(identifiers_path, namespace: Object) if identifiers_path.exist?
83
81
  end
84
82
 
85
83
  # Initialize the ActionMCP logger.
@@ -16,7 +16,7 @@ module ActionMCP
16
16
 
17
17
  def add(severity, message = nil, progname = nil, &block)
18
18
  # Filter out repetitive OAuth metadata requests
19
- if message && message.is_a?(String)
19
+ if message.is_a?(String)
20
20
  return if FILTERED_PATHS.any? { |path| message.include?(path) && message.include?("200 OK") }
21
21
 
22
22
  # Filter out repetitive MCP notifications
@@ -31,21 +31,17 @@ module ActionMCP
31
31
  def authenticate!
32
32
  active_identifiers = filter_active_identifiers
33
33
 
34
- if active_identifiers.empty?
35
- raise ActionMCP::UnauthorizedError, "No authentication methods available"
36
- end
34
+ raise ActionMCP::UnauthorizedError, "No authentication methods available" if active_identifiers.empty?
37
35
 
38
36
  # Try identifiers in order, use the first one that succeeds
39
37
  last_error = nil
40
38
  active_identifiers.each do |klass|
41
- begin
42
- result = klass.new(@request).resolve
43
- return { klass.identifier_name => result }
44
- rescue ActionMCP::GatewayIdentifier::Unauthorized => e
45
- last_error = e
46
- # Try next identifier
47
- next
48
- end
39
+ result = klass.new(@request).resolve
40
+ return { klass.identifier_name => result }
41
+ rescue ActionMCP::GatewayIdentifier::Unauthorized => e
42
+ last_error = e
43
+ # Try next identifier
44
+ next
49
45
  end
50
46
 
51
47
  # If we get here, all identifiers failed
@@ -64,8 +64,6 @@ module ActionMCP
64
64
  when %r{^notifications/}
65
65
  process_notifications(rpc_method, params)
66
66
  true
67
- else
68
- nil
69
67
  end
70
68
  end
71
69
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "jwt"
2
4
 
3
5
  module ActionMCP
@@ -13,14 +15,14 @@ module ActionMCP
13
15
  payload
14
16
  rescue JWT::ExpiredSignature
15
17
  raise DecodeError, "Token has expired"
16
- rescue JWT::DecodeError => e
18
+ rescue JWT::DecodeError
17
19
  # Simplify the error message for invalid tokens
18
20
  raise DecodeError, "Invalid token"
19
21
  end
20
22
  end
21
23
 
22
24
  # Defaults (can be overridden in an initializer)
23
- self.secret = ENV.fetch("ACTION_MCP_JWT_SECRET") { "change-me" }
25
+ self.secret = ENV.fetch("ACTION_MCP_JWT_SECRET", "change-me")
24
26
  self.algorithm = "HS256"
25
27
  end
26
28
  end
@@ -18,7 +18,7 @@ module ActionMCP
18
18
  code_challenge_method: data[:code_challenge_method],
19
19
  expires_at: data[:expires_at],
20
20
  metadata: data.except(:client_id, :user_id, :redirect_uri, :scope,
21
- :code_challenge, :code_challenge_method, :expires_at)
21
+ :code_challenge, :code_challenge_method, :expires_at)
22
22
  )
23
23
  end
24
24
 
@@ -66,9 +66,7 @@ module ActionMCP
66
66
 
67
67
  def update_refresh_token(token, new_access_token)
68
68
  @mutex.synchronize do
69
- if @refresh_tokens[token]
70
- @refresh_tokens[token][:access_token] = new_access_token
71
- end
69
+ @refresh_tokens[token][:access_token] = new_access_token if @refresh_tokens[token]
72
70
  end
73
71
  end
74
72
 
@@ -18,17 +18,13 @@ module ActionMCP
18
18
  return @app.call(env) unless should_process_oauth?(request)
19
19
 
20
20
  # Skip OAuth processing for metadata endpoints
21
- if request.path.start_with?("/.well-known/") || request.path.start_with?("/oauth/")
22
- return @app.call(env)
23
- end
21
+ return @app.call(env) if request.path.start_with?("/.well-known/") || request.path.start_with?("/oauth/")
24
22
 
25
23
  # Skip OAuth processing for initialization-related requests
26
- if initialization_related_request?(request)
27
- return @app.call(env)
28
- end
24
+ return @app.call(env) if initialization_related_request?(request)
29
25
 
30
26
  # Validate Bearer token for API requests
31
- if bearer_token = extract_bearer_token(request)
27
+ if (bearer_token = extract_bearer_token(request))
32
28
  validate_oauth_token(request, bearer_token)
33
29
  end
34
30
 
@@ -39,7 +35,7 @@ module ActionMCP
39
35
 
40
36
  private
41
37
 
42
- def should_process_oauth?(request)
38
+ def should_process_oauth?(_request)
43
39
  # Check if OAuth is enabled in configuration
44
40
  auth_methods = ActionMCP.configuration.authentication_methods
45
41
  return false unless auth_methods&.include?("oauth")
@@ -55,7 +51,7 @@ module ActionMCP
55
51
 
56
52
  # Check if this is an MCP endpoint (ends with / or is the root)
57
53
  path = request.path
58
- return false unless path == "/" || path.match?(/\/action_mcp\/?$/)
54
+ return false unless path == "/" || path.match?(%r{/action_mcp/?$})
59
55
 
60
56
  # Read and parse the request body
61
57
  body = request.body.read
@@ -70,7 +66,6 @@ module ActionMCP
70
66
  false
71
67
  end
72
68
 
73
-
74
69
  def extract_bearer_token(request)
75
70
  auth_header = request.headers["Authorization"] || request.headers["authorization"]
76
71
  return nil unless auth_header&.start_with?("Bearer ")
@@ -82,23 +77,23 @@ module ActionMCP
82
77
  # Use the OAuth provider for token introspection
83
78
  token_info = ActionMCP::OAuth::Provider.introspect_token(token)
84
79
 
85
- if token_info && token_info[:active]
86
- # Store OAuth token info in request environment for Gateway
87
- request.env["action_mcp.oauth_token_info"] = token_info
88
- request.env["action_mcp.oauth_token"] = token
89
- else
80
+ unless token_info && token_info[:active]
90
81
  raise ActionMCP::OAuth::InvalidTokenError, "Invalid or expired OAuth token"
91
82
  end
83
+
84
+ # Store OAuth token info in request environment for Gateway
85
+ request.env["action_mcp.oauth_token_info"] = token_info
86
+ request.env["action_mcp.oauth_token"] = token
92
87
  end
93
88
 
94
89
  def oauth_error_response(error)
95
90
  status = case error
96
91
  when ActionMCP::OAuth::InvalidTokenError
97
- 401
92
+ 401
98
93
  when ActionMCP::OAuth::InsufficientScopeError
99
- 403
94
+ 403
100
95
  else
101
- 400
96
+ 400
102
97
  end
103
98
 
104
99
  headers = {
@@ -18,7 +18,8 @@ module ActionMCP
18
18
  # @param code_challenge_method [String] PKCE challenge method (S256, plain)
19
19
  # @param user_id [String] User identifier
20
20
  # @return [String] Authorization code
21
- def generate_authorization_code(client_id:, redirect_uri:, scope:, code_challenge: nil, code_challenge_method: nil, user_id:)
21
+ def generate_authorization_code(client_id:, redirect_uri:, scope:, user_id:, code_challenge: nil,
22
+ code_challenge_method: nil)
22
23
  # Validate scope
23
24
  validate_scope(scope) if scope
24
25
 
@@ -26,15 +27,15 @@ module ActionMCP
26
27
 
27
28
  # Store authorization code with metadata
28
29
  store_authorization_code(code, {
29
- client_id: client_id,
30
- redirect_uri: redirect_uri,
31
- scope: scope,
32
- code_challenge: code_challenge,
33
- code_challenge_method: code_challenge_method,
34
- user_id: user_id,
35
- created_at: Time.current,
36
- expires_at: 10.minutes.from_now
37
- })
30
+ client_id: client_id,
31
+ redirect_uri: redirect_uri,
32
+ scope: scope,
33
+ code_challenge: code_challenge,
34
+ code_challenge_method: code_challenge_method,
35
+ user_id: user_id,
36
+ created_at: Time.current,
37
+ expires_at: 10.minutes.from_now
38
+ })
38
39
 
39
40
  code
40
41
  end
@@ -46,7 +47,7 @@ module ActionMCP
46
47
  # @param redirect_uri [String] Client redirect URI
47
48
  # @param code_verifier [String] PKCE code verifier
48
49
  # @return [Hash] Token response with access_token, token_type, expires_in, scope
49
- def exchange_code_for_token(code:, client_id:, client_secret: nil, redirect_uri:, code_verifier: nil)
50
+ def exchange_code_for_token(code:, client_id:, redirect_uri:, client_secret: nil, code_verifier: nil)
50
51
  # Retrieve and validate authorization code
51
52
  code_data = retrieve_authorization_code(code)
52
53
  raise InvalidGrantError, "Invalid authorization code" unless code_data
@@ -56,14 +57,10 @@ module ActionMCP
56
57
  validate_client(client_id, client_secret)
57
58
 
58
59
  # Validate redirect URI matches
59
- unless code_data[:redirect_uri] == redirect_uri
60
- raise InvalidGrantError, "Redirect URI mismatch"
61
- end
60
+ raise InvalidGrantError, "Redirect URI mismatch" unless code_data[:redirect_uri] == redirect_uri
62
61
 
63
62
  # Validate client ID matches
64
- unless code_data[:client_id] == client_id
65
- raise InvalidGrantError, "Client ID mismatch"
66
- end
63
+ raise InvalidGrantError, "Client ID mismatch" unless code_data[:client_id] == client_id
67
64
 
68
65
  # Validate PKCE if challenge was provided during authorization
69
66
  if code_data[:code_challenge]
@@ -118,9 +115,7 @@ module ActionMCP
118
115
  validate_client(client_id, client_secret)
119
116
 
120
117
  # Validate client ID matches
121
- unless token_data[:client_id] == client_id
122
- raise InvalidGrantError, "Client ID mismatch"
123
- end
118
+ raise InvalidGrantError, "Client ID mismatch" unless token_data[:client_id] == client_id
124
119
 
125
120
  # Validate scope if provided
126
121
  if scope
@@ -160,9 +155,7 @@ module ActionMCP
160
155
  def introspect_token(access_token)
161
156
  token_data = retrieve_access_token(access_token)
162
157
 
163
- unless token_data
164
- return { active: false }
165
- end
158
+ return { active: false } unless token_data
166
159
 
167
160
  if token_data[:expires_at] < Time.current
168
161
  remove_access_token(access_token)
@@ -188,19 +181,15 @@ module ActionMCP
188
181
  revoked = false
189
182
 
190
183
  # Try access token first if hint suggests it or no hint provided
191
- if token_type_hint == "access_token" || token_type_hint.nil?
192
- if retrieve_access_token(token)
193
- revoke_access_token(token)
194
- revoked = true
195
- end
184
+ if (token_type_hint == "access_token" || token_type_hint.nil?) && retrieve_access_token(token)
185
+ revoke_access_token(token)
186
+ revoked = true
196
187
  end
197
188
 
198
189
  # Try refresh token if not revoked yet
199
- if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?)
200
- if retrieve_refresh_token(token)
201
- revoke_refresh_token(token)
202
- revoked = true
203
- end
190
+ if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?) && retrieve_refresh_token(token)
191
+ revoke_refresh_token(token)
192
+ revoked = true
204
193
  end
205
194
 
206
195
  revoked
@@ -269,9 +258,7 @@ module ActionMCP
269
258
  if client_info
270
259
  # Validate client secret for confidential clients
271
260
  if client_info[:client_secret]
272
- unless client_secret == client_info[:client_secret]
273
- raise InvalidClientError, "Invalid client credentials"
274
- end
261
+ raise InvalidClientError, "Invalid client credentials" unless client_secret == client_info[:client_secret]
275
262
  elsif require_secret
276
263
  raise InvalidClientError, "Client authentication required"
277
264
  end
@@ -280,15 +267,14 @@ module ActionMCP
280
267
 
281
268
  # Fall back to custom provider validation
282
269
  provider_class = oauth_config[:provider]
283
- if provider_class && provider_class.respond_to?(:validate_client)
270
+ if provider_class.respond_to?(:validate_client)
284
271
  provider_class.validate_client(client_id, client_secret)
285
272
  elsif require_secret && client_secret.nil?
286
273
  raise InvalidClientError, "Client authentication required"
287
274
  else
288
275
  # In development, allow unregistered clients if configured
289
- if Rails.env.development? && oauth_config[:allow_unregistered_clients] != false
290
- return true
291
- end
276
+ return true if Rails.env.development? && oauth_config[:allow_unregistered_clients] != false
277
+
292
278
  raise InvalidClientError, "Unknown client"
293
279
  end
294
280
  end
@@ -301,16 +287,10 @@ module ActionMCP
301
287
  expected_challenge = Base64.urlsafe_encode64(
302
288
  Digest::SHA256.digest(code_verifier), padding: false
303
289
  )
304
- unless code_challenge == expected_challenge
305
- raise InvalidGrantError, "Invalid code verifier"
306
- end
290
+ raise InvalidGrantError, "Invalid code verifier" unless code_challenge == expected_challenge
307
291
  when "plain"
308
- unless oauth_config[:allow_plain_pkce]
309
- raise InvalidGrantError, "Plain PKCE not allowed"
310
- end
311
- unless code_challenge == code_verifier
312
- raise InvalidGrantError, "Invalid code verifier"
313
- end
292
+ raise InvalidGrantError, "Plain PKCE not allowed" unless oauth_config[:allow_plain_pkce]
293
+ raise InvalidGrantError, "Invalid code verifier" unless code_challenge == code_verifier
314
294
  else
315
295
  raise InvalidGrantError, "Unsupported code challenge method"
316
296
  end
@@ -320,9 +300,9 @@ module ActionMCP
320
300
  supported_scopes = oauth_config.fetch(:scopes_supported, [ "mcp:tools", "mcp:resources", "mcp:prompts" ])
321
301
  requested_scopes = scope.split(" ")
322
302
  unsupported = requested_scopes - supported_scopes
323
- if unsupported.any?
324
- raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
325
- end
303
+ return unless unsupported.any?
304
+
305
+ raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
326
306
  end
327
307
 
328
308
  def default_scope
@@ -333,12 +313,12 @@ module ActionMCP
333
313
  token = SecureRandom.urlsafe_base64(32)
334
314
 
335
315
  store_access_token(token, {
336
- client_id: client_id,
337
- scope: scope,
338
- user_id: user_id,
339
- created_at: Time.current,
340
- expires_at: token_expires_in.seconds.from_now
341
- })
316
+ client_id: client_id,
317
+ scope: scope,
318
+ user_id: user_id,
319
+ created_at: Time.current,
320
+ expires_at: token_expires_in.seconds.from_now
321
+ })
342
322
 
343
323
  token
344
324
  end
@@ -347,13 +327,13 @@ module ActionMCP
347
327
  token = SecureRandom.urlsafe_base64(32)
348
328
 
349
329
  store_refresh_token(token, {
350
- client_id: client_id,
351
- scope: scope,
352
- user_id: user_id,
353
- access_token: access_token,
354
- created_at: Time.current,
355
- expires_at: refresh_token_expires_in.seconds.from_now
356
- })
330
+ client_id: client_id,
331
+ scope: scope,
332
+ user_id: user_id,
333
+ access_token: access_token,
334
+ created_at: Time.current,
335
+ expires_at: refresh_token_expires_in.seconds.from_now
336
+ })
357
337
 
358
338
  token
359
339
  end
@@ -38,17 +38,15 @@ module ActionMCP
38
38
 
39
39
  # User info from OAuth token response or userinfo endpoint
40
40
  def raw_info
41
- @raw_info ||= begin
42
- if options.userinfo_url
43
- access_token.get(options.userinfo_url).parsed
44
- else
45
- # Extract user info from token response or use minimal info
46
- token_response = access_token.token
47
- {
48
- "sub" => access_token.params["user_id"] || access_token.token,
49
- "scope" => access_token.params["scope"] || options.scope
50
- }
51
- end
41
+ @raw_info ||= if options.userinfo_url
42
+ access_token.get(options.userinfo_url).parsed
43
+ else
44
+ # Extract user info from token response or use minimal info
45
+ access_token.token
46
+ {
47
+ "sub" => access_token.params["user_id"] || access_token.token,
48
+ "scope" => access_token.params["scope"] || options.scope
49
+ }
52
50
  end
53
51
  rescue ::OAuth2::Error => e
54
52
  log(:error, "Failed to fetch user info: #{e.message}")
@@ -93,11 +91,11 @@ module ActionMCP
93
91
  # Override client to use discovered endpoints if available
94
92
  def client
95
93
  @client ||= begin
96
- if discovery_info.any?
94
+ if discovery_info.any? && discovery_info["authorization_endpoint"] && discovery_info["token_endpoint"]
97
95
  options.client_options.merge!(
98
96
  authorize_url: discovery_info["authorization_endpoint"],
99
97
  token_url: discovery_info["token_endpoint"]
100
- ) if discovery_info["authorization_endpoint"] && discovery_info["token_endpoint"]
98
+ )
101
99
  end
102
100
  super
103
101
  end
@@ -115,9 +113,9 @@ module ActionMCP
115
113
 
116
114
  begin
117
115
  response = client.request(:post, options.introspection_url || "/oauth/introspect", {
118
- body: { token: token },
119
- headers: { "Content-Type" => "application/x-www-form-urlencoded" }
120
- })
116
+ body: { token: token },
117
+ headers: { "Content-Type" => "application/x-www-form-urlencoded" }
118
+ })
121
119
 
122
120
  token_info = JSON.parse(response.body)
123
121
  return nil unless token_info["active"]
@@ -137,36 +135,24 @@ module ActionMCP
137
135
  return unless oauth_config.is_a?(Hash)
138
136
 
139
137
  # Set client options from MCP config
140
- if oauth_config["issuer_url"]
141
- options.client_options[:site] = oauth_config["issuer_url"]
142
- end
138
+ options.client_options[:site] = oauth_config["issuer_url"] if oauth_config["issuer_url"]
143
139
 
144
- if oauth_config["client_id"]
145
- options.client_id = oauth_config["client_id"]
146
- end
140
+ options.client_id = oauth_config["client_id"] if oauth_config["client_id"]
147
141
 
148
- if oauth_config["client_secret"]
149
- options.client_secret = oauth_config["client_secret"]
150
- end
142
+ options.client_secret = oauth_config["client_secret"] if oauth_config["client_secret"]
151
143
 
152
- if oauth_config["scopes_supported"]
153
- options.scope = Array(oauth_config["scopes_supported"]).join(" ")
154
- end
144
+ options.scope = Array(oauth_config["scopes_supported"]).join(" ") if oauth_config["scopes_supported"]
155
145
 
156
146
  # Enable PKCE if required (OAuth 2.1 compliance)
157
- if oauth_config["pkce_required"]
158
- options.pkce = true
159
- end
147
+ options.pkce = true if oauth_config["pkce_required"]
160
148
 
161
149
  # Set userinfo endpoint if provided
162
- if oauth_config["userinfo_endpoint"]
163
- options.userinfo_url = oauth_config["userinfo_endpoint"]
164
- end
150
+ options.userinfo_url = oauth_config["userinfo_endpoint"] if oauth_config["userinfo_endpoint"]
165
151
 
166
152
  # Set token introspection endpoint
167
- if oauth_config["introspection_endpoint"]
168
- options.introspection_url = oauth_config["introspection_endpoint"]
169
- end
153
+ return unless oauth_config["introspection_endpoint"]
154
+
155
+ options.introspection_url = oauth_config["introspection_endpoint"]
170
156
  end
171
157
  end
172
158
  end