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'
|
|
@@ -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
|
|
@@ -33,6 +38,52 @@ module MCPClient
|
|
|
33
38
|
SSE_MAX_RECONNECT_DELAY = 30 # Maximum reconnect delay in seconds
|
|
34
39
|
THREAD_JOIN_TIMEOUT = 5 # Timeout for thread cleanup
|
|
35
40
|
|
|
41
|
+
# Floor for the delay between resumption GETs. SEP-1699's polling pattern
|
|
42
|
+
# wants fast reconnects, so this is far smaller than the events-stream
|
|
43
|
+
# floor — it only prevents a peer-supplied "retry: 0" from turning the
|
|
44
|
+
# deadline window into a back-to-back request loop.
|
|
45
|
+
MIN_RESUMPTION_RECONNECT_DELAY = 0.01
|
|
46
|
+
|
|
47
|
+
# Maximum length of a server-supplied SSE event id retained as the
|
|
48
|
+
# resumption cursor. The id is echoed in the Last-Event-ID header of
|
|
49
|
+
# subsequent requests, so an unbounded value means unbounded retained
|
|
50
|
+
# memory and oversized outbound headers.
|
|
51
|
+
MAX_EVENT_ID_LENGTH = 1024
|
|
52
|
+
|
|
53
|
+
# Characters allowed in a retained event id: printable ASCII, since the
|
|
54
|
+
# value becomes an HTTP header value. Notably excludes CR/LF. The range
|
|
55
|
+
# starts at 0x20 because a space is legal inside a field value, and
|
|
56
|
+
# rejecting ids like "cursor 42" would silently strand resumption on a
|
|
57
|
+
# stale cursor.
|
|
58
|
+
EVENT_ID_PATTERN = /\A[\x20-\x7E]+\z/
|
|
59
|
+
|
|
60
|
+
# Ceiling on concurrent threads POSTing server-initiated responses
|
|
61
|
+
# (pongs, roots/sampling/elicitation replies, error responses). Each
|
|
62
|
+
# server request on the events stream costs one blocking HTTP POST in
|
|
63
|
+
# its own thread; without a bound, a peer flooding requests could
|
|
64
|
+
# accumulate threads and connections until the host is exhausted.
|
|
65
|
+
# Responses beyond the budget are dropped, with saturation logged at most
|
|
66
|
+
# once per SATURATION_LOG_INTERVAL seconds.
|
|
67
|
+
MAX_CONCURRENT_RESPONSE_POSTS = 8
|
|
68
|
+
|
|
69
|
+
# Minimum gap between "response budget saturated" warnings
|
|
70
|
+
SATURATION_LOG_INTERVAL = 5
|
|
71
|
+
|
|
72
|
+
# Floor for server-supplied retry directives on the long-lived events
|
|
73
|
+
# stream. The directive is peer-controlled: honoring "retry: 0" literally
|
|
74
|
+
# would let a hostile server that closes every stream drive a tight
|
|
75
|
+
# reconnect loop (sustained CPU/TLS/connection churn). Waiting longer
|
|
76
|
+
# than the directive stays SEP-1699 compliant — the retry field is a
|
|
77
|
+
# lower bound on the reconnect delay, not an exact schedule.
|
|
78
|
+
MIN_EVENTS_RECONNECT_DELAY = 0.1
|
|
79
|
+
|
|
80
|
+
# Maximum bytes an SSE parse buffer (events stream or resumption GET) may
|
|
81
|
+
# hold while waiting for an event terminator. The stream is
|
|
82
|
+
# peer-controlled: without a cap, a hostile server could withhold the
|
|
83
|
+
# blank-line delimiter forever and grow the buffer until the host runs
|
|
84
|
+
# out of memory. Generous enough for any legitimate JSON-RPC event.
|
|
85
|
+
MAX_SSE_BUFFER_BYTES = 32 * 1024 * 1024
|
|
86
|
+
|
|
36
87
|
# @!attribute [r] base_url
|
|
37
88
|
# @return [String] The base URL of the MCP server
|
|
38
89
|
# @!attribute [r] endpoint
|
|
@@ -97,7 +148,9 @@ module MCPClient
|
|
|
97
148
|
})
|
|
98
149
|
|
|
99
150
|
@read_timeout = opts[:read_timeout]
|
|
151
|
+
configure_protocol_mode(opts[:protocol], opts[:discover_timeout])
|
|
100
152
|
@faraday_config = opts[:faraday_config]
|
|
153
|
+
@max_decompressed_body_bytes = validate_decompression_limit(opts[:max_decompressed_body_bytes])
|
|
101
154
|
@tools = nil
|
|
102
155
|
@tools_data = nil
|
|
103
156
|
@prompts = nil
|
|
@@ -113,12 +166,18 @@ module MCPClient
|
|
|
113
166
|
@last_event_id = nil
|
|
114
167
|
@sse_retry_ms = nil
|
|
115
168
|
@pending_stream_responses = {}
|
|
169
|
+
@response_post_count = 0
|
|
170
|
+
# Saturation bookkeeping for the response-POST budget
|
|
171
|
+
@dropped_response_posts = 0
|
|
172
|
+
@last_saturation_log_at = nil
|
|
116
173
|
@oauth_provider = opts[:oauth_provider]
|
|
117
174
|
|
|
118
175
|
# SSE events connection state
|
|
119
176
|
@events_connection = nil
|
|
120
177
|
@events_thread = nil
|
|
121
|
-
@buffer = '' # Buffer for partial SSE event data
|
|
178
|
+
@buffer = +'' # Buffer for partial SSE event data
|
|
179
|
+
# How much of @buffer has already been searched for an event terminator
|
|
180
|
+
@buffer_scanned = 0
|
|
122
181
|
@elicitation_request_callback = nil # MCP 2025-06-18
|
|
123
182
|
@roots_list_request_callback = nil # MCP 2025-06-18
|
|
124
183
|
@sampling_request_callback = nil # MCP 2025-11-25
|
|
@@ -128,35 +187,39 @@ module MCPClient
|
|
|
128
187
|
# @return [Boolean] true if connection was successful
|
|
129
188
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
130
189
|
def connect
|
|
131
|
-
|
|
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
|
|
132
195
|
|
|
133
|
-
|
|
134
|
-
@mutex.synchronize do
|
|
196
|
+
begin
|
|
135
197
|
@connection_established = false
|
|
136
198
|
@initialized = false
|
|
137
|
-
end
|
|
138
199
|
|
|
139
|
-
|
|
140
|
-
|
|
200
|
+
# Test connectivity with a simple HTTP request
|
|
201
|
+
test_connection
|
|
141
202
|
|
|
142
|
-
|
|
143
|
-
|
|
203
|
+
# Establish the protocol era: server/discover for a modern server,
|
|
204
|
+
# the initialize handshake for a legacy one.
|
|
205
|
+
negotiate_protocol
|
|
144
206
|
|
|
145
|
-
|
|
146
|
-
|
|
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?
|
|
147
211
|
|
|
148
|
-
@mutex.synchronize do
|
|
149
212
|
@connection_established = true
|
|
150
213
|
@initialized = true
|
|
151
|
-
end
|
|
152
214
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
160
223
|
end
|
|
161
224
|
end
|
|
162
225
|
|
|
@@ -166,21 +229,18 @@ module MCPClient
|
|
|
166
229
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
167
230
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
|
|
168
231
|
def list_tools
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 }
|
|
173
240
|
begin
|
|
174
241
|
ensure_connected
|
|
175
242
|
|
|
176
|
-
|
|
177
|
-
@mutex.synchronize do
|
|
178
|
-
@tools = tools_data.map do |tool_data|
|
|
179
|
-
MCPClient::Tool.from_json(tool_data, server: self)
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
@mutex.synchronize { @tools }
|
|
243
|
+
refetch_or_serve_stale(:tools, stale_list_entry(:tools)) { fetch_tools_list }
|
|
184
244
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
185
245
|
# Re-raise these errors directly
|
|
186
246
|
raise
|
|
@@ -199,9 +259,15 @@ module MCPClient
|
|
|
199
259
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
200
260
|
def call_tool(tool_name, parameters)
|
|
201
261
|
rpc_request('tools/call', build_named_request_params(tool_name, parameters))
|
|
202
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
262
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ValidationError
|
|
203
263
|
# Re-raise connection/transport errors directly to match test expectations
|
|
204
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}"
|
|
205
271
|
rescue StandardError => e
|
|
206
272
|
# For all other errors, wrap in ToolCallError
|
|
207
273
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
@@ -228,11 +294,17 @@ module MCPClient
|
|
|
228
294
|
require_capability!('completions', method: 'completion/complete')
|
|
229
295
|
params = { ref: ref, argument: argument }
|
|
230
296
|
params[:context] = context if context
|
|
231
|
-
result = rpc_request('completion/complete', params)
|
|
297
|
+
result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
|
|
232
298
|
result['completion'] || { 'values' => [] }
|
|
233
299
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
234
300
|
MCPClient::Errors::CapabilityError
|
|
235
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}"
|
|
236
308
|
rescue StandardError => e
|
|
237
309
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
238
310
|
end
|
|
@@ -242,13 +314,31 @@ module MCPClient
|
|
|
242
314
|
# 'critical', 'alert', 'emergency')
|
|
243
315
|
# @return [Hash] empty result on success
|
|
244
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.
|
|
245
321
|
def log_level=(level)
|
|
322
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
246
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
|
+
|
|
247
331
|
require_capability!('logging', method: 'logging/setLevel')
|
|
248
332
|
rpc_request('logging/setLevel', { level: level })
|
|
249
333
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
250
|
-
MCPClient::Errors::CapabilityError
|
|
334
|
+
MCPClient::Errors::CapabilityError, ArgumentError
|
|
251
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}"
|
|
252
342
|
rescue StandardError => e
|
|
253
343
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
254
344
|
end
|
|
@@ -257,29 +347,40 @@ module MCPClient
|
|
|
257
347
|
# @return [Array<MCPClient::Prompt>] list of available prompts
|
|
258
348
|
# @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
|
|
259
349
|
def list_prompts
|
|
260
|
-
@mutex.synchronize
|
|
261
|
-
|
|
262
|
-
end
|
|
350
|
+
cached = fresh_list_value(:prompts) { @mutex.synchronize { @prompts } }
|
|
351
|
+
return cached if cached
|
|
263
352
|
|
|
353
|
+
@mutex.synchronize { @prompts_data = nil }
|
|
264
354
|
begin
|
|
265
355
|
ensure_connected
|
|
266
|
-
|
|
267
|
-
prompts_data = request_prompts_list
|
|
268
|
-
@mutex.synchronize do
|
|
269
|
-
@prompts = prompts_data.map do |prompt_data|
|
|
270
|
-
MCPClient::Prompt.from_json(prompt_data, server: self)
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
@mutex.synchronize { @prompts }
|
|
356
|
+
refetch_or_serve_stale(:prompts, stale_list_entry(:prompts)) { fetch_prompts_list }
|
|
275
357
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
276
|
-
# Re-raise these errors directly
|
|
277
358
|
raise
|
|
278
359
|
rescue StandardError => e
|
|
279
360
|
raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
|
|
280
361
|
end
|
|
281
362
|
end
|
|
282
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
|
+
|
|
283
384
|
# Get a prompt with the given parameters
|
|
284
385
|
# @param prompt_name [String] the name of the prompt to get
|
|
285
386
|
# @param parameters [Hash] the parameters to pass to the prompt
|
|
@@ -290,6 +391,12 @@ module MCPClient
|
|
|
290
391
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
291
392
|
# Re-raise connection/transport errors directly
|
|
292
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}"
|
|
293
400
|
rescue StandardError => e
|
|
294
401
|
# For all other errors, wrap in PromptGetError
|
|
295
402
|
raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
|
|
@@ -300,44 +407,69 @@ module MCPClient
|
|
|
300
407
|
# @return [Hash] result containing resources array and optional nextCursor
|
|
301
408
|
# @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
|
|
302
409
|
def list_resources(cursor: nil)
|
|
303
|
-
@mutex.synchronize
|
|
304
|
-
|
|
305
|
-
end
|
|
410
|
+
cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
|
|
411
|
+
return cached if cached
|
|
306
412
|
|
|
307
413
|
begin
|
|
308
414
|
ensure_connected
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
resources = (result['resources'] || []).map do |resource_data|
|
|
315
|
-
MCPClient::Resource.from_json(resource_data, server: self)
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
319
|
-
|
|
320
|
-
@mutex.synchronize do
|
|
321
|
-
@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
|
|
322
419
|
end
|
|
323
420
|
|
|
324
|
-
|
|
421
|
+
fetch_resources_list(cursor)
|
|
325
422
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
326
|
-
# Re-raise these errors directly
|
|
327
423
|
raise
|
|
328
424
|
rescue StandardError => e
|
|
329
425
|
raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
|
|
330
426
|
end
|
|
331
427
|
end
|
|
332
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
|
+
|
|
333
461
|
# Read a resource by its URI
|
|
334
462
|
# @param uri [String] the URI of the resource to read
|
|
335
463
|
# @return [Array<MCPClient::ResourceContent>] array of resource contents
|
|
336
464
|
# @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
|
|
337
465
|
def read_resource(uri)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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}"
|
|
341
473
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
342
474
|
# Re-raise connection/transport errors directly
|
|
343
475
|
raise
|
|
@@ -351,19 +483,52 @@ module MCPClient
|
|
|
351
483
|
# @return [Hash] result containing resourceTemplates array and optional nextCursor
|
|
352
484
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
|
|
353
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)
|
|
354
513
|
params = {}
|
|
355
514
|
params['cursor'] = cursor if cursor
|
|
356
|
-
|
|
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
|
|
357
519
|
|
|
358
520
|
templates = (result['resourceTemplates'] || []).map do |template_data|
|
|
359
521
|
MCPClient::ResourceTemplate.from_json(template_data, server: self)
|
|
360
522
|
end
|
|
523
|
+
templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
361
524
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
367
532
|
end
|
|
368
533
|
|
|
369
534
|
# Subscribe to resource updates
|
|
@@ -373,6 +538,13 @@ module MCPClient
|
|
|
373
538
|
def subscribe_resource(uri)
|
|
374
539
|
ensure_connected
|
|
375
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
|
+
|
|
376
548
|
rpc_request('resources/subscribe', { uri: uri })
|
|
377
549
|
true
|
|
378
550
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
@@ -389,6 +561,11 @@ module MCPClient
|
|
|
389
561
|
def unsubscribe_resource(uri)
|
|
390
562
|
ensure_connected
|
|
391
563
|
require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
|
|
564
|
+
if modern?
|
|
565
|
+
unsubscribe_resource_via_listen(uri)
|
|
566
|
+
return true
|
|
567
|
+
end
|
|
568
|
+
|
|
392
569
|
rpc_request('resources/unsubscribe', { uri: uri })
|
|
393
570
|
true
|
|
394
571
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
@@ -402,6 +579,10 @@ module MCPClient
|
|
|
402
579
|
def apply_request_headers(req, request)
|
|
403
580
|
super
|
|
404
581
|
|
|
582
|
+
# Modern servers have no session; the base class added the 2026-07-28
|
|
583
|
+
# request metadata headers.
|
|
584
|
+
return if modern?
|
|
585
|
+
|
|
405
586
|
# Add session and protocol version headers for non-initialize requests
|
|
406
587
|
return unless request['method'] != 'initialize'
|
|
407
588
|
|
|
@@ -429,7 +610,7 @@ module MCPClient
|
|
|
429
610
|
session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
|
|
430
611
|
if session_id
|
|
431
612
|
if valid_session_id?(session_id)
|
|
432
|
-
|
|
613
|
+
capture_session_id(session_id)
|
|
433
614
|
@logger.debug("Captured session ID: #{@session_id}")
|
|
434
615
|
else
|
|
435
616
|
@logger.warn("Invalid session ID format received: #{session_id.inspect}")
|
|
@@ -452,6 +633,10 @@ module MCPClient
|
|
|
452
633
|
# Clean up the server connection
|
|
453
634
|
# Properly closes HTTP connections, stops threads, and clears cached state
|
|
454
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?
|
|
455
640
|
@mutex.synchronize do
|
|
456
641
|
return unless @connection_established || @initialized
|
|
457
642
|
|
|
@@ -461,6 +646,9 @@ module MCPClient
|
|
|
461
646
|
@connection_established = false
|
|
462
647
|
@initialized = false
|
|
463
648
|
|
|
649
|
+
# Subscription streams (MCP 2026-07-28) end with the connection
|
|
650
|
+
close_listen_streams
|
|
651
|
+
|
|
464
652
|
# Attempt to terminate session before cleanup
|
|
465
653
|
begin
|
|
466
654
|
terminate_session if @session_id
|
|
@@ -492,10 +680,27 @@ module MCPClient
|
|
|
492
680
|
@prompts_data = nil
|
|
493
681
|
@resources = nil
|
|
494
682
|
@resources_data = nil
|
|
495
|
-
@
|
|
683
|
+
@resources_result = nil
|
|
684
|
+
@templates_result = nil
|
|
685
|
+
@buffer = +''
|
|
686
|
+
@buffer_scanned = 0
|
|
496
687
|
|
|
497
688
|
@logger.info('Cleanup completed')
|
|
498
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
|
|
499
704
|
end
|
|
500
705
|
|
|
501
706
|
# Register a callback for elicitation requests (MCP 2025-06-18)
|
|
@@ -506,6 +711,13 @@ module MCPClient
|
|
|
506
711
|
end
|
|
507
712
|
|
|
508
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.
|
|
509
721
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
510
722
|
# @return [void]
|
|
511
723
|
def on_roots_list_request(&block)
|
|
@@ -513,6 +725,11 @@ module MCPClient
|
|
|
513
725
|
end
|
|
514
726
|
|
|
515
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.
|
|
516
733
|
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
517
734
|
# @return [void]
|
|
518
735
|
def on_sampling_request(&block)
|
|
@@ -534,6 +751,17 @@ module MCPClient
|
|
|
534
751
|
|
|
535
752
|
# Default options for server initialization
|
|
536
753
|
# @return [Hash] Default options
|
|
754
|
+
# Validate the configured decompression ceiling.
|
|
755
|
+
# @param value [Object] the max_decompressed_body_bytes option
|
|
756
|
+
# @return [Integer] the validated positive byte limit
|
|
757
|
+
# @raise [ArgumentError] if the value is not a positive Integer
|
|
758
|
+
def validate_decompression_limit(value)
|
|
759
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
760
|
+
|
|
761
|
+
raise ArgumentError,
|
|
762
|
+
"max_decompressed_body_bytes must be a positive Integer, got #{value.inspect}"
|
|
763
|
+
end
|
|
764
|
+
|
|
537
765
|
def default_options
|
|
538
766
|
{
|
|
539
767
|
endpoint: '/rpc',
|
|
@@ -544,7 +772,10 @@ module MCPClient
|
|
|
544
772
|
name: nil,
|
|
545
773
|
logger: nil,
|
|
546
774
|
oauth_provider: nil,
|
|
547
|
-
faraday_config: nil
|
|
775
|
+
faraday_config: nil,
|
|
776
|
+
max_decompressed_body_bytes: JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES,
|
|
777
|
+
protocol: :auto,
|
|
778
|
+
discover_timeout: nil
|
|
548
779
|
}
|
|
549
780
|
end
|
|
550
781
|
|
|
@@ -569,65 +800,50 @@ module MCPClient
|
|
|
569
800
|
# @return [void]
|
|
570
801
|
# @raise [MCPClient::Errors::ConnectionError] if connection is not established
|
|
571
802
|
def ensure_connected
|
|
572
|
-
|
|
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
|
|
573
811
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
812
|
+
@logger.debug('Connection not active, attempting to reconnect before request')
|
|
813
|
+
cleanup
|
|
814
|
+
connect
|
|
815
|
+
end
|
|
577
816
|
end
|
|
578
817
|
|
|
579
818
|
# Request the tools list using JSON-RPC
|
|
580
819
|
# @return [Array<Hash>] the tools data
|
|
581
820
|
# @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
|
|
582
821
|
def request_tools_list
|
|
583
|
-
@mutex.synchronize do
|
|
584
|
-
return @tools_data.dup if @tools_data
|
|
585
|
-
end
|
|
586
|
-
|
|
587
822
|
# Follow nextCursor across pages so the full tool list is returned even
|
|
588
|
-
# when the server paginates.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
@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')
|
|
593
827
|
end
|
|
594
828
|
|
|
595
829
|
# Request the prompts list using JSON-RPC
|
|
596
830
|
# @return [Array<Hash>] the prompts data
|
|
597
831
|
# @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
|
|
598
832
|
def request_prompts_list
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
# Follow nextCursor across pages so the full prompt list is returned.
|
|
604
|
-
prompts = request_paginated_list('prompts/list', 'prompts')
|
|
605
|
-
|
|
606
|
-
@mutex.synchronize { @prompts_data = prompts }
|
|
607
|
-
@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')
|
|
608
836
|
end
|
|
609
837
|
|
|
610
838
|
# Request the resources list using JSON-RPC
|
|
611
839
|
# @return [Array<Hash>] the resources data
|
|
612
840
|
# @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
|
|
613
841
|
def request_resources_list
|
|
614
|
-
|
|
615
|
-
return @resources_data if @resources_data
|
|
616
|
-
end
|
|
617
|
-
|
|
842
|
+
# The raw list is this fetch's own (see request_tools_list).
|
|
618
843
|
result = rpc_request('resources/list')
|
|
619
844
|
|
|
620
|
-
if result.is_a?(Hash) && result['resources']
|
|
621
|
-
|
|
622
|
-
@resources_data = result['resources']
|
|
623
|
-
end
|
|
624
|
-
return @mutex.synchronize { @resources_data.dup }
|
|
625
|
-
elsif result.is_a?(Array) || result
|
|
626
|
-
@mutex.synchronize do
|
|
627
|
-
@resources_data = result
|
|
628
|
-
end
|
|
629
|
-
return @mutex.synchronize { @resources_data.dup }
|
|
630
|
-
end
|
|
845
|
+
return result['resources'].dup if result.is_a?(Hash) && result['resources']
|
|
846
|
+
return result.dup if result.is_a?(Array) || result
|
|
631
847
|
|
|
632
848
|
raise MCPClient::Errors::ResourceReadError, 'Failed to get resources list from JSON-RPC request'
|
|
633
849
|
end
|
|
@@ -723,12 +939,17 @@ module MCPClient
|
|
|
723
939
|
end
|
|
724
940
|
|
|
725
941
|
# Reconnect delay for the events stream: the server's SSE retry directive
|
|
726
|
-
# (in ms) when present, otherwise the caller's backoff value.
|
|
942
|
+
# (in ms) when present, otherwise the caller's backoff value. The
|
|
943
|
+
# peer-controlled directive is floored at MIN_EVENTS_RECONNECT_DELAY so a
|
|
944
|
+
# zero/near-zero value cannot drive a tight reconnect loop; the
|
|
945
|
+
# deadline-bounded resumption loop intentionally keeps honoring zero.
|
|
727
946
|
# @param fallback_seconds [Numeric] exponential-backoff fallback
|
|
728
947
|
# @return [Numeric] delay in seconds
|
|
729
948
|
def events_reconnect_delay(fallback_seconds)
|
|
730
949
|
retry_ms = @sse_retry_ms
|
|
731
|
-
|
|
950
|
+
return fallback_seconds unless retry_ms
|
|
951
|
+
|
|
952
|
+
[retry_ms / 1000.0, MIN_EVENTS_RECONNECT_DELAY].max
|
|
732
953
|
end
|
|
733
954
|
|
|
734
955
|
# Wait for a response replayed after the POST stream was closed before
|
|
@@ -770,8 +991,11 @@ module MCPClient
|
|
|
770
991
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
|
|
771
992
|
state = { cursor: cursor, retry_ms: retry_ms }
|
|
772
993
|
# SEP-1699: the client MUST respect the server's retry directive before
|
|
773
|
-
# attempting to reconnect.
|
|
774
|
-
|
|
994
|
+
# attempting to reconnect. With no directive the FIRST GET goes out
|
|
995
|
+
# immediately, as before this change: delaying it adds latency to every
|
|
996
|
+
# resumption and, on a short read_timeout, can burn the whole budget
|
|
997
|
+
# before any I/O happens.
|
|
998
|
+
delay = retry_ms ? resumption_delay(retry_ms) : 0
|
|
775
999
|
|
|
776
1000
|
loop do
|
|
777
1001
|
sleep(delay) if delay.positive?
|
|
@@ -779,10 +1003,21 @@ module MCPClient
|
|
|
779
1003
|
break unless @mutex.synchronize { @pending_stream_responses.key?(request_id) }
|
|
780
1004
|
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
781
1005
|
|
|
782
|
-
delay = state[:retry_ms]
|
|
1006
|
+
delay = resumption_delay(state[:retry_ms])
|
|
783
1007
|
end
|
|
784
1008
|
end
|
|
785
1009
|
|
|
1010
|
+
# Delay before the next resumption GET: the peer's retry directive when
|
|
1011
|
+
# present, floored so "retry: 0" cannot turn the deadline window into a
|
|
1012
|
+
# back-to-back request loop, otherwise the standard reconnect delay.
|
|
1013
|
+
# @param retry_ms [Integer, nil] retry directive in milliseconds
|
|
1014
|
+
# @return [Numeric] delay in seconds
|
|
1015
|
+
def resumption_delay(retry_ms)
|
|
1016
|
+
return SSE_RECONNECT_DELAY unless retry_ms
|
|
1017
|
+
|
|
1018
|
+
[retry_ms / 1000.0, MIN_RESUMPTION_RECONNECT_DELAY].max
|
|
1019
|
+
end
|
|
1020
|
+
|
|
786
1021
|
# One GET with the current cursor; complete SSE events are dispatched
|
|
787
1022
|
# through the standard server-message path, which routes replayed
|
|
788
1023
|
# responses to their registered waiters. `id:` and `retry:` fields
|
|
@@ -803,6 +1038,10 @@ module MCPClient
|
|
|
803
1038
|
req.options.on_data = proc do |chunk, _bytes|
|
|
804
1039
|
buffer << chunk
|
|
805
1040
|
process_resumption_buffer(buffer, state)
|
|
1041
|
+
# A peer replaying delimiter-free data must not grow this buffer
|
|
1042
|
+
# without bound: abort the GET (rescued below); the deadline-bounded
|
|
1043
|
+
# resumption loop decides whether to retry with a fresh buffer.
|
|
1044
|
+
enforce_sse_buffer_cap!(buffer)
|
|
806
1045
|
end
|
|
807
1046
|
end
|
|
808
1047
|
rescue StandardError => e
|
|
@@ -815,10 +1054,16 @@ module MCPClient
|
|
|
815
1054
|
# @param state [Hash] mutable resumption state (:cursor, :retry_ms)
|
|
816
1055
|
# @return [void]
|
|
817
1056
|
def process_resumption_buffer(buffer, state)
|
|
818
|
-
|
|
1057
|
+
# Same incremental scan as the events stream: without a cursor every
|
|
1058
|
+
# chunk re-matched the whole accumulated buffer from byte zero.
|
|
1059
|
+
scan_from = [state[:scanned].to_i - 3, 0].max
|
|
1060
|
+
while (separator = buffer.match(/\r\n\r\n|\n\n/, scan_from))
|
|
819
1061
|
event_text = buffer.slice!(0, separator.end(0))
|
|
820
1062
|
handle_resumption_event(event_text, state)
|
|
1063
|
+
scan_from = 0
|
|
1064
|
+
state[:scanned] = 0
|
|
821
1065
|
end
|
|
1066
|
+
state[:scanned] = buffer.length
|
|
822
1067
|
end
|
|
823
1068
|
|
|
824
1069
|
# Parse one SSE event received on a resumption stream: track `id:` lines
|
|
@@ -835,7 +1080,9 @@ module MCPClient
|
|
|
835
1080
|
data_lines << line.sub(/\Adata:\s*/, '')
|
|
836
1081
|
elsif line.start_with?('id:')
|
|
837
1082
|
id = line.sub(/\Aid:\s*/, '').strip
|
|
838
|
-
|
|
1083
|
+
# Same validation as the events stream: this cursor is echoed in
|
|
1084
|
+
# the Last-Event-ID header of the next resumption GET.
|
|
1085
|
+
state[:cursor] = id if retainable_event_id?(id)
|
|
839
1086
|
elsif line.start_with?('retry:')
|
|
840
1087
|
raw = line.sub(/\Aretry:\s*/, '').strip
|
|
841
1088
|
state[:retry_ms] = raw.to_i if raw.match?(/\A\d+\z/)
|
|
@@ -865,6 +1112,7 @@ module MCPClient
|
|
|
865
1112
|
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
866
1113
|
# MCP: authorization MUST be included in every HTTP request
|
|
867
1114
|
@oauth_provider&.apply_authorization(req)
|
|
1115
|
+
note_request_authorization(req.headers['Authorization'])
|
|
868
1116
|
# SEP-1699: resumption is via GET with the Last-Event-ID cursor, so the
|
|
869
1117
|
# server can replay messages missed since the last received event.
|
|
870
1118
|
last_event_id = @mutex.synchronize { @last_event_id }
|
|
@@ -875,22 +1123,64 @@ module MCPClient
|
|
|
875
1123
|
# Buffers partial chunks and processes complete SSE events
|
|
876
1124
|
# @param chunk [String] the chunk to process
|
|
877
1125
|
def process_event_chunk(chunk)
|
|
878
|
-
|
|
1126
|
+
# Size only: the chunk is raw wire data carrying sampling prompts,
|
|
1127
|
+
# elicitation content and tool results.
|
|
1128
|
+
@logger.debug("Processing event chunk (#{describe_body_size(chunk)})") if @logger.level <= Logger::DEBUG
|
|
879
1129
|
|
|
880
1130
|
@mutex.synchronize do
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
#
|
|
884
|
-
|
|
1131
|
+
# Append in place and scan only the newly arrived bytes. `+= chunk`
|
|
1132
|
+
# reallocated and copied the whole buffer per callback, and each
|
|
1133
|
+
# search restarted at index 0, so an unterminated event delivered in
|
|
1134
|
+
# N chunks cost O(N^2) work: capped memory, uncapped CPU.
|
|
1135
|
+
@buffer << chunk
|
|
1136
|
+
|
|
1137
|
+
scan_from = [@buffer_scanned - 3, 0].max
|
|
1138
|
+
while (event_end = next_buffer_event_end(scan_from))
|
|
885
1139
|
event_data = extract_event(event_end)
|
|
886
1140
|
parse_and_handle_event(event_data)
|
|
1141
|
+
scan_from = 0
|
|
1142
|
+
@buffer_scanned = 0
|
|
1143
|
+
end
|
|
1144
|
+
@buffer_scanned = @buffer.length
|
|
1145
|
+
|
|
1146
|
+
# Everything left is a partial event awaiting its terminator; cap how
|
|
1147
|
+
# much a peer may accumulate. The raise propagates (unlike processing
|
|
1148
|
+
# errors below) so the events loop drops this connection and its
|
|
1149
|
+
# backoff takes over — memory stays bounded either way.
|
|
1150
|
+
begin
|
|
1151
|
+
enforce_sse_buffer_cap!(@buffer)
|
|
1152
|
+
rescue MCPClient::Errors::ConnectionError
|
|
1153
|
+
@buffer = +''
|
|
1154
|
+
@buffer_scanned = 0
|
|
1155
|
+
raise
|
|
887
1156
|
end
|
|
888
1157
|
end
|
|
1158
|
+
rescue MCPClient::Errors::ConnectionError
|
|
1159
|
+
raise
|
|
889
1160
|
rescue StandardError => e
|
|
890
1161
|
@logger.error("Error processing event chunk: #{e.message}")
|
|
891
1162
|
@logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
|
|
892
1163
|
end
|
|
893
1164
|
|
|
1165
|
+
# Index of the earliest event terminator at or after an offset.
|
|
1166
|
+
# @param offset [Integer] character offset to start searching from
|
|
1167
|
+
# @return [Integer, nil] index of the terminator, or nil if none yet
|
|
1168
|
+
def next_buffer_event_end(offset)
|
|
1169
|
+
lf = @buffer.index("\n\n", offset)
|
|
1170
|
+
crlf = @buffer.index("\r\n\r\n", offset)
|
|
1171
|
+
[lf, crlf].compact.min
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
# @param buffer [String] a partial-event SSE buffer
|
|
1175
|
+
# @raise [MCPClient::Errors::ConnectionError] when the buffer exceeds MAX_SSE_BUFFER_BYTES
|
|
1176
|
+
def enforce_sse_buffer_cap!(buffer)
|
|
1177
|
+
return if buffer.bytesize <= MAX_SSE_BUFFER_BYTES
|
|
1178
|
+
|
|
1179
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1180
|
+
"SSE event exceeded the maximum buffered size (#{MAX_SSE_BUFFER_BYTES} bytes) " \
|
|
1181
|
+
'without a terminator'
|
|
1182
|
+
end
|
|
1183
|
+
|
|
894
1184
|
# Extract a single event from the buffer
|
|
895
1185
|
# @param event_end [Integer] the position where the event ends
|
|
896
1186
|
# @return [String] the extracted event data
|
|
@@ -922,9 +1212,12 @@ module MCPClient
|
|
|
922
1212
|
# SSE allows multiple data lines that should be joined with newlines
|
|
923
1213
|
data_lines << line[5..].strip
|
|
924
1214
|
elsif line.start_with?('id:')
|
|
925
|
-
# Track event ID for resumability (MCP future enhancement)
|
|
1215
|
+
# Track event ID for resumability (MCP future enhancement). The id
|
|
1216
|
+
# is peer-controlled and gets echoed in the Last-Event-ID header, so
|
|
1217
|
+
# only a bounded, header-safe value is retained; the previous valid
|
|
1218
|
+
# cursor is kept when one is rejected.
|
|
926
1219
|
event[:id] = line[3..].strip
|
|
927
|
-
@last_event_id = event[:id]
|
|
1220
|
+
@last_event_id = event[:id] if retainable_event_id?(event[:id])
|
|
928
1221
|
elsif line.start_with?('retry:')
|
|
929
1222
|
# SEP-1699: the client MUST respect the server's retry directive
|
|
930
1223
|
# (milliseconds) when reconnecting; zero is a valid directive.
|
|
@@ -947,10 +1240,19 @@ module MCPClient
|
|
|
947
1240
|
return if data.empty?
|
|
948
1241
|
|
|
949
1242
|
begin
|
|
950
|
-
|
|
1243
|
+
message = JSON.parse(data)
|
|
1244
|
+
unless message.is_a?(Hash)
|
|
1245
|
+
# A JSON-parseable scalar/array is not a JSON-RPC message; dispatch
|
|
1246
|
+
# would raise on it. Log the type, never the value.
|
|
1247
|
+
@logger.warn("Skipping non-object JSON-RPC message on the events stream (#{message.class})")
|
|
1248
|
+
return
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
dispatch_server_message(message)
|
|
951
1252
|
rescue JSON::ParserError => e
|
|
952
|
-
|
|
953
|
-
|
|
1253
|
+
# The parser message names the failure position, not the payload; the
|
|
1254
|
+
# payload itself stays out of the log.
|
|
1255
|
+
@logger.error("Invalid JSON in server message: #{describe_parse_error(e, data)}")
|
|
954
1256
|
end
|
|
955
1257
|
end
|
|
956
1258
|
|
|
@@ -959,6 +1261,29 @@ module MCPClient
|
|
|
959
1261
|
# interleaved on a POST SSE response stream.
|
|
960
1262
|
# @param message [Hash] the parsed JSON-RPC message
|
|
961
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
|
+
|
|
962
1287
|
# Handle ping requests from server (keepalive mechanism)
|
|
963
1288
|
if message['method'] == 'ping' && message.key?('id')
|
|
964
1289
|
handle_ping_request(message['id'])
|
|
@@ -967,7 +1292,7 @@ module MCPClient
|
|
|
967
1292
|
handle_server_request(message)
|
|
968
1293
|
elsif message['method'] && !message.key?('id')
|
|
969
1294
|
# Handle server notifications (messages without id)
|
|
970
|
-
|
|
1295
|
+
route_notification(message['method'], message['params'])
|
|
971
1296
|
elsif message.key?('id')
|
|
972
1297
|
# A response replayed on the events stream after its POST stream was
|
|
973
1298
|
# closed before delivery (SEP-1699 resumption)
|
|
@@ -985,26 +1310,9 @@ module MCPClient
|
|
|
985
1310
|
result: {}
|
|
986
1311
|
}
|
|
987
1312
|
|
|
988
|
-
#
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
response = conn.post(@endpoint) do |req|
|
|
992
|
-
@headers.each { |k, v| req.headers[k] = v }
|
|
993
|
-
req.headers['Mcp-Session-Id'] = @session_id if @session_id
|
|
994
|
-
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
995
|
-
# MCP: authorization MUST be included in every HTTP request
|
|
996
|
-
@oauth_provider&.apply_authorization(req)
|
|
997
|
-
req.body = pong_response.to_json
|
|
998
|
-
end
|
|
999
|
-
|
|
1000
|
-
if response.success?
|
|
1001
|
-
@logger.debug("Sent pong response for ping ID: #{ping_id}") if @logger.level <= Logger::DEBUG
|
|
1002
|
-
else
|
|
1003
|
-
@logger.warn("Failed to send pong response: HTTP #{response.status}")
|
|
1004
|
-
end
|
|
1005
|
-
rescue StandardError => e
|
|
1006
|
-
@logger.error("Failed to send pong response: #{e.message}")
|
|
1007
|
-
end
|
|
1313
|
+
# Pongs go through the same bounded response path as every other
|
|
1314
|
+
# server-initiated reply, so a ping flood cannot fan out threads.
|
|
1315
|
+
post_jsonrpc_response(pong_response)
|
|
1008
1316
|
end
|
|
1009
1317
|
|
|
1010
1318
|
# Handle incoming JSON-RPC request from server (MCP 2025-06-18)
|
|
@@ -1029,8 +1337,11 @@ module MCPClient
|
|
|
1029
1337
|
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
1030
1338
|
end
|
|
1031
1339
|
rescue StandardError => e
|
|
1340
|
+
# The exception message is host-internal (file paths, connection
|
|
1341
|
+
# strings, library internals): log it locally, but answer the peer with
|
|
1342
|
+
# a constant message so failures cannot be used to probe the host.
|
|
1032
1343
|
@logger.error("Error handling server request: #{e.message}")
|
|
1033
|
-
send_error_response(request_id, -32_603,
|
|
1344
|
+
send_error_response(request_id, -32_603, 'Internal error')
|
|
1034
1345
|
end
|
|
1035
1346
|
|
|
1036
1347
|
# Handle elicitation/create request from server (MCP 2025-11-25)
|
|
@@ -1067,6 +1378,10 @@ module MCPClient
|
|
|
1067
1378
|
|
|
1068
1379
|
# Call the registered callback
|
|
1069
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)
|
|
1070
1385
|
|
|
1071
1386
|
# Send the response back to the server (echoing related-task _meta)
|
|
1072
1387
|
send_roots_list_response(request_id, merge_related_task_meta(result, params))
|
|
@@ -1080,10 +1395,18 @@ module MCPClient
|
|
|
1080
1395
|
# If no callback is registered, return error
|
|
1081
1396
|
unless @sampling_request_callback
|
|
1082
1397
|
@logger.warn('Received sampling request but no callback registered, returning error')
|
|
1083
|
-
|
|
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')
|
|
1084
1402
|
return
|
|
1085
1403
|
end
|
|
1086
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
|
+
|
|
1087
1410
|
# Call the registered callback
|
|
1088
1411
|
result = @sampling_request_callback.call(request_id, params)
|
|
1089
1412
|
|
|
@@ -1184,7 +1507,31 @@ module MCPClient
|
|
|
1184
1507
|
# @return [void]
|
|
1185
1508
|
# @private
|
|
1186
1509
|
def post_jsonrpc_response(response)
|
|
1187
|
-
# Send response in a separate thread to avoid blocking event
|
|
1510
|
+
# Send the response in a separate thread to avoid blocking event
|
|
1511
|
+
# processing, but never beyond the concurrency budget: server requests
|
|
1512
|
+
# arrive at the peer's rate, and each unanswered POST would otherwise
|
|
1513
|
+
# pin one thread and one connection.
|
|
1514
|
+
unless acquire_response_post_slot
|
|
1515
|
+
log_response_post_saturation
|
|
1516
|
+
return
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
begin
|
|
1520
|
+
start_response_post_thread(response)
|
|
1521
|
+
rescue ThreadError => e
|
|
1522
|
+
# Thread.new failed, so the ensure inside the block never runs and the
|
|
1523
|
+
# reservation would leak. Eight such failures would silently mute this
|
|
1524
|
+
# instance's replies for the rest of its life, including after
|
|
1525
|
+
# reconnect (cleanup does not reset the counter).
|
|
1526
|
+
release_response_post_slot
|
|
1527
|
+
@logger.error("Failed to start response POST thread: #{e.message}")
|
|
1528
|
+
end
|
|
1529
|
+
end
|
|
1530
|
+
|
|
1531
|
+
# Spawn the worker that POSTs one server-initiated response.
|
|
1532
|
+
# @param response [Hash] the JSON-RPC response
|
|
1533
|
+
# @return [Thread]
|
|
1534
|
+
def start_response_post_thread(response)
|
|
1188
1535
|
Thread.new do
|
|
1189
1536
|
conn = http_connection
|
|
1190
1537
|
json_body = JSON.generate(response)
|
|
@@ -1195,17 +1542,60 @@ module MCPClient
|
|
|
1195
1542
|
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
1196
1543
|
# MCP: authorization MUST be included in every HTTP request
|
|
1197
1544
|
@oauth_provider&.apply_authorization(req)
|
|
1545
|
+
note_request_authorization(req.headers['Authorization'])
|
|
1198
1546
|
req.body = json_body
|
|
1199
1547
|
end
|
|
1200
1548
|
|
|
1201
1549
|
if resp.success?
|
|
1202
|
-
@logger.debug("Sent JSON-RPC response: #{
|
|
1550
|
+
@logger.debug("Sent JSON-RPC response: #{describe_jsonrpc_message(response)}")
|
|
1203
1551
|
else
|
|
1204
1552
|
@logger.warn("Failed to send JSON-RPC response: HTTP #{resp.status}")
|
|
1205
1553
|
end
|
|
1206
1554
|
rescue StandardError => e
|
|
1207
1555
|
@logger.error("Failed to send JSON-RPC response: #{e.message}")
|
|
1556
|
+
ensure
|
|
1557
|
+
release_response_post_slot
|
|
1558
|
+
end
|
|
1559
|
+
end
|
|
1560
|
+
|
|
1561
|
+
# Warn that the response-POST budget is saturated, at most once per
|
|
1562
|
+
# SATURATION_LOG_INTERVAL.
|
|
1563
|
+
#
|
|
1564
|
+
# The peer controls how often this path is reached, so logging every
|
|
1565
|
+
# rejection (at WARN, which the default logger emits) would just trade a
|
|
1566
|
+
# thread-exhaustion vector for a log-volume one. The peer-supplied request
|
|
1567
|
+
# id is deliberately omitted for the same reason.
|
|
1568
|
+
# @return [void]
|
|
1569
|
+
def log_response_post_saturation
|
|
1570
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1571
|
+
dropped = @mutex.synchronize do
|
|
1572
|
+
@dropped_response_posts += 1
|
|
1573
|
+
next nil if @last_saturation_log_at && now - @last_saturation_log_at < SATURATION_LOG_INTERVAL
|
|
1574
|
+
|
|
1575
|
+
@last_saturation_log_at = now
|
|
1576
|
+
@dropped_response_posts
|
|
1208
1577
|
end
|
|
1578
|
+
return unless dropped
|
|
1579
|
+
|
|
1580
|
+
@logger.warn("Dropping server-initiated responses: #{MAX_CONCURRENT_RESPONSE_POSTS} " \
|
|
1581
|
+
"response POSTs already in flight (#{dropped} dropped so far)")
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1584
|
+
# Reserve one slot of the response-POST concurrency budget.
|
|
1585
|
+
# @return [Boolean] whether a slot was available
|
|
1586
|
+
def acquire_response_post_slot
|
|
1587
|
+
@mutex.synchronize do
|
|
1588
|
+
return false if @response_post_count >= MAX_CONCURRENT_RESPONSE_POSTS
|
|
1589
|
+
|
|
1590
|
+
@response_post_count += 1
|
|
1591
|
+
true
|
|
1592
|
+
end
|
|
1593
|
+
end
|
|
1594
|
+
|
|
1595
|
+
# Release a slot reserved by acquire_response_post_slot.
|
|
1596
|
+
# @return [void]
|
|
1597
|
+
def release_response_post_slot
|
|
1598
|
+
@mutex.synchronize { @response_post_count -= 1 }
|
|
1209
1599
|
end
|
|
1210
1600
|
end
|
|
1211
1601
|
end
|