diff --git a/Cargo.lock b/Cargo.lock index f4b0a86..c86ee84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2108,7 +2108,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.5.28" +version = "0.6.0" dependencies = [ "anyhow", "askama", @@ -2147,16 +2147,15 @@ dependencies = [ "anyhow", "axum", "dotenvy", - "futures-util", - "http", "reqwest", + "secrets-core", "serde", "serde_json", "tokio", - "tower-http", "tracing", "tracing-subscriber", "url", + "uuid", ] [[package]] diff --git a/README.md b/README.md index 78b5c74..ea6faa7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ cargo build --release -p secrets-mcp ```bash cargo build --release -p secrets-mcp-local -# 产物: target/release/secrets-mcp-local(本机代理远程 /mcp,见下节) +# 产物: target/release/secrets-mcp-local(本机 MCP gateway,见下节) ``` 发版产物见 Gitea Release(tag:`secrets-mcp-`,Linux musl 预编译);其它平台本地 `cargo build`。 @@ -57,22 +57,54 @@ SECRETS_ENV=production ### 本地 MCP gateway(`secrets-mcp-local`) -用于在本机启动一个 **仅监听 localhost** 的 MCP 入口:先在浏览器打开远程 **Dashboard** 登录并复制 API Key,再向本机 `POST /local/unlock` 提交一次 **64 位 hex** 加密密钥;之后 Cursor 等客户端可将 MCP URL 配为 `http://127.0.0.1:9316/mcp`,**无需**在配置里长期保存 `X-Encryption-Key`。解锁状态在进程内按 TTL 缓存,过期需重新解锁。 +`secrets-mcp-local` 现在是**独立的本地 MCP 入口**,不再依赖把远程 `/mcp` 原样透传到本机。它始终能完成 MCP `initialize` / `tools/list`,但会按状态暴露不同工具面: + +- `bootstrap`:尚未绑定或尚未解锁,只暴露 `local_status`、`local_bind_start`、`local_bind_exchange`、`local_unlock_status`、`local_onboarding_info` +- `pendingUnlock`:远端授权已完成,但本地仍未完成 passphrase 解锁;仍只暴露 bootstrap 工具 +- `ready`:绑定 + 解锁均完成,额外暴露 `secrets_find`、`secrets_search`、`secrets_history`、`secrets_overview`、`secrets_delete(dry_run)`、`target_exec` + +上线流程: +1. 启动 `secrets-mcp-local` +2. 在浏览器打开本地首页 `http://127.0.0.1:9316/` +3. 点击“开始绑定”,打开页面给出的 `approve_url` +4. 在远端网页确认授权后,返回本地首页等待自动进入解锁阶段 +5. 在本地页面或 `/unlock` 完成浏览器内 PBKDF2 派生、`key_check` 校验与本地解锁 +6. 之后将 Cursor 等客户端的 MCP URL 配为 `http://127.0.0.1:9316/mcp` + +这套流程下,Cursor 会先稳定连上 local MCP;未就绪时 AI 只能看到 bootstrap 工具,因此会明确告诉用户去打开本地 onboarding 页面或 `approve_url`,不会再因为 `401` 被误判成“连接失败”。 + +运行时说明: +- local gateway 的业务数据面已切到远端 JSON HTTP API:`find/search/history/overview/delete-preview/decrypt` 直接走 `/api/local-mcp/...` +- `target_exec` 首次执行某个目标时,建议同时传入 `secrets_find/search` 返回的目标摘要;local gateway 会按 `entry_id` 缓存解析后的执行上下文,后续同一目标可复用而不必重新读取密钥 +- 远端 `key_version` 变化时,本地会自动从 `ready` 回退到 `pendingUnlock` +- 远端 API key 已失效或绑定用户不存在时,本地会自动清除 bound 状态并重新回到 `bootstrap` + +`target_exec` 运行时会注入一组标准环境变量,例如: +- `TARGET_ENTRY_ID`、`TARGET_NAME`、`TARGET_FOLDER`、`TARGET_TYPE` +- `TARGET_HOST`、`TARGET_PORT`、`TARGET_USER`、`TARGET_BASE_URL` +- `TARGET_API_KEY`、`TARGET_TOKEN`、`TARGET_SSH_KEY` +- `TARGET_META_` 与 `TARGET_SECRET_`(对 metadata / secret 字段名做大写与下划线归一化) + +典型用法: +- 先 `secrets_find` 找到目标服务器,再用 `target_exec` 执行 `ssh -i <(printf '%s' \"$TARGET_SSH_KEY\") \"$TARGET_USER@$TARGET_HOST\" 'df -h'` +- 先 `secrets_search` 找到 API 服务条目,再用 `target_exec` 执行 `curl -H \"Authorization: Bearer $TARGET_API_KEY\" \"$TARGET_BASE_URL/health\"` + +本地状态行为: +- `POST /local/lock`:仅清除本地解锁缓存,保留绑定 +- `POST /local/unbind`:同时清除本地绑定与解锁状态 +- `GET /local/status`:返回 `bootstrap` / `pendingUnlock` / `ready`、待确认绑定会话、缓存目标数、`onboarding_url` / `unlock_url` | 变量 | 说明 | |------|------| -| `SECRETS_REMOTE_MCP_URL` | **必填**。远程 MCP 完整 URL,例如 `https://secrets.example.com/mcp`。 | +| `SECRETS_REMOTE_BASE_URL` | **必填**。远程 Web 基址,例如 `https://secrets.example.com`。 | | `SECRETS_MCP_LOCAL_BIND` | 可选。监听地址,默认 `127.0.0.1:9316`。 | -| `SECRETS_LOCAL_API_KEY` | 可选。若设置,则 `/local/unlock` 可只传 `encryption_key`。 | -| `SECRETS_LOCAL_UNLOCK_TTL_SECS` | 可选。默认解锁缓存秒数(单次 `unlock` 可用 `ttl_secs` 覆盖)。 | -| `SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS` | 可选。设为 `1`/`true` 时允许代理 `secrets_get` / `secrets_export` / `secrets_env_map`;默认 **不允许**(网关直接返回错误,避免明文进入 agent 上下文)。 | -| `SECRETS_REMOTE_DASHBOARD_URL` | 可选。首页引导链接;未设置时由 `SECRETS_REMOTE_MCP_URL` 推导为同 origin 的 `/dashboard`。 | +| `SECRETS_LOCAL_UNLOCK_TTL_SECS` | 可选。默认解锁缓存秒数(`/local/unlock/complete` 可传 `ttl_secs` 覆盖)。 | +| `SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS` | 可选。按 `entry_id` 复用已解析执行上下文的缓存秒数;到期、`lock`、`unbind` 或远端 `key_version` 变化后会失效。 | ```bash -SECRETS_REMOTE_MCP_URL=https://secrets.example.com/mcp cargo run -p secrets-mcp-local -# 浏览器打开首页提示的 Dashboard,解锁示例: -# curl -X POST http://127.0.0.1:9316/local/unlock -H 'Content-Type: application/json' \ -# -d '{"encryption_key":"<64 hex>","api_key":""}' +SECRETS_REMOTE_BASE_URL=https://secrets.example.com cargo run -p secrets-mcp-local +# 启动后直接打开 http://127.0.0.1:9316/ +# 页面会引导你完成 bind -> approve -> unlock -> ready 全流程 ``` ## PostgreSQL TLS 加固 @@ -205,7 +237,7 @@ flowchart LR ## 数据模型 -主表 **`entries`**(`folder`、`type`、`name`、`notes`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`name`、`type`、`encrypted`,通过 `entry_secrets` 中间表与 entry 建立 N:N 关联)。**唯一性**:`UNIQUE(user_id, folder, name)`(`user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`、`api_key`)、**`oauth_accounts`**。首次连库自动迁移建表(`secrets-core` 的 `migrate`);已有库在进程启动时亦由同一 `migrate()` 增量补齐表、索引与 N:N 结构。若需从更早版本对照一次性 SQL,可在 git 历史中检索已移除的 `scripts/migrate-v0.3.0.sql`。**Web 登录会话**(tower-sessions)使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp` 中 `PostgresStore::migrate`),无需额外环境变量。 +主表 **`entries`**(`folder`、`type`、`name`、`notes`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`name`、`type`、`encrypted`,通过 `entry_secrets` 中间表与 entry 建立 N:N 关联)。**唯一性**:`UNIQUE(user_id, folder, name)`(`user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`、`api_key`)、**`oauth_accounts`**、**`local_mcp_bind_sessions`**(短时本地绑定确认会话)。首次连库自动迁移建表(`secrets-core` 的 `migrate`);已有库在进程启动时亦由同一 `migrate()` 增量补齐表、索引与 N:N 结构。若需从更早版本对照一次性 SQL,可在 git 历史中检索已移除的 `scripts/migrate-v0.3.0.sql`。**Web 登录会话**(tower-sessions)使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp` 中 `PostgresStore::migrate`),无需额外环境变量。 | 位置 | 字段 | 说明 | |------|------|------| @@ -253,7 +285,7 @@ crates/secrets-core/ # db / crypto / models / audit / service taxonomy.rs # SECRET_TYPE_OPTIONS(secret 字段类型下拉选项) service/ # 业务逻辑(add, search, update, delete, export, env_map 等) crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key;CHANGELOG.md 嵌入 /changelog -crates/secrets-mcp-local/ # 可选:本机 MCP gateway(代理远程 /mcp) +crates/secrets-mcp-local/ # 可选:本机 MCP gateway(bootstrap + ready 双工具面) scripts/ release-check.sh # 发版前 fmt / clippy / test setup-gitea-actions.sh diff --git a/crates/secrets-core/src/db.rs b/crates/secrets-core/src/db.rs index 3669694..fac5583 100644 --- a/crates/secrets-core/src/db.rs +++ b/crates/secrets-core/src/db.rs @@ -220,6 +220,20 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider ON oauth_accounts(user_id, provider); + -- ── local_mcp_bind_sessions: short-lived browser approval state ────────── + CREATE TABLE IF NOT EXISTS local_mcp_bind_sessions ( + bind_id TEXT PRIMARY KEY, + device_code TEXT NOT NULL, + user_id UUID, + approved BOOLEAN NOT NULL DEFAULT FALSE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_expires_at + ON local_mcp_bind_sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_user_id + ON local_mcp_bind_sessions(user_id) WHERE user_id IS NOT NULL; + -- FK: user_id columns -> users(id) (nullable = legacy rows; ON DELETE SET NULL) DO $$ BEGIN IF NOT EXISTS ( diff --git a/crates/secrets-mcp-local/Cargo.toml b/crates/secrets-mcp-local/Cargo.toml index b20e146..37eff9e 100644 --- a/crates/secrets-mcp-local/Cargo.toml +++ b/crates/secrets-mcp-local/Cargo.toml @@ -2,7 +2,7 @@ name = "secrets-mcp-local" version = "0.1.0" edition.workspace = true -description = "Local MCP gateway: caches unlock credentials and proxies to remote secrets-mcp /mcp" +description = "Local MCP gateway for onboarding, unlock caching, and delegated target execution" license = "MIT OR Apache-2.0" [[bin]] @@ -12,14 +12,13 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true axum = "0.8" -futures-util = "0.3" -http = "1" +dotenvy.workspace = true reqwest = { workspace = true, features = ["stream"] } +secrets-core = { path = "../secrets-core" } serde.workspace = true serde_json.workspace = true tokio.workspace = true -tower-http = { version = "0.6", features = ["cors", "limit"] } tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } -dotenvy.workspace = true url = "2" +uuid.workspace = true diff --git a/crates/secrets-mcp-local/src/bind.rs b/crates/secrets-mcp-local/src/bind.rs new file mode 100644 index 0000000..a5bd132 --- /dev/null +++ b/crates/secrets-mcp-local/src/bind.rs @@ -0,0 +1,212 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::cache::{BoundState, PendingBindState}; +use crate::server::AppState; + +#[derive(Deserialize)] +pub struct BindExchangeBody { + bind_id: Option, + device_code: Option, +} + +fn bind_exchange_error_message(value: &Value) -> String { + value + .get("error") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned) + .or_else(|| { + value + .get("message") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| value.to_string()) +} + +pub async fn refresh_bound_state(state: &AppState) { + let api_key = { + let guard = state.cache.read().await; + guard.bound.as_ref().map(|bound| bound.api_key.clone()) + }; + let Some(api_key) = api_key else { + return; + }; + if let Ok(refreshed) = state.remote.bind_refresh(&api_key).await { + let mut guard = state.cache.write().await; + if matches!(refreshed.status, 401 | 404) { + guard.clear_bound_and_unlock(); + return; + } + if let Some(refreshed) = refreshed.body { + let clear_unlock = if let Some(bound) = guard.bound.as_mut() { + let changed = bound.key_version != refreshed.key_version; + bound.key_version = refreshed.key_version; + bound.key_salt_hex = refreshed.key_salt_hex.clone(); + bound.key_check_hex = refreshed.key_check_hex.clone(); + bound.key_params = refreshed.key_params.clone(); + changed + } else { + false + }; + if clear_unlock { + guard.clear_unlock_and_exec(); + } + } + } +} + +pub async fn start_bind(state: &AppState) -> Result { + let res = state + .remote + .bind_start() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("bind/start failed: {e}")))?; + let started_at = std::time::Instant::now(); + let expires_at = started_at + std::time::Duration::from_secs(res.expires_in_secs); + let mut guard = state.cache.write().await; + guard.clear_bound_and_unlock(); + guard.pending_bind = Some(PendingBindState { + bind_id: res.bind_id.clone(), + device_code: res.device_code.clone(), + approve_url: res.approve_url.clone(), + expires_at, + started_at, + }); + Ok(json!({ + "ok": true, + "bind_id": res.bind_id, + "device_code": res.device_code, + "approve_url": res.approve_url, + "expires_in_secs": res.expires_in_secs, + "onboarding_url": format!("http://{}/", state.config.bind), + "next_action": "在浏览器打开 approve_url 完成授权,然后继续轮询 local_bind_exchange", + })) +} + +pub async fn exchange_bind( + state: &AppState, + bind_id: Option, + device_code: Option, +) -> Result<(StatusCode, serde_json::Value), (StatusCode, String)> { + let (bind_id, device_code) = if let (Some(bind_id), Some(device_code)) = (bind_id, device_code) + { + (bind_id, device_code) + } else { + let guard = state.cache.read().await; + let pending = guard.pending_bind.as_ref().ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "missing bind session; call /local/bind/start first".to_string(), + ) + })?; + (pending.bind_id.clone(), pending.device_code.clone()) + }; + + let result = state + .remote + .bind_exchange(&bind_id, &device_code) + .await + .map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + format!("bind/exchange failed: {e}"), + ) + })?; + let status = result.status; + let payload = result.body; + + if status == 202 || payload.get("status").and_then(|v| v.as_str()) == Some("pending") { + let approve_url = { + let guard = state.cache.read().await; + guard + .pending_bind + .as_ref() + .filter(|pending| pending.bind_id == bind_id && pending.device_code == device_code) + .map(|pending| pending.approve_url.clone()) + }; + return Ok(( + StatusCode::ACCEPTED, + json!({ + "ok": false, + "status": "pending", + "bind_id": bind_id, + "device_code": device_code, + "approve_url": approve_url, + "next_action": "继续等待远端授权完成,或重新打开 approve_url", + }), + )); + } + if !(200..300).contains(&status) { + return Err(( + StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY), + bind_exchange_error_message(&payload), + )); + } + let payload: crate::remote::BindExchangeResponse = + serde_json::from_value(payload).map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + format!("invalid bind/exchange response: {e}"), + ) + })?; + let api_key = payload.api_key.ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + "bind/exchange missing api_key".to_string(), + ) + })?; + let user_id = payload.user_id.ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + "bind/exchange missing user_id".to_string(), + ) + })?; + let mut guard = state.cache.write().await; + guard.clear_pending_bind(); + guard.bound = Some(BoundState { + user_id, + api_key, + key_salt_hex: payload.key_salt_hex, + key_check_hex: payload.key_check_hex, + key_params: payload.key_params, + key_version: payload.key_version.unwrap_or(0), + bound_at: std::time::Instant::now(), + }); + guard.clear_unlock_and_exec(); + + Ok(( + StatusCode::OK, + json!({ + "ok": true, + "status": "bound", + "unlock_url": format!("http://{}/unlock", state.config.bind), + "onboarding_url": format!("http://{}/", state.config.bind), + "next_action": "打开本地 unlock 页面完成 passphrase 解锁", + }), + )) +} + +pub async fn bind_start( + State(state): State, +) -> Result { + let payload = start_bind(&state).await?; + Ok((StatusCode::OK, axum::Json(payload))) +} + +pub async fn bind_exchange( + State(state): State, + axum::Json(input): axum::Json, +) -> Result { + let (status, payload) = exchange_bind(&state, input.bind_id, input.device_code).await?; + Ok((status, axum::Json(payload))) +} + +pub async fn unbind(State(state): State) -> impl IntoResponse { + let mut guard = state.cache.write().await; + guard.clear_bound_and_unlock(); + (StatusCode::OK, axum::Json(json!({ "ok": true }))) +} diff --git a/crates/secrets-mcp-local/src/cache.rs b/crates/secrets-mcp-local/src/cache.rs new file mode 100644 index 0000000..23effe2 --- /dev/null +++ b/crates/secrets-mcp-local/src/cache.rs @@ -0,0 +1,234 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::target::ExecutionTarget; + +#[derive(Clone)] +pub struct BoundState { + pub user_id: Uuid, + pub api_key: String, + pub key_salt_hex: Option, + pub key_check_hex: Option, + pub key_params: Option, + pub key_version: i64, + pub bound_at: Instant, +} + +#[derive(Clone)] +pub struct UnlockState { + pub encryption_key_hex: String, + pub expires_at: Instant, + pub last_used_at: Instant, +} + +#[derive(Clone)] +pub struct ExecContext { + pub target: ExecutionTarget, + pub expires_at: Instant, + pub last_used_at: Instant, +} + +#[derive(Clone)] +pub struct PendingBindState { + pub bind_id: String, + pub device_code: String, + pub approve_url: String, + pub expires_at: Instant, + pub started_at: Instant, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GatewayPhase { + Bootstrap, + PendingUnlock, + Ready, +} + +#[derive(Default)] +pub struct GatewayCache { + pub pending_bind: Option, + pub bound: Option, + pub unlock: Option, + pub exec_contexts: HashMap, +} + +impl GatewayCache { + pub fn clear_bound_and_unlock(&mut self) { + self.pending_bind = None; + self.bound = None; + self.unlock = None; + self.exec_contexts.clear(); + } + + pub fn clear_pending_bind(&mut self) { + self.pending_bind = None; + } + + pub fn clear_unlock_and_exec(&mut self) { + self.unlock = None; + self.exec_contexts.clear(); + } + + pub fn phase(&self, now: Instant) -> GatewayPhase { + if self.bound.is_none() { + return GatewayPhase::Bootstrap; + } + if self + .unlock + .as_ref() + .is_some_and(|unlock| unlock.expires_at > now && !unlock.encryption_key_hex.is_empty()) + { + GatewayPhase::Ready + } else { + GatewayPhase::PendingUnlock + } + } +} + +pub type SharedCache = Arc>; + +pub fn new_cache() -> SharedCache { + Arc::new(RwLock::new(GatewayCache::default())) +} + +fn cleanup_expired(cache: &mut GatewayCache, now: Instant) { + if cache + .pending_bind + .as_ref() + .is_some_and(|bind| bind.expires_at <= now) + { + cache.pending_bind = None; + } + if let Some(unlock) = cache.unlock.as_ref() + && unlock.expires_at <= now + { + cache.clear_unlock_and_exec(); + } + cache.exec_contexts.retain(|_, ctx| ctx.expires_at > now); + if cache.unlock.is_none() { + cache.exec_contexts.clear(); + } +} + +pub fn spawn_cleanup_task(cache: SharedCache) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + let now = Instant::now(); + let mut guard = cache.write().await; + cleanup_expired(&mut guard, now); + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + use crate::target::ResolvedTarget; + + #[tokio::test] + async fn cleanup_task_clears_expired_unlock() { + let mut cache = GatewayCache { + pending_bind: None, + bound: None, + unlock: Some(UnlockState { + encryption_key_hex: "11".repeat(32), + expires_at: Instant::now() - Duration::from_secs(1), + last_used_at: Instant::now(), + }), + exec_contexts: HashMap::new(), + }; + cleanup_expired(&mut cache, Instant::now()); + assert!(cache.unlock.is_none()); + assert!(cache.exec_contexts.is_empty()); + } + + #[test] + fn clear_unlock_and_exec_drops_entry_contexts() { + let mut cache = GatewayCache { + pending_bind: None, + bound: None, + unlock: Some(UnlockState { + encryption_key_hex: "11".repeat(32), + expires_at: Instant::now() + Duration::from_secs(30), + last_used_at: Instant::now(), + }), + exec_contexts: HashMap::from([( + "entry-1".to_string(), + ExecContext { + target: ExecutionTarget { + resolved: ResolvedTarget { + id: "entry-1".to_string(), + folder: "refining".to_string(), + name: "api".to_string(), + entry_type: Some("service".to_string()), + }, + env: BTreeMap::from([( + "TARGET_API_KEY".to_string(), + "sk_test".to_string(), + )]), + }, + expires_at: Instant::now() + Duration::from_secs(30), + last_used_at: Instant::now(), + }, + )]), + }; + cache.clear_unlock_and_exec(); + assert!(cache.unlock.is_none()); + assert!(cache.exec_contexts.is_empty()); + } + + #[test] + fn cleanup_drops_expired_pending_bind() { + let mut cache = GatewayCache { + pending_bind: Some(PendingBindState { + bind_id: "bind-1".to_string(), + device_code: "device-1".to_string(), + approve_url: "http://example.com/approve".to_string(), + expires_at: Instant::now() - Duration::from_secs(1), + started_at: Instant::now() - Duration::from_secs(30), + }), + bound: None, + unlock: None, + exec_contexts: HashMap::new(), + }; + cleanup_expired(&mut cache, Instant::now()); + assert!(cache.pending_bind.is_none()); + } + + #[test] + fn phase_transitions_match_bound_and_unlock() { + let now = Instant::now(); + let mut cache = GatewayCache::default(); + assert_eq!(cache.phase(now), GatewayPhase::Bootstrap); + + cache.bound = Some(BoundState { + user_id: Uuid::nil(), + api_key: "api-key".to_string(), + key_salt_hex: None, + key_check_hex: None, + key_params: None, + key_version: 0, + bound_at: now, + }); + assert_eq!(cache.phase(now), GatewayPhase::PendingUnlock); + + cache.unlock = Some(UnlockState { + encryption_key_hex: "11".repeat(32), + expires_at: now + Duration::from_secs(60), + last_used_at: now, + }); + assert_eq!(cache.phase(now), GatewayPhase::Ready); + } +} diff --git a/crates/secrets-mcp-local/src/config.rs b/crates/secrets-mcp-local/src/config.rs new file mode 100644 index 0000000..85643b5 --- /dev/null +++ b/crates/secrets-mcp-local/src/config.rs @@ -0,0 +1,46 @@ +use anyhow::{Context, Result}; +use std::net::SocketAddr; +use std::time::Duration; +use url::Url; + +const DEFAULT_BIND: &str = "127.0.0.1:9316"; +const DEFAULT_UNLOCK_TTL_SECS: u64 = 3600; +const DEFAULT_EXEC_CONTEXT_TTL_SECS: u64 = 3600; + +#[derive(Clone)] +pub struct LocalConfig { + pub bind: SocketAddr, + pub remote_base_url: Url, + pub default_unlock_ttl: Duration, + pub default_exec_context_ttl: Duration, +} + +fn load_env(name: &str) -> Option { + std::env::var(name).ok().filter(|s| !s.is_empty()) +} + +pub fn load_config() -> Result { + let bind = load_env("SECRETS_MCP_LOCAL_BIND").unwrap_or_else(|| DEFAULT_BIND.to_string()); + let bind: SocketAddr = bind + .parse() + .with_context(|| format!("invalid SECRETS_MCP_LOCAL_BIND: {bind}"))?; + + let remote_base_url: Url = load_env("SECRETS_REMOTE_BASE_URL") + .context("SECRETS_REMOTE_BASE_URL is required")? + .parse() + .context("invalid SECRETS_REMOTE_BASE_URL")?; + + let unlock_ttl_secs: u64 = load_env("SECRETS_LOCAL_UNLOCK_TTL_SECS") + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_UNLOCK_TTL_SECS); + let exec_context_ttl_secs: u64 = load_env("SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS") + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_EXEC_CONTEXT_TTL_SECS); + + Ok(LocalConfig { + bind, + remote_base_url, + default_unlock_ttl: Duration::from_secs(unlock_ttl_secs.clamp(60, 86400 * 7)), + default_exec_context_ttl: Duration::from_secs(exec_context_ttl_secs.clamp(60, 86400 * 7)), + }) +} diff --git a/crates/secrets-mcp-local/src/exec.rs b/crates/secrets-mcp-local/src/exec.rs new file mode 100644 index 0000000..f965188 --- /dev/null +++ b/crates/secrets-mcp-local/src/exec.rs @@ -0,0 +1,200 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use tokio::process::Command; + +use crate::target::{ExecutionTarget, ResolvedTarget}; + +const MAX_OUTPUT_CHARS: usize = 64 * 1024; + +#[derive(Clone, Debug, Deserialize)] +pub struct TargetExecInput { + pub target_ref: Option, + pub target: Option, + pub command: String, + pub timeout_secs: Option, + pub working_dir: Option, + pub env_overrides: Option>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ExecResult { + pub resolved_target: ResolvedTarget, + pub resolved_env_keys: Vec, + pub command: String, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub timed_out: bool, + pub duration_ms: u128, + pub stdout_truncated: bool, + pub stderr_truncated: bool, +} + +fn truncate_output(text: String) -> (String, bool) { + if text.chars().count() <= MAX_OUTPUT_CHARS { + return (text, false); + } + let truncated = text.chars().take(MAX_OUTPUT_CHARS).collect::(); + (truncated, true) +} + +fn stringify_env_override(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(s) => Some(s.clone()), + Value::Bool(v) => Some(v.to_string()), + Value::Number(v) => Some(v.to_string()), + other => serde_json::to_string(other).ok(), + } +} + +fn apply_env_overrides( + env: &mut BTreeMap, + overrides: Option<&Map>, +) -> Result<()> { + let Some(overrides) = overrides else { + return Ok(()); + }; + for (key, value) in overrides { + if key.is_empty() || key.contains('=') { + return Err(anyhow!("invalid env override key: {key}")); + } + if key.starts_with("TARGET_") { + return Err(anyhow!( + "env override `{key}` cannot override reserved TARGET_* variables" + )); + } + if let Some(value) = stringify_env_override(value) { + env.insert(key.clone(), value); + } + } + Ok(()) +} + +pub async fn execute_command( + input: &TargetExecInput, + target: &ExecutionTarget, + timeout_secs: u64, +) -> Result { + let mut env = target.env.clone(); + apply_env_overrides(&mut env, input.env_overrides.as_ref())?; + + let started = std::time::Instant::now(); + let mut command = Command::new("/bin/sh"); + command + .arg("-lc") + .arg(&input.command) + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + if let Some(dir) = input.working_dir.as_ref().filter(|dir| !dir.is_empty()) { + command.current_dir(dir); + } + for (key, value) in &env { + command.env(key, value); + } + + let child = command + .spawn() + .with_context(|| format!("failed to spawn command: {}", input.command))?; + + let timed = tokio::time::timeout( + Duration::from_secs(timeout_secs.clamp(1, 86400)), + child.wait_with_output(), + ) + .await; + + let (exit_code, stdout, stderr, timed_out) = match timed { + Ok(output) => { + let output = output.context("failed waiting for command output")?; + ( + output.status.code(), + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + false, + ) + } + Err(_) => (None, String::new(), "command timed out".to_string(), true), + }; + + let (stdout, stdout_truncated) = truncate_output(stdout); + let (stderr, stderr_truncated) = truncate_output(stderr); + + Ok(ExecResult { + resolved_target: target.resolved.clone(), + resolved_env_keys: target.resolved_env_keys(), + command: input.command.clone(), + exit_code, + stdout, + stderr, + timed_out, + duration_ms: started.elapsed().as_millis(), + stdout_truncated, + stderr_truncated, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::target::ExecutionTarget; + use serde_json::json; + + #[tokio::test] + async fn execute_command_injects_target_env() { + let target = ExecutionTarget { + resolved: ResolvedTarget { + id: "entry-1".to_string(), + folder: "refining".to_string(), + name: "api".to_string(), + entry_type: Some("service".to_string()), + }, + env: BTreeMap::from([ + ("TARGET_HOST".to_string(), "47.238.146.244".to_string()), + ("TARGET_API_KEY".to_string(), "sk_test_123".to_string()), + ]), + }; + let input = TargetExecInput { + target_ref: Some("entry-1".to_string()), + target: None, + command: "printf '%s|%s' \"$TARGET_HOST\" \"$TARGET_API_KEY\"".to_string(), + timeout_secs: Some(5), + working_dir: None, + env_overrides: None, + }; + let result = execute_command(&input, &target, 5).await.unwrap(); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout, "47.238.146.244|sk_test_123"); + } + + #[tokio::test] + async fn execute_command_rejects_reserved_target_override() { + let target = ExecutionTarget { + resolved: ResolvedTarget { + id: "entry-1".to_string(), + folder: "refining".to_string(), + name: "api".to_string(), + entry_type: Some("service".to_string()), + }, + env: BTreeMap::from([("TARGET_HOST".to_string(), "47.238.146.244".to_string())]), + }; + let input = TargetExecInput { + target_ref: Some("entry-1".to_string()), + target: None, + command: "echo test".to_string(), + timeout_secs: Some(5), + working_dir: None, + env_overrides: Some(serde_json::from_value(json!({"TARGET_HOST":"override"})).unwrap()), + }; + let err = execute_command(&input, &target, 5).await.unwrap_err(); + assert!( + err.to_string() + .contains("cannot override reserved TARGET_* variables") + ); + } +} diff --git a/crates/secrets-mcp-local/src/main.rs b/crates/secrets-mcp-local/src/main.rs index 6f040ff..2dc375e 100644 --- a/crates/secrets-mcp-local/src/main.rs +++ b/crates/secrets-mcp-local/src/main.rs @@ -1,368 +1,15 @@ -//! Local MCP gateway: single agent-facing MCP endpoint on localhost. -//! -//! Proxies JSON-RPC to `SECRETS_REMOTE_MCP_URL` and injects `Authorization` + -//! `X-Encryption-Key` from an in-memory unlock cache (TTL). Cursor can connect -//! without embedding the encryption key in its MCP config after a one-time -//! local unlock. - -use std::convert::Infallible; -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::{Duration, Instant}; +mod bind; +mod cache; +mod config; +mod exec; +mod mcp; +mod remote; +mod server; +mod target; +mod unlock; use anyhow::{Context, Result}; -use axum::Router; -use axum::body::Body; -use axum::extract::State; -use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; -use axum::response::{Html, IntoResponse, Response}; -use axum::routing::{get, post}; -use futures_util::TryStreamExt; -use serde::Deserialize; -use serde_json::json; -use tokio::sync::RwLock; -use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; -use url::Url; - -const DEFAULT_BIND: &str = "127.0.0.1:9316"; -const DEFAULT_TTL_SECS: u64 = 3600; - -/// Tools that return decrypted secret material; blocked when -/// `SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS` is not `1`/`true`/`yes`. -const PLAINTEXT_TOOL_NAMES: &[&str] = &["secrets_get", "secrets_export", "secrets_env_map"]; - -#[derive(Clone)] -struct AppState { - remote_mcp_url: Url, - dashboard_hint_url: String, - http_client: reqwest::Client, - unlock: Arc>>, - default_api_key: Option, - ttl: Duration, - allow_plaintext_tools: bool, -} - -struct UnlockState { - api_key: String, - encryption_key_hex: String, - expires_at: Instant, -} - -#[derive(Debug, Deserialize)] -struct UnlockBody { - /// 64-char hex encryption key (PBKDF2-derived), same as remote `X-Encryption-Key`. - encryption_key: String, - /// Optional if `SECRETS_LOCAL_API_KEY` is set in the environment. - api_key: Option, - /// Override TTL for this unlock (seconds). - #[serde(default)] - ttl_secs: Option, -} - -fn load_env(name: &str) -> Option { - std::env::var(name).ok().filter(|s| !s.is_empty()) -} - -fn parse_bool_env(name: &str, default: bool) -> bool { - match load_env(name).map(|s| s.to_ascii_lowercase()).as_deref() { - None => default, - Some("1" | "true" | "yes" | "on") => true, - Some("0" | "false" | "no" | "off") => false, - _ => default, - } -} - -fn dashboard_url_from_remote(remote: &Url) -> String { - load_env("SECRETS_REMOTE_DASHBOARD_URL").unwrap_or_else(|| { - let mut u = remote.clone(); - u.set_path("/dashboard"); - u.set_query(None); - u.set_fragment(None); - u.to_string() - }) -} - -/// If JSON-RPC targets a blocked tool, return an error response body instead of forwarding. -fn maybe_block_plaintext_request( - allow_plaintext: bool, - method: &Method, - body: &[u8], -) -> Option> { - if allow_plaintext || *method != Method::POST || body.is_empty() { - return None; - } - let value: serde_json::Value = serde_json::from_slice(body).ok()?; - - fn tool_blocked(name: &str) -> bool { - PLAINTEXT_TOOL_NAMES.contains(&name) - } - - fn block_single(id: serde_json::Value, name: &str) -> serde_json::Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32000, - "message": format!( - "Local gateway: tool `{name}` is disabled (set SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS=1 to allow)." - ) - } - }) - } - - match value { - serde_json::Value::Object(obj) => { - if obj.get("method").and_then(|m| m.as_str()) != Some("tools/call") { - return None; - } - let name = obj - .get("params") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str())?; - if !tool_blocked(name) { - return None; - } - let id = obj.get("id").cloned().unwrap_or(json!(null)); - Some(block_single(id, name).to_string().into_bytes()) - } - serde_json::Value::Array(arr) => { - let mut out = Vec::with_capacity(arr.len()); - let mut changed = false; - for item in arr { - if let serde_json::Value::Object(ref obj) = item - && obj.get("method").and_then(|m| m.as_str()) == Some("tools/call") - && let Some(name) = obj - .get("params") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) - && tool_blocked(name) - { - changed = true; - let id = obj.get("id").cloned().unwrap_or(json!(null)); - out.push(block_single(id, name)); - continue; - } - out.push(item); - } - if changed { - serde_json::to_vec(&out).ok() - } else { - None - } - } - _ => None, - } -} - -async fn index_html(State(state): State>) -> impl IntoResponse { - let remote = state.remote_mcp_url.as_str(); - let dash = &state.dashboard_hint_url; - Html(format!( - r#" - -secrets-mcp-local - -

