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,29 +1,213 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
+ # Base class for Gateway authentication identifiers.
5
+ #
6
+ # Gateway identifiers provide a clean interface for authentication by reading
7
+ # from request.env keys set by upstream middleware (like Warden, Devise, or custom auth).
8
+ #
9
+ # @example Warden/Devise Integration
10
+ # class WardenIdentifier < ActionMCP::GatewayIdentifier
11
+ # identifier :user
12
+ # authenticates :warden
13
+ #
14
+ # def resolve
15
+ # # Warden sets 'warden.user' in request.env after authentication
16
+ # user = user_from_middleware
17
+ # user || raise(Unauthorized, "No authenticated user found")
18
+ # end
19
+ # end
20
+ #
21
+ # @example API Key Authentication
22
+ # class ApiKeyIdentifier < ActionMCP::GatewayIdentifier
23
+ # identifier :user
24
+ # authenticates :api_key
25
+ #
26
+ # def resolve
27
+ # # Check for API key in header or query param
28
+ # api_key = @request.env['HTTP_X_API_KEY'] ||
29
+ # @request.params['api_key']
30
+ # return raise(Unauthorized, "Missing API key") unless api_key
31
+ #
32
+ # user = User.find_by(api_key: api_key)
33
+ # user || raise(Unauthorized, "Invalid API key")
34
+ # end
35
+ # end
36
+ #
37
+ # @example Session-based Authentication
38
+ # class SessionIdentifier < ActionMCP::GatewayIdentifier
39
+ # identifier :user
40
+ # authenticates :session
41
+ #
42
+ # def resolve
43
+ # user_id = session&.[]('user_id')
44
+ # return raise(Unauthorized, "No user session") unless user_id
45
+ #
46
+ # user = User.find_by(id: user_id)
47
+ # user || raise(Unauthorized, "Invalid session")
48
+ # end
49
+ # end
50
+ #
51
+ # @example Multi-tenant with Organization
52
+ # class TenantIdentifier < ActionMCP::GatewayIdentifier
53
+ # identifier :user
54
+ # authenticates :tenant
55
+ #
56
+ # def resolve
57
+ # # Get user from middleware
58
+ # user = user_from_middleware
59
+ # return raise(Unauthorized, "No user found") unless user
60
+ #
61
+ # # Check tenant header
62
+ # tenant_id = @request.env['HTTP_X_TENANT_ID']
63
+ # return raise(Unauthorized, "Missing tenant") unless tenant_id
64
+ #
65
+ # # Verify user has access to tenant
66
+ # unless user.tenants.exists?(id: tenant_id)
67
+ # raise Unauthorized, "Access denied for tenant"
68
+ # end
69
+ #
70
+ # # Set current tenant for the request
71
+ # Current.tenant = Tenant.find(tenant_id)
72
+ # user
73
+ # end
74
+ # end
75
+ #
76
+ # @example Development/Testing Bypass
77
+ # class DevIdentifier < ActionMCP::GatewayIdentifier
78
+ # identifier :user
79
+ # authenticates :dev
80
+ #
81
+ # def resolve
82
+ # return raise(Unauthorized, "Dev auth disabled in production") unless development_env?
83
+ #
84
+ # # Create or find dev user
85
+ # User.find_or_create_by!(email: "dev@localhost") do |user|
86
+ # user.name = "Development User"
87
+ # end
88
+ # end
89
+ # end
4
90
  class GatewayIdentifier
5
91
  class Unauthorized < StandardError; end
6
92
 
7
93
  class << self
8
- # e.g. JwtIdentifier.identifier_name => :user
9
- attr_reader :identifier_name, :auth_method
94
+ # @return [Symbol] The name of the identity this identifier provides (e.g., :user, :admin)
95
+ attr_reader :identifier_name
10
96
 
97
+ # @return [String] The authentication method this identifier handles (e.g., "session", "api_key")
98
+ attr_reader :auth_method
99
+
100
+ # Declares what identity attribute this identifier provides.
101
+ # This becomes the accessor name on the Gateway instance.
102
+ #
103
+ # @param name [Symbol, String] The identity name (e.g., :user, :admin)
104
+ # @example
105
+ # identifier :user
106
+ # # Gateway instance will have gateway.user accessor
11
107
  def identifier(name)
