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
@@ -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
|