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.
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "sampling/request"
4
+ require_relative "sampling/result"
5
+ require_relative "errors"
6
+
3
7
  module VectorMCP
4
8
  # Represents the state of a single client-server connection session in MCP.
5
9
  # It tracks initialization status, and negotiated capabilities between the client and server.
@@ -10,21 +14,23 @@ module VectorMCP
10
14
  # @attr_reader client_info [Hash, nil] Information about the client, received during initialization.
11
15
  # @attr_reader client_capabilities [Hash, nil] Capabilities supported by the client, received during initialization.
12
16
  class Session
13
- attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities
17
+ attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id
18
+ attr_accessor :data # For user-defined session-specific storage
14
19
 
15
20
  # Initializes a new session.
16
21
  #
17
- # @param server_info [Hash] Hash containing server information (e.g., name, version).
18
- # @param server_capabilities [Hash] Hash describing server capabilities.
19
- # @param protocol_version [String] The protocol version the server adheres to.
20
- def initialize(server_info:, server_capabilities:, protocol_version:)
21
- @server_info = server_info
22
- @server_capabilities = server_capabilities
23
- @protocol_version = protocol_version
24
-
25
- @initialized = false
22
+ # @param server [VectorMCP::Server] The server instance managing this session.
23
+ # @param transport [VectorMCP::Transport::Base, nil] The transport handling this session. Required for sampling.
24
+ # @param id [String] A unique identifier for this session (e.g., from transport layer).
25
+ def initialize(server, transport = nil, id: SecureRandom.uuid)
26
+ @server = server
27
+ @transport = transport # Store the transport for sending requests
28
+ @id = id
29
+ @initialized_state = :pending # :pending, :succeeded, :failed
26
30
  @client_info = nil
27
31
  @client_capabilities = nil
32
+ @data = {} # Initialize user data hash
33
+ @logger = server.logger
28
34
  end
29
35
 
30
36
  # Marks the session as initialized using parameters from the client's `initialize` request.
@@ -33,35 +39,106 @@ module VectorMCP
33
39
  # Expected keys include "protocolVersion", "clientInfo", and "capabilities".
34
40
  # @return [Hash] A hash suitable for the server's `initialize` response result.
35
41
  def initialize!(params)
36
- client_protocol_version = params["protocolVersion"]
42
+ raise InitializationError, "Session already initialized or initialization attempt in progress." unless @initialized_state == :pending
37
43
 
38
- if client_protocol_version != @protocol_version
39
- # raise VectorMCP::ProtocolError.new("Unsupported protocol version: #{client_protocol_version}", code: -32603)
40
- VectorMCP.logger.warn("Client requested protocol version '#{client_protocol_version}', server using '#{@protocol_version}'")
41
- end
44
+ # TODO: More robust validation of params against MCP spec for initialize request
45
+ params["protocolVersion"]
46
+ client_capabilities_raw = params["capabilities"]
47
+ client_info_raw = params["clientInfo"]
48
+
49
+ # For now, we mostly care about clientInfo and capabilities for the session object.
50
+ # Protocol version matching is more of a server/transport concern at a lower level if strict checks are needed.
51
+ @client_info = client_info_raw.transform_keys(&:to_sym) if client_info_raw.is_a?(Hash)
52
+ @client_capabilities = client_capabilities_raw.transform_keys(&:to_sym) if client_capabilities_raw.is_a?(Hash)
42
53
 
43
- @client_info = params["clientInfo"] || {}
44
- @client_capabilities = params["capabilities"] || {}
45
- @initialized = true
54
+ @initialized_state = :succeeded
55
+ @logger.info("[Session #{@id}] Initialized successfully. Client: #{@client_info&.dig(:name)}")
46
56
 
47
- # Return the initialize result (will be sent by transport)
48
57
  {
49
- protocolVersion: @protocol_version,
50
- serverInfo: @server_info,
51
- capabilities: @server_capabilities
58
+ protocolVersion: @server.protocol_version,
59
+ serverInfo: @server.server_info,
60
+ capabilities: @server.server_capabilities
52
61
  }
62
+ rescue StandardError => e
63
+ @initialized_state = :failed
64
+ @logger.error("[Session #{@id}] Initialization failed: #{e.message}")
65
+ # Re-raise as an InitializationError if it's not already one of our ProtocolErrors
66
+ raise e if e.is_a?(ProtocolError)
67
+
68
+ raise InitializationError, "Initialization processing error: #{e.message}", details: { original_error: e.to_s }
53
69
  end
