actionmcp 0.60.2 → 0.71.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -59
  3. data/app/controllers/action_mcp/application_controller.rb +95 -28
  4. data/app/controllers/action_mcp/oauth/metadata_controller.rb +13 -13
  5. data/app/controllers/action_mcp/oauth/registration_controller.rb +206 -0
  6. data/app/models/action_mcp/oauth_client.rb +157 -0
  7. data/app/models/action_mcp/oauth_token.rb +141 -0
  8. data/app/models/action_mcp/session/message.rb +12 -12
  9. data/app/models/action_mcp/session/resource.rb +2 -2
  10. data/app/models/action_mcp/session/sse_event.rb +2 -2
  11. data/app/models/action_mcp/session/subscription.rb +2 -2
  12. data/app/models/action_mcp/session.rb +68 -43
  13. data/config/routes.rb +1 -0
  14. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +42 -0
  15. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +37 -0
  16. data/lib/action_mcp/capability.rb +2 -0
  17. data/lib/action_mcp/client/json_rpc_handler.rb +9 -9
  18. data/lib/action_mcp/client/jwt_client_provider.rb +134 -0
  19. data/lib/action_mcp/configuration.rb +90 -11
  20. data/lib/action_mcp/engine.rb +26 -1
  21. data/lib/action_mcp/filtered_logger.rb +32 -0
  22. data/lib/action_mcp/oauth/active_record_storage.rb +183 -0
  23. data/lib/action_mcp/oauth/memory_storage.rb +23 -1
  24. data/lib/action_mcp/oauth/middleware.rb +33 -0
  25. data/lib/action_mcp/oauth/provider.rb +49 -13
  26. data/lib/action_mcp/oauth.rb +12 -0
  27. data/lib/action_mcp/prompt.rb +14 -0
  28. data/lib/action_mcp/registry_base.rb +25 -4
  29. data/lib/action_mcp/resource_response.rb +110 -0
  30. data/lib/action_mcp/resource_template.rb +30 -2
  31. data/lib/action_mcp/server/capabilities.rb +3 -14
  32. data/lib/action_mcp/server/memory_session.rb +0 -1
  33. data/lib/action_mcp/server/prompts.rb +8 -1
  34. data/lib/action_mcp/server/resources.rb +9 -6
  35. data/lib/action_mcp/server/tools.rb +41 -20
  36. data/lib/action_mcp/server.rb +6 -3
  37. data/lib/action_mcp/sse_listener.rb +0 -7
  38. data/lib/action_mcp/test_helper.rb +5 -0
  39. data/lib/action_mcp/tool.rb +108 -4
  40. data/lib/action_mcp/tools_registry.rb +3 -0
  41. data/lib/action_mcp/version.rb +1 -1
  42. data/lib/generators/action_mcp/install/templates/mcp.yml +16 -16
  43. data/lib/tasks/action_mcp_tasks.rake +238 -0
  44. metadata +11 -1
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Custom logger that filters out repetitive MCP requests
5
+ class FilteredLogger < ActiveSupport::Logger
6
+ FILTERED_PATHS = [
7
+ "/oauth/authorize",
8
+ "/.well-known/oauth-protected-resource",
9
+ "/.well-known/oauth-authorization-server"
10
+ ].freeze
11
+
12
+ FILTERED_METHODS = [
13
+ "notifications/initialized",
14
+ "notifications/ping"
15
+ ].freeze
16
+
17
+ def add(severity, message = nil, progname = nil, &block)
18
+ # Filter out repetitive OAuth metadata requests
19
+ if message && message.is_a?(String)
20
+ return if FILTERED_PATHS.any? { |path| message.include?(path) && message.include?("200 OK") }
21
+
22
+ # Filter out repetitive MCP notifications
23
+ return if FILTERED_METHODS.any? { |method| message.include?(method) }
24
+
25
+ # Filter out MCP protocol version debug messages
26
+ return if message.include?("MCP-Protocol-Version header validation passed")
27
+ end
28
+
29
+ super
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module OAuth
5
+ # ActiveRecord storage for OAuth tokens and codes
6
+ # This is suitable for production multi-server environments
7
+ class ActiveRecordStorage
8
+ # Authorization code storage
9
+ def store_authorization_code(code, data)
10
+ OAuthToken.create!(
11
+ token: code,
12
+ token_type: OAuthToken::AUTHORIZATION_CODE,
13
+ client_id: data[:client_id],
14
+ user_id: data[:user_id],
15
+ redirect_uri: data[:redirect_uri],
16
+ scope: data[:scope],
17
+ code_challenge: data[:code_challenge],
18
+ code_challenge_method: data[:code_challenge_method],
19
+ expires_at: data[:expires_at],
20
+ metadata: data.except(:client_id, :user_id, :redirect_uri, :scope,
21
+ :code_challenge, :code_challenge_method, :expires_at)
22
+ )
23
+ end
24
+
25
+ def retrieve_authorization_code(code)
26
+ token = OAuthToken.authorization_codes.active.find_by(token: code)
27
+ return nil unless token
28
+
29
+ {
30
+ client_id: token.client_id,
31
+ user_id: token.user_id,
32
+ redirect_uri: token.redirect_uri,
33
+ scope: token.scope,
34
+ code_challenge: token.code_challenge,
35
+ code_challenge_method: token.code_challenge_method,
36
+ expires_at: token.expires_at,
37
+ created_at: token.created_at
38
+ }.merge(token.metadata || {})
39
+ end
40
+
41
+ def remove_authorization_code(code)
42
+ OAuthToken.authorization_codes.where(token: code).destroy_all
43
+ end
44
+
45
+ # Access token storage
46
+ def store_access_token(token, data)
47
+ OAuthToken.create!(
48
+ token: token,
49
+ token_type: OAuthToken::ACCESS_TOKEN,
50
+ client_id: data[:client_id],
51
+ user_id: data[:user_id],
52
+ scope: data[:scope],
53
+ expires_at: data[:expires_at],
54
+ metadata: data.except(:client_id, :user_id, :scope, :expires_at)
55
+ )
56
+ end
57
+
58
+ def retrieve_access_token(token)
59
+ token_record = OAuthToken.access_tokens.find_by(token: token)
60
+ return nil unless token_record
61
+
62
+ {
63
+ client_id: token_record.client_id,
64
+ user_id: token_record.user_id,
65
+ scope: token_record.scope,
66
+ expires_at: token_record.expires_at,
67
+ created_at: token_record.created_at,
68
+ active: token_record.still_valid?
69
+ }.merge(token_record.metadata || {})
70
+ end
71
+
72
+ def remove_access_token(token)
73
+ OAuthToken.access_tokens.where(token: token).destroy_all
74
+ end
75
+
76
+ # Refresh token storage
77
+ def store_refresh_token(token, data)
78
+ OAuthToken.create!(
79
+ token: token,
80
+ token_type: OAuthToken::REFRESH_TOKEN,
81
+ client_id: data[:client_id],
82
+ user_id: data[:user_id],
83
+ scope: data[:scope],
84
+ access_token: data[:access_token],
85
+ expires_at: data[:expires_at],
86
+ metadata: data.except(:client_id, :user_id, :scope, :access_token, :expires_at)
87
+ )
88
+ end
89
+
90
+ def retrieve_refresh_token(token)
91
+ token_record = OAuthToken.refresh_tokens.active.find_by(token: token)
92
+ return nil unless token_record
93
+
94
+ {
95
+ client_id: token_record.client_id,
96
+ user_id: token_record.user_id,
97
+ scope: token_record.scope,
98
+ access_token: token_record.access_token,
99
+ expires_at: token_record.expires_at,
100
+ created_at: token_record.created_at
101
+ }.merge(token_record.metadata || {})
102
+ end
103
+
104
+ def update_refresh_token(token, new_access_token)
105
+ token_record = OAuthToken.refresh_tokens.find_by(token: token)
106
+ token_record&.update!(access_token: new_access_token)
107
+ end
108
+
109
+ def remove_refresh_token(token)
110
+ OAuthToken.refresh_tokens.where(token: token).destroy_all
111
+ end
112
+
113
+ # Client registration storage
114
+ def store_client_registration(client_id, data)
115
+ client = OAuthClient.new
116
+
117
+ # Map data fields to model attributes
118
+ client.client_id = client_id
119
+ client.client_secret = data[:client_secret]
120
+ client.client_id_issued_at = data[:client_id_issued_at]
121
+ client.registration_access_token = data[:registration_access_token]
122
+
123
+ # Handle client metadata
124
+ metadata = data[:client_metadata] || {}
125
+ %w[
126
+ client_name redirect_uris grant_types response_types
127
+ token_endpoint_auth_method scope
128
+ ].each do |field|
129
+ client.send("#{field}=", metadata[field]) if metadata.key?(field)
130
+ end
131
+
132
+ # Store any additional metadata
133
+ known_fields = %w[
134
+ client_name redirect_uris grant_types response_types
135
+ token_endpoint_auth_method scope
136
+ ]
137
+ additional_metadata = metadata.except(*known_fields)
138
+ client.metadata = additional_metadata if additional_metadata.present?
139
+
140
+ client.save!
141
+ data
142
+ end
143
+
144
+ def retrieve_client_registration(client_id)
145
+ client = OAuthClient.active.find_by(client_id: client_id)
146
+ return nil unless client
147
+
148
+ {
149
+ client_id: client.client_id,
150
+ client_secret: client.client_secret,
151
+ client_id_issued_at: client.client_id_issued_at,
152
+ registration_access_token: client.registration_access_token,
153
+ client_metadata: client.to_api_response
154
+ }
155
+ end
156
+
157
+ def remove_client_registration(client_id)
158
+ OAuthClient.where(client_id: client_id).destroy_all
159
+ end
160
+
161
+ # Cleanup expired tokens
162
+ def cleanup_expired
163
+ OAuthToken.cleanup_expired
164
+ end
165
+
166
+ # Statistics (for debugging/monitoring)
167
+ def stats
168
+ {
169
+ authorization_codes: OAuthToken.authorization_codes.active.count,
170
+ access_tokens: OAuthToken.access_tokens.active.count,
171
+ refresh_tokens: OAuthToken.refresh_tokens.active.count,
172
+ client_registrations: OAuthClient.active.count
173
+ }
174
+ end
175
+
176
+ # Clear all data (for testing)
177
+ def clear_all
178
+ OAuthToken.delete_all
179
+ OAuthClient.delete_all
180
+ end
181
+ end
182
+ end
183
+ end
@@ -9,6 +9,7 @@ module ActionMCP
9
9
  @authorization_codes = {}
