vector_mcp 0.4.0 → 0.5.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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ # Value object representing the outcome of an authentication attempt.
6
+ # Replaces the unstructured Hash that previously flowed through the auth pipeline.
7
+ class AuthResult
8
+ attr_reader :user, :strategy, :authenticated_at
9
+
10
+ def initialize(authenticated:, user: nil, strategy: nil, authenticated_at: nil)
11
+ @authenticated = authenticated
12
+ @user = user
13
+ @strategy = strategy
14
+ @authenticated_at = authenticated_at || (Time.now if authenticated)
15
+ freeze
16
+ end
17
+
18
+ def authenticated? = @authenticated
19
+
20
+ def self.success(user:, strategy:, authenticated_at: Time.now)
21
+ new(authenticated: true, user: user, strategy: strategy, authenticated_at: authenticated_at)
22
+ end
23
+
24
+ def self.failure
25
+ new(authenticated: false)
26
+ end
27
+
28
+ def self.passthrough
29
+ new(authenticated: true)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -10,6 +10,7 @@ module VectorMCP
10
10
  def initialize
11
11
  @policies = {}
12
12
  @enabled = false
13
+ @logger = VectorMCP.logger_for("authorization")
13
14
  end
14
15
 
15
16
  # Enable authorization system
@@ -45,17 +46,12 @@ module VectorMCP
45
46
 
46
47
  resource_type = determine_resource_type(resource)
47
48
  policy = @policies[resource_type]
48
-
49
- # If no policy is defined, allow access (opt-in authorization)
50
49
  return true unless policy
51
50
 
52
- begin
53
- policy_result = policy.call(user, action, resource)
54
- policy_result ? true : false
55
- rescue StandardError
56
- # Log error but deny access for safety
57
- false
58
- end
51
+ !!policy.call(user, action, resource)
52
+ rescue StandardError => e
53
+ @logger.error("Authorization policy error for #{resource_type}: #{e.message}")
54
+ false
59
55
  end
60
56
 
61
57
  # Check if authorization is required
@@ -113,34 +113,18 @@ module VectorMCP
113
113
  new(authenticated: false)
114
114
  end
115
115
 
116
- # Create an authenticated session context from auth result
117
- # @param auth_result [Hash] the authentication result
118
- # @return [SessionContext] an authenticated session
116
+ # Create an authenticated session context from an AuthResult
117
+ # @param auth_result [VectorMCP::Security::AuthResult] the authentication outcome
118
+ # @return [SessionContext] an authenticated or anonymous session
119
119
  def self.from_auth_result(auth_result)
120
- return anonymous unless auth_result&.dig(:authenticated)
121
-
122
- user_data = auth_result[:user]
123
-
124
- # Handle special marker for authenticated nil user
125
- if user_data == :authenticated_nil_user
126
- new(
127
- user: nil,
128
- authenticated: true,
129
- auth_strategy: "custom",
130
- authenticated_at: Time.now
131
- )
132
- else
133
- # Extract strategy and authenticated_at only if user_data is a Hash
134
- strategy = user_data.is_a?(Hash) ? user_data[:strategy] : nil
135
- auth_time = user_data.is_a?(Hash) ? user_data[:authenticated_at] : nil
136
-
137
- new(
138
- user: user_data,
139
- authenticated: true,
140
- auth_strategy: strategy,
141
- authenticated_at: auth_time
142
- )
143
- end
120
+ return anonymous unless auth_result&.authenticated?
121
+
122
+ new(
123
+ user: auth_result.user,
124
+ authenticated: true,
125
+ auth_strategy: auth_result.strategy,
126
+ authenticated_at: auth_result.authenticated_at
127
+ )
144
128
  end
145
129
  end
146
130
  end
@@ -38,11 +38,7 @@ module VectorMCP
38
38
  return false unless api_key&.length&.positive?
39
39
 
40
40
  if secure_key_match?(api_key)
41
- {
42
- api_key: api_key,
43
- strategy: "api_key",
44
- authenticated_at: Time.now
45
- }
41
+ { api_key: api_key }
46
42
  else
47
43
  false
48
44
  end
@@ -16,20 +16,20 @@ module VectorMCP
16
16
  @handler = handler
17
17
  end
18
18
 
19
- # Authenticate a request using the custom handler
19
+ # Authenticate a request using the custom handler.
20
+ # If the handler returns a Hash with a :user key, the value is extracted
21
+ # so that AuthManager receives the user data directly.
20
22
  # @param request [Hash] the request object
