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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +210 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/vector_mcp/definitions.rb +81 -0
- data/lib/vector_mcp/errors.rb +138 -0
- data/lib/vector_mcp/handlers/core.rb +289 -0
- data/lib/vector_mcp/server.rb +521 -0
- data/lib/vector_mcp/session.rb +67 -0
- data/lib/vector_mcp/transport/sse.rb +663 -0
- data/lib/vector_mcp/transport/stdio.rb +258 -0
- data/lib/vector_mcp/util.rb +113 -0
- data/lib/vector_mcp/version.rb +6 -0
- data/lib/vector_mcp.rb +65 -0
- metadata +131 -0
@@ -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
|