actionmcp 0.70.0 → 0.71.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -41
  3. data/app/controllers/action_mcp/application_controller.rb +67 -15
  4. data/app/controllers/action_mcp/oauth/metadata_controller.rb +13 -13
  5. data/app/controllers/action_mcp/oauth/registration_controller.rb +206 -0
  6. data/app/models/action_mcp/oauth_client.rb +157 -0
  7. data/app/models/action_mcp/oauth_token.rb +141 -0
  8. data/app/models/action_mcp/session/message.rb +12 -12
  9. data/app/models/action_mcp/session/resource.rb +2 -2
  10. data/app/models/action_mcp/session/sse_event.rb +2 -2
  11. data/app/models/action_mcp/session/subscription.rb +2 -2
  12. data/app/models/action_mcp/session.rb +22 -22
  13. data/config/routes.rb +1 -0
  14. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +42 -0
  15. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +37 -0
  16. data/lib/action_mcp/client/jwt_client_provider.rb +134 -0
  17. data/lib/action_mcp/configuration.rb +27 -4
  18. data/lib/action_mcp/filtered_logger.rb +32 -0
  19. data/lib/action_mcp/oauth/active_record_storage.rb +183 -0
  20. data/lib/action_mcp/oauth/memory_storage.rb +23 -1
  21. data/lib/action_mcp/oauth/middleware.rb +33 -0
  22. data/lib/action_mcp/oauth/provider.rb +49 -13
  23. data/lib/action_mcp/oauth.rb +12 -0
  24. data/lib/action_mcp/server/capabilities.rb +0 -3
  25. data/lib/action_mcp/server/resources.rb +1 -1
  26. data/lib/action_mcp/server/tools.rb +36 -24
  27. data/lib/action_mcp/sse_listener.rb +0 -7
  28. data/lib/action_mcp/test_helper.rb +5 -0
  29. data/lib/action_mcp/tool.rb +94 -4
  30. data/lib/action_mcp/tools_registry.rb +3 -0
  31. data/lib/action_mcp/version.rb +1 -1
  32. data/lib/generators/action_mcp/install/templates/mcp.yml +16 -16
  33. metadata +10 -1
