diff --git a/README.md b/README.md index f67b9e8..ee68a00 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract | [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues | | [REMOTE_AGENTS.md](docs/REMOTE_AGENTS.md) | Linux optimization agents | -## MCP Tools (87) +## MCP Tools (89) ### Core workflow diff --git a/docs/MCP_TOOLS.md b/docs/MCP_TOOLS.md index 7405ca3..793076d 100644 --- a/docs/MCP_TOOLS.md +++ b/docs/MCP_TOOLS.md @@ -2,7 +2,7 @@ Full input/output schemas for MT5-Quant tools. -> **Documentation Status:** This file documents 56 of 87 total tools. Missing: +> **Documentation Status:** This file documents 56 of 89 total tools. Missing: > - `list_experts`, `list_indicators`, `list_scripts` > - `healthcheck`, `list_symbols` > - Reports query: `search_reports`, `get_latest_report`, `list_reports`, `prune_reports`, `tail_log`, `get_report_by_id`, `get_reports_summary`, `get_best_reports`, `search_reports_by_tags`, `search_reports_by_date_range`, `search_reports_by_notes`, `get_reports_by_set_file`, `get_comparable_reports` diff --git a/src/tools/definitions/mod.rs b/src/tools/definitions/mod.rs index f2ecce2..18fb026 100644 --- a/src/tools/definitions/mod.rs +++ b/src/tools/definitions/mod.rs @@ -63,6 +63,8 @@ pub fn get_tools_list() -> Value { system::tool_list_symbols(), system::tool_healthcheck(), system::tool_get_active_account(), + system::tool_check_update(), + system::tool_update(), // Utility (8 tools) utility::tool_check_symbol_data_status(), utility::tool_get_backtest_history(), diff --git a/src/tools/definitions/system.rs b/src/tools/definitions/system.rs index f71d464..3ba062d 100644 --- a/src/tools/definitions/system.rs +++ b/src/tools/definitions/system.rs @@ -45,3 +45,25 @@ pub fn tool_get_active_account() -> Value { } }) } + +pub fn tool_check_update() -> Value { + json!({ + "name": "check_update", + "description": "Check if a newer version of mt5-quant is available on GitHub. A background check runs automatically on the first tool call of each session; this tool returns that cached result instantly or fetches it on demand.", + "inputSchema": { + "type": "object", + "properties": {} + } + }) +} + +pub fn tool_update() -> Value { + json!({ + "name": "update", + "description": "Download and install the latest mt5-quant binary from GitHub Releases, then replace the current executable in place. Restart the MCP connection after updating to load the new version.", + "inputSchema": { + "type": "object", + "properties": {} + } + }) +} diff --git a/src/tools/handlers/mod.rs b/src/tools/handlers/mod.rs index 285148e..8865d22 100644 --- a/src/tools/handlers/mod.rs +++ b/src/tools/handlers/mod.rs @@ -2,6 +2,8 @@ use anyhow::Result; use chrono::Datelike; use serde_json::{json, Value}; use std::path::Path; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; use crate::models::Config; mod system; @@ -13,6 +15,13 @@ mod setfiles; mod reports; mod utility; +/// Cached result of the background update check. +/// - Not yet initialized: check still in flight (or not spawned yet) +/// - Some(version): a newer version is available +/// - None: already on latest, or GitHub unreachable +pub(crate) static LATEST_VERSION: OnceLock> = OnceLock::new(); +static BACKGROUND_CHECK_SPAWNED: AtomicBool = AtomicBool::new(false); + #[derive(Debug)] pub struct ToolHandler { pub config: Config, @@ -24,12 +33,22 @@ impl ToolHandler { } pub async fn handle(&self, name: &str, args: &Value) -> Result { + // Spawn a one-shot background update check on the very first tool call of the session. + if !BACKGROUND_CHECK_SPAWNED.swap(true, Ordering::Relaxed) { + tokio::spawn(async { + let result = system::fetch_latest_version().await; + let _ = LATEST_VERSION.set(result); + }); + } + match name { // System handlers - "verify_setup" => system::handle_verify_setup(&self.config).await, - "list_symbols" => system::handle_list_symbols(&self.config).await, - "healthcheck" => system::handle_healthcheck(&self.config, args).await, + "verify_setup" => system::handle_verify_setup(&self.config).await, + "list_symbols" => system::handle_list_symbols(&self.config).await, + "healthcheck" => system::handle_healthcheck(&self.config, args).await, "get_active_account" => system::handle_get_active_account(&self.config).await, + "check_update" => system::handle_check_update(&self.config).await, + "update" => system::handle_update(&self.config).await, // Expert/Indicator/Script handlers "list_experts" => experts::handle_list_experts(&self.config, args).await, diff --git a/src/tools/handlers/system.rs b/src/tools/handlers/system.rs index ea33eba..a91c61f 100644 --- a/src/tools/handlers/system.rs +++ b/src/tools/handlers/system.rs @@ -3,6 +3,178 @@ use serde_json::{json, Value}; use std::path::Path; use crate::models::Config; +// ── Update helpers ──────────────────────────────────────────────────────────── + +fn platform_tag() -> &'static str { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] return "macos-aarch64"; + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] return "macos-x86_64"; + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] return "linux-x86_64"; + #[cfg(not(any( + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "x86_64"), + )))] return "unsupported"; +} + +fn semver_newer(latest: &str, current: &str) -> bool { + let parse = |s: &str| -> (u32, u32, u32) { + let mut p = s.trim_start_matches('v').splitn(3, '.'); + let ma = p.next().and_then(|x| x.parse().ok()).unwrap_or(0); + let mi = p.next().and_then(|x| x.parse().ok()).unwrap_or(0); + let pa = p.next().and_then(|x| x.parse().ok()).unwrap_or(0); + (ma, mi, pa) + }; + parse(latest) > parse(current) +} + +/// Fetch the latest release tag from GitHub API (5 s timeout via curl). +/// Returns the version string without the leading "v", or None on failure. +pub(super) async fn fetch_latest_version() -> Option { + let output = tokio::process::Command::new("curl") + .args([ + "-sf", "--max-time", "5", + "-H", "Accept: application/vnd.github.v3+json", + "-H", "User-Agent: mt5-quant-updater", + "https://api.github.com/repos/masdevid/mt5-quant/releases/latest", + ]) + .output() + .await + .ok()?; + + if !output.status.success() { return None; } + + let body: Value = serde_json::from_slice(&output.stdout).ok()?; + body.get("tag_name") + .and_then(|v| v.as_str()) + .map(|s| s.trim_start_matches('v').to_string()) +} + +fn ok_response(data: Value) -> Value { + json!({ "content": [{ "type": "text", "text": data.to_string() }], "isError": false }) +} + +fn err_response(msg: impl std::fmt::Display) -> Value { + json!({ "content": [{ "type": "text", "text": msg.to_string() }], "isError": true }) +} + +// ── Update tool handlers ────────────────────────────────────────────────────── + +pub async fn handle_check_update(_config: &Config) -> Result { + let current = env!("CARGO_PKG_VERSION"); + + // Use cached background-check result if available; otherwise fetch now. + let latest_opt = match super::LATEST_VERSION.get() { + Some(v) => v.clone(), + None => fetch_latest_version().await, + }; + + let Some(latest) = latest_opt else { + return Ok(ok_response(json!({ + "current_version": current, + "update_available": false, + "error": "Could not reach GitHub API — check network connectivity", + }))); + }; + + let update_available = semver_newer(&latest, current); + Ok(ok_response(json!({ + "current_version": current, + "latest_version": latest, + "update_available": update_available, + "hint": if update_available { + format!("Run the `update` tool to install v{latest}") + } else { + "You are on the latest version".to_string() + }, + }))) +} + +pub async fn handle_update(_config: &Config) -> Result { + let current = env!("CARGO_PKG_VERSION"); + + let latest = match super::LATEST_VERSION.get().and_then(|v| v.as_deref()) { + Some(v) => v.to_string(), + None => match fetch_latest_version().await { + Some(v) => v, + None => return Ok(err_response( + r#"{"success":false,"error":"Could not determine latest version — check network"}"# + )), + }, + }; + + if !semver_newer(&latest, current) { + return Ok(ok_response(json!({ + "up_to_date": true, + "version": current, + }))); + } + + let tag = platform_tag(); + if tag == "unsupported" { + return Ok(err_response( + r#"{"success":false,"error":"Auto-update not supported on this platform — build from source"}"# + )); + } + + let url = format!( + "https://github.com/masdevid/mt5-quant/releases/download/v{latest}/mcp-mt5-quant-{tag}.tar.gz" + ); + + // Download tarball to a temp file + let tmp_tar = tempfile::NamedTempFile::new()?; + let dl = tokio::process::Command::new("curl") + .args(["-sfL", "--max-time", "120", + "-o", tmp_tar.path().to_str().unwrap_or_default(), + &url]) + .status() + .await?; + + if !dl.success() { + return Ok(err_response(format!( + r#"{{"success":false,"error":"Download failed","url":"{}"}}"#, url + ))); + } + + // Extract binary (tarball root dir is mcp-mt5-quant-{platform}/) + let tmp_dir = tempfile::tempdir()?; + let extract = tokio::process::Command::new("tar") + .args(["-xzf", tmp_tar.path().to_str().unwrap_or_default(), + "-C", tmp_dir.path().to_str().unwrap_or_default(), + "--strip-components=1"]) + .status() + .await?; + + if !extract.success() { + return Ok(err_response(r#"{"success":false,"error":"Failed to extract archive"}"#)); + } + + let new_bin = tmp_dir.path().join("mt5-quant"); + if !new_bin.exists() { + return Ok(err_response(r#"{"success":false,"error":"Binary not found in archive"}"#)); + } + + // Atomic replace: write to sibling .tmp, then rename (safe on same FS) + let current_exe = std::env::current_exe()?; + let tmp_dest = current_exe.with_extension("update_tmp"); + std::fs::copy(&new_bin, &tmp_dest)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp_dest, std::fs::Permissions::from_mode(0o755))?; + } + + std::fs::rename(&tmp_dest, ¤t_exe)?; + + Ok(ok_response(json!({ + "success": true, + "previous_version": current, + "updated_to": latest, + "binary": current_exe.to_string_lossy(), + "hint": format!("Updated to v{latest}. Restart the MCP connection to load the new binary."), + }))) +} + pub async fn handle_verify_setup(config: &Config) -> Result { let mut checks = serde_json::Map::new(); let mut all_ok = true;