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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/core_ext/hash/indifferent_access"
5
+ require "vector_mcp/tool"
6
+
7
+ module VectorMCP
8
+ module Rails
9
+ # Rails-aware base class for declarative tool definitions.
10
+ #
11
+ # Adds ergonomics for the common patterns that show up in ActiveRecord-
12
+ # backed MCP tools:
13
+ #
14
+ # * +find!+ -- fetch a record or raise +VectorMCP::NotFoundError+
15
+ # * +respond_with+ -- standard success/error payload from a record
16
+ # * +with_transaction+ -- wrap a mutation in an AR transaction
17
+ # * Auto-rescue of +ActiveRecord::RecordNotFound+ (-> NotFoundError)
18
+ # and +ActiveRecord::RecordInvalid+ (-> error payload)
19
+ # * Arguments delivered to +#call+ as a +HashWithIndifferentAccess+
20
+ # so +args[:id]+ and +args["id"]+ both work
21
+ #
22
+ # @example
23
+ # class UpdateProvider < VectorMCP::Rails::Tool
24
+ # tool_name "update_provider"
25
+ # description "Update an existing provider"
26
+ #
27
+ # param :id, type: :integer, required: true
28
+ # param :name, type: :string
29
+ #
30
+ # def call(args, _session)
31
+ # provider = find!(Provider, args[:id])
32
+ # provider.update(args.except(:id))
33
+ # respond_with(provider, name: provider.name)
34
+ # end
35
+ # end
36
+ class Tool < VectorMCP::Tool
37
+ # Overrides the parent handler to add indifferent-access args and
38
+ # auto-rescue ActiveRecord exceptions.
39
+ def self.build_handler
40
+ klass = self
41
+ params = @params
42
+ lambda do |args, session|
43
+ coerced = klass.coerce_args(args, params).with_indifferent_access
44
+ klass.new.call(coerced, session)
45
+ rescue ActiveRecord::RecordNotFound => e
46
+ raise VectorMCP::NotFoundError, e.message
47
+ rescue ActiveRecord::RecordInvalid => e
48
+ { success: false, errors: e.record.errors.full_messages }
49
+ end
50
+ end
51
+ private_class_method :build_handler
52
+
53
+ # Finds a record by id or raises VectorMCP::NotFoundError.
54
+ #
55
+ # @param model [Class] an ActiveRecord model class
56
+ # @param id [Integer, String] the record id
57
+ # @return [ActiveRecord::Base]
58
+ def find!(model, id)
59
+ model.find_by(id: id) ||
60
+ raise(VectorMCP::NotFoundError, "#{model.name} #{id} not found")
61
+ end
62
+
63
+ # Builds a standard response payload from a record.
64
+ #
65
+ # Success shape: +{ success: true, id: record.id, **extras }+
66
+ # Error shape: +{ success: false, errors: record.errors.full_messages }+
67
+ #
68
+ # @param record [ActiveRecord::Base]
69
+ # @param extras [Hash] additional keys to merge into the success payload
70
+ # @return [Hash]
71
+ def respond_with(record, **extras)
72
+ if record.persisted? && record.errors.empty?
73
+ { success: true, id: record.id, **extras }
74
+ else
75
+ { success: false, errors: record.errors.full_messages }
76
+ end
77
+ end
78
+
79
+ # Runs the given block inside an ActiveRecord transaction.
80
+ def with_transaction(&)
81
+ ActiveRecord::Base.transaction(&)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -92,7 +92,7 @@ module VectorMCP
92
92
  end
93
93
 
94
94
  # Create a minimal request context for non-HTTP transports.
95
- # This is useful for stdio and other command-line transports.
95
+ # This is useful for non-HTTP transports or testing contexts.
96
96
  #
97
97
  # @param transport_type [String] The transport type identifier
98
98
  # @return [RequestContext] A minimal request context
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "auth_result"
4
+
3
5
  module VectorMCP
4
6
  module Security
5
7
  # Manages authentication strategies for VectorMCP servers
