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,156 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
class Server
|
5
|
+
# Handles server capabilities and configuration
|
6
|
+
module Capabilities
|
7
|
+
# --- Server Information and Capabilities ---
|
8
|
+
|
9
|
+
# Provides basic information about the server.
|
10
|
+
# @return [Hash] Server name and version.
|
11
|
+
def server_info
|
12
|
+
{ name: @name, version: @version }
|
13
|
+
end
|
14
|
+
|
15
|
+
# Returns the sampling configuration for this server.
|
16
|
+
# @return [Hash] The sampling configuration including capabilities and limits.
|
17
|
+
def sampling_config
|
18
|
+
@sampling_config[:config]
|
19
|
+
end
|
20
|
+
|
21
|
+
# Describes the capabilities of this server according to MCP specifications.
|
22
|
+
# @return [Hash] A capabilities object.
|
23
|
+
def server_capabilities
|
24
|
+
caps = {}
|
25
|
+
caps[:tools] = { listChanged: false } unless @tools.empty?
|
26
|
+
caps[:resources] = { subscribe: false, listChanged: false } unless @resources.empty?
|
27
|
+
caps[:prompts] = { listChanged: @prompts_list_changed } unless @prompts.empty?
|
28
|
+
caps[:roots] = { listChanged: true } unless @roots.empty?
|
29
|
+
caps[:sampling] = @sampling_config[:capabilities]
|
30
|
+
caps
|
31
|
+
end
|
32
|
+
|
33
|
+
# Resets the `prompts_list_changed` flag to false.
|
34
|
+
# @return [void]
|
35
|
+
def clear_prompts_list_changed
|
36
|
+
@prompts_list_changed = false
|
37
|
+
logger.debug("Prompts listChanged flag cleared.")
|
38
|
+
end
|
39
|
+
|
40
|
+
# Notifies connected clients that the list of available prompts has changed.
|
41
|
+
# @return [void]
|
42
|
+
def notify_prompts_list_changed
|
43
|
+
return unless transport && @prompts_list_changed
|
44
|
+
|
45
|
+
notification_method = "notifications/prompts/list_changed"
|
46
|
+
begin
|
47
|
+
if transport.respond_to?(:broadcast_notification)
|
48
|
+
logger.info("Broadcasting prompts list changed notification.")
|
49
|
+
transport.broadcast_notification(notification_method)
|
50
|
+
elsif transport.respond_to?(:send_notification)
|
51
|
+
logger.info("Sending prompts list changed notification (transport may broadcast or send to first client).")
|
52
|
+
transport.send_notification(notification_method)
|
53
|
+
else
|
54
|
+
logger.warn("Transport does not support sending notifications/prompts/list_changed.")
|
55
|
+
end
|
56
|
+
rescue StandardError => e
|
57
|
+
logger.error("Failed to send prompts list changed notification: #{e.class.name}: #{e.message}")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Resets the `roots_list_changed` flag to false.
|
62
|
+
# @return [void]
|
63
|
+
def clear_roots_list_changed
|
64
|
+
@roots_list_changed = false
|
65
|
+
logger.debug("Roots listChanged flag cleared.")
|
66
|
+
end
|
67
|
+
|
68
|
+
# Notifies connected clients that the list of available roots has changed.
|
69
|
+
# @return [void]
|
70
|
+
def notify_roots_list_changed
|
71
|
+
return unless transport && @roots_list_changed
|
72
|
+
|
73
|
+
notification_method = "notifications/roots/list_changed"
|
74
|
+
begin
|
75
|
+
if transport.respond_to?(:broadcast_notification)
|
76
|
+
logger.info("Broadcasting roots list changed notification.")
|
77
|
+
transport.broadcast_notification(notification_method)
|
78
|
+
elsif transport.respond_to?(:send_notification)
|
79
|
+
logger.info("Sending roots list changed notification (transport may broadcast or send to first client).")
|
80
|
+
transport.send_notification(notification_method)
|
81
|
+
else
|
82
|
+
logger.warn("Transport does not support sending notifications/roots/list_changed.")
|
83
|
+
end
|
84
|
+
rescue StandardError => e
|
85
|
+
logger.error("Failed to send roots list changed notification: #{e.class.name}: #{e.message}")
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Registers a session as a subscriber to prompt list changes.
|
90
|
+
# @api private
|
91
|
+
def subscribe_prompts(session)
|
92
|
+
@prompt_subscribers << session unless @prompt_subscribers.include?(session)
|
93
|
+
logger.debug("Session subscribed to prompt list changes: #{session.object_id}")
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
# Configures sampling capabilities based on provided configuration.
|
99
|
+
# @api private
|
100
|
+
def configure_sampling_capabilities(config)
|
101
|
+
defaults = {
|
102
|
+
enabled: true,
|
103
|
+
methods: ["createMessage"],
|
104
|
+
supports_streaming: false,
|
105
|
+
supports_tool_calls: false,
|
106
|
+
supports_images: false,
|
107
|
+
max_tokens_limit: nil,
|
108
|
+
timeout_seconds: 30,
|
109
|
+
context_inclusion_methods: %w[none thisServer],
|
110
|
+
model_preferences_supported: true
|
111
|
+
}
|
112
|
+
|
113
|
+
resolved_config = defaults.merge(config.transform_keys(&:to_sym))
|
114
|
+
capabilities = build_sampling_capabilities_object(resolved_config)
|
115
|
+
|
116
|
+
{
|
117
|
+
config: resolved_config,
|
118
|
+
capabilities: capabilities
|
119
|
+
}
|
120
|
+
end
|
121
|
+
|
122
|
+
# Builds the complete sampling capabilities object.
|
123
|
+
# @api private
|
124
|
+
def build_sampling_capabilities_object(config)
|
125
|
+
return {} unless config[:enabled]
|
126
|
+
|
127
|
+
{
|
128
|
+
methods: config[:methods],
|
129
|
+
features: build_sampling_features(config),
|
130
|
+
limits: build_sampling_limits(config),
|
131
|
+
contextInclusion: config[:context_inclusion_methods]
|
132
|
+
}
|
133
|
+
end
|
134
|
+
|
135
|
+
# Builds the features section of sampling capabilities.
|
136
|
+
# @api private
|
137
|
+
def build_sampling_features(config)
|
138
|
+
features = {}
|
139
|
+
features[:streaming] = true if config[:supports_streaming]
|
140
|
+
features[:toolCalls] = true if config[:supports_tool_calls]
|
141
|
+
features[:images] = true if config[:supports_images]
|
142
|
+
features[:modelPreferences] = true if config[:model_preferences_supported]
|
143
|
+
features
|
144
|
+
end
|
145
|
+
|
146
|
+
# Builds the limits section of sampling capabilities.
|
147
|
+
# @api private
|
148
|
+
def build_sampling_limits(config)
|
149
|
+
limits = {}
|
150
|
+
limits[:maxTokens] = config[:max_tokens_limit] if config[:max_tokens_limit]
|
151
|
+
limits[:defaultTimeout] = config[:timeout_seconds] if config[:timeout_seconds]
|
152
|
+
limits
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
class Server
|
5
|
+
# Handles message processing and request/notification dispatching
|
6
|
+
module MessageHandling
|
7
|
+
# --- Message Handling Logic (primarily called by transports) ---
|
8
|
+
|
9
|
+
# Handles an incoming JSON-RPC message (request or notification).
|
10
|
+
# This is the main dispatch point for messages received by a transport.
|
11
|
+
#
|
12
|
+
# @param message [Hash] The parsed JSON-RPC message object.
|
13
|
+
# @param session [VectorMCP::Session] The client session associated with this message.
|
14
|
+
# @param session_id [String] A unique identifier for the underlying transport connection.
|
15
|
+
# @return [Object, nil] For requests, returns the result data to be sent in the JSON-RPC response.
|
16
|
+
# For notifications, returns `nil`.
|
17
|
+
# @raise [VectorMCP::ProtocolError] if the message is invalid or an error occurs during handling.
|
18
|
+
def handle_message(message, session, session_id)
|
19
|
+
id = message["id"]
|
20
|
+
method = message["method"]
|
21
|
+
params = message["params"] || {}
|
22
|
+
|
23
|
+
if id && method # Request
|
24
|
+
logger.info("[#{session_id}] Request [#{id}]: #{method} with params: #{params.inspect}")
|
25
|
+
handle_request(id, method, params, session)
|
26
|
+
elsif method # Notification
|
27
|
+
logger.info("[#{session_id}] Notification: #{method} with params: #{params.inspect}")
|
28
|
+
handle_notification(method, params, session)
|
29
|
+
nil # Notifications do not have a return value to send back to client
|
30
|
+
elsif id # Invalid: Has ID but no method
|
31
|
+
logger.warn("[#{session_id}] Invalid message: Has ID [#{id}] but no method. #{message.inspect}")
|
32
|
+
raise VectorMCP::InvalidRequestError.new("Request object must include a 'method' member.", request_id: id)
|
33
|
+
else # Invalid: No ID and no method
|
34
|
+
logger.warn("[#{session_id}] Invalid message: Missing both 'id' and 'method'. #{message.inspect}")
|
35
|
+
raise VectorMCP::InvalidRequestError.new("Invalid message format", request_id: nil)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# --- Request/Notification Hook Methods ---
|
40
|
+
|
41
|
+
# Registers a handler for a specific JSON-RPC request method.
|
42
|
+
#
|
43
|
+
# @param method [String, Symbol] The method name (e.g., "my/customMethod").
|
44
|
+
# @yield [params, session, server] A block to handle the request.
|
45
|
+
# @return [self] The server instance.
|
46
|
+
def on_request(method, &handler)
|
47
|
+
@request_handlers[method.to_s] = handler
|
48
|
+
self
|
49
|
+
end
|
50
|
+
|
51
|
+
# Registers a handler for a specific JSON-RPC notification method.
|
52
|
+
#
|
53
|
+
# @param method [String, Symbol] The method name (e.g., "my/customNotification").
|
54
|
+
# @yield [params, session, server] A block to handle the notification.
|
55
|
+
# @return [self] The server instance.
|
56
|
+
def on_notification(method, &handler)
|
57
|
+
@notification_handlers[method.to_s] = handler
|
58
|
+
self
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
# Internal handler for JSON-RPC requests.
|
64
|
+
# @api private
|
65
|
+
def handle_request(id, method, params, session)
|
66
|
+
validate_session_initialization(id, method, params, session)
|
67
|
+
|
68
|
+
handler = @request_handlers[method]
|
69
|
+
raise VectorMCP::MethodNotFoundError.new(method, request_id: id) unless handler
|
70
|
+
|
71
|
+
execute_request_handler(id, method, params, session, handler)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Validates that the session is properly initialized for the given request.
|
75
|
+
# @api private
|
76
|
+
def validate_session_initialization(id, method, _params, session)
|
77
|
+
return if session.initialized?
|
78
|
+
|
79
|
+
# Allow "initialize" even if not marked initialized yet by server
|
80
|
+
return if method == "initialize"
|
81
|
+
|
82
|
+
# For any other method, session must be initialized
|
83
|
+
raise VectorMCP::InitializationError.new("Session not initialized. Client must send 'initialize' first.", request_id: id)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Executes the request handler with proper error handling and tracking.
|
87
|
+
# @api private
|
88
|
+
def execute_request_handler(id, method, params, session, handler)
|
89
|
+
@in_flight_requests[id] = { method: method, params: params, session: session, start_time: Time.now }
|
90
|
+
result = handler.call(params, session, self)
|
91
|
+
result
|
92
|
+
rescue VectorMCP::ProtocolError => e
|
93
|
+
# Ensure the request ID from the current context is on the error
|
94
|
+
e.request_id = id unless e.request_id && e.request_id == id
|
95
|
+
raise e # Re-raise with potentially updated request_id
|
96
|
+
rescue StandardError => e
|
97
|
+
handle_request_error(id, method, e)
|
98
|
+
ensure
|
99
|
+
@in_flight_requests.delete(id)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Handles unexpected errors during request processing.
|
103
|
+
# @api private
|
104
|
+
def handle_request_error(id, method, error)
|
105
|
+
logger.error("Unhandled error during request '#{method}' (ID: #{id}): #{error.message}\nBacktrace: #{error.backtrace.join("\n ")}")
|
106
|
+
raise VectorMCP::InternalError.new(
|
107
|
+
"Request handler failed unexpectedly",
|
108
|
+
request_id: id,
|
109
|
+
details: { method: method, error: "An internal error occurred" }
|
110
|
+
)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Internal handler for JSON-RPC notifications.
|
114
|
+
# @api private
|
115
|
+
def handle_notification(method, params, session)
|
116
|
+
unless session.initialized? || method == "initialized"
|
117
|
+
logger.warn("Ignoring notification '#{method}' before session is initialized. Params: #{params.inspect}")
|
118
|
+
return
|
119
|
+
end
|
120
|
+
|
121
|
+
handler = @notification_handlers[method]
|
122
|
+
if handler
|
123
|
+
begin
|
124
|
+
handler.call(params, session, self)
|
125
|
+
rescue StandardError => e
|
126
|
+
logger.error("Error executing notification handler '#{method}': #{e.message}\nBacktrace (top 5):\n #{e.backtrace.first(5).join("\n ")}")
|
127
|
+
# Notifications must not generate a response, even on error.
|
128
|
+
end
|
129
|
+
else
|
130
|
+
logger.debug("No handler registered for notification: #{method}")
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Sets up default handlers for core MCP methods using {VectorMCP::Handlers::Core}.
|
135
|
+
# @api private
|
136
|
+
def setup_default_handlers
|
137
|
+
# Core Requests
|
138
|
+
on_request("initialize", &session_method(:initialize!))
|
139
|
+
on_request("ping", &Handlers::Core.method(:ping))
|
140
|
+
on_request("tools/list", &Handlers::Core.method(:list_tools))
|
141
|
+
on_request("tools/call", &Handlers::Core.method(:call_tool))
|
142
|
+
on_request("resources/list", &Handlers::Core.method(:list_resources))
|
143
|
+
on_request("resources/read", &Handlers::Core.method(:read_resource))
|
144
|
+
on_request("prompts/list", &Handlers::Core.method(:list_prompts))
|
145
|
+
on_request("prompts/get", &Handlers::Core.method(:get_prompt))
|
146
|
+
on_request("prompts/subscribe", &Handlers::Core.method(:subscribe_prompts))
|
147
|
+
on_request("roots/list", &Handlers::Core.method(:list_roots))
|
148
|
+
|
149
|
+
# Core Notifications
|
150
|
+
on_notification("initialized", &Handlers::Core.method(:initialized_notification))
|
151
|
+
# Standard cancel request names
|
152
|
+
%w[$/cancelRequest $/cancel notifications/cancelled].each do |cancel_method|
|
153
|
+
on_notification(cancel_method, &Handlers::Core.method(:cancel_request_notification))
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Helper to create a proc that calls a method on the session object.
|
158
|
+
# @api private
|
159
|
+
def session_method(method_name)
|
160
|
+
lambda do |params, session, _server|
|
161
|
+
session.public_send(method_name, params)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,313 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json-schema"
|
4
|
+
|
5
|
+
module VectorMCP
|
6
|
+
class Server
|
7
|
+
# Handles registration of tools, resources, prompts, and roots
|
8
|
+
module Registry
|
9
|
+
# --- Registration Methods ---
|
10
|
+
|
11
|
+
# Registers a new tool with the server.
|
12
|
+
#
|
13
|
+
# @param name [String, Symbol] The unique name for the tool.
|
14
|
+
# @param description [String] A human-readable description of the tool.
|
15
|
+
# @param input_schema [Hash] A JSON Schema object that precisely describes the
|
16
|
+
# structure of the argument hash your tool expects.
|
17
|
+
# @yield [Hash] A block implementing the tool logic.
|
18
|
+
# @return [self] Returns the server instance so you can chain registrations.
|
19
|
+
# @raise [ArgumentError] If another tool with the same name is already registered.
|
20
|
+
def register_tool(name:, description:, input_schema:, &handler)
|
21
|
+
name_s = name.to_s
|
22
|
+
raise ArgumentError, "Tool '#{name_s}' already registered" if @tools[name_s]
|
23
|
+
|
24
|
+
# Validate schema format during registration
|
25
|
+
validate_schema_format!(input_schema) if input_schema
|
26
|
+
|
27
|
+
@tools[name_s] = VectorMCP::Definitions::Tool.new(name_s, description, input_schema, handler)
|
28
|
+
logger.debug("Registered tool: #{name_s}")
|
29
|
+
self
|
30
|
+
end
|
31
|
+
|
32
|
+
# Registers a new resource with the server.
|
33
|
+
#
|
34
|
+
# @param uri [String, URI] The unique URI for the resource.
|
35
|
+
# @param name [String] A human-readable name for the resource.
|
36
|
+
# @param description [String] A description of the resource.
|
37
|
+
# @param mime_type [String] The MIME type of the resource's content (default: "text/plain").
|
38
|
+
# @yield [Hash] A block that provides the resource's content.
|
39
|
+
# @return [self] The server instance, for chaining.
|
40
|
+
# @raise [ArgumentError] if a resource with the same URI is already registered.
|
41
|
+
def register_resource(uri:, name:, description:, mime_type: "text/plain", &handler)
|
42
|
+
uri_s = uri.to_s
|
43
|
+
raise ArgumentError, "Resource '#{uri_s}' already registered" if @resources[uri_s]
|
44
|
+
|
45
|
+
@resources[uri_s] = VectorMCP::Definitions::Resource.new(uri, name, description, mime_type, handler)
|
46
|
+
logger.debug("Registered resource: #{uri_s}")
|
47
|
+
self
|
48
|
+
end
|
49
|
+
|
50
|
+
# Registers a new prompt with the server.
|
51
|
+
#
|
52
|
+
# @param name [String, Symbol] The unique name for the prompt.
|
53
|
+
# @param description [String] A human-readable description of the prompt.
|
54
|
+
# @param arguments [Array<Hash>] An array defining the prompt's arguments.
|
55
|
+
# @yield [Hash] A block that generates the prompt.
|
56
|
+
# @return [self] The server instance, for chaining.
|
57
|
+
# @raise [ArgumentError] if a prompt with the same name is already registered.
|
58
|
+
def register_prompt(name:, description:, arguments: [], &handler)
|
59
|
+
name_s = name.to_s
|
60
|
+
raise ArgumentError, "Prompt '#{name_s}' already registered" if @prompts[name_s]
|
61
|
+
|
62
|
+
validate_prompt_arguments(arguments)
|
63
|
+
@prompts[name_s] = VectorMCP::Definitions::Prompt.new(name_s, description, arguments, handler)
|
64
|
+
@prompts_list_changed = true
|
65
|
+
notify_prompts_list_changed
|
66
|
+
logger.debug("Registered prompt: #{name_s}")
|
67
|
+
self
|
68
|
+
end
|
69
|
+
|
70
|
+
# Registers a new root with the server.
|
71
|
+
#
|
72
|
+
# @param uri [String, URI] The unique URI for the root (must be file:// scheme).
|
73
|
+
# @param name [String] A human-readable name for the root.
|
74
|
+
# @return [self] The server instance, for chaining.
|
75
|
+
# @raise [ArgumentError] if a root with the same URI is already registered.
|
76
|
+
def register_root(uri:, name:)
|
77
|
+
uri_s = uri.to_s
|
78
|
+
raise ArgumentError, "Root '#{uri_s}' already registered" if @roots[uri_s]
|
79
|
+
|
80
|
+
root = VectorMCP::Definitions::Root.new(uri, name)
|
81
|
+
root.validate! # This will raise ArgumentError if invalid
|
82
|
+
|
83
|
+
@roots[uri_s] = root
|
84
|
+
@roots_list_changed = true
|
85
|
+
notify_roots_list_changed
|
86
|
+
logger.debug("Registered root: #{uri_s} (#{name})")
|
87
|
+
self
|
88
|
+
end
|
89
|
+
|
90
|
+
# Helper method to register a root from a local directory path.
|
91
|
+
#
|
92
|
+
# @param path [String] Local filesystem path to the directory.
|
93
|
+
# @param name [String, nil] Human-readable name for the root.
|
94
|
+
# @return [self] The server instance, for chaining.
|
95
|
+
# @raise [ArgumentError] if the path is invalid or not accessible.
|
96
|
+
def register_root_from_path(path, name: nil)
|
97
|
+
root = VectorMCP::Definitions::Root.from_path(path, name: name)
|
98
|
+
register_root(uri: root.uri, name: root.name)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Helper method to register an image resource from a file path.
|
102
|
+
#
|
103
|
+
# @param uri [String] Unique URI for the resource.
|
104
|
+
# @param file_path [String] Path to the image file.
|
105
|
+
# @param name [String, nil] Human-readable name (auto-generated if nil).
|
106
|
+
# @param description [String, nil] Description (auto-generated if nil).
|
107
|
+
# @return [VectorMCP::Definitions::Resource] The registered resource.
|
108
|
+
# @raise [ArgumentError] If the file doesn't exist or isn't a valid image.
|
109
|
+
def register_image_resource(uri:, file_path:, name: nil, description: nil)
|
110
|
+
resource = VectorMCP::Definitions::Resource.from_image_file(
|
111
|
+
uri: uri,
|
112
|
+
file_path: file_path,
|
113
|
+
name: name,
|
114
|
+
description: description
|
115
|
+
)
|
116
|
+
|
117
|
+
register_resource(
|
118
|
+
uri: resource.uri,
|
119
|
+
name: resource.name,
|
120
|
+
description: resource.description,
|
121
|
+
mime_type: resource.mime_type,
|
122
|
+
&resource.handler
|
123
|
+
)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Helper method to register an image resource from binary data.
|
127
|
+
#
|
128
|
+
# @param uri [String] Unique URI for the resource.
|
129
|
+
# @param image_data [String] Binary image data.
|
130
|
+
# @param name [String] Human-readable name.
|
131
|
+
# @param description [String, nil] Description (auto-generated if nil).
|
132
|
+
# @param mime_type [String, nil] MIME type (auto-detected if nil).
|
133
|
+
# @return [VectorMCP::Definitions::Resource] The registered resource.
|
134
|
+
# @raise [ArgumentError] If the data isn't valid image data.
|
135
|
+
def register_image_resource_from_data(uri:, image_data:, name:, description: nil, mime_type: nil)
|
136
|
+
resource = VectorMCP::Definitions::Resource.from_image_data(
|
137
|
+
uri: uri,
|
138
|
+
image_data: image_data,
|
139
|
+
name: name,
|
140
|
+
description: description,
|
141
|
+
mime_type: mime_type
|
142
|
+
)
|
143
|
+
|
144
|
+
register_resource(
|
145
|
+
uri: resource.uri,
|
146
|
+
name: resource.name,
|
147
|
+
description: resource.description,
|
148
|
+
mime_type: resource.mime_type,
|
149
|
+
&resource.handler
|
150
|
+
)
|
151
|
+
end
|
152
|
+
|
153
|
+
# Helper method to register a tool that accepts image inputs.
|
154
|
+
#
|
155
|
+
# @param name [String] Unique name for the tool.
|
156
|
+
# @param description [String] Human-readable description.
|
157
|
+
# @param image_parameter [String] Name of the image parameter (default: "image").
|
158
|
+
# @param additional_parameters [Hash] Additional JSON Schema properties.
|
159
|
+
# @param required_parameters [Array<String>] List of required parameter names.
|
160
|
+
# @param block [Proc] The tool handler block.
|
161
|
+
# @return [VectorMCP::Definitions::Tool] The registered tool.
|
162
|
+
def register_image_tool(name:, description:, image_parameter: "image", additional_parameters: {}, required_parameters: [], &block)
|
163
|
+
# Build the input schema with image support
|
164
|
+
image_property = {
|
165
|
+
type: "string",
|
166
|
+
description: "Base64 encoded image data or file path to image",
|
167
|
+
contentEncoding: "base64",
|
168
|
+
contentMediaType: "image/*"
|
169
|
+
}
|
170
|
+
|
171
|
+
properties = { image_parameter => image_property }.merge(additional_parameters)
|
172
|
+
|
173
|
+
input_schema = {
|
174
|
+
type: "object",
|
175
|
+
properties: properties,
|
176
|
+
required: required_parameters
|
177
|
+
}
|
178
|
+
|
179
|
+
register_tool(
|
180
|
+
name: name,
|
181
|
+
description: description,
|
182
|
+
input_schema: input_schema,
|
183
|
+
&block
|
184
|
+
)
|
185
|
+
end
|
186
|
+
|
187
|
+
# Helper method to register a prompt that supports image arguments.
|
188
|
+
#
|
189
|
+
# @param name [String] Unique name for the prompt.
|
190
|
+
# @param description [String] Human-readable description.
|
191
|
+
# @param image_argument [String] Name of the image argument (default: "image").
|
192
|
+
# @param additional_arguments [Array<Hash>] Additional prompt arguments.
|
193
|
+
# @param block [Proc] The prompt handler block.
|
194
|
+
# @return [VectorMCP::Definitions::Prompt] The registered prompt.
|
195
|
+
def register_image_prompt(name:, description:, image_argument: "image", additional_arguments: [], &block)
|
196
|
+
prompt = VectorMCP::Definitions::Prompt.with_image_support(
|
197
|
+
name: name,
|
198
|
+
description: description,
|
199
|
+
image_argument_name: image_argument,
|
200
|
+
additional_arguments: additional_arguments,
|
201
|
+
&block
|
202
|
+
)
|
203
|
+
|
204
|
+
register_prompt(
|
205
|
+
name: prompt.name,
|
206
|
+
description: prompt.description,
|
207
|
+
arguments: prompt.arguments,
|
208
|
+
&prompt.handler
|
209
|
+
)
|
210
|
+
end
|
211
|
+
|
212
|
+
private
|
213
|
+
|
214
|
+
# Validates that the provided schema is a valid JSON Schema.
|
215
|
+
# @api private
|
216
|
+
# @param schema [Hash, nil] The JSON Schema to validate.
|
217
|
+
# @return [void]
|
218
|
+
# @raise [ArgumentError] if the schema is invalid.
|
219
|
+
def validate_schema_format!(schema)
|
220
|
+
return if schema.nil? || schema.empty?
|
221
|
+
return unless schema.is_a?(Hash)
|
222
|
+
|
223
|
+
# Use JSON::Validator to validate the schema format itself
|
224
|
+
validation_errors = JSON::Validator.fully_validate_schema(schema)
|
225
|
+
|
226
|
+
raise ArgumentError, "Invalid input_schema format: #{validation_errors.join("; ")}" unless validation_errors.empty?
|
227
|
+
rescue JSON::Schema::ValidationError => e
|
228
|
+
raise ArgumentError, "Invalid input_schema format: #{e.message}"
|
229
|
+
rescue JSON::Schema::SchemaError => e
|
230
|
+
raise ArgumentError, "Invalid input_schema structure: #{e.message}"
|
231
|
+
end
|
232
|
+
|
233
|
+
# Validates the structure of the `arguments` array provided to {#register_prompt}.
|
234
|
+
# @api private
|
235
|
+
def validate_prompt_arguments(argument_defs)
|
236
|
+
raise ArgumentError, "Prompt arguments definition must be an Array of Hashes." unless argument_defs.is_a?(Array)
|
237
|
+
|
238
|
+
argument_defs.each_with_index { |arg, idx| validate_single_prompt_argument(arg, idx) }
|
239
|
+
end
|
240
|
+
|
241
|
+
# Defines the keys allowed in a prompt argument definition hash.
|
242
|
+
ALLOWED_PROMPT_ARG_KEYS = %w[name description required type].freeze
|
243
|
+
private_constant :ALLOWED_PROMPT_ARG_KEYS
|
244
|
+
|
245
|
+
# Validates a single prompt argument definition hash.
|
246
|
+
# @api private
|
247
|
+
def validate_single_prompt_argument(arg, idx)
|
248
|
+
raise ArgumentError, "Prompt argument definition at index #{idx} must be a Hash. Found: #{arg.class}" unless arg.is_a?(Hash)
|
249
|
+
|
250
|
+
validate_prompt_arg_name!(arg, idx)
|
251
|
+
validate_prompt_arg_description!(arg, idx)
|
252
|
+
validate_prompt_arg_required_flag!(arg, idx)
|
253
|
+
validate_prompt_arg_type!(arg, idx)
|
254
|
+
validate_prompt_arg_unknown_keys!(arg, idx)
|
255
|
+
end
|
256
|
+
|
257
|
+
# Validates the :name key of a prompt argument definition.
|
258
|
+
# @api private
|
259
|
+
def validate_prompt_arg_name!(arg, idx)
|
260
|
+
name_val = arg[:name] || arg["name"]
|
261
|
+
raise ArgumentError, "Prompt argument at index #{idx} missing :name" if name_val.nil?
|
262
|
+
unless name_val.is_a?(String) || name_val.is_a?(Symbol)
|
263
|
+
raise ArgumentError, "Prompt argument :name at index #{idx} must be a String or Symbol. Found: #{name_val.class}"
|
264
|
+
end
|
265
|
+
raise ArgumentError, "Prompt argument :name at index #{idx} cannot be empty." if name_val.to_s.strip.empty?
|
266
|
+
end
|
267
|
+
|
268
|
+
# Validates the :description key of a prompt argument definition.
|
269
|
+
# @api private
|
270
|
+
def validate_prompt_arg_description!(arg, idx)
|
271
|
+
return unless arg.key?(:description) || arg.key?("description")
|
272
|
+
|
273
|
+
desc_val = arg[:description] || arg["description"]
|
274
|
+
return if desc_val.nil? || desc_val.is_a?(String)
|
275
|
+
|
276
|
+
raise ArgumentError, "Prompt argument :description at index #{idx} must be a String if provided. Found: #{desc_val.class}"
|
277
|
+
end
|
278
|
+
|
279
|
+
# Validates the :required key of a prompt argument definition.
|
280
|
+
# @api private
|
281
|
+
def validate_prompt_arg_required_flag!(arg, idx)
|
282
|
+
return unless arg.key?(:required) || arg.key?("required")
|
283
|
+
|
284
|
+
req_val = arg[:required] || arg["required"]
|
285
|
+
return if [true, false].include?(req_val)
|
286
|
+
|
287
|
+
raise ArgumentError, "Prompt argument :required at index #{idx} must be true or false if provided. Found: #{req_val.inspect}"
|
288
|
+
end
|
289
|
+
|
290
|
+
# Validates the :type key of a prompt argument definition.
|
291
|
+
# @api private
|
292
|
+
def validate_prompt_arg_type!(arg, idx)
|
293
|
+
return unless arg.key?(:type) || arg.key?("type")
|
294
|
+
|
295
|
+
type_val = arg[:type] || arg["type"]
|
296
|
+
return if type_val.nil? || type_val.is_a?(String)
|
297
|
+
|
298
|
+
raise ArgumentError, "Prompt argument :type at index #{idx} must be a String if provided (e.g., JSON schema type). Found: #{type_val.class}"
|
299
|
+
end
|
300
|
+
|
301
|
+
# Checks for any unknown keys in a prompt argument definition.
|
302
|
+
# @api private
|
303
|
+
def validate_prompt_arg_unknown_keys!(arg, idx)
|
304
|
+
unknown_keys = arg.transform_keys(&:to_s).keys - ALLOWED_PROMPT_ARG_KEYS
|
305
|
+
return if unknown_keys.empty?
|
306
|
+
|
307
|
+
raise ArgumentError,
|
308
|
+
"Prompt argument definition at index #{idx} contains unknown keys: #{unknown_keys.join(", ")}. " \
|
309
|
+
"Allowed: #{ALLOWED_PROMPT_ARG_KEYS.join(", ")}."
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|