actionmcp 0.52.2 → 0.53.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a11c68a5511cb3cafd2f90ae24aa31e2099f41c8b4ee3d357719574f598752d4
4
- data.tar.gz: 145398e069f03f6bd2697a16efca476d47983ccdcf84f3730f9ea0b0223eec03
3
+ metadata.gz: 961fd09ccf447057085e6e63d38c64079b460917d48d70c5ad5dc53ee0c3154f
4
+ data.tar.gz: c74e249d91903199a65afa5ecad8ff55bf188aa7e48167742c26aa20396913cf
5
5
  SHA512:
6
- metadata.gz: 9bb02d28a5b99c70bde877ab7e481619c26d55f68b47fbebc40e384400fc445c4683e04e35ac6bc9f837028bb5af9ec84928662fb8fd93e80e3a7bcec446e6ad
7
- data.tar.gz: 78c11ff2bc200f7a482ffefc516b2ab2ad487399b37685d3fe49cd907de6b1e6a3aafd891ea584b3b2387c4567c4d58c1ed9db5d6aaca35b065ac613f505c4d8
6
+ metadata.gz: 0f3637e231ef5d5f668d548c41a3d71c121946b4002d56faf27b6fe1941e8f5fb2cf3ed70b0bb49a370055124777ef7300bf3bec174cfa2802c321bbd56f95b2
7
+ data.tar.gz: d1066a9642357aa64e77e0bcbdfb99205dbbbe27140262d41aa0bc61aad900e007d6da5a0a136ed116e101fba365066184bb16602cff3caed3c755ddfca8d9bc
data/README.md CHANGED
@@ -493,6 +493,8 @@ This will create `config/mcp.yml` with example configurations for all environmen
493
493
 
494
494
  ActionMCP provides a Gateway system similar to ActionCable's Connection for handling authentication. The Gateway allows you to authenticate users and make them available throughout your MCP components.
495
495
 
496
+ ActionMCP supports multiple authentication methods including OAuth 2.1, JWT tokens, and no authentication for development. For detailed OAuth 2.1 configuration and usage, see the [OAuth Authentication Guide](OAUTH.md).
497
+
496
498
  ### Creating an ApplicationGateway
497
499
 
