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'
|
|
@@ -16,14 +17,23 @@ module MCPClient
|
|
|
16
17
|
# @note Elicitation Support (MCP 2025-06-18)
|
|
17
18
|
# This transport does NOT support server-initiated elicitation requests.
|
|
18
19
|
# The HTTP transport uses a pure request-response architecture where only the client
|
|
19
|
-
# can initiate requests.
|
|
20
|
+
# can initiate requests. A legacy server that sends one on an SSE response
|
|
21
|
+
# stream is answered with JSON-RPC -32601 rather than left waiting (a
|
|
22
|
+
# `ping` gets the empty result it requires). For elicitation support, use
|
|
23
|
+
# one of these transports instead:
|
|
20
24
|
# - ServerStdio: Full bidirectional JSON-RPC over stdin/stdout
|
|
21
|
-
# - ServerSSE: Server requests via SSE stream, client responses via HTTP POST
|
|
22
25
|
# - ServerStreamableHTTP: Server requests via SSE-formatted responses, client responses via HTTP POST
|
|
26
|
+
# - ServerSSE: Server requests via SSE stream, client responses via HTTP POST — but the
|
|
27
|
+
# HTTP+SSE transport is deprecated (SEP-2596; earliest removal three months after
|
|
28
|
+
# SEP-2596 reaches Final), so prefer ServerStreamableHTTP for a new integration
|
|
23
29
|
class ServerHTTP < ServerBase
|
|
24
30
|
require_relative 'server_http/json_rpc_transport'
|
|
25
31
|
|
|
26
32
|
include JsonRpcTransport
|
|
33
|
+
# Every operation that may weigh a cache decision runs inside a scope
|
|
34
|
+
# that reserves the host request_meta evaluation for the request it
|
|
35
|
+
# leads to, and drops it when the operation ends.
|
|
36
|
+
prepend MCPClient::RequestMetaScope
|
|
27
37
|
|
|
28
38
|
# Default values for connection settings
|
|
29
39
|
DEFAULT_READ_TIMEOUT = 30
|
|
@@ -93,13 +103,16 @@ module MCPClient
|
|
|
93
103
|
end
|
|
94
104
|
|
|
95
105
|
# Set up headers for HTTP requests
|
|
106
|
+
# MCP 2026-07-28 Streamable HTTP: the client MUST list both content
|
|
107
|
+
# types and support either framing of the response.
|
|
96
108
|
@headers = opts[:headers].merge({
|
|
97
109
|
'Content-Type' => 'application/json',
|
|
98
|
-
'Accept' => 'application/json',
|
|
110
|
+
'Accept' => 'application/json, text/event-stream',
|
|
99
111
|
'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}"
|
|
100
112
|
})
|
|
101
113
|
|
|
102
114
|
@read_timeout = opts[:read_timeout]
|
|
115
|
+
configure_protocol_mode(opts[:protocol], opts[:discover_timeout])
|
|
103
116
|
@faraday_config = opts[:faraday_config]
|
|
104
117
|
@tools = nil
|
|
105
118
|
@tools_data = nil
|
|
@@ -110,38 +123,43 @@ module MCPClient
|
|
|
110
123
|
@http_conn = nil
|
|
111
124
|
@session_id = nil
|
|
112
125
|
@oauth_provider = opts[:oauth_provider]
|
|
126
|
+
@elicitation_request_callback = nil # MCP 2026-07-28 multi round-trip requests
|
|
127
|
+
@roots_list_request_callback = nil
|
|
128
|
+
@sampling_request_callback = nil
|
|
113
129
|
end
|
|
114
130
|
|
|
115
131
|
# Connect to the MCP server over HTTP
|
|
116
132
|
# @return [Boolean] true if connection was successful
|
|
117
133
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
118
134
|
def connect
|
|
119
|
-
|
|
135
|
+
# Serialized: concurrent first requests must not each run the probe
|
|
136
|
+
# and possibly settle on different eras (the monitor is reentrant, so
|
|
137
|
+
# the request plumbing inside may take @mutex again).
|
|
138
|
+
@mutex.synchronize do
|
|
139
|
+
return true if @connection_established
|
|
120
140
|
|
|
121
|
-
|
|
122
|
-
@mutex.synchronize do
|
|
141
|
+
begin
|
|
123
142
|
@connection_established = false
|
|
124
143
|
@initialized = false
|
|
125
|
-
end
|
|
126
144
|
|
|
127
|
-
|
|
128
|
-
|
|
145
|
+
# Test connectivity with a simple HTTP request
|
|
146
|
+
test_connection
|
|
129
147
|
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
# Establish the protocol era: server/discover for a modern server,
|
|
149
|
+
# the initialize handshake for a legacy one.
|
|
150
|
+
negotiate_protocol
|
|
132
151
|
|
|
133
|
-
@mutex.synchronize do
|
|
134
152
|
@connection_established = true
|
|
135
153
|
@initialized = true
|
|
136
|
-
end
|
|
137
154
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
155
|
+
true
|
|
156
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
157
|
+
cleanup
|
|
158
|
+
raise e
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
cleanup
|
|
161
|
+
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
|
162
|
+
end
|
|
145
163
|
end
|
|
146
164
|
end
|
|
147
165
|
|
|
@@ -151,21 +169,18 @@ module MCPClient
|
|
|
151
169
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
152
170
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
|
|
153
171
|
def list_tools
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
# MCP 2026-07-28 caching: a cached list is served only while fresh, and
|
|
173
|
+
# only from the entry that carries its hint.
|
|
174
|
+
cached = fresh_list_value(:tools) { @mutex.synchronize { @tools } }
|
|
175
|
+
return cached if cached
|
|
176
|
+
|
|
177
|
+
# Stale: the raw page cache must go too, or the re-fetch would be
|
|
178
|
+
# answered from memory.
|
|
179
|
+
@mutex.synchronize { @tools_data = nil }
|
|
158
180
|
begin
|
|
159
181
|
ensure_connected
|
|
160
182
|
|
|
161
|
-
|
|
162
|
-
@mutex.synchronize do
|
|
163
|
-
@tools = tools_data.map do |tool_data|
|
|
164
|
-
MCPClient::Tool.from_json(tool_data, server: self)
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
@mutex.synchronize { @tools }
|
|
183
|
+
refetch_or_serve_stale(:tools, stale_list_entry(:tools)) { fetch_tools_list }
|
|
169
184
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
170
185
|
# Re-raise these errors directly
|
|
171
186
|
raise
|
|
@@ -184,9 +199,15 @@ module MCPClient
|
|
|
184
199
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
185
200
|
def call_tool(tool_name, parameters)
|
|
186
201
|
rpc_request('tools/call', build_named_request_params(tool_name, parameters))
|
|
187
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
202
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ValidationError
|
|
188
203
|
# Re-raise connection/transport errors directly to match test expectations
|
|
189
204
|
raise
|
|
205
|
+
rescue MCPClient::Errors::ServerError => e
|
|
206
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
207
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
208
|
+
raise if e.protocol_error?
|
|
209
|
+
|
|
210
|
+
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
190
211
|
rescue StandardError => e
|
|
191
212
|
# For all other errors, wrap in ToolCallError
|
|
192
213
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
@@ -196,6 +217,10 @@ module MCPClient
|
|
|
196
217
|
def apply_request_headers(req, request)
|
|
197
218
|
super
|
|
198
219
|
|
|
220
|
+
# Modern servers have no session; the base class added the 2026-07-28
|
|
221
|
+
# request metadata headers.
|
|
222
|
+
return if modern?
|
|
223
|
+
|
|
199
224
|
# Add session and protocol version headers for non-initialize requests
|
|
200
225
|
return unless request['method'] != 'initialize'
|
|
201
226
|
|
|
@@ -220,7 +245,7 @@ module MCPClient
|
|
|
220
245
|
session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
|
|
221
246
|
if session_id
|
|
222
247
|
if valid_session_id?(session_id)
|
|
223
|
-
|
|
248
|
+
capture_session_id(session_id)
|
|
224
249
|
@logger.debug("Captured session ID: #{@session_id}")
|
|
225
250
|
else
|
|
226
251
|
@logger.warn("Invalid session ID format received: #{session_id.inspect}")
|
|
@@ -236,23 +261,13 @@ module MCPClient
|
|
|
236
261
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
237
262
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
|
|
238
263
|
def list_prompts
|
|
239
|
-
@mutex.synchronize
|
|
240
|
-
|
|
241
|
-
end
|
|
264
|
+
cached = fresh_list_value(:prompts) { @mutex.synchronize { @prompts } }
|
|
265
|
+
return cached if cached
|
|
242
266
|
|
|
267
|
+
@mutex.synchronize { @prompts_data = nil }
|
|
243
268
|
begin
|
|
244
269
|
ensure_connected
|
|
245
|
-
|
|
246
|
-
# Follow nextCursor across pages so the full prompt list is returned.
|
|
247
|
-
prompts = request_paginated_list('prompts/list', 'prompts')
|
|
248
|
-
|
|
249
|
-
@mutex.synchronize do
|
|
250
|
-
@prompts = prompts.map do |prompt_data|
|
|
251
|
-
MCPClient::Prompt.from_json(prompt_data, server: self)
|
|
252
|
-
end
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
@mutex.synchronize { @prompts }
|
|
270
|
+
refetch_or_serve_stale(:prompts, stale_list_entry(:prompts)) { fetch_prompts_list }
|
|
256
271
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
257
272
|
raise
|
|
258
273
|
rescue StandardError => e
|
|
@@ -260,6 +275,28 @@ module MCPClient
|
|
|
260
275
|
end
|
|
261
276
|
end
|
|
262
277
|
|
|
278
|
+
# Fetch and cache the prompt list.
|
|
279
|
+
# @return [Array<MCPClient::Prompt>]
|
|
280
|
+
def fetch_prompts_list
|
|
281
|
+
ensure_connected
|
|
282
|
+
|
|
283
|
+
# Follow nextCursor across pages so the full prompt list is returned.
|
|
284
|
+
prompts = request_paginated_list('prompts/list', 'prompts')
|
|
285
|
+
|
|
286
|
+
prompts = prompts.map { |prompt_data| MCPClient::Prompt.from_json(prompt_data, server: self) }
|
|
287
|
+
@mutex.synchronize do
|
|
288
|
+
@prompts = attach_list_value(:prompts, prompts) ? prompts : nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# This request's own list, never a re-read of @prompts (another
|
|
292
|
+
# request may have stored its list in between).
|
|
293
|
+
prompts
|
|
294
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
295
|
+
raise
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
|
|
298
|
+
end
|
|
299
|
+
|
|
263
300
|
# Get a prompt with the given parameters
|
|
264
301
|
# @param prompt_name [String] the name of the prompt to get
|
|
265
302
|
# @param parameters [Hash] the parameters to pass to the prompt
|
|
@@ -271,6 +308,12 @@ module MCPClient
|
|
|
271
308
|
rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
|
|
272
309
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
273
310
|
raise
|
|
311
|
+
rescue MCPClient::Errors::ServerError => e
|
|
312
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
313
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
314
|
+
raise if e.protocol_error?
|
|
315
|
+
|
|
316
|
+
raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
|
|
274
317
|
rescue StandardError => e
|
|
275
318
|
raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
|
|
276
319
|
end
|
|
@@ -280,28 +323,18 @@ module MCPClient
|
|
|
280
323
|
# @return [Hash] result containing resources array and optional nextCursor
|
|
281
324
|
# @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
|
|
282
325
|
def list_resources(cursor: nil)
|
|
283
|
-
@mutex.synchronize
|
|
284
|
-
|
|
285
|
-
end
|
|
326
|
+
cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
|
|
327
|
+
return cached if cached
|
|
286
328
|
|
|
287
329
|
begin
|
|
288
330
|
ensure_connected
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
resources = (result['resources'] || []).map do |resource_data|
|
|
295
|
-
MCPClient::Resource.from_json(resource_data, server: self)
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
299
|
-
|
|
300
|
-
@mutex.synchronize do
|
|
301
|
-
@resources_result = resources_result unless cursor
|
|
331
|
+
unless cursor
|
|
332
|
+
return refetch_or_serve_stale(:resources, stale_list_entry(:resources)) do
|
|
333
|
+
fetch_resources_list(nil)
|
|
334
|
+
end
|
|
302
335
|
end
|
|
303
336
|
|
|
304
|
-
|
|
337
|
+
fetch_resources_list(cursor)
|
|
305
338
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
306
339
|
raise
|
|
307
340
|
rescue StandardError => e
|
|
@@ -309,14 +342,52 @@ module MCPClient
|
|
|
309
342
|
end
|
|
310
343
|
end
|
|
311
344
|
|
|
345
|
+
# Fetch one page of resources/list, caching the first page.
|
|
346
|
+
# @param cursor [String, nil]
|
|
347
|
+
# @return [Hash]
|
|
348
|
+
def fetch_resources_list(cursor)
|
|
349
|
+
params = {}
|
|
350
|
+
params['cursor'] = cursor if cursor
|
|
351
|
+
epoch = cache_epoch(:resources)
|
|
352
|
+
answer = fetching_list_page(:resources, cursor) { rpc_request('resources/list', params) }
|
|
353
|
+
result = require_complete_result!(answer, 'resources/list')
|
|
354
|
+
record_cache_hint(:resources, result, epoch: epoch) unless cursor
|
|
355
|
+
|
|
356
|
+
resources = (result['resources'] || []).map do |resource_data|
|
|
357
|
+
MCPClient::Resource.from_json(resource_data, server: self)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
361
|
+
|
|
362
|
+
# A list invalidated while in flight is returned but not cached.
|
|
363
|
+
@mutex.synchronize do
|
|
364
|
+
unless cursor
|
|
365
|
+
@resources_result = attach_list_value(:resources, resources_result) ? resources_result : nil
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
resources_result
|
|
370
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
371
|
+
raise
|
|
372
|
+
rescue StandardError => e
|
|
373
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
|
|
374
|
+
end
|
|
375
|
+
|
|
312
376
|
# Read a resource by its URI
|
|
313
377
|
# @param uri [String] the URI of the resource to read
|
|
314
378
|
# @return [Array<MCPClient::ResourceContent>] array of resource contents
|
|
315
379
|
# @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
|
|
316
380
|
def read_resource(uri)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
381
|
+
# The session exists before the cache epoch is snapshotted: a
|
|
382
|
+
# reconnect inside the request would otherwise advance it and drop
|
|
383
|
+
# the first read's entry.
|
|
384
|
+
ensure_connected
|
|
385
|
+
read_resource_with_cache(uri) { |sent| rpc_request('resources/read', { uri: sent }) }
|
|
386
|
+
rescue MCPClient::Errors::ServerError => e
|
|
387
|
+
raise if e.protocol_error?
|
|
388
|
+
raise resource_not_found_error(uri, e) if resource_not_found_response?(e)
|
|
389
|
+
|
|
390
|
+
raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
|
|
320
391
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
321
392
|
raise
|
|
322
393
|
rescue StandardError => e
|
|
@@ -334,11 +405,17 @@ module MCPClient
|
|
|
334
405
|
require_capability!('completions', method: 'completion/complete')
|
|
335
406
|
params = { ref: ref, argument: argument }
|
|
336
407
|
params[:context] = context if context
|
|
337
|
-
result = rpc_request('completion/complete', params)
|
|
408
|
+
result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
|
|
338
409
|
result['completion'] || { 'values' => [] }
|
|
339
410
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
340
411
|
MCPClient::Errors::CapabilityError
|
|
341
412
|
raise
|
|
413
|
+
rescue MCPClient::Errors::ServerError => e
|
|
414
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
415
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
416
|
+
raise if e.protocol_error?
|
|
417
|
+
|
|
418
|
+
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
342
419
|
rescue StandardError => e
|
|
343
420
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
344
421
|
end
|
|
@@ -348,13 +425,31 @@ module MCPClient
|
|
|
348
425
|
# 'critical', 'alert', 'emergency')
|
|
349
426
|
# @return [Hash] empty result on success
|
|
350
427
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
428
|
+
# @deprecated Logging is deprecated since MCP 2026-07-28 (SEP-2577);
|
|
429
|
+
# earliest removal is the first revision released on or after
|
|
430
|
+
# 2027-07-28. Have the server log to stderr (stdio) or use
|
|
431
|
+
# OpenTelemetry instead.
|
|
351
432
|
def log_level=(level)
|
|
433
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
352
434
|
ensure_connected
|
|
435
|
+
# MCP 2026-07-28 removed logging/setLevel: the level travels per request
|
|
436
|
+
# in _meta["io.modelcontextprotocol/logLevel"].
|
|
437
|
+
if modern?
|
|
438
|
+
@log_level = validate_log_level!(level)
|
|
439
|
+
return
|
|
440
|
+
end
|
|
441
|
+
|
|
353
442
|
require_capability!('logging', method: 'logging/setLevel')
|
|
354
443
|
rpc_request('logging/setLevel', { level: level })
|
|
355
444
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
356
|
-
MCPClient::Errors::CapabilityError
|
|
445
|
+
MCPClient::Errors::CapabilityError, ArgumentError
|
|
357
446
|
raise
|
|
447
|
+
rescue MCPClient::Errors::ServerError => e
|
|
448
|
+
# 2026-07-28 protocol errors (typed -3202x, invalid result) carry
|
|
449
|
+
# actionable data such as requiredCapabilities; keep them intact.
|
|
450
|
+
raise if e.protocol_error?
|
|
451
|
+
|
|
452
|
+
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
358
453
|
rescue StandardError => e
|
|
359
454
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
360
455
|
end
|
|
@@ -364,19 +459,52 @@ module MCPClient
|
|
|
364
459
|
# @return [Hash] result containing resourceTemplates array and optional nextCursor
|
|
365
460
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
|
|
366
461
|
def list_resource_templates(cursor: nil)
|
|
462
|
+
# Only a list the server itself bounded is served from here: a
|
|
463
|
+
# positive ttlMs means no second request, while a list with no hint
|
|
464
|
+
# (a 2025-11-25 server) is asked for again, as it was before this
|
|
465
|
+
# transport cached anything (MCP 2026-07-28 caching).
|
|
466
|
+
cached = cursor ? nil : hinted_list_value(:templates)
|
|
467
|
+
return cached if cached
|
|
468
|
+
|
|
469
|
+
begin
|
|
470
|
+
ensure_connected
|
|
471
|
+
unless cursor
|
|
472
|
+
return refetch_or_serve_stale(:templates, stale_list_entry(:templates)) do
|
|
473
|
+
fetch_templates_list(nil)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
fetch_templates_list(cursor)
|
|
478
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
479
|
+
raise
|
|
480
|
+
rescue StandardError => e
|
|
481
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Fetch one page of resources/templates/list, caching the first page.
|
|
486
|
+
# @param cursor [String, nil]
|
|
487
|
+
# @return [Hash]
|
|
488
|
+
def fetch_templates_list(cursor)
|
|
367
489
|
params = {}
|
|
368
490
|
params['cursor'] = cursor if cursor
|
|
369
|
-
|
|
491
|
+
epoch = cache_epoch(:templates)
|
|
492
|
+
answer = fetching_list_page(:templates, cursor) { rpc_request('resources/templates/list', params) }
|
|
493
|
+
result = require_complete_result!(answer, 'resources/templates/list')
|
|
494
|
+
record_cache_hint(:templates, result, epoch: epoch) unless cursor
|
|
370
495
|
|
|
371
496
|
templates = (result['resourceTemplates'] || []).map do |template_data|
|
|
372
497
|
MCPClient::ResourceTemplate.from_json(template_data, server: self)
|
|
373
498
|
end
|
|
499
|
+
templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
374
500
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
501
|
+
@mutex.synchronize do
|
|
502
|
+
unless cursor
|
|
503
|
+
@templates_result = attach_list_value(:templates, templates_result) ? templates_result : nil
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
templates_result
|
|
380
508
|
end
|
|
381
509
|
|
|
382
510
|
# Subscribe to resource updates
|
|
@@ -386,6 +514,13 @@ module MCPClient
|
|
|
386
514
|
def subscribe_resource(uri)
|
|
387
515
|
ensure_connected
|
|
388
516
|
require_capability!('resources', 'subscribe', method: 'resources/subscribe')
|
|
517
|
+
# MCP 2026-07-28 replaced resources/subscribe with a subscriptions/listen
|
|
518
|
+
# stream carrying resourceSubscriptions.
|
|
519
|
+
if modern?
|
|
520
|
+
subscribe_resource_via_listen(uri)
|
|
521
|
+
return true
|
|
522
|
+
end
|
|
523
|
+
|
|
389
524
|
rpc_request('resources/subscribe', { uri: uri })
|
|
390
525
|
true
|
|
391
526
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
@@ -402,6 +537,11 @@ module MCPClient
|
|
|
402
537
|
def unsubscribe_resource(uri)
|
|
403
538
|
ensure_connected
|
|
404
539
|
require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
|
|
540
|
+
if modern?
|
|
541
|
+
unsubscribe_resource_via_listen(uri)
|
|
542
|
+
return true
|
|
543
|
+
end
|
|
544
|
+
|
|
405
545
|
rpc_request('resources/unsubscribe', { uri: uri })
|
|
406
546
|
true
|
|
407
547
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
@@ -421,6 +561,53 @@ module MCPClient
|
|
|
421
561
|
end
|
|
422
562
|
end
|
|
423
563
|
|
|
564
|
+
# Register a handler for elicitation input requests (MCP 2026-07-28 multi
|
|
565
|
+
# round-trip requests; there is no server-initiated request channel on
|
|
566
|
+
# this transport, so these only serve InputRequiredResult round trips).
|
|
567
|
+
# @param block [Proc] callback that receives (key, params) and returns an ElicitResult
|
|
568
|
+
# @return [void]
|
|
569
|
+
def on_elicitation_request(&block)
|
|
570
|
+
@elicitation_request_callback = block
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Register a handler for roots/list input requests (MCP 2026-07-28).
|
|
574
|
+
#
|
|
575
|
+
# @deprecated Roots is deprecated since MCP 2026-07-28 (SEP-2577); earliest
|
|
576
|
+
# removal is the first revision released on or after 2027-07-28. Registering
|
|
577
|
+
# a handler is not itself use of Roots — a handler that answers with no root
|
|
578
|
+
# exposes nothing deprecated — but a handler that answers with a root adopts
|
|
579
|
+
# the deprecated feature and raises the notice. Pass directories or files
|
|
580
|
+
# through tool parameters, resource URIs or server configuration instead.
|
|
581
|
+
# @param block [Proc] callback that receives (key, params) and returns a ListRootsResult
|
|
582
|
+
# @return [void]
|
|
583
|
+
def on_roots_list_request(&block)
|
|
584
|
+
@roots_list_request_callback = block
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Register a handler for sampling input requests (MCP 2026-07-28).
|
|
588
|
+
#
|
|
589
|
+
# @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577); earliest
|
|
590
|
+
# removal is the first revision released on or after 2027-07-28. Integrate
|
|
591
|
+
# directly with the LLM provider API instead of serving
|
|
592
|
+
# sampling/createMessage.
|
|
593
|
+
# @param block [Proc] callback that receives (key, params) and returns a CreateMessageResult
|
|
594
|
+
# @return [void]
|
|
595
|
+
def on_sampling_request(&block)
|
|
596
|
+
@sampling_request_callback = block
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# This transport has no legacy server-request channel (server requests on
|
|
600
|
+
# a response stream are dropped), so on a legacy session it must not
|
|
601
|
+
# advertise roots, elicitation or sampling: the handlers above only serve
|
|
602
|
+
# the modern multi round-trip pattern.
|
|
603
|
+
# @return [Hash]
|
|
604
|
+
def client_capabilities
|
|
605
|
+
capabilities = super
|
|
606
|
+
return capabilities if modern?
|
|
607
|
+
|
|
608
|
+
capabilities.except('roots', 'elicitation', 'sampling')
|
|
609
|
+
end
|
|
610
|
+
|
|
424
611
|
# Terminate the current session (if any)
|
|
425
612
|
# @return [Boolean] true if termination was successful or no session exists
|
|
426
613
|
def terminate_session
|
|
@@ -434,12 +621,18 @@ module MCPClient
|
|
|
434
621
|
# Clean up the server connection
|
|
435
622
|
# Properly closes HTTP connections and clears cached state
|
|
436
623
|
def cleanup
|
|
624
|
+
# Only an established session ends a task's namespace; a sessionless
|
|
625
|
+
# (MCP 2026-07-28) connection is merely closed, and a connection that
|
|
626
|
+
# never came up has nothing to end — see #ending_session?.
|
|
627
|
+
bump_session_epoch if ending_session?
|
|
437
628
|
@mutex.synchronize do
|
|
438
629
|
# Attempt to terminate session before cleanup
|
|
439
630
|
terminate_session if @session_id
|
|
440
631
|
|
|
441
632
|
@connection_established = false
|
|
442
633
|
@initialized = false
|
|
634
|
+
# Subscription streams (MCP 2026-07-28) end with the connection
|
|
635
|
+
close_listen_streams
|
|
443
636
|
|
|
444
637
|
@logger.debug('Cleaning up HTTP connection')
|
|
445
638
|
|
|
@@ -449,7 +642,26 @@ module MCPClient
|
|
|
449
642
|
|
|
450
643
|
@tools = nil
|
|
451
644
|
@tools_data = nil
|
|
645
|
+
@prompts = nil
|
|
646
|
+
@prompts_data = nil
|
|
647
|
+
@resources_result = nil
|
|
648
|
+
@resources_data = nil
|
|
649
|
+
@templates_result = nil
|
|
452
650
|
end
|
|
651
|
+
# Cached results and their hints belong to the connection (and its
|
|
652
|
+
# authorization context) that was just torn down; outside @mutex, as
|
|
653
|
+
# the cache has its own lock.
|
|
654
|
+
clear_result_cache
|
|
655
|
+
ensure
|
|
656
|
+
# Everything this transport left on this thread — the notes of the
|
|
657
|
+
# entries it served and recorded, the credentials, parameters and
|
|
658
|
+
# metadata of its requests — describes a slice that will never be
|
|
659
|
+
# tagged and a request that will never be made. Dropped after the
|
|
660
|
+
# session was terminated, never before: the DELETE that terminates it
|
|
661
|
+
# is a request of this transport's own, and the recorder on its
|
|
662
|
+
# connection would put its Authorization fingerprint straight back on
|
|
663
|
+
# this thread.
|
|
664
|
+
forget_transport_thread_state
|
|
453
665
|
end
|
|
454
666
|
|
|
455
667
|
private
|
|
@@ -485,7 +697,9 @@ module MCPClient
|
|
|
485
697
|
name: nil,
|
|
486
698
|
logger: nil,
|
|
487
699
|
oauth_provider: nil,
|
|
488
|
-
faraday_config: nil
|
|
700
|
+
faraday_config: nil,
|
|
701
|
+
protocol: :auto,
|
|
702
|
+
discover_timeout: nil
|
|
489
703
|
}
|
|
490
704
|
end
|
|
491
705
|
|
|
@@ -510,27 +724,30 @@ module MCPClient
|
|
|
510
724
|
# @return [void]
|
|
511
725
|
# @raise [MCPClient::Errors::ConnectionError] if connection is not established
|
|
512
726
|
def ensure_connected
|
|
513
|
-
|
|
727
|
+
# Serialized on the transport monitor (reentrant, so the nested
|
|
728
|
+
# cleanup/connect may take it again): checking the flags and acting on
|
|
729
|
+
# them must be one step. Otherwise a caller that observed "disconnected"
|
|
730
|
+
# can be overtaken by one that reconnects, and then tear that fresh
|
|
731
|
+
# connection down — terminating its session and re-running the era
|
|
732
|
+
# probe.
|
|
733
|
+
@mutex.synchronize do
|
|
734
|
+
return if @connection_established && @initialized
|
|
514
735
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
736
|
+
@logger.debug('Connection not active, attempting to reconnect before request')
|
|
737
|
+
cleanup
|
|
738
|
+
connect
|
|
739
|
+
end
|
|
518
740
|
end
|
|
519
741
|
|
|
520
742
|
# Request the tools list using JSON-RPC
|
|
521
743
|
# @return [Array<Hash>] the tools data
|
|
522
744
|
# @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
|
|
523
745
|
def request_tools_list
|
|
524
|
-
@mutex.synchronize do
|
|
525
|
-
return @tools_data.dup if @tools_data
|
|
526
|
-
end
|
|
527
|
-
|
|
528
746
|
# Follow nextCursor across pages so the full tool list is returned even
|
|
529
|
-
# when the server paginates.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
@mutex.synchronize { @tools_data.dup }
|
|
747
|
+
# when the server paginates. The raw pages are this fetch's own: a
|
|
748
|
+
# shared copy could answer a concurrent caller under other
|
|
749
|
+
# credentials with a privately scoped list (MCP 2026-07-28 caching).
|
|
750
|
+
request_paginated_list('tools/list', 'tools')
|
|
534
751
|
end
|
|
535
752
|
end
|
|
536
753
|
end
|