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        TaskStatusNotificationParam, TasksCapability,
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: TaskStatusNotificationParam) {
171        let task_id = params.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    #[allow(deprecated)]
354    let capabilities = ClientCapabilities::builder()
355        .enable_experimental()
356        .enable_tasks_with(TasksCapability::client_default())
357        .build();
358    ClientInfo::new(
359        capabilities,
360        Implementation::new("mcp-streamable-proxy-client", env!("CARGO_PKG_VERSION")),
361    )
362}
363
364async fn with_session_peer<F, Fut>(slot: &UpstreamPeerSlot, send: F)
365where
366    F: FnOnce(Peer<RoleServer>) -> Fut,
367    Fut: std::future::Future<Output = Result<(), ServiceError>>,
368{
369    let Some(peer) = slot.get() else {
370        debug!("Session notify slot empty; dropping backend notification");
371        return;
372    };
373    if peer.is_transport_closed() {
374        slot.clear();
375        return;
376    }
377    if let Err(err) = send(peer).await {
378        warn!(error = ?err, "Failed to deliver notification to session peer");
379        slot.clear();
380    }
381}
382
383impl ClientHandler for BackendNotificationBridge {
384    fn get_info(&self) -> ClientInfo {
385        self.info.clone()
386    }
387
388    async fn on_resource_updated(
389        &self,
390        params: ResourceUpdatedNotificationParam,
391        _context: NotificationContext<RoleClient>,
392    ) {
393        match &self.target {
394            NotifyTarget::Shared(reg) => {
395                debug!(uri = %params.uri, "Fan-out resources/updated to upstream peers");
396                reg.fan_out(|peer| {
397                    let params = params.clone();
398                    async move { peer.notify_resource_updated(params).await }
399                })
400                .await;
401            }
402            NotifyTarget::Session(slot) => {
403                with_session_peer(slot, |peer| async move {
404                    peer.notify_resource_updated(params).await
405                })
406                .await;
407            }
408        }
409    }
410
411    async fn on_resource_list_changed(&self, _context: NotificationContext<RoleClient>) {
412        match &self.target {
413            NotifyTarget::Shared(reg) => {
414                reg.fan_out(|peer| async move { peer.notify_resource_list_changed().await })
415                    .await;
416            }
417            NotifyTarget::Session(slot) => {
418                with_session_peer(slot, |peer| async move {
419                    peer.notify_resource_list_changed().await
420                })
421                .await;
422            }
423        }
424    }
425
426    async fn on_tool_list_changed(&self, _context: NotificationContext<RoleClient>) {
427        match &self.target {
428            NotifyTarget::Shared(reg) => {
429                reg.fan_out(|peer| async move { peer.notify_tool_list_changed().await })
430                    .await;
431            }
432            NotifyTarget::Session(slot) => {
433                with_session_peer(
434                    slot,
435                    |peer| async move { peer.notify_tool_list_changed().await },
436                )
437                .await;
438            }
439        }
440    }
441
442    async fn on_prompt_list_changed(&self, _context: NotificationContext<RoleClient>) {
443        match &self.target {
444            NotifyTarget::Shared(reg) => {
445                reg.fan_out(|peer| async move { peer.notify_prompt_list_changed().await })
446                    .await;
447            }
448            NotifyTarget::Session(slot) => {
449                with_session_peer(slot, |peer| async move {
450                    peer.notify_prompt_list_changed().await
451                })
452                .await;
453            }
454        }
455    }
456
457    #[allow(deprecated)]
458    async fn on_logging_message(
459        &self,
460        params: rmcp::model::LoggingMessageNotificationParam,
461        _context: NotificationContext<RoleClient>,
462    ) {
463        match &self.target {
464            NotifyTarget::Shared(reg) => {
465                reg.fan_out(|peer| {
466                    let params = params.clone();
467                    async move { peer.notify_logging_message(params).await }
468                })
469                .await;
470            }
471            NotifyTarget::Session(slot) => {
472                with_session_peer(slot, |peer| async move {
473                    peer.notify_logging_message(params).await
474                })
475                .await;
476            }
477        }
478    }
479
480    async fn on_task_status(
481        &self,
482        params: TaskStatusNotificationParam,
483        _context: NotificationContext<RoleClient>,
484    ) {
485        match &self.target {
486            NotifyTarget::Shared(reg) => {
487                reg.deliver_task_status(params).await;
488            }
489            NotifyTarget::Session(slot) => {
490                with_session_peer(slot, |peer| async move {
491                    peer.send_notification(ServerNotification::TaskStatusNotification(
492                        TaskStatusNotification::new(params),
493                    ))
494                    .await
495                })
496                .await;
497            }
498        }
499    }
500
501    async fn on_progress(
502        &self,
503        params: ProgressNotificationParam,
504        _context: NotificationContext<RoleClient>,
505    ) {
506        match &self.target {
507            NotifyTarget::Shared(reg) => {
508                reg.deliver_progress(params).await;
509            }
510            NotifyTarget::Session(slot) => {
511                with_session_peer(
512                    slot,
513                    |peer| async move { peer.notify_progress(params).await },
514                )
515                .await;
516            }
517        }
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn progress_route_register_unregister() {
527        let registry = UpstreamPeerRegistry::new();
528        assert!(registry.is_empty());
529        assert_eq!(registry.len(), 0);
530        registry.unregister(999);
531        registry.unregister_progress(&ProgressToken(rmcp::model::NumberOrString::Number(1)));
532        registry.unregister_task("nope");
533    }
534
535    #[test]
536    fn upstream_peer_slot_set_clear() {
537        let slot = UpstreamPeerSlot::new();
538        assert!(slot.get().is_none());
539        slot.clear();
540        assert!(slot.get().is_none());
541    }
542}