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.
@@ -0,0 +1,390 @@
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:, code_challenge: nil, code_challenge_method: nil, user_id:)
22
+ # Validate scope
23
+ validate_scope(scope) if scope
24
+
25
+ code = SecureRandom.urlsafe_base64(32)
26
+
27
+ # Store authorization code with metadata
28
+ store_authorization_code(code, {
29
+ client_id: client_id,
30
+ redirect_uri: redirect_uri,
31
+ scope: scope,
32
+ code_challenge: code_challenge,
33
+ code_challenge_method: code_challenge_method,
34
+ user_id: user_id,
35
+ created_at: Time.current,
36
+ expires_at: 10.minutes.from_now
37
+ })
38
+
39
+ code
40
+ end
41
+
42
+ # Exchange authorization code for access token
43
+ # @param code [String] Authorization code
44
+ # @param client_id [String] OAuth client identifier
45
+ # @param client_secret [String] OAuth client secret (optional for public clients)
46
+ # @param redirect_uri [String] Client redirect URI
47
+ # @param code_verifier [String] PKCE code verifier
48
+ # @return [Hash] Token response with access_token, token_type, expires_in, scope
49
+ def exchange_code_for_token(code:, client_id:, client_secret: nil, redirect_uri:, code_verifier: nil)
50
+ # Retrieve and validate authorization code
51
+ code_data = retrieve_authorization_code(code)
52
+ raise InvalidGrantError, "Invalid authorization code" unless code_data
53
+ raise InvalidGrantError, "Authorization code expired" if code_data[:expires_at] < Time.current
54
+
55
+ # Validate client
56
+ validate_client(client_id, client_secret)
57
+
58
+ # Validate redirect URI matches
59
+ unless code_data[:redirect_uri] == redirect_uri
60
+ raise InvalidGrantError, "Redirect URI mismatch"
61
+ end
62
+
63
+ # Validate client ID matches
64
+ unless code_data[:client_id] == client_id
65
+ raise InvalidGrantError, "Client ID mismatch"
66
+ end
67
+
68
+ # Validate PKCE if challenge was provided during authorization
69
+ if code_data[:code_challenge]
70
+ validate_pkce(code_data[:code_challenge], code_data[:code_challenge_method], code_verifier)
71
+ end
72
+
73
+ # Generate access token
74
+ access_token = generate_access_token(
75
+ client_id: client_id,
76
+ scope: code_data[:scope],
77
+ user_id: code_data[:user_id]
78
+ )
79
+
80
+ # Generate refresh token if enabled
81
+ refresh_token = nil
82
+ if oauth_config["enable_refresh_tokens"]
83
+ refresh_token = generate_refresh_token(
84
+ client_id: client_id,
85
+ scope: code_data[:scope],
86
+ user_id: code_data[:user_id],
87
+ access_token: access_token
88
+ )
89
+ end
90
+
91
+ # Remove used authorization code
92
+ remove_authorization_code(code)
93
+
94
+ # Return token response
95
+ response = {
96
+ access_token: access_token,
97
+ token_type: "Bearer",
98
+ expires_in: token_expires_in,
99
+ scope: code_data[:scope]
100
+ }
101
+ response[:refresh_token] = refresh_token if refresh_token
102
+ response
103
+ end
104
+
105
+ # Refresh access token using refresh token
106
+ # @param refresh_token [String] Refresh token
107
+ # @param client_id [String] OAuth client identifier
108
+ # @param client_secret [String] OAuth client secret
109
+ # @param scope [String] Requested scope (optional, must be subset of original)
110
+ # @return [Hash] New token response
111
+ def refresh_access_token(refresh_token:, client_id:, client_secret: nil, scope: nil)
112
+ # Retrieve refresh token data
113
+ token_data = retrieve_refresh_token(refresh_token)
114
+ raise InvalidGrantError, "Invalid refresh token" unless token_data
115
+ raise InvalidGrantError, "Refresh token expired" if token_data[:expires_at] < Time.current
116
+
117
+ # Validate client
118
+ validate_client(client_id, client_secret)
119
+
120
+ # Validate client ID matches
121
+ unless token_data[:client_id] == client_id
122
+ raise InvalidGrantError, "Client ID mismatch"
123
+ end
124
+
125
+ # Validate scope if provided
126
+ if scope
127
+ requested_scopes = scope.split(" ")
128
+ original_scopes = token_data[:scope].split(" ")
129
+ unless (requested_scopes - original_scopes).empty?
130
+ raise InvalidScopeError, "Requested scope exceeds original scope"
131
+ end
132
+ else
133
+ scope = token_data[:scope]
134
+ end
135
+
136
+ # Revoke old access token
137
+ revoke_access_token(token_data[:access_token]) if token_data[:access_token]
138
+
139
+ # Generate new access token
140
+ access_token = generate_access_token(
141
+ client_id: client_id,
142
+ scope: scope,
143
+ user_id: token_data[:user_id]
144
+ )
145
+
146
+ # Update refresh token with new access token
147
+ update_refresh_token(refresh_token, access_token)
148
+
149
+ {
150
+ access_token: access_token,
151
+ token_type: "Bearer",
152
+ expires_in: token_expires_in,
153
+ scope: scope
154
+ }
155
+ end
156
+
157
+ # Validate access token and return token info
158
+ # @param access_token [String] Access token to validate
159
+ # @return [Hash] Token info with active, client_id, scope, user_id, exp
160
+ def introspect_token(access_token)
161
+ token_data = retrieve_access_token(access_token)
162
+
163
+ unless token_data
164
+ return { active: false }
165
+ end
166
+
167
+ if token_data[:expires_at] < Time.current
168
+ remove_access_token(access_token)
169
+ return { active: false }
170
+ end
171
+
172
+ {
173
+ active: true,
174
+ client_id: token_data[:client_id],
175
+ scope: token_data[:scope],
176
+ user_id: token_data[:user_id],
177
+ exp: token_data[:expires_at].to_i,
178
+ iat: token_data[:created_at].to_i,
179
+ token_type: "Bearer"
180
+ }
181
+ end
182
+
183
+ # Revoke access or refresh token
184
+ # @param token [String] Token to revoke
185
+ # @param token_type_hint [String] Type hint: "access_token" or "refresh_token"
186
+ # @return [Boolean] True if token was revoked
187
+ def revoke_token(token, token_type_hint: nil)
188
+ revoked = false
189
+
190
+ # Try access token first if hint suggests it or no hint provided
191
+ if token_type_hint == "access_token" || token_type_hint.nil?
192
+ if retrieve_access_token(token)
193
+ revoke_access_token(token)
194
+ revoked = true
195
+ end
196
+ end
197
+
198
+ # Try refresh token if not revoked yet
199
+ if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?)
200
+ if retrieve_refresh_token(token)
201
+ revoke_refresh_token(token)
202
+ revoked = true
203
+ end
204
+ end
205
+
206
+ revoked
207
+ end
208
+
209
+ # Client Credentials Grant (for server-to-server)
210
+ # @param client_id [String] OAuth client identifier
211
+ # @param client_secret [String] OAuth client secret
212
+ # @param scope [String] Requested scope
213
+ # @return [Hash] Token response
214
+ def client_credentials_grant(client_id:, client_secret:, scope: nil)
215
+ unless oauth_config["enable_client_credentials"]
216
+ raise UnsupportedGrantTypeError, "Client credentials grant not supported"
217
+ end
218
+
219
+ # Validate client credentials
220
+ validate_client(client_id, client_secret, require_secret: true)
221
+
222
+ # Validate scope
223
+ if scope
224
+ validate_scope(scope)
225
+ else
226
+ scope = default_scope
227
+ end
228
+
229
+ # Generate access token (no user context for client credentials)
230
+ access_token = generate_access_token(
231
+ client_id: client_id,
232
+ scope: scope,
233
+ user_id: nil
234
+ )
235
+
236
+ {
237
+ access_token: access_token,
238
+ token_type: "Bearer",
239
+ expires_in: token_expires_in,
240
+ scope: scope
241
+ }
242
+ end
243
+
244
+ private
245
+
246
+ def oauth_config
247
+ @oauth_config ||= ActionMCP.configuration.oauth_config || {}
248
+ end
249
+
250
+ def validate_client(client_id, client_secret, require_secret: false)
251
+ # This should be implemented by the application
252
+ # For now, we'll use a simple validation approach
253
+ provider_class = oauth_config["provider"]
254
+ if provider_class && provider_class.respond_to?(:validate_client)
255
+ provider_class.validate_client(client_id, client_secret)
256
+ elsif require_secret && client_secret.nil?
257
+ raise InvalidClientError, "Client authentication required"
258
+ end
259
+ # Default: allow any client for development
260
+ end
261
+
262
+ def validate_pkce(code_challenge, method, code_verifier)
263
+ raise InvalidGrantError, "Code verifier required" unless code_verifier
264
+
265
+ case method
266
+ when "S256"
267
+ expected_challenge = Base64.urlsafe_encode64(
268
+ Digest::SHA256.digest(code_verifier), padding: false
269
+ )
270
+ unless code_challenge == expected_challenge
271
+ raise InvalidGrantError, "Invalid code verifier"
272
+ end
273
+ when "plain"
274
+ unless oauth_config["allow_plain_pkce"]
275
+ raise InvalidGrantError, "Plain PKCE not allowed"
276
+ end
277
+ unless code_challenge == code_verifier
278
+ raise InvalidGrantError, "Invalid code verifier"
279
+ end
280
+ else
281
+ raise InvalidGrantError, "Unsupported code challenge method"
282
+ end
283
+ end
284
+
285
+ def validate_scope(scope)
286
+ supported_scopes = oauth_config["scopes_supported"] || [ "mcp:tools", "mcp:resources", "mcp:prompts" ]
287
+ requested_scopes = scope.split(" ")
288
+ unsupported = requested_scopes - supported_scopes
289
+ if unsupported.any?
290
+ raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
291
+ end
292
+ end
293
+
294
+ def default_scope
295
+ oauth_config["default_scope"] || "mcp:tools mcp:resources mcp:prompts"
296
+ end
297
+
298
+ def generate_access_token(client_id:, scope:, user_id:)
299
+ token = SecureRandom.urlsafe_base64(32)
300
+
301
+ store_access_token(token, {
302
+ client_id: client_id,
303
+ scope: scope,
304
+ user_id: user_id,
305
+ created_at: Time.current,
306
+ expires_at: token_expires_in.seconds.from_now
307
+ })
308
+
309
+ token
310
+ end
311
+
312
+ def generate_refresh_token(client_id:, scope:, user_id:, access_token:)
313
+ token = SecureRandom.urlsafe_base64(32)
314
+
315
+ store_refresh_token(token, {
316
+ client_id: client_id,
317
+ scope: scope,
318
+ user_id: user_id,
319
+ access_token: access_token,
320
+ created_at: Time.current,
321
+ expires_at: refresh_token_expires_in.seconds.from_now
322
+ })
323
+
324
+ token
325
+ end
326
+
327
+ def token_expires_in
328
+ oauth_config["access_token_expires_in"] || 3600 # 1 hour
329
+ end
330
+
331
+ def refresh_token_expires_in
332
+ oauth_config["refresh_token_expires_in"] || 7.days.to_i # 1 week
333
+ end
334
+
335
+ # Storage methods - these delegate to a configurable storage backend
336
+ def storage
337
+ @storage ||= begin
338
+ storage_class = oauth_config["storage"] || "ActionMCP::OAuth::MemoryStorage"
339
+ storage_class = storage_class.constantize if storage_class.is_a?(String)
340
+ storage_class.new
341
+ end
342
+ end
343
+
344
+ def store_authorization_code(code, data)
345
+ storage.store_authorization_code(code, data)
346
+ end
347
+
348
+ def retrieve_authorization_code(code)
349
+ storage.retrieve_authorization_code(code)
350
+ end
351
+
352
+ def remove_authorization_code(code)
353
+ storage.remove_authorization_code(code)
354
+ end
355
+
356
+ def store_access_token(token, data)
357
+ storage.store_access_token(token, data)
358
+ end
359
+
360
+ def retrieve_access_token(token)
361
+ storage.retrieve_access_token(token)
362
+ end
363
+
364
+ def remove_access_token(token)
365
+ storage.remove_access_token(token)
366
+ end
367
+
368
+ def revoke_access_token(token)
369
+ storage.remove_access_token(token)
370
+ end
371
+
372
+ def store_refresh_token(token, data)
373
+ storage.store_refresh_token(token, data)
374
+ end
375
+
376
+ def retrieve_refresh_token(token)
377
+ storage.retrieve_refresh_token(token)
378
+ end
379
+
380
+ def update_refresh_token(token, new_access_token)
381
+ storage.update_refresh_token(token, new_access_token)
382
+ end
383
+
384
+ def revoke_refresh_token(token)
385
+ storage.remove_refresh_token(token)
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,176 @@
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 ||= begin
42
+ if options.userinfo_url
43
+ access_token.get(options.userinfo_url).parsed
44
+ else
45
+ # Extract user info from token response or use minimal info
46
+ token_response = access_token.token
47
+ {
48
+ "sub" => access_token.params["user_id"] || access_token.token,
49
+ "scope" => access_token.params["scope"] || options.scope
50
+ }
51
+ end
52
+ end
53
+ rescue ::OAuth2::Error => e
54
+ log(:error, "Failed to fetch user info: #{e.message}")
55
+ {}
56
+ end
57
+
58
+ # User ID for Omniauth
59
+ uid { raw_info["sub"] || raw_info["user_id"] }
60
+
61
+ # User info hash
62
+ info do
63
+ {
64
+ name: raw_info["name"],
65
+ email: raw_info["email"],
66
+ username: raw_info["username"] || raw_info["preferred_username"]
67
+ }
68
+ end
69
+
70
+ # Extra credentials and token info
71
+ extra do
72
+ {
73
+ "raw_info" => raw_info,
74
+ "scope" => access_token.params["scope"],
75
+ "token_type" => access_token.params["token_type"] || "Bearer"
76
+ }
77
+ end
78
+
79
+ # OAuth server metadata discovery
80
+ def discovery_info
81
+ @discovery_info ||= begin
82
+ if options.discovery && options.client_options.site
83
+ discovery_url = "#{options.client_options.site}/.well-known/oauth-authorization-server"
84
+ response = client.request(:get, discovery_url)
85
+ JSON.parse(response.body)
86
+ end
87
+ rescue StandardError => e
88
+ log(:warn, "OAuth discovery failed: #{e.message}")
89
+ {}
90
+ end
91
+ end
92
+
93
+ # Override client to use discovered endpoints if available
94
+ def client
95
+ @client ||= begin
96
+ if discovery_info.any?
97
+ options.client_options.merge!(
98
+ authorize_url: discovery_info["authorization_endpoint"],
99
+ token_url: discovery_info["token_endpoint"]
100
+ ) if discovery_info["authorization_endpoint"] && discovery_info["token_endpoint"]
101
+ end
102
+ super
103
+ end
104
+ end
105
+
106
+ # Token validation for API requests (not callback flow)
107
+ def self.validate_token(token, options = {})
108
+ strategy = new(nil, options)
109
+ strategy.validate_token(token)
110
+ end
111
+
112
+ def validate_token(token)
113
+ # Validate access token with OAuth server
114
+ return nil unless token
115
+
116
+ begin
117
+ response = client.request(:post, options.introspection_url || "/oauth/introspect", {
118
+ body: { token: token },
119
+ headers: { "Content-Type" => "application/x-www-form-urlencoded" }
120
+ })
121
+
122
+ token_info = JSON.parse(response.body)
123
+ return nil unless token_info["active"]
124
+
125
+ token_info
126
+ rescue StandardError => e
127
+ log(:error, "Token validation failed: #{e.message}")
128
+ nil
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Configure strategy from ActionMCP configuration
135
+ def configure_from_mcp_config
136
+ oauth_config = ActionMCP.configuration.oauth_config
137
+ return unless oauth_config.is_a?(Hash)
138
+
139
+ # Set client options from MCP config
140
+ if oauth_config["issuer_url"]
141
+ options.client_options[:site] = oauth_config["issuer_url"]
142
+ end
143
+
144
+ if oauth_config["client_id"]
145
+ options.client_id = oauth_config["client_id"]
146
+ end
147
+
148
+ if oauth_config["client_secret"]
149
+ options.client_secret = oauth_config["client_secret"]
150
+ end
151
+
152
+ if oauth_config["scopes_supported"]
153
+ options.scope = Array(oauth_config["scopes_supported"]).join(" ")
154
+ end
155
+
156
+ # Enable PKCE if required (OAuth 2.1 compliance)
157
+ if oauth_config["pkce_required"]
158
+ options.pkce = true
159
+ end
160
+
161
+ # Set userinfo endpoint if provided
162
+ if oauth_config["userinfo_endpoint"]
163
+ options.userinfo_url = oauth_config["userinfo_endpoint"]
164
+ end
165
+
166
+ # Set token introspection endpoint
167
+ if oauth_config["introspection_endpoint"]
168
+ options.introspection_url = oauth_config["introspection_endpoint"]
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ # Register the strategy with Omniauth
176
+ OmniAuth.config.add_camelization "mcp", "MCP"
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.52.2"
5
+ VERSION = "0.54.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
data/lib/action_mcp.rb CHANGED
@@ -13,6 +13,10 @@ require "action_mcp/log_subscriber"
13
13
  require "action_mcp/engine"
