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.
@@ -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; its tools are the model's as mcp_<server>_<tool>.
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 in every request.
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 the model has (what publish declares).
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 chi offers from a server (the tools: filter applied): +name+
251
- # is the server's, +chi_name+ the model's (mcp_<server>_<tool>), +schema+
252
- # its inputSchema (an empty object when it has none). +error+ is why
253
- # declaring it failed (a name clash, a bad schema), nil when the model
254
- # has it.
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), +tools+
259
- # the ones chi offers (McpTool each; #declare swaps in a whole new
260
- # list), +tokens+ their definitions' estimated size (#definition_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 left out so far (a name clash): said once, not at each
275
- # publish.
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
- declare(chi, server, ctx)
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
- clientInfo: { name: "chi", version: Samagotchi::VERSION } },
370
- timeout: @startup_timeout, cancelled: cancelled)
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
- changed = listed != server.listed
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
- # Start a server in an init task; its tools replace the cached ones (or
380
- # come for the first time) when they differ.
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
- publish(ctx) if had_tools
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 others skip.
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
- clientInfo: { name: "chi", version: Samagotchi::VERSION } },
414
- timeout: @startup_timeout, cancelled: cancelled)
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
- publish(ctx) if changed
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 replace the tools for the next turn.
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
- publish(ctx)
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, tools: server.listed }))
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
- # Declare the server's tools on +target+ (chi at load, or the set of
522
- # chi.replace_tools later), the tools: filter applied. Their estimated
523
- # tokens go to the log when they changed.
524
- def declare(target, server, ctx)
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 ArgumentError => e
540
- text = "MCP tool #{server.name}/#{tool.name} left out: #{e.message}"
541
- ctx.notify(text, level: :warn) unless @left_out.include?(text)
542
- @left_out << text
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
- # The server's tools the model has (declared without an error).
554
- def offered(server) = server.tools.reject(&:error)
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
- spec = Samagotchi::Plugin::Api.tool_spec(name, description, schema: schema) { nil }
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
- # The servers' tools changed after load (a live list that differs from
567
- # the cache, a server that didn't start): the whole set again, for the
568
- # next turn.
569
- def publish(ctx)
570
- @publish.synchronize do
571
- @chi.replace_tools do |set|
572
- @servers.each { |server| declare(set, server, ctx) if OFFERED.include?(server.state) }
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::Error, Samagotchi::Plugin::Service::Stopped => e
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. The live list replaces the cached
621
- # one for the next turn when it differs. A start that fails marks the
622
- # server failed (one notice; its calls answer at once, and its tools go
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 tools are left out from the next turn",
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 = offered(server).map(&:chi_name).sort
794
- tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
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, "Total: ~#{thousands(total)} tokens of tool definitions in every request " \
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), #{server.tools.size} tool#{"s" unless server.tools.size == 1}, #{tokens_text(server)}"
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
- else "#{server.state == :failed ? "failed" : "stopped"}: #{server.error}"
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"