Skip to main content

agentmux_srv/backend/storage/
mcp_servers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Standalone MCP Server primitives (v1 composable model).
5//!
6//! Agents reference servers via db_agent_mcp_ref. The `config` field stores
7//! the full server object JSON (command/args/env for stdio; url/headers for
8//! SSE) that gets merged into `.mcp.json` at agent launch.
9
10use std::collections::HashMap;
11
12use rusqlite::params;
13use serde::{Deserialize, Serialize};
14
15use super::error::StoreError;
16use super::managed::{ManagedResource, Owner};
17use super::store::Store;
18
19/// A standalone MCP Server primitive (v1 composable model).
20#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
21#[ts(export, export_to = "../../../frontend/types/rpc/")]
22pub struct McpServer {
23    pub id: String,
24    pub name: String,
25    pub transport: String,
26    pub config: String,
27    pub is_global: bool,
28    #[ts(type = "number")]
29    pub created_at: i64,
30    #[ts(type = "number")]
31    pub updated_at: i64,
32}
33
34impl Store {
35    /// Drop every skill and MCP ref belonging to `bundle_id`. Bundles no
36    /// longer carry MCP servers (`m0036_drop_bundle_mcp`), but an older build
37    /// sharing the channel can still write a bundle MCP ref, so those go too.
38    ///
39    /// Call after deleting the bundle itself. `bundle_delete` lives on the
40    /// store that owns `db_bundles`, which does not have the ref tables at all
41    /// — they sit beside the catalog tables they key into — so the cleanup
42    /// cannot happen inside it and has to be driven from the handler, where
43    /// both stores are in scope. Without it a deleted bundle leaves its refs
44    /// behind, and a future bundle reusing that id would inherit them.
45    ///
46    /// Returns `(skill_refs_removed, mcp_refs_removed)`.
47    pub fn bundle_unbind_all_components(&self, bundle_id: &str) -> Result<(usize, usize), StoreError> {
48        let skills = self
49            .managed_unbind_all_for_bundle::<super::skills::Skill>(bundle_id)?;
50        let mcp = self.managed_unbind_all_for_bundle::<McpServer>(bundle_id)?;
51        Ok((skills, mcp))
52    }
53}
54
55impl ManagedResource for McpServer {
56    const TABLE: &'static str = "db_mcp_servers";
57    const REF_COL: &'static str = "mcp_id";
58    const AGENT_REF_TABLE: &'static str = "db_agent_mcp_ref";
59    // Still defined, so an older build on the same channel finds it, but
60    // nothing reads it: bundles don't carry MCP servers
61    // (`SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md` §3.1).
62    const BUNDLE_REF_TABLE: &'static str = "db_bundle_mcp_ref";
63    const COLUMNS: &'static [&'static str] =
64        &["id", "name", "transport", "config", "is_global", "created_at", "updated_at"];
65    const UPDATE_COLUMNS: &'static [&'static str] = &["name", "transport", "config", "updated_at"];
66    const NAME_NOUN: &'static str = "server";
67    const ARTICLE_NOUN: &'static str = "an MCP server";
68    const KIND: &'static str = "MCP server";
69
70    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
71        Ok(McpServer {
72            id: row.get(0)?,
73            name: row.get(1)?,
74            transport: row.get(2)?,
75            config: row.get(3)?,
76            is_global: row.get::<_, i64>(4)? != 0,
77            created_at: row.get(5)?,
78            updated_at: row.get(6)?,
79        })
80    }
81    fn values(&self) -> Vec<rusqlite::types::Value> {
82        use rusqlite::types::Value;
83        vec![
84            Value::Text(self.id.clone()),
85            Value::Text(self.name.clone()),
86            Value::Text(self.transport.clone()),
87            Value::Text(self.config.clone()),
88            Value::Integer(if self.is_global { 1 } else { 0 }),
89            Value::Integer(self.created_at),
90            Value::Integer(self.updated_at),
91        ]
92    }
93    fn id(&self) -> &str {
94        &self.id
95    }
96    fn name(&self) -> &str {
97        &self.name
98    }
99    fn is_global(&self) -> bool {
100        self.is_global
101    }
102    fn updated_at(&self) -> i64 {
103        self.updated_at
104    }
105}
106
107/// `mcp_server_list`'s response shape: the server plus whether the requesting
108/// agent specifically holds a `db_agent_mcp_ref` row for it. A global server
109/// is always visible to every agent (see the query below), but `is_global`
110/// alone can't tell the UI whether *this* agent has bound it — without
111/// `bound_to_agent`, bind/unbind in the per-agent modal has no way to render
112/// as a stateful toggle (see docs/specs, "bound to me" gap tracked in #1960).
113#[derive(Debug, Clone, Serialize, ts_rs::TS)]
114#[ts(export, export_to = "../../../frontend/types/rpc/")]
115pub struct McpServerListItem {
116    #[serde(flatten)]
117    pub server: McpServer,
118    pub bound_to_agent: bool,
119}
120
121/// `mcp_server_list_global`'s response shape: the server plus how many
122/// agents currently hold a `db_agent_mcp_ref` to it — Connectors → MCP servers'
123/// "used by N agents" count (gap #2 of #1960).
124#[derive(Debug, Clone, Serialize, ts_rs::TS)]
125#[ts(export, export_to = "../../../frontend/types/rpc/")]
126pub struct McpServerCatalogItem {
127    #[serde(flatten)]
128    pub server: McpServer,
129    #[ts(type = "number")]
130    pub bound_count: i64,
131    /// Reaches every agent, not only the bound ones (`McpServerReach`).
132    pub all_agents: bool,
133    /// Container agents get it too.
134    pub in_containers: bool,
135}
136
137/// Who a catalog (global) server reaches beyond its binds, from
138/// `db_mcp_server_reach`. The default, no row, is "only the agents it is
139/// bound to, and no container agent".
140/// `SPEC_CONNECTORS_MCP_WHAT_AGENTS_RUN_2026_10_10.md` §2.1.
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
142#[ts(export, export_to = "../../../frontend/types/rpc/")]
143pub struct McpServerReach {
144    pub all_agents: bool,
145    pub in_containers: bool,
146}
147
148impl Store {
149    /// List all MCP servers visible to an agent: own (referenced) + global,
150    /// each annotated with whether this specific agent holds the bind ref.
151    pub fn mcp_server_list(&self, catalog: &Store, agent_id: &str) -> Result<Vec<McpServerListItem>, StoreError> {
152        Ok(self
153            .managed_list::<McpServer>(catalog, Owner::Agent, agent_id)?
154            .into_iter()
155            .map(|(server, bound_to_agent)| McpServerListItem { server, bound_to_agent })
156            .collect())
157    }
158
159    /// Every MCP server an agent launches with, on both launch paths
160    /// (`agent.open` and the picker's `writeagentconfig`): its own private
161    /// servers, plus the catalog servers bound to it or switched on for all
162    /// agents. A container agent gets only the catalog servers switched on
163    /// for containers: a catalog server is usually a host command. Its own
164    /// servers are delivered as configured. Not its bundles': a bundle
165    /// carries no MCP servers
166    /// (`SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md` §3.1).
167    /// `SPEC_CONNECTORS_MCP_WHAT_AGENTS_RUN_2026_10_10.md` §2.1.
168    pub fn effective_mcp_servers(&self, catalog: &Store, agent_id: &str, container: bool) -> Vec<McpServer> {
169        let reach = catalog.mcp_server_reach_all().unwrap_or_default();
170        self.mcp_server_list(catalog, agent_id)
171            .unwrap_or_default()
172            .into_iter()
173            .filter(|item| {
174                if !item.server.is_global {
175                    return true;
176                }
177                let r = reach.get(&item.server.id).copied().unwrap_or_default();
178                (item.bound_to_agent || r.all_agents) && (!container || r.in_containers)
179            })
180            .map(|item| item.server)
181            .collect()
182    }
183
184    /// Every catalog server's reach switches, by server id. Call on the
185    /// catalog store (the identity store), where `db_mcp_server_reach` sits
186    /// beside `db_mcp_servers`.
187    pub fn mcp_server_reach_all(&self) -> Result<HashMap<String, McpServerReach>, StoreError> {
188        let conn = self.conn.lock().unwrap();
189        let mut stmt = conn.prepare("SELECT mcp_id, all_agents, in_containers FROM db_mcp_server_reach")?;
190        let rows = stmt.query_map([], |row| {
191            Ok((
192                row.get::<_, String>(0)?,
193                McpServerReach { all_agents: row.get::<_, i64>(1)? != 0, in_containers: row.get::<_, i64>(2)? != 0 },
194            ))
195        })?;
196        let mut out = HashMap::new();
197        for r in rows {
198            let (id, reach) = r?;
199            out.insert(id, reach);
200        }
201        Ok(out)
202    }
203
204    /// Set a catalog server's reach switches. Call on the catalog store; the
205    /// caller checks the server is a global row.
206    pub fn mcp_server_set_reach(&self, mcp_id: &str, reach: McpServerReach, now_ms: i64) -> Result<(), StoreError> {
207        self.conn.lock().unwrap().execute(
208            "INSERT INTO db_mcp_server_reach (mcp_id, all_agents, in_containers, updated_at)
209             VALUES (?1, ?2, ?3, ?4)
210             ON CONFLICT(mcp_id) DO UPDATE SET
211                all_agents = excluded.all_agents,
212                in_containers = excluded.in_containers,
213                updated_at = excluded.updated_at",
214            params![mcp_id, reach.all_agents as i64, reach.in_containers as i64, now_ms],
215        )?;
216        Ok(())
217    }
218
219    /// List every GLOBAL MCP server — Connectors → MCP servers. Unlike
220    /// `mcp_server_list`, this takes no `agent_id` and never includes an
221    /// agent's private servers; it backs the window-scoped `mcp.catalog.*`
222    /// MuxAPI (no `check_s1`, so there is no agent context to scope by).
223    /// Each row carries `bound_count` — how many agents currently hold a
224    /// `db_agent_mcp_ref` to it — per SPEC_V1_MCP_SKILLS_PRIMITIVES_2026_06_30.md
225    /// §8 ("used by N agents"), tracked as gap #2 of #1960.
226    pub fn mcp_server_list_global(&self, catalog: &Store) -> Result<Vec<McpServerCatalogItem>, StoreError> {
227        let reach = catalog.mcp_server_reach_all()?;
228        Ok(self
229            .managed_list_global::<McpServer>(catalog)?
230            .into_iter()
231            .map(|(server, bound_count)| {
232                let r = reach.get(&server.id).copied().unwrap_or_default();
233                McpServerCatalogItem { server, bound_count, all_agents: r.all_agents, in_containers: r.in_containers }
234            })
235            .collect())
236    }
237
238    /// Catalog-tier sibling of `mcp_server_list` (above) — same
239    /// `bound_to_agent` shape, but deliberately GLOBAL ROWS ONLY, unlike
240    /// `mcp_server_list`'s UNION with `agent_id`'s own private servers.
241    /// Backs `mcp.catalog.list_for_agent`, which — like every other
242    /// `mcp.catalog.*` command — has no `check_s1`, so `agent_id` here is
243    /// caller-supplied and unverified. Returning private server rows (whose
244    /// `config` can carry secrets: API keys, auth headers, env vars) for an
245    /// arbitrary caller-chosen `agent_id` would let any window connection
246    /// read any agent's private server config. Global rows carry no
247    /// per-agent secret — they're already fully visible via
248    /// `mcp_server_list_global` (Connectors → MCP servers) — so exposing them
249    /// alongside a caller-chosen agent's bind status is safe.
250    /// reagentx P0 on PR #2329.
251    pub fn mcp_server_list_global_for_agent(&self, catalog: &Store, agent_id: &str) -> Result<Vec<McpServerListItem>, StoreError> {
252        Ok(self
253            .managed_list_global_for_agent::<McpServer>(catalog, agent_id)?
254            .into_iter()
255            .map(|(server, bound_to_agent)| McpServerListItem { server, bound_to_agent })
256            .collect())
257    }
258
259    /// Get a standalone MCP server by id.
260    pub fn mcp_server_get(&self, id: &str) -> Result<Option<McpServer>, StoreError> {
261        self.managed_get::<McpServer>(id)
262    }
263
264    /// Delete a standalone MCP server and purge ref rows (both agent- and
265    /// bundle-level — FK cascades may be off on some builds, same reasoning
266    /// as `skill_delete`). Returns true if deleted.
267    pub fn mcp_server_delete(&self, catalog: &Store, id: &str) -> Result<bool, StoreError> {
268        let deleted = self.managed_delete::<McpServer>(catalog, id)?;
269        // Its reach switches go with it.
270        catalog
271            .conn
272            .lock()
273            .unwrap()
274            .execute("DELETE FROM db_mcp_server_reach WHERE mcp_id = ?1", params![id])?;
275        Ok(deleted)
276    }
277
278    /// Bind an MCP server to an agent (insert ref row). Idempotent —
279    /// binding an already-bound pair is a silent no-op success.
280    ///
281    /// Errors if `agent_id` isn't a LOCAL agent:
282    /// `db_agent_mcp_ref.agent_id` has an ON-enforced FK to
283    /// `db_agents(id)` (store.rs; was `db_agent_definitions(id)` before
284    /// Phase 3c, #3088), but Connectors' agent
285    /// picker (`ListAgentDefinitionsCommand` → `agent_def_list()`) also
286    /// lists cross-channel agents that only exist in another channel's
287    /// local database. Binding one of those would otherwise have the FK
288    /// silently swallow the `INSERT OR IGNORE` — indistinguishable, by
289    /// affected-row-count alone, from the equally-silent "already bound"
290    /// case — reporting success while creating nothing. Same fix as
291    /// skill_bind — see
292    /// docs/reports/REPORT_ARMORY_SKILLS_MARKDOWN_AND_BIND_BUG_2026_07_27.md.
293    pub fn mcp_server_bind(&self, catalog: &Store, agent_id: &str, mcp_id: &str) -> Result<(), StoreError> {
294        self.managed_bind_agent::<McpServer>(catalog, agent_id, mcp_id)
295    }
296
297    /// Unbind an MCP server from an agent. Returns true if a row was removed.
298    pub fn mcp_server_unbind(&self, agent_id: &str, mcp_id: &str) -> Result<bool, StoreError> {
299        self.managed_unbind::<McpServer>(Owner::Agent, agent_id, mcp_id)
300    }
301
302    /// Atomically upsert an MCP server enforcing per-agent name uniqueness, and
303    /// (when `bind_new`) bind it — all in one transaction so concurrent
304    /// `mcp.upsert` calls for the same name can't both pass a check and insert
305    /// duplicate-named bindings. Returns an error if another server visible to
306    /// the agent (bound or global) already uses the name.
307    pub fn mcp_server_upsert_unique(
308        &self,
309        catalog: &Store,
310        agent_id: &str,
311        server: &McpServer,
312        bind_new: bool,
313    ) -> Result<(), StoreError> {
314        self.managed_upsert_unique(catalog, Owner::Agent, agent_id, server, bind_new, None)
315    }
316
317    /// Atomically upsert a GLOBAL MCP server enforcing catalog-wide name
318    /// uniqueness (no `agent_id` — unlike `mcp_server_upsert_unique`, this
319    /// checks for a duplicate name among *every* global row, not just those
320    /// visible to one agent). Reagent P1 on #1948: `agent_config.rs`'s
321    /// `build_mcp_config_from_refs` merges servers into a JSON object keyed
322    /// by `server.name` — two same-named global servers would silently
323    /// clobber each other's config for every agent that has either bound.
324    /// `server.is_global` must already be `true`; caller's job.
325    pub fn mcp_server_upsert_unique_global(&self, server: &McpServer) -> Result<(), StoreError> {
326        self.managed_upsert_unique_global(server)
327    }
328
329    // ── Migration-only raw access — see skills.rs's identical trio (same
330    // Phase 2 of SPEC_DURABLE_BINDINGS_2026_09_10.md, same reasoning). ──
331
332    pub(crate) fn mcp_server_list_all_raw(&self) -> Result<Vec<McpServer>, StoreError> {
333        let conn = self.conn.lock().unwrap();
334        let mut stmt = conn.prepare(
335            "SELECT id, name, transport, config, is_global, created_at, updated_at FROM db_mcp_servers",
336        )?;
337        let rows = stmt.query_map([], |row| {
338            Ok(McpServer {
339                id: row.get(0)?,
340                name: row.get(1)?,
341                transport: row.get(2)?,
342                config: row.get(3)?,
343                is_global: row.get::<_, i64>(4)? != 0,
344                created_at: row.get(5)?,
345                updated_at: row.get(6)?,
346            })
347        })?;
348        let mut out = Vec::new();
349        for r in rows {
350            out.push(r?);
351        }
352        Ok(out)
353    }
354
355    /// Returns 1 if a row was actually inserted, 0 if `OR IGNORE` found one
356    /// already there — see `Skill::skill_insert_raw`'s doc comment.
357    pub(crate) fn mcp_server_insert_raw(&self, server: &McpServer) -> Result<usize, StoreError> {
358        let conn = self.conn.lock().unwrap();
359        let rows = conn.execute(
360            "INSERT OR IGNORE INTO db_mcp_servers
361                (id, name, transport, config, is_global, created_at, updated_at)
362             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
363            params![
364                server.id,
365                server.name,
366                server.transport,
367                server.config,
368                server.is_global as i64,
369                server.created_at,
370                server.updated_at,
371            ],
372        )?;
373        Ok(rows)
374    }
375
376    pub(crate) fn mcp_server_find_global_by_name(&self, name: &str) -> Result<Option<McpServer>, StoreError> {
377        let conn = self.conn.lock().unwrap();
378        let mut stmt = conn.prepare(
379            "SELECT id, name, transport, config, is_global, created_at, updated_at
380             FROM db_mcp_servers
381             WHERE is_global = 1 AND name = ?1
382             LIMIT 1",
383        )?;
384        let result = stmt.query_row(params![name], |row| {
385            Ok(McpServer {
386                id: row.get(0)?,
387                name: row.get(1)?,
388                transport: row.get(2)?,
389                config: row.get(3)?,
390                is_global: row.get::<_, i64>(4)? != 0,
391                created_at: row.get(5)?,
392                updated_at: row.get(6)?,
393            })
394        });
395        match result {
396            Ok(s) => Ok(Some(s)),
397            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
398            Err(e) => Err(e.into()),
399        }
400    }
401
402    /// Mirrors `Skill::skill_rewrite_ref_id` exactly, for the MCP ref tables
403    /// — including its insert-then-delete merge semantics (Codex P2, PR
404    /// #3183). Returns the number of ref rows actually repointed — see
405    /// `Skill::skill_rewrite_ref_id`'s doc comment.
406    pub(crate) fn mcp_server_rewrite_ref_id(&self, old_id: &str, new_id: &str) -> Result<usize, StoreError> {
407        if old_id == new_id {
408            return Ok(0);
409        }
410        let conn = self.conn.lock().unwrap();
411        conn.execute(
412            "INSERT OR IGNORE INTO db_agent_mcp_ref (agent_id, mcp_id)
413                SELECT agent_id, ?2 FROM db_agent_mcp_ref WHERE mcp_id = ?1",
414            params![old_id, new_id],
415        )?;
416        let agent = conn.execute("DELETE FROM db_agent_mcp_ref WHERE mcp_id = ?1", params![old_id])?;
417        conn.execute(
418            "INSERT OR IGNORE INTO db_bundle_mcp_ref (bundle_id, mcp_id)
419                SELECT bundle_id, ?2 FROM db_bundle_mcp_ref WHERE mcp_id = ?1",
420            params![old_id, new_id],
421        )?;
422        let bundle = conn.execute("DELETE FROM db_bundle_mcp_ref WHERE mcp_id = ?1", params![old_id])?;
423        Ok(agent + bundle)
424    }
425
426    /// Return true if the given MCP server is accessible to the agent (global or bound).
427    /// Used for read and mutation access checks.
428    pub fn mcp_server_is_accessible_to(&self, catalog: &Store, agent_id: &str, mcp_id: &str) -> Result<bool, StoreError> {
429        self.managed_is_accessible_to::<McpServer>(catalog, Owner::Agent, agent_id, mcp_id)
430    }
431
432    /// Return true if the agent has a direct ref binding to this MCP server.
433    /// Used for delete access — an agent may only delete servers it directly bound.
434    pub fn mcp_server_is_bound_to(&self, agent_id: &str, mcp_id: &str) -> Result<bool, StoreError> {
435        self.managed_is_bound_to::<McpServer>(Owner::Agent, agent_id, mcp_id)
436    }
437
438}
439
440
441#[cfg(test)]
442mod effective_tests {
443    use super::*;
444    use crate::backend::storage::bundles::Bundle;
445
446    fn server(id: &str, name: &str, is_global: bool) -> McpServer {
447        McpServer {
448            id: id.to_string(),
449            name: name.to_string(),
450            transport: "stdio".to_string(),
451            config: "{}".to_string(),
452            is_global,
453            created_at: 1,
454            updated_at: 1,
455        }
456    }
457
458    fn agent(store: &Store, id: &str, memory_id: &str) {
459        let mut def = crate::backend::storage::store::AgentDefinition {
460            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
461            id: id.to_string(),
462            slug: String::new(),
463            name: id.to_string(),
464            icon: String::new(),
465            provider: "claude".to_string(),
466            description: String::new(),
467            working_directory: String::new(),
468            shell: String::new(),
469            provider_flags: String::new(),
470            auto_start: 0,
471            restart_on_crash: 0,
472            idle_timeout_minutes: 0,
473            created_at: 1,
474            agent_type: "host".to_string(),
475            environment: String::new(),
476            agent_bus_id: String::new(),
477            is_seeded: 0,
478            accounts: String::new(),
479            parent_id: String::new(),
480            branch_label: String::new(),
481            updated_at: 1,
482            user_hidden: 0,
483            container_image: String::new(),
484            container_volumes: "[]".to_string(),
485            container_name: String::new(),
486            use_ambient_login: 0,
487            auto_continue_enabled: 0,
488            model_vendor_base_url: String::new(),
489            memory_id: memory_id.to_string(),
490        };
491        store.agent_def_insert(&mut def).unwrap();
492    }
493
494    fn bundle_row(id: &str) -> Bundle {
495        Bundle {
496            id: id.to_string(),
497            name: id.to_string(),
498            description: String::new(),
499            is_blank: false,
500            is_global: false,
501            provider: String::new(),
502            model: String::new(),
503            instructions: String::new(),
504            instructions_by_provider: "{}".to_string(),
505            context_files: "[]".to_string(),
506            mcp_servers: "[]".to_string(),
507            skills: "[]".to_string(),
508            sort_order: 0,
509            created_at: 1,
510            updated_at: 1,
511            is_system: false,
512        }
513    }
514
515    fn bundle(store: &Store, id: &str) {
516        store
517            .bundle_upsert(&Bundle {
518                id: id.to_string(),
519                name: id.to_string(),
520                description: String::new(),
521                is_blank: false,
522                is_global: false,
523                provider: String::new(),
524                model: String::new(),
525                instructions: String::new(),
526                instructions_by_provider: "{}".to_string(),
527                context_files: "[]".to_string(),
528                mcp_servers: "[]".to_string(),
529                skills: "[]".to_string(),
530                sort_order: 0,
531                created_at: 1,
532                updated_at: 1,
533                is_system: false,
534            })
535            .unwrap();
536    }
537
538    fn bundle_ref(store: &Store, bundle_id: &str, mcp_id: &str) {
539        store
540            .conn
541            .lock()
542            .unwrap()
543            .execute(
544                "INSERT INTO db_bundle_mcp_ref (bundle_id, mcp_id) VALUES (?1, ?2)",
545                params![bundle_id, mcp_id],
546            )
547            .unwrap();
548    }
549
550    /// A bundle ref left by an older build reaches no agent: an agent launches
551    /// with its own and the global servers only.
552    #[test]
553    fn an_agent_launches_with_its_own_and_global_servers_not_its_bundles() {
554        let store = Store::open_in_memory().unwrap();
555        bundle(&store, "own");
556        bundle(&store, "picked");
557        agent(&store, "agent-1", "own");
558        store.agent_bundles_set("agent-1", &["picked".to_string()]).unwrap();
559
560        store.mcp_server_upsert_unique_global(&server("g", "Global", true)).unwrap();
561        store.mcp_server_upsert_unique(&store, "agent-1", &server("a", "Agent's", false), true).unwrap();
562        for (bundle_id, id, name) in [("own", "b1", "Own bundle's"), ("picked", "b2", "Picked bundle's")] {
563            store.mcp_server_insert_raw(&server(id, name, false)).unwrap();
564            bundle_ref(&store, bundle_id, id);
565        }
566
567        store.mcp_server_set_reach("g", McpServerReach { all_agents: true, in_containers: false }, 1).unwrap();
568        assert_eq!(effective(&store, "agent-1", false), vec!["Agent's", "Global"]);
569    }
570
571    fn effective(store: &Store, agent_id: &str, container: bool) -> Vec<String> {
572        let mut names: Vec<String> =
573            store.effective_mcp_servers(store, agent_id, container).into_iter().map(|s| s.name).collect();
574        names.sort();
575        names
576    }
577
578    /// A catalog server reaches the agents it is bound to, and every agent
579    /// once switched on for all; being in the catalog alone reaches no one.
580    #[test]
581    fn a_catalog_server_reaches_its_bound_agents_or_all_when_switched_on() {
582        let store = Store::open_in_memory().unwrap();
583        bundle(&store, "own");
584        agent(&store, "agent-1", "own");
585        agent(&store, "agent-2", "own");
586        store.mcp_server_upsert_unique_global(&server("g", "Catalog", true)).unwrap();
587        assert!(effective(&store, "agent-1", false).is_empty(), "in the catalog, bound to no one");
588
589        store.mcp_server_bind(&store, "agent-1", "g").unwrap();
590        assert_eq!(effective(&store, "agent-1", false), vec!["Catalog"]);
591        assert!(effective(&store, "agent-2", false).is_empty());
592
593        store.mcp_server_set_reach("g", McpServerReach { all_agents: true, in_containers: false }, 1).unwrap();
594        assert_eq!(effective(&store, "agent-2", false), vec!["Catalog"]);
595        let item = store.mcp_server_list_global(&store).unwrap().pop().unwrap();
596        assert_eq!((item.bound_count, item.all_agents, item.in_containers), (1, true, false));
597    }
598
599    /// A container agent gets a catalog server only when it is switched on
600    /// for containers; its own servers always.
601    #[test]
602    fn a_container_agent_gets_catalog_servers_only_when_switched_on_for_containers() {
603        let store = Store::open_in_memory().unwrap();
604        bundle(&store, "own");
605        agent(&store, "agent-1", "own");
606        store.mcp_server_upsert_unique_global(&server("g", "Catalog", true)).unwrap();
607        store.mcp_server_upsert_unique(&store, "agent-1", &server("a", "Own", false), true).unwrap();
608        store.mcp_server_set_reach("g", McpServerReach { all_agents: true, in_containers: false }, 1).unwrap();
609        assert_eq!(effective(&store, "agent-1", true), vec!["Own"]);
610        store.mcp_server_set_reach("g", McpServerReach { all_agents: true, in_containers: true }, 2).unwrap();
611        assert_eq!(effective(&store, "agent-1", true), vec!["Catalog", "Own"]);
612    }
613
614    /// Deleting a server drops its reach switches.
615    #[test]
616    fn deleting_a_server_drops_its_reach() {
617        let store = Store::open_in_memory().unwrap();
618        store.mcp_server_upsert_unique_global(&server("g", "Catalog", true)).unwrap();
619        store.mcp_server_set_reach("g", McpServerReach { all_agents: true, in_containers: true }, 1).unwrap();
620        assert!(store.mcp_server_delete(&store, "g").unwrap());
621        assert!(store.mcp_server_reach_all().unwrap().is_empty());
622    }
623
624    /// A bundle write never stores MCP configs, whatever the caller sends, and
625    /// a read never returns any (see `bundles::NO_INLINE_MCP_SERVERS`).
626    #[test]
627    fn a_bundle_write_never_stores_mcp_servers_and_a_read_never_returns_them() {
628        let store = Store::open_in_memory().unwrap();
629        let inline = r#"[{"name":"gh","env":{"GITHUB_TOKEN":"secret"}}]"#;
630        let mut user = Bundle { mcp_servers: inline.to_string(), ..bundle_row("user") };
631        store.bundle_upsert(&user).unwrap();
632        user.id = "system".to_string();
633        user.name = "system".to_string();
634        user.is_system = true;
635        store.bundle_upsert_system(&user).unwrap();
636
637        let stored: i64 = store
638            .conn
639            .lock()
640            .unwrap()
641            .query_row("SELECT COUNT(*) FROM db_bundles WHERE mcp_servers != '[]'", [], |r| r.get(0))
642            .unwrap();
643        assert_eq!(stored, 0);
644
645        // Left by an older build: still not read back.
646        store.conn.lock().unwrap().execute("UPDATE db_bundles SET mcp_servers = ?1", params![inline]).unwrap();
647        for id in ["user", "system"] {
648            assert_eq!(store.bundle_get(id).unwrap().unwrap().mcp_servers, "[]");
649        }
650    }
651
652    #[test]
653    fn deleting_a_bundle_still_clears_a_leftover_bundle_ref() {
654        let store = Store::open_in_memory().unwrap();
655        store.mcp_server_insert_raw(&server("b1", "Leftover", false)).unwrap();
656        bundle_ref(&store, "gone", "b1");
657        assert_eq!(store.bundle_unbind_all_components("gone").unwrap(), (0, 1));
658    }
659}