vector_mcp 0.3.2 → 0.3.4

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/lib/vector_mcp/definitions.rb +25 -9
  4. data/lib/vector_mcp/errors.rb +2 -6
  5. data/lib/vector_mcp/handlers/core.rb +12 -10
  6. data/lib/vector_mcp/image_util.rb +27 -2
  7. data/lib/vector_mcp/log_filter.rb +48 -0
  8. data/lib/vector_mcp/middleware/base.rb +1 -7
  9. data/lib/vector_mcp/middleware/manager.rb +3 -15
  10. data/lib/vector_mcp/request_context.rb +182 -0
  11. data/lib/vector_mcp/sampling/result.rb +11 -1
  12. data/lib/vector_mcp/security/middleware.rb +2 -28
  13. data/lib/vector_mcp/security/strategies/api_key.rb +29 -28
  14. data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
  15. data/lib/vector_mcp/server/capabilities.rb +5 -7
  16. data/lib/vector_mcp/server/message_handling.rb +11 -5
  17. data/lib/vector_mcp/server.rb +21 -10
  18. data/lib/vector_mcp/session.rb +96 -6
  19. data/lib/vector_mcp/transport/base_session_manager.rb +320 -0
  20. data/lib/vector_mcp/transport/http_stream/event_store.rb +157 -0
  21. data/lib/vector_mcp/transport/http_stream/session_manager.rb +191 -0
  22. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +270 -0
  23. data/lib/vector_mcp/transport/http_stream.rb +961 -0
  24. data/lib/vector_mcp/transport/sse/client_connection.rb +1 -1
  25. data/lib/vector_mcp/transport/sse/stream_manager.rb +1 -1
  26. data/lib/vector_mcp/transport/sse.rb +74 -19
  27. data/lib/vector_mcp/transport/sse_session_manager.rb +188 -0
  28. data/lib/vector_mcp/transport/stdio.rb +70 -13
  29. data/lib/vector_mcp/transport/stdio_session_manager.rb +181 -0
  30. data/lib/vector_mcp/util.rb +39 -1
  31. data/lib/vector_mcp/version.rb +1 -1
  32. data/lib/vector_mcp.rb +1 -0
  33. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edd8f7804cea74064b13b6432daa19149d4b2d6c34efbd7ccfbae393870b648a
4
- data.tar.gz: bd04d07372ef947498c39021adfba055d8030b94bf76b567fc8b5cb73aae994b
3
+ metadata.gz: 96c22c8497dcfd618605017a41a11d747e596654d86e856551159303133e0ab9
4
+ data.tar.gz: 301099d4a3b6b21c28ad82adf95c77f3469a0c4ba40dbd63f9f4f1060391b37f
5
5
  SHA512:
