Skip to main content

agent_client_protocol_conductor/
trace.rs

1//! Trace event types for the sequence diagram viewer.
2//!
3//! Events are serialized as newline-delimited JSON (`.jsons` files).
4//! The viewer loads these files to render interactive sequence diagrams.
5
6use 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/// A trace event representing message flow between components.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum TraceEvent {
32    /// A JSON-RPC request from one component to another.
33    Request(RequestEvent),
34
35    /// A JSON-RPC response to a prior request.
36    Response(ResponseEvent),
37
38    /// A JSON-RPC notification (no response expected).
39    Notification(NotificationEvent),
40}
41
42/// Protocol type for messages.
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45#[non_exhaustive]
46pub enum Protocol {
47    /// Standard ACP protocol messages.
48    Acp,
49    /// MCP messages carried over ACP.
50    Mcp,
51}
52
53/// A JSON-RPC request from one component to another.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[non_exhaustive]
56pub struct RequestEvent {
57    /// Monotonic timestamp (seconds since trace start).
58    pub ts: f64,
59
60    /// Protocol: ACP or MCP.
61    pub protocol: Protocol,
62
63    /// Source component (e.g., "client", "proxy:0", "proxy:1", "agent").
64    pub from: String,
65
66    /// Destination component.
67    pub to: String,
68
69    /// JSON-RPC request ID (for correlating with response).
70    pub id: serde_json::Value,
71
72    /// JSON-RPC method name.
73    pub method: String,
74
75    /// ACP session ID, if known; omitted when no session context is available.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub session: Option<String>,
78
79    /// Full request params.
80    pub params: serde_json::Value,
81}
82
83/// A JSON-RPC response to a prior request.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[non_exhaustive]
86pub struct ResponseEvent {
87    /// Monotonic timestamp (seconds since trace start).
88    pub ts: f64,
89
90    /// Source component (who sent the response).
91    pub from: String,
92
93    /// Destination component (who receives the response).
94    pub to: String,
95
96    /// JSON-RPC request ID this responds to.
97    pub id: serde_json::Value,
98
99    /// True if this is an error response.
100    pub is_error: bool,
101
102    /// Whether an error belongs to the ACP binding or the inner MCP peer.
103    /// Older trace files omit this provenance.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub error_domain: Option<Protocol>,
106
107    /// Response result or error object.
108    pub payload: serde_json::Value,
109}
110
111/// A JSON-RPC notification (no response expected).
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct NotificationEvent {
115    /// Monotonic timestamp (seconds since trace start).
116    pub ts: f64,
117
118    /// Protocol: ACP or MCP.
119    pub protocol: Protocol,
120
121    /// Source component.
122    pub from: String,
123
124    /// Destination component.
125    pub to: String,
126
127    /// JSON-RPC method name.
128    pub method: String,
129
130    /// ACP session ID, if known.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub session: Option<String>,
133
134    /// Full notification params.
135    pub params: serde_json::Value,
136}
137
138/// Trait for destinations that can receive trace events.
139pub trait WriteEvent: Send + 'static {
140    /// Write a trace event to the destination.
141    fn write_event(&mut self, event: &TraceEvent) -> std::io::Result<()>;
142}
143
144/// Writes trace events as newline-delimited JSON to a `Write` impl.
145pub(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
163/// Impl for UnboundedSender - sends events to a channel (useful for testing).
164impl 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
171/// Writer for trace events.
172pub struct TraceWriter {
173    dest: Box<dyn WriteEvent>,
174    start_time: Instant,
175
176    /// When we trace a request, we store its id along with the
177    /// details here. When we see responses, we try to match them up.
178    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    /// Create a new trace writer from any WriteEvent destination.
197    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    /// Create a new trace writer that writes to a file path.
206    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    /// Get the elapsed time since trace start, in seconds.
216    fn elapsed(&self) -> f64 {
217        self.start_time.elapsed().as_secs_f64()
218    }
219
220    /// Write a trace event.
221    fn write_event(&mut self, event: &TraceEvent) {
222        // Ignore errors - tracing should not break the conductor
223        drop(self.dest.write_event(event));
224    }
225
226    /// Write a request event.
227    #[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    /// Write a response event.
260    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    /// Write a notification event.
281    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    /// Trace a raw JSON-RPC message being sent from one component to another.
303    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        // We get every message going into or out of a proxy. This includes
312        // a fair number of duplicates: for example, if proxy P0 sends to P1,
313        // we'll get it as an *outgoing* message from P0 and an *incoming* message to P1.
314        // So we want to keep just one copy.
315        //
316        // We retain:
317        //
318        // * Incoming requests/notifications targeting a PROXY.
319        // * Incoming requests/notifications targeting the AGENT.
320
321        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                // Lookup the response by its id.
364                // All of the messages we are intercepting go to our proxies,
365                // and we always assign them globally unique ids.
366                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            // An incoming request/notification to a proxy from its predecessor.
400            (Successor(false), Incoming(true), ComponentIndex::Proxy(proxy_index), _) => (
401                ComponentIndex::predecessor_of(proxy_index),
402                ComponentIndex::Proxy(proxy_index),
403            ),
404
405            // An incoming request/notification to any component from its successor.
406            //
407            // This includes incoming messages to the client in the case where we have no proxies.
408            (Successor(true), Incoming(true), component_index, successor_index) => {
409                (successor_index, component_index)
410            }
411
412            // An outgoing request/notification from a component to its successor
413            // *and* its successor is not a proxy.
414            //
415            // (If its successor is a proxy, we ignore it, because we'll also see the
416            // message in "incoming" form).
417            (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    /// Spawn a trace writer task.
435    ///
436    /// Returns a `TraceHandle` that can be cloned and used from multiple tasks,
437    /// and a future that should be spawned (e.g., via `with_spawned`).
438    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/// A cloneable handle for sending trace events to the trace writer task.
460///
461/// Create with [`spawn_trace_writer`], then clone and pass to bridges.
462#[derive(Clone, Debug)]
463pub(crate) struct TraceHandle {
464    tx: futures::channel::mpsc::UnboundedSender<TracedMessage>,
465}
466
467impl TraceHandle {
468    /// Trace a raw JSON-RPC message being sent from one component to another.
469    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    /// Create a tracing bridge that wraps a proxy component.
487    ///
488    /// Spawns a bridge task that forwards messages between the channel and the component
489    /// while tracing them. Returns the wrapped component.
490    ///
491    /// Tracing strategy:
492    /// - **Left→Right (incoming)**: Trace requests/notifications, skip responses
493    /// - **Right→Left (outgoing)**: Trace responses, and if `trace_outgoing_requests` is true,
494    ///   also trace requests/notifications (needed for edge bridges at conductor boundaries)
495    ///
496    /// - `cx`: Connection context for spawning the bridge task
497    /// - `left_name`: Logical name of the component on the "left" side (e.g., "client", "proxy:0")
498    /// - `right_name`: Logical name of the component on the "right" side (e.g., "proxy:0", "agent")
499    /// - `component`: The component to wrap
500    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
524/// Convert a JSON-RPC id to serde_json::Value.
525fn 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
533/// Keep an MCP outcome separate from a failure of its ACP binding.
534fn 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            // Preserve a malformed carrier as observed, not as an invented error.
552            _ => {}
553        }
554    }
555    (None, payload)
556}
557
558/// Redact only the trace copy; transport declarations keep their credentials.
559/// This is deliberately not a general-purpose sanitizer for protocol payloads.
560fn 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
615/// Recognize common credential keys, case-insensitively and after URL decoding.
616/// Hyphenated, underscored, and compact spellings share the same rule.
617fn 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            // Do not retain credentials in an HTTP URL we cannot safely inspect.
652            // Match WHATWG preprocessing: trim leading C0/space and ignore
653            // ASCII tabs/newlines within the scheme, just as the URL parser does.
654            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        // HTTP URLs have an authority, so clearing userinfo cannot fail.
669        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        // Preserve spelling, order, duplicates, and encoding of non-secret pairs.
675        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    // Leave credential-free URLs byte-for-byte unchanged. Credential-bearing
694    // URLs may be canonicalized by the URL parser when stripping userinfo.
695    changed.then(|| url.into())
696}
697
698/// A message observed going over a channel connected to `left` and `right`.
699/// This could be a successor message, a mcp-over-acp message, etc.
700#[derive(Debug)]
701struct TracedMessage {
702    component_index: ComponentIndex,
703    successor_index: ComponentIndex,
704    incoming: Incoming,
705    message: RawJsonRpcMessage,
706}
707
708/// Fully interpreted message info.
709#[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    /// Extract logical message info from method and params.
726    ///
727    /// This unwraps protocol wrappers to get the "real" message:
728    /// - `_proxy/successor` messages are unwrapped to get the inner message
729    /// - `mcp/message` messages are detected and marked as MCP protocol
730    ///
731    /// Returns (protocol, method, params).
732    fn from_request(req: RpcRequest<RawJsonRpcParams>) -> Self {
733        let untyped =
734            UntypedMessage::parse_message(&req.method, &params_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            &notification.method,
742            &params_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        // Schema 1.10.1 requires named inner parameters. Do not pretend this
1044        // malformed envelope was a successfully decoded MCP notification.
1045        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}