Release secrets-mcp 0.3.0: folder/type schema and MCP folder disambiguation
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m39s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s

- Rename namespace/kind to folder/type on entries, audit_log, and history tables;
  add notes. Unique key is (user_id, folder, name).
- Service layer and MCP tools support name-first lookup with optional folder when
  multiple entries share the same name.
- secrets_delete dry_run uses the same disambiguation as real deletes.
- Add scripts/migrate-v0.3.0.sql for manual DB migration. Refresh README and
  AGENTS.md.

Made-with: Cursor
This commit is contained in:
voson
2026-03-26 15:12:28 +08:00
parent f7afd7f819
commit 409fd78a35
21 changed files with 1108 additions and 558 deletions

View File

@@ -155,17 +155,18 @@ impl SecretsService {
#[derive(Debug, Deserialize, JsonSchema)]
struct SearchInput {
#[schemars(description = "Namespace filter (e.g. 'refining', 'ricnsmart')")]
namespace: Option<String>,
#[schemars(description = "Kind filter (e.g. 'server', 'service', 'key')")]
kind: Option<String>,
#[schemars(description = "Exact record name")]
#[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")]
query: Option<String>,
#[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")]
folder: Option<String>,
#[schemars(description = "Type filter (e.g. 'server', 'service', 'person', 'key')")]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Exact name to match")]
name: Option<String>,
#[schemars(description = "Tag filters (all must match)")]
tags: Option<Vec<String>>,
#[schemars(description = "Fuzzy search across name, namespace, kind, tags, metadata")]
query: Option<String>,
#[schemars(description = "Return only summary fields (name/tags/desc/updated_at)")]
#[schemars(description = "Return only summary fields (name/tags/notes/updated_at)")]
summary: Option<bool>,
#[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
sort: Option<String>,
@@ -177,24 +178,29 @@ struct SearchInput {
#[derive(Debug, Deserialize, JsonSchema)]
struct GetSecretInput {
#[schemars(description = "Namespace of the entry")]
namespace: String,
#[schemars(description = "Kind of the entry (e.g. 'server', 'service')")]
kind: String,
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(
description = "Folder for disambiguation when multiple entries share the same name (optional)"
)]
folder: Option<String>,
#[schemars(description = "Specific field to retrieve. If omitted, returns all fields.")]
field: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct AddInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind (e.g. 'server', 'service', 'key')")]
kind: String,
#[schemars(description = "Unique name within namespace+kind")]
#[schemars(description = "Unique name for this entry")]
name: String,
#[schemars(description = "Folder for organization (optional, e.g. 'personal', 'refining')")]
folder: Option<String>,
#[schemars(
description = "Type/category of this entry (optional, e.g. 'server', 'person', 'key')"
)]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Free-text notes for this entry (optional)")]
notes: Option<String>,
#[schemars(description = "Tags for this entry")]
tags: Option<Vec<String>>,
#[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")]
@@ -205,12 +211,14 @@ struct AddInput {
#[derive(Debug, Deserialize, JsonSchema)]
struct UpdateInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
#[schemars(description = "Name of the entry to update")]
name: String,
#[schemars(
description = "Folder for disambiguation when multiple entries share the same name (optional)"
)]
folder: Option<String>,
#[schemars(description = "Update the notes field")]
notes: Option<String>,
#[schemars(description = "Tags to add")]
add_tags: Option<Vec<String>>,
#[schemars(description = "Tags to remove")]
@@ -227,46 +235,49 @@ struct UpdateInput {
#[derive(Debug, Deserialize, JsonSchema)]
struct DeleteInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind filter (required for single delete)")]
kind: Option<String>,
#[schemars(description = "Exact name to delete. Omit for bulk delete by namespace+kind.")]
#[schemars(description = "Name of the entry to delete (single delete). \
Omit to bulk delete by folder/type filters.")]
name: Option<String>,
#[schemars(description = "Folder filter for bulk delete")]
folder: Option<String>,
#[schemars(description = "Type filter for bulk delete")]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Preview deletions without writing")]
dry_run: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct HistoryInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(
description = "Folder for disambiguation when multiple entries share the same name (optional)"
)]
folder: Option<String>,
#[schemars(description = "Max history entries to return (default 20)")]
limit: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct RollbackInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(
description = "Folder for disambiguation when multiple entries share the same name (optional)"
)]
folder: Option<String>,
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
to_version: Option<i64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ExportInput {
#[schemars(description = "Namespace filter")]
namespace: Option<String>,
#[schemars(description = "Kind filter")]
kind: Option<String>,
#[schemars(description = "Folder filter")]
folder: Option<String>,
#[schemars(description = "Type filter")]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
@@ -279,10 +290,11 @@ struct ExportInput {
#[derive(Debug, Deserialize, JsonSchema)]
struct EnvMapInput {
#[schemars(description = "Namespace filter")]
namespace: Option<String>,
#[schemars(description = "Kind filter")]
kind: Option<String>,
#[schemars(description = "Folder filter")]
folder: Option<String>,
#[schemars(description = "Type filter")]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
@@ -316,8 +328,8 @@ impl SecretsService {
tracing::info!(
tool = "secrets_search",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
name = input.name.as_deref(),
query = input.query.as_deref(),
"tool call start",
@@ -326,8 +338,8 @@ impl SecretsService {
let result = svc_search(
&self.pool,
SearchParams {
namespace: input.namespace.as_deref(),
kind: input.kind.as_deref(),
folder: input.folder.as_deref(),
entry_type: input.entry_type.as_deref(),
name: input.name.as_deref(),
tags: &tags,
query: input.query.as_deref(),
@@ -347,12 +359,11 @@ impl SecretsService {
.map(|e| {
if summary {
serde_json::json!({
"namespace": e.namespace,
"kind": e.kind,
"name": e.name,
"folder": e.folder,
"type": e.entry_type,
"tags": e.tags,
"desc": e.metadata.get("desc").or_else(|| e.metadata.get("url"))
.and_then(|v| v.as_str()).unwrap_or(""),
"notes": e.notes,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
} else {
@@ -363,9 +374,10 @@ impl SecretsService {
.unwrap_or_default();
serde_json::json!({
"id": e.id,
"namespace": e.namespace,
"kind": e.kind,
"name": e.name,
"folder": e.folder,
"type": e.entry_type,
"notes": e.notes,
"tags": e.tags,
"metadata": e.metadata,
"secret_fields": schema,
@@ -408,8 +420,6 @@ impl SecretsService {
tracing::info!(
tool = "secrets_get",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
field = input.field.as_deref(),
"tool call start",
@@ -418,9 +428,8 @@ impl SecretsService {
if let Some(field_name) = &input.field {
let value = get_secret_field(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
input.folder.as_deref(),
field_name,
&user_key,
Some(user_id),
@@ -440,9 +449,8 @@ impl SecretsService {
} else {
let secrets = get_all_secrets(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
input.folder.as_deref(),
&user_key,
Some(user_id),
)
@@ -478,22 +486,26 @@ impl SecretsService {
tracing::info!(
tool = "secrets_add",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
"tool call start",
);
let tags = input.tags.unwrap_or_default();
let meta = input.meta.unwrap_or_default();
let secrets = input.secrets.unwrap_or_default();
let folder = input.folder.as_deref().unwrap_or("");
let entry_type = input.entry_type.as_deref().unwrap_or("");
let notes = input.notes.as_deref().unwrap_or("");
let result = svc_add(
&self.pool,
AddParams {
namespace: &input.namespace,
kind: &input.kind,
name: &input.name,
folder,
entry_type,
notes,
tags: &tags,
meta_entries: &meta,
secret_entries: &secrets,
@@ -507,8 +519,6 @@ impl SecretsService {
tracing::info!(
tool = "secrets_add",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
@@ -532,8 +542,6 @@ impl SecretsService {
tracing::info!(
tool = "secrets_update",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
"tool call start",
);
@@ -548,9 +556,9 @@ impl SecretsService {
let result = svc_update(
&self.pool,
UpdateParams {
namespace: &input.namespace,
kind: &input.kind,
name: &input.name,
folder: input.folder.as_deref(),
notes: input.notes.as_deref(),
add_tags: &add_tags,
remove_tags: &remove_tags,
meta_entries: &meta,
@@ -567,8 +575,6 @@ impl SecretsService {
tracing::info!(
tool = "secrets_update",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
@@ -578,8 +584,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.",
description = "Delete one entry by name, or bulk delete entries matching folder and/or type. \
Use dry_run=true to preview.",
annotations(title = "Delete Secret Entry", destructive_hint = true)
)]
async fn secrets_delete(
@@ -592,9 +598,9 @@ impl SecretsService {
tracing::info!(
tool = "secrets_delete",
?user_id,
namespace = %input.namespace,
kind = input.kind.as_deref(),
name = input.name.as_deref(),
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
dry_run = input.dry_run.unwrap_or(false),
"tool call start",
);
@@ -602,9 +608,9 @@ impl SecretsService {
let result = svc_delete(
&self.pool,
DeleteParams {
namespace: &input.namespace,
kind: input.kind.as_deref(),
name: input.name.as_deref(),
folder: input.folder.as_deref(),
entry_type: input.entry_type.as_deref(),
dry_run: input.dry_run.unwrap_or(false),
user_id,
},
@@ -615,7 +621,6 @@ impl SecretsService {
tracing::info!(
tool = "secrets_delete",
?user_id,
namespace = %input.namespace,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
@@ -642,17 +647,14 @@ impl SecretsService {
tracing::info!(
tool = "secrets_history",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
"tool call start",
);
let result = svc_history(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
input.folder.as_deref(),
input.limit.unwrap_or(20),
user_id,
)
@@ -684,8 +686,6 @@ impl SecretsService {
tracing::info!(
tool = "secrets_rollback",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
to_version = input.to_version,
"tool call start",
@@ -693,9 +693,8 @@ impl SecretsService {
let result = svc_rollback(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
input.folder.as_deref(),
input.to_version,
&user_key,
Some(user_id),
@@ -734,8 +733,8 @@ impl SecretsService {
tracing::info!(
tool = "secrets_export",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
format,
"tool call start",
);
@@ -743,8 +742,8 @@ impl SecretsService {
let data = svc_export(
&self.pool,
ExportParams {
namespace: input.namespace.as_deref(),
kind: input.kind.as_deref(),
folder: input.folder.as_deref(),
entry_type: input.entry_type.as_deref(),
name: input.name.as_deref(),
tags: &tags,
query: input.query.as_deref(),
@@ -800,16 +799,16 @@ impl SecretsService {
tracing::info!(
tool = "secrets_env_map",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
prefix = input.prefix.as_deref().unwrap_or(""),
"tool call start",
);
let env_map = secrets_core::service::env_map::build_env_map(
&self.pool,
input.namespace.as_deref(),
input.kind.as_deref(),
input.folder.as_deref(),
input.entry_type.as_deref(),
input.name.as_deref(),
&tags,
&only_fields,