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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +39 -0
- data/lib/vector_mcp/definitions.rb +30 -0
- data/lib/vector_mcp/handlers/core.rb +32 -89
- data/lib/vector_mcp/invocation.rb +106 -0
- 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/request_context.rb +97 -14
- data/lib/vector_mcp/security/auth_manager.rb +15 -14
- data/lib/vector_mcp/security/auth_result.rb +33 -0
- data/lib/vector_mcp/security/authorization.rb +5 -9
- data/lib/vector_mcp/security/middleware.rb +0 -47
- data/lib/vector_mcp/security/session_context.rb +11 -27
- data/lib/vector_mcp/security/strategies/api_key.rb +7 -52
- data/lib/vector_mcp/security/strategies/custom.rb +15 -39
- data/lib/vector_mcp/security/strategies/jwt_token.rb +7 -17
- data/lib/vector_mcp/server/capabilities.rb +22 -26
- data/lib/vector_mcp/server/message_handling.rb +24 -17
- data/lib/vector_mcp/server/registry.rb +70 -120
- data/lib/vector_mcp/server.rb +53 -19
- data/lib/vector_mcp/session.rb +55 -27
- data/lib/vector_mcp/token_store.rb +80 -0
- data/lib/vector_mcp/transport/base_session_manager.rb +12 -38
- data/lib/vector_mcp/transport/http_stream/event_store.rb +14 -16
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +20 -61
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +7 -7
- data/lib/vector_mcp/transport/http_stream.rb +95 -55
- data/lib/vector_mcp/util/token_sweeper.rb +74 -0
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +11 -0
- metadata +6 -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
|
data/lib/vector_mcp/session.rb
CHANGED
|
@@ -17,7 +17,8 @@ module VectorMCP
|
|
|
17
17
|
# @attr_reader client_capabilities [Hash, nil] Capabilities supported by the client, received during initialization.
|
|
18
18
|
# @attr_reader request_context [RequestContext] The request context for this session.
|
|
19
19
|
class Session
|
|
20
|
-
attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id,
|
|
20
|
+
attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id,
|
|
21
|
+
:request_context, :created_at, :last_accessed_at, :metadata
|
|
21
22
|
attr_accessor :data, :security_context # For user-defined session-specific storage and resolved auth context
|
|
22
23
|
|
|
23
24
|
# Initializes a new session.
|
|
@@ -35,6 +36,9 @@ module VectorMCP
|
|
|
35
36
|
@client_capabilities = nil
|
|
36
37
|
@data = {} # Initialize user data hash
|
|
37
38
|
@security_context = Security::SessionContext.anonymous
|
|
39
|
+
@created_at = Time.now
|
|
40
|
+
@last_accessed_at = @created_at
|
|
41
|
+
@metadata = {} # Transport-owned lifecycle/connection state
|
|
38
42
|
@logger = server.logger
|
|
39
43
|
|
|
40
44
|
# Initialize request context
|
|
@@ -90,14 +94,34 @@ module VectorMCP
|
|
|
90
94
|
@initialized_state == :succeeded
|
|
91
95
|
end
|
|
92
96
|
|
|
97
|
+
# --- Lifecycle (managed by the transport's session manager) ---
|
|
98
|
+
|
|
99
|
+
# Records activity on this session, deferring expiry.
|
|
100
|
+
# @return [void]
|
|
101
|
+
def touch!
|
|
102
|
+
@last_accessed_at = Time.now
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param timeout [Numeric] Idle timeout in seconds.
|
|
106
|
+
# @return [Boolean] True when the session has been idle longer than the timeout.
|
|
107
|
+
def expired?(timeout)
|
|
108
|
+
Time.now - @last_accessed_at > timeout
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Float] Seconds since the session was created.
|
|
112
|
+
def age
|
|
113
|
+
Time.now - @created_at
|
|
114
|
+
end
|
|
115
|
+
|
|
93
116
|
# Sets the request context for this session.
|
|
94
|
-
# This method should be called by transport layers to populate request-specific data.
|
|
95
117
|
#
|
|
118
|
+
# @deprecated Will be removed in 0.7. Request-scoped data travels on the
|
|
119
|
+
# per-request {VectorMCP::Invocation} passed to handlers.
|
|
96
120
|
# @param context [RequestContext, Hash] The request context to set.
|
|
97
|
-
# Can be a RequestContext object or a hash of attributes.
|
|
98
121
|
# @return [RequestContext] The newly set request context.
|
|
99
122
|
# @raise [ArgumentError] If the context is not a RequestContext or Hash.
|
|
100
123
|
def request_context=(context)
|
|
124
|
+
deprecated_request_api(:request_context=)
|
|
101
125
|
@request_context = case context
|
|
102
126
|
when RequestContext
|
|
103
127
|
context
|
|
@@ -109,53 +133,47 @@ module VectorMCP
|
|
|
109
133
|
end
|
|
110
134
|
|
|
111
135
|
# Updates the request context with new data.
|
|
112
|
-
# This merges the provided attributes with the existing context.
|
|
113
136
|
#
|
|
137
|
+
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
138
|
+
# {VectorMCP::Invocation#update_request_context} instead.
|
|
114
139
|
# @param attributes [Hash] The attributes to merge into the request context.
|
|
115
140
|
# @return [RequestContext] The updated request context.
|
|
116
141
|
def update_request_context(**attributes)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
# Deep merge nested hashes like headers and params
|
|
120
|
-
merged_attrs = current_attrs.dup
|
|
121
|
-
attributes.each do |key, value|
|
|
122
|
-
merged_attrs[key] = if value.is_a?(Hash) && current_attrs[key].is_a?(Hash)
|
|
123
|
-
current_attrs[key].merge(value)
|
|
124
|
-
else
|
|
125
|
-
value
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
@request_context = RequestContext.new(**merged_attrs)
|
|
142
|
+
deprecated_request_api(:update_request_context)
|
|
143
|
+
@request_context = @request_context.merge(attributes)
|
|
130
144
|
end
|
|
131
145
|
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
# @return [Boolean] True if the request context has headers
|
|
146
|
+
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
147
|
+
# {VectorMCP::Invocation#request_headers?} instead.
|
|
148
|
+
# @return [Boolean] True if the request context has headers.
|
|
135
149
|
def request_headers?
|
|
150
|
+
deprecated_request_api(:request_headers?)
|
|
136
151
|
@request_context.headers?
|
|
137
152
|
end
|
|
138
153
|
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
# @return [Boolean] True if the request context has parameters
|
|
154
|
+
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
155
|
+
# {VectorMCP::Invocation#request_params?} instead.
|
|
156
|
+
# @return [Boolean] True if the request context has parameters.
|
|
142
157
|
def request_params?
|
|
158
|
+
deprecated_request_api(:request_params?)
|
|
143
159
|
@request_context.params?
|
|
144
160
|
end
|
|
145
161
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
162
|
+
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
163
|
+
# {VectorMCP::Invocation#request_header} instead.
|
|
148
164
|
# @param name [String] The header name.
|
|
149
165
|
# @return [String, nil] The header value or nil if not found.
|
|
150
166
|
def request_header(name)
|
|
167
|
+
deprecated_request_api(:request_header)
|
|
151
168
|
@request_context.header(name)
|
|
152
169
|
end
|
|
153
170
|
|
|
154
|
-
#
|
|
155
|
-
#
|
|
171
|
+
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
172
|
+
# {VectorMCP::Invocation#request_param} instead.
|
|
156
173
|
# @param name [String] The parameter name.
|
|
157
174
|
# @return [String, nil] The parameter value or nil if not found.
|
|
158
175
|
def request_param(name)
|
|
176
|
+
deprecated_request_api(:request_param)
|
|
159
177
|
@request_context.param(name)
|
|
160
178
|
end
|
|
161
179
|
|
|
@@ -220,6 +238,16 @@ module VectorMCP
|
|
|
220
238
|
|
|
221
239
|
private
|
|
222
240
|
|
|
241
|
+
# Emit the standard deprecation warning for the request-scoped Session API.
|
|
242
|
+
# @api private
|
|
243
|
+
def deprecated_request_api(method_name)
|
|
244
|
+
VectorMCP.deprecation_warning(
|
|
245
|
+
"Session##{method_name} is deprecated and will be removed in 0.7; " \
|
|
246
|
+
"request-scoped data lives on the per-request invocation passed to handlers " \
|
|
247
|
+
"(e.g. invocation.request_header)"
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
223
251
|
# Validates that sampling can be performed on this session.
|
|
224
252
|
# @api private
|
|
225
253
|
# @raise [StandardError, InitializationError] if preconditions are not met.
|
|
@@ -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
|
|
@@ -11,21 +11,6 @@ module VectorMCP
|
|
|
11
11
|
#
|
|
12
12
|
# @abstract Subclass and implement transport-specific methods
|
|
13
13
|
class BaseSessionManager
|
|
14
|
-
# Session data structure for unified session management
|
|
15
|
-
Session = Struct.new(:id, :context, :created_at, :last_accessed_at, :metadata) do
|
|
16
|
-
def touch!
|
|
17
|
-
self.last_accessed_at = Time.now
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def expired?(timeout)
|
|
21
|
-
Time.now - last_accessed_at > timeout
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def age
|
|
25
|
-
Time.now - created_at
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
14
|
attr_reader :transport, :session_timeout, :logger
|
|
30
15
|
|
|
31
16
|
# Initializes a new session manager.
|
|
@@ -46,7 +31,7 @@ module VectorMCP
|
|
|
46
31
|
# Gets an existing session by ID.
|
|
47
32
|
#
|
|
48
33
|
# @param session_id [String] The session ID
|
|
49
|
-
# @return [Session, nil] The session if found and valid
|
|
34
|
+
# @return [VectorMCP::Session, nil] The session if found and valid
|
|
50
35
|
def get_session(session_id)
|
|
51
36
|
return nil unless session_id
|
|
52
37
|
|
|
@@ -58,40 +43,29 @@ module VectorMCP
|
|
|
58
43
|
end
|
|
59
44
|
|
|
60
45
|
# Gets an existing session or creates a new one.
|
|
46
|
+
# When a session_id is provided but not found (expired or unknown), returns
|
|
47
|
+
# nil instead of creating a session with the client-supplied ID — session
|
|
48
|
+
# IDs are always server-generated (prevents session fixation).
|
|
61
49
|
#
|
|
62
50
|
# @param session_id [String, nil] The session ID (optional)
|
|
63
|
-
# @return [Session] The existing
|
|
51
|
+
# @return [VectorMCP::Session, nil] The existing session, a newly created one, or nil
|
|
64
52
|
def get_or_create_session(session_id = nil)
|
|
65
53
|
if session_id
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# If session_id was provided but not found, create with that ID
|
|
70
|
-
return create_session(session_id)
|
|
54
|
+
get_session(session_id)
|
|
55
|
+
else
|
|
56
|
+
create_session
|
|
71
57
|
end
|
|
72
|
-
|
|
73
|
-
create_session
|
|
74
58
|
end
|
|
75
59
|
|
|
76
60
|
# Creates a new session.
|
|
77
61
|
#
|
|
78
62
|
# @param session_id [String, nil] Optional specific session ID to use
|
|
79
|
-
# @return [Session] The newly created session
|
|
63
|
+
# @return [VectorMCP::Session] The newly created session
|
|
80
64
|
def create_session(session_id = nil)
|
|
81
65
|
session_id ||= generate_session_id
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
session_context = VectorMCP::Session.new(@transport.server, @transport, id: session_id)
|
|
86
|
-
|
|
87
|
-
# Create internal session record with transport-specific metadata
|
|
88
|
-
session = Session.new(
|
|
89
|
-
session_id,
|
|
90
|
-
session_context,
|
|
91
|
-
now,
|
|
92
|
-
now,
|
|
93
|
-
create_session_metadata
|
|
94
|
-
)
|
|
66
|
+
|
|
67
|
+
session = VectorMCP::Session.new(@transport.server, @transport, id: session_id)
|
|
68
|
+
session.metadata.merge!(create_session_metadata)
|
|
95
69
|
|
|
96
70
|
@sessions[session_id] = session
|
|
97
71
|
|
|
@@ -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.
|
|
@@ -19,20 +19,9 @@ module VectorMCP
|
|
|
19
19
|
#
|
|
20
20
|
# @api private
|
|
21
21
|
class SessionManager < BaseSessionManager
|
|
22
|
-
# HTTP
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
self.last_accessed_at = Time.now
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def expired?(timeout)
|
|
29
|
-
Time.now - last_accessed_at > timeout
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def age
|
|
33
|
-
Time.now - created_at
|
|
34
|
-
end
|
|
35
|
-
|
|
22
|
+
# HTTP-streaming connection state, kept in the session's transport
|
|
23
|
+
# metadata. New sessions are extended with this module at creation.
|
|
24
|
+
module SessionStreaming
|
|
36
25
|
def streaming?
|
|
37
26
|
!streaming_connection.nil? || !streaming_connections.empty?
|
|
38
27
|
end
|
|
@@ -65,28 +54,20 @@ module VectorMCP
|
|
|
65
54
|
end
|
|
66
55
|
end
|
|
67
56
|
|
|
68
|
-
#
|
|
57
|
+
# Creates a new session. RequestContext.from_rack_env handles nil
|
|
58
|
+
# rack_env by falling back to a minimal context, so we don't need to
|
|
59
|
+
# branch here.
|
|
69
60
|
#
|
|
70
|
-
# @
|
|
71
|
-
# @param session_timeout [Integer] Session timeout in seconds
|
|
72
|
-
|
|
73
|
-
# Optimized session creation with reduced object allocation and faster context creation
|
|
61
|
+
# @return [VectorMCP::Session] The newly created session
|
|
74
62
|
def create_session(session_id = nil, rack_env = nil)
|
|
75
63
|
session_id ||= generate_session_id
|
|
76
|
-
now = Time.now
|
|
77
|
-
|
|
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
64
|
|
|
88
|
-
|
|
89
|
-
session = Session.new(
|
|
65
|
+
request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
|
|
66
|
+
session = VectorMCP::Session.new(
|
|
67
|
+
@transport.server, @transport, id: session_id, request_context: request_context
|
|
68
|
+
)
|
|
69
|
+
session.extend(SessionStreaming)
|
|
70
|
+
session.metadata.merge!(create_session_metadata)
|
|
90
71
|
|
|
91
72
|
@sessions[session_id] = session
|
|
92
73
|
|
|
@@ -95,38 +76,16 @@ module VectorMCP
|
|
|
95
76
|
end
|
|
96
77
|
|
|
97
78
|
# Override to add rack_env support.
|
|
98
|
-
# Returns nil when a session_id is provided but not found (expired or
|
|
99
|
-
#
|
|
79
|
+
# Returns nil when a session_id is provided but not found (expired or
|
|
80
|
+
# unknown) — callers are responsible for returning 404 in that case.
|
|
81
|
+
# Request-scoped data is never written onto an existing session; each
|
|
82
|
+
# request carries its own context via the Invocation built at dispatch.
|
|
100
83
|
def get_or_create_session(session_id = nil, rack_env = nil)
|
|
101
84
|
if session_id
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if rack_env
|
|
106
|
-
request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
|
|
107
|
-
session.context.request_context = request_context
|
|
108
|
-
end
|
|
109
|
-
return session
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Session ID provided but not found — signal 404 to caller
|
|
113
|
-
return nil
|
|
85
|
+
get_session(session_id)
|
|
86
|
+
else
|
|
87
|
+
create_session(nil, rack_env)
|
|
114
88
|
end
|
|
115
|
-
|
|
116
|
-
create_session(nil, rack_env)
|
|
117
|
-
end
|
|
118
|
-
|
|
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
89
|
end
|
|
131
90
|
|
|
132
91
|
# Terminates a session by ID.
|
|
@@ -49,7 +49,7 @@ module VectorMCP
|
|
|
49
49
|
# Handles a streaming request (GET request for SSE).
|
|
50
50
|
#
|
|
51
51
|
# @param env [Hash] The Rack environment
|
|
52
|
-
# @param session [
|
|
52
|
+
# @param session [VectorMCP::Session] The session for this request
|
|
53
53
|
# @return [Array] Rack response triplet for SSE
|
|
54
54
|
def handle_streaming_request(env, session)
|
|
55
55
|
last_event_id = extract_last_event_id(env)
|
|
@@ -65,7 +65,7 @@ module VectorMCP
|
|
|
65
65
|
|
|
66
66
|
# Sends a message to a specific session.
|
|
67
67
|
#
|
|
68
|
-
# @param session [
|
|
68
|
+
# @param session [VectorMCP::Session] The target session
|
|
69
69
|
# @param message [Hash] The message to send
|
|
70
70
|
# @return [Boolean] True if message was sent successfully
|
|
71
71
|
def send_message_to_session(session, message)
|
|
@@ -141,7 +141,7 @@ module VectorMCP
|
|
|
141
141
|
|
|
142
142
|
# Creates an SSE stream for a session.
|
|
143
143
|
#
|
|
144
|
-
# @param session [
|
|
144
|
+
# @param session [VectorMCP::Session] The session
|
|
145
145
|
# @param last_event_id [String, nil] The last event ID for resumability
|
|
146
146
|
# @param origin [Symbol] The stream origin (:get or :post)
|
|
147
147
|
# @return [Enumerator] SSE stream enumerator
|
|
@@ -172,7 +172,7 @@ module VectorMCP
|
|
|
172
172
|
|
|
173
173
|
# Streams events to a client.
|
|
174
174
|
#
|
|
175
|
-
# @param session [
|
|
175
|
+
# @param session [VectorMCP::Session] The session
|
|
176
176
|
# @param yielder [Enumerator::Yielder] The SSE yielder
|
|
177
177
|
# @param last_event_id [String, nil] The last event ID for resumability
|
|
178
178
|
# @param stream_id [String] The unique stream identifier
|
|
@@ -193,7 +193,7 @@ module VectorMCP
|
|
|
193
193
|
#
|
|
194
194
|
# @param yielder [Enumerator::Yielder] The SSE yielder
|
|
195
195
|
# @param last_event_id [String] The last event ID received by client
|
|
196
|
-
# @param session [
|
|
196
|
+
# @param session [VectorMCP::Session] The session to filter events for
|
|
197
197
|
# @param stream_id [String] The logical stream ID being resumed
|
|
198
198
|
# @return [void]
|
|
199
199
|
def replay_events(yielder, last_event_id, session, stream_id)
|
|
@@ -212,7 +212,7 @@ module VectorMCP
|
|
|
212
212
|
|
|
213
213
|
# Keeps the connection alive with periodic heartbeat events.
|
|
214
214
|
#
|
|
215
|
-
# @param session [
|
|
215
|
+
# @param session [VectorMCP::Session] The session
|
|
216
216
|
# @param yielder [Enumerator::Yielder] The SSE yielder
|
|
217
217
|
# @param stream_id [String] The stream ID for event storage
|
|
218
218
|
# @return [void]
|
|
@@ -274,7 +274,7 @@ module VectorMCP
|
|
|
274
274
|
|
|
275
275
|
# Cleans up a specific connection.
|
|
276
276
|
#
|
|
277
|
-
# @param session [
|
|
277
|
+
# @param session [VectorMCP::Session] The session to clean up
|
|
278
278
|
# @return [void]
|
|
279
279
|
def cleanup_connection(session, connection = nil)
|
|
280
280
|
connection ||= session.streaming_connection
|