@@ -42,25 +44,22 @@ module VectorMCP
42
44
  # Authenticate a request using the specified or default strategy
43
45
  # @param request [Hash] the request object containing headers, params, etc.
44
46
  # @param strategy [Symbol] optional strategy override
45
- # @return [Object, false] authentication result or false if failed
47
+ # @return [AuthResult] the authentication outcome
46
48
  def authenticate(request, strategy: nil)
47
- return { authenticated: true, user: nil } unless @enabled
49
+ return AuthResult.passthrough unless @enabled
48
50
 
49
51
  strategy_name = strategy || @default_strategy
50
52
  auth_strategy = @strategies[strategy_name]
53
+ return AuthResult.failure unless auth_strategy
51
54
 
52
- return { authenticated: false, error: "Unknown strategy: #{strategy_name}" } unless auth_strategy
53
-
54
- begin
55
- result = auth_strategy.authenticate(request)
56
- if result
57
- { authenticated: true, user: result }
58
- else
59
- { authenticated: false, error: "Authentication failed" }
60
- end
61
- rescue StandardError => e
62
- { authenticated: false, error: "Authentication error: #{e.message}" }
55
+ result = auth_strategy.authenticate(request)
56
+ if result == false
57
+ AuthResult.failure
58
+ else
59
+ AuthResult.success(user: result, strategy: strategy_name.to_s)
63
60
  end
61
+ rescue StandardError
62
+ AuthResult.failure
64
63
  end
65
64
 
66
65
  # Check if authentication is required
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ # Value object representing the outcome of an authentication attempt.
6
+ # Replaces the unstructured Hash that previously flowed through the auth pipeline.
7
+ class AuthResult
8
+ attr_reader :user, :strategy, :authenticated_at
9
+
10
+ def initialize(authenticated:, user: nil, strategy: nil, authenticated_at: nil)
11
+ @authenticated = authenticated
12
+ @user = user
13
+ @strategy = strategy
14
+ @authenticated_at = authenticated_at || (Time.now if authenticated)
15
+ freeze
16
+ end
17
+
18
+ def authenticated? = @authenticated
19
+
20
+ def self.success(user:, strategy:, authenticated_at: Time.now)
21
+ new(authenticated: true, user: user, strategy: strategy, authenticated_at: authenticated_at)
22
+ end
23
+
24
+ def self.failure
25
+ new(authenticated: false)
26
+ end
27
+
28
+ def self.passthrough
29
+ new(authenticated: true)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -10,6 +10,7 @@ module VectorMCP
10
10
  def initialize
11
11
  @policies = {}
12
12
  @enabled = false
13
+ @logger = VectorMCP.logger_for("authorization")
13
14
  end
14
15
 
15
16
  # Enable authorization system
@@ -45,17 +46,12 @@ module VectorMCP
45
46
 
46
47
  resource_type = determine_resource_type(resource)
47
48
  policy = @policies[resource_type]
48
-
49
- # If no policy is defined, allow access (opt-in authorization)
50
49
  return true unless policy
51
50
 
52
- begin
53
- policy_result = policy.call(user, action, resource)
54
- policy_result ? true : false
55
- rescue StandardError
56
- # Log error but deny access for safety
57
- false
58
- end
51
+ !!policy.call(user, action, resource)
52
+ rescue StandardError => e
53
+ @logger.error("Authorization policy error for #{resource_type}: #{e.message}")
54
+ false
59
55
  end
60
56
 
61
57
  # Check if authorization is required
@@ -120,11 +120,11 @@ module VectorMCP
120
120
  # @param transport_request [Object] the transport request
121
121
  # @return [Hash] extracted request data
122
122
  def extract_request_data(transport_request)
123
- # Handle Rack environment (for SSE transport)
123
+ # Handle Rack environment (for HTTP transports)
124
124
  if transport_request.respond_to?(:[]) && transport_request["REQUEST_METHOD"]
125
125
  extract_from_rack_env(transport_request)
126
126
  else
