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

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



108
109
110
111
112
113
114
# File 'lib/mcp_client/json_rpc_common.rb', line 108

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



95
96
97
98
99
100
101
102
# File 'lib/mcp_client/json_rpc_common.rb', line 95

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



83
84
85
86
87
88
# File 'lib/mcp_client/json_rpc_common.rb', line 83

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)


56
57
58
59
60
61
# File 'lib/mcp_client/json_rpc_common.rb', line 56

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



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/mcp_client/json_rpc_common.rb', line 164

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)


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

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.



191
192
193
# File 'lib/mcp_client/json_rpc_common.rb', line 191

def declare_sampling_tools
  @sampling_tools_supported = true
end

#initialization_paramsHash

Generate initialization parameters for MCP protocol

Returns:

  • (Hash)

    the initialization parameters



118
119
120
121
122
123
124
# File 'lib/mcp_client/json_rpc_common.rb', line 118

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:



46
47
48
# File 'lib/mcp_client/json_rpc_common.rb', line 46

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:



210
211
212
213
214
# File 'lib/mcp_client/json_rpc_common.rb', line 210

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



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

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



202
203
204
# File 'lib/mcp_client/json_rpc_common.rb', line 202

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]



69
70
71
72
73
74
75
76
# File 'lib/mcp_client/json_rpc_common.rb', line 69

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:



134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/mcp_client/json_rpc_common.rb', line 134

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 { ... } ⇒ 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.

Yields:

  • block to execute

Returns:

  • (Object)

    result of block

Raises:

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



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/mcp_client/json_rpc_common.rb', line 20

def with_retry
  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.
    raise if e.is_a?(MCPClient::Errors::RequestTimeoutError)

    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