feat: entry update links existing secrets (link_secret_names)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s

- secrets-core: update flow validates and applies secret links
- secrets-mcp: MCP tool params and UI for managing links on edit
- Align errors and templates; minor crypto/.gitignore tweaks

Made-with: Cursor
This commit is contained in:
voson
2026-04-04 20:30:32 +08:00
parent 4a1654c820
commit 0ffb81e57f
10 changed files with 321 additions and 19 deletions

View File

@@ -13,11 +13,65 @@ use rmcp::{
tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde::{Deserialize, Deserializer, de};
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
// ── Serde helpers for numeric parameters that may arrive as strings ──────────
mod deser {
use super::*;
/// Deserialize a value that may come as a JSON number or a JSON string.
pub fn option_u32_from_string<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum NumOrStr {
Num(u32),
Str(String),
}
match Option::<NumOrStr>::deserialize(deserializer)? {
None => Ok(None),
Some(NumOrStr::Num(n)) => Ok(Some(n)),
Some(NumOrStr::Str(s)) => {
if s.is_empty() {
return Ok(None);
}
s.parse::<u32>().map(Some).map_err(de::Error::custom)
}
}
}
/// Deserialize an i64 that may come as a JSON number or a JSON string.
pub fn option_i64_from_string<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum NumOrStr {
Num(i64),
Str(String),
}
match Option::<NumOrStr>::deserialize(deserializer)? {
None => Ok(None),
Some(NumOrStr::Num(n)) => Ok(Some(n)),
Some(NumOrStr::Str(s)) => {
if s.is_empty() {
return Ok(None);
}
s.parse::<i64>().map(Some).map_err(de::Error::custom)
}
}
}
}
use secrets_core::models::ExportFormat;
use secrets_core::service::{
add::{AddParams, run as svc_add},
@@ -188,6 +242,7 @@ struct FindInput {
#[schemars(description = "Tag filters (all must match)")]
tags: Option<Vec<String>>,
#[schemars(description = "Max results (default 20)")]
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
limit: Option<u32>,
}
@@ -215,8 +270,10 @@ struct SearchInput {
#[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
sort: Option<String>,
#[schemars(description = "Max results (default 20)")]
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
limit: Option<u32>,
#[schemars(description = "Pagination offset (default 0)")]
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
offset: Option<u32>,
}
@@ -307,6 +364,14 @@ struct UpdateInput {
secret_types: Option<Map<String, Value>>,
#[schemars(description = "Secret field keys to remove")]
remove_secrets: Option<Vec<String>>,
#[schemars(
description = "Link existing secrets by name to this entry. Names must resolve uniquely under current user."
)]
link_secret_names: Option<Vec<String>>,
#[schemars(
description = "Unlink secrets by name from this entry. Orphaned secrets are auto-deleted."
)]
unlink_secret_names: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -341,6 +406,7 @@ struct HistoryInput {
)]
id: Option<String>,
#[schemars(description = "Max history entries to return (default 20)")]
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
limit: Option<u32>,
}
@@ -357,6 +423,7 @@ struct RollbackInput {
)]
id: Option<String>,
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
#[serde(default, deserialize_with = "deser::option_i64_from_string")]
to_version: Option<i64>,
}
@@ -643,7 +710,7 @@ impl SecretsService {
let value =
get_secret_field_by_id(&self.pool, entry_id, field_name, &user_key, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?;
.map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?;
tracing::info!(
tool = "secrets_get",
@@ -657,7 +724,7 @@ impl SecretsService {
} else {
let secrets = get_all_secrets_by_id(&self.pool, entry_id, &user_key, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?;
.map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?;
tracing::info!(
tool = "secrets_get",
@@ -793,6 +860,8 @@ impl SecretsService {
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
.collect();
let remove_secrets = input.remove_secrets.unwrap_or_default();
let link_secret_names = input.link_secret_names.unwrap_or_default();
let unlink_secret_names = input.unlink_secret_names.unwrap_or_default();
let result = svc_update(
&self.pool,
@@ -807,6 +876,8 @@ impl SecretsService {
secret_entries: &secrets,
secret_types: &secret_types_map,
remove_secrets: &remove_secrets,
link_secret_names: &link_secret_names,
unlink_secret_names: &unlink_secret_names,
user_id: Some(user_id),
},
&user_key,
@@ -1048,7 +1119,7 @@ impl SecretsService {
Some(&user_key),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?;
.map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?;
let fmt = format.parse::<ExportFormat>().map_err(|e| {
tracing::warn!(
@@ -1064,7 +1135,7 @@ impl SecretsService {
})?;
let serialized = fmt
.serialize(&data)
.map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?;
.map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?;
tracing::info!(
tool = "secrets_export",
@@ -1115,7 +1186,7 @@ impl SecretsService {
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_env_map", Some(user_id), e))?;
.map_err(|e| mcp_err_from_anyhow("secrets_env_map", Some(user_id), e))?;
let entry_count = env_map.len();
tracing::info!(