actionmcp 0.71.1 → 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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +187 -16
  3. data/app/controllers/action_mcp/application_controller.rb +64 -49
  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 +71 -113
  9. data/config/routes.rb +0 -11
  10. data/db/migrate/20250512154359_consolidated_migration.rb +3 -3
  11. data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +7 -0
  12. data/db/migrate/20250727000001_remove_oauth_support.rb +59 -0
  13. data/lib/action_mcp/base_response.rb +1 -1
  14. data/lib/action_mcp/client/base.rb +9 -11
  15. data/lib/action_mcp/client/elicitation.rb +4 -4
  16. data/lib/action_mcp/client/json_rpc_handler.rb +11 -13
  17. data/lib/action_mcp/client/streamable_http_transport.rb +19 -74
  18. data/lib/action_mcp/client.rb +6 -26
  19. data/lib/action_mcp/configuration.rb +65 -63
  20. data/lib/action_mcp/engine.rb +1 -10
  21. data/lib/action_mcp/filtered_logger.rb +3 -7
  22. data/lib/action_mcp/gateway.rb +7 -11
  23. data/lib/action_mcp/gateway_identifier.rb +187 -3
  24. data/lib/action_mcp/gateway_identifiers/api_key_identifier.rb +56 -0
  25. data/lib/action_mcp/gateway_identifiers/devise_identifier.rb +34 -0
  26. data/lib/action_mcp/gateway_identifiers/request_env_identifier.rb +58 -0
  27. data/lib/action_mcp/gateway_identifiers/warden_identifier.rb +38 -0
  28. data/lib/action_mcp/gateway_identifiers.rb +26 -0
  29. data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
  30. data/lib/action_mcp/prompt.rb +2 -0
  31. data/lib/action_mcp/renderable.rb +1 -1
  32. data/lib/action_mcp/resource_template.rb +6 -2
  33. data/lib/action_mcp/server/{memory_session.rb → base_session.rb} +41 -26
  34. data/lib/action_mcp/server/base_session_store.rb +86 -0
  35. data/lib/action_mcp/server/capabilities.rb +2 -1
  36. data/lib/action_mcp/server/elicitation.rb +3 -9
  37. data/lib/action_mcp/server/error_handling.rb +14 -1
  38. data/lib/action_mcp/server/handlers/router.rb +31 -0
  39. data/lib/action_mcp/server/json_rpc_handler.rb +2 -5
  40. data/lib/action_mcp/server/{messaging.rb → messaging_service.rb} +38 -14
  41. data/lib/action_mcp/server/prompts.rb +4 -4
  42. data/lib/action_mcp/server/resources.rb +23 -4
  43. data/lib/action_mcp/server/session_store_factory.rb +1 -1
  44. data/lib/action_mcp/server/solid_mcp_adapter.rb +9 -10
  45. data/lib/action_mcp/server/tools.rb +62 -43
  46. data/lib/action_mcp/server/transport_handler.rb +2 -4
  47. data/lib/action_mcp/server/volatile_session_store.rb +1 -93
  48. data/lib/action_mcp/tagged_stream_logging.rb +2 -2
  49. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +4 -4
  50. data/lib/action_mcp/test_helper/session_store_assertions.rb +5 -1
  51. data/lib/action_mcp/tool.rb +48 -37
  52. data/lib/action_mcp/types/float_array_type.rb +5 -3
  53. data/lib/action_mcp/version.rb +1 -1
  54. data/lib/action_mcp.rb +2 -7
  55. data/lib/generators/action_mcp/identifier/identifier_generator.rb +189 -0
  56. data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +35 -0
  57. data/lib/generators/action_mcp/install/install_generator.rb +1 -1
  58. data/lib/generators/action_mcp/install/templates/application_gateway.rb +86 -36
  59. data/lib/generators/action_mcp/install/templates/mcp.yml +4 -21
  60. data/lib/tasks/action_mcp_tasks.rake +7 -5
  61. metadata +18 -100
  62. data/app/controllers/action_mcp/oauth/endpoints_controller.rb +0 -264
  63. data/app/controllers/action_mcp/oauth/metadata_controller.rb +0 -129
  64. data/app/controllers/action_mcp/oauth/registration_controller.rb +0 -206
  65. data/app/models/action_mcp/oauth_client.rb +0 -157
  66. data/app/models/action_mcp/oauth_token.rb +0 -141
  67. data/db/migrate/20250608112101_add_oauth_to_sessions.rb +0 -19
  68. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +0 -42
  69. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +0 -37
  70. data/lib/action_mcp/client/jwt_client_provider.rb +0 -134
  71. data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +0 -47
  72. data/lib/action_mcp/client/oauth_client_provider.rb +0 -234
  73. data/lib/action_mcp/jwt_decoder.rb +0 -26
  74. data/lib/action_mcp/jwt_identifier.rb +0 -28
  75. data/lib/action_mcp/none_identifier.rb +0 -19
  76. data/lib/action_mcp/o_auth_identifier.rb +0 -34
  77. data/lib/action_mcp/oauth/active_record_storage.rb +0 -183
  78. data/lib/action_mcp/oauth/error.rb +0 -79
  79. data/lib/action_mcp/oauth/memory_storage.rb +0 -134
  80. data/lib/action_mcp/oauth/middleware.rb +0 -133
  81. data/lib/action_mcp/oauth/provider.rb +0 -426
  82. data/lib/action_mcp/oauth.rb +0 -12
  83. data/lib/action_mcp/omniauth/mcp_strategy.rb +0 -176
  84. data/lib/action_mcp/server/notifications.rb +0 -58
