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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/README.md +3 -3
  4. data/bin/chi +1 -1
  5. data/docs/broadcast.md +95 -13
  6. data/docs/configuration.md +9 -2
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +17 -2
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/engine.md +4 -2
  11. data/docs/plugins.md +107 -34
  12. data/lib/samagotchi/broadcast/scope_card.rb +15 -2
  13. data/lib/samagotchi/broadcast/triage.rb +284 -0
  14. data/lib/samagotchi/broadcast/triage_log.rb +170 -0
  15. data/lib/samagotchi/broadcast/triage_model.rb +56 -0
  16. data/lib/samagotchi/broadcast_command.rb +275 -69
  17. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
  18. data/lib/samagotchi/bundles/mcp/plugin.rb +439 -92
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  21. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  22. data/lib/samagotchi/cli/command.rb +27 -3
  23. data/lib/samagotchi/config.rb +9 -0
  24. data/lib/samagotchi/context_command.rb +0 -17
  25. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  26. data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
  27. data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
  28. data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
  29. data/lib/samagotchi/desktop/macos.rb +27 -5
  30. data/lib/samagotchi/desktop_command.rb +1 -1
  31. data/lib/samagotchi/engine.rb +7 -8
  32. data/lib/samagotchi/guardrail_wiring.rb +7 -2
  33. data/lib/samagotchi/guardrails/approval.rb +5 -2
  34. data/lib/samagotchi/guardrails/approvals.rb +9 -5
  35. data/lib/samagotchi/guardrails/rules.rb +4 -2
  36. data/lib/samagotchi/guardrails/targets.rb +76 -11
  37. data/lib/samagotchi/idle_client.rb +48 -28
  38. data/lib/samagotchi/idle_recap.rb +6 -5
  39. data/lib/samagotchi/idle_target.rb +95 -0
  40. data/lib/samagotchi/kernel_loop.rb +6 -4
  41. data/lib/samagotchi/llm/openai_chat.rb +29 -3
  42. data/lib/samagotchi/log_line.rb +1 -1
  43. data/lib/samagotchi/note_command.rb +0 -21
  44. data/lib/samagotchi/plugin/api.rb +5 -3
  45. data/lib/samagotchi/recap_setup.rb +38 -52
  46. data/lib/samagotchi/send_command.rb +0 -22
  47. data/lib/samagotchi/tool_response.rb +5 -5
  48. data/lib/samagotchi/tool_runner.rb +18 -9
  49. data/lib/samagotchi/update_command.rb +1 -1
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/question_card.js +4 -3
  52. 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; 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,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), +tools+
252
- # the ones chi offers (the tools: filter applied, each with chi_name),
253
- # +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).
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 left out so far (a name clash): said once, not at each
268
- # publish.
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
- declare(chi, server, ctx)
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
- clientInfo: { name: "chi", version: Samagotchi::VERSION } },
363
- 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)
364
391
  client.notify("notifications/initialized")
365
392
  listed = list_tools(client, cancelled)
366
- changed = listed != server.listed
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
- # Start a server in an init task; its tools replace the cached ones (or
373
- # 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).
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
- publish(ctx) if had_tools
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 others skip.
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
- clientInfo: { name: "chi", version: Samagotchi::VERSION } },
407
- 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)
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
- publish(ctx) if changed
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 replace the tools for the next turn.
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
- publish(ctx)
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, tools: server.listed }))
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
- # Declare the server's tools on +target+ (chi at load, or the set of
515
- # chi.replace_tools later), the tools: filter applied. Their estimated
516
- # tokens go to the log when they changed.
517
- 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)
518
560
  wanted = server.config["tools"] && Array(server.config["tools"]).map(&:to_s)
519
- tools = server.listed.map(&:dup)
520
- tools = tools.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
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.each do |tool|
524
- name = tool_name(server.name, tool["name"])
525
- schema = tool["inputSchema"] || { "type" => "object", "properties" => {} }
526
- target.tool(name, description(tool), schema: schema,
527
- label: "#{server.name}: #{tool["name"]}", preview: ->(args) { preview(args) }) do |args, call_ctx|
528
- call(server, tool["name"], args, call_ctx)
529
- end
530
- tool["chi_name"] = name
531
- tokens += definition_tokens(name, description(tool), schema)
532
- rescue ArgumentError => e
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: tools.count { |tool| tool["chi_name"] },
541
- tokens: tokens)
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
- 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)
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
- # The servers' tools changed after load (a live list that differs from
555
- # the cache, a server that didn't start): the whole set again, for the
556
- # next turn.
557
- def publish(ctx)
558
- @publish.synchronize do
559
- @chi.replace_tools do |set|
560
- @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
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 server starts
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
- cancelled: -> { ctx.cancelled? })
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::Error, Samagotchi::Plugin::Service::Stopped => e
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. The live list replaces the cached
609
- # one for the next turn when it differs. A start that fails marks the
610
- # server failed (one notice; its calls answer at once, and its tools go
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 tools are left out from the next turn",
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 { |tool| tool["chi_name"] }.compact.sort
782
- 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")}"
783
1128
  end
784
1129
  total = @servers.sum { |server| OFFERED.include?(server.state) ? server.tokens : 0 }
785
- [*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 " \
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), #{server.tools.size} tool#{"s" unless server.tools.size == 1}, #{tokens_text(server)}"
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
- 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}"
798
1145
  end
799
1146
  rescue Samagotchi::Plugin::Service::Stopped
800
1147
  "stopped"