vector_mcp 0.3.4 → 0.5.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +147 -337
  4. data/lib/vector_mcp/definitions.rb +30 -0
  5. data/lib/vector_mcp/handlers/core.rb +78 -81
  6. data/lib/vector_mcp/image_util.rb +34 -11
  7. data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
  8. data/lib/vector_mcp/middleware/base.rb +1 -5
  9. data/lib/vector_mcp/middleware/context.rb +11 -1
  10. data/lib/vector_mcp/middleware/hook.rb +7 -24
  11. data/lib/vector_mcp/middleware.rb +26 -9
  12. data/lib/vector_mcp/rails/tool.rb +85 -0
  13. data/lib/vector_mcp/request_context.rb +1 -1
  14. data/lib/vector_mcp/security/auth_manager.rb +12 -13
  15. data/lib/vector_mcp/security/auth_result.rb +33 -0
  16. data/lib/vector_mcp/security/authorization.rb +5 -9
  17. data/lib/vector_mcp/security/middleware.rb +2 -2
  18. data/lib/vector_mcp/security/session_context.rb +11 -27
  19. data/lib/vector_mcp/security/strategies/api_key.rb +1 -5
  20. data/lib/vector_mcp/security/strategies/custom.rb +10 -37
  21. data/lib/vector_mcp/security/strategies/jwt_token.rb +1 -10
  22. data/lib/vector_mcp/server/capabilities.rb +22 -32
  23. data/lib/vector_mcp/server/message_handling.rb +21 -14
  24. data/lib/vector_mcp/server/registry.rb +102 -120
  25. data/lib/vector_mcp/server.rb +98 -57
  26. data/lib/vector_mcp/session.rb +5 -3
  27. data/lib/vector_mcp/token_store.rb +80 -0
  28. data/lib/vector_mcp/tool.rb +221 -0
  29. data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
  30. data/lib/vector_mcp/transport/http_stream/event_store.rb +29 -17
  31. data/lib/vector_mcp/transport/http_stream/session_manager.rb +41 -36
  32. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +132 -47
  33. data/lib/vector_mcp/transport/http_stream.rb +242 -124
  34. data/lib/vector_mcp/util/token_sweeper.rb +74 -0
  35. data/lib/vector_mcp/version.rb +1 -1
  36. data/lib/vector_mcp.rb +8 -8
  37. metadata +8 -10
  38. data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
  39. data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
  40. data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
  41. data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
  42. data/lib/vector_mcp/transport/sse.rb +0 -377
  43. data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
  44. data/lib/vector_mcp/transport/stdio.rb +0 -473
  45. data/lib/vector_mcp/transport/stdio_session_manager.rb +0 -181
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+ require "securerandom"
5
+
6
+ module VectorMCP
7
+ # Thread-safe bidirectional store mapping arbitrary string values to opaque
8
+ # tokens and back. The store has no knowledge of domain semantics: callers
9
+ # supply the prefix, and the store guarantees that the same (value, prefix)
10
+ # pair always yields the same token within its lifetime.
11
+ class TokenStore
12
+ # Regexp describing the token format emitted by {#tokenize}.
13
+ TOKEN_PATTERN = /\A[A-Z]+_[0-9A-F]{8}\z/
14
+
15
+ def initialize
16
+ @forward = Concurrent::Hash.new
17
+ @reverse = Concurrent::Hash.new
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ # Return an opaque token for +value+. Calling this repeatedly with the
22
+ # same +value+ and +prefix+ returns the same token.
23
+ #
24
+ # @param value [String] the value to tokenize.
25
+ # @param prefix [String] the token prefix (uppercase recommended).
26
+ # @return [String] a token of the form +"PREFIX_XXXXXXXX"+.
27
+ def tokenize(value, prefix:)
28
+ key = [prefix, value]
29
+ existing = @forward[key]
30
+ return existing if existing
31
+
32
+ @mutex.synchronize do
33
+ existing = @forward[key]
34
+ return existing if existing
35
+
36
+ token = generate_token(prefix)
37
+ # Populate the reverse map first so any thread that observes the
38
+ # token in @forward can always resolve it.
39
+ @reverse[token] = value
40
+ @forward[key] = token
41
+ token
42
+ end
43
+ end
44
+
45
+ # Resolve a token back to its original value.
46
+ #
47
+ # @param token [String] a token previously returned by {#tokenize}.
48
+ # @return [String, nil] the original value, or +nil+ if unknown.
49
+ def resolve(token)
50
+ @reverse[token]
51
+ end
52
+
53
+ # Predicate: does +string+ look like a token issued by this class?
54
+ # This check is purely structural and does not consult the store.
55
+ #
56
+ # @param string [Object] the value to test.
57
+ # @return [Boolean]
58
+ def token?(string)
59
+ string.is_a?(String) && TOKEN_PATTERN.match?(string)
60
+ end
61
+
62
+ # Remove all mappings. Intended for test teardown.
63
+ # @return [void]
64
+ def clear
65
+ @mutex.synchronize do
66
+ @forward.clear
67
+ @reverse.clear
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def generate_token(prefix)
74
+ loop do
75
+ candidate = "#{prefix}_#{SecureRandom.hex(4).upcase}"
76
+ return candidate unless @reverse.key?(candidate)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require_relative "errors"
6
+
7
+ module VectorMCP
8
+ # Abstract base class for declarative tool definitions.
9
+ #
10
+ # Subclass this to define tools using a class-level DSL instead of
11
+ # the block-based +register_tool+ API. The two styles are fully
12
+ # interchangeable -- both produce the same +VectorMCP::Definitions::Tool+
13
+ # struct that the rest of the system consumes.
14
+ #
15
+ # @example
16
+ # class ListProviders < VectorMCP::Tool
17
+ # tool_name "list_providers"
18
+ # description "List providers filtered by category or status"
19
+ #
20
+ # param :category, type: :string, desc: "Filter by category slug"
21
+ # param :active, type: :boolean, default: true
22
+ #
23
+ # def call(args, session)
24
+ # Provider.where(active: args.fetch("active", true))
25
+ # end
26
+ # end
27
+ #
28
+ # server.register(ListProviders)
29
+ #
30
+ class Tool
31
+ # Maps Ruby symbol types to JSON Schema property fragments.
32
+ # Each value is merged into the generated property hash, so it may carry
33
+ # both +type+ and +format+ (or any other JSON Schema keyword).
34
+ TYPE_MAP = {
35
+ string: { "type" => "string" },
36
+ integer: { "type" => "integer" },
37
+ number: { "type" => "number" },
38
+ boolean: { "type" => "boolean" },
39
+ array: { "type" => "array" },
40
+ object: { "type" => "object" },
41
+ date: { "type" => "string", "format" => "date" },
42
+ datetime: { "type" => "string", "format" => "date-time" }
43
+ }.freeze
44
+
45
+ # Maps Ruby symbol types to a coercer lambda. Types not listed here
46
+ # pass their values through unchanged. Coercers receive the raw value
47
+ # (or nil) and return the coerced value. They must be total over the
48
+ # values JSON Schema validation would accept.
49
+ COERCERS = {
50
+ date: ->(v) { v.nil? || v.is_a?(Date) ? v : Date.parse(v.to_s) },
51
+ datetime: ->(v) { v.nil? || v.is_a?(Time) ? v : Time.parse(v.to_s) }
52
+ }.freeze
53
+
54
+ # Ensures each subclass gets its own +@params+ array so sibling
55
+ # classes do not share mutable state.
56
+ def self.inherited(subclass)
57
+ super
58
+ subclass.instance_variable_set(:@params, [])
59
+ end
60
+
61
+ # Sets or retrieves the tool name.
62
+ #
63
+ # When called with an argument, stores the name.
64
+ # When called without, returns the stored name or derives one from the class name.
65
+ #
66
+ # @param name [String, Symbol, nil] The tool name to set.
67
+ # @return [String] The tool name.
68
+ def self.tool_name(name = nil)
69
+ if name
70
+ @tool_name = name.to_s
71
+ else
72
+ @tool_name || derive_tool_name
73
+ end
74
+ end
75
+
76
+ # Sets or retrieves the tool description.
77
+ #
78
+ # @param text [String, nil] The description to set.
79
+ # @return [String, nil] The description.
80
+ def self.description(text = nil)
81
+ if text
82
+ @description = text
83
+ else
84
+ @description
85
+ end
86
+ end
87
+
88
+ # Declares a parameter for the tool's input schema.
89
+ #
90
+ # @param name [Symbol, String] The parameter name.
91
+ # @param type [Symbol] The parameter type (:string, :integer, :number, :boolean, :array, :object).
92
+ # @param desc [String, nil] A human-readable description.
93
+ # @param required [Boolean] Whether the parameter is required (default: false).
94
+ # @param options [Hash] Additional JSON Schema keywords (enum:, default:, format:, items:, etc.).
95
+ def self.param(name, type: :string, desc: nil, required: false, **options)
96
+ @params << {
97
+ name: name.to_s,
98
+ type: type,
99
+ desc: desc,
100
+ required: required,
101
+ options: options
102
+ }
103
+ end
104
+
105
+ # Builds a +VectorMCP::Definitions::Tool+ struct from the DSL metadata.
106
+ #
107
+ # @return [VectorMCP::Definitions::Tool]
108
+ # @raise [ArgumentError] If the subclass is missing a description or +#call+ method.
109
+ def self.to_definition
110
+ validate_tool_class!
111
+
112
+ VectorMCP::Definitions::Tool.new(
113
+ tool_name,
114
+ description,
115
+ build_input_schema,
116
+ build_handler
117
+ )
118
+ end
119
+
120
+ # The handler method that subclasses must implement.
121
+ #
122
+ # @param _args [Hash] The tool arguments (string keys).
123
+ # @param _session [VectorMCP::Session] The current session.
124
+ # @return [Object] The tool result.
125
+ def call(_args, _session)
126
+ raise NotImplementedError, "#{self.class.name} must implement #call(args, session)"
127
+ end
128
+
129
+ # Derives a snake_case tool name from the class name.
130
+ def self.derive_tool_name
131
+ base = name&.split("::")&.last
132
+ return "unnamed_tool" unless base
133
+
134
+ base.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
135
+ end
136
+ private_class_method :derive_tool_name
137
+
138
+ # Validates that the subclass is properly configured.
139
+ def self.validate_tool_class!
140
+ raise ArgumentError, "#{name || self} must declare a description" unless description
141
+ return if method_defined?(:call) && instance_method(:call).owner != VectorMCP::Tool
142
+
143
+ raise ArgumentError, "#{name || self} must implement #call"
144
+ end
145
+ private_class_method :validate_tool_class!
146
+
147
+ # Builds a 2-arity handler lambda. A new instance is created per invocation.
148
+ # Arguments are coerced based on the declared param types before the
149
+ # handler sees them (e.g. :date param strings become Date objects).
150
+ def self.build_handler
151
+ klass = self
152
+ params = @params
153
+ ->(args, session) { klass.new.call(klass.coerce_args(args, params), session) }
154
+ end
155
+ private_class_method :build_handler
156
+
157
+ # Applies coercers to the raw argument hash. Returns a new hash; does
158
+ # not mutate the original. Keys without a coercible type pass through.
159
+ # Keys that are absent from +args+ stay absent — coercion only fires
160
+ # for keys actually present.
161
+ #
162
+ # A parse failure on a client-supplied value is translated into
163
+ # +VectorMCP::InvalidParamsError+ (JSON-RPC -32602) so the client sees
164
+ # a "bad request" response instead of a generic internal error.
165
+ # This is needed because the +json-schema+ gem does not enforce
166
+ # +format: date+ upstream (it does enforce +format: date-time+), so
167
+ # malformed +:date+ values would otherwise crash inside +Date.parse+.
168
+ def self.coerce_args(args, params)
169
+ coerced = args.dup
170
+ params.each do |param|
171
+ name = param[:name]
172
+ next unless coerced.key?(name)
173
+
174
+ coercer = COERCERS[param[:type]]
175
+ next unless coercer
176
+
177
+ begin
178
+ coerced[name] = coercer.call(coerced[name])
179
+ rescue ArgumentError, TypeError => e
180
+ # Date::Error < ArgumentError in Ruby 3.2+, so ArgumentError alone covers Date.parse failures.
181
+ raise VectorMCP::InvalidParamsError.new(
182
+ "Invalid #{param[:type]} value for param '#{name}': #{e.message}",
183
+ details: { param: name, type: param[:type], message: e.message }
184
+ )
185
+ end
186
+ end
187
+ coerced
188
+ end
189
+
190
+ # Builds a JSON Schema hash from the declared params.
191
+ def self.build_input_schema
192
+ properties = {}
193
+ required = []
194
+
195
+ @params.each do |param|
196
+ type_fragment = TYPE_MAP.fetch(param[:type]) do
197
+ raise ArgumentError, "Unknown param type :#{param[:type]} for param '#{param[:name]}' in #{name}. " \
198
+ "Valid types: #{TYPE_MAP.keys.join(", ")}"
199
+ end
200
+
201
+ prop = type_fragment.dup
202
+ prop["description"] = param[:desc] if param[:desc]
203
+
204
+ param[:options].each do |key, value|
205
+ prop[key.to_s] = value
206
+ end
207
+
208
+ properties[param[:name]] = prop
209
+ required << param[:name] if param[:required]
210
+ end
211
+
212
+ schema = {
213
+ "type" => "object",
214
+ "properties" => properties
215
+ }
216
+ schema["required"] = required unless required.empty?
217
+ schema
218
+ end
219
+ private_class_method :build_input_schema
220
+ end
221
+ end
@@ -183,22 +183,6 @@ module VectorMCP
183
183
  end
