vector_mcp 0.3.0 → 0.3.2

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.
@@ -28,7 +28,8 @@ module VectorMCP
28
28
  # @param details [Hash, nil] Additional details for the error (optional).
29
29
  # @param request_id [String, Integer, nil] The ID of the originating request.
30
30
  def initialize(message, code: -32_600, details: nil, request_id: nil)
31
- VectorMCP.logger.debug("Initializing ProtocolError with code: #{code}")
31
+ logger = VectorMCP.logger_for("errors")
32
+ logger.debug("Initializing ProtocolError with code: #{code}")
32
33
  @code = code
33
34
  @message = message
34
35
  @details = details # NOTE: `data` in JSON-RPC is often used for this purpose.
@@ -104,7 +105,8 @@ module VectorMCP
104
105
  # @param details [Hash, nil] Additional details for the error (optional).
105
106
  # @param request_id [String, Integer, nil] The ID of the originating request.
106
107
  def initialize(message = "Server error", code: -32_000, details: nil, request_id: nil)
107
- VectorMCP.logger.debug("Initializing ServerError with code: #{code}")
108
+ logger = VectorMCP.logger_for("errors")
109
+ logger.debug("Initializing ServerError with code: #{code}")
108
110
  unless (-32_099..-32_000).cover?(code)
109
111
  warn "Server error code #{code} is outside of the reserved range (-32099 to -32000). Using -32000 instead."
110
112
  code = -32_000
@@ -131,7 +133,8 @@ module VectorMCP
131
133
  # @param details [Hash, nil] Additional details for the error (optional).
132
134
  # @param request_id [String, Integer, nil] The ID of the originating request.
133
135
  def initialize(message = "Not Found", details: nil, request_id: nil)
134
- VectorMCP.logger.debug("Initializing NotFoundError with code: -32001")
136
+ logger = VectorMCP.logger_for("errors")
137
+ logger.debug("Initializing NotFoundError with code: -32001")
135
138
  super(message, code: -32_001, details: details, request_id: request_id)
136
139
  end
137
140
  end
@@ -174,4 +177,28 @@ module VectorMCP
174
177
  # super(message, code: client_code, details: client_details, request_id: request_id)
175
178
  # end
176
179
  # end
180
+
181
+ # --- Security Specific Errors ---
182
+
183
+ # Represents an authentication required error (-32401).
184
+ # Indicates the request requires authentication.
185
+ class UnauthorizedError < ProtocolError
186
+ # @param message [String] The error message.
187
+ # @param details [Hash, nil] Additional details for the error (optional).
188
+ # @param request_id [String, Integer, nil] The ID of the originating request.
189
+ def initialize(message = "Authentication required", details: nil, request_id: nil)
190
+ super(message, code: -32_401, details: details, request_id: request_id)
191
+ end
192
+ end
193
+
194
+ # Represents an authorization failed error (-32403).
195
+ # Indicates the authenticated user does not have permission to perform the requested action.
196
+ class ForbiddenError < ProtocolError
197
+ # @param message [String] The error message.
198
+ # @param details [Hash, nil] Additional details for the error (optional).
199
+ # @param request_id [String, Integer, nil] The ID of the originating request.
200
+ def initialize(message = "Access denied", details: nil, request_id: nil)
201
+ super(message, code: -32_403, details: details, request_id: request_id)
202
+ end
203
+ end
177
204
  end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "uri"
5
5
  require "json-schema"
6
+ require_relative "../middleware"
6
7
 
7
8
  module VectorMCP
8
9
  module Handlers
@@ -21,7 +22,8 @@ module VectorMCP
21
22
  # @param _server [VectorMCP::Server] The server instance (ignored).
22
23
  # @return [Hash] An empty hash, as per MCP spec for ping.
23
24
  def self.ping(_params, _session, _server)
24
- VectorMCP.logger.debug("Handling ping request")
25
+ logger = VectorMCP.logger_for("handlers.core")
26
+ logger.debug("Handling ping request")
25
27
  {}
