Skip to main content

hyperdb_mcp/
subscriptions.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Resource-update subscription registry backing the server-side hooks
5//! for `resources/subscribe`, `resources/unsubscribe`, and the two MCP
6//! notifications the server emits after workspace mutations.
7//!
8//! The registry keeps, per URI, a `Vec<Peer<RoleServer>>` of currently
9//! subscribed clients. When a tool call changes workspace state the server
10//! looks up every URI that would be affected (workspace, table list,
11//! per-table schema/sample/csv-sample, per-saved-query result) and calls
12//! [`SubscriptionRegistry::notify_updated`], which spawns a background
13//! task per subscriber to send the notification.
14//!
15//! # Tradeoffs in this implementation
16//!
17//! `rmcp::Peer` is `Clone` but not `Eq`, and its internal `mpsc::Sender`
18//! is not exposed for comparison. Rather than reach into private fields
19//! we accept three pragmatic constraints:
20//!
21//! 1. Subscribing twice for the same URI from the same session stores two
22//!    entries. Subsequent notifications fire twice. The MCP protocol
23//!    defines no "already subscribed" error, so this is spec-compliant;
24//!    well-behaved clients subscribe once per URI per session anyway.
25//! 2. Unsubscribing clears *all* entries for the given URI. In multi-client
26//!    setups this would be surprising, but for the stdio + SSE transports
27//!    typically used with `HyperDB` each process serves at most a handful of
28//!    clients and cross-client URI reuse is rare.
29//! 3. Notify failures (client disconnected) are logged but not pruned.
30//!    Dead peers accumulate until the MCP session tears the registry
31//!    down; a future improvement could prune them from the failing
32//!    detached task, but since notify is a bounded per-tool-call cost
33//!    and sessions are short-lived this hasn't mattered in practice.
34
35use rmcp::model::ResourceUpdatedNotificationParam;
36use rmcp::service::Peer;
37use rmcp::RoleServer;
38use std::collections::HashMap;
39use std::sync::Mutex;
40
41/// Per-URI registry of subscribed peers with broadcast helpers.
42///
43/// Cheap to clone (all state is behind an `Arc<Mutex<...>>` externally —
44/// typically `Arc<SubscriptionRegistry>` on the server). Methods take
45/// `&self` because the internal [`Mutex`] provides interior mutability.
46#[derive(Debug, Default)]
47pub struct SubscriptionRegistry {
48    inner: Mutex<HashMap<String, Vec<Peer<RoleServer>>>>,
49}
50
51impl SubscriptionRegistry {
52    /// Build an empty registry.
53    #[must_use]
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Record a new subscription. See the module-level docs for the
59    /// duplicate-subscribe behaviour: two calls with the same peer land
60    /// two entries in the list.
61    pub fn subscribe(&self, uri: &str, peer: Peer<RoleServer>) {
62        let mut guard = self.lock();
63        guard.entry(uri.to_string()).or_default().push(peer);
64    }
65
66    /// Remove all subscriptions for `uri`. Returns the number of entries
67    /// that were removed.
68    ///
69    /// `_peer` is accepted for API symmetry with the MCP protocol
70    /// (the incoming unsubscribe request carries no peer identity beyond
71    /// "the session issuing the call"), but isn't used for matching.
72    pub fn unsubscribe(&self, uri: &str, _peer: &Peer<RoleServer>) -> usize {
73        let mut guard = self.lock();
74        guard.remove(uri).map_or(0, |v| v.len())
75    }
76
77    /// Return a snapshot of all peers subscribed to `uri`. Useful for
78    /// tests and for the notify helpers below, which want to release the
79    /// mutex before doing any async work.
80    pub fn subscribers_for(&self, uri: &str) -> Vec<Peer<RoleServer>> {
81        let guard = self.lock();
82        guard.get(uri).cloned().unwrap_or_default()
83    }
84
85    /// Return the set of URIs that currently have at least one subscriber.
86    pub fn subscribed_uris(&self) -> Vec<String> {
87        let guard = self.lock();
88        guard.keys().cloned().collect()
89    }
90
91    /// Drop all subscriptions for every URI. Invoked when the server is
92    /// shutting down or a full workspace reset has happened.
93    pub fn clear(&self) {
94        let mut guard = self.lock();
95        guard.clear();
96    }
97
98    /// Fire a `notifications/resources/updated` for `uri` to every current
99    /// subscriber. Sends happen in detached tokio tasks so the caller
100    /// doesn't wait on the channel.
101    ///
102    /// If a send fails (typically because the peer disconnected) we log
103    /// the error at `debug` level but do not prune the dead peer — see
104    /// the module-level "Tradeoffs" note for why. Over very long-lived
105    /// servers this can accumulate stale entries in the registry.
106    ///
107    /// No-op when the caller is outside a tokio runtime or the registry
108    /// has no subscribers for `uri`.
109    pub fn notify_updated(&self, uri: &str) {
110        let peers = self.subscribers_for(uri);
111        if peers.is_empty() {
112            return;
113        }
114        for peer in peers {
115            let uri = uri.to_string();
116            // `tokio::spawn` is only valid inside a runtime; we're always
117            // called from the rmcp server which runs on tokio. The `try_`
118            // variant avoids panicking if someone misuses the registry
119            // outside a runtime (e.g. in a synchronous unit test).
120            let Ok(handle) = tokio::runtime::Handle::try_current() else {
121                return;
122            };
123            let params = ResourceUpdatedNotificationParam { uri: uri.clone() };
124            handle.spawn(async move {
125                if let Err(e) = peer.notify_resource_updated(params).await {
126                    tracing::debug!(uri = %uri, error = ?e, "resource update notify failed");
127                }
128            });
129        }
130    }
131
132    /// Fire a `notifications/resources/list_changed` broadcast to every
133    /// subscribed peer (across all URIs). Called when tables are added or
134    /// dropped, or saved queries created / deleted.
135    ///
136    /// Deduplicates peers across URIs so a client subscribed to three
137    /// URIs only receives one list-changed notification.
138    pub fn notify_list_changed(&self) {
139        // Collect one peer per Vec entry, then dedupe by identity: two
140        // entries that share the underlying mpsc channel are equivalent
141        // from the client's perspective. Since we can't compare peers,
142        // we simply tolerate the rare duplicate send in multi-URI
143        // subscribers — list_changed handlers on the client side are
144        // idempotent (they trigger a resources/list refresh).
145        let peers: Vec<Peer<RoleServer>> = {
146            let guard = self.lock();
147            guard.values().flat_map(|v| v.iter().cloned()).collect()
148        };
149        if peers.is_empty() {
150            return;
151        }
152        let Ok(handle) = tokio::runtime::Handle::try_current() else {
153            return;
154        };
155        for peer in peers {
156            handle.spawn(async move {
157                if let Err(e) = peer.notify_resource_list_changed().await {
158                    tracing::debug!(error = ?e, "resource list changed notify failed");
159                }
160            });
161        }
162    }
163
164    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Vec<Peer<RoleServer>>>> {
165        // Poisoning would indicate a panic inside another notify call;
166        // falling back to the inner state still lets us serve the next
167        // request rather than cascading the failure.
168        self.inner
169            .lock()
170            .unwrap_or_else(std::sync::PoisonError::into_inner)
171    }
172}
173
174/// The full set of resource URIs that are affected when a specific table
175/// is written to. Used by mutation-side helpers in `server.rs` to fire
176/// targeted updates without sprinkling URI strings across every tool.
177#[must_use]
178pub fn uris_for_table_change(table: &str) -> Vec<String> {
179    vec![
180        "hyper://workspace".into(),
181        "hyper://tables".into(),
182        "hyper://readme".into(),
183        format!("hyper://tables/{table}/schema"),
184        format!("hyper://tables/{table}/sample"),
185        format!("hyper://tables/{table}/csv-sample"),
186    ]
187}
188
189/// The URIs affected by a workspace-wide change that doesn't name any
190/// single table (e.g. watcher activity touching multiple files, or a
191/// saved-query list mutation). `hyper://tables` reflects the updated
192/// catalog, `hyper://readme` contains the summary that cross-references
193/// everything else.
194#[must_use]
195pub fn uris_for_workspace_change() -> Vec<&'static str> {
196    vec!["hyper://workspace", "hyper://tables", "hyper://readme"]
197}