Module: MCPClient::JsonRpcCommon

Included in:
HttpTransportBase, ServerSSE::JsonRpcTransport, ServerStdio::JsonRpcTransport
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

Instance Method Details

#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



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

Parameters:

  • method (String)

    JSON-RPC method name

  • params (Hash)

    parameters for the request

  • id (Integer)

    request ID

Returns:

  • (Hash)

    the 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).

Parameters:

  • name (String)

    tool or prompt name

  • arguments (Hash)

    user-supplied arguments (possibly carrying _meta)

Returns:

  • (Hash)

    params hash for the JSON-RPC request



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

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)


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_capabilitiesHash

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



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_payloadHash

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

Returns:

  • (Hash)


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_toolsvoid

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.

Parameters:

  • body (String, nil)

    the response/request body

Returns:

  • (String)


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.

Parameters:

  • message (Hash)

    a JSON-RPC request, notification or response

Returns:

  • (String)

    method/id summary, never payload content



72
73
74
75
76
77
78
79
80
81
# File 'lib/mcp_client/json_rpc_common.rb', line 72

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



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.message[/at line \d+ column \d+/]
  parts = ['malformed JSON']
  parts << location if location
  parts << describe_body_size(payload) if payload
  parts.join(', ')
end

#initialization_paramsHash

Generate initialization parameters for MCP protocol

Returns:

  • (Hash)

    the initialization parameters



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

#pingHash

Ping the server to keep the connection alive

Returns:

  • (Hash)

    the result of the ping request

Raises:



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

Parameters:

  • response (Hash)

    the parsed JSON-RPC response

Returns:

  • (Object)

    the result field from the response

Raises:



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.

Parameters:

  • ivar (Symbol)

    callback instance variable name

Returns:

  • (Boolean)

    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.

Returns:

  • (Boolean)

    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.

Parameters:

  • arguments (Hash, nil)

    user-supplied arguments

Returns:

  • (Array(Hash, Hash|nil))

    [arguments without _meta, _meta or nil]



138
139
140
141
142
143
144
145
# File 'lib/mcp_client/json_rpc_common.rb', line 138

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

#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:



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.message}")
  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.

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



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.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