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.
@@ -72,7 +72,7 @@ module VectorMCP
72
72
  SUPPORTED_PROTOCOL_VERSIONS = %w[2025-11-25 2025-03-26 2024-11-05].freeze
73
73
 
74
74
  attr_reader :logger, :name, :version, :protocol_version, :tools, :resources, :prompts, :roots, :in_flight_requests,
75
- :auth_manager, :authorization, :security_middleware, :middleware_manager
75
+ :auth_manager, :authorization, :security_middleware, :middleware_manager, :oauth_resource_metadata_url
76
76
  attr_accessor :transport
77
77
 
78
78
  # Initializes a new VectorMCP server.
@@ -122,6 +122,7 @@ module VectorMCP
122
122
  @auth_manager = Security::AuthManager.new
123
123
  @authorization = Security::Authorization.new
124
124
  @security_middleware = Security::Middleware.new(@auth_manager, @authorization)
125
+ @oauth_resource_metadata_url = nil
125
126
 
126
127
  # Initialize middleware manager
127
128
  @middleware_manager = Middleware::Manager.new
@@ -188,28 +189,18 @@ module VectorMCP
188
189
  # Enable authentication with specified strategy and configuration
189
190
  # @param strategy [Symbol] the authentication strategy (:api_key, :jwt, :custom)
190
191
  # @param options [Hash] strategy-specific configuration options
192
+ # @option options [String] :resource_metadata_url OAuth 2.1 protected resource metadata URL
193
+ # (RFC 9728). When provided, unauthenticated requests to the HTTP Stream transport's MCP
194
+ # endpoint return HTTP 401 with a +WWW-Authenticate: Bearer+ header pointing at this URL,
195
+ # enabling MCP clients (e.g. Claude Desktop) to discover the authorization server and
196
+ # initiate an OAuth 2.1 flow. When omitted (default), auth failures continue to surface
197
+ # as JSON-RPC +-32401+ errors — existing behavior is preserved for non-OAuth deployments.
191
198
  # @return [void]
192
199
  def enable_authentication!(strategy: :api_key, **options, &block)
193
- # Clear existing strategies when switching to a new configuration
194
200
  clear_auth_strategies unless @auth_manager.strategies.empty?
195
-
201
+ extract_oauth_metadata!(options)
196
202
  @auth_manager.enable!(default_strategy: strategy)
197
-
198
- case strategy
199
- when :api_key
200
- add_api_key_auth(options[:keys] || [], allow_query_params: options[:allow_query_params] || false)
201
- when :jwt
202
- add_jwt_auth(options)
203
- when :custom
204
- handler = block || options[:handler]
205
- raise ArgumentError, "Custom authentication strategy requires a handler block" unless handler
206
-
207
- add_custom_auth(&handler)
208
-
209
- else
210
- raise ArgumentError, "Unknown authentication strategy: #{strategy}"
211
- end
212
-
203
+ register_auth_strategy(strategy, options, block || options.delete(:handler))
213
204
  @logger.info("Authentication enabled with strategy: #{strategy}")
214
205
  end
215
206
 
@@ -217,6 +208,7 @@ module VectorMCP
217
208
  # @return [void]
218
209
  def disable_authentication!
219
210
  @auth_manager.disable!
211
+ @oauth_resource_metadata_url = nil
220
212
  @logger.info("Authentication disabled")
221
213
  end
222
214
 
@@ -318,6 +310,34 @@ module VectorMCP
318
310
 
319
311
  private
320
312
 
313
+ # Extract OAuth resource metadata URL from options before they reach strategy constructors
314
+ # @param options [Hash] the options hash (mutated — :resource_metadata_url is removed)
315
+ # @return [void]
316
+ def extract_oauth_metadata!(options)
317
+ @oauth_resource_metadata_url = options.delete(:resource_metadata_url)
318
+ warn_on_insecure_oauth_metadata_url(@oauth_resource_metadata_url)
319
+ end
320
+
321
+ # Register the appropriate auth strategy based on the strategy name
322
+ # @param strategy [Symbol] the strategy type
323
+ # @param options [Hash] strategy-specific options
324
+ # @param handler [Proc, nil] custom handler block (for :custom strategy)
325
+ # @return [void]
326
+ def register_auth_strategy(strategy, options, handler)
327
+ case strategy
328
+ when :api_key
329
+ add_api_key_auth(options[:keys] || [], allow_query_params: options[:allow_query_params] || false)
330
+ when :jwt
331
+ add_jwt_auth(options)
332
+ when :custom
333
+ raise ArgumentError, "Custom authentication strategy requires a handler block" unless handler
334
+
335
+ add_custom_auth(&handler)
336
+ else
337
+ raise ArgumentError, "Unknown authentication strategy: #{strategy}"
338
+ end
339
+ end
340
+
321
341
  # Add API key authentication strategy
