vector_mcp 0.1.0 → 0.3.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.
@@ -28,6 +28,51 @@ module VectorMCP
28
28
  inputSchema: input_schema # Expected to be a Hash representing JSON Schema
29
29
  }.compact # Remove nil values
30
30
  end
31
+
32
+ # Checks if this tool supports image inputs based on its input schema.
33
+ # @return [Boolean] True if the tool's input schema includes image properties.
34
+ def supports_image_input?
35
+ return false unless input_schema.is_a?(Hash)
36
+
37
+ properties = extract_schema_properties
38
+ properties.any? { |_prop, schema| image_property?(schema) }
39
+ end
40
+
41
+ private
42
+
43
+ # Extracts properties from the input schema, handling both string and symbol keys.
44
+ def extract_schema_properties
45
+ input_schema["properties"] || input_schema[:properties] || {}
46
+ end
47
+
48
+ # Checks if a property schema represents an image input.
49
+ def image_property?(schema)
50
+ return false unless schema.is_a?(Hash)
51
+
52
+ explicit_image_format?(schema) || base64_image_content?(schema)
53
+ end
54
+
55
+ # Checks if the schema explicitly declares image format.
56
+ def explicit_image_format?(schema)
57
+ schema["format"] == "image" || schema[:format] == "image"
58
+ end
59
+
60
+ # Checks if the schema represents base64-encoded image content.
61
+ def base64_image_content?(schema)
62
+ string_type_with_base64_encoding?(schema) && image_media_type?(schema)
63
+ end
64
+
65
+ # Checks if the schema is a string type with base64 encoding.
66
+ def string_type_with_base64_encoding?(schema)
67
+ (schema["type"] == "string" && schema["contentEncoding"] == "base64") ||
68
+ (schema[:type] == "string" && schema[:contentEncoding] == "base64")
69
+ end
70
+
71
+ # Checks if the schema has an image media type.
72
+ def image_media_type?(schema)
73
+ schema["contentMediaType"]&.start_with?("image/") ||
74
+ schema[:contentMediaType]&.start_with?("image/")
75
+ end
31
76
  end
32
77
 
33
78
  # Represents a resource (context or data) that can be provided to the AI model or user.
@@ -39,7 +84,7 @@ module VectorMCP
39
84
  # @!attribute description [rw] String
40
85
  # A description of the resource content.
41
86
  # @!attribute mime_type [rw] String
42
- # The MIME type of the resource content (e.g., "text/plain", "application/json").
87
+ # The MIME type of the resource content (e.g., "text/plain", "application/json", "image/jpeg").
43
88
  # @!attribute handler [rw] Proc
44
89
  # A callable that returns the content of the resource. It may receive parameters from the request (e.g., for dynamic resources).
45
90
  Resource = Struct.new(:uri, :name, :description, :mime_type, :handler) do
@@ -53,6 +98,64 @@ module VectorMCP
53
98
  mimeType: mime_type
54
99
  }.compact
55
100
  end