26
28
  end
27
29
 
@@ -42,28 +44,35 @@ module VectorMCP
42
44
  #
43
45
  # @param params [Hash] The request parameters.
44
46
  # Expected keys: "name" (String), "arguments" (Hash, optional).
45
- # @param _session [VectorMCP::Session] The current session (ignored).
47
+ # @param session [VectorMCP::Session] The current session.
46
48
  # @param server [VectorMCP::Server] The server instance.
47
49
  # @return [Hash] A hash containing the tool call result or an error indication.
48
50
  # Example success: `{ isError: false, content: [{ type: "text", ... }] }`
49
51
  # @raise [VectorMCP::NotFoundError] if the requested tool is not found.
50
52
  # @raise [VectorMCP::InvalidParamsError] if arguments validation fails.
51
- def self.call_tool(params, _session, server)
53
+ # @raise [VectorMCP::UnauthorizedError] if authentication fails.
54
+ # @raise [VectorMCP::ForbiddenError] if authorization fails.
55
+ def self.call_tool(params, session, server)
52
56
  tool_name = params["name"]
53
57
  arguments = params["arguments"] || {}
54
58
 
55
- tool = server.tools[tool_name]
56
- raise VectorMCP::NotFoundError.new("Not Found", details: "Tool not found: #{tool_name}") unless tool
59
+ context = create_tool_context(tool_name, params, session, server)
60
+ context = server.middleware_manager.execute_hooks(:before_tool_call, context)
61
+ return handle_middleware_error(context) if context.error?
57
62
 
58
- # Validate arguments against the tool's input schema
59
- validate_input_arguments!(tool_name, tool, arguments)
63
+ begin
64
+ tool = find_tool!(tool_name, server)
65
+ security_result = validate_tool_security!(session, tool, server)
66
+ validate_input_arguments!(tool_name, tool, arguments)
60
67
 
61
- # Let StandardError propagate to Server#handle_request
62
- result = tool.handler.call(arguments)
63
- {
64
- isError: false,
65
- content: VectorMCP::Util.convert_to_mcp_content(result)
66
- }
68
+ result = execute_tool_handler(tool, arguments, security_result)
69
+ context.result = build_tool_result(result)
70
+
71
+ context = server.middleware_manager.execute_hooks(:after_tool_call, context)
72
+ context.result
73
+ rescue StandardError => e
74
+ handle_tool_error(e, context, server)
75
+ end
67
76
  end
68
77
 
69
78
  # Handles the `resources/list` request.
@@ -83,24 +92,33 @@ module VectorMCP
83
92
  #
84
93
  # @param params [Hash] The request parameters.
85
94
  # Expected key: "uri" (String).
86
- # @param _session [VectorMCP::Session] The current session (ignored).
95
+ # @param session [VectorMCP::Session] The current session.
87
96
  # @param server [VectorMCP::Server] The server instance.
88
97
  # @return [Hash] A hash containing an array of content items from the resource.
89
98
  # Example: `{ contents: [{ type: "text", text: "...", uri: "memory://data" }] }`
90
99
  # @raise [VectorMCP::NotFoundError] if the requested resource URI is not found.
91
- def self.read_resource(params, _session, server)
100
+ # @raise [VectorMCP::UnauthorizedError] if authentication fails.
101
+ # @raise [VectorMCP::ForbiddenError] if authorization fails.
102
+ def self.read_resource(params, session, server)
92
103
  uri_s = params["uri"]
93
- raise VectorMCP::NotFoundError.new("Not Found", details: "Resource not found: #{uri_s}") unless server.resources[uri_s]
94
104
 
