vector_mcp 0.4.0 → 0.6.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +39 -0
  4. data/lib/vector_mcp/definitions.rb +30 -0
  5. data/lib/vector_mcp/handlers/core.rb +32 -89
  6. data/lib/vector_mcp/invocation.rb +106 -0
  7. data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
  8. data/lib/vector_mcp/middleware/hook.rb +7 -24
  9. data/lib/vector_mcp/middleware.rb +26 -9
  10. data/lib/vector_mcp/request_context.rb +97 -14
  11. data/lib/vector_mcp/security/auth_manager.rb +15 -14
  12. data/lib/vector_mcp/security/auth_result.rb +33 -0
  13. data/lib/vector_mcp/security/authorization.rb +5 -9
  14. data/lib/vector_mcp/security/middleware.rb +0 -47
  15. data/lib/vector_mcp/security/session_context.rb +11 -27
  16. data/lib/vector_mcp/security/strategies/api_key.rb +7 -52
  17. data/lib/vector_mcp/security/strategies/custom.rb +15 -39
  18. data/lib/vector_mcp/security/strategies/jwt_token.rb +7 -17
  19. data/lib/vector_mcp/server/capabilities.rb +22 -26
  20. data/lib/vector_mcp/server/message_handling.rb +24 -17
  21. data/lib/vector_mcp/server/registry.rb +70 -120
  22. data/lib/vector_mcp/server.rb +53 -19
  23. data/lib/vector_mcp/session.rb +55 -27
  24. data/lib/vector_mcp/token_store.rb +80 -0
  25. data/lib/vector_mcp/transport/base_session_manager.rb +12 -38
  26. data/lib/vector_mcp/transport/http_stream/event_store.rb +14 -16
  27. data/lib/vector_mcp/transport/http_stream/session_manager.rb +20 -61
  28. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +7 -7
  29. data/lib/vector_mcp/transport/http_stream.rb +95 -55
  30. data/lib/vector_mcp/util/token_sweeper.rb +74 -0
  31. data/lib/vector_mcp/version.rb +1 -1
  32. data/lib/vector_mcp.rb +11 -0
  33. metadata +6 -1
@@ -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,70 @@ 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 carried as a
388
+ # {VectorMCP::RequestContext} — the same type the in-handler auth path uses —
389
+ # so +:custom+ strategy handlers see one contract everywhere. Errors in the
390
+ # strategy are logged and treated as unauthenticated rather than propagated,
391
+ # so a malformed token can never crash the request pipeline.
392
+ #
393
+ # @param env [Hash] The Rack environment
394
+ # @return [VectorMCP::Security::SessionContext]
395
+ def authenticate_transport_request(env)
396
+ request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
397
+ @server.security_middleware.authenticate_request(request_context)
398
+ rescue StandardError => e
399
+ VectorMCP.logger_for("security").warn do
400
+ "OAuth transport auth strategy raised #{e.class}: #{e.message}"
401
+ end
402
+ VectorMCP::Security::SessionContext.anonymous
403
+ end
404
+
405
+ # Returns a 401 Rack response carrying a WWW-Authenticate header that points
406
+ # Claude Desktop (and other RFC 9728 clients) at the configured OAuth 2.1
407
+ # protected resource metadata document. The JSON-RPC error envelope in the
408
+ # body is for clients that parse bodies regardless of status code; the header
409
+ # and status are the parts that drive the discovery flow.
410
+ #
411
+ # @param env [Hash] The Rack environment
412
+ # @return [Array] Rack response triplet
413
+ def unauthorized_oauth_response(env)
414
+ VectorMCP.logger_for("security").info do
415
+ "OAuth 401 challenge issued for #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
416
+ end
417
+
418
+ header_value = %(Bearer realm="mcp", resource_metadata="#{@server.oauth_resource_metadata_url}")
419
+ body = {
420
+ jsonrpc: "2.0",
421
+ id: nil,
422
+ error: { code: -32_401, message: "Authentication required" }
423
+ }.to_json
424
+
425
+ [401,
426
+ { "Content-Type" => "application/json", "WWW-Authenticate" => header_value },
427
+ [body]]
428
+ end
429
+
361
430
  # Handles POST requests (client-to-server JSON-RPC)
362
431
  #
363
432
  # @param env [Hash] The Rack environment
@@ -405,24 +474,35 @@ module VectorMCP
405
474
  return [202, { "Mcp-Session-Id" => session.id }, []]
406
475
  end
407
476
 
477
+ # Each request is dispatched through its own Invocation so that
478
+ # request-scoped state (headers, params, resolved auth) never leaks
479
+ # between concurrent requests on the same session.
480
+ invocation = VectorMCP::Invocation.new(
481
+ session,
482
+ request_context: VectorMCP::RequestContext.from_rack_env(env, "http_stream")
483
+ )
484
+
408
485
  # Notifications: has method, no id -> 202 Accepted with no body (MCP spec requirement)
409
486
  if message["method"] && !message.key?("id")
