actionmcp 0.70.0 → 0.71.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.
Files changed (43) 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/base.rb +3 -2
  17. data/lib/action_mcp/client/collection.rb +3 -3
  18. data/lib/action_mcp/client/jwt_client_provider.rb +134 -0
  19. data/lib/action_mcp/client/streamable_http_transport.rb +56 -10
  20. data/lib/action_mcp/client.rb +16 -4
  21. data/lib/action_mcp/configuration.rb +27 -4
  22. data/lib/action_mcp/engine.rb +7 -1
  23. data/lib/action_mcp/filtered_logger.rb +32 -0
  24. data/lib/action_mcp/gateway.rb +47 -133
  25. data/lib/action_mcp/gateway_identifier.rb +29 -0
  26. data/lib/action_mcp/jwt_identifier.rb +28 -0
  27. data/lib/action_mcp/none_identifier.rb +19 -0
  28. data/lib/action_mcp/o_auth_identifier.rb +34 -0
  29. data/lib/action_mcp/oauth/active_record_storage.rb +183 -0
  30. data/lib/action_mcp/oauth/memory_storage.rb +23 -1
  31. data/lib/action_mcp/oauth/middleware.rb +33 -0
  32. data/lib/action_mcp/oauth/provider.rb +49 -13
  33. data/lib/action_mcp/oauth.rb +12 -0
  34. data/lib/action_mcp/server/capabilities.rb +0 -3
  35. data/lib/action_mcp/server/resources.rb +1 -1
  36. data/lib/action_mcp/server/tools.rb +36 -24
  37. data/lib/action_mcp/sse_listener.rb +0 -7
  38. data/lib/action_mcp/test_helper.rb +5 -0
  39. data/lib/action_mcp/tool.rb +94 -4
  40. data/lib/action_mcp/tools_registry.rb +3 -0
  41. data/lib/action_mcp/version.rb +1 -1
  42. data/lib/generators/action_mcp/install/templates/mcp.yml +16 -16
  43. metadata +14 -1
@@ -5,170 +5,84 @@ module ActionMCP
5
5
 
6
6
  class Gateway
7
7
  class << self
8
- def identified_by(*attrs)
9
- @identifiers ||= []
10
- @identifiers.concat(attrs.map(&:to_sym)).uniq!
11
- attr_accessor(*attrs)
8
+ # pluck in one or many GatewayIdentifier classes
9
+ def identified_by(*klasses)
10
+ @identifier_classes = klasses.flatten
12
11
  end
13
12
 
14
- def identifiers
15
- @identifiers ||= []
13
+ def identifier_classes
14
+ @identifier_classes || []
16
15
  end
17
16
  end
18
17
 
19
- identified_by :user
20
-
21
- attr_reader :request
22
-
23
- def call(request)
18
+ def initialize(request)
24
19
  @request = request
25
- connect
26
- self
27
20
  end
28
21
 
29
- def connect
22
+ # called by your rack/websocket layer
23
+ def call
30
24
  identities = authenticate!
31
- reject_unauthorized_connection unless identities.is_a?(Hash)
32
-
33
- # Assign all identities (e.g., :user, :account)
34
- self.class.identifiers.each do |id|
35
- value = identities[id]
36
- reject_unauthorized_connection unless value
37
-
38
- public_send("#{id}=", value)
39
-
40
- # Set to ActionMCP::Current
41
- ActionMCP::Current.public_send("#{id}=", value)
42
- end
43
-
44
- # Also set the gateway instance itself
45
- ActionMCP::Current.gateway = self
25
+ assign_identities(identities)
26
+ self
46
27
  end
47
28
 
48
-
49
29
  protected
50
30
 
51
31
  def authenticate!
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
66
-
67
- raise UnauthorizedError, "No valid authentication found"
68
- end
69
-
70
- def extract_bearer_token
71
- header = request.headers["Authorization"] || request.headers["authorization"]
72
- return nil unless header&.start_with?("Bearer ")
73
- header.split(" ", 2).last
74
- end
32
+ active_identifiers = filter_active_identifiers
75
33
 
76
- def resolve_user(payload)
77
- return nil unless payload.is_a?(Hash)
78
- user_id = payload["user_id"] || payload["sub"]
79
- return nil unless user_id
80
- user = User.find_by(id: user_id)
81
- return nil unless user
82
-
83
- # Return a hash with all identified_by attributes
84
- self.class.identifiers.each_with_object({}) do |identifier, hash|
85
- hash[identifier] = user if identifier == :user
86
- # Add support for other identifiers as needed
34
+ if active_identifiers.empty?
35
+ raise ActionMCP::UnauthorizedError, "No authentication methods available"
87
36
  end
88
- end
89
-
90
- def reject_unauthorized_connection
91
- raise UnauthorizedError, "Unauthorized"
92
- end
93
37
 
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
38
+ # Try identifiers in order, use the first one that succeeds
39
+ last_error = nil
40
+ 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
101
48
  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
49
  end
113
50
 
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
51
+ # If we get here, all identifiers failed
52
+ # Use the last specific error message if available, otherwise generic message
53
+ error_message = last_error&.message || "Authentication failed"
54
+ raise ActionMCP::UnauthorizedError, error_message
127
55
  end
128
56
 
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"]
57
+ private
136
58
 
137
- resolve_user_from_oauth(token_info)
138
- rescue ActionMCP::OAuth::Error
139
- nil # Let it try other authentication methods
140
- end
59
+ def filter_active_identifiers
60
+ configured_methods = ActionMCP.configuration.authentication_methods || []
141
61
 
142
- def oauth_enabled?
143
- ActionMCP.configuration.authentication_methods&.include?("oauth") &&
144
- ActionMCP.configuration.oauth_config.present?
145
- end
62
+ # If no authentication methods configured, use all identifiers
63
+ return self.class.identifier_classes if configured_methods.empty?
146
64
 
147
- def resolve_user_from_oauth(token_info)
148
- return nil unless token_info.is_a?(Hash)
65
+ # Normalize configured methods to strings for consistent comparison
66
+ normalized_methods = configured_methods.map(&:to_s)
149
67
 
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
68
+ # Filter identifiers to only those matching configured authentication methods
69
+ self.class.identifier_classes.select do |klass|
70
+ normalized_methods.include?(klass.auth_method.to_s)
160
71
  end
161
72
  end
162
73
 
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?
74
+ def assign_identities(identities)
75
+ identities.each do |name, value|
76
+ # define accessor on the fly
77
+ self.class.attr_reader name unless respond_to?(name)
78
+ instance_variable_set("@#{name}", value)
166
79
 
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
80
+ # also set current context if you have one
81
+ ActionMCP::Current.public_send("#{name}=", value) if
82
+ ActionMCP::Current.respond_to?("#{name}=")
171
83
  end
84
+ ActionMCP::Current.gateway = self if
85
+ ActionMCP::Current.respond_to?(:gateway=)
172
86
  end
173
87
  end
174
88
  end
@@ -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
@@ -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
@@ -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"]