12
108
  @identifier_name = name.to_sym
13
109
  end
14
110
 
111
+ # Declares what authentication method this identifier handles.
112
+ # This should match values in your authentication_methods configuration.
113
+ #
114
+ # @param method [Symbol, String] The auth method name (e.g., :session, :api_key)
115
+ # @example
116
+ # authenticates :api_key
117
+ # # Matches authentication_methods: ["api_key"] in config
15
118
  def authenticates(method)
16
119
  @auth_method = method.to_s
17
120
  end
18
121
  end
19
122
 
123
+ # @param request [ActionDispatch::Request] The request object containing env hash
20
124
  def initialize(request)
21
125
  @request = request
22
126
  end
23
127
 
24
- # must return a truthy identity object, or raise Unauthorized
128
+ # Resolves the identity for this authentication method.
129
+ # Must return a truthy identity object, or raise Unauthorized.
130
+ #
131
+ # Common request.env keys set by popular auth middleware:
132
+ # - 'warden.user' - Warden (used by Devise)
133
+ # - 'devise.user' - Devise direct
134
+ # - 'rack.session' - Rack session hash
135
+ # - 'HTTP_AUTHORIZATION' - Authorization header
136
+ # - Custom keys set by your middleware
137
+ #
138
+ # @return [Object] The authenticated identity (User, Admin, etc.)
139
+ # @raise [Unauthorized] When authentication fails
140
+ # @abstract Subclasses must implement this method
25
141
  def resolve
26
142
  raise NotImplementedError, "#{self.class}#resolve must be implemented"
27
143
  end
144
+
145
+ protected
146
+
147
+ # Helper method to extract Bearer token from Authorization header
148
+ # @return [String, nil] The token without "Bearer " prefix
149
+ def extract_bearer_token
150
+ header = @request.env["HTTP_AUTHORIZATION"] || ""
151
+ header[/\ABearer (.+)\z/, 1]
152
+ end
153
+
154
+ # Helper method to extract Basic auth credentials from Authorization header
155
+ # @return [Array<String>, nil] [username, password] or nil if not Basic auth
156
+ def extract_basic_auth
157
+ header = @request.env["HTTP_AUTHORIZATION"] || ""
158
+ return nil unless header.start_with?("Basic ")
159
+
160
+ encoded = header[6..-1] # Remove "Basic " prefix
161
+ decoded = Base64.decode64(encoded)
162
+ decoded.split(":", 2) # Split on first colon only
163
+ rescue StandardError
164
+ nil
165
+ end
166
+
167
+ # Helper method to get user from common middleware env keys
168
+ # @return [Object, nil] User from Warden/Devise or nil
169
+ def user_from_middleware
170
+ @request.env["warden.user"] || @request.env["devise.user"]
171
+ end
172
+
173
+ # Helper method to get session hash
174
+ # @return [Hash, nil] Rack session hash or nil
175
+ def session
176
+ @request.env["rack.session"]
177
+ end
178
+
179
+ # Helper method to read custom env key with optional fallbacks
180
+ # @param keys [String, Array<String>] Primary key or array of keys to try
181
+ # @return [Object, nil] Value from request.env or nil
182
+ def env_value(*keys)
183
+ keys.flatten.each do |key|
184
+ value = @request.env[key]
185
+ return value if value
186
+ end
187
+ nil
188
+ end
189
+
190
+ # Helper method to extract API key from various common locations
191
+ # @param header_name [String] Custom header name (default: 'HTTP_X_API_KEY')
192
+ # @param param_name [String] Query/form parameter name (default: 'api_key')
193
+ # @return [String, nil] The API key or nil
194
+ def extract_api_key(header_name: "HTTP_X_API_KEY", param_name: "api_key")
195
+ # Try custom header first
196
+ api_key = @request.env[header_name]
197
+ return api_key if api_key
198
+
199
+ # Try Authorization header with "Bearer" prefix
200
+ bearer_token = extract_bearer_token
201
+ return bearer_token if bearer_token
202
+
203
+ # Try request parameters (query string or form data)
204
+ @request.params[param_name] if @request.respond_to?(:params)
205
+ end
206
+
207
+ # Helper method to check if user is in a development environment
208
+ # @return [Boolean] true if Rails.env.development?
209
+ def development_env?
210
+ Rails.env.development?
211
+ end
28
212
  end
