Class: Pikuri::Mcp::ClientWrapper
- Inherits:
-
Object
- Object
- Pikuri::Mcp::ClientWrapper
- 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_toolattempts 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
-
#client ⇒ MCP::Client
readonly
The current live client.
-
#entry ⇒ Registry::StdioEntry, Registry::HttpEntry
readonly
The registry entry the wrapper builds (and rebuilds) transports from.
Instance Method Summary collapse
-
#call_tool(tool:, arguments:) ⇒ Hash
Call an MCP tool on the underlying server.
-
#close ⇒ void
Close the underlying transport.
-
#initialize(entry) ⇒ ClientWrapper
constructor
A new instance of ClientWrapper.
-
#server_info ⇒ Hash?
Server’s cached
InitializeResult, as exposed by the transport. -
#tools ⇒ Array<MCP::Client::Tool>
Enumerate the server’s tools via MCP::Client#tools.
Constructor Details
#initialize(entry) ⇒ ClientWrapper
Returns a new instance of ClientWrapper.
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
#client ⇒ MCP::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.
82 83 84 |
# File 'lib/pikuri/mcp/client_wrapper.rb', line 82 def client @client end |
#entry ⇒ Registry::StdioEntry, Registry::HttpEntry (readonly)
Returns the registry entry the wrapper builds (and rebuilds) transports from.
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.
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..inspect}); restarting and retrying " \ "(attempt #{attempt + 1}/#{MAX_CALL_ATTEMPTS})." ) restart! attempt += 1 retry end end |
#close ⇒ void
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.}" ) end @closed = true end |
#server_info ⇒ Hash?
Server’s cached InitializeResult, as exposed by the transport. Delegates to #client.
99 100 101 |
# File 'lib/pikuri/mcp/client_wrapper.rb', line 99 def server_info @client.server_info end |
#tools ⇒ Array<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.
108 109 110 |
# File 'lib/pikuri/mcp/client_wrapper.rb', line 108 def tools @client.tools end |