Skip to main content

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}