184
184
  end
185
185
 
186
- # Broadcasts a message to all sessions that support messaging.
187
- #
188
- # @param message [Hash] The message to broadcast
189
- # @return [Integer] Number of sessions the message was sent to
190
- def broadcast_message(message)
191
- count = 0
192
- @sessions.each_value do |session|
193
- next unless can_send_message_to_session?(session)
194
-
195
- count += 1 if message_sent_to_session?(session, message)
196
- end
197
-
198
- # Message broadcasted to recipients
199
- count
200
- end
201
-
202
186
  protected
203
187
 
204
188
  # Hook called when a session is created. Override in subclasses for transport-specific logic.
@@ -225,7 +209,7 @@ module VectorMCP
225
209
  end
226
210
 
227
211
  # Determines if this session manager should enable automatic cleanup.
228
- # Override in subclasses that don't need automatic cleanup (e.g., stdio with single session).
212
+ # Override in subclasses that don't need automatic cleanup (e.g., transports with single persistent session).
229
213
  #
230
214
  # @return [Boolean] True if auto-cleanup should be enabled
231
215
  def auto_cleanup_enabled?
@@ -17,7 +17,7 @@ module VectorMCP
17
17
  # @api private
18
18
  class EventStore
19
19
  # Event data structure
20
- Event = Struct.new(:id, :data, :type, :timestamp, :session_id) do
20
+ Event = Struct.new(:id, :data, :type, :timestamp, :session_id, :stream_id) do
21
21
  def to_sse_format
