From 6d299472e362464e22538b87d656e161fe1cdb4a Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Thu, 16 Jul 2026 16:44:12 +0100 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20Connectors=20=E2=80=94=20catalog?= =?UTF-8?q?=20+=20global=20vs=20per-user=20runtimes=20(=C2=A77/=C2=A714/?= =?UTF-8?q?=C2=A715)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 () 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. --- CLAUDE.md | 30 +- crates/mcp-client/src/config.rs | 5 + crates/mcp-client/src/server.rs | 43 +- crates/mcp-client/tests/elicitation.rs | 1 + crates/mcp-client/tests/logging.rs | 1 + crates/mcp-client/tests/pagination.rs | 1 + crates/mcp-client/tests/tasks.rs | 1 + crates/skald-core/src/db/mcp_catalog.rs | 177 +++++++ crates/skald-core/src/db/mcp_global_access.rs | 91 ++++ .../skald-core/src/db/mcp_global_servers.rs | 145 +++++ crates/skald-core/src/db/mcp_servers.rs | 129 ----- crates/skald-core/src/db/mcp_user_servers.rs | 156 ++++++ crates/skald-core/src/db/mod.rs | 138 ++++- crates/skald-core/src/db/role_capabilities.rs | 88 ++++ crates/skald-core/src/mcp/mod.rs | 203 ++++--- crates/skald-core/src/mcp/provider.rs | 109 ++++ .../src/session/handler/interface_tools.rs | 7 +- .../src/session/handler/message_builder.rs | 4 +- crates/skald-core/src/session/handler/mod.rs | 6 +- crates/skald-core/src/session/manager.rs | 8 +- crates/skald-core/src/skald/bundles.rs | 14 +- crates/skald-core/src/skald/mod.rs | 2 +- crates/skald-core/src/skald/user_context.rs | 70 ++- crates/skald-core/src/tools/activate_tools.rs | 4 +- crates/skald-core/src/tools/list_items.rs | 17 +- crates/skald-core/src/tools/mod.rs | 1 - crates/skald-core/src/tools/register_mcp.rs | 175 ------ crates/skald-core/src/tools/toggle_item.rs | 23 +- src/frontend/api/approval.rs | 2 +- src/frontend/api/mcp.rs | 404 +++++++++++++- src/frontend/api/mod.rs | 17 +- src/frontend/api/roles.rs | 4 + web/app.js | 2 + web/components/connectors.js | 498 ++++++++++++++++++ web/components/sidebar.js | 7 +- web/index.html | 1 + 36 files changed, 2100 insertions(+), 484 deletions(-) create mode 100644 crates/skald-core/src/db/mcp_catalog.rs create mode 100644 crates/skald-core/src/db/mcp_global_access.rs create mode 100644 crates/skald-core/src/db/mcp_global_servers.rs delete mode 100644 crates/skald-core/src/db/mcp_servers.rs create mode 100644 crates/skald-core/src/db/mcp_user_servers.rs create mode 100644 crates/skald-core/src/db/role_capabilities.rs create mode 100644 crates/skald-core/src/mcp/provider.rs delete mode 100644 crates/skald-core/src/tools/register_mcp.rs create mode 100644 web/components/connectors.js diff --git a/CLAUDE.md b/CLAUDE.md index 67e3431..4d62374 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Two rules keep the boundary real, and both are enforced by the compiler: | `crates/skald-core/src/users/` | `UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it | | `crates/skald-core/src/crypto/` | Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation) | | `src/config.rs` | Loads `config.yml`; LLM clients, strength/use_cases, data root. Also hosts `bootstrap_data_dir()` — under the `desktop` feature, relocates the process cwd to a per-user data dir when running inside a `.app` bundle (no-op in dev mode and headless mode) | -| `crates/skald-core/src/mcp/` | MCP client manager (connects to external MCP servers) | +| `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section | | `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration | | `crates/skald-core/src/cron/` | Scheduled job runner | | `crates/skald-core/src/compactor.rs` | Context compaction (summarises history when token budget exceeded) | @@ -100,8 +100,8 @@ Two rules keep the boundary real, and both are enforced by the compiler: The schema is split into two buckets (§5.1), and the split is the point: -- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `shared_folders` + `shared_folder_members`. The last two (accessor `db/shared_folders.rs`) are the **membership** of the on-disk shared folders (§6): a junction table so a member can be read-only (`can_write`) and so the container mount topology + the `shared/{X}` fs routing both query it. FK `shared_folder_members.user_id → users(id)` is registry→registry (same file), which is allowed — unlike an owner→registry key. -- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `mcp_servers`, `mcp_events`, `sources`, `secrets`, `projects`, `project_tickets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. +- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `role_capabilities`, `shared_folders` + `shared_folder_members`. The MCP four back the Connectors model (§7/§14/§15 — see its own section). The last two (accessor `db/shared_folders.rs`) are the **membership** of the on-disk shared folders (§6): a junction table so a member can be read-only (`can_write`) and so the container mount topology + the `shared/{X}` fs routing both query it. FK `shared_folder_members.user_id → users(id)` is registry→registry (same file), which is allowed — unlike an owner→registry key. +- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `projects`, `project_tickets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT. Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. **No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. Two keys crossed and were fixed: `chat_history.model_db_id` (dropped — write-only, and `llm_requests.model_name` already records the model) and `project_tickets.job_id` (fixed by moving `projects`/`project_tickets` into the owner bucket). @@ -109,7 +109,7 @@ The schema is split into two buckets (§5.1), and the split is the point: **Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → handler → `MessageBuilder`. `main` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`. -`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and `mcp_servers`/`mcp_events` (`SecretsStore` and `McpManager` are built on the system pool and shared by reference into every `UserContext`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4/§7/§14 scope decisions for secrets and MCP, not on call-site migration. +`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and the `mcp_events` lifecycle log (`SecretsStore` and the global `McpManager` are built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets (plus the residual global `mcp_events` log), not on call-site migration. `users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). @@ -130,7 +130,26 @@ Two views, **one storage**: the fs-tools run **host-side** in the Skald process **Containment** (`resolve_host_path`): every physical fs-tool op canonicalizes the resolved path (following symlinks) and prefix-checks it against its mount base, **fail-closed**. Since the same tree is writable from inside the container, a symlink planted there that points outside the home/shared root is caught here — the host-side tool never escapes the user's workspace. `grep_files` stays disk-only (regex ≠ FTS; memory → `memory_search`) but resolves its root the same way. `execute_cmd`'s `workdir` is an agent path mapped to its container path via `UserFs::to_container`. -The threading: `UserContext.fs` (built by `container::build_user_fs` at login, snapshotting shared memberships) → `ChatSessionManager` → `ChatSessionHandler.fs` → `ToolContext.fs`. `execute_cmd` cancellation caveat: dropping the `docker exec` client on /stop may not kill the in-container process (a robust stop tracking the PID + `docker exec … kill` is a follow-up). **MCP servers do not yet run in the container** — relocating the per-user stateful MCPs (WhatsApp/LinkedIn session-file collision, §7) into the container is the next round; the container infra here enables it. +The threading: `UserContext.fs` (built by `container::build_user_fs` at login, snapshotting shared memberships) → `ChatSessionManager` → `ChatSessionHandler.fs` → `ToolContext.fs`. `execute_cmd` cancellation caveat: dropping the `docker exec` client on /stop may not kill the in-container process (a robust stop tracking the PID + `docker exec … kill` is a follow-up). **Per-user MCP connectors now run inside this container** (§7) — the container infra enabled it; see the MCP connectors section. + +## MCP connectors (blueprint §7/§14/§15) + +MCP servers are surfaced to users as **"Connectors"** (UI naming; `mcp`/schema stays neutral, §0.1). The old single owner table `mcp_servers`, the agent-facing `register_mcp`/`delete_mcp` tools, and the `mcp` kinds of `list_items`/`toggle_item` are **gone**. Connectors are now admin-curated and user-activated through the Connectors UI/API — never written by the agent, which closes the §14 RCE vector (prompt-injection → agent writes+registers a local script → arbitrary code on the box). + +**Two runtimes, one view (§7).** A session's MCP tools are the **union** of: + +- **Global runtime** — shared, stateless connectors (web-search, Tavily…) that run on the **host**, connected at boot from `mcp_global_servers` by `McpManager::initialize`. Filtered per user by `mcp_global_access`. +- **Per-user runtime** — the connectors a user has activated, run **inside their container**, started at first login from that user's owner `mcp_user_servers` and living until restart (§9; the `docker exec -i` children die via `kill_on_drop` when the `UserContext` drops). + +`McpProvider` (`mcp/provider.rs`) is the trait the session code talks to, so `all_tool_defs` / `render_mcp_list` / `ActivateTools` never learn which runtime owns a server. `McpManager` implements it directly (used for the inert ownerless bundle, §19); `UserMcpView` implements it as `global ∪ user`, where `accessible_global` is a snapshot of `mcp_global_access` captured when the `UserContext` is built (like fs membership). Both runtimes share `McpManager::connect_all(specs, boot)`; `McpServerSpec` + `global_row_spec`/`user_row_spec` turn a DB row into a connectable spec (a per-user `local_script` spec targets the user's container). + +**Authorization is a capability on the role, not `if role==admin`** (§0.1/§14 — `db/role_capabilities.rs`): `mcp.register_remote` + `mcp.register_local_from_catalog` are self-service (seeded on every new role by `roles::create` via `seed_defaults`); `mcp.register_local_script` + `mcp.manage_catalog` are admin-only. `admin` holds every capability by construction (short-circuit in `has()`). API handlers gate through `require_cap`. + +**Tables** (see DB section) — registry: `mcp_catalog` (admin-vetted templates; holds only the *schema* of what an activation must supply, never 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). + +**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate). `connectors.js` (``) renders the user view (activate/deactivate + granted globals) always, and the admin view (catalog + global + per-server access grants) when `role_id === 'admin'`. + +**Deferred:** interactive per-user auth (the §15 OAuth-callback / QR / SSH elicitation flow) — only `none`/`api_key` auth kinds are wired. No boot seed of catalog presets yet; the admin populates the catalog from the UI. ## Sub-agent system @@ -232,6 +251,7 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/ | `agent-inbox.js` | `` | Pending approvals + clarifications from background sessions | | `approval-rules.js` | `` | Approval rule management | | `cron-jobs.js` | `` | Scheduled job management | +| `connectors.js` | `` | MCP Connectors: user activate/deactivate + granted globals; admin catalog + global-server + per-server access management (§7/§14/§15) | | `llm-providers.js` | `` | LLM provider management | | `models-hub.js` | `` | Models hub landing (LLM / Transcription / Image) | | `models-llm.js` | `` | LLM model CRUD + drag-and-drop priority | diff --git a/crates/mcp-client/src/config.rs b/crates/mcp-client/src/config.rs index 8cdf9b4..22f006e 100644 --- a/crates/mcp-client/src/config.rs +++ b/crates/mcp-client/src/config.rs @@ -16,6 +16,11 @@ pub struct McpServerConfig { pub url: Option, /// http only: API key sent as `Authorization: Bearer ` (supports `${VAR}` interpolation). pub api_key: Option, + /// stdio only: when `Some(container)`, the command runs INSIDE that Docker + /// container via `docker exec -i` instead of on the host. Set at runtime by + /// the manager (per-user connectors, blueprint §7), never parsed from config. + #[serde(skip)] + pub launch_in: Option, } #[derive(Debug, Deserialize, Clone)] diff --git a/crates/mcp-client/src/server.rs b/crates/mcp-client/src/server.rs index 277adc1..8da8c3e 100644 --- a/crates/mcp-client/src/server.rs +++ b/crates/mcp-client/src/server.rs @@ -233,15 +233,42 @@ impl McpServer { let command = cfg.command.as_deref() .ok_or_else(|| anyhow::anyhow!("stdio server '{}' requires 'command'", cfg.name))?; - let mut cmd = Command::new(command); - if let Some(args) = &cfg.args { - cmd.args(args); - } - if let Some(env_map) = &cfg.env { - for (k, v) in env_map { - cmd.env(k, interpolate_env(v)); + let mut cmd = match &cfg.launch_in { + // Host transport: spawn the command directly. + None => { + let mut c = Command::new(command); + if let Some(args) = &cfg.args { + c.args(args); + } + if let Some(env_map) = &cfg.env { + for (k, v) in env_map { + c.env(k, interpolate_env(v)); + } + } + c } - } + // Container transport (per-user connectors, blueprint §7): run the + // command INSIDE the user's container via `docker exec -i`. stdin/ + // stdout/stderr are proxied transparently, so the JSON-RPC read-loop, + // the stderr drain and elicitation write-back all work unchanged. Env + // is passed with `-e K=V` so it lands inside the container, not on the + // `docker` client. Workdir defaults to the image WORKDIR (`/root`, the + // bind-mounted home), so no `-w` coupling to skald-core's path layout. + Some(container) => { + let mut c = Command::new("docker"); + c.arg("exec").arg("-i"); + if let Some(env_map) = &cfg.env { + for (k, v) in env_map { + c.arg("-e").arg(format!("{k}={}", interpolate_env(v))); + } + } + c.arg(container).arg(command); + if let Some(args) = &cfg.args { + c.args(args); + } + c + } + }; cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) // Capture the child's stderr instead of inheriting it: many MCP diff --git a/crates/mcp-client/tests/elicitation.rs b/crates/mcp-client/tests/elicitation.rs index f5f0a8b..7ddd762 100644 --- a/crates/mcp-client/tests/elicitation.rs +++ b/crates/mcp-client/tests/elicitation.rs @@ -109,6 +109,7 @@ async fn elicitation_roundtrip_returns_secret_to_server() { env: None, url: None, api_key: None, + launch_in: None, }; let server = McpServer::start(&cfg, None, None, Some(Arc::new(AcceptHandler))) diff --git a/crates/mcp-client/tests/logging.rs b/crates/mcp-client/tests/logging.rs index 723f4af..161bf8d 100644 --- a/crates/mcp-client/tests/logging.rs +++ b/crates/mcp-client/tests/logging.rs @@ -94,6 +94,7 @@ async fn stderr_and_log_records_are_captured_and_diverted() { env: None, url: None, api_key: None, + launch_in: None, }; let (notif_tx, mut notif_rx) = mpsc::unbounded_channel::(); diff --git a/crates/mcp-client/tests/pagination.rs b/crates/mcp-client/tests/pagination.rs index 5bb1e22..0273d37 100644 --- a/crates/mcp-client/tests/pagination.rs +++ b/crates/mcp-client/tests/pagination.rs @@ -86,6 +86,7 @@ async fn tools_list_follows_next_cursor_across_pages() { env: None, url: None, api_key: None, + launch_in: None, }; let server = McpServer::start(&cfg, None, None, None) diff --git a/crates/mcp-client/tests/tasks.rs b/crates/mcp-client/tests/tasks.rs index 4462bb6..dcc667a 100644 --- a/crates/mcp-client/tests/tasks.rs +++ b/crates/mcp-client/tests/tasks.rs @@ -101,6 +101,7 @@ fn cfg(script: &std::path::Path, mode: &str, marker: Option<&std::path::Path>) - env: None, url: None, api_key: None, + launch_in: None, } } diff --git a/crates/skald-core/src/db/mcp_catalog.rs b/crates/skald-core/src/db/mcp_catalog.rs new file mode 100644 index 0000000..f159ed3 --- /dev/null +++ b/crates/skald-core/src/db/mcp_catalog.rs @@ -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, + pub args_json: Option, + pub env_json: Option, + pub url: Option, + /// local_script: the vetted source path under `./scripts`. + pub script_path: Option, + /// Names of the env/secret keys the activation UI must collect (never values). + pub config_schema_json: Option, + /// '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, + pub friendly_name: Option, + pub description: Option, + pub created_at: String, +} + +impl McpCatalogRow { + pub fn args(&self) -> Vec { + self.args_json.as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default() + } + + pub fn env(&self) -> HashMap { + 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> { + self.role_filter.as_deref() + .and_then(|s| serde_json::from_str::>(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> { + 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> { + 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> { + 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> { + 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, + pub env_json: Option, + pub url: Option<&'a str>, + pub script_path: Option<&'a str>, + pub config_schema_json: Option, + pub auth_kind: &'a str, + pub role_filter: Option, + pub friendly_name: Option<&'a str>, + pub description: Option<&'a str>, +} + +pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result { + 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(()) +} diff --git a/crates/skald-core/src/db/mcp_global_access.rs b/crates/skald-core/src/db/mcp_global_access.rs new file mode 100644 index 0000000..6101c05 --- /dev/null +++ b/crates/skald-core/src/db/mcp_global_access.rs @@ -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> { + 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> { + 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 { + 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(()) +} diff --git a/crates/skald-core/src/db/mcp_global_servers.rs b/crates/skald-core/src/db/mcp_global_servers.rs new file mode 100644 index 0000000..f4036cb --- /dev/null +++ b/crates/skald-core/src/db/mcp_global_servers.rs @@ -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, + pub transport: String, + pub command: Option, + pub args_json: Option, + pub env_json: Option, + pub url: Option, + pub api_key: Option, + pub friendly_name: Option, + pub description: Option, + pub enabled: bool, +} + +impl McpGlobalServerRow { + pub fn args(&self) -> Vec { + self.args_json.as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default() + } + + pub fn env(&self) -> HashMap { + 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> { + 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> { + 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> { + 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> { + 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, + pub env_json: Option, + 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 { + 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(()) +} diff --git a/crates/skald-core/src/db/mcp_servers.rs b/crates/skald-core/src/db/mcp_servers.rs deleted file mode 100644 index dcdb88f..0000000 --- a/crates/skald-core/src/db/mcp_servers.rs +++ /dev/null @@ -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, - pub args_json: Option, - pub env_json: Option, - pub url: Option, - pub api_key: Option, - pub description: Option, - pub friendly_name: Option, - pub enabled: bool, -} - -impl McpServerRow { - pub fn args(&self) -> Vec { - self.args_json.as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_default() - } - - pub fn env(&self) -> HashMap { - self.env_json.as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_default() - } -} - -type RawRow = (i64, String, String, Option, Option, Option, Option, Option, Option, Option, 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> { - 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> { - 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, - pub env_json: Option, - 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 { - 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(()) -} diff --git a/crates/skald-core/src/db/mcp_user_servers.rs b/crates/skald-core/src/db/mcp_user_servers.rs new file mode 100644 index 0000000..d8dbd37 --- /dev/null +++ b/crates/skald-core/src/db/mcp_user_servers.rs @@ -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, + /// 'remote' | 'local_script'. + pub source: String, + pub transport: String, + pub command: Option, + pub args_json: Option, + pub env_json: Option, + pub url: Option, + pub api_key: Option, + /// Container path of the copied script, for a `local_script`. + pub script_rel_path: Option, + /// 'pending' | 'ready' — the interactive-auth gate ('ready' while api-key). + pub auth_state: String, + pub enabled: bool, +} + +impl McpUserServerRow { + pub fn args(&self) -> Vec { + self.args_json.as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default() + } + + pub fn env(&self) -> HashMap { + 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> { + 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> { + 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> { + 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> { + 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, + pub env_json: Option, + 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 { + 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(()) +} diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index a8c8a32..5c6aca0 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -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(); diff --git a/crates/skald-core/src/db/role_capabilities.rs b/crates/skald-core/src/db/role_capabilities.rs new file mode 100644 index 0000000..4cbc9f2 --- /dev/null +++ b/crates/skald-core/src/db/role_capabilities.rs @@ -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 { + 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> { + 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(()) +} diff --git a/crates/skald-core/src/mcp/mod.rs b/crates/skald-core/src/mcp/mod.rs index 9fda435..39083dd 100644 --- a/crates/skald-core/src/mcp/mod.rs +++ b/crates/skald-core/src/mcp/mod.rs @@ -25,6 +25,9 @@ pub use mcp_client::{ use mcp_client::McpTransport; mod logs; +mod provider; + +pub use provider::{McpProvider, UserMcpView}; const SERVER_START_TIMEOUT_SECS: u64 = 120; @@ -116,22 +119,6 @@ impl McpManager { } } - fn cfg_from_row(row: &crate::db::mcp_servers::McpServerRow) -> McpServerConfig { - McpServerConfig { - name: row.name.clone(), - transport: match row.transport.as_str() { - "http" => McpTransport::Http, - "sse" => McpTransport::Sse, - _ => McpTransport::Stdio, - }, - command: row.command.clone(), - args: Some(row.args()).filter(|v| !v.is_empty()), - env: Some(row.env()).filter(|m| !m.is_empty()), - url: row.url.clone(), - api_key: row.api_key.clone(), - } - } - async fn start_one( cfg: &McpServerConfig, notification_tx: Option>, @@ -154,29 +141,49 @@ impl McpManager { } } + /// Connects the GLOBAL runtime at boot: reads the enabled globally-active + /// connectors (`mcp_global_servers`, host transport) and connects to them. + /// The per-user runtime (blueprint §7/§9) is built separately at login and + /// shares [`connect_all`] rather than this table-bound entry point. pub async fn initialize(&self) { - let rows = match crate::db::mcp_servers::all_enabled(&self.pool).await { + let rows = match crate::db::mcp_global_servers::all_enabled(&self.pool).await { Ok(r) => r, Err(e) => { warn!("McpManager::initialize: failed to read DB: {e}"); return; } }; if rows.is_empty() { - info!("No enabled MCP servers in DB — MCP disabled."); + info!("No enabled global MCP servers in DB — global MCP disabled."); crate::boot::section("MCP servers — none enabled"); return; } - let cfgs: Vec<_> = rows.iter().map(Self::cfg_from_row).collect(); + let specs = rows.iter().map(global_row_spec).collect(); + self.connect_all(specs, true).await; + } + + /// Connects to a batch of servers in parallel (each bounded by + /// `SERVER_START_TIMEOUT_SECS`), recording their tools, errors and prompt + /// descriptions. The reusable core shared by the global runtime + /// ([`initialize`]) and the per-user runtime (built at login, §7). `boot` + /// gates the curated boot-console lines, which only make sense at startup — + /// a login-time per-user connect passes `false`. + pub async fn connect_all(&self, specs: Vec, boot: bool) { + if specs.is_empty() { + return; + } { let mut descs = self.descriptions.write().unwrap(); - for row in &rows { - descs.insert(row.name.clone(), row.description.clone()); + for spec in &specs { + descs.insert(spec.config.name.clone(), spec.description.clone()); } } - crate::boot::section(format!( - "MCP servers — connecting to {} in background", cfgs.len() - )); - let handles: Vec<_> = cfgs.into_iter().map(|cfg| { + if boot { + crate::boot::section(format!( + "MCP servers — connecting to {} in background", specs.len() + )); + } + let handles: Vec<_> = specs.into_iter().map(|spec| { + let cfg = spec.config; let tx = self.notification_tx.clone(); let log_tx = self.log_tx.clone(); let eh = self.elicitation_handler(); @@ -186,30 +193,33 @@ impl McpManager { Duration::from_secs(SERVER_START_TIMEOUT_SECS), Self::start_one(&cfg, Some(tx), Some(log_tx), eh), ).await; - (cfg.name, cfg.transport, result) + (cfg.name, result) }) }).collect(); for handle in handles { match handle.await { - Ok((name, _, Ok(Ok(s)))) => { - let tool_names: Vec<_> = s.tools().iter().map(|t| t.name.as_str()).collect(); - info!("MCP server '{}' ready — {} tool(s): {}", name, tool_names.len(), tool_names.join(", ")); + Ok((name, Ok(Ok(s)))) => { + let tool_names: Vec<_> = s.tools().iter().map(|t| t.name.clone()).collect(); let n = tool_names.len(); - crate::boot::ok(format!("{name} ({n} tool{})", if n == 1 { "" } else { "s" })); + info!("MCP server '{}' ready — {n} tool(s): {}", name, tool_names.join(", ")); + if boot { + crate::boot::ok(format!("{name} ({n} tool{})", if n == 1 { "" } else { "s" })); + } self.log_lifecycle(&name, format!("connected — {n} tool(s)")); + self.errors.write().unwrap().remove(&name); self.servers.write().unwrap().insert(name, s); } - Ok((name, _, Ok(Err(e)))) => { + Ok((name, Ok(Err(e)))) => { warn!("MCP server '{}' failed to start: {e}", name); - crate::boot::fail(format!("{name} — {e}")); + if boot { crate::boot::fail(format!("{name} — {e}")); } self.log_lifecycle(&name, format!("failed to start: {e}")); self.errors.write().unwrap().insert(name, e.to_string()); } - Ok((name, _, Err(_))) => { + Ok((name, Err(_))) => { let msg = format!("startup timed out after {SERVER_START_TIMEOUT_SECS}s"); warn!("MCP server '{}' {msg}", name); - crate::boot::fail(format!("{name} — {msg}")); + if boot { crate::boot::fail(format!("{name} — {msg}")); } self.log_lifecycle(&name, &msg); self.errors.write().unwrap().insert(name, msg); } @@ -218,77 +228,38 @@ impl McpManager { } } - pub async fn register(&self, p: crate::db::mcp_servers::UpsertParams<'_>) -> Result> { - let name = p.name.to_string(); - - crate::db::mcp_servers::upsert(&self.pool, p).await?; - - let rows = crate::db::mcp_servers::all_enabled(&self.pool).await?; - let row = rows.into_iter().find(|r| r.name == name) - .ok_or_else(|| anyhow::anyhow!("register: server '{}' not found after upsert", name))?; - let cfg = Self::cfg_from_row(&row); - + /// Starts (or restarts) a single server from a spec and records it in the + /// runtime maps. The DB write is the caller's job (the Connectors activation + /// API) — this only touches the live connections. Returns the tool names. + pub async fn start_server(&self, spec: McpServerSpec) -> Result> { + let name = spec.config.name.clone(); let client = tokio::time::timeout( Duration::from_secs(SERVER_START_TIMEOUT_SECS), - Self::start_one(&cfg, Some(self.notification_tx.clone()), Some(self.log_tx.clone()), self.elicitation_handler()), + Self::start_one(&spec.config, Some(self.notification_tx.clone()), Some(self.log_tx.clone()), self.elicitation_handler()), ).await .map_err(|_| { self.log_lifecycle(&name, "timed out during connection"); - anyhow::anyhow!("MCP server '{}' timed out during connection", name) + anyhow::anyhow!("MCP server '{name}' timed out during connection") })? .map_err(|e| { self.log_lifecycle(&name, format!("failed to start: {e}")); - anyhow::anyhow!("MCP server '{}' failed to start: {e}", name) + anyhow::anyhow!("MCP server '{name}' failed to start: {e}") })?; let tool_names: Vec = client.tools().iter().map(|t| t.name.clone()).collect(); self.log_lifecycle(&name, format!("connected — {} tool(s)", tool_names.len())); self.errors.write().unwrap().remove(&name); - self.descriptions.write().unwrap().insert(name.clone(), row.description.clone()); + self.descriptions.write().unwrap().insert(name.clone(), spec.description); self.servers.write().unwrap().insert(name, client); - Ok(tool_names) } - pub async fn unregister(&self, name: &str) -> Result<()> { - crate::db::mcp_servers::delete(&self.pool, name).await?; + /// Stops a running server (dropping the client → `kill_on_drop`) and forgets + /// it. DB removal is the caller's responsibility. + pub fn stop_server(&self, name: &str) { self.servers.write().unwrap().remove(name); self.errors.write().unwrap().remove(name); self.descriptions.write().unwrap().remove(name); - Ok(()) - } - - pub async fn set_enabled(&self, name: &str, enabled: bool) -> Result<()> { - crate::db::mcp_servers::set_enabled(&self.pool, name, enabled).await - } - - pub async fn list(&self) -> Result> { - let rows = crate::db::mcp_servers::all(&self.pool).await?; - let servers = self.servers.read().unwrap(); - let errors = self.errors.read().unwrap(); - - let infos = rows.into_iter().map(|row| { - let status = if !row.enabled { - McpServerStatus::Disabled - } else if let Some(s) = servers.get(&row.name) { - McpServerStatus::Running { - tools: s.tools().iter().map(|t| t.name.clone()).collect(), - } - } else if let Some(e) = errors.get(&row.name) { - McpServerStatus::Error { message: e.clone() } - } else { - McpServerStatus::Error { message: "not connected".to_string() } - }; - McpServerInfo { - name: row.name, - transport: row.transport, - description: row.description, - friendly_name: row.friendly_name, - status, - } - }).collect(); - - Ok(infos) } pub fn tools(&self) -> Vec { @@ -405,6 +376,68 @@ impl McpManager { } } +/// A server to connect: its transport config plus the description shown in the +/// "Available MCP servers" prompt section. Decouples [`McpManager`] from any DB +/// table — the global and per-user runtimes each build these from their own rows +/// (`global_row_spec` / `user_row_spec`). +pub struct McpServerSpec { + pub config: McpServerConfig, + pub description: Option, +} + +fn transport_of(s: &str) -> McpTransport { + match s { + "http" => McpTransport::Http, + "sse" => McpTransport::Sse, + _ => McpTransport::Stdio, + } +} + +/// Builds a spec for a globally-active connector — host transport (`launch_in` +/// = None), so it runs in the Skald process, not in any container (§7). +pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow) -> McpServerSpec { + McpServerSpec { + config: McpServerConfig { + name: row.name.clone(), + transport: transport_of(&row.transport), + command: row.command.clone(), + args: Some(row.args()).filter(|v| !v.is_empty()), + env: Some(row.env()).filter(|m| !m.is_empty()), + url: row.url.clone(), + api_key: row.api_key.clone(), + launch_in: None, + }, + description: row.description.clone(), + } +} + +/// Builds a spec for a user's per-user connector — container transport: a +/// `local_script` (or any stdio server) runs INSIDE the user's container +/// (`launch_in = Some(container)`), against the script copied into the +/// bind-mounted home. Remote (HTTP) connectors ignore `launch_in`. +pub fn user_row_spec( + row: &crate::db::mcp_user_servers::McpUserServerRow, + container: &str, +) -> McpServerSpec { + let transport = transport_of(&row.transport); + let launch_in = matches!(transport, McpTransport::Stdio).then(|| container.to_string()); + McpServerSpec { + config: McpServerConfig { + name: row.name.clone(), + transport, + command: row.command.clone(), + args: Some(row.args()).filter(|v| !v.is_empty()), + env: Some(row.env()).filter(|m| !m.is_empty()), + url: row.url.clone(), + api_key: row.api_key.clone(), + launch_in, + }, + // A per-user connector's description falls back to its catalog name; the + // catalog's friendly description can be injected by the caller if richer. + description: row.catalog_name.clone(), + } +} + /// Generates a 32-char alphanumeric id for a persisted media filename /// (mirrors `ImageGeneratorManager`). fn random_id() -> String { diff --git a/crates/skald-core/src/mcp/provider.rs b/crates/skald-core/src/mcp/provider.rs new file mode 100644 index 0000000..dcedf5f --- /dev/null +++ b/crates/skald-core/src/mcp/provider.rs @@ -0,0 +1,109 @@ +//! The MCP tool surface a session sees, behind one trait. +//! +//! A logged-in user's tools are the union of two runtimes (blueprint §7): the +//! access-filtered GLOBAL runtime (host, shared) and their own PER-USER runtime +//! (in their container). [`McpProvider`] is the seam the session code talks to, +//! so the round-loop (`all_tool_defs`, `render_mcp_list`, `ActivateTools`) never +//! has to know which runtime owns a server. [`McpManager`] implements it directly +//! (used as-is for the inert ownerless bundle); [`UserMcpView`] implements it as +//! the union. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use serde_json::Value; + +use crate::tools::ToolResult; + +use super::{McpManager, McpTool}; + +#[async_trait] +pub trait McpProvider: Send + Sync { + fn tools(&self) -> Vec; + fn tools_for(&self, names: &[String]) -> Vec; + fn server_descriptions(&self) -> HashMap>; + fn server_infos(&self) -> Vec; + async fn call(&self, server: &str, tool: &str, args: Value) -> Result; +} + +#[async_trait] +impl McpProvider for McpManager { + fn tools(&self) -> Vec { McpManager::tools(self) } + fn tools_for(&self, names: &[String]) -> Vec { McpManager::tools_for(self, names) } + fn server_descriptions(&self) -> HashMap> { McpManager::server_descriptions(self) } + fn server_infos(&self) -> Vec { McpManager::server_infos(self) } + async fn call(&self, server: &str, tool: &str, args: Value) -> Result { + McpManager::call(self, server, tool, args).await + } +} + +/// One logged-in user's MCP view: the access-filtered global runtime unioned with +/// their per-user container runtime. A per-user server wins on a name collision +/// (which activation prevents anyway — see the uniqueness check at activation). +pub struct UserMcpView { + pub global: Arc, + pub user: Arc, + /// Names of the global servers this user may use — a snapshot of + /// `mcp_global_access`, captured when the user's context is built. + pub accessible_global: HashSet, +} + +impl UserMcpView { + fn accessible_names(&self) -> Vec { + self.accessible_global.iter().cloned().collect() + } +} + +#[async_trait] +impl McpProvider for UserMcpView { + fn tools(&self) -> Vec { + let mut out = self.global.tools_for(&self.accessible_names()); + out.extend(self.user.tools()); + out + } + + fn tools_for(&self, names: &[String]) -> Vec { + // A granted name belongs to exactly one runtime (unique per user); route + // the accessible-global ones to the global runtime and the rest to the + // per-user one, which filters to its own server map. + let global_names: Vec = names.iter() + .filter(|n| self.accessible_global.contains(*n)) + .cloned() + .collect(); + let mut out = self.global.tools_for(&global_names); + out.extend(self.user.tools_for(names)); + out + } + + fn server_descriptions(&self) -> HashMap> { + let mut m: HashMap> = self.global.server_descriptions() + .into_iter() + .filter(|(name, _)| self.accessible_global.contains(name)) + .collect(); + m.extend(self.user.server_descriptions()); + m + } + + fn server_infos(&self) -> Vec { + let mut v: Vec = self.global.server_infos() + .into_iter() + .filter(|info| info["name"].as_str() + .map(|n| self.accessible_global.contains(n)) + .unwrap_or(false)) + .collect(); + v.extend(self.user.server_infos()); + v + } + + async fn call(&self, server: &str, tool: &str, args: Value) -> Result { + if self.accessible_global.contains(server) { + self.global.call(server, tool, args).await + } else { + // A per-user server, or an unknown/forbidden one — the per-user + // runtime returns a "not found" error for the latter. + self.user.call(server, tool, args).await + } + } +} diff --git a/crates/skald-core/src/session/handler/interface_tools.rs b/crates/skald-core/src/session/handler/interface_tools.rs index d7058cc..edb3e89 100644 --- a/crates/skald-core/src/session/handler/interface_tools.rs +++ b/crates/skald-core/src/session/handler/interface_tools.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, RwLock}; use serde_json::Value; -use crate::mcp::McpManager; +use crate::mcp::McpProvider; use crate::tools::Tool; use crate::tools::tool_names as tn; @@ -51,8 +51,9 @@ pub struct AgentRunConfig { pub memory_tools: Vec>, /// Image generation tools — present only when at least one provider is registered. pub image_tools: Vec>, - /// MCP manager — used by `all_tool_defs()` to resolve which tools to include. - pub mcp: Arc, + /// MCP provider (global ∪ per-user) — used by `all_tool_defs()` to resolve + /// which tools to include. + pub mcp: Arc, /// Set of MCP server names currently granted (activated) for this agent run. /// /// - Root agents: pre-populated from `session_mcp_grants` DB at config-build time; diff --git a/crates/skald-core/src/session/handler/message_builder.rs b/crates/skald-core/src/session/handler/message_builder.rs index 1001425..36c621d 100644 --- a/crates/skald-core/src/session/handler/message_builder.rs +++ b/crates/skald-core/src/session/handler/message_builder.rs @@ -7,7 +7,7 @@ use sqlx::SqlitePool; use crate::compactor::{ContextCompactor, SUMMARY_PREFIX}; use crate::config::DatetimeConfig; use crate::db::{chat_history, chat_llm_tools, chat_summaries}; -use crate::mcp::McpManager; +use crate::mcp::McpProvider; use crate::tools::tool_names as tn; /// Registry of installed skills, relative to Skald's process cwd. Injected into agents @@ -38,7 +38,7 @@ pub struct MessageBuilder { /// owner `pool` above backs `user-memory/`. pub shared_pool: Arc, pub session_id: i64, - pub mcp: Arc, + pub mcp: Arc, pub datetime_config: DatetimeConfig, pub max_history_messages: usize, pub max_tool_result_chars: Option, diff --git a/crates/skald-core/src/session/handler/mod.rs b/crates/skald-core/src/session/handler/mod.rs index 28039d9..8b865ca 100644 --- a/crates/skald-core/src/session/handler/mod.rs +++ b/crates/skald-core/src/session/handler/mod.rs @@ -22,7 +22,7 @@ use crate::events::ServerEvent; use core_api::message_meta::MessageMetadata; use core_api::user_fs::UserFs; use crate::llm::LlmManager; -use crate::mcp::McpManager; +use crate::mcp::McpProvider; use crate::image_generate::ImageGeneratorManager; use crate::memory::MemoryManager; use crate::tool_discovery::ToolDiscovery; @@ -292,7 +292,7 @@ pub struct ChatSessionHandler { /// True for short-lived automated sessions (cron, tic). pub(super) is_ephemeral: bool, pub(super) tools: Arc, - pub(super) mcp: Arc, + pub(super) mcp: Arc, /// Records tools offered to the LLM each round so the Security-groups UI can /// list/gate dynamically-injected tools (interface/plugin/provider tools). pub(super) tool_discovery: Arc, @@ -354,7 +354,7 @@ impl ChatSessionHandler { is_interactive: bool, is_ephemeral: bool, tools: Arc, - mcp: Arc, + mcp: Arc, approval: Arc, clarification: Arc, event_bus: Arc, diff --git a/crates/skald-core/src/session/manager.rs b/crates/skald-core/src/session/manager.rs index bac1b81..b96861c 100644 --- a/crates/skald-core/src/session/manager.rs +++ b/crates/skald-core/src/session/manager.rs @@ -13,7 +13,7 @@ use crate::compactor::ContextCompactor; use crate::config::DatetimeConfig; use crate::db::{chat_sessions, chat_sessions_stack}; use crate::llm::LlmManager; -use crate::mcp::McpManager; +use crate::mcp::McpProvider; use crate::image_generate::ImageGeneratorManager; use crate::memory::MemoryManager; use crate::run_context::{RunContext, RunContextManager}; @@ -38,7 +38,9 @@ pub struct ChatSessionManager { max_tool_result_chars: Option, datetime_config: DatetimeConfig, tools: Arc, - mcp: Arc, + /// The MCP tools visible to this owner: the access-filtered global runtime + /// unioned with their per-user runtime (blueprint §7), behind one trait. + mcp: Arc, approval: Arc, clarification: Arc, event_bus: Arc, @@ -66,7 +68,7 @@ impl ChatSessionManager { max_tool_result_chars: Option, datetime_config: DatetimeConfig, tools: Arc, - mcp: Arc, + mcp: Arc, approval: Arc, clarification: Arc, event_bus: Arc, diff --git a/crates/skald-core/src/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs index 4f26825..9975ed6 100644 --- a/crates/skald-core/src/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -211,13 +211,13 @@ impl Tools { tool_registry.register(crate::tools::exec::ExecuteCmd); tool_registry.register(crate::tools::read_notification::ReadNotification); tool_registry.register(crate::tools::restart::Restart); - // Unified listing / toggling across mcp, plugins, cron (+ agents for list). + // Unified listing / toggling across plugins, cron (+ agents for list). MCP + // is no longer agent-managed (blueprint §14): connectors are curated by the + // admin and activated by the user via the Connectors UI/API, not tools. tool_registry.register(crate::tools::list_items::ListItems::new( - Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron))); + Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron))); tool_registry.register(crate::tools::toggle_item::ToggleItem::new( - Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron))); - tool_registry.register(crate::tools::register_mcp::RegisterMcp::new(Arc::clone(&integrations.mcp))); - tool_registry.register(crate::tools::register_mcp::DeleteMcp::new(Arc::clone(&integrations.mcp))); + Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron))); tool_registry.register(crate::tools::cron_jobs::DeleteCronJob); tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets))); tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets))); @@ -358,7 +358,9 @@ impl Conversation { config.llm.max_tool_result_chars, DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime }, Arc::clone(&tools.tools), - Arc::clone(&integrations.mcp), + // Inert ownerless bundle (§19): the global runtime as a provider, + // unfiltered — never actually exercised (no loops, no consumers). + Arc::clone(&integrations.mcp) as Arc, Arc::clone(&interaction.approval), Arc::clone(&interaction.clarification), Arc::clone(&rt.event_bus), diff --git a/crates/skald-core/src/skald/mod.rs b/crates/skald-core/src/skald/mod.rs index ec91a91..dec7c7f 100644 --- a/crates/skald-core/src/skald/mod.rs +++ b/crates/skald-core/src/skald/mod.rs @@ -88,7 +88,7 @@ impl Skald { // Per-user context factory: captures the global capability managers, so a // per-user chat/hub/cron/interaction stack can be stamped out on demand. let user_contexts = UserContextRegistry::new(UserContextFactory::new( - &rt, &models, &media, &tools, &integrations, &conversation, config, + &rt, &models, &media, &tools, &integrations, &conversation, &container, config, )); // Build the runtime image and reconcile a container for every active user. diff --git a/crates/skald-core/src/skald/user_context.rs b/crates/skald-core/src/skald/user_context.rs index 6b6e422..b15f1de 100644 --- a/crates/skald-core/src/skald/user_context.rs +++ b/crates/skald-core/src/skald/user_context.rs @@ -43,12 +43,13 @@ use crate::chat_hub::ChatHub; use crate::clarification::ClarificationManager; use crate::compactor::ContextCompactor; use crate::config::{CompactionConfig, CoreConfig, DatetimeConfig}; +use crate::container::ContainerManager; use crate::cron::TaskManager; use crate::elicitation::ElicitationManager; use crate::image_generate::ImageGeneratorManager; use crate::inbox::Inbox; use crate::llm::LlmManager; -use crate::mcp::McpManager; +use crate::mcp::{McpManager, McpProvider, UserMcpView}; use crate::memory::MemoryManager; use crate::projects::tickets::ProjectTicketManager; use crate::run_context::RunContextManager; @@ -76,6 +77,11 @@ pub struct UserContext { pub clarification: Arc, pub elicitation: Arc, pub inbox: Inbox, + /// This user's own MCP runtime (blueprint §7/§9): connectors that run inside + /// their container, started at first login and living until restart. Held + /// here so its lifetime equals the pool's; its `docker exec -i` children die + /// via `kill_on_drop` when the context is dropped at shutdown. + pub user_mcp: Arc, /// Per-user server→client push channel. WS handlers subscribe here (via the /// hub) so a user's `ServerEvent`s never reach another user's socket. pub global_tx: broadcast::Sender, @@ -87,7 +93,12 @@ pub(super) struct UserContextFactory { registry_pool: Arc, llm_manager: Arc, tools: Arc, + /// The GLOBAL MCP runtime (host, shared). Unioned per-user with the per-user + /// runtime built at login (`UserMcpView`). mcp: Arc, + /// Container lifecycle — used to ensure a user's container is up before their + /// per-user (container-hosted) MCP connectors start. + container: ContainerManager, memory_manager: Arc, image_generator_manager: Arc, run_context_manager: Arc, @@ -111,6 +122,7 @@ impl UserContextFactory { tools: &Tools, integrations: &Integrations, conversation: &Conversation, + container: &ContainerManager, config: &CoreConfig, ) -> Self { let cron_tz = config.timezone.as_deref().and_then(|s| s.parse::().ok()); @@ -119,6 +131,7 @@ impl UserContextFactory { llm_manager: Arc::clone(&models.llm_manager), tools: Arc::clone(&tools.tools), mcp: Arc::clone(&integrations.mcp), + container: container.clone(), memory_manager: Arc::clone(&models.memory_manager), image_generator_manager: Arc::clone(&media.image_generator_manager), run_context_manager: Arc::clone(&conversation.run_context_manager), @@ -165,6 +178,58 @@ impl UserContextFactory { )) }); + // Per-user MCP runtime (blueprint §7/§9): the connectors this user has + // activated, run INSIDE their container. Started here on first login and + // living until restart — its `docker exec -i` children die via + // `kill_on_drop` when this context (holding `user_mcp`) is dropped at + // shutdown. Ensure the container is up first (idempotent: boot + // reconciliation and user-create already do this; the belt-and-braces call + // recovers a container stopped since). Non-fatal — a container hiccup + // degrades MCP/exec but must not block login. + if let Err(e) = self.container.ensure(user_id).await { + tracing::warn!(user = %user_id, error = %e, "failed to ensure container before per-user MCP start"); + } + let user_mcp = Arc::new(McpManager::new( + Arc::clone(&pool), + self.shutdown_token.clone(), + "data", + )); + // NOTE: per-user MCP elicitation (interactive connector login, §15) is + // deferred — api-key connectors don't need it. Wire the user's + // ElicitationBridge here when interactive auth lands. + { + let um = Arc::clone(&user_mcp); + let upool = Arc::clone(&pool); + let container = crate::container::container_name(user_id); + let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str()); + self.supervisor.adopt_one(mname, tokio::spawn(async move { + match crate::db::mcp_user_servers::all_startable(&upool).await { + Ok(rows) => { + let specs = rows.iter() + .map(|r| crate::mcp::user_row_spec(r, &container)) + .collect(); + um.connect_all(specs, false).await; + } + Err(e) => tracing::warn!(error = %e, "per-user MCP init: failed to read mcp_user_servers"), + } + })); + } + + // The MCP view this user's sessions see: the access-filtered global runtime + // unioned with their per-user runtime (§7). `accessible_global` is a + // snapshot of `mcp_global_access`, captured at build time like fs membership. + let accessible_global: std::collections::HashSet = + crate::db::mcp_global_access::server_names_for_user(&self.registry_pool, user_id) + .await + .unwrap_or_default() + .into_iter() + .collect(); + let mcp_view: Arc = Arc::new(UserMcpView { + global: Arc::clone(&self.mcp), + user: Arc::clone(&user_mcp), + accessible_global, + }); + let manager = Arc::new(ChatSessionManager::new( Arc::clone(&pool), Arc::clone(&self.registry_pool), // shared pool = system.db, for shared-memory injection @@ -177,7 +242,7 @@ impl UserContextFactory { self.max_tool_result_chars, self.datetime_config.clone(), Arc::clone(&self.tools), - Arc::clone(&self.mcp), + mcp_view, Arc::clone(&approval), Arc::clone(&clarification), Arc::clone(&event_bus), @@ -241,6 +306,7 @@ impl UserContextFactory { clarification, elicitation, inbox, + user_mcp, global_tx, })) } diff --git a/crates/skald-core/src/tools/activate_tools.rs b/crates/skald-core/src/tools/activate_tools.rs index 260b04a..bed7cef 100644 --- a/crates/skald-core/src/tools/activate_tools.rs +++ b/crates/skald-core/src/tools/activate_tools.rs @@ -5,7 +5,7 @@ use anyhow::Result; use serde_json::{Value, json}; use sqlx::SqlitePool; -use crate::mcp::McpManager; +use crate::mcp::McpProvider; use crate::tools::tool_names::CONFIG_GROUP; use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT}; @@ -37,7 +37,7 @@ pub struct ActivateTools { /// `None` for root agents (session-scoped grants). /// `Some(stack_id)` for sub-agents (stack-scoped grants, deleted on frame exit). pub stack_id: Option, - pub mcp: Arc, + pub mcp: Arc, /// 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>>, diff --git a/crates/skald-core/src/tools/list_items.rs b/crates/skald-core/src/tools/list_items.rs index 36c66ec..00bd5f3 100644 --- a/crates/skald-core/src/tools/list_items.rs +++ b/crates/skald-core/src/tools/list_items.rs @@ -5,7 +5,6 @@ use serde_json::{Value, json}; use crate::agents; use crate::cron::TaskManager; -use crate::mcp::McpManager; use crate::plugin::PluginManager; use crate::tools::{Tool, ToolDescriptionLength}; @@ -19,14 +18,13 @@ use crate::tools::{Tool, ToolDescriptionLength}; /// the ability to enumerate secret key names) and carries a `pattern` filter /// that would only apply to that one type. pub struct ListItems { - mcp: Arc, plugins: Arc, cron: Arc, } impl ListItems { - pub fn new(mcp: Arc, plugins: Arc, cron: Arc) -> Self { - Self { mcp, plugins, cron } + pub fn new(plugins: Arc, cron: Arc) -> Self { + Self { plugins, cron } } } @@ -36,7 +34,6 @@ impl Tool for ListItems { fn description(&self) -> &str { "List configured items of a given type. Pass `type`:\n\ - • `mcp` — MCP servers with status (running, error, disabled), description, friendly_name, and exposed tools.\n\ • `plugins` — plugins with id, name, description, enabled flag (persisted), and running flag (live).\n\ • `cron` — scheduled tasks/cron jobs with id, title, cron expression, agent_id, enabled, kind, last/next run.\n\ • `agents` — sub-agents available to delegate to (id, name, description, optional `instructions` on how to call the agent well, optional client). Do NOT invoke the `main` agent.\n\ @@ -50,7 +47,7 @@ impl Tool for ListItems { "properties": { "type": { "type": "string", - "enum": ["mcp", "plugins", "cron", "agents"], + "enum": ["plugins", "cron", "agents"], "description": "Which kind of item to list." } } @@ -67,12 +64,6 @@ impl Tool for ListItems { .ok_or_else(|| anyhow::anyhow!("list_items: missing required argument `type`"))?; match kind { - "mcp" => { - let infos = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(self.mcp.list()) - })?; - Ok(serde_json::to_string_pretty(&infos)?) - } "plugins" => { let plugins = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(self.plugins.list()) @@ -124,7 +115,7 @@ impl Tool for ListItems { .collect(); Ok(serde_json::to_string_pretty(&arr)?) } - other => anyhow::bail!("list_items: unknown type `{other}` (expected one of: mcp, plugins, cron, agents)"), + other => anyhow::bail!("list_items: unknown type `{other}` (expected one of: plugins, cron, agents)"), } } } diff --git a/crates/skald-core/src/tools/mod.rs b/crates/skald-core/src/tools/mod.rs index 688cd1a..b66300d 100644 --- a/crates/skald-core/src/tools/mod.rs +++ b/crates/skald-core/src/tools/mod.rs @@ -43,7 +43,6 @@ pub mod list_secrets; pub mod notify; pub mod set_secret; pub mod read_notification; -pub mod register_mcp; pub mod restart; pub mod show_file; pub mod toggle_item; diff --git a/crates/skald-core/src/tools/register_mcp.rs b/crates/skald-core/src/tools/register_mcp.rs deleted file mode 100644 index 175000a..0000000 --- a/crates/skald-core/src/tools/register_mcp.rs +++ /dev/null @@ -1,175 +0,0 @@ -use std::sync::Arc; - -use anyhow::Result; -use serde_json::{Value, json}; - -use crate::db::mcp_servers::UpsertParams; -use crate::mcp::McpManager; -use crate::tools::{Tool, ToolDescriptionLength}; - -pub struct RegisterMcp { - mcp: Arc, -} - -impl RegisterMcp { - pub fn new(mcp: Arc) -> Self { Self { mcp } } -} - -impl Tool for RegisterMcp { - fn name(&self) -> &str { "register_mcp" } - fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } - - fn description(&self) -> &str { - "Register (or update) an MCP server and connect to it immediately. \ - For stdio servers supply `command` and optionally `args` and `env`. \ - For HTTP/SSE servers supply `url` and optionally `api_key`. \ - Optionally provide `description` (what the server does) and `friendly_name` (display name for UI). \ - Returns the list of tools exposed by the server once connected." - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique name for this MCP server (used to reference it in tool calls)." - }, - "transport": { - "type": "string", - "enum": ["stdio", "http", "sse"], - "description": "Connection transport. Use `stdio` for local processes, `http` for remote servers." - }, - "command": { - "type": "string", - "description": "stdio only: executable to spawn (e.g. `npx`, `uvx`, path to binary)." - }, - "args": { - "type": "array", - "items": { "type": "string" }, - "description": "stdio only: command-line arguments passed to the executable." - }, - "env": { - "type": "object", - "additionalProperties": { "type": "string" }, - "description": "stdio only: extra environment variables. Values support `${VAR}` interpolation." - }, - "url": { - "type": "string", - "description": "http/sse only: base URL of the remote MCP server." - }, - "api_key": { - "type": "string", - "description": "http/sse only: API key sent as `Authorization: Bearer `." - }, - "description": { - "type": "string", - "description": "A short description of what this MCP server provides (shown in list_items type=mcp)." - }, - "friendly_name": { - "type": "string", - "description": "A human-readable display name for this MCP server (e.g. 'Google Calendar')." - } - }, - "required": ["name", "transport"] - }) - } - - fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String { - let name = args["name"].as_str().unwrap_or("?"); - format!("register MCP `{name}`") - } - - fn execute(&self, args: Value) -> Result { - let name = args["name"].as_str() - .ok_or_else(|| anyhow::anyhow!("register_mcp: missing required argument `name`"))?; - let transport = args["transport"].as_str() - .ok_or_else(|| anyhow::anyhow!("register_mcp: missing required argument `transport`"))?; - - let args_json = args["args"].as_array() - .map(|a| serde_json::to_string(a)) - .transpose()?; - let env_json = args["env"].as_object() - .map(|o| serde_json::to_string(o)) - .transpose()?; - - let p = UpsertParams { - name, - transport, - command: args["command"].as_str(), - args_json, - env_json, - url: args["url"].as_str(), - api_key: args["api_key"].as_str(), - description: args["description"].as_str(), - friendly_name: args["friendly_name"].as_str(), - }; - - let tool_names = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(self.mcp.register(p)) - })?; - - Ok(format!( - "MCP server '{}' registered and connected. Tools: {}", - name, - if tool_names.is_empty() { "(none)".to_string() } else { tool_names.join(", ") }, - )) - } -} - -// ── delete_mcp ──────────────────────────────────────────────────────────────── -// -// Destructive counterpart to `register_mcp`. Kept separate from `toggle_item` -// (kind=mcp) for the same reason `delete_cron_job` is: toggling is reversible, -// deletion is not, so the distinct tool can carry its own approval rule and the -// LLM can't conflate "disable" with "remove". Both live here because both manage -// the MCP-server lifecycle and hold only `Arc`. - -pub struct DeleteMcp { - mcp: Arc, -} - -impl DeleteMcp { - pub fn new(mcp: Arc) -> Self { Self { mcp } } -} - -impl Tool for DeleteMcp { - fn name(&self) -> &str { "delete_mcp" } - fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config } - - fn description(&self) -> &str { - "Permanently delete (unregister) an MCP server by name: removes it from the \ - database and disconnects it. This is irreversible — to temporarily turn a \ - server off without losing its configuration, use \ - `toggle_item(kind=\"mcp\", enabled=false)` instead." - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "required": ["name"], - "properties": { - "name": { - "type": "string", - "description": "Name of the MCP server to delete (from list_items type=mcp)." - } - } - }) - } - - fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String { - let name = args["name"].as_str().unwrap_or("?"); - format!("delete MCP `{name}`") - } - - fn execute(&self, args: Value) -> Result { - let name = args["name"].as_str() - .ok_or_else(|| anyhow::anyhow!("delete_mcp: missing required argument `name`"))?; - - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(self.mcp.unregister(name)) - })?; - - Ok(format!("MCP server '{name}' deleted and disconnected.")) - } -} diff --git a/crates/skald-core/src/tools/toggle_item.rs b/crates/skald-core/src/tools/toggle_item.rs index eb20441..693fb38 100644 --- a/crates/skald-core/src/tools/toggle_item.rs +++ b/crates/skald-core/src/tools/toggle_item.rs @@ -4,7 +4,6 @@ use anyhow::Result; use serde_json::{Value, json}; use crate::cron::TaskManager; -use crate::mcp::McpManager; use crate::plugin::PluginManager; use crate::tools::{Tool, ToolDescriptionLength}; @@ -16,14 +15,13 @@ use crate::tools::{Tool, ToolDescriptionLength}; /// (irreversible) whereas toggling is reversible, and keeping it separate lets /// it carry a distinct approval rule. pub struct ToggleItem { - mcp: Arc, plugins: Arc, cron: Arc, } impl ToggleItem { - pub fn new(mcp: Arc, plugins: Arc, cron: Arc) -> Self { - Self { mcp, plugins, cron } + pub fn new(plugins: Arc, cron: Arc) -> Self { + Self { plugins, cron } } } @@ -33,7 +31,6 @@ impl Tool for ToggleItem { fn description(&self) -> &str { "Enable or disable an item by kind. Pass `kind`, `id`, and `enabled`:\n\ - • `mcp` — `id` is the server name. NOTE: a restart is required for the change to take full effect on running servers.\n\ • `plugin` — `id` is the plugin id (e.g. \"telegram\"). Takes effect immediately (the plugin is started/stopped at once).\n\ • `cron` — `id` is the numeric job id (from `list_items` type=cron). Re-enabling recalculates next_run_at.\n\ Use `list_items` to find current names/ids and statuses." @@ -46,12 +43,12 @@ impl Tool for ToggleItem { "properties": { "kind": { "type": "string", - "enum": ["mcp", "plugin", "cron"], + "enum": ["plugin", "cron"], "description": "Which kind of item to toggle." }, "id": { "type": "string", - "description": "MCP server name | plugin id | numeric cron job id (as a string)." + "description": "plugin id | numeric cron job id (as a string)." }, "enabled": { "type": "boolean", @@ -78,16 +75,6 @@ impl Tool for ToggleItem { .ok_or_else(|| anyhow::anyhow!("toggle_item: missing required argument `enabled`"))?; match kind { - "mcp" => { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(self.mcp.set_enabled(id, enabled)) - })?; - Ok(format!( - "MCP server '{}' is now {}. Note: a restart is required for the change to take effect on running servers.", - id, - if enabled { "enabled" } else { "disabled" } - )) - } "plugin" => { tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(self.plugins.toggle(id, enabled)) @@ -107,7 +94,7 @@ impl Tool for ToggleItem { Ok(format!("No task with id {job_id}.")) } } - other => anyhow::bail!("toggle_item: unknown kind `{other}` (expected one of: mcp, plugin, cron)"), + other => anyhow::bail!("toggle_item: unknown kind `{other}` (expected one of: plugin, cron)"), } } } diff --git a/src/frontend/api/approval.rs b/src/frontend/api/approval.rs index 8410195..fb97765 100644 --- a/src/frontend/api/approval.rs +++ b/src/frontend/api/approval.rs @@ -115,7 +115,7 @@ pub async fn list_tools( State(skald): State>, ) -> Result, ApiError> { let mut tools = skald.catalog().list_all(); - let server_rows = skald_core::db::mcp_servers::all(skald.db()).await?; + 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(); diff --git a/src/frontend/api/mcp.rs b/src/frontend/api/mcp.rs index d8ab51e..a4438db 100644 --- a/src/frontend/api/mcp.rs +++ b/src/frontend/api/mcp.rs @@ -1,11 +1,407 @@ -use axum::Json; -use axum::extract::State; -use serde_json::Value; +//! Connectors (MCP) management API (blueprint §14/§15). +//! +//! Two audiences, capability-gated (`role_capabilities`): +//! - **Admin** curates the catalog (`mcp_catalog`) and enables globally-active +//! connectors (`mcp_global_servers` + `mcp_global_access`). +//! - **Any user** activates per-user connectors from the catalog into their own +//! `{userid}.db` (`mcp_user_servers`), started inside their container. +//! +//! Registration is UI/API-driven, never agent-driven — the prompt-injection→ +//! local-script→RCE path (§14) is gone with the old `register_mcp` tool. +use std::collections::HashMap; use std::sync::Arc; + +use axum::extract::{Extension, Path, State}; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; + +use skald_core::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, role_capabilities}; use skald_core::skald::Skald; -/// Returns the list of running MCP servers and their available tools. +use super::guard::AuthUser; +use super::{require_context, ApiError}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/// Fails with 403 unless the caller's role holds `cap` (admin holds everything). +async fn require_cap(skald: &Skald, user_id: &str, cap: &str) -> Result<(), ApiError> { + let user = skald_core::db::users::get(skald.db(), user_id).await? + .ok_or_else(|| ApiError::unauthorized("unknown user"))?; + if role_capabilities::has(skald.db(), &user.role_id, cap).await? { + Ok(()) + } else { + Err(ApiError::forbidden(format!("your role lacks the capability `{cap}`"))) + } +} + +fn to_json_opt(v: &Option) -> Option { + v.as_ref().and_then(|x| serde_json::to_string(x).ok()) +} + +/// Copies a vetted catalog script from `./scripts/` into the user's +/// bind-mounted home under `.skald/mcp//`, and returns the path it will have +/// INSIDE the container (`/root/.skald/mcp/...`). The home is the only durable +/// zone (§6), so the script survives a container recreate. Single files only for +/// now — directory-tree scripts (e.g. whatsapp_mcp/) are a follow-up. +fn copy_script_into_home(user_id: &str, name: &str, script_path: &str) -> Result { + let wd = std::env::current_dir() + .map_err(|e| ApiError::bad_request(format!("cannot resolve working directory: {e}")))?; + let src = wd.join("scripts").join(script_path); + if !src.is_file() { + return Err(ApiError::bad_request(format!( + "catalog script `scripts/{script_path}` not found or not a file \ + (directory-tree scripts are not supported yet)" + ))); + } + let basename = src.file_name() + .ok_or_else(|| ApiError::bad_request("invalid script_path"))? + .to_string_lossy().to_string(); + let dest_dir = wd.join(skald_core::container::HOMES_DIR) + .join(user_id).join(".skald").join("mcp").join(name); + std::fs::create_dir_all(&dest_dir) + .map_err(|e| ApiError::bad_request(format!("failed to create script dir: {e}")))?; + std::fs::copy(&src, dest_dir.join(&basename)) + .map_err(|e| ApiError::bad_request(format!("failed to copy script: {e}")))?; + Ok(format!("/root/.skald/mcp/{name}/{basename}")) +} + +// ── existing: running-server introspection ──────────────────────────────────── + +/// The globally-running MCP servers and their tools (host runtime). pub async fn list_servers(State(skald): State>) -> Json> { Json(skald.mcp().server_infos()) } + +// ── admin: catalog CRUD ─────────────────────────────────────────────────────── + +pub async fn catalog_list( + State(skald): State>, + Extension(auth): Extension, +) -> Result>, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + Ok(Json(mcp_catalog::list(skald.db()).await?)) +} + +#[derive(Deserialize)] +pub struct CatalogUpsertBody { + pub name: String, + pub scope: String, // 'per_user' | 'global' + pub source: String, // 'remote' | 'local_script' + #[serde(default = "default_stdio")] + pub transport: String, + pub command: Option, + pub args: Option>, + pub env: Option>, + pub url: Option, + pub script_path: Option, + pub config_schema: Option>, + #[serde(default = "default_none_auth")] + pub auth_kind: String, + pub role_filter: Option>, + pub friendly_name: Option, + pub description: Option, +} + +fn default_stdio() -> String { "stdio".into() } +fn default_none_auth() -> String { "none".into() } + +pub async fn catalog_upsert( + State(skald): State>, + Extension(auth): Extension, + Json(body): Json, +) -> Result, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + // Adding a NEW local script to the catalog is the RCE-bearing act (§14): it + // needs the admin-only capability on top of catalog management. + if body.source == "local_script" { + require_cap(&skald, &auth.user_id, role_capabilities::REGISTER_LOCAL_SCRIPT).await?; + } + let id = mcp_catalog::upsert(skald.db(), mcp_catalog::UpsertCatalog { + name: &body.name, + scope: &body.scope, + source: &body.source, + transport: &body.transport, + command: body.command.as_deref(), + args_json: to_json_opt(&body.args), + env_json: to_json_opt(&body.env), + url: body.url.as_deref(), + script_path: body.script_path.as_deref(), + config_schema_json: to_json_opt(&body.config_schema), + auth_kind: &body.auth_kind, + role_filter: to_json_opt(&body.role_filter), + friendly_name: body.friendly_name.as_deref(), + description: body.description.as_deref(), + }).await?; + Ok(Json(json!({ "id": id }))) +} + +pub async fn catalog_delete( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, +) -> Result, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + mcp_catalog::delete(skald.db(), id).await?; + Ok(Json(json!({ "ok": true }))) +} + +// ── admin: globally-active connectors + access ──────────────────────────────── + +pub async fn global_list( + State(skald): State>, + Extension(auth): Extension, +) -> Result>, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + Ok(Json(mcp_global_servers::all(skald.db()).await?)) +} + +#[derive(Deserialize)] +pub struct GlobalEnableBody { + /// The catalog entry to enable globally (must be scope='global'). + pub catalog_name: String, + /// Optional runtime name override (defaults to the catalog name). + pub name: Option, + pub api_key: Option, + pub env: Option>, +} + +pub async fn global_enable( + State(skald): State>, + Extension(auth): Extension, + Json(body): Json, +) -> Result, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + let entry = mcp_catalog::get_by_name(skald.db(), &body.catalog_name).await? + .ok_or_else(|| ApiError::not_found(format!("no catalog entry `{}`", body.catalog_name)))?; + if entry.scope != "global" { + return Err(ApiError::bad_request("catalog entry is not a global connector")); + } + let name = body.name.clone().unwrap_or_else(|| entry.name.clone()); + // Snapshot the concrete config from the catalog; the admin supplies the secret. + let id = mcp_global_servers::upsert(skald.db(), mcp_global_servers::UpsertGlobal { + name: &name, + catalog_name: Some(&entry.name), + transport: &entry.transport, + command: entry.command.as_deref(), + args_json: entry.args_json.clone(), + env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()).or_else(|| entry.env_json.clone()), + url: entry.url.as_deref(), + api_key: body.api_key.as_deref(), + friendly_name: entry.friendly_name.as_deref(), + description: entry.description.as_deref(), + }).await?; + + // Start it now in the global runtime (host transport). + let row = mcp_global_servers::get(skald.db(), id).await? + .ok_or_else(|| ApiError::bad_request("global server vanished after upsert"))?; + let spec = skald_core::mcp::global_row_spec(&row); + match skald.mcp().start_server(spec).await { + Ok(tools) => Ok(Json(json!({ "id": id, "tools": tools }))), + Err(e) => Ok(Json(json!({ "id": id, "error": e.to_string() }))), + } +} + +pub async fn global_delete( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, +) -> Result, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + if let Some(row) = mcp_global_servers::get(skald.db(), id).await? { + skald.mcp().stop_server(&row.name); + } + mcp_global_servers::delete(skald.db(), id).await?; + Ok(Json(json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct GlobalAccessBody { + /// The full set of user ids allowed to use this global connector. + pub user_ids: Vec, +} + +pub async fn global_get_access( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, +) -> Result>, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + Ok(Json(mcp_global_access::users_for_server(skald.db(), id).await?)) +} + +pub async fn global_set_access( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + mcp_global_access::set_access(skald.db(), id, &body.user_ids).await?; + Ok(Json(json!({ "ok": true }))) +} + +// ── user: available catalog + activation ────────────────────────────────────── + +/// What a user can activate or already reaches: the per-user catalog entries their +/// role may activate, plus the global connectors they've been granted. +pub async fn available( + State(skald): State>, + Extension(auth): Extension, +) -> Result, ApiError> { + let user = skald_core::db::users::get(skald.db(), &auth.user_id).await? + .ok_or_else(|| ApiError::unauthorized("unknown user"))?; + let catalog: Vec<_> = mcp_catalog::list_for_scope(skald.db(), "per_user").await? + .into_iter() + .filter(|e| e.allowed_for_role(&user.role_id)) + .collect(); + let global_names = mcp_global_access::server_names_for_user(skald.db(), &auth.user_id).await?; + Ok(Json(json!({ "catalog": catalog, "global": global_names }))) +} + +/// The connectors this user has already activated (per-user runtime). +pub async fn activated_list( + State(skald): State>, + Extension(auth): Extension, +) -> Result>, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + Ok(Json(mcp_user_servers::all(&ctx.pool).await?)) +} + +#[derive(Deserialize)] +pub struct ActivateBody { + /// A catalog entry to instantiate (per-user). Omit for a self-registered remote. + pub catalog_name: Option, + /// Runtime name; defaults to the catalog name. Required for a self-registered remote. + pub name: Option, + /// Secrets/env the user supplies for this activation (stored encrypted in {userid}.db). + pub env: Option>, + pub api_key: Option, + // Self-registered remote only: + pub url: Option, + #[serde(default = "default_stdio")] + pub transport: String, +} + +pub async fn activate( + State(skald): State>, + Extension(auth): Extension, + Json(body): Json, +) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + let user = skald_core::db::users::get(skald.db(), &auth.user_id).await? + .ok_or_else(|| ApiError::unauthorized("unknown user"))?; + + // Resolve the row to insert from either the catalog or a self-registered remote. + let insert = match &body.catalog_name { + Some(cat_name) => { + let entry = mcp_catalog::get_by_name(skald.db(), cat_name).await? + .ok_or_else(|| ApiError::not_found(format!("no catalog entry `{cat_name}`")))?; + if entry.scope != "per_user" { + return Err(ApiError::bad_request("catalog entry is not a per-user connector")); + } + if !entry.allowed_for_role(&user.role_id) { + return Err(ApiError::forbidden("your role may not activate this connector")); + } + let cap = if entry.source == "local_script" { + role_capabilities::REGISTER_LOCAL_FROM_CATALOG + } else { + role_capabilities::REGISTER_REMOTE + }; + require_cap(&skald, &auth.user_id, cap).await?; + + let name = body.name.clone().unwrap_or_else(|| entry.name.clone()); + reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?; + + // For a local script, copy it into the container home and point the + // command at the in-container path. + let (command, args_json, script_rel_path) = if entry.source == "local_script" { + let script = entry.script_path.clone() + .ok_or_else(|| ApiError::bad_request("catalog local_script entry has no script_path"))?; + let container_path = copy_script_into_home(&auth.user_id, &name, &script)?; + (entry.command.clone(), Some(json!([container_path]).to_string()), Some(container_path)) + } else { + (entry.command.clone(), entry.args_json.clone(), None) + }; + + mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer { + name: &name, + catalog_name: Some(&entry.name), + source: &entry.source, + transport: &entry.transport, + command: command.as_deref(), + args_json, + env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()).or_else(|| entry.env_json.clone()), + url: entry.url.as_deref(), + api_key: body.api_key.as_deref(), + script_rel_path: script_rel_path.as_deref(), + auth_state: "ready", + }).await? + } + None => { + // Self-registered remote (egress-only, §14) — needs `register_remote`. + require_cap(&skald, &auth.user_id, role_capabilities::REGISTER_REMOTE).await?; + let name = body.name.clone() + .ok_or_else(|| ApiError::bad_request("a self-registered remote needs a `name`"))?; + let url = body.url.clone() + .ok_or_else(|| ApiError::bad_request("a self-registered remote needs a `url`"))?; + reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?; + mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer { + name: &name, + catalog_name: None, + source: "remote", + transport: &body.transport, + command: None, + args_json: None, + env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()), + url: Some(&url), + api_key: body.api_key.as_deref(), + script_rel_path: None, + auth_state: "ready", + }).await? + } + }; + + // Start it now in this user's runtime (container transport for stdio). + let row = mcp_user_servers::get(&ctx.pool, insert).await? + .ok_or_else(|| ApiError::bad_request("user server vanished after insert"))?; + let container = skald_core::container::container_name(&auth.user_id); + let spec = skald_core::mcp::user_row_spec(&row, &container); + match ctx.user_mcp.start_server(spec).await { + Ok(tools) => Ok(Json(json!({ "id": insert, "tools": tools }))), + Err(e) => Ok(Json(json!({ "id": insert, "error": e.to_string() }))), + } +} + +/// Rejects a per-user connector name that collides with an accessible global one +/// or an already-activated per-user one — so a bare grant string resolves to +/// exactly one runtime in `UserMcpView`. +async fn reject_name_collision( + skald: &Skald, + pool: &sqlx::SqlitePool, + user_id: &str, + name: &str, +) -> Result<(), ApiError> { + if mcp_user_servers::get_by_name(pool, name).await?.is_some() { + return Err(ApiError::bad_request(format!("a connector named `{name}` is already activated"))); + } + let globals = mcp_global_access::server_names_for_user(skald.db(), user_id).await?; + if globals.iter().any(|g| g == name) { + return Err(ApiError::bad_request(format!("`{name}` collides with a global connector you can access — choose another name"))); + } + Ok(()) +} + +pub async fn deactivate( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, +) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + if let Some(row) = mcp_user_servers::get(&ctx.pool, id).await? { + ctx.user_mcp.stop_server(&row.name); + } + mcp_user_servers::delete(&ctx.pool, id).await?; + Ok(Json(json!({ "ok": true }))) +} diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index bdefb33..f7a5542 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -128,8 +128,19 @@ pub fn router() -> Router> { .route("/tool-permission-groups/{id}/duplicate", post(run_context::duplicate_group)) // Session tool_group assignment (runtime) .route("/sessions/{session_id}/run-context", put(run_context::set_session_run_context)) - // MCP + // MCP / Connectors (blueprint §14/§15) .route("/mcp/servers", get(mcp::list_servers)) + // admin: catalog + globally-active connectors + .route("/mcp/catalog", get(mcp::catalog_list).post(mcp::catalog_upsert)) + .route("/mcp/catalog/{id}", delete(mcp::catalog_delete)) + .route("/mcp/global", get(mcp::global_list).post(mcp::global_enable)) + .route("/mcp/global/{id}", delete(mcp::global_delete)) + .route("/mcp/global/{id}/access", get(mcp::global_get_access).put(mcp::global_set_access)) + // user: available catalog + per-user activation + .route("/mcp/available", get(mcp::available)) + .route("/mcp/activate", post(mcp::activate)) + .route("/mcp/activated", get(mcp::activated_list)) + .route("/mcp/activated/{id}", delete(mcp::deactivate)) // Dev / debug .route("/dev/debug_mode", get(dev::get_debug_mode).post(dev::set_debug_mode).put(dev::set_debug_mode)) .route("/dev/llm-requests", get(dev::list_llm_requests)) @@ -179,6 +190,10 @@ impl ApiError { pub fn unauthorized(msg: impl Into) -> Self { Self { status: StatusCode::UNAUTHORIZED, message: msg.into() } } + + pub fn forbidden(msg: impl Into) -> Self { + Self { status: StatusCode::FORBIDDEN, message: msg.into() } + } } /// Resolves the authenticated caller's per-user runtime context, or `401` when the diff --git a/src/frontend/api/roles.rs b/src/frontend/api/roles.rs index c397823..5e8abbf 100644 --- a/src/frontend/api/roles.rs +++ b/src/frontend/api/roles.rs @@ -34,6 +34,10 @@ pub async fn create( } roles::insert(skald.db(), id, body.label.trim(), &body.permission_group, body.attrs.as_deref()) .await?; + // Seed the standard self-service Connector capabilities (§14): a new role can + // register remote MCPs and activate vetted catalog scripts, but not add new + // local scripts or manage the catalog (admin-only). + skald_core::db::role_capabilities::seed_defaults(skald.db(), id).await?; let role = roles::get(skald.db(), id).await?.ok_or_else(|| ApiError::not_found("role not found after insert"))?; Ok(Json(role)) } diff --git a/web/app.js b/web/app.js index a766a06..c7f353d 100644 --- a/web/app.js +++ b/web/app.js @@ -11,6 +11,7 @@ import { TasksPage } from './components/tasks/index.js'; import { AgentsPage } from './components/agents.js'; import { UsersPage } from './components/users-page.js'; import { RolesPage } from './components/roles-page.js'; +import { ConnectorsPage } from './components/connectors.js'; import { ProfilePage } from './components/profile-page.js'; import { ApprovalGroupsPage } from './components/approval-groups.js'; import { ApprovalRulesPage } from './components/approval-rules.js'; @@ -42,6 +43,7 @@ customElements.define('tasks-page', TasksPage); customElements.define('agents-page', AgentsPage); customElements.define('users-page', UsersPage); customElements.define('roles-page', RolesPage); +customElements.define('connectors-page', ConnectorsPage); customElements.define('profile-page', ProfilePage); customElements.define('approval-groups-page', ApprovalGroupsPage); customElements.define('approval-rules-page', ApprovalRulesPage); diff --git a/web/components/connectors.js b/web/components/connectors.js new file mode 100644 index 0000000..bbb6e27 --- /dev/null +++ b/web/components/connectors.js @@ -0,0 +1,498 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; + +// Connectors (MCP) management — blueprint §14/§15. +// +// Two audiences on one page: +// • every user: activate/deactivate per-user connectors from the catalog, and +// see the global connectors they've been granted; +// • admin (role_id === 'admin'): curate the catalog and enable globally-active +// connectors + grant per-user access. +// +// Reuses the shared `um-*` / bootstrap styling (no page-specific CSS). + +const ADMIN_ID = 'admin'; + +function parseJson(s, fallback) { + if (!s) return fallback; + try { return JSON.parse(s); } catch { return fallback; } +} + +async function jf(url, opts) { + const res = await fetch(url, opts); + if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`); + const ct = res.headers.get('content-type') || ''; + return ct.includes('application/json') ? res.json() : null; +} + +export class ConnectorsPage extends LightElement { + + static get properties() { + return { + _open: { state: true }, + _me: { state: true }, // { role_id } + _available: { state: true }, // { catalog: [...], global: [names] } + _activated: { state: true }, // [ user server rows ] + _catalog: { state: true }, // admin: catalog rows + _global: { state: true }, // admin: global server rows + _users: { state: true }, // admin: user summaries (for access) + _access: { state: true }, // admin: { server_id -> Set(user_id) } (loaded lazily) + _error: { state: true }, + _modal: { state: true }, + }; + } + + constructor() { + super(); + this._open = false; + this._reset(); + } + + _reset() { + this._me = null; + this._available = null; + this._activated = null; + this._catalog = null; + this._global = null; + this._users = null; + this._error = null; + this._modal = null; + } + + connectedCallback() { + super.connectedCallback(); + window.addEventListener('llm-page-change', (e) => { + this._open = e.detail.page === 'connectors'; + this.style.display = this._open ? 'flex' : 'none'; + if (this._open) this._load(); + }); + } + + get _isAdmin() { return this._me?.role_id === ADMIN_ID; } + + async _load() { + this._error = null; + try { + this._me = await jf('/api/auth/me'); + const [available, activated] = await Promise.all([ + jf('/api/mcp/available'), + jf('/api/mcp/activated'), + ]); + this._available = available; + this._activated = activated; + if (this._isAdmin) { + const [catalog, global, users] = await Promise.all([ + jf('/api/mcp/catalog'), + jf('/api/mcp/global'), + jf('/api/users'), + ]); + this._catalog = catalog; + this._global = global; + this._users = users; + } + } catch (e) { + this._error = e.message; + } + } + + _patch(field, value) { + this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; + } + + _closeModal() { this._modal = null; this._error = null; } + + // ── User: activate / deactivate ──────────────────────────────────────────── + + _openActivate(entry) { + const schema = parseJson(entry.config_schema_json, []); + this._modal = { + kind: 'activate', + entry, + form: { name: entry.name, api_key: '', env: Object.fromEntries((schema || []).map(k => [k, ''])) }, + }; + } + + async _activate() { + const { entry, form } = this._modal; + if (!form.name.trim()) { this._error = 'A name is required.'; return; } + const env = {}; + for (const [k, v] of Object.entries(form.env || {})) if (v !== '') env[k] = v; + try { + await jf('/api/mcp/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + catalog_name: entry.name, + name: form.name.trim(), + api_key: form.api_key || null, + env: Object.keys(env).length ? env : null, + }), + }); + this._closeModal(); + await this._load(); + } catch (e) { this._error = e.message; } + } + + async _deactivate(row) { + if (!confirm(`Deactivate connector "${row.name}"?`)) return; + try { + await jf(`/api/mcp/activated/${row.id}`, { method: 'DELETE' }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + // ── Admin: catalog ───────────────────────────────────────────────────────── + + _openCatalogNew() { + this._modal = { + kind: 'catalog', + form: { + name: '', scope: 'per_user', source: 'remote', transport: 'stdio', + command: '', args: '', url: '', script_path: '', config_schema: '', + auth_kind: 'none', friendly_name: '', description: '', + }, + }; + } + + async _saveCatalog() { + const f = this._modal.form; + if (!f.name.trim()) { this._error = 'Name is required.'; return; } + const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean); + try { + await jf('/api/mcp/catalog', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: f.name.trim(), + scope: f.scope, + source: f.source, + transport: f.transport, + command: f.command.trim() || null, + args: f.args.trim() ? listField(f.args) : null, + url: f.url.trim() || null, + script_path: f.script_path.trim() || null, + config_schema: f.config_schema.trim() ? listField(f.config_schema) : null, + auth_kind: f.auth_kind, + friendly_name: f.friendly_name.trim() || null, + description: f.description.trim() || null, + }), + }); + this._closeModal(); + await this._load(); + } catch (e) { this._error = e.message; } + } + + async _deleteCatalog(row) { + if (!confirm(`Delete catalog entry "${row.name}"?`)) return; + try { + await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + // ── Admin: global connectors + access ────────────────────────────────────── + + _openGlobalEnable() { + const globals = (this._catalog ?? []).filter(c => c.scope === 'global'); + this._modal = { + kind: 'global', + globals, + form: { catalog_name: globals[0]?.name ?? '', name: '', api_key: '' }, + }; + } + + async _enableGlobal() { + const f = this._modal.form; + if (!f.catalog_name) { this._error = 'Pick a catalog entry.'; return; } + try { + await jf('/api/mcp/global', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + catalog_name: f.catalog_name, + name: f.name.trim() || null, + api_key: f.api_key || null, + }), + }); + this._closeModal(); + await this._load(); + } catch (e) { this._error = e.message; } + } + + async _deleteGlobal(row) { + if (!confirm(`Remove global connector "${row.name}"?`)) return; + try { + await jf(`/api/mcp/global/${row.id}`, { method: 'DELETE' }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + async _openAccess(server) { + this._modal = { kind: 'access', server, selected: new Set() }; + try { + const current = await jf(`/api/mcp/global/${server.id}/access`); + // Ignore if the admin already navigated away / opened another modal. + if (this._modal?.kind === 'access' && this._modal.server.id === server.id) { + this._modal = { ...this._modal, selected: new Set(current || []) }; + } + } catch (e) { this._error = e.message; } + } + + _toggleAccess(userId) { + const sel = new Set(this._modal.selected); + sel.has(userId) ? sel.delete(userId) : sel.add(userId); + this._modal = { ...this._modal, selected: sel }; + } + + async _saveAccess() { + const { server, selected } = this._modal; + try { + await jf(`/api/mcp/global/${server.id}/access`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_ids: [...selected] }), + }); + this._closeModal(); + } catch (e) { this._error = e.message; } + } + + // ── Render ───────────────────────────────────────────────────────────────── + + render() { + if (!this._open) return nothing; + const loading = this._available === null && !this._error; + + return html` +
+
+

