Class: Pikuri::Mcp::ClientWrapper

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/mcp/client_wrapper.rb

Overview

Wraps one MCP::Client plus its transport with retry-and-restart semantics for stdio-subprocess death. The wrapper owns the client/transport lifecycle: on construction it builds a fresh transport and runs the initialize handshake; on a subprocess-died failure during #call_tool it closes the dead transport, spawns a fresh one, re-handshakes, and retries — up to MAX_CALL_ATTEMPTS times total. After exhaustion the underlying RequestHandlerError propagates and the synthesized tool’s execute closure converts it to an Error: … observation like any other failure.

Why only subprocess-death messages

Once an stdio subprocess dies, MCP::Client::Stdio‘s state is irreversibly broken: @wait_thread.alive? returns false forever and every subsequent call raises from ensure_running! without ever talking to the server. Restarting is the only way back. Other RequestHandlerError failures (protocol mismatch on the initialize handshake, JSON parse error, ValidationError on a malformed response) leave the transport usable; a restart there would just retry the same logical mistake and waste latency.

HTTP entries

The wrapper accepts Registry::HttpEntry too, but no HTTP failure raises with the subprocess-death messages we match on, so the retry path naturally never triggers — the wrapper is a pass-through for HTTP. If transient HTTP retry is ever wanted, it should land here, not in Servers.

Lifecycle responsibility

Spawning happens inside #initialize (via spawn_fresh!), and if the initialize handshake fails the wrapper closes its own half-opened transport before re-raising. That means a caller who saw ClientWrapper.new(entry) raise does NOT need to also close anything — the wrapper either returns a fully-initialized object or no object at all.

Constant Summary collapse

MAX_CALL_ATTEMPTS =

Maximum number of call_tool attempts including the initial one, before propagating the underlying exception. 3 means: one normal try plus up to two restart-then-retry attempts.

3

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(entry) ⇒ ClientWrapper

Returns a new instance of ClientWrapper.

Parameters:

Raises:

  • (StandardError)

    anything raised by the underlying transport’s spawn / connect handshake. The half-opened transport is closed before the exception propagates.



89
90
91
92
93
# File 'lib/pikuri/mcp/client_wrapper.rb', line 89

def initialize(entry)
  @entry = entry
  @closed = false
  spawn_fresh!
end

Instance Attribute Details

#clientMCP::Client (readonly)

Returns the current live client. Replaced on each restart, so callers must not cache this across #call_tool invocations. Exposed for one-shot boot-time reads of server_info / tools by Servers, Verifier, Synthesizer, and Pikuri::Mcp::Cache.

Returns:



82
83
84
# File 'lib/pikuri/mcp/client_wrapper.rb', line 82

def client
  @client
end

#entryRegistry::StdioEntry, Registry::HttpEntry (readonly)

Returns the registry entry the wrapper builds (and rebuilds) transports from.

Returns:



75
76
77
# File 'lib/pikuri/mcp/client_wrapper.rb', line 75

def entry
  @entry
end

Instance Method Details

#call_tool(tool:, arguments:) ⇒ Hash

Call an MCP tool on the underlying server. On any failure whose message matches SUBPROCESS_DEAD_PATTERNS, closes the dead transport, spawns a fresh one, re-runs the initialize handshake, and retries the call — up to MAX_CALL_ATTEMPTS times total. Other failures (server-returned JSON-RPC errors don’t reach this method — they come back inside the response Hash; non-recoverable transport errors like protocol-version mismatch or JSON parse errors) propagate on the first attempt without restart.

Parameters:

  • tool (MCP::Client::Tool)

    the tool object obtained from #tools at boot. Only name is used at call time, so a reference captured before a restart keeps working as long as the new server still exposes that tool name.

  • arguments (Hash)

    passed verbatim to the underlying call_tool.

Returns:

  • (Hash)

    the JSON-RPC response.

Raises:

  • (MCP::Client::RequestHandlerError)

    when retries are exhausted, or on the first non-recoverable failure.



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/pikuri/mcp/client_wrapper.rb', line 131

def call_tool(tool:, arguments:)
  attempt = 1
  begin
    @client.call_tool(tool: tool, arguments: arguments)
  rescue MCP::Client::RequestHandlerError => e
    raise unless subprocess_dead?(e)
    raise if attempt >= MAX_CALL_ATTEMPTS

    LOGGER.warn(
      "MCP server #{@entry.id.inspect} subprocess died " \
      "(#{e.message.inspect}); restarting and retrying " \
      "(attempt #{attempt + 1}/#{MAX_CALL_ATTEMPTS})."
    )
    restart!
    attempt += 1
    retry
  end
end

#closevoid

This method returns an undefined value.

Close the underlying transport. Idempotent — subsequent calls are no-ops. After close, #call_tool will fail on the dead client.



155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/pikuri/mcp/client_wrapper.rb', line 155

def close
  return if @closed

  begin
    @transport&.close
  rescue StandardError => e
    LOGGER.warn(
      "Error closing MCP transport for #{@entry.id.inspect}: " \
      "#{e.class}: #{e.message}"
    )
  end
  @closed = true
end

#server_infoHash?

Server’s cached InitializeResult, as exposed by the transport. Delegates to #client.

Returns:

  • (Hash, nil)


99
100
101
# File 'lib/pikuri/mcp/client_wrapper.rb', line 99

def server_info
  @client.server_info
end

#toolsArray<MCP::Client::Tool>

Enumerate the server’s tools via MCP::Client#tools. Each call is a real round-trip — callers typically invoke this once at boot and cache the result.

Returns:

  • (Array<MCP::Client::Tool>)


108
109
110
# File 'lib/pikuri/mcp/client_wrapper.rb', line 108

def tools
  @client.tools
end