22
22
  lines = []
23
23
  lines << "id: #{id}"
@@ -36,7 +36,8 @@ module VectorMCP
36
36
  def initialize(max_events)
37
37
  @max_events = max_events
38
38
  @events = Concurrent::Array.new
39
- @event_index = Concurrent::Hash.new # event_id -> index for fast lookup
39
+ @event_index = Concurrent::Hash.new # event_id -> logical position
40
+ @offset = 0 # number of events shifted off the front
40
41
  @current_sequence = Concurrent::AtomicFixnum.new(0)
41
42
  end
42
43
 
@@ -45,26 +46,23 @@ module VectorMCP
45
46
  # @param data [String] The event data
46
47
  # @param type [String] The event type (optional)
47
48
  # @param session_id [String, nil] The session ID to scope this event to
49
+ # @param stream_id [String, nil] The stream ID to scope this event to
48
50
  # @return [String] The generated event ID
49
- def store_event(data, type = nil, session_id: nil)
51
+ def store_event(data, type = nil, session_id: nil, stream_id: nil)
50
52
  event_id = generate_event_id
51
53
  timestamp = Time.now
52
54
 
53
- event = Event.new(event_id, data, type, timestamp, session_id)
55
+ event = Event.new(event_id, data, type, timestamp, session_id, stream_id)
54
56
 
