actionmcp 0.72.0 → 0.80.1
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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/app/controllers/action_mcp/application_controller.rb +20 -12
- data/app/models/action_mcp/session/message.rb +31 -20
- data/app/models/action_mcp/session/resource.rb +35 -20
- data/app/models/action_mcp/session/sse_event.rb +23 -17
- data/app/models/action_mcp/session/subscription.rb +22 -15
- data/app/models/action_mcp/session.rb +42 -119
- data/app/models/concerns/{mcp_console_helpers.rb → action_mcp/mcp_console_helpers.rb} +4 -3
- data/app/models/concerns/{mcp_message_inspect.rb → action_mcp/mcp_message_inspect.rb} +4 -3
- data/config/routes.rb +0 -13
- data/db/migrate/20250727000001_remove_oauth_support.rb +59 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +1 -46
- data/lib/action_mcp/client.rb +2 -25
- data/lib/action_mcp/configuration.rb +51 -24
- data/lib/action_mcp/engine.rb +0 -7
- data/lib/action_mcp/filtered_logger.rb +2 -6
- data/lib/action_mcp/gateway_identifier.rb +187 -3
- data/lib/action_mcp/gateway_identifiers/api_key_identifier.rb +56 -0
- data/lib/action_mcp/gateway_identifiers/devise_identifier.rb +34 -0
- data/lib/action_mcp/gateway_identifiers/request_env_identifier.rb +58 -0
- data/lib/action_mcp/gateway_identifiers/warden_identifier.rb +38 -0
- data/lib/action_mcp/gateway_identifiers.rb +26 -0
- data/lib/action_mcp/resource_template.rb +1 -0
- data/lib/action_mcp/server/base_session.rb +2 -0
- data/lib/action_mcp/server/resources.rb +8 -7
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +1 -6
- data/lib/generators/action_mcp/identifier/identifier_generator.rb +189 -0
- data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +35 -0
- data/lib/generators/action_mcp/install/install_generator.rb +1 -1
- data/lib/generators/action_mcp/install/templates/application_gateway.rb +80 -31
- data/lib/generators/action_mcp/install/templates/mcp.yml +4 -21
- metadata +15 -99
- data/app/controllers/action_mcp/oauth/endpoints_controller.rb +0 -265
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +0 -125
- data/app/controllers/action_mcp/oauth/registration_controller.rb +0 -201
- data/app/models/action_mcp/oauth_client.rb +0 -159
- data/app/models/action_mcp/oauth_token.rb +0 -142
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +0 -28
- data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +0 -44
- data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +0 -39
- data/lib/action_mcp/client/jwt_client_provider.rb +0 -135
- data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +0 -47
- data/lib/action_mcp/client/oauth_client_provider.rb +0 -234
- data/lib/action_mcp/jwt_decoder.rb +0 -28
- data/lib/action_mcp/jwt_identifier.rb +0 -28
- data/lib/action_mcp/none_identifier.rb +0 -19
- data/lib/action_mcp/o_auth_identifier.rb +0 -34
- data/lib/action_mcp/oauth/active_record_storage.rb +0 -183
- data/lib/action_mcp/oauth/error.rb +0 -79
- data/lib/action_mcp/oauth/memory_storage.rb +0 -132
- data/lib/action_mcp/oauth/middleware.rb +0 -128
- data/lib/action_mcp/oauth/provider.rb +0 -406
- data/lib/action_mcp/oauth.rb +0 -12
- data/lib/action_mcp/omniauth/mcp_strategy.rb +0 -162
@@ -15,12 +15,9 @@ module ActionMCP
|
|
15
15
|
|
16
16
|
attr_reader :session_id, :last_event_id, :protocol_version
|
17
17
|
|
18
|
-
def initialize(url, session_store:, session_id: nil,
|
19
|
-
protocol_version: nil, **options)
|
18
|
+
def initialize(url, session_store:, session_id: nil, protocol_version: nil, **options)
|
20
19
|
super(url, session_store: session_store, **options)
|
21
20
|
@session_id = session_id
|
22
|
-
@oauth_provider = oauth_provider
|
23
|
-
@jwt_provider = jwt_provider
|
24
21
|
@protocol_version = protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION
|
25
22
|
@negotiated_protocol_version = nil
|
26
23
|
@last_event_id = nil
|
@@ -105,8 +102,6 @@ module ActionMCP
|
|
105
102
|
# Add MCP-Protocol-Version header for GET requests when we have a negotiated version
|
106
103
|
headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
|
107
104
|
|
108
|
-
headers.merge!(oauth_headers)
|
109
|
-
headers.merge!(jwt_headers)
|
110
105
|
log_debug("Final GET headers: #{headers}")
|
111
106
|
headers
|
112
107
|
end
|
@@ -122,8 +117,6 @@ module ActionMCP
|
|
122
117
|
# Only include when we have a negotiated version from previous handshake
|
123
118
|
headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
|
124
119
|
|
125
|
-
headers.merge!(oauth_headers)
|
126
|
-
headers.merge!(jwt_headers)
|
127
120
|
log_debug("Final POST headers: #{headers}")
|
128
121
|
headers
|
129
122
|
end
|
@@ -208,7 +201,6 @@ module ActionMCP
|
|
208
201
|
# Accepted - message received, no immediate response
|
209
202
|
log_debug("Message accepted (202)")
|
210
203
|
when 401
|
211
|
-
handle_authentication_error(response)
|
212
204
|
raise AuthenticationError, "Authentication required"
|
213
205
|
when 405
|
214
206
|
# Method not allowed - server doesn't support this operation
|
@@ -305,43 +297,6 @@ module ActionMCP
|
|
305
297
|
log_debug("Saved session state")
|
306
298
|
end
|
307
299
|
|
308
|
-
def oauth_headers
|
309
|
-
return {} unless @oauth_provider&.authenticated?
|
310
|
-
|
311
|
-
headers = @oauth_provider.authorization_headers
|
312
|
-
log_debug("OAuth headers: #{headers}") unless headers.empty?
|
313
|
-
headers
|
314
|
-
rescue StandardError => e
|
315
|
-
log_error("Failed to get OAuth headers: #{e.message}")
|
316
|
-
{}
|
317
|
-
end
|
318
|
-
|
319
|
-
def jwt_headers
|
320
|
-
return {} unless @jwt_provider&.authenticated?
|
321
|
-
|
322
|
-
headers = @jwt_provider.authorization_headers
|
323
|
-
log_debug("JWT headers: #{headers}") unless headers.empty?
|
324
|
-
headers
|
325
|
-
rescue StandardError => e
|
326
|
-
log_error("Failed to get JWT headers: #{e.message}")
|
327
|
-
{}
|
328
|
-
end
|
329
|
-
|
330
|
-
def handle_authentication_error(response)
|
331
|
-
# Check for OAuth challenge in WWW-Authenticate header
|
332
|
-
www_auth = response.headers["www-authenticate"]
|
333
|
-
return unless www_auth&.include?("Bearer")
|
334
|
-
|
335
|
-
if @oauth_provider
|
336
|
-
log_debug("Received OAuth challenge, clearing OAuth tokens")
|
337
|
-
@oauth_provider.clear_tokens!
|
338
|
-
end
|
339
|
-
|
340
|
-
return unless @jwt_provider
|
341
|
-
|
342
|
-
log_debug("Received Bearer challenge, clearing JWT tokens")
|
343
|
-
@jwt_provider.clear_tokens!
|
344
|
-
end
|
345
300
|
|
346
301
|
def user_agent
|
347
302
|
"ActionMCP-StreamableHTTP/#{ActionMCP.gem_version}"
|
data/lib/action_mcp/client.rb
CHANGED
@@ -3,8 +3,6 @@
|
|
3
3
|
require_relative "client/transport"
|
4
4
|
require_relative "client/session_store"
|
5
5
|
require_relative "client/streamable_http_transport"
|
6
|
-
require_relative "client/oauth_client_provider"
|
7
|
-
require_relative "client/jwt_client_provider"
|
8
6
|
|
9
7
|
module ActionMCP
|
10
8
|
# Creates a client appropriate for the given endpoint.
|
@@ -13,8 +11,6 @@ module ActionMCP
|
|
13
11
|
# @param transport [Symbol] The transport type to use (:streamable_http, :sse for legacy)
|
14
12
|
# @param session_store [Symbol] The session store type (:memory, :active_record)
|
15
13
|
# @param session_id [String] Optional session ID for resuming connections
|
16
|
-
# @param oauth_provider [ActionMCP::Client::OauthClientProvider] Optional OAuth provider for authentication
|
17
|
-
# @param jwt_provider [ActionMCP::Client::JwtClientProvider] Optional JWT provider for authentication
|
18
14
|
# @param protocol_version [String] The MCP protocol version to use (defaults to ActionMCP::DEFAULT_PROTOCOL_VERSION)
|
19
15
|
# @param logger [Logger] The logger to use. Default is Logger.new($stdout).
|
20
16
|
# @param options [Hash] Additional options to pass to the client constructor.
|
@@ -39,27 +35,8 @@ module ActionMCP
|
|
39
35
|
# session_store: :memory
|
40
36
|
# )
|
41
37
|
#
|
42
|
-
# @example With OAuth authentication
|
43
|
-
# oauth_provider = ActionMCP::Client::OauthClientProvider.new(
|
44
|
-
# authorization_server_url: "https://oauth.example.com",
|
45
|
-
# redirect_url: "http://localhost:3000/callback",
|
46
|
-
# client_metadata: { client_name: "My App" }
|
47
|
-
# )
|
48
|
-
# client = ActionMCP.create_client(
|
49
|
-
# "http://127.0.0.1:3001/action_mcp",
|
50
|
-
# oauth_provider: oauth_provider
|
51
|
-
# )
|
52
|
-
#
|
53
|
-
# @example With JWT authentication
|
54
|
-
# jwt_provider = ActionMCP::Client::JwtClientProvider.new(
|
55
|
-
# token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
56
|
-
# )
|
57
|
-
# client = ActionMCP.create_client(
|
58
|
-
# "http://127.0.0.1:3001/action_mcp",
|
59
|
-
# jwt_provider: jwt_provider
|
60
|
-
# )
|
61
38
|
def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil,
|
62
|
-
|
39
|
+
protocol_version: nil, logger: Logger.new($stdout), **options)
|
63
40
|
unless endpoint =~ %r{\Ahttps?://}
|
64
41
|
raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
|
65
42
|
end
|
@@ -69,7 +46,7 @@ module ActionMCP
|
|
69
46
|
|
70
47
|
# Create transport
|
71
48
|
transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id,
|
72
|
-
|
49
|
+
protocol_version: protocol_version, logger: logger, **options)
|
73
50
|
|
74
51
|
logger.info("Creating #{transport} client for endpoint: #{endpoint}")
|
75
52
|
# Pass session_id and protocol_version to the client
|
@@ -29,7 +29,6 @@ module ActionMCP
|
|
29
29
|
:verbose_logging,
|
30
30
|
# --- Authentication Options ---
|
31
31
|
:authentication_methods,
|
32
|
-
:oauth_config,
|
33
32
|
# --- Transport Options ---
|
34
33
|
:sse_heartbeat_interval,
|
35
34
|
:post_response_preference, # :json or :sse
|
@@ -61,9 +60,8 @@ module ActionMCP
|
|
61
60
|
@active_profile = :primary
|
62
61
|
@profiles = default_profiles
|
63
62
|
|
64
|
-
# Authentication defaults
|
65
|
-
@authentication_methods =
|
66
|
-
@oauth_config = HashWithIndifferentAccess.new
|
63
|
+
# Authentication defaults - empty means all configured identifiers will be tried
|
64
|
+
@authentication_methods = []
|
67
65
|
|
68
66
|
@sse_heartbeat_interval = 30
|
69
67
|
@post_response_preference = :json
|
@@ -110,6 +108,9 @@ module ActionMCP
|
|
110
108
|
# First load defaults from the gem
|
111
109
|
@profiles = default_profiles
|
112
110
|
|
111
|
+
# Preserve any settings that were already set via Rails config
|
112
|
+
preserved_name = @name
|
113
|
+
|
113
114
|
# Try to load from config/mcp.yml in the Rails app using Rails.config_for
|
114
115
|
begin
|
115
116
|
app_config = Rails.application.config_for(:mcp)
|
@@ -117,16 +118,28 @@ module ActionMCP
|
|
117
118
|
raise "Invalid MCP config file" unless app_config.is_a?(Hash)
|
118
119
|
|
119
120
|
# Extract authentication configuration if present
|
120
|
-
|
121
|
-
|
122
|
-
# Extract OAuth configuration if present
|
123
|
-
@oauth_config = HashWithIndifferentAccess.new(app_config["oauth"]) if app_config["oauth"]
|
121
|
+
# Handle both symbol and string keys
|
122
|
+
@authentication_methods = Array(app_config[:authentication] || app_config["authentication"]) if app_config[:authentication] || app_config["authentication"]
|
124
123
|
|
125
124
|
# Extract other top-level configuration settings
|
126
125
|
extract_top_level_settings(app_config)
|
127
126
|
|
128
|
-
# Extract profiles configuration
|
129
|
-
|
127
|
+
# Extract profiles configuration - merge with defaults instead of replacing
|
128
|
+
# Rails.config_for returns OrderedOptions which uses symbol keys
|
129
|
+
if app_config[:profiles] || app_config["profiles"]
|
130
|
+
# Get profiles with either symbol or string key
|
131
|
+
app_profiles = app_config[:profiles] || app_config["profiles"]
|
132
|
+
|
133
|
+
# Convert to regular hash and deep symbolize keys
|
134
|
+
if app_profiles.is_a?(ActiveSupport::OrderedOptions)
|
135
|
+
app_profiles = app_profiles.to_h.deep_symbolize_keys
|
136
|
+
elsif app_profiles.respond_to?(:deep_symbolize_keys)
|
137
|
+
app_profiles = app_profiles.deep_symbolize_keys
|
138
|
+
end
|
139
|
+
|
140
|
+
Rails.logger.debug "[Configuration] Merging profiles: #{app_profiles.inspect}"
|
141
|
+
@profiles = @profiles.deep_merge(app_profiles)
|
142
|
+
end
|
130
143
|
rescue StandardError => e
|
131
144
|
# If the config file doesn't exist in the Rails app, just use the defaults
|
132
145
|
Rails.logger.warn "[Configuration] Failed to load MCP config: #{e.class} - #{e.message}"
|
@@ -134,8 +147,13 @@ module ActionMCP
|
|
134
147
|
end
|
135
148
|
|
136
149
|
# Apply the active profile
|
150
|
+
Rails.logger.info "[ActionMCP] Loaded profiles: #{@profiles.keys.join(', ')}"
|
151
|
+
Rails.logger.info "[ActionMCP] Using profile: #{@active_profile}"
|
137
152
|
use_profile(@active_profile)
|
138
153
|
|
154
|
+
# Restore preserved settings
|
155
|
+
@name = preserved_name if preserved_name
|
156
|
+
|
139
157
|
self
|
140
158
|
end
|
141
159
|
|
@@ -184,6 +202,9 @@ module ActionMCP
|
|
184
202
|
capabilities = {}
|
185
203
|
profile = @profiles[active_profile]
|
186
204
|
|
205
|
+
Rails.logger.debug "[ActionMCP] Generating capabilities for profile: #{active_profile}"
|
206
|
+
Rails.logger.debug "[ActionMCP] Profile config: #{profile.inspect}"
|
207
|
+
|
187
208
|
# Check profile configuration instead of registry contents
|
188
209
|
# If profile includes tools (either "all" or specific tools), advertise tools capability
|
189
210
|
capabilities[:tools] = { listChanged: @list_changed } if profile && profile[:tools]&.any?
|
@@ -268,42 +289,48 @@ module ActionMCP
|
|
268
289
|
end
|
269
290
|
|
270
291
|
def extract_top_level_settings(app_config)
|
292
|
+
# Create a wrapper that handles both symbol and string keys
|
293
|
+
config = HashWithIndifferentAccess.new(app_config)
|
294
|
+
|
271
295
|
# Extract adapter configuration
|
272
|
-
if
|
296
|
+
if config["adapter"]
|
273
297
|
# This will be handled by the pub/sub system, we just store it for now
|
274
|
-
@adapter =
|
298
|
+
@adapter = config["adapter"]
|
275
299
|
end
|
276
300
|
|
277
301
|
# Extract thread pool settings
|
278
|
-
@min_threads =
|
302
|
+
@min_threads = config["min_threads"] if config["min_threads"]
|
279
303
|
|
280
|
-
@max_threads =
|
304
|
+
@max_threads = config["max_threads"] if config["max_threads"]
|
281
305
|
|
282
|
-
@max_queue =
|
306
|
+
@max_queue = config["max_queue"] if config["max_queue"]
|
283
307
|
|
284
308
|
# Extract polling interval for solid_cable
|
285
|
-
@polling_interval =
|
309
|
+
@polling_interval = config["polling_interval"] if config["polling_interval"]
|
286
310
|
|
287
311
|
# Extract connects_to setting
|
288
|
-
@connects_to =
|
312
|
+
@connects_to = config["connects_to"] if config["connects_to"]
|
289
313
|
|
290
314
|
# Extract verbose logging setting
|
291
|
-
@verbose_logging =
|
315
|
+
@verbose_logging = config["verbose_logging"] if app_config.key?("verbose_logging")
|
292
316
|
|
293
317
|
# Extract gateway class configuration
|
294
|
-
@gateway_class_name =
|
318
|
+
@gateway_class_name = config["gateway_class"] if config["gateway_class"]
|
319
|
+
|
320
|
+
# Extract active profile setting
|
321
|
+
@active_profile = config["profile"].to_sym if config["profile"]
|
295
322
|
|
296
323
|
# Extract session store configuration
|
297
|
-
@session_store_type =
|
324
|
+
@session_store_type = config["session_store_type"].to_sym if config["session_store_type"]
|
298
325
|
|
299
326
|
# Extract client and server session store types
|
300
|
-
if
|
301
|
-
@client_session_store_type =
|
327
|
+
if config["client_session_store_type"]
|
328
|
+
@client_session_store_type = config["client_session_store_type"].to_sym
|
302
329
|
end
|
303
330
|
|
304
|
-
return unless
|
331
|
+
return unless config["server_session_store_type"]
|
305
332
|
|
306
|
-
@server_session_store_type =
|
333
|
+
@server_session_store_type = config["server_session_store_type"].to_sym
|
307
334
|
end
|
308
335
|
|
309
336
|
def should_include_all?(type)
|
data/lib/action_mcp/engine.rb
CHANGED
@@ -12,7 +12,6 @@ module ActionMCP
|
|
12
12
|
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
13
13
|
inflect.acronym "SSE"
|
14
14
|
inflect.acronym "MCP"
|
15
|
-
inflect.acronym "OAuth"
|
16
15
|
end
|
17
16
|
|
18
17
|
# Provide a configuration namespace for ActionMCP
|
@@ -54,12 +53,6 @@ module ActionMCP
|
|
54
53
|
ActionMCP.configuration.load_profiles
|
55
54
|
end
|
56
55
|
|
57
|
-
# Add OAuth middleware if OAuth is configured
|
58
|
-
initializer "action_mcp.oauth_middleware", after: "action_mcp.load_profiles" do
|
59
|
-
if ActionMCP.configuration.authentication_methods&.include?("oauth")
|
60
|
-
config.middleware.use ActionMCP::OAuth::Middleware
|
61
|
-
end
|
62
|
-
end
|
63
56
|
|
64
57
|
# Configure autoloading for the mcp/tools directory and identifiers
|
65
58
|
initializer "action_mcp.autoloading", before: :set_autoload_paths do |app|
|
@@ -3,11 +3,7 @@
|
|
3
3
|
module ActionMCP
|
4
4
|
# Custom logger that filters out repetitive MCP requests
|
5
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
|
6
|
+
FILTERED_PATHS = [].freeze
|
11
7
|
|
12
8
|
FILTERED_METHODS = [
|
13
9
|
"notifications/initialized",
|
@@ -15,7 +11,7 @@ module ActionMCP
|
|
15
11
|
].freeze
|
16
12
|
|
17
13
|
def add(severity, message = nil, progname = nil, &block)
|
18
|
-
# Filter out
|
14
|
+
# Filter out specific paths
|
19
15
|
if message.is_a?(String)
|
20
16
|
return if FILTERED_PATHS.any? { |path| message.include?(path) && message.include?("200 OK") }
|
21
17
|
|
@@ -1,29 +1,213 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module ActionMCP
|
4
|
+
# Base class for Gateway authentication identifiers.
|
5
|
+
#
|
6
|
+
# Gateway identifiers provide a clean interface for authentication by reading
|
7
|
+
# from request.env keys set by upstream middleware (like Warden, Devise, or custom auth).
|
8
|
+
#
|
9
|
+
# @example Warden/Devise Integration
|
10
|
+
# class WardenIdentifier < ActionMCP::GatewayIdentifier
|
11
|
+
# identifier :user
|
12
|
+
# authenticates :warden
|
13
|
+
#
|
14
|
+
# def resolve
|
15
|
+
# # Warden sets 'warden.user' in request.env after authentication
|
16
|
+
# user = user_from_middleware
|
17
|
+
# user || raise(Unauthorized, "No authenticated user found")
|
18
|
+
# end
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# @example API Key Authentication
|
22
|
+
# class ApiKeyIdentifier < ActionMCP::GatewayIdentifier
|
23
|
+
# identifier :user
|
24
|
+
# authenticates :api_key
|
25
|
+
#
|
26
|
+
# def resolve
|
27
|
+
# # Check for API key in header or query param
|
28
|
+
# api_key = @request.env['HTTP_X_API_KEY'] ||
|
29
|
+
# @request.params['api_key']
|
30
|
+
# return raise(Unauthorized, "Missing API key") unless api_key
|
31
|
+
#
|
32
|
+
# user = User.find_by(api_key: api_key)
|
33
|
+
# user || raise(Unauthorized, "Invalid API key")
|
34
|
+
# end
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# @example Session-based Authentication
|
38
|
+
# class SessionIdentifier < ActionMCP::GatewayIdentifier
|
39
|
+
# identifier :user
|
40
|
+
# authenticates :session
|
41
|
+
#
|
42
|
+
# def resolve
|
43
|
+
# user_id = session&.[]('user_id')
|
44
|
+
# return raise(Unauthorized, "No user session") unless user_id
|
45
|
+
#
|
46
|
+
# user = User.find_by(id: user_id)
|
47
|
+
# user || raise(Unauthorized, "Invalid session")
|
48
|
+
# end
|
49
|
+
# end
|
50
|
+
#
|
51
|
+
# @example Multi-tenant with Organization
|
52
|
+
# class TenantIdentifier < ActionMCP::GatewayIdentifier
|
53
|
+
# identifier :user
|
54
|
+
# authenticates :tenant
|
55
|
+
#
|
56
|
+
# def resolve
|
57
|
+
# # Get user from middleware
|
58
|
+
# user = user_from_middleware
|
59
|
+
# return raise(Unauthorized, "No user found") unless user
|
60
|
+
#
|
61
|
+
# # Check tenant header
|
62
|
+
# tenant_id = @request.env['HTTP_X_TENANT_ID']
|
63
|
+
# return raise(Unauthorized, "Missing tenant") unless tenant_id
|
64
|
+
#
|
65
|
+
# # Verify user has access to tenant
|
66
|
+
# unless user.tenants.exists?(id: tenant_id)
|
67
|
+
# raise Unauthorized, "Access denied for tenant"
|
68
|
+
# end
|
69
|
+
#
|
70
|
+
# # Set current tenant for the request
|
71
|
+
# Current.tenant = Tenant.find(tenant_id)
|
72
|
+
# user
|
73
|
+
# end
|
74
|
+
# end
|
75
|
+
#
|
76
|
+
# @example Development/Testing Bypass
|
77
|
+
# class DevIdentifier < ActionMCP::GatewayIdentifier
|
78
|
+
# identifier :user
|
79
|
+
# authenticates :dev
|
80
|
+
#
|
81
|
+
# def resolve
|
82
|
+
# return raise(Unauthorized, "Dev auth disabled in production") unless development_env?
|
83
|
+
#
|
84
|
+
# # Create or find dev user
|
85
|
+
# User.find_or_create_by!(email: "dev@localhost") do |user|
|
86
|
+
# user.name = "Development User"
|
87
|
+
# end
|
88
|
+
# end
|
89
|
+
# end
|
4
90
|
class GatewayIdentifier
|
5
91
|
class Unauthorized < StandardError; end
|
6
92
|
|
7
93
|
class << self
|
8
|
-
# e.g
|
9
|
-
attr_reader :identifier_name
|
94
|
+
# @return [Symbol] The name of the identity this identifier provides (e.g., :user, :admin)
|
95
|
+
attr_reader :identifier_name
|
10
96
|
|
97
|
+
# @return [String] The authentication method this identifier handles (e.g., "session", "api_key")
|
98
|
+
attr_reader :auth_method
|
99
|
+
|
100
|
+
# Declares what identity attribute this identifier provides.
|
101
|
+
# This becomes the accessor name on the Gateway instance.
|
102
|
+
#
|
103
|
+
# @param name [Symbol, String] The identity name (e.g., :user, :admin)
|
104
|
+
# @example
|
105
|
+
# identifier :user
|
106
|
+
# # Gateway instance will have gateway.user accessor
|
11
107
|
def identifier(name)
|
12
108
|
@identifier_name = name.to_sym
|
13
109
|
end
|
14
110
|
|
111
|
+
# Declares what authentication method this identifier handles.
|
112
|
+
# This should match values in your authentication_methods configuration.
|
113
|
+
#
|
114
|
+
# @param method [Symbol, String] The auth method name (e.g., :session, :api_key)
|
115
|
+
# @example
|
116
|
+
# authenticates :api_key
|
117
|
+
# # Matches authentication_methods: ["api_key"] in config
|
15
118
|
def authenticates(method)
|
16
119
|
@auth_method = method.to_s
|
17
120
|
end
|
18
121
|
end
|
19
122
|
|
123
|
+
# @param request [ActionDispatch::Request] The request object containing env hash
|
20
124
|
def initialize(request)
|
21
125
|
@request = request
|
22
126
|
end
|
23
127
|
|
24
|
-
#
|
128
|
+
# Resolves the identity for this authentication method.
|
129
|
+
# Must return a truthy identity object, or raise Unauthorized.
|
130
|
+
#
|
131
|
+
# Common request.env keys set by popular auth middleware:
|
132
|
+
# - 'warden.user' - Warden (used by Devise)
|
133
|
+
# - 'devise.user' - Devise direct
|
134
|
+
# - 'rack.session' - Rack session hash
|
135
|
+
# - 'HTTP_AUTHORIZATION' - Authorization header
|
136
|
+
# - Custom keys set by your middleware
|
137
|
+
#
|
138
|
+
# @return [Object] The authenticated identity (User, Admin, etc.)
|
139
|
+
# @raise [Unauthorized] When authentication fails
|
140
|
+
# @abstract Subclasses must implement this method
|
25
141
|
def resolve
|
26
142
|
raise NotImplementedError, "#{self.class}#resolve must be implemented"
|
27
143
|
end
|
144
|
+
|
145
|
+
protected
|
146
|
+
|
147
|
+
# Helper method to extract Bearer token from Authorization header
|
148
|
+
# @return [String, nil] The token without "Bearer " prefix
|
149
|
+
def extract_bearer_token
|
150
|
+
header = @request.env["HTTP_AUTHORIZATION"] || ""
|
151
|
+
header[/\ABearer (.+)\z/, 1]
|
152
|
+
end
|
153
|
+
|
154
|
+
# Helper method to extract Basic auth credentials from Authorization header
|
155
|
+
# @return [Array<String>, nil] [username, password] or nil if not Basic auth
|
156
|
+
def extract_basic_auth
|
157
|
+
header = @request.env["HTTP_AUTHORIZATION"] || ""
|
158
|
+
return nil unless header.start_with?("Basic ")
|
159
|
+
|
160
|
+
encoded = header[6..-1] # Remove "Basic " prefix
|
161
|
+
decoded = Base64.decode64(encoded)
|
162
|
+
decoded.split(":", 2) # Split on first colon only
|
163
|
+
rescue StandardError
|
164
|
+
nil
|
165
|
+
end
|
166
|
+
|
167
|
+
# Helper method to get user from common middleware env keys
|
168
|
+
# @return [Object, nil] User from Warden/Devise or nil
|
169
|
+
def user_from_middleware
|
170
|
+
@request.env["warden.user"] || @request.env["devise.user"]
|
171
|
+
end
|
172
|
+
|
173
|
+
# Helper method to get session hash
|
174
|
+
# @return [Hash, nil] Rack session hash or nil
|
175
|
+
def session
|
176
|
+
@request.env["rack.session"]
|
177
|
+
end
|
178
|
+
|
179
|
+
# Helper method to read custom env key with optional fallbacks
|
180
|
+
# @param keys [String, Array<String>] Primary key or array of keys to try
|
181
|
+
# @return [Object, nil] Value from request.env or nil
|
182
|
+
def env_value(*keys)
|
183
|
+
keys.flatten.each do |key|
|
184
|
+
value = @request.env[key]
|
185
|
+
return value if value
|
186
|
+
end
|
187
|
+
nil
|
188
|
+
end
|
189
|
+
|
190
|
+
# Helper method to extract API key from various common locations
|
191
|
+
# @param header_name [String] Custom header name (default: 'HTTP_X_API_KEY')
|
192
|
+
# @param param_name [String] Query/form parameter name (default: 'api_key')
|
193
|
+
# @return [String, nil] The API key or nil
|
194
|
+
def extract_api_key(header_name: "HTTP_X_API_KEY", param_name: "api_key")
|
195
|
+
# Try custom header first
|
196
|
+
api_key = @request.env[header_name]
|
197
|
+
return api_key if api_key
|
198
|
+
|
199
|
+
# Try Authorization header with "Bearer" prefix
|
200
|
+
bearer_token = extract_bearer_token
|
201
|
+
return bearer_token if bearer_token
|
202
|
+
|
203
|
+
# Try request parameters (query string or form data)
|
204
|
+
@request.params[param_name] if @request.respond_to?(:params)
|
205
|
+
end
|
206
|
+
|
207
|
+
# Helper method to check if user is in a development environment
|
208
|
+
# @return [Boolean] true if Rails.env.development?
|
209
|
+
def development_env?
|
210
|
+
Rails.env.development?
|
211
|
+
end
|
28
212
|
end
|
29
213
|
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module GatewayIdentifiers
|
5
|
+
# Example Gateway identifier for API key-based authentication.
|
6
|
+
#
|
7
|
+
# This identifier looks for API keys in various locations:
|
8
|
+
# - Authorization header (Bearer token)
|
9
|
+
# - Custom X-API-Key header
|
10
|
+
# - Query parameters
|
11
|
+
#
|
12
|
+
# @example Usage in ApplicationGateway
|
13
|
+
# class ApplicationGateway < ActionMCP::Gateway
|
14
|
+
# identified_by ActionMCP::GatewayIdentifiers::ApiKeyIdentifier
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# @example Configuration
|
18
|
+
# # config/mcp.yml
|
19
|
+
# authentication_methods: ["api_key"]
|
20
|
+
#
|
21
|
+
# @example API Key usage
|
22
|
+
# # Via Authorization header:
|
23
|
+
# # Authorization: Bearer your-api-key-here
|
24
|
+
# #
|
25
|
+
# # Via custom header:
|
26
|
+
# # X-API-Key: your-api-key-here
|
27
|
+
# #
|
28
|
+
# # Via query parameter:
|
29
|
+
# # ?api_key=your-api-key-here
|
30
|
+
class ApiKeyIdentifier < ActionMCP::GatewayIdentifier
|
31
|
+
identifier :user
|
32
|
+
authenticates :api_key
|
33
|
+
|
34
|
+
def resolve
|
35
|
+
api_key = extract_api_key
|
36
|
+
raise Unauthorized, "Missing API key" unless api_key
|
37
|
+
|
38
|
+
# Look up user by API key
|
39
|
+
# Assumes you have an api_key or api_token field on your User model
|
40
|
+
user = User.find_by(api_key: api_key) || User.find_by(api_token: api_key)
|
41
|
+
raise Unauthorized, "Invalid API key" unless user
|
42
|
+
|
43
|
+
# Optional: Check if API key is still valid (not expired, user active, etc.)
|
44
|
+
if user.respond_to?(:api_key_expired?) && user.api_key_expired?
|
45
|
+
raise Unauthorized, "API key expired"
|
46
|
+
end
|
47
|
+
|
48
|
+
if user.respond_to?(:active?) && !user.active?
|
49
|
+
raise Unauthorized, "User account inactive"
|
50
|
+
end
|
51
|
+
|
52
|
+
user
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module GatewayIdentifiers
|
5
|
+
# Example Gateway identifier for direct Devise integration.
|
6
|
+
#
|
7
|
+
# This identifier looks for the user directly in request.env['devise.user'] which
|
8
|
+
# may be set by custom Devise middleware or helpers.
|
9
|
+
#
|
10
|
+
# @example Usage in ApplicationGateway
|
11
|
+
# class ApplicationGateway < ActionMCP::Gateway
|
12
|
+
# identified_by ActionMCP::GatewayIdentifiers::DeviseIdentifier
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# @example Configuration
|
16
|
+
# # config/mcp.yml
|
17
|
+
# authentication_methods: ["devise"]
|
18
|
+
#
|
19
|
+
# @example Custom Devise middleware setup
|
20
|
+
# # In your application, you might set this in a before_action:
|
21
|
+
# # request.env['devise.user'] = current_user if user_signed_in?
|
22
|
+
class DeviseIdentifier < ActionMCP::GatewayIdentifier
|
23
|
+
identifier :user
|
24
|
+
authenticates :devise
|
25
|
+
|
26
|
+
def resolve
|
27
|
+
user = @request.env["devise.user"]
|
28
|
+
raise Unauthorized, "Not authenticated" unless user
|
29
|
+
|
30
|
+
user
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|