14
14
  require "zeitwerk"
15
15
 
16
+ # OAuth 2.1 support via Omniauth
17
+ require "omniauth"
18
+ require "omniauth-oauth2"
19
+
16
20
  lib = File.dirname(__FILE__)
17
21
 
18
22
  Zeitwerk::Loader.for_gem.tap do |loader|
@@ -25,8 +29,9 @@ Zeitwerk::Loader.for_gem.tap do |loader|
25
29
 
26
30
  loader.inflector.inflect("action_mcp" => "ActionMCP")
27
31
  loader.inflector.inflect("sse_client" => "SSEClient")
28
- loader.inflector.inflect("sse_server" => "SSEServer")
29
32
  loader.inflector.inflect("sse_listener" => "SSEListener")
33
+ loader.inflector.inflect("oauth" => "OAuth")
34
+ loader.inflector.inflect("mcp_strategy" => "MCPStrategy")
30
35
  end.setup
31
36
 
32
37
  module ActionMCP
@@ -7,6 +7,8 @@ module ActionMcp
7
7
  class InstallGenerator < Rails::Generators::Base
8
8
  source_root File.expand_path("templates", __dir__)
9
9
 
10
+ desc "Install ActionMCP with base classes and configuration"
11
+
10
12
  def create_application_prompt_file
