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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5eb7e5435a5b8e67c4bd570c25432b86b0e791e24c5225ee398f693867030d9a
4
- data.tar.gz: 9e0a8dce81c39d4912136a0985cff18b7c4bb30e5bc361d19bc81530183d53ac
3
+ metadata.gz: 11a17ca5149213858f5ec62dc28a7216cd97103e1cb066a86b96d01cbef808e6
4
+ data.tar.gz: c99bcbc2a611c20432b2c401760512cf545e0cfb5329bd74c76c61c806c2c491
5
5
  SHA512:
6
- metadata.gz: c46edbc723ece6fc4d6f8658a4ea40f97a5e80a675d187d7a58d60fffcdfb0bf1ba3b9c57e1e2f142520a280196aa97635a1ef747316c0111d4097e15006d363
7
- data.tar.gz: f67637b1bdbb0fb86ce500b315382ae93a5caa18ea6c8da2f6e563b8c81208f17840fb753cce92eae11b672961a0c9a628d65d11c7eb723affb35e6c9ba27c0d
6
+ metadata.gz: 065f397ce2448a4cea13944462b009cd511043e9a3afd85d52641cc8f05607f59d8e0ea0653f1822385f2b95def8e7ddee1bd5117db54430717c6bb20196a958
7
+ data.tar.gz: a04b7cd10db5c24f9ab51fb8d4758396675fc24411530ce6a9033629fb66bd0e9974579ffb113c1401d0fe82f4e949073ca1b45d6b899c6502112781e3a5daa4
data/README.md CHANGED
@@ -21,7 +21,7 @@ This library allows you to easily create MCP servers that expose your applicatio
21
21
  * **Sampling:** Server-initiated LLM requests with configurable capabilities (streaming, tool calls, images, token limits).
22
22
  * **Transport:**
23
23
  * **Stdio (stable):** Simple transport using standard input/output, ideal for process-based servers.
24
- * **SSE (work-in-progress):** Server-Sent Events support is under active development and currently unavailable.
24
+ * **SSE (stable):** Server-Sent Events over HTTP, enabling web-based MCP clients and browser integration.
25
25
 
26
26
  ## Installation
27
27
 
@@ -33,7 +33,6 @@ gem 'vector_mcp'
33
33
  gem install vector_mcp
34
34
  ```
35
35
 
36
- > ⚠️ **Note:** SSE transport is not yet supported in the released gem.
37
36
 
38
37
  ## Quick Start
39
38
 
@@ -91,6 +90,71 @@ Or use a script:
91
90
  } | ruby my_server.rb | jq
92
91
  ```
93
92
 
93
+ ### HTTP/SSE Transport
94
+
95
+ For web-based clients and browser integration, use the SSE transport:
96
+
97
+ ```ruby
98
+ require 'vector_mcp'
99
+
100
+ server = VectorMCP.new(
101
+ name: 'My HTTP Server',
102
+ version: '1.0.0'
103
+ )
104
+
105
+ # Register tools, resources, prompts...
106
+ server.register_tool(
107
+ name: 'echo',
108
+ description: 'Returns the input message',
109
+ input_schema: {
110
+ type: 'object',
111
+ properties: { message: { type: 'string' } },
112
+ required: ['message']
113
+ }
114
+ ) { |args| args['message'] }
115
+
116
+ # Start HTTP server with SSE transport
117
+ server.run(transport: :sse, options: { port: 8080, host: 'localhost' })
118
+ ```
119
+
120
+ The server provides two HTTP endpoints:
121
+
122
+ * **SSE Stream:** `GET /sse` - Establishes server-sent events connection
123
+ * **Messages:** `POST /message?session_id=<id>` - Sends JSON-RPC requests
124
+
125
+ **Client Integration:**
126
+
127
+ ```javascript
128
+ // Connect to SSE stream
129
+ const eventSource = new EventSource('http://localhost:8080/sse');
130
+
131
+ eventSource.addEventListener('endpoint', (event) => {
132
+ const data = JSON.parse(event.data);
133
+ const messageUrl = data.uri; // POST endpoint for this session
134
+
135
+ // Send MCP initialization
136
+ fetch(messageUrl, {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({
140
+ jsonrpc: '2.0',
141
+ id: 1,
142
+ method: 'initialize',
143
+ params: {
144
+ protocolVersion: '2024-11-05',
145
+ capabilities: {},
146
+ clientInfo: { name: 'WebClient', version: '1.0' }
147
+ }
148
+ })
149
+ });
150
+ });
151
+
152
+ eventSource.addEventListener('message', (event) => {
153
+ const response = JSON.parse(event.data);
154
+ console.log('MCP Response:', response);
155
+ });
156
+ ```
157
+
94
158
  ## Core Usage
95
159
 
96
160
  ### Creating a Server
