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
@@ -230,6 +230,7 @@ module VectorMCP
230
230
  # Validates that the root URI is properly formatted and secure.
231
231
  # @return [Boolean] True if the root is valid.
232
232
  # @raise [ArgumentError] If the root is invalid.
233
+ # rubocop:disable Naming/PredicateMethod
233
234
  def validate!
234
235
  # Validate URI format
235
236
  parsed_uri = begin
@@ -248,13 +249,14 @@ module VectorMCP
248
249
  raise ArgumentError, "Root path is not a directory: #{path}" unless File.directory?(path)
249
250
 
250
251
  # Security check: ensure we can read the directory
251
- raise ArgumentError, "Root directory is not readable: #{path}" unless File.readable?(path)
252
252
 
253
+ raise ArgumentError, "Root directory is not readable: #{path}" unless File.readable?(path)
253
254
  # Validate against path traversal attempts in the URI itself
254
255
  raise ArgumentError, "Root path contains unsafe traversal patterns: #{path}" if path.include?("..") || path.include?("./")
255
256
 
256
257
  true
257
258
  end
259
+ # rubocop:enable Naming/PredicateMethod
258
260
 
259
261
  # Class method to create a root from a local directory path.
260
262
  # @param path [String] Local filesystem path to the directory.
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "uri"
5
+ require "json-schema"
5
6
 
6
7
  module VectorMCP
7
8
  module Handlers
@@ -41,20 +42,35 @@ module VectorMCP
41
42
  #
42
43
  # @param params [Hash] The request parameters.
43
44
  # Expected keys: "name" (String), "arguments" (Hash, optional).
44
- # @param _session [VectorMCP::Session] The current session (ignored).
45
+ # @param session [VectorMCP::Session] The current session.
45
46
  # @param server [VectorMCP::Server] The server instance.
46
47
  # @return [Hash] A hash containing the tool call result or an error indication.
47
48
  # Example success: `{ isError: false, content: [{ type: "text", ... }] }`
48
49
  # @raise [VectorMCP::NotFoundError] if the requested tool is not found.
49
- def self.call_tool(params, _session, server)
50
+ # @raise [VectorMCP::InvalidParamsError] if arguments validation fails.
51
+ # @raise [VectorMCP::UnauthorizedError] if authentication fails.
52
+ # @raise [VectorMCP::ForbiddenError] if authorization fails.
53
+ def self.call_tool(params, session, server)
50
54
  tool_name = params["name"]
51
55
  arguments = params["arguments"] || {}
52
56
 
53
57
  tool = server.tools[tool_name]
54
58
  raise VectorMCP::NotFoundError.new("Not Found", details: "Tool not found: #{tool_name}") unless tool
55
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
+
64
+ # Validate arguments against the tool's input schema
65
+ validate_input_arguments!(tool_name, tool, arguments)
66
+
56
67
  # Let StandardError propagate to Server#handle_request
57
- 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
58
74
  {
59
75
  isError: false,
60
76
  content: VectorMCP::Util.convert_to_mcp_content(result)
@@ -78,18 +94,30 @@ module VectorMCP
78
94
  #
79
95
  # @param params [Hash] The request parameters.
80
96
  # Expected key: "uri" (String).
81
- # @param _session [VectorMCP::Session] The current session (ignored).
97
+ # @param session [VectorMCP::Session] The current session.
82
98
  # @param server [VectorMCP::Server] The server instance.
83
99
  # @return [Hash] A hash containing an array of content items from the resource.
84
100
  # Example: `{ contents: [{ type: "text", text: "...", uri: "memory://data" }] }`
85
101
  # @raise [VectorMCP::NotFoundError] if the requested resource URI is not found.
86
- 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)
87
105
  uri_s = params["uri"]
88
106
  raise VectorMCP::NotFoundError.new("Not Found", details: "Resource not found: #{uri_s}") unless server.resources[uri_s]
89
107
 
90
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
+
91
114
  # Let StandardError propagate to Server#handle_request
92
- 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
93
121
  contents = VectorMCP::Util.convert_to_mcp_content(content_raw, mime_type: resource.mime_type)
94
122
  contents.each do |item|
95
123
  # Add URI to each content item if not already present
@@ -301,6 +329,104 @@ module VectorMCP
301
329
  end
302
330
  end
303
331
  private_class_method :validate_prompt_response!
332
+
333
+ # Validates arguments provided for a tool against its input schema using json-schema.
334
+ # @api private
335
+ # @param tool_name [String] The name of the tool.
336
+ # @param tool [VectorMCP::Definitions::Tool] The tool definition.
337
+ # @param arguments [Hash] The arguments supplied by the client.
338
+ # @return [void]
339
+ # @raise [VectorMCP::InvalidParamsError] if arguments fail validation.
340
+ def self.validate_input_arguments!(tool_name, tool, arguments)
341
+ return unless tool.input_schema.is_a?(Hash)
342
+ return if tool.input_schema.empty?
343
+
344
+ validation_errors = JSON::Validator.fully_validate(tool.input_schema, arguments)
345
+ return if validation_errors.empty?
346
+
347
+ raise_tool_validation_error(tool_name, validation_errors)
348
+ rescue JSON::Schema::ValidationError => e
349
+ raise_tool_validation_error(tool_name, [e.message])
350
+ end
351
+
352
+ # Raises InvalidParamsError with formatted validation details.
353
+ # @api private
354
+ # @param tool_name [String] The name of the tool.
355
+ # @param validation_errors [Array<String>] The validation error messages.
356
+ # @return [void]
357
+ # @raise [VectorMCP::InvalidParamsError] Always raises with formatted details.
358
+ def self.raise_tool_validation_error(tool_name, validation_errors)
359
+ raise VectorMCP::InvalidParamsError.new(
360
+ "Invalid arguments for tool '#{tool_name}'",
361
+ details: {
362
+ tool: tool_name,
363
+ validation_errors: validation_errors,
364
+ message: validation_errors.join("; ")
365
+ }
366
+ )
367
+ end
368
+ private_class_method :validate_input_arguments!
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
304
430
  end
305
431
  end
306
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