21
- # @return [Object, false] result from custom handler or false if authentication failed
23
+ # @return [Object, nil, false] user data or false if authentication failed.
24
+ # A return of nil (from { user: nil }) signals "authenticated, no user object."
22
25
  def authenticate(request)
23
26
  result = @handler.call(request)
27
+ return false unless result && result != false
24
28
 
25
- # Ensure result includes strategy info if it's successful
26
- if result && result != false
27
- format_successful_result(result)
28
- else
29
- false
30
- end
31
- rescue NoMemoryError, StandardError
32
- # Log error but return false for security
29
+ return result unless result.is_a?(Hash) && result.key?(:user)
30
+
31
+ result[:user]
32
+ rescue StandardError, NoMemoryError
33
33
  false
34
34
  end
35
35
 
@@ -38,33 +38,6 @@ module VectorMCP
38
38
  def configured?
39
39
  !@handler.nil?
40
40
  end
41
-
42
- private
43
-
44
- # Format successful authentication result with strategy metadata
45
- # @param result [Object] the result from the custom handler
46
- # @return [Object] formatted result with strategy metadata
47
- def format_successful_result(result)
48
- case result
49
- when Hash
50
- # If result has a :user key, extract it and use as main user data
51
- if result.key?(:user)
52
- user_data = result[:user]
53
- # For nil user, return a marker that will become nil in session context
54
- return :authenticated_nil_user if user_data.nil?
55
-
56
- user_data
57
- else
58
- result.merge(strategy: "custom", authenticated_at: Time.now)
59
- end
60
- else
61
- {
62
- user: result,
63
- strategy: "custom",
64
- authenticated_at: Time.now
65
- }
66
- end
67
- end
68
41
  end
69
42
  end
70
43
  end
@@ -43,16 +43,7 @@ module VectorMCP
43
43
 
44
44
  begin
45
45
  decoded = JWT.decode(token, @secret, true, @options)
46
- payload = decoded[0] # First element is the payload
47
- headers = decoded[1] # Second element is the headers
48
-
49
- # Return user info from JWT payload
50
- {
51
- **payload,
52
- strategy: "jwt",
53
- authenticated_at: Time.now,
54
- jwt_headers: headers
55
- }
46
+ decoded[0]
56
47
  rescue JWT::ExpiredSignature, JWT::InvalidIssuerError, JWT::InvalidAudienceError,
57
48
  JWT::VerificationError, JWT::DecodeError, StandardError
58
49
  false # Token validation failed
@@ -39,19 +39,7 @@ module VectorMCP
39
39
  # Notifies connected clients that the list of available prompts has changed.
40
40
  # @return [void]
41
41
  def notify_prompts_list_changed
42
- return unless transport && @prompts_list_changed
43
-
44
- notification_method = "notifications/prompts/list_changed"
45
- begin
46
- if transport.respond_to?(:send_notification)
47
- logger.debug("Sending prompts list changed notification.")
48
- transport.send_notification(notification_method)
49
- else
50
- logger.warn("Transport does not support sending notifications/prompts/list_changed.")
51
- end
52
- rescue StandardError => e
53
- logger.error("Failed to send prompts list changed notification: #{e.class.name}: #{e.message}")
54
- end
42
+ send_list_changed_notification("prompts") if @prompts_list_changed
55
43
  end
56
44
 
57
45
  # Resets the `roots_list_changed` flag to false.
@@ -63,19 +51,7 @@ module VectorMCP
63
51
  # Notifies connected clients that the list of available roots has changed.
64
52
  # @return [void]
65
53
  def notify_roots_list_changed
66
- return unless transport && @roots_list_changed
67
-
68
- notification_method = "notifications/roots/list_changed"
69
- begin
70
- if transport.respond_to?(:send_notification)
71
- logger.debug("Sending roots list changed notification.")
72
- transport.send_notification(notification_method)
73
- else
74
- logger.warn("Transport does not support sending notifications/roots/list_changed.")
75
- end
76
- rescue StandardError => e
77
- logger.error("Failed to send roots list changed notification: #{e.class.name}: #{e.message}")
78
- end
54
+ send_list_changed_notification("roots") if @roots_list_changed
79
55
  end
80
56
 
81
57
  # Registers a session as a subscriber to prompt list changes.
@@ -87,6 +63,26 @@ module VectorMCP
87
63
 
88
64
  private
89
65
 
