Skip to main content

protocol_droid/application/bridge/
forward.rs

1//! The bridge use case: carry an envelope from one peer to another.
2//!
3//! ## Why this declares its own port
4//!
5//! [`ClientTransport`](crate::application::client::ClientTransport) is
6//! `(method, params) -> result`. That shape is right for an application that
7//! wants to call a tool, and it is the wrong shape for a bridge, for two
8//! reasons that only show up once you try to build one.
9//!
10//! A bridge moves *envelopes*, and a JSON-RPC envelope has members that are
11//! not parameters. `Mcp-Session-Id` is a header, but an unmodelled top-level
12//! member the peer invented is neither a method nor a parameter — it has
13//! nowhere to go in a `(method, params)` pair. Routing a bridge through that
14//! trait means dropping those members, which is exactly the lossless-forwarding
15//! guarantee this crate is built to keep.
16//!
17//! And a bridge must see a *refusal* as a first-class outcome. `ClientTransport`
18//! surfaces errors as a transport failure, which conflates "the peer said no"
19//! with "the peer could not be reached". A bridge that cannot tell those apart
20//! cannot forward the peer's own error envelope, and a client sees a network
21//! fault where it should have seen the server's explanation.
22//!
23//! So the port is declared here, in the consumer, at envelope granularity —
24//! which is also what the project's port rules ask for. An adapter implements
25//! it by holding a real transport; a test implements it with a recorder.
26//!
27//! ## The check that has to happen here
28//!
29//! A feature the two peers do not share must not be forwarded. Forwarding it
30//! anyway produces a failure the client cannot attribute: the call leaves the
31//! bridge, dies on the far side, and comes back as something that looks like a
32//! network problem. [`BridgeError::FeatureUnavailable`] refuses at the point
33//! where the cause is still known, which is the whole reason
34//! [`Agreement`] exists.
35
36use serde_json::{Map, Value};
37
38use crate::application::bridge::content::{ToolResultCodec, Translator};
39use crate::domain::Agreement;
40use crate::domain::ir::lower::LowerError;
41use crate::domain::ir::{
42    EnvelopeError, IrMessage, MethodName, TranslationReport, lift_envelope, lower_envelope,
43};
44use crate::domain::{DegradePolicy, Feature, ProtocolVersion};
45
46/// The future [`BridgePeer::send`] returns.
47///
48/// A type alias because the signature is long and appears three times — in the
49/// trait, in the `Arc` blanket impl, and in every implementor. Writing it out
50/// each time is where the two would drift apart, and a drift here is invisible
51/// until an adapter fails to satisfy the port.
52pub type SendFuture<'a, T> = std::pin::Pin<Box<dyn Future<Output = T> + Send + 'a>>;
53
54/// One side of the bridge, at envelope granularity.
55///
56/// Implementors hold a real transport. A test implementor holds a recorder.
57///
58/// # Example
59///
60/// ```
61/// use serde_json::{Map, Value};
62/// use protocol_droid::application::bridge::{BridgeError, BridgePeer, SendFuture};
63///
64/// #[derive(Default)]
65/// struct Recorder(std::sync::Mutex<Vec<Map<String, Value>>>);
66///
67/// impl BridgePeer for Recorder {
68///     fn send(
69///         &self,
70///         envelope: Map<String, Value>,
71///     ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
72///         Box::pin(async move {
73///             // Answer with the request's own id, which is what a peer does.
74///             let mut reply = Map::new();
75///             reply.insert("jsonrpc".into(), Value::String("2.0".into()));
76///             reply.insert(
77///                 "id".into(),
78///                 envelope.get("id").cloned().unwrap_or(Value::Null),
79///             );
80///             reply.insert("result".into(), Value::Object(Map::new()));
81///             Ok(reply)
82///         })
83///     }
84///
85///     fn notify(
86///         &self,
87///         _envelope: Map<String, Value>,
88///     ) -> SendFuture<'_, Result<(), BridgeError>> {
89///         Box::pin(async { Ok(()) })
90///     }
91///
92///     fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
93///         Box::pin(async { Ok(()) })
94///     }
95/// }
96///
97/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
98/// # let runtime = tokio::runtime::Builder::new_current_thread().build()?;
99/// runtime.block_on(async {
100///     let peer = Recorder::default();
101///     let request: Map<String, Value> = serde_json::from_str(
102///         r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#,
103///     )?;
104///     peer.send(request).await?;
105///     Ok::<(), Box<dyn std::error::Error>>(())
106/// })?;
107/// # Ok(())
108/// # }
109/// ```
110pub trait BridgePeer: Send + Sync {
111    /// Delivers one envelope and yields the peer's answer.
112    ///
113    /// # Errors
114    ///
115    /// A transport failure — the peer could not be reached, or the connection
116    /// closed. A *protocol* refusal comes back as a normal envelope with an
117    /// `error` member, not as an `Err`: those are two different things and a
118    /// bridge that merged them would report a network fault for a request the
119    /// server simply declined.
120    ///
121    /// Returns a boxed future rather than being an `async fn`. Native
122    /// `async fn` in a trait is RPITIT and not object-safe, and this port has
123    /// to be usable as `Arc<dyn BridgePeer>` — the adapter holds a live
124    /// connection whose lifetime the bridge does not know. A synchronous
125    /// signature would be worse still: every real peer is an async socket, so
126    /// it would force a blocking wait on the runtime thread.
127    fn send(
128        &self,
129        envelope: Map<String, Value>,
130    ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>>;
131
132    /// Delivers one envelope that expects no answer.
133    ///
134    /// Separate from [`send`](Self::send) because a notification has no `id`
135    /// and a conforming peer answers one with `202` and no body. A `send` that
136    /// waited for a reply would wait forever, and a `send` that did not wait
137    /// would have to invent the empty envelope it handed back — which is a
138    /// response the server never sent. `notifications/initialized` is one of
139    /// these, so a peer that could not notify had no way to finish connecting.
140    ///
141    /// # Errors
142    ///
143    /// [`BridgeError::Unreachable`] when the request could not be delivered.
144    /// Nothing else is possible: there is no reply to be wrong about.
145    fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>>;
146
147    /// Ends the conversation, flushing anything in flight.
148    ///
149    /// # Errors
150    ///
151    /// A transport failure on shutdown. Nothing is retried: a peer that has
152    /// already been told to stop is not going to change its mind.
153    fn close(&self) -> SendFuture<'_, Result<(), BridgeError>>;
154
155    /// Frames the peer received that are the answer to nothing the bridge asked.
156    ///
157    /// A server pushes. `notifications/tools/list_changed`,
158    /// `notifications/message`, `notifications/progress` and the rest arrive on
159    /// a stream the bridge is already reading, with no request of its own behind
160    /// them, and they belong to the client as much as a reply does — a client
161    /// whose server registers a tool mid-session learns about it through this,
162    /// or not at all. A bridge that forwards requests and drops these does not
163    /// translate versions; it silently deletes half the protocol.
164    ///
165    /// Draining rather than subscribing, because the peers that hold frames
166    /// already hold them in the read loop they were written for and a second
167    /// consumer would have to fight that loop for the socket. `StdioPeer` and
168    /// `LegacySsePeer` are the two that have anywhere to put them.
169    ///
170    /// ## The default is a true answer, not a shrug
171    ///
172    /// Empty is what a peer that only ever hears the answer to its own request
173    /// has, and [`HttpClient`](crate::adapters::secondary::transport::http::HttpClient)
174    /// is exactly that: it opens no standing stream, so a frame the server
175    /// volunteers has nowhere to arrive. An adapter that *does* buffer must
176    /// override this, and the symptom of forgetting is a client watching a tool
177    /// appear upstream and never hearing about it.
178    fn take_pending(&self) -> Vec<Map<String, Value>> {
179        Vec::new()
180    }
181
182    /// Resolves once the peer may have something for
183    /// [`take_pending`](Self::take_pending).
184    ///
185    /// ## Why the default never resolves
186    ///
187    /// A peer that opens no standing stream learns of a frame only while a call
188    /// is in flight, so by the time a caller could wait there is nothing new
189    /// and a wake-up would be a lie. Returning a future that never resolves
190    /// leaves the caller exactly where it was. An adapter that *does* read out
191    /// of band overrides this, and a caller waiting on it stops depending on
192    /// the client speaking first.
193    ///
194    /// `'static` rather than borrowing: the caller polls this alongside a
195    /// future borrowing its own source, and a borrow here would pin the peer
196    /// for the whole of that pair.
197    fn wait_pending(&self) -> SendFuture<'static, ()> {
198        Box::pin(std::future::pending())
199    }
200
201    /// Starts delivering frames the peer pushes on its own.
202    ///
203    /// ## Why this is separate from `wait_pending`
204    ///
205    /// Waiting and reading are different promises. A peer that only learns of a
206    /// frame mid-call has nothing to wait *for*; a peer whose transport carries
207    /// an out-of-band stream has to actually open it, and the caller cannot
208    /// tell which it was handed. So the caller asks once, after the peer
209    /// exists, and a peer that needs nothing says so.
210    ///
211    /// The default succeeds without doing anything, which is the true answer
212    /// for a peer that pushes only in response to a call.
213    ///
214    /// # Errors
215    ///
216    /// [`BridgeError::Unreachable`] when the out-of-band channel could not be
217    /// opened. Reported rather than swallowed: a caller that believes it is
218    /// listening and is not is a caller silently dropping traffic.
219    fn listen(&self) -> SendFuture<'_, Result<(), BridgeError>> {
220        Box::pin(async { Ok(()) })
221    }
222}
223
224/// A shared peer.
225///
226/// Blanket rather than per-adapter because sharing a peer is not a special
227/// capability — it is the same forwarding, reached through a refcount. An
228/// adapter that needs the peer behind an `Arc` for its own lifetime reasons
229/// would otherwise have to write this impl itself, once per adapter, and get it
230/// subtly different each time.
231impl<T: BridgePeer + ?Sized> BridgePeer for std::sync::Arc<T> {
232    fn send(
233        &self,
234        envelope: Map<String, Value>,
235    ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
236        (**self).send(envelope)
237    }
238
239    /// # Errors
240    ///
241    /// Whatever the inner peer reports.
242    fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
243        (**self).notify(envelope)
244    }
245
246    fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
247        (**self).close()
248    }
249
250    fn wait_pending(&self) -> SendFuture<'static, ()> {
251        (**self).wait_pending()
252    }
253
254    /// Forwarded, and not left to the trait default.
255    ///
256    /// Without this the queue is never drained through an `Arc`, while
257    /// `wait_pending` still resolves the moment anything is held — so the loop
258    /// wakes, finds nothing, and wakes again. That is a spin at full speed
259    /// rather than a stall, which is the worst of the two: it starves the
260    /// runtime and the frames it was woken for are never delivered.
261    fn take_pending(&self) -> Vec<Map<String, Value>> {
262        (**self).take_pending()
263    }
264
265    fn listen(&self) -> SendFuture<'_, Result<(), BridgeError>> {
266        (**self).listen()
267    }
268}
269
270/// Why a bridge could not carry an envelope.
271#[derive(Debug, thiserror::Error)]
272pub enum BridgeError {
273    /// The envelope is not a JSON-RPC message, or a member has the wrong type.
274    #[error("the envelope could not be read: {0}")]
275    Unreadable(#[from] EnvelopeError),
276
277    /// The peers never settled on the same feature.
278    #[error("neither peer can {feature:?}, so the bridge cannot forward it")]
279    FeatureUnavailable {
280        /// The feature that was asked for.
281        feature: Feature,
282    },
283
284    /// The upstream peer could not be reached, or hung up.
285    ///
286    /// Deliberately not carrying the peer's own error envelope: a protocol
287    /// refusal travels as a normal `Err`-free envelope, so reaching this arm
288    /// means the message genuinely did not get through.
289    #[error("the upstream peer could not be reached: {reason}")]
290    Unreachable {
291        /// What the transport reported.
292        reason: String,
293    },
294
295    /// Shutting the connection down failed.
296    #[error("the connection did not close cleanly: {0}")]
297    Close(String),
298
299    /// The reply could not be expressed at the downstream client's revision.
300    ///
301    /// Not a transport failure and not the upstream's refusal — the upstream
302    /// answered, and the answer is in a dialect the client did not negotiate.
303    /// Under a strict policy the bridge refuses rather than hand over something
304    /// the client cannot read; the cause names the feature and the revision so
305    /// the operator can tell a missing capability from a bug.
306    #[error("the downstream client cannot read the reply: {0}")]
307    NotReadable(#[from] LowerError),
308
309    /// The method is one this bridge's cross-protocol codec has no rule for.
310    ///
311    /// Distinct from [`FeatureUnavailable`](Self::FeatureUnavailable):
312    /// `FeatureUnavailable` means the two peers share no feature for the
313    /// method at all; `MethodNotBridged` means the bridge could in principle
314    /// carry it (the codec for the relevant protocol pair exists) but the
315    /// specific method is not modelled yet. The loop renders both as
316    /// `-32601 Method not found`, but the *operator* needs to tell them
317    /// apart: a `FeatureUnavailable` is a configuration gap, a
318    /// `MethodNotBridged` is a follow-up commit.
319    ///
320    /// Gated behind the `a2a` feature because the only bridge that
321    /// surfaces it is a cross-protocol one, and the type it carries
322    /// (`ProtocolSide`) is itself feature-gated. A default-features
323    /// build has no cross-protocol bridge and therefore no way to
324    /// produce this variant, so it is omitted from the enum rather
325    /// than carrying a degenerate form.
326    #[cfg(feature = "a2a")]
327    #[error("method {method:?} is not bridged on the {downstream:?} leg")]
328    MethodNotBridged {
329        /// The method the client called, by the wire name it sent.
330        method: String,
331        /// The protocol side the call came in on.
332        downstream: crate::application::bridge::ProtocolSide,
333    },
334}
335
336/// What one forward produced.
337#[derive(Debug)]
338pub struct Forwarded {
339    /// The envelope the upstream peer received.
340    pub outbound: Map<String, Value>,
341    /// The envelope that came back, in the client's revision.
342    ///
343    /// The upstream's own bytes unless its result needed translating, which is
344    /// the case [`Translator::translate`] decides for itself.
345    pub reply: Map<String, Value>,
346    /// What the translation changed, if anything.
347    pub report: TranslationReport,
348}
349
350/// Carries envelopes to `upstream` under one agreement and brings the answers
351/// back.
352///
353/// The second parameter is the codec the reply path translates through, injected
354/// like the peer. It is not defaulted: a default would have to name the crate's
355/// own adapter, and application code must not know which adapter it is wired
356/// to.
357///
358/// ## Why there is no downstream peer
359///
360/// A bridge used to take one, and the type said "carries envelopes from
361/// `downstream` to `upstream`". It never did: [`forward`](Self::forward) takes
362/// the envelope as an argument and returns the answer, which is what a call
363/// from a loop that already reads the client wants. The peer was reachable only
364/// through `client` — which nothing called — and `close`, so
365/// every caller passed a second handle on the client's connection that was
366/// never used to talk to the client and closed at the end on a guess.
367///
368/// The client channel belongs to whoever owns the client's connection, which is
369/// the loop: it reads frames from a source and writes replies to a sink. This
370/// type is the part that does not care.
371#[derive(Debug)]
372pub struct Bridge<U: BridgePeer, C> {
373    agreement: Agreement,
374    policy: DegradePolicy,
375    upstream: U,
376    translator: Translator<C>,
377}
378
379impl<U: BridgePeer, C: ToolResultCodec> Bridge<U, C> {
380    /// A bridge over a peer that has already agreed.
381    #[must_use]
382    pub const fn new(agreement: Agreement, policy: DegradePolicy, upstream: U, codec: C) -> Self {
383        Self {
384            agreement,
385            policy,
386            upstream,
387            translator: Translator::new(codec),
388        }
389    }
390
391    /// What the bridge can serve.
392    #[must_use]
393    pub const fn agreement(&self) -> &Agreement {
394        &self.agreement
395    }
396
397    /// The version the upstream peer speaks.
398    #[must_use]
399    pub const fn upstream_version(&self) -> ProtocolVersion {
400        self.agreement.upstream().version
401    }
402
403    /// The peer this bridge forwards through.
404    ///
405    /// The owner of the upstream connection, not a handle opened for
406    /// convenience: a bridge that could talk to the server by a second path
407    /// would have two owners of one session. Exposed because the frames a peer
408    /// receives unasked for belong to it and nobody else — see
409    /// [`BridgePeer::take_pending`].
410    #[must_use]
411    pub const fn upstream(&self) -> &U {
412        &self.upstream
413    }
414
415    /// Forwards one envelope and brings the answer back.
416    ///
417    /// A `tools/call` reply is translated into the client's revision on the way
418    /// back, and only if the client's revision cannot read what the server
419    /// said. Every other reply, and every reply that needs no translation, comes
420    /// back as the upstream wrote it — see
421    /// `content` for why rebuilding a
422    /// reply that was not asking to be rebuilt is a bug.
423    ///
424    /// # Errors
425    ///
426    /// [`BridgeError`] — the envelope was unreadable, the two peers share no
427    /// feature for the method asked, the upstream could not be reached, or the
428    /// reply could not be expressed at the client's revision. The method is
429    /// checked against the agreement *before* anything is sent, so a doomed
430    /// call never reaches the wire.
431    pub async fn forward(&self, envelope: &Map<String, Value>) -> Result<Forwarded, BridgeError> {
432        let message = lift_envelope(envelope)?;
433        self.check_method(&message)?;
434        let outbound = lower_envelope(&message);
435
436        // Whether the reply will need translating, decided on the parsed message
437        // rather than guessed from the envelope. `ToolResult` is the only typed
438        // content in the IR, so a reply to anything but `tools/call` has
439        // nothing to translate — and asking the codec about one anyway is how a
440        // `tools/list` reply gets rebuilt into something the client never sent.
441        let tool_call = message.method().map(MethodName::as_str) == Some(TOOL_CALL);
442
443        let mut report = TranslationReport::new(self.upstream_version());
444
445        // A notification has no id and gets no answer. Routing it through
446        // `send` would either wait forever or invent a reply, so it takes its
447        // own path — decided here on the parsed message, rather than by
448        // guessing from the envelope.
449        if matches!(message, IrMessage::Notification(_)) {
450            self.upstream.notify(outbound).await?;
451            return Ok(Forwarded {
452                outbound: lower_envelope(&message),
453                reply: Map::new(),
454                report,
455            });
456        }
457
458        let sent = self.upstream.send(outbound.clone()).await?;
459
460        // The reply arrives in the upstream's encoding. Handing it back as it is
461        // would be right whenever the client can read it, and it is only not
462        // right when the revisions disagree about the result — so that is the
463        // one case the translator is asked about, and it answers "nothing to do"
464        // for everything else.
465        let reply = if tool_call {
466            let translated = self.translator.translate(
467                sent,
468                self.upstream_version(),
469                self.agreement.downstream(),
470                self.policy,
471            )?;
472            report.absorb(&translated.report);
473            translated.reply
474        } else {
475            sent
476        };
477
478        Ok(Forwarded {
479            outbound,
480            reply,
481            report,
482        })
483    }
484
485    /// Refuses a method the two peers have no feature for.
486    ///
487    /// The check happens here, before the envelope is sent, so the refusal
488    /// names the cause while the cause is still knowable. Forwarding first and
489    /// reporting afterwards would produce a failure the client sees as a dead
490    /// server.
491    fn check_method(&self, message: &IrMessage) -> Result<(), BridgeError> {
492        let Some(method) = message.method() else {
493            // A reply arriving on the request path is a peer's business, not
494            // something the bridge should refuse on its behalf.
495            return Ok(());
496        };
497        let feature = feature_for(method.as_str());
498        match feature {
499            // An unmodelled method is forwarded untouched. The bridge cannot
500            // know whether the upstream implements it, and refusing here would
501            // break exactly the forward-compatible extensions the passthrough
502            // bag exists to carry.
503            None => Ok(()),
504            Some(feature) if self.agreement.serves(feature) => Ok(()),
505            Some(feature) => Err(BridgeError::FeatureUnavailable { feature }),
506        }
507    }
508
509    /// Ends the conversation with the upstream peer.
510    ///
511    /// The client's connection is not this type's to close: the loop that read
512    /// the frames opened it, and it ends when the client's stream does.
513    ///
514    /// # Errors
515    ///
516    /// [`BridgeError`] when the upstream refused to close.
517    pub async fn close(&self) -> Result<(), BridgeError> {
518        self.upstream.close().await
519    }
520}
521
522/// The one method whose reply carries typed content.
523///
524/// A constant rather than a literal in two places, because the reply path and
525/// the feature check have to agree on the spelling: if they ever disagreed, the
526/// bridge would either translate a reply that had nothing to translate or miss
527/// a reply that did — and neither failure looks like a typo at the call site.
528const TOOL_CALL: &str = "tools/call";
529
530/// The feature a well-known method needs, if any.
531fn feature_for(method: &str) -> Option<Feature> {
532    let (feature, name) = match method {
533        name if name == "tools/list" => (Feature::Tools, name),
534        name if name == TOOL_CALL => (Feature::Tools, name),
535        name if name == "resources/list" => (Feature::Resources, name),
536        name if name == "resources/read" => (Feature::Resources, name),
537        name if name == "prompts/list" => (Feature::Prompts, name),
538        name if name == "prompts/get" => (Feature::Prompts, name),
539        _ => return None,
540    };
541    debug_assert!(!name.is_empty());
542    Some(feature)
543}
544
545#[cfg(test)]
546mod tests {
547    use std::future::Future;
548    use std::pin::Pin;
549    use std::sync::{Arc, Mutex};
550
551    use serde_json::{Map, Value, json};
552
553    use crate::application::bridge::content::doubles::Counting;
554    use crate::domain::ir::{IrMessage, TranslationReport};
555    use crate::domain::{Agreement, Leg};
556    use crate::domain::{
557        DegradePolicy, Feature, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
558        resolve,
559    };
560
561    use super::{Bridge, BridgeError, BridgePeer, SendFuture};
562
563    type TestError = Box<dyn std::error::Error>;
564
565    /// A codec for the tests that are not about translation.
566    ///
567    /// Reads nothing, so every reply passes through untouched. That is not a
568    /// dodge: most of these tests' replies are `{"ok": true}` or a bare list,
569    /// neither of which is a `CallToolResult`, so their assertions never
570    /// depended on the contents and still do not.
571    fn codec() -> Arc<Counting> {
572        Arc::new(Counting::reading_nothing())
573    }
574
575    /// A peer that records what it was sent and answers with a canned reply.
576    #[derive(Default)]
577    struct Recorder {
578        seen: Mutex<Vec<Map<String, Value>>>,
579    }
580
581    impl Recorder {
582        fn seen(&self) -> Result<Vec<Map<String, Value>>, BridgeError> {
583            self.seen
584                .lock()
585                .map(|seen| seen.clone())
586                .map_err(|_| BridgeError::Close("recorder poisoned".to_owned()))
587        }
588    }
589
590    /// The tests need to see what the upstream peer was sent *after* the
591    /// bridge has taken ownership of it, so the recorder is shared.
592    type Shared = std::sync::Arc<Recorder>;
593
594    impl BridgePeer for Recorder {
595        fn send(
596            &self,
597            envelope: Map<String, Value>,
598        ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
599            Box::pin(async move {
600                let id = envelope.get("id").cloned().unwrap_or(Value::Null);
601                let Ok(mut seen) = self.seen.lock() else {
602                    return Err(BridgeError::Close("recorder poisoned".to_owned()));
603                };
604                seen.push(envelope);
605                let mut reply = Map::new();
606                reply.insert("jsonrpc".into(), json!("2.0"));
607                reply.insert("id".into(), id);
608                reply.insert("result".into(), json!({"content": []}));
609                Ok(reply)
610            })
611        }
612
613        /// Records the notification too, and that is the whole point: without
614        /// it a test cannot tell "delivered to the peer's notify" from "dropped
615        /// on the floor", and both look like a successful send.
616        fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
617            Box::pin(async move {
618                let Ok(mut seen) = self.seen.lock() else {
619                    return Err(BridgeError::Close("recorder poisoned".to_owned()));
620                };
621                seen.push(envelope);
622                Ok(())
623            })
624        }
625
626        fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
627            Box::pin(async { Ok(()) })
628        }
629    }
630
631    /// A peer that is simply not there.
632    struct Absent;
633
634    impl BridgePeer for Absent {
635        fn send(
636            &self,
637            _envelope: Map<String, Value>,
638        ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
639            Box::pin(async {
640                Err(BridgeError::Unreachable {
641                    reason: "connection refused".to_owned(),
642                })
643            })
644        }
645
646        fn notify(&self, _envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
647            Box::pin(async {
648                Err(BridgeError::Unreachable {
649                    reason: "connection refused".to_owned(),
650                })
651            })
652        }
653
654        fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
655            Box::pin(async { Ok(()) })
656        }
657    }
658
659    fn negotiation(version: &str, transport: TransportKind) -> Result<Negotiation, TestError> {
660        Ok(Negotiation::bare(version.parse()?, transport))
661    }
662
663    fn serving(features: &[Feature]) -> FeatureProfile {
664        features
665            .iter()
666            .fold(FeatureProfile::empty(), |p, f| p.with(*f))
667    }
668
669    fn envelope(text: &str) -> Result<Map<String, Value>, TestError> {
670        let object: Map<String, Value> = serde_json::from_str(text)?;
671        Ok(object)
672    }
673
674    /// The task's headline test in miniature: an unmodelled directive on a
675    /// `tools/call` must arrive at the upstream peer intact, on the wire, not
676    /// merely in the parsed IR.
677    #[tokio::test]
678    async fn an_unmodelled_directive_reaches_the_upstream_peer() -> Result<(), TestError> {
679        let upstream: Shared = Recorder::default().into();
680        let sent = envelope(
681            r#"{"jsonrpc":"2.0","id":7,"method":"tools/call",
682                "params":{"name":"echo","_meta":{"com.example/trace":{"span":12}}},
683                "x-vendor-directive":{"anything":[1,2,3]}}"#,
684        )?;
685
686        let bridge = Bridge::new(
687            Agreement::between(
688                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
689                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
690            ),
691            DegradePolicy::default(),
692            upstream.clone(),
693            codec(),
694        );
695
696        bridge.forward(&sent).await?;
697
698        let seen = upstream.seen()?;
699        let received = seen.first().ok_or("the upstream peer saw nothing")?;
700        let params = received
701            .get("params")
702            .and_then(Value::as_object)
703            .ok_or("no params")?;
704
705        assert_eq!(
706            params
707                .get("_meta")
708                .and_then(|meta| meta.get("com.example/trace")),
709            Some(&json!({"span": 12})),
710            "the _meta directive was stripped"
711        );
712        assert_eq!(
713            received.get("x-vendor-directive"),
714            Some(&json!({"anything": [1, 2, 3]})),
715            "the unmodelled top-level member was stripped"
716        );
717        Ok(())
718    }
719
720    /// A feature neither side has must be refused *before* it reaches the wire,
721    /// so the failure is attributable rather than looking like a dead server.
722    #[tokio::test]
723    async fn a_feature_neither_side_has_never_reaches_the_wire() -> Result<(), TestError> {
724        let upstream: Shared = Recorder::default().into();
725        let bridge = Bridge::new(
726            Agreement::between(
727                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
728                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
729            ),
730            DegradePolicy::default(),
731            upstream.clone(),
732            codec(),
733        );
734
735        let refusal = bridge
736            .forward(&envelope(
737                r#"{"jsonrpc":"2.0","id":1,"method":"prompts/list"}"#,
738            )?)
739            .await
740            .map(|_| ());
741
742        assert!(
743            matches!(
744                refusal,
745                Err(BridgeError::FeatureUnavailable {
746                    feature: Feature::Prompts
747                })
748            ),
749            "expected FeatureUnavailable(Prompts), got {refusal:?}"
750        );
751        assert!(
752            upstream.seen()?.is_empty(),
753            "the doomed request was sent anyway"
754        );
755        Ok(())
756    }
757
758    /// A method this build does not model is forwarded untouched. Refusing it
759    /// would break the forward-compatible extensions the passthrough exists for.
760    #[tokio::test]
761    async fn an_unmodelled_method_is_forwarded_untouched() -> Result<(), TestError> {
762        let upstream: Shared = Recorder::default().into();
763        let bridge = Bridge::new(
764            Agreement::between(
765                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
766                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
767            ),
768            DegradePolicy::default(),
769            upstream.clone(),
770            codec(),
771        );
772
773        bridge
774            .forward(&envelope(
775                r#"{"jsonrpc":"2.0","id":2,"method":"experimental/quantum"}"#,
776            )?)
777            .await?;
778
779        let seen = upstream.seen()?;
780        let received = seen.first().ok_or("nothing was forwarded")?;
781        assert_eq!(received.get("method"), Some(&json!("experimental/quantum")));
782        Ok(())
783    }
784
785    /// An upstream that cannot be reached is an error carrying the reason, and
786    /// is distinct from a peer that answered with a refusal.
787    /// A notification has no id and gets no answer. Routing it through `send`
788    /// would either wait forever or invent an envelope the server never sent,
789    /// so it must reach the peer's `notify` and come back with no reply.
790    #[tokio::test]
791    async fn a_notification_is_delivered_without_inventing_a_reply() -> Result<(), TestError> {
792        let upstream: Shared = Recorder::default().into();
793        let bridge = Bridge::new(
794            Agreement::between(
795                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
796                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
797            ),
798            DegradePolicy::default(),
799            upstream.clone(),
800            codec(),
801        );
802
803        let sent = envelope(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)?;
804        let forwarded = bridge.forward(&sent).await?;
805
806        assert!(
807            forwarded.reply.is_empty(),
808            "a notification came back with a reply envelope: {:?}",
809            forwarded.reply
810        );
811        let seen = upstream.seen()?;
812        assert_eq!(seen.len(), 1, "the notification did not reach the peer");
813        Ok(())
814    }
815
816    /// A *request* still comes back with the peer's real answer, so the
817    /// notification path did not swallow the other case.
818    #[tokio::test]
819    async fn a_request_still_brings_an_answer_back() -> Result<(), TestError> {
820        let bridge = Bridge::new(
821            Agreement::between(
822                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
823                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
824            ),
825            DegradePolicy::default(),
826            Recorder::default(),
827            codec(),
828        );
829
830        let sent = envelope(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#)?;
831        let forwarded = bridge.forward(&sent).await?;
832
833        assert!(
834            forwarded.reply.get("result").is_some(),
835            "a request came back with no result: {:?}",
836            forwarded.reply
837        );
838        Ok(())
839    }
840
841    #[tokio::test]
842    async fn an_unreachable_peer_is_an_error_not_a_silent_drop() -> Result<(), TestError> {
843        let bridge = Bridge::new(
844            Agreement::between(
845                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
846                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
847            ),
848            DegradePolicy::default(),
849            Absent,
850            codec(),
851        );
852
853        let outcome = bridge
854            .forward(&envelope(
855                r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
856            )?)
857            .await;
858
859        match outcome {
860            Err(BridgeError::Unreachable { reason }) => {
861                assert_eq!(reason, "connection refused");
862                Ok(())
863            }
864            other => Err(format!("expected Unreachable, got {other:?}").into()),
865        }
866    }
867
868    /// A peer's own refusal arrives as a normal envelope, and the bridge hands
869    /// it back unchanged rather than re-wrapping it as a transport fault.
870    #[tokio::test]
871    async fn a_peer_refusal_comes_back_as_an_envelope() -> Result<(), TestError> {
872        struct Refusing;
873        impl BridgePeer for Refusing {
874            fn send(
875                &self,
876                envelope: Map<String, Value>,
877            ) -> Pin<Box<dyn Future<Output = Result<Map<String, Value>, BridgeError>> + Send + '_>>
878            {
879                Box::pin(async move {
880                    let id = envelope.get("id").cloned().unwrap_or(Value::Null);
881                    let mut reply = Map::new();
882                    reply.insert("jsonrpc".into(), json!("2.0"));
883                    reply.insert("id".into(), id);
884                    reply.insert(
885                        "error".into(),
886                        json!({"code": -32601, "message": "no such method"}),
887                    );
888                    Ok(reply)
889                })
890            }
891            fn notify(
892                &self,
893                _envelope: Map<String, Value>,
894            ) -> SendFuture<'_, Result<(), BridgeError>> {
895                Box::pin(async { Ok(()) })
896            }
897            fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
898                Box::pin(async { Ok(()) })
899            }
900        }
901
902        let bridge = Bridge::new(
903            Agreement::between(
904                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
905                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
906            ),
907            DegradePolicy::default(),
908            Refusing,
909            codec(),
910        );
911
912        let forwarded = bridge
913            .forward(&envelope(
914                r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
915            )?)
916            .await?;
917
918        let error = forwarded
919            .reply
920            .get("error")
921            .and_then(Value::as_object)
922            .ok_or("the refusal was not forwarded")?;
923        assert_eq!(error.get("code"), Some(&json!(-32601)));
924        Ok(())
925    }
926
927    /// A bridge whose peers agree reports no translation loss. The report
928    /// exists for the case where they do not, and must not cry wolf otherwise.
929    #[tokio::test]
930    async fn a_lossless_forward_reports_nothing() -> Result<(), TestError> {
931        let bridge = Bridge::new(
932            Agreement::between(
933                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
934                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
935            ),
936            DegradePolicy::default(),
937            Recorder::default(),
938            codec(),
939        );
940
941        let forwarded = bridge
942            .forward(&envelope(
943                r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
944            )?)
945            .await?;
946
947        assert!(
948            forwarded.report.is_empty(),
949            "a lossless forward reported {:?}",
950            forwarded.report.steps()
951        );
952        Ok(())
953    }
954
955    /// The agreement's `resolve` path still produces two independent profiles.
956    #[test]
957    fn independent_negotiations_stay_independent() -> Result<(), TestError> {
958        let older = negotiation("2024-11-05", TransportKind::LegacySse)?;
959        let newer = negotiation("2025-03-26", TransportKind::StreamableHttp)?;
960
961        let agreement = Agreement::between(
962            Leg::new(older.version, resolve(older)),
963            Leg::new(newer.version, resolve(newer)),
964        );
965
966        assert_eq!(agreement.downstream().version.as_wire(), "2024-11-05");
967        assert_eq!(agreement.upstream().version.as_wire(), "2025-03-26");
968        Ok(())
969    }
970
971    /// Every forwarded envelope lifts and lowers back to itself, so the
972    /// handler cannot quietly mangle one.
973    #[tokio::test]
974    async fn an_envelope_survives_the_handler_intact() -> Result<(), TestError> {
975        let sent = envelope(
976            r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"x"},"tail":1}"#,
977        )?;
978
979        let bridge = Bridge::new(
980            Agreement::between(
981                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
982                Leg::new(ProtocolVersion::latest_known(), serving(&[Feature::Tools])),
983            ),
984            DegradePolicy::default(),
985            Recorder::default(),
986            codec(),
987        );
988
989        let forwarded = bridge.forward(&sent).await?;
990        let rebuilt = forwarded.outbound;
991        assert!(matches!(lift(&rebuilt)?, IrMessage::Request(_)));
992        assert_eq!(rebuilt.get("tail"), Some(&json!(1)), "a member was lost");
993        Ok(())
994    }
995
996    use crate::domain::ir::lift_envelope as lift;
997
998    fn _report_is_used(report: &TranslationReport) -> bool {
999        report.is_empty()
1000    }
1001}