6
- metadata.gz: 3a47a3ad028e39de9bd8d4973779040fd0c571067e4c3588ad406d26dd138cdfad06921d497a3922d1eb2406df3ede02379df7f24620539cb1170ac6fdb1bed5
7
- data.tar.gz: ad1d3d147c3b4ef8624651aeabb0809f689bbfb5c4ae702dcc0c5fa458cc500ff7ffd54e46a4ea02ae995d908af0122f5a88b7ab895eeaab8c1d780e6eec8043
6
+ metadata.gz: 4d464e8ae1e4472eead1580582b2e39dcaadcc5da54087e3022386c05c2dafd0a45f80509a23653273d615a7f739e0cbd8de052acb6d816c1442819bb775b374
7
+ data.tar.gz: 26818191d6c915a31562356b3dec35ada9a1e0bd42f662d37e5884ce05ba139bd54f4fe768d6a2b5106de21e97da2b5a3c9970797eb05082256761e82f276238
data/CHANGELOG.md CHANGED
@@ -1,3 +1,78 @@
1
+ ## [0.3.4] – 2026-03-17
2
+
3
+ ### Added
4
+
5
+ * **Batch JSON-RPC Support**: Added batch request dispatch on POST endpoint for processing multiple JSON-RPC requests in a single HTTP call
6
+ * **SSE Response Mode for POST**: POST requests can now receive responses via Server-Sent Events streaming
7
+ * **Accept Header Validation**: Added proper Accept header validation on POST and GET endpoints per MCP specification
8
+
9
+ ### Security
10
+
11
+ * **Constant-Time API Key Comparison** (SECURITY-001): Replaced direct string comparison with `Rack::Utils.secure_compare` to prevent timing attacks
12
+ * **Disable Query Parameter Token Extraction** (SECURITY-002): Token extraction from query parameters now disabled by default to prevent token leakage via URL logging and browser history
13
+ * **Path Traversal Protection for ImageUtil** (SECURITY-004): Added path traversal validation to `ImageUtil` file operations to prevent unauthorized file access
14
+ * **Sensitive Data Filtering in Debug Logs** (SECURITY-005): Debug log output now filters sensitive fields (tokens, keys, credentials) to prevent accidental credential exposure
15
+ * **Cross-Session Event Leakage Prevention**: Fixed EventStore to prevent events from one session being accessible to another session
16
+ * **Session ID Validation on POST**: Fixed session fixation vulnerability where unknown session IDs were silently accepted; server now returns 404 for invalid sessions
17
+
18
+ ### Fixed
19
+
20
+ * **Code Quality**: RuboCop style fixes and cleanup of unnecessary files
21
+
22
+ ## [0.3.3] – 2025-07-29
23
+
24
+ ### Fixed
25
+
26
+ * **Critical Security Fix - SSE Transport Session Isolation**: Fixed default behavior where SSE transport shared session state across all clients
27
+ - **BREAKING CHANGE**: SSE transport now defaults to secure session isolation mode
28
+ - Session manager is now enabled by default to prevent race conditions and data leakage
29
+ - Legacy shared session mode available via `disable_session_manager: true` option (deprecated with warning)
30
+ - Enhanced security tests to prevent regression of this critical vulnerability
31
+
32
+ * **Critical Security Fix - Path Traversal Vulnerability**: Replaced naive path traversal validation with robust canonicalization
33
+ - **Path Validation Enhancement**: Now uses `File.expand_path` for proper path canonicalization
34
+ - **Attack Prevention**: Eliminates false positives from simple string-based `.` checks
35
+ - **Security Monitoring**: Added warnings for potential path traversal attempts
36
+ - **Bypass Protection**: Prevents sophisticated path traversal attacks through encoding or complex patterns
37
+
38
+ * **Session Compatibility**: Fixed session object type detection across different transports
39
+ - **Transport Compatibility**: Added automatic detection between `BaseSessionManager::Session` and `VectorMCP::Session` types
40
+ - **Method Resolution**: Fixed `undefined method 'initialized?'` and `'initialize!'` errors
41
+ - **Transport Layer**: Enhanced stdio, SSE, and HTTP stream transports for consistent session handling
42
+
43
+ * **Race Condition Fix**: Resolved concurrent session creation test failures in SSE transport
44
+ - **Thread Safety**: Implemented `Concurrent::Array` for thread-safe session tracking
45
+ - **Test Stability**: Enhanced test reliability for concurrent operations
46
+
47
+ * **Code Quality**: Fixed RuboCop style and linting violations
48
+ - **Naming Conventions**: Updated method names to follow Ruby conventions (removed `get_` prefixes)
49
+ - **Style Compliance**: Fixed line length violations and predicate method naming
50
+ - **Consistency**: Applied consistent coding standards across the codebase
51
+
52
+ ### Security
53
+
54
+ * **Defense in Depth**: Major security improvements addressing critical vulnerabilities
55
+ - **Multi-Client Security**: Eliminated shared state vulnerabilities in SSE transport
56
+ - **Path Security**: Comprehensive path traversal protection using canonical path resolution
57
+ - **Session Isolation**: Proper session boundary enforcement across all transport types
58
+
59
+ * **Backward Compatibility**: Security fixes maintain API compatibility while improving defaults
60
+ - **Opt-in Legacy Mode**: Deprecated insecure modes available for gradual migration
61
+ - **Migration Path**: Clear deprecation warnings guide users to secure configurations
62
+
63
+ ### Testing
64
+
65
+ * **Enhanced Security Test Coverage**: Comprehensive test suites for critical security fixes
66
+ - **Path Traversal Tests**: 20+ test cases covering legitimate paths, attack vectors, and edge cases
67
+ - **SSE Security Tests**: Verification of default secure behavior and session isolation
68
+ - **Integration Tests**: Cross-transport compatibility and session handling validation
69
+
70
+ ### Technical Details
71
+
72
+ * **Session Architecture**: Improved session management layer for better transport compatibility
73
+ * **Security Monitoring**: Enhanced logging and warning systems for security events
74
+ * **Error Handling**: Better error messages and debugging information for session-related issues
75
+
1
76
  ## [0.3.2] – 2025-07-02
