vector_mcp 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +421 -114
- data/lib/vector_mcp/definitions.rb +210 -1
- data/lib/vector_mcp/errors.rb +39 -0
- data/lib/vector_mcp/handlers/core.rb +17 -0
- data/lib/vector_mcp/image_util.rb +358 -0
- data/lib/vector_mcp/sampling/request.rb +193 -0
- data/lib/vector_mcp/sampling/result.rb +80 -0
- data/lib/vector_mcp/server/capabilities.rb +156 -0
- data/lib/vector_mcp/server/message_handling.rb +166 -0
- data/lib/vector_mcp/server/registry.rb +289 -0
- data/lib/vector_mcp/server.rb +53 -415
- data/lib/vector_mcp/session.rb +100 -23
- data/lib/vector_mcp/transport/stdio.rb +174 -16
- data/lib/vector_mcp/util.rb +135 -10
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +2 -1
- metadata +21 -1
@@ -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,112 @@ 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
|
+
def validate!
|
234
|
+
# Validate URI format
|
235
|
+
parsed_uri = begin
|
236
|
+
URI(uri.to_s)
|
237
|
+
rescue URI::InvalidURIError
|
238
|
+
raise ArgumentError, "Invalid URI format: #{uri}"
|
239
|
+
end
|
240
|
+
|
241
|
+
# Currently, only file:// scheme is supported per MCP spec
|
242
|
+
raise ArgumentError, "Only file:// URIs are supported for roots, got: #{parsed_uri.scheme}://" unless parsed_uri.scheme == "file"
|
243
|
+
|
244
|
+
# Validate path exists and is a directory
|
245
|
+
path = parsed_uri.path
|
246
|
+
raise ArgumentError, "Root directory does not exist: #{path}" unless File.exist?(path)
|
247
|
+
|
248
|
+
raise ArgumentError, "Root path is not a directory: #{path}" unless File.directory?(path)
|
249
|
+
|
250
|
+
# Security check: ensure we can read the directory
|
251
|
+
raise ArgumentError, "Root directory is not readable: #{path}" unless File.readable?(path)
|
252
|
+
|
253
|
+
# Validate against path traversal attempts in the URI itself
|
254
|
+
raise ArgumentError, "Root path contains unsafe traversal patterns: #{path}" if path.include?("..") || path.include?("./")
|
255
|
+
|
256
|
+
true
|
257
|
+
end
|
258
|
+
|
259
|
+
# Class method to create a root from a local directory path.
|
260
|
+
# @param path [String] Local filesystem path to the directory.
|
261
|
+
# @param name [String] Human-readable name for the root.
|
262
|
+
# @return [Root] A new Root instance.
|
263
|
+
# @raise [ArgumentError] If the path is invalid or not accessible.
|
264
|
+
def self.from_path(path, name: nil)
|
265
|
+
# Expand path to get absolute path and resolve any relative components
|
266
|
+
expanded_path = File.expand_path(path)
|
267
|
+
|
268
|
+
# Create file:// URI
|
269
|
+
uri = "file://#{expanded_path}"
|
270
|
+
|
271
|
+
# Generate name if not provided
|
272
|
+
default_name = name || File.basename(expanded_path)
|
273
|
+
|
274
|
+
root = new(uri, default_name)
|
275
|
+
root.validate! # Ensure the root is valid
|
276
|
+
root
|
277
|
+
end
|
278
|
+
|
279
|
+
# Returns the filesystem path for file:// URIs.
|
280
|
+
# @return [String] The filesystem path.
|
281
|
+
# @raise [ArgumentError] If the URI is not a file:// scheme.
|
282
|
+
def path
|
283
|
+
parsed_uri = URI(uri.to_s)
|
284
|
+
raise ArgumentError, "Cannot get path for non-file URI: #{uri}" unless parsed_uri.scheme == "file"
|
285
|
+
|
286
|
+
parsed_uri.path
|
287
|
+
end
|
79
288
|
end
|
80
289
|
end
|
81
290
|
end
|
data/lib/vector_mcp/errors.rb
CHANGED
@@ -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
|
@@ -115,6 +115,23 @@ module VectorMCP
|
|
115
115
|
result
|
116
116
|
end
|
117
117
|
|
118
|
+
# Handles the `roots/list` request.
|
119
|
+
# Returns the list of available roots and clears the `listChanged` flag.
|
120
|
+
#
|
121
|
+
# @param _params [Hash] The request parameters (ignored).
|
122
|
+
# @param _session [VectorMCP::Session] The current session (ignored).
|
123
|
+
# @param server [VectorMCP::Server] The server instance.
|
124
|
+
# @return [Hash] A hash containing an array of root definitions.
|
125
|
+
# Example: `{ roots: [ { uri: "file:///path/to/dir", name: "My Project" } ] }`
|
126
|
+
def self.list_roots(_params, _session, server)
|
127
|
+
# Once the list is supplied, clear the listChanged flag
|
128
|
+
result = {
|
129
|
+
roots: server.roots.values.map(&:as_mcp_definition)
|
130
|
+
}
|
131
|
+
server.clear_roots_list_changed if server.respond_to?(:clear_roots_list_changed)
|
132
|
+
result
|
133
|
+
end
|
134
|
+
|
118
135
|
# Handles the `prompts/subscribe` request (placeholder).
|
119
136
|
# This implementation is a simple acknowledgement.
|
120
137
|
#
|
@@ -0,0 +1,358 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "base64"
|
4
|
+
require "stringio"
|
5
|
+
|
6
|
+
module VectorMCP
|
7
|
+
# Provides comprehensive image handling utilities for VectorMCP operations,
|
8
|
+
# including format detection, validation, encoding/decoding, and conversion
|
9
|
+
# to MCP-compliant image content format.
|
10
|
+
module ImageUtil
|
11
|
+
module_function
|
12
|
+
|
13
|
+
# Common image MIME types and their magic byte signatures
|
14
|
+
IMAGE_SIGNATURES = {
|
15
|
+
"image/jpeg" => [
|
16
|
+
[0xFF, 0xD8, 0xFF].pack("C*"),
|
17
|
+
[0xFF, 0xD8].pack("C*")
|
18
|
+
],
|
19
|
+
"image/png" => [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A].pack("C*")],
|
20
|
+
"image/gif" => %w[
|
21
|
+
GIF87a
|
22
|
+
GIF89a
|
23
|
+
],
|
24
|
+
"image/webp" => [
|
25
|
+
"WEBP"
|
26
|
+
],
|
27
|
+
"image/bmp" => [
|
28
|
+
"BM"
|
29
|
+
],
|
30
|
+
"image/tiff" => [
|
31
|
+
"II*\0",
|
32
|
+
"MM\0*"
|
33
|
+
]
|
34
|
+
}.freeze
|
35
|
+
|
36
|
+
# Maximum image size in bytes (default: 10MB)
|
37
|
+
DEFAULT_MAX_SIZE = 10 * 1024 * 1024
|
38
|
+
|
39
|
+
# Detects the MIME type of image data based on magic bytes.
|
40
|
+
#
|
41
|
+
# @param data [String] The binary image data.
|
42
|
+
# @return [String, nil] The detected MIME type, or nil if not recognized.
|
43
|
+
#
|
44
|
+
# @example
|
45
|
+
# VectorMCP::ImageUtil.detect_image_format(File.binread("image.jpg"))
|
46
|
+
# # => "image/jpeg"
|
47
|
+
def detect_image_format(data)
|
48
|
+
return nil if data.nil? || data.empty?
|
49
|
+
|
50
|
+
# Ensure we have binary data (dup to avoid modifying frozen strings)
|
51
|
+
binary_data = data.dup.force_encoding(Encoding::ASCII_8BIT)
|
52
|
+
|
53
|
+
IMAGE_SIGNATURES.each do |mime_type, signatures|
|
54
|
+
signatures.each do |signature|
|
55
|
+
case mime_type
|
56
|
+
when "image/webp"
|
57
|
+
# WebP files start with RIFF then have WEBP at offset 8
|
58
|
+
return mime_type if binary_data.start_with?("RIFF") && binary_data[8, 4] == signature
|
59
|
+
else
|
60
|
+
return mime_type if binary_data.start_with?(signature)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
nil
|
66
|
+
end
|
67
|
+
|
68
|
+
# Validates if the provided data is a valid image.
|
69
|
+
#
|
70
|
+
# @param data [String] The binary image data.
|
71
|
+
# @param max_size [Integer] Maximum allowed size in bytes.
|
72
|
+
# @param allowed_formats [Array<String>] Allowed MIME types.
|
73
|
+
# @return [Hash] Validation result with :valid, :mime_type, and :errors keys.
|
74
|
+
#
|
75
|
+
# @example
|
76
|
+
# result = VectorMCP::ImageUtil.validate_image(image_data)
|
77
|
+
# if result[:valid]
|
78
|
+
# puts "Valid #{result[:mime_type]} image"
|
79
|
+
# else
|
80
|
+
# puts "Errors: #{result[:errors].join(', ')}"
|
81
|
+
# end
|
82
|
+
def validate_image(data, max_size: DEFAULT_MAX_SIZE, allowed_formats: nil)
|
83
|
+
errors = []
|
84
|
+
|
85
|
+
if data.nil? || data.empty?
|
86
|
+
errors << "Image data is empty"
|
87
|
+
return { valid: false, mime_type: nil, errors: errors }
|
88
|
+
end
|
89
|
+
|
90
|
+
# Check file size
|
91
|
+
errors << "Image size (#{data.bytesize} bytes) exceeds maximum allowed size (#{max_size} bytes)" if data.bytesize > max_size
|
92
|
+
|
93
|
+
# Detect format
|
94
|
+
mime_type = detect_image_format(data)
|
95
|
+
if mime_type.nil?
|
96
|
+
errors << "Unrecognized or invalid image format"
|
97
|
+
return { valid: false, mime_type: nil, errors: errors }
|
98
|
+
end
|
99
|
+
|
100
|
+
# Check allowed formats
|
101
|
+
if allowed_formats && !allowed_formats.include?(mime_type)
|
102
|
+
errors << "Image format #{mime_type} is not allowed. Allowed formats: #{allowed_formats.join(", ")}"
|
103
|
+
end
|
104
|
+
|
105
|
+
{
|
106
|
+
valid: errors.empty?,
|
107
|
+
mime_type: mime_type,
|
108
|
+
size: data.bytesize,
|
109
|
+
errors: errors
|
110
|
+
}
|
111
|
+
end
|
112
|
+
|
113
|
+
# Encodes binary image data to base64 string.
|
114
|
+
#
|
115
|
+
# @param data [String] The binary image data.
|
116
|
+
# @return [String] Base64 encoded string.
|
117
|
+
#
|
118
|
+
# @example
|
119
|
+
# encoded = VectorMCP::ImageUtil.encode_base64(File.binread("image.jpg"))
|
120
|
+
def encode_base64(data)
|
121
|
+
Base64.strict_encode64(data)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Decodes base64 string to binary image data.
|
125
|
+
#
|
126
|
+
# @param base64_string [String] Base64 encoded image data.
|
127
|
+
# @return [String] Binary image data.
|
128
|
+
# @raise [ArgumentError] If base64 string is invalid.
|
129
|
+
#
|
130
|
+
# @example
|
131
|
+
# data = VectorMCP::ImageUtil.decode_base64(encoded_string)
|
132
|
+
def decode_base64(base64_string)
|
133
|
+
Base64.strict_decode64(base64_string)
|
134
|
+
rescue ArgumentError => e
|
135
|
+
raise ArgumentError, "Invalid base64 encoding: #{e.message}"
|
136
|
+
end
|
137
|
+
|
138
|
+
# Converts image data to MCP-compliant image content format.
|
139
|
+
#
|
140
|
+
# @param data [String] Binary image data or base64 encoded string.
|
141
|
+
# @param mime_type [String, nil] MIME type (auto-detected if nil).
|
142
|
+
# @param validate [Boolean] Whether to validate the image data.
|
143
|
+
# @param max_size [Integer] Maximum allowed size for validation.
|
144
|
+
# @return [Hash] MCP image content hash with :type, :data, and :mimeType.
|
145
|
+
# @raise [ArgumentError] If validation fails.
|
146
|
+
#
|
147
|
+
# @example Convert binary image data
|
148
|
+
# content = VectorMCP::ImageUtil.to_mcp_image_content(
|
149
|
+
# File.binread("image.jpg")
|
150
|
+
# )
|
151
|
+
# # => { type: "image", data: "base64...", mimeType: "image/jpeg" }
|
152
|
+
#
|
153
|
+
# @example Convert base64 string with explicit MIME type
|
154
|
+
# content = VectorMCP::ImageUtil.to_mcp_image_content(
|
155
|
+
# base64_string,
|
156
|
+
# mime_type: "image/png",
|
157
|
+
# validate: false
|
158
|
+
# )
|
159
|
+
def to_mcp_image_content(data, mime_type: nil, validate: true, max_size: DEFAULT_MAX_SIZE)
|
160
|
+
binary_data, base64_data = process_image_data(data)
|
161
|
+
detected_mime_type = validate_and_detect_format(binary_data, validate, max_size)
|
162
|
+
final_mime_type = determine_final_mime_type(mime_type, detected_mime_type)
|
163
|
+
|
164
|
+
{
|
165
|
+
type: "image",
|
166
|
+
data: base64_data,
|
167
|
+
mimeType: final_mime_type
|
168
|
+
}
|
169
|
+
end
|
170
|
+
|
171
|
+
# Processes input data to extract both binary and base64 representations.
|
172
|
+
# @api private
|
173
|
+
def process_image_data(data)
|
174
|
+
is_base64 = base64_string?(data)
|
175
|
+
|
176
|
+
if is_base64
|
177
|
+
# Decode to validate and detect format
|
178
|
+
begin
|
179
|
+
binary_data = decode_base64(data)
|
180
|
+
base64_data = data
|
181
|
+
rescue ArgumentError => e
|
182
|
+
raise ArgumentError, "Invalid base64 image data: #{e.message}"
|
183
|
+
end
|
184
|
+
else
|
185
|
+
# Assume binary data (dup to avoid modifying frozen strings)
|
186
|
+
binary_data = data.dup.force_encoding(Encoding::ASCII_8BIT)
|
187
|
+
base64_data = encode_base64(binary_data)
|
188
|
+
end
|
189
|
+
|
190
|
+
[binary_data, base64_data]
|
191
|
+
end
|
192
|
+
|
193
|
+
# Validates image data and detects MIME type if validation is enabled.
|
194
|
+
# @api private
|
195
|
+
def validate_and_detect_format(binary_data, validate, max_size)
|
196
|
+
if validate
|
197
|
+
validation = validate_image(binary_data, max_size: max_size)
|
198
|
+
raise ArgumentError, "Image validation failed: #{validation[:errors].join(", ")}" unless validation[:valid]
|
199
|
+
|
200
|
+
validation[:mime_type]
|
201
|
+
else
|
202
|
+
detect_image_format(binary_data)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
# Determines the final MIME type to use, preferring explicit over detected.
|
207
|
+
# @api private
|
208
|
+
def determine_final_mime_type(explicit_mime_type, detected_mime_type)
|
209
|
+
final_mime_type = explicit_mime_type || detected_mime_type
|
210
|
+
raise ArgumentError, "Could not determine image MIME type" if final_mime_type.nil?
|
211
|
+
|
212
|
+
final_mime_type
|
213
|
+
end
|
214
|
+
|
215
|
+
# Converts file path to MCP-compliant image content.
|
216
|
+
#
|
217
|
+
# @param file_path [String] Path to the image file.
|
218
|
+
# @param validate [Boolean] Whether to validate the image.
|
219
|
+
# @param max_size [Integer] Maximum allowed size for validation.
|
220
|
+
# @return [Hash] MCP image content hash.
|
221
|
+
# @raise [ArgumentError] If file doesn't exist or validation fails.
|
222
|
+
#
|
223
|
+
# @example
|
224
|
+
# content = VectorMCP::ImageUtil.file_to_mcp_image_content("./avatar.png")
|
225
|
+
def file_to_mcp_image_content(file_path, validate: true, max_size: DEFAULT_MAX_SIZE)
|
226
|
+
raise ArgumentError, "Image file not found: #{file_path}" unless File.exist?(file_path)
|
227
|
+
|
228
|
+
raise ArgumentError, "Image file not readable: #{file_path}" unless File.readable?(file_path)
|
229
|
+
|
230
|
+
binary_data = File.binread(file_path)
|
231
|
+
to_mcp_image_content(binary_data, validate: validate, max_size: max_size)
|
232
|
+
end
|
233
|
+
|
234
|
+
# Extracts image metadata from binary data.
|
235
|
+
#
|
236
|
+
# @param data [String] Binary image data.
|
237
|
+
# @return [Hash] Metadata hash with available information.
|
238
|
+
#
|
239
|
+
# @example
|
240
|
+
# metadata = VectorMCP::ImageUtil.extract_metadata(image_data)
|
241
|
+
# # => { mime_type: "image/jpeg", size: 102400, format: "JPEG" }
|
242
|
+
def extract_metadata(data)
|
243
|
+
return {} if data.nil? || data.empty?
|
244
|
+
|
245
|
+
mime_type = detect_image_format(data)
|
246
|
+
metadata = {
|
247
|
+
size: data.bytesize,
|
248
|
+
mime_type: mime_type
|
249
|
+
}
|
250
|
+
|
251
|
+
metadata[:format] = mime_type.split("/").last.upcase if mime_type
|
252
|
+
|
253
|
+
# Add basic dimension detection for common formats
|
254
|
+
metadata.merge!(extract_dimensions(data, mime_type))
|
255
|
+
end
|
256
|
+
|
257
|
+
# Checks if a string appears to be base64 encoded.
|
258
|
+
#
|
259
|
+
# @param string [String] The string to check.
|
260
|
+
# @return [Boolean] True if the string appears to be base64.
|
261
|
+
def base64_string?(string)
|
262
|
+
return false if string.nil? || string.empty?
|
263
|
+
|
264
|
+
# Base64 strings should only contain valid base64 characters
|
265
|
+
# and be properly padded with correct length
|
266
|
+
return false unless string.match?(%r{\A[A-Za-z0-9+/]*={0,2}\z})
|
267
|
+
|
268
|
+
# Allow both padded and unpadded base64, but require proper structure
|
269
|
+
# For unpadded base64, length should be at least 4 and not result in invalid decoding
|
270
|
+
if string.include?("=")
|
271
|
+
# Padded base64 must be multiple of 4
|
272
|
+
(string.length % 4).zero?
|
273
|
+
else
|
274
|
+
# Unpadded base64 - try to decode to see if it's valid
|
275
|
+
return false if string.length < 4
|
276
|
+
|
277
|
+
begin
|
278
|
+
# Add padding and try to decode
|
279
|
+
padded = string + ("=" * (4 - (string.length % 4)) % 4)
|
280
|
+
Base64.strict_decode64(padded)
|
281
|
+
true
|
282
|
+
rescue ArgumentError
|
283
|
+
false
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
288
|
+
# Extracts basic image dimensions for common formats.
|
289
|
+
# This is a simplified implementation; for production use,
|
290
|
+
# consider using a proper image library like MiniMagick or ImageMagick.
|
291
|
+
#
|
292
|
+
# @param data [String] Binary image data.
|
293
|
+
# @param mime_type [String] Detected MIME type.
|
294
|
+
# @return [Hash] Hash containing width/height if detectable.
|
295
|
+
def extract_dimensions(data, mime_type)
|
296
|
+
case mime_type
|
297
|
+
when "image/png"
|
298
|
+
extract_png_dimensions(data)
|
299
|
+
when "image/jpeg"
|
300
|
+
extract_jpeg_dimensions(data)
|
301
|
+
when "image/gif"
|
302
|
+
extract_gif_dimensions(data)
|
303
|
+
else
|
304
|
+
{}
|
305
|
+
end
|
306
|
+
rescue StandardError
|
307
|
+
{} # Return empty hash if dimension extraction fails
|
308
|
+
end
|
309
|
+
|
310
|
+
private
|
311
|
+
|
312
|
+
# Extracts PNG dimensions from IHDR chunk.
|
313
|
+
def extract_png_dimensions(data)
|
314
|
+
return {} unless data.length > 24
|
315
|
+
|
316
|
+
# PNG IHDR chunk starts at byte 16 and contains width/height
|
317
|
+
width = data[16, 4].unpack1("N")
|
318
|
+
height = data[20, 4].unpack1("N")
|
319
|
+
|
320
|
+
{ width: width, height: height }
|
321
|
+
end
|
322
|
+
|
323
|
+
# Extracts JPEG dimensions from SOF marker.
|
324
|
+
def extract_jpeg_dimensions(data)
|
325
|
+
# Simple JPEG dimension extraction
|
326
|
+
# Look for SOF0 (Start of Frame) marker
|
327
|
+
offset = 2
|
328
|
+
while offset < data.length - 8
|
329
|
+
marker = data[offset, 2].unpack1("n")
|
330
|
+
length = data[offset + 2, 2].unpack1("n")
|
331
|
+
|
332
|
+
# SOF0 marker (0xFFC0)
|
333
|
+
if marker == 0xFFC0
|
334
|
+
height = data[offset + 5, 2].unpack1("n")
|
335
|
+
width = data[offset + 7, 2].unpack1("n")
|
336
|
+
return { width: width, height: height }
|
337
|
+
end
|
338
|
+
|
339
|
+
offset += 2 + length
|
340
|
+
end
|
341
|
+
|
342
|
+
{}
|
343
|
+
end
|
344
|
+
|
345
|
+
# Extracts GIF dimensions from header.
|
346
|
+
def extract_gif_dimensions(data)
|
347
|
+
return {} unless data.length > 10
|
348
|
+
|
349
|
+
# GIF dimensions are at bytes 6-9
|
350
|
+
width = data[6, 2].unpack1("v") # Little-endian
|
351
|
+
height = data[8, 2].unpack1("v") # Little-endian
|
352
|
+
|
353
|
+
{ width: width, height: height }
|
354
|
+
end
|
355
|
+
|
356
|
+
module_function :extract_dimensions, :extract_png_dimensions, :extract_jpeg_dimensions, :extract_gif_dimensions
|
357
|
+
end
|
358
|
+
end
|