Skip to main content

mcp_streamable_proxy/
backend_client.rs

1//! Backend client handler that bridges server notifications to upstream peers.
2//!
3//! Two isolation modes share one [`BackendNotificationBridge`] type so
4//! [`crate::proxy_handler::BackendRunningService`] stays a single concrete type:
5//!
6//! - **Shared** ([`NotifyTarget::Shared`]): one backend, fan-out / progress·task
7//!   routing via [`UpstreamPeerRegistry`] (stdio proxy).
8//! - **Per-session** ([`NotifyTarget::Session`]): one backend connection owns one
9//!   upstream [`Peer`](Peer); notifications are 1:1 (URL proxy).
10
11use dashmap::DashMap;
12use futures::future::join_all;
13use rmcp::{
14    ClientHandler, RoleClient, RoleServer,
15    model::{
16        ClientCapabilities, ClientInfo, Implementation, ProgressNotificationParam, ProgressToken,
17        ResourceUpdatedNotificationParam, ServerNotification, TaskStatus, TaskStatusNotification,
18        TaskStatusNotificationParams,
19    },
20    service::{NotificationContext, Peer, ServiceError},
21};
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::sync::{Arc, RwLock};
24use std::time::Duration;
25use tracing::{debug, warn};
26
27/// Delay before unregistering a progress route after the owning request finishes.
28///
29/// Gives in-flight backend progress notifications a short window to arrive after
30/// the JSON-RPC response has already been returned to the upstream client.
31pub const PROGRESS_ROUTE_GRACE: Duration = Duration::from_millis(500);
32
33/// Slot for the single upstream peer in per-session isolation mode.
34#[derive(Clone, Default, Debug)]
35pub struct UpstreamPeerSlot {
36    peer: Arc<RwLock<Option<Peer<RoleServer>>>>,
37}
38
39impl UpstreamPeerSlot {
40    /// Create an empty slot.
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Bind the upstream session peer (typically from `on_initialized`).
46    pub fn set(&self, peer: Peer<RoleServer>) {
47        match self.peer.write() {
48            Ok(mut guard) => *guard = Some(peer),
49            Err(poisoned) => *poisoned.into_inner() = Some(peer),
50        }
51    }
52
53    /// Clone the bound peer, if any.
54    pub fn get(&self) -> Option<Peer<RoleServer>> {
55        match self.peer.read() {
56            Ok(guard) => guard.clone(),
57            Err(poisoned) => poisoned.into_inner().clone(),
58        }
59    }
60
61    /// Clear the slot (session closing).
62    pub fn clear(&self) {
63        match self.peer.write() {
64            Ok(mut guard) => *guard = None,
65            Err(poisoned) => *poisoned.into_inner() = None,
66        }
67    }
68}
69
70/// How backend notifications are delivered to upstream Streamable HTTP sessions.
71#[derive(Clone, Debug)]
72pub enum NotifyTarget {
73    /// Shared backend: fan-out list-changed / logging; route progress/task.
74    Shared(Arc<UpstreamPeerRegistry>),
75    /// Per-session backend: deliver only to this session's upstream peer.
76    Session(UpstreamPeerSlot),
77}
78
79/// Registry of upstream session peers that should receive backend notifications.
80#[derive(Debug, Default)]
81pub struct UpstreamPeerRegistry {
82    next_id: AtomicU64,
83    peers: DashMap<u64, Peer<RoleServer>>,
84    /// progressToken → upstream peer (request-scoped; not broadcast)
85    progress_routes: DashMap<ProgressToken, Peer<RoleServer>>,
86    /// task_id → upstream peer (task-scoped; not broadcast)
87    task_routes: DashMap<String, Peer<RoleServer>>,
88}
89
90impl UpstreamPeerRegistry {
91    /// Create an empty registry.
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Register an upstream peer (typically from `ServerHandler::on_initialized`).
97    pub fn register(&self, peer: Peer<RoleServer>) -> u64 {
98        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
99        self.peers.insert(id, peer);
100        id
101    }
102
103    /// Explicitly remove a registered peer by id.
104    pub fn unregister(&self, id: u64) {
105        self.peers.remove(&id);
106    }
107
108    /// Number of registered peers (for diagnostics).
109    pub fn len(&self) -> usize {
110        self.peers.len()
111    }
112
113    /// Whether the registry has no peers.
114    pub fn is_empty(&self) -> bool {
115        self.peers.is_empty()
116    }
117
118    /// Bind a progress token to the upstream peer that owns the request.
119    pub fn register_progress(&self, token: ProgressToken, peer: Peer<RoleServer>) {
120        self.progress_routes.insert(token, peer);
121    }
122
123    /// Remove a progress-token route (request finished or cancelled).
124    pub fn unregister_progress(&self, token: &ProgressToken) {
125        self.progress_routes.remove(token);
126    }
127
128    /// Bind a task id to the upstream peer that created/enqueued the task.
129    pub fn register_task(&self, task_id: impl Into<String>, peer: Peer<RoleServer>) {
130        self.task_routes.insert(task_id.into(), peer);
131    }
132
133    /// Remove a task-id route.
134    pub fn unregister_task(&self, task_id: &str) {
135        self.task_routes.remove(task_id);
136    }
137
138    /// Drop peers whose transport is already closed (eager cleanup on session close).
139    pub fn reap_closed_peers(&self) {
140        self.peers.retain(|_, peer| !peer.is_transport_closed());
141    }
142
143    /// Deliver a progress notification to the owning upstream peer only.
144    pub async fn deliver_progress(&self, params: ProgressNotificationParam) {
145        let token = params.progress_token.clone();
146        let Some(peer) = self
147            .progress_routes
148            .get(&token)
149            .map(|entry| entry.value().clone())
150        else {
151            debug!(
152                ?token,
153                "No progress route for token; dropping backend progress notification"
154            );
155            return;
156        };
157
158        if peer.is_transport_closed() {
159            self.progress_routes.remove(&token);
160            return;
161        }
162
163        if let Err(err) = peer.notify_progress(params).await {
164            warn!(?token, error = ?err, "Failed to deliver progress; removing route");
165            self.progress_routes.remove(&token);
166        }
167    }
168
169    /// Deliver a task-status notification to the owning upstream peer only.
170    pub async fn deliver_task_status(&self, params: TaskStatusNotificationParams) {
171        let task_id = params.task.task.task_id.clone();
172        let terminal = matches!(
173            params.task.status(),
174            TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled
175        );
176
177        let Some(peer) = self.task_routes.get(&task_id).map(|e| e.value().clone()) else {
178            debug!(
179                %task_id,
180                "No task route for id; dropping backend task status notification"
181            );
182            return;
183        };
184
185        if peer.is_transport_closed() {
186            self.task_routes.remove(&task_id);
187            return;
188        }
189
190        let send_result = peer
191            .send_notification(ServerNotification::TaskStatusNotification(
192                TaskStatusNotification::new(params),
193            ))
194            .await;
195
196        if let Err(err) = send_result {
197            warn!(%task_id, error = ?err, "Failed to deliver task status; removing route");
198            self.task_routes.remove(&task_id);
199            return;
200        }
201
202        if terminal {
203            self.task_routes.remove(&task_id);
204        }
205    }
206
207    /// Fan-out a notification to all peers; remove peers that fail to send.
208    pub async fn fan_out<F, Fut>(&self, mut send: F)
209    where
210        F: FnMut(Peer<RoleServer>) -> Fut,
211        Fut: std::future::Future<Output = Result<(), ServiceError>>,
212    {
213        let snapshot: Vec<(u64, Peer<RoleServer>)> = self
214            .peers
215            .iter()
216            .map(|entry| (*entry.key(), entry.value().clone()))
217            .collect();
218
219        let mut dead = Vec::new();
220        let mut live = Vec::new();
221        for (id, peer) in snapshot {
222            if peer.is_transport_closed() {
223                dead.push(id);
224            } else {
225                live.push((id, peer));
226            }
227        }
228
229        let results = join_all(live.into_iter().map(|(id, peer)| {
230            let fut = send(peer);
231            async move { (id, fut.await) }
232        }))
233        .await;
234
235        for (id, result) in results {
236            if let Err(err) = result {
237                warn!(
238                    peer_id = id,
239                    error = ?err,
240                    "Failed to fan-out notification to upstream peer; removing"
241                );
242                dead.push(id);
243            }
244        }
245
246        for id in dead {
247            self.peers.remove(&id);
248        }
249    }
250}
251
252/// RAII guard that unregisters a progress route when dropped (after a short grace).
253pub struct ProgressRouteGuard {
254    registry: Arc<UpstreamPeerRegistry>,
255    token: Option<ProgressToken>,
256    grace: Duration,
257}
258
259impl ProgressRouteGuard {
260    /// Register `token` → `peer` and return a guard that cleans up on drop.
261    pub fn register(
262        registry: Arc<UpstreamPeerRegistry>,
263        token: ProgressToken,
264        peer: Peer<RoleServer>,
265    ) -> Self {
266        registry.register_progress(token.clone(), peer);
267        Self {
268            registry,
269            token: Some(token),
270            grace: PROGRESS_ROUTE_GRACE,
271        }
272    }
273
274    /// Register from request meta if a progress token is present.
275    pub fn try_from_meta(
276        registry: Arc<UpstreamPeerRegistry>,
277        meta: &rmcp::model::RequestMetaObject,
278        peer: Peer<RoleServer>,
279    ) -> Option<Self> {
280        let token = meta.get_progress_token()?;
281        Some(Self::register(registry, token, peer))
282    }
283}
284
285impl Drop for ProgressRouteGuard {
286    fn drop(&mut self) {
287        let Some(token) = self.token.take() else {
288            return;
289        };
290        let registry = Arc::clone(&self.registry);
291        let grace = self.grace;
292        // Prefer delayed unregister so late progress can still be routed.
293        // Fall back to immediate cleanup outside a Tokio runtime.
294        match tokio::runtime::Handle::try_current() {
295            Ok(handle) => {
296                handle.spawn(async move {
297                    tokio::time::sleep(grace).await;
298                    registry.unregister_progress(&token);
299                });
300            }
301            Err(_) => {
302                registry.unregister_progress(&token);
303            }
304        }
305    }
306}
307
308/// Client-side handler used when the proxy connects to a backend MCP server.
309#[derive(Clone, Debug)]
310pub struct BackendNotificationBridge {
311    info: ClientInfo,
312    target: NotifyTarget,
313}
314
315impl BackendNotificationBridge {
316    /// Create a bridge with the given client info and notify target.
317    pub fn new(info: ClientInfo, target: NotifyTarget) -> Self {
318        Self { info, target }
319    }
320
321    /// Shared-backend bridge (fan-out / routed progress).
322    pub fn with_default_info(upstream_peers: Arc<UpstreamPeerRegistry>) -> Self {
323        Self::new(
324            default_backend_client_info(),
325            NotifyTarget::Shared(upstream_peers),
326        )
327    }
328
329    /// Per-session bridge (1:1 notify to [`UpstreamPeerSlot`]).
330    pub fn for_session(slot: UpstreamPeerSlot) -> Self {
331        Self::new(default_backend_client_info(), NotifyTarget::Session(slot))
332    }
333
334    /// Shared upstream peer registry when target is [`NotifyTarget::Shared`].
335    pub fn upstream_peers(&self) -> Option<&Arc<UpstreamPeerRegistry>> {
336        match &self.target {
337            NotifyTarget::Shared(reg) => Some(reg),
338            NotifyTarget::Session(_) => None,
339        }
340    }
341
342    /// Session peer slot when target is [`NotifyTarget::Session`].
343    pub fn session_slot(&self) -> Option<&UpstreamPeerSlot> {
344        match &self.target {
345            NotifyTarget::Session(slot) => Some(slot),
346            NotifyTarget::Shared(_) => None,
347        }
348    }
349}
350
351/// Default [`ClientInfo`] for proxy→backend handshake (enables task notifications).
352pub fn default_backend_client_info() -> ClientInfo {
353    let capabilities = ClientCapabilities::builder().enable_tasks().build();
354    ClientInfo::new(
355        capabilities,
356        Implementation::new("mcp-streamable-proxy-client", env!("CARGO_PKG_VERSION")),
357    )
358}
359
360async fn with_session_peer<F, Fut>(slot: &UpstreamPeerSlot, send: F)
361where
362    F: FnOnce(Peer<RoleServer>) -> Fut,
363    Fut: std::future::Future<Output = Result<(), ServiceError>>,
364{
365    let Some(peer) = slot.get() else {
366        debug!("Session notify slot empty; dropping backend notification");
367        return;
368    };
369    if peer.is_transport_closed() {
370        slot.clear();
371        return;
372    }
373    if let Err(err) = send(peer).await {
374        warn!(error = ?err, "Failed to deliver notification to session peer");
375        slot.clear();
376    }
377}
378
379impl ClientHandler for BackendNotificationBridge {
380    fn get_info(&self) -> ClientInfo {
381        self.info.clone()
382    }
383
384    async fn on_resource_updated(
385        &self,
386        params: ResourceUpdatedNotificationParam,
387        _context: NotificationContext<RoleClient>,
388    ) {
389        match &self.target {
390            NotifyTarget::Shared(reg) => {
391                debug!(uri = %params.uri, "Fan-out resources/updated to upstream peers");
392                reg.fan_out(|peer| {
393                    let params = params.clone();
394                    async move { peer.notify_resource_updated(params).await }
395                })
396                .await;
397            }
398            NotifyTarget::Session(slot) => {
399                with_session_peer(slot, |peer| async move {
400                    peer.notify_resource_updated(params).await
401                })
402                .await;
403            }
404        }
405    }
406
407    async fn on_resource_list_changed(&self, _context: NotificationContext<RoleClient>) {
408        match &self.target {
409            NotifyTarget::Shared(reg) => {
410                reg.fan_out(|peer| async move { peer.notify_resource_list_changed().await })
411                    .await;
412            }
413            NotifyTarget::Session(slot) => {
414                with_session_peer(slot, |peer| async move {
415                    peer.notify_resource_list_changed().await
416                })
417                .await;
418            }
419        }
420    }
421
422    async fn on_tool_list_changed(&self, _context: NotificationContext<RoleClient>) {
423        match &self.target {
424            NotifyTarget::Shared(reg) => {
425                reg.fan_out(|peer| async move { peer.notify_tool_list_changed().await })
426                    .await;
427            }
428            NotifyTarget::Session(slot) => {
429                with_session_peer(
430                    slot,
431                    |peer| async move { peer.notify_tool_list_changed().await },
432                )
433                .await;
434            }
435        }
436    }
437
438    async fn on_prompt_list_changed(&self, _context: NotificationContext<RoleClient>) {
439        match &self.target {
440            NotifyTarget::Shared(reg) => {
441                reg.fan_out(|peer| async move { peer.notify_prompt_list_changed().await })
442                    .await;
443            }
444            NotifyTarget::Session(slot) => {
445                with_session_peer(slot, |peer| async move {
446                    peer.notify_prompt_list_changed().await
447                })
448                .await;
449            }
450        }
451    }
452
453    #[allow(deprecated)]
454    async fn on_logging_message(
455        &self,
456        params: rmcp::model::LoggingMessageNotificationParam,
457        _context: NotificationContext<RoleClient>,
458    ) {
459        match &self.target {
460            NotifyTarget::Shared(reg) => {
461                reg.fan_out(|peer| {
462                    let params = params.clone();
463                    async move { peer.notify_logging_message(params).await }
464                })
465                .await;
466            }
467            NotifyTarget::Session(slot) => {
468                with_session_peer(slot, |peer| async move {
469                    peer.notify_logging_message(params).await
470                })
471                .await;
472            }
473        }
474    }
475
476    async fn on_task_status(
477        &self,
478        params: TaskStatusNotificationParams,
479        _context: NotificationContext<RoleClient>,
480    ) {
481        match &self.target {
482            NotifyTarget::Shared(reg) => {
483                reg.deliver_task_status(params).await;
484            }
485            NotifyTarget::Session(slot) => {
486                with_session_peer(slot, |peer| async move {
487                    peer.send_notification(ServerNotification::TaskStatusNotification(
488                        TaskStatusNotification::new(params),
489                    ))
490                    .await
491                })
492                .await;
493            }
494        }
495    }
496
497    async fn on_progress(
498        &self,
499        params: ProgressNotificationParam,
500        _context: NotificationContext<RoleClient>,
501    ) {
502        match &self.target {
503            NotifyTarget::Shared(reg) => {
504                reg.deliver_progress(params).await;
505            }
506            NotifyTarget::Session(slot) => {
507                with_session_peer(
508                    slot,
509                    |peer| async move { peer.notify_progress(params).await },
510                )
511                .await;
512            }
513        }
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn progress_route_register_unregister() {
523        let registry = UpstreamPeerRegistry::new();
524        assert!(registry.is_empty());
525        assert_eq!(registry.len(), 0);
526        registry.unregister(999);
527        registry.unregister_progress(&ProgressToken(rmcp::model::NumberOrString::Number(1)));
528        registry.unregister_task("nope");
529    }
530
531    #[test]
532    fn upstream_peer_slot_set_clear() {
533        let slot = UpstreamPeerSlot::new();
534        assert!(slot.get().is_none());
535        slot.clear();
536        assert!(slot.get().is_none());
537    }
538}