29
213
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module GatewayIdentifiers
5
+ # Example Gateway identifier for API key-based authentication.
6
+ #
7
+ # This identifier looks for API keys in various locations:
8
+ # - Authorization header (Bearer token)
9
+ # - Custom X-API-Key header
10
+ # - Query parameters
11
+ #
12
+ # @example Usage in ApplicationGateway
13
+ # class ApplicationGateway < ActionMCP::Gateway
14
+ # identified_by ActionMCP::GatewayIdentifiers::ApiKeyIdentifier
15
+ # end
16
+ #
17
+ # @example Configuration
18
+ # # config/mcp.yml
19
+ # authentication_methods: ["api_key"]
20
+ #
21
+ # @example API Key usage
22
+ # # Via Authorization header:
23
+ # # Authorization: Bearer your-api-key-here
24
+ # #
25
+ # # Via custom header:
26
+ # # X-API-Key: your-api-key-here
27
+ # #
28
+ # # Via query parameter:
29
+ # # ?api_key=your-api-key-here
30
+ class ApiKeyIdentifier < ActionMCP::GatewayIdentifier
31
+ identifier :user
32
+ authenticates :api_key
33
+
34
+ def resolve
35
+ api_key = extract_api_key
36
+ raise Unauthorized, "Missing API key" unless api_key
37
+
38
+ # Look up user by API key
39
+ # Assumes you have an api_key or api_token field on your User model
40
+ user = User.find_by(api_key: api_key) || User.find_by(api_token: api_key)
41
+ raise Unauthorized, "Invalid API key" unless user
42
+
43
+ # Optional: Check if API key is still valid (not expired, user active, etc.)
44
+ if user.respond_to?(:api_key_expired?) && user.api_key_expired?
45
+ raise Unauthorized, "API key expired"
46
+ end
47
+
48
+ if user.respond_to?(:active?) && !user.active?
49
+ raise Unauthorized, "User account inactive"
50
+ end
51
+
52
+ user
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module GatewayIdentifiers
5
+ # Example Gateway identifier for direct Devise integration.
6
+ #
7
+ # This identifier looks for the user directly in request.env['devise.user'] which
8
+ # may be set by custom Devise middleware or helpers.
9
+ #
10
+ # @example Usage in ApplicationGateway
11
+ # class ApplicationGateway < ActionMCP::Gateway
12
+ # identified_by ActionMCP::GatewayIdentifiers::DeviseIdentifier
13
+ # end
14
+ #
15
+ # @example Configuration
16
+ # # config/mcp.yml
17
+ # authentication_methods: ["devise"]
18
+ #
19
+ # @example Custom Devise middleware setup
20
+ # # In your application, you might set this in a before_action:
21
+ # # request.env['devise.user'] = current_user if user_signed_in?
22
+ class DeviseIdentifier < ActionMCP::GatewayIdentifier
23
+ identifier :user
24
+ authenticates :devise
25
+
26
+ def resolve
27
+ user = @request.env["devise.user"]
28
+ raise Unauthorized, "Not authenticated" unless user
29
+
30
+ user
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module GatewayIdentifiers
5
+ # Example Gateway identifier for custom request environment-based authentication.
6
+ #
7
+ # This identifier looks for user information in custom request headers or environment
8
+ # variables. Useful for authentication set up by upstream proxies, API gateways,
9
+ # or custom middleware.
10
+ #
11
+ # @example Usage in ApplicationGateway
12
+ # class ApplicationGateway < ActionMCP::Gateway
13
+ # identified_by ActionMCP::GatewayIdentifiers::RequestEnvIdentifier
14
+ # end
15
+ #
16
+ # @example Configuration
17
+ # # config/mcp.yml
18
+ # authentication_methods: ["request_env"]
19
+ #
20
+ # @example Nginx/Proxy setup
21
+ # # Your proxy/gateway might set headers like:
22
+ # # X-User-ID: 123
23
+ # # X-User-Email: user@example.com
24
+ # # X-User-Roles: admin,user
25
+ class RequestEnvIdentifier < ActionMCP::GatewayIdentifier
26
+ identifier :user
27
+ authenticates :request_env
28
+
29
+ def resolve
30
+ user_id = @request.env["HTTP_X_USER_ID"]
31
+ raise Unauthorized, "User ID header missing" unless user_id
32
+
33
+ # You might also want to get additional user info from headers
34
+ email = @request.env["HTTP_X_USER_EMAIL"]
35
+ roles = @request.env["HTTP_X_USER_ROLES"]&.split(",") || []
36
+
37
+ # Option 1: Find user in database
38
+ begin
39
+ user = User.find(user_id)
40
+ # Optional: verify email matches if provided
41
+ if email && user.email != email
42
+ raise Unauthorized, "User email mismatch"
43
+ end
44
+ user
45
+ rescue ActiveRecord::RecordNotFound
46
+ raise Unauthorized, "Invalid user"
47
+ end
48
+
49
+ # Option 2: Create a simple user object from headers (if you don't want DB lookup)
50
+ # OpenStruct.new(
51
+ # id: user_id,
52
+ # email: email,
53
+ # roles: roles
54
+ # )
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module GatewayIdentifiers
5
+ # Example Gateway identifier for Warden-based authentication.
6
+ #
7
+ # This identifier works with Warden middleware which is commonly used by Devise.
8
+ # Warden sets the authenticated user in request.env['warden.user'] after successful authentication.
9
+ #
10
+ # @example Usage in ApplicationGateway
11
+ # class ApplicationGateway < ActionMCP::Gateway
12
+ # identified_by ActionMCP::GatewayIdentifiers::WardenIdentifier
13
+ # end
14
+ #
15
+ # @example Configuration
16
+ # # config/mcp.yml
17
+ # authentication_methods: ["warden"]
18
+ #
19
+ # @example With Devise
20
+ # # In your controller or middleware, ensure Warden is configured:
21
+ # # devise_for :users
22
+ # # authenticate_user! # This sets up Warden env
23
+ class WardenIdentifier < ActionMCP::GatewayIdentifier
24
+ identifier :user
25
+ authenticates :warden
26
+
27
+ def resolve
28
+ warden = @request.env["warden"]
29
+ raise Unauthorized, "Warden not available" unless warden
30
+
31
+ user = warden.user
32
+ raise Unauthorized, "Not authenticated" unless user
33
+
34
+ user
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Example Gateway identifiers for common authentication patterns.
5
+ #
6
+ # These identifiers provide ready-to-use implementations for popular
7
+ # Rails authentication patterns. You can use them directly or as
8
+ # templates for your own custom identifiers.
9
+ #
10
+ # @example Using in ApplicationGateway
11
+ # class ApplicationGateway < ActionMCP::Gateway
12
+ # # Use multiple identifiers (tried in order)
13
+ # identified_by ActionMCP::GatewayIdentifiers::WardenIdentifier,
14
+ # ActionMCP::GatewayIdentifiers::ApiKeyIdentifier
15
+ # end
16
+ #
17
+ # @example Configuration
18
+ # # config/mcp.yml
19
+ # authentication_methods: ["warden", "api_key"]
20
+ module GatewayIdentifiers
21
+ autoload :WardenIdentifier, "action_mcp/gateway_identifiers/warden_identifier"
22
+ autoload :DeviseIdentifier, "action_mcp/gateway_identifiers/devise_identifier"
23
+ autoload :RequestEnvIdentifier, "action_mcp/gateway_identifiers/request_env_identifier"
24
+ autoload :ApiKeyIdentifier, "action_mcp/gateway_identifiers/api_key_identifier"
25
+ end
26
+ end
@@ -64,8 +64,6 @@ module ActionMCP
64
64
  when %r{^notifications/}
