ruby-mcp-client 2.0.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 +914 -49
- 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 +1367 -311
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -36
- 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 +851 -478
- 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/elicitation_validator.rb +56 -13
- data/lib/mcp_client/errors.rb +481 -6
- 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 +669 -100
- 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 +967 -11
- 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 -2
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_link.rb +0 -2
- data/lib/mcp_client/resource_template.rb +4 -2
- 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 +893 -175
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +12 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
- data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
- data/lib/mcp_client/server_sse.rb +310 -76
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
- data/lib/mcp_client/server_stdio.rb +778 -186
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
- data/lib/mcp_client/server_streamable_http.rb +547 -157
- 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 -5
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +115 -19
- metadata +71 -7
|
@@ -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
|
|
@@ -40,6 +56,13 @@ module MCPClient
|
|
|
40
56
|
MAX_RECONNECT_DELAY = 30
|
|
41
57
|
JITTER_FACTOR = 0.25
|
|
42
58
|
|
|
59
|
+
# Maximum bytes the SSE parse buffer may hold while waiting for an event
|
|
60
|
+
# terminator. The stream is peer-controlled: without a cap, a hostile
|
|
61
|
+
# server could withhold the blank-line delimiter forever and grow the
|
|
62
|
+
# buffer until the host runs out of memory. Generous enough for any
|
|
63
|
+
# legitimate JSON-RPC response event.
|
|
64
|
+
MAX_SSE_BUFFER_BYTES = 32 * 1024 * 1024
|
|
65
|
+
|
|
43
66
|
# @!attribute [r] base_url
|
|
44
67
|
# @return [String] The base URL of the MCP server
|
|
45
68
|
# @!attribute [r] tools
|
|
@@ -91,8 +114,14 @@ module MCPClient
|
|
|
91
114
|
@tools_data = nil
|
|
92
115
|
@request_id = 0
|
|
93
116
|
@sse_results = {}
|
|
117
|
+
# Ids of requests a caller is actively waiting on. Only responses for
|
|
118
|
+
# these ids are stored in @sse_results — everything else on the peer
|
|
119
|
+
# controlled stream is unsolicited and discarded.
|
|
120
|
+
@pending_request_ids = Set.new
|
|
94
121
|
@mutex = Monitor.new
|
|
95
|
-
@buffer = ''
|
|
122
|
+
@buffer = +''
|
|
123
|
+
# How much of @buffer has already been searched for an event terminator
|
|
124
|
+
@buffer_scanned = 0
|
|
96
125
|
@sse_connected = false
|
|
97
126
|
@connection_established = false
|
|
98
127
|
@connection_cv = @mutex.new_cond
|
|
@@ -131,21 +160,23 @@ module MCPClient
|
|
|
131
160
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
132
161
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
|
|
133
162
|
def list_prompts
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 }
|
|
137
169
|
|
|
138
170
|
begin
|
|
139
171
|
ensure_initialized
|
|
140
172
|
|
|
141
|
-
|
|
173
|
+
prompts = request_prompts_list.map { |prompt_data| MCPClient::Prompt.from_json(prompt_data, server: self) }
|
|
142
174
|
@mutex.synchronize do
|
|
143
|
-
@prompts =
|
|
144
|
-
MCPClient::Prompt.from_json(prompt_data, server: self)
|
|
145
|
-
end
|
|
175
|
+
@prompts = attach_list_value(:prompts, prompts) ? prompts : nil
|
|
146
176
|
end
|
|
147
177
|
|
|
148
|
-
|
|
178
|
+
# This request's own list, never a re-read of @prompts.
|
|
179
|
+
prompts
|
|
149
180
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
150
181
|
# Re-raise these errors directly
|
|
151
182
|
raise
|
|
@@ -167,6 +198,12 @@ module MCPClient
|
|
|
167
198
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
168
199
|
# Re-raise connection/transport errors directly to match test expectations
|
|
169
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}"
|
|
170
207
|
rescue StandardError => e
|
|
171
208
|
# For all other errors, wrap in PromptGetError
|
|
172
209
|
raise MCPClient::Errors::PromptGetError, "Error get prompt '#{prompt_name}': #{e.message}"
|
|
@@ -179,16 +216,21 @@ module MCPClient
|
|
|
179
216
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
180
217
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource listing
|
|
181
218
|
def list_resources(cursor: nil)
|
|
182
|
-
@mutex.synchronize
|
|
183
|
-
|
|
184
|
-
end
|
|
219
|
+
cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
|
|
220
|
+
return cached if cached
|
|
185
221
|
|
|
186
222
|
begin
|
|
187
223
|
ensure_initialized
|
|
188
224
|
|
|
189
225
|
params = {}
|
|
190
226
|
params['cursor'] = cursor if cursor
|
|
191
|
-
|
|
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
|
|
192
234
|
|
|
193
235
|
resources = (result['resources'] || []).map do |resource_data|
|
|
194
236
|
MCPClient::Resource.from_json(resource_data, server: self)
|
|
@@ -197,7 +239,9 @@ module MCPClient
|
|
|
197
239
|
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
198
240
|
|
|
199
241
|
@mutex.synchronize do
|
|
200
|
-
|
|
242
|
+
unless cursor
|
|
243
|
+
@resources_result = attach_list_value(:resources, resources_result) ? resources_result : nil
|
|
244
|
+
end
|
|
201
245
|
end
|
|
202
246
|
|
|
203
247
|
resources_result
|
|
@@ -217,9 +261,13 @@ module MCPClient
|
|
|
217
261
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource reading
|
|
218
262
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
219
263
|
def read_resource(uri)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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}"
|
|
223
271
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
224
272
|
# Re-raise connection/transport errors directly to match test expectations
|
|
225
273
|
raise
|
|
@@ -234,16 +282,35 @@ module MCPClient
|
|
|
234
282
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
235
283
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
|
|
236
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
|
+
|
|
237
292
|
ensure_initialized
|
|
238
293
|
params = {}
|
|
239
294
|
params['cursor'] = cursor if cursor
|
|
240
|
-
|
|
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
|
|
241
301
|
|
|
242
302
|
templates = (result['resourceTemplates'] || []).map do |template_data|
|
|
243
303
|
MCPClient::ResourceTemplate.from_json(template_data, server: self)
|
|
244
304
|
end
|
|
305
|
+
templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
245
306
|
|
|
246
|
-
|
|
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
|
|
247
314
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
248
315
|
raise
|
|
249
316
|
rescue StandardError => e
|
|
@@ -290,21 +357,23 @@ module MCPClient
|
|
|
290
357
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
291
358
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
|
|
292
359
|
def list_tools
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 }
|
|
296
366
|
|
|
297
367
|
begin
|
|
298
368
|
ensure_initialized
|
|
299
369
|
|
|
300
|
-
|
|
370
|
+
tools = request_tools_list.map { |tool_data| MCPClient::Tool.from_json(tool_data, server: self) }
|
|
301
371
|
@mutex.synchronize do
|
|
302
|
-
@tools =
|
|
303
|
-
MCPClient::Tool.from_json(tool_data, server: self)
|
|
304
|
-
end
|
|
372
|
+
@tools = attach_list_value(:tools, tools) ? tools : nil
|
|
305
373
|
end
|
|
306
374
|
|
|
307
|
-
|
|
375
|
+
# This request's own list, never a re-read of @tools.
|
|
376
|
+
tools
|
|
308
377
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
309
378
|
# Re-raise these errors directly
|
|
310
379
|
raise
|
|
@@ -326,6 +395,12 @@ module MCPClient
|
|
|
326
395
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
327
396
|
# Re-raise connection/transport errors directly to match test expectations
|
|
328
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}"
|
|
329
404
|
rescue StandardError => e
|
|
330
405
|
# For all other errors, wrap in ToolCallError
|
|
331
406
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
@@ -342,11 +417,17 @@ module MCPClient
|
|
|
342
417
|
require_capability!('completions', method: 'completion/complete')
|
|
343
418
|
params = { ref: ref, argument: argument }
|
|
344
419
|
params[:context] = context if context
|
|
345
|
-
result = rpc_request('completion/complete', params)
|
|
420
|
+
result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
|
|
346
421
|
result['completion'] || { 'values' => [] }
|
|
347
422
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
348
423
|
MCPClient::Errors::CapabilityError
|
|
349
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}"
|
|
350
431
|
rescue StandardError => e
|
|
351
432
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
352
433
|
end
|
|
@@ -356,13 +437,24 @@ module MCPClient
|
|
|
356
437
|
# 'critical', 'alert', 'emergency')
|
|
357
438
|
# @return [Hash] empty result on success
|
|
358
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.
|
|
359
444
|
def log_level=(level)
|
|
445
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
360
446
|
ensure_initialized
|
|
361
447
|
require_capability!('logging', method: 'logging/setLevel')
|
|
362
448
|
rpc_request('logging/setLevel', { level: level })
|
|
363
449
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
364
450
|
MCPClient::Errors::CapabilityError
|
|
365
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}"
|
|
366
458
|
rescue StandardError => e
|
|
367
459
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
368
460
|
end
|
|
@@ -371,7 +463,15 @@ module MCPClient
|
|
|
371
463
|
# @return [Boolean] true if connection was successful
|
|
372
464
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
373
465
|
def connect
|
|
374
|
-
|
|
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
|
|
375
475
|
|
|
376
476
|
# Check for pre-existing auth error (needed for tests)
|
|
377
477
|
pre_existing_auth_error = @mutex.synchronize { @auth_error }
|
|
@@ -386,6 +486,10 @@ module MCPClient
|
|
|
386
486
|
effective_timeout = [@read_timeout || 30, 30].min
|
|
387
487
|
wait_for_connection(timeout: effective_timeout)
|
|
388
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)
|
|
389
493
|
true
|
|
390
494
|
rescue MCPClient::Errors::ConnectionError => e
|
|
391
495
|
cleanup
|
|
@@ -410,6 +514,12 @@ module MCPClient
|
|
|
410
514
|
# multiple connection attempts. This is essential for proper reconnection
|
|
411
515
|
# logic and exponential backoff.
|
|
412
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
|
|
413
523
|
@mutex.synchronize do
|
|
414
524
|
# Set flags first before killing threads to prevent race conditions
|
|
415
525
|
# where threads might check flags after they're set but before they're killed
|
|
@@ -423,7 +533,18 @@ module MCPClient
|
|
|
423
533
|
|
|
424
534
|
# Reset the SSE parse buffer so a reconnect never inherits a leftover
|
|
425
535
|
# partial event from the previous connection.
|
|
426
|
-
@buffer = ''
|
|
536
|
+
@buffer = +''
|
|
537
|
+
@buffer_scanned = 0
|
|
538
|
+
|
|
539
|
+
# Drop results nobody is waiting for, so peer-supplied state cannot
|
|
540
|
+
# accumulate across reconnects. Results for still-pending requests are
|
|
541
|
+
# KEPT: a response can arrive while its POST is still returning, and
|
|
542
|
+
# the waiter (which reconnects through ensure_sse_connection_active)
|
|
543
|
+
# is about to consume it. Discarding those reported a timeout for a
|
|
544
|
+
# tool call the server had already executed — inviting a duplicate
|
|
545
|
+
# manual retry. unregister_pending_request clears each entry when its
|
|
546
|
+
# request finishes, so nothing lingers.
|
|
547
|
+
@sse_results.select! { |id, _| @pending_request_ids.include?(id) }
|
|
427
548
|
|
|
428
549
|
# Log cleanup for debugging
|
|
429
550
|
@logger.debug('Cleaning up SSE connection')
|
|
@@ -469,9 +590,48 @@ module MCPClient
|
|
|
469
590
|
@sse_conn = nil
|
|
470
591
|
|
|
471
592
|
@tools = nil
|
|
593
|
+
@tools_data = nil
|
|
594
|
+
@prompts = nil
|
|
595
|
+
@prompts_data = nil
|
|
596
|
+
@resources_result = nil
|
|
597
|
+
@templates_result = nil
|
|
472
598
|
# Don't clear auth error as we need it for reporting the correct error
|
|
473
599
|
# Don't reset @consecutive_ping_failures or @reconnect_attempts as they're tracked across reconnections
|
|
474
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))
|
|
475
635
|
end
|
|
476
636
|
|
|
477
637
|
# Register a callback for elicitation requests (MCP 2025-06-18)
|
|
@@ -482,6 +642,13 @@ module MCPClient
|
|
|
482
642
|
end
|
|
483
643
|
|
|
484
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.
|
|
485
652
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
486
653
|
# @return [void]
|
|
487
654
|
def on_roots_list_request(&block)
|
|
@@ -489,6 +656,11 @@ module MCPClient
|
|
|
489
656
|
end
|
|
490
657
|
|
|
491
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.
|
|
492
664
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
493
665
|
# @return [void]
|
|
494
666
|
def on_sampling_request(&block)
|
|
@@ -519,8 +691,11 @@ module MCPClient
|
|
|
519
691
|
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
520
692
|
end
|
|
521
693
|
rescue StandardError => e
|
|
694
|
+
# The exception message is host-internal (file paths, connection
|
|
695
|
+
# strings, library internals): log it locally, but answer the peer with
|
|
696
|
+
# a constant message so failures cannot be used to probe the host.
|
|
522
697
|
@logger.error("Error handling server request: #{e.message}")
|
|
523
|
-
send_error_response(request_id, -32_603,
|
|
698
|
+
send_error_response(request_id, -32_603, 'Internal error')
|
|
524
699
|
end
|
|
525
700
|
|
|
526
701
|
# Handle a server-initiated ping request (MCP ping utility)
|
|
@@ -572,6 +747,10 @@ module MCPClient
|
|
|
572
747
|
|
|
573
748
|
# Call the registered callback
|
|
574
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)
|
|
575
754
|
|
|
576
755
|
# Send the response back to the server (echoing related-task _meta)
|
|
577
756
|
send_roots_list_response(request_id, merge_related_task_meta(result, params))
|
|
@@ -604,10 +783,18 @@ module MCPClient
|
|
|
604
783
|
# If no callback is registered, return error
|
|
605
784
|
unless @sampling_request_callback
|
|
606
785
|
@logger.warn('Received sampling request but no callback registered, returning error')
|
|
607
|
-
|
|
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')
|
|
608
790
|
return
|
|
609
791
|
end
|
|
610
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
|
+
|
|
611
798
|
# Call the registered callback
|
|
612
799
|
result = @sampling_request_callback.call(request_id, params)
|
|
613
800
|
|
|
@@ -714,10 +901,11 @@ module MCPClient
|
|
|
714
901
|
# header on all HTTP requests after the initialize handshake.
|
|
715
902
|
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
716
903
|
@headers.each { |k, v| req.headers[k] = v }
|
|
904
|
+
note_request_authorization(authorization_header_value(req.headers))
|
|
717
905
|
req.body = json_body
|
|
718
906
|
end
|
|
719
907
|
|
|
720
|
-
@logger.debug("Sent response via HTTP POST: #{
|
|
908
|
+
@logger.debug("Sent response via HTTP POST: #{describe_jsonrpc_message(response)}")
|
|
721
909
|
rescue StandardError => e
|
|
722
910
|
@logger.error("Failed to send response via HTTP POST: #{e.message}")
|
|
723
911
|
end
|
|
@@ -777,6 +965,7 @@ module MCPClient
|
|
|
777
965
|
def establish_sse_connection(conn, sse_path)
|
|
778
966
|
conn.get(sse_path) do |req|
|
|
779
967
|
@headers.each { |k, v| req.headers[k] = v }
|
|
968
|
+
note_request_authorization(authorization_header_value(req.headers))
|
|
780
969
|
|
|
781
970
|
req.options.on_data = proc do |chunk, _bytes|
|
|
782
971
|
process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
|
|
@@ -839,7 +1028,14 @@ module MCPClient
|
|
|
839
1028
|
# Process an SSE chunk from the server
|
|
840
1029
|
# @param chunk [String] the chunk to process
|
|
841
1030
|
def process_sse_chunk(chunk)
|
|
842
|
-
|
|
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)
|
|
1036
|
+
# Size only: the chunk is raw wire data carrying sampling prompts,
|
|
1037
|
+
# elicitation content and tool results.
|
|
1038
|
+
@logger.debug("Processing SSE chunk (#{describe_body_size(chunk)})")
|
|
843
1039
|
|
|
844
1040
|
# Only record activity for real events
|
|
845
1041
|
record_activity if chunk.include?('event:')
|
|
@@ -850,7 +1046,7 @@ module MCPClient
|
|
|
850
1046
|
event_buffers = extract_complete_events(chunk)
|
|
851
1047
|
|
|
852
1048
|
# Process extracted events outside the mutex to avoid deadlocks
|
|
853
|
-
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) }
|
|
854
1050
|
end
|
|
855
1051
|
|
|
856
1052
|
# Check if the error represents an authorization error
|
|
@@ -897,21 +1093,61 @@ module MCPClient
|
|
|
897
1093
|
# @return [Array<String>, nil] array of complete events or nil if none
|
|
898
1094
|
# @private
|
|
899
1095
|
def extract_complete_events(chunk)
|
|
900
|
-
event_buffers =
|
|
1096
|
+
event_buffers = []
|
|
901
1097
|
@mutex.synchronize do
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
#
|
|
905
|
-
#
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1098
|
+
# Append in place. `@buffer += chunk` allocates and copies the whole
|
|
1099
|
+
# buffer on every callback, so an unterminated event delivered in N
|
|
1100
|
+
# chunks costs O(N^2) copying — memory stays capped but a peer can
|
|
1101
|
+
# still burn CPU and thrash the allocator on the way there.
|
|
1102
|
+
@buffer << chunk
|
|
1103
|
+
|
|
1104
|
+
# Rescan only the newly arrived bytes, backing up by the longest
|
|
1105
|
+
# delimiter minus one so one split across two chunks is still found.
|
|
1106
|
+
scan_from = [@buffer_scanned - 3, 0].max
|
|
1107
|
+
while (event_end = next_event_end(scan_from))
|
|
1108
|
+
event_buffers << extract_single_event(event_end)
|
|
1109
|
+
# The buffer shifted; what remains is short (one event at most).
|
|
1110
|
+
scan_from = 0
|
|
1111
|
+
@buffer_scanned = 0
|
|
910
1112
|
end
|
|
1113
|
+
@buffer_scanned = @buffer.length
|
|
1114
|
+
|
|
1115
|
+
# Whatever is left is a partial event still awaiting its terminator.
|
|
1116
|
+
# The cap is applied here rather than before appending so a single
|
|
1117
|
+
# oversized chunk that DOES contain complete events is still parsed.
|
|
1118
|
+
fail_oversized_sse_buffer! if @buffer.bytesize > MAX_SSE_BUFFER_BYTES
|
|
911
1119
|
end
|
|
912
1120
|
event_buffers
|
|
913
1121
|
end
|
|
914
1122
|
|
|
1123
|
+
# Index of the earliest event terminator at or after an offset.
|
|
1124
|
+
# @param offset [Integer] character offset to start searching from
|
|
1125
|
+
# @return [Integer, nil] index of the terminator, or nil if none yet
|
|
1126
|
+
def next_event_end(offset)
|
|
1127
|
+
lf = @buffer.index("\n\n", offset)
|
|
1128
|
+
crlf = @buffer.index("\r\n\r\n", offset)
|
|
1129
|
+
[lf, crlf].compact.min
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
# Drop an oversized partial event and fail the connection.
|
|
1133
|
+
#
|
|
1134
|
+
# Recording the cause matters: this runs inside Faraday's on_data callback
|
|
1135
|
+
# on the SSE worker thread, whose generic rescue would otherwise leave
|
|
1136
|
+
# callers with a bare "connection lost" and no reason. Mirrors the
|
|
1137
|
+
# endpoint-URI failure path so wait_for_connection surfaces it promptly.
|
|
1138
|
+
# @raise [MCPClient::Errors::ConnectionError] always
|
|
1139
|
+
def fail_oversized_sse_buffer!
|
|
1140
|
+
message = "SSE event exceeded the maximum buffered size (#{MAX_SSE_BUFFER_BYTES} bytes) " \
|
|
1141
|
+
'without a terminator'
|
|
1142
|
+
@buffer = +''
|
|
1143
|
+
@buffer_scanned = 0
|
|
1144
|
+
@connection_error = message
|
|
1145
|
+
@connection_established = false
|
|
1146
|
+
@connection_cv.broadcast
|
|
1147
|
+
@logger.error(message)
|
|
1148
|
+
raise MCPClient::Errors::ConnectionError, message
|
|
1149
|
+
end
|
|
1150
|
+
|
|
915
1151
|
# Extract a single event from the buffer
|
|
916
1152
|
# @param event_end [Integer] the position where the event ends
|
|
917
1153
|
# @return [String] the extracted event data
|
|
@@ -946,16 +1182,32 @@ module MCPClient
|
|
|
946
1182
|
# @return [Array<Hash>] the prompts data
|
|
947
1183
|
# @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
|
|
948
1184
|
# @private
|
|
949
|
-
|
|
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)
|
|
950
1190
|
@mutex.synchronize do
|
|
951
|
-
|
|
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
|
|
952
1204
|
end
|
|
1205
|
+
end
|
|
953
1206
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
@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')
|
|
959
1211
|
end
|
|
960
1212
|
|
|
961
1213
|
# Request the resources list using JSON-RPC
|
|
@@ -963,23 +1215,11 @@ module MCPClient
|
|
|
963
1215
|
# @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
|
|
964
1216
|
# @private
|
|
965
1217
|
def request_resources_list
|
|
966
|
-
|
|
967
|
-
return @resources_data if @resources_data
|
|
968
|
-
end
|
|
969
|
-
|
|
1218
|
+
# The raw list is this fetch's own (see request_tools_list).
|
|
970
1219
|
result = rpc_request('resources/list')
|
|
971
1220
|
|
|
972
|
-
if result && result['resources']
|
|
973
|
-
|
|
974
|
-
@resources_data = result['resources']
|
|
975
|
-
end
|
|
976
|
-
return @mutex.synchronize { @resources_data.dup }
|
|
977
|
-
elsif result
|
|
978
|
-
@mutex.synchronize do
|
|
979
|
-
@resources_data = result
|
|
980
|
-
end
|
|
981
|
-
return @mutex.synchronize { @resources_data.dup }
|
|
982
|
-
end
|
|
1221
|
+
return result['resources'].dup if result && result['resources']
|
|
1222
|
+
return result.dup if result
|
|
983
1223
|
|
|
984
1224
|
raise MCPClient::Errors::ResourceReadError, 'Failed to get resources list from JSON-RPC request'
|
|
985
1225
|
end
|
|
@@ -989,17 +1229,11 @@ module MCPClient
|
|
|
989
1229
|
# @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
|
|
990
1230
|
# @private
|
|
991
1231
|
def request_tools_list
|
|
992
|
-
@mutex.synchronize do
|
|
993
|
-
return @tools_data.dup if @tools_data
|
|
994
|
-
end
|
|
995
|
-
|
|
996
1232
|
# Follow nextCursor across pages so the full tool list is returned. The
|
|
997
|
-
#
|
|
998
|
-
#
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
@mutex.synchronize { @tools_data = tools }
|
|
1002
|
-
@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')
|
|
1003
1237
|
end
|
|
1004
1238
|
end
|
|
1005
1239
|
end
|