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,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'request_meta_scope'
|
|
3
4
|
require 'uri'
|
|
4
5
|
require 'json'
|
|
5
6
|
require 'monitor'
|
|
@@ -7,26 +8,41 @@ require 'logger'
|
|
|
7
8
|
require 'faraday'
|
|
8
9
|
require 'faraday/retry'
|
|
9
10
|
require 'faraday/follow_redirects'
|
|
11
|
+
require_relative 'deprecations'
|
|
10
12
|
|
|
11
13
|
module MCPClient
|
|
12
14
|
# Implementation of MCP server that communicates via Server-Sent Events (SSE)
|
|
13
15
|
# Useful for communicating with remote MCP servers over HTTP
|
|
14
16
|
#
|
|
17
|
+
# @deprecated The HTTP+SSE transport has been deprecated since MCP
|
|
18
|
+
# 2025-03-26 and is listed in the 2026-07-28 deprecated features
|
|
19
|
+
# registry (SEP-2596); earliest removal is three months after SEP-2596
|
|
20
|
+
# reaches Final. Use {MCPClient::ServerStreamableHTTP}.
|
|
21
|
+
#
|
|
15
22
|
# @note Elicitation Support (MCP 2025-06-18)
|
|
16
23
|
# This transport FULLY supports server-initiated elicitation requests via bidirectional
|
|
17
24
|
# JSON-RPC. The server sends elicitation/create requests via the SSE stream, and the
|
|
18
25
|
# client responds via HTTP POST to the RPC endpoint. This provides full elicitation
|
|
19
26
|
# capability for remote servers.
|
|
20
27
|
class ServerSSE < ServerBase
|
|
28
|
+
require_relative 'request_authorization'
|
|
21
29
|
require_relative 'server_sse/sse_parser'
|
|
22
30
|
require_relative 'server_sse/json_rpc_transport'
|
|
23
31
|
|
|
24
32
|
include SseParser
|
|
25
33
|
include JsonRpcTransport
|
|
34
|
+
# The credentials of the request this thread last sent, kept in the same
|
|
35
|
+
# slot, with the same empty-slot semantics, as on the other transports:
|
|
36
|
+
# {#cleanup} drops it with the rest of what this transport left behind.
|
|
37
|
+
include MCPClient::RequestAuthorization
|
|
26
38
|
|
|
27
39
|
require_relative 'server_sse/reconnect_monitor'
|
|
28
40
|
|
|
29
41
|
include ReconnectMonitor
|
|
42
|
+
# Every operation that may weigh a cache decision runs inside a scope
|
|
43
|
+
# that reserves the host request_meta evaluation for the request it
|
|
44
|
+
# leads to, and drops it when the operation ends.
|
|
45
|
+
prepend MCPClient::RequestMetaScope
|
|
30
46
|
|
|
31
47
|
# Ratio of close_after timeout to ping interval
|
|
32
48
|
CLOSE_AFTER_PING_RATIO = 2.5
|
|
@@ -144,21 +160,23 @@ module MCPClient
|
|
|
144
160
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
145
161
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
|
|
146
162
|
def list_prompts
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
163
|
+
# MCP 2026-07-28 caching: a list is served only while its hint is
|
|
164
|
+
# fresh, and only from the entry that carries the hint.
|
|
165
|
+
cached = fresh_list_value(:prompts) { @mutex.synchronize { @prompts } }
|
|
166
|
+
return cached if cached
|
|
167
|
+
|
|
168
|
+
@mutex.synchronize { @prompts_data = nil }
|
|
150
169
|
|
|
151
170
|
begin
|
|
152
171
|
ensure_initialized
|
|
153
172
|
|
|
154
|
-
|
|
173
|
+
prompts = request_prompts_list.map { |prompt_data| MCPClient::Prompt.from_json(prompt_data, server: self) }
|
|
155
174
|
@mutex.synchronize do
|
|
156
|
-
@prompts =
|
|
157
|
-
MCPClient::Prompt.from_json(prompt_data, server: self)
|
|
158
|
-
end
|
|
175
|
+
@prompts = attach_list_value(:prompts, prompts) ? prompts : nil
|
|
159
176
|
end
|
|
160
177
|
|
|
161
|
-
|
|
178
|
+
# This request's own list, never a re-read of @prompts.
|
|
179
|
+
prompts
|
|
162
180
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
163
181
|
# Re-raise these errors directly
|
|
164
182
|
raise
|
|
@@ -180,6 +198,12 @@ module MCPClient
|
|
|
180
198
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
181
199
|
# Re-raise connection/transport errors directly to match test expectations
|
|
182
200
|
raise
|
|
201
|
+
rescue MCPClient::Errors::ServerError => e
|
|
202
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
203
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
204
|
+
raise if e.protocol_error?
|
|
205
|
+
|
|
206
|
+
raise MCPClient::Errors::PromptGetError, "Error get prompt '#{prompt_name}': #{e.message}"
|
|
183
207
|
rescue StandardError => e
|
|
184
208
|
# For all other errors, wrap in PromptGetError
|
|
185
209
|
raise MCPClient::Errors::PromptGetError, "Error get prompt '#{prompt_name}': #{e.message}"
|
|
@@ -192,16 +216,21 @@ module MCPClient
|
|
|
192
216
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
193
217
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource listing
|
|
194
218
|
def list_resources(cursor: nil)
|
|
195
|
-
@mutex.synchronize
|
|
196
|
-
|
|
197
|
-
end
|
|
219
|
+
cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
|
|
220
|
+
return cached if cached
|
|
198
221
|
|
|
199
222
|
begin
|
|
200
223
|
ensure_initialized
|
|
201
224
|
|
|
202
225
|
params = {}
|
|
203
226
|
params['cursor'] = cursor if cursor
|
|
204
|
-
|
|
227
|
+
epoch = cache_epoch(:resources)
|
|
228
|
+
answer = fetching_list_page(:resources, cursor) { rpc_request('resources/list', params) }
|
|
229
|
+
result = require_complete_result!(answer, 'resources/list')
|
|
230
|
+
# MCP 2026-07-28 caching: the first page's hint decides how long the
|
|
231
|
+
# cached list may be served, counted from receipt (the list is
|
|
232
|
+
# attached once converted).
|
|
233
|
+
record_cache_hint(:resources, result, epoch: epoch) unless cursor
|
|
205
234
|
|
|
206
235
|
resources = (result['resources'] || []).map do |resource_data|
|
|
207
236
|
MCPClient::Resource.from_json(resource_data, server: self)
|
|
@@ -210,7 +239,9 @@ module MCPClient
|
|
|
210
239
|
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
211
240
|
|
|
212
241
|
@mutex.synchronize do
|
|
213
|
-
|
|
242
|
+
unless cursor
|
|
243
|
+
@resources_result = attach_list_value(:resources, resources_result) ? resources_result : nil
|
|
244
|
+
end
|
|
214
245
|
end
|
|
215
246
|
|
|
216
247
|
resources_result
|
|
@@ -230,9 +261,13 @@ module MCPClient
|
|
|
230
261
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource reading
|
|
231
262
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
232
263
|
def read_resource(uri)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
264
|
+
ensure_initialized
|
|
265
|
+
read_resource_with_cache(uri) { |sent| rpc_request('resources/read', { uri: sent }) }
|
|
266
|
+
rescue MCPClient::Errors::ServerError => e
|
|
267
|
+
raise if e.protocol_error?
|
|
268
|
+
raise resource_not_found_error(uri, e) if resource_not_found_response?(e)
|
|
269
|
+
|
|
270
|
+
raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
|
|
236
271
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
237
272
|
# Re-raise connection/transport errors directly to match test expectations
|
|
238
273
|
raise
|
|
@@ -247,16 +282,35 @@ module MCPClient
|
|
|
247
282
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
248
283
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
|
|
249
284
|
def list_resource_templates(cursor: nil)
|
|
285
|
+
# Only a list the server itself bounded is served from here: a
|
|
286
|
+
# positive ttlMs means no second request, while a list with no hint
|
|
287
|
+
# (a 2025-11-25 server) is asked for again, as it was before this
|
|
288
|
+
# transport cached anything (MCP 2026-07-28 caching).
|
|
289
|
+
cached = cursor ? nil : hinted_list_value(:templates)
|
|
290
|
+
return cached if cached
|
|
291
|
+
|
|
250
292
|
ensure_initialized
|
|
251
293
|
params = {}
|
|
252
294
|
params['cursor'] = cursor if cursor
|
|
253
|
-
|
|
295
|
+
epoch = cache_epoch(:templates)
|
|
296
|
+
answer = fetching_list_page(:templates, cursor) { rpc_request('resources/templates/list', params) }
|
|
297
|
+
result = require_complete_result!(answer, 'resources/templates/list')
|
|
298
|
+
# MCP 2026-07-28 caching: the first page's hint decides freshness,
|
|
299
|
+
# counted from receipt (the list is attached once converted).
|
|
300
|
+
record_cache_hint(:templates, result, epoch: epoch) unless cursor
|
|
254
301
|
|
|
255
302
|
templates = (result['resourceTemplates'] || []).map do |template_data|
|
|
256
303
|
MCPClient::ResourceTemplate.from_json(template_data, server: self)
|
|
257
304
|
end
|
|
305
|
+
templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
258
306
|
|
|
259
|
-
|
|
307
|
+
@mutex.synchronize do
|
|
308
|
+
unless cursor
|
|
309
|
+
@templates_result = attach_list_value(:templates, templates_result) ? templates_result : nil
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
templates_result
|
|
260
314
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
261
315
|
raise
|
|
262
316
|
rescue StandardError => e
|
|
@@ -303,21 +357,23 @@ module MCPClient
|
|
|
303
357
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
304
358
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
|
|
305
359
|
def list_tools
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
360
|
+
# MCP 2026-07-28 caching: a list is served only while its hint is
|
|
361
|
+
# fresh, and only from the entry that carries the hint.
|
|
362
|
+
cached = fresh_list_value(:tools) { @mutex.synchronize { @tools } }
|
|
363
|
+
return cached if cached
|
|
364
|
+
|
|
365
|
+
@mutex.synchronize { @tools_data = nil }
|
|
309
366
|
|
|
310
367
|
begin
|
|
311
368
|
ensure_initialized
|
|
312
369
|
|
|
313
|
-
|
|
370
|
+
tools = request_tools_list.map { |tool_data| MCPClient::Tool.from_json(tool_data, server: self) }
|
|
314
371
|
@mutex.synchronize do
|
|
315
|
-
@tools =
|
|
316
|
-
MCPClient::Tool.from_json(tool_data, server: self)
|
|
317
|
-
end
|
|
372
|
+
@tools = attach_list_value(:tools, tools) ? tools : nil
|
|
318
373
|
end
|
|
319
374
|
|
|
320
|
-
|
|
375
|
+
# This request's own list, never a re-read of @tools.
|
|
376
|
+
tools
|
|
321
377
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
322
378
|
# Re-raise these errors directly
|
|
323
379
|
raise
|
|
@@ -339,6 +395,12 @@ module MCPClient
|
|
|
339
395
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
340
396
|
# Re-raise connection/transport errors directly to match test expectations
|
|
341
397
|
raise
|
|
398
|
+
rescue MCPClient::Errors::ServerError => e
|
|
399
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
400
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
401
|
+
raise if e.protocol_error?
|
|
402
|
+
|
|
403
|
+
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
342
404
|
rescue StandardError => e
|
|
343
405
|
# For all other errors, wrap in ToolCallError
|
|
344
406
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
@@ -355,11 +417,17 @@ module MCPClient
|
|
|
355
417
|
require_capability!('completions', method: 'completion/complete')
|
|
356
418
|
params = { ref: ref, argument: argument }
|
|
357
419
|
params[:context] = context if context
|
|
358
|
-
result = rpc_request('completion/complete', params)
|
|
420
|
+
result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
|
|
359
421
|
result['completion'] || { 'values' => [] }
|
|
360
422
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
361
423
|
MCPClient::Errors::CapabilityError
|
|
362
424
|
raise
|
|
425
|
+
rescue MCPClient::Errors::ServerError => e
|
|
426
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
427
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
428
|
+
raise if e.protocol_error?
|
|
429
|
+
|
|
430
|
+
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
363
431
|
rescue StandardError => e
|
|
364
432
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
365
433
|
end
|
|
@@ -369,13 +437,24 @@ module MCPClient
|
|
|
369
437
|
# 'critical', 'alert', 'emergency')
|
|
370
438
|
# @return [Hash] empty result on success
|
|
371
439
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
440
|
+
# @deprecated Logging is deprecated since MCP 2026-07-28 (SEP-2577);
|
|
441
|
+
# earliest removal is the first revision released on or after
|
|
442
|
+
# 2027-07-28. Have the server log to stderr (stdio) or use
|
|
443
|
+
# OpenTelemetry instead.
|
|
372
444
|
def log_level=(level)
|
|
445
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
373
446
|
ensure_initialized
|
|
374
447
|
require_capability!('logging', method: 'logging/setLevel')
|
|
375
448
|
rpc_request('logging/setLevel', { level: level })
|
|
376
449
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
377
450
|
MCPClient::Errors::CapabilityError
|
|
378
451
|
raise
|
|
452
|
+
rescue MCPClient::Errors::ServerError => e
|
|
453
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
454
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
455
|
+
raise if e.protocol_error?
|
|
456
|
+
|
|
457
|
+
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
379
458
|
rescue StandardError => e
|
|
380
459
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
381
460
|
end
|
|
@@ -384,7 +463,15 @@ module MCPClient
|
|
|
384
463
|
# @return [Boolean] true if connection was successful
|
|
385
464
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
386
465
|
def connect
|
|
387
|
-
|
|
466
|
+
# An already-established connection is another use of the transport,
|
|
467
|
+
# and another chance at the notice: the first connect may have found
|
|
468
|
+
# notices disabled, a logger above WARN or a logger that raised, all of
|
|
469
|
+
# which leave the slot unspent. A long-lived connection would otherwise
|
|
470
|
+
# never retry it.
|
|
471
|
+
if @mutex.synchronize { @connection_established }
|
|
472
|
+
MCPClient::Deprecations.warn(:http_sse_transport, @logger)
|
|
473
|
+
return true
|
|
474
|
+
end
|
|
388
475
|
|
|
389
476
|
# Check for pre-existing auth error (needed for tests)
|
|
390
477
|
pre_existing_auth_error = @mutex.synchronize { @auth_error }
|
|
@@ -399,6 +486,10 @@ module MCPClient
|
|
|
399
486
|
effective_timeout = [@read_timeout || 30, 30].min
|
|
400
487
|
wait_for_connection(timeout: effective_timeout)
|
|
401
488
|
start_activity_monitor
|
|
489
|
+
# The notice is about using the transport: MCPClient.connect builds
|
|
490
|
+
# (and tries) an SSE server while probing a URL that may end up on
|
|
491
|
+
# another transport, so only an established connection counts.
|
|
492
|
+
MCPClient::Deprecations.warn(:http_sse_transport, @logger)
|
|
402
493
|
true
|
|
403
494
|
rescue MCPClient::Errors::ConnectionError => e
|
|
404
495
|
cleanup
|
|
@@ -423,6 +514,12 @@ module MCPClient
|
|
|
423
514
|
# multiple connection attempts. This is essential for proper reconnection
|
|
424
515
|
# logic and exponential backoff.
|
|
425
516
|
def cleanup
|
|
517
|
+
# Everything this transport left on this thread — the notes of the
|
|
518
|
+
# entries it served and recorded, the credentials, parameters and
|
|
519
|
+
# metadata of its requests — describes a slice that will never be
|
|
520
|
+
# tagged and a request that will never be made.
|
|
521
|
+
forget_transport_thread_state
|
|
522
|
+
bump_session_epoch
|
|
426
523
|
@mutex.synchronize do
|
|
427
524
|
# Set flags first before killing threads to prevent race conditions
|
|
428
525
|
# where threads might check flags after they're set but before they're killed
|
|
@@ -493,9 +590,48 @@ module MCPClient
|
|
|
493
590
|
@sse_conn = nil
|
|
494
591
|
|
|
495
592
|
@tools = nil
|
|
593
|
+
@tools_data = nil
|
|
594
|
+
@prompts = nil
|
|
595
|
+
@prompts_data = nil
|
|
596
|
+
@resources_result = nil
|
|
597
|
+
@templates_result = nil
|
|
496
598
|
# Don't clear auth error as we need it for reporting the correct error
|
|
497
599
|
# Don't reset @consecutive_ping_failures or @reconnect_attempts as they're tracked across reconnections
|
|
498
600
|
end
|
|
601
|
+
# Cached results and their hints belong to the connection that was just
|
|
602
|
+
# torn down (MCP 2026-07-28 caching); outside @mutex, as the cache has
|
|
603
|
+
# its own lock.
|
|
604
|
+
clear_result_cache
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# The authorization context of this transport (MCP 2026-07-28 caching,
|
|
608
|
+
# cacheScope "private"): HTTP+SSE sends the static Authorization header
|
|
609
|
+
# it was configured with, so that header is both what the next request
|
|
610
|
+
# would carry and what the last one carried.
|
|
611
|
+
# @param _kind [Symbol, String, nil] the cache kind (every request carries the same header)
|
|
612
|
+
# @return [String, nil]
|
|
613
|
+
def current_authorization_context(_kind = nil)
|
|
614
|
+
# Through Faraday's case-insensitive table, so several spellings of
|
|
615
|
+
# the header in the configured hash resolve to the one value a
|
|
616
|
+
# request would actually carry.
|
|
617
|
+
authorization_fingerprint(authorization_header_value(faraday_headers(@headers)))
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Remember the Authorization a request actually carried once it was sent,
|
|
621
|
+
# for a request that recorded none of its own. What binds the result is
|
|
622
|
+
# what the request was built with: `response.env` is mutable and the
|
|
623
|
+
# response phase may rewrite it (a redaction, a redirect that strips the
|
|
624
|
+
# header), which would file an authenticated result under the anonymous
|
|
625
|
+
# context.
|
|
626
|
+
# @param response [Faraday::Response, nil]
|
|
627
|
+
# @return [void]
|
|
628
|
+
def note_sent_authorization(response)
|
|
629
|
+
return if request_authorization_recorded?
|
|
630
|
+
|
|
631
|
+
env = response.respond_to?(:env) ? response.env : nil
|
|
632
|
+
return unless env.respond_to?(:request_headers) && env.request_headers
|
|
633
|
+
|
|
634
|
+
note_request_authorization(authorization_header_value(env.request_headers))
|
|
499
635
|
end
|
|
500
636
|
|
|
501
637
|
# Register a callback for elicitation requests (MCP 2025-06-18)
|
|
@@ -506,6 +642,13 @@ module MCPClient
|
|
|
506
642
|
end
|
|
507
643
|
|
|
508
644
|
# Register a callback for roots/list requests (MCP 2025-06-18)
|
|
645
|
+
#
|
|
646
|
+
# @deprecated Roots is deprecated since MCP 2026-07-28 (SEP-2577); earliest
|
|
647
|
+
# removal is the first revision released on or after 2027-07-28. Registering
|
|
648
|
+
# a handler is not itself use of Roots — a handler that answers with no root
|
|
649
|
+
# exposes nothing deprecated — but a handler that answers with a root adopts
|
|
650
|
+
# the deprecated feature and raises the notice. Pass directories or files
|
|
651
|
+
# through tool parameters, resource URIs or server configuration instead.
|
|
509
652
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
510
653
|
# @return [void]
|
|
511
654
|
def on_roots_list_request(&block)
|
|
@@ -513,6 +656,11 @@ module MCPClient
|
|
|
513
656
|
end
|
|
514
657
|
|
|
515
658
|
# Register a callback for sampling requests (MCP 2025-11-25)
|
|
659
|
+
#
|
|
660
|
+
# @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577); earliest
|
|
661
|
+
# removal is the first revision released on or after 2027-07-28. Integrate
|
|
662
|
+
# directly with the LLM provider API instead of serving
|
|
663
|
+
# sampling/createMessage.
|
|
516
664
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
517
665
|
# @return [void]
|
|
518
666
|
def on_sampling_request(&block)
|
|
@@ -599,6 +747,10 @@ module MCPClient
|
|
|
599
747
|
|
|
600
748
|
# Call the registered callback
|
|
601
749
|
result = @roots_list_request_callback.call(request_id, params)
|
|
750
|
+
# Serving a roots/list answer that carries a root means this host
|
|
751
|
+
# declared, and is using, the deprecated Roots capability (SEP-2577) —
|
|
752
|
+
# with or without a Client. An empty answer is not use of it.
|
|
753
|
+
warn_roots_deprecated(result)
|
|
602
754
|
|
|
603
755
|
# Send the response back to the server (echoing related-task _meta)
|
|
604
756
|
send_roots_list_response(request_id, merge_related_task_meta(result, params))
|
|
@@ -631,10 +783,18 @@ module MCPClient
|
|
|
631
783
|
# If no callback is registered, return error
|
|
632
784
|
unless @sampling_request_callback
|
|
633
785
|
@logger.warn('Received sampling request but no callback registered, returning error')
|
|
634
|
-
|
|
786
|
+
# sampling.mdx § Error Handling reserves -1 for "User rejected sampling
|
|
787
|
+
# request"; a capability this client never declared is an unsupported
|
|
788
|
+
# method (-32601, Method not found), as Client#handle_sampling_request answers.
|
|
789
|
+
send_error_response(request_id, -32_601, 'Sampling not supported')
|
|
635
790
|
return
|
|
636
791
|
end
|
|
637
792
|
|
|
793
|
+
# Sampling, and the includeContext values it may carry, are deprecated
|
|
794
|
+
# (SEP-2577, SEP-2596) — with or without a Client.
|
|
795
|
+
warn_sampling_deprecated(params)
|
|
796
|
+
return if refused_undeclared_sampling_tools?(request_id, params)
|
|
797
|
+
|
|
638
798
|
# Call the registered callback
|
|
639
799
|
result = @sampling_request_callback.call(request_id, params)
|
|
640
800
|
|
|
@@ -741,6 +901,7 @@ module MCPClient
|
|
|
741
901
|
# header on all HTTP requests after the initialize handshake.
|
|
742
902
|
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
743
903
|
@headers.each { |k, v| req.headers[k] = v }
|
|
904
|
+
note_request_authorization(authorization_header_value(req.headers))
|
|
744
905
|
req.body = json_body
|
|
745
906
|
end
|
|
746
907
|
|
|
@@ -804,6 +965,7 @@ module MCPClient
|
|
|
804
965
|
def establish_sse_connection(conn, sse_path)
|
|
805
966
|
conn.get(sse_path) do |req|
|
|
806
967
|
@headers.each { |k, v| req.headers[k] = v }
|
|
968
|
+
note_request_authorization(authorization_header_value(req.headers))
|
|
807
969
|
|
|
808
970
|
req.options.on_data = proc do |chunk, _bytes|
|
|
809
971
|
process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
|
|
@@ -866,6 +1028,11 @@ module MCPClient
|
|
|
866
1028
|
# Process an SSE chunk from the server
|
|
867
1029
|
# @param chunk [String] the chunk to process
|
|
868
1030
|
def process_sse_chunk(chunk)
|
|
1031
|
+
# Every event in this chunk arrived together, before any of them was
|
|
1032
|
+
# decoded or dispatched: a response is dated from that moment, so a
|
|
1033
|
+
# slow callback on an earlier event in the same chunk cannot make it
|
|
1034
|
+
# look younger than the ttlMs its server sent (MCP 2026-07-28 caching).
|
|
1035
|
+
arrived = monotonic_now if respond_to?(:monotonic_now, true)
|
|
869
1036
|
# Size only: the chunk is raw wire data carrying sampling prompts,
|
|
870
1037
|
# elicitation content and tool results.
|
|
871
1038
|
@logger.debug("Processing SSE chunk (#{describe_body_size(chunk)})")
|
|
@@ -879,7 +1046,7 @@ module MCPClient
|
|
|
879
1046
|
event_buffers = extract_complete_events(chunk)
|
|
880
1047
|
|
|
881
1048
|
# Process extracted events outside the mutex to avoid deadlocks
|
|
882
|
-
event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
|
|
1049
|
+
event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data, arrived) }
|
|
883
1050
|
end
|
|
884
1051
|
|
|
885
1052
|
# Check if the error represents an authorization error
|
|
@@ -1015,16 +1182,32 @@ module MCPClient
|
|
|
1015
1182
|
# @return [Array<Hash>] the prompts data
|
|
1016
1183
|
# @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
|
|
1017
1184
|
# @private
|
|
1018
|
-
|
|
1185
|
+
# Drop a cached list so the next access re-fetches it (change
|
|
1186
|
+
# notification, MCP 2026-07-28 caching).
|
|
1187
|
+
# @param kind [Symbol] :tools, :prompts or :resources
|
|
1188
|
+
# @return [void]
|
|
1189
|
+
def invalidate_list_cache(kind)
|
|
1019
1190
|
@mutex.synchronize do
|
|
1020
|
-
|
|
1191
|
+
case kind
|
|
1192
|
+
when :tools
|
|
1193
|
+
@tools = nil
|
|
1194
|
+
@tools_data = nil
|
|
1195
|
+
when :prompts
|
|
1196
|
+
@prompts = nil
|
|
1197
|
+
@prompts_data = nil
|
|
1198
|
+
when :resources
|
|
1199
|
+
@resources_result = nil
|
|
1200
|
+
when :templates
|
|
1201
|
+
# resources/list_changed covers resources/templates/list too.
|
|
1202
|
+
@templates_result = nil
|
|
1203
|
+
end
|
|
1021
1204
|
end
|
|
1205
|
+
end
|
|
1022
1206
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
@mutex.synchronize { @prompts_data.dup }
|
|
1207
|
+
def request_prompts_list
|
|
1208
|
+
# Follow nextCursor across pages so the full prompt list is returned;
|
|
1209
|
+
# the raw pages are this fetch's own (see request_tools_list).
|
|
1210
|
+
request_paginated_list('prompts/list', 'prompts')
|
|
1028
1211
|
end
|
|
1029
1212
|
|
|
1030
1213
|
# Request the resources list using JSON-RPC
|
|
@@ -1032,23 +1215,11 @@ module MCPClient
|
|
|
1032
1215
|
# @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
|
|
1033
1216
|
# @private
|
|
1034
1217
|
def request_resources_list
|
|
1035
|
-
|
|
1036
|
-
return @resources_data if @resources_data
|
|
1037
|
-
end
|
|
1038
|
-
|
|
1218
|
+
# The raw list is this fetch's own (see request_tools_list).
|
|
1039
1219
|
result = rpc_request('resources/list')
|
|
1040
1220
|
|
|
1041
|
-
if result && result['resources']
|
|
1042
|
-
|
|
1043
|
-
@resources_data = result['resources']
|
|
1044
|
-
end
|
|
1045
|
-
return @mutex.synchronize { @resources_data.dup }
|
|
1046
|
-
elsif result
|
|
1047
|
-
@mutex.synchronize do
|
|
1048
|
-
@resources_data = result
|
|
1049
|
-
end
|
|
1050
|
-
return @mutex.synchronize { @resources_data.dup }
|
|
1051
|
-
end
|
|
1221
|
+
return result['resources'].dup if result && result['resources']
|
|
1222
|
+
return result.dup if result
|
|
1052
1223
|
|
|
1053
1224
|
raise MCPClient::Errors::ResourceReadError, 'Failed to get resources list from JSON-RPC request'
|
|
1054
1225
|
end
|
|
@@ -1058,17 +1229,11 @@ module MCPClient
|
|
|
1058
1229
|
# @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
|
|
1059
1230
|
# @private
|
|
1060
1231
|
def request_tools_list
|
|
1061
|
-
@mutex.synchronize do
|
|
1062
|
-
return @tools_data.dup if @tools_data
|
|
1063
|
-
end
|
|
1064
|
-
|
|
1065
1232
|
# Follow nextCursor across pages so the full tool list is returned. The
|
|
1066
|
-
#
|
|
1067
|
-
#
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
@mutex.synchronize { @tools_data = tools }
|
|
1071
|
-
@mutex.synchronize { @tools_data.dup }
|
|
1233
|
+
# raw pages are this fetch's own: a shared copy could answer a
|
|
1234
|
+
# concurrent caller under other credentials with a privately scoped
|
|
1235
|
+
# list (MCP 2026-07-28 caching).
|
|
1236
|
+
request_paginated_list('tools/list', 'tools')
|
|
1072
1237
|
end
|
|
1073
1238
|
end
|
|
1074
1239
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
class ServerStdio
|
|
5
|
+
# The record of one server child process: when the open subscriptions were
|
|
6
|
+
# handed to it, and when its handles were torn down. One record per
|
|
7
|
+
# process, written only by that process's own lifecycle, so nothing
|
|
8
|
+
# another thread does to another process can change what this one says.
|
|
9
|
+
#
|
|
10
|
+
# It exists for the crash-loop bound. That bound used to live in flags and
|
|
11
|
+
# a timestamp on the transport, which two restarts (or a restart and a host
|
|
12
|
+
# request that re-established the process first) raced over: whichever
|
|
13
|
+
# finished last decided what the *other* process's uptime had been, and a
|
|
14
|
+
# server that exited on sight could be respawned for ever. The question a
|
|
15
|
+
# restart actually has to answer is about one particular process — "did the
|
|
16
|
+
# last process we gave these subscriptions to die straight after we gave
|
|
17
|
+
# them to it?" — and both facts it needs are recorded here, on that
|
|
18
|
+
# process, at the two moments they happen.
|
|
19
|
+
class ChildSession
|
|
20
|
+
# @return [Float, nil] monotonic time the open subscriptions were re-sent
|
|
21
|
+
# to this process, nil if it was never asked to carry any
|
|
22
|
+
attr_reader :carried_at
|
|
23
|
+
# @return [Float, nil] monotonic time this process's handles were torn
|
|
24
|
+
# down, nil while it is still the live session
|
|
25
|
+
attr_reader :ended_at
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@carried_at = nil
|
|
29
|
+
@ended_at = nil
|
|
30
|
+
@exited_unexpectedly = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] whether this process went on its own rather than
|
|
34
|
+
# being torn down by the client
|
|
35
|
+
def exited_unexpectedly?
|
|
36
|
+
@exited_unexpectedly
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Float] monotonic seconds
|
|
40
|
+
def self.now
|
|
41
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# This process has been handed the subscriptions a previous process left
|
|
45
|
+
# open. Stamped before they go out, not after: the process can exit while
|
|
46
|
+
# they are still being written, and an exit under the re-send is the
|
|
47
|
+
# clearest crash loop there is.
|
|
48
|
+
# @return [void]
|
|
49
|
+
def carrying_subscriptions
|
|
50
|
+
@carried_at = ChildSession.now
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# This process is gone (its stdio handles have been torn down). First
|
|
54
|
+
# stamp wins: a host `cleanup` racing the reader's own teardown must not
|
|
55
|
+
# move the moment the process ended.
|
|
56
|
+
# @return [void]
|
|
57
|
+
def ended
|
|
58
|
+
return if @ended_at
|
|
59
|
+
|
|
60
|
+
@ended_at = ChildSession.now
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# This process exited on its own — the reader saw EOF on a stdin the
|
|
64
|
+
# client had not closed (see
|
|
65
|
+
# {MCPClient::ServerStdio#handle_server_exit}). Recorded separately from
|
|
66
|
+
# {#ended}, which every teardown stamps, because only an exit is a crash.
|
|
67
|
+
# @return [void]
|
|
68
|
+
def exited_unexpectedly
|
|
69
|
+
@exited_unexpectedly = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether this process died too soon after being given the subscriptions
|
|
73
|
+
# to be given them again — the crash loop the restart bound exists to
|
|
74
|
+
# stop. A process that was never given any is not part of that loop, and
|
|
75
|
+
# one that is still alive has not ended anything.
|
|
76
|
+
#
|
|
77
|
+
# Neither is a process the client itself shut down. A host that closes
|
|
78
|
+
# the transport and reconnects — as a `cleanup`/request cycle does, and
|
|
79
|
+
# as re-authenticating or re-configuring a server does — tears the
|
|
80
|
+
# process down whenever it likes, and reading that as the server crashing
|
|
81
|
+
# closed the very subscriptions the reconnect exists to carry across.
|
|
82
|
+
# Only an exit the reader actually watched happen counts.
|
|
83
|
+
#
|
|
84
|
+
# The interval is measured from the moment it received them, so a server
|
|
85
|
+
# that is slow to start is credited with none of its own handshake, and a
|
|
86
|
+
# process that was already gone when they were sent to it (its exit
|
|
87
|
+
# stamped before the re-send) counts as having survived no time at all.
|
|
88
|
+
# @param min_uptime [Numeric] seconds it had to last (see
|
|
89
|
+
# {MCPClient::ServerStdio::SUBSCRIPTION_RESTART_MIN_INTERVAL})
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def died_carrying_subscriptions?(min_uptime)
|
|
92
|
+
return false unless @exited_unexpectedly && @carried_at && @ended_at
|
|
93
|
+
|
|
94
|
+
(@ended_at - @carried_at) < min_uptime
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|