Skip to content

Commit 5a6ff1f

Browse files
authored
fix: duplicate meta serialization (#662)
1 parent 61ffba8 commit 5a6ff1f

1 file changed

Lines changed: 270 additions & 3 deletions

File tree

crates/rmcp/src/model/serde_impl.rs

Lines changed: 270 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,61 @@ use super::{
66
CustomNotification, CustomRequest, Extensions, Meta, Notification, NotificationNoParam,
77
Request, RequestNoParam, RequestOptionalParam,
88
};
9-
#[derive(Serialize, Deserialize)]
9+
#[derive(Deserialize)]
1010
struct WithMeta<'a, P> {
11-
#[serde(skip_serializing_if = "Option::is_none")]
1211
_meta: Option<Cow<'a, Meta>>,
1312
#[serde(flatten)]
1413
_rest: P,
1514
}
1615

16+
impl<P: Serialize> Serialize for WithMeta<'_, P> {
17+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
18+
where
19+
S: serde::Serializer,
20+
{
21+
use serde::ser::SerializeMap;
22+
23+
// Serialize _rest to a Value so we can inspect and strip any duplicate _meta
24+
let mut rest_value =
25+
serde_json::to_value(&self._rest).map_err(serde::ser::Error::custom)?;
26+
27+
// Extract _meta from the serialized params (if it's an object containing one)
28+
let params_meta: Option<Meta> = rest_value
29+
.as_object_mut()
30+
.and_then(|obj| obj.remove("_meta"))
31+
.and_then(|v| serde_json::from_value(v).ok());
32+
33+
// Merge: params-level _meta as base, extensions-level _meta overwrites on conflict
34+
let merged_meta = match (self._meta.as_deref(), params_meta) {
35+
(Some(ext_meta), Some(mut params_meta)) => {
36+
params_meta.extend(ext_meta.clone());
37+
Some(params_meta)
38+
}
39+
(Some(ext_meta), None) => Some(ext_meta.clone()),
40+
(None, Some(params_meta)) => Some(params_meta),
41+
(None, None) => None,
42+
};
43+
44+
// Serialize as a flat map: single _meta + remaining params fields
45+
let rest_obj = match rest_value {
46+
serde_json::Value::Object(map) => map,
47+
_ => serde_json::Map::new(),
48+
};
49+
let meta_count = usize::from(merged_meta.is_some());
50+
let mut map = serializer.serialize_map(Some(rest_obj.len() + meta_count))?;
51+
52+
if let Some(meta) = &merged_meta {
53+
map.serialize_entry("_meta", meta)?;
54+
}
55+
56+
for (k, v) in &rest_obj {
57+
map.serialize_entry(k, v)?;
58+
}
59+
60+
map.end()
61+
}
62+
}
63+
1764
#[derive(Serialize, Deserialize)]
1865
struct Proxy<'a, M, P> {
1966
method: M,
@@ -359,7 +406,9 @@ impl<'de> Deserialize<'de> for CustomNotification {
359406
mod test {
360407
use serde_json::json;
361408

362-
use crate::model::ListToolsRequest;
409+
use crate::model::{
410+
CallToolRequest, CallToolRequestParams, CustomRequest, Extensions, ListToolsRequest, Meta,
411+
};
363412

364413
#[test]
365414
fn test_deserialize_lost_tools_request() {
@@ -370,4 +419,222 @@ mod test {
370419
))
371420
.unwrap();
372421
}
422+
423+
#[test]
424+
fn test_no_duplicate_meta_both_sources() {
425+
// When both extensions and params contain _meta, the output should have
426+
// a single merged _meta key (not two separate ones).
427+
let mut extensions = Extensions::new();
428+
let mut ext_meta = Meta::new();
429+
ext_meta.0.insert("traceId".to_string(), json!("abc"));
430+
extensions.insert(ext_meta);
431+
432+
let mut params_meta = Meta::new();
433+
params_meta.0.insert("progressToken".to_string(), json!(1));
434+
435+
let req = CallToolRequest {
436+
extensions,
437+
method: Default::default(),
438+
params: CallToolRequestParams {
439+
meta: Some(params_meta),
440+
name: "my_tool".into(),
441+
arguments: None,
442+
task: None,
443+
},
444+
};
445+
446+
let value = serde_json::to_value(&req).unwrap();
447+
let params = value.get("params").unwrap();
448+
449+
// There should be exactly one _meta key (JSON objects naturally deduplicate)
450+
let meta = params.get("_meta").unwrap();
451+
452+
// Both entries should be present in the merged _meta
453+
assert_eq!(meta.get("traceId").unwrap(), "abc");
454+
assert_eq!(meta.get("progressToken").unwrap(), 1);
455+
456+
// Verify the raw JSON string has exactly one occurrence of "_meta"
457+
let raw = serde_json::to_string(&req).unwrap();
458+
assert_eq!(
459+
raw.matches("\"_meta\"").count(),
460+
1,
461+
"Expected exactly one _meta key in serialized output, got: {}",
462+
raw
463+
);
464+
}
465+
466+
#[test]
467+
fn test_meta_only_from_extensions() {
468+
let mut extensions = Extensions::new();
469+
let mut ext_meta = Meta::new();
470+
ext_meta.0.insert("traceId".to_string(), json!("ext-only"));
471+
extensions.insert(ext_meta);
472+
473+
let req = CallToolRequest {
474+
extensions,
475+
method: Default::default(),
476+
params: CallToolRequestParams {
477+
meta: None,
478+
name: "my_tool".into(),
479+
arguments: None,
480+
task: None,
481+
},
482+
};
483+
484+
let value = serde_json::to_value(&req).unwrap();
485+
let meta = value["params"]["_meta"].as_object().unwrap();
486+
assert_eq!(meta.get("traceId").unwrap(), "ext-only");
487+
}
488+
489+
#[test]
490+
fn test_meta_only_from_params() {
491+
let mut params_meta = Meta::new();
492+
params_meta.0.insert("progressToken".to_string(), json!(42));
493+
494+
let req = CallToolRequest {
495+
extensions: Extensions::new(),
496+
method: Default::default(),
497+
params: CallToolRequestParams {
498+
meta: Some(params_meta),
499+
name: "my_tool".into(),
500+
arguments: None,
501+
task: None,
502+
},
503+
};
504+
505+
let value = serde_json::to_value(&req).unwrap();
506+
let meta = value["params"]["_meta"].as_object().unwrap();
507+
assert_eq!(meta.get("progressToken").unwrap(), 42);
508+
}
509+
510+
#[test]
511+
fn test_no_meta_emitted_when_neither_source() {
512+
let req = CallToolRequest {
513+
extensions: Extensions::new(),
514+
method: Default::default(),
515+
params: CallToolRequestParams {
516+
meta: None,
517+
name: "my_tool".into(),
518+
arguments: None,
519+
task: None,
520+
},
521+
};
522+
523+
let value = serde_json::to_value(&req).unwrap();
524+
assert!(
525+
value["params"].get("_meta").is_none(),
526+
"Expected no _meta when neither source is populated"
527+
);
528+
}
529+
530+
#[test]
531+
fn test_extensions_meta_takes_priority_on_conflict() {
532+
// When both sources have the same key, extensions should win.
533+
let mut extensions = Extensions::new();
534+
let mut ext_meta = Meta::new();
535+
ext_meta
536+
.0
537+
.insert("shared_key".to_string(), json!("from_extensions"));
538+
extensions.insert(ext_meta);
539+
540+
let mut params_meta = Meta::new();
541+
params_meta
542+
.0
543+
.insert("shared_key".to_string(), json!("from_params"));
544+
params_meta
545+
.0
546+
.insert("params_only".to_string(), json!("kept"));
547+
548+
let req = CallToolRequest {
549+
extensions,
550+
method: Default::default(),
551+
params: CallToolRequestParams {
552+
meta: Some(params_meta),
553+
name: "my_tool".into(),
554+
arguments: None,
555+
task: None,
556+
},
557+
};
558+
559+
let value = serde_json::to_value(&req).unwrap();
560+
let meta = value["params"]["_meta"].as_object().unwrap();
561+
assert_eq!(meta.get("shared_key").unwrap(), "from_extensions");
562+
assert_eq!(meta.get("params_only").unwrap(), "kept");
563+
}
564+
565+
#[test]
566+
fn test_round_trip_preserves_meta() {
567+
let mut extensions = Extensions::new();
568+
let mut ext_meta = Meta::new();
569+
ext_meta
570+
.0
571+
.insert("traceId".to_string(), json!("round-trip"));
572+
extensions.insert(ext_meta);
573+
574+
let req = CallToolRequest {
575+
extensions,
576+
method: Default::default(),
577+
params: CallToolRequestParams {
578+
meta: None,
579+
name: "my_tool".into(),
580+
arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])),
581+
task: None,
582+
},
583+
};
584+
585+
let serialized = serde_json::to_string(&req).unwrap();
586+
let deserialized: CallToolRequest = serde_json::from_str(&serialized).unwrap();
587+
588+
// Extensions should have the meta after round-trip
589+
let meta = deserialized.extensions.get::<Meta>().unwrap();
590+
assert_eq!(meta.0.get("traceId").unwrap(), "round-trip");
591+
592+
// Params should be preserved
593+
assert_eq!(deserialized.params.name, "my_tool");
594+
assert_eq!(
595+
deserialized
596+
.params
597+
.arguments
598+
.as_ref()
599+
.unwrap()
600+
.get("x")
601+
.unwrap(),
602+
&json!(1)
603+
);
604+
}
605+
606+
#[test]
607+
fn test_custom_request_no_duplicate_meta() {
608+
// CustomRequest uses Option<Value> as params — verify no duplicate _meta.
609+
let mut extensions = Extensions::new();
610+
let mut ext_meta = Meta::new();
611+
ext_meta
612+
.0
613+
.insert("traceId".to_string(), json!("custom-ext"));
614+
extensions.insert(ext_meta);
615+
616+
let params = Some(json!({
617+
"_meta": { "progressToken": 99 },
618+
"foo": "bar"
619+
}));
620+
621+
let req = CustomRequest {
622+
extensions,
623+
method: "custom/method".into(),
624+
params,
625+
};
626+
627+
let raw = serde_json::to_string(&req).unwrap();
628+
assert_eq!(
629+
raw.matches("\"_meta\"").count(),
630+
1,
631+
"Expected exactly one _meta key in CustomRequest output, got: {}",
632+
raw
633+
);
634+
635+
let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
636+
let meta = value["params"]["_meta"].as_object().unwrap();
637+
assert_eq!(meta.get("traceId").unwrap(), "custom-ext");
638+
assert_eq!(meta.get("progressToken").unwrap(), 99);
639+
}
373640
}

0 commit comments

Comments
 (0)