65
65
  process_notifications(rpc_method, params)
66
66
  true
67
- else
68
- nil
69
67
  end
70
68
  end
71
69
 
@@ -28,6 +28,7 @@ module ActionMCP
28
28
  # @return [String] The default prompt name.
29
29
  def self.default_prompt_name
30
30
  return "" if name.nil?
31
+
31
32
  name.demodulize.underscore.sub(/_prompt$/, "")
32
33
  end
33
34
 
@@ -55,6 +56,7 @@ module ActionMCP
55
56
  def meta(data = nil)
56
57
  if data
57
58
  raise ArgumentError, "_meta must be a hash" unless data.is_a?(Hash)
59
+
58
60
  self._meta = _meta.merge(data)
59
61
  else
60
62
  _meta
@@ -49,7 +49,7 @@ module ActionMCP
49
49
  #
50
50
  def render_resource_link(uri:, name: nil, description: nil, mime_type: nil, annotations: nil)
51
51
  Content::ResourceLink.new(uri, name: name, description: description,
52
- mime_type: mime_type, annotations: annotations)
52
+ mime_type: mime_type, annotations: annotations)
53
53
  end
54
54
  end
55
55
  end
@@ -27,7 +27,9 @@ module ActionMCP
27
27
  def abstract!
28
28
  @abstract = true
