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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/lib/vector_mcp/definitions.rb +25 -9
  4. data/lib/vector_mcp/errors.rb +2 -3
  5. data/lib/vector_mcp/handlers/core.rb +206 -50
  6. data/lib/vector_mcp/logger.rb +148 -0
  7. data/lib/vector_mcp/middleware/base.rb +171 -0
  8. data/lib/vector_mcp/middleware/context.rb +76 -0
  9. data/lib/vector_mcp/middleware/hook.rb +169 -0
  10. data/lib/vector_mcp/middleware/manager.rb +179 -0
  11. data/lib/vector_mcp/middleware.rb +43 -0
  12. data/lib/vector_mcp/request_context.rb +182 -0
  13. data/lib/vector_mcp/sampling/result.rb +11 -1
  14. data/lib/vector_mcp/security/middleware.rb +2 -28
  15. data/lib/vector_mcp/security/strategies/api_key.rb +2 -24
  16. data/lib/vector_mcp/security/strategies/jwt_token.rb +6 -3
  17. data/lib/vector_mcp/server/capabilities.rb +5 -7
  18. data/lib/vector_mcp/server/message_handling.rb +11 -5
  19. data/lib/vector_mcp/server.rb +74 -20
  20. data/lib/vector_mcp/session.rb +131 -8
  21. data/lib/vector_mcp/transport/base_session_manager.rb +320 -0
  22. data/lib/vector_mcp/transport/http_stream/event_store.rb +151 -0
  23. data/lib/vector_mcp/transport/http_stream/session_manager.rb +189 -0
  24. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +269 -0
  25. data/lib/vector_mcp/transport/http_stream.rb +779 -0
  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 +10 -35
  33. metadata +25 -24
  34. data/lib/vector_mcp/logging/component.rb +0 -131
  35. data/lib/vector_mcp/logging/configuration.rb +0 -156
  36. data/lib/vector_mcp/logging/constants.rb +0 -21
  37. data/lib/vector_mcp/logging/core.rb +0 -175
  38. data/lib/vector_mcp/logging/filters/component.rb +0 -69
  39. data/lib/vector_mcp/logging/filters/level.rb +0 -23
  40. data/lib/vector_mcp/logging/formatters/base.rb +0 -52
  41. data/lib/vector_mcp/logging/formatters/json.rb +0 -83
  42. data/lib/vector_mcp/logging/formatters/text.rb +0 -72
  43. data/lib/vector_mcp/logging/outputs/base.rb +0 -64
  44. data/lib/vector_mcp/logging/outputs/console.rb +0 -35
  45. data/lib/vector_mcp/logging/outputs/file.rb +0 -157
  46. data/lib/vector_mcp/logging.rb +0 -71