2
77
 
3
78
  ### Added
@@ -242,17 +242,33 @@ module VectorMCP
242
242
  # Currently, only file:// scheme is supported per MCP spec
243
243
  raise ArgumentError, "Only file:// URIs are supported for roots, got: #{parsed_uri.scheme}://" unless parsed_uri.scheme == "file"
244
244
 
245
- # Validate path exists and is a directory
246
- path = parsed_uri.path
247
- raise ArgumentError, "Root directory does not exist: #{path}" unless File.exist?(path)
248
-
249
- raise ArgumentError, "Root path is not a directory: #{path}" unless File.directory?(path)
245
+ # Validate and canonicalize path for security
246
+ raw_path = parsed_uri.path
247
+
248
+ # Canonicalize the path to resolve any relative components (., .., etc.)
249
+ # This prevents path traversal attacks and normalizes the path
250
+ begin
251
+ canonical_path = File.expand_path(raw_path)
252
+ rescue ArgumentError => e
253
+ raise ArgumentError, "Invalid path format: #{raw_path} (#{e.message})"
254
+ end
250
255
 
251
- # Security check: ensure we can read the directory
256
+ # Security check: Verify the canonical path exists and is a directory
257
+ raise ArgumentError, "Root directory does not exist: #{canonical_path}" unless File.exist?(canonical_path)
258
+ raise ArgumentError, "Root path is not a directory: #{canonical_path}" unless File.directory?(canonical_path)
259
+ raise ArgumentError, "Root directory is not readable: #{canonical_path}" unless File.readable?(canonical_path)
260
+
261
+ # Additional security: Check if the canonical path differs significantly from raw path
262
+ # This can indicate potential path traversal attempts
263
+ if raw_path != canonical_path && raw_path.include?("..")
264
+ # Log the canonicalization for security monitoring
265
+ # Note: This is informational - the canonical path is what we'll actually use
266
+ warn "[SECURITY] Path canonicalized from '#{raw_path}' to '#{canonical_path}'. " \
267
+ "This may indicate a path traversal attempt."
268
+ end
252
269
 
253
- raise ArgumentError, "Root directory is not readable: #{path}" unless File.readable?(path)
254
- # Validate against path traversal attempts in the URI itself
255
- raise ArgumentError, "Root path contains unsafe traversal patterns: #{path}" if path.include?("..") || path.include?("./")
270
+ # Update the URI to use the canonical path for consistency
271
+ self.uri = "file://#{canonical_path}"
256
272
 
257
273
  true
258
274
  end
@@ -28,8 +28,6 @@ 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
- logger = VectorMCP.logger_for("errors")
32
- logger.debug("Initializing ProtocolError with code: #{code}")
33
31
  @code = code
34
32
  @message = message
35
33
  @details = details # NOTE: `data` in JSON-RPC is often used for this purpose.
@@ -105,8 +103,7 @@ module VectorMCP
105
103
  # @param details [Hash, nil] Additional details for the error (optional).
106
104
  # @param request_id [String, Integer, nil] The ID of the originating request.
107
105
  def initialize(message = "Server error", code: -32_000, details: nil, request_id: nil)
108
- logger = VectorMCP.logger_for("errors")
109
- logger.debug("Initializing ServerError with code: #{code}")
106
+ # ServerError initialization
110
107
  unless (-32_099..-32_000).cover?(code)
111
108
  warn "Server error code #{code} is outside of the reserved range (-32099 to -32000). Using -32000 instead."
112
109
  code = -32_000
@@ -133,8 +130,7 @@ module VectorMCP
133
130
  # @param details [Hash, nil] Additional details for the error (optional).
134
131
  # @param request_id [String, Integer, nil] The ID of the originating request.
135
132
  def initialize(message = "Not Found", details: nil, request_id: nil)
136
- logger = VectorMCP.logger_for("errors")
137
- logger.debug("Initializing NotFoundError with code: -32001")
133
+ # NotFoundError initialization
138
134
  super(message, code: -32_001, details: details, request_id: request_id)
139
135
  end
140
136
  end
