vector_mcp 0.4.0 → 0.6.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +39 -0
  4. data/lib/vector_mcp/definitions.rb +30 -0
  5. data/lib/vector_mcp/handlers/core.rb +32 -89
  6. data/lib/vector_mcp/invocation.rb +106 -0
  7. data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
  8. data/lib/vector_mcp/middleware/hook.rb +7 -24
  9. data/lib/vector_mcp/middleware.rb +26 -9
  10. data/lib/vector_mcp/request_context.rb +97 -14
  11. data/lib/vector_mcp/security/auth_manager.rb +15 -14
  12. data/lib/vector_mcp/security/auth_result.rb +33 -0
  13. data/lib/vector_mcp/security/authorization.rb +5 -9
  14. data/lib/vector_mcp/security/middleware.rb +0 -47
  15. data/lib/vector_mcp/security/session_context.rb +11 -27
  16. data/lib/vector_mcp/security/strategies/api_key.rb +7 -52
  17. data/lib/vector_mcp/security/strategies/custom.rb +15 -39
  18. data/lib/vector_mcp/security/strategies/jwt_token.rb +7 -17
  19. data/lib/vector_mcp/server/capabilities.rb +22 -26
  20. data/lib/vector_mcp/server/message_handling.rb +24 -17
  21. data/lib/vector_mcp/server/registry.rb +70 -120
  22. data/lib/vector_mcp/server.rb +53 -19
  23. data/lib/vector_mcp/session.rb +55 -27
  24. data/lib/vector_mcp/token_store.rb +80 -0
  25. data/lib/vector_mcp/transport/base_session_manager.rb +12 -38
  26. data/lib/vector_mcp/transport/http_stream/event_store.rb +14 -16
  27. data/lib/vector_mcp/transport/http_stream/session_manager.rb +20 -61
  28. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +7 -7
  29. data/lib/vector_mcp/transport/http_stream.rb +95 -55
  30. data/lib/vector_mcp/util/token_sweeper.rb +74 -0
  31. data/lib/vector_mcp/version.rb +1 -1
  32. data/lib/vector_mcp.rb +11 -0
  33. metadata +6 -1
@@ -31,18 +31,14 @@ module VectorMCP
31
31
  end
32
32
 
33
33
  # Authenticate a request using API key
34
- # @param request [Hash] the request object
34
+ # @param request [VectorMCP::RequestContext, Hash] the request
35
35
  # @return [Hash, false] user info hash or false if authentication failed
36
36
  def authenticate(request)
37
- api_key = extract_api_key(request)
37
+ api_key = extract_api_key(VectorMCP::RequestContext.coerce(request))
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
@@ -76,57 +72,16 @@ module VectorMCP
76
72
  matched
77
73
  end
78
74
 
79
- # Extract API key from various request formats
80
- # @param request [Hash] the request object
75
+ # Extract API key from the request context
76
+ # @param request [VectorMCP::RequestContext] the request context
81
77
  # @return [String, nil] the extracted API key
82
78
  def extract_api_key(request)
83
- headers = normalize_headers(request)
84
-
85
- from_headers = extract_from_headers(headers)
79
+ from_headers = extract_from_headers(request.headers)
86
80
  return from_headers if from_headers
87
81
 
88
82
  return nil unless @allow_query_params
89
83
 
90
- params = normalize_params(request)
91
- extract_from_params(params)
92
- end
93
-
94
- # Normalize headers to handle different formats
95
- # @param request [Hash] the request object
96
- # @return [Hash] normalized headers
97
- def normalize_headers(request)
98
- # Check if it's a Rack environment (has REQUEST_METHOD)
99
- if request["REQUEST_METHOD"]
100
- extract_headers_from_rack_env(request)
101
- else
102
- request[:headers] || request["headers"] || {}
103
- end
104
- end
105
-
106
- # Normalize params to handle different formats
107
- # @param request [Hash] the request object
108
- # @return [Hash] normalized params
109
- def normalize_params(request)
110
- # Check if it's a Rack environment (has REQUEST_METHOD)
111
- if request["REQUEST_METHOD"]
112
- extract_params_from_rack_env(request)
113
- else
114
- request[:params] || request["params"] || {}
115
- end
116
- end
117
-
118
- # Extract headers from Rack environment
119
- # @param env [Hash] the Rack environment
120
- # @return [Hash] normalized headers
121
- def extract_headers_from_rack_env(env)
122
- VectorMCP::Util.extract_headers_from_rack_env(env)
123
- end
124
-
125
- # Extract params from Rack environment
126
- # @param env [Hash] the Rack environment
127
- # @return [Hash] normalized params
128
- def extract_params_from_rack_env(env)
129
- VectorMCP::Util.extract_params_from_rack_env(env)
84
+ extract_from_params(request.params)
130
85
  end
131
86
 
132
87
  # Extract API key from headers
@@ -16,20 +16,23 @@ module VectorMCP
16
16
  @handler = handler
17
17
  end
18
18
 
