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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +147 -337
  4. data/lib/vector_mcp/definitions.rb +30 -0
  5. data/lib/vector_mcp/handlers/core.rb +78 -81
  6. data/lib/vector_mcp/image_util.rb +34 -11
  7. data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
  8. data/lib/vector_mcp/middleware/base.rb +1 -5
  9. data/lib/vector_mcp/middleware/context.rb +11 -1
  10. data/lib/vector_mcp/middleware/hook.rb +7 -24
  11. data/lib/vector_mcp/middleware.rb +26 -9
  12. data/lib/vector_mcp/rails/tool.rb +85 -0
  13. data/lib/vector_mcp/request_context.rb +1 -1
  14. data/lib/vector_mcp/security/auth_manager.rb +12 -13
  15. data/lib/vector_mcp/security/auth_result.rb +33 -0
  16. data/lib/vector_mcp/security/authorization.rb +5 -9
  17. data/lib/vector_mcp/security/middleware.rb +2 -2
  18. data/lib/vector_mcp/security/session_context.rb +11 -27
  19. data/lib/vector_mcp/security/strategies/api_key.rb +1 -5
  20. data/lib/vector_mcp/security/strategies/custom.rb +10 -37
  21. data/lib/vector_mcp/security/strategies/jwt_token.rb +1 -10
  22. data/lib/vector_mcp/server/capabilities.rb +22 -32
  23. data/lib/vector_mcp/server/message_handling.rb +21 -14
  24. data/lib/vector_mcp/server/registry.rb +102 -120
  25. data/lib/vector_mcp/server.rb +98 -57
  26. data/lib/vector_mcp/session.rb +5 -3
  27. data/lib/vector_mcp/token_store.rb +80 -0
  28. data/lib/vector_mcp/tool.rb +221 -0
  29. data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
  30. data/lib/vector_mcp/transport/http_stream/event_store.rb +29 -17
  31. data/lib/vector_mcp/transport/http_stream/session_manager.rb +41 -36
  32. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +132 -47
  33. data/lib/vector_mcp/transport/http_stream.rb +242 -124
  34. data/lib/vector_mcp/util/token_sweeper.rb +74 -0
  35. data/lib/vector_mcp/version.rb +1 -1
  36. data/lib/vector_mcp.rb +8 -8
  37. metadata +8 -10
  38. data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
  39. data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
  40. data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
  41. data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
  42. data/lib/vector_mcp/transport/sse.rb +0 -377
  43. data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
  44. data/lib/vector_mcp/transport/stdio.rb +0 -473
  45. 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 [Array<String>] :allowed_origins (["*"]) List of allowed origins for CORS. Use ["*"] to allow all origins.
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
- # Processing HTTP request
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
- # Validate origin for security (MCP specification requirement)
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
- if parsed.is_a?(Array)
346
- handle_batch_request(parsed, session)
347
- else
348
- handle_single_request(parsed, session, env)
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
- 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
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
- return nil if result.nil? && message["id"].nil?
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
- { 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" } }
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, Array] Parsed JSON-RPC message or batch array
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
- first_message = message.is_a?(Array) ? message.first : message
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
- event_id = @event_store.store_event(event_data, "message", session_id: session_id)
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
- [403, { "Content-Type" => "text/plain" }, [message]]
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
- accept.include?("application/json") || accept.include?("*/*")
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.include?(origin)
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 using pooled condition variables.
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
- @request_mutex.synchronize do
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 = nil
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 and returns condition variable to pool.
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 = nil
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, &block)
843
- transform_object_keys(obj, &block)
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
- @allowed_origins = options[:allowed_origins] || ["*"]
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
- @request_mutex.synchronize do
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.