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 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
-
#live_ids ⇒ Array<String>
readonly
Ids of servers that successfully started.
Class Method Summary collapse
-
.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.
Instance Method Summary collapse
-
#build_mcp_connect_tool(ctx) ⇒ Connect
Build the
mcp_connecttool bound to +ctx+'s agent; each call returns a fresh Connect with an empty activation set. -
#close ⇒ void
Close every live transport, terminating the spawned 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
Construct without side effects — no spawns, no synthesizer/verifier LLM calls (those happen in #start_all, once the caller has armed #close).
-
#register_tools_with_agent(id, ctx) ⇒ Integer
Register every tool exposed by server
idinto +ctx+'s agent chat. -
#start_all ⇒ void
Spawn and handshake every configured server, running the verifier / synthesizer passes.
-
#system_prompt_snippet ⇒ String
System-prompt block advertising every live MCP server.
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.
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_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.
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").
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.
140 141 142 |
# File 'lib/pikuri/mcp/servers.rb', line 140 def build_mcp_connect_tool(ctx) Connect.new(servers: self, ctx: ctx) end |
#close ⇒ void
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).
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).
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_all ⇒ void
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_snippet ⇒ String
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.
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 |