mcp: live-refresh global connector access without restart; add MCP list to kid agent
Nightly Build / build (push) Successful in 6m36s

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>
This commit is contained in:
2026-07-22 00:22:35 +01:00
co-authored by Claude Opus 4.8
parent c11702c3d3
commit 5d5c3ff2ff
6 changed files with 107 additions and 16 deletions
+15
View File
@@ -118,6 +118,21 @@ impl Skald {
}
Ok(())
}
/// Refresh every live user's global-connector access set in place — call after an
/// admin enables/deletes a global connector or changes who may use it, so running
/// sessions see it without a restart (the §7 MCP twin of the §6 fs remount). The
/// global runtime itself is already updated by the caller (`start_server` /
/// `stop_server`); this only re-snapshots each user's access filter. Best-effort:
/// a locked (not-live) user has no snapshot to refresh — their next login rebuilds
/// it from the now-current tables.
pub async fn refresh_global_mcp_access(&self) {
for ctx in self.rt_user_contexts().all_live().await {
if let Err(e) = ctx.refresh_global_access().await {
tracing::warn!(user = %ctx.user_id, error = %e, "failed to refresh global MCP access");
}
}
}
pub fn sessions(&self) -> &Arc<crate::auth::SessionStore> { &self.rt.sessions }
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
+36 -2
View File
@@ -49,7 +49,7 @@ use crate::elicitation::ElicitationManager;
use crate::image_generate::ImageGeneratorManager;
use crate::inbox::Inbox;
use crate::llm::LlmManager;
use crate::mcp::{McpManager, McpProvider, UserMcpView};
use crate::mcp::{McpManager, McpProvider, SharedGlobalAccess, UserMcpView};
use crate::memory::MemoryManager;
use crate::run_context::RunContextManager;
use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS};
@@ -82,11 +82,33 @@ pub struct UserContext {
/// here so its lifetime equals the pool's; its `docker exec -i` children die
/// via `kill_on_drop` when the context is dropped at shutdown.
pub user_mcp: Arc<McpManager>,
/// The registry (`system.db`) pool — used to re-read this user's global-connector
/// access when it changes (see [`UserContext::refresh_global_access`]).
pub registry_pool: Arc<SqlitePool>,
/// This user's global-connector access set, shared (swappable) with their live
/// `UserMcpView` so an admin's enable/grant is visible without a restart (§7).
pub global_access: SharedGlobalAccess,
/// Per-user server→client push channel. WS handlers subscribe here (via the
/// hub) so a user's `ServerEvent`s never reach another user's socket.
pub global_tx: broadcast::Sender<GlobalEvent>,
}
impl UserContext {
/// Re-reads this user's global-connector access from the registry and swaps it
/// into the live `UserMcpView` in place — so an admin enabling/deleting a global
/// connector, or changing who may use it, is reflected in running sessions
/// without a restart (the §7 MCP twin of the §6 fs remount).
pub async fn refresh_global_access(&self) -> anyhow::Result<()> {
let names: std::collections::HashSet<String> =
crate::db::mcp_global_access::server_names_for_user(&self.registry_pool, &self.user_id)
.await?
.into_iter()
.collect();
self.global_access.store(names);
Ok(())
}
}
/// Captures the global capability managers + resolved config once, and stamps out
/// a [`UserContext`] per unlocked pool.
pub(super) struct UserContextFactory {
@@ -263,10 +285,14 @@ impl UserContextFactory {
.unwrap_or_default()
.into_iter()
.collect();
// A swappable cell shared with the view below, so an admin enabling or
// granting a global connector refreshes it in place (§7 — the MCP twin of
// the §6 fs remount) instead of settling only at the next restart.
let global_access = SharedGlobalAccess::new(accessible_global);
let mcp_view: Arc<dyn McpProvider> = Arc::new(UserMcpView {
global: Arc::clone(&self.mcp),
user: Arc::clone(&user_mcp),
accessible_global,
accessible_global: global_access.clone(),
});
let manager = Arc::new(ChatSessionManager::new(
@@ -336,6 +362,8 @@ impl UserContextFactory {
elicitation,
inbox,
user_mcp,
registry_pool: Arc::clone(&self.registry_pool),
global_access,
global_tx,
}))
}
@@ -372,6 +400,12 @@ impl UserContextRegistry {
pub(super) async fn peek(&self, user_id: &str) -> Option<Arc<UserContext>> {
self.contexts.lock().await.get(user_id).cloned()
}
/// A snapshot of every live context — for a broadcast refresh (e.g. global-MCP
/// access changing). Cheap: clones `Arc`s under a short lock.
pub(super) async fn all_live(&self) -> Vec<Arc<UserContext>> {
self.contexts.lock().await.values().cloned().collect()
}
}
// ── UserChannelHandle impl ────────────────────────────────────────────────────