101
+
102
+ # Checks if this resource represents an image.
103
+ # @return [Boolean] True if the resource's MIME type indicates an image.
104
+ def image_resource?
105
+ !!mime_type&.start_with?("image/")
106
+ end
107
+
108
+ # Class method to create an image resource from a file path.
109
+ # @param uri [String] The URI for the resource.
110
+ # @param file_path [String] Path to the image file.
111
+ # @param name [String] Human-readable name for the resource.
112
+ # @param description [String] Description of the resource.
113
+ # @return [Resource] A new Resource instance configured for the image file.
114
+ def self.from_image_file(uri:, file_path:, name: nil, description: nil)
115
+ raise ArgumentError, "Image file not found: #{file_path}" unless File.exist?(file_path)
116
+
117
+ # Auto-detect MIME type
118
+ require_relative "image_util"
119
+ image_data = File.binread(file_path)
120
+ detected_mime_type = VectorMCP::ImageUtil.detect_image_format(image_data)
121
+
122
+ raise ArgumentError, "Could not detect image format for file: #{file_path}" unless detected_mime_type
123
+
124
+ # Generate name and description if not provided
125
+ default_name = name || File.basename(file_path)
126
+ default_description = description || "Image file: #{file_path}"
127
+
128
+ handler = lambda do |_params|
129
+ VectorMCP::ImageUtil.file_to_mcp_image_content(file_path)
130
+ end
131
+
132
+ new(uri, default_name, default_description, detected_mime_type, handler)
133
+ end
134
+
135
+ # Class method to create an image resource from binary data.
136
+ # @param uri [String] The URI for the resource.
137
+ # @param image_data [String] Binary image data.
138
+ # @param name [String] Human-readable name for the resource.
139
+ # @param description [String] Description of the resource.
140
+ # @param mime_type [String, nil] MIME type (auto-detected if nil).
141
+ # @return [Resource] A new Resource instance configured for the image data.
142
+ def self.from_image_data(uri:, image_data:, name:, description: nil, mime_type: nil)
143
+ require_relative "image_util"
144
+
145
+ # Detect or validate MIME type
146
+ detected_mime_type = VectorMCP::ImageUtil.detect_image_format(image_data)
147
+ final_mime_type = mime_type || detected_mime_type
148
+
149
+ raise ArgumentError, "Could not determine MIME type for image data" unless final_mime_type
150
+
151
+ default_description = description || "Image resource: #{name}"
152
+
153
+ handler = lambda do |_params|
154
+ VectorMCP::ImageUtil.to_mcp_image_content(image_data, mime_type: final_mime_type)
155
+ end
156
+
157
+ new(uri, name, default_description, final_mime_type, handler)
158
+ end
56
159
  end
57
160
 
58
161
  # Represents a prompt or templated message workflow for users or AI models.
@@ -76,6 +179,114 @@ module VectorMCP
76
179
  arguments: arguments # Expected to be an array of { name:, description:, required: } hashes
77
180
  }.compact
78
181
  end
182
+
183
+ # Checks if this prompt supports image arguments.
184
+ # @return [Boolean] True if any of the prompt arguments are configured for images.
185
+ def supports_image_arguments?
186
+ return false unless arguments.is_a?(Array)
187
+
188
+ arguments.any? do |arg|
189
+ arg.is_a?(Hash) && (
190
+ arg["type"] == "image" ||
191
+ arg[:type] == "image" ||
192
+ (arg["description"] || arg[:description])&.downcase&.include?("image")
193
+ )
194
+ end
195
+ end
196
+
197
+ # Class method to create an image-enabled prompt with common image argument patterns.
198
+ # @param name [String] The unique name of the prompt.
199
+ # @param description [String] A human-readable description.
200
+ # @param image_argument_name [String] Name of the image argument (default: "image").
201
+ # @param additional_arguments [Array<Hash>] Additional prompt arguments.
202
+ # @param handler [Proc] The prompt handler.
203
+ # @return [Prompt] A new Prompt instance configured for image input.
204
+ def self.with_image_support(name:, description:, image_argument_name: "image", additional_arguments: [], &handler)
205
+ image_arg = {
206
+ name: image_argument_name,
207
+ description: "Image file path or image data to include in the prompt",
208
+ required: false,
209
+ type: "image"
210
+ }
211
+
212
+ all_arguments = [image_arg] + additional_arguments
213
+
214
+ new(name, description, all_arguments, handler)
215
+ end
216
+ end
217
+
218
+ # Represents an MCP root definition.
219
+ # Roots define filesystem boundaries where servers can operate.
220
+ Root = Struct.new(:uri, :name) do
221
+ # Converts the root to its MCP definition hash.
222
+ # @return [Hash] A hash representing the root in MCP format.
223
+ def as_mcp_definition
224
+ {
225
+ uri: uri.to_s,
226
+ name: name
227
+ }.compact
228
+ end
229
+
230
+ # Validates that the root URI is properly formatted and secure.
231
+ # @return [Boolean] True if the root is valid.
232
+ # @raise [ArgumentError] If the root is invalid.
233
+ # rubocop:disable Naming/PredicateMethod
234
+ def validate!
235
+ # Validate URI format
236
+ parsed_uri = begin
237
+ URI(uri.to_s)
238
+ rescue URI::InvalidURIError
239
+ raise ArgumentError, "Invalid URI format: #{uri}"
240
+ end
241
+
242
+ # Currently, only file:// scheme is supported per MCP spec
243
+ raise ArgumentError, "Only file:// URIs are supported for roots, got: #{parsed_uri.scheme}://" unless parsed_uri.scheme == "file"
244
+
245
+ # Validate path exists and is a directory
246
+ path = parsed_uri.path
247
+ raise ArgumentError, "Root directory does not exist: #{path}" unless File.exist?(path)
248
+
249
+ raise ArgumentError, "Root path is not a directory: #{path}" unless File.directory?(path)
250
+
251
+ # Security check: ensure we can read the directory
252
+
253
+ raise ArgumentError, "Root directory is not readable: #{path}" unless File.readable?(path)
254
+ # Validate against path traversal attempts in the URI itself
255
+ raise ArgumentError, "Root path contains unsafe traversal patterns: #{path}" if path.include?("..") || path.include?("./")
256
+
257
+ true
258
+ end
259
+ # rubocop:enable Naming/PredicateMethod
260
+
261
+ # Class method to create a root from a local directory path.
262
+ # @param path [String] Local filesystem path to the directory.
263
+ # @param name [String] Human-readable name for the root.
264
+ # @return [Root] A new Root instance.
265
+ # @raise [ArgumentError] If the path is invalid or not accessible.
266
+ def self.from_path(path, name: nil)
267
+ # Expand path to get absolute path and resolve any relative components
268
+ expanded_path = File.expand_path(path)
269
+
270
+ # Create file:// URI
271
+ uri = "file://#{expanded_path}"
272
+
273
+ # Generate name if not provided
274
+ default_name = name || File.basename(expanded_path)
275
+
276
+ root = new(uri, default_name)
277
+ root.validate! # Ensure the root is valid
278
+ root
279
+ end
280
+
281
+ # Returns the filesystem path for file:// URIs.
282
+ # @return [String] The filesystem path.
283
+ # @raise [ArgumentError] If the URI is not a file:// scheme.
284
+ def path
285
+ parsed_uri = URI(uri.to_s)
286
+ raise ArgumentError, "Cannot get path for non-file URI: #{uri}" unless parsed_uri.scheme == "file"
287
+
288
+ parsed_uri.path
289
+ end
79
290
  end
