Compare commits

...

3 Commits

Author SHA1 Message Date
voson
cab234cfcb feat(secrets-mcp): 增强 MCP 请求日志与 encryption_key 参数支持
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m28s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
logging.rs:
- 每条 MCP POST 日志新增 auth_key(Bearer token 前12字符掩码)、
  enc_key(X-Encryption-Key 前4后4字符指纹,如 146b…5516(64) 或 absent)、
  user_id、tool_args(白名单非敏感参数摘要)字段
- 新增辅助函数 mask_bearer / mask_enc_key / extract_tool_args / summarize_value

tools.rs:
- extract_enc_key 成功路径增加 debug 级指纹日志(raw_len/trimmed_len/prefix/suffix)
- 新增 extract_enc_key_or_arg / require_user_and_key_or_arg:优先使用参数传入的密钥,
  fallback 到 X-Encryption-Key 头,绕过 Cursor Chat MCP 头透传异常
- GetSecretInput / AddInput / UpdateInput / ExportInput / EnvMapInput 各增加可选
  encryption_key 字段,对应工具实现改用 require_user_and_key_or_arg
2026-04-06 11:03:01 +08:00
voson
e0fee639c1 release(secrets-mcp): 0.5.7
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m8s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-05 17:07:31 +08:00
voson
7c53bfb782 feat(core): entry 历史附带关联 secret 密文快照,rollback 可恢复 N:N 与密文
- db: metadata_with_secret_snapshot / strip / parse 辅助
- add/update/delete/rollback 在写 entries_history 前合并快照
- rollback: 按历史快照同步 entry_secrets、更新或插入 secrets
- 满足 clippy collapsible_if
2026-04-05 17:06:53 +08:00
10 changed files with 550 additions and 51 deletions

2
Cargo.lock generated
View File