54
70
 
55
71
  # Checks if the session has been successfully initialized.
56
72
  #
57
73
  # @return [Boolean] True if the session is initialized, false otherwise.
58
74
  def initialized?
59
- @initialized
75
+ @initialized_state == :succeeded
60
76
  end
61
77
 
62
78
  # Helper to check client capabilities later if needed
63
79
  # def supports?(capability_key)
64
80
  # @client_capabilities.key?(capability_key.to_s)
65
81
  # end
82
+
83
+ # --- MCP Sampling Method ---
84
+
85
+ # Initiates an MCP sampling request to the client associated with this session.
86
+ # This is a blocking call that waits for the client's response.
87
+ #
88
+ # @param request_params [Hash] Parameters for the `sampling/createMessage` request.
89
+ # See `VectorMCP::Sampling::Request` for expected structure (e.g., :messages, :max_tokens).
90
+ # @param timeout [Numeric, nil] Optional timeout in seconds for this specific request.
91
+ # Defaults to the transport's default request timeout.
92
+ # @return [VectorMCP::Sampling::Result] The result of the sampling operation.
93
+ # @raise [VectorMCP::SamplingError] if the sampling request fails, is rejected, or times out.
94
+ # @raise [StandardError] if the session's transport does not support `send_request`.
95
+ def sample(request_params, timeout: nil)
96
+ validate_sampling_preconditions
97
+
98
+ sampling_req_obj = VectorMCP::Sampling::Request.new(request_params)
99
+ @logger.info("[Session #{@id}] Sending sampling/createMessage request to client.")
100
+
101
+ send_sampling_request(sampling_req_obj, timeout)
102
+ end
103
+
104
+ private
105
+
106
+ # Validates that sampling can be performed on this session.
107
+ # @api private
108
+ # @raise [StandardError, InitializationError] if preconditions are not met.
109
+ def validate_sampling_preconditions
110
+ unless @transport.respond_to?(:send_request)
111
+ raise StandardError, "Session's transport does not support sending requests (required for sampling)."
112
+ end
113
+
114
+ return if initialized?
115
+
116
+ @logger.warn("[Session #{@id}] Attempted to send sampling request on a non-initialized session.")
117
+ raise InitializationError, "Cannot send sampling request: session not initialized."
118
+ end
119
+
120
+ # Sends the sampling request and handles the response.
121
+ # @api private
122
+ # @param sampling_req_obj [VectorMCP::Sampling::Request] The sampling request object.
123
+ # @param timeout [Numeric, nil] Optional timeout for the request.
124
+ # @return [VectorMCP::Sampling::Result] The sampling result.
125
+ # @raise [VectorMCP::SamplingError] if the request fails.
126
+ def send_sampling_request(sampling_req_obj, timeout)
127
+ send_request_args = ["sampling/createMessage", sampling_req_obj.to_h]
128
+ send_request_kwargs = {}
129
+ send_request_kwargs[:timeout] = timeout if timeout
130
+
131
+ raw_result = @transport.send_request(*send_request_args, **send_request_kwargs)
132
+ VectorMCP::Sampling::Result.new(raw_result)
133
+ rescue ArgumentError => e
134
+ @logger.error("[Session #{@id}] Invalid parameters for sampling request or result: #{e.message}")
135
+ raise VectorMCP::SamplingError, "Invalid sampling parameters or malformed client response: #{e.message}", details: { original_error: e.to_s }
136
+ rescue VectorMCP::SamplingError => e
137
+ @logger.warn("[Session #{@id}] Sampling request failed: #{e.message}")
138
+ raise e
139
+ rescue StandardError => e
140
+ @logger.error("[Session #{@id}] Unexpected error during sampling: #{e.class.name}: #{e.message}")
141
+ raise VectorMCP::SamplingError, "An unexpected error occurred during sampling: #{e.message}", details: { original_error: e.to_s }
142
+ end
66
143
  end
67
144
  end
@@ -4,6 +4,8 @@
4
4
  require "json"
5
5
  require_relative "../errors"
6
6
  require_relative "../util"
7
+ require "securerandom" # For generating unique request IDs
8
+ require "timeout" # For request timeouts
7
9
 
8
10
  module VectorMCP
9
11
  module Transport
