vector_mcp 0.3.1 → 0.3.3

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/lib/vector_mcp/definitions.rb +25 -9
  4. data/lib/vector_mcp/errors.rb +2 -3
  5. data/lib/vector_mcp/handlers/core.rb +206 -50
  6. data/lib/vector_mcp/logger.rb +148 -0
  7. data/lib/vector_mcp/middleware/base.rb +171 -0
  8. data/lib/vector_mcp/middleware/context.rb +76 -0
  9. data/lib/vector_mcp/middleware/hook.rb +169 -0
  10. data/lib/vector_mcp/middleware/manager.rb +179 -0
  11. data/lib/vector_mcp/middleware.rb +43 -0
  12. data/lib/vector_mcp/request_context.rb +182 -0
  13. data/lib/vector_mcp/sampling/result.rb +11 -1
  14. data/lib/vector_mcp/security/middleware.rb +2 -28
  15. data/lib/vector_mcp/security/strategies/api_key.rb +2 -24
  16. data/lib/vector_mcp/security/strategies/jwt_token.rb +6 -3
  17. data/lib/vector_mcp/server/capabilities.rb +5 -7
  18. data/lib/vector_mcp/server/message_handling.rb +11 -5
  19. data/lib/vector_mcp/server.rb +74 -20
  20. data/lib/vector_mcp/session.rb +131 -8
  21. data/lib/vector_mcp/transport/base_session_manager.rb +320 -0
  22. data/lib/vector_mcp/transport/http_stream/event_store.rb +151 -0
  23. data/lib/vector_mcp/transport/http_stream/session_manager.rb +189 -0
  24. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +269 -0
  25. data/lib/vector_mcp/transport/http_stream.rb +779 -0
  26. data/lib/vector_mcp/transport/sse.rb +74 -19
  27. data/lib/vector_mcp/transport/sse_session_manager.rb +188 -0
  28. data/lib/vector_mcp/transport/stdio.rb +70 -13
  29. data/lib/vector_mcp/transport/stdio_session_manager.rb +181 -0
  30. data/lib/vector_mcp/util.rb +39 -1
  31. data/lib/vector_mcp/version.rb +1 -1
  32. data/lib/vector_mcp.rb +10 -35
  33. metadata +25 -24
  34. data/lib/vector_mcp/logging/component.rb +0 -131
  35. data/lib/vector_mcp/logging/configuration.rb +0 -156
  36. data/lib/vector_mcp/logging/constants.rb +0 -21
  37. data/lib/vector_mcp/logging/core.rb +0 -175
  38. data/lib/vector_mcp/logging/filters/component.rb +0 -69
  39. data/lib/vector_mcp/logging/filters/level.rb +0 -23
  40. data/lib/vector_mcp/logging/formatters/base.rb +0 -52
  41. data/lib/vector_mcp/logging/formatters/json.rb +0 -83
  42. data/lib/vector_mcp/logging/formatters/text.rb +0 -72
  43. data/lib/vector_mcp/logging/outputs/base.rb +0 -64
  44. data/lib/vector_mcp/logging/outputs/console.rb +0 -35
  45. data/lib/vector_mcp/logging/outputs/file.rb +0 -157
  46. data/lib/vector_mcp/logging.rb +0 -71