@@ -0,0 +1,189 @@
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
+ def get_or_create_session(session_id = nil, rack_env = nil)
80
+ if session_id
81
+ session = get_session(session_id)
82
+ if session
83
+ # Update existing session context if rack_env is provided
84
+ if rack_env
85
+ request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
86
+ session.context.request_context = request_context
87
+ end
88
+ return session
89
+ end
90
+
91
+ # If session_id was provided but not found, create with that ID
92
+ return create_session(session_id, rack_env)
93
+ end
94
+
95
+ create_session(nil, rack_env)
96
+ end
97
+
98
+ # Creates a VectorMCP::Session with proper request context from Rack environment
99
+ def create_session_with_context(session_id, rack_env)
100
+ request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
101
+ VectorMCP::Session.new(@transport.server, @transport, id: session_id, request_context: request_context)
102
+ end
103
+
104
+ # Creates a minimal session context for each session (no caching to prevent contamination)
105
+ def create_minimal_session_context(session_id)
106
+ # Create a new minimal context for each session to prevent cross-session contamination
107
+ minimal_context = VectorMCP::RequestContext.minimal("http_stream")
108
+ VectorMCP::Session.new(@transport.server, @transport, id: session_id, request_context: minimal_context)
109
+ end
110
+
111
+ # Terminates a session by ID.
112
+ #
113
+ # @param session_id [String] The session ID to terminate
114
+ # @return [Boolean] True if session was found and terminated
115
+ # rubocop:disable Naming/PredicateMethod
116
+ def terminate_session(session_id)
117
+ session = @sessions.delete(session_id)
118
+ return false unless session
119
+
120
+ on_session_terminated(session)
121
+ logger.info { "Session terminated: #{session_id}" }
122
+ true
123
+ end
124
+ # rubocop:enable Naming/PredicateMethod
125
+
126
+ # Associates a streaming connection with a session.
127
+ #
128
+ # @param session [Session] The session to associate with
129
+ # @param connection [Object] The streaming connection object
130
+ # @return [void]
131
+ def set_streaming_connection(session, connection)
132
+ session.streaming_connection = connection
133
+ session.touch!
134
+ logger.debug { "Streaming connection associated: #{session.id}" }
135
+ end
136
+
137
+ # Removes streaming connection from a session.
138
+ #
139
+ # @param session [Session] The session to remove streaming from
140
+ # @return [void]
141
+ def remove_streaming_connection(session)
142
+ session.streaming_connection = nil
143
+ session.touch!
144
+ logger.debug { "Streaming connection removed: #{session.id}" }
145
+ end
146
+
147
+ protected
148
+
149
+ # Override: Called when a session is terminated to clean up streaming connections.
150
+ def on_session_terminated(session)
151
+ close_streaming_connection(session)
152
+ end
153
+
154
+ # Override: Returns metadata for new HTTP stream sessions.
155
+ def create_session_metadata
156
+ { streaming_connection: nil }
157
+ end
158
+
159
+ # Override: Checks if a session can receive messages (has streaming connection).
160
+ def can_send_message_to_session?(session)
161
+ session.streaming?
162
+ end
163
+
164
+ # Override: Sends a message to a session via the stream handler.
165
+ def message_sent_to_session?(session, message)
166
+ @transport.stream_handler.send_message_to_session(session, message)
167
+ end
168
+
169
+ private
170
+
171
+ # Closes a session's streaming connection if it exists.
172
+ #
173
+ # @param session [Session] The session whose connection to close
174
+ # @return [void]
175
+ def close_streaming_connection(session)
176
+ return unless session&.streaming_connection
177
+
178
+ begin
179
+ session.streaming_connection.close
180
+ rescue StandardError => e
181
+ logger.warn { "Error closing streaming connection for #{session.id}: #{e.message}" }
182
+ end
183
+
184
+ session.streaming_connection = nil
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+
5
+ module VectorMCP
6
+ module Transport
7
+ class HttpStream
8
+ # Handles Server-Sent Events streaming for HTTP transport.
9
+ #
10
+ # Manages:
11
+ # - SSE connection lifecycle
12
+ # - Event streaming with resumability
13
+ # - Last-Event-ID header processing
14
+ # - Connection health monitoring
15
+ #
16
+ # @api private
17
+ class StreamHandler
18
+ attr_reader :transport, :logger
19
+
20
+ # Streaming connection data structure
21
+ StreamingConnection = Struct.new(:session, :yielder, :thread, :closed) do
22
+ def close
23
+ self.closed = true
24
+ thread&.kill
25
+ end
26
+
27
+ def closed?
28
+ closed
29
+ end
30
+ end
31
+
32
+ # Initializes a new stream handler.
33
+ #
34
+ # @param transport [HttpStream] The parent transport instance
35
+ def initialize(transport)
36
+ @transport = transport
37
+ @logger = transport.logger
38
+ @active_connections = Concurrent::Hash.new
39
+ end
40
+
41
+ # Handles a streaming request (GET request for SSE).
42
+ #
43
+ # @param env [Hash] The Rack environment
44
+ # @param session [SessionManager::Session] The session for this request
45
+ # @return [Array] Rack response triplet for SSE
46
+ def handle_streaming_request(env, session)
47
+ last_event_id = extract_last_event_id(env)
48
+
49
+ logger.info("Starting SSE stream for session #{session.id}")
50
+
51
+ headers = build_sse_headers
52
+ body = create_sse_stream(session, last_event_id)
53
+
54
+ [200, headers, body]
55
+ end
56
+
57
+ # Sends a message to a specific session.
58
+ #
59
+ # @param session [SessionManager::Session] The target session
60
+ # @param message [Hash] The message to send
61
+ # @return [Boolean] True if message was sent successfully
62
+ def send_message_to_session(session, message)
63
+ return false unless session.streaming?
64
+
65
+ connection = @active_connections[session.id]
66
+ return false unless connection && !connection.closed?
67
+
68
+ begin
69
+ # Store event for resumability
70
+ event_data = message.to_json
71
+ event_id = @transport.event_store.store_event(event_data, "message")
72
+
73
+ # Send via SSE
74
+ sse_event = format_sse_event(event_data, "message", event_id)
75
+ connection.yielder << sse_event
76
+
77
+ logger.debug("Message sent to session #{session.id}")
78
+
79
+ true
80
+ rescue StandardError => e
81
+ logger.error("Error sending message to session #{session.id}: #{e.message}")
82
+
83
+ # Mark connection as closed and clean up
84
+ cleanup_connection(session)
85
+ false
86
+ end
87
+ end
88
+
89
+ # Gets the number of active streaming connections.
90
+ #
91
+ # @return [Integer] Number of active connections
92
+ def active_connection_count
93
+ @active_connections.size
94
+ end
95
+
96
+ # Cleans up all active connections.
97
+ #
98
+ # @return [void]
99
+ def cleanup_all_connections
100
+ logger.info("Cleaning up all streaming connections: #{@active_connections.size}")
101
+
102
+ @active_connections.each_value(&:close)
103
+
104
+ @active_connections.clear
105
+ end
106
+
107
+ private
108
+
109
+ # Extracts Last-Event-ID from request headers.
110
+ #
111
+ # @param env [Hash] The Rack environment
112
+ # @return [String, nil] The last event ID or nil
113
+ def extract_last_event_id(env)
114
+ env["HTTP_LAST_EVENT_ID"]
115
+ end
116
+
117
+ # Builds SSE response headers.
118
+ #
119
+ # @return [Hash] SSE headers
120
+ def build_sse_headers
121
+ {
122
+ "Content-Type" => "text/event-stream",
123
+ "Cache-Control" => "no-cache",
124
+ "Connection" => "keep-alive",
125
+ "X-Accel-Buffering" => "no",
126
+ "Access-Control-Allow-Origin" => "*",
127
+ "Access-Control-Allow-Headers" => "Last-Event-ID"
128
+ }
129
+ end
130
+
131
+ # Creates an SSE stream for a session.
132
+ #
133
+ # @param session [SessionManager::Session] The session
134
+ # @param last_event_id [String, nil] The last event ID for resumability
135
+ # @return [Enumerator] SSE stream enumerator
136
+ def create_sse_stream(session, last_event_id)
137
+ Enumerator.new do |yielder|
138
+ connection = StreamingConnection.new(session, yielder, nil, false)
139
+
140
+ # Register connection
141
+ @active_connections[session.id] = connection
142
+ @transport.session_manager.set_streaming_connection(session, connection)
143
+
144
+ # Start streaming thread
145
+ connection.thread = Thread.new do
146
+ stream_to_client(session, yielder, last_event_id)
147
+ rescue StandardError => e
148
+ logger.error("Error in streaming thread for #{session.id}: #{e.message}")
149
+ ensure
150
+ cleanup_connection(session)
151
+ end
152
+
153
+ # Keep connection alive until thread completes
154
+ connection.thread.join
155
+ end
156
+ end
157
+
158
+ # Streams events to a client.
159
+ #
160
+ # @param session [SessionManager::Session] The session
161
+ # @param yielder [Enumerator::Yielder] The SSE yielder
162
+ # @param last_event_id [String, nil] The last event ID for resumability
163
+ # @return [void]
164
+ def stream_to_client(session, yielder, last_event_id)
165
+ # Send initial connection event
166
+ connection_event = {
167
+ jsonrpc: "2.0",
168
+ method: "connection/established",
169
+ params: {
170
+ session_id: session.id,
171
+ timestamp: Time.now.iso8601
172
+ }
173
+ }
174
+
175
+ event_id = @transport.event_store.store_event(connection_event.to_json, "connection")
176
+ yielder << format_sse_event(connection_event.to_json, "connection", event_id)
177
+
178
+ # Replay missed events if resuming
179
+ replay_events(yielder, last_event_id) if last_event_id
180
+
181
+ # Send periodic keep-alive events
182
+ keep_alive_loop(session, yielder)
183
+ end
184
+
185
+ # Replays events after a specific event ID.
186
+ #
187
+ # @param yielder [Enumerator::Yielder] The SSE yielder
188
+ # @param last_event_id [String] The last event ID received by client
189
+ # @return [void]
190
+ def replay_events(yielder, last_event_id)
191
+ missed_events = @transport.event_store.get_events_after(last_event_id)
192
+
193
+ logger.info("Replaying #{missed_events.length} missed events from #{last_event_id}")
194
+
195
+ missed_events.each do |event|
196
+ yielder << event.to_sse_format
197
+ end
198
+ end
199
+
200
+ # Keeps the connection alive with periodic heartbeat events.
201
+ #
202
+ # @param session [SessionManager::Session] The session
203
+ # @param yielder [Enumerator::Yielder] The SSE yielder
204
+ # @return [void]
205
+ def keep_alive_loop(session, yielder)
206
+ start_time = Time.now
207
+ max_duration = 300 # 5 minutes maximum connection time
208
+
209
+ loop do
210
+ sleep(30) # Send heartbeat every 30 seconds
211
+
212
+ connection = @active_connections[session.id]
213
+ break if connection.nil? || connection.closed?
214
+
215
+ # Check if connection has been alive too long
216
+ if Time.now - start_time > max_duration
217
+ logger.debug("Connection for #{session.id} reached maximum duration, closing")
218
+ break
219
+ end
220
+
221
+ # Send heartbeat
222
+ heartbeat_event = {
223
+ jsonrpc: "2.0",
224
+ method: "heartbeat",
225
+ params: { timestamp: Time.now.iso8601 }
226
+ }
227
+
228
+ begin
229
+ event_id = @transport.event_store.store_event(heartbeat_event.to_json, "heartbeat")
230
+ yielder << format_sse_event(heartbeat_event.to_json, "heartbeat", event_id)
231
+ rescue StandardError
232
+ logger.debug("Heartbeat failed for #{session.id}, connection likely closed")
233
+ break
234
+ end
235
+ end
236
+ end
237
+
238
+ # Formats data as an SSE event.
239
+ #
240
+ # @param data [String] The event data
241
+ # @param type [String] The event type
242
+ # @param event_id [String] The event ID
243
+ # @return [String] Formatted SSE event
244
+ def format_sse_event(data, type, event_id)
245
+ lines = []
246
+ lines << "id: #{event_id}"
247
+ lines << "event: #{type}" if type
248
+ lines << "data: #{data}"
249
+ lines << ""
250
+ "#{lines.join("\n")}\n"
251
+ end
252
+
253
+ # Cleans up a specific connection.
254
+ #
255
+ # @param session [SessionManager::Session] The session to clean up
256
+ # @return [void]
257
+ def cleanup_connection(session)
258
+ connection = @active_connections.delete(session.id)
259
+ return unless connection
260
+
261
+ connection.close
262
+ @transport.session_manager.remove_streaming_connection(session)
263
+
264
+ logger.debug("Streaming connection cleaned up for #{session.id}")
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end