vector_mcp 0.1.0 → 0.3.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.
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "sampling/request"
4
+ require_relative "sampling/result"
5
+ require_relative "errors"
6
+
3
7
  module VectorMCP
4
8
  # Represents the state of a single client-server connection session in MCP.
5
9
  # It tracks initialization status, and negotiated capabilities between the client and server.
@@ -10,21 +14,23 @@ module VectorMCP
10
14
  # @attr_reader client_info [Hash, nil] Information about the client, received during initialization.
11
15
  # @attr_reader client_capabilities [Hash, nil] Capabilities supported by the client, received during initialization.
12
16
  class Session
13
- attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities
17
+ attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id
18
+ attr_accessor :data # For user-defined session-specific storage
14
19
 
15
20
  # Initializes a new session.
16
21
  #
17
- # @param server_info [Hash] Hash containing server information (e.g., name, version).
18
- # @param server_capabilities [Hash] Hash describing server capabilities.
19
- # @param protocol_version [String] The protocol version the server adheres to.
20
- def initialize(server_info:, server_capabilities:, protocol_version:)
21
- @server_info = server_info
22
- @server_capabilities = server_capabilities
23
- @protocol_version = protocol_version
24
-
25
- @initialized = false
22
+ # @param server [VectorMCP::Server] The server instance managing this session.
23
+ # @param transport [VectorMCP::Transport::Base, nil] The transport handling this session. Required for sampling.
24
+ # @param id [String] A unique identifier for this session (e.g., from transport layer).
25
+ def initialize(server, transport = nil, id: SecureRandom.uuid)
26
+ @server = server
27
+ @transport = transport # Store the transport for sending requests
28
+ @id = id
29
+ @initialized_state = :pending # :pending, :succeeded, :failed
26
30
  @client_info = nil
27
31
  @client_capabilities = nil
32
+ @data = {} # Initialize user data hash
33
+ @logger = server.logger
28
34
  end
29
35
 
30
36
  # Marks the session as initialized using parameters from the client's `initialize` request.
@@ -33,35 +39,106 @@ module VectorMCP
33
39
  # Expected keys include "protocolVersion", "clientInfo", and "capabilities".
34
40
  # @return [Hash] A hash suitable for the server's `initialize` response result.
35
41
  def initialize!(params)
36
- client_protocol_version = params["protocolVersion"]
42
+ raise InitializationError, "Session already initialized or initialization attempt in progress." unless @initialized_state == :pending
37
43
 
38
- if client_protocol_version != @protocol_version
39
- # raise VectorMCP::ProtocolError.new("Unsupported protocol version: #{client_protocol_version}", code: -32603)
40
- VectorMCP.logger.warn("Client requested protocol version '#{client_protocol_version}', server using '#{@protocol_version}'")
41
- end
44
+ # TODO: More robust validation of params against MCP spec for initialize request
45
+ params["protocolVersion"]
46
+ client_capabilities_raw = params["capabilities"]
47
+ client_info_raw = params["clientInfo"]
48
+
49
+ # For now, we mostly care about clientInfo and capabilities for the session object.
50
+ # Protocol version matching is more of a server/transport concern at a lower level if strict checks are needed.
51
+ @client_info = client_info_raw.transform_keys(&:to_sym) if client_info_raw.is_a?(Hash)
52
+ @client_capabilities = client_capabilities_raw.transform_keys(&:to_sym) if client_capabilities_raw.is_a?(Hash)
42
53
 
43
- @client_info = params["clientInfo"] || {}
44
- @client_capabilities = params["capabilities"] || {}
45
- @initialized = true
54
+ @initialized_state = :succeeded
55
+ @logger.info("[Session #{@id}] Initialized successfully. Client: #{@client_info&.dig(:name)}")
46
56
 
47
- # Return the initialize result (will be sent by transport)
48
57
  {
49
- protocolVersion: @protocol_version,
50
- serverInfo: @server_info,
51
- capabilities: @server_capabilities
58
+ protocolVersion: @server.protocol_version,
59
+ serverInfo: @server.server_info,
60
+ capabilities: @server.server_capabilities
52
61
  }
