vector_mcp 0.2.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.
@@ -2,22 +2,23 @@
2
2
 
3
3
  require "json"
4
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"
5
+ require "puma"
6
+ require "rack"
7
+ require "concurrent-ruby"
11
8
 
12
9
  require_relative "../errors"
13
10
  require_relative "../util"
14
- require_relative "../session" # Make sure session is loaded
11
+ require_relative "../session"
12
+ require_relative "sse/client_connection"
13
+ require_relative "sse/stream_manager"
14
+ require_relative "sse/message_handler"
15
+ require_relative "sse/puma_config"
15
16
 
16
17
  module VectorMCP
17
18
  module Transport
18
19
  # Implements the Model Context Protocol transport over HTTP using Server-Sent Events (SSE)
19
20
  # 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
+ # This transport uses Puma as the HTTP server with Ruby threading for concurrency.
21
22
  #
22
23
  # It provides two main HTTP endpoints:
23
24
  # 1. SSE Endpoint (`<path_prefix>/sse`): Clients connect here via GET to establish an SSE stream.
@@ -32,7 +33,7 @@ module VectorMCP
32
33
  # server = VectorMCP::Server.new("my-sse-server")
33
34
  # # ... register tools, resources, prompts ...
34
35
  # transport = VectorMCP::Transport::SSE.new(server, port: 8080)
35
- # server.run(transport: transport) # or transport.run if server not managing transport lifecycle
36
+ # server.run(transport: transport)
36
37
  #
37
38
  # @attr_reader logger [Logger] The logger instance, shared with the server.
38
39
  # @attr_reader server [VectorMCP::Server] The server instance this transport is bound to.
@@ -42,13 +43,6 @@ module VectorMCP
42
43
  class SSE
43
44
  attr_reader :logger, :server, :host, :port, :path_prefix
44
45
 
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
46
  # Initializes a new SSE transport.
53
47
  #
54
48
  # @param server [VectorMCP::Server] The server instance that will handle messages.
@@ -67,23 +61,26 @@ module VectorMCP
67
61
  @sse_path = "#{@path_prefix}/sse"
68
62
  @message_path = "#{@path_prefix}/message"
69
63
 
70
- @clients = {} # Thread-safe storage: session_id -> ClientConnection
71
- @clients_mutex = Mutex.new
64
+ # Thread-safe client storage using concurrent-ruby
65
+ @clients = Concurrent::Hash.new
72
66
  @session = nil # Global session for this transport instance, initialized in run
67
+ @puma_server = nil
68
+ @running = false
69
+
73
70
  logger.debug { "SSE Transport initialized with prefix: #{@path_prefix}, SSE path: #{@sse_path}, Message path: #{@message_path}" }
74
71
  end
75
72
 
76
- # Starts the SSE transport, creating a shared session and launching the Falcon server.
73
+ # Starts the SSE transport, creating a shared session and launching the Puma server.
77
74
  # This method will block until the server is stopped (e.g., via SIGINT/SIGTERM).
78
75
  #
79
76
  # @return [void]
80
77
  # @raise [StandardError] if there's a fatal error during server startup.
81
78
  def run
82
- logger.info("Starting server with async SSE transport on #{@host}:#{@port}")
79
+ logger.info("Starting server with Puma SSE transport on #{@host}:#{@port}")
83
80
  create_session
84
- start_async_server
81
+ start_puma_server
85
82
  rescue StandardError => e
86
- handle_fatal_error(e) # Logs and exits
83
+ handle_fatal_error(e)
87
84
  end
88
85
 
89
86
  # --- Rack-compatible #call method ---
@@ -91,12 +88,12 @@ module VectorMCP
91
88
  # Handles incoming HTTP requests. This is the entry point for the Rack application.
92
89
  # It routes requests to the appropriate handler based on the path.
93
90
  #
94
- # @param env [Hash, Async::HTTP::Request] The Rack environment hash or an Async HTTP request object.
91
+ # @param env [Hash] The Rack environment hash.
95
92
  # @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
93
  def call(env)
98
94
  start_time = Time.now
99
- path, http_method = extract_path_and_method(env)
95
+ path = env["PATH_INFO"]
96
+ http_method = env["REQUEST_METHOD"]
100
97
  logger.info "Received #{http_method} request for #{path}"
101
98
 
102
99
  status, headers, body = route_request(path, env)
@@ -104,7 +101,6 @@ module VectorMCP
104
101
  log_response(http_method, path, start_time, status)
105
102
  [status, headers, body]
106
103
  rescue StandardError => e
107
- # Generic error handling for issues within the call chain itself
108
104
  handle_call_error(http_method, path, e)