10
10
  @access_tokens = {}
11
11
  @refresh_tokens = {}
12
+ @client_registrations = {}
12
13
  @mutex = Mutex.new
13
14
  end
14
15
 
@@ -77,6 +78,25 @@ module ActionMCP
77
78
  end
78
79
  end
79
80
 
81
+ # Client registration storage
82
+ def store_client_registration(client_id, data)
83
+ @mutex.synchronize do
84
+ @client_registrations[client_id] = data
85
+ end
86
+ end
87
+
88
+ def retrieve_client_registration(client_id)
89
+ @mutex.synchronize do
90
+ @client_registrations[client_id]
91
+ end
92
+ end
93
+
94
+ def remove_client_registration(client_id)
95
+ @mutex.synchronize do
96
+ @client_registrations.delete(client_id)
97
+ end
98
+ end
99
+
80
100
  # Cleanup expired tokens (optional utility method)
81
101
  def cleanup_expired
82
102
  current_time = Time.current
@@ -94,7 +114,8 @@ module ActionMCP
94
114
  {
95
115
  authorization_codes: @authorization_codes.size,
96
116
  access_tokens: @access_tokens.size,
97
- refresh_tokens: @refresh_tokens.size
117
+ refresh_tokens: @refresh_tokens.size,
118
+ client_registrations: @client_registrations.size
98
119
  }
