Skip to main content

agentmux_srv/backend/
mcp_observed.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The MCP servers each agent's CLI says it has, per pane: what Connectors
5//! shows, as opposed to what AgentMux wrote into the agent's `.mcp.json`
6//! (`SPEC_CONNECTORS_MCP_WHAT_AGENTS_RUN_2026_10_10.md` ยง3).
7//!
8//! Claude Code's `system/init` frame lists every MCP server it loaded, from
9//! every source, with a status and the CLI's own source label:
10//! `{"name":"stub","status":"connected","source":"project"}`. The label is
11//! `project` (`.mcp.json`, which is where AgentMux's servers go), `user`
12//! (the account's config), `local`, `dynamic` (`--mcp-config`, plugins),
13//! `managed`, `enterprise` or `claudeai` (claude.ai connectors). Its tool
14//! list names each server's tools `mcp__<server>__<tool>`, the server name
15//! with everything but letters, digits, `_` and `-` made `_`. Checked
16//! against the real CLI by `scripts/cli-contract/mcp-servers-contract.mjs`.
17//!
18//! Only names, statuses and counts: the frame carries no server config, so
19//! nothing secret passes through here. The health of the `agentmux` entry
20//! and its repair are `mcp_health`'s; this only records.
21//!
22//! Each report belongs to the spawn that sent it, by the spawn's
23//! process-wide registration nonce (`persistent::next_registration_nonce`),
24//! which only grows. A report is kept only over one from an older or the
25//! same spawn, and a spawn's exit clears only its own: so a replaced process,
26//! even one of a replaced controller whose `spawn_generation` restarted,
27//! never writes over or clears a newer process's servers. A spawn that has
28//! exited records nothing more: its reader can still be running after the
29//! exit is handled (an abort doesn't stop it mid-poll), and a late report
30//! would stay with nothing left to clear it.
31
32use std::collections::HashMap;
33use std::sync::{LazyLock, Mutex};
34
35use serde::Serialize;
36use serde_json::Value;
37
38/// One MCP server as the agent's CLI reported it.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40pub struct ObservedServer {
41    pub name: String,
42    /// `connected`, `failed`, `needs-auth` or `pending`, as the CLI says it.
43    pub status: String,
44    /// The CLI's source label (module doc), `""` when it gave none.
45    pub source: String,
46    /// How many of the agent's tools come from this server.
47    pub tools: u32,
48}
49
50/// The servers one pane's CLI last reported, and when.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct Observation {
53    pub servers: Vec<ObservedServer>,
54    pub at_ms: u64,
55    /// The spawn that reported it (module doc).
56    #[serde(skip)]
57    pub owner: u64,
58}
59
60#[derive(Default)]
61struct State {
62    reports: HashMap<String, Observation>,
63    /// Per pane, the newest spawn that has exited: none of its reports, or
64    /// an older spawn's, is kept any more.
65    ended: HashMap<String, u64>,
66}
67
68/// At most this many panes' exit markers (`State::ended`) are kept, the
69/// oldest spawns going first: a marker matters only while its reader may
70/// still be finishing, seconds after the exit.
71const 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
79/// The prefix of a server's tool names in the CLI's tool list.
80fn 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
88/// The servers in an init frame, or `None` when it has no `mcp_servers`
89/// (not an init frame, or another provider's).
90pub 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
119/// Keep what spawn `owner` of a pane reported, unless a newer spawn already
120/// reported or this one has exited. True when it changed, so the caller
121/// tells the panes (`mcp:observed`); a resumed turn's init repeats it.
122pub 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
136/// Record an init frame's servers for spawn `owner`. True when they changed:
137/// the caller then publishes (`publish`).
138pub 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
145/// Spawn `owner` exited: drop its report, or an older spawn's still shown
146/// (a replacement that exits before reporting leaves its predecessor's),
147/// never a newer spawn's; and keep none it sends later. True when there was
148/// one to drop.
149pub 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        // Nonces only grow, so the smallest are the oldest exits.
155        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
167/// Tell the panes a report changed or went (`mcp:observed`).
168pub 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
173/// Every pane's last report, by block id.
174pub fn all() -> HashMap<String, Observation> {
175    observed().reports.clone()
176}
177
178/// Drop everything kept for panes whose block is gone (`exists` says which
179/// still are): their reports, and their exit markers.
180pub 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/// Drop a pane's report and exit marker, whoever sent it. True when there
187/// was a report. Tests only: srv drops gone panes with `prune`.
188#[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    /// The frame the real CLI (2.1.288) sent with a working server, one with
201    /// a space and a dot in its name, and one that exited at once.
202    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    /// A replaced spawn (an older nonce), even under a new controller whose
263    /// generation restarted, never writes over or clears the newer one's.
264    #[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    /// A replacement that exits before reporting takes its predecessor's
277    /// report with it; a newer spawn's report stays.
278    #[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    /// A reader still running after its process's exit was handled (an
303    /// aborted task finishing its poll) can't bring the servers back; the
304    /// next spawn can.
305    #[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}