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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'request_meta_scope'
|
|
3
4
|
require 'open3'
|
|
5
|
+
require 'monitor'
|
|
4
6
|
require 'json'
|
|
5
7
|
require_relative 'version'
|
|
6
8
|
require 'logger'
|
|
@@ -8,9 +10,14 @@ require 'logger'
|
|
|
8
10
|
module MCPClient
|
|
9
11
|
# JSON-RPC implementation of MCP server over stdio.
|
|
10
12
|
class ServerStdio < ServerBase
|
|
13
|
+
require_relative 'server_stdio/child_session'
|
|
11
14
|
require_relative 'server_stdio/json_rpc_transport'
|
|
12
15
|
|
|
13
16
|
include JsonRpcTransport
|
|
17
|
+
# Every operation that may weigh a cache decision runs inside a scope
|
|
18
|
+
# that reserves the host request_meta evaluation for the request it
|
|
19
|
+
# leads to, and drops it when the operation ends.
|
|
20
|
+
prepend MCPClient::RequestMetaScope
|
|
14
21
|
|
|
15
22
|
# @!attribute [r] command
|
|
16
23
|
# @return [String, Array] the command used to launch the server
|
|
@@ -27,6 +34,12 @@ module MCPClient
|
|
|
27
34
|
# for the server to exit, send SIGTERM, then SIGKILL if it still runs.
|
|
28
35
|
SHUTDOWN_GRACE_PERIOD = 2
|
|
29
36
|
|
|
37
|
+
# Seconds a process must last after the open subscriptions were re-sent to
|
|
38
|
+
# it — counted from the moment it received them, not from the moment it
|
|
39
|
+
# was spawned — before another unexpected exit counts as a crash rather
|
|
40
|
+
# than a crash loop (see {JsonRpcTransport#reopen_subscriptions}).
|
|
41
|
+
SUBSCRIPTION_RESTART_MIN_INTERVAL = 5
|
|
42
|
+
|
|
30
43
|
# Chunk size (bytes) used when draining the subprocess stderr pipe
|
|
31
44
|
STDERR_READ_CHUNK_SIZE = 8192
|
|
32
45
|
|
|
@@ -35,6 +48,14 @@ module MCPClient
|
|
|
35
48
|
# (e.g. progress output using carriage returns).
|
|
36
49
|
STDERR_MAX_LINE_SIZE = 64 * 1024
|
|
37
50
|
|
|
51
|
+
# How the server's protocol era is established (MCP 2026-07-28
|
|
52
|
+
# basic/transports/stdio "Backward Compatibility"):
|
|
53
|
+
# - :auto probe with server/discover, fall back to initialize on any
|
|
54
|
+
# non-modern error or timeout (dual-era client, the default)
|
|
55
|
+
# - :modern probe with server/discover and fail if the server is legacy
|
|
56
|
+
# - :legacy skip the probe and run the initialize handshake
|
|
57
|
+
PROTOCOL_MODES = %i[auto modern legacy].freeze
|
|
58
|
+
|
|
38
59
|
# Initialize a new ServerStdio instance
|
|
39
60
|
# @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
|
|
40
61
|
# For improved security, passing an Array is recommended to avoid shell injection issues
|
|
@@ -44,12 +65,28 @@ module MCPClient
|
|
|
44
65
|
# @param name [String, nil] optional name for this server
|
|
45
66
|
# @param logger [Logger, nil] optional logger
|
|
46
67
|
# @param env [Hash] optional environment variables for the subprocess
|
|
47
|
-
|
|
68
|
+
# @param protocol [Symbol] :auto (probe, fall back to initialize), :modern
|
|
69
|
+
# (probe, no fallback) or :legacy (initialize handshake only)
|
|
70
|
+
# @param discover_timeout [Numeric, nil] seconds to wait for the
|
|
71
|
+
# server/discover probe (default: read_timeout — a modern server that is
|
|
72
|
+
# slow to start must not be misclassified as legacy)
|
|
73
|
+
def initialize(command:, retries: 0, retry_backoff: 1, read_timeout: READ_TIMEOUT, name: nil, logger: nil, env: {},
|
|
74
|
+
protocol: :auto, discover_timeout: nil)
|
|
48
75
|
super(name: name)
|
|
76
|
+
unless PROTOCOL_MODES.include?(protocol)
|
|
77
|
+
raise ArgumentError, "protocol must be one of #{PROTOCOL_MODES.inspect}, got #{protocol.inspect}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@protocol_mode = protocol
|
|
81
|
+
@discover_timeout = discover_timeout || read_timeout
|
|
49
82
|
@command_array = command.is_a?(Array) ? command : nil
|
|
50
83
|
@command = command.is_a?(Array) ? command.join(' ') : command
|
|
51
84
|
@mutex = Mutex.new
|
|
52
85
|
@cond = ConditionVariable.new
|
|
86
|
+
# Serializes process start and protocol negotiation: two threads must
|
|
87
|
+
# not each spawn the server or run the probe and the fallback on the
|
|
88
|
+
# same pipes. Reentrant because negotiation waits on @mutex inside.
|
|
89
|
+
@init_lock = Monitor.new
|
|
53
90
|
@next_id = 1
|
|
54
91
|
@pending = {}
|
|
55
92
|
# Ids of requests awaiting a response; used to drop late/unsolicited
|
|
@@ -68,6 +105,29 @@ module MCPClient
|
|
|
68
105
|
@sampling_request_callback = nil # MCP 2025-11-25
|
|
69
106
|
@reader_thread = nil
|
|
70
107
|
@stderr_thread = nil
|
|
108
|
+
# Bumped whenever a subprocess is spawned or torn down, so a reader
|
|
109
|
+
# thread only ever speaks for the transport it was started for.
|
|
110
|
+
@transport_generation = 0
|
|
111
|
+
# Guards the pair (subprocess handles, generation) so that judging
|
|
112
|
+
# whether a request's transport is still current and writing it are
|
|
113
|
+
# one step: a restart cannot slip in between (see
|
|
114
|
+
# JsonRpcTransport#send_request).
|
|
115
|
+
@transport_lock = Mutex.new
|
|
116
|
+
@negotiating = false
|
|
117
|
+
@transport_retired = false
|
|
118
|
+
@modern_answer_received = false
|
|
119
|
+
# The record of the live child process, and of the one the open
|
|
120
|
+
# subscriptions were last re-sent to — the crash-loop bound is read from
|
|
121
|
+
# the latter (see ChildSession and JsonRpcTransport#reopen_subscriptions).
|
|
122
|
+
@session = nil
|
|
123
|
+
@subscription_carrier = nil
|
|
124
|
+
# The subscriptions waiting for a process, and the lock the two paths
|
|
125
|
+
# that write them share: a `cleanup` moving the open subscriptions onto
|
|
126
|
+
# the queue overlaps a hand-over whose listen write failed putting one
|
|
127
|
+
# back (JsonRpcTransport#enqueue_reconnecting_subscriptions). Made here
|
|
128
|
+
# so no two threads ever race to make it.
|
|
129
|
+
@reconnecting_mutex = Mutex.new
|
|
130
|
+
@reconnecting_subscriptions = []
|
|
71
131
|
end
|
|
72
132
|
|
|
73
133
|
# Server info from the initialize response
|
|
@@ -78,20 +138,35 @@ module MCPClient
|
|
|
78
138
|
# @return [Hash, nil] Server capabilities
|
|
79
139
|
attr_reader :capabilities
|
|
80
140
|
|
|
141
|
+
# @return [Symbol] the configured protocol mode (:auto, :modern or :legacy)
|
|
142
|
+
attr_reader :protocol_mode
|
|
143
|
+
|
|
144
|
+
# @return [Numeric] seconds allowed for the server/discover probe
|
|
145
|
+
attr_reader :discover_timeout
|
|
146
|
+
|
|
81
147
|
# Connect to the MCP server by launching the command process via stdin/stdout
|
|
82
148
|
# @return [Boolean] true if connection was successful
|
|
83
149
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
84
150
|
def connect
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
151
|
+
handles = spawn_server_process
|
|
152
|
+
# The handles and the generation change together: a request judged
|
|
153
|
+
# current against the old generation must not find the new stdin.
|
|
154
|
+
@transport_lock.synchronize do
|
|
155
|
+
@stdin, @stdout, @stderr, @wait_thread = handles
|
|
156
|
+
@transport_generation += 1
|
|
157
|
+
# A fresh process is not the one that exited: a restart made for the
|
|
158
|
+
# open subscriptions must not be torn down again by the next request.
|
|
159
|
+
@transport_retired = false
|
|
160
|
+
# A fresh process has said nothing yet: what the previous one wrote
|
|
161
|
+
# identifies nothing about this one, and what was negotiated WITH it
|
|
162
|
+
# binds nothing here. The era is per process (stdio "Backward
|
|
163
|
+
# Compatibility"), and the replacement's reader starts before the
|
|
164
|
+
# probe proposes anything: a 2025-11-25 replacement that pings at
|
|
165
|
+
# startup would otherwise be judged by the dead process's era, its
|
|
166
|
+
# ping dropped, and both the probe and the handshake left waiting on
|
|
167
|
+
# a server that answers nothing until its pong arrives.
|
|
168
|
+
@modern_answer_received = false
|
|
169
|
+
@protocol_version = nil
|
|
95
170
|
end
|
|
96
171
|
pin_pipe_encodings
|
|
97
172
|
true
|
|
@@ -99,6 +174,19 @@ module MCPClient
|
|
|
99
174
|
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server: #{e.message}"
|
|
100
175
|
end
|
|
101
176
|
|
|
177
|
+
# @return [Array] the stdin, stdout, stderr and wait thread of the spawned process
|
|
178
|
+
def spawn_server_process
|
|
179
|
+
if @command_array
|
|
180
|
+
return Open3.popen3(@env, *@command_array) if @env.any?
|
|
181
|
+
|
|
182
|
+
Open3.popen3(*@command_array)
|
|
183
|
+
elsif @env.any?
|
|
184
|
+
Open3.popen3(@env, @command)
|
|
185
|
+
else
|
|
186
|
+
Open3.popen3(@command)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
102
190
|
# Pin the subprocess pipe encodings to UTF-8 instead of inheriting the
|
|
103
191
|
# process locale (Encoding.default_external). JSON-RPC messages MUST be
|
|
104
192
|
# UTF-8 encoded (MCP 2025-11-25 basic/transports.mdx); under a non-UTF-8
|
|
@@ -115,13 +203,99 @@ module MCPClient
|
|
|
115
203
|
# Spawn a reader thread to collect JSON-RPC responses
|
|
116
204
|
# @return [Thread] the reader thread
|
|
117
205
|
def start_reader
|
|
206
|
+
generation = @transport_generation
|
|
207
|
+
# The record of the process this reader belongs to, so an EOF can be
|
|
208
|
+
# told from the EOF of a process that has since been replaced.
|
|
209
|
+
session = @session
|
|
210
|
+
stdout = @stdout
|
|
118
211
|
@reader_thread = Thread.new do
|
|
119
|
-
|
|
212
|
+
stdout.each_line do |line|
|
|
120
213
|
handle_line(line)
|
|
121
214
|
end
|
|
215
|
+
handle_reader_eof(session, generation)
|
|
122
216
|
rescue StandardError
|
|
123
217
|
# Reader thread aborted unexpectedly
|
|
218
|
+
ensure
|
|
219
|
+
retire_transport(generation)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The subprocess closed its stdout: it has exited (or its pipes were
|
|
224
|
+
# dropped), so no response will ever arrive on this transport again. MCP
|
|
225
|
+
# 2026-07-28 basic/transports/stdio ("Unexpected Termination") says a
|
|
226
|
+
# client SHOULD restart a server that terminated unexpectedly, so retire
|
|
227
|
+
# the handshake rather than let it describe a process that no longer
|
|
228
|
+
# exists: the next request releases these handles and negotiates again
|
|
229
|
+
# against a fresh subprocess.
|
|
230
|
+
#
|
|
231
|
+
# Nothing is restarted or replayed from here. A request that was in
|
|
232
|
+
# flight may already have been executed server-side, so it fails as it
|
|
233
|
+
# would on any other broken transport; only the session is recoverable.
|
|
234
|
+
# A deliberate shutdown bumps the generation first, so its own reader
|
|
235
|
+
# reaching EOF is not mistaken for an unexpected exit.
|
|
236
|
+
# @param generation [Integer] the transport this reader was started for
|
|
237
|
+
# @return [void]
|
|
238
|
+
def retire_transport(generation)
|
|
239
|
+
return unless generation == @transport_generation
|
|
240
|
+
|
|
241
|
+
# Waiters are woken: a request in flight on this transport will never
|
|
242
|
+
# be answered, and should fail now rather than wait out its timeout.
|
|
243
|
+
@mutex.synchronize do
|
|
244
|
+
@transport_retired = true
|
|
245
|
+
@cond.broadcast
|
|
124
246
|
end
|
|
247
|
+
@logger.debug('Server stdout closed; the transport will be re-established on the next request')
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# EOF on the process's stdout: unless the client closed its stdin, the
|
|
251
|
+
# server exited on its own.
|
|
252
|
+
#
|
|
253
|
+
# Waiting out an initialization still in flight is what makes an exit
|
|
254
|
+
# *during* one recoverable. The handling used to be skipped outright while
|
|
255
|
+
# `@initialized` was false — which is exactly the state a process that
|
|
256
|
+
# answered the discovery probe and then exited leaves behind. Nothing else
|
|
257
|
+
# noticed: {JsonRpcTransport#ensure_initialized} went on to mark the dead
|
|
258
|
+
# connection initialized and re-send the open subscriptions to it, the
|
|
259
|
+
# failed writes were deferred back onto the queue for "the next process",
|
|
260
|
+
# and with this reader already gone there was no one left to establish one.
|
|
261
|
+
# The subscriptions stayed :reconnecting for ever, with the host neither
|
|
262
|
+
# served nor told.
|
|
263
|
+
#
|
|
264
|
+
# The lock is that initialization finishing, and taking it cannot deadlock:
|
|
265
|
+
# the reader is never the thread inside it, and by the time it is waiting
|
|
266
|
+
# here it can deliver no further responses, so anything that thread is
|
|
267
|
+
# still waiting for is already bounded by its own timeout.
|
|
268
|
+
# @param session [MCPClient::ServerStdio::ChildSession, nil] the record of
|
|
269
|
+
# the process this reader was started for
|
|
270
|
+
# @param generation [Integer] the transport generation this reader was
|
|
271
|
+
# started for; an EOF on a generation that has since been torn down —
|
|
272
|
+
# by the host's `cleanup`, or by another exit — is not an exit to handle
|
|
273
|
+
# @return [void]
|
|
274
|
+
def handle_reader_eof(session, generation = @transport_generation)
|
|
275
|
+
@init_lock.synchronize { nil } unless @initialized
|
|
276
|
+
return unless live_transport?(generation, session)
|
|
277
|
+
|
|
278
|
+
handle_server_exit(session, generation)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Whether the process a reader was started for is still the live one: its
|
|
282
|
+
# transport generation has not been claimed for teardown, and its record
|
|
283
|
+
# is the current session. Read under the transport lock, since a teardown
|
|
284
|
+
# claims the generation under it.
|
|
285
|
+
# @param generation [Integer] the transport generation to check
|
|
286
|
+
# @param session [MCPClient::ServerStdio::ChildSession, nil]
|
|
287
|
+
# @return [Boolean]
|
|
288
|
+
def live_transport?(generation, session)
|
|
289
|
+
@transport_lock.synchronize { !@stdin.nil? && generation == @transport_generation } && current_session?(session)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Whether the process a reader was started for is still the live one. A
|
|
293
|
+
# reader whose process has already been torn down and replaced must not
|
|
294
|
+
# tear down its successor.
|
|
295
|
+
# @param session [MCPClient::ServerStdio::ChildSession, nil]
|
|
296
|
+
# @return [Boolean]
|
|
297
|
+
def current_session?(session)
|
|
298
|
+
session.nil? || @session.equal?(session)
|
|
125
299
|
end
|
|
126
300
|
|
|
127
301
|
# Spawn a thread to continuously drain the subprocess stderr.
|
|
@@ -138,10 +312,11 @@ module MCPClient
|
|
|
138
312
|
# STDERR_MAX_LINE_SIZE is flushed rather than retained.
|
|
139
313
|
# @return [Thread] the stderr reader thread
|
|
140
314
|
def start_stderr_reader
|
|
315
|
+
stderr = @stderr
|
|
141
316
|
@stderr_thread = Thread.new do
|
|
142
317
|
buffer = +''
|
|
143
318
|
loop do
|
|
144
|
-
buffer <<
|
|
319
|
+
buffer << stderr.readpartial(STDERR_READ_CHUNK_SIZE)
|
|
145
320
|
flush_stderr_lines(buffer)
|
|
146
321
|
flush_stderr_overflow(buffer)
|
|
147
322
|
end
|
|
@@ -159,6 +334,9 @@ module MCPClient
|
|
|
159
334
|
# @param line [String] line of output to parse
|
|
160
335
|
# @return [void]
|
|
161
336
|
def handle_line(line)
|
|
337
|
+
# The response is dated from the arrival of its line, before it is
|
|
338
|
+
# decoded: parsing time is not freshness.
|
|
339
|
+
arrived = respond_to?(:monotonic_now, true) ? monotonic_now : nil
|
|
162
340
|
msg = JSON.parse(line)
|
|
163
341
|
@logger.debug("Received line: #{describe_jsonrpc_message(msg)}")
|
|
164
342
|
|
|
@@ -171,34 +349,91 @@ module MCPClient
|
|
|
171
349
|
|
|
172
350
|
# Dispatch JSON-RPC requests from server (has id AND method) - MCP 2025-06-18
|
|
173
351
|
if msg['method'] && msg.key?('id')
|
|
174
|
-
|
|
352
|
+
if modern_peer?
|
|
353
|
+
# MCP 2026-07-28 stdio: "The server MUST NOT write JSON-RPC requests
|
|
354
|
+
# to stdout" and "The client MUST NOT write JSON-RPC responses" —
|
|
355
|
+
# server-to-client interactions travel in InputRequiredResult.
|
|
356
|
+
@logger.warn("Ignoring server-initiated request #{msg['method']}: " \
|
|
357
|
+
'a modern MCP server MUST NOT write JSON-RPC requests to stdout')
|
|
358
|
+
else
|
|
359
|
+
handle_server_request(msg)
|
|
360
|
+
end
|
|
175
361
|
return
|
|
176
362
|
end
|
|
177
363
|
|
|
178
364
|
# Dispatch JSON-RPC notifications (no id, has method)
|
|
179
365
|
if msg['method'] && !msg.key?('id')
|
|
180
|
-
|
|
366
|
+
route_notification(msg['method'], msg['params'])
|
|
181
367
|
return
|
|
182
368
|
end
|
|
183
369
|
|
|
184
370
|
# Handle standard JSON-RPC responses (has id, no method)
|
|
185
371
|
id = msg['id']
|
|
186
372
|
return unless id
|
|
373
|
+
# A response to a subscriptions/listen request ends that subscription
|
|
374
|
+
# (no caller is waiting on it).
|
|
375
|
+
return if handle_subscription_response(msg)
|
|
376
|
+
|
|
377
|
+
record_response(id, msg, arrived)
|
|
378
|
+
rescue JSON::ParserError, EncodingError
|
|
379
|
+
# Skip non-JSONRPC or undecodable lines in the output stream so a single
|
|
380
|
+
# bad line cannot kill the reader thread
|
|
381
|
+
end
|
|
187
382
|
|
|
383
|
+
# Queue a response for the caller waiting on it.
|
|
384
|
+
# @param id [Integer, String] the response's request id
|
|
385
|
+
# @param msg [Hash] the decoded response
|
|
386
|
+
# @param arrived [Float, nil] when its line arrived (monotonic seconds)
|
|
387
|
+
# @return [void]
|
|
388
|
+
def record_response(id, msg, arrived)
|
|
188
389
|
@mutex.synchronize do
|
|
189
390
|
# Only retain a response that corresponds to an outstanding request.
|
|
190
391
|
# Late responses (arriving after the caller timed out) and unsolicited
|
|
191
392
|
# responses are dropped so @pending cannot grow without bound.
|
|
192
393
|
if @awaiting.key?(id)
|
|
394
|
+
# The answer is recorded as identifying the peer BEFORE it is
|
|
395
|
+
# queued and before the next line is read: the thread waiting for
|
|
396
|
+
# it may not run until after the server has written its next line,
|
|
397
|
+
# and if that line is a request a modern server MUST NOT have
|
|
398
|
+
# written, it must already be known as prohibited traffic — a
|
|
399
|
+
# legacy accommodation is only owed while the probe is unanswered.
|
|
400
|
+
# Only an OUTSTANDING request's answer says anything, though: the
|
|
401
|
+
# response to a probe that timed out (and was cancelled) SHOULD be
|
|
402
|
+
# ignored, and the session it fell back to is a 2025-11-25 one
|
|
403
|
+
# whose server requests are still owed their responses. And only
|
|
404
|
+
# while the era is being negotiated: an answer on an established
|
|
405
|
+
# 2025-11-25 session renegotiates nothing, however modern its
|
|
406
|
+
# shape, and that session's ping, roots, sampling and elicitation
|
|
407
|
+
# requests stay owed their responses.
|
|
408
|
+
@modern_answer_received = true if era_probe_in_flight? && identifies_modern_server?(msg)
|
|
193
409
|
@pending[id] = msg
|
|
410
|
+
# Dated from arrival: the waiter may wake much later.
|
|
411
|
+
(@response_arrivals ||= {})[id] = arrived || monotonic_now if respond_to?(:monotonic_now, true)
|
|
194
412
|
@cond.broadcast
|
|
195
413
|
else
|
|
196
414
|
@logger.debug("Discarding response for unknown or expired request id=#{id}")
|
|
197
415
|
end
|
|
198
416
|
end
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Whether a server-initiated request is prohibited traffic.
|
|
420
|
+
#
|
|
421
|
+
# Judged by the ESTABLISHED era, not by protocol_version: during the
|
|
422
|
+
# server/discover probe the latter is only the version this client
|
|
423
|
+
# proposed. A legacy server MAY ping while the probe is unanswered and
|
|
424
|
+
# then wait for the response before doing anything else, so treating its
|
|
425
|
+
# request as prohibited modern traffic deadlocks the negotiation.
|
|
426
|
+
#
|
|
427
|
+
# Two exceptions. A client configured protocol: :modern has already
|
|
428
|
+
# ruled out the legacy fallback that the accommodation exists for: it
|
|
429
|
+
# will never speak legacy, so it never runs a host callback for a server
|
|
430
|
+
# request nor writes the response back — not even while its own probe is
|
|
431
|
+
# still in flight. And once the reader has seen an answer only a modern
|
|
432
|
+
# server could have written, the server is modern whatever the
|
|
433
|
+
# negotiating thread has got round to applying.
|
|
434
|
+
# @return [Boolean]
|
|
435
|
+
def modern_peer?
|
|
436
|
+
protocol_era == :modern || @protocol_mode == :modern || @modern_answer_received
|
|
202
437
|
end
|
|
203
438
|
|
|
204
439
|
# List all prompts available from the MCP server
|
|
@@ -206,24 +441,50 @@ module MCPClient
|
|
|
206
441
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
207
442
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
|
|
208
443
|
def list_prompts
|
|
444
|
+
cached = hinted_list_value(:prompts)
|
|
445
|
+
return cached if cached
|
|
446
|
+
|
|
209
447
|
ensure_initialized
|
|
210
|
-
|
|
448
|
+
# A cursor the server rejects restarts the list from its first page,
|
|
449
|
+
# exactly as it does on the HTTP transports (MCP pagination).
|
|
450
|
+
page = { cursor: nil }
|
|
451
|
+
prompts = restarting_rejected_cursor('prompts', page) { collect_prompt_pages(page) }
|
|
452
|
+
attach_list_value(:prompts, prompts)
|
|
453
|
+
prompts
|
|
454
|
+
rescue MCPClient::Errors::ServerError => e
|
|
455
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
456
|
+
# supported versions); keep them intact instead of wrapping.
|
|
457
|
+
raise if e.protocol_error?
|
|
458
|
+
|
|
459
|
+
raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
|
|
460
|
+
rescue StandardError => e
|
|
461
|
+
raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Collect every page of prompts/list, recording what each page was
|
|
465
|
+
# answered under so the cache can bind the combined list to it.
|
|
466
|
+
# @param page [Hash] holds the cursor of the request in flight
|
|
467
|
+
# @return [Array<MCPClient::Prompt>]
|
|
468
|
+
def collect_prompt_pages(page)
|
|
469
|
+
pages = []
|
|
470
|
+
received_ats = []
|
|
471
|
+
fingerprints = []
|
|
472
|
+
epoch = cache_epoch(:prompts)
|
|
473
|
+
prompts = collect_paginated('prompts') do |cursor|
|
|
211
474
|
params = {}
|
|
212
475
|
params['cursor'] = cursor if cursor
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
result = res['result'] || {}
|
|
476
|
+
started = monotonic_now
|
|
477
|
+
page[:cursor] = cursor
|
|
478
|
+
page_result = fetch_list_page(:prompts, cursor) { rpc_request('prompts/list', params) } || {}
|
|
479
|
+
result = require_complete_result!(page_result, 'prompts/list')
|
|
480
|
+
pages << result
|
|
481
|
+
received_ats << response_received_at(since: started)
|
|
482
|
+
fingerprints << request_params_fingerprint
|
|
222
483
|
prompts = (result['prompts'] || []).map { |td| MCPClient::Prompt.from_json(td, server: self) }
|
|
223
484
|
[prompts, result['nextCursor']]
|
|
224
485
|
end
|
|
225
|
-
|
|
226
|
-
|
|
486
|
+
record_list_cache_hint('prompts/list', pages, received_ats, params: fingerprints, epoch: epoch)
|
|
487
|
+
prompts
|
|
227
488
|
end
|
|
228
489
|
|
|
229
490
|
# Get a prompt with the given parameters
|
|
@@ -234,21 +495,13 @@ module MCPClient
|
|
|
234
495
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
|
|
235
496
|
def get_prompt(prompt_name, parameters)
|
|
236
497
|
ensure_initialized
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
'method' => 'prompts/get',
|
|
243
|
-
'params' => build_named_request_params(prompt_name, parameters)
|
|
244
|
-
}
|
|
245
|
-
send_request(req)
|
|
246
|
-
res = wait_response(req_id)
|
|
247
|
-
if (err = res['error'])
|
|
248
|
-
raise MCPClient::Errors::ServerError, err['message']
|
|
249
|
-
end
|
|
498
|
+
rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
|
|
499
|
+
rescue MCPClient::Errors::ServerError => e
|
|
500
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
501
|
+
# supported versions); keep them intact instead of wrapping.
|
|
502
|
+
raise if e.protocol_error?
|
|
250
503
|
|
|
251
|
-
|
|
504
|
+
raise MCPClient::Errors::PromptGetError, "Error calling prompt '#{prompt_name}': #{e.message}"
|
|
252
505
|
rescue StandardError => e
|
|
253
506
|
raise MCPClient::Errors::PromptGetError, "Error calling prompt '#{prompt_name}': #{e.message}"
|
|
254
507
|
end
|
|
@@ -259,20 +512,26 @@ module MCPClient
|
|
|
259
512
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
260
513
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource listing
|
|
261
514
|
def list_resources(cursor: nil)
|
|
515
|
+
cached = cursor ? nil : hinted_list_value(:resources)
|
|
516
|
+
return cached if cached
|
|
517
|
+
|
|
262
518
|
ensure_initialized
|
|
263
|
-
req_id = next_id
|
|
264
519
|
params = {}
|
|
265
520
|
params['cursor'] = cursor if cursor
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
raise MCPClient::Errors::ServerError, err['message']
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
result = res['result'] || {}
|
|
521
|
+
epoch = cache_epoch(:resources)
|
|
522
|
+
answer = fetching_list_page(:resources, cursor) { rpc_request('resources/list', params) }
|
|
523
|
+
result = require_complete_result!(answer || {}, 'resources/list')
|
|
524
|
+
record_cache_hint(:resources, result, epoch: epoch) unless cursor
|
|
274
525
|
resources = (result['resources'] || []).map { |td| MCPClient::Resource.from_json(td, server: self) }
|
|
275
|
-
{ 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
526
|
+
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
527
|
+
attach_list_value(:resources, resources_result) unless cursor
|
|
528
|
+
resources_result
|
|
529
|
+
rescue MCPClient::Errors::ServerError => e
|
|
530
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
531
|
+
# supported versions); keep them intact instead of wrapping.
|
|
532
|
+
raise if e.protocol_error?
|
|
533
|
+
|
|
534
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
|
|
276
535
|
rescue StandardError => e
|
|
277
536
|
raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
|
|
278
537
|
end
|
|
@@ -284,23 +543,16 @@ module MCPClient
|
|
|
284
543
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource reading
|
|
285
544
|
def read_resource(uri)
|
|
286
545
|
ensure_initialized
|
|
287
|
-
|
|
288
|
-
#
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
'params' => { 'uri' => uri }
|
|
294
|
-
}
|
|
295
|
-
send_request(req)
|
|
296
|
-
res = wait_response(req_id)
|
|
297
|
-
if (err = res['error'])
|
|
298
|
-
raise MCPClient::Errors::ServerError, err['message']
|
|
299
|
-
end
|
|
546
|
+
# A null result reaches the shared guard as-is: it is a malformed
|
|
547
|
+
# response, not an empty resource.
|
|
548
|
+
read_resource_with_cache(uri) { |sent| rpc_request('resources/read', { 'uri' => sent }) }
|
|
549
|
+
rescue MCPClient::Errors::ServerError => e
|
|
550
|
+
raise if e.protocol_error?
|
|
551
|
+
raise resource_not_found_error(uri, e) if resource_not_found_response?(e)
|
|
300
552
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
553
|
+
raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
|
|
554
|
+
rescue MCPClient::Errors::TransportError
|
|
555
|
+
raise
|
|
304
556
|
rescue StandardError => e
|
|
305
557
|
raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
|
|
306
558
|
end
|
|
@@ -311,20 +563,30 @@ module MCPClient
|
|
|
311
563
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
312
564
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
|
|
313
565
|
def list_resource_templates(cursor: nil)
|
|
566
|
+
# Only a list the server itself bounded is served from here: a
|
|
567
|
+
# positive ttlMs means no second request, while a list with no hint
|
|
568
|
+
# (a 2025-11-25 server) is asked for again, as it was before this
|
|
569
|
+
# transport cached anything (MCP 2026-07-28 caching).
|
|
570
|
+
cached = cursor ? nil : hinted_list_value(:templates)
|
|
571
|
+
return cached if cached
|
|
572
|
+
|
|
314
573
|
ensure_initialized
|
|
315
|
-
req_id = next_id
|
|
316
574
|
params = {}
|
|
317
575
|
params['cursor'] = cursor if cursor
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
raise MCPClient::Errors::ServerError, err['message']
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
result = res['result'] || {}
|
|
576
|
+
epoch = cache_epoch(:templates)
|
|
577
|
+
answer = fetching_list_page(:templates, cursor) { rpc_request('resources/templates/list', params) }
|
|
578
|
+
result = require_complete_result!(answer || {}, 'resources/templates/list')
|
|
579
|
+
record_cache_hint(:templates, result, epoch: epoch) unless cursor
|
|
326
580
|
templates = (result['resourceTemplates'] || []).map { |td| MCPClient::ResourceTemplate.from_json(td, server: self) }
|
|
327
|
-
{ 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
581
|
+
templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
582
|
+
attach_list_value(:templates, templates_result) unless cursor
|
|
583
|
+
templates_result
|
|
584
|
+
rescue MCPClient::Errors::ServerError => e
|
|
585
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
586
|
+
# supported versions); keep them intact instead of wrapping.
|
|
587
|
+
raise if e.protocol_error?
|
|
588
|
+
|
|
589
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
|
|
328
590
|
rescue StandardError => e
|
|
329
591
|
raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
|
|
330
592
|
end
|
|
@@ -337,22 +599,23 @@ module MCPClient
|
|
|
337
599
|
def subscribe_resource(uri)
|
|
338
600
|
ensure_initialized
|
|
339
601
|
require_capability!('resources', 'subscribe', method: 'resources/subscribe')
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
'params' => { 'uri' => uri }
|
|
346
|
-
}
|
|
347
|
-
send_request(req)
|
|
348
|
-
res = wait_response(req_id)
|
|
349
|
-
if (err = res['error'])
|
|
350
|
-
raise MCPClient::Errors::ServerError, err['message']
|
|
602
|
+
# MCP 2026-07-28 replaced resources/subscribe with a subscriptions/listen
|
|
603
|
+
# stream carrying resourceSubscriptions.
|
|
604
|
+
if modern?
|
|
605
|
+
subscribe_resource_via_listen(uri)
|
|
606
|
+
return true
|
|
351
607
|
end
|
|
352
608
|
|
|
609
|
+
rpc_request('resources/subscribe', { 'uri' => uri })
|
|
353
610
|
true
|
|
354
611
|
rescue MCPClient::Errors::CapabilityError
|
|
355
612
|
raise
|
|
613
|
+
rescue MCPClient::Errors::ServerError => e
|
|
614
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
615
|
+
# supported versions); keep them intact instead of wrapping.
|
|
616
|
+
raise if e.protocol_error?
|
|
617
|
+
|
|
618
|
+
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
356
619
|
rescue StandardError => e
|
|
357
620
|
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
358
621
|
end
|
|
@@ -365,22 +628,21 @@ module MCPClient
|
|
|
365
628
|
def unsubscribe_resource(uri)
|
|
366
629
|
ensure_initialized
|
|
367
630
|
require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
'id' => req_id,
|
|
372
|
-
'method' => 'resources/unsubscribe',
|
|
373
|
-
'params' => { 'uri' => uri }
|
|
374
|
-
}
|
|
375
|
-
send_request(req)
|
|
376
|
-
res = wait_response(req_id)
|
|
377
|
-
if (err = res['error'])
|
|
378
|
-
raise MCPClient::Errors::ServerError, err['message']
|
|
631
|
+
if modern?
|
|
632
|
+
unsubscribe_resource_via_listen(uri)
|
|
633
|
+
return true
|
|
379
634
|
end
|
|
380
635
|
|
|
636
|
+
rpc_request('resources/unsubscribe', { 'uri' => uri })
|
|
381
637
|
true
|
|
382
638
|
rescue MCPClient::Errors::CapabilityError
|
|
383
639
|
raise
|
|
640
|
+
rescue MCPClient::Errors::ServerError => e
|
|
641
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
642
|
+
# supported versions); keep them intact instead of wrapping.
|
|
643
|
+
raise if e.protocol_error?
|
|
644
|
+
|
|
645
|
+
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
384
646
|
rescue StandardError => e
|
|
385
647
|
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
386
648
|
end
|
|
@@ -390,25 +652,53 @@ module MCPClient
|
|
|
390
652
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
391
653
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
|
|
392
654
|
def list_tools
|
|
655
|
+
# MCP 2026-07-28 caching: a list the server put a positive ttlMs on is
|
|
656
|
+
# served here while it is still fresh, so a host reaching for the
|
|
657
|
+
# transport directly does not re-list on every call.
|
|
658
|
+
cached = hinted_list_value(:tools)
|
|
659
|
+
return cached if cached
|
|
660
|
+
|
|
393
661
|
ensure_initialized
|
|
394
|
-
|
|
662
|
+
# A cursor the server rejects restarts the list from its first page,
|
|
663
|
+
# exactly as it does on the HTTP transports (MCP pagination).
|
|
664
|
+
page = { cursor: nil }
|
|
665
|
+
tools = restarting_rejected_cursor('tools', page) { collect_tool_pages(page) }
|
|
666
|
+
attach_list_value(:tools, tools)
|
|
667
|
+
tools
|
|
668
|
+
rescue MCPClient::Errors::ServerError => e
|
|
669
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
670
|
+
# supported versions); keep them intact instead of wrapping.
|
|
671
|
+
raise if e.protocol_error?
|
|
672
|
+
|
|
673
|
+
raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
|
|
674
|
+
rescue StandardError => e
|
|
675
|
+
raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Collect every page of tools/list, recording what each page was answered
|
|
679
|
+
# under so the cache can bind the combined list to it.
|
|
680
|
+
# @param page [Hash] holds the cursor of the request in flight
|
|
681
|
+
# @return [Array<MCPClient::Tool>]
|
|
682
|
+
def collect_tool_pages(page)
|
|
683
|
+
pages = []
|
|
684
|
+
received_ats = []
|
|
685
|
+
fingerprints = []
|
|
686
|
+
epoch = cache_epoch(:tools)
|
|
687
|
+
tools = collect_paginated('tools') do |cursor|
|
|
395
688
|
params = {}
|
|
396
689
|
params['cursor'] = cursor if cursor
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
result = res['result'] || {}
|
|
690
|
+
started = monotonic_now
|
|
691
|
+
page[:cursor] = cursor
|
|
692
|
+
page_result = fetch_list_page(:tools, cursor) { rpc_request('tools/list', params) } || {}
|
|
693
|
+
result = require_complete_result!(page_result, 'tools/list')
|
|
694
|
+
pages << result
|
|
695
|
+
received_ats << response_received_at(since: started)
|
|
696
|
+
fingerprints << request_params_fingerprint
|
|
407
697
|
tools = (result['tools'] || []).map { |td| MCPClient::Tool.from_json(td, server: self) }
|
|
408
698
|
[tools, result['nextCursor']]
|
|
409
699
|
end
|
|
410
|
-
|
|
411
|
-
|
|
700
|
+
record_list_cache_hint('tools/list', pages, received_ats, params: fingerprints, epoch: epoch)
|
|
701
|
+
tools
|
|
412
702
|
end
|
|
413
703
|
|
|
414
704
|
# Call a tool with the given parameters
|
|
@@ -419,21 +709,13 @@ module MCPClient
|
|
|
419
709
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
|
420
710
|
def call_tool(tool_name, parameters)
|
|
421
711
|
ensure_initialized
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
'method' => 'tools/call',
|
|
428
|
-
'params' => build_named_request_params(tool_name, parameters)
|
|
429
|
-
}
|
|
430
|
-
send_request(req)
|
|
431
|
-
res = wait_response(req_id)
|
|
432
|
-
if (err = res['error'])
|
|
433
|
-
raise MCPClient::Errors::ServerError, err['message']
|
|
434
|
-
end
|
|
712
|
+
rpc_request('tools/call', build_named_request_params(tool_name, parameters))
|
|
713
|
+
rescue MCPClient::Errors::ServerError => e
|
|
714
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
715
|
+
# supported versions); keep them intact instead of wrapping.
|
|
716
|
+
raise if e.protocol_error?
|
|
435
717
|
|
|
436
|
-
|
|
718
|
+
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
437
719
|
rescue StandardError => e
|
|
438
720
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
439
721
|
end
|
|
@@ -447,24 +729,18 @@ module MCPClient
|
|
|
447
729
|
def complete(ref:, argument:, context: nil)
|
|
448
730
|
ensure_initialized
|
|
449
731
|
require_capability!('completions', method: 'completion/complete')
|
|
450
|
-
req_id = next_id
|
|
451
732
|
params = { 'ref' => ref, 'argument' => argument }
|
|
452
733
|
params['context'] = context if context
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
'id' => req_id,
|
|
456
|
-
'method' => 'completion/complete',
|
|
457
|
-
'params' => params
|
|
458
|
-
}
|
|
459
|
-
send_request(req)
|
|
460
|
-
res = wait_response(req_id)
|
|
461
|
-
if (err = res['error'])
|
|
462
|
-
raise MCPClient::Errors::ServerError, err['message']
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
res.dig('result', 'completion') || { 'values' => [] }
|
|
734
|
+
result = require_complete_result!(rpc_request('completion/complete', params) || {}, 'completion/complete')
|
|
735
|
+
result['completion'] || { 'values' => [] }
|
|
466
736
|
rescue MCPClient::Errors::CapabilityError
|
|
467
737
|
raise
|
|
738
|
+
rescue MCPClient::Errors::ServerError => e
|
|
739
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
740
|
+
# supported versions); keep them intact instead of wrapping.
|
|
741
|
+
raise if e.protocol_error?
|
|
742
|
+
|
|
743
|
+
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
468
744
|
rescue StandardError => e
|
|
469
745
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
470
746
|
end
|
|
@@ -474,25 +750,31 @@ module MCPClient
|
|
|
474
750
|
# 'critical', 'alert', 'emergency')
|
|
475
751
|
# @return [Hash] empty result on success
|
|
476
752
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
753
|
+
# @deprecated Logging is deprecated since MCP 2026-07-28 (SEP-2577);
|
|
754
|
+
# earliest removal is the first revision released on or after
|
|
755
|
+
# 2027-07-28. Have the server log to stderr (stdio) or use
|
|
756
|
+
# OpenTelemetry instead.
|
|
477
757
|
def log_level=(level)
|
|
758
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
478
759
|
ensure_initialized
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
'params' => { 'level' => level }
|
|
486
|
-
}
|
|
487
|
-
send_request(req)
|
|
488
|
-
res = wait_response(req_id)
|
|
489
|
-
if (err = res['error'])
|
|
490
|
-
raise MCPClient::Errors::ServerError, err['message']
|
|
760
|
+
# MCP 2026-07-28 removed logging/setLevel: the level is declared per
|
|
761
|
+
# request in _meta["io.modelcontextprotocol/logLevel"], so store it
|
|
762
|
+
# and let every subsequent request carry it.
|
|
763
|
+
if modern?
|
|
764
|
+
@log_level = validate_log_level!(level)
|
|
765
|
+
return
|
|
491
766
|
end
|
|
492
767
|
|
|
493
|
-
|
|
494
|
-
|
|
768
|
+
require_capability!('logging', method: 'logging/setLevel')
|
|
769
|
+
rpc_request('logging/setLevel', { 'level' => level }) || {}
|
|
770
|
+
rescue MCPClient::Errors::CapabilityError, ArgumentError
|
|
495
771
|
raise
|
|
772
|
+
rescue MCPClient::Errors::ServerError => e
|
|
773
|
+
# 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
|
|
774
|
+
# supported versions); keep them intact instead of wrapping.
|
|
775
|
+
raise if e.protocol_error?
|
|
776
|
+
|
|
777
|
+
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
496
778
|
rescue StandardError => e
|
|
497
779
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
498
780
|
end
|
|
@@ -505,6 +787,13 @@ module MCPClient
|
|
|
505
787
|
end
|
|
506
788
|
|
|
507
789
|
# Register a callback for roots/list requests (MCP 2025-06-18)
|
|
790
|
+
#
|
|
791
|
+
# @deprecated Roots is deprecated since MCP 2026-07-28 (SEP-2577); earliest
|
|
792
|
+
# removal is the first revision released on or after 2027-07-28. Registering
|
|
793
|
+
# a handler is not itself use of Roots — a handler that answers with no root
|
|
794
|
+
# exposes nothing deprecated — but a handler that answers with a root adopts
|
|
795
|
+
# the deprecated feature and raises the notice. Pass directories or files
|
|
796
|
+
# through tool parameters, resource URIs or server configuration instead.
|
|
508
797
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
509
798
|
# @return [void]
|
|
510
799
|
def on_roots_list_request(&block)
|
|
@@ -512,6 +801,11 @@ module MCPClient
|
|
|
512
801
|
end
|
|
513
802
|
|
|
514
803
|
# Register a callback for sampling requests (MCP 2025-11-25)
|
|
804
|
+
#
|
|
805
|
+
# @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577); earliest
|
|
806
|
+
# removal is the first revision released on or after 2027-07-28. Integrate
|
|
807
|
+
# directly with the LLM provider API instead of serving
|
|
808
|
+
# sampling/createMessage.
|
|
515
809
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
516
810
|
# @return [void]
|
|
517
811
|
def on_sampling_request(&block)
|
|
@@ -596,6 +890,10 @@ module MCPClient
|
|
|
596
890
|
|
|
597
891
|
# Call the registered callback
|
|
598
892
|
result = @roots_list_request_callback.call(request_id, params)
|
|
893
|
+
# Serving a roots/list answer that carries a root means this host
|
|
894
|
+
# declared, and is using, the deprecated Roots capability (SEP-2577) —
|
|
895
|
+
# with or without a Client. An empty answer is not use of it.
|
|
896
|
+
warn_roots_deprecated(result)
|
|
599
897
|
|
|
600
898
|
# Send the response back to the server (echoing related-task _meta)
|
|
601
899
|
send_roots_list_response(request_id, merge_related_task_meta(result, params))
|
|
@@ -609,10 +907,18 @@ module MCPClient
|
|
|
609
907
|
# If no callback is registered, return error
|
|
610
908
|
unless @sampling_request_callback
|
|
611
909
|
@logger.warn('Received sampling request but no callback registered, returning error')
|
|
612
|
-
|
|
910
|
+
# sampling.mdx § Error Handling reserves -1 for "User rejected sampling
|
|
911
|
+
# request"; a capability this client never declared is an unsupported
|
|
912
|
+
# method (-32601, Method not found), as Client#handle_sampling_request answers.
|
|
913
|
+
send_error_response(request_id, -32_601, 'Sampling not supported')
|
|
613
914
|
return
|
|
614
915
|
end
|
|
615
916
|
|
|
917
|
+
# Sampling, and the includeContext values it may carry, are deprecated
|
|
918
|
+
# (SEP-2577, SEP-2596) — with or without a Client.
|
|
919
|
+
warn_sampling_deprecated(params)
|
|
920
|
+
return if refused_undeclared_sampling_tools?(request_id, params)
|
|
921
|
+
|
|
616
922
|
# Call the registered callback
|
|
617
923
|
result = @sampling_request_callback.call(request_id, params)
|
|
618
924
|
|
|
@@ -723,30 +1029,252 @@ module MCPClient
|
|
|
723
1029
|
buffer.clear
|
|
724
1030
|
end
|
|
725
1031
|
|
|
1032
|
+
# Whether #cleanup ends a session: only a 2025-11-25 handshake opens
|
|
1033
|
+
# one, and only once it completed. A stateless 2026-07-28 peer, a
|
|
1034
|
+
# process that never got through its handshake and a probe still in
|
|
1035
|
+
# flight leave nothing session-scoped behind — task ids and their
|
|
1036
|
+
# bookkeeping stay what they are for the process that comes next.
|
|
1037
|
+
# @return [Boolean]
|
|
1038
|
+
def ending_session?
|
|
1039
|
+
@initialized && !modern_peer?
|
|
1040
|
+
end
|
|
1041
|
+
|
|
726
1042
|
# Clean up the server connection
|
|
727
1043
|
# Closes all stdio handles and terminates any running processes and threads
|
|
728
1044
|
# following the MCP 2025-11-25 stdio shutdown sequence (basic/lifecycle.mdx):
|
|
729
1045
|
# close stdin, wait for the server to exit, send SIGTERM if it does not exit
|
|
730
1046
|
# within a reasonable time, then SIGKILL if it still does not exit.
|
|
1047
|
+
#
|
|
1048
|
+
# Tears down the process the transport holds *now*: a `cleanup` that
|
|
1049
|
+
# finds the process already claimed by another teardown — the reader of a
|
|
1050
|
+
# process that exited, dismantling it while the host got here — has
|
|
1051
|
+
# nothing to do, and never touches a replacement the host established in
|
|
1052
|
+
# the meantime (see {#teardown_transport}).
|
|
731
1053
|
# @return [void]
|
|
732
1054
|
def cleanup
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
@
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1055
|
+
# Everything this transport left on this thread — the notes of the
|
|
1056
|
+
# entries it served and recorded, the credentials, parameters and
|
|
1057
|
+
# metadata of its requests — describes a slice that will never be
|
|
1058
|
+
# tagged and a request that will never be made.
|
|
1059
|
+
forget_transport_thread_state
|
|
1060
|
+
teardown_transport(@transport_lock.synchronize { @transport_generation })
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
# What one teardown claimed: the handles of one process, and the
|
|
1064
|
+
# generation the transport moved on to when it claimed them. Torn down
|
|
1065
|
+
# from the record, not from the transport, so a teardown that finishes
|
|
1066
|
+
# late — after a host request has already established the next process
|
|
1067
|
+
# — dismantles only what it claimed.
|
|
1068
|
+
TornDownTransport = Struct.new(:generation, :stdin, :stdout, :stderr, :wait_thread, :reader_thread,
|
|
1069
|
+
:stderr_thread, :session, keyword_init: true)
|
|
1070
|
+
|
|
1071
|
+
# Tear down the process of one transport generation.
|
|
1072
|
+
#
|
|
1073
|
+
# The teardown claims the process under the transport lock — the handles
|
|
1074
|
+
# come off the transport and the generation moves on, so a request judged
|
|
1075
|
+
# current is written before this or not at all, and a second teardown of
|
|
1076
|
+
# the same process (the host's `cleanup` racing the reader's own, or the
|
|
1077
|
+
# reverse) finds nothing to claim. Everything after the claim works on the
|
|
1078
|
+
# claimed handles alone: a `cleanup` that ran ahead of it may already have
|
|
1079
|
+
# let the host establish a replacement, and a reader whose teardown
|
|
1080
|
+
# finishes only now used to clear that replacement's session, handles,
|
|
1081
|
+
# reader and handshake on its way out — leaving a live, re-sent
|
|
1082
|
+
# subscription registered on a transport that had just forgotten its
|
|
1083
|
+
# process.
|
|
1084
|
+
# @param generation [Integer] the transport generation to tear down; a
|
|
1085
|
+
# generation that has moved on is not this teardown's to touch
|
|
1086
|
+
# @return [void]
|
|
1087
|
+
def teardown_transport(generation)
|
|
1088
|
+
claimed = claim_transport(generation)
|
|
1089
|
+
return unless claimed
|
|
1090
|
+
|
|
1091
|
+
begin
|
|
1092
|
+
park_open_subscriptions(claimed)
|
|
1093
|
+
terminate_server_process(claimed.wait_thread)
|
|
1094
|
+
claimed.stdout.close unless claimed.stdout.nil? || claimed.stdout.closed?
|
|
1095
|
+
claimed.stderr.close unless claimed.stderr.nil? || claimed.stderr.closed?
|
|
1096
|
+
# The reader calls this itself when the process exits on its own, and a
|
|
1097
|
+
# thread that kills itself here would abandon the rest of the shutdown —
|
|
1098
|
+
# including the restart that a live subscription depends on. It is at
|
|
1099
|
+
# EOF by then and returns on its own.
|
|
1100
|
+
claimed.reader_thread&.kill unless claimed.reader_thread.equal?(Thread.current)
|
|
1101
|
+
claimed.stderr_thread&.kill
|
|
1102
|
+
rescue StandardError
|
|
1103
|
+
# Clean up resources during unexpected termination
|
|
1104
|
+
ensure
|
|
1105
|
+
forget_torn_down_transport(claimed)
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
# Take the process of a transport generation off the transport, for one
|
|
1110
|
+
# teardown to dismantle. Under the transport lock: the handles and the
|
|
1111
|
+
# generation change together, and closing stdin here is what makes a
|
|
1112
|
+
# request judged current either already written or never written.
|
|
1113
|
+
# @param generation [Integer] the transport generation to claim
|
|
1114
|
+
# @return [TornDownTransport, nil] the claim, or nil when that generation
|
|
1115
|
+
# is not the live one (already claimed, or replaced)
|
|
1116
|
+
def claim_transport(generation)
|
|
1117
|
+
@transport_lock.synchronize do
|
|
1118
|
+
return nil unless @stdin && generation == @transport_generation
|
|
1119
|
+
|
|
1120
|
+
# Past this point the reader threads speak for a transport that is
|
|
1121
|
+
# being dismantled on purpose: their EOF must not retire whatever
|
|
1122
|
+
# replaces it. A 2025-11-25 handshake opened a session that ends
|
|
1123
|
+
# with the process. A stateless 2026-07-28 peer holds none: a task it
|
|
1124
|
+
# created outlives the connection exactly as it does over sessionless
|
|
1125
|
+
# HTTP, so the process that replaces this one is asked about it
|
|
1126
|
+
# rather than the task being written off as gone with a session that
|
|
1127
|
+
# never existed.
|
|
1128
|
+
bump_session_epoch if ending_session?
|
|
1129
|
+
@transport_generation += 1
|
|
1130
|
+
claimed = TornDownTransport.new(generation: @transport_generation, stdin: @stdin, stdout: @stdout,
|
|
1131
|
+
stderr: @stderr, wait_thread: @wait_thread, reader_thread: @reader_thread,
|
|
1132
|
+
stderr_thread: @stderr_thread, session: @session)
|
|
1133
|
+
@stdin.close unless @stdin.closed?
|
|
1134
|
+
@stdin = @stdout = @stderr = @wait_thread = @reader_thread = @stderr_thread = nil
|
|
1135
|
+
# The ids still outstanding went out on the process just claimed and
|
|
1136
|
+
# will never be answered. They are recorded as dropped here, at the
|
|
1137
|
+
# claim, so their waiters fail on that record — promptly, woken here —
|
|
1138
|
+
# even when a replacement is established before this teardown
|
|
1139
|
+
# finishes and {#forget_torn_down_transport} therefore leaves the
|
|
1140
|
+
# replacement's bookkeeping alone.
|
|
1141
|
+
@mutex.synchronize do
|
|
1142
|
+
dropped_requests.merge(@awaiting.keys)
|
|
1143
|
+
@cond.broadcast
|
|
1144
|
+
end
|
|
1145
|
+
claimed
|
|
1146
|
+
end
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# Subscriptions do not survive the process: keep the ones the host still
|
|
1150
|
+
# wants so they are re-sent once the process is re-established
|
|
1151
|
+
# (basic/patterns/subscriptions "Graceful Closure"). They are moved to
|
|
1152
|
+
# the pending list outside the registry lock, since a subscription being
|
|
1153
|
+
# opened holds its own lock while taking that one.
|
|
1154
|
+
#
|
|
1155
|
+
# Only the claimed process's, the way the pipe teardown is. Draining the
|
|
1156
|
+
# registry took whatever it held at that moment, and a teardown that got
|
|
1157
|
+
# here after a host request had established the replacement parked the
|
|
1158
|
+
# streams that replacement was already serving: their outstanding listen
|
|
1159
|
+
# ids were discarded with the dead process's, so `close` had nothing left
|
|
1160
|
+
# to cancel and the server went on serving a stream this client could no
|
|
1161
|
+
# longer name. A subscription carries the generation its listen went out
|
|
1162
|
+
# on ({MCPClient::Subscription#with_open_id}), which is the same question
|
|
1163
|
+
# the pipe asks, asked of the registry.
|
|
1164
|
+
#
|
|
1165
|
+
# Enqueued through the one lock a deferred hand-over writes under too,
|
|
1166
|
+
# since a listen write failing on the process being torn down lands in
|
|
1167
|
+
# the window between the snapshot and the write
|
|
1168
|
+
# (JsonRpcTransport#queue_subscriptions_of_ended_process, which also
|
|
1169
|
+
# forgets the listen ids this process was holding).
|
|
1170
|
+
# @param claimed [TornDownTransport] what this teardown claimed
|
|
1171
|
+
# @return [void]
|
|
1172
|
+
def park_open_subscriptions(claimed)
|
|
1173
|
+
open_subscriptions = subscriptions_mutex.synchronize do
|
|
1174
|
+
mine, theirs = subscriptions.values.partition { |subscription| claimed_subscription?(subscription, claimed) }
|
|
1175
|
+
subscriptions.keep_if { |_, subscription| theirs.include?(subscription) }
|
|
1176
|
+
mine
|
|
1177
|
+
end
|
|
1178
|
+
open_subscriptions.each(&:mark_reconnecting)
|
|
1179
|
+
queue_subscriptions_of_ended_process(open_subscriptions.select(&:reconnectable?))
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
# Whether a registered subscription belongs to the process a teardown
|
|
1183
|
+
# claimed. The claim's generation is the one the transport moved *to*, so
|
|
1184
|
+
# the process it took is everything below it; a subscription opened on the
|
|
1185
|
+
# replacement carries a higher one. One that never recorded a generation
|
|
1186
|
+
# is treated as the claim's, which is what a registry entry was before
|
|
1187
|
+
# the stamp existed.
|
|
1188
|
+
# @param subscription [MCPClient::Subscription]
|
|
1189
|
+
# @param claimed [TornDownTransport] what this teardown claimed
|
|
1190
|
+
# @return [Boolean]
|
|
1191
|
+
def claimed_subscription?(subscription, claimed)
|
|
1192
|
+
generation = subscription.open_generation
|
|
1193
|
+
generation.nil? || generation < claimed.generation
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
# The last word of a teardown, whatever else went wrong: the record of the
|
|
1197
|
+
# process it claimed is ended, and — only if no process has been
|
|
1198
|
+
# established since the claim — the transport forgets its session and its
|
|
1199
|
+
# handshake, and wakes the requests that will never be answered.
|
|
1200
|
+
#
|
|
1201
|
+
# No further response can arrive on a transport that is being dismantled,
|
|
1202
|
+
# so nothing is outstanding any more. Responses that already arrived are
|
|
1203
|
+
# kept: they are answers this client received and has not handed to their
|
|
1204
|
+
# caller yet, and a restart happening in that window must not turn a
|
|
1205
|
+
# completed request into a timeout. Each one belongs to a caller that is
|
|
1206
|
+
# about to take it out of the map, so keeping them cannot accumulate.
|
|
1207
|
+
# Waiters are woken so a request that will never be answered re-checks its
|
|
1208
|
+
# deadline rather than blocking on a reader thread that has been killed.
|
|
1209
|
+
# A replacement established meanwhile owns whatever is outstanding now,
|
|
1210
|
+
# and its handshake and session are its own.
|
|
1211
|
+
# @param claimed [TornDownTransport] what this teardown claimed
|
|
1212
|
+
# @return [void]
|
|
1213
|
+
def forget_torn_down_transport(claimed)
|
|
1214
|
+
# The process this session was is gone, whatever else went wrong above.
|
|
1215
|
+
# Its record outlives it: it is what the next session's re-send of the
|
|
1216
|
+
# open subscriptions asks about (JsonRpcTransport#reopen_subscriptions).
|
|
1217
|
+
claimed.session&.ended
|
|
1218
|
+
# Asked and acted on in one step, under the lock a replacement is
|
|
1219
|
+
# established through. Asking first and writing afterwards let the
|
|
1220
|
+
# answer go stale in between: a host request that established the
|
|
1221
|
+
# replacement in that window did so *after* both checks passed, and
|
|
1222
|
+
# this teardown then marked its outstanding requests dropped and left
|
|
1223
|
+
# it uninitialized with no session — a live process the transport could
|
|
1224
|
+
# no longer name.
|
|
1225
|
+
@transport_lock.synchronize do
|
|
1226
|
+
# Either signal says a process has been established since the claim:
|
|
1227
|
+
# `connect` moves the generation on, and the handshake that follows
|
|
1228
|
+
# records a new session.
|
|
1229
|
+
next if @transport_generation != claimed.generation
|
|
1230
|
+
next unless @session.equal?(claimed.session)
|
|
1231
|
+
|
|
1232
|
+
@mutex.synchronize do
|
|
1233
|
+
# The ids still outstanding are recorded as dropped: their waiters
|
|
1234
|
+
# fail on that record, whenever they next run, rather than wait out
|
|
1235
|
+
# their timeouts because the restart cleared the retirement first.
|
|
1236
|
+
dropped_requests.merge(@awaiting.keys)
|
|
1237
|
+
@response_arrivals&.clear
|
|
1238
|
+
@awaiting.clear
|
|
1239
|
+
@cond.broadcast
|
|
1240
|
+
end
|
|
1241
|
+
@session = nil
|
|
1242
|
+
# Cached results belong to the process that just ended.
|
|
1243
|
+
clear_result_cache
|
|
1244
|
+
# The next request re-establishes the process and, on a modern
|
|
1245
|
+
# server, re-sends the subscriptions the host still holds.
|
|
1246
|
+
@initialized = false
|
|
748
1247
|
end
|
|
749
|
-
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
# The server process ended on its own (its stdout reached EOF). MCP
|
|
1251
|
+
# 2026-07-28 stdio "Unexpected Termination": the client SHOULD restart
|
|
1252
|
+
# it; in-flight requests are lost and subscriptions must be
|
|
1253
|
+
# re-established. Marking the session uninitialized makes the next
|
|
1254
|
+
# request spawn a fresh process and re-open live subscriptions — and a
|
|
1255
|
+
# host that is only waiting for notifications makes no such request, so
|
|
1256
|
+
# an open subscription restarts the process here instead.
|
|
1257
|
+
# @param session [MCPClient::ServerStdio::ChildSession, nil] the record of
|
|
1258
|
+
# the process that exited; the crash-loop bound only counts an exit this
|
|
1259
|
+
# path recorded, never a teardown the host asked for
|
|
1260
|
+
# (see {JsonRpcTransport#crash_looping?})
|
|
1261
|
+
# @param generation [Integer] the transport generation of the process
|
|
1262
|
+
# that exited; a generation the host has already torn down or replaced
|
|
1263
|
+
# is not this exit's to handle
|
|
1264
|
+
# @return [void]
|
|
1265
|
+
def handle_server_exit(session = @session, generation = @transport_generation)
|
|
1266
|
+
return unless live_transport?(generation, session)
|
|
1267
|
+
|
|
1268
|
+
@logger.warn('MCP server process ended unexpectedly')
|
|
1269
|
+
# Stamped before the teardown, on the record the teardown retires: this
|
|
1270
|
+
# is the one path that knows the process was not asked to go.
|
|
1271
|
+
session&.exited_unexpectedly
|
|
1272
|
+
# Retired before the teardown bumps the generation past this reader's,
|
|
1273
|
+
# so the exit stays observable the way any other unexpected exit is:
|
|
1274
|
+
# the next request releases the dead handles and negotiates again.
|
|
1275
|
+
retire_transport(generation)
|
|
1276
|
+
teardown_transport(generation)
|
|
1277
|
+
restart_for_open_subscriptions
|
|
750
1278
|
end
|
|
751
1279
|
|
|
752
1280
|
# Terminate the spawned server process per the MCP 2025-11-25 stdio
|
|
@@ -754,23 +1282,84 @@ module MCPClient
|
|
|
754
1282
|
# so wait for the process to exit on its own; if it does not exit within
|
|
755
1283
|
# the grace period send SIGTERM, wait again, and finally send SIGKILL.
|
|
756
1284
|
# @return [void]
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1285
|
+
# @param wait_thread [Process::Waiter, nil] the process to wait for; the
|
|
1286
|
+
# one a teardown claimed, since the transport may hold a replacement by now
|
|
1287
|
+
# @return [void]
|
|
1288
|
+
def terminate_server_process(wait_thread = @wait_thread)
|
|
1289
|
+
return unless wait_thread
|
|
1290
|
+
return if wait_thread.join(SHUTDOWN_GRACE_PERIOD)
|
|
1291
|
+
|
|
1292
|
+
signal_server_process('TERM', wait_thread)
|
|
1293
|
+
return if wait_thread.join(SHUTDOWN_GRACE_PERIOD)
|
|
760
1294
|
|
|
761
|
-
signal_server_process('
|
|
762
|
-
|
|
1295
|
+
signal_server_process('KILL', wait_thread)
|
|
1296
|
+
wait_thread.join(SHUTDOWN_GRACE_PERIOD)
|
|
1297
|
+
end
|
|
763
1298
|
|
|
764
|
-
|
|
765
|
-
|
|
1299
|
+
# Cancel a subscription: on stdio there is no per-request stream to
|
|
1300
|
+
# close, so the client sends notifications/cancelled referencing the
|
|
1301
|
+
# subscriptions/listen request id.
|
|
1302
|
+
# @param subscription [MCPClient::Subscription]
|
|
1303
|
+
# @return [void]
|
|
1304
|
+
def cancel_subscription(subscription)
|
|
1305
|
+
# Closed first: a re-open in flight holds the subscription's lock until
|
|
1306
|
+
# it has taken its new id, so the id unregistered and cancelled below is
|
|
1307
|
+
# the one the server was actually sent.
|
|
1308
|
+
subscription.finish(by_client: true)
|
|
1309
|
+
unregister_subscription(subscription)
|
|
1310
|
+
subscriptions_mutex.synchronize { resource_subscriptions.delete_if { |_uri, sub| sub.equal?(subscription) } }
|
|
1311
|
+
cancel_outstanding_listens(subscription)
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
# Tell the server the client has stopped reading every listen request it
|
|
1315
|
+
# wrote for this subscription on the process this pipe belongs to.
|
|
1316
|
+
#
|
|
1317
|
+
# Not just the one the subscription is on: a second listen written for it
|
|
1318
|
+
# on one process — a hand-over the queue duplicated, say — leaves the
|
|
1319
|
+
# server serving the first stream, and naming only the newest id left that
|
|
1320
|
+
# one open with the client no longer able to refer to it.
|
|
1321
|
+
#
|
|
1322
|
+
# The subscription's *current* id is not named on top of those. It used to
|
|
1323
|
+
# be, so that a handle closed between taking an id and writing its request
|
|
1324
|
+
# was cancelled anyway — but that cancellation named a request the server
|
|
1325
|
+
# had not been sent, and reached the pipe ahead of it. Every id this
|
|
1326
|
+
# client wrote is recorded ({MCPClient::Subscription#record_outstanding_listen}),
|
|
1327
|
+
# so a written id is here already; an id that is only assigned is the
|
|
1328
|
+
# opener's to cancel, once it has actually written it.
|
|
1329
|
+
#
|
|
1330
|
+
# Ids written to a pipe other than this one are left where they are: the
|
|
1331
|
+
# process reading this one was never sent those requests, and naming them
|
|
1332
|
+
# on it would cancel requests it has never seen
|
|
1333
|
+
# ({MCPClient::Subscription#take_outstanding_listens}).
|
|
1334
|
+
# @param subscription [MCPClient::Subscription]
|
|
1335
|
+
# @param io [IO, nil] the pipe to cancel on; defaults to the live process's
|
|
1336
|
+
# stdin, and is pinned by a caller that is cancelling ids it wrote to one
|
|
1337
|
+
# particular process (see {JsonRpcTransport#open_subscription})
|
|
1338
|
+
# @return [void]
|
|
1339
|
+
def cancel_outstanding_listens(subscription, io: @stdin)
|
|
1340
|
+
subscription.take_outstanding_listens(io).each { |id| send_subscription_cancellation(id, io: io) }
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
# Tell the server the client closed a subscriptions/listen request.
|
|
1344
|
+
# @param id [Integer, String, nil] the listen request id
|
|
1345
|
+
# @param io [IO, nil] the pipe to write the cancellation to
|
|
1346
|
+
# @return [void]
|
|
1347
|
+
def send_subscription_cancellation(id, io: @stdin)
|
|
1348
|
+
return unless io && id
|
|
1349
|
+
|
|
1350
|
+
notif = build_jsonrpc_notification('notifications/cancelled',
|
|
1351
|
+
{ 'requestId' => id, 'reason' => 'Client closed subscription' })
|
|
1352
|
+
io.puts(notif.to_json)
|
|
1353
|
+
rescue StandardError => e
|
|
1354
|
+
@logger.debug("Failed to send subscription cancellation: #{e.message}")
|
|
766
1355
|
end
|
|
767
1356
|
|
|
768
1357
|
# Send a signal to the server process, tolerating a process that has
|
|
769
1358
|
# already exited or cannot be signalled.
|
|
770
1359
|
# @param signal [String] signal name, e.g. 'TERM' or 'KILL'
|
|
771
1360
|
# @return [void]
|
|
772
|
-
def signal_server_process(signal)
|
|
773
|
-
Process.kill(signal,
|
|
1361
|
+
def signal_server_process(signal, wait_thread = @wait_thread)
|
|
1362
|
+
Process.kill(signal, wait_thread.pid)
|
|
774
1363
|
rescue Errno::ESRCH, Errno::EPERM => e
|
|
775
1364
|
@logger.debug("Could not send SIG#{signal} to server process: #{e.class}")
|
|
776
1365
|
end
|