95
- resource = server.resources[uri_s]
96
- # Let StandardError propagate to Server#handle_request
97
- content_raw = resource.handler.call(params)
98
- contents = VectorMCP::Util.convert_to_mcp_content(content_raw, mime_type: resource.mime_type)
99
- contents.each do |item|
100
- # Add URI to each content item if not already present
101
- item[:uri] ||= uri_s
105
+ context = create_resource_context(uri_s, params, session, server)
106
+ context = server.middleware_manager.execute_hooks(:before_resource_read, context)
107
+ return handle_middleware_error(context) if context.error?
108
+
109
+ begin
110
+ resource = find_resource!(uri_s, server)
111
+ security_result = validate_resource_security!(session, resource, server)
112
+
113
+ content_raw = execute_resource_handler(resource, params, security_result)
114
+ contents = process_resource_content(content_raw, resource, uri_s)
115
+
116
+ context.result = { contents: contents }
117
+ context = server.middleware_manager.execute_hooks(:after_resource_read, context)
118
+ context.result
119
+ rescue StandardError => e
120
+ handle_resource_error(e, context, server)
102
121
  end
103
- { contents: contents }
104
122
  end
105
123
 
106
124
  # Handles the `prompts/list` request.
@@ -161,20 +179,51 @@ module VectorMCP
161
179
  # @raise [VectorMCP::NotFoundError] if the prompt name is not found.
162
180
  # @raise [VectorMCP::InvalidParamsError] if arguments are invalid.
163
181
  # @raise [VectorMCP::InternalError] if the prompt handler returns an invalid data structure.
164
- def self.get_prompt(params, _session, server)
182
+ def self.get_prompt(params, session, server)
165
183
  prompt_name = params["name"]
166
- prompt = fetch_prompt(prompt_name, server)
167
184
 
168
- arguments = params["arguments"] || {}
169
- validate_arguments!(prompt_name, prompt, arguments)
185
+ # Create middleware context
186
+ context = VectorMCP::Middleware::Context.new(
187
+ operation_type: :prompt_get,
188
+ operation_name: prompt_name,
189
+ params: params,
190
+ session: session,
191
+ server: server,
192
+ metadata: { start_time: Time.now }
193
+ )
194
+
195
+ # Execute before_prompt_get hooks
196
+ context = server.middleware_manager.execute_hooks(:before_prompt_get, context)
197
+ return handle_middleware_error(context) if context.error?
198
+
199
+ begin
200
+ prompt = fetch_prompt(prompt_name, server)
201
+
202
+ arguments = params["arguments"] || {}
203
+ validate_arguments!(prompt_name, prompt, arguments)
170
204
 
171
- # Call the registered handler after arguments were validated
172
- result_data = prompt.handler.call(arguments)
205
+ # Call the registered handler after arguments were validated
206
+ result_data = prompt.handler.call(arguments)
173
207
 
174
- validate_prompt_response!(prompt_name, result_data, server)
208
+ validate_prompt_response!(prompt_name, result_data, server)
175
209
 
176
- # Return the handler response directly (must match GetPromptResult schema)
177
- result_data
210
+ # Set result in context
211
+ context.result = result_data
212
+
213
+ # Execute after_prompt_get hooks
214
+ context = server.middleware_manager.execute_hooks(:after_prompt_get, context)
215
+
216
+ context.result
217
+ rescue StandardError => e
218
+ # Set error in context and execute error hooks
219
+ context.error = e
220
+ context = server.middleware_manager.execute_hooks(:on_prompt_error, context)
221
+
222
+ # Re-raise unless middleware handled the error
223
+ raise e unless context.result
224
+
225
+ context.result
226
+ end
178
227
  end
179
228
 
180
229
  # --- Notification Handlers ---
@@ -344,6 +393,194 @@ module VectorMCP
344
393
  end
345
394
  private_class_method :validate_input_arguments!
346
395
  private_class_method :raise_tool_validation_error