@@ -19,6 +21,9 @@ module VectorMCP
19
21
  # @return [Logger] The logger instance, shared with the server.
20
22
  attr_reader :logger
21
23
 
24
+ # Timeout for waiting for a response to a server-initiated request (in seconds)
25
+ DEFAULT_REQUEST_TIMEOUT = 30 # Configurable if needed
26
+
22
27
  # Initializes a new Stdio transport.
23
28
  #
24
29
  # @param server [VectorMCP::Server] The server instance that will handle messages.
@@ -29,6 +34,14 @@ module VectorMCP
29
34
  @output_mutex = Mutex.new
30
35
  @running = false
31
36
  @input_thread = nil
37
+ @shutdown_requested = false
38
+ @outgoing_request_responses = {} # To store responses for server-initiated requests
39
+ @outgoing_request_conditions = {} # ConditionVariables for server-initiated requests
40
+ @mutex = Mutex.new # To synchronize access to shared response data
41
+ @request_id_generator = Enumerator.new do |y|
42
+ i = 0
43
+ loop { y << "vecmcp_stdio_#{i += 1}_#{SecureRandom.hex(4)}" }
44
+ end
32
45
  end
33
46
 
34
47
  # Starts the stdio transport, listening for input and processing messages.
@@ -96,6 +109,30 @@ module VectorMCP
96
109
  write_message(notification)
97
110
  end
98
111
 
112
+ # Sends a server-initiated JSON-RPC request to the client and waits for a response.
113
+ # This is a blocking call.
114
+ #
115
+ # @param method [String] The request method name.
116
+ # @param params [Hash, Array, nil] The request parameters.
117
+ # @param timeout [Numeric] How long to wait for a response, in seconds.
118
+ # @return [Object] The result part of the client's response.
119
+ # @raise [VectorMCP::SamplingError, VectorMCP::SamplingTimeoutError] if the client returns an error or times out.
120
+ # @raise [ArgumentError] if method is blank.
121
+ def send_request(method, params = nil, timeout: DEFAULT_REQUEST_TIMEOUT)
122
+ raise ArgumentError, "Method cannot be blank" if method.to_s.strip.empty?
123
+
124
+ request_id = @request_id_generator.next
125
+ request_payload = { jsonrpc: "2.0", id: request_id, method: method }
126
+ request_payload[:params] = params if params
127
+
128
+ setup_request_tracking(request_id)
129
+ logger.debug "[Stdio Transport] Sending request ID #{request_id}: #{method}"
130
+ write_message(request_payload)
131
+
132
+ response = wait_for_response(request_id, method, timeout)
133
+ process_response(response, request_id, method)
134
+ end
135
+
99
136
  # Initiates an immediate shutdown of the transport.
100
137
  # Sets the running flag to false and attempts to kill the input reading thread.
101
138
  #
@@ -112,9 +149,8 @@ module VectorMCP
112
149
  # @api private
113
150
  # @param session [VectorMCP::Session] The session object for this connection.
114
151
  # @return [void]
152
+ # Constant identifier for stdio sessions
115
153
  def read_input_loop(session)
116
- session_id = "stdio-session" # Constant identifier for stdio sessions
117
-
118
154
  while @running
119
155
  line = read_input_line
120
156
  if line.nil?
@@ -123,7 +159,7 @@ module VectorMCP
123
159
  end
124
160
  next if line.strip.empty?
125
161
 
126
- handle_input_line(line, session, session_id)
162
+ handle_input_line(line, session)
127
163
  end
128
164
  end
129
165
 
@@ -141,18 +177,48 @@ module VectorMCP
141
177
  # @api private
142
178
  # @param line [String] The line of text read from stdin.
143
179
  # @param session [VectorMCP::Session] The current session.
144
- # @param session_id [String] The identifier for this session.
145
180
  # @return [void]
146
- def handle_input_line(line, session, session_id)
181
+ def handle_input_line(line, _session)
147
182
  message = parse_json(line)
148
183
  return if message.is_a?(Array) && message.empty? # Error handled in parse_json, indicated by empty array
149
184
 
