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.
150 lines
5.7 KiB
Rust
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))
|
|
}
|