@@ -22,8 +22,6 @@ module VectorMCP
22
22
  # @param _server [VectorMCP::Server] The server instance (ignored).
23
23
  # @return [Hash] An empty hash, as per MCP spec for ping.
24
24
  def self.ping(_params, _session, _server)
25
- logger = VectorMCP.logger_for("handlers.core")
26
- logger.debug("Handling ping request")
27
25
  {}
28
26
  end
29
27
 
@@ -65,7 +63,7 @@ module VectorMCP
65
63
  security_result = validate_tool_security!(session, tool, server)
66
64
  validate_input_arguments!(tool_name, tool, arguments)
67
65
 
68
- result = execute_tool_handler(tool, arguments, security_result)
66
+ result = execute_tool_handler(tool, arguments, security_result, session)
69
67
  context.result = build_tool_result(result)
70
68
 
71
69
  context = server.middleware_manager.execute_hooks(:after_tool_call, context)
@@ -426,12 +424,16 @@ module VectorMCP
426
424
  # @param session [VectorMCP::Session] The current session
427
425
  # @return [Hash] Request context for security middleware
428
426
  def self.extract_request_from_session(session)
429
- # Extract security context from session
430
- # This will be enhanced as we integrate with transport layers
427
+ # All sessions should have a request_context - this is enforced by Session initialization
428
+ unless session.respond_to?(:request_context) && session.request_context
429
+ raise VectorMCP::InternalError,
430
+ "Session missing request_context - transport layer integration error. Session ID: #{session.id}"
431
+ end
432
+
431
433
  {
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"
434
+ headers: session.request_context.headers,
435
+ params: session.request_context.params,
436
+ session_id: session.id
435
437
  }
436
438
  end
437
439
  private_class_method :extract_request_from_session
@@ -497,11 +499,11 @@ module VectorMCP
497
499
  end
498
500
 
499
501
  # Execute tool handler with proper arity handling
500
- def self.execute_tool_handler(tool, arguments, security_result)
502
+ def self.execute_tool_handler(tool, arguments, _security_result, session)
501
503
  if [1, -1].include?(tool.handler.arity)
502
504
  tool.handler.call(arguments)
503
505
  else
504
- tool.handler.call(arguments, security_result[:session_context])
506
+ tool.handler.call(arguments, session)
505
507
  end
506
508
  end
507
509
 
@@ -217,12 +217,22 @@ module VectorMCP
217
217
  # @param file_path [String] Path to the image file.
218
218
  # @param validate [Boolean] Whether to validate the image.
219
219
  # @param max_size [Integer] Maximum allowed size for validation.
220
+ # @param base_directory [String, nil] Optional base directory for path traversal protection.
221
+ # When provided, the resolved file_path must reside within this directory.
220
222
  # @return [Hash] MCP image content hash.
221
- # @raise [ArgumentError] If file doesn't exist or validation fails.
223
+ # @raise [ArgumentError] If file doesn't exist, validation fails, or path traversal is detected.
222
224
  #
223
225
  # @example
224
226
  # content = VectorMCP::ImageUtil.file_to_mcp_image_content("./avatar.png")
225
- def file_to_mcp_image_content(file_path, validate: true, max_size: DEFAULT_MAX_SIZE)
227
+ #
228
+ # @example With path traversal protection
229
+ # content = VectorMCP::ImageUtil.file_to_mcp_image_content(
230
+ # user_input_path,
231
+ # base_directory: "/app/uploads"
232
+ # )
233
+ def file_to_mcp_image_content(file_path, validate: true, max_size: DEFAULT_MAX_SIZE, base_directory: nil)
234
+ validate_path_safety!(file_path, base_directory) if base_directory
235
+
226
236
  raise ArgumentError, "Image file not found: #{file_path}" unless File.exist?(file_path)
227
237
 
228
238
  raise ArgumentError, "Image file not readable: #{file_path}" unless File.readable?(file_path)
@@ -231,6 +241,21 @@ module VectorMCP
231
241
  to_mcp_image_content(binary_data, validate: validate, max_size: max_size)
232
242
  end
233
243
 
