vector_mcp 0.2.0 → 0.3.1
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/CHANGELOG.md +281 -0
- data/README.md +302 -373
- data/lib/vector_mcp/definitions.rb +3 -1
- data/lib/vector_mcp/errors.rb +24 -0
- data/lib/vector_mcp/handlers/core.rb +132 -6
- data/lib/vector_mcp/logging/component.rb +131 -0
- data/lib/vector_mcp/logging/configuration.rb +156 -0
- data/lib/vector_mcp/logging/constants.rb +21 -0
- data/lib/vector_mcp/logging/core.rb +175 -0
- data/lib/vector_mcp/logging/filters/component.rb +69 -0
- data/lib/vector_mcp/logging/filters/level.rb +23 -0
- data/lib/vector_mcp/logging/formatters/base.rb +52 -0
- data/lib/vector_mcp/logging/formatters/json.rb +83 -0
- data/lib/vector_mcp/logging/formatters/text.rb +72 -0
- data/lib/vector_mcp/logging/outputs/base.rb +64 -0
- data/lib/vector_mcp/logging/outputs/console.rb +35 -0
- data/lib/vector_mcp/logging/outputs/file.rb +157 -0
- data/lib/vector_mcp/logging.rb +71 -0
- data/lib/vector_mcp/security/auth_manager.rb +79 -0
- data/lib/vector_mcp/security/authorization.rb +96 -0
- data/lib/vector_mcp/security/middleware.rb +172 -0
- data/lib/vector_mcp/security/session_context.rb +147 -0
- data/lib/vector_mcp/security/strategies/api_key.rb +167 -0
- data/lib/vector_mcp/security/strategies/custom.rb +71 -0
- data/lib/vector_mcp/security/strategies/jwt_token.rb +118 -0
- data/lib/vector_mcp/security.rb +46 -0
- data/lib/vector_mcp/server/registry.rb +24 -0
- data/lib/vector_mcp/server.rb +141 -1
- 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
- data/lib/vector_mcp.rb +35 -2
- metadata +63 -21
data/lib/vector_mcp/server.rb
CHANGED
@@ -12,6 +12,13 @@ require_relative "util" # Needed if not using Handlers::Core
|
|
12
12
|
require_relative "server/registry"
|
13
13
|
require_relative "server/capabilities"
|
14
14
|
require_relative "server/message_handling"
|
15
|
+
require_relative "security/auth_manager"
|
16
|
+
require_relative "security/authorization"
|
17
|
+
require_relative "security/middleware"
|
18
|
+
require_relative "security/session_context"
|
19
|
+
require_relative "security/strategies/api_key"
|
20
|
+
require_relative "security/strategies/jwt_token"
|
21
|
+
require_relative "security/strategies/custom"
|
15
22
|
|
16
23
|
module VectorMCP
|
17
24
|
# The `Server` class is the central component for an MCP server implementation.
|
@@ -62,7 +69,8 @@ module VectorMCP
|
|
62
69
|
# The specific version of the Model Context Protocol this server implements.
|
63
70
|
PROTOCOL_VERSION = "2024-11-05"
|
64
71
|
|
65
|
-
attr_reader :logger, :name, :version, :protocol_version, :tools, :resources, :prompts, :roots, :in_flight_requests
|
72
|
+
attr_reader :logger, :name, :version, :protocol_version, :tools, :resources, :prompts, :roots, :in_flight_requests,
|
73
|
+
:auth_manager, :authorization, :security_middleware
|
66
74
|
attr_accessor :transport
|
67
75
|
|
68
76
|
# Initializes a new VectorMCP server.
|
@@ -108,6 +116,11 @@ module VectorMCP
|
|
108
116
|
# Configure sampling capabilities
|
109
117
|
@sampling_config = configure_sampling_capabilities(options[:sampling_config] || {})
|
110
118
|
|
119
|
+
# Initialize security components
|
120
|
+
@auth_manager = Security::AuthManager.new
|
121
|
+
@authorization = Security::Authorization.new
|
122
|
+
@security_middleware = Security::Middleware.new(@auth_manager, @authorization)
|
123
|
+
|
111
124
|
setup_default_handlers
|
112
125
|
|
113
126
|
@logger.info("Server instance '#{@name}' v#{@version} (MCP Protocol: #{@protocol_version}, Gem: v#{VectorMCP::VERSION}) initialized.")
|
@@ -148,6 +161,133 @@ module VectorMCP
|
|
148
161
|
self.transport = active_transport
|
149
162
|
active_transport.run
|
150
163
|
end
|
164
|
+
|
165
|
+
# --- Security Configuration ---
|
166
|
+
|
167
|
+
# Enable authentication with specified strategy and configuration
|
168
|
+
# @param strategy [Symbol] the authentication strategy (:api_key, :jwt, :custom)
|
169
|
+
# @param options [Hash] strategy-specific configuration options
|
170
|
+
# @return [void]
|
171
|
+
def enable_authentication!(strategy: :api_key, **options, &block)
|
172
|
+
# Clear existing strategies when switching to a new configuration
|
173
|
+
clear_auth_strategies unless @auth_manager.strategies.empty?
|
174
|
+
|
175
|
+
@auth_manager.enable!(default_strategy: strategy)
|
176
|
+
|
177
|
+
case strategy
|
178
|
+
when :api_key
|
179
|
+
add_api_key_auth(options[:keys] || [])
|
180
|
+
when :jwt
|
181
|
+
add_jwt_auth(options)
|
182
|
+
when :custom
|
183
|
+
handler = block || options[:handler]
|
184
|
+
raise ArgumentError, "Custom authentication strategy requires a handler block" unless handler
|
185
|
+
|
186
|
+
add_custom_auth(&handler)
|
187
|
+
|
188
|
+
else
|
189
|
+
raise ArgumentError, "Unknown authentication strategy: #{strategy}"
|
190
|
+
end
|
191
|
+
|
192
|
+
@logger.info("Authentication enabled with strategy: #{strategy}")
|
193
|
+
end
|
194
|
+
|
195
|
+
# Disable authentication (return to pass-through mode)
|
196
|
+
# @return [void]
|
197
|
+
def disable_authentication!
|
198
|
+
@auth_manager.disable!
|
199
|
+
@logger.info("Authentication disabled")
|
200
|
+
end
|
201
|
+
|
202
|
+
# Enable authorization with optional policy configuration block
|
203
|
+
# @param block [Proc] optional block for configuring authorization policies
|
204
|
+
# @return [void]
|
205
|
+
def enable_authorization!(&)
|
206
|
+
@authorization.enable!
|
207
|
+
instance_eval(&) if block_given?
|
208
|
+
@logger.info("Authorization enabled")
|
209
|
+
end
|
210
|
+
|
211
|
+
# Disable authorization (return to pass-through mode)
|
212
|
+
# @return [void]
|
213
|
+
def disable_authorization!
|
214
|
+
@authorization.disable!
|
215
|
+
@logger.info("Authorization disabled")
|
216
|
+
end
|
217
|
+
|
218
|
+
# Add authorization policy for tools
|
219
|
+
# @param block [Proc] policy block that receives (user, action, tool)
|
220
|
+
# @return [void]
|
221
|
+
def authorize_tools(&)
|
222
|
+
@authorization.add_policy(:tool, &)
|
223
|
+
end
|
224
|
+
|
225
|
+
# Add authorization policy for resources
|
226
|
+
# @param block [Proc] policy block that receives (user, action, resource)
|
227
|
+
# @return [void]
|
228
|
+
def authorize_resources(&)
|
229
|
+
@authorization.add_policy(:resource, &)
|
230
|
+
end
|
231
|
+
|
232
|
+
# Add authorization policy for prompts
|
233
|
+
# @param block [Proc] policy block that receives (user, action, prompt)
|
234
|
+
# @return [void]
|
235
|
+
def authorize_prompts(&)
|
236
|
+
@authorization.add_policy(:prompt, &)
|
237
|
+
end
|
238
|
+
|
239
|
+
# Add authorization policy for roots
|
240
|
+
# @param block [Proc] policy block that receives (user, action, root)
|
241
|
+
# @return [void]
|
242
|
+
def authorize_roots(&)
|
243
|
+
@authorization.add_policy(:root, &)
|
244
|
+
end
|
245
|
+
|
246
|
+
# Check if security features are enabled
|
247
|
+
# @return [Boolean] true if authentication or authorization is enabled
|
248
|
+
def security_enabled?
|
249
|
+
@security_middleware.security_enabled?
|
250
|
+
end
|
251
|
+
|
252
|
+
# Get current security status for debugging/monitoring
|
253
|
+
# @return [Hash] security configuration status
|
254
|
+
def security_status
|
255
|
+
@security_middleware.security_status
|
256
|
+
end
|
257
|
+
|
258
|
+
private
|
259
|
+
|
260
|
+
# Add API key authentication strategy
|
261
|
+
# @param keys [Array<String>] array of valid API keys
|
262
|
+
# @return [void]
|
263
|
+
def add_api_key_auth(keys)
|
264
|
+
strategy = Security::Strategies::ApiKey.new(keys: keys)
|
265
|
+
@auth_manager.add_strategy(:api_key, strategy)
|
266
|
+
end
|
267
|
+
|
268
|
+
# Add JWT authentication strategy
|
269
|
+
# @param options [Hash] JWT configuration options
|
270
|
+
# @return [void]
|
271
|
+
def add_jwt_auth(options)
|
272
|
+
strategy = Security::Strategies::JwtToken.new(**options)
|
273
|
+
@auth_manager.add_strategy(:jwt, strategy)
|
274
|
+
end
|
275
|
+
|
276
|
+
# Add custom authentication strategy
|
277
|
+
# @param handler [Proc] custom authentication handler block
|
278
|
+
# @return [void]
|
279
|
+
def add_custom_auth(&)
|
280
|
+
strategy = Security::Strategies::Custom.new(&)
|
281
|
+
@auth_manager.add_strategy(:custom, strategy)
|
282
|
+
end
|
283
|
+
|
284
|
+
# Clear all authentication strategies
|
285
|
+
# @return [void]
|
286
|
+
def clear_auth_strategies
|
287
|
+
@auth_manager.strategies.each_key do |strategy_name|
|
288
|
+
@auth_manager.remove_strategy(strategy_name)
|
289
|
+
end
|
290
|
+
end
|
151
291
|
end
|
152
292
|
|
153
293
|
module Transport
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
module Transport
|
5
|
+
class SSE
|
6
|
+
# Manages individual client connection state for SSE transport.
|
7
|
+
# Each client connection has a unique session ID, message queue, and streaming thread.
|
8
|
+
class ClientConnection
|
9
|
+
attr_reader :session_id, :message_queue, :logger
|
10
|
+
attr_accessor :stream_thread, :stream_io
|
11
|
+
|
12
|
+
# Initializes a new client connection.
|
13
|
+
#
|
14
|
+
# @param session_id [String] Unique identifier for this client session
|
15
|
+
# @param logger [Logger] Logger instance for debugging and error reporting
|
16
|
+
def initialize(session_id, logger)
|
17
|
+
@session_id = session_id
|
18
|
+
@logger = logger
|
19
|
+
@message_queue = Queue.new
|
20
|
+
@stream_thread = nil
|
21
|
+
@stream_io = nil
|
22
|
+
@closed = false
|
23
|
+
@mutex = Mutex.new
|
24
|
+
|
25
|
+
logger.debug { "Client connection created: #{session_id}" }
|
26
|
+
end
|
27
|
+
|
28
|
+
# Checks if the connection is closed
|
29
|
+
#
|
30
|
+
# @return [Boolean] true if connection is closed
|
31
|
+
def closed?
|
32
|
+
@mutex.synchronize { @closed }
|
33
|
+
end
|
34
|
+
|
35
|
+
# Closes the client connection and cleans up resources.
|
36
|
+
# This method is thread-safe and can be called multiple times.
|
37
|
+
def close
|
38
|
+
@mutex.synchronize do
|
39
|
+
return if @closed
|
40
|
+
|
41
|
+
@closed = true
|
42
|
+
logger.debug { "Closing client connection: #{session_id}" }
|
43
|
+
|
44
|
+
# Close the message queue to signal streaming thread to stop
|
45
|
+
@message_queue.close if @message_queue.respond_to?(:close)
|
46
|
+
|
47
|
+
# Close the stream I/O if it exists
|
48
|
+
begin
|
49
|
+
@stream_io&.close
|
50
|
+
rescue StandardError => e
|
51
|
+
logger.warn { "Error closing stream I/O for #{session_id}: #{e.message}" }
|
52
|
+
end
|
53
|
+
|
54
|
+
# Stop the streaming thread
|
55
|
+
if @stream_thread&.alive?
|
56
|
+
@stream_thread.kill
|
57
|
+
@stream_thread.join(1) # Wait up to 1 second for clean shutdown
|
58
|
+
end
|
59
|
+
|
60
|
+
logger.debug { "Client connection closed: #{session_id}" }
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Enqueues a message to be sent to this client.
|
65
|
+
# This method is thread-safe.
|
66
|
+
#
|
67
|
+
# @param message [Hash] The JSON-RPC message to send
|
68
|
+
# @return [Boolean] true if message was enqueued successfully
|
69
|
+
def enqueue_message(message)
|
70
|
+
return false if closed?
|
71
|
+
|
72
|
+
begin
|
73
|
+
@message_queue.push(message)
|
74
|
+
logger.debug { "Message enqueued for client #{session_id}: #{message.inspect}" }
|
75
|
+
true
|
76
|
+
rescue ClosedQueueError
|
77
|
+
logger.warn { "Attempted to enqueue message to closed queue for client #{session_id}" }
|
78
|
+
false
|
79
|
+
rescue StandardError => e
|
80
|
+
logger.error { "Error enqueuing message for client #{session_id}: #{e.message}" }
|
81
|
+
false
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Dequeues the next message from the client's message queue.
|
86
|
+
# This method blocks until a message is available or the queue is closed.
|
87
|
+
#
|
88
|
+
# @return [Hash, nil] The next message, or nil if queue is closed
|
89
|
+
def dequeue_message
|
90
|
+
return nil if closed?
|
91
|
+
|
92
|
+
begin
|
93
|
+
@message_queue.pop
|
94
|
+
rescue ClosedQueueError
|
95
|
+
nil
|
96
|
+
rescue StandardError => e
|
97
|
+
logger.error { "Error dequeuing message for client #{session_id}: #{e.message}" }
|
98
|
+
nil
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Gets the current queue size
|
103
|
+
#
|
104
|
+
# @return [Integer] Number of messages waiting in the queue
|
105
|
+
def queue_size
|
106
|
+
@message_queue.size
|
107
|
+
rescue StandardError
|
108
|
+
0
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module VectorMCP
|
6
|
+
module Transport
|
7
|
+
class SSE
|
8
|
+
# Handles JSON-RPC message processing for POST requests.
|
9
|
+
# Processes incoming messages and sends responses via SSE streams.
|
10
|
+
class MessageHandler
|
11
|
+
# Initializes a new message handler.
|
12
|
+
#
|
13
|
+
# @param server [VectorMCP::Server] The MCP server instance
|
14
|
+
# @param session [VectorMCP::Session] The server session
|
15
|
+
# @param logger [Logger] Logger instance for debugging
|
16
|
+
def initialize(server, session, logger)
|
17
|
+
@server = server
|
18
|
+
@session = session
|
19
|
+
@logger = logger
|
20
|
+
end
|
21
|
+
|
22
|
+
# Handles a POST message request from a client.
|
23
|
+
#
|
24
|
+
# @param env [Hash] Rack environment hash
|
25
|
+
# @param client_conn [ClientConnection] The client connection
|
26
|
+
# @return [Array] Rack response triplet
|
27
|
+
def handle_post_message(env, client_conn)
|
28
|
+
request_body = read_request_body(env)
|
29
|
+
return error_response(nil, -32_600, "Request body is empty") if request_body.nil? || request_body.empty?
|
30
|
+
|
31
|
+
message = parse_json_message(request_body, client_conn)
|
32
|
+
return message if message.is_a?(Array) # Error response
|
33
|
+
|
34
|
+
process_message(message, client_conn)
|
35
|
+
rescue VectorMCP::ProtocolError => e
|
36
|
+
@logger.error { "Protocol error for client #{client_conn.session_id}: #{e.message}" }
|
37
|
+
request_id = e.request_id || message&.dig("id")
|
38
|
+
enqueue_error_response(client_conn, request_id, e.code, e.message, e.details)
|
39
|
+
error_response(request_id, e.code, e.message, e.details)
|
40
|
+
rescue StandardError => e
|
41
|
+
@logger.error { "Unexpected error for client #{client_conn.session_id}: #{e.message}\n#{e.backtrace.join("\n")}" }
|
42
|
+
request_id = message&.dig("id")
|
43
|
+
enqueue_error_response(client_conn, request_id, -32_603, "Internal server error")
|
44
|
+
error_response(request_id, -32_603, "Internal server error")
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
# Reads the request body from the Rack environment.
|
50
|
+
#
|
51
|
+
# @param env [Hash] Rack environment
|
52
|
+
# @return [String, nil] Request body as string
|
53
|
+
def read_request_body(env)
|
54
|
+
input = env["rack.input"]
|
55
|
+
return nil unless input
|
56
|
+
|
57
|
+
body = input.read
|
58
|
+
input.rewind if input.respond_to?(:rewind)
|
59
|
+
body
|
60
|
+
end
|
61
|
+
|
62
|
+
# Parses JSON message from request body.
|
63
|
+
#
|
64
|
+
# @param body_str [String] JSON string from request body
|
65
|
+
# @param client_conn [ClientConnection] Client connection for error handling
|
66
|
+
# @return [Hash, Array] Parsed message or error response triplet
|
67
|
+
def parse_json_message(body_str, client_conn)
|
68
|
+
JSON.parse(body_str)
|
69
|
+
rescue JSON::ParserError => e
|
70
|
+
@logger.error { "JSON parse error for client #{client_conn.session_id}: #{e.message}" }
|
71
|
+
malformed_id = VectorMCP::Util.extract_id_from_invalid_json(body_str)
|
72
|
+
enqueue_error_response(client_conn, malformed_id, -32_700, "Parse error")
|
73
|
+
error_response(malformed_id, -32_700, "Parse error")
|
74
|
+
end
|
75
|
+
|
76
|
+
# Processes a valid JSON-RPC message.
|
77
|
+
#
|
78
|
+
# @param message [Hash] Parsed JSON-RPC message
|
79
|
+
# @param client_conn [ClientConnection] Client connection
|
80
|
+
# @return [Array] Rack response triplet
|
81
|
+
def process_message(message, client_conn)
|
82
|
+
# Handle the message through the server
|
83
|
+
response_data = @server.handle_message(message, @session, client_conn.session_id)
|
84
|
+
|
85
|
+
# If it's a request (has id), send response via SSE
|
86
|
+
if message["id"]
|
87
|
+
enqueue_success_response(client_conn, message["id"], response_data)
|
88
|
+
else
|
89
|
+
@logger.debug { "Processed notification for client #{client_conn.session_id}" }
|
90
|
+
end
|
91
|
+
|
92
|
+
# Always return 202 Accepted for valid POST messages
|
93
|
+
success_response(message["id"])
|
94
|
+
end
|
95
|
+
|
96
|
+
# Enqueues a successful response to the client's SSE stream.
|
97
|
+
#
|
98
|
+
# @param client_conn [ClientConnection] Client connection
|
99
|
+
# @param request_id [String, Integer] Original request ID
|
100
|
+
# @param result [Object] Response result data
|
101
|
+
def enqueue_success_response(client_conn, request_id, result)
|
102
|
+
response = {
|
103
|
+
jsonrpc: "2.0",
|
104
|
+
id: request_id,
|
105
|
+
result: result
|
106
|
+
}
|
107
|
+
StreamManager.enqueue_message(client_conn, response)
|
108
|
+
end
|
109
|
+
|
110
|
+
# Enqueues an error response to the client's SSE stream.
|
111
|
+
#
|
112
|
+
# @param client_conn [ClientConnection] Client connection
|
113
|
+
# @param request_id [String, Integer, nil] Original request ID
|
114
|
+
# @param code [Integer] Error code
|
115
|
+
# @param message [String] Error message
|
116
|
+
# @param data [Object, nil] Additional error data
|
117
|
+
def enqueue_error_response(client_conn, request_id, code, message, data = nil)
|
118
|
+
error_payload = { code: code, message: message }
|
119
|
+
error_payload[:data] = data if data
|
120
|
+
|
121
|
+
error_response = {
|
122
|
+
jsonrpc: "2.0",
|
123
|
+
id: request_id,
|
124
|
+
error: error_payload
|
125
|
+
}
|
126
|
+
StreamManager.enqueue_message(client_conn, error_response)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Creates a successful HTTP response for the POST request.
|
130
|
+
#
|
131
|
+
# @param request_id [String, Integer, nil] Request ID
|
132
|
+
# @return [Array] Rack response triplet
|
133
|
+
def success_response(request_id)
|
134
|
+
body = { status: "accepted", id: request_id }.to_json
|
135
|
+
[202, { "Content-Type" => "application/json" }, [body]]
|
136
|
+
end
|
137
|
+
|
138
|
+
# Creates an error HTTP response for the POST request.
|
139
|
+
#
|
140
|
+
# @param id [String, Integer, nil] Request ID
|
141
|
+
# @param code [Integer] Error code
|
142
|
+
# @param message [String] Error message
|
143
|
+
# @param data [Object, nil] Additional error data
|
144
|
+
# @return [Array] Rack response triplet
|
145
|
+
def error_response(id, code, message, data = nil)
|
146
|
+
status = case code
|
147
|
+
when -32_700, -32_600, -32_602 then 400 # Parse, Invalid Request, Invalid Params
|
148
|
+
when -32_601, -32_001 then 404 # Method Not Found, Not Found
|
149
|
+
else 500 # Internal Error, Server Error
|
150
|
+
end
|
151
|
+
|
152
|
+
error_payload = { code: code, message: message }
|
153
|
+
error_payload[:data] = data if data
|
154
|
+
|
155
|
+
body = {
|
156
|
+
jsonrpc: "2.0",
|
157
|
+
id: id,
|
158
|
+
error: error_payload
|
159
|
+
}.to_json
|
160
|
+
|
161
|
+
[status, { "Content-Type" => "application/json" }, [body]]
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
module Transport
|
5
|
+
class SSE
|
6
|
+
# Configures Puma server for production-ready SSE transport.
|
7
|
+
# Handles server setup, threading, and resource management.
|
8
|
+
class PumaConfig
|
9
|
+
attr_reader :host, :port, :logger
|
10
|
+
|
11
|
+
# Initializes Puma configuration.
|
12
|
+
#
|
13
|
+
# @param host [String] Host to bind to
|
14
|
+
# @param port [Integer] Port to listen on
|
15
|
+
# @param logger [Logger] Logger instance
|
16
|
+
def initialize(host, port, logger)
|
17
|
+
@host = host
|
18
|
+
@port = port
|
19
|
+
@logger = logger
|
20
|
+
end
|
21
|
+
|
22
|
+
# Configures a Puma server instance.
|
23
|
+
#
|
24
|
+
# @param server [Puma::Server] The Puma server to configure
|
25
|
+
def configure(server)
|
26
|
+
server.add_tcp_listener(host, port)
|
27
|
+
|
28
|
+
# Configure threading for production use
|
29
|
+
configure_threading(server)
|
30
|
+
|
31
|
+
# Set up server options
|
32
|
+
configure_server_options(server)
|
33
|
+
|
34
|
+
logger.debug { "Puma server configured for #{host}:#{port}" }
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
# Configures threading parameters for optimal performance.
|
40
|
+
#
|
41
|
+
# @param server [Puma::Server] The Puma server
|
42
|
+
def configure_threading(server)
|
43
|
+
# Set thread pool size based on CPU cores and expected load
|
44
|
+
min_threads = 2
|
45
|
+
max_threads = [4, Etc.nprocessors * 2].max
|
46
|
+
|
47
|
+
# Puma 6.x does not expose min_threads= and max_threads= as public API.
|
48
|
+
# Thread pool sizing should be set via Puma DSL/config before server creation.
|
49
|
+
# For legacy compatibility, set if possible, otherwise log a warning.
|
50
|
+
if server.respond_to?(:min_threads=) && server.respond_to?(:max_threads=)
|
51
|
+
server.min_threads = min_threads
|
52
|
+
server.max_threads = max_threads
|
53
|
+
logger.debug { "Puma configured with #{min_threads}-#{max_threads} threads" }
|
54
|
+
else
|
55
|
+
logger.warn { "Puma::Server does not support direct thread pool sizing; set threads via Puma config DSL before server creation." }
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Configures server-specific options.
|
60
|
+
#
|
61
|
+
# @param server [Puma::Server] The Puma server
|
62
|
+
def configure_server_options(server)
|
63
|
+
# Set server-specific options for SSE handling
|
64
|
+
server.leak_stack_on_error = false if server.respond_to?(:leak_stack_on_error=)
|
65
|
+
|
66
|
+
# Configure timeouts appropriate for SSE connections
|
67
|
+
# SSE connections should be long-lived, so we set generous timeouts
|
68
|
+
if server.respond_to?(:first_data_timeout=)
|
69
|
+
server.first_data_timeout = 30 # 30 seconds to send first data
|
70
|
+
end
|
71
|
+
|
72
|
+
logger.debug { "Puma server options configured for SSE transport" }
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
module Transport
|
5
|
+
class SSE
|
6
|
+
# Manages Server-Sent Events streaming for client connections.
|
7
|
+
# Handles creation of streaming responses and message broadcasting.
|
8
|
+
class StreamManager
|
9
|
+
class << self
|
10
|
+
# Creates an SSE streaming response body for a client connection.
|
11
|
+
#
|
12
|
+
# @param client_conn [ClientConnection] The client connection to stream to
|
13
|
+
# @param endpoint_url [String] The URL for the client to POST messages to
|
14
|
+
# @param logger [Logger] Logger instance for debugging
|
15
|
+
# @return [Enumerator] Rack-compatible streaming response body
|
16
|
+
def create_sse_stream(client_conn, endpoint_url, logger)
|
17
|
+
Enumerator.new do |yielder|
|
18
|
+
# Send initial endpoint event
|
19
|
+
yielder << format_sse_event("endpoint", endpoint_url)
|
20
|
+
logger.debug { "Sent endpoint event to client #{client_conn.session_id}: #{endpoint_url}" }
|
21
|
+
|
22
|
+
# Start streaming thread for this client
|
23
|
+
client_conn.stream_thread = Thread.new do
|
24
|
+
stream_messages_to_client(client_conn, yielder, logger)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Keep the connection alive by yielding from the streaming thread
|
28
|
+
client_conn.stream_thread.join
|
29
|
+
rescue StandardError => e
|
30
|
+
logger.error { "Error in SSE stream for client #{client_conn.session_id}: #{e.message}\n#{e.backtrace.join("\n")}" }
|
31
|
+
ensure
|
32
|
+
logger.debug { "SSE stream ended for client #{client_conn.session_id}" }
|
33
|
+
client_conn.close
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Enqueues a message to a specific client connection.
|
38
|
+
#
|
39
|
+
# @param client_conn [ClientConnection] The target client connection
|
40
|
+
# @param message [Hash] The JSON-RPC message to send
|
41
|
+
# @return [Boolean] true if message was enqueued successfully
|
42
|
+
def enqueue_message(client_conn, message)
|
43
|
+
return false unless client_conn && !client_conn.closed?
|
44
|
+
|
45
|
+
client_conn.enqueue_message(message)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
# Streams messages from a client's queue to the SSE connection.
|
51
|
+
# This method runs in a dedicated thread per client.
|
52
|
+
#
|
53
|
+
# @param client_conn [ClientConnection] The client connection
|
54
|
+
# @param yielder [Enumerator::Yielder] The response yielder
|
55
|
+
# @param logger [Logger] Logger instance
|
56
|
+
def stream_messages_to_client(client_conn, yielder, logger)
|
57
|
+
logger.debug { "Starting message streaming thread for client #{client_conn.session_id}" }
|
58
|
+
|
59
|
+
loop do
|
60
|
+
message = client_conn.dequeue_message
|
61
|
+
break if message.nil? # Queue closed or connection closed
|
62
|
+
|
63
|
+
begin
|
64
|
+
json_message = message.to_json
|
65
|
+
sse_data = format_sse_event("message", json_message)
|
66
|
+
yielder << sse_data
|
67
|
+
|
68
|
+
logger.debug { "Streamed message to client #{client_conn.session_id}: #{json_message}" }
|
69
|
+
rescue StandardError => e
|
70
|
+
logger.error { "Error streaming message to client #{client_conn.session_id}: #{e.message}" }
|
71
|
+
break
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
logger.debug { "Message streaming thread ended for client #{client_conn.session_id}" }
|
76
|
+
rescue StandardError => e
|
77
|
+
logger.error { "Fatal error in streaming thread for client #{client_conn.session_id}: #{e.message}\n#{e.backtrace.join("\n")}" }
|
78
|
+
end
|
79
|
+
|
80
|
+
# Formats data as a Server-Sent Event.
|
81
|
+
#
|
82
|
+
# @param event [String] The event type
|
83
|
+
# @param data [String] The event data
|
84
|
+
# @return [String] Properly formatted SSE event
|
85
|
+
def format_sse_event(event, data)
|
86
|
+
"event: #{event}\ndata: #{data}\n\n"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|