vector_mcp 0.4.0 → 0.5.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/CHANGELOG.md +24 -1
- data/README.md +20 -0
- data/lib/vector_mcp/definitions.rb +30 -0
- data/lib/vector_mcp/handlers/core.rb +18 -76
- data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
- data/lib/vector_mcp/middleware/hook.rb +7 -24
- data/lib/vector_mcp/middleware.rb +26 -9
- data/lib/vector_mcp/security/auth_manager.rb +12 -13
- data/lib/vector_mcp/security/auth_result.rb +33 -0
- data/lib/vector_mcp/security/authorization.rb +5 -9
- data/lib/vector_mcp/security/session_context.rb +11 -27
- data/lib/vector_mcp/security/strategies/api_key.rb +1 -5
- data/lib/vector_mcp/security/strategies/custom.rb +10 -37
- data/lib/vector_mcp/security/strategies/jwt_token.rb +1 -10
- data/lib/vector_mcp/server/capabilities.rb +22 -26
- data/lib/vector_mcp/server/message_handling.rb +21 -14
- data/lib/vector_mcp/server/registry.rb +70 -120
- data/lib/vector_mcp/server.rb +53 -19
- data/lib/vector_mcp/token_store.rb +80 -0
- data/lib/vector_mcp/transport/http_stream/event_store.rb +14 -16
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +8 -26
- data/lib/vector_mcp/transport/http_stream.rb +81 -42
- data/lib/vector_mcp/util/token_sweeper.rb +74 -0
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +2 -0
- metadata +5 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VectorMCP
|
|
4
|
+
module Security
|
|
5
|
+
# Value object representing the outcome of an authentication attempt.
|
|
6
|
+
# Replaces the unstructured Hash that previously flowed through the auth pipeline.
|
|
7
|
+
class AuthResult
|
|
8
|
+
attr_reader :user, :strategy, :authenticated_at
|
|
9
|
+
|
|
10
|
+
def initialize(authenticated:, user: nil, strategy: nil, authenticated_at: nil)
|
|
11
|
+
@authenticated = authenticated
|
|
12
|
+
@user = user
|
|
13
|
+
@strategy = strategy
|
|
14
|
+
@authenticated_at = authenticated_at || (Time.now if authenticated)
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def authenticated? = @authenticated
|
|
19
|
+
|
|
20
|
+
def self.success(user:, strategy:, authenticated_at: Time.now)
|
|
21
|
+
new(authenticated: true, user: user, strategy: strategy, authenticated_at: authenticated_at)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.failure
|
|
25
|
+
new(authenticated: false)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.passthrough
|
|
29
|
+
new(authenticated: true)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -10,6 +10,7 @@ module VectorMCP
|
|
|
10
10
|
def initialize
|
|
11
11
|
@policies = {}
|
|
12
12
|
@enabled = false
|
|
13
|
+
@logger = VectorMCP.logger_for("authorization")
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
# Enable authorization system
|
|
@@ -45,17 +46,12 @@ module VectorMCP
|
|
|
45
46
|
|
|
46
47
|
resource_type = determine_resource_type(resource)
|
|
47
48
|
policy = @policies[resource_type]
|
|
48
|
-
|
|
49
|
-
# If no policy is defined, allow access (opt-in authorization)
|
|
50
49
|
return true unless policy
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# Log error but deny access for safety
|
|
57
|
-
false
|
|
58
|
-
end
|
|
51
|
+
!!policy.call(user, action, resource)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
@logger.error("Authorization policy error for #{resource_type}: #{e.message}")
|
|
54
|
+
false
|
|
59
55
|
end
|
|
60
56
|
|
|
61
57
|
# Check if authorization is required
|
|
@@ -113,34 +113,18 @@ module VectorMCP
|
|
|
113
113
|
new(authenticated: false)
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
# Create an authenticated session context from
|
|
117
|
-
# @param auth_result [
|
|
118
|
-
# @return [SessionContext] an authenticated session
|
|
116
|
+
# Create an authenticated session context from an AuthResult
|
|
117
|
+
# @param auth_result [VectorMCP::Security::AuthResult] the authentication outcome
|
|
118
|
+
# @return [SessionContext] an authenticated or anonymous session
|
|
119
119
|
def self.from_auth_result(auth_result)
|
|
120
|
-
return anonymous unless auth_result&.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
authenticated: true,
|
|
129
|
-
auth_strategy: "custom",
|
|
130
|
-
authenticated_at: Time.now
|
|
131
|
-
)
|
|
132
|
-
else
|
|
133
|
-
# Extract strategy and authenticated_at only if user_data is a Hash
|
|
134
|
-
strategy = user_data.is_a?(Hash) ? user_data[:strategy] : nil
|
|
135
|
-
auth_time = user_data.is_a?(Hash) ? user_data[:authenticated_at] : nil
|
|
136
|
-
|
|
137
|
-
new(
|
|
138
|
-
user: user_data,
|
|
139
|
-
authenticated: true,
|
|
140
|
-
auth_strategy: strategy,
|
|
141
|
-
authenticated_at: auth_time
|
|
142
|
-
)
|
|
143
|
-
end
|
|
120
|
+
return anonymous unless auth_result&.authenticated?
|
|
121
|
+
|
|
122
|
+
new(
|
|
123
|
+
user: auth_result.user,
|
|
124
|
+
authenticated: true,
|
|
125
|
+
auth_strategy: auth_result.strategy,
|
|
126
|
+
authenticated_at: auth_result.authenticated_at
|
|
127
|
+
)
|
|
144
128
|
end
|
|
145
129
|
end
|
|
146
130
|
end
|
|
@@ -16,20 +16,20 @@ module VectorMCP
|
|
|
16
16
|
@handler = handler
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
# Authenticate a request using the custom handler
|
|
19
|
+
# Authenticate a request using the custom handler.
|
|
20
|
+
# If the handler returns a Hash with a :user key, the value is extracted
|
|
21
|
+
# so that AuthManager receives the user data directly.
|
|
20
22
|
# @param request [Hash] the request object
|
|
21
|
-
# @return [Object, false]
|
|
23
|
+
# @return [Object, nil, false] user data or false if authentication failed.
|
|
24
|
+
# A return of nil (from { user: nil }) signals "authenticated, no user object."
|
|
22
25
|
def authenticate(request)
|
|
23
26
|
result = @handler.call(request)
|
|
27
|
+
return false unless result && result != false
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
false
|
|
30
|
-
end
|
|
31
|
-
rescue NoMemoryError, StandardError
|
|
32
|
-
# Log error but return false for security
|
|
29
|
+
return result unless result.is_a?(Hash) && result.key?(:user)
|
|
30
|
+
|
|
31
|
+
result[:user]
|
|
32
|
+
rescue StandardError, NoMemoryError
|
|
33
33
|
false
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -38,33 +38,6 @@ module VectorMCP
|
|
|
38
38
|
def configured?
|
|
39
39
|
!@handler.nil?
|
|
40
40
|
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
# Format successful authentication result with strategy metadata
|
|
45
|
-
# @param result [Object] the result from the custom handler
|
|
46
|
-
# @return [Object] formatted result with strategy metadata
|
|
47
|
-
def format_successful_result(result)
|
|
48
|
-
case result
|
|
49
|
-
when Hash
|
|
50
|
-
# If result has a :user key, extract it and use as main user data
|
|
51
|
-
if result.key?(:user)
|
|
52
|
-
user_data = result[:user]
|
|
53
|
-
# For nil user, return a marker that will become nil in session context
|
|
54
|
-
return :authenticated_nil_user if user_data.nil?
|
|
55
|
-
|
|
56
|
-
user_data
|
|
57
|
-
else
|
|
58
|
-
result.merge(strategy: "custom", authenticated_at: Time.now)
|
|
59
|
-
end
|
|
60
|
-
else
|
|
61
|
-
{
|
|
62
|
-
user: result,
|
|
63
|
-
strategy: "custom",
|
|
64
|
-
authenticated_at: Time.now
|
|
65
|
-
}
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
41
|
end
|
|
69
42
|
end
|
|
70
43
|
end
|
|
@@ -43,16 +43,7 @@ module VectorMCP
|
|
|
43
43
|
|
|
44
44
|
begin
|
|
45
45
|
decoded = JWT.decode(token, @secret, true, @options)
|
|
46
|
-
|
|
47
|
-
headers = decoded[1] # Second element is the headers
|
|
48
|
-
|
|
49
|
-
# Return user info from JWT payload
|
|
50
|
-
{
|
|
51
|
-
**payload,
|
|
52
|
-
strategy: "jwt",
|
|
53
|
-
authenticated_at: Time.now,
|
|
54
|
-
jwt_headers: headers
|
|
55
|
-
}
|
|
46
|
+
decoded[0]
|
|
56
47
|
rescue JWT::ExpiredSignature, JWT::InvalidIssuerError, JWT::InvalidAudienceError,
|
|
57
48
|
JWT::VerificationError, JWT::DecodeError, StandardError
|
|
58
49
|
false # Token validation failed
|
|
@@ -39,19 +39,7 @@ module VectorMCP
|
|
|
39
39
|
# Notifies connected clients that the list of available prompts has changed.
|
|
40
40
|
# @return [void]
|
|
41
41
|
def notify_prompts_list_changed
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
notification_method = "notifications/prompts/list_changed"
|
|
45
|
-
begin
|
|
46
|
-
if transport.respond_to?(:send_notification)
|
|
47
|
-
logger.debug("Sending prompts list changed notification.")
|
|
48
|
-
transport.send_notification(notification_method)
|
|
49
|
-
else
|
|
50
|
-
logger.warn("Transport does not support sending notifications/prompts/list_changed.")
|
|
51
|
-
end
|
|
52
|
-
rescue StandardError => e
|
|
53
|
-
logger.error("Failed to send prompts list changed notification: #{e.class.name}: #{e.message}")
|
|
54
|
-
end
|
|
42
|
+
send_list_changed_notification("prompts") if @prompts_list_changed
|
|
55
43
|
end
|
|
56
44
|
|
|
57
45
|
# Resets the `roots_list_changed` flag to false.
|
|
@@ -63,19 +51,7 @@ module VectorMCP
|
|
|
63
51
|
# Notifies connected clients that the list of available roots has changed.
|
|
64
52
|
# @return [void]
|
|
65
53
|
def notify_roots_list_changed
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
notification_method = "notifications/roots/list_changed"
|
|
69
|
-
begin
|
|
70
|
-
if transport.respond_to?(:send_notification)
|
|
71
|
-
logger.debug("Sending roots list changed notification.")
|
|
72
|
-
transport.send_notification(notification_method)
|
|
73
|
-
else
|
|
74
|
-
logger.warn("Transport does not support sending notifications/roots/list_changed.")
|
|
75
|
-
end
|
|
76
|
-
rescue StandardError => e
|
|
77
|
-
logger.error("Failed to send roots list changed notification: #{e.class.name}: #{e.message}")
|
|
78
|
-
end
|
|
54
|
+
send_list_changed_notification("roots") if @roots_list_changed
|
|
79
55
|
end
|
|
80
56
|
|
|
81
57
|
# Registers a session as a subscriber to prompt list changes.
|
|
@@ -87,6 +63,26 @@ module VectorMCP
|
|
|
87
63
|
|
|
88
64
|
private
|
|
89
65
|
|
|
66
|
+
# Sends a `notifications/<kind>/list_changed` notification to the transport.
|
|
67
|
+
# No-op if no transport is attached. Logs a warning if the transport does not
|
|
68
|
+
# implement `send_notification` (intentional extension point for alternate
|
|
69
|
+
# transports).
|
|
70
|
+
# @api private
|
|
71
|
+
# @param kind [String] One of "prompts" or "roots".
|
|
72
|
+
def send_list_changed_notification(kind)
|
|
73
|
+
return unless transport
|
|
74
|
+
|
|
75
|
+
notification_method = "notifications/#{kind}/list_changed"
|
|
76
|
+
if transport.respond_to?(:send_notification)
|
|
77
|
+
logger.debug("Sending #{kind} list changed notification.")
|
|
78
|
+
transport.send_notification(notification_method)
|
|
79
|
+
else
|
|
80
|
+
logger.warn("Transport does not support sending #{notification_method}.")
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
logger.error("Failed to send #{kind} list changed notification: #{e.class.name}: #{e.message}")
|
|
84
|
+
end
|
|
85
|
+
|
|
90
86
|
# Configures sampling capabilities based on provided configuration.
|
|
91
87
|
# @api private
|
|
92
88
|
def configure_sampling_capabilities(config)
|
|
@@ -20,17 +20,18 @@ module VectorMCP
|
|
|
20
20
|
method = message["method"]
|
|
21
21
|
params = message["params"] || {}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
case classify_message(id, method)
|
|
24
|
+
when :request
|
|
24
25
|
logger.debug("[#{session_id}] Request [#{id}]: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
|
|
25
26
|
handle_request(id, method, params, session)
|
|
26
|
-
|
|
27
|
+
when :notification
|
|
27
28
|
logger.debug("[#{session_id}] Notification: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
|
|
28
29
|
handle_notification(method, params, session)
|
|
29
|
-
nil
|
|
30
|
-
|
|
30
|
+
nil
|
|
31
|
+
when :invalid_missing_method
|
|
31
32
|
logger.warn("[#{session_id}] Invalid message: Has ID [#{id}] but no method. #{message.inspect}")
|
|
32
33
|
raise VectorMCP::InvalidRequestError.new("Request object must include a 'method' member.", request_id: id)
|
|
33
|
-
|
|
34
|
+
when :invalid_missing_both
|
|
34
35
|
logger.warn("[#{session_id}] Invalid message: Missing both 'id' and 'method'. #{message.inspect}")
|
|
35
36
|
raise VectorMCP::InvalidRequestError.new("Invalid message format", request_id: nil)
|
|
36
37
|
end
|
|
@@ -60,6 +61,16 @@ module VectorMCP
|
|
|
60
61
|
|
|
61
62
|
private
|
|
62
63
|
|
|
64
|
+
# Classify a JSON-RPC message based on presence of id and method fields.
|
|
65
|
+
# @api private
|
|
66
|
+
def classify_message(id, method)
|
|
67
|
+
return :request if id && method
|
|
68
|
+
return :notification if method
|
|
69
|
+
return :invalid_missing_method if id
|
|
70
|
+
|
|
71
|
+
:invalid_missing_both
|
|
72
|
+
end
|
|
73
|
+
|
|
63
74
|
# Internal handler for JSON-RPC requests.
|
|
64
75
|
# @api private
|
|
65
76
|
def handle_request(id, method, params, session)
|
|
@@ -72,11 +83,11 @@ module VectorMCP
|
|
|
72
83
|
end
|
|
73
84
|
|
|
74
85
|
# Validates that the session is properly initialized for the given request.
|
|
86
|
+
# Transports are contractually required to pass a VectorMCP::Session here —
|
|
87
|
+
# never the SessionManager wrapper struct.
|
|
75
88
|
# @api private
|
|
76
89
|
def validate_session_initialization(id, method, _params, session)
|
|
77
|
-
|
|
78
|
-
actual_session = session.respond_to?(:context) ? session.context : session
|
|
79
|
-
return if actual_session.initialized?
|
|
90
|
+
return if session.initialized?
|
|
80
91
|
|
|
81
92
|
# Allow "initialize" even if not marked initialized yet by server
|
|
82
93
|
return if method == "initialize"
|
|
@@ -115,9 +126,7 @@ module VectorMCP
|
|
|
115
126
|
# Internal handler for JSON-RPC notifications.
|
|
116
127
|
# @api private
|
|
117
128
|
def handle_notification(method, params, session)
|
|
118
|
-
|
|
119
|
-
actual_session = session.respond_to?(:context) ? session.context : session
|
|
120
|
-
unless actual_session.initialized? || method == "initialized"
|
|
129
|
+
unless session.initialized? || method == "initialized"
|
|
121
130
|
logger.warn("Ignoring notification '#{method}' before session is initialized. Params: #{params.inspect}")
|
|
122
131
|
return
|
|
123
132
|
end
|
|
@@ -162,9 +171,7 @@ module VectorMCP
|
|
|
162
171
|
# @api private
|
|
163
172
|
def session_method(method_name)
|
|
164
173
|
lambda do |params, session, _server|
|
|
165
|
-
|
|
166
|
-
actual_session = session.respond_to?(:context) ? session.context : session
|
|
167
|
-
actual_session.public_send(method_name, params)
|
|
174
|
+
session.public_send(method_name, params)
|
|
168
175
|
end
|
|
169
176
|
end
|
|
170
177
|
end
|
|
@@ -131,114 +131,74 @@ module VectorMCP
|
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
# Helper method to register an image resource from a file path.
|
|
134
|
+
# Thin wrapper: delegates schema-building to Definitions::Resource.from_image_file,
|
|
135
|
+
# then stores the result via register_resource.
|
|
134
136
|
#
|
|
135
137
|
# @param uri [String] Unique URI for the resource.
|
|
136
138
|
# @param file_path [String] Path to the image file.
|
|
137
139
|
# @param name [String, nil] Human-readable name (auto-generated if nil).
|
|
138
140
|
# @param description [String, nil] Description (auto-generated if nil).
|
|
139
|
-
# @return [
|
|
141
|
+
# @return [self]
|
|
140
142
|
# @raise [ArgumentError] If the file doesn't exist or isn't a valid image.
|
|
141
143
|
def register_image_resource(uri:, file_path:, name: nil, description: nil)
|
|
142
144
|
resource = VectorMCP::Definitions::Resource.from_image_file(
|
|
143
|
-
uri: uri,
|
|
144
|
-
file_path: file_path,
|
|
145
|
-
name: name,
|
|
146
|
-
description: description
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
register_resource(
|
|
150
|
-
uri: resource.uri,
|
|
151
|
-
name: resource.name,
|
|
152
|
-
description: resource.description,
|
|
153
|
-
mime_type: resource.mime_type,
|
|
154
|
-
&resource.handler
|
|
145
|
+
uri: uri, file_path: file_path, name: name, description: description
|
|
155
146
|
)
|
|
147
|
+
register_resource(uri: resource.uri, name: resource.name,
|
|
148
|
+
description: resource.description, mime_type: resource.mime_type, &resource.handler)
|
|
156
149
|
end
|
|
157
150
|
|
|
158
151
|
# Helper method to register an image resource from binary data.
|
|
152
|
+
# Thin wrapper: delegates to Definitions::Resource.from_image_data.
|
|
159
153
|
#
|
|
160
154
|
# @param uri [String] Unique URI for the resource.
|
|
161
155
|
# @param image_data [String] Binary image data.
|
|
162
156
|
# @param name [String] Human-readable name.
|
|
163
157
|
# @param description [String, nil] Description (auto-generated if nil).
|
|
164
158
|
# @param mime_type [String, nil] MIME type (auto-detected if nil).
|
|
165
|
-
# @return [
|
|
166
|
-
# @raise [ArgumentError] If the data isn't valid image data.
|
|
159
|
+
# @return [self]
|
|
167
160
|
def register_image_resource_from_data(uri:, image_data:, name:, description: nil, mime_type: nil)
|
|
168
161
|
resource = VectorMCP::Definitions::Resource.from_image_data(
|
|
169
|
-
uri: uri,
|
|
170
|
-
image_data: image_data,
|
|
171
|
-
name: name,
|
|
172
|
-
description: description,
|
|
173
|
-
mime_type: mime_type
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
register_resource(
|
|
177
|
-
uri: resource.uri,
|
|
178
|
-
name: resource.name,
|
|
179
|
-
description: resource.description,
|
|
180
|
-
mime_type: resource.mime_type,
|
|
181
|
-
&resource.handler
|
|
162
|
+
uri: uri, image_data: image_data, name: name, description: description, mime_type: mime_type
|
|
182
163
|
)
|
|
164
|
+
register_resource(uri: resource.uri, name: resource.name,
|
|
165
|
+
description: resource.description, mime_type: resource.mime_type, &resource.handler)
|
|
183
166
|
end
|
|
184
167
|
|
|
185
168
|
# Helper method to register a tool that accepts image inputs.
|
|
169
|
+
# Thin wrapper: delegates schema-building to Definitions::Tool.with_image_support.
|
|
186
170
|
#
|
|
187
171
|
# @param name [String] Unique name for the tool.
|
|
188
172
|
# @param description [String] Human-readable description.
|
|
189
173
|
# @param image_parameter [String] Name of the image parameter (default: "image").
|
|
190
174
|
# @param additional_parameters [Hash] Additional JSON Schema properties.
|
|
191
175
|
# @param required_parameters [Array<String>] List of required parameter names.
|
|
192
|
-
# @
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
description: "Base64 encoded image data or file path to image",
|
|
199
|
-
contentEncoding: "base64",
|
|
200
|
-
contentMediaType: "image/*"
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
properties = { image_parameter => image_property }.merge(additional_parameters)
|
|
204
|
-
|
|
205
|
-
input_schema = {
|
|
206
|
-
type: "object",
|
|
207
|
-
properties: properties,
|
|
208
|
-
required: required_parameters
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
register_tool(
|
|
212
|
-
name: name,
|
|
213
|
-
description: description,
|
|
214
|
-
input_schema: input_schema,
|
|
215
|
-
&
|
|
176
|
+
# @return [self]
|
|
177
|
+
def register_image_tool(name:, description:, image_parameter: "image",
|
|
178
|
+
additional_parameters: {}, required_parameters: [], &handler)
|
|
179
|
+
tool = VectorMCP::Definitions::Tool.with_image_support(
|
|
180
|
+
name: name, description: description, image_parameter: image_parameter,
|
|
181
|
+
additional_parameters: additional_parameters, required_parameters: required_parameters, &handler
|
|
216
182
|
)
|
|
183
|
+
register_tool(name: tool.name, description: tool.description,
|
|
184
|
+
input_schema: tool.input_schema, &tool.handler)
|
|
217
185
|
end
|
|
218
186
|
|
|
219
187
|
# Helper method to register a prompt that supports image arguments.
|
|
188
|
+
# Thin wrapper: delegates to Definitions::Prompt.with_image_support.
|
|
220
189
|
#
|
|
221
190
|
# @param name [String] Unique name for the prompt.
|
|
222
191
|
# @param description [String] Human-readable description.
|
|
223
192
|
# @param image_argument [String] Name of the image argument (default: "image").
|
|
224
193
|
# @param additional_arguments [Array<Hash>] Additional prompt arguments.
|
|
225
|
-
# @
|
|
226
|
-
|
|
227
|
-
def register_image_prompt(name:, description:, image_argument: "image", additional_arguments: [], &)
|
|
194
|
+
# @return [self]
|
|
195
|
+
def register_image_prompt(name:, description:, image_argument: "image", additional_arguments: [], &handler)
|
|
228
196
|
prompt = VectorMCP::Definitions::Prompt.with_image_support(
|
|
229
|
-
name: name,
|
|
230
|
-
|
|
231
|
-
image_argument_name: image_argument,
|
|
232
|
-
additional_arguments: additional_arguments,
|
|
233
|
-
&
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
register_prompt(
|
|
237
|
-
name: prompt.name,
|
|
238
|
-
description: prompt.description,
|
|
239
|
-
arguments: prompt.arguments,
|
|
240
|
-
&prompt.handler
|
|
197
|
+
name: name, description: description, image_argument_name: image_argument,
|
|
198
|
+
additional_arguments: additional_arguments, &handler
|
|
241
199
|
)
|
|
200
|
+
register_prompt(name: prompt.name, description: prompt.description,
|
|
201
|
+
arguments: prompt.arguments, &prompt.handler)
|
|
242
202
|
end
|
|
243
203
|
|
|
244
204
|
private
|
|
@@ -262,6 +222,34 @@ module VectorMCP
|
|
|
262
222
|
raise ArgumentError, "Invalid input_schema structure: #{e.message}"
|
|
263
223
|
end
|
|
264
224
|
|
|
225
|
+
# Schema for a single prompt argument definition. Each entry names the
|
|
226
|
+
# required/optional key, whether it is required, and the rule that validates
|
|
227
|
+
# its value. Rules return nil on success or an error message fragment.
|
|
228
|
+
PROMPT_ARG_SCHEMA = {
|
|
229
|
+
"name" => {
|
|
230
|
+
required: true,
|
|
231
|
+
missing_message: "missing :name",
|
|
232
|
+
rule: lambda { |v|
|
|
233
|
+
next "must be a String or Symbol. Found: #{v.class}" unless v.is_a?(String) || v.is_a?(Symbol)
|
|
234
|
+
|
|
235
|
+
"cannot be empty." if v.to_s.strip.empty?
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
"description" => {
|
|
239
|
+
required: false,
|
|
240
|
+
rule: ->(v) { "must be a String if provided. Found: #{v.class}" unless v.nil? || v.is_a?(String) }
|
|
241
|
+
},
|
|
242
|
+
"required" => {
|
|
243
|
+
required: false,
|
|
244
|
+
rule: ->(v) { "must be true or false if provided. Found: #{v.inspect}" unless [true, false].include?(v) }
|
|
245
|
+
},
|
|
246
|
+
"type" => {
|
|
247
|
+
required: false,
|
|
248
|
+
rule: ->(v) { "must be a String if provided (e.g., JSON schema type). Found: #{v.class}" unless v.nil? || v.is_a?(String) }
|
|
249
|
+
}
|
|
250
|
+
}.freeze
|
|
251
|
+
private_constant :PROMPT_ARG_SCHEMA
|
|
252
|
+
|
|
265
253
|
# Validates the structure of the `arguments` array provided to {#register_prompt}.
|
|
266
254
|
# @api private
|
|
267
255
|
def validate_prompt_arguments(argument_defs)
|
|
@@ -270,75 +258,37 @@ module VectorMCP
|
|
|
270
258
|
argument_defs.each_with_index { |arg, idx| validate_single_prompt_argument(arg, idx) }
|
|
271
259
|
end
|
|
272
260
|
|
|
273
|
-
#
|
|
274
|
-
ALLOWED_PROMPT_ARG_KEYS = %w[name description required type].freeze
|
|
275
|
-
private_constant :ALLOWED_PROMPT_ARG_KEYS
|
|
276
|
-
|
|
277
|
-
# Validates a single prompt argument definition hash.
|
|
261
|
+
# Validates a single prompt argument definition hash against PROMPT_ARG_SCHEMA.
|
|
278
262
|
# @api private
|
|
279
263
|
def validate_single_prompt_argument(arg, idx)
|
|
280
264
|
raise ArgumentError, "Prompt argument definition at index #{idx} must be a Hash. Found: #{arg.class}" unless arg.is_a?(Hash)
|
|
281
265
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
validate_prompt_arg_required_flag!(arg, idx)
|
|
285
|
-
validate_prompt_arg_type!(arg, idx)
|
|
286
|
-
validate_prompt_arg_unknown_keys!(arg, idx)
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
# Validates the :name key of a prompt argument definition.
|
|
290
|
-
# @api private
|
|
291
|
-
def validate_prompt_arg_name!(arg, idx)
|
|
292
|
-
name_val = arg[:name] || arg["name"]
|
|
293
|
-
raise ArgumentError, "Prompt argument at index #{idx} missing :name" if name_val.nil?
|
|
294
|
-
unless name_val.is_a?(String) || name_val.is_a?(Symbol)
|
|
295
|
-
raise ArgumentError, "Prompt argument :name at index #{idx} must be a String or Symbol. Found: #{name_val.class}"
|
|
296
|
-
end
|
|
297
|
-
raise ArgumentError, "Prompt argument :name at index #{idx} cannot be empty." if name_val.to_s.strip.empty?
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
# Validates the :description key of a prompt argument definition.
|
|
301
|
-
# @api private
|
|
302
|
-
def validate_prompt_arg_description!(arg, idx)
|
|
303
|
-
return unless arg.key?(:description) || arg.key?("description")
|
|
304
|
-
|
|
305
|
-
desc_val = arg[:description] || arg["description"]
|
|
306
|
-
return if desc_val.nil? || desc_val.is_a?(String)
|
|
307
|
-
|
|
308
|
-
raise ArgumentError, "Prompt argument :description at index #{idx} must be a String if provided. Found: #{desc_val.class}"
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
# Validates the :required key of a prompt argument definition.
|
|
312
|
-
# @api private
|
|
313
|
-
def validate_prompt_arg_required_flag!(arg, idx)
|
|
314
|
-
return unless arg.key?(:required) || arg.key?("required")
|
|
315
|
-
|
|
316
|
-
req_val = arg[:required] || arg["required"]
|
|
317
|
-
return if [true, false].include?(req_val)
|
|
318
|
-
|
|
319
|
-
raise ArgumentError, "Prompt argument :required at index #{idx} must be true or false if provided. Found: #{req_val.inspect}"
|
|
266
|
+
PROMPT_ARG_SCHEMA.each { |key, spec| validate_prompt_arg_field(arg, idx, key, spec) }
|
|
267
|
+
validate_prompt_arg_unknown_keys(arg, idx)
|
|
320
268
|
end
|
|
321
269
|
|
|
322
|
-
# Validates
|
|
270
|
+
# Validates a single field of a prompt argument hash against its schema spec.
|
|
323
271
|
# @api private
|
|
324
|
-
def
|
|
325
|
-
|
|
272
|
+
def validate_prompt_arg_field(arg, idx, key, spec)
|
|
273
|
+
present = arg.key?(key.to_sym) || arg.key?(key)
|
|
274
|
+
value = arg[key.to_sym] || arg[key]
|
|
326
275
|
|
|
327
|
-
|
|
328
|
-
return
|
|
276
|
+
raise ArgumentError, "Prompt argument at index #{idx} #{spec[:missing_message]}" if spec[:required] && value.nil?
|
|
277
|
+
return unless present
|
|
329
278
|
|
|
330
|
-
|
|
279
|
+
error_fragment = spec[:rule].call(value)
|
|
280
|
+
raise ArgumentError, "Prompt argument :#{key} at index #{idx} #{error_fragment}" if error_fragment
|
|
331
281
|
end
|
|
332
282
|
|
|
333
|
-
# Checks
|
|
283
|
+
# Checks a prompt argument hash for keys outside PROMPT_ARG_SCHEMA.
|
|
334
284
|
# @api private
|
|
335
|
-
def validate_prompt_arg_unknown_keys
|
|
336
|
-
unknown_keys = arg.transform_keys(&:to_s).keys -
|
|
285
|
+
def validate_prompt_arg_unknown_keys(arg, idx)
|
|
286
|
+
unknown_keys = arg.transform_keys(&:to_s).keys - PROMPT_ARG_SCHEMA.keys
|
|
337
287
|
return if unknown_keys.empty?
|
|
338
288
|
|
|
339
289
|
raise ArgumentError,
|
|
340
290
|
"Prompt argument definition at index #{idx} contains unknown keys: #{unknown_keys.join(", ")}. " \
|
|
341
|
-
"Allowed: #{
|
|
291
|
+
"Allowed: #{PROMPT_ARG_SCHEMA.keys.join(", ")}."
|
|
342
292
|
end
|
|
343
293
|
end
|
|
344
294
|
end
|