410
- @server.handle_message(message, session.context, session.id)
487
+ @server.handle_message(message, invocation)
411
488
  return [202, { "Mcp-Session-Id" => session.id }, []]
412
489
  end
413
490
 
414
- result = @server.handle_message(message, session.context, session.id)
491
+ result = @server.handle_message(message, invocation)
415
492
  build_rpc_response(env, result, message["id"], session.id)
416
493
  rescue VectorMCP::ProtocolError => e
417
494
  build_protocol_error_response(env, e, session_id: session.id)
418
495
  end
419
496
 
420
497
  # Resolves or creates the session for a POST request following MCP spec rules:
421
- # - session_id present and known → return existing session (updating request context)
498
+ # - session_id present and known → return existing session
422
499
  # - session_id present but unknown/expired → 404 Not Found
423
500
  # - no session_id + initialize request → create new session
424
501
  # - no session_id + other request → 400 Bad Request
425
502
  #
503
+ # Request-scoped data is NOT written to the session here; each request
504
+ # carries its own context via the Invocation built at dispatch time.
505
+ #
426
506
  # @param session_id [String, nil] Client-supplied Mcp-Session-Id header value
427
507
  # @param message [Hash] Parsed JSON-RPC message
428
508
  # @param env [Hash] Rack environment
@@ -431,14 +511,7 @@ module VectorMCP
431
511
  is_initialize = message.is_a?(Hash) && message["method"] == "initialize"
432
512
 
433
513
  if session_id
434
- session = @session_manager.get_session(session_id)
435
- return not_found_response("Unknown or expired session") unless session
436
-
437
- if env
438
- request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
439
- session.context.request_context = request_context
440
- end
441
- session
514
+ @session_manager.get_session(session_id) || not_found_response("Unknown or expired session")
442
515
  elsif is_initialize
443
516
  @session_manager.create_session(nil, env)
444
517
  else
@@ -762,15 +835,12 @@ module VectorMCP
762
835
 
763
836
  # Request tracking helpers for server-initiated requests
764
837
 
765
- # Sets up tracking for an outgoing request using pooled condition variables.
838
+ # Sets up tracking for an outgoing request.
766
839
  #
767
840
  # @param request_id [String] The request ID to track
768
841
  # @return [void]
769
842
  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
843
+ @outgoing_request_ivars[request_id] = Concurrent::IVar.new
774
844
  end
775
845
 
776
846
  # Waits for a response to an outgoing request.
@@ -781,11 +851,7 @@ module VectorMCP
781
851
  # @return [Hash] The response data
782
852
  # @raise [VectorMCP::SamplingTimeoutError] if timeout occurs
783
853
  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
-
854
+ ivar = @outgoing_request_ivars[request_id]
789
855
  return nil unless ivar
790
856
 
791
857
  begin
@@ -827,24 +893,11 @@ module VectorMCP
827
893
  response[:result]
828
894
  end
829
895
 
830
- # Cleans up tracking for a request and returns condition variable to pool.
896
+ # Cleans up tracking for a request.
831
897
  #
832
898
  # @param request_id [String] The request ID to clean up
833
899
  # @return [void]
834
900
  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
901
  @outgoing_request_ivars.delete(request_id)
849
902
  end
850
903
 
@@ -873,10 +926,7 @@ module VectorMCP
873
926
  def handle_outgoing_response(message)
874
927
  request_id = message["id"]
875
928
 
876
- ivar = nil
877
- @request_mutex.synchronize do
878
- ivar = @outgoing_request_ivars[request_id]
879
- end
929
+ ivar = @outgoing_request_ivars[request_id]
880
930
 
881
931
  unless ivar
882
932
  logger.debug { "Received response for request ID #{request_id} but no thread is waiting (likely timed out)" }
@@ -885,11 +935,6 @@ module VectorMCP
885
935
 
886
936
  # Convert keys to symbols for consistency and put response in IVar
887
937
  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
938
  if ivar.try_set(response_data)
894
939
  logger.debug { "Response delivered to waiting thread for request ID #{request_id}" }
895
940
  else
@@ -937,6 +982,8 @@ module VectorMCP
937
982
  @path_prefix = normalize_path_prefix(options[:path_prefix] || DEFAULT_PATH_PREFIX)
938
983
  @session_timeout = options[:session_timeout] || DEFAULT_SESSION_TIMEOUT
939
984
  @event_retention = options[:event_retention] || DEFAULT_EVENT_RETENTION
985
+ @min_threads = options[:min_threads] || DEFAULT_MIN_THREADS
986
+ @max_threads = options[:max_threads] || DEFAULT_MAX_THREADS
940
987
  @allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
941
988
  @mounted = options.fetch(:mounted, false)
942
989
 
@@ -960,11 +1007,7 @@ module VectorMCP
960
1007
 
961
1008
  # Initialize request tracking system and ID generation for server-initiated requests
962
1009
  def initialize_request_tracking
963
- # Use IVars for thread-safe request/response handling (eliminates condition variable races)
964
1010
  @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
1011
  initialize_request_id_generation
969
1012
  end
970
1013
 
