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 +4 -4
- data/CHANGELOG.md +21 -0
- data/lib/vector_mcp/image_util.rb +27 -2
- data/lib/vector_mcp/log_filter.rb +48 -0
- data/lib/vector_mcp/security/strategies/api_key.rb +27 -4
- data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
- data/lib/vector_mcp/server/message_handling.rb +2 -2
- data/lib/vector_mcp/server.rb +4 -3
- data/lib/vector_mcp/transport/http_stream/event_store.rb +18 -12
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +5 -3
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +8 -7
- data/lib/vector_mcp/transport/http_stream.rb +195 -13
- data/lib/vector_mcp/transport/sse/client_connection.rb +1 -1
- data/lib/vector_mcp/transport/sse/stream_manager.rb +1 -1
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96c22c8497dcfd618605017a41a11d747e596654d86e856551159303133e0ab9
|
|
4
|
+
data.tar.gz: 301099d4a3b6b21c28ad82adf95c77f3469a0c4ba40dbd63f9f4f1060391b37f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
89
|
-
|
|
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
|
data/lib/vector_mcp/server.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
85
|
+
start_index = last_index + 1
|
|
86
|
+
return [] if start_index >= @events.length
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return [] if start_index >= @events.length
|
|
88
|
+
@events[start_index..]
|
|
89
|
+
end
|
|
85
90
|
|
|
86
|
-
|
|
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
|
-
#
|
|
92
|
-
return
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
rescue
|
|
354
|
-
|
|
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
|
|
473
|
-
|
|
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
|
data/lib/vector_mcp/version.rb
CHANGED
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.
|
|
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
|