62
+ rescue StandardError => e
63
+ @initialized_state = :failed
64
+ @logger.error("[Session #{@id}] Initialization failed: #{e.message}")
65
+ # Re-raise as an InitializationError if it's not already one of our ProtocolErrors
66
+ raise e if e.is_a?(ProtocolError)
67
+
68
+ raise InitializationError, "Initialization processing error: #{e.message}", details: { original_error: e.to_s }
53
69
  end
54
70
 
55
71
  # Checks if the session has been successfully initialized.
56
72
  #
57
73
  # @return [Boolean] True if the session is initialized, false otherwise.
58
74
  def initialized?
59
- @initialized
75
+ @initialized_state == :succeeded
60
76
  end
61
77
 
62
78
  # Helper to check client capabilities later if needed
63
79
  # def supports?(capability_key)
64
80
  # @client_capabilities.key?(capability_key.to_s)
65
81
  # end
82
+
83
+ # --- MCP Sampling Method ---
84
+
85
+ # Initiates an MCP sampling request to the client associated with this session.
86
+ # This is a blocking call that waits for the client's response.
87
+ #
88
+ # @param request_params [Hash] Parameters for the `sampling/createMessage` request.
89
+ # See `VectorMCP::Sampling::Request` for expected structure (e.g., :messages, :max_tokens).
90
+ # @param timeout [Numeric, nil] Optional timeout in seconds for this specific request.
91
+ # Defaults to the transport's default request timeout.
92
+ # @return [VectorMCP::Sampling::Result] The result of the sampling operation.
93
+ # @raise [VectorMCP::SamplingError] if the sampling request fails, is rejected, or times out.
94
+ # @raise [StandardError] if the session's transport does not support `send_request`.
95
+ def sample(request_params, timeout: nil)
96
+ validate_sampling_preconditions
97
+
98
+ sampling_req_obj = VectorMCP::Sampling::Request.new(request_params)
99
+ @logger.info("[Session #{@id}] Sending sampling/createMessage request to client.")
100
+
101
+ send_sampling_request(sampling_req_obj, timeout)
102
+ end
103
+
104
+ private
105
+
106
+ # Validates that sampling can be performed on this session.
107
+ # @api private
108
+ # @raise [StandardError, InitializationError] if preconditions are not met.
109
+ def validate_sampling_preconditions
110
+ unless @transport.respond_to?(:send_request)
111
+ raise StandardError, "Session's transport does not support sending requests (required for sampling)."
112
+ end
113
+
114
+ return if initialized?
115
+
116
+ @logger.warn("[Session #{@id}] Attempted to send sampling request on a non-initialized session.")
117
+ raise InitializationError, "Cannot send sampling request: session not initialized."
118
+ end
119
+
120
+ # Sends the sampling request and handles the response.
121
+ # @api private
122
+ # @param sampling_req_obj [VectorMCP::Sampling::Request] The sampling request object.
123
+ # @param timeout [Numeric, nil] Optional timeout for the request.
124
+ # @return [VectorMCP::Sampling::Result] The sampling result.
125
+ # @raise [VectorMCP::SamplingError] if the request fails.
126
+ def send_sampling_request(sampling_req_obj, timeout)
127
+ send_request_args = ["sampling/createMessage", sampling_req_obj.to_h]
128
+ send_request_kwargs = {}
129
+ send_request_kwargs[:timeout] = timeout if timeout
130
+
131
+ raw_result = @transport.send_request(*send_request_args, **send_request_kwargs)
132
+ VectorMCP::Sampling::Result.new(raw_result)
133
+ rescue ArgumentError => e
134
+ @logger.error("[Session #{@id}] Invalid parameters for sampling request or result: #{e.message}")
135
+ raise VectorMCP::SamplingError, "Invalid sampling parameters or malformed client response: #{e.message}", details: { original_error: e.to_s }
136
+ rescue VectorMCP::SamplingError => e
137
+ @logger.warn("[Session #{@id}] Sampling request failed: #{e.message}")
138
+ raise e
139
+ rescue StandardError => e
140
+ @logger.error("[Session #{@id}] Unexpected error during sampling: #{e.class.name}: #{e.message}")
141
+ raise VectorMCP::SamplingError, "An unexpected error occurred during sampling: #{e.message}", details: { original_error: e.to_s }
142
+ end
66
143
  end
