Skip to main content

protocol_droid/application/bridge/
downstream.rs

1//! The downstream leg: what a bridge promises a client it can do.
2//!
3//! A bridge is a server to the client in front of it and a client to the
4//! server behind it. The upstream leg is negotiated by the caller before this
5//! runs — it has to dial out, run a handshake, and hold a live connection. The
6//! downstream leg arrives as a frame on a socket the caller is already reading,
7//! and this is where it is settled.
8//!
9//! # Why the advertisement is not `CapabilitySet::all_for(version)`
10//!
11//! A server answers `initialize` with everything it can do at the negotiated
12//! revision. A bridge must not. Behind the bridge is a specific upstream
13//! session with a specific feature profile, and the client is being told what
14//! *this bridge, right now, in front of that server* can do. Advertising
15//! everything the revision defines would tell a client it could read resources
16//! when the server behind the bridge has none, and the failure would surface
17//! much later as a request the bridge answers `-32601` to — after the client
18//! had every reason to believe the feature was there.
19//!
20//! So the bridge's own server capabilities are the upstream's advertised set,
21//! and the client's offer narrows that further. What survives both is what
22//! [`Agreement::served`] reports and what `InitializeResult.capabilities`
23//! carries, which makes the two the same number computed once rather than two
24//! numbers that can drift.
25//!
26//! # Why the bridge names itself
27//!
28//! `serverInfo` is the bridge's, not the upstream's. Passing the upstream's
29//! through would tell a client it is talking to the process it named when the
30//! peer on the other end of the socket is a proxy — and a client that
31//! deliberately dials a bridge is making a routing decision that depends on
32//! knowing that.
33
34use crate::application::mcp::initialize::{read_offer, server_capabilities_json};
35use crate::domain::ir::{JsonObject, JsonObjectError};
36use crate::domain::{
37    Agreement, FeatureProfile, Leg, Negotiation, ProtocolVersion, SupportedVersions, TransportKind,
38    handshake_defined_in, negotiate_server, resolve,
39};
40
41/// What this bridge presents to a client, before any client has connected.
42///
43/// Fixed for the life of the deployment, unlike the negotiation itself: which
44/// revisions are acceptable, which transport the client arrived over, and what
45/// this bridge calls itself are properties of the running process, not of any
46/// one session. Bundling them here is what lets [`negotiate`] take the one
47/// thing that varies per connection and nothing else.
48///
49/// # Examples
50///
51/// ```
52/// use protocol_droid::application::bridge::{DownstreamConfig, DownstreamError};
53/// use protocol_droid::domain::TransportKind;
54///
55/// let config = DownstreamConfig::new("bridge", "0.1.0", TransportKind::Stdio)?;
56/// assert_eq!(config.transport(), TransportKind::Stdio);
57/// # Ok::<(), DownstreamError>(())
58/// ```
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct DownstreamConfig {
61    supported: SupportedVersions,
62    transport: TransportKind,
63    identity: Identity,
64}
65
66impl DownstreamConfig {
67    /// A config accepting every revision this build speaks.
68    ///
69    /// # Errors
70    ///
71    /// [`DownstreamError::UnnegotiableVersion`] if the build has no revisions
72    /// at all. The list is a build-time constant and the specification has
73    /// defined at least one since 2024, so this is a formality rather than a
74    /// branch — but a caller building a config at startup deserves a real error
75    /// rather than a panic further in.
76    pub fn new(
77        name: impl Into<String>,
78        version: impl Into<String>,
79        transport: TransportKind,
80    ) -> Result<Self, DownstreamError> {
81        let supported =
82            SupportedVersions::all().map_err(|_| DownstreamError::UnnegotiableVersion)?;
83        Ok(Self::with(
84            supported,
85            Identity::new(name, version),
86            transport,
87        ))
88    }
89
90    /// A config restricted to `supported`, naming itself `identity`.
91    ///
92    /// For a bridge deliberately held to fewer revisions than the build
93    /// supports — an operator who knows a client is broken at a revision and
94    /// would rather see a counter-offer than a mis-negotiation.
95    #[must_use]
96    pub const fn with(
97        supported: SupportedVersions,
98        identity: Identity,
99        transport: TransportKind,
100    ) -> Self {
101        Self {
102            supported,
103            transport,
104            identity,
105        }
106    }
107
108    /// The transport a client reaches this bridge over.
109    ///
110    /// Part of the negotiation and not an incidental detail: the same feature
111    /// set can resolve differently over stdio than over HTTP, because the
112    /// transport gates features of its own.
113    #[must_use]
114    pub const fn transport(&self) -> TransportKind {
115        self.transport
116    }
117
118    /// The revisions this bridge will accept downstream.
119    #[must_use]
120    pub const fn supported(&self) -> &SupportedVersions {
121        &self.supported
122    }
123
124    /// What this bridge calls itself.
125    #[must_use]
126    pub const fn identity(&self) -> &Identity {
127        &self.identity
128    }
129}
130
131/// What this bridge calls itself in `serverInfo`.
132///
133/// A value object rather than two loose `&str` parameters so the two fields
134/// stay together — they are the two members of one wire object, and a signature
135/// that let a caller interleave two unrelated strings was one argument-swap
136/// away from answering with somebody else's name.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Identity {
139    name: String,
140    version: String,
141}
142
143impl Identity {
144    /// A name and a version, both free-form on the wire.
145    #[must_use]
146    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
147        Self {
148            name: name.into(),
149            version: version.into(),
150        }
151    }
152
153    /// The bridge's name.
154    #[must_use]
155    pub fn name(&self) -> &str {
156        &self.name
157    }
158
159    /// The bridge's version.
160    #[must_use]
161    pub fn version(&self) -> &str {
162        &self.version
163    }
164}
165
166/// A settled downstream leg: what to answer, and what the bridge may forward.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Downstream {
169    leg: Leg,
170    agreement: Agreement,
171    body: JsonObject,
172}
173
174impl Downstream {
175    /// The client's side of the bridge, on its own.
176    ///
177    /// The [`Agreement`] is the useful form for forwarding; this is the one a
178    /// report wants, because it is the two numbers that went into the
179    /// reconciliation without everything downstream of them.
180    #[must_use]
181    pub const fn leg(&self) -> &Leg {
182        &self.leg
183    }
184
185    /// Both sides reconciled: what this bridge can serve, and what it cannot.
186    #[must_use]
187    pub const fn agreement(&self) -> &Agreement {
188        &self.agreement
189    }
190
191    /// The `InitializeResult` to send back.
192    ///
193    /// Its `capabilities` are [`Agreement::served`] rendered through the
194    /// writer, so the client is told exactly what the bridge will forward.
195    #[must_use]
196    pub const fn body(&self) -> &JsonObject {
197        &self.body
198    }
199
200    /// What this bridge will serve for this client.
201    #[must_use]
202    pub const fn served(&self) -> FeatureProfile {
203        self.agreement.served()
204    }
205}
206
207/// Why a downstream handshake could not be settled.
208///
209/// Distinct from an upstream that cannot be reached: the upstream failed before
210/// [`negotiate`] was ever called, and this type is only ever about what the
211/// client in front of us said.
212#[derive(Debug, thiserror::Error)]
213pub enum DownstreamError {
214    /// The `initialize` request was not readable as an offer.
215    ///
216    /// Collapsed to one variant for the same reason
217    /// [`InitializeError`](crate::application::mcp::initialize::InitializeError)
218    /// is: the caller answers `-32600` either way, and a proxy reporting which
219    /// of six shapes failed would be telling the client about the proxy's
220    /// internals.
221    #[error("the initialize request was not readable as an offer")]
222    Malformed,
223
224    /// The negotiation settled on a revision with no handshake.
225    ///
226    /// SEP-2575 removed `initialize` from `2026-07-28`, and
227    /// [`negotiate_server`] echoes a revision it supports — so a client asking
228    /// for exactly that one reaches here. It cannot on the path below, because
229    /// such a client is walked back to the newest revision that has a
230    /// handshake. It stays in the signature for the one case that walk-back
231    /// cannot cover: a bridge restricted to revisions with no handshake at all,
232    /// where there is nothing to walk back to.
233    #[error("{0} has no initialize handshake to answer")]
234    NoHandshake(ProtocolVersion),
235
236    /// This build has no revisions to negotiate over at all.
237    #[error("this build supports no revisions that define a handshake")]
238    UnnegotiableVersion,
239
240    /// The reply could not be assembled into an object.
241    ///
242    /// Cannot happen for the body built below, which is a literal object of
243    /// string members — but `from_value` is fallible and the honest signature
244    /// is the one that says so. Inventing a second error for it would be a
245    /// promise this function cannot keep.
246    #[error("the initialize result could not be assembled: {0}")]
247    Assemble(#[from] JsonObjectError),
248}
249
250/// Settles the downstream leg against an already-negotiated upstream.
251///
252/// Reads the client's offer with the same code a server uses, so a bridge and
253/// a server cannot disagree about what a client is allowed to say, then
254/// reconciles the two legs through [`Agreement::between`].
255///
256/// The two things returned are not two views of one value. The [`Agreement`] is
257/// what the bridge may forward; the body is the wire form of it. A caller that
258/// forwarded against the downstream [`Leg`] alone would be forwarding features it
259/// had just told the client it does not have.
260///
261/// Takes the upstream as a [`Leg`] rather than an [`Agreement`] because that is
262/// all it is: the upstream negotiated with the server when the bridge connected,
263/// and the client that will supply the other leg has not arrived yet. Accepting
264/// an `Agreement` would have asked the caller to invent a downstream leg they do
265/// not have, and every invented leg is one more place the served profile can
266/// disagree with the advertised one.
267///
268/// # Errors
269///
270/// [`DownstreamError::Malformed`] for an `initialize` that is not an offer, and
271/// [`DownstreamError::NoHandshake`] if there is no revision with a handshake to
272/// answer with.
273///
274/// # Examples
275///
276/// ```
277/// use protocol_droid::application::bridge::{negotiate_downstream, DownstreamConfig};
278/// use protocol_droid::domain::ir::JsonObject;
279/// use protocol_droid::domain::{FeatureProfile, Leg, ProtocolVersion, TransportKind};
280///
281/// # fn example(upstream: Leg) -> Result<(), Box<dyn std::error::Error>> {
282/// let config = DownstreamConfig::new("bridge", "0.1.0", TransportKind::Stdio)?;
283/// let params = JsonObject::new(
284///     r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe"}}"#,
285/// )?;
286///
287/// let downstream = negotiate_downstream(&params, &upstream, &config)?;
288///
289/// // The advertisement is the agreement, never the client's own leg.
290/// assert_eq!(downstream.served(), downstream.agreement().served());
291/// # let _reply = downstream.body().as_str();
292/// # let _ = FeatureProfile::empty();
293/// # let _ = ProtocolVersion::V2025_06_18;
294/// # Ok(())
295/// # }
296/// ```
297pub fn negotiate(
298    params: &JsonObject,
299    upstream: &Leg,
300    config: &DownstreamConfig,
301) -> Result<Downstream, DownstreamError> {
302    let offer = read_offer(params).map_err(|_| DownstreamError::Malformed)?;
303    let negotiated = negotiate_server(offer.version, &config.supported).version();
304
305    // `negotiate_server` echoes a revision it supports, including the one that
306    // removed `initialize` — but the fact that this request arrived at all is
307    // proof the client speaks the handshake. A client is therefore walked back
308    // to the newest revision that has one rather than refused: it asked a
309    // question it can only be answered in one shape, and the answer exists at
310    // a revision it demonstrably speaks.
311    let version = if handshake_defined_in(negotiated) {
312        negotiated
313    } else {
314        config.supported.latest_handshake()
315    };
316    if !handshake_defined_in(version) {
317        return Err(DownstreamError::NoHandshake(version));
318    }
319
320    // The bridge's own capabilities are the upstream's, not this build's. See
321    // the module docs: advertising everything the revision defines is how a
322    // bridge tells a client it can read resources from a server that has none.
323    let profile = resolve(Negotiation {
324        version,
325        client_caps: offer.capabilities,
326        server_caps: upstream.profile.advertised_by_server(),
327        transport: config.transport,
328    });
329
330    let leg = Leg::new(version, profile);
331    let agreement = Agreement::between(leg, *upstream);
332
333    let body = serde_json::json!({
334        "protocolVersion": version.as_wire(),
335        "capabilities": server_capabilities_json(agreement.served()),
336        "serverInfo": {
337            "name": config.identity.name(),
338            "version": config.identity.version(),
339        },
340    });
341
342    Ok(Downstream {
343        leg,
344        agreement,
345        body: JsonObject::from_value(body)?,
346    })
347}
348
349#[cfg(test)]
350mod tests {
351    use super::{DownstreamConfig, DownstreamError, Identity, negotiate};
352    use crate::domain::ir::JsonObject;
353    use crate::domain::{
354        Capability, CapabilitySet, Feature, Leg, Negotiation, ProtocolVersion, TransportKind,
355        resolve,
356    };
357
358    /// What a test here can fail with.
359    #[derive(Debug)]
360    enum Failure {
361        /// The fixture was not a JSON object.
362        Fixture(crate::domain::ir::JsonObjectError),
363        /// The test's own complaint.
364        Message(String),
365    }
366
367    impl core::fmt::Display for Failure {
368        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
369            match self {
370                Self::Fixture(error) => write!(f, "bad fixture: {error}"),
371                Self::Message(text) => f.write_str(text),
372            }
373        }
374    }
375
376    impl std::error::Error for Failure {}
377
378    impl From<crate::domain::ir::JsonObjectError> for Failure {
379        fn from(error: crate::domain::ir::JsonObjectError) -> Self {
380            Self::Fixture(error)
381        }
382    }
383
384    impl From<String> for Failure {
385        fn from(text: String) -> Self {
386            Self::Message(text)
387        }
388    }
389
390    impl From<DownstreamError> for Failure {
391        fn from(error: DownstreamError) -> Self {
392            Self::Message(format!("the downstream leg was refused: {error}"))
393        }
394    }
395
396    fn params(text: &str) -> Result<JsonObject, Failure> {
397        JsonObject::new(text).map_err(Failure::from)
398    }
399
400    /// The upstream leg a bridge has before any client arrives: everything this
401    /// process can do, narrowed by what the server behind it agreed to.
402    fn upstream_agreeing(version: ProtocolVersion, server_caps: CapabilitySet) -> Leg {
403        Leg::new(
404            version,
405            resolve(Negotiation {
406                version,
407                client_caps: CapabilitySet::all_for(version),
408                server_caps,
409                transport: TransportKind::StreamableHttp,
410            }),
411        )
412    }
413
414    fn config() -> Result<DownstreamConfig, Failure> {
415        DownstreamConfig::new("bridge", "0.1.0", TransportKind::Stdio)
416            .map_err(|e| Failure::Message(format!("a stdio bridge could not be configured: {e}")))
417    }
418
419    /// The whole point of the module: what the upstream can do and what the
420    /// client asked for, and nothing else.
421    ///
422    /// A bridge that advertised `all_for(version)` here would pass a test that
423    /// only checked the reply parses, and then refuse a `resources/read` the
424    /// client had every reason to attempt.
425    #[test]
426    fn the_advertisement_is_the_intersection_not_the_revision() -> Result<(), Failure> {
427        let version = ProtocolVersion::V2025_06_18;
428        let upstream = upstream_agreeing(version, CapabilitySet::empty().with(Capability::Tools));
429        let body = params(
430            r#"{"protocolVersion":"2025-06-18","capabilities":{"roots":{}},"clientInfo":{"name":"p"}}"#,
431        )?;
432
433        let downstream = negotiate(&body, &upstream, &config()?)?;
434
435        assert!(
436            downstream.agreement().serves(Feature::Tools),
437            "the one feature the upstream offers vanished"
438        );
439        assert!(
440            !downstream.agreement().serves(Feature::Resources),
441            "the bridge advertised a feature the upstream never agreed to"
442        );
443        let advertised = downstream.body().as_str();
444        assert!(
445            !advertised.contains("\"resources\""),
446            "the reply contradicts the agreement: {advertised}"
447        );
448        Ok(())
449    }
450
451    /// The client's own capability set narrows it further. A client with no
452    /// `roots` cannot be served roots even though the upstream has them, so
453    /// advertising them would be a promise the bridge has no path to keep.
454    #[test]
455    fn the_clients_offer_narrows_it_too() -> Result<(), Failure> {
456        let version = ProtocolVersion::V2025_06_18;
457        let upstream = upstream_agreeing(version, CapabilitySet::all_for(version));
458        let body = params(
459            r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"p"}}"#,
460        )?;
461
462        let downstream = negotiate(&body, &upstream, &config()?)?;
463
464        assert!(
465            !downstream.agreement().serves(Feature::Roots),
466            "a client that never offered roots was promised them"
467        );
468        Ok(())
469    }
470
471    /// The bridge names itself, and says so in the reply.
472    ///
473    /// A client that deliberately dials a bridge is making a routing decision,
474    /// and passing the upstream's `serverInfo` through would tell it it is
475    /// talking to the process it named.
476    #[test]
477    fn the_reply_carries_the_bridges_own_name() -> Result<(), Failure> {
478        let version = ProtocolVersion::V2025_06_18;
479        let upstream = upstream_agreeing(version, CapabilitySet::all_for(version));
480        let body = params(
481            r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"p"}}"#,
482        )?;
483
484        let reply = negotiate(&body, &upstream, &config()?)?
485            .body()
486            .as_str()
487            .to_owned();
488
489        assert!(reply.contains(r#""name":"bridge""#), "{reply}");
490        assert!(reply.contains(r#""version":"0.1.0""#), "{reply}");
491        Ok(())
492    }
493
494    /// The revision answered is the negotiated one, never the one the client
495    /// asked for, and never the newest this build knows.
496    ///
497    /// `2025-03-26` is the case: a client offering only that gets that, while a
498    /// client offering a revision from the future is walked back to something
499    /// both sides speak.
500    #[test]
501    fn the_reply_names_the_negotiated_revision() -> Result<(), Failure> {
502        let version = ProtocolVersion::V2025_06_18;
503        let upstream = upstream_agreeing(version, CapabilitySet::all_for(version));
504
505        let older = params(
506            r#"{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"p"}}"#,
507        )?;
508        assert_eq!(
509            negotiate(&older, &upstream, &config()?)?.leg().version,
510            ProtocolVersion::V2025_03_26
511        );
512
513        let future = params(
514            r#"{"protocolVersion":"2099-01-01","capabilities":{},"clientInfo":{"name":"p"}}"#,
515        )?;
516        let walked_back = negotiate(&future, &upstream, &config()?)?;
517        assert_ne!(
518            walked_back.leg().version,
519            ProtocolVersion::latest_known(),
520            "the newest revision has no handshake, so it cannot be the answer"
521        );
522        Ok(())
523    }
524
525    /// A malformed offer is refused before anything is promised.
526    #[test]
527    fn a_malformed_offer_is_refused() -> Result<(), Failure> {
528        let version = ProtocolVersion::V2025_06_18;
529        let upstream = upstream_agreeing(version, CapabilitySet::all_for(version));
530        let body = params(r#"{"capabilities":{},"clientInfo":{"name":"p"}}"#)?;
531
532        assert!(matches!(
533            negotiate(&body, &upstream, &config()?),
534            Err(DownstreamError::Malformed)
535        ));
536        Ok(())
537    }
538
539    /// A config restricted to one revision counter-offers rather than silently
540    /// accepting a client the operator said not to serve.
541    #[test]
542    fn a_restricted_config_only_answers_with_what_it_supports() -> Result<(), Failure> {
543        let supported = crate::domain::SupportedVersions::new([ProtocolVersion::V2025_03_26])
544            .map_err(|e| Failure::Message(format!("a one-revision set was refused: {e}")))?;
545        let held = DownstreamConfig::with(
546            supported,
547            Identity::new("bridge", "0.1.0"),
548            TransportKind::Stdio,
549        );
550        let upstream = upstream_agreeing(ProtocolVersion::V2025_03_26, CapabilitySet::empty());
551        let body = params(
552            r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"p"}}"#,
553        )?;
554
555        assert_eq!(
556            negotiate(&body, &upstream, &held)?.leg().version,
557            ProtocolVersion::V2025_03_26
558        );
559        Ok(())
560    }
561}