127
- # Default fallback
127
+ # Default fallback for non-HTTP request formats
128
128
  { headers: {}, params: {} }
129
129
  end
130
130
  end
@@ -113,34 +113,18 @@ module VectorMCP
113
113
  new(authenticated: false)
114
114
  end
115
115
 
116
- # Create an authenticated session context from auth result
117
- # @param auth_result [Hash] the authentication result
118
- # @return [SessionContext] an authenticated session
116
+ # Create an authenticated session context from an AuthResult
117
+ # @param auth_result [VectorMCP::Security::AuthResult] the authentication outcome
118
+ # @return [SessionContext] an authenticated or anonymous session
119
119
  def self.from_auth_result(auth_result)
120
- return anonymous unless auth_result&.dig(:authenticated)
121
-
122
- user_data = auth_result[:user]
123
-
124
- # Handle special marker for authenticated nil user
125
- if user_data == :authenticated_nil_user
126
- new(
127
- user: nil,
128
- authenticated: true,
129
- auth_strategy: "custom",
130
- authenticated_at: Time.now
131
- )
132
- else
133
- # Extract strategy and authenticated_at only if user_data is a Hash
134
- strategy = user_data.is_a?(Hash) ? user_data[:strategy] : nil
135
- auth_time = user_data.is_a?(Hash) ? user_data[:authenticated_at] : nil
136
-
137
- new(
138
- user: user_data,
139
- authenticated: true,
140
- auth_strategy: strategy,
141
- authenticated_at: auth_time
142
- )
143
- end
120
+ return anonymous unless auth_result&.authenticated?
121
+
122
+ new(
123
+ user: auth_result.user,
124
+ authenticated: true,
125
+ auth_strategy: auth_result.strategy,
126
+ authenticated_at: auth_result.authenticated_at
127
+ )
144
128
  end
145
129
  end
146
130
  end
@@ -38,11 +38,7 @@ module VectorMCP
38
38
  return false unless api_key&.length&.positive?
39
39
 
40
40
  if secure_key_match?(api_key)
41
- {
42
- api_key: api_key,
43
- strategy: "api_key",
44
- authenticated_at: Time.now
45
- }
41
+ { api_key: api_key }
46
42
  else
47
43
  false
48
44
  end
@@ -16,20 +16,20 @@ module VectorMCP
16
16
  @handler = handler
17
17
  end
18
18
 
19
- # Authenticate a request using the custom handler
19
+ # Authenticate a request using the custom handler.
20
+ # If the handler returns a Hash with a :user key, the value is extracted
21
+ # so that AuthManager receives the user data directly.
20
22
  # @param request [Hash] the request object
21
- # @return [Object, false] result from custom handler or false if authentication failed
23
+ # @return [Object, nil, false] user data or false if authentication failed.
24
+ # A return of nil (from { user: nil }) signals "authenticated, no user object."
22
25
  def authenticate(request)
23
26
  result = @handler.call(request)
27
+ return false unless result && result != false
24
28
 
25
- # Ensure result includes strategy info if it's successful
26
- if result && result != false
27
- format_successful_result(result)
28
- else
29
- false
30
- end
31
- rescue NoMemoryError, StandardError
32
- # Log error but return false for security
29
+ return result unless result.is_a?(Hash) && result.key?(:user)
30
+
31
+ result[:user]
32
+ rescue StandardError, NoMemoryError
33
33
  false
34
34
  end
35
35
 
@@ -38,33 +38,6 @@ module VectorMCP
38
38
  def configured?
39
39
  !@handler.nil?
40
40
  end
41
-
42
- private
43
-
44
- # Format successful authentication result with strategy metadata
45
- # @param result [Object] the result from the custom handler
46
- # @return [Object] formatted result with strategy metadata
47
- def format_successful_result(result)
48
- case result
49
- when Hash
50
- # If result has a :user key, extract it and use as main user data
51
- if result.key?(:user)
52
- user_data = result[:user]
53
- # For nil user, return a marker that will become nil in session context
54
- return :authenticated_nil_user if user_data.nil?
55
-
56
- user_data
57
- else
58
- result.merge(strategy: "custom", authenticated_at: Time.now)
59
- end
60
- else
61
- {
62
- user: result,
63
- strategy: "custom",
64
- authenticated_at: Time.now
65
- }
66
- end
67
- end
68
41
  end
