vector_mcp 0.3.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64b4dac9a0a1e9c782d3b5d693e7582803621ac81555f992031f9edbdd3f5b6b
4
- data.tar.gz: 571e540ac540859b1fefaadc5e2b3cda39a1415c3496af38cee46cb839316c85
3
+ metadata.gz: 96c22c8497dcfd618605017a41a11d747e596654d86e856551159303133e0ab9
4
+ data.tar.gz: 301099d4a3b6b21c28ad82adf95c77f3469a0c4ba40dbd63f9f4f1060391b37f
5
5
  SHA512:
6
- metadata.gz: cc7c13562e31070ce7aef3d0d20801e4a7368a44b22ea58d01ec872f7f9191e7b70d96e72c3cdcd42b9ecc40dc9b7ea30e63ce12b5b54987734a933402269b71
7
- data.tar.gz: 6f989302cbd97c00aa5f2dfe28950cffb5c2c53c204c3ab10eb886a9dc680e0db6a704fef54229488910c837ebe3857c7e493ae5aa8729d8d6677fe048c223e1
6
+ metadata.gz: 4d464e8ae1e4472eead1580582b2e39dcaadcc5da54087e3022386c05c2dafd0a45f80509a23653273d615a7f739e0cbd8de052acb6d816c1442819bb775b374
7
+ data.tar.gz: 26818191d6c915a31562356b3dec35ada9a1e0bd42f662d37e5884ce05ba139bd54f4fe768d6a2b5106de21e97da2b5a3c9970797eb05082256761e82f276238
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
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
+
1
22
  ## [0.3.3] – 2025-07-29
2
23
 
3
24
  ### Fixed
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "openssl"
4
+
3
5
  module VectorMCP
4
6
  module Security
5
7
  module Strategies
@@ -10,8 +12,10 @@ module VectorMCP
10
12
 
11
13
  # Initialize with a list of valid API keys
12
14
  # @param keys [Array<String>] array of valid API keys
13
- def initialize(keys: [])
15
+ # @param allow_query_params [Boolean] whether to accept API keys from query parameters (default: false)
16
+ def initialize(keys: [], allow_query_params: false)
14
17
  @valid_keys = Set.new(keys.map(&:to_s))
18
+ @allow_query_params = allow_query_params
15
19
  end
16
20
 
17
21
  # Add a valid API key
@@ -33,7 +37,7 @@ module VectorMCP
33
37
  api_key = extract_api_key(request)
34
38
  return false unless api_key&.length&.positive?
35
39
 
