Skip to main content

agentmux_srv/server/app_api/
mcp_health.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `agentmux-mcp doctor`'s two calls (docs/specs/SPEC_AGENT_MCP_SELF_HEAL_2026_10_10.md
5//! ยง7): what srv knows about the caller's own `agentmux` MCP server, and
6//! "repair it now".
7//!
8//! `doctor` runs in the agent's shell, which has no MCP signing key (only the
9//! MCP process does), so the caller's pane comes from its agent token
10//! (`actor::own_pane_by_token`). There is no target: an agent can only look
11//! at, and heal, its own pane.
12
13use axum::extract::State;
14use axum::http::StatusCode;
15use axum::response::{IntoResponse, Response};
16use axum::{Extension, Json};
17use serde::Deserialize;
18use serde_json::json;
19
20use crate::server::caller::Caller;
21use crate::server::state::AppState;
22
23#[derive(Debug, Deserialize)]
24pub(crate) struct SelfRequest {
25    /// The caller's slug (`AGENTMUX_AGENT_SLUG`), which its token must name.
26    agent: String,
27}
28
29fn own_pane(state: &AppState, caller: Option<&Caller>, agent: &str) -> Option<String> {
30    crate::server::actor::own_pane_by_token(&state.mstore, caller, agent.trim())
31}
32
33fn no_pane() -> Response {
34    let error = "no pane for this agent token: run this from the agent's own shell \
35                 (it needs AGENTMUX_AGENT_TOKEN and AGENTMUX_AGENT_SLUG)";
36    (StatusCode::UNAUTHORIZED, Json(json!({ "error": error }))).into_response()
37}
38
39/// `POST /api/v1/agent/self/mcp-health`
40pub(crate) async fn handle_mcp_health(
41    State(state): State<AppState>,
42    caller: Option<Extension<Caller>>,
43    Json(req): Json<SelfRequest>,
44) -> Response {
45    let Some(block_id) = own_pane(&state, caller.as_deref(), &req.agent) else {
46        return no_pane();
47    };
48    let health = crate::backend::mcp_health::snapshot(&block_id);
49    Json(json!({
50        "block_id": block_id,
51        "health": health,
52        "safe_profile": crate::backend::mcp_health::is_safe_profile(&block_id),
53        "auto": crate::backend::mcp_health::auto_enabled(),
54    }))
55    .into_response()
56}
57
58/// `POST /api/v1/agent/self/mcp-heal`: 202 `healing` when a repair started,
59/// else 200 with why not (`connected`, `already_healing`, `no_status_yet`,
60/// `no_process`).
61pub(crate) async fn handle_mcp_heal(
62    State(state): State<AppState>,
63    caller: Option<Extension<Caller>>,
64    Json(req): Json<SelfRequest>,
65) -> Response {
66    use crate::backend::blockcontroller::persistent::PersistentSubprocessController;
67    let Some(block_id) = own_pane(&state, caller.as_deref(), &req.agent) else {
68        return no_pane();
69    };
70    let Some(ctrl) = crate::backend::blockcontroller::get_controller(&block_id) else {
71        return Json(json!({ "status": "no_process" })).into_response();
72    };
73    let Some(ctrl) = ctrl.as_any().downcast_ref::<PersistentSubprocessController>() else {
74        return Json(json!({ "status": "no_process" })).into_response();
75    };
76    let outcome = ctrl.mcp_heal_now();
77    tracing::info!(block_id = %block_id, agent = %req.agent, outcome = ?outcome, "agentmux-mcp doctor --heal");
78    match outcome {
79        Ok(()) => (StatusCode::ACCEPTED, Json(json!({ "status": "healing" }))).into_response(),
80        Err(why) => Json(json!({ "status": why })).into_response(),
81    }
82}