1use std::collections::HashMap;
7use std::fs::OpenOptions;
8use std::io::{BufWriter, Write};
9use std::path::Path;
10use std::time::Instant;
11
12use agent_client_protocol::schema::SuccessorMessage;
13use agent_client_protocol::schema::v1::{
14 MessageMcpNotification, MessageMcpRequest, MessageMcpResponse, Notification as RpcNotification,
15 Request as RpcRequest, RequestId,
16};
17use agent_client_protocol::{
18 DynConnectTo, JsonRpcMessage, RawJsonRpcMessage, RawJsonRpcParams,
19 RawJsonRpcResponse as RpcResponse, Role, UntypedMessage,
20};
21use rustc_hash::FxHashMap;
22use serde::{Deserialize, Serialize};
23
24use crate::ComponentIndex;
25use crate::snoop::SnooperComponent;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum TraceEvent {
32 Request(RequestEvent),
34
35 Response(ResponseEvent),
37
38 Notification(NotificationEvent),
40}
41
42#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45#[non_exhaustive]
46pub enum Protocol {
47 Acp,
49 Mcp,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55#[non_exhaustive]
56pub struct RequestEvent {
57 pub ts: f64,
59
60 pub protocol: Protocol,
62
63 pub from: String,
65
66 pub to: String,
68
69 pub id: serde_json::Value,
71
72 pub method: String,
74
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub session: Option<String>,
78
79 pub params: serde_json::Value,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85#[non_exhaustive]
86pub struct ResponseEvent {
87 pub ts: f64,
89
90 pub from: String,
92
93 pub to: String,
95
96 pub id: serde_json::Value,
98
99 pub is_error: bool,
101
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub error_domain: Option<Protocol>,
106
107 pub payload: serde_json::Value,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct NotificationEvent {
115 pub ts: f64,
117
118 pub protocol: Protocol,
120
121 pub from: String,
123
124 pub to: String,
126
127 pub method: String,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub session: Option<String>,
133
134 pub params: serde_json::Value,
136}
137
138pub trait WriteEvent: Send + 'static {
140 fn write_event(&mut self, event: &TraceEvent) -> std::io::Result<()>;
142}
143
144pub(crate) struct EventWriter<W> {
146 writer: W,
147}
148
149impl<W: Write> EventWriter<W> {
150 pub fn new(writer: W) -> Self {
151 Self { writer }
152 }
153}
154
155impl<W: Write + Send + 'static> WriteEvent for EventWriter<W> {
156 fn write_event(&mut self, event: &TraceEvent) -> std::io::Result<()> {
157 serde_json::to_writer(&mut self.writer, event).map_err(std::io::Error::other)?;
158 self.writer.write_all(b"\n")?;
159 self.writer.flush()
160 }
161}
162
163impl WriteEvent for futures::channel::mpsc::UnboundedSender<TraceEvent> {
165 fn write_event(&mut self, event: &TraceEvent) -> std::io::Result<()> {
166 self.unbounded_send(event.clone())
167 .map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))
168 }
169}
170
171pub struct TraceWriter {
173 dest: Box<dyn WriteEvent>,
174 start_time: Instant,
175
176 request_details: FxHashMap<serde_json::Value, RequestDetails>,
179}
180
181impl std::fmt::Debug for TraceWriter {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.debug_struct("TraceWriter")
184 .field("start_time", &self.start_time)
185 .finish_non_exhaustive()
186 }
187}
188
189struct RequestDetails {
190 protocol: Protocol,
191 request_from: ComponentIndex,
192 request_to: ComponentIndex,
193}
194
195impl TraceWriter {
196 pub fn new<D: WriteEvent>(dest: D) -> Self {
198 Self {
199 dest: Box::new(dest),
200 start_time: Instant::now(),
201 request_details: HashMap::default(),
202 }
203 }
204
205 pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
207 let file = OpenOptions::new()
208 .create(true)
209 .write(true)
210 .truncate(true)
211 .open(path.as_ref())?;
212 Ok(Self::new(EventWriter::new(BufWriter::new(file))))
213 }
214
215 fn elapsed(&self) -> f64 {
217 self.start_time.elapsed().as_secs_f64()
218 }
219
220 fn write_event(&mut self, event: &TraceEvent) {
222 drop(self.dest.write_event(event));
224 }
225
226 #[expect(clippy::too_many_arguments)]
228 fn request(
229 &mut self,
230 protocol: Protocol,
231 from: ComponentIndex,
232 to: ComponentIndex,
233 id: serde_json::Value,
234 method: String,
235 session: Option<String>,
236 mut params: serde_json::Value,
237 ) {
238 redact_http_credentials(&mut params);
239 self.request_details.insert(
240 id.clone(),
241 RequestDetails {
242 protocol,
243 request_from: from,
244 request_to: to,
245 },
246 );
247 self.write_event(&TraceEvent::Request(RequestEvent {
248 ts: self.elapsed(),
249 protocol,
250 from: format!("{from:?}"),
251 to: format!("{to:?}"),
252 id,
253 method,
254 session,
255 params,
256 }));
257 }
258
259 fn response(
261 &mut self,
262 from: ComponentIndex,
263 to: ComponentIndex,
264 id: serde_json::Value,
265 error_domain: Option<Protocol>,
266 mut payload: serde_json::Value,
267 ) {
268 redact_http_credentials(&mut payload);
269 self.write_event(&TraceEvent::Response(ResponseEvent {
270 ts: self.elapsed(),
271 from: format!("{from:?}"),
272 to: format!("{to:?}"),
273 id,
274 is_error: error_domain.is_some(),
275 error_domain,
276 payload,
277 }));
278 }
279
280 fn notification(
282 &mut self,
283 protocol: Protocol,
284 from: ComponentIndex,
285 to: ComponentIndex,
286 method: impl Into<String>,
287 session: Option<String>,
288 mut params: serde_json::Value,
289 ) {
290 redact_http_credentials(&mut params);
291 self.write_event(&TraceEvent::Notification(NotificationEvent {
292 ts: self.elapsed(),
293 protocol,
294 from: format!("{from:?}"),
295 to: format!("{to:?}"),
296 method: method.into(),
297 session,
298 params,
299 }));
300 }
301
302 fn trace_message(&mut self, traced_message: TracedMessage) {
304 let TracedMessage {
305 component_index,
306 successor_index,
307 incoming,
308 message,
309 } = traced_message;
310
311 match message {
322 RawJsonRpcMessage::Request(req) => {
323 let MessageInfo {
324 successor,
325 id,
326 protocol,
327 method,
328 params,
329 } = MessageInfo::from_request(req);
330
331 self.trace_request_or_notification(
332 incoming,
333 component_index,
334 successor_index,
335 successor,
336 id,
337 protocol,
338 method,
339 params,
340 );
341 }
342 RawJsonRpcMessage::Notification(notification) => {
343 let MessageInfo {
344 successor,
345 id,
346 protocol,
347 method,
348 params,
349 } = MessageInfo::from_notification(notification);
350
351 self.trace_request_or_notification(
352 incoming,
353 component_index,
354 successor_index,
355 successor,
356 id,
357 protocol,
358 method,
359 params,
360 );
361 }
362 RawJsonRpcMessage::Response(resp) => {
363 let (id, is_error, payload) = match resp {
367 RpcResponse::Result { id, result } => (id, false, result),
368 RpcResponse::Error { id, error } => {
369 (id, true, serde_json::to_value(error).unwrap_or_default())
370 }
371 };
372 let id = id_to_json(&id);
373 if let Some(RequestDetails {
374 protocol,
375 request_from,
376 request_to,
377 }) = self.request_details.remove(&id)
378 {
379 let (error_domain, payload) = response_outcome(protocol, is_error, payload);
380 self.response(request_to, request_from, id, error_domain, payload);
381 }
382 }
383 }
384 }
385
386 #[expect(clippy::too_many_arguments)]
387 fn trace_request_or_notification(
388 &mut self,
389 incoming: Incoming,
390 component_index: ComponentIndex,
391 successor_index: ComponentIndex,
392 successor: Successor,
393 id: Option<RequestId>,
394 protocol: Protocol,
395 method: String,
396 params: serde_json::Value,
397 ) {
398 let (from, to) = match (successor, incoming, component_index, successor_index) {
399 (Successor(false), Incoming(true), ComponentIndex::Proxy(proxy_index), _) => (
401 ComponentIndex::predecessor_of(proxy_index),
402 ComponentIndex::Proxy(proxy_index),
403 ),
404
405 (Successor(true), Incoming(true), component_index, successor_index) => {
409 (successor_index, component_index)
410 }
411
412 (Successor(true), Incoming(false), component_index, ComponentIndex::Agent) => {
418 (component_index, ComponentIndex::Agent)
419 }
420
421 _ => return,
422 };
423
424 match id {
425 Some(id) => {
426 self.request(protocol, from, to, id_to_json(&id), method, None, params);
427 }
428 None => {
429 self.notification(protocol, from, to, method, None, params);
430 }
431 }
432 }
433
434 pub(crate) fn spawn(
439 mut self: TraceWriter,
440 ) -> (
441 TraceHandle,
442 impl std::future::Future<Output = Result<(), agent_client_protocol::Error>>,
443 ) {
444 use futures::StreamExt;
445
446 let (tx, mut rx) = futures::channel::mpsc::unbounded();
447
448 let future = async move {
449 while let Some(event) = rx.next().await {
450 self.trace_message(event);
451 }
452 Ok(())
453 };
454
455 (TraceHandle { tx }, future)
456 }
457}
458
459#[derive(Clone, Debug)]
463pub(crate) struct TraceHandle {
464 tx: futures::channel::mpsc::UnboundedSender<TracedMessage>,
465}
466
467impl TraceHandle {
468 fn trace_message(
470 &self,
471 component_index: ComponentIndex,
472 successor_index: ComponentIndex,
473 incoming: Incoming,
474 message: &RawJsonRpcMessage,
475 ) -> Result<(), agent_client_protocol::Error> {
476 self.tx
477 .unbounded_send(TracedMessage {
478 component_index,
479 successor_index,
480 incoming,
481 message: message.clone(),
482 })
483 .map_err(agent_client_protocol::util::internal_error)
484 }
485
486 pub fn bridge_component<R: Role>(
501 &self,
502 proxy_index: ComponentIndex,
503 successor_index: ComponentIndex,
504 proxy: impl agent_client_protocol::ConnectTo<R>,
505 ) -> DynConnectTo<R> {
506 DynConnectTo::new(SnooperComponent::new(
507 proxy,
508 {
509 let trace_handle = self.clone();
510 move |msg| {
511 trace_handle.trace_message(proxy_index, successor_index, Incoming(true), msg)
512 }
513 },
514 {
515 let trace_handle = self.clone();
516 move |msg| {
517 trace_handle.trace_message(proxy_index, successor_index, Incoming(false), msg)
518 }
519 },
520 ))
521 }
522}
523
524fn id_to_json(id: &RequestId) -> serde_json::Value {
526 serde_json::to_value(id).expect("RequestId serializes infallibly")
527}
528
529fn params_from_transport(params: Option<RawJsonRpcParams>) -> serde_json::Value {
530 params.map_or(serde_json::Value::Null, RawJsonRpcParams::into_value)
531}
532
533fn response_outcome(
535 protocol: Protocol,
536 outer_error: bool,
537 payload: serde_json::Value,
538) -> (Option<Protocol>, serde_json::Value) {
539 if outer_error {
540 return (Some(Protocol::Acp), payload);
541 }
542 if protocol == Protocol::Mcp {
543 match serde_json::from_value::<MessageMcpResponse>(payload.clone()) {
544 Ok(MessageMcpResponse::Result { result, .. }) => return (None, result),
545 Ok(MessageMcpResponse::Error { error, .. }) => {
546 return (
547 Some(Protocol::Mcp),
548 serde_json::to_value(error).expect("MCP errors contain only JSON values"),
549 );
550 }
551 _ => {}
553 }
554 }
555 (None, payload)
556}
557
558fn redact_http_credentials(value: &mut serde_json::Value) {
561 fn is_credential(name: &str) -> bool {
562 [
563 "authorization",
564 "proxy-authorization",
565 "cookie",
566 "set-cookie",
567 "x-api-key",
568 ]
569 .iter()
570 .any(|candidate| name.eq_ignore_ascii_case(candidate))
571 }
572
573 match value {
574 serde_json::Value::Object(object) => {
575 if let Some(serde_json::Value::String(url)) = object.get_mut("url")
576 && let Some(redacted) = redact_http_url(url)
577 {
578 *url = redacted;
579 }
580 match object.get_mut("headers") {
581 Some(serde_json::Value::Array(headers)) => {
582 for header in headers {
583 if header
584 .get("name")
585 .and_then(serde_json::Value::as_str)
586 .is_some_and(is_credential)
587 && let Some(value) = header.get_mut("value")
588 {
589 *value = serde_json::Value::String("[REDACTED]".to_owned());
590 }
591 }
592 }
593 Some(serde_json::Value::Object(headers)) => {
594 for (name, value) in headers {
595 if is_credential(name) {
596 *value = serde_json::Value::String("[REDACTED]".to_owned());
597 }
598 }
599 }
600 _ => {}
601 }
602 for value in object.values_mut() {
603 redact_http_credentials(value);
604 }
605 }
606 serde_json::Value::Array(values) => {
607 for value in values {
608 redact_http_credentials(value);
609 }
610 }
611 _ => {}
612 }
613}
614
615fn is_secret_query_key(name: &str) -> bool {
618 let normalized: String = name
619 .chars()
620 .filter(|c| !matches!(c, '-' | '_'))
621 .map(|c| c.to_ascii_lowercase())
622 .collect();
623 matches!(
624 normalized.as_str(),
625 "token"
626 | "accesstoken"
627 | "refreshtoken"
628 | "idtoken"
629 | "apikey"
630 | "key"
631 | "secret"
632 | "clientsecret"
633 | "password"
634 | "passwd"
635 | "pwd"
636 | "auth"
637 | "authorization"
638 | "bearer"
639 | "signature"
640 | "sig"
641 | "credential"
642 | "credentials"
643 )
644}
645
646fn redact_http_url(value: &str) -> Option<String> {
647 let mut url = match url::Url::parse(value) {
648 Ok(url) if matches!(url.scheme(), "http" | "https") => url,
649 Ok(_) => return None,
650 Err(_) => {
651 let scheme: String = value
655 .trim_start_matches(|c: char| c <= '\u{20}')
656 .split(':')
657 .next()
658 .unwrap_or_default()
659 .chars()
660 .filter(|c| !matches!(c, '\t' | '\n' | '\r'))
661 .collect();
662 return (scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
663 .then(|| "[REDACTED]".to_owned());
664 }
665 };
666 let mut changed = false;
667 if !url.username().is_empty() || url.password().is_some() {
668 url.set_password(None).expect("HTTP URL has an authority");
670 url.set_username("").expect("HTTP URL has an authority");
671 changed = true;
672 }
673 if let Some(query) = url.query() {
674 let redacted = query
676 .split('&')
677 .map(|pair| {
678 let is_secret = url::form_urlencoded::parse(pair.as_bytes())
679 .next()
680 .is_some_and(|(key, _)| is_secret_query_key(&key));
681 if is_secret {
682 changed = true;
683 let key = pair.split('=').next().unwrap_or_default();
684 format!("{key}=[REDACTED]")
685 } else {
686 pair.to_owned()
687 }
688 })
689 .collect::<Vec<_>>()
690 .join("&");
691 url.set_query(Some(&redacted));
692 }
693 changed.then(|| url.into())
696}
697
698#[derive(Debug)]
701struct TracedMessage {
702 component_index: ComponentIndex,
703 successor_index: ComponentIndex,
704 incoming: Incoming,
705 message: RawJsonRpcMessage,
706}
707
708#[derive(Debug)]
710struct MessageInfo {
711 successor: Successor,
712 id: Option<RequestId>,
713 protocol: Protocol,
714 method: String,
715 params: serde_json::Value,
716}
717
718#[derive(Copy, Clone, Debug)]
719struct Successor(bool);
720
721#[derive(Copy, Clone, Debug)]
722struct Incoming(bool);
723
724impl MessageInfo {
725 fn from_request(req: RpcRequest<RawJsonRpcParams>) -> Self {
733 let untyped =
734 UntypedMessage::parse_message(&req.method, ¶ms_from_transport(req.params))
735 .expect("untyped message is infallible");
736 Self::from_untyped_request(Successor(false), Some(req.id), Protocol::Acp, untyped)
737 }
738
739 fn from_notification(notification: RpcNotification<RawJsonRpcParams>) -> Self {
740 let untyped = UntypedMessage::parse_message(
741 ¬ification.method,
742 ¶ms_from_transport(notification.params),
743 )
744 .expect("untyped message is infallible");
745 Self::from_untyped_notification(Successor(false), Protocol::Acp, untyped)
746 }
747
748 fn from_untyped_request(
749 successor: Successor,
750 id: Option<RequestId>,
751 protocol: Protocol,
752 untyped: UntypedMessage,
753 ) -> Self {
754 if let Ok(m) = SuccessorMessage::parse_message(&untyped.method, &untyped.params) {
755 return Self::from_untyped_request(Successor(true), id, protocol, m.message);
756 }
757
758 if let Ok(m) = MessageMcpRequest::parse_message(&untyped.method, &untyped.params) {
759 let params = m
760 .params
761 .map_or(serde_json::Value::Null, serde_json::Value::Object);
762 return Self::from_untyped_request(
763 successor,
764 id,
765 Protocol::Mcp,
766 UntypedMessage {
767 method: m.method,
768 params,
769 },
770 );
771 }
772
773 Self::new(successor, id, protocol, untyped)
774 }
775
776 fn from_untyped_notification(
777 successor: Successor,
778 protocol: Protocol,
779 untyped: UntypedMessage,
780 ) -> Self {
781 if let Ok(m) = SuccessorMessage::parse_message(&untyped.method, &untyped.params) {
782 return Self::from_untyped_notification(Successor(true), protocol, m.message);
783 }
784
785 if let Ok(m) = MessageMcpNotification::parse_message(&untyped.method, &untyped.params) {
786 let params = m
787 .params
788 .map_or(serde_json::Value::Null, serde_json::Value::Object);
789 return Self::from_untyped_notification(
790 successor,
791 Protocol::Mcp,
792 UntypedMessage {
793 method: m.method,
794 params,
795 },
796 );
797 }
798
799 Self::new(successor, None, protocol, untyped)
800 }
801
802 fn new(
803 successor: Successor,
804 id: Option<RequestId>,
805 protocol: Protocol,
806 untyped: UntypedMessage,
807 ) -> Self {
808 Self {
809 successor,
810 id,
811 protocol,
812 method: untyped.method,
813 params: untyped.params,
814 }
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use agent_client_protocol::RawJsonRpcMessage;
821 use serde_json::json;
822
823 use super::{MessageInfo, Protocol, ResponseEvent, redact_http_credentials, response_outcome};
824
825 #[test]
826 fn http_url_redaction_handles_encoded_keys_duplicates_and_userinfo() {
827 let mut value = json!({
828 "url":"HTTPS://user:p%40ss@example.test:8443/mcp?mode=a%20b&%61ccess_TOKEN=one&api-key=two&token&token=three&&count=2#section"
829 });
830 redact_http_credentials(&mut value);
831 assert_eq!(
832 value["url"],
833 "https://example.test:8443/mcp?mode=a%20b&%61ccess_TOKEN=[REDACTED]&api-key=[REDACTED]&token=[REDACTED]&token=[REDACTED]&&count=2#section"
834 );
835 let once = value.clone();
836 redact_http_credentials(&mut value);
837 assert_eq!(value, once, "redaction must be idempotent");
838
839 for key in [
840 "TOKEN",
841 "access_token",
842 "refresh-token",
843 "idToken",
844 "api_key",
845 "key",
846 "secret",
847 "client_secret",
848 "password",
849 "passwd",
850 "pwd",
851 "auth",
852 "authorization",
853 "bearer",
854 "signature",
855 "sig",
856 "credential",
857 "credentials",
858 ] {
859 let mut value = json!({"url":format!("http://example.test/mcp?{key}=private&mode=ok")});
860 redact_http_credentials(&mut value);
861 assert_eq!(
862 value["url"],
863 format!("http://example.test/mcp?{key}=[REDACTED]&mode=ok")
864 );
865 }
866 for url in [
867 "https://user@example.test/mcp",
868 "https://:private@example.test/mcp",
869 "https://u%40ser:p%40ss@example.test/mcp",
870 ] {
871 let mut value = json!({"url":url});
872 redact_http_credentials(&mut value);
873 assert_eq!(value["url"], "https://example.test/mcp");
874 }
875 for scheme in [
876 "https",
877 "http\t",
878 "ht\ntps",
879 "h\rttp",
880 "\u{0}https",
881 "\u{1f}http",
882 ] {
883 let mut invalid = json!({
884 "url":format!("{scheme}://user:private@[bad-host]/?token=private")
885 });
886 redact_http_credentials(&mut invalid);
887 assert_eq!(invalid["url"], "[REDACTED]");
888 }
889 }
890
891 #[test]
892 fn credential_free_urls_and_explicit_payloads_are_preserved() {
893 let original = json!({
894 "url":"HTTPS://EXAMPLE.test:443/mcp?mode=a+b&mode=a%20b&&count=2#section",
895 "nonHttp":{"url":"file:///tmp/data?token=visible"},
896 "prompt":"an intentionally recorded prompt",
897 "image":{"data":"intentionally recorded image"},
898 "file":{"content":"intentionally recorded file"},
899 "customSecret":"not a recognized credential field"
900 });
901 let mut trace_copy = original.clone();
902 redact_http_credentials(&mut trace_copy);
903 assert_eq!(trace_copy, original);
904 }
905
906 #[tokio::test]
907 async fn recording_redacts_all_event_kinds_without_changing_wire_messages() {
908 use super::{ComponentIndex, TraceEvent, TraceWriter};
909 use agent_client_protocol::{Channel, ConnectTo, TransportFrame, UntypedRole};
910 use futures::StreamExt as _;
911
912 tokio::task::LocalSet::new().run_until(async {
913 let payload = json!({
914 "mcpServers":[{
915 "type":"http", "url":"https://user:private@example.test/mcp?token=private&mode=ok",
916 "headers":[{"name":"Authorization","value":"Bearer private"},
917 {"name":"visible","value":"ok"}]
918 }],
919 "prompt":"recorded prompt", "image":{"data":"recorded image"},
920 "file":{"content":"recorded file"}
921 });
922 let (events_tx, mut events_rx) = futures::channel::mpsc::unbounded();
923 let (handle, recording) = TraceWriter::new(events_tx).spawn();
924 let recording = tokio::task::spawn_local(recording);
925 let (client, mut client_peer) = Channel::duplex();
926 let (base, mut base_peer) = Channel::duplex();
927 let bridge = handle.bridge_component::<UntypedRole>(
928 ComponentIndex::Proxy(0), ComponentIndex::Agent, base);
929 let bridge = tokio::task::spawn_local(bridge.connect_to(client));
930 drop(handle);
931
932 let request = RawJsonRpcMessage::request("test/request".into(), payload.clone(), 1.into()).unwrap();
933 let notification = RawJsonRpcMessage::notification("test/notification".into(), payload.clone()).unwrap();
934 for message in [request, notification] {
935 let frame = TransportFrame::Single(message);
936 let original_wire = frame.to_json().unwrap();
937 client_peer.tx.unbounded_send(frame).unwrap();
938 let forwarded = base_peer.rx.next().await.unwrap();
939 assert_eq!(forwarded.to_json().unwrap(), original_wire);
940 }
941 let response = TransportFrame::Single(RawJsonRpcMessage::response(1.into(), Ok(payload.clone())));
942 let original_wire = response.to_json().unwrap();
943 base_peer.tx.unbounded_send(response).unwrap();
944 assert_eq!(client_peer.rx.next().await.unwrap().to_json().unwrap(), original_wire);
945 drop(client_peer.tx);
946 drop(base_peer.tx);
947 bridge.await.unwrap().unwrap();
948 recording.await.unwrap().unwrap();
949
950 let mut expected = payload.clone();
951 expected["mcpServers"][0]["url"] =
952 json!("https://example.test/mcp?token=[REDACTED]&mode=ok");
953 expected["mcpServers"][0]["headers"][0]["value"] = json!("[REDACTED]");
954 let mut count = 0;
955 while let Some(event) = events_rx.next().await {
956 let recorded = match event {
957 TraceEvent::Request(event) => event.params,
958 TraceEvent::Notification(event) => event.params,
959 TraceEvent::Response(event) => event.payload,
960 };
961 assert_eq!(recorded, expected);
962 count += 1;
963 }
964 assert_eq!(count, 3);
965 assert_eq!(payload["mcpServers"][0]["headers"][0]["value"], "Bearer private");
966 }).await;
967 }
968
969 #[test]
970 fn mcp_and_binding_errors_keep_their_domains() {
971 let inner = json!({"code":-32000,"message":"peer","data":null,"extension":true});
972 assert_eq!(
973 response_outcome(Protocol::Mcp, false, json!({"error":inner})),
974 (Some(Protocol::Mcp), inner)
975 );
976 let outer = json!({"code":-33002,"message":"binding failure"});
977 assert_eq!(
978 response_outcome(Protocol::Mcp, true, outer.clone()),
979 (Some(Protocol::Acp), outer)
980 );
981 for result in [
982 json!(null),
983 json!({"resultType":"input_required","requestState":"opaque"}),
984 ] {
985 assert_eq!(
986 response_outcome(Protocol::Mcp, false, json!({"result":result})),
987 (None, result)
988 );
989 }
990 }
991
992 #[test]
993 fn old_trace_response_without_domain_remains_readable() {
994 let event: ResponseEvent = serde_json::from_value(json!({
995 "ts":0,"from":"client","to":"agent","id":1,"is_error":true,
996 "payload":{"code":-32602,"message":"old trace"}
997 }))
998 .unwrap();
999 assert!(event.is_error);
1000 assert_eq!(event.error_domain, None);
1001 }
1002
1003 #[test]
1004 fn declaration_credentials_are_redacted_without_changing_transport_payload() {
1005 let original = json!({"mcpServers":[
1006 {"headers":[{"name":"Authorization","value":"Bearer private"},{"name":"visible","value":"ok"}]},
1007 {"headers":{"COOKIE":"private","visible":"ok"}}
1008 ]});
1009 let mut trace_copy = original.clone();
1010 redact_http_credentials(&mut trace_copy);
1011 assert_eq!(
1012 trace_copy["mcpServers"][0]["headers"][0]["value"],
1013 "[REDACTED]"
1014 );
1015 assert_eq!(
1016 trace_copy["mcpServers"][1]["headers"]["COOKIE"],
1017 "[REDACTED]"
1018 );
1019 assert_eq!(trace_copy["mcpServers"][0]["headers"][1]["value"], "ok");
1020 assert_eq!(
1021 original["mcpServers"][0]["headers"][0]["value"],
1022 "Bearer private"
1023 );
1024 }
1025
1026 #[test]
1027 fn malformed_mcp_notification_preserves_the_observed_envelope() {
1028 let params = json!({
1029 "serverId": "server-1",
1030 "requestId": "request-1",
1031 "method": "notifications/progress",
1032 "params": ["invalid named params"]
1033 });
1034 let RawJsonRpcMessage::Notification(notification) =
1035 RawJsonRpcMessage::notification("mcp/message".into(), params.clone())
1036 .expect("notification is valid JSON-RPC")
1037 else {
1038 unreachable!("notification constructor returned a different message kind")
1039 };
1040
1041 let info = MessageInfo::from_notification(notification);
1042
1043 assert_eq!(info.protocol, Protocol::Acp);
1046 assert_eq!(info.method, "mcp/message");
1047 assert_eq!(info.params, params);
1048 }
1049
1050 #[test]
1051 fn valid_mcp_notification_is_traced_as_inner_mcp() {
1052 let params = json!({"progressToken":"token", "progress":1});
1053 let RawJsonRpcMessage::Notification(notification) = RawJsonRpcMessage::notification(
1054 "mcp/message".into(),
1055 json!({
1056 "serverId":"server-1","requestId":"request-1",
1057 "method":"notifications/progress","params":params
1058 }),
1059 )
1060 .unwrap() else {
1061 unreachable!("notification constructor")
1062 };
1063 let info = MessageInfo::from_notification(notification);
1064 assert_eq!(info.protocol, Protocol::Mcp);
1065 assert_eq!(info.method, "notifications/progress");
1066 assert_eq!(info.params, params);
1067 }
1068}