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.
@@ -174,4 +174,28 @@ module VectorMCP
174
174
  # super(message, code: client_code, details: client_details, request_id: request_id)
175
175
  # end
176
176
  # end
177
+
178
+ # --- Security Specific Errors ---
179
+
180
+ # Represents an authentication required error (-32401).
181
+ # Indicates the request requires authentication.
182
+ class UnauthorizedError < ProtocolError
183
+ # @param message [String] The error message.
184
+ # @param details [Hash, nil] Additional details for the error (optional).
185
+ # @param request_id [String, Integer, nil] The ID of the originating request.
186
+ def initialize(message = "Authentication required", details: nil, request_id: nil)
187
+ super(message, code: -32_401, details: details, request_id: request_id)
188
+ end
189
+ end
190
+
191
+ # Represents an authorization failed error (-32403).
192
+ # Indicates the authenticated user does not have permission to perform the requested action.
193
+ class ForbiddenError < ProtocolError
194
+ # @param message [String] The error message.
195
+ # @param details [Hash, nil] Additional details for the error (optional).
196
+ # @param request_id [String, Integer, nil] The ID of the originating request.
197
+ def initialize(message = "Access denied", details: nil, request_id: nil)
198
+ super(message, code: -32_403, details: details, request_id: request_id)
199
+ end
200
+ end
177
201
  end
@@ -42,24 +42,35 @@ module VectorMCP
42
42
  #
43
43
  # @param params [Hash] The request parameters.
44
44
  # Expected keys: "name" (String), "arguments" (Hash, optional).
45
- # @param _session [VectorMCP::Session] The current session (ignored).
45
+ # @param session [VectorMCP::Session] The current session.
46
46
  # @param server [VectorMCP::Server] The server instance.
47
47
  # @return [Hash] A hash containing the tool call result or an error indication.
48
48
  # Example success: `{ isError: false, content: [{ type: "text", ... }] }`
49
49
  # @raise [VectorMCP::NotFoundError] if the requested tool is not found.
50
50
  # @raise [VectorMCP::InvalidParamsError] if arguments validation fails.
51
- def self.call_tool(params, _session, server)
51
+ # @raise [VectorMCP::UnauthorizedError] if authentication fails.
52
+ # @raise [VectorMCP::ForbiddenError] if authorization fails.
53
+ def self.call_tool(params, session, server)
52
54
  tool_name = params["name"]
53
55
  arguments = params["arguments"] || {}
54
56
 
55
57
  tool = server.tools[tool_name]
56
58
  raise VectorMCP::NotFoundError.new("Not Found", details: "Tool not found: #{tool_name}") unless tool
57
59
 
60
+ # Security check: authenticate and authorize the request
61
+ security_result = check_tool_security(session, tool, server)
62
+ handle_security_failure(security_result) unless security_result[:success]
63
+
58
64
  # Validate arguments against the tool's input schema
59
65
  validate_input_arguments!(tool_name, tool, arguments)
60
66
 
61
67
  # Let StandardError propagate to Server#handle_request