55
- # Add to events array
57
+ # Add to events array and record logical position
56
58
  @events.push(event)
57
-
58
- # Update index
59
- @event_index[event_id] = @events.length - 1
59
+ @event_index[event_id] = @offset + @events.length - 1
60
60
 
61
61
  # Maintain circular buffer
62
62
  if @events.length > @max_events
63
63
  removed_event = @events.shift
64
64
  @event_index.delete(removed_event.id)
65
-
66
- # Update all indices after removal
67
- @event_index.transform_values! { |index| index - 1 }
65
+ @offset += 1
68
66
  end
69
67
 
70
68
  event_id
@@ -74,24 +72,37 @@ module VectorMCP
74
72
  #
75
73
  # @param last_event_id [String] The last event ID received by client
76
74
  # @param session_id [String, nil] Filter events to this session only
75
+ # @param stream_id [String, nil] Filter events to this stream only
77
76
  # @return [Array<Event>] Array of events after the specified ID
78
- def get_events_after(last_event_id, session_id: nil)
77
+ def get_events_after(last_event_id, session_id: nil, stream_id: nil)
79
78
  events = if last_event_id.nil?
80
79
  @events.to_a
81
80
  else
82
- last_index = @event_index[last_event_id]
83
- return [] if last_index.nil?
81
+ logical = @event_index[last_event_id]
82
+ return [] if logical.nil?
84
83
 
