Skip to main content

agentmux_srv/migrations/
m0016_seed_starter_mcp_servers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Seed the global MCP Servers catalog (`db_mcp_servers`, per-channel) with a
5//! curated, credential-free starter set on fresh install.
6//!
7//! Runs exactly once per channel, tracked in that channel's `db_migrations`
8//! table — NOT gated on "is the catalog currently empty," for the same
9//! reason `m0015_seed_starter_skills` isn't: that gate can't distinguish
10//! "never seeded" from "seeded then the user deleted every starter server on
11//! purpose," which would silently resurrect the defaults on the next restart
12//! after a full deletion. The migration framework's once-ever-per-channel
13//! tracking fixes this at the root: once `0016_seed_starter_mcp_servers` is
14//! marked applied, it never runs again for that channel regardless of what
15//! the user does to the catalog afterward.
16//!
17//! `up()` checks for an existing name collision first (same self-heal as
18//! m0015) and treats it as "already seeded" rather than inserting — guards
19//! against any future retired startup-seed path leaving the six starter
20//! names present before this migration ever ran.
21//!
22//! See docs/specs/SPEC_ARMORY_MCP_SERVER_DEFAULT_SEED_CATALOG_2026_07_13.md
23//! for why only credential-free, fully local servers are in this manifest.
24
25use std::sync::Arc;
26
27use crate::backend::mcp_seed::{any_starter_mcp_server_name_exists, seed_starter_mcp_servers};
28use crate::backend::storage::store::Store;
29use super::{Migration, MigrationContext, MigrationError, MigrationScope};
30
31pub struct M0016SeedStarterMcpServers;
32
33impl Migration for M0016SeedStarterMcpServers {
34    fn id(&self) -> &'static str { "0016_seed_starter_mcp_servers" }
35    fn scope(&self) -> MigrationScope { MigrationScope::Channel }
36    fn description(&self) -> &'static str { "Seed the global MCP Servers catalog with a curated starter set" }
37
38    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
39        if !ctx.channel_store_path.exists() {
40            return Ok(());
41        }
42        let mstore = Arc::new(
43            Store::open(&ctx.channel_store_path)
44                .map_err(|e| MigrationError(format!("seed_starter_mcp_servers: open mstore: {}", e)))?,
45        );
46        if any_starter_mcp_server_name_exists(&mstore)
47            .map_err(|e| MigrationError(format!("seed_starter_mcp_servers: check existing: {}", e)))?
48        {
49            return Ok(());
50        }
51        seed_starter_mcp_servers(&mstore)
52            .map(|_| ())
53            .map_err(|e| MigrationError(format!("seed_starter_mcp_servers: {}", e)))
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    fn ctx_for(path: &std::path::Path) -> MigrationContext {
62        MigrationContext {
63            home: std::env::temp_dir(),
64            data_dir: std::env::temp_dir(),
65            shared_store_path: std::env::temp_dir().join("unused-store.db"),
66            channel_store_path: path.to_path_buf(),
67        }
68    }
69
70    #[test]
71    fn seeds_six_starter_mcp_servers_on_a_fresh_channel() {
72        let tmp = tempfile::NamedTempFile::new().unwrap();
73        // Create the channel store up front — `up()` no-ops when the path
74        // doesn't exist yet (mirrors m0007's guard).
75        Store::open(tmp.path()).unwrap();
76
77        M0016SeedStarterMcpServers.up(&ctx_for(tmp.path())).unwrap();
78
79        let mstore = Store::open(tmp.path()).unwrap();
80        assert_eq!(mstore.mcp_server_list_global(&mstore).unwrap().len(), 6);
81    }
82
83    #[test]
84    fn once_marked_applied_a_full_catalog_deletion_is_never_resurrected() {
85        // This is the actual fix: the migration framework's db_migrations
86        // tracking (not mcp_seed's own logic) is what must prevent
87        // reseeding — reproduce that gate here rather than trusting it.
88        let tmp = tempfile::NamedTempFile::new().unwrap();
89        let mstore = Store::open(tmp.path()).unwrap();
90        let ctx = ctx_for(tmp.path());
91
92        M0016SeedStarterMcpServers.up(&ctx).unwrap();
93        mstore.migration_mark_applied("0016_seed_starter_mcp_servers", "channel", 0).unwrap();
94        assert_eq!(mstore.mcp_server_list_global(&mstore).unwrap().len(), 6);
95
96        for item in mstore.mcp_server_list_global(&mstore).unwrap() {
97            mstore.mcp_server_delete(&mstore, &item.server.id).unwrap();
98        }
99        assert!(mstore.mcp_server_list_global(&mstore).unwrap().is_empty());
100
101        // The real runner (runner.rs) never calls `up()` again once
102        // `migration_is_applied` is true — assert that precondition holds,
103        // matching the actual gate every real boot goes through.
104        assert!(
105            mstore.migration_is_applied("0016_seed_starter_mcp_servers"),
106            "once applied, the tracking row must persist regardless of catalog contents"
107        );
108    }
109
110    #[test]
111    fn self_heals_when_starter_servers_already_exist_from_a_prior_run() {
112        // Mirrors m0015's equivalent test: guards against any future
113        // retired startup-seed path leaving starter-server names present
114        // before this migration ever ran. Simulate that pre-existing state
115        // directly and confirm `up()` succeeds as a no-op instead of
116        // erroring on the name-uniqueness collision.
117        let tmp = tempfile::NamedTempFile::new().unwrap();
118        let mstore = Store::open(tmp.path()).unwrap();
119        let ctx = ctx_for(tmp.path());
120
121        let pre_existing = crate::backend::storage::mcp_servers::McpServer {
122            id: "pre-existing-from-old-path".to_string(),
123            name: "git".to_string(),
124            transport: "stdio".to_string(),
125            config: "{}".to_string(),
126            is_global: true,
127            created_at: 0,
128            updated_at: 0,
129        };
130        mstore.mcp_server_upsert_unique_global(&pre_existing).unwrap();
131
132        M0016SeedStarterMcpServers.up(&ctx).unwrap();
133
134        let after = mstore.mcp_server_list_global(&mstore).unwrap();
135        assert_eq!(
136            after.len(),
137            1,
138            "up() must skip seeding entirely on a name collision, not insert the other 5"
139        );
140        assert_eq!(after[0].server.id, "pre-existing-from-old-path");
141    }
142}