1use serde_json::Value;
10
11pub(crate) fn is_launched(server: &crate::backend::storage::McpServer) -> bool {
15 server.name != "agentmux" && serde_json::from_str::<Value>(&server.config).is_ok()
16}
17
18pub fn missing_servers(
26 expected: &[crate::backend::storage::McpServer],
27 reported: &[crate::backend::mcp_observed::ObservedServer],
28) -> Vec<String> {
29 let mut missing: Vec<String> = expected
30 .iter()
31 .filter(|s| s.is_global && is_launched(s) && !reported.iter().any(|r| r.name == s.name))
32 .map(|s| s.name.clone())
33 .collect();
34 missing.sort();
35 missing.dedup();
36 missing
37}
38
39pub fn missing_for_agent(
43 refs: &crate::backend::storage::store::Store,
44 catalog: &crate::backend::storage::store::Store,
45 agent_id: &str,
46 container: bool,
47 reported: &[crate::backend::mcp_observed::ObservedServer],
48) -> Vec<String> {
49 missing_servers(&refs.effective_mcp_servers(catalog, agent_id, container), reported)
50}
51
52pub fn missing_for_pane(
60 refs: &crate::backend::storage::store::Store,
61 catalog: &crate::backend::storage::store::Store,
62 agent_id: &str,
63 pane_mode: Option<&str>,
64 definition_type: Option<&str>,
65 reported: &[crate::backend::mcp_observed::ObservedServer],
66) -> Vec<String> {
67 let Some(definition_type) = definition_type else { return Vec::new() };
68 let container = pane_mode.filter(|m| !m.is_empty()).unwrap_or(definition_type) == "container";
69 missing_for_agent(refs, catalog, agent_id, container, reported)
70}
71
72pub(crate) fn insert_server_rows(mcp_servers: &mut serde_json::Map<String, Value>, servers: &[crate::backend::storage::McpServer]) {
75 for server in servers {
76 if server.name == "agentmux" {
77 continue; }
79 match serde_json::from_str::<Value>(&server.config) {
80 Ok(cfg) => {
81 mcp_servers.insert(server.name.clone(), cfg);
82 }
83 Err(e) => {
84 tracing::warn!(
85 id = %server.id,
86 name = %server.name,
87 error = %e,
88 "agent_config: invalid JSON in mcp_server.config; skipping entry"
89 );
90 }
91 }
92 }
93}
94
95pub fn overlay_mcp_servers(mcp_json: &str, servers: &[crate::backend::storage::McpServer]) -> Option<String> {
104 let mut root = serde_json::from_str::<Value>(mcp_json).ok()?;
105 let map = root.get_mut("mcpServers")?.as_object_mut()?;
106 insert_server_rows(map, servers);
107 serde_json::to_string_pretty(&root).ok()
108}
109
110pub fn overlay_for_agent(
114 mcp_json: &str,
115 refs: &crate::backend::storage::store::Store,
116 catalog: &crate::backend::storage::store::Store,
117 agent_id: &str,
118 container: bool,
119) -> Option<String> {
120 overlay_mcp_servers(mcp_json, &refs.effective_mcp_servers(catalog, agent_id, container))
121}
122
123pub fn add_to_files(
128 files: &mut [crate::backend::rpc_types::AgentConfigFile],
129 refs: &crate::backend::storage::store::Store,
130 catalog: &crate::backend::storage::store::Store,
131 agent_id: Option<&str>,
132 container: bool,
133) {
134 let Some(agent_id) = agent_id.filter(|id| !id.is_empty()) else { return };
135 let Some(file) = files.iter_mut().find(|f| f.path == ".mcp.json") else { return };
136 if let Some(merged) = overlay_for_agent(&file.content, refs, catalog, agent_id, container) {
137 file.content = merged;
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::backend::storage::McpServer;
145
146 fn row(name: &str, config: &str) -> McpServer {
147 McpServer {
148 id: format!("id-{name}"),
149 name: name.to_string(),
150 transport: "stdio".to_string(),
151 config: config.to_string(),
152 is_global: true,
153 created_at: 1,
154 updated_at: 1,
155 }
156 }
157
158 fn names(json: &str) -> Vec<String> {
159 let v: Value = serde_json::from_str(json).unwrap();
160 v["mcpServers"].as_object().unwrap().keys().cloned().collect()
161 }
162
163 #[test]
164 fn rows_are_added_beside_the_frontends_entries() {
165 let built = r#"{"mcpServers":{"agentmux":{"command":"agentmux-mcp"},"legacy":{"command":"old"}}}"#;
166 let out = overlay_mcp_servers(built, &[row("git", r#"{"command":"uvx","args":["mcp-server-git"]}"#)]).unwrap();
167 assert_eq!(names(&out), vec!["agentmux", "git", "legacy"]);
168 }
169
170 #[test]
171 fn a_row_wins_over_the_blob_but_never_over_the_built_in_entry() {
172 let built = r#"{"mcpServers":{"agentmux":{"command":"agentmux-mcp"},"git":{"command":"old"}}}"#;
173 let rows = [row("git", r#"{"command":"new"}"#), row("agentmux", r#"{"command":"evil"}"#)];
174 let v: Value = serde_json::from_str(&overlay_mcp_servers(built, &rows).unwrap()).unwrap();
175 assert_eq!(v["mcpServers"]["git"]["command"], "new");
176 assert_eq!(v["mcpServers"]["agentmux"]["command"], "agentmux-mcp");
177 }
178
179 fn reported(name: &str, status: &str) -> crate::backend::mcp_observed::ObservedServer {
180 crate::backend::mcp_observed::ObservedServer {
181 name: name.to_string(),
182 status: status.to_string(),
183 source: "project".to_string(),
184 tools: 0,
185 }
186 }
187
188 #[test]
191 fn a_server_the_agent_does_not_report_is_missing() {
192 let expected = [row("git", "{}"), row("fetch", "{}"), row("memory", "{}")];
193 let now = [reported("agentmux", "connected"), reported("git", "connected"), reported("fetch", "failed")];
194 assert_eq!(missing_servers(&expected, &now), vec!["memory"]);
195 assert!(missing_servers(&[], &now).is_empty());
196 }
197
198 #[test]
201 fn an_agent_that_started_before_a_server_was_switched_on_is_missing_it() {
202 use crate::backend::storage::{store::Store, McpServerReach};
203 let store = Store::open_in_memory().unwrap();
204 store.mcp_server_upsert_unique_global(&row("git", r#"{"command":"uvx"}"#)).unwrap();
205 let before = [reported("agentmux", "connected")];
206 assert!(missing_for_agent(&store, &store, "agent-1", false, &before).is_empty(), "not switched on yet");
207
208 store.mcp_server_set_reach("id-git", McpServerReach { all_agents: true, in_containers: false }, 1).unwrap();
209 assert_eq!(missing_for_agent(&store, &store, "agent-1", false, &before), vec!["git"]);
210 let after = [reported("agentmux", "connected"), reported("git", "connected")];
211 assert!(missing_for_agent(&store, &store, "agent-1", false, &after).is_empty());
212 assert!(missing_for_agent(&store, &store, "agent-1", true, &before).is_empty());
215 }
216
217 #[test]
221 fn a_pane_is_judged_by_how_it_was_launched() {
222 use crate::backend::storage::{store::Store, McpServerReach};
223 let store = Store::open_in_memory().unwrap();
224 store.mcp_server_upsert_unique_global(&row("git", "{}")).unwrap();
225 store.mcp_server_set_reach("id-git", McpServerReach { all_agents: true, in_containers: false }, 1).unwrap();
226 let none = [reported("agentmux", "connected")];
227 let missing = |mode: Option<&str>, def: Option<&str>| missing_for_pane(&store, &store, "a", mode, def, &none);
228
229 assert_eq!(missing(None, Some("host")), vec!["git"], "a host agent that started before");
230 assert!(missing(None, None).is_empty(), "quick launch: no definition, no restart can help");
231 assert!(missing(Some("container"), Some("host")).is_empty(), "a host definition launched as a container");
232 assert_eq!(missing(Some("host"), Some("container")), vec!["git"], "the reverse override");
233 assert!(missing(Some(""), Some("container")).is_empty(), "no pane mode: the definition's");
234 }
235
236 #[test]
240 fn a_private_server_is_never_missing_from_the_catalog_view() {
241 let mut private = row("git", "{}");
242 private.is_global = false;
243 let catalog_only = row("fetch", "{}");
244 assert_eq!(missing_servers(&[private.clone(), catalog_only], &[]), vec!["fetch"]);
245 assert!(missing_servers(&[private], &[]).is_empty());
246 }
247
248 #[test]
251 fn a_server_that_is_never_launched_is_never_missing() {
252 let rows = [row("git", "{}"), row("agentmux", "{}"), row("broken", "{not json")];
253 let mut written = serde_json::Map::new();
254 insert_server_rows(&mut written, &rows);
255 let launched: Vec<&str> = rows.iter().filter(|s| is_launched(s)).map(|s| s.name.as_str()).collect();
256 assert_eq!(written.keys().map(String::as_str).collect::<Vec<_>>(), launched);
257 assert_eq!(missing_servers(&rows, &[]), vec!["git"]);
258 }
259
260 #[test]
261 fn a_row_with_broken_config_is_skipped() {
262 let built = r#"{"mcpServers":{"agentmux":{}}}"#;
263 let out = overlay_mcp_servers(built, &[row("bad", "{not json")]).unwrap();
264 assert_eq!(names(&out), vec!["agentmux"]);
265 }
266
267 #[test]
270 fn a_picker_launch_gets_the_catalog_servers_that_reach_it() {
271 use crate::backend::storage::{store::Store, McpServerReach};
272 let store = Store::open_in_memory().unwrap();
273 store.mcp_server_upsert_unique_global(&row("git", r#"{"command":"uvx"}"#)).unwrap();
274 let built = r#"{"mcpServers":{"agentmux":{"command":"agentmux-mcp"}}}"#;
275 let launch = |container| names(&overlay_for_agent(built, &store, &store, "agent-1", container).unwrap());
276
277 assert_eq!(launch(false), vec!["agentmux"], "in the catalog alone reaches no one");
278 let all = McpServerReach { all_agents: true, in_containers: false };
279 store.mcp_server_set_reach("id-git", all, 1).unwrap();
280 assert_eq!(launch(false), vec!["agentmux", "git"]);
281 assert_eq!(launch(true), vec!["agentmux"]);
282 store.mcp_server_set_reach("id-git", McpServerReach { in_containers: true, ..all }, 2).unwrap();
283 assert_eq!(launch(true), vec!["agentmux", "git"]);
284 }
285
286 #[test]
287 fn content_that_isnt_an_mcp_json_is_left_to_the_caller() {
288 assert!(overlay_mcp_servers("not json", &[]).is_none());
289 assert!(overlay_mcp_servers(r#"{"servers":{}}"#, &[]).is_none());
290 }
291}