@@ -141,7 +205,81 @@ server.register_tool(
141
205
  end
142
206
  ```
143
207
 
144
- - `input_schema`: A JSON Schema object describing the tool's expected arguments
208
+ #### Automatic Input Validation
209
+
210
+ VectorMCP automatically validates all tool arguments against their defined `input_schema` before executing the tool handler. This provides several security and reliability benefits:
211
+
212
+ - **Type Safety**: Ensures arguments match expected types (string, number, boolean, etc.)
213
+ - **Required Fields**: Validates that all required parameters are present
214
+ - **Format Validation**: Supports JSON Schema constraints like patterns, enums, and ranges
215
+ - **Security**: Prevents injection attacks and malformed input from reaching your tool logic
216
+ - **Better Error Messages**: Provides clear validation errors to help clients fix their requests
217
+
218
+ ```ruby
219
+ # Example with comprehensive validation
220
+ server.register_tool(
221
+ name: "process_user_data",
222
+ description: "Processes user information with strict validation",
223
+ input_schema: {
224
+ type: "object",
225
+ properties: {
226
+ name: {
227
+ type: "string",
228
+ minLength: 1,
229
+ maxLength: 100,
230
+ pattern: "^[a-zA-Z\\s]+$"
231
+ },
232
+ age: {
233
+ type: "integer",
234
+ minimum: 0,
235
+ maximum: 150
236
+ },
237
+ email: {
238
+ type: "string",
239
+ format: "email"
240
+ },
241
+ role: {
242
+ type: "string",
243
+ enum: ["admin", "user", "guest"]
244
+ },
245
+ preferences: {
246
+ type: "object",
247
+ properties: {
248
+ theme: { type: "string" },
249
+ notifications: { type: "boolean" }
250
+ },
251
+ additionalProperties: false
252
+ }
253
+ },
254
+ required: ["name", "email", "role"],
255
+ additionalProperties: false
256
+ }
257
+ ) do |args, session|
258
+ # At this point, you can trust that:
259
+ # - All required fields are present
260
+ # - Data types are correct
261
+ # - String lengths and number ranges are valid
262
+ # - Email format is valid
263
+ # - Role is one of the allowed values
264
+
265
+ "Processing user: #{args['name']} (#{args['role']})"
266
+ end
267
+ ```
268
+
269
+ **What happens with invalid input:**
270
+ - VectorMCP returns a JSON-RPC error response with code `-32602` (Invalid params)
271
+ - The error message includes specific details about what validation failed
272
+ - Your tool handler is never called with invalid data
273
+ - Clients receive clear feedback on how to fix their requests
274
+
275
+ **Backward Compatibility:**
276
+ - Tools without `input_schema` continue to work normally
277
+ - No validation is performed if `input_schema` is not provided
278
+ - Existing tools are unaffected by this security enhancement
279
+
280
+ #### Tool Registration Options
281
+
282
+ - `input_schema`: A JSON Schema object describing the tool's expected arguments (recommended for security)
145
283
  - Return value is automatically converted to MCP content format:
146
284
  - String → `{type: 'text', text: '...'}`
147
285
  - Hash with proper MCP structure → used as-is
@@ -230,6 +230,7 @@ module VectorMCP
230
230
  # Validates that the root URI is properly formatted and secure.
231
231
  # @return [Boolean] True if the root is valid.
232
232
  # @raise [ArgumentError] If the root is invalid.
233
+ # rubocop:disable Naming/PredicateMethod
233
234
  def validate!
234
235
  # Validate URI format
235
236
  parsed_uri = begin
@@ -248,13 +249,14 @@ module VectorMCP
248
249
  raise ArgumentError, "Root path is not a directory: #{path}" unless File.directory?(path)
249
250
 
250
251
  # Security check: ensure we can read the directory
251
- raise ArgumentError, "Root directory is not readable: #{path}" unless File.readable?(path)
252
252
 
253
+ raise ArgumentError, "Root directory is not readable: #{path}" unless File.readable?(path)
253
254
  # Validate against path traversal attempts in the URI itself
254
255
  raise ArgumentError, "Root path contains unsafe traversal patterns: #{path}" if path.include?("..") || path.include?("./")
255
256
 
256
257
  true
257
258
  end
259
+ # rubocop:enable Naming/PredicateMethod
258
260
 
259
261
  # Class method to create a root from a local directory path.
260
262
  # @param path [String] Local filesystem path to the directory.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "uri"
5
+ require "json-schema"
5
6
 
6
7
  module VectorMCP
7
8
  module Handlers
@@ -46,6 +47,7 @@ module VectorMCP
46
47
  # @return [Hash] A hash containing the tool call result or an error indication.
47
48
  # Example success: `{ isError: false, content: [{ type: "text", ... }] }`
48
49
  # @raise [VectorMCP::NotFoundError] if the requested tool is not found.
50
+ # @raise [VectorMCP::InvalidParamsError] if arguments validation fails.
49
51
  def self.call_tool(params, _session, server)
50
52
  tool_name = params["name"]
51
53
  arguments = params["arguments"] || {}
@@ -53,6 +55,9 @@ module VectorMCP
53
55
  tool = server.tools[tool_name]
54
56
  raise VectorMCP::NotFoundError.new("Not Found", details: "Tool not found: #{tool_name}") unless tool
55
57
 
58
+ # Validate arguments against the tool's input schema
59
+ validate_input_arguments!(tool_name, tool, arguments)
60
+
56
61
  # Let StandardError propagate to Server#handle_request
57
62
  result = tool.handler.call(arguments)
58
63
  {
@@ -301,6 +306,44 @@ module VectorMCP
301
306
  end
302
307
  end
303
308
  private_class_method :validate_prompt_response!
309
+
310
+ # Validates arguments provided for a tool against its input schema using json-schema.
311
+ # @api private
312
+ # @param tool_name [String] The name of the tool.
313
+ # @param tool [VectorMCP::Definitions::Tool] The tool definition.
314
+ # @param arguments [Hash] The arguments supplied by the client.
315
+ # @return [void]
316
+ # @raise [VectorMCP::InvalidParamsError] if arguments fail validation.
317
+ def self.validate_input_arguments!(tool_name, tool, arguments)
318
+ return unless tool.input_schema.is_a?(Hash)
319
+ return if tool.input_schema.empty?
320
+
321
+ validation_errors = JSON::Validator.fully_validate(tool.input_schema, arguments)
322
+ return if validation_errors.empty?
323
+
324
+ raise_tool_validation_error(tool_name, validation_errors)
325
+ rescue JSON::Schema::ValidationError => e
326
+ raise_tool_validation_error(tool_name, [e.message])
327
+ end
328
+
329
+ # Raises InvalidParamsError with formatted validation details.
330
+ # @api private
331
+ # @param tool_name [String] The name of the tool.
332
+ # @param validation_errors [Array<String>] The validation error messages.
333
+ # @return [void]
334
+ # @raise [VectorMCP::InvalidParamsError] Always raises with formatted details.
335
+ def self.raise_tool_validation_error(tool_name, validation_errors)
336
+ raise VectorMCP::InvalidParamsError.new(
337
+ "Invalid arguments for tool '#{tool_name}'",
338
+ details: {
339
+ tool: tool_name,
340
+ validation_errors: validation_errors,
341
+ message: validation_errors.join("; ")
342
+ }
343
+ )
344
+ end
345
+ private_class_method :validate_input_arguments!
346
+ private_class_method :raise_tool_validation_error
304
347
  end
305
348
  end
306
349
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json-schema"
4
+
3
5
  module VectorMCP
4
6
  class Server
5
7
  # Handles registration of tools, resources, prompts, and roots
@@ -19,6 +21,9 @@ module VectorMCP
19
21
  name_s = name.to_s
20
22
  raise ArgumentError, "Tool '#{name_s}' already registered" if @tools[name_s]
21
23
 
24
+ # Validate schema format during registration
25
+ validate_schema_format!(input_schema) if input_schema
26
+
22
27
  @tools[name_s] = VectorMCP::Definitions::Tool.new(name_s, description, input_schema, handler)
23
28
  logger.debug("Registered tool: #{name_s}")
24
29
  self
@@ -206,6 +211,25 @@ module VectorMCP
206
211
 
207
212
  private
208
213
 
214
+ # Validates that the provided schema is a valid JSON Schema.
215
+ # @api private
216
+ # @param schema [Hash, nil] The JSON Schema to validate.
217
+ # @return [void]
218
+ # @raise [ArgumentError] if the schema is invalid.
219
+ def validate_schema_format!(schema)
220
+ return if schema.nil? || schema.empty?
221
+ return unless schema.is_a?(Hash)
222
+
223
+ # Use JSON::Validator to validate the schema format itself
224
+ validation_errors = JSON::Validator.fully_validate_schema(schema)
225
+
226
+ raise ArgumentError, "Invalid input_schema format: #{validation_errors.join("; ")}" unless validation_errors.empty?
227
+ rescue JSON::Schema::ValidationError => e
228
+ raise ArgumentError, "Invalid input_schema format: #{e.message}"
229
+ rescue JSON::Schema::SchemaError => e
230
+ raise ArgumentError, "Invalid input_schema structure: #{e.message}"
231
+ end
232
+
209
233
  # Validates the structure of the `arguments` array provided to {#register_prompt}.
210
234
  # @api private
211
235
  def validate_prompt_arguments(argument_defs)
@@ -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