150
- response_data = server.handle_message(message, session, session_id)
151
- send_response(message["id"], response_data) if message["id"] && response_data
152
- rescue VectorMCP::ProtocolError => e
153
- handle_protocol_error(e, message)
154
- rescue StandardError => e
155
- handle_unexpected_error(e, message)
185
+ return handle_outgoing_response(message) if outgoing_response?(message)
186
+
187
+ ensure_session_exists
188
+ handle_server_message(message)
189
+ end
190
+
191
+ # Checks if a message is a response to an outgoing request.
192
+ # @api private
193
+ # @param message [Hash] The parsed message.
194
+ # @return [Boolean] True if this is an outgoing response.
195
+ def outgoing_response?(message)
196
+ message["id"] && !message["method"] && (message.key?("result") || message.key?("error"))
197
+ end
198
+
199
+ # Ensures a global session exists for this stdio transport.
200
+ # @api private
201
+ # @return [VectorMCP::Session] The current session.
202
+ def ensure_session_exists
203
+ @ensure_session_exists ||= VectorMCP::Session.new(@server, self, id: "stdio_global_session")
204
+ end
205
+
206
+ # Handles a server message with proper error handling.
207
+ # @api private
208
+ # @param message [Hash] The parsed message.
209
+ # @return [void]
210
+ def handle_server_message(message)
211
+ session = ensure_session_exists
212
+ session_id = session.id
213
+
214
+ begin
215
+ result = @server.handle_message(message, session, session_id)
216
+ send_response(message["id"], result) if message["id"] && result
217
+ rescue VectorMCP::ProtocolError => e
218
+ handle_protocol_error(e, message)
219
+ rescue StandardError => e
220
+ handle_unexpected_error(e, message)
221
+ end
156
222
  end
157
223
 
158
224
  # --- Run helpers (private) ---
@@ -161,11 +227,7 @@ module VectorMCP
161
227
  # @api private
162
228
  # @return [VectorMCP::Session] The newly created session.
163
229
  def create_session
164
- VectorMCP::Session.new(
165
- server_info: server.server_info,
166
- server_capabilities: server.server_capabilities,
167
- protocol_version: server.protocol_version
168
- )
230
+ VectorMCP::Session.new(@server, self)
169
231
  end
170
232
 
171
233
  # Launches the input reading loop in a new thread.
@@ -193,6 +255,30 @@ module VectorMCP
193
255
 
194
256
  # --- Input helpers (private) ---
195
257
 
258
+ # Handles responses to outgoing requests (like sampling requests).
259
+ # @api private
260
+ # @param message [Hash] The parsed response message.
261
+ # @return [void]
262
+ def handle_outgoing_response(message)
263
+ request_id = message["id"]
264
+ logger.debug "[Stdio Transport] Received response for outgoing request ID #{request_id}"
265
+
266
+ @mutex.synchronize do
267
+ # Store the response (convert keys to symbols for consistency)
268
+ response_data = deep_transform_keys(message, &:to_sym)
269
+ @outgoing_request_responses[request_id] = response_data
270
+
271
+ # Signal any thread waiting for this response
272
+ condition = @outgoing_request_conditions[request_id]
273
+ if condition
274
+ condition.signal
275
+ logger.debug "[Stdio Transport] Signaled condition for request ID #{request_id}"
276
+ else
277
+ logger.warn "[Stdio Transport] Received response for request ID #{request_id} but no thread is waiting"
278
+ end
279
+ end
280
+ end
281
+
196
282
  # Parses a line of text as JSON.
197
283
  # If parsing fails, sends a JSON-RPC ParseError and returns an empty array
198
284
  # to signal that the error has been handled.
@@ -234,6 +320,21 @@ module VectorMCP
234
320
  send_error(request_id, -32_603, "Internal error", { details: error.message })
235
321
  end
236
322
 
323
+ # Recursively transforms hash keys using the given block.
324
+ # @api private
325
+ # @param obj [Object] The object to transform (Hash, Array, or other).
326
+ # @return [Object] The transformed object.
327
+ def deep_transform_keys(obj, &block)
328
+ case obj
329
+ when Hash
330
+ obj.transform_keys(&block).transform_values { |v| deep_transform_keys(v, &block) }
331
+ when Array
332
+ obj.map { |v| deep_transform_keys(v, &block) }
333
+ else
334
+ obj
335
+ end
336
+ end
337
+
237
338
  # Writes a message hash to `$stdout` as a JSON string, followed by a newline.
238
339
  # Ensures the output is flushed. Handles EPIPE errors if stdout closes.
239
340
  # @api private
@@ -253,6 +354,63 @@ module VectorMCP
253
354
  shutdown # Initiate shutdown as we can no longer communicate
