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 lives one level down in ClientWrapper, which also owns transport selection (stdio vs HTTP) and restart-on-subprocess-death retry; Servers stays transport-agnostic.

Lifecycle: two-phase, and the cleanup gap

#initialize is pure; #start_all spawns. The split lets the owner arm #close between them, before the failure-prone startup:

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

c.on_close writes to the agent's live handler list (see Agent::Configurator's on_close_sink), so a #start_all raise still closes any half-spawned servers via the constructor rescue — which is why Servers needs no Finalizers coupling of its own.

The Subprocess.spawn carve-out (stdio only)

MCP::Client::Stdio calls Process.spawn internally; routing it through pikuri's Subprocess.spawn chokepoint would mean forking the gem or threading custom IO pipes through its API. So stdio MCP is the documented exception to the chokepoint convention (CLAUDE.md): the gem spawns, we own #close (via ClientWrapper#close) — closing stdin → EOF → the server self-terminates per spec. HTTP entries don't spawn (plain Faraday); #close sends the session-termination DELETE.

Roles

ClientWrapper owns the per-server transport + retry; Servers owns two-phase startup, the @wrappers hash, audit logging, the <available_mcps> renderer, and register_tools_with_agent; View is the sub-agent facade (delegates to the root, which alone owns #close); and Connect is the per-agent mcp_connect tool (its own activation Set, so activation is strictly per-agent — never inherited by a sub-agent).

Why MCP tools bypass Pikuri::Tool

MCP tools carry JSON Schema in input_schema, which RubyLLM::Tool.params(schema) accepts directly, so we synthesize RubyLLM::Tool subclasses here and feed them through Agent::ExtensionContext#add_raw_tool — no Pikuri::Tool::Parameters in the middle. The strict-validation contract for native tools is deliberately not extended to MCP tools in v1; MCP-side validation catches bad input, and the provenance prefix + audit log are the compensating transparency.

Sharing

P_one_agent today, and accepted as such: one wiring, one agent. The @wrappers / @tools_cache / @live_ids hashes are unguarded, and #close tears down child processes another agent might be mid-call on. View is the only sanctioned second reference, and it is a read-only facade onto the root that alone owns #close.

Nothing about MCP itself demands this — a pool of servers behind a lock would serve N agents, and each agent needs only its own Connect set. It simply isn't built, so ten agents means ten sets of children.

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 spawns, no synthesizer/verifier LLM calls (those happen in #start_all, once the caller has armed #close). The split means a #start_all raise (notably Cancelled) can't strand half-spawned servers.

Parameters:

  • registry (Registry)

    configured servers to start.

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

    when set, invoked from #resolve_description for servers whose handshake lacks a useful instructions / serverInfo.title. Threaded by Agent#initialize when synthesize_descriptions: is true; nil skips synthesis for the static fallback chain.

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

    when set, invoked from #start_one before #resolve_description; a Verifier::InjectionDetected raise aborts that server's startup. Threaded by Agent#initialize when verify_mcp_servers: is true; nil trusts whatever the server emits.



101
102
103
104
105
106
107
108
109
110
# File 'lib/pikuri/mcp/servers.rb', line 101

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.



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

def live_ids
  @live_ids
end

Class Method Details

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

Construct + start in one step — the convenience for callers that don't need to slot cleanup registration into the gap. Extension uses the two-phase new + #start_all directly instead (see "Lifecycle").

Parameters:

Returns:



81
82
83
# File 'lib/pikuri/mcp/servers.rb', line 81

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(ctx) ⇒ Connect

Build the mcp_connect tool bound to +ctx+'s agent; each call returns a fresh Connect with an empty activation set.

Parameters:

  • ctx (Pikuri::Agent::ExtensionContext)

Returns:



140
141
142
# File 'lib/pikuri/mcp/servers.rb', line 140

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

#closevoid

This method returns an undefined value.

Close every live transport, terminating the spawned subprocesses. Idempotent. Armed via the agent's on_close before #start_all (see "Lifecycle"). ClientWrapper#close logs its own errors, so this loop needs no rescue.



197
198
199
200
201
202
# File 'lib/pikuri/mcp/servers.rb', line 197

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



131
132
133
# File 'lib/pikuri/mcp/servers.rb', line 131

def empty?
  @live_ids.empty?
end

#register_tools_with_agent(id, ctx) ⇒ Integer

Register every tool exposed by server id into +ctx+'s agent chat. Public so View#register_tools_with_agent can delegate; intended caller is Connect, which owns activation tracking (this doesn't).

Parameters:

  • id (String)

    server id; must be in #live_ids.

  • ctx (Pikuri::Agent::ExtensionContext)

Returns:

  • (Integer)

    number of tools registered.

Raises:

  • (ArgumentError)

    if id isn't live.



179
180
181
182
183
184
185
186
187
188
189
# File 'lib/pikuri/mcp/servers.rb', line 179

def register_tools_with_agent(id, ctx)
  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)
    ctx.add_raw_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.



119
120
121
# File 'lib/pikuri/mcp/servers.rb', line 119

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

#system_prompt_snippetString

System-prompt block advertising every live MCP server. Empty string when none are alive, so callers concatenate unconditionally. The available-ids list lives here only — deliberately not duplicated into the mcp_connect description.

Returns:

  • (String)


150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/pikuri/mcp/servers.rb', line 150

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