model-context-protocol-rb 0.4.0 → 0.5.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 +9 -1
- data/README.md +155 -12
- data/lib/model_context_protocol/server/cancellable.rb +54 -0
- data/lib/model_context_protocol/server/configuration.rb +4 -9
- data/lib/model_context_protocol/server/progressable.rb +72 -0
- data/lib/model_context_protocol/server/prompt.rb +3 -1
- data/lib/model_context_protocol/server/redis_client_proxy.rb +134 -0
- data/lib/model_context_protocol/server/redis_config.rb +108 -0
- data/lib/model_context_protocol/server/redis_pool_manager.rb +110 -0
- data/lib/model_context_protocol/server/resource.rb +3 -0
- data/lib/model_context_protocol/server/router.rb +36 -3
- data/lib/model_context_protocol/server/stdio_transport/request_store.rb +102 -0
- data/lib/model_context_protocol/server/stdio_transport.rb +31 -6
- data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +35 -0
- data/lib/model_context_protocol/server/streamable_http_transport/message_poller.rb +101 -0
- data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +80 -0
- data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +224 -0
- data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +120 -0
- data/lib/model_context_protocol/server/{session_store.rb → streamable_http_transport/session_store.rb} +30 -16
- data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +119 -0
- data/lib/model_context_protocol/server/streamable_http_transport.rb +162 -79
- data/lib/model_context_protocol/server/tool.rb +4 -0
- data/lib/model_context_protocol/server.rb +9 -3
- data/lib/model_context_protocol/version.rb +1 -1
- data/tasks/templates/dev-http.erb +58 -14
- metadata +57 -3
@@ -0,0 +1,108 @@
|
|
1
|
+
require "singleton"
|
2
|
+
|
3
|
+
module ModelContextProtocol
|
4
|
+
class Server::RedisConfig
|
5
|
+
include Singleton
|
6
|
+
|
7
|
+
class NotConfiguredError < StandardError
|
8
|
+
def initialize
|
9
|
+
super("Redis not configured. Call ModelContextProtocol::Server.configure_redis first")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :manager
|
14
|
+
|
15
|
+
def self.configure(&block)
|
16
|
+
instance.configure(&block)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.configured?
|
20
|
+
instance.configured?
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.pool
|
24
|
+
instance.pool
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.shutdown!
|
28
|
+
instance.shutdown!
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.reset!
|
32
|
+
instance.reset!
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.stats
|
36
|
+
instance.stats
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.pool_manager
|
40
|
+
instance.manager
|
41
|
+
end
|
42
|
+
|
43
|
+
def initialize
|
44
|
+
reset!
|
45
|
+
end
|
46
|
+
|
47
|
+
def configure(&block)
|
48
|
+
shutdown! if configured?
|
49
|
+
|
50
|
+
config = Configuration.new
|
51
|
+
yield(config) if block_given?
|
52
|
+
|
53
|
+
@manager = Server::RedisPoolManager.new(
|
54
|
+
redis_url: config.redis_url,
|
55
|
+
pool_size: config.pool_size,
|
56
|
+
pool_timeout: config.pool_timeout
|
57
|
+
)
|
58
|
+
|
59
|
+
if config.enable_reaper
|
60
|
+
@manager.configure_reaper(
|
61
|
+
enabled: true,
|
62
|
+
interval: config.reaper_interval,
|
63
|
+
idle_timeout: config.idle_timeout
|
64
|
+
)
|
65
|
+
end
|
66
|
+
|
67
|
+
@manager.start
|
68
|
+
end
|
69
|
+
|
70
|
+
def configured?
|
71
|
+
!@manager.nil? && !@manager.pool.nil?
|
72
|
+
end
|
73
|
+
|
74
|
+
def pool
|
75
|
+
raise NotConfiguredError unless configured?
|
76
|
+
@manager.pool
|
77
|
+
end
|
78
|
+
|
79
|
+
def shutdown!
|
80
|
+
@manager&.shutdown
|
81
|
+
@manager = nil
|
82
|
+
end
|
83
|
+
|
84
|
+
def reset!
|
85
|
+
shutdown!
|
86
|
+
@manager = nil
|
87
|
+
end
|
88
|
+
|
89
|
+
def stats
|
90
|
+
return {} unless configured?
|
91
|
+
@manager.stats
|
92
|
+
end
|
93
|
+
|
94
|
+
class Configuration
|
95
|
+
attr_accessor :redis_url, :pool_size, :pool_timeout,
|
96
|
+
:enable_reaper, :reaper_interval, :idle_timeout
|
97
|
+
|
98
|
+
def initialize
|
99
|
+
@redis_url = nil
|
100
|
+
@pool_size = 20
|
101
|
+
@pool_timeout = 5
|
102
|
+
@enable_reaper = true
|
103
|
+
@reaper_interval = 60
|
104
|
+
@idle_timeout = 300
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
module ModelContextProtocol
|
2
|
+
class Server::RedisPoolManager
|
3
|
+
attr_reader :pool, :reaper_thread
|
4
|
+
|
5
|
+
def initialize(redis_url:, pool_size: 20, pool_timeout: 5)
|
6
|
+
@redis_url = redis_url
|
7
|
+
@pool_size = pool_size
|
8
|
+
@pool_timeout = pool_timeout
|
9
|
+
@pool = nil
|
10
|
+
@reaper_thread = nil
|
11
|
+
@reaper_config = {
|
12
|
+
enabled: false,
|
13
|
+
interval: 60,
|
14
|
+
idle_timeout: 300
|
15
|
+
}
|
16
|
+
end
|
17
|
+
|
18
|
+
def configure_reaper(enabled:, interval: 60, idle_timeout: 300)
|
19
|
+
@reaper_config = {
|
20
|
+
enabled: enabled,
|
21
|
+
interval: interval,
|
22
|
+
idle_timeout: idle_timeout
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
def start
|
27
|
+
validate!
|
28
|
+
create_pool
|
29
|
+
start_reaper if @reaper_config[:enabled]
|
30
|
+
true
|
31
|
+
end
|
32
|
+
|
33
|
+
def shutdown
|
34
|
+
stop_reaper
|
35
|
+
close_pool
|
36
|
+
end
|
37
|
+
|
38
|
+
def healthy?
|
39
|
+
return false unless @pool
|
40
|
+
|
41
|
+
@pool.with do |conn|
|
42
|
+
conn.ping == "PONG"
|
43
|
+
end
|
44
|
+
rescue
|
45
|
+
false
|
46
|
+
end
|
47
|
+
|
48
|
+
def reap_now
|
49
|
+
return unless @pool
|
50
|
+
|
51
|
+
@pool.reap(@reaper_config[:idle_timeout]) do |conn|
|
52
|
+
conn.close
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def stats
|
57
|
+
return {} unless @pool
|
58
|
+
|
59
|
+
{
|
60
|
+
size: @pool.size,
|
61
|
+
available: @pool.available,
|
62
|
+
idle: @pool.instance_variable_get(:@idle_since)&.size || 0
|
63
|
+
}
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def validate!
|
69
|
+
raise ArgumentError, "redis_url is required" if @redis_url.nil? || @redis_url.empty?
|
70
|
+
raise ArgumentError, "pool_size must be positive" if @pool_size <= 0
|
71
|
+
raise ArgumentError, "pool_timeout must be positive" if @pool_timeout <= 0
|
72
|
+
end
|
73
|
+
|
74
|
+
def create_pool
|
75
|
+
@pool = ConnectionPool.new(size: @pool_size, timeout: @pool_timeout) do
|
76
|
+
Redis.new(url: @redis_url)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def close_pool
|
81
|
+
@pool&.shutdown { |conn| conn.close }
|
82
|
+
@pool = nil
|
83
|
+
end
|
84
|
+
|
85
|
+
def start_reaper
|
86
|
+
return if @reaper_thread&.alive?
|
87
|
+
|
88
|
+
@reaper_thread = Thread.new do
|
89
|
+
loop do
|
90
|
+
sleep @reaper_config[:interval]
|
91
|
+
begin
|
92
|
+
reap_now
|
93
|
+
rescue => e
|
94
|
+
warn "Redis reaper error: #{e.message}"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
@reaper_thread.name = "MCP-Redis-Reaper"
|
100
|
+
end
|
101
|
+
|
102
|
+
def stop_reaper
|
103
|
+
return unless @reaper_thread&.alive?
|
104
|
+
|
105
|
+
@reaper_thread.kill
|
106
|
+
@reaper_thread.join(5)
|
107
|
+
@reaper_thread = nil
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require_relative "cancellable"
|
2
|
+
|
1
3
|
module ModelContextProtocol
|
2
4
|
class Server::Router
|
3
5
|
# Raised when an invalid method is provided.
|
@@ -12,14 +14,45 @@ module ModelContextProtocol
|
|
12
14
|
@handlers[method] = handler
|
13
15
|
end
|
14
16
|
|
15
|
-
|
17
|
+
# Route a message to its handler with request tracking support
|
18
|
+
#
|
19
|
+
# @param message [Hash] the JSON-RPC message
|
20
|
+
# @param request_store [Object] the request store for tracking cancellation
|
21
|
+
# @param session_id [String, nil] the session ID for HTTP transport
|
22
|
+
# @param transport [Object, nil] the transport for sending notifications
|
23
|
+
# @return [Object] the handler result, or nil if cancelled
|
24
|
+
def route(message, request_store: nil, session_id: nil, transport: nil)
|
16
25
|
method = message["method"]
|
17
26
|
handler = @handlers[method]
|
18
27
|
raise MethodNotFoundError, "Method not found: #{method}" unless handler
|
19
28
|
|
20
|
-
|
21
|
-
|
29
|
+
request_id = message["id"]
|
30
|
+
progress_token = message.dig("params", "_meta", "progressToken")
|
31
|
+
|
32
|
+
if request_id && request_store
|
33
|
+
request_store.register_request(request_id, session_id)
|
34
|
+
end
|
35
|
+
|
36
|
+
result = nil
|
37
|
+
begin
|
38
|
+
with_environment(@configuration&.environment_variables) do
|
39
|
+
context = {request_id:, request_store:, session_id:, progress_token:, transport:}
|
40
|
+
|
41
|
+
Thread.current[:mcp_context] = context
|
42
|
+
|
43
|
+
result = handler.call(message)
|
44
|
+
end
|
45
|
+
rescue Server::Cancellable::CancellationError
|
46
|
+
return nil
|
47
|
+
ensure
|
48
|
+
if request_id && request_store
|
49
|
+
request_store.unregister_request(request_id)
|
50
|
+
end
|
51
|
+
|
52
|
+
Thread.current[:mcp_context] = nil
|
22
53
|
end
|
54
|
+
|
55
|
+
result
|
23
56
|
end
|
24
57
|
|
25
58
|
private
|
@@ -0,0 +1,102 @@
|
|
1
|
+
module ModelContextProtocol
|
2
|
+
class Server::StdioTransport
|
3
|
+
# Thread-safe in-memory storage for tracking active requests and their cancellation status.
|
4
|
+
# This store is used by StdioTransport to manage request lifecycle and handle cancellation.
|
5
|
+
class RequestStore
|
6
|
+
def initialize
|
7
|
+
@mutex = Mutex.new
|
8
|
+
@requests = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
# Register a new request with its associated thread
|
12
|
+
#
|
13
|
+
# @param request_id [String] the unique request identifier
|
14
|
+
# @param thread [Thread] the thread processing this request (defaults to current thread)
|
15
|
+
# @return [void]
|
16
|
+
def register_request(request_id, thread = Thread.current)
|
17
|
+
@mutex.synchronize do
|
18
|
+
@requests[request_id] = {
|
19
|
+
thread:,
|
20
|
+
cancelled: false,
|
21
|
+
started_at: Time.now
|
22
|
+
}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Mark a request as cancelled
|
27
|
+
#
|
28
|
+
# @param request_id [String] the unique request identifier
|
29
|
+
# @return [Boolean] true if request was found and marked cancelled, false otherwise
|
30
|
+
def mark_cancelled(request_id)
|
31
|
+
@mutex.synchronize do
|
32
|
+
if (request = @requests[request_id])
|
33
|
+
request[:cancelled] = true
|
34
|
+
return true
|
35
|
+
end
|
36
|
+
false
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Check if a request has been cancelled
|
41
|
+
#
|
42
|
+
# @param request_id [String] the unique request identifier
|
43
|
+
# @return [Boolean] true if the request is cancelled, false otherwise
|
44
|
+
def cancelled?(request_id)
|
45
|
+
@mutex.synchronize do
|
46
|
+
@requests[request_id]&.fetch(:cancelled, false) || false
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Unregister a request (typically called when request completes)
|
51
|
+
#
|
52
|
+
# @param request_id [String] the unique request identifier
|
53
|
+
# @return [Hash, nil] the removed request data, or nil if not found
|
54
|
+
def unregister_request(request_id)
|
55
|
+
@mutex.synchronize do
|
56
|
+
@requests.delete(request_id)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Get information about a specific request
|
61
|
+
#
|
62
|
+
# @param request_id [String] the unique request identifier
|
63
|
+
# @return [Hash, nil] request information or nil if not found
|
64
|
+
def get_request(request_id)
|
65
|
+
@mutex.synchronize do
|
66
|
+
@requests[request_id]&.dup
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Get all active request IDs
|
71
|
+
#
|
72
|
+
# @return [Array<String>] list of active request IDs
|
73
|
+
def active_requests
|
74
|
+
@mutex.synchronize do
|
75
|
+
@requests.keys.dup
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Clean up old requests (useful for preventing memory leaks)
|
80
|
+
#
|
81
|
+
# @param max_age_seconds [Integer] maximum age of requests to keep
|
82
|
+
# @return [Array<String>] list of cleaned up request IDs
|
83
|
+
def cleanup_old_requests(max_age_seconds = 300)
|
84
|
+
cutoff_time = Time.now - max_age_seconds
|
85
|
+
removed_ids = []
|
86
|
+
|
87
|
+
@mutex.synchronize do
|
88
|
+
@requests.delete_if do |request_id, data|
|
89
|
+
if data[:started_at] < cutoff_time
|
90
|
+
removed_ids << request_id
|
91
|
+
true
|
92
|
+
else
|
93
|
+
false
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
removed_ids
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require_relative "stdio_transport/request_store"
|
2
|
+
|
1
3
|
module ModelContextProtocol
|
2
4
|
class Server::StdioTransport
|
3
5
|
Response = Data.define(:id, :result) do
|
@@ -12,15 +14,15 @@ module ModelContextProtocol
|
|
12
14
|
end
|
13
15
|
end
|
14
16
|
|
15
|
-
attr_reader :router, :configuration
|
17
|
+
attr_reader :router, :configuration, :request_store
|
16
18
|
|
17
19
|
def initialize(router:, configuration:)
|
18
20
|
@router = router
|
19
21
|
@configuration = configuration
|
22
|
+
@request_store = RequestStore.new
|
20
23
|
end
|
21
24
|
|
22
25
|
def handle
|
23
|
-
# Connect logger to transport
|
24
26
|
@configuration.logger.connect_transport(self)
|
25
27
|
|
26
28
|
loop do
|
@@ -29,10 +31,19 @@ module ModelContextProtocol
|
|
29
31
|
|
30
32
|
begin
|
31
33
|
message = JSON.parse(line.chomp)
|
32
|
-
next if message["method"].start_with?("notifications")
|
33
34
|
|
34
|
-
|
35
|
-
|
35
|
+
if message["method"] == "notifications/cancelled"
|
36
|
+
handle_cancellation(message)
|
37
|
+
next
|
38
|
+
end
|
39
|
+
|
40
|
+
next if message["method"]&.start_with?("notifications/")
|
41
|
+
|
42
|
+
result = router.route(message, request_store: @request_store, transport: self)
|
43
|
+
|
44
|
+
if result
|
45
|
+
send_message(Response[id: message["id"], result: result.serialized])
|
46
|
+
end
|
36
47
|
rescue ModelContextProtocol::Server::ParameterValidationError => validation_error
|
37
48
|
@configuration.logger.error("Validation error", error: validation_error.message)
|
38
49
|
send_message(
|
@@ -61,12 +72,26 @@ module ModelContextProtocol
|
|
61
72
|
$stdout.puts(JSON.generate(notification))
|
62
73
|
$stdout.flush
|
63
74
|
rescue IOError => e
|
64
|
-
# Handle broken pipe gracefully
|
65
75
|
@configuration.logger.debug("Failed to send notification", error: e.message) if @configuration.logging_enabled?
|
66
76
|
end
|
67
77
|
|
68
78
|
private
|
69
79
|
|
80
|
+
# Handle a cancellation notification from the client
|
81
|
+
#
|
82
|
+
# @param message [Hash] the cancellation notification message
|
83
|
+
def handle_cancellation(message)
|
84
|
+
params = message["params"]
|
85
|
+
return unless params
|
86
|
+
|
87
|
+
request_id = params["requestId"]
|
88
|
+
return unless request_id
|
89
|
+
|
90
|
+
@request_store.mark_cancelled(request_id)
|
91
|
+
rescue
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
|
70
95
|
def receive_message
|
71
96
|
$stdin.gets
|
72
97
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module ModelContextProtocol
|
2
|
+
class Server::StreamableHttpTransport
|
3
|
+
class EventCounter
|
4
|
+
COUNTER_KEY_PREFIX = "event_counter:"
|
5
|
+
|
6
|
+
def initialize(redis_client, server_instance)
|
7
|
+
@redis = redis_client
|
8
|
+
@server_instance = server_instance
|
9
|
+
@counter_key = "#{COUNTER_KEY_PREFIX}#{server_instance}"
|
10
|
+
|
11
|
+
if @redis.exists(@counter_key) == 0
|
12
|
+
@redis.set(@counter_key, 0)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def next_event_id
|
17
|
+
count = @redis.incr(@counter_key)
|
18
|
+
"#{@server_instance}-#{count}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def current_count
|
22
|
+
count = @redis.get(@counter_key)
|
23
|
+
count ? count.to_i : 0
|
24
|
+
end
|
25
|
+
|
26
|
+
def reset
|
27
|
+
@redis.set(@counter_key, 0)
|
28
|
+
end
|
29
|
+
|
30
|
+
def set_count(value)
|
31
|
+
@redis.set(@counter_key, value.to_i)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require_relative "session_message_queue"
|
2
|
+
|
3
|
+
module ModelContextProtocol
|
4
|
+
class Server::StreamableHttpTransport
|
5
|
+
class MessagePoller
|
6
|
+
POLL_INTERVAL = 0.1 # 100ms
|
7
|
+
BATCH_SIZE = 100
|
8
|
+
|
9
|
+
def initialize(redis_client, stream_registry, logger, &message_delivery_block)
|
10
|
+
@redis = redis_client
|
11
|
+
@stream_registry = stream_registry
|
12
|
+
@logger = logger
|
13
|
+
@message_delivery_block = message_delivery_block
|
14
|
+
@running = false
|
15
|
+
@poll_thread = nil
|
16
|
+
end
|
17
|
+
|
18
|
+
def start
|
19
|
+
return if @running
|
20
|
+
|
21
|
+
@running = true
|
22
|
+
@poll_thread = Thread.new do
|
23
|
+
poll_loop
|
24
|
+
rescue => e
|
25
|
+
@logger.error("Message poller thread error", error: e.message, backtrace: e.backtrace.first(5))
|
26
|
+
sleep 1
|
27
|
+
retry if @running
|
28
|
+
end
|
29
|
+
|
30
|
+
@poll_thread.name = "MCP-MessagePoller" if @poll_thread.respond_to?(:name=)
|
31
|
+
|
32
|
+
@logger.debug("Message poller started")
|
33
|
+
end
|
34
|
+
|
35
|
+
def stop
|
36
|
+
@running = false
|
37
|
+
|
38
|
+
if @poll_thread&.alive?
|
39
|
+
@poll_thread.kill
|
40
|
+
@poll_thread.join(timeout: 5)
|
41
|
+
end
|
42
|
+
|
43
|
+
@poll_thread = nil
|
44
|
+
@logger.debug("Message poller stopped")
|
45
|
+
end
|
46
|
+
|
47
|
+
def running?
|
48
|
+
@running && @poll_thread&.alive?
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def poll_loop
|
54
|
+
while @running
|
55
|
+
begin
|
56
|
+
poll_and_deliver_messages
|
57
|
+
rescue => e
|
58
|
+
@logger.error("Error in message polling", error: e.message)
|
59
|
+
end
|
60
|
+
|
61
|
+
sleep POLL_INTERVAL
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def poll_and_deliver_messages
|
66
|
+
local_sessions = @stream_registry.get_all_local_streams.keys
|
67
|
+
return if local_sessions.empty?
|
68
|
+
|
69
|
+
local_sessions.each_slice(BATCH_SIZE) do |session_batch|
|
70
|
+
poll_sessions_batch(session_batch)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def poll_sessions_batch(session_ids)
|
75
|
+
session_ids.each do |session_id|
|
76
|
+
queue = SessionMessageQueue.new(@redis, session_id)
|
77
|
+
messages = queue.poll_messages
|
78
|
+
|
79
|
+
next if messages.empty?
|
80
|
+
|
81
|
+
stream = @stream_registry.get_local_stream(session_id)
|
82
|
+
next unless stream
|
83
|
+
|
84
|
+
messages.each do |message|
|
85
|
+
deliver_message_to_stream(stream, message, session_id)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def deliver_message_to_stream(stream, message, session_id)
|
91
|
+
@message_delivery_block&.call(stream, message)
|
92
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
93
|
+
@stream_registry.unregister_stream(session_id)
|
94
|
+
@logger.debug("Unregistered disconnected stream", session_id: session_id)
|
95
|
+
rescue => e
|
96
|
+
@logger.error("Error delivering message to stream",
|
97
|
+
session_id: session_id, error: e.message)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module ModelContextProtocol
|
4
|
+
class Server::StreamableHttpTransport
|
5
|
+
class NotificationQueue
|
6
|
+
QUEUE_KEY_PREFIX = "notifications:"
|
7
|
+
DEFAULT_MAX_SIZE = 1000
|
8
|
+
|
9
|
+
def initialize(redis_client, server_instance, max_size: DEFAULT_MAX_SIZE)
|
10
|
+
@redis = redis_client
|
11
|
+
@server_instance = server_instance
|
12
|
+
@queue_key = "#{QUEUE_KEY_PREFIX}#{server_instance}"
|
13
|
+
@max_size = max_size
|
14
|
+
end
|
15
|
+
|
16
|
+
def push(notification)
|
17
|
+
notification_json = notification.to_json
|
18
|
+
|
19
|
+
@redis.multi do |multi|
|
20
|
+
multi.lpush(@queue_key, notification_json)
|
21
|
+
multi.ltrim(@queue_key, 0, @max_size - 1)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def pop
|
26
|
+
notification_json = @redis.rpop(@queue_key)
|
27
|
+
return nil unless notification_json
|
28
|
+
|
29
|
+
JSON.parse(notification_json)
|
30
|
+
end
|
31
|
+
|
32
|
+
def pop_all
|
33
|
+
notification_jsons = @redis.multi do |multi|
|
34
|
+
multi.lrange(@queue_key, 0, -1)
|
35
|
+
multi.del(@queue_key)
|
36
|
+
end.first
|
37
|
+
|
38
|
+
return [] if notification_jsons.empty?
|
39
|
+
|
40
|
+
notification_jsons.reverse.map do |notification_json|
|
41
|
+
JSON.parse(notification_json)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def peek_all
|
46
|
+
notification_jsons = @redis.lrange(@queue_key, 0, -1)
|
47
|
+
return [] if notification_jsons.empty?
|
48
|
+
|
49
|
+
notification_jsons.reverse.map do |notification_json|
|
50
|
+
JSON.parse(notification_json)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def size
|
55
|
+
@redis.llen(@queue_key)
|
56
|
+
end
|
57
|
+
|
58
|
+
def empty?
|
59
|
+
size == 0
|
60
|
+
end
|
61
|
+
|
62
|
+
def clear
|
63
|
+
@redis.del(@queue_key)
|
64
|
+
end
|
65
|
+
|
66
|
+
def push_bulk(notifications)
|
67
|
+
return if notifications.empty?
|
68
|
+
|
69
|
+
notification_jsons = notifications.map(&:to_json)
|
70
|
+
|
71
|
+
@redis.multi do |multi|
|
72
|
+
notification_jsons.each do |json|
|
73
|
+
multi.lpush(@queue_key, json)
|
74
|
+
end
|
75
|
+
multi.ltrim(@queue_key, 0, @max_size - 1)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|