feat(mcp): Connectors — catalog + global vs per-user runtimes (§7/§14/§15)

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.
This commit is contained in:
2026-07-16 16:44:12 +01:00
parent 8dac783878
commit 6d299472e3
36 changed files with 2100 additions and 484 deletions
+8 -6
View File
@@ -211,13 +211,13 @@ impl Tools {
tool_registry.register(crate::tools::exec::ExecuteCmd);
tool_registry.register(crate::tools::read_notification::ReadNotification);
tool_registry.register(crate::tools::restart::Restart);
// Unified listing / toggling across mcp, plugins, cron (+ agents for list).
// Unified listing / toggling across plugins, cron (+ agents for list). MCP
// is no longer agent-managed (blueprint §14): connectors are curated by the
// admin and activated by the user via the Connectors UI/API, not tools.
tool_registry.register(crate::tools::list_items::ListItems::new(
Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
tool_registry.register(crate::tools::toggle_item::ToggleItem::new(
Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
tool_registry.register(crate::tools::register_mcp::RegisterMcp::new(Arc::clone(&integrations.mcp)));
tool_registry.register(crate::tools::register_mcp::DeleteMcp::new(Arc::clone(&integrations.mcp)));
Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
tool_registry.register(crate::tools::cron_jobs::DeleteCronJob);
tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets)));
tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets)));
@@ -358,7 +358,9 @@ impl Conversation {
config.llm.max_tool_result_chars,
DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime },
Arc::clone(&tools.tools),
Arc::clone(&integrations.mcp),
// Inert ownerless bundle (§19): the global runtime as a provider,
// unfiltered — never actually exercised (no loops, no consumers).
Arc::clone(&integrations.mcp) as Arc<dyn crate::mcp::McpProvider>,
Arc::clone(&interaction.approval),
Arc::clone(&interaction.clarification),
Arc::clone(&rt.event_bus),
+1 -1
View File
@@ -88,7 +88,7 @@ impl Skald {
// Per-user context factory: captures the global capability managers, so a
// per-user chat/hub/cron/interaction stack can be stamped out on demand.
let user_contexts = UserContextRegistry::new(UserContextFactory::new(
&rt, &models, &media, &tools, &integrations, &conversation, config,
&rt, &models, &media, &tools, &integrations, &conversation, &container, config,
));
// Build the runtime image and reconcile a container for every active user.
+68 -2
View File
@@ -43,12 +43,13 @@ use crate::chat_hub::ChatHub;
use crate::clarification::ClarificationManager;
use crate::compactor::ContextCompactor;
use crate::config::{CompactionConfig, CoreConfig, DatetimeConfig};
use crate::container::ContainerManager;
use crate::cron::TaskManager;
use crate::elicitation::ElicitationManager;
use crate::image_generate::ImageGeneratorManager;
use crate::inbox::Inbox;
use crate::llm::LlmManager;
use crate::mcp::McpManager;
use crate::mcp::{McpManager, McpProvider, UserMcpView};
use crate::memory::MemoryManager;
use crate::projects::tickets::ProjectTicketManager;
use crate::run_context::RunContextManager;
@@ -76,6 +77,11 @@ pub struct UserContext {
pub clarification: Arc<ClarificationManager>,
pub elicitation: Arc<ElicitationManager>,
pub inbox: Inbox,
/// This user's own MCP runtime (blueprint §7/§9): connectors that run inside
/// their container, started at first login and living until restart. Held
/// 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>,
/// 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>,
@@ -87,7 +93,12 @@ pub(super) struct UserContextFactory {
registry_pool: Arc<SqlitePool>,
llm_manager: Arc<LlmManager>,
tools: Arc<ToolRegistry>,
/// The GLOBAL MCP runtime (host, shared). Unioned per-user with the per-user
/// runtime built at login (`UserMcpView`).
mcp: Arc<McpManager>,
/// Container lifecycle — used to ensure a user's container is up before their
/// per-user (container-hosted) MCP connectors start.
container: ContainerManager,
memory_manager: Arc<MemoryManager>,
image_generator_manager: Arc<ImageGeneratorManager>,
run_context_manager: Arc<RunContextManager>,
@@ -111,6 +122,7 @@ impl UserContextFactory {
tools: &Tools,
integrations: &Integrations,
conversation: &Conversation,
container: &ContainerManager,
config: &CoreConfig,
) -> Self {
let cron_tz = config.timezone.as_deref().and_then(|s| s.parse::<Tz>().ok());
@@ -119,6 +131,7 @@ impl UserContextFactory {
llm_manager: Arc::clone(&models.llm_manager),
tools: Arc::clone(&tools.tools),
mcp: Arc::clone(&integrations.mcp),
container: container.clone(),
memory_manager: Arc::clone(&models.memory_manager),
image_generator_manager: Arc::clone(&media.image_generator_manager),
run_context_manager: Arc::clone(&conversation.run_context_manager),
@@ -165,6 +178,58 @@ impl UserContextFactory {
))
});
// Per-user MCP runtime (blueprint §7/§9): the connectors this user has
// activated, run INSIDE their container. Started here on first login and
// living until restart — its `docker exec -i` children die via
// `kill_on_drop` when this context (holding `user_mcp`) is dropped at
// shutdown. Ensure the container is up first (idempotent: boot
// reconciliation and user-create already do this; the belt-and-braces call
// recovers a container stopped since). Non-fatal — a container hiccup
// degrades MCP/exec but must not block login.
if let Err(e) = self.container.ensure(user_id).await {
tracing::warn!(user = %user_id, error = %e, "failed to ensure container before per-user MCP start");
}
let user_mcp = Arc::new(McpManager::new(
Arc::clone(&pool),
self.shutdown_token.clone(),
"data",
));
// NOTE: per-user MCP elicitation (interactive connector login, §15) is
// deferred — api-key connectors don't need it. Wire the user's
// ElicitationBridge here when interactive auth lands.
{
let um = Arc::clone(&user_mcp);
let upool = Arc::clone(&pool);
let container = crate::container::container_name(user_id);
let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str());
self.supervisor.adopt_one(mname, tokio::spawn(async move {
match crate::db::mcp_user_servers::all_startable(&upool).await {
Ok(rows) => {
let specs = rows.iter()
.map(|r| crate::mcp::user_row_spec(r, &container))
.collect();
um.connect_all(specs, false).await;
}
Err(e) => tracing::warn!(error = %e, "per-user MCP init: failed to read mcp_user_servers"),
}
}));
}
// The MCP view this user's sessions see: the access-filtered global runtime
// unioned with their per-user runtime (§7). `accessible_global` is a
// snapshot of `mcp_global_access`, captured at build time like fs membership.
let accessible_global: std::collections::HashSet<String> =
crate::db::mcp_global_access::server_names_for_user(&self.registry_pool, user_id)
.await
.unwrap_or_default()
.into_iter()
.collect();
let mcp_view: Arc<dyn McpProvider> = Arc::new(UserMcpView {
global: Arc::clone(&self.mcp),
user: Arc::clone(&user_mcp),
accessible_global,
});
let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&pool),
Arc::clone(&self.registry_pool), // shared pool = system.db, for shared-memory injection
@@ -177,7 +242,7 @@ impl UserContextFactory {
self.max_tool_result_chars,
self.datetime_config.clone(),
Arc::clone(&self.tools),
Arc::clone(&self.mcp),
mcp_view,
Arc::clone(&approval),
Arc::clone(&clarification),
Arc::clone(&event_bus),
@@ -241,6 +306,7 @@ impl UserContextFactory {
clarification,
elicitation,
inbox,
user_mcp,
global_tx,
}))
}