69
42
  end
70
43
  end
@@ -43,16 +43,7 @@ module VectorMCP
43
43
 
44
44
  begin
45
45
  decoded = JWT.decode(token, @secret, true, @options)
46
- payload = decoded[0] # First element is the payload
47
- headers = decoded[1] # Second element is the headers
48
-
49
- # Return user info from JWT payload
50
- {
51
- **payload,
52
- strategy: "jwt",
53
- authenticated_at: Time.now,
54
- jwt_headers: headers
55
- }
46
+ decoded[0]
56
47
  rescue JWT::ExpiredSignature, JWT::InvalidIssuerError, JWT::InvalidAudienceError,
57
48
  JWT::VerificationError, JWT::DecodeError, StandardError
58
49
  false # Token validation failed
@@ -39,22 +39,7 @@ module VectorMCP
39
39
  # Notifies connected clients that the list of available prompts has changed.
40
40
  # @return [void]
41
41
  def notify_prompts_list_changed
42
- return unless transport && @prompts_list_changed
43
-
44
- notification_method = "notifications/prompts/list_changed"
45
- begin
46
- if transport.respond_to?(:broadcast_notification)
47
- logger.debug("Broadcasting prompts list changed notification.")
48
- transport.broadcast_notification(notification_method)
49
- elsif transport.respond_to?(:send_notification)
50
- logger.debug("Sending prompts list changed notification (transport may broadcast or send to first client).")
51
- transport.send_notification(notification_method)
52
- else
53
- logger.warn("Transport does not support sending notifications/prompts/list_changed.")
54
- end
55
- rescue StandardError => e
56
- logger.error("Failed to send prompts list changed notification: #{e.class.name}: #{e.message}")
57
- end
42
+ send_list_changed_notification("prompts") if @prompts_list_changed
58
43
  end
59
44
 
60
45
  # Resets the `roots_list_changed` flag to false.
@@ -66,22 +51,7 @@ module VectorMCP
66
51
  # Notifies connected clients that the list of available roots has changed.
67
52
  # @return [void]
68
53
  def notify_roots_list_changed
69
- return unless transport && @roots_list_changed
70
-
71
- notification_method = "notifications/roots/list_changed"
72
- begin
73
- if transport.respond_to?(:broadcast_notification)
74
- logger.debug("Broadcasting roots list changed notification.")
75
- transport.broadcast_notification(notification_method)
76
- elsif transport.respond_to?(:send_notification)
77
- logger.debug("Sending roots list changed notification (transport may broadcast or send to first client).")
78
- transport.send_notification(notification_method)
79
- else
80
- logger.warn("Transport does not support sending notifications/roots/list_changed.")
81
- end
82
- rescue StandardError => e
83
- logger.error("Failed to send roots list changed notification: #{e.class.name}: #{e.message}")
84
- end
54
+ send_list_changed_notification("roots") if @roots_list_changed
85
55
  end
86
56
 
87
57
  # Registers a session as a subscriber to prompt list changes.
@@ -93,6 +63,26 @@ module VectorMCP
93
63
 
94
64
  private
95
65
 
66
+ # Sends a `notifications/<kind>/list_changed` notification to the transport.
67
+ # No-op if no transport is attached. Logs a warning if the transport does not
68
+ # implement `send_notification` (intentional extension point for alternate
69
+ # transports).
70
+ # @api private
71
+ # @param kind [String] One of "prompts" or "roots".
72
+ def send_list_changed_notification(kind)
73
+ return unless transport
74
+
75
+ notification_method = "notifications/#{kind}/list_changed"
76
+ if transport.respond_to?(:send_notification)
77
+ logger.debug("Sending #{kind} list changed notification.")
78
+ transport.send_notification(notification_method)
79
+ else
80
+ logger.warn("Transport does not support sending #{notification_method}.")
81
+ end
82
+ rescue StandardError => e
83
+ logger.error("Failed to send #{kind} list changed notification: #{e.class.name}: #{e.message}")
84
+ end
85
+
96
86
  # Configures sampling capabilities based on provided configuration.
