vector_mcp 0.3.2 → 0.3.4
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 +75 -0
- data/lib/vector_mcp/definitions.rb +25 -9
- data/lib/vector_mcp/errors.rb +2 -6
- data/lib/vector_mcp/handlers/core.rb +12 -10
- data/lib/vector_mcp/image_util.rb +27 -2
- data/lib/vector_mcp/log_filter.rb +48 -0
- data/lib/vector_mcp/middleware/base.rb +1 -7
- data/lib/vector_mcp/middleware/manager.rb +3 -15
- 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 +29 -28
- data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
- 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 +21 -10
- data/lib/vector_mcp/session.rb +96 -6
- data/lib/vector_mcp/transport/base_session_manager.rb +320 -0
- data/lib/vector_mcp/transport/http_stream/event_store.rb +157 -0
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +191 -0
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +270 -0
- data/lib/vector_mcp/transport/http_stream.rb +961 -0
- data/lib/vector_mcp/transport/sse/client_connection.rb +1 -1
- data/lib/vector_mcp/transport/sse/stream_manager.rb +1 -1
- 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 +1 -0
- metadata +10 -1
|
@@ -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,157 @@
|
|
|
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, :session_id) 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
|
+
# @param session_id [String, nil] The session ID to scope this event to
|
|
48
|
+
# @return [String] The generated event ID
|
|
49
|
+
def store_event(data, type = nil, session_id: nil)
|
|
50
|
+
event_id = generate_event_id
|
|
51
|
+
timestamp = Time.now
|
|
52
|
+
|
|
53
|
+
event = Event.new(event_id, data, type, timestamp, session_id)
|
|
54
|
+
|
|
55
|
+
# Add to events array
|
|
56
|
+
@events.push(event)
|
|
57
|
+
|
|
58
|
+
# Update index
|
|
59
|
+
@event_index[event_id] = @events.length - 1
|
|
60
|
+
|
|
61
|
+
# Maintain circular buffer
|
|
62
|
+
if @events.length > @max_events
|
|
63
|
+
removed_event = @events.shift
|
|
64
|
+
@event_index.delete(removed_event.id)
|
|
65
|
+
|
|
66
|
+
# Update all indices after removal
|
|
67
|
+
@event_index.transform_values! { |index| index - 1 }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
event_id
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Retrieves events starting from a specific event ID, optionally filtered by session.
|
|
74
|
+
#
|
|
75
|
+
# @param last_event_id [String] The last event ID received by client
|
|
76
|
+
# @param session_id [String, nil] Filter events to this session only
|
|
77
|
+
# @return [Array<Event>] Array of events after the specified ID
|
|
78
|
+
def get_events_after(last_event_id, session_id: nil)
|
|
79
|
+
events = if last_event_id.nil?
|
|
80
|
+
@events.to_a
|
|
81
|
+
else
|
|
82
|
+
last_index = @event_index[last_event_id]
|
|
83
|
+
return [] if last_index.nil?
|
|
84
|
+
|
|
85
|
+
start_index = last_index + 1
|
|
86
|
+
return [] if start_index >= @events.length
|
|
87
|
+
|
|
88
|
+
@events[start_index..]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
events = events.select { |e| e.session_id == session_id } if session_id
|
|
92
|
+
events
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Gets the total number of stored events.
|
|
96
|
+
#
|
|
97
|
+
# @return [Integer] Number of events currently stored
|
|
98
|
+
def event_count
|
|
99
|
+
@events.length
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Gets the oldest event ID (for debugging/monitoring).
|
|
103
|
+
#
|
|
104
|
+
# @return [String, nil] The oldest event ID or nil if no events
|
|
105
|
+
def oldest_event_id
|
|
106
|
+
@events.first&.id
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Gets the newest event ID (for debugging/monitoring).
|
|
110
|
+
#
|
|
111
|
+
# @return [String, nil] The newest event ID or nil if no events
|
|
112
|
+
def newest_event_id
|
|
113
|
+
@events.last&.id
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Checks if an event ID exists in the store.
|
|
117
|
+
#
|
|
118
|
+
# @param event_id [String] The event ID to check
|
|
119
|
+
# @return [Boolean] True if event exists
|
|
120
|
+
def event_exists?(event_id)
|
|
121
|
+
@event_index.key?(event_id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Clears all stored events.
|
|
125
|
+
#
|
|
126
|
+
# @return [void]
|
|
127
|
+
def clear
|
|
128
|
+
@events.clear
|
|
129
|
+
@event_index.clear
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Gets statistics about the event store.
|
|
133
|
+
#
|
|
134
|
+
# @return [Hash] Statistics hash
|
|
135
|
+
def stats
|
|
136
|
+
{
|
|
137
|
+
total_events: event_count,
|
|
138
|
+
max_events: @max_events,
|
|
139
|
+
oldest_event_id: oldest_event_id,
|
|
140
|
+
newest_event_id: newest_event_id,
|
|
141
|
+
memory_usage_ratio: event_count.to_f / @max_events
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Generates a unique event ID.
|
|
148
|
+
#
|
|
149
|
+
# @return [String] A unique event ID
|
|
150
|
+
def generate_event_id
|
|
151
|
+
sequence = @current_sequence.increment
|
|
152
|
+
"#{Time.now.to_i}-#{sequence}-#{SecureRandom.hex(4)}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "concurrent-ruby"
|
|
5
|
+
require_relative "../base_session_manager"
|
|
6
|
+
|
|
7
|
+
module VectorMCP
|
|
8
|
+
module Transport
|
|
9
|
+
class HttpStream
|
|
10
|
+
# Manages HTTP stream sessions with automatic cleanup and thread safety.
|
|
11
|
+
# Extends BaseSessionManager with HTTP streaming-specific functionality.
|
|
12
|
+
#
|
|
13
|
+
# Handles:
|
|
14
|
+
# - Session creation and lifecycle management
|
|
15
|
+
# - Thread-safe session storage using concurrent-ruby
|
|
16
|
+
# - Automatic session cleanup based on timeout
|
|
17
|
+
# - Session context integration with VectorMCP::Session
|
|
18
|
+
# - HTTP streaming connection management
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
21
|
+
class SessionManager < BaseSessionManager
|
|
22
|
+
# HTTP stream session data structure extending base session
|
|
23
|
+
Session = Struct.new(:id, :context, :created_at, :last_accessed_at, :metadata) do
|
|
24
|
+
def touch!
|
|
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
|
+
|
|
36
|
+
def streaming?
|
|
37
|
+
metadata[:streaming_connection] && !metadata[:streaming_connection].nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def streaming_connection
|
|
41
|
+
metadata[:streaming_connection]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def streaming_connection=(connection)
|
|
45
|
+
metadata[:streaming_connection] = connection
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Initializes a new HTTP stream session manager.
|
|
50
|
+
#
|
|
51
|
+
# @param transport [HttpStream] The parent transport instance
|
|
52
|
+
# @param session_timeout [Integer] Session timeout in seconds
|
|
53
|
+
|
|
54
|
+
# Optimized session creation with reduced object allocation and faster context creation
|
|
55
|
+
def create_session(session_id = nil, rack_env = nil)
|
|
56
|
+
session_id ||= generate_session_id
|
|
57
|
+
now = Time.now
|
|
58
|
+
|
|
59
|
+
# Optimize session context creation - use cached minimal context when rack_env is nil
|
|
60
|
+
session_context = if rack_env
|
|
61
|
+
create_session_with_context(session_id, rack_env)
|
|
62
|
+
else
|
|
63
|
+
create_minimal_session_context(session_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Pre-allocate metadata hash for better performance
|
|
67
|
+
metadata = { streaming_connection: nil }
|
|
68
|
+
|
|
69
|
+
# Create internal session record with streaming connection metadata
|
|
70
|
+
session = Session.new(session_id, session_context, now, now, metadata)
|
|
71
|
+
|
|
72
|
+
@sessions[session_id] = session
|
|
73
|
+
|
|
74
|
+
logger.info { "Session created: #{session_id}" }
|
|
75
|
+
session
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Override to add rack_env support.
|
|
79
|
+
# Returns nil when a session_id is provided but not found (expired or unknown).
|
|
80
|
+
# Callers are responsible for returning 404 in that case.
|
|
81
|
+
def get_or_create_session(session_id = nil, rack_env = nil)
|
|
82
|
+
if session_id
|
|
83
|
+
session = get_session(session_id)
|
|
84
|
+
if session
|
|
85
|
+
# Update existing session context if rack_env is provided
|
|
86
|
+
if rack_env
|
|
87
|
+
request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
|
|
88
|
+
session.context.request_context = request_context
|
|
89
|
+
end
|
|
90
|
+
return session
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Session ID provided but not found — signal 404 to caller
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
create_session(nil, rack_env)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Creates a VectorMCP::Session with proper request context from Rack environment
|
|
101
|
+
def create_session_with_context(session_id, rack_env)
|
|
102
|
+
request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
|
|
103
|
+
VectorMCP::Session.new(@transport.server, @transport, id: session_id, request_context: request_context)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Creates a minimal session context for each session (no caching to prevent contamination)
|
|
107
|
+
def create_minimal_session_context(session_id)
|
|
108
|
+
# Create a new minimal context for each session to prevent cross-session contamination
|
|
109
|
+
minimal_context = VectorMCP::RequestContext.minimal("http_stream")
|
|
110
|
+
VectorMCP::Session.new(@transport.server, @transport, id: session_id, request_context: minimal_context)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Terminates a session by ID.
|
|
114
|
+
#
|
|
115
|
+
# @param session_id [String] The session ID to terminate
|
|
116
|
+
# @return [Boolean] True if session was found and terminated
|
|
117
|
+
# rubocop:disable Naming/PredicateMethod
|
|
118
|
+
def terminate_session(session_id)
|
|
119
|
+
session = @sessions.delete(session_id)
|
|
120
|
+
return false unless session
|
|
121
|
+
|
|
122
|
+
on_session_terminated(session)
|
|
123
|
+
logger.info { "Session terminated: #{session_id}" }
|
|
124
|
+
true
|
|
125
|
+
end
|
|
126
|
+
# rubocop:enable Naming/PredicateMethod
|
|
127
|
+
|
|
128
|
+
# Associates a streaming connection with a session.
|
|
129
|
+
#
|
|
130
|
+
# @param session [Session] The session to associate with
|
|
131
|
+
# @param connection [Object] The streaming connection object
|
|
132
|
+
# @return [void]
|
|
133
|
+
def set_streaming_connection(session, connection)
|
|
134
|
+
session.streaming_connection = connection
|
|
135
|
+
session.touch!
|
|
136
|
+
logger.debug { "Streaming connection associated: #{session.id}" }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Removes streaming connection from a session.
|
|
140
|
+
#
|
|
141
|
+
# @param session [Session] The session to remove streaming from
|
|
142
|
+
# @return [void]
|
|
143
|
+
def remove_streaming_connection(session)
|
|
144
|
+
session.streaming_connection = nil
|
|
145
|
+
session.touch!
|
|
146
|
+
logger.debug { "Streaming connection removed: #{session.id}" }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
protected
|
|
150
|
+
|
|
151
|
+
# Override: Called when a session is terminated to clean up streaming connections.
|
|
152
|
+
def on_session_terminated(session)
|
|
153
|
+
close_streaming_connection(session)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Override: Returns metadata for new HTTP stream sessions.
|
|
157
|
+
def create_session_metadata
|
|
158
|
+
{ streaming_connection: nil }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Override: Checks if a session can receive messages (has streaming connection).
|
|
162
|
+
def can_send_message_to_session?(session)
|
|
163
|
+
session.streaming?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Override: Sends a message to a session via the stream handler.
|
|
167
|
+
def message_sent_to_session?(session, message)
|
|
168
|
+
@transport.stream_handler.send_message_to_session(session, message)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# Closes a session's streaming connection if it exists.
|
|
174
|
+
#
|
|
175
|
+
# @param session [Session] The session whose connection to close
|
|
176
|
+
# @return [void]
|
|
177
|
+
def close_streaming_connection(session)
|
|
178
|
+
return unless session&.streaming_connection
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
session.streaming_connection.close
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
logger.warn { "Error closing streaming connection for #{session.id}: #{e.message}" }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
session.streaming_connection = nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|