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,763 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module HttpTransportBase
5
+ # The subscriptions/listen stream on Streamable HTTP (MCP 2026-07-28
6
+ # basic/patterns/subscriptions): a POST whose SSE response stays open and
7
+ # carries the notifications the client opted in to. Closing the stream is
8
+ # the cancellation signal; an abrupt drop is re-opened with a new id
9
+ # while the host still wants the subscription.
10
+ module ListenStream
11
+ # Raised inside the streaming read to abandon a stream the host closed.
12
+ class ListenStreamClosed < StandardError; end
13
+
14
+ # Delay before re-opening a subscriptions/listen stream that ended
15
+ # without the server's closing response (doubles up to the maximum).
16
+ LISTEN_RECONNECT_DELAY = 1
17
+ LISTEN_MAX_RECONNECT_DELAY = 30
18
+ # Read timeout for a listen stream; servers keep quiet streams alive with
19
+ # SSE comment lines, so this only bounds a dead connection.
20
+ LISTEN_STREAM_TIMEOUT = 300
21
+ # Time allowed for a listen stream's socket to open. A cancellation
22
+ # cannot close a session that is still inside this window (there is no
23
+ # response stream yet), so it is also how long a request can stay
24
+ # abandoned-but-unsent after a {#cancel_subscription}.
25
+ LISTEN_OPEN_TIMEOUT = 10
26
+ # Cap on a listen stream's unterminated-event buffer (peer-controlled).
27
+ LISTEN_MAX_BUFFER_BYTES = 32 * 1024 * 1024
28
+
29
+ # An SSE line ends with CRLF, CR or LF (a CR-only or mixed-ending
30
+ # server is as compliant as a CRLF one).
31
+ LINE_TERMINATOR = /\r\n|\r|\n/
32
+ # An event ends at a blank line — two line terminators in a row, in any
33
+ # mix. CRLF is matched first so one CRLF is never read as two.
34
+ EVENT_TERMINATOR = /(?:\r\n|\r(?!\n)|\n){2}/
35
+ # A complete comment line (one starting with a colon) — the keep-alive
36
+ # the transport specification lets a server send and tells a client to
37
+ # ignore. A comment ending in a bare CR at the very end of the buffer is
38
+ # left alone: its LF may be in the next chunk, and dropping the CR
39
+ # alone could turn the line terminator before it into an event
40
+ # terminator that was never on the wire.
41
+ COMMENT_LINE = /(?:\A|(?<=[\r\n])):[^\r\n]*(?:\r\n|\n|\r(?=[^\n]))/
42
+
43
+ # Ready the connection a subscription is about to be opened on, and mark
44
+ # this connection as one listen streams may still be opened on.
45
+ #
46
+ # {#open_subscription} asks that question again under the very lock
47
+ # {#close_listen_streams} closes them under, which is what a `listen`
48
+ # paused between the two needs: it used to register and POST regardless,
49
+ # so a `cleanup` landing in that window closed the registries while they
50
+ # were still empty and the stream that arrived afterwards ran on a
51
+ # transport the host had closed — unreachable to a later `cleanup`, which
52
+ # returns at once on a transport that is already disconnected.
53
+ #
54
+ # Clearing the flag cannot be trusted on its own, because it is not the
55
+ # same operation as the connect above it: a `cleanup` landing between
56
+ # the two is *undone* by the listen that resumes afterwards, which sets
57
+ # the flag back to false and goes on to POST on the transport that was
58
+ # just disconnected. So the question {#claim_listen_stream} asks is
59
+ # whether the connection is still up, not merely whether this flag was
60
+ # cleared since the last shutdown.
61
+ # @return [void]
62
+ def ensure_session_ready
63
+ ensure_connected
64
+ listen_threads_mutex.synchronize { @listen_streams_closed = false }
65
+ end
66
+
67
+ # Open a subscriptions/listen stream (MCP 2026-07-28): the request is a
68
+ # POST whose SSE response stays open and carries the notifications the
69
+ # client opted in to. Runs on its own thread; the subscription id is
70
+ # assigned before returning so callers can correlate immediately.
71
+ # @param subscription [MCPClient::Subscription]
72
+ # @return [void]
73
+ def open_subscription(subscription)
74
+ return unless subscription.with_open_id(next_request_id) { register_subscription(subscription) }
75
+
76
+ # The thread is held until both registries name it, so a cancellation
77
+ # that arrives the moment this returns always finds the stream to
78
+ # close and the thread to wait for — and it never starts at all when
79
+ # the connection it was readied on has since been closed.
80
+ started = Thread::Queue.new
81
+ thread = Thread.new do
82
+ Thread.current.name = 'MCP-listen'
83
+ Thread.current.report_on_exception = false
84
+ started.pop
85
+ run_listen_stream(subscription)
86
+ end
87
+ return refuse_listen_on_closed_connection(subscription, thread) unless claim_listen_stream(subscription, thread)
88
+
89
+ started << :go
90
+ end
91
+
92
+ # Cancel a subscription: on Streamable HTTP closing the SSE response
93
+ # stream is the cancellation signal, so the response stream is closed
94
+ # and the reader ends with it; no notifications/cancelled is sent. The
95
+ # thread is not killed — a kill would interrupt it wherever it happened
96
+ # to be, losing a notification it was in the middle of delivering.
97
+ # @param subscription [MCPClient::Subscription]
98
+ # @return [void]
99
+ def cancel_subscription(subscription)
100
+ # Closed first: the stream's own thread reads this state before it
101
+ # sends anything, so once this line has run no listen request for the
102
+ # subscription can still go out.
103
+ subscription.finish(by_client: true)
104
+ unregister_subscription(subscription)
105
+ subscriptions_mutex.synchronize { resource_subscriptions.delete_if { |_uri, sub| sub.equal?(subscription) } }
106
+ close_listen_stream(subscription)
107
+ await_listen_stream(subscription)
108
+ end
109
+
110
+ THREAD_JOIN_TIMEOUT_FOR_LISTEN = 2
111
+ # How often a cancellation looks again at a session whose socket was
112
+ # still being opened the last time it looked.
113
+ LISTEN_CLOSE_POLL_INTERVAL = 0.05
114
+
115
+ # Stop every listen stream (transport shutdown). Unlike stdio there is no
116
+ # process to re-establish: the host re-listens on a new connection.
117
+ #
118
+ # The threads are neither killed nor waited for. A kill would interrupt
119
+ # a reader wherever it happened to be — losing whatever it was
120
+ # delivering, or dropping it while it holds the subscription's own lock
121
+ # to take a new listen id, which is exactly what a later {MCPClient::Subscription#close} or
122
+ # {#listen} would then wait on. Waiting is no better: this runs under
123
+ # the transport lock a reader needs for its next id, so a join here
124
+ # would stall every later call for its whole timeout. Once its
125
+ # subscription is closed and its response stream is closed, a reader can
126
+ # no longer send anything: it leaves the loop and cleans up after itself.
127
+ # @return [void]
128
+ def close_listen_streams
129
+ threads = listen_threads_mutex.synchronize do
130
+ # Under the lock a stream is claimed under: a listen that was readied
131
+ # on this connection and has not claimed its place yet is refused
132
+ # rather than left running on a transport that is gone
133
+ # (see {#ensure_session_ready}).
134
+ @listen_streams_closed = true
135
+ listen_threads.dup.tap { listen_threads.clear }
136
+ end
137
+ # Both registries move together under their own lock; the
138
+ # Subscriptions are finished outside it, since a subscription being
139
+ # opened holds its own lock while taking this one.
140
+ closing = subscriptions_mutex.synchronize do
141
+ open = subscriptions.values
142
+ subscriptions.clear
143
+ resource_subscriptions.clear
144
+ open
145
+ end
146
+ # A stream between two listen ids is registered under neither, so the
147
+ # threads name their own subscriptions too: one left open here would
148
+ # re-open onto a transport that is already gone.
149
+ (closing | threads.keys).each { |sub| sub.finish(gracefully: false, reason: 'transport closed') }
150
+ threads.each_key { |sub| close_listen_stream(sub) }
151
+ end
152
+
153
+ private
154
+
155
+ # Put the stream in the per-stream bookkeeping, unless the connection it
156
+ # was readied on has been closed since (see {#ensure_session_ready}).
157
+ # Both happen under the one lock {#close_listen_streams} takes, so a
158
+ # `cleanup` either finds this stream and closes it or stops it here.
159
+ # @param subscription [MCPClient::Subscription]
160
+ # @param thread [Thread] the stream's own thread, not yet released
161
+ # @return [Boolean] false when the connection was closed under it
162
+ def claim_listen_stream(subscription, thread)
163
+ listen_threads_mutex.synchronize do
164
+ next false if @listen_streams_closed || !listen_connection_open?
165
+
166
+ listen_wakeups[subscription] ||= Thread::Queue.new
167
+ listen_threads[subscription] = thread
168
+ true
169
+ end
170
+ end
171
+
172
+ # Whether the transport is still connected, asked while the per-stream
173
+ # lock is held.
174
+ #
175
+ # Deliberately a bare read rather than `listen_transport_connected?`:
176
+ # every `cleanup` takes the transport lock and then this one, so taking
177
+ # them in the other order here would be a lock inversion. The read needs
178
+ # no lock to be correct for what it decides. `cleanup` clears the flag
179
+ # *before* it calls {#close_listen_streams}, so a claim that runs after
180
+ # the flag was cleared is refused, and one that runs before it is
181
+ # refused or found and closed by {#close_listen_streams} — whichever of
182
+ # the two reaches this lock first.
183
+ # @return [Boolean]
184
+ def listen_connection_open?
185
+ @connection_established
186
+ end
187
+
188
+ # End a listen the connection was closed under, before anything is sent:
189
+ # its thread is still waiting to be released, so killing it holds nothing
190
+ # up and no request ever goes out.
191
+ # @param subscription [MCPClient::Subscription]
192
+ # @param thread [Thread] the stream's own thread, still waiting
193
+ # @return [void]
194
+ # @raise [MCPClient::Errors::ConnectionError] always
195
+ def refuse_listen_on_closed_connection(subscription, thread)
196
+ thread.kill
197
+ unregister_subscription(subscription)
198
+ error = MCPClient::Errors::ConnectionError.new(
199
+ 'the connection was closed while the subscription was being opened'
200
+ )
201
+ subscription.finish(gracefully: false, error: error)
202
+ raise error
203
+ end
204
+
205
+ # Wake a subscription's thread if it is waiting to re-open, and close the
206
+ # response stream it is reading: that ends the reader without killing it.
207
+ # @param subscription [MCPClient::Subscription]
208
+ # @return [Symbol] :closed, or :opening while the socket is still being
209
+ # opened (see {#close_listen_session})
210
+ def close_listen_stream(subscription)
211
+ wakeup = listen_threads_mutex.synchronize { listen_wakeups[subscription] }
212
+ wakeup&.push(:cancelled)
213
+ close_listen_session(subscription)
214
+ end
215
+
216
+ # Close the HTTP response stream of a listen request that is in flight —
217
+ # the cancellation signal on Streamable HTTP. A session whose socket is
218
+ # still being opened cannot be closed and must not be forgotten: the
219
+ # request it is about to send would leave the server holding a stream
220
+ # nothing reads, so the caller comes back for it.
221
+ # @param subscription [MCPClient::Subscription]
222
+ # @return [Symbol] :closed when the response stream is closed (or none
223
+ # was ever opened), :opening while its socket is still being opened
224
+ def close_listen_session(subscription)
225
+ session = listen_threads_mutex.synchronize { listen_sessions[subscription] }
226
+ return :closed unless session
227
+ return :opening unless session.started?
228
+
229
+ listen_threads_mutex.synchronize { listen_sessions.delete(subscription) }
230
+ begin
231
+ session.finish
232
+ rescue StandardError => e
233
+ @logger.debug("Closing a listen stream raised #{e.class}")
234
+ end
235
+ :closed
236
+ end
237
+
238
+ # Wait for a cancelled stream's thread to end, closing its response
239
+ # stream as soon as there is one to close.
240
+ #
241
+ # A close that finds the socket still opening has nothing to close, and
242
+ # the socket can open at any point inside {LISTEN_OPEN_TIMEOUT} — longer
243
+ # than anything worth blocking a {Subscription#close} for. So the close
244
+ # is attempted again after every short join instead of once, and the
245
+ # thread is left in {#listen_threads} throughout: it removes itself when
246
+ # it ends, and until then a stream this gave up on is still one a later
247
+ # {#close_listen_streams} can find and close. Nothing can be sent on it
248
+ # meanwhile — {#arm_listen_session} has already refused the request.
249
+ # @param subscription [MCPClient::Subscription]
250
+ # @return [void]
251
+ def await_listen_stream(subscription)
252
+ thread = listen_threads_mutex.synchronize { listen_threads[subscription] }
253
+ return if thread.nil? || thread.equal?(Thread.current)
254
+
255
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + THREAD_JOIN_TIMEOUT_FOR_LISTEN
256
+ while close_listen_session(subscription) == :opening
257
+ return if thread.join(LISTEN_CLOSE_POLL_INTERVAL)
258
+ return if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
259
+ end
260
+ thread.join(THREAD_JOIN_TIMEOUT_FOR_LISTEN)
261
+ end
262
+
263
+ # Hand the stream's HTTP session over before its socket is opened, so a
264
+ # cancellation can close the response stream — and refuse to open one at
265
+ # all for a subscription the host has already closed. Both halves happen
266
+ # under the lock {#close_listen_stream} takes, so a close either stops
267
+ # the request outright or finds the session it has to close.
268
+ # @param subscription [MCPClient::Subscription]
269
+ # @param http [Net::HTTP] the session Faraday is about to use
270
+ # @return [void]
271
+ # @raise [ListenStreamClosed] when the subscription is already closed
272
+ def arm_listen_session(subscription, http)
273
+ listen_threads_mutex.synchronize do
274
+ raise ListenStreamClosed if subscription.closed?
275
+
276
+ refuse_listen_send_once_closed(subscription, http)
277
+ listen_sessions[subscription] = http
278
+ end
279
+ end
280
+
281
+ # Stop the request of a subscription that was closed while this session
282
+ # was still opening its socket.
283
+ #
284
+ # This is the one point a cancellation cannot otherwise reach: Net::HTTP
285
+ # opens the socket in `start` and sends the request only afterwards, and
286
+ # a session that has not finished starting cannot be closed — so a close
287
+ # that lands in that window has nothing to act on, and the connect can
288
+ # go on to POST a subscription the host has already ended, leaving the
289
+ # server holding a stream nothing will ever read. The check sits between
290
+ # the two, on the session itself, so it holds however long the connect
291
+ # takes and whether or not anything is still waiting for it.
292
+ # @param subscription [MCPClient::Subscription]
293
+ # @param http [Net::HTTP] the session Faraday is about to use
294
+ # @return [void]
295
+ def refuse_listen_send_once_closed(subscription, http)
296
+ http.define_singleton_method(:request) do |*args, &block|
297
+ raise MCPClient::HttpTransportBase::ListenStream::ListenStreamClosed if subscription.closed?
298
+
299
+ super(*args, &block)
300
+ end
301
+ end
302
+
303
+ # @return [Hash{MCPClient::Subscription => Thread}] running listen streams
304
+ def listen_threads
305
+ @listen_threads ||= {}
306
+ end
307
+
308
+ # The HTTP session each listen stream is reading, so a cancellation can
309
+ # close the response stream from the host's thread.
310
+ # @return [Hash{MCPClient::Subscription => Net::HTTP}]
311
+ def listen_sessions
312
+ @listen_sessions ||= {}
313
+ end
314
+
315
+ # Queues that interrupt a stream's re-open backoff.
316
+ # @return [Hash{MCPClient::Subscription => Thread::Queue}]
317
+ def listen_wakeups
318
+ @listen_wakeups ||= {}
319
+ end
320
+
321
+ # @return [Mutex] guards the per-stream bookkeeping above
322
+ def listen_threads_mutex
323
+ @listen_threads_mutex ||= Mutex.new
324
+ end
325
+
326
+ # @return [Integer] a fresh JSON-RPC request id
327
+ def next_request_id
328
+ @mutex.synchronize { @request_id += 1 }
329
+ end
330
+
331
+ # Keep a subscription's stream open: POST subscriptions/listen, consume
332
+ # the SSE response until the server closes it, and — while the host still
333
+ # wants the subscription and the transport is connected — re-open it with
334
+ # a new id after an abrupt drop ("a transport that closes without [the
335
+ # closing response] indicates an unexpected disconnect, which the client
336
+ # MAY treat as a trigger to reconnect").
337
+ # @param subscription [MCPClient::Subscription]
338
+ # @return [void]
339
+ def run_listen_stream(subscription)
340
+ delay = LISTEN_RECONNECT_DELAY
341
+ loop do
342
+ outcome = stream_listen_request(subscription)
343
+ break unless outcome == :dropped && subscription.reconnectable? && listen_transport_connected?
344
+
345
+ # No server-side subscription exists between the drop and the next
346
+ # acknowledgment, so it stops being active for as long as that lasts.
347
+ subscription.mark_reconnecting
348
+ @logger.info("Subscription #{subscription.id} stream ended without a closing response; " \
349
+ "re-opening in #{delay}s")
350
+ wait_before_reopen(subscription, delay)
351
+ delay = [delay * 2, LISTEN_MAX_RECONNECT_DELAY].min
352
+ unregister_subscription(subscription)
353
+ # Taking the new id, registering it and sending the request are one
354
+ # step: a close that lands in between must not leave a stream the
355
+ # host can no longer cancel.
356
+ break unless subscription.with_open_id(next_request_id) { register_subscription(subscription) }
357
+
358
+ # The request about to go out is unanswered, so it carries the same
359
+ # acknowledgment deadline the first one did: a server that accepts
360
+ # the replacement and then keeps it alive with SSE comments alone
361
+ # resets nothing else.
362
+ rearm_acknowledgment_deadline(subscription)
363
+ end
364
+ # The subscription may still be marked open after an unrecoverable drop.
365
+ unless subscription.closed?
366
+ unregister_subscription(subscription)
367
+ subscription.finish(gracefully: false, reason: 'stream ended')
368
+ end
369
+ ensure
370
+ listen_threads_mutex.synchronize do
371
+ listen_threads.delete(subscription)
372
+ listen_sessions.delete(subscription)
373
+ listen_wakeups.delete(subscription)
374
+ end
375
+ end
376
+
377
+ # Wait out the re-open backoff, interruptibly: a cancellation wakes the
378
+ # stream so it ends at once instead of after the whole delay.
379
+ # @param subscription [MCPClient::Subscription]
380
+ # @param delay [Numeric] seconds to wait
381
+ # @return [void]
382
+ def wait_before_reopen(subscription, delay)
383
+ wakeup = listen_threads_mutex.synchronize { listen_wakeups[subscription] }
384
+ return sleep(delay) unless wakeup
385
+
386
+ wakeup.pop(timeout: delay)
387
+ end
388
+
389
+ # @return [Boolean] whether the transport is still connected
390
+ def listen_transport_connected?
391
+ @mutex.synchronize { @connection_established }
392
+ end
393
+
394
+ # One POST of subscriptions/listen, streaming its SSE response.
395
+ # @param subscription [MCPClient::Subscription]
396
+ # @return [Symbol] :closed when the subscription ended (response, error,
397
+ # client), :dropped when the stream ended without a closing response
398
+ def stream_listen_request(subscription)
399
+ # A request for a subscription the host has already closed must never
400
+ # go out: the server would hold a stream nothing reads until its own
401
+ # timeout. {#arm_listen_session} checks again under the cancellation's
402
+ # own lock, when the request is about to open its socket.
403
+ return :closed if subscription.closed?
404
+
405
+ request = build_jsonrpc_request('subscriptions/listen', { 'notifications' => subscription.requested },
406
+ subscription.id)
407
+ buffer = +''
408
+ state = { finished: nil, scanned: 0, framing: nil }
409
+ response = listen_connection(subscription).post(@endpoint) do |req|
410
+ apply_request_headers(req, request)
411
+ # The stream is parsed incrementally as it arrives; a compressed
412
+ # body could not be. Ask for it uncompressed.
413
+ req.headers['Accept-Encoding'] = 'identity'
414
+ req.body = request.to_json
415
+ req.options.on_data = proc do |chunk, _bytes, env|
416
+ # Closing the response stream is the cancellation signal: stop
417
+ # reading as soon as the subscription is closed, by whichever end.
418
+ raise ListenStreamClosed if subscription.closed?
419
+ next if state[:finished]
420
+
421
+ ingest_listen_chunk(buffer, chunk, subscription, state, env)
422
+ end
423
+ end
424
+ return :closed if state[:finished]
425
+ return listen_stream_ended(subscription, response, buffer, state[:framing]) if response.success?
426
+
427
+ listen_response_rejected(subscription, response, buffer)
428
+ rescue ListenStreamClosed
429
+ :closed
430
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Net::ReadTimeout, IOError => e
431
+ @logger.debug("Subscription #{subscription.id} stream dropped: #{e.class}")
432
+ :dropped
433
+ rescue Faraday::ServerError => e
434
+ # raise_error middleware configured by the host, on the status the
435
+ # branch below treats as transient: same answer.
436
+ listen_server_error_dropped(subscription, e.response_status || 'error')
437
+ rescue Faraday::ClientError => e
438
+ # raise_error middleware configured by the host: same pipeline as a
439
+ # plain error response.
440
+ normalized = normalize_error_response(e.response)
441
+ if normalized
442
+ # With on_data streaming the middleware sees an empty body; the
443
+ # bytes went to the buffer.
444
+ body = normalized.body.to_s
445
+ fail_subscription_from_response(subscription, normalized, body.empty? ? buffer : body)
446
+ else
447
+ unregister_subscription(subscription)
448
+ subscription.finish(gracefully: false, error: MCPClient::Errors::TransportError.new(e.message))
449
+ end
450
+ :closed
451
+ rescue MCPClient::Errors::MCPError => e
452
+ unregister_subscription(subscription)
453
+ subscription.finish(gracefully: false, error: e)
454
+ :closed
455
+ rescue StandardError => e
456
+ unregister_subscription(subscription)
457
+ subscription.finish(gracefully: false, error: MCPClient::Errors::TransportError.new(e.message))
458
+ :closed
459
+ end
460
+
461
+ # A non-2xx answer to the listen POST.
462
+ #
463
+ # A 5xx is the server being temporarily unable to serve the stream, not
464
+ # a refusal of the subscription — {#listen_rejection_error} already
465
+ # calls it {MCPClient::Errors::TransientServerError}, and every other
466
+ # request retries it. Ending the subscription on one let a single 500 or
467
+ # 503 kill a long-lived stream for good, while a connection that failed
468
+ # or timed out on the very same request re-opened on the usual backoff.
469
+ # So it takes that path instead. Anything else — a 4xx carrying the
470
+ # server's typed JSON-RPC error, an authorization challenge — is the
471
+ # server refusing this subscription, and still ends it.
472
+ # @param subscription [MCPClient::Subscription]
473
+ # @param response [Faraday::Response] the non-2xx answer
474
+ # @param buffer [String] the bytes it delivered
475
+ # @return [Symbol] :dropped or :closed
476
+ def listen_response_rejected(subscription, response, buffer)
477
+ return listen_server_error_dropped(subscription, response.status) if (500..599).cover?(response.status)
478
+
479
+ fail_subscription_from_response(subscription, response, buffer)
480
+ :closed
481
+ end
482
+
483
+ # @param subscription [MCPClient::Subscription]
484
+ # @param status [Integer, String] the server error the listen POST got
485
+ # @return [Symbol] :dropped
486
+ def listen_server_error_dropped(subscription, status)
487
+ @logger.info("subscriptions/listen #{subscription.id} answered with HTTP #{status}; " \
488
+ 'treating it as a dropped stream')
489
+ :dropped
490
+ end
491
+
492
+ # A 2xx listen response that ended without an SSE-framed closing
493
+ # response. The server MAY answer with a single JSON object instead of
494
+ # a stream: a JSON-RPC response for this listen id is then the closing
495
+ # response (or the rejection). Anything else is a dropped stream.
496
+ # @param framing [Symbol, nil] the framing the stream was read with
497
+ # @return [Symbol] :closed or :dropped
498
+ def listen_stream_ended(subscription, response, buffer, framing = nil)
499
+ return :dropped unless json_framed_answer?(response, buffer, framing)
500
+
501
+ message = parse_listen_message(buffer.strip)
502
+ return :closed if message&.key?('id') && handle_listen_message(message, subscription) == :closed
503
+
504
+ :dropped
505
+ end
506
+
507
+ # How a listen answer is framed. The server MAY answer the listen
508
+ # request with a single JSON object rather than a stream, and SSE
509
+ # framing applied to one would consume it as an event with no data
510
+ # lines: a graceful close would then read as a dropped stream and a
511
+ # typed rejection as a generic one. The Content-Type decides — Faraday
512
+ # has saved the response headers before the first chunk reaches
513
+ # `on_data` — and a server that sent none is read the way its answer
514
+ # opens.
515
+ # @param env [Faraday::Env, nil] the streaming request's environment
516
+ # @param buffer [String] what has arrived so far
517
+ # @return [Symbol, nil] :json or :sse, nil while it cannot be told yet
518
+ def listen_stream_framing(env, buffer)
519
+ content_type = listen_response_content_type(env)
520
+ return :json if content_type.include?('application/json')
521
+ return :sse if content_type.include?('text/event-stream')
522
+
523
+ head = buffer.lstrip
524
+ return nil if head.empty?
525
+
526
+ head.start_with?('{') ? :json : :sse
527
+ end
528
+
529
+ # @param env [Faraday::Env, nil] the streaming request's environment
530
+ # @return [String] the answer's Content-Type, empty when it had none
531
+ def listen_response_content_type(env)
532
+ headers = env.respond_to?(:response_headers) ? env.response_headers : nil
533
+ return '' unless headers.respond_to?(:[])
534
+
535
+ (headers['content-type'] || headers['Content-Type']).to_s
536
+ end
537
+
538
+ # @param response [Faraday::Response] the finished listen response
539
+ # @param buffer [String] the bytes it delivered
540
+ # @param framing [Symbol, nil] the framing the stream was read with
541
+ # @return [Boolean] whether the answer is a single JSON object
542
+ def json_framed_answer?(response, buffer, framing)
543
+ return true if framing == :json
544
+ return false if framing == :sse
545
+
546
+ headers = response.headers || {}
547
+ content_type = (headers['content-type'] || headers['Content-Type']).to_s
548
+ content_type.include?('application/json') || buffer.lstrip.start_with?('{')
549
+ end
550
+
551
+ # A non-2xx answer to subscriptions/listen: 401/403 are authorization
552
+ # failures, other 4xx carry the (possibly typed) JSON-RPC error.
553
+ # @return [void]
554
+ def fail_subscription_from_response(subscription, response, body)
555
+ normalized = NormalizedResponse.new(response.status, response.headers || {}, body)
556
+ error = listen_rejection_error(normalized)
557
+ @logger.warn("subscriptions/listen #{subscription.id} rejected: #{sanitize_log_text(error.message)}")
558
+ unregister_subscription(subscription)
559
+ subscription.finish(gracefully: false, error: error)
560
+ end
561
+
562
+ # The error for a non-2xx listen response, through the same pipeline
563
+ # as any other request: 401/403 feed the OAuth challenge handling
564
+ # (insufficient_scope surfaces as InsufficientScopeError), 5xx is
565
+ # transient, other 4xx carry the (possibly typed) JSON-RPC error. A 5xx
566
+ # answer to the listen POST itself no longer arrives here — it re-opens
567
+ # the stream instead of ending it (see {#listen_response_rejected}) —
568
+ # but middleware that surfaces one as a rejection is still classified.
569
+ # @param response [NormalizedResponse]
570
+ # @return [MCPClient::Errors::MCPError]
571
+ def listen_rejection_error(response)
572
+ if [401, 403].include?(response.status)
573
+ process_authorization_challenge(response)
574
+ raise_authorization_error(response)
575
+ elsif (500..599).cover?(response.status)
576
+ MCPClient::Errors::TransientServerError.new("Server error: HTTP #{response.status}")
577
+ else
578
+ jsonrpc_error_from_http_response(response, "Client error: HTTP #{response.status}")
579
+ end
580
+ rescue MCPClient::Errors::MCPError => e
581
+ e
582
+ end
583
+
584
+ # One chunk of a listen stream as it arrives: appended, framed on the
585
+ # first chunk, parsed for complete events, and measured. The cap is on
586
+ # the event — {#consume_listen_events} refuses one over it before it is
587
+ # parsed, and what is still waiting for its terminator afterwards is
588
+ # measured too — so the verdict on an oversized event does not depend on
589
+ # where the chunk boundaries fell.
590
+ # @param buffer [String] mutable stream buffer
591
+ # @param chunk [String] what arrived
592
+ # @param subscription [MCPClient::Subscription]
593
+ # @param state [Hash] the stream's parsing state
594
+ # @param env [Faraday::Env, nil] the response environment, for the framing
595
+ # @return [Symbol, nil] :closed once the subscription ended
596
+ # @raise [MCPClient::Errors::ConnectionError] on an event over LISTEN_MAX_BUFFER_BYTES
597
+ def ingest_listen_chunk(buffer, chunk, subscription, state, env = nil)
598
+ buffer << chunk
599
+ state[:framing] ||= listen_stream_framing(env, buffer)
600
+ state[:finished] = consume_listen_events(buffer, subscription, state) unless state[:framing] == :json
601
+ enforce_listen_buffer_cap!(buffer)
602
+ state[:finished]
603
+ end
604
+
605
+ # Consume the complete SSE events in the buffer: notifications are
606
+ # routed (acknowledgment, tagged notifications, server-side
607
+ # cancellation), a response to the listen request ends the subscription,
608
+ # and comment lines are ignored (keep-alives).
609
+ # @param buffer [String] mutable stream buffer
610
+ # @param subscription [MCPClient::Subscription]
611
+ # @param state [Hash] the stream's parsing state
612
+ # @return [Symbol, nil] :closed once the subscription ended
613
+ def consume_listen_events(buffer, subscription, state = { scanned: 0 })
614
+ finished = nil
615
+ strip_listen_bom(buffer, state)
616
+ discard_comment_lines(buffer, state)
617
+ while (separator = match_event_terminator(buffer, [state[:scanned].to_i - 3, 0].max))
618
+ # A complete event over the cap is refused before it is parsed: it
619
+ # used to be consumed here and so never measured.
620
+ enforce_listen_buffer_cap!(buffer, separator.end(0))
621
+ event = buffer.slice!(0, separator.end(0))
622
+ state[:scanned] = 0
623
+ data = event.split(LINE_TERMINATOR).select { |l| l.start_with?('data:') }.map { |l| l.sub(/\Adata:\s*/, '') }
624
+ next if data.empty?
625
+
626
+ message = parse_listen_message(data.join("\n"))
627
+ next unless message
628
+
629
+ finished ||= handle_listen_message(message, subscription)
630
+ end
631
+ # Only what arrives next is searched next time, so an unterminated
632
+ # event delivered in many chunks stays linear. Counted in characters,
633
+ # like the offset String#match takes.
634
+ state[:scanned] = buffer.length
635
+ finished
636
+ end
637
+
638
+ # Drop one byte order mark from the head of the stream.
639
+ #
640
+ # The UTF-8 decode step of the SSE processing model drops it, and the
641
+ # field it precedes must still be recognized: without this the first
642
+ # event's `data:` lines are not data lines at all, so an acknowledgment
643
+ # opening the stream was discarded and its subscription cancelled by
644
+ # the watchdog. Only the head of the stream is examined, and only until
645
+ # the question is settled — a mark arriving split across chunks is
646
+ # neither stripped as a partial nor mistaken for data, and the bytes
647
+ # are never sought again once real content has started.
648
+ # @param buffer [String] mutable stream buffer
649
+ # @param state [Hash] the stream's parsing state
650
+ # @return [void]
651
+ def strip_listen_bom(buffer, state)
652
+ return if state[:bom_settled] || buffer.empty?
653
+
654
+ bom = buffer.encoding == Encoding::BINARY ? SseEventScanner::BOM : "\uFEFF".encode(buffer.encoding)
655
+ if buffer.start_with?(bom)
656
+ buffer.slice!(0, bom.length)
657
+ elsif bom.start_with?(buffer)
658
+ return # the mark itself is still arriving
659
+ end
660
+ state[:bom_settled] = true
661
+ rescue EncodingError
662
+ state[:bom_settled] = true
663
+ end
664
+
665
+ # Drop the complete comment lines the buffer holds, wherever they sit.
666
+ # Waiting for the next event terminator to drop them let a stream kept
667
+ # alive with comment lines alone — a server MAY do exactly that — grow
668
+ # the buffer without bound, until the cap ended the subscription.
669
+ # @param buffer [String] mutable stream buffer
670
+ # @param state [Hash] the scan state; rewound when anything was dropped
671
+ # @return [void]
672
+ def discard_comment_lines(buffer, state)
673
+ state[:scanned] = 0 if buffer.gsub!(COMMENT_LINE, '')
674
+ end
675
+
676
+ # @param buffer [String] the stream buffer
677
+ # @param from [Integer] character offset to search from
678
+ # @return [MatchData, nil] the next event terminator
679
+ def match_event_terminator(buffer, from)
680
+ buffer.match(EVENT_TERMINATOR, from)
681
+ end
682
+
683
+ # @param message [Hash] a JSON-RPC message from the listen stream
684
+ # @param subscription [MCPClient::Subscription]
685
+ # @return [Symbol, nil] :closed once the subscription ended
686
+ def handle_listen_message(message, subscription)
687
+ if message['method']
688
+ if message.key?('id')
689
+ @logger.warn("Ignoring server-initiated request #{sanitize_log_text(message['method'])} on a listen stream")
690
+ else
691
+ route_notification(message['method'], message['params'])
692
+ end
693
+ return :closed if subscription.closed?
694
+ elsif message.key?('id')
695
+ # A listen stream is scoped to its own request: a response for any
696
+ # other id on it is not this subscription's business.
697
+ unless message['id'] == subscription.id
698
+ @logger.warn("Ignoring a response for request #{sanitize_log_text(message['id'].to_s)} " \
699
+ "on the stream of subscription #{subscription.id}")
700
+ return nil
701
+ end
702
+ return :closed if handle_subscription_response(message)
703
+ end
704
+ nil
705
+ end
706
+
707
+ # @param json [String] one SSE event's data
708
+ # @return [Hash, nil]
709
+ def parse_listen_message(json)
710
+ message = JSON.parse(json)
711
+ return message if message.is_a?(Hash)
712
+
713
+ @logger.warn("Skipping non-object JSON-RPC message on a listen stream (#{message.class})")
714
+ nil
715
+ rescue JSON::ParserError => e
716
+ @logger.warn("Skipping invalid JSON on a listen stream: #{describe_parse_error(e, json)}")
717
+ nil
718
+ end
719
+
720
+ # @param buffer [String] a partial-event buffer, or one holding a
721
+ # complete event whose extent is given
722
+ # @param length [Integer, nil] the extent, in characters, of the event
723
+ # to measure; the whole buffer when nil
724
+ # @raise [MCPClient::Errors::ConnectionError] when it exceeds LISTEN_MAX_BUFFER_BYTES
725
+ def enforce_listen_buffer_cap!(buffer, length = nil)
726
+ bytes = length ? buffer[0, length].bytesize : buffer.bytesize
727
+ return if bytes <= LISTEN_MAX_BUFFER_BYTES
728
+
729
+ raise MCPClient::Errors::ConnectionError,
730
+ "Listen stream event exceeded the maximum buffered size (#{LISTEN_MAX_BUFFER_BYTES} bytes)"
731
+ end
732
+
733
+ # A streaming connection for listen requests (no retries: the loop above
734
+ # decides about re-opening).
735
+ #
736
+ # The host's `faraday_config` is applied first and the stream's own
737
+ # settings last, since two of them are what make a listen cancellable at
738
+ # all: a retry middleware would re-issue a request whose stream was
739
+ # closed on purpose (re-opening is the loop's decision, on its own
740
+ # backoff), and the adapter block is where the cancellation signal is
741
+ # armed — a host replacing the adapter, or adding retries, used to undo
742
+ # both without noticing.
743
+ # @param subscription [MCPClient::Subscription] the stream it serves
744
+ # @return [Faraday::Connection]
745
+ def listen_connection(subscription)
746
+ conn = Faraday.new(url: @base_url)
747
+ @faraday_config&.call(conn)
748
+ conn.builder.delete(Faraday::Retry::Middleware)
749
+ conn.options.open_timeout = LISTEN_OPEN_TIMEOUT
750
+ conn.options.timeout = LISTEN_STREAM_TIMEOUT
751
+ conn.adapter :net_http do |http|
752
+ http.read_timeout = LISTEN_STREAM_TIMEOUT
753
+ http.open_timeout = LISTEN_OPEN_TIMEOUT
754
+ # Runs on the stream's own thread, just before the socket is
755
+ # opened: the last point at which a cancellation can still stop
756
+ # the request, and the first at which it can close it.
757
+ arm_listen_session(subscription, http)
758
+ end
759
+ conn
760
+ end
761
+ end
762
+ end
763
+ end