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 +7 -0
- data/README.md +54 -0
- data/lib/pikuri/mcp/cache.rb +154 -0
- data/lib/pikuri/mcp/client_wrapper.rb +226 -0
- data/lib/pikuri/mcp/extension.rb +144 -0
- data/lib/pikuri/mcp/registry.rb +123 -0
- data/lib/pikuri/mcp/servers.rb +472 -0
- data/lib/pikuri/mcp/synthesizer.rb +104 -0
- data/lib/pikuri/mcp/verifier.rb +298 -0
- data/lib/pikuri-mcp.rb +27 -0
- metadata +93 -0
|
@@ -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('&', '&')
|
|
405
|
+
.gsub('<', '<')
|
|
406
|
+
.gsub('>', '>')
|
|
407
|
+
.gsub('"', '"')
|
|
408
|
+
.gsub("'", ''')
|
|
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
|