97
87
  # @api private
98
88
  def configure_sampling_capabilities(config)
@@ -20,17 +20,18 @@ module VectorMCP
20
20
  method = message["method"]
21
21
  params = message["params"] || {}
22
22
 
23
- if id && method # Request
23
+ case classify_message(id, method)
24
+ when :request
24
25
  logger.debug("[#{session_id}] Request [#{id}]: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
25
26
  handle_request(id, method, params, session)
26
- elsif method # Notification
27
+ when :notification
27
28
  logger.debug("[#{session_id}] Notification: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
28
29
  handle_notification(method, params, session)
29
- nil # Notifications do not have a return value to send back to client
30
- elsif id # Invalid: Has ID but no method
30
+ nil
31
+ when :invalid_missing_method
31
32
  logger.warn("[#{session_id}] Invalid message: Has ID [#{id}] but no method. #{message.inspect}")
32
33
  raise VectorMCP::InvalidRequestError.new("Request object must include a 'method' member.", request_id: id)
33
- else # Invalid: No ID and no method
34
+ when :invalid_missing_both
34
35
  logger.warn("[#{session_id}] Invalid message: Missing both 'id' and 'method'. #{message.inspect}")
35
36
  raise VectorMCP::InvalidRequestError.new("Invalid message format", request_id: nil)
36
37
  end
@@ -60,6 +61,16 @@ module VectorMCP
60
61
 
61
62
  private
62
63
 
64
+ # Classify a JSON-RPC message based on presence of id and method fields.
65
+ # @api private
66
+ def classify_message(id, method)
67
+ return :request if id && method
68
+ return :notification if method
69
+ return :invalid_missing_method if id
70
+
71
+ :invalid_missing_both
72
+ end
73
+
63
74
  # Internal handler for JSON-RPC requests.
64
75
  # @api private
65
76
  def handle_request(id, method, params, session)
@@ -72,11 +83,11 @@ module VectorMCP
72
83
  end
73
84
 
74
85
  # Validates that the session is properly initialized for the given request.
86
+ # Transports are contractually required to pass a VectorMCP::Session here —
87
+ # never the SessionManager wrapper struct.
75
88
  # @api private
76
89
  def validate_session_initialization(id, method, _params, session)
77
- # Handle both direct VectorMCP::Session and BaseSessionManager::Session wrapper
78
- actual_session = session.respond_to?(:context) ? session.context : session
79
- return if actual_session.initialized?
90
+ return if session.initialized?
80
91
 
81
92
  # Allow "initialize" even if not marked initialized yet by server
82
93
  return if method == "initialize"
@@ -115,9 +126,7 @@ module VectorMCP
115
126
  # Internal handler for JSON-RPC notifications.
116
127
  # @api private
117
128
  def handle_notification(method, params, session)
118
- # Handle both direct VectorMCP::Session and BaseSessionManager::Session wrapper
119
- actual_session = session.respond_to?(:context) ? session.context : session
120
- unless actual_session.initialized? || method == "initialized"
129
+ unless session.initialized? || method == "initialized"
121
130
  logger.warn("Ignoring notification '#{method}' before session is initialized. Params: #{params.inspect}")
122
131
  return
123
132
  end
@@ -162,9 +171,7 @@ module VectorMCP
162
171
  # @api private
163
172
  def session_method(method_name)
164
173
  lambda do |params, session, _server|
165
- # Handle both direct VectorMCP::Session and BaseSessionManager::Session wrapper
166
- actual_session = session.respond_to?(:context) ? session.context : session
167
- actual_session.public_send(method_name, params)
174
+ session.public_send(method_name, params)
168
175
  end
169
176
  end
170
177
  end