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}