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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Mcp
5
+ # Pure configuration for MCP servers — id → transport descriptor
6
+ # pairs and nothing more. No I/O, no live state. {Servers}
7
+ # consumes a registry at construction time to start the actual
8
+ # clients.
9
+ #
10
+ # Split from {Servers} so the dependency on the +mcp+ gem and on
11
+ # subprocess / network lifecycle is confined to the runtime side.
12
+ # Tests that only need to exercise registry value semantics never
13
+ # have to load the gem or open a transport.
14
+ #
15
+ # == Entry types
16
+ #
17
+ # Two sibling value classes, picked per server. The registry holds
18
+ # a mixed array; {Servers#start_one} dispatches on the type.
19
+ #
20
+ # * {StdioEntry} — local subprocess, +command:+ argv. The +mcp+ gem
21
+ # spawns it; see CLAUDE.md "Subprocess seam" carve-out.
22
+ # * {HttpEntry} — remote MCP endpoint, +url:+ + optional +headers:+.
23
+ # Plain Faraday under the hood — no subprocess, no carve-out
24
+ # needed.
25
+ #
26
+ # == EMPTY
27
+ #
28
+ # The +EMPTY+ singleton is the default for {Agent#initialize}'s
29
+ # +mcp_registry:+ kwarg. When the registry is empty {Agent}
30
+ # short-circuits — no {Servers} is built, no +mcp_connect+ tool is
31
+ # registered, no +<available_mcps>+ block is appended to the system
32
+ # prompt. The +pikuri-chat+ binary relies on this short-circuit so it
33
+ # can omit the kwarg entirely.
34
+ #
35
+ # Same shape as {Pikuri::Skill::Catalog::EMPTY}.
36
+ class Registry
37
+ # One local-subprocess MCP server's configuration: a unique id
38
+ # (used by the LLM to call +mcp_connect+) and the argv array used
39
+ # to spawn the server process.
40
+ #
41
+ # +command+ is an argv array (not a shell string) to align with
42
+ # {Pikuri::Subprocess.spawn} conventions even though the +mcp+ gem
43
+ # owns the actual spawn — see CLAUDE.md "Subprocess seam" carve-out.
44
+ # First element is the executable; remaining elements are arguments.
45
+ #
46
+ # @!attribute [r] id
47
+ # @return [String] short identifier the LLM uses to refer to this
48
+ # server, e.g. +"gmail"+ or +"maven-tools"+.
49
+ # @!attribute [r] command
50
+ # @return [Array<String>] argv to spawn the server,
51
+ # e.g. +["npx", "mcp-maven-deps"]+.
52
+ StdioEntry = Data.define(:id, :command)
53
+
54
+ # One remote-HTTP MCP server's configuration: a unique id, the
55
+ # endpoint URL, and optional headers (typically auth) sent on every
56
+ # request.
57
+ #
58
+ # No OAuth in v1 — if the endpoint needs a bearer token, pass it
59
+ # via +headers:+ ({+"Authorization" => "Bearer ..."+}). Wiring the
60
+ # gem's +oauth:+ provider would start a browser flow and persist
61
+ # tokens; deferred until someone needs it. See CLAUDE.md
62
+ # "Subprocess seam" — HTTP entries don't fall under the carve-out
63
+ # because nothing is spawned.
64
+ #
65
+ # @!attribute [r] id
66
+ # @return [String] short identifier the LLM uses to refer to this
67
+ # server, e.g. +"hubspot"+.
68
+ # @!attribute [r] url
69
+ # @return [String] full MCP endpoint URL, e.g.
70
+ # +"https://mcp.example.com/v1"+.
71
+ # @!attribute [r] headers
72
+ # @return [Hash{String => String}] headers sent on every request.
73
+ # Frozen. Defaults to an empty hash.
74
+ HttpEntry = Data.define(:id, :url, :headers) do
75
+ def initialize(id:, url:, headers: {})
76
+ super(id: id, url: url, headers: headers.freeze)
77
+ end
78
+ end
79
+
80
+ # @param entries [Array<StdioEntry, HttpEntry>] zero or more server
81
+ # definitions. Order is preserved; duplicate ids raise.
82
+ # @return [Registry]
83
+ # @raise [ArgumentError] if two entries share an id.
84
+ def initialize(entries: [])
85
+ seen = {}
86
+ entries.each do |e|
87
+ raise ArgumentError, "Duplicate MCP id #{e.id.inspect}" if seen.key?(e.id)
88
+
89
+ seen[e.id] = true
90
+ end
91
+ @entries = entries.dup.freeze
92
+ freeze
93
+ end
94
+
95
+ # @return [Array<StdioEntry, HttpEntry>] all configured entries, in
96
+ # declaration order.
97
+ attr_reader :entries
98
+
99
+ # @return [Boolean] true when no servers are configured. {Agent}
100
+ # keys its MCP auto-wiring off this — empty registry ⇒ no surface
101
+ # change, same pattern as {Pikuri::Skill::Catalog#empty?}.
102
+ def empty?
103
+ @entries.empty?
104
+ end
105
+
106
+ # @param id [String]
107
+ # @return [StdioEntry, HttpEntry, nil] the entry with this id, or
108
+ # +nil+.
109
+ def get(id)
110
+ @entries.find { |e| e.id == id }
111
+ end
112
+
113
+ # @return [Array<String>] all configured ids, in declaration order.
114
+ def ids
115
+ @entries.map(&:id)
116
+ end
117
+
118
+ # Frozen empty registry. Used as the default for
119
+ # {Agent#initialize}'s +mcp_registry:+ kwarg.
120
+ EMPTY = new.freeze
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,472 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Pikuri
6
+ module Mcp
7
+ # Runtime side of MCP support: spawns the configured servers, holds
8
+ # one {ClientWrapper} per live server, and orchestrates the
9
+ # {Verifier} / {Synthesizer} passes that turn a raw MCP surface into
10
+ # the +<available_mcps>+ snippet. The +mcp+ gem dependency now lives
11
+ # one level down in {ClientWrapper}. Constructed from a {Registry}
12
+ # (config), eager-starts every entry at +#initialize+, registers an
13
+ # +at_exit+ hook to close cleanly on process exit.
14
+ #
15
+ # == Transports
16
+ #
17
+ # Transport selection (stdio vs HTTP) and the +mcp+ gem's
18
+ # +MCP::Client::Stdio+ / +MCP::Client::HTTP+ construction live inside
19
+ # {ClientWrapper}. {Servers} stays transport-agnostic: it asks the
20
+ # wrapper for +server_info+ / +tools+ at boot and delegates
21
+ # per-tool calls back to the wrapper, which handles restart-on-
22
+ # subprocess-death retry internally (see {ClientWrapper} for the
23
+ # retry contract).
24
+ #
25
+ # == Lifecycle and the +Subprocess.spawn+ carve-out
26
+ #
27
+ # The +Subprocess.spawn+ chokepoint carve-out applies *only* to stdio
28
+ # entries. The +mcp+ gem owns the subprocess lifecycle:
29
+ # +MCP::Client::Stdio+ calls +Process.spawn+ internally; forcing it
30
+ # through pikuri's {Pikuri::Subprocess.spawn} chokepoint would mean
31
+ # either forking the gem or threading custom IO pipes through its
32
+ # API — duplicating work the gem exists to do. So stdio MCP is the
33
+ # documented exception to the chokepoint convention in CLAUDE.md
34
+ # "Scope decisions": the gem spawns, we own +#close+ (via
35
+ # {ClientWrapper#close}), and +at_exit { close }+ runs from inside
36
+ # {#initialize} to make sure cleanup happens on every exit path.
37
+ # Graceful close on a stdio transport closes stdin → server's read
38
+ # loop hits EOF → server self-terminates, per the MCP spec.
39
+ #
40
+ # HTTP entries don't spawn anything — they're plain Faraday — so the
41
+ # carve-out doesn't apply. +at_exit { close }+ still runs to send the
42
+ # session-termination +DELETE+ per spec.
43
+ #
44
+ # == What lives where
45
+ #
46
+ # * {ClientWrapper}: the per-server client/transport lifecycle plus
47
+ # restart-and-retry on stdio subprocess death. {Servers} holds one
48
+ # wrapper per live server in +@wrappers+.
49
+ # * {Servers}: eager startup, audit logging, the wrappers hash,
50
+ # +#close+, the +<available_mcps>+ snippet renderer, and the
51
+ # shared +register_tools_with_agent+ that actually wires MCP tools
52
+ # into a given agent's underlying +RubyLLM::Chat+.
53
+ # * {View}: thin facade for sub-agents — same public surface as
54
+ # {Servers} but every method delegates to the root. Sub-agents
55
+ # share the parent's live wrappers; only the root owns +#close+.
56
+ # * {Connect}: the +mcp_connect+ tool the LLM calls. Private inner
57
+ # {Pikuri::Tool} subclass instantiated per-agent by
58
+ # {Agent#initialize}. Tracks its own +Set+ of activated ids so
59
+ # re-activation comes back as an LLM-actionable +"Error: ..."+
60
+ # String. Each agent (parent + each sub-agent) gets its own
61
+ # instance with its own set, so activation is strictly per-agent.
62
+ #
63
+ # == Why MCP tools bypass +Pikuri::Tool+
64
+ #
65
+ # MCP tools arrive with JSON Schema in their +input_schema+;
66
+ # +RubyLLM::Tool.params(schema)+ accepts that shape directly. We
67
+ # therefore synthesize +RubyLLM::Tool+ subclasses inside this class
68
+ # and feed them through {Agent#internal_add_tool} — no
69
+ # +Pikuri::Tool::Parameters+ in the middle. The strict-validation
70
+ # contract pikuri commits to for native tools is intentionally not
71
+ # extended to MCP tools in v1; MCP-side validation catches bad input,
72
+ # and the provenance prefix + audit log are the compensating
73
+ # transparency.
74
+ class Servers
75
+ LOGGER = Pikuri.logger_for('Mcp::Servers')
76
+ private_constant :LOGGER
77
+
78
+ # @param registry [Registry] configured servers to start.
79
+ # @param synthesizer [Synthesizer, nil] when set, invoked from
80
+ # {#resolve_description} for servers whose +initialize+
81
+ # handshake doesn't carry an +instructions+ or
82
+ # +serverInfo.title+ field useful enough to show the LLM.
83
+ # {Agent#initialize} builds and threads a {Synthesizer} (with
84
+ # thinker + cache) when its +synthesize_descriptions:+ kwarg
85
+ # is true. Pass +nil+ (the default) to skip synthesis and rely
86
+ # on the static fallback chain.
87
+ # @param verifier [Verifier, nil] when set, invoked from
88
+ # {#start_one} after +wrapper.tools+ but before
89
+ # {#resolve_description}. A {Verifier::InjectionDetected} raise
90
+ # aborts startup for that server (wrapper closed, exception
91
+ # propagated). {Agent#initialize} builds + threads a real
92
+ # {Verifier} when its +verify_mcp_servers:+ kwarg is true.
93
+ # Pass +nil+ (the default) to skip injection-checking and
94
+ # trust whatever the server emits.
95
+ # @return [Servers]
96
+ def initialize(registry, synthesizer: nil, verifier: nil)
97
+ @registry = registry
98
+ @synthesizer = synthesizer
99
+ @verifier = verifier
100
+ @wrappers = {} # id => ClientWrapper (only for servers that started)
101
+ @tools_cache = {} # id => Array<MCP::Client::Tool>
102
+ @descriptions = {} # id => short description shown in <available_mcps>
103
+ @live_ids = []
104
+ @closed = false
105
+
106
+ # Register cleanup *before* starting any server. If a later
107
+ # +start_one+ raises (notably Cancelled from synthesis), the
108
+ # exception propagates out of {#initialize} and the at_exit
109
+ # handler still fires at process exit to close any
110
+ # wrappers that did get opened.
111
+ register_at_exit
112
+ start_all
113
+ end
114
+
115
+ # @return [Array<String>] ids of servers that successfully started.
116
+ # Excludes any whose subprocess spawn or handshake failed (those
117
+ # are logged as warnings and dropped). Identical to {Registry#ids}
118
+ # when no startup failures occurred.
119
+ attr_reader :live_ids
120
+
121
+ # @return [Boolean] true when no servers are alive (either the
122
+ # registry was empty, or every configured server failed to start).
123
+ def empty?
124
+ @live_ids.empty?
125
+ end
126
+
127
+ # Build the +mcp_connect+ tool bound to the given agent. The agent
128
+ # passes itself so the tool can call +agent.internal_add_tool+
129
+ # without a circular constructor dance. Each call returns a fresh
130
+ # {Connect} with an empty activation set.
131
+ #
132
+ # @param agent [Agent]
133
+ # @return [Connect]
134
+ def build_mcp_connect_tool(agent)
135
+ Connect.new(servers: self, agent: agent)
136
+ end
137
+
138
+ # System-prompt block advertising every live MCP server to the LLM.
139
+ # Empty string when no servers are alive so the caller can
140
+ # unconditionally concatenate, same shape as
141
+ # {Pikuri::Skill::Catalog#format_for_prompt}. The block is *not*
142
+ # duplicated into the +mcp_connect+ tool description — the
143
+ # available-ids list lives in one semantic home.
144
+ #
145
+ # @return [String]
146
+ def system_prompt_snippet
147
+ return '' if empty?
148
+
149
+ lines = [
150
+ '',
151
+ '',
152
+ 'The following MCP (Model Context Protocol) servers expose tools you can pull into your toolset on demand.',
153
+ "Call `mcp_connect` with a server's id to register its tools. Schemas only enter context after you connect.",
154
+ '',
155
+ '<available_mcps>'
156
+ ]
157
+ @live_ids.each do |id|
158
+ lines << ' <mcp>'
159
+ lines << " <id>#{escape_xml(id)}</id>"
160
+ lines << " <description>#{escape_xml(@descriptions[id] || '(no description)')}</description>"
161
+ lines << ' </mcp>'
162
+ end
163
+ lines << '</available_mcps>'
164
+ lines.join("\n")
165
+ end
166
+
167
+ # Register every tool exposed by server +id+ into +agent+'s
168
+ # underlying +RubyLLM::Chat+. Public so {View#register_tools_with_agent}
169
+ # can delegate; intended caller is {Connect} via
170
+ # {Agent#internal_add_tool}. Doesn't track activation — that's
171
+ # {Connect}'s job (each agent's tool instance owns its own set).
172
+ #
173
+ # @param id [String] server id; must be in {#live_ids}.
174
+ # @param agent [Agent]
175
+ # @return [Integer] number of tools registered.
176
+ # @raise [ArgumentError] if +id+ isn't live.
177
+ def register_tools_with_agent(id, agent)
178
+ raise ArgumentError, "MCP server #{id.inspect} is not live" unless @live_ids.include?(id)
179
+
180
+ wrapper = @wrappers.fetch(id)
181
+ tools = @tools_cache.fetch(id)
182
+ tools.each do |mcp_tool|
183
+ rb_tool = synthesize_ruby_llm_tool(server_id: id, wrapper: wrapper, mcp_tool: mcp_tool)
184
+ agent.internal_add_tool(rb_tool)
185
+ end
186
+ tools.size
187
+ end
188
+
189
+ # Close every live transport, terminating the spawned MCP server
190
+ # subprocesses. Idempotent. Registered via +at_exit+ from
191
+ # {#initialize} so process exit always cleans up, but callers may
192
+ # invoke it explicitly (e.g. tests that swap in a fresh
193
+ # {Servers}). {ClientWrapper#close} catches and logs its own
194
+ # transport-close errors, so the loop here doesn't need a rescue.
195
+ #
196
+ # @return [void]
197
+ def close
198
+ return if @closed
199
+
200
+ @wrappers.each_value(&:close)
201
+ @closed = true
202
+ end
203
+
204
+ private
205
+
206
+ def start_all
207
+ @registry.entries.each { |entry| start_one(entry) }
208
+ end
209
+
210
+ def start_one(entry)
211
+ wrapper = ClientWrapper.new(entry)
212
+ tools = wrapper.tools
213
+
214
+ # Verification, when wired, runs BEFORE the server enters
215
+ # @live_ids and BEFORE its description is resolved — a
216
+ # rejected server never gets surfaced to the LLM at all. An
217
+ # InjectionDetected raise propagates out through the rescue
218
+ # below; it is intentionally not caught as a generic
219
+ # StandardError because we want the user to see the
220
+ # rejection, not have it logged as a per-server "Failed to
221
+ # start" warning.
222
+ @verifier&.call(entry: entry, client: wrapper.client, tools: tools)
223
+
224
+ @wrappers[entry.id] = wrapper
225
+ @tools_cache[entry.id] = tools
226
+ @descriptions[entry.id] = resolve_description(entry, wrapper.client, tools)
227
+ @live_ids << entry.id
228
+
229
+ log_audit(entry, wrapper.client, tools)
230
+ rescue Agent::Control::Cancellable::Cancelled, Verifier::InjectionDetected
231
+ # Cancellation or verifier rejection — abort startup rather
232
+ # than logging this as a per-server "Failed to start" warning,
233
+ # so the user sees the actual reason. {ClientWrapper.new}
234
+ # cleans up its own half-opened transport on failure, so
235
+ # +wrapper+ is either nil (handshake failed inside the wrapper)
236
+ # or a fully-initialized object that we have to close here.
237
+ wrapper&.close
238
+ raise
239
+ rescue StandardError => e
240
+ LOGGER.warn(
241
+ "Failed to start MCP server #{entry.id.inspect} " \
242
+ "(#{describe_entry(entry)}): #{e.class}: #{e.message}"
243
+ )
244
+ # Same as above — {ClientWrapper.new}'s own rescue handles the
245
+ # half-opened-transport case, so +wrapper+ here is either nil
246
+ # or fully initialized. Closing covers the "verifier passed but
247
+ # resolve_description / log_audit raised" path.
248
+ wrapper&.close
249
+ end
250
+
251
+ # Short human-readable identifier for an entry's transport, used in
252
+ # the warning log when startup fails.
253
+ def describe_entry(entry)
254
+ case entry
255
+ when Registry::StdioEntry then "command: #{entry.command.join(' ')}"
256
+ when Registry::HttpEntry then "url: #{entry.url}"
257
+ else entry.class.to_s
258
+ end
259
+ end
260
+
261
+ # Resolve the short description shown to the LLM under
262
+ # +<available_mcps>+. Priority chain:
263
+ #
264
+ # 1. +info['instructions']+ — the MCP spec field for "what this
265
+ # server does." When the server populates it, that's the
266
+ # authoritative blurb.
267
+ # 2. +info['serverInfo']['title']+ — human-readable name. Less
268
+ # informative than instructions but usually still better
269
+ # than +name+.
270
+ # 3. Synthesized via +@synthesizer+, when wired. This is the
271
+ # path that turns a no-+instructions+, no-+title+ server
272
+ # (the common case in the wild — most server authors skip
273
+ # both) from a useless +serverInfo.name+ echo into a real
274
+ # "this is what the server does" sentence. Errors inside
275
+ # {Synthesizer#call} are logged at WARN there and surface
276
+ # here as a +nil+ return; the chain continues to step 4.
277
+ # +Cancellable::Cancelled+ propagates so a boot-time Ctrl+C
278
+ # aborts the whole startup.
279
+ # 4. +info['serverInfo']['name']+ — operational identifier,
280
+ # last-resort fallback when steps 1-3 produced nothing.
281
+ # Non-informative (often just the package name) but better
282
+ # than nothing.
283
+ # 5. +'(no description)'+ — sentinel string for the unreachable
284
+ # case of a server whose +initialize+ response carries
285
+ # neither +instructions+ nor any +serverInfo+ fields.
286
+ def resolve_description(entry, client, tools)
287
+ info = client.server_info || {}
288
+ server_info = info['serverInfo'] || {}
289
+
290
+ return info['instructions'] if info['instructions'] && !info['instructions'].to_s.strip.empty?
291
+ return server_info['title'] if server_info['title'] && !server_info['title'].to_s.strip.empty?
292
+
293
+ if @synthesizer
294
+ synthesized = @synthesizer.call(entry: entry, client: client, tools: tools)
295
+ return synthesized if synthesized
296
+ end
297
+
298
+ server_info['name'] || '(no description)'
299
+ end
300
+
301
+ def log_audit(entry, client, tools)
302
+ info = client.server_info || {}
303
+ server_info = info['serverInfo'] || {}
304
+ lines = []
305
+ lines << "MCP server #{entry.id.inspect} started (#{describe_entry(entry)})"
306
+ lines << " Protocol version: #{info['protocolVersion'] || '(unset)'}"
307
+ lines << " Server name: #{server_info['name'] || '(unset)'}"
308
+ lines << " Server version: #{server_info['version'] || '(unset)'}"
309
+ lines << " Instructions: #{info['instructions'] || '(none)'}"
310
+ lines << " Description: #{@descriptions[entry.id]}"
311
+ lines << " Tools (#{tools.size}):"
312
+ tools.each do |t|
313
+ lines << " - #{t.name}"
314
+ lines << " description: #{t.description}"
315
+ lines << " input_schema: #{(t.input_schema || {}).inspect}"
316
+ out = t.respond_to?(:output_schema) ? t.output_schema : nil
317
+ lines << " output_schema: #{out ? out.inspect : '(none)'}"
318
+ end
319
+ LOGGER.info(lines.join("\n"))
320
+ end
321
+
322
+ # Build an anonymous +RubyLLM::Tool+ subclass for one MCP-exposed
323
+ # tool. Name is namespaced as +"<server_id>__<tool_name>"+ to avoid
324
+ # collisions across servers (and with pikuri's native tools).
325
+ # Description is prefixed with +[From MCP server "<id>"]+ as the
326
+ # cheap provenance / injection-defense marker. Input-parameter
327
+ # descriptions from +mcp_tool.input_schema+ flow through verbatim:
328
+ # +RubyLLM::Tool.params(schema_hash)+ stores the JSON Schema and
329
+ # +instance.params_schema+ deep-dups + stringifies it on the way
330
+ # to the provider, so each property's +"description"+ field
331
+ # reaches the LLM.
332
+ #
333
+ # The execute closure captures the {ClientWrapper}, not the raw
334
+ # +MCP::Client+. This is what makes restart-on-subprocess-death
335
+ # transparent to the LLM: the wrapper handles close + respawn +
336
+ # retry internally, so the call site stays a plain +call_tool+.
337
+ #
338
+ # Note: +mcp_tool.output_schema+ has no destination — RubyLLM has
339
+ # no return-schema slot in its +Tool+ surface (no equivalent of
340
+ # +params+ for outputs), and providers don't accept one in the
341
+ # tool-call schema either. We surface output schemas in the INFO
342
+ # audit log (+#log_audit+) so a privacy-conscious user can still
343
+ # see what an MCP server claims to return, and otherwise the
344
+ # data simply lives in the call response.
345
+ def synthesize_ruby_llm_tool(server_id:, wrapper:, mcp_tool:)
346
+ namespaced_name = "#{server_id}__#{mcp_tool.name}"
347
+ prefixed_desc = %([From MCP server "#{server_id}"]\n\n#{mcp_tool.description})
348
+ schema = normalize_schema(mcp_tool.input_schema)
349
+
350
+ # Capture the wrapper + tool for the execute closure. The
351
+ # closure converts the MCP::Tool::Response into the
352
+ # +"Error: ..."+ / plain-text shape pikuri's tool-call
353
+ # observation contract uses.
354
+ Class.new(RubyLLM::Tool) do
355
+ description(prefixed_desc)
356
+ params(schema)
357
+
358
+ define_singleton_method(:name) { namespaced_name }
359
+
360
+ define_method(:execute) do |**args|
361
+ # +ClientWrapper#call_tool+ returns the raw JSON-RPC response
362
+ # Hash: +{"jsonrpc", "id", "result" => {"content" => [...],
363
+ # "isError" => bool}}+ on success, or +{... "error" => {...}}+
364
+ # on JSON-RPC-level failure. Subprocess-death retries are
365
+ # handled inside the wrapper; we only see them here if all
366
+ # {ClientWrapper::MAX_CALL_ATTEMPTS} attempts failed.
367
+ begin
368
+ response = wrapper.call_tool(tool: mcp_tool, arguments: args)
369
+ rescue StandardError => e
370
+ next "Error: MCP server #{server_id.inspect} call failed: #{e.class}: #{e.message}"
371
+ end
372
+
373
+ if response['error']
374
+ err = response['error']
375
+ next "Error: MCP server #{server_id.inspect} returned JSON-RPC error " \
376
+ "#{err['code']}: #{err['message']}"
377
+ end
378
+
379
+ content_arr = Array(response.dig('result', 'content'))
380
+ text = content_arr.map { |c| c.is_a?(Hash) ? c['text'] : nil }
381
+ .compact.join("\n")
382
+ is_error = response.dig('result', 'isError') == true
383
+
384
+ is_error ? "Error: #{text.empty? ? '(no detail)' : text}" : text
385
+ end
386
+ end
387
+ end
388
+
389
+ # Normalize an MCP tool's +input_schema+ into a shape
390
+ # +RubyLLM::Tool.params+ accepts. Returns a minimal empty-object
391
+ # schema when the MCP tool didn't supply one.
392
+ def normalize_schema(schema)
393
+ return { type: 'object', properties: {}, required: [] } if schema.nil?
394
+
395
+ h = schema.is_a?(Hash) ? schema : schema.to_h
396
+ # Strip the +$schema+ URI declaration the MCP gem adds; RubyLLM
397
+ # doesn't need it and some providers reject extra top-level keys.
398
+ h = h.reject { |k, _| k.to_s == '$schema' }
399
+ # Symbol-keyed for consistency with +Pikuri::Tool::Parameters#to_h+.
400
+ h.transform_keys(&:to_sym)
401
+ end
402
+
403
+ def escape_xml(str)
404
+ str.to_s.gsub('&', '&amp;')
405
+ .gsub('<', '&lt;')
406
+ .gsub('>', '&gt;')
407
+ .gsub('"', '&quot;')
408
+ .gsub("'", '&apos;')
409
+ end
410
+
411
+ def register_at_exit
412
+ me = self
413
+ at_exit { me.close }
414
+ end
415
+
416
+ # The +mcp_connect+ tool exposed to the LLM. Private inner class —
417
+ # instantiated only by {Servers#build_mcp_connect_tool} (called
418
+ # from {Agent#initialize}), never by application code.
419
+ #
420
+ # Tracks its own activation +Set+ so re-connecting an already-active
421
+ # server returns a recoverable +"Error: ..."+ String (the LLM has
422
+ # the tools in its toolset and should just call them). Each agent
423
+ # owns its own {Connect} instance and therefore its own
424
+ # activation set — activation does not propagate between parent and
425
+ # sub-agent. See IDEAS.md §"v1 implementation shape".
426
+ class Connect < Pikuri::Tool
427
+ DESCRIPTION = <<~DESC
428
+ Connect to an MCP (Model Context Protocol) server, adding its tools to your toolset for the rest of this conversation.
429
+
430
+ Usage:
431
+ - The list of available server ids and their descriptions is in your system prompt under `<available_mcps>`.
432
+ - Call with a single `id` that matches one of those entries. The server's tools become callable immediately after.
433
+ - On `Error: server '...' is already connected`, do NOT retry — the tools are already in your toolset; call them directly.
434
+ - On `Error: unknown MCP server id ...`, pick an id that actually appears in `<available_mcps>`.
435
+ DESC
436
+
437
+ # @param servers [Servers] shared runtime this tool resolves
438
+ # ids against and registers tools through.
439
+ # @param agent [Agent] agent into whose chat the activated tools
440
+ # are registered.
441
+ def initialize(servers:, agent:)
442
+ activated = Set.new
443
+
444
+ super(
445
+ name: 'mcp_connect',
446
+ description: DESCRIPTION,
447
+ parameters: Pikuri::Tool::Parameters.build { |p|
448
+ p.required_string :id,
449
+ 'Id of the MCP server to connect to. ' \
450
+ 'Must match one of the ids listed under ' \
451
+ '`<available_mcps>` in the system prompt, e.g. "maven-tools".'
452
+ },
453
+ execute: lambda { |id:|
454
+ unless servers.live_ids.include?(id)
455
+ available = servers.live_ids.empty? ? 'none' : servers.live_ids.join(', ')
456
+ next "Error: unknown MCP server id #{id.inspect}. Available: #{available}."
457
+ end
458
+
459
+ if activated.include?(id)
460
+ next "Error: server '#{id}' is already connected; its tools are in your toolset, call them directly."
461
+ end
462
+
463
+ count = servers.register_tools_with_agent(id, agent)
464
+ activated << id
465
+ "Connected to MCP server '#{id}'. Added #{count} tool#{'s' if count != 1} to your toolset."
466
+ }
467
+ )
468
+ end
469
+ end
470
+ end
471
+ end
472
+ end