67
144
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Transport
5
+ class SSE
6
+ # Manages individual client connection state for SSE transport.
7
+ # Each client connection has a unique session ID, message queue, and streaming thread.
8
+ class ClientConnection
9
+ attr_reader :session_id, :message_queue, :logger
10
+ attr_accessor :stream_thread, :stream_io
11
+
12
+ # Initializes a new client connection.
13
+ #
14
+ # @param session_id [String] Unique identifier for this client session
15
+ # @param logger [Logger] Logger instance for debugging and error reporting
16
+ def initialize(session_id, logger)
17
+ @session_id = session_id
18
+ @logger = logger
19
+ @message_queue = Queue.new
20
+ @stream_thread = nil
21
+ @stream_io = nil
22
+ @closed = false
23
+ @mutex = Mutex.new
24
+
25
+ logger.debug { "Client connection created: #{session_id}" }
26
+ end
27
+
28
+ # Checks if the connection is closed
29
+ #
30
+ # @return [Boolean] true if connection is closed
31
+ def closed?
32
+ @mutex.synchronize { @closed }
33
+ end
34
+
35
+ # Closes the client connection and cleans up resources.
36
+ # This method is thread-safe and can be called multiple times.
37
+ def close
38
+ @mutex.synchronize do
39
+ return if @closed
40
+
41
+ @closed = true
42
+ logger.debug { "Closing client connection: #{session_id}" }
43
+
44
+ # Close the message queue to signal streaming thread to stop
45
+ @message_queue.close if @message_queue.respond_to?(:close)
46
+
47
+ # Close the stream I/O if it exists
48
+ begin
49
+ @stream_io&.close
50
+ rescue StandardError => e
51
+ logger.warn { "Error closing stream I/O for #{session_id}: #{e.message}" }
52
+ end
53
+
54
+ # Stop the streaming thread
55
+ if @stream_thread&.alive?
56
+ @stream_thread.kill
57
+ @stream_thread.join(1) # Wait up to 1 second for clean shutdown
58
+ end
59
+
60
+ logger.debug { "Client connection closed: #{session_id}" }
61
+ end
62
+ end
63
+
64
+ # Enqueues a message to be sent to this client.
65
+ # This method is thread-safe.
66
+ #
67
+ # @param message [Hash] The JSON-RPC message to send
68
+ # @return [Boolean] true if message was enqueued successfully
69
+ def enqueue_message(message)
70
+ return false if closed?
71
+
72
+ begin
73
+ @message_queue.push(message)
74
+ logger.debug { "Message enqueued for client #{session_id}: #{message.inspect}" }
75
+ true
76
+ rescue ClosedQueueError
77
+ logger.warn { "Attempted to enqueue message to closed queue for client #{session_id}" }
78
+ false
79
+ rescue StandardError => e
80
+ logger.error { "Error enqueuing message for client #{session_id}: #{e.message}" }
81
+ false
82
+ end
83
+ end
84
+
85
+ # Dequeues the next message from the client's message queue.
86
+ # This method blocks until a message is available or the queue is closed.
87
+ #
88
+ # @return [Hash, nil] The next message, or nil if queue is closed
89
+ def dequeue_message
90
+ return nil if closed?
91
+
92
+ begin
93
+ @message_queue.pop
94
+ rescue ClosedQueueError
95
+ nil
96
+ rescue StandardError => e
97
+ logger.error { "Error dequeuing message for client #{session_id}: #{e.message}" }
98
+ nil
99
+ end
100
+ end
101
+
102
+ # Gets the current queue size
103
+ #
104
+ # @return [Integer] Number of messages waiting in the queue
105
+ def queue_size
106
+ @message_queue.size
107
+ rescue StandardError
108
+ 0
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module VectorMCP
6
+ module Transport
7
+ class SSE
8
+ # Handles JSON-RPC message processing for POST requests.
9
+ # Processes incoming messages and sends responses via SSE streams.
10
+ class MessageHandler
11
+ # Initializes a new message handler.
12
+ #
13
+ # @param server [VectorMCP::Server] The MCP server instance
14
+ # @param session [VectorMCP::Session] The server session
15
+ # @param logger [Logger] Logger instance for debugging
16
+ def initialize(server, session, logger)
17
+ @server = server
18
+ @session = session
19
+ @logger = logger
20
+ end
21
+
22
+ # Handles a POST message request from a client.
23
+ #
24
+ # @param env [Hash] Rack environment hash
25
+ # @param client_conn [ClientConnection] The client connection
26
+ # @return [Array] Rack response triplet
27
+ def handle_post_message(env, client_conn)
28
+ request_body = read_request_body(env)
29
+ return error_response(nil, -32_600, "Request body is empty") if request_body.nil? || request_body.empty?
30
+
31
+ message = parse_json_message(request_body, client_conn)
32
+ return message if message.is_a?(Array) # Error response
33
+
34
+ process_message(message, client_conn)
35
+ rescue VectorMCP::ProtocolError => e
36
+ @logger.error { "Protocol error for client #{client_conn.session_id}: #{e.message}" }
37
+ request_id = e.request_id || message&.dig("id")
38
+ enqueue_error_response(client_conn, request_id, e.code, e.message, e.details)
39
+ error_response(request_id, e.code, e.message, e.details)
40
+ rescue StandardError => e
41
+ @logger.error { "Unexpected error for client #{client_conn.session_id}: #{e.message}\n#{e.backtrace.join("\n")}" }
42
+ request_id = message&.dig("id")
43
+ enqueue_error_response(client_conn, request_id, -32_603, "Internal server error")
44
+ error_response(request_id, -32_603, "Internal server error")
45
+ end
46
+
47
+ private
48
+
49
+ # Reads the request body from the Rack environment.
50
+ #
51
+ # @param env [Hash] Rack environment
52
+ # @return [String, nil] Request body as string
53
+ def read_request_body(env)
54
+ input = env["rack.input"]
55
+ return nil unless input
56
+
57
+ body = input.read
58
+ input.rewind if input.respond_to?(:rewind)
59
+ body
60
+ end
61
+
62
+ # Parses JSON message from request body.
63
+ #
64
+ # @param body_str [String] JSON string from request body
65
+ # @param client_conn [ClientConnection] Client connection for error handling
66
+ # @return [Hash, Array] Parsed message or error response triplet
67
+ def parse_json_message(body_str, client_conn)
68
+ JSON.parse(body_str)
69
+ rescue JSON::ParserError => e
70
+ @logger.error { "JSON parse error for client #{client_conn.session_id}: #{e.message}" }
71
+ malformed_id = VectorMCP::Util.extract_id_from_invalid_json(body_str)
72
+ enqueue_error_response(client_conn, malformed_id, -32_700, "Parse error")
73
+ error_response(malformed_id, -32_700, "Parse error")
74
+ end
75
+
76
+ # Processes a valid JSON-RPC message.
77
+ #
78
+ # @param message [Hash] Parsed JSON-RPC message
79
+ # @param client_conn [ClientConnection] Client connection
80
+ # @return [Array] Rack response triplet
81
+ def process_message(message, client_conn)
82
+ # Handle the message through the server
83
+ response_data = @server.handle_message(message, @session, client_conn.session_id)
84
+
85
+ # If it's a request (has id), send response via SSE
86
+ if message["id"]
87
+ enqueue_success_response(client_conn, message["id"], response_data)
88
+ else
89
+ @logger.debug { "Processed notification for client #{client_conn.session_id}" }
90
+ end
91
+
92
+ # Always return 202 Accepted for valid POST messages
93
+ success_response(message["id"])
94
+ end
95
+
96
+ # Enqueues a successful response to the client's SSE stream.
97
+ #
98
+ # @param client_conn [ClientConnection] Client connection
99
+ # @param request_id [String, Integer] Original request ID
100
+ # @param result [Object] Response result data
101
+ def enqueue_success_response(client_conn, request_id, result)
102
+ response = {
103
+ jsonrpc: "2.0",
104
+ id: request_id,
105
+ result: result
106
+ }
107
+ StreamManager.enqueue_message(client_conn, response)
108
+ end
109
+
110
+ # Enqueues an error response to the client's SSE stream.
111
+ #
112
+ # @param client_conn [ClientConnection] Client connection
113
+ # @param request_id [String, Integer, nil] Original request ID
114
+ # @param code [Integer] Error code
115
+ # @param message [String] Error message
116
+ # @param data [Object, nil] Additional error data
117
+ def enqueue_error_response(client_conn, request_id, code, message, data = nil)
118
+ error_payload = { code: code, message: message }
119
+ error_payload[:data] = data if data
120
+
121
+ error_response = {
122
+ jsonrpc: "2.0",
123
+ id: request_id,
124
+ error: error_payload
125
+ }
126
+ StreamManager.enqueue_message(client_conn, error_response)
127
+ end
128
+
129
+ # Creates a successful HTTP response for the POST request.
130
+ #
131
+ # @param request_id [String, Integer, nil] Request ID
132
+ # @return [Array] Rack response triplet
133
+ def success_response(request_id)
134
+ body = { status: "accepted", id: request_id }.to_json
135
+ [202, { "Content-Type" => "application/json" }, [body]]
136
+ end
137
+
138
+ # Creates an error HTTP response for the POST request.
139
+ #
140
+ # @param id [String, Integer, nil] Request ID
141
+ # @param code [Integer] Error code
142
+ # @param message [String] Error message
143
+ # @param data [Object, nil] Additional error data
144
+ # @return [Array] Rack response triplet
145
+ def error_response(id, code, message, data = nil)
146
+ status = case code
147
+ when -32_700, -32_600, -32_602 then 400 # Parse, Invalid Request, Invalid Params
148
+ when -32_601, -32_001 then 404 # Method Not Found, Not Found
149
+ else 500 # Internal Error, Server Error
150
+ end
151
+
152
+ error_payload = { code: code, message: message }
153
+ error_payload[:data] = data if data
154
+
155
+ body = {
156
+ jsonrpc: "2.0",
157
+ id: id,
158
+ error: error_payload
159
+ }.to_json
160
+
161
+ [status, { "Content-Type" => "application/json" }, [body]]
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Transport
5
+ class SSE
6
+ # Configures Puma server for production-ready SSE transport.
7
+ # Handles server setup, threading, and resource management.
8
+ class PumaConfig
9
+ attr_reader :host, :port, :logger
10
+
11
+ # Initializes Puma configuration.
12
+ #
13
+ # @param host [String] Host to bind to
14
+ # @param port [Integer] Port to listen on
15
+ # @param logger [Logger] Logger instance
16
+ def initialize(host, port, logger)
17
+ @host = host
18
+ @port = port
19
+ @logger = logger
20
+ end
21
+
22
+ # Configures a Puma server instance.
23
+ #
24
+ # @param server [Puma::Server] The Puma server to configure
25
+ def configure(server)
26
+ server.add_tcp_listener(host, port)
27
+
28
+ # Configure threading for production use
29
+ configure_threading(server)
30
+
31
+ # Set up server options
32
+ configure_server_options(server)
33
+
34
+ logger.debug { "Puma server configured for #{host}:#{port}" }
35
+ end
36
+
37
+ private
38
+
39
+ # Configures threading parameters for optimal performance.
40
+ #
41
+ # @param server [Puma::Server] The Puma server
42
+ def configure_threading(server)
43
+ # Set thread pool size based on CPU cores and expected load
44
+ min_threads = 2
45
+ max_threads = [4, Etc.nprocessors * 2].max
46
+
47
+ # Puma 6.x does not expose min_threads= and max_threads= as public API.
48
+ # Thread pool sizing should be set via Puma DSL/config before server creation.
49
+ # For legacy compatibility, set if possible, otherwise log a warning.
50
+ if server.respond_to?(:min_threads=) && server.respond_to?(:max_threads=)
51
+ server.min_threads = min_threads
52
+ server.max_threads = max_threads
53
+ logger.debug { "Puma configured with #{min_threads}-#{max_threads} threads" }
54
+ else
55
+ logger.warn { "Puma::Server does not support direct thread pool sizing; set threads via Puma config DSL before server creation." }
56
+ end
57
+ end
58
+
59
+ # Configures server-specific options.
60
+ #
61
+ # @param server [Puma::Server] The Puma server
62
+ def configure_server_options(server)
63
+ # Set server-specific options for SSE handling
64
+ server.leak_stack_on_error = false if server.respond_to?(:leak_stack_on_error=)
65
+
66
+ # Configure timeouts appropriate for SSE connections
67
+ # SSE connections should be long-lived, so we set generous timeouts
68
+ if server.respond_to?(:first_data_timeout=)
69
+ server.first_data_timeout = 30 # 30 seconds to send first data
70
+ end
71
+
72
+ logger.debug { "Puma server options configured for SSE transport" }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Transport
5
+ class SSE
6
+ # Manages Server-Sent Events streaming for client connections.
7
+ # Handles creation of streaming responses and message broadcasting.
8
+ class StreamManager
9
+ class << self
10
+ # Creates an SSE streaming response body for a client connection.
11
+ #
12
+ # @param client_conn [ClientConnection] The client connection to stream to
13
+ # @param endpoint_url [String] The URL for the client to POST messages to
14
+ # @param logger [Logger] Logger instance for debugging
15
+ # @return [Enumerator] Rack-compatible streaming response body
16
+ def create_sse_stream(client_conn, endpoint_url, logger)
17
+ Enumerator.new do |yielder|
18
+ # Send initial endpoint event
19
+ yielder << format_sse_event("endpoint", endpoint_url)
20
+ logger.debug { "Sent endpoint event to client #{client_conn.session_id}: #{endpoint_url}" }
21
+
22
+ # Start streaming thread for this client
23
+ client_conn.stream_thread = Thread.new do
24
+ stream_messages_to_client(client_conn, yielder, logger)
25
+ end
26
+
27
+ # Keep the connection alive by yielding from the streaming thread
28
+ client_conn.stream_thread.join
29
+ rescue StandardError => e
30
+ logger.error { "Error in SSE stream for client #{client_conn.session_id}: #{e.message}\n#{e.backtrace.join("\n")}" }
31
+ ensure
32
+ logger.debug { "SSE stream ended for client #{client_conn.session_id}" }
33
+ client_conn.close
34
+ end
35
+ end
36
+
37
+ # Enqueues a message to a specific client connection.
38
+ #
39
+ # @param client_conn [ClientConnection] The target client connection
40
+ # @param message [Hash] The JSON-RPC message to send
41
+ # @return [Boolean] true if message was enqueued successfully
42
+ def enqueue_message(client_conn, message)
43
+ return false unless client_conn && !client_conn.closed?
44
+
45
+ client_conn.enqueue_message(message)
46
+ end
47
+
48
+ private
49
+
50
+ # Streams messages from a client's queue to the SSE connection.
51
+ # This method runs in a dedicated thread per client.
52
+ #
53
+ # @param client_conn [ClientConnection] The client connection
54
+ # @param yielder [Enumerator::Yielder] The response yielder
55
+ # @param logger [Logger] Logger instance
56
+ def stream_messages_to_client(client_conn, yielder, logger)
57
+ logger.debug { "Starting message streaming thread for client #{client_conn.session_id}" }
58
+
59
+ loop do
60
+ message = client_conn.dequeue_message
61
+ break if message.nil? # Queue closed or connection closed
62
+
63
+ begin
64
+ json_message = message.to_json
65
+ sse_data = format_sse_event("message", json_message)
66
+ yielder << sse_data
67
+
68
+ logger.debug { "Streamed message to client #{client_conn.session_id}: #{json_message}" }
69
+ rescue StandardError => e
70
+ logger.error { "Error streaming message to client #{client_conn.session_id}: #{e.message}" }
71
+ break
72
+ end
73
+ end
74
+
75
+ logger.debug { "Message streaming thread ended for client #{client_conn.session_id}" }
76
+ rescue StandardError => e
77
+ logger.error { "Fatal error in streaming thread for client #{client_conn.session_id}: #{e.message}\n#{e.backtrace.join("\n")}" }
78
+ end
79
+
80
+ # Formats data as a Server-Sent Event.
81
+ #
82
+ # @param event [String] The event type
83
+ # @param data [String] The event data
84
+ # @return [String] Properly formatted SSE event
85
+ def format_sse_event(event, data)
86
+ "event: #{event}\ndata: #{data}\n\n"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end