36
- if @valid_keys.include?(api_key)
40
+ if secure_key_match?(api_key)
37
41
  {
38
42
  api_key: api_key,
39
43
  strategy: "api_key",
@@ -58,14 +62,33 @@ module VectorMCP
58
62
 
59
63
  private
60
64
 
65
+ # Constant-time comparison of API key against all valid keys.
66
+ # Iterates all keys to prevent timing side-channels.
67
+ # @param candidate [String] the API key to check
68
+ # @return [Boolean] true if the candidate matches a valid key
69
+ def secure_key_match?(candidate)
70
+ matched = false
71
+ @valid_keys.each do |valid_key|
72
+ next unless candidate.bytesize == valid_key.bytesize
73
+
74
+ matched = true if OpenSSL.fixed_length_secure_compare(candidate, valid_key)
75
+ end
76
+ matched
77
+ end
78
+
61
79
  # Extract API key from various request formats
62
80
  # @param request [Hash] the request object
63
81
  # @return [String, nil] the extracted API key
64
82
  def extract_api_key(request)
65
83
  headers = normalize_headers(request)
66
- params = normalize_params(request)
67
84
 
68
- extract_from_headers(headers) || extract_from_params(params)
85
+ from_headers = extract_from_headers(headers)
86
+ return from_headers if from_headers
87
+
88
+ return nil unless @allow_query_params
89
+
90
+ params = normalize_params(request)
91
+ extract_from_params(params)
69
92
  end
70
93
 
71
94
  # Normalize headers to handle different formats
@@ -17,12 +17,14 @@ module VectorMCP
17
17
  # Initialize JWT strategy
18
18
  # @param secret [String] the secret key for JWT verification
19
19
  # @param algorithm [String] the JWT algorithm (default: HS256)
20
+ # @param allow_query_params [Boolean] whether to accept JWT tokens from query parameters (default: false)
20
21
  # @param options [Hash] additional JWT verification options
21
- def initialize(secret:, algorithm: "HS256", **options)
22
+ def initialize(secret:, algorithm: "HS256", allow_query_params: false, **options)
22
23
  raise LoadError, "JWT gem is required for JWT authentication strategy" unless defined?(JWT)
23
24
 
24
25
  @secret = secret
25
26
  @algorithm = algorithm
27
+ @allow_query_params = allow_query_params
26
28
  @options = {
27
29
  algorithm: @algorithm,
28
30
  verify_expiration: true,
@@ -82,11 +84,14 @@ module VectorMCP
82
84
  # @return [String, nil] the extracted token
83
85
  def extract_token(request)
84
86
  headers = request[:headers] || request["headers"] || {}
85
- params = request[:params] || request["params"] || {}
86
87
 
87
- extract_from_auth_header(headers) ||
88
- extract_from_jwt_header(headers) ||
89
- extract_from_params(params)
88
+ from_headers = extract_from_auth_header(headers) || extract_from_jwt_header(headers)
89
+ return from_headers if from_headers
90
+
91
+ return nil unless @allow_query_params
92
+
93
+ params = request[:params] || request["params"] || {}
94
+ extract_from_params(params)
90
95
  end
91
96
 
92
97
  # Extract token from Authorization header
@@ -21,10 +21,10 @@ module VectorMCP
21
21
  params = message["params"] || {}
22
22
 
23
23
  if id && method # Request
24
- logger.debug("[#{session_id}] Request [#{id}]: #{method} with params: #{params.inspect}")
24
+ logger.debug("[#{session_id}] Request [#{id}]: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
25
25
  handle_request(id, method, params, session)
26
26
  elsif method # Notification
27
- logger.debug("[#{session_id}] Notification: #{method} with params: #{params.inspect}")
27
+ logger.debug("[#{session_id}] Notification: #{method} with params: #{VectorMCP::LogFilter.filter_hash(params).inspect}")
28
28
  handle_notification(method, params, session)
29
29
  nil # Notifications do not have a return value to send back to client
30
30
  elsif id # Invalid: Has ID but no method
@@ -190,7 +190,7 @@ module VectorMCP
190
190
 
191
191
  case strategy
192
192
  when :api_key
193
- add_api_key_auth(options[:keys] || [])
193
+ add_api_key_auth(options[:keys] || [], allow_query_params: options[:allow_query_params] || false)
194
194
  when :jwt
195
195
  add_jwt_auth(options)
196
196
  when :custom
@@ -313,9 +313,10 @@ module VectorMCP
313
313
 
314
314
  # Add API key authentication strategy
315
315
  # @param keys [Array<String>] array of valid API keys
316
+ # @param allow_query_params [Boolean] whether to accept API keys from query parameters
316
317
  # @return [void]
317
- def add_api_key_auth(keys)
318
- strategy = Security::Strategies::ApiKey.new(keys: keys)
318
+ def add_api_key_auth(keys, allow_query_params: false)
319
+ strategy = Security::Strategies::ApiKey.new(keys: keys, allow_query_params: allow_query_params)
319
320
  @auth_manager.add_strategy(:api_key, strategy)
320
321
  end
321
322
 
@@ -17,7 +17,7 @@ module VectorMCP
17
17
  # @api private
18
18
  class EventStore
19
19
  # Event data structure
20
- Event = Struct.new(:id, :data, :type, :timestamp) do
20
+ Event = Struct.new(:id, :data, :type, :timestamp, :session_id) do
21
21
  def to_sse_format
22
22
  lines = []
23
23
  lines << "id: #{id}"
@@ -44,12 +44,13 @@ module VectorMCP
44
44
  #
45
45
  # @param data [String] The event data
46
46
  # @param type [String] The event type (optional)
47
+ # @param session_id [String, nil] The session ID to scope this event to
47
48
  # @return [String] The generated event ID
48
- def store_event(data, type = nil)
49
+ def store_event(data, type = nil, session_id: nil)
49
50
  event_id = generate_event_id
50
51
  timestamp = Time.now
51
52
 
52
- event = Event.new(event_id, data, type, timestamp)
53
+ event = Event.new(event_id, data, type, timestamp, session_id)
53
54
 
54
55
  # Add to events array
55
56
  @events.push(event)
@@ -69,21 +70,26 @@ module VectorMCP
69
70
  event_id
70
71
  end
71
72
 
72
- # Retrieves events starting from a specific event ID.
73
+ # Retrieves events starting from a specific event ID, optionally filtered by session.
73
74
  #
74
75
  # @param last_event_id [String] The last event ID received by client
76
+ # @param session_id [String, nil] Filter events to this session only
75
77
  # @return [Array<Event>] Array of events after the specified ID
76
- def get_events_after(last_event_id)
77
- return @events.to_a if last_event_id.nil?
78
+ def get_events_after(last_event_id, session_id: nil)
79
+ events = if last_event_id.nil?
80
+ @events.to_a
81
+ else
82
+ last_index = @event_index[last_event_id]
83
+ return [] if last_index.nil?
78
84
 
79
- last_index = @event_index[last_event_id]
80
- return [] if last_index.nil?
85
+ start_index = last_index + 1
86
+ return [] if start_index >= @events.length
81
87
 
82
- # Return events after the last_event_id
83
- start_index = last_index + 1
84
- return [] if start_index >= @events.length
88
+ @events[start_index..]
89
+ end
85
90
 
86
- @events[start_index..]
91
+ events = events.select { |e| e.session_id == session_id } if session_id
92
+ events
87
93
  end
88
94
 
89
95
  # Gets the total number of stored events.
@@ -75,7 +75,9 @@ module VectorMCP
75
75
  session
76
76
  end
77
77
 
78
- # Override to add rack_env support
78
+ # Override to add rack_env support.
79
+ # Returns nil when a session_id is provided but not found (expired or unknown).
80
+ # Callers are responsible for returning 404 in that case.
79
81
  def get_or_create_session(session_id = nil, rack_env = nil)
80
82
  if session_id
81
83
  session = get_session(session_id)
@@ -88,8 +90,8 @@ module VectorMCP
88
90
  return session
89
91
  end
90
92
 
91
- # If session_id was provided but not found, create with that ID
92
- return create_session(session_id, rack_env)
93
+ # Session ID provided but not found signal 404 to caller
94
+ return nil
93
95
  end
94
96
 
95
97
  create_session(nil, rack_env)
@@ -68,7 +68,7 @@ module VectorMCP
68
68
  begin
69
69
  # Store event for resumability
70
70
  event_data = message.to_json
71
- event_id = @transport.event_store.store_event(event_data, "message")
71
+ event_id = @transport.event_store.store_event(event_data, "message", session_id: session.id)
72
72
 
73
73
  # Send via SSE
74
74
  sse_event = format_sse_event(event_data, "message", event_id)
@@ -172,23 +172,24 @@ module VectorMCP
172
172
  }
173
173
  }
174
174
 
175
- event_id = @transport.event_store.store_event(connection_event.to_json, "connection")
175
+ event_id = @transport.event_store.store_event(connection_event.to_json, "connection", session_id: session.id)
176
176
  yielder << format_sse_event(connection_event.to_json, "connection", event_id)
177
177
 
178
178
  # Replay missed events if resuming
179
- replay_events(yielder, last_event_id) if last_event_id
179
+ replay_events(yielder, last_event_id, session) if last_event_id
180
180
 
181
181
  # Send periodic keep-alive events
182
182
  keep_alive_loop(session, yielder)
183
183
  end
184
184
 
185
- # Replays events after a specific event ID.
185
+ # Replays events after a specific event ID, scoped to the session.
186
186
  #
187
187
  # @param yielder [Enumerator::Yielder] The SSE yielder
188
188
  # @param last_event_id [String] The last event ID received by client
189
+ # @param session [SessionManager::Session] The session to filter events for
189
190
  # @return [void]
190
- def replay_events(yielder, last_event_id)
191
- missed_events = @transport.event_store.get_events_after(last_event_id)
191
+ def replay_events(yielder, last_event_id, session)
192
+ missed_events = @transport.event_store.get_events_after(last_event_id, session_id: session.id)
192
193
 
193
194
  logger.info("Replaying #{missed_events.length} missed events from #{last_event_id}")
194
195
 
@@ -226,7 +227,7 @@ module VectorMCP
226
227
  }