109
105
  end
110
106
 
@@ -119,7 +115,11 @@ module VectorMCP
119
115
  def send_notification(session_id, method, params = nil)
120
116
  message = { jsonrpc: "2.0", method: method }
121
117
  message[:params] = params if params
122
- enqueue_message(session_id, message)
118
+
119
+ client_conn = @clients[session_id]
120
+ return false unless client_conn
121
+
122
+ StreamManager.enqueue_message(client_conn, message)
123
123
  end
124
124
 
125
125
  # Broadcasts a JSON-RPC notification to all currently connected client sessions.
@@ -129,10 +129,11 @@ module VectorMCP
129
129
  # @return [void]
130
130
  def broadcast_notification(method, params = nil)
131
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
132
+ message = { jsonrpc: "2.0", method: method }
133
+ message[:params] = params if params
134
+
135
+ @clients.each_value do |client_conn|
136
+ StreamManager.enqueue_message(client_conn, message)
136
137
  end
137
138
  end
138
139
 
@@ -142,114 +143,78 @@ module VectorMCP
142
143
  # @param session [VectorMCP::Session, nil] An optional session to persist for testing.
143
144
  # @return [self] The transport instance itself.
144
145
  def build_rack_app(session = nil)
145
- @session = session if session # Used by some tests to inject a specific session
146
+ @session = session if session
146
147
  self
147
148
  end
148
149
 
150
+ # Stops the transport and cleans up resources
151
+ def stop
152
+ @running = false
153
+ cleanup_clients
154
+ @puma_server&.stop
155
+ logger.info("SSE transport stopped")
156
+ end
157
+
149
158
  # --- Private methods ---
150
159
  private
151
160
 
152
- # --- Initialization and Server Lifecycle Helpers ---
153
-
154
161
  # Creates a single, shared {VectorMCP::Session} instance for this transport run.
155
162
  # All client interactions will use this session context.
156
- # @api private
157
- # @return [void]
158
163
  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
+ @session = VectorMCP::Session.new(server, self, id: SecureRandom.uuid)
164
165
  end
165
166
 
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
167
+ # Starts the Puma HTTP server.
168
+ def start_puma_server
169
+ @puma_server = Puma::Server.new(build_rack_app)
170
+ puma_config = PumaConfig.new(@host, @port, logger)
171
+ puma_config.configure(@puma_server)
172
+
173
+ @running = true
174
+ setup_signal_traps
175
+
176
+ logger.info("Puma server starting on #{@host}:#{@port}")
177
+ @puma_server.run.join # This blocks until server stops
178
+ logger.info("Puma server stopped.")
179
+ ensure
180
+ cleanup_clients
181
+ @session = nil
182
+ logger.info("SSE transport and resources shut down.")
184
183
  end
185
184
 
186
185
  # 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
186
+ def setup_signal_traps
187
+ Signal.trap("INT") do
188
+ logger.info("SIGINT received, stopping server...")
189
+ stop
190
+ end
191
+ Signal.trap("TERM") do
192
+ logger.info("SIGTERM received, stopping server...")
193
+ stop
200
194
  end
201
195
  end
202
196
 
203
197
  # 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
198
  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
199
+ logger.info("Cleaning up #{@clients.size} client connection(s)...")
200
+ @clients.each_value(&:close)
201
+ @clients.clear
216
202
  end
