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.
@@ -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, :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}"
@@ -45,12 +45,13 @@ module VectorMCP
45
45
  # @param data [String] The event data
46
46
  # @param type [String] The event type (optional)
47
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
48
49
  # @return [String] The generated event ID
49
- def store_event(data, type = nil, session_id: nil)
50
+ def store_event(data, type = nil, session_id: nil, stream_id: nil)
50
51
  event_id = generate_event_id
51
52
  timestamp = Time.now
52
53
 
53
- event = Event.new(event_id, data, type, timestamp, session_id)
54
+ event = Event.new(event_id, data, type, timestamp, session_id, stream_id)
54
55
 
55
56
  # Add to events array
56
57
  @events.push(event)
@@ -74,8 +75,9 @@ module VectorMCP
74
75
  #
75
76
  # @param last_event_id [String] The last event ID received by client
76
77
  # @param session_id [String, nil] Filter events to this session only
78
+ # @param stream_id [String, nil] Filter events to this stream only
77
79
  # @return [Array<Event>] Array of events after the specified ID
78
- def get_events_after(last_event_id, session_id: nil)
80
+ def get_events_after(last_event_id, session_id: nil, stream_id: nil)
79
81
  events = if last_event_id.nil?
80
82
  @events.to_a
81
83
  else
@@ -89,9 +91,21 @@ module VectorMCP
89
91
  end
90
92
 
91
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
92
95
  events
93
96
  end
94
97
 
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?
105
+
106
+ @events[index]
107
+ end
108
+
95
109
  # Gets the total number of stored events.
96
110
  #
97
111
  # @return [Integer] Number of events currently stored
@@ -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)
@@ -131,17 +150,18 @@ module VectorMCP
131
150
  # @param connection [Object] The streaming connection object
132
151
  # @return [void]
133
152
  def set_streaming_connection(session, connection)
134
- session.streaming_connection = connection
153
+ session.add_streaming_connection(connection)
135
154
  session.touch!
136
- logger.debug { "Streaming connection associated: #{session.id}" }
155
+ logger.debug { "Streaming connection associated: #{session.id} (stream #{connection.stream_id})" }
137
156
  end
138
157
 
139
158
  # Removes streaming connection from a session.
140
159
  #
141
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
142
162
  # @return [void]
143
- def remove_streaming_connection(session)
144
- session.streaming_connection = nil
163
+ def remove_streaming_connection(session, connection = nil)
164
+ session.remove_streaming_connection(connection)
145
165
  session.touch!
146
166
  logger.debug { "Streaming connection removed: #{session.id}" }
147
167
  end
@@ -155,7 +175,7 @@ module VectorMCP
155
175
 
156
176
  # Override: Returns metadata for new HTTP stream sessions.
157
177
  def create_session_metadata
158
- { streaming_connection: nil }
178
+ { streaming_connection: nil, streaming_connections: Concurrent::Hash.new }
159
179
  end
160
180
 
161
181
  # Override: Checks if a session can receive messages (has streaming connection).
@@ -175,15 +195,18 @@ module VectorMCP
175
195
  # @param session [Session] The session whose connection to close
176
196
  # @return [void]
177
197
  def close_streaming_connection(session)
178
- 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
179
202
 
180
- begin
181
- session.streaming_connection.close
203
+ connections.each do |connection|
204
+ connection.close
182
205
  rescue StandardError => e
183
206
  logger.warn { "Error closing streaming connection for #{session.id}: #{e.message}" }
184
207
  end
185
208
 
186
- session.streaming_connection = nil
209
+ session.remove_streaming_connection
187
210
  end
188
211
  end
189
212
  end