vector_mcp 0.2.0 → 0.3.1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +281 -0
  3. data/README.md +302 -373
  4. data/lib/vector_mcp/definitions.rb +3 -1
  5. data/lib/vector_mcp/errors.rb +24 -0
  6. data/lib/vector_mcp/handlers/core.rb +132 -6
  7. data/lib/vector_mcp/logging/component.rb +131 -0
  8. data/lib/vector_mcp/logging/configuration.rb +156 -0
  9. data/lib/vector_mcp/logging/constants.rb +21 -0
  10. data/lib/vector_mcp/logging/core.rb +175 -0
  11. data/lib/vector_mcp/logging/filters/component.rb +69 -0
  12. data/lib/vector_mcp/logging/filters/level.rb +23 -0
  13. data/lib/vector_mcp/logging/formatters/base.rb +52 -0
  14. data/lib/vector_mcp/logging/formatters/json.rb +83 -0
  15. data/lib/vector_mcp/logging/formatters/text.rb +72 -0
  16. data/lib/vector_mcp/logging/outputs/base.rb +64 -0
  17. data/lib/vector_mcp/logging/outputs/console.rb +35 -0
  18. data/lib/vector_mcp/logging/outputs/file.rb +157 -0
  19. data/lib/vector_mcp/logging.rb +71 -0
  20. data/lib/vector_mcp/security/auth_manager.rb +79 -0
  21. data/lib/vector_mcp/security/authorization.rb +96 -0
  22. data/lib/vector_mcp/security/middleware.rb +172 -0
  23. data/lib/vector_mcp/security/session_context.rb +147 -0
  24. data/lib/vector_mcp/security/strategies/api_key.rb +167 -0
  25. data/lib/vector_mcp/security/strategies/custom.rb +71 -0
  26. data/lib/vector_mcp/security/strategies/jwt_token.rb +118 -0
  27. data/lib/vector_mcp/security.rb +46 -0
  28. data/lib/vector_mcp/server/registry.rb +24 -0
  29. data/lib/vector_mcp/server.rb +141 -1
  30. data/lib/vector_mcp/transport/sse/client_connection.rb +113 -0
  31. data/lib/vector_mcp/transport/sse/message_handler.rb +166 -0
  32. data/lib/vector_mcp/transport/sse/puma_config.rb +77 -0
  33. data/lib/vector_mcp/transport/sse/stream_manager.rb +92 -0
  34. data/lib/vector_mcp/transport/sse.rb +119 -460
  35. data/lib/vector_mcp/version.rb +1 -1
  36. data/lib/vector_mcp.rb +35 -2
  37. metadata +63 -21
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ # Represents the security context for a user session
6
+ # Contains authentication and authorization information
7
+ class SessionContext
8
+ attr_reader :user, :authenticated, :permissions, :auth_strategy, :authenticated_at
9
+
10
+ # Initialize session context
11
+ # @param user [Object] the authenticated user object
12
+ # @param authenticated [Boolean] whether the user is authenticated
13
+ # @param auth_strategy [String] the authentication strategy used
14
+ # @param authenticated_at [Time] when authentication occurred
15
+ def initialize(user: nil, authenticated: false, auth_strategy: nil, authenticated_at: nil)
16
+ @user = user
17
+ @authenticated = authenticated
18
+ @auth_strategy = auth_strategy
19
+ @authenticated_at = authenticated_at || Time.now
20
+ @permissions = Set.new
21
+ end
22
+
23
+ # Check if the session is authenticated
24
+ # @return [Boolean] true if authenticated
25
+ def authenticated?
26
+ @authenticated
27
+ end
28
+
29
+ # Check if the user has a specific permission
30
+ # @param permission [String, Symbol] the permission to check
31
+ # @return [Boolean] true if user has the permission
32
+ def can?(permission)
33
+ @permissions.include?(permission.to_s)
34
+ end
35
+
36
+ # Check if the user can perform an action on a resource
37
+ # @param action [String, Symbol] the action (e.g., 'read', 'write', 'execute')
38
+ # @param resource [String, Symbol] the resource (e.g., 'tools', 'resources')
39
+ # @return [Boolean] true if user can perform the action
40
+ def can_access?(action, resource)
41
+ can?("#{action}:#{resource}") || can?("#{action}:*") || can?("*:#{resource}") || can?("*:*")
42
+ end
43
+
44
+ # Add a permission to the session
45
+ # @param permission [String, Symbol] the permission to add
46
+ def add_permission(permission)
47
+ @permissions << permission.to_s
48
+ end
49
+
50
+ # Add multiple permissions to the session
51
+ # @param permissions [Array<String, Symbol>] the permissions to add
52
+ def add_permissions(permissions)
53
+ permissions.each { |perm| add_permission(perm) }
54
+ end
55
+
56
+ # Remove a permission from the session
57
+ # @param permission [String, Symbol] the permission to remove
58
+ def remove_permission(permission)
59
+ @permissions.delete(permission.to_s)
60
+ end
61
+
62
+ # Clear all permissions
63
+ def clear_permissions
64
+ @permissions.clear
65
+ end
66
+
67
+ # Get user identifier for logging/auditing
68
+ # @return [String] a string identifying the user
69
+ def user_identifier
70
+ return "anonymous" unless authenticated?
71
+ return "anonymous" if @user.nil?
72
+
73
+ case @user
74
+ when Hash
75
+ @user[:user_id] || @user[:sub] || @user[:email] || @user[:api_key] || "authenticated_user"
76
+ when String
77
+ @user
78
+ else
79
+ @user.respond_to?(:id) ? @user.id.to_s : "authenticated_user"
80
+ end
81
+ end
82
+
83
+ # Get authentication method used
84
+ # @return [String] the authentication strategy
85
+ def auth_method
86
+ @auth_strategy || "none"
87
+ end
88
+
89
+ # Check if authentication is recent (within specified seconds)
90
+ # @param max_age [Integer] maximum age in seconds (default: 3600 = 1 hour)
91
+ # @return [Boolean] true if authentication is recent
92
+ def auth_recent?(max_age: 3600)
93
+ return false unless authenticated?
94
+
95
+ (Time.now - @authenticated_at) <= max_age
96
+ end
97
+
98
+ # Convert to hash for serialization
99
+ # @return [Hash] session context as hash
100
+ def to_h
101
+ {
102
+ authenticated: @authenticated,
103
+ user_identifier: user_identifier,
104
+ auth_strategy: @auth_strategy,
105
+ authenticated_at: @authenticated_at&.iso8601,
106
+ permissions: @permissions.to_a
107
+ }
108
+ end
109
+
110
+ # Create an anonymous (unauthenticated) session context
111
+ # @return [SessionContext] an unauthenticated session
112
+ def self.anonymous
113
+ new(authenticated: false)
114
+ end
115
+
116
+ # Create an authenticated session context from auth result
117
+ # @param auth_result [Hash] the authentication result
118
+ # @return [SessionContext] an authenticated session
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
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ module Strategies
6
+ # API Key authentication strategy
7
+ # Supports multiple key formats and storage methods
8
+ class ApiKey
9
+ attr_reader :valid_keys
10
+
11
+ # Initialize with a list of valid API keys
12
+ # @param keys [Array<String>] array of valid API keys
13
+ def initialize(keys: [])
14
+ @valid_keys = Set.new(keys.map(&:to_s))
15
+ end
16
+
17
+ # Add a valid API key
18
+ # @param key [String] the API key to add
19
+ def add_key(key)
20
+ @valid_keys << key.to_s
21
+ end
22
+
23
+ # Remove an API key
24
+ # @param key [String] the API key to remove
25
+ def remove_key(key)
26
+ @valid_keys.delete(key.to_s)
27
+ end
28
+
29
+ # Authenticate a request using API key
30
+ # @param request [Hash] the request object
31
+ # @return [Hash, false] user info hash or false if authentication failed
32
+ def authenticate(request)
33
+ api_key = extract_api_key(request)
34
+ return false unless api_key&.length&.positive?
35
+
36
+ if @valid_keys.include?(api_key)
37
+ {
38
+ api_key: api_key,
39
+ strategy: "api_key",
40
+ authenticated_at: Time.now
41
+ }
42
+ else
43
+ false
44
+ end
45
+ end
46
+
47
+ # Check if any keys are configured
48
+ # @return [Boolean] true if keys are available
49
+ def configured?
50
+ !@valid_keys.empty?
51
+ end
52
+
53
+ # Get count of configured keys (for debugging)
54
+ # @return [Integer] number of configured keys
55
+ def key_count
56
+ @valid_keys.size
57
+ end
58
+
59
+ private
60
+
61
+ # Extract API key from various request formats
62
+ # @param request [Hash] the request object
63
+ # @return [String, nil] the extracted API key
64
+ def extract_api_key(request)
65
+ headers = normalize_headers(request)
66
+ params = normalize_params(request)
67
+
68
+ extract_from_headers(headers) || extract_from_params(params)
69
+ end
70
+
71
+ # Normalize headers to handle different formats
72
+ # @param request [Hash] the request object
73
+ # @return [Hash] normalized headers
74
+ def normalize_headers(request)
75
+ # Check if it's a Rack environment (has REQUEST_METHOD)
76
+ if request["REQUEST_METHOD"]
77
+ extract_headers_from_rack_env(request)
78
+ else
79
+ request[:headers] || request["headers"] || {}
80
+ end
81
+ end
82
+
83
+ # Normalize params to handle different formats
84
+ # @param request [Hash] the request object
85
+ # @return [Hash] normalized params
86
+ def normalize_params(request)
87
+ # Check if it's a Rack environment (has REQUEST_METHOD)
88
+ if request["REQUEST_METHOD"]
89
+ extract_params_from_rack_env(request)
90
+ else
91
+ request[:params] || request["params"] || {}
92
+ end
93
+ end
94
+
95
+ # Extract headers from Rack environment
96
+ # @param env [Hash] the Rack environment
97
+ # @return [Hash] normalized headers
98
+ def extract_headers_from_rack_env(env)
99
+ headers = {}
100
+ env.each do |key, value|
101
+ next unless key.start_with?("HTTP_")
102
+
103
+ # Convert HTTP_X_API_KEY to X-API-Key format
104
+ header_name = key[5..].split("_").map do |part|
105
+ case part.upcase
106
+ when "API" then "API" # Keep API in all caps
107
+ else part.capitalize
108
+ end
109
+ end.join("-")
110
+ headers[header_name] = value
111
+ end
112
+
113
+ # Add special headers
114
+ headers["Authorization"] = env["HTTP_AUTHORIZATION"] if env["HTTP_AUTHORIZATION"]
115
+ headers["Content-Type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
116
+ headers
117
+ end
118
+
119
+ # Extract params from Rack environment
120
+ # @param env [Hash] the Rack environment
121
+ # @return [Hash] normalized params
122
+ def extract_params_from_rack_env(env)
123
+ params = {}
124
+ if env["QUERY_STRING"]
125
+ require "uri"
126
+ params = URI.decode_www_form(env["QUERY_STRING"]).to_h
127
+ end
128
+ params
129
+ end
130
+
131
+ # Extract API key from headers
132
+ # @param headers [Hash] request headers
133
+ # @return [String, nil] the API key if found
134
+ def extract_from_headers(headers)
135
+ # 1. X-API-Key header (most common)
136
+ api_key = headers["X-API-Key"] || headers["x-api-key"]
137
+ return api_key if api_key
138
+
139
+ # 2. Authorization header
140
+ extract_from_auth_header(headers["Authorization"] || headers["authorization"])
141
+ end
142
+
143
+ # Extract API key from Authorization header
144
+ # @param auth_header [String, nil] the authorization header value
145
+ # @return [String, nil] the API key if found
146
+ def extract_from_auth_header(auth_header)
147
+ return nil unless auth_header
148
+
149
+ # Bearer token format
150
+ return auth_header[7..] if auth_header.start_with?("Bearer ")
151
+
152
+ # API-Key scheme format
153
+ return auth_header[8..] if auth_header.start_with?("API-Key ")
154
+
155
+ nil
156
+ end
157
+
158
+ # Extract API key from query parameters
159
+ # @param params [Hash] request parameters
160
+ # @return [String, nil] the API key if found
161
+ def extract_from_params(params)
162
+ params["api_key"] || params["apikey"]
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ module Strategies
6
+ # Custom authentication strategy
7
+ # Allows developers to implement their own authentication logic
8
+ class Custom
9
+ attr_reader :handler
10
+
11
+ # Initialize with a custom authentication handler
12
+ # @param handler [Proc] a block that takes a request and returns user info or false
13
+ def initialize(&handler)
14
+ raise ArgumentError, "Custom authentication strategy requires a block" unless handler
15
+
16
+ @handler = handler
17
+ end
18
+
19
+ # Authenticate a request using the custom handler
20
+ # @param request [Hash] the request object
21
+ # @return [Object, false] result from custom handler or false if authentication failed
22
+ def authenticate(request)
23
+ result = @handler.call(request)
24
+
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
33
+ false
34
+ end
35
+
36
+ # Check if handler is configured
37
+ # @return [Boolean] true if handler is present
38
+ def configured?
39
+ !@handler.nil?
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
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "jwt"
5
+ rescue LoadError
6
+ # JWT gem is optional - will raise error when trying to use JWT strategy
7
+ end
8
+
9
+ module VectorMCP
10
+ module Security
11
+ module Strategies
12
+ # JWT Token authentication strategy
13
+ # Provides stateless authentication using JSON Web Tokens
14
+ class JwtToken
15
+ attr_reader :secret, :algorithm, :options
16
+
17
+ # Initialize JWT strategy
18
+ # @param secret [String] the secret key for JWT verification
19
+ # @param algorithm [String] the JWT algorithm (default: HS256)
20
+ # @param options [Hash] additional JWT verification options
21
+ def initialize(secret:, algorithm: "HS256", **options)
22
+ raise LoadError, "JWT gem is required for JWT authentication strategy" unless defined?(JWT)
23
+
24
+ @secret = secret
25
+ @algorithm = algorithm
26
+ @options = {
27
+ algorithm: @algorithm,
28
+ verify_expiration: true,
29
+ verify_iat: true,
30
+ verify_iss: false,
31
+ verify_aud: false
32
+ }.merge(options)
33
+ end
34
+
35
+ # Authenticate a request using JWT token
36
+ # @param request [Hash] the request object
37
+ # @return [Hash, false] decoded JWT payload or false if authentication failed
38
+ def authenticate(request)
39
+ token = extract_token(request)
40
+ return false unless token
41
+
42
+ begin
43
+ decoded = JWT.decode(token, @secret, true, @options)
44
+ payload = decoded[0] # First element is the payload
45
+ headers = decoded[1] # Second element is the headers
46
+
47
+ # Return user info from JWT payload
48
+ {
49
+ **payload,
50
+ strategy: "jwt",
51
+ authenticated_at: Time.now,
52
+ jwt_headers: headers
53
+ }
54
+ rescue JWT::ExpiredSignature, JWT::InvalidIssuerError,
55
+ JWT::DecodeError, StandardError
56
+ false # Token validation failed
57
+ end
58
+ end
59
+
60
+ # Generate a JWT token (utility method for testing/development)
61
+ # @param payload [Hash] the payload to encode
62
+ # @param expires_in [Integer] expiration time in seconds from now
63
+ # @return [String] the generated JWT token
64
+ def generate_token(payload, expires_in: 3600)
65
+ exp_payload = payload.merge(
66
+ exp: Time.now.to_i + expires_in,
67
+ iat: Time.now.to_i
68
+ )
69
+ JWT.encode(exp_payload, @secret, @algorithm)
70
+ end
71
+
72
+ # Check if JWT gem is available
73
+ # @return [Boolean] true if JWT gem is loaded
74
+ def self.available?
75
+ defined?(JWT)
76
+ end
77
+
78
+ private
79
+
80
+ # Extract JWT token from request
81
+ # @param request [Hash] the request object
82
+ # @return [String, nil] the extracted token
83
+ def extract_token(request)
84
+ headers = request[:headers] || request["headers"] || {}
85
+ params = request[:params] || request["params"] || {}
86
+
87
+ extract_from_auth_header(headers) ||
88
+ extract_from_jwt_header(headers) ||
89
+ extract_from_params(params)
90
+ end
91
+
92
+ # Extract token from Authorization header
93
+ # @param headers [Hash] request headers
94
+ # @return [String, nil] the token if found
95
+ def extract_from_auth_header(headers)
96
+ auth_header = headers["Authorization"] || headers["authorization"]
97
+ return nil unless auth_header&.start_with?("Bearer ")
98
+
99
+ auth_header[7..] # Remove 'Bearer ' prefix
100
+ end
101
+
102
+ # Extract token from custom JWT header
103
+ # @param headers [Hash] request headers
104
+ # @return [String, nil] the token if found
105
+ def extract_from_jwt_header(headers)
106
+ headers["X-JWT-Token"] || headers["x-jwt-token"]
107
+ end
108
+
109
+ # Extract token from query parameters
110
+ # @param params [Hash] request parameters
111
+ # @return [String, nil] the token if found
112
+ def extract_from_params(params)
113
+ params["jwt_token"] || params["token"]
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Security namespace for VectorMCP
4
+ # Contains authentication, authorization, and security middleware components
5
+
6
+ require_relative "security/auth_manager"
7
+ require_relative "security/authorization"
8
+ require_relative "security/middleware"
9
+ require_relative "security/session_context"
10
+ require_relative "security/strategies/api_key"
11
+ require_relative "security/strategies/jwt_token"
12
+ require_relative "security/strategies/custom"
13
+
14
+ module VectorMCP
15
+ # Security components for VectorMCP servers
16
+ # Provides opt-in authentication and authorization
17
+ module Security
18
+ # Get default authentication manager
19
+ # @return [AuthManager] a new authentication manager instance
20
+ def self.auth_manager
21
+ AuthManager.new
22
+ end
23
+
24
+ # Get default authorization manager
25
+ # @return [Authorization] a new authorization manager instance
26
+ def self.authorization
27
+ Authorization.new
28
+ end
29
+
30
+ # Create security middleware with default components
31
+ # @param auth_manager [AuthManager] optional custom auth manager
32
+ # @param authorization [Authorization] optional custom authorization
33
+ # @return [Middleware] configured security middleware
34
+ def self.middleware(auth_manager: nil, authorization: nil)
35
+ auth_manager ||= self.auth_manager
36
+ authorization ||= self.authorization
37
+ Middleware.new(auth_manager, authorization)
38
+ end
39
+
40
+ # Check if JWT support is available
41
+ # @return [Boolean] true if JWT gem is loaded
42
+ def self.jwt_available?
43
+ Strategies::JwtToken.available?
44
+ end
45
+ end
46
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json-schema"
4
+
3
5
  module VectorMCP
4
6
  class Server
5
7
  # Handles registration of tools, resources, prompts, and roots
@@ -19,6 +21,9 @@ module VectorMCP
19
21
  name_s = name.to_s
20
22
  raise ArgumentError, "Tool '#{name_s}' already registered" if @tools[name_s]
21
23
 
24
+ # Validate schema format during registration
25
+ validate_schema_format!(input_schema) if input_schema
26
+
22
27
  @tools[name_s] = VectorMCP::Definitions::Tool.new(name_s, description, input_schema, handler)
23
28
  logger.debug("Registered tool: #{name_s}")
24
29
  self
@@ -206,6 +211,25 @@ module VectorMCP
206
211
 
207
212
  private
208
213
 
214
+ # Validates that the provided schema is a valid JSON Schema.
215
+ # @api private
216
+ # @param schema [Hash, nil] The JSON Schema to validate.
217
+ # @return [void]
218
+ # @raise [ArgumentError] if the schema is invalid.
219
+ def validate_schema_format!(schema)
220
+ return if schema.nil? || schema.empty?
221
+ return unless schema.is_a?(Hash)
222
+
223
+ # Use JSON::Validator to validate the schema format itself
224
+ validation_errors = JSON::Validator.fully_validate_schema(schema)
225
+
226
+ raise ArgumentError, "Invalid input_schema format: #{validation_errors.join("; ")}" unless validation_errors.empty?
227
+ rescue JSON::Schema::ValidationError => e
228
+ raise ArgumentError, "Invalid input_schema format: #{e.message}"
229
+ rescue JSON::Schema::SchemaError => e
230
+ raise ArgumentError, "Invalid input_schema structure: #{e.message}"
231
+ end
232
+
209
233
  # Validates the structure of the `arguments` array provided to {#register_prompt}.
210
234
  # @api private
211
235
  def validate_prompt_arguments(argument_defs)