Connectors

+
+ + ${this._error && !this._modal ? html` +
${this._error}
+ ` : nothing} + + ${loading ? html`
Loading…
` : html` +
+ ${this._renderMine()} + ${this._renderAvailable()} + ${this._isAdmin ? this._renderAdmin() : nothing} +
+ `} +
+ ${this._renderModal()} + `; + } + + _section(title, icon, right, body) { + return html` +
+
+

${title}

+
${right ?? nothing}
+
+ ${body} +
`; + } + + _renderMine() { + const rows = this._activated ?? []; + const globals = this._available?.global ?? []; + return this._section('My connectors', 'bi-check2-circle', nothing, html` + ${globals.length ? html` +
+ Global (granted by admin): ${globals.map(g => html`${g}`)} +
` : nothing} + ${rows.length === 0 ? html`

No per-user connectors activated.

` : html` + + + + ${rows.map(r => html` + + + + + + `)} + +
NameSourceFrom catalog
${r.name}${r.source}${r.catalog_name ? html`${r.catalog_name}` : html``}
+ +
`} + `); + } + + _renderAvailable() { + const entries = this._available?.catalog ?? []; + if (entries.length === 0) return nothing; + const activatedNames = new Set((this._activated ?? []).map(r => r.catalog_name)); + return this._section('Available to activate', 'bi-plus-square', nothing, html` + + + + ${entries.map(e => html` + + + + + + `)} + +
ConnectorSourceAuth
${e.friendly_name || e.name} + ${e.description ? html`
${e.description}
` : nothing}
${e.source}${e.auth_kind}
+ + ${activatedNames.has(e.name) ? html`active` : nothing} +
+ `); + } + + _renderAdmin() { + const catalog = this._catalog ?? []; + const global = this._global ?? []; + return html` +
+ ${this._section('Catalog', 'bi-journal-text', html` + + `, catalog.length === 0 ? html`

Empty catalog.

` : html` + + + + ${catalog.map(c => html` + + + + + + + `)} + +
NameScopeSourceTransport
${c.name}${c.friendly_name ? html` (${c.friendly_name})` : nothing}${c.scope}${c.source}${c.transport}
+ +
+ `)} + + ${this._section('Global connectors', 'bi-globe', html` + + `, global.length === 0 ? html`

No global connectors.

` : html` + + + + ${global.map(g => html` + + + + + + `)} + +
NameTransportEnabled
${g.name}${g.transport}${g.enabled ? html`on` : html`off`}
+ + +
+ `)} + `; + } + + // ── Modals ───────────────────────────────────────────────────────────────── + + _modalShell(title, icon, body, onSave, saveLabel) { + return html` +
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> +
+
+ ${title} + +
+
+ ${this._error ? html`
${this._error}
` : nothing} + ${body} +
+ +
+
`; + } + + _field(label, value, oninput, opts = {}) { + return html`
+ + +
`; + } + + _select(label, value, options, onchange) { + return html`
+ + +
`; + } + + _renderModal() { + if (!this._modal) return nothing; + const m = this._modal; + + if (m.kind === 'activate') { + const f = m.form; + const schema = parseJson(m.entry.config_schema_json, []) || []; + return this._modalShell(`Activate ${m.entry.friendly_name || m.entry.name}`, 'bi-plug', html` + ${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'unique for you', mono: true })} + ${m.entry.auth_kind === 'api_key' ? this._field('API key', f.api_key, e => this._patch('api_key', e.target.value), { type: 'password', mono: true }) : nothing} + ${schema.map(k => html`
+ + this._patch('env', { ...f.env, [k]: e.target.value })} /> +
`)} + `, () => this._activate(), 'Activate'); + } + + if (m.kind === 'catalog') { + const f = m.form; + const isScript = f.source === 'local_script'; + return this._modalShell('New catalog entry', 'bi-journal-plus', html` + ${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'slug', mono: true })} + ${this._select('Scope', f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))} + ${this._select('Source', f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))} + ${this._select('Transport', f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} + ${isScript + ? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: 'python', mono: true })} + ${this._field('Script path', f.script_path, e => this._patch('script_path', e.target.value), { hint: 'under ./scripts', mono: true })}` + : this._field('URL', f.url, e => this._patch('url', e.target.value), { mono: true })} + ${this._field('Args', f.args, e => this._patch('args', e.target.value), { hint: 'one per line', mono: true })} + ${this._field('Required secret/env keys', f.config_schema, e => this._patch('config_schema', e.target.value), { hint: 'comma/newline', mono: true })} + ${this._select('Auth', f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))} + ${this._field('Friendly name', f.friendly_name, e => this._patch('friendly_name', e.target.value))} + ${this._field('Description', f.description, e => this._patch('description', e.target.value))} + `, () => this._saveCatalog(), 'Create'); + } + + if (m.kind === 'global') { + const f = m.form; + return this._modalShell('Enable global connector', 'bi-globe', html` + ${m.globals.length === 0 ? html`
No global-scoped catalog entries yet. Add one to the catalog first.
` : nothing} + ${this._select('Catalog entry', f.catalog_name, m.globals.map(g => g.name), e => this._patch('catalog_name', e.target.value))} + ${this._field('Name override', f.name, e => this._patch('name', e.target.value), { hint: 'optional', mono: true })} + ${this._field('API key', f.api_key, e => this._patch('api_key', e.target.value), { type: 'password', mono: true })} + `, () => this._enableGlobal(), 'Enable'); + } + + if (m.kind === 'access') { + const users = this._users ?? []; + return this._modalShell(`Access — ${m.server.name}`, 'bi-people', html` +
Select who may use this global connector. This replaces the current list.
+ ${users.map(u => html`
+ this._toggleAccess(u.id)} /> + +
`)} + `, () => this._saveAccess(), 'Save access'); + } + + return nothing; + } +} diff --git a/web/components/sidebar.js b/web/components/sidebar.js index 0a509fe..b08205a 100644 --- a/web/components/sidebar.js +++ b/web/components/sidebar.js @@ -106,7 +106,7 @@ export class AppSidebar extends LightElement { // Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`). const match = hash.match(/^([^/?]+)/); const segment = match ? match[1] : ''; - return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; + return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; } _tasksSectionFromHash() { @@ -286,6 +286,11 @@ export class AppSidebar extends LightElement { Roles + this._togglePage('connectors', e)}> + + Connectors + this._togglePage('config', e)}> diff --git a/web/index.html b/web/index.html index 2d1e68f..7455592 100644 --- a/web/index.html +++ b/web/index.html @@ -92,6 +92,7 @@ +