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
@@ -5,8 +5,6 @@ require "logger"
5
5
  require_relative "definitions"
6
6
  require_relative "session"
7
7
  require_relative "errors"
8
- require_relative "transport/stdio" # Default transport
9
- # require_relative "transport/sse" # Load on demand to avoid async dependencies
10
8
  require_relative "handlers/core" # Default handlers
11
9
  require_relative "util" # Needed if not using Handlers::Core
12
10
  require_relative "server/registry"
@@ -26,7 +24,7 @@ module VectorMCP
26
24
  # It manages tools, resources, prompts, and handles the MCP message lifecycle.
27
25
  #
28
26
  # A server instance is typically initialized, configured with capabilities (tools,
29
- # resources, prompts), and then run with a chosen transport mechanism (e.g., Stdio, SSE).
27
+ # resources, prompts), and then run with a chosen transport mechanism (e.g., HttpStream).
30
28
  #
31
29
  # @example Creating and running a simple server
32
30
  # server = VectorMCP::Server.new(name: "MySimpleServer", version: "1.0")
@@ -39,7 +37,7 @@ module VectorMCP
39
37
  # args["message"]
40
38
  # end
41
39
  #
42
- # server.run(transport: :stdio) # Runs with Stdio transport by default
40
+ # server.run # Runs with HttpStream transport by default
43
41
  #
44
42
  # @!attribute [r] logger
45
43
  # @return [Logger] The logger instance for this server.
@@ -68,7 +66,10 @@ module VectorMCP
68
66
  include MessageHandling
69
67
 
70
68
  # The specific version of the Model Context Protocol this server implements.
71
- PROTOCOL_VERSION = "2024-11-05"
69
+ PROTOCOL_VERSION = "2025-11-25"
70
+
71
+ # All protocol versions this server accepts via the MCP-Protocol-Version header.
72
+ SUPPORTED_PROTOCOL_VERSIONS = %w[2025-11-25 2025-03-26 2024-11-05].freeze
72
73
 
73
74
  attr_reader :logger, :name, :version, :protocol_version, :tools, :resources, :prompts, :roots, :in_flight_requests,
74
75
  :auth_manager, :authorization, :security_middleware, :middleware_manager
@@ -134,33 +135,19 @@ module VectorMCP
134
135
 
135
136
  # Runs the server using the specified transport mechanism.
136
137
  #
137
- # @param transport [:stdio, :sse, :http_stream, VectorMCP::Transport::Base] The transport to use.
138
- # Can be a symbol (`:stdio`, `:sse`, `:http_stream`) or an initialized transport instance.
139
- # If a symbol is provided, the method will instantiate the corresponding transport class.
140
- # If `:sse` is chosen, it uses Puma as the HTTP server (deprecated).
141
- # If `:http_stream` is chosen, it uses the MCP-compliant streamable HTTP transport.
142
- # @param options [Hash] Transport-specific options (e.g., `:host`, `:port` for HTTP transports).
138
+ # @param transport [:http_stream, VectorMCP::Transport::Base] The transport to use.
139
+ # Can be the symbol `:http_stream` or an initialized transport instance.
140
+ # If `:http_stream` is provided, the method will instantiate the MCP-compliant streamable HTTP transport.
141
+ # @param options [Hash] Transport-specific options (e.g., `:host`, `:port`).
143
142
  # These are passed to the transport's constructor if a symbol is provided for `transport`.
144
143
  # @return [void]
145
144
  # @raise [ArgumentError] if an unsupported transport symbol is given.
146
- # @raise [NotImplementedError] if `:sse` transport is specified (currently a placeholder).
147
- def run(transport: :stdio, **options)
145
+ def run(transport: :http_stream, **)
148
146
  active_transport = case transport
149
- when :stdio
150
- VectorMCP::Transport::Stdio.new(self, **options)
151
- when :sse
152
- begin
153
- require_relative "transport/sse"
154
- logger.warn("SSE transport is deprecated. Please use :http_stream instead.")
155
- VectorMCP::Transport::SSE.new(self, **options)
156
- rescue LoadError => e
157
- logger.fatal("SSE transport requires additional dependencies.")
158
- raise NotImplementedError, "SSE transport dependencies not available: #{e.message}"
159
- end
160
147
  when :http_stream
161
148
  begin
162
149
  require_relative "transport/http_stream"
163
- VectorMCP::Transport::HttpStream.new(self, **options)
150
+ VectorMCP::Transport::HttpStream.new(self, **)
164
151
  rescue LoadError => e
