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.
- checksums.yaml +4 -4
- data/README.md +552 -107
- data/lib/vector_mcp/definitions.rb +212 -1
- data/lib/vector_mcp/errors.rb +39 -0
- data/lib/vector_mcp/handlers/core.rb +60 -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 +313 -0
- data/lib/vector_mcp/server.rb +53 -415
- data/lib/vector_mcp/session.rb +100 -23
- data/lib/vector_mcp/transport/sse/client_connection.rb +113 -0
- data/lib/vector_mcp/transport/sse/message_handler.rb +166 -0
- data/lib/vector_mcp/transport/sse/puma_config.rb +77 -0
- data/lib/vector_mcp/transport/sse/stream_manager.rb +92 -0
- data/lib/vector_mcp/transport/sse.rb +119 -460
- 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 +26 -16
@@ -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
|
@@ -0,0 +1,193 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../errors"
|
4
|
+
|
5
|
+
module VectorMCP
|
6
|
+
module Sampling
|
7
|
+
# Represents a sampling request to be sent to an MCP client.
|
8
|
+
# It validates the basic structure of the request.
|
9
|
+
class Request
|
10
|
+
attr_reader :messages, :model_preferences, :system_prompt,
|
11
|
+
:include_context, :temperature, :max_tokens,
|
12
|
+
:stop_sequences, :metadata
|
13
|
+
|
14
|
+
# Initializes a new Sampling::Request.
|
15
|
+
#
|
16
|
+
# @param params [Hash] The parameters for the sampling request.
|
17
|
+
# - :messages [Array<Hash>] (Required) Conversation history. Each message:
|
18
|
+
# - :role [String] (Required) "user" or "assistant".
|
19
|
+
# - :content [Hash] (Required) Message content.
|
20
|
+
# - :type [String] (Required) "text" or "image".
|
21
|
+
# - :text [String] (Optional) Text content if type is "text".
|
22
|
+
# - :data [String] (Optional) Base64 image data if type is "image".
|
23
|
+
# - :mime_type [String] (Optional) Mime type if type is "image".
|
24
|
+
# - :model_preferences [Hash] (Optional) Model selection preferences.
|
25
|
+
# - :system_prompt [String] (Optional) System prompt.
|
26
|
+
# - :include_context [String] (Optional) "none", "thisServer", "allServers".
|
27
|
+
# - :temperature [Float] (Optional) Sampling temperature.
|
28
|
+
# - :max_tokens [Integer] (Optional) Maximum tokens to generate.
|
29
|
+
# - :stop_sequences [Array<String>] (Optional) Stop sequences.
|
30
|
+
# - :metadata [Hash] (Optional) Provider-specific parameters.
|
31
|
+
# @raise [ArgumentError] if the basic structure is invalid.
|
32
|
+
def initialize(params = {})
|
33
|
+
params = params.transform_keys(&:to_sym) # Normalize keys
|
34
|
+
|
35
|
+
@messages = params[:messages]
|
36
|
+
@model_preferences = params[:model_preferences]
|
37
|
+
@system_prompt = params[:system_prompt]
|
38
|
+
@include_context = params[:include_context]
|
39
|
+
@temperature = params[:temperature]
|
40
|
+
@max_tokens = params[:max_tokens]
|
41
|
+
@stop_sequences = params[:stop_sequences]
|
42
|
+
@metadata = params[:metadata]
|
43
|
+
|
44
|
+
validate!
|
45
|
+
end
|
46
|
+
|
47
|
+
# Returns the request parameters as a hash, suitable for JSON serialization.
|
48
|
+
#
|
49
|
+
# @return [Hash]
|
50
|
+
def to_h
|
51
|
+
{
|
52
|
+
messages: @messages,
|
53
|
+
modelPreferences: @model_preferences, # MCP uses camelCase
|
54
|
+
systemPrompt: @system_prompt,
|
55
|
+
includeContext: @include_context,
|
56
|
+
temperature: @temperature,
|
57
|
+
maxTokens: @max_tokens,
|
58
|
+
stopSequences: @stop_sequences,
|
59
|
+
metadata: @metadata
|
60
|
+
}.compact # Remove nil values
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def validate!
|
66
|
+
raise ArgumentError, "'messages' array is required" unless @messages.is_a?(Array) && !@messages.empty?
|
67
|
+
|
68
|
+
@messages.each_with_index do |msg, idx|
|
69
|
+
validate_message(msg, idx)
|
70
|
+
end
|
71
|
+
|
72
|
+
validate_optional_params
|
73
|
+
end
|
74
|
+
|
75
|
+
def validate_message(msg, idx)
|
76
|
+
raise ArgumentError, "Each message in 'messages' must be a Hash (at index #{idx})" unless msg.is_a?(Hash)
|
77
|
+
|
78
|
+
msg_role = extract_message_role(msg)
|
79
|
+
msg_content = extract_message_content(msg)
|
80
|
+
|
81
|
+
validate_message_role(msg_role, idx)
|
82
|
+
validate_message_content_structure(msg_content, idx)
|
83
|
+
validate_content_by_type(msg_content, idx)
|
84
|
+
end
|
85
|
+
|
86
|
+
def extract_message_role(msg)
|
87
|
+
msg[:role] || msg["role"]
|
88
|
+
end
|
89
|
+
|
90
|
+
def extract_message_content(msg)
|
91
|
+
msg[:content] || msg["content"]
|
92
|
+
end
|
93
|
+
|
94
|
+
def validate_message_role(role, idx)
|
95
|
+
raise ArgumentError, "Message role must be 'user' or 'assistant' (at index #{idx})" unless %w[user assistant].include?(role)
|
96
|
+
end
|
97
|
+
|
98
|
+
def validate_message_content_structure(content, idx)
|
99
|
+
raise ArgumentError, "Message content must be a Hash (at index #{idx})" unless content.is_a?(Hash)
|
100
|
+
end
|
101
|
+
|
102
|
+
def validate_content_by_type(content, idx)
|
103
|
+
content_type = content[:type] || content["type"]
|
104
|
+
raise ArgumentError, "Message content type must be 'text' or 'image' (at index #{idx})" unless %w[text image].include?(content_type)
|
105
|
+
|
106
|
+
case content_type
|
107
|
+
when "text"
|
108
|
+
validate_text_content(content, idx)
|
109
|
+
when "image"
|
110
|
+
validate_image_content(content, idx)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def validate_text_content(content, idx)
|
115
|
+
text_value = content[:text] || content["text"]
|
116
|
+
return unless text_value.to_s.empty?
|
117
|
+
|
118
|
+
raise ArgumentError, "Text content must not be empty if type is 'text' (at index #{idx})"
|
119
|
+
end
|
120
|
+
|
121
|
+
def validate_image_content(content, idx)
|
122
|
+
validate_image_data(content, idx)
|
123
|
+
validate_image_mime_type(content, idx)
|
124
|
+
end
|
125
|
+
|
126
|
+
def validate_image_data(content, idx)
|
127
|
+
data_value = content[:data] || content["data"]
|
128
|
+
return if data_value.is_a?(String) && !data_value.empty?
|
129
|
+
|
130
|
+
raise ArgumentError, "Image content 'data' (base64 string) is required if type is 'image' (at index #{idx})"
|
131
|
+
end
|
132
|
+
|
133
|
+
def validate_image_mime_type(content, idx)
|
134
|
+
mime_type_value = content[:mime_type] || content["mime_type"]
|
135
|
+
return if mime_type_value.is_a?(String) && !mime_type_value.empty?
|
136
|
+
|
137
|
+
raise ArgumentError, "Image content 'mime_type' is required if type is 'image' (at index #{idx})"
|
138
|
+
end
|
139
|
+
|
140
|
+
def validate_optional_params
|
141
|
+
validate_model_preferences
|
142
|
+
validate_system_prompt
|
143
|
+
validate_include_context
|
144
|
+
validate_temperature
|
145
|
+
validate_max_tokens
|
146
|
+
validate_stop_sequences
|
147
|
+
validate_metadata
|
148
|
+
end
|
149
|
+
|
150
|
+
def validate_model_preferences
|
151
|
+
return unless @model_preferences && !@model_preferences.is_a?(Hash)
|
152
|
+
|
153
|
+
raise ArgumentError, "'model_preferences' must be a Hash if provided"
|
154
|
+
end
|
155
|
+
|
156
|
+
def validate_system_prompt
|
157
|
+
return unless @system_prompt && !@system_prompt.is_a?(String)
|
158
|
+
|
159
|
+
raise ArgumentError, "'system_prompt' must be a String if provided"
|
160
|
+
end
|
161
|
+
|
162
|
+
def validate_include_context
|
163
|
+
return unless @include_context && !%w[none thisServer allServers].include?(@include_context)
|
164
|
+
|
165
|
+
raise ArgumentError, "'include_context' must be 'none', 'thisServer', or 'allServers' if provided"
|
166
|
+
end
|
167
|
+
|
168
|
+
def validate_temperature
|
169
|
+
return unless @temperature && !@temperature.is_a?(Numeric)
|
170
|
+
|
171
|
+
raise ArgumentError, "'temperature' must be a Numeric if provided"
|
172
|
+
end
|
173
|
+
|
174
|
+
def validate_max_tokens
|
175
|
+
return unless @max_tokens && !@max_tokens.is_a?(Integer)
|
176
|
+
|
177
|
+
raise ArgumentError, "'max_tokens' must be an Integer if provided"
|
178
|
+
end
|
179
|
+
|
180
|
+
def validate_stop_sequences
|
181
|
+
return unless @stop_sequences && !@stop_sequences.is_a?(Array)
|
182
|
+
|
183
|
+
raise ArgumentError, "'stop_sequences' must be an Array if provided"
|
184
|
+
end
|
185
|
+
|
186
|
+
def validate_metadata
|
187
|
+
return unless @metadata && !@metadata.is_a?(Hash)
|
188
|
+
|
189
|
+
raise ArgumentError, "'metadata' must be a Hash if provided"
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
module Sampling
|
5
|
+
# Represents the result of a sampling request returned by an MCP client.
|
6
|
+
class Result
|
7
|
+
attr_reader :raw_result, :model, :stop_reason, :role, :content
|
8
|
+
|
9
|
+
# Initializes a new Sampling::Result.
|
10
|
+
#
|
11
|
+
# @param result_hash [Hash] The raw hash returned by the client for a sampling request.
|
12
|
+
# Expected keys (MCP spec uses camelCase, we symbolize and underscore internally):
|
13
|
+
# - 'model' [String] (Required) Name of the model used.
|
14
|
+
# - 'stopReason' [String] (Optional) Reason why generation stopped.
|
15
|
+
# - 'role' [String] (Required) "user" or "assistant".
|
16
|
+
# - 'content' [Hash] (Required) The generated content.
|
17
|
+
# - 'type' [String] (Required) "text" or "image".
|
18
|
+
# - 'text' [String] (Optional) Text content if type is "text".
|
19
|
+
# - 'data' [String] (Optional) Base64 image data if type is "image".
|
20
|
+
# - 'mimeType' [String] (Optional) Mime type if type is "image".
|
21
|
+
def initialize(result_hash)
|
22
|
+
@raw_result = result_hash.transform_keys { |k| k.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase.to_sym }
|
23
|
+
|
24
|
+
@model = @raw_result[:model]
|
25
|
+
@stop_reason = @raw_result[:stop_reason]
|
26
|
+
@role = @raw_result[:role]
|
27
|
+
@content = (@raw_result[:content] || {}).transform_keys(&:to_sym)
|
28
|
+
|
29
|
+
validate!
|
30
|
+
end
|
31
|
+
|
32
|
+
# @return [Boolean] True if the content type is 'text'.
|
33
|
+
def text?
|
34
|
+
@content[:type] == "text"
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [Boolean] True if the content type is 'image'.
|
38
|
+
def image?
|
39
|
+
@content[:type] == "image"
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [String, nil] The text content if type is 'text', otherwise nil.
|
43
|
+
def text_content
|
44
|
+
text? ? @content[:text] : nil
|
45
|
+
end
|
46
|
+
|
47
|
+
# @return [String, nil] The base64 encoded image data if type is 'image', otherwise nil.
|
48
|
+
def image_data
|
49
|
+
image? ? @content[:data] : nil
|
50
|
+
end
|
51
|
+
|
52
|
+
# @return [String, nil] The mime type of the image if type is 'image', otherwise nil.
|
53
|
+
def image_mime_type
|
54
|
+
image? ? @content[:mime_type] : nil
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def validate!
|
60
|
+
raise ArgumentError, "'model' is required in sampling result" if @model.to_s.empty?
|
61
|
+
raise ArgumentError, "'role' is required in sampling result and must be 'user' or 'assistant'" unless %w[user assistant].include?(@role)
|
62
|
+
raise ArgumentError, "'content' hash is required in sampling result" if @content.empty?
|
63
|
+
|
64
|
+
content_type = @content[:type]
|
65
|
+
raise ArgumentError, "Content 'type' must be 'text' or 'image' in sampling result" unless %w[text image].include?(content_type)
|
66
|
+
|
67
|
+
if content_type == "text" && @content[:text].to_s.empty?
|
68
|
+
# NOTE: Some models might return empty text, so we don't raise an error here but allow nil from text_content
|
69
|
+
# raise ArgumentError, "Content 'text' must not be empty if type is 'text'"
|
70
|
+
end
|
71
|
+
|
72
|
+
return unless content_type == "image"
|
73
|
+
raise ArgumentError, "Content 'data' (base64 string) is required if type is 'image'" if @content[:data].to_s.empty?
|
74
|
+
return unless @content[:mime_type].to_s.empty?
|
75
|
+
|
76
|
+
raise ArgumentError, "Content 'mime_type' is required if type is 'image'"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|