vector_mcp 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/lib/vector_mcp/definitions.rb +25 -9
  4. data/lib/vector_mcp/errors.rb +2 -6
  5. data/lib/vector_mcp/handlers/core.rb +12 -10
  6. data/lib/vector_mcp/image_util.rb +27 -2
  7. data/lib/vector_mcp/log_filter.rb +48 -0
  8. data/lib/vector_mcp/middleware/base.rb +1 -7
  9. data/lib/vector_mcp/middleware/manager.rb +3 -15
  10. data/lib/vector_mcp/request_context.rb +182 -0
  11. data/lib/vector_mcp/sampling/result.rb +11 -1
  12. data/lib/vector_mcp/security/middleware.rb +2 -28
  13. data/lib/vector_mcp/security/strategies/api_key.rb +29 -28
  14. data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
  15. data/lib/vector_mcp/server/capabilities.rb +5 -7
  16. data/lib/vector_mcp/server/message_handling.rb +11 -5
  17. data/lib/vector_mcp/server.rb +21 -10
  18. data/lib/vector_mcp/session.rb +96 -6
  19. data/lib/vector_mcp/transport/base_session_manager.rb +320 -0
  20. data/lib/vector_mcp/transport/http_stream/event_store.rb +157 -0
  21. data/lib/vector_mcp/transport/http_stream/session_manager.rb +191 -0
  22. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +270 -0
  23. data/lib/vector_mcp/transport/http_stream.rb +961 -0
  24. data/lib/vector_mcp/transport/sse/client_connection.rb +1 -1
  25. data/lib/vector_mcp/transport/sse/stream_manager.rb +1 -1
  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 +1 -0
  33. metadata +10 -1