165
152
  logger.fatal("HttpStream transport requires additional dependencies.")
166
153
  raise NotImplementedError, "HttpStream transport dependencies not available: #{e.message}"
@@ -176,6 +163,26 @@ module VectorMCP
176
163
  active_transport.run
177
164
  end
178
165
 
166
+ # Returns the MCP server as a Rack application suitable for mounting inside
167
+ # another Rack-based framework (e.g., Rails, Sinatra).
168
+ #
169
+ # Unlike {#run}, this method does NOT start its own HTTP server or block.
170
+ # The returned object responds to `#call(env)` and can be mounted directly:
171
+ #
172
+ # # config/routes.rb (Rails)
173
+ # mount MCP_APP => "/mcp"
174
+ #
175
+ # Call `server.transport.stop` on application shutdown to clean up resources.
176
+ #
177
+ # @param options [Hash] Transport options (e.g., :session_timeout, :event_retention, :allowed_origins)
178
+ # @return [VectorMCP::Transport::HttpStream] A Rack-compatible app
179
+ def rack_app(**)
180
+ require_relative "transport/http_stream"
181
+ active_transport = VectorMCP::Transport::HttpStream.new(self, mounted: true, **)
182
+ self.transport = active_transport
183
+ active_transport
184
+ end
185
+
179
186
  # --- Security Configuration ---
180
187
 
181
188
  # Enable authentication with specified strategy and configuration
@@ -190,7 +197,7 @@ module VectorMCP
190
197
 
191
198
  case strategy
192
199
  when :api_key
193
- add_api_key_auth(options[:keys] || [])
200
+ add_api_key_auth(options[:keys] || [], allow_query_params: options[:allow_query_params] || false)
194
201
  when :jwt
195
202
  add_jwt_auth(options)
196
203
  when :custom
@@ -216,9 +223,9 @@ module VectorMCP
216
223
  # Enable authorization with optional policy configuration block
217
224
  # @param block [Proc] optional block for configuring authorization policies
218
225
  # @return [void]
219
- def enable_authorization!(&block)
226
+ def enable_authorization!(&)
220
227
  @authorization.enable!
221
- instance_eval(&block) if block_given?
228
+ instance_eval(&) if block_given?
222
229
  @logger.info("Authorization enabled")
223
230
  end
224
231
 
@@ -232,29 +239,29 @@ module VectorMCP
232
239
  # Add authorization policy for tools
233
240
  # @param block [Proc] policy block that receives (user, action, tool)
234
241
  # @return [void]
235
- def authorize_tools(&block)
236
- @authorization.add_policy(:tool, &block)
242
+ def authorize_tools(&)
243
+ @authorization.add_policy(:tool, &)
237
244
  end
238
245
 
239
246
  # Add authorization policy for resources
240
247
  # @param block [Proc] policy block that receives (user, action, resource)
241
248
  # @return [void]
242
- def authorize_resources(&block)
243
- @authorization.add_policy(:resource, &block)
249
+ def authorize_resources(&)
250
+ @authorization.add_policy(:resource, &)
244
251
  end
245
252
 
246
253
  # Add authorization policy for prompts
247
254
  # @param block [Proc] policy block that receives (user, action, prompt)
248
255
  # @return [void]
249
- def authorize_prompts(&block)
250
- @authorization.add_policy(:prompt, &block)
256
+ def authorize_prompts(&)
257
+ @authorization.add_policy(:prompt, &)
251
258
  end
252
259
 
253
260
  # Add authorization policy for roots
254
261
  # @param block [Proc] policy block that receives (user, action, root)
255
262
  # @return [void]
256
- def authorize_roots(&block)
257
- @authorization.add_policy(:root, &block)
263
+ def authorize_roots(&)
264
+ @authorization.add_policy(:root, &)
258
265
  end
259
266
 
260
267
  # Check if security features are enabled
@@ -313,9 +320,10 @@ module VectorMCP
313
320
 
314
321
  # Add API key authentication strategy
315
322
  # @param keys [Array<String>] array of valid API keys
323
+ # @param allow_query_params [Boolean] whether to accept API keys from query parameters
316
324
  # @return [void]
317
- def add_api_key_auth(keys)
318
- strategy = Security::Strategies::ApiKey.new(keys: keys)
325
+ def add_api_key_auth(keys, allow_query_params: false)
326
+ strategy = Security::Strategies::ApiKey.new(keys: keys, allow_query_params: allow_query_params)
319
327
  @auth_manager.add_strategy(:api_key, strategy)