227
228
 
228
229
  begin
229
- event_id = @transport.event_store.store_event(heartbeat_event.to_json, "heartbeat")
230
+ event_id = @transport.event_store.store_event(heartbeat_event.to_json, "heartbeat", session_id: session.id)
230
231
  yielder << format_sse_event(heartbeat_event.to_json, "heartbeat", event_id)
231
232
  rescue StandardError
232
233
  logger.debug("Heartbeat failed for #{session.id}, connection likely closed")
@@ -330,28 +330,122 @@ module VectorMCP
330
330
  # @param env [Hash] The Rack environment
331
331
  # @return [Array] Rack response triplet
332
332
  def handle_post_request(env)
333
- session_id = extract_session_id(env)
334
- session = @session_manager.get_or_create_session(session_id, env)
333
+ unless valid_post_accept?(env)
334
+ logger.warn { "POST request with unsupported Accept header: #{env["HTTP_ACCEPT"]}" }
335
+ return not_acceptable_response("Not Acceptable: POST requires Accept: application/json")
336
+ end
335
337
 
338
+ session_id = extract_session_id(env)
336
339
  request_body = read_request_body(env)
337
- message = parse_json_message(request_body)
340
+ parsed = parse_json_message(request_body)
341
+
342
+ session = resolve_session_for_post(session_id, parsed, env)
343
+ return session if session.is_a?(Array) # Rack error response
344
+
345
+ if parsed.is_a?(Array)
346
+ handle_batch_request(parsed, session)
347
+ else
348
+ handle_single_request(parsed, session, env)
349
+ end
350
+ rescue JSON::ParserError => e
351
+ json_error_response(nil, -32_700, "Parse error", { details: e.message })
352
+ end
338
353
 
