vector_mcp 0.3.3 → 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 +80 -0
- data/README.md +132 -342
- data/lib/vector_mcp/handlers/core.rb +82 -27
- data/lib/vector_mcp/image_util.rb +53 -5
- data/lib/vector_mcp/log_filter.rb +48 -0
- 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/security/strategies/api_key.rb +27 -4
- data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
- data/lib/vector_mcp/server/capabilities.rb +4 -10
- data/lib/vector_mcp/server/message_handling.rb +2 -2
- data/lib/vector_mcp/server/registry.rb +36 -4
- data/lib/vector_mcp/server.rb +49 -41
- 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 +33 -13
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +39 -14
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +133 -47
- data/lib/vector_mcp/transport/http_stream.rb +294 -33
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +7 -8
- metadata +5 -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
|
|
@@ -330,28 +363,87 @@ module VectorMCP
|
|
|
330
363
|
# @param env [Hash] The Rack environment
|
|
331
364
|
# @return [Array] Rack response triplet
|
|
332
365
|
def handle_post_request(env)
|
|
333
|
-
|
|
334
|
-
|
|
366
|
+
unless valid_post_accept?(env)
|
|
367
|
+
logger.warn { "POST request with unsupported Accept header: #{env["HTTP_ACCEPT"]}" }
|
|
368
|
+
return not_acceptable_response("Not Acceptable: POST requires Accept: application/json")
|
|
369
|
+
end
|
|
335
370
|
|
|
371
|
+
session_id = extract_session_id(env)
|
|
336
372
|
request_body = read_request_body(env)
|
|
337
|
-
|
|
373
|
+
parsed = parse_json_message(request_body)
|
|
338
374
|
|
|
339
|
-
#
|
|
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
|
+
|
|
381
|
+
session = resolve_session_for_post(session_id, parsed, env)
|
|
382
|
+
return session if session.is_a?(Array) # Rack error response
|
|
383
|
+
|
|
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
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
handle_single_request(parsed, session, env)
|
|
392
|
+
rescue JSON::ParserError => e
|
|
393
|
+
json_error_response(nil, -32_700, "Parse error", { details: e.message })
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Handles a single JSON-RPC message from a POST request.
|
|
397
|
+
#
|
|
398
|
+
# @param message [Hash] Parsed JSON-RPC message
|
|
399
|
+
# @param session [Session] The resolved session
|
|
400
|
+
# @param env [Hash] The Rack environment
|
|
401
|
+
# @return [Array] Rack response triplet
|
|
402
|
+
def handle_single_request(message, session, env)
|
|
340
403
|
if outgoing_response?(message)
|
|
341
404
|
handle_outgoing_response(message)
|
|
342
|
-
# For responses, return 202 Accepted with no body
|
|
343
405
|
return [202, { "Mcp-Session-Id" => session.id }, []]
|
|
344
406
|
end
|
|
345
407
|
|
|
346
|
-
|
|
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 }, []]
|
|
412
|
+
end
|
|
347
413
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
json_rpc_response(result, message["id"], headers)
|
|
414
|
+
result = @server.handle_message(message, session.context, session.id)
|
|
415
|
+
build_rpc_response(env, result, message["id"], session.id)
|
|
351
416
|
rescue VectorMCP::ProtocolError => e
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
417
|
+
build_protocol_error_response(env, e, session_id: session.id)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Resolves or creates the session for a POST request following MCP spec rules:
|
|
421
|
+
# - session_id present and known → return existing session (updating request context)
|
|
422
|
+
# - session_id present but unknown/expired → 404 Not Found
|
|
423
|
+
# - no session_id + initialize request → create new session
|
|
424
|
+
# - no session_id + other request → 400 Bad Request
|
|
425
|
+
#
|
|
426
|
+
# @param session_id [String, nil] Client-supplied Mcp-Session-Id header value
|
|
427
|
+
# @param message [Hash] Parsed JSON-RPC message
|
|
428
|
+
# @param env [Hash] Rack environment
|
|
429
|
+
# @return [Session, Array] Session object or Rack error response triplet
|
|
430
|
+
def resolve_session_for_post(session_id, message, env)
|
|
431
|
+
is_initialize = message.is_a?(Hash) && message["method"] == "initialize"
|
|
432
|
+
|
|
433
|
+
if session_id
|
|
434
|
+
session = @session_manager.get_session(session_id)
|
|
435
|
+
return not_found_response("Unknown or expired session") unless session
|
|
436
|
+
|
|
437
|
+
if env
|
|
438
|
+
request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
|
|
439
|
+
session.context.request_context = request_context
|
|
440
|
+
end
|
|
441
|
+
session
|
|
442
|
+
elsif is_initialize
|
|
443
|
+
@session_manager.create_session(nil, env)
|
|
444
|
+
else
|
|
445
|
+
bad_request_response("Missing Mcp-Session-Id header")
|
|
446
|
+
end
|
|
355
447
|
end
|
|
356
448
|
|
|
357
449
|
# Handles GET requests (SSE streaming)
|
|
@@ -359,12 +451,20 @@ module VectorMCP
|
|
|
359
451
|
# @param env [Hash] The Rack environment
|
|
360
452
|
# @return [Array] Rack response triplet
|
|
361
453
|
def handle_get_request(env)
|
|
454
|
+
unless valid_get_accept?(env)
|
|
455
|
+
logger.warn { "GET request with unsupported Accept header: #{env["HTTP_ACCEPT"]}" }
|
|
456
|
+
return not_acceptable_response("Not Acceptable: GET requires Accept: text/event-stream")
|
|
457
|
+
end
|
|
458
|
+
|
|
362
459
|
session_id = extract_session_id(env)
|
|
363
460
|
return bad_request_response("Missing Mcp-Session-Id header") unless session_id
|
|
364
461
|
|
|
365
462
|
session = @session_manager.get_or_create_session(session_id, env)
|
|
366
463
|
return not_found_response unless session
|
|
367
464
|
|
|
465
|
+
version_error = validate_protocol_version_header(env)
|
|
466
|
+
return version_error if version_error
|
|
467
|
+
|
|
368
468
|
@stream_handler.handle_streaming_request(env, session)
|
|
369
469
|
end
|
|
370
470
|
|
|
@@ -376,6 +476,9 @@ module VectorMCP
|
|
|
376
476
|
session_id = extract_session_id(env)
|
|
377
477
|
return bad_request_response("Missing Mcp-Session-Id header") unless session_id
|
|
378
478
|
|
|
479
|
+
version_error = validate_protocol_version_header(env)
|
|
480
|
+
return version_error if version_error
|
|
481
|
+
|
|
379
482
|
success = @session_manager.terminate_session(session_id)
|
|
380
483
|
if success
|
|
381
484
|
[204, {}, []]
|
|
@@ -469,8 +572,85 @@ module VectorMCP
|
|
|
469
572
|
[400, { "Content-Type" => "application/json" }, [response.to_json]]
|
|
470
573
|
end
|
|
471
574
|
|
|
472
|
-
def
|
|
473
|
-
|
|
575
|
+
def build_rpc_response(env, result, request_id, session_id)
|
|
576
|
+
headers = { "Mcp-Session-Id" => session_id }
|
|
577
|
+
if client_accepts_sse?(env)
|
|
578
|
+
sse_rpc_response(result, request_id, headers, session_id: session_id)
|
|
579
|
+
else
|
|
580
|
+
json_rpc_response(result, request_id, headers)
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def build_protocol_error_response(env, error, session_id: nil)
|
|
585
|
+
if client_accepts_sse?(env)
|
|
586
|
+
sse_error_response(error.request_id, error.code, error.message, error.details, session_id: session_id)
|
|
587
|
+
else
|
|
588
|
+
json_error_response(error.request_id, error.code, error.message, error.details)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def client_accepts_sse?(env)
|
|
593
|
+
accept = env["HTTP_ACCEPT"] || ""
|
|
594
|
+
accept.include?("text/event-stream")
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def format_sse_event(data, type, event_id, retry_ms: nil)
|
|
598
|
+
lines = []
|
|
599
|
+
lines << "id: #{event_id}" if event_id
|
|
600
|
+
lines << "event: #{type}" if type
|
|
601
|
+
lines << "retry: #{retry_ms}" if retry_ms
|
|
602
|
+
lines << "data: #{data}"
|
|
603
|
+
lines << ""
|
|
604
|
+
"#{lines.join("\n")}\n"
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def sse_rpc_response(result, request_id, headers = {}, session_id: nil)
|
|
608
|
+
response = { jsonrpc: "2.0", id: request_id, result: result }
|
|
609
|
+
event_data = response.to_json
|
|
610
|
+
stream_id = generate_sse_stream_id(session_id, :post)
|
|
611
|
+
|
|
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)
|
|
618
|
+
sse_event = format_sse_event(event_data, "message", event_id)
|
|
619
|
+
|
|
620
|
+
response_headers = {
|
|
621
|
+
"Content-Type" => "text/event-stream",
|
|
622
|
+
"Cache-Control" => "no-cache",
|
|
623
|
+
"Connection" => "keep-alive",
|
|
624
|
+
"X-Accel-Buffering" => "no"
|
|
625
|
+
}.merge(headers)
|
|
626
|
+
|
|
627
|
+
[200, response_headers, [prime_event, sse_event]]
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def sse_error_response(id, code, err_message, data = nil, session_id: nil)
|
|
631
|
+
error_obj = { code: code, message: err_message }
|
|
632
|
+
error_obj[:data] = data if data
|
|
633
|
+
response = { jsonrpc: "2.0", id: id, error: error_obj }
|
|
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"
|
|
640
|
+
|
|
641
|
+
event_id = @event_store.store_event(event_data, "message", session_id: session_id, stream_id: stream_id)
|
|
642
|
+
sse_event = format_sse_event(event_data, "message", event_id)
|
|
643
|
+
|
|
644
|
+
response_headers = {
|
|
645
|
+
"Content-Type" => "text/event-stream",
|
|
646
|
+
"Cache-Control" => "no-cache"
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
[200, response_headers, [prime_event, sse_event]]
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def not_found_response(message = "Not Found")
|
|
653
|
+
[404, { "Content-Type" => "text/plain" }, [message]]
|
|
474
654
|
end
|
|
475
655
|
|
|
476
656
|
def bad_request_response(message = "Bad Request")
|
|
@@ -478,7 +658,8 @@ module VectorMCP
|
|
|
478
658
|
end
|
|
479
659
|
|
|
480
660
|
def forbidden_response(message = "Forbidden")
|
|
481
|
-
|
|
661
|
+
error = { jsonrpc: "2.0", error: { code: -32_600, message: message } }
|
|
662
|
+
[403, { "Content-Type" => "application/json" }, [error.to_json]]
|
|
482
663
|
end
|
|
483
664
|
|
|
484
665
|
def method_not_allowed_response(allowed_methods)
|
|
@@ -486,7 +667,46 @@ module VectorMCP
|
|
|
486
667
|
["Method Not Allowed"]]
|
|
487
668
|
end
|
|
488
669
|
|
|
489
|
-
|
|
670
|
+
def not_acceptable_response(message = "Not Acceptable")
|
|
671
|
+
[406, { "Content-Type" => "text/plain" }, [message]]
|
|
672
|
+
end
|
|
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
|
+
|
|
687
|
+
def valid_post_accept?(env)
|
|
688
|
+
accept = env["HTTP_ACCEPT"]
|
|
689
|
+
return true if accept.nil? || accept.strip.empty?
|
|
690
|
+
return true if accept.include?("*/*")
|
|
691
|
+
|
|
692
|
+
# MCP spec: client MUST include both application/json AND text/event-stream
|
|
693
|
+
accept.include?("application/json") && accept.include?("text/event-stream")
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def valid_get_accept?(env)
|
|
697
|
+
accept = env["HTTP_ACCEPT"]
|
|
698
|
+
return true if accept.nil? || accept.strip.empty?
|
|
699
|
+
|
|
700
|
+
accept.include?("text/event-stream") || accept.include?("*/*")
|
|
701
|
+
end
|
|
702
|
+
|
|
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.).
|
|
490
710
|
#
|
|
491
711
|
# @param env [Hash] The Rack environment
|
|
492
712
|
# @return [Boolean] True if origin is allowed, false otherwise
|
|
@@ -496,7 +716,9 @@ module VectorMCP
|
|
|
496
716
|
origin = env["HTTP_ORIGIN"]
|
|
497
717
|
return true if origin.nil? # Allow requests without Origin header (e.g., server-to-server)
|
|
498
718
|
|
|
499
|
-
@allowed_origins.
|
|
719
|
+
@allowed_origins.any? do |allowed|
|
|
720
|
+
origin == allowed || origin.start_with?("#{allowed}:")
|
|
721
|
+
end
|
|
500
722
|
end
|
|
501
723
|
|
|
502
724
|
# Logging and error handling
|
|
@@ -510,6 +732,29 @@ module VectorMCP
|
|
|
510
732
|
[500, { "Content-Type" => "text/plain" }, ["Internal Server Error"]]
|
|
511
733
|
end
|
|
512
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
|
+
|
|
513
758
|
def handle_fatal_error(error)
|
|
514
759
|
logger.fatal { "Fatal error in HttpStream transport: #{error.message}" }
|
|
515
760
|
exit(1)
|
|
@@ -657,8 +902,8 @@ module VectorMCP
|
|
|
657
902
|
#
|
|
658
903
|
# @param obj [Object] The object to transform (Hash, Array, or other)
|
|
659
904
|
# @return [Object] The transformed object
|
|
660
|
-
def deep_transform_keys(obj, &
|
|
661
|
-
transform_object_keys(obj, &
|
|
905
|
+
def deep_transform_keys(obj, &)
|
|
906
|
+
transform_object_keys(obj, &)
|
|
662
907
|
end
|
|
663
908
|
|
|
664
909
|
# Core transformation logic extracted for better maintainability
|
|
@@ -692,7 +937,18 @@ module VectorMCP
|
|
|
692
937
|
@path_prefix = normalize_path_prefix(options[:path_prefix] || DEFAULT_PATH_PREFIX)
|
|
693
938
|
@session_timeout = options[:session_timeout] || DEFAULT_SESSION_TIMEOUT
|
|
694
939
|
@event_retention = options[:event_retention] || DEFAULT_EVENT_RETENTION
|
|
695
|
-
@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
|
|
696
952
|
end
|
|
697
953
|
|
|
698
954
|
# Initialize core HTTP stream components
|
|
@@ -719,6 +975,11 @@ module VectorMCP
|
|
|
719
975
|
@request_id_counter = Concurrent::AtomicFixnum.new(0)
|
|
720
976
|
end
|
|
721
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
|
+
|
|
722
983
|
# Generate a unique, thread-safe request ID for server-initiated requests
|
|
723
984
|
#
|
|
724
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
|
@@ -8,19 +8,19 @@ require_relative "vector_mcp/errors"
|
|
|
8
8
|
require_relative "vector_mcp/definitions"
|
|
9
9
|
require_relative "vector_mcp/session"
|
|
10
10
|
require_relative "vector_mcp/util"
|
|
11
|
+
require_relative "vector_mcp/log_filter"
|
|
11
12
|
require_relative "vector_mcp/image_util"
|
|
12
13
|
require_relative "vector_mcp/handlers/core"
|
|
13
|
-
require_relative "vector_mcp/transport/stdio"
|
|
14
|
-
# require_relative "vector_mcp/transport/sse" # Load on demand
|
|
15
14
|
require_relative "vector_mcp/logger"
|
|
16
15
|
require_relative "vector_mcp/middleware"
|
|
17
16
|
require_relative "vector_mcp/server"
|
|
17
|
+
require_relative "vector_mcp/tool"
|
|
18
18
|
|
|
19
19
|
# The VectorMCP module provides a full-featured, opinionated Ruby implementation
|
|
20
20
|
# of the **Model Context Protocol (MCP)**. It gives developers everything needed
|
|
21
21
|
# to spin up an MCP-compatible server—including:
|
|
22
22
|
#
|
|
23
|
-
# * **Transport adapters** (
|
|
23
|
+
# * **Transport adapters** (streamable HTTP)
|
|
24
24
|
# * **High-level abstractions** for *tools*, *resources*, and *prompts*
|
|
25
25
|
# * **JSON-RPC 2.0** message handling with sensible defaults and detailed
|
|
26
26
|
# error reporting helpers
|
|
@@ -39,11 +39,10 @@ require_relative "vector_mcp/server"
|
|
|
39
39
|
# input_schema: {type: "object", properties: {text: {type: "string"}}}
|
|
40
40
|
# ) { |args| args["text"] }
|
|
41
41
|
#
|
|
42
|
-
# server.run # => starts the
|
|
42
|
+
# server.run # => starts the HTTP stream transport and begins processing JSON-RPC messages
|
|
43
43
|
# ```
|
|
44
44
|
#
|
|
45
|
-
#
|
|
46
|
-
# order to serve multiple concurrent clients over HTTP.
|
|
45
|
+
# The default HTTP stream transport supports multiple concurrent clients over HTTP.
|
|
47
46
|
#
|
|
48
47
|
module VectorMCP
|
|
49
48
|
class << self
|
|
@@ -67,8 +66,8 @@ module VectorMCP
|
|
|
67
66
|
# Any positional or keyword arguments are forwarded verbatim to the underlying
|
|
68
67
|
# constructor, so refer to {VectorMCP::Server#initialize} for the full list of
|
|
69
68
|
# accepted parameters.
|
|
70
|
-
def new(
|
|
71
|
-
Server.new(
|
|
69
|
+
def new(*, **)
|
|
70
|
+
Server.new(*, **)
|
|
72
71
|
end
|
|
73
72
|
end
|
|
74
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
|
|
@@ -129,12 +129,14 @@ files:
|
|
|
129
129
|
- lib/vector_mcp/errors.rb
|
|
130
130
|
- lib/vector_mcp/handlers/core.rb
|
|
131
131
|
- lib/vector_mcp/image_util.rb
|
|
132
|
+
- lib/vector_mcp/log_filter.rb
|
|
132
133
|
- lib/vector_mcp/logger.rb
|
|
133
134
|
- lib/vector_mcp/middleware.rb
|
|
134
135
|
- lib/vector_mcp/middleware/base.rb
|
|
135
136
|
- lib/vector_mcp/middleware/context.rb
|
|
136
137
|
- lib/vector_mcp/middleware/hook.rb
|
|
137
138
|
- lib/vector_mcp/middleware/manager.rb
|
|
139
|
+
- lib/vector_mcp/rails/tool.rb
|
|
138
140
|
- lib/vector_mcp/request_context.rb
|
|
139
141
|
- lib/vector_mcp/sampling/request.rb
|
|
140
142
|
- lib/vector_mcp/sampling/result.rb
|
|
@@ -151,19 +153,12 @@ files:
|
|
|
151
153
|
- lib/vector_mcp/server/message_handling.rb
|
|
152
154
|
- lib/vector_mcp/server/registry.rb
|
|
153
155
|
- lib/vector_mcp/session.rb
|
|
156
|
+
- lib/vector_mcp/tool.rb
|
|
154
157
|
- lib/vector_mcp/transport/base_session_manager.rb
|
|
155
158
|
- lib/vector_mcp/transport/http_stream.rb
|
|
156
159
|
- lib/vector_mcp/transport/http_stream/event_store.rb
|
|
157
160
|
- lib/vector_mcp/transport/http_stream/session_manager.rb
|
|
158
161
|
- lib/vector_mcp/transport/http_stream/stream_handler.rb
|
|
159
|
-
- lib/vector_mcp/transport/sse.rb
|
|
160
|
-
- lib/vector_mcp/transport/sse/client_connection.rb
|
|
161
|
-
- lib/vector_mcp/transport/sse/message_handler.rb
|
|
162
|
-
- lib/vector_mcp/transport/sse/puma_config.rb
|
|
163
|
-
- lib/vector_mcp/transport/sse/stream_manager.rb
|
|
164
|
-
- lib/vector_mcp/transport/sse_session_manager.rb
|
|
165
|
-
- lib/vector_mcp/transport/stdio.rb
|
|
166
|
-
- lib/vector_mcp/transport/stdio_session_manager.rb
|
|
167
162
|
- lib/vector_mcp/util.rb
|
|
168
163
|
- lib/vector_mcp/version.rb
|
|
169
164
|
homepage: https://github.com/sergiobayona/vector_mcp
|
|
@@ -181,7 +176,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
181
176
|
requirements:
|
|
182
177
|
- - ">="
|
|
183
178
|
- !ruby/object:Gem::Version
|
|
184
|
-
version: 3.
|
|
179
|
+
version: '3.2'
|
|
185
180
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
186
181
|
requirements:
|
|
187
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}: #{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
|