vector_mcp 0.3.1 → 0.3.3
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 +122 -0
- data/lib/vector_mcp/definitions.rb +25 -9
- data/lib/vector_mcp/errors.rb +2 -3
- data/lib/vector_mcp/handlers/core.rb +206 -50
- data/lib/vector_mcp/logger.rb +148 -0
- data/lib/vector_mcp/middleware/base.rb +171 -0
- data/lib/vector_mcp/middleware/context.rb +76 -0
- data/lib/vector_mcp/middleware/hook.rb +169 -0
- data/lib/vector_mcp/middleware/manager.rb +179 -0
- data/lib/vector_mcp/middleware.rb +43 -0
- data/lib/vector_mcp/request_context.rb +182 -0
- data/lib/vector_mcp/sampling/result.rb +11 -1
- data/lib/vector_mcp/security/middleware.rb +2 -28
- data/lib/vector_mcp/security/strategies/api_key.rb +2 -24
- data/lib/vector_mcp/security/strategies/jwt_token.rb +6 -3
- data/lib/vector_mcp/server/capabilities.rb +5 -7
- data/lib/vector_mcp/server/message_handling.rb +11 -5
- data/lib/vector_mcp/server.rb +74 -20
- data/lib/vector_mcp/session.rb +131 -8
- data/lib/vector_mcp/transport/base_session_manager.rb +320 -0
- data/lib/vector_mcp/transport/http_stream/event_store.rb +151 -0
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +189 -0
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +269 -0
- data/lib/vector_mcp/transport/http_stream.rb +779 -0
- data/lib/vector_mcp/transport/sse.rb +74 -19
- data/lib/vector_mcp/transport/sse_session_manager.rb +188 -0
- data/lib/vector_mcp/transport/stdio.rb +70 -13
- data/lib/vector_mcp/transport/stdio_session_manager.rb +181 -0
- data/lib/vector_mcp/util.rb +39 -1
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +10 -35
- metadata +25 -24
- data/lib/vector_mcp/logging/component.rb +0 -131
- data/lib/vector_mcp/logging/configuration.rb +0 -156
- data/lib/vector_mcp/logging/constants.rb +0 -21
- data/lib/vector_mcp/logging/core.rb +0 -175
- data/lib/vector_mcp/logging/filters/component.rb +0 -69
- data/lib/vector_mcp/logging/filters/level.rb +0 -23
- data/lib/vector_mcp/logging/formatters/base.rb +0 -52
- data/lib/vector_mcp/logging/formatters/json.rb +0 -83
- data/lib/vector_mcp/logging/formatters/text.rb +0 -72
- data/lib/vector_mcp/logging/outputs/base.rb +0 -64
- data/lib/vector_mcp/logging/outputs/console.rb +0 -35
- data/lib/vector_mcp/logging/outputs/file.rb +0 -157
- data/lib/vector_mcp/logging.rb +0 -71
data/lib/vector_mcp/session.rb
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
require_relative "sampling/request"
|
4
4
|
require_relative "sampling/result"
|
5
5
|
require_relative "errors"
|
6
|
+
require_relative "request_context"
|
6
7
|
|
7
8
|
module VectorMCP
|
8
9
|
# Represents the state of a single client-server connection session in MCP.
|
@@ -13,8 +14,9 @@ module VectorMCP
|
|
13
14
|
# @attr_reader protocol_version [String] The MCP protocol version used by the server.
|
14
15
|
# @attr_reader client_info [Hash, nil] Information about the client, received during initialization.
|
15
16
|
# @attr_reader client_capabilities [Hash, nil] Capabilities supported by the client, received during initialization.
|
17
|
+
# @attr_reader request_context [RequestContext] The request context for this session.
|
16
18
|
class Session
|
17
|
-
attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id
|
19
|
+
attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id, :request_context
|
18
20
|
attr_accessor :data # For user-defined session-specific storage
|
19
21
|
|
20
22
|
# Initializes a new session.
|
@@ -22,7 +24,8 @@ module VectorMCP
|
|
22
24
|
# @param server [VectorMCP::Server] The server instance managing this session.
|
23
25
|
# @param transport [VectorMCP::Transport::Base, nil] The transport handling this session. Required for sampling.
|
24
26
|
# @param id [String] A unique identifier for this session (e.g., from transport layer).
|
25
|
-
|
27
|
+
# @param request_context [RequestContext, Hash, nil] The request context for this session.
|
28
|
+
def initialize(server, transport = nil, id: SecureRandom.uuid, request_context: nil)
|
26
29
|
@server = server
|
27
30
|
@transport = transport # Store the transport for sending requests
|
28
31
|
@id = id
|
@@ -31,6 +34,16 @@ module VectorMCP
|
|
31
34
|
@client_capabilities = nil
|
32
35
|
@data = {} # Initialize user data hash
|
33
36
|
@logger = server.logger
|
37
|
+
|
38
|
+
# Initialize request context
|
39
|
+
@request_context = case request_context
|
40
|
+
when RequestContext
|
41
|
+
request_context
|
42
|
+
when Hash
|
43
|
+
RequestContext.new(**request_context)
|
44
|
+
else
|
45
|
+
RequestContext.new
|
46
|
+
end
|
34
47
|
end
|
35
48
|
|
36
49
|
# Marks the session as initialized using parameters from the client's `initialize` request.
|
@@ -75,6 +88,75 @@ module VectorMCP
|
|
75
88
|
@initialized_state == :succeeded
|
76
89
|
end
|
77
90
|
|
91
|
+
# Sets the request context for this session.
|
92
|
+
# This method should be called by transport layers to populate request-specific data.
|
93
|
+
#
|
94
|
+
# @param context [RequestContext, Hash] The request context to set.
|
95
|
+
# Can be a RequestContext object or a hash of attributes.
|
96
|
+
# @return [RequestContext] The newly set request context.
|
97
|
+
# @raise [ArgumentError] If the context is not a RequestContext or Hash.
|
98
|
+
def request_context=(context)
|
99
|
+
@request_context = case context
|
100
|
+
when RequestContext
|
101
|
+
context
|
102
|
+
when Hash
|
103
|
+
RequestContext.new(**context)
|
104
|
+
else
|
105
|
+
raise ArgumentError, "Request context must be a RequestContext or Hash, got #{context.class}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Updates the request context with new data.
|
110
|
+
# This merges the provided attributes with the existing context.
|
111
|
+
#
|
112
|
+
# @param attributes [Hash] The attributes to merge into the request context.
|
113
|
+
# @return [RequestContext] The updated request context.
|
114
|
+
def update_request_context(**attributes)
|
115
|
+
current_attrs = @request_context.to_h
|
116
|
+
|
117
|
+
# Deep merge nested hashes like headers and params
|
118
|
+
merged_attrs = current_attrs.dup
|
119
|
+
attributes.each do |key, value|
|
120
|
+
merged_attrs[key] = if value.is_a?(Hash) && current_attrs[key].is_a?(Hash)
|
121
|
+
current_attrs[key].merge(value)
|
122
|
+
else
|
123
|
+
value
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
@request_context = RequestContext.new(**merged_attrs)
|
128
|
+
end
|
129
|
+
|
130
|
+
# Convenience method to check if the session has request headers.
|
131
|
+
#
|
132
|
+
# @return [Boolean] True if the request context has headers, false otherwise.
|
133
|
+
def request_headers?
|
134
|
+
@request_context.headers?
|
135
|
+
end
|
136
|
+
|
137
|
+
# Convenience method to check if the session has request parameters.
|
138
|
+
#
|
139
|
+
# @return [Boolean] True if the request context has parameters, false otherwise.
|
140
|
+
def request_params?
|
141
|
+
@request_context.params?
|
142
|
+
end
|
143
|
+
|
144
|
+
# Convenience method to get a request header value.
|
145
|
+
#
|
146
|
+
# @param name [String] The header name.
|
147
|
+
# @return [String, nil] The header value or nil if not found.
|
148
|
+
def request_header(name)
|
149
|
+
@request_context.header(name)
|
150
|
+
end
|
151
|
+
|
152
|
+
# Convenience method to get a request parameter value.
|
153
|
+
#
|
154
|
+
# @param name [String] The parameter name.
|
155
|
+
# @return [String, nil] The parameter value or nil if not found.
|
156
|
+
def request_param(name)
|
157
|
+
@request_context.param(name)
|
158
|
+
end
|
159
|
+
|
78
160
|
# Helper to check client capabilities later if needed
|
79
161
|
# def supports?(capability_key)
|
80
162
|
# @client_capabilities.key?(capability_key.to_s)
|
@@ -95,10 +177,43 @@ module VectorMCP
|
|
95
177
|
def sample(request_params, timeout: nil)
|
96
178
|
validate_sampling_preconditions
|
97
179
|
|
98
|
-
|
99
|
-
|
180
|
+
# Create middleware context for sampling
|
181
|
+
context = VectorMCP::Middleware::Context.new(
|
182
|
+
operation_type: :sampling,
|
183
|
+
operation_name: "createMessage",
|
184
|
+
params: request_params,
|
185
|
+
session: self,
|
186
|
+
server: @server,
|
187
|
+
metadata: { start_time: Time.now, timeout: timeout }
|
188
|
+
)
|
189
|
+
|
190
|
+
# Execute before_sampling_request hooks
|
191
|
+
context = @server.middleware_manager.execute_hooks(:before_sampling_request, context)
|
192
|
+
raise context.error if context.error?
|
193
|
+
|
194
|
+
begin
|
195
|
+
sampling_req_obj = VectorMCP::Sampling::Request.new(request_params)
|
196
|
+
@logger.debug("[Session #{@id}] Sending sampling/createMessage request to client.")
|
100
197
|
|
101
|
-
|
198
|
+
result = send_sampling_request(sampling_req_obj, timeout)
|
199
|
+
|
200
|
+
# Set result in context
|
201
|
+
context.result = result
|
202
|
+
|
203
|
+
# Execute after_sampling_response hooks
|
204
|
+
context = @server.middleware_manager.execute_hooks(:after_sampling_response, context)
|
205
|
+
|
206
|
+
context.result
|
207
|
+
rescue StandardError => e
|
208
|
+
# Set error in context and execute error hooks
|
209
|
+
context.error = e
|
210
|
+
context = @server.middleware_manager.execute_hooks(:on_sampling_error, context)
|
211
|
+
|
212
|
+
# Re-raise unless middleware handled the error
|
213
|
+
raise e unless context.result
|
214
|
+
|
215
|
+
context.result
|
216
|
+
end
|
102
217
|
end
|
103
218
|
|
104
219
|
private
|
@@ -128,17 +243,25 @@ module VectorMCP
|
|
128
243
|
send_request_kwargs = {}
|
129
244
|
send_request_kwargs[:timeout] = timeout if timeout
|
130
245
|
|
131
|
-
|
246
|
+
# For HTTP transport, we need to use send_request_to_session to target this specific session
|
247
|
+
raw_result = if @transport.respond_to?(:send_request_to_session)
|
248
|
+
@transport.send_request_to_session(@id, *send_request_args, **send_request_kwargs)
|
249
|
+
else
|
250
|
+
# Fallback to generic send_request for other transports
|
251
|
+
@transport.send_request(*send_request_args, **send_request_kwargs)
|
252
|
+
end
|
253
|
+
|
132
254
|
VectorMCP::Sampling::Result.new(raw_result)
|
133
255
|
rescue ArgumentError => e
|
134
256
|
@logger.error("[Session #{@id}] Invalid parameters for sampling request or result: #{e.message}")
|
135
|
-
raise VectorMCP::SamplingError
|
257
|
+
raise VectorMCP::SamplingError.new("Invalid sampling parameters or malformed client response: #{e.message}",
|
258
|
+
details: { original_error: e.to_s })
|
136
259
|
rescue VectorMCP::SamplingError => e
|
137
260
|
@logger.warn("[Session #{@id}] Sampling request failed: #{e.message}")
|
138
261
|
raise e
|
139
262
|
rescue StandardError => e
|
140
263
|
@logger.error("[Session #{@id}] Unexpected error during sampling: #{e.class.name}: #{e.message}")
|
141
|
-
raise VectorMCP::SamplingError
|
264
|
+
raise VectorMCP::SamplingError.new("An unexpected error occurred during sampling: #{e.message}", details: { original_error: e.to_s })
|
142
265
|
end
|
143
266
|
end
|
144
267
|
end
|
@@ -0,0 +1,320 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require "concurrent-ruby"
|
5
|
+
|
6
|
+
module VectorMCP
|
7
|
+
module Transport
|
8
|
+
# Base session manager providing unified session lifecycle management across all transports.
|
9
|
+
# This abstract base class defines the standard interface that all transport session managers
|
10
|
+
# should implement, ensuring consistent session handling regardless of transport type.
|
11
|
+
#
|
12
|
+
# @abstract Subclass and implement transport-specific methods
|
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
|
+
attr_reader :transport, :session_timeout, :logger
|
30
|
+
|
31
|
+
# Initializes a new session manager.
|
32
|
+
#
|
33
|
+
# @param transport [Object] The parent transport instance
|
34
|
+
# @param session_timeout [Integer] Session timeout in seconds (default: 300)
|
35
|
+
def initialize(transport, session_timeout = 300)
|
36
|
+
@transport = transport
|
37
|
+
@session_timeout = session_timeout
|
38
|
+
@logger = transport.logger
|
39
|
+
@sessions = Concurrent::Hash.new
|
40
|
+
@cleanup_timer = nil
|
41
|
+
|
42
|
+
start_cleanup_timer if auto_cleanup_enabled?
|
43
|
+
logger.debug { "#{self.class.name} initialized with session_timeout: #{session_timeout}" }
|
44
|
+
end
|
45
|
+
|
46
|
+
# Gets an existing session by ID.
|
47
|
+
#
|
48
|
+
# @param session_id [String] The session ID
|
49
|
+
# @return [Session, nil] The session if found and valid
|
50
|
+
def get_session(session_id)
|
51
|
+
return nil unless session_id
|
52
|
+
|
53
|
+
session = @sessions[session_id]
|
54
|
+
return nil unless session && !session.expired?(@session_timeout)
|
55
|
+
|
56
|
+
session.touch!
|
57
|
+
session
|
58
|
+
end
|
59
|
+
|
60
|
+
# Gets an existing session or creates a new one.
|
61
|
+
#
|
62
|
+
# @param session_id [String, nil] The session ID (optional)
|
63
|
+
# @return [Session] The existing or newly created session
|
64
|
+
def get_or_create_session(session_id = nil)
|
65
|
+
if session_id
|
66
|
+
session = get_session(session_id)
|
67
|
+
return session if session
|
68
|
+
|
69
|
+
# If session_id was provided but not found, create with that ID
|
70
|
+
return create_session(session_id)
|
71
|
+
end
|
72
|
+
|
73
|
+
create_session
|
74
|
+
end
|
75
|
+
|
76
|
+
# Creates a new session.
|
77
|
+
#
|
78
|
+
# @param session_id [String, nil] Optional specific session ID to use
|
79
|
+
# @return [Session] The newly created session
|
80
|
+
def create_session(session_id = nil)
|
81
|
+
session_id ||= generate_session_id
|
82
|
+
now = Time.now
|
83
|
+
|
84
|
+
# Create VectorMCP session context
|
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
|
+
)
|
95
|
+
|
96
|
+
@sessions[session_id] = session
|
97
|
+
|
98
|
+
logger.info { "Session created: #{session_id}" }
|
99
|
+
on_session_created(session)
|
100
|
+
session
|
101
|
+
end
|
102
|
+
|
103
|
+
# Terminates a session by ID.
|
104
|
+
#
|
105
|
+
# @param session_id [String] The session ID to terminate
|
106
|
+
# @return [Boolean] True if session was found and terminated
|
107
|
+
def session_terminated?(session_id)
|
108
|
+
session = @sessions.delete(session_id)
|
109
|
+
return false unless session
|
110
|
+
|
111
|
+
on_session_terminated(session)
|
112
|
+
logger.info { "Session terminated: #{session_id}" }
|
113
|
+
true
|
114
|
+
end
|
115
|
+
|
116
|
+
# Gets the current number of active sessions.
|
117
|
+
#
|
118
|
+
# @return [Integer] Number of active sessions
|
119
|
+
def session_count
|
120
|
+
@sessions.size
|
121
|
+
end
|
122
|
+
|
123
|
+
# Gets all active session IDs.
|
124
|
+
#
|
125
|
+
# @return [Array<String>] Array of session IDs
|
126
|
+
def active_session_ids
|
127
|
+
@sessions.keys
|
128
|
+
end
|
129
|
+
|
130
|
+
# Checks if any sessions exist.
|
131
|
+
#
|
132
|
+
# @return [Boolean] True if at least one session exists
|
133
|
+
def sessions?
|
134
|
+
!@sessions.empty?
|
135
|
+
end
|
136
|
+
|
137
|
+
# Cleans up all sessions and stops the cleanup timer.
|
138
|
+
#
|
139
|
+
# @return [void]
|
140
|
+
def cleanup_all_sessions
|
141
|
+
logger.info { "Cleaning up all sessions: #{@sessions.size}" }
|
142
|
+
|
143
|
+
@sessions.each_value do |session|
|
144
|
+
on_session_terminated(session)
|
145
|
+
end
|
146
|
+
|
147
|
+
@sessions.clear
|
148
|
+
stop_cleanup_timer
|
149
|
+
end
|
150
|
+
|
151
|
+
# Updates session metadata.
|
152
|
+
#
|
153
|
+
# @param session_id [String] The session ID
|
154
|
+
# @param metadata [Hash] Metadata to merge
|
155
|
+
# @return [Boolean] True if session was found and updated
|
156
|
+
def session_metadata_updated?(session_id, metadata)
|
157
|
+
session = @sessions[session_id]
|
158
|
+
return false unless session
|
159
|
+
|
160
|
+
session.metadata.merge!(metadata)
|
161
|
+
session.touch!
|
162
|
+
true
|
163
|
+
end
|
164
|
+
|
165
|
+
# Gets session metadata.
|
166
|
+
#
|
167
|
+
# @param session_id [String] The session ID
|
168
|
+
# @return [Hash, nil] Session metadata or nil if session not found
|
169
|
+
def get_session_metadata(session_id)
|
170
|
+
session = @sessions[session_id]
|
171
|
+
session&.metadata
|
172
|
+
end
|
173
|
+
|
174
|
+
# Finds sessions matching criteria.
|
175
|
+
#
|
176
|
+
# @param criteria [Hash] Search criteria
|
177
|
+
# @option criteria [Symbol] :created_after Time to search after
|
178
|
+
# @option criteria [Symbol] :metadata Hash of metadata to match
|
179
|
+
# @return [Array<Session>] Matching sessions
|
180
|
+
def find_sessions(criteria = {})
|
181
|
+
@sessions.values.select do |session|
|
182
|
+
matches_criteria?(session, criteria)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
# Broadcasts a message to all sessions that support messaging.
|
187
|
+
#
|
188
|
+
# @param message [Hash] The message to broadcast
|
189
|
+
# @return [Integer] Number of sessions the message was sent to
|
190
|
+
def broadcast_message(message)
|
191
|
+
count = 0
|
192
|
+
@sessions.each_value do |session|
|
193
|
+
next unless can_send_message_to_session?(session)
|
194
|
+
|
195
|
+
count += 1 if message_sent_to_session?(session, message)
|
196
|
+
end
|
197
|
+
|
198
|
+
# Message broadcasted to recipients
|
199
|
+
count
|
200
|
+
end
|
201
|
+
|
202
|
+
protected
|
203
|
+
|
204
|
+
# Hook called when a session is created. Override in subclasses for transport-specific logic.
|
205
|
+
#
|
206
|
+
# @param session [Session] The newly created session
|
207
|
+
# @return [void]
|
208
|
+
def on_session_created(session)
|
209
|
+
# Override in subclasses
|
210
|
+
end
|
211
|
+
|
212
|
+
# Hook called when a session is terminated. Override in subclasses for transport-specific cleanup.
|
213
|
+
#
|
214
|
+
# @param session [Session] The session being terminated
|
215
|
+
# @return [void]
|
216
|
+
def on_session_terminated(session)
|
217
|
+
# Override in subclasses
|
218
|
+
end
|
219
|
+
|
220
|
+
# Creates transport-specific session metadata. Override in subclasses.
|
221
|
+
#
|
222
|
+
# @return [Hash] Initial metadata for the session
|
223
|
+
def create_session_metadata
|
224
|
+
{}
|
225
|
+
end
|
226
|
+
|
227
|
+
# Determines if this session manager should enable automatic cleanup.
|
228
|
+
# Override in subclasses that don't need automatic cleanup (e.g., stdio with single session).
|
229
|
+
#
|
230
|
+
# @return [Boolean] True if auto-cleanup should be enabled
|
231
|
+
def auto_cleanup_enabled?
|
232
|
+
true
|
233
|
+
end
|
234
|
+
|
235
|
+
# Checks if a message can be sent to the given session.
|
236
|
+
# Override in subclasses based on transport capabilities.
|
237
|
+
#
|
238
|
+
# @param session [Session] The session to check
|
239
|
+
# @return [Boolean] True if messaging is supported for this session
|
240
|
+
def can_send_message_to_session?(_session)
|
241
|
+
false # Override in subclasses
|
242
|
+
end
|
243
|
+
|
244
|
+
# Sends a message to a specific session.
|
245
|
+
# Override in subclasses based on transport messaging mechanism.
|
246
|
+
#
|
247
|
+
# @param session [Session] The target session
|
248
|
+
# @param message [Hash] The message to send
|
249
|
+
# @return [Boolean] True if message was sent successfully
|
250
|
+
def message_sent_to_session?(_session, _message)
|
251
|
+
false # Override in subclasses
|
252
|
+
end
|
253
|
+
|
254
|
+
private
|
255
|
+
|
256
|
+
# Generates a cryptographically secure session ID.
|
257
|
+
#
|
258
|
+
# @return [String] A unique session ID
|
259
|
+
def generate_session_id
|
260
|
+
SecureRandom.uuid
|
261
|
+
end
|
262
|
+
|
263
|
+
# Starts the automatic cleanup timer if auto-cleanup is enabled.
|
264
|
+
#
|
265
|
+
# @return [void]
|
266
|
+
def start_cleanup_timer
|
267
|
+
return unless auto_cleanup_enabled?
|
268
|
+
|
269
|
+
# Run cleanup every 60 seconds
|
270
|
+
@cleanup_timer = Concurrent::TimerTask.new(execution_interval: 60) do
|
271
|
+
cleanup_expired_sessions
|
272
|
+
end
|
273
|
+
@cleanup_timer.execute
|
274
|
+
end
|
275
|
+
|
276
|
+
# Stops the automatic cleanup timer.
|
277
|
+
#
|
278
|
+
# @return [void]
|
279
|
+
def stop_cleanup_timer
|
280
|
+
@cleanup_timer&.shutdown
|
281
|
+
@cleanup_timer = nil
|
282
|
+
end
|
283
|
+
|
284
|
+
# Cleans up expired sessions.
|
285
|
+
#
|
286
|
+
# @return [void]
|
287
|
+
def cleanup_expired_sessions
|
288
|
+
expired_sessions = []
|
289
|
+
|
290
|
+
@sessions.each do |session_id, session|
|
291
|
+
expired_sessions << session_id if session.expired?(@session_timeout)
|
292
|
+
end
|
293
|
+
|
294
|
+
expired_sessions.each do |session_id|
|
295
|
+
session = @sessions.delete(session_id)
|
296
|
+
on_session_terminated(session) if session
|
297
|
+
end
|
298
|
+
|
299
|
+
return unless expired_sessions.any?
|
300
|
+
|
301
|
+
logger.debug { "Cleaned up expired sessions: #{expired_sessions.size}" }
|
302
|
+
end
|
303
|
+
|
304
|
+
# Checks if a session matches the given criteria.
|
305
|
+
#
|
306
|
+
# @param session [Session] The session to check
|
307
|
+
# @param criteria [Hash] The search criteria
|
308
|
+
# @return [Boolean] True if session matches all criteria
|
309
|
+
def matches_criteria?(session, criteria)
|
310
|
+
return false if criteria[:created_after] && session.created_at <= criteria[:created_after]
|
311
|
+
|
312
|
+
criteria[:metadata]&.each do |key, value|
|
313
|
+
return false unless session.metadata[key] == value
|
314
|
+
end
|
315
|
+
|
316
|
+
true
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
320
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent-ruby"
|
4
|
+
require "securerandom"
|
5
|
+
|
6
|
+
module VectorMCP
|
7
|
+
module Transport
|
8
|
+
class HttpStream
|
9
|
+
# Manages Server-Sent Events storage for resumable connections.
|
10
|
+
#
|
11
|
+
# Handles:
|
12
|
+
# - Event storage with unique IDs
|
13
|
+
# - Event replay from a specific Last-Event-ID
|
14
|
+
# - Circular buffer for memory efficiency
|
15
|
+
# - Thread-safe operations
|
16
|
+
#
|
17
|
+
# @api private
|
18
|
+
class EventStore
|
19
|
+
# Event data structure
|
20
|
+
Event = Struct.new(:id, :data, :type, :timestamp) do
|
21
|
+
def to_sse_format
|
22
|
+
lines = []
|
23
|
+
lines << "id: #{id}"
|
24
|
+
lines << "event: #{type}" if type
|
25
|
+
lines << "data: #{data}"
|
26
|
+
lines << ""
|
27
|
+
lines.join("\n")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
attr_reader :max_events, :logger
|
32
|
+
|
33
|
+
# Initializes a new event store.
|
34
|
+
#
|
35
|
+
# @param max_events [Integer] Maximum number of events to retain
|
36
|
+
def initialize(max_events)
|
37
|
+
@max_events = max_events
|
38
|
+
@events = Concurrent::Array.new
|
39
|
+
@event_index = Concurrent::Hash.new # event_id -> index for fast lookup
|
40
|
+
@current_sequence = Concurrent::AtomicFixnum.new(0)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Stores a new event and returns its ID.
|
44
|
+
#
|
45
|
+
# @param data [String] The event data
|
46
|
+
# @param type [String] The event type (optional)
|
47
|
+
# @return [String] The generated event ID
|
48
|
+
def store_event(data, type = nil)
|
49
|
+
event_id = generate_event_id
|
50
|
+
timestamp = Time.now
|
51
|
+
|
52
|
+
event = Event.new(event_id, data, type, timestamp)
|
53
|
+
|
54
|
+
# Add to events array
|
55
|
+
@events.push(event)
|
56
|
+
|
57
|
+
# Update index
|
58
|
+
@event_index[event_id] = @events.length - 1
|
59
|
+
|
60
|
+
# Maintain circular buffer
|
61
|
+
if @events.length > @max_events
|
62
|
+
removed_event = @events.shift
|
63
|
+
@event_index.delete(removed_event.id)
|
64
|
+
|
65
|
+
# Update all indices after removal
|
66
|
+
@event_index.transform_values! { |index| index - 1 }
|
67
|
+
end
|
68
|
+
|
69
|
+
event_id
|
70
|
+
end
|
71
|
+
|
72
|
+
# Retrieves events starting from a specific event ID.
|
73
|
+
#
|
74
|
+
# @param last_event_id [String] The last event ID received by client
|
75
|
+
# @return [Array<Event>] Array of events after the specified ID
|
76
|
+
def get_events_after(last_event_id)
|
77
|
+
return @events.to_a if last_event_id.nil?
|
78
|
+
|
79
|
+
last_index = @event_index[last_event_id]
|
80
|
+
return [] if last_index.nil?
|
81
|
+
|
82
|
+
# Return events after the last_event_id
|
83
|
+
start_index = last_index + 1
|
84
|
+
return [] if start_index >= @events.length
|
85
|
+
|
86
|
+
@events[start_index..]
|
87
|
+
end
|
88
|
+
|
89
|
+
# Gets the total number of stored events.
|
90
|
+
#
|
91
|
+
# @return [Integer] Number of events currently stored
|
92
|
+
def event_count
|
93
|
+
@events.length
|
94
|
+
end
|
95
|
+
|
96
|
+
# Gets the oldest event ID (for debugging/monitoring).
|
97
|
+
#
|
98
|
+
# @return [String, nil] The oldest event ID or nil if no events
|
99
|
+
def oldest_event_id
|
100
|
+
@events.first&.id
|
101
|
+
end
|
102
|
+
|
103
|
+
# Gets the newest event ID (for debugging/monitoring).
|
104
|
+
#
|
105
|
+
# @return [String, nil] The newest event ID or nil if no events
|
106
|
+
def newest_event_id
|
107
|
+
@events.last&.id
|
108
|
+
end
|
109
|
+
|
110
|
+
# Checks if an event ID exists in the store.
|
111
|
+
#
|
112
|
+
# @param event_id [String] The event ID to check
|
113
|
+
# @return [Boolean] True if event exists
|
114
|
+
def event_exists?(event_id)
|
115
|
+
@event_index.key?(event_id)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Clears all stored events.
|
119
|
+
#
|
120
|
+
# @return [void]
|
121
|
+
def clear
|
122
|
+
@events.clear
|
123
|
+
@event_index.clear
|
124
|
+
end
|
125
|
+
|
126
|
+
# Gets statistics about the event store.
|
127
|
+
#
|
128
|
+
# @return [Hash] Statistics hash
|
129
|
+
def stats
|
130
|
+
{
|
131
|
+
total_events: event_count,
|
132
|
+
max_events: @max_events,
|
133
|
+
oldest_event_id: oldest_event_id,
|
134
|
+
newest_event_id: newest_event_id,
|
135
|
+
memory_usage_ratio: event_count.to_f / @max_events
|
136
|
+
}
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
# Generates a unique event ID.
|
142
|
+
#
|
143
|
+
# @return [String] A unique event ID
|
144
|
+
def generate_event_id
|
145
|
+
sequence = @current_sequence.increment
|
146
|
+
"#{Time.now.to_i}-#{sequence}-#{SecureRandom.hex(4)}"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|