Skip to main content

protocol_droid/domain/bridge/
mod.rs

1//! What a bridge can honestly promise.
2//!
3//! A bridge sits between two peers that negotiated independently. The client
4//! on one side agreed to a set of features with the bridge; the server on the
5//! other side agreed to a set with the bridge. Neither negotiation saw the
6//! other, and neither one is the truth on its own.
7//!
8//! ## Why the intersection is the only honest answer
9//!
10//! A bridge that advertised the *downstream* profile would be promising the
11//! client things it cannot deliver. The client sees `tools/call` supported, so
12//! it calls — and the request dies somewhere behind the bridge that the client
13//! cannot see and has no way to attribute. That is strictly worse than a
14//! refusal at handshake time, where the client learns the truth before it acts.
15//!
16//! So the bridge advertises the intersection, and keeps every feature it had to
17//! drop together with the side that refused it. A dropped feature with a
18//! recorded reason is a limitation an operator can see; a dropped feature
19//! with no record is a bug that only shows up as a failed tool call.
20//!
21//! ## Two versions, not one
22//!
23//! The downstream and upstream sessions each pin their own
24//! [`ProtocolVersion`]. They are frequently different — that is the whole
25//! reason to run a bridge — and they are stored separately rather than
26//! collapsed, because "the client speaks 2025-03-26 and the server speaks
27//! 2026-07-28" is exactly the fact an operator needs when a message is
28//! translated.
29//!
30//! ## What a bridge does *not* claim
31//!
32//! Agreement is not reachability. Both peers negotiated with the bridge, and
33//! the bridge has still not proved either one is currently answering. A bridge
34//! that reported success here would be reporting on a connection it has not
35//! made.
36
37use core::fmt;
38
39use crate::domain::{Feature, FeatureProfile, ProtocolVersion, resolve};
40
41#[cfg(feature = "a2a")]
42pub mod a2a_mcp;
43
44/// The two sides of a bridge, each having negotiated with it independently.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Leg {
47    /// The revision this side settled on.
48    pub version: ProtocolVersion,
49    /// What that side agreed to, after resolving its own capabilities.
50    pub profile: FeatureProfile,
51}
52
53impl Leg {
54    /// A leg from a version and an already-resolved profile.
55    #[must_use]
56    pub const fn new(version: ProtocolVersion, profile: FeatureProfile) -> Self {
57        Self { version, profile }
58    }
59}
60
61/// A feature the bridge cannot serve, and which side refused it.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct Withheld {
64    /// The feature neither side can provide.
65    pub feature: Feature,
66    /// Which side said no.
67    pub refused_by: RefusedBy,
68}
69
70/// Which of the two peers could not provide a feature.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum RefusedBy {
73    /// The server, behind the bridge, cannot do it.
74    Upstream,
75    /// The client, in front of the bridge, cannot do it.
76    Downstream,
77}
78
79impl fmt::Display for RefusedBy {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::Upstream => formatter.write_str("the upstream server"),
83            Self::Downstream => formatter.write_str("the downstream client"),
84        }
85    }
86}
87
88/// Both sides, and what the bridge can therefore serve.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct Agreement {
91    downstream: Leg,
92    upstream: Leg,
93    served: FeatureProfile,
94    withheld: Vec<Withheld>,
95}
96
97impl Agreement {
98    /// Reconciles two independently negotiated legs.
99    ///
100    /// Pure and total: any two legs produce an agreement, including two that
101    /// share no features at all. Refusing to build one in that case would leave
102    /// the caller with nothing to report on, and "nothing in common" is exactly
103    /// the situation where an operator most needs to be told.
104    #[must_use]
105    pub fn between(downstream: Leg, upstream: Leg) -> Self {
106        let served = downstream.profile.intersection(upstream.profile);
107
108        // Downstream is checked first so a feature *neither* side has is
109        // attributed to one side rather than to both. Both would be true, but
110        // an operator reading "the client cannot do this" and then "the server
111        // cannot do this" for the same feature reads as a bug in the report.
112        let withheld = downstream
113            .profile
114            .difference(served)
115            .iter()
116            .map(|feature| Withheld {
117                feature,
118                refused_by: RefusedBy::Downstream,
119            })
120            .chain(
121                upstream
122                    .profile
123                    .difference(served)
124                    .iter()
125                    .map(|feature| Withheld {
126                        feature,
127                        refused_by: RefusedBy::Upstream,
128                    }),
129            )
130            .collect();
131
132        Self {
133            downstream,
134            upstream,
135            served,
136            withheld,
137        }
138    }
139
140    /// The client side of the bridge.
141    #[must_use]
142    pub const fn downstream(&self) -> &Leg {
143        &self.downstream
144    }
145
146    /// The server side of the bridge.
147    #[must_use]
148    pub const fn upstream(&self) -> &Leg {
149        &self.upstream
150    }
151
152    /// What the bridge can actually serve: the intersection.
153    ///
154    /// This is what gets advertised downstream. Anything else is a promise the
155    /// bridge cannot keep.
156    #[must_use]
157    pub const fn served(&self) -> FeatureProfile {
158        self.served
159    }
160
161    /// The features dropped, and which side refused each.
162    #[must_use]
163    pub fn withheld(&self) -> &[Withheld] {
164        &self.withheld
165    }
166
167    /// Whether `feature` can be served, in both directions.
168    #[must_use]
169    pub fn serves(&self, feature: Feature) -> bool {
170        self.served.allows(feature)
171    }
172
173    /// Whether the two sides settled on the same revision.
174    ///
175    /// Not required for a bridge — translating between revisions is its whole
176    /// purpose — but a bridge answering `true` has nothing to translate and is
177    /// usually a misconfiguration, so it is worth asking.
178    #[must_use]
179    pub fn same_version(&self) -> bool {
180        self.downstream.version == self.upstream.version
181    }
182
183    /// A one-line summary for a log or a `--json` field.
184    #[must_use]
185    pub fn summary(&self) -> String {
186        format!(
187            "client {} / server {} — {} features served, {} withheld",
188            self.downstream.version,
189            self.upstream.version,
190            self.served.iter().count(),
191            self.withheld.len()
192        )
193    }
194}
195
196/// Resolves both legs from their negotiations and reconciles them.
197///
198/// The convenience path for the common case where the caller has
199/// [`Negotiation`](crate::domain::Negotiation) values rather than resolved
200/// profiles. Both legs are resolved *independently* — that is the point, and
201/// collapsing them into one negotiation would produce a profile nobody agreed
202/// to.
203#[must_use]
204pub fn agree(
205    downstream: crate::domain::Negotiation,
206    upstream: crate::domain::Negotiation,
207) -> Agreement {
208    Agreement::between(
209        Leg::new(downstream.version, resolve(downstream)),
210        Leg::new(upstream.version, resolve(upstream)),
211    )
212}
213
214#[cfg(test)]
215mod tests {
216    use crate::domain::{
217        Feature, FeatureProfile, Negotiation, ProtocolVersion, TransportKind, agree,
218    };
219
220    use super::{Agreement, Leg, RefusedBy};
221
222    type TestError = Box<dyn std::error::Error>;
223
224    fn profile_with(features: &[Feature]) -> FeatureProfile {
225        features
226            .iter()
227            .fold(FeatureProfile::empty(), |profile, feature| {
228                profile.with(*feature)
229            })
230    }
231
232    /// The whole reason this type exists: a feature the client supports and
233    /// the server does not must not be advertised, because the client would
234    /// then use it and the failure would surface as a dead tool call.
235    #[test]
236    fn a_feature_only_the_client_has_is_not_served() {
237        let agreement = Agreement::between(
238            Leg::new(
239                ProtocolVersion::latest_known(),
240                profile_with(&[Feature::Tools, Feature::Resources]),
241            ),
242            Leg::new(
243                ProtocolVersion::latest_known(),
244                profile_with(&[Feature::Tools]),
245            ),
246        );
247
248        assert!(
249            agreement.serves(Feature::Tools),
250            "the shared feature vanished"
251        );
252        assert!(
253            !agreement.serves(Feature::Resources),
254            "the bridge advertised a feature the server cannot serve"
255        );
256    }
257
258    /// Every dropped feature names the side that refused it. A withheld list
259    /// with no reason is the failure mode this type was written to prevent.
260    #[test]
261    fn each_withheld_feature_names_its_refuser() {
262        let agreement = Agreement::between(
263            Leg::new(
264                ProtocolVersion::latest_known(),
265                profile_with(&[Feature::Tools, Feature::Resources]),
266            ),
267            Leg::new(
268                ProtocolVersion::latest_known(),
269                profile_with(&[Feature::Tools, Feature::Prompts]),
270            ),
271        );
272
273        let refused: Vec<(&str, RefusedBy)> = agreement
274            .withheld()
275            .iter()
276            .map(|entry| (entry.feature.as_str(), entry.refused_by))
277            .collect();
278
279        assert!(
280            refused.contains(&("resources", RefusedBy::Downstream)),
281            "the client's own feature was not attributed to the client: {refused:?}"
282        );
283        assert!(
284            refused.contains(&("prompts", RefusedBy::Upstream)),
285            "the server's missing feature was not attributed to the server: {refused:?}"
286        );
287    }
288
289    /// Two sides with nothing in common still produce an agreement, because
290    /// "these share nothing" is the report an operator most needs.
291    #[test]
292    fn sides_with_nothing_in_common_still_agree() {
293        let agreement = Agreement::between(
294            Leg::new(
295                ProtocolVersion::latest_known(),
296                profile_with(&[Feature::Resources]),
297            ),
298            Leg::new(
299                ProtocolVersion::latest_known(),
300                profile_with(&[Feature::Prompts]),
301            ),
302        );
303
304        assert!(agreement.served().is_empty(), "something was invented");
305        assert_eq!(agreement.withheld().len(), 2);
306    }
307
308    /// Identical legs withhold nothing. A non-empty withheld list here would
309    /// mean the intersection is wrong.
310    #[test]
311    fn identical_legs_withhold_nothing() {
312        let features = profile_with(&[Feature::Tools, Feature::Resources, Feature::Prompts]);
313        let agreement = Agreement::between(
314            Leg::new(ProtocolVersion::latest_known(), features),
315            Leg::new(ProtocolVersion::latest_known(), features),
316        );
317
318        assert!(
319            agreement.withheld().is_empty(),
320            "{:?}",
321            agreement.withheld()
322        );
323    }
324
325    /// The two versions are stored apart. Collapsing them would lose the one
326    /// fact an operator needs when a message is translated.
327    #[test]
328    fn the_two_versions_are_kept_apart() -> Result<(), TestError> {
329        let negotiation = |version| Negotiation::bare(version, TransportKind::StreamableHttp);
330        let newer = ProtocolVersion::latest_known();
331        let older = "2024-11-05".parse()?;
332
333        let agreement = agree(negotiation(older), negotiation(newer));
334
335        assert_eq!(agreement.downstream().version, older);
336        assert_eq!(agreement.upstream().version, newer);
337        assert!(!agreement.same_version());
338        Ok(())
339    }
340}