vector_mcp 0.3.4 → 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 +59 -0
- data/README.md +132 -342
- data/lib/vector_mcp/handlers/core.rb +82 -27
- data/lib/vector_mcp/image_util.rb +34 -11
- 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/server/capabilities.rb +4 -10
- data/lib/vector_mcp/server/registry.rb +36 -4
- data/lib/vector_mcp/server.rb +45 -38
- 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 +18 -4
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +34 -11
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +132 -47
- data/lib/vector_mcp/transport/http_stream.rb +161 -82
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +6 -8
- metadata +4 -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,36 +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, session) 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, scoped to the
|
|
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
|
|
189
196
|
# @param session [SessionManager::Session] The session to filter events for
|
|
197
|
+
# @param stream_id [String] The logical stream ID being resumed
|
|
190
198
|
# @return [void]
|
|
191
|
-
def replay_events(yielder, last_event_id, session)
|
|
192
|
-
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
|
+
)
|
|
193
205
|
|
|
194
206
|
logger.info("Replaying #{missed_events.length} missed events from #{last_event_id}")
|
|
195
207
|
|
|
@@ -202,33 +214,29 @@ module VectorMCP
|
|
|
202
214
|
#
|
|
203
215
|
# @param session [SessionManager::Session] The session
|
|
204
216
|
# @param yielder [Enumerator::Yielder] The SSE yielder
|
|
217
|
+
# @param stream_id [String] The stream ID for event storage
|
|
205
218
|
# @return [void]
|
|
206
|
-
def keep_alive_loop(session, yielder)
|
|
219
|
+
def keep_alive_loop(session, yielder, stream_id)
|
|
207
220
|
start_time = Time.now
|
|
208
221
|
max_duration = 300 # 5 minutes maximum connection time
|
|
209
222
|
|
|
210
223
|
loop do
|
|
211
224
|
sleep(30) # Send heartbeat every 30 seconds
|
|
212
225
|
|
|
213
|
-
connection = @active_connections[session.id]
|
|
226
|
+
connection = @active_connections[stream_id] || @active_connections[session.id]
|
|
214
227
|
break if connection.nil? || connection.closed?
|
|
215
228
|
|
|
216
229
|
# Check if connection has been alive too long
|
|
217
230
|
if Time.now - start_time > max_duration
|
|
218
|
-
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"
|
|
219
234
|
break
|
|
220
235
|
end
|
|
221
236
|
|
|
222
|
-
# Send heartbeat
|
|
223
|
-
heartbeat_event = {
|
|
224
|
-
jsonrpc: "2.0",
|
|
225
|
-
method: "heartbeat",
|
|
226
|
-
params: { timestamp: Time.now.iso8601 }
|
|
227
|
-
}
|
|
228
|
-
|
|
237
|
+
# Send heartbeat as SSE comment (not a JSON-RPC notification)
|
|
229
238
|
begin
|
|
230
|
-
|
|
231
|
-
yielder << format_sse_event(heartbeat_event.to_json, "heartbeat", event_id)
|
|
239
|
+
yielder << ": heartbeat\n\n"
|
|
232
240
|
rescue StandardError
|
|
233
241
|
logger.debug("Heartbeat failed for #{session.id}, connection likely closed")
|
|
234
242
|
break
|
|
@@ -242,28 +250,105 @@ module VectorMCP
|
|
|
242
250
|
# @param type [String] The event type
|
|
243
251
|
# @param event_id [String] The event ID
|
|
244
252
|
# @return [String] Formatted SSE event
|
|
245
|
-
def format_sse_event(data, type, event_id)
|
|
253
|
+
def format_sse_event(data, type, event_id, retry_ms: nil)
|
|
246
254
|
lines = []
|
|
247
|
-
lines << "id: #{event_id}"
|
|
255
|
+
lines << "id: #{event_id}" if event_id
|
|
248
256
|
lines << "event: #{type}" if type
|
|
257
|
+
lines << "retry: #{retry_ms}" if retry_ms
|
|
249
258
|
lines << "data: #{data}"
|
|
250
259
|
lines << ""
|
|
251
260
|
"#{lines.join("\n")}\n"
|
|
252
261
|
end
|
|
253
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
|
+
|
|
254
275
|
# Cleans up a specific connection.
|
|
255
276
|
#
|
|
256
277
|
# @param session [SessionManager::Session] The session to clean up
|
|
257
278
|
# @return [void]
|
|
258
|
-
def cleanup_connection(session)
|
|
259
|
-
connection
|
|
279
|
+
def cleanup_connection(session, connection = nil)
|
|
280
|
+
connection ||= session.streaming_connection
|
|
281
|
+
connection ||= @active_connections[session.id]
|
|
260
282
|
return unless connection
|
|
261
283
|
|
|
284
|
+
@active_connections.delete(connection.stream_id)
|
|
285
|
+
@active_connections.delete(session.id) if @active_connections[session.id] == connection
|
|
286
|
+
|
|
262
287
|
connection.close
|
|
263
|
-
@transport.session_manager.remove_streaming_connection(session)
|
|
288
|
+
@transport.session_manager.remove_streaming_connection(session, connection)
|
|
264
289
|
|
|
265
290
|
logger.debug("Streaming connection cleaned up for #{session.id}")
|
|
266
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
|
|
267
352
|
end
|
|
268
353
|
end
|
|
269
354
|
end
|