vector_mcp 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,377 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "securerandom"
5
- require "puma"
6
- require "rack"
7
- require "concurrent-ruby"
8
-
9
- require_relative "../errors"
10
- require_relative "../util"
11
- require_relative "../session"
12
- require_relative "sse_session_manager"
13
- require_relative "sse/client_connection"
14
- require_relative "sse/stream_manager"
15
- require_relative "sse/message_handler"
16
- require_relative "sse/puma_config"
17
-
18
- module VectorMCP
19
- module Transport
20
- # Implements the Model Context Protocol transport over HTTP using Server-Sent Events (SSE)
21
- # for server-to-client messages and HTTP POST for client-to-server messages.
22
- # This transport uses Puma as the HTTP server with Ruby threading for concurrency.
23
- #
24
- # It provides two main HTTP endpoints:
25
- # 1. SSE Endpoint (`<path_prefix>/sse`): Clients connect here via GET to establish an SSE stream.
26
- # The server sends an initial `event: endpoint` with a unique URL for the client to POST messages back.
27
- # Subsequent messages from the server (responses, notifications) are sent as `event: message`.
28
- # 2. Message Endpoint (`<path_prefix>/message`): Clients POST JSON-RPC messages here.
29
- # The `session_id` (obtained from the SSE endpoint event) must be included as a query parameter.
30
- # The server responds with a 202 Accepted and then sends the actual JSON-RPC response/error
31
- # asynchronously over the client's established SSE stream.
32
- #
33
- # @example Basic Usage with a Server
34
- # server = VectorMCP::Server.new("my-sse-server")
35
- # # ... register tools, resources, prompts ...
36
- # transport = VectorMCP::Transport::SSE.new(server, port: 8080)
37
- # server.run(transport: transport)
38
- #
39
- # @attr_reader logger [Logger] The logger instance, shared with the server.
40
- # @attr_reader server [VectorMCP::Server] The server instance this transport is bound to.
41
- # @attr_reader host [String] The hostname or IP address the server will bind to.
42
- # @attr_reader port [Integer] The port number the server will listen on.
43
- # @attr_reader path_prefix [String] The base URL path for MCP endpoints (e.g., "/mcp").
44
- class SSE
45
- attr_reader :logger, :server, :host, :port, :path_prefix, :session_manager
46
-
47
- # Initializes a new SSE transport.
48
- #
49
- # @param server [VectorMCP::Server] The server instance that will handle messages.
50
- # @param options [Hash] Configuration options for the transport.
51
- # @option options [String] :host ("localhost") The hostname or IP to bind to.
52
- # @option options [Integer] :port (8000) The port to listen on.
53
- # @option options [String] :path_prefix ("/mcp") The base path for HTTP endpoints.
54
- # @option options [Boolean] :disable_session_manager (false) **DEPRECATED**: Whether to disable secure session isolation.
55
- # When false (default), each client gets isolated sessions. When true, all clients share a global session (security risk).
56
- def initialize(server, options = {})
57
- @server = server
58
- @logger = server.logger
59
- @host = options[:host] || "localhost"
60
- @port = options[:port] || 8000
61
- prefix = options[:path_prefix] || "/mcp"
62
- @path_prefix = prefix.start_with?("/") ? prefix : "/#{prefix}"
63
- @path_prefix = @path_prefix.delete_suffix("/")
64
- @sse_path = "#{@path_prefix}/sse"
65
- @message_path = "#{@path_prefix}/message"
66
-
67
- # Thread-safe client storage using concurrent-ruby (legacy approach)
68
- @clients = Concurrent::Hash.new
69
- @session = nil # Global session for this transport instance, initialized in run
70
-
71
- # Initialize session manager for secure multi-client session isolation (default behavior)
72
- # Legacy shared session behavior can be enabled with disable_session_manager: true (deprecated)
73
- if options[:disable_session_manager]
74
- logger.warn("[DEPRECATED] SSE shared session mode is deprecated and poses security risks in multi-client scenarios. " \
75
- "Consider removing disable_session_manager: true to use secure per-client sessions.")
76
- @session_manager = nil
77
- else
78
- @session_manager = SseSessionManager.new(self)
79
- end
80
- @puma_server = nil
81
- @running = false
82
-
83
- logger.debug { "SSE Transport initialized with prefix: #{@path_prefix}, SSE path: #{@sse_path}, Message path: #{@message_path}" }
84
- end
85
-
86
- # Starts the SSE transport, creating a shared session and launching the Puma server.
87
- # This method will block until the server is stopped (e.g., via SIGINT/SIGTERM).
88
- #
89
- # @return [void]
90
- # @raise [StandardError] if there's a fatal error during server startup.
91
- def run
92
- logger.info("Starting server with Puma SSE transport on #{@host}:#{@port}")
93
- # Only create shared session if explicitly using legacy mode (deprecated)
94
- create_session unless @session_manager
95
- start_puma_server
96
- rescue StandardError => e
97
- handle_fatal_error(e)
98
- end
99
-
100
- # --- Rack-compatible #call method ---
101
-
102
- # Handles incoming HTTP requests. This is the entry point for the Rack application.
103
- # It routes requests to the appropriate handler based on the path.
104
- #
105
- # @param env [Hash] The Rack environment hash.
106
- # @return [Array(Integer, Hash, Object)] A standard Rack response triplet: [status, headers, body].
107
- def call(env)
108
- start_time = Time.now
109
- path = env["PATH_INFO"]
110
- http_method = env["REQUEST_METHOD"]
111
- logger.info "Received #{http_method} request for #{path}"
112
-
113
- status, headers, body = route_request(path, env)
114
-
115
- log_response(http_method, path, start_time, status)
116
- [status, headers, body]
117
- rescue StandardError => e
118
- handle_call_error(http_method, path, e)
119
- end
120
-
121
- # --- Public methods for Server to send notifications ---
122
-
123
- # Sends a JSON-RPC notification to the first available client session.
124
- # If no clients are connected, returns false.
125
- #
126
- # @param method [String] The method name of the notification.
127
- # @param params [Hash, Array, nil] The parameters for the notification (optional).
128
- # @return [Boolean] True if the message was sent successfully, false otherwise.
129
- def send_notification(method, params = nil)
130
- return false if @clients.empty?
131
-
132
- # Send to first available client
133
- first_client = @clients.values.first
134
- return false unless first_client
135
-
136
- message = { jsonrpc: "2.0", method: method }
137
- message[:params] = params if params
138
-
139
- StreamManager.enqueue_message(first_client, message)
140
- end
141
-
142
- # Sends a JSON-RPC notification to a specific client session via its SSE stream.
143
- #
144
- # @param session_id [String] The ID of the client session to send the notification to.
145
- # @param method [String] The method name of the notification.
146
- # @param params [Hash, Array, nil] The parameters for the notification (optional).
147
- # @return [Boolean] True if the message was successfully enqueued, false otherwise (e.g., client not found).
148
- def send_notification_to_session(session_id, method, params = nil)
149
- message = { jsonrpc: "2.0", method: method }
150
- message[:params] = params if params
151
-
152
- client_conn = @clients[session_id]
153
- return false unless client_conn
154
-
155
- StreamManager.enqueue_message(client_conn, message)
156
- end
157
-
158
- # Broadcasts a JSON-RPC notification to all currently connected client sessions.
159
- #
160
- # @param method [String] The method name of the notification.
161
- # @param params [Hash, Array, nil] The parameters for the notification (optional).
162
- # @return [void]
163
- def broadcast_notification(method, params = nil)
164
- # Broadcasting notification to clients
165
- message = { jsonrpc: "2.0", method: method }
166
- message[:params] = params if params
167
-
168
- @clients.each_value do |client_conn|
169
- StreamManager.enqueue_message(client_conn, message)
170
- end
171
- end
172
-
173
- # Provides compatibility for tests that expect a `build_rack_app` helper.
174
- # Since the transport itself is a Rack app (defines `#call`), it returns `self`.
175
- #
176
- # @param session [VectorMCP::Session, nil] An optional session to persist for testing.
177
- # @return [self] The transport instance itself.
178
- def build_rack_app(session = nil)
179
- @session = session if session
180
- self
181
- end
182
-
183
- # Stops the transport and cleans up resources
184
- def stop
185
- @running = false
186
- if @session_manager
187
- @session_manager.cleanup_all_sessions
188
- else
189
- cleanup_clients
190
- end
191
- @puma_server&.stop
192
- logger.info("SSE transport stopped")
193
- end
194
-
195
- # Cleans up all client connections (legacy mode)
196
- def cleanup_clients
197
- logger.info("Cleaning up #{@clients.size} client connection(s)")
198
- @clients.each_value do |client_conn|
199
- client_conn.close if client_conn.respond_to?(:close)
200
- rescue StandardError => e
201
- logger.warn("Error closing client connection: #{e.message}")
202
- end
203
- @clients.clear
204
- end
205
-
206
- # --- Private methods ---
207
- private
208
-
209
- # Creates a single, shared {VectorMCP::Session} instance for this transport run.
210
- # All client interactions will use this session context.
211
- def create_session
212
- @session = VectorMCP::Session.new(server, self, id: SecureRandom.uuid)
213
- end
214
-
215
- # Starts the Puma HTTP server.
216
- def start_puma_server
217
- @puma_server = Puma::Server.new(build_rack_app)
218
- puma_config = PumaConfig.new(@host, @port, logger)
219
- puma_config.configure(@puma_server)
220
-
221
- @running = true
222
- setup_signal_traps
223
-
224
- logger.info("Puma server starting on #{@host}:#{@port}")
225
- @puma_server.run.join # This blocks until server stops
226
- logger.info("Puma server stopped.")
227
- ensure
228
- # Only cleanup if session manager is enabled
229
- @session_manager&.cleanup_all_sessions
230
- logger.info("SSE transport and resources shut down.")
231
- end
232
-
233
- # Sets up POSIX signal traps for graceful server shutdown (INT, TERM).
234
- def setup_signal_traps
235
- Signal.trap("INT") do
236
- logger.info("SIGINT received, stopping server...")
237
- stop
238
- end
239
- Signal.trap("TERM") do
240
- logger.info("SIGTERM received, stopping server...")
241
- stop
242
- end
243
- end
244
-
245
- # Handles fatal errors during server startup or main run loop.
246
- def handle_fatal_error(error)
247
- logger.fatal("Fatal error in SSE transport: #{error.message}\n#{error.backtrace.join("\n")}")
248
- exit(1)
249
- end
250
-
251
- # Routes an incoming request to the appropriate handler based on its path.
252
- def route_request(path, env)
253
- case path
254
- when @sse_path
255
- handle_sse_connection(env)
256
- when @message_path
257
- handle_message_post(env)
258
- when "/"
259
- [200, { "Content-Type" => "text/plain" }, ["VectorMCP Server OK"]]
260
- else
261
- [404, { "Content-Type" => "text/plain" }, ["Not Found"]]
262
- end
263
- end
264
-
265
- # Logs the response details including status, method, path, and duration.
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
- def handle_call_error(method, path, error)
273
- error_context = method || "UNKNOWN_METHOD"
274
- path_context = path || "UNKNOWN_PATH"
275
- backtrace = error.backtrace.join("\n")
276
- logger.error("Error during SSE request processing for #{error_context} #{path_context}: #{error.message}\n#{backtrace}")
277
- [500, { "Content-Type" => "text/plain", "connection" => "close" }, ["Internal Server Error"]]
278
- end
279
-
280
- # Handles a new client connection to the SSE endpoint.
281
- def handle_sse_connection(env)
282
- return invalid_method_response(env) unless env["REQUEST_METHOD"] == "GET"
283
-
284
- session_id = SecureRandom.uuid
285
- logger.info("New SSE client connected: #{session_id}")
286
-
287
- # Create client connection
288
- client_conn = ClientConnection.new(session_id, logger)
289
-
290
- # Store client connection
291
- if @session_manager
292
- @session_manager.register_client(session_id, client_conn)
293
- else
294
- @clients[session_id] = client_conn
295
- end
296
-
297
- # Build message POST URL for this client
298
- message_post_url = build_post_url(session_id)
299
- # Client message POST URL configured
300
-
301
- # Set up SSE stream
302
- headers = sse_headers
303
- body = StreamManager.create_sse_stream(client_conn, message_post_url, logger)
304
-
305
- [200, headers, body]
306
- end
307
-
308
- # Handles incoming POST requests containing JSON-RPC messages from clients.
309
- def handle_message_post(env)
310
- return invalid_post_method_response(env) unless env["REQUEST_METHOD"] == "POST"
311
-
312
- session_id = extract_session_id(env["QUERY_STRING"])
313
- unless session_id
314
- return error_response(nil, VectorMCP::InvalidRequestError.new("Missing session_id parameter").code,
315
- "Missing session_id parameter")
316
- end
317
-
318
- # Get client connection and session
319
- if @session_manager
320
- client_conn = @session_manager.clients[session_id]
321
- shared_session = @session_manager.shared_session
322
- else
323
- client_conn = @clients[session_id]
324
- shared_session = @session
325
- end
326
-
327
- return error_response(nil, VectorMCP::NotFoundError.new("Invalid session_id").code, "Invalid session_id") unless client_conn
328
-
329
- MessageHandler.new(@server, shared_session, logger).handle_post_message(env, client_conn)
330
- end
331
-
332
- # Helper methods
333
- def invalid_method_response(env)
334
- method = env["REQUEST_METHOD"]
335
- logger.warn("Received non-GET request on SSE endpoint: #{method}")
336
- [405, { "Content-Type" => "text/plain", "Allow" => "GET" }, ["Method Not Allowed. Only GET is supported for SSE endpoint."]]
337
- end
338
-
339
- def invalid_post_method_response(env)
340
- method = env["REQUEST_METHOD"]
341
- logger.warn("Received non-POST request on message endpoint: #{method}")
342
- [405, { "Content-Type" => "text/plain", "Allow" => "POST" }, ["Method Not Allowed"]]
343
- end
344
-
345
- def sse_headers
346
- {
347
- "Content-Type" => "text/event-stream",
348
- "Cache-Control" => "no-cache",
349
- "Connection" => "keep-alive",
350
- "X-Accel-Buffering" => "no"
351
- }
352
- end
353
-
354
- def build_post_url(session_id)
355
- "#{@message_path}?session_id=#{session_id}"
356
- end
357
-
358
- def extract_session_id(query_string)
359
- return nil unless query_string
360
-
361
- URI.decode_www_form(query_string).to_h["session_id"]
362
- end
363
-
364
- def error_response(id, code, message, data = nil)
365
- status = case code
366
- when -32_700, -32_600, -32_602 then 400
367
- when -32_601, -32_001 then 404
368
- else 500
369
- end
370
- error_payload = { code: code, message: message }
371
- error_payload[:data] = data if data
372
- body = { jsonrpc: "2.0", id: id, error: error_payload }.to_json
373
- [status, { "Content-Type" => "application/json" }, [body]]
374
- end
375
- end
376
- end
377
- end
@@ -1,188 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "base_session_manager"
4
-
5
- module VectorMCP
6
- module Transport
7
- # Session manager for SSE transport with single shared session and client connection management.
8
- # Extends BaseSessionManager with SSE-specific functionality.
9
- #
10
- # The SSE transport uses a single shared session for all client connections,
11
- # but manages multiple client connections separately.
12
- class SseSessionManager < BaseSessionManager
13
- attr_reader :clients
14
-
15
- # Initializes a new SSE session manager.
16
- #
17
- # @param transport [SSE] The parent transport instance
18
- # @param session_timeout [Integer] Session timeout in seconds
19
- def initialize(transport, session_timeout = 300)
20
- @clients = Concurrent::Hash.new
21
- super
22
-
23
- # Create the single shared session for SSE transport
24
- @shared_session = create_shared_session
25
- end
26
-
27
- # Gets the shared session for SSE transport.
28
- # SSE uses a single session shared across all client connections.
29
- #
30
- # @return [Session] The shared session
31
- def shared_session
32
- @shared_session.touch!
33
- @shared_session
34
- end
35
-
36
- # Registers a client connection with the session manager.
37
- #
38
- # @param client_id [String] The client connection ID
39
- # @param client_connection [Object] The client connection object
40
- # @return [void]
41
- def register_client(client_id, client_connection)
42
- @clients[client_id] = client_connection
43
- session_metadata_updated?(@shared_session.id, clients_count: @clients.size)
44
- logger.debug { "Client registered: #{client_id}" }
45
- end
46
-
47
- # Unregisters a client connection from the session manager.
48
- #
49
- # @param client_id [String] The client connection ID
50
- # @return [Boolean] True if client was found and removed
51
- def client_unregistered?(client_id)
52
- client = @clients.delete(client_id)
53
- return false unless client
54
-
55
- session_metadata_updated?(@shared_session.id, clients_count: @clients.size)
56
- logger.debug { "Client unregistered: #{client_id}" }
57
- true
58
- end
59
-
60
- # Gets all client connections.
61
- #
62
- # @return [Hash] Hash of client_id => client_connection
63
- def all_clients
64
- @clients.dup
65
- end
66
-
67
- # Gets the number of connected clients.
68
- #
69
- # @return [Integer] Number of connected clients
70
- def client_count
71
- @clients.size
72
- end
73
-
74
- # Cleans up all clients and the shared session.
75
- #
76
- # @return [void]
77
- def cleanup_all_sessions
78
- logger.info { "Cleaning up #{@clients.size} client connection(s)" }
79
-
80
- @clients.each_value do |client_conn|
81
- close_client_connection(client_conn)
82
- end
83
- @clients.clear
84
-
85
- super
86
- end
87
-
88
- protected
89
-
90
- # Override: SSE doesn't need automatic cleanup since it has a single shared session.
91
- def auto_cleanup_enabled?
92
- false
93
- end
94
-
95
- # Override: Called when the shared session is terminated.
96
- def on_session_terminated(_session)
97
- # Clean up all client connections when session is terminated
98
- @clients.each_value do |client_conn|
99
- close_client_connection(client_conn)
100
- end
101
- @clients.clear
102
- end
103
-
104
- # Override: Returns metadata for SSE sessions.
105
- def create_session_metadata
106
- { clients_count: 0, session_type: :sse_shared }
107
- end
108
-
109
- # Override: Checks if any clients are connected to receive messages.
110
- def can_send_message_to_session?(_session)
111
- !@clients.empty?
112
- end
113
-
114
- # Override: Sends a message to the first available client.
115
- def send_message_to_session(_session, message)
116
- return false if @clients.empty?
117
-
118
- first_client = @clients.values.first
119
- return false unless first_client
120
-
121
- @transport.class::StreamManager.enqueue_message(first_client, message)
122
- end
123
-
124
- # Override: Broadcasts messages to all connected clients.
125
- def broadcast_message(message)
126
- count = 0
127
- @clients.each_value do |client_conn|
128
- count += 1 if @transport.class::StreamManager.enqueue_message(client_conn, message)
129
- end
130
-
131
- logger.debug { "Message broadcasted to #{count} client(s)" }
132
- count
133
- end
134
-
135
- private
136
-
137
- # Creates the single shared session for SSE transport.
138
- #
139
- # @return [BaseSessionManager::Session] The shared session
140
- def create_shared_session(rack_env = nil)
141
- session_id = "sse_shared_session_#{SecureRandom.uuid}"
142
- now = Time.now
143
-
144
- # Create VectorMCP session context with request context
145
- session_context = create_session_with_context(session_id, rack_env)
146
-
147
- # Create internal session record using base session manager struct
148
- session = BaseSessionManager::Session.new(
149
- session_id,
150
- session_context,
151
- now,
152
- now,
153
- create_session_metadata
154
- )
155
-
156
- @sessions[session_id] = session
157
- logger.info { "Shared SSE session created: #{session_id}" }
158
- session
159
- end
160
-
161
- # Creates a VectorMCP::Session with proper request context from Rack environment
162
- def create_session_with_context(session_id, rack_env)
163
- request_context = if rack_env
164
- # Create request context from Rack environment
165
- VectorMCP::RequestContext.from_rack_env(rack_env, "sse")
166
- else
167
- # Fallback to minimal context for cases where rack_env is not available
168
- VectorMCP::RequestContext.minimal("sse")
169
- end
170
- VectorMCP::Session.new(@transport.server, @transport, id: session_id, request_context: request_context)
171
- end
172
-
173
- # Closes a client connection safely.
174
- #
175
- # @param client_conn [Object] The client connection to close
176
- # @return [void]
177
- def close_client_connection(client_conn)
178
- return unless client_conn
179
-
180
- begin
181
- client_conn.close if client_conn.respond_to?(:close)
182
- rescue StandardError => e
183
- logger.warn { "Error closing client connection: #{e.message}" }
184
- end
185
- end
186
- end
187
- end
188
- end