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:
+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();
|
||||
|
||||
Reference in New Issue
Block a user