254
355
  end
255
356
  end
357
+
358
+ # Sets up tracking for an outgoing request.
359
+ # @api private
360
+ # @param request_id [String] The request ID to track.
361
+ # @return [void]
362
+ def setup_request_tracking(request_id)
363
+ condition = ConditionVariable.new
364
+ @mutex.synchronize do
365
+ @outgoing_request_conditions[request_id] = condition
366
+ end
367
+ end
368
+
369
+ # Waits for a response to an outgoing request.
370
+ # @api private
371
+ # @param request_id [String] The request ID to wait for.
372
+ # @param method [String] The request method name.
373
+ # @param timeout [Numeric] How long to wait.
374
+ # @return [Hash] The response data.
375
+ # @raise [VectorMCP::SamplingTimeoutError] if timeout occurs.
376
+ def wait_for_response(request_id, method, timeout)
377
+ condition = @outgoing_request_conditions[request_id]
378
+
379
+ @mutex.synchronize do
380
+ Timeout.timeout(timeout) do
381
+ condition.wait(@mutex) until @outgoing_request_responses.key?(request_id)
382
+ @outgoing_request_responses.delete(request_id)
383
+ end
384
+ rescue Timeout::Error
385
+ logger.warn "[Stdio Transport] Timeout waiting for response to request ID #{request_id} (#{method}) after #{timeout}s"
386
+ @outgoing_request_responses.delete(request_id)
387
+ @outgoing_request_conditions.delete(request_id)
388
+ raise VectorMCP::SamplingTimeoutError, "Timeout waiting for client response to '#{method}' request (ID: #{request_id})"
389
+ ensure
390
+ @outgoing_request_conditions.delete(request_id)
391
+ end
392
+ end
393
+
394
+ # Processes the response from an outgoing request.
395
+ # @api private
396
+ # @param response [Hash, nil] The response data.
397
+ # @param request_id [String] The request ID.
398
+ # @param method [String] The request method name.
399
+ # @return [Object] The result data.
400
+ # @raise [VectorMCP::SamplingError] if response contains an error or is nil.
401
+ def process_response(response, request_id, method)
402
+ if response.nil?
403
+ raise VectorMCP::SamplingError, "No response received for '#{method}' request (ID: #{request_id}) - this indicates a logic error."
404
+ end
405
+
406
+ if response.key?(:error)
407
+ err = response[:error]
408
+ logger.warn "[Stdio Transport] Client returned error for request ID #{request_id} (#{method}): #{err.inspect}"
409
+ raise VectorMCP::SamplingError, "Client returned an error for '#{method}' request (ID: #{request_id}): [#{err[:code]}] #{err[:message]}"
410
+ end
411
+
412
+ response[:result]
413
+ end
256
414
  end
257
415
  end
258
416
  end
@@ -14,9 +14,10 @@ module VectorMCP
14
14
  # into the wire-format expected by the MCP spec.
15
15
  #
16
16
  # Keys present in each returned hash:
17
- # * **:type** – Currently always `"text"`; future protocol versions may add rich/binary types.
18
- # * **:text** – UTF-8 encoded payload.
19
- # * **:mimeType** IANA media-type describing `:text` (`"text/plain"`, `"application/json"`, ).
17
+ # * **:type** – `"text"` or `"image"`; automatic detection for binary data.
18
+ # * **:text** – UTF-8 encoded payload (for text content).
19
+ # * **:data** Base64 encoded payload (for image content).
20
+ # * **:mimeType** – IANA media-type describing the content.
20
21
  # * **:uri** – _Optional._ Added downstream (e.g., by {Handlers::Core.read_resource}).
21
22
  #
22
23
  # The method **never** returns `nil` and **always** returns at least one element.
@@ -35,6 +36,10 @@ module VectorMCP
35
36
  # @example Complex object
36
37
  # VectorMCP::Util.convert_to_mcp_content({foo: 1})
37
38
  # # => [{type: "text", text: "{\"foo\":1}", mimeType: "application/json"}]
39
+ #
40
+ # @example Image file path
41
+ # VectorMCP::Util.convert_to_mcp_content("image.jpg")
42
+ # # => [{type: "image", data: "base64...", mimeType: "image/jpeg"}]
38
43
  def convert_to_mcp_content(input, mime_type: "text/plain")
39
44
  return string_content(input, mime_type) if input.is_a?(String)
