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,270 @@
|
|
|
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", session_id: session.id)
|
|
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", session_id: session.id)
|
|
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, session) 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, scoped to the session.
|
|
186
|
+
#
|
|
187
|
+
# @param yielder [Enumerator::Yielder] The SSE yielder
|
|
188
|
+
# @param last_event_id [String] The last event ID received by client
|
|
189
|
+
# @param session [SessionManager::Session] The session to filter events for
|
|
190
|
+
# @return [void]
|
|
191
|
+
def replay_events(yielder, last_event_id, session)
|
|
192
|
+
missed_events = @transport.event_store.get_events_after(last_event_id, session_id: session.id)
|
|
193
|
+
|
|
194
|
+
logger.info("Replaying #{missed_events.length} missed events from #{last_event_id}")
|
|
195
|
+
|
|
196
|
+
missed_events.each do |event|
|
|
197
|
+
yielder << event.to_sse_format
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Keeps the connection alive with periodic heartbeat events.
|
|
202
|
+
#
|
|
203
|
+
# @param session [SessionManager::Session] The session
|
|
204
|
+
# @param yielder [Enumerator::Yielder] The SSE yielder
|
|
205
|
+
# @return [void]
|
|
206
|
+
def keep_alive_loop(session, yielder)
|
|
207
|
+
start_time = Time.now
|
|
208
|
+
max_duration = 300 # 5 minutes maximum connection time
|
|
209
|
+
|
|
210
|
+
loop do
|
|
211
|
+
sleep(30) # Send heartbeat every 30 seconds
|
|
212
|
+
|
|
213
|
+
connection = @active_connections[session.id]
|
|
214
|
+
break if connection.nil? || connection.closed?
|
|
215
|
+
|
|
216
|
+
# Check if connection has been alive too long
|
|
217
|
+
if Time.now - start_time > max_duration
|
|
218
|
+
logger.debug("Connection for #{session.id} reached maximum duration, closing")
|
|
219
|
+
break
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Send heartbeat
|
|
223
|
+
heartbeat_event = {
|
|
224
|
+
jsonrpc: "2.0",
|
|
225
|
+
method: "heartbeat",
|
|
226
|
+
params: { timestamp: Time.now.iso8601 }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
begin
|
|
230
|
+
event_id = @transport.event_store.store_event(heartbeat_event.to_json, "heartbeat", session_id: session.id)
|
|
231
|
+
yielder << format_sse_event(heartbeat_event.to_json, "heartbeat", event_id)
|
|
232
|
+
rescue StandardError
|
|
233
|
+
logger.debug("Heartbeat failed for #{session.id}, connection likely closed")
|
|
234
|
+
break
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Formats data as an SSE event.
|
|
240
|
+
#
|
|
241
|
+
# @param data [String] The event data
|
|
242
|
+
# @param type [String] The event type
|
|
243
|
+
# @param event_id [String] The event ID
|
|
244
|
+
# @return [String] Formatted SSE event
|
|
245
|
+
def format_sse_event(data, type, event_id)
|
|
246
|
+
lines = []
|
|
247
|
+
lines << "id: #{event_id}"
|
|
248
|
+
lines << "event: #{type}" if type
|
|
249
|
+
lines << "data: #{data}"
|
|
250
|
+
lines << ""
|
|
251
|
+
"#{lines.join("\n")}\n"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Cleans up a specific connection.
|
|
255
|
+
#
|
|
256
|
+
# @param session [SessionManager::Session] The session to clean up
|
|
257
|
+
# @return [void]
|
|
258
|
+
def cleanup_connection(session)
|
|
259
|
+
connection = @active_connections.delete(session.id)
|
|
260
|
+
return unless connection
|
|
261
|
+
|
|
262
|
+
connection.close
|
|
263
|
+
@transport.session_manager.remove_streaming_connection(session)
|
|
264
|
+
|
|
265
|
+
logger.debug("Streaming connection cleaned up for #{session.id}")
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|