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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +132 -342
  4. data/lib/vector_mcp/handlers/core.rb +82 -27
  5. data/lib/vector_mcp/image_util.rb +53 -5
  6. data/lib/vector_mcp/log_filter.rb +48 -0
  7. data/lib/vector_mcp/middleware/base.rb +1 -5
  8. data/lib/vector_mcp/middleware/context.rb +11 -1
  9. data/lib/vector_mcp/rails/tool.rb +85 -0
  10. data/lib/vector_mcp/request_context.rb +1 -1
  11. data/lib/vector_mcp/security/middleware.rb +2 -2
  12. data/lib/vector_mcp/security/strategies/api_key.rb +27 -4
  13. data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
  14. data/lib/vector_mcp/server/capabilities.rb +4 -10
  15. data/lib/vector_mcp/server/message_handling.rb +2 -2
  16. data/lib/vector_mcp/server/registry.rb +36 -4
  17. data/lib/vector_mcp/server.rb +49 -41
  18. data/lib/vector_mcp/session.rb +5 -3
  19. data/lib/vector_mcp/tool.rb +221 -0
  20. data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
  21. data/lib/vector_mcp/transport/http_stream/event_store.rb +33 -13
  22. data/lib/vector_mcp/transport/http_stream/session_manager.rb +39 -14
  23. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +133 -47
  24. data/lib/vector_mcp/transport/http_stream.rb +294 -33
  25. data/lib/vector_mcp/version.rb +1 -1
  26. data/lib/vector_mcp.rb +7 -8
  27. metadata +5 -10
  28. data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
  29. data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
  30. data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
  31. data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
  32. data/lib/vector_mcp/transport/sse.rb +0 -377
  33. data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
  34. data/lib/vector_mcp/transport/stdio.rb +0 -473
  35. 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
- security_result = validate_tool_security!(session, tool, server)
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, security_result, session)
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
- security_result = validate_resource_security!(session, resource, server)
115
+ authorize_action!(session_context, :read, resource, server)
110
116
 
111
- content_raw = execute_resource_handler(resource, params, security_result)
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 = params["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
- # Validate tool security
495
- def self.validate_tool_security!(session, tool, server)
496
- security_result = check_tool_security(session, tool, server)
497
- handle_security_failure(security_result) unless security_result[:success]
498
- security_result
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, _security_result, session)
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.validate_resource_security!(session, resource, server)
550
- security_result = check_resource_security(session, resource, server)
551
- handle_security_failure(security_result) unless security_result[:success]
552
- security_result
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, security_result)
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, security_result[:session_context])
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
- private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :validate_tool_security!,
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!, :validate_resource_security!, :execute_resource_handler,
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 validation fails.
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
- def file_to_mcp_image_content(file_path, validate: true, max_size: DEFAULT_MAX_SIZE)
226
- raise ArgumentError, "Image file not found: #{file_path}" unless File.exist?(file_path)
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 readable: #{file_path}" unless File.readable?(file_path)
236
+ raise ArgumentError, "Image file not found: #{validated_path}" unless File.exist?(validated_path)
229
237
 
230
- binary_data = File.binread(file_path)
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
- if context.respond_to?(:params=)
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
- @params = (options[:params] || {}).dup.freeze # Immutable copy
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 stdio and other command-line transports.
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 SSE transport)
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
- def initialize(keys: [])
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 @valid_keys.include?(api_key)
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) || extract_from_params(params)
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
- extract_from_jwt_header(headers) ||
89
- extract_from_params(params)
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?(:broadcast_notification)
47
- logger.debug("Broadcasting prompts list changed notification.")
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?(:broadcast_notification)
74
- logger.debug("Broadcasting roots list changed notification.")
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: [], &block)
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
- &block
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: [], &block)
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
- &block
233
+ &
202
234
  )
203
235
 
204
236
  register_prompt(