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}