vector_mcp 0.3.3 → 0.4.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 +80 -0
- data/README.md +132 -342
- data/lib/vector_mcp/handlers/core.rb +82 -27
- data/lib/vector_mcp/image_util.rb +53 -5
- data/lib/vector_mcp/log_filter.rb +48 -0
- data/lib/vector_mcp/middleware/base.rb +1 -5
- data/lib/vector_mcp/middleware/context.rb +11 -1
- data/lib/vector_mcp/rails/tool.rb +85 -0
- data/lib/vector_mcp/request_context.rb +1 -1
- data/lib/vector_mcp/security/middleware.rb +2 -2
- data/lib/vector_mcp/security/strategies/api_key.rb +27 -4
- data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
- data/lib/vector_mcp/server/capabilities.rb +4 -10
- data/lib/vector_mcp/server/message_handling.rb +2 -2
- data/lib/vector_mcp/server/registry.rb +36 -4
- data/lib/vector_mcp/server.rb +49 -41
- data/lib/vector_mcp/session.rb +5 -3
- data/lib/vector_mcp/tool.rb +221 -0
- data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
- data/lib/vector_mcp/transport/http_stream/event_store.rb +33 -13
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +39 -14
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +133 -47
- data/lib/vector_mcp/transport/http_stream.rb +294 -33
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +7 -8
- metadata +5 -10
- data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
- data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
- data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
- data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
- data/lib/vector_mcp/transport/sse.rb +0 -377
- data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
- data/lib/vector_mcp/transport/stdio.rb +0 -473
- data/lib/vector_mcp/transport/stdio_session_manager.rb +0 -181
|
@@ -52,18 +52,21 @@ module VectorMCP
|
|
|
52
52
|
# @raise [VectorMCP::ForbiddenError] if authorization fails.
|
|
53
53
|
def self.call_tool(params, session, server)
|
|
54
54
|
tool_name = params["name"]
|
|
55
|
-
arguments = params["arguments"] || {}
|
|
56
|
-
|
|
57
55
|
context = create_tool_context(tool_name, params, session, server)
|
|
58
|
-
context = server.middleware_manager.execute_hooks(:before_tool_call, context)
|
|
59
|
-
return handle_middleware_error(context) if context.error?
|
|
60
56
|
|
|
61
57
|
begin
|
|
58
|
+
session_context = authenticate_session!(session, server, operation_type: :tool_call, operation_name: tool_name)
|
|
59
|
+
|
|
60
|
+
context = server.middleware_manager.execute_hooks(:before_tool_call, context)
|
|
61
|
+
return handle_middleware_error(context) if context.error?
|
|
62
|
+
|
|
63
|
+
tool_name = context.params["name"] || tool_name
|
|
64
|
+
arguments = context.params["arguments"] || {}
|
|
62
65
|
tool = find_tool!(tool_name, server)
|
|
63
|
-
|
|
66
|
+
authorize_action!(session_context, :call, tool, server)
|
|
64
67
|
validate_input_arguments!(tool_name, tool, arguments)
|
|
65
68
|
|
|
66
|
-
result = execute_tool_handler(tool, arguments,
|
|
69
|
+
result = execute_tool_handler(tool, arguments, session)
|
|
67
70
|
context.result = build_tool_result(result)
|
|
68
71
|
|
|
69
72
|
context = server.middleware_manager.execute_hooks(:after_tool_call, context)
|
|
@@ -99,16 +102,19 @@ module VectorMCP
|
|
|
99
102
|
# @raise [VectorMCP::ForbiddenError] if authorization fails.
|
|
100
103
|
def self.read_resource(params, session, server)
|
|
101
104
|
uri_s = params["uri"]
|
|
102
|
-
|
|
103
105
|
context = create_resource_context(uri_s, params, session, server)
|
|
104
|
-
context = server.middleware_manager.execute_hooks(:before_resource_read, context)
|
|
105
|
-
return handle_middleware_error(context) if context.error?
|
|
106
106
|
|
|
107
107
|
begin
|
|
108
|
+
session_context = authenticate_session!(session, server, operation_type: :resource_read, operation_name: uri_s)
|
|
109
|
+
|
|
110
|
+
context = server.middleware_manager.execute_hooks(:before_resource_read, context)
|
|
111
|
+
return handle_middleware_error(context) if context.error?
|
|
112
|
+
|
|
113
|
+
uri_s = context.params["uri"] || uri_s
|
|
108
114
|
resource = find_resource!(uri_s, server)
|
|
109
|
-
|
|
115
|
+
authorize_action!(session_context, :read, resource, server)
|
|
110
116
|
|
|
111
|
-
content_raw = execute_resource_handler(resource, params,
|
|
117
|
+
content_raw = execute_resource_handler(resource, context.params, session_context)
|
|
112
118
|
contents = process_resource_content(content_raw, resource, uri_s)
|
|
113
119
|
|
|
114
120
|
context.result = { contents: contents }
|
|
@@ -195,9 +201,11 @@ module VectorMCP
|
|
|
195
201
|
return handle_middleware_error(context) if context.error?
|
|
196
202
|
|
|
197
203
|
begin
|
|
204
|
+
context_params = context.params
|
|
205
|
+
prompt_name = context_params["name"] || prompt_name
|
|
198
206
|
prompt = fetch_prompt(prompt_name, server)
|
|
199
207
|
|
|
200
|
-
arguments =
|
|
208
|
+
arguments = context_params["arguments"] || {}
|
|
201
209
|
validate_arguments!(prompt_name, prompt, arguments)
|
|
202
210
|
|
|
203
211
|
# Call the registered handler after arguments were validated
|
|
@@ -491,15 +499,34 @@ module VectorMCP
|
|
|
491
499
|
tool
|
|
492
500
|
end
|
|
493
501
|
|
|
494
|
-
#
|
|
495
|
-
def self.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
502
|
+
# Resolve the session authentication state before operation middleware runs.
|
|
503
|
+
def self.authenticate_session!(session, server, operation_type:, operation_name:)
|
|
504
|
+
request = extract_request_from_session(session)
|
|
505
|
+
context = create_auth_context(operation_type, operation_name, request, session, server)
|
|
506
|
+
context = server.middleware_manager.execute_hooks(:before_auth, context)
|
|
507
|
+
raise context.error if context.error?
|
|
508
|
+
|
|
509
|
+
request = context.params
|
|
510
|
+
session_context = if server.security_middleware.respond_to?(:authenticate_request)
|
|
511
|
+
server.security_middleware.authenticate_request(request)
|
|
512
|
+
else
|
|
513
|
+
legacy_result = server.security_middleware.process_request(request)
|
|
514
|
+
legacy_result[:session_context]
|
|
515
|
+
end
|
|
516
|
+
session.security_context = session_context if session.respond_to?(:security_context=)
|
|
517
|
+
|
|
518
|
+
auth_required = server.respond_to?(:auth_manager) ? server.auth_manager.required? : false
|
|
519
|
+
raise VectorMCP::UnauthorizedError, "Authentication required" if auth_required && !session_context.authenticated?
|
|
520
|
+
|
|
521
|
+
context.result = session_context
|
|
522
|
+
server.middleware_manager.execute_hooks(:after_auth, context)
|
|
523
|
+
session_context
|
|
524
|
+
rescue StandardError => e
|
|
525
|
+
handle_auth_error(e, context, server)
|
|
499
526
|
end
|
|
500
527
|
|
|
501
528
|
# Execute tool handler with proper arity handling
|
|
502
|
-
def self.execute_tool_handler(tool, arguments,
|
|
529
|
+
def self.execute_tool_handler(tool, arguments, session)
|
|
503
530
|
if [1, -1].include?(tool.handler.arity)
|
|
504
531
|
tool.handler.call(arguments)
|
|
505
532
|
else
|
|
@@ -546,18 +573,24 @@ module VectorMCP
|
|
|
546
573
|
end
|
|
547
574
|
|
|
548
575
|
# Validate resource security
|
|
549
|
-
def self.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
576
|
+
def self.authorize_action!(session_context, action, resource, server)
|
|
577
|
+
authorized = if server.security_middleware.respond_to?(:authorize_action)
|
|
578
|
+
server.security_middleware.authorize_action(session_context, action, resource)
|
|
579
|
+
else
|
|
580
|
+
legacy_result = server.security_middleware.process_request({}, action: action, resource: resource)
|
|
581
|
+
legacy_result[:success]
|
|
582
|
+
end
|
|
583
|
+
return if authorized
|
|
584
|
+
|
|
585
|
+
raise VectorMCP::ForbiddenError, "Access denied"
|
|
553
586
|
end
|
|
554
587
|
|
|
555
588
|
# Execute resource handler with proper arity handling
|
|
556
|
-
def self.execute_resource_handler(resource, params,
|
|
589
|
+
def self.execute_resource_handler(resource, params, session_context)
|
|
557
590
|
if [1, -1].include?(resource.handler.arity)
|
|
558
591
|
resource.handler.call(params)
|
|
559
592
|
else
|
|
560
|
-
resource.handler.call(params,
|
|
593
|
+
resource.handler.call(params, session_context)
|
|
561
594
|
end
|
|
562
595
|
end
|
|
563
596
|
|
|
@@ -579,10 +612,32 @@ module VectorMCP
|
|
|
579
612
|
context.result
|
|
580
613
|
end
|
|
581
614
|
|
|
582
|
-
|
|
615
|
+
# Create middleware context for authentication hooks.
|
|
616
|
+
def self.create_auth_context(operation_type, operation_name, request, session, server)
|
|
617
|
+
VectorMCP::Middleware::Context.new(
|
|
618
|
+
operation_type: operation_type,
|
|
619
|
+
operation_name: operation_name,
|
|
620
|
+
params: request,
|
|
621
|
+
session: session,
|
|
622
|
+
server: server,
|
|
623
|
+
metadata: { start_time: Time.now }
|
|
624
|
+
)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Run auth error hooks and re-raise the original error.
|
|
628
|
+
def self.handle_auth_error(error, context, server)
|
|
629
|
+
return raise error unless context
|
|
630
|
+
|
|
631
|
+
context.error = error
|
|
632
|
+
server.middleware_manager.execute_hooks(:on_auth_error, context)
|
|
633
|
+
raise error
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :authenticate_session!,
|
|
583
637
|
:execute_tool_handler, :build_tool_result, :handle_tool_error, :create_resource_context,
|
|
584
|
-
:find_resource!, :
|
|
585
|
-
:process_resource_content, :handle_resource_error
|
|
638
|
+
:find_resource!, :authorize_action!, :execute_resource_handler,
|
|
639
|
+
:process_resource_content, :handle_resource_error, :create_auth_context,
|
|
640
|
+
:handle_auth_error
|
|
586
641
|
end
|
|
587
642
|
end
|
|
588
643
|
end
|
|
@@ -217,20 +217,68 @@ module VectorMCP
|
|
|
217
217
|
# @param file_path [String] Path to the image file.
|
|
218
218
|
# @param validate [Boolean] Whether to validate the image.
|
|
219
219
|
# @param max_size [Integer] Maximum allowed size for validation.
|
|
220
|
+
# @param base_directory [String, nil] Optional base directory for path traversal protection.
|
|
221
|
+
# When provided, the resolved file_path must reside within this directory.
|
|
220
222
|
# @return [Hash] MCP image content hash.
|
|
221
|
-
# @raise [ArgumentError] If file doesn't exist or
|
|
223
|
+
# @raise [ArgumentError] If file doesn't exist, validation fails, or path traversal is detected.
|
|
222
224
|
#
|
|
223
225
|
# @example
|
|
224
226
|
# content = VectorMCP::ImageUtil.file_to_mcp_image_content("./avatar.png")
|
|
225
|
-
|
|
226
|
-
|
|
227
|
+
#
|
|
228
|
+
# @example With path traversal protection
|
|
229
|
+
# content = VectorMCP::ImageUtil.file_to_mcp_image_content(
|
|
230
|
+
# user_input_path,
|
|
231
|
+
# base_directory: "/app/uploads"
|
|
232
|
+
# )
|
|
233
|
+
def file_to_mcp_image_content(file_path, validate: true, max_size: DEFAULT_MAX_SIZE, base_directory: nil)
|
|
234
|
+
validated_path = validate_path_safety!(file_path, base_directory)
|
|
227
235
|
|
|
228
|
-
raise ArgumentError, "Image file not
|
|
236
|
+
raise ArgumentError, "Image file not found: #{validated_path}" unless File.exist?(validated_path)
|
|
229
237
|
|
|
230
|
-
|
|
238
|
+
raise ArgumentError, "Image file not readable: #{validated_path}" unless File.readable?(validated_path)
|
|
239
|
+
|
|
240
|
+
binary_data = File.binread(validated_path)
|
|
231
241
|
to_mcp_image_content(binary_data, validate: validate, max_size: max_size)
|
|
232
242
|
end
|
|
233
243
|
|
|
244
|
+
# Validates that a file path is safe to access.
|
|
245
|
+
#
|
|
246
|
+
# When +base_directory+ is provided, the resolved path must reside within it.
|
|
247
|
+
# When +base_directory+ is omitted, the path is still canonicalized and
|
|
248
|
+
# rejected if it contains path traversal sequences (+..+) that resolve
|
|
249
|
+
# outside the current working directory.
|
|
250
|
+
#
|
|
251
|
+
# @param file_path [String] The file path to validate.
|
|
252
|
+
# @param base_directory [String, nil] Optional base directory boundary.
|
|
253
|
+
# @return [String] The canonicalized, validated path.
|
|
254
|
+
# @raise [ArgumentError] If path traversal is detected.
|
|
255
|
+
# @api private
|
|
256
|
+
def validate_path_safety!(file_path, base_directory)
|
|
257
|
+
if base_directory
|
|
258
|
+
resolved_base = File.expand_path(base_directory)
|
|
259
|
+
resolved_path = File.expand_path(file_path, resolved_base)
|
|
260
|
+
|
|
261
|
+
unless resolved_path.start_with?("#{resolved_base}/") || resolved_path == resolved_base
|
|
262
|
+
raise ArgumentError, "Path traversal detected: resolved path is outside the allowed base directory"
|
|
263
|
+
end
|
|
264
|
+
else
|
|
265
|
+
resolved_path = File.expand_path(file_path)
|
|
266
|
+
|
|
267
|
+
# Reject paths that use traversal sequences to escape upward, even without
|
|
268
|
+
# an explicit base directory. This catches the common case of
|
|
269
|
+
# user-supplied input like "../../etc/passwd".
|
|
270
|
+
if file_path.to_s.include?("..")
|
|
271
|
+
canonical_base = File.expand_path(".")
|
|
272
|
+
unless resolved_path.start_with?("#{canonical_base}/") || resolved_path == canonical_base
|
|
273
|
+
raise ArgumentError,
|
|
274
|
+
"Path traversal detected: '#{file_path}' resolves outside the working directory"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
resolved_path
|
|
280
|
+
end
|
|
281
|
+
|
|
234
282
|
# Extracts image metadata from binary data.
|
|
235
283
|
#
|
|
236
284
|
# @param data [String] Binary image data.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VectorMCP
|
|
4
|
+
# Filters sensitive data from values before they are written to logs.
|
|
5
|
+
# Redacts known sensitive keys in hashes and token patterns in strings.
|
|
6
|
+
module LogFilter
|
|
7
|
+
SENSITIVE_KEYS = %w[
|
|
8
|
+
authorization x-api-key api_key apikey token jwt_token
|
|
9
|
+
password secret cookie set-cookie x-jwt-token
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
FILTERED = "[FILTERED]"
|
|
13
|
+
|
|
14
|
+
# Bearer/Basic token pattern: "Bearer <token>" or "Basic <token>"
|
|
15
|
+
TOKEN_PATTERN = /\b(Bearer|Basic|API-Key)\s+\S+/i
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Deep-redacts sensitive keys from a hash.
|
|
20
|
+
# @param hash [Hash] the hash to filter
|
|
21
|
+
# @return [Hash] a copy with sensitive values replaced by "[FILTERED]"
|
|
22
|
+
def filter_hash(hash)
|
|
23
|
+
return hash unless hash.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
hash.each_with_object({}) do |(key, value), filtered|
|
|
26
|
+
str_key = key.to_s.downcase
|
|
27
|
+
filtered[key] = if SENSITIVE_KEYS.include?(str_key)
|
|
28
|
+
FILTERED
|
|
29
|
+
elsif value.is_a?(Hash)
|
|
30
|
+
filter_hash(value)
|
|
31
|
+
elsif value.is_a?(String)
|
|
32
|
+
filter_string(value)
|
|
33
|
+
else
|
|
34
|
+
value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Redacts Bearer/Basic/API-Key token patterns in a string.
|
|
40
|
+
# @param str [String] the string to filter
|
|
41
|
+
# @return [String] the filtered string
|
|
42
|
+
def filter_string(str)
|
|
43
|
+
return str unless str.is_a?(String)
|
|
44
|
+
|
|
45
|
+
str.gsub(TOKEN_PATTERN, '\1 [FILTERED]')
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -147,11 +147,7 @@ module VectorMCP
|
|
|
147
147
|
# @param context [VectorMCP::Middleware::Context] Execution context
|
|
148
148
|
# @param new_params [Hash] New parameters to set
|
|
149
149
|
def modify_params(context, new_params)
|
|
150
|
-
|
|
151
|
-
context.params = new_params
|
|
152
|
-
else
|
|
153
|
-
@logger.warn("Cannot modify immutable params in context")
|
|
154
|
-
end
|
|
150
|
+
context.params = new_params
|
|
155
151
|
end
|
|
156
152
|
|
|
157
153
|
# Helper method to modify response result
|
|
@@ -18,7 +18,7 @@ module VectorMCP
|
|
|
18
18
|
def initialize(options = {})
|
|
19
19
|
@operation_type = options[:operation_type]
|
|
20
20
|
@operation_name = options[:operation_name]
|
|
21
|
-
|
|
21
|
+
self.params = options[:params]
|
|
22
22
|
@session = options[:session]
|
|
23
23
|
@server = options[:server]
|
|
24
24
|
@metadata = (options[:metadata] || {}).dup
|
|
@@ -27,6 +27,16 @@ module VectorMCP
|
|
|
27
27
|
@skip_remaining_hooks = false
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Replace request parameters for the current operation.
|
|
31
|
+
#
|
|
32
|
+
# @param value [Hash, nil] New request parameters.
|
|
33
|
+
# @return [Hash] The normalized parameter hash.
|
|
34
|
+
def params=(value)
|
|
35
|
+
raise ArgumentError, "params must be a Hash" unless value.nil? || value.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
@params = (value || {}).dup
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
# Check if operation completed successfully
|
|
31
41
|
# @return [Boolean] true if no error occurred
|
|
32
42
|
def success?
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
5
|
+
require "vector_mcp/tool"
|
|
6
|
+
|
|
7
|
+
module VectorMCP
|
|
8
|
+
module Rails
|
|
9
|
+
# Rails-aware base class for declarative tool definitions.
|
|
10
|
+
#
|
|
11
|
+
# Adds ergonomics for the common patterns that show up in ActiveRecord-
|
|
12
|
+
# backed MCP tools:
|
|
13
|
+
#
|
|
14
|
+
# * +find!+ -- fetch a record or raise +VectorMCP::NotFoundError+
|
|
15
|
+
# * +respond_with+ -- standard success/error payload from a record
|
|
16
|
+
# * +with_transaction+ -- wrap a mutation in an AR transaction
|
|
17
|
+
# * Auto-rescue of +ActiveRecord::RecordNotFound+ (-> NotFoundError)
|
|
18
|
+
# and +ActiveRecord::RecordInvalid+ (-> error payload)
|
|
19
|
+
# * Arguments delivered to +#call+ as a +HashWithIndifferentAccess+
|
|
20
|
+
# so +args[:id]+ and +args["id"]+ both work
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# class UpdateProvider < VectorMCP::Rails::Tool
|
|
24
|
+
# tool_name "update_provider"
|
|
25
|
+
# description "Update an existing provider"
|
|
26
|
+
#
|
|
27
|
+
# param :id, type: :integer, required: true
|
|
28
|
+
# param :name, type: :string
|
|
29
|
+
#
|
|
30
|
+
# def call(args, _session)
|
|
31
|
+
# provider = find!(Provider, args[:id])
|
|
32
|
+
# provider.update(args.except(:id))
|
|
33
|
+
# respond_with(provider, name: provider.name)
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
class Tool < VectorMCP::Tool
|
|
37
|
+
# Overrides the parent handler to add indifferent-access args and
|
|
38
|
+
# auto-rescue ActiveRecord exceptions.
|
|
39
|
+
def self.build_handler
|
|
40
|
+
klass = self
|
|
41
|
+
params = @params
|
|
42
|
+
lambda do |args, session|
|
|
43
|
+
coerced = klass.coerce_args(args, params).with_indifferent_access
|
|
44
|
+
klass.new.call(coerced, session)
|
|
45
|
+
rescue ActiveRecord::RecordNotFound => e
|
|
46
|
+
raise VectorMCP::NotFoundError, e.message
|
|
47
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
48
|
+
{ success: false, errors: e.record.errors.full_messages }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :build_handler
|
|
52
|
+
|
|
53
|
+
# Finds a record by id or raises VectorMCP::NotFoundError.
|
|
54
|
+
#
|
|
55
|
+
# @param model [Class] an ActiveRecord model class
|
|
56
|
+
# @param id [Integer, String] the record id
|
|
57
|
+
# @return [ActiveRecord::Base]
|
|
58
|
+
def find!(model, id)
|
|
59
|
+
model.find_by(id: id) ||
|
|
60
|
+
raise(VectorMCP::NotFoundError, "#{model.name} #{id} not found")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Builds a standard response payload from a record.
|
|
64
|
+
#
|
|
65
|
+
# Success shape: +{ success: true, id: record.id, **extras }+
|
|
66
|
+
# Error shape: +{ success: false, errors: record.errors.full_messages }+
|
|
67
|
+
#
|
|
68
|
+
# @param record [ActiveRecord::Base]
|
|
69
|
+
# @param extras [Hash] additional keys to merge into the success payload
|
|
70
|
+
# @return [Hash]
|
|
71
|
+
def respond_with(record, **extras)
|
|
72
|
+
if record.persisted? && record.errors.empty?
|
|
73
|
+
{ success: true, id: record.id, **extras }
|
|
74
|
+
else
|
|
75
|
+
{ success: false, errors: record.errors.full_messages }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Runs the given block inside an ActiveRecord transaction.
|
|
80
|
+
def with_transaction(&)
|
|
81
|
+
ActiveRecord::Base.transaction(&)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -92,7 +92,7 @@ module VectorMCP
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
# Create a minimal request context for non-HTTP transports.
|
|
95
|
-
# This is useful for
|
|
95
|
+
# This is useful for non-HTTP transports or testing contexts.
|
|
96
96
|
#
|
|
97
97
|
# @param transport_type [String] The transport type identifier
|
|
98
98
|
# @return [RequestContext] A minimal request context
|
|
@@ -120,11 +120,11 @@ module VectorMCP
|
|
|
120
120
|
# @param transport_request [Object] the transport request
|
|
121
121
|
# @return [Hash] extracted request data
|
|
122
122
|
def extract_request_data(transport_request)
|
|
123
|
-
# Handle Rack environment (for
|
|
123
|
+
# Handle Rack environment (for HTTP transports)
|
|
124
124
|
if transport_request.respond_to?(:[]) && transport_request["REQUEST_METHOD"]
|
|
125
125
|
extract_from_rack_env(transport_request)
|
|
126
126
|
else
|
|
127
|
-
# Default fallback
|
|
127
|
+
# Default fallback for non-HTTP request formats
|
|
128
128
|
{ headers: {}, params: {} }
|
|
129
129
|
end
|
|
130
130
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
3
5
|
module VectorMCP
|
|
4
6
|
module Security
|
|
5
7
|
module Strategies
|
|
@@ -10,8 +12,10 @@ module VectorMCP
|
|
|
10
12
|
|
|
11
13
|
# Initialize with a list of valid API keys
|
|
12
14
|
# @param keys [Array<String>] array of valid API keys
|
|
13
|
-
|
|
15
|
+
# @param allow_query_params [Boolean] whether to accept API keys from query parameters (default: false)
|
|
16
|
+
def initialize(keys: [], allow_query_params: false)
|
|
14
17
|
@valid_keys = Set.new(keys.map(&:to_s))
|
|
18
|
+
@allow_query_params = allow_query_params
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# Add a valid API key
|
|
@@ -33,7 +37,7 @@ module VectorMCP
|
|
|
33
37
|
api_key = extract_api_key(request)
|
|
34
38
|
return false unless api_key&.length&.positive?
|
|
35
39
|
|
|
36
|
-
if
|
|
40
|
+
if secure_key_match?(api_key)
|
|
37
41
|
{
|
|
38
42
|
api_key: api_key,
|
|
39
43
|
strategy: "api_key",
|
|
@@ -58,14 +62,33 @@ module VectorMCP
|
|
|
58
62
|
|
|
59
63
|
private
|
|
60
64
|
|
|
65
|
+
# Constant-time comparison of API key against all valid keys.
|
|
66
|
+
# Iterates all keys to prevent timing side-channels.
|
|
67
|
+
# @param candidate [String] the API key to check
|
|
68
|
+
# @return [Boolean] true if the candidate matches a valid key
|
|
69
|
+
def secure_key_match?(candidate)
|
|
70
|
+
matched = false
|
|
71
|
+
@valid_keys.each do |valid_key|
|
|
72
|
+
next unless candidate.bytesize == valid_key.bytesize
|
|
73
|
+
|
|
74
|
+
matched = true if OpenSSL.fixed_length_secure_compare(candidate, valid_key)
|
|
75
|
+
end
|
|
76
|
+
matched
|
|
77
|
+
end
|
|
78
|
+
|
|
61
79
|
# Extract API key from various request formats
|
|
62
80
|
# @param request [Hash] the request object
|
|
63
81
|
# @return [String, nil] the extracted API key
|
|
64
82
|
def extract_api_key(request)
|
|
65
83
|
headers = normalize_headers(request)
|
|
66
|
-
params = normalize_params(request)
|
|
67
84
|
|
|
68
|
-
extract_from_headers(headers)
|
|
85
|
+
from_headers = extract_from_headers(headers)
|
|
86
|
+
return from_headers if from_headers
|
|
87
|
+
|
|
88
|
+
return nil unless @allow_query_params
|
|
89
|
+
|
|
90
|
+
params = normalize_params(request)
|
|
91
|
+
extract_from_params(params)
|
|
69
92
|
end
|
|
70
93
|
|
|
71
94
|
# Normalize headers to handle different formats
|
|
@@ -17,12 +17,14 @@ module VectorMCP
|
|
|
17
17
|
# Initialize JWT strategy
|
|
18
18
|
# @param secret [String] the secret key for JWT verification
|
|
19
19
|
# @param algorithm [String] the JWT algorithm (default: HS256)
|
|
20
|
+
# @param allow_query_params [Boolean] whether to accept JWT tokens from query parameters (default: false)
|
|
20
21
|
# @param options [Hash] additional JWT verification options
|
|
21
|
-
def initialize(secret:, algorithm: "HS256", **options)
|
|
22
|
+
def initialize(secret:, algorithm: "HS256", allow_query_params: false, **options)
|
|
22
23
|
raise LoadError, "JWT gem is required for JWT authentication strategy" unless defined?(JWT)
|
|
23
24
|
|
|
24
25
|
@secret = secret
|
|
25
26
|
@algorithm = algorithm
|
|
27
|
+
@allow_query_params = allow_query_params
|
|
26
28
|
@options = {
|
|
27
29
|
algorithm: @algorithm,
|
|
28
30
|
verify_expiration: true,
|
|
@@ -82,11 +84,14 @@ module VectorMCP
|
|
|
82
84
|
# @return [String, nil] the extracted token
|
|
83
85
|
def extract_token(request)
|
|
84
86
|
headers = request[:headers] || request["headers"] || {}
|
|
85
|
-
params = request[:params] || request["params"] || {}
|
|
86
87
|
|
|
87
|
-
extract_from_auth_header(headers) ||
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
from_headers = extract_from_auth_header(headers) || extract_from_jwt_header(headers)
|
|
89
|
+
return from_headers if from_headers
|
|
90
|
+
|
|
91
|
+
return nil unless @allow_query_params
|
|
92
|
+
|
|
93
|
+
params = request[:params] || request["params"] || {}
|
|
94
|
+
extract_from_params(params)
|
|
90
95
|
end
|
|
91
96
|
|
|
92
97
|
# Extract token from Authorization header
|
|
@@ -43,11 +43,8 @@ module VectorMCP
|
|
|
43
43
|
|
|
44
44
|
notification_method = "notifications/prompts/list_changed"
|
|
45
45
|
begin
|
|
46
|
-
if transport.respond_to?(:
|
|
47
|
-
logger.debug("
|
|
48
|
-
transport.broadcast_notification(notification_method)
|
|
49
|
-
elsif transport.respond_to?(:send_notification)
|
|
50
|
-
logger.debug("Sending prompts list changed notification (transport may broadcast or send to first client).")
|
|
46
|
+
if transport.respond_to?(:send_notification)
|
|
47
|
+
logger.debug("Sending prompts list changed notification.")
|
|
51
48
|
transport.send_notification(notification_method)
|
|
52
49
|
else
|
|
53
50
|
logger.warn("Transport does not support sending notifications/prompts/list_changed.")
|
|
@@ -70,11 +67,8 @@ module VectorMCP
|
|
|
70
67
|
|
|
71
68
|
notification_method = "notifications/roots/list_changed"
|
|
72
69
|
begin
|
|
73
|
-
if transport.respond_to?(:
|
|
74
|
-
logger.debug("
|
|
75
|
-
transport.broadcast_notification(notification_method)
|
|
76
|
-
elsif transport.respond_to?(:send_notification)
|
|
77
|
-
logger.debug("Sending roots list changed notification (transport may broadcast or send to first client).")
|
|
70
|
+
if transport.respond_to?(:send_notification)
|
|
71
|
+
logger.debug("Sending roots list changed notification.")
|
|
78
72
|
transport.send_notification(notification_method)
|
|
79
73
|
else
|
|
80
74
|
logger.warn("Transport does not support sending notifications/roots/list_changed.")
|
|
@@ -21,10 +21,10 @@ module VectorMCP
|
|
|
21
21
|
params = message["params"] || {}
|
|
22
22
|
|
|
23
23
|
if id && method # Request
|
|
24
|
-
logger.debug("[#{session_id}] Request [#{id}]: #{method} with params: #{params.inspect}")
|
|
24
|
+
logger.debug("[#{session_id}] Request [#{id}]: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
|
|
25
25
|
handle_request(id, method, params, session)
|
|
26
26
|
elsif method # Notification
|
|
27
|
-
logger.debug("[#{session_id}] Notification: #{method} with params: #{params.inspect}")
|
|
27
|
+
logger.debug("[#{session_id}] Notification: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
|
|
28
28
|
handle_notification(method, params, session)
|
|
29
29
|
nil # Notifications do not have a return value to send back to client
|
|
30
30
|
elsif id # Invalid: Has ID but no method
|
|
@@ -29,6 +29,38 @@ module VectorMCP
|
|
|
29
29
|
self
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Registers one or more class-based tool definitions with the server.
|
|
33
|
+
#
|
|
34
|
+
# Each argument must be a subclass of +VectorMCP::Tool+ that declares
|
|
35
|
+
# its metadata via the class-level DSL (+tool_name+, +description+,
|
|
36
|
+
# +param+) and implements +#call+.
|
|
37
|
+
#
|
|
38
|
+
# @param tool_classes [Array<Class>] One or more +VectorMCP::Tool+ subclasses.
|
|
39
|
+
# @return [self] The server instance, for chaining.
|
|
40
|
+
# @raise [ArgumentError] If any argument is not a +VectorMCP::Tool+ subclass.
|
|
41
|
+
#
|
|
42
|
+
# @example Register a single tool
|
|
43
|
+
# server.register(ListProviders)
|
|
44
|
+
#
|
|
45
|
+
# @example Register multiple tools
|
|
46
|
+
# server.register(ListProviders, CreateProvider, UpdateProvider)
|
|
47
|
+
def register(*tool_classes)
|
|
48
|
+
tool_classes.each do |tool_class|
|
|
49
|
+
unless tool_class.is_a?(Class) && tool_class < VectorMCP::Tool
|
|
50
|
+
raise ArgumentError, "#{tool_class.inspect} is not a VectorMCP::Tool subclass"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
definition = tool_class.to_definition
|
|
54
|
+
register_tool(
|
|
55
|
+
name: definition.name,
|
|
56
|
+
description: definition.description,
|
|
57
|
+
input_schema: definition.input_schema,
|
|
58
|
+
&definition.handler
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
32
64
|
# Registers a new resource with the server.
|
|
33
65
|
#
|
|
34
66
|
# @param uri [String, URI] The unique URI for the resource.
|
|
@@ -159,7 +191,7 @@ module VectorMCP
|
|
|
159
191
|
# @param required_parameters [Array<String>] List of required parameter names.
|
|
160
192
|
# @param block [Proc] The tool handler block.
|
|
161
193
|
# @return [VectorMCP::Definitions::Tool] The registered tool.
|
|
162
|
-
def register_image_tool(name:, description:, image_parameter: "image", additional_parameters: {}, required_parameters: [], &
|
|
194
|
+
def register_image_tool(name:, description:, image_parameter: "image", additional_parameters: {}, required_parameters: [], &)
|
|
163
195
|
# Build the input schema with image support
|
|
164
196
|
image_property = {
|
|
165
197
|
type: "string",
|
|
@@ -180,7 +212,7 @@ module VectorMCP
|
|
|
180
212
|
name: name,
|
|
181
213
|
description: description,
|
|
182
214
|
input_schema: input_schema,
|
|
183
|
-
&
|
|
215
|
+
&
|
|
184
216
|
)
|
|
185
217
|
end
|
|
186
218
|
|
|
@@ -192,13 +224,13 @@ module VectorMCP
|
|
|
192
224
|
# @param additional_arguments [Array<Hash>] Additional prompt arguments.
|
|
193
225
|
# @param block [Proc] The prompt handler block.
|
|
194
226
|
# @return [VectorMCP::Definitions::Prompt] The registered prompt.
|
|
195
|
-
def register_image_prompt(name:, description:, image_argument: "image", additional_arguments: [], &
|
|
227
|
+
def register_image_prompt(name:, description:, image_argument: "image", additional_arguments: [], &)
|
|
196
228
|
prompt = VectorMCP::Definitions::Prompt.with_image_support(
|
|
197
229
|
name: name,
|
|
198
230
|
description: description,
|
|
199
231
|
image_argument_name: image_argument,
|
|
200
232
|
additional_arguments: additional_arguments,
|
|
201
|
-
&
|
|
233
|
+
&
|
|
202
234
|
)
|
|
203
235
|
|
|
204
236
|
register_prompt(
|