samagotchi 0.37.0 → 0.38.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/CHANGELOG.md +39 -1
- data/README.md +1 -1
- data/docs/broadcast.md +5 -2
- data/docs/configuration.md +1 -1
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/plugins.md +84 -32
- data/lib/samagotchi/broadcast/scope_card.rb +15 -2
- data/lib/samagotchi/broadcast/triage.rb +29 -12
- data/lib/samagotchi/broadcast/triage_log.rb +9 -4
- data/lib/samagotchi/broadcast_command.rb +4 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/plugin.rb +424 -89
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
- data/lib/samagotchi/desktop/macos.rb +27 -5
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +6 -4
- data/lib/samagotchi/plugin/api.rb +5 -3
- data/lib/samagotchi/tool_response.rb +5 -5
- data/lib/samagotchi/tool_runner.rb +18 -9
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- metadata +2 -1
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# The mcp bundle (docs/plugins.md, The mcp bundle): tools from MCP servers.
|
|
4
4
|
# Each server in config.yml runs as a child process (stdio only in v1) for
|
|
5
|
-
# the session's life
|
|
5
|
+
# the session's life. Its tools aren't declared one by one: they are kept in
|
|
6
|
+
# an index, and the model has two fixed tools, find_mcp_tools (a keyword
|
|
7
|
+
# search that answers each match's inputSchema) and mcp_call (calls one as
|
|
8
|
+
# <server>/<tool>). The request's tools never change with the servers', so
|
|
9
|
+
# the prompt cache keeps.
|
|
6
10
|
#
|
|
7
11
|
# bundles:
|
|
8
12
|
# mcp:
|
|
@@ -14,6 +18,7 @@
|
|
|
14
18
|
# env: {DEBUG: "0"} # added to chi's environment
|
|
15
19
|
# cwd: ~/scratch # default: where chi runs
|
|
16
20
|
# tools: [echo, add] # optional: only these (globs work)
|
|
21
|
+
# description: adds and echoes # optional: its line in find_mcp_tools
|
|
17
22
|
# timeout: 120 # optional: this server's per-call timeout
|
|
18
23
|
# attach_image_paths: true # default: an image path in the text, in the
|
|
19
24
|
# # temp dir or cwd, attaches it
|
|
@@ -28,12 +33,14 @@
|
|
|
28
33
|
# notice; the rest of chi works. One that exits mid-session starts again on
|
|
29
34
|
# its next call, at most MAX_RESTARTS times a session. /mcp lists the
|
|
30
35
|
# servers and their tools, with an estimate of the tokens their
|
|
31
|
-
# definitions take
|
|
36
|
+
# definitions take (only a search's answer carries them) and of what the
|
|
37
|
+
# two tools take in every request.
|
|
32
38
|
require "digest"
|
|
33
39
|
require "json"
|
|
34
40
|
require "open3"
|
|
35
41
|
require "samagotchi/process_group"
|
|
36
42
|
require "samagotchi/token_usage"
|
|
43
|
+
require "samagotchi/tools/args"
|
|
37
44
|
require "samagotchi/tool_declarations"
|
|
38
45
|
require "time"
|
|
39
46
|
require "shellwords"
|
|
@@ -46,11 +53,20 @@ class Plugin
|
|
|
46
53
|
DESCRIPTION_CHARS = 1024
|
|
47
54
|
PREVIEW_CHARS = 60
|
|
48
55
|
NAME_CHARS = 48
|
|
56
|
+
# Matches a search answers, and names a miss suggests.
|
|
57
|
+
FIND_RESULTS = 5
|
|
58
|
+
CLOSEST = 3
|
|
59
|
+
# A query word in a tool's name (or its server's) counts this many times
|
|
60
|
+
# one in its description.
|
|
61
|
+
NAME_WEIGHT = 3
|
|
62
|
+
# Tool names that stand for a server's summary when it has none.
|
|
63
|
+
SAMPLE_NAMES = 6
|
|
49
64
|
# A cached tool list older than this is refreshed in the background.
|
|
50
65
|
CACHE_TTL = 24 * 60 * 60
|
|
51
66
|
# Restarts of a server that exited, per session.
|
|
52
67
|
MAX_RESTARTS = 3
|
|
53
|
-
# The states whose server's tools
|
|
68
|
+
# The states whose server's tools mcp_call can reach (a cached or exited
|
|
69
|
+
# server starts on the call).
|
|
54
70
|
OFFERED = %i[running cached exited].freeze
|
|
55
71
|
IMAGE_EXT = { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
|
|
56
72
|
|
|
@@ -247,33 +263,34 @@ class Plugin
|
|
|
247
263
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
248
264
|
end
|
|
249
265
|
|
|
250
|
-
# One tool
|
|
251
|
-
# is the server's, +chi_name+
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
266
|
+
# One tool of a server in the index (the tools: filter applied): +name+
|
|
267
|
+
# is the server's, +chi_name+ mcp_<server>_<tool> (what guardrail rules
|
|
268
|
+
# match, and a name mcp_call takes too), +schema+ its inputSchema (an
|
|
269
|
+
# empty object when it has none). +error+ is why it can't be called (a
|
|
270
|
+
# bad schema), nil when it can.
|
|
255
271
|
McpTool = Data.define(:server, :name, :chi_name, :description, :schema, :error)
|
|
256
272
|
|
|
257
273
|
# One configured server: its service, state and tools. +listed+ is its
|
|
258
|
-
# tools/list as the server answers it (what the cache keeps), +
|
|
259
|
-
#
|
|
260
|
-
# list), +tokens+
|
|
274
|
+
# tools/list as the server answers it (what the cache keeps), +info+ the
|
|
275
|
+
# first sentence of its initialize instructions (cached too), +tools+
|
|
276
|
+
# its index (McpTool each; #index swaps in a whole new list), +tokens+
|
|
277
|
+
# their definitions' estimated size (#definition_tokens).
|
|
261
278
|
# +client+ is the running process's (a restart replaces it; the
|
|
262
279
|
# service's stop closes the current one), +restarts+ how many it had.
|
|
263
280
|
Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
|
|
264
|
-
:digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, keyword_init: true)
|
|
281
|
+
:digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, :info, keyword_init: true)
|
|
265
282
|
|
|
266
283
|
def initialize(settings = {})
|
|
267
284
|
@settings = settings
|
|
268
285
|
@timeout = positive(settings["timeout"]) || CALL_TIMEOUT
|
|
269
286
|
@startup_timeout = positive(settings["startup_timeout"]) || STARTUP_TIMEOUT
|
|
270
287
|
@servers = []
|
|
271
|
-
@publish = Mutex.new
|
|
272
288
|
@relist = Mutex.new
|
|
273
289
|
@restart = Mutex.new
|
|
274
|
-
# The tools
|
|
275
|
-
#
|
|
290
|
+
# The tools that can't be called (a bad schema): said once, not at each
|
|
291
|
+
# index.
|
|
276
292
|
@left_out = []
|
|
293
|
+
@left_out_lock = Mutex.new
|
|
277
294
|
end
|
|
278
295
|
|
|
279
296
|
# Each server's tools come from its cache (tools-<server>.json in the
|
|
@@ -283,7 +300,8 @@ class Plugin
|
|
|
283
300
|
# in the background. Without a cache (the first run, a changed config)
|
|
284
301
|
# the server starts in an init task (chi.init) that every UI shows; a
|
|
285
302
|
# turn sent meanwhile waits for its tools. start: eager starts it with
|
|
286
|
-
# every session.
|
|
303
|
+
# every session. find_mcp_tools and mcp_call are declared once, when a
|
|
304
|
+
# server is configured; nothing the servers do later changes them.
|
|
287
305
|
def register(chi)
|
|
288
306
|
@chi = chi
|
|
289
307
|
ctx = chi.ctx
|
|
@@ -304,7 +322,7 @@ class Plugin
|
|
|
304
322
|
# the error in its body.
|
|
305
323
|
failed = "#{server.name} didn't start"
|
|
306
324
|
if load_cache(server, ctx)
|
|
307
|
-
|
|
325
|
+
index(server, ctx)
|
|
308
326
|
if server.config["start"].to_s == "eager"
|
|
309
327
|
chi.init("Starting MCP server #{server.name}", timeout: wait, failed: failed) { boot(server, ctx) }
|
|
310
328
|
elsif server.cached_at.nil? || Time.now - server.cached_at > CACHE_TTL
|
|
@@ -321,6 +339,7 @@ class Plugin
|
|
|
321
339
|
end
|
|
322
340
|
end
|
|
323
341
|
end
|
|
342
|
+
declare_tools(chi) unless @servers.empty?
|
|
324
343
|
chi.command "/mcp", "list the MCP servers, their state and their tools", anytime: true do |_args, command_ctx|
|
|
325
344
|
command_ctx.card(title: "MCP servers", body: listing, id: "mcp-servers")
|
|
326
345
|
nil
|
|
@@ -353,7 +372,8 @@ class Plugin
|
|
|
353
372
|
|
|
354
373
|
# Spawn the server's process (it is server.client from then on),
|
|
355
374
|
# initialize, list the tools (cancelled with the turn that waits for
|
|
356
|
-
# it), then cache the list
|
|
375
|
+
# it), then cache the list and its info when either changed, and index
|
|
376
|
+
# the tools.
|
|
357
377
|
# @return [Client]
|
|
358
378
|
def spawn(server, ctx)
|
|
359
379
|
cancelled = -> { ctx.cancelled? }
|
|
@@ -365,42 +385,53 @@ class Plugin
|
|
|
365
385
|
tools_changed(server, ctx) if method == "notifications/tools/list_changed"
|
|
366
386
|
}
|
|
367
387
|
)
|
|
368
|
-
client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
369
|
-
|
|
370
|
-
|
|
388
|
+
initialized = client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
389
|
+
clientInfo: { name: "chi", version: Samagotchi::VERSION } },
|
|
390
|
+
timeout: @startup_timeout, cancelled: cancelled)
|
|
371
391
|
client.notify("notifications/initialized")
|
|
372
392
|
listed = list_tools(client, cancelled)
|
|
373
|
-
|
|
393
|
+
info = info_of(initialized)
|
|
394
|
+
changed = listed != server.listed || info != server.info
|
|
374
395
|
server.listed = listed
|
|
396
|
+
server.info = info
|
|
375
397
|
save_cache(server, ctx) if changed
|
|
398
|
+
index(server, ctx)
|
|
376
399
|
client
|
|
377
400
|
end
|
|
378
401
|
|
|
379
|
-
#
|
|
380
|
-
#
|
|
402
|
+
# The first sentence of initialize's instructions (at most
|
|
403
|
+
# DESCRIPTION_CHARS), nil when there are none.
|
|
404
|
+
def info_of(result)
|
|
405
|
+
text = result["instructions"].to_s.strip.gsub(/\s+/, " ")
|
|
406
|
+
return nil if text.empty?
|
|
407
|
+
|
|
408
|
+
sentence = text[/\A.*?[.!?](?=\s|\z)/] || text
|
|
409
|
+
sentence.length > DESCRIPTION_CHARS ? "#{sentence[0, DESCRIPTION_CHARS - 1]}…" : sentence
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Start a server in an init task; its tools replace the cached ones in
|
|
413
|
+
# the index (or come for the first time: #spawn indexes them).
|
|
381
414
|
# @return [String] the task's summary
|
|
382
|
-
# @raise [Client::Error] it didn't start (the task's warn card says why
|
|
415
|
+
# @raise [Client::Error] it didn't start (the task's warn card says why;
|
|
416
|
+
# the index keeps its tools, marked failed)
|
|
383
417
|
def boot(server, ctx)
|
|
384
|
-
before = server.listed
|
|
385
418
|
client = server.service.value
|
|
386
419
|
server.state = :running
|
|
387
420
|
ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size)
|
|
388
|
-
publish(ctx) if server.listed != before
|
|
389
421
|
count = server.listed.size
|
|
390
422
|
"#{server.name} ready, #{count} tool#{"s" unless count == 1}"
|
|
391
423
|
rescue StandardError => e
|
|
392
|
-
had_tools = server.state == :cached
|
|
393
424
|
server.state = :failed
|
|
394
425
|
server.error = e.message
|
|
395
426
|
ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message)
|
|
396
|
-
|
|
397
|
-
raise Client::Error, "MCP server #{server.name} didn't start: #{e.message}; its tools are left out"
|
|
427
|
+
raise Client::Error, "MCP server #{server.name} didn't start: #{e.message}; its calls fail until chi restarts"
|
|
398
428
|
end
|
|
399
429
|
|
|
400
430
|
# The quiet daily refresh of a cached server's list: a server of its own
|
|
401
431
|
# (not the session's: that one still starts on the first call), listed
|
|
402
432
|
# and stopped. The cache is rewritten (its clock too) and a changed list
|
|
403
|
-
# replaces the tools. One worker at a time (a lock file); the
|
|
433
|
+
# replaces the indexed tools. One worker at a time (a lock file); the
|
|
434
|
+
# others skip.
|
|
404
435
|
def refresh(server, ctx)
|
|
405
436
|
File.open("#{cache_path(server, ctx)}.lock", File::CREAT | File::RDWR) do |lock|
|
|
406
437
|
return nil unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
@@ -409,9 +440,9 @@ class Plugin
|
|
|
409
440
|
client = Client.new(server.command, env: server.env, cwd: server.cwd,
|
|
410
441
|
log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) })
|
|
411
442
|
begin
|
|
412
|
-
client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
413
|
-
|
|
414
|
-
|
|
443
|
+
initialized = client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
444
|
+
clientInfo: { name: "chi", version: Samagotchi::VERSION } },
|
|
445
|
+
timeout: @startup_timeout, cancelled: cancelled)
|
|
415
446
|
client.notify("notifications/initialized")
|
|
416
447
|
listed = list_tools(client, cancelled)
|
|
417
448
|
ensure
|
|
@@ -422,16 +453,17 @@ class Plugin
|
|
|
422
453
|
|
|
423
454
|
changed = listed != server.listed
|
|
424
455
|
server.listed = listed
|
|
456
|
+
server.info = info_of(initialized)
|
|
425
457
|
save_cache(server, ctx)
|
|
426
458
|
ctx.log.info("mcp_tools_refreshed", server: server.name, tools: listed.size, changed: changed)
|
|
427
|
-
|
|
459
|
+
index(server, ctx) if changed
|
|
428
460
|
end
|
|
429
461
|
nil
|
|
430
462
|
end
|
|
431
463
|
|
|
432
464
|
# notifications/tools/list_changed from a running server: list its tools
|
|
433
465
|
# again (on a thread of its own: the answer comes on the reader thread
|
|
434
|
-
# that told us), save them and
|
|
466
|
+
# that told us), save them and index them.
|
|
435
467
|
# Notices that come together list once more after the one running.
|
|
436
468
|
def tools_changed(server, ctx)
|
|
437
469
|
@relist.synchronize do
|
|
@@ -464,7 +496,7 @@ class Plugin
|
|
|
464
496
|
server.listed = listed
|
|
465
497
|
save_cache(server, ctx)
|
|
466
498
|
ctx.log.info("mcp_tools_changed", server: server.name, tools: listed.size)
|
|
467
|
-
|
|
499
|
+
index(server, ctx)
|
|
468
500
|
rescue StandardError => e
|
|
469
501
|
ctx.log.warn("mcp_tools_not_relisted", server: server.name, error: e.class.name, msg: e.message)
|
|
470
502
|
end
|
|
@@ -497,6 +529,7 @@ class Plugin
|
|
|
497
529
|
return false unless data.is_a?(Hash) && data["digest"] == server.digest && data["tools"].is_a?(Array)
|
|
498
530
|
|
|
499
531
|
server.listed = data["tools"].select { |tool| tool.is_a?(Hash) && tool["name"] }
|
|
532
|
+
server.info = data["info"].is_a?(String) ? data["info"] : nil
|
|
500
533
|
server.cached_at = begin
|
|
501
534
|
Time.iso8601(data["saved_at"].to_s)
|
|
502
535
|
rescue ArgumentError
|
|
@@ -511,69 +544,375 @@ class Plugin
|
|
|
511
544
|
# Written aside and renamed: workers share the dir.
|
|
512
545
|
def save_cache(server, ctx)
|
|
513
546
|
Samagotchi::AtomicFile.write(cache_path(server, ctx),
|
|
514
|
-
JSON.generate({ digest: server.digest, saved_at: Time.now.utc.iso8601,
|
|
547
|
+
JSON.generate({ digest: server.digest, saved_at: Time.now.utc.iso8601, info: server.info,
|
|
548
|
+
tools: server.listed }.compact))
|
|
515
549
|
rescue SystemCallError => e
|
|
516
550
|
ctx.log.warn("mcp_cache_not_written", server: server.name, msg: e.message)
|
|
517
551
|
end
|
|
518
552
|
|
|
519
553
|
# ── Tools ─────────────────────────────────────────────────────────────
|
|
520
554
|
|
|
521
|
-
#
|
|
522
|
-
#
|
|
523
|
-
#
|
|
524
|
-
|
|
555
|
+
# Index the server's tools, the tools: filter applied: each named, its
|
|
556
|
+
# schema checked (a bad one is kept, marked, and said once). The whole
|
|
557
|
+
# list is built, then swapped in: a search on another thread never sees
|
|
558
|
+
# half a list. Their estimated tokens go to the log when they changed.
|
|
559
|
+
def index(server, ctx)
|
|
525
560
|
wanted = server.config["tools"] && Array(server.config["tools"]).map(&:to_s)
|
|
526
|
-
listed = server.listed
|
|
561
|
+
listed = server.listed || []
|
|
527
562
|
listed = listed.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
|
|
528
563
|
tokens = 0
|
|
529
564
|
tools = listed.map do |listed_tool|
|
|
530
|
-
tool = McpTool.new(server: server.name, name: listed_tool["name"],
|
|
565
|
+
tool = McpTool.new(server: server.name, name: listed_tool["name"].to_s,
|
|
531
566
|
chi_name: tool_name(server.name, listed_tool["name"]), description: description(listed_tool),
|
|
532
567
|
schema: listed_tool["inputSchema"] || { "type" => "object", "properties" => {} }, error: nil)
|
|
533
|
-
target.tool(tool.chi_name, tool.description, schema: tool.schema,
|
|
534
|
-
label: "#{server.name}: #{tool.name}", preview: ->(args) { preview(args) }) do |args, call_ctx|
|
|
535
|
-
call(server, tool, args, call_ctx)
|
|
536
|
-
end
|
|
537
568
|
tokens += definition_tokens(tool.chi_name, tool.description, tool.schema)
|
|
538
569
|
tool
|
|
539
|
-
rescue
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
tool.with(error: e.message)
|
|
570
|
+
rescue StandardError => e # a schema chat_schemas can't read raises more than ArgumentError
|
|
571
|
+
error = e.message.sub(/\Atool \S+: /, "")
|
|
572
|
+
left_out("MCP tool #{server.name}/#{tool.name} can't be called: #{error}", ctx)
|
|
573
|
+
tool.with(error: error)
|
|
544
574
|
end
|
|
545
|
-
# Whole, in one swap: a reader on another thread never sees half a list.
|
|
546
575
|
server.tools = tools.freeze
|
|
576
|
+
say_clashes(ctx)
|
|
547
577
|
return if tokens == server.tokens
|
|
548
578
|
|
|
549
579
|
server.tokens = tokens
|
|
550
580
|
ctx.log.info("mcp_tools_estimated", server: server.name, tools: offered(server).size, tokens: tokens)
|
|
551
581
|
end
|
|
552
582
|
|
|
553
|
-
#
|
|
554
|
-
def
|
|
583
|
+
# Said once (a notice and the log), not at each index.
|
|
584
|
+
def left_out(text, ctx)
|
|
585
|
+
first = @left_out_lock.synchronize { @left_out.include?(text) ? false : (@left_out << text) }
|
|
586
|
+
return unless first
|
|
587
|
+
|
|
588
|
+
ctx.log.warn("mcp_tool_left_out", msg: text)
|
|
589
|
+
ctx.notify(text, level: :warn)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Tools of every server whose mcp_<server>_<tool> another tool has too
|
|
593
|
+
# (two servers, git-hub and git_hub; names alike up to NAME_CHARS):
|
|
594
|
+
# guardrail rules and approvals key on that name, so neither is called.
|
|
595
|
+
# Said once per clash; checked after each server's index, so whichever
|
|
596
|
+
# server comes second says it.
|
|
597
|
+
def say_clashes(ctx)
|
|
598
|
+
@servers.flat_map(&:tools).group_by(&:chi_name).each do |chi_name, same|
|
|
599
|
+
next if same.size < 2
|
|
600
|
+
|
|
601
|
+
left_out("MCP tools #{same.map { |t| "#{t.server}/#{t.name}" }.join(" and ")} can't be called: " \
|
|
602
|
+
"both are #{chi_name} to guardrail rules", ctx)
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# The other indexed tools with +tool+'s mcp_<server>_<tool>.
|
|
607
|
+
def clashes(tool)
|
|
608
|
+
@servers.flat_map(&:tools).select { |t| t.chi_name == tool.chi_name && [t.server, t.name] != [tool.server, tool.name] }
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Why mcp_call refuses +tool+, nil when it can call it.
|
|
612
|
+
def refusal(tool)
|
|
613
|
+
return tool.error if tool.error
|
|
614
|
+
|
|
615
|
+
others = clashes(tool)
|
|
616
|
+
"name clash with #{others.map { |t| "#{t.server}/#{t.name}" }.join(", ")} (#{tool.chi_name})" unless others.empty?
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# The server's tools mcp_call can call (no bad schema, no name clash).
|
|
620
|
+
def offered(server) = server.tools.reject { |tool| refusal(tool) }
|
|
621
|
+
|
|
622
|
+
# The marks a search and /mcp give a tool mcp_call refuses.
|
|
623
|
+
def mark(tool)
|
|
624
|
+
return "(bad schema)" if tool.error
|
|
625
|
+
|
|
626
|
+
others = clashes(tool)
|
|
627
|
+
"(name clash with #{others.map { |t| "#{t.server}/#{t.name}" }.join(", ")})" unless others.empty?
|
|
628
|
+
end
|
|
555
629
|
|
|
556
630
|
# A tool's definition as the chat path sends it (LLM::ChatLoop#tool_definitions:
|
|
557
631
|
# the chat schema, wrapped as a function), in estimated tokens
|
|
558
632
|
# (TokenUsage::CHARS_PER_TOKEN). The native prompts (Gemma, Qwen) render a
|
|
559
633
|
# flatter schema, so it is an upper bound there.
|
|
634
|
+
# @raise [ArgumentError] a bad schema
|
|
560
635
|
def definition_tokens(name, description, schema)
|
|
561
|
-
|
|
636
|
+
spec_tokens(Samagotchi::Plugin::Api.tool_spec(name, description, schema: schema) { nil })
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# A checked tool declaration's (Api.tool_spec) definition, in estimated
|
|
640
|
+
# tokens.
|
|
641
|
+
def spec_tokens(spec)
|
|
562
642
|
function = Samagotchi::ToolDeclarations.chat_schemas([spec[:schema]]).first.slice(:name, :description, :parameters)
|
|
563
643
|
Samagotchi::TokenUsage.estimate(JSON.generate({ type: "function", function: function }))
|
|
564
644
|
end
|
|
565
645
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
646
|
+
FIND_PARAMS = {
|
|
647
|
+
query: { type: "string", required: true,
|
|
648
|
+
description: "keywords for what the tool does (\"screenshot page\"); empty lists every tool's name" },
|
|
649
|
+
server: { type: "string", description: "search only this server" }
|
|
650
|
+
}.freeze
|
|
651
|
+
CALL_DESCRIPTION = "Call an MCP server's tool that find_mcp_tools found."
|
|
652
|
+
CALL_PARAMS = {
|
|
653
|
+
tool: { type: "string", required: true, description: "the tool as <server>/<tool>" },
|
|
654
|
+
args: { type: "object", description: "the arguments, per the inputSchema find_mcp_tools gave" }
|
|
655
|
+
}.freeze
|
|
656
|
+
|
|
657
|
+
# find_mcp_tools and mcp_call, the model's only MCP tools; their
|
|
658
|
+
# definitions' estimated tokens are /mcp's.
|
|
659
|
+
def declare_tools(chi)
|
|
660
|
+
description = find_description
|
|
661
|
+
chi.tool("find_mcp_tools", description, params: FIND_PARAMS,
|
|
662
|
+
preview: ->(args) { [args["query"].to_s.strip, args["server"] && "in #{args["server"]}"].compact.join(" ") }) do |args, _ctx|
|
|
663
|
+
find(args["query"].to_s, args["server"])
|
|
664
|
+
end
|
|
665
|
+
chi.tool("mcp_call", CALL_DESCRIPTION, params: CALL_PARAMS, label: "mcp", preview: ->(args) { call_preview(args) },
|
|
666
|
+
targets: ->(args) { call_targets(args) }) do |args, ctx|
|
|
667
|
+
mcp_call(args["tool"].to_s, args["args"], ctx)
|
|
668
|
+
end
|
|
669
|
+
@fixed_tokens = spec_tokens(Samagotchi::Plugin::Api.tool_spec("find_mcp_tools", description, params: FIND_PARAMS) { nil }) +
|
|
670
|
+
spec_tokens(Samagotchi::Plugin::Api.tool_spec("mcp_call", CALL_DESCRIPTION, params: CALL_PARAMS) { nil })
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# find_mcp_tools' description: how to use it, and one line per server
|
|
674
|
+
# from what is known at load (a first run's server: its name, and its
|
|
675
|
+
# description: if set). Never every tool: knowing the names, a model
|
|
676
|
+
# searches less and guesses arguments.
|
|
677
|
+
def find_description
|
|
678
|
+
lines = @servers.map do |server|
|
|
679
|
+
count = offered(server).size
|
|
680
|
+
summary = server_summary(server)
|
|
681
|
+
line = "- #{server.name}"
|
|
682
|
+
line += ": #{summary}" if summary
|
|
683
|
+
line += " (#{count} tool#{"s" unless count == 1})" unless server.tools.empty?
|
|
684
|
+
line
|
|
685
|
+
end
|
|
686
|
+
"Search the tools of the MCP servers below by keywords. It answers up to #{FIND_RESULTS} tools, each with " \
|
|
687
|
+
"its name (<server>/<tool>) and inputSchema; call one with mcp_call. An empty query lists every tool's " \
|
|
688
|
+
"name.\nServers:\n#{lines.join("\n")}"
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# The server's line: its description: from config, else the first
|
|
692
|
+
# sentence of its instructions, else its first tool names.
|
|
693
|
+
def server_summary(server)
|
|
694
|
+
given = server.config["description"].to_s.strip
|
|
695
|
+
return given unless given.empty?
|
|
696
|
+
return server.info if server.info
|
|
697
|
+
|
|
698
|
+
names = server.tools.map(&:name)
|
|
699
|
+
return nil if names.empty?
|
|
700
|
+
|
|
701
|
+
"tools #{names.take(SAMPLE_NAMES).join(", ")}#{", …" if names.size > SAMPLE_NAMES}"
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# ── Search ────────────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
# find_mcp_tools: the best matches with their schemas, or with an empty
|
|
707
|
+
# query every server's tool names.
|
|
708
|
+
def find(query, only)
|
|
709
|
+
servers = @servers
|
|
710
|
+
if only && !only.strip.empty?
|
|
711
|
+
servers = @servers.select { |server| server.name == only.strip }
|
|
712
|
+
return "Error: no MCP server #{only.strip}. Servers: #{@servers.map(&:name).join(", ")}" if servers.empty?
|
|
713
|
+
end
|
|
714
|
+
return names_text(servers) if words(query).empty?
|
|
715
|
+
|
|
716
|
+
matches = ranked(query, servers.flat_map(&:tools)).take(FIND_RESULTS)
|
|
717
|
+
return "No MCP tool matches \"#{query.strip}\".\n\n#{names_text(servers)}" if matches.empty?
|
|
718
|
+
|
|
719
|
+
blocks = matches.map { |tool| match_text(tool) }
|
|
720
|
+
unsearched = servers.filter_map { |server| "#{server.name} #{note(server)}" if server.tools.empty? && note(server) }
|
|
721
|
+
blocks << "Not searched: #{unsearched.join("; ")}." unless unsearched.empty?
|
|
722
|
+
"Call one with mcp_call(tool: \"<server>/<tool>\", args: {…}).\n\n#{blocks.join("\n\n")}"
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# One match: its name, why it can't be called (if so), its description
|
|
726
|
+
# and schema.
|
|
727
|
+
def match_text(tool)
|
|
728
|
+
server = @servers.find { |s| s.name == tool.server }
|
|
729
|
+
head = ["#{tool.server}/#{tool.name}", mark(tool), note(server)].compact.join(" ")
|
|
730
|
+
refused = refusal(tool)
|
|
731
|
+
return "#{head}: #{tool.description}\nIt can't be called: #{refused}" if refused
|
|
732
|
+
|
|
733
|
+
"#{head}: #{tool.description}\ninputSchema: #{JSON.generate(tool.schema)}"
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# What a search says about a server whose tools can't be called now.
|
|
737
|
+
def note(server)
|
|
738
|
+
case server.state
|
|
739
|
+
when :failed then "(failed: #{server.error})"
|
|
740
|
+
when :starting then "(starting, try again)"
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# Each server's tool names, one line each.
|
|
745
|
+
def names_text(servers)
|
|
746
|
+
lines = servers.map do |server|
|
|
747
|
+
names = server.tools.map { |tool| [tool.name, mark(tool)].compact.join(" ") }
|
|
748
|
+
head = [server.name, note(server)].compact.join(" ")
|
|
749
|
+
names.empty? ? head : "#{head}: #{names.join(", ")}"
|
|
750
|
+
end
|
|
751
|
+
"MCP tools by server (call one as <server>/<tool>; search for its inputSchema):\n#{lines.join("\n")}"
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
# +tools+ that share a word with +query+, best first: a word scores its
|
|
755
|
+
# IDF over +tools+ (a word most of them have counts little), times
|
|
756
|
+
# NAME_WEIGHT in the tool's or its server's name. Ties keep the index's
|
|
757
|
+
# order.
|
|
758
|
+
def ranked(query, tools)
|
|
759
|
+
wanted = words(query).uniq
|
|
760
|
+
docs = tools.map { |tool| [words("#{tool.server} #{tool.name}"), words(tool.description)] }
|
|
761
|
+
df = Hash.new(0)
|
|
762
|
+
docs.each { |name, text| (name | text).each { |word| df[word] += 1 } }
|
|
763
|
+
count = tools.size.to_f
|
|
764
|
+
scored = tools.each_with_index.filter_map do |tool, i|
|
|
765
|
+
name, text = docs[i]
|
|
766
|
+
score = wanted.sum do |word|
|
|
767
|
+
idf = Math.log(((count - df[word] + 0.5) / (df[word] + 0.5)) + 1)
|
|
768
|
+
if name.include?(word) then NAME_WEIGHT * idf
|
|
769
|
+
elsif text.include?(word) then idf
|
|
770
|
+
else 0
|
|
771
|
+
end
|
|
573
772
|
end
|
|
773
|
+
[tool, score, i] if score.positive?
|
|
774
|
+
end
|
|
775
|
+
scored.sort_by { |_, score, i| [-score, i] }.map(&:first)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# Lower-case words, split at camelCase too, a plural's s dropped.
|
|
779
|
+
def words(text)
|
|
780
|
+
text.to_s.gsub(/([a-z0-9])([A-Z])/, '\1 \2').downcase.scan(/[a-z0-9]+/).map do |word|
|
|
781
|
+
word.length > 3 && word.end_with?("s") && !word.end_with?("ss") ? word.chomp("s") : word
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# ── mcp_call ──────────────────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
# A call of the tool +name+ names, with +args+ typed by its schema. A
|
|
788
|
+
# failure the arguments may cause answers its inputSchema too. It fails
|
|
789
|
+
# closed: a tool that isn't the one guardrails were given (#claimed) is
|
|
790
|
+
# refused.
|
|
791
|
+
def mcp_call(name, args, ctx)
|
|
792
|
+
tool = lookup(name)
|
|
793
|
+
return tool if tool.is_a?(String)
|
|
794
|
+
unless claimed(name.strip)&.first == tool.chi_name
|
|
795
|
+
return "Error: the MCP tools changed while the call waited for its server; call #{tool.server}/#{tool.name} again"
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
server = @servers.find { |s| s.name == tool.server }
|
|
799
|
+
refused = refusal(tool)
|
|
800
|
+
return "Error: #{tool.server}/#{tool.name} can't be called: #{refused}" if refused
|
|
801
|
+
|
|
802
|
+
inner = inner_args(args, tool)
|
|
803
|
+
return "Error: args must be an object.#{schema_note(tool)}" unless inner
|
|
804
|
+
|
|
805
|
+
call(server, tool, inner, ctx)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# The McpTool +name+ names: <server>/<tool>, or mcp_<server>_<tool>
|
|
809
|
+
# looked up by chi_name (never parsed: server names may hold _, and long
|
|
810
|
+
# names are cut). A miss while a server is starting waits for it (its
|
|
811
|
+
# tools may be the one); then a miss is the error text: a failed
|
|
812
|
+
# server's error, else the closest tools.
|
|
813
|
+
# @return [McpTool, String]
|
|
814
|
+
def lookup(name, waited: false)
|
|
815
|
+
name = name.strip
|
|
816
|
+
tool = @servers.lazy.filter_map do |server|
|
|
817
|
+
next unless name.start_with?("#{server.name}/")
|
|
818
|
+
|
|
819
|
+
rest = name.delete_prefix("#{server.name}/")
|
|
820
|
+
server.tools.find { |t| t.name == rest }
|
|
821
|
+
end.first
|
|
822
|
+
return tool if tool
|
|
823
|
+
|
|
824
|
+
same = @servers.flat_map(&:tools).select { |t| t.chi_name == name }
|
|
825
|
+
return same.first if same.size == 1
|
|
826
|
+
if same.size > 1
|
|
827
|
+
return "Error: #{name} names #{same.map { |t| "#{t.server}/#{t.name}" }.join(" and ")}; " \
|
|
828
|
+
"call one as <server>/<tool>"
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
starting = @servers.select { |server| server.state == :starting }
|
|
832
|
+
unless waited || starting.empty?
|
|
833
|
+
starting.each { |server| wait_started(server) }
|
|
834
|
+
return lookup(name, waited: true)
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
failed = @servers.find { |server| server.state == :failed && name.start_with?("#{server.name}/") }
|
|
838
|
+
return "Error: MCP server #{failed.name} didn't start: #{failed.error}" if failed
|
|
839
|
+
|
|
840
|
+
closest = ranked(name, @servers.flat_map(&:tools)).take(CLOSEST)
|
|
841
|
+
return "Error: no MCP tool #{name}. Search with find_mcp_tools." if closest.empty?
|
|
842
|
+
|
|
843
|
+
"Error: no MCP tool #{name}. Closest: #{closest.map { |t| "#{t.server}/#{t.name}" }.join(", ")}"
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
# Wait for a first-run server's start (its init task's); a failure is
|
|
847
|
+
# its init card's.
|
|
848
|
+
def wait_started(server)
|
|
849
|
+
server.service.value
|
|
850
|
+
rescue StandardError
|
|
851
|
+
nil
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
# The inner arguments as a Hash typed by +tool+'s schema (a JSON text
|
|
855
|
+
# parsed; untyped without a tool), nil when they aren't an object; none
|
|
856
|
+
# is {}.
|
|
857
|
+
def inner_args(args, tool)
|
|
858
|
+
args = {} if args.nil? || (args.is_a?(String) && args.strip.empty?)
|
|
859
|
+
args = Samagotchi::Tools::Args.coerce({ "args" => args }, { "properties" => { "args" => { "type" => "object" } } })["args"]
|
|
860
|
+
return nil unless args.is_a?(Hash)
|
|
861
|
+
|
|
862
|
+
Samagotchi::Tools::Args.coerce(args, tool&.schema)
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# Appended to a failure the arguments may cause: the tool's inputSchema,
|
|
866
|
+
# so the next call can fix them (a call guessed without a search).
|
|
867
|
+
def schema_note(tool)
|
|
868
|
+
"\n\n#{tool.server}/#{tool.name}'s inputSchema: #{JSON.generate(tool.schema)}"
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# The activity row: <server>/<tool> and its arguments.
|
|
872
|
+
def call_preview(args)
|
|
873
|
+
tool = lookup_quietly(args["tool"].to_s)
|
|
874
|
+
name = tool ? "#{tool.server}/#{tool.name}" : args["tool"].to_s.strip
|
|
875
|
+
inner = tool && inner_args(args["args"], tool)
|
|
876
|
+
inner ? [name, preview(inner)].reject(&:empty?).join(" ") : name
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# What guardrail rules match an mcp_call: the tool it calls, by today's
|
|
880
|
+
# mcp_<server>_<tool> (rules written for it still fire), its arguments,
|
|
881
|
+
# and the question's label. A tool not indexed yet (its server still
|
|
882
|
+
# starting) is named from what the model gave (#claimed), so a rule on
|
|
883
|
+
# it fires before the call waits for the server. Nothing for a name
|
|
884
|
+
# that can't be a tool (mcp_call refuses it).
|
|
885
|
+
def call_targets(args)
|
|
886
|
+
name = args["tool"].to_s.strip
|
|
887
|
+
tool = lookup_quietly(name)
|
|
888
|
+
acts_as, label = tool ? [tool.chi_name, "#{tool.server}: #{tool.name}"] : claimed(name)
|
|
889
|
+
return {} unless acts_as
|
|
890
|
+
|
|
891
|
+
{ acts_as: acts_as, args: inner_args(args["args"], tool) || {}, label: label }.compact
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
# The mcp_<server>_<tool> and label +name+ stands for, from the name
|
|
895
|
+
# alone: <server>/<tool> of a configured server (the one whose index has
|
|
896
|
+
# the tool, else the first whose name it starts with), or an
|
|
897
|
+
# mcp_<server>_<tool> as it is (no label). Nil for anything else.
|
|
898
|
+
# @return [Array(String, String), Array(String, nil), nil]
|
|
899
|
+
def claimed(name)
|
|
900
|
+
servers = @servers.select { |server| name.start_with?("#{server.name}/") }
|
|
901
|
+
server = servers.find { |s| s.tools.any? { |t| t.name == name.delete_prefix("#{s.name}/") } } || servers.first
|
|
902
|
+
if server
|
|
903
|
+
rest = name.delete_prefix("#{server.name}/")
|
|
904
|
+
[tool_name(server.name, rest), "#{server.name}: #{rest}"]
|
|
905
|
+
elsif name.start_with?("mcp_")
|
|
906
|
+
[name, nil]
|
|
574
907
|
end
|
|
575
908
|
end
|
|
576
909
|
|
|
910
|
+
# #lookup's tool, without waiting for a start: nil on a miss.
|
|
911
|
+
def lookup_quietly(name)
|
|
912
|
+
tool = lookup(name, waited: true)
|
|
913
|
+
tool.is_a?(McpTool) ? tool : nil
|
|
914
|
+
end
|
|
915
|
+
|
|
577
916
|
# mcp_<server>_<tool> in the tool name rule: a-z, 0-9 and _, at most 48.
|
|
578
917
|
def tool_name(server, tool)
|
|
579
918
|
"mcp_#{server}_#{tool}".downcase.gsub(/[^a-z0-9_]+/, "_").squeeze("_")[0, NAME_CHARS].sub(/_+\z/, "")
|
|
@@ -592,7 +931,8 @@ class Plugin
|
|
|
592
931
|
end
|
|
593
932
|
|
|
594
933
|
# A tools/call of +tool+ (McpTool), as the model's tool result. A cached
|
|
595
|
-
# server starts here, on its first call.
|
|
934
|
+
# server starts here, on its first call. The server's isError and a
|
|
935
|
+
# JSON-RPC error answer the tool's inputSchema too (#schema_note).
|
|
596
936
|
def call(server, tool, args, ctx)
|
|
597
937
|
return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
|
|
598
938
|
|
|
@@ -608,29 +948,26 @@ class Plugin
|
|
|
608
948
|
result = client.request("tools/call", { name: tool.name, arguments: args }, timeout: server.timeout,
|
|
609
949
|
cancelled: -> { ctx.cancelled? })
|
|
610
950
|
text, images = content(result, server, tool.name)
|
|
611
|
-
return "Error: #{text.empty? ? "the tool failed" : text}" if result["isError"]
|
|
951
|
+
return "Error: #{text.empty? ? "the tool failed" : text}#{schema_note(tool)}" if result["isError"]
|
|
612
952
|
|
|
613
953
|
images.empty? ? text : Samagotchi::Plugin::ToolResult.new(text, images: images)
|
|
614
954
|
rescue Client::Dead => e
|
|
615
955
|
"Error: MCP server #{server.name} is not running (#{e.message})"
|
|
616
|
-
rescue Client::
|
|
956
|
+
rescue Client::Timeout, Client::Cancelled, Samagotchi::Plugin::Service::Stopped => e
|
|
617
957
|
"Error: #{e.message}"
|
|
958
|
+
rescue Client::Error => e
|
|
959
|
+
"Error: #{e.message}#{schema_note(tool)}"
|
|
618
960
|
end
|
|
619
961
|
|
|
620
|
-
# Start a cached server for a call
|
|
621
|
-
#
|
|
622
|
-
#
|
|
623
|
-
# next turn); a cancelled one leaves it cached for the next call.
|
|
962
|
+
# Start a cached server for a call (#spawn indexes the live list). A
|
|
963
|
+
# start that fails marks the server failed (one notice; its calls answer
|
|
964
|
+
# at once); a cancelled one leaves it cached for the next call.
|
|
624
965
|
# @return [Client, String] the client, or the call's error text
|
|
625
966
|
def lazy_start(server, ctx)
|
|
626
|
-
cached = server.listed
|
|
627
967
|
client = server.service.value
|
|
628
968
|
started = server.state == :cached
|
|
629
969
|
server.state = :running
|
|
630
|
-
if started
|
|
631
|
-
ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size, lazy: true)
|
|
632
|
-
publish(ctx) if server.listed != cached
|
|
633
|
-
end
|
|
970
|
+
ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size, lazy: true) if started
|
|
634
971
|
client
|
|
635
972
|
rescue Client::Cancelled => e
|
|
636
973
|
"Error: #{e.message}"
|
|
@@ -642,9 +979,7 @@ class Plugin
|
|
|
642
979
|
server.state = :failed
|
|
643
980
|
server.error = e.message
|
|
644
981
|
ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message, lazy: true)
|
|
645
|
-
ctx.notify("MCP server #{server.name} didn't start: #{e.message}; its
|
|
646
|
-
level: :warn)
|
|
647
|
-
publish(ctx)
|
|
982
|
+
ctx.notify("MCP server #{server.name} didn't start: #{e.message}; its calls fail until chi restarts", level: :warn)
|
|
648
983
|
"Error: MCP server #{server.name} didn't start: #{e.message}"
|
|
649
984
|
end
|
|
650
985
|
|
|
@@ -662,7 +997,6 @@ class Plugin
|
|
|
662
997
|
end
|
|
663
998
|
|
|
664
999
|
server.restarts += 1
|
|
665
|
-
cached = server.listed
|
|
666
1000
|
server.client&.close
|
|
667
1001
|
begin
|
|
668
1002
|
client = spawn(server, ctx)
|
|
@@ -673,7 +1007,6 @@ class Plugin
|
|
|
673
1007
|
end
|
|
674
1008
|
server.state = :running
|
|
675
1009
|
ctx.log.info("mcp_server_restarted", server: server.name, pid: client.pid, restarts: server.restarts)
|
|
676
|
-
publish(ctx) if server.listed != cached
|
|
677
1010
|
client
|
|
678
1011
|
end
|
|
679
1012
|
end
|
|
@@ -790,23 +1123,25 @@ class Plugin
|
|
|
790
1123
|
servers = @servers.map do |server|
|
|
791
1124
|
head = "**#{server.name}**: #{state_text(server)}"
|
|
792
1125
|
# Sorted: a server lists its tools in its own (often grouped) order.
|
|
793
|
-
tools =
|
|
794
|
-
tools.empty? ? head : "#{head}\n#{tools.
|
|
1126
|
+
tools = server.tools.sort_by(&:name).map { |t| ["- `#{t.server}/#{t.name}`", mark(t)].compact.join(" ") }
|
|
1127
|
+
tools.empty? ? head : "#{head}\n#{tools.join("\n")}"
|
|
795
1128
|
end
|
|
796
1129
|
total = @servers.sum { |server| OFFERED.include?(server.state) ? server.tokens : 0 }
|
|
797
|
-
[*servers, "
|
|
1130
|
+
[*servers, "Every request carries find_mcp_tools and mcp_call: ~#{thousands(@fixed_tokens.to_i)} tokens. " \
|
|
1131
|
+
"The tools above, ~#{thousands(total)} tokens, reach the model only in a search's answer " \
|
|
798
1132
|
"(estimated: their JSON as the chat API gets it, ÷ #{format("%g", Samagotchi::TokenUsage::CHARS_PER_TOKEN)})."]
|
|
799
1133
|
.join("\n\n")
|
|
800
1134
|
end
|
|
801
1135
|
|
|
802
1136
|
def state_text(server)
|
|
1137
|
+
count = offered(server).size
|
|
1138
|
+
tools = "#{count} tool#{"s" unless count == 1}"
|
|
803
1139
|
case server.state
|
|
804
|
-
when :cached then "cached (not started), #{
|
|
805
|
-
when :running
|
|
806
|
-
count = offered(server).size
|
|
807
|
-
"running (pid #{server.client.pid}), #{count} tool#{"s" unless count == 1}, #{tokens_text(server)}"
|
|
1140
|
+
when :cached then "cached (not started), #{tools}, #{tokens_text(server)}"
|
|
1141
|
+
when :running then "running (pid #{server.client.pid}), #{tools}, #{tokens_text(server)}"
|
|
808
1142
|
when :starting then "starting"
|
|
809
|
-
|
|
1143
|
+
when :failed then server.tools.empty? ? "failed: #{server.error}" : "failed (#{tools}): #{server.error}"
|
|
1144
|
+
else "stopped: #{server.error}"
|
|
810
1145
|
end
|
|
811
1146
|
rescue Samagotchi::Plugin::Service::Stopped
|
|
812
1147
|
"stopped"
|