99
120
  end
100
121
  end
@@ -105,6 +126,7 @@ module ActionMCP
105
126
  @authorization_codes.clear
106
127
  @access_tokens.clear
107
128
  @refresh_tokens.clear
129
+ @client_registrations.clear
108
130
  end
109
131
  end
110
132
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error"
4
+
3
5
  module ActionMCP
4
6
  module OAuth
5
7
  # OAuth middleware that integrates with Omniauth for request authentication
@@ -15,6 +17,15 @@ module ActionMCP
15
17
  # Skip OAuth processing for non-MCP requests or if OAuth not configured
16
18
  return @app.call(env) unless should_process_oauth?(request)
17
19
 
20
+ # Skip OAuth processing for metadata endpoints
21
+ if request.path.start_with?("/.well-known/") || request.path.start_with?("/oauth/")
22
+ return @app.call(env)
23
+ end
24
+
25
+ # Skip OAuth processing for initialization-related requests
26
+ if initialization_related_request?(request)
27
+ return @app.call(env)
28
+ end
18
29
 
19
30
  # Validate Bearer token for API requests
20
31
  if bearer_token = extract_bearer_token(request)
@@ -37,6 +48,28 @@ module ActionMCP
37
48
  true
38
49
  end
39
50
 
51
+ def initialization_related_request?(request)
52
+ # Only check JSON-RPC POST requests to MCP endpoints
53
+ # The path might include the mount path (e.g., /action_mcp/ or just /)
54
+ return false unless request.post? && request.content_type&.include?("application/json")
55
+
56
+ # Check if this is an MCP endpoint (ends with / or is the root)
57
+ path = request.path
58
+ return false unless path == "/" || path.match?(/\/action_mcp\/?$/)
59
+
60
+ # Read and parse the request body
61
+ body = request.body.read
62
+ request.body.rewind # Reset for subsequent reads
63
+
64
+ json = JSON.parse(body)
65
+ method = json["method"]
66
+
67
+ # Check if it's an initialization-related method
68
+ %w[initialize notifications/initialized].include?(method)
69
+ rescue JSON::ParserError, StandardError
70
+ false
71
+ end
72
+
40
73
 
