1use std::collections::HashMap;
33use std::sync::{LazyLock, Mutex};
34
35use serde::Serialize;
36use serde_json::Value;
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40pub struct ObservedServer {
41 pub name: String,
42 pub status: String,
44 pub source: String,
46 pub tools: u32,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct Observation {
53 pub servers: Vec<ObservedServer>,
54 pub at_ms: u64,
55 #[serde(skip)]
57 pub owner: u64,
58}
59
60#[derive(Default)]
61struct State {
62 reports: HashMap<String, Observation>,
63 ended: HashMap<String, u64>,
66}
67
68const MAX_ENDED: usize = 4096;
72
73static OBSERVED: LazyLock<Mutex<State>> = LazyLock::new(|| Mutex::new(State::default()));
74
75fn observed() -> std::sync::MutexGuard<'static, State> {
76 OBSERVED.lock().unwrap_or_else(|e| e.into_inner())
77}
78
79fn tool_prefix(server: &str) -> String {
81 let name: String = server
82 .chars()
83 .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
84 .collect();
85 format!("mcp__{name}__")
86}
87
88pub fn parse_init(frame: &Value) -> Option<Vec<ObservedServer>> {
91 let servers = frame.get("mcp_servers")?.as_array()?;
92 let tools: Vec<&str> = frame
93 .get("tools")
94 .and_then(|t| t.as_array())
95 .map(|t| t.iter().filter_map(|v| v.as_str()).collect())
96 .unwrap_or_default();
97 let text = |v: &Value, key: &str| v.get(key).and_then(|s| s.as_str()).unwrap_or("").to_string();
98 Some(
99 servers
100 .iter()
101 .filter_map(|s| {
102 let name = text(s, "name");
103 if name.is_empty() {
104 return None;
105 }
106 let prefix = tool_prefix(&name);
107 let count = tools.iter().filter(|t| t.starts_with(&prefix)).count();
108 Some(ObservedServer {
109 status: text(s, "status"),
110 source: text(s, "source"),
111 tools: u32::try_from(count).unwrap_or(u32::MAX),
112 name,
113 })
114 })
115 .collect(),
116 )
117}
118
119pub fn record(block_id: &str, owner: u64, servers: Vec<ObservedServer>, at_ms: u64) -> bool {
123 let mut state = observed();
124 if state.ended.get(block_id).is_some_and(|&ended| owner <= ended) {
125 return false;
126 }
127 let changed = match state.reports.get(block_id) {
128 Some(o) if o.owner > owner => return false,
129 Some(o) => o.servers != servers || o.owner != owner,
130 None => true,
131 };
132 state.reports.insert(block_id.to_string(), Observation { servers, at_ms, owner });
133 changed
134}
135
136pub fn record_init(block_id: &str, owner: u64, frame: &Value) -> bool {
139 match parse_init(frame) {
140 Some(servers) => record(block_id, owner, servers, agentmux_common::time::now_ms_u64()),
141 None => false,
142 }
143}
144
145pub fn forget_owned(block_id: &str, owner: u64) -> bool {
150 let mut state = observed();
151 let ended = state.ended.entry(block_id.to_string()).or_insert(0);
152 *ended = (*ended).max(owner);
153 if state.ended.len() > MAX_ENDED {
154 let mut owners: Vec<u64> = state.ended.values().copied().collect();
156 owners.sort_unstable();
157 let cutoff = owners[state.ended.len() - MAX_ENDED];
158 state.ended.retain(|_, &mut o| o >= cutoff);
159 }
160 if state.reports.get(block_id).is_some_and(|o| o.owner <= owner) {
161 state.reports.remove(block_id);
162 return true;
163 }
164 false
165}
166
167pub fn publish(broker: Option<&crate::backend::mps::Broker>, block_id: &str) {
169 let Some(broker) = broker else { return };
170 broker.publish_event::<crate::backend::mps_events::McpObserved>(&crate::backend::mps_events::BlockRefEvent { block_id: block_id.to_string() }, vec![], 0);
171}
172
173pub fn all() -> HashMap<String, Observation> {
175 observed().reports.clone()
176}
177
178pub fn prune(exists: impl Fn(&str) -> bool) {
181 let mut state = observed();
182 state.reports.retain(|block, _| exists(block));
183 state.ended.retain(|block, _| exists(block));
184}
185
186#[cfg(test)]
189fn forget(block_id: &str) -> bool {
190 let mut state = observed();
191 state.ended.remove(block_id);
192 state.reports.remove(block_id).is_some()
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use serde_json::json;
199
200 fn init_frame() -> Value {
203 json!({
204 "type": "system",
205 "subtype": "init",
206 "mcp_servers": [
207 {"name": "stub", "status": "connected", "source": "project"},
208 {"name": "my server.v2", "status": "connected", "source": "project"},
209 {"name": "broken", "status": "failed", "source": "project"},
210 {"name": "claude.ai Gmail", "status": "needs-auth", "source": "claudeai"}
211 ],
212 "tools": ["Bash", "mcp__my_server_v2__alpha", "mcp__my_server_v2__beta", "mcp__stub__alpha", "mcp__stub__beta", "mcp__stubby__x"]
213 })
214 }
215
216 #[test]
217 fn an_init_frame_gives_each_server_its_status_source_and_tool_count() {
218 let servers = parse_init(&init_frame()).unwrap();
219 let got: Vec<(&str, &str, &str, u32)> =
220 servers.iter().map(|s| (s.name.as_str(), s.status.as_str(), s.source.as_str(), s.tools)).collect();
221 assert_eq!(
222 got,
223 vec![
224 ("stub", "connected", "project", 2),
225 ("my server.v2", "connected", "project", 2),
226 ("broken", "failed", "project", 0),
227 ("claude.ai Gmail", "needs-auth", "claudeai", 0),
228 ]
229 );
230 }
231
232 #[test]
233 fn a_frame_without_mcp_servers_is_not_an_observation() {
234 assert!(parse_init(&json!({"type": "system", "subtype": "init"})).is_none());
235 assert!(parse_init(&json!({"type": "assistant"})).is_none());
236 }
237
238 #[test]
239 fn a_repeated_report_is_not_a_change() {
240 let block = "mcp-observed-test-block";
241 let servers = parse_init(&init_frame()).unwrap();
242 assert!(record(block, 7, servers.clone(), 1));
243 assert!(!record(block, 7, servers.clone(), 2), "same servers again");
244 let mut fewer = servers;
245 fewer.pop();
246 assert!(record(block, 7, fewer, 3));
247 assert_eq!(all().get(block).map(|o| o.at_ms), Some(3));
248 forget(block);
249 assert!(!all().contains_key(block));
250 }
251
252 #[test]
253 fn an_init_frame_is_recorded_once_and_forgotten_once() {
254 let block = "mcp-observed-test-ended";
255 assert!(record_init(block, 3, &init_frame()));
256 assert!(!record_init(block, 3, &init_frame()), "the same report again");
257 assert!(!record_init(block, 3, &json!({"type": "system"})), "not an init frame");
258 assert!(forget_owned(block, 3));
259 assert!(!forget_owned(block, 3), "nothing left to forget");
260 }
261
262 #[test]
265 fn an_older_spawn_never_overwrites_or_clears_a_newer_ones_report() {
266 let block = "mcp-observed-test-owner";
267 assert!(record_init(block, 10, &init_frame()));
268 assert!(!record_init(block, 9, &json!({"mcp_servers": []})), "older spawn's late init");
269 assert_eq!(all().get(block).map(|o| o.servers.len()), Some(4));
270 assert!(!forget_owned(block, 9), "older spawn's exit");
271 assert!(all().contains_key(block));
272 assert!(forget_owned(block, 10));
273 forget(block);
274 }
275
276 #[test]
279 fn an_exit_clears_an_older_spawns_report_but_not_a_newer_ones() {
280 let block = "mcp-observed-test-older";
281 record_init(block, 40, &init_frame());
282 assert!(forget_owned(block, 41), "the replacement exited without reporting");
283 assert!(!all().contains_key(block));
284 record_init(block, 43, &init_frame());
285 assert!(!forget_owned(block, 42), "an older spawn's exit");
286 assert!(all().contains_key(block));
287 forget(block);
288 }
289
290 #[test]
291 fn a_deleted_panes_report_and_exit_marker_both_go() {
292 let (gone, kept) = ("mcp-observed-test-gone", "mcp-observed-test-kept");
293 record_init(gone, 30, &init_frame());
294 forget_owned(gone, 30);
295 record_init(kept, 31, &init_frame());
296 prune(|block| block != gone);
297 assert!(!observed().ended.contains_key(gone));
298 assert!(all().contains_key(kept));
299 forget(kept);
300 }
301
302 #[test]
306 fn an_exited_spawns_late_report_is_not_kept() {
307 let block = "mcp-observed-test-late";
308 assert!(!forget_owned(block, 20), "exit handled before any report");
309 assert!(!record_init(block, 20, &init_frame()), "the late init");
310 assert!(!all().contains_key(block));
311 assert!(record_init(block, 21, &init_frame()), "the next spawn");
312 forget(block);
313 }
314}