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}