1use super::*;
17
18pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
19 register_mcp_list(engine, state);
20 register_mcp_get(engine, state);
21 register_mcp_upsert(engine, state);
22 register_mcp_delete(engine, state);
23 register_mcp_bind(engine, state);
24 register_mcp_unbind(engine, state);
25 register_mcp_probe(engine, state);
26 register_mcp_catalog_list(engine, state);
27 register_mcp_catalog_upsert(engine, state);
28 register_mcp_catalog_delete(engine, state);
29 register_mcp_catalog_probe(engine, state);
30 register_mcp_catalog_set_reach(engine, state);
31 register_mcp_observed_list(engine, state);
32 register_mcp_catalog_bind(engine, state);
33 register_mcp_catalog_list_for_agent(engine, state);
34 register_mcp_catalog_unbind(engine, state);
35}
36
37fn register_mcp_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
38 let mstore = state.mstore.clone();
39 let identity_store = state.identity_store.clone();
40 engine.register_typed(
41 COMMAND_MCP_LIST,
42 move |req: McpAgentScopeData, ctx| {
43 let mstore = mstore.clone();
44 let identity_store = identity_store.clone();
45 async move {
46 check_s1(&mstore, &ctx, &req.agent_id)?;
47 let servers = mstore.mcp_server_list(&identity_store, &req.agent_id)
48 .map_err(|e| format!("mcp.list: {e}"))?;
49 Ok(servers)
50 }
51 },
52 );
53}
54
55fn register_mcp_get(engine: &Arc<WshRpcEngine>, state: &AppState) {
56 let mstore = state.mstore.clone();
57 let identity_store = state.identity_store.clone();
58 engine.register_typed(
59 COMMAND_MCP_GET,
60 move |req: McpAgentItemData, ctx| {
61 let mstore = mstore.clone();
62 let identity_store = identity_store.clone();
63 async move {
64 check_s1(&mstore, &ctx, &req.agent_id)?;
65 if !mstore.mcp_server_is_accessible_to(&identity_store, &req.agent_id, &req.id)
66 .map_err(|e| format!("mcp.get: {e}"))?
67 {
68 return Err("FORBIDDEN: MCP server not accessible to this agent".to_string());
69 }
70 let server = identity_store.mcp_server_get(&req.id)
71 .map_err(|e| format!("mcp.get: {e}"))?;
72 Ok(server)
73 }
74 },
75 );
76}
77
78fn register_mcp_upsert(engine: &Arc<WshRpcEngine>, state: &AppState) {
79 let mstore = state.mstore.clone();
80 let identity_store = state.identity_store.clone();
81 let broker = state.broker.clone();
82 engine.register_typed(
83 COMMAND_MCP_UPSERT,
84 move |req: CommandMcpUpsertData, ctx| {
85 let mstore = mstore.clone();
86 let identity_store = identity_store.clone();
87 let broker = broker.clone();
88 async move {
89 check_s1(&mstore, &ctx, &req.agent_id)?;
90
91 match serde_json::from_str::<serde_json::Value>(&req.config) {
92 Ok(serde_json::Value::Object(_)) => {}
93 Ok(_) => return Err("mcp.upsert: config must be a JSON object".to_string()),
94 Err(_) => return Err("mcp.upsert: config must be valid JSON".to_string()),
95 }
96 if req.name == "agentmux" {
97 return Err("FORBIDDEN: 'agentmux' is a reserved MCP server name".to_string());
98 }
99
100 let now = now_ms();
101 let (id, created_at) = if req.id.is_empty() {
105 (uuid::Uuid::new_v4().to_string(), now)
106 } else {
107 if !mstore.mcp_server_is_bound_to(&req.agent_id, &req.id)
108 .map_err(|e| format!("mcp.upsert: {e}"))?
109 {
110 return Err("FORBIDDEN: MCP server not bound to this agent".to_string());
111 }
112 let existing = identity_store.mcp_server_get(&req.id)
113 .map_err(|e| format!("mcp.upsert: {e}"))?;
114 match existing {
115 Some(s) if s.is_global => {
116 return Err("FORBIDDEN: cannot mutate a global MCP server".to_string());
117 }
118 Some(s) => (req.id.clone(), s.created_at),
119 None => (req.id.clone(), now),
120 }
121 };
122
123 let server = crate::backend::storage::McpServer {
124 id: id.clone(),
125 name: req.name,
126 transport: req.transport,
127 config: req.config,
128 is_global: false,
129 created_at,
130 updated_at: now,
131 };
132 mstore.mcp_server_upsert_unique(&identity_store, &req.agent_id, &server, req.id.is_empty())
137 .map_err(|e| format!("mcp.upsert: {e}"))?;
138 broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
139 Ok(server)
140 }
141 },
142 );
143}
144
145fn register_mcp_delete(engine: &Arc<WshRpcEngine>, state: &AppState) {
146 let mstore = state.mstore.clone();
147 let identity_store = state.identity_store.clone();
148 let broker = state.broker.clone();
149 engine.register_typed(
150 COMMAND_MCP_DELETE,
151 move |req: McpAgentItemData, ctx| {
152 let mstore = mstore.clone();
153 let identity_store = identity_store.clone();
154 let broker = broker.clone();
155 async move {
156 check_s1(&mstore, &ctx, &req.agent_id)?;
157 if !mstore.mcp_server_is_bound_to(&req.agent_id, &req.id)
158 .map_err(|e| format!("mcp.delete: {e}"))?
159 {
160 return Err("FORBIDDEN: MCP server not bound to this agent".to_string());
161 }
162 if let Some(existing) = identity_store.mcp_server_get(&req.id)
163 .map_err(|e| format!("mcp.delete: {e}"))?
164 {
165 if existing.is_global {
166 return Err("FORBIDDEN: cannot delete a global MCP server".to_string());
167 }
168 }
169 let deleted = mstore.mcp_server_delete(&identity_store, &req.id)
170 .map_err(|e| format!("mcp.delete: {e}"))?;
171 if deleted {
172 broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
173 }
174 Ok(McpDeleteResult { deleted })
175 }
176 },
177 );
178}
179
180fn register_mcp_bind(engine: &Arc<WshRpcEngine>, state: &AppState) {
181 let mstore = state.mstore.clone();
182 let identity_store = state.identity_store.clone();
183 let broker = state.broker.clone();
184 engine.register_typed(
185 COMMAND_MCP_BIND,
186 move |req: McpAgentBindingData, ctx| {
187 let mstore = mstore.clone();
188 let identity_store = identity_store.clone();
189 let broker = broker.clone();
190 async move {
191 check_s1(&mstore, &ctx, &req.agent_id)?;
192 match identity_store.mcp_server_get(&req.mcp_id)
195 .map_err(|e| format!("mcp.bind: {e}"))?
196 {
197 None => return Err("mcp.bind: MCP server not found".to_string()),
198 Some(s) if !s.is_global => {
199 if !mstore.mcp_server_is_bound_to(&req.agent_id, &req.mcp_id)
200 .map_err(|e| format!("mcp.bind: {e}"))?
201 {
202 return Err("FORBIDDEN: can only bind global MCP servers".to_string());
203 }
204 }
205 Some(_) => {}
206 }
207 mstore.mcp_server_bind(&identity_store, &req.agent_id, &req.mcp_id)
208 .map_err(|e| format!("mcp.bind: {e}"))?;
209 broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
214 Ok(McpBindResult { bound: true })
215 }
216 },
217 );
218}
219
220fn register_mcp_unbind(engine: &Arc<WshRpcEngine>, state: &AppState) {
221 let mstore = state.mstore.clone();
222 let broker = state.broker.clone();
223 engine.register_typed(
224 COMMAND_MCP_UNBIND,
225 move |req: McpAgentBindingData, ctx| {
226 let mstore = mstore.clone();
227 let broker = broker.clone();
228 async move {
229 check_s1(&mstore, &ctx, &req.agent_id)?;
230 let unbound = mstore.mcp_server_unbind(&req.agent_id, &req.mcp_id)
231 .map_err(|e| format!("mcp.unbind: {e}"))?;
232 if unbound {
233 broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
234 }
235 Ok(McpUnbindResult { unbound })
236 }
237 },
238 );
239}
240
241fn register_mcp_probe(engine: &Arc<WshRpcEngine>, state: &AppState) {
247 let mstore = state.mstore.clone();
248 let identity_store = state.identity_store.clone();
249 engine.register_typed(
250 COMMAND_MCP_PROBE,
251 move |req: McpAgentItemData, ctx| {
252 let mstore = mstore.clone();
253 let identity_store = identity_store.clone();
254 async move {
255 check_s1(&mstore, &ctx, &req.agent_id)?;
256 if !mstore.mcp_server_is_accessible_to(&identity_store, &req.agent_id, &req.id)
257 .map_err(|e| format!("mcp.probe: {e}"))?
258 {
259 return Err("FORBIDDEN: MCP server not accessible to this agent".to_string());
260 }
261 let server = identity_store.mcp_server_get(&req.id)
262 .map_err(|e| format!("mcp.probe: {e}"))?
263 .ok_or_else(|| "mcp.probe: MCP server not found".to_string())?;
264 let result = crate::backend::mcp_probe::probe(&server.transport, &server.config).await;
265 Ok(result)
266 }
267 },
268 );
269}
270
271fn register_mcp_catalog_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
272 let mstore = state.mstore.clone();
273 let identity_store = state.identity_store.clone();
274 engine.register_typed(
275 COMMAND_MCP_CATALOG_LIST,
276 move |_req: McpCatalogListData, _ctx| {
277 let mstore = mstore.clone();
278 let identity_store = identity_store.clone();
279 async move {
280 let servers = mstore.mcp_server_list_global(&identity_store)
281 .map_err(|e| format!("mcp.catalog.list: {e}"))?;
282 Ok(servers)
283 }
284 },
285 );
286}
287
288fn register_mcp_catalog_upsert(engine: &Arc<WshRpcEngine>, state: &AppState) {
289 let identity_store = state.identity_store.clone();
290 let broker = state.broker.clone();
291 engine.register_typed(
292 COMMAND_MCP_CATALOG_UPSERT,
293 move |req: CommandMcpCatalogUpsertData, _ctx| {
294 let identity_store = identity_store.clone();
295 let broker = broker.clone();
296 async move {
297
298 match serde_json::from_str::<serde_json::Value>(&req.config) {
299 Ok(serde_json::Value::Object(_)) => {}
300 Ok(_) => return Err("mcp.catalog.upsert: config must be a JSON object".to_string()),
301 Err(_) => return Err("mcp.catalog.upsert: config must be valid JSON".to_string()),
302 }
303 if req.name == "agentmux" {
304 return Err("FORBIDDEN: 'agentmux' is a reserved MCP server name".to_string());
305 }
306
307 let now = now_ms();
308 let (id, created_at) = if req.id.is_empty() {
309 (uuid::Uuid::new_v4().to_string(), now)
310 } else {
311 let existing = identity_store.mcp_server_get(&req.id)
312 .map_err(|e| format!("mcp.catalog.upsert: {e}"))?;
313 match existing {
314 Some(s) if !s.is_global => {
319 return Err("FORBIDDEN: cannot promote a private MCP server via the catalog".to_string());
320 }
321 Some(s) => (req.id.clone(), s.created_at),
322 None => (req.id.clone(), now),
323 }
324 };
325
326 let server = crate::backend::storage::McpServer {
327 id: id.clone(),
328 name: req.name,
329 transport: req.transport,
330 config: req.config,
331 is_global: true,
332 created_at,
333 updated_at: now,
334 };
335 identity_store.mcp_server_upsert_unique_global(&server)
342 .map_err(|e| format!("mcp.catalog.upsert: {e}"))?;
343 broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
344 Ok(server)
345 }
346 },
347 );
348}
349
350fn register_mcp_catalog_bind(engine: &Arc<WshRpcEngine>, state: &AppState) {
361 let mstore = state.mstore.clone();
362 let identity_store = state.identity_store.clone();
363 let broker = state.broker.clone();
364 engine.register_typed(
365 COMMAND_MCP_CATALOG_BIND,
366 move |req: McpAgentBindingData, _ctx| {
367 let mstore = mstore.clone();
368 let identity_store = identity_store.clone();
369 let broker = broker.clone();
370 async move {
371 match identity_store.mcp_server_get(&req.mcp_id)
376 .map_err(|e| format!("mcp.catalog.bind: {e}"))?
377 {
378 None => return Err("mcp.catalog.bind: MCP server not found".to_string()),
379 Some(s) if !s.is_global => {
380 if !mstore.mcp_server_is_bound_to(&req.agent_id, &req.mcp_id)
381 .map_err(|e| format!("mcp.catalog.bind: {e}"))?
382 {
383 return Err("FORBIDDEN: can only bind global MCP servers".to_string());
384 }
385 }
386 Some(_) => {}
387 }
388 mstore.mcp_server_bind(&identity_store, &req.agent_id, &req.mcp_id)
389 .map_err(|e| format!("mcp.catalog.bind: {e}"))?;
390 broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
394 Ok(McpBindResult { bound: true })
395 }
396 },
397 );
398}
399
400fn register_mcp_catalog_list_for_agent(engine: &Arc<WshRpcEngine>, state: &AppState) {
413 let mstore = state.mstore.clone();
414 let identity_store = state.identity_store.clone();
415 engine.register_typed(
416 COMMAND_MCP_CATALOG_LIST_FOR_AGENT,
417 move |req: McpAgentScopeData, _ctx| {
418 let mstore = mstore.clone();
419 let identity_store = identity_store.clone();
420 async move {
421 let servers = mstore.mcp_server_list_global_for_agent(&identity_store, &req.agent_id)
422 .map_err(|e| format!("mcp.catalog.list_for_agent: {e}"))?;
423 Ok(servers)
424 }
425 },
426 );
427}
428
429fn register_mcp_catalog_unbind(engine: &Arc<WshRpcEngine>, state: &AppState) {
440 let mstore = state.mstore.clone();
441 let identity_store = state.identity_store.clone();
442 let broker = state.broker.clone();
443 engine.register_typed(
444 COMMAND_MCP_CATALOG_UNBIND,
445 move |req: McpAgentBindingData, _ctx| {
446 let mstore = mstore.clone();
447 let identity_store = identity_store.clone();
448 let broker = broker.clone();
449 async move {
450 match identity_store.mcp_server_get(&req.mcp_id)
451 .map_err(|e| format!("mcp.catalog.unbind: {e}"))?
452 {
453 None => return Err("mcp.catalog.unbind: MCP server not found".to_string()),
454 Some(s) if !s.is_global => {
455 return Err("FORBIDDEN: can only unbind global MCP servers".to_string());
456 }
457 Some(_) => {}
458 }
459 let unbound = mstore.mcp_server_unbind(&req.agent_id, &req.mcp_id)
460 .map_err(|e| format!("mcp.catalog.unbind: {e}"))?;
461 if unbound {
462 broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
463 }
464 Ok(McpUnbindResult { unbound })
465 }
466 },
467 );
468}
469
470fn register_mcp_catalog_delete(engine: &Arc<WshRpcEngine>, state: &AppState) {
471 let mstore = state.mstore.clone();
472 let identity_store = state.identity_store.clone();
473 let broker = state.broker.clone();
474 engine.register_typed(
475 COMMAND_MCP_CATALOG_DELETE,
476 move |req: McpCatalogItemData, _ctx| {
477 let mstore = mstore.clone();
478 let identity_store = identity_store.clone();
479 let broker = broker.clone();
480 async move {
481 if let Some(existing) = identity_store.mcp_server_get(&req.id)
482 .map_err(|e| format!("mcp.catalog.delete: {e}"))?
483 {
484 if !existing.is_global {
485 return Err("FORBIDDEN: cannot delete a private MCP server via the catalog".to_string());
486 }
487 }
488 let deleted = mstore.mcp_server_delete(&identity_store, &req.id)
489 .map_err(|e| format!("mcp.catalog.delete: {e}"))?;
490 if deleted {
491 broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
492 }
493 Ok(McpDeleteResult { deleted })
494 }
495 },
496 );
497}
498
499fn register_mcp_catalog_set_reach(engine: &Arc<WshRpcEngine>, state: &AppState) {
504 let identity_store = state.identity_store.clone();
505 let broker = state.broker.clone();
506 engine.register_typed(
507 COMMAND_MCP_CATALOG_SET_REACH,
508 move |req: CommandMcpCatalogSetReachData, _ctx| {
509 let identity_store = identity_store.clone();
510 let broker = broker.clone();
511 async move {
512 match identity_store.mcp_server_get(&req.id).map_err(|e| format!("mcp.catalog.set_reach: {e}"))? {
513 Some(s) if s.is_global => {}
514 Some(_) => return Err("FORBIDDEN: a private MCP server has no catalog reach".to_string()),
515 None => return Err(format!("mcp.catalog.set_reach: no MCP server {}", req.id)),
516 }
517 let reach = crate::backend::storage::McpServerReach {
518 all_agents: req.all_agents,
519 in_containers: req.in_containers,
520 };
521 identity_store
522 .mcp_server_set_reach(&req.id, reach, now_ms())
523 .map_err(|e| format!("mcp.catalog.set_reach: {e}"))?;
524 broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
525 Ok(reach)
526 }
527 },
528 );
529}
530
531fn register_mcp_observed_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
536 let mstore = state.mstore.clone();
537 let identity_store = state.identity_store.clone();
538 engine.register_typed(
539 COMMAND_MCP_OBSERVED_LIST,
540 move |_req: McpObservedListData, _ctx| {
541 let mstore = mstore.clone();
542 let identity_store = identity_store.clone();
543 async move {
544 let catalog: std::collections::HashSet<String> = mstore
545 .mcp_server_list_global(&identity_store)
546 .map_err(|e| format!("mcp.observed.list: {e}"))?
547 .into_iter()
548 .map(|item| item.server.name)
549 .collect();
550 Ok(observed_agents(&mstore, &identity_store, &catalog))
551 }
552 },
553 );
554}
555
556fn observed_source(name: &str, cli_source: String, catalog: &std::collections::HashSet<String>) -> String {
559 if name == "agentmux" {
560 "builtin".to_string()
561 } else if catalog.contains(name) {
562 "catalog".to_string()
563 } else {
564 cli_source
565 }
566}
567
568fn observed_agents(
569 mstore: &crate::backend::storage::store::Store,
570 identity_store: &crate::backend::storage::store::Store,
571 catalog: &std::collections::HashSet<String>,
572) -> Vec<McpObservedAgent> {
573 crate::backend::mcp_observed::prune(|block_id| {
576 !matches!(mstore.get::<crate::backend::obj::Block>(block_id), Ok(None))
577 });
578 let mut agents = Vec::new();
579 for (block_id, observation) in crate::backend::mcp_observed::all() {
580 let Some(label) = crate::backend::tower_sampler::block_label(mstore, &block_id) else {
581 continue;
582 };
583 let agent_id = crate::identity::resolver::block_agent_id(mstore, &block_id);
584 let missing = agent_id
585 .as_deref()
586 .map(|id| {
587 let pane_mode = mstore
588 .get::<crate::backend::obj::Block>(&block_id)
589 .ok()
590 .flatten()
591 .map(|block| crate::backend::obj::meta_get_string(&block.meta, "agentMode", ""));
592 let def = mstore.agent_def_get(id).ok().flatten();
593 crate::backend::mcp_launch::missing_for_pane(
594 mstore,
595 identity_store,
596 id,
597 pane_mode.as_deref(),
598 def.as_ref().map(|d| d.agent_type.as_str()),
599 &observation.servers,
600 )
601 })
602 .unwrap_or_default();
603 let servers = observation
604 .servers
605 .into_iter()
606 .map(|s| McpObservedServer {
607 source: observed_source(&s.name, s.source, catalog),
608 name: s.name,
609 status: s.status,
610 tools: s.tools,
611 })
612 .collect();
613 agents.push(McpObservedAgent {
614 agent_id,
615 label: label.label,
616 at_ms: observation.at_ms,
617 servers,
618 missing,
619 block_id,
620 });
621 }
622 agents.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| a.block_id.cmp(&b.block_id)));
623 agents
624}
625
626fn register_mcp_catalog_probe(engine: &Arc<WshRpcEngine>, state: &AppState) {
632 let identity_store = state.identity_store.clone();
633 engine.register_typed(
634 COMMAND_MCP_CATALOG_PROBE,
635 move |req: McpCatalogItemData, _ctx| {
636 let identity_store = identity_store.clone();
637 async move {
638 let server = identity_store.mcp_server_get(&req.id)
639 .map_err(|e| format!("mcp.catalog.probe: {e}"))?
640 .ok_or_else(|| "mcp.catalog.probe: MCP server not found".to_string())?;
641 if !server.is_global {
642 return Err("FORBIDDEN: mcp.catalog.probe only probes global servers".to_string());
643 }
644 let result = crate::backend::mcp_probe::probe(&server.transport, &server.config).await;
645 Ok(result)
646 }
647 },
648 );
649}
650
651#[cfg(test)]
652mod observed_tests {
653 use super::observed_source;
654
655 #[test]
656 fn agentmux_and_catalog_servers_are_named_as_ours_the_rest_keep_the_clis_label() {
657 let catalog: std::collections::HashSet<String> = ["git".to_string()].into();
658 assert_eq!(observed_source("agentmux", "project".into(), &catalog), "builtin");
659 assert_eq!(observed_source("git", "project".into(), &catalog), "catalog");
660 assert_eq!(observed_source("claude.ai Gmail", "claudeai".into(), &catalog), "claudeai");
661 assert_eq!(observed_source("mine", "project".into(), &catalog), "project");
662 }
663}