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:
@@ -25,6 +25,9 @@ pub use mcp_client::{
|
||||
use mcp_client::McpTransport;
|
||||
|
||||
mod logs;
|
||||
mod provider;
|
||||
|
||||
pub use provider::{McpProvider, UserMcpView};
|
||||
|
||||
const SERVER_START_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
@@ -116,22 +119,6 @@ impl McpManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn cfg_from_row(row: &crate::db::mcp_servers::McpServerRow) -> McpServerConfig {
|
||||
McpServerConfig {
|
||||
name: row.name.clone(),
|
||||
transport: match row.transport.as_str() {
|
||||
"http" => McpTransport::Http,
|
||||
"sse" => McpTransport::Sse,
|
||||
_ => McpTransport::Stdio,
|
||||
},
|
||||
command: row.command.clone(),
|
||||
args: Some(row.args()).filter(|v| !v.is_empty()),
|
||||
env: Some(row.env()).filter(|m| !m.is_empty()),
|
||||
url: row.url.clone(),
|
||||
api_key: row.api_key.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_one(
|
||||
cfg: &McpServerConfig,
|
||||
notification_tx: Option<mpsc::UnboundedSender<McpNotification>>,
|
||||
@@ -154,29 +141,49 @@ impl McpManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects the GLOBAL runtime at boot: reads the enabled globally-active
|
||||
/// connectors (`mcp_global_servers`, host transport) and connects to them.
|
||||
/// The per-user runtime (blueprint §7/§9) is built separately at login and
|
||||
/// shares [`connect_all`] rather than this table-bound entry point.
|
||||
pub async fn initialize(&self) {
|
||||
let rows = match crate::db::mcp_servers::all_enabled(&self.pool).await {
|
||||
let rows = match crate::db::mcp_global_servers::all_enabled(&self.pool).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => { warn!("McpManager::initialize: failed to read DB: {e}"); return; }
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
info!("No enabled MCP servers in DB — MCP disabled.");
|
||||
info!("No enabled global MCP servers in DB — global MCP disabled.");
|
||||
crate::boot::section("MCP servers — none enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
let cfgs: Vec<_> = rows.iter().map(Self::cfg_from_row).collect();
|
||||
let specs = rows.iter().map(global_row_spec).collect();
|
||||
self.connect_all(specs, true).await;
|
||||
}
|
||||
|
||||
/// Connects to a batch of servers in parallel (each bounded by
|
||||
/// `SERVER_START_TIMEOUT_SECS`), recording their tools, errors and prompt
|
||||
/// descriptions. The reusable core shared by the global runtime
|
||||
/// ([`initialize`]) and the per-user runtime (built at login, §7). `boot`
|
||||
/// gates the curated boot-console lines, which only make sense at startup —
|
||||
/// a login-time per-user connect passes `false`.
|
||||
pub async fn connect_all(&self, specs: Vec<McpServerSpec>, boot: bool) {
|
||||
if specs.is_empty() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut descs = self.descriptions.write().unwrap();
|
||||
for row in &rows {
|
||||
descs.insert(row.name.clone(), row.description.clone());
|
||||
for spec in &specs {
|
||||
descs.insert(spec.config.name.clone(), spec.description.clone());
|
||||
}
|
||||
}
|
||||
crate::boot::section(format!(
|
||||
"MCP servers — connecting to {} in background", cfgs.len()
|
||||
));
|
||||
let handles: Vec<_> = cfgs.into_iter().map(|cfg| {
|
||||
if boot {
|
||||
crate::boot::section(format!(
|
||||
"MCP servers — connecting to {} in background", specs.len()
|
||||
));
|
||||
}
|
||||
let handles: Vec<_> = specs.into_iter().map(|spec| {
|
||||
let cfg = spec.config;
|
||||
let tx = self.notification_tx.clone();
|
||||
let log_tx = self.log_tx.clone();
|
||||
let eh = self.elicitation_handler();
|
||||
@@ -186,30 +193,33 @@ impl McpManager {
|
||||
Duration::from_secs(SERVER_START_TIMEOUT_SECS),
|
||||
Self::start_one(&cfg, Some(tx), Some(log_tx), eh),
|
||||
).await;
|
||||
(cfg.name, cfg.transport, result)
|
||||
(cfg.name, result)
|
||||
})
|
||||
}).collect();
|
||||
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok((name, _, Ok(Ok(s)))) => {
|
||||
let tool_names: Vec<_> = s.tools().iter().map(|t| t.name.as_str()).collect();
|
||||
info!("MCP server '{}' ready — {} tool(s): {}", name, tool_names.len(), tool_names.join(", "));
|
||||
Ok((name, Ok(Ok(s)))) => {
|
||||
let tool_names: Vec<_> = s.tools().iter().map(|t| t.name.clone()).collect();
|
||||
let n = tool_names.len();
|
||||
crate::boot::ok(format!("{name} ({n} tool{})", if n == 1 { "" } else { "s" }));
|
||||
info!("MCP server '{}' ready — {n} tool(s): {}", name, tool_names.join(", "));
|
||||
if boot {
|
||||
crate::boot::ok(format!("{name} ({n} tool{})", if n == 1 { "" } else { "s" }));
|
||||
}
|
||||
self.log_lifecycle(&name, format!("connected — {n} tool(s)"));
|
||||
self.errors.write().unwrap().remove(&name);
|
||||
self.servers.write().unwrap().insert(name, s);
|
||||
}
|
||||
Ok((name, _, Ok(Err(e)))) => {
|
||||
Ok((name, Ok(Err(e)))) => {
|
||||
warn!("MCP server '{}' failed to start: {e}", name);
|
||||
crate::boot::fail(format!("{name} — {e}"));
|
||||
if boot { crate::boot::fail(format!("{name} — {e}")); }
|
||||
self.log_lifecycle(&name, format!("failed to start: {e}"));
|
||||
self.errors.write().unwrap().insert(name, e.to_string());
|
||||
}
|
||||
Ok((name, _, Err(_))) => {
|
||||
Ok((name, Err(_))) => {
|
||||
let msg = format!("startup timed out after {SERVER_START_TIMEOUT_SECS}s");
|
||||
warn!("MCP server '{}' {msg}", name);
|
||||
crate::boot::fail(format!("{name} — {msg}"));
|
||||
if boot { crate::boot::fail(format!("{name} — {msg}")); }
|
||||
self.log_lifecycle(&name, &msg);
|
||||
self.errors.write().unwrap().insert(name, msg);
|
||||
}
|
||||
@@ -218,77 +228,38 @@ impl McpManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register(&self, p: crate::db::mcp_servers::UpsertParams<'_>) -> Result<Vec<String>> {
|
||||
let name = p.name.to_string();
|
||||
|
||||
crate::db::mcp_servers::upsert(&self.pool, p).await?;
|
||||
|
||||
let rows = crate::db::mcp_servers::all_enabled(&self.pool).await?;
|
||||
let row = rows.into_iter().find(|r| r.name == name)
|
||||
.ok_or_else(|| anyhow::anyhow!("register: server '{}' not found after upsert", name))?;
|
||||
let cfg = Self::cfg_from_row(&row);
|
||||
|
||||
/// Starts (or restarts) a single server from a spec and records it in the
|
||||
/// runtime maps. The DB write is the caller's job (the Connectors activation
|
||||
/// API) — this only touches the live connections. Returns the tool names.
|
||||
pub async fn start_server(&self, spec: McpServerSpec) -> Result<Vec<String>> {
|
||||
let name = spec.config.name.clone();
|
||||
let client = tokio::time::timeout(
|
||||
Duration::from_secs(SERVER_START_TIMEOUT_SECS),
|
||||
Self::start_one(&cfg, Some(self.notification_tx.clone()), Some(self.log_tx.clone()), self.elicitation_handler()),
|
||||
Self::start_one(&spec.config, Some(self.notification_tx.clone()), Some(self.log_tx.clone()), self.elicitation_handler()),
|
||||
).await
|
||||
.map_err(|_| {
|
||||
self.log_lifecycle(&name, "timed out during connection");
|
||||
anyhow::anyhow!("MCP server '{}' timed out during connection", name)
|
||||
anyhow::anyhow!("MCP server '{name}' timed out during connection")
|
||||
})?
|
||||
.map_err(|e| {
|
||||
self.log_lifecycle(&name, format!("failed to start: {e}"));
|
||||
anyhow::anyhow!("MCP server '{}' failed to start: {e}", name)
|
||||
anyhow::anyhow!("MCP server '{name}' failed to start: {e}")
|
||||
})?;
|
||||
|
||||
let tool_names: Vec<String> = client.tools().iter().map(|t| t.name.clone()).collect();
|
||||
self.log_lifecycle(&name, format!("connected — {} tool(s)", tool_names.len()));
|
||||
self.errors.write().unwrap().remove(&name);
|
||||
self.descriptions.write().unwrap().insert(name.clone(), row.description.clone());
|
||||
self.descriptions.write().unwrap().insert(name.clone(), spec.description);
|
||||
self.servers.write().unwrap().insert(name, client);
|
||||
|
||||
Ok(tool_names)
|
||||
}
|
||||
|
||||
pub async fn unregister(&self, name: &str) -> Result<()> {
|
||||
crate::db::mcp_servers::delete(&self.pool, name).await?;
|
||||
/// Stops a running server (dropping the client → `kill_on_drop`) and forgets
|
||||
/// it. DB removal is the caller's responsibility.
|
||||
pub fn stop_server(&self, name: &str) {
|
||||
self.servers.write().unwrap().remove(name);
|
||||
self.errors.write().unwrap().remove(name);
|
||||
self.descriptions.write().unwrap().remove(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_enabled(&self, name: &str, enabled: bool) -> Result<()> {
|
||||
crate::db::mcp_servers::set_enabled(&self.pool, name, enabled).await
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Result<Vec<McpServerInfo>> {
|
||||
let rows = crate::db::mcp_servers::all(&self.pool).await?;
|
||||
let servers = self.servers.read().unwrap();
|
||||
let errors = self.errors.read().unwrap();
|
||||
|
||||
let infos = rows.into_iter().map(|row| {
|
||||
let status = if !row.enabled {
|
||||
McpServerStatus::Disabled
|
||||
} else if let Some(s) = servers.get(&row.name) {
|
||||
McpServerStatus::Running {
|
||||
tools: s.tools().iter().map(|t| t.name.clone()).collect(),
|
||||
}
|
||||
} else if let Some(e) = errors.get(&row.name) {
|
||||
McpServerStatus::Error { message: e.clone() }
|
||||
} else {
|
||||
McpServerStatus::Error { message: "not connected".to_string() }
|
||||
};
|
||||
McpServerInfo {
|
||||
name: row.name,
|
||||
transport: row.transport,
|
||||
description: row.description,
|
||||
friendly_name: row.friendly_name,
|
||||
status,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(infos)
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Vec<McpTool> {
|
||||
@@ -405,6 +376,68 @@ impl McpManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// A server to connect: its transport config plus the description shown in the
|
||||
/// "Available MCP servers" prompt section. Decouples [`McpManager`] from any DB
|
||||
/// table — the global and per-user runtimes each build these from their own rows
|
||||
/// (`global_row_spec` / `user_row_spec`).
|
||||
pub struct McpServerSpec {
|
||||
pub config: McpServerConfig,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
fn transport_of(s: &str) -> McpTransport {
|
||||
match s {
|
||||
"http" => McpTransport::Http,
|
||||
"sse" => McpTransport::Sse,
|
||||
_ => McpTransport::Stdio,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a spec for a globally-active connector — host transport (`launch_in`
|
||||
/// = None), so it runs in the Skald process, not in any container (§7).
|
||||
pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow) -> McpServerSpec {
|
||||
McpServerSpec {
|
||||
config: McpServerConfig {
|
||||
name: row.name.clone(),
|
||||
transport: transport_of(&row.transport),
|
||||
command: row.command.clone(),
|
||||
args: Some(row.args()).filter(|v| !v.is_empty()),
|
||||
env: Some(row.env()).filter(|m| !m.is_empty()),
|
||||
url: row.url.clone(),
|
||||
api_key: row.api_key.clone(),
|
||||
launch_in: None,
|
||||
},
|
||||
description: row.description.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a spec for a user's per-user connector — container transport: a
|
||||
/// `local_script` (or any stdio server) runs INSIDE the user's container
|
||||
/// (`launch_in = Some(container)`), against the script copied into the
|
||||
/// bind-mounted home. Remote (HTTP) connectors ignore `launch_in`.
|
||||
pub fn user_row_spec(
|
||||
row: &crate::db::mcp_user_servers::McpUserServerRow,
|
||||
container: &str,
|
||||
) -> McpServerSpec {
|
||||
let transport = transport_of(&row.transport);
|
||||
let launch_in = matches!(transport, McpTransport::Stdio).then(|| container.to_string());
|
||||
McpServerSpec {
|
||||
config: McpServerConfig {
|
||||
name: row.name.clone(),
|
||||
transport,
|
||||
command: row.command.clone(),
|
||||
args: Some(row.args()).filter(|v| !v.is_empty()),
|
||||
env: Some(row.env()).filter(|m| !m.is_empty()),
|
||||
url: row.url.clone(),
|
||||
api_key: row.api_key.clone(),
|
||||
launch_in,
|
||||
},
|
||||
// A per-user connector's description falls back to its catalog name; the
|
||||
// catalog's friendly description can be injected by the caller if richer.
|
||||
description: row.catalog_name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a 32-char alphanumeric id for a persisted media filename
|
||||
/// (mirrors `ImageGeneratorManager`).
|
||||
fn random_id() -> String {
|
||||
|
||||
Reference in New Issue
Block a user