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}