85
- start_index = last_index + 1
86
- return [] if start_index >= @events.length
84
+ physical = logical - @offset + 1
85
+ return [] if physical >= @events.length
87
86
 
88
- @events[start_index..]
87
+ @events[physical..]
89
88
  end
90
89
 
91
90
  events = events.select { |e| e.session_id == session_id } if session_id
91
+ events = events.select { |e| e.stream_id == stream_id } if stream_id
92
92
  events
93
93
  end
94
94
 
95
+ # Retrieves a specific event by ID.
96
+ #
97
+ # @param event_id [String] The event ID to look up
98
+ # @return [Event, nil] The stored event, or nil if it is no longer retained
99
+ def get_event(event_id)
100
+ logical = @event_index[event_id]
101
+ return nil if logical.nil?
102
+
103
+ @events[logical - @offset]
104
+ end
105
+
95
106
  # Gets the total number of stored events.
96
107
  #
97
108
  # @return [Integer] Number of events currently stored
@@ -127,6 +138,7 @@ module VectorMCP
127
138
  def clear
128
139
  @events.clear
129
140
  @event_index.clear
141
+ @offset = 0
130
142
  end
131
143
 
132
144
  # Gets statistics about the event store.
@@ -34,7 +34,7 @@ module VectorMCP
34
34
  end
35
35
 
36
36
  def streaming?
37
- metadata[:streaming_connection] && !metadata[:streaming_connection].nil?
37
+ !streaming_connection.nil? || !streaming_connections.empty?
38
38
  end
39
39
 
40
40
  def streaming_connection
@@ -44,6 +44,25 @@ module VectorMCP
44
44
  def streaming_connection=(connection)
45
45
  metadata[:streaming_connection] = connection
46
46
  end
47
+
48
+ def streaming_connections
49
+ metadata[:streaming_connections] ||= Concurrent::Hash.new
50
+ end
51
+
52
+ def add_streaming_connection(connection)
53
+ streaming_connections[connection.stream_id] = connection
54
+ self.streaming_connection = connection
55
+ end
56
+
57
+ def remove_streaming_connection(connection = nil)
58
+ if connection
59
+ streaming_connections.delete(connection.stream_id)
60
+ self.streaming_connection = streaming_connections.values.first if streaming_connection == connection
61
+ else
62
+ streaming_connections.clear
63
+ self.streaming_connection = nil
64
+ end
65
+ end
47
66
  end
48
67
 
49
68
  # Initializes a new HTTP stream session manager.
