Skip to main content

horto_os_ui_mcp/
lib.rs

1//! Horto OS UI MCP library: status-api client, backends, and rmcp tools.
2
3pub mod api_client;
4pub mod config;
5pub mod embedded_ops;
6pub mod net;
7pub mod remote_ops;
8pub mod server;
9pub mod tool_args;
10
11pub use config::{McpMode, McpSettings};
12pub use server::{run_http, HortoMcp, DEFAULT_HTTP_LISTEN_BOX, DEFAULT_HTTP_LISTEN_PC};
13
14#[cfg(test)]
15mod tests {
16    use std::sync::{Mutex, OnceLock};
17
18    use serde_json::json;
19    use wiremock::matchers::{header, method, path};
20    use wiremock::{Mock, MockServer, ResponseTemplate};
21
22    use crate::api_client::StatusApiClient;
23    use crate::config::{McpMode, McpSettings};
24    use crate::server::{default_listen_for, HortoMcp};
25    use rmcp::ServerHandler;
26
27    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
28        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
29        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
30    }
31
32    fn restore_env(key: &str, prev: Option<String>) {
33        match prev {
34            Some(v) => std::env::set_var(key, v),
35            None => std::env::remove_var(key),
36        }
37    }
38
39    #[test]
40    fn mode_and_defaults() {
41        let _g = env_lock();
42        let keys = [
43            "HORTO_MCP_MODE",
44            "HORTO_STATUS_API_URL",
45            "HORTO_API_TOKEN",
46            "HORTO_MCP_TOKEN",
47            "HORTO_REMOTE_HOST",
48            "HORTO_RELEASE_TAG",
49            "HORTO_BIN_DIR",
50            "HORTO_INSTALL_SSH_KEY",
51        ];
52        let prev: Vec<_> = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
53        for k in keys {
54            std::env::remove_var(k);
55        }
56
57        assert_eq!(McpMode::from_env(), McpMode::Pc);
58        let pc = McpSettings::from_env();
59        assert_eq!(pc.mode, McpMode::Pc);
60        assert!(pc.status_api_url.contains("8787"));
61        assert!(pc.http_bearer().is_none());
62        assert_eq!(default_listen_for(McpMode::Pc), "127.0.0.1:8790");
63        assert_eq!(default_listen_for(McpMode::Box), "0.0.0.0:8790");
64
65        std::env::set_var("HORTO_MCP_MODE", "box");
66        std::env::set_var("HORTO_API_TOKEN", "tok");
67        let box_s = McpSettings::from_env();
68        assert_eq!(box_s.mode, McpMode::Box);
69        assert_eq!(box_s.http_bearer(), Some("tok"));
70        assert!(box_s.status_api_url.contains("127.0.0.1"));
71
72        std::env::set_var("HORTO_MCP_TOKEN", "mcp-only");
73        let mcp = McpSettings::from_env();
74        assert_eq!(mcp.http_bearer(), Some("mcp-only"));
75
76        for (k, v) in prev {
77            restore_env(k, v);
78        }
79    }
80
81    #[test]
82    fn server_info_identity() {
83        let settings = McpSettings {
84            mode: McpMode::Pc,
85            status_api_url: "http://127.0.0.1:8787".into(),
86            api_token: None,
87            mcp_token: None,
88            remote_host: None,
89            release_tag: None,
90            bin_dir: None,
91            install_ssh_key: false,
92        };
93        let info = HortoMcp::new(settings).get_info();
94        assert_eq!(info.server_info.name.as_str(), "horto-os-ui");
95        assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
96        assert!(info.capabilities.tools.is_some());
97    }
98
99    #[tokio::test]
100    async fn api_client_health_status_backup() {
101        let server = MockServer::start().await;
102        Mock::given(method("GET"))
103            .and(path("/health"))
104            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
105            .mount(&server)
106            .await;
107        Mock::given(method("GET"))
108            .and(path("/v1/status"))
109            .and(header("authorization", "Bearer secret"))
110            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"hostname": "box"})))
111            .mount(&server)
112            .await;
113        Mock::given(method("POST"))
114            .and(path("/v1/backup/etc"))
115            .and(header("authorization", "Bearer secret"))
116            .and(header("x-horto-confirm", "backup-etc"))
117            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"path": "/backup"})))
118            .mount(&server)
119            .await;
120
121        let settings = McpSettings {
122            mode: McpMode::Pc,
123            status_api_url: server.uri(),
124            api_token: Some("secret".into()),
125            mcp_token: Some("secret".into()),
126            remote_host: None,
127            release_tag: None,
128            bin_dir: None,
129            install_ssh_key: false,
130        };
131        let client = StatusApiClient::from_settings(&settings).expect("client");
132        let health = client.health().await.expect("health");
133        assert_eq!(health["ok"], true);
134        let status = client.status().await.expect("status");
135        assert_eq!(status["hostname"], "box");
136        let backup = client.backup_etc("backup-etc").await.expect("backup");
137        assert_eq!(backup["path"], "/backup");
138        assert!(client.backup_etc("nope").await.is_err());
139    }
140
141    #[tokio::test]
142    async fn backup_requires_token() {
143        let settings = McpSettings {
144            mode: McpMode::Pc,
145            status_api_url: "http://127.0.0.1:9".into(),
146            api_token: None,
147            mcp_token: None,
148            remote_host: None,
149            release_tag: None,
150            bin_dir: None,
151            install_ssh_key: false,
152        };
153        let client = StatusApiClient::from_settings(&settings).expect("client");
154        assert!(client.backup_etc("backup-etc").await.is_err());
155    }
156}