Files
Skald-Circle/src/frontend/api/approval.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

150 lines
5.7 KiB
Rust

use axum::{
Json, Extension,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use skald_core::approval::NewApprovalRule;
use skald_core::db::approval_rules;
use skald_core::tool_catalog::{AllTools, McpServerMeta, ToolInfo};
use std::collections::HashSet;
use std::sync::Arc;
use skald_core::skald::Skald;
use super::{ApiError, guard::AuthUser, require_context};
// ── GET /api/approval/rules ───────────────────────────────────────────────────
pub async fn list_rules(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Value>, ApiError> {
let rules = approval_rules::list(skald.db()).await?;
Ok(Json(json!(rules)))
}
// ── POST /api/approval/rules ──────────────────────────────────────────────────
pub async fn create_rule(
State(skald): State<Arc<Skald>>,
Json(body): Json<NewApprovalRule>,
) -> Result<Json<Value>, ApiError> {
let id = approval_rules::insert(skald.db(), body).await?;
Ok(Json(json!({ "id": id })))
}
// ── PUT /api/approval/rules/:id ───────────────────────────────────────────────
#[derive(Deserialize)]
pub struct RulePath { pub id: i64 }
pub async fn update_rule(
State(skald): State<Arc<Skald>>,
Path(p): Path<RulePath>,
Json(body): Json<NewApprovalRule>,
) -> Result<Json<Value>, ApiError> {
approval_rules::update(skald.db(), p.id, body).await?;
Ok(Json(json!({ "ok": true })))
}
// ── DELETE /api/approval/rules/:id ────────────────────────────────────────────
pub async fn delete_rule(
State(skald): State<Arc<Skald>>,
Path(p): Path<RulePath>,
) -> Result<Json<Value>, ApiError> {
approval_rules::delete(skald.db(), p.id).await?;
Ok(Json(json!({ "ok": true })))
}
// ── POST /api/approval/pending/:request_id/resolve ───────────────────────────
//
// Resolve a pending approval by request_id, regardless of which session or
// source it belongs to. Useful for Telegram sub-agent approvals when the
// Telegram keyboard is unavailable.
#[derive(Deserialize)]
pub struct ResolvePath { pub request_id: i64 }
#[derive(Deserialize)]
pub struct ResolveBody {
/// "approve" (default) or "reject".
#[serde(default = "default_action")]
pub action: String,
#[serde(default)]
pub note: String,
}
fn default_action() -> String { "approve".to_string() }
pub async fn resolve_pending(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ResolvePath>,
Json(body): Json<ResolveBody>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
if body.action == "reject" {
// Pass the raw note; the waiting session builds the canonical message.
ctx.inbox.reject(p.request_id, body.note.clone()).await;
} else {
ctx.inbox.approve(p.request_id).await;
}
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action })))
}
// ── GET /api/approval/pending ─────────────────────────────────────────────────
//
// Returns all currently-pending approval requests (all sessions).
pub async fn list_pending(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let pending = ctx.inbox.list_pending().await.approvals;
Ok(Json(json!(pending)))
}
// ── GET /api/approval/tools ───────────────────────────────────────────────────
//
// Returns all available tools (built-in + MCP) so the frontend can show a
// picker with names and descriptions when creating approval rules.
pub async fn list_tools(
State(skald): State<Arc<Skald>>,
) -> Result<Json<AllTools>, ApiError> {
let mut tools = skald.catalog().list_all();
let server_rows = skald_core::db::mcp_global_servers::all(skald.db()).await?;
tools.mcp_servers = server_rows.into_iter()
.map(|r| (r.name, McpServerMeta { friendly_name: r.friendly_name, description: r.description }))
.collect();
// Merge dynamically-discovered tools (recorded by `ToolDiscovery` when they
// were offered to the LLM) that the catalog does not already surface — the
// interface/plugin/provider tools injected outside the `ToolRegistry`. This
// is what makes them configurable in the Security-groups grid. Names already
// known as built-in or MCP tools are deduped out; the rest are grouped under
// the "dynamic" category.
let discovered = skald_core::db::known_tools::all(skald.db()).await?;
let existing: HashSet<&str> = tools.built_in.iter()
.chain(tools.mcp.iter())
.map(|t| t.name.as_str())
.collect();
let mut extra: Vec<ToolInfo> = discovered.into_iter()
.filter(|k| !existing.contains(k.name.as_str()))
.map(|k| ToolInfo {
name: k.name,
description: k.description,
source: "built-in".into(),
server: None,
category: Some("dynamic".into()),
})
.collect();
drop(existing);
tools.built_in.append(&mut extra);
tools.built_in.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Json(tools))
}