322
342
  # @param keys [Array<String>] array of valid API keys
323
343
  # @param allow_query_params [Boolean] whether to accept API keys from query parameters
@@ -350,6 +370,20 @@ module VectorMCP
350
370
  @auth_manager.remove_strategy(strategy_name)
351
371
  end
352
372
  end
373
+
374
+ # Emit a warning when the OAuth resource metadata URL is not HTTPS.
375
+ # We don't raise because local development against http://localhost is a valid use case.
376
+ # @param url [String, nil] the configured metadata URL
377
+ # @return [void]
378
+ def warn_on_insecure_oauth_metadata_url(url)
379
+ return if url.nil?
380
+ return if url.start_with?("https://")
381
+
382
+ @logger.warn do
383
+ "[SECURITY] resource_metadata_url is not HTTPS (#{url}). " \
384
+ "Use HTTPS in production; plaintext is only acceptable for local development."
385
+ end
386
+ end
353
387
  end
354
388
 
355
389
  module Transport
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+ require "securerandom"
5
+
6
+ module VectorMCP
7
+ # Thread-safe bidirectional store mapping arbitrary string values to opaque
8
+ # tokens and back. The store has no knowledge of domain semantics: callers
9
+ # supply the prefix, and the store guarantees that the same (value, prefix)
10
+ # pair always yields the same token within its lifetime.
11
+ class TokenStore
12
+ # Regexp describing the token format emitted by {#tokenize}.
13
+ TOKEN_PATTERN = /\A[A-Z]+_[0-9A-F]{8}\z/
14
+
15
+ def initialize
16
+ @forward = Concurrent::Hash.new
17
+ @reverse = Concurrent::Hash.new
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ # Return an opaque token for +value+. Calling this repeatedly with the
22
+ # same +value+ and +prefix+ returns the same token.
23
+ #
24
+ # @param value [String] the value to tokenize.
25
+ # @param prefix [String] the token prefix (uppercase recommended).
26
+ # @return [String] a token of the form +"PREFIX_XXXXXXXX"+.
27
+ def tokenize(value, prefix:)
28
+ key = [prefix, value]
29
+ existing = @forward[key]
30
+ return existing if existing
31
+
32
+ @mutex.synchronize do
33
+ existing = @forward[key]
34
+ return existing if existing
35
+
36
+ token = generate_token(prefix)
37
+ # Populate the reverse map first so any thread that observes the
38
+ # token in @forward can always resolve it.
39
+ @reverse[token] = value
40
+ @forward[key] = token
41
+ token
42
+ end
43
+ end
44
+
45
+ # Resolve a token back to its original value.
46
+ #
47
+ # @param token [String] a token previously returned by {#tokenize}.
48
+ # @return [String, nil] the original value, or +nil+ if unknown.
49
+ def resolve(token)
50
+ @reverse[token]
51
+ end
52
+
53
+ # Predicate: does +string+ look like a token issued by this class?
54
+ # This check is purely structural and does not consult the store.
55
+ #
56
+ # @param string [Object] the value to test.
57
+ # @return [Boolean]
58
+ def token?(string)
59
+ string.is_a?(String) && TOKEN_PATTERN.match?(string)
60
+ end
61
+
62
+ # Remove all mappings. Intended for test teardown.
63
+ # @return [void]
64
+ def clear
65
+ @mutex.synchronize do
66
+ @forward.clear
67
+ @reverse.clear
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def generate_token(prefix)
74
+ loop do
75
+ candidate = "#{prefix}_#{SecureRandom.hex(4).upcase}"
76
+ return candidate unless @reverse.key?(candidate)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -36,7 +36,8 @@ module VectorMCP
36
36
  def initialize(max_events)
