vector_mcp 0.3.1 → 0.3.3
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 +122 -0
- data/lib/vector_mcp/definitions.rb +25 -9
- data/lib/vector_mcp/errors.rb +2 -3
- data/lib/vector_mcp/handlers/core.rb +206 -50
- data/lib/vector_mcp/logger.rb +148 -0
- data/lib/vector_mcp/middleware/base.rb +171 -0
- data/lib/vector_mcp/middleware/context.rb +76 -0
- data/lib/vector_mcp/middleware/hook.rb +169 -0
- data/lib/vector_mcp/middleware/manager.rb +179 -0
- data/lib/vector_mcp/middleware.rb +43 -0
- data/lib/vector_mcp/request_context.rb +182 -0
- data/lib/vector_mcp/sampling/result.rb +11 -1
- data/lib/vector_mcp/security/middleware.rb +2 -28
- data/lib/vector_mcp/security/strategies/api_key.rb +2 -24
- data/lib/vector_mcp/security/strategies/jwt_token.rb +6 -3
- data/lib/vector_mcp/server/capabilities.rb +5 -7
- data/lib/vector_mcp/server/message_handling.rb +11 -5
- data/lib/vector_mcp/server.rb +74 -20
- data/lib/vector_mcp/session.rb +131 -8
- data/lib/vector_mcp/transport/base_session_manager.rb +320 -0
- data/lib/vector_mcp/transport/http_stream/event_store.rb +151 -0
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +189 -0
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +269 -0
- data/lib/vector_mcp/transport/http_stream.rb +779 -0
- data/lib/vector_mcp/transport/sse.rb +74 -19
- data/lib/vector_mcp/transport/sse_session_manager.rb +188 -0
- data/lib/vector_mcp/transport/stdio.rb +70 -13
- data/lib/vector_mcp/transport/stdio_session_manager.rb +181 -0
- data/lib/vector_mcp/util.rb +39 -1
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +10 -35
- metadata +25 -24
- data/lib/vector_mcp/logging/component.rb +0 -131
- data/lib/vector_mcp/logging/configuration.rb +0 -156
- data/lib/vector_mcp/logging/constants.rb +0 -21
- data/lib/vector_mcp/logging/core.rb +0 -175
- data/lib/vector_mcp/logging/filters/component.rb +0 -69
- data/lib/vector_mcp/logging/filters/level.rb +0 -23
- data/lib/vector_mcp/logging/formatters/base.rb +0 -52
- data/lib/vector_mcp/logging/formatters/json.rb +0 -83
- data/lib/vector_mcp/logging/formatters/text.rb +0 -72
- data/lib/vector_mcp/logging/outputs/base.rb +0 -64
- data/lib/vector_mcp/logging/outputs/console.rb +0 -35
- data/lib/vector_mcp/logging/outputs/file.rb +0 -157
- data/lib/vector_mcp/logging.rb +0 -71
@@ -0,0 +1,779 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "securerandom"
|
5
|
+
require "puma"
|
6
|
+
require "rack"
|
7
|
+
require "concurrent-ruby"
|
8
|
+
require "timeout"
|
9
|
+
|
10
|
+
require_relative "../errors"
|
11
|
+
require_relative "../util"
|
12
|
+
require_relative "../session"
|
13
|
+
require_relative "http_stream/session_manager"
|
14
|
+
require_relative "http_stream/event_store"
|
15
|
+
require_relative "http_stream/stream_handler"
|
16
|
+
|
17
|
+
module VectorMCP
|
18
|
+
module Transport
|
19
|
+
# Implements the Model Context Protocol transport over HTTP with streaming support
|
20
|
+
# according to the MCP specification for Streamable HTTP transport.
|
21
|
+
#
|
22
|
+
# This transport supports:
|
23
|
+
# - Client-to-server communication via HTTP POST
|
24
|
+
# - Optional server-to-client streaming via Server-Sent Events (SSE)
|
25
|
+
# - Session management with Mcp-Session-Id headers
|
26
|
+
# - Resumable connections with event IDs and Last-Event-ID support
|
27
|
+
# - Bidirectional communication patterns
|
28
|
+
#
|
29
|
+
# Endpoints:
|
30
|
+
# - POST /mcp - Client sends JSON-RPC requests
|
31
|
+
# - GET /mcp - Optional SSE streaming for server-initiated messages
|
32
|
+
# - DELETE /mcp - Session termination
|
33
|
+
#
|
34
|
+
# @example Basic Usage
|
35
|
+
# server = VectorMCP::Server.new("http-stream-server")
|
36
|
+
# transport = VectorMCP::Transport::HttpStream.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
|
44
|
+
# rubocop:disable Metrics/ClassLength
|
45
|
+
class HttpStream
|
46
|
+
attr_reader :logger, :server, :host, :port, :path_prefix
|
47
|
+
|
48
|
+
# Default configuration values
|
49
|
+
DEFAULT_HOST = "localhost"
|
50
|
+
DEFAULT_PORT = 8000
|
51
|
+
DEFAULT_PATH_PREFIX = "/mcp"
|
52
|
+
DEFAULT_SESSION_TIMEOUT = 300 # 5 minutes
|
53
|
+
DEFAULT_EVENT_RETENTION = 100 # Keep last 100 events for resumability
|
54
|
+
DEFAULT_REQUEST_TIMEOUT = 30 # Default timeout for server-initiated requests
|
55
|
+
|
56
|
+
# Initializes a new HTTP Stream transport.
|
57
|
+
#
|
58
|
+
# @param server [VectorMCP::Server] The server instance that will handle messages
|
59
|
+
# @param options [Hash] Configuration options for the transport
|
60
|
+
# @option options [String] :host ("localhost") The hostname or IP to bind to
|
61
|
+
# @option options [Integer] :port (8000) The port to listen on
|
62
|
+
# @option options [String] :path_prefix ("/mcp") The base path for HTTP endpoints
|
63
|
+
# @option options [Integer] :session_timeout (300) Session timeout in seconds
|
64
|
+
# @option options [Integer] :event_retention (100) Number of events to retain for resumability
|
65
|
+
# @option options [Array<String>] :allowed_origins (["*"]) List of allowed origins for CORS. Use ["*"] to allow all origins.
|
66
|
+
def initialize(server, options = {})
|
67
|
+
@server = server
|
68
|
+
@logger = server.logger
|
69
|
+
initialize_configuration(options)
|
70
|
+
initialize_components
|
71
|
+
initialize_request_tracking
|
72
|
+
initialize_object_pools
|
73
|
+
initialize_server_state
|
74
|
+
|
75
|
+
logger.info { "HttpStream transport initialized: #{@host}:#{@port}#{@path_prefix}" }
|
76
|
+
end
|
77
|
+
|
78
|
+
# Starts the HTTP Stream transport.
|
79
|
+
# This method will block until the server is stopped.
|
80
|
+
#
|
81
|
+
# @return [void]
|
82
|
+
# @raise [StandardError] if there's a fatal error during server startup
|
83
|
+
def run
|
84
|
+
start_puma_server
|
85
|
+
rescue StandardError => e
|
86
|
+
handle_fatal_error(e)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Handles incoming HTTP requests (Rack interface).
|
90
|
+
# Routes requests to appropriate handlers based on path and method.
|
91
|
+
#
|
92
|
+
# @param env [Hash] The Rack environment hash
|
93
|
+
# @return [Array(Integer, Hash, Object)] Standard Rack response triplet
|
94
|
+
def call(env)
|
95
|
+
start_time = Time.now
|
96
|
+
path = env["PATH_INFO"]
|
97
|
+
method = env["REQUEST_METHOD"]
|
98
|
+
|
99
|
+
# Processing HTTP request
|
100
|
+
|
101
|
+
response = route_request(path, method, env)
|
102
|
+
log_request_completion(method, path, start_time, response[0])
|
103
|
+
response
|
104
|
+
rescue StandardError => e
|
105
|
+
handle_request_error(method, path, e)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Sends a notification to the first available session.
|
109
|
+
#
|
110
|
+
# @param method [String] The notification method name
|
111
|
+
# @param params [Hash, Array, nil] The notification parameters
|
112
|
+
# @return [Boolean] True if notification was sent successfully
|
113
|
+
def send_notification(method, params = nil)
|
114
|
+
# Find the first available session
|
115
|
+
first_session = find_first_session
|
116
|
+
return false unless first_session
|
117
|
+
|
118
|
+
message = build_notification(method, params)
|
119
|
+
@stream_handler.send_message_to_session(first_session, message)
|
120
|
+
end
|
121
|
+
|
122
|
+
# Sends a notification to a specific session.
|
123
|
+
#
|
124
|
+
# @param session_id [String] The target session ID
|
125
|
+
# @param method [String] The notification method name
|
126
|
+
# @param params [Hash, Array, nil] The notification parameters
|
127
|
+
# @return [Boolean] True if notification was sent successfully
|
128
|
+
def send_notification_to_session(session_id, method, params = nil)
|
129
|
+
session = @session_manager.get_session(session_id)
|
130
|
+
return false unless session
|
131
|
+
|
132
|
+
message = build_notification(method, params)
|
133
|
+
@stream_handler.send_message_to_session(session, message)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Broadcasts a notification to all active sessions.
|
137
|
+
#
|
138
|
+
# @param method [String] The notification method name
|
139
|
+
# @param params [Hash, Array, nil] The notification parameters
|
140
|
+
# @return [Integer] Number of sessions the notification was sent to
|
141
|
+
def broadcast_notification(method, params = nil)
|
142
|
+
message = build_notification(method, params)
|
143
|
+
@session_manager.broadcast_message(message)
|
144
|
+
end
|
145
|
+
|
146
|
+
# Sends a server-initiated JSON-RPC request compatible with Session expectations.
|
147
|
+
# This method will block until a response is received or the timeout is reached.
|
148
|
+
# For HTTP transport, this requires finding an appropriate session with streaming connection.
|
149
|
+
#
|
150
|
+
# @param method [String] The request method name
|
151
|
+
# @param params [Hash, Array, nil] The request parameters
|
152
|
+
# @param timeout [Numeric] How long to wait for a response, in seconds
|
153
|
+
# @return [Object] The result part of the client's response
|
154
|
+
# @raise [VectorMCP::SamplingError, VectorMCP::SamplingTimeoutError] if the client returns an error or times out
|
155
|
+
# @raise [ArgumentError] if method is blank or no streaming session found
|
156
|
+
def send_request(method, params = nil, timeout: DEFAULT_REQUEST_TIMEOUT)
|
157
|
+
raise ArgumentError, "Method cannot be blank" if method.to_s.strip.empty?
|
158
|
+
|
159
|
+
# Find the first session with streaming connection
|
160
|
+
# In HTTP transport, we need an active streaming connection to send server-initiated requests
|
161
|
+
streaming_session = find_streaming_session
|
162
|
+
raise ArgumentError, "No streaming session available for server-initiated requests" unless streaming_session
|
163
|
+
|
164
|
+
send_request_to_session(streaming_session.id, method, params, timeout: timeout)
|
165
|
+
end
|
166
|
+
|
167
|
+
# Sends a server-initiated JSON-RPC request to a specific session and waits for a response.
|
168
|
+
# This method will block until a response is received or the timeout is reached.
|
169
|
+
#
|
170
|
+
# @param session_id [String] The target session ID
|
171
|
+
# @param method [String] The request method name
|
172
|
+
# @param params [Hash, Array, nil] The request parameters
|
173
|
+
# @param timeout [Numeric] How long to wait for a response, in seconds
|
174
|
+
# @return [Object] The result part of the client's response
|
175
|
+
# @raise [VectorMCP::SamplingError, VectorMCP::SamplingTimeoutError] if the client returns an error or times out
|
176
|
+
# @raise [ArgumentError] if method is blank or session not found
|
177
|
+
def send_request_to_session(session_id, method, params = nil, timeout: DEFAULT_REQUEST_TIMEOUT)
|
178
|
+
raise ArgumentError, "Method cannot be blank" if method.to_s.strip.empty?
|
179
|
+
raise ArgumentError, "Session ID cannot be blank" if session_id.to_s.strip.empty?
|
180
|
+
|
181
|
+
session = @session_manager.get_session(session_id)
|
182
|
+
raise ArgumentError, "Session not found: #{session_id}" unless session
|
183
|
+
|
184
|
+
raise ArgumentError, "Session must have streaming connection for server-initiated requests" unless session.streaming?
|
185
|
+
|
186
|
+
request_id = generate_request_id
|
187
|
+
request_payload = { jsonrpc: "2.0", id: request_id, method: method }
|
188
|
+
request_payload[:params] = params if params
|
189
|
+
|
190
|
+
setup_request_tracking(request_id)
|
191
|
+
# Sending request to session
|
192
|
+
|
193
|
+
# Send request via existing streaming connection
|
194
|
+
unless @stream_handler.send_message_to_session(session, request_payload)
|
195
|
+
cleanup_request_tracking(request_id)
|
196
|
+
raise VectorMCP::SamplingError, "Failed to send request to session #{session_id}"
|
197
|
+
end
|
198
|
+
|
199
|
+
response = wait_for_response(request_id, method, timeout)
|
200
|
+
process_response(response, request_id, method)
|
201
|
+
end
|
202
|
+
|
203
|
+
# Stops the transport and cleans up resources.
|
204
|
+
#
|
205
|
+
# @return [void]
|
206
|
+
def stop
|
207
|
+
logger.info { "Stopping HttpStream transport" }
|
208
|
+
@running = false
|
209
|
+
cleanup_all_pending_requests
|
210
|
+
@session_manager.cleanup_all_sessions
|
211
|
+
@puma_server&.stop
|
212
|
+
logger.info { "HttpStream transport stopped" }
|
213
|
+
end
|
214
|
+
|
215
|
+
# Provides access to session manager for internal components.
|
216
|
+
#
|
217
|
+
# @return [HttpStream::SessionManager]
|
218
|
+
# @api private
|
219
|
+
attr_reader :session_manager
|
220
|
+
|
221
|
+
# Provides access to event store for internal components.
|
222
|
+
#
|
223
|
+
# @return [HttpStream::EventStore]
|
224
|
+
# @api private
|
225
|
+
attr_reader :event_store
|
226
|
+
|
227
|
+
# Provides access to stream handler for internal components.
|
228
|
+
#
|
229
|
+
# @return [HttpStream::StreamHandler]
|
230
|
+
# @api private
|
231
|
+
attr_reader :stream_handler
|
232
|
+
|
233
|
+
private
|
234
|
+
|
235
|
+
# Normalizes the path prefix to ensure it starts with / and doesn't end with /
|
236
|
+
#
|
237
|
+
# @param prefix [String] The path prefix to normalize
|
238
|
+
# @return [String] The normalized path prefix
|
239
|
+
def normalize_path_prefix(prefix)
|
240
|
+
prefix = prefix.to_s
|
241
|
+
prefix = "/#{prefix}" unless prefix.start_with?("/")
|
242
|
+
prefix = prefix.chomp("/")
|
243
|
+
prefix.empty? ? "/" : prefix
|
244
|
+
end
|
245
|
+
|
246
|
+
# Starts the Puma HTTP server
|
247
|
+
#
|
248
|
+
# @return [void]
|
249
|
+
def start_puma_server
|
250
|
+
@puma_server = Puma::Server.new(self)
|
251
|
+
@puma_server.add_tcp_listener(@host, @port)
|
252
|
+
|
253
|
+
@running = true
|
254
|
+
setup_signal_handlers
|
255
|
+
|
256
|
+
logger.info { "HttpStream server listening on #{@host}:#{@port}#{@path_prefix}" }
|
257
|
+
@puma_server.run.join
|
258
|
+
rescue StandardError => e
|
259
|
+
logger.error { "Error starting Puma server: #{e.message}" }
|
260
|
+
raise
|
261
|
+
ensure
|
262
|
+
cleanup_server
|
263
|
+
end
|
264
|
+
|
265
|
+
# Sets up signal handlers for graceful shutdown
|
266
|
+
#
|
267
|
+
# @return [void]
|
268
|
+
def setup_signal_handlers
|
269
|
+
%w[INT TERM].each do |signal|
|
270
|
+
Signal.trap(signal) do
|
271
|
+
# Use a simple flag to avoid trap context issues
|
272
|
+
@running = false
|
273
|
+
# Defer the actual shutdown to avoid trap context limitations
|
274
|
+
Thread.new { stop_server_safely }
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
# Safely stops the server outside of trap context
|
280
|
+
#
|
281
|
+
# @return [void]
|
282
|
+
def stop_server_safely
|
283
|
+
return unless @puma_server
|
284
|
+
|
285
|
+
begin
|
286
|
+
@puma_server.stop
|
287
|
+
rescue StandardError => e
|
288
|
+
# Simple puts to avoid logger issues in signal context
|
289
|
+
puts "Error stopping server: #{e.message}"
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
# Cleans up server resources
|
294
|
+
#
|
295
|
+
# @return [void]
|
296
|
+
def cleanup_server
|
297
|
+
cleanup_all_pending_requests
|
298
|
+
@session_manager.cleanup_all_sessions
|
299
|
+
@running = false
|
300
|
+
logger.info { "HttpStream server cleanup completed" }
|
301
|
+
end
|
302
|
+
|
303
|
+
# Routes requests to appropriate handlers
|
304
|
+
#
|
305
|
+
# @param path [String] The request path
|
306
|
+
# @param method [String] The HTTP method
|
307
|
+
# @param env [Hash] The Rack environment
|
308
|
+
# @return [Array] Rack response triplet
|
309
|
+
def route_request(path, method, env)
|
310
|
+
return handle_health_check if path == "/"
|
311
|
+
return not_found_response unless path == @path_prefix
|
312
|
+
|
313
|
+
# Validate origin for security (MCP specification requirement)
|
314
|
+
return forbidden_response("Origin not allowed") unless valid_origin?(env)
|
315
|
+
|
316
|
+
case method
|
317
|
+
when "POST"
|
318
|
+
handle_post_request(env)
|
319
|
+
when "GET"
|
320
|
+
handle_get_request(env)
|
321
|
+
when "DELETE"
|
322
|
+
handle_delete_request(env)
|
323
|
+
else
|
324
|
+
method_not_allowed_response(%w[POST GET DELETE])
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
# Handles POST requests (client-to-server JSON-RPC)
|
329
|
+
#
|
330
|
+
# @param env [Hash] The Rack environment
|
331
|
+
# @return [Array] Rack response triplet
|
332
|
+
def handle_post_request(env)
|
333
|
+
session_id = extract_session_id(env)
|
334
|
+
session = @session_manager.get_or_create_session(session_id, env)
|
335
|
+
|
336
|
+
request_body = read_request_body(env)
|
337
|
+
message = parse_json_message(request_body)
|
338
|
+
|
339
|
+
# Check if this is a response to a server-initiated request
|
340
|
+
if outgoing_response?(message)
|
341
|
+
handle_outgoing_response(message)
|
342
|
+
# For responses, return 202 Accepted with no body
|
343
|
+
return [202, { "Mcp-Session-Id" => session.id }, []]
|
344
|
+
end
|
345
|
+
|
346
|
+
result = @server.handle_message(message, session.context, session.id)
|
347
|
+
|
348
|
+
# Set session ID header in response
|
349
|
+
headers = { "Mcp-Session-Id" => session.id }
|
350
|
+
json_rpc_response(result, message["id"], headers)
|
351
|
+
rescue VectorMCP::ProtocolError => e
|
352
|
+
json_error_response(e.request_id, e.code, e.message, e.details)
|
353
|
+
rescue JSON::ParserError => e
|
354
|
+
json_error_response(nil, -32_700, "Parse error", { details: e.message })
|
355
|
+
end
|
356
|
+
|
357
|
+
# Handles GET requests (SSE streaming)
|
358
|
+
#
|
359
|
+
# @param env [Hash] The Rack environment
|
360
|
+
# @return [Array] Rack response triplet
|
361
|
+
def handle_get_request(env)
|
362
|
+
session_id = extract_session_id(env)
|
363
|
+
return bad_request_response("Missing Mcp-Session-Id header") unless session_id
|
364
|
+
|
365
|
+
session = @session_manager.get_or_create_session(session_id, env)
|
366
|
+
return not_found_response unless session
|
367
|
+
|
368
|
+
@stream_handler.handle_streaming_request(env, session)
|
369
|
+
end
|
370
|
+
|
371
|
+
# Handles DELETE requests (session termination)
|
372
|
+
#
|
373
|
+
# @param env [Hash] The Rack environment
|
374
|
+
# @return [Array] Rack response triplet
|
375
|
+
def handle_delete_request(env)
|
376
|
+
session_id = extract_session_id(env)
|
377
|
+
return bad_request_response("Missing Mcp-Session-Id header") unless session_id
|
378
|
+
|
379
|
+
success = @session_manager.terminate_session(session_id)
|
380
|
+
if success
|
381
|
+
[204, {}, []]
|
382
|
+
else
|
383
|
+
not_found_response
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
# Extracts session ID from request headers
|
388
|
+
#
|
389
|
+
# @param env [Hash] The Rack environment
|
390
|
+
# @return [String, nil] The session ID or nil if not present
|
391
|
+
def extract_session_id(env)
|
392
|
+
env["HTTP_MCP_SESSION_ID"]
|
393
|
+
end
|
394
|
+
|
395
|
+
# Reads and returns the request body
|
396
|
+
#
|
397
|
+
# @param env [Hash] The Rack environment
|
398
|
+
# @return [String] The request body
|
399
|
+
def read_request_body(env)
|
400
|
+
input = env["rack.input"]
|
401
|
+
input.rewind
|
402
|
+
input.read
|
403
|
+
end
|
404
|
+
|
405
|
+
# Optimized JSON parsing with better error handling and performance
|
406
|
+
#
|
407
|
+
# @param body [String] The request body
|
408
|
+
# @return [Hash] The parsed JSON message
|
409
|
+
# @raise [JSON::ParserError] if JSON is invalid
|
410
|
+
def parse_json_message(body)
|
411
|
+
# Early validation to avoid expensive parsing on malformed input
|
412
|
+
raise JSON::ParserError, "Empty or nil body" if body.nil? || body.empty?
|
413
|
+
|
414
|
+
# Fast-path check for basic JSON structure
|
415
|
+
body_stripped = body.strip
|
416
|
+
unless (body_stripped.start_with?("{") && body_stripped.end_with?("}")) ||
|
417
|
+
(body_stripped.start_with?("[") && body_stripped.end_with?("]"))
|
418
|
+
raise JSON::ParserError, "Invalid JSON structure"
|
419
|
+
end
|
420
|
+
|
421
|
+
JSON.parse(body_stripped)
|
422
|
+
rescue JSON::ParserError => e
|
423
|
+
logger.warn { "JSON parsing failed: #{e.message}" }
|
424
|
+
raise
|
425
|
+
end
|
426
|
+
|
427
|
+
# Builds a notification message
|
428
|
+
#
|
429
|
+
# @param method [String] The notification method
|
430
|
+
# @param params [Hash, Array, nil] The notification parameters
|
431
|
+
# @return [Hash] The notification message
|
432
|
+
def build_notification(method, params = nil)
|
433
|
+
message = { jsonrpc: "2.0", method: method }
|
434
|
+
message[:params] = params if params
|
435
|
+
message
|
436
|
+
end
|
437
|
+
|
438
|
+
# Response helper methods
|
439
|
+
def handle_health_check
|
440
|
+
[200, { "Content-Type" => "text/plain" }, ["VectorMCP HttpStream Server OK"]]
|
441
|
+
end
|
442
|
+
|
443
|
+
def json_response(data, headers = {})
|
444
|
+
response_headers = { "Content-Type" => "application/json" }.merge(headers)
|
445
|
+
[200, response_headers, [data.to_json]]
|
446
|
+
end
|
447
|
+
|
448
|
+
def json_rpc_response(result, request_id, headers = {})
|
449
|
+
# Use pooled hash for response to reduce allocation
|
450
|
+
response = @hash_pool.pop || {}
|
451
|
+
response.clear
|
452
|
+
response[:jsonrpc] = "2.0"
|
453
|
+
response[:id] = request_id
|
454
|
+
response[:result] = result
|
455
|
+
|
456
|
+
response_headers = { "Content-Type" => "application/json" }.merge(headers)
|
457
|
+
json_result = response.to_json
|
458
|
+
|
459
|
+
# Return hash to pool after JSON conversion
|
460
|
+
@hash_pool << response if @hash_pool.size < 20
|
461
|
+
|
462
|
+
[200, response_headers, [json_result]]
|
463
|
+
end
|
464
|
+
|
465
|
+
def json_error_response(id, code, message, data = nil)
|
466
|
+
error_obj = { code: code, message: message }
|
467
|
+
error_obj[:data] = data if data
|
468
|
+
response = { jsonrpc: "2.0", id: id, error: error_obj }
|
469
|
+
[400, { "Content-Type" => "application/json" }, [response.to_json]]
|
470
|
+
end
|
471
|
+
|
472
|
+
def not_found_response
|
473
|
+
[404, { "Content-Type" => "text/plain" }, ["Not Found"]]
|
474
|
+
end
|
475
|
+
|
476
|
+
def bad_request_response(message = "Bad Request")
|
477
|
+
[400, { "Content-Type" => "text/plain" }, [message]]
|
478
|
+
end
|
479
|
+
|
480
|
+
def forbidden_response(message = "Forbidden")
|
481
|
+
[403, { "Content-Type" => "text/plain" }, [message]]
|
482
|
+
end
|
483
|
+
|
484
|
+
def method_not_allowed_response(allowed_methods)
|
485
|
+
[405, { "Content-Type" => "text/plain", "Allow" => allowed_methods.join(", ") },
|
486
|
+
["Method Not Allowed"]]
|
487
|
+
end
|
488
|
+
|
489
|
+
# Validates the Origin header for security
|
490
|
+
#
|
491
|
+
# @param env [Hash] The Rack environment
|
492
|
+
# @return [Boolean] True if origin is allowed, false otherwise
|
493
|
+
def valid_origin?(env)
|
494
|
+
return true if @allowed_origins.include?("*")
|
495
|
+
|
496
|
+
origin = env["HTTP_ORIGIN"]
|
497
|
+
return true if origin.nil? # Allow requests without Origin header (e.g., server-to-server)
|
498
|
+
|
499
|
+
@allowed_origins.include?(origin)
|
500
|
+
end
|
501
|
+
|
502
|
+
# Logging and error handling
|
503
|
+
def log_request_completion(method, path, start_time, status)
|
504
|
+
duration = Time.now - start_time
|
505
|
+
logger.info { "#{method} #{path} #{status} (#{(duration * 1000).round(2)}ms)" }
|
506
|
+
end
|
507
|
+
|
508
|
+
def handle_request_error(method, path, error)
|
509
|
+
logger.error { "Request processing error for #{method} #{path}: #{error.message}" }
|
510
|
+
[500, { "Content-Type" => "text/plain" }, ["Internal Server Error"]]
|
511
|
+
end
|
512
|
+
|
513
|
+
def handle_fatal_error(error)
|
514
|
+
logger.fatal { "Fatal error in HttpStream transport: #{error.message}" }
|
515
|
+
exit(1)
|
516
|
+
end
|
517
|
+
|
518
|
+
# Request tracking helpers for server-initiated requests
|
519
|
+
|
520
|
+
# Sets up tracking for an outgoing request using pooled condition variables.
|
521
|
+
#
|
522
|
+
# @param request_id [String] The request ID to track
|
523
|
+
# @return [void]
|
524
|
+
def setup_request_tracking(request_id)
|
525
|
+
@request_mutex.synchronize do
|
526
|
+
# Create IVar for thread-safe request tracking (no race conditions)
|
527
|
+
@outgoing_request_ivars[request_id] = Concurrent::IVar.new
|
528
|
+
end
|
529
|
+
end
|
530
|
+
|
531
|
+
# Waits for a response to an outgoing request.
|
532
|
+
#
|
533
|
+
# @param request_id [String] The request ID to wait for
|
534
|
+
# @param method [String] The request method name
|
535
|
+
# @param timeout [Numeric] How long to wait
|
536
|
+
# @return [Hash] The response data
|
537
|
+
# @raise [VectorMCP::SamplingTimeoutError] if timeout occurs
|
538
|
+
def wait_for_response(request_id, method, timeout)
|
539
|
+
ivar = nil
|
540
|
+
@request_mutex.synchronize do
|
541
|
+
ivar = @outgoing_request_ivars[request_id]
|
542
|
+
end
|
543
|
+
|
544
|
+
return nil unless ivar
|
545
|
+
|
546
|
+
begin
|
547
|
+
# IVar handles timeout and thread safety automatically
|
548
|
+
response = ivar.value!(timeout)
|
549
|
+
logger.debug { "Received response for request ID #{request_id}" }
|
550
|
+
response
|
551
|
+
rescue Concurrent::TimeoutError
|
552
|
+
logger.warn { "Timeout waiting for response to request ID #{request_id} (#{method}) after #{timeout}s" }
|
553
|
+
cleanup_request_tracking(request_id)
|
554
|
+
raise VectorMCP::SamplingTimeoutError, "Timeout waiting for client response to '#{method}' request (ID: #{request_id})"
|
555
|
+
end
|
556
|
+
end
|
557
|
+
|
558
|
+
# Processes the response from an outgoing request.
|
559
|
+
#
|
560
|
+
# @param response [Hash, nil] The response data
|
561
|
+
# @param request_id [String] The request ID
|
562
|
+
# @param method [String] The request method name
|
563
|
+
# @return [Object] The result data
|
564
|
+
# @raise [VectorMCP::SamplingError] if response contains an error or is nil
|
565
|
+
def process_response(response, request_id, method)
|
566
|
+
if response.nil?
|
567
|
+
raise VectorMCP::SamplingError, "No response received for '#{method}' request (ID: #{request_id}) - this indicates a logic error."
|
568
|
+
end
|
569
|
+
|
570
|
+
if response.key?(:error)
|
571
|
+
err = response[:error]
|
572
|
+
logger.warn { "Client returned error for request ID #{request_id} (#{method}): #{err.inspect}" }
|
573
|
+
raise VectorMCP::SamplingError, "Client returned an error for '#{method}' request (ID: #{request_id}): [#{err[:code]}] #{err[:message]}"
|
574
|
+
end
|
575
|
+
|
576
|
+
# Check if response has result key, if not treat as malformed
|
577
|
+
unless response.key?(:result)
|
578
|
+
raise VectorMCP::SamplingError,
|
579
|
+
"Malformed response for '#{method}' request (ID: #{request_id}): missing 'result' field. Response: #{response.inspect}"
|
580
|
+
end
|
581
|
+
|
582
|
+
response[:result]
|
583
|
+
end
|
584
|
+
|
585
|
+
# Cleans up tracking for a request and returns condition variable to pool.
|
586
|
+
#
|
587
|
+
# @param request_id [String] The request ID to clean up
|
588
|
+
# @return [void]
|
589
|
+
def cleanup_request_tracking(request_id)
|
590
|
+
@request_mutex.synchronize do
|
591
|
+
cleanup_request_tracking_unsafe(request_id)
|
592
|
+
end
|
593
|
+
end
|
594
|
+
|
595
|
+
# Internal cleanup method that assumes mutex is already held.
|
596
|
+
# This prevents recursive locking when called from within synchronized blocks.
|
597
|
+
#
|
598
|
+
# @param request_id [String] The request ID to clean up
|
599
|
+
# @return [void]
|
600
|
+
# @api private
|
601
|
+
def cleanup_request_tracking_unsafe(request_id)
|
602
|
+
# Remove IVar for this request (no condition variable cleanup needed)
|
603
|
+
@outgoing_request_ivars.delete(request_id)
|
604
|
+
end
|
605
|
+
|
606
|
+
# Checks if a message is a response to an outgoing request.
|
607
|
+
#
|
608
|
+
# @param message [Hash] The parsed message
|
609
|
+
# @return [Boolean] True if this is an outgoing response
|
610
|
+
def outgoing_response?(message)
|
611
|
+
return false unless message["id"]
|
612
|
+
return false if message["method"]
|
613
|
+
|
614
|
+
# Standard response with result or error
|
615
|
+
return true if message.key?("result") || message.key?("error")
|
616
|
+
|
617
|
+
# Handle malformed responses: if we have a pending request with this ID,
|
618
|
+
# treat it as a response (even if malformed) rather than letting it
|
619
|
+
# go through normal request processing
|
620
|
+
request_id = message["id"]
|
621
|
+
@outgoing_request_ivars.key?(request_id)
|
622
|
+
end
|
623
|
+
|
624
|
+
# Handles a response to an outgoing request.
|
625
|
+
#
|
626
|
+
# @param message [Hash] The parsed response message
|
627
|
+
# @return [void]
|
628
|
+
def handle_outgoing_response(message)
|
629
|
+
request_id = message["id"]
|
630
|
+
|
631
|
+
ivar = nil
|
632
|
+
@request_mutex.synchronize do
|
633
|
+
ivar = @outgoing_request_ivars[request_id]
|
634
|
+
end
|
635
|
+
|
636
|
+
unless ivar
|
637
|
+
logger.debug { "Received response for request ID #{request_id} but no thread is waiting (likely timed out)" }
|
638
|
+
return
|
639
|
+
end
|
640
|
+
|
641
|
+
# Convert keys to symbols for consistency and put response in IVar
|
642
|
+
response_data = deep_transform_keys(message, &:to_sym)
|
643
|
+
|
644
|
+
# Store in both places for compatibility with tests
|
645
|
+
@outgoing_request_responses[request_id] = response_data
|
646
|
+
|
647
|
+
# IVar handles thread-safe response delivery - no race conditions possible
|
648
|
+
if ivar.try_set(response_data)
|
649
|
+
logger.debug { "Response delivered to waiting thread for request ID #{request_id}" }
|
650
|
+
else
|
651
|
+
logger.debug { "IVar was already resolved for request ID #{request_id} (duplicate response)" }
|
652
|
+
end
|
653
|
+
end
|
654
|
+
|
655
|
+
# Optimized hash key transformation for better performance.
|
656
|
+
# Uses simple recursive approach but with early returns to reduce overhead.
|
657
|
+
#
|
658
|
+
# @param obj [Object] The object to transform (Hash, Array, or other)
|
659
|
+
# @return [Object] The transformed object
|
660
|
+
def deep_transform_keys(obj, &block)
|
661
|
+
transform_object_keys(obj, &block)
|
662
|
+
end
|
663
|
+
|
664
|
+
# Core transformation logic extracted for better maintainability
|
665
|
+
def transform_object_keys(obj, &block)
|
666
|
+
case obj
|
667
|
+
when Hash
|
668
|
+
# Pre-allocate hash with known size for efficiency
|
669
|
+
result = Hash.new(obj.size)
|
670
|
+
obj.each do |k, v|
|
671
|
+
# Safe key transformation - only transform string keys to symbols
|
672
|
+
new_key = if block && (k.is_a?(String) || k.is_a?(Symbol))
|
673
|
+
block.call(k)
|
674
|
+
else
|
675
|
+
k
|
676
|
+
end
|
677
|
+
result[new_key] = transform_object_keys(v, &block)
|
678
|
+
end
|
679
|
+
result
|
680
|
+
when Array
|
681
|
+
# Use map! for in-place transformation when possible
|
682
|
+
obj.map { |v| transform_object_keys(v, &block) }
|
683
|
+
else
|
684
|
+
obj
|
685
|
+
end
|
686
|
+
end
|
687
|
+
|
688
|
+
# Initialize configuration options from the provided options hash
|
689
|
+
def initialize_configuration(options)
|
690
|
+
@host = options[:host] || DEFAULT_HOST
|
691
|
+
@port = options[:port] || DEFAULT_PORT
|
692
|
+
@path_prefix = normalize_path_prefix(options[:path_prefix] || DEFAULT_PATH_PREFIX)
|
693
|
+
@session_timeout = options[:session_timeout] || DEFAULT_SESSION_TIMEOUT
|
694
|
+
@event_retention = options[:event_retention] || DEFAULT_EVENT_RETENTION
|
695
|
+
@allowed_origins = options[:allowed_origins] || ["*"]
|
696
|
+
end
|
697
|
+
|
698
|
+
# Initialize core HTTP stream components
|
699
|
+
def initialize_components
|
700
|
+
@session_manager = HttpStream::SessionManager.new(self, @session_timeout)
|
701
|
+
@event_store = HttpStream::EventStore.new(@event_retention)
|
702
|
+
@stream_handler = HttpStream::StreamHandler.new(self)
|
703
|
+
end
|
704
|
+
|
705
|
+
# Initialize request tracking system and ID generation for server-initiated requests
|
706
|
+
def initialize_request_tracking
|
707
|
+
# Use IVars for thread-safe request/response handling (eliminates condition variable races)
|
708
|
+
@outgoing_request_ivars = Concurrent::Hash.new
|
709
|
+
# Keep compatibility with tests that expect @outgoing_request_responses
|
710
|
+
@outgoing_request_responses = Concurrent::Hash.new
|
711
|
+
@request_mutex = Mutex.new
|
712
|
+
initialize_request_id_generation
|
713
|
+
end
|
714
|
+
|
715
|
+
# Initialize thread-safe request ID generation components
|
716
|
+
def initialize_request_id_generation
|
717
|
+
# Thread-safe request ID generation - avoid Fiber/Enumerator which can't cross threads
|
718
|
+
@request_id_base = "vecmcp_http_#{Process.pid}_#{SecureRandom.hex(4)}"
|
719
|
+
@request_id_counter = Concurrent::AtomicFixnum.new(0)
|
720
|
+
end
|
721
|
+
|
722
|
+
# Generate a unique, thread-safe request ID for server-initiated requests
|
723
|
+
#
|
724
|
+
# @return [String] A unique request ID in format: vecmcp_http_{pid}_{random}_{counter}
|
725
|
+
def generate_request_id
|
726
|
+
"#{@request_id_base}_#{@request_id_counter.increment}"
|
727
|
+
end
|
728
|
+
|
729
|
+
# Initialize object pools for performance optimization
|
730
|
+
def initialize_object_pools
|
731
|
+
# Pool for reusable hash objects to reduce GC pressure
|
732
|
+
@hash_pool = Concurrent::Array.new
|
733
|
+
20.times { @hash_pool << {} }
|
734
|
+
end
|
735
|
+
|
736
|
+
# Initialize server state variables
|
737
|
+
def initialize_server_state
|
738
|
+
@puma_server = nil
|
739
|
+
@running = false
|
740
|
+
end
|
741
|
+
|
742
|
+
# Cleans up all pending requests during shutdown.
|
743
|
+
#
|
744
|
+
# @return [void]
|
745
|
+
def cleanup_all_pending_requests
|
746
|
+
return if @outgoing_request_ivars.empty?
|
747
|
+
|
748
|
+
logger.debug { "Cleaning up #{@outgoing_request_ivars.size} pending requests" }
|
749
|
+
|
750
|
+
@request_mutex.synchronize do
|
751
|
+
# IVars will timeout naturally, just clear the tracking
|
752
|
+
@outgoing_request_ivars.clear
|
753
|
+
end
|
754
|
+
end
|
755
|
+
|
756
|
+
# Finds the first session with an active streaming connection.
|
757
|
+
#
|
758
|
+
# @return [SessionManager::Session, nil] The first streaming session or nil if none found
|
759
|
+
def find_streaming_session
|
760
|
+
@session_manager.active_session_ids.each do |session_id|
|
761
|
+
session = @session_manager.get_session(session_id)
|
762
|
+
return session if session&.streaming?
|
763
|
+
end
|
764
|
+
nil
|
765
|
+
end
|
766
|
+
|
767
|
+
# Finds the first available session (streaming or non-streaming).
|
768
|
+
#
|
769
|
+
# @return [SessionManager::Session, nil] The first available session or nil if none found
|
770
|
+
def find_first_session
|
771
|
+
session_ids = @session_manager.active_session_ids
|
772
|
+
return nil if session_ids.empty?
|
773
|
+
|
774
|
+
@session_manager.get_session(session_ids.first)
|
775
|
+
end
|
776
|
+
end
|
777
|
+
# rubocop:enable Metrics/ClassLength
|
778
|
+
end
|
779
|
+
end
|