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