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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- 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
|