fix: show per-user connectors in the security-group picker
Nightly Build / build (push) Successful in 7m40s

The Security-groups tool grid listed only global connectors. Its endpoint
built the MCP half from `skald.catalog()`, whose `ToolCatalog` is constructed
once around the ownerless GLOBAL `McpManager` — the per-user runtimes live on
each `UserContext` and it never sees them. `known_tools` did not cover the gap
either: `ToolDiscovery` records what is offered to a model, and an MCP tool
reaches the wire only once activated, so an unused connector was invisible
exactly when the admin wanted to write its rule.

The listing now unions three sources: the global runtime, the caller's own
per-user runtime (so a connector activated moments ago appears at once), and
`known_tools`, which per-user MCP startup now writes at login so a connector
belonging to an offline user is still nameable — security groups are
instance-wide config, and a grid that describes only whoever is online is a
grid the admin cannot finish.

An `mcp__<server>__<tool>` row from `known_tools` is routed to the MCP bucket
under its own server instead of the flat "dynamic" category, and a non-global
server takes its friendly name from the catalog entry it was activated from.
This commit is contained in:
2026-08-07 12:04:27 +01:00
parent 94bffe6760
commit 31b4c76f51
2 changed files with 108 additions and 16 deletions
@@ -302,6 +302,7 @@ impl UserContextFactory {
specs.push(crate::mcp::user_row_spec_resolved(r, &container, &registry).await);
}
um.connect_all(specs, false).await;
record_known_tools(&registry, &um).await;
}
Err(e) => tracing::warn!(error = %e, "per-user MCP init: failed to read mcp_user_servers"),
}
@@ -409,6 +410,33 @@ impl UserContextFactory {
}
}
/// Records this user's connector tools in the registry's `known_tools`, so an
/// instance-wide surface can name them while the user is offline.
///
/// Security groups are instance config, but a per-user connector's tools live in
/// a runtime that exists only between that user's login and the next restart —
/// so the Security-groups grid could only ever describe whoever happened to be
/// online. `ToolDiscovery` does not close the gap on its own: it records what is
/// *offered to a model*, and an MCP tool reaches the wire only once activated
/// (`SkaldToolSet::defs`), so a connector nobody has used yet is invisible
/// exactly when the admin wants to write its rule.
///
/// Registry-side is the right home under §2: the names say which connectors run
/// on this box, which the admin already curates in `mcp_catalog` — never who
/// activated one, and never a call or an argument. Best-effort: a row that does
/// not get written costs a tool that is gated by the catch-all `* require` until
/// the next login, which is the safe direction.
async fn record_known_tools(registry: &SqlitePool, mcp: &McpManager) {
for t in mcp.tools() {
let schema = serde_json::to_string(&t.input_schema).ok();
if let Err(e) = crate::db::known_tools::upsert(
registry, &t.tool_id(), &t.description, schema.as_deref(),
).await {
tracing::warn!(tool = %t.tool_id(), error = %e, "failed to record per-user MCP tool in known_tools");
}
}
}
/// The live per-user contexts, keyed by user id, plus the factory that builds them.
/// A `tokio::Mutex` serialises the build so a context (and its cron loop) is created
/// at most once per user, even under concurrent first-use.