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
This commit is contained in:
voson
2026-04-06 10:53:06 +08:00
parent e0fee639c1
commit cab234cfcb
2 changed files with 235 additions and 10 deletions

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!(