model-context-protocol-rb 0.3.4 → 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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/README.md +886 -196
  4. data/lib/model_context_protocol/server/cancellable.rb +54 -0
  5. data/lib/model_context_protocol/server/configuration.rb +80 -8
  6. data/lib/model_context_protocol/server/content.rb +321 -0
  7. data/lib/model_context_protocol/server/content_helpers.rb +84 -0
  8. data/lib/model_context_protocol/server/pagination.rb +71 -0
  9. data/lib/model_context_protocol/server/progressable.rb +72 -0
  10. data/lib/model_context_protocol/server/prompt.rb +108 -14
  11. data/lib/model_context_protocol/server/redis_client_proxy.rb +134 -0
  12. data/lib/model_context_protocol/server/redis_config.rb +108 -0
  13. data/lib/model_context_protocol/server/redis_pool_manager.rb +110 -0
  14. data/lib/model_context_protocol/server/registry.rb +94 -18
  15. data/lib/model_context_protocol/server/resource.rb +98 -25
  16. data/lib/model_context_protocol/server/resource_template.rb +26 -13
  17. data/lib/model_context_protocol/server/router.rb +36 -3
  18. data/lib/model_context_protocol/server/stdio_transport/request_store.rb +102 -0
  19. data/lib/model_context_protocol/server/stdio_transport.rb +31 -6
  20. data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +35 -0
  21. data/lib/model_context_protocol/server/streamable_http_transport/message_poller.rb +101 -0
  22. data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +80 -0
  23. data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +224 -0
  24. data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +120 -0
  25. data/lib/model_context_protocol/server/{session_store.rb → streamable_http_transport/session_store.rb} +30 -16
  26. data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +119 -0
  27. data/lib/model_context_protocol/server/streamable_http_transport.rb +352 -112
  28. data/lib/model_context_protocol/server/tool.rb +79 -53
  29. data/lib/model_context_protocol/server.rb +124 -21
  30. data/lib/model_context_protocol/version.rb +1 -1
  31. data/tasks/mcp.rake +28 -2
  32. data/tasks/templates/dev-http.erb +288 -0
  33. data/tasks/templates/dev.erb +7 -1
  34. metadata +61 -3
@@ -0,0 +1,72 @@
1
+ require "concurrent-ruby"
2
+
3
+ module ModelContextProtocol
4
+ module Server::Progressable
5
+ # Execute a block with automatic time-based progress reporting.
6
+ # Uses Concurrent::TimerTask to send progress notifications at regular intervals.
7
+ #
8
+ # @param max_duration [Numeric] Expected duration in seconds
9
+ # @param message [String, nil] Optional custom progress message
10
+ # @yield block to execute with progress tracking
11
+ # @return [Object] the result of the block
12
+ #
13
+ # @example
14
+ # progressable(max_duration: 30) do # 30 seconds
15
+ # perform_long_operation
16
+ # end
17
+ def progressable(max_duration:, message: nil, &block)
18
+ context = Thread.current[:mcp_context]
19
+
20
+ return yield unless context && context[:progress_token] && context[:transport]
21
+
22
+ progress_token = context[:progress_token]
23
+ transport = context[:transport]
24
+ start_time = Time.now
25
+ update_interval = [1.0, max_duration * 0.05].max
26
+
27
+ timer_task = Concurrent::TimerTask.new(execution_interval: update_interval) do
28
+ elapsed_seconds = Time.now - start_time
29
+ progress_pct = [(elapsed_seconds / max_duration) * 100, 99].min
30
+
31
+ progress_message = if message
32
+ "#{message} (#{elapsed_seconds.round(1)}s / ~#{max_duration}s)"
33
+ else
34
+ "Processing... (#{elapsed_seconds.round(1)}s / ~#{max_duration}s)"
35
+ end
36
+
37
+ begin
38
+ transport.send_notification("notifications/progress", {
39
+ progressToken: progress_token,
40
+ progress: progress_pct.round(1),
41
+ total: 100,
42
+ message: progress_message
43
+ })
44
+ rescue
45
+ nil
46
+ end
47
+
48
+ timer_task.shutdown if elapsed_seconds >= max_duration
49
+ end
50
+
51
+ begin
52
+ timer_task.execute
53
+ result = yield
54
+
55
+ begin
56
+ transport.send_notification("notifications/progress", {
57
+ progressToken: progress_token,
58
+ progress: 100,
59
+ total: 100,
60
+ message: "Completed"
61
+ })
62
+ rescue
63
+ nil
64
+ end
65
+
66
+ result
67
+ ensure
68
+ timer_task&.shutdown if timer_task&.running?
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,5 +1,9 @@
1
1
  module ModelContextProtocol
2
2
  class Server::Prompt
3
+ include ModelContextProtocol::Server::Cancellable
4
+ include ModelContextProtocol::Server::ContentHelpers
5
+ include ModelContextProtocol::Server::Progressable
6
+
3
7
  attr_reader :arguments, :context, :logger
