release(secrets-mcp): 0.2.0
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m12s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s

- 日志时间戳使用本地时区(chrono RFC3339 + 偏移)
- MCP tools / Web 路由与行为调整
- 新增 static/llms.txt、robots.txt;文档与 deploy 示例同步

Made-with: Cursor
This commit is contained in:
voson
2026-03-22 14:44:00 +08:00
parent 0b57605103
commit e3ca43ca3f
10 changed files with 382 additions and 108 deletions

View File

@@ -17,6 +17,7 @@ use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use secrets_core::models::ExportFormat;
use secrets_core::service::{
add::{AddParams, run as svc_add},
delete::{DeleteParams, run as svc_delete},
@@ -30,6 +31,32 @@ use secrets_core::service::{
use crate::auth::AuthUser;
// ── MCP client-facing errors (no internal details) ───────────────────────────
fn mcp_err_missing_http_parts() -> rmcp::ErrorData {
rmcp::ErrorData::internal_error("Invalid MCP request context.", None)
}
fn mcp_err_internal_logged(
tool: &'static str,
user_id: Option<Uuid>,
err: impl std::fmt::Display,
) -> rmcp::ErrorData {
tracing::warn!(tool, ?user_id, error = %err, "tool call failed");
rmcp::ErrorData::internal_error(
"Request failed due to a server error. Check service logs if you need details.",
None,
)
}
fn mcp_err_invalid_encryption_key_logged(err: impl std::fmt::Display) -> rmcp::ErrorData {
tracing::warn!(error = %err, "invalid X-Encryption-Key");
rmcp::ErrorData::invalid_request(
"Invalid X-Encryption-Key: must be exactly 64 hexadecimal characters (32-byte key).",
None,
)
}
// ── Shared state ──────────────────────────────────────────────────────────────
#[derive(Clone)]
@@ -51,7 +78,7 @@ impl SecretsService {
let parts = ctx
.extensions
.get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?;
.ok_or_else(mcp_err_missing_http_parts)?;
Ok(parts.extensions.get::<AuthUser>().map(|a| a.user_id))
}
@@ -60,7 +87,7 @@ impl SecretsService {
let parts = ctx
.extensions
.get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?;
.ok_or_else(mcp_err_missing_http_parts)?;
parts
.extensions
.get::<AuthUser>()
@@ -74,7 +101,7 @@ impl SecretsService {
let parts = ctx
.extensions
.get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?;
.ok_or_else(mcp_err_missing_http_parts)?;
let hex_str = parts
.headers
.get("x-encryption-key")
@@ -89,8 +116,29 @@ impl SecretsService {
.map_err(|_| {
rmcp::ErrorData::invalid_request("Invalid X-Encryption-Key header value", None)
})?;
let trimmed = hex_str.trim();
if trimmed.len() != 64 {
tracing::warn!(
got_len = trimmed.len(),
"X-Encryption-Key has wrong length after trim"
);
return Err(rmcp::ErrorData::invalid_request(
format!(
"X-Encryption-Key must be exactly 64 hex characters (32-byte key), got {} characters.",
trimmed.len()
),
None,
));
}
if !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
tracing::warn!("X-Encryption-Key contains non-hexadecimal characters");
return Err(rmcp::ErrorData::invalid_request(
"X-Encryption-Key contains non-hexadecimal characters.",
None,
));
}
secrets_core::crypto::extract_key_from_hex(hex_str)
.map_err(|e| rmcp::ErrorData::invalid_request(e.to_string(), None))
.map_err(mcp_err_invalid_encryption_key_logged)
}
/// Require both user_id and encryption key.
@@ -251,7 +299,12 @@ struct EnvMapInput {
impl SecretsService {
#[tool(
description = "Search entries in the secrets store. Returns entries with metadata and \
secret field names (not values). Use secrets_get to decrypt secret values."
secret field names (not values). Use secrets_get to decrypt secret values.",
annotations(
title = "Search Secrets",
read_only_hint = true,
idempotent_hint = true
)
)]
async fn secrets_search(
&self,
@@ -285,10 +338,7 @@ impl SecretsService {
},
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_search", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_search", user_id, e))?;
let summary = input.summary.unwrap_or(false);
let entries: Vec<serde_json::Value> = result
@@ -341,7 +391,12 @@ impl SecretsService {
#[tool(
description = "Get decrypted secret field values for an entry. Requires your \
encryption key via X-Encryption-Key header (64 hex chars, PBKDF2-derived). \
Returns all fields, or a specific field if 'field' is provided."
Returns all fields, or a specific field if 'field' is provided.",
annotations(
title = "Get Secret Values",
read_only_hint = true,
idempotent_hint = true
)
)]
async fn secrets_get(
&self,
@@ -371,10 +426,7 @@ impl SecretsService {
Some(user_id),
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_get", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?;
tracing::info!(
tool = "secrets_get",
@@ -395,10 +447,7 @@ impl SecretsService {
Some(user_id),
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_get", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?;
let count = secrets.len();
tracing::info!(
@@ -416,7 +465,8 @@ impl SecretsService {
#[tool(
description = "Add or upsert an entry with metadata and encrypted secret fields. \
Requires X-Encryption-Key header. \
Meta and secret values use 'key=value', 'key=@file', or 'key:=<json>' format."
Meta and secret values use 'key=value', 'key=@file', or 'key:=<json>' format.",
annotations(title = "Add Secret Entry")
)]
async fn secrets_add(
&self,
@@ -452,10 +502,7 @@ impl SecretsService {
&user_key,
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_add", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_add", Some(user_id), e))?;
tracing::info!(
tool = "secrets_add",
@@ -472,7 +519,8 @@ impl SecretsService {
#[tool(
description = "Incrementally update an existing entry. Requires X-Encryption-Key header. \
Only the fields you specify are changed; everything else is preserved."
Only the fields you specify are changed; everything else is preserved.",
annotations(title = "Update Secret Entry")
)]
async fn secrets_update(
&self,
@@ -514,10 +562,7 @@ impl SecretsService {
&user_key,
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_update", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?;
tracing::info!(
tool = "secrets_update",
@@ -534,7 +579,8 @@ impl SecretsService {
#[tool(
description = "Delete one entry (specify namespace+kind+name) or bulk delete all \
entries matching namespace+kind. Use dry_run=true to preview."
entries matching namespace+kind. Use dry_run=true to preview.",
annotations(title = "Delete Secret Entry", destructive_hint = true)
)]
async fn secrets_delete(
&self,
@@ -564,10 +610,7 @@ impl SecretsService {
},
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_delete", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_delete", user_id, e))?;
tracing::info!(
tool = "secrets_delete",
@@ -582,7 +625,12 @@ impl SecretsService {
#[tool(
description = "View change history for an entry. Returns a list of versions with \
actions and timestamps."
actions and timestamps.",
annotations(
title = "View Secret History",
read_only_hint = true,
idempotent_hint = true
)
)]
async fn secrets_history(
&self,
@@ -609,10 +657,7 @@ impl SecretsService {
user_id,
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_history", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_history", user_id, e))?;
tracing::info!(
tool = "secrets_history",
@@ -626,7 +671,8 @@ impl SecretsService {
#[tool(
description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \
Omit to_version to restore the most recent snapshot."
Omit to_version to restore the most recent snapshot.",
annotations(title = "Rollback Secret Entry", destructive_hint = true)
)]
async fn secrets_rollback(
&self,
@@ -655,10 +701,7 @@ impl SecretsService {
Some(user_id),
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_rollback", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
tracing::info!(
tool = "secrets_rollback",
@@ -672,7 +715,12 @@ impl SecretsService {
#[tool(
description = "Export matching entries with decrypted secrets as JSON/TOML/YAML string. \
Requires X-Encryption-Key header. Useful for backup or data migration."
Requires X-Encryption-Key header. Useful for backup or data migration.",
annotations(
title = "Export Secrets",
read_only_hint = true,
idempotent_hint = true
)
)]
async fn secrets_export(
&self,
@@ -706,15 +754,23 @@ impl SecretsService {
Some(&user_key),
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_export", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?;
let serialized = format
.parse::<secrets_core::models::ExportFormat>()
.and_then(|fmt| fmt.serialize(&data))
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let fmt = format.parse::<ExportFormat>().map_err(|e| {
tracing::warn!(
tool = "secrets_export",
?user_id,
error = %e,
"invalid export format"
);
rmcp::ErrorData::invalid_request(
"Invalid export format. Use json, toml, or yaml.",
None,
)
})?;
let serialized = fmt
.serialize(&data)
.map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?;
tracing::info!(
tool = "secrets_export",
@@ -729,7 +785,8 @@ impl SecretsService {
#[tool(
description = "Build the environment variable map from entry secrets with decrypted \
plaintext values. Requires X-Encryption-Key header. \
Returns a JSON object of VAR_NAME -> plaintext_value ready for injection."
Returns a JSON object of VAR_NAME -> plaintext_value ready for injection.",
annotations(title = "Build Env Map", read_only_hint = true, idempotent_hint = true)
)]
async fn secrets_env_map(
&self,
@@ -761,10 +818,7 @@ impl SecretsService {
Some(user_id),
)
.await
.map_err(|e| {
tracing::warn!(tool = "secrets_env_map", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
.map_err(|e| mcp_err_internal_logged("secrets_env_map", Some(user_id), e))?;
let entry_count = env_map.len();
tracing::info!(
@@ -785,8 +839,12 @@ impl SecretsService {
impl ServerHandler for SecretsService {
fn get_info(&self) -> InitializeResult {
let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build());
info.server_info = Implementation::new("secrets-mcp", env!("CARGO_PKG_VERSION"));
info.protocol_version = ProtocolVersion::V_2025_03_26;
info.server_info = Implementation::new("secrets-mcp", env!("CARGO_PKG_VERSION"))
.with_title("Secrets MCP")
.with_description(
"Secure cross-device secrets and configuration management with encrypted secret fields.",
);
info.protocol_version = ProtocolVersion::V_2025_06_18;
info.instructions = Some(
"Manage cross-device secrets and configuration securely. \
Data is encrypted with your passphrase-derived key. \