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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "date"
5
+
6
+ module VectorMCP
7
+ module Logging
8
+ module Outputs
9
+ class File < Base
10
+ def initialize(config = {})
11
+ super
12
+ @path = @config[:path] or raise OutputError, "File path required"
13
+ @max_size = parse_size(@config[:max_size] || "100MB")
14
+ @max_files = @config[:max_files] || 7
15
+ @rotation = @config[:rotation] || "daily"
16
+ @mutex = Mutex.new
17
+ @file = nil
18
+ @current_date = nil
19
+
20
+ ensure_directory_exists
21
+ open_file
22
+ end
23
+
24
+ def close
25
+ @mutex.synchronize do
26
+ @file&.close
27
+ @file = nil
28
+ end
29
+ super
30
+ end
31
+
32
+ protected
33
+
34
+ def write_formatted(message)
35
+ @mutex.synchronize do
36
+ rotate_if_needed
37
+ @file.write(message)
38
+ @file.flush
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def ensure_directory_exists
45
+ dir = ::File.dirname(@path)
46
+ FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
47
+ rescue StandardError => e
48
+ raise OutputError, "Cannot create log directory #{dir}: #{e.message}"
49
+ end
50
+
51
+ def open_file
52
+ @file = ::File.open(current_log_path, "a")
53
+ @file.sync = true
54
+ @current_date = Date.today if daily_rotation?
55
+ rescue StandardError => e
56
+ raise OutputError, "Cannot open log file #{current_log_path}: #{e.message}"
57
+ end
58
+
59
+ def current_log_path
60
+ if daily_rotation?
61
+ base, ext = split_path(@path)
62
+ "#{base}_#{Date.today.strftime("%Y%m%d")}#{ext}"
63
+ else
64
+ @path
65
+ end
66
+ end
67
+
68
+ def rotate_if_needed
69
+ return unless should_rotate?
70
+
71
+ rotate_file
72
+ open_file
73
+ end
74
+
75
+ def should_rotate?
76
+ return false unless @file
77
+
78
+ case @rotation
79
+ when "daily"
80
+ daily_rotation? && @current_date != Date.today
81
+ when "size"
82
+ @file.size >= @max_size
83
+ else
84
+ false
85
+ end
86
+ end
87
+
88
+ def rotate_file
89
+ @file&.close
90
+
91
+ if daily_rotation?
92
+ cleanup_old_files
93
+ else
94
+ rotate_numbered_files
95
+ end
96
+ end
97
+
98
+ def daily_rotation?
99
+ @rotation == "daily"
100
+ end
101
+
102
+ def rotate_numbered_files
103
+ return unless ::File.exist?(@path)
104
+
105
+ (@max_files - 1).downto(1) do |i|
106
+ old_file = "#{@path}.#{i}"
107
+ new_file = "#{@path}.#{i + 1}"
108
+
109
+ ::File.rename(old_file, new_file) if ::File.exist?(old_file)
110
+ end
111
+
112
+ ::File.rename(@path, "#{@path}.1")
113
+ end
114
+
115
+ def cleanup_old_files
116
+ base, ext = split_path(@path)
117
+ pattern = "#{base}_*#{ext}"
118
+
119
+ old_files = Dir.glob(pattern).reverse
120
+ files_to_remove = old_files[@max_files..] || []
121
+
122
+ files_to_remove.each do |file|
123
+ ::File.unlink(file)
124
+ rescue StandardError => e
125
+ fallback_write("Warning: Could not remove old log file #{file}: #{e.message}\n")
126
+ end
127
+ end
128
+
129
+ def split_path(path)
130
+ ext = ::File.extname(path)
131
+ base = path.chomp(ext)
132
+ [base, ext]
133
+ end
134
+
135
+ def parse_size(size_str)
136
+ size_str = size_str.to_s.upcase
137
+
138
+ raise OutputError, "Invalid size format: #{size_str}" unless size_str =~ /\A(\d+)(KB|MB|GB)?\z/
139
+
140
+ number = ::Regexp.last_match(1).to_i
141
+ unit = ::Regexp.last_match(2) || "B"
142
+
143
+ case unit
144
+ when "KB"
145
+ number * 1024
146
+ when "MB"
147
+ number * 1024 * 1024
148
+ when "GB"
149
+ number * 1024 * 1024 * 1024
150
+ else
151
+ number
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "logging/constants"
4
+ require_relative "logging/core"
5
+ require_relative "logging/configuration"
6
+ require_relative "logging/component"
7
+ require_relative "logging/formatters/base"
8
+ require_relative "logging/formatters/text"
9
+ require_relative "logging/formatters/json"
10
+ require_relative "logging/outputs/base"
11
+ require_relative "logging/outputs/console"
12
+ require_relative "logging/outputs/file"
13
+ require_relative "logging/filters/level"
14
+ require_relative "logging/filters/component"
15
+
16
+ module VectorMCP
17
+ module Logging
18
+ class Error < StandardError; end
19
+ class ConfigurationError < Error; end
20
+ class FormatterError < Error; end
21
+ class OutputError < Error; end
22
+
23
+ LEVELS = {
24
+ TRACE: 0,
25
+ DEBUG: 1,
26
+ INFO: 2,
27
+ WARN: 3,
28
+ ERROR: 4,
29
+ FATAL: 5,
30
+ SECURITY: 6
31
+ }.freeze
32
+
33
+ LEVEL_NAMES = LEVELS.invert.freeze
34
+
35
+ def self.level_name(level)
36
+ (LEVEL_NAMES[level] || "UNKNOWN").to_s
37
+ end
38
+
39
+ def self.level_value(name)
40
+ LEVELS[name.to_s.upcase.to_sym] || LEVELS[:INFO]
41
+ end
42
+
43
+ class LogEntry
44
+ attr_reader :timestamp, :level, :component, :message, :context, :thread_id
45
+
46
+ def initialize(attributes = {})
47
+ @timestamp = attributes[:timestamp]
48
+ @level = attributes[:level]
49
+ @component = attributes[:component]
50
+ @message = attributes[:message]
51
+ @context = attributes[:context] || {}
52
+ @thread_id = attributes[:thread_id]
53
+ end
54
+
55
+ def level_name
56
+ Logging.level_name(@level)
57
+ end
58
+
59
+ def to_h
60
+ {
61
+ timestamp: @timestamp.iso8601(Constants::TIMESTAMP_PRECISION),
62
+ level: level_name,
63
+ component: @component,
64
+ message: @message,
65
+ context: @context,
66
+ thread_id: @thread_id
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ # Manages authentication strategies for VectorMCP servers
6
+ # Provides opt-in authentication with zero configuration by default
7
+ class AuthManager
8
+ attr_reader :strategies, :enabled, :default_strategy
9
+
10
+ def initialize
11
+ @strategies = {}
12
+ @enabled = false
13
+ @default_strategy = nil
14
+ end
15
+
16
+ # Enable authentication with optional default strategy
17
+ # @param default_strategy [Symbol] the default authentication strategy to use
18
+ def enable!(default_strategy: :api_key)
19
+ @enabled = true
20
+ @default_strategy = default_strategy
21
+ end
22
+
23
+ # Disable authentication (return to pass-through mode)
24
+ def disable!
25
+ @enabled = false
26
+ @default_strategy = nil
27
+ end
28
+
29
+ # Add an authentication strategy
30
+ # @param name [Symbol] the strategy name
31
+ # @param strategy [Object] the strategy instance
32
+ def add_strategy(name, strategy)
33
+ @strategies[name] = strategy
34
+ end
35
+
36
+ # Remove an authentication strategy
37
+ # @param name [Symbol] the strategy name to remove
38
+ def remove_strategy(name)
39
+ @strategies.delete(name)
40
+ end
41
+
42
+ # Authenticate a request using the specified or default strategy
43
+ # @param request [Hash] the request object containing headers, params, etc.
44
+ # @param strategy [Symbol] optional strategy override
45
+ # @return [Object, false] authentication result or false if failed
46
+ def authenticate(request, strategy: nil)
47
+ return { authenticated: true, user: nil } unless @enabled
48
+
49
+ strategy_name = strategy || @default_strategy
50
+ auth_strategy = @strategies[strategy_name]
51
+
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}" }
63
+ end
64
+ end
65
+
66
+ # Check if authentication is required
67
+ # @return [Boolean] true if authentication is enabled
68
+ def required?
69
+ @enabled
70
+ end
71
+
72
+ # Get list of available strategies
73
+ # @return [Array<Symbol>] array of strategy names
74
+ def available_strategies
75
+ @strategies.keys
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ # Manages authorization policies for VectorMCP servers
6
+ # Provides fine-grained access control for tools and resources
7
+ class Authorization
8
+ attr_reader :policies, :enabled
9
+
10
+ def initialize
11
+ @policies = {}
12
+ @enabled = false
13
+ end
14
+
15
+ # Enable authorization system
16
+ def enable!
17
+ @enabled = true
18
+ end
19
+
20
+ # Disable authorization (return to pass-through mode)
21
+ def disable!
22
+ @enabled = false
23
+ end
24
+
25
+ # Add an authorization policy for a resource type
26
+ # @param resource_type [Symbol] the type of resource (e.g., :tool, :resource, :prompt)
27
+ # @param block [Proc] the policy block that receives (user, action, resource)
28
+ def add_policy(resource_type, &block)
29
+ @policies[resource_type] = block
30
+ end
31
+
32
+ # Remove an authorization policy
33
+ # @param resource_type [Symbol] the resource type to remove policy for
34
+ def remove_policy(resource_type)
35
+ @policies.delete(resource_type)
36
+ end
37
+
38
+ # Check if a user is authorized to perform an action on a resource
39
+ # @param user [Object] the authenticated user object
40
+ # @param action [Symbol] the action being attempted (e.g., :call, :read, :list)
41
+ # @param resource [Object] the resource being accessed
42
+ # @return [Boolean] true if authorized, false otherwise
43
+ def authorize(user, action, resource)
44
+ return true unless @enabled
45
+
46
+ resource_type = determine_resource_type(resource)
47
+ policy = @policies[resource_type]
48
+
49
+ # If no policy is defined, allow access (opt-in authorization)
50
+ return true unless policy
51
+
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
59
+ end
60
+
61
+ # Check if authorization is required
62
+ # @return [Boolean] true if authorization is enabled
63
+ def required?
64
+ @enabled
65
+ end
66
+
67
+ # Get list of resource types with policies
68
+ # @return [Array<Symbol>] array of resource types
69
+ def policy_types
70
+ @policies.keys
71
+ end
72
+
73
+ private
74
+
75
+ # Determine the resource type from the resource object
76
+ # @param resource [Object] the resource object
77
+ # @return [Symbol] the resource type
78
+ def determine_resource_type(resource)
79
+ case resource
80
+ when VectorMCP::Definitions::Tool
81
+ :tool
82
+ when VectorMCP::Definitions::Resource
83
+ :resource
84
+ when VectorMCP::Definitions::Prompt
85
+ :prompt
86
+ when VectorMCP::Definitions::Root
87
+ :root
88
+ else
89
+ # Try to infer from class name
90
+ class_name = resource.class.name.split("::").last&.downcase
91
+ class_name&.to_sym || :unknown
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ # Security middleware for request authentication and authorization
6
+ # Integrates with transport layers to provide security controls
7
+ class Middleware
8
+ attr_reader :auth_manager, :authorization
9
+
10
+ # Initialize middleware with auth components
11
+ # @param auth_manager [AuthManager] the authentication manager
12
+ # @param authorization [Authorization] the authorization manager
13
+ def initialize(auth_manager, authorization)
14
+ @auth_manager = auth_manager
15
+ @authorization = authorization
16
+ end
17
+
18
+ # Authenticate a request and return session context
19
+ # @param request [Hash] the request object
20
+ # @param strategy [Symbol] optional authentication strategy override
21
+ # @return [SessionContext] the session context for the request
22
+ def authenticate_request(request, strategy: nil)
23
+ auth_result = @auth_manager.authenticate(request, strategy: strategy)
24
+ SessionContext.from_auth_result(auth_result)
25
+ end
26
+
27
+ # Check if a session is authorized for an action on a resource
28
+ # @param session_context [SessionContext] the session context
29
+ # @param action [Symbol] the action being attempted
30
+ # @param resource [Object] the resource being accessed
31
+ # @return [Boolean] true if authorized
32
+ def authorize_action(session_context, action, resource)
33
+ # Always allow if authorization is disabled
34
+ return true unless @authorization.required?
35
+
36
+ # Check authorization policy
37
+ @authorization.authorize(session_context.user, action, resource)
38
+ end
39
+
40
+ # Process a request through the complete security pipeline
41
+ # @param request [Hash] the request object
42
+ # @param action [Symbol] the action being attempted
43
+ # @param resource [Object] the resource being accessed
44
+ # @return [Hash] result with session_context and authorization status
45
+ def process_request(request, action: :access, resource: nil)
46
+ # Step 1: Authenticate the request
47
+ session_context = authenticate_request(request)
48
+
49
+ # Step 2: Check if authentication is required but failed
50
+ if @auth_manager.required? && !session_context.authenticated?
51
+ return {
52
+ success: false,
53
+ error: "Authentication required",
54
+ error_code: "AUTHENTICATION_REQUIRED",
55
+ session_context: session_context
56
+ }
57
+ end
58
+
59
+ # Step 3: Check authorization if resource is provided
60
+ if resource && !authorize_action(session_context, action, resource)
61
+ return {
62
+ success: false,
63
+ error: "Access denied",
64
+ error_code: "AUTHORIZATION_FAILED",
65
+ session_context: session_context
66
+ }
67
+ end
68
+
69
+ # Step 4: Success
70
+ {
71
+ success: true,
72
+ session_context: session_context
73
+ }
74
+ end
75
+
76
+ # Create a request object from different transport formats
77
+ # @param transport_request [Object] the transport-specific request
78
+ # @return [Hash] normalized request object
79
+ def normalize_request(transport_request)
80
+ case transport_request
81
+ when Hash
82
+ # Check if it's a Rack environment (has REQUEST_METHOD key)
83
+ if transport_request.key?("REQUEST_METHOD")
84
+ extract_from_rack_env(transport_request)
85
+ else
86
+ # Already normalized
87
+ transport_request
88
+ end
89
+ else
90
+ # Extract from transport-specific request (e.g., custom objects)
91
+ extract_request_data(transport_request)
92
+ end
93
+ end
94
+
95
+ # Check if security is enabled
96
+ # @return [Boolean] true if any security features are enabled
97
+ def security_enabled?
98
+ @auth_manager.required? || @authorization.required?
99
+ end
100
+
101
+ # Get security status for debugging/monitoring
102
+ # @return [Hash] current security configuration status
103
+ def security_status
104
+ {
105
+ authentication: {
106
+ enabled: @auth_manager.required?,
107
+ strategies: @auth_manager.available_strategies,
108
+ default_strategy: @auth_manager.default_strategy
109
+ },
110
+ authorization: {
111
+ enabled: @authorization.required?,
112
+ policy_types: @authorization.policy_types
113
+ }
114
+ }
115
+ end
116
+
117
+ private
118
+
119
+ # Extract request data from transport-specific formats
120
+ # @param transport_request [Object] the transport request
121
+ # @return [Hash] extracted request data
122
+ def extract_request_data(transport_request)
123
+ # Handle Rack environment (for SSE transport)
124
+ if transport_request.respond_to?(:[]) && transport_request["REQUEST_METHOD"]
125
+ extract_from_rack_env(transport_request)
126
+ else
127
+ # Default fallback
128
+ { headers: {}, params: {} }
129
+ end
130
+ end
131
+
132
+ # Extract data from Rack environment
133
+ # @param env [Hash] the Rack environment
134
+ # @return [Hash] extracted request data
135
+ def extract_from_rack_env(env)
136
+ # Extract headers (HTTP_ prefixed in Rack env)
137
+ headers = {}
138
+ env.each do |key, value|
139
+ next unless key.start_with?("HTTP_")
140
+
141
+ # Convert HTTP_X_API_KEY to X-API-Key format
142
+ header_name = key[5..].split("_").map do |part|
143
+ case part.upcase
144
+ when "API" then "API" # Keep API in all caps
145
+ else part.capitalize
146
+ end
147
+ end.join("-")
148
+ headers[header_name] = value
149
+ end
150
+
151
+ # Add special headers
152
+ headers["Authorization"] = env["HTTP_AUTHORIZATION"] if env["HTTP_AUTHORIZATION"]
153
+ headers["Content-Type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
154
+
155
+ # Extract query parameters
156
+ params = {}
157
+ if env["QUERY_STRING"]
158
+ require "uri"
159
+ params = URI.decode_www_form(env["QUERY_STRING"]).to_h
160
+ end
161
+
162
+ {
163
+ headers: headers,
164
+ params: params,
165
+ method: env["REQUEST_METHOD"],
166
+ path: env["PATH_INFO"],
167
+ rack_env: env
168
+ }
169
+ end
170
+ end
171
+ end
172
+ end