pikuri-mcp 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8eea96fdc325316f284a0af4b04409190da30a250b183dc260e6bc238631780f
4
- data.tar.gz: 886eed8a4913860b9571c75f71a5ff91860f9d73d8489b0c64e14e5e034ced16
3
+ metadata.gz: c0a2f932c3cd9c179cf9ad0bc3f2ec0d74fa4fe0c2b465e868665521253eb6a7
4
+ data.tar.gz: ecb49fc2d8b17be786f100e2aa037ef8133ca7beb80ae14746cf80112ee01e6f
5
5
  SHA512:
6
- metadata.gz: fafa923f924cfd8a39a8e88776828badd61b551a5c7165c292831b6185972267d2cb420a406e3578b65e443ff8bc2ead11e770f68c2dd12372416beba001500b
7
- data.tar.gz: cc6dc8b41f9978413dcc1ac81c15a26ea2f3c844be5b58adce4c8246bafbdfccb216f43f8a732d1ebe9429334022e078c23d4b5300bfdd15fb38e0d802145033
6
+ metadata.gz: be21898aa50c5bc6677117311426444e4bec1f475b8307221261fc0e266e61affdb45503e97e3f0086a0d4448758c5dafbe5370549a5a12d7d1b76c44f6bc312
7
+ data.tar.gz: 8c471fdb29e9afd66ec2c77dbd72f1ba60da7dc663ab03b68d7d489c8c3147de0bca6fdbb137fab6630031006ef3b01a09032db7e35d296679e0963447f1d5a8
data/README.md CHANGED
@@ -23,7 +23,7 @@ Adds:
23
23
  server, the community [Open Library
24
24
  catalogue reader](https://github.com/8enSmith/mcp-open-library)
25
25
  running in a locally-built Docker container, plus `calculator`.
26
- The worked example of guide chapter 5.
26
+ The worked example of the guide's MCP chapter.
27
27
 
28
28
  ## Install
29
29
 
@@ -61,12 +61,11 @@ activation set.
61
61
 
62
62
  ## Further reading
63
63
 
64
- - **Narrative walkthrough:** [chapter 5 of the
65
- guide](../docs/guide/05-mcp.md) is the gentle MCP intro — one
64
+ - **Narrative walkthrough:** [the MCP chapter of the guide](../book/mcp.md) is the gentle MCP intro — one
66
65
  low-stakes server in a locally-built container (run via
67
66
  `bin/pikuri-openlibrary`), rootless Docker as the boundary, the
68
67
  verifier and synthesizer in depth.
69
- [Chapter 10](../docs/guide/10-mcp-revisited.md) picks
68
+ [The MCP-revisited chapter](../book/mcp-revisited.md) picks
70
69
  the thread back up at the harder end: the official GitHub MCP
71
70
  in Docker, npx- and pip-based community servers, Playwright,
72
71
  and the full threat model.
@@ -5,75 +5,40 @@ require 'json'
5
5
 
6
6
  module Pikuri
7
7
  module Mcp
8
- # On-disk cache for the per-server descriptions
9
- # {Servers#try_synthesize_description} pays an LLM round-trip to
10
- # produce. Wraps {Pikuri::UrlCache} for storage; this class owns
11
- # the *key calculation* the fingerprint of every input that
12
- # could change the synthesized output, so an unchanged surface
13
- # short-circuits to the stored description and a changed surface
14
- # auto-invalidates.
8
+ # On-disk cache for the per-server descriptions {Synthesizer} pays an LLM
9
+ # round-trip to produce. Wraps {Pikuri::UrlCache} for storage; this class
10
+ # owns the *key calculation* the fingerprint of every input that could
11
+ # change the output, so an unchanged surface short-circuits and a changed
12
+ # one auto-invalidates. The key hashes canonical JSON over: +model_id+
13
+ # (quality varies by model), +prompt_version+, the transport descriptor
14
+ # (argv or URL), server name+version, and the full tools surface (name,
15
+ # description, +input_schema+ including nested properties), canonicalised by
16
+ # recursive sort so a reordered tools array / schema keys don't blow the
17
+ # cache. The server's registry *id* is deliberately excluded — a rename
18
+ # doesn't change what the server does.
15
19
  #
16
- # == What goes into the key
17
- #
18
- # The key hashes a canonical JSON serialization of:
19
- #
20
- # * +model_id+ — synthesis quality varies by model; cached output
21
- # from a different model should not be served.
22
- # * +prompt_version+ — {Servers::PROMPT_VERSION}. Bumped when the
23
- # synthesizer prompt changes meaningfully so old cache entries
24
- # stop being served without having to delete the cache file.
25
- # * Transport descriptor — for {Registry::StdioEntry}, the argv;
26
- # for {Registry::HttpEntry}, the URL.
27
- # * Server name + version from +client.server_info['serverInfo']+.
28
- # * Full tools surface: every tool's +name+, +description+, and
29
- # +input_schema+ — including nested property names, types, and
30
- # per-property descriptions. The surface is canonicalised
31
- # (recursive sort) so a server that reorders its tools array
32
- # or its schema keys doesn't blow the cache.
33
- #
34
- # The server's id from the registry is intentionally *not* part of
35
- # the key — renaming a server entry in the registry doesn't change
36
- # what the server actually does, and the id appears only as a
37
- # passing reference in the synth prompt, never in the produced
38
- # description.
39
- #
40
- # == Storage
41
- #
42
- # Reuses {Pikuri::UrlCache} with a +ttl: Float::INFINITY+. There is
43
- # no time-based expiry — cache entries are valid forever, until
44
- # the keyed surface changes. To force a rebuild of everything, +rm+
45
- # the {DIR} directory (or bump {Servers::PROMPT_VERSION}).
46
- #
47
- # == Fail-soft contract
48
- #
49
- # {#fetch} mirrors {UrlCache#fetch} — yields on miss, returns the
50
- # block's result, and persists it. {Servers} additionally rescues
51
- # +StandardError+ around the {#fetch} call to keep startup robust
52
- # against a corrupt cache file or a writer-side error.
20
+ # +ttl: Float::INFINITY+: no time expiry, entries valid until the keyed
21
+ # surface changes; force a full rebuild by +rm+-ing {DIR} or bumping the
22
+ # prompt version. {#fetch} mirrors {UrlCache#fetch} (yield on miss, persist
23
+ # the result); callers rescue around it for corrupt-file robustness.
53
24
  class Cache
54
- # Bumping {Servers::PROMPT_VERSION} invalidates every cached
55
- # entry; this class doesn't define one of its own.
56
-
57
- # On-disk root. Sibling of {Pikuri::UrlCache::ROOT_DIR} so all
58
- # of pikuri's caches live under one path.
25
+ # On-disk root. Sibling of {Pikuri::UrlCache::ROOT_DIR} so all of pikuri's
26
+ # caches live under one path.
59
27
  DIR = File.join(File.dirname(UrlCache::ROOT_DIR), 'mcp_descriptions').freeze
60
28
 
61
- # @param model_id [String, nil] the synthesizer model id; folded
62
- # into the cache key so a model swap doesn't serve stale output
63
- # @param prompt_version [Integer] {Servers::PROMPT_VERSION}; folded
64
- # into the key so a prompt edit invalidates the world
65
- # @param dir [String] storage directory; defaults to {DIR}
29
+ # @param model_id [String, nil] model id; folded into the key so a model
30
+ # swap doesn't serve stale output.
31
+ # @param prompt_version [Integer] the caller's +PROMPT_VERSION+; folded in
32
+ # so a prompt edit invalidates the world.
33
+ # @param dir [String] storage directory; defaults to {DIR}.
66
34
  def initialize(model_id:, prompt_version:, dir: DIR)
67
35
  @model_id = model_id
68
36
  @prompt_version = prompt_version
69
37
  @url_cache = UrlCache.new(ttl: Float::INFINITY, dir: dir)
70
38
  end
71
39
 
72
- # Return the cached description for the given +(entry, client,
73
- # tools)+ triple if a fresh entry exists, otherwise yield to
74
- # compute it (typically a one-shot thinker-agent run inside
75
- # {Servers#try_synthesize_description}), persist the result, and
76
- # return it.
40
+ # Return the cached description for the +(entry, client, tools)+ triple, or
41
+ # yield to compute + persist + return it on a miss.
77
42
  #
78
43
  # @param entry [Registry::StdioEntry, Registry::HttpEntry]
79
44
  # @param client [MCP::Client] for +server_info+ (name + version)
@@ -84,10 +49,9 @@ module Pikuri
84
49
  @url_cache.fetch(key_for(entry, client, tools), &block)
85
50
  end
86
51
 
87
- # Compute the canonical fingerprint string for a +(entry, client,
88
- # tools)+ triple. The fingerprint is what {Pikuri::UrlCache}
89
- # SHA-256s into the on-disk filename; we don't hash here so the
90
- # raw JSON is inspectable in tests.
52
+ # The canonical fingerprint for a +(entry, client, tools)+ triple —
53
+ # {Pikuri::UrlCache} SHA-256s it into the filename; we return the raw JSON
54
+ # (not a hash) so it's inspectable in tests.
91
55
  #
92
56
  # @return [String] canonical JSON over the keyed inputs
93
57
  def key_for(entry, client, tools)
@@ -123,10 +87,9 @@ module Pikuri
123
87
  end
124
88
  end
125
89
 
126
- # Recursively normalize a Hash/Array tree so semantically-equal
127
- # inputs produce byte-identical JSON: Hash keys sorted, String
128
- # vs Symbol keys both stringified, Arrays left in order (order
129
- # is part of the meaning in JSON Schema's +required+).
90
+ # Recursively normalize so semantically-equal inputs produce
91
+ # byte-identical JSON: Hash keys stringified + sorted, Arrays left in
92
+ # order (order is meaningful in JSON Schema's +required+).
130
93
  def canonicalize(obj)
131
94
  case obj
132
95
  when Hash
@@ -139,11 +102,9 @@ module Pikuri
139
102
  end
140
103
  end
141
104
 
142
- # Null cache: drop-in replacement that always misses and never
143
- # persists. {Servers} uses this as the default when no real cache
144
- # is provided, so the body of +#try_synthesize_description+ can
145
- # call +@cache.fetch(...)+ unconditionally instead of branching
146
- # on +nil+. Same shape as {Pikuri::UrlCache::NULL}.
105
+ # Null cache: always misses, never persists the default when no real
106
+ # cache is provided, so callers +@cache.fetch(...)+ unconditionally
107
+ # instead of branching on +nil+. Same shape as {Pikuri::UrlCache::NULL}.
147
108
  NULL = Object.new
148
109
  def NULL.fetch(entry:, client:, tools:)
149
110
  yield
@@ -4,44 +4,25 @@ require 'mcp'
4
4
 
5
5
  module Pikuri
6
6
  module Mcp
7
- # Wraps one +MCP::Client+ plus its transport with retry-and-restart
8
- # semantics for stdio-subprocess death. The wrapper owns the
9
- # client/transport lifecycle: on construction it builds a fresh
10
- # transport and runs the +initialize+ handshake; on a
11
- # subprocess-died failure during {#call_tool} it closes the dead
12
- # transport, spawns a fresh one, re-handshakes, and retries up
13
- # to {MAX_CALL_ATTEMPTS} times total. After exhaustion the
14
- # underlying +RequestHandlerError+ propagates and the synthesized
15
- # tool's execute closure converts it to an +Error: ...+ observation
16
- # like any other failure.
7
+ # Wraps one +MCP::Client+ + its transport with retry-and-restart on
8
+ # stdio-subprocess death. On construction it builds a fresh transport and
9
+ # runs the +initialize+ handshake; on a subprocess-died {#call_tool} it
10
+ # closes the dead transport, respawns, re-handshakes, and retries up to
11
+ # {MAX_CALL_ATTEMPTS} times, after which the +RequestHandlerError+ propagates
12
+ # (the synthesized tool's closure turns it into an +Error: ...+ observation).
17
13
  #
18
- # == Why only subprocess-death messages
14
+ # Only subprocess-death messages trigger the restart: once an stdio
15
+ # subprocess dies, +MCP::Client::Stdio+ is irreversibly broken
16
+ # (+@wait_thread.alive?+ stays false, every call raises from
17
+ # +ensure_running!+), so restarting is the only way back. Other
18
+ # +RequestHandlerError+s (protocol mismatch, JSON parse, ValidationError)
19
+ # leave the transport usable — a restart would just retry the same mistake.
20
+ # {Registry::HttpEntry} is accepted but no HTTP failure matches those
21
+ # patterns, so the wrapper is a pass-through for HTTP.
19
22
  #
20
- # Once an stdio subprocess dies, +MCP::Client::Stdio+'s state is
21
- # irreversibly broken: +@wait_thread.alive?+ returns false forever
22
- # and every subsequent call raises from +ensure_running!+ without
23
- # ever talking to the server. Restarting is the only way back.
24
- # Other +RequestHandlerError+ failures (protocol mismatch on the
25
- # initialize handshake, JSON parse error, ValidationError on a
26
- # malformed response) leave the transport usable; a restart there
27
- # would just retry the same logical mistake and waste latency.
28
- #
29
- # == HTTP entries
30
- #
31
- # The wrapper accepts {Registry::HttpEntry} too, but no HTTP
32
- # failure raises with the subprocess-death messages we match on,
33
- # so the retry path naturally never triggers — the wrapper is a
34
- # pass-through for HTTP. If transient HTTP retry is ever wanted,
35
- # it should land here, not in {Servers}.
36
- #
37
- # == Lifecycle responsibility
38
- #
39
- # Spawning happens inside {#initialize} (via +spawn_fresh!+), and
40
- # if the +initialize+ handshake fails the wrapper closes its own
41
- # half-opened transport before re-raising. That means a caller
42
- # who saw +ClientWrapper.new(entry)+ raise does NOT need to also
43
- # close anything — the wrapper either returns a fully-initialized
44
- # object or no object at all.
23
+ # Lifecycle: if the handshake fails, {#initialize} closes its own half-opened
24
+ # transport before re-raising a caller who saw +ClientWrapper.new+ raise
25
+ # need not close anything (fully-initialized object, or no object at all).
45
26
  class ClientWrapper
46
27
  LOGGER = Pikuri.logger_for('Mcp::ClientWrapper')
47
28
  private_constant :LOGGER
@@ -51,17 +32,10 @@ module Pikuri
51
32
  # normal try plus up to two restart-then-retry attempts.
52
33
  MAX_CALL_ATTEMPTS = 3
53
34
 
54
- # Substrings of +MCP::Client::RequestHandlerError#message+ that
55
- # signal "the stdio subprocess is dead and the transport is
56
- # unrecoverable." All three come from
57
- # +mcp-X.Y.Z/lib/mcp/client/stdio.rb+:
58
- #
59
- # * +Server process has exited+ — +ensure_running!+ on a
60
- # +wait_thread+ that is no longer alive.
61
- # * +Failed to write to server process+ — broken pipe on
62
- # write (+EPIPE+ / +IOError+).
63
- # * +Server process closed stdout unexpectedly+ — +gets+
64
- # returned +nil+ mid-read because the pipe was closed.
35
+ # Substrings of +RequestHandlerError#message+ signalling "stdio subprocess
36
+ # dead, transport unrecoverable" all from the +mcp+ gem's
37
+ # +client/stdio.rb+: exited +wait_thread+, broken write pipe (+EPIPE+), and
38
+ # +gets+ → +nil+ on a closed stdout.
65
39
  SUBPROCESS_DEAD_PATTERNS = [
66
40
  'Server process has exited',
67
41
  'Failed to write to server process',
@@ -109,25 +83,18 @@ module Pikuri
109
83
  @client.tools
110
84
  end
111
85
 
112
- # Call an MCP tool on the underlying server. On any failure
113
- # whose message matches {SUBPROCESS_DEAD_PATTERNS}, closes the
114
- # dead transport, spawns a fresh one, re-runs the +initialize+
115
- # handshake, and retries the call — up to {MAX_CALL_ATTEMPTS}
116
- # times total. Other failures (server-returned JSON-RPC errors
117
- # don't reach this method — they come back inside the response
118
- # Hash; non-recoverable transport errors like protocol-version
119
- # mismatch or JSON parse errors) propagate on the first attempt
120
- # without restart.
86
+ # Call an MCP tool. On a {SUBPROCESS_DEAD_PATTERNS} failure, restart and
87
+ # retry up to {MAX_CALL_ATTEMPTS} times; other failures propagate on the
88
+ # first attempt (JSON-RPC errors don't reach here — they ride in the
89
+ # response Hash).
121
90
  #
122
- # @param tool [MCP::Client::Tool] the tool object obtained from
123
- # {#tools} at boot. Only +name+ is used at call time, so a
124
- # reference captured before a restart keeps working as long
125
- # as the new server still exposes that tool name.
126
- # @param arguments [Hash] passed verbatim to the underlying
127
- # +call_tool+.
91
+ # @param tool [MCP::Client::Tool] from {#tools} at boot. Only +name+ is
92
+ # used at call time, so a reference captured before a restart keeps
93
+ # working if the new server still exposes that name.
94
+ # @param arguments [Hash] passed verbatim to the underlying +call_tool+.
128
95
  # @return [Hash] the JSON-RPC response.
129
- # @raise [MCP::Client::RequestHandlerError] when retries are
130
- # exhausted, or on the first non-recoverable failure.
96
+ # @raise [MCP::Client::RequestHandlerError] when retries exhaust, or on the
97
+ # first non-recoverable failure.
131
98
  def call_tool(tool:, arguments:)
132
99
  attempt = 1
133
100
  begin
@@ -174,13 +141,9 @@ module Pikuri
174
141
  @client = MCP::Client.new(transport: @transport)
175
142
  @client.connect
176
143
  rescue StandardError
177
- # The +initialize+ handshake (or the subprocess spawn it
178
- # implicitly triggers for stdio) failed. Close whatever
179
- # half-opened state we have so we don't leak a stdio
180
- # subprocess that succeeded popen3 but failed handshake.
181
- # The new transport is not retained — the wrapper either
182
- # returns from initialize cleanly or raises with no live
183
- # state.
144
+ # Handshake (or the stdio spawn it triggers) failed — close the
145
+ # half-opened state so a subprocess that popen3'd but failed handshake
146
+ # doesn't leak.
184
147
  begin
185
148
  @transport.close
186
149
  rescue StandardError
@@ -209,7 +172,9 @@ module Pikuri
209
172
  MCP::Client::Stdio.new(
210
173
  command: @entry.command.first,
211
174
  args: @entry.command.drop(1),
212
- env: @entry.env
175
+ # De-bundlerized, or a Ruby-written server resolves its own gems
176
+ # out of *pikuri's* bundle. The entry's own env wins over it.
177
+ env: Pikuri::BundlerEnv.clean_delta.merge(@entry.env)
213
178
  )
214
179
  when Registry::HttpEntry
215
180
  MCP::Client::HTTP.new(url: @entry.url, headers: @entry.headers)
@@ -2,28 +2,10 @@
2
2
 
3
3
  module Pikuri
4
4
  module Mcp
5
- # An {Pikuri::Agent::Extension} that wires MCP (Model Context
6
- # Protocol) support onto an agent: builds an {Mcp::Servers}
7
- # runtime from a {Registry}, appends the +<available_mcps>+ block
8
- # to the system prompt, registers an +on_close+ handler that
9
- # tears down the live MCP clients, and (in +bind+) installs a
10
- # per-agent +mcp_connect+ tool so the LLM can pull MCP-exposed
11
- # tools into its toolset on demand.
12
- #
13
- # == Configure / bind split
14
- #
15
- # +configure+ runs once on the parent's Configurator and creates
16
- # the *shared* {Mcp::Servers} runtime — that's the resource the
17
- # extension owns. +bind+ fires once on the parent agent and
18
- # installs a +Connect+ tool keyed to it, registered via
19
- # {Pikuri::Agent::ExtensionContext#add_raw_tool} so the tool's
20
- # +execute+ closure can call back into the right chat when the
21
- # LLM activates a server.
22
- # Sub-agents do not inherit extensions, so they receive neither
23
- # the +Connect+ tool nor any MCP-backed tools — personas own
24
- # their toolset by construction.
25
- #
26
- # == Usage
5
+ # An {Pikuri::Agent::Extension} wiring MCP support onto an agent: builds an
6
+ # {Mcp::Servers} runtime from a {Registry}, appends +<available_mcps>+ to the
7
+ # system prompt, arms an +on_close+ teardown, and (in +bind+) installs a
8
+ # per-agent +mcp_connect+ tool so the LLM pulls MCP tools in on demand.
27
9
  #
28
10
  # registry = Pikuri::Mcp::Registry.new(entries: [
29
11
  # Pikuri::Mcp::Registry::StdioEntry.new(id: 'gmail', command: %w[gmail-mcp])
@@ -32,13 +14,13 @@ module Pikuri
32
14
  # c.add_extension Pikuri::Mcp::Extension.new(registry: registry)
33
15
  # end
34
16
  #
35
- # == Empty registry
36
- #
37
- # When the registry is {Registry#empty?}, the extension is a
38
- # no-op no Servers, no snippet, no tool, no on_close. Same
39
- # semantics as the legacy +mcp_registry:+ kwarg on
40
- # {Pikuri::Agent#initialize}, which routes through this
41
- # extension as a transition layer.
17
+ # +configure+ (once, on the parent's Configurator) builds the *shared*
18
+ # {Mcp::Servers} — the owned resource; +bind+ (once, on the parent agent)
19
+ # installs the +Connect+ tool keyed to it. Sub-agents don't inherit
20
+ # extensions, so they get neither personas own their toolset. On a
21
+ # {Registry#empty?} registry the whole extension is a no-op (same semantics
22
+ # as the +mcp_registry:+ kwarg on {Pikuri::Agent#initialize}, which routes
23
+ # through here).
42
24
  class Extension
43
25
  include Pikuri::Agent::Extension
44
26
 
@@ -69,13 +51,10 @@ module Pikuri
69
51
  # no-op).
70
52
  attr_reader :servers
71
53
 
72
- # Build the shared {Servers} runtime, append the
73
- # +<available_mcps>+ block to the system prompt, and register
74
- # the close handler. The {Synthesizer} / {Verifier} are
75
- # handed the Configurator's transport + cancellable and build
76
- # their own {Thinker} + production {Cache} from them, so the
77
- # LLM-driven boot passes run against the same model the agent
78
- # itself uses and honor the same cancel flag.
54
+ # Build the shared {Servers} runtime and arm its teardown. The
55
+ # {Synthesizer} / {Verifier} get the Configurator's transport + cancellable
56
+ # (building their own {Thinker} + {Cache}), so the boot passes run against
57
+ # the agent's own model and honor the same cancel flag.
79
58
  #
80
59
  # @param c [Pikuri::Agent::Configurator]
81
60
  # @return [void]
@@ -89,27 +68,30 @@ module Pikuri
89
68
  verifier = Mcp::Verifier.new(transport: c.transport, cancellable: c.cancellable)
90
69
  end
91
70
 
92
- # Two-phase on purpose: construct (pure), arm cleanup, then
93
- # start. Arming +on_close+ *before* the failure-prone
94
- # +start_all+ is what makes a start failure (Cancelled,
95
- # injection) non-leaking: +c.on_close+ writes straight to the
96
- # agent's live handler list, so if +start_all+ raises, the
97
- # agent's constructor rescue closes these half-started servers.
98
- # See {Mcp::Servers}' "Lifecycle: two-phase, and the cleanup
99
- # gap" and {Agent#run_configure}.
71
+ # Two-phase: construct (pure), arm cleanup, then start. Arming
72
+ # +on_close+ *before* the failure-prone +start_all+ is what makes a start
73
+ # failure non-leaking see {Mcp::Servers}' "Lifecycle: two-phase, and
74
+ # the cleanup gap".
100
75
  @servers = Mcp::Servers.new(@registry, synthesizer: synthesizer, verifier: verifier)
101
76
  c.on_close { @servers.close }
102
77
  @servers.start_all
103
-
104
- c.append_system_prompt(@servers.system_prompt_snippet.lstrip) unless @servers.empty?
105
78
  nil
106
79
  end
107
80
 
108
- # Register a per-agent +mcp_connect+ tool on the agent's chat.
109
- # The tool's +execute+ closure captures the {ExtensionContext}
110
- # so activations register their tools on the correct chat — see
111
- # IDEAS.md §"Two invariants worth recording" for the
112
- # static-vs-dynamic tool boundary.
81
+ # The +<available_mcps>+ block for the started servers, from
82
+ # already-probed state (no re-probe), so it's cheap to re-pull on a
83
+ # conversation clear. +[]+ when no registry / no live servers.
84
+ #
85
+ # @return [Array<String>]
86
+ def system_prompt_snippets
87
+ return [] if @servers.nil? || @servers.empty?
88
+
89
+ [@servers.system_prompt_snippet.lstrip]
90
+ end
91
+
92
+ # Register the per-agent +mcp_connect+ tool; its closure captures +ctx+ so
93
+ # activations register on the correct chat (see
94
+ # {Pikuri::Agent::ExtensionContext#add_raw_tool}).
113
95
  #
114
96
  # @param ctx [Pikuri::Agent::ExtensionContext]
115
97
  # @return [void]
@@ -126,6 +108,35 @@ module Pikuri
126
108
  nil
127
109
  end
128
110
 
111
+ # The MCP surface's legs, attached here because MCP tools synthesize as
112
+ # raw +RubyLLM::Tool+ subclasses with no leg surface of their own (the
113
+ # MCP-tool bypass seam) — so nothing downstream can declare them.
114
+ #
115
+ # Hard +:untrusted+ and unreviewed, attacker-reachable egress, both
116
+ # unconditionally. A server's
117
+ # reachable set is unknown by construction: the tool list arrives at
118
+ # runtime from a server you didn't write, which is the whole-host case
119
+ # rather than the scoped one, and no trust claim can be honest about it.
120
+ # That is exactly {Pikuri::Tool::TrifectaLegs::ASSUMED}, reached
121
+ # independently — a foreign tool surface and an undeclared local tool are
122
+ # the same problem.
123
+ #
124
+ # +:private+ rides {Registry#private?}, so an open-data server next to a
125
+ # calculator stays silent and the same wiring beside a mail server does
126
+ # not.
127
+ #
128
+ # @param tools [Array<Pikuri::Tool>] unused; MCP contributes no children
129
+ # @return [Pikuri::Trifecta::Contribution, nil] +nil+ when no servers are
130
+ # registered, so an unused extension adds nothing
131
+ def trifecta_contribution(tools)
132
+ return nil if @registry.empty?
133
+
134
+ Pikuri::Trifecta::Contribution.new(
135
+ label: '(mcp)',
136
+ legs: Pikuri::Tool::TrifectaLegs::ASSUMED.with(private: @registry.private?)
137
+ )
138
+ end
139
+
129
140
  end
130
141
  end
131
142
  end