80
291
  end
81
292
  end
@@ -135,4 +135,43 @@ module VectorMCP
135
135
  super(message, code: -32_001, details: details, request_id: request_id)
136
136
  end
137
137
  end
138
+
139
+ # --- Sampling Specific Errors ---
140
+ # These are errors that can occur when a server initiates a `sampling/createMessage` request.
141
+
142
+ # Base class for sampling-related errors originating from client or transport during a server-initiated sample request.
143
+ class SamplingError < ProtocolError
144
+ # @param message [String] The error message.
145
+ # @param code [Integer] JSON-RPC error code (typically from client response, or server defined for timeouts etc).
146
+ # @param details [Object, nil] Optional additional error data from client or transport.
147
+ # @param request_id [String, Integer, nil] The ID of the `sampling/createMessage` request.
148
+ def initialize(message = "An error occurred during sampling.", code: -32_050, details: nil, request_id: nil)
149
+ # Using -32050 as a generic base for sampling errors from server's perspective.
150
+ # Specific errors from client might have different codes.
151
+ super
152
+ end
153
+ end
154
+
155
+ # Raised when a server-initiated sampling request times out waiting for a client response.
156
+ class SamplingTimeoutError < SamplingError
157
+ def initialize(message = "Timeout waiting for client response to sampling request.", details: nil, request_id: nil)
158
+ super(message, code: -32_051, details: details, request_id: request_id) # Server-defined code for this timeout
159
+ end
160
+ end
161
+
162
+ # Raised if the client explicitly rejects or denies the sampling request (e.g., user vetoed).
163
+ # This would typically correspond to a specific error response from the client.
164
+ class SamplingRejectedError < SamplingError
165
+ def initialize(message = "Client rejected the sampling request.", code: -32_052, details: nil, request_id: nil)
166
+ # This code might be overridden by the actual error code from the client if available.
167
+ super
168
+ end
169
+ end
170
+
171
+ # Placeholder for other specific sampling errors that might be defined based on client responses.
172
+ # class SpecificSamplingClientError < SamplingError
173
+ # def initialize(message, client_code:, client_details:, request_id: nil)
174
+ # super(message, code: client_code, details: client_details, request_id: request_id)
175
+ # end
176
+ # end
138
177
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "uri"
5
+ require "json-schema"
5
6
 
