Skip to main content

agentmux_srv/server/app_api/
mcp.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! MuxAPI handlers for the v1 standalone MCP Server primitive.
5//! mcp.list / mcp.get / mcp.upsert / mcp.delete / mcp.bind / mcp.unbind
6//!
7//! Plus the Connectors-level catalog (mcp.catalog.*): global servers only,
8//! window-scoped — no `agent_id`, no `check_s1` (mirrors bundle.* auth
9//! shape, since Connectors has no agent connection context to gate on).
10//!
11//! `db_mcp_servers` is authoritatively `identity_store` as of Phase 2 of
12//! SPEC_DURABLE_BINDINGS_2026_09_10.md (§5.1/§5.4) — see `skill.rs`'s
13//! module doc comment for the full `catalog`/`mstore` split; this file
14//! mirrors it exactly for MCP servers.
15
16use 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                // created_at is preserved across updates (ON CONFLICT keeps the
102                // original), so the returned struct never reports a timestamp the
103                // DB won't persist. New rows default to `now`.
104                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                // No longer a single transaction across the catalog insert and
133                // the ref-bind (Phase 2 of SPEC_DURABLE_BINDINGS_2026_09_10.md
134                // §5.4, accepted interim gap) — see
135                // `Store::managed_upsert_unique`'s doc comment.
136                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                // Only global servers (or ones already bound) may be bound, so an
193                // agent can't escalate to read another agent's server config.
194                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                // An agent binding a server to itself over its own authenticated
210                // connection should reach an already-open Stash MCP Servers tab
211                // for that agent too — same reactivity as the catalog-tier bind.
212                // reagentx P2 on PR #2329.
213                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
241/// Health/prerequisite probe for one of this agent's own or bound-global
242/// servers (SPEC_MCP_INTEGRATION_PARITY_ABLETON_PILOT_2026_07_08.md §4.4).
243/// Opens a short-lived MCP connection and reports whether the server
244/// actually speaks the protocol — distinct from `mcp.upsert`'s "is this
245/// valid JSON" check, which tells you nothing about reachability.
246fn 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                        // No agent_id/check_s1 here to verify ownership of a private
315                        // server, so the catalog surface may only create new global
316                        // rows or edit ones that are ALREADY global — never promote
317                        // a private server into the catalog by supplying its id.
318                        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                // Global-scoped uniqueness (not mcp_server_upsert_unique's
336                // per-agent check): agent_config.rs merges servers into a
337                // JSON object keyed by name, so two same-named global
338                // servers would silently clobber each other's config for
339                // every agent that has either bound. Reagent P1 on #1948.
340                // Pure single-table op — called directly on identity_store.
341                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
350// Catalog-tier sibling of register_mcp_bind (above) — same DB write and
351// "only global servers may be bound" safety check, but no check_s1: the
352// Connectors' WebSocket never authenticates as an agent (see this file's
353// module doc comment), so a check_s1-gated bind can never be satisfied from
354// that caller. mcp.bind itself is left untouched — it may still be the
355// intended surface for an agent binding a server to itself over its own
356// authenticated connection, and loosening its gate would silently widen
357// what any authenticated agent connection can do (bind arbitrary servers to
358// *other* agent ids), which nobody asked for.
359// See docs/reports/REPORT_ARMORY_SKILLS_MARKDOWN_AND_BIND_BUG_2026_07_27.md.
360fn 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                // Only global servers (or ones already bound) may be bound, so
372                // the catalog surface can't be used to bootstrap read access
373                // to another agent's private server config — same rule as
374                // mcp.bind.
375                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                // Lets any other open Stash/Connectors view for this agent pick up
391                // the new binding without a manual refresh — mcp.bind (the
392                // check_s1 agent-self-service path) intentionally left alone.
393                broker.publish_event::<crate::backend::mps_events::McpChanged>(&(), vec![], 0);
394                Ok(McpBindResult { bound: true })
395            }
396        },
397    );
398}
399
400/// Catalog-tier sibling of `mcp.list` (above) — GLOBAL SERVERS ONLY, each
401/// annotated with `bound_to_agent` for the caller-supplied `agent_id`, no
402/// `check_s1`. AgentStashModal's MCP Servers tab (the per-agent Stash view)
403/// runs over the dashboard's connection, which is never agent-authenticated,
404/// so `mcp.list`'s gate can never be satisfied from that caller — the exact
405/// same reasoning as `mcp.catalog.bind` above. Deliberately does NOT reuse
406/// `mcp.list`'s full computation (global + this agent's own private
407/// servers): with no `check_s1`, `agent_id` is unverified, and a private
408/// server's `config` can carry secrets — returning it for an arbitrary
409/// caller-chosen `agent_id` would be an IDOR into every agent's private MCP
410/// config. See `Store::mcp_server_list_global_for_agent`'s doc comment and
411/// docs/reports/REPORT_ARMORY_ARCHITECTURE_AND_NAMING_REVIEW_2026_07_23.md §2.2.
412fn 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
429/// Catalog-tier sibling of `mcp.unbind` (above) — same DB write, no
430/// `check_s1`. Restricted to global rows only, matching the guard
431/// `register_mcp_catalog_bind`/`register_mcp_catalog_delete` already apply
432/// in this file: any window connection can sever any agent's binding to a
433/// *global* server (an intentional, pre-existing trust boundary — bind and
434/// delete already have the same or larger blast radius), but a private
435/// row's own binding must not be touchable via this no-`check_s1` surface,
436/// even though `mcp_id` is never exposed by any no-`check_s1` command
437/// today — defense in depth over relying solely on UUID secrecy.
438/// reagentx P1 on PR #2329 (round 2).
439fn 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
499/// Who a global catalog server reaches beyond its binds (every agent,
500/// container agents too). Global rows only, like the rest of `mcp.catalog.*`.
501/// The switches live in the identity store beside the catalog, so they
502/// survive an upgrade (SPEC_CONNECTORS_MCP_WHAT_AGENTS_RUN_2026_10_10.md §2.1).
503fn 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
531/// The MCP servers each running agent's CLI reports having
532/// (`backend::mcp_observed`), with AgentMux's own sources named: `builtin`
533/// for the `agentmux` server, `catalog` for a catalog server of that name.
534/// Names and statuses only; a pane that no longer exists is dropped.
535fn 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
556/// Where an observed server comes from: AgentMux's own sources by name
557/// (its `agentmux` server, a catalog server), else what the CLI said.
558fn 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    // Panes whose block is gone: their reports and exit markers go. A block
574    // the store couldn't read is kept: that isn't a gone block.
575    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
626/// Health/prerequisite probe for a global catalog server, callable without
627/// an agent context (mirrors mcp.catalog.*'s window-scoped, no-`check_s1`
628/// shape) — this is what lets Connectors → MCP servers show a
629/// connected/error status per row before any agent has bound the server.
630/// See SPEC_MCP_INTEGRATION_PARITY_ABLETON_PILOT_2026_07_08.md §4.4.
631fn 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}