19
- # Authenticate a request using the custom handler
20
- # @param request [Hash] the request object
21
- # @return [Object, false] result from custom handler or false if authentication failed
19
+ # Authenticate a request using the custom handler.
20
+ # The handler receives a {VectorMCP::RequestContext}, which supports
21
+ # hash-style access (`request[:headers]`, `request[:params]`) for
22
+ # compatibility with handlers written against the legacy request hash.
23
+ # If the handler returns a Hash with a :user key, the value is extracted
24
+ # so that AuthManager receives the user data directly.
25
+ # @param request [VectorMCP::RequestContext, Hash] the request
26
+ # @return [Object, nil, false] user data or false if authentication failed.
27
+ # A return of nil (from { user: nil }) signals "authenticated, no user object."
22
28
  def authenticate(request)
23
- result = @handler.call(request)
29
+ result = @handler.call(VectorMCP::RequestContext.coerce(request))
30
+ return false unless result && result != false
24
31
 
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
32
+ return result unless result.is_a?(Hash) && result.key?(:user)
33
+
34
+ result[:user]
35
+ rescue StandardError, NoMemoryError
33
36
  false
34
37
  end
35
38
 
@@ -38,33 +41,6 @@ module VectorMCP
38
41
  def configured?
39
42
  !@handler.nil?
40
43
  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
44
  end
69
45
  end
70
46
  end
@@ -35,24 +35,15 @@ module VectorMCP
35
35
  end
36
36
 
37
37
  # Authenticate a request using JWT token
38
- # @param request [Hash] the request object
38
+ # @param request [VectorMCP::RequestContext, Hash] the request
39
39
  # @return [Hash, false] decoded JWT payload or false if authentication failed
40
40
  def authenticate(request)
41
- token = extract_token(request)
41
+ token = extract_token(VectorMCP::RequestContext.coerce(request))
42
42
  return false unless token
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
@@ -79,19 +70,18 @@ module VectorMCP
79
70
 
80
71
  private
81
72
 
82
- # Extract JWT token from request
83
- # @param request [Hash] the request object
73
+ # Extract JWT token from the request context
74
+ # @param request [VectorMCP::RequestContext] the request context
84
75
  # @return [String, nil] the extracted token
85
76
  def extract_token(request)
86
- headers = request[:headers] || request["headers"] || {}
77
+ headers = request.headers
87
78
 
88
79
  from_headers = extract_from_auth_header(headers) || extract_from_jwt_header(headers)
89
80
  return from_headers if from_headers
90
81
 
91
82
  return nil unless @allow_query_params
92
83
 
93
- params = request[:params] || request["params"] || {}
94
- extract_from_params(params)
84
+ extract_from_params(request.params)
95
85
  end
96
86
 
97
87
  # Extract token from Authorization header
@@ -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)
@@ -10,27 +10,30 @@ module VectorMCP
10
10
  # This is the main dispatch point for messages received by a transport.
11
11
  #
12
12
  # @param message [Hash] The parsed JSON-RPC message object.
13
- # @param session [VectorMCP::Session] The client session associated with this message.
14
- # @param session_id [String] A unique identifier for the underlying transport connection.
13
+ # @param session [VectorMCP::Session, VectorMCP::Invocation] The session (or per-request
14
+ # invocation view of it) this message belongs to.
15
+ # @param session_id [String, nil] Deprecated: defaults to session.id; used only for log prefixes.
15
16
  # @return [Object, nil] For requests, returns the result data to be sent in the JSON-RPC response.
16
17
  # For notifications, returns `nil`.
17
18
  # @raise [VectorMCP::ProtocolError] if the message is invalid or an error occurs during handling.
18
- def handle_message(message, session, session_id)
19
+ def handle_message(message, session, session_id = nil)
20
+ session_id ||= session.id
19
21
  id = message["id"]
20
22
  method = message["method"]
21
23
  params = message["params"] || {}
22
24
 
23
- if id && method # Request
25
+ case classify_message(id, method)
26
+ when :request
24
27
  logger.debug("[#{session_id}] Request [#{id}]: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
25
28
  handle_request(id, method, params, session)
26
- elsif method # Notification
29
+ when :notification
27
30
  logger.debug("[#{session_id}] Notification: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
28
31
  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
32
+ nil
33
+ when :invalid_missing_method
31
34
  logger.warn("[#{session_id}] Invalid message: Has ID [#{id}] but no method. #{message.inspect}")
32
35
  raise VectorMCP::InvalidRequestError.new("Request object must include a 'method' member.", request_id: id)
33
- else # Invalid: No ID and no method
36
+ when :invalid_missing_both
34
37
  logger.warn("[#{session_id}] Invalid message: Missing both 'id' and 'method'. #{message.inspect}")
35
38
  raise VectorMCP::InvalidRequestError.new("Invalid message format", request_id: nil)
36
39
  end
@@ -60,6 +63,16 @@ module VectorMCP
60
63
 
61
64
  private
62
65
 
66
+ # Classify a JSON-RPC message based on presence of id and method fields.
67
+ # @api private
68
+ def classify_message(id, method)
69
+ return :request if id && method
70
+ return :notification if method
71
+ return :invalid_missing_method if id
72
+
73
+ :invalid_missing_both
74
+ end
75
+
63
76
  # Internal handler for JSON-RPC requests.
64
77
  # @api private
65
78
  def handle_request(id, method, params, session)
@@ -74,9 +87,7 @@ module VectorMCP
74
87
  # Validates that the session is properly initialized for the given request.
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