vector_mcp 0.3.3 → 0.4.0
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 +80 -0
- data/README.md +132 -342
- data/lib/vector_mcp/handlers/core.rb +82 -27
- data/lib/vector_mcp/image_util.rb +53 -5
- data/lib/vector_mcp/log_filter.rb +48 -0
- data/lib/vector_mcp/middleware/base.rb +1 -5
- data/lib/vector_mcp/middleware/context.rb +11 -1
- data/lib/vector_mcp/rails/tool.rb +85 -0
- data/lib/vector_mcp/request_context.rb +1 -1
- data/lib/vector_mcp/security/middleware.rb +2 -2
- data/lib/vector_mcp/security/strategies/api_key.rb +27 -4
- data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
- data/lib/vector_mcp/server/capabilities.rb +4 -10
- data/lib/vector_mcp/server/message_handling.rb +2 -2
- data/lib/vector_mcp/server/registry.rb +36 -4
- data/lib/vector_mcp/server.rb +49 -41
- data/lib/vector_mcp/session.rb +5 -3
- data/lib/vector_mcp/tool.rb +221 -0
- data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
- data/lib/vector_mcp/transport/http_stream/event_store.rb +33 -13
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +39 -14
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +133 -47
- data/lib/vector_mcp/transport/http_stream.rb +294 -33
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +7 -8
- metadata +5 -10
- data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
- data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
- data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
- data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
- data/lib/vector_mcp/transport/sse.rb +0 -377
- data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
- data/lib/vector_mcp/transport/stdio.rb +0 -473
- data/lib/vector_mcp/transport/stdio_session_manager.rb +0 -181
|
@@ -18,7 +18,7 @@ module VectorMCP
|
|
|
18
18
|
attr_reader :transport, :logger
|
|
19
19
|
|
|
20
20
|
# Streaming connection data structure
|
|
21
|
-
StreamingConnection = Struct.new(:session, :yielder, :thread, :closed) do
|
|
21
|
+
StreamingConnection = Struct.new(:session, :yielder, :thread, :closed, :origin, :stream_id) do
|
|
22
22
|
def close
|
|
23
23
|
self.closed = true
|
|
24
24
|
thread&.kill
|
|
@@ -27,8 +27,16 @@ module VectorMCP
|
|
|
27
27
|
def closed?
|
|
28
28
|
closed
|
|
29
29
|
end
|
|
30
|
+
|
|
31
|
+
# Returns true if this connection originated from a GET request.
|
|
32
|
+
def from_get?
|
|
33
|
+
origin == :get
|
|
34
|
+
end
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
# Default reconnection time in milliseconds sent before intentional disconnections.
|
|
38
|
+
DEFAULT_RETRY_MS = 5000
|
|
39
|
+
|
|
32
40
|
# Initializes a new stream handler.
|
|
33
41
|
#
|
|
34
42
|
# @param transport [HttpStream] The parent transport instance
|
|
@@ -45,11 +53,12 @@ module VectorMCP
|
|
|
45
53
|
# @return [Array] Rack response triplet for SSE
|
|
46
54
|
def handle_streaming_request(env, session)
|
|
47
55
|
last_event_id = extract_last_event_id(env)
|
|
56
|
+
stream_id = resolve_stream_id(session, last_event_id, :get)
|
|
48
57
|
|
|
49
58
|
logger.info("Starting SSE stream for session #{session.id}")
|
|
50
59
|
|
|
51
60
|
headers = build_sse_headers
|
|
52
|
-
body = create_sse_stream(session, last_event_id)
|
|
61
|
+
body = create_sse_stream(session, last_event_id, stream_id: stream_id)
|
|
53
62
|
|
|
54
63
|
[200, headers, body]
|
|
55
64
|
end
|
|
@@ -62,13 +71,15 @@ module VectorMCP
|
|
|
62
71
|
def send_message_to_session(session, message)
|
|
63
72
|
return false unless session.streaming?
|
|
64
73
|
|
|
65
|
-
connection =
|
|
66
|
-
return false unless connection
|
|
74
|
+
connection = select_connection_for_message(session, message)
|
|
75
|
+
return false unless connection
|
|
67
76
|
|
|
68
77
|
begin
|
|
69
78
|
# Store event for resumability
|
|
70
79
|
event_data = message.to_json
|
|
71
|
-
event_id = @transport.event_store.store_event(event_data, "message"
|
|
80
|
+
event_id = @transport.event_store.store_event(event_data, "message",
|
|
81
|
+
session_id: session.id,
|
|
82
|
+
stream_id: connection.stream_id)
|
|
72
83
|
|
|
73
84
|
# Send via SSE
|
|
74
85
|
sse_event = format_sse_event(event_data, "message", event_id)
|
|
@@ -81,7 +92,7 @@ module VectorMCP
|
|
|
81
92
|
logger.error("Error sending message to session #{session.id}: #{e.message}")
|
|
82
93
|
|
|
83
94
|
# Mark connection as closed and clean up
|
|
84
|
-
cleanup_connection(session)
|
|
95
|
+
cleanup_connection(session, connection)
|
|
85
96
|
false
|
|
86
97
|
end
|
|
87
98
|
end
|
|
@@ -132,22 +143,26 @@ module VectorMCP
|
|
|
132
143
|
#
|
|
133
144
|
# @param session [SessionManager::Session] The session
|
|
134
145
|
# @param last_event_id [String, nil] The last event ID for resumability
|
|
146
|
+
# @param origin [Symbol] The stream origin (:get or :post)
|
|
135
147
|
# @return [Enumerator] SSE stream enumerator
|
|
136
|
-
def create_sse_stream(session, last_event_id)
|
|
148
|
+
def create_sse_stream(session, last_event_id, origin: :get, stream_id: nil)
|
|
149
|
+
stream_id ||= generate_stream_id(session.id, origin)
|
|
150
|
+
|
|
137
151
|
Enumerator.new do |yielder|
|
|
138
|
-
connection = StreamingConnection.new(session, yielder, nil, false)
|
|
152
|
+
connection = StreamingConnection.new(session, yielder, nil, false, origin, stream_id)
|
|
139
153
|
|
|
140
154
|
# Register connection
|
|
141
|
-
|
|
155
|
+
replace_existing_connection(session, stream_id)
|
|
156
|
+
@active_connections[stream_id] = connection
|
|
142
157
|
@transport.session_manager.set_streaming_connection(session, connection)
|
|
143
158
|
|
|
144
159
|
# Start streaming thread
|
|
145
160
|
connection.thread = Thread.new do
|
|
146
|
-
stream_to_client(session, yielder, last_event_id)
|
|
161
|
+
stream_to_client(session, yielder, last_event_id, stream_id)
|
|
147
162
|
rescue StandardError => e
|
|
148
163
|
logger.error("Error in streaming thread for #{session.id}: #{e.message}")
|
|
149
164
|
ensure
|
|
150
|
-
cleanup_connection(session)
|
|
165
|
+
cleanup_connection(session, connection)
|
|
151
166
|
end
|
|
152
167
|
|
|
153
168
|
# Keep connection alive until thread completes
|
|
@@ -160,35 +175,33 @@ module VectorMCP
|
|
|
160
175
|
# @param session [SessionManager::Session] The session
|
|
161
176
|
# @param yielder [Enumerator::Yielder] The SSE yielder
|
|
162
177
|
# @param last_event_id [String, nil] The last event ID for resumability
|
|
178
|
+
# @param stream_id [String] The unique stream identifier
|
|
163
179
|
# @return [void]
|
|
164
|
-
def stream_to_client(session, yielder, last_event_id)
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
method: "connection/established",
|
|
169
|
-
params: {
|
|
170
|
-
session_id: session.id,
|
|
171
|
-
timestamp: Time.now.iso8601
|
|
172
|
-
}
|
|
173
|
-
}
|
|
180
|
+
def stream_to_client(session, yielder, last_event_id, stream_id)
|
|
181
|
+
# SSE priming event per MCP spec: event ID + empty data field
|
|
182
|
+
prime_event_id = @transport.event_store.store_event("", nil, session_id: session.id, stream_id: stream_id)
|
|
183
|
+
yielder << "id: #{prime_event_id}\ndata:\n\n"
|
|
174
184
|
|
|
175
|
-
|
|
176
|
-
yielder
|
|
177
|
-
|
|
178
|
-
# Replay missed events if resuming
|
|
179
|
-
replay_events(yielder, last_event_id) if last_event_id
|
|
185
|
+
# Replay missed events if resuming — scoped to the original stream only
|
|
186
|
+
replay_events(yielder, last_event_id, session, stream_id) if last_event_id
|
|
180
187
|
|
|
181
188
|
# Send periodic keep-alive events
|
|
182
|
-
keep_alive_loop(session, yielder)
|
|
189
|
+
keep_alive_loop(session, yielder, stream_id)
|
|
183
190
|
end
|
|
184
191
|
|
|
185
|
-
# Replays events after a specific event ID.
|
|
192
|
+
# Replays events after a specific event ID, scoped to the originating stream.
|
|
186
193
|
#
|
|
187
194
|
# @param yielder [Enumerator::Yielder] The SSE yielder
|
|
188
195
|
# @param last_event_id [String] The last event ID received by client
|
|
196
|
+
# @param session [SessionManager::Session] The session to filter events for
|
|
197
|
+
# @param stream_id [String] The logical stream ID being resumed
|
|
189
198
|
# @return [void]
|
|
190
|
-
def replay_events(yielder, last_event_id)
|
|
191
|
-
missed_events = @transport.event_store.get_events_after(
|
|
199
|
+
def replay_events(yielder, last_event_id, session, stream_id)
|
|
200
|
+
missed_events = @transport.event_store.get_events_after(
|
|
201
|
+
last_event_id,
|
|
202
|
+
session_id: session.id,
|
|
203
|
+
stream_id: stream_id
|
|
204
|
+
)
|
|
192
205
|
|
|
193
206
|
logger.info("Replaying #{missed_events.length} missed events from #{last_event_id}")
|
|
194
207
|
|
|
@@ -201,33 +214,29 @@ module VectorMCP
|
|
|
201
214
|
#
|
|
202
215
|
# @param session [SessionManager::Session] The session
|
|
203
216
|
# @param yielder [Enumerator::Yielder] The SSE yielder
|
|
217
|
+
# @param stream_id [String] The stream ID for event storage
|
|
204
218
|
# @return [void]
|
|
205
|
-
def keep_alive_loop(session, yielder)
|
|
219
|
+
def keep_alive_loop(session, yielder, stream_id)
|
|
206
220
|
start_time = Time.now
|
|
207
221
|
max_duration = 300 # 5 minutes maximum connection time
|
|
208
222
|
|
|
209
223
|
loop do
|
|
210
224
|
sleep(30) # Send heartbeat every 30 seconds
|
|
211
225
|
|
|
212
|
-
connection = @active_connections[session.id]
|
|
226
|
+
connection = @active_connections[stream_id] || @active_connections[session.id]
|
|
213
227
|
break if connection.nil? || connection.closed?
|
|
214
228
|
|
|
215
229
|
# Check if connection has been alive too long
|
|
216
230
|
if Time.now - start_time > max_duration
|
|
217
|
-
logger.debug("Connection for #{session.id} reached maximum duration, closing")
|
|
231
|
+
logger.debug("Connection for #{session.id} reached maximum duration, sending retry guidance and closing")
|
|
232
|
+
# Send retry field before intentional disconnect per MCP spec
|
|
233
|
+
yielder << "retry: #{DEFAULT_RETRY_MS}\n\n"
|
|
218
234
|
break
|
|
219
235
|
end
|
|
220
236
|
|
|
221
|
-
# Send heartbeat
|
|
222
|
-
heartbeat_event = {
|
|
223
|
-
jsonrpc: "2.0",
|
|
224
|
-
method: "heartbeat",
|
|
225
|
-
params: { timestamp: Time.now.iso8601 }
|
|
226
|
-
}
|
|
227
|
-
|
|
237
|
+
# Send heartbeat as SSE comment (not a JSON-RPC notification)
|
|
228
238
|
begin
|
|
229
|
-
|
|
230
|
-
yielder << format_sse_event(heartbeat_event.to_json, "heartbeat", event_id)
|
|
239
|
+
yielder << ": heartbeat\n\n"
|
|
231
240
|
rescue StandardError
|
|
232
241
|
logger.debug("Heartbeat failed for #{session.id}, connection likely closed")
|
|
233
242
|
break
|
|
@@ -241,28 +250,105 @@ module VectorMCP
|
|
|
241
250
|
# @param type [String] The event type
|
|
242
251
|
# @param event_id [String] The event ID
|
|
243
252
|
# @return [String] Formatted SSE event
|
|
244
|
-
def format_sse_event(data, type, event_id)
|
|
253
|
+
def format_sse_event(data, type, event_id, retry_ms: nil)
|
|
245
254
|
lines = []
|
|
246
|
-
lines << "id: #{event_id}"
|
|
255
|
+
lines << "id: #{event_id}" if event_id
|
|
247
256
|
lines << "event: #{type}" if type
|
|
257
|
+
lines << "retry: #{retry_ms}" if retry_ms
|
|
248
258
|
lines << "data: #{data}"
|
|
249
259
|
lines << ""
|
|
250
260
|
"#{lines.join("\n")}\n"
|
|
251
261
|
end
|
|
252
262
|
|
|
263
|
+
# Checks if a message is a JSON-RPC response (has result or error, no method).
|
|
264
|
+
#
|
|
265
|
+
# @param message [Hash] The JSON-RPC message
|
|
266
|
+
# @return [Boolean] True if the message is a response
|
|
267
|
+
def json_rpc_response?(message)
|
|
268
|
+
return false unless message.is_a?(Hash)
|
|
269
|
+
|
|
270
|
+
(message.key?("result") || message.key?(:result) ||
|
|
271
|
+
message.key?("error") || message.key?(:error)) &&
|
|
272
|
+
!message.key?("method") && !message.key?(:method)
|
|
273
|
+
end
|
|
274
|
+
|
|
253
275
|
# Cleans up a specific connection.
|
|
254
276
|
#
|
|
255
277
|
# @param session [SessionManager::Session] The session to clean up
|
|
256
278
|
# @return [void]
|
|
257
|
-
def cleanup_connection(session)
|
|
258
|
-
connection
|
|
279
|
+
def cleanup_connection(session, connection = nil)
|
|
280
|
+
connection ||= session.streaming_connection
|
|
281
|
+
connection ||= @active_connections[session.id]
|
|
259
282
|
return unless connection
|
|
260
283
|
|
|
284
|
+
@active_connections.delete(connection.stream_id)
|
|
285
|
+
@active_connections.delete(session.id) if @active_connections[session.id] == connection
|
|
286
|
+
|
|
261
287
|
connection.close
|
|
262
|
-
@transport.session_manager.remove_streaming_connection(session)
|
|
288
|
+
@transport.session_manager.remove_streaming_connection(session, connection)
|
|
263
289
|
|
|
264
290
|
logger.debug("Streaming connection cleaned up for #{session.id}")
|
|
265
291
|
end
|
|
292
|
+
|
|
293
|
+
def resolve_stream_id(session, last_event_id, origin)
|
|
294
|
+
return generate_stream_id(session.id, origin) unless last_event_id
|
|
295
|
+
|
|
296
|
+
last_event = @transport.event_store.get_event(last_event_id)
|
|
297
|
+
|
|
298
|
+
if last_event && last_event.session_id == session.id && last_event.stream_id
|
|
299
|
+
last_event.stream_id
|
|
300
|
+
else
|
|
301
|
+
generate_stream_id(session.id, origin)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def generate_stream_id(session_id, origin)
|
|
306
|
+
"#{session_id}-#{origin}-#{SecureRandom.hex(4)}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def select_connection_for_message(session, message)
|
|
310
|
+
connections = active_connections_for_session(session)
|
|
311
|
+
return nil if connections.empty?
|
|
312
|
+
|
|
313
|
+
if json_rpc_response?(message)
|
|
314
|
+
eligible_connections = connections.reject(&:from_get?)
|
|
315
|
+
if eligible_connections.empty?
|
|
316
|
+
logger.debug("Blocked JSON-RPC response on GET stream for session #{session.id}")
|
|
317
|
+
return nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
preferred_connection_for(session, eligible_connections)
|
|
321
|
+
else
|
|
322
|
+
preferred_connection_for(session, connections)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def active_connections_for_session(session)
|
|
327
|
+
connections = session.streaming_connections.values.select do |connection|
|
|
328
|
+
@active_connections[connection.stream_id] == connection && !connection.closed?
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
legacy_connection = @active_connections[session.id]
|
|
332
|
+
connections << legacy_connection if legacy_connection &&
|
|
333
|
+
!legacy_connection.closed? &&
|
|
334
|
+
!connections.include?(legacy_connection)
|
|
335
|
+
|
|
336
|
+
connections
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def preferred_connection_for(session, connections)
|
|
340
|
+
preferred = session.streaming_connection
|
|
341
|
+
return connections.find { |connection| connection == preferred } if preferred && connections.include?(preferred)
|
|
342
|
+
|
|
343
|
+
connections.first
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def replace_existing_connection(session, stream_id)
|
|
347
|
+
existing_connection = @active_connections[stream_id]
|
|
348
|
+
return unless existing_connection
|
|
349
|
+
|
|
350
|
+
cleanup_connection(session, existing_connection)
|
|
351
|
+
end
|
|
266
352
|
end
|
|
267
353
|
end
|
|
268
354
|
end
|