@@ -0,0 +1,42 @@
1
+ class CreateActionMCPOAuthClients < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :action_mcp_oauth_clients do |t|
4
+ t.string :client_id, null: false, index: { unique: true }
5
+ t.string :client_secret
6
+ t.string :client_name
7
+
8
+ # Store arrays as JSON for database compatibility
9
+ if connection.adapter_name.downcase.include?('postgresql')
10
+ t.text :redirect_uris, array: true, default: []
11
+ t.text :grant_types, array: true, default: [ "authorization_code" ]
12
+ t.text :response_types, array: true, default: [ "code" ]
13
+ else
14
+ # For SQLite and other databases, use JSON
15
+ t.json :redirect_uris, default: []
16
+ t.json :grant_types, default: [ "authorization_code" ]
17
+ t.json :response_types, default: [ "code" ]
18
+ end
19
+
20
+ t.string :token_endpoint_auth_method, default: "client_secret_basic"
21
+ t.text :scope
22
+ t.boolean :active, default: true
23
+
24
+ # Registration metadata
25
+ t.integer :client_id_issued_at
26
+ t.integer :client_secret_expires_at
27
+ t.string :registration_access_token # OAuth 2.1 Dynamic Client Registration
28
+
29
+ # Additional metadata as JSON for database compatibility
30
+ if connection.adapter_name.downcase.include?('postgresql')
31
+ t.jsonb :metadata, default: {}
32
+ else
33
+ t.json :metadata, default: {}
34
+ end
35
+
36
+ t.timestamps
37
+ end
38
+
39
+ add_index :action_mcp_oauth_clients, :active
40
+ add_index :action_mcp_oauth_clients, :client_id_issued_at
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ class CreateActionMCPOAuthTokens < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :action_mcp_oauth_tokens do |t|
4
+ t.string :token, null: false, index: { unique: true }
5
+ t.string :token_type, null: false # 'access_token', 'refresh_token', 'authorization_code'
6
+ t.string :client_id, null: false
7
+ t.string :user_id
8
+ t.text :scope
9
+ t.datetime :expires_at
10
+ t.boolean :revoked, default: false
11
+
12
+ # For authorization codes
13
+ t.string :redirect_uri
14
+ t.string :code_challenge
15
+ t.string :code_challenge_method
16
+
17
+ # For refresh tokens
18
+ t.string :access_token # Reference to associated access token
19
+
20
+ # Additional data - use JSON for database compatibility
21
+ if connection.adapter_name.downcase.include?('postgresql')
22
+ t.jsonb :metadata, default: {}
23
+ else
24
+ t.json :metadata, default: {}
25
+ end
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ add_index :action_mcp_oauth_tokens, :token_type
31
+ add_index :action_mcp_oauth_tokens, :client_id
32
+ add_index :action_mcp_oauth_tokens, :user_id
33
+ add_index :action_mcp_oauth_tokens, :expires_at
34
+ add_index :action_mcp_oauth_tokens, :revoked
35
+ add_index :action_mcp_oauth_tokens, [ :token_type, :expires_at ]
36
+ end
37
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+
6
+ module ActionMCP
7
+ module Client
8
+ # JWT client provider for MCP client authentication
9
+ # Provides clean JWT token management for ActionMCP client connections
10
+ class JwtClientProvider
11
+ class AuthenticationError < StandardError; end
12
+ class TokenExpiredError < StandardError; end
13
+
14
+ attr_reader :storage
15
+
16
+ def initialize(token: nil, storage: nil, logger: ActionMCP.logger)
17
+ @storage = storage || MemoryStorage.new
18
+ @logger = logger
19
+
20
+ # If token provided during initialization, store it
21
+ if token
22
+ save_token(token)
23
+ end
24
+ end
25
+
26
+ # Check if client has valid authentication
27
+ def authenticated?
28
+ token = current_token
29
+ return false unless token
30
+
31
+ !token_expired?(token)
32
+ end
33
+
34
+ # Get authorization headers for HTTP requests
35
+ def authorization_headers
36
+ token = current_token
37
+ return {} unless token
38
+
39
+ if token_expired?(token)
40
+ log_debug("JWT token expired")
41
+ clear_tokens!
42
+ return {}
43
+ end
44
+
45
+ { "Authorization" => "Bearer #{token}" }
46
+ end
47
+
48
+ # Set/update the JWT token
49
+ def set_token(token)
50
+ save_token(token)
51
+ log_debug("JWT token updated")
52
+ end
53
+
54
+ # Clear stored tokens (logout)
55
+ def clear_tokens!
56
+ @storage.clear_token
57
+ log_debug("Cleared JWT token")
58
+ end
59
+
60
+ # Get current valid token
61
+ def access_token
62
+ token = current_token
63
+ return nil unless token
64
+ return nil if token_expired?(token)
65
+ token
66
+ end
67
+
68
+ private
69
+
70
+ def current_token
71
+ @storage.load_token
72
+ end
73
+
74
+ def save_token(token)
75
+ @storage.save_token(token)
76
+ end
77
+
78
+ def token_expired?(token)
79
+ return false unless token
80
+
81
+ begin
82
+ payload = decode_jwt_payload(token)
83
+ exp = payload["exp"]
84
+ return false unless exp
85
+
86
+ # Add 30 second buffer for clock skew
87
+ Time.at(exp) <= Time.now + 30
88
+ rescue => e
89
+ log_debug("Error checking token expiration: #{e.message}")
90
+ true # Treat invalid tokens as expired
91
+ end
92
+ end
93
+
94
+ def decode_jwt_payload(token)
95
+ # Split JWT into parts
96
+ parts = token.split(".")
97
+ raise AuthenticationError, "Invalid JWT format" unless parts.length == 3
98
+
99
+ # Decode payload (second part)
100
+ payload_base64 = parts[1]
101
+ # Add padding if needed
102
+ payload_base64 += "=" * (4 - payload_base64.length % 4) if payload_base64.length % 4 != 0
103
+
104
+ payload_json = Base64.urlsafe_decode64(payload_base64)
105
+ JSON.parse(payload_json)
106
+ rescue => e
107
+ raise AuthenticationError, "Failed to decode JWT: #{e.message}"
108
+ end
109
+
110
+ def log_debug(message)
111
+ @logger.debug("[ActionMCP::JwtClientProvider] #{message}")
112
+ end
113
+
114
+ # Simple memory storage for JWT tokens
115
+ class MemoryStorage
116
+ def initialize
117
+ @token = nil
118
+ end
119
+
120
+ def save_token(token)
121
+ @token = token
122
+ end
123
+
124
+ def load_token
125
+ @token
126
+ end
127
+
128
+ def clear_token
129
+ @token = nil
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -26,6 +26,7 @@ module ActionMCP
26
26
  :active_profile,