40
45
  return hash_content(input) if input.is_a?(Hash)
@@ -45,11 +50,20 @@ module VectorMCP
45
50
 
46
51
  # --- Conversion helpers (exposed as module functions) ---
47
52
 
48
- # Converts a String into an MCP text content item.
53
+ # Converts a String into an MCP content item.
54
+ # Intelligently detects if the string is binary image data, a file path to an image,
55
+ # or regular text content.
49
56
  # @param str [String] The string to convert.
50
57
  # @param mime_type [String] The MIME type for the content.
51
- # @return [Array<Hash>] MCP content array with one text item.
58
+ # @return [Array<Hash>] MCP content array with one item.
52
59
  def string_content(str, mime_type)
60
+ # Check if this might be a file path to an image
61
+ return file_path_to_image_content(str) if looks_like_image_file_path?(str)
62
+
63
+ # Check if this is binary image data
64
+ return binary_image_to_content(str) if binary_image_data?(str)
65
+
66
+ # Default to text content
53
67
  [{ type: "text", text: str, mimeType: mime_type }]
54
68
  end
55
69
 
@@ -60,7 +74,12 @@ module VectorMCP
60
74
  # @return [Array<Hash>] MCP content array.
61
75
  def hash_content(hash)
62
76
  if hash[:type] || hash["type"] # Already in content format
63
- [hash.transform_keys(&:to_sym)]
77
+ normalized = hash.transform_keys(&:to_sym)
78
+
79
+ # Validate and enhance image content if needed
80
+ return [validate_and_enhance_image_content(normalized)] if normalized[:type] == "image"
81
+
82
+ [normalized]
64
83
  else
65
84
  [{ type: "text", text: hash.to_json, mimeType: "application/json" }]
66
85
  end
@@ -73,14 +92,30 @@ module VectorMCP
73
92
  # @param mime_type [String] The default MIME type for child items if they need conversion.
74
93
  # @return [Array<Hash>] MCP content array.
75
94
  def array_content(arr, mime_type)
76
- if arr.all? { |item| item.is_a?(Hash) && (item[:type] || item["type"]) }
77
- arr.map { |item| item.transform_keys(&:to_sym) }
95
+ if all_content_items?(arr)
96
+ arr.map { |item| process_content_item(item) }
78
97
  else
79
- # Recursively convert each item, preserving the original mime_type intent for non-structured children.
80
98
  arr.flat_map { |item| convert_to_mcp_content(item, mime_type: mime_type) }
81
99
  end
82
100
  end
83
101
 
102
+ # Checks if all array items are pre-formatted MCP content items.
103
+ # @param arr [Array] The array to check.
104
+ # @return [Boolean] True if all items have type fields.
105
+ def all_content_items?(arr)
106
+ arr.all? { |item| item.is_a?(Hash) && (item[:type] || item["type"]) }
107
+ end
108
+
109
+ # Processes a single content item, normalizing and validating as needed.
110
+ # @param item [Hash] The content item to process.
111
+ # @return [Hash] The processed content item.
112
+ def process_content_item(item)
113
+ normalized = item.transform_keys(&:to_sym)
114
+ return validate_and_enhance_image_content(normalized) if normalized[:type] == "image"
115
+
116
+ normalized
117
+ end
118
+
84
119
  # Fallback conversion for any other object type to an MCP text content item.
85
120
  # Converts the object to its string representation.
86
121
  # @param obj [Object] The object to convert.
@@ -90,7 +125,8 @@ module VectorMCP
90
125
  [{ type: "text", text: obj.to_s, mimeType: mime_type }]
91
126
  end
92
127
 
93
- module_function :string_content, :hash_content, :array_content, :fallback_content
128
+ module_function :string_content, :hash_content, :array_content, :fallback_content,
129
+ :all_content_items?, :process_content_item
94
130
 
95
131
  # Extracts an ID from a potentially malformed JSON string using regex.
96
132
  # This is a best-effort attempt, primarily for error reporting when full JSON parsing fails.
@@ -109,5 +145,94 @@ module VectorMCP
109
145
 
110
146
  nil
111
147
  end
