Skip to main content

mcpkit_server/
adapter_peer.rs

1//! Server→client request/response peer for the HTTP adapters (#153).
2//!
3//! The stdio runtime has had server-initiated requests since #111 via
4//! `TransportPeer`; the HTTP adapters could not make them at all (every
5//! `Context` got a `NoOpPeer`). This module is the shared primitive that
6//! closes the gap:
7//!
8//! - [`SessionOutbound`] — per-session outbound-request state: id
9//!   allocation and response correlation. Also used by the stdio runtime's
10//!   `ServerState`, so there is exactly one correlation implementation.
11//! - [`OutboundOwner`] — the session map's exclusive owner token; dropping
12//!   it fails all pending requests. Deliberately separate from the `Arc`
13//!   the peers clone: a `Drop` on the shared `Arc` could only fire once no
14//!   waiter exists, i.e. exactly when it has nothing to do.
15//! - [`SessionSink`] — how a peer delivers a message to the session's SSE
16//!   stream(s); implemented per adapter over the session's
17//!   [`StreamRegistry`](crate::streams::StreamRegistry).
18//! - [`SessionPeer`] — the [`Peer`] implementation handlers see:
19//!   notifications are best-effort (stored for replay, never an error when
20//!   no stream is open); requests fail fast on a missing stream after a
21//!   bounded reconnect grace, and time out per method class.
22
23use crate::context::Peer;
24use futures::channel::oneshot;
25use mcpkit_core::error::McpError;
26use mcpkit_core::protocol::{Message, Notification, Request, RequestId, Response};
27use std::borrow::Cow;
28use std::collections::HashMap;
29use std::future::Future;
30use std::pin::Pin;
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{Arc, RwLock};
33use std::time::{Duration, Instant};
34
35/// How long a request tolerates the session having no live SSE stream.
36///
37/// Covers the ordinary reconnect blip before failing fast. Fixed by design:
38/// above the ~3s conventional SSE retry, and the adapters emit `retry: 2000`,
39/// so client cadence is dictated rather than guessed.
40pub const RECONNECT_GRACE: Duration = Duration::from_secs(5);
41
42/// How often the no-stream watcher re-checks for a live stream.
43const GRACE_POLL: Duration = Duration::from_millis(100);
44
45// ============================================================================
46// Correlation registry
47// ============================================================================
48
49/// Per-session outbound-request state: id allocation + response correlation.
50///
51/// The same shape the stdio runtime's `ServerState` uses (which now delegates
52/// here): plain incrementing numeric ids — JSON-RPC ids are per-sender, so
53/// they cannot collide with client-chosen ids — and a oneshot per pending
54/// request.
55#[derive(Debug, Default)]
56pub struct SessionOutbound {
57    next_id: AtomicU64,
58    pending: RwLock<HashMap<RequestId, oneshot::Sender<Response>>>,
59}
60
61impl SessionOutbound {
62    /// Create an empty registry.
63    #[must_use]
64    pub fn new() -> Self {
65        Self {
66            next_id: AtomicU64::new(1),
67            pending: RwLock::new(HashMap::new()),
68        }
69    }
70
71    /// Allocate a unique id for a server-initiated request.
72    #[must_use]
73    pub fn next_id(&self) -> RequestId {
74        RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed))
75    }
76
77    /// Register a pending request, returning the receiver that resolves when
78    /// the matching response arrives.
79    #[must_use]
80    pub fn register(&self, id: RequestId) -> oneshot::Receiver<Response> {
81        let (tx, rx) = oneshot::channel();
82        if let Ok(mut pending) = self.pending.write() {
83            pending.insert(id, tx);
84        }
85        rx
86    }
87
88    /// Drop a pending request (e.g. on timeout or cancellation).
89    pub fn remove(&self, id: &RequestId) {
90        if let Ok(mut pending) = self.pending.write() {
91            pending.remove(id);
92        }
93    }
94
95    /// Route an inbound response to the request waiting for it. Returns
96    /// `false` when no pending request matches (late or unknown id — the
97    /// caller logs and drops, matching the stdio runtime).
98    pub fn resolve(&self, response: Response) -> bool {
99        let sender = self
100            .pending
101            .write()
102            .ok()
103            .and_then(|mut pending| pending.remove(&response.id));
104        match sender {
105            Some(sender) => {
106                let _ = sender.send(response);
107                true
108            }
109            None => false,
110        }
111    }
112
113    /// Fail every pending request (session terminated, expired, or the
114    /// transport closed). Dropping the senders resolves the waiting
115    /// receivers with an error.
116    pub fn fail_all(&self) {
117        if let Ok(mut pending) = self.pending.write() {
118            pending.clear();
119        }
120    }
121}
122
123/// Exclusive owner token for a session's [`SessionOutbound`].
124///
125/// Held only by the session map; dropping it (session reap, DELETE, store
126/// teardown) fails all pending requests so waiting hooks resolve immediately
127/// instead of running out their timeout.
128///
129/// Peers clone the inner [`Arc`] — never the owner — so a waiter's own clone
130/// cannot keep the failure from firing.
131#[derive(Debug)]
132pub struct OutboundOwner(Arc<SessionOutbound>);
133
134impl OutboundOwner {
135    /// Create an owner (and its registry).
136    #[must_use]
137    pub fn new() -> Self {
138        Self(Arc::new(SessionOutbound::new()))
139    }
140
141    /// The shared registry, for cloning into peers and response routing.
142    #[must_use]
143    pub fn outbound(&self) -> &Arc<SessionOutbound> {
144        &self.0
145    }
146}
147
148impl Default for OutboundOwner {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154impl Drop for OutboundOwner {
155    fn drop(&mut self) {
156        self.0.fail_all();
157    }
158}
159
160// ============================================================================
161// Sink
162// ============================================================================
163
164/// Why a sink could not deliver a message.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum SinkError {
167    /// The session has no live SSE stream to deliver a request on.
168    /// Matchable so hooks can degrade deliberately.
169    NoClientStream,
170    /// The message could not be serialized.
171    Serialization(String),
172}
173
174impl std::fmt::Display for SinkError {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self {
177            Self::NoClientStream => {
178                write!(
179                    f,
180                    "client has no open SSE stream; server-initiated requests require one"
181                )
182            }
183            Self::Serialization(e) => write!(f, "failed to serialize message: {e}"),
184        }
185    }
186}
187
188impl std::error::Error for SinkError {}
189
190/// How a peer delivers a message to the session's client stream(s).
191///
192/// Implemented per adapter (a thin wrapper over the session's
193/// [`StreamRegistry`](crate::streams::StreamRegistry)). Boxed futures,
194/// deliberately matching [`Peer`]'s own shape: the trait must be dyn-able
195/// because the peer is threaded through erased call paths, while each
196/// adapter's sink is a per-crate type.
197pub trait SessionSink: Send + Sync {
198    /// Store-and-forward a notification. MUST NOT error when no stream is
199    /// open (mirrors the runtime: a client without a stream simply misses
200    /// best-effort notifications; the event is stored for replay).
201    fn send_notification(
202        &self,
203        message: Message,
204    ) -> Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send + '_>>;
205
206    /// Deliver a server-initiated request on the session's designated live
207    /// stream. Pure predicate: fails immediately with
208    /// [`SinkError::NoClientStream`] when no live stream is registered — the
209    /// reconnect grace lives in [`SessionPeer`], which owns the deadline.
210    fn send_request(
211        &self,
212        message: Message,
213    ) -> Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send + '_>>;
214
215    /// Whether the session currently has a live SSE stream (drives the
216    /// mid-flight reconnect grace).
217    fn has_live_stream(&self) -> bool;
218}
219
220/// The standard adapter sink: delivers peer messages onto a session's
221/// [`StreamRegistry`](crate::streams::StreamRegistry). Framework-free —
222/// every HTTP adapter uses this same implementation.
223#[cfg(feature = "tokio")]
224pub struct StreamRegistrySink {
225    registry: Arc<crate::streams::StreamRegistry>,
226}
227
228#[cfg(feature = "tokio")]
229impl StreamRegistrySink {
230    /// Create a sink over a session's stream registry.
231    #[must_use]
232    pub fn new(registry: Arc<crate::streams::StreamRegistry>) -> Self {
233        Self { registry }
234    }
235}
236
237#[cfg(feature = "tokio")]
238impl SessionSink for StreamRegistrySink {
239    fn send_notification(
240        &self,
241        message: Message,
242    ) -> Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send + '_>> {
243        Box::pin(async move {
244            let json = serde_json::to_string(&message)
245                .map_err(|e| SinkError::Serialization(e.to_string()))?;
246            // Best-effort: with no live stream the notification is dropped
247            // (runtime parity — a client without a stream misses it).
248            let _ = self.registry.send("message", json);
249            Ok(())
250        })
251    }
252
253    fn send_request(
254        &self,
255        message: Message,
256    ) -> Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send + '_>> {
257        Box::pin(async move {
258            let json = serde_json::to_string(&message)
259                .map_err(|e| SinkError::Serialization(e.to_string()))?;
260            self.registry
261                .send("message", json)
262                .map(|_| ())
263                .ok_or(SinkError::NoClientStream)
264        })
265    }
266
267    fn has_live_stream(&self) -> bool {
268        self.registry.has_live_stream()
269    }
270}
271
272// ============================================================================
273// Peer
274// ============================================================================
275
276/// Request timeouts by method class.
277///
278/// One number cannot serve both: elicitation is human-in-the-loop (60s is
279/// short), while `roots/list` is a machine round-trip (60s is long). The
280/// timeout is resolved inside [`SessionPeer`] by method name because
281/// `Context` funnels every server-initiated request through
282/// `Peer::request(method, params)`, which has no timeout parameter.
283#[derive(Debug, Clone, Copy)]
284pub struct PeerTimeouts {
285    /// Timeout for machine round-trips (everything but elicitation).
286    pub default: Duration,
287    /// Timeout for `elicitation/*` requests (a human answers these).
288    pub elicitation: Duration,
289}
290
291impl Default for PeerTimeouts {
292    fn default() -> Self {
293        Self {
294            default: Duration::from_secs(60),
295            elicitation: Duration::from_secs(300),
296        }
297    }
298}
299
300impl PeerTimeouts {
301    fn resolve(&self, method: &str) -> Duration {
302        if method.starts_with("elicitation/") {
303            self.elicitation
304        } else {
305            self.default
306        }
307    }
308}
309
310/// A request-capable [`Peer`] for one adapter session.
311pub struct SessionPeer {
312    sink: Arc<dyn SessionSink>,
313    outbound: Arc<SessionOutbound>,
314    timeouts: PeerTimeouts,
315    grace: Duration,
316}
317
318impl SessionPeer {
319    /// Create a peer over a session's sink and outbound registry.
320    #[must_use]
321    pub fn new(
322        sink: Arc<dyn SessionSink>,
323        outbound: Arc<SessionOutbound>,
324        timeouts: PeerTimeouts,
325    ) -> Self {
326        Self {
327            sink,
328            outbound,
329            timeouts,
330            grace: RECONNECT_GRACE,
331        }
332    }
333
334    /// Override the reconnect grace. Test hook — the grace is a fixed
335    /// constant by design.
336    #[doc(hidden)]
337    #[must_use]
338    pub fn with_reconnect_grace(mut self, grace: Duration) -> Self {
339        self.grace = grace;
340        self
341    }
342
343    /// Resolves when the session has had no live stream for `grace`
344    /// continuously (checked every [`GRACE_POLL`]).
345    async fn no_stream_for_grace(sink: Arc<dyn SessionSink>, grace: Duration) {
346        let mut none_since: Option<Instant> = None;
347        loop {
348            if sink.has_live_stream() {
349                none_since = None;
350            } else {
351                let since = *none_since.get_or_insert_with(Instant::now);
352                if since.elapsed() >= grace {
353                    return;
354                }
355            }
356            mcpkit_transport::runtime::sleep(GRACE_POLL).await;
357        }
358    }
359}
360
361impl std::fmt::Debug for SessionPeer {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        f.debug_struct("SessionPeer")
364            .field("timeouts", &self.timeouts)
365            .field("grace", &self.grace)
366            .finish_non_exhaustive()
367    }
368}
369
370impl Peer for SessionPeer {
371    fn notify(
372        &self,
373        notification: Notification,
374    ) -> Pin<Box<dyn Future<Output = Result<(), McpError>> + Send + '_>> {
375        let sink = Arc::clone(&self.sink);
376        Box::pin(async move {
377            sink.send_notification(Message::Notification(notification))
378                .await
379                .map_err(|e| McpError::internal(e.to_string()))
380        })
381    }
382
383    fn request(
384        &self,
385        method: Cow<'static, str>,
386        params: Option<serde_json::Value>,
387    ) -> Pin<Box<dyn Future<Output = Result<Response, McpError>> + Send + '_>> {
388        let sink = Arc::clone(&self.sink);
389        let outbound = Arc::clone(&self.outbound);
390        let timeout = self.timeouts.resolve(&method);
391        let grace = self.grace;
392        Box::pin(async move {
393            use futures::future::{Either, select};
394
395            let started = Instant::now();
396            let id = outbound.next_id();
397            let rx = outbound.register(id.clone());
398            let request = match params {
399                Some(p) => Request::with_params(method, id.clone(), p),
400                None => Request::new(method, id.clone()),
401            };
402            let message = Message::Request(request);
403
404            // Send-time reconnect grace: a session whose stream dropped a
405            // moment ago gets `grace` to come back before we fail fast.
406            let send_deadline = grace.min(timeout);
407            loop {
408                match sink.send_request(message.clone()).await {
409                    Ok(()) => break,
410                    Err(SinkError::NoClientStream) if started.elapsed() < send_deadline => {
411                        mcpkit_transport::runtime::sleep(GRACE_POLL).await;
412                    }
413                    Err(e) => {
414                        outbound.remove(&id);
415                        return Err(McpError::internal(e.to_string()));
416                    }
417                }
418            }
419
420            // Await the response, bounded by the per-method timeout, failing
421            // early if the session goes streamless for a full grace window
422            // mid-flight (the request may be sitting undelivered in a dead
423            // stream's replay buffer; a client that already consumed it and
424            // answers via POST resolves `rx` before the watcher can fire).
425            let remaining = timeout.saturating_sub(started.elapsed());
426            let deadline = mcpkit_transport::runtime::sleep(remaining);
427            let watcher = Self::no_stream_for_grace(Arc::clone(&sink), grace);
428            futures::pin_mut!(deadline);
429            futures::pin_mut!(watcher);
430            let interrupt = select(deadline, watcher);
431            match select(rx, interrupt).await {
432                Either::Left((Ok(response), _)) => Ok(response),
433                Either::Left((Err(_canceled), _)) => {
434                    outbound.remove(&id);
435                    Err(McpError::internal("session closed before a reply arrived"))
436                }
437                Either::Right((Either::Left(((), _)), _)) => {
438                    outbound.remove(&id);
439                    Err(McpError::internal(format!(
440                        "server-initiated request timed out after {timeout:?}"
441                    )))
442                }
443                Either::Right((Either::Right(((), _)), _)) => {
444                    outbound.remove(&id);
445                    Err(McpError::internal(
446                        "client has had no open SSE stream for the reconnect grace; \
447                         server-initiated request abandoned",
448                    ))
449                }
450            }
451        })
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use std::sync::Mutex;
459    use std::sync::atomic::AtomicBool;
460
461    /// Mock sink: delivery recorded; liveness toggleable.
462    struct MockSink {
463        live: AtomicBool,
464        sent: Mutex<Vec<Message>>,
465    }
466
467    impl MockSink {
468        fn new(live: bool) -> Arc<Self> {
469            Arc::new(Self {
470                live: AtomicBool::new(live),
471                sent: Mutex::new(Vec::new()),
472            })
473        }
474    }
475
476    impl SessionSink for MockSink {
477        fn send_notification(
478            &self,
479            message: Message,
480        ) -> Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send + '_>> {
481            // Contract: notifications never fail on "no stream".
482            self.sent.lock().unwrap().push(message);
483            Box::pin(async { Ok(()) })
484        }
485        fn send_request(
486            &self,
487            message: Message,
488        ) -> Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send + '_>> {
489            if self.live.load(Ordering::SeqCst) {
490                self.sent.lock().unwrap().push(message);
491                Box::pin(async { Ok(()) })
492            } else {
493                Box::pin(async { Err(SinkError::NoClientStream) })
494            }
495        }
496        fn has_live_stream(&self) -> bool {
497            self.live.load(Ordering::SeqCst)
498        }
499    }
500
501    fn peer(sink: Arc<MockSink>) -> SessionPeer {
502        SessionPeer::new(
503            sink,
504            Arc::new(SessionOutbound::new()),
505            PeerTimeouts::default(),
506        )
507    }
508
509    fn sent_request_id(sink: &MockSink) -> RequestId {
510        let sent = sink.sent.lock().unwrap();
511        match sent.first().expect("a request was sent") {
512            Message::Request(r) => r.id.clone(),
513            other => panic!("expected request, got {other:?}"),
514        }
515    }
516
517    #[tokio::test]
518    async fn request_correlates_response() {
519        let sink = MockSink::new(true);
520        let outbound = Arc::new(SessionOutbound::new());
521        let p = SessionPeer::new(sink.clone(), Arc::clone(&outbound), PeerTimeouts::default());
522
523        let fut = p.request(Cow::Borrowed("roots/list"), None);
524        futures::pin_mut!(fut);
525        // Drive until the request is sent.
526        assert!(futures::poll!(fut.as_mut()).is_pending());
527        let id = sent_request_id(&sink);
528
529        // The client answers via POST -> resolve.
530        assert!(outbound.resolve(Response::success(id, serde_json::json!({"roots": []}))));
531        let response = fut.await.expect("correlated");
532        assert_eq!(response.result.unwrap()["roots"], serde_json::json!([]));
533    }
534
535    #[tokio::test]
536    async fn timeout_cleans_up_pending() {
537        let sink = MockSink::new(true);
538        let outbound = Arc::new(SessionOutbound::new());
539        let p = SessionPeer::new(
540            sink.clone(),
541            Arc::clone(&outbound),
542            PeerTimeouts {
543                default: Duration::from_millis(50),
544                elicitation: Duration::from_millis(50),
545            },
546        );
547
548        let err = p
549            .request(Cow::Borrowed("roots/list"), None)
550            .await
551            .unwrap_err();
552        assert!(err.to_string().contains("timed out"), "{err}");
553        // Pending entry removed: a late response resolves nothing.
554        let id = sent_request_id(&sink);
555        assert!(!outbound.resolve(Response::success(id, serde_json::json!({}))));
556    }
557
558    #[tokio::test]
559    async fn owner_drop_fails_pending_waiters() {
560        let sink = MockSink::new(true);
561        let owner = OutboundOwner::new();
562        let p = SessionPeer::new(
563            sink.clone(),
564            Arc::clone(owner.outbound()),
565            PeerTimeouts::default(),
566        );
567
568        let fut = p.request(Cow::Borrowed("roots/list"), None);
569        futures::pin_mut!(fut);
570        assert!(futures::poll!(fut.as_mut()).is_pending());
571
572        // Session reaped/DELETEd: the map's exclusive owner drops.
573        drop(owner);
574        let err = fut.await.unwrap_err();
575        assert!(err.to_string().contains("closed"), "{err}");
576    }
577
578    #[tokio::test]
579    async fn notifications_never_fail_without_stream() {
580        let sink = MockSink::new(false);
581        let p = peer(sink.clone());
582        p.notify(Notification::new("notifications/progress"))
583            .await
584            .expect("best-effort notification must not error");
585        assert_eq!(sink.sent.lock().unwrap().len(), 1);
586    }
587
588    #[tokio::test]
589    async fn request_fails_fast_after_grace_without_stream() {
590        let sink = MockSink::new(false);
591        let p = peer(sink.clone()).with_reconnect_grace(Duration::from_millis(50));
592
593        let started = Instant::now();
594        let err = p
595            .request(Cow::Borrowed("roots/list"), None)
596            .await
597            .unwrap_err();
598        assert!(
599            err.to_string().contains("SSE stream"),
600            "expected no-stream error, got: {err}"
601        );
602        assert!(
603            started.elapsed() < Duration::from_secs(5),
604            "must fail at the grace, not the request timeout"
605        );
606    }
607
608    #[tokio::test]
609    async fn request_survives_reconnect_within_grace() {
610        let sink = MockSink::new(false);
611        let outbound = Arc::new(SessionOutbound::new());
612        let p = SessionPeer::new(sink.clone(), Arc::clone(&outbound), PeerTimeouts::default())
613            .with_reconnect_grace(Duration::from_secs(2));
614
615        let sink2 = sink.clone();
616        let reconnect = tokio::spawn(async move {
617            tokio::time::sleep(Duration::from_millis(150)).await;
618            sink2.live.store(true, Ordering::SeqCst);
619        });
620
621        let fut = p.request(Cow::Borrowed("roots/list"), None);
622        futures::pin_mut!(fut);
623        // Poll until the send goes through post-reconnect, then answer.
624        loop {
625            assert!(
626                futures::poll!(fut.as_mut()).is_pending(),
627                "request should still be awaiting its response"
628            );
629            if !sink.sent.lock().unwrap().is_empty() {
630                break;
631            }
632            tokio::time::sleep(Duration::from_millis(20)).await;
633        }
634        let id = sent_request_id(&sink);
635        assert!(outbound.resolve(Response::success(id, serde_json::json!({}))));
636        fut.await.expect("survived the blip");
637        reconnect.await.unwrap();
638    }
639
640    #[tokio::test]
641    async fn midflight_stream_loss_fails_after_grace() {
642        let sink = MockSink::new(true);
643        let p = peer(sink.clone()).with_reconnect_grace(Duration::from_millis(80));
644
645        let sink2 = sink.clone();
646        let killer = tokio::spawn(async move {
647            tokio::time::sleep(Duration::from_millis(50)).await;
648            sink2.live.store(false, Ordering::SeqCst);
649        });
650
651        let started = Instant::now();
652        let err = p
653            .request(Cow::Borrowed("roots/list"), None)
654            .await
655            .unwrap_err();
656        assert!(
657            err.to_string().contains("reconnect grace"),
658            "expected mid-flight grace failure, got: {err}"
659        );
660        assert!(started.elapsed() < Duration::from_secs(5));
661        killer.await.unwrap();
662    }
663
664    #[tokio::test]
665    async fn cross_session_ids_do_not_collide() {
666        // Per-session ids both start at 1: session B's response id 1 must not
667        // resolve session A's pending id 1 (regression from review round 2).
668        let a = Arc::new(SessionOutbound::new());
669        let b = Arc::new(SessionOutbound::new());
670        let id_a = a.next_id();
671        let _rx_a = a.register(id_a.clone());
672        let id_b = b.next_id();
673        assert_eq!(id_a, id_b, "both sessions allocate id 1");
674
675        assert!(!b.resolve(Response::success(id_b, serde_json::json!({})))); // B has no pending
676        // A's pending entry is untouched by B's traffic.
677        assert!(a.resolve(Response::success(id_a, serde_json::json!({}))));
678    }
679
680    #[test]
681    fn elicitation_gets_the_longer_timeout() {
682        let t = PeerTimeouts::default();
683        assert_eq!(t.resolve("elicitation/create"), t.elicitation);
684        assert_eq!(t.resolve("elicitation/createUrl"), t.elicitation);
685        assert_eq!(t.resolve("roots/list"), t.default);
686        assert_eq!(t.resolve("sampling/createMessage"), t.default);
687    }
688}