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:
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user