An agent that wanted to know which MCP servers it had called
`list_mcp_servers` — a tool that has never existed anywhere in this
repo — and got "unknown tool". It was not a random hallucination: the
prompt block says "the system prompt shows available servers", and
`render_mcp_list` returned an empty string when nothing was connected.
The model read a promise, found no table, and invented the discovery
tool the text implied. The `mcp` kinds of `list_items`/`toggle_item`
had been removed to close the §14 RCE vector, which was right for the
write half and left no read half at all.
So `list_items` gains `type: "mcp"` and returns the whole picture in
one call, split into four buckets that each answer a different
question: what is already loaded (call its tools directly), what is
ready for `activate_tools`, what is installed but unusable and why,
and what the user could still activate. Conflating the first two is
what produced the original failure, so they stay apart. Every entry
carries a derived note and a next step; when the step is a human one,
it says so and names the UI page, because there is no tool for it.
Read-only, and structurally so: `toggle_item` deliberately gains
nothing, and the new `McpDirectory` trait exposes exactly one method.
Enabling a connector from a tool is the thing §14 removed, and a wider
seam here is how it would come back. Deny-by-default survives the
report — an ungranted connector is not named at all, since a listing
of what to ask for is itself a leak — except for a catalogue manager,
who cannot administer what they cannot see.
Three sources answer three questions and none is redundant: the
registry says what exists and who may have it, the owner database says
what was activated, and the live runtimes say what is connected right
now — a row can read `ready` while its process is dead. The live half
reaches the tool through the turn's extension map, alongside the pool
and the fs view; with no live view the durable picture still renders,
so freshness is an improvement and never a precondition.
The static `__MCP_LIST__` table stays as it was, because it is frozen
per conversation for prompt-cache stability. Its empty case now says
so out loud and points at the tool.
scripts/ held the pre-marketplace MCP servers (gmail, gcal, gmaps, ssh, weather,
google_trends, whatsapp, serpapi_flights). Nothing referenced them any more:
connectors are admin-curated and installed into connectors/ from the
marketplace, and ci/package.sh never shipped scripts/ in the first place — so on
every installed box requirements.txt was pulling google-auth, googlemaps,
paramiko, trendspyg and friends for files that did not exist there.
requirements.txt now states what it is actually for: the two TTS plugins, which
spawn a bare `python3` on an embedded server script and so have no dependency
reconciler of their own. A connector's deps stay with the connector —
`ensure_installed` puts them in .pydeps/node_modules inside the user's
container, `ensure_installed_host` beside the files for a global one.
The venv itself stays load-bearing for those two plugins and for the host pip
that installs a global connector's deps, so the run/install/update scripts keep
creating it — but their "Python MCP servers will be unavailable" warning was
naming the one thing that no longer depends on it, and now says what really
breaks.
CONNECTOR_MANIFEST_GUIDE.md moves to the repo root: it was the one thing in
scripts/ still referenced (CLAUDE.md), and being under a gitignored directory it
had never been committed at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TIC said nothing about what the agent does, and named the wrong thing: the
tick belongs to the scheduler, which is generic and lives outside it. The
agent's only decision is whether an incoming event deserves an interruption
— it sorts, it never acts — so it is now event-triage, matching the
functional naming of the two memory lints.
- agents/tic/ -> agents/event-triage/, module tic/ -> event_triage/,
TicManager -> EventTriageManager, TicConfig -> EventTriageConfig
- agent id and chat source: "tic" -> "event-triage"
- config keys: tic.* -> event_triage.*, and the config.yml section tic: ->
event_triage: (greenfield: previously set values fall back to defaults)
- i18n en/it/fr: Event triage / Triage eventi / Tri des evenements; dropped
the stale "TIC sessions" mention from the debug-pages description
- docs/system-agents.md, docs/index.md, docs/settings.md, CLAUDE.md, SKALD.md
Reframe TIC from an ownerless global loop into a per-user system agent.
The events it reads live in each user's own encrypted mcp_events, the
connectors that produced them run in that user's container, and the
notifications go to that user's hub — so the previous design (built
against the ownerless Conversation bundle, writing into system.db and
notifying a hub with no subscribers) was inert by construction.
Core changes
- TicManager owns no timer and no user list. It now exposes
run_for(user_id, pool, sessions, hub): one tick for one user, over
deps unpacked from that user's UserContext. Removed from the
Conversation bundle; Skald::tic_manager() is gone.
- New spawn_system_agents in wiring.rs: one instance-wide loop, spawned
post-construction with a Weak<Skald> (like spawn_user_lifecycle).
Each pass walks the directory and runs TIC for one user at a time —
sequential, because a pass is N container round-trips and N LLM calls
nobody is waiting on. A ConfigKeyUpdated on the interval key cuts the
current wait short; enabled is re-read per pass.
- A user whose database is still locked is skipped (normal, not an
error): the pool is the unlock token, so a user who hasn't logged in
since restart has no readable events and nowhere to record a skip.
- The configured tic.security_group is re-checked per user through
run_context::reconcile_group_for_user — a restricted member never
gets a tool set their role wouldn't grant; unconfigured starts from
role_default_run_context, never None (None = catch-all = wider).
- New system_agent_runs owner table (no user_id column — the file is
the owner): start/finish split so a crash leaves a visible 'running'
row, swept to 'failed' by the next start; safe because the scheduler
is sequential and single-instance. An idle tick writes nothing.
- counting_notify wraps the notify tool so the run log can report
notifications emitted without the tool knowing it's counted.
- The session's event channel is drained by a spawned task instead of
a dropped receiver — the translator awaits its sends and would wedge
at capacity.
EventLog::{Persist,Discard} on McpManager::new
- mcp_events is an owner table and its only reader (TIC) is per-user,
so an event is something that happened to someone. The per-user
runtime gets Persist; the ownerless global runtime gets Discard (its
pool is system.db, rows would be unattributable and unread).
API + UI
- GET /api/system-agents/runs: the caller's own run history, scoped
through require_context with no admin override (same promise as the
rest of the private pool).
- web/components/system-agents.js replaces tic-sessions.js. The old
#tic debug page inferred runs from leftover ephemeral sessions; the
new #system-agents page (sidebar group 'extensions', visible to
everyone — the data is the caller's own) reads the real run log.
- i18n: tic.* keys replaced with system_agents.* in en/it/fr.
Docs
- New docs/system-agents.md (user-facing: what TIC does, why it runs
per person, why a run can be missing). Updated docs/settings.md and
docs/index.md.
- agents/tic/AGENT.md reframed per-user: events are that person's,
memory is user-memory/ (private) — never shared-memory/.
- CLAUDE.md records the system-agents design and the EventLog seam.
After a reinstall the catalog entry carries new llm_short_description,
icon and code. Previously the running servers (global + per-user) kept
their old metadata and code until the next login.
- Add refresh_connector_after_reinstall on Skald: re-snapshots the
description from the catalog, reconciles local files on per-user
connectors, and restarts both the global and per-user servers
- Add set_description db accessor for mcp_global_servers
- user_row_spec_resolved now injects the live catalog description
(over the bare name) so user-runtime connectors show the right blurb
- marketplace install() fetches a fresh feed instead of the browse
cache, so a reinstall reflects the changed manifest immediately
A user's session sees global MCP connectors through UserMcpView, filtered by
accessible_global — a snapshot of mcp_global_access taken when the user's
UserContext is built at login. That context is cached until restart, so an admin
enabling/deleting a global connector or changing its access set was invisible in
MCP_LIST (and in the tool surface) until the whole process restarted.
Make accessible_global a swappable cell (SharedGlobalAccess, the MCP twin of
SharedFs for §6 fs remount): UserContext::refresh_global_access re-reads the
registry and stores it in place, and Skald::refresh_global_mcp_access broadcasts
that to every live context. Wire it into global_enable, global_delete,
global_set_access and user_connectors_set so a grant/enable is reflected in
running sessions immediately.
Also add the shared common/mcp.md include (the <!-- MCP_LIST --> sentinel) to the
kid agent, aligning it with the other agents.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add MoonshotProvider and MoonshotCodeProvider (OpenAI-compatible)
- Extract `fetch_openai_models()` and `build_openai_llm()` shared functions,
deduplicating model-listing and client construction across OpenAI,
OpenRouter, DeepSeek, LM Studio, and Z.AI providers
- Add `lists_models` field to `ProviderUiMeta` — drives frontend model
picker dynamically instead of hardcoded `type_id` list
- Port DeepSeek, LM Studio, OpenRouter model listing to `fetch_openai_models`
- Set `lists_models: true` on Z.AI provider (missed in prior pass)
feat(mcp): named key placeholders {SECRET:name}/{ENV:name} in connector URLs
- `apply_key_placeholder` now accepts an `env` map alongside `api_key`
- Named tokens resolve from the connector's described `env[]` fields,
with `{SECRET:name}` falling back to `api_key` for backward compat
- Replace `substitute_secret_tokens` with `substitute_named_tokens`
- Unresolved tokens are left in place (visible misconfig) rather than
silently producing a wrong URL
fix(ui): connector detail hides generic API key when schema has secret
fix(ui): model picker uses `lists_models` from provider types endpoint
Fills a gap the blueprint names: the admin had to hand-author every
`mcp_catalog` entry. A remote feed of vetted connectors now proposes them
and the admin installs — the feed is *consultative*, so §14's risk axis is
untouched and the trust anchor stays on the box.
Marketplace client (`src/frontend/api/marketplace.rs`):
- Fetches the feed server-side (it sends no CORS headers) and caches it;
icons are proxied for the same reason.
- Verifies every declared SHA-256 before writing, fail-closed and
all-or-nothing. Feed-supplied paths are refused if they escape
`./scripts/<id>/`. Importing an `mcp_local` entry still demands the
admin-only `mcp.register_local_script`.
- Translates the feed's vocabulary into Skald's: `user`→`per_user`,
`mcp_local`→`local_script`. Scope is read, never inferred from transport
(a remote connector can be per-user — that is what `mcp.register_remote`
is for), and an unreadable `type` fails closed to the answer needing more
authority. The feed's `llm_short_description` maps to `description`, the
column `render_mcp_list` puts in front of the LLM for `activate_tools()`.
- Feed URL is config (`marketplace.url`), not a constant: an on-premise
product must not hard-require reaching one vendor's host.
Two silent failures found while wiring it:
- `transport_of` maps anything unknown to Stdio, so the feed's
`streamable-http` would have tried to spawn a command. Normalised on import.
- Some servers want their key as a query param, not a bearer header, and say
so with a `{key}` placeholder. Substituted at connect time in
`global_row_spec`/`user_row_spec` — never at rest, so the key stays in its
own column and the stored URL stays a template.
Pages, split by the question each answers:
- Connectors — what runs (`UserMcpView` = global ∪ per-user) and what I can
add. Same page for everyone; the admin just has more verbs. One Available
list with the verb per row: `per_user`→Activate, `global`→Enable globally.
Enabling a global is the admin's counterpart to activating a per-user one,
so the catalog picker dropdown is gone — the entry comes from the row.
- Connector Catalog (admin) — what this box offers. One `Add connector`
with two sources: marketplace first (vetted, hashed), manual second
(unvetted by nature) — the order mirrors the trust model.
- Marketplace (admin) — reached from the catalog, not the sidebar: it is a
destination of an action, not a place.
`available()` no longer returns `McpGlobalServerRow`: that row carries
`api_key` and this view now reaches every logged-in user. A slim `GlobalView`
crosses instead, and an admin sees every global (with `can_use` marking their
own) so one enabled for someone else stays manageable.
Also fixes `connectors-page` having no CSS rule at all — every sibling page
has one, so it never got `flex: 1` and left an empty column beside it.
Re-architects MCP from one owner table + agent-written registration into an
admin-curated catalog with two runtimes unioned per session, surfaced in the UI
as "Connectors" (mcp/schema stays neutral, §0.1).
Two runtimes behind one seam (§7):
- Global runtime: shared, stateless connectors (web-search, Tavily…) on the
HOST, connected at boot from mcp_global_servers, access-filtered per user via
mcp_global_access.
- Per-user runtime: a user's activated connectors run INSIDE their container,
started at first login from mcp_user_servers and living until restart (§9);
docker exec -i children die via kill_on_drop when the UserContext drops.
- McpProvider trait (mcp/provider.rs): the session round-loop never learns which
runtime owns a server. McpManager implements it directly (inert ownerless
bundle); UserMcpView implements global ∪ user with an accessible_global
snapshot. Both share McpManager::connect_all; McpServerSpec +
global_row_spec/user_row_spec turn a DB row into a connectable spec.
- mcp-client: McpServerConfig.launch_in runs a stdio command inside a container
via docker exec -i (set at runtime, never parsed from config).
Authorization is a capability on the role, not `if role==admin` (§0.1/§14):
role_capabilities table + db/role_capabilities.rs — register_remote and
register_local_from_catalog are self-service (seeded on every new role), while
register_local_script and manage_catalog are admin-only. admin holds every
capability by construction. This removes the agent-facing register_mcp/delete_mcp
tools and the mcp kinds of list_items/toggle_item, closing the §14 RCE vector.
Schema:
- Registry: mcp_catalog (vetted templates — schema only, no live creds),
mcp_global_servers + mcp_global_access, role_capabilities.
- Owner: mcp_user_servers (per-user activations; api_key encrypted at rest,
catalog_name a bare TEXT snapshot, never an owner→registry FK).
- Drops the old owner table mcp_servers.
API + UI: src/frontend/api/mcp.rs (admin catalog/global/access + user
available/activate/activated, all capability-gated via require_cap);
web/components/connectors.js (<connectors-page>) renders the user view always
and the admin view for role_id === 'admin'.
Deferred: interactive per-user auth (OAuth callback / QR / SSH elicitation, §15)
— only none/api_key wired; no boot seed of catalog presets; per-(user, session)
MCP grant model still open.
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.
## UserManager + per-user encryption (§9/§11)
New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.
New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).
- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
<0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
`create_owner_tables` (one owner's content, identical in every file). No FK in
the owner bucket may reach the registry — enforced by a standalone test.
Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
so a crash leaves an orphan file, never a user without a database. `open_db`
never creates: a missing file is an error, not a silent empty database.
Not consumed yet: no login, call sites still use the shared system.db pool.
## Extract crates/skald-core
The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:
- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
(sibling of `http_router`), so the core no longer downcasts to
`MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
teardown-and-respawn; the core defaults to the supervisor exit code. The core
loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
only emits tracing events.
All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.