320
328
  end
321
329
 
@@ -330,8 +338,8 @@ module VectorMCP
330
338
  # Add custom authentication strategy
331
339
  # @param handler [Proc] custom authentication handler block
332
340
  # @return [void]
333
- def add_custom_auth(&block)
334
- strategy = Security::Strategies::Custom.new(&block)
341
+ def add_custom_auth(&)
342
+ strategy = Security::Strategies::Custom.new(&)
335
343
  @auth_manager.add_strategy(:custom, strategy)
336
344
  end
337
345
 
@@ -346,7 +354,7 @@ module VectorMCP
346
354
 
347
355
  module Transport
348
356
  # Dummy base class placeholder used only for argument validation in tests.
349
- # Real transport classes (e.g., Stdio, SSE) are separate concrete classes.
357
+ # Real transport classes (e.g., HttpStream) are separate concrete classes.
350
358
  class Base # :nodoc:
351
359
  end
352
360
  end
@@ -4,6 +4,7 @@ require_relative "sampling/request"
4
4
  require_relative "sampling/result"
5
5
  require_relative "errors"
6
6
  require_relative "request_context"
7
+ require_relative "security/session_context"
7
8
 
8
9
  module VectorMCP
9
10
  # Represents the state of a single client-server connection session in MCP.
@@ -17,7 +18,7 @@ module VectorMCP
17
18
  # @attr_reader request_context [RequestContext] The request context for this session.
18
19
  class Session
19
20
  attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id, :request_context
20
- attr_accessor :data # For user-defined session-specific storage
21
+ attr_accessor :data, :security_context # For user-defined session-specific storage and resolved auth context
21
22
 
22
23
  # Initializes a new session.
23
24
  #
@@ -33,6 +34,7 @@ module VectorMCP
33
34
  @client_info = nil
34
35
  @client_capabilities = nil
35
36
  @data = {} # Initialize user data hash
37
+ @security_context = Security::SessionContext.anonymous
36
38
  @logger = server.logger
37
39
 
38
40
  # Initialize request context
@@ -243,11 +245,11 @@ module VectorMCP
243
245
  send_request_kwargs = {}
244
246
  send_request_kwargs[:timeout] = timeout if timeout
245
247
 
246
- # For HTTP transport, we need to use send_request_to_session to target this specific session
248
+ # Prefer session-targeted request sending when available
247
249
  raw_result = if @transport.respond_to?(:send_request_to_session)
248
250
  @transport.send_request_to_session(@id, *send_request_args, **send_request_kwargs)
249
251
  else
250
- # Fallback to generic send_request for other transports
252
+ # Fallback to generic send_request for transports without session targeting
251
253
  @transport.send_request(*send_request_args, **send_request_kwargs)
252
254
  end
253
255
 
@@ -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) 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}"
@@ -44,12 +44,14 @@ module VectorMCP
44
44
  #
45
45
  # @param data [String] The event data
46
46
  # @param type [String] The event type (optional)
47
+ # @param session_id [String, nil] The session ID to scope this event to
48
+ # @param stream_id [String, nil] The stream ID to scope this event to
47
49
  # @return [String] The generated event ID
48
- def store_event(data, type = nil)
50
+ def store_event(data, type = nil, session_id: nil, stream_id: nil)
49
51
  event_id = generate_event_id
50
52
  timestamp = Time.now
51
53
 
52
- event = Event.new(event_id, data, type, timestamp)
54
+ event = Event.new(event_id, data, type, timestamp, session_id, stream_id)
53
55
 
54
56
  # Add to events array
55
57
  @events.push(event)
@@ -69,21 +71,39 @@ module VectorMCP
69
71
  event_id
70
72
  end
71
73
 
72
- # Retrieves events starting from a specific event ID.
74
+ # Retrieves events starting from a specific event ID, optionally filtered by session.
73
75
  #
74
76
  # @param last_event_id [String] The last event ID received by client
77
+ # @param session_id [String, nil] Filter events to this session only
78
+ # @param stream_id [String, nil] Filter events to this stream only
75
79
  # @return [Array<Event>] Array of events after the specified ID
76
- def get_events_after(last_event_id)
77
- return @events.to_a if last_event_id.nil?
78
-
79
- last_index = @event_index[last_event_id]
80
- return [] if last_index.nil?
80
+ def get_events_after(last_event_id, session_id: nil, stream_id: nil)
81
+ events = if last_event_id.nil?
82
+ @events.to_a
83
+ else
84
+ last_index = @event_index[last_event_id]
85
+ return [] if last_index.nil?
86
+
87
+ start_index = last_index + 1
88
+ return [] if start_index >= @events.length
89
+
90
+ @events[start_index..]
91
+ end
92
+
93
+ events = events.select { |e| e.session_id == session_id } if session_id
94
+ events = events.select { |e| e.stream_id == stream_id } if stream_id
95
+ events
96
+ end
81
97
 
