actionmcp 0.71.0 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -15
  3. data/app/controllers/action_mcp/application_controller.rb +47 -40
  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 +12 -13
  18. data/lib/action_mcp/client/collection.rb +3 -3
  19. data/lib/action_mcp/client/elicitation.rb +4 -4
  20. data/lib/action_mcp/client/json_rpc_handler.rb +11 -13
  21. data/lib/action_mcp/client/jwt_client_provider.rb +6 -5
  22. data/lib/action_mcp/client/oauth_client_provider.rb +8 -8
  23. data/lib/action_mcp/client/streamable_http_transport.rb +63 -27
  24. data/lib/action_mcp/client.rb +19 -4
  25. data/lib/action_mcp/configuration.rb +28 -53
  26. data/lib/action_mcp/engine.rb +5 -1
  27. data/lib/action_mcp/filtered_logger.rb +1 -1
  28. data/lib/action_mcp/gateway.rb +47 -137
  29. data/lib/action_mcp/gateway_identifier.rb +29 -0
  30. data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
  31. data/lib/action_mcp/jwt_decoder.rb +4 -2
  32. data/lib/action_mcp/jwt_identifier.rb +28 -0
  33. data/lib/action_mcp/none_identifier.rb +19 -0
  34. data/lib/action_mcp/o_auth_identifier.rb +34 -0
  35. data/lib/action_mcp/oauth/active_record_storage.rb +1 -1
  36. data/lib/action_mcp/oauth/memory_storage.rb +1 -3
  37. data/lib/action_mcp/oauth/middleware.rb +13 -18
  38. data/lib/action_mcp/oauth/provider.rb +45 -65
  39. data/lib/action_mcp/omniauth/mcp_strategy.rb +23 -37
  40. data/lib/action_mcp/prompt.rb +2 -0
  41. data/lib/action_mcp/renderable.rb +1 -1
  42. data/lib/action_mcp/resource_template.rb +6 -2
  43. data/lib/action_mcp/server/{memory_session.rb → base_session.rb} +39 -26
  44. data/lib/action_mcp/server/base_session_store.rb +86 -0
  45. data/lib/action_mcp/server/capabilities.rb +2 -1
  46. data/lib/action_mcp/server/elicitation.rb +3 -9
  47. data/lib/action_mcp/server/error_handling.rb +14 -1
  48. data/lib/action_mcp/server/handlers/router.rb +31 -0
  49. data/lib/action_mcp/server/json_rpc_handler.rb +2 -5
  50. data/lib/action_mcp/server/{messaging.rb → messaging_service.rb} +38 -14
  51. data/lib/action_mcp/server/prompts.rb +4 -4
  52. data/lib/action_mcp/server/resources.rb +23 -4
  53. data/lib/action_mcp/server/session_store_factory.rb +1 -1
  54. data/lib/action_mcp/server/solid_mcp_adapter.rb +9 -10
  55. data/lib/action_mcp/server/tools.rb +62 -43
  56. data/lib/action_mcp/server/transport_handler.rb +2 -4
  57. data/lib/action_mcp/server/volatile_session_store.rb +1 -93
  58. data/lib/action_mcp/tagged_stream_logging.rb +2 -2
  59. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +4 -4
  60. data/lib/action_mcp/test_helper/session_store_assertions.rb +5 -1
  61. data/lib/action_mcp/tool.rb +48 -37
  62. data/lib/action_mcp/types/float_array_type.rb +5 -3
  63. data/lib/action_mcp/version.rb +1 -1
  64. data/lib/action_mcp.rb +1 -1
  65. data/lib/generators/action_mcp/install/templates/application_gateway.rb +1 -0
  66. data/lib/tasks/action_mcp_tasks.rake +7 -5
  67. metadata +24 -18
  68. data/lib/action_mcp/server/notifications.rb +0 -58
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class GatewayIdentifier
5
+ class Unauthorized < StandardError; end
6
+
7
+ class << self
8
+ # e.g. JwtIdentifier.identifier_name => :user
9
+ attr_reader :identifier_name, :auth_method
10
+
11
+ def identifier(name)
12
+ @identifier_name = name.to_sym
13
+ end
14
+
15
+ def authenticates(method)
16
+ @auth_method = method.to_s
17
+ end
18
+ end
19
+
20
+ def initialize(request)
21
+ @request = request
22
+ end
23
+
24
+ # must return a truthy identity object, or raise Unauthorized
25
+ def resolve
26
+ raise NotImplementedError, "#{self.class}#resolve must be implemented"
27
+ end
28
+ end
29
+ end
@@ -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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class JwtIdentifier < GatewayIdentifier
5
+ identifier :user
6
+ authenticates :jwt
7
+
8
+ def resolve
9
+ token = extract_bearer_token
10
+ raise Unauthorized, "Missing JWT" unless token
11
+
12
+ payload = ActionMCP::JwtDecoder.decode(token)
13
+ user = User.find_by(id: payload["sub"] || payload["user_id"])
14
+ return user if user
15
+
16
+ raise Unauthorized, "Invalid JWT user"
17
+ rescue ActionMCP::JwtDecoder::DecodeError => e
18
+ raise Unauthorized, "Invalid JWT token: #{e.message}"
19
+ end
20
+
21
+ private
22
+
23
+ def extract_bearer_token
24
+ header = @request.env["HTTP_AUTHORIZATION"] || ""
25
+ header[/\ABearer (.+)\z/, 1]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class NoneIdentifier < GatewayIdentifier
5
+ identifier :user
6
+ authenticates :none
7
+
8
+ def resolve
9
+ Rails.env.production? &&
10
+ raise(Unauthorized, "No auth allowed in production")
11
+
12
+ return "anonymous_user" unless defined?(User)
13
+
14
+ User.find_or_create_by!(email: "dev@localhost") do |user|
15
+ user.name = "Development User" if user.respond_to?(:name=)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class OAuthIdentifier < GatewayIdentifier
5
+ identifier :user
6
+ authenticates :oauth
7
+
8
+ def resolve
9
+ info = @request.env["action_mcp.oauth_token_info"] or
10
+ raise Unauthorized, "Missing OAuth info"
11
+
12
+ uid = info["user_id"] || info["sub"] || info[:user_id]
13
+ raise Unauthorized, "Invalid OAuth info" unless uid
14
+
15
+ # Try to find existing user or create one for demo purposes
16
+ user = User.find_by(email: uid) ||
17
+ User.find_by(email: "#{uid}@example.com") ||
18
+ create_oauth_user(uid)
19
+
20
+ user || raise(Unauthorized, "Unable to resolve OAuth user")
21
+ end
22
+
23
+ private
24
+
25
+ def create_oauth_user(uid)
26
+ return nil unless defined?(User)
27
+
28
+ email = uid.include?("@") ? uid : "#{uid}@example.com"
29
+ User.create!(email: email)
30
+ rescue ActiveRecord::RecordInvalid
31
+ nil
32
+ end
33
+ end
34
+ 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
@@ -28,6 +28,7 @@ module ActionMCP
28
28
  # @return [String] The default prompt name.
