Files
Skald-Circle/crates/skald-core/src/tools/activate_tools.rs
T
dguiducci 6d299472e3 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.
2026-07-16 16:44:12 +01:00

157 lines
6.2 KiB
Rust

use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use anyhow::Result;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::mcp::McpProvider;
use crate::tools::tool_names::CONFIG_GROUP;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
/// Per-session (or per-stack) tool that activates **tool groups** on demand.
///
/// A group is either:
/// - an **MCP server name** — loads that server's tools, or
/// - the reserved keyword `"config"` — loads all built-in `Config`-category
/// tools (system configuration: MCP/plugin/cron management, secrets).
///
/// When the LLM calls `activate_tools(["gmail", "config"])`:
/// - The in-memory grant set is updated immediately, so the group's tools appear
/// in the *next LLM round* of the current turn (via `all_tool_defs()`).
/// - If `stack_id` is `None` (root agent): grants are persisted to
/// `session_mcp_grants` — they survive across turns and restarts.
/// - If `stack_id` is `Some(id)` (sub-agent): grants are persisted to
/// `stack_mcp_grants` for that stack frame — they survive restarts but are
/// deleted when the frame terminates (`dispatch_call_agent` calls
/// `stack_mcp_grants::delete_for_stack` on cleanup).
///
/// The `session_mcp_grants` / `stack_mcp_grants` tables store the group string
/// verbatim, so `"config"` is persisted just like an MCP server name.
///
/// Not in the global `ToolRegistry` — injected as an `InterfaceTool` in
/// `build_agent_config` (root) and `dispatch_call_agent` (sub-agents).
pub struct ActivateTools {
pub pool: Arc<SqlitePool>,
pub session_id: i64,
/// `None` for root agents (session-scoped grants).
/// `Some(stack_id)` for sub-agents (stack-scoped grants, deleted on frame exit).
pub stack_id: Option<i64>,
pub mcp: Arc<dyn McpProvider>,
/// Shared in-memory grant set. Updated in-place on every call so subsequent
/// rounds within the same turn see the new tools via `all_tool_defs()`.
pub active_mcp_grants: Arc<RwLock<HashSet<String>>>,
}
impl Tool for ActivateTools {
fn name(&self) -> &str { crate::tools::tool_names::ACTIVATE_TOOLS }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
fn description(&self) -> &str {
"Activate one or more tool groups so their tools become available. \
A group is either an MCP server name (see the MCP list) or the reserved \
keyword `config`, which loads all system-configuration tools (managing \
MCP servers, plugins, scheduled cron jobs, and secrets). \
Pass an array of group names. \
Once activated, the tools are available from the next tool-call round onward."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": { "type": "string" },
"description": "Tool groups to activate: MCP server names and/or the reserved \
keyword \"config\" (e.g. [\"gmail\", \"config\"])."
}
},
"required": ["groups"]
})
}
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
let names = args["groups"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
.unwrap_or_else(|| "?".to_string());
truncate_label(&format!("activate tools [{names}]"), MAX_LABEL_SHORT)
}
fn execute(&self, args: Value) -> Result<String> {
let names: Vec<String> = args["groups"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("activate_tools: `groups` must be an array"))?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if names.is_empty() {
anyhow::bail!("activate_tools: `groups` is empty");
}
let available: HashSet<String> = self.mcp.tools()
.iter()
.map(|t| t.server_name.clone())
.collect();
let pool = Arc::clone(&self.pool);
let session_id = self.session_id;
let stack_id = self.stack_id;
let grants_set = Arc::clone(&self.active_mcp_grants);
// Persist to DB (session-scoped or stack-scoped) and update in-memory set.
// The reserved `config` group is stored verbatim, exactly like a server name.
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
for name in &names {
match stack_id {
None => {
crate::db::session_mcp_grants::grant(&pool, session_id, name).await?;
}
Some(sid) => {
crate::db::stack_mcp_grants::grant(&pool, sid, name).await?;
}
}
}
anyhow::Ok(())
})
})?;
// Update in-memory set so the next LLM round sees the new grants.
{
let mut set = grants_set.write()
.map_err(|_| anyhow::anyhow!("activate_tools: lock poisoned"))?;
for name in &names {
set.insert(name.clone());
}
}
let activated: Vec<String> = names.iter()
.map(|n| {
if n == CONFIG_GROUP {
// Built-in group — always available, no MCP server to reconnect.
format!("{n} ✓")
} else if available.contains(n) {
format!("{n} ✓")
} else {
format!("{n} (registered but not yet running — tools will appear after reconnect)")
}
})
.collect();
let scope = match stack_id {
None => "session".to_string(),
Some(s) => format!("stack {s}"),
};
Ok(format!(
"Tool groups activated for this {scope}: {}. \
Their tools are available from the next tool-call round.",
activated.join(", ")
))
}
}