29
29
  # Unregister from the appropriate registry if already registered
30
- ActionMCP::ResourceTemplatesRegistry.unregister(self) if ActionMCP::ResourceTemplatesRegistry.items.values.include?(self)
30
+ return unless ActionMCP::ResourceTemplatesRegistry.items.values.include?(self)
31
+
32
+ ActionMCP::ResourceTemplatesRegistry.unregister(self)
31
33
  end
32
34
 
33
35
  def inherited(subclass)
@@ -92,6 +94,7 @@ module ActionMCP
92
94
  def meta(data = nil)
93
95
  if data
94
96
  raise ArgumentError, "_meta must be a hash" unless data.is_a?(Hash)
97
+
95
98
  @_meta ||= {}
96
99
  @_meta = @_meta.merge(data)
97
100
  else
@@ -110,13 +113,14 @@ module ActionMCP
110
113
  }.compact
111
114
 
112
115
  # Add _meta if present
113
- result[:_meta] = @_meta if @_meta && @_meta.any?
116
+ result[:_meta] = @_meta if @_meta&.any?
114
117
 
115
118
  result
116
119
  end
117
120
 
118
121
  def capability_name
119
122
  return "" if name.nil?
123
+
120
124
  @capability_name ||= name.demodulize.underscore.sub(/_template$/, "")
121
125
  end
122
126
 
@@ -2,14 +2,14 @@
2
2
 
3
3
  module ActionMCP
4
4
  module Server
5
- # Memory-based session object that mimics ActiveRecord Session
6
- class MemorySession
5
+ # Base session object that mimics ActiveRecord Session with common functionality
6
+ class BaseSession
7
7
  attr_accessor :id, :status, :initialized, :role, :messages_count,
8
8
  :sse_event_counter, :protocol_version, :client_info,
9
9
  :client_capabilities, :server_info, :server_capabilities,
10
10
  :tool_registry, :prompt_registry, :resource_registry,
11
11
  :created_at, :updated_at, :ended_at, :last_event_id,
12
- :session_data
12
+ :session_data, :consents
13
13
 
14
14
  def initialize(attributes = {}, store = nil)
15
15
  @store = store
@@ -21,12 +21,15 @@ module ActionMCP
21
21
  @message_counter = Concurrent::AtomicFixnum.new(0)
22
22
  @new_record = true
23
23
 
24
+ # Initialize consents as empty hash if not provided
25
+ @consents = {}
26
+
24
27
  attributes.each do |key, value|
25
28
  send("#{key}=", value) if respond_to?("#{key}=")
26
29
  end
27
30
  end
28
31
 
29
- # Mimic ActiveRecord interface
32
+ # ActiveRecord-like interface
30
33
  def new_record?
31
34
  @new_record
32
35
  end
@@ -37,7 +40,7 @@ module ActionMCP
37
40
 
38
41
  def save
39
42
  self.updated_at = Time.current
40
- @store.save_session(self) if @store
43
+ @store&.save_session(self)
41
44
  @new_record = false
42
45
  true
43
46
  end
@@ -58,7 +61,7 @@ module ActionMCP
58
61
  end
59
62
 
60
63
  def destroy
61
- @store.delete_session(id) if @store
64
+ @store&.delete_session(id)
62
65
  end
63
66
 
64
67
  def reload
@@ -133,32 +136,26 @@ module ActionMCP
133
136
  event = { event_id: event_id, data: data, created_at: Time.current }
134
137
  @sse_events << event
135
138
 
