From beade4503d0d1d20ea7f47c5ec5c114a6c250f84 Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 26 Mar 2026 17:35:56 +0800 Subject: [PATCH] release(secrets-mcp): v0.3.1 - MCP: secrets_find, secrets_overview; secrets_get id-only; id on update/delete/history/rollback - Add meta_obj/secrets_obj; delete guard; env_map/instructions updates - Core: resolve_entry_by_id; get_*_by_id validates entry + tenant before decrypt Made-with: Cursor --- Cargo.lock | 2 +- crates/secrets-core/src/service/get_secret.rs | 51 ++- crates/secrets-core/src/service/search.rs | 30 ++ crates/secrets-mcp/Cargo.toml | 2 +- crates/secrets-mcp/src/tools.rs | 407 +++++++++++++++--- 5 files changed, 426 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 714ff2c..e0f7af2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-core/src/service/get_secret.rs b/crates/secrets-core/src/service/get_secret.rs index 48dfcd1..4cf9726 100644 --- a/crates/secrets-core/src/service/get_secret.rs +++ b/crates/secrets-core/src/service/get_secret.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use uuid::Uuid; use crate::crypto; -use crate::service::search::{fetch_secrets_for_entries, resolve_entry}; +use crate::service::search::{fetch_secrets_for_entries, resolve_entry, resolve_entry_by_id}; /// Decrypt a single named field from an entry. /// `folder` is optional; if omitted and multiple entries share the name, an error is returned. @@ -53,3 +53,52 @@ pub async fn get_all_secrets( } Ok(map) } + +/// Decrypt a single named field from an entry, located by its UUID. +pub async fn get_secret_field_by_id( + pool: &PgPool, + entry_id: Uuid, + field_name: &str, + master_key: &[u8; 32], + user_id: Option, +) -> Result { + resolve_entry_by_id(pool, entry_id, user_id) + .await + .map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?; + + let entry_ids = vec![entry_id]; + let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?; + let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]); + + let field = fields + .iter() + .find(|f| f.field_name == field_name) + .ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?; + + crypto::decrypt_json(master_key, &field.encrypted) +} + +/// Decrypt all secret fields from an entry, located by its UUID. +/// Returns a map field_name → decrypted Value. +pub async fn get_all_secrets_by_id( + pool: &PgPool, + entry_id: Uuid, + master_key: &[u8; 32], + user_id: Option, +) -> Result> { + // Validate entry exists (and that it belongs to the requesting user) + resolve_entry_by_id(pool, entry_id, user_id) + .await + .map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?; + + let entry_ids = vec![entry_id]; + let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?; + let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]); + + let mut map = HashMap::new(); + for f in fields { + let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?; + map.insert(f.field_name.clone(), decrypted); + } + Ok(map) +} diff --git a/crates/secrets-core/src/service/search.rs b/crates/secrets-core/src/service/search.rs index bebb8ff..b372194 100644 --- a/crates/secrets-core/src/service/search.rs +++ b/crates/secrets-core/src/service/search.rs @@ -208,6 +208,36 @@ pub async fn fetch_secrets_for_entries( Ok(map) } +/// Resolve exactly one entry by its UUID primary key. +/// +/// Returns an error if the entry does not exist or does not belong to the given user. +pub async fn resolve_entry_by_id( + pool: &PgPool, + id: Uuid, + user_id: Option, +) -> Result { + let row: Option = if let Some(uid) = user_id { + sqlx::query_as( + "SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \ + created_at, updated_at FROM entries WHERE id = $1 AND user_id = $2", + ) + .bind(id) + .bind(uid) + .fetch_optional(pool) + .await? + } else { + sqlx::query_as( + "SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \ + created_at, updated_at FROM entries WHERE id = $1 AND user_id IS NULL", + ) + .bind(id) + .fetch_optional(pool) + .await? + }; + row.map(Entry::from) + .ok_or_else(|| anyhow::anyhow!("Entry with id '{}' not found", id)) +} + /// Resolve exactly one entry by name, with optional folder for disambiguation. /// /// - If `folder` is provided: exact `(folder, name)` match. diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 5809db7..f493e52 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.3.0" +version = "0.3.1" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index ac6eaf3..4842d64 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -14,6 +14,7 @@ use rmcp::{ }; use schemars::JsonSchema; use serde::Deserialize; +use serde_json::{Map, Value}; use sqlx::PgPool; use uuid::Uuid; @@ -22,10 +23,10 @@ use secrets_core::service::{ add::{AddParams, run as svc_add}, delete::{DeleteParams, run as svc_delete}, export::{ExportParams, export as svc_export}, - get_secret::{get_all_secrets, get_secret_field}, + get_secret::{get_all_secrets_by_id, get_secret_field_by_id}, history::run as svc_history, rollback::run as svc_rollback, - search::{SearchParams, run as svc_search}, + search::{SearchParams, resolve_entry_by_id, run as svc_search}, update::{UpdateParams, run as svc_update}, }; @@ -153,6 +154,25 @@ impl SecretsService { // ── Tool parameter types ────────────────────────────────────────────────────── +#[derive(Debug, Deserialize, JsonSchema)] +struct FindInput { + #[schemars( + description = "Fuzzy search across name, folder, type, notes, tags, and metadata values" + )] + query: Option, + #[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")] + folder: Option, + #[schemars(description = "Exact type filter (e.g. 'server', 'service', 'person', 'key')")] + #[serde(rename = "type")] + entry_type: Option, + #[schemars(description = "Exact name filter")] + name: Option, + #[schemars(description = "Tag filters (all must match)")] + tags: Option>, + #[schemars(description = "Max results (default 20)")] + limit: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] struct SearchInput { #[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")] @@ -178,12 +198,8 @@ struct SearchInput { #[derive(Debug, Deserialize, JsonSchema)] struct GetSecretInput { - #[schemars(description = "Name of the entry")] - name: String, - #[schemars( - description = "Folder for disambiguation when multiple entries share the same name (optional)" - )] - folder: Option, + #[schemars(description = "Entry UUID obtained from secrets_find results")] + id: String, #[schemars(description = "Specific field to retrieve. If omitted, returns all fields.")] field: Option, } @@ -205,8 +221,16 @@ struct AddInput { tags: Option>, #[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")] meta: Option>, + #[schemars( + description = "Metadata fields as a JSON object {\"key\": value}. Merged with 'meta' if both provided." + )] + meta_obj: Option>, #[schemars(description = "Secret fields as 'key=value' strings")] secrets: Option>, + #[schemars( + description = "Secret fields as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided." + )] + secrets_obj: Option>, } #[derive(Debug, Deserialize, JsonSchema)] @@ -217,6 +241,10 @@ struct UpdateInput { description = "Folder for disambiguation when multiple entries share the same name (optional)" )] folder: Option, + #[schemars( + description = "Entry UUID (from secrets_find). If provided, name/folder are used for disambiguation only." + )] + id: Option, #[schemars(description = "Update the notes field")] notes: Option, #[schemars(description = "Tags to add")] @@ -225,16 +253,29 @@ struct UpdateInput { remove_tags: Option>, #[schemars(description = "Metadata fields to update/add as 'key=value' strings")] meta: Option>, + #[schemars( + description = "Metadata fields to update/add as a JSON object {\"key\": value}. Merged with 'meta' if both provided." + )] + meta_obj: Option>, #[schemars(description = "Metadata field keys to remove")] remove_meta: Option>, #[schemars(description = "Secret fields to update/add as 'key=value' strings")] secrets: Option>, + #[schemars( + description = "Secret fields to update/add as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided." + )] + secrets_obj: Option>, #[schemars(description = "Secret field keys to remove")] remove_secrets: Option>, } #[derive(Debug, Deserialize, JsonSchema)] struct DeleteInput { + #[schemars( + description = "Entry UUID (from secrets_find). If provided, deletes this specific entry \ + regardless of name/folder." + )] + id: Option, #[schemars(description = "Name of the entry to delete (single delete). \ Omit to bulk delete by folder/type filters.")] name: Option, @@ -255,6 +296,10 @@ struct HistoryInput { description = "Folder for disambiguation when multiple entries share the same name (optional)" )] folder: Option, + #[schemars( + description = "Entry UUID (from secrets_find). If provided, name/folder are ignored." + )] + id: Option, #[schemars(description = "Max history entries to return (default 20)")] limit: Option, } @@ -267,6 +312,10 @@ struct RollbackInput { description = "Folder for disambiguation when multiple entries share the same name (optional)" )] folder: Option, + #[schemars( + description = "Entry UUID (from secrets_find). If provided, name/folder are ignored." + )] + id: Option, #[schemars(description = "Target version number. Omit to restore the most recent snapshot.")] to_version: Option, } @@ -301,17 +350,118 @@ struct EnvMapInput { tags: Option>, #[schemars(description = "Only include these secret fields")] only_fields: Option>, - #[schemars(description = "Environment variable name prefix")] + #[schemars(description = "Environment variable name prefix. \ + Variable names are built as UPPER(prefix)_UPPER(entry_name)_UPPER(field_name), \ + with hyphens and dots replaced by underscores. \ + Example: entry 'aliyun', field 'access_key_id' → ALIYUN_ACCESS_KEY_ID \ + (or PREFIX_ALIYUN_ACCESS_KEY_ID with prefix set).")] prefix: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +struct OverviewInput {} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Convert a JSON object map into "key=value" / "key:=json" strings for service-layer parsing. +fn map_to_kv_strings(map: Map) -> Vec { + map.into_iter() + .map(|(k, v)| match &v { + Value::String(s) => format!("{}={}", k, s), + _ => format!("{}:={}", k, v), + }) + .collect() +} + +/// Parse a UUID string, returning an MCP error on failure. +fn parse_uuid(s: &str) -> Result { + s.parse::() + .map_err(|_| rmcp::ErrorData::invalid_request(format!("Invalid UUID: '{}'", s), None)) +} + // ── Tool implementations ────────────────────────────────────────────────────── #[tool_router] impl SecretsService { + #[tool( + description = "Find entries in the secrets store by folder, name, type, tags, or a \ + fuzzy query that also searches metadata values. Requires Bearer API key. \ + Returns 0 or more entries with id, metadata, and secret field names (not values). \ + Use the returned id with secrets_get to decrypt secret values. \ + Replaces secrets_search for discovery tasks.", + annotations(title = "Find Secrets", read_only_hint = true, idempotent_hint = true) + )] + async fn secrets_find( + &self, + Parameters(input): Parameters, + ctx: RequestContext, + ) -> Result { + let t = Instant::now(); + let user_id = Self::require_user_id(&ctx)?; + tracing::info!( + tool = "secrets_find", + ?user_id, + 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", + ); + let tags = input.tags.unwrap_or_default(); + let result = svc_search( + &self.pool, + SearchParams { + folder: input.folder.as_deref(), + entry_type: input.entry_type.as_deref(), + name: input.name.as_deref(), + tags: &tags, + query: input.query.as_deref(), + sort: "name", + limit: input.limit.unwrap_or(20), + offset: 0, + user_id: Some(user_id), + }, + ) + .await + .map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?; + + let entries: Vec = result + .entries + .iter() + .map(|e| { + let schema: Vec<&str> = result + .secret_schemas + .get(&e.id) + .map(|f| f.iter().map(|s| s.field_name.as_str()).collect()) + .unwrap_or_default(); + serde_json::json!({ + "id": e.id, + "name": e.name, + "folder": e.folder, + "type": e.entry_type, + "tags": e.tags, + "metadata": e.metadata, + "secret_fields": schema, + "updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + }) + }) + .collect(); + + tracing::info!( + tool = "secrets_find", + ?user_id, + result_count = entries.len(), + elapsed_ms = t.elapsed().as_millis(), + "tool call ok", + ); + let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + #[tool( description = "Search entries in the secrets store. Requires Bearer API key. Returns \ - entries with metadata and secret field names (not values). Use secrets_get to decrypt secret values.", + entries with metadata and secret field names (not values). \ + Prefer secrets_find for discovery; secrets_search is kept for backward compatibility.", annotations( title = "Search Secrets", read_only_hint = true, @@ -401,8 +551,8 @@ 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). \ + description = "Get decrypted secret field values for an entry identified by its UUID \ + (from secrets_find). Requires X-Encryption-Key header. \ Returns all fields, or a specific field if 'field' is provided.", annotations( title = "Get Secret Values", @@ -417,29 +567,23 @@ impl SecretsService { ) -> Result { let t = Instant::now(); let (user_id, user_key) = Self::require_user_and_key(&ctx)?; + let entry_id = parse_uuid(&input.id)?; tracing::info!( tool = "secrets_get", - ?user_id, - name = %input.name, + id = %input.id, field = input.field.as_deref(), "tool call start", ); if let Some(field_name) = &input.field { - let value = get_secret_field( - &self.pool, - &input.name, - input.folder.as_deref(), - field_name, - &user_key, - Some(user_id), - ) - .await - .map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?; + 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))?; tracing::info!( tool = "secrets_get", - ?user_id, + id = %input.id, elapsed_ms = t.elapsed().as_millis(), "tool call ok", ); @@ -447,21 +591,14 @@ impl SecretsService { let json = serde_json::to_string_pretty(&result).unwrap_or_default(); Ok(CallToolResult::success(vec![Content::text(json)])) } else { - let secrets = get_all_secrets( - &self.pool, - &input.name, - input.folder.as_deref(), - &user_key, - Some(user_id), - ) - .await - .map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?; + 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))?; - let count = secrets.len(); tracing::info!( - tool = "secrets_get", - ?user_id, - field_count = count, + tool = "secrets_get", + id = %entry_id, + field_count = secrets.len(), elapsed_ms = t.elapsed().as_millis(), "tool call ok", ); @@ -473,7 +610,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:=' format.", + Meta and secret values use 'key=value', 'key=@file', or 'key:=' format, \ + or pass a JSON object via meta_obj / secrets_obj.", annotations(title = "Add Secret Entry") )] async fn secrets_add( @@ -493,8 +631,14 @@ impl SecretsService { ); let tags = input.tags.unwrap_or_default(); - let meta = input.meta.unwrap_or_default(); - let secrets = input.secrets.unwrap_or_default(); + let mut meta = input.meta.unwrap_or_default(); + if let Some(obj) = input.meta_obj { + meta.extend(map_to_kv_strings(obj)); + } + let mut secrets = input.secrets.unwrap_or_default(); + if let Some(obj) = input.secrets_obj { + secrets.extend(map_to_kv_strings(obj)); + } 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(""); @@ -529,7 +673,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. \ + Optionally pass 'id' (from secrets_find) to target the entry directly.", annotations(title = "Update Secret Entry") )] async fn secrets_update( @@ -543,21 +688,40 @@ impl SecretsService { tool = "secrets_update", ?user_id, name = %input.name, + id = ?input.id, "tool call start", ); + // When id is provided, resolve to (name, folder) via primary key to skip disambiguation. + let (resolved_name, resolved_folder): (String, Option) = + if let Some(ref id_str) = input.id { + let eid = parse_uuid(id_str)?; + let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id)) + .await + .map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?; + (entry.name, Some(entry.folder)) + } else { + (input.name.clone(), input.folder.clone()) + }; + let add_tags = input.add_tags.unwrap_or_default(); let remove_tags = input.remove_tags.unwrap_or_default(); - let meta = input.meta.unwrap_or_default(); + let mut meta = input.meta.unwrap_or_default(); + if let Some(obj) = input.meta_obj { + meta.extend(map_to_kv_strings(obj)); + } let remove_meta = input.remove_meta.unwrap_or_default(); - let secrets = input.secrets.unwrap_or_default(); + let mut secrets = input.secrets.unwrap_or_default(); + if let Some(obj) = input.secrets_obj { + secrets.extend(map_to_kv_strings(obj)); + } let remove_secrets = input.remove_secrets.unwrap_or_default(); let result = svc_update( &self.pool, UpdateParams { - name: &input.name, - folder: input.folder.as_deref(), + name: &resolved_name, + folder: resolved_folder.as_deref(), notes: input.notes.as_deref(), add_tags: &add_tags, remove_tags: &remove_tags, @@ -575,7 +739,7 @@ impl SecretsService { tracing::info!( tool = "secrets_update", ?user_id, - name = %input.name, + name = %resolved_name, elapsed_ms = t.elapsed().as_millis(), "tool call ok", ); @@ -584,8 +748,9 @@ impl SecretsService { } #[tool( - description = "Delete one entry by name, or bulk delete entries matching folder and/or type. \ - Use dry_run=true to preview.", + description = "Delete one entry by name (or id), or bulk delete entries matching folder \ + and/or type. Use dry_run=true to preview. \ + At least one of id, name, folder, or type must be provided.", annotations(title = "Delete Secret Entry", destructive_hint = true) )] async fn secrets_delete( @@ -595,9 +760,23 @@ impl SecretsService { ) -> Result { let t = Instant::now(); let user_id = Self::user_id_from_ctx(&ctx)?; + + // Safety: require at least one filter. + if input.id.is_none() + && input.name.is_none() + && input.folder.is_none() + && input.entry_type.is_none() + { + return Err(rmcp::ErrorData::invalid_request( + "At least one of id, name, folder, or type must be provided.", + None, + )); + } + tracing::info!( tool = "secrets_delete", ?user_id, + id = ?input.id, name = input.name.as_deref(), folder = input.folder.as_deref(), entry_type = input.entry_type.as_deref(), @@ -605,11 +784,24 @@ impl SecretsService { "tool call start", ); + // When id is provided, resolve to name+folder for the single-entry delete path. + let (effective_name, effective_folder): (Option, Option) = + if let Some(ref id_str) = input.id { + let eid = parse_uuid(id_str)?; + let uid = user_id; + let entry = resolve_entry_by_id(&self.pool, eid, uid) + .await + .map_err(|e| mcp_err_internal_logged("secrets_delete", uid, e))?; + (Some(entry.name), Some(entry.folder)) + } else { + (input.name.clone(), input.folder.clone()) + }; + let result = svc_delete( &self.pool, DeleteParams { - name: input.name.as_deref(), - folder: input.folder.as_deref(), + name: effective_name.as_deref(), + folder: effective_folder.as_deref(), entry_type: input.entry_type.as_deref(), dry_run: input.dry_run.unwrap_or(false), user_id, @@ -630,7 +822,7 @@ impl SecretsService { #[tool( description = "View change history for an entry. Returns a list of versions with \ - actions and timestamps.", + actions and timestamps. Optionally pass 'id' (from secrets_find) to target directly.", annotations( title = "View Secret History", read_only_hint = true, @@ -648,13 +840,25 @@ impl SecretsService { tool = "secrets_history", ?user_id, name = %input.name, + id = ?input.id, "tool call start", ); + let (resolved_name, resolved_folder): (String, Option) = + if let Some(ref id_str) = input.id { + let eid = parse_uuid(id_str)?; + let entry = resolve_entry_by_id(&self.pool, eid, user_id) + .await + .map_err(|e| mcp_err_internal_logged("secrets_history", user_id, e))?; + (entry.name, Some(entry.folder)) + } else { + (input.name.clone(), input.folder.clone()) + }; + let result = svc_history( &self.pool, - &input.name, - input.folder.as_deref(), + &resolved_name, + resolved_folder.as_deref(), input.limit.unwrap_or(20), user_id, ) @@ -673,7 +877,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. \ + Optionally pass 'id' (from secrets_find) to target directly.", annotations(title = "Rollback Secret Entry", destructive_hint = true) )] async fn secrets_rollback( @@ -687,14 +892,26 @@ impl SecretsService { tool = "secrets_rollback", ?user_id, name = %input.name, + id = ?input.id, to_version = input.to_version, "tool call start", ); + let (resolved_name, resolved_folder): (String, Option) = + if let Some(ref id_str) = input.id { + let eid = parse_uuid(id_str)?; + let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id)) + .await + .map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?; + (entry.name, Some(entry.folder)) + } else { + (input.name.clone(), input.folder.clone()) + }; + let result = svc_rollback( &self.pool, - &input.name, - input.folder.as_deref(), + &resolved_name, + resolved_folder.as_deref(), input.to_version, &user_key, Some(user_id), @@ -784,7 +1001,10 @@ 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. \ + Variable names follow the pattern UPPER(entry_name)_UPPER(field_name), \ + with hyphens and dots replaced by underscores. \ + Example: entry 'aliyun', field 'access_key_id' → ALIYUN_ACCESS_KEY_ID.", annotations(title = "Build Env Map", read_only_hint = true, idempotent_hint = true) )] async fn secrets_env_map( @@ -830,6 +1050,67 @@ impl SecretsService { let json = serde_json::to_string_pretty(&env_map).unwrap_or_default(); Ok(CallToolResult::success(vec![Content::text(json)])) } + + #[tool( + description = "Get an overview of the secrets store: counts of entries per folder and \ + per type. Requires Bearer API key. Useful for exploring the store structure.", + annotations( + title = "Secrets Overview", + read_only_hint = true, + idempotent_hint = true + ) + )] + async fn secrets_overview( + &self, + Parameters(_input): Parameters, + ctx: RequestContext, + ) -> Result { + let t = Instant::now(); + let user_id = Self::require_user_id(&ctx)?; + tracing::info!(tool = "secrets_overview", ?user_id, "tool call start"); + + #[derive(sqlx::FromRow)] + struct CountRow { + name: String, + count: i64, + } + + let folder_rows: Vec = sqlx::query_as( + "SELECT folder AS name, COUNT(*) AS count FROM entries \ + WHERE user_id = $1 GROUP BY folder ORDER BY folder", + ) + .bind(user_id) + .fetch_all(&*self.pool) + .await + .map_err(|e| mcp_err_internal_logged("secrets_overview", Some(user_id), e))?; + + let type_rows: Vec = sqlx::query_as( + "SELECT type AS name, COUNT(*) AS count FROM entries \ + WHERE user_id = $1 GROUP BY type ORDER BY type", + ) + .bind(user_id) + .fetch_all(&*self.pool) + .await + .map_err(|e| mcp_err_internal_logged("secrets_overview", Some(user_id), e))?; + + let total: i64 = folder_rows.iter().map(|r| r.count).sum(); + + let result = serde_json::json!({ + "total": total, + "folders": folder_rows.iter().map(|r| serde_json::json!({"name": r.name, "count": r.count})).collect::>(), + "types": type_rows.iter().map(|r| serde_json::json!({"name": r.name, "count": r.count})).collect::>(), + }); + + tracing::info!( + tool = "secrets_overview", + ?user_id, + total, + elapsed_ms = t.elapsed().as_millis(), + "tool call ok", + ); + let json = serde_json::to_string_pretty(&result).unwrap_or_default(); + Ok(CallToolResult::success(vec![Content::text(json)])) + } } // ── ServerHandler ───────────────────────────────────────────────────────────── @@ -846,11 +1127,11 @@ impl ServerHandler for SecretsService { 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. \ - Include your 64-char hex key in the X-Encryption-Key header for all read/write operations. \ - Use secrets_search to discover entries (Bearer token required; encryption key not needed), \ - secrets_get to decrypt secret values, \ - and secrets_add/secrets_update to write encrypted secrets." + Use secrets_find to discover entries by folder, name, type, tags, or query \ + (query also searches metadata values). \ + Use secrets_get with the entry id (from secrets_find) to decrypt secret values. \ + Use secrets_add / secrets_update to write entries. \ + Use secrets_overview for a quick count of entries per folder and type." .to_string(), ); info