model-context-protocol-rb 0.5.0 → 0.6.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.
@@ -6,10 +6,10 @@ module ModelContextProtocol
6
6
  POLL_INTERVAL = 0.1 # 100ms
7
7
  BATCH_SIZE = 100
8
8
 
9
- def initialize(redis_client, stream_registry, logger, &message_delivery_block)
9
+ def initialize(redis_client, stream_registry, client_logger, &message_delivery_block)
10
10
  @redis = redis_client
11
11
  @stream_registry = stream_registry
12
- @logger = logger
12
+ @client_logger = client_logger
13
13
  @message_delivery_block = message_delivery_block
14
14
  @running = false
15
15
  @poll_thread = nil
@@ -22,14 +22,14 @@ module ModelContextProtocol
22
22
  @poll_thread = Thread.new do
23
23
  poll_loop
24
24
  rescue => e
25
- @logger.error("Message poller thread error", error: e.message, backtrace: e.backtrace.first(5))
25
+ @client_logger.error("Message poller thread error", error: e.message, backtrace: e.backtrace.first(5))
26
26
  sleep 1
27
27
  retry if @running
28
28
  end
29
29
 
30
30
  @poll_thread.name = "MCP-MessagePoller" if @poll_thread.respond_to?(:name=)
31
31
 
32
- @logger.debug("Message poller started")
32
+ @client_logger.debug("Message poller started")
33
33
  end
34
34
 
35
35
  def stop
@@ -37,11 +37,11 @@ module ModelContextProtocol
37
37
 
38
38
  if @poll_thread&.alive?
39
39
  @poll_thread.kill
40
- @poll_thread.join(timeout: 5)
40
+ @poll_thread.join(5)
41
41
  end
42
42
 
43
43
  @poll_thread = nil
44
- @logger.debug("Message poller stopped")
44
+ @client_logger.debug("Message poller stopped")
45
45
  end
46
46
 
47
47
  def running?
@@ -55,7 +55,7 @@ module ModelContextProtocol
55
55
  begin
56
56
  poll_and_deliver_messages
57
57
  rescue => e
58
- @logger.error("Error in message polling", error: e.message)
58
+ @client_logger.error("Error in message polling", error: e.message)
59
59
  end
60
60
 
61
61
  sleep POLL_INTERVAL
@@ -91,9 +91,9 @@ module ModelContextProtocol
91
91
  @message_delivery_block&.call(stream, message)
92
92
  rescue IOError, Errno::EPIPE, Errno::ECONNRESET
93
93
  @stream_registry.unregister_stream(session_id)
94
- @logger.debug("Unregistered disconnected stream", session_id: session_id)
94
+ @client_logger.debug("Unregistered disconnected stream", session_id: session_id)
95
95
  rescue => e