244
+ # Validates that a file path does not escape the given base directory.
245
+ #
246
+ # @param file_path [String] The file path to validate.
247
+ # @param base_directory [String] The base directory boundary.
248
+ # @raise [ArgumentError] If the resolved path is outside base_directory.
249
+ # @api private
250
+ def validate_path_safety!(file_path, base_directory)
251
+ resolved_base = File.expand_path(base_directory)
252
+ resolved_path = File.expand_path(file_path, resolved_base)
253
+
254
+ return if resolved_path.start_with?("#{resolved_base}/") || resolved_path == resolved_base
255
+
256
+ raise ArgumentError, "Path traversal detected: resolved path is outside the allowed base directory"
257
+ end
258
+
234
259
  # Extracts image metadata from binary data.
235
260
  #
236
261
  # @param data [String] Binary image data.
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ # Filters sensitive data from values before they are written to logs.
5
+ # Redacts known sensitive keys in hashes and token patterns in strings.
6
+ module LogFilter
7
+ SENSITIVE_KEYS = %w[
8
+ authorization x-api-key api_key apikey token jwt_token
9
+ password secret cookie set-cookie x-jwt-token
10
+ ].freeze
11
+
12
+ FILTERED = "[FILTERED]"
13
+
14
+ # Bearer/Basic token pattern: "Bearer <token>" or "Basic <token>"
15
+ TOKEN_PATTERN = /\b(Bearer|Basic|API-Key)\s+\S+/i
16
+
17
+ module_function
18
+
19
+ # Deep-redacts sensitive keys from a hash.
20
+ # @param hash [Hash] the hash to filter
21
+ # @return [Hash] a copy with sensitive values replaced by "[FILTERED]"
22
+ def filter_hash(hash)
23
+ return hash unless hash.is_a?(Hash)
24
+
25
+ hash.each_with_object({}) do |(key, value), filtered|
26
+ str_key = key.to_s.downcase
27
+ filtered[key] = if SENSITIVE_KEYS.include?(str_key)
28
+ FILTERED
29
+ elsif value.is_a?(Hash)
30
+ filter_hash(value)
31
+ elsif value.is_a?(String)
32
+ filter_string(value)
33
+ else
34
+ value
35
+ end
36
+ end
37
+ end
38
+
39
+ # Redacts Bearer/Basic/API-Key token patterns in a string.
40
+ # @param str [String] the string to filter
41
+ # @return [String] the filtered string
42
+ def filter_string(str)
43
+ return str unless str.is_a?(String)
44
+
45
+ str.gsub(TOKEN_PATTERN, '\1 [FILTERED]')
46
+ end
47
+ end
48
+ end
@@ -16,13 +16,7 @@ module VectorMCP
16
16
  # @param hook_type [String] Type of hook being executed
17
17
  # @param context [VectorMCP::Middleware::Context] Execution context
18
18
  def call(hook_type, context)
19
- @logger.debug("Executing middleware hook") do
20
- {
21
- middleware: self.class.name,
22
- hook_type: hook_type,
23
- operation: context.operation_name
24
- }
25
- end
19
+ # Generic middleware hook execution
26
20
  end
27
21
 
28
22
  # Tool operation hooks
@@ -113,16 +113,10 @@ module VectorMCP
113
113
  @hooks[hook_type].to_a
114
114
  end
115
115
 
116
- def initialize_execution_state(hook_type_str, hooks, context)
116
+ def initialize_execution_state(hook_type_str, hooks, _context)
117
117
  start_time = Time.now
118
118
 
119
- @logger.debug("Executing middleware hooks") do
120
- {
121
- hook_type: hook_type_str,
122
- hook_count: hooks.size,
123
- operation: context.operation_name
124
- }
125
- end
119
+ # Executing middleware hooks
126
120
 
127
121
  {
128
122
  hook_type: hook_type_str,
@@ -157,13 +151,7 @@ module VectorMCP
157
151
  hooks_total: execution_state[:total_hooks]
158
152
  })
159
153
 
160
- @logger.debug("Completed middleware execution") do
161
- {
162
- hook_type: execution_state[:hook_type],
163
- execution_time: execution_time,
164
- hooks_executed: execution_state[:executed_count]
165
- }
166
- end
154
+ # Completed middleware execution
167
155
  end
168
156
 
