Class: Pikuri::Mcp::Servers

Inherits:
Object
  • Object
show all
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) without side effects; the owner calls #start_all to spawn every entry and registers #close with Finalizers in between (or uses the Servers.start convenience when it doesn’t need that gap).

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: two-phase, and the cleanup gap

#initialize is pure; #start_all does the spawning. The split exists so the owner can arm #close between them — before the failure-prone startup runs. Extension does exactly that:

servers = Mcp::Servers.new(registry, ...)   # pure
c.on_close { servers.close }                 # cleanup armed
servers.start_all                            # may raise (Cancelled, injection)

c.on_close writes straight to the agent’s live handler list (see Agent::Configurator‘s on_close_sink), so if #start_all raises, the agent’s constructor rescue still fires this and closes any half-spawned servers — and on the happy path the same handler closes them on agent.close / at process exit. That central handling is why Servers itself needs no Finalizers coupling.

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). 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; #close still sends 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: two-phase startup (new + #start_all), audit logging, the wrappers hash, #close, the <available_mcps> snippet renderer, and the shared register_tools_with_agent that actually wires MCP tools into a given agent’s underlying RubyLLM::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_connect tool the LLM calls. Private inner Tool subclass instantiated per-agent by Agent#initialize. Tracks its own Set of 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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(registry, synthesizer: nil, verifier: nil) ⇒ Servers

Construct *without side effects* — no subprocess spawns, no synthesizer/verifier LLM calls. Those happen in #start_all, which the caller invokes once it has registered #close for cleanup. Splitting the two means a #start_all that raises (notably Cancelled from synthesis) can’t strand half-spawned servers: the owner has already registered cleanup.

Parameters:

  • registry (Registry)

    configured servers to start.

  • synthesizer (Synthesizer, nil) (defaults to: nil)

    when set, invoked from #resolve_description for servers whose initialize handshake doesn’t carry an instructions or serverInfo.title field useful enough to show the LLM. Agent#initialize builds and threads a Pikuri::Mcp::Synthesizer (with thinker + cache) when its synthesize_descriptions: kwarg is true. Pass nil (the default) to skip synthesis and rely on the static fallback chain.

  • verifier (Verifier, nil) (defaults to: nil)

    when set, invoked from #start_one after wrapper.tools but before #resolve_description. A Verifier::InjectionDetected raise aborts startup for that server (wrapper closed, exception propagated). Agent#initialize builds + threads a real Verifier when its verify_mcp_servers: kwarg is true. Pass nil (the default) to skip injection-checking and trust whatever the server emits.



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

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
end

Instance Attribute Details

#live_idsArray<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.

Returns:

  • (Array<String>)

    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.



181
182
183
# File 'lib/pikuri/mcp/servers.rb', line 181

def live_ids
  @live_ids
end

Class Method Details

.start(registry, synthesizer: nil, verifier: nil) ⇒ Servers

Construct + start in one step. Convenience for callers that don’t need to slot work between construction and startup — most of them. The one caller that does is Extension, which uses the two-phase new + #start_all directly so it can register #close with Finalizers in the gap (see the “Lifecycle” section in the class header).

Parameters:

  • registry (Registry)

    configured servers to start.

  • synthesizer (Synthesizer, nil) (defaults to: nil)

    when set, invoked from #resolve_description for servers whose initialize handshake doesn’t carry an instructions or serverInfo.title field useful enough to show the LLM. Agent#initialize builds and threads a Pikuri::Mcp::Synthesizer (with thinker + cache) when its synthesize_descriptions: kwarg is true. Pass nil (the default) to skip synthesis and rely on the static fallback chain.

  • verifier (Verifier, nil) (defaults to: nil)

    when set, invoked from #start_one after wrapper.tools but before #resolve_description. A Verifier::InjectionDetected raise aborts startup for that server (wrapper closed, exception propagated). Agent#initialize builds + threads a real Verifier when its verify_mcp_servers: kwarg is true. Pass nil (the default) to skip injection-checking and trust whatever the server emits.

  • registry (Registry)
  • synthesizer (Synthesizer, nil) (defaults to: nil)
  • verifier (Verifier, nil) (defaults to: nil)

Returns:



126
127
128
# File 'lib/pikuri/mcp/servers.rb', line 126

def self.start(registry, synthesizer: nil, verifier: nil)
  new(registry, synthesizer: synthesizer, verifier: verifier).tap(&:start_all)
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.

Parameters:

  • agent (Agent)

Returns:



196
197
198
# File 'lib/pikuri/mcp/servers.rb', line 196

def build_mcp_connect_tool(agent)
  Connect.new(servers: self, agent: agent)
end

#closevoid

This method returns an undefined value.

Close every live transport, terminating the spawned MCP server subprocesses. Idempotent. The owner (Extension) arms this via the agent’s on_close before calling #start_all, so the agent closes it — on an explicit agent.close, at exit via Finalizers, or from the constructor’s rescue if start_all raised mid-build. Tests call it explicitly. ClientWrapper#close catches and logs its own transport-close errors, so the loop here needs no rescue.



261
262
263
264
265
266
# File 'lib/pikuri/mcp/servers.rb', line 261

def close
  return if @closed

  @closed = true
  @wrappers.each_value(&:close)
end

#empty?Boolean

Returns true when no servers are alive (either the registry was empty, or every configured server failed to start).

Returns:

  • (Boolean)

    true when no servers are alive (either the registry was empty, or every configured server failed to start).



185
186
187
# File 'lib/pikuri/mcp/servers.rb', line 185

def empty?
  @live_ids.empty?
end

#register_tools_with_agent(id, agent) ⇒ Integer

Register every tool exposed by server id into agent‘s underlying RubyLLM::Chat. Public so View#register_tools_with_agent can delegate; intended caller is Connect via Agent#internal_add_tool. Doesn’t track activation — that’s Connect‘s job (each agent’s tool instance owns its own set).

Parameters:

  • id (String)

    server id; must be in #live_ids.

  • agent (Agent)

Returns:

  • (Integer)

    number of tools registered.

Raises:

  • (ArgumentError)

    if id isn’t live.



239
240
241
242
243
244
245
246
247
248
249
# File 'lib/pikuri/mcp/servers.rb', line 239

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

#start_allvoid

This method returns an undefined value.

Spawn and handshake every configured server, running the verifier / synthesizer passes. The failure-prone phase, split out of #initialize so the caller can register #close first. A start_one raise (notably Cancelled) propagates; any wrappers already opened are reachable via #close.



173
174
175
# File 'lib/pikuri/mcp/servers.rb', line 173

def start_all
  @registry.entries.each { |entry| start_one(entry) }
end

#system_prompt_snippetString

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.

Returns:

  • (String)


208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/pikuri/mcp/servers.rb', line 208

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