pikuri-mcp 0.0.3

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3b512b9e2c5bacf829cee8104a9198b36d94d7d09b110060e7524e5e82ae70a0
4
+ data.tar.gz: c36e458112d63a32ca0dad35c3b0ab4b07cd934347b7bfd4fe488ac252ccd64c
5
+ SHA512:
6
+ metadata.gz: 89059a85a63a60cc2f69d64e881df7fb3fb005b64850040a5b696f4b4c6e862d28af77cb8e815f1a4bf259b3fd5ff720684c1a4fb5a16529c68596a7459ccd5e
7
+ data.tar.gz: 5e31d7cc61ba3f802b2888d9078ba22de7fd132b82f2874e7daf483212de42b53aa608aa76e961bb69f44877acfa03871139acbba2caf594d7c128f95eeafa45
data/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # pikuri-mcp
2
+
3
+ [Model Context Protocol](https://modelcontextprotocol.io) support
4
+ for the [pikuri](https://codeberg.org/mvysny/pikuri) AI-assistant
5
+ toolkit.
6
+
7
+ Adds:
8
+ - `Pikuri::Mcp::Registry` — declarative config layer for stdio +
9
+ HTTP MCP servers.
10
+ - `Pikuri::Mcp::Servers` — runtime that spawns the configured
11
+ servers via the [`mcp`](https://rubygems.org/gems/mcp) gem.
12
+ - `Pikuri::Mcp::Synthesizer` — LLM-driven description summarizer
13
+ for MCP servers whose handshake omits useful instructions.
14
+ - `Pikuri::Mcp::Verifier` — pre-flight prompt-injection scan of
15
+ every MCP server's tool surface before tools are advertised to
16
+ the LLM.
17
+ - `Pikuri::Mcp::Cache` — on-disk cache of synthesized descriptions
18
+ and verifier results, keyed on the full server surface.
19
+ - `Pikuri::Mcp::Extension` — wires everything into a
20
+ `Pikuri::Agent` via the `c.add_extension(...)` block API.
21
+
22
+ ## Install
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem 'pikuri-mcp'
27
+ ```
28
+
29
+ Adds the `mcp` gem as a runtime dep on top of `pikuri-core`.
30
+
31
+ ## Usage
32
+
33
+ ```ruby
34
+ require 'pikuri-core'
35
+ require 'pikuri-mcp'
36
+
37
+ registry = Pikuri::Mcp::Registry.new(entries: [
38
+ Pikuri::Mcp::Registry::StdioEntry.new(id: 'gmail', command: %w[npx @gongrzhe/server-gmail-autoauth-mcp]),
39
+ Pikuri::Mcp::Registry::HttpEntry.new(id: 'hubspot', url: 'https://mcp.example.com/v1',
40
+ headers: { 'Authorization' => "Bearer #{ENV.fetch('HUBSPOT_TOKEN')}" })
41
+ ])
42
+
43
+ agent = Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
44
+ c.add_extension(Pikuri::Mcp::Extension.new(registry: registry))
45
+ end
46
+ ```
47
+
48
+ The extension's `configure` builds the shared `Mcp::Servers` (which
49
+ eager-starts every configured server), appends `<available_mcps>` to
50
+ the system prompt, and registers a close handler so the agent's
51
+ `#close` tears down the MCP subprocesses. Its `bind(agent)` adds a
52
+ per-agent `mcp_connect` tool — sub-agents share the live MCP clients
53
+ via the same extension instance but each has its own connect tool +
54
+ activation set.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Pikuri
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.
15
+ #
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.
53
+ 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.
59
+ DIR = File.join(File.dirname(UrlCache::ROOT_DIR), 'mcp_descriptions').freeze
60
+
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}
66
+ def initialize(model_id:, prompt_version:, dir: DIR)
67
+ @model_id = model_id
68
+ @prompt_version = prompt_version
69
+ @url_cache = UrlCache.new(ttl: Float::INFINITY, dir: dir)
70
+ end
71
+
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 {Pikuri::Agent.think} call inside
75
+ # {Servers#try_synthesize_description}), persist the result, and
76
+ # return it.
77
+ #
78
+ # @param entry [Registry::StdioEntry, Registry::HttpEntry]
79
+ # @param client [MCP::Client] for +server_info+ (name + version)
80
+ # @param tools [Array<MCP::Client::Tool>] the server's tool list
81
+ # @yieldreturn [String] freshly-computed description
82
+ # @return [String] cached or freshly-computed description
83
+ def fetch(entry:, client:, tools:, &block)
84
+ @url_cache.fetch(key_for(entry, client, tools), &block)
85
+ end
86
+
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.
91
+ #
92
+ # @return [String] canonical JSON over the keyed inputs
93
+ def key_for(entry, client, tools)
94
+ info = (client.server_info || {})['serverInfo'] || {}
95
+ fingerprint = {
96
+ 'model_id' => @model_id,
97
+ 'prompt_version' => @prompt_version,
98
+ 'transport' => transport_descriptor(entry),
99
+ 'server_name' => info['name'],
100
+ 'server_version' => info['version'],
101
+ 'tools' => tools_descriptor(tools)
102
+ }
103
+ JSON.generate(canonicalize(fingerprint))
104
+ end
105
+
106
+ private
107
+
108
+ def transport_descriptor(entry)
109
+ case entry
110
+ when Registry::StdioEntry then { 'kind' => 'stdio', 'command' => entry.command }
111
+ when Registry::HttpEntry then { 'kind' => 'http', 'url' => entry.url }
112
+ else { 'kind' => 'unknown', 'class' => entry.class.to_s }
113
+ end
114
+ end
115
+
116
+ def tools_descriptor(tools)
117
+ tools.map do |t|
118
+ {
119
+ 'name' => t.name,
120
+ 'description' => t.description,
121
+ 'input_schema' => t.input_schema || {}
122
+ }
123
+ end
124
+ end
125
+
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+).
130
+ def canonicalize(obj)
131
+ case obj
132
+ when Hash
133
+ obj.each_with_object({}) { |(k, v), out| out[k.to_s] = canonicalize(v) }
134
+ .sort.to_h
135
+ when Array
136
+ obj.map { |v| canonicalize(v) }
137
+ else
138
+ obj
139
+ end
140
+ end
141
+
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}.
147
+ NULL = Object.new
148
+ def NULL.fetch(entry:, client:, tools:)
149
+ yield
150
+ end
151
+ NULL.freeze
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Pikuri
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.
17
+ #
18
+ # == Why only subprocess-death messages
19
+ #
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.
45
+ class ClientWrapper
46
+ LOGGER = Pikuri.logger_for('Mcp::ClientWrapper')
47
+ private_constant :LOGGER
48
+
49
+ # Maximum number of +call_tool+ attempts including the initial
50
+ # one, before propagating the underlying exception. 3 means: one
51
+ # normal try plus up to two restart-then-retry attempts.
52
+ MAX_CALL_ATTEMPTS = 3
53
+
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.
65
+ SUBPROCESS_DEAD_PATTERNS = [
66
+ 'Server process has exited',
67
+ 'Failed to write to server process',
68
+ 'Server process closed stdout unexpectedly'
69
+ ].freeze
70
+ private_constant :SUBPROCESS_DEAD_PATTERNS
71
+
72
+ # @return [Registry::StdioEntry, Registry::HttpEntry] the
73
+ # registry entry the wrapper builds (and rebuilds) transports
74
+ # from.
75
+ attr_reader :entry
76
+
77
+ # @return [MCP::Client] the *current* live client. Replaced on
78
+ # each restart, so callers must not cache this across
79
+ # {#call_tool} invocations. Exposed for one-shot boot-time
80
+ # reads of +server_info+ / +tools+ by {Servers}, {Verifier},
81
+ # {Synthesizer}, and {Cache}.
82
+ attr_reader :client
83
+
84
+ # @param entry [Registry::StdioEntry, Registry::HttpEntry] the
85
+ # configured server.
86
+ # @raise [StandardError] anything raised by the underlying
87
+ # transport's spawn / +connect+ handshake. The half-opened
88
+ # transport is closed before the exception propagates.
89
+ def initialize(entry)
90
+ @entry = entry
91
+ @closed = false
92
+ spawn_fresh!
93
+ end
94
+
95
+ # Server's cached +InitializeResult+, as exposed by the
96
+ # transport. Delegates to {#client}.
97
+ #
98
+ # @return [Hash, nil]
99
+ def server_info
100
+ @client.server_info
101
+ end
102
+
103
+ # Enumerate the server's tools via +MCP::Client#tools+. Each
104
+ # call is a real round-trip — callers typically invoke this
105
+ # once at boot and cache the result.
106
+ #
107
+ # @return [Array<MCP::Client::Tool>]
108
+ def tools
109
+ @client.tools
110
+ end
111
+
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.
121
+ #
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+.
128
+ # @return [Hash] the JSON-RPC response.
129
+ # @raise [MCP::Client::RequestHandlerError] when retries are
130
+ # exhausted, or on the first non-recoverable failure.
131
+ def call_tool(tool:, arguments:)
132
+ attempt = 1
133
+ begin
134
+ @client.call_tool(tool: tool, arguments: arguments)
135
+ rescue MCP::Client::RequestHandlerError => e
136
+ raise unless subprocess_dead?(e)
137
+ raise if attempt >= MAX_CALL_ATTEMPTS
138
+
139
+ LOGGER.warn(
140
+ "MCP server #{@entry.id.inspect} subprocess died " \
141
+ "(#{e.message.inspect}); restarting and retrying " \
142
+ "(attempt #{attempt + 1}/#{MAX_CALL_ATTEMPTS})."
143
+ )
144
+ restart!
145
+ attempt += 1
146
+ retry
147
+ end
148
+ end
149
+
150
+ # Close the underlying transport. Idempotent — subsequent calls
151
+ # are no-ops. After close, {#call_tool} will fail on the dead
152
+ # client.
153
+ #
154
+ # @return [void]
155
+ def close
156
+ return if @closed
157
+
158
+ begin
159
+ @transport&.close
160
+ rescue StandardError => e
161
+ LOGGER.warn(
162
+ "Error closing MCP transport for #{@entry.id.inspect}: " \
163
+ "#{e.class}: #{e.message}"
164
+ )
165
+ end
166
+ @closed = true
167
+ end
168
+
169
+ private
170
+
171
+ def spawn_fresh!
172
+ @transport = build_transport
173
+ begin
174
+ @client = MCP::Client.new(transport: @transport)
175
+ @client.connect
176
+ 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.
184
+ begin
185
+ @transport.close
186
+ rescue StandardError
187
+ nil
188
+ end
189
+ raise
190
+ end
191
+ end
192
+
193
+ def restart!
194
+ begin
195
+ @transport.close
196
+ rescue StandardError
197
+ # The old transport's subprocess is presumed dead — close
198
+ # may raise (e.g. on an already-closed pipe). We don't
199
+ # care: we're about to discard the transport anyway, and
200
+ # the recovery is the fresh spawn below.
201
+ nil
202
+ end
203
+ spawn_fresh!
204
+ end
205
+
206
+ def build_transport
207
+ case @entry
208
+ when Registry::StdioEntry
209
+ MCP::Client::Stdio.new(
210
+ command: @entry.command.first,
211
+ args: @entry.command.drop(1)
212
+ )
213
+ when Registry::HttpEntry
214
+ MCP::Client::HTTP.new(url: @entry.url, headers: @entry.headers)
215
+ else
216
+ raise ArgumentError, "Unknown MCP entry type: #{@entry.class}"
217
+ end
218
+ end
219
+
220
+ def subprocess_dead?(error)
221
+ msg = error.message.to_s
222
+ SUBPROCESS_DEAD_PATTERNS.any? { |pat| msg.include?(pat) }
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
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
17
+ # extensions own. +bind+ fires per agent (parent + each
18
+ # sub-agent in Step 4's world) and creates a *fresh* {Connect}
19
+ # tool keyed to whichever agent is being bound, registered via
20
+ # {Agent#internal_add_tool} so it lands only on that agent's
21
+ # +RubyLLM::Chat+ (not in pikuri's +@tools+ list — sub-agents
22
+ # therefore don't inherit the parent's connect tool through the
23
+ # snapshot, and each agent's activation Set stays per-agent).
24
+ #
25
+ # == Usage
26
+ #
27
+ # registry = Pikuri::Mcp::Registry.new(entries: [
28
+ # Pikuri::Mcp::Registry::StdioEntry.new(id: 'gmail', command: %w[gmail-mcp])
29
+ # ])
30
+ # Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
31
+ # c.add_extension Pikuri::Mcp::Extension.new(registry: registry)
32
+ # end
33
+ #
34
+ # == Empty registry
35
+ #
36
+ # When the registry is {Registry#empty?}, the extension is a
37
+ # no-op — no Servers, no snippet, no tool, no on_close. Same
38
+ # semantics as the legacy +mcp_registry:+ kwarg on
39
+ # {Pikuri::Agent#initialize}, which routes through this
40
+ # extension as a transition layer.
41
+ class Extension
42
+ include Pikuri::Agent::Extension
43
+
44
+ # @param registry [Mcp::Registry] configured MCP servers.
45
+ # Defaults to {Registry::EMPTY} — extension is a no-op then.
46
+ # @param synthesize_descriptions [Boolean] threads a
47
+ # {Synthesizer} into {Servers} so servers without an
48
+ # +instructions+ / +serverInfo.title+ field get an
49
+ # LLM-synthesized description for the +<available_mcps>+
50
+ # block. On by default — see the +synthesize_descriptions:+
51
+ # kwarg on +Agent#initialize+ for the full rationale.
52
+ # @param verify_mcp_servers [Boolean] threads a {Verifier}
53
+ # into {Servers} so every server's handshake + tool surface
54
+ # is checked for prompt-injection patterns before tools can
55
+ # register. On by default — see the +verify_mcp_servers:+
56
+ # kwarg on +Agent#initialize+.
57
+ def initialize(registry: Registry::EMPTY,
58
+ synthesize_descriptions: true,
59
+ verify_mcp_servers: true)
60
+ @registry = registry
61
+ @synthesize_descriptions = synthesize_descriptions
62
+ @verify_mcp_servers = verify_mcp_servers
63
+ @servers = nil
64
+ end
65
+
66
+ # @return [Mcp::Servers, nil] the runtime built in +configure+,
67
+ # or +nil+ when the registry was empty (extension is a
68
+ # no-op).
69
+ attr_reader :servers
70
+
71
+ # Build the shared {Servers} runtime, append the
72
+ # +<available_mcps>+ block to the system prompt, and register
73
+ # the close handler. The +Synthesizer+ / +Verifier+ thinker
74
+ # closure captures the Configurator's transport + cancellable
75
+ # so the LLM-driven boot passes run against the same model
76
+ # the agent itself uses and honor the same cancel flag.
77
+ #
78
+ # @param c [Pikuri::Agent::Configurator]
79
+ # @return [void]
80
+ def configure(c)
81
+ return if @registry.empty?
82
+
83
+ if @synthesize_descriptions || @verify_mcp_servers
84
+ thinker = build_thinker(c.transport, c.cancellable)
85
+ end
86
+ synthesizer = build_synthesizer(thinker, c.transport.model) if @synthesize_descriptions
87
+ verifier = build_verifier(thinker, c.transport.model) if @verify_mcp_servers
88
+
89
+ @servers = Mcp::Servers.new(@registry, synthesizer: synthesizer, verifier: verifier)
90
+ c.append_system_prompt(@servers.system_prompt_snippet.lstrip) unless @servers.empty?
91
+ c.on_close { @servers.close }
92
+ nil
93
+ end
94
+
95
+ # Register a per-agent +mcp_connect+ tool on the agent's chat.
96
+ # The tool's +execute+ closure captures the agent reference so
97
+ # activations register their tools on the correct chat — see
98
+ # IDEAS.md §"Two invariants worth recording" for the
99
+ # static-vs-dynamic tool boundary.
100
+ #
101
+ # @param agent [Pikuri::Agent]
102
+ # @return [void]
103
+ def bind(agent)
104
+ return if @servers.nil? || @servers.empty?
105
+
106
+ if agent.tools.any?(Mcp::Servers::Connect)
107
+ raise 'Mcp::Servers::Connect cannot be passed in tools: when an MCP runtime is wired; ' \
108
+ 'Agent auto-registers it.'
109
+ end
110
+
111
+ connect = @servers.build_mcp_connect_tool(agent)
112
+ agent.internal_add_tool(connect.to_ruby_llm_tool)
113
+ nil
114
+ end
115
+
116
+ private
117
+
118
+ def build_thinker(transport, cancellable)
119
+ ->(prompt) { Pikuri::Agent.think(transport: transport, prompt: prompt, cancellable: cancellable) }
120
+ end
121
+
122
+ def build_synthesizer(thinker, model_id)
123
+ Mcp::Synthesizer.new(
124
+ thinker: thinker,
125
+ cache: Mcp::Cache.new(
126
+ model_id: model_id,
127
+ prompt_version: Mcp::Synthesizer::PROMPT_VERSION
128
+ )
129
+ )
130
+ end
131
+
132
+ def build_verifier(thinker, model_id)
133
+ Mcp::Verifier.new(
134
+ thinker: thinker,
135
+ cache: Mcp::Cache.new(
136
+ model_id: model_id,
137
+ prompt_version: Mcp::Verifier::PROMPT_VERSION,
138
+ dir: File.join(File.dirname(Mcp::Cache::DIR), 'mcp_verifications')
139
+ )
140
+ )
141
+ end
142
+ end
143
+ end
144
+ end