Skip to main content

protocol_droid/adapters/secondary/transport/legacy_sse/
bridge_peer.rs

1//! Carries envelopes over a `2024-11-05` stream.
2//!
3//! ## Why this is a wrapper and not an `impl` on [`LegacySseClient`]
4//!
5//! The [`HttpClient`](crate::adapters::secondary::transport::http::HttpClient)
6//! peer is a plain `impl BridgePeer` on the client type, because an HTTP reply
7//! arrives in the response to the POST that asked for it. There is nothing to
8//! correlate and nowhere for an unsolicited frame to appear.
9//!
10//! This transport has both problems. `send` answers `202` with an empty body
11//! and the real answer arrives later as a `message` event on the stream, so
12//! `send` has to read until it finds the frame carrying *its own* id — and a
13//! frame that belongs to no call in flight, such as a server-pushed
14//! notification, arrives on the same stream and belongs to nobody. Neither
15//! needs new state on the client to handle, so the wrapper owns it.
16//!
17//! ## The id is the caller's, not ours
18//!
19//! Same rule as the HTTP peer, and for the same reason: a proxy that renumbers
20//! the id leaves the downstream client waiting for a reply carrying an id it
21//! never sent.
22//!
23//! ## An unsolicited frame is buffered, never dropped
24//!
25//! The crate's whole promise is that an unmodelled directive reaches the other
26//! side byte-for-byte. A notification the server pushed while the bridge waited
27//! for a call's answer is real traffic, and discarding it to make the
28//! correlation loop tidy would quietly violate that. It goes to
29//! [`take_pending`](Self::take_pending) instead, and the caller decides where it
30//! goes next.
31
32use std::sync::Arc;
33
34use serde_json::{Map, Value};
35
36use crate::application::bridge::{BridgeError, BridgePeer, SendFuture, Unsolicited};
37
38use super::{ClientFrame, LegacySseClient, LegacySseError};
39
40/// A `2024-11-05` peer that holds the frames nothing was waiting for.
41#[derive(Debug)]
42pub struct LegacySsePeer {
43    inner: Arc<LegacySseClient>,
44    unsolicited: Unsolicited,
45}
46
47impl LegacySsePeer {
48    /// Wraps a connected client.
49    #[must_use]
50    pub fn new(inner: Arc<LegacySseClient>) -> Self {
51        Self {
52            inner,
53            unsolicited: Unsolicited::new(),
54        }
55    }
56
57    /// The client underneath, for the parts of it a bridge does not use.
58    #[must_use]
59    pub const fn client(&self) -> &Arc<LegacySseClient> {
60        &self.inner
61    }
62
63    /// Takes every frame that arrived while some other call was in flight.
64    pub fn take_pending(&self) -> Vec<Map<String, Value>> {
65        self.unsolicited.take()
66    }
67}
68
69impl BridgePeer for LegacySsePeer {
70    /// Delivers one envelope and yields the frame carrying its answer.
71    ///
72    /// # Errors
73    ///
74    /// [`BridgeError::Unreachable`] when the POST failed, when the stream
75    /// closed before the answer arrived, or when the answer was not an object.
76    /// A peer that answered with an `error` member is *not* an error here: it
77    /// comes back through `Ok`, because a declined request is a successful
78    /// exchange.
79    fn send(
80        &self,
81        envelope: Map<String, Value>,
82    ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
83        Box::pin(async move {
84            // A notification has no id, so there is nothing to match the answer
85            // against and a `send` that tried would read the stream until it
86            // hung. `notify` is the method for that, and reaching here with no
87            // id is the caller using the wrong one.
88            let Some(id) = envelope.get("id").cloned() else {
89                return Err(BridgeError::Unreachable {
90                    reason: "a notification was sent through the request path".to_owned(),
91                });
92            };
93            self.inner
94                .send(&encode(&envelope)?)
95                .await
96                .map_err(|error| unreachable(&error))?;
97
98            loop {
99                match self
100                    .inner
101                    .next()
102                    .await
103                    .map_err(|error| unreachable(&error))?
104                {
105                    None => {
106                        return Err(BridgeError::Unreachable {
107                            reason: "the stream closed before the peer answered".to_owned(),
108                        });
109                    }
110                    // The `endpoint` event is how the client learned where to
111                    // post in the first place; a second one carries nothing
112                    // this call is waiting for.
113                    Some(ClientFrame::Endpoint(_)) => {}
114                    Some(ClientFrame::Message(value)) => {
115                        let Some(answer) = value.as_object().cloned() else {
116                            return Err(BridgeError::Unreachable {
117                                reason: "the peer answered with something that is not a JSON-RPC message"
118                                    .to_owned(),
119                            });
120                        };
121                        if answer.get("id") == Some(&id) {
122                            return Ok(answer);
123                        }
124                        // Someone else's frame, or an unsolicited notification.
125                        // Held rather than dropped — see the module docs.
126                        self.unsolicited.hold(answer);
127                    }
128                }
129            }
130        })
131    }
132
133    /// Delivers one envelope that expects no answer.
134    ///
135    /// # Errors
136    ///
137    /// [`BridgeError::Unreachable`] when the POST failed.
138    fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
139        Box::pin(async move {
140            self.inner
141                .send(&encode(&envelope)?)
142                .await
143                .map_err(|error| unreachable(&error))
144        })
145    }
146
147    /// Ends the upstream session.
148    ///
149    /// # Errors
150    ///
151    /// Never. `2024-11-05` has no teardown verb — the stream closing *is* the
152    /// teardown, and dropping the client drops it. Reporting a failure here
153    /// would be reporting on something the transport does not have.
154    fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
155        Box::pin(async { Ok(()) })
156    }
157
158    /// The frames the stream carried while another call was in flight.
159    ///
160    /// Overrides the trait's empty answer because `send` walks past every frame
161    /// that is not its own answer and holds it here rather than discarding it —
162    /// see [`send`](Self::send).
163    fn take_pending(&self) -> Vec<Map<String, Value>> {
164        Self::take_pending(self)
165    }
166
167    fn wait_pending(&self) -> SendFuture<'static, ()> {
168        // The queue is where the frames are, so waiting on the queue is
169        // waiting on the peer.
170        Box::pin(self.unsolicited.wait())
171    }
172}
173
174/// Serialises an envelope for the POST body.
175///
176/// Mapping by hand rather than with `?`: there is no `From<serde_json::Error>`
177/// into [`BridgeError`], because a failure to *encode* our own outbound
178/// envelope says nothing about the peer — it is a bridge-side fault, and the
179/// nearest honest thing to say to the downstream client is that the exchange
180/// could not be completed.
181fn encode(envelope: &Map<String, Value>) -> Result<Vec<u8>, BridgeError> {
182    serde_json::to_vec(&Value::Object(envelope.clone())).map_err(|error| BridgeError::Unreachable {
183        reason: format!("the envelope could not be encoded for the wire: {error}"),
184    })
185}
186
187/// Maps a transport failure onto the bridge's vocabulary.
188fn unreachable(error: &LegacySseError) -> BridgeError {
189    BridgeError::Unreachable {
190        reason: error.to_string(),
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use std::sync::Arc;
197    use std::sync::atomic::{AtomicU64, Ordering};
198
199    use serde_json::{Map, Value, json};
200
201    use crate::adapters::primary::transport::http::{Refusal, ServerConfig, SessionStore};
202    use crate::adapters::primary::transport::legacy_sse::{
203        DEFAULT_STREAM_PATH, LegacyDispatcher, LegacyRouter, LegacyTail,
204    };
205    use crate::application::bridge::{BridgeError, BridgePeer};
206    use crate::application::session::IdSource;
207    use crate::domain::McpSessionId;
208    use crate::domain::registry::AuthContext;
209    use crate::domain::session::SessionId;
210
211    use super::LegacySsePeer;
212    use crate::adapters::secondary::transport::legacy_sse::{LegacySseClient, LegacySseConfig};
213
214    type TestError = Box<dyn std::error::Error>;
215
216    struct Counting(AtomicU64);
217
218    impl IdSource for Counting {
219        fn next_session_id(&self) -> SessionId {
220            SessionId::new(self.0.fetch_add(1, Ordering::SeqCst))
221        }
222    }
223
224    /// The real `2024-11-05` router, so these tests are a claim about the two
225    /// halves of this crate agreeing rather than about a stub agreeing with a
226    /// stub. A peer that pushes an extra frame needs a dispatcher that does
227    /// that, and the real router has no reason to.
228    struct Pushes {
229        unsolicited: Option<Value>,
230        decline: bool,
231    }
232
233    impl LegacyDispatcher for Pushes {
234        type Session = ();
235
236        fn open(&self, _id: SessionId, _tail: &LegacyTail) {}
237
238        fn dispatch(
239            &self,
240            _auth: &AuthContext,
241            _session: &McpSessionId,
242            _state: &(),
243            frame: &Value,
244        ) -> impl core::future::Future<Output = Result<Vec<Value>, Refusal>> + Send {
245            core::future::ready({
246                let id = frame.get("id").cloned().unwrap_or(Value::Null);
247                let answer = if self.decline {
248                    json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": "no"}})
249                } else {
250                    json!({"jsonrpc": "2.0", "id": id, "result": {"ok": true}})
251                };
252                match self.unsolicited.clone() {
253                    Some(extra) => Ok(vec![extra, answer]),
254                    None => Ok(vec![answer]),
255                }
256            })
257        }
258    }
259
260    async fn peer(dispatcher: Pushes) -> Result<LegacySsePeer, TestError> {
261        let router = LegacyRouter::new(
262            ServerConfig::new()?,
263            Arc::new(SessionStore::new(Arc::new(Counting(AtomicU64::new(0))))),
264            dispatcher,
265        )
266        .into_router()?;
267
268        // Port 0 so two tests can run at once; a fixed port fails the moment
269        // anything else takes it, and looks like a transport bug.
270        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
271        let port = listener.local_addr()?.port();
272        tokio::spawn(async move {
273            let _ = axum::serve(listener, router).await;
274        });
275
276        let config =
277            LegacySseConfig::new(&format!("http://127.0.0.1:{port}{DEFAULT_STREAM_PATH}"))?;
278        let client = LegacySseClient::connect(config).await?;
279        Ok(LegacySsePeer::new(Arc::new(client)))
280    }
281
282    fn envelope(text: &str) -> Result<Map<String, Value>, TestError> {
283        Ok(serde_json::from_str(text)?)
284    }
285
286    /// The point of the transport: the answer comes back on the stream, carrying
287    /// the id the caller sent. Renumbering it would leave the downstream client
288    /// waiting for a reply it was never going to receive.
289    #[tokio::test]
290    async fn an_answer_comes_back_on_the_stream_carrying_the_callers_id() -> Result<(), TestError> {
291        let peer = peer(Pushes {
292            unsolicited: None,
293            decline: false,
294        })
295        .await?;
296
297        let answer = peer
298            .send(envelope(r#"{"jsonrpc":"2.0","id":41,"method":"ping"}"#)?)
299            .await?;
300
301        assert_eq!(answer.get("id"), Some(&json!(41)));
302        assert_eq!(answer.get("result"), Some(&json!({"ok": true})));
303        Ok(())
304    }
305
306    /// A frame that belongs to no call in flight is held, not dropped.
307    ///
308    /// The crate promises lossless forwarding, and a server-pushed
309    /// notification is real traffic. Discarding it to keep the correlation loop
310    /// tidy would violate that silently.
311    #[tokio::test]
312    async fn an_unsolicited_frame_is_held_rather_than_dropped() -> Result<(), TestError> {
313        let pushed = json!({
314            "jsonrpc": "2.0",
315            "method": "notifications/tools/list_changed"
316        });
317        let peer = peer(Pushes {
318            unsolicited: Some(pushed.clone()),
319            decline: false,
320        })
321        .await?;
322
323        let answer = peer
324            .send(envelope(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#)?)
325            .await?;
326
327        assert_eq!(answer.get("id"), Some(&json!(1)));
328        let held = peer.take_pending();
329        assert_eq!(held.len(), 1, "the pushed frame was not held");
330        assert_eq!(
331            held.first().and_then(|frame| frame.get("method")),
332            Some(&json!("notifications/tools/list_changed"))
333        );
334
335        // Draining, not cloning: the second poll must not hand it back again.
336        assert!(peer.take_pending().is_empty());
337        Ok(())
338    }
339
340    /// A declined request is a successful exchange, not a transport fault.
341    ///
342    /// Conflating the two tells the downstream client "network error" about a
343    /// request the server processed and deliberately refused.
344    #[tokio::test]
345    async fn a_refusal_comes_back_through_ok_carrying_the_error_member() -> Result<(), TestError> {
346        let peer = peer(Pushes {
347            unsolicited: None,
348            decline: true,
349        })
350        .await?;
351
352        let answer = peer
353            .send(envelope(
354                r#"{"jsonrpc":"2.0","id":9,"method":"tools/call"}"#,
355            )?)
356            .await?;
357
358        assert_eq!(
359            answer
360                .get("error")
361                .and_then(|error| error.get("code"))
362                .and_then(Value::as_i64),
363            Some(-32601)
364        );
365        Ok(())
366    }
367
368    /// A notification sent down the request path is refused, not waited on.
369    ///
370    /// It has no id, so the correlation loop would match nothing and read the
371    /// stream until it hung. `notify` is the method for it.
372    #[tokio::test]
373    async fn a_notification_on_the_request_path_is_refused() -> Result<(), TestError> {
374        let peer = peer(Pushes {
375            unsolicited: None,
376            decline: false,
377        })
378        .await?;
379
380        let outcome = peer
381            .send(envelope(
382                r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
383            )?)
384            .await;
385
386        assert!(
387            matches!(outcome, Err(BridgeError::Unreachable { .. })),
388            "a notification on the request path was not refused"
389        );
390        Ok(())
391    }
392
393    /// A notification really is deliverable: it posts and expects no answer.
394    #[tokio::test]
395    async fn a_notification_posts_without_waiting_for_a_reply() -> Result<(), TestError> {
396        let peer = peer(Pushes {
397            unsolicited: None,
398            decline: false,
399        })
400        .await?;
401
402        peer.notify(envelope(
403            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
404        )?)
405        .await?;
406        Ok(())
407    }
408}