Files
secrets/crates/secrets-mcp/src/tools.rs
voson aefad33870
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m22s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
chore(secrets-mcp): 0.5.1 — 移除 entry type 归一化,MCP 参数兼容字符串形式
- 去掉 taxonomy 对 entry type 的自动映射与 metadata.subtype 回填;仅 trim 后入库
- MCP tools:Vec/Map/bool 等可选字段支持 JSON 内嵌字符串解析,并改进解析失败提示
- 新增 deser 单元测试;README/AGENTS 与 models 注释同步

Made-with: Cursor
2026-04-04 21:27:33 +08:00

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"));
}
}