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.
@@ -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