Skip to main content

lean_ctx/server/
tools_config_watch.rs

1//! Detects tool-profile config changes at runtime and notifies MCP clients.
2//!
3//! When the user changes `tool_profile`, `tools_enabled`, or `disabled_tools`
4//! via the dashboard, CLI, or manual config edit, the MCP client (Cursor,
5//! Claude Code, etc.) needs a `notifications/tools/list_changed` to re-fetch
6//! `tools/list`. Without this, the client serves a stale tool surface until
7//! the next IDE restart.
8//!
9//! The watcher computes a lightweight hash of the config fields that affect
10//! tool visibility. Dispatch checks it on every tool call — a miss costs one
11//! `Config::load()` (already cached by content-hash) plus a u64 comparison.
12
13use std::sync::atomic::{AtomicU64, Ordering};
14
15use crate::core::config::Config;
16
17/// Computes a stable hash of the config fields that determine the
18/// `tools/list` response: `tool_profile`, `tools_enabled`, `disabled_tools`.
19/// Two configs with the same hash produce the same advertised tool set.
20#[must_use]
21pub fn tools_config_hash(cfg: &Config) -> u64 {
22    use std::hash::{Hash, Hasher};
23    let mut hasher = std::collections::hash_map::DefaultHasher::new();
24    cfg.tool_profile.hash(&mut hasher);
25    cfg.tools_enabled.hash(&mut hasher);
26    cfg.disabled_tools.hash(&mut hasher);
27    hasher.finish()
28}
29
30/// Returns the current tools-config hash (loads config from disk).
31#[must_use]
32pub fn current_hash() -> u64 {
33    tools_config_hash(&Config::load())
34}
35
36/// Checks whether the tools-relevant config has changed since the last
37/// snapshot. If it has, atomically updates the stored hash and returns `true`.
38/// The first call after initialization always returns `false` (the hash is
39/// seeded in the constructor).
40#[must_use]
41pub fn has_changed(last_hash: &AtomicU64) -> bool {
42    let now = current_hash();
43    let prev = last_hash.load(Ordering::Relaxed);
44    if now == prev {
45        false
46    } else {
47        last_hash.store(now, Ordering::Relaxed);
48        true
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn same_config_produces_same_hash() {
58        let cfg = Config::default();
59        assert_eq!(tools_config_hash(&cfg), tools_config_hash(&cfg));
60    }
61
62    #[test]
63    fn different_profile_produces_different_hash() {
64        let mut a = Config::default();
65        let mut b = Config::default();
66        a.tool_profile = Some("minimal".to_string());
67        b.tool_profile = Some("standard".to_string());
68        assert_ne!(tools_config_hash(&a), tools_config_hash(&b));
69    }
70
71    #[test]
72    fn different_disabled_tools_produces_different_hash() {
73        let mut a = Config::default();
74        let mut b = Config::default();
75        a.disabled_tools = vec![];
76        b.disabled_tools = vec!["ctx_call".to_string()];
77        assert_ne!(tools_config_hash(&a), tools_config_hash(&b));
78    }
79
80    #[test]
81    fn has_changed_returns_false_when_unchanged() {
82        let hash = AtomicU64::new(current_hash());
83        assert!(!has_changed(&hash));
84    }
85
86    #[test]
87    fn has_changed_detects_difference() {
88        let hash = AtomicU64::new(0);
89        assert!(has_changed(&hash));
90        assert!(!has_changed(&hash));
91    }
92
93    #[test]
94    fn different_enabled_tools_produces_different_hash() {
95        let mut a = Config::default();
96        let mut b = Config::default();
97        a.tools_enabled = vec![];
98        b.tools_enabled = vec!["ctx_read".to_string(), "ctx_shell".to_string()];
99        assert_ne!(tools_config_hash(&a), tools_config_hash(&b));
100    }
101
102    #[test]
103    fn all_three_fields_contribute_to_hash() {
104        let base = Config::default();
105        let base_hash = tools_config_hash(&base);
106
107        let mut with_profile = base.clone();
108        with_profile.tool_profile = Some("power".to_string());
109
110        let mut with_enabled = base.clone();
111        with_enabled.tools_enabled = vec!["ctx_read".to_string()];
112
113        let mut with_disabled = base.clone();
114        with_disabled.disabled_tools = vec!["ctx_graph".to_string()];
115
116        let hashes = [
117            tools_config_hash(&with_profile),
118            tools_config_hash(&with_enabled),
119            tools_config_hash(&with_disabled),
120        ];
121        for h in &hashes {
122            assert_ne!(*h, base_hash, "changing any field must produce a new hash");
123        }
124        assert_ne!(hashes[0], hashes[1]);
125        assert_ne!(hashes[1], hashes[2]);
126    }
127
128    #[test]
129    fn has_changed_stabilizes_after_update() {
130        let hash = AtomicU64::new(0);
131        assert!(has_changed(&hash), "first call with mismatched seed");
132        let stored = hash.load(Ordering::Relaxed);
133        assert_ne!(stored, 0, "hash should have been updated");
134        assert!(!has_changed(&hash), "same config → no change");
135        assert!(!has_changed(&hash), "still no change on third call");
136    }
137}