samagotchi 0.36.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 +63 -1
- data/README.md +3 -3
- data/bin/chi +1 -1
- data/docs/broadcast.md +95 -13
- data/docs/configuration.md +9 -2
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +17 -2
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/plugins.md +107 -34
- data/lib/samagotchi/broadcast/scope_card.rb +15 -2
- data/lib/samagotchi/broadcast/triage.rb +284 -0
- data/lib/samagotchi/broadcast/triage_log.rb +170 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +275 -69
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/plugin.rb +439 -92
- 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/cli/command.rb +27 -3
- data/lib/samagotchi/config.rb +9 -0
- data/lib/samagotchi/context_command.rb +0 -17
- 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 +7 -8
- data/lib/samagotchi/guardrail_wiring.rb +7 -2
- data/lib/samagotchi/guardrails/approval.rb +5 -2
- data/lib/samagotchi/guardrails/approvals.rb +9 -5
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/targets.rb +76 -11
- data/lib/samagotchi/idle_client.rb +48 -28
- data/lib/samagotchi/idle_recap.rb +6 -5
- data/lib/samagotchi/idle_target.rb +95 -0
- data/lib/samagotchi/kernel_loop.rb +6 -4
- data/lib/samagotchi/llm/openai_chat.rb +29 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/note_command.rb +0 -21
- data/lib/samagotchi/plugin/api.rb +5 -3
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- 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
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +6 -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,26 +263,34 @@ class Plugin
|
|
|
247
263
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
248
264
|
end
|
|
249
265
|
|
|
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.
|
|
271
|
+
McpTool = Data.define(:server, :name, :chi_name, :description, :schema, :error)
|
|
272
|
+
|
|
250
273
|
# One configured server: its service, state and tools. +listed+ is its
|
|
251
|
-
# tools/list as the server answers it (what the cache keeps), +
|
|
252
|
-
#
|
|
253
|
-
#
|
|
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).
|
|
254
278
|
# +client+ is the running process's (a restart replaces it; the
|
|
255
279
|
# service's stop closes the current one), +restarts+ how many it had.
|
|
256
280
|
Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
|
|
257
|
-
:digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, keyword_init: true)
|
|
281
|
+
:digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, :info, keyword_init: true)
|
|
258
282
|
|
|
259
283
|
def initialize(settings = {})
|
|
260
284
|
@settings = settings
|
|
261
285
|
@timeout = positive(settings["timeout"]) || CALL_TIMEOUT
|
|
262
286
|
@startup_timeout = positive(settings["startup_timeout"]) || STARTUP_TIMEOUT
|
|
263
287
|
@servers = []
|
|
264
|
-
@publish = Mutex.new
|
|
265
288
|
@relist = Mutex.new
|
|
266
289
|
@restart = Mutex.new
|
|
267
|
-
# The tools
|
|
268
|
-
#
|
|
290
|
+
# The tools that can't be called (a bad schema): said once, not at each
|
|
291
|
+
# index.
|
|
269
292
|
@left_out = []
|
|
293
|
+
@left_out_lock = Mutex.new
|
|
270
294
|
end
|
|
271
295
|
|
|
272
296
|
# Each server's tools come from its cache (tools-<server>.json in the
|
|
@@ -276,7 +300,8 @@ class Plugin
|
|
|
276
300
|
# in the background. Without a cache (the first run, a changed config)
|
|
277
301
|
# the server starts in an init task (chi.init) that every UI shows; a
|
|
278
302
|
# turn sent meanwhile waits for its tools. start: eager starts it with
|
|
279
|
-
# 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.
|
|
280
305
|
def register(chi)
|
|
281
306
|
@chi = chi
|
|
282
307
|
ctx = chi.ctx
|
|
@@ -297,7 +322,7 @@ class Plugin
|
|
|
297
322
|
# the error in its body.
|
|
298
323
|
failed = "#{server.name} didn't start"
|
|
299
324
|
if load_cache(server, ctx)
|
|
300
|
-
|
|
325
|
+
index(server, ctx)
|
|
301
326
|
if server.config["start"].to_s == "eager"
|
|
302
327
|
chi.init("Starting MCP server #{server.name}", timeout: wait, failed: failed) { boot(server, ctx) }
|
|
303
328
|
elsif server.cached_at.nil? || Time.now - server.cached_at > CACHE_TTL
|
|
@@ -314,6 +339,7 @@ class Plugin
|
|
|
314
339
|
end
|
|
315
340
|
end
|
|
316
341
|
end
|
|
342
|
+
declare_tools(chi) unless @servers.empty?
|
|
317
343
|
chi.command "/mcp", "list the MCP servers, their state and their tools", anytime: true do |_args, command_ctx|
|
|
318
344
|
command_ctx.card(title: "MCP servers", body: listing, id: "mcp-servers")
|
|
319
345
|
nil
|
|
@@ -346,7 +372,8 @@ class Plugin
|
|
|
346
372
|
|
|
347
373
|
# Spawn the server's process (it is server.client from then on),
|
|
348
374
|
# initialize, list the tools (cancelled with the turn that waits for
|
|
349
|
-
# it), then cache the list
|
|
375
|
+
# it), then cache the list and its info when either changed, and index
|
|
376
|
+
# the tools.
|
|
350
377
|
# @return [Client]
|
|
351
378
|
def spawn(server, ctx)
|
|
352
379
|
cancelled = -> { ctx.cancelled? }
|
|
@@ -358,42 +385,53 @@ class Plugin
|
|
|
358
385
|
tools_changed(server, ctx) if method == "notifications/tools/list_changed"
|
|
359
386
|
}
|
|
360
387
|
)
|
|
361
|
-
client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
362
|
-
|
|
363
|
-
|
|
388
|
+
initialized = client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
389
|
+
clientInfo: { name: "chi", version: Samagotchi::VERSION } },
|
|
390
|
+
timeout: @startup_timeout, cancelled: cancelled)
|
|
364
391
|
client.notify("notifications/initialized")
|
|
365
392
|
listed = list_tools(client, cancelled)
|
|
366
|
-
|
|
393
|
+
info = info_of(initialized)
|
|
394
|
+
changed = listed != server.listed || info != server.info
|
|
367
395
|
server.listed = listed
|
|
396
|
+
server.info = info
|
|
368
397
|
save_cache(server, ctx) if changed
|
|
398
|
+
index(server, ctx)
|
|
369
399
|
client
|
|
370
400
|
end
|
|
371
401
|
|
|
372
|
-
#
|
|
373
|
-
#
|
|
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).
|
|
374
414
|
# @return [String] the task's summary
|
|
375
|
-
# @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)
|
|
376
417
|
def boot(server, ctx)
|
|
377
|
-
before = server.listed
|
|
378
418
|
client = server.service.value
|
|
379
419
|
server.state = :running
|
|
380
420
|
ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size)
|
|
381
|
-
publish(ctx) if server.listed != before
|
|
382
421
|
count = server.listed.size
|
|
383
422
|
"#{server.name} ready, #{count} tool#{"s" unless count == 1}"
|
|
384
423
|
rescue StandardError => e
|
|
385
|
-
had_tools = server.state == :cached
|
|
386
424
|
server.state = :failed
|
|
387
425
|
server.error = e.message
|
|
388
426
|
ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message)
|
|
389
|
-
|
|
390
|
-
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"
|
|
391
428
|
end
|
|
392
429
|
|
|
393
430
|
# The quiet daily refresh of a cached server's list: a server of its own
|
|
394
431
|
# (not the session's: that one still starts on the first call), listed
|
|
395
432
|
# and stopped. The cache is rewritten (its clock too) and a changed list
|
|
396
|
-
# 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.
|
|
397
435
|
def refresh(server, ctx)
|
|
398
436
|
File.open("#{cache_path(server, ctx)}.lock", File::CREAT | File::RDWR) do |lock|
|
|
399
437
|
return nil unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
@@ -402,9 +440,9 @@ class Plugin
|
|
|
402
440
|
client = Client.new(server.command, env: server.env, cwd: server.cwd,
|
|
403
441
|
log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) })
|
|
404
442
|
begin
|
|
405
|
-
client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
406
|
-
|
|
407
|
-
|
|
443
|
+
initialized = client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
444
|
+
clientInfo: { name: "chi", version: Samagotchi::VERSION } },
|
|
445
|
+
timeout: @startup_timeout, cancelled: cancelled)
|
|
408
446
|
client.notify("notifications/initialized")
|
|
409
447
|
listed = list_tools(client, cancelled)
|
|
410
448
|
ensure
|
|
@@ -415,16 +453,17 @@ class Plugin
|
|
|
415
453
|
|
|
416
454
|
changed = listed != server.listed
|
|
417
455
|
server.listed = listed
|
|
456
|
+
server.info = info_of(initialized)
|
|
418
457
|
save_cache(server, ctx)
|
|
419
458
|
ctx.log.info("mcp_tools_refreshed", server: server.name, tools: listed.size, changed: changed)
|
|
420
|
-
|
|
459
|
+
index(server, ctx) if changed
|
|
421
460
|
end
|
|
422
461
|
nil
|
|
423
462
|
end
|
|
424
463
|
|
|
425
464
|
# notifications/tools/list_changed from a running server: list its tools
|
|
426
465
|
# again (on a thread of its own: the answer comes on the reader thread
|
|
427
|
-
# that told us), save them and
|
|
466
|
+
# that told us), save them and index them.
|
|
428
467
|
# Notices that come together list once more after the one running.
|
|
429
468
|
def tools_changed(server, ctx)
|
|
430
469
|
@relist.synchronize do
|
|
@@ -457,7 +496,7 @@ class Plugin
|
|
|
457
496
|
server.listed = listed
|
|
458
497
|
save_cache(server, ctx)
|
|
459
498
|
ctx.log.info("mcp_tools_changed", server: server.name, tools: listed.size)
|
|
460
|
-
|
|
499
|
+
index(server, ctx)
|
|
461
500
|
rescue StandardError => e
|
|
462
501
|
ctx.log.warn("mcp_tools_not_relisted", server: server.name, error: e.class.name, msg: e.message)
|
|
463
502
|
end
|
|
@@ -490,6 +529,7 @@ class Plugin
|
|
|
490
529
|
return false unless data.is_a?(Hash) && data["digest"] == server.digest && data["tools"].is_a?(Array)
|
|
491
530
|
|
|
492
531
|
server.listed = data["tools"].select { |tool| tool.is_a?(Hash) && tool["name"] }
|
|
532
|
+
server.info = data["info"].is_a?(String) ? data["info"] : nil
|
|
493
533
|
server.cached_at = begin
|
|
494
534
|
Time.iso8601(data["saved_at"].to_s)
|
|
495
535
|
rescue ArgumentError
|
|
@@ -504,62 +544,373 @@ class Plugin
|
|
|
504
544
|
# Written aside and renamed: workers share the dir.
|
|
505
545
|
def save_cache(server, ctx)
|
|
506
546
|
Samagotchi::AtomicFile.write(cache_path(server, ctx),
|
|
507
|
-
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))
|
|
508
549
|
rescue SystemCallError => e
|
|
509
550
|
ctx.log.warn("mcp_cache_not_written", server: server.name, msg: e.message)
|
|
510
551
|
end
|
|
511
552
|
|
|
512
553
|
# ── Tools ─────────────────────────────────────────────────────────────
|
|
513
554
|
|
|
514
|
-
#
|
|
515
|
-
#
|
|
516
|
-
#
|
|
517
|
-
|
|
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)
|
|
518
560
|
wanted = server.config["tools"] && Array(server.config["tools"]).map(&:to_s)
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
server.tools = tools
|
|
561
|
+
listed = server.listed || []
|
|
562
|
+
listed = listed.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
|
|
522
563
|
tokens = 0
|
|
523
|
-
tools.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
text = "MCP tool #{server.name}/#{tool["name"]} left out: #{e.message}"
|
|
534
|
-
ctx.notify(text, level: :warn) unless @left_out.include?(text)
|
|
535
|
-
@left_out << text
|
|
564
|
+
tools = listed.map do |listed_tool|
|
|
565
|
+
tool = McpTool.new(server: server.name, name: listed_tool["name"].to_s,
|
|
566
|
+
chi_name: tool_name(server.name, listed_tool["name"]), description: description(listed_tool),
|
|
567
|
+
schema: listed_tool["inputSchema"] || { "type" => "object", "properties" => {} }, error: nil)
|
|
568
|
+
tokens += definition_tokens(tool.chi_name, tool.description, tool.schema)
|
|
569
|
+
tool
|
|
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)
|
|
536
574
|
end
|
|
575
|
+
server.tools = tools.freeze
|
|
576
|
+
say_clashes(ctx)
|
|
537
577
|
return if tokens == server.tokens
|
|
538
578
|
|
|
539
579
|
server.tokens = tokens
|
|
540
|
-
ctx.log.info("mcp_tools_estimated", server: server.name, tools:
|
|
541
|
-
|
|
580
|
+
ctx.log.info("mcp_tools_estimated", server: server.name, tools: offered(server).size, tokens: tokens)
|
|
581
|
+
end
|
|
582
|
+
|
|
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?
|
|
542
628
|
end
|
|
543
629
|
|
|
544
630
|
# A tool's definition as the chat path sends it (LLM::ChatLoop#tool_definitions:
|
|
545
631
|
# the chat schema, wrapped as a function), in estimated tokens
|
|
546
632
|
# (TokenUsage::CHARS_PER_TOKEN). The native prompts (Gemma, Qwen) render a
|
|
547
633
|
# flatter schema, so it is an upper bound there.
|
|
634
|
+
# @raise [ArgumentError] a bad schema
|
|
548
635
|
def definition_tokens(name, description, schema)
|
|
549
|
-
|
|
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)
|
|
550
642
|
function = Samagotchi::ToolDeclarations.chat_schemas([spec[:schema]]).first.slice(:name, :description, :parameters)
|
|
551
643
|
Samagotchi::TokenUsage.estimate(JSON.generate({ type: "function", function: function }))
|
|
552
644
|
end
|
|
553
645
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
561
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)
|
|
562
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]
|
|
907
|
+
end
|
|
908
|
+
end
|
|
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
|
|
563
914
|
end
|
|
564
915
|
|
|
565
916
|
# mcp_<server>_<tool> in the tool name rule: a-z, 0-9 and _, at most 48.
|
|
@@ -579,8 +930,9 @@ class Plugin
|
|
|
579
930
|
line.length > PREVIEW_CHARS ? "#{line[0, PREVIEW_CHARS - 1]}…" : line
|
|
580
931
|
end
|
|
581
932
|
|
|
582
|
-
# A tools/call, as the model's tool result. A cached
|
|
583
|
-
# here, on its first call.
|
|
933
|
+
# A tools/call of +tool+ (McpTool), as the model's tool result. A cached
|
|
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).
|
|
584
936
|
def call(server, tool, args, ctx)
|
|
585
937
|
return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
|
|
586
938
|
|
|
@@ -593,32 +945,29 @@ class Plugin
|
|
|
593
945
|
end
|
|
594
946
|
return client if client.is_a?(String)
|
|
595
947
|
|
|
596
|
-
result = client.request("tools/call", { name: tool, arguments: args }, timeout: server.timeout,
|
|
597
|
-
|
|
598
|
-
text, images = content(result, server, tool)
|
|
599
|
-
return "Error: #{text.empty? ? "the tool failed" : text}" if result["isError"]
|
|
948
|
+
result = client.request("tools/call", { name: tool.name, arguments: args }, timeout: server.timeout,
|
|
949
|
+
cancelled: -> { ctx.cancelled? })
|
|
950
|
+
text, images = content(result, server, tool.name)
|
|
951
|
+
return "Error: #{text.empty? ? "the tool failed" : text}#{schema_note(tool)}" if result["isError"]
|
|
600
952
|
|
|
601
953
|
images.empty? ? text : Samagotchi::Plugin::ToolResult.new(text, images: images)
|
|
602
954
|
rescue Client::Dead => e
|
|
603
955
|
"Error: MCP server #{server.name} is not running (#{e.message})"
|
|
604
|
-
rescue Client::
|
|
956
|
+
rescue Client::Timeout, Client::Cancelled, Samagotchi::Plugin::Service::Stopped => e
|
|
605
957
|
"Error: #{e.message}"
|
|
958
|
+
rescue Client::Error => e
|
|
959
|
+
"Error: #{e.message}#{schema_note(tool)}"
|
|
606
960
|
end
|
|
607
961
|
|
|
608
|
-
# Start a cached server for a call
|
|
609
|
-
#
|
|
610
|
-
#
|
|
611
|
-
# 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.
|
|
612
965
|
# @return [Client, String] the client, or the call's error text
|
|
613
966
|
def lazy_start(server, ctx)
|
|
614
|
-
cached = server.listed
|
|
615
967
|
client = server.service.value
|
|
616
968
|
started = server.state == :cached
|
|
617
969
|
server.state = :running
|
|
618
|
-
if started
|
|
619
|
-
ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size, lazy: true)
|
|
620
|
-
publish(ctx) if server.listed != cached
|
|
621
|
-
end
|
|
970
|
+
ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size, lazy: true) if started
|
|
622
971
|
client
|
|
623
972
|
rescue Client::Cancelled => e
|
|
624
973
|
"Error: #{e.message}"
|
|
@@ -630,9 +979,7 @@ class Plugin
|
|
|
630
979
|
server.state = :failed
|
|
631
980
|
server.error = e.message
|
|
632
981
|
ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message, lazy: true)
|
|
633
|
-
ctx.notify("MCP server #{server.name} didn't start: #{e.message}; its
|
|
634
|
-
level: :warn)
|
|
635
|
-
publish(ctx)
|
|
982
|
+
ctx.notify("MCP server #{server.name} didn't start: #{e.message}; its calls fail until chi restarts", level: :warn)
|
|
636
983
|
"Error: MCP server #{server.name} didn't start: #{e.message}"
|
|
637
984
|
end
|
|
638
985
|
|
|
@@ -650,7 +997,6 @@ class Plugin
|
|
|
650
997
|
end
|
|
651
998
|
|
|
652
999
|
server.restarts += 1
|
|
653
|
-
cached = server.listed
|
|
654
1000
|
server.client&.close
|
|
655
1001
|
begin
|
|
656
1002
|
client = spawn(server, ctx)
|
|
@@ -661,7 +1007,6 @@ class Plugin
|
|
|
661
1007
|
end
|
|
662
1008
|
server.state = :running
|
|
663
1009
|
ctx.log.info("mcp_server_restarted", server: server.name, pid: client.pid, restarts: server.restarts)
|
|
664
|
-
publish(ctx) if server.listed != cached
|
|
665
1010
|
client
|
|
666
1011
|
end
|
|
667
1012
|
end
|
|
@@ -778,23 +1123,25 @@ class Plugin
|
|
|
778
1123
|
servers = @servers.map do |server|
|
|
779
1124
|
head = "**#{server.name}**: #{state_text(server)}"
|
|
780
1125
|
# Sorted: a server lists its tools in its own (often grouped) order.
|
|
781
|
-
tools = server.tools.map { |
|
|
782
|
-
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")}"
|
|
783
1128
|
end
|
|
784
1129
|
total = @servers.sum { |server| OFFERED.include?(server.state) ? server.tokens : 0 }
|
|
785
|
-
[*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 " \
|
|
786
1132
|
"(estimated: their JSON as the chat API gets it, ÷ #{format("%g", Samagotchi::TokenUsage::CHARS_PER_TOKEN)})."]
|
|
787
1133
|
.join("\n\n")
|
|
788
1134
|
end
|
|
789
1135
|
|
|
790
1136
|
def state_text(server)
|
|
1137
|
+
count = offered(server).size
|
|
1138
|
+
tools = "#{count} tool#{"s" unless count == 1}"
|
|
791
1139
|
case server.state
|
|
792
|
-
when :cached then "cached (not started), #{
|
|
793
|
-
when :running
|
|
794
|
-
count = server.tools.count { |tool| tool["chi_name"] }
|
|
795
|
-
"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)}"
|
|
796
1142
|
when :starting then "starting"
|
|
797
|
-
|
|
1143
|
+
when :failed then server.tools.empty? ? "failed: #{server.error}" : "failed (#{tools}): #{server.error}"
|
|
1144
|
+
else "stopped: #{server.error}"
|
|
798
1145
|
end
|
|
799
1146
|
rescue Samagotchi::Plugin::Service::Stopped
|
|
800
1147
|
"stopped"
|