@@ -51,24 +70,19 @@ module VectorMCP
51
70
  # @param transport [HttpStream] The parent transport instance
52
71
  # @param session_timeout [Integer] Session timeout in seconds
53
72
 
54
- # Optimized session creation with reduced object allocation and faster context creation
73
+ # Creates a new session. RequestContext.from_rack_env handles nil
74
+ # rack_env by falling back to a minimal context, so we don't need to
75
+ # branch here.
55
76
  def create_session(session_id = nil, rack_env = nil)
56
77
  session_id ||= generate_session_id
57
78
  now = Time.now
58
79
 
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)
80
+ request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
81
+ session_context = VectorMCP::Session.new(
82
+ @transport.server, @transport, id: session_id, request_context: request_context
83
+ )
71
84
 
85
+ session = Session.new(session_id, session_context, now, now, create_session_metadata)
72
86
  @sessions[session_id] = session
73
87
 
74
88
  logger.info { "Session created: #{session_id}" }
@@ -97,19 +111,6 @@ module VectorMCP
97
111
  create_session(nil, rack_env)
98
112
  end
99
113
 
100
- # Creates a VectorMCP::Session with proper request context from Rack environment
101
- def create_session_with_context(session_id, rack_env)
102
- request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
103
- VectorMCP::Session.new(@transport.server, @transport, id: session_id, request_context: request_context)
104
- end
105
-
106
- # Creates a minimal session context for each session (no caching to prevent contamination)
107
- def create_minimal_session_context(session_id)
108
- # Create a new minimal context for each session to prevent cross-session contamination
109
- minimal_context = VectorMCP::RequestContext.minimal("http_stream")
110
- VectorMCP::Session.new(@transport.server, @transport, id: session_id, request_context: minimal_context)
111
- end
112
-
113
114
  # Terminates a session by ID.
114
115
  #
115
116
  # @param session_id [String] The session ID to terminate
@@ -131,17 +132,18 @@ module VectorMCP
131
132
  # @param connection [Object] The streaming connection object
132
133
  # @return [void]
133
134
  def set_streaming_connection(session, connection)
134
- session.streaming_connection = connection
135
+ session.add_streaming_connection(connection)
135
136
  session.touch!
136
- logger.debug { "Streaming connection associated: #{session.id}" }
137
+ logger.debug { "Streaming connection associated: #{session.id} (stream #{connection.stream_id})" }
137
138
  end
138
139
 
139
140
  # Removes streaming connection from a session.
140
141
  #
141
142
  # @param session [Session] The session to remove streaming from
143
+ # @param connection [Object, nil] The specific connection to remove, or nil to clear all
142
144
  # @return [void]
143
- def remove_streaming_connection(session)
144
- session.streaming_connection = nil
145
+ def remove_streaming_connection(session, connection = nil)
146
+ session.remove_streaming_connection(connection)
145
147
  session.touch!
146
148
  logger.debug { "Streaming connection removed: #{session.id}" }
147
149
  end
@@ -155,7 +157,7 @@ module VectorMCP
155
157
 
156
158
  # Override: Returns metadata for new HTTP stream sessions.
157
159
  def create_session_metadata
158
- { streaming_connection: nil }
160
+ { streaming_connection: nil, streaming_connections: Concurrent::Hash.new }
159
161
  end
160
162
 
161
163
  # Override: Checks if a session can receive messages (has streaming connection).
@@ -175,15 +177,18 @@ module VectorMCP
175
177
  # @param session [Session] The session whose connection to close
176
178
  # @return [void]
177
179
  def close_streaming_connection(session)
178
- return unless session&.streaming_connection
180
+ return unless session&.streaming?
181
+
182
+ connections = session.streaming_connections.values
183
+ connections = [session.streaming_connection] if connections.empty? && session.streaming_connection
179
184
 
180
- begin
181
- session.streaming_connection.close
185
+ connections.each do |connection|
186
+ connection.close
182
187
  rescue StandardError => e
183
188
  logger.warn { "Error closing streaming connection for #{session.id}: #{e.message}" }
184
189
  end
185
190
 
186
- session.streaming_connection = nil
191
+ session.remove_streaming_connection
187
192
  end
188
193
  end
189
194
  end