Skip to main content

protocol_droid/application/bridge/
cross_protocol.rs

1//! The cross-protocol half of the bridge: request envelopes whose method
2//! names and parameter shapes differ between the two legs.
3//!
4//! # Why this is a separate trait from `ToolResultCodec`
5//!
6//! `ToolResultCodec` is the per-revision *content* translator — the piece that
7//! decides whether a `CallToolResult` needs to be downgraded before the
8//! downstream client can read it. The shape of the *request* envelope is
9//! identical at every MCP revision: `tools/call` is `tools/call`, `params`
10//! carries the same keys. A bridge that only had `ToolResultCodec` could
11//! forward MCP ↔ MCP, because nothing in the request needs renaming.
12//!
13//! A bridge between two *different* protocols cannot. A2A's
14//! `SendMessage` has no MCP counterpart, and MCP's `tools/call` has no
15//! A2A counterpart, and the bridge has to know the pair exists. That
16//! knowledge does not belong in `ToolResultCodec` because it is about
17//! *requests*, not about results. It does not belong in `BridgePeer`,
18//! because the peer is a transport and does not know the two protocols
19//! speak different languages. It is its own thing.
20//!
21//! # What this trait is *not*
22//!
23//! It is not the codec that lifts/lowers JSON-RPC envelopes. Both protocols
24//! use the same JSON-RPC 2.0 wire envelope, so the bridge's `lift_envelope`
25//! and `lower_envelope` are unchanged. This trait operates on the IR the
26//! lift produced, with the protocol-agnostic method name (`MethodName`) the
27//! bridge tracks.
28//!
29//! # Why the codec is asked per-message, not pre-loaded
30//!
31//! A bridge might speak MCP downstream and A2A upstream, or A2A downstream
32//! and MCP upstream, or MCP on both legs (in which case this codec is
33//! never called). The right question is "given *this* downstream leg and
34//! *this* upstream envelope, what does the wire form look like?" — and
35//! the answer depends on the leg's protocol, the upstream's protocol, and
36//! the method being called. A pre-loaded table is a stale table the moment
37//! a new leg arrives.
38
39use serde_json::{Map, Value};
40
41/// The two sides of a cross-protocol translation, recorded for the operator.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum ProtocolSide {
44    /// A2A v1.0. The wire surface is JSON-RPC 2.0; the method names are
45    /// `SendMessage`, `GetTask`, etc.
46    A2a,
47    /// MCP at any revision the bridge negotiated.
48    Mcp,
49}
50
51impl ProtocolSide {
52    /// The wire method name the bridge will hand to the peer on this side.
53    ///
54    /// Returns `None` when the bridge cannot express the method on this
55    /// side at all — the caller refuses the call rather than sending a
56    /// method name the peer will reject as `-32601`.
57    ///
58    /// The first arm is the row an A2A client hits: it called
59    /// `SendMessage`, the bridge lifts that to the IR name `SendMessage`,
60    /// and the codec now needs the wire name the *upstream* peer
61    /// (the MCP agent) reads, which is `tools/call`. The second arm
62    /// is the mirror case: an MCP client called `tools/call`, the
63    /// bridge lifted to the IR name `tools/call`, and the upstream
64    /// (an A2A agent) reads `SendMessage`.
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// use protocol_droid::application::bridge::ProtocolSide;
70    ///
71    /// assert_eq!(
72    ///     ProtocolSide::A2a.wire_method("SendMessage"),
73    ///     Some("tools/call")
74    /// );
75    /// assert_eq!(
76    ///     ProtocolSide::Mcp.wire_method("tools/call"),
77    ///     Some("SendMessage")
78    /// );
79    /// ```
80    #[must_use]
81    pub fn wire_method(self, ir_method: &str) -> Option<&'static str> {
82        match (self, ir_method) {
83            (Self::A2a, "SendMessage") => Some("tools/call"),
84            (Self::Mcp, "tools/call") => Some("SendMessage"),
85            // The two protocols' other methods have no IR counterpart yet.
86            // The bridge refuses them with `MethodNotBridged` rather than
87            // forwarding a name the peer will reject.
88            _ => None,
89        }
90    }
91}
92
93/// What translating a request and its reply produced.
94///
95/// Distinct from [`crate::domain::bridge::a2a_mcp::CrossProtocolReport`],
96/// which records what one of the pure mapping functions did. This one
97/// records what a single bridge call (one request envelope in, one reply
98/// envelope out) cost the bridge.
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub struct RequestCodecReport {
101    /// The steps the translation took, in order. An empty list means the
102    /// call passed through with no recorded loss.
103    pub steps: Vec<RequestCodecStep>,
104}
105
106impl RequestCodecReport {
107    /// A report with no recorded steps.
108    #[must_use]
109    pub fn empty() -> Self {
110        Self::default()
111    }
112
113    /// Whether the translation did anything worth reporting.
114    #[must_use]
115    pub const fn is_empty(&self) -> bool {
116        self.steps.is_empty()
117    }
118
119    /// The number of recorded steps.
120    #[must_use]
121    pub const fn len(&self) -> usize {
122        self.steps.len()
123    }
124
125    /// Records `step`.
126    pub fn record(&mut self, step: RequestCodecStep) {
127        self.steps.push(step);
128    }
129}
130
131/// One named step in a request/reply translation.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum RequestCodecStep {
134    /// The method name was renamed to the upstream's vocabulary.
135    MethodRenamed {
136        /// The IR-side name the bridge carried in.
137        from: &'static str,
138        /// The wire-side name the bridge sent.
139        to: &'static str,
140    },
141    /// The request params were translated. The shape change is recorded
142    /// with enough detail for a reviewer to know what the bridge did.
143    ParamsTranslated {
144        /// What the IR shape is, in human terms.
145        from: &'static str,
146        /// What the wire shape is, in human terms.
147        to: &'static str,
148    },
149    /// The reply was translated. Same `from`/`to` shape.
150    ReplyTranslated {
151        /// What the wire reply shape is, in human terms.
152        from: &'static str,
153        /// What the IR reply shape is, in human terms.
154        to: &'static str,
155    },
156}
157
158impl core::fmt::Display for RequestCodecStep {
159    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
160        match self {
161            Self::MethodRenamed { from, to } => {
162                write!(formatter, "method {from} → {to}")
163            }
164            Self::ParamsTranslated { from, to } => {
165                write!(formatter, "params {from} → {to}")
166            }
167            Self::ReplyTranslated { from, to } => {
168                write!(formatter, "reply {from} → {to}")
169            }
170        }
171    }
172}
173
174impl core::fmt::Display for RequestCodecReport {
175    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
176        for (i, step) in self.steps.iter().enumerate() {
177            if i > 0 {
178                formatter.write_str("; ")?;
179            }
180            core::fmt::Display::fmt(step, formatter)?;
181        }
182        Ok(())
183    }
184}
185
186/// What a cross-protocol codec can refuse.
187#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
188pub enum CrossProtocolError {
189    /// The downstream leg's protocol is not one this codec speaks.
190    /// The bridge should never have called it; this is a wiring error.
191    #[error("downstream leg speaks {0:?}, which is not bridged by this codec")]
192    UnknownDownstream(ProtocolSide),
193    /// The method on the IR is not one the codec has a rule for.
194    /// A bridge that received this returns `-32601 Method not found` to
195    /// the client, naming the method so the operator can see which call
196    /// hit the gap.
197    #[error("method {0:?} is not bridged between {1:?} and the upstream")]
198    MethodNotBridged(String, ProtocolSide),
199}
200
201/// A codec that translates request and reply envelopes between the
202/// downstream leg's protocol and the upstream's protocol.
203///
204/// The downstream leg's protocol is the one the client spoke; the upstream
205/// is the one the bridge is calling out to. The two are usually different
206/// in the cross-protocol case (an MCP client on one side, an A2A agent on
207/// the other), and a `BridgePeer` for the upstream is a real connection
208/// to that agent.
209///
210/// # Why the trait takes `Leg` not `ProtocolVersion`
211///
212/// The downstream leg's `ProtocolVersion` says which MCP revision the
213/// client negotiated. The cross-protocol decision is bigger than that —
214/// it is "is this MCP at all, or is it A2A?" — and `Leg` already carries
215/// the protocol shape. A bridge that only sees one leg's protocol can
216/// answer "MCP" or "A2A" and skip the per-revision question entirely,
217/// which is what the per-revision `ToolResultCodec` is for on the way
218/// back.
219///
220/// # Why the codec takes the raw downstream envelope, not just the IR
221///
222/// The IR is protocol-agnostic at the method-name level (it carries the
223/// canonical name the bridge has assigned), but the *parameters* are
224/// opaque: a `tools/call` `arguments` value is just an entry in
225/// `IrParams`, indistinguishable from a `SendMessage` `message` field.
226/// The codec needs the raw wire shape to know which entry is which, so
227/// it re-reads `raw_envelope` rather than trying to re-derive the shape
228/// from the IR. The bridge already has both — the IR for routing, the
229/// raw envelope for translation.
230pub trait CrossProtocolCodec: Send + Sync + core::fmt::Debug {
231    /// The protocol this codec expects the downstream leg to speak.
232    fn downstream_protocol(&self) -> ProtocolSide;
233
234    /// Translates a downstream request envelope into the upstream's wire form.
235    ///
236    /// `downstream_side` is the protocol the downstream leg speaks
237    /// (A2A or MCP). `ir_method` is the canonical method name the
238    /// bridge has assigned (e.g. `SendMessage` for an A2A call,
239    /// `tools/call` for an MCP call). `raw_envelope` is the
240    /// downstream's original wire shape, so the codec can read the
241    /// parameters in their native form.
242    ///
243    /// # Errors
244    ///
245    /// [`CrossProtocolError::MethodNotBridged`] if the method is one
246    /// this codec has no rule for.
247    fn encode_request(
248        &self,
249        downstream_side: ProtocolSide,
250        ir_method: &str,
251        raw_envelope: &Map<String, Value>,
252        report: &mut RequestCodecReport,
253    ) -> Result<Map<String, Value>, CrossProtocolError>;
254
255    /// Translates an upstream reply envelope back into the downstream's
256    /// wire form.
257    ///
258    /// `downstream_side` is the protocol the downstream leg speaks.
259    /// `ir_method` is the canonical method name the request carried; the
260    /// codec uses it to know which reply shape to expect (an A2A `Task`
261    /// reply for a `SendMessage` request, a `Content` list for a
262    /// `tools/call` request).
263    ///
264    /// # Errors
265    ///
266    /// The same set of errors as [`encode_request`](Self::encode_request).
267    fn decode_reply(
268        &self,
269        downstream_side: ProtocolSide,
270        ir_method: &str,
271        reply: &Map<String, Value>,
272        report: &mut RequestCodecReport,
273    ) -> Result<Map<String, Value>, CrossProtocolError>;
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn an_a2a_downstream_calling_send_message_lets_the_codec_route_to_tools_call() {
282        assert_eq!(
283            ProtocolSide::A2a.wire_method("SendMessage"),
284            Some("tools/call")
285        );
286    }
287
288    #[test]
289    fn an_mcp_downstream_calling_tools_call_lets_the_codec_route_to_send_message() {
290        assert_eq!(
291            ProtocolSide::Mcp.wire_method("tools/call"),
292            Some("SendMessage")
293        );
294    }
295
296    #[test]
297    fn an_unknown_method_returns_none_on_both_sides() {
298        assert_eq!(ProtocolSide::Mcp.wire_method("GetTask"), None);
299        assert_eq!(ProtocolSide::A2a.wire_method("resources/list"), None);
300    }
301
302    #[test]
303    fn report_starts_empty_and_records_steps() {
304        let mut report = RequestCodecReport::empty();
305        assert!(report.is_empty());
306        report.record(RequestCodecStep::MethodRenamed {
307            from: "SendMessage",
308            to: "tools/call",
309        });
310        assert_eq!(report.len(), 1);
311    }
312
313    #[test]
314    fn report_display_lists_steps_in_order() {
315        let mut report = RequestCodecReport::empty();
316        report.record(RequestCodecStep::MethodRenamed {
317            from: "SendMessage",
318            to: "tools/call",
319        });
320        report.record(RequestCodecStep::ParamsTranslated {
321            from: "A2A message+metadata",
322            to: "MCP arguments",
323        });
324        let s = report.to_string();
325        assert!(s.contains("SendMessage → tools/call"));
326        assert!(s.contains("A2A message+metadata → MCP arguments"));
327    }
328}