@@ -0,0 +1,779 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "puma"
6
+ require "rack"
7
+ require "concurrent-ruby"
8
+ require "timeout"
9
+
10
+ require_relative "../errors"
11
+ require_relative "../util"
12
+ require_relative "../session"
13
+ require_relative "http_stream/session_manager"
14
+ require_relative "http_stream/event_store"
15
+ require_relative "http_stream/stream_handler"
16
+
17
+ module VectorMCP
18
+ module Transport
19
+ # Implements the Model Context Protocol transport over HTTP with streaming support
20
+ # according to the MCP specification for Streamable HTTP transport.
21
+ #
22
+ # This transport supports:
23
+ # - Client-to-server communication via HTTP POST
24
+ # - Optional server-to-client streaming via Server-Sent Events (SSE)
25
+ # - Session management with Mcp-Session-Id headers
26
+ # - Resumable connections with event IDs and Last-Event-ID support
27
+ # - Bidirectional communication patterns
28
+ #
29
+ # Endpoints:
30
+ # - POST /mcp - Client sends JSON-RPC requests
31
+ # - GET /mcp - Optional SSE streaming for server-initiated messages
32
+ # - DELETE /mcp - Session termination
33
+ #
34
+ # @example Basic Usage
35
+ # server = VectorMCP::Server.new("http-stream-server")
36
+ # transport = VectorMCP::Transport::HttpStream.new(server, port: 8080)
37
+ # server.run(transport: transport)
38
+ #
39
+ # @attr_reader logger [Logger] The logger instance, shared with the server
40
+ # @attr_reader server [VectorMCP::Server] The server instance this transport is bound to
41
+ # @attr_reader host [String] The hostname or IP address the server will bind to
42
+ # @attr_reader port [Integer] The port number the server will listen on
43
+ # @attr_reader path_prefix [String] The base URL path for MCP endpoints
44
+ # rubocop:disable Metrics/ClassLength
45
+ class HttpStream
46
+ attr_reader :logger, :server, :host, :port, :path_prefix
47
+
48
+ # Default configuration values
49
+ DEFAULT_HOST = "localhost"
50
+ DEFAULT_PORT = 8000
51
+ DEFAULT_PATH_PREFIX = "/mcp"
52
+ DEFAULT_SESSION_TIMEOUT = 300 # 5 minutes
53
+ DEFAULT_EVENT_RETENTION = 100 # Keep last 100 events for resumability
54
+ DEFAULT_REQUEST_TIMEOUT = 30 # Default timeout for server-initiated requests
55
+
56
+ # Initializes a new HTTP Stream transport.
57
+ #
58
+ # @param server [VectorMCP::Server] The server instance that will handle messages
59
+ # @param options [Hash] Configuration options for the transport
60
+ # @option options [String] :host ("localhost") The hostname or IP to bind to
61
+ # @option options [Integer] :port (8000) The port to listen on
62
+ # @option options [String] :path_prefix ("/mcp") The base path for HTTP endpoints
63
+ # @option options [Integer] :session_timeout (300) Session timeout in seconds
64
+ # @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.
66
+ def initialize(server, options = {})
67
+ @server = server
68
+ @logger = server.logger
69
+ initialize_configuration(options)
70
+ initialize_components
71
+ initialize_request_tracking
72
+ initialize_object_pools
73
+ initialize_server_state
74
+
75
+ logger.info { "HttpStream transport initialized: #{@host}:#{@port}#{@path_prefix}" }
76
+ end
77
+
78
+ # Starts the HTTP Stream transport.
79
+ # This method will block until the server is stopped.
80
+ #
81
+ # @return [void]
82
+ # @raise [StandardError] if there's a fatal error during server startup
83
+ def run
84
+ start_puma_server
85
+ rescue StandardError => e
86
+ handle_fatal_error(e)
87
+ end
88
+
89
+ # Handles incoming HTTP requests (Rack interface).
90
+ # Routes requests to appropriate handlers based on path and method.
91
+ #
92
+ # @param env [Hash] The Rack environment hash
93
+ # @return [Array(Integer, Hash, Object)] Standard Rack response triplet
94
+ def call(env)
95
+ start_time = Time.now
96
+ path = env["PATH_INFO"]
97
+ method = env["REQUEST_METHOD"]
98
+
99
+ # Processing HTTP request
100
+
101
+ response = route_request(path, method, env)
102
+ log_request_completion(method, path, start_time, response[0])
103
+ response
104
+ rescue StandardError => e
105
+ handle_request_error(method, path, e)
106
+ end
107
+
108
+ # Sends a notification to the first available session.
109
+ #
110
+ # @param method [String] The notification method name
111
+ # @param params [Hash, Array, nil] The notification parameters
112
+ # @return [Boolean] True if notification was sent successfully
113
+ def send_notification(method, params = nil)
114
+ # Find the first available session
115
+ first_session = find_first_session
116
+ return false unless first_session
117
+
118
+ message = build_notification(method, params)
119
+ @stream_handler.send_message_to_session(first_session, message)
120
+ end
121
+
122
+ # Sends a notification to a specific session.
123
+ #
124
+ # @param session_id [String] The target session ID
125
+ # @param method [String] The notification method name
126
+ # @param params [Hash, Array, nil] The notification parameters
127
+ # @return [Boolean] True if notification was sent successfully
128
+ def send_notification_to_session(session_id, method, params = nil)
129
+ session = @session_manager.get_session(session_id)
130
+ return false unless session
131
+
132
+ message = build_notification(method, params)
133
+ @stream_handler.send_message_to_session(session, message)
134
+ end
135
+
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
+ # Sends a server-initiated JSON-RPC request compatible with Session expectations.
147
+ # This method will block until a response is received or the timeout is reached.
148
+ # For HTTP transport, this requires finding an appropriate session with streaming connection.
149
+ #
150
+ # @param method [String] The request method name
151
+ # @param params [Hash, Array, nil] The request parameters
152
+ # @param timeout [Numeric] How long to wait for a response, in seconds
153
+ # @return [Object] The result part of the client's response
154
+ # @raise [VectorMCP::SamplingError, VectorMCP::SamplingTimeoutError] if the client returns an error or times out
155
+ # @raise [ArgumentError] if method is blank or no streaming session found
156
+ def send_request(method, params = nil, timeout: DEFAULT_REQUEST_TIMEOUT)
157
+ raise ArgumentError, "Method cannot be blank" if method.to_s.strip.empty?
158
+
159
+ # Find the first session with streaming connection
160
+ # In HTTP transport, we need an active streaming connection to send server-initiated requests
161
+ streaming_session = find_streaming_session
162
+ raise ArgumentError, "No streaming session available for server-initiated requests" unless streaming_session
163
+
164
+ send_request_to_session(streaming_session.id, method, params, timeout: timeout)
165
+ end
166
+
167
+ # Sends a server-initiated JSON-RPC request to a specific session and waits for a response.
168
+ # This method will block until a response is received or the timeout is reached.
169
+ #
170
+ # @param session_id [String] The target session ID
171
+ # @param method [String] The request method name
172
+ # @param params [Hash, Array, nil] The request parameters
173
+ # @param timeout [Numeric] How long to wait for a response, in seconds
174
+ # @return [Object] The result part of the client's response
175
+ # @raise [VectorMCP::SamplingError, VectorMCP::SamplingTimeoutError] if the client returns an error or times out
176
+ # @raise [ArgumentError] if method is blank or session not found
177
+ def send_request_to_session(session_id, method, params = nil, timeout: DEFAULT_REQUEST_TIMEOUT)
178
+ raise ArgumentError, "Method cannot be blank" if method.to_s.strip.empty?
179
+ raise ArgumentError, "Session ID cannot be blank" if session_id.to_s.strip.empty?
180
+
181
+ session = @session_manager.get_session(session_id)
182
+ raise ArgumentError, "Session not found: #{session_id}" unless session
183
+
184
+ raise ArgumentError, "Session must have streaming connection for server-initiated requests" unless session.streaming?
185
+
186
+ request_id = generate_request_id
187
+ request_payload = { jsonrpc: "2.0", id: request_id, method: method }
188
+ request_payload[:params] = params if params
189
+
190
+ setup_request_tracking(request_id)
191
+ # Sending request to session
192
+
193
+ # Send request via existing streaming connection
194
+ unless @stream_handler.send_message_to_session(session, request_payload)
195
+ cleanup_request_tracking(request_id)
196
+ raise VectorMCP::SamplingError, "Failed to send request to session #{session_id}"
197
+ end
198
+
199
+ response = wait_for_response(request_id, method, timeout)
200
+ process_response(response, request_id, method)
201
+ end
202
+
203
+ # Stops the transport and cleans up resources.
204
+ #
205
+ # @return [void]
206
+ def stop
207
+ logger.info { "Stopping HttpStream transport" }
208
+ @running = false
209
+ cleanup_all_pending_requests
210
+ @session_manager.cleanup_all_sessions
211
+ @puma_server&.stop
212
+ logger.info { "HttpStream transport stopped" }
213
+ end
214
+
215
+ # Provides access to session manager for internal components.
216
+ #
217
+ # @return [HttpStream::SessionManager]
218
+ # @api private
219
+ attr_reader :session_manager
220
+
221
+ # Provides access to event store for internal components.
222
+ #
223
+ # @return [HttpStream::EventStore]
224
+ # @api private
225
+ attr_reader :event_store
226
+
227
+ # Provides access to stream handler for internal components.
228
+ #
229
+ # @return [HttpStream::StreamHandler]
230
+ # @api private
231
+ attr_reader :stream_handler
232
+
233
+ private
234
+
235
+ # Normalizes the path prefix to ensure it starts with / and doesn't end with /
236
+ #
237
+ # @param prefix [String] The path prefix to normalize
238
+ # @return [String] The normalized path prefix
239
+ def normalize_path_prefix(prefix)
240
+ prefix = prefix.to_s
241
+ prefix = "/#{prefix}" unless prefix.start_with?("/")
242
+ prefix = prefix.chomp("/")
243
+ prefix.empty? ? "/" : prefix
244
+ end
245
+
246
+ # Starts the Puma HTTP server
247
+ #
248
+ # @return [void]
249
+ def start_puma_server
250
+ @puma_server = Puma::Server.new(self)
251
+ @puma_server.add_tcp_listener(@host, @port)
252
+
253
+ @running = true
254
+ setup_signal_handlers
255
+
256
+ logger.info { "HttpStream server listening on #{@host}:#{@port}#{@path_prefix}" }
257
+ @puma_server.run.join
258
+ rescue StandardError => e
259
+ logger.error { "Error starting Puma server: #{e.message}" }
260
+ raise
261
+ ensure
262
+ cleanup_server
263
+ end
264
+
265
+ # Sets up signal handlers for graceful shutdown
266
+ #
267
+ # @return [void]
268
+ def setup_signal_handlers
269
+ %w[INT TERM].each do |signal|
270
+ Signal.trap(signal) do
271
+ # Use a simple flag to avoid trap context issues
272
+ @running = false
273
+ # Defer the actual shutdown to avoid trap context limitations
274
+ Thread.new { stop_server_safely }
275
+ end
276
+ end
277
+ end
278
+
279
+ # Safely stops the server outside of trap context
280
+ #
281
+ # @return [void]
282
+ def stop_server_safely
283
+ return unless @puma_server
284
+
285
+ begin
286
+ @puma_server.stop
287
+ rescue StandardError => e
288
+ # Simple puts to avoid logger issues in signal context
289
+ puts "Error stopping server: #{e.message}"
290
+ end
291
+ end
292
+
293
+ # Cleans up server resources
294
+ #
295
+ # @return [void]
296
+ def cleanup_server
297
+ cleanup_all_pending_requests
298
+ @session_manager.cleanup_all_sessions
299
+ @running = false
300
+ logger.info { "HttpStream server cleanup completed" }
301
+ end
302
+
303
+ # Routes requests to appropriate handlers
304
+ #
305
+ # @param path [String] The request path
306
+ # @param method [String] The HTTP method
307
+ # @param env [Hash] The Rack environment
308
+ # @return [Array] Rack response triplet
309
+ def route_request(path, method, env)
310
+ return handle_health_check if path == "/"
311
+ return not_found_response unless path == @path_prefix
312
+
313
+ # Validate origin for security (MCP specification requirement)
314
+ return forbidden_response("Origin not allowed") unless valid_origin?(env)
315
+
316
+ case method
317
+ when "POST"
318
+ handle_post_request(env)
319
+ when "GET"
320
+ handle_get_request(env)
321
+ when "DELETE"
322
+ handle_delete_request(env)
323
+ else
324
+ method_not_allowed_response(%w[POST GET DELETE])
325
+ end
326
+ end
327
+
328
+ # Handles POST requests (client-to-server JSON-RPC)
329
+ #
330
+ # @param env [Hash] The Rack environment
331
+ # @return [Array] Rack response triplet
332
+ def handle_post_request(env)
333
+ session_id = extract_session_id(env)
334
+ session = @session_manager.get_or_create_session(session_id, env)
335
+
336
+ request_body = read_request_body(env)
337
+ message = parse_json_message(request_body)
338
+
339
+ # Check if this is a response to a server-initiated request
340
+ if outgoing_response?(message)
341
+ handle_outgoing_response(message)
342
+ # For responses, return 202 Accepted with no body
343
+ return [202, { "Mcp-Session-Id" => session.id }, []]
344
+ end
345
+
346
+ result = @server.handle_message(message, session.context, session.id)
347
+
348
+ # Set session ID header in response
349
+ headers = { "Mcp-Session-Id" => session.id }
350
+ json_rpc_response(result, message["id"], headers)
351
+ rescue VectorMCP::ProtocolError => e
352
+ json_error_response(e.request_id, e.code, e.message, e.details)
353
+ rescue JSON::ParserError => e
354
+ json_error_response(nil, -32_700, "Parse error", { details: e.message })
355
+ end
356
+
357
+ # Handles GET requests (SSE streaming)
358
+ #
359
+ # @param env [Hash] The Rack environment
360
+ # @return [Array] Rack response triplet
361
+ def handle_get_request(env)
362
+ session_id = extract_session_id(env)
363
+ return bad_request_response("Missing Mcp-Session-Id header") unless session_id
364
+
365
+ session = @session_manager.get_or_create_session(session_id, env)
366
+ return not_found_response unless session
367
+
368
+ @stream_handler.handle_streaming_request(env, session)
369
+ end
370
+
371
+ # Handles DELETE requests (session termination)
372
+ #
373
+ # @param env [Hash] The Rack environment
374
+ # @return [Array] Rack response triplet
375
+ def handle_delete_request(env)
376
+ session_id = extract_session_id(env)
377
+ return bad_request_response("Missing Mcp-Session-Id header") unless session_id
378
+
379
+ success = @session_manager.terminate_session(session_id)
380
+ if success
381
+ [204, {}, []]
382
+ else
383
+ not_found_response
384
+ end
385
+ end
386
+
387
+ # Extracts session ID from request headers
388
+ #
389
+ # @param env [Hash] The Rack environment
390
+ # @return [String, nil] The session ID or nil if not present
391
+ def extract_session_id(env)
392
+ env["HTTP_MCP_SESSION_ID"]
393
+ end
394
+
395
+ # Reads and returns the request body
396
+ #
397
+ # @param env [Hash] The Rack environment
398
+ # @return [String] The request body
399
+ def read_request_body(env)
400
+ input = env["rack.input"]
401
+ input.rewind
402
+ input.read
403
+ end
404
+
405
+ # Optimized JSON parsing with better error handling and performance
406
+ #
407
+ # @param body [String] The request body
408
+ # @return [Hash] The parsed JSON message
409
+ # @raise [JSON::ParserError] if JSON is invalid
410
+ def parse_json_message(body)
411
+ # Early validation to avoid expensive parsing on malformed input
412
+ raise JSON::ParserError, "Empty or nil body" if body.nil? || body.empty?
413
+
414
+ # Fast-path check for basic JSON structure
415
+ body_stripped = body.strip
416
+ unless (body_stripped.start_with?("{") && body_stripped.end_with?("}")) ||
417
+ (body_stripped.start_with?("[") && body_stripped.end_with?("]"))
418
+ raise JSON::ParserError, "Invalid JSON structure"
419
+ end
420
+
421
+ JSON.parse(body_stripped)
422
+ rescue JSON::ParserError => e
423
+ logger.warn { "JSON parsing failed: #{e.message}" }
424
+ raise
425
+ end
426
+
427
+ # Builds a notification message
428
+ #
429
+ # @param method [String] The notification method
430
+ # @param params [Hash, Array, nil] The notification parameters
431
+ # @return [Hash] The notification message
432
+ def build_notification(method, params = nil)
433
+ message = { jsonrpc: "2.0", method: method }
434
+ message[:params] = params if params
435
+ message
436
+ end
437
+
438
+ # Response helper methods
439
+ def handle_health_check
440
+ [200, { "Content-Type" => "text/plain" }, ["VectorMCP HttpStream Server OK"]]
441
+ end
442
+
443
+ def json_response(data, headers = {})
444
+ response_headers = { "Content-Type" => "application/json" }.merge(headers)
445
+ [200, response_headers, [data.to_json]]
446
+ end
447
+
448
+ def json_rpc_response(result, request_id, headers = {})
449
+ # Use pooled hash for response to reduce allocation
450
+ response = @hash_pool.pop || {}
451
+ response.clear
452
+ response[:jsonrpc] = "2.0"
453
+ response[:id] = request_id
454
+ response[:result] = result
455
+
456
+ response_headers = { "Content-Type" => "application/json" }.merge(headers)
457
+ json_result = response.to_json
458
+
459
+ # Return hash to pool after JSON conversion
460
+ @hash_pool << response if @hash_pool.size < 20
461
+
462
+ [200, response_headers, [json_result]]
463
+ end
464
+
465
+ def json_error_response(id, code, message, data = nil)
466
+ error_obj = { code: code, message: message }
467
+ error_obj[:data] = data if data
468
+ response = { jsonrpc: "2.0", id: id, error: error_obj }
469
+ [400, { "Content-Type" => "application/json" }, [response.to_json]]
470
+ end
471
+
472
+ def not_found_response
473
+ [404, { "Content-Type" => "text/plain" }, ["Not Found"]]
474
+ end
475
+
476
+ def bad_request_response(message = "Bad Request")
477
+ [400, { "Content-Type" => "text/plain" }, [message]]
478
+ end
479
+
480
+ def forbidden_response(message = "Forbidden")
481
+ [403, { "Content-Type" => "text/plain" }, [message]]
482
+ end
483
+
484
+ def method_not_allowed_response(allowed_methods)
485
+ [405, { "Content-Type" => "text/plain", "Allow" => allowed_methods.join(", ") },
486
+ ["Method Not Allowed"]]
487
+ end
488
+
489
+ # Validates the Origin header for security
490
+ #
491
+ # @param env [Hash] The Rack environment
492
+ # @return [Boolean] True if origin is allowed, false otherwise
493
+ def valid_origin?(env)
494
+ return true if @allowed_origins.include?("*")
495
+
496
+ origin = env["HTTP_ORIGIN"]
497
+ return true if origin.nil? # Allow requests without Origin header (e.g., server-to-server)
498
+
499
+ @allowed_origins.include?(origin)
500
+ end
501
+
502
+ # Logging and error handling
503
+ def log_request_completion(method, path, start_time, status)
504
+ duration = Time.now - start_time
505
+ logger.info { "#{method} #{path} #{status} (#{(duration * 1000).round(2)}ms)" }
506
+ end
507
+
508
+ def handle_request_error(method, path, error)
509
+ logger.error { "Request processing error for #{method} #{path}: #{error.message}" }
510
+ [500, { "Content-Type" => "text/plain" }, ["Internal Server Error"]]
511
+ end
512
+
513
+ def handle_fatal_error(error)
514
+ logger.fatal { "Fatal error in HttpStream transport: #{error.message}" }
515
+ exit(1)
516
+ end
517
+
518
+ # Request tracking helpers for server-initiated requests
519
+
520
+ # Sets up tracking for an outgoing request using pooled condition variables.
521
+ #
522
+ # @param request_id [String] The request ID to track
523
+ # @return [void]
524
+ def setup_request_tracking(request_id)
525
+ @request_mutex.synchronize do
526
+ # Create IVar for thread-safe request tracking (no race conditions)
527
+ @outgoing_request_ivars[request_id] = Concurrent::IVar.new
528
+ end
529
+ end
530
+
531
+ # Waits for a response to an outgoing request.
532
+ #
533
+ # @param request_id [String] The request ID to wait for
534
+ # @param method [String] The request method name
535
+ # @param timeout [Numeric] How long to wait
536
+ # @return [Hash] The response data
537
+ # @raise [VectorMCP::SamplingTimeoutError] if timeout occurs
538
+ def wait_for_response(request_id, method, timeout)
539
+ ivar = nil
540
+ @request_mutex.synchronize do
541
+ ivar = @outgoing_request_ivars[request_id]
542
+ end
543
+
544
+ return nil unless ivar
545
+
546
+ begin
547
+ # IVar handles timeout and thread safety automatically
548
+ response = ivar.value!(timeout)
549
+ logger.debug { "Received response for request ID #{request_id}" }
550
+ response
551
+ rescue Concurrent::TimeoutError
552
+ logger.warn { "Timeout waiting for response to request ID #{request_id} (#{method}) after #{timeout}s" }
553
+ cleanup_request_tracking(request_id)
554
+ raise VectorMCP::SamplingTimeoutError, "Timeout waiting for client response to '#{method}' request (ID: #{request_id})"
555
+ end
556
+ end
557
+
558
+ # Processes the response from an outgoing request.
559
+ #
560
+ # @param response [Hash, nil] The response data
561
+ # @param request_id [String] The request ID
562
+ # @param method [String] The request method name
563
+ # @return [Object] The result data
564
+ # @raise [VectorMCP::SamplingError] if response contains an error or is nil
565
+ def process_response(response, request_id, method)
566
+ if response.nil?
567
+ raise VectorMCP::SamplingError, "No response received for '#{method}' request (ID: #{request_id}) - this indicates a logic error."
568
+ end
569
+
570
+ if response.key?(:error)
571
+ err = response[:error]
572
+ logger.warn { "Client returned error for request ID #{request_id} (#{method}): #{err.inspect}" }
573
+ raise VectorMCP::SamplingError, "Client returned an error for '#{method}' request (ID: #{request_id}): [#{err[:code]}] #{err[:message]}"
574
+ end
575
+
576
+ # Check if response has result key, if not treat as malformed
577
+ unless response.key?(:result)
578
+ raise VectorMCP::SamplingError,
579
+ "Malformed response for '#{method}' request (ID: #{request_id}): missing 'result' field. Response: #{response.inspect}"
580
+ end
581
+
582
+ response[:result]
583
+ end
584
+
585
+ # Cleans up tracking for a request and returns condition variable to pool.
586
+ #
587
+ # @param request_id [String] The request ID to clean up
588
+ # @return [void]
589
+ def cleanup_request_tracking(request_id)
590
+ @request_mutex.synchronize do
591
+ cleanup_request_tracking_unsafe(request_id)
592
+ end
593
+ end
594
+
595
+ # Internal cleanup method that assumes mutex is already held.
596
+ # This prevents recursive locking when called from within synchronized blocks.
597
+ #
598
+ # @param request_id [String] The request ID to clean up
599
+ # @return [void]
600
+ # @api private
601
+ def cleanup_request_tracking_unsafe(request_id)
602
+ # Remove IVar for this request (no condition variable cleanup needed)
603
+ @outgoing_request_ivars.delete(request_id)
604
+ end
605
+
606
+ # Checks if a message is a response to an outgoing request.
607
+ #
608
+ # @param message [Hash] The parsed message
609
+ # @return [Boolean] True if this is an outgoing response
610
+ def outgoing_response?(message)
611
+ return false unless message["id"]
612
+ return false if message["method"]
613
+
614
+ # Standard response with result or error
615
+ return true if message.key?("result") || message.key?("error")
616
+
617
+ # Handle malformed responses: if we have a pending request with this ID,
618
+ # treat it as a response (even if malformed) rather than letting it
619
+ # go through normal request processing
620
+ request_id = message["id"]
621
+ @outgoing_request_ivars.key?(request_id)
622
+ end
623
+
624
+ # Handles a response to an outgoing request.
625
+ #
626
+ # @param message [Hash] The parsed response message
627
+ # @return [void]
628
+ def handle_outgoing_response(message)
629
+ request_id = message["id"]
630
+
631
+ ivar = nil
632
+ @request_mutex.synchronize do
633
+ ivar = @outgoing_request_ivars[request_id]
634
+ end
635
+
636
+ unless ivar
637
+ logger.debug { "Received response for request ID #{request_id} but no thread is waiting (likely timed out)" }
638
+ return
639
+ end
640
+
641
+ # Convert keys to symbols for consistency and put response in IVar
642
+ response_data = deep_transform_keys(message, &:to_sym)
643
+
644
+ # Store in both places for compatibility with tests
645
+ @outgoing_request_responses[request_id] = response_data
646
+
647
+ # IVar handles thread-safe response delivery - no race conditions possible
648
+ if ivar.try_set(response_data)
649
+ logger.debug { "Response delivered to waiting thread for request ID #{request_id}" }
650
+ else
651
+ logger.debug { "IVar was already resolved for request ID #{request_id} (duplicate response)" }
652
+ end
653
+ end
654
+
655
+ # Optimized hash key transformation for better performance.
656
+ # Uses simple recursive approach but with early returns to reduce overhead.
657
+ #
658
+ # @param obj [Object] The object to transform (Hash, Array, or other)
659
+ # @return [Object] The transformed object
660
+ def deep_transform_keys(obj, &block)
661
+ transform_object_keys(obj, &block)
662
+ end
663
+
664
+ # Core transformation logic extracted for better maintainability
665
+ def transform_object_keys(obj, &block)
666
+ case obj
667
+ when Hash
668
+ # Pre-allocate hash with known size for efficiency
669
+ result = Hash.new(obj.size)
670
+ obj.each do |k, v|
671
+ # Safe key transformation - only transform string keys to symbols
672
+ new_key = if block && (k.is_a?(String) || k.is_a?(Symbol))
673
+ block.call(k)
674
+ else
675
+ k
676
+ end
677
+ result[new_key] = transform_object_keys(v, &block)
678
+ end
679
+ result
680
+ when Array
681
+ # Use map! for in-place transformation when possible
682
+ obj.map { |v| transform_object_keys(v, &block) }
683
+ else
684
+ obj
685
+ end
686
+ end
687
+
688
+ # Initialize configuration options from the provided options hash
689
+ def initialize_configuration(options)
690
+ @host = options[:host] || DEFAULT_HOST
691
+ @port = options[:port] || DEFAULT_PORT
692
+ @path_prefix = normalize_path_prefix(options[:path_prefix] || DEFAULT_PATH_PREFIX)
693
+ @session_timeout = options[:session_timeout] || DEFAULT_SESSION_TIMEOUT
694
+ @event_retention = options[:event_retention] || DEFAULT_EVENT_RETENTION
695
+ @allowed_origins = options[:allowed_origins] || ["*"]
696
+ end
697
+
698
+ # Initialize core HTTP stream components
699
+ def initialize_components
700
+ @session_manager = HttpStream::SessionManager.new(self, @session_timeout)
701
+ @event_store = HttpStream::EventStore.new(@event_retention)
702
+ @stream_handler = HttpStream::StreamHandler.new(self)
703
+ end
704
+
705
+ # Initialize request tracking system and ID generation for server-initiated requests
706
+ def initialize_request_tracking
707
+ # Use IVars for thread-safe request/response handling (eliminates condition variable races)
708
+ @outgoing_request_ivars = Concurrent::Hash.new
709
+ # Keep compatibility with tests that expect @outgoing_request_responses
710
+ @outgoing_request_responses = Concurrent::Hash.new
711
+ @request_mutex = Mutex.new
712
+ initialize_request_id_generation
713
+ end
714
+
715
+ # Initialize thread-safe request ID generation components
716
+ def initialize_request_id_generation
717
+ # Thread-safe request ID generation - avoid Fiber/Enumerator which can't cross threads
718
+ @request_id_base = "vecmcp_http_#{Process.pid}_#{SecureRandom.hex(4)}"
719
+ @request_id_counter = Concurrent::AtomicFixnum.new(0)
720
+ end
721
+
722
+ # Generate a unique, thread-safe request ID for server-initiated requests
723
+ #
724
+ # @return [String] A unique request ID in format: vecmcp_http_{pid}_{random}_{counter}
725
+ def generate_request_id
726
+ "#{@request_id_base}_#{@request_id_counter.increment}"
727
+ end
728
+
729
+ # Initialize object pools for performance optimization
730
+ def initialize_object_pools
731
+ # Pool for reusable hash objects to reduce GC pressure
732
+ @hash_pool = Concurrent::Array.new
733
+ 20.times { @hash_pool << {} }
734
+ end
735
+
736
+ # Initialize server state variables
737
+ def initialize_server_state
738
+ @puma_server = nil
739
+ @running = false
740
+ end
741
+
742
+ # Cleans up all pending requests during shutdown.
743
+ #
744
+ # @return [void]
745
+ def cleanup_all_pending_requests
746
+ return if @outgoing_request_ivars.empty?
747
+
748
+ logger.debug { "Cleaning up #{@outgoing_request_ivars.size} pending requests" }
749
+
750
+ @request_mutex.synchronize do
751
+ # IVars will timeout naturally, just clear the tracking
752
+ @outgoing_request_ivars.clear
753
+ end
754
+ end
755
+
756
+ # Finds the first session with an active streaming connection.
757
+ #
758
+ # @return [SessionManager::Session, nil] The first streaming session or nil if none found
759
+ def find_streaming_session
760
+ @session_manager.active_session_ids.each do |session_id|
761
+ session = @session_manager.get_session(session_id)
762
+ return session if session&.streaming?
763
+ end
764
+ nil
765
+ end
766
+
767
+ # Finds the first available session (streaming or non-streaming).
768
+ #
769
+ # @return [SessionManager::Session, nil] The first available session or nil if none found
770
+ def find_first_session
771
+ session_ids = @session_manager.active_session_ids
772
+ return nil if session_ids.empty?
773
+
774
+ @session_manager.get_session(session_ids.first)
775
+ end
776
+ end
777
+ # rubocop:enable Metrics/ClassLength
778
+ end
779
+ end