vector_mcp 0.1.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.
@@ -0,0 +1,663 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "async"
6
+ require "async/io"
7
+ require "async/http/endpoint"
8
+ require "async/http/body/writable"
9
+ require "falcon/server"
10
+ require "falcon/endpoint"
11
+
12
+ require_relative "../errors"
13
+ require_relative "../util"
14
+ require_relative "../session" # Make sure session is loaded
15
+
16
+ module VectorMCP
17
+ module Transport
18
+ # Implements the Model Context Protocol transport over HTTP using Server-Sent Events (SSE)
19
+ # for server-to-client messages and HTTP POST for client-to-server messages.
20
+ # This transport uses the `async` and `falcon` gems for an event-driven, non-blocking I/O model.
21
+ #
22
+ # It provides two main HTTP endpoints:
23
+ # 1. SSE Endpoint (`<path_prefix>/sse`): Clients connect here via GET to establish an SSE stream.
24
+ # The server sends an initial `event: endpoint` with a unique URL for the client to POST messages back.
25
+ # Subsequent messages from the server (responses, notifications) are sent as `event: message`.
26
+ # 2. Message Endpoint (`<path_prefix>/message`): Clients POST JSON-RPC messages here.
27
+ # The `session_id` (obtained from the SSE endpoint event) must be included as a query parameter.
28
+ # The server responds with a 202 Accepted and then sends the actual JSON-RPC response/error
29
+ # asynchronously over the client's established SSE stream.
30
+ #
31
+ # @example Basic Usage with a Server
32
+ # server = VectorMCP::Server.new("my-sse-server")
33
+ # # ... register tools, resources, prompts ...
34
+ # transport = VectorMCP::Transport::SSE.new(server, port: 8080)
35
+ # server.run(transport: transport) # or transport.run if server not managing transport lifecycle
36
+ #
37
+ # @attr_reader logger [Logger] The logger instance, shared with the server.
38
+ # @attr_reader server [VectorMCP::Server] The server instance this transport is bound to.
39
+ # @attr_reader host [String] The hostname or IP address the server will bind to.
40
+ # @attr_reader port [Integer] The port number the server will listen on.
41
+ # @attr_reader path_prefix [String] The base URL path for MCP endpoints (e.g., "/mcp").
42
+ class SSE
43
+ attr_reader :logger, :server, :host, :port, :path_prefix
44
+
45
+ # Internal structure to hold client connection state, including its unique ID,
46
+ # a message queue for outbound messages, and the Async task managing its stream.
47
+ # @!attribute id [r] String The unique ID for this client connection (session_id).
48
+ # @!attribute queue [r] Async::Queue The queue for messages to be sent to this client.
49
+ # @!attribute task [rw] Async::Task The task managing the SSE stream for this client.
50
+ ClientConnection = Struct.new(:id, :queue, :task)
51
+
52
+ # Initializes a new SSE transport.
53
+ #
54
+ # @param server [VectorMCP::Server] The server instance that will handle messages.
55
+ # @param options [Hash] Configuration options for the transport.
56
+ # @option options [String] :host ("localhost") The hostname or IP to bind to.
57
+ # @option options [Integer] :port (8000) The port to listen on.
58
+ # @option options [String] :path_prefix ("/mcp") The base path for HTTP endpoints.
59
+ def initialize(server, options = {})
60
+ @server = server
61
+ @logger = server.logger
62
+ @host = options[:host] || "localhost"
63
+ @port = options[:port] || 8000
64
+ prefix = options[:path_prefix] || "/mcp"
65
+ @path_prefix = prefix.start_with?("/") ? prefix : "/#{prefix}"
66
+ @path_prefix = @path_prefix.delete_suffix("/")
67
+ @sse_path = "#{@path_prefix}/sse"
68
+ @message_path = "#{@path_prefix}/message"
69
+
70
+ @clients = {} # Thread-safe storage: session_id -> ClientConnection
71
+ @clients_mutex = Mutex.new
72
+ @session = nil # Global session for this transport instance, initialized in run
73
+ logger.debug { "SSE Transport initialized with prefix: #{@path_prefix}, SSE path: #{@sse_path}, Message path: #{@message_path}" }
74
+ end
75
+
76
+ # Starts the SSE transport, creating a shared session and launching the Falcon server.
77
+ # This method will block until the server is stopped (e.g., via SIGINT/SIGTERM).
78
+ #
79
+ # @return [void]
80
+ # @raise [StandardError] if there's a fatal error during server startup.
81
+ def run
82
+ logger.info("Starting server with async SSE transport on #{@host}:#{@port}")
83
+ create_session
84
+ start_async_server
85
+ rescue StandardError => e
86
+ handle_fatal_error(e) # Logs and exits
87
+ end
88
+
89
+ # --- Rack-compatible #call method ---
90
+
91
+ # Handles incoming HTTP requests. This is the entry point for the Rack application.
92
+ # It routes requests to the appropriate handler based on the path.
93
+ #
94
+ # @param env [Hash, Async::HTTP::Request] The Rack environment hash or an Async HTTP request object.
95
+ # @return [Array(Integer, Hash, Object)] A standard Rack response triplet: [status, headers, body].
96
+ # The body is typically an `Async::HTTP::Body::Writable` for SSE or an Array of strings.
97
+ def call(env)
98
+ start_time = Time.now
99
+ path, http_method = extract_path_and_method(env)
100
+ logger.info "Received #{http_method} request for #{path}"
101
+
102
+ status, headers, body = route_request(path, env)
103
+
104
+ log_response(http_method, path, start_time, status)
105
+ [status, headers, body]
106
+ rescue StandardError => e
107
+ # Generic error handling for issues within the call chain itself
108
+ handle_call_error(http_method, path, e)
109
+ end
110
+
111
+ # --- Public methods for Server to send notifications ---
112
+
113
+ # Sends a JSON-RPC notification to a specific client session via its SSE stream.
114
+ #
115
+ # @param session_id [String] The ID of the client session to send the notification to.
116
+ # @param method [String] The method name of the notification.
117
+ # @param params [Hash, Array, nil] The parameters for the notification (optional).
118
+ # @return [Boolean] True if the message was successfully enqueued, false otherwise (e.g., client not found).
119
+ def send_notification(session_id, method, params = nil)
120
+ message = { jsonrpc: "2.0", method: method }
121
+ message[:params] = params if params
122
+ enqueue_message(session_id, message)
123
+ end
124
+
125
+ # Broadcasts a JSON-RPC notification to all currently connected client sessions.
126
+ #
127
+ # @param method [String] The method name of the notification.
128
+ # @param params [Hash, Array, nil] The parameters for the notification (optional).
129
+ # @return [void]
130
+ def broadcast_notification(method, params = nil)
131
+ logger.debug { "Broadcasting notification '#{method}' to #{@clients.size} client(s)" }
132
+ @clients_mutex.synchronize do
133
+ @clients.each_key do |sid|
134
+ send_notification(sid, method, params)
135
+ end
136
+ end
137
+ end
138
+
139
+ # Provides compatibility for tests that expect a `build_rack_app` helper.
140
+ # Since the transport itself is a Rack app (defines `#call`), it returns `self`.
141
+ #
142
+ # @param session [VectorMCP::Session, nil] An optional session to persist for testing.
143
+ # @return [self] The transport instance itself.
144
+ def build_rack_app(session = nil)
145
+ @session = session if session # Used by some tests to inject a specific session
146
+ self
147
+ end
148
+
149
+ # --- Private methods ---
150
+ private
151
+
152
+ # --- Initialization and Server Lifecycle Helpers ---
153
+
154
+ # Creates a single, shared {VectorMCP::Session} instance for this transport run.
155
+ # All client interactions will use this session context.
156
+ # @api private
157
+ # @return [void]
158
+ def create_session
159
+ @session = VectorMCP::Session.new(
160
+ server_info: server.server_info,
161
+ server_capabilities: server.server_capabilities,
162
+ protocol_version: server.protocol_version
163
+ )
164
+ end
165
+
166
+ # Starts the Falcon async HTTP server.
167
+ # @api private
168
+ # @return [void]
169
+ def start_async_server
170
+ endpoint = Falcon::Endpoint.parse("http://#{@host}:#{@port}")
171
+ app = self # The transport instance itself is the Rack app
172
+
173
+ Async do |task|
174
+ setup_signal_traps(task)
175
+ logger.info("Falcon server starting on #{endpoint.url}")
176
+ falcon_server = Falcon::Server.new(Falcon::Server.middleware(app), endpoint)
177
+ falcon_server.run # This blocks until server stops
178
+ logger.info("Falcon server stopped.")
179
+ ensure
180
+ cleanup_clients
181
+ @session = nil # Clear the session on shutdown
182
+ logger.info("SSE transport and resources shut down.")
183
+ end
184
+ end
185
+
186
+ # Sets up POSIX signal traps for graceful server shutdown (INT, TERM).
187
+ # @api private
188
+ # @param task [Async::Task] The parent async task to stop on signal.
189
+ # @return [void]
190
+ def setup_signal_traps(task)
191
+ task.async do
192
+ trap(:INT) do
193
+ logger.info("SIGINT received, stopping server...")
194
+ task.stop
195
+ end
196
+ trap(:TERM) do
197
+ logger.info("SIGTERM received, stopping server...")
198
+ task.stop
199
+ end
200
+ end
201
+ end
202
+
203
+ # Cleans up resources for all connected clients on server shutdown.
204
+ # Closes their message queues and stops their async tasks.
205
+ # @api private
206
+ # @return [void]
207
+ def cleanup_clients
208
+ @clients_mutex.synchronize do
209
+ logger.info("Cleaning up #{@clients.size} client connection(s)...")
210
+ @clients.each_value do |conn|
211
+ conn.queue&.close if conn.queue.respond_to?(:close)
212
+ conn.task&.stop # Attempt to stop the client's streaming task
213
+ end
214
+ @clients.clear
215
+ end
216
+ end
217
+
218
+ # Handles fatal errors during server startup or main run loop. Logs and exits.
219
+ # @api private
220
+ # @param error [StandardError] The fatal error.
221
+ # @return [void] This method calls `exit(1)`.
222
+ def handle_fatal_error(error)
223
+ logger.fatal("Fatal error in SSE transport: #{error.message}\n#{error.backtrace.join("\n")}")
224
+ exit(1)
225
+ end
226
+
227
+ # --- HTTP Request Routing and Basic Handling ---
228
+
229
+ # Extracts the request path and HTTP method from the Rack `env` or `Async::HTTP::Request`.
230
+ # @api private
231
+ # @param env [Hash, Async::HTTP::Request] The request environment.
232
+ # @return [Array(String, String)] The request path and HTTP method.
233
+ def extract_path_and_method(env)
234
+ if env.is_a?(Hash) # Rack env
235
+ [env["PATH_INFO"], env["REQUEST_METHOD"]]
236
+ else # Async::HTTP::Request
237
+ [env.path, env.method]
238
+ end
239
+ end
240
+
241
+ # Routes an incoming request to the appropriate handler based on its path.
242
+ # @api private
243
+ # @param path [String] The request path.
244
+ # @param env [Hash, Async::HTTP::Request] The request environment.
245
+ # @return [Array] A Rack response triplet.
246
+ def route_request(path, env)
247
+ case path
248
+ when @sse_path
249
+ handle_sse_connection(env, @session)
250
+ when @message_path
251
+ handle_message_post(env, @session)
252
+ when "/" # Root path, useful for health checks
253
+ [200, { "Content-Type" => "text/plain" }, ["VectorMCP Server OK"]]
254
+ else
255
+ [404, { "Content-Type" => "text/plain" }, ["Not Found"]]
256
+ end
257
+ end
258
+
259
+ # Logs the response details including status, method, path, and duration.
260
+ # @api private
261
+ # @param method [String] The HTTP method of the request.
262
+ # @param path [String] The request path.
263
+ # @param start_time [Time] The time the request processing started.
264
+ # @param status [Integer] The HTTP status code of the response.
265
+ # @return [void]
266
+ def log_response(method, path, start_time, status)
267
+ duration = format("%.4f", Time.now - start_time)
268
+ logger.info "Responded #{status} to #{method} #{path} in #{duration}s"
269
+ end
270
+
271
+ # Generic error handler for exceptions occurring within the `#call` method's request processing.
272
+ # @api private
273
+ # @param method [String, nil] The HTTP method, if known.
274
+ # @param path [String, nil] The request path, if known.
275
+ # @param error [StandardError] The error that occurred.
276
+ # @return [Array] A 500 Internal Server Error Rack response.
277
+ def handle_call_error(method, path, error)
278
+ error_context = method || "UNKNOWN_METHOD"
279
+ path_context = path || "UNKNOWN_PATH"
280
+ backtrace = error.backtrace.join("\n")
281
+ logger.error("Error during SSE request processing for #{error_context} #{path_context}: #{error.message}\n#{backtrace}")
282
+ # Optional: for local debugging, print to console too
283
+ begin
284
+ warn "[DEBUG-SSE-Transport] Exception in #call: #{error.class}: #{error.message}\n\t#{error.backtrace.join("\n\t")}"
285
+ rescue StandardError
286
+ nil
287
+ end
288
+ [500, { "Content-Type" => "text/plain", "connection" => "close" }, ["Internal Server Error"]]
289
+ end
290
+
291
+ # --- SSE Connection Handling (`/sse` endpoint) ---
292
+
293
+ # Handles a new client connection to the SSE endpoint.
294
+ # Validates it's a GET request, sets up the SSE stream, and sends the initial endpoint event.
295
+ # @api private
296
+ # @param env [Hash, Async::HTTP::Request] The request environment.
297
+ # @param _session [VectorMCP::Session] The shared server session (currently unused by this method but passed for consistency).
298
+ # @return [Array] A Rack response triplet for the SSE stream (200 OK with SSE headers).
299
+ # session is the shared server session, not a per-client one here
300
+ def handle_sse_connection(env, _session)
301
+ return invalid_method_response(env) unless get_request?(env)
302
+
303
+ session_id = SecureRandom.uuid # This is the *client's* unique session ID for this SSE connection
304
+ client_queue = Async::Queue.new
305
+
306
+ headers = default_sse_headers
307
+ message_post_url = build_post_url(session_id) # URL for this client to POST messages to
308
+
309
+ logger.info("New SSE client connected: #{session_id}")
310
+ logger.debug("Client #{session_id} should POST messages to: #{message_post_url}")
311
+
312
+ client_conn, body = create_client_connection(session_id, client_queue)
313
+ stream_client_messages(client_conn, client_queue, body, message_post_url) # Starts async task
314
+
315
+ [200, headers, body] # Return SSE stream
316
+ end
317
+
318
+ # Helper to check if the request is a GET.
319
+ # @api private
320
+ def get_request?(env)
321
+ request_method(env) == "GET"
322
+ end
323
+
324
+ # Returns a 405 Method Not Allowed response, used by SSE endpoint for non-GET requests.
325
+ # @api private
326
+ def invalid_method_response(env)
327
+ method = request_method(env)
328
+ logger.warn("Received non-GET request on SSE endpoint from #{begin
329
+ env["REMOTE_ADDR"]
330
+ rescue StandardError
331
+ "unknown"
332
+ end}: #{method} #{begin
333
+ env["PATH_INFO"]
334
+ rescue StandardError
335
+ ""
336
+ end}")
337
+ [405, { "Content-Type" => "text/plain", "Allow" => "GET" }, ["Method Not Allowed. Only GET is supported for SSE endpoint."]]
338
+ end
339
+
340
+ # Provides default HTTP headers for an SSE stream.
341
+ # @api private
342
+ # @return [Hash] SSE-specific HTTP headers.
343
+ def default_sse_headers
344
+ {
345
+ "Content-Type" => "text/event-stream",
346
+ "Cache-Control" => "no-cache", # Important for SSE
347
+ "Connection" => "keep-alive",
348
+ "X-Accel-Buffering" => "no" # Disable buffering in proxies like Nginx
349
+ }
350
+ end
351
+
352
+ # Constructs the unique URL that a specific client should use to POST messages back to the server.
353
+ # @api private
354
+ # @param session_id [String] The client's unique session ID for the SSE connection.
355
+ # @return [String] The full URL for posting messages.
356
+ def build_post_url(session_id)
357
+ # Assuming server runs behind a proxy that sets X-Forwarded-Proto and X-Forwarded-Host
358
+ # For simplicity, this example constructs a relative path.
359
+ # In a production setup, you might want to construct an absolute URL.
360
+ "#{@message_path}?session_id=#{session_id}"
361
+ end
362
+
363
+ # Creates a new {ClientConnection} struct and an `Async::HTTP::Body::Writable` for the SSE stream.
364
+ # @api private
365
+ # @param session_id [String] The client's unique session ID.
366
+ # @param client_queue [Async::Queue] The message queue for this client.
367
+ # @return [Array(ClientConnection, Async::HTTP::Body::Writable)] The connection object and writable body.
368
+ def create_client_connection(session_id, client_queue)
369
+ client_conn = ClientConnection.new(session_id, client_queue, nil) # Task will be set later
370
+ body = Async::HTTP::Body::Writable.new # For streaming SSE events
371
+ [client_conn, body]
372
+ end
373
+
374
+ # Starts an asynchronous task to stream messages from a client's queue to its SSE connection.
375
+ # Handles client disconnections and cleans up resources.
376
+ # @api private
377
+ # @param client_conn [ClientConnection] The client's connection object.
378
+ # @param queue [Async::Queue] The client's message queue.
379
+ # @param body [Async::HTTP::Body::Writable] The writable body for the SSE stream.
380
+ # @param post_url [String] The URL for the client to POST messages to (sent as initial event).
381
+ # @return [void]
382
+ def stream_client_messages(client_conn, queue, body, post_url)
383
+ Async do |task| # This task manages the lifecycle of one SSE client connection
384
+ prepare_client_stream(client_conn, task)
385
+ begin
386
+ send_endpoint_event(body, post_url) # First, tell client where to POST
387
+ stream_queue_messages(queue, body, client_conn) # Then, stream messages from its queue
388
+ rescue Async::Stop, IOError, Errno::EPIPE => e # Expected client disconnects
389
+ logger.info("SSE client #{client_conn.id} disconnected (#{e.class.name}: #{e.message}).")
390
+ rescue StandardError => e # Unexpected errors in this client's stream
391
+ logger.error("Error in SSE streaming task for client #{client_conn.id}: #{e.class.name}: #{e.message}\n#{e.backtrace.join("\n")}")
392
+ ensure
393
+ finalize_client_stream(body, queue, client_conn)
394
+ end
395
+ end
396
+ end
397
+
398
+ # Prepares a client stream by associating the async task and registering the client.
399
+ # @api private
400
+ def prepare_client_stream(client_conn, task)
401
+ client_conn.task = task
402
+ @clients_mutex.synchronize { @clients[client_conn.id] = client_conn }
403
+ logger.debug("SSE client stream prepared for #{client_conn.id}")
404
+ end
405
+
406
+ # Sends the initial `event: endpoint` to the client with the URL for POSTing messages.
407
+ # @api private
408
+ def send_endpoint_event(body, post_url)
409
+ logger.debug("Sending 'endpoint' event with URL: #{post_url}")
410
+ body.write("event: endpoint\ndata: #{post_url}\n\n")
411
+ end
412
+
413
+ # Continuously dequeues messages and writes them to the SSE stream.
414
+ # Blocks until the queue is closed or an error occurs.
415
+ # @api private
416
+ def stream_queue_messages(queue, body, client_conn)
417
+ logger.debug("Starting message streaming loop for SSE client #{client_conn.id}")
418
+ while (message = queue.dequeue) # Blocks until message available or queue closed
419
+ json_message = message.to_json
420
+ logger.debug { "[SSE Client: #{client_conn.id}] Sending message: #{json_message.inspect}" }
421
+ body.write("event: message\ndata: #{json_message}\n\n")
422
+ end
423
+ logger.debug("Message streaming loop ended for SSE client #{client_conn.id} (queue closed).")
424
+ end
425
+
426
+ # Finalizes a client stream by finishing the body, closing the queue, and unregistering the client.
427
+ # @api private
428
+ def finalize_client_stream(body, queue, client_conn)
429
+ body.finish unless body.finished?
430
+ queue.close if queue.respond_to?(:close) && (!queue.respond_to?(:closed?) || !queue.closed?)
431
+ @clients_mutex.synchronize { @clients.delete(client_conn.id) }
432
+ logger.info("SSE client stream finalized and resources cleaned for #{client_conn.id}")
433
+ end
434
+
435
+ # --- Message POST Handling (`/message` endpoint) ---
436
+
437
+ # Handles incoming POST requests containing JSON-RPC messages from clients.
438
+ # @api private
439
+ # @param env [Hash, Async::HTTP::Request] The request environment.
440
+ # @param session [VectorMCP::Session] The shared server session.
441
+ # @return [Array] A Rack response triplet (typically 202 Accepted or an error).
442
+ def handle_message_post(env, session)
443
+ return invalid_post_method_response(env) unless post_request?(env)
444
+
445
+ raw_path = build_raw_path(env)
446
+ session_id = extract_session_id(raw_path)
447
+ unless session_id
448
+ return error_response(nil, VectorMCP::InvalidRequestError.new("Missing session_id parameter").code,
449
+ "Missing session_id parameter")
450
+ end
451
+
452
+ client_conn = fetch_client_connection(session_id)
453
+ return error_response(nil, VectorMCP::NotFoundError.new("Invalid session_id").code, "Invalid session_id") unless client_conn
454
+
455
+ request_body_str = read_request_body(env, session_id)
456
+ if request_body_str.nil? || request_body_str.empty?
457
+ return error_response(nil, VectorMCP::InvalidRequestError.new("Request body is empty or unreadable").code,
458
+ "Request body is empty or unreadable")
459
+ end
460
+
461
+ process_post_message(request_body_str, client_conn, session, session_id)
462
+ end
463
+
464
+ # Helper to check if the request is a POST.
465
+ # @api private
466
+ def post_request?(env)
467
+ request_method(env) == "POST"
468
+ end
469
+
470
+ # Returns a 405 Method Not Allowed response for non-POST requests to the message endpoint.
471
+ # @api private
472
+ def invalid_post_method_response(env)
473
+ method = request_method(env)
474
+ logger.warn("Received non-POST request on message endpoint from #{begin
475
+ env["REMOTE_ADDR"]
476
+ rescue StandardError
477
+ "unknown"
478
+ end}: #{method} #{begin
479
+ env["PATH_INFO"]
480
+ rescue StandardError
481
+ ""
482
+ end}")
483
+ [405, { "Content-Type" => "text/plain", "Allow" => "POST" }, ["Method Not Allowed"]]
484
+ end
485
+
486
+ # Builds the full raw path including query string from the request environment.
487
+ # @api private
488
+ # @param env [Hash, Async::HTTP::Request] The request environment.
489
+ # @return [String] The raw path with query string.
490
+ def build_raw_path(env)
491
+ if env.is_a?(Hash) # Rack env
492
+ query_string = env["QUERY_STRING"]
493
+ query_suffix = query_string && !query_string.empty? ? "?#{query_string}" : ""
494
+ env["PATH_INFO"] + query_suffix
495
+ else # Async::HTTP::Request
496
+ env.path # Async::HTTP::Request.path includes query string
497
+ end
498
+ end
499
+
500
+ # Extracts the `session_id` query parameter from a raw path string.
501
+ # @api private
502
+ # @param raw_path [String] The path string, possibly including a query string.
503
+ # @return [String, nil] The extracted session_id, or nil if not found.
504
+ def extract_session_id(raw_path)
505
+ query_str = URI(raw_path).query
506
+ return nil unless query_str
507
+
508
+ URI.decode_www_form(query_str).to_h["session_id"]
509
+ end
510
+
511
+ # Fetches an active {ClientConnection} based on session_id.
512
+ # @api private
513
+ # @param session_id [String] The client session ID.
514
+ # @return [ClientConnection, nil] The connection object if found, otherwise nil.
515
+ def fetch_client_connection(session_id)
516
+ @clients_mutex.synchronize { @clients[session_id] }
517
+ end
518
+
519
+ # Reads the request body from the environment.
520
+ # @api private
521
+ # @param env [Hash, Async::HTTP::Request] The request environment.
522
+ # @param session_id [String] The client session ID (for logging).
523
+ # @return [String, nil] The request body as a string, or nil if unreadable/empty.
524
+ def read_request_body(env, session_id)
525
+ source = env.is_a?(Hash) ? env["rack.input"] : env.body # env.body is an Async::HTTP::Body
526
+ body_str = source&.read
527
+ logger.error("[POST Client: #{session_id}] Request body is empty or could not be read.") if body_str.nil? || body_str.empty?
528
+ body_str
529
+ end
530
+
531
+ # Processes the JSON-RPC message from a POST request.
532
+ # Parses, handles via server, and enqueues response/error to the client's SSE stream.
533
+ # @api private
534
+ # @return [Array] A Rack response triplet (typically 202 Accepted).
535
+ # rubocop:disable Metrics/AbcSize
536
+ def process_post_message(body_str, client_conn, session, _session_id)
537
+ message = parse_json_body(body_str, client_conn.id) # Use client_conn.id for logging consistency
538
+ # parse_json_body returns an error triplet if parsing fails and error was enqueued
539
+ return message if message.is_a?(Array) && message.size == 3
540
+
541
+ # If message is valid JSON, proceed to handle it with the server
542
+ response_data = server.handle_message(message, session, client_conn.id) # Pass client_conn.id as session_id for server context
543
+
544
+ # If handle_message returns data, it was a request needing a response.
545
+ # If it was a notification, handle_message would typically return nil or not be called for POSTs.
546
+ # Assuming POSTs are always requests needing a response pushed via SSE.
547
+ if message["id"] # It's a request
548
+ enqueue_formatted_response(client_conn, message["id"], response_data)
549
+ else # It's a notification (client shouldn't POST notifications, but handle defensively)
550
+ logger.warn("[POST Client: #{client_conn.id}] Received a notification via POST. Ignoring response_data for notifications.")
551
+ end
552
+
553
+ # Always return 202 Accepted for valid POSTs that are being processed asynchronously.
554
+ [202, { "Content-Type" => "application/json" }, [{ status: "accepted", id: message["id"] }.to_json]]
555
+ rescue VectorMCP::ProtocolError => e
556
+ # Errors from server.handle_message (application-level protocol errors)
557
+ # rubocop:disable Layout/LineLength
558
+ logger.error("[POST Client: #{client_conn.id}] Protocol Error during message handling: #{e.message} (Code: #{e.code}), Details: #{e.details.inspect}")
559
+ # rubocop:enable Layout/LineLength
560
+ request_id = e.request_id || message&.fetch("id", nil)
561
+ enqueue_error(client_conn, request_id, e.code, e.message, e.details)
562
+ # Return an appropriate HTTP error response for the POST request itself
563
+ error_response(request_id, e.code, e.message, e.details)
564
+ rescue StandardError => e
565
+ # Unexpected errors during server.handle_message
566
+ logger.error("[POST Client: #{client_conn.id}] Unhandled Error during message processing: #{e.message}\n#{e.backtrace.join("\n")}")
567
+ request_id = message&.fetch("id", nil)
568
+ details = { details: e.message }
569
+ enqueue_error(client_conn, request_id, -32_603, "Internal server error", details)
570
+ # Return a 500 Internal Server Error response for the POST request
571
+ error_response(request_id, -32_603, "Internal server error", details)
572
+ end
573
+ # rubocop:enable Metrics/AbcSize
574
+
575
+ # Parses the JSON body of a POST request. Handles JSON::ParserError by enqueuing an error
576
+ # to the client and returning a Rack error response triplet.
577
+ # @api private
578
+ # @param body_str [String] The JSON string from the request body.
579
+ # @param client_session_id [String] The client's session ID for logging and error enqueuing.
580
+ # @return [Hash, Array] Parsed JSON message as a Hash, or a Rack error triplet if parsing failed.
581
+ def parse_json_body(body_str, client_session_id)
582
+ JSON.parse(body_str)
583
+ rescue JSON::ParserError => e
584
+ logger.error("[POST Client: #{client_session_id}] JSON Parse Error: #{e.message} for body: #{body_str.inspect}")
585
+ # Try to get original request ID for error response, even from invalid JSON
586
+ malformed_id = VectorMCP::Util.extract_id_from_invalid_json(body_str)
587
+ # Enqueue error to client's SSE stream
588
+ target_client = fetch_client_connection(client_session_id) # Re-fetch in case it's needed
589
+ enqueue_error(target_client, malformed_id, -32_700, "Parse error") if target_client
590
+ # Return a Rack error response for the POST itself
591
+ error_response(malformed_id, -32_700, "Parse error")
592
+ end
593
+
594
+ # --- Message Enqueuing and Formatting Helpers (Private) ---
595
+
596
+ # Enqueues a message hash to a specific client's outbound queue.
597
+ # @api private
598
+ # @param session_id [String] The target client's session ID.
599
+ # @param message_hash [Hash] The JSON-RPC message (request, response, or notification) to send.
600
+ # @return [Boolean] True if enqueued, false if client not found.
601
+ def enqueue_message(session_id, message_hash)
602
+ client_conn = @clients_mutex.synchronize { @clients[session_id] }
603
+ if client_conn&.queue && (!client_conn.queue.respond_to?(:closed?) || !client_conn.queue.closed?)
604
+ logger.debug { "[SSE Enqueue Client: #{session_id}] Queuing message: #{message_hash.inspect}" }
605
+ client_conn.queue.enqueue(message_hash)
606
+ true
607
+ else
608
+ logger.warn("Cannot enqueue message for session_id #{session_id}: Client queue not found or closed.")
609
+ false
610
+ end
611
+ end
612
+
613
+ # Formats a successful JSON-RPC response and enqueues it.
614
+ # @api private
615
+ def enqueue_formatted_response(client_conn, request_id, result_data)
616
+ response = { jsonrpc: "2.0", id: request_id, result: result_data }
617
+ enqueue_message(client_conn.id, response)
618
+ end
619
+
620
+ # Formats a JSON-RPC error and enqueues it.
621
+ # @api private
622
+ def enqueue_error(client_conn, request_id, code, message, data = nil)
623
+ error_payload = format_error_payload(code, message, data)
624
+ error_msg = { jsonrpc: "2.0", id: request_id, error: error_payload }
625
+ enqueue_message(client_conn.id, error_msg) if client_conn # Only enqueue if client connection is valid
626
+ end
627
+
628
+ # Formats the `error` object for a JSON-RPC message.
629
+ # @api private
630
+ # @return [Hash] The error payload.
631
+ def format_error_payload(code, message, data = nil)
632
+ payload = { code: code, message: message }
633
+ payload[:data] = data if data # `data` is optional
634
+ payload
635
+ end
636
+
637
+ # Formats the body of an HTTP error response (for 4xx/5xx replies to POSTs).
638
+ # @api private
639
+ # @return [String] JSON string representing the error.
640
+ def format_error_body(id, code, message, data = nil)
641
+ { jsonrpc: "2.0", id: id, error: format_error_payload(code, message, data) }.to_json
642
+ end
643
+
644
+ # Creates a full Rack error response triplet (status, headers, body) for HTTP errors.
645
+ # @api private
646
+ # @return [Array] The Rack response.
647
+ def error_response(id, code, message, data = nil)
648
+ status = case code
649
+ when -32_700, -32_600, -32_602 then 400 # ParseError, InvalidRequest, InvalidParams
650
+ when -32_601, -32_001 then 404 # MethodNotFound, NotFoundError (custom)
651
+ else 500 # InternalError, ServerError, or any other
652
+ end
653
+ [status, { "Content-Type" => "application/json" }, [format_error_body(id, code, message, data)]]
654
+ end
655
+
656
+ # Generic helper to extract HTTP method, used by get_request? and post_request?
657
+ # @api private
658
+ def request_method(env)
659
+ env.is_a?(Hash) ? env["REQUEST_METHOD"] : env.method
660
+ end
661
+ end
662
+ end
663
+ end