217
203
 
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)`.
204
+ # Handles fatal errors during server startup or main run loop.
222
205
  def handle_fatal_error(error)
223
206
  logger.fatal("Fatal error in SSE transport: #{error.message}\n#{error.backtrace.join("\n")}")
224
207
  exit(1)
225
208
  end
226
209
 
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
210
  # 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
211
  def route_request(path, env)
247
212
  case path
248
213
  when @sse_path
249
- handle_sse_connection(env, @session)
214
+ handle_sse_connection(env)
250
215
  when @message_path
251
- handle_message_post(env, @session)
252
- when "/" # Root path, useful for health checks
216
+ handle_message_post(env)
217
+ when "/"
253
218
  [200, { "Content-Type" => "text/plain" }, ["VectorMCP Server OK"]]
254
219
  else
255
220
  [404, { "Content-Type" => "text/plain" }, ["Not Found"]]
@@ -257,406 +222,100 @@ module VectorMCP
257
222
  end
258
223
 
259
224
  # 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
225
  def log_response(method, path, start_time, status)
267
226
  duration = format("%.4f", Time.now - start_time)
268
227
  logger.info "Responded #{status} to #{method} #{path} in #{duration}s"
269
228
  end
270
229
 
271
230
  # 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
231
  def handle_call_error(method, path, error)
278
232
  error_context = method || "UNKNOWN_METHOD"
279
- path_context = path || "UNKNOWN_PATH"
233
+ path_context = path || "UNKNOWN_PATH"
280
234
  backtrace = error.backtrace.join("\n")
281
235
  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
236
  [500, { "Content-Type" => "text/plain", "connection" => "close" }, ["Internal Server Error"]]
289
237
  end
290
238
 
291
- # --- SSE Connection Handling (`/sse` endpoint) ---
292
-
293
239
  # 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
240
+ def handle_sse_connection(env)
241
+ return invalid_method_response(env) unless env["REQUEST_METHOD"] == "GET"
308
242
 
243
+ session_id = SecureRandom.uuid
309
244
  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
245
 
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
246
+ # Create client connection
247
+ client_conn = ClientConnection.new(session_id, logger)
248
+ @clients[session_id] = client_conn
339
249
 
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
250
+ # Build message POST URL for this client
251
+ message_post_url = build_post_url(session_id)
252
+ logger.debug("Client #{session_id} should POST messages to: #{message_post_url}")
412
253
 
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
254
+ # Set up SSE stream
255
+ headers = sse_headers
256
+ body = StreamManager.create_sse_stream(client_conn, message_post_url, logger)
425
257
 
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}")
258
+ [200, headers, body]
433
259
  end
434
260
 
435
- # --- Message POST Handling (`/message` endpoint) ---
436
-
437
261
  # 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)
262
+ def handle_message_post(env)
263
+ return invalid_post_method_response(env) unless env["REQUEST_METHOD"] == "POST"
264
+
265
+ session_id = extract_session_id(env["QUERY_STRING"])
447
266
  unless session_id
448
267
  return error_response(nil, VectorMCP::InvalidRequestError.new("Missing session_id parameter").code,
449
268
  "Missing session_id parameter")
450
269
  end
451
270
 
452
- client_conn = fetch_client_connection(session_id)
271
+ client_conn = @clients[session_id]
453
272
  return error_response(nil, VectorMCP::NotFoundError.new("Invalid session_id").code, "Invalid session_id") unless client_conn
454
273
 
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)
274
+ MessageHandler.new(@server, @session, logger).handle_post_message(env, client_conn)
462
275
  end
463
276
 
464
- # Helper to check if the request is a POST.
465
- # @api private
466
- def post_request?(env)
467
- request_method(env) == "POST"
277
+ # Helper methods
278
+ def invalid_method_response(env)
279
+ method = env["REQUEST_METHOD"]
280
+ logger.warn("Received non-GET request on SSE endpoint: #{method}")
281
+ [405, { "Content-Type" => "text/plain", "Allow" => "GET" }, ["Method Not Allowed. Only GET is supported for SSE endpoint."]]
468
282
  end
469
283
 
470
- # Returns a 405 Method Not Allowed response for non-POST requests to the message endpoint.
471
- # @api private
472
284
  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}")
285
+ method = env["REQUEST_METHOD"]
286
+ logger.warn("Received non-POST request on message endpoint: #{method}")
483
287
  [405, { "Content-Type" => "text/plain", "Allow" => "POST" }, ["Method Not Allowed"]]
484
288
  end
485
289
 
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)
290
+ def sse_headers
291
+ {
292
+ "Content-Type" => "text/event-stream",
293
+ "Cache-Control" => "no-cache",
294
+ "Connection" => "keep-alive",
295
+ "X-Accel-Buffering" => "no"
296
+ }
618
297
  end
619
298
 
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
299
+ def build_post_url(session_id)
300
+ "#{@message_path}?session_id=#{session_id}"
626
301
  end
627
302
 
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
303
+ def extract_session_id(query_string)
304
+ return nil unless query_string
636
305
 
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
306
+ URI.decode_www_form(query_string).to_h["session_id"]
642
307
  end
643
308
 
644
- # Creates a full Rack error response triplet (status, headers, body) for HTTP errors.
645
- # @api private
646
- # @return [Array] The Rack response.
647
309
  def error_response(id, code, message, data = nil)
648
310
  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
311
+ when -32_700, -32_600, -32_602 then 400
312
+ when -32_601, -32_001 then 404
313
+ else 500
652
314
  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
315
+ error_payload = { code: code, message: message }
316
+ error_payload[:data] = data if data
317
+ body = { jsonrpc: "2.0", id: id, error: error_payload }.to_json
318
+ [status, { "Content-Type" => "application/json" }, [body]]
660
319
  end
661
320
  end
662
321
  end