29
29
  def self.default_prompt_name
30
30
  return "" if name.nil?
31
+
31
32
  name.demodulize.underscore.sub(/_prompt$/, "")
32
33
  end
33
34
 
@@ -55,6 +56,7 @@ module ActionMCP
55
56
  def meta(data = nil)
56
57
  if data
57
58
  raise ArgumentError, "_meta must be a hash" unless data.is_a?(Hash)
59
+
58
60
  self._meta = _meta.merge(data)
59
61
  else
60
62
  _meta
@@ -49,7 +49,7 @@ module ActionMCP
49
49
  #
50
50
  def render_resource_link(uri:, name: nil, description: nil, mime_type: nil, annotations: nil)
51
51
  Content::ResourceLink.new(uri, name: name, description: description,
52
- mime_type: mime_type, annotations: annotations)
52
+ mime_type: mime_type, annotations: annotations)
53
53
  end
54
54
  end
55
55
  end
@@ -27,7 +27,9 @@ module ActionMCP
27
27
  def abstract!
28
28
  @abstract = true
29
29
  # Unregister from the appropriate registry if already registered
30
- ActionMCP::ResourceTemplatesRegistry.unregister(self) if ActionMCP::ResourceTemplatesRegistry.items.values.include?(self)
30
+ return unless ActionMCP::ResourceTemplatesRegistry.items.values.include?(self)
31
+
32
+ ActionMCP::ResourceTemplatesRegistry.unregister(self)
31
33
  end
32
34
 
33
35
  def inherited(subclass)
@@ -92,6 +94,7 @@ module ActionMCP
92
94
  def meta(data = nil)
93
95
  if data
94
96
  raise ArgumentError, "_meta must be a hash" unless data.is_a?(Hash)
97
+
95
98
  @_meta ||= {}
96
99
  @_meta = @_meta.merge(data)
97
100
  else
@@ -110,13 +113,14 @@ module ActionMCP
110
113
  }.compact
111
114
 
112
115
  # Add _meta if present
113
- result[:_meta] = @_meta if @_meta && @_meta.any?
116
+ result[:_meta] = @_meta if @_meta&.any?
114
117
 
115
118
  result
116
119
  end
117
120
 
118
121
  def capability_name
119
122
  return "" if name.nil?
123
+
120
124
  @capability_name ||= name.demodulize.underscore.sub(/_template$/, "")
121
125
  end
122
126