actionmcp 0.52.2 → 0.54.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.
- checksums.yaml +4 -4
- data/README.md +14 -7
- data/app/controllers/action_mcp/oauth/endpoints_controller.rb +264 -0
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +129 -0
- data/app/models/action_mcp/session/message.rb +12 -12
- data/app/models/action_mcp/session.rb +116 -17
- data/config/routes.rb +10 -0
- data/db/migrate/20250512154359_consolidated_migration.rb +15 -12
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +19 -0
- data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +47 -0
- data/lib/action_mcp/client/oauth_client_provider.rb +234 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +25 -1
- data/lib/action_mcp/client.rb +15 -2
- data/lib/action_mcp/configuration.rb +65 -6
- data/lib/action_mcp/engine.rb +8 -0
- data/lib/action_mcp/gateway.rb +95 -6
- data/lib/action_mcp/oauth/error.rb +79 -0
- data/lib/action_mcp/oauth/memory_storage.rb +112 -0
- data/lib/action_mcp/oauth/middleware.rb +100 -0
- data/lib/action_mcp/oauth/provider.rb +390 -0
- data/lib/action_mcp/omniauth/mcp_strategy.rb +176 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +6 -1
- data/lib/generators/action_mcp/install/install_generator.rb +32 -1
- data/lib/generators/action_mcp/install/templates/mcp.yml +96 -19
- metadata +81 -3
- data/lib/generators/action_mcp/config/config_generator.rb +0 -28
- data/lib/generators/action_mcp/config/templates/mcp.yml +0 -36
data/lib/action_mcp/gateway.rb
CHANGED
@@ -49,13 +49,22 @@ module ActionMCP
|
|
49
49
|
protected
|
50
50
|
|
51
51
|
def authenticate!
|
52
|
-
|
53
|
-
|
52
|
+
auth_methods = ActionMCP.configuration.authentication_methods || [ "jwt" ]
|
53
|
+
|
54
|
+
auth_methods.each do |method|
|
55
|
+
case method
|
56
|
+
when "none"
|
57
|
+
return default_user_identity
|
58
|
+
when "jwt"
|
59
|
+
result = jwt_authenticate
|
60
|
+
return result if result
|
61
|
+
when "oauth"
|
62
|
+
result = oauth_authenticate
|
63
|
+
return result if result
|
64
|
+
end
|
65
|
+
end
|
54
66
|
|
55
|
-
|
56
|
-
resolve_user(payload)
|
57
|
-
rescue ActionMCP::JwtDecoder::DecodeError => e
|
58
|
-
raise UnauthorizedError, e.message
|
67
|
+
raise UnauthorizedError, "No valid authentication found"
|
59
68
|
end
|
60
69
|
|
61
70
|
def extract_bearer_token
|
@@ -81,5 +90,85 @@ module ActionMCP
|
|
81
90
|
def reject_unauthorized_connection
|
82
91
|
raise UnauthorizedError, "Unauthorized"
|
83
92
|
end
|
93
|
+
|
94
|
+
# Default user identity for "none" authentication
|
95
|
+
def default_user_identity
|
96
|
+
# Return a hash with all identified_by attributes set to a default user
|
97
|
+
self.class.identifiers.each_with_object({}) do |identifier, hash|
|
98
|
+
if identifier == :user
|
99
|
+
# Create or find a default user for development
|
100
|
+
hash[identifier] = find_or_create_default_user
|
101
|
+
end
|
102
|
+
# Add support for other identifiers as needed
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# JWT authentication (existing implementation)
|
107
|
+
def jwt_authenticate
|
108
|
+
token = extract_bearer_token
|
109
|
+
unless token
|
110
|
+
raise UnauthorizedError, "Missing token" if ActionMCP.configuration.authentication_methods == [ "jwt" ]
|
111
|
+
return nil
|
112
|
+
end
|
113
|
+
|
114
|
+
payload = ActionMCP::JwtDecoder.decode(token)
|
115
|
+
result = resolve_user(payload)
|
116
|
+
unless result
|
117
|
+
raise UnauthorizedError, "Unauthorized" if ActionMCP.configuration.authentication_methods == [ "jwt" ]
|
118
|
+
return nil
|
119
|
+
end
|
120
|
+
result
|
121
|
+
rescue ActionMCP::JwtDecoder::DecodeError => e
|
122
|
+
if ActionMCP.configuration.authentication_methods == [ "jwt" ]
|
123
|
+
raise UnauthorizedError, "Invalid token"
|
124
|
+
else
|
125
|
+
nil # Let it try other authentication methods
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# OAuth authentication via middleware
|
130
|
+
def oauth_authenticate
|
131
|
+
return nil unless oauth_enabled?
|
132
|
+
|
133
|
+
# Check if OAuth middleware has already validated the token
|
134
|
+
token_info = request.env["action_mcp.oauth_token_info"]
|
135
|
+
return nil unless token_info && token_info["active"]
|
136
|
+
|
137
|
+
resolve_user_from_oauth(token_info)
|
138
|
+
rescue ActionMCP::OAuth::Error
|
139
|
+
nil # Let it try other authentication methods
|
140
|
+
end
|
141
|
+
|
142
|
+
def oauth_enabled?
|
143
|
+
ActionMCP.configuration.authentication_methods&.include?("oauth") &&
|
144
|
+
ActionMCP.configuration.oauth_config.present?
|
145
|
+
end
|
146
|
+
|
147
|
+
def resolve_user_from_oauth(token_info)
|
148
|
+
return nil unless token_info.is_a?(Hash)
|
149
|
+
|
150
|
+
user_id = token_info["sub"] || token_info["user_id"]
|
151
|
+
return nil unless user_id
|
152
|
+
|
153
|
+
user = User.find_by(id: user_id) || User.find_by(oauth_subject: user_id)
|
154
|
+
return nil unless user
|
155
|
+
|
156
|
+
# Return a hash with all identified_by attributes
|
157
|
+
self.class.identifiers.each_with_object({}) do |identifier, hash|
|
158
|
+
hash[identifier] = user if identifier == :user
|
159
|
+
# Add support for other identifiers as needed
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def find_or_create_default_user
|
164
|
+
# Only for development/testing with "none" authentication
|
165
|
+
return nil unless Rails.env.development? || Rails.env.test?
|
166
|
+
|
167
|
+
if defined?(User)
|
168
|
+
User.find_or_create_by(email: "dev@localhost") do |user|
|
169
|
+
user.name = "Development User" if user.respond_to?(:name=)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
84
173
|
end
|
85
174
|
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module OAuth
|
5
|
+
# Base OAuth error class
|
6
|
+
class Error < StandardError
|
7
|
+
attr_reader :oauth_error_code
|
8
|
+
|
9
|
+
def initialize(message, oauth_error_code = "invalid_request")
|
10
|
+
super(message)
|
11
|
+
@oauth_error_code = oauth_error_code
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# OAuth 2.1 standard error types
|
16
|
+
class InvalidRequestError < Error
|
17
|
+
def initialize(message = "Invalid request")
|
18
|
+
super(message, "invalid_request")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class InvalidClientError < Error
|
23
|
+
def initialize(message = "Invalid client")
|
24
|
+
super(message, "invalid_client")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class InvalidGrantError < Error
|
29
|
+
def initialize(message = "Invalid grant")
|
30
|
+
super(message, "invalid_grant")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class UnauthorizedClientError < Error
|
35
|
+
def initialize(message = "Unauthorized client")
|
36
|
+
super(message, "unauthorized_client")
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class UnsupportedGrantTypeError < Error
|
41
|
+
def initialize(message = "Unsupported grant type")
|
42
|
+
super(message, "unsupported_grant_type")
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
class InvalidScopeError < Error
|
47
|
+
def initialize(message = "Invalid scope")
|
48
|
+
super(message, "invalid_scope")
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class InvalidTokenError < Error
|
53
|
+
def initialize(message = "Invalid token")
|
54
|
+
super(message, "invalid_token")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class InsufficientScopeError < Error
|
59
|
+
attr_reader :required_scope
|
60
|
+
|
61
|
+
def initialize(message = "Insufficient scope", required_scope = nil)
|
62
|
+
super(message, "insufficient_scope")
|
63
|
+
@required_scope = required_scope
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class ServerError < Error
|
68
|
+
def initialize(message = "Server error")
|
69
|
+
super(message, "server_error")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
class TemporarilyUnavailableError < Error
|
74
|
+
def initialize(message = "Temporarily unavailable")
|
75
|
+
super(message, "temporarily_unavailable")
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module OAuth
|
5
|
+
# In-memory storage for OAuth tokens and codes
|
6
|
+
# This is suitable for development and testing, but not for production
|
7
|
+
class MemoryStorage
|
8
|
+
def initialize
|
9
|
+
@authorization_codes = {}
|
10
|
+
@access_tokens = {}
|
11
|
+
@refresh_tokens = {}
|
12
|
+
@mutex = Mutex.new
|
13
|
+
end
|
14
|
+
|
15
|
+
# Authorization code storage
|
16
|
+
def store_authorization_code(code, data)
|
17
|
+
@mutex.synchronize do
|
18
|
+
@authorization_codes[code] = data
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def retrieve_authorization_code(code)
|
23
|
+
@mutex.synchronize do
|
24
|
+
@authorization_codes[code]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def remove_authorization_code(code)
|
29
|
+
@mutex.synchronize do
|
30
|
+
@authorization_codes.delete(code)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Access token storage
|
35
|
+
def store_access_token(token, data)
|
36
|
+
@mutex.synchronize do
|
37
|
+
@access_tokens[token] = data
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def retrieve_access_token(token)
|
42
|
+
@mutex.synchronize do
|
43
|
+
@access_tokens[token]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def remove_access_token(token)
|
48
|
+
@mutex.synchronize do
|
49
|
+
@access_tokens.delete(token)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Refresh token storage
|
54
|
+
def store_refresh_token(token, data)
|
55
|
+
@mutex.synchronize do
|
56
|
+
@refresh_tokens[token] = data
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def retrieve_refresh_token(token)
|
61
|
+
@mutex.synchronize do
|
62
|
+
@refresh_tokens[token]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def update_refresh_token(token, new_access_token)
|
67
|
+
@mutex.synchronize do
|
68
|
+
if @refresh_tokens[token]
|
69
|
+
@refresh_tokens[token][:access_token] = new_access_token
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def remove_refresh_token(token)
|
75
|
+
@mutex.synchronize do
|
76
|
+
@refresh_tokens.delete(token)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Cleanup expired tokens (optional utility method)
|
81
|
+
def cleanup_expired
|
82
|
+
current_time = Time.current
|
83
|
+
|
84
|
+
@mutex.synchronize do
|
85
|
+
@authorization_codes.reject! { |_, data| data[:expires_at] < current_time }
|
86
|
+
@access_tokens.reject! { |_, data| data[:expires_at] < current_time }
|
87
|
+
@refresh_tokens.reject! { |_, data| data[:expires_at] < current_time }
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Statistics (for debugging/monitoring)
|
92
|
+
def stats
|
93
|
+
@mutex.synchronize do
|
94
|
+
{
|
95
|
+
authorization_codes: @authorization_codes.size,
|
96
|
+
access_tokens: @access_tokens.size,
|
97
|
+
refresh_tokens: @refresh_tokens.size
|
98
|
+
}
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Clear all data (for testing)
|
103
|
+
def clear_all
|
104
|
+
@mutex.synchronize do
|
105
|
+
@authorization_codes.clear
|
106
|
+
@access_tokens.clear
|
107
|
+
@refresh_tokens.clear
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module OAuth
|
5
|
+
# OAuth middleware that integrates with Omniauth for request authentication
|
6
|
+
# Handles Bearer token validation for API requests
|
7
|
+
class Middleware
|
8
|
+
def initialize(app)
|
9
|
+
@app = app
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(env)
|
13
|
+
request = ActionDispatch::Request.new(env)
|
14
|
+
|
15
|
+
# Skip OAuth processing for non-MCP requests or if OAuth not configured
|
16
|
+
return @app.call(env) unless should_process_oauth?(request)
|
17
|
+
|
18
|
+
|
19
|
+
# Validate Bearer token for API requests
|
20
|
+
if bearer_token = extract_bearer_token(request)
|
21
|
+
validate_oauth_token(request, bearer_token)
|
22
|
+
end
|
23
|
+
|
24
|
+
@app.call(env)
|
25
|
+
rescue ActionMCP::OAuth::Error => e
|
26
|
+
oauth_error_response(e)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def should_process_oauth?(request)
|
32
|
+
# Check if OAuth is enabled in configuration
|
33
|
+
auth_methods = ActionMCP.configuration.authentication_methods
|
34
|
+
return false unless auth_methods&.include?("oauth")
|
35
|
+
|
36
|
+
# Process all MCP requests (ActionMCP serves at root "/") and OAuth-related paths
|
37
|
+
true
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
def extract_bearer_token(request)
|
42
|
+
auth_header = request.headers["Authorization"] || request.headers["authorization"]
|
43
|
+
return nil unless auth_header&.start_with?("Bearer ")
|
44
|
+
|
45
|
+
auth_header.split(" ", 2).last
|
46
|
+
end
|
47
|
+
|
48
|
+
def validate_oauth_token(request, token)
|
49
|
+
# Use the OAuth provider for token introspection
|
50
|
+
token_info = ActionMCP::OAuth::Provider.introspect_token(token)
|
51
|
+
|
52
|
+
if token_info && token_info[:active]
|
53
|
+
# Store OAuth token info in request environment for Gateway
|
54
|
+
request.env["action_mcp.oauth_token_info"] = token_info
|
55
|
+
request.env["action_mcp.oauth_token"] = token
|
56
|
+
else
|
57
|
+
raise ActionMCP::OAuth::InvalidTokenError, "Invalid or expired OAuth token"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def oauth_error_response(error)
|
62
|
+
status = case error
|
63
|
+
when ActionMCP::OAuth::InvalidTokenError
|
64
|
+
401
|
65
|
+
when ActionMCP::OAuth::InsufficientScopeError
|
66
|
+
403
|
67
|
+
else
|
68
|
+
400
|
69
|
+
end
|
70
|
+
|
71
|
+
headers = {
|
72
|
+
"Content-Type" => "application/json",
|
73
|
+
"WWW-Authenticate" => www_authenticate_header(error)
|
74
|
+
}
|
75
|
+
|
76
|
+
body = {
|
77
|
+
error: error.oauth_error_code,
|
78
|
+
error_description: error.message
|
79
|
+
}.to_json
|
80
|
+
|
81
|
+
[ status, headers, [ body ] ]
|
82
|
+
end
|
83
|
+
|
84
|
+
def www_authenticate_header(error)
|
85
|
+
params = []
|
86
|
+
params << 'realm="MCP API"'
|
87
|
+
|
88
|
+
case error
|
89
|
+
when ActionMCP::OAuth::InvalidTokenError
|
90
|
+
params << 'error="invalid_token"'
|
91
|
+
when ActionMCP::OAuth::InsufficientScopeError
|
92
|
+
params << 'error="insufficient_scope"'
|
93
|
+
params << "scope=\"#{error.required_scope}\"" if error.required_scope
|
94
|
+
end
|
95
|
+
|
96
|
+
"Bearer #{params.join(', ')}"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|