37
37
  @max_events = max_events
38
38
  @events = Concurrent::Array.new
39
- @event_index = Concurrent::Hash.new # event_id -> index for fast lookup
39
+ @event_index = Concurrent::Hash.new # event_id -> logical position
40
+ @offset = 0 # number of events shifted off the front
40
41
  @current_sequence = Concurrent::AtomicFixnum.new(0)
41
42
  end
42
43
 
@@ -53,19 +54,15 @@ module VectorMCP
53
54
 
54
55
  event = Event.new(event_id, data, type, timestamp, session_id, stream_id)
55
56
 
56
- # Add to events array
57
+ # Add to events array and record logical position
57
58
  @events.push(event)
58
-
59
- # Update index
60
- @event_index[event_id] = @events.length - 1
59
+ @event_index[event_id] = @offset + @events.length - 1
61
60
 
62
61
  # Maintain circular buffer
63
62
  if @events.length > @max_events
64
63
  removed_event = @events.shift
65
64
  @event_index.delete(removed_event.id)
66
-
67
- # Update all indices after removal
68
- @event_index.transform_values! { |index| index - 1 }
65
+ @offset += 1
69
66
  end
70
67
 
71
68
  event_id
@@ -81,13 +78,13 @@ module VectorMCP
81
78
  events = if last_event_id.nil?
82
79
  @events.to_a
83
80
  else
84
- last_index = @event_index[last_event_id]
85
- return [] if last_index.nil?
81
+ logical = @event_index[last_event_id]
82
+ return [] if logical.nil?
86
83
 
87
- start_index = last_index + 1
88
- return [] if start_index >= @events.length
84
+ physical = logical - @offset + 1
85
+ return [] if physical >= @events.length
89
86
 
90
- @events[start_index..]
87
+ @events[physical..]
91
88
  end
92
89
 
93
90
  events = events.select { |e| e.session_id == session_id } if session_id
@@ -100,10 +97,10 @@ module VectorMCP
100
97
  # @param event_id [String] The event ID to look up
101
98
  # @return [Event, nil] The stored event, or nil if it is no longer retained
102
99
  def get_event(event_id)
103
- index = @event_index[event_id]
104
- return nil if index.nil?
100
+ logical = @event_index[event_id]
101
+ return nil if logical.nil?
105
102
 
106
- @events[index]
103
+ @events[logical - @offset]
107
104
  end
108
105
 
109
106
  # Gets the total number of stored events.
@@ -141,6 +138,7 @@ module VectorMCP
141
138
  def clear
142
139
  @events.clear
143
140
  @event_index.clear
141
+ @offset = 0
144
142
  end
145
143
 
146
144
  # Gets statistics about the event store.
@@ -70,24 +70,19 @@ module VectorMCP
70
70
  # @param transport [HttpStream] The parent transport instance
71
71
  # @param session_timeout [Integer] Session timeout in seconds
72
72
 
73
- # Optimized session creation with reduced object allocation and faster context creation
73
+ # Creates a new session. RequestContext.from_rack_env handles nil
74
+ # rack_env by falling back to a minimal context, so we don't need to
75
+ # branch here.
74
76
  def create_session(session_id = nil, rack_env = nil)
75
77
  session_id ||= generate_session_id
76
78
  now = Time.now
77
79
 
78
- # Optimize session context creation - use cached minimal context when rack_env is nil
79
- session_context = if rack_env
80
- create_session_with_context(session_id, rack_env)
81
- else
82
- create_minimal_session_context(session_id)
83
- end
84
-
85
- # Pre-allocate metadata hash for better performance
86
- metadata = create_session_metadata
87
-
88
- # Create internal session record with streaming connection metadata
89
- session = Session.new(session_id, session_context, now, now, metadata)
80
+ request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
81
+ session_context = VectorMCP::Session.new(
82
+ @transport.server, @transport, id: session_id, request_context: request_context
83
+ )
90
84
 
85
+ session = Session.new(session_id, session_context, now, now, create_session_metadata)
91
86
  @sessions[session_id] = session
92
87
 
93
88
  logger.info { "Session created: #{session_id}" }
@@ -116,19 +111,6 @@ module VectorMCP
116
111
  create_session(nil, rack_env)
117
112
  end
