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.
- checksums.yaml +4 -4
- data/README.md +141 -3
- data/lib/vector_mcp/definitions.rb +3 -1
- data/lib/vector_mcp/handlers/core.rb +43 -0
- data/lib/vector_mcp/server/registry.rb +24 -0
- data/lib/vector_mcp/transport/sse/client_connection.rb +113 -0
- data/lib/vector_mcp/transport/sse/message_handler.rb +166 -0
- data/lib/vector_mcp/transport/sse/puma_config.rb +77 -0
- data/lib/vector_mcp/transport/sse/stream_manager.rb +92 -0
- data/lib/vector_mcp/transport/sse.rb +119 -460
- data/lib/vector_mcp/version.rb +1 -1
- metadata +20 -30
@@ -2,22 +2,23 @@
|
|
2
2
|
|
3
3
|
require "json"
|
4
4
|
require "securerandom"
|
5
|
-
require "
|
6
|
-
require "
|
7
|
-
require "
|
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"
|
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
|
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)
|
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
|
-
|
71
|
-
@
|
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
|
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
|
79
|
+
logger.info("Starting server with Puma SSE transport on #{@host}:#{@port}")
|
83
80
|
create_session
|
84
|
-
|
81
|
+
start_puma_server
|
85
82
|
rescue StandardError => e
|
86
|
-
handle_fatal_error(e)
|
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
|
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
|
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
|
-
|
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
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
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
|
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
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
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
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
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
|
-
@
|
209
|
-
|
210
|
-
|
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.
|
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
|
214
|
+
handle_sse_connection(env)
|
250
215
|
when @message_path
|
251
|
-
handle_message_post(env
|
252
|
-
when "/"
|
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
|
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
|
-
|
295
|
-
|
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
|
-
|
325
|
-
|
326
|
-
|
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
|
-
|
341
|
-
|
342
|
-
|
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
|
-
|
414
|
-
|
415
|
-
|
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
|
-
|
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
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
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 =
|
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
|
-
|
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
|
465
|
-
|
466
|
-
|
467
|
-
|
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 =
|
474
|
-
logger.warn("Received non-POST request on message endpoint
|
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
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
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
|
-
|
621
|
-
|
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
|
-
|
629
|
-
|
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
|
-
|
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
|
650
|
-
when -32_601, -32_001 then 404
|
651
|
-
else 500
|
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
|
-
|
654
|
-
|
655
|
-
|
656
|
-
|
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
|