@@ -2066,7 +2066,7 @@ dependencies = [
[[package]]
name = "secrets-mcp"
version = "0.5.6"
version = "0.5.7"
dependencies = [
"anyhow",
"askama",

View File

@@ -1,7 +1,7 @@
use std::str::FromStr;
use anyhow::{Context, Result};
use serde_json::Value;
use serde_json::{Map, Value};
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
@@ -562,4 +562,75 @@ pub async fn snapshot_secret_history(
Ok(())
}
pub const ENTRY_HISTORY_SECRETS_KEY: &str = "__secrets_snapshot_v1";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EntrySecretSnapshot {
pub name: String,
#[serde(rename = "type")]
pub secret_type: String,
pub encrypted_hex: String,
}
pub async fn metadata_with_secret_snapshot(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
entry_id: uuid::Uuid,
metadata: &Value,
) -> Result<Value> {
#[derive(sqlx::FromRow)]
struct Row {
name: String,
#[sqlx(rename = "type")]
secret_type: String,
encrypted: Vec<u8>,
}
let rows: Vec<Row> = sqlx::query_as(
"SELECT s.name, s.type, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 \
ORDER BY s.name ASC",
)
.bind(entry_id)
.fetch_all(&mut **tx)
.await?;
let snapshots: Vec<EntrySecretSnapshot> = rows
.into_iter()
.map(|r| EntrySecretSnapshot {
name: r.name,
secret_type: r.secret_type,
encrypted_hex: ::hex::encode(r.encrypted),
})
.collect();
let mut merged = match metadata.clone() {
Value::Object(obj) => obj,
_ => Map::new(),
};
merged.insert(
ENTRY_HISTORY_SECRETS_KEY.to_string(),
serde_json::to_value(snapshots)?,
);
Ok(Value::Object(merged))
}
pub fn strip_secret_snapshot_from_metadata(metadata: &Value) -> Value {
let mut m = match metadata.clone() {
Value::Object(obj) => obj,
_ => return metadata.clone(),
};
m.remove(ENTRY_HISTORY_SECRETS_KEY);
Value::Object(m)
}
pub fn entry_secret_snapshot_from_metadata(metadata: &Value) -> Option<Vec<EntrySecretSnapshot>> {
let Value::Object(map) = metadata else {
return None;
};
let raw = map.get(ENTRY_HISTORY_SECRETS_KEY)?;
serde_json::from_value(raw.clone()).ok()
}
// ── DB helpers ────────────────────────────────────────────────────────────────

View File

@@ -223,8 +223,16 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
.await?
};
if let Some(ref ex) = existing
&& let Err(e) = db::snapshot_entry_history(
if let Some(ref ex) = existing {
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, ex.id, &ex.metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
ex.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: ex.id,
@@ -235,13 +243,14 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
version: ex.version,
action: "add",
tags: &ex.tags,
metadata: &ex.metadata,
metadata: &history_metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
}
}
// Upsert the entry row. On conflict (existing entry with same user_id+folder+name),
// the entry columns are replaced wholesale. The old secret associations are torn down
@@ -303,26 +312,6 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
.fetch_one(&mut *tx)
.await?;
if existing.is_none()
&& let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id,
user_id: params.user_id,
folder: params.folder,
entry_type,
name: params.name,
version: current_entry_version,
action: "create",
tags: params.tags,
metadata: &metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history on create");
}
if existing.is_some() {
#[derive(sqlx::FromRow)]
struct ExistingField {
@@ -432,6 +421,35 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
}
}
if existing.is_none() {
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, entry_id, &metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id,
user_id: params.user_id,
folder: params.folder,
entry_type,
name: params.name,
version: current_entry_version,
action: "create",
tags: params.tags,
metadata: &history_metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history on create");
}
}
crate::audit::log_tx(
&mut tx,
params.user_id,

View File

@@ -441,6 +441,15 @@ async fn snapshot_and_delete(
row: &EntryRow,
user_id: Option<Uuid>,
) -> Result<()> {
let history_metadata = match db::metadata_with_secret_snapshot(tx, row.id, &row.metadata).await
{
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
row.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
tx,
db::EntrySnapshotParams {
@@ -452,7 +461,7 @@ async fn snapshot_and_delete(
version: row.version,
action: "delete",
tags: &row.tags,
metadata: &row.metadata,
metadata: &history_metadata,
},
)
.await

View File

@@ -31,8 +31,11 @@ pub async fn run(
let entry = resolve_entry(pool, name, folder, user_id).await?;
let rows: Vec<Row> = sqlx::query_as(
"SELECT version, action, created_at FROM entries_history \
WHERE entry_id = $1 ORDER BY id DESC LIMIT $2",
"SELECT DISTINCT ON (version) version, action, created_at \
FROM entries_history \
WHERE entry_id = $1 \
ORDER BY version DESC, id DESC \
LIMIT $2",
)
.bind(entry.id)
.bind(limit as i64)

View File

@@ -1,3 +1,5 @@
use std::collections::HashSet;
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
@@ -122,7 +124,7 @@ pub async fn run(
sqlx::query_as(
"SELECT folder, type, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 AND version = $2 ORDER BY id DESC LIMIT 1",
WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1",
)
.bind(entry_id)
.bind(ver)
@@ -149,6 +151,9 @@ pub async fn run(
)
})?;
let snap_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata);
let snap_metadata = db::strip_secret_snapshot_from_metadata(&snap.metadata);
let _ = master_key;
let mut tx = pool.begin().await?;
@@ -176,6 +181,15 @@ pub async fn run(
.await?;
let live_entry_id = if let Some(ref lr) = live {
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
lr.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
@@ -187,7 +201,7 @@ pub async fn run(
version: lr.version,
action: "rollback",
tags: &lr.tags,
metadata: &lr.metadata,
metadata: &history_metadata,
},
)
.await
@@ -234,7 +248,7 @@ pub async fn run(
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(&snap_metadata)
.bind(lr.id)
.execute(&mut *tx)
.await?;
@@ -252,7 +266,7 @@ pub async fn run(
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(&snap_metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
@@ -266,16 +280,16 @@ pub async fn run(
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(&snap_metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
}
};
// In N:N mode, rollback restores entry metadata/tags only.
// Secret snapshots are kept for audit but secret linkage/content is not rewritten here.
let _ = live_entry_id;
if let Some(secret_snapshot) = snap_secret_snapshot {
restore_entry_secrets(&mut tx, live_entry_id, user_id, &secret_snapshot).await?;
}
crate::audit::log_tx(
&mut tx,
@@ -300,3 +314,144 @@ pub async fn run(
restored_version: snap.version,
})
}
async fn restore_entry_secrets(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
entry_id: Uuid,
user_id: Option<Uuid>,
snapshot: &[db::EntrySecretSnapshot],
) -> Result<()> {
#[derive(sqlx::FromRow)]
struct LinkedSecret {
id: Uuid,
name: String,
encrypted: Vec<u8>,
}
let linked: Vec<LinkedSecret> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(entry_id)
.fetch_all(&mut **tx)
.await?;
let target_names: HashSet<&str> = snapshot.iter().map(|s| s.name.as_str()).collect();
for s in &linked {
if target_names.contains(s.name.as_str()) {
continue;
}
if let Err(e) = db::snapshot_secret_history(
tx,
db::SecretSnapshotParams {
secret_id: s.id,
name: &s.name,
encrypted: &s.encrypted,
action: "rollback",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret before rollback unlink");
}
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(entry_id)
.bind(s.id)
.execute(&mut **tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE s.id = $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(s.id)
.execute(&mut **tx)
.await?;
}
for snap in snapshot {
let encrypted = ::hex::decode(&snap.encrypted_hex).map_err(|e| {
anyhow::anyhow!("invalid secret snapshot data for '{}': {}", snap.name, e)
})?;
#[derive(sqlx::FromRow)]
struct ExistingSecret {
id: Uuid,
encrypted: Vec<u8>,
}
let existing: Option<ExistingSecret> = if let Some(uid) = user_id {
sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 AND name = $2")
.bind(uid)
.bind(&snap.name)
.fetch_optional(&mut **tx)
.await?
} else {
sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id IS NULL AND name = $1")
.bind(&snap.name)
.fetch_optional(&mut **tx)
.await?
};
let secret_id = if let Some(ex) = existing {
if ex.encrypted != encrypted
&& let Err(e) = db::snapshot_secret_history(
tx,
db::SecretSnapshotParams {
secret_id: ex.id,
name: &snap.name,
encrypted: &ex.encrypted,
action: "rollback",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret before rollback restore");
}
sqlx::query(
"UPDATE secrets SET type = $1, encrypted = $2, version = version + 1, updated_at = NOW() \
WHERE id = $3",
)
.bind(&snap.secret_type)
.bind(&encrypted)
.bind(ex.id)
.execute(&mut **tx)
.await?;
ex.id
} else if let Some(uid) = user_id {
sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(uid)
.bind(&snap.name)
.bind(&snap.secret_type)
.bind(&encrypted)
.fetch_one(&mut **tx)
.await?
} else {
sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, $2, $3) RETURNING id",
)
.bind(&snap.name)
.bind(&snap.secret_type)
.bind(&encrypted)
.fetch_one(&mut **tx)
.await?
};
sqlx::query(
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(entry_id)
.bind(secret_id)
.execute(&mut **tx)
.await?;
}
Ok(())
}

View File

@@ -112,6 +112,15 @@ pub async fn run(
}
};
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
row.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
@@ -123,7 +132,7 @@ pub async fn run(
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
metadata: &history_metadata,
},
)
.await
@@ -481,6 +490,15 @@ pub async fn update_fields_by_id(
}
};
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
row.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
@@ -492,7 +510,7 @@ pub async fn update_fields_by_id(
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
metadata: &history_metadata,
},
)
.await

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets-mcp"
version = "0.5.6"
version = "0.5.7"
edition.workspace = true
[[bin]]

View File

@@ -5,20 +5,24 @@ use axum::{
extract::Request,
http::{
HeaderMap, Method, StatusCode,
header::{CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
},
middleware::Next,
response::{IntoResponse, Response},
};
use crate::auth::AuthUser;
/// Axum middleware that logs structured info for every HTTP request.
///
/// All requests: method, path, status, latency_ms, client_ip, user_agent.
/// POST /mcp requests: additionally parses JSON-RPC body for jsonrpc_method,
/// tool_name, jsonrpc_id, mcp_session, batch_size.
/// tool_name, jsonrpc_id, mcp_session, batch_size, tool_args (non-sensitive
/// arguments only), plus masked auth_key / enc_key fingerprints and user_id
/// for diagnosing header forwarding issues.
///
/// Sensitive headers (Authorization, X-Encryption-Key) and secret values
/// are never logged.
/// Sensitive headers (Authorization, X-Encryption-Key) are never logged in
/// full — only short fingerprints are emitted.
pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
@@ -32,6 +36,10 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Capture header fingerprints before consuming the request.
let auth_key = mask_bearer(req.headers());
let enc_key = mask_enc_key(req.headers());
let is_mcp_post = path.starts_with("/mcp") && method == Method::POST;
let is_json = header_str(req.headers(), CONTENT_TYPE)
.map(|ct| ct.contains("application/json"))
@@ -45,6 +53,11 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
let cap = content_len.unwrap_or(0);
if cap <= 512 * 1024 {
let (parts, body) = req.into_parts();
// user_id is available after auth middleware has run (injected into extensions).
let user_id = parts
.extensions
.get::<AuthUser>()
.map(|a| a.user_id.to_string());
match to_bytes(body, 512 * 1024).await {
Ok(bytes) => {
let rpc = parse_jsonrpc_meta(&bytes);
@@ -61,6 +74,9 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
ua.as_deref(),
content_len,
mcp_session.as_deref(),
auth_key.as_deref(),
&enc_key,
user_id.as_deref(),
&rpc,
);
return resp;
@@ -77,6 +93,9 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
ua = ua.as_deref(),
content_length = content_len,
mcp_session = mcp_session.as_deref(),
auth_key = auth_key.as_deref(),
enc_key = enc_key.as_str(),
user_id = user_id.as_deref(),
"mcp request",
);
return (
@@ -159,6 +178,9 @@ fn log_mcp_request(
ua: Option<&str>,
content_length: Option<u64>,
mcp_session: Option<&str>,
auth_key: Option<&str>,
enc_key: &str,
user_id: Option<&str>,
rpc: &JsonRpcMeta,
) {
tracing::info!(
@@ -174,18 +196,94 @@ fn log_mcp_request(
tool = rpc.tool_name.as_deref(),
jsonrpc_id = rpc.request_id.as_deref(),
batch_size = rpc.batch_size,
tool_args = rpc.tool_args.as_deref(),
auth_key,
enc_key,
user_id,
"mcp request",
);
}
// ── Sensitive header masking ──────────────────────────────────────────────────
/// Mask a Bearer token: emit only the first 12 characters followed by `…`.
/// Returns `None` if the Authorization header is absent or not a Bearer token.
/// Example: `sk_90c88844e4e5…`
fn mask_bearer(headers: &HeaderMap) -> Option<String> {
let val = headers.get(AUTHORIZATION)?.to_str().ok()?;
let token = val.strip_prefix("Bearer ")?.trim();
if token.is_empty() {
return None;
}
if token.len() > 12 {
Some(format!("{}", &token[..12]))
} else {
Some(token.to_string())
}
}
/// Fingerprint the X-Encryption-Key header.
///
/// Emits first 4 chars, last 4 chars, and raw byte length, e.g. `146b…5516(64)`.
/// Returns `"absent"` when the header is missing. Reveals enough to confirm
/// which key arrived and whether it was truncated or padded, without revealing
/// the full value.
fn mask_enc_key(headers: &HeaderMap) -> String {
match headers
.get("x-encryption-key")
.and_then(|v| v.to_str().ok())
{
Some(val) => {
let raw_len = val.len();
let t = val.trim();
let len = t.len();
if len >= 8 {
let prefix = &t[..4];
let suffix = &t[len - 4..];
if raw_len != len {
// Trailing/leading whitespace detected — extra diagnostic.
format!("{prefix}{suffix}({len}, raw={raw_len})")
} else {
format!("{prefix}{suffix}({len})")
}
} else {
format!("…({len})")
}
}
None => "absent".to_string(),
}
}
// ── JSON-RPC body parsing ─────────────────────────────────────────────────────
/// Safe (non-sensitive) argument keys that may be included verbatim in logs.
/// Keys NOT in this list (e.g. `secrets`, `secrets_obj`, `meta_obj`,
/// `encryption_key`) are silently dropped.
const SAFE_ARG_KEYS: &[&str] = &[
"id",
"name",
"name_query",
"folder",
"type",
"entry_type",
"field",
"query",
"tags",
"limit",
"offset",
"format",
"dry_run",
"prefix",
];
#[derive(Debug, Default)]
struct JsonRpcMeta {
request_id: Option<String>,
rpc_method: Option<String>,
tool_name: Option<String>,
batch_size: Option<usize>,
/// Non-sensitive tool call arguments for diagnostic logging.
tool_args: Option<String>,
}
fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta {
@@ -215,12 +313,47 @@ fn parse_single(value: &serde_json::Value) -> JsonRpcMeta {
.pointer("/params/name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tool_args = extract_tool_args(value);
JsonRpcMeta {
request_id,
rpc_method,
tool_name,
batch_size: None,
tool_args,
}
}
/// Extract a compact summary of non-sensitive tool arguments for logging.
/// Only keys listed in `SAFE_ARG_KEYS` are included.
fn extract_tool_args(value: &serde_json::Value) -> Option<String> {
let args = value.pointer("/params/arguments")?;
let obj = args.as_object()?;
let pairs: Vec<String> = obj
.iter()
.filter(|(k, v)| SAFE_ARG_KEYS.contains(&k.as_str()) && !v.is_null())
.map(|(k, v)| format!("{}={}", k, summarize_value(v)))
.collect();
if pairs.is_empty() {
None
} else {
Some(pairs.join(" "))
}
}
/// Produce a short, log-safe representation of a JSON value.
fn summarize_value(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => {
if s.len() > 64 {
format!("\"{}\"", &s[..64])
} else {
format!("\"{s}\"")
}
}
serde_json::Value::Array(arr) => format!("[…{}]", arr.len()),
serde_json::Value::Object(_) => "{…}".to_string(),
other => other.to_string(),
}
}

View File

@@ -265,6 +265,18 @@ impl SecretsService {
rmcp::ErrorData::invalid_request("Invalid X-Encryption-Key header value", None)
})?;
let trimmed = hex_str.trim();
// Debug-level fingerprint: helps diagnose header forwarding issues
// (e.g. Cursor Chat MCP truncating or transforming the key value)
// without revealing the full secret.
tracing::debug!(
raw_len = hex_str.len(),
trimmed_len = trimmed.len(),
key_prefix = trimmed.get(..8).unwrap_or(trimmed),
key_suffix = trimmed
.get(trimmed.len().saturating_sub(8)..)
.unwrap_or(trimmed),
"X-Encryption-Key received",
);
if trimmed.len() != 64 {
tracing::warn!(
got_len = trimmed.len(),
@@ -289,7 +301,51 @@ impl SecretsService {
.map_err(mcp_err_invalid_encryption_key_logged)
}
/// Require both user_id and encryption key.
/// Extract the encryption key, preferring an explicit argument value over
/// the X-Encryption-Key HTTP header.
///
/// `arg_key` is the optional `encryption_key` field from the tool's input
/// struct. When present, it is used directly and the header is ignored.
/// This allows MCP clients that cannot reliably forward custom HTTP headers
/// (e.g. Cursor Chat) to pass the key as a normal tool argument.
fn extract_enc_key_or_arg(
ctx: &RequestContext<RoleServer>,
arg_key: Option<&str>,
) -> Result<[u8; 32], rmcp::ErrorData> {
if let Some(hex_str) = arg_key {
let trimmed = hex_str.trim();
tracing::debug!(
source = "argument",
raw_len = hex_str.len(),
trimmed_len = trimmed.len(),
key_prefix = trimmed.get(..8).unwrap_or(trimmed),
key_suffix = trimmed
.get(trimmed.len().saturating_sub(8)..)
.unwrap_or(trimmed),
"X-Encryption-Key received",
);
if trimmed.len() != 64 {
return Err(rmcp::ErrorData::invalid_request(
format!(
"encryption_key must be exactly 64 hex characters (32-byte key), got {}.",
trimmed.len()
),
None,
));
}
if !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(rmcp::ErrorData::invalid_request(
"encryption_key contains non-hexadecimal characters.",
None,
));
}
return secrets_core::crypto::extract_key_from_hex(trimmed)
.map_err(mcp_err_invalid_encryption_key_logged);
}
Self::extract_enc_key(ctx)
}
/// Require both user_id and encryption key (header only, no arg fallback).
fn require_user_and_key(
ctx: &RequestContext<RoleServer>,
) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> {
@@ -297,6 +353,17 @@ impl SecretsService {
let key = Self::extract_enc_key(ctx)?;
Ok((user_id, key))
}
/// Require both user_id and encryption key, preferring an explicit argument
/// value over the X-Encryption-Key header.
fn require_user_and_key_or_arg(
ctx: &RequestContext<RoleServer>,
arg_key: Option<&str>,
) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> {
let user_id = Self::require_user_id(ctx)?;
let key = Self::extract_enc_key_or_arg(ctx, arg_key)?;
Ok((user_id, key))
}
}
// ── Tool parameter types ──────────────────────────────────────────────────────
@@ -370,6 +437,10 @@ struct GetSecretInput {
id: String,
#[schemars(description = "Specific field to retrieve. If omitted, returns all fields.")]
field: Option<String>,
#[schemars(description = "Encryption key as a 64-char hex string. \
If provided, takes priority over the X-Encryption-Key HTTP header. \
Use this when the MCP client cannot reliably forward custom headers.")]
encryption_key: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -416,6 +487,10 @@ struct AddInput {
)]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
link_secret_names: Option<Vec<String>>,
#[schemars(description = "Encryption key as a 64-char hex string. \
If provided, takes priority over the X-Encryption-Key HTTP header. \
Use this when the MCP client cannot reliably forward custom headers.")]
encryption_key: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -477,6 +552,10 @@ struct UpdateInput {
)]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
unlink_secret_names: Option<Vec<String>>,
#[schemars(description = "Encryption key as a 64-char hex string. \
If provided, takes priority over the X-Encryption-Key HTTP header. \
Use this when the MCP client cannot reliably forward custom headers.")]
encryption_key: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -549,6 +628,10 @@ struct ExportInput {
query: Option<String>,
#[schemars(description = "Export format: 'json' (default), 'toml', 'yaml'")]
format: Option<String>,
#[schemars(description = "Encryption key as a 64-char hex string. \
If provided, takes priority over the X-Encryption-Key HTTP header. \
Use this when the MCP client cannot reliably forward custom headers.")]
encryption_key: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -572,6 +655,10 @@ struct EnvMapInput {
Example: entry 'aliyun', field 'access_key_id' → ALIYUN_ACCESS_KEY_ID \
(or PREFIX_ALIYUN_ACCESS_KEY_ID with prefix set).")]
prefix: Option<String>,
#[schemars(description = "Encryption key as a 64-char hex string. \
If provided, takes priority over the X-Encryption-Key HTTP header. \
Use this when the MCP client cannot reliably forward custom headers.")]
encryption_key: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -876,7 +963,8 @@ impl SecretsService {
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let (user_id, user_key) =
Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
let entry_id = parse_uuid(&input.id)?;
tracing::info!(
tool = "secrets_get",
@@ -930,7 +1018,8 @@ impl SecretsService {
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let (user_id, user_key) =
Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
tracing::info!(
tool = "secrets_add",
?user_id,
@@ -1024,7 +1113,8 @@ impl SecretsService {
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let (user_id, user_key) =
Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
tracing::info!(
tool = "secrets_update",
?user_id,
@@ -1318,7 +1408,8 @@ impl SecretsService {
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let (user_id, user_key) =
Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
let tags = input.tags.unwrap_or_default();
let format = input.format.as_deref().unwrap_or("json");
tracing::info!(
@@ -1387,7 +1478,8 @@ impl SecretsService {
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let (user_id, user_key) =
Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
let tags = input.tags.unwrap_or_default();
let only_fields = input.only_fields.unwrap_or_default();
tracing::info!(