27
27
  :profiles,
28
28
  :elicitation_enabled,
29
+ :verbose_logging,
29
30
  # --- Authentication Options ---
30
31
  :authentication_methods,
31
32
  :oauth_config,
@@ -56,12 +57,13 @@ module ActionMCP
56
57
  @logging_level = :info
57
58
  @resources_subscribe = false
58
59
  @elicitation_enabled = false
60
+ @verbose_logging = false
59
61
  @active_profile = :primary
60
62
  @profiles = default_profiles
61
63
 
62
64
  # Authentication defaults
63
65
  @authentication_methods = Rails.env.production? ? [ "jwt" ] : [ "none" ]
64
- @oauth_config = {}
66
+ @oauth_config = HashWithIndifferentAccess.new
65
67
 
66
68
  @sse_heartbeat_interval = 30
67
69
  @post_response_preference = :json
@@ -73,6 +75,7 @@ module ActionMCP
73
75
 
74
76
  # Gateway - default to ApplicationGateway if it exists, otherwise ActionMCP::Gateway
75
77
  @gateway_class = defined?(::ApplicationGateway) ? ::ApplicationGateway : ActionMCP::Gateway
78
+ @gateway_class_name = nil
76
79
 
77
80
  # Session Store
78
81
  @session_store_type = Rails.env.production? ? :active_record : :volatile
@@ -88,6 +91,15 @@ module ActionMCP
88
91
  @version || (has_rails_version ? Rails.application.version.to_s : "0.0.1")
89
92
  end
90
93
 
94
+ def gateway_class
95
+ if @gateway_class_name
96
+ klass = @gateway_class_name.constantize
97
+ klass
98
+ else
99
+ @gateway_class
100
+ end
101
+ end
102
+
91
103
  # Get active profile (considering thread-local override)
92
104
  def active_profile
93
105
  ActionMCP.thread_profiles.value || @active_profile
@@ -111,7 +123,7 @@ module ActionMCP
111
123
 
112
124
  # Extract OAuth configuration if present
113
125
  if app_config["oauth"]
114
- @oauth_config = app_config["oauth"]
126
+ @oauth_config = HashWithIndifferentAccess.new(app_config["oauth"])
115
127
  end
116
128
 
117
129
  # Extract other top-level configuration settings
@@ -121,9 +133,10 @@ module ActionMCP
121
133
  if app_config["profiles"]
122
134
  @profiles = app_config["profiles"]
123
135
  end
124
- rescue StandardError
136
+ rescue StandardError => e
125
137
  # If the config file doesn't exist in the Rails app, just use the defaults
126
- Rails.logger.debug "No MCP config found in Rails app, using defaults from gem"
138
+ Rails.logger.warn "[Configuration] Failed to load MCP config: #{e.class} - #{e.message}"
139
+ # No MCP config found in Rails app, using defaults from gem
127
140
  end
128
141
 
129
142
  # Apply the active profile