本地 MCP Gateway

-

远程 MCP: {remote}

-

在浏览器打开 Dashboard 登录并复制 API Key:{dash}

-

然后在本机执行解锁(示例):

-
curl -sS -X POST http://127.0.0.1:9316/local/unlock \
-  -H "Content-Type: application/json" \
-  -d '{{"encryption_key":"YOUR_64_HEX","api_key":"YOUR_API_KEY"}}'
-

或将 Cursor MCP 指向 http://127.0.0.1:9316/mcp(无需在配置里写 X-Encryption-Key)。

-

/local/status

- -"#, - remote = remote, - dash = dash - )) -} - -async fn local_status(State(state): State>) -> impl IntoResponse { - let guard = state.unlock.read().await; - let now = Instant::now(); - let body = match guard.as_ref() { - None => json!({ "unlocked": false }), - Some(u) if u.expires_at <= now => json!({ "unlocked": false, "reason": "expired" }), - Some(u) => json!({ - "unlocked": true, - "expires_in_secs": u.expires_at.duration_since(now).as_secs(), - "allow_plaintext_tools": state.allow_plaintext_tools, - }), - }; - (StatusCode::OK, axum::Json(body)) -} - -async fn local_unlock( - State(state): State>, - axum::Json(body): axum::Json, -) -> Result { - let hex = body.encryption_key.trim(); - if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(( - StatusCode::BAD_REQUEST, - "encryption_key must be 64 hex characters".to_string(), - )); - } - let api_key = body - .api_key - .or_else(|| state.default_api_key.clone()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - "api_key required (or set SECRETS_LOCAL_API_KEY)".to_string(), - ) - })?; - - let ttl_secs = body.ttl_secs.unwrap_or(state.ttl.as_secs()); - let ttl = Duration::from_secs(ttl_secs.clamp(60, 86400 * 7)); - - let expires_at = Instant::now() + ttl; - let mut guard = state.unlock.write().await; - *guard = Some(UnlockState { - api_key, - encryption_key_hex: hex.to_string(), - expires_at, - }); - - tracing::info!( - ttl_secs = ttl.as_secs(), - "local unlock: credentials cached until expiry" - ); - - Ok(( - StatusCode::OK, - axum::Json(json!({ - "ok": true, - "expires_in_secs": ttl.as_secs(), - })), - )) -} - -async fn local_lock(State(state): State>) -> impl IntoResponse { - let mut guard = state.unlock.write().await; - *guard = None; - tracing::info!("local lock: credentials cleared"); - (StatusCode::OK, axum::Json(json!({ "ok": true }))) -} - -fn header_value_copy(h: &axum::http::HeaderValue) -> Option { - HeaderValue::from_bytes(h.as_bytes()).ok() -} - -async fn proxy_mcp( - State(state): State>, - method: Method, - headers: HeaderMap, - body: Body, -) -> Result { - let now = Instant::now(); - let unlock = state.unlock.read().await; - let Some(u) = unlock.as_ref() else { - return Ok(Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - axum::http::header::CONTENT_TYPE, - "application/json; charset=utf-8", - ) - .body(Body::from( - r#"{"error":"local gateway locked: POST /local/unlock first"}"#, - )) - .unwrap()); - }; - if u.expires_at <= now { - drop(unlock); - let mut w = state.unlock.write().await; - *w = None; - return Ok(Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - axum::http::header::CONTENT_TYPE, - "application/json; charset=utf-8", - ) - .body(Body::from( - r#"{"error":"local gateway unlock expired: POST /local/unlock again"}"#, - )) - .unwrap()); - } - - let api_key = u.api_key.clone(); - let enc_key = u.encryption_key_hex.clone(); - drop(unlock); - - let bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await { - Ok(b) => b.to_vec(), - Err(e) => { - tracing::warn!(error = %e, "read body failed"); - return Ok(Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from("body read failed")) - .unwrap()); - } - }; - - let body_to_send = if let Some(blocked) = - maybe_block_plaintext_request(state.allow_plaintext_tools, &method, &bytes) - { - blocked - } else { - bytes - }; - - let mut req_builder = state - .http_client - .request(method.clone(), state.remote_mcp_url.as_str()) - .body(body_to_send); - - // Forward MCP session / accept headers from client. - for name in ["accept", "content-type", "mcp-session-id", "x-mcp-session"] { - if let Ok(hn) = HeaderName::from_bytes(name.as_bytes()) - && let Some(v) = headers.get(&hn) - && let Some(copy) = header_value_copy(v) - { - req_builder = req_builder.header(hn, copy); - } - } - - req_builder = req_builder - .header( - axum::http::header::AUTHORIZATION, - format!("Bearer {}", api_key), - ) - .header("X-Encryption-Key", enc_key); - - let upstream = match req_builder.send().await { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "upstream request failed"); - return Ok(Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from(format!("upstream error: {e}"))) - .unwrap()); - } - }; - - let status = upstream.status(); - let mut response_builder = Response::builder().status(status.as_u16()); - - for (key, value) in upstream.headers().iter() { - // Skip hop-by-hop headers if any; reqwest already decompresses. - let key_str = key.as_str(); - if key_str.eq_ignore_ascii_case("transfer-encoding") { - continue; - } - if let Some(v) = header_value_copy(value) { - response_builder = response_builder.header(key, v); - } - } - - let stream = upstream.bytes_stream().map_err(std::io::Error::other); - let body = Body::from_stream(stream); - - Ok(response_builder.body(body).unwrap()) -} #[tokio::main] async fn main() -> Result<()> { @@ -375,76 +22,34 @@ async fn main() -> Result<()> { ) .init(); - let remote_mcp_url = load_env("SECRETS_REMOTE_MCP_URL") - .context("SECRETS_REMOTE_MCP_URL is required (e.g. https://secrets.example.com/mcp)")?; - let remote_mcp_url: Url = remote_mcp_url - .parse() - .context("invalid SECRETS_REMOTE_MCP_URL")?; + let config = config::load_config()?; + let remote = std::sync::Arc::new(remote::RemoteClient::new(config.remote_base_url.clone())?); + let cache = cache::new_cache(); + let cleanup = cache::spawn_cleanup_task(cache.clone()); - let dashboard_hint_url = dashboard_url_from_remote(&remote_mcp_url); - let bind = load_env("SECRETS_MCP_LOCAL_BIND").unwrap_or_else(|| DEFAULT_BIND.to_string()); - let default_api_key = load_env("SECRETS_LOCAL_API_KEY"); - let ttl_secs: u64 = load_env("SECRETS_LOCAL_UNLOCK_TTL_SECS") - .and_then(|s| s.parse().ok()) - .unwrap_or(DEFAULT_TTL_SECS); - let ttl = Duration::from_secs(ttl_secs); - let allow_plaintext_tools = parse_bool_env("SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS", false); - - let http_client = reqwest::Client::builder() - .timeout(Duration::from_secs(120)) - .build() - .context("failed to build HTTP client")?; - - let state = Arc::new(AppState { - remote_mcp_url: remote_mcp_url.clone(), - dashboard_hint_url, - http_client, - unlock: Arc::new(RwLock::new(None)), - default_api_key, - ttl, - allow_plaintext_tools, - }); - - let app = Router::new() - .route("/", get(index_html)) - .route("/local/unlock", post(local_unlock)) - .route("/local/lock", post(local_lock)) - .route("/local/status", get(local_status)) - .route("/mcp", axum::routing::any(proxy_mcp)) - .layer( - CorsLayer::new() - .allow_origin(tower_http::cors::Any) - .allow_methods(tower_http::cors::Any) - .allow_headers(tower_http::cors::Any), - ) - .layer(tower_http::limit::RequestBodyLimitLayer::new( - 10 * 1024 * 1024, - )) - .with_state(state); - - let addr: SocketAddr = bind - .parse() - .with_context(|| format!("invalid SECRETS_MCP_LOCAL_BIND: {bind}"))?; + let app_state = server::AppState { + config: config.clone(), + cache, + remote, + }; + let app = server::router(app_state); tracing::info!( - bind = %addr, - remote = %remote_mcp_url, - allow_plaintext_tools = allow_plaintext_tools, - "secrets-mcp-local gateway" + bind = %config.bind, + remote = %config.remote_base_url, + "secrets-mcp-local service started" ); - tracing::info!("MCP (agent): http://{}/mcp", addr); - tracing::info!("Unlock: POST http://{}/local/unlock", addr); - let listener = tokio::net::TcpListener::bind(addr) + let listener = tokio::net::TcpListener::bind(config.bind) .await - .with_context(|| format!("failed to bind {addr}"))?; + .with_context(|| format!("failed to bind {}", config.bind))?; - axum::serve( + let result = axum::serve( listener, - app.into_make_service_with_connect_info::(), + app.into_make_service_with_connect_info::(), ) .await - .context("server error")?; - - Ok(()) + .context("server error"); + cleanup.abort(); + result } diff --git a/crates/secrets-mcp-local/src/mcp.rs b/crates/secrets-mcp-local/src/mcp.rs new file mode 100644 index 0000000..499a2e2 --- /dev/null +++ b/crates/secrets-mcp-local/src/mcp.rs @@ -0,0 +1,828 @@ +use std::convert::Infallible; +use std::time::Instant; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{StatusCode, header}; +use axum::response::Response; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::bind::{exchange_bind, start_bind}; +use crate::cache::{ExecContext, GatewayPhase}; +use crate::exec::{TargetExecInput, execute_command}; +use crate::server::AppState; +use crate::target::{TargetSnapshot, build_execution_target}; +use crate::unlock::status_payload; + +const LOCAL_EXEC_TOOL: &str = "target_exec"; + +#[derive(Deserialize, Default)] +struct BindExchangeArgs { + bind_id: Option, + device_code: Option, +} + +fn json_response(status: StatusCode, value: Value) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .body(Body::from(value.to_string())) + .unwrap() +} + +fn jsonrpc_result_response(id: Value, result: Value) -> Response { + json_response( + StatusCode::OK, + json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }), + ) +} + +fn tool_success_response(id: Value, value: Value) -> Response { + let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()); + jsonrpc_result_response( + id, + json!({ + "content": [ + { + "type": "text", + "text": pretty, + } + ], + "isError": false + }), + ) +} + +fn tool_error_response(id: Value, message: impl Into) -> Response { + jsonrpc_result_response( + id, + json!({ + "content": [ + { + "type": "text", + "text": message.into(), + } + ], + "isError": true + }), + ) +} + +fn empty_notification_response() -> Response { + Response::builder() + .status(StatusCode::ACCEPTED) + .body(Body::empty()) + .unwrap() +} + +fn method_not_found(id: Value, method: &str) -> Response { + json_response( + StatusCode::OK, + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32601, + "message": format!("method `{method}` not supported by secrets-mcp-local"), + } + }), + ) +} + +fn invalid_request_response(message: impl Into) -> Response { + json_response( + StatusCode::BAD_REQUEST, + json!({ + "jsonrpc": "2.0", + "id": null, + "error": { + "code": -32600, + "message": message.into(), + } + }), + ) +} + +fn status_tool_definitions() -> Vec { + vec![ + json!({ + "name": "local_status", + "description": "Read the local gateway readiness state, onboarding URL, unlock URL, and any pending approval session.", + "inputSchema": { "type": "object", "properties": {} }, + "annotations": { "title": "Local MCP Status" } + }), + json!({ + "name": "local_unlock_status", + "description": "Return whether the local gateway is waiting for passphrase unlock or already ready.", + "inputSchema": { "type": "object", "properties": {} }, + "annotations": { "title": "Local Unlock Status" } + }), + json!({ + "name": "local_onboarding_info", + "description": "Return the local onboarding page URL, MCP URL, and current next-step guidance for the user.", + "inputSchema": { "type": "object", "properties": {} }, + "annotations": { "title": "Local Onboarding Info" } + }), + ] +} + +fn bind_tool_definitions() -> Vec { + vec![ + json!({ + "name": "local_bind_start", + "description": "Start a new remote authorization session and return the approve_url that the user should open in a browser.", + "inputSchema": { "type": "object", "properties": {} }, + "annotations": { "title": "Start Local MCP Binding" } + }), + json!({ + "name": "local_bind_exchange", + "description": "Poll the current bind session. When the user has approved in the browser, this moves the gateway into pendingUnlock and returns the local unlock URL.", + "inputSchema": { + "type": "object", + "properties": { + "bind_id": { "type": ["string", "null"] }, + "device_code": { "type": ["string", "null"] } + } + }, + "annotations": { "title": "Poll Binding State" } + }), + ] +} + +fn ready_tool_definitions() -> Vec { + vec![ + json!({ + "name": "secrets_find", + "description": "Find entries in the secrets store and return target snapshots suitable for target_exec.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": ["string", "null"] }, + "metadata_query": { "type": ["string", "null"] }, + "folder": { "type": ["string", "null"] }, + "type": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "name_query": { "type": ["string", "null"] }, + "tags": { "type": ["array", "null"], "items": { "type": "string" } }, + "limit": { "type": ["integer", "null"] }, + "offset": { "type": ["integer", "null"] } + } + }, + "annotations": { "title": "Find Secrets" } + }), + json!({ + "name": "secrets_search", + "description": "Search entries with optional summary mode. Returns metadata and secret field names, not secret values.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": ["string", "null"] }, + "metadata_query": { "type": ["string", "null"] }, + "folder": { "type": ["string", "null"] }, + "type": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "name_query": { "type": ["string", "null"] }, + "tags": { "type": ["array", "null"], "items": { "type": "string" } }, + "summary": { "type": ["boolean", "null"] }, + "sort": { "type": ["string", "null"] }, + "limit": { "type": ["integer", "null"] }, + "offset": { "type": ["integer", "null"] } + } + }, + "annotations": { "title": "Search Secrets" } + }), + json!({ + "name": "secrets_history", + "description": "View change history for an entry by id or by name/folder.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "folder": { "type": ["string", "null"] }, + "limit": { "type": ["integer", "null"] } + } + }, + "annotations": { "title": "View Secret History" } + }), + json!({ + "name": "secrets_overview", + "description": "Get counts of entries per folder and per type.", + "inputSchema": { "type": "object", "properties": {} }, + "annotations": { "title": "Secrets Overview" } + }), + json!({ + "name": "secrets_delete", + "description": "Preview deletions only. dry_run must be true.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "folder": { "type": ["string", "null"] }, + "type": { "type": ["string", "null"] }, + "dry_run": { "type": ["boolean", "null"] } + } + }, + "annotations": { "title": "Delete Secret Entry Preview", "destructiveHint": true } + }), + json!({ + "name": LOCAL_EXEC_TOOL, + "description": "Execute a standard local command against a resolved secrets target. The local gateway injects target metadata and secret values as environment variables without exposing raw secret values to the AI.", + "inputSchema": { + "type": "object", + "properties": { + "target_ref": { + "type": ["string", "null"], + "description": "Target entry id from secrets_find/secrets_search. Required on first use; later calls may reuse the cached execution context for the same entry id." + }, + "target": { + "type": ["object", "null"], + "description": "Optional target snapshot copied from secrets_find/secrets_search. Required on first use when the local gateway has not cached this entry id yet." + }, + "command": { + "type": "string", + "description": "Standard shell command to execute locally, such as ssh/curl/docker/http." + }, + "timeout_secs": { + "type": ["integer", "null"], + "description": "Execution timeout in seconds." + }, + "working_dir": { + "type": ["string", "null"], + "description": "Optional working directory for the command." + }, + "env_overrides": { + "type": ["object", "null"], + "description": "Optional extra environment variables. Reserved TARGET_* names cannot be overridden." + } + }, + "required": ["command"] + }, + "annotations": { "title": "Execute Against Target" } + }), + ] +} + +fn tools_for_phase(phase: GatewayPhase) -> Vec { + let mut tools = status_tool_definitions(); + if phase != GatewayPhase::Ready { + tools.extend(bind_tool_definitions()); + } + if phase == GatewayPhase::Ready { + tools.extend(ready_tool_definitions()); + } + tools +} + +async fn current_phase_and_status(state: &AppState) -> (GatewayPhase, Value) { + let payload = status_payload(state).await; + let phase = payload + .get("state") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or(GatewayPhase::Bootstrap); + (phase, payload) +} + +fn instructions_for_phase(phase: GatewayPhase) -> &'static str { + match phase { + GatewayPhase::Bootstrap => { + "Use local_status and local_bind_start first. The user must open the approve_url in a browser before the local gateway can continue." + } + GatewayPhase::PendingUnlock => { + "Remote authorization is complete. Ask the user to open the local unlock page and finish passphrase unlock before calling business tools." + } + GatewayPhase::Ready => { + "The local gateway is ready. Use secrets_find/secrets_search for discovery and target_exec for delegated command execution against decrypted targets." + } + } +} + +fn initialize_response(id: Value, phase: GatewayPhase) -> Response { + let session_id = format!( + "local-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) + ); + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "secrets-mcp-local", + "version": env!("CARGO_PKG_VERSION"), + "title": "Secrets MCP Local" + }, + "instructions": instructions_for_phase(phase), + } + }); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .header("mcp-session-id", session_id) + .body(Body::from(response.to_string())) + .unwrap() +} + +async fn resolve_target_context( + state: &AppState, + api_key: &str, + unlock_key: &str, + unlock_expires_at: Instant, + input: &TargetExecInput, +) -> anyhow::Result { + let target_ref = input + .target_ref + .clone() + .or_else(|| input.target.as_ref().map(|t| t.id.clone())) + .ok_or_else(|| anyhow::anyhow!("target_ref is required"))?; + + { + let mut guard = state.cache.write().await; + if let Some(ctx) = guard.exec_contexts.get_mut(&target_ref) + && ctx.expires_at > Instant::now() + { + ctx.last_used_at = Instant::now(); + return Ok(ctx.target.clone()); + } + } + + let snapshot: TargetSnapshot = input.target.clone().ok_or_else(|| { + anyhow::anyhow!( + "target details required on first use for entry `{target_ref}`; pass the matching secrets_find/search result as `target`" + ) + })?; + if snapshot.id != target_ref { + return Err(anyhow::anyhow!( + "target_ref `{target_ref}` does not match target.id `{}`", + snapshot.id + )); + } + + let secrets = state + .remote + .get_entry_secrets_by_id(api_key, unlock_key, &target_ref) + .await?; + let target = build_execution_target(&snapshot, &secrets)?; + let expires_at = std::cmp::min( + Instant::now() + state.config.default_exec_context_ttl, + unlock_expires_at, + ); + let mut guard = state.cache.write().await; + guard.exec_contexts.insert( + target_ref, + ExecContext { + target: target.clone(), + expires_at, + last_used_at: Instant::now(), + }, + ); + Ok(target) +} + +async fn handle_target_exec(state: &AppState, id: Value, args: Option) -> Response { + let input: TargetExecInput = match args { + Some(value) => match serde_json::from_value(value) { + Ok(input) => input, + Err(err) => { + return tool_error_response(id, format!("invalid `{LOCAL_EXEC_TOOL}` args: {err}")); + } + }, + None => { + return tool_error_response(id, format!("`{LOCAL_EXEC_TOOL}` arguments are required")); + } + }; + if input.command.trim().is_empty() { + return tool_error_response(id, "command is required"); + } + + let api_key = { + let guard = state.cache.read().await; + match guard.bound.as_ref() { + Some(bound) => bound.api_key.clone(), + None => { + return tool_error_response( + id, + "local MCP is not bound; call local_bind_start first", + ); + } + } + }; + let (unlock_key, unlock_expires_at) = { + let mut guard = state.cache.write().await; + match guard.unlock.as_mut() { + Some(unlock) if unlock.expires_at > Instant::now() => { + unlock.last_used_at = Instant::now(); + (unlock.encryption_key_hex.clone(), unlock.expires_at) + } + _ => { + guard.clear_unlock_and_exec(); + return tool_error_response( + id, + "local MCP is not unlocked; ask the user to open the local unlock page first", + ); + } + } + }; + let target = + match resolve_target_context(state, &api_key, &unlock_key, unlock_expires_at, &input).await + { + Ok(target) => target, + Err(err) => return tool_error_response(id, format!("failed resolving target: {err}")), + }; + let timeout_secs = input.timeout_secs.unwrap_or(120).clamp(1, 3600); + let result = match execute_command(&input, &target, timeout_secs).await { + Ok(result) => result, + Err(err) => return tool_error_response(id, format!("execution failed: {err}")), + }; + tool_success_response( + id, + serde_json::to_value(result).unwrap_or_else(|_| json!({})), + ) +} + +async fn handle_bootstrap_tool( + state: &AppState, + tool_name: &str, + id: Value, + args: Option, +) -> Response { + match tool_name { + "local_status" | "local_unlock_status" | "local_onboarding_info" => { + tool_success_response(id, status_payload(state).await) + } + "local_bind_start" => match start_bind(state).await { + Ok(payload) => tool_success_response(id, payload), + Err((_status, message)) => tool_error_response(id, message), + }, + "local_bind_exchange" => { + let parsed = match args { + Some(value) => match serde_json::from_value::(value) { + Ok(parsed) => parsed, + Err(err) => { + return tool_error_response( + id, + format!("invalid local_bind_exchange args: {err}"), + ); + } + }, + None => BindExchangeArgs::default(), + }; + match exchange_bind(state, parsed.bind_id, parsed.device_code).await { + Ok((_status, payload)) => tool_success_response(id, payload), + Err((_status, message)) => tool_error_response(id, message), + } + } + _ => tool_error_response(id, format!("unknown bootstrap tool `{tool_name}`")), + } +} + +fn bootstrap_tool_allowed_in_phase(tool_name: &str, phase: GatewayPhase) -> bool { + is_status_tool(tool_name) || (phase != GatewayPhase::Ready && is_bind_tool(tool_name)) +} + +fn is_status_tool(tool_name: &str) -> bool { + matches!( + tool_name, + "local_status" | "local_unlock_status" | "local_onboarding_info" + ) +} + +fn is_bind_tool(tool_name: &str) -> bool { + matches!(tool_name, "local_bind_start" | "local_bind_exchange") +} + +fn is_bootstrap_tool(tool_name: &str) -> bool { + is_status_tool(tool_name) || is_bind_tool(tool_name) +} + +fn is_ready_tool(tool_name: &str) -> bool { + matches!( + tool_name, + "secrets_find" + | "secrets_search" + | "secrets_history" + | "secrets_overview" + | "secrets_delete" + | LOCAL_EXEC_TOOL + ) +} + +fn not_ready_message(status: &Value) -> String { + let onboarding_url = status + .get("onboarding_url") + .and_then(|v| v.as_str()) + .unwrap_or("/"); + let state_name = status + .get("state") + .and_then(|v| v.as_str()) + .unwrap_or("bootstrap"); + format!( + "local MCP is not ready (state: {state_name}). Use local_status/local_bind_start first and ask the user to complete onboarding at {onboarding_url}" + ) +} + +async fn handle_ready_tool( + state: &AppState, + tool_name: &str, + id: Value, + args: Option, +) -> Response { + let api_key = { + let guard = state.cache.read().await; + match guard.bound.as_ref() { + Some(bound) => bound.api_key.clone(), + None => return tool_error_response(id, "local MCP is not bound"), + } + }; + let args_value = args.unwrap_or_else(|| json!({})); + let result = match tool_name { + "secrets_find" => state.remote.entries_find(&api_key, &args_value).await, + "secrets_search" => state.remote.entries_search(&api_key, &args_value).await, + "secrets_history" => state.remote.entry_history(&api_key, &args_value).await, + "secrets_overview" => state.remote.entries_overview(&api_key).await, + "secrets_delete" => { + if args_value.get("dry_run").and_then(|value| value.as_bool()) != Some(true) { + return tool_error_response( + id, + "secrets_delete is exposed in local mode only for dry_run=true previews", + ); + } + state.remote.delete_preview(&api_key, &args_value).await + } + LOCAL_EXEC_TOOL => return handle_target_exec(state, id, Some(args_value)).await, + _ => return tool_error_response(id, format!("unknown ready tool `{tool_name}`")), + }; + match result { + Ok(value) => tool_success_response(id, value), + Err(err) => tool_error_response(id, err.to_string()), + } +} + +pub async fn handle_mcp(State(state): State, body: Body) -> Result { + let body_bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await { + Ok(bytes) => bytes, + Err(_) => return Ok(invalid_request_response("invalid request body")), + }; + let request: Value = match serde_json::from_slice(&body_bytes) { + Ok(request) => request, + Err(err) => { + return Ok(invalid_request_response(format!( + "invalid json body: {err}" + ))); + } + }; + let method = request + .get("method") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let id = request.get("id").cloned().unwrap_or(json!(null)); + let (phase, status) = current_phase_and_status(&state).await; + + let response = match method { + "initialize" => initialize_response(id, phase), + "notifications/initialized" => empty_notification_response(), + "tools/list" => jsonrpc_result_response(id, json!({ "tools": tools_for_phase(phase) })), + "tools/call" => { + let params = request.get("params").cloned().unwrap_or_else(|| json!({})); + let tool_name = params + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let args = params.get("arguments").cloned(); + if is_bootstrap_tool(tool_name) { + if !bootstrap_tool_allowed_in_phase(tool_name, phase) { + tool_error_response( + id, + "local MCP is already ready; binding tools are disabled until you explicitly unbind", + ) + } else { + handle_bootstrap_tool(&state, tool_name, id, args).await + } + } else if phase != GatewayPhase::Ready { + tool_error_response(id, not_ready_message(&status)) + } else if is_ready_tool(tool_name) { + handle_ready_tool(&state, tool_name, id, args).await + } else { + tool_error_response( + id, + format!("tool `{tool_name}` is not exposed by local policy"), + ) + } + } + "ping" => jsonrpc_result_response(id, json!({})), + _ => method_not_found(id, method), + }; + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::{BoundState, UnlockState, new_cache}; + use crate::config::LocalConfig; + use crate::remote::RemoteClient; + use crate::server::AppState; + use std::sync::Arc; + use std::time::Duration; + use url::Url; + use uuid::Uuid; + + fn test_state() -> AppState { + AppState { + config: LocalConfig { + bind: "127.0.0.1:9316".parse().unwrap(), + remote_base_url: Url::parse("https://example.com").unwrap(), + default_unlock_ttl: Duration::from_secs(3600), + default_exec_context_ttl: Duration::from_secs(3600), + }, + cache: new_cache(), + remote: Arc::new( + RemoteClient::new(Url::parse("https://example.com").unwrap()).unwrap(), + ), + } + } + + #[test] + fn bootstrap_phase_hides_ready_tools() { + let tools = tools_for_phase(GatewayPhase::Bootstrap); + let names: Vec<_> = tools + .iter() + .filter_map(|tool| tool.get("name").and_then(|value| value.as_str())) + .collect(); + assert!(names.contains(&"local_status")); + assert!(names.contains(&"local_bind_start")); + assert!(!names.contains(&"secrets_find")); + assert!(!names.contains(&LOCAL_EXEC_TOOL)); + } + + #[tokio::test] + async fn initialize_succeeds_when_unbound() { + let response = handle_mcp( + State(test_state()), + Body::from( + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }) + .to_string(), + ), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn tools_list_returns_bootstrap_tools_when_unbound() { + let response = handle_mcp( + State(test_state()), + Body::from( + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }) + .to_string(), + ), + ) + .await + .unwrap(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + let names: Vec<_> = value["result"]["tools"] + .as_array() + .unwrap() + .iter() + .filter_map(|tool| tool.get("name").and_then(|name| name.as_str())) + .collect(); + assert!(names.contains(&"local_status")); + assert!(names.contains(&"local_bind_exchange")); + assert!(!names.contains(&"secrets_find")); + } + + #[tokio::test] + async fn tools_list_in_ready_phase_exposes_business_tools() { + let state = test_state(); + { + let mut guard = state.cache.write().await; + guard.bound = Some(BoundState { + user_id: Uuid::nil(), + api_key: "api-key".to_string(), + key_salt_hex: None, + key_check_hex: None, + key_params: None, + key_version: 0, + bound_at: Instant::now(), + }); + guard.unlock = Some(UnlockState { + encryption_key_hex: "11".repeat(32), + expires_at: Instant::now() + Duration::from_secs(600), + last_used_at: Instant::now(), + }); + } + let response = handle_mcp( + State(state), + Body::from( + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/list", + "params": {} + }) + .to_string(), + ), + ) + .await + .unwrap(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + let names: Vec<_> = value["result"]["tools"] + .as_array() + .unwrap() + .iter() + .filter_map(|tool| tool.get("name").and_then(|name| name.as_str())) + .collect(); + assert!(names.contains(&"local_status")); + assert!(names.contains(&"secrets_find")); + assert!(names.contains(&LOCAL_EXEC_TOOL)); + assert!(!names.contains(&"local_bind_start")); + } + + #[tokio::test] + async fn tools_call_rejects_bind_start_when_ready() { + let state = test_state(); + { + let mut guard = state.cache.write().await; + guard.bound = Some(BoundState { + user_id: Uuid::nil(), + api_key: "api-key".to_string(), + key_salt_hex: None, + key_check_hex: None, + key_params: None, + key_version: 0, + bound_at: Instant::now(), + }); + guard.unlock = Some(UnlockState { + encryption_key_hex: "11".repeat(32), + expires_at: Instant::now() + Duration::from_secs(600), + last_used_at: Instant::now(), + }); + } + let response = handle_mcp( + State(state), + Body::from( + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "local_bind_start", + "arguments": {} + } + }) + .to_string(), + ), + ) + .await + .unwrap(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["result"]["isError"], Value::Bool(true)); + assert!(value.get("error").is_none()); + } + + #[tokio::test] + async fn tool_error_response_uses_mcp_tool_result_shape() { + let response = tool_error_response(json!(9), "boom"); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["id"], json!(9)); + assert_eq!(value["result"]["isError"], Value::Bool(true)); + assert_eq!(value["result"]["content"][0]["text"], json!("boom")); + assert!(value.get("error").is_none()); + } +} diff --git a/crates/secrets-mcp-local/src/remote.rs b/crates/secrets-mcp-local/src/remote.rs new file mode 100644 index 0000000..1620cff --- /dev/null +++ b/crates/secrets-mcp-local/src/remote.rs @@ -0,0 +1,263 @@ +use std::collections::HashMap; + +use anyhow::{Context, Result, anyhow}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use url::Url; +use uuid::Uuid; + +#[derive(Clone)] +pub struct RemoteClient { + pub http_client: reqwest::Client, + pub remote_base_url: Url, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BindStartResponse { + pub bind_id: String, + pub device_code: String, + pub approve_url: String, + pub expires_in_secs: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BindExchangeResponse { + pub status: Option, + pub user_id: Option, + pub api_key: Option, + pub key_salt_hex: Option, + pub key_check_hex: Option, + pub key_params: Option, + pub key_version: Option, +} + +#[derive(Debug)] +pub struct BindExchangeResult { + pub status: u16, + pub body: Value, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BindRefreshResponse { + pub user_id: Uuid, + pub key_salt_hex: Option, + pub key_check_hex: Option, + pub key_params: Option, + pub key_version: i64, +} + +#[derive(Debug)] +pub struct BindRefreshResult { + pub status: u16, + pub body: Option, +} + +impl RemoteClient { + pub fn new(remote_base_url: Url) -> Result { + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .context("failed to build HTTP client")?; + Ok(Self { + http_client, + remote_base_url, + }) + } + + fn authed_request( + &self, + method: reqwest::Method, + path: &str, + api_key: &str, + encryption_key_hex: Option<&str>, + ) -> reqwest::RequestBuilder { + let mut url = self.remote_base_url.clone(); + url.set_path(path); + let mut req = self + .http_client + .request(method, url.as_str()) + .bearer_auth(api_key) + .header(reqwest::header::ACCEPT, "application/json"); + if let Some(key) = encryption_key_hex { + req = req.header("X-Encryption-Key", key); + } + req + } + + async fn parse_json_response( + &self, + res: reqwest::Response, + label: &str, + ) -> Result { + let status = res.status(); + let bytes = res + .bytes() + .await + .with_context(|| format!("{label} body read failed"))?; + let value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice::(&bytes).unwrap_or_else(|_| { + Value::String(String::from_utf8_lossy(&bytes).trim().to_string()) + }) + }; + if !status.is_success() { + let message = value + .get("error") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| value.to_string()); + return Err(anyhow!("{label} failed ({}): {message}", status)); + } + Ok(value) + } + + pub async fn bind_start(&self) -> Result { + let mut url = self.remote_base_url.clone(); + url.set_path("/api/local-mcp/bind/start"); + let res = self + .http_client + .post(url.as_str()) + .send() + .await + .context("bind/start request failed")?; + if !res.status().is_success() { + return Err(anyhow!("bind/start failed: {}", res.status())); + } + res.json::() + .await + .context("invalid bind/start response") + } + + pub async fn bind_exchange( + &self, + bind_id: &str, + device_code: &str, + ) -> Result { + let mut url = self.remote_base_url.clone(); + url.set_path("/api/local-mcp/bind/exchange"); + let res = self + .http_client + .post(url.as_str()) + .json(&serde_json::json!({ + "bind_id": bind_id, + "device_code": device_code, + })) + .send() + .await + .context("bind/exchange request failed")?; + let status = res.status().as_u16(); + let bytes = res + .bytes() + .await + .context("bind/exchange body read failed")?; + let body = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice::(&bytes).unwrap_or_else(|_| { + Value::String(String::from_utf8_lossy(&bytes).trim().to_string()) + }) + }; + Ok(BindExchangeResult { status, body }) + } + + pub async fn bind_refresh(&self, api_key: &str) -> Result { + let mut url = self.remote_base_url.clone(); + url.set_path("/api/local-mcp/bind/refresh"); + let res = self + .http_client + .post(url.as_str()) + .header( + axum::http::header::AUTHORIZATION, + format!("Bearer {api_key}"), + ) + .send() + .await + .context("bind/refresh request failed")?; + let status = res.status().as_u16(); + if !res.status().is_success() { + return Ok(BindRefreshResult { status, body: None }); + } + let body = res + .json::() + .await + .context("invalid bind/refresh response")?; + Ok(BindRefreshResult { + status, + body: Some(body), + }) + } + + async fn post_api_json( + &self, + api_key: &str, + encryption_key_hex: Option<&str>, + path: &str, + body: &Value, + ) -> Result { + let res = self + .authed_request(reqwest::Method::POST, path, api_key, encryption_key_hex) + .json(body) + .send() + .await + .with_context(|| format!("{path} request failed"))?; + self.parse_json_response(res, path).await + } + + async fn get_api_json( + &self, + api_key: &str, + encryption_key_hex: Option<&str>, + path: &str, + ) -> Result { + let req = self.authed_request(reqwest::Method::GET, path, api_key, encryption_key_hex); + let res = req + .send() + .await + .with_context(|| format!("{path} request failed"))?; + Ok(res) + } + + pub async fn entries_find(&self, api_key: &str, args: &Value) -> Result { + self.post_api_json(api_key, None, "/api/local-mcp/entries/find", args) + .await + } + + pub async fn entries_search(&self, api_key: &str, args: &Value) -> Result { + self.post_api_json(api_key, None, "/api/local-mcp/entries/search", args) + .await + } + + pub async fn entry_history(&self, api_key: &str, args: &Value) -> Result { + self.post_api_json(api_key, None, "/api/local-mcp/entries/history", args) + .await + } + + pub async fn entries_overview(&self, api_key: &str) -> Result { + let res = self + .get_api_json(api_key, None, "/api/local-mcp/entries/overview") + .await?; + self.parse_json_response(res, "/api/local-mcp/entries/overview") + .await + } + + pub async fn delete_preview(&self, api_key: &str, args: &Value) -> Result { + self.post_api_json(api_key, None, "/api/local-mcp/entries/delete-preview", args) + .await + } + + pub async fn get_entry_secrets_by_id( + &self, + api_key: &str, + encryption_key_hex: &str, + entry_id: &str, + ) -> Result> { + let path = format!("/api/local-mcp/entries/{entry_id}/secrets"); + let res = self + .get_api_json(api_key, Some(encryption_key_hex), &path) + .await?; + let value = self.parse_json_response(res, &path).await?; + serde_json::from_value::>(value) + .context("invalid decrypt payload from remote HTTP API") + } +} diff --git a/crates/secrets-mcp-local/src/server.rs b/crates/secrets-mcp-local/src/server.rs new file mode 100644 index 0000000..3cbc5fa --- /dev/null +++ b/crates/secrets-mcp-local/src/server.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; + +use axum::Router; +use axum::extract::State; +use axum::response::{Html, IntoResponse}; +use axum::routing::{get, post}; + +use crate::cache::SharedCache; +use crate::config::LocalConfig; +use crate::remote::RemoteClient; + +#[derive(Clone)] +pub struct AppState { + pub config: LocalConfig, + pub cache: SharedCache, + pub remote: Arc, +} + +async fn index(State(state): State) -> impl IntoResponse { + Html(format!( + r#" + + + + secrets-mcp-local onboarding + + + +

secrets-mcp-local

+

本地 MCP 地址:http://{bind}/mcp

+

远端服务地址:{remote}

+ +
+

当前状态

+
loading...
+
+ + + 打开解锁页 + +
+
+ +
+

步骤 1:远端授权

+

点击“开始绑定”后,这里会显示授权地址。

+
+
+ +
+

步骤 2:本地解锁

+

授权完成后,本页会自动切换到解锁阶段。你也可以直接在下方完成解锁。

+ +
+ +
+

接入 Cursor

+

把 MCP 地址配置为 http://{bind}/mcp。在未就绪时,AI 只会看到 bootstrap 工具;完成授权和解锁后会自动暴露业务工具。

+
+ + + +"#, + bind = state.config.bind, + remote = state.config.remote_base_url, + )) +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/", get(index)) + .route("/mcp", axum::routing::any(crate::mcp::handle_mcp)) + .route("/local/bind/start", post(crate::bind::bind_start)) + .route("/local/bind/exchange", post(crate::bind::bind_exchange)) + .route("/local/unbind", post(crate::bind::unbind)) + .route("/unlock", get(crate::unlock::unlock_page)) + .route( + "/local/unlock/complete", + post(crate::unlock::unlock_complete), + ) + .route("/local/lock", post(crate::unlock::lock)) + .route("/local/status", get(crate::unlock::status)) + .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)) + .with_state(state) +} diff --git a/crates/secrets-mcp-local/src/target.rs b/crates/secrets-mcp-local/src/target.rs new file mode 100644 index 0000000..c1788b3 --- /dev/null +++ b/crates/secrets-mcp-local/src/target.rs @@ -0,0 +1,263 @@ +use std::collections::{BTreeMap, HashMap}; + +use anyhow::{Result, anyhow}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SecretFieldRef { + pub name: String, + #[serde(rename = "type")] + pub secret_type: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TargetSnapshot { + pub id: String, + pub folder: String, + pub name: String, + #[serde(rename = "type")] + pub entry_type: Option, + #[serde(default)] + pub notes: Option, + #[serde(default)] + pub metadata: Map, + #[serde(default)] + pub secret_fields: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ResolvedTarget { + pub id: String, + pub folder: String, + pub name: String, + #[serde(rename = "type")] + pub entry_type: Option, +} + +#[derive(Clone, Debug)] +pub struct ExecutionTarget { + pub resolved: ResolvedTarget, + pub env: BTreeMap, +} + +impl ExecutionTarget { + pub fn resolved_env_keys(&self) -> Vec { + self.env.keys().cloned().collect() + } +} + +fn stringify_value(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(s) => Some(s.clone()), + Value::Bool(v) => Some(v.to_string()), + Value::Number(v) => Some(v.to_string()), + other => serde_json::to_string(other).ok(), + } +} + +fn sanitize_env_key(key: &str) -> String { + let mut out = String::with_capacity(key.len()); + for ch in key.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_uppercase()); + } else { + out.push('_'); + } + } + while out.contains("__") { + out = out.replace("__", "_"); + } + out.trim_matches('_').to_string() +} + +fn set_if_missing(env: &mut BTreeMap, key: &str, value: Option) { + if let Some(value) = value.filter(|v| !v.is_empty()) { + env.entry(key.to_string()).or_insert(value); + } +} + +fn metadata_alias(metadata: &Map, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| metadata.get(*key)) + .and_then(stringify_value) +} + +fn secret_alias( + secrets: &HashMap, + secret_types: &HashMap<&str, Option<&str>>, + name_match: impl Fn(&str) -> bool, + type_match: impl Fn(Option<&str>) -> bool, +) -> Option { + secrets.iter().find_map(|(name, value)| { + let normalized = sanitize_env_key(name); + let ty = secret_types.get(name.as_str()).copied().flatten(); + if name_match(&normalized) || type_match(ty) { + stringify_value(value) + } else { + None + } + }) +} + +pub fn build_execution_target( + snapshot: &TargetSnapshot, + secrets: &HashMap, +) -> Result { + if snapshot.id.trim().is_empty() { + return Err(anyhow!("target snapshot missing id")); + } + + let mut env = BTreeMap::new(); + env.insert("TARGET_ENTRY_ID".to_string(), snapshot.id.clone()); + env.insert("TARGET_NAME".to_string(), snapshot.name.clone()); + env.insert("TARGET_FOLDER".to_string(), snapshot.folder.clone()); + if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) { + env.insert("TARGET_TYPE".to_string(), entry_type.clone()); + } + if let Some(notes) = snapshot.notes.as_ref().filter(|v| !v.is_empty()) { + env.insert("TARGET_NOTES".to_string(), notes.clone()); + } + + for (key, value) in &snapshot.metadata { + if let Some(value) = stringify_value(value) { + let name = sanitize_env_key(key); + if !name.is_empty() { + env.insert(format!("TARGET_META_{name}"), value); + } + } + } + + let secret_type_map: HashMap<&str, Option<&str>> = snapshot + .secret_fields + .iter() + .map(|field| (field.name.as_str(), field.secret_type.as_deref())) + .collect(); + + for (key, value) in secrets { + if let Some(value) = stringify_value(value) { + let name = sanitize_env_key(key); + if !name.is_empty() { + env.insert(format!("TARGET_SECRET_{name}"), value); + } + } + } + + set_if_missing( + &mut env, + "TARGET_HOST", + metadata_alias( + &snapshot.metadata, + &["public_ip", "ipv4", "private_ip", "host", "hostname"], + ), + ); + set_if_missing( + &mut env, + "TARGET_PORT", + metadata_alias(&snapshot.metadata, &["ssh_port", "port"]), + ); + set_if_missing( + &mut env, + "TARGET_USER", + metadata_alias(&snapshot.metadata, &["username", "ssh_user", "user"]), + ); + set_if_missing( + &mut env, + "TARGET_BASE_URL", + metadata_alias(&snapshot.metadata, &["base_url", "url", "endpoint"]), + ); + set_if_missing( + &mut env, + "TARGET_API_KEY", + secret_alias( + secrets, + &secret_type_map, + |name| matches!(name, "API_KEY" | "APIKEY" | "ACCESS_KEY" | "ACCESS_KEY_ID"), + |_| false, + ), + ); + set_if_missing( + &mut env, + "TARGET_TOKEN", + secret_alias( + secrets, + &secret_type_map, + |name| name.contains("TOKEN"), + |_| false, + ), + ); + set_if_missing( + &mut env, + "TARGET_SSH_KEY", + secret_alias( + secrets, + &secret_type_map, + |name| name.contains("SSH") || name.ends_with("PEM"), + |ty| ty.is_some_and(|v| v.eq_ignore_ascii_case("ssh-key")), + ), + ); + + Ok(ExecutionTarget { + resolved: ResolvedTarget { + id: snapshot.id.clone(), + folder: snapshot.folder.clone(), + name: snapshot.name.clone(), + entry_type: snapshot.entry_type.clone(), + }, + env, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn build_execution_target_maps_common_aliases() { + let snapshot = TargetSnapshot { + id: "entry-1".to_string(), + folder: "refining".to_string(), + name: "hk_api_hub".to_string(), + entry_type: Some("server".to_string()), + notes: None, + metadata: serde_json::from_value(json!({ + "public_ip": "47.238.146.244", + "username": "ecs-user", + "base_url": "https://api.refining.dev" + })) + .unwrap(), + secret_fields: vec![ + SecretFieldRef { + name: "api_key".to_string(), + secret_type: None, + }, + SecretFieldRef { + name: "hk-20240726.pem".to_string(), + secret_type: Some("ssh-key".to_string()), + }, + ], + }; + let secrets = HashMap::from([ + ("api_key".to_string(), json!("sk_test_123")), + ( + "hk-20240726.pem".to_string(), + json!("-----BEGIN PRIVATE KEY-----"), + ), + ]); + + let target = build_execution_target(&snapshot, &secrets).unwrap(); + assert_eq!(target.env.get("TARGET_HOST").unwrap(), "47.238.146.244"); + assert_eq!(target.env.get("TARGET_USER").unwrap(), "ecs-user"); + assert_eq!( + target.env.get("TARGET_BASE_URL").unwrap(), + "https://api.refining.dev" + ); + assert_eq!(target.env.get("TARGET_API_KEY").unwrap(), "sk_test_123"); + assert_eq!( + target.env.get("TARGET_SSH_KEY").unwrap(), + "-----BEGIN PRIVATE KEY-----" + ); + } +} diff --git a/crates/secrets-mcp-local/src/unlock.rs b/crates/secrets-mcp-local/src/unlock.rs new file mode 100644 index 0000000..5037b01 --- /dev/null +++ b/crates/secrets-mcp-local/src/unlock.rs @@ -0,0 +1,265 @@ +use std::time::Instant; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse}; +use secrets_core::crypto::{decrypt, extract_key_from_hex, hex}; +use serde::Deserialize; +use serde_json::json; + +use crate::bind::refresh_bound_state; +use crate::cache::UnlockState; +use crate::server::AppState; + +const KEY_CHECK_PLAINTEXT: &[u8] = b"secrets-mcp-key-check"; + +fn verify_key_check_hex(key_hex: &str, key_check_hex: &str) -> Result<(), (StatusCode, String)> { + let key_check = hex::decode_hex(key_check_hex).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("invalid key_check hex: {e}"), + ) + })?; + let user_key = extract_key_from_hex(key_hex).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("invalid encryption key: {e}"), + ) + })?; + let plaintext = decrypt(&user_key, &key_check) + .map_err(|_| (StatusCode::UNAUTHORIZED, "wrong passphrase".to_string()))?; + if plaintext != KEY_CHECK_PLAINTEXT { + return Err((StatusCode::UNAUTHORIZED, "wrong passphrase".to_string())); + } + Ok(()) +} + +#[derive(Deserialize)] +pub struct UnlockCompleteBody { + encryption_key: String, + ttl_secs: Option, +} + +pub async fn unlock_page(State(state): State) -> impl IntoResponse { + refresh_bound_state(&state).await; + let bound = { + let guard = state.cache.read().await; + guard.bound.clone() + }; + let Some(mut bound) = bound else { + return Html( + "

Not bound

Run /local/bind/start and complete approve first.

" + .to_string(), + ); + }; + { + let guard = state.cache.read().await; + if let Some(updated) = guard.bound.clone() { + bound = updated; + } + } + let key_salt_hex = bound.key_salt_hex.as_deref().unwrap_or(""); + let key_check_hex = bound.key_check_hex.as_deref().unwrap_or(""); + let iterations = bound + .key_params + .as_ref() + .and_then(|v| v.get("iterations")) + .and_then(|n| n.as_u64()) + .unwrap_or(600_000); + + Html(format!( + r#" + +Local MCP Unlock + +

解锁本地 MCP

+

用户:{user_id}

+ + + +

+
+
+"#,
+        user_id = bound.user_id,
+        ttl = state.config.default_unlock_ttl.as_secs(),
+        salt = key_salt_hex,
+        key_check = key_check_hex,
+        iter = iterations
+    ))
+}
+
+pub async fn unlock_complete(
+    State(state): State,
+    axum::Json(input): axum::Json,
+) -> Result {
+    let key = input.encryption_key.trim();
+    if key.len() != 64 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
+        return Err((
+            StatusCode::BAD_REQUEST,
+            "encryption_key must be 64 hex chars".to_string(),
+        ));
+    }
+    let ttl = std::time::Duration::from_secs(
+        input
+            .ttl_secs
+            .unwrap_or(state.config.default_unlock_ttl.as_secs())
+            .clamp(60, 86400 * 7),
+    );
+    let mut guard = state.cache.write().await;
+    let Some(bound) = guard.bound.as_ref() else {
+        return Err((StatusCode::UNAUTHORIZED, "not bound".to_string()));
+    };
+    let key_check_hex = bound
+        .key_check_hex
+        .as_deref()
+        .ok_or((StatusCode::BAD_REQUEST, "key_check missing".to_string()))?;
+    verify_key_check_hex(key, key_check_hex)?;
+    guard.exec_contexts.clear();
+    guard.unlock = Some(UnlockState {
+        encryption_key_hex: key.to_string(),
+        expires_at: Instant::now() + ttl,
+        last_used_at: Instant::now(),
+    });
+    Ok((
+        StatusCode::OK,
+        axum::Json(json!({"ok": true, "ttl_secs": ttl.as_secs()})),
+    ))
+}
+
+pub async fn lock(State(state): State) -> impl IntoResponse {
+    let mut guard = state.cache.write().await;
+    guard.clear_unlock_and_exec();
+    (StatusCode::OK, axum::Json(json!({"ok": true})))
+}
+
+pub async fn status(State(state): State) -> impl IntoResponse {
+    let payload = status_payload(&state).await;
+    (StatusCode::OK, axum::Json(payload))
+}
+
+pub async fn status_payload(state: &AppState) -> serde_json::Value {
+    refresh_bound_state(state).await;
+    let now = Instant::now();
+    let mut guard = state.cache.write().await;
+    let unlocked = guard
+        .unlock
+        .as_ref()
+        .is_some_and(|u| u.expires_at > now && !u.encryption_key_hex.is_empty());
+    let expires_in_secs = guard
+        .unlock
+        .as_ref()
+        .and_then(|u| (u.expires_at > now).then_some(u.expires_at.duration_since(now).as_secs()));
+    if guard.unlock.as_ref().is_some_and(|u| u.expires_at <= now) {
+        guard.clear_unlock_and_exec();
+    }
+    let state_name = guard.phase(now);
+    let bound = guard.bound.as_ref().map(|b| {
+        json!({
+            "user_id": b.user_id,
+            "key_version": b.key_version,
+            "bound_for_secs": b.bound_at.elapsed().as_secs(),
+        })
+    });
+    let pending_bind = guard.pending_bind.as_ref().map(|pending| {
+        json!({
+            "bind_id": pending.bind_id,
+            "device_code": pending.device_code,
+            "approve_url": pending.approve_url,
+            "expires_in_secs": pending.expires_at.saturating_duration_since(now).as_secs(),
+            "started_for_secs": pending.started_at.elapsed().as_secs(),
+        })
+    });
+    json!({
+        "state": state_name,
+        "bound": bound,
+        "pending_bind": pending_bind,
+        "unlocked": unlocked,
+        "expires_in_secs": expires_in_secs,
+        "cached_targets": guard.exec_contexts.len(),
+        "onboarding_url": format!("http://{}/", state.config.bind),
+        "unlock_url": format!("http://{}/unlock", state.config.bind),
+        "mcp_url": format!("http://{}/mcp", state.config.bind),
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use secrets_core::crypto::encrypt;
+
+    #[test]
+    fn verify_key_check_accepts_matching_key() {
+        let key_hex = "11".repeat(32);
+        let key = extract_key_from_hex(&key_hex).unwrap();
+        let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
+        let ciphertext_hex = hex::encode_hex(&ciphertext);
+        assert!(verify_key_check_hex(&key_hex, &ciphertext_hex).is_ok());
+    }
+
+    #[test]
+    fn verify_key_check_rejects_wrong_key() {
+        let correct_key_hex = "11".repeat(32);
+        let wrong_key_hex = "22".repeat(32);
+        let key = extract_key_from_hex(&correct_key_hex).unwrap();
+        let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
+        let ciphertext_hex = hex::encode_hex(&ciphertext);
+        let err = verify_key_check_hex(&wrong_key_hex, &ciphertext_hex).unwrap_err();
+        assert_eq!(err.0, StatusCode::UNAUTHORIZED);
+    }
+}
diff --git a/crates/secrets-mcp/CHANGELOG.md b/crates/secrets-mcp/CHANGELOG.md
index 4e3092e..d6f5a99 100644
--- a/crates/secrets-mcp/CHANGELOG.md
+++ b/crates/secrets-mcp/CHANGELOG.md
@@ -1,10 +1,23 @@
 本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
 
+## [0.6.0] - 2026-04-12
+
+### Changed
+
+- 重构 `secrets-mcp-local` 为本地 MCP 服务:`initialize` / `tools/list` 在未绑定、未解锁时也始终成功,不再通过连接级 `401` 让 MCP 客户端误判为服务离线。
+- 本地 gateway 改为三态工具面:`bootstrap` / `pendingUnlock` / `ready`;未就绪时仅暴露 `local_status`、`local_bind_start`、`local_bind_exchange`、`local_unlock_status`、`local_onboarding_info` 等 bootstrap 工具。
+- 本地首页改为真实 onboarding 页面:可直接发起绑定、展示 `approve_url`、轮询授权结果,并衔接本地 unlock;不再要求用户手工拼 `curl` 请求。
+- 本地绑定闭环改为持久化短时会话:远程 `secrets-mcp` 新增 `local_mcp_bind_sessions` 存储绑定确认状态,避免仅靠单进程内存状态。
+- 本地解锁增加 `key_check` 校验与生命周期收敛:浏览器内先验证密码短语,再缓存本地 unlock;当远程 `key_version` 变化、API key 失效或绑定用户缺失时,本地自动失效 unlock 或清除 bound 状态。
+- 远程 `secrets-mcp` 新增 `/api/local-mcp/entries/find|search|history|overview|delete-preview|{id}/secrets` JSON API;local gateway 的发现、预览删除与解密读取已切到这些 HTTP API,不再依赖远程 `/mcp` 作为运行时后端。
+- 本地 gateway 新增 `target_exec` 通用代执行能力:AI 可先发现服务器或 API 服务条目,再由 local gateway 内部读取条目密钥并注入 `TARGET_*` 环境变量执行标准命令;执行上下文按 `entry_id` 本地缓存,可在 unlock 生命周期内复用。
+
 ## [0.5.28] - 2026-04-12
 
 ### Added
 
-- 工作区新增 **`secrets-mcp-local`**:本地 MCP gateway(`secrets-mcp-local` 二进制),在解锁后缓存 `Authorization` + `X-Encryption-Key` 并代理至远程 `/mcp`;可选默认拦截 `secrets_get` / `secrets_export` / `secrets_env_map`(`SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS`)。
+- 工作区新增 **`secrets-mcp-local`** 并升级为本地 MCP 服务:支持 `bind/start -> approve -> bind/exchange -> /unlock` 闭环,复用远程 Web 会话完成本地绑定,浏览器内派生后按 TTL 缓存解锁状态。
+- 远程 `secrets-mcp` 新增本地绑定 API:`/api/local-mcp/bind/start`、`/api/local-mcp/bind/approve`、`/api/local-mcp/bind/exchange` 以及确认页 `/local-mcp/approve`。
 
 ## [0.5.27] - 2026-04-11
 
diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml
index 42c3bda..534345b 100644
--- a/crates/secrets-mcp/Cargo.toml
+++ b/crates/secrets-mcp/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "secrets-mcp"
-version = "0.5.28"
+version = "0.6.0"
 edition.workspace = true
 
 [[bin]]
diff --git a/crates/secrets-mcp/src/web/local_mcp.rs b/crates/secrets-mcp/src/web/local_mcp.rs
new file mode 100644
index 0000000..9d0d429
--- /dev/null
+++ b/crates/secrets-mcp/src/web/local_mcp.rs
@@ -0,0 +1,894 @@
+use askama::Template;
+use axum::{
+    Json,
+    extract::{Path, Query, State},
+    http::{HeaderMap, StatusCode, header},
+    response::{IntoResponse, Response},
+};
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use sqlx::PgPool;
+use tower_sessions::Session;
+use uuid::Uuid;
+
+use secrets_core::crypto::hex;
+use secrets_core::service::api_key::validate_api_key;
+use secrets_core::service::delete::{DeleteParams, run as svc_delete};
+use secrets_core::service::get_secret::get_all_secrets_by_id;
+use secrets_core::service::history::run as svc_history;
+use secrets_core::service::relations::get_relations_for_entries;
+use secrets_core::service::search::{
+    SearchParams, count_entries, resolve_entry_by_id, run as svc_search,
+};
+use secrets_core::service::user::get_user_by_id;
+
+use crate::AppState;
+
+use super::{
+    UiLang, render_template, request_ui_lang, require_valid_user, require_valid_user_json,
+};
+
+const BIND_TTL_SECS: u64 = 600;
+
+#[derive(Clone, sqlx::FromRow)]
+struct BindRow {
+    device_code: String,
+    user_id: Option,
+    approved: bool,
+}
+
+enum ConsumeBindOutcome {
+    Pending,
+    Ready(BindRow),
+    NotFound,
+    DeviceMismatch,
+}
+
+#[derive(Serialize)]
+pub(super) struct BindStartOutput {
+    bind_id: String,
+    device_code: String,
+    approve_url: String,
+    expires_in_secs: u64,
+}
+
+#[derive(Deserialize)]
+pub(super) struct BindApproveInput {
+    bind_id: String,
+    device_code: String,
+}
+
+#[derive(Deserialize)]
+pub(super) struct BindExchangeInput {
+    bind_id: String,
+    device_code: String,
+}
+
+#[derive(Template)]
+#[template(
+    source = r#"
+
+Local MCP 绑定确认
+
+  

确认绑定本地 MCP

+ {% if error.is_some() %} +

{{ error.as_ref().unwrap() }}

+ {% endif %} + {% if approved %} +

绑定已确认。你可以返回本地页面继续下一步。

+ {% else %} +

Bind ID: {{ bind_id }}

+
+ + + +
+ {% endif %} + +"#, + ext = "html" +)] +struct ApproveTemplate { + bind_id: String, + device_code: String, + approved: bool, + error: Option, +} + +async fn cleanup_expired(pool: &PgPool) { + let _ = sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE expires_at <= NOW()") + .execute(pool) + .await; +} + +async fn fetch_bind(pool: &PgPool, bind_id: &str) -> Result, StatusCode> { + sqlx::query_as::<_, BindRow>( + "SELECT device_code, user_id, approved + FROM local_mcp_bind_sessions + WHERE bind_id = $1 AND expires_at > NOW()", + ) + .bind(bind_id) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!(error = %e, bind_id, "failed to fetch local MCP bind"); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +async fn require_user_from_bearer(pool: &PgPool, headers: &HeaderMap) -> Result { + let auth_header = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let raw_key = auth_header + .strip_prefix("Bearer ") + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or(StatusCode::UNAUTHORIZED)?; + validate_api_key(pool, raw_key) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to validate api key for local MCP refresh"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::UNAUTHORIZED) +} + +async fn consume_bind_session( + pool: &PgPool, + bind_id: &str, + device_code: &str, +) -> Result)> { + let mut tx = pool.begin().await.map_err(|e| { + tracing::error!(error = %e, bind_id, "failed to start tx for bind exchange"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to start bind exchange" })), + ) + })?; + let stored = sqlx::query_as::<_, BindRow>( + "SELECT device_code, user_id, approved + FROM local_mcp_bind_sessions + WHERE bind_id = $1 AND expires_at > NOW() + FOR UPDATE", + ) + .bind(bind_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| { + tracing::error!(error = %e, bind_id, "failed to lock bind session"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to load bind session" })), + ) + })?; + let Some(bind) = stored else { + tx.rollback().await.ok(); + return Ok(ConsumeBindOutcome::NotFound); + }; + if bind.device_code != device_code { + tx.rollback().await.ok(); + return Ok(ConsumeBindOutcome::DeviceMismatch); + } + if !bind.approved { + tx.rollback().await.ok(); + return Ok(ConsumeBindOutcome::Pending); + } + sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE bind_id = $1") + .bind(bind_id) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!(error = %e, bind_id, "failed to consume bind session"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to consume bind session" })), + ) + })?; + tx.commit().await.map_err(|e| { + tracing::error!(error = %e, bind_id, "failed to commit bind exchange"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to commit bind exchange" })), + ) + })?; + Ok(ConsumeBindOutcome::Ready(bind)) +} + +pub(super) async fn api_bind_start( + State(state): State, +) -> Result, (StatusCode, Json)> { + cleanup_expired(&state.pool).await; + let bind_id = Uuid::new_v4().to_string(); + let device_code = Uuid::new_v4().simple().to_string(); + sqlx::query( + "INSERT INTO local_mcp_bind_sessions (bind_id, device_code, expires_at) + VALUES ($1, $2, NOW() + ($3 * INTERVAL '1 second'))", + ) + .bind(&bind_id) + .bind(&device_code) + .bind(BIND_TTL_SECS as i64) + .execute(&state.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, bind_id, "failed to insert local MCP bind session"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to create bind session" })), + ) + })?; + + let approve_url = format!( + "{}/local-mcp/approve?bind_id={}&device_code={}", + state.base_url, bind_id, device_code + ); + + Ok(Json(BindStartOutput { + bind_id, + device_code, + approve_url, + expires_in_secs: BIND_TTL_SECS, + })) +} + +#[derive(Deserialize)] +pub(super) struct ApproveQuery { + bind_id: String, + device_code: String, +} + +pub(super) async fn approve_page( + State(state): State, + session: Session, + Query(query): Query, +) -> Result { + let _user = require_valid_user(&state.pool, &session, "local_mcp.approve_page").await?; + + cleanup_expired(&state.pool).await; + let mut approved = false; + let mut error = None; + + match fetch_bind(&state.pool, &query.bind_id).await { + Ok(Some(bind)) if bind.device_code == query.device_code => approved = bind.approved, + Ok(Some(_)) => error = Some("device_code 不匹配".to_string()), + Ok(None) => error = Some("绑定已过期或不存在".to_string()), + Err(status) => return Err(status.into_response()), + } + + render_template(ApproveTemplate { + bind_id: query.bind_id, + device_code: query.device_code, + approved, + error, + }) + .map_err(|status| status.into_response()) +} + +pub(super) async fn api_bind_approve( + State(state): State, + session: Session, + headers: axum::http::HeaderMap, + axum::Form(input): axum::Form, +) -> Result)> { + let lang: UiLang = request_ui_lang(&headers); + let user = require_valid_user_json(&state.pool, &session, lang).await?; + cleanup_expired(&state.pool).await; + + match fetch_bind(&state.pool, &input.bind_id).await { + Ok(Some(bind)) if bind.device_code == input.device_code => { + sqlx::query( + "UPDATE local_mcp_bind_sessions + SET user_id = $1, approved = TRUE + WHERE bind_id = $2 AND expires_at > NOW()", + ) + .bind(user.id) + .bind(&input.bind_id) + .execute(&state.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, bind_id = %input.bind_id, "failed to approve bind session"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to approve bind session" })), + ) + })?; + Ok(axum::response::Redirect::to(&format!( + "/local-mcp/approve?bind_id={}&device_code={}&approved=1", + input.bind_id, input.device_code + )) + .into_response()) + } + Ok(Some(_)) => Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "device_code mismatch" })), + )), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": "bind session not found or expired" })), + )), + Err(status) => Err(( + status, + Json(json!({ "error": "failed to load bind session" })), + )), + } +} + +pub(super) async fn api_bind_exchange( + State(state): State, + Json(input): Json, +) -> Result)> { + cleanup_expired(&state.pool).await; + + let bind = match consume_bind_session(&state.pool, &input.bind_id, &input.device_code).await? { + ConsumeBindOutcome::Pending => { + return Ok((StatusCode::ACCEPTED, Json(json!({ "status": "pending" }))).into_response()); + } + ConsumeBindOutcome::NotFound => { + return Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": "bind session not found or expired" })), + )); + } + ConsumeBindOutcome::DeviceMismatch => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "device_code mismatch" })), + )); + } + ConsumeBindOutcome::Ready(bind) => bind, + }; + let user_id = bind.user_id.ok_or_else(|| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "approved bind missing user_id" })), + ) + })?; + let user = get_user_by_id(&state.pool, user_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("failed to load user: {e}") })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "user not found" })), + ) + })?; + + let key_salt_hex = user.key_salt.as_ref().map(|bytes| { + bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() + }); + let key_check_hex = user.key_check.as_deref().map(hex::encode_hex); + + Ok(( + StatusCode::OK, + Json(json!({ + "status": "ok", + "user_id": user.id, + "api_key": user.api_key, + "key_salt_hex": key_salt_hex, + "key_check_hex": key_check_hex, + "key_params": user.key_params, + "key_version": user.key_version, + })), + ) + .into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + use secrets_core::{ + config::resolve_db_config, + db::{create_pool, migrate}, + }; + + async fn test_pool() -> Option { + let config = resolve_db_config("").ok()?; + let pool = create_pool(&config).await.ok()?; + migrate(&pool).await.ok()?; + Some(pool) + } + + #[tokio::test] + async fn consume_bind_session_is_single_use() { + let Some(pool) = test_pool().await else { + return; + }; + let bind_id = format!("test-{}", Uuid::new_v4()); + let device_code = Uuid::new_v4().simple().to_string(); + let user_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO local_mcp_bind_sessions (bind_id, device_code, user_id, approved, expires_at) + VALUES ($1, $2, $3, TRUE, NOW() + INTERVAL '10 minutes')", + ) + .bind(&bind_id) + .bind(&device_code) + .bind(user_id) + .execute(&pool) + .await + .unwrap(); + + let first = consume_bind_session(&pool, &bind_id, &device_code) + .await + .unwrap(); + assert!(matches!(first, ConsumeBindOutcome::Ready(_))); + let second = consume_bind_session(&pool, &bind_id, &device_code) + .await + .unwrap(); + assert!(matches!(second, ConsumeBindOutcome::NotFound)); + } +} + +pub(super) async fn api_bind_refresh( + State(state): State, + headers: HeaderMap, +) -> Result)> { + let user_id = require_user_from_bearer(&state.pool, &headers) + .await + .map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?; + let user = get_user_by_id(&state.pool, user_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("failed to load user: {e}") })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "user not found" })), + ) + })?; + Ok(( + StatusCode::OK, + Json(json!({ + "user_id": user.id, + "key_salt_hex": user.key_salt.as_deref().map(hex::encode_hex), + "key_check_hex": user.key_check.as_deref().map(hex::encode_hex), + "key_params": user.key_params, + "key_version": user.key_version, + })), + ) + .into_response()) +} + +#[derive(Deserialize)] +pub(super) struct LocalSearchInput { + query: Option, + metadata_query: Option, + folder: Option, + #[serde(rename = "type")] + entry_type: Option, + name: Option, + name_query: Option, + tags: Option>, + summary: Option, + sort: Option, + limit: Option, + offset: Option, +} + +#[derive(Deserialize)] +pub(super) struct LocalHistoryInput { + name: Option, + folder: Option, + id: Option, + limit: Option, +} + +#[derive(Deserialize)] +pub(super) struct LocalDeleteInput { + id: Option, + name: Option, + folder: Option, + #[serde(rename = "type")] + entry_type: Option, + dry_run: Option, +} + +fn require_encryption_key_local( + headers: &HeaderMap, +) -> Result<[u8; 32], (StatusCode, Json)> { + let enc_key_hex = headers + .get("x-encryption-key") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Missing X-Encryption-Key header" })), + ) + })?; + secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid X-Encryption-Key format" })), + ) + }) +} + +fn render_entry_json( + entry: &secrets_core::models::Entry, + relations: secrets_core::service::relations::EntryRelations, + secret_fields: &[secrets_core::models::SecretField], + summary: bool, +) -> Value { + if summary { + json!({ + "name": entry.name, + "folder": entry.folder, + "type": entry.entry_type, + "tags": entry.tags, + "notes": entry.notes, + "parents": relations.parents, + "children": relations.children, + "updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + }) + } else { + let schema: Vec<_> = secret_fields + .iter() + .map(|field| { + json!({ + "id": field.id, + "name": field.name, + "type": field.secret_type, + }) + }) + .collect(); + json!({ + "id": entry.id, + "name": entry.name, + "folder": entry.folder, + "type": entry.entry_type, + "notes": entry.notes, + "tags": entry.tags, + "metadata": entry.metadata, + "parents": relations.parents, + "children": relations.children, + "secret_fields": schema, + "version": entry.version, + "updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + }) + } +} + +pub(super) async fn api_entries_find( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Result, (StatusCode, Json)> { + let user_id = require_user_from_bearer(&state.pool, &headers) + .await + .map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?; + let tags = input.tags.unwrap_or_default(); + let result = svc_search( + &state.pool, + SearchParams { + folder: input.folder.as_deref(), + entry_type: input.entry_type.as_deref(), + name: input.name.as_deref(), + name_query: input.name_query.as_deref(), + tags: &tags, + query: input.query.as_deref(), + metadata_query: input.metadata_query.as_deref(), + sort: "name", + limit: input.limit.unwrap_or(20), + offset: input.offset.unwrap_or(0), + user_id: Some(user_id), + }, + ) + .await + .map_err(|e| { + tracing::error!(error = %e, %user_id, "local mcp find failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "find failed" })), + ) + })?; + let total_count = count_entries( + &state.pool, + &SearchParams { + folder: input.folder.as_deref(), + entry_type: input.entry_type.as_deref(), + name: input.name.as_deref(), + name_query: input.name_query.as_deref(), + tags: &tags, + query: input.query.as_deref(), + metadata_query: input.metadata_query.as_deref(), + sort: "name", + limit: 0, + offset: 0, + user_id: Some(user_id), + }, + ) + .await + .map_err(|e| { + tracing::error!(error = %e, %user_id, "local mcp find count failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "find count failed" })), + ) + })?; + let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect(); + let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id)) + .await + .map_err(|e| { + tracing::error!(error = %e, %user_id, "local mcp find relations failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "find relations failed" })), + ) + })?; + let entries = result + .entries + .iter() + .map(|entry| { + let relations = relation_map.get(&entry.id).cloned().unwrap_or_default(); + let secret_fields = result + .secret_schemas + .get(&entry.id) + .map(Vec::as_slice) + .unwrap_or(&[]); + render_entry_json(entry, relations, secret_fields, false) + }) + .collect::>(); + Ok(Json(json!({ + "total_count": total_count, + "entries": entries, + }))) +} + +pub(super) async fn api_entries_search( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Result, (StatusCode, Json)> { + let user_id = require_user_from_bearer(&state.pool, &headers) + .await + .map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?; + let tags = input.tags.unwrap_or_default(); + let result = svc_search( + &state.pool, + SearchParams { + folder: input.folder.as_deref(), + entry_type: input.entry_type.as_deref(), + name: input.name.as_deref(), + name_query: input.name_query.as_deref(), + tags: &tags, + query: input.query.as_deref(), + metadata_query: input.metadata_query.as_deref(), + sort: input.sort.as_deref().unwrap_or("name"), + limit: input.limit.unwrap_or(20), + offset: input.offset.unwrap_or(0), + user_id: Some(user_id), + }, + ) + .await + .map_err(|e| { + tracing::error!(error = %e, %user_id, "local mcp search failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "search failed" })), + ) + })?; + let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect(); + let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id)) + .await + .map_err(|e| { + tracing::error!(error = %e, %user_id, "local mcp search relations failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "search relations failed" })), + ) + })?; + let summary = input.summary.unwrap_or(false); + let entries = result + .entries + .iter() + .map(|entry| { + let relations = relation_map.get(&entry.id).cloned().unwrap_or_default(); + let secret_fields = result + .secret_schemas + .get(&entry.id) + .map(Vec::as_slice) + .unwrap_or(&[]); + render_entry_json(entry, relations, secret_fields, summary) + }) + .collect::>(); + Ok(Json(Value::Array(entries))) +} + +pub(super) async fn api_entry_history( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Result, (StatusCode, Json)> { + let user_id = require_user_from_bearer(&state.pool, &headers) + .await + .map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?; + let (name, folder) = if let Some(id) = input.id { + let entry = resolve_entry_by_id(&state.pool, id, Some(user_id)) + .await + .map_err(|e| { + tracing::warn!(error = %e, %user_id, %id, "local mcp history missing entry"); + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "entry not found" })), + ) + })?; + (entry.name, Some(entry.folder)) + } else { + let name = input.name.ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "name or id is required" })), + ) + })?; + (name, input.folder) + }; + let result = svc_history( + &state.pool, + &name, + folder.as_deref(), + input.limit.unwrap_or(20), + Some(user_id), + ) + .await + .map_err(|e| { + tracing::warn!(error = %e, %user_id, name = %name, "local mcp history failed"); + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": e.to_string() })), + ) + })?; + Ok(Json( + serde_json::to_value(result).unwrap_or_else(|_| json!([])), + )) +} + +pub(super) async fn api_entries_overview( + State(state): State, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + #[derive(sqlx::FromRow)] + struct CountRow { + name: String, + count: i64, + } + + let user_id = require_user_from_bearer(&state.pool, &headers) + .await + .map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?; + let folder_rows: Vec = sqlx::query_as::<_, CountRow>( + "SELECT folder AS name, COUNT(*) AS count FROM entries \ + WHERE user_id = $1 GROUP BY folder ORDER BY folder", + ) + .bind(user_id) + .fetch_all(&state.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, %user_id, "local mcp overview folders failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "overview failed" })), + ) + })?; + let type_rows: Vec = sqlx::query_as::<_, CountRow>( + "SELECT type AS name, COUNT(*) AS count FROM entries \ + WHERE user_id = $1 GROUP BY type ORDER BY type", + ) + .bind(user_id) + .fetch_all(&state.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, %user_id, "local mcp overview types failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "overview failed" })), + ) + })?; + let total: i64 = folder_rows.iter().map(|row| row.count).sum(); + Ok(Json(json!({ + "total": total, + "folders": folder_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::>(), + "types": type_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::>(), + }))) +} + +pub(super) async fn api_entries_delete_preview( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Result, (StatusCode, Json)> { + let user_id = require_user_from_bearer(&state.pool, &headers) + .await + .map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?; + if !input.dry_run.unwrap_or(false) { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "dry_run=true is required" })), + )); + } + let (effective_name, effective_folder) = + if let Some(id) = input.id { + let entry = resolve_entry_by_id(&state.pool, id, Some(user_id)) + .await + .map_err(|e| { + tracing::warn!(error = %e, %user_id, %id, "local mcp delete preview missing entry"); + (StatusCode::NOT_FOUND, Json(json!({ "error": "entry not found" }))) + })?; + (Some(entry.name), Some(entry.folder)) + } else { + (input.name, input.folder) + }; + let result = svc_delete( + &state.pool, + DeleteParams { + name: effective_name.as_deref(), + folder: effective_folder.as_deref(), + entry_type: input.entry_type.as_deref(), + dry_run: true, + user_id: Some(user_id), + }, + ) + .await + .map_err(|e| { + tracing::warn!(error = %e, %user_id, "local mcp delete preview failed"); + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": e.to_string() })), + ) + })?; + Ok(Json( + serde_json::to_value(result).unwrap_or_else(|_| json!({})), + )) +} + +pub(super) async fn api_entry_secrets_decrypt_bearer( + State(state): State, + headers: HeaderMap, + Path(entry_id): Path, +) -> Result, (StatusCode, Json)> { + let user_id = require_user_from_bearer(&state.pool, &headers) + .await + .map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?; + let master_key = require_encryption_key_local(&headers)?; + let secrets = get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id)) + .await + .map_err(|e| { + tracing::warn!(error = %e, %user_id, %entry_id, "local mcp decrypt failed"); + if let Some(app_err) = e.downcast_ref::() { + return match app_err { + secrets_core::error::AppError::DecryptionFailed => ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({ "error": "Decryption failed, verify passphrase" })), + ), + secrets_core::error::AppError::NotFoundEntry + | secrets_core::error::AppError::NotFoundUser + | secrets_core::error::AppError::NotFoundSecret => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "entry not found" })), + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "decrypt failed" })), + ), + }; + } + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "decrypt failed" })), + ) + })?; + Ok(Json( + serde_json::to_value(secrets).unwrap_or_else(|_| json!({})), + )) +} diff --git a/crates/secrets-mcp/src/web/mod.rs b/crates/secrets-mcp/src/web/mod.rs index 887af91..03fbc5b 100644 --- a/crates/secrets-mcp/src/web/mod.rs +++ b/crates/secrets-mcp/src/web/mod.rs @@ -18,6 +18,7 @@ mod audit; mod auth; mod changelog; mod entries; +mod local_mcp; // ── Session keys ────────────────────────────────────────────────────────────── @@ -259,6 +260,7 @@ pub fn web_router() -> Router { .route("/auth/google", get(auth::auth_google)) .route("/auth/google/callback", get(auth::auth_google_callback)) .route("/auth/logout", post(auth::auth_logout)) + .route("/local-mcp/approve", get(local_mcp::approve_page)) .route("/dashboard", get(account::dashboard)) .route("/entries", get(entries::entries_page)) .route("/trash", get(entries::trash_page)) @@ -266,6 +268,43 @@ pub fn web_router() -> Router { .route("/account/bind/google", get(auth::account_bind_google)) .route("/account/unbind/{provider}", post(auth::account_unbind)) .route("/api/key-salt", get(account::api_key_salt)) + .route("/api/local-mcp/bind/start", post(local_mcp::api_bind_start)) + .route( + "/api/local-mcp/bind/approve", + post(local_mcp::api_bind_approve), + ) + .route( + "/api/local-mcp/bind/exchange", + post(local_mcp::api_bind_exchange), + ) + .route( + "/api/local-mcp/bind/refresh", + post(local_mcp::api_bind_refresh), + ) + .route( + "/api/local-mcp/entries/find", + post(local_mcp::api_entries_find), + ) + .route( + "/api/local-mcp/entries/search", + post(local_mcp::api_entries_search), + ) + .route( + "/api/local-mcp/entries/history", + post(local_mcp::api_entry_history), + ) + .route( + "/api/local-mcp/entries/overview", + get(local_mcp::api_entries_overview), + ) + .route( + "/api/local-mcp/entries/delete-preview", + post(local_mcp::api_entries_delete_preview), + ) + .route( + "/api/local-mcp/entries/{id}/secrets", + get(local_mcp::api_entry_secrets_decrypt_bearer), + ) .route("/api/key-setup", post(account::api_key_setup)) .route("/api/key-change", post(account::api_key_change)) .route("/api/apikey", get(account::api_apikey_get)) diff --git a/deploy/.env.example b/deploy/.env.example index ca335c9..299997e 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -58,10 +58,9 @@ GOOGLE_CLIENT_SECRET= # TRUST_PROXY=1 # ─── 本机 MCP gateway(secrets-mcp-local,可选)──────────────────────── -# 在开发者机器上运行,与上方服务端 .env 通常分开配置;用于代理远程 /mcp 并缓存解锁状态。 -# SECRETS_REMOTE_MCP_URL=https://secrets.example.com/mcp +# 在开发者机器上运行,与上方服务端 .env 通常分开配置;用于本地 MCP onboarding、解锁缓存与 target_exec。 +# 直接配置远端 Web 基址。 +# SECRETS_REMOTE_BASE_URL=https://secrets.example.com # SECRETS_MCP_LOCAL_BIND=127.0.0.1:9316 -# SECRETS_LOCAL_API_KEY= # SECRETS_LOCAL_UNLOCK_TTL_SECS=3600 -# SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS=0 -# SECRETS_REMOTE_DASHBOARD_URL=https://secrets.example.com/dashboard +# SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS=3600