ruby-mcp-client 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -1,15 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'zlib'
|
|
7
|
+
require 'stringio'
|
|
8
|
+
require_relative 'deep_copy'
|
|
9
|
+
require_relative 'deprecation_notices'
|
|
10
|
+
require_relative 'header_params'
|
|
11
|
+
require_relative 'json_rpc_common/envelopes'
|
|
12
|
+
require_relative 'json_rpc_common/error_bodies'
|
|
13
|
+
require_relative 'json_rpc_common/input_waits'
|
|
14
|
+
require_relative 'subscription_support'
|
|
15
|
+
require_relative 'input_round_trips'
|
|
16
|
+
require_relative 'result_caching'
|
|
17
|
+
require_relative 'request_metadata'
|
|
18
|
+
require_relative 'round_trip_marker'
|
|
19
|
+
require_relative 'result_completeness'
|
|
20
|
+
require_relative 'session_pin'
|
|
21
|
+
|
|
3
22
|
module MCPClient
|
|
4
23
|
# Shared retry/backoff logic for JSON-RPC transports
|
|
5
24
|
module JsonRpcCommon
|
|
25
|
+
include Envelopes
|
|
26
|
+
include ErrorBodies
|
|
27
|
+
include InputWaits
|
|
28
|
+
include RoundTripMarker
|
|
29
|
+
include ResultCompleteness
|
|
30
|
+
include DeprecationNotices
|
|
31
|
+
include SubscriptionSupport
|
|
32
|
+
include InputRoundTrips
|
|
33
|
+
include ResultCaching
|
|
34
|
+
# The `_meta` a request carries, the fingerprint a cached result is bound
|
|
35
|
+
# to, and the evaluation a cache decision holds for the request it leads to.
|
|
36
|
+
include RequestMetadata
|
|
37
|
+
# Requests may be pinned to the session they belong to (see SessionPin).
|
|
38
|
+
include SessionPin
|
|
39
|
+
|
|
40
|
+
# Input requests of a multi-round tool call (see InputRoundTrips).
|
|
41
|
+
|
|
6
42
|
# JSON-RPC methods with arbitrary side effects that MUST NOT be re-sent
|
|
7
43
|
# automatically. Even a "transient" failure (5xx, dropped connection,
|
|
8
44
|
# malformed response) can arrive AFTER the server received the request,
|
|
9
45
|
# so a retry could execute the operation twice — and JSON-RPC has no
|
|
10
46
|
# idempotency key to make the duplicate safe. Callers who want to retry
|
|
11
47
|
# such an operation must decide that explicitly.
|
|
12
|
-
|
|
48
|
+
# tasks/update (MCP 2026-07-28 tasks extension) delivers one-shot input
|
|
49
|
+
# responses: a replay could advance a task twice.
|
|
50
|
+
NON_IDEMPOTENT_METHODS = %w[tools/call tasks/update].freeze
|
|
13
51
|
|
|
14
52
|
# Execute the block with retry/backoff for transient errors only.
|
|
15
53
|
#
|
|
@@ -43,6 +81,12 @@ module MCPClient
|
|
|
43
81
|
# side effect (and re-does the oversized decode).
|
|
44
82
|
raise if e.is_a?(MCPClient::Errors::RequestTimeoutError)
|
|
45
83
|
raise if e.is_a?(MCPClient::Errors::ResponseTooLargeError)
|
|
84
|
+
# A broken response stream is already handled where it is raised: the
|
|
85
|
+
# transport issues the one replacement request MCP 2026-07-28 calls
|
|
86
|
+
# for and this error means that replacement was lost too. Retrying
|
|
87
|
+
# here would silently turn "re-issue once" into retries + 1 rounds of
|
|
88
|
+
# two attempts each.
|
|
89
|
+
raise if e.is_a?(MCPClient::Errors::ResponseStreamClosedError)
|
|
46
90
|
|
|
47
91
|
if NON_IDEMPOTENT_METHODS.include?(method)
|
|
48
92
|
@logger.debug("Not retrying non-idempotent #{method} after error: #{e.message}")
|
|
@@ -60,6 +104,40 @@ module MCPClient
|
|
|
60
104
|
end
|
|
61
105
|
end
|
|
62
106
|
|
|
107
|
+
# Maximum characters of peer-supplied text written to the host log.
|
|
108
|
+
MAX_PEER_LOG_TEXT_LENGTH = 4096
|
|
109
|
+
|
|
110
|
+
# Make peer-supplied text safe to write to the host log: control
|
|
111
|
+
# characters (notably newlines, which would let a server forge log
|
|
112
|
+
# entries) are escaped and the result is capped.
|
|
113
|
+
# @param text [Object] peer-supplied text
|
|
114
|
+
# @return [String] sanitized, length-bounded text
|
|
115
|
+
def sanitize_log_text(text)
|
|
116
|
+
escaped = text.to_s.gsub(/[\x00-\x1F\x7F]/) { |c| format('\\x%02X', c.ord) }
|
|
117
|
+
return escaped if escaped.length <= MAX_PEER_LOG_TEXT_LENGTH
|
|
118
|
+
|
|
119
|
+
"#{escaped[0, MAX_PEER_LOG_TEXT_LENGTH]}... (truncated from #{escaped.length} chars)"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Tell a host layered above this transport to drop the caches a
|
|
123
|
+
# notification invalidates, surviving whatever it does with it.
|
|
124
|
+
#
|
|
125
|
+
# Called by every path that fans a notification out to the host — the
|
|
126
|
+
# subscription routing on stdio and both HTTP transports, the legacy SSE
|
|
127
|
+
# parser, and the synthetic tools/list_changed a HeaderMismatch refresh
|
|
128
|
+
# announces — and always before the notification reaches a subscription's
|
|
129
|
+
# listeners. See {MCPClient::ServerBase#on_cache_invalidation} for why the
|
|
130
|
+
# host's own callback cannot serve.
|
|
131
|
+
# @param method [String] notification method
|
|
132
|
+
# @param params [Hash, nil] notification params
|
|
133
|
+
# @return [void]
|
|
134
|
+
def notify_cache_invalidation(method, params)
|
|
135
|
+
@cache_invalidation_callback&.call(method, params)
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
@logger.warn("Cache invalidation callback error for #{sanitize_log_text(method)}: " \
|
|
138
|
+
"#{sanitize_log_text(e.message)}")
|
|
139
|
+
end
|
|
140
|
+
|
|
63
141
|
# A log-safe description of a JSON-RPC message: its method and id only.
|
|
64
142
|
#
|
|
65
143
|
# Params and results are deliberately omitted. tools/call arguments and
|
|
@@ -99,10 +177,13 @@ module MCPClient
|
|
|
99
177
|
end
|
|
100
178
|
|
|
101
179
|
# A log-safe description of a payload body: its size, never its content.
|
|
102
|
-
#
|
|
180
|
+
# A host's `conn.response :json` middleware hands the decoded object here
|
|
181
|
+
# instead of the bytes it came from; say so rather than measuring it.
|
|
182
|
+
# @param body [String, Object, nil] the response/request body
|
|
103
183
|
# @return [String]
|
|
104
184
|
def describe_body_size(body)
|
|
105
|
-
return 'empty body' if body.nil? || body.empty?
|
|
185
|
+
return 'empty body' if body.nil? || (body.respond_to?(:empty?) && body.empty?)
|
|
186
|
+
return "decoded #{body.class} body" unless body.is_a?(String)
|
|
106
187
|
|
|
107
188
|
"#{body.bytesize} bytes"
|
|
108
189
|
end
|
|
@@ -156,38 +237,469 @@ module MCPClient
|
|
|
156
237
|
params
|
|
157
238
|
end
|
|
158
239
|
|
|
240
|
+
# Transports that derive `Mcp-Param-*` headers from their tool list run a
|
|
241
|
+
# call inside a slot of its own for the definition it goes out under
|
|
242
|
+
# ({MCPClient::CalledToolDefinition}); the others have nothing to record
|
|
243
|
+
# and the call runs as it is.
|
|
244
|
+
# @yield the call
|
|
245
|
+
# @return [Object] the block value
|
|
246
|
+
def recording_called_tool_definition
|
|
247
|
+
yield
|
|
248
|
+
end
|
|
249
|
+
private :recording_called_tool_definition
|
|
250
|
+
|
|
251
|
+
# @see MCPClient::CalledToolDefinition#outside_called_tool_definition
|
|
252
|
+
# @yield the host code
|
|
253
|
+
# @return [Object] the block value
|
|
254
|
+
def outside_called_tool_definition
|
|
255
|
+
yield
|
|
256
|
+
end
|
|
257
|
+
private :outside_called_tool_definition
|
|
258
|
+
|
|
259
|
+
# Which claim a message being built makes on the evaluation the open
|
|
260
|
+
# operation reserved (see {MCPClient::RequestMetadata::HeldRequestMeta}).
|
|
261
|
+
#
|
|
262
|
+
# A probe is never sent: it models the reserved request, so it reads that
|
|
263
|
+
# request's evaluation without spending it. A real request spends the
|
|
264
|
+
# reservation only when it *is* the request the reservation was made for
|
|
265
|
+
# -- the one the operation holding it sends. Everything else -- a
|
|
266
|
+
# reconnect's handshake, a re-opened `subscriptions/listen`, a
|
|
267
|
+
# cancellation, and everything host code issues from behind the boundary
|
|
268
|
+
# a transport crosses to reach it ({MCPClient::RequestMetadata#outside_request_meta_hold}),
|
|
269
|
+
# raw `rpc_request` of the very same method included -- reads the host
|
|
270
|
+
# afresh and leaves the reservation for the request that holds it.
|
|
271
|
+
# @param method [String] the JSON-RPC method being built
|
|
272
|
+
# @param note [Boolean] whether the message is really going out
|
|
273
|
+
# @return [Symbol] :spend, :model or :none
|
|
274
|
+
def request_meta_claim(method, note)
|
|
275
|
+
return :model unless note
|
|
276
|
+
|
|
277
|
+
held = claimable_request_meta_hold
|
|
278
|
+
held && held.request_method == method ? :spend : :none
|
|
279
|
+
end
|
|
280
|
+
|
|
159
281
|
# Build a JSON-RPC request object
|
|
160
282
|
# @param method [String] JSON-RPC method name
|
|
161
283
|
# @param params [Hash] parameters for the request
|
|
162
284
|
# @param id [Integer] request ID
|
|
285
|
+
# @param note [Boolean] whether this request's effective parameters are
|
|
286
|
+
# remembered as this thread's current request (a probe that is never
|
|
287
|
+
# sent passes false)
|
|
163
288
|
# @return [Hash] the JSON-RPC request object
|
|
164
|
-
def build_jsonrpc_request(method, params, id)
|
|
289
|
+
def build_jsonrpc_request(method, params, id, note: true)
|
|
290
|
+
effective = with_request_meta(params, claim: request_meta_claim(method, note))
|
|
291
|
+
note_request_params(effective) if note
|
|
165
292
|
{
|
|
166
293
|
'jsonrpc' => '2.0',
|
|
167
294
|
'id' => id,
|
|
168
295
|
'method' => method,
|
|
169
|
-
'params' =>
|
|
296
|
+
'params' => effective
|
|
170
297
|
}
|
|
171
298
|
end
|
|
172
299
|
|
|
300
|
+
# Log levels defined by the logging utility (RFC 5424 severities).
|
|
301
|
+
LOG_LEVELS = %w[debug info notice warning error critical alert emergency].freeze
|
|
302
|
+
|
|
303
|
+
# Extension identifiers follow the `_meta` key naming rules with a
|
|
304
|
+
# mandatory prefix (basic/versioning "Extension Negotiation"): dotted
|
|
305
|
+
# labels, a slash, then a name. The name is optional — basic/index says
|
|
306
|
+
# of it "Unless empty, MUST begin and end with an alphanumeric
|
|
307
|
+
# character" — so a prefix on its own (`com.example/`) is a valid
|
|
308
|
+
# identifier.
|
|
309
|
+
EXTENSION_ID_PATTERN = %r{\A(?:[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)*[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?/
|
|
310
|
+
(?:[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)?\z}x
|
|
311
|
+
|
|
312
|
+
# The server's established protocol era.
|
|
313
|
+
#
|
|
314
|
+
# Deliberately not the same question as {#modern?}: while a
|
|
315
|
+
# server/discover probe is in flight, protocol_version holds the version
|
|
316
|
+
# the probe *proposes*, which is what outgoing requests must declare but
|
|
317
|
+
# says nothing about what the server speaks. Anything that reacts to the
|
|
318
|
+
# peer — above all, whether a server-initiated request is prohibited —
|
|
319
|
+
# must consult the era, not the tentative outgoing version.
|
|
320
|
+
# @return [Symbol, nil] :modern, :legacy, or nil before the era is known
|
|
321
|
+
def protocol_era
|
|
322
|
+
return nil if era_probe_in_flight? || protocol_version.nil?
|
|
323
|
+
|
|
324
|
+
modern? ? :modern : :legacy
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Begin proposing a protocol version that the server has not confirmed:
|
|
328
|
+
# until the probe settles, the era is unknown.
|
|
329
|
+
# @return [void]
|
|
330
|
+
def begin_era_probe
|
|
331
|
+
@era_probe_in_flight = true
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# The probe has been answered (or given up on): the era is now whatever
|
|
335
|
+
# protocol_version says.
|
|
336
|
+
# @return [void]
|
|
337
|
+
def settle_era_probe
|
|
338
|
+
@era_probe_in_flight = false
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# @return [Boolean] whether protocol_version is only a proposal so far
|
|
342
|
+
def era_probe_in_flight?
|
|
343
|
+
defined?(@era_probe_in_flight) ? @era_probe_in_flight : false
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Protocol versions a modern server advertised in its DiscoverResult.
|
|
347
|
+
# @return [Array<String>, nil]
|
|
348
|
+
def supported_versions
|
|
349
|
+
defined?(@supported_versions) ? @supported_versions : nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Pick the newest modern version this client speaks from a server's
|
|
353
|
+
# advertised list (DiscoverResult.supportedVersions or
|
|
354
|
+
# UnsupportedProtocolVersionError.data.supported).
|
|
355
|
+
# @param supported [Array<String>, nil] versions the server supports
|
|
356
|
+
# @return [String, nil] the chosen version, nil when none is mutual
|
|
357
|
+
def select_protocol_version(supported)
|
|
358
|
+
return nil unless supported.is_a?(Array)
|
|
359
|
+
|
|
360
|
+
MCPClient::MODERN_PROTOCOL_VERSIONS.find { |version| supported.include?(version) }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Host-supplied metadata merged into every request's `_meta`: a Hash, or
|
|
364
|
+
# a callable returning one, evaluated per request. Intended for
|
|
365
|
+
# OpenTelemetry trace context (`traceparent`, `tracestate`, `baggage`)
|
|
366
|
+
# and vendor-prefixed keys. Reserved protocol fields cannot be
|
|
367
|
+
# overridden through it.
|
|
368
|
+
# @return [Hash, #call, nil]
|
|
369
|
+
attr_accessor :request_meta
|
|
370
|
+
|
|
371
|
+
# Whether to identify this client on every request via
|
|
372
|
+
# `io.modelcontextprotocol/clientInfo` (MCP 2026-07-28: clients SHOULD,
|
|
373
|
+
# "unless specifically configured not to do so").
|
|
374
|
+
# @param value [Boolean]
|
|
375
|
+
attr_writer :send_client_info
|
|
376
|
+
|
|
377
|
+
# @return [Boolean] whether clientInfo is sent (default true)
|
|
378
|
+
def send_client_info?
|
|
379
|
+
!(defined?(@send_client_info) && @send_client_info == false)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Declare support for an MCP extension (basic/versioning "Extension
|
|
383
|
+
# Negotiation"): advertised under `clientCapabilities.extensions` on
|
|
384
|
+
# every modern request.
|
|
385
|
+
# @param identifier [String] the extension id, e.g. 'io.modelcontextprotocol/tasks'
|
|
386
|
+
# @param settings [Hash] per-extension settings ({} = support, no settings)
|
|
387
|
+
# @return [void]
|
|
388
|
+
# @raise [ArgumentError] if the identifier lacks the mandatory prefix
|
|
389
|
+
def declare_extension(identifier, settings = {})
|
|
390
|
+
unless identifier.is_a?(String) && identifier.match?(EXTENSION_ID_PATTERN)
|
|
391
|
+
raise ArgumentError, "Extension identifier #{identifier.inspect} must have a dotted prefix and a slash " \
|
|
392
|
+
'(e.g. io.modelcontextprotocol/tasks)'
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
settings = {} if settings.nil?
|
|
396
|
+
unless settings.is_a?(Hash)
|
|
397
|
+
raise ArgumentError, "Extension settings for #{identifier} must be an object (Hash), got #{settings.class}"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# An extension that adds a result type is advertised only by a client
|
|
401
|
+
# that can accept that result type: a server told the extension is
|
|
402
|
+
# negotiated may answer with it, and an unrecognized resultType is an
|
|
403
|
+
# invalid response — the usable answer would be lost.
|
|
404
|
+
added = RESULT_TYPE_EXTENSIONS[identifier]
|
|
405
|
+
if added && !implemented_extension_result_types.key?(identifier)
|
|
406
|
+
raise ArgumentError,
|
|
407
|
+
"Extension #{identifier} adds the result type #{added.inspect}, which this client does not implement"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
@declared_extensions ||= {}
|
|
411
|
+
@declared_extensions[identifier] = settings
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# @return [Hash] declared extension id => settings
|
|
415
|
+
def declared_extensions
|
|
416
|
+
defined?(@declared_extensions) && @declared_extensions ? @declared_extensions : {}
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Attach request-level `_meta` to a params object: the host's
|
|
420
|
+
# request_meta defaults first, then any per-request `_meta` the caller
|
|
421
|
+
# supplied (which wins over the defaults), then — for a modern server —
|
|
422
|
+
# the reserved protocol fields, which always win. Params are returned
|
|
423
|
+
# untouched when there is nothing to add, so legacy traffic is unchanged.
|
|
424
|
+
# @param params [Hash, nil] request params (String or Symbol keys)
|
|
425
|
+
# @return [Hash, nil] params with `_meta` merged under the String key
|
|
426
|
+
def with_request_meta(params, claim: :none)
|
|
427
|
+
params = merge_meta_spellings(params)
|
|
428
|
+
defaults = host_request_meta(claim)
|
|
429
|
+
if defaults.empty? && !modern? && !reserved_meta_supplied?(params)
|
|
430
|
+
# Legacy traffic is passed through untouched — a `_meta` the caller
|
|
431
|
+
# supplied goes out as it stands, unless it names a transport-owned key.
|
|
432
|
+
warn_request_log_level_deprecated(params.is_a?(Hash) ? (params['_meta'] || params[:_meta]) : nil)
|
|
433
|
+
return params
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
params = params.is_a?(Hash) ? params.dup : {}
|
|
437
|
+
supplied = params.delete('_meta')
|
|
438
|
+
supplied = supplied.is_a?(Hash) ? supplied.transform_keys(&:to_s) : {}
|
|
439
|
+
# The reserved protocol fields are transport-owned in per-call `_meta`
|
|
440
|
+
# exactly as they are in request_meta. Merging the transport's own
|
|
441
|
+
# values over the caller's is not enough: a field the transport omits
|
|
442
|
+
# (clientInfo, once the host set send_client_info = false) has nothing
|
|
443
|
+
# to overwrite the caller's value with, so it would be transmitted
|
|
444
|
+
# anyway. Drop them before the defaults are merged.
|
|
445
|
+
supplied = supplied.except(*PROTECTED_META_KEYS)
|
|
446
|
+
|
|
447
|
+
meta = defaults.merge(supplied)
|
|
448
|
+
if modern?
|
|
449
|
+
meta[META_LOG_LEVEL] = @log_level if defined?(@log_level) && @log_level && !meta.key?(META_LOG_LEVEL)
|
|
450
|
+
meta.merge!(required_request_meta)
|
|
451
|
+
end
|
|
452
|
+
# The request carries a copy, never the host's own objects. `request_meta`
|
|
453
|
+
# is read from whatever the host keeps -- a string it may rewrite in
|
|
454
|
+
# place, a container it may add to -- and a merge is shallow, so a
|
|
455
|
+
# request built from it would otherwise go on changing after it was
|
|
456
|
+
# built: the body one fingerprint describes is not the body the next
|
|
457
|
+
# one does, and neither need be the body that was sent (MCP 2026-07-28
|
|
458
|
+
# server/utilities/caching: a result is bound to the parameters of the
|
|
459
|
+
# request that produced it).
|
|
460
|
+
params['_meta'] = MCPClient::DeepCopy.copy(meta)
|
|
461
|
+
warn_request_log_level_deprecated(meta)
|
|
462
|
+
params
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# A caller's `_meta` supplied under the Symbol key, or under both
|
|
466
|
+
# spellings, becomes one String-keyed `_meta` (the String one winning on
|
|
467
|
+
# a clash). Two spellings would otherwise serialize as two `_meta`
|
|
468
|
+
# members — and whatever was stripped from one copy would reach the wire
|
|
469
|
+
# through the other, since only one is inspected.
|
|
470
|
+
# @param params [Hash, nil] request params
|
|
471
|
+
# @return [Hash, nil] params with at most one `_meta` member, under the String key
|
|
472
|
+
def merge_meta_spellings(params)
|
|
473
|
+
return params unless params.is_a?(Hash) && params.key?(:_meta)
|
|
474
|
+
|
|
475
|
+
params = params.dup
|
|
476
|
+
symbol_meta = params.delete(:_meta)
|
|
477
|
+
string_meta = params['_meta']
|
|
478
|
+
symbol_meta = symbol_meta.is_a?(Hash) ? symbol_meta.transform_keys(&:to_s) : {}
|
|
479
|
+
string_meta = string_meta.is_a?(Hash) ? string_meta.transform_keys(&:to_s) : {}
|
|
480
|
+
params['_meta'] = symbol_meta.merge(string_meta)
|
|
481
|
+
params
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Whether a caller's params carry a `_meta` key the transport owns.
|
|
485
|
+
#
|
|
486
|
+
# A legacy request with no host defaults has nothing to merge and no
|
|
487
|
+
# protocol fields to add, so it is otherwise handed on untouched — but the
|
|
488
|
+
# reserved keys are the client's to set in every era. A dual-era server
|
|
489
|
+
# reads a request carrying modern per-request `_meta` AS a modern request
|
|
490
|
+
# (basic/versioning), so leaving a caller's copy on the wire would have
|
|
491
|
+
# one call served statelessly while this session goes on believing it
|
|
492
|
+
# negotiated 2025-11-25.
|
|
493
|
+
# @param params [Hash, nil] request params
|
|
494
|
+
# @return [Boolean]
|
|
495
|
+
def reserved_meta_supplied?(params)
|
|
496
|
+
return false unless params.is_a?(Hash)
|
|
497
|
+
|
|
498
|
+
supplied = params['_meta'] || params[:_meta]
|
|
499
|
+
return false unless supplied.is_a?(Hash)
|
|
500
|
+
|
|
501
|
+
supplied.any? { |key, _| PROTECTED_META_KEYS.include?(key.to_s) }
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# The reserved per-request protocol fields for a modern server
|
|
505
|
+
# (basic/index "Per-request protocol fields").
|
|
506
|
+
# @return [Hash]
|
|
507
|
+
def required_request_meta
|
|
508
|
+
meta = { META_PROTOCOL_VERSION => protocol_version }
|
|
509
|
+
meta[META_CLIENT_INFO] = client_info_payload if send_client_info?
|
|
510
|
+
meta[META_CLIENT_CAPABILITIES] = client_capabilities
|
|
511
|
+
meta
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# The host's request_meta for one message, with any reserved protocol
|
|
515
|
+
# keys it tries to set dropped.
|
|
516
|
+
#
|
|
517
|
+
# A message that claims the open operation's reservation reads the
|
|
518
|
+
# evaluation held for it (making it, the first time, and holding it);
|
|
519
|
+
# `:spend` marks it spent, so the request it was held for carries it and
|
|
520
|
+
# nothing else ever does. `:none` reads the host afresh and leaves the
|
|
521
|
+
# reservation alone -- a host callable that vends a one-time value is
|
|
522
|
+
# never spent twice, and never on the wrong request.
|
|
523
|
+
# @param claim [Symbol] :spend, :model or :none
|
|
524
|
+
# @return [Hash] String-keyed metadata (possibly empty)
|
|
525
|
+
def host_request_meta(claim = :none)
|
|
526
|
+
held = claim == :none ? nil : claimable_request_meta_hold
|
|
527
|
+
return spend_held_request_meta(held, claim) if held&.evaluated
|
|
528
|
+
|
|
529
|
+
source = request_meta
|
|
530
|
+
source = source.call if source.respond_to?(:call)
|
|
531
|
+
meta = source.is_a?(Hash) ? source.transform_keys(&:to_s).except(*PROTECTED_META_KEYS) : {}
|
|
532
|
+
return meta unless held
|
|
533
|
+
|
|
534
|
+
held.evaluated = true
|
|
535
|
+
held.value = meta
|
|
536
|
+
spend_held_request_meta(held, claim)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# @param held [MCPClient::RequestMetadata::HeldRequestMeta]
|
|
540
|
+
# @param claim [Symbol]
|
|
541
|
+
# @return [Hash] the held evaluation
|
|
542
|
+
def spend_held_request_meta(held, claim)
|
|
543
|
+
held.spent = true if claim == :spend
|
|
544
|
+
held.value
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Apply a DiscoverResult (server/discover): choose the protocol version
|
|
548
|
+
# for subsequent requests and record the server's capabilities,
|
|
549
|
+
# identity and instructions.
|
|
550
|
+
# @param result [Hash] the DiscoverResult
|
|
551
|
+
# @return [Hash] the result
|
|
552
|
+
# @raise [MCPClient::Errors::ConnectionError] if the result is malformed or no version is mutual
|
|
553
|
+
def apply_discover_result(result)
|
|
554
|
+
unless result.is_a?(Hash)
|
|
555
|
+
raise MCPClient::Errors::ConnectionError, "Server returned an invalid server/discover result (#{result.class})"
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
reject_input_required_discover!(result)
|
|
559
|
+
reject_task_result_discover!(result)
|
|
560
|
+
versions = result['supportedVersions']
|
|
561
|
+
unless versions.is_a?(Array) && versions.all?(String)
|
|
562
|
+
raise MCPClient::Errors::ConnectionError, 'server/discover result has no supportedVersions list'
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
version = select_protocol_version(versions)
|
|
566
|
+
unless version
|
|
567
|
+
# A DiscoverResult settles the era even when it settles no version:
|
|
568
|
+
# only a modern server answers server/discover with one. Raising the
|
|
569
|
+
# typed error keeps MCPClient.connect from trying the legacy
|
|
570
|
+
# transports, which cannot do better against a modern server.
|
|
571
|
+
raise MCPClient::Errors::ModernServerError,
|
|
572
|
+
"Server supports protocol versions #{versions.join(', ')}, none of which this client speaks " \
|
|
573
|
+
"(modern versions supported: #{MCPClient::MODERN_PROTOCOL_VERSIONS.join(', ')})"
|
|
574
|
+
end
|
|
575
|
+
# Everything is checked before anything is recorded: a refresh that
|
|
576
|
+
# fails to validate changes nothing, not even the identity it carried.
|
|
577
|
+
capabilities = result['capabilities']
|
|
578
|
+
unless capabilities.nil? || capabilities.is_a?(Hash)
|
|
579
|
+
raise MCPClient::Errors::ConnectionError, 'server/discover result capabilities is not an object'
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
meta = result['_meta']
|
|
583
|
+
unless meta.nil? || meta.is_a?(Hash)
|
|
584
|
+
raise MCPClient::Errors::ConnectionError, 'server/discover result _meta is not an object'
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
@protocol_version = version
|
|
588
|
+
@supported_versions = versions
|
|
589
|
+
@last_discover_result = result
|
|
590
|
+
entry = record_cache_hint(:discover, result)
|
|
591
|
+
@capabilities = capabilities || {}
|
|
592
|
+
@instructions = result['instructions']
|
|
593
|
+
info = meta && meta[META_SERVER_INFO]
|
|
594
|
+
@server_info = info if info.is_a?(Hash)
|
|
595
|
+
record_discovery_freshness(result, entry)
|
|
596
|
+
result
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Record a DiscoverResult's cache hints (CacheableResult: ttlMs,
|
|
600
|
+
# cacheScope). A ttlMs of zero means the result is immediately stale.
|
|
601
|
+
# @param result [Hash] the DiscoverResult
|
|
602
|
+
# @param entry [MCPClient::CachedResult, nil] the cache entry this result was recorded as
|
|
603
|
+
# @return [void]
|
|
604
|
+
def record_discovery_freshness(result, entry = nil)
|
|
605
|
+
# One reading of ttlMs for every cached result, on one clock, so
|
|
606
|
+
# cache_info(:discover) and this decision never disagree: a JSON number
|
|
607
|
+
# of milliseconds; zero is immediately stale, and a negative, absent or
|
|
608
|
+
# malformed hint is treated as zero (a DiscoverResult without a hint is
|
|
609
|
+
# re-read on the next access that needs it).
|
|
610
|
+
#
|
|
611
|
+
# It runs from the RECEIPT of the response, which is what the entry was
|
|
612
|
+
# dated by ("fresh for that many milliseconds" after it arrived), not
|
|
613
|
+
# from this moment: everything between the response arriving and the
|
|
614
|
+
# client getting round to applying it — a notification delivered off
|
|
615
|
+
# the same stream, host middleware, a slow parse — is time the result
|
|
616
|
+
# has already spent, not time it is owed.
|
|
617
|
+
received = entry&.received_at || discovery_clock
|
|
618
|
+
@discovery_expires_at = received + (MCPClient::CachedResult.normalize_ttl(result['ttlMs']) / 1000.0)
|
|
619
|
+
scope = result['cacheScope']
|
|
620
|
+
@discovery_cache_scope = scope.is_a?(String) ? scope : nil
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# @return [Float] the monotonic clock, in seconds, discovery freshness is
|
|
624
|
+
# judged by: the transport's own when it has one (the one its cache
|
|
625
|
+
# entries are dated by), the process clock otherwise
|
|
626
|
+
def discovery_clock
|
|
627
|
+
return monotonic_now if respond_to?(:monotonic_now, true)
|
|
628
|
+
|
|
629
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Whether the last DiscoverResult is still fresh by its own ttlMs. A
|
|
633
|
+
# session that recorded no discovery at all (a 2025-11-25 one) has
|
|
634
|
+
# nothing to refresh.
|
|
635
|
+
# @return [Boolean]
|
|
636
|
+
def discovery_fresh?
|
|
637
|
+
deadline = defined?(@discovery_expires_at) ? @discovery_expires_at : nil
|
|
638
|
+
deadline.nil? || discovery_clock < deadline
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# @return [String, nil] the cacheScope the last DiscoverResult declared
|
|
642
|
+
def discovery_cache_scope
|
|
643
|
+
defined?(@discovery_cache_scope) ? @discovery_cache_scope : nil
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Validate a log level name (logging utility levels).
|
|
647
|
+
# @param level [String, Symbol] the level
|
|
648
|
+
# @return [String] the normalized level
|
|
649
|
+
# @raise [ArgumentError] if it is not a defined level
|
|
650
|
+
def validate_log_level!(level)
|
|
651
|
+
name = level.to_s
|
|
652
|
+
return name if LOG_LEVELS.include?(name)
|
|
653
|
+
|
|
654
|
+
raise ArgumentError, "Unknown log level #{level.inspect}; expected one of #{LOG_LEVELS.join(', ')}"
|
|
655
|
+
end
|
|
656
|
+
|
|
173
657
|
# Build a JSON-RPC notification object (no response expected)
|
|
174
658
|
# @param method [String] JSON-RPC method name
|
|
175
659
|
# @param params [Hash] parameters for the notification
|
|
176
660
|
# @return [Hash] the JSON-RPC notification object
|
|
177
661
|
def build_jsonrpc_notification(method, params)
|
|
662
|
+
# A notification is never the request a cache decision was made for: it
|
|
663
|
+
# reads the host afresh and leaves the reservation for that request.
|
|
664
|
+
effective = with_request_meta(params, claim: :none)
|
|
178
665
|
{
|
|
179
666
|
'jsonrpc' => '2.0',
|
|
180
667
|
'method' => method,
|
|
181
|
-
|
|
668
|
+
# Modern notifications carry the same _meta as requests: on HTTP the
|
|
669
|
+
# MCP-Protocol-Version header must match the body.
|
|
670
|
+
'params' => effective
|
|
182
671
|
}
|
|
183
672
|
end
|
|
184
673
|
|
|
674
|
+
# The protocol version in use with this server, once established: chosen
|
|
675
|
+
# via server/discover for a modern server or negotiated by initialize for
|
|
676
|
+
# a legacy one. nil until then.
|
|
677
|
+
# @return [String, nil]
|
|
678
|
+
def protocol_version
|
|
679
|
+
defined?(@protocol_version) ? @protocol_version : nil
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Whether this server speaks a modern (per-request metadata, no
|
|
683
|
+
# handshake) protocol revision (MCP 2026-07-28 basic/versioning
|
|
684
|
+
# "Terminology"). false until the era is established.
|
|
685
|
+
# @return [Boolean]
|
|
686
|
+
def modern?
|
|
687
|
+
MCPClient::MODERN_PROTOCOL_VERSIONS.include?(protocol_version)
|
|
688
|
+
end
|
|
689
|
+
|
|
185
690
|
# Generate initialization parameters for MCP protocol
|
|
186
691
|
# @return [Hash] the initialization parameters
|
|
187
692
|
def initialization_params
|
|
693
|
+
# Extension negotiation is a 2026-07-28 mechanism (basic/versioning
|
|
694
|
+
# "Extension Negotiation", carried in every modern request's _meta):
|
|
695
|
+
# the 2025-11-25 handshake has no such capability, and an extension
|
|
696
|
+
# defined for 2026-07-28 (the tasks extension, say) is not advertised
|
|
697
|
+
# to a server that negotiates the legacy protocol.
|
|
698
|
+
capabilities = client_capabilities
|
|
699
|
+
capabilities.delete('extensions')
|
|
188
700
|
{
|
|
189
701
|
'protocolVersion' => MCPClient::PROTOCOL_VERSION,
|
|
190
|
-
'capabilities' =>
|
|
702
|
+
'capabilities' => capabilities,
|
|
191
703
|
'clientInfo' => client_info_payload
|
|
192
704
|
}
|
|
193
705
|
end
|
|
@@ -202,7 +714,9 @@ module MCPClient
|
|
|
202
714
|
# @raise [MCPClient::Errors::ConnectionError] if the version is unsupported
|
|
203
715
|
def validate_protocol_version!(result)
|
|
204
716
|
version = result['protocolVersion']
|
|
205
|
-
|
|
717
|
+
# Only handshake-based revisions are valid here: a server answering
|
|
718
|
+
# initialize with a modern (per-request metadata) version is confused.
|
|
719
|
+
return version if MCPClient::LEGACY_PROTOCOL_VERSIONS.include?(version)
|
|
206
720
|
|
|
207
721
|
begin
|
|
208
722
|
cleanup if respond_to?(:cleanup)
|
|
@@ -211,7 +725,7 @@ module MCPClient
|
|
|
211
725
|
end
|
|
212
726
|
raise MCPClient::Errors::ConnectionError,
|
|
213
727
|
"Server negotiated unsupported protocol version #{version.inspect} " \
|
|
214
|
-
"(supported: #{MCPClient::
|
|
728
|
+
"(supported: #{MCPClient::LEGACY_PROTOCOL_VERSIONS.join(', ')}); disconnecting"
|
|
215
729
|
end
|
|
216
730
|
|
|
217
731
|
# The Implementation object sent as clientInfo: the host-provided info
|
|
@@ -232,17 +746,27 @@ module MCPClient
|
|
|
232
746
|
# @return [Hash] the capabilities object for the initialize request
|
|
233
747
|
def client_capabilities
|
|
234
748
|
capabilities = {}
|
|
749
|
+
# On a modern server these features are served through the multi
|
|
750
|
+
# round-trip pattern (InputRequiredResult), on a legacy one through
|
|
751
|
+
# server-initiated requests; either way they are declared only when
|
|
752
|
+
# the host registered a handler, since the server MUST NOT ask for
|
|
753
|
+
# what the client did not declare.
|
|
235
754
|
if registered_callback?(:@elicitation_request_callback)
|
|
236
755
|
# Both defined elicitation modes are implemented (an empty object
|
|
237
756
|
# would mean form-only per the spec's backwards-compatibility rule).
|
|
238
757
|
capabilities['elicitation'] = { 'form' => {}, 'url' => {} }
|
|
239
758
|
end
|
|
240
|
-
|
|
759
|
+
if registered_callback?(:@roots_list_request_callback)
|
|
760
|
+
# notifications/roots/list_changed was removed in 2026-07-28, so the
|
|
761
|
+
# modern roots capability has no listChanged flag.
|
|
762
|
+
capabilities['roots'] = modern? ? {} : { 'listChanged' => true }
|
|
763
|
+
end
|
|
241
764
|
if registered_callback?(:@sampling_request_callback)
|
|
242
765
|
# SEP-1577: servers may only send tool-enabled sampling requests when
|
|
243
766
|
# the client declares the sampling.tools sub-capability.
|
|
244
767
|
capabilities['sampling'] = sampling_tools_supported? ? { 'tools' => {} } : {}
|
|
245
768
|
end
|
|
769
|
+
capabilities['extensions'] = declared_extensions.dup unless declared_extensions.empty?
|
|
246
770
|
# NOTE: we intentionally do NOT declare a client `tasks` capability. That
|
|
247
771
|
# capability marks the client as a RECEIVER of task-augmented
|
|
248
772
|
# sampling/elicitation requests, which is not implemented here — this
|
|
@@ -256,6 +780,13 @@ module MCPClient
|
|
|
256
780
|
# before connect so the initialize request advertises it; it only takes
|
|
257
781
|
# effect when a sampling request callback is also registered, since
|
|
258
782
|
# sampling.tools is a sub-capability of sampling.
|
|
783
|
+
#
|
|
784
|
+
# @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577);
|
|
785
|
+
# earliest removal is the first revision released on or after
|
|
786
|
+
# 2027-07-28, and this sub-capability goes with the capability it
|
|
787
|
+
# refines. Declaring it raises no notice of its own — serving a
|
|
788
|
+
# sampling/createMessage request does. Integrate directly with the LLM
|
|
789
|
+
# provider API instead.
|
|
259
790
|
# @return [void]
|
|
260
791
|
def declare_sampling_tools
|
|
261
792
|
@sampling_tools_supported = true
|
|
@@ -272,14 +803,370 @@ module MCPClient
|
|
|
272
803
|
instance_variable_defined?(:@sampling_tools_supported) && @sampling_tools_supported
|
|
273
804
|
end
|
|
274
805
|
|
|
806
|
+
# SEP-1577 (schema.ts CreateMessageRequestParams.tools/.toolChoice): "The
|
|
807
|
+
# client MUST return an error if this field is provided but
|
|
808
|
+
# ClientCapabilities.sampling.tools is not declared." The 2025-11-25
|
|
809
|
+
# server-initiated path refuses here, before any handler sees the request,
|
|
810
|
+
# with the Invalid params code sampling.mdx § Error Handling uses; the
|
|
811
|
+
# multi round-trip path refuses the same way in InputRoundTrips.
|
|
812
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
813
|
+
# @param params [Hash] the sampling/createMessage params
|
|
814
|
+
# @return [Boolean] true when the request was refused (and answered)
|
|
815
|
+
def refused_undeclared_sampling_tools?(request_id, params)
|
|
816
|
+
return false unless undeclared_sampling_tool_use?('sampling/createMessage', params)
|
|
817
|
+
|
|
818
|
+
# The line is a courtesy to the host; the refusal is the answer the peer
|
|
819
|
+
# is owed. A logger that fails here must not turn Invalid params into
|
|
820
|
+
# the dispatcher's Internal error.
|
|
821
|
+
begin
|
|
822
|
+
@logger.warn('Rejecting tool-enabled sampling request: sampling.tools capability not declared')
|
|
823
|
+
rescue StandardError
|
|
824
|
+
nil
|
|
825
|
+
end
|
|
826
|
+
send_error_response(request_id, -32_602,
|
|
827
|
+
'Invalid params: tools/toolChoice provided but the sampling.tools ' \
|
|
828
|
+
'capability was not declared')
|
|
829
|
+
true
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Result types defined by the core protocol (basic/index.mdx "ResultType").
|
|
833
|
+
# Extensions add more (e.g. "task"); the accepted set widens with the
|
|
834
|
+
# declared extensions this client implements (#accepted_result_types).
|
|
835
|
+
CORE_RESULT_TYPES = %w[complete input_required].freeze
|
|
836
|
+
|
|
837
|
+
# The result type each known result-type-adding extension introduces. A
|
|
838
|
+
# client advertises one of these only when it implements it (see
|
|
839
|
+
# #implemented_extension_result_types).
|
|
840
|
+
RESULT_TYPE_EXTENSIONS = { 'io.modelcontextprotocol/tasks' => 'task' }.freeze
|
|
841
|
+
|
|
842
|
+
# The only result type a handshake-era (legacy) server can validly send:
|
|
843
|
+
# the others were introduced with the discriminator itself.
|
|
844
|
+
LEGACY_RESULT_TYPES = %w[complete].freeze
|
|
845
|
+
|
|
846
|
+
# The MCP 2026-07-28 tasks extension (extensions/tasks): once declared in
|
|
847
|
+
# the per-request clientCapabilities, a server MAY answer a supported
|
|
848
|
+
# request with a CreateTaskResult (resultType "task").
|
|
849
|
+
TASKS_EXTENSION = 'io.modelcontextprotocol/tasks'
|
|
850
|
+
|
|
851
|
+
# Requests the tasks extension allows a CreateTaskResult for. "A client
|
|
852
|
+
# that receives CreateTaskResult in response to an unsupported request
|
|
853
|
+
# type MUST interpret this as an invalid response".
|
|
854
|
+
TASK_METHODS = %w[tools/call].freeze
|
|
855
|
+
|
|
856
|
+
# @return [Boolean] whether the host declared the tasks extension
|
|
857
|
+
def tasks_extension_declared?
|
|
858
|
+
declared_extensions.key?(TASKS_EXTENSION)
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
# The result types this client implements on top of the core ones, per
|
|
862
|
+
# extension: declaring the tasks extension makes a CreateTaskResult
|
|
863
|
+
# (resultType "task") an accepted answer on the requests it allows.
|
|
864
|
+
# @return [Hash{String => Array<String>}]
|
|
865
|
+
def implemented_extension_result_types
|
|
866
|
+
{ TASKS_EXTENSION => ['task'] }
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
# The resultType of a result object. MCP 2026-07-28 makes the field
|
|
870
|
+
# required, but "for backward compatibility with servers implementing
|
|
871
|
+
# earlier protocol versions, which do not include resultType, clients
|
|
872
|
+
# MUST treat an absent resultType as 'complete'". Non-object results
|
|
873
|
+
# (lenient handling of older servers) are likewise complete.
|
|
874
|
+
# @param result [Object] a JSON-RPC result
|
|
875
|
+
# @return [Object] the resultType value, 'complete' when absent
|
|
876
|
+
def self.result_type(result)
|
|
877
|
+
return 'complete' unless result.is_a?(Hash)
|
|
878
|
+
return result['resultType'] if result.key?('resultType')
|
|
879
|
+
return result[:resultType] if result.key?(:resultType)
|
|
880
|
+
|
|
881
|
+
'complete'
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
# Restore the wire spelling of a peer's own JSON object. JSON object keys
|
|
885
|
+
# are always strings, but a host's response middleware may symbolize the
|
|
886
|
+
# keys of everything it parses (Faraday's :json parser with
|
|
887
|
+
# symbolize_names) — the middleware ::result_type already tolerates for
|
|
888
|
+
# the resultType discriminator. Undoing it once, on the protocol object
|
|
889
|
+
# about to be read, keeps every lookup below (and the params the input
|
|
890
|
+
# handlers see) on the shape the protocol defines. Values are returned
|
|
891
|
+
# untouched, so an opaque requestState is still echoed verbatim.
|
|
892
|
+
# @param value [Object] a parsed JSON value
|
|
893
|
+
# @return [Object] the same value with Symbol keys spelled as Strings
|
|
894
|
+
def self.restore_wire_keys(value)
|
|
895
|
+
case value
|
|
896
|
+
when Hash
|
|
897
|
+
value.to_h { |key, member| [key.is_a?(Symbol) ? key.to_s : key, restore_wire_keys(member)] }
|
|
898
|
+
when Array
|
|
899
|
+
value.map { |member| restore_wire_keys(member) }
|
|
900
|
+
else
|
|
901
|
+
value
|
|
902
|
+
end
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
# Result types this transport accepts. Overridden (widened) by transports
|
|
906
|
+
# that negotiated a result-type-adding extension.
|
|
907
|
+
# @return [Array<String>]
|
|
908
|
+
def accepted_result_types
|
|
909
|
+
# input_required names the multi round-trip pattern, which exists only
|
|
910
|
+
# in modern revisions: a handshake-era server answering with it is
|
|
911
|
+
# malformed, and treating it as valid would let a wrapper flatten an
|
|
912
|
+
# unfinished result into an empty successful one.
|
|
913
|
+
return LEGACY_RESULT_TYPES unless modern?
|
|
914
|
+
|
|
915
|
+
extra = implemented_extension_result_types.select { |id, _| declared_extensions.key?(id) }.values.flatten
|
|
916
|
+
extra.empty? ? CORE_RESULT_TYPES : (CORE_RESULT_TYPES + extra).uniq.freeze
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
# Which request field mirrors into the Mcp-Name header (MCP 2026-07-28
|
|
920
|
+
# Streamable HTTP "Standard Request Headers"; the tasks extension adds
|
|
921
|
+
# taskId routing for its methods).
|
|
922
|
+
NAME_HEADER_SOURCES = {
|
|
923
|
+
'tools/call' => 'name',
|
|
924
|
+
'prompts/get' => 'name',
|
|
925
|
+
'resources/read' => 'uri',
|
|
926
|
+
'tasks/get' => 'taskId',
|
|
927
|
+
'tasks/update' => 'taskId',
|
|
928
|
+
'tasks/cancel' => 'taskId',
|
|
929
|
+
'tasks/result' => 'taskId'
|
|
930
|
+
}.freeze
|
|
931
|
+
|
|
932
|
+
# Encode a parameter value for an MCP request header (Mcp-Name,
|
|
933
|
+
# Mcp-Param-*): strings as-is when header-safe, integers in decimal,
|
|
934
|
+
# booleans lowercase; anything not safely representable — non-ASCII,
|
|
935
|
+
# control characters, leading/trailing whitespace, an empty string, or a
|
|
936
|
+
# value that looks like the sentinel — as `=?base64?<b64 of UTF-8>?=`.
|
|
937
|
+
# @param value [String, Integer, true, false] the parameter value
|
|
938
|
+
# @return [String] the header value
|
|
939
|
+
def encode_header_value(value)
|
|
940
|
+
MCPClient::HeaderParams.encode_header_value(value)
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# The HTTP headers a modern (2026-07-28) request must carry: the protocol
|
|
944
|
+
# version (matching the body's _meta), the method, and for named
|
|
945
|
+
# requests the name/URI (MCP 2026-07-28 Streamable HTTP "Request
|
|
946
|
+
# Metadata").
|
|
947
|
+
# @param request [Hash] the JSON-RPC request (String keys)
|
|
948
|
+
# @return [Hash{String => String}] header name => value
|
|
949
|
+
def modern_request_headers(request)
|
|
950
|
+
# The version the body was built with, not the transport's current one:
|
|
951
|
+
# a concurrent request may have switched versions in between, and the
|
|
952
|
+
# header MUST match the body's _meta.
|
|
953
|
+
meta = request['params'].is_a?(Hash) ? request['params']['_meta'] : nil
|
|
954
|
+
version = (meta.is_a?(Hash) && meta[META_PROTOCOL_VERSION]) || protocol_version
|
|
955
|
+
headers = { 'MCP-Protocol-Version' => version, 'Mcp-Method' => request['method'].to_s }
|
|
956
|
+
name = mcp_name_header_value(request)
|
|
957
|
+
headers['Mcp-Name'] = name if name
|
|
958
|
+
headers
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# @param request [Hash] the JSON-RPC request
|
|
962
|
+
# @return [String, nil] the encoded Mcp-Name value, or nil when the method has none
|
|
963
|
+
def mcp_name_header_value(request)
|
|
964
|
+
key = NAME_HEADER_SOURCES[request['method']]
|
|
965
|
+
params = request['params']
|
|
966
|
+
return nil unless key && params.is_a?(Hash)
|
|
967
|
+
|
|
968
|
+
value = params.key?(key) ? params[key] : params[key.to_sym]
|
|
969
|
+
return nil if value.nil?
|
|
970
|
+
|
|
971
|
+
encode_header_value(value)
|
|
972
|
+
end
|
|
973
|
+
|
|
275
974
|
# Process JSON-RPC response
|
|
276
975
|
# @param response [Hash] the parsed JSON-RPC response
|
|
277
976
|
# @return [Object] the result field from the response
|
|
278
977
|
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
|
279
|
-
|
|
280
|
-
|
|
978
|
+
# @raise [MCPClient::Errors::InvalidResultError] if the result's resultType is unrecognized
|
|
979
|
+
def process_jsonrpc_response(response, method: nil)
|
|
980
|
+
error = envelope_member(response, 'error')
|
|
981
|
+
raise MCPClient::Errors::ServerError.from_jsonrpc(error) if error
|
|
982
|
+
|
|
983
|
+
result = envelope_member(response, 'result')
|
|
984
|
+
validate_result_type!(result)
|
|
985
|
+
record_server_info(result, method: method)
|
|
986
|
+
result
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# Client requests a server MAY answer with an InputRequiredResult (MCP
|
|
990
|
+
# 2026-07-28 basic/patterns/mrtr "Supported Requests"); on any other
|
|
991
|
+
# request such a result is invalid.
|
|
992
|
+
MRTR_METHODS = %w[tools/call resources/read prompts/get].freeze
|
|
993
|
+
|
|
994
|
+
# Ceiling on consecutive input_required answers to one logical request.
|
|
995
|
+
# Servers MAY keep asking, but an unbounded loop is a hostile server.
|
|
996
|
+
MAX_INPUT_ROUND_TRIPS = 10
|
|
997
|
+
|
|
998
|
+
# Pause before retrying an InputRequiredResult that asked for nothing
|
|
999
|
+
# (requestState only — e.g. a URL-mode elicitation still in progress out
|
|
1000
|
+
# of band). The client MAY retry immediately, but a tight loop would just
|
|
1001
|
+
# burn the round-trip budget; doubles up to the maximum.
|
|
1002
|
+
INPUT_RETRY_DELAY = 0.5
|
|
1003
|
+
INPUT_RETRY_MAX_DELAY = 5
|
|
1004
|
+
|
|
1005
|
+
# Drive a request through the multi round-trip pattern (MCP 2026-07-28
|
|
1006
|
+
# basic/patterns/mrtr): while the server answers with an
|
|
1007
|
+
# InputRequiredResult, fulfil its inputRequests through the registered
|
|
1008
|
+
# handlers and retry the original request — as an independent request
|
|
1009
|
+
# with a new id — carrying inputResponses keyed like the requests and
|
|
1010
|
+
# the opaque requestState echoed verbatim (omitted when the server sent
|
|
1011
|
+
# none). A result without inputRequests asks for nothing this client can
|
|
1012
|
+
# fulfil, so it is retried after a growing pause (INPUT_RETRY_DELAY) that
|
|
1013
|
+
# the host steers through {#on_input_required_wait} and that never runs
|
|
1014
|
+
# past the request timeout: the continuation is handed back instead, on
|
|
1015
|
+
# an error {#resume_input_required} accepts.
|
|
1016
|
+
# @param method [String] the JSON-RPC method
|
|
1017
|
+
# @param params [Hash] the original params
|
|
1018
|
+
# @param timeout [Numeric, nil] per-request timeout, also bounding the waits
|
|
1019
|
+
# @yieldparam params [Hash] params for one attempt (original, or with inputResponses)
|
|
1020
|
+
# @yieldreturn [Object] the attempt's result
|
|
1021
|
+
# @return [Object] the final (complete) result
|
|
1022
|
+
# @raise [MCPClient::Errors::InvalidResultError] input_required on an unsupported method
|
|
1023
|
+
# @raise [MCPClient::Errors::InputRequiredError] when a round trip cannot be fulfilled, is
|
|
1024
|
+
# cancelled or times out, or too many occur — carrying the continuation
|
|
1025
|
+
def resolve_input_round_trips(method, params, timeout = nil)
|
|
1026
|
+
result = yield(params)
|
|
1027
|
+
round_trips = 0
|
|
1028
|
+
delay = INPUT_RETRY_DELAY
|
|
1029
|
+
started = input_wait_clock
|
|
1030
|
+
deadline = input_wait_deadline(started, timeout)
|
|
1031
|
+
while MCPClient::JsonRpcCommon.result_type(result) == 'input_required'
|
|
1032
|
+
# Read on the wire spelling, whatever the transport's JSON middleware
|
|
1033
|
+
# did to the keys: a symbolized inputRequests/requestState would
|
|
1034
|
+
# otherwise be invisible here and the retry would go out with neither
|
|
1035
|
+
# the fulfilled answers nor the state the server MUST get back.
|
|
1036
|
+
result = MCPClient::JsonRpcCommon.restore_wire_keys(result)
|
|
1037
|
+
unless modern? && MRTR_METHODS.include?(method)
|
|
1038
|
+
raise MCPClient::Errors::InvalidResultError.new(
|
|
1039
|
+
"Invalid result: input_required is only valid for #{MRTR_METHODS.join(', ')} " \
|
|
1040
|
+
"on an MCP 2026-07-28 server, not #{method} (#{protocol_version})", data: result
|
|
1041
|
+
)
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
round_trips += 1
|
|
1045
|
+
if round_trips > MAX_INPUT_ROUND_TRIPS
|
|
1046
|
+
raise MCPClient::Errors::InputRequiredError.new(
|
|
1047
|
+
"Server kept requesting input for #{method} after #{MAX_INPUT_ROUND_TRIPS} round trips", data: result
|
|
1048
|
+
)
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
@logger.debug("#{method} requires input (round trip #{round_trips}); fulfilling and retrying")
|
|
1052
|
+
retry_params = retry_params_for(params, result)
|
|
1053
|
+
unless retry_params.key?('inputResponses')
|
|
1054
|
+
now = input_wait_clock
|
|
1055
|
+
wait = InputRequiredWait.new(rpc_method: method, round_trip: round_trips, delay: delay,
|
|
1056
|
+
request_state: result['requestState'], result: result,
|
|
1057
|
+
elapsed: now - started)
|
|
1058
|
+
delay = pace_input_round_trip(wait, deadline)
|
|
1059
|
+
end
|
|
1060
|
+
result = yield(retry_params)
|
|
1061
|
+
end
|
|
1062
|
+
mark_round_trip_result(round_trips.positive?)
|
|
1063
|
+
reject_task_result_on_unsupported_method!(method, result)
|
|
1064
|
+
result
|
|
1065
|
+
rescue MCPClient::Errors::InputRequiredError => e
|
|
1066
|
+
# Every failure of the round trip hands the continuation back: the
|
|
1067
|
+
# request it was driving, for #resume_input_required.
|
|
1068
|
+
e.request_method ||= method
|
|
1069
|
+
e.request_params ||= params
|
|
1070
|
+
e.transport ||= self
|
|
1071
|
+
raise
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# A CreateTaskResult is only a valid answer to the request types the
|
|
1075
|
+
# tasks extension covers (TASK_METHODS); anywhere else it is an invalid
|
|
1076
|
+
# response (extensions/tasks "Capability Negotiation").
|
|
1077
|
+
# @param method [String] the JSON-RPC method
|
|
1078
|
+
# @param result [Object] the final result
|
|
1079
|
+
# @return [void]
|
|
1080
|
+
# @raise [MCPClient::Errors::InvalidResultError]
|
|
1081
|
+
def reject_task_result_on_unsupported_method!(method, result)
|
|
1082
|
+
return unless MCPClient::JsonRpcCommon.result_type(result) == 'task'
|
|
1083
|
+
return if TASK_METHODS.include?(method)
|
|
1084
|
+
|
|
1085
|
+
raise MCPClient::Errors::InvalidResultError,
|
|
1086
|
+
"Invalid result: resultType \"task\" is only valid for #{TASK_METHODS.join(', ')}, not #{method}"
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
# server/discover is not one of the request types the tasks extension
|
|
1090
|
+
# covers, so a CreateTaskResult there is invalid and MUST NOT be applied:
|
|
1091
|
+
# the probe would otherwise adopt a protocol version and install
|
|
1092
|
+
# capabilities out of a task creation, and the discovery-shaped members a
|
|
1093
|
+
# non-conforming server bolted onto it would override the discriminator.
|
|
1094
|
+
# The ordinary rejection ({#reject_task_result_on_unsupported_method!})
|
|
1095
|
+
# runs in the round-trip resolver, which discovery does not go through.
|
|
1096
|
+
#
|
|
1097
|
+
# Like the input_required sibling this is a ModernServerError, not an
|
|
1098
|
+
# InvalidResultError: resultType is a 2026-07-28 field, so a server that
|
|
1099
|
+
# answered with one is modern and the era is settled — it must never be
|
|
1100
|
+
# retried with the initialize handshake, nor sent on to the legacy
|
|
1101
|
+
# transports by MCPClient.connect.
|
|
1102
|
+
# @param result [Object] the server/discover result
|
|
1103
|
+
# @return [void]
|
|
1104
|
+
# @raise [MCPClient::Errors::ModernServerError] if the result is a CreateTaskResult
|
|
1105
|
+
def reject_task_result_discover!(result)
|
|
1106
|
+
return unless MCPClient::JsonRpcCommon.result_type(result) == 'task'
|
|
1107
|
+
|
|
1108
|
+
raise MCPClient::Errors::ModernServerError,
|
|
1109
|
+
'Server answered server/discover with a task result; resultType "task" is ' \
|
|
1110
|
+
"only valid for #{TASK_METHODS.join(', ')}"
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
# Notifications the 2026-07-28 revision removed; never written to a
|
|
1114
|
+
# modern server (the roots capability has no listChanged there).
|
|
1115
|
+
REMOVED_MODERN_NOTIFICATIONS = %w[notifications/roots/list_changed notifications/initialized].freeze
|
|
1116
|
+
|
|
1117
|
+
# @param method [String] a notification method
|
|
1118
|
+
# @return [Boolean] whether it must be dropped for a modern server
|
|
1119
|
+
def suppressed_modern_notification?(method)
|
|
1120
|
+
modern? && REMOVED_MODERN_NOTIFICATIONS.include?(method)
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
# Servers SHOULD identify themselves in every result's `_meta`
|
|
1124
|
+
# (`io.modelcontextprotocol/serverInfo`, MCP 2026-07-28); keep the latest
|
|
1125
|
+
# self-reported identity for display and logging.
|
|
1126
|
+
# @param result [Object] a JSON-RPC result
|
|
1127
|
+
# @param method [String, nil] the method the result answers, when known
|
|
1128
|
+
# @return [void]
|
|
1129
|
+
def record_server_info(result, method: nil)
|
|
1130
|
+
return unless result.is_a?(Hash)
|
|
1131
|
+
# A DiscoverResult's identity is recorded by apply_discover_result, once
|
|
1132
|
+
# the WHOLE result has validated: a refresh that fails must change
|
|
1133
|
+
# nothing, not even the identity it carried. Judged by the method the
|
|
1134
|
+
# result answers rather than by the result's own shape — an answer that
|
|
1135
|
+
# is missing supportedVersions is exactly the one apply_discover_result
|
|
1136
|
+
# rejects, and reading the shape recorded its identity first. The shape
|
|
1137
|
+
# still stands in for the method where the caller cannot name it.
|
|
1138
|
+
return if method == 'server/discover' || result.key?('supportedVersions')
|
|
1139
|
+
|
|
1140
|
+
info = result['_meta'].is_a?(Hash) ? result['_meta'][META_SERVER_INFO] : nil
|
|
1141
|
+
@server_info = info if info.is_a?(Hash)
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
# "A resultType of any value unrecognized by the client MUST be
|
|
1145
|
+
# considered invalid" (basic/index.mdx). The value is peer-controlled, so
|
|
1146
|
+
# only its class or a short prefix reaches the exception message.
|
|
1147
|
+
# @param result [Object] a JSON-RPC result
|
|
1148
|
+
# @return [void]
|
|
1149
|
+
# @raise [MCPClient::Errors::InvalidResultError]
|
|
1150
|
+
def validate_result_type!(result)
|
|
1151
|
+
unless result.is_a?(Hash)
|
|
1152
|
+
# A modern result MUST be an object. Legacy servers occasionally
|
|
1153
|
+
# answered list requests with a bare array; keep tolerating that.
|
|
1154
|
+
return unless modern?
|
|
1155
|
+
|
|
1156
|
+
raise MCPClient::Errors::InvalidResultError, "Invalid result: expected an object, got #{result.class}"
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
type = MCPClient::JsonRpcCommon.result_type(result)
|
|
1160
|
+
return if type.is_a?(String) && accepted_result_types.include?(type)
|
|
281
1161
|
|
|
282
|
-
|
|
1162
|
+
shown = type.is_a?(String) ? type[0, 64].inspect : type.class.name
|
|
1163
|
+
# The refused result travels with the error: only a modern server names
|
|
1164
|
+
# a resultType at all, which is how the discovery probe tells a modern
|
|
1165
|
+
# server's unusable answer from a legacy endpoint's.
|
|
1166
|
+
raise MCPClient::Errors::InvalidResultError.new(
|
|
1167
|
+
"Invalid result: unrecognized resultType #{shown} (accepted: #{accepted_result_types.join(', ')})",
|
|
1168
|
+
data: result
|
|
1169
|
+
)
|
|
283
1170
|
end
|
|
284
1171
|
end
|
|
285
1172
|
end
|