4
8
 
5
9
  def initialize(arguments, logger, context = {})
@@ -13,15 +17,23 @@ module ModelContextProtocol
13
17
  raise NotImplementedError, "Subclasses must implement the call method"
14
18
  end
15
19
 
16
- Response = Data.define(:messages, :description) do
20
+ Response = Data.define(:messages, :description, :title) do
17
21
  def serialized
18
- {description:, messages:}
22
+ result = {description:, messages:}
23
+ result[:title] = title if title
24
+ result
19
25
  end
20
26
  end
21
27
  private_constant :Response
22
28
 
29
+ def message_history(&block)
30
+ builder = MessageHistoryBuilder.new(self)
31
+ builder.instance_eval(&block)
32
+ builder.messages
33
+ end
34
+
23
35
  private def respond_with(messages:)
24
- Response[messages:, description: self.class.description]
36
+ Response[messages:, description: self.class.description, title: self.class.title]
25
37
  end
26
38
 
27
39
  private def validate!(arguments = {})
@@ -43,16 +55,18 @@ module ModelContextProtocol
43
55
  end
44
56
 
45
57
  class << self
46
- attr_reader :name, :description, :defined_arguments
58
+ attr_reader :name, :description, :title, :defined_arguments
47
59
 
48
- def with_metadata(&block)
60
+ def define(&block)
49
61
  @defined_arguments ||= []
50
62
 
51
- metadata_dsl = MetadataDSL.new
52
- metadata_dsl.instance_eval(&block)
63
+ definition_dsl = DefinitionDSL.new
64
+ definition_dsl.instance_eval(&block)
53
65
 
54
- @name = metadata_dsl.name
55
- @description = metadata_dsl.description
66
+ @name = definition_dsl.name
67
+ @description = definition_dsl.description
68
+ @title = definition_dsl.title
69
+ @defined_arguments.concat(definition_dsl.arguments)
56
70
  end
57
71
 
58
72
  def with_argument(&block)
@@ -72,6 +86,7 @@ module ModelContextProtocol
72
86
  def inherited(subclass)
73
87
  subclass.instance_variable_set(:@name, @name)
74
88
  subclass.instance_variable_set(:@description, @description)
89
+ subclass.instance_variable_set(:@title, @title)
75
90
  subclass.instance_variable_set(:@defined_arguments, @defined_arguments&.dup)
76
91
  end
77
92
 
@@ -81,8 +96,10 @@ module ModelContextProtocol
81
96
  raise ModelContextProtocol::Server::ParameterValidationError, error.message
82
97
  end
83
98
 
84
- def metadata
85
- {name: @name, description: @description, arguments: @defined_arguments}
99
+ def definition
100
+ result = {name: @name, description: @description, arguments: @defined_arguments}
101
+ result[:title] = @title if @title
102
+ result
86
103
  end
87
104
 
88
105
  def complete_for(arg_name, value)
@@ -92,7 +109,52 @@ module ModelContextProtocol
92
109
  end
93
110
  end
94
111
 
95
- class MetadataDSL
112
+ class MessageHistoryBuilder
113
+ include Server::ContentHelpers
114
+
115
+ attr_reader :messages
116
+
117
+ def initialize(prompt_instance)
118
+ @messages = []
119
+ @prompt_instance = prompt_instance
120
+ end
121
+
122
+ def arguments
123
+ @prompt_instance.arguments
124
+ end
125
+
126
+ def context
127
+ @prompt_instance.context
128
+ end
129
+
130
+ def logger
131
+ @prompt_instance.logger
132
+ end
133
+
134
+ def user_message(&block)
135
+ content = instance_eval(&block).serialized
136
+ @messages << {
137
+ role: "user",
138
+ content: content
139
+ }
140
+ end
141
+
142
+ def assistant_message(&block)
143
+ content = instance_eval(&block).serialized
144
+ @messages << {
145
+ role: "assistant",
146
+ content: content
147
+ }
148
+ end
149
+ end
150
+
151
+ class DefinitionDSL
152
+ attr_reader :arguments
153
+
154
+ def initialize
155
+ @arguments = []
156
+ end
157
+
96
158
  def name(value = nil)
97
159
  @name = value if value
98
160
  @name
@@ -102,6 +164,23 @@ module ModelContextProtocol
102
164
  @description = value if value
103
165
  @description
104
166
  end
167
+
168
+ def title(value = nil)
169
+ @title = value if value
170
+ @title
171
+ end
172
+
173
+ def argument(&block)
174
+ argument_dsl = ArgumentDSL.new
175
+ argument_dsl.instance_eval(&block)
176
+
177
+ @arguments << {
178
+ name: argument_dsl.name,
179
+ description: argument_dsl.description,
180
+ required: argument_dsl.required,
181
+ completion: argument_dsl.completion
182
+ }
183
+ end
105
184
  end
