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), 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 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

Instance Method Summary collapse

Constructor Details

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

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.



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_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.



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.

Parameters:

  • agent (Agent)

Returns:



134
135
136
# File 'lib/pikuri/mcp/servers.rb', line 134

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. 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).

Returns:

  • (Boolean)

    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

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.



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_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)


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