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
This commit is contained in:
@@ -70,6 +70,94 @@ mod deser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -229,7 +317,7 @@ struct FindInput {
|
||||
#[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")]
|
||||
folder: Option<String>,
|
||||
#[schemars(
|
||||
description = "Exact type filter (recommended: 'server', 'service', 'person', 'document')"
|
||||
description = "Exact type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted."
|
||||
)]
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
@@ -240,6 +328,7 @@ struct FindInput {
|
||||
)]
|
||||
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")]
|
||||
@@ -253,7 +342,7 @@ struct SearchInput {
|
||||
#[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")]
|
||||
folder: Option<String>,
|
||||
#[schemars(
|
||||
description = "Type filter (recommended: 'server', 'service', 'person', 'document')"
|
||||
description = "Type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted."
|
||||
)]
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
@@ -264,8 +353,10 @@ struct SearchInput {
|
||||
)]
|
||||
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>,
|
||||
@@ -292,35 +383,42 @@ struct AddInput {
|
||||
#[schemars(description = "Folder for organization (optional, e.g. 'personal', 'refining')")]
|
||||
folder: Option<String>,
|
||||
#[schemars(
|
||||
description = "Type/category of this entry (optional, recommended: 'server', 'service', 'person', 'document')"
|
||||
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>>,
|
||||
}
|
||||
|
||||
@@ -339,38 +437,49 @@ struct UpdateInput {
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
@@ -390,6 +499,7 @@ struct DeleteInput {
|
||||
#[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>,
|
||||
}
|
||||
|
||||
@@ -437,6 +547,7 @@ struct ExportInput {
|
||||
#[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>,
|
||||
@@ -454,8 +565,10 @@ struct EnvMapInput {
|
||||
#[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), \
|
||||
@@ -1286,3 +1399,202 @@ impl ServerHandler for SecretsService {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user