Module: MCPClient::JsonRpcCommon::ErrorBodies

Included in:
MCPClient::JsonRpcCommon
Defined in:
lib/mcp_client/json_rpc_common/error_bodies.rb

Overview

Reading a JSON-RPC error object out of an HTTP error response body, bounded in size and inflated only within that bound. Mixed into MCPClient::JsonRpcCommon, so every transport that includes it has these helpers.

Constant Summary collapse

MAX_ERROR_BODY_BYTES =

Ceiling on the size of an HTTP error body inspected for a JSON-RPC error. A protocol error response is a few hundred bytes; the body is peer-controlled, so anything larger is not parsed at all rather than handed to JSON.parse.

64 * 1024

Instance Method Summary collapse

Instance Method Details

#decoded_error_body(response) ⇒ Object?

The error body as a decoded object.

A host may configure the connection (faraday_config) with response middleware — conn.response :json — that decodes the body before it reaches this transport, on the exception path (raise_error) as well as the response path. That already-parsed body carries the same protocol error, so it is accepted as-is; only a raw String body is size-bounded, gunzipped and parsed here (the middleware has already spent the memory for the ones it decoded).

Parameters:

  • response (Faraday::Response) —

    the HTTP response

Returns:

  • (Object, nil) —

    the decoded body, or nil when it cannot be read



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/mcp_client/json_rpc_common/error_bodies.rb', line 63

def decoded_error_body(response)
  body = response.body
  return body if body.is_a?(Hash)
  return nil unless body.is_a?(String) && !body.empty?
  return nil if oversized_error_body?(body)

  headers = response.respond_to?(:headers) ? response.headers || {} : {}
  encoding = headers['content-encoding'] || headers['Content-Encoding'] || ''
  body = gunzip_bounded(body) if encoding.include?('gzip')
  return nil if body.nil?

  JSON.parse(body)
rescue JSON::ParserError, Zlib::Error => e
  @logger.debug("HTTP error body is not a JSON-RPC error: #{e.class}")
  nil
end

#gunzip_bounded(body) ⇒ String?

Decompress a gzip error body, giving up once the expansion passes the inspection ceiling (a compressed 4xx body is peer-controlled too).

Parameters:

  • body (String) —

    gzip data

Returns:

  • (String, nil) —

    the decompressed body, or nil when too large



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

def gunzip_bounded(body)
  reader = Zlib::GzipReader.new(StringIO.new(body))
  expanded = reader.read(MAX_ERROR_BODY_BYTES + 1) || ''
  return expanded if expanded.bytesize <= MAX_ERROR_BODY_BYTES

  @logger.debug("Ignoring gzip HTTP error body expanding past #{MAX_ERROR_BODY_BYTES} bytes")
  nil
ensure
  reader&.close
end

#jsonrpc_error_from_http_response(response, fallback) ⇒ MCPClient::Errors::ServerError

Build the error for a 4xx response: the typed JSON-RPC error when the body is a JSON-RPC error response (with the HTTP status prefixed to the peer's message), otherwise a plain ServerError with the fallback text.

Parameters:

  • response (Faraday::Response) —

    the 4xx response

  • fallback (String) —

    message when the body carries no JSON-RPC error

Returns:



19
20
21
22
23
24
25
26
27
# File 'lib/mcp_client/json_rpc_common/error_bodies.rb', line 19

def jsonrpc_error_from_http_response(response, fallback)
  status = response.status
  error = jsonrpc_error_in_body(response)
  return MCPClient::Errors::ServerError.new(fallback).tap { |e| e.http_status = status } unless error

  typed = MCPClient::Errors::ServerError.from_jsonrpc(error)
  typed.class.new("#{fallback}: #{typed.message}", code: typed.code, data: typed.data)
       .tap { |e| e.http_status = status }
end

#jsonrpc_error_in_body(response) ⇒ Hash?

Extract a JSON-RPC error object from an HTTP error body, if there is one. Only a JSON-RPC 2.0 error response is recognized; anything else is ignored.

Parameters:

  • response (Faraday::Response) —

    the HTTP response

Returns:

  • (Hash, nil) —

    the JSON-RPC error member, or nil



40
41
42
43
44
45
46
47
48
49
50
# File 'lib/mcp_client/json_rpc_common/error_bodies.rb', line 40

def jsonrpc_error_in_body(response)
  return nil unless response.respond_to?(:body)

  data = decoded_error_body(response)
  # Only a JSON-RPC 2.0 error response counts; an arbitrary JSON body
  # with an "error" member is not a protocol error.
  return nil unless data.is_a?(Hash) && (data['jsonrpc'] || data[:jsonrpc]) == '2.0'

  error = data['error'] || data[:error]
  error.is_a?(Hash) ? error : nil
end

#oversized_error_body?(body) ⇒ Boolean

Returns whether it exceeds the inspection ceiling (logged).

Parameters:

  • body (String) —

    an HTTP error body

Returns:

  • (Boolean) —

    whether it exceeds the inspection ceiling (logged)



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

def oversized_error_body?(body)
  return false if body.bytesize <= MAX_ERROR_BODY_BYTES

  @logger.debug("Ignoring HTTP error body of #{body.bytesize} bytes (over #{MAX_ERROR_BODY_BYTES})")
  true
end