396
+
397
+ # Security helper methods
398
+
399
+ # Check security for tool access
400
+ # @api private
401
+ # @param session [VectorMCP::Session] The current session
402
+ # @param tool [VectorMCP::Definitions::Tool] The tool being accessed
403
+ # @param server [VectorMCP::Server] The server instance
404
+ # @return [Hash] Security check result
405
+ def self.check_tool_security(session, tool, server)
406
+ # Extract request context from session for security middleware
407
+ request = extract_request_from_session(session)
408
+ server.security_middleware.process_request(request, action: :call, resource: tool)
409
+ end
410
+ private_class_method :check_tool_security
411
+
412
+ # Check security for resource access
413
+ # @api private
414
+ # @param session [VectorMCP::Session] The current session
415
+ # @param resource [VectorMCP::Definitions::Resource] The resource being accessed
416
+ # @param server [VectorMCP::Server] The server instance
417
+ # @return [Hash] Security check result
418
+ def self.check_resource_security(session, resource, server)
419
+ request = extract_request_from_session(session)
420
+ server.security_middleware.process_request(request, action: :read, resource: resource)
421
+ end
422
+ private_class_method :check_resource_security
423
+
424
+ # Extract request context from session for security processing
425
+ # @api private
426
+ # @param session [VectorMCP::Session] The current session
427
+ # @return [Hash] Request context for security middleware
428
+ def self.extract_request_from_session(session)
429
+ # Extract security context from session
430
+ # This will be enhanced as we integrate with transport layers
431
+ {
432
+ headers: session.instance_variable_get(:@request_headers) || {},
433
+ params: session.instance_variable_get(:@request_params) || {},
434
+ session_id: session.respond_to?(:id) ? session.id : "test-session"
435
+ }
436
+ end
437
+ private_class_method :extract_request_from_session
438
+
439
+ # Handle security failure by raising appropriate error
440
+ # @api private
441
+ # @param security_result [Hash] The security check result
442
+ # @return [void]
443
+ # @raise [VectorMCP::UnauthorizedError, VectorMCP::ForbiddenError]
444
+ def self.handle_security_failure(security_result)
445
+ case security_result[:error_code]
446
+ when "AUTHENTICATION_REQUIRED"
447
+ raise VectorMCP::UnauthorizedError, security_result[:error]
448
+ when "AUTHORIZATION_FAILED"
449
+ raise VectorMCP::ForbiddenError, security_result[:error]
450
+ else
451
+ # Fallback to generic unauthorized error
452
+ raise VectorMCP::UnauthorizedError, security_result[:error] || "Security check failed"
453
+ end
454
+ end
455
+ private_class_method :handle_security_failure
456
+
457
+ # Handle middleware error by returning appropriate response or raising error
458
+ # @api private
459
+ # @param context [VectorMCP::Middleware::Context] The middleware context with error
460
+ # @return [Hash, nil] Response hash if middleware provided one
461
+ # @raise [StandardError] Re-raises the original error if not handled
462
+ def self.handle_middleware_error(context)
463
+ # If middleware provided a result, return it
464
+ return context.result if context.result
465
+
466
+ # Otherwise, re-raise the middleware error
467
+ raise context.error
468
+ end
469
+
470
+ # Tool helper methods
471
+
472
+ # Create middleware context for tool operations
473
+ def self.create_tool_context(tool_name, params, session, server)
474
+ VectorMCP::Middleware::Context.new(
475
+ operation_type: :tool_call,
476
+ operation_name: tool_name,
477
+ params: params,
478
+ session: session,
479
+ server: server,
480
+ metadata: { start_time: Time.now }
481
+ )
482
+ end
483
+
484
+ # Find and validate tool exists
485
+ def self.find_tool!(tool_name, server)
486
+ tool = server.tools[tool_name]
487
+ raise VectorMCP::NotFoundError.new("Not Found", details: "Tool not found: #{tool_name}") unless tool
488
+
489
+ tool
490
+ end
491
+
492
+ # Validate tool security
493
+ def self.validate_tool_security!(session, tool, server)
494
+ security_result = check_tool_security(session, tool, server)
495
+ handle_security_failure(security_result) unless security_result[:success]
496
+ security_result
497
+ end
498
+
499
+ # Execute tool handler with proper arity handling
500
+ def self.execute_tool_handler(tool, arguments, security_result)
501
+ if [1, -1].include?(tool.handler.arity)
502
+ tool.handler.call(arguments)
503
+ else
504
+ tool.handler.call(arguments, security_result[:session_context])
505
+ end
506
+ end
507
+
508
+ # Build tool result response
509
+ def self.build_tool_result(result)
510
+ {
511
+ isError: false,
512
+ content: VectorMCP::Util.convert_to_mcp_content(result)
513
+ }
514
+ end
515
+
516
+ # Handle tool execution errors
517
+ def self.handle_tool_error(error, context, server)
518
+ context.error = error
519
+ context = server.middleware_manager.execute_hooks(:on_tool_error, context)
520
+ raise error unless context.result
521
+
522
+ context.result
523
+ end
524
+
525
+ # Resource helper methods
526
+
527
+ # Create middleware context for resource operations
528
+ def self.create_resource_context(uri_s, params, session, server)
529
+ VectorMCP::Middleware::Context.new(
530
+ operation_type: :resource_read,
531
+ operation_name: uri_s,
532
+ params: params,
533
+ session: session,
534
+ server: server,
535
+ metadata: { start_time: Time.now }
536
+ )
537
+ end
538
+
539
+ # Find and validate resource exists
540
+ def self.find_resource!(uri_s, server)
541
+ raise VectorMCP::NotFoundError.new("Not Found", details: "Resource not found: #{uri_s}") unless server.resources[uri_s]
542
+
543
+ server.resources[uri_s]
544
+ end
545
+
546
+ # Validate resource security
547
+ def self.validate_resource_security!(session, resource, server)
548
+ security_result = check_resource_security(session, resource, server)
549
+ handle_security_failure(security_result) unless security_result[:success]
550
+ security_result
551
+ end
552
+
553
+ # Execute resource handler with proper arity handling
554
+ def self.execute_resource_handler(resource, params, security_result)
555
+ if [1, -1].include?(resource.handler.arity)
556
+ resource.handler.call(params)
557
+ else
558
+ resource.handler.call(params, security_result[:session_context])
559
+ end
560
+ end
561
+
562
+ # Process resource content and add URI
563
+ def self.process_resource_content(content_raw, resource, uri_s)
564
+ contents = VectorMCP::Util.convert_to_mcp_content(content_raw, mime_type: resource.mime_type)
565
+ contents.each do |item|
566
+ item[:uri] ||= uri_s
567
+ end
568
+ contents
569
+ end
570
+
571
+ # Handle resource execution errors
572
+ def self.handle_resource_error(error, context, server)
573
+ context.error = error
574
+ context = server.middleware_manager.execute_hooks(:on_resource_error, context)
575
+ raise error unless context.result
576
+
577
+ context.result
578
+ end
579
+
580
+ private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :validate_tool_security!,
581
+ :execute_tool_handler, :build_tool_result, :handle_tool_error, :create_resource_context,
582
+ :find_resource!, :validate_resource_security!, :execute_resource_handler,
583
+ :process_resource_content, :handle_resource_error
347
584
  end
