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}