169
157
  def handle_critical_error(error, hook_type, context)
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ # Encapsulates request-specific data for MCP sessions.
5
+ # This provides a formal interface for transports to populate request context
6
+ # and for handlers to access request data without coupling to session internals.
7
+ #
8
+ # @attr_reader headers [Hash] HTTP headers from the request
9
+ # @attr_reader params [Hash] Query parameters from the request
10
+ # @attr_reader method [String, nil] HTTP method (GET, POST, etc.) or transport-specific method
11
+ # @attr_reader path [String, nil] Request path or transport-specific path
12
+ # @attr_reader transport_metadata [Hash] Transport-specific metadata
13
+ class RequestContext
14
+ attr_reader :headers, :params, :method, :path, :transport_metadata
15
+
16
+ # Initialize a new request context with the provided data.
17
+ #
18
+ # @param headers [Hash] HTTP headers from the request (default: {})
19
+ # @param params [Hash] Query parameters from the request (default: {})
20
+ # @param method [String, nil] HTTP method or transport-specific method (default: nil)
21
+ # @param path [String, nil] Request path or transport-specific path (default: nil)
22
+ # @param transport_metadata [Hash] Transport-specific metadata (default: {})
23
+ def initialize(headers: {}, params: {}, method: nil, path: nil, transport_metadata: {})
24
+ @headers = normalize_headers(headers).freeze
25
+ @params = normalize_params(params).freeze
26
+ @method = method&.to_s&.freeze
27
+ @path = path&.to_s&.freeze
28
+ @transport_metadata = normalize_metadata(transport_metadata).freeze
29
+ end
30
+
31
+ # Convert the request context to a hash representation.
32
+ # This is useful for serialization and debugging.
33
+ #
34
+ # @return [Hash] Hash representation of the request context
35
+ def to_h
36
+ {
37
+ headers: @headers,
38
+ params: @params,
39
+ method: @method,
40
+ path: @path,
41
+ transport_metadata: @transport_metadata
42
+ }
43
+ end
44
+
45
+ # Check if the request context has any headers.
46
+ #
47
+ # @return [Boolean] True if headers are present, false otherwise
48
+ def headers?
49
+ !@headers.empty?
50
+ end
51
+
52
+ # Check if the request context has any parameters.
53
+ #
54
+ # @return [Boolean] True if parameters are present, false otherwise
55
+ def params?
56
+ !@params.empty?
57
+ end
58
+
59
+ # Get a specific header value.
60
+ #
61
+ # @param name [String] The header name
62
+ # @return [String, nil] The header value or nil if not found
63
+ def header(name)
64
+ @headers[name.to_s]
65
+ end
66
+
67
+ # Get a specific parameter value.
68
+ #
69
+ # @param name [String] The parameter name
70
+ # @return [String, nil] The parameter value or nil if not found
71
+ def param(name)
72
+ @params[name.to_s]
73
+ end
74
+
75
+ # Get transport-specific metadata.
76
+ #
77
+ # @param key [String, Symbol] The metadata key
78
+ # @return [Object, nil] The metadata value or nil if not found
79
+ def metadata(key)
80
+ @transport_metadata[key.to_s]
81
+ end
82
+
83
+ # Check if this is an HTTP-based transport.
84
+ #
85
+ # @return [Boolean] True if method is an HTTP method and path is present
86
+ def http_transport?
87
+ return false unless @method && @path
88
+
89
+ # Check if method is an HTTP method
90
+ http_methods = %w[GET POST PUT DELETE HEAD OPTIONS PATCH TRACE CONNECT]
91
+ http_methods.include?(@method.upcase)
92
+ end
93
+
94
+ # Create a minimal request context for non-HTTP transports.
95
+ # This is useful for stdio and other command-line transports.
96
+ #
97
+ # @param transport_type [String] The transport type identifier
98
+ # @return [RequestContext] A minimal request context
99
+ def self.minimal(transport_type)
100
+ new(
101
+ headers: {},
102
+ params: {},
103
+ method: transport_type.to_s.upcase,
104
+ path: "/",
105
+ transport_metadata: { transport_type: transport_type.to_s }
106
+ )
107
+ end
108
+
109
+ # Create a request context from a Rack environment.
110
+ # This is a convenience method for HTTP-based transports.
111
+ #
112
+ # @param rack_env [Hash] The Rack environment hash
113
+ # @param transport_type [String] The transport type identifier
114
+ # @return [RequestContext] A request context populated from the Rack environment
115
+ def self.from_rack_env(rack_env, transport_type)
116
+ # Handle nil rack_env by returning a minimal context
117
+ return minimal(transport_type) if rack_env.nil?
118
+
119
+ new(
120
+ headers: VectorMCP::Util.extract_headers_from_rack_env(rack_env),
121
+ params: VectorMCP::Util.extract_params_from_rack_env(rack_env),
122
+ method: rack_env["REQUEST_METHOD"],
123
+ path: rack_env["PATH_INFO"],
124
+ transport_metadata: {
125
+ transport_type: transport_type.to_s,
126
+ remote_addr: rack_env["REMOTE_ADDR"],
127
+ user_agent: rack_env["HTTP_USER_AGENT"],
128
+ content_type: rack_env["CONTENT_TYPE"]
129
+ }
130
+ )
131
+ end
132
+
133
+ # String representation of the request context.
134
+ #
135
+ # @return [String] String representation for debugging
136
+ def to_s
137
+ "<RequestContext method=#{@method} path=#{@path} headers=#{@headers.keys.size} params=#{@params.keys.size}>"
138
+ end
139
+
140
+ # Detailed string representation for debugging.
141
+ #
142
+ # @return [String] Detailed string representation
143
+ def inspect
144
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} " \
145
+ "method=#{@method.inspect} path=#{@path.inspect} " \
146
+ "headers=#{@headers.inspect} params=#{@params.inspect} " \
147
+ "transport_metadata=#{@transport_metadata.inspect}>"
148
+ end
149
+
150
+ private
151
+
152
+ # Normalize headers to ensure consistent format.
153
+ #
154
+ # @param headers [Hash] Raw headers hash
155
+ # @return [Hash] Normalized headers hash
156
+ def normalize_headers(headers)
157
+ return {} unless headers.is_a?(Hash)
158
+
159
+ headers.transform_keys(&:to_s).transform_values { |v| v.nil? ? "" : v.to_s }
160
+ end
161
+
162
+ # Normalize parameters to ensure consistent format.
163
+ #
164
+ # @param params [Hash] Raw parameters hash
165
+ # @return [Hash] Normalized parameters hash
166
+ def normalize_params(params)
167
+ return {} unless params.is_a?(Hash)
168
+
169
+ params.transform_keys(&:to_s).transform_values { |v| v.nil? ? "" : v.to_s }
170
+ end
171
+
172
+ # Normalize transport metadata to ensure consistent format.
173
+ #
174
+ # @param metadata [Hash] Raw metadata hash
175
+ # @return [Hash] Normalized metadata hash
176
+ def normalize_metadata(metadata)
177
+ return {} unless metadata.is_a?(Hash)
178
+
179
+ metadata.transform_keys(&:to_s)
180
+ end
181
+ end
182
+ end
@@ -19,12 +19,22 @@ module VectorMCP
19
19
  # - 'data' [String] (Optional) Base64 image data if type is "image".