@@ -294,6 +307,16 @@ module ActionMCP
294
307
  @connects_to = app_config["connects_to"]
295
308
  end
296
309
 
310
+ # Extract verbose logging setting
311
+ if app_config.key?("verbose_logging")
312
+ @verbose_logging = app_config["verbose_logging"]
313
+ end
314
+
315
+ # Extract gateway class configuration
316
+ if app_config["gateway_class"]
317
+ @gateway_class_name = app_config["gateway_class"]
318
+ end
319
+
297
320
  # Extract session store configuration
298
321
  if app_config["session_store_type"]
299
322
  @session_store_type = app_config["session_store_type"].to_sym
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Custom logger that filters out repetitive MCP requests
5
+ class FilteredLogger < ActiveSupport::Logger
6
+ FILTERED_PATHS = [
7
+ "/oauth/authorize",
8
+ "/.well-known/oauth-protected-resource",
9
+ "/.well-known/oauth-authorization-server"
10
+ ].freeze
11
+
12
+ FILTERED_METHODS = [
13
+ "notifications/initialized",
14
+ "notifications/ping"
15
+ ].freeze
16
+
17
+ def add(severity, message = nil, progname = nil, &block)
18
+ # Filter out repetitive OAuth metadata requests
19
+ if message && message.is_a?(String)
20
+ return if FILTERED_PATHS.any? { |path| message.include?(path) && message.include?("200 OK") }
21
+
22
+ # Filter out repetitive MCP notifications
23
+ return if FILTERED_METHODS.any? { |method| message.include?(method) }
24
+
25
+ # Filter out MCP protocol version debug messages
26
+ return if message.include?("MCP-Protocol-Version header validation passed")
27
+ end
28
+
29
+ super
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module OAuth
5
+ # ActiveRecord storage for OAuth tokens and codes
6
+ # This is suitable for production multi-server environments
7
+ class ActiveRecordStorage
8
+ # Authorization code storage
9
+ def store_authorization_code(code, data)
10
+ OAuthToken.create!(
11
+ token: code,
12
+ token_type: OAuthToken::AUTHORIZATION_CODE,
13
+ client_id: data[:client_id],
14
+ user_id: data[:user_id],
15
+ redirect_uri: data[:redirect_uri],
16
+ scope: data[:scope],
17
+ code_challenge: data[:code_challenge],
18
+ code_challenge_method: data[:code_challenge_method],
19
+ expires_at: data[:expires_at],
20
+ metadata: data.except(:client_id, :user_id, :redirect_uri, :scope,
21
+ :code_challenge, :code_challenge_method, :expires_at)
22
+ )
23
+ end
24
+
25
+ def retrieve_authorization_code(code)
26
+ token = OAuthToken.authorization_codes.active.find_by(token: code)
27
+ return nil unless token
28
+
29
+ {
30
+ client_id: token.client_id,
31
+ user_id: token.user_id,
32
+ redirect_uri: token.redirect_uri,
33
+ scope: token.scope,
34
+ code_challenge: token.code_challenge,
35
+ code_challenge_method: token.code_challenge_method,
36
+ expires_at: token.expires_at,
37
+ created_at: token.created_at
38
+ }.merge(token.metadata || {})
39
+ end
40
+
41
+ def remove_authorization_code(code)
42
+ OAuthToken.authorization_codes.where(token: code).destroy_all
43
+ end
44
+
45
+ # Access token storage
46
+ def store_access_token(token, data)
47
+ OAuthToken.create!(
48
+ token: token,
49
+ token_type: OAuthToken::ACCESS_TOKEN,
50
+ client_id: data[:client_id],
51
+ user_id: data[:user_id],
52
+ scope: data[:scope],
53
+ expires_at: data[:expires_at],
54
+ metadata: data.except(:client_id, :user_id, :scope, :expires_at)
55
+ )
56
+ end
57
+
58
+ def retrieve_access_token(token)
59
+ token_record = OAuthToken.access_tokens.find_by(token: token)
60
+ return nil unless token_record
61
+
62
+ {
63
+ client_id: token_record.client_id,
64
+ user_id: token_record.user_id,
65
+ scope: token_record.scope,
66
+ expires_at: token_record.expires_at,
67
+ created_at: token_record.created_at,
68
+ active: token_record.still_valid?
69
+ }.merge(token_record.metadata || {})
70
+ end
71
+
72
+ def remove_access_token(token)
73
+ OAuthToken.access_tokens.where(token: token).destroy_all
74
+ end
75
+
76
+ # Refresh token storage
77
+ def store_refresh_token(token, data)
78
+ OAuthToken.create!(
79
+ token: token,
80
+ token_type: OAuthToken::REFRESH_TOKEN,
81
+ client_id: data[:client_id],
82
+ user_id: data[:user_id],
83
+ scope: data[:scope],
84
+ access_token: data[:access_token],
85
+ expires_at: data[:expires_at],
86
+ metadata: data.except(:client_id, :user_id, :scope, :access_token, :expires_at)
87
+ )
88
+ end
89
+
90
+ def retrieve_refresh_token(token)
91
+ token_record = OAuthToken.refresh_tokens.active.find_by(token: token)
92
+ return nil unless token_record
93
+
94
+ {
95
+ client_id: token_record.client_id,
96
+ user_id: token_record.user_id,
97
+ scope: token_record.scope,
98
+ access_token: token_record.access_token,
99
+ expires_at: token_record.expires_at,
100
+ created_at: token_record.created_at
101
+ }.merge(token_record.metadata || {})
102
+ end
103
+
104
+ def update_refresh_token(token, new_access_token)
105
+ token_record = OAuthToken.refresh_tokens.find_by(token: token)
106
+ token_record&.update!(access_token: new_access_token)
107
+ end
108
+
109
+ def remove_refresh_token(token)
110
+ OAuthToken.refresh_tokens.where(token: token).destroy_all
111
+ end
112
+
113
+ # Client registration storage
114
+ def store_client_registration(client_id, data)
115
+ client = OAuthClient.new
116
+
117
+ # Map data fields to model attributes
118
+ client.client_id = client_id
119
+ client.client_secret = data[:client_secret]
120
+ client.client_id_issued_at = data[:client_id_issued_at]
121
+ client.registration_access_token = data[:registration_access_token]
122
+
123
+ # Handle client metadata
124
+ metadata = data[:client_metadata] || {}
125
+ %w[
126
+ client_name redirect_uris grant_types response_types
127
+ token_endpoint_auth_method scope
128
+ ].each do |field|
129
+ client.send("#{field}=", metadata[field]) if metadata.key?(field)
130
+ end
131
+
132
+ # Store any additional metadata
133
+ known_fields = %w[
134
+ client_name redirect_uris grant_types response_types
135
+ token_endpoint_auth_method scope
136
+ ]
137
+ additional_metadata = metadata.except(*known_fields)
138
+ client.metadata = additional_metadata if additional_metadata.present?
139
+
140
+ client.save!
141
+ data
142
+ end
143
+
144
+ def retrieve_client_registration(client_id)
145
+ client = OAuthClient.active.find_by(client_id: client_id)
146
+ return nil unless client
147
+
148
+ {
149
+ client_id: client.client_id,
150
+ client_secret: client.client_secret,
151
+ client_id_issued_at: client.client_id_issued_at,
152
+ registration_access_token: client.registration_access_token,
153
+ client_metadata: client.to_api_response
154
+ }
155
+ end
156
+
157
+ def remove_client_registration(client_id)
158
+ OAuthClient.where(client_id: client_id).destroy_all
159
+ end
160
+
161
+ # Cleanup expired tokens
162
+ def cleanup_expired
163
+ OAuthToken.cleanup_expired
164
+ end
165
+
166
+ # Statistics (for debugging/monitoring)
167
+ def stats
168
+ {
169
+ authorization_codes: OAuthToken.authorization_codes.active.count,
170
+ access_tokens: OAuthToken.access_tokens.active.count,
171
+ refresh_tokens: OAuthToken.refresh_tokens.active.count,
172
+ client_registrations: OAuthClient.active.count
173
+ }
174
+ end
175
+
176
+ # Clear all data (for testing)
177
+ def clear_all
178
+ OAuthToken.delete_all
179
+ OAuthClient.delete_all
180
+ end
181
+ end
182
+ end
183
+ end
@@ -9,6 +9,7 @@ module ActionMCP
9
9
  @authorization_codes = {}