96
- @logger.error("Error delivering message to stream",
96
+ @client_logger.error("Error delivering message to stream",
97
97
  session_id: session_id, error: e.message)
98
98
  end
99
99
  end
@@ -19,10 +19,10 @@ module ModelContextProtocol
19
19
 
20
20
  # Register a new request with its associated session
21
21
  #
22
- # @param request_id [String] the unique request identifier
22
+ # @param jsonrpc_request_id [String] the unique JSON-RPC request identifier
23
23
  # @param session_id [String] the session identifier (can be nil for sessionless requests)
24
24
  # @return [void]
25
- def register_request(request_id, session_id = nil)
25
+ def register_request(jsonrpc_request_id, session_id = nil)
26
26
  request_data = {
27
27
  session_id: session_id,
28
28
  server_instance: @server_instance,
@@ -30,11 +30,11 @@ module ModelContextProtocol
30
30
  }
31
31
 
32
32
  @redis.multi do |multi|
33
- multi.set("#{REQUEST_KEY_PREFIX}#{request_id}",
33
+ multi.set("#{REQUEST_KEY_PREFIX}#{jsonrpc_request_id}",
34
34
  request_data.to_json, ex: @ttl)
35
35
 
36
36
  if session_id
37
- multi.set("#{SESSION_KEY_PREFIX}#{session_id}:#{request_id}",
37
+ multi.set("#{SESSION_KEY_PREFIX}#{session_id}:#{jsonrpc_request_id}",
38
38
  true, ex: @ttl)
39
39
  end
40
40
  end
@@ -42,34 +42,34 @@ module ModelContextProtocol
42
42
 
43
43
  # Mark a request as cancelled
44
44
  #
45
- # @param request_id [String] the unique request identifier
45
+ # @param jsonrpc_request_id [String] the unique JSON-RPC request identifier
46
46
  # @param reason [String] optional reason for cancellation
47
47
  # @return [Boolean] true if cancellation was recorded
48
- def mark_cancelled(request_id, reason = nil)
48
+ def mark_cancelled(jsonrpc_request_id, reason = nil)
49
49
  cancellation_data = {
50
50
  cancelled_at: Time.now.to_f,
51
51
  reason: reason
52
52
  }
53
53
 
54
- result = @redis.set("#{CANCELLED_KEY_PREFIX}#{request_id}",
54
+ result = @redis.set("#{CANCELLED_KEY_PREFIX}#{jsonrpc_request_id}",
55
55
  cancellation_data.to_json, ex: @ttl)
56
56
  result == "OK"
57
57
  end
58
58
 
59
59
  # Check if a request has been cancelled
60
60
  #
61
- # @param request_id [String] the unique request identifier
61
+ # @param jsonrpc_request_id [String] the unique JSON-RPC request identifier
62
62
  # @return [Boolean] true if the request is cancelled, false otherwise
63
- def cancelled?(request_id)
64
- @redis.exists("#{CANCELLED_KEY_PREFIX}#{request_id}") == 1
63
+ def cancelled?(jsonrpc_request_id)
64
+ @redis.exists("#{CANCELLED_KEY_PREFIX}#{jsonrpc_request_id}") == 1
65
65
  end
66
66
 
67
67
  # Get cancellation information for a request
68
68
  #
69
- # @param request_id [String] the unique request identifier
69
+ # @param jsonrpc_request_id [String] the unique JSON-RPC request identifier
70
70
  # @return [Hash, nil] cancellation data or nil if not cancelled
71
- def get_cancellation_info(request_id)
72
- data = @redis.get("#{CANCELLED_KEY_PREFIX}#{request_id}")
71
+ def get_cancellation_info(jsonrpc_request_id)
72
+ data = @redis.get("#{CANCELLED_KEY_PREFIX}#{jsonrpc_request_id}")
73
73
  data ? JSON.parse(data) : nil
74
74
  rescue JSON::ParserError
75
75
  nil
@@ -77,13 +77,13 @@ module ModelContextProtocol
77
77
 
78
78
  # Unregister a request (typically called when request completes)
79
79
  #
80
- # @param request_id [String] the unique request identifier
80
+ # @param jsonrpc_request_id [String] the unique JSON-RPC request identifier
81
81
  # @return [void]
82
- def unregister_request(request_id)
83
- request_data = @redis.get("#{REQUEST_KEY_PREFIX}#{request_id}")
82
+ def unregister_request(jsonrpc_request_id)
83
+ request_data = @redis.get("#{REQUEST_KEY_PREFIX}#{jsonrpc_request_id}")
84
84
 
85
- keys_to_delete = ["#{REQUEST_KEY_PREFIX}#{request_id}",
86
- "#{CANCELLED_KEY_PREFIX}#{request_id}"]
85
+ keys_to_delete = ["#{REQUEST_KEY_PREFIX}#{jsonrpc_request_id}",
86
+ "#{CANCELLED_KEY_PREFIX}#{jsonrpc_request_id}"]
87
87
 
88
88
  if request_data
89
89
  begin
@@ -91,7 +91,7 @@ module ModelContextProtocol
91
91
  session_id = data["session_id"]
92
92
 
93
93
  if session_id
94
- keys_to_delete << "#{SESSION_KEY_PREFIX}#{session_id}:#{request_id}"
94
+ keys_to_delete << "#{SESSION_KEY_PREFIX}#{session_id}:#{jsonrpc_request_id}"
95
95
  end
96
96
  rescue JSON::ParserError
97
97
  nil
@@ -103,10 +103,10 @@ module ModelContextProtocol
103
103
 
104
104
  # Get information about a specific request
105
105
  #
106
- # @param request_id [String] the unique request identifier
106
+ # @param jsonrpc_request_id [String] the unique JSON-RPC request identifier
107
107
  # @return [Hash, nil] request information or nil if not found
108
- def get_request(request_id)
109
- data = @redis.get("#{REQUEST_KEY_PREFIX}#{request_id}")
108
+ def get_request(jsonrpc_request_id)
109
+ data = @redis.get("#{REQUEST_KEY_PREFIX}#{jsonrpc_request_id}")
110
110
  data ? JSON.parse(data) : nil
111
111
  rescue JSON::ParserError
112
112
  nil
@@ -114,10 +114,10 @@ module ModelContextProtocol
114
114
 
115
115
  # Check if a request is currently active
116
116
  #
117
- # @param request_id [String] the unique request identifier
117
+ # @param jsonrpc_request_id [String] the unique JSON-RPC request identifier
118
118
  # @return [Boolean] true if the request is active, false otherwise
119
- def active?(request_id)
120
- @redis.exists("#{REQUEST_KEY_PREFIX}#{request_id}") == 1
119
+ def active?(jsonrpc_request_id)
120
+ @redis.exists("#{REQUEST_KEY_PREFIX}#{jsonrpc_request_id}") == 1
121
121
  end
122
122
 
123
123
  # Clean up all requests associated with a session
@@ -131,20 +131,20 @@ module ModelContextProtocol
131
131
  return [] if request_keys.empty?
132
132
 
133
133
  # Extract request IDs from the keys
134
- request_ids = request_keys.map do |key|
134
+ jsonrpc_request_ids = request_keys.map do |key|
135
135
  key.sub("#{SESSION_KEY_PREFIX}#{session_id}:", "")
136
136
  end
137
137
 
138
138
  # Delete all related keys
139
139
  all_keys = []
140
- request_ids.each do |request_id|
141
- all_keys << "#{REQUEST_KEY_PREFIX}#{request_id}"
142
- all_keys << "#{CANCELLED_KEY_PREFIX}#{request_id}"
140
+ jsonrpc_request_ids.each do |jsonrpc_request_id|
141
+ all_keys << "#{REQUEST_KEY_PREFIX}#{jsonrpc_request_id}"
142
+ all_keys << "#{CANCELLED_KEY_PREFIX}#{jsonrpc_request_id}"
143
143
  end
144
144
  all_keys.concat(request_keys)
145
145
 
146
146
  @redis.del(*all_keys) unless all_keys.empty?
147
- request_ids
147
+ jsonrpc_request_ids
148
148
  end
149
149
 
150
150
  # Get all active request IDs for a specific session
@@ -196,21 +196,21 @@ module ModelContextProtocol
196
196
 
197
197
  # Refresh the TTL for an active request
198
198
  #
199
- # @param request_id [String] the unique request identifier
199
+ # @param jsonrpc_request_id [String] the unique JSON-RPC request identifier
200
200
  # @return [Boolean] true if TTL was refreshed, false if request doesn't exist
201
- def refresh_request_ttl(request_id)
202
- request_data = @redis.get("#{REQUEST_KEY_PREFIX}#{request_id}")
201
+ def refresh_request_ttl(jsonrpc_request_id)
202
+ request_data = @redis.get("#{REQUEST_KEY_PREFIX}#{jsonrpc_request_id}")
203
203
  return false unless request_data
204
204
 
205
205
  @redis.multi do |multi|
206
- multi.expire("#{REQUEST_KEY_PREFIX}#{request_id}", @ttl)
207
- multi.expire("#{CANCELLED_KEY_PREFIX}#{request_id}", @ttl)
206
+ multi.expire("#{REQUEST_KEY_PREFIX}#{jsonrpc_request_id}", @ttl)
207
+ multi.expire("#{CANCELLED_KEY_PREFIX}#{jsonrpc_request_id}", @ttl)
208
208
 
209
209
  begin
210
210
  data = JSON.parse(request_data)
211
211
  session_id = data["session_id"]
212
212
  if session_id
213
- multi.expire("#{SESSION_KEY_PREFIX}#{session_id}:#{request_id}", @ttl)
213
+ multi.expire("#{SESSION_KEY_PREFIX}#{session_id}:#{jsonrpc_request_id}", @ttl)
214
214
  end
215
215
  rescue JSON::ParserError
216
216
  nil
@@ -0,0 +1,231 @@
1
+ require "json"
2
+
3
+ module ModelContextProtocol
4
+ class Server::StreamableHttpTransport
5
+ # Redis-based distributed storage for tracking server-initiated requests and their response status.
6
+ # This store is used by StreamableHttpTransport to manage outgoing request lifecycle (like pings)
7
+ # across multiple server instances and handle timeouts in a distributed environment.
8
+ class ServerRequestStore
9
+ REQUEST_KEY_PREFIX = "server_request:pending:"
10
+ SESSION_KEY_PREFIX = "server_request:session:"
11
+ DEFAULT_TTL = 60 # 1 minute TTL for request entries
12
+
13
+ def initialize(redis_client, server_instance, ttl: DEFAULT_TTL)
14
+ @redis = redis_client
15
+ @server_instance = server_instance
16
+ @ttl = ttl
17
+ end
18
+
19
+ # Register a new server-initiated request with its associated session
20
+ #
21
+ # @param request_id [String] the unique JSON-RPC request identifier
22
+ # @param session_id [String] the session identifier (can be nil for sessionless requests)
23
+ # @param type [Symbol] the type of request (e.g., :ping)
24
+ # @return [void]
25
+ def register_request(request_id, session_id = nil, type: :ping)
26
+ request_data = {
27
+ session_id: session_id,
28
+ server_instance: @server_instance,
29
+ type: type.to_s,
30
+ created_at: Time.now.to_f
31
+ }
32
+
33
+ @redis.multi do |multi|
34
+ multi.set("#{REQUEST_KEY_PREFIX}#{request_id}",
35
+ request_data.to_json, ex: @ttl)
36
+
37
+ if session_id
38
+ multi.set("#{SESSION_KEY_PREFIX}#{session_id}:#{request_id}",
39
+ true, ex: @ttl)
40
+ end
41
+ end
42
+ end
43
+
44
+ # Mark a server-initiated request as completed (response received)
45
+ #
46
+ # @param request_id [String] the unique JSON-RPC request identifier
47
+ # @return [Boolean] true if request was pending, false if not found
48
+ def mark_completed(request_id)
49
+ request_data = @redis.get("#{REQUEST_KEY_PREFIX}#{request_id}")
50
+ return false unless request_data
51
+
52
+ unregister_request(request_id)
53
+ true
54
+ end
55
+
56
+ # Check if a server-initiated request is still pending
57
+ #
58
+ # @param request_id [String] the unique JSON-RPC request identifier
59
+ # @return [Boolean] true if the request is pending, false otherwise
60
+ def pending?(request_id)
61
+ @redis.exists("#{REQUEST_KEY_PREFIX}#{request_id}") == 1
62
+ end
63
+
64
+ # Get information about a specific pending request
65
+ #
66
+ # @param request_id [String] the unique JSON-RPC request identifier
67
+ # @return [Hash, nil] request information or nil if not found
68
+ def get_request(request_id)
69
+ data = @redis.get("#{REQUEST_KEY_PREFIX}#{request_id}")
70
+ data ? JSON.parse(data) : nil
71
+ rescue JSON::ParserError
72
+ nil
73
+ end
74
+
75
+ # Find requests that have exceeded the specified timeout
76
+ #
77
+ # @param timeout_seconds [Integer] timeout in seconds
78
+ # @return [Array<Hash>] array of expired request info with request_id and session_id
79
+ def get_expired_requests(timeout_seconds)
80
+ current_time = Time.now.to_f
81
+ expired_requests = []
82
+
83
+ # Get all pending request keys
84
+ request_keys = @redis.keys("#{REQUEST_KEY_PREFIX}*")
85
+ return expired_requests if request_keys.empty?
86
+
87
+ # Get all request data in batch
88
+ request_values = @redis.mget(request_keys)
89
+
90
+ request_keys.each_with_index do |key, index|
91
+ next unless request_values[index]
92
+
93
+ begin
94
+ request_data = JSON.parse(request_values[index])
95
+ created_at = request_data["created_at"]
96
+
97
+ if created_at && (current_time - created_at) > timeout_seconds
98
+ request_id = key.sub(REQUEST_KEY_PREFIX, "")
99
+ expired_requests << {
100
+ request_id: request_id,
101
+ session_id: request_data["session_id"],
102
+ type: request_data["type"],
103
+ age: current_time - created_at
104
+ }
105
+ end
106
+ rescue JSON::ParserError
107
+ # Skip malformed entries
108
+ next
109
+ end
110
+ end
111
+
112
+ expired_requests
113
+ end
114
+
115
+ # Clean up expired requests based on timeout
116
+ #
117
+ # @param timeout_seconds [Integer] timeout in seconds
118
+ # @return [Array<String>] list of cleaned up request IDs
119
+ def cleanup_expired_requests(timeout_seconds)
120
+ expired_requests = get_expired_requests(timeout_seconds)
121
+
122
+ expired_requests.each do |request_info|
123
+ unregister_request(request_info[:request_id])
124
+ end
125
+
126
+ expired_requests.map { |r| r[:request_id] }
127
+ end
128
+
129
+ # Unregister a request (typically called when request completes or times out)
130
+ #
131
+ # @param request_id [String] the unique JSON-RPC request identifier
132
+ # @return [void]
133
+ def unregister_request(request_id)
134
+ request_data = @redis.get("#{REQUEST_KEY_PREFIX}#{request_id}")
135
+
136
+ keys_to_delete = ["#{REQUEST_KEY_PREFIX}#{request_id}"]
137
+
138
+ if request_data
139
+ begin
140
+ data = JSON.parse(request_data)
141
+ session_id = data["session_id"]
142
+
143
+ if session_id
144
+ keys_to_delete << "#{SESSION_KEY_PREFIX}#{session_id}:#{request_id}"
145
+ end
146
+ rescue JSON::ParserError
147
+ nil
148
+ end
149
+ end
150
+
151
+ @redis.del(*keys_to_delete) unless keys_to_delete.empty?
152
+ end
153
+
154
+ # Clean up all server requests associated with a session
155
+ # This is typically called when a session is terminated
156
+ #
157
+ # @param session_id [String] the session identifier
158
+ # @return [Array<String>] list of cleaned up request IDs
159
+ def cleanup_session_requests(session_id)
160
+ pattern = "#{SESSION_KEY_PREFIX}#{session_id}:*"
161
+ request_keys = @redis.keys(pattern)
162
+ return [] if request_keys.empty?
163
+
164
+ # Extract request IDs from the keys
165
+ request_ids = request_keys.map do |key|
166
+ key.sub("#{SESSION_KEY_PREFIX}#{session_id}:", "")
167
+ end
168
+
169
+ # Delete all related keys
170
+ all_keys = []
171
+ request_ids.each do |request_id|
172
+ all_keys << "#{REQUEST_KEY_PREFIX}#{request_id}"
173
+ end
174
+ all_keys.concat(request_keys)
175
+
176
+ @redis.del(*all_keys) unless all_keys.empty?
177
+ request_ids
178
+ end
179
+
180
+ # Get all pending request IDs for a specific session
181
+ #
182
+ # @param session_id [String] the session identifier
183
+ # @return [Array<String>] list of pending request IDs for the session
184
+ def get_session_requests(session_id)
185
+ pattern = "#{SESSION_KEY_PREFIX}#{session_id}:*"
186
+ request_keys = @redis.keys(pattern)
187
+
188
+ request_keys.map do |key|
189
+ key.sub("#{SESSION_KEY_PREFIX}#{session_id}:", "")
190
+ end
191
+ end
192
+
193
+ # Get all pending request IDs across all sessions
194
+ #
195
+ # @return [Array<String>] list of all pending request IDs
196
+ def get_all_pending_requests
197
+ pattern = "#{REQUEST_KEY_PREFIX}*"
198
+ request_keys = @redis.keys(pattern)
199
+
200
+ request_keys.map do |key|
201
+ key.sub(REQUEST_KEY_PREFIX, "")
202
+ end
203
+ end
204
+
205
+ # Refresh the TTL for a pending request
206
+ #
207
+ # @param request_id [String] the unique JSON-RPC request identifier
208
+ # @return [Boolean] true if TTL was refreshed, false if request doesn't exist
209
+ def refresh_request_ttl(request_id)
210
+ request_data = @redis.get("#{REQUEST_KEY_PREFIX}#{request_id}")
211
+ return false unless request_data
212
+
213
+ @redis.multi do |multi|
214
+ multi.expire("#{REQUEST_KEY_PREFIX}#{request_id}", @ttl)
215
+
216
+ begin
217
+ data = JSON.parse(request_data)
218
+ session_id = data["session_id"]
219
+ if session_id
220
+ multi.expire("#{SESSION_KEY_PREFIX}#{session_id}:#{request_id}", @ttl)
221
+ end
222
+ rescue JSON::ParserError
223
+ nil
224
+ end
225
+ end
226
+
227
+ true
228
+ end
229
+ end
230
+ end
231
+ end