148
+
149
+ private
150
+
151
+ # Checks if a string looks like a file path to an image.
152
+ # @param str [String] The string to check.
153
+ # @return [Boolean] True if it looks like an image file path.
154
+ def looks_like_image_file_path?(str)
155
+ return false if str.nil? || str.empty? || str.length > 500
156
+
157
+ # Check for common image extensions
158
+ image_extensions = %w[.jpg .jpeg .png .gif .webp .bmp .tiff .tif .svg]
159
+ has_image_extension = image_extensions.any? { |ext| str.downcase.end_with?(ext) }
160
+
161
+ # Check if it looks like a file path (contains / or \ or ends with image extension)
162
+ looks_like_path = str.include?("/") || str.include?("\\") || has_image_extension
163
+
164
+ has_image_extension && looks_like_path
165
+ end
166
+
167
+ # Checks if a string contains binary image data.
168
+ # @param str [String] The string to check.
169
+ # @return [Boolean] True if it appears to be binary image data.
170
+ def binary_image_data?(str)
171
+ return false if str.nil? || str.empty?
172
+
173
+ # Check encoding first
174
+ encoding = str.encoding
175
+ is_binary = encoding == Encoding::ASCII_8BIT || !str.valid_encoding?
176
+
177
+ return false unless is_binary
178
+
179
+ # Use ImageUtil to detect if it's actually image data
180
+ require_relative "image_util"
181
+ !VectorMCP::ImageUtil.detect_image_format(str).nil?
182
+ rescue StandardError
183
+ false
184
+ end
185
+
186
+ # Converts a file path string to image content.
187
+ # @param file_path [String] Path to the image file.
188
+ # @return [Array<Hash>] MCP content array with image content.
189
+ def file_path_to_image_content(file_path)
190
+ require_relative "image_util"
191
+
192
+ begin
193
+ image_content = VectorMCP::ImageUtil.file_to_mcp_image_content(file_path)
194
+ [image_content]
195
+ rescue ArgumentError => e
196
+ # If image processing fails, fall back to text content with error message
197
+ [{ type: "text", text: "Error loading image '#{file_path}': #{e.message}", mimeType: "text/plain" }]
198
+ end
199
+ end
200
+
201
+ # Converts binary image data to MCP image content.
202
+ # @param binary_data [String] Binary image data.
203
+ # @return [Array<Hash>] MCP content array with image content.
204
+ def binary_image_to_content(binary_data)
205
+ require_relative "image_util"
206
+
207
+ begin
208
+ image_content = VectorMCP::ImageUtil.to_mcp_image_content(binary_data)
209
+ [image_content]
210
+ rescue ArgumentError
211
+ # If image processing fails, fall back to text content
212
+ [{ type: "text", text: binary_data.to_s, mimeType: "application/octet-stream" }]
213
+ end
214
+ end
215
+
216
+ # Validates and enhances existing image content hash.
217
+ # @param content [Hash] Existing image content hash.
218
+ # @return [Hash] Validated and enhanced image content.
219
+ def validate_and_enhance_image_content(content)
220
+ # Ensure required fields are present
221
+ raise ArgumentError, "Image content must have both :data and :mimeType fields" unless content[:data] && content[:mimeType]
222
+
223
+ # Validate the base64 data if possible
224
+ begin
225
+ require_relative "image_util"
226
+ VectorMCP::ImageUtil.decode_base64(content[:data])
227
+ rescue ArgumentError => e
228
+ raise ArgumentError, "Invalid base64 image data: #{e.message}"
229
+ end
230
+
231
+ content
232
+ end
233
+
234
+ module_function :looks_like_image_file_path?, :binary_image_data?,
235
+ :file_path_to_image_content, :binary_image_to_content,
236
+ :validate_and_enhance_image_content
112
237
  end
113
238
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module VectorMCP
4
4
  # The current version of the VectorMCP gem.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
data/lib/vector_mcp.rb CHANGED
@@ -8,9 +8,10 @@ require_relative "vector_mcp/errors"
8
8
  require_relative "vector_mcp/definitions"
9
9
  require_relative "vector_mcp/session"
10
10
  require_relative "vector_mcp/util"
11
+ require_relative "vector_mcp/image_util"
11
12
  require_relative "vector_mcp/handlers/core"
12
13
  require_relative "vector_mcp/transport/stdio"
13
- require_relative "vector_mcp/transport/sse"
14
+ # require_relative "vector_mcp/transport/sse" # Load on demand to avoid async dependencies
14
15
  require_relative "vector_mcp/server"
15
16
 
16
17
  # The VectorMCP module provides a full-featured, opinionated Ruby implementation