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.
Files changed (56) 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/app/models/concerns/{mcp_console_helpers.rb → action_mcp/mcp_console_helpers.rb} +4 -3
  10. data/app/models/concerns/{mcp_message_inspect.rb → action_mcp/mcp_message_inspect.rb} +4 -3
  11. data/config/routes.rb +0 -13
  12. data/db/migrate/20250727000001_remove_oauth_support.rb +59 -0
  13. data/lib/action_mcp/client/streamable_http_transport.rb +1 -46
  14. data/lib/action_mcp/client.rb +2 -25
  15. data/lib/action_mcp/configuration.rb +51 -24
  16. data/lib/action_mcp/engine.rb +0 -7
  17. data/lib/action_mcp/filtered_logger.rb +2 -6
  18. data/lib/action_mcp/gateway_identifier.rb +187 -3
  19. data/lib/action_mcp/gateway_identifiers/api_key_identifier.rb +56 -0
  20. data/lib/action_mcp/gateway_identifiers/devise_identifier.rb +34 -0
  21. data/lib/action_mcp/gateway_identifiers/request_env_identifier.rb +58 -0
  22. data/lib/action_mcp/gateway_identifiers/warden_identifier.rb +38 -0
  23. data/lib/action_mcp/gateway_identifiers.rb +26 -0
  24. data/lib/action_mcp/resource_template.rb +1 -0
  25. data/lib/action_mcp/server/base_session.rb +2 -0
  26. data/lib/action_mcp/server/resources.rb +8 -7
  27. data/lib/action_mcp/version.rb +1 -1
  28. data/lib/action_mcp.rb +1 -6
  29. data/lib/generators/action_mcp/identifier/identifier_generator.rb +189 -0
  30. data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +35 -0
  31. data/lib/generators/action_mcp/install/install_generator.rb +1 -1
  32. data/lib/generators/action_mcp/install/templates/application_gateway.rb +80 -31
  33. data/lib/generators/action_mcp/install/templates/mcp.yml +4 -21
  34. metadata +15 -99
  35. data/app/controllers/action_mcp/oauth/endpoints_controller.rb +0 -265
  36. data/app/controllers/action_mcp/oauth/metadata_controller.rb +0 -125
  37. data/app/controllers/action_mcp/oauth/registration_controller.rb +0 -201
  38. data/app/models/action_mcp/oauth_client.rb +0 -159
  39. data/app/models/action_mcp/oauth_token.rb +0 -142
  40. data/db/migrate/20250608112101_add_oauth_to_sessions.rb +0 -28
  41. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +0 -44
  42. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +0 -39
  43. data/lib/action_mcp/client/jwt_client_provider.rb +0 -135
  44. data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +0 -47
  45. data/lib/action_mcp/client/oauth_client_provider.rb +0 -234
  46. data/lib/action_mcp/jwt_decoder.rb +0 -28
  47. data/lib/action_mcp/jwt_identifier.rb +0 -28
  48. data/lib/action_mcp/none_identifier.rb +0 -19
  49. data/lib/action_mcp/o_auth_identifier.rb +0 -34
  50. data/lib/action_mcp/oauth/active_record_storage.rb +0 -183
  51. data/lib/action_mcp/oauth/error.rb +0 -79
  52. data/lib/action_mcp/oauth/memory_storage.rb +0 -132
  53. data/lib/action_mcp/oauth/middleware.rb +0 -128
  54. data/lib/action_mcp/oauth/provider.rb +0 -406
  55. data/lib/action_mcp/oauth.rb +0 -12
  56. data/lib/action_mcp/omniauth/mcp_strategy.rb +0 -162