10
10
  @access_tokens = {}
11
11
  @refresh_tokens = {}
12
+ @client_registrations = {}
12
13
  @mutex = Mutex.new
13
14
  end
14
15
 
@@ -77,6 +78,25 @@ module ActionMCP
77
78
  end
78
79
  end
79
80
 
81
+ # Client registration storage
82
+ def store_client_registration(client_id, data)
83
+ @mutex.synchronize do
84
+ @client_registrations[client_id] = data
85
+ end
86
+ end
87
+
88
+ def retrieve_client_registration(client_id)
89
+ @mutex.synchronize do
90
+ @client_registrations[client_id]
91
+ end
92
+ end
93
+
94
+ def remove_client_registration(client_id)
95
+ @mutex.synchronize do
96
+ @client_registrations.delete(client_id)
97
+ end
98
+ end
99
+
80
100
  # Cleanup expired tokens (optional utility method)
81
101
  def cleanup_expired
82
102
  current_time = Time.current
@@ -94,7 +114,8 @@ module ActionMCP
94
114
  {
95
115
  authorization_codes: @authorization_codes.size,
96
116
  access_tokens: @access_tokens.size,
97
- refresh_tokens: @refresh_tokens.size
117
+ refresh_tokens: @refresh_tokens.size,
118
+ client_registrations: @client_registrations.size
98
119
  }