62
- result = tool.handler.call(arguments)
68
+ # Pass session_context only if the handler supports it (for backward compatibility)
69
+ result = if [1, -1].include?(tool.handler.arity)
70
+ tool.handler.call(arguments)
71
+ else
72
+ tool.handler.call(arguments, security_result[:session_context])
73
+ end
63
74
  {
64
75
  isError: false,
65
76
  content: VectorMCP::Util.convert_to_mcp_content(result)
@@ -83,18 +94,30 @@ module VectorMCP
83
94
  #
84
95
  # @param params [Hash] The request parameters.
85
96
  # Expected key: "uri" (String).
86
- # @param _session [VectorMCP::Session] The current session (ignored).
97
+ # @param session [VectorMCP::Session] The current session.
87
98
  # @param server [VectorMCP::Server] The server instance.
88
99
  # @return [Hash] A hash containing an array of content items from the resource.
89
100
  # Example: `{ contents: [{ type: "text", text: "...", uri: "memory://data" }] }`
90
101
  # @raise [VectorMCP::NotFoundError] if the requested resource URI is not found.
91
- def self.read_resource(params, _session, server)
102
+ # @raise [VectorMCP::UnauthorizedError] if authentication fails.
103
+ # @raise [VectorMCP::ForbiddenError] if authorization fails.
104
+ def self.read_resource(params, session, server)
92
105
  uri_s = params["uri"]
93
106
  raise VectorMCP::NotFoundError.new("Not Found", details: "Resource not found: #{uri_s}") unless server.resources[uri_s]
94
107
 
95
108
  resource = server.resources[uri_s]
109
+
110
+ # Security check: authenticate and authorize the request
111
+ security_result = check_resource_security(session, resource, server)
112
+ handle_security_failure(security_result) unless security_result[:success]
113
+
96
114
  # Let StandardError propagate to Server#handle_request
97
- content_raw = resource.handler.call(params)
115
+ # Pass session_context only if the handler supports it (for backward compatibility)
116
+ content_raw = if [1, -1].include?(resource.handler.arity)
117
+ resource.handler.call(params)
118
+ else
119
+ resource.handler.call(params, security_result[:session_context])
120
+ end
98
121
  contents = VectorMCP::Util.convert_to_mcp_content(content_raw, mime_type: resource.mime_type)
99
122
  contents.each do |item|
100
123
  # Add URI to each content item if not already present
@@ -344,6 +367,66 @@ module VectorMCP
344
367
  end
345
368
  private_class_method :validate_input_arguments!
346
369
  private_class_method :raise_tool_validation_error
370
+
371
+ # Security helper methods
372
+
373
+ # Check security for tool access
374
+ # @api private
375
+ # @param session [VectorMCP::Session] The current session
376
+ # @param tool [VectorMCP::Definitions::Tool] The tool being accessed
377
+ # @param server [VectorMCP::Server] The server instance
378
+ # @return [Hash] Security check result
379
+ def self.check_tool_security(session, tool, server)
380
+ # Extract request context from session for security middleware
381
+ request = extract_request_from_session(session)
382
+ server.security_middleware.process_request(request, action: :call, resource: tool)
383
+ end
384
+ private_class_method :check_tool_security
385
+
386
+ # Check security for resource access
387
+ # @api private
388
+ # @param session [VectorMCP::Session] The current session
389
+ # @param resource [VectorMCP::Definitions::Resource] The resource being accessed
390
+ # @param server [VectorMCP::Server] The server instance
391
+ # @return [Hash] Security check result
392
+ def self.check_resource_security(session, resource, server)
393
+ request = extract_request_from_session(session)
394
+ server.security_middleware.process_request(request, action: :read, resource: resource)
395
+ end
396
+ private_class_method :check_resource_security
397
+
398
+ # Extract request context from session for security processing
399
+ # @api private
400
+ # @param session [VectorMCP::Session] The current session
401
+ # @return [Hash] Request context for security middleware
402
+ def self.extract_request_from_session(session)
403
+ # Extract security context from session
404
+ # This will be enhanced as we integrate with transport layers
405
+ {
406
+ headers: session.instance_variable_get(:@request_headers) || {},
407
+ params: session.instance_variable_get(:@request_params) || {},
408
+ session_id: session.respond_to?(:id) ? session.id : "test-session"
409
+ }
410
+ end
411
+ private_class_method :extract_request_from_session
412
+
413
+ # Handle security failure by raising appropriate error
414
+ # @api private
415
+ # @param security_result [Hash] The security check result
416
+ # @return [void]
417
+ # @raise [VectorMCP::UnauthorizedError, VectorMCP::ForbiddenError]
418
+ def self.handle_security_failure(security_result)
419
+ case security_result[:error_code]
420
+ when "AUTHENTICATION_REQUIRED"
421
+ raise VectorMCP::UnauthorizedError, security_result[:error]
422
+ when "AUTHORIZATION_FAILED"
423
+ raise VectorMCP::ForbiddenError, security_result[:error]
424
+ else
425
+ # Fallback to generic unauthorized error
426
+ raise VectorMCP::UnauthorizedError, security_result[:error] || "Security check failed"
427
+ end
428
+ end
429
+ private_class_method :handle_security_failure
347
430
  end
348
431
  end
349
432
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Logging
5
+ class Component
6
+ attr_reader :name, :core, :config
7
+
8
+ def initialize(name, core, config = {})
9
+ @name = name.to_s
10
+ @core = core
11
+ @config = config
12
+ @context = {}
13
+ end
14
+
15
+ def with_context(context)
16
+ old_context = @context
17
+ @context = @context.merge(context)
18
+ yield
19
+ ensure
20
+ @context = old_context
21
+ end
22
+
23
+ def add_context(context)
24
+ @context = @context.merge(context)
25
+ end
26
+
27
+ def clear_context
28
+ @context = {}
29
+ end
30
+
31
+ def trace(message = nil, context: {}, &block)
32
+ log_with_block(Logging::LEVELS[:TRACE], message, context, &block)
33
+ end
34
+
35
+ def debug(message = nil, context: {}, &block)
36
+ log_with_block(Logging::LEVELS[:DEBUG], message, context, &block)
37
+ end
38
+
39
+ def info(message = nil, context: {}, &block)
40
+ log_with_block(Logging::LEVELS[:INFO], message, context, &block)
41
+ end
42
+
43
+ def warn(message = nil, context: {}, &block)
44
+ log_with_block(Logging::LEVELS[:WARN], message, context, &block)
45
+ end
46
+
47
+ def error(message = nil, context: {}, &block)
48
+ log_with_block(Logging::LEVELS[:ERROR], message, context, &block)
49
+ end
50
+
51
+ def fatal(message = nil, context: {}, &block)
52
+ log_with_block(Logging::LEVELS[:FATAL], message, context, &block)
53
+ end
54
+
55
+ def security(message = nil, context: {}, &block)
56
+ log_with_block(Logging::LEVELS[:SECURITY], message, context, &block)
57
+ end
58
+
59
+ def level
60
+ @core.configuration.level_for(@name)
61
+ end
62
+
63
+ def level_enabled?(level)
64
+ level >= self.level
65
+ end
66
+
67
+ def trace?
68
+ level_enabled?(Logging::LEVELS[:TRACE])
69
+ end
70
+
71
+ def debug?
72
+ level_enabled?(Logging::LEVELS[:DEBUG])
73
+ end
74
+
75
+ def info?
76
+ level_enabled?(Logging::LEVELS[:INFO])
77
+ end
78
+
79
+ def warn?
80
+ level_enabled?(Logging::LEVELS[:WARN])
81
+ end
82
+
83
+ def error?
84
+ level_enabled?(Logging::LEVELS[:ERROR])
85
+ end
86
+
87
+ def fatal?
88
+ level_enabled?(Logging::LEVELS[:FATAL])
89
+ end
90
+
91
+ def security?
92
+ level_enabled?(Logging::LEVELS[:SECURITY])
93
+ end
94
+
95
+ def measure(message, context: {}, level: :info, &block)
96
+ start_time = Time.now
97
+ result = nil
98
+ error = nil
99
+
100
+ begin
101
+ result = block.call
102
+ rescue StandardError => e
103
+ error = e
104
+ raise
105
+ ensure
106
+ duration = Time.now - start_time
107
+ measure_context = context.merge(
108
+ duration_ms: (duration * 1000).round(2),
109
+ success: error.nil?
110
+ )
111
+ measure_context[:error] = error.class.name if error
112
+
113
+ send(level, "#{message} completed", context: measure_context)
114
+ end
115
+
116
+ result
117
+ end
118
+
119
+ private
120
+
121
+ def log_with_block(level, message, context, &block)
122
+ return unless level_enabled?(level)
123
+
124
+ message = block.call if block_given?
125
+
126
+ full_context = @context.merge(context)
127
+ @core.log(level, @name, message, full_context)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module VectorMCP
6
+ module Logging
7
+ class Configuration
8
+ DEFAULT_CONFIG = {
9
+ level: "INFO",
10
+ format: "text",
11
+ output: "console",
12
+ components: {},
13
+ console: {
14
+ colorize: true,
15
+ include_timestamp: true,
16
+ include_thread: false
17
+ },
18
+ file: {
19
+ path: nil,
20
+ rotation: "daily",
21
+ max_size: "100MB",
22
+ max_files: 7
23
+ }
24
+ }.freeze
25
+
26
+ attr_reader :config
27
+
28
+ def initialize(config = {})
29
+ @config = deep_merge(DEFAULT_CONFIG, normalize_config(config))
30
+ validate_config!
31
+ end
32
+
33
+ def self.from_file(path)
34
+ config = YAML.load_file(path)
35
+ new(config["logging"] || config)
36
+ rescue StandardError => e
37
+ raise ConfigurationError, "Failed to load configuration from #{path}: #{e.message}"
38
+ end
39
+
40
+ def self.from_env
41
+ config = {}
42
+
43
+ config[:level] = ENV["VECTORMCP_LOG_LEVEL"] if ENV["VECTORMCP_LOG_LEVEL"]
44
+ config[:format] = ENV["VECTORMCP_LOG_FORMAT"] if ENV["VECTORMCP_LOG_FORMAT"]
45
+ config[:output] = ENV["VECTORMCP_LOG_OUTPUT"] if ENV["VECTORMCP_LOG_OUTPUT"]
46
+
47
+ config[:file] = { path: ENV["VECTORMCP_LOG_FILE_PATH"] } if ENV["VECTORMCP_LOG_FILE_PATH"]
48
+
49
+ new(config)
50
+ end
51
+
52
+ def level_for(component)
53
+ component_level = @config[:components][component.to_s]
54
+ level_value = component_level || @config[:level]
55
+ Logging.level_value(level_value)
56
+ end
57
+
58
+ def set_component_level(component, level)
59
+ @config[:components][component.to_s] = if level.is_a?(Integer)
60
+ Logging.level_name(level)
61
+ else
62
+ level.to_s.upcase
63
+ end
64
+ end
65
+
66
+ def component_config(component_name)
67
+ @config[:components][component_name.to_s] || {}
68
+ end
69
+
70
+ def console_config
71
+ @config[:console]
72
+ end
73
+
74
+ def file_config
75
+ @config[:file]
76
+ end
77
+
78
+ def format
79
+ @config[:format]
80
+ end
81
+
82
+ def output
83
+ @config[:output]
84
+ end
85
+
86
+ def configure(&)
87
+ instance_eval(&) if block_given?
88
+ validate_config!
89
+ end
90
+
91
+ def level(new_level)
92
+ @config[:level] = new_level.to_s.upcase
93
+ end
94
+
95
+ def component(name, level:)
96
+ @config[:components][name.to_s] = level.to_s.upcase
97
+ end
98
+
99
+ def console(options = {})
100
+ @config[:console].merge!(options)
101
+ end
102
+
103
+ def file(options = {})
104
+ @config[:file].merge!(options)
105
+ end
106
+
107
+ def to_h
108
+ @config.dup
109
+ end
110
+
111
+ private
112
+
113
+ def normalize_config(config)
114
+ case config
115
+ when Hash
116
+ config.transform_keys(&:to_sym)
117
+ when String
118
+ { level: config }
119
+ else
120
+ {}
121
+ end
122
+ end
123
+
124
+ def deep_merge(hash1, hash2)
125
+ result = hash1.dup
126
+ hash2.each do |key, value|
127
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
128
+ deep_merge(result[key], value)
129
+ else
130
+ value
131
+ end
132
+ end
133
+ result
134
+ end
135
+
136
+ def validate_config!
137
+ validate_level!(@config[:level])
138
+ @config[:components].each_value do |level|
139
+ validate_level!(level)
140
+ end
141
+
142
+ raise ConfigurationError, "Invalid format: #{@config[:format]}" unless %w[text json].include?(@config[:format])
143
+
144
+ return if %w[console file both].include?(@config[:output])
145
+
146
+ raise ConfigurationError, "Invalid output: #{@config[:output]}"
147
+ end
148
+
149
+ def validate_level!(level)
150
+ return if Logging::LEVELS.key?(level.to_s.upcase.to_sym)
151
+
152
+ raise ConfigurationError, "Invalid log level: #{level}"
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Logging
5
+ module Constants
6
+ # JSON serialization limits
7
+ MAX_SERIALIZATION_DEPTH = 5
8
+ MAX_ARRAY_SERIALIZATION_DEPTH = 3
9
+ MAX_ARRAY_ELEMENTS_TO_SERIALIZE = 10
10
+
11
+ # Text formatting limits
12
+ DEFAULT_MAX_MESSAGE_LENGTH = 1000
13
+ DEFAULT_COMPONENT_WIDTH = 20
14
+ DEFAULT_LEVEL_WIDTH = 8
15
+ TRUNCATION_SUFFIX_LENGTH = 4 # for "..."
16
+
17
+ # ISO timestamp precision
18
+ TIMESTAMP_PRECISION = 3 # milliseconds
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module VectorMCP
6
+ module Logging
7
+ class Core
8
+ attr_reader :configuration, :components, :outputs
9
+
10
+ def initialize(configuration = nil)
11
+ @configuration = configuration || Configuration.new
12
+ @components = {}
13
+ @outputs = []
14
+ @mutex = Mutex.new
15
+ @legacy_logger = nil
16
+
17
+ setup_default_output
18
+ end
19
+
20
+ def logger_for(component_name)
21
+ @mutex.synchronize do
22
+ @components[component_name] ||= Component.new(
23
+ component_name,
24
+ self,
25
+ @configuration.component_config(component_name)
26
+ )
27
+ end
28
+ end
29
+
30
+ def legacy_logger
31
+ @legacy_logger ||= LegacyAdapter.new(self)
32
+ end
33
+
34
+ def log(level, component, message, context = {})
35
+ return unless should_log?(level, component)
36
+
37
+ log_entry = Logging::LogEntry.new({
38
+ timestamp: Time.now,
39
+ level: level,
40
+ component: component,
41
+ message: message,
42
+ context: context,
43
+ thread_id: Thread.current.object_id
44
+ })
45
+
46
+ @outputs.each do |output|
47
+ output.write(log_entry)
48
+ rescue StandardError => e
49
+ warn "Failed to write to output #{output.class}: #{e.message}"
50
+ end
51
+ end
52
+
53
+ def add_output(output)
54
+ @mutex.synchronize do
55
+ @outputs << output unless @outputs.include?(output)
56
+ end
57
+ end
58
+
59
+ def remove_output(output)
60
+ @mutex.synchronize do
61
+ @outputs.delete(output)
62
+ end
63
+ end
64
+
65
+ def configure(&)
66
+ @configuration.configure(&)
67
+ reconfigure_outputs
68
+ end
69
+
70
+ def shutdown
71
+ @outputs.each(&:close)
72
+ @outputs.clear
73
+ end
74
+
75
+ private
76
+
77
+ def should_log?(level, component)
78
+ min_level = @configuration.level_for(component)
79
+ level >= min_level
80
+ end
81
+
82
+ def setup_default_output
83
+ console_output = Outputs::Console.new(@configuration.console_config)
84
+ add_output(console_output)
85
+ end
86
+
87
+ def reconfigure_outputs
88
+ @outputs.each(&:reconfigure)
89
+ end
90
+ end
91
+
92
+ class LegacyAdapter
93
+ def initialize(core)
94
+ @core = core
95
+ @component = "legacy"
96
+ @progname = "VectorMCP"
97
+ end
98
+
99
+ def debug(message = nil, &)
100
+ log_with_block(Logging::LEVELS[:DEBUG], message, &)
101
+ end
102
+
103
+ def info(message = nil, &)
104
+ log_with_block(Logging::LEVELS[:INFO], message, &)
105
+ end
106
+
107
+ def warn(message = nil, &)
108
+ log_with_block(Logging::LEVELS[:WARN], message, &)
109
+ end
110
+
111
+ def error(message = nil, &)
112
+ log_with_block(Logging::LEVELS[:ERROR], message, &)
113
+ end
114
+
115
+ def fatal(message = nil, &)
116
+ log_with_block(Logging::LEVELS[:FATAL], message, &)
117
+ end
118
+
119
+ def level
120
+ @core.configuration.level_for(@component)
121
+ end
122
+
123
+ def level=(new_level)
124
+ @core.configuration.set_component_level(@component, new_level)
125
+ end
126
+
127
+ attr_accessor :progname
128
+
129
+ def add(severity, message = nil, progname = nil, &block)
130
+ actual_message = message || block&.call || progname
131
+ @core.log(severity, @component, actual_message)
132
+ end
133
+
134
+ # For backward compatibility with Logger interface checks
135
+ def is_a?(klass)
136
+ return true if klass == Logger
137
+
138
+ super
139
+ end
140
+
141
+ def kind_of?(klass)
142
+ return true if klass == Logger
143
+
144
+ super
145
+ end
146
+
147
+ # Simulate Logger's logdev for compatibility
148
+ def instance_variable_get(var_name)
149
+ if var_name == :@logdev
150
+ # Return a mock object that simulates Logger's logdev
151
+ MockLogdev.new
152
+ else
153
+ super
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def log_with_block(level, message, &block)
160
+ if block_given?
161
+ return unless @core.configuration.level_for(@component) <= level
162
+
163
+ message = block.call
164
+ end
165
+ @core.log(level, @component, message)
166
+ end
167
+ end
168
+
169
+ class MockLogdev
170
+ def dev
171
+ $stderr
172
+ end
173
+ end
174
+ end
175
+ end