- 去掉 taxonomy 对 entry type 的自动映射与 metadata.subtype 回填;仅 trim 后入库 - MCP tools:Vec/Map/bool 等可选字段支持 JSON 内嵌字符串解析,并改进解析失败提示 - 新增 deser 单元测试;README/AGENTS 与 models 注释同步 Made-with: Cursor
1601 lines
59 KiB
Rust
1601 lines
59 KiB
Rust
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<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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<Option<bool>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum BoolOrStr {
|
|
Bool(bool),
|
|
Str(String),
|
|
}
|
|
|
|
match Option::<BoolOrStr>::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::<bool>().map(Some).map_err(de::Error::custom)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Deserialize a Vec<String> 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<Option<Vec<String>>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum VecOrStr {
|
|
Vec(Vec<String>),
|
|
Str(String),
|
|
}
|
|
|
|
match Option::<VecOrStr>::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<String, Value> 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<Option<Map<String, Value>>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum MapOrStr {
|
|
Map(Map<String, Value>),
|
|
Str(String),
|
|
}
|
|
|
|
match Option::<MapOrStr>::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<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_from_anyhow(
|
|
tool: &'static str,
|
|
user_id: Option<Uuid>,
|
|
err: anyhow::Error,
|
|
) -> rmcp::ErrorData {
|
|
if let Some(app_err) = err.downcast_ref::<secrets_core::error::AppError>() {
|
|
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<PgPool>,
|
|
pub tool_router: rmcp::handler::server::router::tool::ToolRouter<SecretsService>,
|
|
}
|
|
|
|
impl SecretsService {
|
|
pub fn new(pool: Arc<PgPool>) -> 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<RoleServer>) -> Result<Option<Uuid>, rmcp::ErrorData> {
|
|
let parts = ctx
|
|
.extensions
|
|
.get::<http::request::Parts>()
|
|
.ok_or_else(mcp_err_missing_http_parts)?;
|
|
Ok(parts.extensions.get::<AuthUser>().map(|a| a.user_id))
|
|
}
|
|
|
|
/// Get the authenticated user_id (returns error if not authenticated).
|
|
fn require_user_id(ctx: &RequestContext<RoleServer>) -> Result<Uuid, rmcp::ErrorData> {
|
|
let parts = ctx
|
|
.extensions
|
|
.get::<http::request::Parts>()
|
|
.ok_or_else(mcp_err_missing_http_parts)?;
|
|
parts
|
|
.extensions
|
|
.get::<AuthUser>()
|
|
.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<RoleServer>) -> Result<[u8; 32], rmcp::ErrorData> {
|
|
let parts = ctx
|
|
.extensions
|
|
.get::<http::request::Parts>()
|
|
.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<RoleServer>,
|
|
) -> 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<String>,
|
|
#[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")]
|
|
folder: Option<String>,
|
|
#[schemars(
|
|
description = "Exact type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted."
|
|
)]
|
|
#[serde(rename = "type")]
|
|
entry_type: Option<String>,
|
|
#[schemars(description = "Exact name filter. For fuzzy matching use name_query instead.")]
|
|
name: Option<String>,
|
|
#[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<String>,
|
|
#[schemars(description = "Tag filters (all must match)")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
tags: Option<Vec<String>>,
|
|
#[schemars(description = "Max results (default 20)")]
|
|
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
|
limit: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct SearchInput {
|
|
#[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', 'account', 'person', 'document'). User-defined, any value accepted."
|
|
)]
|
|
#[serde(rename = "type")]
|
|
entry_type: Option<String>,
|
|
#[schemars(description = "Exact name to match. For fuzzy matching use name_query instead.")]
|
|
name: Option<String>,
|
|
#[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<String>,
|
|
#[schemars(description = "Tag filters (all must match)")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
tags: Option<Vec<String>>,
|
|
#[schemars(description = "Return only summary fields (name/tags/notes/updated_at)")]
|
|
#[serde(default, deserialize_with = "deser::option_bool_from_string")]
|
|
summary: Option<bool>,
|
|
#[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>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[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<String>,
|
|
#[schemars(description = "Free-text notes for this entry (optional)")]
|
|
notes: Option<String>,
|
|
#[schemars(description = "Tags for this entry")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
tags: Option<Vec<String>>,
|
|
#[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
meta: Option<Vec<String>>,
|
|
#[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<Map<String, Value>>,
|
|
#[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<Vec<String>>,
|
|
#[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<Map<String, Value>>,
|
|
#[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<Map<String, Value>>,
|
|
#[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<Vec<String>>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[schemars(
|
|
description = "Entry UUID (from secrets_find). If provided, name/folder are used for disambiguation only."
|
|
)]
|
|
id: Option<String>,
|
|
#[schemars(description = "Update the notes field")]
|
|
notes: Option<String>,
|
|
#[schemars(description = "Tags to add")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
add_tags: Option<Vec<String>>,
|
|
#[schemars(description = "Tags to remove")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
remove_tags: Option<Vec<String>>,
|
|
#[schemars(description = "Metadata fields to update/add as 'key=value' strings")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
meta: Option<Vec<String>>,
|
|
#[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<Map<String, Value>>,
|
|
#[schemars(description = "Metadata field keys to remove")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
remove_meta: Option<Vec<String>>,
|
|
#[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<Vec<String>>,
|
|
#[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<Map<String, Value>>,
|
|
#[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<Map<String, Value>>,
|
|
#[schemars(description = "Secret field keys to remove")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
remove_secrets: Option<Vec<String>>,
|
|
#[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<Vec<String>>,
|
|
#[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<Vec<String>>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[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")]
|
|
#[serde(default, deserialize_with = "deser::option_bool_from_string")]
|
|
dry_run: Option<bool>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[schemars(
|
|
description = "Entry UUID (from secrets_find). If provided, name/folder are ignored."
|
|
)]
|
|
id: Option<String>,
|
|
#[schemars(description = "Max history entries to return (default 20)")]
|
|
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
|
limit: Option<u32>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[schemars(
|
|
description = "Entry UUID (from secrets_find). If provided, name/folder are ignored."
|
|
)]
|
|
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>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct ExportInput {
|
|
#[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")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
tags: Option<Vec<String>>,
|
|
#[schemars(description = "Fuzzy query")]
|
|
query: Option<String>,
|
|
#[schemars(description = "Export format: 'json' (default), 'toml', 'yaml'")]
|
|
format: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct EnvMapInput {
|
|
#[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")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
tags: Option<Vec<String>>,
|
|
#[schemars(description = "Only include these secret fields")]
|
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
|
only_fields: Option<Vec<String>>,
|
|
#[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<String>,
|
|
}
|
|
|
|
#[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<String, Value>) -> Vec<String> {
|
|
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<Uuid, rmcp::ErrorData> {
|
|
s.parse::<Uuid>()
|
|
.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<FindInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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<serde_json::Value> = result
|
|
.entries
|
|
.iter()
|
|
.map(|e| {
|
|
let schema: Vec<serde_json::Value> = 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<SearchInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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<serde_json::Value> = 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<serde_json::Value> = 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<GetSecretInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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:=<json>' format, \
|
|
or pass a JSON object via meta_obj / secrets_obj.",
|
|
annotations(title = "Add Secret Entry")
|
|
)]
|
|
async fn secrets_add(
|
|
&self,
|
|
Parameters(input): Parameters<AddInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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<String, String> = 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<UpdateInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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<String>) =
|
|
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<String, String> = 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<DeleteInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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<String>, Option<String>) =
|
|
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<HistoryInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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<String>) =
|
|
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<RollbackInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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<String>) =
|
|
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<ExportInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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::<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_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<EnvMapInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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<OverviewInput>,
|
|
ctx: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
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<CountRow> = 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<CountRow> = 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::<Vec<_>>(),
|
|
"types": type_rows.iter().map(|r| serde_json::json!({"name": r.name, "count": r.count})).collect::<Vec<_>>(),
|
|
});
|
|
|
|
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<u32>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TestI64 {
|
|
#[serde(deserialize_with = "deser::option_i64_from_string")]
|
|
val: Option<i64>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TestBool {
|
|
#[serde(deserialize_with = "deser::option_bool_from_string")]
|
|
val: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TestVec {
|
|
#[serde(deserialize_with = "deser::option_vec_string_from_string")]
|
|
val: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TestMap {
|
|
#[serde(deserialize_with = "deser::option_map_from_string")]
|
|
val: Option<serde_json::Map<String, serde_json::Value>>,
|
|
}
|
|
|
|
// 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::<TestVec>(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::<TestMap>(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"));
|
|
}
|
|
}
|