348
585
  end
349
586
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+
6
+ module VectorMCP
7
+ # Simple, environment-driven logger for VectorMCP
8
+ # Supports JSON and text formats with component-based identification
9
+ class Logger
10
+ LEVELS = {
11
+ "TRACE" => ::Logger::DEBUG,
12
+ "DEBUG" => ::Logger::DEBUG,
13
+ "INFO" => ::Logger::INFO,
14
+ "WARN" => ::Logger::WARN,
15
+ "ERROR" => ::Logger::ERROR,
16
+ "FATAL" => ::Logger::FATAL
17
+ }.freeze
18
+
19
+ attr_reader :component, :ruby_logger
20
+
21
+ def initialize(component = "vectormcp")
22
+ @component = component.to_s
23
+ @ruby_logger = create_ruby_logger
24
+ @format = ENV.fetch("VECTORMCP_LOG_FORMAT", "text").downcase
25
+ end
26
+
27
+ def self.for(component)
28
+ new(component)
29
+ end
30
+
31
+ # Log methods with context support and block evaluation
32
+ def debug(message = nil, **context, &block)
33
+ log(:debug, message || block&.call, context)
34
+ end
35
+
36
+ def info(message = nil, **context, &block)
37
+ log(:info, message || block&.call, context)
38
+ end
39
+
40
+ def warn(message = nil, **context, &block)
41
+ log(:warn, message || block&.call, context)
42
+ end
43
+
44
+ def error(message = nil, **context, &block)
45
+ log(:error, message || block&.call, context)
46
+ end
47
+
48
+ def fatal(message = nil, **context, &block)
49
+ log(:fatal, message || block&.call, context)
50
+ end
51
+
52
+ # Security-specific logging
53
+ def security(message, **context)
54
+ log(:error, "[SECURITY] #{message}", context.merge(security_event: true))
55
+ end
56
+
57
+ # Performance measurement
58
+ def measure(description, **context)
59
+ start_time = Time.now
60
+ result = yield
61
+ duration = Time.now - start_time
62
+
63
+ info("#{description} completed", **context, duration_ms: (duration * 1000).round(2),
64
+ success: true)
65
+
66
+ result
67
+ rescue StandardError => e
68
+ duration = Time.now - start_time
69
+ error("#{description} failed", **context, duration_ms: (duration * 1000).round(2),
70
+ success: false,
71
+ error: e.class.name,
72
+ error_message: e.message)
73
+ raise
74
+ end
75
+
76
+ private
77
+
78
+ def log(level, message, context)
79
+ return unless @ruby_logger.send("#{level}?")
80
+
81
+ if @format == "json"
82
+ log_json(level, message, context)
83
+ else
84
+ log_text(level, message, context)
85
+ end
86
+ end
87
+
88
+ def log_json(level, message, context)
89
+ entry = {
90
+ timestamp: Time.now.iso8601(3),
91
+ level: level.to_s.upcase,
92
+ component: @component,
93
+ message: message,
94
+ thread_id: Thread.current.object_id
95
+ }
96
+ entry.merge!(context) unless context.empty?
97
+
98
+ @ruby_logger.send(level, entry.to_json)
99
+ end
100
+
101
+ def log_text(level, message, context)
102
+ formatted_message = if context.empty?
103
+ "[#{@component}] #{message}"
104
+ else
105
+ context_str = context.map { |k, v| "#{k}=#{v}" }.join(" ")
106
+ "[#{@component}] #{message} (#{context_str})"
107
+ end
108
+
109
+ @ruby_logger.send(level, formatted_message)
110
+ end
111
+
112
+ def create_ruby_logger
113
+ output = determine_output
114
+ logger = ::Logger.new(output)
115
+ logger.level = determine_level
116
+ logger.formatter = method(:format_log_entry)
117
+ logger
118
+ end
119
+
120
+ def determine_output
121
+ case ENV.fetch("VECTORMCP_LOG_OUTPUT", "stderr").downcase
122
+ when "stdout"
123
+ $stdout
124
+ when "file"
125
+ file_path = ENV.fetch("VECTORMCP_LOG_FILE", "./vectormcp.log")
126
+ File.open(file_path, "a")
127
+ else
128
+ $stderr
129
+ end
130
+ end
131
+
132
+ def determine_level
133
+ level_name = ENV.fetch("VECTORMCP_LOG_LEVEL", "INFO").upcase
134
+ LEVELS.fetch(level_name, ::Logger::INFO)
135
+ end
136
+
137
+ def format_log_entry(severity, datetime, _progname, msg)
138
+ if @format == "json"
139
+ # JSON messages are already formatted
140
+ "#{msg}\n"
141
+ else
142
+ # Text format with timestamp
143
+ timestamp = datetime.strftime("%Y-%m-%d %H:%M:%S.%3N")
144
+ "#{timestamp} [#{severity}] #{msg}\n"
145
+ end
146
+ end
147
+ end
148
+ end