Module: MCPClient::JsonRpcCommon
- Defined in:
- lib/mcp_client/json_rpc_common.rb
Overview
Shared retry/backoff logic for JSON-RPC transports
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.
%w[tools/call].freeze
Instance Method Summary collapse
-
#build_jsonrpc_notification(method, params) ⇒ Hash
Build a JSON-RPC notification object (no response expected).
-
#build_jsonrpc_request(method, params, id) ⇒ 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_sampling_tools ⇒ void
Opt this transport into declaring tool-use support for sampling (ClientCapabilities.sampling.tools, MCP 2025-11-25 / SEP-1577).
-
#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.
-
#initialization_params ⇒ Hash
Generate initialization parameters for MCP protocol.
-
#ping ⇒ Hash
Ping the server to keep the connection alive.
-
#process_jsonrpc_response(response) ⇒ Object
Process JSON-RPC response.
-
#registered_callback?(ivar) ⇒ Boolean
Whether the callback is registered on this transport.
-
#sampling_tools_supported? ⇒ Boolean
Whether the host opted into sampling tool use.
-
#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.
-
#validate_protocol_version!(result) ⇒ String
Validate the protocol version the server negotiated in its initialize result.
-
#with_retry(method = nil) { ... } ⇒ Object
Execute the block with retry/backoff for transient errors only.
Instance Method Details
#build_jsonrpc_notification(method, params) ⇒ Hash
Build a JSON-RPC notification object (no response expected)
177 178 179 180 181 182 183 |
# File 'lib/mcp_client/json_rpc_common.rb', line 177 def build_jsonrpc_notification(method, params) { 'jsonrpc' => '2.0', 'method' => method, 'params' => params } end |
#build_jsonrpc_request(method, params, id) ⇒ Hash
Build a JSON-RPC request object
164 165 166 167 168 169 170 171 |
# File 'lib/mcp_client/json_rpc_common.rb', line 164 def build_jsonrpc_request(method, params, id) { 'jsonrpc' => '2.0', 'id' => id, 'method' => method, 'params' => params } 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).
152 153 154 155 156 157 |
# File 'lib/mcp_client/json_rpc_common.rb', line 152 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).
125 126 127 128 129 130 |
# File 'lib/mcp_client/json_rpc_common.rb', line 125 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.
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/mcp_client/json_rpc_common.rb', line 233 def client_capabilities capabilities = {} 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 capabilities['roots'] = { 'listChanged' => true } if registered_callback?(:@roots_list_request_callback) 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 # 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.
220 221 222 223 224 |
# File 'lib/mcp_client/json_rpc_common.rb', line 220 def client_info_payload return @client_info if defined?(@client_info) && @client_info { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION } end |
#declare_sampling_tools ⇒ void
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.
260 261 262 |
# File 'lib/mcp_client/json_rpc_common.rb', line 260 def declare_sampling_tools @sampling_tools_supported = true end |
#describe_body_size(body) ⇒ String
A log-safe description of a payload body: its size, never its content.
104 105 106 107 108 |
# File 'lib/mcp_client/json_rpc_common.rb', line 104 def describe_body_size(body) return 'empty body' if body.nil? || body.empty? "#{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.
72 73 74 75 76 77 78 79 80 81 |
# File 'lib/mcp_client/json_rpc_common.rb', line 72 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.
93 94 95 96 97 98 99 |
# File 'lib/mcp_client/json_rpc_common.rb', line 93 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 |
#initialization_params ⇒ Hash
Generate initialization parameters for MCP protocol
187 188 189 190 191 192 193 |
# File 'lib/mcp_client/json_rpc_common.rb', line 187 def initialization_params { 'protocolVersion' => MCPClient::PROTOCOL_VERSION, 'capabilities' => client_capabilities, 'clientInfo' => client_info_payload } end |
#ping ⇒ Hash
Ping the server to keep the connection alive
115 116 117 |
# File 'lib/mcp_client/json_rpc_common.rb', line 115 def ping rpc_request('ping') end |
#process_jsonrpc_response(response) ⇒ Object
Process JSON-RPC response
279 280 281 282 283 |
# File 'lib/mcp_client/json_rpc_common.rb', line 279 def process_jsonrpc_response(response) raise MCPClient::Errors::ServerError, response['error']['message'] if response['error'] response['result'] end |
#registered_callback?(ivar) ⇒ Boolean
Returns whether the callback is registered on this transport.
266 267 268 |
# File 'lib/mcp_client/json_rpc_common.rb', line 266 def registered_callback?(ivar) instance_variable_defined?(ivar) && !instance_variable_get(ivar).nil? end |
#sampling_tools_supported? ⇒ Boolean
Returns whether the host opted into sampling tool use.
271 272 273 |
# File 'lib/mcp_client/json_rpc_common.rb', line 271 def sampling_tools_supported? instance_variable_defined?(:@sampling_tools_supported) && @sampling_tools_supported 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.
138 139 140 141 142 143 144 145 |
# File 'lib/mcp_client/json_rpc_common.rb', line 138 def (arguments) return [arguments, nil] unless arguments.is_a?(Hash) = arguments[:_meta] || arguments['_meta'] return [arguments, nil] unless [arguments.except(:_meta, '_meta'), ] 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.
203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/mcp_client/json_rpc_common.rb', line 203 def validate_protocol_version!(result) version = result['protocolVersion'] return version if MCPClient::SUPPORTED_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::SUPPORTED_PROTOCOL_VERSIONS.join(', ')}); disconnecting" 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.
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/mcp_client/json_rpc_common.rb', line 33 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) 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 |