41
74
  def extract_bearer_token(request)
42
75
  auth_header = request.headers["Authorization"] || request.headers["authorization"]
@@ -79,7 +79,7 @@ module ActionMCP
79
79
 
80
80
  # Generate refresh token if enabled
81
81
  refresh_token = nil
82
- if oauth_config["enable_refresh_tokens"]
82
+ if oauth_config[:enable_refresh_tokens]
83
83
  refresh_token = generate_refresh_token(
84
84
  client_id: client_id,
85
85
  scope: code_data[:scope],
@@ -206,13 +206,29 @@ module ActionMCP
206
206
  revoked
207
207
  end
208
208
 
209
+ # Register a new OAuth client (Dynamic Client Registration)
210
+ # @param client_info [Hash] Client registration information
211
+ # @return [Hash] Registered client information
212
+ def register_client(client_info)
213
+ # Store client registration
214
+ storage.store_client_registration(client_info[:client_id], client_info)
215
+ client_info
216
+ end
217
+
218
+ # Retrieve registered client information
219
+ # @param client_id [String] OAuth client identifier
220
+ # @return [Hash, nil] Client information or nil if not found
221
+ def get_client(client_id)
222
+ storage.retrieve_client_registration(client_id)
223
+ end
224
+
209
225
  # Client Credentials Grant (for server-to-server)
210
226
  # @param client_id [String] OAuth client identifier
211
227
  # @param client_secret [String] OAuth client secret
212
228
  # @param scope [String] Requested scope
213
229
  # @return [Hash] Token response
214
230
  def client_credentials_grant(client_id:, client_secret:, scope: nil)
215
- unless oauth_config["enable_client_credentials"]
231
+ unless oauth_config[:enable_client_credentials]
216
232
  raise UnsupportedGrantTypeError, "Client credentials grant not supported"
217
233
  end
218
234
 
@@ -244,19 +260,37 @@ module ActionMCP
244
260
  private
245
261
 
246
262
  def oauth_config
247
- @oauth_config ||= ActionMCP.configuration.oauth_config || {}
263
+ @oauth_config ||= HashWithIndifferentAccess.new(ActionMCP.configuration.oauth_config || {})
248
264
  end
249
265
 
250
266
  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"]
267
+ # First check if client is registered via dynamic registration
268
+ client_info = get_client(client_id)
269
+ if client_info
270
+ # Validate client secret for confidential clients
271
+ if client_info[:client_secret]
272
+ unless client_secret == client_info[:client_secret]
273
+ raise InvalidClientError, "Invalid client credentials"
274
+ end
275
+ elsif require_secret
276
+ raise InvalidClientError, "Client authentication required"
277
+ end
278
+ return true
279
+ end
280
+
281
+ # Fall back to custom provider validation
282
+ provider_class = oauth_config[:provider]
254
283
  if provider_class && provider_class.respond_to?(:validate_client)
255
284
  provider_class.validate_client(client_id, client_secret)
256
285
  elsif require_secret && client_secret.nil?
257
286
  raise InvalidClientError, "Client authentication required"
287
+ else
288
+ # In development, allow unregistered clients if configured
289
+ if Rails.env.development? && oauth_config[:allow_unregistered_clients] != false
290
+ return true
291
+ end
292
+ raise InvalidClientError, "Unknown client"
258
293
  end
259
- # Default: allow any client for development
260
294
  end
261
295
 
262
296
  def validate_pkce(code_challenge, method, code_verifier)
@@ -271,7 +305,7 @@ module ActionMCP
271
305
  raise InvalidGrantError, "Invalid code verifier"
272
306
  end
273
307
  when "plain"
274
- unless oauth_config["allow_plain_pkce"]
308
+ unless oauth_config[:allow_plain_pkce]
275
309
  raise InvalidGrantError, "Plain PKCE not allowed"
276
310
  end
277
311
  unless code_challenge == code_verifier
@@ -283,7 +317,7 @@ module ActionMCP
283
317
  end
284
318
 
285
319
  def validate_scope(scope)
286
- supported_scopes = oauth_config["scopes_supported"] || [ "mcp:tools", "mcp:resources", "mcp:prompts" ]
320
+ supported_scopes = oauth_config.fetch(:scopes_supported, [ "mcp:tools", "mcp:resources", "mcp:prompts" ])
287
321
  requested_scopes = scope.split(" ")
288
322
  unsupported = requested_scopes - supported_scopes
289
323
  if unsupported.any?
@@ -292,7 +326,7 @@ module ActionMCP
292
326
  end
293
327
 
294
328
  def default_scope
295
- oauth_config["default_scope"] || "mcp:tools mcp:resources mcp:prompts"
329
+ oauth_config.fetch(:default_scope, "mcp:tools mcp:resources mcp:prompts")
296
330
  end
297
331
 
298
332
  def generate_access_token(client_id:, scope:, user_id:)
@@ -325,17 +359,19 @@ module ActionMCP
325
359
  end
326
360
 
327
361
  def token_expires_in
328
- oauth_config["access_token_expires_in"] || 3600 # 1 hour
362
+ oauth_config.fetch(:access_token_expires_in, 3600) # 1 hour
329
363
  end
330
364
 
331
365
  def refresh_token_expires_in
332
- oauth_config["refresh_token_expires_in"] || 7.days.to_i # 1 week
366
+ oauth_config.fetch(:refresh_token_expires_in, 7.days.to_i) # 1 week
333
367
  end
334
368
 
335
369
  # Storage methods - these delegate to a configurable storage backend
336
370
  def storage
337
371
  @storage ||= begin
338
- storage_class = oauth_config["storage"] || "ActionMCP::OAuth::MemoryStorage"
372
+ # Default to ActiveRecord storage for production, memory for test
373
+ default_storage = Rails.env.test? ? "ActionMCP::OAuth::MemoryStorage" : "ActionMCP::OAuth::ActiveRecordStorage"
374
+ storage_class = oauth_config.fetch(:storage, default_storage)
339
375
  storage_class = storage_class.constantize if storage_class.is_a?(String)
340
376
  storage_class.new
341
377
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module OAuth
5
+ # Load OAuth components
6
+ autoload :Error, "action_mcp/oauth/error"
7
+ autoload :Provider, "action_mcp/oauth/provider"
8
+ autoload :Middleware, "action_mcp/oauth/middleware"
9
+ autoload :MemoryStorage, "action_mcp/oauth/memory_storage"
10
+ autoload :ActiveRecordStorage, "action_mcp/oauth/active_record_storage"
11
+ end
12
+ end
@@ -27,6 +27,7 @@ module ActionMCP
27
27
  #
28
28
  # @return [String] The default prompt name.
29
29
  def self.default_prompt_name
30
+ return "" if name.nil?
30
31
  name.demodulize.underscore.sub(/_prompt$/, "")
31
32
  end
32
33
 
@@ -37,6 +38,19 @@ module ActionMCP
37
38
  :prompt
38
39
  end
39
40
 
41
+ def unregister_from_registry
42
+ ActionMCP::PromptsRegistry.unregister(self) if ActionMCP::PromptsRegistry.items.values.include?(self)
43
+ end
44
+
45
+ # Hook called when a class inherits from Prompt
46
+ def inherited(subclass)
47
+ super
48
+ # Run the ActiveSupport load hook when a prompt is defined
49
+ subclass.class_eval do
50
+ ActiveSupport.run_load_hooks(:action_mcp_prompt, subclass)
51
+ end
52
+ end
53
+
40
54
  # Sets or retrieves the _meta field
41
55
  def meta(data = nil)
42
56
  if data
@@ -11,11 +11,25 @@ module ActionMCP
11
11
  #
12
12
  # @return [Hash] A hash of registered items.
13
13
  def items
14
- @items = item_klass.descendants.each_with_object({}) do |klass, hash|
15
- next if klass.abstract?
14
+ @items ||= {}
15
+ end
16
+
17
+ # Register an item explicitly
18
+ #
19
+ # @param klass [Class] The class to register
20
+ # @return [void]
21
+ def register(klass)
22
+ return if klass.abstract?
23
+
24
+ items[klass.capability_name] = klass
25
+ end
16
26
 
17
- hash[klass.capability_name] = klass
18
- end
27
+ # Unregister an item
28
+ #
29
+ # @param klass [Class] The class to unregister
30
+ # @return [void]
31
+ def unregister(klass)
32
+ items.delete(klass.capability_name)
19
33
  end
20
34
 
21
35
  # Retrieve an item by name.
@@ -44,6 +58,13 @@ module ActionMCP
44
58
  RegistryScope.new(items)
45
59
  end
46
60
 
61
+ # Clears the registry cache.
62
+ #
63
+ # @return [void]
64
+ def clear!
65
+ @items = nil
66
+ end
67
+
47
68
  private
48
69
 
49
70
  # Helper to determine if an item is abstract.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Manages resource resolution results and errors for ResourceTemplate operations.
5
+ # Unlike ToolResponse, ResourceResponse only uses JSON-RPC protocol errors per MCP spec.
6
+ class ResourceResponse < BaseResponse
7
+ attr_reader :contents
8
+
9
+ delegate :empty?, :size, :each, :find, :map, to: :contents
10
+
11
+ def initialize
12
+ super
13
+ @contents = []
14
+ end
15
+
16
+ # Add a resource content item to the response
17
+ def add_content(content)
18
+ @contents << content
19
+ content # Return the content for chaining
20
+ end
21
+
22
+ # Add multiple content items
23
+ def add_contents(contents_array)
24
+ @contents.concat(contents_array)
25
+ self
26
+ end
27
+
28
+ # Mark as error with ResourceTemplate-specific error types
29
+ def mark_as_template_not_found!(uri)
30
+ mark_as_error!(
31
+ :invalid_params,
32
+ message: "Resource template not found for URI: #{uri}",
33
+ data: { uri: uri, error_type: "TEMPLATE_NOT_FOUND" }
34
+ )
35
+ end
36
+
37
+ def mark_as_parameter_validation_failed!(missing_params, uri)
38
+ mark_as_error!(
39
+ :invalid_params,
40
+ message: "Required parameters missing: #{missing_params.join(', ')}",
41
+ data: {
42
+ uri: uri,
43
+ missing_parameters: missing_params,
44
+ error_type: "PARAMETER_VALIDATION_FAILED"
45
+ }
46
+ )
47
+ end
48
+
49
+ def mark_as_resolution_failed!(uri, reason = nil)
50
+ message = "Resource resolution failed for URI: #{uri}"
51
+ message += " - #{reason}" if reason
52
+
53
+ mark_as_error!(
54
+ :internal_error,
55
+ message: message,
56
+ data: {
57
+ uri: uri,
58
+ reason: reason,
59
+ error_type: "RESOLUTION_FAILED"
60
+ }
61
+ )
62
+ end
63
+
64
+ def mark_as_callback_aborted!(uri)
65
+ mark_as_error!(
66
+ :internal_error,
67
+ message: "Resource resolution was aborted by callback chain",
68
+ data: {
69
+ uri: uri,
70
+ error_type: "CALLBACK_ABORTED"
71
+ }
72
+ )
73
+ end
74
+
75
+ def mark_as_not_found!(uri)
76
+ # Use method_not_found for resource not found (closest standard JSON-RPC error)
77
+ mark_as_error!(
78
+ :method_not_found, # -32601 - closest standard error for "not found"
79
+ message: "Resource not found",
80
+ data: { uri: uri }
81
+ )
82
+ end
83
+
84
+ # Implementation of build_success_hash for ResourceResponse
85
+ def build_success_hash
86
+ {
87
+ contents: @contents.map(&:to_h)
88
+ }
89
+ end
90
+
91
+ # Implementation of compare_with_same_class for ResourceResponse
92
+ def compare_with_same_class(other)
93
+ contents == other.contents && is_error == other.is_error
94
+ end
95
+
96
+ # Implementation of hash_components for ResourceResponse
97
+ def hash_components
98
+ [ contents, is_error ]
99
+ end
100
+
101
+ # Pretty print for better debugging
102
+ def inspect
103
+ if is_error
104
+ "#<#{self.class.name} error: #{@error_message}>"
105
+ else
106
+ "#<#{self.class.name} contents: #{contents.size} items>"
107
+ end
108
+ end
109
+ end
110
+ end