vector_mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +141 -3
- data/lib/vector_mcp/definitions.rb +3 -1
- data/lib/vector_mcp/handlers/core.rb +43 -0
- data/lib/vector_mcp/server/registry.rb +24 -0
- data/lib/vector_mcp/transport/sse/client_connection.rb +113 -0
- data/lib/vector_mcp/transport/sse/message_handler.rb +166 -0
- data/lib/vector_mcp/transport/sse/puma_config.rb +77 -0
- data/lib/vector_mcp/transport/sse/stream_manager.rb +92 -0
- data/lib/vector_mcp/transport/sse.rb +119 -460
- data/lib/vector_mcp/version.rb +1 -1
- metadata +20 -30
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 11a17ca5149213858f5ec62dc28a7216cd97103e1cb066a86b96d01cbef808e6
|
4
|
+
data.tar.gz: c99bcbc2a611c20432b2c401760512cf545e0cfb5329bd74c76c61c806c2c491
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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 (
|
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
|
-
|
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
|