82
- # Return events after the last_event_id
83
- start_index = last_index + 1
84
- return [] if start_index >= @events.length
98
+ # Retrieves a specific event by ID.
99
+ #
100
+ # @param event_id [String] The event ID to look up
101
+ # @return [Event, nil] The stored event, or nil if it is no longer retained
102
+ def get_event(event_id)
103
+ index = @event_index[event_id]
104
+ return nil if index.nil?
85
105
 
86
- @events[start_index..]
106
+ @events[index]
87
107
  end
88
108
 
89
109
  # Gets the total number of stored events.
@@ -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.
@@ -64,7 +83,7 @@ module VectorMCP
64
83
  end
65
84
 
66
85
  # Pre-allocate metadata hash for better performance
67
- metadata = { streaming_connection: nil }
86
+ metadata = create_session_metadata
68
87
 
69
88
  # Create internal session record with streaming connection metadata
70
89
  session = Session.new(session_id, session_context, now, now, metadata)
@@ -75,7 +94,9 @@ module VectorMCP
75
94
  session
76
95
  end
77
96
 
78
- # Override to add rack_env support
97
+ # Override to add rack_env support.
98
+ # Returns nil when a session_id is provided but not found (expired or unknown).
99
+ # Callers are responsible for returning 404 in that case.
79
100
  def get_or_create_session(session_id = nil, rack_env = nil)
80
101
  if session_id
81
102
  session = get_session(session_id)
@@ -88,8 +109,8 @@ module VectorMCP
88
109
  return session
89
110
  end
90
111
 
91
- # If session_id was provided but not found, create with that ID
92
- return create_session(session_id, rack_env)
112
+ # Session ID provided but not found signal 404 to caller
113
+ return nil
93
114
  end
94
115
 
95
116
  create_session(nil, rack_env)
@@ -129,17 +150,18 @@ module VectorMCP
129
150
  # @param connection [Object] The streaming connection object
130
151
  # @return [void]
131
152
  def set_streaming_connection(session, connection)
132
- session.streaming_connection = connection
153
+ session.add_streaming_connection(connection)
133
154
  session.touch!
134
- logger.debug { "Streaming connection associated: #{session.id}" }
155
+ logger.debug { "Streaming connection associated: #{session.id} (stream #{connection.stream_id})" }
135
156
  end
136
157
 
137
158
  # Removes streaming connection from a session.
138
159
  #
139
160
  # @param session [Session] The session to remove streaming from
161
+ # @param connection [Object, nil] The specific connection to remove, or nil to clear all
140
162
  # @return [void]
141
- def remove_streaming_connection(session)
142
- session.streaming_connection = nil
163
+ def remove_streaming_connection(session, connection = nil)
164
+ session.remove_streaming_connection(connection)
143
165
  session.touch!
144
166
  logger.debug { "Streaming connection removed: #{session.id}" }
145
167
  end
@@ -153,7 +175,7 @@ module VectorMCP
153
175
 
154
176
  # Override: Returns metadata for new HTTP stream sessions.
155
177
  def create_session_metadata
156
- { streaming_connection: nil }
178
+ { streaming_connection: nil, streaming_connections: Concurrent::Hash.new }
157
179
  end
158
180
 
159
181
  # Override: Checks if a session can receive messages (has streaming connection).
@@ -173,15 +195,18 @@ module VectorMCP
173
195
  # @param session [Session] The session whose connection to close
174
196
  # @return [void]
175
197
  def close_streaming_connection(session)
176
- return unless session&.streaming_connection
198
+ return unless session&.streaming?
199
+
200
+ connections = session.streaming_connections.values
201
+ connections = [session.streaming_connection] if connections.empty? && session.streaming_connection
177
202
 
178
- begin
179
- session.streaming_connection.close
203
+ connections.each do |connection|
204
+ connection.close
180
205
  rescue StandardError => e
181
206
  logger.warn { "Error closing streaming connection for #{session.id}: #{e.message}" }
182
207
  end
183
208
 
184
- session.streaming_connection = nil
209
+ session.remove_streaming_connection
185
210
  end
186
211
  end
187
212
  end