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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +132 -342
  4. data/lib/vector_mcp/handlers/core.rb +82 -27
  5. data/lib/vector_mcp/image_util.rb +53 -5
  6. data/lib/vector_mcp/log_filter.rb +48 -0
  7. data/lib/vector_mcp/middleware/base.rb +1 -5
  8. data/lib/vector_mcp/middleware/context.rb +11 -1
  9. data/lib/vector_mcp/rails/tool.rb +85 -0
  10. data/lib/vector_mcp/request_context.rb +1 -1
  11. data/lib/vector_mcp/security/middleware.rb +2 -2
  12. data/lib/vector_mcp/security/strategies/api_key.rb +27 -4
  13. data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
  14. data/lib/vector_mcp/server/capabilities.rb +4 -10
  15. data/lib/vector_mcp/server/message_handling.rb +2 -2
  16. data/lib/vector_mcp/server/registry.rb +36 -4
  17. data/lib/vector_mcp/server.rb +49 -41
  18. data/lib/vector_mcp/session.rb +5 -3
  19. data/lib/vector_mcp/tool.rb +221 -0
  20. data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
  21. data/lib/vector_mcp/transport/http_stream/event_store.rb +33 -13
  22. data/lib/vector_mcp/transport/http_stream/session_manager.rb +39 -14
  23. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +133 -47
  24. data/lib/vector_mcp/transport/http_stream.rb +294 -33
  25. data/lib/vector_mcp/version.rb +1 -1
  26. data/lib/vector_mcp.rb +7 -8
  27. metadata +5 -10
  28. data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
  29. data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
  30. data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
  31. data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
  32. data/lib/vector_mcp/transport/sse.rb +0 -377
  33. data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
  34. data/lib/vector_mcp/transport/stdio.rb +0 -473
  35. 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 = @active_connections[session.id]
66
- return false unless connection && !connection.closed?
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
- @active_connections[session.id] = connection
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
- # 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
- }
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
- 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
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(last_event_id)
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
- event_id = @transport.event_store.store_event(heartbeat_event.to_json, "heartbeat")
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 = @active_connections.delete(session.id)
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