11
13
  template "application_mcp_prompt.rb", File.join("app/mcp/prompts", "application_mcp_prompt.rb")
12
14
  end
@@ -20,13 +22,42 @@ module ActionMcp
20
22
  File.join("app/mcp/resource_templates", "application_mcp_res_template.rb")
21
23
  end
22
24
 
23
- def create_mcp_profile_file
25
+ def create_mcp_configuration_file
24
26
  template "mcp.yml", File.join("config", "mcp.yml")
25
27
  end
26
28
 
27
29
  def create_application_gateway_file
28
30
  template "application_gateway.rb", File.join("app/mcp", "application_gateway.rb")
29
31
  end
32
+
33
+ def show_instructions
34
+ say ""
35
+ say "ActionMCP has been installed successfully!"
36
+ say ""
37
+ say "Files created:"
38
+ say " - app/mcp/prompts/application_mcp_prompt.rb"
39
+ say " - app/mcp/tools/application_mcp_tool.rb"
40
+ say " - app/mcp/resource_templates/application_mcp_res_template.rb"
41
+ say " - app/mcp/application_gateway.rb"
42
+ say " - config/mcp.yml"
43
+ say ""
44
+ say "Configuration:"
45
+ say " The mcp.yml file contains authentication, profiles, and adapter settings."
46
+ say " You can customize authentication methods, OAuth settings, and PubSub adapters."
47
+ say ""
48
+ say "Available adapters:"
49
+ say " - simple : In-memory adapter for development"
50
+ say " - test : Test adapter for testing environments"
51
+ say " - solid_cable : Database-backed adapter (requires solid_cable gem)"
52
+ say " - redis : Redis-backed adapter (requires redis gem)"
53
+ say ""
54
+ say "Next steps:"
55
+ say " 1. Generate your first tool: rails generate action_mcp:tool MyTool"
56
+ say " 2. Generate your first prompt: rails generate action_mcp:prompt MyPrompt"
57
+ say " 3. Generate your first resource template: rails generate action_mcp:resource_template MyResource"
58
+ say " 4. Start the MCP server: bundle exec rails s -c mcp.ru -p 62770"
59
+ say ""
60
+ end
30
61
  end
31
62
  end
32
63
  end