@@ -0,0 +1,961 @@
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
+ unless valid_post_accept?(env)
334
+ logger.warn { "POST request with unsupported Accept header: #{env["HTTP_ACCEPT"]}" }
335
+ return not_acceptable_response("Not Acceptable: POST requires Accept: application/json")
336
+ end
337
+
338
+ session_id = extract_session_id(env)
339
+ request_body = read_request_body(env)
340
+ parsed = parse_json_message(request_body)
341
+
342
+ session = resolve_session_for_post(session_id, parsed, env)
343
+ return session if session.is_a?(Array) # Rack error response
344
+
345
+ if parsed.is_a?(Array)
346
+ handle_batch_request(parsed, session)
347
+ else
348
+ handle_single_request(parsed, session, env)
349
+ end
350
+ rescue JSON::ParserError => e
351
+ json_error_response(nil, -32_700, "Parse error", { details: e.message })
352
+ end
353
+
354
+ # Handles a single JSON-RPC message from a POST request.
355
+ #
356
+ # @param message [Hash] Parsed JSON-RPC message
357
+ # @param session [Session] The resolved session
358
+ # @param env [Hash] The Rack environment
359
+ # @return [Array] Rack response triplet
360
+ def handle_single_request(message, session, env)
361
+ if outgoing_response?(message)
362
+ handle_outgoing_response(message)
363
+ return [202, { "Mcp-Session-Id" => session.id }, []]
364
+ end
365
+
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
401
+ end
402
+
403
+ result = @server.handle_message(message, session.context, session.id)
404
+ return nil if result.nil? && message["id"].nil?
405
+
406
+ { jsonrpc: "2.0", id: message["id"], result: result }
407
+ 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" } }
419
+ end
420
+
421
+ # Resolves or creates the session for a POST request following MCP spec rules:
422
+ # - session_id present and known → return existing session (updating request context)
423
+ # - session_id present but unknown/expired → 404 Not Found
424
+ # - no session_id + initialize request → create new session
425
+ # - no session_id + other request → 400 Bad Request
426
+ #
427
+ # @param session_id [String, nil] Client-supplied Mcp-Session-Id header value
428
+ # @param message [Hash, Array] Parsed JSON-RPC message or batch array
429
+ # @param env [Hash] Rack environment
430
+ # @return [Session, Array] Session object or Rack error response triplet
431
+ def resolve_session_for_post(session_id, message, env)
432
+ first_message = message.is_a?(Array) ? message.first : message
433
+ is_initialize = first_message.is_a?(Hash) && first_message["method"] == "initialize"
434
+
435
+ if session_id
436
+ session = @session_manager.get_session(session_id)
437
+ return not_found_response("Unknown or expired session") unless session
438
+
439
+ if env
440
+ request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
441
+ session.context.request_context = request_context
442
+ end
443
+ session
444
+ elsif is_initialize
445
+ @session_manager.create_session(nil, env)
446
+ else
447
+ bad_request_response("Missing Mcp-Session-Id header")
448
+ end
449
+ end
450
+
451
+ # Handles GET requests (SSE streaming)
452
+ #
453
+ # @param env [Hash] The Rack environment
454
+ # @return [Array] Rack response triplet
455
+ def handle_get_request(env)
456
+ unless valid_get_accept?(env)
457
+ logger.warn { "GET request with unsupported Accept header: #{env["HTTP_ACCEPT"]}" }
458
+ return not_acceptable_response("Not Acceptable: GET requires Accept: text/event-stream")
459
+ end
460
+
461
+ session_id = extract_session_id(env)
462
+ return bad_request_response("Missing Mcp-Session-Id header") unless session_id
463
+
464
+ session = @session_manager.get_or_create_session(session_id, env)
465
+ return not_found_response unless session
466
+
467
+ @stream_handler.handle_streaming_request(env, session)
468
+ end
469
+
470
+ # Handles DELETE requests (session termination)
471
+ #
472
+ # @param env [Hash] The Rack environment
473
+ # @return [Array] Rack response triplet
474
+ def handle_delete_request(env)
475
+ session_id = extract_session_id(env)
476
+ return bad_request_response("Missing Mcp-Session-Id header") unless session_id
477
+
478
+ success = @session_manager.terminate_session(session_id)
479
+ if success
480
+ [204, {}, []]
481
+ else
482
+ not_found_response
483
+ end
484
+ end
485
+
486
+ # Extracts session ID from request headers
487
+ #
488
+ # @param env [Hash] The Rack environment
489
+ # @return [String, nil] The session ID or nil if not present
490
+ def extract_session_id(env)
491
+ env["HTTP_MCP_SESSION_ID"]
492
+ end
493
+
494
+ # Reads and returns the request body
495
+ #
496
+ # @param env [Hash] The Rack environment
497
+ # @return [String] The request body
498
+ def read_request_body(env)
499
+ input = env["rack.input"]
500
+ input.rewind
501
+ input.read
502
+ end
503
+
504
+ # Optimized JSON parsing with better error handling and performance
505
+ #
506
+ # @param body [String] The request body
507
+ # @return [Hash] The parsed JSON message
508
+ # @raise [JSON::ParserError] if JSON is invalid
509
+ def parse_json_message(body)
510
+ # Early validation to avoid expensive parsing on malformed input
511
+ raise JSON::ParserError, "Empty or nil body" if body.nil? || body.empty?
512
+
513
+ # Fast-path check for basic JSON structure
514
+ body_stripped = body.strip
515
+ unless (body_stripped.start_with?("{") && body_stripped.end_with?("}")) ||
516
+ (body_stripped.start_with?("[") && body_stripped.end_with?("]"))
517
+ raise JSON::ParserError, "Invalid JSON structure"
518
+ end
519
+
520
+ JSON.parse(body_stripped)
521
+ rescue JSON::ParserError => e
522
+ logger.warn { "JSON parsing failed: #{e.message}" }
523
+ raise
524
+ end
525
+
526
+ # Builds a notification message
527
+ #
528
+ # @param method [String] The notification method
529
+ # @param params [Hash, Array, nil] The notification parameters
530
+ # @return [Hash] The notification message
531
+ def build_notification(method, params = nil)
532
+ message = { jsonrpc: "2.0", method: method }
533
+ message[:params] = params if params
534
+ message
535
+ end
536
+
537
+ # Response helper methods
538
+ def handle_health_check
539
+ [200, { "Content-Type" => "text/plain" }, ["VectorMCP HttpStream Server OK"]]
540
+ end
541
+
542
+ def json_response(data, headers = {})
543
+ response_headers = { "Content-Type" => "application/json" }.merge(headers)
544
+ [200, response_headers, [data.to_json]]
545
+ end
546
+
547
+ def json_rpc_response(result, request_id, headers = {})
548
+ # Use pooled hash for response to reduce allocation
549
+ response = @hash_pool.pop || {}
550
+ response.clear
551
+ response[:jsonrpc] = "2.0"
552
+ response[:id] = request_id
553
+ response[:result] = result
554
+
555
+ response_headers = { "Content-Type" => "application/json" }.merge(headers)
556
+ json_result = response.to_json
557
+
558
+ # Return hash to pool after JSON conversion
559
+ @hash_pool << response if @hash_pool.size < 20
560
+
561
+ [200, response_headers, [json_result]]
562
+ end
563
+
564
+ def json_error_response(id, code, message, data = nil)
565
+ error_obj = { code: code, message: message }
566
+ error_obj[:data] = data if data
567
+ response = { jsonrpc: "2.0", id: id, error: error_obj }
568
+ [400, { "Content-Type" => "application/json" }, [response.to_json]]
569
+ end
570
+
571
+ def build_rpc_response(env, result, request_id, session_id)
572
+ headers = { "Mcp-Session-Id" => session_id }
573
+ if client_accepts_sse?(env)
574
+ sse_rpc_response(result, request_id, headers, session_id: session_id)
575
+ else
576
+ json_rpc_response(result, request_id, headers)
577
+ end
578
+ end
579
+
580
+ def build_protocol_error_response(env, error, session_id: nil)
581
+ if client_accepts_sse?(env)
582
+ sse_error_response(error.request_id, error.code, error.message, error.details, session_id: session_id)
583
+ else
584
+ json_error_response(error.request_id, error.code, error.message, error.details)
585
+ end
586
+ end
587
+
588
+ def client_accepts_sse?(env)
589
+ accept = env["HTTP_ACCEPT"] || ""
590
+ accept.include?("text/event-stream")
591
+ end
592
+
593
+ def format_sse_event(data, type, event_id)
594
+ lines = []
595
+ lines << "id: #{event_id}"
596
+ lines << "event: #{type}" if type
597
+ lines << "data: #{data}"
598
+ lines << ""
599
+ "#{lines.join("\n")}\n"
600
+ end
601
+
602
+ def sse_rpc_response(result, request_id, headers = {}, session_id: nil)
603
+ response = { jsonrpc: "2.0", id: request_id, result: result }
604
+ event_data = response.to_json
605
+
606
+ event_id = @event_store.store_event(event_data, "message", session_id: session_id)
607
+ sse_event = format_sse_event(event_data, "message", event_id)
608
+
609
+ response_headers = {
610
+ "Content-Type" => "text/event-stream",
611
+ "Cache-Control" => "no-cache",
612
+ "Connection" => "keep-alive",
613
+ "X-Accel-Buffering" => "no"
614
+ }.merge(headers)
615
+
616
+ [200, response_headers, [sse_event]]
617
+ end
618
+
619
+ def sse_error_response(id, code, err_message, data = nil, session_id: nil)
620
+ error_obj = { code: code, message: err_message }
621
+ error_obj[:data] = data if data
622
+ response = { jsonrpc: "2.0", id: id, error: error_obj }
623
+ event_data = response.to_json
624
+
625
+ event_id = @event_store.store_event(event_data, "message", session_id: session_id)
626
+ sse_event = format_sse_event(event_data, "message", event_id)
627
+
628
+ response_headers = {
629
+ "Content-Type" => "text/event-stream",
630
+ "Cache-Control" => "no-cache"
631
+ }
632
+
633
+ [200, response_headers, [sse_event]]
634
+ end
635
+
636
+ def not_found_response(message = "Not Found")
637
+ [404, { "Content-Type" => "text/plain" }, [message]]
638
+ end
639
+
640
+ def bad_request_response(message = "Bad Request")
641
+ [400, { "Content-Type" => "text/plain" }, [message]]
642
+ end
643
+
644
+ def forbidden_response(message = "Forbidden")
645
+ [403, { "Content-Type" => "text/plain" }, [message]]
646
+ end
647
+
648
+ def method_not_allowed_response(allowed_methods)
649
+ [405, { "Content-Type" => "text/plain", "Allow" => allowed_methods.join(", ") },
650
+ ["Method Not Allowed"]]
651
+ end
652
+
653
+ def not_acceptable_response(message = "Not Acceptable")
654
+ [406, { "Content-Type" => "text/plain" }, [message]]
655
+ end
656
+
657
+ def valid_post_accept?(env)
658
+ accept = env["HTTP_ACCEPT"]
659
+ return true if accept.nil? || accept.strip.empty?
660
+
661
+ accept.include?("application/json") || accept.include?("*/*")
662
+ end
663
+
664
+ def valid_get_accept?(env)
665
+ accept = env["HTTP_ACCEPT"]
666
+ return true if accept.nil? || accept.strip.empty?
667
+
668
+ accept.include?("text/event-stream") || accept.include?("*/*")
669
+ end
670
+
671
+ # Validates the Origin header for security
672
+ #
673
+ # @param env [Hash] The Rack environment
674
+ # @return [Boolean] True if origin is allowed, false otherwise
675
+ def valid_origin?(env)
676
+ return true if @allowed_origins.include?("*")
677
+
678
+ origin = env["HTTP_ORIGIN"]
679
+ return true if origin.nil? # Allow requests without Origin header (e.g., server-to-server)
680
+
681
+ @allowed_origins.include?(origin)
682
+ end
683
+
684
+ # Logging and error handling
685
+ def log_request_completion(method, path, start_time, status)
686
+ duration = Time.now - start_time
687
+ logger.info { "#{method} #{path} #{status} (#{(duration * 1000).round(2)}ms)" }
688
+ end
689
+
690
+ def handle_request_error(method, path, error)
691
+ logger.error { "Request processing error for #{method} #{path}: #{error.message}" }
692
+ [500, { "Content-Type" => "text/plain" }, ["Internal Server Error"]]
693
+ end
694
+
695
+ def handle_fatal_error(error)
696
+ logger.fatal { "Fatal error in HttpStream transport: #{error.message}" }
697
+ exit(1)
698
+ end
699
+
700
+ # Request tracking helpers for server-initiated requests
701
+
702
+ # Sets up tracking for an outgoing request using pooled condition variables.
703
+ #
704
+ # @param request_id [String] The request ID to track
705
+ # @return [void]
706
+ 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
711
+ end
712
+
713
+ # Waits for a response to an outgoing request.
714
+ #
715
+ # @param request_id [String] The request ID to wait for
716
+ # @param method [String] The request method name
717
+ # @param timeout [Numeric] How long to wait
718
+ # @return [Hash] The response data
719
+ # @raise [VectorMCP::SamplingTimeoutError] if timeout occurs
720
+ 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
+
726
+ return nil unless ivar
727
+
728
+ begin
729
+ # IVar handles timeout and thread safety automatically
730
+ response = ivar.value!(timeout)
731
+ logger.debug { "Received response for request ID #{request_id}" }
732
+ response
733
+ rescue Concurrent::TimeoutError
734
+ logger.warn { "Timeout waiting for response to request ID #{request_id} (#{method}) after #{timeout}s" }
735
+ cleanup_request_tracking(request_id)
736
+ raise VectorMCP::SamplingTimeoutError, "Timeout waiting for client response to '#{method}' request (ID: #{request_id})"
737
+ end
738
+ end
739
+
740
+ # Processes the response from an outgoing request.
741
+ #
742
+ # @param response [Hash, nil] The response data
743
+ # @param request_id [String] The request ID
744
+ # @param method [String] The request method name
745
+ # @return [Object] The result data
746
+ # @raise [VectorMCP::SamplingError] if response contains an error or is nil
747
+ def process_response(response, request_id, method)
748
+ if response.nil?
749
+ raise VectorMCP::SamplingError, "No response received for '#{method}' request (ID: #{request_id}) - this indicates a logic error."
750
+ end
751
+
752
+ if response.key?(:error)
753
+ err = response[:error]
754
+ logger.warn { "Client returned error for request ID #{request_id} (#{method}): #{err.inspect}" }
755
+ raise VectorMCP::SamplingError, "Client returned an error for '#{method}' request (ID: #{request_id}): [#{err[:code]}] #{err[:message]}"
756
+ end
757
+
758
+ # Check if response has result key, if not treat as malformed
759
+ unless response.key?(:result)
760
+ raise VectorMCP::SamplingError,
761
+ "Malformed response for '#{method}' request (ID: #{request_id}): missing 'result' field. Response: #{response.inspect}"
762
+ end
763
+
764
+ response[:result]
765
+ end
766
+
767
+ # Cleans up tracking for a request and returns condition variable to pool.
768
+ #
769
+ # @param request_id [String] The request ID to clean up
770
+ # @return [void]
771
+ 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
+ @outgoing_request_ivars.delete(request_id)
786
+ end
787
+
788
+ # Checks if a message is a response to an outgoing request.
789
+ #
790
+ # @param message [Hash] The parsed message
791
+ # @return [Boolean] True if this is an outgoing response
792
+ def outgoing_response?(message)
793
+ return false unless message["id"]
794
+ return false if message["method"]
795
+
796
+ # Standard response with result or error
797
+ return true if message.key?("result") || message.key?("error")
798
+
799
+ # Handle malformed responses: if we have a pending request with this ID,
800
+ # treat it as a response (even if malformed) rather than letting it
801
+ # go through normal request processing
802
+ request_id = message["id"]
803
+ @outgoing_request_ivars.key?(request_id)
804
+ end
805
+
806
+ # Handles a response to an outgoing request.
807
+ #
808
+ # @param message [Hash] The parsed response message
809
+ # @return [void]
810
+ def handle_outgoing_response(message)
811
+ request_id = message["id"]
812
+
813
+ ivar = nil
814
+ @request_mutex.synchronize do
815
+ ivar = @outgoing_request_ivars[request_id]
816
+ end
817
+
818
+ unless ivar
819
+ logger.debug { "Received response for request ID #{request_id} but no thread is waiting (likely timed out)" }
820
+ return
821
+ end
822
+
823
+ # Convert keys to symbols for consistency and put response in IVar
824
+ 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
+ if ivar.try_set(response_data)
831
+ logger.debug { "Response delivered to waiting thread for request ID #{request_id}" }
832
+ else
833
+ logger.debug { "IVar was already resolved for request ID #{request_id} (duplicate response)" }
834
+ end
835
+ end
836
+
837
+ # Optimized hash key transformation for better performance.
838
+ # Uses simple recursive approach but with early returns to reduce overhead.
839
+ #
840
+ # @param obj [Object] The object to transform (Hash, Array, or other)
841
+ # @return [Object] The transformed object
842
+ def deep_transform_keys(obj, &block)
843
+ transform_object_keys(obj, &block)
844
+ end
845
+
846
+ # Core transformation logic extracted for better maintainability
847
+ def transform_object_keys(obj, &block)
848
+ case obj
849
+ when Hash
850
+ # Pre-allocate hash with known size for efficiency
851
+ result = Hash.new(obj.size)
852
+ obj.each do |k, v|
853
+ # Safe key transformation - only transform string keys to symbols
854
+ new_key = if block && (k.is_a?(String) || k.is_a?(Symbol))
855
+ block.call(k)
856
+ else
857
+ k
858
+ end
859
+ result[new_key] = transform_object_keys(v, &block)
860
+ end
861
+ result
862
+ when Array
863
+ # Use map! for in-place transformation when possible
864
+ obj.map { |v| transform_object_keys(v, &block) }
865
+ else
866
+ obj
867
+ end
868
+ end
869
+
870
+ # Initialize configuration options from the provided options hash
871
+ def initialize_configuration(options)
872
+ @host = options[:host] || DEFAULT_HOST
873
+ @port = options[:port] || DEFAULT_PORT
874
+ @path_prefix = normalize_path_prefix(options[:path_prefix] || DEFAULT_PATH_PREFIX)
875
+ @session_timeout = options[:session_timeout] || DEFAULT_SESSION_TIMEOUT
876
+ @event_retention = options[:event_retention] || DEFAULT_EVENT_RETENTION
877
+ @allowed_origins = options[:allowed_origins] || ["*"]
878
+ end
879
+
880
+ # Initialize core HTTP stream components
881
+ def initialize_components
882
+ @session_manager = HttpStream::SessionManager.new(self, @session_timeout)
883
+ @event_store = HttpStream::EventStore.new(@event_retention)
884
+ @stream_handler = HttpStream::StreamHandler.new(self)
885
+ end
886
+
887
+ # Initialize request tracking system and ID generation for server-initiated requests
888
+ def initialize_request_tracking
889
+ # Use IVars for thread-safe request/response handling (eliminates condition variable races)
890
+ @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
+ initialize_request_id_generation
895
+ end
896
+
897
+ # Initialize thread-safe request ID generation components
898
+ def initialize_request_id_generation
899
+ # Thread-safe request ID generation - avoid Fiber/Enumerator which can't cross threads
900
+ @request_id_base = "vecmcp_http_#{Process.pid}_#{SecureRandom.hex(4)}"
901
+ @request_id_counter = Concurrent::AtomicFixnum.new(0)
902
+ end
903
+
904
+ # Generate a unique, thread-safe request ID for server-initiated requests
905
+ #
906
+ # @return [String] A unique request ID in format: vecmcp_http_{pid}_{random}_{counter}
907
+ def generate_request_id
908
+ "#{@request_id_base}_#{@request_id_counter.increment}"
909
+ end
910
+
911
+ # Initialize object pools for performance optimization
912
+ def initialize_object_pools
913
+ # Pool for reusable hash objects to reduce GC pressure
914
+ @hash_pool = Concurrent::Array.new
915
+ 20.times { @hash_pool << {} }
916
+ end
917
+
918
+ # Initialize server state variables
919
+ def initialize_server_state
920
+ @puma_server = nil
921
+ @running = false
922
+ end
923
+
924
+ # Cleans up all pending requests during shutdown.
925
+ #
926
+ # @return [void]
927
+ def cleanup_all_pending_requests
928
+ return if @outgoing_request_ivars.empty?
929
+
930
+ logger.debug { "Cleaning up #{@outgoing_request_ivars.size} pending requests" }
931
+
932
+ @request_mutex.synchronize do
933
+ # IVars will timeout naturally, just clear the tracking
934
+ @outgoing_request_ivars.clear
935
+ end
936
+ end
937
+
938
+ # Finds the first session with an active streaming connection.
939
+ #
940
+ # @return [SessionManager::Session, nil] The first streaming session or nil if none found
941
+ def find_streaming_session
942
+ @session_manager.active_session_ids.each do |session_id|
943
+ session = @session_manager.get_session(session_id)
944
+ return session if session&.streaming?
945
+ end
946
+ nil
947
+ end
948
+
949
+ # Finds the first available session (streaming or non-streaming).
950
+ #
951
+ # @return [SessionManager::Session, nil] The first available session or nil if none found
952
+ def find_first_session
953
+ session_ids = @session_manager.active_session_ids
954
+ return nil if session_ids.empty?
955
+
956
+ @session_manager.get_session(session_ids.first)
957
+ end
958
+ end
959
+ # rubocop:enable Metrics/ClassLength
960
+ end
961
+ end