Module: MCPClient::JsonRpcCommon

Includes:
DeprecationNotices, InputRoundTrips, Envelopes, ErrorBodies, InputWaits, RequestMetadata, ResultCaching, ResultCompleteness, RoundTripMarker, SessionPin, SubscriptionSupport
Included in:
HttpTransportBase, ServerSSE::JsonRpcTransport, ServerStdio::JsonRpcTransport
Defined in:
lib/mcp_client/json_rpc_common.rb,
lib/mcp_client/json_rpc_common/envelopes.rb,
lib/mcp_client/json_rpc_common/input_waits.rb,
lib/mcp_client/json_rpc_common/error_bodies.rb

Overview

Shared retry/backoff logic for JSON-RPC transports

Defined Under Namespace

Modules: Envelopes, ErrorBodies, InputWaits

Constant Summary collapse

NON_IDEMPOTENT_METHODS =

JSON-RPC methods with arbitrary side effects that MUST NOT be re-sent automatically. Even a "transient" failure (5xx, dropped connection, malformed response) can arrive AFTER the server received the request, so a retry could execute the operation twice — and JSON-RPC has no idempotency key to make the duplicate safe. Callers who want to retry such an operation must decide that explicitly. tasks/update (MCP 2026-07-28 tasks extension) delivers one-shot input responses: a replay could advance a task twice.

%w[tools/call tasks/update].freeze
MAX_PEER_LOG_TEXT_LENGTH =

Maximum characters of peer-supplied text written to the host log.

4096
LOG_LEVELS =

Log levels defined by the logging utility (RFC 5424 severities).

%w[debug info notice warning error critical alert emergency].freeze
EXTENSION_ID_PATTERN =

Extension identifiers follow the _meta key naming rules with a mandatory prefix (basic/versioning "Extension Negotiation"): dotted labels, a slash, then a name. The name is optional — basic/index says of it "Unless empty, MUST begin and end with an alphanumeric character" — so a prefix on its own (com.example/) is a valid identifier.

