Skip to main content

mur_common/agent/
mcp_status.rs

1//! What a running agent got from each MCP server at boot.
2//!
3//! Tool discovery (`tools/list`) that fails is not fatal: the runtime logs a
4//! warning to its own stderr and serves the agent without that server's tools.
5//! Nothing outside the process could see that, so `mur agent doctor` reported
6//! a healthy agent whose MCP tools had silently vanished. The runtime now
7//! records each server's outcome here; `doctor` reads it back.
8
9use serde::{Deserialize, Serialize};
10
11/// File in `agent_home` where a running agent records MCP discovery results.
12/// Written by the runtime (agent_home stays writable), read by `doctor`.
13pub const MCP_STATUS_FILE: &str = "mcp_status.json";
14
15/// Outcome of one server's `tools/list` at boot.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct McpServerStatus {
18    pub name: String,
19    /// Tools the server listed (before per-tool deny rules). Zero on failure.
20    pub tools: usize,
21    /// Why discovery failed; `None` when it succeeded.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub error: Option<String>,
24}
25
26/// Every enabled server's discovery outcome for the current boot.
27#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
28pub struct McpStatus {
29    /// PID of the runtime that wrote this. A reader compares it with
30    /// `running.lock`'s `pid` to tell this boot from a stale previous one.
31    #[serde(default)]
32    pub pid: u32,
33    pub servers: Vec<McpServerStatus>,
34}
35
36impl McpStatus {
37    /// Servers whose discovery failed.
38    pub fn failed(&self) -> impl Iterator<Item = &McpServerStatus> {
39        self.servers.iter().filter(|s| s.error.is_some())
40    }
41
42    /// Atomic write: temp file + rename.
43    pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
44        let tmp = path.with_extension("json.tmp");
45        std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
46        std::fs::rename(&tmp, path)
47    }
48
49    /// `None` when absent or unreadable — a runtime that predates this file.
50    pub fn load(path: &std::path::Path) -> Option<Self> {
51        serde_json::from_slice(&std::fs::read(path).ok()?).ok()
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn round_trips_and_reports_only_failures() {
61        let d = tempfile::tempdir().unwrap();
62        let p = d.path().join(MCP_STATUS_FILE);
63        let s = McpStatus {
64            pid: 42,
65            servers: vec![
66                McpServerStatus {
67                    name: "ok".into(),
68                    tools: 3,
69                    error: None,
70                },
71                McpServerStatus {
72                    name: "bad".into(),
73                    tools: 0,
74                    error: Some("io: Operation not permitted".into()),
75                },
76            ],
77        };
78        s.save(&p).unwrap();
79        let back = McpStatus::load(&p).unwrap();
80        assert_eq!(back, s);
81        let failed: Vec<_> = back.failed().map(|f| f.name.as_str()).collect();
82        assert_eq!(failed, ["bad"]);
83    }
84
85    #[test]
86    fn missing_file_is_none() {
87        let d = tempfile::tempdir().unwrap();
88        assert!(McpStatus::load(&d.path().join(MCP_STATUS_FILE)).is_none());
89    }
90}