Class: Pikuri::Mcp::Extension
- Inherits:
-
Object
- Object
- Pikuri::Mcp::Extension
- Includes:
- Agent::Extension
- Defined in:
- lib/pikuri/mcp/extension.rb
Overview
An Agent::Extension wiring MCP support onto an agent: builds an
Servers runtime from a Registry, appends <available_mcps> to the
system prompt, arms an on_close teardown, and (in bind) installs a
per-agent mcp_connect tool so the LLM pulls MCP tools in on demand.
registry = Pikuri::Mcp::Registry.new(entries: [
Pikuri::Mcp::Registry::StdioEntry.new(id: 'gmail', command: %w[gmail-mcp])
])
Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
c.add_extension Pikuri::Mcp::Extension.new(registry: registry)
end
configure (once, on the parent's Configurator) builds the shared
Servers — the owned resource; bind (once, on the parent agent)
installs the Connect tool keyed to it. Sub-agents don't inherit
extensions, so they get neither — personas own their toolset. On a
Registry#empty? registry the whole extension is a no-op (same semantics
as the mcp_registry: kwarg on Agent#initialize, which routes
through here).
Instance Attribute Summary collapse
-
#servers ⇒ Mcp::Servers?
readonly
The runtime built in
configure, ornilwhen the registry was empty (extension is a no-op).
Instance Method Summary collapse
-
#bind(ctx) ⇒ void
Register the per-agent
mcp_connecttool; its closure capturesctxso activations register on the correct chat (see Agent::ExtensionContext#add_raw_tool). -
#configure(c) ⇒ void
Build the shared Servers runtime and arm its teardown.
-
#initialize(registry: Registry::EMPTY, synthesize_descriptions: true, verify_mcp_servers: true) ⇒ Extension
constructor
A new instance of Extension.
-
#system_prompt_snippets ⇒ Array<String>
The
<available_mcps>block for the started servers, from already-probed state (no re-probe), so it's cheap to re-pull on a conversation clear. -
#trifecta_contribution(tools) ⇒ Pikuri::Trifecta::Contribution?
The MCP surface's legs, attached here because MCP tools synthesize as raw
RubyLLM::Toolsubclasses with no leg surface of their own (the MCP-tool bypass seam) — so nothing downstream can declare them.
Constructor Details
#initialize(registry: Registry::EMPTY, synthesize_descriptions: true, verify_mcp_servers: true) ⇒ Extension
Returns a new instance of Extension.
40 41 42 43 44 45 46 47 |
# File 'lib/pikuri/mcp/extension.rb', line 40 def initialize(registry: Registry::EMPTY, synthesize_descriptions: true, verify_mcp_servers: true) @registry = registry @synthesize_descriptions = synthesize_descriptions @verify_mcp_servers = verify_mcp_servers @servers = nil end |
Instance Attribute Details
#servers ⇒ Mcp::Servers? (readonly)
Returns the runtime built in configure,
or nil when the registry was empty (extension is a
no-op).
52 53 54 |
# File 'lib/pikuri/mcp/extension.rb', line 52 def servers @servers end |
Instance Method Details
#bind(ctx) ⇒ void
This method returns an undefined value.
Register the per-agent mcp_connect tool; its closure captures ctx so
activations register on the correct chat (see
Agent::ExtensionContext#add_raw_tool).
98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/pikuri/mcp/extension.rb', line 98 def bind(ctx) return if @servers.nil? || @servers.empty? if ctx.agent.tools.any?(Mcp::Servers::Connect) raise 'Mcp::Servers::Connect cannot be passed in tools: when an MCP runtime is wired; ' \ 'Agent auto-registers it.' end connect = @servers.build_mcp_connect_tool(ctx) ctx.add_raw_tool(connect.to_ruby_llm_tool) nil end |
#configure(c) ⇒ void
This method returns an undefined value.
Build the shared Servers runtime and arm its teardown. The Synthesizer / Verifier get the Configurator's transport + cancellable (building their own Thinker + Cache), so the boot passes run against the agent's own model and honor the same cancel flag.
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/pikuri/mcp/extension.rb', line 61 def configure(c) return if @registry.empty? if @synthesize_descriptions synthesizer = Mcp::Synthesizer.new(transport: c.transport, cancellable: c.cancellable) end if @verify_mcp_servers verifier = Mcp::Verifier.new(transport: c.transport, cancellable: c.cancellable) end # Two-phase: construct (pure), arm cleanup, then start. Arming # +on_close+ *before* the failure-prone +start_all+ is what makes a start # failure non-leaking — see {Mcp::Servers}' "Lifecycle: two-phase, and # the cleanup gap". @servers = Mcp::Servers.new(@registry, synthesizer: synthesizer, verifier: verifier) c.on_close { @servers.close } @servers.start_all nil end |
#system_prompt_snippets ⇒ Array<String>
The <available_mcps> block for the started servers, from
already-probed state (no re-probe), so it's cheap to re-pull on a
conversation clear. [] when no registry / no live servers.
86 87 88 89 90 |
# File 'lib/pikuri/mcp/extension.rb', line 86 def system_prompt_snippets return [] if @servers.nil? || @servers.empty? [@servers.system_prompt_snippet.lstrip] end |
#trifecta_contribution(tools) ⇒ Pikuri::Trifecta::Contribution?
The MCP surface's legs, attached here because MCP tools synthesize as
raw RubyLLM::Tool subclasses with no leg surface of their own (the
MCP-tool bypass seam) — so nothing downstream can declare them.
Hard :untrusted and unreviewed, attacker-reachable egress, both
unconditionally. A server's
reachable set is unknown by construction: the tool list arrives at
runtime from a server you didn't write, which is the whole-host case
rather than the scoped one, and no trust claim can be honest about it.
That is exactly Tool::TrifectaLegs::ASSUMED, reached
independently — a foreign tool surface and an undeclared local tool are
the same problem.
:private rides Registry#private?, so an open-data server next to a
calculator stays silent and the same wiring beside a mail server does
not.
131 132 133 134 135 136 137 138 |
# File 'lib/pikuri/mcp/extension.rb', line 131 def trifecta_contribution(tools) return nil if @registry.empty? Pikuri::Trifecta::Contribution.new( label: '(mcp)', legs: Pikuri::Tool::TrifectaLegs::ASSUMED.with(private: @registry.private?) ) end |