use std::sync::Arc; use std::time::Instant; use anyhow::Result; use rmcp::{ RoleServer, ServerHandler, handler::server::wrapper::Parameters, model::{ CallToolResult, Content, Implementation, InitializeResult, ProtocolVersion, ServerCapabilities, }, service::RequestContext, tool, tool_handler, tool_router, }; use schemars::JsonSchema; 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, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum NumOrStr { Num(u32), Str(String), } match Option::::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::().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, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum NumOrStr { Num(i64), Str(String), } match Option::::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::().map(Some).map_err(de::Error::custom) } } } /// Deserialize a bool that may come as a JSON bool or a JSON string ("true"/"false"). pub fn option_bool_from_string<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum BoolOrStr { Bool(bool), Str(String), } match Option::::deserialize(deserializer)? { None => Ok(None), Some(BoolOrStr::Bool(b)) => Ok(Some(b)), Some(BoolOrStr::Str(s)) => { if s.is_empty() { return Ok(None); } s.parse::().map(Some).map_err(de::Error::custom) } } } /// Deserialize a Vec that may come as a JSON array or a JSON string containing an array. pub fn option_vec_string_from_string<'de, D>( deserializer: D, ) -> Result>, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum VecOrStr { Vec(Vec), Str(String), } match Option::::deserialize(deserializer)? { None => Ok(None), Some(VecOrStr::Vec(v)) => Ok(Some(v)), Some(VecOrStr::Str(s)) => { if s.is_empty() { return Ok(None); } serde_json::from_str(&s) .map(Some) .map_err(|e| { de::Error::custom(format!( "invalid string value for array field: expected a JSON array, e.g. '[\"a\",\"b\"]': {e}" )) }) } } } /// Deserialize a Map that may come as a JSON object or a JSON string containing an object. pub fn option_map_from_string<'de, D>( deserializer: D, ) -> Result>, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum MapOrStr { Map(Map), Str(String), } match Option::::deserialize(deserializer)? { None => Ok(None), Some(MapOrStr::Map(m)) => Ok(Some(m)), Some(MapOrStr::Str(s)) => { if s.is_empty() { return Ok(None); } serde_json::from_str(&s) .map(Some) .map_err(|e| { de::Error::custom(format!( "invalid string value for object field: expected a JSON object, e.g. '{{\"key\":\"value\"}}': {e}" )) }) } } } } use secrets_core::models::ExportFormat; 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_by_id, get_secret_field_by_id}, history::run as svc_history, rollback::run as svc_rollback, search::{SearchParams, resolve_entry_by_id, run as svc_search}, update::{UpdateParams, run as svc_update}, }; use crate::auth::AuthUser; use crate::error; // ── 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, 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_from_anyhow( tool: &'static str, user_id: Option, err: anyhow::Error, ) -> rmcp::ErrorData { if let Some(app_err) = err.downcast_ref::() { return error::app_error_to_mcp(app_err); } mcp_err_internal_logged(tool, user_id, err) } 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)] pub struct SecretsService { pub pool: Arc, pub tool_router: rmcp::handler::server::router::tool::ToolRouter, } impl SecretsService { pub fn new(pool: Arc) -> Self { Self { pool, tool_router: Self::tool_router(), } } /// Extract user_id from the HTTP request parts injected by auth middleware. fn user_id_from_ctx(ctx: &RequestContext) -> Result, rmcp::ErrorData> { let parts = ctx .extensions .get::() .ok_or_else(mcp_err_missing_http_parts)?; Ok(parts.extensions.get::().map(|a| a.user_id)) } /// Get the authenticated user_id (returns error if not authenticated). fn require_user_id(ctx: &RequestContext) -> Result { let parts = ctx .extensions .get::() .ok_or_else(mcp_err_missing_http_parts)?; parts .extensions .get::() .map(|a| a.user_id) .ok_or_else(|| rmcp::ErrorData::invalid_request("Unauthorized: API key required", None)) } /// Extract the 32-byte encryption key from the X-Encryption-Key request header. /// The header value must be 64 lowercase hex characters (PBKDF2-derived key). fn extract_enc_key(ctx: &RequestContext) -> Result<[u8; 32], rmcp::ErrorData> { let parts = ctx .extensions .get::() .ok_or_else(mcp_err_missing_http_parts)?; let hex_str = parts .headers .get("x-encryption-key") .ok_or_else(|| { rmcp::ErrorData::invalid_request( "Missing X-Encryption-Key header. \ Set this to your 64-char hex encryption key derived from your passphrase.", None, ) })? .to_str() .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(mcp_err_invalid_encryption_key_logged) } /// Require both user_id and encryption key. fn require_user_and_key( ctx: &RequestContext, ) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> { let user_id = Self::require_user_id(ctx)?; let key = Self::extract_enc_key(ctx)?; Ok((user_id, key)) } } // ── 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', 'account', 'person', 'document'). User-defined, any value accepted." )] #[serde(rename = "type")] entry_type: Option, #[schemars(description = "Exact name filter. For fuzzy matching use name_query instead.")] name: Option, #[schemars( description = "Fuzzy name filter (ILIKE, case-insensitive partial match). Use this instead of 'name' when you don't know the exact name." )] name_query: Option, #[schemars(description = "Tag filters (all must match)")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Max results (default 20)")] #[serde(default, deserialize_with = "deser::option_u32_from_string")] limit: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct SearchInput { #[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")] query: Option, #[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")] folder: Option, #[schemars( description = "Type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted." )] #[serde(rename = "type")] entry_type: Option, #[schemars(description = "Exact name to match. For fuzzy matching use name_query instead.")] name: Option, #[schemars( description = "Fuzzy name filter (ILIKE, case-insensitive partial match). Use this instead of 'name' when you don't know the exact name." )] name_query: Option, #[schemars(description = "Tag filters (all must match)")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Return only summary fields (name/tags/notes/updated_at)")] #[serde(default, deserialize_with = "deser::option_bool_from_string")] summary: Option, #[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")] sort: Option, #[schemars(description = "Max results (default 20)")] #[serde(default, deserialize_with = "deser::option_u32_from_string")] limit: Option, #[schemars(description = "Pagination offset (default 0)")] #[serde(default, deserialize_with = "deser::option_u32_from_string")] offset: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct GetSecretInput { #[schemars(description = "Entry UUID obtained from secrets_find results")] id: String, #[schemars(description = "Specific field to retrieve. If omitted, returns all fields.")] field: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct AddInput { #[schemars(description = "Unique name for this entry")] name: String, #[schemars(description = "Folder for organization (optional, e.g. 'personal', 'refining')")] folder: Option, #[schemars( description = "Type/category of this entry (optional, e.g. 'server', 'service', 'account', 'person', 'document'). Free-form, choose what best describes the entry." )] #[serde(rename = "type")] entry_type: Option, #[schemars(description = "Free-text notes for this entry (optional)")] notes: Option, #[schemars(description = "Tags for this entry")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] meta: Option>, #[schemars( description = "Metadata fields as a JSON object {\"key\": value}. Merged with 'meta' if both provided." )] #[serde(default, deserialize_with = "deser::option_map_from_string")] meta_obj: Option>, #[schemars( description = "Secret fields as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets." )] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] secrets: Option>, #[schemars( description = "Secret fields as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address." )] #[serde(default, deserialize_with = "deser::option_map_from_string")] secrets_obj: Option>, #[schemars( description = "Secret types as {\"secret_name\": \"type\"}. Keys must match secret field names. Missing keys default to \"text\"." )] #[serde(default, deserialize_with = "deser::option_map_from_string")] secret_types: Option>, #[schemars( description = "Link existing secrets by secret name. Names must resolve uniquely under current user." )] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] link_secret_names: Option>, } #[derive(Debug, Deserialize, JsonSchema)] struct UpdateInput { #[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, #[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")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] add_tags: Option>, #[schemars(description = "Tags to remove")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] remove_tags: Option>, #[schemars(description = "Metadata fields to update/add as 'key=value' strings")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] meta: Option>, #[schemars( description = "Metadata fields to update/add as a JSON object {\"key\": value}. Merged with 'meta' if both provided." )] #[serde(default, deserialize_with = "deser::option_map_from_string")] meta_obj: Option>, #[schemars(description = "Metadata field keys to remove")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] remove_meta: Option>, #[schemars( description = "Secret fields to update/add as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets." )] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] secrets: Option>, #[schemars( description = "Secret fields to update/add as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address." )] #[serde(default, deserialize_with = "deser::option_map_from_string")] secrets_obj: Option>, #[schemars( description = "Secret types as {\"secret_name\": \"type\"}. Keys must match secret field names. Missing keys default to \"text\"." )] #[serde(default, deserialize_with = "deser::option_map_from_string")] secret_types: Option>, #[schemars(description = "Secret field keys to remove")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] remove_secrets: Option>, #[schemars( description = "Link existing secrets by name to this entry. Names must resolve uniquely under current user." )] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] link_secret_names: Option>, #[schemars( description = "Unlink secrets by name from this entry. Orphaned secrets are auto-deleted." )] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] unlink_secret_names: 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, #[schemars(description = "Folder filter for bulk delete")] folder: Option, #[schemars(description = "Type filter for bulk delete")] #[serde(rename = "type")] entry_type: Option, #[schemars(description = "Preview deletions without writing")] #[serde(default, deserialize_with = "deser::option_bool_from_string")] dry_run: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct HistoryInput { #[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 (from secrets_find). If provided, name/folder are ignored." )] id: Option, #[schemars(description = "Max history entries to return (default 20)")] #[serde(default, deserialize_with = "deser::option_u32_from_string")] limit: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct RollbackInput { #[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 (from secrets_find). If provided, name/folder are ignored." )] id: Option, #[schemars(description = "Target version number. Omit to restore the most recent snapshot.")] #[serde(default, deserialize_with = "deser::option_i64_from_string")] to_version: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct ExportInput { #[schemars(description = "Folder filter")] folder: Option, #[schemars(description = "Type filter")] #[serde(rename = "type")] entry_type: Option, #[schemars(description = "Exact name filter")] name: Option, #[schemars(description = "Tag filters")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Fuzzy query")] query: Option, #[schemars(description = "Export format: 'json' (default), 'toml', 'yaml'")] format: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct EnvMapInput { #[schemars(description = "Folder filter")] folder: Option, #[schemars(description = "Type filter")] #[serde(rename = "type")] entry_type: Option, #[schemars(description = "Exact name filter")] name: Option, #[schemars(description = "Tag filters")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Only include these secret fields")] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] only_fields: Option>, #[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(), name_query = input.name_query.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(), name_query: input.name_query.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 = result .secret_schemas .get(&e.id) .map(|f| { f.iter() .map(|s| { serde_json::json!({ "id": s.id, "name": s.name, "type": s.secret_type, }) }) .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). \ Prefer secrets_find for discovery; secrets_search is kept for backward compatibility.", annotations( title = "Search Secrets", read_only_hint = true, idempotent_hint = true ) )] async fn secrets_search( &self, Parameters(input): Parameters, ctx: RequestContext, ) -> Result { let t = Instant::now(); let user_id = Self::require_user_id(&ctx)?; tracing::info!( tool = "secrets_search", ?user_id, folder = input.folder.as_deref(), entry_type = input.entry_type.as_deref(), name = input.name.as_deref(), name_query = input.name_query.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(), name_query: input.name_query.as_deref(), tags: &tags, query: input.query.as_deref(), sort: input.sort.as_deref().unwrap_or("name"), limit: input.limit.unwrap_or(20), offset: input.offset.unwrap_or(0), user_id: Some(user_id), }, ) .await .map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?; let summary = input.summary.unwrap_or(false); let entries: Vec = result .entries .iter() .map(|e| { if summary { serde_json::json!({ "name": e.name, "folder": e.folder, "type": e.entry_type, "tags": e.tags, "notes": e.notes, "updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), }) } else { let schema: Vec = result .secret_schemas .get(&e.id) .map(|f| { f.iter() .map(|s| { serde_json::json!({ "id": s.id, "name": s.name, "type": s.secret_type, }) }) .collect() }) .unwrap_or_default(); serde_json::json!({ "id": e.id, "name": e.name, "folder": e.folder, "type": e.entry_type, "notes": e.notes, "tags": e.tags, "metadata": e.metadata, "secret_fields": schema, "version": e.version, "updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), }) } }) .collect(); let count = entries.len(); tracing::info!( tool = "secrets_search", ?user_id, result_count = count, 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 = "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", read_only_hint = true, idempotent_hint = true ) )] async fn secrets_get( &self, Parameters(input): Parameters, ctx: RequestContext, ) -> 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", id = %input.id, field = input.field.as_deref(), "tool call start", ); if let Some(field_name) = &input.field { let value = get_secret_field_by_id(&self.pool, entry_id, field_name, &user_key, Some(user_id)) .await .map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?; tracing::info!( tool = "secrets_get", id = %input.id, elapsed_ms = t.elapsed().as_millis(), "tool call ok", ); let result = serde_json::json!({ field_name: value }); let json = serde_json::to_string_pretty(&result).unwrap_or_default(); Ok(CallToolResult::success(vec![Content::text(json)])) } else { let secrets = get_all_secrets_by_id(&self.pool, entry_id, &user_key, Some(user_id)) .await .map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?; tracing::info!( tool = "secrets_get", id = %entry_id, field_count = secrets.len(), elapsed_ms = t.elapsed().as_millis(), "tool call ok", ); let json = serde_json::to_string_pretty(&secrets).unwrap_or_default(); Ok(CallToolResult::success(vec![Content::text(json)])) } } #[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, \ or pass a JSON object via meta_obj / secrets_obj.", annotations(title = "Add Secret Entry") )] async fn secrets_add( &self, Parameters(input): Parameters, ctx: RequestContext, ) -> Result { let t = Instant::now(); let (user_id, user_key) = Self::require_user_and_key(&ctx)?; tracing::info!( tool = "secrets_add", ?user_id, 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 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 secret_types = input.secret_types.unwrap_or_default(); let secret_types_map: std::collections::HashMap = secret_types .into_iter() .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string()))) .collect(); let link_secret_names = input.link_secret_names.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 { name: &input.name, folder, entry_type, notes, tags: &tags, meta_entries: &meta, secret_entries: &secrets, secret_types: &secret_types_map, link_secret_names: &link_secret_names, user_id: Some(user_id), }, &user_key, ) .await .map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?; tracing::info!( tool = "secrets_add", ?user_id, name = %input.name, 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)])) } #[tool( description = "Incrementally update an existing entry. Requires X-Encryption-Key header. \ 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( &self, Parameters(input): Parameters, ctx: RequestContext, ) -> Result { let t = Instant::now(); let (user_id, user_key) = Self::require_user_and_key(&ctx)?; tracing::info!( 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 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 mut secrets = input.secrets.unwrap_or_default(); if let Some(obj) = input.secrets_obj { secrets.extend(map_to_kv_strings(obj)); } let secret_types = input.secret_types.unwrap_or_default(); let secret_types_map: std::collections::HashMap = secret_types .into_iter() .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, UpdateParams { name: &resolved_name, folder: resolved_folder.as_deref(), notes: input.notes.as_deref(), add_tags: &add_tags, remove_tags: &remove_tags, meta_entries: &meta, remove_meta: &remove_meta, 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, ) .await .map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?; tracing::info!( tool = "secrets_update", ?user_id, name = %resolved_name, 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)])) } #[tool( 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( &self, Parameters(input): Parameters, ctx: RequestContext, ) -> 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(), dry_run = input.dry_run.unwrap_or(false), "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: 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, }, ) .await .map_err(|e| mcp_err_internal_logged("secrets_delete", user_id, e))?; tracing::info!( tool = "secrets_delete", ?user_id, 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)])) } #[tool( description = "View change history for an entry. Returns a list of versions with \ actions and timestamps. Optionally pass 'id' (from secrets_find) to target directly.", annotations( title = "View Secret History", read_only_hint = true, idempotent_hint = true ) )] async fn secrets_history( &self, Parameters(input): Parameters, ctx: RequestContext, ) -> Result { let t = Instant::now(); let user_id = Self::user_id_from_ctx(&ctx)?; tracing::info!( 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, &resolved_name, resolved_folder.as_deref(), input.limit.unwrap_or(20), user_id, ) .await .map_err(|e| mcp_err_internal_logged("secrets_history", user_id, e))?; tracing::info!( tool = "secrets_history", ?user_id, 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)])) } #[tool( description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \ 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( &self, Parameters(input): Parameters, ctx: RequestContext, ) -> Result { let t = Instant::now(); let (user_id, user_key) = Self::require_user_and_key(&ctx)?; tracing::info!( 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, &resolved_name, resolved_folder.as_deref(), input.to_version, &user_key, Some(user_id), ) .await .map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?; tracing::info!( tool = "secrets_rollback", ?user_id, 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)])) } #[tool( description = "Export matching entries with decrypted secrets as JSON/TOML/YAML string. \ 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, Parameters(input): Parameters, ctx: RequestContext, ) -> Result { let t = Instant::now(); let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let tags = input.tags.unwrap_or_default(); let format = input.format.as_deref().unwrap_or("json"); tracing::info!( tool = "secrets_export", ?user_id, folder = input.folder.as_deref(), entry_type = input.entry_type.as_deref(), format, "tool call start", ); let data = svc_export( &self.pool, ExportParams { folder: input.folder.as_deref(), entry_type: input.entry_type.as_deref(), name: input.name.as_deref(), tags: &tags, query: input.query.as_deref(), no_secrets: false, user_id: Some(user_id), }, Some(&user_key), ) .await .map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?; let fmt = format.parse::().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_from_anyhow("secrets_export", Some(user_id), e))?; tracing::info!( tool = "secrets_export", ?user_id, entry_count = data.entries.len(), elapsed_ms = t.elapsed().as_millis(), "tool call ok", ); Ok(CallToolResult::success(vec![Content::text(serialized)])) } #[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. \ 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( &self, Parameters(input): Parameters, ctx: RequestContext, ) -> Result { let t = Instant::now(); let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let tags = input.tags.unwrap_or_default(); let only_fields = input.only_fields.unwrap_or_default(); tracing::info!( tool = "secrets_env_map", ?user_id, 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.folder.as_deref(), input.entry_type.as_deref(), input.name.as_deref(), &tags, &only_fields, input.prefix.as_deref().unwrap_or(""), &user_key, Some(user_id), ) .await .map_err(|e| mcp_err_from_anyhow("secrets_env_map", Some(user_id), e))?; let entry_count = env_map.len(); tracing::info!( tool = "secrets_env_map", ?user_id, entry_count, elapsed_ms = t.elapsed().as_millis(), "tool call ok", ); 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 ───────────────────────────────────────────────────────────── #[tool_handler] 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")) .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. \ 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 } } #[cfg(test)] mod deser_tests { use super::deser; use serde::Deserialize; use serde_json::json; #[derive(Deserialize)] struct TestU32 { #[serde(deserialize_with = "deser::option_u32_from_string")] val: Option, } #[derive(Deserialize)] struct TestI64 { #[serde(deserialize_with = "deser::option_i64_from_string")] val: Option, } #[derive(Deserialize)] struct TestBool { #[serde(deserialize_with = "deser::option_bool_from_string")] val: Option, } #[derive(Debug, Deserialize)] struct TestVec { #[serde(deserialize_with = "deser::option_vec_string_from_string")] val: Option>, } #[derive(Debug, Deserialize)] struct TestMap { #[serde(deserialize_with = "deser::option_map_from_string")] val: Option>, } // option_u32_from_string #[test] fn u32_native_number() { let v: TestU32 = serde_json::from_value(json!({"val": 42})).unwrap(); assert_eq!(v.val, Some(42)); } #[test] fn u32_string_number() { let v: TestU32 = serde_json::from_value(json!({"val": "42"})).unwrap(); assert_eq!(v.val, Some(42)); } #[test] fn u32_empty_string() { let v: TestU32 = serde_json::from_value(json!({"val": ""})).unwrap(); assert_eq!(v.val, None); } #[test] fn u32_none() { let v: TestU32 = serde_json::from_value(json!({"val": null})).unwrap(); assert_eq!(v.val, None); } // option_i64_from_string #[test] fn i64_native_number() { let v: TestI64 = serde_json::from_value(json!({"val": -100})).unwrap(); assert_eq!(v.val, Some(-100)); } #[test] fn i64_string_number() { let v: TestI64 = serde_json::from_value(json!({"val": "999"})).unwrap(); assert_eq!(v.val, Some(999)); } #[test] fn i64_empty_string() { let v: TestI64 = serde_json::from_value(json!({"val": ""})).unwrap(); assert_eq!(v.val, None); } #[test] fn i64_none() { let v: TestI64 = serde_json::from_value(json!({"val": null})).unwrap(); assert_eq!(v.val, None); } // option_bool_from_string #[test] fn bool_native_true() { let v: TestBool = serde_json::from_value(json!({"val": true})).unwrap(); assert_eq!(v.val, Some(true)); } #[test] fn bool_native_false() { let v: TestBool = serde_json::from_value(json!({"val": false})).unwrap(); assert_eq!(v.val, Some(false)); } #[test] fn bool_string_true() { let v: TestBool = serde_json::from_value(json!({"val": "true"})).unwrap(); assert_eq!(v.val, Some(true)); } #[test] fn bool_string_false() { let v: TestBool = serde_json::from_value(json!({"val": "false"})).unwrap(); assert_eq!(v.val, Some(false)); } #[test] fn bool_empty_string() { let v: TestBool = serde_json::from_value(json!({"val": ""})).unwrap(); assert_eq!(v.val, None); } #[test] fn bool_none() { let v: TestBool = serde_json::from_value(json!({"val": null})).unwrap(); assert_eq!(v.val, None); } // option_vec_string_from_string #[test] fn vec_native_array() { let v: TestVec = serde_json::from_value(json!({"val": ["a", "b"]})).unwrap(); assert_eq!(v.val, Some(vec!["a".to_string(), "b".to_string()])); } #[test] fn vec_json_string_array() { let v: TestVec = serde_json::from_value(json!({"val": "[\"x\",\"y\"]"})).unwrap(); assert_eq!(v.val, Some(vec!["x".to_string(), "y".to_string()])); } #[test] fn vec_empty_string() { let v: TestVec = serde_json::from_value(json!({"val": ""})).unwrap(); assert_eq!(v.val, None); } #[test] fn vec_none() { let v: TestVec = serde_json::from_value(json!({"val": null})).unwrap(); assert_eq!(v.val, None); } #[test] fn vec_invalid_string_errors() { let err = serde_json::from_value::(json!({"val": "not-json"})) .expect_err("should fail on invalid JSON"); let msg = err.to_string(); assert!(msg.contains("invalid string value for array field")); assert!(msg.contains("expected a JSON array")); } // option_map_from_string #[test] fn map_native_object() { let v: TestMap = serde_json::from_value(json!({"val": {"key": "value"}})).unwrap(); assert!(v.val.is_some()); let m = v.val.unwrap(); assert_eq!( m.get("key"), Some(&serde_json::Value::String("value".to_string())) ); } #[test] fn map_json_string_object() { let v: TestMap = serde_json::from_value(json!({"val": "{\"a\":1}"})).unwrap(); assert!(v.val.is_some()); let m = v.val.unwrap(); assert_eq!(m.get("a"), Some(&serde_json::Value::Number(1.into()))); } #[test] fn map_empty_string() { let v: TestMap = serde_json::from_value(json!({"val": ""})).unwrap(); assert_eq!(v.val, None); } #[test] fn map_none() { let v: TestMap = serde_json::from_value(json!({"val": null})).unwrap(); assert_eq!(v.val, None); } #[test] fn map_invalid_string_errors() { let err = serde_json::from_value::(json!({"val": "not-json"})) .expect_err("should fail on invalid JSON"); let msg = err.to_string(); assert!(msg.contains("invalid string value for object field")); assert!(msg.contains("expected a JSON object")); } }