@@ -1,37 +0,0 @@
1
- class CreateActionMCPOAuthTokens < ActiveRecord::Migration[7.2]
2
- def change
3
- create_table :action_mcp_oauth_tokens do |t|
4
- t.string :token, null: false, index: { unique: true }
5
- t.string :token_type, null: false # 'access_token', 'refresh_token', 'authorization_code'
6
- t.string :client_id, null: false
7
- t.string :user_id
8
- t.text :scope
9
- t.datetime :expires_at
10
- t.boolean :revoked, default: false
11
-
12
- # For authorization codes
13
- t.string :redirect_uri
14
- t.string :code_challenge
15
- t.string :code_challenge_method
16
-
17
- # For refresh tokens
18
- t.string :access_token # Reference to associated access token
19
-
20
- # Additional data - use JSON for database compatibility
21
- if connection.adapter_name.downcase.include?('postgresql')
22
- t.jsonb :metadata, default: {}
23
- else
24
- t.json :metadata, default: {}
25
- end
26
-
27
- t.timestamps
28
- end
29
-
30
- add_index :action_mcp_oauth_tokens, :token_type
31
- add_index :action_mcp_oauth_tokens, :client_id
32
- add_index :action_mcp_oauth_tokens, :user_id
33
- add_index :action_mcp_oauth_tokens, :expires_at
34
- add_index :action_mcp_oauth_tokens, :revoked
35
- add_index :action_mcp_oauth_tokens, [ :token_type, :expires_at ]
36
- end
37
- end
@@ -1,134 +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
- if token
22
- save_token(token)
23
- end
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
- token
66
- end
67
-
68
- private
69
-
70
- def current_token
71
- @storage.load_token
72
- end
73
-
74
- def save_token(token)
75
- @storage.save_token(token)
76
- end
77
-
78
- def token_expired?(token)
79
- return false unless token
80
-
81
- begin
82
- payload = decode_jwt_payload(token)
83
- exp = payload["exp"]
84
- return false unless exp
85
-
86
- # Add 30 second buffer for clock skew
87
- Time.at(exp) <= Time.now + 30
88
- rescue => e
89
- log_debug("Error checking token expiration: #{e.message}")
90
- true # Treat invalid tokens as expired
91
- end
92
- end
93
-
94
- def decode_jwt_payload(token)
95
- # Split JWT into parts
96
- parts = token.split(".")
97
- raise AuthenticationError, "Invalid JWT format" unless parts.length == 3
98
-
99
- # Decode payload (second part)
100
- payload_base64 = parts[1]
101
- # Add padding if needed
102
- payload_base64 += "=" * (4 - payload_base64.length % 4) if payload_base64.length % 4 != 0
103
-
104
- payload_json = Base64.urlsafe_decode64(payload_base64)
105
- JSON.parse(payload_json)
106
- rescue => e
107
- raise AuthenticationError, "Failed to decode JWT: #{e.message}"
108
- end
109
-
110
- def log_debug(message)
111
- @logger.debug("[ActionMCP::JwtClientProvider] #{message}")
112
- end
113
-
114
- # Simple memory storage for JWT tokens
115
- class MemoryStorage
116
- def initialize
117
- @token = nil
118
- end
119
-
120
- def save_token(token)
121
- @token = token
122
- end
123
-
124
- def load_token
125
- @token
126
- end
127
-
128
- def clear_token
129
- @token = nil
130
- end
131
- end
132
- end
133
- end
134
- 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
- unless response.success?
177
- raise AuthenticationError, "Failed to fetch server metadata: #{response.status}"
178
- end
179
-
180
- JSON.parse(response.body, symbolize_names: true)
181
- end
182
-
183
- def handle_token_response(response)
184
- unless response.success?
185
- error_body = JSON.parse(response.body) rescue {}
186
- error_msg = error_body["error_description"] || error_body["error"] || "Token request failed"
187
- raise AuthenticationError, "#{error_msg} (#{response.status})"
188
- end
189
-
190
- token_data = JSON.parse(response.body, symbolize_names: true)
191
-
192
- # Calculate token expiration
193
- if token_data[:expires_in]
194
- token_data[:expires_at] = Time.now.to_i + token_data[:expires_in].to_i
195
- end
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: [ "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,26 +0,0 @@
1
- require "jwt"
2
-
3
- module ActionMCP
4
- class JwtDecoder
5
- class DecodeError < StandardError; end
6
-
7
- # Configurable defaults
8
- class << self
9
- attr_accessor :secret, :algorithm
10
-
11
- def decode(token)
12
- payload, _header = JWT.decode(token, secret, true, { algorithm: algorithm })
13
- payload
14
- rescue JWT::ExpiredSignature
15
- raise DecodeError, "Token has expired"
16
- rescue JWT::DecodeError => e
17
- # Simplify the error message for invalid tokens
18
- raise DecodeError, "Invalid token"
19
- end
20
- end
21
-
22
- # Defaults (can be overridden in an initializer)
23
- self.secret = ENV.fetch("ACTION_MCP_JWT_SECRET") { "change-me" }
24
- self.algorithm = "HS256"
25
- end
26
- 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