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:
@@ -0,0 +1,177 @@
|
||||
//! The admin-curated catalog of installable MCP connectors (blueprint §14/§15).
|
||||
//!
|
||||
//! Registry table in `system.db`: instance-wide, listable without any user key so
|
||||
//! the "Connectors" UI can render it. Each entry is a *template* — a per-user
|
||||
//! connector is later instantiated from it into a `{userid}.db`
|
||||
//! (`mcp_user_servers`), or a global one is enabled by the admin
|
||||
//! (`mcp_global_servers`). No live credential ever lands here: `config_schema_json`
|
||||
//! only names the env/secret keys an activation must collect.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct McpCatalogRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// 'per_user' | 'global' — which category (§15) this entry can be activated as.
|
||||
pub scope: String,
|
||||
/// 'remote' | 'local_script' — the §14 risk axis.
|
||||
pub source: String,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
/// local_script: the vetted source path under `./scripts`.
|
||||
pub script_path: Option<String>,
|
||||
/// Names of the env/secret keys the activation UI must collect (never values).
|
||||
pub config_schema_json: Option<String>,
|
||||
/// 'none'|'api_key'|'oauth'|'qr'|'ssh_key'. Only 'none'/'api_key' are wired now.
|
||||
pub auth_kind: String,
|
||||
/// JSON array of role ids allowed to activate this; NULL = all roles (§15).
|
||||
pub role_filter: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl McpCatalogRow {
|
||||
pub fn args(&self) -> Vec<String> {
|
||||
self.args_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn env(&self) -> HashMap<String, String> {
|
||||
self.env_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The role ids allowed to activate this entry, or `None` when unrestricted.
|
||||
pub fn allowed_roles(&self) -> Option<Vec<String>> {
|
||||
self.role_filter.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
||||
}
|
||||
|
||||
/// Whether a user in `role_id` may activate this entry (§15 per-role catalog).
|
||||
pub fn allowed_for_role(&self, role_id: &str) -> bool {
|
||||
match self.allowed_roles() {
|
||||
None => true,
|
||||
Some(roles) => roles.iter().any(|r| r == role_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, name, scope, source, transport, command, args_json, env_json, url, \
|
||||
script_path, config_schema_json, auth_kind, role_filter, friendly_name, \
|
||||
description, created_at \
|
||||
FROM mcp_catalog";
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<McpCatalogRow>> {
|
||||
let rows = sqlx::query_as::<_, McpCatalogRow>(sqlx::AssertSqlSafe(format!("{SELECT} ORDER BY name")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Catalog entries in a given scope ('per_user' | 'global').
|
||||
pub async fn list_for_scope(pool: &SqlitePool, scope: &str) -> Result<Vec<McpCatalogRow>> {
|
||||
let rows = sqlx::query_as::<_, McpCatalogRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE scope = ? ORDER BY name")))
|
||||
.bind(scope)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Option<McpCatalogRow>> {
|
||||
let row = sqlx::query_as::<_, McpCatalogRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE name = ?")))
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<McpCatalogRow>> {
|
||||
let row = sqlx::query_as::<_, McpCatalogRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fields for creating or updating a catalog entry (keyed on `name`).
|
||||
pub struct UpsertCatalog<'a> {
|
||||
pub name: &'a str,
|
||||
pub scope: &'a str,
|
||||
pub source: &'a str,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub script_path: Option<&'a str>,
|
||||
pub config_schema_json: Option<String>,
|
||||
pub auth_kind: &'a str,
|
||||
pub role_filter: Option<String>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"INSERT INTO mcp_catalog
|
||||
(name, scope, source, transport, command, args_json, env_json, url,
|
||||
script_path, config_schema_json, auth_kind, role_filter, friendly_name, description)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
scope = excluded.scope,
|
||||
source = excluded.source,
|
||||
transport = excluded.transport,
|
||||
command = excluded.command,
|
||||
args_json = excluded.args_json,
|
||||
env_json = excluded.env_json,
|
||||
url = excluded.url,
|
||||
script_path = excluded.script_path,
|
||||
config_schema_json = excluded.config_schema_json,
|
||||
auth_kind = excluded.auth_kind,
|
||||
role_filter = excluded.role_filter,
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(e.name)
|
||||
.bind(e.scope)
|
||||
.bind(e.source)
|
||||
.bind(e.transport)
|
||||
.bind(e.command)
|
||||
.bind(e.args_json)
|
||||
.bind(e.env_json)
|
||||
.bind(e.url)
|
||||
.bind(e.script_path)
|
||||
.bind(e.config_schema_json)
|
||||
.bind(e.auth_kind)
|
||||
.bind(e.role_filter)
|
||||
.bind(e.friendly_name)
|
||||
.bind(e.description)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM mcp_catalog WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Which users may use each globally-active MCP connector (blueprint §15).
|
||||
//!
|
||||
//! Registry junction table in `system.db` — the per-user access filter over
|
||||
//! `mcp_global_servers`. Both FKs are registry→registry (allowed), mirroring
|
||||
//! `shared_folder_members`. The admin UI's "grant to all / by role" is just a
|
||||
//! convenience that inserts rows here.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The names of the **enabled** global servers a user may use. Feeds the
|
||||
/// `accessible_global` snapshot captured when the user's context is built.
|
||||
pub async fn server_names_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT s.name
|
||||
FROM mcp_global_access a
|
||||
JOIN mcp_global_servers s ON s.id = a.server_id
|
||||
WHERE a.user_id = ? AND s.enabled = 1
|
||||
ORDER BY s.name",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(n,)| n).collect())
|
||||
}
|
||||
|
||||
/// The ids of the users granted access to a given global server.
|
||||
pub async fn users_for_server(pool: &SqlitePool, server_id: i64) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT user_id FROM mcp_global_access WHERE server_id = ? ORDER BY user_id",
|
||||
)
|
||||
.bind(server_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(u,)| u).collect())
|
||||
}
|
||||
|
||||
pub async fn has_access(pool: &SqlitePool, server_id: i64, user_id: &str) -> Result<bool> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"SELECT 1 FROM mcp_global_access WHERE server_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(server_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Grants a user access to a global server. Idempotent on the PK.
|
||||
pub async fn grant(pool: &SqlitePool, server_id: i64, user_id: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO mcp_global_access (server_id, user_id) VALUES (?, ?)",
|
||||
)
|
||||
.bind(server_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke(pool: &SqlitePool, server_id: i64, user_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM mcp_global_access WHERE server_id = ? AND user_id = ?")
|
||||
.bind(server_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces the full access list for a server in one shot (used by the admin UI's
|
||||
/// "set who can use this" form, incl. the by-role bulk grant).
|
||||
pub async fn set_access(pool: &SqlitePool, server_id: i64, user_ids: &[String]) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("DELETE FROM mcp_global_access WHERE server_id = ?")
|
||||
.bind(server_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for user_id in user_ids {
|
||||
sqlx::query("INSERT OR IGNORE INTO mcp_global_access (server_id, user_id) VALUES (?, ?)")
|
||||
.bind(server_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Globally-active MCP connectors (blueprint §7/§15b): shared, stateless servers
|
||||
//! (web-search, Tavily…) that run on the HOST and are offered to every user the
|
||||
//! admin grants access to (`mcp_global_access`).
|
||||
//!
|
||||
//! Registry table in `system.db`. The global secret (the admin's API key) is fine
|
||||
//! here — `system.db` is admin-owned (§4). `catalog_name` is a registry→registry
|
||||
//! FK to `mcp_catalog(name)` (both in this file), which is allowed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct McpGlobalServerRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub catalog_name: Option<String>,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl McpGlobalServerRow {
|
||||
pub fn args(&self) -> Vec<String> {
|
||||
self.args_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn env(&self) -> HashMap<String, String> {
|
||||
self.env_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, name, catalog_name, transport, command, args_json, env_json, url, \
|
||||
api_key, friendly_name, description, enabled \
|
||||
FROM mcp_global_servers";
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn all(pool: &SqlitePool) -> Result<Vec<McpGlobalServerRow>> {
|
||||
let rows = sqlx::query_as::<_, McpGlobalServerRow>(sqlx::AssertSqlSafe(format!("{SELECT} ORDER BY name")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn all_enabled(pool: &SqlitePool) -> Result<Vec<McpGlobalServerRow>> {
|
||||
let rows = sqlx::query_as::<_, McpGlobalServerRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE enabled = 1 ORDER BY name")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<McpGlobalServerRow>> {
|
||||
let row = sqlx::query_as::<_, McpGlobalServerRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Option<McpGlobalServerRow>> {
|
||||
let row = sqlx::query_as::<_, McpGlobalServerRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE name = ?")))
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct UpsertGlobal<'a> {
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"INSERT INTO mcp_global_servers
|
||||
(name, catalog_name, transport, command, args_json, env_json, url, api_key, friendly_name, description, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
catalog_name = excluded.catalog_name,
|
||||
transport = excluded.transport,
|
||||
command = excluded.command,
|
||||
args_json = excluded.args_json,
|
||||
env_json = excluded.env_json,
|
||||
url = excluded.url,
|
||||
api_key = excluded.api_key,
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description,
|
||||
enabled = 1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(p.name)
|
||||
.bind(p.catalog_name)
|
||||
.bind(p.transport)
|
||||
.bind(p.command)
|
||||
.bind(p.args_json)
|
||||
.bind(p.env_json)
|
||||
.bind(p.url)
|
||||
.bind(p.api_key)
|
||||
.bind(p.friendly_name)
|
||||
.bind(p.description)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<()> {
|
||||
sqlx::query("UPDATE mcp_global_servers SET enabled = ?1 WHERE id = ?2")
|
||||
.bind(enabled as i64)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM mcp_global_servers WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl McpServerRow {
|
||||
pub fn args(&self) -> Vec<String> {
|
||||
self.args_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn env(&self) -> HashMap<String, String> {
|
||||
self.env_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
type RawRow = (i64, String, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, i64);
|
||||
|
||||
fn from_raw(r: RawRow) -> McpServerRow {
|
||||
McpServerRow {
|
||||
id: r.0,
|
||||
name: r.1,
|
||||
transport: r.2,
|
||||
command: r.3,
|
||||
args_json: r.4,
|
||||
env_json: r.5,
|
||||
url: r.6,
|
||||
api_key: r.7,
|
||||
description: r.8,
|
||||
friendly_name: r.9,
|
||||
enabled: r.10 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, name, transport, command, args_json, env_json, url, api_key, description, friendly_name, enabled \
|
||||
FROM mcp_servers";
|
||||
|
||||
pub async fn all(pool: &SqlitePool) -> Result<Vec<McpServerRow>> {
|
||||
let rows = sqlx::query_as::<_, RawRow>(sqlx::AssertSqlSafe(format!("{SELECT} ORDER BY name")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(from_raw).collect())
|
||||
}
|
||||
|
||||
pub async fn all_enabled(pool: &SqlitePool) -> Result<Vec<McpServerRow>> {
|
||||
let rows = sqlx::query_as::<_, RawRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE enabled = 1 ORDER BY name")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(from_raw).collect())
|
||||
}
|
||||
|
||||
pub struct UpsertParams<'a> {
|
||||
pub name: &'a str,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &SqlitePool, p: UpsertParams<'_>) -> Result<i64> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"INSERT INTO mcp_servers (name, transport, command, args_json, env_json, url, api_key, description, friendly_name, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
transport = excluded.transport,
|
||||
command = excluded.command,
|
||||
args_json = excluded.args_json,
|
||||
env_json = excluded.env_json,
|
||||
url = excluded.url,
|
||||
api_key = excluded.api_key,
|
||||
description = excluded.description,
|
||||
friendly_name = excluded.friendly_name,
|
||||
enabled = 1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(p.name)
|
||||
.bind(p.transport)
|
||||
.bind(p.command)
|
||||
.bind(p.args_json)
|
||||
.bind(p.env_json)
|
||||
.bind(p.url)
|
||||
.bind(p.api_key)
|
||||
.bind(p.description)
|
||||
.bind(p.friendly_name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, name: &str, enabled: bool) -> Result<()> {
|
||||
sqlx::query("UPDATE mcp_servers SET enabled = ?1 WHERE name = ?2")
|
||||
.bind(enabled as i64)
|
||||
.bind(name)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, name: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM mcp_servers WHERE name = ?1")
|
||||
.bind(name)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! A user's activated per-user MCP connectors (blueprint §7/§14).
|
||||
//!
|
||||
//! Owner table in each `{userid}.db` — encrypted at rest (SQLCipher), so `api_key`
|
||||
//! (a personal secret / OAuth refresh token) needs no column-level crypto.
|
||||
//! `catalog_name` is a BARE `TEXT` snapshot of `mcp_catalog.name`, never a FK: an
|
||||
//! owner→registry key would fail every INSERT under `PRAGMA foreign_keys=ON` in an
|
||||
//! isolated file. Local-script connectors run INSIDE the user's container against a
|
||||
//! script copied into the bind-mounted home (`script_rel_path`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct McpUserServerRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Bare snapshot of the originating `mcp_catalog.name`; NULL for a
|
||||
/// self-registered remote.
|
||||
pub catalog_name: Option<String>,
|
||||
/// 'remote' | 'local_script'.
|
||||
pub source: String,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
/// Container path of the copied script, for a `local_script`.
|
||||
pub script_rel_path: Option<String>,
|
||||
/// 'pending' | 'ready' — the interactive-auth gate ('ready' while api-key).
|
||||
pub auth_state: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl McpUserServerRow {
|
||||
pub fn args(&self) -> Vec<String> {
|
||||
self.args_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn env(&self) -> HashMap<String, String> {
|
||||
self.env_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, name, catalog_name, source, transport, command, args_json, env_json, url, \
|
||||
api_key, script_rel_path, auth_state, enabled \
|
||||
FROM mcp_user_servers";
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn all(pool: &SqlitePool) -> Result<Vec<McpUserServerRow>> {
|
||||
let rows = sqlx::query_as::<_, McpUserServerRow>(sqlx::AssertSqlSafe(format!("{SELECT} ORDER BY name")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The enabled, `auth_state='ready'` connectors — the set the per-user runtime
|
||||
/// starts at login. A 'pending' one is activated but not yet authenticated.
|
||||
pub async fn all_startable(pool: &SqlitePool) -> Result<Vec<McpUserServerRow>> {
|
||||
let rows = sqlx::query_as::<_, McpUserServerRow>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE enabled = 1 AND auth_state = 'ready' ORDER BY name"
|
||||
)))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<McpUserServerRow>> {
|
||||
let row = sqlx::query_as::<_, McpUserServerRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Option<McpUserServerRow>> {
|
||||
let row = sqlx::query_as::<_, McpUserServerRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE name = ?")))
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct InsertUserServer<'a> {
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub script_rel_path: Option<&'a str>,
|
||||
pub auth_state: &'a str,
|
||||
}
|
||||
|
||||
pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO mcp_user_servers
|
||||
(name, catalog_name, source, transport, command, args_json, env_json, url, api_key, script_rel_path, auth_state, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 1)",
|
||||
)
|
||||
.bind(s.name)
|
||||
.bind(s.catalog_name)
|
||||
.bind(s.source)
|
||||
.bind(s.transport)
|
||||
.bind(s.command)
|
||||
.bind(s.args_json)
|
||||
.bind(s.env_json)
|
||||
.bind(s.url)
|
||||
.bind(s.api_key)
|
||||
.bind(s.script_rel_path)
|
||||
.bind(s.auth_state)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<()> {
|
||||
sqlx::query("UPDATE mcp_user_servers SET enabled = ?1 WHERE id = ?2")
|
||||
.bind(enabled as i64)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_auth_state(pool: &SqlitePool, id: i64, auth_state: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE mcp_user_servers SET auth_state = ?1 WHERE id = ?2")
|
||||
.bind(auth_state)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM mcp_user_servers WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+117
-21
@@ -11,10 +11,14 @@ pub mod job_runs;
|
||||
pub mod known_tools;
|
||||
pub mod llm_requests;
|
||||
pub mod llm_request_payloads;
|
||||
pub mod mcp_catalog;
|
||||
pub mod mcp_events;
|
||||
pub mod mcp_servers;
|
||||
pub mod mcp_global_access;
|
||||
pub mod mcp_global_servers;
|
||||
pub mod mcp_user_servers;
|
||||
pub mod memory_docs;
|
||||
pub mod plugins;
|
||||
pub mod role_capabilities;
|
||||
pub mod roles;
|
||||
pub mod scheduled_jobs;
|
||||
pub mod scratchpad;
|
||||
@@ -419,6 +423,86 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// ── MCP catalog + globally-active instances (blueprint §7/§14/§15) ──────────
|
||||
//
|
||||
// Registry tables: instance-wide MCP config, listable without any user key so
|
||||
// the admin can render the "Connectors" catalog. The catalog is the admin's
|
||||
// vetted set of installable connectors; a user later *instantiates* a per-user
|
||||
// one into their own `{userid}.db` (`mcp_user_servers`, owner bucket) or the
|
||||
// admin *enables* a global one here. Per-user credentials never land here — the
|
||||
// catalog holds only the *schema* of what an activation must supply.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_catalog (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
scope TEXT NOT NULL, -- 'per_user' | 'global'
|
||||
source TEXT NOT NULL, -- 'remote' | 'local_script'
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
script_path TEXT, -- local_script: source under ./scripts
|
||||
config_schema_json TEXT, -- names of env/secret keys the UI must collect
|
||||
auth_kind TEXT NOT NULL DEFAULT 'none', -- 'none'|'api_key'|'oauth'|'qr'|'ssh_key'
|
||||
role_filter TEXT, -- JSON array of role ids; NULL = all
|
||||
friendly_name TEXT,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Concrete globally-active connectors (shared, stateless — web-search etc.).
|
||||
// They run on the HOST. The global secret (admin's API key) is fine here:
|
||||
// `system.db` is admin-owned (§4/§15b). `catalog_name` is a registry→registry
|
||||
// FK (both in this file) — allowed.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_global_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT REFERENCES mcp_catalog(name),
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT,
|
||||
friendly_name TEXT,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Which users may use each globally-active connector (§15 per-user access).
|
||||
// Mirrors `shared_folder_members`: both FKs are registry→registry, allowed.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_global_access (
|
||||
server_id INTEGER NOT NULL REFERENCES mcp_global_servers(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (server_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Capability grants per role (blueprint §14). A single indexed lookup instead
|
||||
// of parsing `roles.attrs`. `admin` implicitly holds every capability (checked
|
||||
// in code), so only non-admin roles need rows here.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS role_capabilities (
|
||||
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
capability TEXT NOT NULL,
|
||||
PRIMARY KEY (role_id, capability)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -626,25 +710,6 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT,
|
||||
description TEXT,
|
||||
friendly_name TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -667,6 +732,35 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// A user's activated per-user connectors (blueprint §7/§14). Owner table:
|
||||
// encrypted at rest in `{userid}.db`, so `api_key` (a personal secret / OAuth
|
||||
// refresh token) needs no column-level crypto. `catalog_name` is a BARE `TEXT`
|
||||
// snapshot of `mcp_catalog.name`, never a FK — an owner→registry key would pass
|
||||
// CREATE TABLE and fail every INSERT under `PRAGMA foreign_keys=ON` in an
|
||||
// isolated file (guarded by `owner_tables_stand_alone_with_foreign_keys_on`).
|
||||
// Local-script connectors run INSIDE the user's container against a script
|
||||
// copied into the bind-mounted home (`script_rel_path`).
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_user_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT, -- bare ref to mcp_catalog.name; NULL = self-registered remote
|
||||
source TEXT NOT NULL, -- 'remote' | 'local_script'
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT, -- per-user secret / OAuth refresh token
|
||||
script_rel_path TEXT, -- container path for a local_script
|
||||
auth_state TEXT NOT NULL DEFAULT 'ready', -- 'pending' | 'ready'
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -841,7 +935,9 @@ mod tests {
|
||||
one("INSERT INTO scheduled_jobs (id, title, cron, prompt, session_id) VALUES (1, 't', '* * * * *', 'p', 1)")
|
||||
.await.unwrap();
|
||||
one("INSERT INTO job_runs (job_id, started_at, status) VALUES (1, 'now', 'completed')").await.unwrap();
|
||||
one("INSERT INTO mcp_servers (name) VALUES ('srv')").await.unwrap();
|
||||
// Owner table with a BARE `catalog_name` ref — proves it stands alone with
|
||||
// FKs on (an owner→registry FK here would die on this INSERT).
|
||||
one("INSERT INTO mcp_user_servers (name, catalog_name, source) VALUES ('u', 'whatsapp', 'local_script')").await.unwrap();
|
||||
one("INSERT INTO mcp_events (source, method, payload) VALUES ('s', 'm', '{}')").await.unwrap();
|
||||
one("INSERT INTO sources (id, active_session_id) VALUES ('web', 1)").await.unwrap();
|
||||
one("INSERT INTO secrets (key, value) VALUES ('k', 'v')").await.unwrap();
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Capability grants per role (blueprint §14).
|
||||
//!
|
||||
//! Registry table in `system.db`. Authorization is a **capability on the role**,
|
||||
//! not `if role == admin` (§0.1). The MCP registration axis (§14):
|
||||
//!
|
||||
//! - [`REGISTER_REMOTE`] / [`REGISTER_LOCAL_FROM_CATALOG`] — self-service, any user
|
||||
//! (egress-only / already-vetted code).
|
||||
//! - [`REGISTER_LOCAL_SCRIPT`] / [`MANAGE_CATALOG`] — admin-only (RCE / catalog
|
||||
//! curation).
|
||||
//!
|
||||
//! The built-in `admin` role implicitly holds every capability — [`has`] short-
|
||||
//! circuits on it — so only non-admin roles ever need rows here.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use super::roles::ADMIN_ROLE_ID;
|
||||
|
||||
/// Register a remote MCP into one's own scope (egress-only, self-service).
|
||||
pub const REGISTER_REMOTE: &str = "mcp.register_remote";
|
||||
/// Instantiate an admin-vetted local-script connector from the catalog.
|
||||
pub const REGISTER_LOCAL_FROM_CATALOG: &str = "mcp.register_local_from_catalog";
|
||||
/// Add a brand-new local script to the catalog (RCE surface — admin only).
|
||||
pub const REGISTER_LOCAL_SCRIPT: &str = "mcp.register_local_script";
|
||||
/// Curate the connector catalog (admin only).
|
||||
pub const MANAGE_CATALOG: &str = "mcp.manage_catalog";
|
||||
|
||||
/// The default capabilities of an ordinary (non-admin) user role.
|
||||
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether a role holds a capability. `admin` holds everything by construction.
|
||||
pub async fn has(pool: &SqlitePool, role_id: &str, capability: &str) -> Result<bool> {
|
||||
if role_id == ADMIN_ROLE_ID {
|
||||
return Ok(true);
|
||||
}
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"SELECT 1 FROM role_capabilities WHERE role_id = ? AND capability = ?",
|
||||
)
|
||||
.bind(role_id)
|
||||
.bind(capability)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
pub async fn list_for_role(pool: &SqlitePool, role_id: &str) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT capability FROM role_capabilities WHERE role_id = ? ORDER BY capability",
|
||||
)
|
||||
.bind(role_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(c,)| c).collect())
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn grant(pool: &SqlitePool, role_id: &str, capability: &str) -> Result<()> {
|
||||
sqlx::query("INSERT OR IGNORE INTO role_capabilities (role_id, capability) VALUES (?, ?)")
|
||||
.bind(role_id)
|
||||
.bind(capability)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke(pool: &SqlitePool, role_id: &str, capability: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM role_capabilities WHERE role_id = ? AND capability = ?")
|
||||
.bind(role_id)
|
||||
.bind(capability)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Seeds the standard non-admin capability set for a newly created role.
|
||||
/// Idempotent. No-op for `admin` (which holds everything implicitly).
|
||||
pub async fn seed_defaults(pool: &SqlitePool, role_id: &str) -> Result<()> {
|
||||
if role_id == ADMIN_ROLE_ID {
|
||||
return Ok(());
|
||||
}
|
||||
for cap in DEFAULT_USER_CAPABILITIES {
|
||||
grant(pool, role_id, cap).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user