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,135 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "base64"
5
-
6
- module ActionMCP
7
- module Client
8
- # JWT client provider for MCP client authentication
9
- # Provides clean JWT token management for ActionMCP client connections
10
- class JwtClientProvider
11
- class AuthenticationError < StandardError; end
12
- class TokenExpiredError < StandardError; end
13
-
14
- attr_reader :storage
15
-
16
- def initialize(token: nil, storage: nil, logger: ActionMCP.logger)
17
- @storage = storage || MemoryStorage.new
18
- @logger = logger
19
-
20
- # If token provided during initialization, store it
21
- return unless token
22
-
23
- save_token(token)
24
- end
25
-
26
- # Check if client has valid authentication
27
- def authenticated?
28
- token = current_token
29
- return false unless token
30
-
31
- !token_expired?(token)
32
- end
33
-
34
- # Get authorization headers for HTTP requests
35
- def authorization_headers
36
- token = current_token
37
- return {} unless token
38
-
39
- if token_expired?(token)
40
- log_debug("JWT token expired")
41
- clear_tokens!
42
- return {}
43
- end
44
-
45
- { "Authorization" => "Bearer #{token}" }
46
- end
47
-
48
- # Set/update the JWT token
49
- def set_token(token)
50
- save_token(token)
51
- log_debug("JWT token updated")
52
- end
53
-
54
- # Clear stored tokens (logout)
55
- def clear_tokens!
56
- @storage.clear_token
57
- log_debug("Cleared JWT token")
58
- end
59
-
60
- # Get current valid token
61
- def access_token
62
- token = current_token
63
- return nil unless token
64
- return nil if token_expired?(token)
65
-
66
- token
67
- end
68
-
69
- private
70
-
71
- def current_token
72
- @storage.load_token
73
- end
74
-
75
- def save_token(token)
76
- @storage.save_token(token)
77
- end
78
-
79
- def token_expired?(token)
80
- return false unless token
81
-
82
- begin
83
- payload = decode_jwt_payload(token)
84
- exp = payload["exp"]
85
- return false unless exp
86
-
87
- # Add 30 second buffer for clock skew
88
- Time.at(exp) <= Time.now + 30
89
- rescue StandardError => e
90
- log_debug("Error checking token expiration: #{e.message}")
91
- true # Treat invalid tokens as expired
92
- end
93
- end
94
-
95
- def decode_jwt_payload(token)
96
- # Split JWT into parts
97
- parts = token.split(".")
98
- raise AuthenticationError, "Invalid JWT format" unless parts.length == 3
99
-
100
- # Decode payload (second part)
101
- payload_base64 = parts[1]
102
- # Add padding if needed
103
- payload_base64 += "=" * (4 - payload_base64.length % 4) if payload_base64.length % 4 != 0
104
-
105
- payload_json = Base64.urlsafe_decode64(payload_base64)
106
- JSON.parse(payload_json)
107
- rescue StandardError => e
108
- raise AuthenticationError, "Failed to decode JWT: #{e.message}"
109
- end
110
-
111
- def log_debug(message)
112
- @logger.debug("[ActionMCP::JwtClientProvider] #{message}")
113
- end
114
-
115
- # Simple memory storage for JWT tokens
116
- class MemoryStorage
117
- def initialize
118
- @token = nil
119
- end
120
-
121
- def save_token(token)
122
- @token = token
123
- end
124
-
125
- def load_token
126
- @token
127
- end
128
-
129
- def clear_token
130
- @token = nil
131
- end
132
- end
133
- end
134
- end
135
- end
@@ -1,47 +0,0 @@
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
@@ -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