Module: MCPClient::JsonRpcCommon
- Includes:
- DeprecationNotices, InputRoundTrips, Envelopes, ErrorBodies, InputWaits, RequestMetadata, ResultCaching, ResultCompleteness, RoundTripMarker, SessionPin, SubscriptionSupport
- 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
_metakey 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
-
#request_meta ⇒ Hash, ...
Host-supplied metadata merged into every request's
_meta: a Hash, or a callable returning one, evaluated per request. -
#send_client_info ⇒ 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").
Class Method Summary collapse
-
.restore_wire_keys(value) ⇒ Object
Restore the wire spelling of a peer's own JSON object.
-
.result_type(result) ⇒ Object
The resultType of a result object.
Instance Method Summary collapse
-
#accepted_result_types ⇒ Array<String>
Result types this transport accepts.
-
#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.
-
#begin_era_probe ⇒ void
Begin proposing a protocol version that the server has not confirmed: until the probe settles, the era is unknown.
-
#build_jsonrpc_notification(method, params) ⇒ Hash
Build a JSON-RPC notification object (no response expected).
-
#build_jsonrpc_request(method, params, id, note: true) ⇒ Hash
Build a JSON-RPC request object.
-
#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).
-
#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).
-
#client_capabilities ⇒ Hash
Declared client capabilities, derived from the server-request callbacks the host actually registered before connecting.
-
#client_info_payload ⇒ Hash
The Implementation object sent as clientInfo: the host-provided info when configured (client_info=), otherwise the gem's identity.
-
#declare_extension(identifier, settings = {}) ⇒ void
Declare support for an MCP extension (basic/versioning "Extension Negotiation"): advertised under
clientCapabilities.extensionson every modern request. -
#declare_sampling_tools ⇒ void
deprecated
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.
-
#declared_extensions ⇒ Hash
Declared extension id => settings.
-
#describe_body_size(body) ⇒ String
A log-safe description of a payload body: its size, never its content.
-
#describe_jsonrpc_message(message) ⇒ String
A log-safe description of a JSON-RPC message: its method and id only.
-
#describe_parse_error(error, payload = nil) ⇒ String
A log-safe description of a JSON parse failure.
-
#discovery_cache_scope ⇒ String?
The cacheScope the last DiscoverResult declared.
-
#discovery_clock ⇒ 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.
-
#discovery_fresh? ⇒ Boolean
Whether the last DiscoverResult is still fresh by its own ttlMs.
-
#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>?=. -
#era_probe_in_flight? ⇒ Boolean
Whether protocol_version is only a proposal so far.
-
#host_request_meta(claim = :none) ⇒ Hash
The host's request_meta for one message, with any reserved protocol keys it tries to set dropped.
-
#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.
-
#initialization_params ⇒ Hash
Generate initialization parameters for MCP protocol.
-
#mcp_name_header_value(request) ⇒ String?
The encoded Mcp-Name value, or nil when the method has none.
-
#merge_meta_spellings(params) ⇒ Hash?
A caller's
_metasupplied under the Symbol key, or under both spellings, becomes one String-keyed_meta(the String one winning on a clash). -
#modern? ⇒ Boolean
Whether this server speaks a modern (per-request metadata, no handshake) protocol revision (MCP 2026-07-28 basic/versioning "Terminology").
-
#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").
-
#notify_cache_invalidation(method, params) ⇒ void
Tell a host layered above this transport to drop the caches a notification invalidates, surviving whatever it does with it.
-
#ping ⇒ Hash
Ping the server to keep the connection alive.
-
#process_jsonrpc_response(response, method: nil) ⇒ Object
Process JSON-RPC response.
-
#protocol_era ⇒ Symbol?
The server's established protocol era.
-
#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.
-
#record_discovery_freshness(result, entry = nil) ⇒ void
Record a DiscoverResult's cache hints (CacheableResult: ttlMs, cacheScope).
-
#record_server_info(result, method: nil) ⇒ void
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. -
#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.
-
#registered_callback?(ivar) ⇒ Boolean
Whether the callback is registered on this transport.
-
#reject_task_result_discover!(result) ⇒ void
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.
-
#reject_task_result_on_unsupported_method!(method, result) ⇒ void
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").
-
#request_meta_claim(method, note) ⇒ Symbol
Which claim a message being built makes on the evaluation the open operation reserved (see RequestMetadata::HeldRequestMeta).
-
#required_request_meta ⇒ Hash
The reserved per-request protocol fields for a modern server (basic/index "Per-request protocol fields").
-
#reserved_meta_supplied?(params) ⇒ Boolean
Whether a caller's params carry a
_metakey the transport owns. -
#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).
-
#sampling_tools_supported? ⇒ Boolean
Whether the host opted into sampling tool use.
-
#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.
-
#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).
-
#send_client_info? ⇒ Boolean
Whether clientInfo is sent (default true).
-
#settle_era_probe ⇒ void
The probe has been answered (or given up on): the era is now whatever protocol_version says.
-
#spend_held_request_meta(held, claim) ⇒ Hash
The held evaluation.
-
#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.
-
#supported_versions ⇒ Array<String>?
Protocol versions a modern server advertised in its DiscoverResult.
-
#suppressed_modern_notification?(method) ⇒ Boolean
Whether it must be dropped for a modern server.
-
#tasks_extension_declared? ⇒ Boolean
Whether the host declared the tasks extension.
-
#validate_log_level!(level) ⇒ String
Validate a log level name (logging utility levels).
-
#validate_protocol_version!(result) ⇒ String
Validate the protocol version the server negotiated in its initialize result.
-
#validate_result_type!(result) ⇒ void
"A resultType of any value unrecognized by the client MUST be considered invalid" (basic/index.mdx).
-
#with_request_meta(params, claim: :none) ⇒ Hash?
Attach request-level
_metato a params object: the host's request_meta defaults first, then any per-request_metathe caller supplied (which wins over the defaults), then — for a modern server — the reserved protocol fields, which always win. -
#with_retry(method = nil) { ... } ⇒ Object
Execute the block with retry/backoff for transient errors only.
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.
369 370 371 |
# File 'lib/mcp_client/json_rpc_common.rb', line 369 def @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").
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.
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.
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.
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.
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 = result['_meta'] unless .nil? || .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_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)
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 = (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
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 = (params, 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).
233 234 235 236 237 238 |
# File 'lib/mcp_client/json_rpc_common.rb', line 233 def build_named_request_params(name, arguments) args, = (arguments) params = { 'name' => name, 'arguments' => args } params['_meta'] = if 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).
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.
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.
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.
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
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.
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.
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.
150 151 152 153 154 155 156 157 158 159 |
# File 'lib/mcp_client/json_rpc_common.rb', line 150 def () return '(non-object message)' unless .is_a?(Hash) parts = [] parts << (['method'] || [:method] || '(response)').to_s id = ['id'] || [:id] parts << "id=#{id}" if id parts << 'error' if ['error'] || [: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.
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.[/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.
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.
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.
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>?=.
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.
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.
525 526 527 528 529 530 531 532 533 534 535 536 537 |
# File 'lib/mcp_client/json_rpc_common.rb', line 525 def (claim = :none) held = claim == :none ? nil : return (held, claim) if held&.evaluated source = source = source.call if source.respond_to?(:call) = source.is_a?(Hash) ? source.transform_keys(&:to_s).except(*PROTECTED_META_KEYS) : {} return unless held held.evaluated = true held.value = (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.
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
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.
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.
472 473 474 475 476 477 478 479 480 481 482 |
# File 'lib/mcp_client/json_rpc_common.rb', line 472 def (params) return params unless params.is_a?(Hash) && params.key?(:_meta) params = params.dup = params.delete(:_meta) = params['_meta'] = .is_a?(Hash) ? .transform_keys(&:to_s) : {} = .is_a?(Hash) ? .transform_keys(&:to_s) : {} params['_meta'] = .merge() 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.
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").
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. = request['params'].is_a?(Hash) ? request['params']['_meta'] : nil version = (.is_a?(Hash) && [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.
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.)}") end |
#ping ⇒ Hash
Ping the server to keep the connection alive
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
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.
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.
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.
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.
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.
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.
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.
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").
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.
274 275 276 277 278 279 |
# File 'lib/mcp_client/json_rpc_common.rb', line 274 def (method, note) return :model unless note held = 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").
507 508 509 510 511 512 |
# File 'lib/mcp_client/json_rpc_common.rb', line 507 def = { META_PROTOCOL_VERSION => protocol_version } [META_CLIENT_INFO] = client_info_payload if send_client_info? [META_CLIENT_CAPABILITIES] = client_capabilities 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.
495 496 497 498 499 500 501 502 |
# File 'lib/mcp_client/json_rpc_common.rb', line 495 def (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.
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.
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.
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).
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).
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.
542 543 544 545 |
# File 'lib/mcp_client/json_rpc_common.rb', line 542 def (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.
219 220 221 222 223 224 225 226 |
# File 'lib/mcp_client/json_rpc_common.rb', line 219 def (arguments) return [arguments, nil] unless arguments.is_a?(Hash) = arguments[:_meta] || arguments['_meta'] return [arguments, nil] unless [arguments.except(:_meta, '_meta'), ] end |
#supported_versions ⇒ Array<String>?
Protocol versions a modern server advertised in its DiscoverResult.
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.
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.
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).
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.
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.}") 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.
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.
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 (params, claim: :none) params = (params) defaults = (claim) if defaults.empty? && !modern? && !(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) = defaults.merge(supplied) if modern? [META_LOG_LEVEL] = @log_level if defined?(@log_level) && @log_level && !.key?(META_LOG_LEVEL) .merge!() 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() warn_request_log_level_deprecated() 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.
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.}") raise end attempts += 1 if attempts <= @max_retries delay = @retry_backoff * (2**(attempts - 1)) @logger.debug("Retry attempt #{attempts} after error: #{e.}, sleeping #{delay}s") sleep(delay) retry end raise end end |