vector_mcp 0.3.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.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module VectorMCP
6
+ module Logging
7
+ module Filters
8
+ class Component
9
+ def initialize(allowed_components = nil, blocked_components = nil)
10
+ @allowed_components = normalize_components(allowed_components)
11
+ @blocked_components = normalize_components(blocked_components)
12
+ end
13
+
14
+ def accept?(log_entry)
15
+ return false if blocked?(log_entry.component)
16
+ return true if @allowed_components.nil?
17
+
18
+ allowed?(log_entry.component)
19
+ end
20
+
21
+ def allow_component(component)
22
+ @allowed_components ||= Set.new
23
+ @allowed_components.add(component.to_s)
24
+ end
25
+
26
+ def block_component(component)
27
+ @blocked_components ||= Set.new
28
+ @blocked_components.add(component.to_s)
29
+ end
30
+
31
+ def remove_component_filter(component)
32
+ @allowed_components&.delete(component.to_s)
33
+ @blocked_components&.delete(component.to_s)
34
+ end
35
+
36
+ private
37
+
38
+ def allowed?(component)
39
+ return true if @allowed_components.nil?
40
+
41
+ @allowed_components.include?(component) ||
42
+ @allowed_components.any? { |pattern| component.start_with?(pattern) }
43
+ end
44
+
45
+ def blocked?(component)
46
+ return false if @blocked_components.nil?
47
+
48
+ @blocked_components.include?(component) ||
49
+ @blocked_components.any? { |pattern| component.start_with?(pattern) }
50
+ end
51
+
52
+ def normalize_components(components)
53
+ case components
54
+ when nil
55
+ nil
56
+ when String
57
+ Set.new([components])
58
+ when Array
59
+ Set.new(components.map(&:to_s))
60
+ when Set
61
+ components
62
+ else
63
+ Set.new([components.to_s])
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Logging
5
+ module Filters
6
+ class Level
7
+ def initialize(min_level)
8
+ @min_level = min_level.is_a?(Integer) ? min_level : Logging.level_value(min_level)
9
+ end
10
+
11
+ def accept?(log_entry)
12
+ log_entry.level >= @min_level
13
+ end
14
+
15
+ def min_level=(new_level)
16
+ @min_level = new_level.is_a?(Integer) ? new_level : Logging.level_value(new_level)
17
+ end
18
+
19
+ attr_reader :min_level
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+
5
+ module VectorMCP
6
+ module Logging
7
+ module Formatters
8
+ class Base
9
+ def initialize(options = {})
10
+ @options = options
11
+ end
12
+
13
+ def format(log_entry)
14
+ raise NotImplementedError, "Subclasses must implement #format"
15
+ end
16
+
17
+ protected
18
+
19
+ def format_timestamp(timestamp)
20
+ timestamp.strftime("%Y-%m-%d %H:%M:%S.%#{Constants::TIMESTAMP_PRECISION}N")
21
+ end
22
+
23
+ def format_level(level_name, width = Constants::DEFAULT_LEVEL_WIDTH)
24
+ level_name.ljust(width)
25
+ end
26
+
27
+ def format_component(component, width = Constants::DEFAULT_COMPONENT_WIDTH)
28
+ if component.length > width
29
+ "#{component[0..(width - Constants::TRUNCATION_SUFFIX_LENGTH)]}..."
30
+ else
31
+ component.ljust(width)
32
+ end
33
+ end
34
+
35
+ def format_context(context)
36
+ return "" if context.empty?
37
+
38
+ pairs = context.map do |key, value|
39
+ "#{key}=#{value}"
40
+ end
41
+ " (#{pairs.join(", ")})"
42
+ end
43
+
44
+ def truncate_message(message, max_length = Constants::DEFAULT_MAX_MESSAGE_LENGTH)
45
+ return message if message.length <= max_length
46
+
47
+ "#{message[0..(max_length - Constants::TRUNCATION_SUFFIX_LENGTH)]}..."
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../constants"
5
+
6
+ module VectorMCP
7
+ module Logging
8
+ module Formatters
9
+ class Json < Base
10
+ def initialize(options = {})
11
+ super
12
+ @pretty = options.fetch(:pretty, false)
13
+ @include_thread = options.fetch(:include_thread, false)
14
+ end
15
+
16
+ def format(log_entry)
17
+ data = {
18
+ timestamp: log_entry.timestamp.iso8601(Constants::TIMESTAMP_PRECISION),
19
+ level: log_entry.level_name,
20
+ component: log_entry.component,
21
+ message: log_entry.message
22
+ }
23
+
24
+ data[:context] = log_entry.context unless log_entry.context.empty?
25
+ data[:thread_id] = log_entry.thread_id if @include_thread
26
+
27
+ if @pretty
28
+ "#{JSON.pretty_generate(data)}\n"
29
+ else
30
+ "#{JSON.generate(data)}\n"
31
+ end
32
+ rescue JSON::GeneratorError, JSON::NestingError => e
33
+ fallback_format(log_entry, e)
34
+ end
35
+
36
+ private
37
+
38
+ def fallback_format(log_entry, error)
39
+ safe_data = {
40
+ timestamp: log_entry.timestamp.iso8601(Constants::TIMESTAMP_PRECISION),
41
+ level: log_entry.level_name,
42
+ component: log_entry.component,
43
+ message: "JSON serialization failed: #{error.message}",
44
+ original_message: log_entry.message.to_s,
45
+ context: sanitize_context(log_entry.context)
46
+ }
47
+
48
+ "#{JSON.generate(safe_data)}\n"
49
+ end
50
+
51
+ def sanitize_context(context, depth = 0)
52
+ return "<max_depth_reached>" if depth > Constants::MAX_SERIALIZATION_DEPTH
53
+ return {} unless context.is_a?(Hash)
54
+
55
+ context.each_with_object({}) do |(key, value), sanitized|
56
+ sanitized[key.to_s] = sanitize_value(value, depth + 1)
57
+ end
58
+ rescue StandardError
59
+ { "<sanitization_error>" => true }
60
+ end
61
+
62
+ def sanitize_value(value, depth = 0)
63
+ return "<max_depth_reached>" if depth > Constants::MAX_SERIALIZATION_DEPTH
64
+
65
+ case value
66
+ when String, Numeric, TrueClass, FalseClass, NilClass
67
+ value
68
+ when Array
69
+ return "<complex_array>" if depth > Constants::MAX_ARRAY_SERIALIZATION_DEPTH
70
+
71
+ value.first(Constants::MAX_ARRAY_ELEMENTS_TO_SERIALIZE).map { |v| sanitize_value(v, depth + 1) }
72
+ when Hash
73
+ sanitize_context(value, depth)
74
+ else
75
+ value.to_s
76
+ end
77
+ rescue StandardError
78
+ "<serialization_error>"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+
5
+ module VectorMCP
6
+ module Logging
7
+ module Formatters
8
+ class Text < Base
9
+ COLORS = {
10
+ TRACE: "\e[90m", # gray
11
+ DEBUG: "\e[36m", # cyan
12
+ INFO: "\e[32m", # green
13
+ WARN: "\e[33m", # yellow
14
+ ERROR: "\e[31m", # red
15
+ FATAL: "\e[35m", # magenta
16
+ SECURITY: "\e[1;31m" # bold red
17
+ }.freeze
18
+
19
+ RESET_COLOR = "\e[0m"
20
+
21
+ def initialize(options = {})
22
+ super
23
+ @colorize = options.fetch(:colorize, true)
24
+ @include_timestamp = options.fetch(:include_timestamp, true)
25
+ @include_thread = options.fetch(:include_thread, false)
26
+ @include_component = options.fetch(:include_component, true)
27
+ @max_message_length = options.fetch(:max_message_length, Constants::DEFAULT_MAX_MESSAGE_LENGTH)
28
+ end
29
+
30
+ def format(log_entry)
31
+ parts = []
32
+
33
+ parts << format_timestamp_part(log_entry.timestamp) if @include_timestamp
34
+
35
+ parts << format_level_part(log_entry.level_name)
36
+
37
+ parts << format_component_part(log_entry.component) if @include_component
38
+
39
+ parts << format_thread_part(log_entry.thread_id) if @include_thread
40
+
41
+ message = truncate_message(log_entry.message, @max_message_length)
42
+ context_str = format_context(log_entry.context)
43
+
44
+ "#{parts.join(" ")} #{message}#{context_str}\n"
45
+ end
46
+
47
+ private
48
+
49
+ def format_timestamp_part(timestamp)
50
+ "[#{format_timestamp(timestamp)}]"
51
+ end
52
+
53
+ def format_level_part(level_name)
54
+ level_str = format_level(level_name.to_s, Constants::DEFAULT_LEVEL_WIDTH)
55
+ if @colorize && COLORS[level_name.to_sym]
56
+ "#{COLORS[level_name.to_sym]}#{level_str}#{RESET_COLOR}"
57
+ else
58
+ level_str
59
+ end
60
+ end
61
+
62
+ def format_component_part(component)
63
+ format_component(component, Constants::DEFAULT_COMPONENT_WIDTH)
64
+ end
65
+
66
+ def format_thread_part(thread_id)
67
+ "[#{thread_id}]"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Logging
5
+ module Outputs
6
+ class Base
7
+ attr_reader :formatter, :config
8
+
9
+ def initialize(config = {})
10
+ @config = config
11
+ @formatter = create_formatter
12
+ @closed = false
13
+ end
14
+
15
+ def write(log_entry)
16
+ return if @closed
17
+
18
+ formatted_message = @formatter.format(log_entry)
19
+ write_formatted(formatted_message)
20
+ rescue StandardError => e
21
+ fallback_write("Logging output error: #{e.message}\n")
22
+ end
23
+
24
+ def close
25
+ @closed = true
26
+ end
27
+
28
+ def closed?
29
+ @closed
30
+ end
31
+
32
+ def reconfigure
33
+ @formatter = create_formatter
34
+ end
35
+
36
+ protected
37
+
38
+ def write_formatted(message)
39
+ raise NotImplementedError, "Subclasses must implement #write_formatted"
40
+ end
41
+
42
+ def fallback_write(message)
43
+ $stderr.write(message)
44
+ end
45
+
46
+ private
47
+
48
+ def create_formatter
49
+ format_type = @config[:format] || "text"
50
+ formatter_options = @config.except(:format)
51
+
52
+ case format_type.to_s.downcase
53
+ when "json"
54
+ Formatters::Json.new(formatter_options)
55
+ when "text"
56
+ Formatters::Text.new(formatter_options)
57
+ else
58
+ raise OutputError, "Unknown formatter type: #{format_type}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Logging
5
+ module Outputs
6
+ class Console < Base
7
+ def initialize(config = {})
8
+ super
9
+ @stream = determine_stream
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ protected
14
+
15
+ def write_formatted(message)
16
+ @mutex.synchronize do
17
+ @stream.write(message)
18
+ @stream.flush if @stream.respond_to?(:flush)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def determine_stream
25
+ case @config[:stream]&.to_s&.downcase
26
+ when "stdout"
27
+ $stdout
28
+ else
29
+ $stderr # Default to stderr for logging
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -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