20
20
  # - 'mimeType' [String] (Optional) Mime type if type is "image".
21
21
  def initialize(result_hash)
22
+ # Handle malformed or nil result_hash
23
+ raise ArgumentError, "Sampling result must be a Hash, got #{result_hash.class}: #{result_hash.inspect}" unless result_hash.is_a?(Hash)
24
+
22
25
  @raw_result = result_hash.transform_keys { |k| k.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase.to_sym }
23
26
 
24
27
  @model = @raw_result[:model]
25
28
  @stop_reason = @raw_result[:stop_reason]
26
29
  @role = @raw_result[:role]
27
- @content = (@raw_result[:content] || {}).transform_keys(&:to_sym)
30
+
31
+ # Safe content processing for malformed responses
32
+ content_raw = @raw_result[:content]
33
+ @content = if content_raw.is_a?(Hash)
34
+ content_raw.transform_keys(&:to_sym)
35
+ else
36
+ {}
37
+ end
28
38
 
29
39
  validate!
30
40
  end
@@ -133,35 +133,9 @@ module VectorMCP
133
133
  # @param env [Hash] the Rack environment
134
134
  # @return [Hash] extracted request data
135
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
136
  {
163
- headers: headers,
164
- params: params,
137
+ headers: VectorMCP::Util.extract_headers_from_rack_env(env),
138
+ params: VectorMCP::Util.extract_params_from_rack_env(env),
165
139
  method: env["REQUEST_METHOD"],
166
140
  path: env["PATH_INFO"],
167
141
  rack_env: env