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'
|
|
@@ -22,6 +23,10 @@ module MCPClient
|
|
|
22
23
|
require_relative 'server_streamable_http/json_rpc_transport'
|
|
23
24
|
|
|
24
25
|
include JsonRpcTransport
|
|
26
|
+
# Every operation that may weigh a cache decision runs inside a scope
|
|
27
|
+
# that reserves the host request_meta evaluation for the request it
|
|
28
|
+
# leads to, and drops it when the operation ends.
|
|
29
|
+
prepend MCPClient::RequestMetaScope
|
|
25
30
|
|
|
26
31
|
# Default values for connection settings
|
|
27
32
|
DEFAULT_READ_TIMEOUT = 30
|
|
@@ -143,6 +148,7 @@ module MCPClient
|
|
|
143
148
|
})
|
|
144
149
|
|
|
145
150
|
@read_timeout = opts[:read_timeout]
|
|
151
|
+
configure_protocol_mode(opts[:protocol], opts[:discover_timeout])
|
|
146
152
|
@faraday_config = opts[:faraday_config]
|
|
147
153
|
@max_decompressed_body_bytes = validate_decompression_limit(opts[:max_decompressed_body_bytes])
|
|
148
154
|
@tools = nil
|
|
@@ -181,35 +187,39 @@ module MCPClient
|
|
|
181
187
|
# @return [Boolean] true if connection was successful
|
|
182
188
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
183
189
|
def connect
|
|
184
|
-
|
|
190
|
+
# Serialized: concurrent first requests must not each run the probe
|
|
191
|
+
# and possibly settle on different eras (the monitor is reentrant, so
|
|
192
|
+
# the request plumbing inside may take @mutex again).
|
|
193
|
+
@mutex.synchronize do
|
|
194
|
+
return true if @connection_established
|
|
185
195
|
|
|
186
|
-
|
|
187
|
-
@mutex.synchronize do
|
|
196
|
+
begin
|
|
188
197
|
@connection_established = false
|
|
189
198
|
@initialized = false
|
|
190
|
-
end
|
|
191
199
|
|
|
192
|
-
|
|
193
|
-
|
|
200
|
+
# Test connectivity with a simple HTTP request
|
|
201
|
+
test_connection
|
|
194
202
|
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
# Establish the protocol era: server/discover for a modern server,
|
|
204
|
+
# the initialize handshake for a legacy one.
|
|
205
|
+
negotiate_protocol
|
|
197
206
|
|
|
198
|
-
|
|
199
|
-
|
|
207
|
+
# Long-lived GET stream for server events: legacy only. MCP
|
|
208
|
+
# 2026-07-28 removed the GET endpoint; change notifications arrive
|
|
209
|
+
# on subscriptions/listen streams instead.
|
|
210
|
+
start_events_connection unless modern?
|
|
200
211
|
|
|
201
|
-
@mutex.synchronize do
|
|
202
212
|
@connection_established = true
|
|
203
213
|
@initialized = true
|
|
204
|
-
end
|
|
205
214
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
215
|
+
true
|
|
216
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
217
|
+
cleanup
|
|
218
|
+
raise e
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
cleanup
|
|
221
|
+
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
|
222
|
+
end
|
|
213
223
|
end
|
|
214
224
|
end
|
|
215
225
|
|
|
@@ -219,21 +229,18 @@ module MCPClient
|
|
|
219
229
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
220
230
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
|
|
221
231
|
def list_tools
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
232
|
+
# MCP 2026-07-28 caching: a cached list is served only while fresh, and
|
|
233
|
+
# only from the entry that carries its hint.
|
|
234
|
+
cached = fresh_list_value(:tools) { @mutex.synchronize { @tools } }
|
|
235
|
+
return cached if cached
|
|
236
|
+
|
|
237
|
+
# Stale: the raw page cache must go too, or the re-fetch would be
|
|
238
|
+
# answered from memory.
|
|
239
|
+
@mutex.synchronize { @tools_data = nil }
|
|
226
240
|
begin
|
|
227
241
|
ensure_connected
|
|
228
242
|
|
|
229
|
-
|
|
230
|
-
@mutex.synchronize do
|
|
231
|
-
@tools = tools_data.map do |tool_data|
|
|
232
|
-
MCPClient::Tool.from_json(tool_data, server: self)
|
|
233
|
-
end
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
@mutex.synchronize { @tools }
|
|
243
|
+
refetch_or_serve_stale(:tools, stale_list_entry(:tools)) { fetch_tools_list }
|
|
237
244
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
238
245
|
# Re-raise these errors directly
|
|
239
246
|
raise
|
|
@@ -252,9 +259,15 @@ module MCPClient
|
|
|
252
259
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
253
260
|
def call_tool(tool_name, parameters)
|
|
254
261
|
rpc_request('tools/call', build_named_request_params(tool_name, parameters))
|
|
255
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
262
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ValidationError
|
|
256
263
|
# Re-raise connection/transport errors directly to match test expectations
|
|
257
264
|
raise
|
|
265
|
+
rescue MCPClient::Errors::ServerError => e
|
|
266
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
267
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
268
|
+
raise if e.protocol_error?
|
|
269
|
+
|
|
270
|
+
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
258
271
|
rescue StandardError => e
|
|
259
272
|
# For all other errors, wrap in ToolCallError
|
|
260
273
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
@@ -281,11 +294,17 @@ module MCPClient
|
|
|
281
294
|
require_capability!('completions', method: 'completion/complete')
|
|
282
295
|
params = { ref: ref, argument: argument }
|
|
283
296
|
params[:context] = context if context
|
|
284
|
-
result = rpc_request('completion/complete', params)
|
|
297
|
+
result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
|
|
285
298
|
result['completion'] || { 'values' => [] }
|
|
286
299
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
287
300
|
MCPClient::Errors::CapabilityError
|
|
288
301
|
raise
|
|
302
|
+
rescue MCPClient::Errors::ServerError => e
|
|
303
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
304
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
305
|
+
raise if e.protocol_error?
|
|
306
|
+
|
|
307
|
+
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
289
308
|
rescue StandardError => e
|
|
290
309
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
291
310
|
end
|
|
@@ -295,13 +314,31 @@ module MCPClient
|
|
|
295
314
|
# 'critical', 'alert', 'emergency')
|
|
296
315
|
# @return [Hash] empty result on success
|
|
297
316
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
317
|
+
# @deprecated Logging is deprecated since MCP 2026-07-28 (SEP-2577);
|
|
318
|
+
# earliest removal is the first revision released on or after
|
|
319
|
+
# 2027-07-28. Have the server log to stderr (stdio) or use
|
|
320
|
+
# OpenTelemetry instead.
|
|
298
321
|
def log_level=(level)
|
|
322
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
299
323
|
ensure_connected
|
|
324
|
+
# MCP 2026-07-28 removed logging/setLevel: the level travels per request
|
|
325
|
+
# in _meta["io.modelcontextprotocol/logLevel"].
|
|
326
|
+
if modern?
|
|
327
|
+
@log_level = validate_log_level!(level)
|
|
328
|
+
return
|
|
329
|
+
end
|
|
330
|
+
|
|
300
331
|
require_capability!('logging', method: 'logging/setLevel')
|
|
301
332
|
rpc_request('logging/setLevel', { level: level })
|
|
302
333
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
303
|
-
MCPClient::Errors::CapabilityError
|
|
334
|
+
MCPClient::Errors::CapabilityError, ArgumentError
|
|
304
335
|
raise
|
|
336
|
+
rescue MCPClient::Errors::ServerError => e
|
|
337
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
338
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
339
|
+
raise if e.protocol_error?
|
|
340
|
+
|
|
341
|
+
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
305
342
|
rescue StandardError => e
|
|
306
343
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
307
344
|
end
|
|
@@ -310,29 +347,40 @@ module MCPClient
|
|
|
310
347
|
# @return [Array<MCPClient::Prompt>] list of available prompts
|
|
311
348
|
# @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
|
|
312
349
|
def list_prompts
|
|
313
|
-
@mutex.synchronize
|
|
314
|
-
|
|
315
|
-
end
|
|
350
|
+
cached = fresh_list_value(:prompts) { @mutex.synchronize { @prompts } }
|
|
351
|
+
return cached if cached
|
|
316
352
|
|
|
353
|
+
@mutex.synchronize { @prompts_data = nil }
|
|
317
354
|
begin
|
|
318
355
|
ensure_connected
|
|
319
|
-
|
|
320
|
-
prompts_data = request_prompts_list
|
|
321
|
-
@mutex.synchronize do
|
|
322
|
-
@prompts = prompts_data.map do |prompt_data|
|
|
323
|
-
MCPClient::Prompt.from_json(prompt_data, server: self)
|
|
324
|
-
end
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
@mutex.synchronize { @prompts }
|
|
356
|
+
refetch_or_serve_stale(:prompts, stale_list_entry(:prompts)) { fetch_prompts_list }
|
|
328
357
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
329
|
-
# Re-raise these errors directly
|
|
330
358
|
raise
|
|
331
359
|
rescue StandardError => e
|
|
332
360
|
raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
|
|
333
361
|
end
|
|
334
362
|
end
|
|
335
363
|
|
|
364
|
+
# Fetch and cache the prompt list.
|
|
365
|
+
# @return [Array<MCPClient::Prompt>]
|
|
366
|
+
def fetch_prompts_list
|
|
367
|
+
ensure_connected
|
|
368
|
+
|
|
369
|
+
prompts = request_prompts_list.map { |prompt_data| MCPClient::Prompt.from_json(prompt_data, server: self) }
|
|
370
|
+
@mutex.synchronize do
|
|
371
|
+
@prompts = attach_list_value(:prompts, prompts) ? prompts : nil
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# This request's own list, never a re-read of @prompts (another
|
|
375
|
+
# request may have stored its list in between).
|
|
376
|
+
prompts
|
|
377
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
378
|
+
# Re-raise these errors directly
|
|
379
|
+
raise
|
|
380
|
+
rescue StandardError => e
|
|
381
|
+
raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
|
|
382
|
+
end
|
|
383
|
+
|
|
336
384
|
# Get a prompt with the given parameters
|
|
337
385
|
# @param prompt_name [String] the name of the prompt to get
|
|
338
386
|
# @param parameters [Hash] the parameters to pass to the prompt
|
|
@@ -343,6 +391,12 @@ module MCPClient
|
|
|
343
391
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
344
392
|
# Re-raise connection/transport errors directly
|
|
345
393
|
raise
|
|
394
|
+
rescue MCPClient::Errors::ServerError => e
|
|
395
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
396
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
397
|
+
raise if e.protocol_error?
|
|
398
|
+
|
|
399
|
+
raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
|
|
346
400
|
rescue StandardError => e
|
|
347
401
|
# For all other errors, wrap in PromptGetError
|
|
348
402
|
raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
|
|
@@ -353,44 +407,69 @@ module MCPClient
|
|
|
353
407
|
# @return [Hash] result containing resources array and optional nextCursor
|
|
354
408
|
# @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
|
|
355
409
|
def list_resources(cursor: nil)
|
|
356
|
-
@mutex.synchronize
|
|
357
|
-
|
|
358
|
-
end
|
|
410
|
+
cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
|
|
411
|
+
return cached if cached
|
|
359
412
|
|
|
360
413
|
begin
|
|
361
414
|
ensure_connected
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
resources = (result['resources'] || []).map do |resource_data|
|
|
368
|
-
MCPClient::Resource.from_json(resource_data, server: self)
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
372
|
-
|
|
373
|
-
@mutex.synchronize do
|
|
374
|
-
@resources_result = resources_result unless cursor
|
|
415
|
+
unless cursor
|
|
416
|
+
return refetch_or_serve_stale(:resources, stale_list_entry(:resources)) do
|
|
417
|
+
fetch_resources_list(nil)
|
|
418
|
+
end
|
|
375
419
|
end
|
|
376
420
|
|
|
377
|
-
|
|
421
|
+
fetch_resources_list(cursor)
|
|
378
422
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
379
|
-
# Re-raise these errors directly
|
|
380
423
|
raise
|
|
381
424
|
rescue StandardError => e
|
|
382
425
|
raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
|
|
383
426
|
end
|
|
384
427
|
end
|
|
385
428
|
|
|
429
|
+
# Fetch one page of resources/list, caching the first page.
|
|
430
|
+
# @param cursor [String, nil]
|
|
431
|
+
# @return [Hash]
|
|
432
|
+
def fetch_resources_list(cursor)
|
|
433
|
+
params = {}
|
|
434
|
+
params['cursor'] = cursor if cursor
|
|
435
|
+
epoch = cache_epoch(:resources)
|
|
436
|
+
answer = fetching_list_page(:resources, cursor) { rpc_request('resources/list', params) }
|
|
437
|
+
result = require_complete_result!(answer, 'resources/list')
|
|
438
|
+
record_cache_hint(:resources, result, epoch: epoch) unless cursor
|
|
439
|
+
|
|
440
|
+
resources = (result['resources'] || []).map do |resource_data|
|
|
441
|
+
MCPClient::Resource.from_json(resource_data, server: self)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
445
|
+
|
|
446
|
+
# A list invalidated while in flight is returned but not cached.
|
|
447
|
+
@mutex.synchronize do
|
|
448
|
+
unless cursor
|
|
449
|
+
@resources_result = attach_list_value(:resources, resources_result) ? resources_result : nil
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
resources_result
|
|
454
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
455
|
+
# Re-raise these errors directly
|
|
456
|
+
raise
|
|
457
|
+
rescue StandardError => e
|
|
458
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
|
|
459
|
+
end
|
|
460
|
+
|
|
386
461
|
# Read a resource by its URI
|
|
387
462
|
# @param uri [String] the URI of the resource to read
|
|
388
463
|
# @return [Array<MCPClient::ResourceContent>] array of resource contents
|
|
389
464
|
# @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
|
|
390
465
|
def read_resource(uri)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
466
|
+
ensure_connected
|
|
467
|
+
read_resource_with_cache(uri) { |sent| rpc_request('resources/read', { uri: sent }) }
|
|
468
|
+
rescue MCPClient::Errors::ServerError => e
|
|
469
|
+
raise if e.protocol_error?
|
|
470
|
+
raise resource_not_found_error(uri, e) if resource_not_found_response?(e)
|
|
471
|
+
|
|
472
|
+
raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
|
|
394
473
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
395
474
|
# Re-raise connection/transport errors directly
|
|
396
475
|
raise
|
|
@@ -404,19 +483,52 @@ module MCPClient
|
|
|
404
483
|
# @return [Hash] result containing resourceTemplates array and optional nextCursor
|
|
405
484
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
|
|
406
485
|
def list_resource_templates(cursor: nil)
|
|
486
|
+
# Only a list the server itself bounded is served from here: a
|
|
487
|
+
# positive ttlMs means no second request, while a list with no hint
|
|
488
|
+
# (a 2025-11-25 server) is asked for again, as it was before this
|
|
489
|
+
# transport cached anything (MCP 2026-07-28 caching).
|
|
490
|
+
cached = cursor ? nil : hinted_list_value(:templates)
|
|
491
|
+
return cached if cached
|
|
492
|
+
|
|
493
|
+
begin
|
|
494
|
+
ensure_connected
|
|
495
|
+
unless cursor
|
|
496
|
+
return refetch_or_serve_stale(:templates, stale_list_entry(:templates)) do
|
|
497
|
+
fetch_templates_list(nil)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
fetch_templates_list(cursor)
|
|
502
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
503
|
+
raise
|
|
504
|
+
rescue StandardError => e
|
|
505
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Fetch one page of resources/templates/list, caching the first page.
|
|
510
|
+
# @param cursor [String, nil]
|
|
511
|
+
# @return [Hash]
|
|
512
|
+
def fetch_templates_list(cursor)
|
|
407
513
|
params = {}
|
|
408
514
|
params['cursor'] = cursor if cursor
|
|
409
|
-
|
|
515
|
+
epoch = cache_epoch(:templates)
|
|
516
|
+
answer = fetching_list_page(:templates, cursor) { rpc_request('resources/templates/list', params) }
|
|
517
|
+
result = require_complete_result!(answer, 'resources/templates/list')
|
|
518
|
+
record_cache_hint(:templates, result, epoch: epoch) unless cursor
|
|
410
519
|
|
|
411
520
|
templates = (result['resourceTemplates'] || []).map do |template_data|
|
|
412
521
|
MCPClient::ResourceTemplate.from_json(template_data, server: self)
|
|
413
522
|
end
|
|
523
|
+
templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
414
524
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
525
|
+
@mutex.synchronize do
|
|
526
|
+
unless cursor
|
|
527
|
+
@templates_result = attach_list_value(:templates, templates_result) ? templates_result : nil
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
templates_result
|
|
420
532
|
end
|
|
421
533
|
|
|
422
534
|
# Subscribe to resource updates
|
|
@@ -426,6 +538,13 @@ module MCPClient
|
|
|
426
538
|
def subscribe_resource(uri)
|
|
427
539
|
ensure_connected
|
|
428
540
|
require_capability!('resources', 'subscribe', method: 'resources/subscribe')
|
|
541
|
+
# MCP 2026-07-28 replaced resources/subscribe with a subscriptions/listen
|
|
542
|
+
# stream carrying resourceSubscriptions.
|
|
543
|
+
if modern?
|
|
544
|
+
subscribe_resource_via_listen(uri)
|
|
545
|
+
return true
|
|
546
|
+
end
|
|
547
|
+
|
|
429
548
|
rpc_request('resources/subscribe', { uri: uri })
|
|
430
549
|
true
|
|
431
550
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
@@ -442,6 +561,11 @@ module MCPClient
|
|
|
442
561
|
def unsubscribe_resource(uri)
|
|
443
562
|
ensure_connected
|
|
444
563
|
require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
|
|
564
|
+
if modern?
|
|
565
|
+
unsubscribe_resource_via_listen(uri)
|
|
566
|
+
return true
|
|
567
|
+
end
|
|
568
|
+
|
|
445
569
|
rpc_request('resources/unsubscribe', { uri: uri })
|
|
446
570
|
true
|
|
447
571
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
@@ -455,6 +579,10 @@ module MCPClient
|
|
|
455
579
|
def apply_request_headers(req, request)
|
|
456
580
|
super
|
|
457
581
|
|
|
582
|
+
# Modern servers have no session; the base class added the 2026-07-28
|
|
583
|
+
# request metadata headers.
|
|
584
|
+
return if modern?
|
|
585
|
+
|
|
458
586
|
# Add session and protocol version headers for non-initialize requests
|
|
459
587
|
return unless request['method'] != 'initialize'
|
|
460
588
|
|
|
@@ -482,7 +610,7 @@ module MCPClient
|
|
|
482
610
|
session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
|
|
483
611
|
if session_id
|
|
484
612
|
if valid_session_id?(session_id)
|
|
485
|
-
|
|
613
|
+
capture_session_id(session_id)
|
|
486
614
|
@logger.debug("Captured session ID: #{@session_id}")
|
|
487
615
|
else
|
|
488
616
|
@logger.warn("Invalid session ID format received: #{session_id.inspect}")
|
|
@@ -505,6 +633,10 @@ module MCPClient
|
|
|
505
633
|
# Clean up the server connection
|
|
506
634
|
# Properly closes HTTP connections, stops threads, and clears cached state
|
|
507
635
|
def cleanup
|
|
636
|
+
# Only an established session ends a task's namespace; a sessionless
|
|
637
|
+
# (MCP 2026-07-28) connection is merely closed, and a connection that
|
|
638
|
+
# never came up has nothing to end — see #ending_session?.
|
|
639
|
+
bump_session_epoch if ending_session?
|
|
508
640
|
@mutex.synchronize do
|
|
509
641
|
return unless @connection_established || @initialized
|
|
510
642
|
|
|
@@ -514,6 +646,9 @@ module MCPClient
|
|
|
514
646
|
@connection_established = false
|
|
515
647
|
@initialized = false
|
|
516
648
|
|
|
649
|
+
# Subscription streams (MCP 2026-07-28) end with the connection
|
|
650
|
+
close_listen_streams
|
|
651
|
+
|
|
517
652
|
# Attempt to terminate session before cleanup
|
|
518
653
|
begin
|
|
519
654
|
terminate_session if @session_id
|
|
@@ -545,11 +680,27 @@ module MCPClient
|
|
|
545
680
|
@prompts_data = nil
|
|
546
681
|
@resources = nil
|
|
547
682
|
@resources_data = nil
|
|
683
|
+
@resources_result = nil
|
|
684
|
+
@templates_result = nil
|
|
548
685
|
@buffer = +''
|
|
549
686
|
@buffer_scanned = 0
|
|
550
687
|
|
|
551
688
|
@logger.info('Cleanup completed')
|
|
552
689
|
end
|
|
690
|
+
# Cached results and their hints belong to the connection (and its
|
|
691
|
+
# authorization context) that was just torn down; outside @mutex, as
|
|
692
|
+
# the cache has its own lock.
|
|
693
|
+
clear_result_cache
|
|
694
|
+
ensure
|
|
695
|
+
# Everything this transport left on this thread — the notes of the
|
|
696
|
+
# entries it served and recorded, the credentials, parameters and
|
|
697
|
+
# metadata of its requests — describes a slice that will never be
|
|
698
|
+
# tagged and a request that will never be made. Dropped after the
|
|
699
|
+
# session was terminated, never before: the DELETE that terminates it
|
|
700
|
+
# is a request of this transport's own, and the recorder on its
|
|
701
|
+
# connection would put its Authorization fingerprint straight back on
|
|
702
|
+
# this thread.
|
|
703
|
+
forget_transport_thread_state
|
|
553
704
|
end
|
|
554
705
|
|
|
555
706
|
# Register a callback for elicitation requests (MCP 2025-06-18)
|
|
@@ -560,6 +711,13 @@ module MCPClient
|
|
|
560
711
|
end
|
|
561
712
|
|
|
562
713
|
# Register a callback for roots/list requests (MCP 2025-06-18)
|
|
714
|
+
#
|
|
715
|
+
# @deprecated Roots is deprecated since MCP 2026-07-28 (SEP-2577); earliest
|
|
716
|
+
# removal is the first revision released on or after 2027-07-28. Registering
|
|
717
|
+
# a handler is not itself use of Roots — a handler that answers with no root
|
|
718
|
+
# exposes nothing deprecated — but a handler that answers with a root adopts
|
|
719
|
+
# the deprecated feature and raises the notice. Pass directories or files
|
|
720
|
+
# through tool parameters, resource URIs or server configuration instead.
|
|
563
721
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
564
722
|
# @return [void]
|
|
565
723
|
def on_roots_list_request(&block)
|
|
@@ -567,6 +725,11 @@ module MCPClient
|
|
|
567
725
|
end
|
|
568
726
|
|
|
569
727
|
# Register a callback for sampling requests (MCP 2025-11-25)
|
|
728
|
+
#
|
|
729
|
+
# @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577); earliest
|
|
730
|
+
# removal is the first revision released on or after 2027-07-28. Integrate
|
|
731
|
+
# directly with the LLM provider API instead of serving
|
|
732
|
+
# sampling/createMessage.
|
|
570
733
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
571
734
|
# @return [void]
|
|
572
735
|
def on_sampling_request(&block)
|
|
@@ -610,7 +773,9 @@ module MCPClient
|
|
|
610
773
|
logger: nil,
|
|
611
774
|
oauth_provider: nil,
|
|
612
775
|
faraday_config: nil,
|
|
613
|
-
max_decompressed_body_bytes: JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES
|
|
776
|
+
max_decompressed_body_bytes: JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES,
|
|
777
|
+
protocol: :auto,
|
|
778
|
+
discover_timeout: nil
|
|
614
779
|
}
|
|
615
780
|
end
|
|
616
781
|
|
|
@@ -635,65 +800,50 @@ module MCPClient
|
|
|
635
800
|
# @return [void]
|
|
636
801
|
# @raise [MCPClient::Errors::ConnectionError] if connection is not established
|
|
637
802
|
def ensure_connected
|
|
638
|
-
|
|
803
|
+
# Serialized on the transport monitor (reentrant, so the nested
|
|
804
|
+
# cleanup/connect may take it again): checking the flags and acting on
|
|
805
|
+
# them must be one step. Otherwise a caller that observed "disconnected"
|
|
806
|
+
# can be overtaken by one that reconnects, and then tear that fresh
|
|
807
|
+
# connection down — terminating its session and re-running the era
|
|
808
|
+
# probe.
|
|
809
|
+
@mutex.synchronize do
|
|
810
|
+
return if @connection_established && @initialized
|
|
639
811
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
812
|
+
@logger.debug('Connection not active, attempting to reconnect before request')
|
|
813
|
+
cleanup
|
|
814
|
+
connect
|
|
815
|
+
end
|
|
643
816
|
end
|
|
644
817
|
|
|
645
818
|
# Request the tools list using JSON-RPC
|
|
646
819
|
# @return [Array<Hash>] the tools data
|
|
647
820
|
# @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
|
|
648
821
|
def request_tools_list
|
|
649
|
-
@mutex.synchronize do
|
|
650
|
-
return @tools_data.dup if @tools_data
|
|
651
|
-
end
|
|
652
|
-
|
|
653
822
|
# Follow nextCursor across pages so the full tool list is returned even
|
|
654
|
-
# when the server paginates.
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
@mutex.synchronize { @tools_data.dup }
|
|
823
|
+
# when the server paginates. The raw pages are this fetch's own: a
|
|
824
|
+
# shared copy could answer a concurrent caller under other
|
|
825
|
+
# credentials with a privately scoped list (MCP 2026-07-28 caching).
|
|
826
|
+
request_paginated_list('tools/list', 'tools')
|
|
659
827
|
end
|
|
660
828
|
|
|
661
829
|
# Request the prompts list using JSON-RPC
|
|
662
830
|
# @return [Array<Hash>] the prompts data
|
|
663
831
|
# @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
|
|
664
832
|
def request_prompts_list
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
# Follow nextCursor across pages so the full prompt list is returned.
|
|
670
|
-
prompts = request_paginated_list('prompts/list', 'prompts')
|
|
671
|
-
|
|
672
|
-
@mutex.synchronize { @prompts_data = prompts }
|
|
673
|
-
@mutex.synchronize { @prompts_data.dup }
|
|
833
|
+
# Follow nextCursor across pages so the full prompt list is returned;
|
|
834
|
+
# the raw pages are this fetch's own (see request_tools_list).
|
|
835
|
+
request_paginated_list('prompts/list', 'prompts')
|
|
674
836
|
end
|
|
675
837
|
|
|
676
838
|
# Request the resources list using JSON-RPC
|
|
677
839
|
# @return [Array<Hash>] the resources data
|
|
678
840
|
# @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
|
|
679
841
|
def request_resources_list
|
|
680
|
-
|
|
681
|
-
return @resources_data if @resources_data
|
|
682
|
-
end
|
|
683
|
-
|
|
842
|
+
# The raw list is this fetch's own (see request_tools_list).
|
|
684
843
|
result = rpc_request('resources/list')
|
|
685
844
|
|
|
686
|
-
if result.is_a?(Hash) && result['resources']
|
|
687
|
-
|
|
688
|
-
@resources_data = result['resources']
|
|
689
|
-
end
|
|
690
|
-
return @mutex.synchronize { @resources_data.dup }
|
|
691
|
-
elsif result.is_a?(Array) || result
|
|
692
|
-
@mutex.synchronize do
|
|
693
|
-
@resources_data = result
|
|
694
|
-
end
|
|
695
|
-
return @mutex.synchronize { @resources_data.dup }
|
|
696
|
-
end
|
|
845
|
+
return result['resources'].dup if result.is_a?(Hash) && result['resources']
|
|
846
|
+
return result.dup if result.is_a?(Array) || result
|
|
697
847
|
|
|
698
848
|
raise MCPClient::Errors::ResourceReadError, 'Failed to get resources list from JSON-RPC request'
|
|
699
849
|
end
|
|
@@ -962,6 +1112,7 @@ module MCPClient
|
|
|
962
1112
|
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
963
1113
|
# MCP: authorization MUST be included in every HTTP request
|
|
964
1114
|
@oauth_provider&.apply_authorization(req)
|
|
1115
|
+
note_request_authorization(req.headers['Authorization'])
|
|
965
1116
|
# SEP-1699: resumption is via GET with the Last-Event-ID cursor, so the
|
|
966
1117
|
# server can replay messages missed since the last received event.
|
|
967
1118
|
last_event_id = @mutex.synchronize { @last_event_id }
|
|
@@ -1110,6 +1261,29 @@ module MCPClient
|
|
|
1110
1261
|
# interleaved on a POST SSE response stream.
|
|
1111
1262
|
# @param message [Hash] the parsed JSON-RPC message
|
|
1112
1263
|
def dispatch_server_message(message)
|
|
1264
|
+
# Host code reached from here -- a notification listener, a handler for
|
|
1265
|
+
# a server-initiated request -- may issue a request of its own while the
|
|
1266
|
+
# response that carried this message is still being parsed. That request
|
|
1267
|
+
# is an exchange of its own, and the call still waiting for this response
|
|
1268
|
+
# must keep both its recorded definition and its own failures
|
|
1269
|
+
# (HttpTransportBase::RequestRecovery#dispatching_to_host).
|
|
1270
|
+
dispatching_to_host { dispatch_server_message_now(message) }
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
# @param message [Hash] the parsed JSON-RPC message
|
|
1274
|
+
def dispatch_server_message_now(message)
|
|
1275
|
+
if protocol_era == :modern && message['method'] && message.key?('id')
|
|
1276
|
+
# MCP 2026-07-28: "The server MUST NOT send independent JSON-RPC
|
|
1277
|
+
# requests on this stream" — server-to-client interactions are
|
|
1278
|
+
# embedded in InputRequiredResult. There is no response channel
|
|
1279
|
+
# either (clients MUST NOT POST responses), so the request is dropped.
|
|
1280
|
+
# Judged by the ESTABLISHED era: while the probe is in flight a legacy
|
|
1281
|
+
# server may be waiting for its ping on the probe's own stream.
|
|
1282
|
+
@logger.warn("Ignoring server-initiated request #{message['method']} on a response stream: " \
|
|
1283
|
+
'not permitted by MCP 2026-07-28')
|
|
1284
|
+
return
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1113
1287
|
# Handle ping requests from server (keepalive mechanism)
|
|
1114
1288
|
if message['method'] == 'ping' && message.key?('id')
|
|
1115
1289
|
handle_ping_request(message['id'])
|
|
@@ -1118,7 +1292,7 @@ module MCPClient
|
|
|
1118
1292
|
handle_server_request(message)
|
|
1119
1293
|
elsif message['method'] && !message.key?('id')
|
|
1120
1294
|
# Handle server notifications (messages without id)
|
|
1121
|
-
|
|
1295
|
+
route_notification(message['method'], message['params'])
|
|
1122
1296
|
elsif message.key?('id')
|
|
1123
1297
|
# A response replayed on the events stream after its POST stream was
|
|
1124
1298
|
# closed before delivery (SEP-1699 resumption)
|
|
@@ -1204,6 +1378,10 @@ module MCPClient
|
|
|
1204
1378
|
|
|
1205
1379
|
# Call the registered callback
|
|
1206
1380
|
result = @roots_list_request_callback.call(request_id, params)
|
|
1381
|
+
# Serving a roots/list answer that carries a root means this host
|
|
1382
|
+
# declared, and is using, the deprecated Roots capability (SEP-2577) —
|
|
1383
|
+
# with or without a Client. An empty answer is not use of it.
|
|
1384
|
+
warn_roots_deprecated(result)
|
|
1207
1385
|
|
|
1208
1386
|
# Send the response back to the server (echoing related-task _meta)
|
|
1209
1387
|
send_roots_list_response(request_id, merge_related_task_meta(result, params))
|
|
@@ -1217,10 +1395,18 @@ module MCPClient
|
|
|
1217
1395
|
# If no callback is registered, return error
|
|
1218
1396
|
unless @sampling_request_callback
|
|
1219
1397
|
@logger.warn('Received sampling request but no callback registered, returning error')
|
|
1220
|
-
|
|
1398
|
+
# sampling.mdx § Error Handling reserves -1 for "User rejected sampling
|
|
1399
|
+
# request"; a capability this client never declared is an unsupported
|
|
1400
|
+
# method (-32601, Method not found), as Client#handle_sampling_request answers.
|
|
1401
|
+
send_error_response(request_id, -32_601, 'Sampling not supported')
|
|
1221
1402
|
return
|
|
1222
1403
|
end
|
|
1223
1404
|
|
|
1405
|
+
# Sampling, and the includeContext values it may carry, are deprecated
|
|
1406
|
+
# (SEP-2577, SEP-2596) — with or without a Client.
|
|
1407
|
+
warn_sampling_deprecated(params)
|
|
1408
|
+
return if refused_undeclared_sampling_tools?(request_id, params)
|
|
1409
|
+
|
|
1224
1410
|
# Call the registered callback
|
|
1225
1411
|
result = @sampling_request_callback.call(request_id, params)
|
|
1226
1412
|
|
|
@@ -1356,6 +1542,7 @@ module MCPClient
|
|
|
1356
1542
|
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
1357
1543
|
# MCP: authorization MUST be included in every HTTP request
|
|
1358
1544
|
@oauth_provider&.apply_authorization(req)
|
|
1545
|
+
note_request_authorization(req.headers['Authorization'])
|
|
1359
1546
|
req.body = json_body
|
|
1360
1547
|
end
|
|
1361
1548
|
|