actionmcp 0.72.0 → 0.80.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/app/controllers/action_mcp/application_controller.rb +20 -12
  4. data/app/models/action_mcp/session/message.rb +31 -20
  5. data/app/models/action_mcp/session/resource.rb +35 -20
  6. data/app/models/action_mcp/session/sse_event.rb +23 -17
  7. data/app/models/action_mcp/session/subscription.rb +22 -15
  8. data/app/models/action_mcp/session.rb +42 -119
  9. data/config/routes.rb +0 -13
  10. data/db/migrate/20250727000001_remove_oauth_support.rb +59 -0
  11. data/lib/action_mcp/client/streamable_http_transport.rb +1 -46
  12. data/lib/action_mcp/client.rb +2 -25
  13. data/lib/action_mcp/configuration.rb +51 -24
  14. data/lib/action_mcp/engine.rb +0 -7
  15. data/lib/action_mcp/filtered_logger.rb +2 -6
  16. data/lib/action_mcp/gateway_identifier.rb +187 -3
  17. data/lib/action_mcp/gateway_identifiers/api_key_identifier.rb +56 -0
  18. data/lib/action_mcp/gateway_identifiers/devise_identifier.rb +34 -0
  19. data/lib/action_mcp/gateway_identifiers/request_env_identifier.rb +58 -0
  20. data/lib/action_mcp/gateway_identifiers/warden_identifier.rb +38 -0
  21. data/lib/action_mcp/gateway_identifiers.rb +26 -0
  22. data/lib/action_mcp/server/base_session.rb +2 -0
  23. data/lib/action_mcp/version.rb +1 -1
  24. data/lib/action_mcp.rb +1 -6
  25. data/lib/generators/action_mcp/identifier/identifier_generator.rb +189 -0
  26. data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +35 -0
  27. data/lib/generators/action_mcp/install/install_generator.rb +1 -1
  28. data/lib/generators/action_mcp/install/templates/application_gateway.rb +80 -31
  29. data/lib/generators/action_mcp/install/templates/mcp.yml +4 -21
  30. metadata +13 -97
  31. data/app/controllers/action_mcp/oauth/endpoints_controller.rb +0 -265
  32. data/app/controllers/action_mcp/oauth/metadata_controller.rb +0 -125
  33. data/app/controllers/action_mcp/oauth/registration_controller.rb +0 -201
  34. data/app/models/action_mcp/oauth_client.rb +0 -159
  35. data/app/models/action_mcp/oauth_token.rb +0 -142
  36. data/db/migrate/20250608112101_add_oauth_to_sessions.rb +0 -28
  37. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +0 -44
  38. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +0 -39
  39. data/lib/action_mcp/client/jwt_client_provider.rb +0 -135
  40. data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +0 -47
  41. data/lib/action_mcp/client/oauth_client_provider.rb +0 -234
  42. data/lib/action_mcp/jwt_decoder.rb +0 -28
  43. data/lib/action_mcp/jwt_identifier.rb +0 -28
  44. data/lib/action_mcp/none_identifier.rb +0 -19
  45. data/lib/action_mcp/o_auth_identifier.rb +0 -34
  46. data/lib/action_mcp/oauth/active_record_storage.rb +0 -183
  47. data/lib/action_mcp/oauth/error.rb +0 -79
  48. data/lib/action_mcp/oauth/memory_storage.rb +0 -132
  49. data/lib/action_mcp/oauth/middleware.rb +0 -128
  50. data/lib/action_mcp/oauth/provider.rb +0 -406
  51. data/lib/action_mcp/oauth.rb +0 -12
  52. data/lib/action_mcp/omniauth/mcp_strategy.rb +0 -162