@@ -1,406 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
- require "digest"
5
- require "base64"
6
-
7
- module ActionMCP
8
- module OAuth
9
- # OAuth 2.1 Provider implementation
10
- # Handles authorization codes, access tokens, refresh tokens, and token validation
11
- class Provider
12
- class << self
13
- # Generate authorization code for OAuth flow
14
- # @param client_id [String] OAuth client identifier
15
- # @param redirect_uri [String] Client redirect URI
16
- # @param scope [String] Requested scope
17
- # @param code_challenge [String] PKCE code challenge
18
- # @param code_challenge_method [String] PKCE challenge method (S256, plain)
19
- # @param user_id [String] User identifier
20
- # @return [String] Authorization code
21
- def generate_authorization_code(client_id:, redirect_uri:, scope:, user_id:, code_challenge: nil,
22
- code_challenge_method: nil)
23
- # Validate scope
24
- validate_scope(scope) if scope
25
-
26
- code = SecureRandom.urlsafe_base64(32)
27
-
28
- # Store authorization code with metadata
29
- store_authorization_code(code, {
30
- client_id: client_id,
31
- redirect_uri: redirect_uri,
32
- scope: scope,
33
- code_challenge: code_challenge,
34
- code_challenge_method: code_challenge_method,
35
- user_id: user_id,
36
- created_at: Time.current,
37
- expires_at: 10.minutes.from_now
38
- })
39
-
40
- code
41
- end
42
-
43
- # Exchange authorization code for access token
44
- # @param code [String] Authorization code
45
- # @param client_id [String] OAuth client identifier
46
- # @param client_secret [String] OAuth client secret (optional for public clients)
47
- # @param redirect_uri [String] Client redirect URI
48
- # @param code_verifier [String] PKCE code verifier
49
- # @return [Hash] Token response with access_token, token_type, expires_in, scope
50
- def exchange_code_for_token(code:, client_id:, redirect_uri:, client_secret: nil, code_verifier: nil)
51
- # Retrieve and validate authorization code
52
- code_data = retrieve_authorization_code(code)
53
- raise InvalidGrantError, "Invalid authorization code" unless code_data
54
- raise InvalidGrantError, "Authorization code expired" if code_data[:expires_at] < Time.current
55
-
56
- # Validate client
57
- validate_client(client_id, client_secret)
58
-
59
- # Validate redirect URI matches
60
- raise InvalidGrantError, "Redirect URI mismatch" unless code_data[:redirect_uri] == redirect_uri
61
-
62
- # Validate client ID matches
63
- raise InvalidGrantError, "Client ID mismatch" unless code_data[:client_id] == client_id
64
-
65
- # Validate PKCE if challenge was provided during authorization
66
- if code_data[:code_challenge]
67
- validate_pkce(code_data[:code_challenge], code_data[:code_challenge_method], code_verifier)
68
- end
69
-
70
- # Generate access token
71
- access_token = generate_access_token(
72
- client_id: client_id,
73
- scope: code_data[:scope],
74
- user_id: code_data[:user_id]
75
- )
76
-
77
- # Generate refresh token if enabled
78
- refresh_token = nil
79
- if oauth_config[:enable_refresh_tokens]
80
- refresh_token = generate_refresh_token(
81
- client_id: client_id,
82
- scope: code_data[:scope],
83
- user_id: code_data[:user_id],
84
- access_token: access_token
85
- )
86
- end
87
-
88
- # Remove used authorization code
89
- remove_authorization_code(code)
90
-
91
- # Return token response
92
- response = {
93
- access_token: access_token,
94
- token_type: "Bearer",
95
- expires_in: token_expires_in,
96
- scope: code_data[:scope]
97
- }
98
- response[:refresh_token] = refresh_token if refresh_token
99
- response
100
- end
101
-
102
- # Refresh access token using refresh token
103
- # @param refresh_token [String] Refresh token
104
- # @param client_id [String] OAuth client identifier
105
- # @param client_secret [String] OAuth client secret
106
- # @param scope [String] Requested scope (optional, must be subset of original)
107
- # @return [Hash] New token response
108
- def refresh_access_token(refresh_token:, client_id:, client_secret: nil, scope: nil)
109
- # Retrieve refresh token data
110
- token_data = retrieve_refresh_token(refresh_token)
111
- raise InvalidGrantError, "Invalid refresh token" unless token_data
112
- raise InvalidGrantError, "Refresh token expired" if token_data[:expires_at] < Time.current
113
-
114
- # Validate client
115
- validate_client(client_id, client_secret)
116
-
117
- # Validate client ID matches
118
- raise InvalidGrantError, "Client ID mismatch" unless token_data[:client_id] == client_id
119
-
120
- # Validate scope if provided
121
- if scope
122
- requested_scopes = scope.split(" ")
123
- original_scopes = token_data[:scope].split(" ")
124
- unless (requested_scopes - original_scopes).empty?
125
- raise InvalidScopeError, "Requested scope exceeds original scope"
126
- end
127
- else
128
- scope = token_data[:scope]
129
- end
130
-
131
- # Revoke old access token
132
- revoke_access_token(token_data[:access_token]) if token_data[:access_token]
133
-
134
- # Generate new access token
135
- access_token = generate_access_token(
136
- client_id: client_id,
137
- scope: scope,
138
- user_id: token_data[:user_id]
139
- )
140
-
141
- # Update refresh token with new access token
142
- update_refresh_token(refresh_token, access_token)
143
-
144
- {
145
- access_token: access_token,
146
- token_type: "Bearer",
147
- expires_in: token_expires_in,
148
- scope: scope
149
- }
150
- end
151
-
152
- # Validate access token and return token info
153
- # @param access_token [String] Access token to validate
154
- # @return [Hash] Token info with active, client_id, scope, user_id, exp
155
- def introspect_token(access_token)
156
- token_data = retrieve_access_token(access_token)
157
-
158
- return { active: false } unless token_data
159
-
160
- if token_data[:expires_at] < Time.current
161
- remove_access_token(access_token)
162
- return { active: false }
163
- end
164
-
165
- {
166
- active: true,
167
- client_id: token_data[:client_id],
168
- scope: token_data[:scope],
169
- user_id: token_data[:user_id],
170
- exp: token_data[:expires_at].to_i,
171
- iat: token_data[:created_at].to_i,
172
- token_type: "Bearer"
173
- }
174
- end
175
-
176
- # Revoke access or refresh token
177
- # @param token [String] Token to revoke
178
- # @param token_type_hint [String] Type hint: "access_token" or "refresh_token"
179
- # @return [Boolean] True if token was revoked
180
- def revoke_token(token, token_type_hint: nil)
181
- revoked = false
182
-
183
- # Try access token first if hint suggests it or no hint provided
184
- if (token_type_hint == "access_token" || token_type_hint.nil?) && retrieve_access_token(token)
185
- revoke_access_token(token)
186
- revoked = true
187
- end
188
-
189
- # Try refresh token if not revoked yet
190
- if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?) && retrieve_refresh_token(token)
191
- revoke_refresh_token(token)
192
- revoked = true
193
- end
194
-
195
- revoked
196
- end
197
-
198
- # Register a new OAuth client (Dynamic Client Registration)
199
- # @param client_info [Hash] Client registration information
200
- # @return [Hash] Registered client information
201
- def register_client(client_info)
202
- # Store client registration
203
- storage.store_client_registration(client_info[:client_id], client_info)
204
- client_info
205
- end
206
-
207
- # Retrieve registered client information
208
- # @param client_id [String] OAuth client identifier
209
- # @return [Hash, nil] Client information or nil if not found
210
- def get_client(client_id)
211
- storage.retrieve_client_registration(client_id)
212
- end
213
-
214
- # Client Credentials Grant (for server-to-server)
215
- # @param client_id [String] OAuth client identifier
216
- # @param client_secret [String] OAuth client secret
217
- # @param scope [String] Requested scope
218
- # @return [Hash] Token response
219
- def client_credentials_grant(client_id:, client_secret:, scope: nil)
220
- unless oauth_config[:enable_client_credentials]
221
- raise UnsupportedGrantTypeError, "Client credentials grant not supported"
222
- end
223
-
224
- # Validate client credentials
225
- validate_client(client_id, client_secret, require_secret: true)
226
-
227
- # Validate scope
228
- if scope
229
- validate_scope(scope)
230
- else
231
- scope = default_scope
232
- end
233
-
234
- # Generate access token (no user context for client credentials)
235
- access_token = generate_access_token(
236
- client_id: client_id,
237
- scope: scope,
238
- user_id: nil
239
- )
240
-
241
- {
242
- access_token: access_token,
243
- token_type: "Bearer",
244
- expires_in: token_expires_in,
245
- scope: scope
246
- }
247
- end
248
-
249
- private
250
-
251
- def oauth_config
252
- @oauth_config ||= HashWithIndifferentAccess.new(ActionMCP.configuration.oauth_config || {})
253
- end
254
-
255
- def validate_client(client_id, client_secret, require_secret: false)
256
- # First check if client is registered via dynamic registration
257
- client_info = get_client(client_id)
258
- if client_info
259
- # Validate client secret for confidential clients
260
- if client_info[:client_secret]
261
- raise InvalidClientError, "Invalid client credentials" unless client_secret == client_info[:client_secret]
262
- elsif require_secret
263
- raise InvalidClientError, "Client authentication required"
264
- end
265
- return true
266
- end
267
-
268
- # Fall back to custom provider validation
269
- provider_class = oauth_config[:provider]
270
- if provider_class.respond_to?(:validate_client)
271
- provider_class.validate_client(client_id, client_secret)
272
- elsif require_secret && client_secret.nil?
273
- raise InvalidClientError, "Client authentication required"
274
- else
275
- # In development, allow unregistered clients if configured
276
- return true if Rails.env.development? && oauth_config[:allow_unregistered_clients] != false
277
-
278
- raise InvalidClientError, "Unknown client"
279
- end
280
- end
281
-
282
- def validate_pkce(code_challenge, method, code_verifier)
283
- raise InvalidGrantError, "Code verifier required" unless code_verifier
284
-
285
- case method
286
- when "S256"
287
- expected_challenge = Base64.urlsafe_encode64(
288
- Digest::SHA256.digest(code_verifier), padding: false
289
- )
290
- raise InvalidGrantError, "Invalid code verifier" unless code_challenge == expected_challenge
291
- when "plain"
292
- raise InvalidGrantError, "Plain PKCE not allowed" unless oauth_config[:allow_plain_pkce]
293
- raise InvalidGrantError, "Invalid code verifier" unless code_challenge == code_verifier
294
- else
295
- raise InvalidGrantError, "Unsupported code challenge method"
296
- end
297
- end
298
-
299
- def validate_scope(scope)
300
- supported_scopes = oauth_config.fetch(:scopes_supported, [ "mcp:tools", "mcp:resources", "mcp:prompts" ])
301
- requested_scopes = scope.split(" ")
302
- unsupported = requested_scopes - supported_scopes
303
- return unless unsupported.any?
304
-
305
- raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
306
- end
307
-
308
- def default_scope
309
- oauth_config.fetch(:default_scope, "mcp:tools mcp:resources mcp:prompts")
310
- end
311
-
312
- def generate_access_token(client_id:, scope:, user_id:)
313
- token = SecureRandom.urlsafe_base64(32)
314
-
315
- store_access_token(token, {
316
- client_id: client_id,
317
- scope: scope,
318
- user_id: user_id,
319
- created_at: Time.current,
320
- expires_at: token_expires_in.seconds.from_now
321
- })
322
-
323
- token
324
- end
325
-
326
- def generate_refresh_token(client_id:, scope:, user_id:, access_token:)
327
- token = SecureRandom.urlsafe_base64(32)
328
-
329
- store_refresh_token(token, {
330
- client_id: client_id,
331
- scope: scope,
332
- user_id: user_id,
333
- access_token: access_token,
334
- created_at: Time.current,
335
- expires_at: refresh_token_expires_in.seconds.from_now
336
- })
337
-
338
- token
339
- end
340
-
341
- def token_expires_in
342
- oauth_config.fetch(:access_token_expires_in, 3600) # 1 hour
343
- end
344
-
345
- def refresh_token_expires_in
346
- oauth_config.fetch(:refresh_token_expires_in, 7.days.to_i) # 1 week
347
- end
348
-
349
- # Storage methods - these delegate to a configurable storage backend
350
- def storage
351
- @storage ||= begin
352
- # Default to ActiveRecord storage for production, memory for test
353
- default_storage = Rails.env.test? ? "ActionMCP::OAuth::MemoryStorage" : "ActionMCP::OAuth::ActiveRecordStorage"
354
- storage_class = oauth_config.fetch(:storage, default_storage)
355
- storage_class = storage_class.constantize if storage_class.is_a?(String)
356
- storage_class.new
357
- end
358
- end
359
-
360
- def store_authorization_code(code, data)
361
- storage.store_authorization_code(code, data)
362
- end
363
-
364
- def retrieve_authorization_code(code)
365
- storage.retrieve_authorization_code(code)
366
- end
367
-
368
- def remove_authorization_code(code)
369
- storage.remove_authorization_code(code)
370
- end
371
-
372
- def store_access_token(token, data)
373
- storage.store_access_token(token, data)
374
- end
375
-
376
- def retrieve_access_token(token)
377
- storage.retrieve_access_token(token)
378
- end
379
-
380
- def remove_access_token(token)
381
- storage.remove_access_token(token)
382
- end
383
-
384
- def revoke_access_token(token)
385
- storage.remove_access_token(token)
386
- end
387
-
388
- def store_refresh_token(token, data)
389
- storage.store_refresh_token(token, data)
390
- end
391
-
392
- def retrieve_refresh_token(token)
393
- storage.retrieve_refresh_token(token)
394
- end
395
-
396
- def update_refresh_token(token, new_access_token)
397
- storage.update_refresh_token(token, new_access_token)
398
- end
399
-
400
- def revoke_refresh_token(token)
401
- storage.remove_refresh_token(token)
402
- end
403
- end
404
- end
405
- end
406
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module OAuth
5
- # Load OAuth components
6
- autoload :Error, "action_mcp/oauth/error"
7
- autoload :Provider, "action_mcp/oauth/provider"
8
- autoload :Middleware, "action_mcp/oauth/middleware"
9
- autoload :MemoryStorage, "action_mcp/oauth/memory_storage"
10
- autoload :ActiveRecordStorage, "action_mcp/oauth/active_record_storage"
11
- end
12
- end
@@ -1,162 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "omniauth-oauth2"
4
-
5
- module ActionMCP
6
- module Omniauth
7
- # MCP-specific Omniauth strategy for OAuth 2.1 authentication
8
- # This strategy integrates with ActionMCP's configuration system and provider interface
9
- class MCPStrategy < ::OmniAuth::Strategies::OAuth2
10
- # Strategy name used in configuration
11
- option :name, "mcp"
12
-
13
- # Default OAuth options with MCP-specific settings
14
- option :client_options, {
15
- authorize_url: "/oauth/authorize",
16
- token_url: "/oauth/token",
17
- auth_scheme: :request_body
18
- }
19
-
20
- # OAuth 2.1 compliance - PKCE is required
21
- option :pkce, true
22
-
23
- # Default scopes for MCP access
24
- option :scope, "mcp:tools mcp:resources mcp:prompts"
25
-
26
- # Use authorization code grant flow
27
- option :response_type, "code"
28
-
29
- # OAuth server metadata discovery
30
- option :discovery, true
31
-
32
- def initialize(app, *args, &block)
33
- super
34
-
35
- # Load configuration from ActionMCP if available
36
- configure_from_mcp_config if defined?(ActionMCP)
37
- end
38
-
39
- # User info from OAuth token response or userinfo endpoint
40
- def raw_info
41
- @raw_info ||= if options.userinfo_url
42
- access_token.get(options.userinfo_url).parsed
43
- else
44
- # Extract user info from token response or use minimal info
45
- access_token.token
46
- {
47
- "sub" => access_token.params["user_id"] || access_token.token,
48
- "scope" => access_token.params["scope"] || options.scope
49
- }
50
- end
51
- rescue ::OAuth2::Error => e
52
- log(:error, "Failed to fetch user info: #{e.message}")
53
- {}
54
- end
55
-
56
- # User ID for Omniauth
57
- uid { raw_info["sub"] || raw_info["user_id"] }
58
-
59
- # User info hash
60
- info do
61
- {
62
- name: raw_info["name"],
63
- email: raw_info["email"],
64
- username: raw_info["username"] || raw_info["preferred_username"]
65
- }
66
- end
67
-
68
- # Extra credentials and token info
69
- extra do
70
- {
71
- "raw_info" => raw_info,
72
- "scope" => access_token.params["scope"],
73
- "token_type" => access_token.params["token_type"] || "Bearer"
74
- }
75
- end
76
-
77
- # OAuth server metadata discovery
78
- def discovery_info
79
- @discovery_info ||= begin
80
- if options.discovery && options.client_options.site
81
- discovery_url = "#{options.client_options.site}/.well-known/oauth-authorization-server"
82
- response = client.request(:get, discovery_url)
83
- JSON.parse(response.body)
84
- end
85
- rescue StandardError => e
86
- log(:warn, "OAuth discovery failed: #{e.message}")
87
- {}
88
- end
89
- end
90
-
91
- # Override client to use discovered endpoints if available
92
- def client
93
- @client ||= begin
94
- if discovery_info.any? && discovery_info["authorization_endpoint"] && discovery_info["token_endpoint"]
95
- options.client_options.merge!(
96
- authorize_url: discovery_info["authorization_endpoint"],
97
- token_url: discovery_info["token_endpoint"]
98
- )
99
- end
100
- super
101
- end
102
- end
103
-
104
- # Token validation for API requests (not callback flow)
105
- def self.validate_token(token, options = {})
106
- strategy = new(nil, options)
107
- strategy.validate_token(token)
108
- end
109
-
110
- def validate_token(token)
111
- # Validate access token with OAuth server
112
- return nil unless token
113
-
114
- begin
115
- response = client.request(:post, options.introspection_url || "/oauth/introspect", {
116
- body: { token: token },
117
- headers: { "Content-Type" => "application/x-www-form-urlencoded" }
118
- })
119
-
120
- token_info = JSON.parse(response.body)
121
- return nil unless token_info["active"]
122
-
123
- token_info
124
- rescue StandardError => e
125
- log(:error, "Token validation failed: #{e.message}")
126
- nil
127
- end
128
- end
129
-
130
- private
131
-
132
- # Configure strategy from ActionMCP configuration
133
- def configure_from_mcp_config
134
- oauth_config = ActionMCP.configuration.oauth_config
135
- return unless oauth_config.is_a?(Hash)
136
-
137
- # Set client options from MCP config
138
- options.client_options[:site] = oauth_config["issuer_url"] if oauth_config["issuer_url"]
139
-
140
- options.client_id = oauth_config["client_id"] if oauth_config["client_id"]
141
-
142
- options.client_secret = oauth_config["client_secret"] if oauth_config["client_secret"]
143
-
144
- options.scope = Array(oauth_config["scopes_supported"]).join(" ") if oauth_config["scopes_supported"]
145
-
146
- # Enable PKCE if required (OAuth 2.1 compliance)
147
- options.pkce = true if oauth_config["pkce_required"]
148
-
149
- # Set userinfo endpoint if provided
150
- options.userinfo_url = oauth_config["userinfo_endpoint"] if oauth_config["userinfo_endpoint"]
151
-
152
- # Set token introspection endpoint
153
- return unless oauth_config["introspection_endpoint"]
154
-
155
- options.introspection_url = oauth_config["introspection_endpoint"]
156
- end
157
- end
158
- end
159
- end
160
-
161
- # Register the strategy with Omniauth
162
- OmniAuth.config.add_camelization "mcp", "MCP"