samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
# The mcp bundle (docs/plugins.md, The mcp bundle): tools from MCP servers.
|
|
2
|
+
# Each server in config.yml runs as a child process (stdio only in v1) for
|
|
3
|
+
# the session's life; its tools are the model's as mcp_<server>_<tool>.
|
|
4
|
+
#
|
|
5
|
+
# bundles:
|
|
6
|
+
# mcp:
|
|
7
|
+
# timeout: 60 # seconds per tool call (default 60)
|
|
8
|
+
# startup_timeout: 10 # seconds for initialize and tools/list (default 10)
|
|
9
|
+
# servers:
|
|
10
|
+
# everything:
|
|
11
|
+
# command: [npx, -y, "@modelcontextprotocol/server-everything"]
|
|
12
|
+
# env: {DEBUG: "0"} # added to chi's environment
|
|
13
|
+
# cwd: ~/scratch # default: where chi runs
|
|
14
|
+
# tools: [echo, add] # optional: only these (globs work)
|
|
15
|
+
# timeout: 120 # optional: this server's per-call timeout
|
|
16
|
+
# attach_image_paths: true # default: a result that is only the path of an
|
|
17
|
+
# # image in the temp dir or cwd attaches it
|
|
18
|
+
#
|
|
19
|
+
# Image blocks in a result go to the model as images (text: "[image 1:
|
|
20
|
+
# image/png, attached]"); so does a text block that is only the absolute
|
|
21
|
+
# path of an image file under the system temp dir or the server's cwd
|
|
22
|
+
# (chrome-devtools-mcp --slim answers a screenshot that way).
|
|
23
|
+
# A server that doesn't start, answer or list its tools is skipped with a
|
|
24
|
+
# notice; the rest of chi works. /mcp lists the servers and their tools.
|
|
25
|
+
require "digest"
|
|
26
|
+
require "json"
|
|
27
|
+
require "open3"
|
|
28
|
+
require "time"
|
|
29
|
+
require "shellwords"
|
|
30
|
+
require "tmpdir"
|
|
31
|
+
|
|
32
|
+
class Plugin
|
|
33
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
34
|
+
CALL_TIMEOUT = 60
|
|
35
|
+
STARTUP_TIMEOUT = 10
|
|
36
|
+
DESCRIPTION_CHARS = 1024
|
|
37
|
+
PREVIEW_CHARS = 60
|
|
38
|
+
NAME_CHARS = 48
|
|
39
|
+
# A cached tool list older than this is refreshed in the background.
|
|
40
|
+
CACHE_TTL = 24 * 60 * 60
|
|
41
|
+
IMAGE_EXT = { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
|
|
42
|
+
|
|
43
|
+
# A JSON-RPC client for one MCP server over stdio: newline-delimited JSON
|
|
44
|
+
# on the process's stdin and stdout; stderr goes to the log.
|
|
45
|
+
class Client
|
|
46
|
+
# The request failed: an error answer, a timeout, or the server is gone.
|
|
47
|
+
class Error < StandardError; end
|
|
48
|
+
class Timeout < Error; end
|
|
49
|
+
class Cancelled < Error; end
|
|
50
|
+
# The server's process ended (or never started).
|
|
51
|
+
class Dead < Error; end
|
|
52
|
+
|
|
53
|
+
POLL_SECONDS = 0.1
|
|
54
|
+
|
|
55
|
+
attr_reader :pid
|
|
56
|
+
|
|
57
|
+
# @param command [Array<String>]
|
|
58
|
+
# @param env [Hash] added to the environment
|
|
59
|
+
# @param cwd [String]
|
|
60
|
+
# @param log [#call] (event, fields) for stderr lines and protocol noise
|
|
61
|
+
# @param on_exit [#call, nil] called once, with the reason, when the
|
|
62
|
+
# process ends while the client is open
|
|
63
|
+
def initialize(command, env: {}, cwd: Dir.pwd, log: ->(*) {}, on_exit: nil)
|
|
64
|
+
@log = log
|
|
65
|
+
@on_exit = on_exit
|
|
66
|
+
@mutex = Mutex.new
|
|
67
|
+
@write_mutex = Mutex.new
|
|
68
|
+
@pending = {}
|
|
69
|
+
@next_id = 0
|
|
70
|
+
@dead = nil
|
|
71
|
+
@closing = false
|
|
72
|
+
@stdin, @stdout, @stderr, @wait = Open3.popen3(env.to_h { |k, v| [k.to_s, v.to_s] }, *command,
|
|
73
|
+
chdir: cwd, pgroup: true)
|
|
74
|
+
@pid = @wait.pid
|
|
75
|
+
@reader = Thread.new { read_loop }
|
|
76
|
+
@err_reader = Thread.new { stderr_loop }
|
|
77
|
+
rescue SystemCallError => e
|
|
78
|
+
raise Dead, "can't start #{command.first}: #{e.message.sub(/ - .*\z/m, "")}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [String, nil] why the server is gone, or nil while it runs
|
|
82
|
+
def dead = @mutex.synchronize { @dead }
|
|
83
|
+
|
|
84
|
+
# Send a request and wait for its answer.
|
|
85
|
+
# @param cancelled [#call, nil] polled while waiting; true sends
|
|
86
|
+
# notifications/cancelled and raises Cancelled
|
|
87
|
+
# @return [Hash] the result
|
|
88
|
+
def request(method, params = nil, timeout:, cancelled: nil)
|
|
89
|
+
queue = Queue.new
|
|
90
|
+
id = @mutex.synchronize do
|
|
91
|
+
raise Dead, @dead if @dead
|
|
92
|
+
|
|
93
|
+
@next_id += 1
|
|
94
|
+
@pending[@next_id] = queue
|
|
95
|
+
@next_id
|
|
96
|
+
end
|
|
97
|
+
write({ jsonrpc: "2.0", id: id, method: method, params: params }.compact)
|
|
98
|
+
deadline = monotonic + timeout
|
|
99
|
+
loop do
|
|
100
|
+
left = deadline - monotonic
|
|
101
|
+
if left <= 0
|
|
102
|
+
cancel(id, "timed out")
|
|
103
|
+
raise Timeout, "#{method} timed out after #{format("%g", timeout)}s"
|
|
104
|
+
end
|
|
105
|
+
if cancelled&.call
|
|
106
|
+
cancel(id, "cancelled by the user")
|
|
107
|
+
raise Cancelled, "#{method} was cancelled"
|
|
108
|
+
end
|
|
109
|
+
message = queue.pop(timeout: [left, POLL_SECONDS].min)
|
|
110
|
+
next unless message
|
|
111
|
+
raise Dead, message[:dead] if message[:dead]
|
|
112
|
+
if (error = message["error"])
|
|
113
|
+
raise Error, "#{error["message"] || "error"} (#{error["code"]})"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return message["result"] || {}
|
|
117
|
+
end
|
|
118
|
+
ensure
|
|
119
|
+
@mutex.synchronize { @pending.delete(id) } if id
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def notify(method, params = nil)
|
|
123
|
+
write({ jsonrpc: "2.0", method: method, params: params }.compact)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# End the process: stdin closed first (most servers leave then), then
|
|
127
|
+
# TERM and KILL to its process group.
|
|
128
|
+
def close
|
|
129
|
+
@mutex.synchronize { @closing = true }
|
|
130
|
+
[@stdin].each { |io| io.close unless io.closed? }
|
|
131
|
+
unless @wait.join(1)
|
|
132
|
+
signal("TERM")
|
|
133
|
+
signal("KILL") unless @wait.join(1)
|
|
134
|
+
@wait.join(1)
|
|
135
|
+
end
|
|
136
|
+
[@stdout, @stderr].each { |io| io.close unless io.closed? }
|
|
137
|
+
[@reader, @err_reader].each { |t| t.join(1) }
|
|
138
|
+
nil
|
|
139
|
+
rescue IOError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def write(message)
|
|
146
|
+
line = JSON.generate(message)
|
|
147
|
+
@write_mutex.synchronize do
|
|
148
|
+
@stdin.write(line, "\n")
|
|
149
|
+
@stdin.flush
|
|
150
|
+
end
|
|
151
|
+
rescue IOError, SystemCallError => e
|
|
152
|
+
raise Dead, dead || "the server's stdin is closed (#{e.class})"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def cancel(id, reason)
|
|
156
|
+
notify("notifications/cancelled", { requestId: id, reason: reason })
|
|
157
|
+
rescue Dead
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def read_loop
|
|
162
|
+
@stdout.each_line do |line|
|
|
163
|
+
next if line.strip.empty?
|
|
164
|
+
|
|
165
|
+
message = begin
|
|
166
|
+
JSON.parse(line)
|
|
167
|
+
rescue JSON::ParserError
|
|
168
|
+
@log.call("bad_line", line: line[0, 200])
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
dispatch(message) if message.is_a?(Hash)
|
|
172
|
+
end
|
|
173
|
+
rescue IOError
|
|
174
|
+
nil
|
|
175
|
+
ensure
|
|
176
|
+
ended
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def dispatch(message)
|
|
180
|
+
if message.key?("method")
|
|
181
|
+
# A request from the server (ping, roots/list, …): ping is answered,
|
|
182
|
+
# the rest aren't supported. A notification is logged.
|
|
183
|
+
return @log.call("server_notification", method: message["method"]) unless message.key?("id")
|
|
184
|
+
|
|
185
|
+
answer = if message["method"] == "ping"
|
|
186
|
+
{ jsonrpc: "2.0", id: message["id"], result: {} }
|
|
187
|
+
else
|
|
188
|
+
{ jsonrpc: "2.0", id: message["id"], error: { code: -32_601, message: "not supported by chi" } }
|
|
189
|
+
end
|
|
190
|
+
begin
|
|
191
|
+
write(answer)
|
|
192
|
+
rescue Dead
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
else
|
|
196
|
+
queue = @mutex.synchronize { @pending[message["id"]] }
|
|
197
|
+
queue&.push(message)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def stderr_loop
|
|
202
|
+
@stderr.each_line { |line| @log.call("stderr", line: line.chomp[0, 500]) }
|
|
203
|
+
rescue IOError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def ended
|
|
208
|
+
status = @wait.value
|
|
209
|
+
reason = "the server exited (#{status.exitstatus ? "status #{status.exitstatus}" : "signal #{status.termsig}"})"
|
|
210
|
+
waiting, closing = @mutex.synchronize do
|
|
211
|
+
@dead ||= reason
|
|
212
|
+
[@pending.values, @closing]
|
|
213
|
+
end
|
|
214
|
+
waiting.each { |queue| queue.push({ dead: reason }) }
|
|
215
|
+
@on_exit&.call(reason) unless closing
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def signal(name)
|
|
219
|
+
Process.kill(name, -@pid)
|
|
220
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# One configured server: its service, state and tools. +listed+ is its
|
|
228
|
+
# tools/list as the server answers it (what the cache keeps), +tools+
|
|
229
|
+
# the ones chi offers (the tools: filter applied, each with chi_name).
|
|
230
|
+
Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
|
|
231
|
+
:digest, :cached_at, keyword_init: true)
|
|
232
|
+
|
|
233
|
+
def initialize(settings = {})
|
|
234
|
+
@settings = settings
|
|
235
|
+
@timeout = positive(settings["timeout"]) || CALL_TIMEOUT
|
|
236
|
+
@startup_timeout = positive(settings["startup_timeout"]) || STARTUP_TIMEOUT
|
|
237
|
+
@servers = []
|
|
238
|
+
@publish = Mutex.new
|
|
239
|
+
# The tools left out so far (a name clash): said once, not at each
|
|
240
|
+
# publish.
|
|
241
|
+
@left_out = []
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Each server's tools come from its cache (tools-<server>.json in the
|
|
245
|
+
# bundle's data dir, keyed by a digest of its config) when there is one:
|
|
246
|
+
# the server then starts on the first call of one of its tools (start:
|
|
247
|
+
# lazy, the default), and a cache older than a day is refreshed quietly
|
|
248
|
+
# in the background. Without a cache (the first run, a changed config)
|
|
249
|
+
# the server starts in an init task (chi.init) that every UI shows; a
|
|
250
|
+
# turn sent meanwhile waits for its tools. start: eager starts it with
|
|
251
|
+
# every session.
|
|
252
|
+
def register(chi)
|
|
253
|
+
@chi = chi
|
|
254
|
+
ctx = chi.ctx
|
|
255
|
+
configs = @settings["servers"]
|
|
256
|
+
configs = {} unless configs.is_a?(Hash)
|
|
257
|
+
@servers = configs.map do |name, config|
|
|
258
|
+
server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [])
|
|
259
|
+
server.timeout = positive(server.config["timeout"]) || @timeout
|
|
260
|
+
resolve(server, ctx)
|
|
261
|
+
server.service = chi.service(name) { |svc| start(server, svc, ctx) }
|
|
262
|
+
server
|
|
263
|
+
end
|
|
264
|
+
# Two startup_timeouts: initialize, then tools/list.
|
|
265
|
+
wait = @startup_timeout * 2
|
|
266
|
+
@servers.each do |server|
|
|
267
|
+
# A failure's card title, short: the card shows "mcp" beside it and
|
|
268
|
+
# the error in its body.
|
|
269
|
+
failed = "#{server.name} didn't start"
|
|
270
|
+
if load_cache(server, ctx)
|
|
271
|
+
declare(chi, server, ctx)
|
|
272
|
+
if server.config["start"].to_s == "eager"
|
|
273
|
+
chi.init("Starting MCP server #{server.name}", timeout: wait, failed: failed) { boot(server, ctx) }
|
|
274
|
+
elsif server.cached_at.nil? || Time.now - server.cached_at > CACHE_TTL
|
|
275
|
+
chi.init("Refreshing MCP server #{server.name}'s tools", quiet: true, timeout: wait,
|
|
276
|
+
failed: "#{server.name}'s tools weren't refreshed") do
|
|
277
|
+
refresh(server, ctx)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
else
|
|
281
|
+
why = File.exist?(cache_path(server, ctx)) ? "config changed" : "first run"
|
|
282
|
+
chi.init("Starting MCP server #{server.name} (#{why}, saving its tools)", provides_tools: true, timeout: wait,
|
|
283
|
+
failed: failed) do
|
|
284
|
+
boot(server, ctx)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
chi.command "/mcp", "list the MCP servers, their state and their tools", anytime: true do |_args, command_ctx|
|
|
289
|
+
command_ctx.card(title: "MCP servers", body: listing, id: "mcp-servers")
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private
|
|
295
|
+
|
|
296
|
+
# The command, env and cwd the server runs with, and their digest (the
|
|
297
|
+
# cache's key: command, args, env names and values, and the cwd; the
|
|
298
|
+
# env itself is never stored).
|
|
299
|
+
def resolve(server, ctx)
|
|
300
|
+
command = server.config["command"]
|
|
301
|
+
command = Shellwords.split(command) if command.is_a?(String)
|
|
302
|
+
server.command = Array(command).map(&:to_s)
|
|
303
|
+
env = server.config["env"].is_a?(Hash) ? server.config["env"] : {}
|
|
304
|
+
server.env = env.to_h { |key, value| [key.to_s, value.to_s] }
|
|
305
|
+
server.cwd = server.config["cwd"] ? File.expand_path(server.config["cwd"].to_s) : ctx.cwd
|
|
306
|
+
server.digest = Digest::SHA256.hexdigest(JSON.generate([server.command, server.env.sort, server.cwd]))
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The service's start: spawn, initialize, list the tools (cancelled with
|
|
310
|
+
# the turn that waits for it), then cache the list.
|
|
311
|
+
def start(server, svc, ctx)
|
|
312
|
+
raise Client::Error, "no command (bundles: mcp: servers: #{server.name}: command: [...])" if server.command.empty?
|
|
313
|
+
|
|
314
|
+
cancelled = -> { ctx.cancelled? }
|
|
315
|
+
client = Client.new(server.command, env: server.env, cwd: server.cwd,
|
|
316
|
+
log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) },
|
|
317
|
+
on_exit: ->(reason) { exited(server, reason, ctx) })
|
|
318
|
+
svc.on_stop { client.close }
|
|
319
|
+
client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
320
|
+
clientInfo: { name: "chi", version: Samagotchi::VERSION } },
|
|
321
|
+
timeout: @startup_timeout, cancelled: cancelled)
|
|
322
|
+
client.notify("notifications/initialized")
|
|
323
|
+
listed = list_tools(client, cancelled)
|
|
324
|
+
changed = listed != server.listed
|
|
325
|
+
server.listed = listed
|
|
326
|
+
save_cache(server, ctx) if changed
|
|
327
|
+
client
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Start a server in an init task; its tools replace the cached ones (or
|
|
331
|
+
# come for the first time) when they differ.
|
|
332
|
+
# @return [String] the task's summary
|
|
333
|
+
# @raise [Client::Error] it didn't start (the task's warn card says why)
|
|
334
|
+
def boot(server, ctx)
|
|
335
|
+
before = server.listed
|
|
336
|
+
client = server.service.value
|
|
337
|
+
server.state = :running
|
|
338
|
+
ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size)
|
|
339
|
+
publish(ctx) if server.listed != before
|
|
340
|
+
count = server.listed.size
|
|
341
|
+
"#{server.name} ready, #{count} tool#{"s" unless count == 1}"
|
|
342
|
+
rescue StandardError => e
|
|
343
|
+
had_tools = server.state == :cached
|
|
344
|
+
server.state = :failed
|
|
345
|
+
server.error = e.message
|
|
346
|
+
ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message)
|
|
347
|
+
publish(ctx) if had_tools
|
|
348
|
+
raise Client::Error, "MCP server #{server.name} didn't start: #{e.message}; its tools are left out"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The quiet daily refresh of a cached server's list: a server of its own
|
|
352
|
+
# (not the session's: that one still starts on the first call), listed
|
|
353
|
+
# and stopped. The cache is rewritten (its clock too) and a changed list
|
|
354
|
+
# replaces the tools. One worker at a time (a lock file); the others skip.
|
|
355
|
+
def refresh(server, ctx)
|
|
356
|
+
File.open("#{cache_path(server, ctx)}.lock", File::CREAT | File::RDWR) do |lock|
|
|
357
|
+
return nil unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
358
|
+
|
|
359
|
+
cancelled = -> { ctx.cancelled? }
|
|
360
|
+
client = Client.new(server.command, env: server.env, cwd: server.cwd,
|
|
361
|
+
log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) })
|
|
362
|
+
begin
|
|
363
|
+
client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
364
|
+
clientInfo: { name: "chi", version: Samagotchi::VERSION } },
|
|
365
|
+
timeout: @startup_timeout, cancelled: cancelled)
|
|
366
|
+
client.notify("notifications/initialized")
|
|
367
|
+
listed = list_tools(client, cancelled)
|
|
368
|
+
ensure
|
|
369
|
+
client.close
|
|
370
|
+
end
|
|
371
|
+
# Started meanwhile: its own list is the fresh one.
|
|
372
|
+
return nil unless server.state == :cached
|
|
373
|
+
|
|
374
|
+
changed = listed != server.listed
|
|
375
|
+
server.listed = listed
|
|
376
|
+
save_cache(server, ctx)
|
|
377
|
+
ctx.log.info("mcp_tools_refreshed", server: server.name, tools: listed.size, changed: changed)
|
|
378
|
+
publish(ctx) if changed
|
|
379
|
+
end
|
|
380
|
+
nil
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# tools/list, every page.
|
|
384
|
+
def list_tools(client, cancelled)
|
|
385
|
+
tools = []
|
|
386
|
+
cursor = nil
|
|
387
|
+
20.times do
|
|
388
|
+
result = client.request("tools/list", cursor ? { cursor: cursor } : nil, timeout: @startup_timeout,
|
|
389
|
+
cancelled: cancelled)
|
|
390
|
+
tools.concat(Array(result["tools"]).select { |tool| tool.is_a?(Hash) && tool["name"] })
|
|
391
|
+
cursor = result["nextCursor"]
|
|
392
|
+
break unless cursor
|
|
393
|
+
end
|
|
394
|
+
tools
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# ── The tools/list cache ──────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
# Where the server's cache is: tools-<server>.json, the name sanitized.
|
|
400
|
+
def cache_path(server, ctx)
|
|
401
|
+
File.join(ctx.data_dir, "tools-#{server.name.gsub(/[^A-Za-z0-9_.-]+/, "_")}.json")
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Take the server's tools from its cache: true when it holds this
|
|
405
|
+
# config's list (the server is then :cached, not started).
|
|
406
|
+
def load_cache(server, ctx)
|
|
407
|
+
data = JSON.parse(File.read(cache_path(server, ctx)))
|
|
408
|
+
return false unless data.is_a?(Hash) && data["digest"] == server.digest && data["tools"].is_a?(Array)
|
|
409
|
+
|
|
410
|
+
server.listed = data["tools"].select { |tool| tool.is_a?(Hash) && tool["name"] }
|
|
411
|
+
server.cached_at = begin
|
|
412
|
+
Time.iso8601(data["saved_at"].to_s)
|
|
413
|
+
rescue ArgumentError
|
|
414
|
+
nil
|
|
415
|
+
end
|
|
416
|
+
server.state = :cached
|
|
417
|
+
true
|
|
418
|
+
rescue SystemCallError, JSON::ParserError
|
|
419
|
+
false
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Written aside and renamed: workers share the dir.
|
|
423
|
+
def save_cache(server, ctx)
|
|
424
|
+
path = cache_path(server, ctx)
|
|
425
|
+
tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
426
|
+
File.write(tmp, JSON.generate({ digest: server.digest, saved_at: Time.now.utc.iso8601, tools: server.listed }))
|
|
427
|
+
File.rename(tmp, path)
|
|
428
|
+
rescue SystemCallError => e
|
|
429
|
+
ctx.log.warn("mcp_cache_not_written", server: server.name, msg: e.message)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# ── Tools ─────────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
# Declare the server's tools on +target+ (chi at load, or the set of
|
|
435
|
+
# chi.replace_tools later), the tools: filter applied.
|
|
436
|
+
def declare(target, server, ctx)
|
|
437
|
+
wanted = server.config["tools"] && Array(server.config["tools"]).map(&:to_s)
|
|
438
|
+
tools = server.listed.map(&:dup)
|
|
439
|
+
tools = tools.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
|
|
440
|
+
server.tools = tools
|
|
441
|
+
tools.each do |tool|
|
|
442
|
+
name = tool_name(server.name, tool["name"])
|
|
443
|
+
target.tool(name, description(tool), schema: tool["inputSchema"] || { "type" => "object", "properties" => {} },
|
|
444
|
+
label: "#{server.name}: #{tool["name"]}", preview: ->(args) { preview(args) }) do |args, call_ctx|
|
|
445
|
+
call(server, tool["name"], args, call_ctx)
|
|
446
|
+
end
|
|
447
|
+
tool["chi_name"] = name
|
|
448
|
+
rescue ArgumentError => e
|
|
449
|
+
text = "MCP tool #{server.name}/#{tool["name"]} left out: #{e.message}"
|
|
450
|
+
ctx.notify(text, level: :warn) unless @left_out.include?(text)
|
|
451
|
+
@left_out << text
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# The servers' tools changed after load (a live list that differs from
|
|
456
|
+
# the cache, a server that didn't start): the whole set again, for the
|
|
457
|
+
# next turn.
|
|
458
|
+
def publish(ctx)
|
|
459
|
+
@publish.synchronize do
|
|
460
|
+
@chi.replace_tools do |set|
|
|
461
|
+
@servers.each { |server| declare(set, server, ctx) if %i[running cached].include?(server.state) }
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# mcp_<server>_<tool> in the tool name rule: a-z, 0-9 and _, at most 48.
|
|
467
|
+
def tool_name(server, tool)
|
|
468
|
+
"mcp_#{server}_#{tool}".downcase.gsub(/[^a-z0-9_]+/, "_").squeeze("_")[0, NAME_CHARS].sub(/_+\z/, "")
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def description(tool)
|
|
472
|
+
text = tool["description"].to_s.strip
|
|
473
|
+
text = tool["title"].to_s if text.empty?
|
|
474
|
+
text.length > DESCRIPTION_CHARS ? "#{text[0, DESCRIPTION_CHARS - 1]}…" : text
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def preview(args)
|
|
478
|
+
line = args.map { |key, value| "#{key}=#{value.is_a?(String) ? value : JSON.generate(value)}" }.join(" ")
|
|
479
|
+
line = line.gsub(/\s+/, " ")
|
|
480
|
+
line.length > PREVIEW_CHARS ? "#{line[0, PREVIEW_CHARS - 1]}…" : line
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# A tools/call, as the model's tool result. A cached server starts
|
|
484
|
+
# here, on its first call.
|
|
485
|
+
def call(server, tool, args, ctx)
|
|
486
|
+
return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
|
|
487
|
+
|
|
488
|
+
client = server.state == :cached ? lazy_start(server, ctx) : server.service.value
|
|
489
|
+
return client if client.is_a?(String)
|
|
490
|
+
|
|
491
|
+
result = client.request("tools/call", { name: tool, arguments: args }, timeout: server.timeout,
|
|
492
|
+
cancelled: -> { ctx.cancelled? })
|
|
493
|
+
text, images = content(result, server, tool)
|
|
494
|
+
return "Error: #{text.empty? ? "the tool failed" : text}" if result["isError"]
|
|
495
|
+
|
|
496
|
+
images.empty? ? text : Samagotchi::Plugin::ToolResult.new(text, images: images)
|
|
497
|
+
rescue Client::Dead => e
|
|
498
|
+
"Error: MCP server #{server.name} is not running (#{e.message})"
|
|
499
|
+
rescue Client::Error, Samagotchi::Plugin::Service::Stopped => e
|
|
500
|
+
"Error: #{e.message}"
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Start a cached server for a call. The live list replaces the cached
|
|
504
|
+
# one for the next turn when it differs. A start that fails marks the
|
|
505
|
+
# server failed (one notice; its calls answer at once, and its tools go
|
|
506
|
+
# next turn); a cancelled one leaves it cached for the next call.
|
|
507
|
+
# @return [Client, String] the client, or the call's error text
|
|
508
|
+
def lazy_start(server, ctx)
|
|
509
|
+
cached = server.listed
|
|
510
|
+
client = server.service.value
|
|
511
|
+
started = server.state == :cached
|
|
512
|
+
server.state = :running
|
|
513
|
+
if started
|
|
514
|
+
ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size, lazy: true)
|
|
515
|
+
publish(ctx) if server.listed != cached
|
|
516
|
+
end
|
|
517
|
+
client
|
|
518
|
+
rescue Client::Cancelled => e
|
|
519
|
+
"Error: #{e.message}"
|
|
520
|
+
rescue Samagotchi::Plugin::Service::Stopped => e
|
|
521
|
+
"Error: #{e.message}"
|
|
522
|
+
rescue StandardError => e
|
|
523
|
+
raise unless server.state == :cached
|
|
524
|
+
|
|
525
|
+
server.state = :failed
|
|
526
|
+
server.error = e.message
|
|
527
|
+
ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message, lazy: true)
|
|
528
|
+
ctx.notify("MCP server #{server.name} didn't start: #{e.message}; its tools are left out from the next turn",
|
|
529
|
+
level: :warn)
|
|
530
|
+
publish(ctx)
|
|
531
|
+
"Error: MCP server #{server.name} didn't start: #{e.message}"
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# The answer's text, and the images to attach: its image blocks, and a
|
|
535
|
+
# text block that is only an image's path (#image_path).
|
|
536
|
+
# @return [Array(String, Array<Hash>)]
|
|
537
|
+
def content(result, server, tool)
|
|
538
|
+
blocks = Array(result["content"])
|
|
539
|
+
return [JSON.generate(result["structuredContent"]), []] if blocks.empty? && result["structuredContent"]
|
|
540
|
+
|
|
541
|
+
images = []
|
|
542
|
+
text = blocks.map do |block|
|
|
543
|
+
case block["type"]
|
|
544
|
+
when "text"
|
|
545
|
+
line = block["text"].to_s
|
|
546
|
+
path = image_path(line, server)
|
|
547
|
+
next line unless path
|
|
548
|
+
|
|
549
|
+
images << { path: path, name: File.basename(path) }
|
|
550
|
+
"#{line}\n[image #{images.size}: #{File.basename(path)}, attached]"
|
|
551
|
+
when "image"
|
|
552
|
+
mime = block["mimeType"] || "unknown type"
|
|
553
|
+
bytes = block["data"].to_s.unpack1("m")
|
|
554
|
+
next "[image: #{mime}, empty]" if bytes.empty?
|
|
555
|
+
|
|
556
|
+
images << { bytes: bytes, name: "#{tool}-#{images.size + 1}.#{IMAGE_EXT.fetch(mime, "img")}" }
|
|
557
|
+
"[image #{images.size}: #{mime}, attached]"
|
|
558
|
+
when "audio" then "[audio: #{block["mimeType"] || "unknown type"}]"
|
|
559
|
+
when "resource"
|
|
560
|
+
resource = block["resource"] || {}
|
|
561
|
+
resource["text"] || "[resource: #{resource["uri"]}]"
|
|
562
|
+
when "resource_link" then "[resource link: #{block["uri"]}]"
|
|
563
|
+
else "[#{block["type"] || "unknown"} content]"
|
|
564
|
+
end
|
|
565
|
+
end.join("\n")
|
|
566
|
+
[text, images]
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# The path when +text+ is only the absolute path of an image file under
|
|
570
|
+
# the system temp dir or the server's cwd (a server's text can't pull
|
|
571
|
+
# in any image on disk), and the server's attach_image_paths isn't off.
|
|
572
|
+
def image_path(text, server)
|
|
573
|
+
return nil if server.config["attach_image_paths"] == false
|
|
574
|
+
|
|
575
|
+
path = text.strip
|
|
576
|
+
return nil unless path.start_with?("/") && !path.include?("\n") && File.file?(path)
|
|
577
|
+
|
|
578
|
+
real = File.realpath(path)
|
|
579
|
+
roots = [Dir.tmpdir, server.cwd].compact.map { |dir| File.realpath(dir) rescue nil }.compact
|
|
580
|
+
return nil unless roots.any? { |root| real.start_with?(root.end_with?("/") ? root : "#{root}/") }
|
|
581
|
+
|
|
582
|
+
image_magic?(real) ? real : nil
|
|
583
|
+
rescue SystemCallError
|
|
584
|
+
nil
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# png, jpeg, gif or webp by the file's first bytes.
|
|
588
|
+
def image_magic?(path)
|
|
589
|
+
head = File.binread(path, 12).to_s.b
|
|
590
|
+
head.start_with?("\x89PNG\r\n\x1a\n".b, "\xFF\xD8\xFF".b, "GIF87a", "GIF89a") ||
|
|
591
|
+
(head.start_with?("RIFF") && head[8, 4] == "WEBP")
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# The process ended while chi runs: one notice; the calls say so.
|
|
595
|
+
def exited(server, reason, ctx)
|
|
596
|
+
return unless server.state == :running
|
|
597
|
+
|
|
598
|
+
server.state = :exited
|
|
599
|
+
server.error = reason
|
|
600
|
+
ctx.log.warn("mcp_server_exited", server: server.name, msg: reason)
|
|
601
|
+
ctx.notify("MCP server #{server.name} stopped: #{reason}; its tools fail until chi restarts", level: :warn)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def listing
|
|
605
|
+
return "No servers. Add them in config.yml under `bundles: mcp: servers:` (docs/plugins.md, The mcp bundle)." if @servers.empty?
|
|
606
|
+
|
|
607
|
+
@servers.map do |server|
|
|
608
|
+
head = "**#{server.name}**: #{state_text(server)}"
|
|
609
|
+
tools = server.tools.map { |tool| tool["chi_name"] }.compact
|
|
610
|
+
tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
|
|
611
|
+
end.join("\n\n")
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def state_text(server)
|
|
615
|
+
case server.state
|
|
616
|
+
when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}"
|
|
617
|
+
when :running
|
|
618
|
+
count = server.tools.count { |tool| tool["chi_name"] }
|
|
619
|
+
"running (pid #{server.service.value.pid}), #{count} tool#{"s" unless count == 1}"
|
|
620
|
+
when :starting then "starting"
|
|
621
|
+
else "#{server.state == :failed ? "failed" : "stopped"}: #{server.error}"
|
|
622
|
+
end
|
|
623
|
+
rescue Samagotchi::Plugin::Service::Stopped
|
|
624
|
+
"stopped"
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def positive(value)
|
|
628
|
+
number = Float(value.to_s, exception: false)
|
|
629
|
+
number&.positive? ? number : nil
|
|
630
|
+
end
|
|
631
|
+
end
|