ruby-mcp-client 2.1.0 → 3.0.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module MCPClient
6
+ # Pinning a request to the server session it belongs to: a payload whose
7
+ # meaning is session-scoped (task ids, input request keys) must never be
8
+ # written into the session that replaced the one it was built in, and the
9
+ # transports establish (and so may re-establish) their session inside the
10
+ # very request that carries it. Mixed into the JSON-RPC transports, which
11
+ # call {#check_session_pin!} immediately before the wire.
12
+ module SessionPin
13
+ # Fiber-local key of the session pins in effect (see #pinned_to_session).
14
+ SESSION_PINS = :mcp_client_session_pins
15
+ # Fiber-local key of the extra pre-write guards (see #guarded_writes).
16
+ WRITE_GUARDS = :mcp_client_write_guards
17
+
18
+ # Run the block with every request this thread sends through this server
19
+ # pinned to `epoch` (a {MCPClient::ServerBase#session_epoch} reading):
20
+ # the transport refuses to write once that session has ended, however the
21
+ # reconnect that ended it got in — a lazy `ensure_initialized` /
22
+ # `ensure_connected` inside the very request, a transport retry, a
23
+ # concurrent cleanup. A session-scoped payload (the tasks extension's
24
+ # `inputResponses`, whose task ids and input keys are per session and
25
+ # reusable) must never reach the session that replaced the one it was
26
+ # built in, where it could answer an unrelated request.
27
+ # @param epoch [Integer, nil] the session the requests belong to (nil: no pin)
28
+ # @return [Object] the block's value
29
+ def pinned_to_session(epoch)
30
+ return yield if epoch.nil?
31
+
32
+ previous = Thread.current[SESSION_PINS]
33
+ pins = {}.compare_by_identity
34
+ previous&.each { |server, pinned| pins[server] = pinned }
35
+ pins[self] = epoch
36
+ Thread.current[SESSION_PINS] = pins
37
+ begin
38
+ yield
39
+ ensure
40
+ Thread.current[SESSION_PINS] = previous
41
+ end
42
+ end
43
+
44
+ # Run the block with `guard` called immediately before every request this
45
+ # thread writes through this server, at the very point the session pin is
46
+ # checked (see {#check_session_pin!}). A request whose payload a
47
+ # concurrent answer can invalidate — the tasks extension's task ids, which
48
+ # a fresh CreateTaskResult hands to a different task — is guarded there
49
+ # rather than before the request is built: everything the client records
50
+ # up to the wire is seen, so a decision taken earlier cannot leave the
51
+ # request going out for a task that no longer exists. Only one guard is in
52
+ # force per server; a nested one replaces it for the duration of its block.
53
+ # @param guard [#call] raises to refuse the write
54
+ # @return [Object] the block's value
55
+ def guarded_writes(guard)
56
+ previous = Thread.current[WRITE_GUARDS]
57
+ guards = {}.compare_by_identity
58
+ previous&.each { |server, guarded| guards[server] = guarded }
59
+ guards[self] = guard
60
+ Thread.current[WRITE_GUARDS] = guards
61
+ begin
62
+ yield
63
+ ensure
64
+ Thread.current[WRITE_GUARDS] = previous
65
+ end
66
+ end
67
+
68
+ # Run the block with this server's pin — and its pre-write guard — lifted
69
+ # for this thread: the request that establishes the session replacing an
70
+ # ended one is not part of the session it replaces, and the pin (whose
71
+ # epoch the end of that session has just invalidated) would otherwise
72
+ # refuse the very handshake the caller is in the middle of performing.
73
+ # @return [Object] the block's value
74
+ def unpinned_session
75
+ previous = Thread.current[SESSION_PINS]
76
+ guarded = Thread.current[WRITE_GUARDS]
77
+ return yield if (previous.nil? || !previous.key?(self)) && (guarded.nil? || !guarded.key?(self))
78
+
79
+ Thread.current[SESSION_PINS] = without_self(previous)
80
+ Thread.current[WRITE_GUARDS] = without_self(guarded)
81
+ begin
82
+ yield
83
+ ensure
84
+ Thread.current[SESSION_PINS] = previous
85
+ Thread.current[WRITE_GUARDS] = guarded
86
+ end
87
+ end
88
+
89
+ # Refuse a request whose session has ended (see #pinned_to_session), or
90
+ # which the caller's own guard turns down (see #guarded_writes).
91
+ # Transports call this as late as they can, immediately before the
92
+ # request goes on the wire, so nothing of an ended session is written.
93
+ # @return [void]
94
+ # @raise [MCPClient::Errors::SessionChangedError]
95
+ def check_session_pin!
96
+ Thread.current[WRITE_GUARDS]&.[](self)&.call
97
+ pinned = Thread.current[SESSION_PINS]&.[](self)
98
+ return if pinned.nil?
99
+
100
+ current = respond_to?(:session_epoch) ? session_epoch : nil
101
+ return if current.nil? || current == pinned
102
+
103
+ raise MCPClient::Errors::SessionChangedError,
104
+ "The server session the request belongs to ended before it was sent (session #{pinned} is over)"
105
+ end
106
+
107
+ private
108
+
109
+ # A copy of a per-server fiber-local map without this server's entry.
110
+ # @return [Hash, nil]
111
+ def without_self(entries)
112
+ return entries if entries.nil?
113
+
114
+ copy = {}.compare_by_identity
115
+ entries.each { |server, value| copy[server] = value unless server.equal?(self) }
116
+ copy
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module MCPClient
6
+ class Subscription
7
+ # One subscription's notification dispatcher: the queue the transport
8
+ # reader fills, the thread the listeners run on, and the policy that keeps
9
+ # the queue bounded.
10
+ #
11
+ # Listeners never run on the transport's reader. On stdio that is the
12
+ # single stdout reader, so a listener reacting to an update with a request
13
+ # of its own (re-reading the resource that changed, say) would otherwise
14
+ # wait for a response only the thread it is blocking could deliver — and
15
+ # every other message would wait with it. Enqueuing therefore never blocks
16
+ # and never waits for a listener.
17
+ #
18
+ # The queue is filled by the peer and drained by the host, so it needs a
19
+ # ceiling, and overflow has to discard something. There are two ceilings,
20
+ # because a queue bounded by count alone is not bounded in memory: the
21
+ # method name and params of every queued notification are retained until
22
+ # its listener has run, and the peer chooses how big they are. So there is
23
+ # a count ceiling
24
+ # ({MCPClient::Subscription::MAX_PENDING_NOTIFICATIONS}) and a byte budget
25
+ # ({MCPClient::Subscription::MAX_PENDING_NOTIFICATION_BYTES}).
26
+ #
27
+ # Two rules keep the queue honest, and they are the same rule seen from
28
+ # each end:
29
+ #
30
+ # 1. **Every queued notification is charged exactly what it retains** —
31
+ # its method name as well as its params, since the entry keeps both.
32
+ # {#pending_bytes} is the sum of the queue, always, because the queue
33
+ # and its totals are only ever changed together (see {#push} and
34
+ # {#discard}).
35
+ # 2. **Every eviction removes an entry whose removal relieves the pressure
36
+ # that caused it.** Each pressure has its own candidates: the byte
37
+ # budget admits only the entries charged against it, the count ceiling
38
+ # admits every entry (each is one of the count), and the oversized slot
39
+ # admits only its occupant. So overflow always makes progress and a
40
+ # signal is never spent on pressure that discarding it cannot relieve.
41
+ #
42
+ # Earlier revisions decided these two by rules that disagreed — a payload
43
+ # exempt from the charge but not from eviction — and the queue could throw
44
+ # away the only notice of a resource and still be over budget.
45
+ #
46
+ # *Which* of the candidates goes is chosen by identity — the
47
+ # notification's method together with the resource URI or task id it names
48
+ # — never by arrival order alone: one stream can carry a mixed filter, and
49
+ # dropping the oldest entry would throw away the only queued update for a
50
+ # quiet resource to keep newer ones for a busy one, with nothing left to
51
+ # tell the listener to re-read the quiet one. Since every MCP notification
52
+ # is a "look again" signal about state the host re-reads for itself, a
53
+ # second notice of the same thing is redundant and the first notice of a
54
+ # thing is not. So overflow gives up, in order of preference:
55
+ #
56
+ # 1. the oldest candidate of the same identity as the arriving
57
+ # notification — the listener still gets the newest word on it;
58
+ # 2. otherwise the oldest candidate of whichever identity has the most
59
+ # queued, so nothing loses its only notice while something else has a
60
+ # spare;
61
+ # 3. only when every candidate names a different thing, the oldest — the
62
+ # queue is then full of distinct signals and one must go.
63
+ #
64
+ # A notification whose payload is larger than the whole byte budget is not
65
+ # charged against it. It is held in a slot of its own instead, and there is
66
+ # only ever one such slot: a second oversized payload takes it from the
67
+ # first, which is the only thing that ever displaces one. So such a payload
68
+ # is neither lost for being large nor able to displace what the budget
69
+ # holds — nothing else is charged to its slot, and discarding it would free
70
+ # nothing the budget is short of — while what the queue retains stays
71
+ # within the budget plus one peer-sized payload.
72
+ class NotificationDispatcher
73
+ # One queued notification: what it is about, who wants it, what it says,
74
+ # what it costs to hold on to, and whether that cost is the budget's or
75
+ # its own slot's.
76
+ Queued = Struct.new(:key, :listeners, :method_name, :params, :bytes, :oversized)
77
+
78
+ # @param owner [MCPClient::Subscription] the subscription it serves
79
+ def initialize(owner)
80
+ @owner = owner
81
+ @mutex = Mutex.new
82
+ @ready = ConditionVariable.new
83
+ @buffer = []
84
+ @bytes = 0
85
+ @oversized_bytes = 0
86
+ @dropped = 0
87
+ @warned_about_drops = false
88
+ @stopped = false
89
+ start_thread
90
+ end
91
+
92
+ # @return [Integer] notifications waiting for the listeners
93
+ def pending
94
+ @mutex.synchronize { @buffer.size }
95
+ end
96
+
97
+ # @return [Integer] bytes retained by the notifications waiting for the
98
+ # listeners
99
+ def pending_bytes
100
+ @mutex.synchronize { @bytes }
101
+ end
102
+
103
+ # @return [Integer] notifications discarded because the listeners could
104
+ # not keep up with the peer
105
+ def dropped
106
+ @mutex.synchronize { @dropped }
107
+ end
108
+
109
+ # Queue one notification for the listeners, making room for it first.
110
+ # @param listeners [Array<Proc>] the listeners to run
111
+ # @param method [String] notification method
112
+ # @param params [Hash, nil] notification params
113
+ # @return [void]
114
+ def deliver(listeners, method, params)
115
+ # Measured before the lock is taken: sizing a large payload must not
116
+ # hold up the reader thread that is delivering the next one.
117
+ bytes = payload_bytesize(method, params)
118
+ entry = Queued.new(identity(method, params), listeners, method, params, bytes, bytes > byte_capacity)
119
+ @mutex.synchronize do
120
+ return if @stopped
121
+
122
+ make_room(entry)
123
+ push(entry)
124
+ @ready.signal
125
+ end
126
+ end
127
+
128
+ # End the dispatcher after everything already queued has been delivered.
129
+ # @return [void]
130
+ def stop
131
+ @mutex.synchronize do
132
+ @stopped = true
133
+ @ready.broadcast
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ # What a notification is *about*: two notifications with the same
140
+ # identity say the same thing about the same resource or task, so the
141
+ # newer one carries everything the older one did.
142
+ # @param method [String] notification method
143
+ # @param params [Hash, nil] notification params
144
+ # @return [Array(String, String, nil)]
145
+ def identity(method, params)
146
+ named = params.is_a?(Hash) ? (params['uri'] || params['taskId']) : nil
147
+ [method, named]
148
+ end
149
+
150
+ # What holding a notification costs, measured as the JSON the peer sent
151
+ # for it: the parsed objects are larger but proportional, and the peer
152
+ # decides the size either way.
153
+ #
154
+ # Everything the entry retains is charged, the method name included. It
155
+ # is not decoration on the params: the entry keeps it to call the
156
+ # listeners with, and keeps it again inside the identity the eviction
157
+ # policy is keyed by. Charging only the params let a peer tag `{}` with
158
+ # a multi-megabyte method name for two bytes apiece and put
159
+ # {MCPClient::Subscription::MAX_PENDING_NOTIFICATIONS} of them behind a
160
+ # slow listener without ever touching the byte ceiling.
161
+ # @param method [String] notification method
162
+ # @param params [Hash, nil] notification params
163
+ # @return [Integer] bytes
164
+ def payload_bytesize(method, params)
165
+ method.to_s.bytesize + params_bytesize(params)
166
+ end
167
+
168
+ # @param params [Hash, nil] notification params
169
+ # @return [Integer] bytes
170
+ def params_bytesize(params)
171
+ return 0 if params.nil?
172
+
173
+ JSON.generate(params).bytesize
174
+ rescue StandardError
175
+ # Params always come from a parsed JSON message; if one somehow cannot
176
+ # be re-encoded, charge for it rather than letting it slip the budget.
177
+ params.to_s.bytesize
178
+ end
179
+
180
+ # @return [Integer] the ceiling, read at each delivery so a host can
181
+ # change it for a transport it knows is chatty
182
+ def capacity
183
+ MCPClient::Subscription::MAX_PENDING_NOTIFICATIONS
184
+ end
185
+
186
+ # @return [Integer] the byte ceiling, read at each delivery for the same
187
+ # reason as {#capacity}
188
+ def byte_capacity
189
+ MCPClient::Subscription::MAX_PENDING_NOTIFICATION_BYTES
190
+ end
191
+
192
+ # Add one entry, charging exactly what it retains. The queue and its
193
+ # totals only change here and in {#discard}, which is what makes
194
+ # {#pending_bytes} the sum of the queue rather than an estimate of it.
195
+ # Called with the lock held.
196
+ # @param entry [Queued]
197
+ # @return [void]
198
+ def push(entry)
199
+ @buffer << entry
200
+ @bytes += entry.bytes
201
+ @oversized_bytes += entry.bytes if entry.oversized
202
+ end
203
+
204
+ # Remove the entry at `index`, releasing exactly what it was charged.
205
+ # Called with the lock held.
206
+ # @param index [Integer]
207
+ # @return [Queued] the entry that was removed
208
+ def discard(index)
209
+ entry = @buffer.delete_at(index)
210
+ @bytes -= entry.bytes
211
+ @oversized_bytes -= entry.bytes if entry.oversized
212
+ entry
213
+ end
214
+
215
+ # @return [Integer] the bytes charged against the budget: everything
216
+ # queued except the payload holding the oversized slot
217
+ def budgeted_bytes
218
+ @bytes - @oversized_bytes
219
+ end
220
+
221
+ # @return [Integer, nil] the position of the oversized payload, nil when
222
+ # the slot is free. There is at most one by construction: an arriving
223
+ # oversized payload takes the slot from its occupant first.
224
+ def oversized_index
225
+ @buffer.index(&:oversized)
226
+ end
227
+
228
+ # Make room for one more notification. Called with the lock held.
229
+ # @param entry [Queued] the arriving notification
230
+ # @return [void]
231
+ def make_room(entry)
232
+ dropped = 0
233
+ while (index = crowded_out(entry))
234
+ discard(index)
235
+ dropped += 1
236
+ end
237
+ return if dropped.zero?
238
+
239
+ @dropped += dropped
240
+ report_dropped(dropped)
241
+ end
242
+
243
+ # The position of the entry that has to go before `entry` can be queued,
244
+ # or nil once it fits. Each pressure admits only the entries whose
245
+ # removal relieves *it*, so every eviction makes progress and the loop in
246
+ # {#make_room} always ends:
247
+ #
248
+ # * the oversized slot: only its occupant will do, and an arriving
249
+ # payload that needs the slot always frees it in one step;
250
+ # * the byte budget: only the entries charged against it, and with none
251
+ # of them left the budget holds anything that is not oversized;
252
+ # * the count ceiling: every queued entry is one of the count.
253
+ #
254
+ # Which of the candidates goes is then the identity question (see the
255
+ # class comment). Called with the lock held.
256
+ # @param entry [Queued] the arriving notification
257
+ # @return [Integer, nil]
258
+ def crowded_out(entry)
259
+ return oversized_index if entry.oversized && oversized_index
260
+ return evictable_index(entry.key, budgeted_indices) if budgeted_bytes + budgeted_cost(entry) > byte_capacity
261
+ return evictable_index(entry.key, (0...@buffer.size).to_a) if @buffer.size >= capacity
262
+
263
+ nil
264
+ end
265
+
266
+ # @param entry [Queued] the arriving notification
267
+ # @return [Integer] what queuing it would add to the budget: nothing when
268
+ # it is bound for the slot of its own
269
+ def budgeted_cost(entry)
270
+ entry.oversized ? 0 : entry.bytes
271
+ end
272
+
273
+ # @return [Array<Integer>] the positions of the entries the budget is
274
+ # charged for
275
+ def budgeted_indices
276
+ (0...@buffer.size).reject { |index| @buffer[index].oversized }
277
+ end
278
+
279
+ # The candidate overflow should discard (see the class comment). Called
280
+ # with the lock held.
281
+ # @param key [Array] the arriving notification's identity
282
+ # @param candidates [Array<Integer>] positions that may be discarded
283
+ # @return [Integer, nil] its position, nil when there is no candidate
284
+ def evictable_index(key, candidates)
285
+ return nil if candidates.empty?
286
+
287
+ same = candidates.find { |index| @buffer[index].key == key }
288
+ return same if same
289
+
290
+ redundant = most_queued_identity(candidates)
291
+ redundant ? candidates.find { |index| @buffer[index].key == redundant } : candidates.first
292
+ end
293
+
294
+ # @param candidates [Array<Integer>] positions that may be discarded
295
+ # @return [Array, nil] the identity with more than one candidate queued,
296
+ # nil when every candidate names its own thing
297
+ def most_queued_identity(candidates)
298
+ counts = candidates.each_with_object(Hash.new(0)) { |index, tally| tally[@buffer[index].key] += 1 }
299
+ identity, count = counts.max_by { |_key, queued| queued }
300
+ count > 1 ? identity : nil
301
+ end
302
+
303
+ # @param dropped [Integer] how many were discarded just now
304
+ # @return [void]
305
+ def report_dropped(dropped)
306
+ logger = @owner.server.respond_to?(:logger) ? @owner.server.logger : nil
307
+ return unless logger
308
+
309
+ # The peer controls how often this happens, so it is said once per
310
+ # subscription at warn level and counted after that.
311
+ if @warned_about_drops
312
+ logger.debug("Subscription #{@owner.id} dropped #{dropped} more queued notification(s)")
313
+ else
314
+ @warned_about_drops = true
315
+ logger.warn("Subscription #{@owner.id} is receiving notifications faster than its listeners handle " \
316
+ "them; dropping repeats of what is already queued (at most #{capacity} notifications " \
317
+ "or #{byte_capacity} bytes, see MCPClient::Subscription#dropped_notifications)")
318
+ end
319
+ end
320
+
321
+ # @return [Thread] the thread that runs the listeners
322
+ def start_thread
323
+ Thread.new do
324
+ Thread.current.name = 'MCP-subscription'
325
+ Thread.current.report_on_exception = false
326
+ while (entry = next_entry)
327
+ call_listeners(entry.listeners, entry.method_name, entry.params)
328
+ end
329
+ end
330
+ end
331
+
332
+ # @return [Queued, nil] the next notification to deliver, nil once the
333
+ # subscription has ended and everything queued has been delivered
334
+ def next_entry
335
+ @mutex.synchronize do
336
+ @ready.wait(@mutex) while @buffer.empty? && !@stopped
337
+ discard(0) unless @buffer.empty?
338
+ end
339
+ end
340
+
341
+ # @param listeners [Array<Proc>] listeners to run
342
+ # @param method [String] notification method
343
+ # @param params [Hash, nil] notification params
344
+ # @return [void]
345
+ def call_listeners(listeners, method, params)
346
+ listeners.each do |listener|
347
+ listener.call(method, params)
348
+ rescue StandardError => e
349
+ @owner.server.logger.warn("Subscription listener error: #{e.message}") if @owner.server.respond_to?(:logger)
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end