339
- # Check if this is a response to a server-initiated request
354
+ # Handles a single JSON-RPC message from a POST request.
355
+ #
356
+ # @param message [Hash] Parsed JSON-RPC message
357
+ # @param session [Session] The resolved session
358
+ # @param env [Hash] The Rack environment
359
+ # @return [Array] Rack response triplet
360
+ def handle_single_request(message, session, env)
340
361
  if outgoing_response?(message)
341
362
  handle_outgoing_response(message)
342
- # For responses, return 202 Accepted with no body
343
363
  return [202, { "Mcp-Session-Id" => session.id }, []]
344
364
  end
345
365
 
346
366
  result = @server.handle_message(message, session.context, session.id)
367
+ build_rpc_response(env, result, message["id"], session.id)
368
+ rescue VectorMCP::ProtocolError => e
369
+ build_protocol_error_response(env, e, session_id: session.id)
370
+ end
347
371
 
348
- # Set session ID header in response
349
- headers = { "Mcp-Session-Id" => session.id }
350
- json_rpc_response(result, message["id"], headers)
372
+ # Handles a batch of JSON-RPC messages per JSON-RPC 2.0 spec.
373
+ #
374
+ # @param messages [Array] Array of parsed JSON-RPC messages
375
+ # @param session [Session] The resolved session
376
+ # @return [Array] Rack response triplet
377
+ def handle_batch_request(messages, session)
378
+ return json_error_response(nil, -32_600, "Invalid Request", { details: "Empty batch" }) if messages.empty?
379
+
380
+ responses = messages.filter_map do |message|
381
+ next batch_invalid_item_error unless message.is_a?(Hash)
382
+
383
+ process_batch_item(message, session)
384
+ end
385
+
386
+ return [204, { "Mcp-Session-Id" => session.id }, []] if responses.empty?
387
+
388
+ headers = { "Content-Type" => "application/json", "Mcp-Session-Id" => session.id }
389
+ [200, headers, [responses.to_json]]
390
+ end
391
+
392
+ # Processes a single item within a batch request.
393
+ #
394
+ # @param message [Hash] A single JSON-RPC message
395
+ # @param session [Session] The resolved session
396
+ # @return [Hash, nil] Response hash or nil for notifications/outgoing responses
397
+ def process_batch_item(message, session)
398
+ if outgoing_response?(message)
399
+ handle_outgoing_response(message)
400
+ return nil
401
+ end
402
+
403
+ result = @server.handle_message(message, session.context, session.id)
404
+ return nil if result.nil? && message["id"].nil?
405
+
406
+ { jsonrpc: "2.0", id: message["id"], result: result }
351
407
  rescue VectorMCP::ProtocolError => e
