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
|
# Copy of default.config.yaml with real API keys — never commit
|
||||||
/config.yml
|
/config.yml
|
||||||
/config/
|
/config/
|
||||||
# OAuth tokens, credentials, WhatsApp session data
|
|
||||||
/secrets/
|
|
||||||
!/secrets/.gitkeep
|
|
||||||
config.yml.bak
|
config.yml.bak
|
||||||
blueprint/
|
blueprint/
|
||||||
/.understand-anything
|
/.understand-anything
|
||||||
# ── Database & runtime data ───────────────────────────────────────────────────
|
# ── Database & runtime data ───────────────────────────────────────────────────
|
||||||
/database/
|
/database/
|
||||||
|
# Per-user container home dirs ({WD}/homes/{userid}) — instance data, not source
|
||||||
|
/homes/
|
||||||
# SQLite WAL-mode sidecar files (journal_mode=WAL)
|
# SQLite WAL-mode sidecar files (journal_mode=WAL)
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
@@ -18,6 +17,9 @@ blueprint/
|
|||||||
/logs/
|
/logs/
|
||||||
/tmp/
|
/tmp/
|
||||||
/scripts/
|
/scripts/
|
||||||
|
# Connector folders installed from the marketplace — instance data, like homes/
|
||||||
|
# and database/, not source. See crates/skald-core/src/mcp/install.rs
|
||||||
|
/connectors/
|
||||||
|
|
||||||
# ── Rust build artifacts ──────────────────────────────────────────────────────
|
# ── Rust build artifacts ──────────────────────────────────────────────────────
|
||||||
/target/
|
/target/
|
||||||
@@ -58,6 +60,7 @@ scripts/.gitignore
|
|||||||
*.swo
|
*.swo
|
||||||
run-log.sh
|
run-log.sh
|
||||||
/backup.sh
|
/backup.sh
|
||||||
|
/reset.sh
|
||||||
debug/
|
debug/
|
||||||
|
|
||||||
# Honcho Docker secrets
|
# Honcho Docker secrets
|
||||||
|
|||||||
@@ -100,8 +100,10 @@ Two rules keep the boundary real, and both are enforced by the compiler:
|
|||||||
|
|
||||||
The schema is split into two buckets (§5.1), and the split is the point:
|
The schema is split into two buckets (§5.1), and the split is the point:
|
||||||
|
|
||||||
- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `role_capabilities`, `shared_folders` + `shared_folder_members`. The MCP four back the Connectors model (§7/§14/§15 — see its own section). The last two (accessor `db/shared_folders.rs`) are the **membership** of the on-disk shared folders (§6): a junction table so a member can be read-only (`can_write`) and so the container mount topology + the `shared/{X}` fs routing both query it. FK `shared_folder_members.user_id → users(id)` is registry→registry (same file), which is allowed — unlike an owner→registry key.
|
- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two (accessor `db/shared_folders.rs`) are the **membership** of the on-disk shared folders (§6): a junction table so a member can be read-only (`can_write`) and so the container mount topology + the `shared/{X}` fs routing both query it. FK `shared_folder_members.user_id → users(id)` is registry→registry (same file), which is allowed — unlike an owner→registry key.
|
||||||
- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `projects`, `project_tickets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT. Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below.
|
- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `projects`, `project_tickets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below.
|
||||||
|
|
||||||
|
Schema is greenfield (no migrations, §0), but a purely **additive** column lands on an existing DB in place: `db::ensure_column` runs `ALTER TABLE … ADD COLUMN` and swallows the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already has the column. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers` so a dev box need not be wiped for an additive change (a full recreate is still valid).
|
||||||
|
|
||||||
**No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. Two keys crossed and were fixed: `chat_history.model_db_id` (dropped — write-only, and `llm_requests.model_name` already records the model) and `project_tickets.job_id` (fixed by moving `projects`/`project_tickets` into the owner bucket).
|
**No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. Two keys crossed and were fixed: `chat_history.model_db_id` (dropped — write-only, and `llm_requests.model_name` already records the model) and `project_tickets.job_id` (fixed by moving `projects`/`project_tickets` into the owner bucket).
|
||||||
|
|
||||||
@@ -145,11 +147,20 @@ MCP servers are surfaced to users as **"Connectors"** (UI naming; `mcp`/schema s
|
|||||||
|
|
||||||
**Authorization is a capability on the role, not `if role==admin`** (§0.1/§14 — `db/role_capabilities.rs`): `mcp.register_remote` + `mcp.register_local_from_catalog` are self-service (seeded on every new role by `roles::create` via `seed_defaults`); `mcp.register_local_script` + `mcp.manage_catalog` are admin-only. `admin` holds every capability by construction (short-circuit in `has()`). API handlers gate through `require_cap`.
|
**Authorization is a capability on the role, not `if role==admin`** (§0.1/§14 — `db/role_capabilities.rs`): `mcp.register_remote` + `mcp.register_local_from_catalog` are self-service (seeded on every new role by `roles::create` via `seed_defaults`); `mcp.register_local_script` + `mcp.manage_catalog` are admin-only. `admin` holds every capability by construction (short-circuit in `has()`). API handlers gate through `require_cap`.
|
||||||
|
|
||||||
**Tables** (see DB section) — registry: `mcp_catalog` (admin-vetted templates; holds only the *schema* of what an activation must supply, never live creds), `mcp_global_servers` + `mcp_global_access`, `role_capabilities`. Owner: `mcp_user_servers` (per-user activations; `api_key` encrypted at rest, `catalog_name` a bare `TEXT` snapshot).
|
**Tables** (see DB section) — registry: `mcp_catalog` (admin-vetted templates; holds only the *schema* of what an activation must supply, never live creds — plus, for OAuth, `oauth_provider` + `oauth_scopes_json` + `deliver_json`), `mcp_global_servers` + `mcp_global_access`, `oauth_providers` (per-provider client creds), `role_capabilities`. Owner: `mcp_user_servers` (per-user activations; `api_key` encrypted at rest — the refresh token for an OAuth one — `catalog_name`/`oauth_provider`/`deliver_json` bare `TEXT` snapshots).
|
||||||
|
|
||||||
**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate). `connectors.js` (`<connectors-page>`) renders the user view (activate/deactivate + granted globals) always, and the admin view (catalog + global + per-server access grants) when `role_id === 'admin'`.
|
**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 login). `connectors.js` (`<connectors-page>`) renders the user view (activate/deactivate + granted globals) always, plus the admin view (catalog + global + per-server access + a **Sign-in providers** modal) when `role_id === 'admin'`; `connector-detail.js` (`<connector-detail-page>`) is a connector's own page and hosts the OAuth login panel.
|
||||||
|
|
||||||
**Deferred:** interactive per-user auth (the §15 OAuth-callback / QR / SSH elicitation flow) — only `none`/`api_key` auth kinds are wired. No boot seed of catalog presets yet; the admin populates the catalog from the UI.
|
### OAuth per-user connectors (blueprint §15 — copy-paste flow)
|
||||||
|
|
||||||
|
OAuth2 authorization-code + PKCE is wired for per-user connectors (Gmail is the first). The consent is a **human copy-paste**, not a headless action: no callback route into the (NAT'd, hostname-less) box, and no client secret on the public feed.
|
||||||
|
|
||||||
|
- **Providers, not per-connector URLs.** The client is per-**provider** (one Google app covers Gmail/Calendar/Drive): `oauth_providers` holds `auth_url`/`token_url`/`client_id`/`client_secret`/`redirect_uri`/`extra_params`, admin-entered via the Sign-in-providers modal (Google preset fills all but the two secrets; `redirect_uri` = the static `oauth/show.html` page, `extra_params` = `access_type=offline`+`prompt=consent` so Google returns a refresh token). The manifest only names `auth.provider` + `auth.scopes` + `auth.deliver` — never URLs or secrets (feed is remote data, §14).
|
||||||
|
- **Flow** (`mcp/oauth.rs`): `activate` on an OAuth catalog entry persists a **pending** `mcp_user_servers` row (files installed, command wired, no token) and returns `needs_oauth` — it does **not** start the server. `/mcp/oauth/start` builds the consent URL (PKCE S256 + opaque `state`) and stashes the verifier in a RAM-only, TTL'd flow store keyed by `state`; the user approves in a browser, the provider lands the code on `oauth/show.html`, they paste it back. `/mcp/oauth/complete` exchanges code+verifier for a refresh token (`client_secret` sent server-side), stores it in the row's `api_key`, flips to `ready`, and starts the server. PKCE makes an intercepted code worthless; a restart drops in-flight flows (mirrors the RAM-only session model).
|
||||||
|
- **Credential delivery = env, nothing on disk.** The manifest's `deliver` (`{as,format,env}`, parsed as `mcp::DeliverSpec`) says how the token reaches the server. `user_row_spec_resolved` assembles the credential (`google_authorized_user` JSON = client creds from the provider + refresh token) and injects it as an env var (`GMAIL_CREDS_JSON`) on the `docker exec` — never a file, coherent with §2 (the tempted admin doesn't read `/proc`). The server reads it via `Credentials.from_authorized_user_info`. Ran both at OAuth-complete and at login-time per-user startup.
|
||||||
|
- **Google needs a Web-application client**: a Desktop client rejects an `https://` redirect (loopback only), so the `oauth/show.html` redirect must be registered on a **Web app** OAuth client, and exact-match under Authorized redirect URIs — `redirect_uri_mismatch` otherwise.
|
||||||
|
|
||||||
|
**Deferred:** the other §15 interactive kinds (QR / SSH via elicitation) — `deliver.as=file` and non-Google providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace.
|
||||||
|
|
||||||
## Sub-agent system
|
## Sub-agent system
|
||||||
|
|
||||||
@@ -251,7 +262,9 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/
|
|||||||
| `agent-inbox.js` | `<agent-inbox-page>` | Pending approvals + clarifications from background sessions |
|
| `agent-inbox.js` | `<agent-inbox-page>` | Pending approvals + clarifications from background sessions |
|
||||||
| `approval-rules.js` | `<approval-rules-page>` | Approval rule management |
|
| `approval-rules.js` | `<approval-rules-page>` | Approval rule management |
|
||||||
| `cron-jobs.js` | `<cron-jobs-page>` | Scheduled job management |
|
| `cron-jobs.js` | `<cron-jobs-page>` | Scheduled job management |
|
||||||
| `connectors.js` | `<connectors-page>` | MCP Connectors: user activate/deactivate + granted globals; admin catalog + global-server + per-server access management (§7/§14/§15) |
|
| `connectors.js` | `<connectors-page>` | MCP Connectors list (one row per connector): user activate/deactivate + granted globals; admin gets a **Sign-in providers** modal (OAuth client creds) + Catalog/Marketplace nav (§7/§14/§15) |
|
||||||
|
| `connector-detail.js` | `<connector-detail-page>` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable + per-user access grants |
|
||||||
|
| `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch |
|
||||||
| `llm-providers.js` | `<llm-providers-page>` | LLM provider management |
|
| `llm-providers.js` | `<llm-providers-page>` | LLM provider management |
|
||||||
| `models-hub.js` | `<models-hub-page>` | Models hub landing (LLM / Transcription / Image) |
|
| `models-hub.js` | `<models-hub-page>` | Models hub landing (LLM / Transcription / Image) |
|
||||||
| `models-llm.js` | `<models-llm-section>` | LLM model CRUD + drag-and-drop priority |
|
| `models-llm.js` | `<models-llm-section>` | LLM model CRUD + drag-and-drop priority |
|
||||||
|
|||||||
@@ -338,12 +338,15 @@ impl ApprovalManager {
|
|||||||
/// shared memory is visible to everyone, so a write is a deliberate, human-confirmed
|
/// shared memory is visible to everyone, so a write is a deliberate, human-confirmed
|
||||||
/// act — the agent must not silently push one person's information into it.
|
/// act — the agent must not silently push one person's information into it.
|
||||||
/// - `data/*` → **allow** (scratch/data workspace).
|
/// - `data/*` → **allow** (scratch/data workspace).
|
||||||
/// - `secrets/*` → **deny** (`@fs_any` denies reads *and* writes; a read would leak
|
|
||||||
/// the secret into the LLM context / history / WS stream, and `Deny` is
|
|
||||||
/// non-bypassable). The `/*` pattern also matches the `secrets` dir node itself, so
|
|
||||||
/// recursive `list_files`/`grep_files` rooted at it are covered.
|
|
||||||
/// - `memory_search` → **allow**, path-less: it searches note *content* (arg `query`,
|
/// - `memory_search` → **allow**, path-less: it searches note *content* (arg `query`,
|
||||||
/// not `path`), so it needs a tool-scoped rule rather than a path pattern.
|
/// not `path`), so it needs a tool-scoped rule rather than a path pattern.
|
||||||
|
///
|
||||||
|
/// There is no `secrets/*` deny any more. It guarded an on-disk credential store
|
||||||
|
/// that no longer exists — connectors now take their credentials from the
|
||||||
|
/// activation form, held in the owner DB. Worse, it had quietly become wrong:
|
||||||
|
/// fs-tool paths are rooted at the caller's own home (§6), so `secrets/*` had
|
||||||
|
/// stopped meaning "the box's credential store" and started meaning "any folder a
|
||||||
|
/// user dared name `secrets`".
|
||||||
pub async fn seed_fs_path_rules(&self) -> Result<()> {
|
pub async fn seed_fs_path_rules(&self) -> Result<()> {
|
||||||
// (tool_pattern, path_pattern, action, note). `path_pattern = None` is a
|
// (tool_pattern, path_pattern, action, note). `path_pattern = None` is a
|
||||||
// tool-scoped rule that matches regardless of args.
|
// tool-scoped rule that matches regardless of args.
|
||||||
@@ -352,7 +355,6 @@ impl ApprovalManager {
|
|||||||
("@fs_read", Some("shared-memory/*"), "allow", "auto-allow read shared-memory/"),
|
("@fs_read", Some("shared-memory/*"), "allow", "auto-allow read shared-memory/"),
|
||||||
("@fs_write", Some("shared-memory/*"), "require", "require write shared-memory/"),
|
("@fs_write", Some("shared-memory/*"), "require", "require write shared-memory/"),
|
||||||
("@fs_any", Some("data/*"), "allow", "auto-allow data/"),
|
("@fs_any", Some("data/*"), "allow", "auto-allow data/"),
|
||||||
("@fs_any", Some("secrets/*"), "deny", "deny secrets/ access"),
|
|
||||||
("memory_search", None, "allow", "allow memory_search"),
|
("memory_search", None, "allow", "allow memory_search"),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -409,7 +411,9 @@ impl ApprovalManager {
|
|||||||
/// - the per-tool write `require` defaults (`note = 'default rule'`, no path) — fs
|
/// - the per-tool write `require` defaults (`note = 'default rule'`, no path) — fs
|
||||||
/// gating now lives in the File System panel + the `*` catch-all;
|
/// gating now lives in the File System panel + the `*` catch-all;
|
||||||
/// - the old `data/*` allow rows (`note = 'auto-allow data/ writes'`);
|
/// - the old `data/*` allow rows (`note = 'auto-allow data/ writes'`);
|
||||||
/// - the old `secrets` deny rows (`note = 'deny reading secrets/'`);
|
/// - both generations of `secrets/` deny row (`note = 'deny reading secrets/'` and
|
||||||
|
/// `'deny secrets/ access'`) — the on-disk secrets store is gone, and rooted at a
|
||||||
|
/// user's home the pattern had come to deny them any folder named `secrets`;
|
||||||
/// - the old single `memory/*` allow row (`note = 'auto-allow memory/'`) — the memory
|
/// - the old single `memory/*` allow row (`note = 'auto-allow memory/'`) — the memory
|
||||||
/// namespace split into `user-memory/` + `shared-memory/`, so the `memory/*` pattern
|
/// namespace split into `user-memory/` + `shared-memory/`, so the `memory/*` pattern
|
||||||
/// no longer routes and would otherwise linger as a stale allow on a disk `./memory/`.
|
/// no longer routes and would otherwise linger as a stale allow on a disk `./memory/`.
|
||||||
@@ -430,10 +434,13 @@ impl ApprovalManager {
|
|||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
|
|
||||||
let n3 = sqlx::query("DELETE FROM approval_rules WHERE note = 'deny reading secrets/'")
|
let n3 = sqlx::query(
|
||||||
.execute(self.db.as_ref())
|
"DELETE FROM approval_rules
|
||||||
.await?
|
WHERE note IN ('deny reading secrets/', 'deny secrets/ access')",
|
||||||
.rows_affected();
|
)
|
||||||
|
.execute(self.db.as_ref())
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
let n4 = sqlx::query("DELETE FROM approval_rules WHERE note = 'auto-allow memory/'")
|
let n4 = sqlx::query("DELETE FROM approval_rules WHERE note = 'auto-allow memory/'")
|
||||||
.execute(self.db.as_ref())
|
.execute(self.db.as_ref())
|
||||||
@@ -1154,14 +1161,15 @@ mod tests {
|
|||||||
// Legacy per-tool fs rows are migrated away…
|
// Legacy per-tool fs rows are migrated away…
|
||||||
let legacy: i64 = sqlx::query_scalar(
|
let legacy: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM approval_rules
|
"SELECT COUNT(*) FROM approval_rules
|
||||||
WHERE note IN ('default rule', 'auto-allow data/ writes', 'deny reading secrets/')",
|
WHERE note IN ('default rule', 'auto-allow data/ writes', 'deny reading secrets/',
|
||||||
|
'deny secrets/ access')",
|
||||||
)
|
)
|
||||||
.fetch_one(db.as_ref())
|
.fetch_one(db.as_ref())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(legacy, 0, "legacy fs rules should be removed by migration");
|
assert_eq!(legacy, 0, "legacy fs rules should be removed by migration");
|
||||||
|
|
||||||
// …and replaced by exactly the five @fs_* token rows (shared-memory has two:
|
// …and replaced by exactly the four @fs_* token rows (shared-memory has two:
|
||||||
// read-allow and write-require).
|
// read-allow and write-require).
|
||||||
let fs_rows: i64 = sqlx::query_scalar(
|
let fs_rows: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'",
|
"SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'",
|
||||||
@@ -1169,7 +1177,7 @@ mod tests {
|
|||||||
.fetch_one(db.as_ref())
|
.fetch_one(db.as_ref())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(fs_rows, 5, "user-memory + shared-memory(r/w) + data + secrets @fs_* rules should be seeded");
|
assert_eq!(fs_rows, 4, "user-memory + shared-memory(r/w) + data @fs_* rules should be seeded");
|
||||||
|
|
||||||
// Gate decisions through the real check() path.
|
// Gate decisions through the real check() path.
|
||||||
async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult {
|
async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult {
|
||||||
@@ -1186,9 +1194,10 @@ mod tests {
|
|||||||
assert!(matches!(decide(&mgr, "read_file", "data/x.txt").await, GateResult::Allow));
|
assert!(matches!(decide(&mgr, "read_file", "data/x.txt").await, GateResult::Allow));
|
||||||
// memory_search is allowed by a path-less tool rule (it has `query`, not `path`).
|
// memory_search is allowed by a path-less tool rule (it has `query`, not `path`).
|
||||||
assert!(matches!(decide(&mgr, "memory_search", "ignored").await, GateResult::Allow));
|
assert!(matches!(decide(&mgr, "memory_search", "ignored").await, GateResult::Allow));
|
||||||
// Improvement over legacy: secrets *writes* are now denied too, not just reads.
|
// The on-disk secrets store is gone, and with it its blanket deny: `secrets/`
|
||||||
assert!(matches!(decide(&mgr, "write_file", "secrets/key").await, GateResult::Deny));
|
// is now an ordinary path in the caller's own home, gated by the catch-all
|
||||||
assert!(matches!(decide(&mgr, "read_file", "secrets/key").await, GateResult::Deny));
|
// like any other. Denying it would deny a user their own folder.
|
||||||
|
assert!(matches!(decide(&mgr, "read_file", "secrets/key").await, GateResult::Require));
|
||||||
// Unmatched write falls through to the `*` catch-all.
|
// Unmatched write falls through to the `*` catch-all.
|
||||||
assert!(matches!(decide(&mgr, "write_file", "src/main.rs").await, GateResult::Require));
|
assert!(matches!(decide(&mgr, "write_file", "src/main.rs").await, GateResult::Require));
|
||||||
// Non-filesystem tool: unaffected by @fs_* rules, gated by catch-all.
|
// Non-filesystem tool: unaffected by @fs_* rules, gated by catch-all.
|
||||||
|
|||||||
@@ -26,14 +26,32 @@ pub struct McpCatalogRow {
|
|||||||
pub args_json: Option<String>,
|
pub args_json: Option<String>,
|
||||||
pub env_json: Option<String>,
|
pub env_json: Option<String>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
/// local_script: the vetted source path under `./scripts`.
|
/// local_script: the vetted entry file, as `<connector>/<file>` under
|
||||||
|
/// `./connectors` (see [`crate::mcp::install`]).
|
||||||
pub script_path: Option<String>,
|
pub script_path: Option<String>,
|
||||||
/// Names of the env/secret keys the activation UI must collect (never values).
|
/// JSON array of `{name,label,description,required,secret,example,default}` objects
|
||||||
|
/// describing the env/secret fields the activation UI must collect (never values).
|
||||||
pub config_schema_json: Option<String>,
|
pub config_schema_json: Option<String>,
|
||||||
/// 'none'|'api_key'|'oauth'|'qr'|'ssh_key'. Only 'none'/'api_key' are wired now.
|
/// 'none'|'api_key'|'oauth'|'qr'|'ssh_key'.
|
||||||
pub auth_kind: String,
|
pub auth_kind: String,
|
||||||
|
/// oauth: slug into `oauth_providers.name` (which app to consent to).
|
||||||
|
pub oauth_provider: Option<String>,
|
||||||
|
/// oauth: JSON array of the scopes this connector requests at consent.
|
||||||
|
pub oauth_scopes_json: Option<String>,
|
||||||
|
/// oauth: JSON `{as,format,env,path}` — how Skald delivers the obtained
|
||||||
|
/// credential to the connector's server process (§15).
|
||||||
|
pub deliver_json: Option<String>,
|
||||||
/// JSON array of role ids allowed to activate this; NULL = all roles (§15).
|
/// JSON array of role ids allowed to activate this; NULL = all roles (§15).
|
||||||
pub role_filter: Option<String>,
|
pub role_filter: Option<String>,
|
||||||
|
/// Shell command run before persisting an activation (verify-before-save).
|
||||||
|
pub verify_command: Option<String>,
|
||||||
|
/// Script file the verify command references (e.g. `verify.py`), if any.
|
||||||
|
pub verify_script_path: Option<String>,
|
||||||
|
/// Icon file *inside* `./connectors/<name>/`, if the feed shipped one. Stored
|
||||||
|
/// rather than derived because the manifest names its icons freely (`.png` for
|
||||||
|
/// one connector, `.svg` for the next), and the browser cannot guess.
|
||||||
|
pub icon_small_path: Option<String>,
|
||||||
|
pub icon_large_path: Option<String>,
|
||||||
pub friendly_name: Option<String>,
|
pub friendly_name: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
@@ -65,11 +83,21 @@ impl McpCatalogRow {
|
|||||||
Some(roles) => roles.iter().any(|r| r == role_id),
|
Some(roles) => roles.iter().any(|r| r == role_id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The OAuth scopes this connector requests at consent, or empty when it is not
|
||||||
|
/// an OAuth connector.
|
||||||
|
pub fn oauth_scopes(&self) -> Vec<String> {
|
||||||
|
self.oauth_scopes_json.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELECT: &str =
|
const SELECT: &str =
|
||||||
"SELECT id, name, scope, source, transport, command, args_json, env_json, url, \
|
"SELECT id, name, scope, source, transport, command, args_json, env_json, url, \
|
||||||
script_path, config_schema_json, auth_kind, role_filter, friendly_name, \
|
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
|
||||||
|
deliver_json, role_filter, verify_command, \
|
||||||
|
verify_script_path, icon_small_path, icon_large_path, friendly_name, \
|
||||||
description, created_at \
|
description, created_at \
|
||||||
FROM mcp_catalog";
|
FROM mcp_catalog";
|
||||||
|
|
||||||
@@ -122,7 +150,14 @@ pub struct UpsertCatalog<'a> {
|
|||||||
pub script_path: Option<&'a str>,
|
pub script_path: Option<&'a str>,
|
||||||
pub config_schema_json: Option<String>,
|
pub config_schema_json: Option<String>,
|
||||||
pub auth_kind: &'a str,
|
pub auth_kind: &'a str,
|
||||||
|
pub oauth_provider: Option<&'a str>,
|
||||||
|
pub oauth_scopes_json: Option<String>,
|
||||||
|
pub deliver_json: Option<String>,
|
||||||
pub role_filter: Option<String>,
|
pub role_filter: Option<String>,
|
||||||
|
pub verify_command: Option<&'a str>,
|
||||||
|
pub verify_script_path: Option<&'a str>,
|
||||||
|
pub icon_small_path: Option<&'a str>,
|
||||||
|
pub icon_large_path: Option<&'a str>,
|
||||||
pub friendly_name: Option<&'a str>,
|
pub friendly_name: Option<&'a str>,
|
||||||
pub description: Option<&'a str>,
|
pub description: Option<&'a str>,
|
||||||
}
|
}
|
||||||
@@ -131,8 +166,10 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
|||||||
let row = sqlx::query_as::<_, (i64,)>(
|
let row = sqlx::query_as::<_, (i64,)>(
|
||||||
"INSERT INTO mcp_catalog
|
"INSERT INTO mcp_catalog
|
||||||
(name, scope, source, transport, command, args_json, env_json, url,
|
(name, scope, source, transport, command, args_json, env_json, url,
|
||||||
script_path, config_schema_json, auth_kind, role_filter, friendly_name, description)
|
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
deliver_json, role_filter, verify_command,
|
||||||
|
verify_script_path, icon_small_path, icon_large_path, friendly_name, description)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
scope = excluded.scope,
|
scope = excluded.scope,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
@@ -144,7 +181,18 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
|||||||
script_path = excluded.script_path,
|
script_path = excluded.script_path,
|
||||||
config_schema_json = excluded.config_schema_json,
|
config_schema_json = excluded.config_schema_json,
|
||||||
auth_kind = excluded.auth_kind,
|
auth_kind = excluded.auth_kind,
|
||||||
|
oauth_provider = excluded.oauth_provider,
|
||||||
|
oauth_scopes_json = excluded.oauth_scopes_json,
|
||||||
|
deliver_json = excluded.deliver_json,
|
||||||
role_filter = excluded.role_filter,
|
role_filter = excluded.role_filter,
|
||||||
|
verify_command = excluded.verify_command,
|
||||||
|
verify_script_path = excluded.verify_script_path,
|
||||||
|
-- Icons belong to whoever installed the files, not to whoever last
|
||||||
|
-- edited the row: COALESCE keeps them when an admin saves the catalog
|
||||||
|
-- form (which knows nothing about icons and would otherwise blank
|
||||||
|
-- them), while a reinstall still updates them.
|
||||||
|
icon_small_path = COALESCE(excluded.icon_small_path, mcp_catalog.icon_small_path),
|
||||||
|
icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
|
||||||
friendly_name = excluded.friendly_name,
|
friendly_name = excluded.friendly_name,
|
||||||
description = excluded.description
|
description = excluded.description
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
@@ -160,7 +208,14 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
|||||||
.bind(e.script_path)
|
.bind(e.script_path)
|
||||||
.bind(e.config_schema_json)
|
.bind(e.config_schema_json)
|
||||||
.bind(e.auth_kind)
|
.bind(e.auth_kind)
|
||||||
|
.bind(e.oauth_provider)
|
||||||
|
.bind(e.oauth_scopes_json)
|
||||||
|
.bind(e.deliver_json)
|
||||||
.bind(e.role_filter)
|
.bind(e.role_filter)
|
||||||
|
.bind(e.verify_command)
|
||||||
|
.bind(e.verify_script_path)
|
||||||
|
.bind(e.icon_small_path)
|
||||||
|
.bind(e.icon_large_path)
|
||||||
.bind(e.friendly_name)
|
.bind(e.friendly_name)
|
||||||
.bind(e.description)
|
.bind(e.description)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|||||||
@@ -14,18 +14,22 @@ use sqlx::SqlitePool;
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct McpGlobalServerRow {
|
pub struct McpGlobalServerRow {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub catalog_name: Option<String>,
|
pub catalog_name: Option<String>,
|
||||||
pub transport: String,
|
pub transport: String,
|
||||||
pub command: Option<String>,
|
pub command: Option<String>,
|
||||||
pub args_json: Option<String>,
|
pub args_json: Option<String>,
|
||||||
pub env_json: Option<String>,
|
pub env_json: Option<String>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
pub friendly_name: Option<String>,
|
/// Snapshot of `mcp_catalog.verify_command` (NULL = no test).
|
||||||
pub description: Option<String>,
|
pub verify_command: Option<String>,
|
||||||
pub enabled: bool,
|
/// 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 {
|
impl McpGlobalServerRow {
|
||||||
@@ -44,7 +48,7 @@ impl McpGlobalServerRow {
|
|||||||
|
|
||||||
const SELECT: &str =
|
const SELECT: &str =
|
||||||
"SELECT id, name, catalog_name, transport, command, args_json, env_json, url, \
|
"SELECT id, name, catalog_name, transport, command, args_json, env_json, url, \
|
||||||
api_key, friendly_name, description, enabled \
|
api_key, verify_command, verify_script_path, friendly_name, description, enabled \
|
||||||
FROM mcp_global_servers";
|
FROM mcp_global_servers";
|
||||||
|
|
||||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||||
@@ -82,34 +86,39 @@ pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Option<McpGlob
|
|||||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct UpsertGlobal<'a> {
|
pub struct UpsertGlobal<'a> {
|
||||||
pub name: &'a str,
|
pub name: &'a str,
|
||||||
pub catalog_name: Option<&'a str>,
|
pub catalog_name: Option<&'a str>,
|
||||||
pub transport: &'a str,
|
pub transport: &'a str,
|
||||||
pub command: Option<&'a str>,
|
pub command: Option<&'a str>,
|
||||||
pub args_json: Option<String>,
|
pub args_json: Option<String>,
|
||||||
pub env_json: Option<String>,
|
pub env_json: Option<String>,
|
||||||
pub url: Option<&'a str>,
|
pub url: Option<&'a str>,
|
||||||
pub api_key: Option<&'a str>,
|
pub api_key: Option<&'a str>,
|
||||||
pub friendly_name: Option<&'a str>,
|
pub verify_command: Option<&'a str>,
|
||||||
pub description: 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> {
|
pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
|
||||||
let row = sqlx::query_as::<_, (i64,)>(
|
let row = sqlx::query_as::<_, (i64,)>(
|
||||||
"INSERT INTO mcp_global_servers
|
"INSERT INTO mcp_global_servers
|
||||||
(name, catalog_name, transport, command, args_json, env_json, url, api_key, friendly_name, description, enabled)
|
(name, catalog_name, transport, command, args_json, env_json, url, api_key,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1)
|
verify_command, verify_script_path, friendly_name, description, enabled)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1)
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
catalog_name = excluded.catalog_name,
|
catalog_name = excluded.catalog_name,
|
||||||
transport = excluded.transport,
|
transport = excluded.transport,
|
||||||
command = excluded.command,
|
command = excluded.command,
|
||||||
args_json = excluded.args_json,
|
args_json = excluded.args_json,
|
||||||
env_json = excluded.env_json,
|
env_json = excluded.env_json,
|
||||||
url = excluded.url,
|
url = excluded.url,
|
||||||
api_key = excluded.api_key,
|
api_key = excluded.api_key,
|
||||||
friendly_name = excluded.friendly_name,
|
verify_command = excluded.verify_command,
|
||||||
description = excluded.description,
|
verify_script_path = excluded.verify_script_path,
|
||||||
enabled = 1
|
friendly_name = excluded.friendly_name,
|
||||||
|
description = excluded.description,
|
||||||
|
enabled = 1
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(p.name)
|
.bind(p.name)
|
||||||
@@ -120,6 +129,8 @@ pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
|
|||||||
.bind(p.env_json)
|
.bind(p.env_json)
|
||||||
.bind(p.url)
|
.bind(p.url)
|
||||||
.bind(p.api_key)
|
.bind(p.api_key)
|
||||||
|
.bind(p.verify_command)
|
||||||
|
.bind(p.verify_script_path)
|
||||||
.bind(p.friendly_name)
|
.bind(p.friendly_name)
|
||||||
.bind(p.description)
|
.bind(p.description)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|||||||
@@ -15,24 +15,34 @@ use sqlx::SqlitePool;
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct McpUserServerRow {
|
pub struct McpUserServerRow {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Bare snapshot of the originating `mcp_catalog.name`; NULL for a
|
/// Bare snapshot of the originating `mcp_catalog.name`; NULL for a
|
||||||
/// self-registered remote.
|
/// self-registered remote.
|
||||||
pub catalog_name: Option<String>,
|
pub catalog_name: Option<String>,
|
||||||
/// 'remote' | 'local_script'.
|
/// 'remote' | 'local_script'.
|
||||||
pub source: String,
|
pub source: String,
|
||||||
pub transport: String,
|
pub transport: String,
|
||||||
pub command: Option<String>,
|
pub command: Option<String>,
|
||||||
pub args_json: Option<String>,
|
pub args_json: Option<String>,
|
||||||
pub env_json: Option<String>,
|
pub env_json: Option<String>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
pub api_key: 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`.
|
/// Container path of the copied script, for a `local_script`.
|
||||||
pub script_rel_path: Option<String>,
|
pub script_rel_path: Option<String>,
|
||||||
/// 'pending' | 'ready' — the interactive-auth gate ('ready' while api-key).
|
/// Snapshot of `mcp_catalog.verify_command` (NULL = no test).
|
||||||
pub auth_state: String,
|
pub verify_command: Option<String>,
|
||||||
pub enabled: bool,
|
/// 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 {
|
impl McpUserServerRow {
|
||||||
@@ -47,11 +57,19 @@ impl McpUserServerRow {
|
|||||||
.and_then(|s| serde_json::from_str(s).ok())
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The credential delivery spec (`{as,format,env,path}`), if this is an OAuth
|
||||||
|
/// connector that snapshotted one.
|
||||||
|
pub fn deliver(&self) -> Option<crate::mcp::DeliverSpec> {
|
||||||
|
self.deliver_json.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELECT: &str =
|
const SELECT: &str =
|
||||||
"SELECT id, name, catalog_name, source, transport, command, args_json, env_json, url, \
|
"SELECT id, name, catalog_name, source, transport, command, args_json, env_json, url, \
|
||||||
api_key, script_rel_path, auth_state, enabled \
|
api_key, oauth_provider, deliver_json, script_rel_path, verify_command, \
|
||||||
|
verify_script_rel_path, auth_state, enabled \
|
||||||
FROM mcp_user_servers";
|
FROM mcp_user_servers";
|
||||||
|
|
||||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||||
@@ -93,24 +111,30 @@ pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Option<McpUser
|
|||||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct InsertUserServer<'a> {
|
pub struct InsertUserServer<'a> {
|
||||||
pub name: &'a str,
|
pub name: &'a str,
|
||||||
pub catalog_name: Option<&'a str>,
|
pub catalog_name: Option<&'a str>,
|
||||||
pub source: &'a str,
|
pub source: &'a str,
|
||||||
pub transport: &'a str,
|
pub transport: &'a str,
|
||||||
pub command: Option<&'a str>,
|
pub command: Option<&'a str>,
|
||||||
pub args_json: Option<String>,
|
pub args_json: Option<String>,
|
||||||
pub env_json: Option<String>,
|
pub env_json: Option<String>,
|
||||||
pub url: Option<&'a str>,
|
pub url: Option<&'a str>,
|
||||||
pub api_key: Option<&'a str>,
|
pub api_key: Option<&'a str>,
|
||||||
pub script_rel_path: Option<&'a str>,
|
pub oauth_provider: Option<&'a str>,
|
||||||
pub auth_state: &'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> {
|
pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
||||||
let id = sqlx::query(
|
let id = sqlx::query(
|
||||||
"INSERT INTO mcp_user_servers
|
"INSERT INTO mcp_user_servers
|
||||||
(name, catalog_name, source, transport, command, args_json, env_json, url, api_key, script_rel_path, auth_state, enabled)
|
(name, catalog_name, source, transport, command, args_json, env_json, url, api_key,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 1)",
|
oauth_provider, deliver_json, script_rel_path, verify_command, verify_script_rel_path,
|
||||||
|
auth_state, enabled)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 1)",
|
||||||
)
|
)
|
||||||
.bind(s.name)
|
.bind(s.name)
|
||||||
.bind(s.catalog_name)
|
.bind(s.catalog_name)
|
||||||
@@ -121,7 +145,11 @@ pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
|||||||
.bind(s.env_json)
|
.bind(s.env_json)
|
||||||
.bind(s.url)
|
.bind(s.url)
|
||||||
.bind(s.api_key)
|
.bind(s.api_key)
|
||||||
|
.bind(s.oauth_provider)
|
||||||
|
.bind(s.deliver_json)
|
||||||
.bind(s.script_rel_path)
|
.bind(s.script_rel_path)
|
||||||
|
.bind(s.verify_command)
|
||||||
|
.bind(s.verify_script_rel_path)
|
||||||
.bind(s.auth_state)
|
.bind(s.auth_state)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?
|
.await?
|
||||||
@@ -129,6 +157,17 @@ pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores a freshly-obtained OAuth refresh token and flips the connector to
|
||||||
|
/// `ready`, in one write — the completion of the §15 login flow.
|
||||||
|
pub async fn set_oauth_token(pool: &SqlitePool, id: i64, refresh_token: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE mcp_user_servers SET api_key = ?1, auth_state = 'ready' WHERE id = ?2")
|
||||||
|
.bind(refresh_token)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<()> {
|
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<()> {
|
||||||
sqlx::query("UPDATE mcp_user_servers SET enabled = ?1 WHERE id = ?2")
|
sqlx::query("UPDATE mcp_user_servers SET enabled = ?1 WHERE id = ?2")
|
||||||
.bind(enabled as i64)
|
.bind(enabled as i64)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub mod mcp_global_access;
|
|||||||
pub mod mcp_global_servers;
|
pub mod mcp_global_servers;
|
||||||
pub mod mcp_user_servers;
|
pub mod mcp_user_servers;
|
||||||
pub mod memory_docs;
|
pub mod memory_docs;
|
||||||
|
pub mod oauth_providers;
|
||||||
pub mod plugins;
|
pub mod plugins;
|
||||||
pub mod role_capabilities;
|
pub mod role_capabilities;
|
||||||
pub mod roles;
|
pub mod roles;
|
||||||
@@ -155,6 +156,21 @@ pub async fn open_user_pool(path: &Path, key: Option<&Dek>) -> Result<SqlitePool
|
|||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds a nullable column if it is not already present, so a purely **additive**
|
||||||
|
/// schema change lands on an existing database without a wipe. Greenfield still
|
||||||
|
/// permits a clean recreate (§0); this only spares an existing box's data when the
|
||||||
|
/// change is additive, and is a no-op on a fresh DB where the column already exists
|
||||||
|
/// in the `CREATE TABLE`. The "duplicate column name" error means it's already there.
|
||||||
|
async fn ensure_column(pool: &SqlitePool, table: &str, column: &str, decl: &str) -> Result<()> {
|
||||||
|
let sql = format!("ALTER TABLE {table} ADD COLUMN {column} {decl}");
|
||||||
|
if let Err(e) = sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await {
|
||||||
|
if !e.to_string().contains("duplicate column name") {
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ── Registry tables ───────────────────────────────────────────────────────────
|
// ── Registry tables ───────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Instance-wide, readable without any user key: the directory you must open
|
// Instance-wide, readable without any user key: the directory you must open
|
||||||
@@ -442,10 +458,17 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
|||||||
args_json TEXT,
|
args_json TEXT,
|
||||||
env_json TEXT,
|
env_json TEXT,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
script_path TEXT, -- local_script: source under ./scripts
|
script_path TEXT, -- local_script: entry file, as <connector>/<file> under ./connectors
|
||||||
config_schema_json TEXT, -- names of env/secret keys the UI must collect
|
config_schema_json TEXT, -- env[] entries (objects) the UI must collect
|
||||||
auth_kind TEXT NOT NULL DEFAULT 'none', -- 'none'|'api_key'|'oauth'|'qr'|'ssh_key'
|
auth_kind TEXT NOT NULL DEFAULT 'none', -- 'none'|'api_key'|'oauth'|'qr'|'ssh_key'
|
||||||
|
oauth_provider TEXT, -- oauth: slug into oauth_providers.name
|
||||||
|
oauth_scopes_json TEXT, -- oauth: JSON array of scopes requested at consent
|
||||||
|
deliver_json TEXT, -- oauth: {as,format,env,path} credential delivery spec
|
||||||
role_filter TEXT, -- JSON array of role ids; NULL = all
|
role_filter TEXT, -- JSON array of role ids; NULL = all
|
||||||
|
verify_command TEXT, -- shell command run before persisting an activation
|
||||||
|
verify_script_path TEXT, -- script file the verify command references, if any
|
||||||
|
icon_small_path TEXT, -- icon file inside ./connectors/<name>/, if the feed shipped one
|
||||||
|
icon_large_path TEXT,
|
||||||
friendly_name TEXT,
|
friendly_name TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
@@ -453,6 +476,10 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
|||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
// OAuth columns are additive (§15) — reach an already-created catalog in place.
|
||||||
|
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
|
||||||
|
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
|
||||||
|
ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?;
|
||||||
|
|
||||||
// Concrete globally-active connectors (shared, stateless — web-search etc.).
|
// Concrete globally-active connectors (shared, stateless — web-search etc.).
|
||||||
// They run on the HOST. The global secret (admin's API key) is fine here:
|
// They run on the HOST. The global secret (admin's API key) is fine here:
|
||||||
@@ -460,19 +487,21 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
|||||||
// FK (both in this file) — allowed.
|
// FK (both in this file) — allowed.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS mcp_global_servers (
|
"CREATE TABLE IF NOT EXISTS mcp_global_servers (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
catalog_name TEXT REFERENCES mcp_catalog(name),
|
catalog_name TEXT REFERENCES mcp_catalog(name),
|
||||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||||
command TEXT,
|
command TEXT,
|
||||||
args_json TEXT,
|
args_json TEXT,
|
||||||
env_json TEXT,
|
env_json TEXT,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
api_key TEXT,
|
api_key TEXT,
|
||||||
friendly_name TEXT,
|
verify_command TEXT, -- snapshot of mcp_catalog.verify_command
|
||||||
description TEXT,
|
verify_script_path TEXT, -- absolute host path of the verify script, if any
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
friendly_name TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
description TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -503,6 +532,32 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// OAuth providers for per-user connectors (blueprint §15). One row per identity
|
||||||
|
// provider (Google, …), referenced by name from `mcp_catalog.oauth_provider`. A
|
||||||
|
// single app covers every service of that provider (Gmail, Calendar, Drive) —
|
||||||
|
// client credentials are keyed on the provider, scopes on the connector.
|
||||||
|
//
|
||||||
|
// Registry table (`system.db`): `client_secret` is a household/global secret the
|
||||||
|
// admin owns (§4/§15b), not a per-user one, so it belongs here in the admin-
|
||||||
|
// readable file. The per-user refresh tokens each activation obtains never land
|
||||||
|
// here — they go, encrypted, into the user's `mcp_user_servers.api_key`.
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS oauth_providers (
|
||||||
|
name TEXT PRIMARY KEY, -- slug referenced by mcp_catalog.oauth_provider
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
auth_url TEXT NOT NULL, -- authorization endpoint
|
||||||
|
token_url TEXT NOT NULL, -- token endpoint
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
client_secret TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL, -- copy-paste landing page (oauth/show.html)
|
||||||
|
extra_params TEXT, -- JSON of extra auth params (access_type, prompt, …)
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,24 +797,31 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
|||||||
// copied into the bind-mounted home (`script_rel_path`).
|
// copied into the bind-mounted home (`script_rel_path`).
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS mcp_user_servers (
|
"CREATE TABLE IF NOT EXISTS mcp_user_servers (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
catalog_name TEXT, -- bare ref to mcp_catalog.name; NULL = self-registered remote
|
catalog_name TEXT, -- bare ref to mcp_catalog.name; NULL = self-registered remote
|
||||||
source TEXT NOT NULL, -- 'remote' | 'local_script'
|
source TEXT NOT NULL, -- 'remote' | 'local_script'
|
||||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||||
command TEXT,
|
command TEXT,
|
||||||
args_json TEXT,
|
args_json TEXT,
|
||||||
env_json TEXT,
|
env_json TEXT,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
api_key TEXT, -- per-user secret / OAuth refresh token
|
api_key TEXT, -- per-user secret / OAuth refresh token
|
||||||
script_rel_path TEXT, -- container path for a local_script
|
oauth_provider TEXT, -- oauth: snapshot of catalog oauth_provider
|
||||||
auth_state TEXT NOT NULL DEFAULT 'ready', -- 'pending' | 'ready'
|
deliver_json TEXT, -- oauth: snapshot of catalog credential delivery spec
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
script_rel_path TEXT, -- container path for a local_script
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
// OAuth columns are additive (§15) — reach an already-created table in place.
|
||||||
|
ensure_column(pool, "mcp_user_servers", "oauth_provider", "TEXT").await?;
|
||||||
|
ensure_column(pool, "mcp_user_servers", "deliver_json", "TEXT").await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS sources (
|
"CREATE TABLE IF NOT EXISTS sources (
|
||||||
|
|||||||
@@ -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;
|
use mcp_client::McpTransport;
|
||||||
|
|
||||||
|
pub mod install;
|
||||||
mod logs;
|
mod logs;
|
||||||
|
pub mod oauth;
|
||||||
mod provider;
|
mod provider;
|
||||||
|
pub mod verify;
|
||||||
|
|
||||||
|
pub use install::{CONNECTORS_DIR, MANIFEST_FILE, connector_dir, install_into_home, split_script_path};
|
||||||
|
pub use oauth::DeliverSpec;
|
||||||
pub use provider::{McpProvider, UserMcpView};
|
pub use provider::{McpProvider, UserMcpView};
|
||||||
|
pub use verify::{VerifyReport, VerifyTarget, apply_placeholders, run_verify};
|
||||||
|
|
||||||
const SERVER_START_TIMEOUT_SECS: u64 = 120;
|
const SERVER_START_TIMEOUT_SECS: u64 = 120;
|
||||||
|
|
||||||
@@ -409,10 +415,35 @@ fn apply_key_placeholder(
|
|||||||
) -> (Option<String>, Option<String>) {
|
) -> (Option<String>, Option<String>) {
|
||||||
match (url, api_key) {
|
match (url, api_key) {
|
||||||
(Some(u), Some(k)) if u.contains("{key}") => (Some(u.replace("{key}", &k)), None),
|
(Some(u), Some(k)) if u.contains("{key}") => (Some(u.replace("{key}", &k)), None),
|
||||||
|
// Unified {SECRET:<param>} placeholder (e.g. Tavily's
|
||||||
|
// `?tavilyApiKey={SECRET:tavilyApiKey}`). Any SECRET token in a URL is
|
||||||
|
// the api_key for a remote connector — a URL never carries the user's
|
||||||
|
// other secrets — so we substitute every occurrence.
|
||||||
|
(Some(u), Some(k)) if u.contains("{SECRET:") => (Some(substitute_secret_tokens(&u, &k)), None),
|
||||||
(u, k) => (u, k),
|
(u, k) => (u, k),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces every `{SECRET:…}` token in `text` with `value`. Used for the
|
||||||
|
/// api-key-in-URL case; other placeholders are left untouched.
|
||||||
|
fn substitute_secret_tokens(text: &str, value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut rest = text;
|
||||||
|
while let Some(open) = rest.find("{SECRET:") {
|
||||||
|
out.push_str(&rest[..open]);
|
||||||
|
let after = &rest[open..];
|
||||||
|
if let Some(close) = after.find('}') {
|
||||||
|
out.push_str(value);
|
||||||
|
rest = &after[close + 1..];
|
||||||
|
} else {
|
||||||
|
out.push_str(after);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds a spec for a globally-active connector — host transport (`launch_in`
|
/// Builds a spec for a globally-active connector — host transport (`launch_in`
|
||||||
/// = None), so it runs in the Skald process, not in any container (§7).
|
/// = None), so it runs in the Skald process, not in any container (§7).
|
||||||
pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow) -> McpServerSpec {
|
pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow) -> McpServerSpec {
|
||||||
@@ -460,6 +491,51 @@ pub fn user_row_spec(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`user_row_spec`], but for an OAuth connector it also resolves the stored
|
||||||
|
/// refresh token into the credential the server reads and injects it into the
|
||||||
|
/// process env per the delivery spec (§15). Non-OAuth rows are returned unchanged.
|
||||||
|
///
|
||||||
|
/// `registry` is the system pool, where `oauth_providers` (the client credentials)
|
||||||
|
/// lives. A resolution failure is logged, not fatal: the server still starts, and
|
||||||
|
/// fails its own auth visibly, rather than the whole login batch aborting.
|
||||||
|
pub async fn user_row_spec_resolved(
|
||||||
|
row: &crate::db::mcp_user_servers::McpUserServerRow,
|
||||||
|
container: &str,
|
||||||
|
registry: &SqlitePool,
|
||||||
|
) -> McpServerSpec {
|
||||||
|
let mut spec = user_row_spec(row, container);
|
||||||
|
if let (Some(provider), Some(deliver), Some(refresh)) =
|
||||||
|
(row.oauth_provider.as_deref(), row.deliver(), row.api_key.as_deref())
|
||||||
|
{
|
||||||
|
if let Err(e) = inject_oauth_env(&mut spec, provider, &deliver, refresh, registry).await {
|
||||||
|
warn!("connector '{}': OAuth credential delivery failed: {e}", row.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spec
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assembles the credential from the provider's client creds + the refresh token and
|
||||||
|
/// sets it on `spec.config.env` under the delivery spec's env name.
|
||||||
|
async fn inject_oauth_env(
|
||||||
|
spec: &mut McpServerSpec,
|
||||||
|
provider_name: &str,
|
||||||
|
deliver: &DeliverSpec,
|
||||||
|
refresh_token: &str,
|
||||||
|
registry: &SqlitePool,
|
||||||
|
) -> Result<()> {
|
||||||
|
if deliver.as_ != "env" {
|
||||||
|
anyhow::bail!("only `env` credential delivery is wired (deliver.as = `{}`)", deliver.as_);
|
||||||
|
}
|
||||||
|
let env_name = deliver.env.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("deliver.as=env but no deliver.env name"))?;
|
||||||
|
let format = deliver.format.as_deref().unwrap_or("google_authorized_user");
|
||||||
|
let provider = crate::db::oauth_providers::get(registry, provider_name).await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("unknown OAuth provider `{provider_name}`"))?;
|
||||||
|
let cred = oauth::assemble_credential(format, &provider, refresh_token)?;
|
||||||
|
spec.config.env.get_or_insert_with(HashMap::new).insert(env_name.to_string(), cred);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates a 32-char alphanumeric id for a persisted media filename
|
/// Generates a 32-char alphanumeric id for a persisted media filename
|
||||||
/// (mirrors `ImageGeneratorManager`).
|
/// (mirrors `ImageGeneratorManager`).
|
||||||
fn random_id() -> String {
|
fn random_id() -> String {
|
||||||
@@ -510,3 +586,50 @@ pub fn content_type_for_ext(ext: &str) -> &'static str {
|
|||||||
_ => "application/octet-stream",
|
_ => "application/octet-stream",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_placeholder_legacy_is_substituted() {
|
||||||
|
let (url, key) = apply_key_placeholder(
|
||||||
|
Some("https://x/?k={key}".into()),
|
||||||
|
Some("secret123".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(url.as_deref(), Some("https://x/?k=secret123"));
|
||||||
|
assert!(key.is_none(), "api_key is consumed after substitution");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_placeholder_secret_token_is_substituted() {
|
||||||
|
// Tavily's unified form: the URL carries {SECRET:tavilyApiKey}.
|
||||||
|
let (url, key) = apply_key_placeholder(
|
||||||
|
Some("https://mcp.tavily.com/mcp/?tavilyApiKey={SECRET:tavilyApiKey}".into()),
|
||||||
|
Some("tvly-abc".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(url.as_deref(), Some("https://mcp.tavily.com/mcp/?tavilyApiKey=tvly-abc"));
|
||||||
|
assert!(key.is_none(), "api_key is consumed when the URL had a SECRET token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_placeholder_no_token_keeps_key_for_bearer() {
|
||||||
|
// No placeholder in the URL → the key stays, so the HTTP transport
|
||||||
|
// sends it as `Authorization: Bearer`.
|
||||||
|
let (url, key) = apply_key_placeholder(
|
||||||
|
Some("https://x.example.com/mcp".into()),
|
||||||
|
Some("bearer-key".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(url.as_deref(), Some("https://x.example.com/mcp"));
|
||||||
|
assert_eq!(key.as_deref(), Some("bearer-key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn substitute_secret_tokens_replaces_every_occurrence() {
|
||||||
|
let s = substitute_secret_tokens(
|
||||||
|
"a={SECRET:K}&b={SECRET:K}&c={ENV:C}",
|
||||||
|
"VAL",
|
||||||
|
);
|
||||||
|
assert_eq!(s, "a=VAL&b=VAL&c={ENV:C}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 um = Arc::clone(&user_mcp);
|
||||||
let upool = Arc::clone(&pool);
|
let upool = Arc::clone(&pool);
|
||||||
|
let registry = Arc::clone(&self.registry_pool);
|
||||||
let container = crate::container::container_name(user_id);
|
let container = crate::container::container_name(user_id);
|
||||||
let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str());
|
let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str());
|
||||||
self.supervisor.adopt_one(mname, tokio::spawn(async move {
|
self.supervisor.adopt_one(mname, tokio::spawn(async move {
|
||||||
match crate::db::mcp_user_servers::all_startable(&upool).await {
|
match crate::db::mcp_user_servers::all_startable(&upool).await {
|
||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
let specs = rows.iter()
|
let mut specs = Vec::with_capacity(rows.len());
|
||||||
.map(|r| crate::mcp::user_row_spec(r, &container))
|
for r in &rows {
|
||||||
.collect();
|
// OAuth connectors resolve their stored refresh token into
|
||||||
|
// the env-delivered credential here (§15).
|
||||||
|
specs.push(crate::mcp::user_row_spec_resolved(r, &container, ®istry).await);
|
||||||
|
}
|
||||||
um.connect_all(specs, false).await;
|
um.connect_all(specs, false).await;
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!(error = %e, "per-user MCP init: failed to read mcp_user_servers"),
|
Err(e) => tracing::warn!(error = %e, "per-user MCP init: failed to read mcp_user_servers"),
|
||||||
|
|||||||
@@ -173,9 +173,8 @@ impl Tool for GrepFiles {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `secrets` is skipped so a recursive grep rooted at a parent (e.g. the auto-read
|
// Noise, not policy: build output and vendored trees a grep is never looking for.
|
||||||
// working directory) never descends into and leaks secret values.
|
const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", ".venv", "__pycache__"];
|
||||||
const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", ".venv", "__pycache__", "secrets"];
|
|
||||||
const MAX_FILE_BYTES: u64 = 200_000;
|
const MAX_FILE_BYTES: u64 = 200_000;
|
||||||
const MAX_OUTPUT_BYTES: usize = 60_000;
|
const MAX_OUTPUT_BYTES: usize = 60_000;
|
||||||
const MAX_LINE_BYTES: usize = 500;
|
const MAX_LINE_BYTES: usize = 500;
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ use crate::tools::{
|
|||||||
};
|
};
|
||||||
use super::{classify_memory, resolve, MemScope};
|
use super::{classify_memory, resolve, MemScope};
|
||||||
|
|
||||||
/// Directories to skip unconditionally when walking.
|
/// Directories to skip unconditionally when walking — noise, not policy.
|
||||||
/// `secrets` is skipped so a recursive listing rooted at a parent (e.g. the auto-read
|
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache"];
|
||||||
/// working directory) never reveals the contents of the secrets store.
|
|
||||||
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "secrets"];
|
|
||||||
|
|
||||||
pub struct ListFiles {
|
pub struct ListFiles {
|
||||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ pub fn resolve(user_path: &str) -> Result<PathBuf> {
|
|||||||
/// does not exist yet) is appended lexically. Falls back to a pure lexical normalization
|
/// does not exist yet) is appended lexically. Falls back to a pure lexical normalization
|
||||||
/// when nothing along the path can be canonicalized.
|
/// when nothing along the path can be canonicalized.
|
||||||
///
|
///
|
||||||
/// This closes `docs/../secrets/x` traversal and symlink escapes for both the allow
|
/// This closes `docs/../private/x` traversal and symlink escapes for both the allow
|
||||||
/// fast-paths (`RunContext`) and the deny rules (`approval::normalize_path`).
|
/// fast-paths (`RunContext`) and the deny rules (`approval::normalize_path`).
|
||||||
pub fn canonicalize_for_policy(path: &str, base: &Path) -> PathBuf {
|
pub fn canonicalize_for_policy(path: &str, base: &Path) -> PathBuf {
|
||||||
let raw = {
|
let raw = {
|
||||||
|
|||||||
+274
-33
@@ -129,6 +129,14 @@ struct AuthSpec {
|
|||||||
#[serde(default, rename = "type")] kind: Option<String>,
|
#[serde(default, rename = "type")] kind: Option<String>,
|
||||||
#[serde(default)] delivery: Option<String>,
|
#[serde(default)] delivery: Option<String>,
|
||||||
#[serde(default)] scopes: Vec<String>,
|
#[serde(default)] scopes: Vec<String>,
|
||||||
|
/// oauth: the identity provider slug (`google`) — resolved to client creds +
|
||||||
|
/// endpoints from the `oauth_providers` registry table (§15). Never carries
|
||||||
|
/// URLs or secrets: those stay out of the public feed by design.
|
||||||
|
#[serde(default)] provider: Option<String>,
|
||||||
|
/// oauth: how the obtained credential is delivered to the server process
|
||||||
|
/// (`{as,format,env,path}`). Parsed with skald-core's own type so the stored
|
||||||
|
/// snapshot and the runtime injector agree on the shape.
|
||||||
|
#[serde(default)] deliver: Option<skald_core::mcp::DeliverSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
@@ -150,6 +158,27 @@ struct McpConfigManifest {
|
|||||||
#[serde(default)] transport: Option<String>,
|
#[serde(default)] transport: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One env/secret field the activation UI must collect (the feed's `env[]` entry).
|
||||||
|
/// Richer than the old `Vec<String>` of bare key names — drives a real form.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
struct EnvEntry {
|
||||||
|
name: String,
|
||||||
|
label: String,
|
||||||
|
description: String,
|
||||||
|
#[serde(default)] required: bool,
|
||||||
|
#[serde(default)] secret: bool,
|
||||||
|
#[serde(default)] default: Option<String>,
|
||||||
|
#[serde(default)] example: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The verify-before-save step. `command` is a shell snippet run with the
|
||||||
|
/// collected env/secret injected; `timeout_secs` defaults to 15.
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
struct VerifySpec {
|
||||||
|
command: String,
|
||||||
|
#[serde(default)] timeout_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
struct Manifest {
|
struct Manifest {
|
||||||
#[serde(default)] name: Option<String>,
|
#[serde(default)] name: Option<String>,
|
||||||
@@ -167,12 +196,19 @@ struct Manifest {
|
|||||||
#[serde(default)] files: Vec<FileEntry>,
|
#[serde(default)] files: Vec<FileEntry>,
|
||||||
#[serde(default)] scope: Option<String>,
|
#[serde(default)] scope: Option<String>,
|
||||||
#[serde(default)] auth: Option<AuthSpec>,
|
#[serde(default)] auth: Option<AuthSpec>,
|
||||||
|
/// The full env/secret schema for the activation form (objects, not key names).
|
||||||
|
#[serde(default)] env: Vec<EnvEntry>,
|
||||||
|
/// Optional verify-before-save command.
|
||||||
|
#[serde(default)] verify: Option<VerifySpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct Hydrated {
|
struct Hydrated {
|
||||||
entry: IndexEntry,
|
entry: IndexEntry,
|
||||||
manifest: Manifest,
|
manifest: Manifest,
|
||||||
|
/// The manifest exactly as served, so [`install`] can record it verbatim
|
||||||
|
/// without a second fetch. `None` when the manifest could not be read.
|
||||||
|
manifest_raw: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── feed vocabulary → Skald vocabulary ────────────────────────────────────────
|
// ── feed vocabulary → Skald vocabulary ────────────────────────────────────────
|
||||||
@@ -373,12 +409,17 @@ async fn fetch_feed() -> Result<Vec<Hydrated>, ApiError> {
|
|||||||
set.spawn(async move {
|
set.spawn(async move {
|
||||||
let url = format!("{}/{}/connector.json", base, folder_of(&entry));
|
let url = format!("{}/{}/connector.json", base, folder_of(&entry));
|
||||||
// A manifest that fails to load degrades that one card to whatever the
|
// A manifest that fails to load degrades that one card to whatever the
|
||||||
// index said; it never fails the whole listing.
|
// index said; it never fails the whole listing. The raw text is kept
|
||||||
let manifest = match HTTP.get(&url).send().await {
|
// alongside the parsed form so `install` can record what was served
|
||||||
Ok(r) => r.json::<Manifest>().await.unwrap_or_default(),
|
// even if some field of it did not parse.
|
||||||
Err(_) => Manifest::default(),
|
let (manifest, manifest_raw) = match HTTP.get(&url).send().await {
|
||||||
|
Ok(r) => match r.text().await {
|
||||||
|
Ok(t) => (serde_json::from_str::<Manifest>(&t).unwrap_or_default(), Some(t)),
|
||||||
|
Err(_) => (Manifest::default(), None),
|
||||||
|
},
|
||||||
|
Err(_) => (Manifest::default(), None),
|
||||||
};
|
};
|
||||||
Hydrated { entry, manifest }
|
Hydrated { entry, manifest, manifest_raw }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,10 +589,10 @@ pub struct InstallBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Imports a feed entry into `mcp_catalog` — the act that moves a connector from
|
/// Imports a feed entry into `mcp_catalog` — the act that moves a connector from
|
||||||
/// "someone else vetted this" to "this household's admin accepted it". For an
|
/// "someone else vetted this" to "this household's admin accepted it". It downloads
|
||||||
/// `mcp_local` entry it first downloads and hash-verifies the scripts into
|
/// and hash-verifies the connector's folder into `./connectors/<id>/`; for an
|
||||||
/// `./scripts/<id>/`, which is code landing on the box and therefore needs
|
/// `mcp_local` entry that folder holds code which will run on this box, and
|
||||||
/// `mcp.register_local_script` (§14) on top of `mcp.manage_catalog`.
|
/// therefore needs `mcp.register_local_script` (§14) on top of `mcp.manage_catalog`.
|
||||||
///
|
///
|
||||||
/// Installing does **not** activate: a global entry still needs the admin to
|
/// Installing does **not** activate: a global entry still needs the admin to
|
||||||
/// enable it with a key, a per-user one still needs each user to activate it.
|
/// enable it with a key, a per-user one still needs each user to activate it.
|
||||||
@@ -579,9 +620,25 @@ pub async fn install(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download + verify before touching the catalog, so a failed digest leaves no
|
// Download + verify before touching the catalog, so a failed digest leaves no
|
||||||
// trace of a half-installed connector.
|
// trace of a half-installed connector. A `local_script` always downloads its
|
||||||
let (script_path, verified) = if source == "local_script" {
|
// files; a `remote` connector downloads them only if it declares a `verify`
|
||||||
let files = download_verified(&h.entry, &h.manifest).await?;
|
// step that references a script (otherwise there is nothing to fetch).
|
||||||
|
let verify_command = h.manifest.verify.as_ref().map(|v| v.command.clone());
|
||||||
|
let verify_timeout = h.manifest.verify.as_ref().and_then(|v| v.timeout_secs);
|
||||||
|
let all_files = files_of(&h.entry, &h.manifest);
|
||||||
|
let verify_script_rel = verify_command
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|c| verify_script_of(c, &all_files));
|
||||||
|
let verify_script_path = verify_script_rel
|
||||||
|
.as_ref()
|
||||||
|
.map(|f| format!("{}/{}", body.id, f));
|
||||||
|
|
||||||
|
// Every source downloads its folder now — a remote connector has an icon and a
|
||||||
|
// manifest to record even when it has no code to run here.
|
||||||
|
let installed =
|
||||||
|
download_verified(&h.entry, &h.manifest, h.manifest_raw.as_deref(), &source).await?;
|
||||||
|
|
||||||
|
let script_path = if source == "local_script" {
|
||||||
let entry_file = cfg
|
let entry_file = cfg
|
||||||
.args
|
.args
|
||||||
.first()
|
.first()
|
||||||
@@ -590,9 +647,9 @@ pub async fn install(
|
|||||||
"manifest has no mcp_config.args[0] naming the script to run",
|
"manifest has no mcp_config.args[0] naming the script to run",
|
||||||
))?;
|
))?;
|
||||||
let entry_file = safe_rel_path(&entry_file)?.to_string();
|
let entry_file = safe_rel_path(&entry_file)?.to_string();
|
||||||
(Some(format!("{}/{}", body.id, entry_file)), files)
|
Some(format!("{}/{}", body.id, entry_file))
|
||||||
} else {
|
} else {
|
||||||
(None, 0)
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let args_json = if source == "local_script" {
|
let args_json = if source == "local_script" {
|
||||||
@@ -605,9 +662,23 @@ pub async fn install(
|
|||||||
serde_json::to_string(&cfg.args).ok()
|
serde_json::to_string(&cfg.args).ok()
|
||||||
};
|
};
|
||||||
|
|
||||||
// `requires` is the feed's coarse precondition list; the activation UI needs
|
// The full env/secret schema for the activation form. The manifest's top-level
|
||||||
// the concrete env keys, which only the manifest's mcp_config knows.
|
// `env[]` (array of objects with label/description/required/secret/…) is the
|
||||||
let config_schema: Vec<String> = cfg.env.keys().cloned().collect();
|
// source of truth; if a feed only ships the old `mcp_config.env` placeholder
|
||||||
|
// map, fall back to bare key names so the form still renders.
|
||||||
|
let config_schema_json = if !h.manifest.env.is_empty() {
|
||||||
|
serde_json::to_string(&h.manifest.env).ok()
|
||||||
|
} else if !cfg.env.is_empty() {
|
||||||
|
let names: Vec<String> = cfg.env.keys().cloned().collect();
|
||||||
|
serde_json::to_string(&names).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let _ = verify_timeout; // surfaced to the runtime via the verify module's default
|
||||||
|
|
||||||
|
let folder = folder_of(&h.entry);
|
||||||
|
let icon_small_path = installed_icon(h.entry.icon_small.as_deref(), &folder, &installed);
|
||||||
|
let icon_large_path = installed_icon(h.entry.icon_large.as_deref(), &folder, &installed);
|
||||||
|
|
||||||
let id = mcp_catalog::upsert(
|
let id = mcp_catalog::upsert(
|
||||||
skald.db(),
|
skald.db(),
|
||||||
@@ -621,9 +692,23 @@ pub async fn install(
|
|||||||
env_json: if cfg.env.is_empty() { None } else { serde_json::to_string(&cfg.env).ok() },
|
env_json: if cfg.env.is_empty() { None } else { serde_json::to_string(&cfg.env).ok() },
|
||||||
url: cfg.url.as_deref(),
|
url: cfg.url.as_deref(),
|
||||||
script_path: script_path.as_deref(),
|
script_path: script_path.as_deref(),
|
||||||
config_schema_json: if config_schema.is_empty() { None } else { serde_json::to_string(&config_schema).ok() },
|
config_schema_json,
|
||||||
auth_kind: &norm_auth_kind(&h.entry, &h.manifest),
|
auth_kind: &norm_auth_kind(&h.entry, &h.manifest),
|
||||||
|
// OAuth wiring (§15): provider slug, the scopes shown at consent, and the
|
||||||
|
// credential delivery spec — all snapshotted so an activation is
|
||||||
|
// reproducible even if the feed later changes.
|
||||||
|
oauth_provider: h.manifest.auth.as_ref().and_then(|a| a.provider.as_deref()),
|
||||||
|
oauth_scopes_json: h.manifest.auth.as_ref()
|
||||||
|
.filter(|a| !a.scopes.is_empty())
|
||||||
|
.and_then(|a| serde_json::to_string(&a.scopes).ok()),
|
||||||
|
deliver_json: h.manifest.auth.as_ref()
|
||||||
|
.and_then(|a| a.deliver.as_ref())
|
||||||
|
.and_then(|d| serde_json::to_string(d).ok()),
|
||||||
role_filter: None,
|
role_filter: None,
|
||||||
|
verify_command: verify_command.as_deref(),
|
||||||
|
verify_script_path: verify_script_path.as_deref(),
|
||||||
|
icon_small_path: icon_small_path.as_deref(),
|
||||||
|
icon_large_path: icon_large_path.as_deref(),
|
||||||
friendly_name: h.entry.name.as_deref().or(h.manifest.name.as_deref()),
|
friendly_name: h.entry.name.as_deref().or(h.manifest.name.as_deref()),
|
||||||
// The LLM-facing blurb — this is the column `render_mcp_list` puts in
|
// The LLM-facing blurb — this is the column `render_mcp_list` puts in
|
||||||
// the prompt for `activate_tools()`, so the feed's
|
// the prompt for `activate_tools()`, so the feed's
|
||||||
@@ -641,17 +726,50 @@ pub async fn install(
|
|||||||
"name": h.entry.id,
|
"name": h.entry.id,
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
"source": source,
|
"source": source,
|
||||||
"files_verified": verified,
|
"files_verified": installed.verified,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads every file the manifest declares into `./scripts/<id>/`, refusing any
|
/// If a verify `command` references one of the connector's shipped files (by
|
||||||
|
/// basename match against `files[]`), return that file's relative path — the
|
||||||
|
/// caller stores `<id>/<file>` so the runtime resolves `./connectors/<id>/<file>`.
|
||||||
|
/// Returns `None` for inline commands (`curl …`) with no script file.
|
||||||
|
fn verify_script_of(command: &str, files: &[FileEntry]) -> Option<String> {
|
||||||
|
for f in files {
|
||||||
|
let basename = f.path.rsplit_once('/').map(|(_, b)| b).unwrap_or(&f.path);
|
||||||
|
if !basename.is_empty() && command.contains(basename) {
|
||||||
|
return Some(f.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`download_verified`] put on disk.
|
||||||
|
struct Installed {
|
||||||
|
/// How many files were downloaded and matched their declared digest.
|
||||||
|
verified: usize,
|
||||||
|
/// The relative paths actually written, so the caller can record a manifest
|
||||||
|
/// claim (an icon, say) only once the file backing it exists.
|
||||||
|
files: std::collections::HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads a connector's whole folder into `./connectors/<id>/`, refusing any file
|
||||||
/// whose SHA-256 does not match. All-or-nothing: files are verified in memory and
|
/// whose SHA-256 does not match. All-or-nothing: files are verified in memory and
|
||||||
/// only written once every digest checks out, so a tampered feed never leaves a
|
/// only written once every digest checks out, so a tampered feed never leaves a
|
||||||
/// partial connector on disk. Returns how many files were verified.
|
/// partial connector on disk.
|
||||||
async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<usize, ApiError> {
|
///
|
||||||
|
/// Runs for **every** source, not just `local_script`. Fetching an icon is not the
|
||||||
|
/// §14 risk axis — that axis is about code the box will *execute*, and it stays
|
||||||
|
/// gated on `mcp.register_local_script` in [`install`]. What lands here for a remote
|
||||||
|
/// connector is inert: an icon and the manifest.
|
||||||
|
async fn download_verified(
|
||||||
|
entry: &IndexEntry,
|
||||||
|
manifest: &Manifest,
|
||||||
|
raw: Option<&str>,
|
||||||
|
source: &str,
|
||||||
|
) -> Result<Installed, ApiError> {
|
||||||
let files = files_of(entry, manifest);
|
let files = files_of(entry, manifest);
|
||||||
if files.is_empty() {
|
if files.is_empty() && source == "local_script" {
|
||||||
return Err(ApiError::bad_request(
|
return Err(ApiError::bad_request(
|
||||||
"the feed declares no `files` with digests for this connector — \
|
"the feed declares no `files` with digests for this connector — \
|
||||||
refusing to install unverifiable code (§14)",
|
refusing to install unverifiable code (§14)",
|
||||||
@@ -660,15 +778,16 @@ async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<us
|
|||||||
|
|
||||||
let base = base_url();
|
let base = base_url();
|
||||||
let folder = folder_of(entry);
|
let folder = folder_of(entry);
|
||||||
|
let index_digests = !entry.files.is_empty();
|
||||||
let mut staged: Vec<(String, Vec<u8>)> = Vec::new();
|
let mut staged: Vec<(String, Vec<u8>)> = Vec::new();
|
||||||
|
|
||||||
for f in files {
|
for f in files {
|
||||||
let rel = safe_rel_path(&f.path)?;
|
let rel = safe_rel_path(&f.path)?;
|
||||||
|
|
||||||
// Defensive: a document can never carry its own digest (writing the hash
|
// A document can never carry its own digest (writing the hash into the file
|
||||||
// changes the file), so a self-entry is unverifiable by construction. The
|
// changes the file), so a *manifest*-declared self-entry is unverifiable by
|
||||||
// feed now keeps digests in the index, where this cannot arise.
|
// construction. From the index it is verifiable, and gets no exception.
|
||||||
if rel == "connector.json" {
|
if rel == skald_core::mcp::MANIFEST_FILE && !index_digests {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,19 +822,19 @@ async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<us
|
|||||||
staged.push((rel.to_string(), bytes.to_vec()));
|
staged.push((rel.to_string(), bytes.to_vec()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if staged.is_empty() {
|
if staged.is_empty() && source == "local_script" {
|
||||||
return Err(ApiError::bad_request(
|
return Err(ApiError::bad_request(
|
||||||
"manifest declares no installable file besides connector.json",
|
"manifest declares no installable file besides connector.json",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let wd = std::env::current_dir()
|
let dest = skald_core::mcp::connector_dir(&entry.id)
|
||||||
.map_err(|e| ApiError::bad_request(format!("cannot resolve working directory: {e}")))?;
|
.map_err(|e| ApiError::bad_request(format!("cannot resolve the connectors dir: {e}")))?;
|
||||||
let dest = wd.join("scripts").join(&entry.id);
|
|
||||||
std::fs::create_dir_all(&dest)
|
std::fs::create_dir_all(&dest)
|
||||||
.map_err(|e| ApiError::bad_request(format!("cannot create {}: {e}", dest.display())))?;
|
.map_err(|e| ApiError::bad_request(format!("cannot create {}: {e}", dest.display())))?;
|
||||||
|
|
||||||
let count = staged.len();
|
let verified = staged.len();
|
||||||
|
let mut written: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
for (rel, bytes) in staged {
|
for (rel, bytes) in staged {
|
||||||
let path = dest.join(&rel);
|
let path = dest.join(&rel);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -724,8 +843,36 @@ async fn download_verified(entry: &IndexEntry, manifest: &Manifest) -> Result<us
|
|||||||
}
|
}
|
||||||
std::fs::write(&path, &bytes)
|
std::fs::write(&path, &bytes)
|
||||||
.map_err(|e| ApiError::bad_request(format!("cannot write `{rel}`: {e}")))?;
|
.map_err(|e| ApiError::bad_request(format!("cannot write `{rel}`: {e}")))?;
|
||||||
|
written.insert(rel);
|
||||||
}
|
}
|
||||||
Ok(count)
|
|
||||||
|
// The manifest, recorded verbatim. The feed does not digest it (it lists only
|
||||||
|
// the folder's other files), and it does not need to be: nothing ever reads this
|
||||||
|
// file back — `mcp_catalog` drives every connect. Its one job is to say what was
|
||||||
|
// served on the day the admin accepted it, and for that job a tampered copy is
|
||||||
|
// still the truth of what we accepted. See the module header on what digests do
|
||||||
|
// and do not buy.
|
||||||
|
if !written.contains(skald_core::mcp::MANIFEST_FILE) {
|
||||||
|
if let Some(raw) = raw {
|
||||||
|
std::fs::write(dest.join(skald_core::mcp::MANIFEST_FILE), raw).map_err(|e| {
|
||||||
|
ApiError::bad_request(format!("cannot record connector.json: {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Installed { verified, files: written })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The icon path to record in the catalog: the manifest's feed-root-relative claim
|
||||||
|
/// (`gmail/icon_sm.svg`) reduced to a path inside the connector's own folder
|
||||||
|
/// (`icon_sm.svg`), and only if that file actually got installed.
|
||||||
|
///
|
||||||
|
/// The two vocabularies differ — the index names icons from the feed root but names
|
||||||
|
/// `files[]` from the folder — so this is where they are reconciled.
|
||||||
|
fn installed_icon(claim: Option<&str>, folder: &str, installed: &Installed) -> Option<String> {
|
||||||
|
let claim = claim?.trim_start_matches('/');
|
||||||
|
let rel = claim.strip_prefix(&format!("{folder}/")).unwrap_or(claim);
|
||||||
|
installed.files.contains(rel).then(|| rel.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -837,6 +984,35 @@ mod tests {
|
|||||||
assert_eq!(files_of(&entry(r#"{"id":"x"}"#), &m)[0].path, "b.py");
|
assert_eq!(files_of(&entry(r#"{"id":"x"}"#), &m)[0].path, "b.py");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The index names icons from the feed root (`gmail/icon_sm.svg`) but names
|
||||||
|
/// `files[]` from the connector's folder (`icon_sm.svg`). Recording the wrong one
|
||||||
|
/// would 404 every icon, so this is where the two vocabularies must meet.
|
||||||
|
#[test]
|
||||||
|
fn icon_paths_are_reduced_to_the_connector_folder() {
|
||||||
|
let installed = Installed {
|
||||||
|
verified: 2,
|
||||||
|
files: ["icon_sm.svg", "server.py"].iter().map(|s| s.to_string()).collect(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
installed_icon(Some("gmail/icon_sm.svg"), "gmail", &installed),
|
||||||
|
Some("icon_sm.svg".to_string())
|
||||||
|
);
|
||||||
|
// A leading slash is still feed-root-relative.
|
||||||
|
assert_eq!(
|
||||||
|
installed_icon(Some("/gmail/icon_sm.svg"), "gmail", &installed),
|
||||||
|
Some("icon_sm.svg".to_string())
|
||||||
|
);
|
||||||
|
// Already folder-relative: left alone.
|
||||||
|
assert_eq!(
|
||||||
|
installed_icon(Some("icon_sm.svg"), "gmail", &installed),
|
||||||
|
Some("icon_sm.svg".to_string())
|
||||||
|
);
|
||||||
|
// Claimed but never installed → recorded as absent, so the endpoint says
|
||||||
|
// "no icon" instead of pointing the browser at a file that is not there.
|
||||||
|
assert_eq!(installed_icon(Some("gmail/icon_lg.svg"), "gmail", &installed), None);
|
||||||
|
assert_eq!(installed_icon(None, "gmail", &installed), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sha256_matches_a_known_vector() {
|
fn sha256_matches_a_known_vector() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -845,6 +1021,71 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Installs every connector the live feed offers into a throwaway directory and
|
||||||
|
/// checks what actually lands: the digests hold, the manifest is recorded, and the
|
||||||
|
/// icon path stored in the catalog names a file that exists.
|
||||||
|
///
|
||||||
|
/// That last one is the whole point of the icon column — a path that does not
|
||||||
|
/// resolve would 404 silently in the browser and look like "this connector has no
|
||||||
|
/// icon", which is exactly the failure a unit test with a hand-written feed cannot
|
||||||
|
/// see. `#[ignore]`d (network + it moves the process cwd, so it must run alone):
|
||||||
|
/// `cargo test --bin skald -- --ignored live_feed_installs`.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn live_feed_installs_folder_with_icon_and_manifest() {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let tmp = std::env::temp_dir().join(format!("skald-install-test-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
std::env::set_current_dir(&tmp).unwrap();
|
||||||
|
|
||||||
|
let feed = match fetch_feed().await {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => panic!("feed unreachable: {}", e.message),
|
||||||
|
};
|
||||||
|
assert!(!feed.is_empty(), "feed returned no connectors");
|
||||||
|
|
||||||
|
for h in &feed {
|
||||||
|
let source = norm_source(&h.entry, &h.manifest);
|
||||||
|
let installed =
|
||||||
|
download_verified(&h.entry, &h.manifest, h.manifest_raw.as_deref(), &source)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("`{}` failed to install: {}", h.entry.id, e.message));
|
||||||
|
|
||||||
|
let dir = skald_core::mcp::connector_dir(&h.entry.id).unwrap();
|
||||||
|
let folder = folder_of(&h.entry);
|
||||||
|
|
||||||
|
// Every source installs now — a remote connector included, which is what
|
||||||
|
// gives Tavily an icon at all.
|
||||||
|
assert!(dir.is_dir(), "`{}` installed no folder", h.entry.id);
|
||||||
|
|
||||||
|
// The manifest is recorded even though the feed does not digest it.
|
||||||
|
assert!(
|
||||||
|
dir.join(skald_core::mcp::MANIFEST_FILE).is_file(),
|
||||||
|
"`{}` recorded no connector.json",
|
||||||
|
h.entry.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// The icon path the catalog would store must name a real file.
|
||||||
|
for (size, claim) in [("sm", &h.entry.icon_small), ("lg", &h.entry.icon_large)] {
|
||||||
|
let Some(rel) = installed_icon(claim.as_deref(), &folder, &installed) else {
|
||||||
|
panic!("`{}` declares a {size} icon the installer did not keep", h.entry.id);
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
dir.join(&rel).is_file(),
|
||||||
|
"`{}` {size} icon `{rel}` is not on disk",
|
||||||
|
h.entry.id
|
||||||
|
);
|
||||||
|
// An icon must never be shipped into a user's container.
|
||||||
|
assert!(skald_core::mcp::install::is_host_asset(&rel), "`{rel}` should be a host asset");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{:<8} {} files verified, icon + manifest on disk", h.entry.id, installed.verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// Hits the real feed. `#[ignore]`d so the suite stays offline-clean; run with
|
/// Hits the real feed. `#[ignore]`d so the suite stays offline-clean; run with
|
||||||
/// `cargo test --bin skald -- --ignored live_feed`.
|
/// `cargo test --bin skald -- --ignored live_feed`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+647
-62
@@ -17,7 +17,7 @@ use axum::Json;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use skald_core::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, role_capabilities};
|
use skald_core::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities};
|
||||||
use skald_core::skald::Skald;
|
use skald_core::skald::Skald;
|
||||||
|
|
||||||
use super::guard::AuthUser;
|
use super::guard::AuthUser;
|
||||||
@@ -40,31 +40,217 @@ fn to_json_opt<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
|
|||||||
v.as_ref().and_then(|x| serde_json::to_string(x).ok())
|
v.as_ref().and_then(|x| serde_json::to_string(x).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copies a vetted catalog script from `./scripts/<script_path>` into the user's
|
/// Installs the connector folder that `script_path` (`<connector>/<file>`) belongs
|
||||||
/// bind-mounted home under `.skald/mcp/<name>/`, and returns the path it will have
|
/// to into the caller's container home, and returns the path the entry file will
|
||||||
/// INSIDE the container (`/root/.skald/mcp/...`). The home is the only durable
|
/// have INSIDE the container.
|
||||||
/// zone (§6), so the script survives a container recreate. Single files only for
|
///
|
||||||
/// now — directory-tree scripts (e.g. whatsapp_mcp/) are a follow-up.
|
/// The whole folder travels, not just the entry file — which is what finally gets a
|
||||||
fn copy_script_into_home(user_id: &str, name: &str, script_path: &str) -> Result<String, ApiError> {
|
/// connector's `requirements.txt` and its multi-file trees to where the server
|
||||||
let wd = std::env::current_dir()
|
/// actually runs. The home is the only durable zone (§6), so it survives a
|
||||||
.map_err(|e| ApiError::bad_request(format!("cannot resolve working directory: {e}")))?;
|
/// container recreate.
|
||||||
let src = wd.join("scripts").join(script_path);
|
fn install_connector_for_user(
|
||||||
if !src.is_file() {
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
script_path: &str,
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
let (folder, entry_file) = skald_core::mcp::split_script_path(script_path)
|
||||||
|
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||||
|
let dir = skald_core::mcp::install_into_home(user_id, name, folder)
|
||||||
|
.map_err(|e| ApiError::bad_request(format!("failed to install connector files: {e}")))?
|
||||||
|
.ok_or_else(|| ApiError::bad_request(format!(
|
||||||
|
"connector `{folder}` has no installed files under ./{}/ — \
|
||||||
|
reinstall it from the marketplace",
|
||||||
|
skald_core::mcp::CONNECTORS_DIR,
|
||||||
|
)))?;
|
||||||
|
Ok(dir.join(entry_file).to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── verify-before-save helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// One entry of the catalog's `config_schema_json` (the marketplace `env[]`).
|
||||||
|
/// Drives both the activation form (frontend) and the env/secret split (here).
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct EnvSchemaEntry {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)] secret: bool,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[serde(default)] required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the catalog's `config_schema_json` into schema entries. Tolerates the
|
||||||
|
/// legacy `["KEY1","KEY2"]` shape (treated as non-secret) and the new object
|
||||||
|
/// array; anything unparseable yields an empty schema.
|
||||||
|
fn parse_env_schema(config_schema_json: &Option<String>) -> Vec<EnvSchemaEntry> {
|
||||||
|
let raw = match config_schema_json.as_deref() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
// Object-array form (the new manifest `env[]`).
|
||||||
|
if let Ok(v) = serde_json::from_str::<Vec<EnvSchemaEntry>>(raw) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
// Legacy bare-name form.
|
||||||
|
serde_json::from_str::<Vec<String>>(raw)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| EnvSchemaEntry { name, secret: false, required: false })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits the form values into non-secret (`env`) and secret (`secret`) maps,
|
||||||
|
/// the two channels [`run_verify`] substitutes into `{ENV:…}` / `{SECRET:…}`.
|
||||||
|
///
|
||||||
|
/// When `auth_kind == "api_key"`, the supplied `api_key` is also injected under
|
||||||
|
/// every secret-name declared by the schema — the canonical case being Tavily,
|
||||||
|
/// whose schema names the key `tavilyApiKey` and whose URL carries the matching
|
||||||
|
/// `{SECRET:tavilyApiKey}` token.
|
||||||
|
fn split_form_values(
|
||||||
|
form: Option<&HashMap<String, String>>,
|
||||||
|
api_key: Option<&str>,
|
||||||
|
schema: &[EnvSchemaEntry],
|
||||||
|
auth_kind: &str,
|
||||||
|
) -> (HashMap<String, String>, HashMap<String, String>) {
|
||||||
|
let secret_names: std::collections::HashSet<&str> =
|
||||||
|
schema.iter().filter(|e| e.secret).map(|e| e.name.as_str()).collect();
|
||||||
|
|
||||||
|
let mut env = HashMap::new();
|
||||||
|
let mut secret = HashMap::new();
|
||||||
|
if let Some(form) = form {
|
||||||
|
for (k, v) in form {
|
||||||
|
if secret_names.contains(k.as_str()) {
|
||||||
|
secret.insert(k.clone(), v.clone());
|
||||||
|
} else {
|
||||||
|
env.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An api_key connector maps the key into every declared secret name that the
|
||||||
|
// form did not already fill (the UI's api_key box is the same value).
|
||||||
|
if auth_kind == "api_key" {
|
||||||
|
if let Some(key) = api_key {
|
||||||
|
for name in secret_names {
|
||||||
|
secret.entry(name.to_string()).or_insert_with(|| key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(env, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs the connector folder holding a catalog entry's verify script into the
|
||||||
|
/// user's home, returning the in-container directory to run it from. `None` if the
|
||||||
|
/// entry declares no verify script.
|
||||||
|
///
|
||||||
|
/// This runs on the Test button too, before any activation exists — which is why it
|
||||||
|
/// installs rather than assuming the folder is already there.
|
||||||
|
fn prepare_user_verify_workdir(
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
entry: &mcp_catalog::McpCatalogRow,
|
||||||
|
) -> Result<Option<std::path::PathBuf>, ApiError> {
|
||||||
|
let verify_path = match &entry.verify_script_path {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
let (folder, _) = skald_core::mcp::split_script_path(verify_path)
|
||||||
|
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||||
|
let dir = skald_core::mcp::install_into_home(user_id, name, folder)
|
||||||
|
.map_err(|e| ApiError::bad_request(format!("failed to install connector files: {e}")))?
|
||||||
|
.ok_or_else(|| ApiError::bad_request(format!(
|
||||||
|
"connector `{folder}` has no installed files — reinstall it from the marketplace"
|
||||||
|
)))?;
|
||||||
|
Ok(Some(dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host working dir for a global connector's verify script — the
|
||||||
|
/// `./connectors/<catalog_name>/` directory the marketplace installer populated.
|
||||||
|
fn global_verify_workdir(catalog_name: &str) -> Result<std::path::PathBuf, ApiError> {
|
||||||
|
let dir = skald_core::mcp::connector_dir(catalog_name)
|
||||||
|
.map_err(|e| ApiError::bad_request(format!("cannot resolve the connectors dir: {e}")))?;
|
||||||
|
if !dir.is_dir() {
|
||||||
return Err(ApiError::bad_request(format!(
|
return Err(ApiError::bad_request(format!(
|
||||||
"catalog script `scripts/{script_path}` not found or not a file \
|
"connector directory `{}/{catalog_name}` not found — reinstall the connector",
|
||||||
(directory-tree scripts are not supported yet)"
|
skald_core::mcp::CONNECTORS_DIR,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let basename = src.file_name()
|
Ok(dir)
|
||||||
.ok_or_else(|| ApiError::bad_request("invalid script_path"))?
|
}
|
||||||
.to_string_lossy().to_string();
|
|
||||||
let dest_dir = wd.join(skald_core::container::HOMES_DIR)
|
/// Default timeout for a connector-declared verify step. (The manifest can also
|
||||||
.join(user_id).join(".skald").join("mcp").join(name);
|
/// carry `verify.timeout_secs`; wiring that through is a follow-up.)
|
||||||
std::fs::create_dir_all(&dest_dir)
|
const VERIFY_TIMEOUT_SECS: u64 = 20;
|
||||||
.map_err(|e| ApiError::bad_request(format!("failed to create script dir: {e}")))?;
|
|
||||||
std::fs::copy(&src, dest_dir.join(&basename))
|
// ── connector icons ───────────────────────────────────────────────────────────
|
||||||
.map_err(|e| ApiError::bad_request(format!("failed to copy script: {e}")))?;
|
|
||||||
Ok(format!("/root/.skald/mcp/{name}/{basename}"))
|
#[derive(Deserialize)]
|
||||||
|
pub struct IconQuery {
|
||||||
|
/// `sm` (default) | `lg`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/mcp/catalog/{name}/icon?size=sm|lg` — the icon of an **installed**
|
||||||
|
/// connector, served off `./connectors/<name>/`.
|
||||||
|
///
|
||||||
|
/// Authenticated but deliberately **not** capability-gated: seeing the icon of a
|
||||||
|
/// connector you are allowed to activate is not an administrative act. The
|
||||||
|
/// marketplace's own icon endpoint cannot do this job — it proxies the live feed
|
||||||
|
/// behind `manage_catalog`, so a normal user gets a 403, and the image would vanish
|
||||||
|
/// the moment the feed went down or the entry was pulled upstream. Once installed,
|
||||||
|
/// the bytes are ours.
|
||||||
|
pub async fn catalog_icon(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(_auth): Extension<AuthUser>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<IconQuery>,
|
||||||
|
) -> Result<axum::response::Response, ApiError> {
|
||||||
|
use axum::http::header;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
|
let entry = mcp_catalog::get_by_name(skald.db(), &name).await?
|
||||||
|
.ok_or_else(|| ApiError::not_found(format!("no catalog entry `{name}`")))?;
|
||||||
|
|
||||||
|
let large = q.size.as_deref() == Some("lg");
|
||||||
|
let rel = if large {
|
||||||
|
entry.icon_large_path.clone().or_else(|| entry.icon_small_path.clone())
|
||||||
|
} else {
|
||||||
|
entry.icon_small_path.clone().or_else(|| entry.icon_large_path.clone())
|
||||||
|
}
|
||||||
|
.ok_or_else(|| ApiError::not_found("this connector has no installed icon"))?;
|
||||||
|
|
||||||
|
// Containment, mirroring `tools::fs::resolve_host_path`: canonicalize and
|
||||||
|
// prefix-check, fail-closed. `rel` only ever holds a path the installer already
|
||||||
|
// proved safe, and `name` only ever names a real catalog row — this is the belt
|
||||||
|
// to those braces, and the reason a bad row cannot turn into an arbitrary read.
|
||||||
|
let dir = skald_core::mcp::connector_dir(&entry.name)
|
||||||
|
.map_err(|e| ApiError::bad_request(format!("cannot resolve the connectors dir: {e}")))?;
|
||||||
|
let base = dir.canonicalize()
|
||||||
|
.map_err(|_| ApiError::not_found("this connector has no installed files"))?;
|
||||||
|
let path = base.join(&rel).canonicalize()
|
||||||
|
.map_err(|_| ApiError::not_found("icon file is missing — reinstall the connector"))?;
|
||||||
|
if !path.starts_with(&base) {
|
||||||
|
return Err(ApiError::forbidden("icon path escapes the connector directory"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path)
|
||||||
|
.map_err(|e| ApiError::bad_request(format!("cannot read icon: {e}")))?;
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
|
||||||
|
let ct = skald_core::mcp::content_type_for_ext(&ext);
|
||||||
|
|
||||||
|
// An icon is untrusted bytes from a remote feed, and half of them are SVGs —
|
||||||
|
// a format that can carry script. Served from our own origin, that would be
|
||||||
|
// stored XSS for anyone who opened the URL directly. `nosniff` pins the type,
|
||||||
|
// and the CSP neuters any script or fetch the document tries to perform.
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, ct.to_string()),
|
||||||
|
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
|
||||||
|
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'".to_string()),
|
||||||
|
(header::CACHE_CONTROL, "private, max-age=3600".to_string()),
|
||||||
|
],
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── existing: running-server introspection ────────────────────────────────────
|
// ── existing: running-server introspection ────────────────────────────────────
|
||||||
@@ -100,6 +286,8 @@ pub struct CatalogUpsertBody {
|
|||||||
#[serde(default = "default_none_auth")]
|
#[serde(default = "default_none_auth")]
|
||||||
pub auth_kind: String,
|
pub auth_kind: String,
|
||||||
pub role_filter: Option<Vec<String>>,
|
pub role_filter: Option<Vec<String>>,
|
||||||
|
pub verify_command: Option<String>,
|
||||||
|
pub verify_script_path: Option<String>,
|
||||||
pub friendly_name: Option<String>,
|
pub friendly_name: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -130,7 +318,18 @@ pub async fn catalog_upsert(
|
|||||||
script_path: body.script_path.as_deref(),
|
script_path: body.script_path.as_deref(),
|
||||||
config_schema_json: to_json_opt(&body.config_schema),
|
config_schema_json: to_json_opt(&body.config_schema),
|
||||||
auth_kind: &body.auth_kind,
|
auth_kind: &body.auth_kind,
|
||||||
|
// OAuth catalog entries come from the vetted feed (marketplace install),
|
||||||
|
// not the admin's manual form — so these stay unset here.
|
||||||
|
oauth_provider: None,
|
||||||
|
oauth_scopes_json: None,
|
||||||
|
deliver_json: None,
|
||||||
role_filter: to_json_opt(&body.role_filter),
|
role_filter: to_json_opt(&body.role_filter),
|
||||||
|
verify_command: body.verify_command.as_deref(),
|
||||||
|
verify_script_path: body.verify_script_path.as_deref(),
|
||||||
|
// Not the admin form's to set: the installer owns them, and `upsert`
|
||||||
|
// COALESCEs these away rather than blanking an installed connector's icons.
|
||||||
|
icon_small_path: None,
|
||||||
|
icon_large_path: None,
|
||||||
friendly_name: body.friendly_name.as_deref(),
|
friendly_name: body.friendly_name.as_deref(),
|
||||||
description: body.description.as_deref(),
|
description: body.description.as_deref(),
|
||||||
}).await?;
|
}).await?;
|
||||||
@@ -147,6 +346,71 @@ pub async fn catalog_delete(
|
|||||||
Ok(Json(json!({ "ok": true })))
|
Ok(Json(json!({ "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── admin: OAuth providers (§15) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The OAuth identity providers, **without** client secrets (never leaves the
|
||||||
|
/// process for the browser).
|
||||||
|
pub async fn providers_list(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
) -> Result<Json<Vec<oauth_providers::OauthProviderView>>, ApiError> {
|
||||||
|
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
|
||||||
|
let rows = oauth_providers::list(skald.db()).await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ProviderUpsertBody {
|
||||||
|
pub name: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub auth_url: String,
|
||||||
|
pub token_url: String,
|
||||||
|
pub client_id: String,
|
||||||
|
/// Empty keeps the stored secret — the list view never gave it back, so editing
|
||||||
|
/// the URLs must not force the admin to re-paste it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_secret: String,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
pub extra_params: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn providers_upsert(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
Json(body): Json<ProviderUpsertBody>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
|
||||||
|
// `extra_params` must be valid JSON — it is merged into the consent URL, and a
|
||||||
|
// malformed value would silently drop `access_type`/`prompt` and cost the user a
|
||||||
|
// refresh token. Reject it here rather than fail quietly at sign-in.
|
||||||
|
if let Some(extra) = body.extra_params.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||||
|
serde_json::from_str::<HashMap<String, String>>(extra)
|
||||||
|
.map_err(|e| ApiError::bad_request(format!("extra_params is not a JSON object of strings: {e}")))?;
|
||||||
|
}
|
||||||
|
let extra = body.extra_params.as_deref().filter(|s| !s.trim().is_empty());
|
||||||
|
oauth_providers::upsert(skald.db(), oauth_providers::UpsertProvider {
|
||||||
|
name: &body.name,
|
||||||
|
display_name: &body.display_name,
|
||||||
|
auth_url: &body.auth_url,
|
||||||
|
token_url: &body.token_url,
|
||||||
|
client_id: &body.client_id,
|
||||||
|
client_secret: &body.client_secret,
|
||||||
|
redirect_uri: &body.redirect_uri,
|
||||||
|
extra_params: extra,
|
||||||
|
}).await?;
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn providers_delete(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
|
||||||
|
oauth_providers::delete(skald.db(), &name).await?;
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
// ── admin: globally-active connectors + access ────────────────────────────────
|
// ── admin: globally-active connectors + access ────────────────────────────────
|
||||||
|
|
||||||
pub async fn global_list(
|
pub async fn global_list(
|
||||||
@@ -181,25 +445,37 @@ pub async fn global_enable(
|
|||||||
let name = body.name.clone().unwrap_or_else(|| entry.name.clone());
|
let name = body.name.clone().unwrap_or_else(|| entry.name.clone());
|
||||||
// Snapshot the concrete config from the catalog; the admin supplies the secret.
|
// Snapshot the concrete config from the catalog; the admin supplies the secret.
|
||||||
let id = mcp_global_servers::upsert(skald.db(), mcp_global_servers::UpsertGlobal {
|
let id = mcp_global_servers::upsert(skald.db(), mcp_global_servers::UpsertGlobal {
|
||||||
name: &name,
|
name: &name,
|
||||||
catalog_name: Some(&entry.name),
|
catalog_name: Some(&entry.name),
|
||||||
transport: &entry.transport,
|
transport: &entry.transport,
|
||||||
command: entry.command.as_deref(),
|
command: entry.command.as_deref(),
|
||||||
args_json: entry.args_json.clone(),
|
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()),
|
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()).or_else(|| entry.env_json.clone()),
|
||||||
url: entry.url.as_deref(),
|
url: entry.url.as_deref(),
|
||||||
api_key: body.api_key.as_deref(),
|
api_key: body.api_key.as_deref(),
|
||||||
friendly_name: entry.friendly_name.as_deref(),
|
verify_command: entry.verify_command.as_deref(),
|
||||||
description: entry.description.as_deref(),
|
verify_script_path: entry.verify_script_path.as_deref(),
|
||||||
|
friendly_name: entry.friendly_name.as_deref(),
|
||||||
|
description: entry.description.as_deref(),
|
||||||
}).await?;
|
}).await?;
|
||||||
|
|
||||||
|
// Verify the admin-supplied credentials before starting the server. A failure
|
||||||
|
// disables the row so it does not run with bad creds; the admin sees the
|
||||||
|
// message and can fix + re-enable. A connector with no verify step is allowed
|
||||||
|
// through unchanged.
|
||||||
|
let verify = run_verify_for_entry(&skald, &auth, &entry, &name, body.env.as_ref(), body.api_key.as_deref()).await?;
|
||||||
|
if !verify.skipped && !verify.ok {
|
||||||
|
mcp_global_servers::set_enabled(skald.db(), id, false).await?;
|
||||||
|
return Ok(Json(json!({ "id": id, "verify": verify, "error": verify.message })));
|
||||||
|
}
|
||||||
|
|
||||||
// Start it now in the global runtime (host transport).
|
// Start it now in the global runtime (host transport).
|
||||||
let row = mcp_global_servers::get(skald.db(), id).await?
|
let row = mcp_global_servers::get(skald.db(), id).await?
|
||||||
.ok_or_else(|| ApiError::bad_request("global server vanished after upsert"))?;
|
.ok_or_else(|| ApiError::bad_request("global server vanished after upsert"))?;
|
||||||
let spec = skald_core::mcp::global_row_spec(&row);
|
let spec = skald_core::mcp::global_row_spec(&row);
|
||||||
match skald.mcp().start_server(spec).await {
|
match skald.mcp().start_server(spec).await {
|
||||||
Ok(tools) => Ok(Json(json!({ "id": id, "tools": tools }))),
|
Ok(tools) => Ok(Json(json!({ "id": id, "tools": tools, "verify": verify }))),
|
||||||
Err(e) => Ok(Json(json!({ "id": id, "error": e.to_string() }))),
|
Err(e) => Ok(Json(json!({ "id": id, "error": e.to_string(), "verify": verify }))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +492,98 @@ pub async fn global_delete(
|
|||||||
Ok(Json(json!({ "ok": true })))
|
Ok(Json(json!({ "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs a catalog entry's verify step in the right target — inside the caller's
|
||||||
|
/// container for a `per_user` local_script, on the host for a `global` remote.
|
||||||
|
/// Returns [`VerifyReport::skipped`] when the entry declares no verify step, so
|
||||||
|
/// callers can treat "no test" uniformly.
|
||||||
|
///
|
||||||
|
/// `runtime_name` is the in-container directory key (the user's chosen runtime
|
||||||
|
/// name for an activation, or the catalog name for a standalone Test).
|
||||||
|
async fn run_verify_for_entry(
|
||||||
|
skald: &Skald,
|
||||||
|
auth: &AuthUser,
|
||||||
|
entry: &mcp_catalog::McpCatalogRow,
|
||||||
|
runtime_name: &str,
|
||||||
|
form: Option<&HashMap<String, String>>,
|
||||||
|
api_key: Option<&str>,
|
||||||
|
) -> Result<skald_core::mcp::VerifyReport, ApiError> {
|
||||||
|
use skald_core::mcp::{run_verify, VerifyReport, VerifyTarget};
|
||||||
|
|
||||||
|
let verify_command = match entry.verify_command.as_deref() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Ok(VerifyReport::skipped()),
|
||||||
|
};
|
||||||
|
let schema = parse_env_schema(&entry.config_schema_json);
|
||||||
|
let (env_values, secret_values) = split_form_values(form, api_key, &schema, &entry.auth_kind);
|
||||||
|
|
||||||
|
let report = match entry.scope.as_str() {
|
||||||
|
"per_user" => {
|
||||||
|
// `require_context` ensures the container exists before we exec into it.
|
||||||
|
let _ctx = require_context(skald, &auth.user_id).await?;
|
||||||
|
let workdir = prepare_user_verify_workdir(&auth.user_id, runtime_name, entry)?
|
||||||
|
.ok_or_else(|| ApiError::bad_request(
|
||||||
|
"catalog entry declares verify_command but no verify_script_path",
|
||||||
|
))?;
|
||||||
|
let container = skald_core::container::container_name(&auth.user_id);
|
||||||
|
run_verify(
|
||||||
|
verify_command,
|
||||||
|
&env_values,
|
||||||
|
&secret_values,
|
||||||
|
VerifyTarget::Container { container: &container, workdir: &workdir },
|
||||||
|
VERIFY_TIMEOUT_SECS,
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
"global" => {
|
||||||
|
require_cap(skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
|
||||||
|
let workdir = global_verify_workdir(&entry.name)?;
|
||||||
|
run_verify(
|
||||||
|
verify_command,
|
||||||
|
&env_values,
|
||||||
|
&secret_values,
|
||||||
|
VerifyTarget::Host { workdir: &workdir },
|
||||||
|
VERIFY_TIMEOUT_SECS,
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
other => return Err(ApiError::bad_request(format!("unknown catalog scope `{other}`"))),
|
||||||
|
};
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── user: test a connector without persisting ─────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TestBody {
|
||||||
|
/// The catalog entry whose credentials to probe.
|
||||||
|
pub catalog_name: String,
|
||||||
|
/// The form values the user just typed (env + secret mixed).
|
||||||
|
pub env: Option<HashMap<String, String>>,
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/mcp/test` — runs a connector's verify step with the supplied
|
||||||
|
/// credentials and returns the [`VerifyReport`] **without persisting anything**.
|
||||||
|
/// The frontend Test button calls this before offering Activate.
|
||||||
|
pub async fn test(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
Json(body): Json<TestBody>,
|
||||||
|
) -> Result<Json<skald_core::mcp::VerifyReport>, ApiError> {
|
||||||
|
let entry = mcp_catalog::get_by_name(skald.db(), &body.catalog_name).await?
|
||||||
|
.ok_or_else(|| ApiError::not_found(format!("no catalog entry `{}`", body.catalog_name)))?;
|
||||||
|
// Per-role gate, mirroring `activate`: a user may only test connectors
|
||||||
|
// their role is allowed to activate.
|
||||||
|
let user = skald_core::db::users::get(skald.db(), &auth.user_id).await?
|
||||||
|
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
|
||||||
|
if !entry.allowed_for_role(&user.role_id) {
|
||||||
|
return Err(ApiError::forbidden("your role may not use this connector"));
|
||||||
|
}
|
||||||
|
let report = run_verify_for_entry(
|
||||||
|
&skald, &auth, &entry, &entry.name,
|
||||||
|
body.env.as_ref(), body.api_key.as_deref(),
|
||||||
|
).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct GlobalAccessBody {
|
pub struct GlobalAccessBody {
|
||||||
/// The full set of user ids allowed to use this global connector.
|
/// The full set of user ids allowed to use this global connector.
|
||||||
@@ -369,29 +737,79 @@ pub async fn activate(
|
|||||||
let name = body.name.clone().unwrap_or_else(|| entry.name.clone());
|
let name = body.name.clone().unwrap_or_else(|| entry.name.clone());
|
||||||
reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?;
|
reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?;
|
||||||
|
|
||||||
// For a local script, copy it into the container home and point the
|
// For a local script, install its folder into the container home and
|
||||||
// command at the in-container path.
|
// point the command at the in-container path.
|
||||||
let (command, args_json, script_rel_path) = if entry.source == "local_script" {
|
let (command, args_json, script_rel_path) = if entry.source == "local_script" {
|
||||||
let script = entry.script_path.clone()
|
let script = entry.script_path.clone()
|
||||||
.ok_or_else(|| ApiError::bad_request("catalog local_script entry has no script_path"))?;
|
.ok_or_else(|| ApiError::bad_request("catalog local_script entry has no script_path"))?;
|
||||||
let container_path = copy_script_into_home(&auth.user_id, &name, &script)?;
|
let container_path = install_connector_for_user(&auth.user_id, &name, &script)?;
|
||||||
(entry.command.clone(), Some(json!([container_path]).to_string()), Some(container_path))
|
(entry.command.clone(), Some(json!([container_path]).to_string()), Some(container_path))
|
||||||
} else {
|
} else {
|
||||||
(entry.command.clone(), entry.args_json.clone(), None)
|
(entry.command.clone(), entry.args_json.clone(), None)
|
||||||
};
|
};
|
||||||
|
let env_json = body.env.as_ref()
|
||||||
|
.and_then(|e| serde_json::to_string(e).ok())
|
||||||
|
.or_else(|| entry.env_json.clone());
|
||||||
|
|
||||||
|
// OAuth connectors do NOT activate directly (§15): the refresh token
|
||||||
|
// comes from an interactive consent, not from the activation form. We
|
||||||
|
// persist a PENDING row (files installed, command wired) and hand off to
|
||||||
|
// `/mcp/oauth/start` → `/complete`, which obtains the token, flips the
|
||||||
|
// row to `ready`, and starts the server. Nothing runs until then.
|
||||||
|
if entry.auth_kind == "oauth" {
|
||||||
|
let provider_name = entry.oauth_provider.as_deref().ok_or_else(|| {
|
||||||
|
ApiError::bad_request("this OAuth connector names no provider in the catalog")
|
||||||
|
})?;
|
||||||
|
// Fail early, clearly, if the admin has not configured the provider —
|
||||||
|
// better than a dead pending row the user cannot complete.
|
||||||
|
let provider = skald_core::db::oauth_providers::get(skald.db(), provider_name).await?
|
||||||
|
.ok_or_else(|| ApiError::bad_request(format!(
|
||||||
|
"the `{provider_name}` sign-in provider is not set up yet — \
|
||||||
|
an admin must add its client credentials first"
|
||||||
|
)))?;
|
||||||
|
if provider.client_id.is_empty() {
|
||||||
|
return Err(ApiError::bad_request(format!(
|
||||||
|
"the `{provider_name}` sign-in provider has no client id configured"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let id = mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
|
||||||
|
name: &name,
|
||||||
|
catalog_name: Some(&entry.name),
|
||||||
|
source: &entry.source,
|
||||||
|
transport: &entry.transport,
|
||||||
|
command: command.as_deref(),
|
||||||
|
args_json,
|
||||||
|
env_json,
|
||||||
|
url: entry.url.as_deref(),
|
||||||
|
api_key: None, // obtained by the OAuth flow
|
||||||
|
oauth_provider: Some(provider_name),
|
||||||
|
deliver_json: entry.deliver_json.clone(),
|
||||||
|
script_rel_path: script_rel_path.as_deref(),
|
||||||
|
verify_command: None,
|
||||||
|
verify_script_rel_path: None,
|
||||||
|
auth_state: "pending",
|
||||||
|
}).await?;
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"id": id, "auth_state": "pending", "needs_oauth": true,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
|
mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
|
||||||
name: &name,
|
name: &name,
|
||||||
catalog_name: Some(&entry.name),
|
catalog_name: Some(&entry.name),
|
||||||
source: &entry.source,
|
source: &entry.source,
|
||||||
transport: &entry.transport,
|
transport: &entry.transport,
|
||||||
command: command.as_deref(),
|
command: command.as_deref(),
|
||||||
args_json,
|
args_json,
|
||||||
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()).or_else(|| entry.env_json.clone()),
|
env_json,
|
||||||
url: entry.url.as_deref(),
|
url: entry.url.as_deref(),
|
||||||
api_key: body.api_key.as_deref(),
|
api_key: body.api_key.as_deref(),
|
||||||
script_rel_path: script_rel_path.as_deref(),
|
oauth_provider: None,
|
||||||
auth_state: "ready",
|
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?
|
}).await?
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -403,17 +821,21 @@ pub async fn activate(
|
|||||||
.ok_or_else(|| ApiError::bad_request("a self-registered remote needs a `url`"))?;
|
.ok_or_else(|| ApiError::bad_request("a self-registered remote needs a `url`"))?;
|
||||||
reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?;
|
reject_name_collision(&skald, &ctx.pool, &auth.user_id, &name).await?;
|
||||||
mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
|
mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
|
||||||
name: &name,
|
name: &name,
|
||||||
catalog_name: None,
|
catalog_name: None,
|
||||||
source: "remote",
|
source: "remote",
|
||||||
transport: &body.transport,
|
transport: &body.transport,
|
||||||
command: None,
|
command: None,
|
||||||
args_json: None,
|
args_json: None,
|
||||||
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()),
|
env_json: body.env.as_ref().and_then(|e| serde_json::to_string(e).ok()),
|
||||||
url: Some(&url),
|
url: Some(&url),
|
||||||
api_key: body.api_key.as_deref(),
|
api_key: body.api_key.as_deref(),
|
||||||
script_rel_path: None,
|
oauth_provider: None,
|
||||||
auth_state: "ready",
|
deliver_json: None,
|
||||||
|
script_rel_path: None,
|
||||||
|
verify_command: None,
|
||||||
|
verify_script_rel_path: None,
|
||||||
|
auth_state: "ready",
|
||||||
}).await?
|
}).await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -421,11 +843,44 @@ pub async fn activate(
|
|||||||
// Start it now in this user's runtime (container transport for stdio).
|
// Start it now in this user's runtime (container transport for stdio).
|
||||||
let row = mcp_user_servers::get(&ctx.pool, insert).await?
|
let row = mcp_user_servers::get(&ctx.pool, insert).await?
|
||||||
.ok_or_else(|| ApiError::bad_request("user server vanished after insert"))?;
|
.ok_or_else(|| ApiError::bad_request("user server vanished after insert"))?;
|
||||||
|
|
||||||
|
// Verify-before-start for catalog local_script connectors (a self-registered
|
||||||
|
// remote has no verify step). The activation is persisted either way; a
|
||||||
|
// failed verify flips auth_state to 'pending' so the user can retry without
|
||||||
|
// retyping, and the row stays out of `all_startable` until a test passes.
|
||||||
|
let verify = if row.source == "local_script" && row.verify_command.is_some() {
|
||||||
|
match &row.catalog_name {
|
||||||
|
Some(catalog_name) => match mcp_catalog::get_by_name(skald.db(), catalog_name).await? {
|
||||||
|
Some(entry) => {
|
||||||
|
let report = run_verify_for_entry(
|
||||||
|
&skald, &auth, &entry, &row.name,
|
||||||
|
body.env.as_ref(), body.api_key.as_deref(),
|
||||||
|
).await?;
|
||||||
|
if !report.skipped && !report.ok {
|
||||||
|
mcp_user_servers::set_auth_state(&ctx.pool, insert, "pending").await?;
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"id": insert, "verify": report, "auth_state": "pending",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
report
|
||||||
|
}
|
||||||
|
None => skald_core::mcp::VerifyReport::skipped(),
|
||||||
|
},
|
||||||
|
None => skald_core::mcp::VerifyReport::skipped(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skald_core::mcp::VerifyReport::skipped()
|
||||||
|
};
|
||||||
|
|
||||||
let container = skald_core::container::container_name(&auth.user_id);
|
let container = skald_core::container::container_name(&auth.user_id);
|
||||||
let spec = skald_core::mcp::user_row_spec(&row, &container);
|
let spec = skald_core::mcp::user_row_spec_resolved(&row, &container, skald.db()).await;
|
||||||
match ctx.user_mcp.start_server(spec).await {
|
match ctx.user_mcp.start_server(spec).await {
|
||||||
Ok(tools) => Ok(Json(json!({ "id": insert, "tools": tools }))),
|
Ok(tools) => Ok(Json(json!({
|
||||||
Err(e) => Ok(Json(json!({ "id": insert, "error": e.to_string() }))),
|
"id": insert, "tools": tools, "verify": verify, "auth_state": "ready",
|
||||||
|
}))),
|
||||||
|
Err(e) => Ok(Json(json!({
|
||||||
|
"id": insert, "error": e.to_string(), "verify": verify,
|
||||||
|
}))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,3 +915,133 @@ pub async fn deactivate(
|
|||||||
mcp_user_servers::delete(&ctx.pool, id).await?;
|
mcp_user_servers::delete(&ctx.pool, id).await?;
|
||||||
Ok(Json(json!({ "ok": true })))
|
Ok(Json(json!({ "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── user: interactive OAuth login for a per-user connector (§15) ───────────────
|
||||||
|
//
|
||||||
|
// A pending consent lives only in RAM, keyed by an opaque `state`: it holds the
|
||||||
|
// PKCE verifier that a copy-pasteable authorization code is worthless without, plus
|
||||||
|
// which user + connector row it belongs to. The flow is stateless on disk — an
|
||||||
|
// abandoned consent is simply pruned, and a restart drops every in-flight flow (the
|
||||||
|
// user just starts again), mirroring the RAM-only session model.
|
||||||
|
|
||||||
|
struct PendingFlow {
|
||||||
|
user_id: String,
|
||||||
|
server_id: i64,
|
||||||
|
verifier: String,
|
||||||
|
provider_name: String,
|
||||||
|
created_at: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a started-but-uncompleted consent stays valid.
|
||||||
|
const OAUTH_FLOW_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
|
static OAUTH_FLOWS: std::sync::LazyLock<std::sync::Mutex<HashMap<String, PendingFlow>>> =
|
||||||
|
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
/// Stores a pending flow, pruning any that timed out first. The lock is never held
|
||||||
|
/// across an `.await` — a single synchronous critical section.
|
||||||
|
fn oauth_flow_insert(state: String, flow: PendingFlow) {
|
||||||
|
let mut map = OAUTH_FLOWS.lock().unwrap();
|
||||||
|
map.retain(|_, f| f.created_at.elapsed() < OAUTH_FLOW_TTL);
|
||||||
|
map.insert(state, flow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes and returns a pending flow, or `None` if unknown or expired.
|
||||||
|
fn oauth_flow_take(state: &str) -> Option<PendingFlow> {
|
||||||
|
let mut map = OAUTH_FLOWS.lock().unwrap();
|
||||||
|
let flow = map.remove(state)?;
|
||||||
|
(flow.created_at.elapsed() < OAUTH_FLOW_TTL).then_some(flow)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct OauthStartBody {
|
||||||
|
/// The pending `mcp_user_servers` row (created by `activate`) to sign in.
|
||||||
|
pub server_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/mcp/oauth/start` — begins the consent for a pending OAuth connector.
|
||||||
|
/// Returns the URL the user opens and an opaque `state` the caller echoes back to
|
||||||
|
/// `/complete`. Does not touch the provider or the network beyond building a URL.
|
||||||
|
pub async fn oauth_start(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
Json(body): Json<OauthStartBody>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||||
|
let row = mcp_user_servers::get(&ctx.pool, body.server_id).await?
|
||||||
|
.ok_or_else(|| ApiError::not_found("no such connector"))?;
|
||||||
|
let provider_name = row.oauth_provider.as_deref()
|
||||||
|
.ok_or_else(|| ApiError::bad_request("this connector does not use OAuth"))?;
|
||||||
|
let provider = skald_core::db::oauth_providers::get(skald.db(), provider_name).await?
|
||||||
|
.ok_or_else(|| ApiError::bad_request(format!("sign-in provider `{provider_name}` is not configured")))?;
|
||||||
|
|
||||||
|
// The scopes to request live on the catalog entry (kept current), linked by the
|
||||||
|
// row's snapshotted catalog name.
|
||||||
|
let scopes = match &row.catalog_name {
|
||||||
|
Some(cn) => mcp_catalog::get_by_name(skald.db(), cn).await?
|
||||||
|
.map(|e| e.oauth_scopes())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let pkce = skald_core::mcp::oauth::generate_pkce();
|
||||||
|
let state = skald_core::mcp::oauth::random_state();
|
||||||
|
let auth_url = skald_core::mcp::oauth::build_consent_url(&provider, &scopes, &state, &pkce.challenge)
|
||||||
|
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||||
|
|
||||||
|
oauth_flow_insert(state.clone(), PendingFlow {
|
||||||
|
user_id: auth.user_id.clone(),
|
||||||
|
server_id: row.id,
|
||||||
|
verifier: pkce.verifier,
|
||||||
|
provider_name: provider_name.to_string(),
|
||||||
|
created_at: std::time::Instant::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(json!({ "auth_url": auth_url, "state": state })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct OauthCompleteBody {
|
||||||
|
/// The `state` returned by `/start`, identifying the pending flow.
|
||||||
|
pub state: String,
|
||||||
|
/// The authorization code the user pasted from the provider's page.
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/mcp/oauth/complete` — exchanges the pasted code for a refresh token,
|
||||||
|
/// stores it, flips the connector to `ready`, and starts it in the user's runtime.
|
||||||
|
pub async fn oauth_complete(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
Json(body): Json<OauthCompleteBody>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||||
|
let flow = oauth_flow_take(&body.state)
|
||||||
|
.ok_or_else(|| ApiError::bad_request("this sign-in has expired — start it again"))?;
|
||||||
|
// The flow is bound to the user who started it: a leaked `state` cannot let
|
||||||
|
// someone finish another person's consent into their own connector.
|
||||||
|
if flow.user_id != auth.user_id {
|
||||||
|
return Err(ApiError::forbidden("this sign-in does not belong to you"));
|
||||||
|
}
|
||||||
|
let provider = skald_core::db::oauth_providers::get(skald.db(), &flow.provider_name).await?
|
||||||
|
.ok_or_else(|| ApiError::bad_request("sign-in provider is no longer configured"))?;
|
||||||
|
|
||||||
|
let token = skald_core::mcp::oauth::exchange_code(&provider, body.code.trim(), &flow.verifier)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||||
|
let refresh = token.refresh_token.ok_or_else(|| ApiError::bad_request(
|
||||||
|
"the provider returned no refresh token — revoke this app's access in your \
|
||||||
|
account settings and try the sign-in again",
|
||||||
|
))?;
|
||||||
|
mcp_user_servers::set_oauth_token(&ctx.pool, flow.server_id, &refresh).await?;
|
||||||
|
|
||||||
|
// Start it now, with the credential resolved into the server's env (§15).
|
||||||
|
let row = mcp_user_servers::get(&ctx.pool, flow.server_id).await?
|
||||||
|
.ok_or_else(|| ApiError::bad_request("connector vanished after sign-in"))?;
|
||||||
|
let container = skald_core::container::container_name(&auth.user_id);
|
||||||
|
let spec = skald_core::mcp::user_row_spec_resolved(&row, &container, skald.db()).await;
|
||||||
|
match ctx.user_mcp.start_server(spec).await {
|
||||||
|
Ok(tools) => Ok(Json(json!({ "id": row.id, "tools": tools, "auth_state": "ready" }))),
|
||||||
|
Err(e) => Ok(Json(json!({ "id": row.id, "error": e.to_string(), "auth_state": "ready" }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -139,14 +139,24 @@ pub fn router() -> Router<Arc<Skald>> {
|
|||||||
// admin: catalog + globally-active connectors
|
// admin: catalog + globally-active connectors
|
||||||
.route("/mcp/catalog", get(mcp::catalog_list).post(mcp::catalog_upsert))
|
.route("/mcp/catalog", get(mcp::catalog_list).post(mcp::catalog_upsert))
|
||||||
.route("/mcp/catalog/{id}", delete(mcp::catalog_delete))
|
.route("/mcp/catalog/{id}", delete(mcp::catalog_delete))
|
||||||
|
// The icon of an installed connector, off the local `connectors/` folder.
|
||||||
|
// Any logged-in user, not just a catalog manager — see `catalog_icon`.
|
||||||
|
.route("/mcp/catalog/{name}/icon", get(mcp::catalog_icon))
|
||||||
.route("/mcp/global", get(mcp::global_list).post(mcp::global_enable))
|
.route("/mcp/global", get(mcp::global_list).post(mcp::global_enable))
|
||||||
.route("/mcp/global/{id}", delete(mcp::global_delete))
|
.route("/mcp/global/{id}", delete(mcp::global_delete))
|
||||||
.route("/mcp/global/{id}/access", get(mcp::global_get_access).put(mcp::global_set_access))
|
.route("/mcp/global/{id}/access", get(mcp::global_get_access).put(mcp::global_set_access))
|
||||||
|
// admin: OAuth providers (client credentials for per-user sign-in, §15)
|
||||||
|
.route("/mcp/providers", get(mcp::providers_list).post(mcp::providers_upsert))
|
||||||
|
.route("/mcp/providers/{name}", delete(mcp::providers_delete))
|
||||||
// user: available catalog + per-user activation
|
// user: available catalog + per-user activation
|
||||||
.route("/mcp/available", get(mcp::available))
|
.route("/mcp/available", get(mcp::available))
|
||||||
.route("/mcp/activate", post(mcp::activate))
|
.route("/mcp/activate", post(mcp::activate))
|
||||||
|
.route("/mcp/test", post(mcp::test))
|
||||||
.route("/mcp/activated", get(mcp::activated_list))
|
.route("/mcp/activated", get(mcp::activated_list))
|
||||||
.route("/mcp/activated/{id}", delete(mcp::deactivate))
|
.route("/mcp/activated/{id}", delete(mcp::deactivate))
|
||||||
|
// user: interactive OAuth login for a pending per-user connector (§15)
|
||||||
|
.route("/mcp/oauth/start", post(mcp::oauth_start))
|
||||||
|
.route("/mcp/oauth/complete", post(mcp::oauth_complete))
|
||||||
// Dev / debug
|
// Dev / debug
|
||||||
.route("/dev/debug_mode", get(dev::get_debug_mode).post(dev::set_debug_mode).put(dev::set_debug_mode))
|
.route("/dev/debug_mode", get(dev::get_debug_mode).post(dev::set_debug_mode).put(dev::set_debug_mode))
|
||||||
.route("/dev/llm-requests", get(dev::list_llm_requests))
|
.route("/dev/llm-requests", get(dev::list_llm_requests))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AgentsPage } from './components/agents.js';
|
|||||||
import { UsersPage } from './components/users-page.js';
|
import { UsersPage } from './components/users-page.js';
|
||||||
import { RolesPage } from './components/roles-page.js';
|
import { RolesPage } from './components/roles-page.js';
|
||||||
import { ConnectorsPage } from './components/connectors.js';
|
import { ConnectorsPage } from './components/connectors.js';
|
||||||
|
import { ConnectorDetailPage } from './components/connector-detail.js';
|
||||||
import { MarketplacePage } from './components/marketplace.js';
|
import { MarketplacePage } from './components/marketplace.js';
|
||||||
import { CatalogPage } from './components/catalog.js';
|
import { CatalogPage } from './components/catalog.js';
|
||||||
import { ProfilePage } from './components/profile-page.js';
|
import { ProfilePage } from './components/profile-page.js';
|
||||||
@@ -46,6 +47,7 @@ customElements.define('agents-page', AgentsPage);
|
|||||||
customElements.define('users-page', UsersPage);
|
customElements.define('users-page', UsersPage);
|
||||||
customElements.define('roles-page', RolesPage);
|
customElements.define('roles-page', RolesPage);
|
||||||
customElements.define('connectors-page', ConnectorsPage);
|
customElements.define('connectors-page', ConnectorsPage);
|
||||||
|
customElements.define('connector-detail-page', ConnectorDetailPage);
|
||||||
customElements.define('marketplace-page', MarketplacePage);
|
customElements.define('marketplace-page', MarketplacePage);
|
||||||
customElements.define('catalog-page', CatalogPage);
|
customElements.define('catalog-page', CatalogPage);
|
||||||
customElements.define('profile-page', ProfilePage);
|
customElements.define('profile-page', ProfilePage);
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class CatalogPage extends LightElement {
|
|||||||
${this._select('Transport', f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
|
${this._select('Transport', f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
|
||||||
${isScript
|
${isScript
|
||||||
? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: 'python3', mono: true })}
|
? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: 'python3', mono: true })}
|
||||||
${this._field('Script path', f.script_path, e => this._patch('script_path', e.target.value), { hint: 'under ./scripts', mono: true })}`
|
${this._field('Script path', f.script_path, e => this._patch('script_path', e.target.value), { hint: 'as <connector>/<file>, under ./connectors', mono: true })}`
|
||||||
: this._field('URL', f.url, e => this._patch('url', e.target.value), { mono: true })}
|
: this._field('URL', f.url, e => this._patch('url', e.target.value), { mono: true })}
|
||||||
${this._field('Args', f.args, e => this._patch('args', e.target.value), { hint: 'one per line', mono: true })}
|
${this._field('Args', f.args, e => this._patch('args', e.target.value), { hint: 'one per line', mono: true })}
|
||||||
${this._field('Required secret/env keys', f.config_schema, e => this._patch('config_schema', e.target.value), { hint: 'comma/newline', mono: true })}
|
${this._field('Required secret/env keys', f.config_schema, e => this._patch('config_schema', e.target.value), { hint: 'comma/newline', mono: true })}
|
||||||
|
|||||||
@@ -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 { html, nothing } from 'lit';
|
||||||
import { LightElement } from '../lib/base.js';
|
import { LightElement } from '../lib/base.js';
|
||||||
|
import { connectorIconUrl, statusOf, STATUS_LABEL } from './shared/connector-common.js';
|
||||||
|
|
||||||
// Connectors (MCP) — blueprint §7/§14/§15.
|
// Connectors (MCP) — blueprint §7/§14/§15.
|
||||||
//
|
//
|
||||||
// One question: **what is running, and what can I add?** This is the runtime view —
|
// **One row per connector**, not one per runtime instance. A catalog entry is a
|
||||||
// literally `UserMcpView` (global ∪ per-user) plus the actions that create those
|
// template with two runtimes (§7), and a person thinks in terms of "do I have
|
||||||
// instances. What this box *offers* is a different question, answered by the
|
// Gmail?" — not "how many `mcp_user_servers` rows named gmail-ish do I own?". So the
|
||||||
// Connector Catalog page.
|
// old three-section split (Mine / Global / Available) is gone: the same connector
|
||||||
|
// used to appear twice, once as a template and once as its instance, and the reader
|
||||||
|
// had to join the two by eye. Here each connector appears exactly once, and its
|
||||||
|
// state is a chip on the card.
|
||||||
//
|
//
|
||||||
// The same page serves everyone; the admin just has more verbs. A catalog entry is a
|
// The card is a link, not a form. Everything that needs typing lives on the
|
||||||
// template with two runtimes (§7), so "Available" is one list with the verb that fits
|
// connector's own page (`#connector?name=X`) — an activation form has as many
|
||||||
// each row: a `per_user` entry says Activate (anyone), a `global` entry says Enable
|
// fields as the connector declares (EMAIL has a dozen), which a fixed-size dialog
|
||||||
// globally (admin only). Enabling a global is the admin's counterpart to activating a
|
// could never hold.
|
||||||
// per-user one — which is why they live side by side instead of in an admin dungeon.
|
|
||||||
//
|
//
|
||||||
// Reuses the shared `um-*` / bootstrap styling (no page-specific CSS).
|
// Reuses the marketplace's card styling (`web/css/connectors.css`).
|
||||||
|
|
||||||
const ADMIN_ID = 'admin';
|
const ADMIN_ID = 'admin';
|
||||||
|
|
||||||
function parseJson(s, fallback) {
|
|
||||||
if (!s) return fallback;
|
|
||||||
try { return JSON.parse(s); } catch { return fallback; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function jf(url, opts) {
|
async function jf(url, opts) {
|
||||||
const res = await fetch(url, opts);
|
const res = await fetch(url, opts);
|
||||||
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
|
||||||
@@ -38,15 +36,20 @@ export class ConnectorsPage extends LightElement {
|
|||||||
_me: { state: true }, // { role_id }
|
_me: { state: true }, // { role_id }
|
||||||
_available: { state: true }, // { catalog: [...], globals: [...] }
|
_available: { state: true }, // { catalog: [...], globals: [...] }
|
||||||
_activated: { state: true }, // my per-user server rows
|
_activated: { state: true }, // my per-user server rows
|
||||||
_users: { state: true }, // admin: user summaries (for the access modal)
|
|
||||||
_error: { state: true },
|
_error: { state: true },
|
||||||
_modal: { state: true },
|
_q: { state: true },
|
||||||
|
_noIcon: { state: true }, // names whose icon failed to load
|
||||||
|
_providers: { state: true }, // admin: OAuth provider list (modal)
|
||||||
|
_pForm: { state: true }, // admin: provider being edited, or null
|
||||||
|
_pError: { state: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this._open = false;
|
this._open = false;
|
||||||
|
this._q = '';
|
||||||
|
this._noIcon = new Set();
|
||||||
this._reset();
|
this._reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +57,10 @@ export class ConnectorsPage extends LightElement {
|
|||||||
this._me = null;
|
this._me = null;
|
||||||
this._available = null;
|
this._available = null;
|
||||||
this._activated = null;
|
this._activated = null;
|
||||||
this._users = null;
|
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this._modal = null;
|
this._providers = null;
|
||||||
|
this._pForm = null;
|
||||||
|
this._pError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -66,6 +70,9 @@ export class ConnectorsPage extends LightElement {
|
|||||||
this.style.display = this._open ? 'flex' : 'none';
|
this.style.display = this._open ? 'flex' : 'none';
|
||||||
if (this._open) this._load();
|
if (this._open) this._load();
|
||||||
});
|
});
|
||||||
|
// Coming back from a connector's page must show its new state, not the state
|
||||||
|
// captured before the user activated it.
|
||||||
|
window.addEventListener('connectors-changed', () => { if (this._open) this._load(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
|
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
|
||||||
@@ -80,130 +87,147 @@ export class ConnectorsPage extends LightElement {
|
|||||||
]);
|
]);
|
||||||
this._available = available;
|
this._available = available;
|
||||||
this._activated = activated;
|
this._activated = activated;
|
||||||
// Only the access modal needs the user list, and only an admin opens it.
|
|
||||||
if (this._isAdmin) this._users = await jf('/api/users');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = e.message;
|
this._error = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_patch(field, value) {
|
_go(page, hash) {
|
||||||
this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } };
|
history.pushState({ page }, '', hash);
|
||||||
|
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
_closeModal() { this._modal = null; this._error = null; }
|
_openConnector(name) {
|
||||||
|
this._go('connector', `#connector?name=${encodeURIComponent(name)}`);
|
||||||
_goCatalog() {
|
|
||||||
history.pushState({ page: 'catalog' }, '', '#catalog');
|
|
||||||
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'catalog' } }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Activate a per-user connector ──────────────────────────────────────────
|
// ── admin: OAuth sign-in providers (§15) ─────────────────────────────────────
|
||||||
|
|
||||||
_openActivate(entry) {
|
async _openProviders() {
|
||||||
const schema = parseJson(entry.config_schema_json, []) || [];
|
this._pError = null;
|
||||||
this._modal = {
|
this._pForm = null;
|
||||||
kind: 'activate',
|
try {
|
||||||
entry,
|
this._providers = await jf('/api/mcp/providers');
|
||||||
form: { name: entry.name, api_key: '', env: Object.fromEntries(schema.map(k => [k, ''])) },
|
} catch (e) { this._pError = e.message; this._providers = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeProviders() {
|
||||||
|
this._providers = null;
|
||||||
|
this._pForm = null;
|
||||||
|
this._pError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_blankProvider() {
|
||||||
|
return { name: '', display_name: '', auth_url: '', token_url: '',
|
||||||
|
client_id: '', client_secret: '', redirect_uri: '', extra_params: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Google preset — fills everything but the client_id/secret the admin pastes
|
||||||
|
/// from their Google Cloud console. `prompt=consent` + `access_type=offline` are
|
||||||
|
/// what make Google return a refresh token (§15).
|
||||||
|
_presetGoogle() {
|
||||||
|
this._pError = null;
|
||||||
|
this._pForm = {
|
||||||
|
name: 'google',
|
||||||
|
display_name: 'Google',
|
||||||
|
auth_url: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
token_url: 'https://oauth2.googleapis.com/token',
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
redirect_uri: 'https://connectors.skaldagent.net/oauth/show.html',
|
||||||
|
extra_params: '{"access_type":"offline","prompt":"consent"}',
|
||||||
|
_isNew: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async _activate() {
|
_editProvider(p) {
|
||||||
const { entry, form } = this._modal;
|
// The secret never came back from the server; an empty box means "keep it".
|
||||||
if (!form.name.trim()) { this._error = 'A name is required.'; return; }
|
this._pForm = { ...p, client_secret: '', extra_params: p.extra_params || '', _isNew: false };
|
||||||
const env = {};
|
this._pError = null;
|
||||||
for (const [k, v] of Object.entries(form.env || {})) if (v !== '') env[k] = v;
|
}
|
||||||
|
|
||||||
|
_patchProvider(key, value) {
|
||||||
|
this._pForm = { ...this._pForm, [key]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _saveProvider() {
|
||||||
|
const f = this._pForm;
|
||||||
|
if (!f.name.trim() || !f.client_id.trim()) {
|
||||||
|
this._pError = 'Name and client id are required.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (f._isNew && !f.client_secret.trim()) {
|
||||||
|
this._pError = 'A client secret is required for a new provider.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._pError = null;
|
||||||
try {
|
try {
|
||||||
await jf('/api/mcp/activate', {
|
const { _isNew, has_client_secret, ...body } = f;
|
||||||
method: 'POST',
|
await jf('/api/mcp/providers', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
catalog_name: entry.name,
|
|
||||||
name: form.name.trim(),
|
|
||||||
api_key: form.api_key || null,
|
|
||||||
env: Object.keys(env).length ? env : null,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
this._closeModal();
|
this._pForm = null;
|
||||||
await this._load();
|
this._providers = await jf('/api/mcp/providers');
|
||||||
} catch (e) { this._error = e.message; }
|
} catch (e) { this._pError = e.message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async _deactivate(row) {
|
async _deleteProvider(name) {
|
||||||
if (!confirm(`Deactivate connector "${row.name}"?`)) return;
|
if (!confirm(`Delete the “${name}” sign-in provider?\n\nConnectors that use it will no longer be able to sign in.`)) return;
|
||||||
try {
|
try {
|
||||||
await jf(`/api/mcp/activated/${row.id}`, { method: 'DELETE' });
|
await jf(`/api/mcp/providers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||||
await this._load();
|
this._providers = await jf('/api/mcp/providers');
|
||||||
} catch (e) { this._error = e.message; }
|
} catch (e) { this._pError = e.message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Enable a global connector (admin) ──────────────────────────────────────
|
/// The merged view: every connector the caller can see, exactly once, carrying
|
||||||
|
/// whichever runtime rows exist for it.
|
||||||
|
get _rows() {
|
||||||
|
const catalog = this._available?.catalog ?? [];
|
||||||
|
const globals = this._available?.globals ?? [];
|
||||||
|
const activated = this._activated ?? [];
|
||||||
|
|
||||||
// The entry comes from the row the admin clicked, so there is no catalog picker:
|
const rows = catalog.map(e => ({
|
||||||
// the old dropdown existed only because this action lived on a page that did not
|
...e,
|
||||||
// show the catalog.
|
_act: activated.find(r => r.catalog_name === e.name) ?? null,
|
||||||
_openEnableGlobal(entry) {
|
_glob: globals.find(g => (g.catalog_name ?? g.name) === e.name) ?? null,
|
||||||
this._modal = {
|
}));
|
||||||
kind: 'global',
|
|
||||||
entry,
|
|
||||||
form: { name: entry.name, api_key: '' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async _enableGlobal() {
|
// A granted global whose catalog row the caller cannot see. `/api/mcp/available`
|
||||||
const { entry, form } = this._modal;
|
// only returns `global` catalog entries to a catalog manager, so without this the
|
||||||
try {
|
// connector an ordinary user actually uses every day would be missing from their
|
||||||
await jf('/api/mcp/global', {
|
// own list — visible to the admin, invisible to its user.
|
||||||
method: 'POST',
|
for (const g of globals) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const key = g.catalog_name ?? g.name;
|
||||||
body: JSON.stringify({
|
if (rows.some(r => r.name === key)) continue;
|
||||||
catalog_name: entry.name,
|
rows.push({
|
||||||
name: form.name.trim() || null,
|
name: key,
|
||||||
api_key: form.api_key || null,
|
friendly_name: g.friendly_name,
|
||||||
}),
|
description: g.description,
|
||||||
|
scope: 'global',
|
||||||
|
source: 'remote',
|
||||||
|
auth_kind: 'none',
|
||||||
|
_act: null,
|
||||||
|
_glob: g,
|
||||||
});
|
});
|
||||||
this._closeModal();
|
}
|
||||||
await this._load();
|
|
||||||
} catch (e) { this._error = e.message; }
|
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) {
|
_iconFailed(name) {
|
||||||
if (!confirm(`Disable global connector "${row.name}"?\n\nIt stops for everyone who can use it.`)) return;
|
// Re-render with the placeholder. A synthetic row (a granted global whose
|
||||||
try {
|
// catalog entry the caller cannot read) has no icon path to check up front, so
|
||||||
await jf(`/api/mcp/global/${row.id}`, { method: 'DELETE' });
|
// the 404 is the check.
|
||||||
await this._load();
|
const next = new Set(this._noIcon);
|
||||||
} catch (e) { this._error = e.message; }
|
next.add(name);
|
||||||
}
|
this._noIcon = next;
|
||||||
|
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
@@ -211,6 +235,7 @@ export class ConnectorsPage extends LightElement {
|
|||||||
render() {
|
render() {
|
||||||
if (!this._open) return nothing;
|
if (!this._open) return nothing;
|
||||||
const loading = this._available === null && !this._error;
|
const loading = this._available === null && !this._error;
|
||||||
|
const rows = loading ? [] : this._rows;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="um-page">
|
<div class="um-page">
|
||||||
@@ -218,232 +243,187 @@ export class ConnectorsPage extends LightElement {
|
|||||||
<h2 class="um-title"><i class="bi bi-plug me-2"></i>Connectors</h2>
|
<h2 class="um-title"><i class="bi bi-plug me-2"></i>Connectors</h2>
|
||||||
<div class="um-header-right">
|
<div class="um-header-right">
|
||||||
${this._isAdmin ? html`
|
${this._isAdmin ? html`
|
||||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._goCatalog()}>
|
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._openProviders()}>
|
||||||
|
<i class="bi bi-key me-1"></i>Sign-in providers
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._go('catalog', '#catalog')}>
|
||||||
<i class="bi bi-journal-text me-1"></i>Catalog
|
<i class="bi bi-journal-text me-1"></i>Catalog
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-primary" @click=${() => this._go('marketplace', '#marketplace')}>
|
||||||
|
<i class="bi bi-bag me-1"></i>Marketplace
|
||||||
</button>` : nothing}
|
</button>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this._error && !this._modal ? html`
|
${this._error ? html`
|
||||||
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
|
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||||
|
|
||||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : html`
|
${loading
|
||||||
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
|
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>`
|
||||||
${this._renderMine()}
|
: html`
|
||||||
${this._renderGlobals()}
|
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
|
||||||
${this._renderAvailable()}
|
<div class="connector-filters">
|
||||||
</div>`}
|
<div class="connector-search">
|
||||||
</div>
|
<i class="bi bi-search"></i>
|
||||||
${this._renderModal()}`;
|
<input class="form-control form-control-sm" placeholder="Search connectors…"
|
||||||
}
|
.value=${this._q} @input=${(e) => { this._q = e.target.value; }} />
|
||||||
|
</div>
|
||||||
_section(title, icon, right, body) {
|
</div>
|
||||||
return html`
|
${rows.length === 0 ? this._renderEmpty() : html`
|
||||||
<div style="margin-top:1.5rem">
|
<div class="connector-grid">${rows.map(r => this._renderCard(r))}</div>`}
|
||||||
<div class="um-header" style="padding:0 0 .5rem">
|
</div>`}
|
||||||
<h3 class="um-title" style="font-size:1rem"><i class="bi ${icon} me-2"></i>${title}</h3>
|
${this._providers !== null ? this._renderProvidersModal() : nothing}
|
||||||
<div class="um-header-right">${right ?? nothing}</div>
|
|
||||||
</div>
|
|
||||||
${body}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderMine() {
|
_renderProvidersModal() {
|
||||||
const rows = this._activated ?? [];
|
|
||||||
return this._section('My connectors', 'bi-check2-circle', nothing,
|
|
||||||
rows.length === 0
|
|
||||||
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-plug"></i>
|
|
||||||
<p>No per-user connectors activated.</p></div>`
|
|
||||||
: html`
|
|
||||||
<table class="um-table">
|
|
||||||
<thead><tr><th>Name</th><th>Type</th><th>From catalog</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${rows.map(r => html`
|
|
||||||
<tr>
|
|
||||||
<td><strong>${r.name}</strong></td>
|
|
||||||
<td><span class="badge ${r.source === 'local_script' ? 'bg-warning text-dark' : 'bg-secondary'}"
|
|
||||||
style="font-size:.65rem">${r.source === 'local_script' ? 'local script' : 'remote'}</span></td>
|
|
||||||
<td>${r.catalog_name ? html`<code>${r.catalog_name}</code>` : html`<span class="text-muted">—</span>`}</td>
|
|
||||||
<td><div class="um-actions">
|
|
||||||
<button class="um-btn-icon" title="Deactivate" @click=${() => this._deactivate(r)}>
|
|
||||||
<i class="bi bi-trash"></i></button>
|
|
||||||
</div></td>
|
|
||||||
</tr>`)}
|
|
||||||
</tbody>
|
|
||||||
</table>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderGlobals() {
|
|
||||||
const rows = this._available?.globals ?? [];
|
|
||||||
if (rows.length === 0 && !this._isAdmin) return nothing;
|
|
||||||
return this._section('Global connectors', 'bi-globe', nothing,
|
|
||||||
rows.length === 0
|
|
||||||
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-globe"></i>
|
|
||||||
<p>None enabled. Enable one from Available below.</p></div>`
|
|
||||||
: html`
|
|
||||||
${this._isAdmin ? html`
|
|
||||||
<div class="text-muted mb-2" style="font-size:.75rem">
|
|
||||||
Shared by the household. You see every one so you can manage it —
|
|
||||||
<span class="badge bg-success" style="font-size:.6rem">yours</span> marks the ones granted to you.
|
|
||||||
</div>` : nothing}
|
|
||||||
<table class="um-table">
|
|
||||||
<thead><tr><th>Name</th><th>Transport</th><th>Status</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${rows.map(g => html`
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong>${g.friendly_name || g.name}</strong>
|
|
||||||
${this._isAdmin && g.can_use ? html`
|
|
||||||
<span class="badge bg-success ms-1" style="font-size:.6rem">yours</span>` : nothing}
|
|
||||||
${g.description ? html`
|
|
||||||
<div class="text-muted" style="font-size:.75rem;max-width:44ch;overflow:hidden;
|
|
||||||
text-overflow:ellipsis;white-space:nowrap" title=${g.description}>${g.description}</div>` : nothing}
|
|
||||||
</td>
|
|
||||||
<td><span class="text-muted" style="font-size:.78rem">${g.transport}</span></td>
|
|
||||||
<td>${g.enabled
|
|
||||||
? html`<span class="badge bg-success" style="font-size:.65rem">on</span>`
|
|
||||||
: html`<span class="badge bg-secondary" style="font-size:.65rem">off</span>`}</td>
|
|
||||||
<td><div class="um-actions">
|
|
||||||
${this._isAdmin ? html`
|
|
||||||
<button class="um-btn-icon" title="Manage access" @click=${() => this._openAccess(g)}>
|
|
||||||
<i class="bi bi-people"></i></button>
|
|
||||||
<button class="um-btn-icon" title="Disable" @click=${() => this._deleteGlobal(g)}>
|
|
||||||
<i class="bi bi-trash"></i></button>
|
|
||||||
` : nothing}
|
|
||||||
</div></td>
|
|
||||||
</tr>`)}
|
|
||||||
</tbody>
|
|
||||||
</table>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderAvailable() {
|
|
||||||
const entries = this._available?.catalog ?? [];
|
|
||||||
const enabledGlobals = new Set((this._available?.globals ?? []).map(g => g.catalog_name ?? g.name));
|
|
||||||
const activatedNames = new Set((this._activated ?? []).map(r => r.catalog_name));
|
|
||||||
|
|
||||||
const right = this._isAdmin ? html`
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._goCatalog()}>
|
|
||||||
<i class="bi bi-plus-lg me-1"></i>Add to catalog
|
|
||||||
</button>` : nothing;
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return this._section('Available', 'bi-plus-square', right, html`
|
|
||||||
<div class="um-empty" style="padding:1rem"><i class="bi bi-journal"></i>
|
|
||||||
<p>${this._isAdmin ? 'The catalog is empty.' : 'Nothing available to you yet.'}</p>
|
|
||||||
${this._isAdmin ? html`
|
|
||||||
<p style="font-size:.8rem;opacity:.7">Add connectors to the catalog first.</p>` : nothing}
|
|
||||||
</div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._section('Available', 'bi-plus-square', right, html`
|
|
||||||
<table class="um-table">
|
|
||||||
<thead><tr><th>Connector</th><th>Scope</th><th>Auth</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${entries.map(e => {
|
|
||||||
const isGlobal = e.scope === 'global';
|
|
||||||
const already = isGlobal ? enabledGlobals.has(e.name) : activatedNames.has(e.name);
|
|
||||||
return html`
|
|
||||||
<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) {
|
|
||||||
return html`
|
return html`
|
||||||
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
|
<div style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1050;
|
||||||
<div class="um-modal">
|
display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:2rem 1rem"
|
||||||
<div class="um-modal-header">
|
@click=${(e) => { if (e.target === e.currentTarget) this._closeProviders(); }}>
|
||||||
<i class="bi ${icon}"></i><span>${title}</span>
|
<div class="connector-card" style="width:100%;max-width:560px;cursor:default">
|
||||||
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
|
<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>
|
||||||
<div class="um-modal-body">
|
<div class="text-muted mb-3" style="font-size:.78rem">
|
||||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
|
OAuth apps that per-user connectors sign in through. One app (e.g. Google) covers all of
|
||||||
${body}
|
its services. The client secret is stored on this box and never shown again.
|
||||||
</div>
|
|
||||||
<div class="um-modal-footer">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
|
||||||
<button class="btn btn-sm btn-primary" @click=${onSave}><i class="bi bi-check-lg me-1"></i>${saveLabel}</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
${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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_field(label, value, oninput, opts = {}) {
|
_renderProviderList() {
|
||||||
return html`<div class="mb-3">
|
const list = this._providers ?? [];
|
||||||
<label class="form-label">${label}${opts.hint ? html` <span class="text-muted">(${opts.hint})</span>` : nothing}</label>
|
return html`
|
||||||
<input class="form-control ${opts.mono ? 'font-monospace' : ''}" type=${opts.type || 'text'}
|
${list.length === 0 ? html`
|
||||||
placeholder=${opts.placeholder || ''} .value=${value} @input=${oninput} />
|
<div class="um-empty" style="padding:1rem"><i class="bi bi-key"></i>
|
||||||
</div>`;
|
<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() {
|
_renderProviderForm() {
|
||||||
if (!this._modal) return nothing;
|
const f = this._pForm;
|
||||||
const m = this._modal;
|
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') {
|
_renderEmpty() {
|
||||||
const f = m.form;
|
if (this._q.trim()) {
|
||||||
const schema = parseJson(m.entry.config_schema_json, []) || [];
|
return html`<div class="um-empty" style="padding:1rem"><i class="bi bi-search"></i>
|
||||||
return this._modalShell(`Activate ${m.entry.friendly_name || m.entry.name}`, 'bi-plug', html`
|
<p>No connector matches “${this._q}”.</p></div>`;
|
||||||
${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');
|
|
||||||
}
|
}
|
||||||
|
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') {
|
_renderCard(r) {
|
||||||
const f = m.form;
|
const status = statusOf(r);
|
||||||
return this._modalShell(`Enable ${m.entry.friendly_name || m.entry.name} globally`, 'bi-globe', html`
|
const isGlobal = r.scope === 'global';
|
||||||
<div class="text-muted mb-3" style="font-size:.78rem">
|
const isScript = r.source === 'local_script';
|
||||||
Runs once for the household on the host. Nobody reaches it until you grant access.
|
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>
|
</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') {
|
${r.description ? html`<div class="connector-card-desc">${r.description}</div>` : nothing}
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
async _install(card) {
|
||||||
const warn = card.source === 'local_script'
|
const warn = card.source === 'local_script'
|
||||||
? `\n\nThis puts code on this box:\n • ${card.file_count} file(s), each verified against its SHA-256\n • installed into ./scripts/${card.id}/`
|
? `\n\nThis puts code on this box:\n • ${card.file_count} file(s), each verified against its SHA-256\n • installed into ./connectors/${card.id}/`
|
||||||
: '';
|
: '';
|
||||||
if (!confirm(`Install "${card.name}" into the catalog?${warn}\n\nInstalling does not activate it.`)) return;
|
if (!confirm(`Install "${card.name}" into the catalog?${warn}\n\nInstalling does not activate it.`)) return;
|
||||||
this._installing = card.id;
|
this._installing = card.id;
|
||||||
|
|||||||
@@ -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=...`).
|
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
|
||||||
const match = hash.match(/^([^/?]+)/);
|
const match = hash.match(/^([^/?]+)/);
|
||||||
const segment = match ? match[1] : '';
|
const segment = match ? match[1] : '';
|
||||||
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
|
// `connector` (singular) is the per-connector detail page, `connectors` the list.
|
||||||
|
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
|
||||||
}
|
}
|
||||||
|
|
||||||
_tasksSectionFromHash() {
|
_tasksSectionFromHash() {
|
||||||
@@ -299,7 +300,7 @@ export class AppSidebar extends LightElement {
|
|||||||
<i class="bi bi-tags"></i>
|
<i class="bi bi-tags"></i>
|
||||||
<span class="sidebar-link-name">Roles</span>
|
<span class="sidebar-link-name">Roles</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="sidebar-link ${this._activePage === 'connectors' ? 'active' : ''}"
|
<a href="#" class="sidebar-link ${this._activePage === 'connectors' || this._activePage === 'connector' ? 'active' : ''}"
|
||||||
@click=${(e) => this._togglePage('connectors', e)}>
|
@click=${(e) => this._togglePage('connectors', e)}>
|
||||||
<i class="bi bi-plug"></i>
|
<i class="bi bi-plug"></i>
|
||||||
<span class="sidebar-link-name">Connectors</span>
|
<span class="sidebar-link-name">Connectors</span>
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ file-viewer-page {
|
|||||||
users-page,
|
users-page,
|
||||||
roles-page,
|
roles-page,
|
||||||
connectors-page,
|
connectors-page,
|
||||||
|
connector-detail-page,
|
||||||
marketplace-page,
|
marketplace-page,
|
||||||
catalog-page,
|
catalog-page,
|
||||||
profile-page {
|
profile-page {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
<users-page></users-page>
|
<users-page></users-page>
|
||||||
<roles-page></roles-page>
|
<roles-page></roles-page>
|
||||||
<connectors-page></connectors-page>
|
<connectors-page></connectors-page>
|
||||||
|
<connector-detail-page></connector-detail-page>
|
||||||
<marketplace-page></marketplace-page>
|
<marketplace-page></marketplace-page>
|
||||||
<catalog-page></catalog-page>
|
<catalog-page></catalog-page>
|
||||||
<profile-page style="display:none"></profile-page>
|
<profile-page style="display:none"></profile-page>
|
||||||
|
|||||||
Reference in New Issue
Block a user