Skip to main content

agentmux_srv/backend/
mcp_launch.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The MCP servers an agent launches with, put into its `.mcp.json`: the same
5//! rows on both launch paths, `agent.open` (`agent_config::build_mcp_config_from_refs`)
6//! and the picker's `writeagentconfig` ([`overlay_mcp_servers`]).
7//! `SPEC_CONNECTORS_MCP_WHAT_AGENTS_RUN_2026_10_10.md` §2.2.
8
9use serde_json::Value;
10
11/// Whether a row is written into `.mcp.json` at all: not the reserved
12/// `agentmux` name, and a config that is JSON. [`insert_server_rows`] skips
13/// the others, so a server that is never launched is never "missing".
14pub(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
18/// The catalog (global) servers AgentMux launches an agent with that its CLI
19/// doesn't report (`reported`, from `backend::mcp_observed`), by name. That
20/// is a server bound or switched on after the agent started: `.mcp.json` is
21/// written at launch, so the agent needs a restart to get it. A server the
22/// CLI reports as failed is reported, so not missing. The agent's own private
23/// servers are left out: the pane matches by name against catalog rows, and a
24/// private server can share a name with a catalog one.
25pub 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
39/// [`missing_servers`] for `agent_id`: what AgentMux would launch it with now
40/// (`Store::effective_mcp_servers`, which gives a container agent only what
41/// is switched on for containers) against what its CLI reported.
42pub 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
52/// What a pane needs a restart for: [`missing_for_agent`] for a pane running
53/// an agent definition. `definition_type` is that definition's kind, `None`
54/// when `agent_id` names no definition: a quick-launch pane's "agent id" is
55/// its provider's name, and no `.mcp.json` is written for it, so there is
56/// nothing to restart for. `pane_mode` is the pane's own `agentMode`, which
57/// `writeagentconfig` launched it by and the picker's runtime override can
58/// set differently from the definition's.
59pub 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
72/// Put server rows into an `mcpServers` map, each under its name. A row
73/// named `agentmux` is skipped: the built-in entry always wins.
74pub(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; // synthetic entry wins; user cannot override the key
78        }
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
95/// Lay an agent's effective MCP servers (`Store::effective_mcp_servers`)
96/// over a `.mcp.json` the frontend built (the `agentmux` entry and the
97/// agent's legacy blob), so a picker launch gets the same catalog servers
98/// as `agent.open`. Rows win over the blob on a name collision, as in
99/// `agent_config::build_mcp_config_from_refs`; the `agentmux` entry is never replaced.
100/// `None` when `mcp_json` isn't a JSON object with an `mcpServers` object:
101/// the caller writes it unchanged.
102/// `SPEC_CONNECTORS_MCP_WHAT_AGENTS_RUN_2026_10_10.md` §2.2.
103pub 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
110/// [`overlay_mcp_servers`] with `agent_id`'s effective servers
111/// (`Store::effective_mcp_servers`): `refs` holds the agent binds, `catalog`
112/// the catalog and its reach switches. The picker path's whole MCP step.
113pub 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
123/// The picker path's MCP step (`writeagentconfig`): lay the agent's
124/// effective servers over the `.mcp.json` among the frontend's files, which
125/// has only the `agentmux` entry and the agent's legacy blob. Nothing to do
126/// without one, or without an agent id (an older frontend sends none).
127pub 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    /// A server launched after the agent started isn't reported; one the CLI
189    /// reports as failed is (it was launched), and so is the built-in.
190    #[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    /// Through the real stores: a catalog server switched on for all agents
199    /// is missing from an agent that started before, until it reports it.
200    #[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        // A container pane never got it (not switched on for containers), so
213        // isn't told to restart for it.
214        assert!(missing_for_agent(&store, &store, "agent-1", true, &before).is_empty());
215    }
216
217    /// A pane is judged as what it was launched as: its own mode wins over
218    /// the definition's, and a pane with no definition (quick launch, which
219    /// writes no `.mcp.json`) has nothing to restart for.
220    #[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    /// An agent's private server isn't a catalog row, even when a catalog
237    /// server of the same name exists: it is never listed, so the pane can't
238    /// show it under that row.
239    #[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    /// What `insert_server_rows` skips is never launched, so never missing:
249    /// the two stay in step.
250    #[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    /// A picker launch gets a catalog server switched on for all agents, and
268    /// a container launch only once it is switched on for containers too.
269    #[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}