352
- json_error_response(e.request_id, e.code, e.message, e.details)
353
- rescue JSON::ParserError => e
354
- json_error_response(nil, -32_700, "Parse error", { details: e.message })
408
+ { jsonrpc: "2.0", id: e.request_id, error: { code: e.code, message: e.message, data: e.details } }
409
+ rescue StandardError => e
410
+ { jsonrpc: "2.0", id: message["id"],
411
+ error: { code: -32_603, message: "Internal error", data: { details: e.message } } }
412
+ end
413
+
414
+ # Returns an error object for non-Hash items in a batch.
415
+ #
416
+ # @return [Hash] JSON-RPC error object
417
+ def batch_invalid_item_error
418
+ { jsonrpc: "2.0", id: nil, error: { code: -32_600, message: "Invalid Request" } }
419
+ end
420
+
421
+ # Resolves or creates the session for a POST request following MCP spec rules:
422
+ # - session_id present and known → return existing session (updating request context)
423
+ # - session_id present but unknown/expired → 404 Not Found
424
+ # - no session_id + initialize request → create new session
425
+ # - no session_id + other request → 400 Bad Request
426
+ #
427
+ # @param session_id [String, nil] Client-supplied Mcp-Session-Id header value
428
+ # @param message [Hash, Array] Parsed JSON-RPC message or batch array
429
+ # @param env [Hash] Rack environment
430
+ # @return [Session, Array] Session object or Rack error response triplet
431
+ def resolve_session_for_post(session_id, message, env)
432
+ first_message = message.is_a?(Array) ? message.first : message
433
+ is_initialize = first_message.is_a?(Hash) && first_message["method"] == "initialize"
434
+
435
+ if session_id
436
+ session = @session_manager.get_session(session_id)
437
+ return not_found_response("Unknown or expired session") unless session
438
+
439
+ if env
440
+ request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
441
+ session.context.request_context = request_context
442
+ end
443
+ session
444
+ elsif is_initialize
445
+ @session_manager.create_session(nil, env)
446
+ else
447
+ bad_request_response("Missing Mcp-Session-Id header")
448
+ end
355
449
  end
356
450
 
357
451
  # Handles GET requests (SSE streaming)
@@ -359,6 +453,11 @@ module VectorMCP
359
453
  # @param env [Hash] The Rack environment
360
454
  # @return [Array] Rack response triplet
361
455
  def handle_get_request(env)
456
+ unless valid_get_accept?(env)
457
+ logger.warn { "GET request with unsupported Accept header: #{env["HTTP_ACCEPT"]}" }
458
+ return not_acceptable_response("Not Acceptable: GET requires Accept: text/event-stream")
459
+ end
460
+
362
461
  session_id = extract_session_id(env)
363
462
  return bad_request_response("Missing Mcp-Session-Id header") unless session_id
364
463
 
@@ -469,8 +568,73 @@ module VectorMCP
469
568
  [400, { "Content-Type" => "application/json" }, [response.to_json]]
470
569
  end
471
570
 
472
- def not_found_response
473
- [404, { "Content-Type" => "text/plain" }, ["Not Found"]]
571
+ def build_rpc_response(env, result, request_id, session_id)
572
+ headers = { "Mcp-Session-Id" => session_id }
573
+ if client_accepts_sse?(env)
574
+ sse_rpc_response(result, request_id, headers, session_id: session_id)
575
+ else
576
+ json_rpc_response(result, request_id, headers)
577
+ end
578
+ end
579
+
580
+ def build_protocol_error_response(env, error, session_id: nil)
581
+ if client_accepts_sse?(env)
582
+ sse_error_response(error.request_id, error.code, error.message, error.details, session_id: session_id)
583
+ else
584
+ json_error_response(error.request_id, error.code, error.message, error.details)
585
+ end
586
+ end
587
+
588
+ def client_accepts_sse?(env)
589
+ accept = env["HTTP_ACCEPT"] || ""
590
+ accept.include?("text/event-stream")
591
+ end
592
+
593
+ def format_sse_event(data, type, event_id)
594
+ lines = []
595
+ lines << "id: #{event_id}"
596
+ lines << "event: #{type}" if type
597
+ lines << "data: #{data}"
598
+ lines << ""
599
+ "#{lines.join("\n")}\n"
600
+ end
601
+
602
+ def sse_rpc_response(result, request_id, headers = {}, session_id: nil)
603
+ response = { jsonrpc: "2.0", id: request_id, result: result }
604
+ event_data = response.to_json
605
+
606
+ event_id = @event_store.store_event(event_data, "message", session_id: session_id)
607
+ sse_event = format_sse_event(event_data, "message", event_id)
608
+
609
+ response_headers = {
610
+ "Content-Type" => "text/event-stream",
611
+ "Cache-Control" => "no-cache",
612
+ "Connection" => "keep-alive",
613
+ "X-Accel-Buffering" => "no"
614
+ }.merge(headers)
615
+
616
+ [200, response_headers, [sse_event]]
617
+ end
618
+
619
+ def sse_error_response(id, code, err_message, data = nil, session_id: nil)
620
+ error_obj = { code: code, message: err_message }
621
+ error_obj[:data] = data if data
622
+ response = { jsonrpc: "2.0", id: id, error: error_obj }
623
+ event_data = response.to_json
624
+
625
+ event_id = @event_store.store_event(event_data, "message", session_id: session_id)
626
+ sse_event = format_sse_event(event_data, "message", event_id)
627
+
628
+ response_headers = {
629
+ "Content-Type" => "text/event-stream",
630
+ "Cache-Control" => "no-cache"
631
+ }
632
+
633
+ [200, response_headers, [sse_event]]
634
+ end
635
+
636
+ def not_found_response(message = "Not Found")
637
+ [404, { "Content-Type" => "text/plain" }, [message]]
474
638
  end