118
113
 
119
- # Creates a VectorMCP::Session with proper request context from Rack environment
120
- def create_session_with_context(session_id, rack_env)
121
- request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
122
- VectorMCP::Session.new(@transport.server, @transport, id: session_id, request_context: request_context)
123
- end
124
-
125
- # Creates a minimal session context for each session (no caching to prevent contamination)
126
- def create_minimal_session_context(session_id)
127
- # Create a new minimal context for each session to prevent cross-session contamination
128
- minimal_context = VectorMCP::RequestContext.minimal("http_stream")
129
- VectorMCP::Session.new(@transport.server, @transport, id: session_id, request_context: minimal_context)
130
- end
131
-
132
114
  # Terminates a session by ID.
133
115
  #
134
116
  # @param session_id [String] The session ID to terminate
@@ -52,6 +52,8 @@ module VectorMCP
52
52
  DEFAULT_SESSION_TIMEOUT = 300 # 5 minutes
53
53
  DEFAULT_EVENT_RETENTION = 100 # Keep last 100 events for resumability
54
54
  DEFAULT_REQUEST_TIMEOUT = 30 # Default timeout for server-initiated requests
55
+ DEFAULT_MIN_THREADS = 4
56
+ DEFAULT_MAX_THREADS = 32
55
57
 
56
58
  # Default allowed origins — restrict to localhost by default for security.