@@ -1,234 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "faraday"
4
- require "pkce_challenge"
5
- require "securerandom"
6
- require "uri"
7
- require "json"
8
-
9
- module ActionMCP
10
- module Client
11
- # OAuth client provider for MCP client authentication
12
- # Implements OAuth 2.1 authorization code flow with PKCE
13
- class OauthClientProvider
14
- class AuthenticationError < StandardError; end
15
- class TokenExpiredError < StandardError; end
16
- attr_reader :redirect_url, :client_metadata, :authorization_server_url
17
-
18
- def initialize(
19
- authorization_server_url:,
20
- redirect_url:,
21
- client_metadata: {},
22
- storage: nil,
23
- logger: ActionMCP.logger
24
- )
25
- @authorization_server_url = URI(authorization_server_url)
26
- @redirect_url = URI(redirect_url)
27
- @client_metadata = default_client_metadata.merge(client_metadata)
28
- @storage = storage || MemoryStorage.new
29
- @logger = logger
30
- @http_client = build_http_client
31
- end
32
-
33
- # Get current access token for authorization headers
34
- def access_token
35
- tokens = current_tokens
36
- return nil unless tokens
37
-
38
- if token_expired?(tokens)
39
- refresh_tokens! if tokens[:refresh_token]
40
- tokens = current_tokens
41
- end
42
-
43
- tokens&.dig(:access_token)
44
- end
45
-
46
- # Check if client has valid authentication
47
- def authenticated?
48
- !access_token.nil?
49
- end
50
-
51
- # Start OAuth authorization flow
52
- def start_authorization_flow(scope: nil, state: nil)
53
- # Generate PKCE challenge
54
- pkce = PkceChallenge.challenge
55
- code_verifier = pkce.code_verifier
56
- code_challenge = pkce.code_challenge
57
- @storage.save_code_verifier(code_verifier)
58
-
59
- # Build authorization URL
60
- auth_params = {
61
- response_type: "code",
62
- client_id: client_id,
63
- redirect_uri: @redirect_url.to_s,
64
- code_challenge: code_challenge,
65
- code_challenge_method: "S256"
66
- }
67
- auth_params[:scope] = scope if scope
68
- auth_params[:state] = state if state
69
-
70
- authorization_url = build_url(server_metadata[:authorization_endpoint], auth_params)
71
-
72
- log_debug("Starting OAuth flow: #{authorization_url}")
73
- authorization_url
74
- end
75
-
76
- # Complete OAuth flow with authorization code
77
- def complete_authorization_flow(authorization_code, state: nil)
78
- code_verifier = @storage.load_code_verifier
79
- raise AuthenticationError, "No code verifier found" unless code_verifier
80
-
81
- # Exchange code for tokens
82
- token_params = {
83
- grant_type: "authorization_code",
84
- code: authorization_code,
85
- redirect_uri: @redirect_url.to_s,
86
- code_verifier: code_verifier,
87
- client_id: client_id
88
- }
89
-
90
- response = @http_client.post(server_metadata[:token_endpoint]) do |req|
91
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
92
- req.headers["Accept"] = "application/json"
93
- req.body = URI.encode_www_form(token_params)
94
- end
95
-
96
- handle_token_response(response)
97
- end
98
-
99
- # Refresh access token using refresh token
100
- def refresh_tokens!
101
- tokens = current_tokens
102
- refresh_token = tokens&.dig(:refresh_token)
103
- raise TokenExpiredError, "No refresh token available" unless refresh_token
104
-
105
- token_params = {
106
- grant_type: "refresh_token",
107
- refresh_token: refresh_token,
108
- client_id: client_id
109
- }
110
-
111
- response = @http_client.post(server_metadata[:token_endpoint]) do |req|
112
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
113
- req.headers["Accept"] = "application/json"
114
- req.body = URI.encode_www_form(token_params)
115
- end
116
-
117
- handle_token_response(response)
118
- end
119
-
120
- # Clear stored tokens (logout)
121
- def clear_tokens!
122
- @storage.clear_tokens
123
- @storage.clear_code_verifier if @storage.respond_to?(:clear_code_verifier)
124
- log_debug("Cleared OAuth tokens and code verifier")
125
- end
126
-
127
- # Get client information for registration
128
- def client_information
129
- @storage.load_client_information
130
- end
131
-
132
- # Save client information after registration
133
- def save_client_information(client_info)
134
- @storage.save_client_information(client_info)
135
- end
136
-
137
- # Get authorization headers for HTTP requests
138
- def authorization_headers
139
- token = access_token
140
- return {} unless token
141
-
142
- { "Authorization" => "Bearer #{token}" }
143
- end
144
-
145
- private
146
-
147
- def current_tokens
148
- @storage.load_tokens
149
- end
150
-
151
- def save_tokens(tokens)
152
- @storage.save_tokens(tokens)
153
- end
154
-
155
- def token_expired?(tokens)
156
- expires_at = tokens[:expires_at]
157
- return false unless expires_at
158
-
159
- Time.at(expires_at) <= Time.now + 30 # 30 second buffer
160
- end
161
-
162
- def client_id
163
- client_info = client_information
164
- client_info&.dig(:client_id) || @client_metadata[:client_id]
165
- end
166
-
167
- def server_metadata
168
- @server_metadata ||= fetch_server_metadata
169
- end
170
-
171
- def fetch_server_metadata
172
- well_known_url = @authorization_server_url.dup
173
- well_known_url.path = "/.well-known/oauth-authorization-server"
174
-
175
- response = @http_client.get(well_known_url)
176
- raise AuthenticationError, "Failed to fetch server metadata: #{response.status}" unless response.success?
177
-
178
- JSON.parse(response.body, symbolize_names: true)
179
- end
180
-
181
- def handle_token_response(response)
182
- unless response.success?
183
- error_body = begin
184
- JSON.parse(response.body)
185
- rescue StandardError
186
- {}
187
- end
188
- error_msg = error_body["error_description"] || error_body["error"] || "Token request failed"
189
- raise AuthenticationError, "#{error_msg} (#{response.status})"
190
- end
191
-
192
- token_data = JSON.parse(response.body, symbolize_names: true)
193
-
194
- # Calculate token expiration
195
- token_data[:expires_at] = Time.now.to_i + token_data[:expires_in].to_i if token_data[:expires_in]
196
-
197
- save_tokens(token_data)
198
- log_debug("OAuth tokens obtained successfully")
199
- token_data
200
- end
201
-
202
- def build_url(base_url, params)
203
- uri = URI(base_url)
204
- uri.query = URI.encode_www_form(params)
205
- uri.to_s
206
- end
207
-
208
- def build_http_client
209
- Faraday.new do |f|
210
- f.headers["User-Agent"] = "ActionMCP-OAuth/#{ActionMCP.gem_version}"
211
- f.options.timeout = 30
212
- f.options.open_timeout = 10
213
- f.adapter :net_http
214
- end
215
- end
216
-
217
- def default_client_metadata
218
- {
219
- client_name: "ActionMCP Client",
220
- client_uri: "https://github.com/anthropics/action_mcp",
221
- redirect_uris: [ @redirect_url.to_s ],
222
- grant_types: %w[authorization_code refresh_token],
223
- response_types: [ "code" ],
224
- token_endpoint_auth_method: "none", # Public client
225
- code_challenge_methods_supported: [ "S256" ]
226
- }
227
- end
228
-
229
- def log_debug(message)
230
- @logger.debug("[ActionMCP::OAuthClientProvider] #{message}")
231
- end
232
- end
233
- end
234
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "jwt"
4
-
5
- module ActionMCP
6
- class JwtDecoder
7
- class DecodeError < StandardError; end
8
-
9
- # Configurable defaults
10
- class << self
11
- attr_accessor :secret, :algorithm
12
-
13
- def decode(token)
14
- payload, _header = JWT.decode(token, secret, true, { algorithm: algorithm })
15
- payload
16
- rescue JWT::ExpiredSignature
17
- raise DecodeError, "Token has expired"
18
- rescue JWT::DecodeError
19
- # Simplify the error message for invalid tokens
20
- raise DecodeError, "Invalid token"
21
- end
22
- end
23
-
24
- # Defaults (can be overridden in an initializer)
25
- self.secret = ENV.fetch("ACTION_MCP_JWT_SECRET", "change-me")
26
- self.algorithm = "HS256"
27
- end
28
- end
@@ -1,28 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,34 +0,0 @@
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
@@ -1,183 +0,0 @@
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
@@ -1,79 +0,0 @@
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