475
639
 
476
640
  def bad_request_response(message = "Bad Request")
@@ -486,6 +650,24 @@ module VectorMCP
486
650
  ["Method Not Allowed"]]
487
651
  end
488
652
 
653
+ def not_acceptable_response(message = "Not Acceptable")
654
+ [406, { "Content-Type" => "text/plain" }, [message]]
655
+ end
656
+
657
+ def valid_post_accept?(env)
658
+ accept = env["HTTP_ACCEPT"]
659
+ return true if accept.nil? || accept.strip.empty?
660
+
661
+ accept.include?("application/json") || accept.include?("*/*")
662
+ end
663
+
664
+ def valid_get_accept?(env)
665
+ accept = env["HTTP_ACCEPT"]
666
+ return true if accept.nil? || accept.strip.empty?
667
+
668
+ accept.include?("text/event-stream") || accept.include?("*/*")
669
+ end
670
+
489
671
  # Validates the Origin header for security
490
672
  #
491
673
  # @param env [Hash] The Rack environment
@@ -71,7 +71,7 @@ module VectorMCP
71
71
 
72
72
  begin
73
73
  @message_queue.push(message)
74
- logger.debug { "Message enqueued for client #{session_id}: #{message.inspect}" }
74
+ logger.debug { "Message enqueued for client #{session_id}: #{VectorMCP::LogFilter.filter_hash(message).inspect}" }
75
75
  true
76
76
  rescue ClosedQueueError
77
77
  logger.warn { "Attempted to enqueue message to closed queue for client #{session_id}" }
@@ -65,7 +65,7 @@ module VectorMCP
65
65
  sse_data = format_sse_event("message", json_message)
66
66
  yielder << sse_data
67
67
 
68
- logger.debug { "Streamed message to client #{client_conn.session_id}: #{json_message}" }
68
+ logger.debug { "Streamed message to client #{client_conn.session_id}: #{VectorMCP::LogFilter.filter_string(json_message)}" }
69
69
  rescue StandardError => e
70
70
  logger.error { "Error streaming message to client #{client_conn.session_id}: #{e.message}" }
71
71
  break
@@ -2,5 +2,5 @@
2
2
 
3
3
  module VectorMCP
4
4
  # The current version of the VectorMCP gem.
5
- VERSION = "0.3.3"
5
+ VERSION = "0.3.4"
6
6
  end
data/lib/vector_mcp.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "vector_mcp/errors"
8
8
  require_relative "vector_mcp/definitions"
9
9
  require_relative "vector_mcp/session"
10
10
  require_relative "vector_mcp/util"
11
+ require_relative "vector_mcp/log_filter"
11
12
  require_relative "vector_mcp/image_util"
12
13
  require_relative "vector_mcp/handlers/core"
13
14
  require_relative "vector_mcp/transport/stdio"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vector_mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
@@ -129,6 +129,7 @@ files:
129
129
  - lib/vector_mcp/errors.rb
130
130
  - lib/vector_mcp/handlers/core.rb
131
131
  - lib/vector_mcp/image_util.rb
132
+ - lib/vector_mcp/log_filter.rb
132
133
  - lib/vector_mcp/logger.rb
133
134
  - lib/vector_mcp/middleware.rb
134
135
  - lib/vector_mcp/middleware/base.rb