agentmux_srv/backend/mcp_seed.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Starter MCP Servers catalog seed: preloads a small set of curated global,
5//! credential-free MCP servers (`db_mcp_servers`, the v1 standalone catalog)
6//! on fresh install.
7//!
8//! Mirrors `skill_seed.rs` exactly, for the same reasons documented there:
9//! the actual "run exactly once, ever" gating lives in
10//! `migrations::m0016_seed_starter_mcp_servers`, tracked in the channel's
11//! `db_migrations` table — NOT in this module, and NOT gated on "is the
12//! catalog currently empty" (that gate can't distinguish "never seeded" from
13//! "seeded then the user deleted every starter server on purpose", which
14//! would silently resurrect the defaults on the next restart after a full
15//! deletion — see `m0015_seed_starter_skills.rs`'s history, reagent P2 on
16//! PR #2141 round 2, for why this shape was chosen over a simpler one). This
17//! module exposes only the pure insert logic; the migration owns invocation.
18//!
19//! Only credential-free, fully local servers belong in this manifest — see
20//! docs/specs/SPEC_ARMORY_MCP_SERVER_DEFAULT_SEED_CATALOG_2026_07_13.md §2:
21//! every `is_global` MCP server is auto-injected into every agent's
22//! `.mcp.json` at launch with no per-agent opt-in, so a seeded row needing an
23//! API key or OAuth token would break every agent's launch by default. The
24//! Filesystem reference server is deliberately NOT included despite being
25//! credential-free — it requires an allowlisted directory as a CLI arg with
26//! no environment-agnostic default (spec §9 open question 3); it can be
27//! added once that's resolved, either by computing a per-agent default at
28//! config-build time or shipping it with an explicit prereq the user fills
29//! in via the existing catalog-picker mechanism instead.
30//!
31//! **Ids are deterministic, not randomly minted** — same reasoning as
32//! `skill_seed.rs::starter_skill_id`, mirrored here for MCP servers via
33//! `entry.name` (there is no separate slug field for a server the way a
34//! skill has `trigger`; `name` already plays that role — it's the catalog
35//! key `mcp_server_upsert_unique_global` enforces uniqueness on). See Phase 1
36//! of `SPEC_DURABLE_BINDINGS_2026_09_10.md`.
37
38use std::sync::Arc;
39
40use serde::Deserialize;
41use uuid::Uuid;
42
43use super::storage::mcp_servers::McpServer;
44use super::storage::store::Store;
45use super::storage::StoreError;
46
47/// One entry in the embedded starter-mcp-servers manifest. `config` is a raw
48/// JSON object here (command/args/env) and gets re-serialized to a string to
49/// match `McpServer.config`'s storage shape.
50#[derive(Debug, Deserialize)]
51struct StarterMcpServer {
52 name: String,
53 transport: String,
54 config: serde_json::Value,
55}
56
57/// The embedded starter-mcp-servers manifest JSON. Content is authored
58/// externally and must not be edited here — see
59/// `crates/srv/src/config/starter-mcp-servers.json`.
60const STARTER_MCP_SERVERS_JSON: &str = include_str!("../config/starter-mcp-servers.json");
61
62/// Deterministic row id for a starter MCP server, derived from its `name`.
63/// See `skill_seed::starter_skill_id`'s doc comment for the full reasoning —
64/// this is the identical derivation keyed on the server's catalog name
65/// instead of a skill's trigger, in its own, distinct namespace so a skill
66/// and an MCP server that happened to share a stable-key string could never
67/// collide on id.
68pub(crate) fn starter_mcp_server_id(name: &str) -> Uuid {
69 let namespace = Uuid::new_v5(&Uuid::NAMESPACE_URL, b"https://agentmux.ai/catalog/mcp-servers/v1");
70 Uuid::new_v5(&namespace, name.as_bytes())
71}
72
73/// True if `name` matches a starter MCP server's `name` in the embedded
74/// manifest. Mirrors `skill_seed::starter_skill_trigger_for_name` — see its
75/// doc comment for why the durable-bindings carry-across migration needs
76/// this distinction (recognized starters converge on
77/// [`starter_mcp_server_id`]; everything else keeps first-wins dedup).
78#[cfg(test)]
79pub(crate) fn is_starter_mcp_server_name(name: &str) -> bool {
80 let Ok(manifest) = serde_json::from_str::<Vec<StarterMcpServer>>(STARTER_MCP_SERVERS_JSON) else {
81 return false;
82 };
83 manifest.iter().any(|s| s.name == name)
84}
85
86/// Guard against a manifest-authoring mistake: two entries sharing a `name`.
87/// Mirrors `skill_seed::reject_duplicate_triggers` exactly — see its doc
88/// comment for the full failure mode this closes (reagent P2, PR #3175).
89/// `name` here plays the role `trigger` plays for skills: it is both the
90/// stable key `starter_mcp_server_id` derives from AND the field
91/// `mcp_server_upsert_unique_global`'s own duplicate check is keyed on,
92/// which is exactly the combination that turned "id collision" into
93/// "duplicate-check bypass" for skills.
94fn reject_duplicate_names(manifest: &[StarterMcpServer]) -> Result<(), StoreError> {
95 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
96 for entry in manifest {
97 if !seen.insert(entry.name.as_str()) {
98 return Err(StoreError::Other(format!(
99 "mcp server seed: manifest has more than one entry named '{}' — \
100 this is an authoring bug in starter-mcp-servers.json, not a \
101 runtime condition to recover from",
102 entry.name
103 )));
104 }
105 }
106 Ok(())
107}
108
109/// True if any global MCP server whose name matches a starter entry's name
110/// already exists — i.e. this channel already effectively has the starter
111/// set (or a name collision with it). The caller
112/// (`migrations::m0016_seed_starter_mcp_servers`) uses this to skip seeding
113/// entirely rather than attempting an insert that would collide on
114/// `mcp_server_upsert_unique_global`'s name-uniqueness check and permanently
115/// fail the migration on every subsequent boot — same self-heal
116/// `skill_seed.rs::any_starter_skill_name_exists` provides (reagent P1, PR
117/// #2144).
118pub(crate) fn any_starter_mcp_server_name_exists(mstore: &Arc<Store>) -> Result<bool, StoreError> {
119 let manifest: Vec<StarterMcpServer> = serde_json::from_str(STARTER_MCP_SERVERS_JSON)
120 .map_err(|e| StoreError::Other(format!("mcp server seed: parse manifest: {e}")))?;
121 // Pre-dates Phase 2 of SPEC_DURABLE_BINDINGS_2026_09_10.md's catalog
122 // redirect — see skill_seed.rs's identical note.
123 let existing = mstore.mcp_server_list_global(mstore)?;
124 Ok(manifest
125 .iter()
126 .any(|entry| existing.iter().any(|item| item.server.name == entry.name)))
127}
128
129/// Parse the embedded manifest and insert every entry as a global MCP server
130/// via the validated `mcp_server_upsert_unique_global` path. Does NOT check
131/// whether the catalog is already populated — the caller
132/// (`migrations::m0016_seed_starter_mcp_servers`) owns run-once gating via
133/// `db_migrations` tracking, not catalog contents. Exposed at `pub(crate)`
134/// so the migration and tests can call it directly.
135///
136/// All-or-nothing: `mcp_server_upsert_unique_global` commits each insert in
137/// its own transaction, so a mid-loop failure can't be rolled back by the
138/// database itself. If any insert fails, this compensates by deleting every
139/// server already inserted in THIS call before returning the error —
140/// otherwise a retry (the migration framework re-runs `up()` on the next
141/// boot when a migration returns `Err`, since it's never marked applied)
142/// would hit the name-uniqueness rejection on the servers already stranded
143/// from the failed attempt. Mirrors `skill_seed::seed_starter_skills`
144/// exactly (reagent P2, PR #2141 round 1).
145pub(crate) fn seed_starter_mcp_servers(mstore: &Arc<Store>) -> Result<usize, StoreError> {
146 let manifest: Vec<StarterMcpServer> = serde_json::from_str(STARTER_MCP_SERVERS_JSON)
147 .map_err(|e| StoreError::Other(format!("mcp server seed: parse manifest: {e}")))?;
148 reject_duplicate_names(&manifest)?;
149
150 let now = agentmux_common::time::now_ms();
151
152 let mut inserted_ids: Vec<String> = Vec::with_capacity(manifest.len());
153 for entry in &manifest {
154 let config = serde_json::to_string(&entry.config)
155 .map_err(|e| StoreError::Other(format!("mcp server seed: serialize config for '{}': {e}", entry.name)))?;
156 let server = McpServer {
157 id: starter_mcp_server_id(&entry.name).to_string(),
158 name: entry.name.clone(),
159 transport: entry.transport.clone(),
160 config,
161 is_global: true,
162 created_at: now,
163 updated_at: now,
164 };
165 if let Err(e) = mstore.mcp_server_upsert_unique_global(&server) {
166 for id in &inserted_ids {
167 if let Err(cleanup_err) = mstore.mcp_server_delete(mstore, id) {
168 tracing::error!(
169 "mcp server seed: cleanup after partial failure could not remove {id}: {cleanup_err}"
170 );
171 }
172 }
173 return Err(e);
174 }
175 inserted_ids.push(server.id);
176 }
177
178 Ok(inserted_ids.len())
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 fn starter(name: &str) -> StarterMcpServer {
186 StarterMcpServer {
187 name: name.to_string(),
188 transport: "stdio".to_string(),
189 config: serde_json::json!({"command": "true"}),
190 }
191 }
192
193 #[test]
194 fn reject_duplicate_names_fails_loudly_on_a_colliding_manifest() {
195 // reagent P2, PR #3175 — mirrors skill_seed's equivalent test. See
196 // reject_duplicate_names's own doc comment for the mechanism.
197 let manifest = vec![starter("dup"), starter("dup")];
198 let err = reject_duplicate_names(&manifest).unwrap_err();
199 assert!(format!("{err}").contains("dup"), "error must name the offending server: {err}");
200 }
201
202 #[test]
203 fn reject_duplicate_names_accepts_the_real_manifest() {
204 let manifest: Vec<StarterMcpServer> = serde_json::from_str(STARTER_MCP_SERVERS_JSON).unwrap();
205 assert!(reject_duplicate_names(&manifest).is_ok());
206 }
207
208 #[test]
209 fn starter_mcp_server_ids_are_identical_across_two_independently_seeded_stores() {
210 // Mirrors skill_seed.rs's equivalent test — the property Phase 1 of
211 // SPEC_DURABLE_BINDINGS_2026_09_10.md exists for.
212 let store_a = Arc::new(Store::open_in_memory().unwrap());
213 let store_b = Arc::new(Store::open_in_memory().unwrap());
214
215 seed_starter_mcp_servers(&store_a).unwrap();
216 seed_starter_mcp_servers(&store_b).unwrap();
217
218 let mut ids_a: Vec<(String, String)> = store_a
219 .mcp_server_list_global(&store_a)
220 .unwrap()
221 .into_iter()
222 .map(|item| (item.server.name, item.server.id))
223 .collect();
224 let mut ids_b: Vec<(String, String)> = store_b
225 .mcp_server_list_global(&store_b)
226 .unwrap()
227 .into_iter()
228 .map(|item| (item.server.name, item.server.id))
229 .collect();
230 ids_a.sort();
231 ids_b.sort();
232
233 assert_eq!(ids_a.len(), 6);
234 assert_eq!(ids_a, ids_b, "the same starter server must get the same id in every store");
235 }
236
237 #[test]
238 fn a_skill_and_an_mcp_server_sharing_a_stable_key_do_not_collide() {
239 // Different namespaces for the two catalogs — if a skill's trigger
240 // and a server's name were ever the same literal string, they must
241 // still land on different ids, since they're different resources in
242 // different tables.
243 assert_ne!(
244 crate::backend::skill_seed::starter_skill_id("git"),
245 starter_mcp_server_id("git"),
246 );
247 }
248
249 #[test]
250 fn seeds_six_mcp_servers_into_an_empty_catalog() {
251 let mstore = Arc::new(Store::open_in_memory().unwrap());
252 assert!(mstore.mcp_server_list_global(&mstore).unwrap().is_empty());
253
254 let created = seed_starter_mcp_servers(&mstore).unwrap();
255
256 assert_eq!(created, 6);
257 let after = mstore.mcp_server_list_global(&mstore).unwrap();
258 assert_eq!(after.len(), 6, "all six starter MCP servers should be seeded");
259 assert!(after.iter().all(|item| item.server.is_global));
260 assert!(
261 after.iter().all(|item| item.server.transport == "stdio"),
262 "every Tier A entry is a local stdio process"
263 );
264 }
265
266 #[test]
267 fn seeded_config_round_trips_as_valid_json() {
268 let mstore = Arc::new(Store::open_in_memory().unwrap());
269 seed_starter_mcp_servers(&mstore).unwrap();
270
271 let after = mstore.mcp_server_list_global(&mstore).unwrap();
272 let git = after
273 .iter()
274 .find(|item| item.server.name == "git")
275 .expect("git should be seeded");
276 let parsed: serde_json::Value = serde_json::from_str(&git.server.config).unwrap();
277 assert_eq!(parsed["command"], "uvx");
278 assert_eq!(parsed["args"][0], "mcp-server-git");
279 }
280
281 #[test]
282 fn a_failed_insert_rolls_back_the_ones_already_seeded_this_call() {
283 // Same failure mode as skill_seed.rs's equivalent test (reagent P2,
284 // PR #2141 round 1): mcp_server_upsert_unique_global commits each
285 // insert in its own transaction, so a mid-loop failure can't be
286 // rolled back by the database. Simulate it by pre-inserting a global
287 // server whose NAME collides with one of the starter entries — this
288 // forces seed_starter_mcp_servers to fail partway through the
289 // manifest, and the catalog must end up back at exactly the one
290 // pre-existing server, not a stranded partial starter set.
291 let mstore = Arc::new(Store::open_in_memory().unwrap());
292 let now = agentmux_common::time::now_ms();
293
294 // "fetch" is the second entry in the manifest — colliding on it
295 // guarantees at least one successful insert ("git") precedes the
296 // failure, so the rollback path actually has something to clean up.
297 let colliding = McpServer {
298 id: "pre-existing-collision".to_string(),
299 name: "fetch".to_string(),
300 transport: "stdio".to_string(),
301 config: "{}".to_string(),
302 is_global: true,
303 created_at: now,
304 updated_at: now,
305 };
306 mstore.mcp_server_upsert_unique_global(&colliding).unwrap();
307
308 let result = seed_starter_mcp_servers(&mstore);
309 assert!(result.is_err(), "seeding must fail when a name collides");
310
311 let after = mstore.mcp_server_list_global(&mstore).unwrap();
312 assert_eq!(
313 after.len(),
314 1,
315 "a failed seed must roll back every server it inserted this call, leaving only the pre-existing one"
316 );
317 assert_eq!(after[0].server.id, "pre-existing-collision");
318 }
319
320 #[test]
321 fn any_starter_mcp_server_name_exists_detects_a_collision_without_inserting() {
322 let mstore = Arc::new(Store::open_in_memory().unwrap());
323 assert!(!any_starter_mcp_server_name_exists(&mstore).unwrap());
324
325 let now = agentmux_common::time::now_ms();
326 let user_created = McpServer {
327 id: "user-server".to_string(),
328 name: "memory".to_string(),
329 transport: "stdio".to_string(),
330 config: "{}".to_string(),
331 is_global: true,
332 created_at: now,
333 updated_at: now,
334 };
335 mstore.mcp_server_upsert_unique_global(&user_created).unwrap();
336
337 assert!(any_starter_mcp_server_name_exists(&mstore).unwrap());
338 assert_eq!(
339 mstore.mcp_server_list_global(&mstore).unwrap().len(),
340 1,
341 "the check itself must not insert anything"
342 );
343 }
344}