6
7
  module VectorMCP
7
8
  module Handlers
@@ -46,6 +47,7 @@ module VectorMCP
46
47
  # @return [Hash] A hash containing the tool call result or an error indication.
47
48
  # Example success: `{ isError: false, content: [{ type: "text", ... }] }`
48
49
  # @raise [VectorMCP::NotFoundError] if the requested tool is not found.
50
+ # @raise [VectorMCP::InvalidParamsError] if arguments validation fails.
49
51
  def self.call_tool(params, _session, server)
50
52
  tool_name = params["name"]
51
53
  arguments = params["arguments"] || {}
@@ -53,6 +55,9 @@ module VectorMCP
53
55
  tool = server.tools[tool_name]
54
56
  raise VectorMCP::NotFoundError.new("Not Found", details: "Tool not found: #{tool_name}") unless tool
55
57
 
58
+ # Validate arguments against the tool's input schema
59
+ validate_input_arguments!(tool_name, tool, arguments)
60
+
56
61
  # Let StandardError propagate to Server#handle_request
57
62
  result = tool.handler.call(arguments)
58
63
  {
@@ -115,6 +120,23 @@ module VectorMCP
115
120
  result
116
121
  end
117
122
 
123
+ # Handles the `roots/list` request.
124
+ # Returns the list of available roots and clears the `listChanged` flag.
125
+ #
126
+ # @param _params [Hash] The request parameters (ignored).
127
+ # @param _session [VectorMCP::Session] The current session (ignored).
128
+ # @param server [VectorMCP::Server] The server instance.
129
+ # @return [Hash] A hash containing an array of root definitions.
130
+ # Example: `{ roots: [ { uri: "file:///path/to/dir", name: "My Project" } ] }`
131
+ def self.list_roots(_params, _session, server)
132
+ # Once the list is supplied, clear the listChanged flag
133
+ result = {
134
+ roots: server.roots.values.map(&:as_mcp_definition)
135
+ }
136
+ server.clear_roots_list_changed if server.respond_to?(:clear_roots_list_changed)
137
+ result
138
+ end
139
+
118
140
  # Handles the `prompts/subscribe` request (placeholder).
119
141
  # This implementation is a simple acknowledgement.
120
142
  #
@@ -284,6 +306,44 @@ module VectorMCP
284
306
  end
285
307
  end
286
308
  private_class_method :validate_prompt_response!
309
+
310
+ # Validates arguments provided for a tool against its input schema using json-schema.
311
+ # @api private
312
+ # @param tool_name [String] The name of the tool.
313
+ # @param tool [VectorMCP::Definitions::Tool] The tool definition.
314
+ # @param arguments [Hash] The arguments supplied by the client.
315
+ # @return [void]
316
+ # @raise [VectorMCP::InvalidParamsError] if arguments fail validation.
317
+ def self.validate_input_arguments!(tool_name, tool, arguments)
318
+ return unless tool.input_schema.is_a?(Hash)
319
+ return if tool.input_schema.empty?
320
+
321
+ validation_errors = JSON::Validator.fully_validate(tool.input_schema, arguments)
322
+ return if validation_errors.empty?
323
+
324
+ raise_tool_validation_error(tool_name, validation_errors)
325
+ rescue JSON::Schema::ValidationError => e
326
+ raise_tool_validation_error(tool_name, [e.message])
327
+ end
328
+
329
+ # Raises InvalidParamsError with formatted validation details.
330
+ # @api private
331
+ # @param tool_name [String] The name of the tool.
332
+ # @param validation_errors [Array<String>] The validation error messages.
333
+ # @return [void]
334
+ # @raise [VectorMCP::InvalidParamsError] Always raises with formatted details.
335
+ def self.raise_tool_validation_error(tool_name, validation_errors)
336
+ raise VectorMCP::InvalidParamsError.new(
337
+ "Invalid arguments for tool '#{tool_name}'",
338
+ details: {
339
+ tool: tool_name,
340
+ validation_errors: validation_errors,
341
+ message: validation_errors.join("; ")
342
+ }
343
+ )
344
+ end
345
+ private_class_method :validate_input_arguments!
346
+ private_class_method :raise_tool_validation_error
287
347
  end
288
348
  end
289
349
  end