Skip to main content

harn_vm/
mcp_progress.rs

1//! MCP `notifications/progress` plumbing — server-to-client progress
2//! updates emitted from a long-running tool handler.
3//!
4//! The MCP spec lets a client opt into progress updates by
5//! attaching `_meta.progressToken` to a request. While the matching tool
6//! is in flight, the server may emit any number of
7//! `notifications/progress` notifications carrying the same token. The
8//! server must not emit progress for requests without a token, and
9//! progress values must strictly increase per token.
10//!
11//! A per-connection [`ProgressBus`] wraps the transport's outbound JSON
12//! sink (installed thread-locally
13//! via [`install_active_bus`]), and a per-call [`ProgressContext`] is
14//! bound for the duration of a tool handler future via [`scope_context`]
15//! (a tokio task-local) so that helpers — notably the
16//! `mcp_report_progress` stdlib builtin — can find the right token
17//! without taking it as an explicit argument. The split between
18//! thread-local bus and task-local context matters: adapters spawn
19//! concurrent tool calls onto a shared `LocalSet`, so a thread-local
20//! context would race across awaits.
21//!
22//! Spec: <https://modelcontextprotocol.io/specification/2026-07-28/basic/utilities/progress>
23
24use std::cell::RefCell;
25use std::sync::{Arc, Mutex};
26
27use serde_json::{json, Value as JsonValue};
28
29/// Outbound JSON sink for progress notifications.
30///
31/// We accept a closure rather than a concrete `mpsc::UnboundedSender` so
32/// HTTP transports — which feed an `axum::Sse` stream via a wrapping
33/// closure — can install the same kind of bus as stdio without an
34/// adapter shim.
35pub type OutboundFn = Arc<dyn Fn(JsonValue) + Send + Sync>;
36
37/// Per-connection progress notifier.
38///
39/// Cheap to clone — every clone shares the same outbound sink and the
40/// same "last-progress" map used to enforce monotonicity.
41#[derive(Clone)]
42pub struct ProgressBus {
43    outbound: OutboundFn,
44    last_progress: Arc<Mutex<std::collections::HashMap<String, f64>>>,
45}
46
47impl std::fmt::Debug for ProgressBus {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("ProgressBus").finish_non_exhaustive()
50    }
51}
52
53impl ProgressBus {
54    pub fn new(outbound: OutboundFn) -> Self {
55        Self {
56            outbound,
57            last_progress: Arc::new(Mutex::new(std::collections::HashMap::new())),
58        }
59    }
60
61    /// Convenience constructor that wraps a `tokio::sync::mpsc`
62    /// unbounded sender — the shape used by the stdio MCP server.
63    pub fn from_mpsc(tx: tokio::sync::mpsc::UnboundedSender<JsonValue>) -> Self {
64        Self::new(Arc::new(move |message| {
65            let _ = tx.send(message);
66        }))
67    }
68
69    /// Emit a `notifications/progress` notification for `token`.
70    ///
71    /// Per spec the `progress` value MUST monotonically increase across
72    /// calls with the same token. We silently drop any update that would
73    /// regress so a buggy handler can't violate the contract on the
74    /// wire; the user-visible builtin returns `false` in that case so
75    /// scripts can detect it if they care.
76    pub fn report(
77        &self,
78        token: &JsonValue,
79        progress: f64,
80        total: Option<f64>,
81        message: Option<String>,
82    ) -> bool {
83        if !is_valid_progress_token(token) {
84            return false;
85        }
86        if !progress.is_finite() {
87            return false;
88        }
89        if let Some(total) = total {
90            if !total.is_finite() {
91                return false;
92            }
93        }
94        let key = canonical_token(token);
95        {
96            let mut last = self.last_progress.lock().expect("progress map poisoned");
97            if let Some(previous) = last.get(&key).copied() {
98                if progress <= previous {
99                    return false;
100                }
101            }
102            last.insert(key, progress);
103        }
104        let mut params = serde_json::Map::new();
105        params.insert("progressToken".to_string(), token.clone());
106        params.insert("progress".to_string(), json!(progress));
107        if let Some(total) = total {
108            params.insert("total".to_string(), json!(total));
109        }
110        if let Some(message) = message {
111            params.insert("message".to_string(), JsonValue::String(message));
112        }
113        (self.outbound)(crate::jsonrpc::notification(
114            "notifications/progress",
115            JsonValue::Object(params),
116        ));
117        true
118    }
119}
120
121/// Per-call progress context — the bus plus the token the current
122/// request supplied. Bound for the lifetime of a tool handler future
123/// via [`scope_context`] so that `mcp_report_progress(...)` can find it
124/// without explicit threading.
125#[derive(Clone, Debug)]
126pub struct ProgressContext {
127    pub bus: ProgressBus,
128    pub token: JsonValue,
129}
130
131impl ProgressContext {
132    pub fn new(bus: ProgressBus, token: JsonValue) -> Self {
133        Self { bus, token }
134    }
135
136    pub fn report(&self, progress: f64, total: Option<f64>, message: Option<String>) -> bool {
137        self.bus.report(&self.token, progress, total, message)
138    }
139}
140
141tokio::task_local! {
142    /// Per-call progress context — the token plus the bus. Bound for
143    /// the lifetime of a single tool handler future via
144    /// [`scope_context`]. We use a tokio task-local rather than a
145    /// thread-local because adapters (e.g. `harn-serve`) spawn
146    /// concurrent tool calls onto a shared `LocalSet`; a thread-local
147    /// would let one task's await yield to another that overwrites the
148    /// context, which is the exact race tokio task-locals are designed
149    /// to avoid.
150    static CURRENT_CONTEXT: ProgressContext;
151}
152
153thread_local! {
154    static ACTIVE_BUS: RefCell<Option<ProgressBus>> = const { RefCell::new(None) };
155}
156
157/// Run `future` with `ctx` installed as the active progress context.
158/// When `ctx` is `None`, the future runs without a context (so
159/// [`current_context`] returns `None` from inside it). Use this rather
160/// than installing into a thread-local: tool handlers are async and
161/// concurrent on the same OS thread, so we need per-task scoping.
162pub async fn scope_context<F>(ctx: Option<ProgressContext>, future: F) -> F::Output
163where
164    F: std::future::Future,
165{
166    match ctx {
167        Some(ctx) => CURRENT_CONTEXT.scope(ctx, future).await,
168        None => future.await,
169    }
170}
171
172/// Snapshot the progress context for the current task, if any.
173pub fn current_context() -> Option<ProgressContext> {
174    CURRENT_CONTEXT.try_with(|ctx| ctx.clone()).ok()
175}
176
177/// Install a connection-scoped [`ProgressBus`] for the current thread.
178/// Connections are single-threaded (the dispatch loop owns one OS
179/// thread or LocalSet), so a thread-local is the right scope here:
180/// every per-call task derived from this connection sees the same bus.
181pub fn install_active_bus(bus: Option<ProgressBus>) -> Option<ProgressBus> {
182    ACTIVE_BUS.with(|cell| std::mem::replace(&mut *cell.borrow_mut(), bus))
183}
184
185/// Snapshot the active connection-scoped progress bus, if any.
186pub fn active_bus() -> Option<ProgressBus> {
187    ACTIVE_BUS.with(|cell| cell.borrow().clone())
188}
189
190/// RAII guard for [`install_active_bus`]. Connection-scoped, so a
191/// thread-local guard is correct here.
192pub struct ActiveBusGuard {
193    previous: Option<ProgressBus>,
194}
195
196impl ActiveBusGuard {
197    pub fn install(bus: Option<ProgressBus>) -> Self {
198        Self {
199            previous: install_active_bus(bus),
200        }
201    }
202}
203
204impl Drop for ActiveBusGuard {
205    fn drop(&mut self) {
206        install_active_bus(self.previous.take());
207    }
208}
209
210/// MCP progress tokens are constrained to strings or numbers (no nulls,
211/// objects, arrays, or booleans). Validate at the boundary so we never
212/// echo a malformed token back to the client.
213pub fn is_valid_progress_token(value: &JsonValue) -> bool {
214    matches!(value, JsonValue::String(_) | JsonValue::Number(_))
215}
216
217/// Coerce JSON-RPC tokens (strings or numbers) into a single string key
218/// for the per-token monotonicity check.
219fn canonical_token(value: &JsonValue) -> String {
220    if let Some(s) = value.as_str() {
221        return s.to_string();
222    }
223    if let Some(n) = value.as_i64() {
224        return n.to_string();
225    }
226    if let Some(n) = value.as_u64() {
227        return n.to_string();
228    }
229    if let Some(n) = value.as_f64() {
230        return n.to_string();
231    }
232    value.to_string()
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::sync::Mutex;
239
240    fn capturing_bus() -> (ProgressBus, Arc<Mutex<Vec<JsonValue>>>) {
241        let captured: Arc<Mutex<Vec<JsonValue>>> = Arc::new(Mutex::new(Vec::new()));
242        let captured_for_sink = captured.clone();
243        let bus = ProgressBus::new(Arc::new(move |message| {
244            captured_for_sink
245                .lock()
246                .expect("captured progress poisoned")
247                .push(message);
248        }));
249        (bus, captured)
250    }
251
252    #[test]
253    fn reports_progress_with_monotonic_check() {
254        let (bus, captured) = capturing_bus();
255        assert!(bus.report(&json!("tok"), 0.25, Some(1.0), Some("a".into())));
256        assert!(bus.report(&json!("tok"), 0.5, Some(1.0), None));
257        assert!(!bus.report(&json!("tok"), 0.5, Some(1.0), None));
258        assert!(!bus.report(&json!("tok"), 0.4, Some(1.0), None));
259        let captured = captured.lock().unwrap();
260        assert_eq!(captured.len(), 2);
261        assert_eq!(captured[0]["method"], json!("notifications/progress"));
262        assert_eq!(captured[0]["params"]["progressToken"], json!("tok"));
263        assert_eq!(captured[0]["params"]["progress"], json!(0.25));
264        assert_eq!(captured[0]["params"]["total"], json!(1.0));
265        assert_eq!(captured[0]["params"]["message"], json!("a"));
266        assert!(captured[1]["params"].get("message").is_none());
267    }
268
269    #[test]
270    fn reports_progress_for_numeric_token_independently() {
271        let (bus, captured) = capturing_bus();
272        assert!(bus.report(&json!(1), 0.1, None, None));
273        assert!(bus.report(&json!("tok"), 0.05, None, None));
274        let captured = captured.lock().unwrap();
275        assert_eq!(captured.len(), 2);
276    }
277
278    #[test]
279    fn rejects_non_finite_or_invalid_token() {
280        let (bus, captured) = capturing_bus();
281        assert!(!bus.report(&JsonValue::Null, 0.1, None, None));
282        assert!(!bus.report(&json!(true), 0.1, None, None));
283        assert!(!bus.report(&json!("tok"), f64::NAN, None, None));
284        assert!(!bus.report(&json!("tok"), 0.1, Some(f64::INFINITY), None));
285        assert!(captured.lock().unwrap().is_empty());
286    }
287
288    #[tokio::test]
289    async fn scope_context_is_visible_inside_and_absent_outside() {
290        assert!(current_context().is_none());
291        let (bus, _) = capturing_bus();
292        let ctx = ProgressContext::new(bus, json!("tok"));
293        scope_context(Some(ctx), async {
294            assert!(current_context().is_some());
295        })
296        .await;
297        assert!(current_context().is_none());
298    }
299
300    #[tokio::test]
301    async fn scope_context_isolates_concurrent_tasks() {
302        let (bus, captured) = capturing_bus();
303        let ctx_a = ProgressContext::new(bus.clone(), json!("a"));
304        let ctx_b = ProgressContext::new(bus, json!("b"));
305        let task_a = scope_context(Some(ctx_a), async {
306            tokio::task::yield_now().await;
307            current_context().unwrap().token
308        });
309        let task_b = scope_context(Some(ctx_b), async {
310            tokio::task::yield_now().await;
311            current_context().unwrap().token
312        });
313        let (a, b) = tokio::join!(task_a, task_b);
314        assert_eq!(a, json!("a"));
315        assert_eq!(b, json!("b"));
316        assert!(captured.lock().unwrap().is_empty());
317    }
318}