vector_mcp 0.3.4 → 0.5.0
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 +82 -0
- data/README.md +147 -337
- data/lib/vector_mcp/definitions.rb +30 -0
- data/lib/vector_mcp/handlers/core.rb +78 -81
- data/lib/vector_mcp/image_util.rb +34 -11
- data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
- data/lib/vector_mcp/middleware/base.rb +1 -5
- data/lib/vector_mcp/middleware/context.rb +11 -1
- data/lib/vector_mcp/middleware/hook.rb +7 -24
- data/lib/vector_mcp/middleware.rb +26 -9
- data/lib/vector_mcp/rails/tool.rb +85 -0
- data/lib/vector_mcp/request_context.rb +1 -1
- data/lib/vector_mcp/security/auth_manager.rb +12 -13
- data/lib/vector_mcp/security/auth_result.rb +33 -0
- data/lib/vector_mcp/security/authorization.rb +5 -9
- data/lib/vector_mcp/security/middleware.rb +2 -2
- data/lib/vector_mcp/security/session_context.rb +11 -27
- data/lib/vector_mcp/security/strategies/api_key.rb +1 -5
- data/lib/vector_mcp/security/strategies/custom.rb +10 -37
- data/lib/vector_mcp/security/strategies/jwt_token.rb +1 -10
- data/lib/vector_mcp/server/capabilities.rb +22 -32
- data/lib/vector_mcp/server/message_handling.rb +21 -14
- data/lib/vector_mcp/server/registry.rb +102 -120
- data/lib/vector_mcp/server.rb +98 -57
- data/lib/vector_mcp/session.rb +5 -3
- data/lib/vector_mcp/token_store.rb +80 -0
- data/lib/vector_mcp/tool.rb +221 -0
- data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
- data/lib/vector_mcp/transport/http_stream/event_store.rb +29 -17
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +41 -36
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +132 -47
- data/lib/vector_mcp/transport/http_stream.rb +242 -124
- data/lib/vector_mcp/util/token_sweeper.rb +74 -0
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +8 -8
- metadata +8 -10
- data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
- data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
- data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
- data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
- data/lib/vector_mcp/transport/sse.rb +0 -377
- data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
- data/lib/vector_mcp/transport/stdio.rb +0 -473
- data/lib/vector_mcp/transport/stdio_session_manager.rb +0 -181
|
@@ -52,6 +52,18 @@ module VectorMCP
|
|
|
52
52
|
DEFAULT_SESSION_TIMEOUT = 300 # 5 minutes
|
|
53
53
|
DEFAULT_EVENT_RETENTION = 100 # Keep last 100 events for resumability
|
|
54
54
|
DEFAULT_REQUEST_TIMEOUT = 30 # Default timeout for server-initiated requests
|
|
55
|
+
DEFAULT_MIN_THREADS = 4
|
|
56
|
+
DEFAULT_MAX_THREADS = 32
|
|
57
|
+
|
|
58
|
+
# Default allowed origins — restrict to localhost by default for security.
|
|
59
|
+
DEFAULT_ALLOWED_ORIGINS = %w[
|
|
60
|
+
http://localhost
|
|
61
|
+
https://localhost
|
|
62
|
+
http://127.0.0.1
|
|
63
|
+
https://127.0.0.1
|
|
64
|
+
http://[::1]
|
|
65
|
+
https://[::1]
|
|
66
|
+
].freeze
|
|
55
67
|
|
|
56
68
|
# Initializes a new HTTP Stream transport.
|
|
57
69
|
#
|
|
@@ -62,7 +74,10 @@ module VectorMCP
|
|
|
62
74
|
# @option options [String] :path_prefix ("/mcp") The base path for HTTP endpoints
|
|
63
75
|
# @option options [Integer] :session_timeout (300) Session timeout in seconds
|
|
64
76
|
# @option options [Integer] :event_retention (100) Number of events to retain for resumability
|
|
65
|
-
# @option options [
|
|
77
|
+
# @option options [Integer] :min_threads (4) Minimum Puma thread pool size
|
|
78
|
+
# @option options [Integer] :max_threads (32) Maximum Puma thread pool size
|
|
79
|
+
# @option options [Array<String>] :allowed_origins Allowed origins for CORS validation.
|
|
80
|
+
# Defaults to localhost origins only. Pass ["*"] to allow all origins (NOT recommended for production).
|
|
66
81
|
def initialize(server, options = {})
|
|
67
82
|
@server = server
|
|
68
83
|
@logger = server.logger
|
|
@@ -95,13 +110,29 @@ module VectorMCP
|
|
|
95
110
|
start_time = Time.now
|
|
96
111
|
path = env["PATH_INFO"]
|
|
97
112
|
method = env["REQUEST_METHOD"]
|
|
113
|
+
transport_context = build_transport_context(env, method, path, start_time)
|
|
98
114
|
|
|
99
|
-
|
|
115
|
+
logger.debug { "Processing HTTP request #{method} #{path}" }
|
|
116
|
+
|
|
117
|
+
transport_context = execute_transport_hooks(:before_request, transport_context)
|
|
118
|
+
raise transport_context.error if transport_context.error?
|
|
119
|
+
return transport_context.result if transport_context.result
|
|
100
120
|
|
|
101
121
|
response = route_request(path, method, env)
|
|
122
|
+
transport_context.result = response
|
|
123
|
+
transport_context = execute_transport_hooks(:after_response, transport_context)
|
|
124
|
+
raise transport_context.error if transport_context.error?
|
|
125
|
+
|
|
126
|
+
response = transport_context.result || response
|
|
102
127
|
log_request_completion(method, path, start_time, response[0])
|
|
103
128
|
response
|
|
104
129
|
rescue StandardError => e
|
|
130
|
+
if transport_context
|
|
131
|
+
transport_context.error = e
|
|
132
|
+
transport_context = execute_transport_hooks(:on_transport_error, transport_context)
|
|
133
|
+
return transport_context.result if transport_context.result
|
|
134
|
+
end
|
|
135
|
+
|
|
105
136
|
handle_request_error(method, path, e)
|
|
106
137
|
end
|
|
107
138
|
|
|
@@ -133,16 +164,6 @@ module VectorMCP
|
|
|
133
164
|
@stream_handler.send_message_to_session(session, message)
|
|
134
165
|
end
|
|
135
166
|
|
|
136
|
-
# Broadcasts a notification to all active sessions.
|
|
137
|
-
#
|
|
138
|
-
# @param method [String] The notification method name
|
|
139
|
-
# @param params [Hash, Array, nil] The notification parameters
|
|
140
|
-
# @return [Integer] Number of sessions the notification was sent to
|
|
141
|
-
def broadcast_notification(method, params = nil)
|
|
142
|
-
message = build_notification(method, params)
|
|
143
|
-
@session_manager.broadcast_message(message)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
167
|
# Sends a server-initiated JSON-RPC request compatible with Session expectations.
|
|
147
168
|
# This method will block until a response is received or the timeout is reached.
|
|
148
169
|
# For HTTP transport, this requires finding an appropriate session with streaming connection.
|
|
@@ -247,7 +268,7 @@ module VectorMCP
|
|
|
247
268
|
#
|
|
248
269
|
# @return [void]
|
|
249
270
|
def start_puma_server
|
|
250
|
-
@puma_server = Puma::Server.new(self)
|
|
271
|
+
@puma_server = Puma::Server.new(self, nil, min_threads: @min_threads, max_threads: @max_threads)
|
|
251
272
|
@puma_server.add_tcp_listener(@host, @port)
|
|
252
273
|
|
|
253
274
|
@running = true
|
|
@@ -307,11 +328,28 @@ module VectorMCP
|
|
|
307
328
|
# @param env [Hash] The Rack environment
|
|
308
329
|
# @return [Array] Rack response triplet
|
|
309
330
|
def route_request(path, method, env)
|
|
331
|
+
return route_mounted_request(path, method, env) if @mounted
|
|
332
|
+
|
|
333
|
+
# Standalone mode: unchanged behavior
|
|
310
334
|
return handle_health_check if path == "/"
|
|
311
335
|
return not_found_response unless path == @path_prefix
|
|
312
336
|
|
|
313
|
-
|
|
337
|
+
validate_and_dispatch(method, env)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Routes requests when mounted inside another Rack app (e.g., Rails).
|
|
341
|
+
# PATH_INFO is relative to the mount point: "/" = MCP endpoint, "/health" = health check.
|
|
342
|
+
def route_mounted_request(path, method, env)
|
|
343
|
+
return handle_health_check if path == "/health"
|
|
344
|
+
return not_found_response unless ["", "/"].include?(path)
|
|
345
|
+
|
|
346
|
+
validate_and_dispatch(method, env)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Validates origin and dispatches to the appropriate handler by HTTP method.
|
|
350
|
+
def validate_and_dispatch(method, env)
|
|
314
351
|
return forbidden_response("Origin not allowed") unless valid_origin?(env)
|
|
352
|
+
return unauthorized_oauth_response(env) if oauth_gate_should_reject?(env)
|
|
315
353
|
|
|
316
354
|
case method
|
|
317
355
|
when "POST"
|
|
@@ -325,6 +363,73 @@ module VectorMCP
|
|
|
325
363
|
end
|
|
326
364
|
end
|
|
327
365
|
|
|
366
|
+
# True when OAuth 2.1 resource server mode is enabled and the incoming
|
|
367
|
+
# request has not successfully authenticated. Opt-in: only activates when the
|
|
368
|
+
# server was configured with a +resource_metadata_url+ via +enable_authentication!+.
|
|
369
|
+
#
|
|
370
|
+
# @param env [Hash] The Rack environment
|
|
371
|
+
# @return [Boolean]
|
|
372
|
+
def oauth_gate_should_reject?(env)
|
|
373
|
+
return false unless oauth_resource_server_enabled?
|
|
374
|
+
|
|
375
|
+
!authenticate_transport_request(env).authenticated?
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# @return [Boolean] true when the server is configured to act as an OAuth 2.1 resource server.
|
|
379
|
+
def oauth_resource_server_enabled?
|
|
380
|
+
return false unless @server.respond_to?(:oauth_resource_metadata_url)
|
|
381
|
+
return false if @server.oauth_resource_metadata_url.nil?
|
|
382
|
+
|
|
383
|
+
@server.auth_manager.required?
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Runs the configured authentication strategy against the Rack env and returns
|
|
387
|
+
# the resulting SessionContext. The request is normalized into the
|
|
388
|
+
# +{ headers:, params:, method:, path:, rack_env: }+ hash shape that the rest
|
|
389
|
+
# of the codebase's authentication pipeline uses (see
|
|
390
|
+
# +VectorMCP::Handlers::Core.extract_request_from_session+), so +:custom+
|
|
391
|
+
# strategy handlers see the same contract here as they do on the in-handler
|
|
392
|
+
# auth path. Errors in the strategy are logged and treated as unauthenticated
|
|
393
|
+
# rather than propagated, so a malformed token can never crash the request
|
|
394
|
+
# pipeline.
|
|
395
|
+
#
|
|
396
|
+
# @param env [Hash] The Rack environment
|
|
397
|
+
# @return [VectorMCP::Security::SessionContext]
|
|
398
|
+
def authenticate_transport_request(env)
|
|
399
|
+
normalized_request = @server.security_middleware.normalize_request(env)
|
|
400
|
+
@server.security_middleware.authenticate_request(normalized_request)
|
|
401
|
+
rescue StandardError => e
|
|
402
|
+
VectorMCP.logger_for("security").warn do
|
|
403
|
+
"OAuth transport auth strategy raised #{e.class}: #{e.message}"
|
|
404
|
+
end
|
|
405
|
+
VectorMCP::Security::SessionContext.anonymous
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Returns a 401 Rack response carrying a WWW-Authenticate header that points
|
|
409
|
+
# Claude Desktop (and other RFC 9728 clients) at the configured OAuth 2.1
|
|
410
|
+
# protected resource metadata document. The JSON-RPC error envelope in the
|
|
411
|
+
# body is for clients that parse bodies regardless of status code; the header
|
|
412
|
+
# and status are the parts that drive the discovery flow.
|
|
413
|
+
#
|
|
414
|
+
# @param env [Hash] The Rack environment
|
|
415
|
+
# @return [Array] Rack response triplet
|
|
416
|
+
def unauthorized_oauth_response(env)
|
|
417
|
+
VectorMCP.logger_for("security").info do
|
|
418
|
+
"OAuth 401 challenge issued for #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
header_value = %(Bearer realm="mcp", resource_metadata="#{@server.oauth_resource_metadata_url}")
|
|
422
|
+
body = {
|
|
423
|
+
jsonrpc: "2.0",
|
|
424
|
+
id: nil,
|
|
425
|
+
error: { code: -32_401, message: "Authentication required" }
|
|
426
|
+
}.to_json
|
|
427
|
+
|
|
428
|
+
[401,
|
|
429
|
+
{ "Content-Type" => "application/json", "WWW-Authenticate" => header_value },
|
|
430
|
+
[body]]
|
|
431
|
+
end
|
|
432
|
+
|
|
328
433
|
# Handles POST requests (client-to-server JSON-RPC)
|
|
329
434
|
#
|
|
330
435
|
# @param env [Hash] The Rack environment
|
|
@@ -339,14 +444,23 @@ module VectorMCP
|
|
|
339
444
|
request_body = read_request_body(env)
|
|
340
445
|
parsed = parse_json_message(request_body)
|
|
341
446
|
|
|
447
|
+
# MCP spec: POST body MUST be a single JSON-RPC message, not a batch array
|
|
448
|
+
if parsed.is_a?(Array)
|
|
449
|
+
return json_error_response(nil, -32_600, "Invalid Request",
|
|
450
|
+
{ details: "Batch requests are not supported. Send a single JSON-RPC message per POST." })
|
|
451
|
+
end
|
|
452
|
+
|
|
342
453
|
session = resolve_session_for_post(session_id, parsed, env)
|
|
343
454
|
return session if session.is_a?(Array) # Rack error response
|
|
344
455
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
456
|
+
# Validate MCP-Protocol-Version header (skip for initialize requests)
|
|
457
|
+
is_initialize = parsed.is_a?(Hash) && parsed["method"] == "initialize"
|
|
458
|
+
unless is_initialize
|
|
459
|
+
version_error = validate_protocol_version_header(env)
|
|
460
|
+
return version_error if version_error
|
|
349
461
|
end
|
|
462
|
+
|
|
463
|
+
handle_single_request(parsed, session, env)
|
|
350
464
|
rescue JSON::ParserError => e
|
|
351
465
|
json_error_response(nil, -32_700, "Parse error", { details: e.message })
|
|
352
466
|
end
|
|
@@ -363,59 +477,16 @@ module VectorMCP
|
|
|
363
477
|
return [202, { "Mcp-Session-Id" => session.id }, []]
|
|
364
478
|
end
|
|
365
479
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
end
|
|
371
|
-
|
|
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
|
|
480
|
+
# Notifications: has method, no id -> 202 Accepted with no body (MCP spec requirement)
|
|
481
|
+
if message["method"] && !message.key?("id")
|
|
482
|
+
@server.handle_message(message, session.context, session.id)
|
|
483
|
+
return [202, { "Mcp-Session-Id" => session.id }, []]
|
|
401
484
|
end
|
|
402
485
|
|
|
403
486
|
result = @server.handle_message(message, session.context, session.id)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
{ jsonrpc: "2.0", id: message["id"], result: result }
|
|
487
|
+
build_rpc_response(env, result, message["id"], session.id)
|
|
407
488
|
rescue VectorMCP::ProtocolError => e
|
|
408
|
-
|
|
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" } }
|
|
489
|
+
build_protocol_error_response(env, e, session_id: session.id)
|
|
419
490
|
end
|
|
420
491
|
|
|
421
492
|
# Resolves or creates the session for a POST request following MCP spec rules:
|
|
@@ -425,12 +496,11 @@ module VectorMCP
|
|
|
425
496
|
# - no session_id + other request → 400 Bad Request
|
|
426
497
|
#
|
|
427
498
|
# @param session_id [String, nil] Client-supplied Mcp-Session-Id header value
|
|
428
|
-
# @param message [Hash
|
|
499
|
+
# @param message [Hash] Parsed JSON-RPC message
|
|
429
500
|
# @param env [Hash] Rack environment
|
|
430
501
|
# @return [Session, Array] Session object or Rack error response triplet
|
|
431
502
|
def resolve_session_for_post(session_id, message, env)
|
|
432
|
-
|
|
433
|
-
is_initialize = first_message.is_a?(Hash) && first_message["method"] == "initialize"
|
|
503
|
+
is_initialize = message.is_a?(Hash) && message["method"] == "initialize"
|
|
434
504
|
|
|
435
505
|
if session_id
|
|
436
506
|
session = @session_manager.get_session(session_id)
|
|
@@ -464,6 +534,9 @@ module VectorMCP
|
|
|
464
534
|
session = @session_manager.get_or_create_session(session_id, env)
|
|
465
535
|
return not_found_response unless session
|
|
466
536
|
|
|
537
|
+
version_error = validate_protocol_version_header(env)
|
|
538
|
+
return version_error if version_error
|
|
539
|
+
|
|
467
540
|
@stream_handler.handle_streaming_request(env, session)
|
|
468
541
|
end
|
|
469
542
|
|
|
@@ -475,6 +548,9 @@ module VectorMCP
|
|
|
475
548
|
session_id = extract_session_id(env)
|
|
476
549
|
return bad_request_response("Missing Mcp-Session-Id header") unless session_id
|
|
477
550
|
|
|
551
|
+
version_error = validate_protocol_version_header(env)
|
|
552
|
+
return version_error if version_error
|
|
553
|
+
|
|
478
554
|
success = @session_manager.terminate_session(session_id)
|
|
479
555
|
if success
|
|
480
556
|
[204, {}, []]
|
|
@@ -590,10 +666,11 @@ module VectorMCP
|
|
|
590
666
|
accept.include?("text/event-stream")
|
|
591
667
|
end
|
|
592
668
|
|
|
593
|
-
def format_sse_event(data, type, event_id)
|
|
669
|
+
def format_sse_event(data, type, event_id, retry_ms: nil)
|
|
594
670
|
lines = []
|
|
595
|
-
lines << "id: #{event_id}"
|
|
671
|
+
lines << "id: #{event_id}" if event_id
|
|
596
672
|
lines << "event: #{type}" if type
|
|
673
|
+
lines << "retry: #{retry_ms}" if retry_ms
|
|
597
674
|
lines << "data: #{data}"
|
|
598
675
|
lines << ""
|
|
599
676
|
"#{lines.join("\n")}\n"
|
|
@@ -602,8 +679,14 @@ module VectorMCP
|
|
|
602
679
|
def sse_rpc_response(result, request_id, headers = {}, session_id: nil)
|
|
603
680
|
response = { jsonrpc: "2.0", id: request_id, result: result }
|
|
604
681
|
event_data = response.to_json
|
|
682
|
+
stream_id = generate_sse_stream_id(session_id, :post)
|
|
683
|
+
|
|
684
|
+
# Priming event per MCP spec: event ID + empty data field
|
|
685
|
+
prime_event_id = @event_store.store_event("", nil, session_id: session_id, stream_id: stream_id)
|
|
686
|
+
prime_event = "id: #{prime_event_id}\ndata:\n\n"
|
|
605
687
|
|
|
606
|
-
|
|
688
|
+
# Actual response event
|
|
689
|
+
event_id = @event_store.store_event(event_data, "message", session_id: session_id, stream_id: stream_id)
|
|
607
690
|
sse_event = format_sse_event(event_data, "message", event_id)
|
|
608
691
|
|
|
609
692
|
response_headers = {
|
|
@@ -613,7 +696,7 @@ module VectorMCP
|
|
|
613
696
|
"X-Accel-Buffering" => "no"
|
|
614
697
|
}.merge(headers)
|
|
615
698
|
|
|
616
|
-
[200, response_headers, [sse_event]]
|
|
699
|
+
[200, response_headers, [prime_event, sse_event]]
|
|
617
700
|
end
|
|
618
701
|
|
|
619
702
|
def sse_error_response(id, code, err_message, data = nil, session_id: nil)
|
|
@@ -621,8 +704,13 @@ module VectorMCP
|
|
|
621
704
|
error_obj[:data] = data if data
|
|
622
705
|
response = { jsonrpc: "2.0", id: id, error: error_obj }
|
|
623
706
|
event_data = response.to_json
|
|
707
|
+
stream_id = generate_sse_stream_id(session_id, :post)
|
|
708
|
+
|
|
709
|
+
# Priming event per MCP spec
|
|
710
|
+
prime_event_id = @event_store.store_event("", nil, session_id: session_id, stream_id: stream_id)
|
|
711
|
+
prime_event = "id: #{prime_event_id}\ndata:\n\n"
|
|
624
712
|
|
|
625
|
-
event_id = @event_store.store_event(event_data, "message", session_id: session_id)
|
|
713
|
+
event_id = @event_store.store_event(event_data, "message", session_id: session_id, stream_id: stream_id)
|
|
626
714
|
sse_event = format_sse_event(event_data, "message", event_id)
|
|
627
715
|
|
|
628
716
|
response_headers = {
|
|
@@ -630,7 +718,7 @@ module VectorMCP
|
|
|
630
718
|
"Cache-Control" => "no-cache"
|
|
631
719
|
}
|
|
632
720
|
|
|
633
|
-
[200, response_headers, [sse_event]]
|
|
721
|
+
[200, response_headers, [prime_event, sse_event]]
|
|
634
722
|
end
|
|
635
723
|
|
|
636
724
|
def not_found_response(message = "Not Found")
|
|
@@ -642,7 +730,8 @@ module VectorMCP
|
|
|
642
730
|
end
|
|
643
731
|
|
|
644
732
|
def forbidden_response(message = "Forbidden")
|
|
645
|
-
|
|
733
|
+
error = { jsonrpc: "2.0", error: { code: -32_600, message: message } }
|
|
734
|
+
[403, { "Content-Type" => "application/json" }, [error.to_json]]
|
|
646
735
|
end
|
|
647
736
|
|
|
648
737
|
def method_not_allowed_response(allowed_methods)
|
|
@@ -654,11 +743,26 @@ module VectorMCP
|
|
|
654
743
|
[406, { "Content-Type" => "text/plain" }, [message]]
|
|
655
744
|
end
|
|
656
745
|
|
|
746
|
+
# Validates the MCP-Protocol-Version header per spec.
|
|
747
|
+
# Returns nil if valid, or a 400 Rack response if unsupported.
|
|
748
|
+
def validate_protocol_version_header(env)
|
|
749
|
+
version = env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
750
|
+
return nil if version.nil? # Backwards compatibility: assume 2025-03-26
|
|
751
|
+
|
|
752
|
+
unless VectorMCP::Server::SUPPORTED_PROTOCOL_VERSIONS.include?(version)
|
|
753
|
+
return bad_request_response("Unsupported MCP-Protocol-Version: #{version}")
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
nil
|
|
757
|
+
end
|
|
758
|
+
|
|
657
759
|
def valid_post_accept?(env)
|
|
658
760
|
accept = env["HTTP_ACCEPT"]
|
|
659
761
|
return true if accept.nil? || accept.strip.empty?
|
|
762
|
+
return true if accept.include?("*/*")
|
|
660
763
|
|
|
661
|
-
|
|
764
|
+
# MCP spec: client MUST include both application/json AND text/event-stream
|
|
765
|
+
accept.include?("application/json") && accept.include?("text/event-stream")
|
|
662
766
|
end
|
|
663
767
|
|
|
664
768
|
def valid_get_accept?(env)
|
|
@@ -668,7 +772,13 @@ module VectorMCP
|
|
|
668
772
|
accept.include?("text/event-stream") || accept.include?("*/*")
|
|
669
773
|
end
|
|
670
774
|
|
|
671
|
-
# Validates the Origin header for security
|
|
775
|
+
# Validates the Origin header for security.
|
|
776
|
+
#
|
|
777
|
+
# Matches are checked both exactly and as prefix (so that
|
|
778
|
+
# +http://localhost+ in the allowed list matches +http://localhost:3000+).
|
|
779
|
+
#
|
|
780
|
+
# Requests without an Origin header are allowed through because they
|
|
781
|
+
# originate from non-browser contexts (curl, server-to-server, etc.).
|
|
672
782
|
#
|
|
673
783
|
# @param env [Hash] The Rack environment
|
|
674
784
|
# @return [Boolean] True if origin is allowed, false otherwise
|
|
@@ -678,7 +788,9 @@ module VectorMCP
|
|
|
678
788
|
origin = env["HTTP_ORIGIN"]
|
|
679
789
|
return true if origin.nil? # Allow requests without Origin header (e.g., server-to-server)
|
|
680
790
|
|
|
681
|
-
@allowed_origins.
|
|
791
|
+
@allowed_origins.any? do |allowed|
|
|
792
|
+
origin == allowed || origin.start_with?("#{allowed}:")
|
|
793
|
+
end
|
|
682
794
|
end
|
|
683
795
|
|
|
684
796
|
# Logging and error handling
|
|
@@ -692,6 +804,29 @@ module VectorMCP
|
|
|
692
804
|
[500, { "Content-Type" => "text/plain" }, ["Internal Server Error"]]
|
|
693
805
|
end
|
|
694
806
|
|
|
807
|
+
def build_transport_context(env, method, path, start_time)
|
|
808
|
+
request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
|
|
809
|
+
|
|
810
|
+
VectorMCP::Middleware::Context.new(
|
|
811
|
+
operation_type: :transport,
|
|
812
|
+
operation_name: "#{method} #{path}",
|
|
813
|
+
params: request_context.to_h,
|
|
814
|
+
session: nil,
|
|
815
|
+
server: @server,
|
|
816
|
+
metadata: {
|
|
817
|
+
start_time: start_time,
|
|
818
|
+
path: path,
|
|
819
|
+
method: method
|
|
820
|
+
}
|
|
821
|
+
)
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def execute_transport_hooks(hook_type, context)
|
|
825
|
+
return context unless @server.respond_to?(:middleware_manager) && @server.middleware_manager
|
|
826
|
+
|
|
827
|
+
@server.middleware_manager.execute_hooks(hook_type, context)
|
|
828
|
+
end
|
|
829
|
+
|
|
695
830
|
def handle_fatal_error(error)
|
|
696
831
|
logger.fatal { "Fatal error in HttpStream transport: #{error.message}" }
|
|
697
832
|
exit(1)
|
|
@@ -699,15 +834,12 @@ module VectorMCP
|
|
|
699
834
|
|
|
700
835
|
# Request tracking helpers for server-initiated requests
|
|
701
836
|
|
|
702
|
-
# Sets up tracking for an outgoing request
|
|
837
|
+
# Sets up tracking for an outgoing request.
|
|
703
838
|
#
|
|
704
839
|
# @param request_id [String] The request ID to track
|
|
705
840
|
# @return [void]
|
|
706
841
|
def setup_request_tracking(request_id)
|
|
707
|
-
@
|
|
708
|
-
# Create IVar for thread-safe request tracking (no race conditions)
|
|
709
|
-
@outgoing_request_ivars[request_id] = Concurrent::IVar.new
|
|
710
|
-
end
|
|
842
|
+
@outgoing_request_ivars[request_id] = Concurrent::IVar.new
|
|
711
843
|
end
|
|
712
844
|
|
|
713
845
|
# Waits for a response to an outgoing request.
|
|
@@ -718,11 +850,7 @@ module VectorMCP
|
|
|
718
850
|
# @return [Hash] The response data
|
|
719
851
|
# @raise [VectorMCP::SamplingTimeoutError] if timeout occurs
|
|
720
852
|
def wait_for_response(request_id, method, timeout)
|
|
721
|
-
ivar =
|
|
722
|
-
@request_mutex.synchronize do
|
|
723
|
-
ivar = @outgoing_request_ivars[request_id]
|
|
724
|
-
end
|
|
725
|
-
|
|
853
|
+
ivar = @outgoing_request_ivars[request_id]
|
|
726
854
|
return nil unless ivar
|
|
727
855
|
|
|
728
856
|
begin
|
|
@@ -764,24 +892,11 @@ module VectorMCP
|
|
|
764
892
|
response[:result]
|
|
765
893
|
end
|
|
766
894
|
|
|
767
|
-
# Cleans up tracking for a request
|
|
895
|
+
# Cleans up tracking for a request.
|
|
768
896
|
#
|
|
769
897
|
# @param request_id [String] The request ID to clean up
|
|
770
898
|
# @return [void]
|
|
771
899
|
def cleanup_request_tracking(request_id)
|
|
772
|
-
@request_mutex.synchronize do
|
|
773
|
-
cleanup_request_tracking_unsafe(request_id)
|
|
774
|
-
end
|
|
775
|
-
end
|
|
776
|
-
|
|
777
|
-
# Internal cleanup method that assumes mutex is already held.
|
|
778
|
-
# This prevents recursive locking when called from within synchronized blocks.
|
|
779
|
-
#
|
|
780
|
-
# @param request_id [String] The request ID to clean up
|
|
781
|
-
# @return [void]
|
|
782
|
-
# @api private
|
|
783
|
-
def cleanup_request_tracking_unsafe(request_id)
|
|
784
|
-
# Remove IVar for this request (no condition variable cleanup needed)
|
|
785
900
|
@outgoing_request_ivars.delete(request_id)
|
|
786
901
|
end
|
|
787
902
|
|
|
@@ -810,10 +925,7 @@ module VectorMCP
|
|
|
810
925
|
def handle_outgoing_response(message)
|
|
811
926
|
request_id = message["id"]
|
|
812
927
|
|
|
813
|
-
ivar =
|
|
814
|
-
@request_mutex.synchronize do
|
|
815
|
-
ivar = @outgoing_request_ivars[request_id]
|
|
816
|
-
end
|
|
928
|
+
ivar = @outgoing_request_ivars[request_id]
|
|
817
929
|
|
|
818
930
|
unless ivar
|
|
819
931
|
logger.debug { "Received response for request ID #{request_id} but no thread is waiting (likely timed out)" }
|
|
@@ -822,11 +934,6 @@ module VectorMCP
|
|
|
822
934
|
|
|
823
935
|
# Convert keys to symbols for consistency and put response in IVar
|
|
824
936
|
response_data = deep_transform_keys(message, &:to_sym)
|
|
825
|
-
|
|
826
|
-
# Store in both places for compatibility with tests
|
|
827
|
-
@outgoing_request_responses[request_id] = response_data
|
|
828
|
-
|
|
829
|
-
# IVar handles thread-safe response delivery - no race conditions possible
|
|
830
937
|
if ivar.try_set(response_data)
|
|
831
938
|
logger.debug { "Response delivered to waiting thread for request ID #{request_id}" }
|
|
832
939
|
else
|
|
@@ -839,8 +946,8 @@ module VectorMCP
|
|
|
839
946
|
#
|
|
840
947
|
# @param obj [Object] The object to transform (Hash, Array, or other)
|
|
841
948
|
# @return [Object] The transformed object
|
|
842
|
-
def deep_transform_keys(obj, &
|
|
843
|
-
transform_object_keys(obj, &
|
|
949
|
+
def deep_transform_keys(obj, &)
|
|
950
|
+
transform_object_keys(obj, &)
|
|
844
951
|
end
|
|
845
952
|
|
|
846
953
|
# Core transformation logic extracted for better maintainability
|
|
@@ -874,7 +981,20 @@ module VectorMCP
|
|
|
874
981
|
@path_prefix = normalize_path_prefix(options[:path_prefix] || DEFAULT_PATH_PREFIX)
|
|
875
982
|
@session_timeout = options[:session_timeout] || DEFAULT_SESSION_TIMEOUT
|
|
876
983
|
@event_retention = options[:event_retention] || DEFAULT_EVENT_RETENTION
|
|
877
|
-
@
|
|
984
|
+
@min_threads = options[:min_threads] || DEFAULT_MIN_THREADS
|
|
985
|
+
@max_threads = options[:max_threads] || DEFAULT_MAX_THREADS
|
|
986
|
+
@allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
|
|
987
|
+
@mounted = options.fetch(:mounted, false)
|
|
988
|
+
|
|
989
|
+
warn_on_permissive_origins if @allowed_origins.include?("*")
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# Logs a security warning when wildcard origin is configured.
|
|
993
|
+
def warn_on_permissive_origins
|
|
994
|
+
logger.warn do
|
|
995
|
+
"[SECURITY] allowed_origins includes '*', which permits cross-origin requests from any website. " \
|
|
996
|
+
"This is not recommended for production. Specify explicit origins instead."
|
|
997
|
+
end
|
|
878
998
|
end
|
|
879
999
|
|
|
880
1000
|
# Initialize core HTTP stream components
|
|
@@ -886,11 +1006,7 @@ module VectorMCP
|
|
|
886
1006
|
|
|
887
1007
|
# Initialize request tracking system and ID generation for server-initiated requests
|
|
888
1008
|
def initialize_request_tracking
|
|
889
|
-
# Use IVars for thread-safe request/response handling (eliminates condition variable races)
|
|
890
1009
|
@outgoing_request_ivars = Concurrent::Hash.new
|
|
891
|
-
# Keep compatibility with tests that expect @outgoing_request_responses
|
|
892
|
-
@outgoing_request_responses = Concurrent::Hash.new
|
|
893
|
-
@request_mutex = Mutex.new
|
|
894
1010
|
initialize_request_id_generation
|
|
895
1011
|
end
|
|
896
1012
|
|
|
@@ -901,6 +1017,11 @@ module VectorMCP
|
|
|
901
1017
|
@request_id_counter = Concurrent::AtomicFixnum.new(0)
|
|
902
1018
|
end
|
|
903
1019
|
|
|
1020
|
+
def generate_sse_stream_id(session_id, origin)
|
|
1021
|
+
session_label = session_id || "anonymous"
|
|
1022
|
+
"#{session_label}-#{origin}-#{SecureRandom.hex(4)}"
|
|
1023
|
+
end
|
|
1024
|
+
|
|
904
1025
|
# Generate a unique, thread-safe request ID for server-initiated requests
|
|
905
1026
|
#
|
|
906
1027
|
# @return [String] A unique request ID in format: vecmcp_http_{pid}_{random}_{counter}
|
|
@@ -929,10 +1050,7 @@ module VectorMCP
|
|
|
929
1050
|
|
|
930
1051
|
logger.debug { "Cleaning up #{@outgoing_request_ivars.size} pending requests" }
|
|
931
1052
|
|
|
932
|
-
@
|
|
933
|
-
# IVars will timeout naturally, just clear the tracking
|
|
934
|
-
@outgoing_request_ivars.clear
|
|
935
|
-
end
|
|
1053
|
+
@outgoing_request_ivars.clear
|
|
936
1054
|
end
|
|
937
1055
|
|
|
938
1056
|
# Finds the first session with an active streaming connection.
|