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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/lib/vector_mcp/definitions.rb +25 -9
  4. data/lib/vector_mcp/errors.rb +2 -6
  5. data/lib/vector_mcp/handlers/core.rb +12 -10
  6. data/lib/vector_mcp/image_util.rb +27 -2
  7. data/lib/vector_mcp/log_filter.rb +48 -0
  8. data/lib/vector_mcp/middleware/base.rb +1 -7
  9. data/lib/vector_mcp/middleware/manager.rb +3 -15
  10. data/lib/vector_mcp/request_context.rb +182 -0
  11. data/lib/vector_mcp/sampling/result.rb +11 -1
  12. data/lib/vector_mcp/security/middleware.rb +2 -28
  13. data/lib/vector_mcp/security/strategies/api_key.rb +29 -28
  14. data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
  15. data/lib/vector_mcp/server/capabilities.rb +5 -7
  16. data/lib/vector_mcp/server/message_handling.rb +11 -5
  17. data/lib/vector_mcp/server.rb +21 -10
  18. data/lib/vector_mcp/session.rb +96 -6
  19. data/lib/vector_mcp/transport/base_session_manager.rb +320 -0
  20. data/lib/vector_mcp/transport/http_stream/event_store.rb +157 -0
  21. data/lib/vector_mcp/transport/http_stream/session_manager.rb +191 -0
  22. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +270 -0
  23. data/lib/vector_mcp/transport/http_stream.rb +961 -0
  24. data/lib/vector_mcp/transport/sse/client_connection.rb +1 -1
  25. data/lib/vector_mcp/transport/sse/stream_manager.rb +1 -1
  26. data/lib/vector_mcp/transport/sse.rb +74 -19
  27. data/lib/vector_mcp/transport/sse_session_manager.rb +188 -0
  28. data/lib/vector_mcp/transport/stdio.rb +70 -13
  29. data/lib/vector_mcp/transport/stdio_session_manager.rb +181 -0
  30. data/lib/vector_mcp/util.rb +39 -1
  31. data/lib/vector_mcp/version.rb +1 -1
  32. data/lib/vector_mcp.rb +1 -0
  33. 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