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