%r{\A(?:[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)*[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?/
(?:[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)?\z}x
CORE_RESULT_TYPES =

Result types defined by the core protocol (basic/index.mdx "ResultType"). Extensions add more (e.g. "task"); the accepted set widens with the declared extensions this client implements (#accepted_result_types).

%w[complete input_required].freeze
RESULT_TYPE_EXTENSIONS =

The result type each known result-type-adding extension introduces. A client advertises one of these only when it implements it (see #implemented_extension_result_types).

{ 'io.modelcontextprotocol/tasks' => 'task' }.freeze
LEGACY_RESULT_TYPES =

The only result type a handshake-era (legacy) server can validly send: the others were introduced with the discriminator itself.

%w[complete].freeze
TASKS_EXTENSION =

The MCP 2026-07-28 tasks extension (extensions/tasks): once declared in the per-request clientCapabilities, a server MAY answer a supported request with a CreateTaskResult (resultType "task").

'io.modelcontextprotocol/tasks'
TASK_METHODS =

Requests the tasks extension allows a CreateTaskResult for. "A client that receives CreateTaskResult in response to an unsupported request type MUST interpret this as an invalid response".

%w[tools/call].freeze
NAME_HEADER_SOURCES =

Which request field mirrors into the Mcp-Name header (MCP 2026-07-28 Streamable HTTP "Standard Request Headers"; the tasks extension adds taskId routing for its methods).

{
  'tools/call' => 'name',
  'prompts/get' => 'name',
  'resources/read' => 'uri',
  'tasks/get' => 'taskId',
  'tasks/update' => 'taskId',
  'tasks/cancel' => 'taskId',
  'tasks/result' => 'taskId'
}.freeze
MRTR_METHODS =

Client requests a server MAY answer with an InputRequiredResult (MCP 2026-07-28 basic/patterns/mrtr "Supported Requests"); on any other request such a result is invalid.

%w[tools/call resources/read prompts/get].freeze
MAX_INPUT_ROUND_TRIPS =

Ceiling on consecutive input_required answers to one logical request. Servers MAY keep asking, but an unbounded loop is a hostile server.

10
INPUT_RETRY_DELAY =

Pause before retrying an InputRequiredResult that asked for nothing (requestState only — e.g. a URL-mode elicitation still in progress out of band). The client MAY retry immediately, but a tight loop would just burn the round-trip budget; doubles up to the maximum.

0.5
INPUT_RETRY_MAX_DELAY =
5
REMOVED_MODERN_NOTIFICATIONS =

Notifications the 2026-07-28 revision removed; never written to a modern server (the roots capability has no listChanged there).

%w[notifications/roots/list_changed notifications/initialized].freeze

Constants included from SessionPin

SessionPin::SESSION_PINS, SessionPin::WRITE_GUARDS

Constants included from RequestMetadata

RequestMetadata::CACHE_NEUTRAL_META_KEYS, RequestMetadata::META_CLIENT_CAPABILITIES, RequestMetadata::META_CLIENT_INFO, RequestMetadata::META_LOG_LEVEL, RequestMetadata::META_PROTOCOL_VERSION, RequestMetadata::META_SERVER_INFO, RequestMetadata::META_SUBSCRIPTION_ID, RequestMetadata::OPAQUE_PARAMS, RequestMetadata::PROTECTED_META_KEYS, RequestMetadata::UNRECORDED_PARAMS

Constants included from ResultCaching

ResultCaching::CACHE_INIT_LOCK, ResultCaching::LEGACY_ENTRY, ResultCaching::LIST_CHANGE_NOTIFICATIONS, ResultCaching::LIST_METHOD_KINDS, ResultCaching::LIST_VALUE_KINDS, ResultCaching::MAX_CACHED_READS, ResultCaching::MAX_READ_GENERATIONS, ResultCaching::PLACEHOLDER_KINDS

Constants included from InputRoundTrips

InputRoundTrips::INPUT_REQUEST_HANDLERS

Constants included from SubscriptionSupport

SubscriptionSupport::CONTROL_NOTIFICATIONS, SubscriptionSupport::DEFAULT_ACK_TIMEOUT

Constants included from ErrorBodies

ErrorBodies::MAX_ERROR_BODY_BYTES

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from SessionPin

#check_session_pin!, #guarded_writes, #pinned_to_session, #unpinned_session

Methods included from RequestMetadata

#adoptable_request_meta_hold, #claimable_request_meta_hold, #close_request_meta_hold, #current_params_fingerprint, #deep_sort_keys, #held_request_meta, #held_request_meta_key, #holding_request_meta, #note_request_params, #note_request_params_pending, #offer_request_meta_hold, #offered_request_meta_key, #open_request_meta_hold, #outside_request_meta_hold, #params_fingerprint_of, #recorded_request_params, #release_held_request_meta, #request_params_fingerprint, #request_params_key, #restore_request_params, #take_offered_request_meta_hold, #withdraw_request_meta_hold

Methods included from ResultCaching

#assume_zero_ttl?, #attach_list_value, #authorization_fingerprint, authorization_header_value, #authorization_header_value, #bind_authorization_context, #bump_cache_epoch, #bump_cache_generation, #cache_entries, #cache_entries_mutex, #cache_entry_for, #cache_entry_fresh?, #cache_entry_hinted?, #cache_entry_token, #cache_epoch, #cache_fresh?, #cache_generation, #cache_info, #cached_list_value, #clear_response_received_at, #clear_result_cache, #discard_paginated_list, #empty_list_copy?, #entry_for_current_params?, #entry_in_current_context?, #entry_matches_authorization?, faraday_headers, #faraday_headers, #fetching_list_page, #forget_served_entries, #forget_transport_thread_state, #fresh_list_value, #hinted_list_value, #invalid_cursor_error?, #invalidate_cache, #invalidate_cache_for_notification, #invalidate_read_cache, #list_cache_epoch, #list_kind_for, #mixed_pages_placeholder, #monotonic_now, #note_legacy_served, #note_response_received_at, #note_served_entry, #on_cache_invalidation, #private_entry_for_current_context, #prune_read_entries, #read_cache_key, #read_resource_with_cache, #record_cache_hint, #record_list_cache_hint, #record_paginated_cache_hint, #recorded_entries_key, #release_serving_request_meta, #remember_recorded_entry, #response_received_at, #response_received_key, #sent_authorization_known?, #served_entries_key, #stale_fallback_for, #stale_list_entry, #stale_list_value, #store_read_entry, #take_served_entry, #transport_thread_local_keys

Methods included from InputRoundTrips

#fulfil_input_request, #fulfil_input_requests, #undeclared_sampling_tool_use?

Methods included from SubscriptionSupport

#await_acknowledgment_deadline, #close_subscription_gracefully, #confirm_resource_subscription, #deliver_subscription_notification, #discard_mapped_resource_subscription, #drop_unacknowledged_resource_subscriptions, #ensure_modern_listen!, #expire_unacknowledged_subscription, #handle_server_cancellation, #handle_subscription_acknowledgment, #handle_subscription_control, #handle_subscription_response, #listen, #live_resource_subscription, #notify_host, #open_listen, #open_resource_subscription, #rearm_acknowledgment_deadline, #recheck_mapped_resource_subscription, #register_subscription, #report_declined_subscription_types, #require_resource_watch, #resource_subscription_mutex, #resource_subscription_mutexes, #resource_subscriptions, #route_notification, #settled_resource_subscription, #subscribe_resource_via_listen, #subscription_ack_timeout, #subscription_by_id, #subscription_delivery_target, #subscription_for_notification, #subscriptions, #subscriptions_mutex, #unmap_resource_subscription, #unregister_subscription, #unregister_subscription_id, #unsubscribe_resource_via_listen

Methods included from DeprecationNotices

#warn_input_request_answer_deprecated, #warn_input_request_deprecated, #warn_logging_deprecated, #warn_request_log_level_deprecated, #warn_roots_deprecated, #warn_sampling_deprecated

Methods included from InputWaits

#on_input_required_wait, #reject_input_required_discover!, #resume_input_required

Methods included from ErrorBodies

#decoded_error_body, #gunzip_bounded, #jsonrpc_error_from_http_response, #jsonrpc_error_in_body, #oversized_error_body?

Instance Attribute Details

#request_meta ⇒ Hash, ...

Host-supplied metadata merged into every request's _meta: a Hash, or a callable returning one, evaluated per request. Intended for OpenTelemetry trace context (traceparent, tracestate, baggage) and vendor-prefixed keys. Reserved protocol fields cannot be overridden through it.

Returns:

  • (Hash, #call, nil)


369
370
371
# File 'lib/mcp_client/json_rpc_common.rb', line 369

def request_meta
  @request_meta
end

#send_client_info=(value) ⇒ Object (writeonly)

Whether to identify this client on every request via io.modelcontextprotocol/clientInfo (MCP 2026-07-28: clients SHOULD, "unless specifically configured not to do so").

Parameters:

  • value (Boolean)


375
376
377
# File 'lib/mcp_client/json_rpc_common.rb', line 375

def send_client_info=(value)
  @send_client_info = value
end

Class Method Details

.restore_wire_keys(value) ⇒ Object

Restore the wire spelling of a peer's own JSON object. JSON object keys are always strings, but a host's response middleware may symbolize the keys of everything it parses (Faraday's :json parser with symbolize_names) — the middleware ::result_type already tolerates for the resultType discriminator. Undoing it once, on the protocol object about to be read, keeps every lookup below (and the params the input handlers see) on the shape the protocol defines. Values are returned untouched, so an opaque requestState is still echoed verbatim.

Parameters:

  • value (Object) —

    a parsed JSON value

Returns:

  • (Object) —

    the same value with Symbol keys spelled as Strings



894
895
896
897
898
899
900
901
902
903
# File 'lib/mcp_client/json_rpc_common.rb', line 894

def self.restore_wire_keys(value)
  case value
  when Hash
    value.to_h { |key, member| [key.is_a?(Symbol) ? key.to_s : key, restore_wire_keys(member)] }
  when Array
    value.map { |member| restore_wire_keys(member) }
  else
    value
  end
end

.result_type(result) ⇒ Object

The resultType of a result object. MCP 2026-07-28 makes the field required, but "for backward compatibility with servers implementing earlier protocol versions, which do not include resultType, clients MUST treat an absent resultType as 'complete'". Non-object results (lenient handling of older servers) are likewise complete.

Parameters:

  • result (Object) —

    a JSON-RPC result

Returns:

  • (Object) —

    the resultType value, 'complete' when absent



876
877
878
879
880
881
882
# File 'lib/mcp_client/json_rpc_common.rb', line 876

def self.result_type(result)
  return 'complete' unless result.is_a?(Hash)
  return result['resultType'] if result.key?('resultType')
  return result[:resultType] if result.key?(:resultType)

  'complete'
end

Instance Method Details

#accepted_result_types ⇒ Array<String>

Result types this transport accepts. Overridden (widened) by transports that negotiated a result-type-adding extension.

Returns:

  • (Array<String>)


908
909
910
911
912
913
914
915
916
917
# File 'lib/mcp_client/json_rpc_common.rb', line 908

def accepted_result_types
  # input_required names the multi round-trip pattern, which exists only
  # in modern revisions: a handshake-era server answering with it is
  # malformed, and treating it as valid would let a wrapper flatten an
  # unfinished result into an empty successful one.
  return LEGACY_RESULT_TYPES unless modern?

  extra = implemented_extension_result_types.select { |id, _| declared_extensions.key?(id) }.values.flatten
  extra.empty? ? CORE_RESULT_TYPES : (CORE_RESULT_TYPES + extra).uniq.freeze
end

#apply_discover_result(result) ⇒ Hash

Apply a DiscoverResult (server/discover): choose the protocol version for subsequent requests and record the server's capabilities, identity and instructions.

Parameters:

  • result (Hash) —

    the DiscoverResult

Returns:

  • (Hash) —

    the result

Raises:



553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
# File 'lib/mcp_client/json_rpc_common.rb', line 553

def apply_discover_result(result)
  unless result.is_a?(Hash)
    raise MCPClient::Errors::ConnectionError, "Server returned an invalid server/discover result (#{result.class})"
  end

  reject_input_required_discover!(result)
  reject_task_result_discover!(result)
  versions = result['supportedVersions']
  unless versions.is_a?(Array) && versions.all?(String)
    raise MCPClient::Errors::ConnectionError, 'server/discover result has no supportedVersions list'
  end

  version = select_protocol_version(versions)
  unless version
    # A DiscoverResult settles the era even when it settles no version:
    # only a modern server answers server/discover with one. Raising the
    # typed error keeps MCPClient.connect from trying the legacy
    # transports, which cannot do better against a modern server.
    raise MCPClient::Errors::ModernServerError,
          "Server supports protocol versions #{versions.join(', ')}, none of which this client speaks " \
          "(modern versions supported: #{MCPClient::MODERN_PROTOCOL_VERSIONS.join(', ')})"
  end
  # Everything is checked before anything is recorded: a refresh that
  # fails to validate changes nothing, not even the identity it carried.
  capabilities = result['capabilities']
  unless capabilities.nil? || capabilities.is_a?(Hash)
    raise MCPClient::Errors::ConnectionError, 'server/discover result capabilities is not an object'
  end

  meta = result['_meta']
  unless meta.nil? || meta.is_a?(Hash)
    raise MCPClient::Errors::ConnectionError, 'server/discover result _meta is not an object'
  end

  @protocol_version = version
  @supported_versions = versions
  @last_discover_result = result
  entry = record_cache_hint(:discover, result)
  @capabilities = capabilities || {}
  @instructions = result['instructions']
  info = meta && meta[META_SERVER_INFO]
  @server_info = info if info.is_a?(Hash)
  record_discovery_freshness(result, entry)
  result
end

#begin_era_probe ⇒ void

This method returns an undefined value.

Begin proposing a protocol version that the server has not confirmed: until the probe settles, the era is unknown.



330
331
332
# File 'lib/mcp_client/json_rpc_common.rb', line 330

def begin_era_probe
  @era_probe_in_flight = true
end

#build_jsonrpc_notification(method, params) ⇒ Hash

Build a JSON-RPC notification object (no response expected)

Parameters:

  • method (String) —

    JSON-RPC method name

  • params (Hash) —

    parameters for the notification

Returns:

  • (Hash) —

    the JSON-RPC notification object



661
662
663
664
665
666
667
668
669
670
671
672
# File 'lib/mcp_client/json_rpc_common.rb', line 661

def build_jsonrpc_notification(method, params)
  # A notification is never the request a cache decision was made for: it
  # reads the host afresh and leaves the reservation for that request.
  effective = with_request_meta(params, claim: :none)
  {
    'jsonrpc' => '2.0',
    'method' => method,
    # Modern notifications carry the same _meta as requests: on HTTP the
    # MCP-Protocol-Version header must match the body.
    'params' => effective
  }
end

#build_jsonrpc_request(method, params, id, note: true) ⇒ Hash

Build a JSON-RPC request object

Parameters:

  • method (String) —

    JSON-RPC method name

  • params (Hash) —

    parameters for the request

  • id (Integer) —

    request ID

  • note (Boolean) (defaults to: true) —

    whether this request's effective parameters are remembered as this thread's current request (a probe that is never sent passes false)

Returns:

  • (Hash) —

    the JSON-RPC request object



289
290
291
292
293
294
295
296
297
298
# File 'lib/mcp_client/json_rpc_common.rb', line 289

def build_jsonrpc_request(method, params, id, note: true)
  effective = with_request_meta(params, claim: request_meta_claim(method, note))
  note_request_params(effective) if note
  {
    'jsonrpc' => '2.0',
    'id' => id,
    'method' => method,
    'params' => effective
  }
end

#build_named_request_params(name, arguments) ⇒ Hash

Build tools/call- or prompts/get-style params with request-level _meta hoisted out of the arguments (string keys, matching the JSON wire form).

Parameters:

  • name (String) —

    tool or prompt name

  • arguments (Hash) —

    user-supplied arguments (possibly carrying _meta)

Returns:

  • (Hash) —

    params hash for the JSON-RPC request



233
234
235
236
237
238
# File 'lib/mcp_client/json_rpc_common.rb', line 233

def build_named_request_params(name, arguments)
  args, meta = split_request_meta(arguments)
  params = { 'name' => name, 'arguments' => args }
  params['_meta'] = meta if meta
  params
end

#cancellable_request?(method, params) ⇒ Boolean

Whether automatic notifications/cancelled on timeout is appropriate for this request: never for initialize (MUST NOT be cancelled), and never for task-augmented requests (tasks use tasks/cancel instead).

Parameters:

  • method (String) —

    JSON-RPC method

  • params (Hash) —

    request params

Returns:

  • (Boolean)


206
207
208
209
210
211
# File 'lib/mcp_client/json_rpc_common.rb', line 206

def cancellable_request?(method, params)
  return false if method == 'initialize'
  return false if params.is_a?(Hash) && (params.key?('task') || params.key?(:task))

  true
end

#client_capabilities ⇒ Hash

Declared client capabilities, derived from the server-request callbacks the host actually registered before connecting. Per MCP 2025-11-25, clients that support a feature MUST declare it during initialization, and only negotiated capabilities may be used afterwards — so declaring a hardcoded set independent of host support violates the lifecycle in both directions.

Returns:

  • (Hash) —

    the capabilities object for the initialize request



747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
# File 'lib/mcp_client/json_rpc_common.rb', line 747

def client_capabilities
  capabilities = {}
  # On a modern server these features are served through the multi
  # round-trip pattern (InputRequiredResult), on a legacy one through
  # server-initiated requests; either way they are declared only when
  # the host registered a handler, since the server MUST NOT ask for
  # what the client did not declare.
  if registered_callback?(:@elicitation_request_callback)
    # Both defined elicitation modes are implemented (an empty object
    # would mean form-only per the spec's backwards-compatibility rule).
    capabilities['elicitation'] = { 'form' => {}, 'url' => {} }
  end
  if registered_callback?(:@roots_list_request_callback)
    # notifications/roots/list_changed was removed in 2026-07-28, so the
    # modern roots capability has no listChanged flag.
    capabilities['roots'] = modern? ? {} : { 'listChanged' => true }
  end
  if registered_callback?(:@sampling_request_callback)
    # SEP-1577: servers may only send tool-enabled sampling requests when
    # the client declares the sampling.tools sub-capability.
    capabilities['sampling'] = sampling_tools_supported? ? { 'tools' => {} } : {}
  end
  capabilities['extensions'] = declared_extensions.dup unless declared_extensions.empty?
  # NOTE: we intentionally do NOT declare a client `tasks` capability. That
  # capability marks the client as a RECEIVER of task-augmented
  # sampling/elicitation requests, which is not implemented here — this
  # client only acts as a task REQUESTOR for tools/call (see
  # Client#call_tool_as_task), which requires no client-side declaration.
  capabilities
end

#client_info_payload ⇒ Hash

The Implementation object sent as clientInfo: the host-provided info when configured (client_info=), otherwise the gem's identity.

Returns:

  • (Hash)


734
735
736
737
738
# File 'lib/mcp_client/json_rpc_common.rb', line 734

def client_info_payload
  return @client_info if defined?(@client_info) && @client_info

  { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
end

#declare_extension(identifier, settings = {}) ⇒ void

This method returns an undefined value.

Declare support for an MCP extension (basic/versioning "Extension Negotiation"): advertised under clientCapabilities.extensions on every modern request.

Parameters:

  • identifier (String) —

    the extension id, e.g. 'io.modelcontextprotocol/tasks'

  • settings (Hash) (defaults to: {}) —

    per-extension settings ({} = support, no settings)

Raises:

  • (ArgumentError) —

    if the identifier lacks the mandatory prefix



389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# File 'lib/mcp_client/json_rpc_common.rb', line 389

def declare_extension(identifier, settings = {})
  unless identifier.is_a?(String) && identifier.match?(EXTENSION_ID_PATTERN)
    raise ArgumentError, "Extension identifier #{identifier.inspect} must have a dotted prefix and a slash " \
                         '(e.g. io.modelcontextprotocol/tasks)'
  end

  settings = {} if settings.nil?
  unless settings.is_a?(Hash)
    raise ArgumentError, "Extension settings for #{identifier} must be an object (Hash), got #{settings.class}"
  end

  # An extension that adds a result type is advertised only by a client
  # that can accept that result type: a server told the extension is
  # negotiated may answer with it, and an unrecognized resultType is an
  # invalid response — the usable answer would be lost.
  added = RESULT_TYPE_EXTENSIONS[identifier]
  if added && !implemented_extension_result_types.key?(identifier)
    raise ArgumentError,
          "Extension #{identifier} adds the result type #{added.inspect}, which this client does not implement"
  end

  @declared_extensions ||= {}
  @declared_extensions[identifier] = settings
end

#declare_sampling_tools ⇒ void

Deprecated.

Sampling is deprecated since MCP 2026-07-28 (SEP-2577); earliest removal is the first revision released on or after 2027-07-28, and this sub-capability goes with the capability it refines. Declaring it raises no notice of its own — serving a sampling/createMessage request does. Integrate directly with the LLM provider API instead.

This method returns an undefined value.

Opt this transport into declaring tool-use support for sampling (ClientCapabilities.sampling.tools, MCP 2025-11-25 / SEP-1577). Call before connect so the initialize request advertises it; it only takes effect when a sampling request callback is also registered, since sampling.tools is a sub-capability of sampling.



791
792
793
# File 'lib/mcp_client/json_rpc_common.rb', line 791

def declare_sampling_tools
  @sampling_tools_supported = true
end

#declared_extensions ⇒ Hash

Returns declared extension id => settings.

Returns:

  • (Hash) —

    declared extension id => settings



415
416
417
# File 'lib/mcp_client/json_rpc_common.rb', line 415

def declared_extensions
  defined?(@declared_extensions) && @declared_extensions ? @declared_extensions : {}
end

#describe_body_size(body) ⇒ String

A log-safe description of a payload body: its size, never its content. A host's conn.response :json middleware hands the decoded object here instead of the bytes it came from; say so rather than measuring it.

Parameters:

  • body (String, Object, nil) —

    the response/request body

Returns:

  • (String)


184
185
186
187
188
189
# File 'lib/mcp_client/json_rpc_common.rb', line 184

def describe_body_size(body)
  return 'empty body' if body.nil? || (body.respond_to?(:empty?) && body.empty?)
  return "decoded #{body.class} body" unless body.is_a?(String)

  "#{body.bytesize} bytes"
end

#describe_jsonrpc_message(message) ⇒ String

A log-safe description of a JSON-RPC message: its method and id only.

Params and results are deliberately omitted. tools/call arguments and tool results routinely carry credentials, personal data or customer content, and logs are frequently shipped to lower-trust destinations (aggregators, CI artifacts, support bundles) — so enabling DEBUG must not silently start recording payloads.

Parameters:

  • message (Hash) —

    a JSON-RPC request, notification or response

Returns:

  • (String) —

    method/id summary, never payload content



150
151
152
153
154
155
156
157
158
159
# File 'lib/mcp_client/json_rpc_common.rb', line 150

def describe_jsonrpc_message(message)
  return '(non-object message)' unless message.is_a?(Hash)

  parts = []
  parts << (message['method'] || message[:method] || '(response)').to_s
  id = message['id'] || message[:id]
  parts << "id=#{id}" if id
  parts << 'error' if message['error'] || message[:error]
  parts.join(' ')
end

#describe_parse_error(error, payload = nil) ⇒ String

A log-safe description of a JSON parse failure.

JSON::ParserError#message quotes the offending token — e.g. "expected object key, got 'SECRET-123' at line 1 column 2" — so interpolating it puts peer-controlled bytes straight into logs and exception messages. Keep the position, which is what actually helps diagnose a broken server, and drop the quoted content.

Parameters:

  • error (JSON::ParserError) —

    the parse failure

  • payload (String, nil) (defaults to: nil) —

    the payload that failed to parse

Returns:

  • (String) —

    position and size, never payload content



171
172
173
174
175
176
177
# File 'lib/mcp_client/json_rpc_common.rb', line 171

def describe_parse_error(error, payload = nil)
  location = error.message[/at line \d+ column \d+/]
  parts = ['malformed JSON']
  parts << location if location
  parts << describe_body_size(payload) if payload
  parts.join(', ')
end

#discovery_cache_scope ⇒ String?

Returns the cacheScope the last DiscoverResult declared.

Returns:

  • (String, nil) —

    the cacheScope the last DiscoverResult declared



642
643
644
# File 'lib/mcp_client/json_rpc_common.rb', line 642

def discovery_cache_scope
  defined?(@discovery_cache_scope) ? @discovery_cache_scope : nil
end

#discovery_clock ⇒ Float

Returns the monotonic clock, in seconds, discovery freshness is judged by: the transport's own when it has one (the one its cache entries are dated by), the process clock otherwise.

Returns:

  • (Float) —

    the monotonic clock, in seconds, discovery freshness is judged by: the transport's own when it has one (the one its cache entries are dated by), the process clock otherwise



626
627
628
629
630
# File 'lib/mcp_client/json_rpc_common.rb', line 626

def discovery_clock
  return monotonic_now if respond_to?(:monotonic_now, true)

  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

#discovery_fresh? ⇒ Boolean

Whether the last DiscoverResult is still fresh by its own ttlMs. A session that recorded no discovery at all (a 2025-11-25 one) has nothing to refresh.

Returns:

  • (Boolean)


636
637
638
639
# File 'lib/mcp_client/json_rpc_common.rb', line 636

def discovery_fresh?
  deadline = defined?(@discovery_expires_at) ? @discovery_expires_at : nil
  deadline.nil? || discovery_clock < deadline
end

#encode_header_value(value) ⇒ String

Encode a parameter value for an MCP request header (Mcp-Name, Mcp-Param-*): strings as-is when header-safe, integers in decimal, booleans lowercase; anything not safely representable — non-ASCII, control characters, leading/trailing whitespace, an empty string, or a value that looks like the sentinel — as =?base64?<b64 of UTF-8>?=.

Parameters:

  • value (String, Integer, true, false) —

    the parameter value

Returns:

  • (String) —

    the header value



939
940
941
# File 'lib/mcp_client/json_rpc_common.rb', line 939

def encode_header_value(value)
  MCPClient::HeaderParams.encode_header_value(value)
end

#era_probe_in_flight? ⇒ Boolean

Returns whether protocol_version is only a proposal so far.

Returns:

  • (Boolean) —

    whether protocol_version is only a proposal so far



342
343
344
# File 'lib/mcp_client/json_rpc_common.rb', line 342

def era_probe_in_flight?
  defined?(@era_probe_in_flight) ? @era_probe_in_flight : false
end

#host_request_meta(claim = :none) ⇒ Hash

The host's request_meta for one message, with any reserved protocol keys it tries to set dropped.

A message that claims the open operation's reservation reads the evaluation held for it (making it, the first time, and holding it); :spend marks it spent, so the request it was held for carries it and nothing else ever does. :none reads the host afresh and leaves the reservation alone -- a host callable that vends a one-time value is never spent twice, and never on the wrong request.

Parameters:

  • claim (Symbol) (defaults to: :none) —

    :spend, :model or :none

Returns:

  • (Hash) —

    String-keyed metadata (possibly empty)



525
526
527
528
529
530
531
532
533
534
535
536
537
# File 'lib/mcp_client/json_rpc_common.rb', line 525

def host_request_meta(claim = :none)
  held = claim == :none ? nil : claimable_request_meta_hold
  return spend_held_request_meta(held, claim) if held&.evaluated

  source = request_meta
  source = source.call if source.respond_to?(:call)
  meta = source.is_a?(Hash) ? source.transform_keys(&:to_s).except(*PROTECTED_META_KEYS) : {}
  return meta unless held

  held.evaluated = true
  held.value = meta
  spend_held_request_meta(held, claim)
end

#implemented_extension_result_types ⇒ Hash{String => Array<String>}

The result types this client implements on top of the core ones, per extension: declaring the tasks extension makes a CreateTaskResult (resultType "task") an accepted answer on the requests it allows.

Returns:

  • (Hash{String => Array<String>})


865
866
867
# File 'lib/mcp_client/json_rpc_common.rb', line 865

def implemented_extension_result_types
  { TASKS_EXTENSION => ['task'] }
end

#initialization_params ⇒ Hash

Generate initialization parameters for MCP protocol

Returns:

  • (Hash) —

    the initialization parameters



692
693
694
695
696
697
698
699
700
701
702
703
704
705
# File 'lib/mcp_client/json_rpc_common.rb', line 692

def initialization_params
  # Extension negotiation is a 2026-07-28 mechanism (basic/versioning
  # "Extension Negotiation", carried in every modern request's _meta):
  # the 2025-11-25 handshake has no such capability, and an extension
  # defined for 2026-07-28 (the tasks extension, say) is not advertised
  # to a server that negotiates the legacy protocol.
  capabilities = client_capabilities
  capabilities.delete('extensions')
  {
    'protocolVersion' => MCPClient::PROTOCOL_VERSION,
    'capabilities' => capabilities,
    'clientInfo' => client_info_payload
  }
end

#mcp_name_header_value(request) ⇒ String?

Returns the encoded Mcp-Name value, or nil when the method has none.

Parameters:

  • request (Hash) —

    the JSON-RPC request

Returns:

  • (String, nil) —

    the encoded Mcp-Name value, or nil when the method has none



963
964
965
966
967
968
969
970
971
972
# File 'lib/mcp_client/json_rpc_common.rb', line 963

def mcp_name_header_value(request)
  key = NAME_HEADER_SOURCES[request['method']]
  params = request['params']
  return nil unless key && params.is_a?(Hash)

  value = params.key?(key) ? params[key] : params[key.to_sym]
  return nil if value.nil?

  encode_header_value(value)
end

#merge_meta_spellings(params) ⇒ Hash?

A caller's _meta supplied under the Symbol key, or under both spellings, becomes one String-keyed _meta (the String one winning on a clash). Two spellings would otherwise serialize as two _meta members — and whatever was stripped from one copy would reach the wire through the other, since only one is inspected.

Parameters:

  • params (Hash, nil) —

    request params

Returns:

  • (Hash, nil) —

    params with at most one _meta member, under the String key



472
473
474
475
476
477
478
479
480
481
482
# File 'lib/mcp_client/json_rpc_common.rb', line 472

def merge_meta_spellings(params)
  return params unless params.is_a?(Hash) && params.key?(:_meta)

  params = params.dup
  symbol_meta = params.delete(:_meta)
  string_meta = params['_meta']
  symbol_meta = symbol_meta.is_a?(Hash) ? symbol_meta.transform_keys(&:to_s) : {}
  string_meta = string_meta.is_a?(Hash) ? string_meta.transform_keys(&:to_s) : {}
  params['_meta'] = symbol_meta.merge(string_meta)
  params
end

#modern? ⇒ Boolean

Whether this server speaks a modern (per-request metadata, no handshake) protocol revision (MCP 2026-07-28 basic/versioning "Terminology"). false until the era is established.

Returns:

  • (Boolean)


686
687
688
# File 'lib/mcp_client/json_rpc_common.rb', line 686

def modern?
  MCPClient::MODERN_PROTOCOL_VERSIONS.include?(protocol_version)
end

#modern_request_headers(request) ⇒ Hash{String => String}

The HTTP headers a modern (2026-07-28) request must carry: the protocol version (matching the body's _meta), the method, and for named requests the name/URI (MCP 2026-07-28 Streamable HTTP "Request Metadata").

Parameters:

  • request (Hash) —

    the JSON-RPC request (String keys)

Returns:

  • (Hash{String => String}) —

    header name => value



949
950
951
952
953
954
955
956
957
958
959
# File 'lib/mcp_client/json_rpc_common.rb', line 949

def modern_request_headers(request)
  # The version the body was built with, not the transport's current one:
  # a concurrent request may have switched versions in between, and the
  # header MUST match the body's _meta.
  meta = request['params'].is_a?(Hash) ? request['params']['_meta'] : nil
  version = (meta.is_a?(Hash) && meta[META_PROTOCOL_VERSION]) || protocol_version
  headers = { 'MCP-Protocol-Version' => version, 'Mcp-Method' => request['method'].to_s }
  name = mcp_name_header_value(request)
  headers['Mcp-Name'] = name if name
  headers
end

#notify_cache_invalidation(method, params) ⇒ void

This method returns an undefined value.

Tell a host layered above this transport to drop the caches a notification invalidates, surviving whatever it does with it.

Called by every path that fans a notification out to the host — the subscription routing on stdio and both HTTP transports, the legacy SSE parser, and the synthetic tools/list_changed a HeaderMismatch refresh announces — and always before the notification reaches a subscription's listeners. See ServerBase#on_cache_invalidation for why the host's own callback cannot serve.

Parameters:

  • method (String) —

    notification method

  • params (Hash, nil) —

    notification params



134
135
136
137
138
139
# File 'lib/mcp_client/json_rpc_common.rb', line 134

def notify_cache_invalidation(method, params)
  @cache_invalidation_callback&.call(method, params)
rescue StandardError => e
  @logger.warn("Cache invalidation callback error for #{sanitize_log_text(method)}: " \
               "#{sanitize_log_text(e.message)}")
end

#ping ⇒ Hash

Ping the server to keep the connection alive

Returns:

  • (Hash) —

    the result of the ping request

Raises:



196
197
198
# File 'lib/mcp_client/json_rpc_common.rb', line 196

def ping
  rpc_request('ping')
end

#process_jsonrpc_response(response, method: nil) ⇒ Object

Process JSON-RPC response

Parameters:

  • response (Hash) —

    the parsed JSON-RPC response

Returns:

  • (Object) —

    the result field from the response

Raises:



979
980
981
982
983
984
985
986
987
# File 'lib/mcp_client/json_rpc_common.rb', line 979

def process_jsonrpc_response(response, method: nil)
  error = envelope_member(response, 'error')
  raise MCPClient::Errors::ServerError.from_jsonrpc(error) if error

  result = envelope_member(response, 'result')
  validate_result_type!(result)
  record_server_info(result, method: method)
  result
end

#protocol_era ⇒ Symbol?

The server's established protocol era.

Deliberately not the same question as #modern?: while a server/discover probe is in flight, protocol_version holds the version the probe proposes, which is what outgoing requests must declare but says nothing about what the server speaks. Anything that reacts to the peer — above all, whether a server-initiated request is prohibited — must consult the era, not the tentative outgoing version.

Returns:

  • (Symbol, nil) —

    :modern, :legacy, or nil before the era is known



321
322
323
324
325
# File 'lib/mcp_client/json_rpc_common.rb', line 321

def protocol_era
  return nil if era_probe_in_flight? || protocol_version.nil?

  modern? ? :modern : :legacy
end

#protocol_version ⇒ String?

The protocol version in use with this server, once established: chosen via server/discover for a modern server or negotiated by initialize for a legacy one. nil until then.

Returns:

  • (String, nil)


678
679
680
# File 'lib/mcp_client/json_rpc_common.rb', line 678

def protocol_version
  defined?(@protocol_version) ? @protocol_version : nil
end

#record_discovery_freshness(result, entry = nil) ⇒ void

This method returns an undefined value.

Record a DiscoverResult's cache hints (CacheableResult: ttlMs, cacheScope). A ttlMs of zero means the result is immediately stale.

Parameters:

  • result (Hash) —

    the DiscoverResult

  • entry (MCPClient::CachedResult, nil) (defaults to: nil) —

    the cache entry this result was recorded as



604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
# File 'lib/mcp_client/json_rpc_common.rb', line 604

def record_discovery_freshness(result, entry = nil)
  # One reading of ttlMs for every cached result, on one clock, so
  # cache_info(:discover) and this decision never disagree: a JSON number
  # of milliseconds; zero is immediately stale, and a negative, absent or
  # malformed hint is treated as zero (a DiscoverResult without a hint is
  # re-read on the next access that needs it).
  #
  # It runs from the RECEIPT of the response, which is what the entry was
  # dated by ("fresh for that many milliseconds" after it arrived), not
  # from this moment: everything between the response arriving and the
  # client getting round to applying it — a notification delivered off
  # the same stream, host middleware, a slow parse — is time the result
  # has already spent, not time it is owed.
  received = entry&.received_at || discovery_clock
  @discovery_expires_at = received + (MCPClient::CachedResult.normalize_ttl(result['ttlMs']) / 1000.0)
  scope = result['cacheScope']
  @discovery_cache_scope = scope.is_a?(String) ? scope : nil
end

#record_server_info(result, method: nil) ⇒ void

This method returns an undefined value.

Servers SHOULD identify themselves in every result's _meta (io.modelcontextprotocol/serverInfo, MCP 2026-07-28); keep the latest self-reported identity for display and logging.

Parameters:

  • result (Object) —

    a JSON-RPC result

  • method (String, nil) (defaults to: nil) —

    the method the result answers, when known



1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
# File 'lib/mcp_client/json_rpc_common.rb', line 1129

def record_server_info(result, method: nil)
  return unless result.is_a?(Hash)
  # A DiscoverResult's identity is recorded by apply_discover_result, once
  # the WHOLE result has validated: a refresh that fails must change
  # nothing, not even the identity it carried. Judged by the method the
  # result answers rather than by the result's own shape — an answer that
  # is missing supportedVersions is exactly the one apply_discover_result
  # rejects, and reading the shape recorded its identity first. The shape
  # still stands in for the method where the caller cannot name it.
  return if method == 'server/discover' || result.key?('supportedVersions')

  info = result['_meta'].is_a?(Hash) ? result['_meta'][META_SERVER_INFO] : nil
  @server_info = info if info.is_a?(Hash)
end

#refused_undeclared_sampling_tools?(request_id, params) ⇒ Boolean

SEP-1577 (schema.ts CreateMessageRequestParams.tools/.toolChoice): "The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared." The 2025-11-25 server-initiated path refuses here, before any handler sees the request, with the Invalid params code sampling.mdx § Error Handling uses; the multi round-trip path refuses the same way in InputRoundTrips.

Parameters:

  • request_id (String, Integer) —

    the JSON-RPC request ID

  • params (Hash) —

    the sampling/createMessage params

Returns:

  • (Boolean) —

    true when the request was refused (and answered)



815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
# File 'lib/mcp_client/json_rpc_common.rb', line 815

def refused_undeclared_sampling_tools?(request_id, params)
  return false unless undeclared_sampling_tool_use?('sampling/createMessage', params)

  # The line is a courtesy to the host; the refusal is the answer the peer
  # is owed. A logger that fails here must not turn Invalid params into
  # the dispatcher's Internal error.
  begin
    @logger.warn('Rejecting tool-enabled sampling request: sampling.tools capability not declared')
  rescue StandardError
    nil
  end
  send_error_response(request_id, -32_602,
                      'Invalid params: tools/toolChoice provided but the sampling.tools ' \
                      'capability was not declared')
  true
end

#registered_callback?(ivar) ⇒ Boolean

Returns whether the callback is registered on this transport.

Parameters:

  • ivar (Symbol) —

    callback instance variable name

Returns:

  • (Boolean) —

    whether the callback is registered on this transport



797
798
799
# File 'lib/mcp_client/json_rpc_common.rb', line 797

def registered_callback?(ivar)
  instance_variable_defined?(ivar) && !instance_variable_get(ivar).nil?
end

#reject_task_result_discover!(result) ⇒ void

This method returns an undefined value.

server/discover is not one of the request types the tasks extension covers, so a CreateTaskResult there is invalid and MUST NOT be applied: the probe would otherwise adopt a protocol version and install capabilities out of a task creation, and the discovery-shaped members a non-conforming server bolted onto it would override the discriminator. The ordinary rejection (#reject_task_result_on_unsupported_method!) runs in the round-trip resolver, which discovery does not go through.

Like the input_required sibling this is a ModernServerError, not an InvalidResultError: resultType is a 2026-07-28 field, so a server that answered with one is modern and the era is settled — it must never be retried with the initialize handshake, nor sent on to the legacy transports by MCPClient.connect.

Parameters:

  • result (Object) —

    the server/discover result

Raises:



1105
1106
1107
1108
1109
1110
1111
# File 'lib/mcp_client/json_rpc_common.rb', line 1105

def reject_task_result_discover!(result)
  return unless MCPClient::JsonRpcCommon.result_type(result) == 'task'

  raise MCPClient::Errors::ModernServerError,
        'Server answered server/discover with a task result; resultType "task" is ' \
        "only valid for #{TASK_METHODS.join(', ')}"
end

#reject_task_result_on_unsupported_method!(method, result) ⇒ void

This method returns an undefined value.

A CreateTaskResult is only a valid answer to the request types the tasks extension covers (TASK_METHODS); anywhere else it is an invalid response (extensions/tasks "Capability Negotiation").

Parameters:

  • method (String) —

    the JSON-RPC method

  • result (Object) —

    the final result

Raises:



1081
1082
1083
1084
1085
1086
1087
# File 'lib/mcp_client/json_rpc_common.rb', line 1081

def reject_task_result_on_unsupported_method!(method, result)
  return unless MCPClient::JsonRpcCommon.result_type(result) == 'task'
  return if TASK_METHODS.include?(method)

  raise MCPClient::Errors::InvalidResultError,
        "Invalid result: resultType \"task\" is only valid for #{TASK_METHODS.join(', ')}, not #{method}"
end

#request_meta_claim(method, note) ⇒ Symbol

Which claim a message being built makes on the evaluation the open operation reserved (see RequestMetadata::HeldRequestMeta).

A probe is never sent: it models the reserved request, so it reads that request's evaluation without spending it. A real request spends the reservation only when it is the request the reservation was made for -- the one the operation holding it sends. Everything else -- a reconnect's handshake, a re-opened subscriptions/listen, a cancellation, and everything host code issues from behind the boundary a transport crosses to reach it (RequestMetadata#outside_request_meta_hold), raw rpc_request of the very same method included -- reads the host afresh and leaves the reservation for the request that holds it.

Parameters:

  • method (String) —

    the JSON-RPC method being built

  • note (Boolean) —

    whether the message is really going out

Returns:

  • (Symbol) —

    :spend, :model or :none



274
275
276
277
278
279
# File 'lib/mcp_client/json_rpc_common.rb', line 274

def request_meta_claim(method, note)
  return :model unless note

  held = claimable_request_meta_hold
  held && held.request_method == method ? :spend : :none
end

#required_request_meta ⇒ Hash

The reserved per-request protocol fields for a modern server (basic/index "Per-request protocol fields").

Returns:

  • (Hash)


507
508
509
510
511
512
# File 'lib/mcp_client/json_rpc_common.rb', line 507

def required_request_meta
  meta = { META_PROTOCOL_VERSION => protocol_version }
  meta[META_CLIENT_INFO] = client_info_payload if send_client_info?
  meta[META_CLIENT_CAPABILITIES] = client_capabilities
  meta
end

#reserved_meta_supplied?(params) ⇒ Boolean

Whether a caller's params carry a _meta key the transport owns.

A legacy request with no host defaults has nothing to merge and no protocol fields to add, so it is otherwise handed on untouched — but the reserved keys are the client's to set in every era. A dual-era server reads a request carrying modern per-request _meta AS a modern request (basic/versioning), so leaving a caller's copy on the wire would have one call served statelessly while this session goes on believing it negotiated 2025-11-25.

Parameters:

  • params (Hash, nil) —

    request params

Returns:

  • (Boolean)


495
496
497
498
499
500
501
502
# File 'lib/mcp_client/json_rpc_common.rb', line 495

def reserved_meta_supplied?(params)
  return false unless params.is_a?(Hash)

  supplied = params['_meta'] || params[:_meta]
  return false unless supplied.is_a?(Hash)

  supplied.any? { |key, _| PROTECTED_META_KEYS.include?(key.to_s) }
end

#resolve_input_round_trips(method, params, timeout = nil) {|params| ... } ⇒ Object

Drive a request through the multi round-trip pattern (MCP 2026-07-28 basic/patterns/mrtr): while the server answers with an InputRequiredResult, fulfil its inputRequests through the registered handlers and retry the original request — as an independent request with a new id — carrying inputResponses keyed like the requests and the opaque requestState echoed verbatim (omitted when the server sent none). A result without inputRequests asks for nothing this client can fulfil, so it is retried after a growing pause (INPUT_RETRY_DELAY) that the host steers through MCPClient::JsonRpcCommon::InputWaits#on_input_required_wait and that never runs past the request timeout: the continuation is handed back instead, on an error MCPClient::JsonRpcCommon::InputWaits#resume_input_required accepts.

Parameters:

  • method (String) —

    the JSON-RPC method

  • params (Hash) —

    the original params

  • timeout (Numeric, nil) (defaults to: nil) —

    per-request timeout, also bounding the waits

Yield Parameters:

  • params (Hash) —

    params for one attempt (original, or with inputResponses)

Yield Returns:

  • (Object) —

    the attempt's result

Returns:

  • (Object) —

    the final (complete) result

Raises:



1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
# File 'lib/mcp_client/json_rpc_common.rb', line 1025

def resolve_input_round_trips(method, params, timeout = nil)
  result = yield(params)
  round_trips = 0
  delay = INPUT_RETRY_DELAY
  started = input_wait_clock
  deadline = input_wait_deadline(started, timeout)
  while MCPClient::JsonRpcCommon.result_type(result) == 'input_required'
    # Read on the wire spelling, whatever the transport's JSON middleware
    # did to the keys: a symbolized inputRequests/requestState would
    # otherwise be invisible here and the retry would go out with neither
    # the fulfilled answers nor the state the server MUST get back.
    result = MCPClient::JsonRpcCommon.restore_wire_keys(result)
    unless modern? && MRTR_METHODS.include?(method)
      raise MCPClient::Errors::InvalidResultError.new(
        "Invalid result: input_required is only valid for #{MRTR_METHODS.join(', ')} " \
        "on an MCP 2026-07-28 server, not #{method} (#{protocol_version})", data: result
      )
    end

    round_trips += 1
    if round_trips > MAX_INPUT_ROUND_TRIPS
      raise MCPClient::Errors::InputRequiredError.new(
        "Server kept requesting input for #{method} after #{MAX_INPUT_ROUND_TRIPS} round trips", data: result
      )
    end

    @logger.debug("#{method} requires input (round trip #{round_trips}); fulfilling and retrying")
    retry_params = retry_params_for(params, result)
    unless retry_params.key?('inputResponses')
      now = input_wait_clock
      wait = InputRequiredWait.new(rpc_method: method, round_trip: round_trips, delay: delay,
                                   request_state: result['requestState'], result: result,
                                   elapsed: now - started)
      delay = pace_input_round_trip(wait, deadline)
    end
    result = yield(retry_params)
  end
  mark_round_trip_result(round_trips.positive?)
  reject_task_result_on_unsupported_method!(method, result)
  result
rescue MCPClient::Errors::InputRequiredError => e
  # Every failure of the round trip hands the continuation back: the
  # request it was driving, for #resume_input_required.
  e.request_method ||= method
  e.request_params ||= params
  e.transport ||= self
  raise
end

#sampling_tools_supported? ⇒ Boolean

Returns whether the host opted into sampling tool use.

Returns:

  • (Boolean) —

    whether the host opted into sampling tool use



802
803
804
# File 'lib/mcp_client/json_rpc_common.rb', line 802

def sampling_tools_supported?
  instance_variable_defined?(:@sampling_tools_supported) && @sampling_tools_supported
end

#sanitize_log_text(text) ⇒ String

Make peer-supplied text safe to write to the host log: control characters (notably newlines, which would let a server forge log entries) are escaped and the result is capped.

Parameters:

  • text (Object) —

    peer-supplied text

Returns:

  • (String) —

    sanitized, length-bounded text



115
116
117
118
119
120
# File 'lib/mcp_client/json_rpc_common.rb', line 115

def sanitize_log_text(text)
  escaped = text.to_s.gsub(/[\x00-\x1F\x7F]/) { |c| format('\\x%02X', c.ord) }
  return escaped if escaped.length <= MAX_PEER_LOG_TEXT_LENGTH

  "#{escaped[0, MAX_PEER_LOG_TEXT_LENGTH]}... (truncated from #{escaped.length} chars)"
end

#select_protocol_version(supported) ⇒ String?

Pick the newest modern version this client speaks from a server's advertised list (DiscoverResult.supportedVersions or UnsupportedProtocolVersionError.data.supported).

Parameters:

  • supported (Array<String>, nil) —

    versions the server supports

Returns:

  • (String, nil) —

    the chosen version, nil when none is mutual



357
358
359
360
361
# File 'lib/mcp_client/json_rpc_common.rb', line 357

def select_protocol_version(supported)
  return nil unless supported.is_a?(Array)

  MCPClient::MODERN_PROTOCOL_VERSIONS.find { |version| supported.include?(version) }
end

#send_client_info? ⇒ Boolean

Returns whether clientInfo is sent (default true).

Returns:

  • (Boolean) —

    whether clientInfo is sent (default true)



378
379
380
# File 'lib/mcp_client/json_rpc_common.rb', line 378

def send_client_info?
  !(defined?(@send_client_info) && @send_client_info == false)
end

#settle_era_probe ⇒ void

This method returns an undefined value.

The probe has been answered (or given up on): the era is now whatever protocol_version says.



337
338
339
# File 'lib/mcp_client/json_rpc_common.rb', line 337

def settle_era_probe
  @era_probe_in_flight = false
end

#spend_held_request_meta(held, claim) ⇒ Hash

Returns the held evaluation.

Parameters:

Returns:

  • (Hash) —

    the held evaluation



542
543
544
545
# File 'lib/mcp_client/json_rpc_common.rb', line 542

def spend_held_request_meta(held, claim)
  held.spent = true if claim == :spend
  held.value
end

#split_request_meta(arguments) ⇒ Array(Hash, Hash|nil)

Split request-level _meta (RequestParams._meta, e.g. progressToken or related-task metadata) out of user-supplied tool/prompt arguments. Accepts both :_meta and '_meta' key spellings; per MCP, _meta belongs at the request params level, not inside the tool's arguments.

Parameters:

  • arguments (Hash, nil) —

    user-supplied arguments

Returns:

  • (Array(Hash, Hash|nil)) —

    [arguments without _meta, _meta or nil]



219
220
221
222
223
224
225
226
# File 'lib/mcp_client/json_rpc_common.rb', line 219

def split_request_meta(arguments)
  return [arguments, nil] unless arguments.is_a?(Hash)

  meta = arguments[:_meta] || arguments['_meta']
  return [arguments, nil] unless meta

  [arguments.except(:_meta, '_meta'), meta]
end

#supported_versions ⇒ Array<String>?

Protocol versions a modern server advertised in its DiscoverResult.

Returns:

  • (Array<String>, nil)


348
349
350
# File 'lib/mcp_client/json_rpc_common.rb', line 348

def supported_versions
  defined?(@supported_versions) ? @supported_versions : nil
end

#suppressed_modern_notification?(method) ⇒ Boolean

Returns whether it must be dropped for a modern server.

Parameters:

  • method (String) —

    a notification method

Returns:

  • (Boolean) —

    whether it must be dropped for a modern server



1119
1120
1121
# File 'lib/mcp_client/json_rpc_common.rb', line 1119

def suppressed_modern_notification?(method)
  modern? && REMOVED_MODERN_NOTIFICATIONS.include?(method)
end

#tasks_extension_declared? ⇒ Boolean

Returns whether the host declared the tasks extension.

Returns:

  • (Boolean) —

    whether the host declared the tasks extension



857
858
859
# File 'lib/mcp_client/json_rpc_common.rb', line 857

def tasks_extension_declared?
  declared_extensions.key?(TASKS_EXTENSION)
end

#validate_log_level!(level) ⇒ String

Validate a log level name (logging utility levels).

Parameters:

  • level (String, Symbol) —

    the level

Returns:

  • (String) —

    the normalized level

Raises:

  • (ArgumentError) —

    if it is not a defined level



650
651
652
653
654
655
# File 'lib/mcp_client/json_rpc_common.rb', line 650

def validate_log_level!(level)
  name = level.to_s
  return name if LOG_LEVELS.include?(name)

  raise ArgumentError, "Unknown log level #{level.inspect}; expected one of #{LOG_LEVELS.join(', ')}"
end

#validate_protocol_version!(result) ⇒ String

Validate the protocol version the server negotiated in its initialize result. Per the MCP lifecycle, the server may answer with a different version than requested; if the client cannot support it, it MUST disconnect. Disconnects (via the transport's cleanup) and raises when the version is unsupported or absent.

Parameters:

  • result (Hash) —

    the initialize result

Returns:

  • (String) —

    the negotiated protocol version

Raises:



715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
# File 'lib/mcp_client/json_rpc_common.rb', line 715

def validate_protocol_version!(result)
  version = result['protocolVersion']
  # Only handshake-based revisions are valid here: a server answering
  # initialize with a modern (per-request metadata) version is confused.
  return version if MCPClient::LEGACY_PROTOCOL_VERSIONS.include?(version)

  begin
    cleanup if respond_to?(:cleanup)
  rescue StandardError => e
    @logger.debug("Cleanup after protocol version mismatch failed: #{e.message}")
  end
  raise MCPClient::Errors::ConnectionError,
        "Server negotiated unsupported protocol version #{version.inspect} " \
        "(supported: #{MCPClient::LEGACY_PROTOCOL_VERSIONS.join(', ')}); disconnecting"
end

#validate_result_type!(result) ⇒ void

This method returns an undefined value.

"A resultType of any value unrecognized by the client MUST be considered invalid" (basic/index.mdx). The value is peer-controlled, so only its class or a short prefix reaches the exception message.

Parameters:

  • result (Object) —

    a JSON-RPC result

Raises:



1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
# File 'lib/mcp_client/json_rpc_common.rb', line 1150

def validate_result_type!(result)
  unless result.is_a?(Hash)
    # A modern result MUST be an object. Legacy servers occasionally
    # answered list requests with a bare array; keep tolerating that.
    return unless modern?

    raise MCPClient::Errors::InvalidResultError, "Invalid result: expected an object, got #{result.class}"
  end

  type = MCPClient::JsonRpcCommon.result_type(result)
  return if type.is_a?(String) && accepted_result_types.include?(type)

  shown = type.is_a?(String) ? type[0, 64].inspect : type.class.name
  # The refused result travels with the error: only a modern server names
  # a resultType at all, which is how the discovery probe tells a modern
  # server's unusable answer from a legacy endpoint's.
  raise MCPClient::Errors::InvalidResultError.new(
    "Invalid result: unrecognized resultType #{shown} (accepted: #{accepted_result_types.join(', ')})",
    data: result
  )
end

#with_request_meta(params, claim: :none) ⇒ Hash?

Attach request-level _meta to a params object: the host's request_meta defaults first, then any per-request _meta the caller supplied (which wins over the defaults), then — for a modern server — the reserved protocol fields, which always win. Params are returned untouched when there is nothing to add, so legacy traffic is unchanged.

Parameters:

  • params (Hash, nil) —

    request params (String or Symbol keys)

Returns:

  • (Hash, nil) —

    params with _meta merged under the String key



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
# File 'lib/mcp_client/json_rpc_common.rb', line 426

def with_request_meta(params, claim: :none)
  params = merge_meta_spellings(params)
  defaults = host_request_meta(claim)
  if defaults.empty? && !modern? && !reserved_meta_supplied?(params)
    # Legacy traffic is passed through untouched — a `_meta` the caller
    # supplied goes out as it stands, unless it names a transport-owned key.
    warn_request_log_level_deprecated(params.is_a?(Hash) ? (params['_meta'] || params[:_meta]) : nil)
    return params
  end

  params = params.is_a?(Hash) ? params.dup : {}
  supplied = params.delete('_meta')
  supplied = supplied.is_a?(Hash) ? supplied.transform_keys(&:to_s) : {}
  # The reserved protocol fields are transport-owned in per-call `_meta`
  # exactly as they are in request_meta. Merging the transport's own
  # values over the caller's is not enough: a field the transport omits
  # (clientInfo, once the host set send_client_info = false) has nothing
  # to overwrite the caller's value with, so it would be transmitted
  # anyway. Drop them before the defaults are merged.
  supplied = supplied.except(*PROTECTED_META_KEYS)

  meta = defaults.merge(supplied)
  if modern?
    meta[META_LOG_LEVEL] = @log_level if defined?(@log_level) && @log_level && !meta.key?(META_LOG_LEVEL)
    meta.merge!(required_request_meta)
  end
  # The request carries a copy, never the host's own objects. `request_meta`
  # is read from whatever the host keeps -- a string it may rewrite in
  # place, a container it may add to -- and a merge is shallow, so a
  # request built from it would otherwise go on changing after it was
  # built: the body one fingerprint describes is not the body the next
  # one does, and neither need be the body that was sent (MCP 2026-07-28
  # server/utilities/caching: a result is bound to the parameters of the
  # request that produced it).
  params['_meta'] = MCPClient::DeepCopy.copy(meta)
  warn_request_log_level_deprecated(meta)
  params
end

#with_retry(method = nil) { ... } ⇒ Object

Execute the block with retry/backoff for transient errors only.

Retries genuinely transient failures where the request most likely did not complete at the server: transport/network errors (TransportError, IOError, Errno::ETIMEDOUT/ECONNRESET/EPIPE) and TransientServerError (HTTP 5xx).

It deliberately does NOT retry a plain ServerError. A plain ServerError is raised for a JSON-RPC error response or an HTTP 4xx — cases where the server received and processed (or deterministically rejected) the request. Re-sending those would silently re-execute a non-idempotent operation (e.g. a tools/call), which JSON-RPC provides no way to make safe.

It also never retries a NON_IDEMPOTENT_METHODS request (pass the JSON-RPC method being sent): an ambiguous failure may follow server-side receipt, so those fail fast instead of risking a duplicate execution.

Parameters:

  • method (String, nil) (defaults to: nil) —

    the JSON-RPC method the block sends

Yields:

  • block to execute

Returns:

  • (Object) —

    result of block

Raises:

  • original exception if max retries exceeded or the error is not retryable



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/mcp_client/json_rpc_common.rb', line 71

def with_retry(method = nil)
  attempts = 0
  begin
    yield
  rescue MCPClient::Errors::TransientServerError, MCPClient::Errors::TransportError, IOError,
         Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE => e
    # A timed-out request may still be executing server-side; re-sending
    # it could run a non-idempotent operation twice. Never retry those.
    # An oversized response is the same story from the other direction:
    # the server already ran the request, so a re-send risks a duplicate
    # side effect (and re-does the oversized decode).
    raise if e.is_a?(MCPClient::Errors::RequestTimeoutError)
    raise if e.is_a?(MCPClient::Errors::ResponseTooLargeError)
    # A broken response stream is already handled where it is raised: the
    # transport issues the one replacement request MCP 2026-07-28 calls
    # for and this error means that replacement was lost too. Retrying
    # here would silently turn "re-issue once" into retries + 1 rounds of
    # two attempts each.
    raise if e.is_a?(MCPClient::Errors::ResponseStreamClosedError)

    if NON_IDEMPOTENT_METHODS.include?(method)
      @logger.debug("Not retrying non-idempotent #{method} after error: #{e.message}")
      raise
    end

    attempts += 1
    if attempts <= @max_retries
      delay = @retry_backoff * (2**(attempts - 1))
      @logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
      sleep(delay)
      retry
    end
    raise
  end
end