1use 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#[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 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 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#[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#[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 pub all_agents: bool,
133 pub in_containers: bool,
135}
136
137#[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 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 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 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 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 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 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 pub fn mcp_server_get(&self, id: &str) -> Result<Option<McpServer>, StoreError> {
261 self.managed_get::<McpServer>(id)
262 }
263
264 pub fn mcp_server_delete(&self, catalog: &Store, id: &str) -> Result<bool, StoreError> {
268 let deleted = self.managed_delete::<McpServer>(catalog, id)?;
269 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 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 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 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 pub fn mcp_server_upsert_unique_global(&self, server: &McpServer) -> Result<(), StoreError> {
326 self.managed_upsert_unique_global(server)
327 }
328
329 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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}