136
- # Maintain cache limit
137
- while @sse_events.size > max_events
138
- @sse_events.shift
139
- end
139
+ @sse_events.shift while @sse_events.size > max_events
140
140
 
141
141
  event
142
142
  end
143
143
 
144
144
  def get_sse_events_after(last_event_id, limit = 50)
145
- @sse_events.select { |e| e[:event_id] > last_event_id }
146
- .first(limit)
145
+ @sse_events.select { |e| e[:event_id] > last_event_id }.first(limit)
147
146
  end
148
147
 
149
148
  def cleanup_old_sse_events(max_age = 15.minutes)
150
149
  cutoff_time = Time.current - max_age
150
+ original_size = @sse_events.size
151
151
  @sse_events.delete_if { |e| e[:created_at] < cutoff_time }
152
+ original_size - @sse_events.size
152
153
  end
153
154
 
154
- # Calculates the maximum number of SSE events to store based on configuration
155
- # @return [Integer] The maximum number of events
156
155
  def max_stored_sse_events
157
156
  ActionMCP.configuration.max_stored_sse_events || 100
158
157
  end
159
158
 
160
- # Returns the SSE event retention period from configuration
161
- # @return [ActiveSupport::Duration] The retention period (default: 15 minutes)
162
159
  def sse_event_retention_period
163
160
  ActionMCP.configuration.sse_event_retention_period || 15.minutes
164
161
  end
@@ -196,9 +193,9 @@ module ActionMCP
196
193
 
197
194
  # Subscription management
198
195
  def resource_subscribe(uri)
199
- unless @subscriptions.any? { |s| s[:uri] == uri }
200
- @subscriptions << { uri: uri, created_at: Time.current }
201
- end
196
+ return if @subscriptions.any? { |s| s[:uri] == uri }
197
+
198
+ @subscriptions << { uri: uri, created_at: Time.current }
202
199
  end
203
200
 
204
201
  def resource_unsubscribe(uri)
@@ -310,6 +307,29 @@ module ActionMCP
310
307
  end
311
308
  end
312
309
 
310
+ # Consent management methods
311
+ def consent_granted_for?(key)
312
+ consents_hash = consents.is_a?(String) ? JSON.parse(consents) : consents
313
+ consents_hash ||= {}
314
+ consents_hash[key] == true
315
+ end
316
+
317
+ def grant_consent(key)
318
+ consents_hash = consents.is_a?(String) ? JSON.parse(consents) : consents
319
+ consents_hash ||= {}
320
+ consents_hash[key] = true
321
+ self.consents = consents_hash
322
+ save!
323
+ end
324
+
325
+ def revoke_consent(key)
326
+ consents_hash = consents.is_a?(String) ? JSON.parse(consents) : consents
327
+ consents_hash ||= {}
328
+ consents_hash.delete(key)
329
+ self.consents = consents_hash
330
+ save!
331
+ end
332
+
313
333
  private
314
334
 
315
335
  def normalize_name(class_or_name, type)
@@ -352,7 +372,6 @@ module ActionMCP
352
372
  end
353
373
 
354
374
  def send_tools_list_changed_notification
355
- # Only send if server capabilities allow it
356
375
  return unless server_capabilities.dig("tools", "listChanged")
357
376
 
358
377
  write(JSON_RPC::Notification.new(method: "notifications/tools/list_changed"))
@@ -370,9 +389,7 @@ module ActionMCP
370
389
  write(JSON_RPC::Notification.new(method: "notifications/resources/list_changed"))
371
390
  end
372
391
 
373
- public
374
-
375
- # Simple collection classes to mimic ActiveRecord associations
392
+ # Collection classes
376
393
  class MessageCollection < Array
377
394
  def create!(attributes)
378
395
  self << attributes
@@ -380,7 +397,6 @@ module ActionMCP
380
397
  end
381
398
 
382
399
  def order(field)
383
- # Simple ordering implementation
384
400
  sort_by { |msg| msg[field] || msg[field.to_s] }
385
401
  end
386
402
  end
@@ -413,8 +429,7 @@ module ActionMCP
413
429
  size
414
430
  end
415
431
 
416
- def where(condition, value)
417
- # Simple implementation for "event_id > ?" condition
432
+ def where(_condition, value)
418
433
  select { |e| e[:event_id] > value }
419
434
  end
420
435