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(¶ms, &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}