106
185
 
107
186
  class ArgumentDSL
@@ -120,10 +199,25 @@ module ModelContextProtocol
120
199
  @required
121
200
  end
122
201
 
123
- def completion(klass = nil)
124
- @completion = klass unless klass.nil?
202
+ def completion(klass_or_values = nil)
203
+ unless klass_or_values.nil?
204
+ @completion = if klass_or_values.is_a?(Array)
205
+ create_array_completion(klass_or_values)
206
+ else
207
+ klass_or_values
208
+ end
209
+ end
125
210
  @completion
126
211
  end
212
+
213
+ private
214
+
215
+ def create_array_completion(values)
216
+ ModelContextProtocol::Server::Completion.define do
217
+ filtered_values = values.grep(/#{argument_value}/)
218
+ respond_with values: filtered_values
219
+ end
220
+ end
127
221
  end
128
222
  end
129
223
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModelContextProtocol
4
+ class Server
5
+ class RedisClientProxy
6
+ def initialize(pool)
7
+ @pool = pool
8
+ end
9
+
10
+ def get(key)
11
+ with_connection { |redis| redis.get(key) }
12
+ end
13
+
14
+ def set(key, value, **options)
15
+ with_connection { |redis| redis.set(key, value, **options) }
16
+ end
17
+
18
+ def del(*keys)
19
+ with_connection { |redis| redis.del(*keys) }
20
+ end
21
+
22
+ def exists(*keys)
23
+ with_connection { |redis| redis.exists(*keys) }
24
+ end
25
+
26
+ def expire(key, seconds)
27
+ with_connection { |redis| redis.expire(key, seconds) }
28
+ end
29
+
30
+ def ttl(key)
31
+ with_connection { |redis| redis.ttl(key) }
32
+ end
33
+
34
+ def hget(key, field)
35
+ with_connection { |redis| redis.hget(key, field) }
36
+ end
37
+
38
+ def hset(key, *args)
39
+ with_connection { |redis| redis.hset(key, *args) }
40
+ end
41
+
42
+ def hgetall(key)
43
+ with_connection { |redis| redis.hgetall(key) }
44
+ end
45
+
46
+ def lpush(key, *values)
47
+ with_connection { |redis| redis.lpush(key, *values) }
48
+ end
49
+
50
+ def rpop(key)
51
+ with_connection { |redis| redis.rpop(key) }
52
+ end
53
+
54
+ def lrange(key, start, stop)
55
+ with_connection { |redis| redis.lrange(key, start, stop) }
56
+ end
57
+
58
+ def llen(key)
59
+ with_connection { |redis| redis.llen(key) }
60
+ end
61
+
62
+ def ltrim(key, start, stop)
63
+ with_connection { |redis| redis.ltrim(key, start, stop) }
64
+ end
65
+
66
+ def incr(key)
67
+ with_connection { |redis| redis.incr(key) }
68
+ end
69
+
70
+ def decr(key)
71
+ with_connection { |redis| redis.decr(key) }
72
+ end
73
+
74
+ def keys(pattern)
75
+ with_connection { |redis| redis.keys(pattern) }
76
+ end
77
+
78
+ def multi(&block)
79
+ with_connection do |redis|
80
+ redis.multi do |multi|
81
+ multi_wrapper = RedisMultiWrapper.new(multi)
82
+ block.call(multi_wrapper)
83
+ end
84
+ end
85
+ end
86
+
87
+ def pipelined(&block)
88
+ with_connection do |redis|
89
+ redis.pipelined do |pipeline|
90
+ pipeline_wrapper = RedisMultiWrapper.new(pipeline)
91
+ block.call(pipeline_wrapper)
92
+ end
93
+ end
94
+ end
95
+
96
+ def mget(*keys)
97
+ with_connection { |redis| redis.mget(*keys) }
98
+ end
99
+
100
+ def eval(script, keys: [], argv: [])
101
+ with_connection { |redis| redis.eval(script, keys: keys, argv: argv) }
102
+ end
103
+
104
+ def ping
105
+ with_connection { |redis| redis.ping }
106
+ end
107
+
108
+ def flushdb
109
+ with_connection { |redis| redis.flushdb }
110
+ end
111
+
112
+ private
113
+
114
+ def with_connection(&block)
115
+ @pool.with(&block)
116
+ end
117
+
118
+ # Wrapper for Redis multi/pipeline operations
119
+ class RedisMultiWrapper
120
+ def initialize(multi)
121
+ @multi = multi
122
+ end
123
+
124
+ def method_missing(method, *args, **kwargs, &block)
125
+ @multi.send(method, *args, **kwargs, &block)
126
+ end
127
+
128
+ def respond_to_missing?(method, include_private = false)
129
+ @multi.respond_to?(method, include_private)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -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