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:
+6
-3
@@ -2,14 +2,13 @@
|
||||
# Copy of default.config.yaml with real API keys — never commit
|
||||
/config.yml
|
||||
/config/
|
||||
# OAuth tokens, credentials, WhatsApp session data
|
||||
/secrets/
|
||||
!/secrets/.gitkeep
|
||||
config.yml.bak
|
||||
blueprint/
|
||||
/.understand-anything
|
||||
# ── Database & runtime data ───────────────────────────────────────────────────
|
||||
/database/
|
||||
# Per-user container home dirs ({WD}/homes/{userid}) — instance data, not source
|
||||
/homes/
|
||||
# SQLite WAL-mode sidecar files (journal_mode=WAL)
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
@@ -18,6 +17,9 @@ blueprint/
|
||||
/logs/
|
||||
/tmp/
|
||||
/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 ──────────────────────────────────────────────────────
|
||||
/target/
|
||||
@@ -58,6 +60,7 @@ scripts/.gitignore
|
||||
*.swo
|
||||
run-log.sh
|
||||
/backup.sh
|
||||
/reset.sh
|
||||
debug/
|
||||
|
||||
# Honcho Docker secrets
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `role_capabilities`, `shared_folders` + `shared_folder_members`. The MCP four back the Connectors model (§7/§14/§15 — see its own section). The last two (accessor `db/shared_folders.rs`) are the **membership** of the on-disk shared folders (§6): a junction table so a member can be read-only (`can_write`) and so the container mount topology + the `shared/{X}` fs routing both query it. FK `shared_folder_members.user_id → users(id)` is registry→registry (same file), which is allowed — unlike an owner→registry key.
|
||||
- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `projects`, `project_tickets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT. Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below.
|
||||
- **`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; 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).
|
||||
|
||||
@@ -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`.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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 |
|
||||
| `approval-rules.js` | `<approval-rules-page>` | Approval rule 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 |
|
||||
| `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 |
|
||||
|
||||
@@ -338,12 +338,15 @@ impl ApprovalManager {
|
||||
/// 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.
|
||||
/// - `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`,
|
||||
/// 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<()> {
|
||||
// (tool_pattern, path_pattern, action, note). `path_pattern = None` is a
|
||||
// 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_write", Some("shared-memory/*"), "require", "require write shared-memory/"),
|
||||
("@fs_any", Some("data/*"), "allow", "auto-allow data/"),
|
||||
("@fs_any", Some("secrets/*"), "deny", "deny secrets/ access"),
|
||||
("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
|
||||
/// gating now lives in the File System panel + the `*` catch-all;
|
||||
/// - 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
|
||||
/// 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/`.
|
||||
@@ -430,10 +434,13 @@ impl ApprovalManager {
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
let n3 = sqlx::query("DELETE FROM approval_rules WHERE note = 'deny reading secrets/'")
|
||||
.execute(self.db.as_ref())
|
||||
.await?
|
||||
.rows_affected();
|
||||
let n3 = sqlx::query(
|
||||
"DELETE FROM approval_rules
|
||||
WHERE note IN ('deny reading secrets/', 'deny secrets/ access')",
|
||||
)
|
||||
.execute(self.db.as_ref())
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
let n4 = sqlx::query("DELETE FROM approval_rules WHERE note = 'auto-allow memory/'")
|
||||
.execute(self.db.as_ref())
|
||||
@@ -1154,14 +1161,15 @@ mod tests {
|
||||
// Legacy per-tool fs rows are migrated away…
|
||||
let legacy: i64 = sqlx::query_scalar(
|
||||
"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())
|
||||
.await
|
||||
.unwrap();
|
||||
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).
|
||||
let fs_rows: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'",
|
||||
@@ -1169,7 +1177,7 @@ mod tests {
|
||||
.fetch_one(db.as_ref())
|
||||
.await
|
||||
.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.
|
||||
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));
|
||||
// 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));
|
||||
// Improvement over legacy: secrets *writes* are now denied too, not just reads.
|
||||
assert!(matches!(decide(&mgr, "write_file", "secrets/key").await, GateResult::Deny));
|
||||
assert!(matches!(decide(&mgr, "read_file", "secrets/key").await, GateResult::Deny));
|
||||
// The on-disk secrets store is gone, and with it its blanket deny: `secrets/`
|
||||
// is now an ordinary path in the caller's own home, gated by the catch-all
|
||||
// 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.
|
||||
assert!(matches!(decide(&mgr, "write_file", "src/main.rs").await, GateResult::Require));
|
||||
// Non-filesystem tool: unaffected by @fs_* rules, gated by catch-all.
|
||||
|
||||
@@ -26,14 +26,32 @@ pub struct McpCatalogRow {
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: 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>,
|
||||
/// 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>,
|
||||
/// '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,
|
||||
/// 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).
|
||||
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 description: Option<String>,
|
||||
pub created_at: String,
|
||||
@@ -65,11 +83,21 @@ impl McpCatalogRow {
|
||||
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 =
|
||||
"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 \
|
||||
FROM mcp_catalog";
|
||||
|
||||
@@ -122,7 +150,14 @@ pub struct UpsertCatalog<'a> {
|
||||
pub script_path: Option<&'a str>,
|
||||
pub config_schema_json: Option<String>,
|
||||
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 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 description: Option<&'a str>,
|
||||
}
|
||||
@@ -131,8 +166,10 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"INSERT INTO mcp_catalog
|
||||
(name, scope, source, transport, command, args_json, env_json, url,
|
||||
script_path, config_schema_json, auth_kind, role_filter, friendly_name, description)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||
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)
|
||||
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
|
||||
scope = excluded.scope,
|
||||
source = excluded.source,
|
||||
@@ -144,7 +181,18 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
script_path = excluded.script_path,
|
||||
config_schema_json = excluded.config_schema_json,
|
||||
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,
|
||||
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,
|
||||
description = excluded.description
|
||||
RETURNING id",
|
||||
@@ -160,7 +208,14 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
.bind(e.script_path)
|
||||
.bind(e.config_schema_json)
|
||||
.bind(e.auth_kind)
|
||||
.bind(e.oauth_provider)
|
||||
.bind(e.oauth_scopes_json)
|
||||
.bind(e.deliver_json)
|
||||
.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.description)
|
||||
.fetch_one(pool)
|
||||
|
||||
@@ -14,18 +14,22 @@ use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct McpGlobalServerRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub catalog_name: Option<String>,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub catalog_name: Option<String>,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
/// 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 description: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl McpGlobalServerRow {
|
||||
@@ -44,7 +48,7 @@ impl McpGlobalServerRow {
|
||||
|
||||
const SELECT: &str =
|
||||
"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";
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
@@ -82,34 +86,39 @@ pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Option<McpGlob
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct UpsertGlobal<'a> {
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub verify_command: Option<&'a str>,
|
||||
pub verify_script_path: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"INSERT INTO mcp_global_servers
|
||||
(name, catalog_name, transport, command, args_json, env_json, url, api_key, friendly_name, description, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1)
|
||||
(name, catalog_name, transport, command, args_json, env_json, url, api_key,
|
||||
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
|
||||
catalog_name = excluded.catalog_name,
|
||||
transport = excluded.transport,
|
||||
command = excluded.command,
|
||||
args_json = excluded.args_json,
|
||||
env_json = excluded.env_json,
|
||||
url = excluded.url,
|
||||
api_key = excluded.api_key,
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description,
|
||||
enabled = 1
|
||||
catalog_name = excluded.catalog_name,
|
||||
transport = excluded.transport,
|
||||
command = excluded.command,
|
||||
args_json = excluded.args_json,
|
||||
env_json = excluded.env_json,
|
||||
url = excluded.url,
|
||||
api_key = excluded.api_key,
|
||||
verify_command = excluded.verify_command,
|
||||
verify_script_path = excluded.verify_script_path,
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description,
|
||||
enabled = 1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(p.name)
|
||||
@@ -120,6 +129,8 @@ pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
|
||||
.bind(p.env_json)
|
||||
.bind(p.url)
|
||||
.bind(p.api_key)
|
||||
.bind(p.verify_command)
|
||||
.bind(p.verify_script_path)
|
||||
.bind(p.friendly_name)
|
||||
.bind(p.description)
|
||||
.fetch_one(pool)
|
||||
|
||||
@@ -15,24 +15,34 @@ use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct McpUserServerRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Bare snapshot of the originating `mcp_catalog.name`; NULL for a
|
||||
/// self-registered remote.
|
||||
pub catalog_name: Option<String>,
|
||||
pub catalog_name: Option<String>,
|
||||
/// 'remote' | 'local_script'.
|
||||
pub source: String,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub source: String,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: 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>,
|
||||
/// 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`.
|
||||
pub script_rel_path: Option<String>,
|
||||
/// 'pending' | 'ready' — the interactive-auth gate ('ready' while api-key).
|
||||
pub auth_state: String,
|
||||
pub enabled: bool,
|
||||
pub script_rel_path: Option<String>,
|
||||
/// 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 enabled: bool,
|
||||
}
|
||||
|
||||
impl McpUserServerRow {
|
||||
@@ -47,11 +57,19 @@ impl McpUserServerRow {
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.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 =
|
||||
"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";
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
@@ -93,24 +111,30 @@ pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Option<McpUser
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct InsertUserServer<'a> {
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub script_rel_path: Option<&'a str>,
|
||||
pub auth_state: &'a str,
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub oauth_provider: Option<&'a str>,
|
||||
pub deliver_json: Option<String>,
|
||||
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 async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO mcp_user_servers
|
||||
(name, catalog_name, source, transport, command, args_json, env_json, url, api_key, script_rel_path, auth_state, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 1)",
|
||||
(name, catalog_name, source, transport, command, args_json, env_json, url, api_key,
|
||||
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.catalog_name)
|
||||
@@ -121,7 +145,11 @@ pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
||||
.bind(s.env_json)
|
||||
.bind(s.url)
|
||||
.bind(s.api_key)
|
||||
.bind(s.oauth_provider)
|
||||
.bind(s.deliver_json)
|
||||
.bind(s.script_rel_path)
|
||||
.bind(s.verify_command)
|
||||
.bind(s.verify_script_rel_path)
|
||||
.bind(s.auth_state)
|
||||
.execute(pool)
|
||||
.await?
|
||||
@@ -129,6 +157,17 @@ pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
||||
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<()> {
|
||||
sqlx::query("UPDATE mcp_user_servers SET enabled = ?1 WHERE id = ?2")
|
||||
.bind(enabled as i64)
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod mcp_global_access;
|
||||
pub mod mcp_global_servers;
|
||||
pub mod mcp_user_servers;
|
||||
pub mod memory_docs;
|
||||
pub mod oauth_providers;
|
||||
pub mod plugins;
|
||||
pub mod role_capabilities;
|
||||
pub mod roles;
|
||||
@@ -155,6 +156,21 @@ pub async fn open_user_pool(path: &Path, key: Option<&Dek>) -> Result<SqlitePool
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// 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,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
script_path TEXT, -- local_script: source under ./scripts
|
||||
config_schema_json TEXT, -- names of env/secret keys the UI must collect
|
||||
script_path TEXT, -- local_script: entry file, as <connector>/<file> under ./connectors
|
||||
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'
|
||||
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
|
||||
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,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
@@ -453,6 +476,10 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
)
|
||||
.execute(pool)
|
||||
.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.).
|
||||
// They run on the HOST. The global secret (admin's API key) is fine here:
|
||||
@@ -460,19 +487,21 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
// FK (both in this file) — allowed.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_global_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT REFERENCES mcp_catalog(name),
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT,
|
||||
friendly_name TEXT,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT REFERENCES mcp_catalog(name),
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT,
|
||||
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,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
@@ -503,6 +532,32 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -742,24 +797,31 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
// copied into the bind-mounted home (`script_rel_path`).
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_user_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT, -- bare ref to mcp_catalog.name; NULL = self-registered remote
|
||||
source TEXT NOT NULL, -- 'remote' | 'local_script'
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT, -- per-user secret / OAuth refresh token
|
||||
script_rel_path TEXT, -- container path for a local_script
|
||||
auth_state TEXT NOT NULL DEFAULT 'ready', -- 'pending' | 'ready'
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT, -- bare ref to mcp_catalog.name; NULL = self-registered remote
|
||||
source TEXT NOT NULL, -- 'remote' | 'local_script'
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT, -- per-user secret / OAuth refresh token
|
||||
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
|
||||
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'
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.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(
|
||||
"CREATE TABLE IF NOT EXISTS sources (
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,16 @@ pub use mcp_client::{
|
||||
|
||||
use mcp_client::McpTransport;
|
||||
|
||||
pub mod install;
|
||||
mod logs;
|
||||
pub mod oauth;
|
||||
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 verify::{VerifyReport, VerifyTarget, apply_placeholders, run_verify};
|
||||
|
||||
const SERVER_START_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
@@ -409,10 +415,35 @@ fn apply_key_placeholder(
|
||||
) -> (Option<String>, Option<String>) {
|
||||
match (url, api_key) {
|
||||
(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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`
|
||||
/// = 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 {
|
||||
@@ -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
|
||||
/// (mirrors `ImageGeneratorManager`).
|
||||
fn random_id() -> String {
|
||||
@@ -510,3 +586,50 @@ pub fn content_type_for_ext(ext: &str) -> &'static str {
|
||||
_ => "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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms)
|
||||
.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/", ¶ms)
|
||||
.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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -200,14 +200,18 @@ impl UserContextFactory {
|
||||
{
|
||||
let um = Arc::clone(&user_mcp);
|
||||
let upool = Arc::clone(&pool);
|
||||
let registry = Arc::clone(&self.registry_pool);
|
||||
let container = crate::container::container_name(user_id);
|
||||
let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str());
|
||||
self.supervisor.adopt_one(mname, tokio::spawn(async move {
|
||||
match crate::db::mcp_user_servers::all_startable(&upool).await {
|
||||
Ok(rows) => {
|
||||
let specs = rows.iter()
|
||||
.map(|r| crate::mcp::user_row_spec(r, &container))
|
||||
.collect();
|
||||
let mut specs = Vec::with_capacity(rows.len());
|
||||
for r in &rows {
|
||||
// OAuth connectors resolve their stored refresh token into
|
||||
// the env-delivered credential here (§15).
|
||||
specs.push(crate::mcp::user_row_spec_resolved(r, &container, ®istry).await);
|
||||
}
|
||||
um.connect_all(specs, false).await;
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "per-user MCP init: failed to read mcp_user_servers"),
|
||||
|
||||
@@ -173,9 +173,8 @@ impl Tool for GrepFiles {
|
||||
}
|
||||
}
|
||||
|
||||
// `secrets` is skipped so a recursive grep rooted at a parent (e.g. the auto-read
|
||||
// working directory) never descends into and leaks secret values.
|
||||
const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", ".venv", "__pycache__", "secrets"];
|
||||
// Noise, not policy: build output and vendored trees a grep is never looking for.
|
||||
const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", ".venv", "__pycache__"];
|
||||
const MAX_FILE_BYTES: u64 = 200_000;
|
||||
const MAX_OUTPUT_BYTES: usize = 60_000;
|
||||
const MAX_LINE_BYTES: usize = 500;
|
||||
|
||||
@@ -11,10 +11,8 @@ use crate::tools::{
|
||||
};
|
||||
use super::{classify_memory, resolve, MemScope};
|
||||
|
||||
/// Directories to skip unconditionally when walking.
|
||||
/// `secrets` is skipped so a recursive listing rooted at a parent (e.g. the auto-read
|
||||
/// working directory) never reveals the contents of the secrets store.
|
||||
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "secrets"];
|
||||
/// Directories to skip unconditionally when walking — noise, not policy.
|
||||
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache"];
|
||||
|
||||
pub struct ListFiles {
|
||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||
|
||||
@@ -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
|
||||
/// 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`).
|
||||
pub fn canonicalize_for_policy(path: &str, base: &Path) -> PathBuf {
|
||||
let raw = {
|
||||
|
||||
+274
-33
@@ -129,6 +129,14 @@ struct AuthSpec {
|
||||
#[serde(default, rename = "type")] kind: Option<String>,
|
||||
#[serde(default)] delivery: Option<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)]
|
||||
@@ -150,6 +158,27 @@ struct McpConfigManifest {
|
||||
#[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)]
|
||||
struct Manifest {
|
||||
#[serde(default)] name: Option<String>,
|
||||
@@ -167,12 +196,19 @@ struct Manifest {
|
||||
#[serde(default)] files: Vec<FileEntry>,
|
||||
#[serde(default)] scope: Option<String>,
|
||||
#[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)]
|
||||
struct Hydrated {
|
||||
entry: IndexEntry,
|
||||
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 ────────────────────────────────────────
|
||||
@@ -373,12 +409,17 @@ async fn fetch_feed() -> Result<Vec<Hydrated>, ApiError> {
|
||||
set.spawn(async move {
|
||||
let url = format!("{}/{}/connector.json", base, folder_of(&entry));
|
||||
// A manifest that fails to load degrades that one card to whatever the
|
||||
// index said; it never fails the whole listing.
|
||||
let manifest = match HTTP.get(&url).send().await {
|
||||
Ok(r) => r.json::<Manifest>().await.unwrap_or_default(),
|
||||
Err(_) => Manifest::default(),
|
||||
// index said; it never fails the whole listing. The raw text is kept
|
||||
// alongside the parsed form so `install` can record what was served
|
||||
// even if some field of it did not parse.
|
||||
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
|
||||
/// "someone else vetted this" to "this household's admin accepted it". For an
|
||||
/// `mcp_local` entry it first downloads and hash-verifies the scripts into
|
||||
/// `./scripts/<id>/`, which is code landing on the box and therefore needs
|
||||
/// `mcp.register_local_script` (§14) on top of `mcp.manage_catalog`.
|
||||
/// "someone else vetted this" to "this household's admin accepted it". It downloads
|
||||
/// and hash-verifies the connector's folder into `./connectors/<id>/`; for an
|
||||
/// `mcp_local` entry that folder holds code which will run on this box, and
|
||||
/// 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
|
||||
/// 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
|
||||
// trace of a half-installed connector.
|
||||
let (script_path, verified) = if source == "local_script" {
|
||||
let files = download_verified(&h.entry, &h.manifest).await?;
|
||||
// trace of a half-installed connector. A `local_script` always downloads its
|
||||
// files; a `remote` connector downloads them only if it declares a `verify`
|
||||
// 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
|
||||
.args
|
||||
.first()
|
||||
@@ -590,9 +647,9 @@ pub async fn install(
|
||||
"manifest has no mcp_config.args[0] naming the script to run",
|
||||
))?;
|
||||
let entry_file = safe_rel_path(&entry_file)?.to_string();
|
||||
(Some(format!("{}/{}", body.id, entry_file)), files)
|
||||
Some(format!("{}/{}", body.id, entry_file))
|
||||
} else {
|
||||
(None, 0)
|
||||
None
|
||||
};
|
||||
|
||||
let args_json = if source == "local_script" {
|
||||
@@ -605,9 +662,23 @@ pub async fn install(
|
||||
serde_json::to_string(&cfg.args).ok()
|
||||
};
|
||||
|
||||
// `requires` is the feed's coarse precondition list; the activation UI needs
|
||||
// the concrete env keys, which only the manifest's mcp_config knows.
|
||||
let config_schema: Vec<String> = cfg.env.keys().cloned().collect();
|
||||
// The full env/secret schema for the activation form. The manifest's top-level
|
||||
// `env[]` (array of objects with label/description/required/secret/…) is the
|
||||
// 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(
|
||||
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() },
|
||||
url: cfg.url.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),
|
||||
// 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,
|
||||
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()),
|
||||
// The LLM-facing blurb — this is the column `render_mcp_list` puts in
|
||||
// the prompt for `activate_tools()`, so the feed's
|
||||
@@ -641,17 +726,50 @@ pub async fn install(
|
||||
"name": h.entry.id,
|
||||
"scope": scope,
|
||||
"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
|
||||
/// only written once every digest checks out, so a tampered feed never leaves a
|
||||
/// partial connector on disk. Returns how many files were verified.
|
||||
async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<usize, ApiError> {
|
||||
/// partial connector on disk.
|
||||
///
|
||||
/// 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);
|
||||
if files.is_empty() {
|
||||
if files.is_empty() && source == "local_script" {
|
||||
return Err(ApiError::bad_request(
|
||||
"the feed declares no `files` with digests for this connector — \
|
||||
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 folder = folder_of(entry);
|
||||
let index_digests = !entry.files.is_empty();
|
||||
let mut staged: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
|
||||
for f in files {
|
||||
let rel = safe_rel_path(&f.path)?;
|
||||
|
||||
// Defensive: a document can never carry its own digest (writing the hash
|
||||
// changes the file), so a self-entry is unverifiable by construction. The
|
||||
// feed now keeps digests in the index, where this cannot arise.
|
||||
if rel == "connector.json" {
|
||||
// A document can never carry its own digest (writing the hash into the file
|
||||
// changes the file), so a *manifest*-declared self-entry is unverifiable by
|
||||
// construction. From the index it is verifiable, and gets no exception.
|
||||
if rel == skald_core::mcp::MANIFEST_FILE && !index_digests {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -703,19 +822,19 @@ async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<us
|
||||
staged.push((rel.to_string(), bytes.to_vec()));
|
||||
}
|
||||
|
||||
if staged.is_empty() {
|
||||
if staged.is_empty() && source == "local_script" {
|
||||
return Err(ApiError::bad_request(
|
||||
"manifest declares no installable file besides connector.json",
|
||||
));
|
||||
}
|
||||
|
||||
let wd = std::env::current_dir()
|
||||
.map_err(|e| ApiError::bad_request(format!("cannot resolve working directory: {e}")))?;
|
||||
let dest = wd.join("scripts").join(&entry.id);
|
||||
let dest = skald_core::mcp::connector_dir(&entry.id)
|
||||
.map_err(|e| ApiError::bad_request(format!("cannot resolve the connectors dir: {e}")))?;
|
||||
std::fs::create_dir_all(&dest)
|
||||
.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 {
|
||||
let path = dest.join(&rel);
|
||||
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)
|
||||
.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)]
|
||||
@@ -837,6 +984,35 @@ mod tests {
|
||||
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]
|
||||
fn sha256_matches_a_known_vector() {
|
||||
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
|
||||
/// `cargo test --bin skald -- --ignored live_feed`.
|
||||
#[tokio::test]
|
||||
|
||||
+647
-62
@@ -17,7 +17,7 @@ use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use skald_core::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, role_capabilities};
|
||||
use skald_core::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities};
|
||||
use skald_core::skald::Skald;
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// Copies a vetted catalog script from `./scripts/<script_path>` into the user's
|
||||
/// bind-mounted home under `.skald/mcp/<name>/`, and returns the path it will have
|
||||
/// INSIDE the container (`/root/.skald/mcp/...`). The home is the only durable
|
||||
/// zone (§6), so the script survives a container recreate. Single files only for
|
||||
/// now — directory-tree scripts (e.g. whatsapp_mcp/) are a follow-up.
|
||||
fn copy_script_into_home(user_id: &str, name: &str, script_path: &str) -> Result<String, ApiError> {
|
||||
let wd = std::env::current_dir()
|
||||
.map_err(|e| ApiError::bad_request(format!("cannot resolve working directory: {e}")))?;
|
||||
let src = wd.join("scripts").join(script_path);
|
||||
if !src.is_file() {
|
||||
/// Installs the connector folder that `script_path` (`<connector>/<file>`) belongs
|
||||
/// to into the caller's container home, and returns the path the entry file will
|
||||
/// have INSIDE the container.
|
||||
///
|
||||
/// The whole folder travels, not just the entry file — which is what finally gets a
|
||||
/// connector's `requirements.txt` and its multi-file trees to where the server
|
||||
/// actually runs. The home is the only durable zone (§6), so it survives a
|
||||
/// container recreate.
|
||||
fn install_connector_for_user(
|
||||
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!(
|
||||
"catalog script `scripts/{script_path}` not found or not a file \
|
||||
(directory-tree scripts are not supported yet)"
|
||||
"connector directory `{}/{catalog_name}` not found — reinstall the connector",
|
||||
skald_core::mcp::CONNECTORS_DIR,
|
||||
)));
|
||||
}
|
||||
let basename = src.file_name()
|
||||
.ok_or_else(|| ApiError::bad_request("invalid script_path"))?
|
||||
.to_string_lossy().to_string();
|
||||
let dest_dir = wd.join(skald_core::container::HOMES_DIR)
|
||||
.join(user_id).join(".skald").join("mcp").join(name);
|
||||
std::fs::create_dir_all(&dest_dir)
|
||||
.map_err(|e| ApiError::bad_request(format!("failed to create script dir: {e}")))?;
|
||||
std::fs::copy(&src, dest_dir.join(&basename))
|
||||
.map_err(|e| ApiError::bad_request(format!("failed to copy script: {e}")))?;
|
||||
Ok(format!("/root/.skald/mcp/{name}/{basename}"))
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Default timeout for a connector-declared verify step. (The manifest can also
|
||||
/// carry `verify.timeout_secs`; wiring that through is a follow-up.)
|
||||
const VERIFY_TIMEOUT_SECS: u64 = 20;
|
||||
|
||||
// ── connector icons ───────────────────────────────────────────────────────────
|
||||
|
||||
#[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 ────────────────────────────────────
|
||||
@@ -100,6 +286,8 @@ pub struct CatalogUpsertBody {
|
||||
#[serde(default = "default_none_auth")]
|
||||
pub auth_kind: String,
|
||||
pub role_filter: Option<Vec<String>>,
|
||||
pub verify_command: Option<String>,
|
||||
pub verify_script_path: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
@@ -130,7 +318,18 @@ pub async fn catalog_upsert(
|
||||
script_path: body.script_path.as_deref(),
|
||||
config_schema_json: to_json_opt(&body.config_schema),
|
||||
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),
|
||||
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(),
|
||||
description: body.description.as_deref(),
|
||||
}).await?;
|
||||
@@ -147,6 +346,71 @@ pub async fn catalog_delete(
|
||||
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 ────────────────────────────────
|
||||
|
||||
pub async fn global_list(
|
||||
@@ -181,25 +445,37 @@ pub async fn global_enable(
|
||||
let name = body.name.clone().unwrap_or_else(|| entry.name.clone());
|
||||
// Snapshot the concrete config from the catalog; the admin supplies the secret.
|
||||
let id = mcp_global_servers::upsert(skald.db(), mcp_global_servers::UpsertGlobal {
|
||||
name: &name,
|
||||
catalog_name: Some(&entry.name),
|
||||
transport: &entry.transport,
|
||||
command: entry.command.as_deref(),
|
||||
args_json: entry.args_json.clone(),
|
||||
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()).or_else(|| entry.env_json.clone()),
|
||||
url: entry.url.as_deref(),
|
||||
api_key: body.api_key.as_deref(),
|
||||
friendly_name: entry.friendly_name.as_deref(),
|
||||
description: entry.description.as_deref(),
|
||||
name: &name,
|
||||
catalog_name: Some(&entry.name),
|
||||
transport: &entry.transport,
|
||||
command: entry.command.as_deref(),
|
||||
args_json: entry.args_json.clone(),
|
||||
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()).or_else(|| entry.env_json.clone()),
|
||||
url: entry.url.as_deref(),
|
||||
api_key: body.api_key.as_deref(),
|
||||
verify_command: entry.verify_command.as_deref(),
|
||||
verify_script_path: entry.verify_script_path.as_deref(),
|
||||
friendly_name: entry.friendly_name.as_deref(),
|
||||
description: entry.description.as_deref(),
|
||||
}).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).
|
||||
let row = mcp_global_servers::get(skald.db(), id).await?
|
||||
.ok_or_else(|| ApiError::bad_request("global server vanished after upsert"))?;
|
||||
let spec = skald_core::mcp::global_row_spec(&row);
|
||||
match skald.mcp().start_server(spec).await {
|
||||
Ok(tools) => Ok(Json(json!({ "id": id, "tools": tools }))),
|
||||
Err(e) => Ok(Json(json!({ "id": id, "error": e.to_string() }))),
|
||||
Ok(tools) => Ok(Json(json!({ "id": id, "tools": tools, "verify": verify }))),
|
||||
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 })))
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct GlobalAccessBody {
|
||||
/// The full set of user ids allowed to use this global connector.
|
||||
@@ -369,29 +737,79 @@ pub async fn activate(
|
||||
let name = body.name.clone().unwrap_or_else(|| entry.name.clone());
|
||||
reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?;
|
||||
|
||||
// For a local script, copy it into the container home and point the
|
||||
// command at the in-container path.
|
||||
// For a local script, install its folder into the container home and
|
||||
// point the command at the in-container path.
|
||||
let (command, args_json, script_rel_path) = if entry.source == "local_script" {
|
||||
let script = entry.script_path.clone()
|
||||
.ok_or_else(|| ApiError::bad_request("catalog local_script entry has no script_path"))?;
|
||||
let container_path = copy_script_into_home(&auth.user_id, &name, &script)?;
|
||||
let container_path = install_connector_for_user(&auth.user_id, &name, &script)?;
|
||||
(entry.command.clone(), Some(json!([container_path]).to_string()), Some(container_path))
|
||||
} else {
|
||||
(entry.command.clone(), entry.args_json.clone(), None)
|
||||
};
|
||||
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 {
|
||||
name: &name,
|
||||
catalog_name: Some(&entry.name),
|
||||
source: &entry.source,
|
||||
transport: &entry.transport,
|
||||
command: command.as_deref(),
|
||||
name: &name,
|
||||
catalog_name: Some(&entry.name),
|
||||
source: &entry.source,
|
||||
transport: &entry.transport,
|
||||
command: command.as_deref(),
|
||||
args_json,
|
||||
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()).or_else(|| entry.env_json.clone()),
|
||||
url: entry.url.as_deref(),
|
||||
api_key: body.api_key.as_deref(),
|
||||
script_rel_path: script_rel_path.as_deref(),
|
||||
auth_state: "ready",
|
||||
env_json,
|
||||
url: entry.url.as_deref(),
|
||||
api_key: body.api_key.as_deref(),
|
||||
oauth_provider: None,
|
||||
deliver_json: None,
|
||||
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",
|
||||
}).await?
|
||||
}
|
||||
None => {
|
||||
@@ -403,17 +821,21 @@ pub async fn activate(
|
||||
.ok_or_else(|| ApiError::bad_request("a self-registered remote needs a `url`"))?;
|
||||
reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?;
|
||||
mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
|
||||
name: &name,
|
||||
catalog_name: None,
|
||||
source: "remote",
|
||||
transport: &body.transport,
|
||||
command: None,
|
||||
args_json: None,
|
||||
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()),
|
||||
url: Some(&url),
|
||||
api_key: body.api_key.as_deref(),
|
||||
script_rel_path: None,
|
||||
auth_state: "ready",
|
||||
name: &name,
|
||||
catalog_name: None,
|
||||
source: "remote",
|
||||
transport: &body.transport,
|
||||
command: None,
|
||||
args_json: None,
|
||||
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()),
|
||||
url: Some(&url),
|
||||
api_key: body.api_key.as_deref(),
|
||||
oauth_provider: None,
|
||||
deliver_json: None,
|
||||
script_rel_path: None,
|
||||
verify_command: None,
|
||||
verify_script_rel_path: None,
|
||||
auth_state: "ready",
|
||||
}).await?
|
||||
}
|
||||
};
|
||||
@@ -421,11 +843,44 @@ pub async fn activate(
|
||||
// Start it now in this user's runtime (container transport for stdio).
|
||||
let row = mcp_user_servers::get(&ctx.pool, insert).await?
|
||||
.ok_or_else(|| ApiError::bad_request("user server vanished after insert"))?;
|
||||
|
||||
// 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 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 {
|
||||
Ok(tools) => Ok(Json(json!({ "id": insert, "tools": tools }))),
|
||||
Err(e) => Ok(Json(json!({ "id": insert, "error": e.to_string() }))),
|
||||
Ok(tools) => Ok(Json(json!({
|
||||
"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?;
|
||||
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" }))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,14 +139,24 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
// admin: catalog + globally-active connectors
|
||||
.route("/mcp/catalog", get(mcp::catalog_list).post(mcp::catalog_upsert))
|
||||
.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/{id}", delete(mcp::global_delete))
|
||||
.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
|
||||
.route("/mcp/available", get(mcp::available))
|
||||
.route("/mcp/activate", post(mcp::activate))
|
||||
.route("/mcp/test", post(mcp::test))
|
||||
.route("/mcp/activated", get(mcp::activated_list))
|
||||
.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
|
||||
.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))
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AgentsPage } from './components/agents.js';
|
||||
import { UsersPage } from './components/users-page.js';
|
||||
import { RolesPage } from './components/roles-page.js';
|
||||
import { ConnectorsPage } from './components/connectors.js';
|
||||
import { ConnectorDetailPage } from './components/connector-detail.js';
|
||||
import { MarketplacePage } from './components/marketplace.js';
|
||||
import { CatalogPage } from './components/catalog.js';
|
||||
import { ProfilePage } from './components/profile-page.js';
|
||||
@@ -46,6 +47,7 @@ customElements.define('agents-page', AgentsPage);
|
||||
customElements.define('users-page', UsersPage);
|
||||
customElements.define('roles-page', RolesPage);
|
||||
customElements.define('connectors-page', ConnectorsPage);
|
||||
customElements.define('connector-detail-page', ConnectorDetailPage);
|
||||
customElements.define('marketplace-page', MarketplacePage);
|
||||
customElements.define('catalog-page', CatalogPage);
|
||||
customElements.define('profile-page', ProfilePage);
|
||||
|
||||
@@ -301,7 +301,7 @@ export class CatalogPage extends LightElement {
|
||||
${this._select('Transport', f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
|
||||
${isScript
|
||||
? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: '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('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 })}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
+300
-320
@@ -1,28 +1,26 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { connectorIconUrl, statusOf, STATUS_LABEL } from './shared/connector-common.js';
|
||||
|
||||
// Connectors (MCP) — blueprint §7/§14/§15.
|
||||
//
|
||||
// One question: **what is running, and what can I add?** This is the runtime view —
|
||||
// literally `UserMcpView` (global ∪ per-user) plus the actions that create those
|
||||
// instances. What this box *offers* is a different question, answered by the
|
||||
// Connector Catalog page.
|
||||
// **One row per connector**, not one per runtime instance. A catalog entry is a
|
||||
// template with two runtimes (§7), and a person thinks in terms of "do I have
|
||||
// Gmail?" — not "how many `mcp_user_servers` rows named gmail-ish do I own?". So the
|
||||
// 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
|
||||
// template with two runtimes (§7), so "Available" is one list with the verb that fits
|
||||
// each row: a `per_user` entry says Activate (anyone), a `global` entry says Enable
|
||||
// globally (admin only). Enabling a global is the admin's counterpart to activating a
|
||||
// per-user one — which is why they live side by side instead of in an admin dungeon.
|
||||
// The card is a link, not a form. Everything that needs typing lives on the
|
||||
// connector's own page (`#connector?name=X`) — an activation form has as many
|
||||
// fields as the connector declares (EMAIL has a dozen), which a fixed-size dialog
|
||||
// could never hold.
|
||||
//
|
||||
// Reuses the shared `um-*` / bootstrap styling (no page-specific CSS).
|
||||
// Reuses the marketplace's card styling (`web/css/connectors.css`).
|
||||
|
||||
const ADMIN_ID = 'admin';
|
||||
|
||||
function parseJson(s, fallback) {
|
||||
if (!s) return fallback;
|
||||
try { return JSON.parse(s); } catch { return fallback; }
|
||||
}
|
||||
|
||||
async function jf(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
|
||||
@@ -38,15 +36,20 @@ export class ConnectorsPage extends LightElement {
|
||||
_me: { state: true }, // { role_id }
|
||||
_available: { state: true }, // { catalog: [...], globals: [...] }
|
||||
_activated: { state: true }, // my per-user server rows
|
||||
_users: { state: true }, // admin: user summaries (for the access modal)
|
||||
_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() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._q = '';
|
||||
this._noIcon = new Set();
|
||||
this._reset();
|
||||
}
|
||||
|
||||
@@ -54,9 +57,10 @@ export class ConnectorsPage extends LightElement {
|
||||
this._me = null;
|
||||
this._available = null;
|
||||
this._activated = null;
|
||||
this._users = null;
|
||||
this._error = null;
|
||||
this._modal = null;
|
||||
this._providers = null;
|
||||
this._pForm = null;
|
||||
this._pError = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -66,6 +70,9 @@ export class ConnectorsPage extends LightElement {
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
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; }
|
||||
@@ -80,130 +87,147 @@ export class ConnectorsPage extends LightElement {
|
||||
]);
|
||||
this._available = available;
|
||||
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) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_patch(field, value) {
|
||||
this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } };
|
||||
_go(page, hash) {
|
||||
history.pushState({ page }, '', hash);
|
||||
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
_goCatalog() {
|
||||
history.pushState({ page: 'catalog' }, '', '#catalog');
|
||||
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'catalog' } }));
|
||||
_openConnector(name) {
|
||||
this._go('connector', `#connector?name=${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
// ── Activate a per-user connector ──────────────────────────────────────────
|
||||
// ── admin: OAuth sign-in providers (§15) ─────────────────────────────────────
|
||||
|
||||
_openActivate(entry) {
|
||||
const schema = parseJson(entry.config_schema_json, []) || [];
|
||||
this._modal = {
|
||||
kind: 'activate',
|
||||
entry,
|
||||
form: { name: entry.name, api_key: '', env: Object.fromEntries(schema.map(k => [k, ''])) },
|
||||
async _openProviders() {
|
||||
this._pError = null;
|
||||
this._pForm = null;
|
||||
try {
|
||||
this._providers = await jf('/api/mcp/providers');
|
||||
} 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() {
|
||||
const { entry, form } = this._modal;
|
||||
if (!form.name.trim()) { this._error = 'A name is required.'; return; }
|
||||
const env = {};
|
||||
for (const [k, v] of Object.entries(form.env || {})) if (v !== '') env[k] = v;
|
||||
_editProvider(p) {
|
||||
// The secret never came back from the server; an empty box means "keep it".
|
||||
this._pForm = { ...p, client_secret: '', extra_params: p.extra_params || '', _isNew: false };
|
||||
this._pError = null;
|
||||
}
|
||||
|
||||
_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 {
|
||||
await jf('/api/mcp/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
catalog_name: entry.name,
|
||||
name: form.name.trim(),
|
||||
api_key: form.api_key || null,
|
||||
env: Object.keys(env).length ? env : null,
|
||||
}),
|
||||
const { _isNew, has_client_secret, ...body } = f;
|
||||
await jf('/api/mcp/providers', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
this._closeModal();
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
this._pForm = null;
|
||||
this._providers = await jf('/api/mcp/providers');
|
||||
} catch (e) { this._pError = e.message; }
|
||||
}
|
||||
|
||||
async _deactivate(row) {
|
||||
if (!confirm(`Deactivate connector "${row.name}"?`)) return;
|
||||
async _deleteProvider(name) {
|
||||
if (!confirm(`Delete the “${name}” sign-in provider?\n\nConnectors that use it will no longer be able to sign in.`)) return;
|
||||
try {
|
||||
await jf(`/api/mcp/activated/${row.id}`, { method: 'DELETE' });
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
await jf(`/api/mcp/providers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
this._providers = await jf('/api/mcp/providers');
|
||||
} 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:
|
||||
// the old dropdown existed only because this action lived on a page that did not
|
||||
// show the catalog.
|
||||
_openEnableGlobal(entry) {
|
||||
this._modal = {
|
||||
kind: 'global',
|
||||
entry,
|
||||
form: { name: entry.name, api_key: '' },
|
||||
};
|
||||
}
|
||||
const rows = catalog.map(e => ({
|
||||
...e,
|
||||
_act: activated.find(r => r.catalog_name === e.name) ?? null,
|
||||
_glob: globals.find(g => (g.catalog_name ?? g.name) === e.name) ?? null,
|
||||
}));
|
||||
|
||||
async _enableGlobal() {
|
||||
const { entry, form } = this._modal;
|
||||
try {
|
||||
await jf('/api/mcp/global', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
catalog_name: entry.name,
|
||||
name: form.name.trim() || null,
|
||||
api_key: form.api_key || null,
|
||||
}),
|
||||
// A granted global whose catalog row the caller cannot see. `/api/mcp/available`
|
||||
// only returns `global` catalog entries to a catalog manager, so without this the
|
||||
// connector an ordinary user actually uses every day would be missing from their
|
||||
// own list — visible to the admin, invisible to its user.
|
||||
for (const g of globals) {
|
||||
const key = g.catalog_name ?? g.name;
|
||||
if (rows.some(r => r.name === key)) continue;
|
||||
rows.push({
|
||||
name: key,
|
||||
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; }
|
||||
}
|
||||
|
||||
const q = this._q.trim().toLowerCase();
|
||||
return rows
|
||||
.filter(r => !q
|
||||
|| r.name.toLowerCase().includes(q)
|
||||
|| (r.friendly_name ?? '').toLowerCase().includes(q)
|
||||
|| (r.description ?? '').toLowerCase().includes(q))
|
||||
.sort((a, b) => (a.friendly_name || a.name).localeCompare(b.friendly_name || b.name));
|
||||
}
|
||||
|
||||
async _deleteGlobal(row) {
|
||||
if (!confirm(`Disable global connector "${row.name}"?\n\nIt stops for everyone who can use it.`)) return;
|
||||
try {
|
||||
await jf(`/api/mcp/global/${row.id}`, { method: 'DELETE' });
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
async _openAccess(server) {
|
||||
this._modal = { kind: 'access', server, selected: new Set() };
|
||||
try {
|
||||
const current = await jf(`/api/mcp/global/${server.id}/access`);
|
||||
// Ignore if the admin already navigated away / opened another modal.
|
||||
if (this._modal?.kind === 'access' && this._modal.server.id === server.id) {
|
||||
this._modal = { ...this._modal, selected: new Set(current || []) };
|
||||
}
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_toggleAccess(userId) {
|
||||
const sel = new Set(this._modal.selected);
|
||||
sel.has(userId) ? sel.delete(userId) : sel.add(userId);
|
||||
this._modal = { ...this._modal, selected: sel };
|
||||
}
|
||||
|
||||
async _saveAccess() {
|
||||
const { server, selected } = this._modal;
|
||||
try {
|
||||
await jf(`/api/mcp/global/${server.id}/access`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_ids: [...selected] }),
|
||||
});
|
||||
this._closeModal();
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
_iconFailed(name) {
|
||||
// Re-render with the placeholder. A synthetic row (a granted global whose
|
||||
// catalog entry the caller cannot read) has no icon path to check up front, so
|
||||
// the 404 is the check.
|
||||
const next = new Set(this._noIcon);
|
||||
next.add(name);
|
||||
this._noIcon = next;
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
@@ -211,6 +235,7 @@ export class ConnectorsPage extends LightElement {
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const loading = this._available === null && !this._error;
|
||||
const rows = loading ? [] : this._rows;
|
||||
|
||||
return html`
|
||||
<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>
|
||||
<div class="um-header-right">
|
||||
${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
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._go('marketplace', '#marketplace')}>
|
||||
<i class="bi bi-bag me-1"></i>Marketplace
|
||||
</button>` : nothing}
|
||||
</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}
|
||||
|
||||
${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">
|
||||
${this._renderMine()}
|
||||
${this._renderGlobals()}
|
||||
${this._renderAvailable()}
|
||||
</div>`}
|
||||
</div>
|
||||
${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}
|
||||
${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 class="connector-filters">
|
||||
<div class="connector-search">
|
||||
<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>`}
|
||||
${this._providers !== null ? this._renderProvidersModal() : nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderMine() {
|
||||
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`
|
||||
<tr>
|
||||
<td><strong>${e.friendly_name || e.name}</strong>
|
||||
${e.description ? html`
|
||||
<div class="text-muted" style="font-size:.75rem;max-width:44ch;overflow:hidden;
|
||||
text-overflow:ellipsis;white-space:nowrap" title=${e.description}>${e.description}</div>` : nothing}</td>
|
||||
<td><span class="badge ${isGlobal ? 'bg-info' : 'bg-secondary'}" style="font-size:.65rem">
|
||||
${isGlobal ? 'global' : 'per-user'}</span></td>
|
||||
<td><span class="text-muted" style="font-size:.78rem">${e.auth_kind}</span></td>
|
||||
<td><div class="um-actions">
|
||||
${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) {
|
||||
_renderProvidersModal() {
|
||||
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 style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1050;
|
||||
display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:2rem 1rem"
|
||||
@click=${(e) => { if (e.target === e.currentTarget) this._closeProviders(); }}>
|
||||
<div class="connector-card" style="width:100%;max-width:560px;cursor:default">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<h3 class="um-title" style="font-size:1rem;margin:0"><i class="bi bi-key me-2"></i>Sign-in providers</h3>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeProviders()}>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</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 class="text-muted mb-3" style="font-size:.78rem">
|
||||
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>
|
||||
${this._pError ? html`
|
||||
<div class="alert alert-danger py-2 mb-2" style="font-size:.82rem">${this._pError}</div>` : nothing}
|
||||
${this._pForm ? this._renderProviderForm() : this._renderProviderList()}
|
||||
</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>`;
|
||||
_renderProviderList() {
|
||||
const list = this._providers ?? [];
|
||||
return html`
|
||||
${list.length === 0 ? html`
|
||||
<div class="um-empty" style="padding:1rem"><i class="bi bi-key"></i>
|
||||
<p>No sign-in providers yet.</p></div>` : html`
|
||||
<div class="d-flex flex-column gap-2 mb-3">
|
||||
${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>`}
|
||||
<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>`;
|
||||
}
|
||||
|
||||
_renderModal() {
|
||||
if (!this._modal) return nothing;
|
||||
const m = this._modal;
|
||||
_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>`;
|
||||
}
|
||||
|
||||
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');
|
||||
_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>`;
|
||||
}
|
||||
|
||||
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">
|
||||
Runs once for the household on the host. Nobody reaches it until you grant access.
|
||||
_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>
|
||||
${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'runtime name', 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}
|
||||
`, () => this._enableGlobal(), 'Enable');
|
||||
}
|
||||
|
||||
if (m.kind === 'access') {
|
||||
const users = this._users ?? [];
|
||||
return this._modalShell(`Access — ${m.server.name}`, 'bi-people', html`
|
||||
<div class="text-muted mb-2" style="font-size:.8rem">Select who may use this global connector. This replaces the current list.</div>
|
||||
${users.map(u => html`<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id=${'acc-' + u.id}
|
||||
.checked=${m.selected.has(u.id)} @change=${() => this._toggleAccess(u.id)} />
|
||||
<label class="form-check-label" for=${'acc-' + u.id}>${u.display_name || u.username} <code class="text-muted">${u.id}</code></label>
|
||||
</div>`)}
|
||||
`, () => this._saveAccess(), 'Save access');
|
||||
}
|
||||
${r.description ? html`<div class="connector-card-desc">${r.description}</div>` : nothing}
|
||||
|
||||
return 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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export class MarketplacePage extends LightElement {
|
||||
|
||||
async _install(card) {
|
||||
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;
|
||||
this._installing = card.id;
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -119,7 +119,8 @@ export class AppSidebar extends LightElement {
|
||||
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
|
||||
const match = hash.match(/^([^/?]+)/);
|
||||
const segment = match ? match[1] : '';
|
||||
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', '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() {
|
||||
@@ -299,7 +300,7 @@ export class AppSidebar extends LightElement {
|
||||
<i class="bi bi-tags"></i>
|
||||
<span class="sidebar-link-name">Roles</span>
|
||||
</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)}>
|
||||
<i class="bi bi-plug"></i>
|
||||
<span class="sidebar-link-name">Connectors</span>
|
||||
|
||||
@@ -74,6 +74,7 @@ file-viewer-page {
|
||||
users-page,
|
||||
roles-page,
|
||||
connectors-page,
|
||||
connector-detail-page,
|
||||
marketplace-page,
|
||||
catalog-page,
|
||||
profile-page {
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
<users-page></users-page>
|
||||
<roles-page></roles-page>
|
||||
<connectors-page></connectors-page>
|
||||
<connector-detail-page></connector-detail-page>
|
||||
<marketplace-page></marketplace-page>
|
||||
<catalog-page></catalog-page>
|
||||
<profile-page style="display:none"></profile-page>
|
||||
|
||||
Reference in New Issue
Block a user