99
120
  end
100
121
  end
@@ -105,6 +126,7 @@ module ActionMCP
105
126
  @authorization_codes.clear
106
127
  @access_tokens.clear
107
128
  @refresh_tokens.clear
129
+ @client_registrations.clear
108
130
  end
109
131
  end
110
132
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error"
4
+
3
5
  module ActionMCP
4
6
  module OAuth
5
7
  # OAuth middleware that integrates with Omniauth for request authentication
@@ -15,6 +17,15 @@ module ActionMCP
15
17
  # Skip OAuth processing for non-MCP requests or if OAuth not configured
16
18
  return @app.call(env) unless should_process_oauth?(request)
17
19
 
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
24
+
25
+ # Skip OAuth processing for initialization-related requests
26
+ if initialization_related_request?(request)
27
+ return @app.call(env)
28
+ end
18
29
 
19
30
  # Validate Bearer token for API requests
20
31
  if bearer_token = extract_bearer_token(request)
@@ -37,6 +48,28 @@ module ActionMCP
37
48
  true
38
49
  end
39
50
 
51
+ def initialization_related_request?(request)
52
+ # Only check JSON-RPC POST requests to MCP endpoints
53
+ # The path might include the mount path (e.g., /action_mcp/ or just /)
54
+ return false unless request.post? && request.content_type&.include?("application/json")
55
+
56
+ # Check if this is an MCP endpoint (ends with / or is the root)
57
+ path = request.path
58
+ return false unless path == "/" || path.match?(/\/action_mcp\/?$/)
59
+
60
+ # Read and parse the request body
61
+ body = request.body.read
62
+ request.body.rewind # Reset for subsequent reads
63
+
64
+ json = JSON.parse(body)
65
+ method = json["method"]
66
+
67
+ # Check if it's an initialization-related method
68
+ %w[initialize notifications/initialized].include?(method)
69
+ rescue JSON::ParserError, StandardError
70
+ false
71
+ end
72
+
40
73
 
41
74
  def extract_bearer_token(request)
42
75
  auth_header = request.headers["Authorization"] || request.headers["authorization"]