@@ -10,33 +10,67 @@ use crate::models::{
1010
1111// ─── Deserialization structs ────────────────────────────────────
1212
13+ /// #330: many MCP clients send explicit JSON `null` for an optional field
14+ /// they didn't set (rather than omitting the key), because the tool schema
15+ /// lists the field as optional/defaulted. serde's `#[serde(default = "...")]`
16+ /// only fires when the key is *absent*; a present `null` still hits the
17+ /// field's real type and fails with a misleading "invalid type: null,
18+ /// expected a string/boolean/f64/..." error that names the wrong field
19+ /// entirely once combined with `#[serde(deny_unknown_fields)]`-style
20+ /// confusion. This helper treats an explicit `null` the same as an absent
21+ /// key by falling through to `Default::default()` for the field type; pair
22+ /// it with `#[serde(default = "...", deserialize_with = "null_as_default")]`
23+ /// when the field also needs a non-Default::default() default value.
24+ fn null_as_default < ' de , D , T > ( deserializer : D ) -> Result < T , D :: Error >
25+ where
26+ D : serde:: Deserializer < ' de > ,
27+ T : Deserialize < ' de > + Default ,
28+ {
29+ Ok ( Option :: < T > :: deserialize ( deserializer) ?. unwrap_or_default ( ) )
30+ }
31+
1332#[ derive( Debug , Deserialize ) ]
1433pub struct RememberArgs {
1534 pub category : String ,
1635 pub key : String ,
1736 pub body_json : String ,
18- #[ serde( default = "default_status" ) ]
37+ #[ serde(
38+ default = "default_status" ,
39+ deserialize_with = "null_as_default_status"
40+ ) ]
1941 pub status : String ,
20- #[ serde( default = "default_entity_type" ) ]
21- #[ serde( rename = "type" ) ]
42+ #[ serde(
43+ default = "default_entity_type" ,
44+ rename = "type" ,
45+ deserialize_with = "null_as_default_entity_type"
46+ ) ]
2247 pub entity_type : String ,
23- #[ serde( default ) ]
48+ #[ serde( default , deserialize_with = "null_as_default" ) ]
2449 pub tags : Vec < String > ,
25- #[ serde( default = "default_importance" ) ]
50+ #[ serde(
51+ default = "default_importance" ,
52+ deserialize_with = "null_as_default_importance"
53+ ) ]
2654 pub importance : f64 ,
27- #[ serde( default ) ]
55+ #[ serde( default , deserialize_with = "null_as_default" ) ]
2856 pub topic_path : String ,
29- #[ serde( default ) ]
57+ #[ serde( default , deserialize_with = "null_as_default" ) ]
3058 pub recall_when : Vec < String > ,
31- #[ serde( default ) ]
59+ #[ serde( default , deserialize_with = "null_as_default" ) ]
3260 pub always_on : bool ,
33- #[ serde( default = "default_certainty" ) ]
61+ #[ serde(
62+ default = "default_certainty" ,
63+ deserialize_with = "null_as_default_certainty"
64+ ) ]
3465 pub certainty : f64 ,
35- #[ serde( default ) ]
66+ #[ serde( default , deserialize_with = "null_as_default" ) ]
3667 pub workspace_hash : String ,
37- #[ serde( default ) ]
68+ #[ serde( default , deserialize_with = "null_as_default" ) ]
3869 pub agent_id : String ,
39- #[ serde( default = "default_visibility" ) ]
70+ #[ serde(
71+ default = "default_visibility" ,
72+ deserialize_with = "null_as_default_visibility"
73+ ) ]
4074 pub visibility : String ,
4175 #[ serde( default ) ]
4276 pub layer : Option < String > ,
@@ -62,6 +96,26 @@ fn default_importance() -> f64 {
6296 0.5
6397}
6498
99+ /// #330: same null-tolerance as `null_as_default`, but falls through to a
100+ /// named default function instead of `T::default()` for fields whose
101+ /// "unset" value isn't the type's zero value (e.g. status="active", not "").
102+ macro_rules! null_as_named_default {
103+ ( $fn_name: ident, $ty: ty, $default_fn: ident) => {
104+ fn $fn_name<' de, D >( deserializer: D ) -> Result <$ty, D :: Error >
105+ where
106+ D : serde:: Deserializer <' de>,
107+ {
108+ Ok ( Option :: <$ty>:: deserialize( deserializer) ?. unwrap_or_else( $default_fn) )
109+ }
110+ } ;
111+ }
112+
113+ null_as_named_default ! ( null_as_default_status, String , default_status) ;
114+ null_as_named_default ! ( null_as_default_entity_type, String , default_entity_type) ;
115+ null_as_named_default ! ( null_as_default_importance, f64 , default_importance) ;
116+ null_as_named_default ! ( null_as_default_certainty, f64 , default_certainty) ;
117+ null_as_named_default ! ( null_as_default_visibility, String , default_visibility) ;
118+
65119#[ derive( Debug , Deserialize ) ]
66120pub struct RecallArgs {
67121 pub query : String ,
@@ -70,29 +124,38 @@ pub struct RecallArgs {
70124 #[ serde( rename = "type" ) ]
71125 #[ serde( default ) ]
72126 pub entity_type : Option < String > ,
73- #[ serde( default = "default_limit" ) ]
127+ #[ serde(
128+ default = "default_limit" ,
129+ deserialize_with = "null_as_default_limit"
130+ ) ]
74131 pub limit : i64 ,
75- #[ serde( default ) ]
132+ #[ serde( default , deserialize_with = "null_as_default" ) ]
76133 pub offset : i64 ,
77- #[ serde( default ) ]
134+ #[ serde( default , deserialize_with = "null_as_default" ) ]
78135 pub min_decay : f64 ,
79136 #[ serde( default ) ]
80137 pub topic_path : Option < String > ,
81- #[ serde( default ) ]
138+ #[ serde( default , deserialize_with = "null_as_default" ) ]
82139 pub include_archived : bool ,
83- #[ serde( default ) ]
140+ #[ serde( default , deserialize_with = "null_as_default" ) ]
84141 pub expansion : crate :: models:: QueryExpansionConfig ,
85- #[ serde( default ) ]
142+ #[ serde( default , deserialize_with = "null_as_default" ) ]
86143 pub mode : String , // "fts5", "dense", or "hybrid"
87144 #[ serde( default ) ]
88145 pub preview_cap : Option < i64 > ,
89146 #[ serde( default ) ]
90147 pub always_on : Option < bool > ,
91- #[ serde( default ) ]
148+ #[ serde( default , deserialize_with = "null_as_default" ) ]
92149 pub content_weight : f64 ,
93- #[ serde( default = "crate::models::default_trust_weight" ) ]
150+ #[ serde(
151+ default = "crate::models::default_trust_weight" ,
152+ deserialize_with = "null_as_default_trust_weight"
153+ ) ]
94154 pub trust_weight : f64 ,
95- #[ serde( default = "default_halving" ) ]
155+ #[ serde(
156+ default = "default_halving" ,
157+ deserialize_with = "null_as_default_halving"
158+ ) ]
96159 pub diversity_halving : f64 ,
97160 /// Recency half-life in seconds for time-aware hybrid ranking (#235).
98161 /// Omit (default) for relevance-only ranking; set to bias toward recent memories.
@@ -107,7 +170,7 @@ pub struct RecallArgs {
107170 /// #287: opt-in. When true, each result gets a normalized `confidence`
108171 /// (0.0–1.0) rolled up from rank, trust, and decay. Default false so
109172 /// existing callers and snapshot tests are unaffected; ranking is unchanged.
110- #[ serde( default ) ]
173+ #[ serde( default , deserialize_with = "null_as_default" ) ]
111174 pub include_confidence : bool ,
112175}
113176
@@ -160,6 +223,18 @@ fn default_limit() -> i64 {
160223 10
161224}
162225
226+ null_as_named_default ! ( null_as_default_limit, i64 , default_limit) ;
227+ null_as_named_default ! (
228+ null_as_default_trust_weight,
229+ f64 ,
230+ default_trust_weight_wrapper
231+ ) ;
232+ null_as_named_default ! ( null_as_default_halving, f64 , default_halving) ;
233+
234+ fn default_trust_weight_wrapper ( ) -> f64 {
235+ crate :: models:: default_trust_weight ( )
236+ }
237+
163238#[ derive( Debug , Deserialize ) ]
164239pub struct ForgetArgs {
165240 pub category : String ,
@@ -1917,3 +1992,114 @@ pub fn handle_purge(db: &Database, args: Value) -> Result<String, String> {
19171992 serde_json:: to_string ( & report) . map_err ( |e| format ! ( "Serialization failed: {}" , e) )
19181993}
19191994
1995+ #[ cfg( test) ]
1996+ mod tests {
1997+ use super :: * ;
1998+
1999+ // #330: mimir_remember rejected the documented optional `topic_path`
2000+ // field (and other optional fields with custom defaults) whenever a
2001+ // caller sent explicit JSON `null` instead of omitting the key. Many
2002+ // MCP clients do this because the tool schema lists the field as
2003+ // optional/defaulted, not because they're being unusual.
2004+
2005+ #[ test]
2006+ fn remember_args_accepts_null_topic_path ( ) {
2007+ let v = json ! ( {
2008+ "category" : "reference" ,
2009+ "key" : "example-key" ,
2010+ "body_json" : "{}" ,
2011+ "topic_path" : null
2012+ } ) ;
2013+ let a: RememberArgs = serde_json:: from_value ( v) . expect ( "null topic_path must deserialize" ) ;
2014+ assert_eq ! ( a. topic_path, "" ) ;
2015+ }
2016+
2017+ #[ test]
2018+ fn remember_args_accepts_null_for_every_optional_field_with_custom_default ( ) {
2019+ // Explicit null on each of these must fall back to that field's
2020+ // documented default, not fail deserialization.
2021+ for field in [
2022+ "status" ,
2023+ "type" ,
2024+ "tags" ,
2025+ "importance" ,
2026+ "topic_path" ,
2027+ "recall_when" ,
2028+ "always_on" ,
2029+ "certainty" ,
2030+ "workspace_hash" ,
2031+ "agent_id" ,
2032+ "visibility" ,
2033+ ] {
2034+ let mut v = json ! ( {
2035+ "category" : "reference" ,
2036+ "key" : "example-key" ,
2037+ "body_json" : "{}" ,
2038+ } ) ;
2039+ v. as_object_mut ( )
2040+ . unwrap ( )
2041+ . insert ( field. to_string ( ) , Value :: Null ) ;
2042+ let result: Result < RememberArgs , _ > = serde_json:: from_value ( v) ;
2043+ assert ! (
2044+ result. is_ok( ) ,
2045+ "field `{}` with explicit null should deserialize, got {:?}" ,
2046+ field,
2047+ result. err( )
2048+ ) ;
2049+ }
2050+ }
2051+
2052+ #[ test]
2053+ fn remember_args_still_reports_missing_category_correctly ( ) {
2054+ // Regression guard: fixing the null-tolerance bug must not break the
2055+ // genuinely-missing-required-field error path (the original bug
2056+ // report's error message pointed at the wrong field — `category` —
2057+ // when the real offender was `topic_path: null`; once null is
2058+ // handled, a real missing `category` must still be reported as such).
2059+ let v = json ! ( { "key" : "example-key" , "body_json" : "{}" } ) ;
2060+ let result: Result < RememberArgs , _ > = serde_json:: from_value ( v) ;
2061+ let err = result. expect_err ( "missing category must fail" ) . to_string ( ) ;
2062+ assert ! (
2063+ err. contains( "category" ) ,
2064+ "error should name the actually-missing field `category`, got: {}" ,
2065+ err
2066+ ) ;
2067+ }
2068+
2069+ #[ test]
2070+ fn recall_args_accepts_null_for_every_optional_field_with_custom_default ( ) {
2071+ for field in [
2072+ "limit" ,
2073+ "offset" ,
2074+ "min_decay" ,
2075+ "include_archived" ,
2076+ "expansion" ,
2077+ "mode" ,
2078+ "content_weight" ,
2079+ "trust_weight" ,
2080+ "diversity_halving" ,
2081+ "include_confidence" ,
2082+ ] {
2083+ let mut v = json ! ( { "query" : "test" } ) ;
2084+ v. as_object_mut ( )
2085+ . unwrap ( )
2086+ . insert ( field. to_string ( ) , Value :: Null ) ;
2087+ let result: Result < RecallArgs , _ > = serde_json:: from_value ( v) ;
2088+ assert ! (
2089+ result. is_ok( ) ,
2090+ "field `{}` with explicit null should deserialize, got {:?}" ,
2091+ field,
2092+ result. err( )
2093+ ) ;
2094+ }
2095+ }
2096+
2097+ #[ test]
2098+ fn recall_args_null_limit_falls_back_to_default_ten ( ) {
2099+ let v = json ! ( { "query" : "test" , "limit" : null } ) ;
2100+ let a: RecallArgs = serde_json:: from_value ( v) . unwrap ( ) ;
2101+ assert_eq ! ( a. limit, 10 ) ;
2102+ }
2103+ }
2104+
2105+
0 commit comments