Second half of the MCP client: the tools the manager discovers become rows in the existing act allowlist instead of a parallel capability system. An MCP tool is encoded in the columns that already exist — cmd ["mcp",<server>,<tool>], scope mcp:<server> — so no migration, and ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn need no changes. One branch in Executor.Exec routes such a row to the manager instead of exec, and "mcp" is never run as a binary. Discovery only ever PROPOSES. destructive comes from the inverse of the MCP readOnlyHint, so a tool that does not promise to be read-only inherits the confirm turn, and enabling stays on /tools behind step-up. Voice args are positional and MCP args are named, so CallPositional binds only what it can defend: no required properties runs bare, and a read-only tool with exactly one required string or number gets the tail. Everything else refuses with ErrNeedsArgs rather than guessing. The read-only condition was learned against the live Vikunja server: update_task requires only task_id and takes the rest as optional, so one guessed argument blanked the fields it did not mention. A partially-filled write destroys what it omits, so a mutating tool never receives a guessed argument. Also: a read-only mcp_servers IPC method and an "MCP servers" card on /tools showing transport, target and state, with the trust level of a local target spelled out. There is deliberately no call-a-tool IPC method and no run button, so mutation keeps exactly one path. Vikunja #251
Maven — Docker deployment
One image, one container per daemon (docker-compose.yml). Core (mavend)
holds the encryption key and the db; the modules mount only the shared socket
dir and read-only models.
First run
# 1. generate the at-rest db key (32 bytes, base64) — keep it safe, losing it loses the db
cp deploy/db_key.env.example deploy/db_key.env
printf 'MAVEN_DB_KEY=%s\n' "$(openssl rand 32 | base64 -w0)" > deploy/db_key.env
# 2. build + start
docker compose build
docker compose up -d
# 3. logs
docker compose logs -f mavend
models/ and deps/ are bind-mounted / baked from the host — they are NOT in
git (fetched via make deps + downloaded models). The build context needs
deps/lib, deps/piper, deps/include, and deps/whisper.cpp/ggml/include
present (see .dockerignore).
Layout
| Path (in container) | What |
|---|---|
/opt/maven/bin |
the six daemons |
/opt/maven/lib |
native .so (whisper+vulkan, onnxruntime) |
/opt/maven/piper |
piper binary + espeak data |
/opt/maven/models (ro) |
bind-mount of ./models |
/run/maven (volume) |
shared IPC sockets |
/var/lib/maven (volume) |
encrypted db at rest |
/dev/shm (tmpfs) |
decrypted db working copy (RAM only) |
Reading the outside world (off by default)
mavend.json ships without a feeds block, which means no RSS/Atom feed is
fetched and no outbound request is made. Switching it on is adding the block:
"feeds": {
"poll_interval": "30m",
"max_items": 5,
"max_age": "24h",
"sources": [
{ "name": "habr", "url": "https://habr.com/ru/rss/best/daily/",
"category": "технологии", "exclude": ["реклама"] }
]
}
What it does and does not do:
- items are written as notes with source
rss:<name>, visible on/dash; - nothing is announced. She reads them back when asked — "что нового в лентах?", "что нового по технологиям?" — and never on arrival. There is no severity or channel knob here on purpose;
- the fetcher is allowlisted to the hosts of the configured feeds, plus any
allow_hosts. It refuses non-http(s) schemes and every private address (loopback, the LAN, the10.42.0.0/24wg range, cloud metadata). It caps the response at 2 MiB and redirects at 3, and makes at most one request per host per second. Seeinternal/webfetch; - how far each feed was read is stored as a config fact
rss:latest:<name>, so a restart does not re-note yesterday's headlines.
Reading a page (crawl, also off by default)
There is no crawl block either, so no page is fetched. Two halves, separately
switched:
"crawl": {
"on_demand": true,
"interval": "6h",
"max_runes": 4000,
"watches": [
{ "name": "changelog", "url": "https://example.org/changelog", "interval": "12h" }
]
}
on_demandlets her read a page he names in the utterance: "посмотри https://example.org/x — что там?". The page becomes context for his question, and only the URL leaves the box. Without a URL nothing is fetched, so this is a fallback and not a habit;watchesre-reads a fixed list on its interval and writes a note when the text changed. Like the feeds, it announces nothing;- the answer path sits last in the query chain, behind his memory, his notes and (once wired) the local Kiwix ZIMs. A local read costs nothing;
robots.txtis fetched first and obeyed with no override; aDisallowis a refusal she says out loud.Crawl-delayis honoured;- same guarded fetcher as the feeds: allowlist/denylist, no private addresses, size cap, redirect cap, timeout, one request per host per second;
- dedup state is the config fact
crawl:hash:<name>.
Not yet verified / host-dependent
This stack is correct-by-construction but has not been build-tested here (no docker in the authoring env; ~1GB context; GPU). Expect a tweak on first build on the target host, most likely in one of these:
- GPU passthrough —
mavsttdmaps/dev/drifor Vulkan. On an NVIDIA host you'd swap to the nvidia container runtime instead of/dev/dri. - onnxruntime lib path —
mavend's embedder needslibonnxruntime.so(onLD_LIBRARY_PATH=/opt/maven/lib). If the embedder wants an explicit path, set it in the config's embedder block. - cross-container voice —
mavweb -voice mavend:9100only works oncemavendbinds its voice server on0.0.0.0:9100(Voice config, currently unset). Until then, voice-over-web is inert;/tools, passkey, and the dash work fine over the core socket. - netdata —
mavpollreaches it viahost.docker.internal; adjust if netdata runs elsewhere.
Updating her (mavupdate, Vikunja #249)
Off unless configured, and there is deliberately no button for it. There is no
IPC method, no web route, no timer and no act that starts an update — the trigger
is a human running mavupdate on the host, which needs shell access, a strictly
higher bar than the step-up passkey gate that guards /tools. She cannot update
herself; she can be updated. Nothing here ever fetches code: the new version is
whatever you pulled into the working tree yourself.
Add an update block to mavend.json (mavend ignores it — only the CLI reads
it), with paths as they exist on the host, not inside a container:
"update": {
"source_dir": "/home/kami/apps/Maven",
"install_dir": "/home/kami/apps/Maven",
"snapshot_dir": "/var/lib/maven-snapshots",
"binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
"mavenclient", "mavpoll", "mavcaldav", "mavmaild"],
"config_files": ["deploy/mavend.json"],
"restart_cmd": ["docker", "compose", "up", "-d", "--build"],
"health_socket": "/var/lib/docker/volumes/maven_sockets/_data/mavend.sock",
"health_timeout_sec": 120
}
snapshot_dir must be outside install_dir (a restore must not read from what
the install writes) and health_socket is required: an update that cannot check
its own result cannot roll itself back, so the config is refused without one.
Then:
mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
mavupdate -config deploy/mavend.json apply -yes # snapshot, verify, install, restart, health-check
mavupdate -config deploy/mavend.json list # what you can roll back to
mavupdate -config deploy/mavend.json rollback -yes # restore the previous artifacts and restart
apply refuses to start if she is not already answering — otherwise a failed
update and a box that was already broken are indistinguishable afterwards. On any
failure after the install it restores the snapshot, restarts, and checks again;
if that also fails it says so loudly and names the directory to copy back by hand.
The database is never snapshotted or rolled back (see the package comment in
internal/update); schema compatibility is store.Migrate's job.