Class: Pikuri::Mcp::Extension

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initialize(registry: Registry::EMPTY, synthesize_descriptions: true, verify_mcp_servers: true) ⇒ Extension

Returns a new instance of Extension.

Parameters:

  • registry (Mcp::Registry) (defaults to: Registry::EMPTY)

    configured MCP servers. Defaults to Registry::EMPTY — extension is a no-op then.

  • synthesize_descriptions (Boolean) (defaults to: true)

    threads a Synthesizer into Servers so servers without an instructions / serverInfo.title field get an LLM-synthesized description for the <available_mcps> block. On by default — see the synthesize_descriptions: kwarg on Agent#initialize for the full rationale.

  • verify_mcp_servers (Boolean) (defaults to: true)

    threads a Verifier into Servers so every server's handshake + tool surface is checked for prompt-injection patterns before tools can register. On by default — see the verify_mcp_servers: kwarg on Agent#initialize.



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

#serversMcp::Servers? (readonly)

Returns the runtime built in configure, or nil when the registry was empty (extension is a no-op).

Returns:

  • (Mcp::Servers, nil)

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

Parameters:

  • ctx (Pikuri::Agent::ExtensionContext)


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.

Parameters:

  • c (Pikuri::Agent::Configurator)


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

Returns:

  • (Array<String>)


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.

Parameters:

  • tools (Array<Pikuri::Tool>)

    unused; MCP contributes no children

Returns:

  • (Pikuri::Trifecta::Contribution, nil)

    nil when no servers are registered, so an unused extension adds nothing



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