Class: Pikuri::Mcp::Servers
- Inherits:
-
Object
- Object
- Pikuri::Mcp::Servers
- Defined in:
- lib/pikuri/mcp/servers.rb
Overview
Runtime side of MCP support: spawns the configured servers, holds one ClientWrapper per live server, and orchestrates the Verifier / Synthesizer passes that turn a raw MCP surface into the <available_mcps> snippet. The mcp gem dependency now lives one level down in ClientWrapper. Constructed from a Registry (config), eager-starts every entry at #initialize, registers an at_exit hook to close cleanly on process exit.
Transports
Transport selection (stdio vs HTTP) and the mcp gem’s MCP::Client::Stdio / MCP::Client::HTTP construction live inside ClientWrapper. Servers stays transport-agnostic: it asks the wrapper for server_info / tools at boot and delegates per-tool calls back to the wrapper, which handles restart-on- subprocess-death retry internally (see ClientWrapper for the retry contract).
Lifecycle and the Subprocess.spawn carve-out
The Subprocess.spawn chokepoint carve-out applies only to stdio entries. The mcp gem owns the subprocess lifecycle: MCP::Client::Stdio calls Process.spawn internally; forcing it through pikuri’s Subprocess.spawn chokepoint would mean either forking the gem or threading custom IO pipes through its API — duplicating work the gem exists to do. So stdio MCP is the documented exception to the chokepoint convention in CLAUDE.md “Scope decisions”: the gem spawns, we own #close (via ClientWrapper#close), and at_exit { close } runs from inside #initialize to make sure cleanup happens on every exit path. Graceful close on a stdio transport closes stdin → server’s read loop hits EOF → server self-terminates, per the MCP spec.
HTTP entries don’t spawn anything — they’re plain Faraday — so the carve-out doesn’t apply. at_exit { close } still runs to send the session-termination DELETE per spec.
What lives where
-
ClientWrapper: the per-server client/transport lifecycle plus restart-and-retry on stdio subprocess death. Servers holds one wrapper per live server in @wrappers.
-
Servers: eager startup, audit logging, the wrappers hash,
#close, the <available_mcps> snippet renderer, and the sharedregister_tools_with_agentthat actually wires MCP tools into a given agent’s underlyingRubyLLM::Chat. -
View: thin facade for sub-agents — same public surface as Servers but every method delegates to the root. Sub-agents share the parent’s live wrappers; only the root owns
#close. -
Connect: the
mcp_connecttool the LLM calls. Private inner Tool subclass instantiated per-agent by Agent#initialize. Tracks its ownSetof activated ids so re-activation comes back as an LLM-actionable “Error: …” String. Each agent (parent + each sub-agent) gets its own instance with its own set, so activation is strictly per-agent.
Why MCP tools bypass Pikuri::Tool
MCP tools arrive with JSON Schema in their input_schema; RubyLLM::Tool.params(schema) accepts that shape directly. We therefore synthesize RubyLLM::Tool subclasses inside this class and feed them through Agent#internal_add_tool — no Pikuri::Tool::Parameters in the middle. The strict-validation contract pikuri commits to for native tools is intentionally not extended to MCP tools in v1; MCP-side validation catches bad input, and the provenance prefix + audit log are the compensating transparency.
Defined Under Namespace
Classes: Connect
Instance Attribute Summary collapse
-
#live_ids ⇒ Array<String>
readonly
Ids of servers that successfully started.
Instance Method Summary collapse
-
#build_mcp_connect_tool(agent) ⇒ Connect
Build the
mcp_connecttool bound to the given agent. -
#close ⇒ void
Close every live transport, terminating the spawned MCP server subprocesses.
-
#empty? ⇒ Boolean
True when no servers are alive (either the registry was empty, or every configured server failed to start).
- #initialize(registry, synthesizer: nil, verifier: nil) ⇒ Servers constructor
-
#register_tools_with_agent(id, agent) ⇒ Integer
Register every tool exposed by server
idintoagent‘s underlyingRubyLLM::Chat. -
#system_prompt_snippet ⇒ String
System-prompt block advertising every live MCP server to the LLM.
Constructor Details
#initialize(registry, synthesizer: nil, verifier: nil) ⇒ Servers
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/pikuri/mcp/servers.rb', line 96 def initialize(registry, synthesizer: nil, verifier: nil) @registry = registry @synthesizer = synthesizer @verifier = verifier @wrappers = {} # id => ClientWrapper (only for servers that started) @tools_cache = {} # id => Array<MCP::Client::Tool> @descriptions = {} # id => short description shown in <available_mcps> @live_ids = [] @closed = false # Register cleanup *before* starting any server. If a later # +start_one+ raises (notably Cancelled from synthesis), the # exception propagates out of {#initialize} and the at_exit # handler still fires at process exit to close any # wrappers that did get opened. register_at_exit start_all end |
Instance Attribute Details
#live_ids ⇒ Array<String> (readonly)
Returns ids of servers that successfully started. Excludes any whose subprocess spawn or handshake failed (those are logged as warnings and dropped). Identical to Registry#ids when no startup failures occurred.
119 120 121 |
# File 'lib/pikuri/mcp/servers.rb', line 119 def live_ids @live_ids end |
Instance Method Details
#build_mcp_connect_tool(agent) ⇒ Connect
Build the mcp_connect tool bound to the given agent. The agent passes itself so the tool can call agent.internal_add_tool without a circular constructor dance. Each call returns a fresh Connect with an empty activation set.
134 135 136 |
# File 'lib/pikuri/mcp/servers.rb', line 134 def build_mcp_connect_tool(agent) Connect.new(servers: self, agent: agent) end |
#close ⇒ void
This method returns an undefined value.
Close every live transport, terminating the spawned MCP server subprocesses. Idempotent. Registered via at_exit from #initialize so process exit always cleans up, but callers may invoke it explicitly (e.g. tests that swap in a fresh Pikuri::Mcp::Servers). ClientWrapper#close catches and logs its own transport-close errors, so the loop here doesn’t need a rescue.
197 198 199 200 201 202 |
# File 'lib/pikuri/mcp/servers.rb', line 197 def close return if @closed @wrappers.each_value(&:close) @closed = true end |
#empty? ⇒ Boolean
Returns true when no servers are alive (either the registry was empty, or every configured server failed to start).
123 124 125 |
# File 'lib/pikuri/mcp/servers.rb', line 123 def empty? @live_ids.empty? end |
#register_tools_with_agent(id, agent) ⇒ Integer
177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/pikuri/mcp/servers.rb', line 177 def register_tools_with_agent(id, agent) raise ArgumentError, "MCP server #{id.inspect} is not live" unless @live_ids.include?(id) wrapper = @wrappers.fetch(id) tools = @tools_cache.fetch(id) tools.each do |mcp_tool| rb_tool = synthesize_ruby_llm_tool(server_id: id, wrapper: wrapper, mcp_tool: mcp_tool) agent.internal_add_tool(rb_tool) end tools.size end |
#system_prompt_snippet ⇒ String
System-prompt block advertising every live MCP server to the LLM. Empty string when no servers are alive so the caller can unconditionally concatenate, same shape as Skill::Catalog#format_for_prompt. The block is not duplicated into the mcp_connect tool description — the available-ids list lives in one semantic home.
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/pikuri/mcp/servers.rb', line 146 def system_prompt_snippet return '' if empty? lines = [ '', '', 'The following MCP (Model Context Protocol) servers expose tools you can pull into your toolset on demand.', "Call `mcp_connect` with a server's id to register its tools. Schemas only enter context after you connect.", '', '<available_mcps>' ] @live_ids.each do |id| lines << ' <mcp>' lines << " <id>#{escape_xml(id)}</id>" lines << " <description>#{escape_xml(@descriptions[id] || '(no description)')}</description>" lines << ' </mcp>' end lines << '</available_mcps>' lines.join("\n") end |