vector_mcp 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +132 -342
- data/lib/vector_mcp/handlers/core.rb +82 -27
- data/lib/vector_mcp/image_util.rb +34 -11
- data/lib/vector_mcp/middleware/base.rb +1 -5
- data/lib/vector_mcp/middleware/context.rb +11 -1
- data/lib/vector_mcp/rails/tool.rb +85 -0
- data/lib/vector_mcp/request_context.rb +1 -1
- data/lib/vector_mcp/security/middleware.rb +2 -2
- data/lib/vector_mcp/server/capabilities.rb +4 -10
- data/lib/vector_mcp/server/registry.rb +36 -4
- data/lib/vector_mcp/server.rb +45 -38
- data/lib/vector_mcp/session.rb +5 -3
- data/lib/vector_mcp/tool.rb +221 -0
- data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
- data/lib/vector_mcp/transport/http_stream/event_store.rb +18 -4
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +34 -11
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +132 -47
- data/lib/vector_mcp/transport/http_stream.rb +161 -82
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +6 -8
- metadata +4 -10
- data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
- data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
- data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
- data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
- data/lib/vector_mcp/transport/sse.rb +0 -377
- data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
- data/lib/vector_mcp/transport/stdio.rb +0 -473
- data/lib/vector_mcp/transport/stdio_session_manager.rb +0 -181
|
@@ -53,6 +53,16 @@ module VectorMCP
|
|
|
53
53
|
DEFAULT_EVENT_RETENTION = 100 # Keep last 100 events for resumability
|
|
54
54
|
DEFAULT_REQUEST_TIMEOUT = 30 # Default timeout for server-initiated requests
|
|
55
55
|
|
|
56
|
+
# Default allowed origins — restrict to localhost by default for security.
|
|
57
|
+
DEFAULT_ALLOWED_ORIGINS = %w[
|
|
58
|
+
http://localhost
|
|
59
|
+
https://localhost
|
|
60
|
+
http://127.0.0.1
|
|
61
|
+
https://127.0.0.1
|
|
62
|
+
http://[::1]
|
|
63
|
+
https://[::1]
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
56
66
|
# Initializes a new HTTP Stream transport.
|
|
57
67
|
#
|
|
58
68
|
# @param server [VectorMCP::Server] The server instance that will handle messages
|
|
@@ -62,7 +72,8 @@ module VectorMCP
|
|
|
62
72
|
# @option options [String] :path_prefix ("/mcp") The base path for HTTP endpoints
|
|
63
73
|
# @option options [Integer] :session_timeout (300) Session timeout in seconds
|
|
64
74
|
# @option options [Integer] :event_retention (100) Number of events to retain for resumability
|
|
65
|
-
# @option options [Array<String>] :allowed_origins
|
|
75
|
+
# @option options [Array<String>] :allowed_origins Allowed origins for CORS validation.
|
|
76
|
+
# Defaults to localhost origins only. Pass ["*"] to allow all origins (NOT recommended for production).
|
|
66
77
|
def initialize(server, options = {})
|
|
67
78
|
@server = server
|
|
68
79
|
@logger = server.logger
|
|
@@ -95,13 +106,29 @@ module VectorMCP
|
|
|
95
106
|
start_time = Time.now
|
|
96
107
|
path = env["PATH_INFO"]
|
|
97
108
|
method = env["REQUEST_METHOD"]
|
|
109
|
+
transport_context = build_transport_context(env, method, path, start_time)
|
|
110
|
+
|
|
111
|
+
logger.debug { "Processing HTTP request #{method} #{path}" }
|
|
98
112
|
|
|
99
|
-
|
|
113
|
+
transport_context = execute_transport_hooks(:before_request, transport_context)
|
|
114
|
+
raise transport_context.error if transport_context.error?
|
|
115
|
+
return transport_context.result if transport_context.result
|
|
100
116
|
|
|
101
117
|
response = route_request(path, method, env)
|
|
118
|
+
transport_context.result = response
|
|
119
|
+
transport_context = execute_transport_hooks(:after_response, transport_context)
|
|
120
|
+
raise transport_context.error if transport_context.error?
|
|
121
|
+
|
|
122
|
+
response = transport_context.result || response
|
|
102
123
|
log_request_completion(method, path, start_time, response[0])
|
|
103
124
|
response
|
|
104
125
|
rescue StandardError => e
|
|
126
|
+
if transport_context
|
|
127
|
+
transport_context.error = e
|
|
128
|
+
transport_context = execute_transport_hooks(:on_transport_error, transport_context)
|
|
129
|
+
return transport_context.result if transport_context.result
|
|
130
|
+
end
|
|
131
|
+
|
|
105
132
|
handle_request_error(method, path, e)
|
|
106
133
|
end
|
|
107
134
|
|
|
@@ -133,16 +160,6 @@ module VectorMCP
|
|
|
133
160
|
@stream_handler.send_message_to_session(session, message)
|
|
134
161
|
end
|
|
135
162
|
|
|
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
163
|
# Sends a server-initiated JSON-RPC request compatible with Session expectations.
|
|
147
164
|
# This method will block until a response is received or the timeout is reached.
|
|
148
165
|
# For HTTP transport, this requires finding an appropriate session with streaming connection.
|
|
@@ -307,10 +324,26 @@ module VectorMCP
|
|
|
307
324
|
# @param env [Hash] The Rack environment
|
|
308
325
|
# @return [Array] Rack response triplet
|
|
309
326
|
def route_request(path, method, env)
|
|
327
|
+
return route_mounted_request(path, method, env) if @mounted
|
|
328
|
+
|
|
329
|
+
# Standalone mode: unchanged behavior
|
|
310
330
|
return handle_health_check if path == "/"
|
|
311
331
|
return not_found_response unless path == @path_prefix
|
|
312
332
|
|
|
313
|
-
|
|
333
|
+
validate_and_dispatch(method, env)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Routes requests when mounted inside another Rack app (e.g., Rails).
|
|
337
|
+
# PATH_INFO is relative to the mount point: "/" = MCP endpoint, "/health" = health check.
|
|
338
|
+
def route_mounted_request(path, method, env)
|
|
339
|
+
return handle_health_check if path == "/health"
|
|
340
|
+
return not_found_response unless ["", "/"].include?(path)
|
|
341
|
+
|
|
342
|
+
validate_and_dispatch(method, env)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Validates origin and dispatches to the appropriate handler by HTTP method.
|
|
346
|
+
def validate_and_dispatch(method, env)
|
|
314
347
|
return forbidden_response("Origin not allowed") unless valid_origin?(env)
|
|
315
348
|
|
|
316
349
|
case method
|
|
@@ -339,14 +372,23 @@ module VectorMCP
|
|
|
339
372
|
request_body = read_request_body(env)
|
|
340
373
|
parsed = parse_json_message(request_body)
|
|
341
374
|
|
|
375
|
+
# MCP spec: POST body MUST be a single JSON-RPC message, not a batch array
|
|
376
|
+
if parsed.is_a?(Array)
|
|
377
|
+
return json_error_response(nil, -32_600, "Invalid Request",
|
|
378
|
+
{ details: "Batch requests are not supported. Send a single JSON-RPC message per POST." })
|
|
379
|
+
end
|
|
380
|
+
|
|
342
381
|
session = resolve_session_for_post(session_id, parsed, env)
|
|
343
382
|
return session if session.is_a?(Array) # Rack error response
|
|
344
383
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
384
|
+
# Validate MCP-Protocol-Version header (skip for initialize requests)
|
|
385
|
+
is_initialize = parsed.is_a?(Hash) && parsed["method"] == "initialize"
|
|
386
|
+
unless is_initialize
|
|
387
|
+
version_error = validate_protocol_version_header(env)
|
|
388
|
+
return version_error if version_error
|
|
349
389
|
end
|
|
390
|
+
|
|
391
|
+
handle_single_request(parsed, session, env)
|
|
350
392
|
rescue JSON::ParserError => e
|
|
351
393
|
json_error_response(nil, -32_700, "Parse error", { details: e.message })
|
|
352
394
|
end
|
|
@@ -363,59 +405,16 @@ module VectorMCP
|
|
|
363
405
|
return [202, { "Mcp-Session-Id" => session.id }, []]
|
|
364
406
|
end
|
|
365
407
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
# Handles a batch of JSON-RPC messages per JSON-RPC 2.0 spec.
|
|
373
|
-
#
|
|
374
|
-
# @param messages [Array] Array of parsed JSON-RPC messages
|
|
375
|
-
# @param session [Session] The resolved session
|
|
376
|
-
# @return [Array] Rack response triplet
|
|
377
|
-
def handle_batch_request(messages, session)
|
|
378
|
-
return json_error_response(nil, -32_600, "Invalid Request", { details: "Empty batch" }) if messages.empty?
|
|
379
|
-
|
|
380
|
-
responses = messages.filter_map do |message|
|
|
381
|
-
next batch_invalid_item_error unless message.is_a?(Hash)
|
|
382
|
-
|
|
383
|
-
process_batch_item(message, session)
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
return [204, { "Mcp-Session-Id" => session.id }, []] if responses.empty?
|
|
387
|
-
|
|
388
|
-
headers = { "Content-Type" => "application/json", "Mcp-Session-Id" => session.id }
|
|
389
|
-
[200, headers, [responses.to_json]]
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
# Processes a single item within a batch request.
|
|
393
|
-
#
|
|
394
|
-
# @param message [Hash] A single JSON-RPC message
|
|
395
|
-
# @param session [Session] The resolved session
|
|
396
|
-
# @return [Hash, nil] Response hash or nil for notifications/outgoing responses
|
|
397
|
-
def process_batch_item(message, session)
|
|
398
|
-
if outgoing_response?(message)
|
|
399
|
-
handle_outgoing_response(message)
|
|
400
|
-
return nil
|
|
408
|
+
# Notifications: has method, no id -> 202 Accepted with no body (MCP spec requirement)
|
|
409
|
+
if message["method"] && !message.key?("id")
|
|
410
|
+
@server.handle_message(message, session.context, session.id)
|
|
411
|
+
return [202, { "Mcp-Session-Id" => session.id }, []]
|
|
401
412
|
end
|
|
402
413
|
|
|
403
414
|
result = @server.handle_message(message, session.context, session.id)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
{ jsonrpc: "2.0", id: message["id"], result: result }
|
|
415
|
+
build_rpc_response(env, result, message["id"], session.id)
|
|
407
416
|
rescue VectorMCP::ProtocolError => e
|
|
408
|
-
|
|
409
|
-
rescue StandardError => e
|
|
410
|
-
{ jsonrpc: "2.0", id: message["id"],
|
|
411
|
-
error: { code: -32_603, message: "Internal error", data: { details: e.message } } }
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
# Returns an error object for non-Hash items in a batch.
|
|
415
|
-
#
|
|
416
|
-
# @return [Hash] JSON-RPC error object
|
|
417
|
-
def batch_invalid_item_error
|
|
418
|
-
{ jsonrpc: "2.0", id: nil, error: { code: -32_600, message: "Invalid Request" } }
|
|
417
|
+
build_protocol_error_response(env, e, session_id: session.id)
|
|
419
418
|
end
|
|
420
419
|
|
|
421
420
|
# Resolves or creates the session for a POST request following MCP spec rules:
|
|
@@ -425,12 +424,11 @@ module VectorMCP
|
|
|
425
424
|
# - no session_id + other request → 400 Bad Request
|
|
426
425
|
#
|
|
427
426
|
# @param session_id [String, nil] Client-supplied Mcp-Session-Id header value
|
|
428
|
-
# @param message [Hash
|
|
427
|
+
# @param message [Hash] Parsed JSON-RPC message
|
|
429
428
|
# @param env [Hash] Rack environment
|
|
430
429
|
# @return [Session, Array] Session object or Rack error response triplet
|
|
431
430
|
def resolve_session_for_post(session_id, message, env)
|
|
432
|
-
|
|
433
|
-
is_initialize = first_message.is_a?(Hash) && first_message["method"] == "initialize"
|
|
431
|
+
is_initialize = message.is_a?(Hash) && message["method"] == "initialize"
|
|
434
432
|
|
|
435
433
|
if session_id
|
|
436
434
|
session = @session_manager.get_session(session_id)
|
|
@@ -464,6 +462,9 @@ module VectorMCP
|
|
|
464
462
|
session = @session_manager.get_or_create_session(session_id, env)
|
|
465
463
|
return not_found_response unless session
|
|
466
464
|
|
|
465
|
+
version_error = validate_protocol_version_header(env)
|
|
466
|
+
return version_error if version_error
|
|
467
|
+
|
|
467
468
|
@stream_handler.handle_streaming_request(env, session)
|
|
468
469
|
end
|
|
469
470
|
|
|
@@ -475,6 +476,9 @@ module VectorMCP
|
|
|
475
476
|
session_id = extract_session_id(env)
|
|
476
477
|
return bad_request_response("Missing Mcp-Session-Id header") unless session_id
|
|
477
478
|
|
|
479
|
+
version_error = validate_protocol_version_header(env)
|
|
480
|
+
return version_error if version_error
|
|
481
|
+
|
|
478
482
|
success = @session_manager.terminate_session(session_id)
|
|
479
483
|
if success
|
|
480
484
|
[204, {}, []]
|
|
@@ -590,10 +594,11 @@ module VectorMCP
|
|
|
590
594
|
accept.include?("text/event-stream")
|
|
591
595
|
end
|
|
592
596
|
|
|
593
|
-
def format_sse_event(data, type, event_id)
|
|
597
|
+
def format_sse_event(data, type, event_id, retry_ms: nil)
|
|
594
598
|
lines = []
|
|
595
|
-
lines << "id: #{event_id}"
|
|
599
|
+
lines << "id: #{event_id}" if event_id
|
|
596
600
|
lines << "event: #{type}" if type
|
|
601
|
+
lines << "retry: #{retry_ms}" if retry_ms
|
|
597
602
|
lines << "data: #{data}"
|
|
598
603
|
lines << ""
|
|
599
604
|
"#{lines.join("\n")}\n"
|
|
@@ -602,8 +607,14 @@ module VectorMCP
|
|
|
602
607
|
def sse_rpc_response(result, request_id, headers = {}, session_id: nil)
|
|
603
608
|
response = { jsonrpc: "2.0", id: request_id, result: result }
|
|
604
609
|
event_data = response.to_json
|
|
610
|
+
stream_id = generate_sse_stream_id(session_id, :post)
|
|
605
611
|
|
|
606
|
-
|
|
612
|
+
# Priming event per MCP spec: event ID + empty data field
|
|
613
|
+
prime_event_id = @event_store.store_event("", nil, session_id: session_id, stream_id: stream_id)
|
|
614
|
+
prime_event = "id: #{prime_event_id}\ndata:\n\n"
|
|
615
|
+
|
|
616
|
+
# Actual response event
|
|
617
|
+
event_id = @event_store.store_event(event_data, "message", session_id: session_id, stream_id: stream_id)
|
|
607
618
|
sse_event = format_sse_event(event_data, "message", event_id)
|
|
608
619
|
|
|
609
620
|
response_headers = {
|
|
@@ -613,7 +624,7 @@ module VectorMCP
|
|
|
613
624
|
"X-Accel-Buffering" => "no"
|
|
614
625
|
}.merge(headers)
|
|
615
626
|
|
|
616
|
-
[200, response_headers, [sse_event]]
|
|
627
|
+
[200, response_headers, [prime_event, sse_event]]
|
|
617
628
|
end
|
|
618
629
|
|
|
619
630
|
def sse_error_response(id, code, err_message, data = nil, session_id: nil)
|
|
@@ -621,8 +632,13 @@ module VectorMCP
|
|
|
621
632
|
error_obj[:data] = data if data
|
|
622
633
|
response = { jsonrpc: "2.0", id: id, error: error_obj }
|
|
623
634
|
event_data = response.to_json
|
|
635
|
+
stream_id = generate_sse_stream_id(session_id, :post)
|
|
636
|
+
|
|
637
|
+
# Priming event per MCP spec
|
|
638
|
+
prime_event_id = @event_store.store_event("", nil, session_id: session_id, stream_id: stream_id)
|
|
639
|
+
prime_event = "id: #{prime_event_id}\ndata:\n\n"
|
|
624
640
|
|
|
625
|
-
event_id = @event_store.store_event(event_data, "message", session_id: session_id)
|
|
641
|
+
event_id = @event_store.store_event(event_data, "message", session_id: session_id, stream_id: stream_id)
|
|
626
642
|
sse_event = format_sse_event(event_data, "message", event_id)
|
|
627
643
|
|
|
628
644
|
response_headers = {
|
|
@@ -630,7 +646,7 @@ module VectorMCP
|
|
|
630
646
|
"Cache-Control" => "no-cache"
|
|
631
647
|
}
|
|
632
648
|
|
|
633
|
-
[200, response_headers, [sse_event]]
|
|
649
|
+
[200, response_headers, [prime_event, sse_event]]
|
|
634
650
|
end
|
|
635
651
|
|
|
636
652
|
def not_found_response(message = "Not Found")
|
|
@@ -642,7 +658,8 @@ module VectorMCP
|
|
|
642
658
|
end
|
|
643
659
|
|
|
644
660
|
def forbidden_response(message = "Forbidden")
|
|
645
|
-
|
|
661
|
+
error = { jsonrpc: "2.0", error: { code: -32_600, message: message } }
|
|
662
|
+
[403, { "Content-Type" => "application/json" }, [error.to_json]]
|
|
646
663
|
end
|
|
647
664
|
|
|
648
665
|
def method_not_allowed_response(allowed_methods)
|
|
@@ -654,11 +671,26 @@ module VectorMCP
|
|
|
654
671
|
[406, { "Content-Type" => "text/plain" }, [message]]
|
|
655
672
|
end
|
|
656
673
|
|
|
674
|
+
# Validates the MCP-Protocol-Version header per spec.
|
|
675
|
+
# Returns nil if valid, or a 400 Rack response if unsupported.
|
|
676
|
+
def validate_protocol_version_header(env)
|
|
677
|
+
version = env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
678
|
+
return nil if version.nil? # Backwards compatibility: assume 2025-03-26
|
|
679
|
+
|
|
680
|
+
unless VectorMCP::Server::SUPPORTED_PROTOCOL_VERSIONS.include?(version)
|
|
681
|
+
return bad_request_response("Unsupported MCP-Protocol-Version: #{version}")
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
nil
|
|
685
|
+
end
|
|
686
|
+
|
|
657
687
|
def valid_post_accept?(env)
|
|
658
688
|
accept = env["HTTP_ACCEPT"]
|
|
659
689
|
return true if accept.nil? || accept.strip.empty?
|
|
690
|
+
return true if accept.include?("*/*")
|
|
660
691
|
|
|
661
|
-
|
|
692
|
+
# MCP spec: client MUST include both application/json AND text/event-stream
|
|
693
|
+
accept.include?("application/json") && accept.include?("text/event-stream")
|
|
662
694
|
end
|
|
663
695
|
|
|
664
696
|
def valid_get_accept?(env)
|
|
@@ -668,7 +700,13 @@ module VectorMCP
|
|
|
668
700
|
accept.include?("text/event-stream") || accept.include?("*/*")
|
|
669
701
|
end
|
|
670
702
|
|
|
671
|
-
# Validates the Origin header for security
|
|
703
|
+
# Validates the Origin header for security.
|
|
704
|
+
#
|
|
705
|
+
# Matches are checked both exactly and as prefix (so that
|
|
706
|
+
# +http://localhost+ in the allowed list matches +http://localhost:3000+).
|
|
707
|
+
#
|
|
708
|
+
# Requests without an Origin header are allowed through because they
|
|
709
|
+
# originate from non-browser contexts (curl, server-to-server, etc.).
|
|
672
710
|
#
|
|
673
711
|
# @param env [Hash] The Rack environment
|
|
674
712
|
# @return [Boolean] True if origin is allowed, false otherwise
|
|
@@ -678,7 +716,9 @@ module VectorMCP
|
|
|
678
716
|
origin = env["HTTP_ORIGIN"]
|
|
679
717
|
return true if origin.nil? # Allow requests without Origin header (e.g., server-to-server)
|
|
680
718
|
|
|
681
|
-
@allowed_origins.
|
|
719
|
+
@allowed_origins.any? do |allowed|
|
|
720
|
+
origin == allowed || origin.start_with?("#{allowed}:")
|
|
721
|
+
end
|
|
682
722
|
end
|
|
683
723
|
|
|
684
724
|
# Logging and error handling
|
|
@@ -692,6 +732,29 @@ module VectorMCP
|
|
|
692
732
|
[500, { "Content-Type" => "text/plain" }, ["Internal Server Error"]]
|
|
693
733
|
end
|
|
694
734
|
|
|
735
|
+
def build_transport_context(env, method, path, start_time)
|
|
736
|
+
request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
|
|
737
|
+
|
|
738
|
+
VectorMCP::Middleware::Context.new(
|
|
739
|
+
operation_type: :transport,
|
|
740
|
+
operation_name: "#{method} #{path}",
|
|
741
|
+
params: request_context.to_h,
|
|
742
|
+
session: nil,
|
|
743
|
+
server: @server,
|
|
744
|
+
metadata: {
|
|
745
|
+
start_time: start_time,
|
|
746
|
+
path: path,
|
|
747
|
+
method: method
|
|
748
|
+
}
|
|
749
|
+
)
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def execute_transport_hooks(hook_type, context)
|
|
753
|
+
return context unless @server.respond_to?(:middleware_manager) && @server.middleware_manager
|
|
754
|
+
|
|
755
|
+
@server.middleware_manager.execute_hooks(hook_type, context)
|
|
756
|
+
end
|
|
757
|
+
|
|
695
758
|
def handle_fatal_error(error)
|
|
696
759
|
logger.fatal { "Fatal error in HttpStream transport: #{error.message}" }
|
|
697
760
|
exit(1)
|
|
@@ -839,8 +902,8 @@ module VectorMCP
|
|
|
839
902
|
#
|
|
840
903
|
# @param obj [Object] The object to transform (Hash, Array, or other)
|
|
841
904
|
# @return [Object] The transformed object
|
|
842
|
-
def deep_transform_keys(obj, &
|
|
843
|
-
transform_object_keys(obj, &
|
|
905
|
+
def deep_transform_keys(obj, &)
|
|
906
|
+
transform_object_keys(obj, &)
|
|
844
907
|
end
|
|
845
908
|
|
|
846
909
|
# Core transformation logic extracted for better maintainability
|
|
@@ -874,7 +937,18 @@ module VectorMCP
|
|
|
874
937
|
@path_prefix = normalize_path_prefix(options[:path_prefix] || DEFAULT_PATH_PREFIX)
|
|
875
938
|
@session_timeout = options[:session_timeout] || DEFAULT_SESSION_TIMEOUT
|
|
876
939
|
@event_retention = options[:event_retention] || DEFAULT_EVENT_RETENTION
|
|
877
|
-
@allowed_origins = options[:allowed_origins] ||
|
|
940
|
+
@allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
|
|
941
|
+
@mounted = options.fetch(:mounted, false)
|
|
942
|
+
|
|
943
|
+
warn_on_permissive_origins if @allowed_origins.include?("*")
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# Logs a security warning when wildcard origin is configured.
|
|
947
|
+
def warn_on_permissive_origins
|
|
948
|
+
logger.warn do
|
|
949
|
+
"[SECURITY] allowed_origins includes '*', which permits cross-origin requests from any website. " \
|
|
950
|
+
"This is not recommended for production. Specify explicit origins instead."
|
|
951
|
+
end
|
|
878
952
|
end
|
|
879
953
|
|
|
880
954
|
# Initialize core HTTP stream components
|
|
@@ -901,6 +975,11 @@ module VectorMCP
|
|
|
901
975
|
@request_id_counter = Concurrent::AtomicFixnum.new(0)
|
|
902
976
|
end
|
|
903
977
|
|
|
978
|
+
def generate_sse_stream_id(session_id, origin)
|
|
979
|
+
session_label = session_id || "anonymous"
|
|
980
|
+
"#{session_label}-#{origin}-#{SecureRandom.hex(4)}"
|
|
981
|
+
end
|
|
982
|
+
|
|
904
983
|
# Generate a unique, thread-safe request ID for server-initiated requests
|
|
905
984
|
#
|
|
906
985
|
# @return [String] A unique request ID in format: vecmcp_http_{pid}_{random}_{counter}
|
data/lib/vector_mcp/version.rb
CHANGED
data/lib/vector_mcp.rb
CHANGED
|
@@ -11,17 +11,16 @@ require_relative "vector_mcp/util"
|
|
|
11
11
|
require_relative "vector_mcp/log_filter"
|
|
12
12
|
require_relative "vector_mcp/image_util"
|
|
13
13
|
require_relative "vector_mcp/handlers/core"
|
|
14
|
-
require_relative "vector_mcp/transport/stdio"
|
|
15
|
-
# require_relative "vector_mcp/transport/sse" # Load on demand
|
|
16
14
|
require_relative "vector_mcp/logger"
|
|
17
15
|
require_relative "vector_mcp/middleware"
|
|
18
16
|
require_relative "vector_mcp/server"
|
|
17
|
+
require_relative "vector_mcp/tool"
|
|
19
18
|
|
|
20
19
|
# The VectorMCP module provides a full-featured, opinionated Ruby implementation
|
|
21
20
|
# of the **Model Context Protocol (MCP)**. It gives developers everything needed
|
|
22
21
|
# to spin up an MCP-compatible server—including:
|
|
23
22
|
#
|
|
24
|
-
# * **Transport adapters** (
|
|
23
|
+
# * **Transport adapters** (streamable HTTP)
|
|
25
24
|
# * **High-level abstractions** for *tools*, *resources*, and *prompts*
|
|
26
25
|
# * **JSON-RPC 2.0** message handling with sensible defaults and detailed
|
|
27
26
|
# error reporting helpers
|
|
@@ -40,11 +39,10 @@ require_relative "vector_mcp/server"
|
|
|
40
39
|
# input_schema: {type: "object", properties: {text: {type: "string"}}}
|
|
41
40
|
# ) { |args| args["text"] }
|
|
42
41
|
#
|
|
43
|
-
# server.run # => starts the
|
|
42
|
+
# server.run # => starts the HTTP stream transport and begins processing JSON-RPC messages
|
|
44
43
|
# ```
|
|
45
44
|
#
|
|
46
|
-
#
|
|
47
|
-
# order to serve multiple concurrent clients over HTTP.
|
|
45
|
+
# The default HTTP stream transport supports multiple concurrent clients over HTTP.
|
|
48
46
|
#
|
|
49
47
|
module VectorMCP
|
|
50
48
|
class << self
|
|
@@ -68,8 +66,8 @@ module VectorMCP
|
|
|
68
66
|
# Any positional or keyword arguments are forwarded verbatim to the underlying
|
|
69
67
|
# constructor, so refer to {VectorMCP::Server#initialize} for the full list of
|
|
70
68
|
# accepted parameters.
|
|
71
|
-
def new(
|
|
72
|
-
Server.new(
|
|
69
|
+
def new(*, **)
|
|
70
|
+
Server.new(*, **)
|
|
73
71
|
end
|
|
74
72
|
end
|
|
75
73
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vector_mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergio Bayona
|
|
@@ -136,6 +136,7 @@ files:
|
|
|
136
136
|
- lib/vector_mcp/middleware/context.rb
|
|
137
137
|
- lib/vector_mcp/middleware/hook.rb
|
|
138
138
|
- lib/vector_mcp/middleware/manager.rb
|
|
139
|
+
- lib/vector_mcp/rails/tool.rb
|
|
139
140
|
- lib/vector_mcp/request_context.rb
|
|
140
141
|
- lib/vector_mcp/sampling/request.rb
|
|
141
142
|
- lib/vector_mcp/sampling/result.rb
|
|
@@ -152,19 +153,12 @@ files:
|
|
|
152
153
|
- lib/vector_mcp/server/message_handling.rb
|
|
153
154
|
- lib/vector_mcp/server/registry.rb
|
|
154
155
|
- lib/vector_mcp/session.rb
|
|
156
|
+
- lib/vector_mcp/tool.rb
|
|
155
157
|
- lib/vector_mcp/transport/base_session_manager.rb
|
|
156
158
|
- lib/vector_mcp/transport/http_stream.rb
|
|
157
159
|
- lib/vector_mcp/transport/http_stream/event_store.rb
|
|
158
160
|
- lib/vector_mcp/transport/http_stream/session_manager.rb
|
|
159
161
|
- lib/vector_mcp/transport/http_stream/stream_handler.rb
|
|
160
|
-
- lib/vector_mcp/transport/sse.rb
|
|
161
|
-
- lib/vector_mcp/transport/sse/client_connection.rb
|
|
162
|
-
- lib/vector_mcp/transport/sse/message_handler.rb
|
|
163
|
-
- lib/vector_mcp/transport/sse/puma_config.rb
|
|
164
|
-
- lib/vector_mcp/transport/sse/stream_manager.rb
|
|
165
|
-
- lib/vector_mcp/transport/sse_session_manager.rb
|
|
166
|
-
- lib/vector_mcp/transport/stdio.rb
|
|
167
|
-
- lib/vector_mcp/transport/stdio_session_manager.rb
|
|
168
162
|
- lib/vector_mcp/util.rb
|
|
169
163
|
- lib/vector_mcp/version.rb
|
|
170
164
|
homepage: https://github.com/sergiobayona/vector_mcp
|
|
@@ -182,7 +176,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
182
176
|
requirements:
|
|
183
177
|
- - ">="
|
|
184
178
|
- !ruby/object:Gem::Version
|
|
185
|
-
version: 3.
|
|
179
|
+
version: '3.2'
|
|
186
180
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
187
181
|
requirements:
|
|
188
182
|
- - ">="
|
|
@@ -1,113 +0,0 @@
|
|
|
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}: #{VectorMCP::LogFilter.filter_hash(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
|