57
59
  DEFAULT_ALLOWED_ORIGINS = %w[
@@ -72,6 +74,8 @@ module VectorMCP
72
74
  # @option options [String] :path_prefix ("/mcp") The base path for HTTP endpoints
73
75
  # @option options [Integer] :session_timeout (300) Session timeout in seconds
74
76
  # @option options [Integer] :event_retention (100) Number of events to retain for resumability
77
+ # @option options [Integer] :min_threads (4) Minimum Puma thread pool size
78
+ # @option options [Integer] :max_threads (32) Maximum Puma thread pool size
75
79
  # @option options [Array<String>] :allowed_origins Allowed origins for CORS validation.
76
80
  # Defaults to localhost origins only. Pass ["*"] to allow all origins (NOT recommended for production).
77
81
  def initialize(server, options = {})
@@ -264,7 +268,7 @@ module VectorMCP
264
268
  #
265
269
  # @return [void]
266
270
  def start_puma_server
267
- @puma_server = Puma::Server.new(self)
271
+ @puma_server = Puma::Server.new(self, nil, min_threads: @min_threads, max_threads: @max_threads)
268
272
  @puma_server.add_tcp_listener(@host, @port)
269
273
 
270
274
  @running = true
@@ -345,6 +349,7 @@ module VectorMCP
345
349
  # Validates origin and dispatches to the appropriate handler by HTTP method.
346
350
  def validate_and_dispatch(method, env)
347
351
  return forbidden_response("Origin not allowed") unless valid_origin?(env)
352
+ return unauthorized_oauth_response(env) if oauth_gate_should_reject?(env)
348
353
 
349
354
  case method
350
355
  when "POST"
@@ -358,6 +363,73 @@ module VectorMCP
358
363
  end
359
364
  end
360
365
 
366
+ # True when OAuth 2.1 resource server mode is enabled and the incoming
367
+ # request has not successfully authenticated. Opt-in: only activates when the
368
+ # server was configured with a +resource_metadata_url+ via +enable_authentication!+.
369
+ #
370
+ # @param env [Hash] The Rack environment
371
+ # @return [Boolean]
372
+ def oauth_gate_should_reject?(env)
373
+ return false unless oauth_resource_server_enabled?
374
+
375
+ !authenticate_transport_request(env).authenticated?
376
+ end
377
+
378
+ # @return [Boolean] true when the server is configured to act as an OAuth 2.1 resource server.
379
+ def oauth_resource_server_enabled?
380
+ return false unless @server.respond_to?(:oauth_resource_metadata_url)
381
+ return false if @server.oauth_resource_metadata_url.nil?
382
+
383
+ @server.auth_manager.required?
384
+ end
385
+
386
+ # Runs the configured authentication strategy against the Rack env and returns
387
+ # the resulting SessionContext. The request is normalized into the
388
+ # +{ headers:, params:, method:, path:, rack_env: }+ hash shape that the rest
389
+ # of the codebase's authentication pipeline uses (see
390
+ # +VectorMCP::Handlers::Core.extract_request_from_session+), so +:custom+
391
+ # strategy handlers see the same contract here as they do on the in-handler
392
+ # auth path. Errors in the strategy are logged and treated as unauthenticated
393
+ # rather than propagated, so a malformed token can never crash the request
394
+ # pipeline.
395
+ #
396
+ # @param env [Hash] The Rack environment
397
+ # @return [VectorMCP::Security::SessionContext]
398
+ def authenticate_transport_request(env)
399
+ normalized_request = @server.security_middleware.normalize_request(env)
400
+ @server.security_middleware.authenticate_request(normalized_request)
401
+ rescue StandardError => e
402
+ VectorMCP.logger_for("security").warn do
403
+ "OAuth transport auth strategy raised #{e.class}: #{e.message}"
404
+ end
405
+ VectorMCP::Security::SessionContext.anonymous
406
+ end
407
+
408
+ # Returns a 401 Rack response carrying a WWW-Authenticate header that points
409
+ # Claude Desktop (and other RFC 9728 clients) at the configured OAuth 2.1
410
+ # protected resource metadata document. The JSON-RPC error envelope in the
411
+ # body is for clients that parse bodies regardless of status code; the header
412
+ # and status are the parts that drive the discovery flow.
413
+ #
414
+ # @param env [Hash] The Rack environment
415
+ # @return [Array] Rack response triplet
416
+ def unauthorized_oauth_response(env)
417
+ VectorMCP.logger_for("security").info do
418
+ "OAuth 401 challenge issued for #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
419
+ end
420
+
421
+ header_value = %(Bearer realm="mcp", resource_metadata="#{@server.oauth_resource_metadata_url}")
422
+ body = {
423
+ jsonrpc: "2.0",
424
+ id: nil,
425
+ error: { code: -32_401, message: "Authentication required" }
426
+ }.to_json
427
+
428
+ [401,
429
+ { "Content-Type" => "application/json", "WWW-Authenticate" => header_value },
430
+ [body]]
431
+ end
432
+
361
433
  # Handles POST requests (client-to-server JSON-RPC)
362
434
  #
363
435
  # @param env [Hash] The Rack environment
@@ -762,15 +834,12 @@ module VectorMCP
762
834
 
763
835
  # Request tracking helpers for server-initiated requests
764
836
 
765
- # Sets up tracking for an outgoing request using pooled condition variables.
837
+ # Sets up tracking for an outgoing request.
766
838
  #
767
839
  # @param request_id [String] The request ID to track
768
840
  # @return [void]
769
841
  def setup_request_tracking(request_id)
770
- @request_mutex.synchronize do
771
- # Create IVar for thread-safe request tracking (no race conditions)
772
- @outgoing_request_ivars[request_id] = Concurrent::IVar.new
773
- end
842
+ @outgoing_request_ivars[request_id] = Concurrent::IVar.new
774
843
  end
775
844
 
776
845
  # Waits for a response to an outgoing request.
@@ -781,11 +850,7 @@ module VectorMCP
781
850
  # @return [Hash] The response data
782
851
  # @raise [VectorMCP::SamplingTimeoutError] if timeout occurs
783
852
  def wait_for_response(request_id, method, timeout)
784
- ivar = nil
785
- @request_mutex.synchronize do
786
- ivar = @outgoing_request_ivars[request_id]
787
- end
788
-
853
+ ivar = @outgoing_request_ivars[request_id]
789
854
  return nil unless ivar
790
855
 
791
856
  begin
@@ -827,24 +892,11 @@ module VectorMCP
827
892
  response[:result]
828
893
  end
829
894
 
830
- # Cleans up tracking for a request and returns condition variable to pool.
895
+ # Cleans up tracking for a request.
831
896
  #
832
897
  # @param request_id [String] The request ID to clean up
833
898
  # @return [void]
834
899
  def cleanup_request_tracking(request_id)
835
- @request_mutex.synchronize do
836
- cleanup_request_tracking_unsafe(request_id)
837
- end
838
- end
839
-
840
- # Internal cleanup method that assumes mutex is already held.
841
- # This prevents recursive locking when called from within synchronized blocks.
842
- #
843
- # @param request_id [String] The request ID to clean up
844
- # @return [void]
845
- # @api private
846
- def cleanup_request_tracking_unsafe(request_id)
847
- # Remove IVar for this request (no condition variable cleanup needed)
848
900
  @outgoing_request_ivars.delete(request_id)
849
901
  end
850
902
 
@@ -873,10 +925,7 @@ module VectorMCP
873
925
  def handle_outgoing_response(message)
874
926
  request_id = message["id"]
875
927
 
876
- ivar = nil
877
- @request_mutex.synchronize do
878
- ivar = @outgoing_request_ivars[request_id]
879
- end
928
+ ivar = @outgoing_request_ivars[request_id]
880
929
 
881
930
  unless ivar
882
931
  logger.debug { "Received response for request ID #{request_id} but no thread is waiting (likely timed out)" }
@@ -885,11 +934,6 @@ module VectorMCP
885
934
 
886
935
  # Convert keys to symbols for consistency and put response in IVar
887
936
  response_data = deep_transform_keys(message, &:to_sym)
888
-
889
- # Store in both places for compatibility with tests
890
- @outgoing_request_responses[request_id] = response_data
891
-
892
- # IVar handles thread-safe response delivery - no race conditions possible
893
937
  if ivar.try_set(response_data)
894
938
  logger.debug { "Response delivered to waiting thread for request ID #{request_id}" }
895
939
  else
@@ -937,6 +981,8 @@ module VectorMCP
937
981
  @path_prefix = normalize_path_prefix(options[:path_prefix] || DEFAULT_PATH_PREFIX)
938
982
  @session_timeout = options[:session_timeout] || DEFAULT_SESSION_TIMEOUT
939
983
  @event_retention = options[:event_retention] || DEFAULT_EVENT_RETENTION
984
+ @min_threads = options[:min_threads] || DEFAULT_MIN_THREADS
985
+ @max_threads = options[:max_threads] || DEFAULT_MAX_THREADS
940
986
  @allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
941
987
  @mounted = options.fetch(:mounted, false)
942
988
 
@@ -960,11 +1006,7 @@ module VectorMCP
960
1006
 
961
1007
  # Initialize request tracking system and ID generation for server-initiated requests
962
1008
  def initialize_request_tracking
963
- # Use IVars for thread-safe request/response handling (eliminates condition variable races)
964
1009
  @outgoing_request_ivars = Concurrent::Hash.new
965
- # Keep compatibility with tests that expect @outgoing_request_responses
966
- @outgoing_request_responses = Concurrent::Hash.new
967
- @request_mutex = Mutex.new
968
1010
  initialize_request_id_generation
969
1011
  end
970
1012
 
@@ -1008,10 +1050,7 @@ module VectorMCP
1008
1050
 
1009
1051
  logger.debug { "Cleaning up #{@outgoing_request_ivars.size} pending requests" }
1010
1052
 
1011
- @request_mutex.synchronize do
1012
- # IVars will timeout naturally, just clear the tracking
1013
- @outgoing_request_ivars.clear
1014
- end
1053
+ @outgoing_request_ivars.clear
1015
1054
  end
1016
1055
 
1017
1056
  # Finds the first session with an active streaming connection.
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Util
5
+ # Stateless recursive traversal utility for parsed JSON-like structures.
6
+ #
7
+ # {.sweep} walks Hashes, Arrays, and String leaves, yielding each String
8
+ # value together with its parent Hash key (or the enclosing Hash key of
9
+ # the nearest containing Array). The block's return value replaces the
10
+ # String in the output. All other scalar types (Integer, Float, nil,
11
+ # Boolean, etc.) are returned unchanged and are not yielded.
12
+ #
13
+ # The method is purely functional: it never mutates the input structure
14
+ # and always returns a fresh Hash/Array spine when containers are
15
+ # encountered. Circular references are detected via an identity-compared
16
+ # visited set and the originally-referenced node is returned unchanged
17
+ # on cycles.
18
+ module TokenSweeper
19
+ # Traverse +obj+ and return a new structure with String leaves
20
+ # transformed by +block+.
21
+ #
22
+ # @param obj [Object] the object to sweep (typically Hash/Array/String/scalar).
23
+ # @yield [value, parent_key] invoked for each String leaf.
24
+ # @yieldparam value [String] the String value.
25
+ # @yieldparam parent_key [Object, nil] the Hash key under which +value+
26
+ # lives, or +nil+ when the String is a top-level scalar; propagated
27
+ # from the nearest containing Hash when inside Arrays.
28
+ # @yieldreturn [Object] the replacement value.
29
+ # @return [Object] the transformed structure.
30
+ def self.sweep(obj, &block)
31
+ raise ArgumentError, "TokenSweeper.sweep requires a block" unless block
32
+
33
+ walk(obj, nil, {}.compare_by_identity, &block)
34
+ end
35
+
36
+ class << self
37
+ private
38
+
39
+ def walk(obj, parent_key, visited, &)
40
+ case obj
41
+ when Hash then walk_hash(obj, visited, &)
42
+ when Array then walk_array(obj, parent_key, visited, &)
43
+ when String then yield(obj, parent_key)
44
+ else obj
45
+ end
46
+ end
47
+
48
+ def walk_hash(hash, visited, &)
49
+ return hash if visited[hash]
50
+
51
+ visited[hash] = true
52
+ begin
53
+ hash.each_with_object({}) do |(key, value), out|
54
+ out[key] = walk(value, key, visited, &)
55
+ end
56
+ ensure
57
+ visited.delete(hash)
58
+ end
59
+ end
60
+
61
+ def walk_array(array, parent_key, visited, &)
62
+ return array if visited[array]
63
+
64
+ visited[array] = true
65
+ begin
66
+ array.map { |element| walk(element, parent_key, visited, &) }
67
+ ensure
68
+ visited.delete(array)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module VectorMCP
4
4
  # The current version of the VectorMCP gem.
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
data/lib/vector_mcp.rb CHANGED
@@ -8,6 +8,8 @@ require_relative "vector_mcp/errors"
8
8
  require_relative "vector_mcp/definitions"
9
9
  require_relative "vector_mcp/session"
10
10
  require_relative "vector_mcp/util"
11
+ require_relative "vector_mcp/util/token_sweeper"
12
+ require_relative "vector_mcp/token_store"
11
13
  require_relative "vector_mcp/log_filter"
12
14
  require_relative "vector_mcp/image_util"
13
15
  require_relative "vector_mcp/handlers/core"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vector_mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
@@ -132,6 +132,7 @@ files:
132
132
  - lib/vector_mcp/log_filter.rb
133
133
  - lib/vector_mcp/logger.rb
134
134
  - lib/vector_mcp/middleware.rb
135
+ - lib/vector_mcp/middleware/anonymizer.rb
135
136
  - lib/vector_mcp/middleware/base.rb
136
137
  - lib/vector_mcp/middleware/context.rb
137
138
  - lib/vector_mcp/middleware/hook.rb
@@ -142,6 +143,7 @@ files:
142
143
  - lib/vector_mcp/sampling/result.rb
143
144
  - lib/vector_mcp/security.rb
144
145
  - lib/vector_mcp/security/auth_manager.rb
146
+ - lib/vector_mcp/security/auth_result.rb
145
147
  - lib/vector_mcp/security/authorization.rb
146
148
  - lib/vector_mcp/security/middleware.rb
147
149
  - lib/vector_mcp/security/session_context.rb
@@ -153,6 +155,7 @@ files:
153
155
  - lib/vector_mcp/server/message_handling.rb
154
156
  - lib/vector_mcp/server/registry.rb
155
157
  - lib/vector_mcp/session.rb
158
+ - lib/vector_mcp/token_store.rb
156
159
  - lib/vector_mcp/tool.rb
157
160
  - lib/vector_mcp/transport/base_session_manager.rb
158
161
  - lib/vector_mcp/transport/http_stream.rb
@@ -160,6 +163,7 @@ files:
160
163
  - lib/vector_mcp/transport/http_stream/session_manager.rb
161
164
  - lib/vector_mcp/transport/http_stream/stream_handler.rb
162
165
  - lib/vector_mcp/util.rb
166
+ - lib/vector_mcp/util/token_sweeper.rb
163
167
  - lib/vector_mcp/version.rb
164
168
  homepage: https://github.com/sergiobayona/vector_mcp
165
169
  licenses: