vector_mcp 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -1
- data/README.md +20 -0
- data/lib/vector_mcp/definitions.rb +30 -0
- data/lib/vector_mcp/handlers/core.rb +18 -76
- data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
- data/lib/vector_mcp/middleware/hook.rb +7 -24
- data/lib/vector_mcp/middleware.rb +26 -9
- data/lib/vector_mcp/security/auth_manager.rb +12 -13
- data/lib/vector_mcp/security/auth_result.rb +33 -0
- data/lib/vector_mcp/security/authorization.rb +5 -9
- data/lib/vector_mcp/security/session_context.rb +11 -27
- data/lib/vector_mcp/security/strategies/api_key.rb +1 -5
- data/lib/vector_mcp/security/strategies/custom.rb +10 -37
- data/lib/vector_mcp/security/strategies/jwt_token.rb +1 -10
- data/lib/vector_mcp/server/capabilities.rb +22 -26
- data/lib/vector_mcp/server/message_handling.rb +21 -14
- data/lib/vector_mcp/server/registry.rb +70 -120
- data/lib/vector_mcp/server.rb +53 -19
- data/lib/vector_mcp/token_store.rb +80 -0
- data/lib/vector_mcp/transport/http_stream/event_store.rb +14 -16
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +8 -26
- data/lib/vector_mcp/transport/http_stream.rb +81 -42
- data/lib/vector_mcp/util/token_sweeper.rb +74 -0
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +2 -0
- metadata +5 -1
data/lib/vector_mcp/server.rb
CHANGED
|
@@ -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 ->
|
|
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
|
-
|
|
85
|
-
return [] if
|
|
81
|
+
logical = @event_index[last_event_id]
|
|
82
|
+
return [] if logical.nil?
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
return [] if
|
|
84
|
+
physical = logical - @offset + 1
|
|
85
|
+
return [] if physical >= @events.length
|
|
89
86
|
|
|
90
|
-
@events[
|
|
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
|
-
|
|
104
|
-
return nil if
|
|
100
|
+
logical = @event_index[event_id]
|
|
101
|
+
return nil if logical.nil?
|
|
105
102
|
|
|
106
|
-
@events[
|
|
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
|
-
#
|
|
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
|
-
|
|
79
|
-
session_context =
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
@
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
@
|
|
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
|
data/lib/vector_mcp/version.rb
CHANGED
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
|
+
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:
|