feat(mcp): OAuth per-user connectors (§15) — providers, PKCE copy-paste flow, env credential delivery

- oauth_providers registry table (per-provider client creds) + db/oauth_providers.rs
- mcp/oauth.rs: authorization-code + PKCE S256, RAM-only TTL'd flow store, copy-paste consent
- mcp/install.rs + verify.rs: connector file install + manifest verification
- activate persists a pending row (needs_oauth); /mcp/oauth/start + /complete exchange code for refresh token
- credential delivery via env var on docker exec (google_authorized_user JSON), never on disk
- mcp_catalog/mcp_user_servers: additive OAuth columns (ensure_column), catalog_name/oauth_provider/deliver_json bare TEXT snapshots
- frontend: connector-detail.js (OAuth login panel), shared/connector-common.js, connectors.js admin Sign-in providers modal
- API: /mcp/providers (admin OAuth creds), /mcp/oauth/start|complete
- .gitignore: add /homes/ (instance data), /connectors/, /reset.sh; drop stale /secrets/
This commit is contained in:
2026-07-17 21:47:51 +01:00
parent bcd8f7b5c0
commit e6c4e202a4
28 changed files with 3349 additions and 553 deletions
+6 -3
View File
@@ -2,14 +2,13 @@
# Copy of default.config.yaml with real API keys — never commit # Copy of default.config.yaml with real API keys — never commit
/config.yml /config.yml
/config/ /config/
# OAuth tokens, credentials, WhatsApp session data
/secrets/
!/secrets/.gitkeep
config.yml.bak config.yml.bak
blueprint/ blueprint/
/.understand-anything /.understand-anything
# ── Database & runtime data ─────────────────────────────────────────────────── # ── Database & runtime data ───────────────────────────────────────────────────
/database/ /database/
# Per-user container home dirs ({WD}/homes/{userid}) — instance data, not source
/homes/
# SQLite WAL-mode sidecar files (journal_mode=WAL) # SQLite WAL-mode sidecar files (journal_mode=WAL)
*.db-wal *.db-wal
*.db-shm *.db-shm
@@ -18,6 +17,9 @@ blueprint/
/logs/ /logs/
/tmp/ /tmp/
/scripts/ /scripts/
# Connector folders installed from the marketplace — instance data, like homes/
# and database/, not source. See crates/skald-core/src/mcp/install.rs
/connectors/
# ── Rust build artifacts ────────────────────────────────────────────────────── # ── Rust build artifacts ──────────────────────────────────────────────────────
/target/ /target/
@@ -58,6 +60,7 @@ scripts/.gitignore
*.swo *.swo
run-log.sh run-log.sh
/backup.sh /backup.sh
/reset.sh
debug/ debug/
# Honcho Docker secrets # Honcho Docker secrets
+19 -6
View File
@@ -100,8 +100,10 @@ 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: 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`, `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_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`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. 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. - **`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; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). 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.
Schema is greenfield (no migrations, §0), but a purely **additive** column lands on an existing DB in place: `db::ensure_column` runs `ALTER TABLE … ADD COLUMN` and swallows the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already has the column. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers` so a dev box need not be wiped for an additive change (a full recreate is still valid).
**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). **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).
@@ -145,11 +147,20 @@ MCP servers are surfaced to users as **"Connectors"** (UI naming; `mcp`/schema s
**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`. **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). **Tables** (see DB section) — registry: `mcp_catalog` (admin-vetted templates; holds only the *schema* of what an activation must supply, never live creds — plus, for OAuth, `oauth_provider` + `oauth_scopes_json` + `deliver_json`), `mcp_global_servers` + `mcp_global_access`, `oauth_providers` (per-provider client creds), `role_capabilities`. Owner: `mcp_user_servers` (per-user activations; `api_key` encrypted at rest — the refresh token for an OAuth one — `catalog_name`/`oauth_provider`/`deliver_json` bare `TEXT` snapshots).
**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` (`<connectors-page>`) renders the user view (activate/deactivate + granted globals) always, and the admin view (catalog + global + per-server access grants) when `role_id === 'admin'`. **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), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 login). `connectors.js` (`<connectors-page>`) renders the user view (activate/deactivate + granted globals) always, plus the admin view (catalog + global + per-server access + a **Sign-in providers** modal) when `role_id === 'admin'`; `connector-detail.js` (`<connector-detail-page>`) is a connector's own page and hosts the OAuth login panel.
**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. ### OAuth per-user connectors (blueprint §15 — copy-paste flow)
OAuth2 authorization-code + PKCE is wired for per-user connectors (Gmail is the first). The consent is a **human copy-paste**, not a headless action: no callback route into the (NAT'd, hostname-less) box, and no client secret on the public feed.
- **Providers, not per-connector URLs.** The client is per-**provider** (one Google app covers Gmail/Calendar/Drive): `oauth_providers` holds `auth_url`/`token_url`/`client_id`/`client_secret`/`redirect_uri`/`extra_params`, admin-entered via the Sign-in-providers modal (Google preset fills all but the two secrets; `redirect_uri` = the static `oauth/show.html` page, `extra_params` = `access_type=offline`+`prompt=consent` so Google returns a refresh token). The manifest only names `auth.provider` + `auth.scopes` + `auth.deliver` — never URLs or secrets (feed is remote data, §14).
- **Flow** (`mcp/oauth.rs`): `activate` on an OAuth catalog entry persists a **pending** `mcp_user_servers` row (files installed, command wired, no token) and returns `needs_oauth` — it does **not** start the server. `/mcp/oauth/start` builds the consent URL (PKCE S256 + opaque `state`) and stashes the verifier in a RAM-only, TTL'd flow store keyed by `state`; the user approves in a browser, the provider lands the code on `oauth/show.html`, they paste it back. `/mcp/oauth/complete` exchanges code+verifier for a refresh token (`client_secret` sent server-side), stores it in the row's `api_key`, flips to `ready`, and starts the server. PKCE makes an intercepted code worthless; a restart drops in-flight flows (mirrors the RAM-only session model).
- **Credential delivery = env, nothing on disk.** The manifest's `deliver` (`{as,format,env}`, parsed as `mcp::DeliverSpec`) says how the token reaches the server. `user_row_spec_resolved` assembles the credential (`google_authorized_user` JSON = client creds from the provider + refresh token) and injects it as an env var (`GMAIL_CREDS_JSON`) on the `docker exec` — never a file, coherent with §2 (the tempted admin doesn't read `/proc`). The server reads it via `Credentials.from_authorized_user_info`. Ran both at OAuth-complete and at login-time per-user startup.
- **Google needs a Web-application client**: a Desktop client rejects an `https://` redirect (loopback only), so the `oauth/show.html` redirect must be registered on a **Web app** OAuth client, and exact-match under Authorized redirect URIs — `redirect_uri_mismatch` otherwise.
**Deferred:** the other §15 interactive kinds (QR / SSH via elicitation) — `deliver.as=file` and non-Google providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace.
## Sub-agent system ## Sub-agent system
@@ -251,7 +262,9 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/
| `agent-inbox.js` | `<agent-inbox-page>` | Pending approvals + clarifications from background sessions | | `agent-inbox.js` | `<agent-inbox-page>` | Pending approvals + clarifications from background sessions |
| `approval-rules.js` | `<approval-rules-page>` | Approval rule management | | `approval-rules.js` | `<approval-rules-page>` | Approval rule management |
| `cron-jobs.js` | `<cron-jobs-page>` | Scheduled job management | | `cron-jobs.js` | `<cron-jobs-page>` | Scheduled job management |
| `connectors.js` | `<connectors-page>` | MCP Connectors: user activate/deactivate + granted globals; admin catalog + global-server + per-server access management (§7/§14/§15) | | `connectors.js` | `<connectors-page>` | MCP Connectors list (one row per connector): user activate/deactivate + granted globals; admin gets a **Sign-in providers** modal (OAuth client creds) + Catalog/Marketplace nav (§7/§14/§15) |
| `connector-detail.js` | `<connector-detail-page>` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable + per-user access grants |
| `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch |
| `llm-providers.js` | `<llm-providers-page>` | LLM provider management | | `llm-providers.js` | `<llm-providers-page>` | LLM provider management |
| `models-hub.js` | `<models-hub-page>` | Models hub landing (LLM / Transcription / Image) | | `models-hub.js` | `<models-hub-page>` | Models hub landing (LLM / Transcription / Image) |
| `models-llm.js` | `<models-llm-section>` | LLM model CRUD + drag-and-drop priority | | `models-llm.js` | `<models-llm-section>` | LLM model CRUD + drag-and-drop priority |
+22 -13
View File
@@ -338,12 +338,15 @@ impl ApprovalManager {
/// shared memory is visible to everyone, so a write is a deliberate, human-confirmed /// shared memory is visible to everyone, so a write is a deliberate, human-confirmed
/// act — the agent must not silently push one person's information into it. /// act — the agent must not silently push one person's information into it.
/// - `data/*` → **allow** (scratch/data workspace). /// - `data/*` → **allow** (scratch/data workspace).
/// - `secrets/*` → **deny** (`@fs_any` denies reads *and* writes; a read would leak
/// the secret into the LLM context / history / WS stream, and `Deny` is
/// non-bypassable). The `/*` pattern also matches the `secrets` dir node itself, so
/// recursive `list_files`/`grep_files` rooted at it are covered.
/// - `memory_search` → **allow**, path-less: it searches note *content* (arg `query`, /// - `memory_search` → **allow**, path-less: it searches note *content* (arg `query`,
/// not `path`), so it needs a tool-scoped rule rather than a path pattern. /// not `path`), so it needs a tool-scoped rule rather than a path pattern.
///
/// There is no `secrets/*` deny any more. It guarded an on-disk credential store
/// that no longer exists — connectors now take their credentials from the
/// activation form, held in the owner DB. Worse, it had quietly become wrong:
/// fs-tool paths are rooted at the caller's own home (§6), so `secrets/*` had
/// stopped meaning "the box's credential store" and started meaning "any folder a
/// user dared name `secrets`".
pub async fn seed_fs_path_rules(&self) -> Result<()> { pub async fn seed_fs_path_rules(&self) -> Result<()> {
// (tool_pattern, path_pattern, action, note). `path_pattern = None` is a // (tool_pattern, path_pattern, action, note). `path_pattern = None` is a
// tool-scoped rule that matches regardless of args. // tool-scoped rule that matches regardless of args.
@@ -352,7 +355,6 @@ impl ApprovalManager {
("@fs_read", Some("shared-memory/*"), "allow", "auto-allow read shared-memory/"), ("@fs_read", Some("shared-memory/*"), "allow", "auto-allow read shared-memory/"),
("@fs_write", Some("shared-memory/*"), "require", "require write shared-memory/"), ("@fs_write", Some("shared-memory/*"), "require", "require write shared-memory/"),
("@fs_any", Some("data/*"), "allow", "auto-allow data/"), ("@fs_any", Some("data/*"), "allow", "auto-allow data/"),
("@fs_any", Some("secrets/*"), "deny", "deny secrets/ access"),
("memory_search", None, "allow", "allow memory_search"), ("memory_search", None, "allow", "allow memory_search"),
]; ];
@@ -409,7 +411,9 @@ impl ApprovalManager {
/// - the per-tool write `require` defaults (`note = 'default rule'`, no path) — fs /// - the per-tool write `require` defaults (`note = 'default rule'`, no path) — fs
/// gating now lives in the File System panel + the `*` catch-all; /// gating now lives in the File System panel + the `*` catch-all;
/// - the old `data/*` allow rows (`note = 'auto-allow data/ writes'`); /// - the old `data/*` allow rows (`note = 'auto-allow data/ writes'`);
/// - the old `secrets` deny rows (`note = 'deny reading secrets/'`); /// - both generations of `secrets/` deny row (`note = 'deny reading secrets/'` and
/// `'deny secrets/ access'`) — the on-disk secrets store is gone, and rooted at a
/// user's home the pattern had come to deny them any folder named `secrets`;
/// - the old single `memory/*` allow row (`note = 'auto-allow memory/'`) — the memory /// - the old single `memory/*` allow row (`note = 'auto-allow memory/'`) — the memory
/// namespace split into `user-memory/` + `shared-memory/`, so the `memory/*` pattern /// namespace split into `user-memory/` + `shared-memory/`, so the `memory/*` pattern
/// no longer routes and would otherwise linger as a stale allow on a disk `./memory/`. /// no longer routes and would otherwise linger as a stale allow on a disk `./memory/`.
@@ -430,7 +434,10 @@ impl ApprovalManager {
.await? .await?
.rows_affected(); .rows_affected();
let n3 = sqlx::query("DELETE FROM approval_rules WHERE note = 'deny reading secrets/'") let n3 = sqlx::query(
"DELETE FROM approval_rules
WHERE note IN ('deny reading secrets/', 'deny secrets/ access')",
)
.execute(self.db.as_ref()) .execute(self.db.as_ref())
.await? .await?
.rows_affected(); .rows_affected();
@@ -1154,14 +1161,15 @@ mod tests {
// Legacy per-tool fs rows are migrated away… // Legacy per-tool fs rows are migrated away…
let legacy: i64 = sqlx::query_scalar( let legacy: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM approval_rules "SELECT COUNT(*) FROM approval_rules
WHERE note IN ('default rule', 'auto-allow data/ writes', 'deny reading secrets/')", WHERE note IN ('default rule', 'auto-allow data/ writes', 'deny reading secrets/',
'deny secrets/ access')",
) )
.fetch_one(db.as_ref()) .fetch_one(db.as_ref())
.await .await
.unwrap(); .unwrap();
assert_eq!(legacy, 0, "legacy fs rules should be removed by migration"); assert_eq!(legacy, 0, "legacy fs rules should be removed by migration");
// …and replaced by exactly the five @fs_* token rows (shared-memory has two: // …and replaced by exactly the four @fs_* token rows (shared-memory has two:
// read-allow and write-require). // read-allow and write-require).
let fs_rows: i64 = sqlx::query_scalar( let fs_rows: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'", "SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'",
@@ -1169,7 +1177,7 @@ mod tests {
.fetch_one(db.as_ref()) .fetch_one(db.as_ref())
.await .await
.unwrap(); .unwrap();
assert_eq!(fs_rows, 5, "user-memory + shared-memory(r/w) + data + secrets @fs_* rules should be seeded"); assert_eq!(fs_rows, 4, "user-memory + shared-memory(r/w) + data @fs_* rules should be seeded");
// Gate decisions through the real check() path. // Gate decisions through the real check() path.
async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult { async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult {
@@ -1186,9 +1194,10 @@ mod tests {
assert!(matches!(decide(&mgr, "read_file", "data/x.txt").await, GateResult::Allow)); assert!(matches!(decide(&mgr, "read_file", "data/x.txt").await, GateResult::Allow));
// memory_search is allowed by a path-less tool rule (it has `query`, not `path`). // memory_search is allowed by a path-less tool rule (it has `query`, not `path`).
assert!(matches!(decide(&mgr, "memory_search", "ignored").await, GateResult::Allow)); assert!(matches!(decide(&mgr, "memory_search", "ignored").await, GateResult::Allow));
// Improvement over legacy: secrets *writes* are now denied too, not just reads. // The on-disk secrets store is gone, and with it its blanket deny: `secrets/`
assert!(matches!(decide(&mgr, "write_file", "secrets/key").await, GateResult::Deny)); // is now an ordinary path in the caller's own home, gated by the catch-all
assert!(matches!(decide(&mgr, "read_file", "secrets/key").await, GateResult::Deny)); // like any other. Denying it would deny a user their own folder.
assert!(matches!(decide(&mgr, "read_file", "secrets/key").await, GateResult::Require));
// Unmatched write falls through to the `*` catch-all. // Unmatched write falls through to the `*` catch-all.
assert!(matches!(decide(&mgr, "write_file", "src/main.rs").await, GateResult::Require)); assert!(matches!(decide(&mgr, "write_file", "src/main.rs").await, GateResult::Require));
// Non-filesystem tool: unaffected by @fs_* rules, gated by catch-all. // Non-filesystem tool: unaffected by @fs_* rules, gated by catch-all.
+61 -6
View File
@@ -26,14 +26,32 @@ pub struct McpCatalogRow {
pub args_json: Option<String>, pub args_json: Option<String>,
pub env_json: Option<String>, pub env_json: Option<String>,
pub url: Option<String>, pub url: Option<String>,
/// local_script: the vetted source path under `./scripts`. /// local_script: the vetted entry file, as `<connector>/<file>` under
/// `./connectors` (see [`crate::mcp::install`]).
pub script_path: Option<String>, pub script_path: Option<String>,
/// Names of the env/secret keys the activation UI must collect (never values). /// JSON array of `{name,label,description,required,secret,example,default}` objects
/// describing the env/secret fields the activation UI must collect (never values).
pub config_schema_json: Option<String>, pub config_schema_json: Option<String>,
/// 'none'|'api_key'|'oauth'|'qr'|'ssh_key'. Only 'none'/'api_key' are wired now. /// 'none'|'api_key'|'oauth'|'qr'|'ssh_key'.
pub auth_kind: String, pub auth_kind: String,
/// oauth: slug into `oauth_providers.name` (which app to consent to).
pub oauth_provider: Option<String>,
/// oauth: JSON array of the scopes this connector requests at consent.
pub oauth_scopes_json: Option<String>,
/// oauth: JSON `{as,format,env,path}` — how Skald delivers the obtained
/// credential to the connector's server process (§15).
pub deliver_json: Option<String>,
/// JSON array of role ids allowed to activate this; NULL = all roles (§15). /// JSON array of role ids allowed to activate this; NULL = all roles (§15).
pub role_filter: Option<String>, pub role_filter: Option<String>,
/// Shell command run before persisting an activation (verify-before-save).
pub verify_command: Option<String>,
/// Script file the verify command references (e.g. `verify.py`), if any.
pub verify_script_path: Option<String>,
/// Icon file *inside* `./connectors/<name>/`, if the feed shipped one. Stored
/// rather than derived because the manifest names its icons freely (`.png` for
/// one connector, `.svg` for the next), and the browser cannot guess.
pub icon_small_path: Option<String>,
pub icon_large_path: Option<String>,
pub friendly_name: Option<String>, pub friendly_name: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub created_at: String, pub created_at: String,
@@ -65,11 +83,21 @@ impl McpCatalogRow {
Some(roles) => roles.iter().any(|r| r == role_id), Some(roles) => roles.iter().any(|r| r == role_id),
} }
} }
/// The OAuth scopes this connector requests at consent, or empty when it is not
/// an OAuth connector.
pub fn oauth_scopes(&self) -> Vec<String> {
self.oauth_scopes_json.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default()
}
} }
const SELECT: &str = const SELECT: &str =
"SELECT id, name, scope, source, transport, command, args_json, env_json, url, \ "SELECT id, name, scope, source, transport, command, args_json, env_json, url, \
script_path, config_schema_json, auth_kind, role_filter, friendly_name, \ script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
deliver_json, role_filter, verify_command, \
verify_script_path, icon_small_path, icon_large_path, friendly_name, \
description, created_at \ description, created_at \
FROM mcp_catalog"; FROM mcp_catalog";
@@ -122,7 +150,14 @@ pub struct UpsertCatalog<'a> {
pub script_path: Option<&'a str>, pub script_path: Option<&'a str>,
pub config_schema_json: Option<String>, pub config_schema_json: Option<String>,
pub auth_kind: &'a str, pub auth_kind: &'a str,
pub oauth_provider: Option<&'a str>,
pub oauth_scopes_json: Option<String>,
pub deliver_json: Option<String>,
pub role_filter: Option<String>, pub role_filter: Option<String>,
pub verify_command: Option<&'a str>,
pub verify_script_path: Option<&'a str>,
pub icon_small_path: Option<&'a str>,
pub icon_large_path: Option<&'a str>,
pub friendly_name: Option<&'a str>, pub friendly_name: Option<&'a str>,
pub description: Option<&'a str>, pub description: Option<&'a str>,
} }
@@ -131,8 +166,10 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
let row = sqlx::query_as::<_, (i64,)>( let row = sqlx::query_as::<_, (i64,)>(
"INSERT INTO mcp_catalog "INSERT INTO mcp_catalog
(name, scope, source, transport, command, args_json, env_json, url, (name, scope, source, transport, command, args_json, env_json, url,
script_path, config_schema_json, auth_kind, role_filter, friendly_name, description) script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) deliver_json, role_filter, verify_command,
verify_script_path, icon_small_path, icon_large_path, friendly_name, description)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
scope = excluded.scope, scope = excluded.scope,
source = excluded.source, source = excluded.source,
@@ -144,7 +181,18 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
script_path = excluded.script_path, script_path = excluded.script_path,
config_schema_json = excluded.config_schema_json, config_schema_json = excluded.config_schema_json,
auth_kind = excluded.auth_kind, auth_kind = excluded.auth_kind,
oauth_provider = excluded.oauth_provider,
oauth_scopes_json = excluded.oauth_scopes_json,
deliver_json = excluded.deliver_json,
role_filter = excluded.role_filter, role_filter = excluded.role_filter,
verify_command = excluded.verify_command,
verify_script_path = excluded.verify_script_path,
-- Icons belong to whoever installed the files, not to whoever last
-- edited the row: COALESCE keeps them when an admin saves the catalog
-- form (which knows nothing about icons and would otherwise blank
-- them), while a reinstall still updates them.
icon_small_path = COALESCE(excluded.icon_small_path, mcp_catalog.icon_small_path),
icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
friendly_name = excluded.friendly_name, friendly_name = excluded.friendly_name,
description = excluded.description description = excluded.description
RETURNING id", RETURNING id",
@@ -160,7 +208,14 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
.bind(e.script_path) .bind(e.script_path)
.bind(e.config_schema_json) .bind(e.config_schema_json)
.bind(e.auth_kind) .bind(e.auth_kind)
.bind(e.oauth_provider)
.bind(e.oauth_scopes_json)
.bind(e.deliver_json)
.bind(e.role_filter) .bind(e.role_filter)
.bind(e.verify_command)
.bind(e.verify_script_path)
.bind(e.icon_small_path)
.bind(e.icon_large_path)
.bind(e.friendly_name) .bind(e.friendly_name)
.bind(e.description) .bind(e.description)
.fetch_one(pool) .fetch_one(pool)
+14 -3
View File
@@ -23,6 +23,10 @@ pub struct McpGlobalServerRow {
pub env_json: Option<String>, pub env_json: Option<String>,
pub url: Option<String>, pub url: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
/// Snapshot of `mcp_catalog.verify_command` (NULL = no test).
pub verify_command: Option<String>,
/// Absolute host path of the verify script, if any.
pub verify_script_path: Option<String>,
pub friendly_name: Option<String>, pub friendly_name: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub enabled: bool, pub enabled: bool,
@@ -44,7 +48,7 @@ impl McpGlobalServerRow {
const SELECT: &str = const SELECT: &str =
"SELECT id, name, catalog_name, transport, command, args_json, env_json, url, \ "SELECT id, name, catalog_name, transport, command, args_json, env_json, url, \
api_key, friendly_name, description, enabled \ api_key, verify_command, verify_script_path, friendly_name, description, enabled \
FROM mcp_global_servers"; FROM mcp_global_servers";
// ── Reads ──────────────────────────────────────────────────────────────────── // ── Reads ────────────────────────────────────────────────────────────────────
@@ -90,6 +94,8 @@ pub struct UpsertGlobal<'a> {
pub env_json: Option<String>, pub env_json: Option<String>,
pub url: Option<&'a str>, pub url: Option<&'a str>,
pub api_key: Option<&'a str>, pub api_key: Option<&'a str>,
pub verify_command: Option<&'a str>,
pub verify_script_path: Option<&'a str>,
pub friendly_name: Option<&'a str>, pub friendly_name: Option<&'a str>,
pub description: Option<&'a str>, pub description: Option<&'a str>,
} }
@@ -97,8 +103,9 @@ pub struct UpsertGlobal<'a> {
pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> { pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
let row = sqlx::query_as::<_, (i64,)>( let row = sqlx::query_as::<_, (i64,)>(
"INSERT INTO mcp_global_servers "INSERT INTO mcp_global_servers
(name, catalog_name, transport, command, args_json, env_json, url, api_key, friendly_name, description, enabled) (name, catalog_name, transport, command, args_json, env_json, url, api_key,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1) verify_command, verify_script_path, friendly_name, description, enabled)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1)
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
catalog_name = excluded.catalog_name, catalog_name = excluded.catalog_name,
transport = excluded.transport, transport = excluded.transport,
@@ -107,6 +114,8 @@ pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
env_json = excluded.env_json, env_json = excluded.env_json,
url = excluded.url, url = excluded.url,
api_key = excluded.api_key, api_key = excluded.api_key,
verify_command = excluded.verify_command,
verify_script_path = excluded.verify_script_path,
friendly_name = excluded.friendly_name, friendly_name = excluded.friendly_name,
description = excluded.description, description = excluded.description,
enabled = 1 enabled = 1
@@ -120,6 +129,8 @@ pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
.bind(p.env_json) .bind(p.env_json)
.bind(p.url) .bind(p.url)
.bind(p.api_key) .bind(p.api_key)
.bind(p.verify_command)
.bind(p.verify_script_path)
.bind(p.friendly_name) .bind(p.friendly_name)
.bind(p.description) .bind(p.description)
.fetch_one(pool) .fetch_one(pool)
+43 -4
View File
@@ -27,10 +27,20 @@ pub struct McpUserServerRow {
pub args_json: Option<String>, pub args_json: Option<String>,
pub env_json: Option<String>, pub env_json: Option<String>,
pub url: Option<String>, pub url: Option<String>,
/// Per-user secret. For an OAuth connector this holds the refresh token; empty
/// until the OAuth flow completes (`auth_state='pending'`).
pub api_key: Option<String>, pub api_key: Option<String>,
/// oauth: snapshot of the catalog's `oauth_provider` (which app issued the token).
pub oauth_provider: Option<String>,
/// oauth: snapshot of the catalog's delivery spec `{as,format,env,path}`.
pub deliver_json: Option<String>,
/// Container path of the copied script, for a `local_script`. /// Container path of the copied script, for a `local_script`.
pub script_rel_path: Option<String>, pub script_rel_path: Option<String>,
/// 'pending' | 'ready' — the interactive-auth gate ('ready' while api-key). /// Snapshot of `mcp_catalog.verify_command` (NULL = no test).
pub verify_command: Option<String>,
/// Container path of the verify script, if any.
pub verify_script_rel_path: Option<String>,
/// 'pending' | 'ready' — the verify-before-save gate.
pub auth_state: String, pub auth_state: String,
pub enabled: bool, pub enabled: bool,
} }
@@ -47,11 +57,19 @@ impl McpUserServerRow {
.and_then(|s| serde_json::from_str(s).ok()) .and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default() .unwrap_or_default()
} }
/// The credential delivery spec (`{as,format,env,path}`), if this is an OAuth
/// connector that snapshotted one.
pub fn deliver(&self) -> Option<crate::mcp::DeliverSpec> {
self.deliver_json.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
}
} }
const SELECT: &str = const SELECT: &str =
"SELECT id, name, catalog_name, source, transport, command, args_json, env_json, url, \ "SELECT id, name, catalog_name, source, transport, command, args_json, env_json, url, \
api_key, script_rel_path, auth_state, enabled \ api_key, oauth_provider, deliver_json, script_rel_path, verify_command, \
verify_script_rel_path, auth_state, enabled \
FROM mcp_user_servers"; FROM mcp_user_servers";
// ── Reads ──────────────────────────────────────────────────────────────────── // ── Reads ────────────────────────────────────────────────────────────────────
@@ -102,15 +120,21 @@ pub struct InsertUserServer<'a> {
pub env_json: Option<String>, pub env_json: Option<String>,
pub url: Option<&'a str>, pub url: Option<&'a str>,
pub api_key: Option<&'a str>, pub api_key: Option<&'a str>,
pub oauth_provider: Option<&'a str>,
pub deliver_json: Option<String>,
pub script_rel_path: Option<&'a str>, pub script_rel_path: Option<&'a str>,
pub verify_command: Option<&'a str>,
pub verify_script_rel_path: Option<&'a str>,
pub auth_state: &'a str, pub auth_state: &'a str,
} }
pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> { pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
let id = sqlx::query( let id = sqlx::query(
"INSERT INTO mcp_user_servers "INSERT INTO mcp_user_servers
(name, catalog_name, source, transport, command, args_json, env_json, url, api_key, script_rel_path, auth_state, enabled) (name, catalog_name, source, transport, command, args_json, env_json, url, api_key,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 1)", oauth_provider, deliver_json, script_rel_path, verify_command, verify_script_rel_path,
auth_state, enabled)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 1)",
) )
.bind(s.name) .bind(s.name)
.bind(s.catalog_name) .bind(s.catalog_name)
@@ -121,7 +145,11 @@ pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
.bind(s.env_json) .bind(s.env_json)
.bind(s.url) .bind(s.url)
.bind(s.api_key) .bind(s.api_key)
.bind(s.oauth_provider)
.bind(s.deliver_json)
.bind(s.script_rel_path) .bind(s.script_rel_path)
.bind(s.verify_command)
.bind(s.verify_script_rel_path)
.bind(s.auth_state) .bind(s.auth_state)
.execute(pool) .execute(pool)
.await? .await?
@@ -129,6 +157,17 @@ pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
Ok(id) Ok(id)
} }
/// Stores a freshly-obtained OAuth refresh token and flips the connector to
/// `ready`, in one write — the completion of the §15 login flow.
pub async fn set_oauth_token(pool: &SqlitePool, id: i64, refresh_token: &str) -> Result<()> {
sqlx::query("UPDATE mcp_user_servers SET api_key = ?1, auth_state = 'ready' WHERE id = ?2")
.bind(refresh_token)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<()> { pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<()> {
sqlx::query("UPDATE mcp_user_servers SET enabled = ?1 WHERE id = ?2") sqlx::query("UPDATE mcp_user_servers SET enabled = ?1 WHERE id = ?2")
.bind(enabled as i64) .bind(enabled as i64)
+64 -2
View File
@@ -17,6 +17,7 @@ pub mod mcp_global_access;
pub mod mcp_global_servers; pub mod mcp_global_servers;
pub mod mcp_user_servers; pub mod mcp_user_servers;
pub mod memory_docs; pub mod memory_docs;
pub mod oauth_providers;
pub mod plugins; pub mod plugins;
pub mod role_capabilities; pub mod role_capabilities;
pub mod roles; pub mod roles;
@@ -155,6 +156,21 @@ pub async fn open_user_pool(path: &Path, key: Option<&Dek>) -> Result<SqlitePool
Ok(pool) Ok(pool)
} }
/// Adds a nullable column if it is not already present, so a purely **additive**
/// schema change lands on an existing database without a wipe. Greenfield still
/// permits a clean recreate (§0); this only spares an existing box's data when the
/// change is additive, and is a no-op on a fresh DB where the column already exists
/// in the `CREATE TABLE`. The "duplicate column name" error means it's already there.
async fn ensure_column(pool: &SqlitePool, table: &str, column: &str, decl: &str) -> Result<()> {
let sql = format!("ALTER TABLE {table} ADD COLUMN {column} {decl}");
if let Err(e) = sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await {
if !e.to_string().contains("duplicate column name") {
return Err(e.into());
}
}
Ok(())
}
// ── Registry tables ─────────────────────────────────────────────────────────── // ── Registry tables ───────────────────────────────────────────────────────────
// //
// Instance-wide, readable without any user key: the directory you must open // Instance-wide, readable without any user key: the directory you must open
@@ -442,10 +458,17 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
args_json TEXT, args_json TEXT,
env_json TEXT, env_json TEXT,
url TEXT, url TEXT,
script_path TEXT, -- local_script: source under ./scripts script_path TEXT, -- local_script: entry file, as <connector>/<file> under ./connectors
config_schema_json TEXT, -- names of env/secret keys the UI must collect config_schema_json TEXT, -- env[] entries (objects) the UI must collect
auth_kind TEXT NOT NULL DEFAULT 'none', -- 'none'|'api_key'|'oauth'|'qr'|'ssh_key' auth_kind TEXT NOT NULL DEFAULT 'none', -- 'none'|'api_key'|'oauth'|'qr'|'ssh_key'
oauth_provider TEXT, -- oauth: slug into oauth_providers.name
oauth_scopes_json TEXT, -- oauth: JSON array of scopes requested at consent
deliver_json TEXT, -- oauth: {as,format,env,path} credential delivery spec
role_filter TEXT, -- JSON array of role ids; NULL = all role_filter TEXT, -- JSON array of role ids; NULL = all
verify_command TEXT, -- shell command run before persisting an activation
verify_script_path TEXT, -- script file the verify command references, if any
icon_small_path TEXT, -- icon file inside ./connectors/<name>/, if the feed shipped one
icon_large_path TEXT,
friendly_name TEXT, friendly_name TEXT,
description TEXT, description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -453,6 +476,10 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
) )
.execute(pool) .execute(pool)
.await?; .await?;
// OAuth columns are additive (§15) — reach an already-created catalog in place.
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?;
// Concrete globally-active connectors (shared, stateless — web-search etc.). // Concrete globally-active connectors (shared, stateless — web-search etc.).
// They run on the HOST. The global secret (admin's API key) is fine here: // They run on the HOST. The global secret (admin's API key) is fine here:
@@ -469,6 +496,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
env_json TEXT, env_json TEXT,
url TEXT, url TEXT,
api_key TEXT, api_key TEXT,
verify_command TEXT, -- snapshot of mcp_catalog.verify_command
verify_script_path TEXT, -- absolute host path of the verify script, if any
friendly_name TEXT, friendly_name TEXT,
description TEXT, description TEXT,
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
@@ -503,6 +532,32 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
// OAuth providers for per-user connectors (blueprint §15). One row per identity
// provider (Google, …), referenced by name from `mcp_catalog.oauth_provider`. A
// single app covers every service of that provider (Gmail, Calendar, Drive) —
// client credentials are keyed on the provider, scopes on the connector.
//
// Registry table (`system.db`): `client_secret` is a household/global secret the
// admin owns (§4/§15b), not a per-user one, so it belongs here in the admin-
// readable file. The per-user refresh tokens each activation obtains never land
// here — they go, encrypted, into the user's `mcp_user_servers.api_key`.
sqlx::query(
"CREATE TABLE IF NOT EXISTS oauth_providers (
name TEXT PRIMARY KEY, -- slug referenced by mcp_catalog.oauth_provider
display_name TEXT NOT NULL,
auth_url TEXT NOT NULL, -- authorization endpoint
token_url TEXT NOT NULL, -- token endpoint
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
redirect_uri TEXT NOT NULL, -- copy-paste landing page (oauth/show.html)
extra_params TEXT, -- JSON of extra auth params (access_type, prompt, …)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
Ok(()) Ok(())
} }
@@ -752,7 +807,11 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
env_json TEXT, env_json TEXT,
url TEXT, url TEXT,
api_key TEXT, -- per-user secret / OAuth refresh token api_key TEXT, -- per-user secret / OAuth refresh token
oauth_provider TEXT, -- oauth: snapshot of catalog oauth_provider
deliver_json TEXT, -- oauth: snapshot of catalog credential delivery spec
script_rel_path TEXT, -- container path for a local_script script_rel_path TEXT, -- container path for a local_script
verify_command TEXT, -- snapshot of mcp_catalog.verify_command
verify_script_rel_path TEXT, -- container path of the verify script, if any
auth_state TEXT NOT NULL DEFAULT 'ready', -- 'pending' | 'ready' auth_state TEXT NOT NULL DEFAULT 'ready', -- 'pending' | 'ready'
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -760,6 +819,9 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
) )
.execute(pool) .execute(pool)
.await?; .await?;
// OAuth columns are additive (§15) — reach an already-created table in place.
ensure_column(pool, "mcp_user_servers", "oauth_provider", "TEXT").await?;
ensure_column(pool, "mcp_user_servers", "deliver_json", "TEXT").await?;
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS sources ( "CREATE TABLE IF NOT EXISTS sources (
+147
View File
@@ -0,0 +1,147 @@
//! OAuth identity providers for per-user connectors (blueprint §15).
//!
//! Registry table in `system.db`: one row per provider (Google, …), keyed by a
//! stable slug that `mcp_catalog.oauth_provider` references. A single provider row
//! covers every service that provider exposes (Gmail, Calendar, Drive) — the
//! client credentials live here, the per-connector scopes in the catalog.
//!
//! `client_secret` is a household/global secret the admin owns (§4/§15b), so it is
//! fine in the admin-readable file. Per-user refresh tokens never land here.
use std::collections::HashMap;
use anyhow::Result;
use serde::Serialize;
use sqlx::SqlitePool;
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct OauthProviderRow {
pub name: String,
pub display_name: String,
pub auth_url: String,
pub token_url: String,
pub client_id: String,
/// Never leaves the process for the browser — see [`OauthProviderView`].
pub client_secret: String,
pub redirect_uri: String,
pub extra_params: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl OauthProviderRow {
/// Extra authorization-endpoint params (e.g. `access_type=offline`,
/// `prompt=consent`) merged into the consent URL. Google needs both to return a
/// refresh token; a provider that needs neither leaves this NULL.
pub fn extra(&self) -> HashMap<String, String> {
self.extra_params.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default()
}
}
/// The provider as the admin UI renders it — **without** `client_secret`. The list
/// endpoint reaches the browser, and the secret has no business there.
#[derive(Debug, Clone, Serialize)]
pub struct OauthProviderView {
pub name: String,
pub display_name: String,
pub auth_url: String,
pub token_url: String,
pub client_id: String,
pub redirect_uri: String,
pub extra_params: Option<String>,
/// So the admin sees a secret is set without the value crossing the wire.
pub has_client_secret: bool,
}
impl From<OauthProviderRow> for OauthProviderView {
fn from(r: OauthProviderRow) -> Self {
OauthProviderView {
has_client_secret: !r.client_secret.is_empty(),
name: r.name,
display_name: r.display_name,
auth_url: r.auth_url,
token_url: r.token_url,
client_id: r.client_id,
redirect_uri: r.redirect_uri,
extra_params: r.extra_params,
}
}
}
const SELECT: &str =
"SELECT name, display_name, auth_url, token_url, client_id, client_secret, \
redirect_uri, extra_params, created_at, updated_at \
FROM oauth_providers";
// ── Reads ────────────────────────────────────────────────────────────────────
pub async fn list(pool: &SqlitePool) -> Result<Vec<OauthProviderRow>> {
let rows = sqlx::query_as::<_, OauthProviderRow>(sqlx::AssertSqlSafe(format!("{SELECT} ORDER BY name")))
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn get(pool: &SqlitePool, name: &str) -> Result<Option<OauthProviderRow>> {
let row = sqlx::query_as::<_, OauthProviderRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE name = ?")))
.bind(name)
.fetch_optional(pool)
.await?;
Ok(row)
}
// ── Writes ───────────────────────────────────────────────────────────────────
pub struct UpsertProvider<'a> {
pub name: &'a str,
pub display_name: &'a str,
pub auth_url: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
pub client_secret: &'a str,
pub redirect_uri: &'a str,
pub extra_params: Option<&'a str>,
}
pub async fn upsert(pool: &SqlitePool, p: UpsertProvider<'_>) -> Result<()> {
sqlx::query(
"INSERT INTO oauth_providers
(name, display_name, auth_url, token_url, client_id, client_secret, redirect_uri, extra_params)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(name) DO UPDATE SET
display_name = excluded.display_name,
auth_url = excluded.auth_url,
token_url = excluded.token_url,
client_id = excluded.client_id,
-- Keep the stored secret when the form submits an empty one: the admin
-- editing a provider's URLs should not have to re-paste the secret,
-- which the list view never gave back to the browser.
client_secret = CASE WHEN excluded.client_secret = ''
THEN oauth_providers.client_secret
ELSE excluded.client_secret END,
redirect_uri = excluded.redirect_uri,
extra_params = excluded.extra_params,
updated_at = datetime('now')",
)
.bind(p.name)
.bind(p.display_name)
.bind(p.auth_url)
.bind(p.token_url)
.bind(p.client_id)
.bind(p.client_secret)
.bind(p.redirect_uri)
.bind(p.extra_params)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(pool: &SqlitePool, name: &str) -> Result<()> {
sqlx::query("DELETE FROM oauth_providers WHERE name = ?")
.bind(name)
.execute(pool)
.await?;
Ok(())
}
+181
View File
@@ -0,0 +1,181 @@
//! On-disk layout of installed connectors (blueprint §7/§14).
//!
//! One folder per connector, `{WD}/connectors/<name>/`, holding exactly what the
//! marketplace served: the runtime files, the icons, and the `connector.json` the
//! admin accepted. It sits beside `homes/` and `shared/` because it belongs to the
//! **instance**, not to the checkout — `scripts/` was the wrong home for it, being
//! a source-tree directory that also carries hand-written dev scripts.
//!
//! Two consumers, and the split matters:
//!
//! - A **global** connector runs on the host, straight out of this folder.
//! - A **per-user** connector runs inside the user's container, so its runtime
//! files are copied into the bind-mounted home ([`install_into_home`]) — the only
//! durable zone (§6), so they survive a container recreate.
//!
//! `connector.json` is written but never read back: [`crate::db::mcp_catalog`] is
//! the only thing that drives a connect. The file is provenance — what was accepted,
//! and on what day — which is also what makes a later silent upstream change
//! detectable. Reading it at runtime would create a second source of truth that
//! diverges the moment the admin edits the catalog row.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::container::{CONTAINER_HOME, HOMES_DIR};
/// Subdirectory of the working directory holding installed connector folders.
pub const CONNECTORS_DIR: &str = "connectors";
/// The manifest, saved verbatim at install time as provenance (never read back).
pub const MANIFEST_FILE: &str = "connector.json";
/// Where a per-user connector's files land inside the container, under the home
/// mount. `{CONTAINER_HOME}/.skald/mcp/<runtime_name>/`.
const IN_CONTAINER_MCP_SUBDIR: &str = ".skald/mcp";
/// The host directory holding `name`'s installed files. Does not check existence —
/// callers that need the files present say so themselves, with their own message.
pub fn connector_dir(name: &str) -> Result<PathBuf> {
let wd = std::env::current_dir().context("failed to read working directory")?;
Ok(wd.join(CONNECTORS_DIR).join(name))
}
/// Splits a catalog `script_path` (`<folder>/<rel>`) into the connector folder and
/// the entry file's path *inside* it.
///
/// The tail is kept whole rather than reduced to a basename: a connector may ship a
/// tree (`pkg/server.py`), and flattening it would break the import that made it a
/// tree in the first place.
pub fn split_script_path(script_path: &str) -> Result<(&str, &str)> {
match script_path.split_once('/') {
Some((folder, rel)) if !folder.is_empty() && !rel.is_empty() => Ok((folder, rel)),
_ => bail!("script_path `{script_path}` is not of the form `<connector>/<file>`"),
}
}
/// Whether a file is a host-side asset rather than something the runtime needs.
///
/// Icons are for the browser and the manifest is provenance; neither has any job
/// inside a user's container, so they stay out of the home. The rule is extension-
/// based because the manifest names icons freely (`icon_sm.png`, `icon_lg.svg`);
/// if some future connector ever ships an image it genuinely needs at runtime, this
/// is the one place to reconsider.
pub fn is_host_asset(rel: &str) -> bool {
if rel == MANIFEST_FILE {
return true;
}
let ext = Path::new(rel)
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
matches!(ext.as_str(), "png" | "svg" | "jpg" | "jpeg" | "webp" | "gif" | "ico")
}
/// The in-container path of a per-user connector's directory.
fn container_dir_for(runtime_name: &str) -> PathBuf {
Path::new(CONTAINER_HOME).join(IN_CONTAINER_MCP_SUBDIR).join(runtime_name)
}
/// The host path of a per-user connector's directory, inside the bind-mounted home.
fn home_dir_for(user_id: &str, runtime_name: &str) -> Result<PathBuf> {
let wd = std::env::current_dir().context("failed to read working directory")?;
Ok(wd
.join(HOMES_DIR)
.join(user_id)
.join(IN_CONTAINER_MCP_SUBDIR)
.join(runtime_name))
}
/// Copies the runtime files of the installed connector `folder` into `user_id`'s
/// home under `.skald/mcp/<runtime_name>/`, and returns the directory's path
/// **inside** the container.
///
/// The whole tree is copied, minus host assets ([`is_host_asset`]) — which is what
/// finally gets a connector's `requirements.txt` and its multi-file trees into the
/// container, where copying a single entry file never did.
///
/// Returns `Ok(None)` when `folder` was never installed on this box, so a caller
/// that does not actually need the files (a catalog entry pointing at nothing, a
/// connector with no verify step) can carry on. Idempotent: re-running overwrites.
pub fn install_into_home(
user_id: &str,
runtime_name: &str,
folder: &str,
) -> Result<Option<PathBuf>> {
let src = connector_dir(folder)?;
if !src.is_dir() {
return Ok(None);
}
let dest = home_dir_for(user_id, runtime_name)?;
std::fs::create_dir_all(&dest)
.with_context(|| format!("failed to create {}", dest.display()))?;
copy_runtime_files(&src, &dest, Path::new(""))?;
Ok(Some(container_dir_for(runtime_name)))
}
/// Recursively copies `src` into `dest`, skipping host assets. `rel` tracks the
/// path relative to the connector root so [`is_host_asset`] sees the same string
/// the manifest declared.
fn copy_runtime_files(src: &Path, dest: &Path, rel: &Path) -> Result<()> {
for entry in std::fs::read_dir(src).with_context(|| format!("cannot read {}", src.display()))? {
let entry = entry?;
let name = entry.file_name();
let child_rel = rel.join(&name);
let from = entry.path();
let to = dest.join(&name);
if entry.file_type()?.is_dir() {
std::fs::create_dir_all(&to)
.with_context(|| format!("failed to create {}", to.display()))?;
copy_runtime_files(&from, &to, &child_rel)?;
continue;
}
if is_host_asset(&child_rel.to_string_lossy()) {
continue;
}
std::fs::copy(&from, &to)
.with_context(|| format!("failed to copy {}", child_rel.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_a_script_path_into_folder_and_tail() {
assert_eq!(split_script_path("gmail/server.py").unwrap(), ("gmail", "server.py"));
// A tree keeps its shape — the tail is not reduced to a basename.
assert_eq!(
split_script_path("whatsapp/pkg/index.js").unwrap(),
("whatsapp", "pkg/index.js")
);
for bad in ["server.py", "", "gmail/", "/server.py"] {
assert!(split_script_path(bad).is_err(), "should have rejected `{bad}`");
}
}
/// Icons and the manifest are host-side only: they must never reach a user's
/// container, while everything the server actually runs on must.
#[test]
fn host_assets_are_icons_and_the_manifest() {
for asset in ["connector.json", "icon_sm.png", "icon_lg.svg", "a/b/logo.WEBP"] {
assert!(is_host_asset(asset), "`{asset}` should be a host asset");
}
for runtime in ["server.py", "requirements.txt", "pkg/index.js", "verify.py"] {
assert!(!is_host_asset(runtime), "`{runtime}` should reach the container");
}
}
#[test]
fn container_dir_hangs_off_the_home_mount() {
assert_eq!(
container_dir_for("gmail"),
PathBuf::from("/root/.skald/mcp/gmail")
);
}
}
+123
View File
@@ -24,10 +24,16 @@ pub use mcp_client::{
use mcp_client::McpTransport; use mcp_client::McpTransport;
pub mod install;
mod logs; mod logs;
pub mod oauth;
mod provider; mod provider;
pub mod verify;
pub use install::{CONNECTORS_DIR, MANIFEST_FILE, connector_dir, install_into_home, split_script_path};
pub use oauth::DeliverSpec;
pub use provider::{McpProvider, UserMcpView}; pub use provider::{McpProvider, UserMcpView};
pub use verify::{VerifyReport, VerifyTarget, apply_placeholders, run_verify};
const SERVER_START_TIMEOUT_SECS: u64 = 120; const SERVER_START_TIMEOUT_SECS: u64 = 120;
@@ -409,10 +415,35 @@ fn apply_key_placeholder(
) -> (Option<String>, Option<String>) { ) -> (Option<String>, Option<String>) {
match (url, api_key) { match (url, api_key) {
(Some(u), Some(k)) if u.contains("{key}") => (Some(u.replace("{key}", &k)), None), (Some(u), Some(k)) if u.contains("{key}") => (Some(u.replace("{key}", &k)), None),
// Unified {SECRET:<param>} placeholder (e.g. Tavily's
// `?tavilyApiKey={SECRET:tavilyApiKey}`). Any SECRET token in a URL is
// the api_key for a remote connector — a URL never carries the user's
// other secrets — so we substitute every occurrence.
(Some(u), Some(k)) if u.contains("{SECRET:") => (Some(substitute_secret_tokens(&u, &k)), None),
(u, k) => (u, k), (u, k) => (u, k),
} }
} }
/// Replaces every `{SECRET:…}` token in `text` with `value`. Used for the
/// api-key-in-URL case; other placeholders are left untouched.
fn substitute_secret_tokens(text: &str, value: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(open) = rest.find("{SECRET:") {
out.push_str(&rest[..open]);
let after = &rest[open..];
if let Some(close) = after.find('}') {
out.push_str(value);
rest = &after[close + 1..];
} else {
out.push_str(after);
break;
}
}
out.push_str(rest);
out
}
/// Builds a spec for a globally-active connector — host transport (`launch_in` /// 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). /// = 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 { pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow) -> McpServerSpec {
@@ -460,6 +491,51 @@ pub fn user_row_spec(
} }
} }
/// Like [`user_row_spec`], but for an OAuth connector it also resolves the stored
/// refresh token into the credential the server reads and injects it into the
/// process env per the delivery spec (§15). Non-OAuth rows are returned unchanged.
///
/// `registry` is the system pool, where `oauth_providers` (the client credentials)
/// lives. A resolution failure is logged, not fatal: the server still starts, and
/// fails its own auth visibly, rather than the whole login batch aborting.
pub async fn user_row_spec_resolved(
row: &crate::db::mcp_user_servers::McpUserServerRow,
container: &str,
registry: &SqlitePool,
) -> McpServerSpec {
let mut spec = user_row_spec(row, container);
if let (Some(provider), Some(deliver), Some(refresh)) =
(row.oauth_provider.as_deref(), row.deliver(), row.api_key.as_deref())
{
if let Err(e) = inject_oauth_env(&mut spec, provider, &deliver, refresh, registry).await {
warn!("connector '{}': OAuth credential delivery failed: {e}", row.name);
}
}
spec
}
/// Assembles the credential from the provider's client creds + the refresh token and
/// sets it on `spec.config.env` under the delivery spec's env name.
async fn inject_oauth_env(
spec: &mut McpServerSpec,
provider_name: &str,
deliver: &DeliverSpec,
refresh_token: &str,
registry: &SqlitePool,
) -> Result<()> {
if deliver.as_ != "env" {
anyhow::bail!("only `env` credential delivery is wired (deliver.as = `{}`)", deliver.as_);
}
let env_name = deliver.env.as_deref()
.ok_or_else(|| anyhow::anyhow!("deliver.as=env but no deliver.env name"))?;
let format = deliver.format.as_deref().unwrap_or("google_authorized_user");
let provider = crate::db::oauth_providers::get(registry, provider_name).await?
.ok_or_else(|| anyhow::anyhow!("unknown OAuth provider `{provider_name}`"))?;
let cred = oauth::assemble_credential(format, &provider, refresh_token)?;
spec.config.env.get_or_insert_with(HashMap::new).insert(env_name.to_string(), cred);
Ok(())
}
/// Generates a 32-char alphanumeric id for a persisted media filename /// Generates a 32-char alphanumeric id for a persisted media filename
/// (mirrors `ImageGeneratorManager`). /// (mirrors `ImageGeneratorManager`).
fn random_id() -> String { fn random_id() -> String {
@@ -510,3 +586,50 @@ pub fn content_type_for_ext(ext: &str) -> &'static str {
_ => "application/octet-stream", _ => "application/octet-stream",
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_placeholder_legacy_is_substituted() {
let (url, key) = apply_key_placeholder(
Some("https://x/?k={key}".into()),
Some("secret123".into()),
);
assert_eq!(url.as_deref(), Some("https://x/?k=secret123"));
assert!(key.is_none(), "api_key is consumed after substitution");
}
#[test]
fn key_placeholder_secret_token_is_substituted() {
// Tavily's unified form: the URL carries {SECRET:tavilyApiKey}.
let (url, key) = apply_key_placeholder(
Some("https://mcp.tavily.com/mcp/?tavilyApiKey={SECRET:tavilyApiKey}".into()),
Some("tvly-abc".into()),
);
assert_eq!(url.as_deref(), Some("https://mcp.tavily.com/mcp/?tavilyApiKey=tvly-abc"));
assert!(key.is_none(), "api_key is consumed when the URL had a SECRET token");
}
#[test]
fn key_placeholder_no_token_keeps_key_for_bearer() {
// No placeholder in the URL → the key stays, so the HTTP transport
// sends it as `Authorization: Bearer`.
let (url, key) = apply_key_placeholder(
Some("https://x.example.com/mcp".into()),
Some("bearer-key".into()),
);
assert_eq!(url.as_deref(), Some("https://x.example.com/mcp"));
assert_eq!(key.as_deref(), Some("bearer-key"));
}
#[test]
fn substitute_secret_tokens_replaces_every_occurrence() {
let s = substitute_secret_tokens(
"a={SECRET:K}&b={SECRET:K}&c={ENV:C}",
"VAL",
);
assert_eq!(s, "a=VAL&b=VAL&c={ENV:C}");
}
}
+200
View File
@@ -0,0 +1,200 @@
//! OAuth 2.0 authorization-code + PKCE for per-user connectors (blueprint §15).
//!
//! The consent step is a **human copy-paste**, not a headless action (§15): Skald
//! builds a consent URL, the user approves it in a browser, and the provider lands
//! the `code` on a static page (`redirect_uri`, e.g. `oauth/show.html`) that shows
//! it for copying. Skald then exchanges the code for a refresh token. PKCE means an
//! intercepted code is useless without the verifier, which never leaves this
//! process — so the copy-paste page can be a plain static file with no backend.
//!
//! The obtained refresh token is delivered to the connector's server per its
//! manifest `auth.deliver` spec; only `env` delivery is wired (the credential is
//! injected as an environment variable at `docker exec` time — nothing on disk).
use anyhow::{Context, Result, bail};
use base64::Engine;
use rand::Rng as _;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::db::oauth_providers::OauthProviderRow;
/// How Skald delivers the obtained credential to the connector's server process,
/// mirrored from the manifest's `auth.deliver` (§15).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DeliverSpec {
/// `env` | `file`. Only `env` is implemented; a `file` target is rejected at
/// activation with a clear message rather than silently half-working.
#[serde(rename = "as")]
pub as_: String,
/// The serialization Skald must produce (`google_authorized_user` | `refresh_token`).
#[serde(default)]
pub format: Option<String>,
/// `as=env`: the environment variable the credential is injected into.
#[serde(default)]
pub env: Option<String>,
/// `as=file`: the target path (unused while file delivery is unimplemented).
#[serde(default)]
pub path: Option<String>,
}
const URL_SAFE: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
/// A high-entropy PKCE verifier and its S256 challenge (RFC 7636).
pub struct Pkce {
pub verifier: String,
pub challenge: String,
}
pub fn generate_pkce() -> Pkce {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
let verifier = URL_SAFE.encode(bytes); // 43-char base64url, within the RFC range
let challenge = URL_SAFE.encode(Sha256::digest(verifier.as_bytes()));
Pkce { verifier, challenge }
}
/// An opaque, URL-safe `state` value: CSRF guard and the key of the pending flow.
pub fn random_state() -> String {
let mut bytes = [0u8; 24];
rand::rng().fill_bytes(&mut bytes);
URL_SAFE.encode(bytes)
}
/// Builds the authorization-endpoint URL the user opens to consent. Merges the
/// provider's `extra_params` (Google needs `access_type=offline` + `prompt=consent`
/// to return a refresh token) after the standard params.
pub fn build_consent_url(
provider: &OauthProviderRow,
scopes: &[String],
state: &str,
challenge: &str,
) -> Result<String> {
let scope = scopes.join(" ");
let mut params: Vec<(String, String)> = vec![
("client_id".into(), provider.client_id.clone()),
("redirect_uri".into(), provider.redirect_uri.clone()),
("response_type".into(), "code".into()),
("scope".into(), scope),
("state".into(), state.into()),
("code_challenge".into(), challenge.into()),
("code_challenge_method".into(), "S256".into()),
];
for (k, v) in provider.extra() {
params.push((k, v));
}
let url = reqwest::Url::parse_with_params(&provider.auth_url, &params)
.with_context(|| format!("invalid authorization endpoint `{}`", provider.auth_url))?;
Ok(url.to_string())
}
/// The token endpoint's response. Google returns `refresh_token` only on the first
/// consent for a client, or when `prompt=consent` forces re-issue — hence the
/// provider's `extra_params`.
#[derive(Debug, Deserialize)]
pub struct TokenResponse {
#[serde(default)] pub access_token: Option<String>,
#[serde(default)] pub refresh_token: Option<String>,
#[serde(default)] pub expires_in: Option<i64>,
#[serde(default)] pub scope: Option<String>,
#[serde(default)] pub error: Option<String>,
#[serde(default)] pub error_description: Option<String>,
}
/// Exchanges an authorization `code` (+ PKCE `verifier`) for tokens at the
/// provider's token endpoint.
pub async fn exchange_code(
provider: &OauthProviderRow,
code: &str,
verifier: &str,
) -> Result<TokenResponse> {
let params = [
("grant_type", "authorization_code"),
("code", code),
("client_id", provider.client_id.as_str()),
("client_secret", provider.client_secret.as_str()),
("redirect_uri", provider.redirect_uri.as_str()),
("code_verifier", verifier),
];
// `RequestBuilder::form` needs reqwest's `urlencoded` feature, which this build
// doesn't enable — so encode the body ourselves. Parsing a throwaway URL with
// these params yields exactly the `application/x-www-form-urlencoded` string.
let body = reqwest::Url::parse_with_params("http://form.local/", &params)
.ok()
.and_then(|u| u.query().map(str::to_owned))
.unwrap_or_default();
let resp = reqwest::Client::new()
.post(&provider.token_url)
.header(reqwest::header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.context("token endpoint request failed")?;
let status = resp.status();
let body: TokenResponse = resp
.json()
.await
.context("token endpoint returned a non-JSON body")?;
if let Some(err) = &body.error {
let detail = body.error_description.as_deref()
.map(|d| format!("{d}")).unwrap_or_default();
bail!("token exchange failed: {err}{detail}");
}
if !status.is_success() {
bail!("token exchange failed with HTTP {status}");
}
Ok(body)
}
/// Serializes a refresh token into the shape the connector's server reads, per
/// `deliver.format`. `google_authorized_user` is the JSON that
/// `google.oauth2.credentials.Credentials.from_authorized_user_info` accepts — the
/// server refreshes access tokens from it on its own.
pub fn assemble_credential(
format: &str,
provider: &OauthProviderRow,
refresh_token: &str,
) -> Result<String> {
match format {
"google_authorized_user" => Ok(serde_json::json!({
"type": "authorized_user",
"client_id": provider.client_id,
"client_secret": provider.client_secret,
"refresh_token": refresh_token,
"token_uri": provider.token_url,
}).to_string()),
"refresh_token" => Ok(refresh_token.to_string()),
other => bail!("unsupported deliver.format `{other}`"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkce_challenge_is_s256_of_verifier() {
let p = generate_pkce();
let expect = URL_SAFE.encode(Sha256::digest(p.verifier.as_bytes()));
assert_eq!(p.challenge, expect);
assert!(!p.verifier.contains(['+', '/', '=']), "verifier must be url-safe, unpadded");
}
#[test]
fn authorized_user_credential_has_googles_fields() {
let provider = OauthProviderRow {
name: "google".into(), display_name: "Google".into(),
auth_url: "https://a".into(), token_url: "https://t".into(),
client_id: "cid".into(), client_secret: "csec".into(),
redirect_uri: "https://r".into(), extra_params: None,
created_at: String::new(), updated_at: String::new(),
};
let cred = assemble_credential("google_authorized_user", &provider, "rt-123").unwrap();
let v: serde_json::Value = serde_json::from_str(&cred).unwrap();
assert_eq!(v["type"], "authorized_user");
assert_eq!(v["client_id"], "cid");
assert_eq!(v["refresh_token"], "rt-123");
assert_eq!(v["token_uri"], "https://t");
}
}
+417
View File
@@ -0,0 +1,417 @@
//! Verify-before-save for MCP connectors (blueprint §15 verify step).
//!
//! When a user fills the activation form, Skald can run the connector's declared
//! `verify` command to confirm the credentials actually work *before* persisting
//! the activation. This module owns:
//!
//! - [`apply_placeholders`] — the single substitution engine for `{ENV:NAME}`
//! and `{SECRET:NAME}` tokens (used here for the verify command, and by the
//! MCP transport for URLs / env values).
//! - [`run_verify`] — launches the resolved command either on the host (for a
//! global `mcp_remote` connector) or inside the caller's container (for a
//! per-user `mcp_local` connector), parses the JSON result, and returns a
//! [`VerifyReport`].
//!
//! Output contract: the verify command must print one JSON object on stdout,
//! `{"ok": bool, "message": string, "details"?: object}`, and exit 0 on success.
//! If the JSON parse fails, [`run_verify`] falls back to the exit code. Secrets
//! are never logged.
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::Value;
use tokio::io::AsyncReadExt;
use tracing::debug;
/// Default timeout for a verify command (seconds). Overridable per-connector via
/// the manifest's `verify.timeout_secs`.
pub const DEFAULT_VERIFY_TIMEOUT_SECS: u64 = 15;
/// The outcome of a verify run, surfaced to the UI verbatim.
#[derive(Debug, Clone, Serialize)]
pub struct VerifyReport {
/// `true` when the credentials check out.
pub ok: bool,
/// Human-readable result line (shown next to the Test button).
pub message: String,
/// Optional structured details (shown in a `<pre>` block). Never holds
/// secrets — the verify script is responsible for not echoing them.
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
/// Wall-clock time the command took.
#[serde(skip)]
pub elapsed: Duration,
/// `true` when the connector declares no `verify` step, so "no test" must
/// be distinguishable from "test passed" in the UI.
#[serde(skip)]
pub skipped: bool,
}
impl VerifyReport {
/// Synthesized when the connector has no `verify` step — the UI shows
/// "no test available" rather than a pass/fail.
pub fn skipped() -> Self {
Self {
ok: true,
message: "This connector has no verification step.".into(),
details: None,
elapsed: Duration::ZERO,
skipped: true,
}
}
}
/// Where [`run_verify`] executes the command. Mirrors `McpServerSpec.launch_in`:
/// `None` runs on the host (a global `mcp_remote` connector), `Some(container)`
/// runs inside the user's container via `docker exec`.
pub enum VerifyTarget<'a> {
/// Run on the Skald host process. `workdir` is an absolute host path
/// (typically `<data_root>/scripts/<id>/`).
Host { workdir: &'a Path },
/// Run inside the user's sandbox container. `workdir` is an absolute path
/// *inside* the container (e.g. `/root/.skald/mcp/<name>`).
Container {
container: &'a str,
workdir: &'a Path,
},
}
/// Substitutes `{ENV:NAME}` and `{SECRET:NAME}` tokens in `text`.
///
/// - `{ENV:NAME}` → `env[NAME]`, or empty string if absent.
/// - `{SECRET:NAME}` → `secret[NAME]`, or empty string if absent.
/// - Any other `{...}` token is left untouched — `{key}` belongs to the remote
/// transport's URL substitution (see `mcp::apply_key`), and anything else is a
/// misconfiguration that should stay visible rather than be silently erased.
pub fn apply_placeholders(
text: &str,
env: &HashMap<String, String>,
secret: &HashMap<String, String>,
) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(open) = rest.find('{') {
// Append everything up to the '{'.
out.push_str(&rest[..open]);
let after = &rest[open..];
if let Some(close) = after.find('}') {
let token = &after[..=close]; // includes both braces
let inner = token.strip_prefix('{').unwrap().strip_suffix('}').unwrap();
// ENV:/SECRET: tokens are always consumed (missing key → empty);
// any other `{...}` is left untouched so a misconfiguration stays
// visible rather than being silently erased.
if let Some(name) = inner.strip_prefix("ENV:") {
out.push_str(env.get(name).map(|s| s.as_str()).unwrap_or(""));
} else if let Some(name) = inner.strip_prefix("SECRET:") {
out.push_str(secret.get(name).map(|s| s.as_str()).unwrap_or(""));
} else {
out.push_str(token);
}
rest = &after[close + 1..];
} else {
// No closing brace — emit the rest literally and stop.
out.push_str(after);
return out;
}
}
out.push_str(rest);
out
}
/// Runs the verify `command` (after placeholder substitution) in the given
/// target, injects the env/secret values as environment variables, captures
/// stdout/stderr under a timeout, and parses the JSON result.
///
/// The command and resolved env are NOT logged (secrets may be inline). Only
/// the final `ok`/`message` are traced at debug level.
pub async fn run_verify(
command: &str,
env_values: &HashMap<String, String>,
secret_values: &HashMap<String, String>,
target: VerifyTarget<'_>,
timeout_secs: u64,
) -> VerifyReport {
let resolved = apply_placeholders(command, env_values, secret_values);
let timeout = Duration::from_secs(timeout_secs.max(1));
let started = Instant::now();
// Build the process: `docker exec … sh -c "<cmd>"` or host `sh -c "<cmd>"`.
let mut cmd = match target {
VerifyTarget::Container { container, workdir } => {
let mut c = tokio::process::Command::new("docker");
c.arg("exec")
.arg("-w").arg(workdir)
.arg(container);
inject_env_flags(&mut c, env_values, secret_values);
c.arg("sh").arg("-c").arg(&resolved);
c
}
VerifyTarget::Host { workdir } => {
let mut c = tokio::process::Command::new("sh");
c.arg("-c").arg(&resolved).current_dir(workdir);
inject_env_vars(&mut c, env_values, secret_values);
c
}
};
cmd.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.kill_on_drop(true);
let child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return VerifyReport {
ok: false,
message: format!("Could not start the verify command: {e}"),
details: None,
elapsed: started.elapsed(),
skipped: false,
};
}
};
let outcome = run_with_timeout(child, timeout).await;
let elapsed = started.elapsed();
let report = parse_verify_output(&outcome, elapsed);
debug!(ok = report.ok, elapsed_ms = elapsed.as_millis() as u64, "verify");
report
}
/// Collects the child's stdout/stderr under a single timeout, returning the
/// captured buffers and the exit code (None if killed by timeout).
async fn run_with_timeout(
mut child: tokio::process::Child,
timeout: Duration,
) -> VerifyOutcome {
let mut stdout = child.stdout.take().expect("stdout piped");
let mut stderr = child.stderr.take().expect("stderr piped");
let collect = async {
let mut out = Vec::new();
let mut err = Vec::new();
// Read concurrently — the pipes are independent.
let r1 = stdout.read_to_end(&mut out);
let r2 = stderr.read_to_end(&mut err);
let (ro, re, status) = tokio::join!(r1, r2, child.wait());
ro.map_err(anyhow::Error::from)?;
re.map_err(anyhow::Error::from)?;
let code = status.ok().and_then(|s| s.code());
Ok::<_, anyhow::Error>((out, err, code))
};
match tokio::time::timeout(timeout, collect).await {
Ok(Ok((out, err, code))) => VerifyOutcome { stdout: out, stderr: err, code, timed_out: false },
// Inner error (spawn/io).
Ok(Err(e)) => VerifyOutcome {
stdout: Vec::new(),
stderr: e.to_string().into_bytes(),
code: None,
timed_out: false,
},
// Timeout: kill_on_drop takes care of the child.
Err(_) => VerifyOutcome {
stdout: Vec::new(),
stderr: format!("verify timed out after {}s", timeout.as_secs()).into_bytes(),
code: None,
timed_out: true,
},
}
}
struct VerifyOutcome {
stdout: Vec<u8>,
stderr: Vec<u8>,
code: Option<i32>,
timed_out: bool,
}
/// Parses the verify command's output into a [`VerifyReport`].
///
/// Contract: the command prints one JSON object on stdout:
/// `{"ok": bool, "message": string, "details"?: object}`. If the parse fails,
/// falls back to the exit code (0 = ok, anything else = fail) and uses stderr
/// (or stdout) as the message.
fn parse_verify_output(outcome: &VerifyOutcome, elapsed: Duration) -> VerifyReport {
let stdout = String::from_utf8_lossy(&outcome.stdout);
let stderr = String::from_utf8_lossy(&outcome.stderr);
if outcome.timed_out {
return VerifyReport {
ok: false,
message: stderr.trim().to_string(),
details: None,
elapsed,
skipped: false,
};
}
// Try JSON parse first (prefer the last line, in case the script emitted a
// trailing newline or a preamble).
let trimmed = stdout.trim();
if !trimmed.is_empty() {
if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
let ok = v.get("ok").and_then(|o| o.as_bool()).unwrap_or_else(|| outcome.code == Some(0));
let message = v
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let details = v.get("details").cloned();
return VerifyReport { ok, message, details, elapsed, skipped: false };
}
}
// Fallback: exit-code semantics. Empty stdout → fall back to stderr.
let ok = outcome.code == Some(0);
let message = if !trimmed.is_empty() {
trimmed.to_string()
} else if !stderr.trim().is_empty() {
stderr.trim().to_string()
} else if ok {
"Verification succeeded.".into()
} else {
format!("Verify failed (exit code {}).", outcome.code.unwrap_or(-1))
};
VerifyReport { ok, message, details: None, elapsed, skipped: false }
}
/// Adds `-e KEY=VALUE` flags for `docker exec`, for both env and secret values.
fn inject_env_flags(
cmd: &mut tokio::process::Command,
env: &HashMap<String, String>,
secret: &HashMap<String, String>,
) {
for (k, v) in env.iter().chain(secret.iter()) {
cmd.arg("-e").arg(format!("{k}={v}"));
}
}
/// Sets environment variables for a host `sh -c` process.
fn inject_env_vars(
cmd: &mut tokio::process::Command,
env: &HashMap<String, String>,
secret: &HashMap<String, String>,
) {
for (k, v) in env.iter().chain(secret.iter()) {
cmd.env(k, v);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn m(items: &[(&str, &str)]) -> HashMap<String, String> {
items.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn placeholders_env_and_secret() {
let env = m(&[("HOST", "imap.example.com"), ("PORT", "993")]);
let secret = m(&[("PASS", "hunter2")]);
let s = apply_placeholders("h={ENV:HOST} p={ENV:PORT} s={SECRET:PASS}", &env, &secret);
assert_eq!(s, "h=imap.example.com p=993 s=hunter2");
}
#[test]
fn placeholders_missing_become_empty() {
let env = m(&[("HOST", "x")]);
let secret = HashMap::new();
let s = apply_placeholders("[{ENV:HOST}][{ENV:MISSING}][{SECRET:X}]", &env, &secret);
assert_eq!(s, "[x][][]");
}
#[test]
fn placeholders_unknown_left_untouched() {
let env = HashMap::new();
let secret = HashMap::new();
let s = apply_placeholders("{key} {ENV:A} {0}", &env, &secret);
assert_eq!(s, "{key} {0}");
}
#[test]
fn placeholders_no_braces() {
let env = HashMap::new();
let secret = HashMap::new();
assert_eq!(apply_placeholders("plain text", &env, &secret), "plain text");
}
#[test]
fn placeholders_unclosed_brace_kept() {
let env = HashMap::new();
let secret = HashMap::new();
assert_eq!(apply_placeholders("a {ENV:B c", &env, &secret), "a {ENV:B c");
}
#[test]
fn parse_json_ok() {
let o = VerifyOutcome {
stdout: br#"{"ok": true, "message": "all good"}"#.to_vec(),
stderr: vec![],
code: Some(0),
timed_out: false,
};
let r = parse_verify_output(&o, Duration::from_millis(10));
assert!(r.ok);
assert_eq!(r.message, "all good");
}
#[test]
fn parse_json_fail_with_details() {
let o = VerifyOutcome {
stdout: br#"{"ok": false, "message": "bad creds", "details": {"imap": "ok", "smtp": "no"}}"#.to_vec(),
stderr: vec![],
code: Some(1),
timed_out: false,
};
let r = parse_verify_output(&o, Duration::from_millis(10));
assert!(!r.ok);
assert_eq!(r.message, "bad creds");
assert_eq!(r.details.unwrap()["smtp"], "no");
}
#[test]
fn parse_fallback_exit_code() {
let o = VerifyOutcome {
stdout: b"some plain output".to_vec(),
stderr: vec![],
code: Some(0),
timed_out: false,
};
let r = parse_verify_output(&o, Duration::from_millis(10));
assert!(r.ok);
assert_eq!(r.message, "some plain output");
}
#[test]
fn parse_fallback_stderr_on_fail() {
let o = VerifyOutcome {
stdout: vec![],
stderr: b"connection refused".to_vec(),
code: Some(2),
timed_out: false,
};
let r = parse_verify_output(&o, Duration::from_millis(10));
assert!(!r.ok);
assert_eq!(r.message, "connection refused");
}
#[test]
fn parse_timeout_is_fail() {
let o = VerifyOutcome {
stdout: vec![],
stderr: b"verify timed out after 15s".to_vec(),
code: None,
timed_out: true,
};
let r = parse_verify_output(&o, Duration::from_secs(15));
assert!(!r.ok);
assert!(r.message.contains("timed out"));
}
}
+7 -3
View File
@@ -200,14 +200,18 @@ impl UserContextFactory {
{ {
let um = Arc::clone(&user_mcp); let um = Arc::clone(&user_mcp);
let upool = Arc::clone(&pool); let upool = Arc::clone(&pool);
let registry = Arc::clone(&self.registry_pool);
let container = crate::container::container_name(user_id); let container = crate::container::container_name(user_id);
let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str()); let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str());
self.supervisor.adopt_one(mname, tokio::spawn(async move { self.supervisor.adopt_one(mname, tokio::spawn(async move {
match crate::db::mcp_user_servers::all_startable(&upool).await { match crate::db::mcp_user_servers::all_startable(&upool).await {
Ok(rows) => { Ok(rows) => {
let specs = rows.iter() let mut specs = Vec::with_capacity(rows.len());
.map(|r| crate::mcp::user_row_spec(r, &container)) for r in &rows {
.collect(); // OAuth connectors resolve their stored refresh token into
// the env-delivered credential here (§15).
specs.push(crate::mcp::user_row_spec_resolved(r, &container, &registry).await);
}
um.connect_all(specs, false).await; um.connect_all(specs, false).await;
} }
Err(e) => tracing::warn!(error = %e, "per-user MCP init: failed to read mcp_user_servers"), Err(e) => tracing::warn!(error = %e, "per-user MCP init: failed to read mcp_user_servers"),
+2 -3
View File
@@ -173,9 +173,8 @@ impl Tool for GrepFiles {
} }
} }
// `secrets` is skipped so a recursive grep rooted at a parent (e.g. the auto-read // Noise, not policy: build output and vendored trees a grep is never looking for.
// working directory) never descends into and leaks secret values. const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", ".venv", "__pycache__"];
const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", ".venv", "__pycache__", "secrets"];
const MAX_FILE_BYTES: u64 = 200_000; const MAX_FILE_BYTES: u64 = 200_000;
const MAX_OUTPUT_BYTES: usize = 60_000; const MAX_OUTPUT_BYTES: usize = 60_000;
const MAX_LINE_BYTES: usize = 500; const MAX_LINE_BYTES: usize = 500;
+2 -4
View File
@@ -11,10 +11,8 @@ use crate::tools::{
}; };
use super::{classify_memory, resolve, MemScope}; use super::{classify_memory, resolve, MemScope};
/// Directories to skip unconditionally when walking. /// Directories to skip unconditionally when walking — noise, not policy.
/// `secrets` is skipped so a recursive listing rooted at a parent (e.g. the auto-read const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache"];
/// working directory) never reveals the contents of the secrets store.
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "secrets"];
pub struct ListFiles { pub struct ListFiles {
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile). /// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
+1 -1
View File
@@ -110,7 +110,7 @@ pub fn resolve(user_path: &str) -> Result<PathBuf> {
/// does not exist yet) is appended lexically. Falls back to a pure lexical normalization /// does not exist yet) is appended lexically. Falls back to a pure lexical normalization
/// when nothing along the path can be canonicalized. /// when nothing along the path can be canonicalized.
/// ///
/// This closes `docs/../secrets/x` traversal and symlink escapes for both the allow /// This closes `docs/../private/x` traversal and symlink escapes for both the allow
/// fast-paths (`RunContext`) and the deny rules (`approval::normalize_path`). /// fast-paths (`RunContext`) and the deny rules (`approval::normalize_path`).
pub fn canonicalize_for_policy(path: &str, base: &Path) -> PathBuf { pub fn canonicalize_for_policy(path: &str, base: &Path) -> PathBuf {
let raw = { let raw = {
+274 -33
View File
@@ -129,6 +129,14 @@ struct AuthSpec {
#[serde(default, rename = "type")] kind: Option<String>, #[serde(default, rename = "type")] kind: Option<String>,
#[serde(default)] delivery: Option<String>, #[serde(default)] delivery: Option<String>,
#[serde(default)] scopes: Vec<String>, #[serde(default)] scopes: Vec<String>,
/// oauth: the identity provider slug (`google`) — resolved to client creds +
/// endpoints from the `oauth_providers` registry table (§15). Never carries
/// URLs or secrets: those stay out of the public feed by design.
#[serde(default)] provider: Option<String>,
/// oauth: how the obtained credential is delivered to the server process
/// (`{as,format,env,path}`). Parsed with skald-core's own type so the stored
/// snapshot and the runtime injector agree on the shape.
#[serde(default)] deliver: Option<skald_core::mcp::DeliverSpec>,
} }
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
@@ -150,6 +158,27 @@ struct McpConfigManifest {
#[serde(default)] transport: Option<String>, #[serde(default)] transport: Option<String>,
} }
/// One env/secret field the activation UI must collect (the feed's `env[]` entry).
/// Richer than the old `Vec<String>` of bare key names — drives a real form.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct EnvEntry {
name: String,
label: String,
description: String,
#[serde(default)] required: bool,
#[serde(default)] secret: bool,
#[serde(default)] default: Option<String>,
#[serde(default)] example: Option<String>,
}
/// The verify-before-save step. `command` is a shell snippet run with the
/// collected env/secret injected; `timeout_secs` defaults to 15.
#[derive(Debug, Clone, Default, Deserialize)]
struct VerifySpec {
command: String,
#[serde(default)] timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
struct Manifest { struct Manifest {
#[serde(default)] name: Option<String>, #[serde(default)] name: Option<String>,
@@ -167,12 +196,19 @@ struct Manifest {
#[serde(default)] files: Vec<FileEntry>, #[serde(default)] files: Vec<FileEntry>,
#[serde(default)] scope: Option<String>, #[serde(default)] scope: Option<String>,
#[serde(default)] auth: Option<AuthSpec>, #[serde(default)] auth: Option<AuthSpec>,
/// The full env/secret schema for the activation form (objects, not key names).
#[serde(default)] env: Vec<EnvEntry>,
/// Optional verify-before-save command.
#[serde(default)] verify: Option<VerifySpec>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct Hydrated { struct Hydrated {
entry: IndexEntry, entry: IndexEntry,
manifest: Manifest, manifest: Manifest,
/// The manifest exactly as served, so [`install`] can record it verbatim
/// without a second fetch. `None` when the manifest could not be read.
manifest_raw: Option<String>,
} }
// ── feed vocabulary → Skald vocabulary ──────────────────────────────────────── // ── feed vocabulary → Skald vocabulary ────────────────────────────────────────
@@ -373,12 +409,17 @@ async fn fetch_feed() -> Result<Vec<Hydrated>, ApiError> {
set.spawn(async move { set.spawn(async move {
let url = format!("{}/{}/connector.json", base, folder_of(&entry)); let url = format!("{}/{}/connector.json", base, folder_of(&entry));
// A manifest that fails to load degrades that one card to whatever the // A manifest that fails to load degrades that one card to whatever the
// index said; it never fails the whole listing. // index said; it never fails the whole listing. The raw text is kept
let manifest = match HTTP.get(&url).send().await { // alongside the parsed form so `install` can record what was served
Ok(r) => r.json::<Manifest>().await.unwrap_or_default(), // even if some field of it did not parse.
Err(_) => Manifest::default(), let (manifest, manifest_raw) = match HTTP.get(&url).send().await {
Ok(r) => match r.text().await {
Ok(t) => (serde_json::from_str::<Manifest>(&t).unwrap_or_default(), Some(t)),
Err(_) => (Manifest::default(), None),
},
Err(_) => (Manifest::default(), None),
}; };
Hydrated { entry, manifest } Hydrated { entry, manifest, manifest_raw }
}); });
} }
@@ -548,10 +589,10 @@ pub struct InstallBody {
} }
/// Imports a feed entry into `mcp_catalog` — the act that moves a connector from /// Imports a feed entry into `mcp_catalog` — the act that moves a connector from
/// "someone else vetted this" to "this household's admin accepted it". For an /// "someone else vetted this" to "this household's admin accepted it". It downloads
/// `mcp_local` entry it first downloads and hash-verifies the scripts into /// and hash-verifies the connector's folder into `./connectors/<id>/`; for an
/// `./scripts/<id>/`, which is code landing on the box and therefore needs /// `mcp_local` entry that folder holds code which will run on this box, and
/// `mcp.register_local_script` (§14) on top of `mcp.manage_catalog`. /// therefore needs `mcp.register_local_script` (§14) on top of `mcp.manage_catalog`.
/// ///
/// Installing does **not** activate: a global entry still needs the admin to /// Installing does **not** activate: a global entry still needs the admin to
/// enable it with a key, a per-user one still needs each user to activate it. /// enable it with a key, a per-user one still needs each user to activate it.
@@ -579,9 +620,25 @@ pub async fn install(
} }
// Download + verify before touching the catalog, so a failed digest leaves no // Download + verify before touching the catalog, so a failed digest leaves no
// trace of a half-installed connector. // trace of a half-installed connector. A `local_script` always downloads its
let (script_path, verified) = if source == "local_script" { // files; a `remote` connector downloads them only if it declares a `verify`
let files = download_verified(&h.entry, &h.manifest).await?; // step that references a script (otherwise there is nothing to fetch).
let verify_command = h.manifest.verify.as_ref().map(|v| v.command.clone());
let verify_timeout = h.manifest.verify.as_ref().and_then(|v| v.timeout_secs);
let all_files = files_of(&h.entry, &h.manifest);
let verify_script_rel = verify_command
.as_deref()
.and_then(|c| verify_script_of(c, &all_files));
let verify_script_path = verify_script_rel
.as_ref()
.map(|f| format!("{}/{}", body.id, f));
// Every source downloads its folder now — a remote connector has an icon and a
// manifest to record even when it has no code to run here.
let installed =
download_verified(&h.entry, &h.manifest, h.manifest_raw.as_deref(), &source).await?;
let script_path = if source == "local_script" {
let entry_file = cfg let entry_file = cfg
.args .args
.first() .first()
@@ -590,9 +647,9 @@ pub async fn install(
"manifest has no mcp_config.args[0] naming the script to run", "manifest has no mcp_config.args[0] naming the script to run",
))?; ))?;
let entry_file = safe_rel_path(&entry_file)?.to_string(); let entry_file = safe_rel_path(&entry_file)?.to_string();
(Some(format!("{}/{}", body.id, entry_file)), files) Some(format!("{}/{}", body.id, entry_file))
} else { } else {
(None, 0) None
}; };
let args_json = if source == "local_script" { let args_json = if source == "local_script" {
@@ -605,9 +662,23 @@ pub async fn install(
serde_json::to_string(&cfg.args).ok() serde_json::to_string(&cfg.args).ok()
}; };
// `requires` is the feed's coarse precondition list; the activation UI needs // The full env/secret schema for the activation form. The manifest's top-level
// the concrete env keys, which only the manifest's mcp_config knows. // `env[]` (array of objects with label/description/required/secret/…) is the
let config_schema: Vec<String> = cfg.env.keys().cloned().collect(); // source of truth; if a feed only ships the old `mcp_config.env` placeholder
// map, fall back to bare key names so the form still renders.
let config_schema_json = if !h.manifest.env.is_empty() {
serde_json::to_string(&h.manifest.env).ok()
} else if !cfg.env.is_empty() {
let names: Vec<String> = cfg.env.keys().cloned().collect();
serde_json::to_string(&names).ok()
} else {
None
};
let _ = verify_timeout; // surfaced to the runtime via the verify module's default
let folder = folder_of(&h.entry);
let icon_small_path = installed_icon(h.entry.icon_small.as_deref(), &folder, &installed);
let icon_large_path = installed_icon(h.entry.icon_large.as_deref(), &folder, &installed);
let id = mcp_catalog::upsert( let id = mcp_catalog::upsert(
skald.db(), skald.db(),
@@ -621,9 +692,23 @@ pub async fn install(
env_json: if cfg.env.is_empty() { None } else { serde_json::to_string(&cfg.env).ok() }, env_json: if cfg.env.is_empty() { None } else { serde_json::to_string(&cfg.env).ok() },
url: cfg.url.as_deref(), url: cfg.url.as_deref(),
script_path: script_path.as_deref(), script_path: script_path.as_deref(),
config_schema_json: if config_schema.is_empty() { None } else { serde_json::to_string(&config_schema).ok() }, config_schema_json,
auth_kind: &norm_auth_kind(&h.entry, &h.manifest), auth_kind: &norm_auth_kind(&h.entry, &h.manifest),
// OAuth wiring (§15): provider slug, the scopes shown at consent, and the
// credential delivery spec — all snapshotted so an activation is
// reproducible even if the feed later changes.
oauth_provider: h.manifest.auth.as_ref().and_then(|a| a.provider.as_deref()),
oauth_scopes_json: h.manifest.auth.as_ref()
.filter(|a| !a.scopes.is_empty())
.and_then(|a| serde_json::to_string(&a.scopes).ok()),
deliver_json: h.manifest.auth.as_ref()
.and_then(|a| a.deliver.as_ref())
.and_then(|d| serde_json::to_string(d).ok()),
role_filter: None, role_filter: None,
verify_command: verify_command.as_deref(),
verify_script_path: verify_script_path.as_deref(),
icon_small_path: icon_small_path.as_deref(),
icon_large_path: icon_large_path.as_deref(),
friendly_name: h.entry.name.as_deref().or(h.manifest.name.as_deref()), friendly_name: h.entry.name.as_deref().or(h.manifest.name.as_deref()),
// The LLM-facing blurb — this is the column `render_mcp_list` puts in // The LLM-facing blurb — this is the column `render_mcp_list` puts in
// the prompt for `activate_tools()`, so the feed's // the prompt for `activate_tools()`, so the feed's
@@ -641,17 +726,50 @@ pub async fn install(
"name": h.entry.id, "name": h.entry.id,
"scope": scope, "scope": scope,
"source": source, "source": source,
"files_verified": verified, "files_verified": installed.verified,
}))) })))
} }
/// Downloads every file the manifest declares into `./scripts/<id>/`, refusing any /// If a verify `command` references one of the connector's shipped files (by
/// basename match against `files[]`), return that file's relative path — the
/// caller stores `<id>/<file>` so the runtime resolves `./connectors/<id>/<file>`.
/// Returns `None` for inline commands (`curl …`) with no script file.
fn verify_script_of(command: &str, files: &[FileEntry]) -> Option<String> {
for f in files {
let basename = f.path.rsplit_once('/').map(|(_, b)| b).unwrap_or(&f.path);
if !basename.is_empty() && command.contains(basename) {
return Some(f.path.clone());
}
}
None
}
/// What [`download_verified`] put on disk.
struct Installed {
/// How many files were downloaded and matched their declared digest.
verified: usize,
/// The relative paths actually written, so the caller can record a manifest
/// claim (an icon, say) only once the file backing it exists.
files: std::collections::HashSet<String>,
}
/// Downloads a connector's whole folder into `./connectors/<id>/`, refusing any file
/// whose SHA-256 does not match. All-or-nothing: files are verified in memory and /// whose SHA-256 does not match. All-or-nothing: files are verified in memory and
/// only written once every digest checks out, so a tampered feed never leaves a /// only written once every digest checks out, so a tampered feed never leaves a
/// partial connector on disk. Returns how many files were verified. /// partial connector on disk.
async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<usize, ApiError> { ///
/// Runs for **every** source, not just `local_script`. Fetching an icon is not the
/// §14 risk axis — that axis is about code the box will *execute*, and it stays
/// gated on `mcp.register_local_script` in [`install`]. What lands here for a remote
/// connector is inert: an icon and the manifest.
async fn download_verified(
entry: &IndexEntry,
manifest: &Manifest,
raw: Option<&str>,
source: &str,
) -> Result<Installed, ApiError> {
let files = files_of(entry, manifest); let files = files_of(entry, manifest);
if files.is_empty() { if files.is_empty() && source == "local_script" {
return Err(ApiError::bad_request( return Err(ApiError::bad_request(
"the feed declares no `files` with digests for this connector — \ "the feed declares no `files` with digests for this connector — \
refusing to install unverifiable code (§14)", refusing to install unverifiable code (§14)",
@@ -660,15 +778,16 @@ async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<us
let base = base_url(); let base = base_url();
let folder = folder_of(entry); let folder = folder_of(entry);
let index_digests = !entry.files.is_empty();
let mut staged: Vec<(String, Vec<u8>)> = Vec::new(); let mut staged: Vec<(String, Vec<u8>)> = Vec::new();
for f in files { for f in files {
let rel = safe_rel_path(&f.path)?; let rel = safe_rel_path(&f.path)?;
// Defensive: a document can never carry its own digest (writing the hash // A document can never carry its own digest (writing the hash into the file
// changes the file), so a self-entry is unverifiable by construction. The // changes the file), so a *manifest*-declared self-entry is unverifiable by
// feed now keeps digests in the index, where this cannot arise. // construction. From the index it is verifiable, and gets no exception.
if rel == "connector.json" { if rel == skald_core::mcp::MANIFEST_FILE && !index_digests {
continue; continue;
} }
@@ -703,19 +822,19 @@ async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<us
staged.push((rel.to_string(), bytes.to_vec())); staged.push((rel.to_string(), bytes.to_vec()));
} }
if staged.is_empty() { if staged.is_empty() && source == "local_script" {
return Err(ApiError::bad_request( return Err(ApiError::bad_request(
"manifest declares no installable file besides connector.json", "manifest declares no installable file besides connector.json",
)); ));
} }
let wd = std::env::current_dir() let dest = skald_core::mcp::connector_dir(&entry.id)
.map_err(|e| ApiError::bad_request(format!("cannot resolve working directory: {e}")))?; .map_err(|e| ApiError::bad_request(format!("cannot resolve the connectors dir: {e}")))?;
let dest = wd.join("scripts").join(&entry.id);
std::fs::create_dir_all(&dest) std::fs::create_dir_all(&dest)
.map_err(|e| ApiError::bad_request(format!("cannot create {}: {e}", dest.display())))?; .map_err(|e| ApiError::bad_request(format!("cannot create {}: {e}", dest.display())))?;
let count = staged.len(); let verified = staged.len();
let mut written: std::collections::HashSet<String> = std::collections::HashSet::new();
for (rel, bytes) in staged { for (rel, bytes) in staged {
let path = dest.join(&rel); let path = dest.join(&rel);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@@ -724,8 +843,36 @@ async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<us
} }
std::fs::write(&path, &bytes) std::fs::write(&path, &bytes)
.map_err(|e| ApiError::bad_request(format!("cannot write `{rel}`: {e}")))?; .map_err(|e| ApiError::bad_request(format!("cannot write `{rel}`: {e}")))?;
written.insert(rel);
} }
Ok(count)
// The manifest, recorded verbatim. The feed does not digest it (it lists only
// the folder's other files), and it does not need to be: nothing ever reads this
// file back — `mcp_catalog` drives every connect. Its one job is to say what was
// served on the day the admin accepted it, and for that job a tampered copy is
// still the truth of what we accepted. See the module header on what digests do
// and do not buy.
if !written.contains(skald_core::mcp::MANIFEST_FILE) {
if let Some(raw) = raw {
std::fs::write(dest.join(skald_core::mcp::MANIFEST_FILE), raw).map_err(|e| {
ApiError::bad_request(format!("cannot record connector.json: {e}"))
})?;
}
}
Ok(Installed { verified, files: written })
}
/// The icon path to record in the catalog: the manifest's feed-root-relative claim
/// (`gmail/icon_sm.svg`) reduced to a path inside the connector's own folder
/// (`icon_sm.svg`), and only if that file actually got installed.
///
/// The two vocabularies differ — the index names icons from the feed root but names
/// `files[]` from the folder — so this is where they are reconciled.
fn installed_icon(claim: Option<&str>, folder: &str, installed: &Installed) -> Option<String> {
let claim = claim?.trim_start_matches('/');
let rel = claim.strip_prefix(&format!("{folder}/")).unwrap_or(claim);
installed.files.contains(rel).then(|| rel.to_string())
} }
#[cfg(test)] #[cfg(test)]
@@ -837,6 +984,35 @@ mod tests {
assert_eq!(files_of(&entry(r#"{"id":"x"}"#), &m)[0].path, "b.py"); assert_eq!(files_of(&entry(r#"{"id":"x"}"#), &m)[0].path, "b.py");
} }
/// The index names icons from the feed root (`gmail/icon_sm.svg`) but names
/// `files[]` from the connector's folder (`icon_sm.svg`). Recording the wrong one
/// would 404 every icon, so this is where the two vocabularies must meet.
#[test]
fn icon_paths_are_reduced_to_the_connector_folder() {
let installed = Installed {
verified: 2,
files: ["icon_sm.svg", "server.py"].iter().map(|s| s.to_string()).collect(),
};
assert_eq!(
installed_icon(Some("gmail/icon_sm.svg"), "gmail", &installed),
Some("icon_sm.svg".to_string())
);
// A leading slash is still feed-root-relative.
assert_eq!(
installed_icon(Some("/gmail/icon_sm.svg"), "gmail", &installed),
Some("icon_sm.svg".to_string())
);
// Already folder-relative: left alone.
assert_eq!(
installed_icon(Some("icon_sm.svg"), "gmail", &installed),
Some("icon_sm.svg".to_string())
);
// Claimed but never installed → recorded as absent, so the endpoint says
// "no icon" instead of pointing the browser at a file that is not there.
assert_eq!(installed_icon(Some("gmail/icon_lg.svg"), "gmail", &installed), None);
assert_eq!(installed_icon(None, "gmail", &installed), None);
}
#[test] #[test]
fn sha256_matches_a_known_vector() { fn sha256_matches_a_known_vector() {
assert_eq!( assert_eq!(
@@ -845,6 +1021,71 @@ mod tests {
); );
} }
/// Installs every connector the live feed offers into a throwaway directory and
/// checks what actually lands: the digests hold, the manifest is recorded, and the
/// icon path stored in the catalog names a file that exists.
///
/// That last one is the whole point of the icon column — a path that does not
/// resolve would 404 silently in the browser and look like "this connector has no
/// icon", which is exactly the failure a unit test with a hand-written feed cannot
/// see. `#[ignore]`d (network + it moves the process cwd, so it must run alone):
/// `cargo test --bin skald -- --ignored live_feed_installs`.
#[tokio::test]
#[ignore]
async fn live_feed_installs_folder_with_icon_and_manifest() {
let _ = rustls::crypto::ring::default_provider().install_default();
let tmp = std::env::temp_dir().join(format!("skald-install-test-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::env::set_current_dir(&tmp).unwrap();
let feed = match fetch_feed().await {
Ok(f) => f,
Err(e) => panic!("feed unreachable: {}", e.message),
};
assert!(!feed.is_empty(), "feed returned no connectors");
for h in &feed {
let source = norm_source(&h.entry, &h.manifest);
let installed =
download_verified(&h.entry, &h.manifest, h.manifest_raw.as_deref(), &source)
.await
.unwrap_or_else(|e| panic!("`{}` failed to install: {}", h.entry.id, e.message));
let dir = skald_core::mcp::connector_dir(&h.entry.id).unwrap();
let folder = folder_of(&h.entry);
// Every source installs now — a remote connector included, which is what
// gives Tavily an icon at all.
assert!(dir.is_dir(), "`{}` installed no folder", h.entry.id);
// The manifest is recorded even though the feed does not digest it.
assert!(
dir.join(skald_core::mcp::MANIFEST_FILE).is_file(),
"`{}` recorded no connector.json",
h.entry.id
);
// The icon path the catalog would store must name a real file.
for (size, claim) in [("sm", &h.entry.icon_small), ("lg", &h.entry.icon_large)] {
let Some(rel) = installed_icon(claim.as_deref(), &folder, &installed) else {
panic!("`{}` declares a {size} icon the installer did not keep", h.entry.id);
};
assert!(
dir.join(&rel).is_file(),
"`{}` {size} icon `{rel}` is not on disk",
h.entry.id
);
// An icon must never be shipped into a user's container.
assert!(skald_core::mcp::install::is_host_asset(&rel), "`{rel}` should be a host asset");
}
println!("{:<8} {} files verified, icon + manifest on disk", h.entry.id, installed.verified);
}
std::fs::remove_dir_all(&tmp).ok();
}
/// Hits the real feed. `#[ignore]`d so the suite stays offline-clean; run with /// Hits the real feed. `#[ignore]`d so the suite stays offline-clean; run with
/// `cargo test --bin skald -- --ignored live_feed`. /// `cargo test --bin skald -- --ignored live_feed`.
#[tokio::test] #[tokio::test]
+617 -32
View File
@@ -17,7 +17,7 @@ use axum::Json;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; 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::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities};
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::guard::AuthUser; use super::guard::AuthUser;
@@ -40,31 +40,217 @@ fn to_json_opt<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
v.as_ref().and_then(|x| serde_json::to_string(x).ok()) v.as_ref().and_then(|x| serde_json::to_string(x).ok())
} }
/// Copies a vetted catalog script from `./scripts/<script_path>` into the user's /// Installs the connector folder that `script_path` (`<connector>/<file>`) belongs
/// bind-mounted home under `.skald/mcp/<name>/`, and returns the path it will have /// to into the caller's container home, and returns the path the entry file will
/// INSIDE the container (`/root/.skald/mcp/...`). The home is the only durable /// have INSIDE the container.
/// 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. /// The whole folder travels, not just the entry file — which is what finally gets a
fn copy_script_into_home(user_id: &str, name: &str, script_path: &str) -> Result<String, ApiError> { /// connector's `requirements.txt` and its multi-file trees to where the server
let wd = std::env::current_dir() /// actually runs. The home is the only durable zone (§6), so it survives a
.map_err(|e| ApiError::bad_request(format!("cannot resolve working directory: {e}")))?; /// container recreate.
let src = wd.join("scripts").join(script_path); fn install_connector_for_user(
if !src.is_file() { user_id: &str,
name: &str,
script_path: &str,
) -> Result<String, ApiError> {
let (folder, entry_file) = skald_core::mcp::split_script_path(script_path)
.map_err(|e| ApiError::bad_request(e.to_string()))?;
let dir = skald_core::mcp::install_into_home(user_id, name, folder)
.map_err(|e| ApiError::bad_request(format!("failed to install connector files: {e}")))?
.ok_or_else(|| ApiError::bad_request(format!(
"connector `{folder}` has no installed files under ./{}/ — \
reinstall it from the marketplace",
skald_core::mcp::CONNECTORS_DIR,
)))?;
Ok(dir.join(entry_file).to_string_lossy().into_owned())
}
// ── verify-before-save helpers ───────────────────────────────────────────────
/// One entry of the catalog's `config_schema_json` (the marketplace `env[]`).
/// Drives both the activation form (frontend) and the env/secret split (here).
#[derive(Debug, serde::Deserialize)]
struct EnvSchemaEntry {
name: String,
#[serde(default)] secret: bool,
#[allow(dead_code)]
#[serde(default)] required: bool,
}
/// Parses the catalog's `config_schema_json` into schema entries. Tolerates the
/// legacy `["KEY1","KEY2"]` shape (treated as non-secret) and the new object
/// array; anything unparseable yields an empty schema.
fn parse_env_schema(config_schema_json: &Option<String>) -> Vec<EnvSchemaEntry> {
let raw = match config_schema_json.as_deref() {
Some(s) => s,
None => return Vec::new(),
};
// Object-array form (the new manifest `env[]`).
if let Ok(v) = serde_json::from_str::<Vec<EnvSchemaEntry>>(raw) {
return v;
}
// Legacy bare-name form.
serde_json::from_str::<Vec<String>>(raw)
.unwrap_or_default()
.into_iter()
.map(|name| EnvSchemaEntry { name, secret: false, required: false })
.collect()
}
/// Splits the form values into non-secret (`env`) and secret (`secret`) maps,
/// the two channels [`run_verify`] substitutes into `{ENV:…}` / `{SECRET:…}`.
///
/// When `auth_kind == "api_key"`, the supplied `api_key` is also injected under
/// every secret-name declared by the schema — the canonical case being Tavily,
/// whose schema names the key `tavilyApiKey` and whose URL carries the matching
/// `{SECRET:tavilyApiKey}` token.
fn split_form_values(
form: Option<&HashMap<String, String>>,
api_key: Option<&str>,
schema: &[EnvSchemaEntry],
auth_kind: &str,
) -> (HashMap<String, String>, HashMap<String, String>) {
let secret_names: std::collections::HashSet<&str> =
schema.iter().filter(|e| e.secret).map(|e| e.name.as_str()).collect();
let mut env = HashMap::new();
let mut secret = HashMap::new();
if let Some(form) = form {
for (k, v) in form {
if secret_names.contains(k.as_str()) {
secret.insert(k.clone(), v.clone());
} else {
env.insert(k.clone(), v.clone());
}
}
}
// An api_key connector maps the key into every declared secret name that the
// form did not already fill (the UI's api_key box is the same value).
if auth_kind == "api_key" {
if let Some(key) = api_key {
for name in secret_names {
secret.entry(name.to_string()).or_insert_with(|| key.to_string());
}
}
}
(env, secret)
}
/// Installs the connector folder holding a catalog entry's verify script into the
/// user's home, returning the in-container directory to run it from. `None` if the
/// entry declares no verify script.
///
/// This runs on the Test button too, before any activation exists — which is why it
/// installs rather than assuming the folder is already there.
fn prepare_user_verify_workdir(
user_id: &str,
name: &str,
entry: &mcp_catalog::McpCatalogRow,
) -> Result<Option<std::path::PathBuf>, ApiError> {
let verify_path = match &entry.verify_script_path {
Some(p) => p,
None => return Ok(None),
};
let (folder, _) = skald_core::mcp::split_script_path(verify_path)
.map_err(|e| ApiError::bad_request(e.to_string()))?;
let dir = skald_core::mcp::install_into_home(user_id, name, folder)
.map_err(|e| ApiError::bad_request(format!("failed to install connector files: {e}")))?
.ok_or_else(|| ApiError::bad_request(format!(
"connector `{folder}` has no installed files — reinstall it from the marketplace"
)))?;
Ok(Some(dir))
}
/// The host working dir for a global connector's verify script — the
/// `./connectors/<catalog_name>/` directory the marketplace installer populated.
fn global_verify_workdir(catalog_name: &str) -> Result<std::path::PathBuf, ApiError> {
let dir = skald_core::mcp::connector_dir(catalog_name)
.map_err(|e| ApiError::bad_request(format!("cannot resolve the connectors dir: {e}")))?;
if !dir.is_dir() {
return Err(ApiError::bad_request(format!( return Err(ApiError::bad_request(format!(
"catalog script `scripts/{script_path}` not found or not a file \ "connector directory `{}/{catalog_name}` not found — reinstall the connector",
(directory-tree scripts are not supported yet)" skald_core::mcp::CONNECTORS_DIR,
))); )));
} }
let basename = src.file_name() Ok(dir)
.ok_or_else(|| ApiError::bad_request("invalid script_path"))? }
.to_string_lossy().to_string();
let dest_dir = wd.join(skald_core::container::HOMES_DIR) /// Default timeout for a connector-declared verify step. (The manifest can also
.join(user_id).join(".skald").join("mcp").join(name); /// carry `verify.timeout_secs`; wiring that through is a follow-up.)
std::fs::create_dir_all(&dest_dir) const VERIFY_TIMEOUT_SECS: u64 = 20;
.map_err(|e| ApiError::bad_request(format!("failed to create script dir: {e}")))?;
std::fs::copy(&src, dest_dir.join(&basename)) // ── connector icons ───────────────────────────────────────────────────────────
.map_err(|e| ApiError::bad_request(format!("failed to copy script: {e}")))?;
Ok(format!("/root/.skald/mcp/{name}/{basename}")) #[derive(Deserialize)]
pub struct IconQuery {
/// `sm` (default) | `lg`.
#[serde(default)]
pub size: Option<String>,
}
/// `GET /api/mcp/catalog/{name}/icon?size=sm|lg` — the icon of an **installed**
/// connector, served off `./connectors/<name>/`.
///
/// Authenticated but deliberately **not** capability-gated: seeing the icon of a
/// connector you are allowed to activate is not an administrative act. The
/// marketplace's own icon endpoint cannot do this job — it proxies the live feed
/// behind `manage_catalog`, so a normal user gets a 403, and the image would vanish
/// the moment the feed went down or the entry was pulled upstream. Once installed,
/// the bytes are ours.
pub async fn catalog_icon(
State(skald): State<Arc<Skald>>,
Extension(_auth): Extension<AuthUser>,
Path(name): Path<String>,
axum::extract::Query(q): axum::extract::Query<IconQuery>,
) -> Result<axum::response::Response, ApiError> {
use axum::http::header;
use axum::response::IntoResponse;
let entry = mcp_catalog::get_by_name(skald.db(), &name).await?
.ok_or_else(|| ApiError::not_found(format!("no catalog entry `{name}`")))?;
let large = q.size.as_deref() == Some("lg");
let rel = if large {
entry.icon_large_path.clone().or_else(|| entry.icon_small_path.clone())
} else {
entry.icon_small_path.clone().or_else(|| entry.icon_large_path.clone())
}
.ok_or_else(|| ApiError::not_found("this connector has no installed icon"))?;
// Containment, mirroring `tools::fs::resolve_host_path`: canonicalize and
// prefix-check, fail-closed. `rel` only ever holds a path the installer already
// proved safe, and `name` only ever names a real catalog row — this is the belt
// to those braces, and the reason a bad row cannot turn into an arbitrary read.
let dir = skald_core::mcp::connector_dir(&entry.name)
.map_err(|e| ApiError::bad_request(format!("cannot resolve the connectors dir: {e}")))?;
let base = dir.canonicalize()
.map_err(|_| ApiError::not_found("this connector has no installed files"))?;
let path = base.join(&rel).canonicalize()
.map_err(|_| ApiError::not_found("icon file is missing — reinstall the connector"))?;
if !path.starts_with(&base) {
return Err(ApiError::forbidden("icon path escapes the connector directory"));
}
let bytes = std::fs::read(&path)
.map_err(|e| ApiError::bad_request(format!("cannot read icon: {e}")))?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
let ct = skald_core::mcp::content_type_for_ext(&ext);
// An icon is untrusted bytes from a remote feed, and half of them are SVGs —
// a format that can carry script. Served from our own origin, that would be
// stored XSS for anyone who opened the URL directly. `nosniff` pins the type,
// and the CSP neuters any script or fetch the document tries to perform.
Ok((
[
(header::CONTENT_TYPE, ct.to_string()),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'".to_string()),
(header::CACHE_CONTROL, "private, max-age=3600".to_string()),
],
bytes,
)
.into_response())
} }
// ── existing: running-server introspection ──────────────────────────────────── // ── existing: running-server introspection ────────────────────────────────────
@@ -100,6 +286,8 @@ pub struct CatalogUpsertBody {
#[serde(default = "default_none_auth")] #[serde(default = "default_none_auth")]
pub auth_kind: String, pub auth_kind: String,
pub role_filter: Option<Vec<String>>, pub role_filter: Option<Vec<String>>,
pub verify_command: Option<String>,
pub verify_script_path: Option<String>,
pub friendly_name: Option<String>, pub friendly_name: Option<String>,
pub description: Option<String>, pub description: Option<String>,
} }
@@ -130,7 +318,18 @@ pub async fn catalog_upsert(
script_path: body.script_path.as_deref(), script_path: body.script_path.as_deref(),
config_schema_json: to_json_opt(&body.config_schema), config_schema_json: to_json_opt(&body.config_schema),
auth_kind: &body.auth_kind, auth_kind: &body.auth_kind,
// OAuth catalog entries come from the vetted feed (marketplace install),
// not the admin's manual form — so these stay unset here.
oauth_provider: None,
oauth_scopes_json: None,
deliver_json: None,
role_filter: to_json_opt(&body.role_filter), role_filter: to_json_opt(&body.role_filter),
verify_command: body.verify_command.as_deref(),
verify_script_path: body.verify_script_path.as_deref(),
// Not the admin form's to set: the installer owns them, and `upsert`
// COALESCEs these away rather than blanking an installed connector's icons.
icon_small_path: None,
icon_large_path: None,
friendly_name: body.friendly_name.as_deref(), friendly_name: body.friendly_name.as_deref(),
description: body.description.as_deref(), description: body.description.as_deref(),
}).await?; }).await?;
@@ -147,6 +346,71 @@ pub async fn catalog_delete(
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
// ── admin: OAuth providers (§15) ──────────────────────────────────────────────
/// The OAuth identity providers, **without** client secrets (never leaves the
/// process for the browser).
pub async fn providers_list(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<oauth_providers::OauthProviderView>>, ApiError> {
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
let rows = oauth_providers::list(skald.db()).await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
#[derive(Deserialize)]
pub struct ProviderUpsertBody {
pub name: String,
pub display_name: String,
pub auth_url: String,
pub token_url: String,
pub client_id: String,
/// Empty keeps the stored secret — the list view never gave it back, so editing
/// the URLs must not force the admin to re-paste it.
#[serde(default)]
pub client_secret: String,
pub redirect_uri: String,
pub extra_params: Option<String>,
}
pub async fn providers_upsert(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<ProviderUpsertBody>,
) -> Result<Json<Value>, ApiError> {
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
// `extra_params` must be valid JSON — it is merged into the consent URL, and a
// malformed value would silently drop `access_type`/`prompt` and cost the user a
// refresh token. Reject it here rather than fail quietly at sign-in.
if let Some(extra) = body.extra_params.as_deref().filter(|s| !s.trim().is_empty()) {
serde_json::from_str::<HashMap<String, String>>(extra)
.map_err(|e| ApiError::bad_request(format!("extra_params is not a JSON object of strings: {e}")))?;
}
let extra = body.extra_params.as_deref().filter(|s| !s.trim().is_empty());
oauth_providers::upsert(skald.db(), oauth_providers::UpsertProvider {
name: &body.name,
display_name: &body.display_name,
auth_url: &body.auth_url,
token_url: &body.token_url,
client_id: &body.client_id,
client_secret: &body.client_secret,
redirect_uri: &body.redirect_uri,
extra_params: extra,
}).await?;
Ok(Json(json!({ "ok": true })))
}
pub async fn providers_delete(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(name): Path<String>,
) -> Result<Json<Value>, ApiError> {
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
oauth_providers::delete(skald.db(), &name).await?;
Ok(Json(json!({ "ok": true })))
}
// ── admin: globally-active connectors + access ──────────────────────────────── // ── admin: globally-active connectors + access ────────────────────────────────
pub async fn global_list( pub async fn global_list(
@@ -189,17 +453,29 @@ pub async fn global_enable(
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()).or_else(|| entry.env_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(), url: entry.url.as_deref(),
api_key: body.api_key.as_deref(), api_key: body.api_key.as_deref(),
verify_command: entry.verify_command.as_deref(),
verify_script_path: entry.verify_script_path.as_deref(),
friendly_name: entry.friendly_name.as_deref(), friendly_name: entry.friendly_name.as_deref(),
description: entry.description.as_deref(), description: entry.description.as_deref(),
}).await?; }).await?;
// Verify the admin-supplied credentials before starting the server. A failure
// disables the row so it does not run with bad creds; the admin sees the
// message and can fix + re-enable. A connector with no verify step is allowed
// through unchanged.
let verify = run_verify_for_entry(&skald, &auth, &entry, &name, body.env.as_ref(), body.api_key.as_deref()).await?;
if !verify.skipped && !verify.ok {
mcp_global_servers::set_enabled(skald.db(), id, false).await?;
return Ok(Json(json!({ "id": id, "verify": verify, "error": verify.message })));
}
// Start it now in the global runtime (host transport). // Start it now in the global runtime (host transport).
let row = mcp_global_servers::get(skald.db(), id).await? let row = mcp_global_servers::get(skald.db(), id).await?
.ok_or_else(|| ApiError::bad_request("global server vanished after upsert"))?; .ok_or_else(|| ApiError::bad_request("global server vanished after upsert"))?;
let spec = skald_core::mcp::global_row_spec(&row); let spec = skald_core::mcp::global_row_spec(&row);
match skald.mcp().start_server(spec).await { match skald.mcp().start_server(spec).await {
Ok(tools) => Ok(Json(json!({ "id": id, "tools": tools }))), Ok(tools) => Ok(Json(json!({ "id": id, "tools": tools, "verify": verify }))),
Err(e) => Ok(Json(json!({ "id": id, "error": e.to_string() }))), Err(e) => Ok(Json(json!({ "id": id, "error": e.to_string(), "verify": verify }))),
} }
} }
@@ -216,6 +492,98 @@ pub async fn global_delete(
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
/// Runs a catalog entry's verify step in the right target — inside the caller's
/// container for a `per_user` local_script, on the host for a `global` remote.
/// Returns [`VerifyReport::skipped`] when the entry declares no verify step, so
/// callers can treat "no test" uniformly.
///
/// `runtime_name` is the in-container directory key (the user's chosen runtime
/// name for an activation, or the catalog name for a standalone Test).
async fn run_verify_for_entry(
skald: &Skald,
auth: &AuthUser,
entry: &mcp_catalog::McpCatalogRow,
runtime_name: &str,
form: Option<&HashMap<String, String>>,
api_key: Option<&str>,
) -> Result<skald_core::mcp::VerifyReport, ApiError> {
use skald_core::mcp::{run_verify, VerifyReport, VerifyTarget};
let verify_command = match entry.verify_command.as_deref() {
Some(c) => c,
None => return Ok(VerifyReport::skipped()),
};
let schema = parse_env_schema(&entry.config_schema_json);
let (env_values, secret_values) = split_form_values(form, api_key, &schema, &entry.auth_kind);
let report = match entry.scope.as_str() {
"per_user" => {
// `require_context` ensures the container exists before we exec into it.
let _ctx = require_context(skald, &auth.user_id).await?;
let workdir = prepare_user_verify_workdir(&auth.user_id, runtime_name, entry)?
.ok_or_else(|| ApiError::bad_request(
"catalog entry declares verify_command but no verify_script_path",
))?;
let container = skald_core::container::container_name(&auth.user_id);
run_verify(
verify_command,
&env_values,
&secret_values,
VerifyTarget::Container { container: &container, workdir: &workdir },
VERIFY_TIMEOUT_SECS,
).await
}
"global" => {
require_cap(skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
let workdir = global_verify_workdir(&entry.name)?;
run_verify(
verify_command,
&env_values,
&secret_values,
VerifyTarget::Host { workdir: &workdir },
VERIFY_TIMEOUT_SECS,
).await
}
other => return Err(ApiError::bad_request(format!("unknown catalog scope `{other}`"))),
};
Ok(report)
}
// ── user: test a connector without persisting ─────────────────────────────────
#[derive(Deserialize)]
pub struct TestBody {
/// The catalog entry whose credentials to probe.
pub catalog_name: String,
/// The form values the user just typed (env + secret mixed).
pub env: Option<HashMap<String, String>>,
pub api_key: Option<String>,
}
/// `POST /api/mcp/test` — runs a connector's verify step with the supplied
/// credentials and returns the [`VerifyReport`] **without persisting anything**.
/// The frontend Test button calls this before offering Activate.
pub async fn test(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<TestBody>,
) -> Result<Json<skald_core::mcp::VerifyReport>, ApiError> {
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)))?;
// Per-role gate, mirroring `activate`: a user may only test connectors
// their role is allowed to activate.
let user = skald_core::db::users::get(skald.db(), &auth.user_id).await?
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
if !entry.allowed_for_role(&user.role_id) {
return Err(ApiError::forbidden("your role may not use this connector"));
}
let report = run_verify_for_entry(
&skald, &auth, &entry, &entry.name,
body.env.as_ref(), body.api_key.as_deref(),
).await?;
Ok(Json(report))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct GlobalAccessBody { pub struct GlobalAccessBody {
/// The full set of user ids allowed to use this global connector. /// The full set of user ids allowed to use this global connector.
@@ -369,16 +737,62 @@ pub async fn activate(
let name = body.name.clone().unwrap_or_else(|| entry.name.clone()); let name = body.name.clone().unwrap_or_else(|| entry.name.clone());
reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?; reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?;
// For a local script, copy it into the container home and point the // For a local script, install its folder into the container home and
// command at the in-container path. // point the command at the in-container path.
let (command, args_json, script_rel_path) = if entry.source == "local_script" { let (command, args_json, script_rel_path) = if entry.source == "local_script" {
let script = entry.script_path.clone() let script = entry.script_path.clone()
.ok_or_else(|| ApiError::bad_request("catalog local_script entry has no script_path"))?; .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)?; let container_path = install_connector_for_user(&auth.user_id, &name, &script)?;
(entry.command.clone(), Some(json!([container_path]).to_string()), Some(container_path)) (entry.command.clone(), Some(json!([container_path]).to_string()), Some(container_path))
} else { } else {
(entry.command.clone(), entry.args_json.clone(), None) (entry.command.clone(), entry.args_json.clone(), None)
}; };
let env_json = body.env.as_ref()
.and_then(|e| serde_json::to_string(e).ok())
.or_else(|| entry.env_json.clone());
// OAuth connectors do NOT activate directly (§15): the refresh token
// comes from an interactive consent, not from the activation form. We
// persist a PENDING row (files installed, command wired) and hand off to
// `/mcp/oauth/start` → `/complete`, which obtains the token, flips the
// row to `ready`, and starts the server. Nothing runs until then.
if entry.auth_kind == "oauth" {
let provider_name = entry.oauth_provider.as_deref().ok_or_else(|| {
ApiError::bad_request("this OAuth connector names no provider in the catalog")
})?;
// Fail early, clearly, if the admin has not configured the provider —
// better than a dead pending row the user cannot complete.
let provider = skald_core::db::oauth_providers::get(skald.db(), provider_name).await?
.ok_or_else(|| ApiError::bad_request(format!(
"the `{provider_name}` sign-in provider is not set up yet — \
an admin must add its client credentials first"
)))?;
if provider.client_id.is_empty() {
return Err(ApiError::bad_request(format!(
"the `{provider_name}` sign-in provider has no client id configured"
)));
}
let id = 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,
url: entry.url.as_deref(),
api_key: None, // obtained by the OAuth flow
oauth_provider: Some(provider_name),
deliver_json: entry.deliver_json.clone(),
script_rel_path: script_rel_path.as_deref(),
verify_command: None,
verify_script_rel_path: None,
auth_state: "pending",
}).await?;
return Ok(Json(json!({
"id": id, "auth_state": "pending", "needs_oauth": true,
})));
}
mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer { mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
name: &name, name: &name,
@@ -387,10 +801,14 @@ pub async fn activate(
transport: &entry.transport, transport: &entry.transport,
command: command.as_deref(), command: command.as_deref(),
args_json, args_json,
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()).or_else(|| entry.env_json.clone()), env_json,
url: entry.url.as_deref(), url: entry.url.as_deref(),
api_key: body.api_key.as_deref(), api_key: body.api_key.as_deref(),
oauth_provider: None,
deliver_json: None,
script_rel_path: script_rel_path.as_deref(), script_rel_path: script_rel_path.as_deref(),
verify_command: entry.verify_command.as_deref(),
verify_script_rel_path: None, // resolved when verify runs in-container
auth_state: "ready", auth_state: "ready",
}).await? }).await?
} }
@@ -412,7 +830,11 @@ pub async fn activate(
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()), env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()),
url: Some(&url), url: Some(&url),
api_key: body.api_key.as_deref(), api_key: body.api_key.as_deref(),
oauth_provider: None,
deliver_json: None,
script_rel_path: None, script_rel_path: None,
verify_command: None,
verify_script_rel_path: None,
auth_state: "ready", auth_state: "ready",
}).await? }).await?
} }
@@ -421,11 +843,44 @@ pub async fn activate(
// Start it now in this user's runtime (container transport for stdio). // Start it now in this user's runtime (container transport for stdio).
let row = mcp_user_servers::get(&ctx.pool, insert).await? let row = mcp_user_servers::get(&ctx.pool, insert).await?
.ok_or_else(|| ApiError::bad_request("user server vanished after insert"))?; .ok_or_else(|| ApiError::bad_request("user server vanished after insert"))?;
// Verify-before-start for catalog local_script connectors (a self-registered
// remote has no verify step). The activation is persisted either way; a
// failed verify flips auth_state to 'pending' so the user can retry without
// retyping, and the row stays out of `all_startable` until a test passes.
let verify = if row.source == "local_script" && row.verify_command.is_some() {
match &row.catalog_name {
Some(catalog_name) => match mcp_catalog::get_by_name(skald.db(), catalog_name).await? {
Some(entry) => {
let report = run_verify_for_entry(
&skald, &auth, &entry, &row.name,
body.env.as_ref(), body.api_key.as_deref(),
).await?;
if !report.skipped && !report.ok {
mcp_user_servers::set_auth_state(&ctx.pool, insert, "pending").await?;
return Ok(Json(json!({
"id": insert, "verify": report, "auth_state": "pending",
})));
}
report
}
None => skald_core::mcp::VerifyReport::skipped(),
},
None => skald_core::mcp::VerifyReport::skipped(),
}
} else {
skald_core::mcp::VerifyReport::skipped()
};
let container = skald_core::container::container_name(&auth.user_id); let container = skald_core::container::container_name(&auth.user_id);
let spec = skald_core::mcp::user_row_spec(&row, &container); let spec = skald_core::mcp::user_row_spec_resolved(&row, &container, skald.db()).await;
match ctx.user_mcp.start_server(spec).await { match ctx.user_mcp.start_server(spec).await {
Ok(tools) => Ok(Json(json!({ "id": insert, "tools": tools }))), Ok(tools) => Ok(Json(json!({
Err(e) => Ok(Json(json!({ "id": insert, "error": e.to_string() }))), "id": insert, "tools": tools, "verify": verify, "auth_state": "ready",
}))),
Err(e) => Ok(Json(json!({
"id": insert, "error": e.to_string(), "verify": verify,
}))),
} }
} }
@@ -460,3 +915,133 @@ pub async fn deactivate(
mcp_user_servers::delete(&ctx.pool, id).await?; mcp_user_servers::delete(&ctx.pool, id).await?;
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
// ── user: interactive OAuth login for a per-user connector (§15) ───────────────
//
// A pending consent lives only in RAM, keyed by an opaque `state`: it holds the
// PKCE verifier that a copy-pasteable authorization code is worthless without, plus
// which user + connector row it belongs to. The flow is stateless on disk — an
// abandoned consent is simply pruned, and a restart drops every in-flight flow (the
// user just starts again), mirroring the RAM-only session model.
struct PendingFlow {
user_id: String,
server_id: i64,
verifier: String,
provider_name: String,
created_at: std::time::Instant,
}
/// How long a started-but-uncompleted consent stays valid.
const OAUTH_FLOW_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
static OAUTH_FLOWS: std::sync::LazyLock<std::sync::Mutex<HashMap<String, PendingFlow>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
/// Stores a pending flow, pruning any that timed out first. The lock is never held
/// across an `.await` — a single synchronous critical section.
fn oauth_flow_insert(state: String, flow: PendingFlow) {
let mut map = OAUTH_FLOWS.lock().unwrap();
map.retain(|_, f| f.created_at.elapsed() < OAUTH_FLOW_TTL);
map.insert(state, flow);
}
/// Removes and returns a pending flow, or `None` if unknown or expired.
fn oauth_flow_take(state: &str) -> Option<PendingFlow> {
let mut map = OAUTH_FLOWS.lock().unwrap();
let flow = map.remove(state)?;
(flow.created_at.elapsed() < OAUTH_FLOW_TTL).then_some(flow)
}
#[derive(Deserialize)]
pub struct OauthStartBody {
/// The pending `mcp_user_servers` row (created by `activate`) to sign in.
pub server_id: i64,
}
/// `POST /api/mcp/oauth/start` — begins the consent for a pending OAuth connector.
/// Returns the URL the user opens and an opaque `state` the caller echoes back to
/// `/complete`. Does not touch the provider or the network beyond building a URL.
pub async fn oauth_start(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<OauthStartBody>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let row = mcp_user_servers::get(&ctx.pool, body.server_id).await?
.ok_or_else(|| ApiError::not_found("no such connector"))?;
let provider_name = row.oauth_provider.as_deref()
.ok_or_else(|| ApiError::bad_request("this connector does not use OAuth"))?;
let provider = skald_core::db::oauth_providers::get(skald.db(), provider_name).await?
.ok_or_else(|| ApiError::bad_request(format!("sign-in provider `{provider_name}` is not configured")))?;
// The scopes to request live on the catalog entry (kept current), linked by the
// row's snapshotted catalog name.
let scopes = match &row.catalog_name {
Some(cn) => mcp_catalog::get_by_name(skald.db(), cn).await?
.map(|e| e.oauth_scopes())
.unwrap_or_default(),
None => Vec::new(),
};
let pkce = skald_core::mcp::oauth::generate_pkce();
let state = skald_core::mcp::oauth::random_state();
let auth_url = skald_core::mcp::oauth::build_consent_url(&provider, &scopes, &state, &pkce.challenge)
.map_err(|e| ApiError::bad_request(e.to_string()))?;
oauth_flow_insert(state.clone(), PendingFlow {
user_id: auth.user_id.clone(),
server_id: row.id,
verifier: pkce.verifier,
provider_name: provider_name.to_string(),
created_at: std::time::Instant::now(),
});
Ok(Json(json!({ "auth_url": auth_url, "state": state })))
}
#[derive(Deserialize)]
pub struct OauthCompleteBody {
/// The `state` returned by `/start`, identifying the pending flow.
pub state: String,
/// The authorization code the user pasted from the provider's page.
pub code: String,
}
/// `POST /api/mcp/oauth/complete` — exchanges the pasted code for a refresh token,
/// stores it, flips the connector to `ready`, and starts it in the user's runtime.
pub async fn oauth_complete(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<OauthCompleteBody>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let flow = oauth_flow_take(&body.state)
.ok_or_else(|| ApiError::bad_request("this sign-in has expired — start it again"))?;
// The flow is bound to the user who started it: a leaked `state` cannot let
// someone finish another person's consent into their own connector.
if flow.user_id != auth.user_id {
return Err(ApiError::forbidden("this sign-in does not belong to you"));
}
let provider = skald_core::db::oauth_providers::get(skald.db(), &flow.provider_name).await?
.ok_or_else(|| ApiError::bad_request("sign-in provider is no longer configured"))?;
let token = skald_core::mcp::oauth::exchange_code(&provider, body.code.trim(), &flow.verifier)
.await
.map_err(|e| ApiError::bad_request(e.to_string()))?;
let refresh = token.refresh_token.ok_or_else(|| ApiError::bad_request(
"the provider returned no refresh token — revoke this app's access in your \
account settings and try the sign-in again",
))?;
mcp_user_servers::set_oauth_token(&ctx.pool, flow.server_id, &refresh).await?;
// Start it now, with the credential resolved into the server's env (§15).
let row = mcp_user_servers::get(&ctx.pool, flow.server_id).await?
.ok_or_else(|| ApiError::bad_request("connector vanished after sign-in"))?;
let container = skald_core::container::container_name(&auth.user_id);
let spec = skald_core::mcp::user_row_spec_resolved(&row, &container, skald.db()).await;
match ctx.user_mcp.start_server(spec).await {
Ok(tools) => Ok(Json(json!({ "id": row.id, "tools": tools, "auth_state": "ready" }))),
Err(e) => Ok(Json(json!({ "id": row.id, "error": e.to_string(), "auth_state": "ready" }))),
}
}
+10
View File
@@ -139,14 +139,24 @@ pub fn router() -> Router<Arc<Skald>> {
// admin: catalog + globally-active connectors // admin: catalog + globally-active connectors
.route("/mcp/catalog", get(mcp::catalog_list).post(mcp::catalog_upsert)) .route("/mcp/catalog", get(mcp::catalog_list).post(mcp::catalog_upsert))
.route("/mcp/catalog/{id}", delete(mcp::catalog_delete)) .route("/mcp/catalog/{id}", delete(mcp::catalog_delete))
// The icon of an installed connector, off the local `connectors/` folder.
// Any logged-in user, not just a catalog manager — see `catalog_icon`.
.route("/mcp/catalog/{name}/icon", get(mcp::catalog_icon))
.route("/mcp/global", get(mcp::global_list).post(mcp::global_enable)) .route("/mcp/global", get(mcp::global_list).post(mcp::global_enable))
.route("/mcp/global/{id}", delete(mcp::global_delete)) .route("/mcp/global/{id}", delete(mcp::global_delete))
.route("/mcp/global/{id}/access", get(mcp::global_get_access).put(mcp::global_set_access)) .route("/mcp/global/{id}/access", get(mcp::global_get_access).put(mcp::global_set_access))
// admin: OAuth providers (client credentials for per-user sign-in, §15)
.route("/mcp/providers", get(mcp::providers_list).post(mcp::providers_upsert))
.route("/mcp/providers/{name}", delete(mcp::providers_delete))
// user: available catalog + per-user activation // user: available catalog + per-user activation
.route("/mcp/available", get(mcp::available)) .route("/mcp/available", get(mcp::available))
.route("/mcp/activate", post(mcp::activate)) .route("/mcp/activate", post(mcp::activate))
.route("/mcp/test", post(mcp::test))
.route("/mcp/activated", get(mcp::activated_list)) .route("/mcp/activated", get(mcp::activated_list))
.route("/mcp/activated/{id}", delete(mcp::deactivate)) .route("/mcp/activated/{id}", delete(mcp::deactivate))
// user: interactive OAuth login for a pending per-user connector (§15)
.route("/mcp/oauth/start", post(mcp::oauth_start))
.route("/mcp/oauth/complete", post(mcp::oauth_complete))
// Dev / debug // Dev / debug
.route("/dev/debug_mode", get(dev::get_debug_mode).post(dev::set_debug_mode).put(dev::set_debug_mode)) .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)) .route("/dev/llm-requests", get(dev::list_llm_requests))
+2
View File
@@ -12,6 +12,7 @@ import { AgentsPage } from './components/agents.js';
import { UsersPage } from './components/users-page.js'; import { UsersPage } from './components/users-page.js';
import { RolesPage } from './components/roles-page.js'; import { RolesPage } from './components/roles-page.js';
import { ConnectorsPage } from './components/connectors.js'; import { ConnectorsPage } from './components/connectors.js';
import { ConnectorDetailPage } from './components/connector-detail.js';
import { MarketplacePage } from './components/marketplace.js'; import { MarketplacePage } from './components/marketplace.js';
import { CatalogPage } from './components/catalog.js'; import { CatalogPage } from './components/catalog.js';
import { ProfilePage } from './components/profile-page.js'; import { ProfilePage } from './components/profile-page.js';
@@ -46,6 +47,7 @@ customElements.define('agents-page', AgentsPage);
customElements.define('users-page', UsersPage); customElements.define('users-page', UsersPage);
customElements.define('roles-page', RolesPage); customElements.define('roles-page', RolesPage);
customElements.define('connectors-page', ConnectorsPage); customElements.define('connectors-page', ConnectorsPage);
customElements.define('connector-detail-page', ConnectorDetailPage);
customElements.define('marketplace-page', MarketplacePage); customElements.define('marketplace-page', MarketplacePage);
customElements.define('catalog-page', CatalogPage); customElements.define('catalog-page', CatalogPage);
customElements.define('profile-page', ProfilePage); customElements.define('profile-page', ProfilePage);
+1 -1
View File
@@ -301,7 +301,7 @@ export class CatalogPage extends LightElement {
${this._select('Transport', f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} ${this._select('Transport', f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
${isScript ${isScript
? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: 'python3', mono: true })} ? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: 'python3', mono: true })}
${this._field('Script path', f.script_path, e => this._patch('script_path', e.target.value), { hint: 'under ./scripts', mono: true })}` ${this._field('Script path', f.script_path, e => this._patch('script_path', e.target.value), { hint: 'as <connector>/<file>, under ./connectors', mono: true })}`
: this._field('URL', f.url, e => this._patch('url', e.target.value), { 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('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._field('Required secret/env keys', f.config_schema, e => this._patch('config_schema', e.target.value), { hint: 'comma/newline', mono: true })}
+626
View File
@@ -0,0 +1,626 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import {
announceChange, connectorIconUrl, jf, normalizeSchema, parseJson, seedEnv, statusOf,
} from './shared/connector-common.js';
// One connector's own page — `#connector?name=<catalog name>`.
//
// This replaces the activation dialog. A connector declares its own env/secret
// schema, so the form's height is the *connector's* choice, not the UI's: EMAIL asks
// for a dozen fields, and a fixed-size modal simply could not hold them — it grew
// taller than the viewport and the buttons went off-screen. A page scrolls.
//
// It is also the natural home for everything else that is per-connector and was
// scattered before: the Test button, the global enable, and the per-user access
// grants — which used to be a second modal reached from a third place.
//
// Deliberately not a `name` field: the list is one row per connector (§7 template),
// so the runtime name is the catalog name. The backend still defends against
// collisions; the UI just stops offering a way to cause them.
const ADMIN_ID = 'admin';
const PAGE_ID = 'connector';
function nameFromHash() {
const m = location.hash.match(/^#connector\?name=(.*)$/);
if (!m) return null;
try { return decodeURIComponent(m[1]); } catch { return null; }
}
export class ConnectorDetailPage extends LightElement {
static get properties() {
return {
_open: { state: true },
_name: { state: true },
_me: { state: true },
_entry: { state: true }, // catalog row (null for a global we cannot read)
_act: { state: true }, // my activation row, if any
_glob: { state: true }, // the global instance, if any
_schema: { state: true },
_form: { state: true }, // { api_key, env: {} }
_test: { state: true }, // null | 'running' | report
_busy: { state: true },
_error: { state: true },
_users: { state: true }, // admin: for the access panel
_access: { state: true }, // admin: Set of granted user ids
_noIcon: { state: true },
_oauth: { state: true }, // in-flight OAuth login: { state, auth_url, code }
};
}
constructor() {
super();
this._open = false;
this._noIcon = false;
this._reset();
}
_reset() {
this._name = null;
this._me = null;
this._entry = null;
this._act = null;
this._glob = null;
this._schema = [];
this._form = { api_key: '', env: {} };
this._test = null;
this._busy = false;
this._error = null;
this._users = null;
this._access = null;
this._oauth = null;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._loadFromHash();
});
window.addEventListener('hashchange', () => {
if (this._open) this._loadFromHash();
});
}
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; }
get _status() { return statusOf({ _act: this._act, _glob: this._glob }); }
async _loadFromHash() {
const name = nameFromHash();
if (!name) return;
// A different connector must not inherit the previous one's typed secrets.
if (name !== this._name) this._reset();
this._name = name;
await this._load();
}
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'),
]);
const entry = (available?.catalog ?? []).find(e => e.name === this._name) ?? null;
const glob = (available?.globals ?? [])
.find(g => (g.catalog_name ?? g.name) === this._name) ?? null;
const act = (activated ?? []).find(r => r.catalog_name === this._name) ?? null;
if (!entry && !glob) {
this._error = `No connector named “${this._name}” is available to you.`;
return;
}
this._entry = entry;
this._glob = glob;
this._act = act;
const schema = normalizeSchema(parseJson(entry?.config_schema_json, []));
this._schema = schema;
// Keep whatever the user has already typed across a reload triggered by a save.
this._form = { api_key: this._form.api_key || '', env: { ...seedEnv(schema), ...this._form.env } };
if (this._isAdmin && this._isGlobal) await this._loadAccess();
} catch (e) {
this._error = e.message;
}
}
async _loadAccess() {
try {
this._users = await jf('/api/users');
if (this._glob) {
const granted = await jf(`/api/mcp/global/${this._glob.id}/access`);
this._access = new Set(granted || []);
}
} catch (e) { this._error = e.message; }
}
_back() {
// Prefer real history so the browser's own Back stays consistent; fall back to
// the list when this page was opened straight from a pasted URL.
if (history.length > 1) { history.back(); return; }
history.pushState({ page: 'connectors' }, '', '#connectors');
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'connectors' } }));
}
_patchEnv(key, value) {
this._form = { ...this._form, env: { ...this._form.env, [key]: value } };
}
/// The env map to send: empty fields are dropped so a blank box means "unset"
/// rather than "set to empty string".
get _envPayload() {
const env = {};
for (const [k, v] of Object.entries(this._form.env || {})) if (v !== '') env[k] = v;
return Object.keys(env).length ? env : null;
}
// ── Actions ────────────────────────────────────────────────────────────────
async _testCreds() {
this._test = 'running';
try {
this._test = await jf('/api/mcp/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
catalog_name: this._name,
api_key: this._form.api_key || null,
env: this._envPayload,
}),
});
} catch (e) {
this._test = { ok: false, message: e.message };
}
}
async _activate() {
this._busy = true; this._error = null;
try {
const res = await jf('/api/mcp/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
catalog_name: this._name,
api_key: this._form.api_key || null,
env: this._envPayload,
}),
});
if (res?.auth_state === 'pending') {
this._test = res.verify ?? { ok: false, message: 'Verification failed.' };
this._error = 'Saved, but the credentials did not check out — fix them and test again.';
} else if (res?.error) {
this._error = res.error;
}
announceChange();
await this._load();
} catch (e) { this._error = e.message; }
finally { this._busy = false; }
}
async _deactivate() {
if (!confirm(`Deactivate “${this._entry?.friendly_name || this._name}”?`)) return;
this._busy = true;
try {
await jf(`/api/mcp/activated/${this._act.id}`, { method: 'DELETE' });
this._act = null;
this._test = null;
announceChange();
await this._load();
} catch (e) { this._error = e.message; }
finally { this._busy = false; }
}
// ── OAuth login (§15): activate → consent in a tab → paste code → complete ────
async _startOauth() {
this._busy = true; this._error = null;
try {
// The activation may not exist yet (first sign-in) — create the pending row,
// then reuse it. A `pending` row from a previous attempt is signed in again.
let serverId = this._act?.id;
if (!serverId) {
const res = await jf('/api/mcp/activate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ catalog_name: this._name }),
});
if (res?.error) { this._error = res.error; return; }
serverId = res.id;
}
const start = await jf('/api/mcp/oauth/start', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_id: serverId }),
});
this._oauth = { state: start.state, auth_url: start.auth_url, code: '' };
window.open(start.auth_url, '_blank', 'noopener');
announceChange();
await this._load();
} catch (e) { this._error = e.message; }
finally { this._busy = false; }
}
async _completeOauth() {
this._busy = true; this._error = null;
try {
const res = await jf('/api/mcp/oauth/complete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: this._oauth.state, code: this._oauth.code.trim() }),
});
if (res?.error) { this._error = res.error; }
else { this._oauth = null; }
announceChange();
await this._load();
} catch (e) { this._error = e.message; }
finally { this._busy = false; }
}
async _enableGlobal() {
this._busy = true; this._error = null;
try {
const res = await jf('/api/mcp/global', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
catalog_name: this._name,
api_key: this._form.api_key || null,
env: this._envPayload,
}),
});
if (res?.verify && !res.verify.ok && !res.verify.skipped) {
this._test = res.verify;
this._error = 'Verification failed — the connector stays disabled until the credentials are fixed.';
} else if (res?.error) {
this._error = res.error;
}
announceChange();
await this._load();
} catch (e) { this._error = e.message; }
finally { this._busy = false; }
}
async _disableGlobal() {
if (!confirm(`Disable “${this._glob.friendly_name || this._name}”?\n\nIt stops for everyone who can use it.`)) return;
this._busy = true;
try {
await jf(`/api/mcp/global/${this._glob.id}`, { method: 'DELETE' });
this._glob = null;
announceChange();
await this._load();
} catch (e) { this._error = e.message; }
finally { this._busy = false; }
}
_toggleAccess(userId) {
const next = new Set(this._access);
next.has(userId) ? next.delete(userId) : next.add(userId);
this._access = next;
}
async _saveAccess() {
this._busy = true;
try {
await jf(`/api/mcp/global/${this._glob.id}/access`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_ids: [...this._access] }),
});
announceChange();
await this._load();
} catch (e) { this._error = e.message; }
finally { this._busy = false; }
}
// ── Render ─────────────────────────────────────────────────────────────────
render() {
if (!this._open) return nothing;
if (this._error && !this._entry && !this._glob) {
return html`
<div class="um-page">
${this._renderHeader()}
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>
</div>`;
}
if (!this._entry && !this._glob) {
return html`<div class="um-page">${this._renderHeader()}
<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div></div>`;
}
return html`
<div class="um-page">
${this._renderHeader()}
<div style="padding:0 1.25rem 2rem; overflow:auto">
${this._error ? html`
<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${this._renderSummary()}
${this._renderConfig()}
${this._renderAccess()}
</div>
</div>`;
}
_renderHeader() {
const title = this._entry?.friendly_name || this._glob?.friendly_name || this._name || 'Connector';
return html`
<div class="um-header">
<div class="d-flex align-items-center gap-2" style="min-width:0">
<button class="btn btn-sm btn-outline-secondary" title="Back" @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i>
</button>
<h2 class="um-title" style="min-width:0;overflow:hidden;text-overflow:ellipsis">${title}</h2>
</div>
</div>`;
}
_renderSummary() {
const e = this._entry;
const isScript = e?.source === 'local_script';
const status = this._status;
const desc = e?.description || this._glob?.description;
return html`
<div class="connector-card" style="margin-top:1rem">
<div class="connector-card-head">
${!this._noIcon
? html`<img class="connector-card-icon" style="width:44px;height:44px"
src=${connectorIconUrl(this._name, 'lg')} alt=""
@error=${() => { this._noIcon = true; }} />`
: html`<div class="connector-card-icon connector-card-icon--empty" style="width:44px;height:44px">
<i class="bi bi-plug"></i></div>`}
<div class="connector-card-title">
<div class="connector-card-name" style="font-size:1rem">
${e?.friendly_name || this._glob?.friendly_name || this._name}
</div>
<div class="connector-card-sub">${this._name}</div>
</div>
</div>
${desc ? html`<div class="connector-card-desc" style="-webkit-line-clamp:initial">${desc}</div>` : nothing}
<div class="connector-chips">
<span class="connector-chip connector-chip--scope">
<i class="bi ${this._isGlobal ? 'bi-globe' : 'bi-person'}"></i>${this._isGlobal ? 'global' : 'per-user'}
</span>
${isScript ? html`
<span class="connector-chip connector-chip--script">
<i class="bi bi-file-earmark-code"></i>runs code on this box
</span>` : nothing}
${e?.auth_kind && e.auth_kind !== 'none' ? html`
<span class="connector-chip"><i class="bi bi-key"></i>${e.auth_kind}</span>` : nothing}
${status === 'active' ? html`
<span class="connector-chip connector-chip--ok"><i class="bi bi-check-circle"></i>active</span>` : nothing}
${status === 'pending' ? html`
<span class="connector-chip connector-chip--script"><i class="bi bi-exclamation-triangle"></i>needs fixing</span>` : nothing}
${status === 'needs_login' ? html`
<span class="connector-chip connector-chip--script"><i class="bi bi-box-arrow-in-right"></i>needs sign-in</span>` : nothing}
</div>
${this._isGlobal ? html`
<div class="connector-card-note">
<i class="bi bi-info-circle"></i>Runs once for the household, on the host. Nobody reaches it until they are granted access.
</div>` : nothing}
</div>`;
}
_renderConfig() {
const e = this._entry;
// A granted global we have no catalog row for: nothing here is ours to configure.
if (!e) {
return html`
<div style="margin-top:1.5rem">
<div class="um-empty" style="padding:1rem"><i class="bi bi-check2-circle"></i>
<p>This connector is managed for you.</p>
<p style="font-size:.8rem;opacity:.7">It is enabled by an admin and granted to you — there is nothing to configure.</p>
</div>
</div>`;
}
const active = this._isGlobal ? !!this._glob : !!this._act;
const canManage = this._isGlobal ? this._isAdmin : true;
const hasVerify = !!e.verify_command;
const oauth = e.auth_kind === 'oauth';
if (this._isGlobal && !this._isAdmin) return nothing;
// OAuth is a per-user, interactive flow — a browser consent, not a form of
// typed credentials — so it gets its own panel instead of the api_key/env body.
if (oauth && !this._isGlobal) {
return html`
<div style="margin-top:1.5rem">
<div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-key me-2"></i>Sign in</h3>
</div>
${this._renderOauth()}
</div>`;
}
return html`
<div style="margin-top:1.5rem">
<div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem">
<i class="bi bi-sliders me-2"></i>${active ? 'Configuration' : 'Set up'}
</h3>
</div>
${active ? html`
<div class="text-muted mb-3" style="font-size:.78rem">
${this._isGlobal
? 'Already enabled. Re-submitting replaces the stored credentials.'
: 'Already active. Re-submitting replaces the stored credentials.'}
</div>` : nothing}
${e.auth_kind === 'api_key' ? html`
<div class="mb-3">
<label class="form-label">API key<span class="text-danger">*</span></label>
<input class="form-control" type="password" .value=${this._form.api_key}
@input=${(ev) => { this._form = { ...this._form, api_key: ev.target.value }; }} />
</div>` : nothing}
${this._renderEnvFields()}
${this._renderVerifyBox()}
<div class="d-flex gap-2 flex-wrap" style="margin-top:.5rem">
${hasVerify && canManage ? html`
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._test === 'running' || this._busy}
@click=${() => this._testCreds()}>
<i class="bi bi-${this._test === 'running' ? 'arrow-repeat' : 'check2-gear'} me-1"></i>
${this._test === 'running' ? 'Testing…' : 'Test credentials'}
</button>` : nothing}
${this._isGlobal
? html`
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._enableGlobal()}>
<i class="bi bi-globe me-1"></i>${this._glob ? 'Save & restart' : 'Enable globally'}
</button>
${this._glob ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._disableGlobal()}>
<i class="bi bi-trash me-1"></i>Disable
</button>` : nothing}`
: html`
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._activate()}>
<i class="bi bi-plug me-1"></i>${this._act ? 'Save & restart' : 'Activate'}
</button>
${this._act ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deactivate()}>
<i class="bi bi-trash me-1"></i>Deactivate
</button>` : nothing}`}
</div>
</div>`;
}
_renderOauth() {
const provider = this._entry?.oauth_provider || 'provider';
const label = provider.charAt(0).toUpperCase() + provider.slice(1);
const active = this._act && this._act.auth_state === 'ready';
const pending = this._act && this._act.auth_state === 'pending';
const scopes = parseJson(this._entry?.oauth_scopes_json, []);
return html`
<div class="text-muted mb-3" style="font-size:.78rem">
Signs in with ${label}. You approve access in a browser tab, then paste back the
code the page shows you — nothing is stored on this box until you do.
</div>
${scopes.length ? html`
<div class="mb-3" style="font-size:.72rem">
<div class="text-muted mb-1">It will request access to:</div>
<ul class="mb-0 ps-3">${scopes.map(s => html`<li><code style="font-size:.68rem">${s}</code></li>`)}</ul>
</div>` : nothing}
${active ? html`
<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-check-circle-fill me-1"></i>Signed in and active.
</div>` : nothing}
${!this._oauth ? html`
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._startOauth()}>
<i class="bi bi-box-arrow-in-right me-1"></i>${active ? 'Sign in again' : (pending ? 'Finish sign-in' : `Sign in with ${label}`)}
</button>
${this._act ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deactivate()}>
<i class="bi bi-trash me-1"></i>Deactivate
</button>` : nothing}
</div>`
: html`
<div class="connector-card" style="margin-top:.25rem">
<div class="mb-2" style="font-size:.8rem">
<i class="bi bi-1-circle me-1"></i>A tab opened for ${label}. Approve access there.
<div class="mt-1"><a href=${this._oauth.auth_url} target="_blank" rel="noopener">Re-open the sign-in page</a></div>
</div>
<div class="mb-2" style="font-size:.8rem">
<i class="bi bi-2-circle me-1"></i>Paste the code the page gave you:
</div>
<input class="form-control font-monospace mb-2" placeholder="4/0A…"
.value=${this._oauth.code}
@input=${(ev) => { this._oauth = { ...this._oauth, code: ev.target.value }; }} />
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" ?disabled=${this._busy || !this._oauth.code.trim()}
@click=${() => this._completeOauth()}>
<i class="bi bi-check-lg me-1"></i>Complete sign-in
</button>
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy}
@click=${() => { this._oauth = null; }}>Cancel</button>
</div>
</div>`}
`;
}
_renderEnvFields() {
if (!this._schema.length) return nothing;
return this._schema.map(f => html`
<div class="mb-3">
<label class="form-label">
${f.label || f.name}
${f.required ? html`<span class="text-danger">*</span>` : nothing}
${f.secret ? html` <span class="badge bg-warning text-dark" style="font-size:.6rem">secret</span>` : nothing}
</label>
<input
class="form-control ${f.secret ? '' : 'font-monospace'}"
type=${f.secret ? 'password' : 'text'}
placeholder=${f.example || ''}
.value=${this._form.env[f.name] ?? ''}
@input=${(ev) => this._patchEnv(f.name, ev.target.value)} />
${f.description ? html`<div class="form-text" style="font-size:.72rem">${f.description}</div>` : nothing}
</div>`);
}
_renderVerifyBox() {
const t = this._test;
if (t === null) return nothing;
if (t === 'running') {
return html`<div class="alert alert-secondary py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-arrow-repeat me-1"></i>Testing credentials…</div>`;
}
if (t.skipped) {
return html`<div class="alert alert-secondary py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-info-circle me-1"></i>${t.message || 'No verification step for this connector.'}</div>`;
}
return html`
<div class="alert alert-${t.ok ? 'success' : 'danger'} py-2 mb-3" style="font-size:.82rem">
<i class="bi ${t.ok ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>${t.ok ? 'OK' : 'Failed'}</strong> — ${t.message}
${t.details ? html`
<pre class="mb-0 mt-1 p-2 rounded bg-dark text-light"
style="font-size:.7rem;white-space:pre-wrap">${JSON.stringify(t.details, null, 2)}</pre>` : nothing}
</div>`;
}
/// Who may use this global connector. Only meaningful once it is enabled — there
/// is no instance to grant access to before that.
_renderAccess() {
if (!this._isGlobal || !this._isAdmin || !this._glob) return nothing;
const users = this._users ?? [];
return html`
<div style="margin-top:1.75rem">
<div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-people me-2"></i>Who can use it</h3>
</div>
<div class="text-muted mb-2" style="font-size:.78rem">
Ticking a box grants this connector's tools to that person's agent. Saving replaces the whole list.
</div>
${users.length === 0
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i><p>No users.</p></div>`
: html`
<div class="connector-card">
${users.map(u => html`
<div class="form-check">
<input class="form-check-input" type="checkbox" id=${'acc-' + u.id}
.checked=${this._access?.has(u.id) ?? false}
@change=${() => this._toggleAccess(u.id)} />
<label class="form-check-label" for=${'acc-' + u.id}>
${u.display_name || u.username}
<code class="text-muted" style="font-size:.7rem">${u.id}</code>
</label>
</div>`)}
</div>`}
<button class="btn btn-sm btn-primary mt-2" ?disabled=${this._busy || !this._access}
@click=${() => this._saveAccess()}>
<i class="bi bi-check-lg me-1"></i>Save access
</button>
</div>`;
}
}
+302 -322
View File
@@ -1,28 +1,26 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { connectorIconUrl, statusOf, STATUS_LABEL } from './shared/connector-common.js';
// Connectors (MCP) — blueprint §7/§14/§15. // Connectors (MCP) — blueprint §7/§14/§15.
// //
// One question: **what is running, and what can I add?** This is the runtime view — // **One row per connector**, not one per runtime instance. A catalog entry is a
// literally `UserMcpView` (global per-user) plus the actions that create those // template with two runtimes (§7), and a person thinks in terms of "do I have
// instances. What this box *offers* is a different question, answered by the // Gmail?" — not "how many `mcp_user_servers` rows named gmail-ish do I own?". So the
// Connector Catalog page. // old three-section split (Mine / Global / Available) is gone: the same connector
// used to appear twice, once as a template and once as its instance, and the reader
// had to join the two by eye. Here each connector appears exactly once, and its
// state is a chip on the card.
// //
// The same page serves everyone; the admin just has more verbs. A catalog entry is a // The card is a link, not a form. Everything that needs typing lives on the
// template with two runtimes (§7), so "Available" is one list with the verb that fits // connector's own page (`#connector?name=X`) — an activation form has as many
// each row: a `per_user` entry says Activate (anyone), a `global` entry says Enable // fields as the connector declares (EMAIL has a dozen), which a fixed-size dialog
// globally (admin only). Enabling a global is the admin's counterpart to activating a // could never hold.
// per-user one — which is why they live side by side instead of in an admin dungeon.
// //
// Reuses the shared `um-*` / bootstrap styling (no page-specific CSS). // Reuses the marketplace's card styling (`web/css/connectors.css`).
const ADMIN_ID = 'admin'; 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) { async function jf(url, opts) {
const res = await fetch(url, opts); const res = await fetch(url, opts);
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`); if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
@@ -38,15 +36,20 @@ export class ConnectorsPage extends LightElement {
_me: { state: true }, // { role_id } _me: { state: true }, // { role_id }
_available: { state: true }, // { catalog: [...], globals: [...] } _available: { state: true }, // { catalog: [...], globals: [...] }
_activated: { state: true }, // my per-user server rows _activated: { state: true }, // my per-user server rows
_users: { state: true }, // admin: user summaries (for the access modal)
_error: { state: true }, _error: { state: true },
_modal: { state: true }, _q: { state: true },
_noIcon: { state: true }, // names whose icon failed to load
_providers: { state: true }, // admin: OAuth provider list (modal)
_pForm: { state: true }, // admin: provider being edited, or null
_pError: { state: true },
}; };
} }
constructor() { constructor() {
super(); super();
this._open = false; this._open = false;
this._q = '';
this._noIcon = new Set();
this._reset(); this._reset();
} }
@@ -54,9 +57,10 @@ export class ConnectorsPage extends LightElement {
this._me = null; this._me = null;
this._available = null; this._available = null;
this._activated = null; this._activated = null;
this._users = null;
this._error = null; this._error = null;
this._modal = null; this._providers = null;
this._pForm = null;
this._pError = null;
} }
connectedCallback() { connectedCallback() {
@@ -66,6 +70,9 @@ export class ConnectorsPage extends LightElement {
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._load(); if (this._open) this._load();
}); });
// Coming back from a connector's page must show its new state, not the state
// captured before the user activated it.
window.addEventListener('connectors-changed', () => { if (this._open) this._load(); });
} }
get _isAdmin() { return this._me?.role_id === ADMIN_ID; } get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
@@ -80,130 +87,147 @@ export class ConnectorsPage extends LightElement {
]); ]);
this._available = available; this._available = available;
this._activated = activated; this._activated = activated;
// Only the access modal needs the user list, and only an admin opens it.
if (this._isAdmin) this._users = await jf('/api/users');
} catch (e) { } catch (e) {
this._error = e.message; this._error = e.message;
} }
} }
_patch(field, value) { _go(page, hash) {
this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; history.pushState({ page }, '', hash);
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
} }
_closeModal() { this._modal = null; this._error = null; } _openConnector(name) {
this._go('connector', `#connector?name=${encodeURIComponent(name)}`);
_goCatalog() {
history.pushState({ page: 'catalog' }, '', '#catalog');
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'catalog' } }));
} }
// ── Activate a per-user connector ────────────────────────────────────────── // ── admin: OAuth sign-in providers (§15) ─────────────────────────────────────
_openActivate(entry) { async _openProviders() {
const schema = parseJson(entry.config_schema_json, []) || []; this._pError = null;
this._modal = { this._pForm = null;
kind: 'activate', try {
entry, this._providers = await jf('/api/mcp/providers');
form: { name: entry.name, api_key: '', env: Object.fromEntries(schema.map(k => [k, ''])) }, } catch (e) { this._pError = e.message; this._providers = []; }
}
_closeProviders() {
this._providers = null;
this._pForm = null;
this._pError = null;
}
_blankProvider() {
return { name: '', display_name: '', auth_url: '', token_url: '',
client_id: '', client_secret: '', redirect_uri: '', extra_params: '' };
}
/// A Google preset — fills everything but the client_id/secret the admin pastes
/// from their Google Cloud console. `prompt=consent` + `access_type=offline` are
/// what make Google return a refresh token (§15).
_presetGoogle() {
this._pError = null;
this._pForm = {
name: 'google',
display_name: 'Google',
auth_url: 'https://accounts.google.com/o/oauth2/v2/auth',
token_url: 'https://oauth2.googleapis.com/token',
client_id: '',
client_secret: '',
redirect_uri: 'https://connectors.skaldagent.net/oauth/show.html',
extra_params: '{"access_type":"offline","prompt":"consent"}',
_isNew: true,
}; };
} }
async _activate() { _editProvider(p) {
const { entry, form } = this._modal; // The secret never came back from the server; an empty box means "keep it".
if (!form.name.trim()) { this._error = 'A name is required.'; return; } this._pForm = { ...p, client_secret: '', extra_params: p.extra_params || '', _isNew: false };
const env = {}; this._pError = null;
for (const [k, v] of Object.entries(form.env || {})) if (v !== '') env[k] = v; }
_patchProvider(key, value) {
this._pForm = { ...this._pForm, [key]: value };
}
async _saveProvider() {
const f = this._pForm;
if (!f.name.trim() || !f.client_id.trim()) {
this._pError = 'Name and client id are required.';
return;
}
if (f._isNew && !f.client_secret.trim()) {
this._pError = 'A client secret is required for a new provider.';
return;
}
this._pError = null;
try { try {
await jf('/api/mcp/activate', { const { _isNew, has_client_secret, ...body } = f;
method: 'POST', await jf('/api/mcp/providers', {
headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(body),
catalog_name: entry.name,
name: form.name.trim(),
api_key: form.api_key || null,
env: Object.keys(env).length ? env : null,
}),
}); });
this._closeModal(); this._pForm = null;
await this._load(); this._providers = await jf('/api/mcp/providers');
} catch (e) { this._error = e.message; } } catch (e) { this._pError = e.message; }
} }
async _deactivate(row) { async _deleteProvider(name) {
if (!confirm(`Deactivate connector "${row.name}"?`)) return; if (!confirm(`Delete the “${name}” sign-in provider?\n\nConnectors that use it will no longer be able to sign in.`)) return;
try { try {
await jf(`/api/mcp/activated/${row.id}`, { method: 'DELETE' }); await jf(`/api/mcp/providers/${encodeURIComponent(name)}`, { method: 'DELETE' });
await this._load(); this._providers = await jf('/api/mcp/providers');
} catch (e) { this._error = e.message; } } catch (e) { this._pError = e.message; }
} }
// ── Enable a global connector (admin) ────────────────────────────────────── /// The merged view: every connector the caller can see, exactly once, carrying
/// whichever runtime rows exist for it.
get _rows() {
const catalog = this._available?.catalog ?? [];
const globals = this._available?.globals ?? [];
const activated = this._activated ?? [];
// The entry comes from the row the admin clicked, so there is no catalog picker: const rows = catalog.map(e => ({
// the old dropdown existed only because this action lived on a page that did not ...e,
// show the catalog. _act: activated.find(r => r.catalog_name === e.name) ?? null,
_openEnableGlobal(entry) { _glob: globals.find(g => (g.catalog_name ?? g.name) === e.name) ?? null,
this._modal = { }));
kind: 'global',
entry,
form: { name: entry.name, api_key: '' },
};
}
async _enableGlobal() { // A granted global whose catalog row the caller cannot see. `/api/mcp/available`
const { entry, form } = this._modal; // only returns `global` catalog entries to a catalog manager, so without this the
try { // connector an ordinary user actually uses every day would be missing from their
await jf('/api/mcp/global', { // own list — visible to the admin, invisible to its user.
method: 'POST', for (const g of globals) {
headers: { 'Content-Type': 'application/json' }, const key = g.catalog_name ?? g.name;
body: JSON.stringify({ if (rows.some(r => r.name === key)) continue;
catalog_name: entry.name, rows.push({
name: form.name.trim() || null, name: key,
api_key: form.api_key || null, friendly_name: g.friendly_name,
}), description: g.description,
scope: 'global',
source: 'remote',
auth_kind: 'none',
_act: null,
_glob: g,
}); });
this._closeModal();
await this._load();
} catch (e) { this._error = e.message; }
} }
async _deleteGlobal(row) { const q = this._q.trim().toLowerCase();
if (!confirm(`Disable global connector "${row.name}"?\n\nIt stops for everyone who can use it.`)) return; return rows
try { .filter(r => !q
await jf(`/api/mcp/global/${row.id}`, { method: 'DELETE' }); || r.name.toLowerCase().includes(q)
await this._load(); || (r.friendly_name ?? '').toLowerCase().includes(q)
} catch (e) { this._error = e.message; } || (r.description ?? '').toLowerCase().includes(q))
.sort((a, b) => (a.friendly_name || a.name).localeCompare(b.friendly_name || b.name));
} }
async _openAccess(server) { _iconFailed(name) {
this._modal = { kind: 'access', server, selected: new Set() }; // Re-render with the placeholder. A synthetic row (a granted global whose
try { // catalog entry the caller cannot read) has no icon path to check up front, so
const current = await jf(`/api/mcp/global/${server.id}/access`); // the 404 is the check.
// Ignore if the admin already navigated away / opened another modal. const next = new Set(this._noIcon);
if (this._modal?.kind === 'access' && this._modal.server.id === server.id) { next.add(name);
this._modal = { ...this._modal, selected: new Set(current || []) }; this._noIcon = next;
}
} 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();
await this._load();
} catch (e) { this._error = e.message; }
} }
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
@@ -211,6 +235,7 @@ export class ConnectorsPage extends LightElement {
render() { render() {
if (!this._open) return nothing; if (!this._open) return nothing;
const loading = this._available === null && !this._error; const loading = this._available === null && !this._error;
const rows = loading ? [] : this._rows;
return html` return html`
<div class="um-page"> <div class="um-page">
@@ -218,232 +243,187 @@ export class ConnectorsPage extends LightElement {
<h2 class="um-title"><i class="bi bi-plug me-2"></i>Connectors</h2> <h2 class="um-title"><i class="bi bi-plug me-2"></i>Connectors</h2>
<div class="um-header-right"> <div class="um-header-right">
${this._isAdmin ? html` ${this._isAdmin ? html`
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._goCatalog()}> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._openProviders()}>
<i class="bi bi-key me-1"></i>Sign-in providers
</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._go('catalog', '#catalog')}>
<i class="bi bi-journal-text me-1"></i>Catalog <i class="bi bi-journal-text me-1"></i>Catalog
</button>
<button class="btn btn-sm btn-primary" @click=${() => this._go('marketplace', '#marketplace')}>
<i class="bi bi-bag me-1"></i>Marketplace
</button>` : nothing} </button>` : nothing}
</div> </div>
</div> </div>
${this._error && !this._modal ? html` ${this._error ? html`
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing} <div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : html` ${loading
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>`
: html`
<div style="padding:0 1.25rem 1.5rem; overflow:auto"> <div style="padding:0 1.25rem 1.5rem; overflow:auto">
${this._renderMine()} <div class="connector-filters">
${this._renderGlobals()} <div class="connector-search">
${this._renderAvailable()} <i class="bi bi-search"></i>
<input class="form-control form-control-sm" placeholder="Search connectors…"
.value=${this._q} @input=${(e) => { this._q = e.target.value; }} />
</div>
</div>
${rows.length === 0 ? this._renderEmpty() : html`
<div class="connector-grid">${rows.map(r => this._renderCard(r))}</div>`}
</div>`} </div>`}
</div> ${this._providers !== null ? this._renderProvidersModal() : nothing}
${this._renderModal()}`;
}
_section(title, icon, right, body) {
return html`
<div style="margin-top:1.5rem">
<div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem"><i class="bi ${icon} me-2"></i>${title}</h3>
<div class="um-header-right">${right ?? nothing}</div>
</div>
${body}
</div>`; </div>`;
} }
_renderMine() { _renderProvidersModal() {
const rows = this._activated ?? [];
return this._section('My connectors', 'bi-check2-circle', nothing,
rows.length === 0
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-plug"></i>
<p>No per-user connectors activated.</p></div>`
: html`
<table class="um-table">
<thead><tr><th>Name</th><th>Type</th><th>From catalog</th><th></th></tr></thead>
<tbody>
${rows.map(r => html`
<tr>
<td><strong>${r.name}</strong></td>
<td><span class="badge ${r.source === 'local_script' ? 'bg-warning text-dark' : 'bg-secondary'}"
style="font-size:.65rem">${r.source === 'local_script' ? 'local script' : 'remote'}</span></td>
<td>${r.catalog_name ? html`<code>${r.catalog_name}</code>` : html`<span class="text-muted">—</span>`}</td>
<td><div class="um-actions">
<button class="um-btn-icon" title="Deactivate" @click=${() => this._deactivate(r)}>
<i class="bi bi-trash"></i></button>
</div></td>
</tr>`)}
</tbody>
</table>`);
}
_renderGlobals() {
const rows = this._available?.globals ?? [];
if (rows.length === 0 && !this._isAdmin) return nothing;
return this._section('Global connectors', 'bi-globe', nothing,
rows.length === 0
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-globe"></i>
<p>None enabled. Enable one from Available below.</p></div>`
: html`
${this._isAdmin ? html`
<div class="text-muted mb-2" style="font-size:.75rem">
Shared by the household. You see every one so you can manage it —
<span class="badge bg-success" style="font-size:.6rem">yours</span> marks the ones granted to you.
</div>` : nothing}
<table class="um-table">
<thead><tr><th>Name</th><th>Transport</th><th>Status</th><th></th></tr></thead>
<tbody>
${rows.map(g => html`
<tr>
<td>
<strong>${g.friendly_name || g.name}</strong>
${this._isAdmin && g.can_use ? html`
<span class="badge bg-success ms-1" style="font-size:.6rem">yours</span>` : nothing}
${g.description ? html`
<div class="text-muted" style="font-size:.75rem;max-width:44ch;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap" title=${g.description}>${g.description}</div>` : nothing}
</td>
<td><span class="text-muted" style="font-size:.78rem">${g.transport}</span></td>
<td>${g.enabled
? html`<span class="badge bg-success" style="font-size:.65rem">on</span>`
: html`<span class="badge bg-secondary" style="font-size:.65rem">off</span>`}</td>
<td><div class="um-actions">
${this._isAdmin ? html`
<button class="um-btn-icon" title="Manage access" @click=${() => this._openAccess(g)}>
<i class="bi bi-people"></i></button>
<button class="um-btn-icon" title="Disable" @click=${() => this._deleteGlobal(g)}>
<i class="bi bi-trash"></i></button>
` : nothing}
</div></td>
</tr>`)}
</tbody>
</table>`);
}
_renderAvailable() {
const entries = this._available?.catalog ?? [];
const enabledGlobals = new Set((this._available?.globals ?? []).map(g => g.catalog_name ?? g.name));
const activatedNames = new Set((this._activated ?? []).map(r => r.catalog_name));
const right = this._isAdmin ? html`
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._goCatalog()}>
<i class="bi bi-plus-lg me-1"></i>Add to catalog
</button>` : nothing;
if (entries.length === 0) {
return this._section('Available', 'bi-plus-square', right, html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-journal"></i>
<p>${this._isAdmin ? 'The catalog is empty.' : 'Nothing available to you yet.'}</p>
${this._isAdmin ? html`
<p style="font-size:.8rem;opacity:.7">Add connectors to the catalog first.</p>` : nothing}
</div>`);
}
return this._section('Available', 'bi-plus-square', right, html`
<table class="um-table">
<thead><tr><th>Connector</th><th>Scope</th><th>Auth</th><th></th></tr></thead>
<tbody>
${entries.map(e => {
const isGlobal = e.scope === 'global';
const already = isGlobal ? enabledGlobals.has(e.name) : activatedNames.has(e.name);
return html` return html`
<tr> <div style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1050;
<td><strong>${e.friendly_name || e.name}</strong> display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:2rem 1rem"
${e.description ? html` @click=${(e) => { if (e.target === e.currentTarget) this._closeProviders(); }}>
<div class="text-muted" style="font-size:.75rem;max-width:44ch;overflow:hidden; <div class="connector-card" style="width:100%;max-width:560px;cursor:default">
text-overflow:ellipsis;white-space:nowrap" title=${e.description}>${e.description}</div>` : nothing}</td> <div class="d-flex align-items-center justify-content-between mb-2">
<td><span class="badge ${isGlobal ? 'bg-info' : 'bg-secondary'}" style="font-size:.65rem"> <h3 class="um-title" style="font-size:1rem;margin:0"><i class="bi bi-key me-2"></i>Sign-in providers</h3>
${isGlobal ? 'global' : 'per-user'}</span></td> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeProviders()}>
<td><span class="text-muted" style="font-size:.78rem">${e.auth_kind}</span></td> <i class="bi bi-x-lg"></i>
<td><div class="um-actions"> </button>
${already
? html`<span class="badge bg-success">${isGlobal ? 'enabled' : 'active'}</span>`
: isGlobal
? html`<button class="btn btn-sm btn-primary" @click=${() => this._openEnableGlobal(e)}>
<i class="bi bi-globe me-1"></i>Enable globally</button>`
: html`<button class="btn btn-sm btn-primary" @click=${() => this._openActivate(e)}>
<i class="bi bi-plug me-1"></i>Activate</button>`}
</div></td>
</tr>`;
})}
</tbody>
</table>`);
}
// ── Modals ─────────────────────────────────────────────────────────────────
_modalShell(title, icon, body, onSave, saveLabel) {
return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
<div class="um-modal">
<div class="um-modal-header">
<i class="bi ${icon}"></i><span>${title}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div> </div>
<div class="um-modal-body">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${body}
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button class="btn btn-sm btn-primary" @click=${onSave}><i class="bi bi-check-lg me-1"></i>${saveLabel}</button>
</div>
</div>
</div>`;
}
_field(label, value, oninput, opts = {}) {
return html`<div class="mb-3">
<label class="form-label">${label}${opts.hint ? html` <span class="text-muted">(${opts.hint})</span>` : nothing}</label>
<input class="form-control ${opts.mono ? 'font-monospace' : ''}" type=${opts.type || 'text'}
placeholder=${opts.placeholder || ''} .value=${value} @input=${oninput} />
</div>`;
}
_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}
${m.entry.auth_kind === 'oauth' ? html`
<div class="alert alert-warning py-2" style="font-size:.78rem">
<i class="bi bi-exclamation-triangle me-1"></i>This connector needs an interactive login,
which is not wired up yet — it will activate but cannot authenticate.
</div>` : nothing}
${schema.map(k => html`<div class="mb-3">
<label class="form-label font-monospace" style="font-size:.8rem">${k}</label>
<input class="form-control font-monospace" .value=${f.env[k] ?? ''}
@input=${e => this._patch('env', { ...f.env, [k]: e.target.value })} />
</div>`)}
`, () => this._activate(), 'Activate');
}
if (m.kind === 'global') {
const f = m.form;
return this._modalShell(`Enable ${m.entry.friendly_name || m.entry.name} globally`, 'bi-globe', html`
<div class="text-muted mb-3" style="font-size:.78rem"> <div class="text-muted mb-3" style="font-size:.78rem">
Runs once for the household on the host. Nobody reaches it until you grant access. OAuth apps that per-user connectors sign in through. One app (e.g. Google) covers all of
its services. The client secret is stored on this box and never shown again.
</div> </div>
${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'runtime name', mono: true })} ${this._pError ? html`
${m.entry.auth_kind === 'api_key' <div class="alert alert-danger py-2 mb-2" style="font-size:.82rem">${this._pError}</div>` : nothing}
? this._field('API key', f.api_key, e => this._patch('api_key', e.target.value), { type: 'password', mono: true }) ${this._pForm ? this._renderProviderForm() : this._renderProviderList()}
: nothing} </div>
`, () => this._enableGlobal(), 'Enable'); </div>`;
} }
if (m.kind === 'access') { _renderProviderList() {
const users = this._users ?? []; const list = this._providers ?? [];
return this._modalShell(`Access — ${m.server.name}`, 'bi-people', html` return html`
<div class="text-muted mb-2" style="font-size:.8rem">Select who may use this global connector. This replaces the current list.</div> ${list.length === 0 ? html`
${users.map(u => html`<div class="form-check"> <div class="um-empty" style="padding:1rem"><i class="bi bi-key"></i>
<input class="form-check-input" type="checkbox" id=${'acc-' + u.id} <p>No sign-in providers yet.</p></div>` : html`
.checked=${m.selected.has(u.id)} @change=${() => this._toggleAccess(u.id)} /> <div class="d-flex flex-column gap-2 mb-3">
<label class="form-check-label" for=${'acc-' + u.id}>${u.display_name || u.username} <code class="text-muted">${u.id}</code></label> ${list.map(p => html`
<div class="d-flex align-items-center justify-content-between p-2 rounded"
style="border:1px solid var(--bs-border-color,#333)">
<div style="min-width:0">
<div style="font-weight:500">${p.display_name || p.name}
<code class="text-muted" style="font-size:.7rem">${p.name}</code></div>
<div class="text-muted" style="font-size:.72rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${p.has_client_secret
? html`<i class="bi bi-check-circle text-success"></i> secret set`
: html`<i class="bi bi-exclamation-triangle text-warning"></i> no secret`}
· ${p.client_id || '(no client id)'}
</div>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._editProvider(p)}>
<i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @click=${() => this._deleteProvider(p.name)}>
<i class="bi bi-trash"></i></button>
</div>
</div>`)} </div>`)}
`, () => this._saveAccess(), 'Save access'); </div>`}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" @click=${() => this._presetGoogle()}>
<i class="bi bi-google me-1"></i>Add Google
</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => { this._pForm = { ...this._blankProvider(), _isNew: true }; }}>
<i class="bi bi-plus-lg me-1"></i>Add other
</button>
</div>`;
} }
return nothing; _renderProviderForm() {
const f = this._pForm;
const field = (key, label, opts = {}) => html`
<div class="mb-2">
<label class="form-label" style="font-size:.8rem">${label}${opts.req ? html`<span class="text-danger">*</span>` : nothing}</label>
<input class="form-control form-control-sm ${opts.mono ? 'font-monospace' : ''}"
type=${opts.secret ? 'password' : 'text'}
placeholder=${opts.ph || ''}
.value=${f[key] ?? ''}
@input=${(e) => this._patchProvider(key, e.target.value)} />
${opts.help ? html`<div class="form-text" style="font-size:.7rem">${opts.help}</div>` : nothing}
</div>`;
return html`
${field('name', 'Provider id', { req: true, mono: true, ph: 'google',
help: 'The slug a connector references (must match the manifest\'s auth.provider).' })}
${field('display_name', 'Display name', { ph: 'Google' })}
${field('client_id', 'Client id', { req: true, mono: true })}
${field('client_secret', 'Client secret', { secret: true, mono: true,
help: f._isNew ? 'Required.' : 'Leave blank to keep the stored secret.' })}
${field('auth_url', 'Authorization URL', { mono: true, ph: 'https://accounts.google.com/o/oauth2/v2/auth' })}
${field('token_url', 'Token URL', { mono: true, ph: 'https://oauth2.googleapis.com/token' })}
${field('redirect_uri', 'Redirect URI', { mono: true,
help: 'The copy-paste page. Must be registered as an authorized redirect in the provider\'s console.' })}
${field('extra_params', 'Extra params (JSON)', { mono: true, ph: '{"access_type":"offline","prompt":"consent"}',
help: 'Merged into the consent URL. Google needs these two to return a refresh token.' })}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-sm btn-primary" @click=${() => this._saveProvider()}>
<i class="bi bi-check-lg me-1"></i>Save
</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => { this._pForm = null; this._pError = null; }}>
Cancel
</button>
</div>`;
}
_renderEmpty() {
if (this._q.trim()) {
return html`<div class="um-empty" style="padding:1rem"><i class="bi bi-search"></i>
<p>No connector matches “${this._q}”.</p></div>`;
}
return html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-plug"></i>
<p>${this._isAdmin ? 'No connectors installed yet.' : 'Nothing available to you yet.'}</p>
${this._isAdmin
? html`<p style="font-size:.8rem;opacity:.7">Install one from the Marketplace to get started.</p>`
: html`<p style="font-size:.8rem;opacity:.7">Ask an admin to make one available.</p>`}
</div>`;
}
_renderCard(r) {
const status = statusOf(r);
const isGlobal = r.scope === 'global';
const isScript = r.source === 'local_script';
const showIcon = !this._noIcon.has(r.name);
return html`
<div class="connector-card" role="button" tabindex="0"
style="cursor:pointer"
@click=${() => this._openConnector(r.name)}
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._openConnector(r.name); } }}>
<div class="connector-card-head">
${showIcon
? html`<img class="connector-card-icon" src=${connectorIconUrl(r.name, 'sm')} alt=""
@error=${() => this._iconFailed(r.name)} />`
: html`<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-plug"></i></div>`}
<div class="connector-card-title">
<div class="connector-card-name">${r.friendly_name || r.name}</div>
<div class="connector-card-sub">${r.name}</div>
</div>
<span class=${`connector-chip${STATUS_LABEL[status].tone ? ` connector-chip--${STATUS_LABEL[status].tone}` : ''}`}>
${STATUS_LABEL[status].text}
</span>
</div>
${r.description ? html`<div class="connector-card-desc">${r.description}</div>` : nothing}
<div class="connector-chips">
<span class="connector-chip connector-chip--scope">
<i class="bi ${isGlobal ? 'bi-globe' : 'bi-person'}"></i>${isGlobal ? 'global' : 'per-user'}
</span>
${isScript ? html`
<span class="connector-chip connector-chip--script">
<i class="bi bi-file-earmark-code"></i>local script
</span>` : nothing}
${r.auth_kind && r.auth_kind !== 'none' ? html`
<span class="connector-chip"><i class="bi bi-key"></i>${r.auth_kind}</span>` : nothing}
</div>
</div>`;
} }
} }
+1 -1
View File
@@ -91,7 +91,7 @@ export class MarketplacePage extends LightElement {
async _install(card) { async _install(card) {
const warn = card.source === 'local_script' const warn = card.source === 'local_script'
? `\n\nThis puts code on this box:\n${card.file_count} file(s), each verified against its SHA-256\n • installed into ./scripts/${card.id}/` ? `\n\nThis puts code on this box:\n${card.file_count} file(s), each verified against its SHA-256\n • installed into ./connectors/${card.id}/`
: ''; : '';
if (!confirm(`Install "${card.name}" into the catalog?${warn}\n\nInstalling does not activate it.`)) return; if (!confirm(`Install "${card.name}" into the catalog?${warn}\n\nInstalling does not activate it.`)) return;
this._installing = card.id; this._installing = card.id;
+88
View File
@@ -0,0 +1,88 @@
// Shared vocabulary for the Connectors list and a connector's own page.
//
// Both surfaces have to answer "what state is this connector in?" and both draw the
// same env/secret form. Deriving that twice is how the two drift, so the derivation
// lives here and each page only decides layout.
/// The icon of an **installed** connector, off the box's own `connectors/` folder —
/// not the marketplace proxy, which is admin-only and dies with the feed.
export function connectorIconUrl(name, size = 'sm') {
return `/api/mcp/catalog/${encodeURIComponent(name)}/icon?size=${size}`;
}
/// How each status reads on a chip. `tone` maps to the `connector-chip--*` accents
/// in `web/css/connectors.css`.
export const STATUS_LABEL = {
active: { text: 'active', tone: 'ok' },
pending: { text: 'needs fix', tone: 'script' },
needs_login: { text: 'needs sign-in', tone: 'script' },
enabled: { text: 'enabled', tone: 'scope' },
off: { text: 'off', tone: '' },
available: { text: 'available', tone: '' },
};
/// The one place that decides what a connector's state *is*, from whichever runtime
/// rows exist for it.
///
/// A per-user activation whose credentials failed verification is `pending`, not
/// `active`: the row exists but is deliberately held out of `all_startable`, and
/// calling that "active" would be a lie the user acts on.
///
/// `enabled` vs `active` for a global is the §7 distinction between *running* and
/// *reachable by me*: an admin can enable a connector for someone else and never
/// grant it to themselves, and their own list must not claim they have it.
export function statusOf(row) {
if (row._act) {
if (row._act.auth_state !== 'pending') return 'active';
// An OAuth connector sitting at `pending` is waiting for its interactive
// sign-in, not for a failed credential to be fixed — a different ask.
return row._act.oauth_provider ? 'needs_login' : 'pending';
}
if (row._glob) {
if (!row._glob.enabled) return 'off';
return row._glob.can_use ? 'active' : 'enabled';
}
return 'available';
}
/// Normalizes a catalog entry's `config_schema_json` into form-field descriptors,
/// whether the feed shipped the object-array form or the legacy bare-name list.
export function normalizeSchema(raw) {
if (!Array.isArray(raw)) return [];
return raw.map(e => {
if (typeof e === 'string') {
return { name: e, label: e, description: '', required: false, secret: false, example: '', default: '' };
}
return {
name: e.name || '',
label: e.label || e.name || '',
description: e.description || '',
required: !!e.required,
secret: !!e.secret,
example: e.example || '',
default: e.default || '',
};
});
}
export function parseJson(s, fallback) {
if (!s) return fallback;
try { return JSON.parse(s); } catch { return fallback; }
}
/// Every schema field seeded with its `default` (empty string when none).
export function seedEnv(schema) {
return Object.fromEntries(schema.map(e => [e.name, e.default || '']));
}
export 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;
}
/// Tells the Connectors list its cached state is stale.
export function announceChange() {
window.dispatchEvent(new CustomEvent('connectors-changed'));
}
+3 -2
View File
@@ -119,7 +119,8 @@ export class AppSidebar extends LightElement {
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`). // Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
const match = hash.match(/^([^/?]+)/); const match = hash.match(/^([^/?]+)/);
const segment = match ? match[1] : ''; const segment = match ? match[1] : '';
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; // `connector` (singular) is the per-connector detail page, `connectors` the list.
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
} }
_tasksSectionFromHash() { _tasksSectionFromHash() {
@@ -299,7 +300,7 @@ export class AppSidebar extends LightElement {
<i class="bi bi-tags"></i> <i class="bi bi-tags"></i>
<span class="sidebar-link-name">Roles</span> <span class="sidebar-link-name">Roles</span>
</a> </a>
<a href="#" class="sidebar-link ${this._activePage === 'connectors' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'connectors' || this._activePage === 'connector' ? 'active' : ''}"
@click=${(e) => this._togglePage('connectors', e)}> @click=${(e) => this._togglePage('connectors', e)}>
<i class="bi bi-plug"></i> <i class="bi bi-plug"></i>
<span class="sidebar-link-name">Connectors</span> <span class="sidebar-link-name">Connectors</span>
+1
View File
@@ -74,6 +74,7 @@ file-viewer-page {
users-page, users-page,
roles-page, roles-page,
connectors-page, connectors-page,
connector-detail-page,
marketplace-page, marketplace-page,
catalog-page, catalog-page,
profile-page { profile-page {
+1
View File
@@ -94,6 +94,7 @@
<users-page></users-page> <users-page></users-page>
<roles-page></roles-page> <roles-page></roles-page>
<connectors-page></connectors-page> <connectors-page></connectors-page>
<connector-detail-page></connector-detail-page>
<marketplace-page></marketplace-page> <marketplace-page></marketplace-page>
<catalog-page></catalog-page> <catalog-page></catalog-page>
<profile-page style="display:none"></profile-page> <profile-page style="display:none"></profile-page>