498
500
  When you run the install generator, it creates an `ApplicationGateway` class:
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module ActionMCP
6
+ module OAuth
7
+ # OAuth 2.1 endpoints controller
8
+ # Handles authorization, token, introspection, and revocation endpoints
9
+ class EndpointsController < ActionController::Base
10
+ protect_from_forgery with: :null_session
11
+ before_action :check_oauth_enabled
12
+
13
+ # GET /oauth/authorize
14
+ # Authorization endpoint for OAuth 2.1 authorization code flow
15
+ def authorize
16
+ # Extract parameters
17
+ client_id = params[:client_id]
18
+ redirect_uri = params[:redirect_uri]
19
+ response_type = params[:response_type]
20
+ scope = params[:scope]
21
+ state = params[:state]
22
+ code_challenge = params[:code_challenge]
23
+ code_challenge_method = params[:code_challenge_method]
24
+
25
+ # Validate required parameters
26
+ if client_id.blank? || redirect_uri.blank? || response_type.blank?
27
+ return render_error("invalid_request", "Missing required parameters")
28
+ end
29
+
30
+ # Validate response type
31
+ unless response_type == "code"
32
+ return render_error("unsupported_response_type", "Only authorization code flow supported")
33
+ end
34
+
35
+ # Validate PKCE if required
36
+ if oauth_config["pkce_required"] && code_challenge.blank?
37
+ return render_error("invalid_request", "PKCE required")
38
+ end
39
+
40
+ # In a real implementation, this would show a consent page
41
+ # For now, we'll auto-approve for configured clients
42
+ if auto_approve_client?(client_id)
43
+ # Generate authorization code
44
+ user_id = current_user&.id || "anonymous"
45
+
46
+ begin
47
+ code = ActionMCP::OAuth::Provider.generate_authorization_code(
48
+ client_id: client_id,
49
+ redirect_uri: redirect_uri,
50
+ scope: scope || default_scope,
51
+ code_challenge: code_challenge,
52
+ code_challenge_method: code_challenge_method,
53
+ user_id: user_id
54
+ )
55
+
56
+ # Redirect back to client with authorization code
57
+ redirect_params = { code: code }
58
+ redirect_params[:state] = state if state
59
+ redirect_to "#{redirect_uri}?#{redirect_params.to_query}", allow_other_host: true
60
+ rescue ActionMCP::OAuth::Error => e
61
+ render_error(e.oauth_error_code, e.message)
62
+ end
63
+ else
64
+ # In production, show consent page
65
+ render_consent_page(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
66
+ end
67
+ end
68
+
69
+ # POST /oauth/token
70
+ # Token endpoint for exchanging authorization codes and refreshing tokens
71
+ def token
72
+ grant_type = params[:grant_type]
73
+
74
+ case grant_type
75
+ when "authorization_code"
76
+ handle_authorization_code_grant
77
+ when "refresh_token"
78
+ handle_refresh_token_grant
79
+ when "client_credentials"
80
+ handle_client_credentials_grant
81
+ else
82
+ render_token_error("unsupported_grant_type", "Unsupported grant type")
83
+ end
84
+ rescue ActionMCP::OAuth::Error => e
85
+ render_token_error(e.oauth_error_code, e.message)
86
+ end
87
+
88
+ # POST /oauth/introspect
89
+ # Token introspection endpoint (RFC 7662)
90
+ def introspect
91
+ token = params[:token]
92
+ return render_introspection_error unless token
93
+
94
+ # Authenticate client for introspection
95
+ client_id, client_secret = extract_client_credentials
96
+ return render_introspection_error unless client_id
97
+
98
+ begin
99
+ token_info = ActionMCP::OAuth::Provider.introspect_token(token)
100
+ render json: token_info
101
+ rescue ActionMCP::OAuth::Error
102
+ render json: { active: false }
103
+ end
104
+ end
105
+
106
+ # POST /oauth/revoke
107
+ # Token revocation endpoint (RFC 7009)
108
+ def revoke
109
+ token = params[:token]
110
+ token_type_hint = params[:token_type_hint]
111
+
112
+ return head :bad_request unless token
113
+
114
+ # Authenticate client
115
+ client_id, client_secret = extract_client_credentials
116
+ return head :unauthorized unless client_id
117
+
118
+ begin
119
+ ActionMCP::OAuth::Provider.revoke_token(token, token_type_hint: token_type_hint)
120
+ head :ok
121
+ rescue ActionMCP::OAuth::Error
122
+ head :bad_request
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def check_oauth_enabled
129
+ auth_methods = ActionMCP.configuration.authentication_methods
130
+ unless auth_methods&.include?("oauth")
131
+ head :not_found
132
+ end
133
+ end
134
+
135
+ def oauth_config
136
+ @oauth_config ||= ActionMCP.configuration.oauth_config || {}
137
+ end
138
+
139
+ def handle_authorization_code_grant
140
+ code = params[:code]
141
+ client_id = params[:client_id]
142
+ client_secret = params[:client_secret]
143
+ redirect_uri = params[:redirect_uri]
144
+ code_verifier = params[:code_verifier]
145
+
146
+ # Extract client credentials from Authorization header if not in params
147
+ if client_id.blank?
148
+ client_id, client_secret = extract_client_credentials
149
+ end
150
+
151
+ return render_token_error("invalid_request", "Missing required parameters") if code.blank? || client_id.blank?
152
+
153
+ token_response = ActionMCP::OAuth::Provider.exchange_code_for_token(
154
+ code: code,
155
+ client_id: client_id,
156
+ client_secret: client_secret,
157
+ redirect_uri: redirect_uri,
158
+ code_verifier: code_verifier
159
+ )
160
+
161
+ render json: token_response
162
+ end
163
+
164
+ def handle_refresh_token_grant
165
+ refresh_token = params[:refresh_token]
166
+ scope = params[:scope]
167
+
168
+ # Extract client credentials
169
+ client_id, client_secret = extract_client_credentials
170
+ client_id ||= params[:client_id]
171
+ client_secret ||= params[:client_secret]
172
+
173
+ return render_token_error("invalid_request", "Missing required parameters") if refresh_token.blank? || client_id.blank?
174
+
175
+ token_response = ActionMCP::OAuth::Provider.refresh_access_token(
176
+ refresh_token: refresh_token,
177
+ client_id: client_id,
178
+ client_secret: client_secret,
179
+ scope: scope
180
+ )
181
+
182
+ render json: token_response
183
+ end
184
+
185
+ def handle_client_credentials_grant
186
+ scope = params[:scope]
187
+
188
+ # Extract client credentials
189
+ client_id, client_secret = extract_client_credentials
190
+ client_id ||= params[:client_id]
191
+ client_secret ||= params[:client_secret]
192
+
193
+ return render_token_error("invalid_request", "Missing client credentials") if client_id.blank?
194
+
195
+ token_response = ActionMCP::OAuth::Provider.client_credentials_grant(
196
+ client_id: client_id,
197
+ client_secret: client_secret,
198
+ scope: scope
199
+ )
200
+
201
+ render json: token_response
202
+ end
203
+
204
+ def extract_client_credentials
205
+ auth_header = request.headers["Authorization"]
206
+ if auth_header&.start_with?("Basic ")
207
+ encoded = auth_header.split(" ", 2).last
208
+ decoded = Base64.decode64(encoded)
209
+ decoded.split(":", 2)
210
+ else
211
+ [ nil, nil ]
212
+ end
213
+ end
214
+
215
+ def auto_approve_client?(client_id)
216
+ # In development/testing, auto-approve known clients
217
+ # In production, this should check a proper client registry
218
+ Rails.env.development? || Rails.env.test? || oauth_config["auto_approve_clients"]&.include?(client_id)
219
+ end
220
+
221
+ def current_user
222
+ # This should be implemented by the application
223
+ # For now, return a default user for development
224
+ if Rails.env.development? || Rails.env.test?
225
+ OpenStruct.new(id: "dev_user", email: "dev@example.com")
226
+ else
227
+ # In production, this should integrate with your authentication system
228
+ nil
229
+ end
230
+ end
231
+
232
+ def default_scope
233
+ oauth_config["default_scope"] || "mcp:tools mcp:resources mcp:prompts"
234
+ end
235
+
236
+ def render_error(error_code, description)
237
+ render json: {
238
+ error: error_code,
239
+ error_description: description
240
+ }, status: :bad_request
241
+ end
242
+
243
+ def render_token_error(error_code, description)
244
+ render json: {
245
+ error: error_code,
246
+ error_description: description
247
+ }, status: :bad_request
248
+ end
249
+
250
+ def render_introspection_error
251
+ render json: { active: false }, status: :bad_request
252
+ end
253
+
254
+ def render_consent_page(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
255
+ # In production, this would render a proper consent page
256
+ # For now, just auto-deny unknown clients
257
+ render json: {
258
+ error: "access_denied",
259
+ error_description: "User denied authorization"
260
+ }, status: :forbidden
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module OAuth
5
+ # Controller for OAuth 2.1 metadata endpoints
6
+ # Provides server discovery information as per RFC 8414
7
+ class MetadataController < ActionController::Base
8
+ before_action :check_oauth_enabled
9
+
10
+ # GET /.well-known/oauth-authorization-server
11
+ # Returns OAuth Authorization Server Metadata as per RFC 8414
12
+ def authorization_server
13
+ metadata = {
14
+ issuer: issuer_url,
15
+ authorization_endpoint: authorization_endpoint,
16
+ token_endpoint: token_endpoint,
17
+ introspection_endpoint: introspection_endpoint,
18
+ revocation_endpoint: revocation_endpoint,
19
+ response_types_supported: response_types_supported,
20
+ grant_types_supported: grant_types_supported,
21
+ token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
22
+ scopes_supported: scopes_supported,
23
+ code_challenge_methods_supported: code_challenge_methods_supported,
24
+ service_documentation: service_documentation
25
+ }
26
+
27
+ # Add optional fields based on configuration
28
+ if oauth_config["enable_dynamic_registration"]
29
+ metadata[:registration_endpoint] = registration_endpoint
30
+ end
31
+
32
+ if oauth_config["jwks_uri"]
33
+ metadata[:jwks_uri] = oauth_config["jwks_uri"]
34
+ end
35
+
36
+ render json: metadata
37
+ end
38
+
39
+ # GET /.well-known/oauth-protected-resource
40
+ # Returns Protected Resource Metadata as per RFC 8705
41
+ def protected_resource
42
+ metadata = {
43
+ resource: issuer_url,
44
+ authorization_servers: [ issuer_url ],
45
+ scopes_supported: scopes_supported,
46
+ bearer_methods_supported: [ "header" ],
47
+ resource_documentation: resource_documentation
48
+ }
49
+
50
+ render json: metadata
51
+ end
52
+
53
+ private
54
+
55
+ def check_oauth_enabled
56
+ auth_methods = ActionMCP.configuration.authentication_methods
57
+ unless auth_methods&.include?("oauth")
58
+ head :not_found
59
+ end
60
+ end
61
+
62
+ def oauth_config
63
+ @oauth_config ||= ActionMCP.configuration.oauth_config || {}
64
+ end
65
+
66
+ def issuer_url
67
+ @issuer_url ||= oauth_config["issuer_url"] || request.base_url
68
+ end
69
+
70
+ def authorization_endpoint
71
+ "#{issuer_url}/oauth/authorize"
72
+ end
73
+
74
+ def token_endpoint
75
+ "#{issuer_url}/oauth/token"
76
+ end
77
+
78
+ def introspection_endpoint
79
+ "#{issuer_url}/oauth/introspect"
80
+ end
81
+
82
+ def revocation_endpoint
83
+ "#{issuer_url}/oauth/revoke"
84
+ end
85
+
86
+ def registration_endpoint
87
+ "#{issuer_url}/oauth/register"
88
+ end
89
+
90
+ def response_types_supported
91
+ [ "code" ]
92
+ end
93
+
94
+ def grant_types_supported
95
+ grants = [ "authorization_code" ]
96
+ grants << "refresh_token" if oauth_config["enable_refresh_tokens"]
97
+ grants << "client_credentials" if oauth_config["enable_client_credentials"]
98
+ grants
99
+ end
100
+
101
+ def token_endpoint_auth_methods_supported
102
+ methods = [ "client_secret_basic", "client_secret_post" ]
103
+ methods << "none" if oauth_config["allow_public_clients"]
104
+ methods
105
+ end
106
+
107
+ def scopes_supported
108
+ oauth_config["scopes_supported"] || [ "mcp:tools", "mcp:resources", "mcp:prompts" ]
109
+ end
110
+
111
+ def code_challenge_methods_supported
112
+ methods = []
113
+ if oauth_config["pkce_required"] || oauth_config["pkce_supported"]
114
+ methods << "S256"
115
+ methods << "plain" if oauth_config["allow_plain_pkce"]
116
+ end
117
+ methods
118
+ end
119
+
120
+ def service_documentation
121
+ oauth_config["service_documentation"] || "#{request.base_url}/docs"
122
+ end
123
+
124
+ def resource_documentation
125
+ oauth_config["resource_documentation"] || "#{request.base_url}/docs/api"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -5,11 +5,16 @@
5
5
  # Table name: action_mcp_sessions
6
6
  #
7
7
  # id :string not null, primary key
8
+ # authentication_method :string default("none")
8
9
  # client_capabilities(The capabilities of the client) :jsonb
9
10
  # client_info(The information about the client) :jsonb
10
11
  # ended_at(The time the session ended) :datetime
11
12
  # initialized :boolean default(FALSE), not null
12
13
  # messages_count :integer default(0), not null
14
+ # oauth_access_token :string
15
+ # oauth_refresh_token :string
16
+ # oauth_token_expires_at :datetime
17
+ # oauth_user_context :jsonb
13
18
  # prompt_registry :jsonb
14
19
  # protocol_version :string
15
20
  # resource_registry :jsonb
@@ -22,6 +27,12 @@
22
27
  # created_at :datetime not null
23
28
  # updated_at :datetime not null
24
29
  #
30
+ # Indexes
31
+ #
32
+ # index_action_mcp_sessions_on_authentication_method (authentication_method)
33
+ # index_action_mcp_sessions_on_oauth_access_token (oauth_access_token) UNIQUE
34
+ # index_action_mcp_sessions_on_oauth_token_expires_at (oauth_token_expires_at)
35
+ #
25
36
  module ActionMCP
26
37
  ##
27
38
  # Represents an MCP session, which is a connection between a client and a server.
@@ -323,6 +334,94 @@ module ActionMCP
323
334
  end
324
335
  end
325
336
 
337
+ # OAuth Session Management
338
+ # Required by MCP 2025-03-26 specification for session binding
339
+
340
+ # Store OAuth token and user context in session
341
+ def store_oauth_token(access_token:, refresh_token: nil, expires_at:, user_context: {})
342
+ update!(
343
+ oauth_access_token: access_token,
344
+ oauth_refresh_token: refresh_token,
345
+ oauth_token_expires_at: expires_at,
346
+ oauth_user_context: user_context,
347
+ authentication_method: "oauth"
348
+ )
349
+ end
350
+
351
+ # Retrieve OAuth token information
352
+ def oauth_token_info
353
+ return nil unless oauth_access_token
354
+
355
+ {
356
+ access_token: oauth_access_token,
357
+ refresh_token: oauth_refresh_token,
358
+ expires_at: oauth_token_expires_at,
359
+ user_context: oauth_user_context || {},
360
+ authentication_method: authentication_method
361
+ }
362
+ end
363
+
364
+ # Check if OAuth token is valid and not expired
365
+ def oauth_token_valid?
366
+ return false unless oauth_access_token
367
+ return true unless oauth_token_expires_at
368
+
369
+ oauth_token_expires_at > Time.current
370
+ end
371
+
372
+ # Clear OAuth token data
373
+ def clear_oauth_token!
374
+ update!(
375
+ oauth_access_token: nil,
376
+ oauth_refresh_token: nil,
377
+ oauth_token_expires_at: nil,
378
+ oauth_user_context: nil,
379
+ authentication_method: "none"
380
+ )
381
+ end
382
+
383
+ # Update OAuth token (for refresh flow)
384
+ def update_oauth_token(access_token:, refresh_token: nil, expires_at:)
385
+ update!(
386
+ oauth_access_token: access_token,
387
+ oauth_refresh_token: refresh_token,
388
+ oauth_token_expires_at: expires_at
389
+ )
390
+ end
391
+
392
+ # Get user information from OAuth context
393
+ def oauth_user
394
+ return nil unless oauth_user_context.is_a?(Hash)
395
+
396
+ OpenStruct.new(oauth_user_context)
397
+ end
398
+
399
+ # Check if session is authenticated via OAuth
400
+ def oauth_authenticated?
401
+ authentication_method == "oauth" && oauth_token_valid?
402
+ end
403
+
404
+ # Find session by OAuth access token (class method)
405
+ def self.find_by_oauth_token(access_token)
406
+ find_by(oauth_access_token: access_token)
407
+ end
408
+
409
+ # Find sessions with expired OAuth tokens (class method)
410
+ def self.with_expired_oauth_tokens
411
+ where("oauth_token_expires_at IS NOT NULL AND oauth_token_expires_at < ?", Time.current)
412
+ end
413
+
414
+ # Cleanup expired OAuth tokens (class method)
415
+ def self.cleanup_expired_oauth_tokens
416
+ with_expired_oauth_tokens.update_all(
417
+ oauth_access_token: nil,
418
+ oauth_refresh_token: nil,
419
+ oauth_token_expires_at: nil,
420
+ oauth_user_context: nil,
421
+ authentication_method: "none"
422
+ )
423
+ end
424
+
326
425
  private
327
426
 
328
427
  # if this session is from a server, the writer is the client
data/config/routes.rb CHANGED
@@ -3,6 +3,16 @@
3
3
  ActionMCP::Engine.routes.draw do
4
4
  get "/up", to: "/rails/health#show", as: :action_mcp_health_check
5
5
 
6
+ # OAuth 2.1 metadata endpoints
7
+ get "/.well-known/oauth-authorization-server", to: "oauth/metadata#authorization_server", as: :oauth_authorization_server_metadata
8
+ get "/.well-known/oauth-protected-resource", to: "oauth/metadata#protected_resource", as: :oauth_protected_resource_metadata
9
+
10
+ # OAuth 2.1 endpoints
11
+ get "/oauth/authorize", to: "oauth/endpoints#authorize", as: :oauth_authorize
12
+ post "/oauth/token", to: "oauth/endpoints#token", as: :oauth_token
13
+ post "/oauth/introspect", to: "oauth/endpoints#introspect", as: :oauth_introspect
14
+ post "/oauth/revoke", to: "oauth/endpoints#revoke", as: :oauth_revoke
15
+
6
16
  # MCP 2025-03-26 Spec routes
7
17
  get "/", to: "application#show", as: :mcp_get
8
18
  post "/", to: "application#create", as: :mcp_post
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddOAuthToSessions < ActiveRecord::Migration[8.0]
4
+ def change
5
+ # Use jsonb for PostgreSQL, json for other databases (SQLite3, MySQL)
6
+ json_type = connection.adapter_name.downcase == 'postgresql' ? :jsonb : :json
7
+
8
+ add_column :action_mcp_sessions, :oauth_access_token, :string
9
+ add_column :action_mcp_sessions, :oauth_refresh_token, :string
10
+ add_column :action_mcp_sessions, :oauth_token_expires_at, :datetime
11
+ add_column :action_mcp_sessions, :oauth_user_context, json_type
12
+ add_column :action_mcp_sessions, :authentication_method, :string, default: "none"
13
+
14
+ # Add indexes for performance
15
+ add_index :action_mcp_sessions, :oauth_access_token, unique: true
16
+ add_index :action_mcp_sessions, :oauth_token_expires_at
17
+ add_index :action_mcp_sessions, :authentication_method
18
+ end
19
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ class OauthClientProvider
6
+ # Simple in-memory storage for development
7
+ # In production, use persistent storage
8
+ class MemoryStorage
9
+ def initialize
10
+ @data = {}
11
+ end
12
+
13
+ def save_tokens(tokens)
14
+ @data[:tokens] = tokens
15
+ end
16
+
17
+ def load_tokens
18
+ @data[:tokens]
19
+ end
20
+
21
+ def clear_tokens
22
+ @data.delete(:tokens)
23
+ end
24
+
25
+ def save_code_verifier(verifier)
26
+ @data[:code_verifier] = verifier
27
+ end
28
+
29
+ def load_code_verifier
30
+ @data[:code_verifier]
31
+ end
32
+
33
+ def clear_code_verifier
34
+ @data.delete(:code_verifier)
35
+ end
36
+
37
+ def save_client_information(info)
38
+ @data[:client_information] = info
39
+ end
40
+
41
+ def load_client_information
42
+ @data[:client_information]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end