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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -1
  3. data/README.md +155 -12
  4. data/lib/model_context_protocol/server/cancellable.rb +54 -0
  5. data/lib/model_context_protocol/server/configuration.rb +4 -9
  6. data/lib/model_context_protocol/server/progressable.rb +72 -0
  7. data/lib/model_context_protocol/server/prompt.rb +3 -1
  8. data/lib/model_context_protocol/server/redis_client_proxy.rb +134 -0
  9. data/lib/model_context_protocol/server/redis_config.rb +108 -0
  10. data/lib/model_context_protocol/server/redis_pool_manager.rb +110 -0
  11. data/lib/model_context_protocol/server/resource.rb +3 -0
  12. data/lib/model_context_protocol/server/router.rb +36 -3
  13. data/lib/model_context_protocol/server/stdio_transport/request_store.rb +102 -0
  14. data/lib/model_context_protocol/server/stdio_transport.rb +31 -6
  15. data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +35 -0
  16. data/lib/model_context_protocol/server/streamable_http_transport/message_poller.rb +101 -0
  17. data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +80 -0
  18. data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +224 -0
  19. data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +120 -0
  20. data/lib/model_context_protocol/server/{session_store.rb → streamable_http_transport/session_store.rb} +30 -16
  21. data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +119 -0
  22. data/lib/model_context_protocol/server/streamable_http_transport.rb +162 -79
  23. data/lib/model_context_protocol/server/tool.rb +4 -0
  24. data/lib/model_context_protocol/server.rb +9 -3
  25. data/lib/model_context_protocol/version.rb +1 -1
  26. data/tasks/templates/dev-http.erb +58 -14
  27. 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,5 +1,8 @@
1
1
  module ModelContextProtocol
2
2
  class Server::Resource
3
+ include ModelContextProtocol::Server::Cancellable
4
+ include ModelContextProtocol::Server::Progressable
5
+
3
6
  attr_reader :mime_type, :uri
4
7
 
5
8
  def initialize
@@ -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
- def route(message)
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
- with_environment(@configuration&.environment_variables) do
21
- handler.call(message)
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
- result = router.route(message)
35
- send_message(Response[id: message["id"], result: result.serialized])
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