66
+ # Sends a `notifications/<kind>/list_changed` notification to the transport.
67
+ # No-op if no transport is attached. Logs a warning if the transport does not
68
+ # implement `send_notification` (intentional extension point for alternate
69
+ # transports).
70
+ # @api private
71
+ # @param kind [String] One of "prompts" or "roots".
72
+ def send_list_changed_notification(kind)
73
+ return unless transport
74
+
75
+ notification_method = "notifications/#{kind}/list_changed"
76
+ if transport.respond_to?(:send_notification)
77
+ logger.debug("Sending #{kind} list changed notification.")
78
+ transport.send_notification(notification_method)
79
+ else
80
+ logger.warn("Transport does not support sending #{notification_method}.")
81
+ end
82
+ rescue StandardError => e
83
+ logger.error("Failed to send #{kind} list changed notification: #{e.class.name}: #{e.message}")
84
+ end
85
+
90
86
  # Configures sampling capabilities based on provided configuration.
91
87
  # @api private
92
88
  def configure_sampling_capabilities(config)
@@ -20,17 +20,18 @@ module VectorMCP
20
20
  method = message["method"]
21
21
  params = message["params"] || {}
22
22
 
23
- if id && method # Request
23
+ case classify_message(id, method)
24
+ when :request
24
25
  logger.debug("[#{session_id}] Request [#{id}]: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
25
26
  handle_request(id, method, params, session)
26
- elsif method # Notification
27
+ when :notification
27
28
  logger.debug("[#{session_id}] Notification: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
28
29
  handle_notification(method, params, session)
29
- nil # Notifications do not have a return value to send back to client
30
- elsif id # Invalid: Has ID but no method
30
+ nil
31
+ when :invalid_missing_method
31
32
  logger.warn("[#{session_id}] Invalid message: Has ID [#{id}] but no method. #{message.inspect}")
32
33
  raise VectorMCP::InvalidRequestError.new("Request object must include a 'method' member.", request_id: id)
33
- else # Invalid: No ID and no method
34
+ when :invalid_missing_both
34
35
  logger.warn("[#{session_id}] Invalid message: Missing both 'id' and 'method'. #{message.inspect}")
35
36
  raise VectorMCP::InvalidRequestError.new("Invalid message format", request_id: nil)
36
37
  end
@@ -60,6 +61,16 @@ module VectorMCP
60
61
 
61
62
  private
62
63
 
64
+ # Classify a JSON-RPC message based on presence of id and method fields.
65
+ # @api private
66
+ def classify_message(id, method)
67
+ return :request if id && method
68
+ return :notification if method
69
+ return :invalid_missing_method if id
70
+
71
+ :invalid_missing_both
72
+ end
73
+
63
74
  # Internal handler for JSON-RPC requests.
64
75
  # @api private
65
76
  def handle_request(id, method, params, session)
@@ -72,11 +83,11 @@ module VectorMCP
72
83
  end
73
84
 
74
85
  # Validates that the session is properly initialized for the given request.
86
+ # Transports are contractually required to pass a VectorMCP::Session here —
87
+ # never the SessionManager wrapper struct.
75
88
  # @api private
76
89
  def validate_session_initialization(id, method, _params, session)
77
- # Handle both direct VectorMCP::Session and BaseSessionManager::Session wrapper
78
- actual_session = session.respond_to?(:context) ? session.context : session
79
- return if actual_session.initialized?
90
+ return if session.initialized?
80
91
 
81
92
  # Allow "initialize" even if not marked initialized yet by server
82
93
  return if method == "initialize"
@@ -115,9 +126,7 @@ module VectorMCP
115
126
  # Internal handler for JSON-RPC notifications.
116
127
  # @api private
117
128
  def handle_notification(method, params, session)
118
- # Handle both direct VectorMCP::Session and BaseSessionManager::Session wrapper
119
- actual_session = session.respond_to?(:context) ? session.context : session
120
- unless actual_session.initialized? || method == "initialized"
129
+ unless session.initialized? || method == "initialized"
121
130
  logger.warn("Ignoring notification '#{method}' before session is initialized. Params: #{params.inspect}")
122
131
  return
123
132
  end
@@ -162,9 +171,7 @@ module VectorMCP
162
171
  # @api private
163
172
  def session_method(method_name)
164
173
  lambda do |params, session, _server|
165
- # Handle both direct VectorMCP::Session and BaseSessionManager::Session wrapper
166
- actual_session = session.respond_to?(:context) ? session.context : session
167
- actual_session.public_send(method_name, params)
174
+ session.public_send(method_name, params)
168
175
  end
169
176
  end
170
177
  end
@@ -131,114 +131,74 @@ module VectorMCP
131
131
  end
132
132
 
133
133
  # Helper method to register an image resource from a file path.
134
+ # Thin wrapper: delegates schema-building to Definitions::Resource.from_image_file,
135
+ # then stores the result via register_resource.
134
136
  #
135
137
  # @param uri [String] Unique URI for the resource.
136
138
  # @param file_path [String] Path to the image file.
137
139
  # @param name [String, nil] Human-readable name (auto-generated if nil).
138
140
  # @param description [String, nil] Description (auto-generated if nil).
139
- # @return [VectorMCP::Definitions::Resource] The registered resource.
141
+ # @return [self]
140
142
  # @raise [ArgumentError] If the file doesn't exist or isn't a valid image.
141
143
  def register_image_resource(uri:, file_path:, name: nil, description: nil)
142
144
  resource = VectorMCP::Definitions::Resource.from_image_file(
143
- uri: uri,
144
- file_path: file_path,
145
- name: name,
146
- description: description
147
- )
148
-
149
- register_resource(
150
- uri: resource.uri,
151
- name: resource.name,
152
- description: resource.description,
153
- mime_type: resource.mime_type,
154
- &resource.handler
145
+ uri: uri, file_path: file_path, name: name, description: description
155
146
  )
147
+ register_resource(uri: resource.uri, name: resource.name,
148
+ description: resource.description, mime_type: resource.mime_type, &resource.handler)
156
149
  end
157
150
 
158
151
  # Helper method to register an image resource from binary data.
152
+ # Thin wrapper: delegates to Definitions::Resource.from_image_data.
159
153
  #
160
154
  # @param uri [String] Unique URI for the resource.
161
155
  # @param image_data [String] Binary image data.
162
156
  # @param name [String] Human-readable name.
163
157
  # @param description [String, nil] Description (auto-generated if nil).
164
158
  # @param mime_type [String, nil] MIME type (auto-detected if nil).
165
- # @return [VectorMCP::Definitions::Resource] The registered resource.
166
- # @raise [ArgumentError] If the data isn't valid image data.
159
+ # @return [self]
167
160
  def register_image_resource_from_data(uri:, image_data:, name:, description: nil, mime_type: nil)
168
161
  resource = VectorMCP::Definitions::Resource.from_image_data(
169
- uri: uri,
170
- image_data: image_data,
171
- name: name,
172
- description: description,
173
- mime_type: mime_type
174
- )
175
-
176
- register_resource(
177
- uri: resource.uri,
178
- name: resource.name,
179
- description: resource.description,
180
- mime_type: resource.mime_type,
181
- &resource.handler
162
+ uri: uri, image_data: image_data, name: name, description: description, mime_type: mime_type
182
163
  )
164
+ register_resource(uri: resource.uri, name: resource.name,
165
+ description: resource.description, mime_type: resource.mime_type, &resource.handler)
183
166
  end
184
167
 
185
168
  # Helper method to register a tool that accepts image inputs.
169
+ # Thin wrapper: delegates schema-building to Definitions::Tool.with_image_support.
186
170
  #
187
171
  # @param name [String] Unique name for the tool.
188
172
  # @param description [String] Human-readable description.
189
173
  # @param image_parameter [String] Name of the image parameter (default: "image").
190
174
  # @param additional_parameters [Hash] Additional JSON Schema properties.
191
175
  # @param required_parameters [Array<String>] List of required parameter names.
192
- # @param block [Proc] The tool handler block.
193
- # @return [VectorMCP::Definitions::Tool] The registered tool.
194
- def register_image_tool(name:, description:, image_parameter: "image", additional_parameters: {}, required_parameters: [], &)
195
- # Build the input schema with image support
196
- image_property = {
197
- type: "string",
198
- description: "Base64 encoded image data or file path to image",
199
- contentEncoding: "base64",
200
- contentMediaType: "image/*"
201
- }
202
-
203
- properties = { image_parameter => image_property }.merge(additional_parameters)
204
-
205
- input_schema = {
206
- type: "object",
207
- properties: properties,
208
- required: required_parameters
209
- }
210
-
211
- register_tool(
212
- name: name,
213
- description: description,
214
- input_schema: input_schema,
215
- &
176
+ # @return [self]
177
+ def register_image_tool(name:, description:, image_parameter: "image",
178
+ additional_parameters: {}, required_parameters: [], &handler)
179
+ tool = VectorMCP::Definitions::Tool.with_image_support(
180
+ name: name, description: description, image_parameter: image_parameter,
181
+ additional_parameters: additional_parameters, required_parameters: required_parameters, &handler
216
182
  )
183
+ register_tool(name: tool.name, description: tool.description,
184
+ input_schema: tool.input_schema, &tool.handler)
217
185
  end
218
186
 
219
187
  # Helper method to register a prompt that supports image arguments.
188
+ # Thin wrapper: delegates to Definitions::Prompt.with_image_support.
220
189
  #
221
190
  # @param name [String] Unique name for the prompt.
222
191
  # @param description [String] Human-readable description.
223
192
  # @param image_argument [String] Name of the image argument (default: "image").
224
193
  # @param additional_arguments [Array<Hash>] Additional prompt arguments.
225
- # @param block [Proc] The prompt handler block.
226
- # @return [VectorMCP::Definitions::Prompt] The registered prompt.
227
- def register_image_prompt(name:, description:, image_argument: "image", additional_arguments: [], &)
194
+ # @return [self]
195
+ def register_image_prompt(name:, description:, image_argument: "image", additional_arguments: [], &handler)
228
196
  prompt = VectorMCP::Definitions::Prompt.with_image_support(
229
- name: name,
230
- description: description,
231
- image_argument_name: image_argument,
232
- additional_arguments: additional_arguments,
233
- &
234
- )
235
-
236
- register_prompt(
237
- name: prompt.name,
238
- description: prompt.description,
239
- arguments: prompt.arguments,
240
- &prompt.handler
197
+ name: name, description: description, image_argument_name: image_argument,
198
+ additional_arguments: additional_arguments, &handler
241
199
  )
200
+ register_prompt(name: prompt.name, description: prompt.description,
201
+ arguments: prompt.arguments, &prompt.handler)
242
202
  end
243
203
 
244
204
  private
@@ -262,6 +222,34 @@ module VectorMCP
262
222
  raise ArgumentError, "Invalid input_schema structure: #{e.message}"
263
223
  end
264
224
 
225
+ # Schema for a single prompt argument definition. Each entry names the
226
+ # required/optional key, whether it is required, and the rule that validates
227
+ # its value. Rules return nil on success or an error message fragment.
228
+ PROMPT_ARG_SCHEMA = {
229
+ "name" => {
230
+ required: true,
231
+ missing_message: "missing :name",
232
+ rule: lambda { |v|
233
+ next "must be a String or Symbol. Found: #{v.class}" unless v.is_a?(String) || v.is_a?(Symbol)
234
+
235
+ "cannot be empty." if v.to_s.strip.empty?
236
+ }
237
+ },
238
+ "description" => {
239
+ required: false,
240
+ rule: ->(v) { "must be a String if provided. Found: #{v.class}" unless v.nil? || v.is_a?(String) }
241
+ },
242
+ "required" => {
243
+ required: false,
244
+ rule: ->(v) { "must be true or false if provided. Found: #{v.inspect}" unless [true, false].include?(v) }
245
+ },
246
+ "type" => {
247
+ required: false,
248
+ rule: ->(v) { "must be a String if provided (e.g., JSON schema type). Found: #{v.class}" unless v.nil? || v.is_a?(String) }
249
+ }
250
+ }.freeze
251
+ private_constant :PROMPT_ARG_SCHEMA
252
+
265
253
  # Validates the structure of the `arguments` array provided to {#register_prompt}.
266
254
  # @api private
267
255
  def validate_prompt_arguments(argument_defs)
@@ -270,75 +258,37 @@ module VectorMCP
270
258
  argument_defs.each_with_index { |arg, idx| validate_single_prompt_argument(arg, idx) }
271
259
  end
272
260
 
273
- # Defines the keys allowed in a prompt argument definition hash.
274
- ALLOWED_PROMPT_ARG_KEYS = %w[name description required type].freeze
275
- private_constant :ALLOWED_PROMPT_ARG_KEYS
276
-
277
- # Validates a single prompt argument definition hash.
261
+ # Validates a single prompt argument definition hash against PROMPT_ARG_SCHEMA.
278
262
  # @api private
279
263
  def validate_single_prompt_argument(arg, idx)
280
264
  raise ArgumentError, "Prompt argument definition at index #{idx} must be a Hash. Found: #{arg.class}" unless arg.is_a?(Hash)
281
265
 
282
- validate_prompt_arg_name!(arg, idx)
283
- validate_prompt_arg_description!(arg, idx)
284
- validate_prompt_arg_required_flag!(arg, idx)
285
- validate_prompt_arg_type!(arg, idx)
286
- validate_prompt_arg_unknown_keys!(arg, idx)
287
- end
288
-
289
- # Validates the :name key of a prompt argument definition.
290
- # @api private
291
- def validate_prompt_arg_name!(arg, idx)
292
- name_val = arg[:name] || arg["name"]
293
- raise ArgumentError, "Prompt argument at index #{idx} missing :name" if name_val.nil?
294
- unless name_val.is_a?(String) || name_val.is_a?(Symbol)
295
- raise ArgumentError, "Prompt argument :name at index #{idx} must be a String or Symbol. Found: #{name_val.class}"
296
- end
297
- raise ArgumentError, "Prompt argument :name at index #{idx} cannot be empty." if name_val.to_s.strip.empty?
298
- end
299
-
300
- # Validates the :description key of a prompt argument definition.
301
- # @api private
302
- def validate_prompt_arg_description!(arg, idx)
303
- return unless arg.key?(:description) || arg.key?("description")
304
-
305
- desc_val = arg[:description] || arg["description"]
306
- return if desc_val.nil? || desc_val.is_a?(String)
307
-
308
- raise ArgumentError, "Prompt argument :description at index #{idx} must be a String if provided. Found: #{desc_val.class}"
309
- end
310
-
311
- # Validates the :required key of a prompt argument definition.
312
- # @api private
313
- def validate_prompt_arg_required_flag!(arg, idx)
314
- return unless arg.key?(:required) || arg.key?("required")
315
-
316
- req_val = arg[:required] || arg["required"]
317
- return if [true, false].include?(req_val)
318
-
319
- raise ArgumentError, "Prompt argument :required at index #{idx} must be true or false if provided. Found: #{req_val.inspect}"
266
+ PROMPT_ARG_SCHEMA.each { |key, spec| validate_prompt_arg_field(arg, idx, key, spec) }
267
+ validate_prompt_arg_unknown_keys(arg, idx)
320
268
  end
321
269
 
322
- # Validates the :type key of a prompt argument definition.
270
+ # Validates a single field of a prompt argument hash against its schema spec.
323
271
  # @api private
324
- def validate_prompt_arg_type!(arg, idx)
325
- return unless arg.key?(:type) || arg.key?("type")
272
+ def validate_prompt_arg_field(arg, idx, key, spec)
273
+ present = arg.key?(key.to_sym) || arg.key?(key)
274
+ value = arg[key.to_sym] || arg[key]
326
275
 
327
- type_val = arg[:type] || arg["type"]
328
- return if type_val.nil? || type_val.is_a?(String)
276
+ raise ArgumentError, "Prompt argument at index #{idx} #{spec[:missing_message]}" if spec[:required] && value.nil?
277
+ return unless present
329
278
 
330
- raise ArgumentError, "Prompt argument :type at index #{idx} must be a String if provided (e.g., JSON schema type). Found: #{type_val.class}"
279
+ error_fragment = spec[:rule].call(value)
280
+ raise ArgumentError, "Prompt argument :#{key} at index #{idx} #{error_fragment}" if error_fragment
331
281
  end
332
282
 
333
- # Checks for any unknown keys in a prompt argument definition.
283
+ # Checks a prompt argument hash for keys outside PROMPT_ARG_SCHEMA.
334
284
  # @api private
335
- def validate_prompt_arg_unknown_keys!(arg, idx)
336
- unknown_keys = arg.transform_keys(&:to_s).keys - ALLOWED_PROMPT_ARG_KEYS
285
+ def validate_prompt_arg_unknown_keys(arg, idx)
286
+ unknown_keys = arg.transform_keys(&:to_s).keys - PROMPT_ARG_SCHEMA.keys
337
287
  return if unknown_keys.empty?
338
288
 
339
289
  raise ArgumentError,
340
290
  "Prompt argument definition at index #{idx} contains unknown keys: #{unknown_keys.join(", ")}. " \
341
- "Allowed: #{ALLOWED_PROMPT_ARG_KEYS.join(", ")}."
291
+ "Allowed: #{PROMPT_ARG_SCHEMA.keys.join(", ")}."
342
292
  end
343
293
  end
344
294
  end