@@ -1008,15 +1051,12 @@ module VectorMCP
1008
1051
 
1009
1052
  logger.debug { "Cleaning up #{@outgoing_request_ivars.size} pending requests" }
1010
1053
 
1011
- @request_mutex.synchronize do
1012
- # IVars will timeout naturally, just clear the tracking
1013
- @outgoing_request_ivars.clear
1014
- end
1054
+ @outgoing_request_ivars.clear
1015
1055
  end
1016
1056
 
1017
1057
  # Finds the first session with an active streaming connection.
1018
1058
  #
1019
- # @return [SessionManager::Session, nil] The first streaming session or nil if none found
1059
+ # @return [VectorMCP::Session, nil] The first streaming session or nil if none found
1020
1060
  def find_streaming_session
1021
1061
  @session_manager.active_session_ids.each do |session_id|
1022
1062
  session = @session_manager.get_session(session_id)
@@ -1027,7 +1067,7 @@ module VectorMCP
1027
1067
 
1028
1068
  # Finds the first available session (streaming or non-streaming).
1029
1069
  #
1030
- # @return [SessionManager::Session, nil] The first available session or nil if none found
1070
+ # @return [VectorMCP::Session, nil] The first available session or nil if none found
1031
1071
  def find_first_session
1032
1072
  session_ids = @session_manager.active_session_ids
1033
1073
  return nil if session_ids.empty?
@@ -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.6.0"
6
6
  end
data/lib/vector_mcp.rb CHANGED
@@ -7,7 +7,10 @@ require_relative "vector_mcp/version"
7
7
  require_relative "vector_mcp/errors"
8
8
  require_relative "vector_mcp/definitions"
9
9
  require_relative "vector_mcp/session"
10
+ require_relative "vector_mcp/invocation"
10
11
  require_relative "vector_mcp/util"
12
+ require_relative "vector_mcp/util/token_sweeper"
13
+ require_relative "vector_mcp/token_store"
11
14
  require_relative "vector_mcp/log_filter"
12
15
  require_relative "vector_mcp/image_util"
13
16
  require_relative "vector_mcp/handlers/core"
@@ -59,6 +62,14 @@ module VectorMCP
59
62
  @logger ||= Logger.for("vectormcp")
60
63
  end
61
64
 
65
+ # Emit a deprecation warning. Centralized so tests and applications can
66
+ # intercept or silence deprecations in one place.
67
+ # @param message [String] description of the deprecated usage and its replacement
68
+ # @return [void]
69
+ def deprecation_warning(message)
70
+ warn("[DEPRECATION] VectorMCP: #{message}")
71
+ end
72
+
62
73
  # Creates a new {VectorMCP::Server} instance. This is a **thin wrapper** around
63
74
  # `VectorMCP::Server.new`; it exists purely for syntactic sugar so you can write
64
75
  # `VectorMCP.new` instead of `VectorMCP::Server.new`.
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.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
@@ -129,9 +129,11 @@ files:
129
129
  - lib/vector_mcp/errors.rb
130
130
  - lib/vector_mcp/handlers/core.rb
131
131
  - lib/vector_mcp/image_util.rb
132
+ - lib/vector_mcp/invocation.rb
132
133
  - lib/vector_mcp/log_filter.rb
133
134
  - lib/vector_mcp/logger.rb
134
135
  - lib/vector_mcp/middleware.rb
136
+ - lib/vector_mcp/middleware/anonymizer.rb
135
137
  - lib/vector_mcp/middleware/base.rb
136
138
  - lib/vector_mcp/middleware/context.rb
137
139
  - lib/vector_mcp/middleware/hook.rb
@@ -142,6 +144,7 @@ files:
142
144
  - lib/vector_mcp/sampling/result.rb
143
145
  - lib/vector_mcp/security.rb
144
146
  - lib/vector_mcp/security/auth_manager.rb
147
+ - lib/vector_mcp/security/auth_result.rb
145
148
  - lib/vector_mcp/security/authorization.rb
146
149
  - lib/vector_mcp/security/middleware.rb
147
150
  - lib/vector_mcp/security/session_context.rb
@@ -153,6 +156,7 @@ files:
153
156
  - lib/vector_mcp/server/message_handling.rb
154
157
  - lib/vector_mcp/server/registry.rb
155
158
  - lib/vector_mcp/session.rb
159
+ - lib/vector_mcp/token_store.rb
156
160
  - lib/vector_mcp/tool.rb
157
161
  - lib/vector_mcp/transport/base_session_manager.rb
158
162
  - lib/vector_mcp/transport/http_stream.rb
@@ -160,6 +164,7 @@ files:
160
164
  - lib/vector_mcp/transport/http_stream/session_manager.rb
161
165
  - lib/vector_mcp/transport/http_stream/stream_handler.rb
162
166
  - lib/vector_mcp/util.rb
167
+ - lib/vector_mcp/util/token_sweeper.rb
163
168
  - lib/vector_mcp/version.rb
164
169
  homepage: https://github.com/sergiobayona/vector_mcp
165
170
  licenses: