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
data/lib/vector_mcp/session.rb
CHANGED
@@ -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
|
18
|
-
# @param
|
19
|
-
# @param
|
20
|
-
def initialize(
|
21
|
-
@
|
22
|
-
@
|
23
|
-
@
|
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
|
-
|
42
|
+
raise InitializationError, "Session already initialized or initialization attempt in progress." unless @initialized_state == :pending
|
37
43
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
-
@
|
44
|
-
@
|
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
|
-
@
|
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
|
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,
|
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
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
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
|
data/lib/vector_mcp/util.rb
CHANGED
@@ -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** –
|
18
|
-
# * **:text** – UTF-8 encoded payload.
|
19
|
-
# * **:
|
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
|
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
|
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
|
-
|
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
|
77
|
-
arr.map { |item| item
|
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
|
data/lib/vector_mcp/version.rb
CHANGED
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
|